metrale_model_layers/layers/vision_encoder/enc_impl/
forward.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: `VisionEncoder::forward` / `forward_batched`: the image → token pipeline
4//! (pos_embed → RoPE → patch embed → ViT blocks with DeepStack merger taps →
5//! final merger).
6//!
7//! Owner: model-layers (vision).
8//! Invariants:
9//! - Rows are packed in image order: image `i`'s final-merger rows start at
10//!   row `mp_off[i]` of `buf_out`.
11//!
12//! The batched form runs the block GEMMs once over Σpatches of N images;
13//! stages that depend on one image's geometry (host pos/rope prep, attention,
14//! mergers) loop per image.
15
16use anyhow::Result;
17use metrale_gpu_runtime::gpu::GpuBackend;
18
19use super::super::VisionEncoder;
20
21/// 2026-09-25: Whether the packed merged rows of this batch fit `buf_out` (`p_max` rows).
22///
23/// All temporal groups of one video arrive as one media item and are encoded
24/// as one batch, so a per-item cap upstream does not bound their sum.
25/// Returns an error for inconsistent lengths, an overflowing offset, or an
26/// end row past `p_max`. A free function, so the refusal is testable without
27/// a GPU.
28fn check_packed_rows(mp_i: &[usize], mp_off: &[usize], p_max: usize) -> Result<()> {
29    anyhow::ensure!(
30        mp_i.len() == mp_off.len(),
31        "vision: {} merged row counts but {} offsets; the packed layout is inconsistent",
32        mp_i.len(),
33        mp_off.len()
34    );
35    let end = match (mp_off.last(), mp_i.last()) {
36        (Some(off), Some(n)) => off
37            .checked_add(*n)
38            .ok_or_else(|| anyhow::anyhow!("vision: merged row offset {off} + {n} overflows"))?,
39        _ => 0,
40    };
41    anyhow::ensure!(
42        end <= p_max,
43        "vision: this batch packs {end} merged rows into an output buffer of {p_max} rows. \
44         A video arrives as one media item whose temporal groups encode as a single batch, \
45         which defeats the scheduler's per-item cap. Send fewer frames, or allocate a \
46         larger vision scratch."
47    );
48    Ok(())
49}
50
51impl VisionEncoder {
52    /// 2026-09-25: Single-image forward through `forward_batched`. `pixels` is
53    /// `[P, PATCH_DIM]` f32. Returns `(1 + deepstack_indexes.len()) * merged_p`.
54    pub fn forward(
55        &self,
56        pixels: &[f32],
57        grid_h: usize,
58        grid_w: usize,
59        gpu: &dyn GpuBackend,
60        stream: u64,
61    ) -> Result<usize> {
62        let images = [(pixels, grid_h, grid_w)];
63        let per_image = self.forward_batched(&images, gpu, stream)?;
64        let merged_p = per_image[0].2;
65        Ok((1 + self.deepstack_indexes.len()) * merged_p)
66    }
67
68    /// 2026-09-25: Batched forward over N images. Ops that do not depend on image
69    /// geometry (patch_embed and every block's GEMMs, norms, GELU and
70    /// residuals) run once over M = Σpᵢ; host pos/rope prep, attention and the
71    /// mergers loop per image.
72    ///
73    /// `buf_out` layout, rows of `out_hidden_size` BF16:
74    /// - `0 .. Σmerged_p`: the final merger, packed in image order (the rows
75    ///   the LLM reads);
76    /// - from `(k+1)*Σmerged_p`: DeepStack merger `k`, packed the same way.
77    ///
78    /// When Σp <= p_max this path writes up to `(1 + deepstack count) *
79    /// Σmerged_p` rows, which fits the `p_max`-row `buf_out` only while
80    /// `1 + deepstack count <= spatial_merge_size²`; nothing checks it. When
81    /// Σp > p_max it encodes image by image without DeepStack rows.
82    ///
83    /// Returns per-image `(post_h, post_w, merged_p)` in image order.
84    pub fn forward_batched(
85        &self,
86        images: &[(&[f32], usize, usize)],
87        gpu: &dyn GpuBackend,
88        stream: u64,
89    ) -> Result<Vec<(usize, usize, usize)>> {
90        // 2026-09-25: Allocate the ViT scratch on the first image (`forward`
91        // delegates here).
92        self.scratch_init(gpu)?;
93        let sms2 = self.spatial_merge_size * self.spatial_merge_size;
94        let sms = self.spatial_merge_size.max(1);
95        let n_img = images.len();
96
97        // 2026-09-25: Per-image pre-merge patch counts (p_i) and post-merge counts
98        // (mp_i), with running row offsets into the shared buffers (p_off / mp_off).
99        let mut p_i = Vec::with_capacity(n_img);
100        let mut p_off = Vec::with_capacity(n_img);
101        let mut mp_i = Vec::with_capacity(n_img);
102        let mut mp_off = Vec::with_capacity(n_img);
103        let (mut p_total, mut mp_total) = (0usize, 0usize);
104        for (_px, gh, gw) in images.iter() {
105            let p = gh * gw;
106            let mp = p / sms2;
107            p_off.push(p_total);
108            mp_off.push(mp_total);
109            p_i.push(p);
110            mp_i.push(mp);
111            p_total += p;
112            mp_total += mp;
113        }
114
115        // 2026-09-25: Past p_max, encode image by image, packing the final-merger
116        // rows of `buf_out` the same way.
117        if p_total > self.p_max {
118            return self.forward_oversized_fallback(images, &p_i, &mp_i, &mp_off, sms, gpu, stream);
119        }
120
121        let _sec0 = std::time::Instant::now();
122        // 2026-09-25: 1. Per-image host prep, packed into the shared buffers at p_off[i].
123        let pos_interp_on = std::env::var("METRALE_VISION_POSINTERP")
124            .map(|v| v != "0")
125            .unwrap_or(true);
126        for (i, (_px, gh, gw)) in images.iter().enumerate() {
127            let p = p_i[i];
128            let pos_dst = self
129                .scratch()
130                .buf_pos_resampled
131                .offset(p_off[i] * self.hidden_size * 2);
132            if pos_interp_on {
133                self.resample_pos_embed_into(*gh, *gw, pos_dst, gpu, stream)?;
134            } else {
135                self.gpu_copy_bf16(
136                    gpu,
137                    self.pos_embed,
138                    pos_dst,
139                    p * self.hidden_size * 2,
140                    stream,
141                )?;
142            }
143            let cos_dst = self
144                .scratch()
145                .buf_rope_cos
146                .offset(p_off[i] * self.head_dim * 2);
147            let sin_dst = self
148                .scratch()
149                .buf_rope_sin
150                .offset(p_off[i] * self.head_dim * 2);
151            self.build_rope_cossin_into(*gh, *gw, cos_dst, sin_dst, gpu, stream)?;
152        }
153
154        let timing = std::env::var("METRALE_VISION_TIMING").is_ok();
155        if timing {
156            gpu.synchronize(stream).ok();
157            tracing::info!(
158                "VIT_SEC host_prep({n_img} imgs): {:.1}ms",
159                _sec0.elapsed().as_secs_f64() * 1000.0
160            );
161        }
162        let _sec1 = std::time::Instant::now();
163        // 2026-09-25: 2. Patch embed over M=Σp.
164        self.patch_embed_batched(images, &p_off, p_total, gpu, stream)?;
165        Self::maybe_dump_buf(
166            gpu,
167            self.scratch().buf_h1,
168            p_total * self.hidden_size,
169            "patch_embed",
170            stream,
171        )?;
172
173        // 2026-09-25: 3. The blocks: batched ops once, attention and DeepStack
174        // mergers per image.
175        let n_h_bytes = p_total * self.hidden_size * 2;
176        let mut deepstack_iter = self.deepstack_indexes.iter().enumerate();
177        let mut next_ds = deepstack_iter.next(); // 2026-09-25: (merger index, &1-based block)
178        for (block_idx, blk) in self.blocks.iter().enumerate() {
179            self.vit_block_batched(blk, p_total, &p_i, &p_off, gpu, stream)?;
180            Self::maybe_dump_buf(
181                gpu,
182                self.scratch().buf_h1,
183                p_total * self.hidden_size,
184                &format!("block{block_idx:02}"),
185                stream,
186            )?;
187            if let Some((ds_idx, &ds_block)) = next_ds
188                && block_idx + 1 == ds_block
189            {
190                // 2026-09-25: Copy buf_h1 → buf_h2 and merge from the copy:
191                // `apply_merger` normalises its source in place, and buf_h1 feeds
192                // the next block.
193                self.gpu_copy_bf16(
194                    gpu,
195                    self.scratch().buf_h1,
196                    self.scratch().buf_h2,
197                    n_h_bytes,
198                    stream,
199                )?;
200                let ds_region_base = (ds_idx + 1) * mp_total;
201                for (i, (_px, gh, gw)) in images.iter().enumerate() {
202                    let src = self
203                        .scratch()
204                        .buf_h2
205                        .offset(p_off[i] * self.hidden_size * 2);
206                    let out_rows = ds_region_base + mp_off[i];
207                    let out_slice = self
208                        .scratch()
209                        .buf_out
210                        .offset(out_rows * self.out_hidden_size * 2);
211                    self.apply_merger(
212                        &self.deepstack[ds_idx],
213                        p_i[i],
214                        *gh,
215                        *gw,
216                        src,
217                        out_slice,
218                        gpu,
219                        stream,
220                    )?;
221                }
222                next_ds = deepstack_iter.next();
223            }
224        }
225
226        if timing {
227            gpu.synchronize(stream).ok();
228            tracing::info!(
229                "VIT_SEC patch+27blocks(M={p_total}): {:.1}ms",
230                _sec1.elapsed().as_secs_f64() * 1000.0
231            );
232        }
233        let _sec2 = std::time::Instant::now();
234        // 2026-09-25: 4. Final merger per image → packed rows `0 .. Σmerged_p`.
235        for (i, (_px, gh, gw)) in images.iter().enumerate() {
236            let src = self
237                .scratch()
238                .buf_h1
239                .offset(p_off[i] * self.hidden_size * 2);
240            let out_slice = self
241                .scratch()
242                .buf_out
243                .offset(mp_off[i] * self.out_hidden_size * 2);
244            self.apply_merger(&self.merger, p_i[i], *gh, *gw, src, out_slice, gpu, stream)?;
245        }
246        if timing {
247            gpu.synchronize(stream).ok();
248            tracing::info!(
249                "VIT_SEC mergers(final+{} ds): {:.1}ms",
250                self.deepstack_indexes.len(),
251                _sec2.elapsed().as_secs_f64() * 1000.0
252            );
253        }
254        // 2026-09-25: Dump the final and DeepStack regions (METRALE_DUMP_VIT).
255        let dump_rows = (1 + self.deepstack_indexes.len()) * mp_total;
256        Self::maybe_dump_buf(
257            gpu,
258            self.scratch().buf_out,
259            dump_rows * self.out_hidden_size,
260            "final",
261            stream,
262        )?;
263
264        Ok(images
265            .iter()
266            .map(|(_px, gh, gw)| (gh / sms, gw / sms, (gh * gw) / sms2))
267            .collect())
268    }
269
270    /// 2026-09-25: Fallback for Σp > p_max: encode each image alone, writing its
271    /// final-merger rows into the packed `buf_out` at `mp_off[i]`, after
272    /// `check_packed_rows` has bounded them. No DeepStack rows are written.
273    #[allow(clippy::too_many_arguments)]
274    fn forward_oversized_fallback(
275        &self,
276        images: &[(&[f32], usize, usize)],
277        p_i: &[usize],
278        mp_i: &[usize],
279        mp_off: &[usize],
280        sms: usize,
281        gpu: &dyn GpuBackend,
282        stream: u64,
283    ) -> Result<Vec<(usize, usize, usize)>> {
284        check_packed_rows(mp_i, mp_off, self.p_max)?;
285        let pos_interp_on = std::env::var("METRALE_VISION_POSINTERP")
286            .map(|v| v != "0")
287            .unwrap_or(true);
288        for (i, (pixels, gh, gw)) in images.iter().enumerate() {
289            let p = p_i[i];
290            if pos_interp_on {
291                self.resample_pos_embed(*gh, *gw, gpu, stream)?;
292            } else {
293                self.gpu_copy_bf16(
294                    gpu,
295                    self.pos_embed,
296                    self.scratch().buf_pos_resampled,
297                    p * self.hidden_size * 2,
298                    stream,
299                )?;
300            }
301            self.build_rope_cossin(*gh, *gw, gpu, stream)?;
302            self.patch_embed(pixels, p, gpu, stream)?;
303            for blk in self.blocks.iter() {
304                self.vit_block(blk, p, gpu, stream)?;
305            }
306            let out_slice = self
307                .scratch()
308                .buf_out
309                .offset(mp_off[i] * self.out_hidden_size * 2);
310            self.apply_merger(
311                &self.merger,
312                p,
313                *gh,
314                *gw,
315                self.scratch().buf_h1,
316                out_slice,
317                gpu,
318                stream,
319            )?;
320        }
321        Ok(images
322            .iter()
323            .map(|(_px, gh, gw)| (gh / sms, gw / sms, (gh * gw) / (sms * sms)))
324            .collect())
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::check_packed_rows;
331
332    /// 2026-09-25: One video of 19 temporal groups at 30x34 patches merges 2x2 to
333    /// 255 rows per group, so the packed write ends at row 4845. Against a
334    /// 1024-row buffer it is refused, and the error names both numbers.
335    #[test]
336    fn refuses_the_video_batch_that_poisoned_the_cuda_context() {
337        let groups = 19;
338        let merged_per_group = (30 / 2) * (34 / 2);
339        let mp_i = vec![merged_per_group; groups];
340        let mp_off: Vec<usize> = (0..groups).map(|g| g * merged_per_group).collect();
341        assert_eq!(mp_off.last().unwrap() + mp_i.last().unwrap(), 4845);
342
343        let err = check_packed_rows(&mp_i, &mp_off, 1024)
344            .unwrap_err()
345            .to_string();
346        assert!(
347            err.contains("4845"),
348            "must name the rows it would have written: {err}"
349        );
350        assert!(err.contains("1024"), "must name the capacity: {err}");
351    }
352
353    /// 2026-09-25: A batch that fits passes, including one that fills the buffer
354    /// exactly; one row more fails.
355    #[test]
356    fn admits_a_batch_that_fits() {
357        assert!(check_packed_rows(&[100, 200], &[0, 100], 1024).is_ok());
358        assert!(check_packed_rows(&[24], &[1000], 1024).is_ok());
359        assert!(check_packed_rows(&[25], &[1000], 1024).is_err());
360    }
361
362    /// 2026-09-25: An empty batch passes; counts and offsets of different lengths
363    /// are an error, not a panic.
364    #[test]
365    fn handles_empty_and_mismatched_layouts_without_panicking() {
366        assert!(check_packed_rows(&[], &[], 1024).is_ok());
367        let err = check_packed_rows(&[], &[0], 1024).unwrap_err().to_string();
368        assert!(err.contains("inconsistent"), "{err}");
369    }
370
371    /// 2026-09-25: `usize` addition on request-controlled geometry must not wrap
372    /// into a passing comparison.
373    #[test]
374    fn an_overflowing_offset_is_an_error_not_a_wrap() {
375        let err = check_packed_rows(&[2], &[usize::MAX - 1], 1024)
376            .unwrap_err()
377            .to_string();
378        assert!(err.contains("overflows"), "{err}");
379    }
380}