spark_model/layers/vision_encoder.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Qwen3-VL vision encoder: 27-block ViT + DeepStack mergers.
4//!
5//! Processes patch embeddings (BF16) through a ViT backbone, extracts
6//! intermediate hidden states at deepstack indices [8, 16, 24, 27], applies
7//! 2×2 spatial merges + 2-layer MLPs, and concatenates the four outputs.
8//! Result: [num_patches, out_hidden_size=2048] BF16 ready for LLM embedding.
9
10use spark_runtime::gpu::{DevicePtr, KernelHandle};
11
12pub(super) const IMAGE_PAD_TOKEN: u32 = 151_655;
13pub const IMAGE_PAD_TOKEN_ID: u32 = IMAGE_PAD_TOKEN;
14
15/// Fallback `<|video_pad|>` id, used when the checkpoint's config declares
16/// none. Qwen3-VL's video token sits directly after its image token, and the
17/// same holds for Qwen3.6/3.8 (248056 / 248057) — but a checkpoint that
18/// declares its own always wins, exactly as for the image token.
19pub const VIDEO_PAD_TOKEN_ID: u32 = IMAGE_PAD_TOKEN + 1;
20
21/// The ViT's per-image scratch buffers, allocated as one group.
22///
23/// Sizes derive only from the encoder's geometry (`p_max` and the head/hidden
24/// dims), so nothing here depends on the image itself — which is why it can be
25/// built once, lazily, and reused for every image after.
26pub struct VisionScratch {
27 pub buf_f32: DevicePtr,
28 pub buf_h1: DevicePtr,
29 pub buf_h2: DevicePtr,
30 pub buf_wide: DevicePtr,
31 pub buf_merge_in: DevicePtr,
32 pub buf_merge_fc1: DevicePtr,
33 pub buf_out: DevicePtr,
34 pub buf_pos_resampled: DevicePtr,
35 pub buf_rope_cos: DevicePtr,
36 pub buf_rope_sin: DevicePtr,
37 pub buf_qr: DevicePtr,
38 pub buf_kr: DevicePtr,
39 pub buf_vt: DevicePtr,
40 pub buf_scores: DevicePtr,
41 pub buf_probs: DevicePtr,
42 pub buf_o_stage: DevicePtr,
43}
44
45/// Flattened per-patch pixel dimension `C × temporal_patch_size × patch_size²`
46/// = 3 × 2 × 16 × 16 for this ViT. It is baked into the encoder, not read from
47/// config: `buf_f32` is allocated at `p_max × PATCH_DIM × 4` and the
48/// patch-embed GEMM is issued with `K = PATCH_DIM`.
49///
50/// The host side computes the same quantity from `vision_config`
51/// (`vision_preprocess::preprocess_image`), so a checkpoint declaring a
52/// different `patch_size`/`temporal_patch_size` produces a pixel buffer of a
53/// DIFFERENT length. Every use of this constant that touches a host slice must
54/// therefore check the length rather than assume it — see `patch_embed`.
55pub(crate) const PATCH_DIM: usize = 1536;
56
57pub struct ViTBlock {
58 pub norm1_w: DevicePtr,
59 pub norm1_b: DevicePtr,
60 pub qkv_w: DevicePtr,
61 pub qkv_b: DevicePtr,
62 pub proj_w: DevicePtr,
63 pub proj_b: DevicePtr,
64 pub norm2_w: DevicePtr,
65 pub norm2_b: DevicePtr,
66 pub fc1_w: DevicePtr,
67 pub fc1_b: DevicePtr,
68 pub fc2_w: DevicePtr,
69 pub fc2_b: DevicePtr,
70}
71
72pub struct MergerLayer {
73 pub norm_w: DevicePtr,
74 pub norm_b: DevicePtr,
75 pub fc1_w: DevicePtr,
76 pub fc1_b: DevicePtr,
77 pub fc2_w: DevicePtr,
78 pub fc2_b: DevicePtr,
79}
80
81pub struct VisionEncoder {
82 pub patch_embed_w: DevicePtr, // [1152, 1536] BF16
83 pub patch_embed_b: DevicePtr, // [1152] BF16
84 pub pos_embed: DevicePtr, // [2304, 1152] BF16 (untouched, kept for reference)
85 pub blocks: Vec<ViTBlock>, // 27 blocks
86 pub deepstack: Vec<MergerLayer>, // 3 deepstack mergers
87 pub deepstack_indexes: Vec<usize>, // [8, 16, 24] (1-indexed, after Nth block)
88 pub merger: MergerLayer, // final merger (after block 27)
89 // kernel handles
90 k_gemm: KernelHandle, // vision_gemm_bias: C[M,N] = A[M,K]@B[N,K]^T + bias
91 k_gemm_pipelined: KernelHandle, // dense_gemm_bf16_pipelined (tensor-core, ~40×; no bias)
92 k_add_bias: KernelHandle, // vision_add_bias: C += bias[n] (fuses bias for the TC path)
93 k_norm: KernelHandle, // vision_layer_norm (biased, in-place)
94 k_add: KernelHandle, // vision_add_inplace
95 k_gelu: KernelHandle, // vision_gelu (in-place)
96 k_attn: KernelHandle, // vision_attention_rope (legacy SDPA — ATLAS_VISION_ATTN_LEGACY=1)
97 k_rope_deint: KernelHandle, // vit_rope_deinterleave (rope + head-contig Qr/Kr + V transpose)
98 k_softmax: KernelHandle, // vit_softmax_rows (parallel row softmax)
99 k_scatter_head: KernelHandle, // vit_scatter_head (contig → interleaved O slot)
100 k_gemm_f32: KernelHandle, // dense_gemm_bf16_f32out (raw QKᵀ scores, f32 out)
101 k_merge: KernelHandle, // vision_spatial_merge (2×2)
102 k_f32_bf16: KernelHandle, // vision_f32_to_bf16
103 k_copy: KernelHandle, // vision_bf16_copy
104 // config
105 pub hidden_size: usize, // 1152
106 pub num_heads: usize, // 16
107 pub head_dim: usize, // 72
108 pub spatial_merge_size: usize, // 2
109 pub out_hidden_size: usize, // 2048
110 pub intermediate_size: usize, // 4304
111 pub p_max: usize, // 6400 (80×80 patches for 1280×1280 image)
112 // num_grid_per_side = sqrt(num_position_embeddings) = 48 for Qwen3-VL/3.6.
113 pub num_grid_per_side: usize,
114 /// ViT scratch, allocated on the FIRST IMAGE rather than at load.
115 ///
116 /// ~2.2 GB at the 16384-patch rung on Qwen3.8-27B — the fourth-largest
117 /// consumer in the process — and a text-only serve never touches a byte of
118 /// it. Deferring hands that back to the KV budget on every text workload
119 /// while costing an image request one allocation it used to pay at boot.
120 ///
121 /// `OnceLock` rather than a flag: the encoder's forward path takes `&self`,
122 /// and the buffers must be filled exactly once even if two images race.
123 scratch: std::sync::OnceLock<VisionScratch>,
124 // host-side prep state
125 pos_embed_host_f32: Vec<f32>, // [num_position_embeddings × hidden_size] row-major
126 rope_inv_freq: Vec<f32>, // [head_dim / 4] frequencies
127}
128
129mod enc_impl;