spark_model/layers/glm_vit/
forward.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `GlmVit::forward_batched` — pixels → merged LM-splice embeddings.
4//!
5//! `buf_out` holds the merger's output, packed in image order and nothing
6//! else. GLM's tower is single-scale (there is no `deepstack_visual_indexes`
7//! key in its `vision_config` and no deepstack merger in the checkpoint), so
8//! unlike the Qwen encoder there is no second region to pack behind it.
9
10use anyhow::Result;
11use spark_runtime::gpu::GpuBackend;
12use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
13
14use super::GlmVit;
15
16/// The host pixel buffer for one image, checked against BOTH the geometry the
17/// encoder was built for and the device rows this upload will touch.
18///
19/// Two separate bounds because either alone is insufficient — the same pair the
20/// Qwen encoder learned the hard way (a `patch_size: 14` checkpoint reading 360
21/// floats per patch past the end of a `Vec`, and a width-consistent buffer whose
22/// patch count ran past the allocation). Prose is not a bound; this is.
23fn check_pixels(
24    pixels: &[f32],
25    patches: usize,
26    end_row: usize,
27    patch_dim: usize,
28    p_max: usize,
29) -> Result<()> {
30    let want = patches
31        .checked_mul(patch_dim)
32        .ok_or_else(|| anyhow::anyhow!("glm_vit: patch count {patches} overflows"))?;
33    anyhow::ensure!(
34        pixels.len() == want,
35        "glm_vit: pixel buffer is {} floats for {patches} patches, but this encoder is built \
36         for {patch_dim} floats per patch ({want}). The checkpoint's vision_config \
37         patch_size/temporal_patch_size do not match the tower that was bound.",
38        pixels.len()
39    );
40    anyhow::ensure!(
41        end_row <= p_max,
42        "glm_vit: this upload ends at patch row {end_row} but the encoder's buffers hold \
43         {p_max} rows ({patches} patches in this image)."
44    );
45    Ok(())
46}
47
48impl GlmVit {
49    /// Encode N images. Returns per-image `(post_h, post_w, merged_p)` in image
50    /// order, matching the Qwen encoder's contract so the prefill splice is
51    /// shared.
52    ///
53    /// Images are encoded in greedy chunks whose Σpatches fits `p_max`; the
54    /// merged rows of every chunk land contiguously in `buf_out` at the global
55    /// image order, so a batch that overflows the scratch degrades to more
56    /// launches rather than to a wrong answer or an out-of-bounds write.
57    pub fn forward_batched(
58        &self,
59        images: &[(&[f32], usize, usize)],
60        gpu: &dyn GpuBackend,
61        stream: u64,
62    ) -> Result<Vec<(usize, usize, usize)>> {
63        self.scratch_init(gpu)?;
64        let sms = self.spatial_merge_size;
65        let sms2 = sms * sms;
66
67        for (_, gh, gw) in images {
68            anyhow::ensure!(
69                gh % sms == 0 && gw % sms == 0 && *gh > 0 && *gw > 0,
70                "glm_vit: patch grid {gh}x{gw} is not a multiple of the {sms}x{sms} merge — \
71                 the downsample reshapes four CONSECUTIVE tokens into one block and an odd \
72                 grid silently mixes rows"
73            );
74            anyhow::ensure!(
75                gh * gw <= self.p_max,
76                "glm_vit: one image of {} patches exceeds the encoder capacity of {} rows",
77                gh * gw,
78                self.p_max
79            );
80        }
81
82        let mut total_merged = 0usize;
83        for (_, gh, gw) in images {
84            total_merged += (gh * gw) / sms2;
85        }
86        anyhow::ensure!(
87            total_merged <= self.mp_max(),
88            "glm_vit: this batch packs {total_merged} merged rows into an output buffer of {} \
89             rows. Send fewer images, or allocate a larger vision scratch.",
90            self.mp_max()
91        );
92
93        let mut mp_base = 0usize;
94        let mut start = 0usize;
95        while start < images.len() {
96            let mut end = start;
97            let mut p_sum = 0usize;
98            while end < images.len() {
99                let (_, gh, gw) = images[end];
100                if p_sum + gh * gw > self.p_max {
101                    break;
102                }
103                p_sum += gh * gw;
104                end += 1;
105            }
106            debug_assert!(end > start, "capacity was checked per image above");
107            self.encode_chunk(&images[start..end], mp_base, gpu, stream)?;
108            mp_base += p_sum / sms2;
109            start = end;
110        }
111
112        Ok(images
113            .iter()
114            .map(|(_, gh, gw)| (gh / sms, gw / sms, (gh * gw) / sms2))
115            .collect())
116    }
117
118    /// One chunk whose Σpatches fits `p_max`. Writes `Σmerged` rows of
119    /// `buf_out` starting at `mp_base`.
120    fn encode_chunk(
121        &self,
122        images: &[(&[f32], usize, usize)],
123        mp_base: usize,
124        gpu: &dyn GpuBackend,
125        stream: u64,
126    ) -> Result<()> {
127        let s = self.scratch();
128        let sms2 = self.spatial_merge_size * self.spatial_merge_size;
129        let mut p_i = Vec::with_capacity(images.len());
130        let mut p_off = Vec::with_capacity(images.len());
131        let mut p_total = 0usize;
132        for (_, gh, gw) in images {
133            p_off.push(p_total);
134            p_i.push(gh * gw);
135            p_total += gh * gw;
136        }
137
138        // 1. Per-image host prep: the axial RoPE tables, packed at p_off[i].
139        for (i, (_, gh, gw)) in images.iter().enumerate() {
140            self.upload_rope_tables(
141                *gh,
142                *gw,
143                s.buf_rope_cos.offset(p_off[i] * self.head_dim * 2),
144                s.buf_rope_sin.offset(p_off[i] * self.head_dim * 2),
145                gpu,
146                stream,
147            )?;
148        }
149
150        // 2. Patch embed over M = Σp. The Conv3d degenerates to one dot product
151        //    per patch (kernel == input block, no sliding), so it IS a GEMM
152        //    against the weight flattened as (in_channel, temporal, py, px) —
153        //    the same order the host preprocessor lays each patch out in.
154        for (i, (pixels, gh, gw)) in images.iter().enumerate() {
155            let p = gh * gw;
156            let end_row = p_off[i]
157                .checked_add(p)
158                .ok_or_else(|| anyhow::anyhow!("glm_vit: patch row offset overflows"))?;
159            check_pixels(pixels, p, end_row, self.patch_dim, self.p_max)?;
160            // SAFETY: `pixels` is a live `&[f32]` and the byte length is taken
161            // from that same slice, so the view never leaves the allocation.
162            // `f32` has no invalid bit patterns and `u8` has alignment 1.
163            let bytes: &[u8] = unsafe {
164                std::slice::from_raw_parts(pixels.as_ptr().cast::<u8>(), pixels.len() * 4)
165            };
166            gpu.copy_h2d_async(
167                bytes,
168                s.buf_f32.offset(p_off[i] * self.patch_dim * 4),
169                stream,
170            )?;
171        }
172        let n_f32 = (p_total * self.patch_dim) as u32;
173        KernelLaunch::new(gpu, self.k_f32_bf16)
174            .grid([div_ceil(n_f32, 256), 1, 1])
175            .block([256, 1, 1])
176            .arg_ptr(s.buf_f32)
177            .arg_ptr(s.buf_gate)
178            .arg_u32(n_f32)
179            .launch(stream)?;
180        self.gemm(
181            gpu,
182            s.buf_gate,
183            self.patch_embed_w,
184            Some(self.patch_embed_b),
185            s.buf_h1,
186            p_total as u32,
187            self.hidden_size as u32,
188            self.patch_dim as u32,
189            stream,
190        )?;
191        self.maybe_dump(
192            gpu,
193            s.buf_h1,
194            p_total * self.hidden_size,
195            "patch_embed",
196            stream,
197        )?;
198
199        // 3. There is NO learned positional embedding to add here — the
200        //    checkpoint ships no `pos_embed` tensor and the config declares
201        //    none. All position information enters through the axial RoPE
202        //    inside each block's attention.
203        for (idx, blk) in self.blocks.iter().enumerate() {
204            self.block(blk, p_total, &p_i, &p_off, gpu, stream)?;
205            self.maybe_dump(
206                gpu,
207                s.buf_h1,
208                p_total * self.hidden_size,
209                &format!("block{idx:02}"),
210                stream,
211            )?;
212        }
213
214        // 4. post_layernorm (weight-only RMSNorm) → conv downsample → merger.
215        self.rmsnorm(
216            gpu,
217            s.buf_h1,
218            self.merger.post_layernorm_w,
219            p_total as u32,
220            self.hidden_size as u32,
221            stream,
222        )?;
223        let out = s.buf_out.offset(mp_base * self.out_hidden_size * 2);
224        self.downsample_and_merge(p_total / sms2, out, gpu, stream)?;
225        self.maybe_dump(
226            gpu,
227            out,
228            (p_total / sms2) * self.out_hidden_size,
229            "merged",
230            stream,
231        )
232    }
233
234    /// `AVAROK_DUMP_GLM_VIT=<dir>` snapshots a BF16 stage to
235    /// `<dir>/<label>.bin` (raw little-endian, no header) for a tap-by-tap diff
236    /// against the Python reference. Off by default.
237    pub(super) fn maybe_dump(
238        &self,
239        gpu: &dyn GpuBackend,
240        ptr: spark_runtime::gpu::DevicePtr,
241        n_elements: usize,
242        label: &str,
243        stream: u64,
244    ) -> Result<()> {
245        let Ok(dir) = std::env::var("AVAROK_DUMP_GLM_VIT") else {
246            return Ok(());
247        };
248        if dir.is_empty() {
249            return Ok(());
250        }
251        gpu.synchronize(stream)?;
252        let mut buf = vec![0u8; n_elements * 2];
253        gpu.copy_d2h(ptr, &mut buf)?;
254        std::fs::create_dir_all(&dir).ok();
255        std::fs::write(
256            std::path::Path::new(&dir).join(format!("{label}.bin")),
257            &buf,
258        )?;
259        tracing::info!("AVAROK_DUMP_GLM_VIT: wrote {label} ({n_elements} elements)");
260        Ok(())
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::check_pixels;
267
268    /// GLM's geometry: 3 × 2 × 14² = 1176 floats per patch.
269    #[test]
270    fn accepts_the_glm_patch_width() {
271        assert!(check_pixels(&vec![0.0; 64 * 1176], 64, 64, 1176, 6400).is_ok());
272    }
273
274    /// A Qwen-shaped buffer (1536 floats per patch) against a GLM encoder is
275    /// the mirror image of the bug that made the Qwen encoder unable to bind
276    /// this checkpoint — and it must be refused, not DMA'd.
277    #[test]
278    fn rejects_a_qwen_width_buffer() {
279        let err = check_pixels(&vec![0.0; 64 * 1536], 64, 64, 1176, 6400)
280            .unwrap_err()
281            .to_string();
282        assert!(err.contains("1176"), "{err}");
283        assert!(err.contains("patch_size"), "{err}");
284    }
285
286    /// The bound on the DESTINATION row, not the per-image count: a small
287    /// image placed near the end of the packed buffer still overruns.
288    #[test]
289    fn rejects_a_small_image_placed_past_the_end() {
290        let pixels = vec![0.0f32; 8 * 1176];
291        assert!(check_pixels(&pixels, 8, 6399 + 8, 1176, 6400).is_err());
292        assert!(check_pixels(&pixels, 8, 100 + 8, 1176, 6400).is_ok());
293    }
294
295    #[test]
296    fn rejects_a_patch_count_that_overflows() {
297        let err = check_pixels(&[], usize::MAX / 2, 0, 1176, 6400)
298            .unwrap_err()
299            .to_string();
300        assert!(err.contains("overflow"), "{err}");
301    }
302}