metrale_model_layers/layers/vision_encoder/enc_impl/
init.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: `VisionEncoder::new`, the patch capacity, and the lazily allocated scratch.
4//!
5//! Owner: model-layers (vision).
6//! Invariants:
7//! - `derive_max_patches` never returns 0 and never more than `CEILING_MAX_PATCHES`.
8
9use anyhow::Result;
10use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
11
12use super::super::{MergerLayer, PATCH_DIM, ViTBlock, VisionEncoder, VisionScratch};
13
14/// 2026-09-25: Encoder capacity, in patches, when no area bound is given.
15/// 6400 = 80×80, i.e. 1280×1280 at patch 16.
16pub const FALLBACK_MAX_PATCHES: usize = 6400;
17
18/// 2026-09-25: Ceiling on the derived capacity, in patches. 16384 = 128×128, i.e.
19/// 2048×2048 at patch 16.
20///
21/// The ViT attention scratch holds a full `[p_max, p_max]` f32 score matrix
22/// and a BF16 probability matrix (`build_scratch`), so the allocation grows
23/// with the square of the capacity. Qwen3.8-27B's preprocessor config declares
24/// `size.longest_edge = 16777216` (4096²), which would be 65536 patches.
25pub const CEILING_MAX_PATCHES: usize = 16384;
26
27/// 2026-09-25: Patches the encoder must hold to serve an area bound, clamped to
28/// `CEILING_MAX_PATCHES`.
29///
30/// `max_pixels` is an area, so patches = area / patch² (at least 1; a
31/// `patch_size` of 0 counts as 1). `None` or 0 gives `FALLBACK_MAX_PATCHES`.
32/// The second value is `Some(wanted)` when the ceiling clamped the result, so
33/// the caller can log it.
34pub fn derive_max_patches(max_pixels: Option<usize>, patch_size: usize) -> (usize, Option<usize>) {
35    let Some(area) = max_pixels.filter(|&a| a > 0) else {
36        return (FALLBACK_MAX_PATCHES, None);
37    };
38    let per_patch = patch_size.max(1) * patch_size.max(1);
39    let wanted = (area / per_patch).max(1);
40    if wanted > CEILING_MAX_PATCHES {
41        (CEILING_MAX_PATCHES, Some(wanted))
42    } else {
43        (wanted.max(FALLBACK_MAX_PATCHES.min(wanted)), None)
44    }
45}
46
47impl VisionEncoder {
48    #[allow(clippy::too_many_arguments)]
49    pub fn new(
50        patch_embed_w: DevicePtr,
51        patch_embed_b: DevicePtr,
52        pos_embed: DevicePtr,
53        num_position_embeddings: usize,
54        blocks: Vec<ViTBlock>,
55        deepstack: Vec<MergerLayer>,
56        deepstack_indexes: Vec<usize>,
57        merger: MergerLayer,
58        hidden_size: usize,
59        num_heads: usize,
60        spatial_merge_size: usize,
61        out_hidden_size: usize,
62        intermediate_size: usize,
63        patch_size: usize,
64        max_pixels: Option<usize>,
65        gpu: &dyn GpuBackend,
66    ) -> Result<Self> {
67        let head_dim = hidden_size / num_heads;
68        let (p_max, asked_for) = derive_max_patches(max_pixels, patch_size);
69        match asked_for {
70            Some(wanted) => tracing::warn!(
71                "Vision encoder capacity {p_max} patches ({}x{} px) — the resolved area bound \
72                 wanted {wanted} patches, clamped: the ViT score matrix is O(patches^2) and \
73                 {wanted} would need ~{:.1} GB of scratch. Lower --vision-max-pixels to reclaim \
74                 memory, or raise CEILING_MAX_PATCHES only alongside tiled ViT attention.",
75                (p_max as f64).sqrt() as usize * patch_size,
76                (p_max as f64).sqrt() as usize * patch_size,
77                ((wanted * wanted * 6) as f64) / (1024.0 * 1024.0 * 1024.0),
78            ),
79            None => tracing::info!(
80                "Vision encoder capacity {p_max} patches ({}x{} px){}",
81                (p_max as f64).sqrt() as usize * patch_size,
82                (p_max as f64).sqrt() as usize * patch_size,
83                if max_pixels.is_some() {
84                    " from the resolved area bound"
85                } else {
86                    " (no bound declared — historical fallback)"
87                }
88            ),
89        }
90
91        // 2026-09-25: The side of the square pos_embed grid; a non-square count is
92        // refused.
93        let num_grid_per_side = (num_position_embeddings as f64).sqrt().round() as usize;
94        anyhow::ensure!(
95            num_grid_per_side * num_grid_per_side == num_position_embeddings,
96            "non-square pos_embed: {num_position_embeddings} is not a perfect square"
97        );
98
99        // 2026-09-25: Keep a host f32 copy of pos_embed for the per-image bilinear
100        // interpolation in `resample_pos_embed_into`.
101        let pos_n = num_position_embeddings * hidden_size;
102        let mut pe_bytes = vec![0u8; pos_n * 2];
103        gpu.copy_d2h(pos_embed, &mut pe_bytes)?;
104        let pos_embed_host_f32: Vec<f32> = pe_bytes
105            .chunks_exact(2)
106            .map(|c| {
107                let bits = u16::from_le_bytes([c[0], c[1]]);
108                f32::from_bits((bits as u32) << 16)
109            })
110            .collect();
111
112        // 2026-09-25: Vision RoPE inverse frequencies: `rope_dim = head_dim / 2`,
113        // `inv_freq[k] = theta^(-2k/rope_dim)` for k in [0, rope_dim/2), with
114        // theta fixed at 10000.
115        let rope_dim = head_dim / 2;
116        let rope_half = rope_dim / 2;
117        let theta: f32 = 10_000.0;
118        let rope_inv_freq: Vec<f32> = (0..rope_half)
119            .map(|k| 1.0 / theta.powf(2.0 * k as f32 / rope_dim as f32))
120            .collect();
121
122        Ok(Self {
123            patch_embed_w,
124            patch_embed_b,
125            pos_embed,
126            blocks,
127            deepstack,
128            deepstack_indexes,
129            merger,
130            k_gemm: gpu.kernel("vision_encoder", "vision_gemm_bias")?,
131            // 2026-09-25: Tensor-core pipelined matmul + a row-broadcast bias add.
132            // Optional: when either is null, `vit_gemm_bias` uses `vision_gemm_bias`.
133            k_gemm_pipelined: crate::layers::try_kernel(gpu, "gemm", "dense_gemm_bf16_pipelined"),
134            k_add_bias: crate::layers::try_kernel(gpu, "vision_encoder", "vision_add_bias"),
135            k_norm: gpu.kernel("vision_encoder", "vision_layer_norm")?,
136            k_add: gpu.kernel("vision_encoder", "vision_add_inplace")?,
137            k_gelu: gpu.kernel("vision_encoder", "vision_gelu")?,
138            // 2026-09-25: Warp-per-query ViT attention, required: both
139            // `vision_encoder.cu` sources (qwen3-vl-30b-a3b, qwen3.6-35b-a3b)
140            // define it, and it is the fallback for the GEMM-based path.
141            k_attn: gpu.kernel("vision_encoder", "vision_attention_rope")?,
142            // 2026-09-25: GEMM-based ViT attention kernels, optional: only
143            // `qwen3.6-35b-a3b/nvfp4/vision_encoder.cu` defines them. Trees built
144            // from the qwen3-vl-30b-a3b source leave them null, and `vit_block`
145            // then uses `k_attn`.
146            k_rope_deint: crate::layers::try_kernel(gpu, "vision_encoder", "vit_rope_deinterleave"),
147            k_softmax: crate::layers::try_kernel(gpu, "vision_encoder", "vit_softmax_rows"),
148            k_scatter_head: crate::layers::try_kernel(gpu, "vision_encoder", "vit_scatter_head"),
149            // 2026-09-25: f32-out dense GEMM for the raw QKᵀ scores of the GEMM-based
150            // attention; optional, like the kernels above.
151            k_gemm_f32: crate::layers::try_kernel(gpu, "gemm", "dense_gemm_bf16_f32out"),
152            k_merge: gpu.kernel("vision_encoder", "vision_spatial_merge")?,
153            k_f32_bf16: gpu.kernel("vision_encoder", "vision_f32_to_bf16")?,
154            k_copy: gpu.kernel("vision_encoder", "vision_bf16_copy")?,
155            hidden_size,
156            num_heads,
157            head_dim,
158            spatial_merge_size,
159            out_hidden_size,
160            intermediate_size,
161            p_max,
162            num_grid_per_side,
163            scratch: std::sync::OnceLock::new(),
164            pos_embed_host_f32,
165            rope_inv_freq,
166        })
167    }
168}
169
170impl VisionEncoder {
171    /// 2026-09-25: Allocate the ViT scratch group. Only `scratch_init` calls it.
172    fn build_scratch(&self, gpu: &dyn GpuBackend) -> Result<VisionScratch> {
173        let p_max = self.p_max;
174        let hidden_size = self.hidden_size;
175        let intermediate_size = self.intermediate_size;
176        let out_hidden_size = self.out_hidden_size;
177        let merger_in_dim = self.spatial_merge_size * self.spatial_merge_size * hidden_size;
178        let num_heads = self.num_heads;
179        let head_dim = self.head_dim;
180        let buf_f32 = gpu.alloc(p_max * PATCH_DIM * 4)?;
181        let buf_h1 = gpu.alloc(p_max * hidden_size * 2)?;
182        let buf_h2 = gpu.alloc(p_max * hidden_size * 2)?;
183        let buf_wide = gpu.alloc(p_max * intermediate_size * 2)?;
184        let buf_merge_in = gpu.alloc((p_max / 4) * merger_in_dim * 2)?;
185        let buf_merge_fc1 = gpu.alloc((p_max / 4) * merger_in_dim * 2)?;
186        let buf_out = gpu.alloc(p_max * out_hidden_size * 2)?;
187        let buf_pos_resampled = gpu.alloc(p_max * hidden_size * 2)?;
188        let buf_rope_cos = gpu.alloc(p_max * head_dim * 2)?;
189        let buf_rope_sin = gpu.alloc(p_max * head_dim * 2)?;
190
191        // 2026-09-25: GEMM-based ViT attention scratch, sized to p_max so any image
192        // the encoder admits fits: head-contiguous Qr/Kr `[H, p_max, D]` and Vt
193        // `[H, D, p_max]` BF16, the `[p_max, p_max]` f32 scores and BF16 probs
194        // (reused by every head), and a `[p_max, D]` BF16 per-head output stage.
195        let attn_max = p_max;
196        let qkv_head_elems = p_max * num_heads * head_dim;
197        let buf_qr = gpu.alloc(qkv_head_elems * 2)?;
198        let buf_kr = gpu.alloc(qkv_head_elems * 2)?;
199        let buf_vt = gpu.alloc(qkv_head_elems * 2)?;
200        let buf_scores = gpu.alloc(attn_max * attn_max * 4)?;
201        let buf_probs = gpu.alloc(attn_max * attn_max * 2)?;
202        let buf_o_stage = gpu.alloc(p_max * head_dim * 2)?;
203        Ok(VisionScratch {
204            buf_f32,
205            buf_h1,
206            buf_h2,
207            buf_wide,
208            buf_merge_in,
209            buf_merge_fc1,
210            buf_out,
211            buf_pos_resampled,
212            buf_rope_cos,
213            buf_rope_sin,
214            buf_qr,
215            buf_kr,
216            buf_vt,
217            buf_scores,
218            buf_probs,
219            buf_o_stage,
220        })
221    }
222
223    /// 2026-09-25: Allocate the ViT scratch if it is not set yet.
224    ///
225    /// Racing callers may each build a group; `OnceLock::set` keeps one.
226    pub(crate) fn scratch_init(&self, gpu: &dyn GpuBackend) -> Result<()> {
227        if self.scratch.get().is_none() {
228            let s = self.build_scratch(gpu)?;
229            // 2026-09-25: If a racing caller set it first, this group's buffers are
230            // not freed here.
231            let _ = self.scratch.set(s);
232            tracing::info!(
233                "Vision scratch allocated on first image: {} patches",
234                self.p_max
235            );
236        }
237        Ok(())
238    }
239
240    /// 2026-09-25: Scratch accessor for the encode path. Panics if `scratch_init` has
241    /// not run: that is a wiring bug, and this accessor cannot return an error.
242    pub(crate) fn scratch(&self) -> &VisionScratch {
243        self.scratch
244            .get()
245            .expect("vision scratch: encode entry must call scratch_init(gpu) first")
246    }
247}
248
249#[cfg(test)]
250mod derive_tests {
251    use super::*;
252
253    /// 2026-09-25: Qwen3.8-27B's `size.longest_edge` (4096²), at patch 16.
254    const Q38_BOUND: usize = 16_777_216;
255
256    #[test]
257    fn no_bound_keeps_the_historical_capacity() {
258        // 2026-09-25: No bound, or a zero bound, gives the fallback capacity.
259        assert_eq!(derive_max_patches(None, 16), (FALLBACK_MAX_PATCHES, None));
260        assert_eq!(
261            derive_max_patches(Some(0), 16),
262            (FALLBACK_MAX_PATCHES, None)
263        );
264    }
265
266    #[test]
267    fn a_declared_bound_over_the_ceiling_is_clamped_and_reported() {
268        // 2026-09-25: Qwen3.8-27B's bound asks for 65536 patches; it gets the
269        // ceiling, and the amount it asked for comes back.
270        let (got, asked) = derive_max_patches(Some(Q38_BOUND), 16);
271        assert_eq!(got, CEILING_MAX_PATCHES);
272        assert_eq!(
273            asked,
274            Some(65_536),
275            "the caller must be able to report the ask"
276        );
277    }
278
279    #[test]
280    fn a_low_operator_bound_shrinks_the_allocation() {
281        // 2026-09-25: A bound below the fallback allocates less than the fallback.
282        let (got, asked) = derive_max_patches(Some(512 * 512), 16);
283        assert_eq!(asked, None, "under the ceiling, nothing was clamped");
284        assert_eq!(got, 1024, "512x512 at patch 16 is 32x32 = 1024 patches");
285        assert!(
286            got < FALLBACK_MAX_PATCHES,
287            "a low bound must allocate LESS than the historical default"
288        );
289    }
290
291    #[test]
292    fn capacity_tracks_patch_size() {
293        // 2026-09-25: patches = area / patch^2, so a finer grid needs more rows for
294        // the same pixel area.
295        let (at16, _) = derive_max_patches(Some(1024 * 1024), 16);
296        let (at14, _) = derive_max_patches(Some(1024 * 1024), 14);
297        assert!(
298            at14 > at16,
299            "finer patches need more rows: {at14} vs {at16}"
300        );
301    }
302
303    #[test]
304    fn the_ceiling_matches_the_measured_affordable_rung() {
305        // 2026-09-25: Pins the ceiling at 16384 patches, a 2048x2048 image at patch 16.
306        assert_eq!(CEILING_MAX_PATCHES, 16_384);
307        let side = (CEILING_MAX_PATCHES as f64).sqrt() as usize * 16;
308        assert_eq!(side, 2048, "the ceiling should be a clean square image");
309    }
310
311    #[test]
312    fn degenerate_inputs_do_not_produce_a_zero_allocation() {
313        // 2026-09-25: The capacity is never 0, even for a 1-pixel area or patch size 0.
314        assert_eq!(derive_max_patches(Some(1), 16), (1, None));
315        assert_eq!(derive_max_patches(Some(1024), 0), (1024, None));
316    }
317}