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}