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}