Skip to main content

ferrox_models/
glm52_decoder.rs

1//! A dedicated decoder for GLM-5.2's real architecture, separate from
2//! `ferrox-models::decoder::Decoder` (the generic GQA path every other
3//! preset uses) -- analogous to `kimi_decoder.rs`'s role for Kimi K3:
4//! composes the already-independently-tested `glm_dsa` attention
5//! module with a standard SwiGLU dense/MoE FFN into a real forward
6//! pass, without touching the existing GQA decoder.
7//!
8//! Real per-layer flow, transcribed from `llama_model_glm_dsa::graph`'s
9//! real per-layer loop in `src/models/glm-dsa.cpp` (confirmed against
10//! both PR #23346's DeepSeek-V3.2 fork point and PR #25407's GLM-5.2
11//! diff on top) -- notably **simpler** than Kimi K3's real per-layer
12//! flow (`kimi_decoder.rs`'s module doc comment): no block-residual
13//! scaffolding at all, an ordinary pre-norm transformer block:
14//!
15//! ```text
16//! attn_in = rms_norm(hidden, attn_norm)
17//! attn_out = glm52_attn_forward_token(attn_in)   // glm_dsa module
18//! ffn_in = hidden + attn_out
19//! ffn_out = rms_norm(ffn_in, ffn_norm) |> dense_or_moe_ffn
20//! hidden = ffn_in + ffn_out
21//! ```
22//!
23//! FFN: dense leading layers use ordinary SiLU-gated SwiGLU
24//! (`ffn_gate`/`ffn_up`/`ffn_down`, `ggml`'s `LLM_FFN_SILU`/
25//! `LLM_FFN_PAR` -- the same convention every other architecture's
26//! dense FFN uses in this codebase, `ferrox_core::matmul::swiglu`). MoE
27//! layers use real sigmoid gating with an aux-loss-free per-expert bias
28//! (`noaux_tc`, confirmed against the real `config.json`:
29//! `"scoring_func": "sigmoid"`, `"topk_method": "noaux_tc"` -- see
30//! docs/MODELS.md) plus a shared expert, reusing
31//! `ferrox_moe::route_top_k_sigmoid_with_bias`/`run_expert`/
32//! `combine_expert_outputs` directly rather than re-deriving that math
33//! here (it's already independently tested there, and it's exactly the
34//! same real convention DeepSeek-V3/Kimi K3 use for their own
35//! `noaux_tc` routing).
36//!
37//! Not yet run against a real GLM-5.2 checkpoint (~744B params, no
38//! feasible download in this environment) -- tested here against
39//! synthetic weights only, cross-validated for the attention math via
40//! `glm_dsa`'s own independent Python cross-check;
41//! this module's own test additionally confirms the full decoder
42//! (attention + both dense and MoE FFN branches, across a full/shared
43//! indexer-layer pair) composes into a finite, real forward pass end
44//! to end, the same rigor `kimi_decoder.rs`'s own test applies for
45//! Kimi K3.
46
47use ferrox_core::matmul::{rms_norm, swiglu};
48use ferrox_core::tensor::Tensor;
49use ferrox_core::weight_matrix::WeightMatrix;
50use ferrox_moe::{
51    combine_expert_outputs, route_top_k_sigmoid_with_bias, run_expert, ExpertWeights,
52};
53
54use crate::glm_dsa::{Glm52AttnState, Glm52AttnWeights, Glm52MlaConfig, IndexerConfig};
55
56/// The dense leading layer's feed-forward block (real tensor names
57/// `blk.{bid}.ffn_{gate,down,up}`).
58pub struct Glm52DenseFfnWeights {
59    pub gate_proj: WeightMatrix,
60    pub up_proj: WeightMatrix,
61    pub down_proj: WeightMatrix,
62}
63
64impl Glm52DenseFfnWeights {
65    fn forward(&self, x: &[f32]) -> Vec<f32> {
66        let gate = self.gate_proj.apply(x);
67        let up = self.up_proj.apply(x);
68        let combined = swiglu(&gate, &up);
69        self.down_proj.apply(&combined)
70    }
71}
72
73/// One MoE layer's real weights: routed experts (sigmoid gating +
74/// aux-loss-free bias) plus a shared expert, always active.
75pub struct Glm52MoeFfnWeights {
76    pub router_weight: WeightMatrix,
77    pub e_score_correction_bias: Vec<f32>,
78    pub experts: Vec<ExpertWeights>,
79    pub shared_expert: ExpertWeights,
80}
81
82pub enum Glm52LayerFfn {
83    Dense(Box<Glm52DenseFfnWeights>),
84    Moe(Box<Glm52MoeFfnWeights>),
85}
86
87pub struct Glm52DecoderLayerWeights {
88    pub attn_norm_weight: Vec<f32>,
89    pub attn: Glm52AttnWeights,
90    pub ffn_norm_weight: Vec<f32>,
91    pub ffn: Glm52LayerFfn,
92    /// Whether this layer's indexer is "full" (computes its own top-k)
93    /// or "shared" (reuses the nearest preceding "full" layer's top-k)
94    /// -- real per-layer `indexer_types` array, see `glm_dsa`'s module
95    /// doc comment point 1. The very first layer processed must always
96    /// be `true` (the real architecture guarantees this).
97    pub is_full_indexer_layer: bool,
98}
99
100pub struct Glm52DecoderWeights {
101    pub embedding: Tensor, // [vocab_size, hidden_dim]
102    pub layers: Vec<Glm52DecoderLayerWeights>,
103    pub final_norm_weight: Vec<f32>,
104    pub output_head: WeightMatrix, // [vocab_size, hidden_dim]
105}
106
107pub struct Glm52DecoderConfig {
108    pub rms_norm_eps: f32,
109    pub mla: Glm52MlaConfig,
110    pub indexer: IndexerConfig,
111    pub n_experts_active: usize,
112    /// GLM-5.2's real `norm_topk_prob`/`routed_scaling_factor`
113    /// (2.5 in the real published config -- see docs/MODELS.md).
114    pub moe_renormalize: bool,
115    pub routed_scaling_factor: f32,
116}
117
118pub struct Glm52DecodeState {
119    layer_states: Vec<Glm52AttnState>,
120}
121
122impl Glm52DecodeState {
123    pub fn new(weights: &Glm52DecoderWeights) -> Self {
124        Glm52DecodeState {
125            layer_states: weights
126                .layers
127                .iter()
128                .map(|_| Glm52AttnState::new())
129                .collect(),
130        }
131    }
132}
133
134fn moe_ffn_forward(weights: &Glm52MoeFfnWeights, cfg: &Glm52DecoderConfig, x: &[f32]) -> Vec<f32> {
135    let router_logits = weights.router_weight.apply(x);
136    let decision = route_top_k_sigmoid_with_bias(
137        &router_logits,
138        &weights.e_score_correction_bias,
139        cfg.n_experts_active,
140        cfg.moe_renormalize,
141        cfg.routed_scaling_factor,
142    );
143    let routed_outputs: Vec<(Vec<f32>, f32)> = decision
144        .expert_ids
145        .iter()
146        .zip(decision.weights.iter())
147        .map(|(&e, &w)| (run_expert(x, &weights.experts[e]), w))
148        .collect();
149    let shared_out = run_expert(x, &weights.shared_expert);
150    combine_expert_outputs(&routed_outputs, &[shared_out], x.len())
151}
152
153/// One decode step across every layer. `prev_top_k` is reset to `None`
154/// at the start of this function (per-token-forward-pass scope, see
155/// `glm_dsa::glm52_attn_forward_token`'s doc comment) -- it must not be
156/// threaded in from a previous token.
157pub fn glm52_forward_token(
158    weights: &Glm52DecoderWeights,
159    cfg: &Glm52DecoderConfig,
160    token_id: usize,
161    state: &mut Glm52DecodeState,
162) -> Vec<f32> {
163    let hidden_dim = weights.embedding.cols();
164    let mut hidden = weights.embedding.row(token_id).to_vec();
165    let mut prev_top_k: Option<Vec<usize>> = None;
166
167    for (layer_idx, layer) in weights.layers.iter().enumerate() {
168        let attn_in = rms_norm(&hidden, &layer.attn_norm_weight, cfg.rms_norm_eps);
169        let attn_out = crate::glm_dsa::glm52_attn_forward_token(
170            &layer.attn,
171            &cfg.mla,
172            &cfg.indexer,
173            &attn_in,
174            cfg.rms_norm_eps,
175            layer.is_full_indexer_layer,
176            &mut state.layer_states[layer_idx],
177            &mut prev_top_k,
178        );
179
180        let mut ffn_in = hidden;
181        for (f, a) in ffn_in.iter_mut().zip(attn_out.iter()) {
182            *f += a;
183        }
184
185        let ffn_normed = rms_norm(&ffn_in, &layer.ffn_norm_weight, cfg.rms_norm_eps);
186        let ffn_out = match &layer.ffn {
187            Glm52LayerFfn::Dense(w) => w.forward(&ffn_normed),
188            Glm52LayerFfn::Moe(w) => moe_ffn_forward(w, cfg, &ffn_normed),
189        };
190
191        let mut next_hidden = ffn_in;
192        for (h, f) in next_hidden.iter_mut().zip(ffn_out.iter()) {
193            *h += f;
194        }
195        hidden = next_hidden;
196    }
197
198    let final_normed = rms_norm(&hidden, &weights.final_norm_weight, cfg.rms_norm_eps);
199    assert_eq!(final_normed.len(), hidden_dim);
200    weights.output_head.apply(&final_normed)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::config::MlaRopeConfig;
207    use crate::glm_dsa::IndexerWeights;
208
209    const HIDDEN_DIM: usize = 8;
210    const EPS: f32 = 1e-5;
211    const NUM_HEADS: usize = 2;
212    const QK_NOPE: usize = 4;
213    const QK_ROPE: usize = 4;
214    const KV_LORA: usize = 4;
215    const Q_LORA: usize = 6;
216    const V_HEAD_DIM: usize = 3;
217    const IDX_N_HEADS: usize = 2;
218    const IDX_HEAD_DIM: usize = 4;
219    const IDX_ROPE_DIM: usize = 2;
220    const TOP_K: usize = 2;
221    const MOE_HIDDEN_DIM: usize = 8;
222    const MOE_FFN_DIM: usize = 3;
223    const N_EXPERTS: usize = 4;
224    const N_EXPERTS_ACTIVE: usize = 2;
225    const OUTPUT_VOCAB: usize = 5;
226    const DENSE_FFN_DIM: usize = 5;
227
228    fn wm(data: Vec<f32>, rows: usize, cols: usize) -> WeightMatrix {
229        assert_eq!(data.len(), rows * cols);
230        WeightMatrix::F32(Tensor::new(data, vec![rows, cols]))
231    }
232
233    // Small deterministic pseudo-random generator (no external RNG
234    // dependency needed for a purely-structural synthetic test) --
235    // same style used elsewhere in this codebase's synthetic fixtures.
236    fn synth(seed: usize, n: usize) -> Vec<f32> {
237        (0..n)
238            .map(|i| (((seed * 131 + i * 17 + 7) % 23) as f32 * 0.05) - 0.55)
239            .collect()
240    }
241
242    fn make_attn_weights(seed: usize, is_full: bool) -> Glm52AttnWeights {
243        let q_head_dim = QK_NOPE + QK_ROPE;
244        Glm52AttnWeights {
245            q_a_proj: wm(synth(seed + 1, Q_LORA * HIDDEN_DIM), Q_LORA, HIDDEN_DIM),
246            q_a_layernorm: vec![1.0; Q_LORA],
247            q_b_proj: wm(
248                synth(seed + 2, NUM_HEADS * q_head_dim * Q_LORA),
249                NUM_HEADS * q_head_dim,
250                Q_LORA,
251            ),
252            kv_a_proj_with_mqa: wm(
253                synth(seed + 3, (KV_LORA + QK_ROPE) * HIDDEN_DIM),
254                KV_LORA + QK_ROPE,
255                HIDDEN_DIM,
256            ),
257            kv_a_layernorm: vec![1.0; KV_LORA],
258            wk_b: (0..NUM_HEADS)
259                .map(|h| wm(synth(seed + 4 + h, QK_NOPE * KV_LORA), QK_NOPE, KV_LORA))
260                .collect(),
261            wv_b: (0..NUM_HEADS)
262                .map(|h| {
263                    wm(
264                        synth(seed + 6 + h, V_HEAD_DIM * KV_LORA),
265                        V_HEAD_DIM,
266                        KV_LORA,
267                    )
268                })
269                .collect(),
270            o_proj: wm(
271                synth(seed + 8, HIDDEN_DIM * NUM_HEADS * V_HEAD_DIM),
272                HIDDEN_DIM,
273                NUM_HEADS * V_HEAD_DIM,
274            ),
275            indexer: if is_full {
276                Some(IndexerWeights {
277                    k_norm_weight: vec![1.0; IDX_HEAD_DIM],
278                    k_norm_bias: vec![0.0; IDX_HEAD_DIM],
279                    proj: wm(
280                        synth(seed + 9, IDX_N_HEADS * HIDDEN_DIM),
281                        IDX_N_HEADS,
282                        HIDDEN_DIM,
283                    ),
284                    attn_k: wm(
285                        synth(seed + 10, IDX_HEAD_DIM * HIDDEN_DIM),
286                        IDX_HEAD_DIM,
287                        HIDDEN_DIM,
288                    ),
289                    attn_q_b: wm(
290                        synth(seed + 11, IDX_N_HEADS * IDX_HEAD_DIM * Q_LORA),
291                        IDX_N_HEADS * IDX_HEAD_DIM,
292                        Q_LORA,
293                    ),
294                })
295            } else {
296                None
297            },
298        }
299    }
300
301    fn make_weights() -> Glm52DecoderWeights {
302        let layer0 = Glm52DecoderLayerWeights {
303            attn_norm_weight: vec![1.0; HIDDEN_DIM],
304            attn: make_attn_weights(100, true),
305            ffn_norm_weight: vec![1.0; HIDDEN_DIM],
306            ffn: Glm52LayerFfn::Dense(Box::new(Glm52DenseFfnWeights {
307                gate_proj: wm(
308                    synth(200, DENSE_FFN_DIM * HIDDEN_DIM),
309                    DENSE_FFN_DIM,
310                    HIDDEN_DIM,
311                ),
312                up_proj: wm(
313                    synth(201, DENSE_FFN_DIM * HIDDEN_DIM),
314                    DENSE_FFN_DIM,
315                    HIDDEN_DIM,
316                ),
317                down_proj: wm(
318                    synth(202, HIDDEN_DIM * DENSE_FFN_DIM),
319                    HIDDEN_DIM,
320                    DENSE_FFN_DIM,
321                ),
322            })),
323            is_full_indexer_layer: true,
324        };
325
326        let expert = |seed: usize| ExpertWeights {
327            gate: wm(
328                synth(seed, MOE_FFN_DIM * MOE_HIDDEN_DIM),
329                MOE_FFN_DIM,
330                MOE_HIDDEN_DIM,
331            ),
332            up: wm(
333                synth(seed + 1, MOE_FFN_DIM * MOE_HIDDEN_DIM),
334                MOE_FFN_DIM,
335                MOE_HIDDEN_DIM,
336            ),
337            down: wm(
338                synth(seed + 2, MOE_HIDDEN_DIM * MOE_FFN_DIM),
339                MOE_HIDDEN_DIM,
340                MOE_FFN_DIM,
341            ),
342        };
343
344        let layer1 = Glm52DecoderLayerWeights {
345            attn_norm_weight: vec![1.0; HIDDEN_DIM],
346            attn: make_attn_weights(300, false),
347            ffn_norm_weight: vec![1.0; HIDDEN_DIM],
348            ffn: Glm52LayerFfn::Moe(Box::new(Glm52MoeFfnWeights {
349                router_weight: wm(synth(400, N_EXPERTS * HIDDEN_DIM), N_EXPERTS, HIDDEN_DIM),
350                e_score_correction_bias: vec![0.0; N_EXPERTS],
351                experts: (0..N_EXPERTS).map(|e| expert(500 + e * 10)).collect(),
352                shared_expert: expert(900),
353            })),
354            is_full_indexer_layer: false,
355        };
356
357        Glm52DecoderWeights {
358            embedding: Tensor::new(
359                synth(1000, OUTPUT_VOCAB * HIDDEN_DIM),
360                vec![OUTPUT_VOCAB, HIDDEN_DIM],
361            ),
362            layers: vec![layer0, layer1],
363            final_norm_weight: vec![1.0; HIDDEN_DIM],
364            output_head: wm(
365                synth(1100, OUTPUT_VOCAB * HIDDEN_DIM),
366                OUTPUT_VOCAB,
367                HIDDEN_DIM,
368            ),
369        }
370    }
371
372    fn decoder_cfg() -> Glm52DecoderConfig {
373        Glm52DecoderConfig {
374            rms_norm_eps: EPS,
375            mla: Glm52MlaConfig {
376                num_heads: NUM_HEADS,
377                q_lora_rank: Q_LORA,
378                kv_lora_rank: KV_LORA,
379                qk_nope_head_dim: QK_NOPE,
380                qk_rope_head_dim: QK_ROPE,
381                v_head_dim: V_HEAD_DIM,
382                rope: MlaRopeConfig { theta: 8_000_000.0 },
383            },
384            indexer: IndexerConfig {
385                n_heads: IDX_N_HEADS,
386                head_dim: IDX_HEAD_DIM,
387                rope_dim: IDX_ROPE_DIM,
388                top_k: TOP_K,
389                rope_theta: 8_000_000.0,
390            },
391            n_experts_active: N_EXPERTS_ACTIVE,
392            moe_renormalize: true,
393            routed_scaling_factor: 2.5,
394        }
395    }
396
397    #[test]
398    fn two_mixed_layers_run_end_to_end_across_three_tokens() {
399        let weights = make_weights();
400        let cfg = decoder_cfg();
401        let mut state = Glm52DecodeState::new(&weights);
402
403        for token_id in 0..3 {
404            let logits = glm52_forward_token(&weights, &cfg, token_id % OUTPUT_VOCAB, &mut state);
405            assert_eq!(logits.len(), OUTPUT_VOCAB);
406            assert!(
407                logits.iter().all(|v| v.is_finite()),
408                "token {token_id}: logits must be finite, got {logits:?}"
409            );
410        }
411    }
412
413    #[test]
414    fn shared_layer_without_a_prior_full_layer_in_the_same_token_panics() {
415        // Build a decoder whose only layer is "shared" -- violates the
416        // real architecture's invariant that the first layer processed
417        // is always "full" (see `glm_dsa`'s module doc comment). Must
418        // panic loudly, matching the real `GGML_ASSERT`, not silently
419        // produce wrong output.
420        let mut weights = make_weights();
421        weights.layers.truncate(1);
422        weights.layers[0].is_full_indexer_layer = false;
423        weights.layers[0].attn.indexer = None;
424
425        let cfg = decoder_cfg();
426        let mut state = Glm52DecodeState::new(&weights);
427
428        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
429            glm52_forward_token(&weights, &cfg, 0, &mut state)
430        }));
431        assert!(
432            result.is_err(),
433            "a lone \"shared\" layer with no preceding \"full\" layer must panic"
434        );
435    }
436}