spark_model/layers/glm_vit/
init.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `GlmVit::new` and the lazily-allocated device scratch.
4
5use anyhow::Result;
6use spark_runtime::gpu::{DevicePtr, GpuBackend};
7
8use super::{GlmVit, GlmVitBlock, GlmVitMerger, GlmVitScratch};
9use crate::layers::vision_encoder::enc_impl::init::derive_max_patches;
10
11/// Everything `new` needs that is not a device pointer. A struct rather than
12/// fifteen positional arguments, because eleven of them are `usize` and the
13/// compiler cannot tell `hidden_size` from `intermediate_size`.
14#[derive(Debug, Clone)]
15pub struct GlmVitGeometry {
16    pub hidden_size: usize,
17    pub num_heads: usize,
18    pub intermediate_size: usize,
19    pub spatial_merge_size: usize,
20    pub out_hidden_size: usize,
21    pub projection_intermediate_size: usize,
22    pub patch_size: usize,
23    pub temporal_patch_size: usize,
24    pub rms_norm_eps: f32,
25    pub swiglu_limit: f32,
26    pub rope_theta: f32,
27    pub max_pixels: Option<usize>,
28}
29
30impl GlmVit {
31    pub fn new(
32        patch_embed_w: DevicePtr,
33        patch_embed_b: DevicePtr,
34        blocks: Vec<GlmVitBlock>,
35        merger: GlmVitMerger,
36        geo: &GlmVitGeometry,
37        gpu: &dyn GpuBackend,
38    ) -> Result<Self> {
39        anyhow::ensure!(
40            geo.num_heads > 0 && geo.hidden_size.is_multiple_of(geo.num_heads),
41            "glm_vit: hidden_size {} is not divisible by num_heads {}",
42            geo.hidden_size,
43            geo.num_heads
44        );
45        let head_dim = geo.hidden_size / geo.num_heads;
46        anyhow::ensure!(
47            head_dim.is_multiple_of(4),
48            "glm_vit: head_dim {head_dim} must be a multiple of 4 — the axial RoPE splits it \
49             into an h half and a w half, each of which is then rotate-half'd"
50        );
51        anyhow::ensure!(
52            geo.spatial_merge_size == 2,
53            "glm_vit: spatial_merge_size {} — the 2x2 conv downsample and its im2col are \
54             written for 2 and only 2; a different merge needs a different kernel, not a \
55             different argument",
56            geo.spatial_merge_size
57        );
58        anyhow::ensure!(
59            geo.patch_size > 0 && geo.temporal_patch_size > 0,
60            "glm_vit: patch_size/temporal_patch_size must be non-zero"
61        );
62
63        let (p_max, asked_for) = derive_max_patches(geo.max_pixels, geo.patch_size);
64        if let Some(wanted) = asked_for {
65            tracing::warn!(
66                "GLM vision encoder capacity clamped to {p_max} patches; the resolved area \
67                 bound wanted {wanted}. The ViT materialises a full [seq, seq] score matrix, \
68                 so scratch is O(patches^2). Lower --vision-max-pixels to reclaim memory."
69            );
70        }
71
72        // `spatial_dim = head_dim / 2`; `inv_freq[k] = theta^(-2k/spatial_dim)`
73        // for k in [0, spatial_dim/2) — the reference's
74        // `1 / theta ** (arange(0, spatial_dim, 2) / spatial_dim)`.
75        let spatial_dim = head_dim / 2;
76        let rope_inv_freq: Vec<f32> = (0..spatial_dim / 2)
77            .map(|k| 1.0 / geo.rope_theta.powf(2.0 * k as f32 / spatial_dim as f32))
78            .collect();
79
80        Ok(Self {
81            patch_embed_w,
82            patch_embed_b,
83            blocks,
84            merger,
85            k_gemm: gpu.kernel("gemm", "dense_gemm_bf16_pipelined")?,
86            k_gemm_f32: gpu.kernel("gemm", "dense_gemm_bf16_f32out")?,
87            k_add_bias: gpu.kernel("glm_vit", "glm_vit_add_bias")?,
88            k_rmsnorm: gpu.kernel("glm_vit", "glm_vit_rmsnorm")?,
89            k_layernorm: gpu.kernel("glm_vit", "glm_vit_layernorm")?,
90            k_gelu: gpu.kernel("glm_vit", "glm_vit_gelu_erf")?,
91            k_swiglu: gpu.kernel("glm_vit", "glm_vit_swiglu_clamp")?,
92            k_qknorm_rope: gpu.kernel("glm_vit", "glm_vit_qknorm_rope_deint")?,
93            k_softmax: gpu.kernel("glm_vit", "glm_vit_softmax_rows")?,
94            k_scatter_head: gpu.kernel("glm_vit", "glm_vit_scatter_head")?,
95            k_im2col: gpu.kernel("glm_vit", "glm_vit_im2col_2x2")?,
96            k_copy: gpu.kernel("glm_vit", "glm_vit_copy")?,
97            k_add: gpu.kernel("glm_vit", "glm_vit_add_inplace")?,
98            k_f32_bf16: gpu.kernel("glm_vit", "glm_vit_f32_to_bf16")?,
99            hidden_size: geo.hidden_size,
100            num_heads: geo.num_heads,
101            head_dim,
102            intermediate_size: geo.intermediate_size,
103            spatial_merge_size: geo.spatial_merge_size,
104            out_hidden_size: geo.out_hidden_size,
105            projection_intermediate_size: geo.projection_intermediate_size,
106            patch_dim: 3 * geo.temporal_patch_size * geo.patch_size * geo.patch_size,
107            rms_norm_eps: geo.rms_norm_eps,
108            swiglu_limit: geo.swiglu_limit,
109            p_max,
110            rope_inv_freq,
111            scratch: std::sync::OnceLock::new(),
112        })
113    }
114
115    /// Merged (post-downsample) row capacity of `buf_out`.
116    pub(crate) fn mp_max(&self) -> usize {
117        let sms2 = self.spatial_merge_size * self.spatial_merge_size;
118        self.p_max.div_ceil(sms2)
119    }
120
121    fn build_scratch(&self, gpu: &dyn GpuBackend) -> Result<GlmVitScratch> {
122        let p = self.p_max;
123        let mp = self.mp_max();
124        let h = self.hidden_size;
125        let inter = self.intermediate_size;
126        let out = self.out_hidden_size;
127        let pi = self.projection_intermediate_size;
128        // The QKV projection is the widest per-patch intermediate the block
129        // path produces (3*H*D = 3*hidden). `buf_gate` holds it, so size that
130        // buffer for the larger of the two rather than assuming the MLP wins.
131        let wide = inter.max(3 * h).max(self.patch_dim);
132        Ok(GlmVitScratch {
133            buf_f32: gpu.alloc(p * self.patch_dim * 4)?,
134            buf_h1: gpu.alloc(p * h * 2)?,
135            buf_h2: gpu.alloc(p * h * 2)?,
136            buf_gate: gpu.alloc(p * wide * 2)?,
137            buf_up: gpu.alloc(p * inter * 2)?,
138            buf_qr: gpu.alloc(p * h * 2)?,
139            buf_kr: gpu.alloc(p * h * 2)?,
140            buf_vt: gpu.alloc(p * h * 2)?,
141            // O(patches²), and by far the largest pair here: 6400 patches is
142            // 164 MB + 82 MB. Lazily allocated for exactly that reason.
143            buf_scores: gpu.alloc(p * p * 4)?,
144            buf_probs: gpu.alloc(p * p * 2)?,
145            buf_o_stage: gpu.alloc(p * self.head_dim * 2)?,
146            buf_rope_cos: gpu.alloc(p * self.head_dim * 2)?,
147            buf_rope_sin: gpu.alloc(p * self.head_dim * 2)?,
148            buf_merge_a: gpu.alloc(mp * out * 2)?,
149            buf_merge_b: gpu.alloc(mp * out * 2)?,
150            buf_merge_g: gpu.alloc(mp * pi * 2)?,
151            buf_merge_u: gpu.alloc(mp * pi * 2)?,
152            buf_out: gpu.alloc(mp * out * 2)?,
153        })
154    }
155
156    /// Allocate the scratch on first use. A text-only GLM serve never reaches
157    /// this line.
158    pub(crate) fn scratch_init(&self, gpu: &dyn GpuBackend) -> Result<()> {
159        if self.scratch.get().is_none() {
160            let s = self.build_scratch(gpu)?;
161            let _ = self.scratch.set(s);
162            tracing::info!(
163                "GLM vision scratch allocated on first image: {} patches ({} merged rows)",
164                self.p_max,
165                self.mp_max()
166            );
167        }
168        Ok(())
169    }
170
171    pub(crate) fn scratch(&self) -> &GlmVitScratch {
172        self.scratch
173            .get()
174            .expect("glm_vit scratch: encode entry must call scratch_init(gpu) first")
175    }
176
177    /// Device row of the packed merged output — what the embedding splice
178    /// copies over an `<|image|>` position.
179    pub fn out_row(&self, row: usize) -> DevicePtr {
180        self.scratch()
181            .buf_out
182            .offset(row * self.out_hidden_size * 2)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188
189    /// 3 × 2 × 14² = 1176, the width the checkpoint's `patch_embed.proj`
190    /// expects — and the exact value the Qwen encoder's compiled `PATCH_DIM`
191    /// of 1536 cannot represent.
192    #[test]
193    fn glm_patch_dim_is_a_runtime_value_of_1176() {
194        let patch_dim = 3 * 2 * 14 * 14;
195        assert_eq!(patch_dim, 1176);
196        assert_ne!(patch_dim, crate::layers::vision_encoder::PATCH_DIM);
197    }
198
199    /// The rope frequency table: 16 entries for head_dim 64, geometric in
200    /// theta, first entry exactly 1.
201    #[test]
202    fn rope_inv_freq_matches_the_reference_formula() {
203        let head_dim = 64usize;
204        let spatial_dim = head_dim / 2;
205        let theta = 10_000.0f32;
206        let f: Vec<f32> = (0..spatial_dim / 2)
207            .map(|k| 1.0 / theta.powf(2.0 * k as f32 / spatial_dim as f32))
208            .collect();
209        assert_eq!(f.len(), 16);
210        assert_eq!(f[0], 1.0);
211        let last = 1.0 / theta.powf(30.0 / 32.0);
212        assert!((f[15] - last).abs() < 1e-9);
213    }
214}