Skip to main content

ferrox_models/
mla.rs

1//! DeepSeek-style Multi-head Latent Attention (MLA): low-rank Q/KV
2//! compression, with an optional sigmoid output gate (Kimi K3's real
3//! addition) and an optional RoPE rotation of the decoupled `q_rot`/
4//! `k_rot` slices (`MlaConfig::rope`; GLM-5.2's real addition -- see
5//! below). Transcribed directly from real reference code, not guessed
6//! or derived by analogy:
7//!
8//! 1. **Kimi K3** (`moonshotai/Kimi-K3`'s `modeling_kimi_linear.py`,
9//!    `KimiMLAAttention.forward`, fetched live from the model repo): no
10//!    rotary embedding is actually applied. `q_rot`/`k_rot` are named
11//!    for the historical DeepSeek "rope part" split, but the real
12//!    module asserts `self.use_nope` and never calls a rotary
13//!    embedding function in `forward()` — the "rot" slice is just
14//!    extra head-dim content, never position-rotated. Represented here
15//!    as `MlaConfig::rope: None`.
16//! 2. **GLM-5.2** (`zai-org/GLM-5.2`'s real `config.json`, confirmed
17//!    against llama.cpp PR #25407's `src/models/glm-dsa.cpp`) DOES
18//!    rotate its decoupled `q_rot`/`k_rot` slices, with the interleaved
19//!    convention (`rope_interleave: true`,
20//!    `ferrox_core::attention::apply_rope_interleaved`) — the opposite
21//!    of the natural-but-wrong assumption the Kimi K3 module doc above
22//!    warns against, for a *different* real architecture. Represented
23//!    here as `MlaConfig::rope: Some(MlaRopeConfig { theta })`. `k_rot`
24//!    is MQA-style (one shared vector per position, broadcast to every
25//!    head — see point 4) so it's rotated once, before broadcasting;
26//!    rotating a shared vector once then copying it into every head
27//!    is exactly equivalent to rotating each head's copy separately,
28//!    since RoPE's rotation angle depends only on position, not on the
29//!    vector's per-head value.
30//! 3. `kv_b_proj` expands to `num_heads * (...)`, not
31//!    `num_key_value_heads * (...)`, despite `num_key_value_heads`/
32//!    `num_key_value_groups` being computed in Kimi K3's real
33//!    `__init__` — they go unused in `forward()`. Every query head gets
34//!    its own decompressed K/V; there is no GQA-style grouping layered
35//!    on top of the latent compression, which is why this module uses
36//!    `ferrox_core::attention::causal_mla_attention` rather than
37//!    `causal_gqa_attention`.
38//!
39//! When `rope` is `None` (Kimi K3's real path), a further simplification
40//! applies, not present in the reference code's literal structure but
41//! mathematically identical to it: the real `forward()` splits
42//! `q_b_proj`'s output into `q_pass`/`q_rot` and immediately
43//! re-concatenates them in the same order to form `query_states`. Since
44//! nothing is inserted between the split and the concat (no rotation),
45//! that round-trip is a no-op — `concat(x[..a], x[a..]) == x` — so this
46//! implementation uses `q_b_proj`'s raw output directly as the query in
47//! that case. When `rope` is `Some` (GLM-5.2's real path), the split is
48//! no longer a no-op (rotation happens in between), so the `q_rot`
49//! slice is rotated in place before attention runs.
50//!
51//! Not yet wired into `Decoder`'s forward pass (`AttentionKind` doesn't
52//! dispatch to this yet) or into `ferrox_core::cache::KvCache` (which
53//! assumes K and V share one `head_dim`, whereas MLA's K head dim
54//! `qk_nope_head_dim + qk_rope_head_dim` and V head dim `v_head_dim`
55//! generally differ) — both are handled by `kimi_decoder` (Kimi K3;
56//! `rope: None`) and `glm_dsa`/`glm52_decoder` (GLM-5.2; `rope: Some`),
57//! the dedicated decoders that consume this module. Tested here against
58//! synthetic weights, cross-validated against independent Python
59//! transcriptions of the same real reference algorithms for both
60//! rope-disabled and rope-enabled paths.
61
62use ferrox_core::attention::{apply_rope_interleaved, causal_mla_attention};
63use ferrox_core::matmul::rms_norm;
64use ferrox_core::weight_matrix::WeightMatrix;
65
66use crate::config::MlaConfig;
67
68pub struct MlaAttnWeights {
69    pub q_a_proj: WeightMatrix,           // [q_lora_rank, hidden_dim]
70    pub q_a_layernorm: Vec<f32>,          // [q_lora_rank]
71    pub q_b_proj: WeightMatrix,           // [n_heads*q_head_dim, q_lora_rank]
72    pub kv_a_proj_with_mqa: WeightMatrix, // [kv_lora_rank+qk_rope_head_dim, hidden_dim]
73    pub kv_a_layernorm: Vec<f32>,         // [kv_lora_rank]
74    pub kv_b_proj: WeightMatrix,          // [n_heads*(qk_nope_head_dim+v_head_dim), kv_lora_rank]
75    pub o_proj: WeightMatrix,             // [hidden_dim, n_heads*v_head_dim]
76    /// Present iff `MlaConfig::use_output_gate`.
77    pub g_proj: Option<WeightMatrix>, // [n_heads*v_head_dim, hidden_dim]
78}
79
80/// One decode step. `k_cache`/`v_cache` are growable, caller-owned
81/// buffers in `[seq_len_so_far, n_heads, head_dim]` layout (head_dim =
82/// `qk_nope_head_dim + qk_rope_head_dim` for `k`, `v_head_dim` for `v`)
83/// — plain `Vec<f32>`, not yet `ferrox_core::cache::KvCache` (see module
84/// doc comment). This function appends the current position's K/V to
85/// both before running attention over every position pushed so far.
86#[allow(clippy::too_many_arguments)]
87pub fn mla_forward_token(
88    weights: &MlaAttnWeights,
89    cfg: &MlaConfig,
90    hidden: &[f32],
91    rms_norm_eps: f32,
92    k_cache: &mut Vec<f32>,
93    v_cache: &mut Vec<f32>,
94) -> Vec<f32> {
95    let q_head_dim = cfg.qk_nope_head_dim + cfg.qk_rope_head_dim;
96    // Position of the token being processed this call = how many
97    // positions this layer's cache already holds, before this call
98    // appends one more -- the same implicit convention `seq_len` below
99    // already relies on (cache length as a proxy for "how many tokens
100    // this layer has processed so far").
101    let pos = k_cache.len() / (cfg.num_heads * q_head_dim);
102
103    let q_a = weights.q_a_proj.apply(hidden);
104    let q_a_normed = rms_norm(&q_a, &weights.q_a_layernorm, rms_norm_eps);
105    // Without rope: `query_states` == raw `q_b_proj` output; see module
106    // doc comment for why the reference's split+re-concat round-trip is
107    // skipped in that case. With rope (GLM-5.2): the split is no longer
108    // a no-op, so `q_rot` is rotated in place per head before use.
109    let mut query = weights.q_b_proj.apply(&q_a_normed); // [n_heads*q_head_dim]
110    if let Some(rope) = &cfg.rope {
111        for h in 0..cfg.num_heads {
112            let q_rot_h = &mut query[h * q_head_dim + cfg.qk_nope_head_dim..(h + 1) * q_head_dim];
113            apply_rope_interleaved(q_rot_h, pos, rope.theta);
114        }
115    }
116
117    let compressed_kv = weights.kv_a_proj_with_mqa.apply(hidden);
118    let (k_pass_c, k_rot_raw) = compressed_kv.split_at(cfg.kv_lora_rank);
119    // `k_rot` is MQA-style: one shared vector broadcast to every head,
120    // not a per-head projection (real `kv_a_proj_with_mqa` name says so
121    // directly, and the reference `.expand(...)`s it across heads) --
122    // so with rope enabled, it's rotated once here before broadcasting
123    // (see module doc comment point 2 for why that's equivalent to
124    // rotating each head's copy separately).
125    let mut k_rot = k_rot_raw.to_vec();
126    if let Some(rope) = &cfg.rope {
127        apply_rope_interleaved(&mut k_rot, pos, rope.theta);
128    }
129    let k_pass_c_normed = rms_norm(k_pass_c, &weights.kv_a_layernorm, rms_norm_eps);
130    let k_pass_full = weights.kv_b_proj.apply(&k_pass_c_normed); // [n_heads*(qk_nope_head_dim+v_head_dim)]
131
132    let mut key_step = vec![0f32; cfg.num_heads * q_head_dim];
133    let mut value_step = vec![0f32; cfg.num_heads * cfg.v_head_dim];
134    let kpf_stride = cfg.qk_nope_head_dim + cfg.v_head_dim;
135    for h in 0..cfg.num_heads {
136        let k_pass = &k_pass_full[h * kpf_stride..h * kpf_stride + cfg.qk_nope_head_dim];
137        let v_h = &k_pass_full[h * kpf_stride + cfg.qk_nope_head_dim..(h + 1) * kpf_stride];
138
139        let key_h = &mut key_step[h * q_head_dim..(h + 1) * q_head_dim];
140        key_h[..cfg.qk_nope_head_dim].copy_from_slice(k_pass);
141        key_h[cfg.qk_nope_head_dim..].copy_from_slice(&k_rot);
142
143        value_step[h * cfg.v_head_dim..(h + 1) * cfg.v_head_dim].copy_from_slice(v_h);
144    }
145
146    k_cache.extend_from_slice(&key_step);
147    v_cache.extend_from_slice(&value_step);
148    let seq_len = k_cache.len() / (cfg.num_heads * q_head_dim);
149
150    let attn_out = causal_mla_attention(
151        &query,
152        k_cache,
153        v_cache,
154        cfg.num_heads,
155        q_head_dim,
156        cfg.v_head_dim,
157        seq_len,
158    );
159
160    let gated = match &weights.g_proj {
161        Some(g_proj) => {
162            let g = g_proj.apply(hidden);
163            attn_out
164                .iter()
165                .zip(g.iter())
166                .map(|(a, g)| a * (1.0 / (1.0 + (-g).exp())))
167                .collect::<Vec<f32>>()
168        }
169        None => attn_out,
170    };
171
172    weights.o_proj.apply(&gated)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::config::MlaRopeConfig;
179    use ferrox_core::tensor::Tensor;
180
181    const HIDDEN_SIZE: usize = 8;
182    const NUM_HEADS: usize = 2;
183    const QK_NOPE_HEAD_DIM: usize = 3;
184    const QK_ROPE_HEAD_DIM: usize = 2;
185    const KV_LORA_RANK: usize = 4;
186    const Q_LORA_RANK: usize = 6;
187    const V_HEAD_DIM: usize = 3;
188    const EPS: f32 = 1e-5;
189
190    fn wm(data: &[f32], rows: usize, cols: usize) -> WeightMatrix {
191        assert_eq!(data.len(), rows * cols);
192        WeightMatrix::F32(Tensor::new(data.to_vec(), vec![rows, cols]))
193    }
194
195    fn cfg() -> MlaConfig {
196        MlaConfig {
197            num_heads: NUM_HEADS,
198            q_lora_rank: Q_LORA_RANK,
199            kv_lora_rank: KV_LORA_RANK,
200            qk_nope_head_dim: QK_NOPE_HEAD_DIM,
201            qk_rope_head_dim: QK_ROPE_HEAD_DIM,
202            v_head_dim: V_HEAD_DIM,
203            use_output_gate: true,
204            rope: None,
205        }
206    }
207
208    // Generated by an independent Python reference -- do not hand-edit.
209    const MLA_Q_A_PROJ: [f32; 48] = [
210        -0.237937, 0.0721714, -0.568898, 0.418732, 0.191488, -0.0876142, -0.0935848, 0.0911506,
211        -0.0802981, -0.0677727, 0.21602, 0.154412, -0.0192384, -0.025643, 0.0482749, -0.184206,
212        -0.121125, 0.164478, -0.0391448, -0.412328, -0.143184, 0.196986, -0.0696848, -0.0446198,
213        0.192551, 0.547383, -0.213957, 0.404462, -0.369004, 0.0524933, -0.350859, 0.405437,
214        0.250177, 0.341315, -0.26566, 0.205367, -0.155704, -0.137216, 0.151961, 0.263015,
215        0.0613261, -0.188396, -0.247745, 0.433295, 0.178184, 0.215918, 0.655046, -0.244759,
216    ];
217    const MLA_Q_A_LAYERNORM_W: [f32; 6] = [1.25595, 1.31509, 1.16184, 1.08271, 0.933618, 1.09945];
218    const MLA_Q_B_PROJ: [f32; 60] = [
219        -0.132808,
220        -0.00649524,
221        -0.08713,
222        0.085149,
223        0.386423,
224        -0.166675,
225        -0.295621,
226        -0.300887,
227        -0.290483,
228        -0.429331,
229        -0.27388,
230        0.38798,
231        -0.177994,
232        0.0771323,
233        -0.365068,
234        0.0508952,
235        -0.522234,
236        -0.209627,
237        0.676362,
238        -0.174891,
239        0.335993,
240        0.136516,
241        -0.0458957,
242        -0.195632,
243        0.386073,
244        -0.053215,
245        0.458227,
246        -0.215724,
247        0.0172017,
248        0.13965,
249        0.111948,
250        -0.37014,
251        -0.199219,
252        -0.0587941,
253        -0.25611,
254        0.203198,
255        0.176406,
256        -0.587125,
257        -0.541576,
258        -0.384469,
259        0.0351779,
260        0.609952,
261        -0.114707,
262        0.0751952,
263        -0.318934,
264        -0.314051,
265        -0.587168,
266        -0.00850327,
267        0.284165,
268        -0.107014,
269        0.418936,
270        0.0593565,
271        -0.0109237,
272        0.155176,
273        0.146213,
274        0.344342,
275        -0.240586,
276        -0.686415,
277        0.034424,
278        -0.183503,
279    ];
280    const MLA_KV_A_PROJ: [f32; 48] = [
281        -0.00815316,
282        0.49929,
283        -0.330853,
284        0.229439,
285        0.28376,
286        0.138221,
287        0.335552,
288        -0.137509,
289        -0.204453,
290        0.311695,
291        0.21609,
292        0.417113,
293        0.0636869,
294        0.487069,
295        -0.0846241,
296        -0.318099,
297        -0.611127,
298        -0.33042,
299        0.245551,
300        -0.439479,
301        -0.135709,
302        0.631975,
303        0.254039,
304        0.537295,
305        -0.297469,
306        -0.737993,
307        0.454914,
308        -0.425083,
309        0.0272771,
310        0.065128,
311        -0.284669,
312        0.46579,
313        0.464801,
314        0.165433,
315        -0.0088472,
316        0.057431,
317        -0.328772,
318        -0.0717191,
319        -0.0314799,
320        -0.25567,
321        0.254764,
322        -0.427944,
323        -0.136026,
324        -0.675863,
325        0.149046,
326        0.222782,
327        0.186388,
328        0.875426,
329    ];
330    const MLA_KV_A_LAYERNORM_W: [f32; 4] = [1.09398, 0.905801, 1.26635, 0.907968];
331    const MLA_KV_B_PROJ: [f32; 48] = [
332        0.236622, 0.222938, -0.263917, 0.418605, 0.106378, 0.171286, -0.20272, 0.39079, -0.258281,
333        -0.533748, 0.427538, -0.128019, 0.013011, 0.576827, 0.340947, 0.189813, 0.320974,
334        -0.218253, 0.225927, 0.332981, 0.226627, -0.0612404, -1.12422, 0.167191, 0.0344629,
335        -0.110866, 0.23343, 0.0797109, 0.0611867, -0.445077, -0.437797, 0.111161, 0.0228171,
336        0.0416223, 0.0498789, 0.123437, 0.0108197, 0.0473313, 0.0768343, 0.278823, -0.154407,
337        0.235919, 0.190915, 0.0316206, -0.142534, 0.110742, 0.259192, -0.322493,
338    ];
339    const MLA_O_PROJ: [f32; 48] = [
340        0.0186437, 0.0194232, -0.198361, -0.185786, -0.465344, -0.330938, -0.461243, -0.184972,
341        -0.167996, -0.162386, 0.111553, -0.05627, -0.218674, -0.216565, -0.339453, -0.0588504,
342        -0.0642164, 0.486887, 0.460246, 0.443371, 0.623773, -0.36512, 0.201895, 0.228316, 0.142865,
343        0.638001, 0.761363, 0.29381, 0.195944, -0.336719, -0.336108, 0.210067, -0.0226898,
344        -0.399168, -0.0372073, 0.108002, 0.280778, -0.0323038, -0.0807652, 0.00186235, 0.051895,
345        -0.34894, 0.249993, 0.657098, 0.24426, -0.434459, 0.0283526, -0.529688,
346    ];
347    const MLA_G_PROJ: [f32; 48] = [
348        -0.548764, 0.197775, 0.0232947, -0.206133, -0.626234, -0.38068, 0.0486588, 0.00956812,
349        0.20691, 0.457018, -0.101777, 0.14069, -0.0413395, -0.148962, -0.0734372, 0.288226,
350        -0.308526, 0.342312, 0.443186, 0.0107199, 0.0764755, -0.42354, 0.70816, 0.293666,
351        0.0516828, 0.0172313, 0.135292, -0.195371, 0.0849785, -0.277073, 0.149196, -0.203464,
352        0.268656, -0.0361107, 0.0806238, 0.888267, 0.24127, 0.0803401, 0.0133166, -0.311749,
353        0.325266, 0.480702, 0.0193065, 0.503025, -0.0336833, -0.531916, -0.195972, -0.317098,
354    ];
355
356    const MLA_HIDDEN_0: [f32; 8] = [
357        -0.13727, 0.31349, -0.464487, -0.0401226, 0.0996761, -0.366514, -0.45466, 0.0435914,
358    ];
359    const MLA_HIDDEN_1: [f32; 8] = [
360        0.436185, -0.301614, -0.375696, -0.0706267, 0.144974, -0.271508, -0.167074, -0.115634,
361    ];
362    const MLA_HIDDEN_2: [f32; 8] = [
363        -0.542896, -0.0887686, -0.280024, 0.791695, -0.258661, 0.228556, 0.189759, -0.559016,
364    ];
365
366    const MLA_GOLDEN_OUT_0: [f32; 8] = [
367        0.01809, 0.174022, -0.240674, 0.212529, 0.493314, 0.191567, -0.184672, 0.118346,
368    ];
369    const MLA_GOLDEN_OUT_1: [f32; 8] = [
370        0.0683158, 0.13817, -0.241981, 0.162395, 0.452095, 0.159199, -0.139237, 0.145646,
371    ];
372    const MLA_GOLDEN_OUT_2: [f32; 8] = [
373        0.074717, 0.0909497, -0.208236, 0.166908, 0.390386, 0.129038, -0.124391, 0.12221,
374    ];
375
376    fn make_weights() -> MlaAttnWeights {
377        MlaAttnWeights {
378            q_a_proj: wm(&MLA_Q_A_PROJ, Q_LORA_RANK, HIDDEN_SIZE),
379            q_a_layernorm: MLA_Q_A_LAYERNORM_W.to_vec(),
380            q_b_proj: wm(
381                &MLA_Q_B_PROJ,
382                NUM_HEADS * (QK_NOPE_HEAD_DIM + QK_ROPE_HEAD_DIM),
383                Q_LORA_RANK,
384            ),
385            kv_a_proj_with_mqa: wm(&MLA_KV_A_PROJ, KV_LORA_RANK + QK_ROPE_HEAD_DIM, HIDDEN_SIZE),
386            kv_a_layernorm: MLA_KV_A_LAYERNORM_W.to_vec(),
387            kv_b_proj: wm(
388                &MLA_KV_B_PROJ,
389                NUM_HEADS * (QK_NOPE_HEAD_DIM + V_HEAD_DIM),
390                KV_LORA_RANK,
391            ),
392            o_proj: wm(&MLA_O_PROJ, HIDDEN_SIZE, NUM_HEADS * V_HEAD_DIM),
393            g_proj: Some(wm(&MLA_G_PROJ, NUM_HEADS * V_HEAD_DIM, HIDDEN_SIZE)),
394        }
395    }
396
397    #[test]
398    fn matches_independent_python_reference_across_three_decode_steps() {
399        // With `cfg().rope == None` (Kimi K3's real, rope-less path),
400        // this also serves as the regression guard required before ever
401        // touching `mla_forward_token` to add optional RoPE support --
402        // these golden values and this cfg() are completely unchanged
403        // from before that change, so a passing test here proves the
404        // `rope: None` path is still byte-for-byte what it always was.
405        let weights = make_weights();
406        let cfg = cfg();
407        assert!(
408            cfg.rope.is_none(),
409            "this test's whole point is pinning the rope-less path"
410        );
411        let mut k_cache = Vec::new();
412        let mut v_cache = Vec::new();
413
414        let hiddens = [&MLA_HIDDEN_0[..], &MLA_HIDDEN_1[..], &MLA_HIDDEN_2[..]];
415        let goldens = [
416            &MLA_GOLDEN_OUT_0[..],
417            &MLA_GOLDEN_OUT_1[..],
418            &MLA_GOLDEN_OUT_2[..],
419        ];
420
421        for (pos, (hidden, golden)) in hiddens.iter().zip(goldens.iter()).enumerate() {
422            let out = mla_forward_token(&weights, &cfg, hidden, EPS, &mut k_cache, &mut v_cache);
423            assert_eq!(out.len(), golden.len());
424            for (i, (a, b)) in out.iter().zip(golden.iter()).enumerate() {
425                assert!(
426                    (a - b).abs() < 1e-3,
427                    "position {pos} element {i}: rust={a} python={b}"
428                );
429            }
430        }
431    }
432
433    #[test]
434    fn without_output_gate_skips_the_sigmoid_multiply() {
435        let mut weights = make_weights();
436        weights.g_proj = None;
437        let mut cfg = cfg();
438        cfg.use_output_gate = false;
439        let mut k_cache = Vec::new();
440        let mut v_cache = Vec::new();
441
442        let out = mla_forward_token(
443            &weights,
444            &cfg,
445            &MLA_HIDDEN_0,
446            EPS,
447            &mut k_cache,
448            &mut v_cache,
449        );
450        assert_eq!(out.len(), HIDDEN_SIZE);
451        assert!(out.iter().all(|v| v.is_finite()));
452        // Without gating this must differ from the golden (gated) output.
453        assert!((out[0] - MLA_GOLDEN_OUT_0[0]).abs() > 1e-6);
454    }
455
456    // --- RoPE-enabled path (GLM-5.2's real convention), cross-validated
457    // with an independent Python transcription applying interleaved RoPE
458    // to q_rot/k_rot. No
459    // output gate here (GLM-5.2's real tensor list has none), unlike
460    // the Kimi K3 fixtures above.
461    const ROPE_HIDDEN_SIZE: usize = 8;
462    const ROPE_NUM_HEADS: usize = 2;
463    const ROPE_QK_NOPE_HEAD_DIM: usize = 4;
464    const ROPE_QK_ROPE_HEAD_DIM: usize = 4;
465    const ROPE_KV_LORA_RANK: usize = 4;
466    const ROPE_Q_LORA_RANK: usize = 6;
467    const ROPE_V_HEAD_DIM: usize = 3;
468    const ROPE_THETA: f32 = 10000.0;
469
470    // Generated by an independent Python reference -- do not hand-edit.
471    const MLA_ROPE_Q_A_PROJ: [f32; 48] = [
472        -0.0282433, -0.200786, 0.229784, 0.162609, 0.161474, 0.190923, -0.28271, -0.259493,
473        -0.299452, -0.445281, 0.438341, 0.223402, -0.114472, 0.108293, 0.138669, -0.0983588,
474        -0.139929, -0.287077, 0.233259, -0.0924404, -0.48316, 0.236404, 0.487659, -0.22182,
475        -0.425881, 0.474637, 0.224366, 0.227792, 0.155548, 0.139447, -0.0826791, -0.435336,
476        0.438776, -0.328727, 0.415205, -0.280294, 0.25502, -0.264693, -0.374199, 0.626933,
477        0.175746, 0.198436, -0.0882081, -0.131098, 0.416386, 0.0938253, -0.086288, -0.173465,
478    ];
479    const MLA_ROPE_Q_A_LAYERNORM_W: [f32; 6] =
480        [0.983826, 0.879749, 1.11838, 1.05149, 1.1818, 0.834347];
481    const MLA_ROPE_Q_B_PROJ: [f32; 96] = [
482        0.291994,
483        -0.728247,
484        -0.316577,
485        0.0813452,
486        -0.266261,
487        0.389674,
488        0.0833719,
489        0.351552,
490        0.356938,
491        0.350334,
492        -0.0473987,
493        -0.0266404,
494        -0.169264,
495        0.0701104,
496        0.0207743,
497        0.44759,
498        0.372409,
499        0.283663,
500        0.161893,
501        0.0691206,
502        0.164776,
503        -0.159844,
504        0.244357,
505        0.254148,
506        0.781266,
507        -0.0410461,
508        0.00448047,
509        0.167438,
510        0.134256,
511        -0.117364,
512        0.613949,
513        -0.207111,
514        0.42746,
515        0.45351,
516        0.237126,
517        -0.50974,
518        0.328859,
519        -0.250491,
520        -0.356138,
521        0.122879,
522        0.254109,
523        0.120117,
524        -0.244618,
525        0.090442,
526        0.572282,
527        -0.175117,
528        0.150304,
529        0.127176,
530        -0.230927,
531        -0.181049,
532        0.0503238,
533        -0.252932,
534        -0.00813607,
535        -0.169141,
536        0.178562,
537        -0.172518,
538        -0.163208,
539        -0.286795,
540        0.358209,
541        0.355661,
542        -0.0321808,
543        0.025399,
544        -0.227651,
545        -0.0153813,
546        -0.0254572,
547        -0.364581,
548        -0.450488,
549        0.155816,
550        0.0033226,
551        0.481021,
552        -0.000260049,
553        -0.230117,
554        -0.0422523,
555        0.269254,
556        -0.225551,
557        -0.265757,
558        -0.192519,
559        -0.300859,
560        -0.152023,
561        0.31445,
562        -0.229592,
563        -0.417754,
564        -0.219984,
565        0.0230321,
566        0.162062,
567        -0.162489,
568        -0.504785,
569        0.117479,
570        -0.152083,
571        0.203557,
572        -0.232979,
573        -0.537171,
574        -0.131909,
575        -0.0782392,
576        0.187798,
577        -0.364894,
578    ];
579    const MLA_ROPE_KV_A_PROJ: [f32; 64] = [
580        -0.0108888,
581        0.122853,
582        -0.388147,
583        -0.320502,
584        0.288834,
585        0.0587081,
586        0.0027565,
587        -0.0303023,
588        0.252204,
589        0.103756,
590        -0.563955,
591        0.539224,
592        -0.515732,
593        0.475067,
594        -0.179422,
595        0.512039,
596        0.564391,
597        -0.309453,
598        0.178157,
599        0.4829,
600        0.304922,
601        -0.327155,
602        0.235531,
603        -0.223351,
604        0.113775,
605        -0.326219,
606        0.129363,
607        0.343847,
608        -0.555633,
609        -0.00371874,
610        -0.480022,
611        0.0622793,
612        0.121396,
613        0.902273,
614        -0.271857,
615        -0.0787809,
616        -0.148056,
617        -0.246381,
618        -0.388923,
619        -0.326308,
620        0.754771,
621        -0.188557,
622        0.157124,
623        -0.242718,
624        -0.196856,
625        0.168396,
626        0.116464,
627        0.406121,
628        -0.0524445,
629        -0.226537,
630        -0.220791,
631        -0.42747,
632        -0.109609,
633        0.327875,
634        0.238249,
635        0.262922,
636        0.0603609,
637        0.259383,
638        -0.125942,
639        0.0253563,
640        -0.672037,
641        -0.0822506,
642        -0.313883,
643        -0.079927,
644    ];
645    const MLA_ROPE_KV_A_LAYERNORM_W: [f32; 4] = [1.11964, 1.00794, 0.927425, 0.974059];
646    const MLA_ROPE_KV_B_PROJ: [f32; 56] = [
647        0.152348, 0.223828, 0.104805, 0.146991, 0.167048, 0.0596911, 0.13428, -0.165432, 0.185469,
648        -0.438659, 0.0505363, -0.378193, -0.367254, 0.291605, 0.25605, -0.278039, 0.32183,
649        0.077576, 0.657456, -0.210592, -0.241762, -0.113545, 0.305935, -0.142537, -0.131723,
650        -0.0698308, -0.231153, 0.0832406, -0.184562, -0.395013, -0.434206, 0.643208, -0.0451786,
651        0.016892, -0.503596, 0.38556, 0.0672434, -0.345241, 0.261026, 0.113356, 0.195666, 0.124068,
652        0.169155, 0.0241996, 0.0460725, -0.199187, 0.525018, 0.704311, 0.214609, 0.155865,
653        -0.13945, -0.361349, 0.200727, 0.669041, -0.35694, 0.405635,
654    ];
655    const MLA_ROPE_O_PROJ: [f32; 48] = [
656        0.156636,
657        0.435755,
658        0.254836,
659        -0.28038,
660        -0.00686566,
661        0.254093,
662        0.13879,
663        0.298608,
664        -0.654407,
665        0.544604,
666        -0.40823,
667        0.557235,
668        -0.401607,
669        0.0393622,
670        -0.0108063,
671        -0.425778,
672        -0.0790213,
673        0.183181,
674        0.770074,
675        0.431033,
676        -0.191665,
677        -0.321149,
678        -0.243943,
679        -0.0704616,
680        0.180775,
681        -0.216385,
682        0.0824125,
683        -0.320591,
684        -0.182163,
685        -0.0257085,
686        -0.0184709,
687        0.292862,
688        -0.215734,
689        0.652291,
690        -0.0461593,
691        0.249014,
692        -0.205017,
693        0.0634068,
694        0.087137,
695        0.529326,
696        0.477227,
697        0.171185,
698        0.0539693,
699        0.0189488,
700        -0.138254,
701        -0.173556,
702        0.65771,
703        -0.0616593,
704    ];
705
706    const MLA_ROPE_HIDDEN_0: [f32; 8] = [
707        -0.145978, -0.0867699, 0.281822, -0.765789, -0.590168, -0.252274, -0.397543, 1.30624,
708    ];
709    const MLA_ROPE_HIDDEN_1: [f32; 8] = [
710        0.0788124, -0.477519, -0.142939, 0.0694206, 0.639385, 0.515165, -0.118041, -0.755483,
711    ];
712    const MLA_ROPE_HIDDEN_2: [f32; 8] = [
713        -0.0406029, 0.440107, 0.194938, 0.0549023, 0.270816, 0.624518, 0.0925645, 0.0192617,
714    ];
715
716    const MLA_ROPE_GOLDEN_OUT_0: [f32; 8] = [
717        0.0871067, -0.172861, 0.884245, -1.2416, 0.0539017, -0.224481, 0.230219, -0.137034,
718    ];
719    const MLA_ROPE_GOLDEN_OUT_1: [f32; 8] = [
720        -0.24112, -0.298445, -0.0592754, -0.296603, -0.0395015, -0.0695785, 0.0639684, -0.0166364,
721    ];
722    const MLA_ROPE_GOLDEN_OUT_2: [f32; 8] = [
723        0.0310026, -0.604048, 0.119056, 0.10382, 0.137893, -0.456617, -0.176747, 0.347884,
724    ];
725
726    fn rope_cfg() -> MlaConfig {
727        MlaConfig {
728            num_heads: ROPE_NUM_HEADS,
729            q_lora_rank: ROPE_Q_LORA_RANK,
730            kv_lora_rank: ROPE_KV_LORA_RANK,
731            qk_nope_head_dim: ROPE_QK_NOPE_HEAD_DIM,
732            qk_rope_head_dim: ROPE_QK_ROPE_HEAD_DIM,
733            v_head_dim: ROPE_V_HEAD_DIM,
734            use_output_gate: false,
735            rope: Some(MlaRopeConfig { theta: ROPE_THETA }),
736        }
737    }
738
739    fn make_rope_weights() -> MlaAttnWeights {
740        MlaAttnWeights {
741            q_a_proj: wm(&MLA_ROPE_Q_A_PROJ, ROPE_Q_LORA_RANK, ROPE_HIDDEN_SIZE),
742            q_a_layernorm: MLA_ROPE_Q_A_LAYERNORM_W.to_vec(),
743            q_b_proj: wm(
744                &MLA_ROPE_Q_B_PROJ,
745                ROPE_NUM_HEADS * (ROPE_QK_NOPE_HEAD_DIM + ROPE_QK_ROPE_HEAD_DIM),
746                ROPE_Q_LORA_RANK,
747            ),
748            kv_a_proj_with_mqa: wm(
749                &MLA_ROPE_KV_A_PROJ,
750                ROPE_KV_LORA_RANK + ROPE_QK_ROPE_HEAD_DIM,
751                ROPE_HIDDEN_SIZE,
752            ),
753            kv_a_layernorm: MLA_ROPE_KV_A_LAYERNORM_W.to_vec(),
754            kv_b_proj: wm(
755                &MLA_ROPE_KV_B_PROJ,
756                ROPE_NUM_HEADS * (ROPE_QK_NOPE_HEAD_DIM + ROPE_V_HEAD_DIM),
757                ROPE_KV_LORA_RANK,
758            ),
759            o_proj: wm(
760                &MLA_ROPE_O_PROJ,
761                ROPE_HIDDEN_SIZE,
762                ROPE_NUM_HEADS * ROPE_V_HEAD_DIM,
763            ),
764            g_proj: None,
765        }
766    }
767
768    #[test]
769    fn rope_enabled_matches_independent_python_reference_across_three_decode_steps() {
770        let weights = make_rope_weights();
771        let cfg = rope_cfg();
772        let mut k_cache = Vec::new();
773        let mut v_cache = Vec::new();
774
775        let hiddens = [
776            &MLA_ROPE_HIDDEN_0[..],
777            &MLA_ROPE_HIDDEN_1[..],
778            &MLA_ROPE_HIDDEN_2[..],
779        ];
780        let goldens = [
781            &MLA_ROPE_GOLDEN_OUT_0[..],
782            &MLA_ROPE_GOLDEN_OUT_1[..],
783            &MLA_ROPE_GOLDEN_OUT_2[..],
784        ];
785
786        for (pos, (hidden, golden)) in hiddens.iter().zip(goldens.iter()).enumerate() {
787            let out = mla_forward_token(&weights, &cfg, hidden, EPS, &mut k_cache, &mut v_cache);
788            assert_eq!(out.len(), golden.len());
789            for (i, (a, b)) in out.iter().zip(golden.iter()).enumerate() {
790                assert!(
791                    (a - b).abs() < 1e-3,
792                    "position {pos} element {i}: rust={a} python={b}"
793                );
794            }
795        }
796    }
797
798    #[test]
799    fn rope_enabled_output_changes_with_position() {
800        // A direct, position-dependence check independent of the golden
801        // values above: feed the *same* hidden state at two different
802        // positions (by pre-filling the cache with a dummy earlier
803        // position first) and confirm the outputs differ -- RoPE is the
804        // only thing in this function that makes output depend on
805        // absolute position rather than just on content, so if this
806        // ever failed it would mean RoPE silently stopped being applied.
807        let weights = make_rope_weights();
808        let cfg = rope_cfg();
809
810        let mut k_cache_pos0 = Vec::new();
811        let mut v_cache_pos0 = Vec::new();
812        let out_pos0 = mla_forward_token(
813            &weights,
814            &cfg,
815            &MLA_ROPE_HIDDEN_0,
816            EPS,
817            &mut k_cache_pos0,
818            &mut v_cache_pos0,
819        );
820
821        // Prime the cache with one earlier (dummy) position so the next
822        // call happens at position 1 instead of 0, then feed the exact
823        // same hidden state as above.
824        let mut k_cache_pos1 = Vec::new();
825        let mut v_cache_pos1 = Vec::new();
826        mla_forward_token(
827            &weights,
828            &cfg,
829            &MLA_ROPE_HIDDEN_1,
830            EPS,
831            &mut k_cache_pos1,
832            &mut v_cache_pos1,
833        );
834        let out_pos1 = mla_forward_token(
835            &weights,
836            &cfg,
837            &MLA_ROPE_HIDDEN_0,
838            EPS,
839            &mut k_cache_pos1,
840            &mut v_cache_pos1,
841        );
842
843        assert_eq!(out_pos0.len(), out_pos1.len());
844        let differs = out_pos0
845            .iter()
846            .zip(out_pos1.iter())
847            .any(|(a, b)| (a - b).abs() > 1e-4);
848        assert!(
849            differs,
850            "identical hidden state at two different positions must produce \
851             different output when RoPE is enabled"
852        );
853    }
854
855    #[test]
856    fn rope_disabled_config_is_unaffected_by_position_change() {
857        // The mirror-image check: with `rope: None` (Kimi K3's real
858        // path), the *only* thing that should make output vary across
859        // calls is the growing KV cache/causal history -- feeding the
860        // identical hidden state as the very first token in two
861        // otherwise-empty caches must give byte-identical output
862        // regardless of "which call this was," since there is no
863        // position-dependent rotation at all.
864        let weights = make_weights();
865        let cfg = cfg();
866        assert!(cfg.rope.is_none());
867
868        let mut k_cache_a = Vec::new();
869        let mut v_cache_a = Vec::new();
870        let out_a = mla_forward_token(
871            &weights,
872            &cfg,
873            &MLA_HIDDEN_0,
874            EPS,
875            &mut k_cache_a,
876            &mut v_cache_a,
877        );
878
879        let mut k_cache_b = Vec::new();
880        let mut v_cache_b = Vec::new();
881        let out_b = mla_forward_token(
882            &weights,
883            &cfg,
884            &MLA_HIDDEN_0,
885            EPS,
886            &mut k_cache_b,
887            &mut v_cache_b,
888        );
889
890        for (a, b) in out_a.iter().zip(out_b.iter()) {
891            assert_eq!(a.to_bits(), b.to_bits());
892        }
893    }
894}