spark_model/layers/glm5next_dsa/binding.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3 DSA checkpoint binding: the 14 `self_attn` tensors of a DSA block,
4//! their expected dtype and shape, and a verifier that fails loudly.
5//!
6//! Scoped to `LibertAIDAI/GLM-5.3-Flash-NVFP4@9e0d74e3`. Mirrors
7//! [`crate::layers::glm5next_kda::binding`] and deliberately reuses its
8//! `RawTensor` / `TensorSource` / dtype types instead of growing a parallel set.
9//!
10//! Shapes are expressed against [`Glm5NextDsaConfig`], so a geometry change fails
11//! here rather than at kernel launch â the same contract the KDA binder holds.
12//!
13//! # ðŠĪ What this exists to catch
14//!
15//! * **`indexer.k_norm` has a `bias`.** It is an `nn.LayerNorm`, not an RMSNorm â
16//! the only other norm in GLM-5.3 with a bias. A binder that loads only
17//! `.weight` drops mean-subtraction *and* the bias, and nothing about the shapes
18//! says so. The spec table lists the bias as REQUIRED so its absence is an error,
19//! not a silent zero.
20//! * **`index_kpool_compress_ape` is `[kpool, index_head_dim]`** â indexed by pool
21//! SLOT, not by head and not by pool. Its 1 KB size makes a wrong-axis bind easy
22//! to miss.
23//! * **`q_b_proj` and `kv_b_proj` carry different per-head widths** â
24//! `qk_head_dim` (256) vs `nope + v_head_dim` (512). Both are `[heads * w, lora]`,
25//! so a swapped width still yields a well-formed 2-D tensor.
26//! * **NoPE**: there is no `wkv_a_rope` / `wq_b_rope` here at all, and
27//! `kv_a_proj_with_mqa` is `kv_lora_rank` wide (512), not `+ rope` (576).
28//! A spec that expects 576 rejects the real checkpoint.
29
30use std::collections::BTreeSet;
31
32use anyhow::{Result, bail};
33
34use super::Glm5NextDsaConfig;
35use crate::layers::glm5next_kda::binding::{KdaDtype as Dtype, KdaTensorSource as TensorSource};
36
37/// One expected tensor: layer-relative name, dtype, and full (unsharded) shape.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct DsaSpec {
40 pub name: String,
41 pub dtype: Dtype,
42 pub shape: Vec<usize>,
43}
44
45/// Every `self_attn` tensor a DSA block has â and the complete list of what it may
46/// have. `full_heads` is the pre-shard head count; the checkpoint is never sharded
47/// on disk, so binding always validates against the full geometry and slices after.
48pub fn dsa_tensor_specs(cfg: &Glm5NextDsaConfig, full_heads: usize) -> Vec<DsaSpec> {
49 let x = cfg.hidden;
50 let ql = cfg.q_lora_rank;
51 let kvl = cfg.kv_lora_rank;
52 let qk = cfg.qk_head_dim();
53 let kvb = cfg.qk_nope_head_dim + cfg.v_head_dim;
54 let ihd = cfg.index_head_dim;
55 let ih = cfg.index_heads;
56
57 let s = |name: &str, shape: Vec<usize>| DsaSpec {
58 name: name.to_string(),
59 dtype: Dtype::Bf16,
60 shape,
61 };
62
63 vec![
64 // ââ MLA ââ
65 s("self_attn.q_a_proj.weight", vec![ql, x]),
66 s("self_attn.q_a_layernorm.weight", vec![ql]),
67 s("self_attn.q_b_proj.weight", vec![full_heads * qk, ql]),
68 // ðŠĪ NoPE: kv_cache_dim == kv_lora_rank, no rope section.
69 s(
70 "self_attn.kv_a_proj_with_mqa.weight",
71 vec![cfg.kv_cache_dim(), x],
72 ),
73 s("self_attn.kv_a_layernorm.weight", vec![kvl]),
74 s("self_attn.kv_b_proj.weight", vec![full_heads * kvb, kvl]),
75 s(
76 "self_attn.o_proj.weight",
77 vec![x, full_heads * cfg.v_head_dim],
78 ),
79 // ââ indexer ââ
80 s("self_attn.indexer.wq_b.weight", vec![ih * ihd, ql]),
81 s("self_attn.indexer.wk.weight", vec![ihd, x]),
82 s("self_attn.indexer.k_norm.weight", vec![ihd]),
83 // ðŠĪ REQUIRED: LayerNorm bias. Its absence is an error, never a silent zero.
84 s("self_attn.indexer.k_norm.bias", vec![ihd]),
85 s("self_attn.indexer.weights_proj.weight", vec![ih, x]),
86 s("self_attn.indexer.index_kpool_compress_gate", vec![ihd, x]),
87 // ðŠĪ indexed by pool SLOT: [kpool, index_head_dim].
88 s(
89 "self_attn.indexer.index_kpool_compress_ape",
90 vec![cfg.index_kpool, ihd],
91 ),
92 ]
93}
94
95/// What a successful bind saw.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct DsaBindReport {
98 pub bound: usize,
99 pub total_bytes: usize,
100 /// `self_attn.*` names present in the source that no spec claims. Never empty-
101 /// tolerated: an unclaimed attention tensor means the architecture moved.
102 pub unclaimed: Vec<String>,
103}
104
105/// Verify one DSA block against the spec table.
106///
107/// Every spec must be present with the exact dtype and shape, and no `self_attn.*`
108/// tensor may be left unclaimed. Both directions matter: a missing tensor is a
109/// broken layer, and an unexpected one means the checkpoint is not the model we
110/// think it is.
111pub fn verify_dsa_block(
112 cfg: &Glm5NextDsaConfig,
113 full_heads: usize,
114 source: &dyn TensorSource,
115) -> Result<DsaBindReport> {
116 cfg.validate()?;
117 let specs = dsa_tensor_specs(cfg, full_heads);
118 let mut total_bytes = 0usize;
119
120 for spec in &specs {
121 let Some(raw) = source.get(&spec.name) else {
122 bail!("DSA bind: missing required tensor `{}`", spec.name);
123 };
124 if raw.dtype != spec.dtype {
125 bail!(
126 "DSA bind: `{}` is {:?}, expected {:?}",
127 spec.name,
128 raw.dtype,
129 spec.dtype
130 );
131 }
132 if raw.shape != spec.shape {
133 bail!(
134 "DSA bind: `{}` has shape {:?}, expected {:?}",
135 spec.name,
136 raw.shape,
137 spec.shape
138 );
139 }
140 let elems: usize = spec.shape.iter().product();
141 if raw.bytes.len() != elems * 2 {
142 bail!(
143 "DSA bind: `{}` carries {} bytes, expected {} for {:?} BF16",
144 spec.name,
145 raw.bytes.len(),
146 elems * 2,
147 spec.shape
148 );
149 }
150 total_bytes += raw.bytes.len();
151 }
152
153 let claimed: BTreeSet<&str> = specs.iter().map(|s| s.name.as_str()).collect();
154 let unclaimed: Vec<String> = source
155 .names()
156 .into_iter()
157 .filter(|n| n.starts_with("self_attn.") && !claimed.contains(n.as_str()))
158 .collect();
159 if !unclaimed.is_empty() {
160 bail!(
161 "DSA bind: {} unclaimed self_attn tensor(s), first: {:?}. An unexpected \
162 attention tensor means the architecture moved â do not skip it.",
163 unclaimed.len(),
164 &unclaimed[..unclaimed.len().min(5)]
165 );
166 }
167
168 Ok(DsaBindReport {
169 bound: specs.len(),
170 total_bytes,
171 unclaimed,
172 })
173}
174
175#[cfg(test)]
176mod tests;