spark_model/layers/vision_encoder/enc_impl/
forward.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Top-level `VisionEncoder::forward` / `forward_batched`: drives the full
4//! image → token pipeline (pos_embed → RoPE → patch embed → 27 ViT blocks
5//! with deepstack merger taps → final merger). The batched form runs the
6//! weight-bound block GEMMs ONCE over Σpatches across N images so concurrent
7//! image requests stop serializing; per-image-geometry stages (host pos/rope
8//! prep, attention, mergers) loop per image.
9
10use anyhow::Result;
11use spark_runtime::gpu::GpuBackend;
12
13use super::super::VisionEncoder;
14
15/// Do the packed merged rows this batch will write fit `buf_out`?
16///
17/// #799: this bound existed as a `debug_assert!`, which is compiled out of the
18/// `--release` binaries we serve. A single video request then wrote 4.7x past
19/// the allocation, raising `CUDA_ERROR_ILLEGAL_ADDRESS` and poisoning the CUDA
20/// context — the process survived and answered 503 to every later request, for
21/// every tenant, until it was restarted.
22///
23/// The caller's own doc comment says the scheduler caps `Σp <= p_max` "so this
24/// is normally unreachable". Video defeats that cap: all temporal groups of one
25/// clip arrive as a SINGLE media item and are encoded as one batch, so the
26/// per-item cap never sees the sum. 19 groups of 30x34 patches merge to 4845
27/// rows against a 1024-row buffer.
28///
29/// A `Result` rather than an assert, and a free function rather than a method,
30/// for the same reason `check_pixel_len` next door is one: the refusal is the
31/// behaviour worth testing, and a bound that can only be exercised with a GPU
32/// attached is a bound nothing will exercise. Prose is not a bound; this is.
33fn check_packed_rows(mp_i: &[usize], mp_off: &[usize], p_max: usize) -> Result<()> {
34    anyhow::ensure!(
35        mp_i.len() == mp_off.len(),
36        "vision: {} merged row counts but {} offsets; the packed layout is inconsistent",
37        mp_i.len(),
38        mp_off.len()
39    );
40    let end = match (mp_off.last(), mp_i.last()) {
41        (Some(off), Some(n)) => off
42            .checked_add(*n)
43            .ok_or_else(|| anyhow::anyhow!("vision: merged row offset {off} + {n} overflows"))?,
44        _ => 0,
45    };
46    anyhow::ensure!(
47        end <= p_max,
48        "vision: this batch packs {end} merged rows into an output buffer of {p_max} rows. \
49         A video arrives as one media item whose temporal groups encode as a single batch, \
50         which defeats the scheduler's per-item cap. Send fewer frames, or allocate a \
51         larger vision scratch."
52    );
53    Ok(())
54}
55
56impl VisionEncoder {
57    /// Single-image forward (back-compat shim). For N=1 this issues the SAME
58    /// kernels with the SAME args in the SAME order as the old per-image path
59    /// → byte-identical output. Returns `total_rows = (1+n_deepstack)*merged_p`
60    /// (the OLD return value; only ever tested `> 0` downstream).
61    pub fn forward(
62        &self,
63        pixels: &[f32], // [P, C*T*Hp*Wp = 1536]
64        grid_h: usize,
65        grid_w: usize,
66        gpu: &dyn GpuBackend,
67        stream: u64,
68    ) -> Result<usize> {
69        let images = [(pixels, grid_h, grid_w)];
70        let per_image = self.forward_batched(&images, gpu, stream)?;
71        let merged_p = per_image[0].2;
72        Ok((1 + self.deepstack_indexes.len()) * merged_p)
73    }
74
75    /// Batched forward over N images. M-agnostic ops (patch_embed + all 27
76    /// blocks' GEMMs/norms/gelu/residuals) run ONCE over M=Σpᵢ; per-image-
77    /// geometry stages (host pos/rope prep, attention, mergers) loop per image.
78    ///
79    /// `buf_out` layout (rows of out_hidden_size BF16):
80    ///   [0 .. Σmerged_p)            = final merger, IMAGE-ORDER packed ← splicer reads here
81    ///   [(k+1)*Σmerged_p .. )       = deepstack-k, image-order packed   ← LLM-unused
82    ///
83    /// IN-BOUNDS INVARIANT: the deepstack high-water row is 4·Σmerged_p = Σp ≤
84    /// p_max (all four mergers emit exactly merged_p rows each), and buf_out is
85    /// p_max rows → no realloc, no overrun.
86    ///
87    /// Returns per-image `(post_h, post_w, merged_p)` in image order.
88    pub fn forward_batched(
89        &self,
90        images: &[(&[f32], usize, usize)],
91        gpu: &dyn GpuBackend,
92        stream: u64,
93    ) -> Result<Vec<(usize, usize, usize)>> {
94        // Allocate the ViT scratch on the first image. THE single entry point
95        // for image work — `forward` delegates here — so a text-only serve
96        // never reaches this line and never pays the ~2.2 GB.
97        self.scratch_init(gpu)?;
98        let sms2 = self.spatial_merge_size * self.spatial_merge_size;
99        let sms = self.spatial_merge_size.max(1);
100        let n_img = images.len();
101
102        // Per-image pre-merge patch counts (p_i) and post-merge counts (mp_i),
103        // with running row offsets into the shared buffers (p_off / mp_off).
104        let mut p_i = Vec::with_capacity(n_img);
105        let mut p_off = Vec::with_capacity(n_img);
106        let mut mp_i = Vec::with_capacity(n_img);
107        let mut mp_off = Vec::with_capacity(n_img);
108        let (mut p_total, mut mp_total) = (0usize, 0usize);
109        for (_px, gh, gw) in images.iter() {
110            let p = gh * gw;
111            let mp = p / sms2;
112            p_off.push(p_total);
113            mp_off.push(mp_total);
114            p_i.push(p);
115            mp_i.push(mp);
116            p_total += p;
117            mp_total += mp;
118        }
119
120        // Callers cap Σp ≤ p_max; defend here with a still-correct per-image
121        // fallback that packs buf_out the same way.
122        if p_total > self.p_max {
123            return self.forward_oversized_fallback(images, &p_i, &mp_i, &mp_off, sms, gpu, stream);
124        }
125
126        let _sec0 = std::time::Instant::now();
127        // 1. Per-image host prep, packed into the SHARED buffers at p_off[i].
128        let pos_interp_on = std::env::var("ATLAS_VISION_POSINTERP")
129            .map(|v| v != "0")
130            .unwrap_or(true);
131        for (i, (_px, gh, gw)) in images.iter().enumerate() {
132            let p = p_i[i];
133            let pos_dst = self
134                .scratch()
135                .buf_pos_resampled
136                .offset(p_off[i] * self.hidden_size * 2);
137            if pos_interp_on {
138                self.resample_pos_embed_into(*gh, *gw, pos_dst, gpu, stream)?;
139            } else {
140                self.gpu_copy_bf16(
141                    gpu,
142                    self.pos_embed,
143                    pos_dst,
144                    p * self.hidden_size * 2,
145                    stream,
146                )?;
147            }
148            let cos_dst = self
149                .scratch()
150                .buf_rope_cos
151                .offset(p_off[i] * self.head_dim * 2);
152            let sin_dst = self
153                .scratch()
154                .buf_rope_sin
155                .offset(p_off[i] * self.head_dim * 2);
156            self.build_rope_cossin_into(*gh, *gw, cos_dst, sin_dst, gpu, stream)?;
157        }
158
159        let timing = std::env::var("ATLAS_VISION_TIMING").is_ok();
160        if timing {
161            gpu.synchronize(stream).ok();
162            tracing::info!(
163                "VIT_SEC host_prep({n_img} imgs): {:.1}ms",
164                _sec0.elapsed().as_secs_f64() * 1000.0
165            );
166        }
167        let _sec1 = std::time::Instant::now();
168        // 2. Patch embed over M=Σp.
169        self.patch_embed_batched(images, &p_off, p_total, gpu, stream)?;
170        Self::maybe_dump_buf(
171            gpu,
172            self.scratch().buf_h1,
173            p_total * self.hidden_size,
174            "patch_embed",
175            stream,
176        )?;
177
178        // 3. 27 blocks: M-agnostic ops once, attention per image, deepstack per image.
179        let n_h_bytes = p_total * self.hidden_size * 2;
180        let mut deepstack_iter = self.deepstack_indexes.iter().enumerate();
181        let mut next_ds = deepstack_iter.next(); // (merger_idx, &block_1indexed)
182        for (block_idx, blk) in self.blocks.iter().enumerate() {
183            self.vit_block_batched(blk, p_total, &p_i, &p_off, gpu, stream)?;
184            Self::maybe_dump_buf(
185                gpu,
186                self.scratch().buf_h1,
187                p_total * self.hidden_size,
188                &format!("block{block_idx:02}"),
189                stream,
190            )?;
191            if let Some((ds_idx, &ds_block)) = next_ds
192                && block_idx + 1 == ds_block
193            {
194                // snapshot buf_h1 → buf_h2 (out-of-place merger; residual stream
195                // into the next block stays intact), then merge each image's slice.
196                self.gpu_copy_bf16(
197                    gpu,
198                    self.scratch().buf_h1,
199                    self.scratch().buf_h2,
200                    n_h_bytes,
201                    stream,
202                )?;
203                let ds_region_base = (ds_idx + 1) * mp_total;
204                for (i, (_px, gh, gw)) in images.iter().enumerate() {
205                    let src = self
206                        .scratch()
207                        .buf_h2
208                        .offset(p_off[i] * self.hidden_size * 2);
209                    let out_rows = ds_region_base + mp_off[i];
210                    let out_slice = self
211                        .scratch()
212                        .buf_out
213                        .offset(out_rows * self.out_hidden_size * 2);
214                    self.apply_merger(
215                        &self.deepstack[ds_idx],
216                        p_i[i],
217                        *gh,
218                        *gw,
219                        src,
220                        out_slice,
221                        gpu,
222                        stream,
223                    )?;
224                }
225                next_ds = deepstack_iter.next();
226            }
227        }
228
229        if timing {
230            gpu.synchronize(stream).ok();
231            tracing::info!(
232                "VIT_SEC patch+27blocks(M={p_total}): {:.1}ms",
233                _sec1.elapsed().as_secs_f64() * 1000.0
234            );
235        }
236        let _sec2 = std::time::Instant::now();
237        // 4. Final merger per image → packed [0 .. Σmerged_p).
238        for (i, (_px, gh, gw)) in images.iter().enumerate() {
239            let src = self
240                .scratch()
241                .buf_h1
242                .offset(p_off[i] * self.hidden_size * 2);
243            let out_slice = self
244                .scratch()
245                .buf_out
246                .offset(mp_off[i] * self.out_hidden_size * 2);
247            self.apply_merger(&self.merger, p_i[i], *gh, *gw, src, out_slice, gpu, stream)?;
248        }
249        if timing {
250            gpu.synchronize(stream).ok();
251            tracing::info!(
252                "VIT_SEC mergers(final+{} ds): {:.1}ms",
253                self.deepstack_indexes.len(),
254                _sec2.elapsed().as_secs_f64() * 1000.0
255            );
256        }
257        // Dump the full packed region (final + deepstack) so N=1 == the old
258        // `total_rows` span exactly (byte-identity validation).
259        let dump_rows = (1 + self.deepstack_indexes.len()) * mp_total;
260        Self::maybe_dump_buf(
261            gpu,
262            self.scratch().buf_out,
263            dump_rows * self.out_hidden_size,
264            "final",
265            stream,
266        )?;
267
268        Ok(images
269            .iter()
270            .map(|(_px, gh, gw)| (gh / sms, gw / sms, (gh * gw) / sms2))
271            .collect())
272    }
273
274    /// Fallback for Σp > p_max: encode each image alone (full single-image
275    /// kernel sequence) writing its final-merger rows into the PACKED buf_out
276    /// at mp_off[i]. NO deepstack write (LLM-unused; a packed deepstack region
277    /// could overrun under an oversized batch). The scheduler caps Σp ≤ p_max
278    /// so this is normally unreachable — a correctness guard only.
279    #[allow(clippy::too_many_arguments)]
280    fn forward_oversized_fallback(
281        &self,
282        images: &[(&[f32], usize, usize)],
283        p_i: &[usize],
284        mp_i: &[usize],
285        mp_off: &[usize],
286        sms: usize,
287        gpu: &dyn GpuBackend,
288        stream: u64,
289    ) -> Result<Vec<(usize, usize, usize)>> {
290        check_packed_rows(mp_i, mp_off, self.p_max)?;
291        let pos_interp_on = std::env::var("ATLAS_VISION_POSINTERP")
292            .map(|v| v != "0")
293            .unwrap_or(true);
294        for (i, (pixels, gh, gw)) in images.iter().enumerate() {
295            let p = p_i[i];
296            if pos_interp_on {
297                self.resample_pos_embed(*gh, *gw, gpu, stream)?;
298            } else {
299                self.gpu_copy_bf16(
300                    gpu,
301                    self.pos_embed,
302                    self.scratch().buf_pos_resampled,
303                    p * self.hidden_size * 2,
304                    stream,
305                )?;
306            }
307            self.build_rope_cossin(*gh, *gw, gpu, stream)?;
308            self.patch_embed(pixels, p, gpu, stream)?;
309            for blk in self.blocks.iter() {
310                self.vit_block(blk, p, gpu, stream)?;
311            }
312            let out_slice = self
313                .scratch()
314                .buf_out
315                .offset(mp_off[i] * self.out_hidden_size * 2);
316            self.apply_merger(
317                &self.merger,
318                p,
319                *gh,
320                *gw,
321                self.scratch().buf_h1,
322                out_slice,
323                gpu,
324                stream,
325            )?;
326        }
327        Ok(images
328            .iter()
329            .map(|(_px, gh, gw)| (gh / sms, gw / sms, (gh * gw) / (sms * sms)))
330            .collect())
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::check_packed_rows;
337
338    /// The batch from issue #799, with its real numbers. A Qwen3.8-27B serve at
339    /// `--vision-max-pixels 262144` allocates 1024 patch rows; one video of 19
340    /// temporal groups at 30x34 patches merges 2x2 to 255 rows per group, so
341    /// the packed write ends at 4845 — 4.7x the allocation.
342    ///
343    /// Before this check that write happened, in release, and poisoned the CUDA
344    /// context: the server then answered 503 to every request, for every
345    /// tenant, until restarted. It must now be a refused request instead.
346    #[test]
347    fn refuses_the_video_batch_that_poisoned_the_cuda_context() {
348        let groups = 19;
349        let merged_per_group = (30 / 2) * (34 / 2); // 255
350        let mp_i = vec![merged_per_group; groups];
351        let mp_off: Vec<usize> = (0..groups).map(|g| g * merged_per_group).collect();
352        assert_eq!(mp_off.last().unwrap() + mp_i.last().unwrap(), 4845);
353
354        let err = check_packed_rows(&mp_i, &mp_off, 1024)
355            .unwrap_err()
356            .to_string();
357        assert!(
358            err.contains("4845"),
359            "must name the rows it would have written: {err}"
360        );
361        assert!(err.contains("1024"), "must name the capacity: {err}");
362    }
363
364    /// The ordinary path the scheduler's cap produces must still pass, or the
365    /// fix trades an outage for a refusal of every image request.
366    #[test]
367    fn admits_a_batch_that_fits() {
368        assert!(check_packed_rows(&[100, 200], &[0, 100], 1024).is_ok());
369        // Exactly full is not over-full.
370        assert!(check_packed_rows(&[24], &[1000], 1024).is_ok());
371        // One row past is.
372        assert!(check_packed_rows(&[25], &[1000], 1024).is_err());
373    }
374
375    /// An empty batch writes nothing. The previous expression reached
376    /// `mp_i.last().unwrap()` whenever `mp_off` was non-empty, so a length
377    /// mismatch was a panic rather than an error.
378    #[test]
379    fn handles_empty_and_mismatched_layouts_without_panicking() {
380        assert!(check_packed_rows(&[], &[], 1024).is_ok());
381        let err = check_packed_rows(&[], &[0], 1024).unwrap_err().to_string();
382        assert!(err.contains("inconsistent"), "{err}");
383    }
384
385    /// `usize` addition on attacker-influenced geometry must not wrap into a
386    /// passing comparison.
387    #[test]
388    fn an_overflowing_offset_is_an_error_not_a_wrap() {
389        let err = check_packed_rows(&[2], &[usize::MAX - 1], 1024)
390            .unwrap_err()
391            .to_string();
392        assert!(err.contains("overflows"), "{err}");
393    }
394}