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}