spark_model/layers/glm5next_skeleton.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3-Flash **45-layer text-model skeleton** — Slice 9.
4//!
5//! The topology, the residual/norm/mHC wiring, the structural weight contract and the state
6//! plumbing of the text stack, in one place that can be checked against the checkpoint without
7//! a GPU and without executing anything.
8//!
9//! Scope is deliberate: **no MoE, no dense FFN, no MTP speculation, no forward pass.** The MLP
10//! site is represented as a hole in the residual plan (`ResidualStep::Mlp`) with its kind
11//! recorded, so the shape of what is missing is explicit rather than implied.
12//!
13//! # Why a skeleton is its own artifact
14//!
15//! "Every attention block executes" and "the model is assembled correctly" are different
16//! statements. Slices 1–8 proved the first for all 34 KDA and all 12 DSA blocks. A stack that
17//! binds every tensor and still orders its layers wrong, or hangs the hyper-connection off the
18//! wrong site, produces perfectly plausible output — the failure mode this campaign keeps
19//! paying for. So the ordering, the wiring and the binding are asserted as data.
20//!
21//! # Measured facts this module encodes (checkpoint `LibertAIDAI/GLM-5.3-Flash-NVFP4@9e0d74e3`)
22//!
23//! * The structural (non-MLP) surface is **1,047 tensors** in exactly **3 signatures**:
24//! 34 KDA layers × 23, 11 DSA layers × 22, layer 45 × 20, plus 3 non-layer tensors.
25//! * 🪤 **Layer 45 has NO hyper-connection.** All 270 `hc_*` tensors live on layers 0..=44.
26//! A skeleton that gives the MTP layer an `attn_hc`/`ffn_hc` looks for six tensors that do
27//! not exist.
28//! * 🪤 **The final collapse is an UNWEIGHTED MEAN.** `Glm5NextTextHyperHead` has no
29//! parameters and the checkpoint carries **zero** `hc_head` tensors — unlike DeepSeek-V4,
30//! whose `hc_head` is a learned sigmoid-weighted sum. Atlas's `hc_head` CUDA kernel is the
31//! DeepSeek one; for GLM it is **ADAPT, not REUSE**.
32//! * 🪤 **`hc_*_fn` is BF16 on disk**; only `base`/`scale` are F32. The `hc_pre` kernel takes
33//! `f32*`, so binding must upcast.
34//! * Layers **0..=2** carry a dense MLP (`first_k_dense_replace = 3`); 42 text layers and the
35//! MTP layer route to experts.
36
37use std::collections::{BTreeMap, BTreeSet};
38
39use anyhow::{Result, bail};
40use atlas_core::config::{LayerType, ModelConfig};
41
42/// Which mixer a layer runs. Narrower than [`LayerType`] on purpose: the skeleton refuses the
43/// kinds GLM-5.3 does not have rather than carrying them as unreachable arms.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Mixer {
46 /// Kimi Delta Attention — recurrent, carries state, no KV cache.
47 Kda,
48 /// NoPE sparse MLA behind a kpool top-k indexer — attends a KV cache.
49 Dsa,
50}
51
52/// Which MLP a layer runs. Not executed by this slice; recorded so the hole is named.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Mlp {
55 Dense,
56 RoutedMoe,
57}
58
59/// One decoder layer's structure.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct SkeletonLayer {
62 pub index: usize,
63 pub mixer: Mixer,
64 pub mlp: Mlp,
65 /// False only for the MTP layer — see the module trap note.
66 pub hyper_connection: bool,
67 /// True for layer 45. It is NOT part of the text stack.
68 pub is_mtp: bool,
69}
70
71/// One step of a layer's residual path, in execution order.
72///
73/// This is the wiring, as data. HF's `Glm5NextTextDecoderLayer::forward` runs, per site:
74/// `hc_pre` → norm → sublayer → `hc_post`, twice; the residual streams entering `hc_pre` are the
75/// ones `hc_post` mixes through `comb`.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ResidualStep {
78 /// Snapshot the `hc_mult` streams as the residual for this site's `hc_post`.
79 SaveResidual,
80 /// `hc_pre`: collapse the streams to one sequence, emit `post`/`comb`.
81 HcPre(Site),
82 /// RMSNorm on the collapsed sequence.
83 Norm(&'static str),
84 /// The attention mixer.
85 Mixer,
86 /// The MLP site. **Not implemented by this slice.**
87 Mlp,
88 /// `hc_post`: `out[j] = post[j]*block_out + Σ_i comb[i][j]*residual[i]`.
89 HcPost(Site),
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum Site {
94 Attn,
95 Ffn,
96}
97
98/// What the model does once the 45 text layers are done.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum FinalStep {
101 /// 🪤 UNWEIGHTED mean over the `hc_mult` streams. Not DeepSeek-V4's learned collapse.
102 HyperHeadMean,
103 Norm(&'static str),
104 LmHead,
105}
106
107/// Which cache a layer needs. KDA and DSA are mutually exclusive here, and admission needs
108/// BOTH kinds satisfied — a KDA slot is not interchangeable with KV blocks.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum StateKind {
111 /// Recurrent `[H/ep, 128, 128]` fp32 state + a bf16 causal-conv window.
112 KdaRecurrent,
113 /// Paged KV blocks + the indexer's own key state.
114 SparseKv,
115}
116
117#[derive(Debug, Clone)]
118pub struct Glm5NextTextSkeleton {
119 pub hidden_size: usize,
120 pub hc_mult: usize,
121 /// Text stack, indices 0..=44. Length is `num_hidden_layers` and never includes MTP.
122 pub layers: Vec<SkeletonLayer>,
123 /// Layer 45. Held separately so every "iterate the text stack" loop keeps meaning what it
124 /// says — the same discipline `ModelConfig::mtp_layer_types` established in Slice 8.
125 pub mtp: Option<SkeletonLayer>,
126}
127
128const NON_LAYER_TENSORS: [&str; 3] = [
129 "model.language_model.embed_tokens.weight",
130 "model.language_model.norm.weight",
131 "lm_head.weight",
132];
133
134/// The 15 `self_attn` tensors of a KDA block (Slice 7: one signature across all 34).
135const KDA_ATTN: [&str; 15] = [
136 "self_attn.q_proj.weight",
137 "self_attn.k_proj.weight",
138 "self_attn.v_proj.weight",
139 "self_attn.b_proj.weight",
140 "self_attn.f_a_proj.weight",
141 "self_attn.f_b_proj.weight",
142 "self_attn.g_a_proj.weight",
143 "self_attn.g_b_proj.weight",
144 "self_attn.q_conv1d.weight",
145 "self_attn.k_conv1d.weight",
146 "self_attn.v_conv1d.weight",
147 "self_attn.A_log",
148 "self_attn.dt_bias",
149 "self_attn.o_norm.weight",
150 "self_attn.o_proj.weight",
151];
152
153/// The 14 `self_attn` tensors of a DSA block (Slice 8: one signature across all 12,
154/// layer 45 included).
155///
156/// 🪤 `indexer.k_norm` is an `nn.LayerNorm` and carries a **bias**. Every other norm in this
157/// model is a bias-free RMSNorm; a binder that takes only `.weight` drops it silently.
158const DSA_ATTN: [&str; 14] = [
159 "self_attn.q_a_proj.weight",
160 "self_attn.q_a_layernorm.weight",
161 "self_attn.q_b_proj.weight",
162 "self_attn.kv_a_proj_with_mqa.weight",
163 "self_attn.kv_a_layernorm.weight",
164 "self_attn.kv_b_proj.weight",
165 "self_attn.o_proj.weight",
166 "self_attn.indexer.wq_b.weight",
167 "self_attn.indexer.wk.weight",
168 "self_attn.indexer.k_norm.weight",
169 "self_attn.indexer.k_norm.bias",
170 "self_attn.indexer.weights_proj.weight",
171 "self_attn.indexer.index_kpool_compress_ape",
172 "self_attn.indexer.index_kpool_compress_gate",
173];
174
175const LAYER_NORMS: [&str; 2] = ["input_layernorm.weight", "post_attention_layernorm.weight"];
176
177const HC_PARAMS: [&str; 6] = [
178 "hc_attn_fn",
179 "hc_attn_base",
180 "hc_attn_scale",
181 "hc_ffn_fn",
182 "hc_ffn_base",
183 "hc_ffn_scale",
184];
185
186/// MTP-head tensors. A head, not attention — layer 45's mixer is a plain DSA block, so MTP
187/// needs no attention implementation of its own.
188const MTP_HEAD: [&str; 4] = [
189 "eh_proj.weight",
190 "enorm.weight",
191 "hnorm.weight",
192 "shared_head.norm.weight",
193];
194
195fn qualify(layer: usize, leaf: &str) -> String {
196 format!("model.language_model.layers.{layer}.{leaf}")
197}
198
199impl Glm5NextTextSkeleton {
200 /// Derive the topology from a parsed config. Every kind is read, never defaulted: an
201 /// unexpected `LayerType` is a hard error, because a sparse layer silently bound as dense
202 /// attends the whole cache and produces plausible output.
203 pub fn from_config(cfg: &ModelConfig) -> Result<Self> {
204 if cfg.model_type != "glm5_next" {
205 bail!(
206 "Glm5NextTextSkeleton built from a {:?} config",
207 cfg.model_type
208 );
209 }
210 if cfg.layer_types.len() != cfg.num_hidden_layers {
211 bail!(
212 "layer_types has {} entries, num_hidden_layers is {}",
213 cfg.layer_types.len(),
214 cfg.num_hidden_layers
215 );
216 }
217 if cfg.hc_mult == 0 {
218 bail!("glm5_next skeleton needs hc_mult > 0; got 0 (mHC is not optional here)");
219 }
220 let dense: BTreeSet<usize> = cfg.mlp_only_layers.iter().copied().collect();
221
222 let mixer_of = |t: LayerType, i: usize| -> Result<Mixer> {
223 Ok(match t {
224 LayerType::LinearAttention => Mixer::Kda,
225 LayerType::SparseAttention => Mixer::Dsa,
226 other => {
227 bail!("layer {i}: GLM-5.3-Flash has no {other:?} layers; refusing to bind one")
228 }
229 })
230 };
231
232 let mut layers = Vec::with_capacity(cfg.num_hidden_layers);
233 for (i, t) in cfg.layer_types.iter().enumerate() {
234 layers.push(SkeletonLayer {
235 index: i,
236 mixer: mixer_of(*t, i)?,
237 mlp: if dense.contains(&i) {
238 Mlp::Dense
239 } else {
240 Mlp::RoutedMoe
241 },
242 hyper_connection: true,
243 is_mtp: false,
244 });
245 }
246
247 // MTP sits PAST the text stack and is reached through `layer_type_at`, never appended.
248 let mtp = match cfg.mtp_layer_types.len() {
249 0 => None,
250 1 => {
251 let i = cfg.num_hidden_layers;
252 Some(SkeletonLayer {
253 index: i,
254 mixer: mixer_of(cfg.mtp_layer_types[0], i)?,
255 mlp: Mlp::RoutedMoe,
256 // 🪤 Measured: zero `hc_*` tensors on layer 45.
257 hyper_connection: false,
258 is_mtp: true,
259 })
260 }
261 n => bail!("glm5_next skeleton expects 0 or 1 MTP layers, config declares {n}"),
262 };
263
264 Ok(Self {
265 hidden_size: cfg.hidden_size,
266 hc_mult: cfg.hc_mult,
267 layers,
268 mtp,
269 })
270 }
271
272 /// Text stack plus the MTP layer, in checkpoint index order.
273 pub fn all_layers(&self) -> Vec<SkeletonLayer> {
274 let mut v = self.layers.clone();
275 v.extend(self.mtp);
276 v
277 }
278
279 /// The structural (non-MLP, non-MoE) tensors this layer needs, fully qualified.
280 pub fn structural_tensors(&self, l: &SkeletonLayer) -> Vec<String> {
281 let mut v: Vec<String> = match l.mixer {
282 Mixer::Kda => KDA_ATTN.iter().map(|t| qualify(l.index, t)).collect(),
283 Mixer::Dsa => DSA_ATTN.iter().map(|t| qualify(l.index, t)).collect(),
284 };
285 v.extend(LAYER_NORMS.iter().map(|t| qualify(l.index, t)));
286 if l.hyper_connection {
287 v.extend(HC_PARAMS.iter().map(|t| qualify(l.index, t)));
288 }
289 if l.is_mtp {
290 v.extend(MTP_HEAD.iter().map(|t| qualify(l.index, t)));
291 }
292 v
293 }
294
295 /// Every structural tensor the whole skeleton needs, including the three non-layer ones.
296 pub fn structural_tensor_set(&self) -> BTreeSet<String> {
297 let mut s: BTreeSet<String> = NON_LAYER_TENSORS.iter().map(|t| t.to_string()).collect();
298 for l in self.all_layers() {
299 s.extend(self.structural_tensors(&l));
300 }
301 s
302 }
303
304 /// The residual/norm/mHC wiring for one layer, in execution order.
305 ///
306 /// The MTP layer has no hyper-connection, so its residual path is the ordinary
307 /// `x = x + sublayer(norm(x))` shape and the mHC steps drop out entirely.
308 pub fn residual_plan(&self, l: &SkeletonLayer) -> Vec<ResidualStep> {
309 let mut p = Vec::new();
310 for (site, norm, sub) in [
311 (Site::Attn, LAYER_NORMS[0], ResidualStep::Mixer),
312 (Site::Ffn, LAYER_NORMS[1], ResidualStep::Mlp),
313 ] {
314 if l.hyper_connection {
315 p.push(ResidualStep::SaveResidual);
316 p.push(ResidualStep::HcPre(site));
317 p.push(ResidualStep::Norm(norm));
318 p.push(sub);
319 p.push(ResidualStep::HcPost(site));
320 } else {
321 p.push(ResidualStep::SaveResidual);
322 p.push(ResidualStep::Norm(norm));
323 p.push(sub);
324 }
325 }
326 p
327 }
328
329 /// What runs after the last text layer.
330 pub fn final_plan(&self) -> [FinalStep; 3] {
331 [
332 FinalStep::HyperHeadMean,
333 FinalStep::Norm("model.language_model.norm.weight"),
334 FinalStep::LmHead,
335 ]
336 }
337
338 /// Per-layer cache requirement. Admission needs every kind satisfied at once.
339 pub fn state_plan(&self) -> BTreeMap<usize, StateKind> {
340 self.all_layers()
341 .iter()
342 .map(|l| {
343 (
344 l.index,
345 match l.mixer {
346 Mixer::Kda => StateKind::KdaRecurrent,
347 Mixer::Dsa => StateKind::SparseKv,
348 },
349 )
350 })
351 .collect()
352 }
353
354 /// Layers whose recurrent state must be carried between steps.
355 pub fn kda_state_layers(&self) -> Vec<usize> {
356 self.state_plan()
357 .into_iter()
358 .filter(|(_, k)| *k == StateKind::KdaRecurrent)
359 .map(|(i, _)| i)
360 .collect()
361 }
362
363 /// Layers that consume paged KV blocks.
364 pub fn kv_cache_layers(&self) -> Vec<usize> {
365 self.state_plan()
366 .into_iter()
367 .filter(|(_, k)| *k == StateKind::SparseKv)
368 .map(|(i, _)| i)
369 .collect()
370 }
371
372 /// The per-sequence state contract, from the config's real geometry.
373 ///
374 /// 🪤 `kda_recurrent` is **fp32 and not negotiable**: HF casts the recurrent state to
375 /// float32 and vLLM hardcodes `kda_state_dtype`, so `--ssm-h-dtype f16` is unavailable.
376 /// Sizing it as bf16 halves the number and is wrong.
377 pub fn state_budget(&self, cfg: &ModelConfig, num_spec: usize) -> StateBudget {
378 let kda_layers = self.kda_state_layers().len();
379 let kv_layers = self.layers.iter().filter(|l| l.mixer == Mixer::Dsa).count();
380 let heads = cfg.linear_num_value_heads.max(1);
381 let hd = cfg.linear_value_head_dim.max(1);
382 let conv_dim = cfg.linear_num_key_heads * cfg.linear_key_head_dim * 2
383 + cfg.linear_num_value_heads * cfg.linear_value_head_dim;
384 StateBudget {
385 kda_recurrent: kda_layers * heads * hd * hd * 4,
386 kda_conv: kda_layers * conv_dim * (cfg.linear_conv_kernel_dim - 1 + num_spec) * 2,
387 dsa_kv_per_token: kv_layers * cfg.kv_lora_rank * 2,
388 // 🔴 TWO buffers of `index_head_dim` BF16 (`k_normed` AND the compress `gate`)
389 // plus the 1 B validity flag — `Glm5NextDsaState::alloc`. Counting only
390 // `k_normed` halved this, which did not bite while the cache was pinned at a
391 // fixed 16,384 rows and does bite the moment it scales with --max-seq-len.
392 dsa_indexer_per_token: kv_layers * (cfg.index_head_dim * 2 * 2 + 1),
393 mhc_highway_per_token: self.hc_mult * self.hidden_size * 4,
394 moe_routing_per_token: cfg.num_experts * 4 + cfg.num_experts_per_tok * 8,
395 }
396 }
397
398 /// Account the skeleton's structural contract against a checkpoint's tensor names.
399 ///
400 /// `available` is the FULL name list; MLP/MoE and vision names are expected to be present
401 /// and are reported as `deferred`, not as errors — this slice does not bind them. Anything
402 /// structural that is missing, and any non-MLP text tensor the skeleton did not ask for,
403 /// is a hard failure. Zero unknown, zero silent skips.
404 pub fn account(&self, available: &BTreeSet<String>) -> StructuralAccounting {
405 let required = self.structural_tensor_set();
406 let mut missing = Vec::new();
407 for r in &required {
408 if !available.contains(r) {
409 missing.push(r.clone());
410 }
411 }
412 let mut unexpected = Vec::new();
413 let mut deferred = 0usize;
414 for a in available {
415 if required.contains(a) {
416 continue;
417 }
418 let is_mlp = a.contains(".mlp.");
419 let is_vision = a.starts_with("model.visual.") || a.starts_with("model.vision");
420 if is_mlp || is_vision {
421 deferred += 1;
422 } else {
423 unexpected.push(a.clone());
424 }
425 }
426 StructuralAccounting {
427 required: required.len(),
428 bound: required.len() - missing.len(),
429 missing,
430 unexpected,
431 deferred,
432 }
433 }
434}
435
436/// Per-sequence state contract (Slice 11 gate 3).
437///
438/// Derived from the checkpoint config, not from a formula chosen to look tidy. Every field is
439/// bytes for ONE sequence at EP=1; `per_rank` halves only what EP actually shards.
440#[derive(Debug, Clone, Copy)]
441pub struct StateBudget {
442 /// KDA recurrent state — `[heads, head_dim, head_dim]` **fp32 mandatory** (HF casts to
443 /// float32 and vLLM hardcodes it), 34 layers. FIXED: does not grow with sequence length.
444 pub kda_recurrent: usize,
445 /// KDA causal-conv window, bf16, `conv_dim x (kernel - 1 + num_spec)`. FIXED.
446 pub kda_conv: usize,
447 /// DSA MLA KV per TOKEN across the 11 text layers — `kv_lora_rank` bf16, NoPE so there is
448 /// no rope section. GROWS with sequence length.
449 pub dsa_kv_per_token: usize,
450 /// Indexer key + gate state per TOKEN across the 11 text layers. GROWS.
451 /// 🪤 REPLICATED, not sharded: the indexer is `DsaShard::Replicated` (`dsa/tp.rs`), so
452 /// [`Self::per_rank`] must not divide it.
453 pub dsa_indexer_per_token: usize,
454 /// mHC highway per TOKEN — `hc_mult x hidden` fp32 in Atlas (bf16 in HF; see the OPEN
455 /// highway-dtype item). Activation-lifetime, not persistent across steps.
456 pub mhc_highway_per_token: usize,
457 /// MoE routing scratch per token: logits + top-k ids + weights.
458 pub moe_routing_per_token: usize,
459}
460
461impl StateBudget {
462 /// Fixed (sequence-length-independent) bytes per sequence.
463 pub fn fixed(&self) -> usize {
464 self.kda_recurrent + self.kda_conv
465 }
466 /// Bytes that grow with every token of context.
467 pub fn per_token(&self) -> usize {
468 self.dsa_kv_per_token + self.dsa_indexer_per_token
469 }
470 /// Total persistent state for a sequence of `tokens`.
471 pub fn for_sequence(&self, tokens: usize) -> usize {
472 self.fixed() + tokens * self.per_token()
473 }
474 /// EP shards the KDA head dimension and the KV heads; the DSA INDEXER cache, the mHC
475 /// highway and the routing scratch are replicated. Only what EP actually shards is
476 /// divided.
477 pub fn per_rank(&self, ep: usize) -> StateBudget {
478 StateBudget {
479 kda_recurrent: self.kda_recurrent / ep,
480 kda_conv: self.kda_conv / ep,
481 dsa_kv_per_token: self.dsa_kv_per_token / ep,
482 // 🔴 NOT divided: the indexer's `wk`/gate projections are replicated on every
483 // rank (`DsaShard::Replicated`), so every rank holds the whole cache.
484 dsa_indexer_per_token: self.dsa_indexer_per_token,
485 mhc_highway_per_token: self.mhc_highway_per_token,
486 moe_routing_per_token: self.moe_routing_per_token,
487 }
488 }
489}
490
491#[derive(Debug)]
492pub struct StructuralAccounting {
493 pub required: usize,
494 pub bound: usize,
495 /// Structural tensors the checkpoint does not have. MUST be empty.
496 pub missing: Vec<String>,
497 /// Text tensors that are neither structural nor MLP/vision — i.e. names the skeleton was
498 /// never taught. MUST be empty.
499 pub unexpected: Vec<String>,
500 /// MLP / MoE / vision tensors, deliberately not bound by this slice.
501 pub deferred: usize,
502}
503
504impl StructuralAccounting {
505 pub fn is_complete(&self) -> bool {
506 self.missing.is_empty() && self.unexpected.is_empty() && self.bound == self.required
507 }
508}