spark_model/weight_loader/
glm5_next_vision.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Bind GLM-5.3-Flash's vision tower: 347 tensors under `model.visual.*`.
4//!
5//! All 347 are BF16 even in `nvidia/GLM-5.3-Flash-NVFP4` — only the text/MoE
6//! stack is quantised — so this is a pure pointer bind with no dequant path
7//! and no copy. Until this file existed the same 1.05 GiB was uploaded by the
8//! checkpoint loader and then freed again by `factory::build`, because that
9//! reclaim is keyed off "did any loader bind a tower", and none did.
10//!
11//! Layout notes that matter to the shapes below:
12//! * `patch_embed.proj.weight` is `(1024, 3, 2, 14, 14)` on disk. The Conv3d's
13//!   kernel equals its input block, so the convolution degenerates to one dot
14//!   product per patch and the tensor is read as a `[1024, 1176]` GEMM weight
15//!   over the SAME bytes — `(in_channel, temporal, py, px)`, which is exactly
16//!   the order the host preprocessor lays a patch out in.
17//! * `downsample.weight` is `(4096, 1024, 2, 2)`, read as `[4096, 4096]` in
18//!   `(in_channel, kh, kw)` order against the im2col the merger builds.
19
20use anyhow::{Context, Result};
21use avarok_core::config::{ModelConfig, VisionConfig};
22use spark_runtime::gpu::{DevicePtr, GpuBackend};
23use spark_runtime::weights::{WeightDtype, WeightStore};
24
25use crate::layers::glm_vit::init::GlmVitGeometry;
26use crate::layers::{GlmVit, GlmVitBlock, GlmVitMerger, VisionTower};
27
28/// Bind one BF16 tensor by name, refusing any other dtype rather than
29/// reinterpreting its bytes.
30///
31/// A silent dtype pun here is the failure this guard exists for: an FP8 or
32/// U8-packed tensor read as BF16 walks twice its allocation and the first
33/// symptom is `CUDA_ERROR_ILLEGAL_ADDRESS` from somewhere else entirely.
34fn bf16(store: &WeightStore, name: &str) -> Result<DevicePtr> {
35    let w = store
36        .get(name)
37        .with_context(|| format!("glm5_next vision: missing tensor {name}"))?;
38    anyhow::ensure!(
39        w.dtype == WeightDtype::BF16,
40        "glm5_next vision: {name} is {:?}, but the GLM tower is BF16 throughout (347/347 \
41         tensors in the official NVFP4 export). A quantised tower needs a dequant path, not \
42         a cast of this pointer.",
43        w.dtype
44    );
45    Ok(w.ptr)
46}
47
48/// Check a bound tensor's on-disk shape against what the encoder will read.
49///
50/// Shapes are compared on the PRODUCT of the trailing dims, because the two
51/// tensors that matter here are stored with more axes than they are read with
52/// (the 5-D patch-embed conv and the 4-D downsample conv). Comparing the flat
53/// widths is the claim actually being made — "these bytes are a `[rows, cols]`
54/// GEMM weight" — and it catches a checkpoint whose patch or merge geometry
55/// moved, which is the case a name lookup cannot.
56fn check_shape(store: &WeightStore, name: &str, rows: usize, cols: usize) -> Result<()> {
57    let shape = store.get(name)?.shape.clone();
58    let got_rows = shape.first().copied().unwrap_or(0);
59    let got_cols: usize = shape.iter().skip(1).product::<usize>().max(1);
60    anyhow::ensure!(
61        got_rows == rows && (shape.len() == 1 || got_cols == cols),
62        "glm5_next vision: {name} has shape {shape:?}, which flattens to \
63         [{got_rows}, {got_cols}] — the encoder was built for [{rows}, {cols}] from \
64         vision_config. The checkpoint's tower geometry does not match its config."
65    );
66    Ok(())
67}
68
69fn load_block(store: &WeightStore, vp: &str, i: usize, v: &VisionConfig) -> Result<GlmVitBlock> {
70    let bp = format!("{vp}.blocks.{i}");
71    let h = v.hidden_size;
72    let head_dim = h / v.num_heads.max(1);
73    check_shape(store, &format!("{bp}.attn.qkv.weight"), 3 * h, h)?;
74    check_shape(store, &format!("{bp}.attn.q_norm.weight"), head_dim, 1)?;
75    check_shape(
76        store,
77        &format!("{bp}.mlp.gate_proj.weight"),
78        v.intermediate_size,
79        h,
80    )?;
81    Ok(GlmVitBlock {
82        norm1_w: bf16(store, &format!("{bp}.norm1.weight"))?,
83        qkv_w: bf16(store, &format!("{bp}.attn.qkv.weight"))?,
84        qkv_b: bf16(store, &format!("{bp}.attn.qkv.bias"))?,
85        q_norm_w: bf16(store, &format!("{bp}.attn.q_norm.weight"))?,
86        k_norm_w: bf16(store, &format!("{bp}.attn.k_norm.weight"))?,
87        proj_w: bf16(store, &format!("{bp}.attn.proj.weight"))?,
88        proj_b: bf16(store, &format!("{bp}.attn.proj.bias"))?,
89        norm2_w: bf16(store, &format!("{bp}.norm2.weight"))?,
90        gate_w: bf16(store, &format!("{bp}.mlp.gate_proj.weight"))?,
91        gate_b: bf16(store, &format!("{bp}.mlp.gate_proj.bias"))?,
92        up_w: bf16(store, &format!("{bp}.mlp.up_proj.weight"))?,
93        up_b: bf16(store, &format!("{bp}.mlp.up_proj.bias"))?,
94        down_w: bf16(store, &format!("{bp}.mlp.down_proj.weight"))?,
95        down_b: bf16(store, &format!("{bp}.mlp.down_proj.bias"))?,
96    })
97}
98
99fn load_merger(store: &WeightStore, vp: &str, v: &VisionConfig) -> Result<GlmVitMerger> {
100    let out = v.out_hidden_size;
101    let sms2 = v.spatial_merge_size * v.spatial_merge_size;
102    check_shape(
103        store,
104        &format!("{vp}.downsample.weight"),
105        out,
106        v.hidden_size * sms2,
107    )?;
108    check_shape(store, &format!("{vp}.merger.proj.weight"), out, out)?;
109    check_shape(
110        store,
111        &format!("{vp}.merger.gate_proj.weight"),
112        v.projection_intermediate_size,
113        out,
114    )?;
115    check_shape(
116        store,
117        &format!("{vp}.merger.down_proj.weight"),
118        out,
119        v.projection_intermediate_size,
120    )?;
121    Ok(GlmVitMerger {
122        post_layernorm_w: bf16(store, &format!("{vp}.post_layernorm.weight"))?,
123        downsample_w: bf16(store, &format!("{vp}.downsample.weight"))?,
124        downsample_b: bf16(store, &format!("{vp}.downsample.bias"))?,
125        proj_w: bf16(store, &format!("{vp}.merger.proj.weight"))?,
126        norm_w: bf16(store, &format!("{vp}.merger.post_projection_norm.weight"))?,
127        // The presence of this bias is what distinguishes the merger's
128        // LayerNorm from every RMSNorm in the tower. If a future checkpoint
129        // drops it, the load must fail here rather than the encoder quietly
130        // running an RMSNorm in its place.
131        norm_b: bf16(store, &format!("{vp}.merger.post_projection_norm.bias"))?,
132        gate_w: bf16(store, &format!("{vp}.merger.gate_proj.weight"))?,
133        up_w: bf16(store, &format!("{vp}.merger.up_proj.weight"))?,
134        down_w: bf16(store, &format!("{vp}.merger.down_proj.weight"))?,
135    })
136}
137
138/// `Glm5NextWeightLoader::load_vision_encoder`.
139///
140/// `Ok(None)` for a text-only checkpoint (`glm5_next_text`, whose config has no
141/// `vision_config`) and for a multimodal one whose tower is absent from the
142/// shards — the second case warns, because a declared-but-missing tower means
143/// image requests will be rejected later and the operator should hear it at
144/// load rather than at the first request.
145pub fn load_glm5_next_vision(
146    store: &WeightStore,
147    config: &ModelConfig,
148    gpu: &dyn GpuBackend,
149) -> Result<Option<VisionTower>> {
150    let Some(v) = config.vision.clone() else {
151        return Ok(None);
152    };
153    // Same two-prefix probe every other VL loader does: a re-quant produced via
154    // `AutoModelForImageTextToText` keeps the nested `model.language_model.visual.*`
155    // layout instead of the flat one.
156    let vp = ["model.visual", "model.language_model.visual", "visual"]
157        .into_iter()
158        .find(|p| store.contains(&format!("{p}.patch_embed.proj.weight")));
159    let Some(vp) = vp else {
160        tracing::warn!(
161            "glm5_next declares a vision_config but no `*.visual.patch_embed.proj.weight` is \
162             present in the checkpoint; serving TEXT-ONLY (image input will be refused)."
163        );
164        return Ok(None);
165    };
166
167    let patch_dim = 3 * v.temporal_patch_size * v.patch_size * v.patch_size;
168    check_shape(
169        store,
170        &format!("{vp}.patch_embed.proj.weight"),
171        v.hidden_size,
172        patch_dim,
173    )?;
174    let patch_embed_w = bf16(store, &format!("{vp}.patch_embed.proj.weight"))?;
175    let patch_embed_b = bf16(store, &format!("{vp}.patch_embed.proj.bias"))?;
176
177    let mut blocks = Vec::with_capacity(v.depth);
178    for i in 0..v.depth {
179        blocks.push(load_block(store, vp, i, &v)?);
180    }
181    let merger = load_merger(store, vp, &v)?;
182
183    let geo = GlmVitGeometry {
184        hidden_size: v.hidden_size,
185        num_heads: v.num_heads,
186        intermediate_size: v.intermediate_size,
187        spatial_merge_size: v.spatial_merge_size,
188        out_hidden_size: v.out_hidden_size,
189        projection_intermediate_size: v.projection_intermediate_size,
190        patch_size: v.patch_size,
191        temporal_patch_size: v.temporal_patch_size,
192        rms_norm_eps: v.rms_norm_eps,
193        swiglu_limit: v.swiglu_limit,
194        rope_theta: v.rope_theta,
195        max_pixels: v.max_pixels,
196    };
197    let encoder = GlmVit::new(patch_embed_w, patch_embed_b, blocks, merger, &geo, gpu)?;
198    tracing::info!(
199        "GLM-5.3 vision tower bound: depth={} hidden={} heads={} patch={} merge={} → {} \
200         (BF16, {} tensors)",
201        v.depth,
202        v.hidden_size,
203        v.num_heads,
204        v.patch_size,
205        v.spatial_merge_size,
206        v.out_hidden_size,
207        v.depth * 14 + 11,
208    );
209    Ok(Some(VisionTower::glm(encoder)))
210}
211
212#[cfg(test)]
213mod tests {
214    /// The census, as arithmetic: 24 blocks × 14 tensors + 11 non-block
215    /// tensors = 347, the number the shard headers report for
216    /// `model.visual.*`. A block pattern added or dropped moves this.
217    #[test]
218    fn the_bound_tensor_count_is_the_checkpoints_347() {
219        let per_block = 14; // norm1, norm2, qkv w+b, proj w+b, q_norm, k_norm, 3×mlp w+b
220        let non_block = 11; // patch_embed w+b, downsample w+b, post_layernorm,
221        // merger proj/gate/up/down + post_projection_norm w+b
222        assert_eq!(24 * per_block + non_block, 347);
223    }
224}