spark_model/layers/vision_encoder/enc_impl/init.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `VisionEncoder::new` constructor.
4
5use anyhow::Result;
6use spark_runtime::gpu::{DevicePtr, GpuBackend};
7
8use super::super::{MergerLayer, PATCH_DIM, ViTBlock, VisionEncoder, VisionScratch};
9
10/// Encoder capacity, in patches, when nothing bounds the image.
11///
12/// 6400 = 80×80, i.e. 1280×1280 at patch 16 — the value this was hard-coded to
13/// until 2026-08-14, kept as the fallback so a checkpoint that declares no
14/// bound behaves exactly as before.
15pub const FALLBACK_MAX_PATCHES: usize = 6400;
16
17/// Ceiling on the derived capacity, in patches. 16384 = 128×128 = 2048×2048.
18///
19/// A ceiling exists because the ViT attention materialises a full `[seq, seq]`
20/// score matrix, so allocation is **O(patches²)**. Measured on GB10 at
21/// Qwen3.8's vision geometry:
22///
23/// | patches | image | encoder alloc | pre-KV | max KV tokens |
24/// |---------|--------|---------------|---------|---------------|
25/// | 6 400 | 1280² | 502 MB | 57.3 GB | 457 328 |
26/// | 16 384 | 2048² | 2 221 MB | 59.8 GB | 375 184 |
27/// | 65 536 | 4096² | 26 680 MB | — | — |
28///
29/// 16384 is the last rung that is affordable: it costs +2.5 GB and ~18% of KV
30/// capacity at util 0.70. Qwen3.8-27B *declares* 4096² (`size.longest_edge =
31/// 16777216`), which would need 26.7 GB of scratch — 69% of it in those two
32/// quadratic buffers — so honouring it needs tiled/flash attention in the ViT,
33/// not a bigger number here. Raising this without that work will OOM the box.
34pub const CEILING_MAX_PATCHES: usize = 16384;
35
36/// Patches the encoder must hold to serve an area bound, clamped to what is
37/// affordable.
38///
39/// `max_pixels` is an AREA, so patches = area / patch². Returns the clamp
40/// decision alongside the value so the caller can say out loud when a
41/// checkpoint asked for more than it got — silently ignoring the checkpoint is
42/// the failure mode this whole change exists to remove.
43pub fn derive_max_patches(max_pixels: Option<usize>, patch_size: usize) -> (usize, Option<usize>) {
44 let Some(area) = max_pixels.filter(|&a| a > 0) else {
45 return (FALLBACK_MAX_PATCHES, None);
46 };
47 let per_patch = patch_size.max(1) * patch_size.max(1);
48 let wanted = (area / per_patch).max(1);
49 if wanted > CEILING_MAX_PATCHES {
50 (CEILING_MAX_PATCHES, Some(wanted))
51 } else {
52 (wanted.max(FALLBACK_MAX_PATCHES.min(wanted)), None)
53 }
54}
55
56impl VisionEncoder {
57 #[allow(clippy::too_many_arguments)]
58 pub fn new(
59 patch_embed_w: DevicePtr,
60 patch_embed_b: DevicePtr,
61 pos_embed: DevicePtr,
62 num_position_embeddings: usize,
63 blocks: Vec<ViTBlock>,
64 deepstack: Vec<MergerLayer>,
65 deepstack_indexes: Vec<usize>,
66 merger: MergerLayer,
67 hidden_size: usize,
68 num_heads: usize,
69 spatial_merge_size: usize,
70 out_hidden_size: usize,
71 intermediate_size: usize,
72 patch_size: usize,
73 max_pixels: Option<usize>,
74 gpu: &dyn GpuBackend,
75 ) -> Result<Self> {
76 let head_dim = hidden_size / num_heads;
77 // Derived from the SAME resolved bound the CPU preprocessor uses, so
78 // the two can no longer disagree. See `derive_max_patches`.
79 let (p_max, asked_for) = derive_max_patches(max_pixels, patch_size);
80 match asked_for {
81 Some(wanted) => tracing::warn!(
82 "Vision encoder capacity {p_max} patches ({}x{} px) — the resolved area bound \
83 wanted {wanted} patches, clamped: the ViT score matrix is O(patches^2) and \
84 {wanted} would need ~{:.1} GB of scratch. Lower --vision-max-pixels to reclaim \
85 memory, or raise CEILING_MAX_PATCHES only alongside tiled ViT attention.",
86 (p_max as f64).sqrt() as usize * patch_size,
87 (p_max as f64).sqrt() as usize * patch_size,
88 ((wanted * wanted * 6) as f64) / (1024.0 * 1024.0 * 1024.0),
89 ),
90 None => tracing::info!(
91 "Vision encoder capacity {p_max} patches ({}x{} px){}",
92 (p_max as f64).sqrt() as usize * patch_size,
93 (p_max as f64).sqrt() as usize * patch_size,
94 if max_pixels.is_some() {
95 " from the resolved area bound"
96 } else {
97 " (no bound declared — historical fallback)"
98 }
99 ),
100 }
101
102 // num_grid_per_side is the side length of the square pos_embed grid
103 // (e.g. 48 for Qwen3-VL with 2304 position embeddings). Non-square
104 // layouts are not seen in the wild for this family.
105 let num_grid_per_side = (num_position_embeddings as f64).sqrt().round() as usize;
106 anyhow::ensure!(
107 num_grid_per_side * num_grid_per_side == num_position_embeddings,
108 "non-square pos_embed: {num_position_embeddings} is not a perfect square"
109 );
110
111 // Download pos_embed weight to host as f32 so we can bilinear-
112 // interpolate it per image (HF: `fast_pos_embed_interpolate`).
113 let pos_n = num_position_embeddings * hidden_size;
114 let mut pe_bytes = vec![0u8; pos_n * 2];
115 gpu.copy_d2h(pos_embed, &mut pe_bytes)?;
116 let pos_embed_host_f32: Vec<f32> = pe_bytes
117 .chunks_exact(2)
118 .map(|c| {
119 let bits = u16::from_le_bytes([c[0], c[1]]);
120 f32::from_bits((bits as u32) << 16)
121 })
122 .collect();
123
124 // RoPE inverse-frequency table. Qwen3-VL/3.6 vision RoPE uses
125 // `rotary_dim = head_dim / 2`, with `inv_freq[k] = theta^(-2k/dim)`
126 // for k in [0, dim/2). theta is fixed at 10000 for vision.
127 let rope_dim = head_dim / 2; // e.g. 36
128 let rope_half = rope_dim / 2; // e.g. 18
129 let theta: f32 = 10_000.0;
130 let rope_inv_freq: Vec<f32> = (0..rope_half)
131 .map(|k| 1.0 / theta.powf(2.0 * k as f32 / rope_dim as f32))
132 .collect();
133
134 Ok(Self {
135 patch_embed_w,
136 patch_embed_b,
137 pos_embed,
138 blocks,
139 deepstack,
140 deepstack_indexes,
141 merger,
142 k_gemm: gpu.kernel("vision_encoder", "vision_gemm_bias")?,
143 // Tensor-core pipelined matmul (~40× the scalar vision_gemm_bias on
144 // the ViT's large-M GEMMs) + a row-broadcast bias add. Both gated to
145 // 0 → fall back to vision_gemm_bias. The ViT GEMMs dominate image prefill.
146 k_gemm_pipelined: crate::layers::try_kernel(gpu, "gemm", "dense_gemm_bf16_pipelined"),
147 k_add_bias: crate::layers::try_kernel(gpu, "vision_encoder", "vision_add_bias"),
148 k_norm: gpu.kernel("vision_encoder", "vision_layer_norm")?,
149 k_add: gpu.kernel("vision_encoder", "vision_add_inplace")?,
150 k_gelu: gpu.kernel("vision_encoder", "vision_gelu")?,
151 // Legacy warp-per-query ViT attention — present in EVERY vision
152 // kernel tree, the universal fallback (hard-required).
153 k_attn: gpu.kernel("vision_encoder", "vision_attention_rope")?,
154 // GEMM-based ViT SDPA kernels (the ~2× image-TTFT path). SOFT: only
155 // the qwen3.6 / Holo vision tree ships them. Vision models on an
156 // older tree (qwen3-vl-30b, gemma-4) leave these null and
157 // `vit_block` auto-falls back to `k_attn` — see `vit_attention_gemm`
158 // gate. Hard-requiring them here would break every such model at
159 // init with `vit_rope_deinterleave: named symbol not found`.
160 k_rope_deint: crate::layers::try_kernel(gpu, "vision_encoder", "vit_rope_deinterleave"),
161 k_softmax: crate::layers::try_kernel(gpu, "vision_encoder", "vit_softmax_rows"),
162 k_scatter_head: crate::layers::try_kernel(gpu, "vision_encoder", "vit_scatter_head"),
163 // f32-out dense GEMM for raw QKᵀ scores (GEMM-ViT path only). SOFT,
164 // paired with the kernels above.
165 k_gemm_f32: crate::layers::try_kernel(gpu, "gemm", "dense_gemm_bf16_f32out"),
166 k_merge: gpu.kernel("vision_encoder", "vision_spatial_merge")?,
167 k_f32_bf16: gpu.kernel("vision_encoder", "vision_f32_to_bf16")?,
168 k_copy: gpu.kernel("vision_encoder", "vision_bf16_copy")?,
169 hidden_size,
170 num_heads,
171 head_dim,
172 spatial_merge_size,
173 out_hidden_size,
174 intermediate_size,
175 p_max,
176 num_grid_per_side,
177 scratch: std::sync::OnceLock::new(),
178 pos_embed_host_f32,
179 rope_inv_freq,
180 })
181 }
182}
183
184impl VisionEncoder {
185 /// Allocate the ViT scratch group. Called on the FIRST image, never at
186 /// load — see `VisionEncoder::scratch`.
187 fn build_scratch(&self, gpu: &dyn GpuBackend) -> Result<VisionScratch> {
188 let p_max = self.p_max;
189 let hidden_size = self.hidden_size;
190 let intermediate_size = self.intermediate_size;
191 let out_hidden_size = self.out_hidden_size;
192 // Same derivation `new` uses; the encoder stores the merge size, not
193 // the product.
194 let merger_in_dim = self.spatial_merge_size * self.spatial_merge_size * hidden_size;
195 let num_heads = self.num_heads;
196 let head_dim = self.head_dim;
197 let buf_f32 = gpu.alloc(p_max * PATCH_DIM * 4)?;
198 let buf_h1 = gpu.alloc(p_max * hidden_size * 2)?;
199 let buf_h2 = gpu.alloc(p_max * hidden_size * 2)?;
200 let buf_wide = gpu.alloc(p_max * intermediate_size * 2)?;
201 let buf_merge_in = gpu.alloc((p_max / 4) * merger_in_dim * 2)?;
202 let buf_merge_fc1 = gpu.alloc((p_max / 4) * merger_in_dim * 2)?;
203 let buf_out = gpu.alloc(p_max * out_hidden_size * 2)?;
204 let buf_pos_resampled = gpu.alloc(p_max * hidden_size * 2)?;
205 let buf_rope_cos = gpu.alloc(p_max * head_dim * 2)?;
206 let buf_rope_sin = gpu.alloc(p_max * head_dim * 2)?;
207
208 // GEMM-based ViT SDPA scratch. Q/K/V head-contiguous copies sized to
209 // p_max (~44 MB total). scores/probs are the [seq,seq] score matrix,
210 // reused across the 16-head loop.
211 //
212 // BUG FIX (2026-06-29): attn_max was hardcoded to 1024, but a single
213 // image's ViT sequence can be up to p_max (6400 patches = 1280×1280).
214 // The mona_lisa fixture produces seq=4096 → the GEMM1 launch
215 // grid=[ceil(4096/16),...] writes a [4096,4096] score matrix into a
216 // [1024,1024] buffer → CUDA-700 illegal access. (In release builds the
217 // debug_assert guard is compiled out, so smaller-than-fault overflows
218 // silently corrupted adjacent GPU memory instead of crashing — which is
219 // why it "passed" on some weight layouts.) Size to the real per-image
220 // cap p_max so any admissible image fits: 6400²·4 ≈ 164 MB scores +
221 // 6400²·2 ≈ 82 MB probs. One-time scratch, fine on GB10.
222 let attn_max = p_max;
223 let qkv_head_elems = p_max * num_heads * head_dim;
224 let buf_qr = gpu.alloc(qkv_head_elems * 2)?; // [H, p_max, D] bf16
225 let buf_kr = gpu.alloc(qkv_head_elems * 2)?; // [H, p_max, D] bf16
226 let buf_vt = gpu.alloc(qkv_head_elems * 2)?; // [H, D, p_max] bf16
227 let buf_scores = gpu.alloc(attn_max * attn_max * 4)?; // [seq, seq] f32
228 let buf_probs = gpu.alloc(attn_max * attn_max * 2)?; // [seq, seq] bf16
229 let buf_o_stage = gpu.alloc(p_max * head_dim * 2)?; // [seq, D] bf16
230 Ok(VisionScratch {
231 buf_f32,
232 buf_h1,
233 buf_h2,
234 buf_wide,
235 buf_merge_in,
236 buf_merge_fc1,
237 buf_out,
238 buf_pos_resampled,
239 buf_rope_cos,
240 buf_rope_sin,
241 buf_qr,
242 buf_kr,
243 buf_vt,
244 buf_scores,
245 buf_probs,
246 buf_o_stage,
247 })
248 }
249
250 /// The ViT scratch, allocating it on first use.
251 ///
252 /// Every image path reaches its buffers through here, so a serve that
253 /// never sees an image never pays the ~2.2 GB. The allocation is one-shot
254 /// and racing callers converge on a single group via `OnceLock`.
255 pub(crate) fn scratch_init(&self, gpu: &dyn GpuBackend) -> Result<()> {
256 if self.scratch.get().is_none() {
257 let s = self.build_scratch(gpu)?;
258 // A racing caller may have won; theirs is equally valid and the
259 // loser's buffers are dropped by the backend ledger at teardown.
260 let _ = self.scratch.set(s);
261 tracing::info!(
262 "Vision scratch allocated on first image: {} patches",
263 self.p_max
264 );
265 }
266 Ok(())
267 }
268
269 /// Scratch accessor for the encode path. Panics if the encode entry point
270 /// did not call `scratch_init` first — that is a wiring bug, not a runtime
271 /// condition, so it fails loudly rather than allocating behind a `&self`
272 /// that cannot report an error.
273 pub(crate) fn scratch(&self) -> &VisionScratch {
274 self.scratch
275 .get()
276 .expect("vision scratch: encode entry must call scratch_init(gpu) first")
277 }
278}
279
280#[cfg(test)]
281mod derive_tests {
282 use super::*;
283
284 /// Qwen3.8-27B: `size.longest_edge = 16777216` (4096²) at patch 16.
285 const Q38_BOUND: usize = 16_777_216;
286
287 #[test]
288 fn no_bound_keeps_the_historical_capacity() {
289 // A checkpoint shipping no preprocessor_config.json must allocate
290 // exactly what it always did — this change is not allowed to move
291 // memory for models that declare nothing.
292 assert_eq!(derive_max_patches(None, 16), (FALLBACK_MAX_PATCHES, None));
293 assert_eq!(
294 derive_max_patches(Some(0), 16),
295 (FALLBACK_MAX_PATCHES, None)
296 );
297 }
298
299 #[test]
300 fn a_declared_bound_over_the_ceiling_is_clamped_and_reported() {
301 // THE case that motivated the ceiling. Qwen3.8 asks for 65536 patches
302 // (26.7 GB of scratch, 69% of it in the O(p^2) score matrix); it gets
303 // the ceiling, and the amount it asked for comes back so the caller
304 // can say so out loud rather than silently ignoring the checkpoint.
305 let (got, asked) = derive_max_patches(Some(Q38_BOUND), 16);
306 assert_eq!(got, CEILING_MAX_PATCHES);
307 assert_eq!(
308 asked,
309 Some(65_536),
310 "the caller must be able to report the ask"
311 );
312 }
313
314 #[test]
315 fn a_low_operator_bound_shrinks_the_allocation() {
316 // The direction that did not exist before: --vision-max-pixels used to
317 // be a quality knob only, because p_max was a literal. A deployment
318 // serving thumbnails should not pay for 1280x1280 buffers.
319 let (got, asked) = derive_max_patches(Some(512 * 512), 16);
320 assert_eq!(asked, None, "under the ceiling, nothing was clamped");
321 assert_eq!(got, 1024, "512x512 at patch 16 is 32x32 = 1024 patches");
322 assert!(
323 got < FALLBACK_MAX_PATCHES,
324 "a low bound must allocate LESS than the historical default"
325 );
326 }
327
328 #[test]
329 fn capacity_tracks_patch_size() {
330 // patches = area / patch^2, so a finer grid needs MORE rows for the
331 // same pixel area. A checkpoint at patch 14 must not silently get a
332 // patch-16 allocation.
333 let (at16, _) = derive_max_patches(Some(1024 * 1024), 16);
334 let (at14, _) = derive_max_patches(Some(1024 * 1024), 14);
335 assert!(
336 at14 > at16,
337 "finer patches need more rows: {at14} vs {at16}"
338 );
339 }
340
341 #[test]
342 fn the_ceiling_matches_the_measured_affordable_rung() {
343 // 16384 patches = 2048x2048, measured on GB10 at +2.5 GB pre-KV and
344 // -18% KV tokens. Pinned so raising it is a deliberate act with a
345 // measurement behind it, not a passing edit.
346 assert_eq!(CEILING_MAX_PATCHES, 16_384);
347 let side = (CEILING_MAX_PATCHES as f64).sqrt() as usize * 16;
348 assert_eq!(side, 2048, "the ceiling should be a clean square image");
349 }
350
351 #[test]
352 fn degenerate_inputs_do_not_produce_a_zero_allocation() {
353 // A zero-row allocation would make every buffer empty and turn the
354 // first upload into the same opaque CUDA error this work removed.
355 assert_eq!(derive_max_patches(Some(1), 16), (1, None));
356 assert_eq!(derive_max_patches(Some(1024), 0), (1024, None));
357 }
358}