metrale_model_layers/layers/glm_vit/
forward.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: `GlmVit::forward_batched`: pixels to the merged embeddings in `buf_out`.
4//!
5//! `buf_out` holds only the merger's output rows, packed in image order.
6//!
7//! Owner: model-layers (vision).
8//! Invariants: an image's merged rows start at the sum of the merged-row
9//! counts of the images before it.
10
11use anyhow::Result;
12use metrale_gpu_runtime::gpu::GpuBackend;
13use metrale_gpu_runtime::kernel_args::{KernelLaunch, div_ceil};
14
15use super::GlmVit;
16
17/// 2026-09-25: Check one image's host pixel buffer against both the encoder's
18/// `patch_dim` (length must be `patches * patch_dim`) and the scratch rows the
19/// upload touches (`end_row <= p_max`).
20fn check_pixels(
21    pixels: &[f32],
22    patches: usize,
23    end_row: usize,
24    patch_dim: usize,
25    p_max: usize,
26) -> Result<()> {
27    let want = patches
28        .checked_mul(patch_dim)
29        .ok_or_else(|| anyhow::anyhow!("glm_vit: patch count {patches} overflows"))?;
30    anyhow::ensure!(
31        pixels.len() == want,
32        "glm_vit: pixel buffer is {} floats for {patches} patches, but this encoder is built \
33         for {patch_dim} floats per patch ({want}). The checkpoint's vision_config \
34         patch_size/temporal_patch_size do not match the tower that was bound.",
35        pixels.len()
36    );
37    anyhow::ensure!(
38        end_row <= p_max,
39        "glm_vit: this upload ends at patch row {end_row} but the encoder's buffers hold \
40         {p_max} rows ({patches} patches in this image)."
41    );
42    Ok(())
43}
44
45impl GlmVit {
46    /// 2026-09-25: Encode `images` (pixels, grid h, grid w). Returns per-image
47    /// `(post_h, post_w, merged_p)` in image order, the `VisionTower::forward_batched` contract.
48    ///
49    /// Images are encoded in greedy chunks whose Σpatches fits `p_max`, and
50    /// each chunk's merged rows land after the previous chunk's in `buf_out`.
51    /// Refuses a grid not divisible by the merge size, an image larger than
52    /// `p_max`, and a batch whose merged rows exceed `buf_out`.
53    pub fn forward_batched(
54        &self,
55        images: &[(&[f32], usize, usize)],
56        gpu: &dyn GpuBackend,
57        stream: u64,
58    ) -> Result<Vec<(usize, usize, usize)>> {
59        self.scratch_init(gpu)?;
60        let sms = self.spatial_merge_size;
61        let sms2 = sms * sms;
62
63        for (_, gh, gw) in images {
64            anyhow::ensure!(
65                gh % sms == 0 && gw % sms == 0 && *gh > 0 && *gw > 0,
66                "glm_vit: patch grid {gh}x{gw} is not a multiple of the {sms}x{sms} merge — \
67                 the downsample reshapes four CONSECUTIVE tokens into one block and an odd \
68                 grid silently mixes rows"
69            );
70            anyhow::ensure!(
71                gh * gw <= self.p_max,
72                "glm_vit: one image of {} patches exceeds the encoder capacity of {} rows",
73                gh * gw,
74                self.p_max
75            );
76        }
77
78        let mut total_merged = 0usize;
79        for (_, gh, gw) in images {
80            total_merged += (gh * gw) / sms2;
81        }
82        anyhow::ensure!(
83            total_merged <= self.mp_max(),
84            "glm_vit: this batch packs {total_merged} merged rows into an output buffer of {} \
85             rows. Send fewer images, or allocate a larger vision scratch.",
86            self.mp_max()
87        );
88
89        let mut mp_base = 0usize;
90        let mut start = 0usize;
91        while start < images.len() {
92            let mut end = start;
93            let mut p_sum = 0usize;
94            while end < images.len() {
95                let (_, gh, gw) = images[end];
96                if p_sum + gh * gw > self.p_max {
97                    break;
98                }
99                p_sum += gh * gw;
100                end += 1;
101            }
102            debug_assert!(end > start, "capacity was checked per image above");
103            self.encode_chunk(&images[start..end], mp_base, gpu, stream)?;
104            mp_base += p_sum / sms2;
105            start = end;
106        }
107
108        Ok(images
109            .iter()
110            .map(|(_, gh, gw)| (gh / sms, gw / sms, (gh * gw) / sms2))
111            .collect())
112    }
113
114    /// 2026-09-25: One chunk whose Σpatches fits `p_max`. Writes `Σmerged` rows of
115    /// `buf_out` starting at `mp_base`.
116    fn encode_chunk(
117        &self,
118        images: &[(&[f32], usize, usize)],
119        mp_base: usize,
120        gpu: &dyn GpuBackend,
121        stream: u64,
122    ) -> Result<()> {
123        let s = self.scratch();
124        let sms2 = self.spatial_merge_size * self.spatial_merge_size;
125        let mut p_i = Vec::with_capacity(images.len());
126        let mut p_off = Vec::with_capacity(images.len());
127        let mut p_total = 0usize;
128        for (_, gh, gw) in images {
129            p_off.push(p_total);
130            p_i.push(gh * gw);
131            p_total += gh * gw;
132        }
133
134        // 2026-09-25: 1. Per-image axial RoPE tables, packed at `p_off[i]`.
135        for (i, (_, gh, gw)) in images.iter().enumerate() {
136            self.upload_rope_tables(
137                *gh,
138                *gw,
139                s.buf_rope_cos.offset(p_off[i] * self.head_dim * 2),
140                s.buf_rope_sin.offset(p_off[i] * self.head_dim * 2),
141                gpu,
142                stream,
143            )?;
144        }
145
146        // 2026-09-25: 2. Patch embed: one GEMM over all Σp rows against
147        //    `patch_embed.proj.weight` read as `[hidden, patch_dim]` (the loader
148        //    checks that flattened shape).
149        for (i, (pixels, gh, gw)) in images.iter().enumerate() {
150            let p = gh * gw;
151            let end_row = p_off[i]
152                .checked_add(p)
153                .ok_or_else(|| anyhow::anyhow!("glm_vit: patch row offset overflows"))?;
154            check_pixels(pixels, p, end_row, self.patch_dim, self.p_max)?;
155            // 2026-09-25: SAFETY: `pixels` is a live `&[f32]` and the byte length is
156            // taken from that same slice, so the view stays inside the allocation.
157            // Every byte of an `f32` is initialized and `u8` has alignment 1.
158            let bytes: &[u8] = unsafe {
159                std::slice::from_raw_parts(pixels.as_ptr().cast::<u8>(), pixels.len() * 4)
160            };
161            gpu.copy_h2d_async(
162                bytes,
163                s.buf_f32.offset(p_off[i] * self.patch_dim * 4),
164                stream,
165            )?;
166        }
167        let n_f32 = (p_total * self.patch_dim) as u32;
168        KernelLaunch::new(gpu, self.k_f32_bf16)
169            .grid([div_ceil(n_f32, 256), 1, 1])
170            .block([256, 1, 1])
171            .arg_ptr(s.buf_f32)
172            .arg_ptr(s.buf_gate)
173            .arg_u32(n_f32)
174            .launch(stream)?;
175        self.gemm(
176            gpu,
177            s.buf_gate,
178            self.patch_embed_w,
179            Some(self.patch_embed_b),
180            s.buf_h1,
181            p_total as u32,
182            self.hidden_size as u32,
183            self.patch_dim as u32,
184            stream,
185        )?;
186        self.maybe_dump(
187            gpu,
188            s.buf_h1,
189            p_total * self.hidden_size,
190            "patch_embed",
191            stream,
192        )?;
193
194        // 2026-09-25: 3. No positional embedding is added: the loader binds no
195        //    `pos_embed` tensor, and position enters only through the axial
196        //    RoPE in each block's attention.
197        for (idx, blk) in self.blocks.iter().enumerate() {
198            self.block(blk, p_total, &p_i, &p_off, gpu, stream)?;
199            self.maybe_dump(
200                gpu,
201                s.buf_h1,
202                p_total * self.hidden_size,
203                &format!("block{idx:02}"),
204                stream,
205            )?;
206        }
207
208        // 2026-09-25: 4. `post_layernorm` (weight-only RMSNorm), conv downsample, merger.
209        self.rmsnorm(
210            gpu,
211            s.buf_h1,
212            self.merger.post_layernorm_w,
213            p_total as u32,
214            self.hidden_size as u32,
215            stream,
216        )?;
217        let out = s.buf_out.offset(mp_base * self.out_hidden_size * 2);
218        self.downsample_and_merge(p_total / sms2, out, gpu, stream)?;
219        self.maybe_dump(
220            gpu,
221            out,
222            (p_total / sms2) * self.out_hidden_size,
223            "merged",
224            stream,
225        )
226    }
227
228    /// 2026-09-25: `METRALE_DUMP_GLM_VIT=<dir>` synchronizes the stream and writes
229    /// a BF16 buffer to `<dir>/<label>.bin` (raw bytes, no header). A no-op when
230    /// the variable is unset or empty.
231    pub(super) fn maybe_dump(
232        &self,
233        gpu: &dyn GpuBackend,
234        ptr: metrale_gpu_runtime::gpu::DevicePtr,
235        n_elements: usize,
236        label: &str,
237        stream: u64,
238    ) -> Result<()> {
239        let Ok(dir) = std::env::var("METRALE_DUMP_GLM_VIT") else {
240            return Ok(());
241        };
242        if dir.is_empty() {
243            return Ok(());
244        }
245        gpu.synchronize(stream)?;
246        let mut buf = vec![0u8; n_elements * 2];
247        gpu.copy_d2h(ptr, &mut buf)?;
248        std::fs::create_dir_all(&dir).ok();
249        std::fs::write(
250            std::path::Path::new(&dir).join(format!("{label}.bin")),
251            &buf,
252        )?;
253        tracing::info!("METRALE_DUMP_GLM_VIT: wrote {label} ({n_elements} elements)");
254        Ok(())
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::check_pixels;
261
262    /// 2026-09-25: 1176 = 3 × 2 × 14² floats per patch.
263    #[test]
264    fn accepts_the_glm_patch_width() {
265        assert!(check_pixels(&vec![0.0; 64 * 1176], 64, 64, 1176, 6400).is_ok());
266    }
267
268    /// 2026-09-25: A buffer of 1536 floats per patch (the Qwen encoder's
269    /// `PATCH_DIM`) is refused by a 1176-wide encoder.
270    #[test]
271    fn rejects_a_qwen_width_buffer() {
272        let err = check_pixels(&vec![0.0; 64 * 1536], 64, 64, 1176, 6400)
273            .unwrap_err()
274            .to_string();
275        assert!(err.contains("1176"), "{err}");
276        assert!(err.contains("patch_size"), "{err}");
277    }
278
279    /// 2026-09-25: The bound is on the destination row, not the per-image count:
280    /// a small image placed near the end of the packed buffer is refused.
281    #[test]
282    fn rejects_a_small_image_placed_past_the_end() {
283        let pixels = vec![0.0f32; 8 * 1176];
284        assert!(check_pixels(&pixels, 8, 6399 + 8, 1176, 6400).is_err());
285        assert!(check_pixels(&pixels, 8, 100 + 8, 1176, 6400).is_ok());
286    }
287
288    #[test]
289    fn rejects_a_patch_count_that_overflows() {
290        let err = check_pixels(&[], usize::MAX / 2, 0, 1176, 6400)
291            .unwrap_err()
292            .to_string();
293        assert!(err.contains("overflow"), "{err}");
294    }
295}