Skip to main content

memra_engine/
mla.rs

1//! MLA (multi-head latent attention, DeepSeek lineage / GLM-5 "MLA-256") — CPU f32 reference.
2//!
3//! Increment 1 of the GLM-5.2 bring-up lane (`research/mla-bringup-20260801/DESIGN.md`).
4//! This module pins the decode-path math BEFORE any kernel work: both the naive form
5//! (decompress the latent cache to per-head K/V, then attend — vLLM "forward_mha") and the
6//! absorbed form (fold W_UK into the query, attend in latent space as MQA, decompress the
7//! output through W_UV — vLLM "forward_mqa", llama.cpp glm-dsa.cpp). The unit tests prove the
8//! two forms agree to f32 tolerance on random inputs across shapes (t=1 decode and small
9//! causal prefill), including full GLM-5.2 dims (64 heads, nope 192, rope 64, v 256, rank 512).
10//!
11//! Also pinned here: the interleaved ("NORM", `rope_interleave: true`) vs NEOX rope pairing and
12//! the load-time permutation that maps one onto the other (DESIGN.md §1.4) — memra only ships a
13//! NEOX kernel, GLM-5.2 needs NORM, and the permutation trick lets the existing kernel serve.
14//!
15//! Everything is plain CPU f32, no CUDA, no engine deps: this is the permanent oracle for the
16//! MLA kernel family's maxdiff gates.
17
18/// MLA head geometry. GLM-5.2: n_head=64, d_nope=192, d_rope=64, d_v=256, kv_rank=512.
19#[derive(Clone, Copy, Debug)]
20pub struct MlaDims {
21    pub n_head: usize,
22    /// qk nope head dim (P)
23    pub d_nope: usize,
24    /// qk rope head dim (R); latent cache row = kv_rank + d_rope
25    pub d_rope: usize,
26    /// v head dim (V)
27    pub d_v: usize,
28    /// kv lora rank (Lkv)
29    pub kv_rank: usize,
30}
31
32impl MlaDims {
33    pub const GLM52: MlaDims = MlaDims {
34        n_head: 64,
35        d_nope: 192,
36        d_rope: 64,
37        d_v: 256,
38        kv_rank: 512,
39    };
40
41    /// Softmax scale: 1/sqrt(d_nope + d_rope) — the ORIGINAL qk head dim (256 for GLM-5.2),
42    /// NOT the absorbed width (576). llama.cpp glm-dsa.cpp `kq_scale` with mscale=1 (no yarn).
43    pub fn scale(&self) -> f32 {
44        1.0 / ((self.d_nope + self.d_rope) as f32).sqrt()
45    }
46}
47
48/// Inputs shared by both forms. Rope is already applied to `q_pe`/`k_pe` (it happens upstream
49/// of the attention core and is identical in both forms). `c_kv` is already RMS-normed.
50///
51/// Layouts (row-major):
52///   q_nope: [t_q][n_head][d_nope]
53///   q_pe:   [t_q][n_head][d_rope]
54///   c_kv:   [t_kv][kv_rank]           — the latent KV cache (one row per token, all heads)
55///   k_pe:   [t_kv][d_rope]            — decoupled rope key (one per token, all heads)
56///   w_uk:   [n_head][d_nope][kv_rank] — k_nope_h = w_uk[h] · c_kv
57///   w_uv:   [n_head][d_v][kv_rank]    — v_h      = w_uv[h] · c_kv
58///
59/// The queries occupy the LAST `t_q` positions of the cache (decode/prefill convention:
60/// their own rows are already appended). Causal: query i attends to cache rows
61/// 0 ..= (t_kv - t_q + i).
62pub struct MlaInputs<'a> {
63    pub q_nope: &'a [f32],
64    pub q_pe: &'a [f32],
65    pub c_kv: &'a [f32],
66    pub k_pe: &'a [f32],
67    pub w_uk: &'a [f32],
68    pub w_uv: &'a [f32],
69    pub t_q: usize,
70    pub t_kv: usize,
71}
72
73fn check_shapes(d: &MlaDims, x: &MlaInputs) {
74    assert_eq!(x.q_nope.len(), x.t_q * d.n_head * d.d_nope, "q_nope shape");
75    assert_eq!(x.q_pe.len(), x.t_q * d.n_head * d.d_rope, "q_pe shape");
76    assert_eq!(x.c_kv.len(), x.t_kv * d.kv_rank, "c_kv shape");
77    assert_eq!(x.k_pe.len(), x.t_kv * d.d_rope, "k_pe shape");
78    assert_eq!(x.w_uk.len(), d.n_head * d.d_nope * d.kv_rank, "w_uk shape");
79    assert_eq!(x.w_uv.len(), d.n_head * d.d_v * d.kv_rank, "w_uv shape");
80    assert!(x.t_q <= x.t_kv, "queries must be a suffix of the cache");
81}
82
83/// In-place softmax with max-subtraction over `s[..n]`.
84fn softmax(s: &mut [f32]) {
85    let m = s.iter().copied().fold(f32::NEG_INFINITY, f32::max);
86    let mut sum = 0.0f32;
87    for v in s.iter_mut() {
88        *v = (*v - m).exp();
89        sum += *v;
90    }
91    let inv = 1.0 / sum;
92    for v in s.iter_mut() {
93        *v *= inv;
94    }
95}
96
97/// Naive form: decompress k_nope/v per head from the latent cache, attend at qk dim
98/// d_nope+d_rope, output [t_q][n_head][d_v]. Quadratic decompression cost — prefill-only
99/// shape in production; here it is the independent oracle.
100pub fn mla_attend_naive(d: &MlaDims, x: &MlaInputs) -> Vec<f32> {
101    check_shapes(d, x);
102    let (nh, dn, dr, dv, r) = (d.n_head, d.d_nope, d.d_rope, d.d_v, d.kv_rank);
103    let scale = d.scale();
104    let mut out = vec![0.0f32; x.t_q * nh * dv];
105
106    // Decompress the whole cache per head: k_nope[t][dn], v[t][dv].
107    let mut k_nope = vec![0.0f32; x.t_kv * dn];
108    let mut v = vec![0.0f32; x.t_kv * dv];
109    let mut scores = vec![0.0f32; x.t_kv];
110    for h in 0..nh {
111        let wuk = &x.w_uk[h * dn * r..(h + 1) * dn * r];
112        let wuv = &x.w_uv[h * dv * r..(h + 1) * dv * r];
113        for t in 0..x.t_kv {
114            let c = &x.c_kv[t * r..(t + 1) * r];
115            for p in 0..dn {
116                let row = &wuk[p * r..(p + 1) * r];
117                let mut acc = 0.0f32;
118                for l in 0..r {
119                    acc += row[l] * c[l];
120                }
121                k_nope[t * dn + p] = acc;
122            }
123            for j in 0..dv {
124                let row = &wuv[j * r..(j + 1) * r];
125                let mut acc = 0.0f32;
126                for l in 0..r {
127                    acc += row[l] * c[l];
128                }
129                v[t * dv + j] = acc;
130            }
131        }
132        for i in 0..x.t_q {
133            let visible = x.t_kv - x.t_q + i + 1; // causal horizon for query i
134            let qn = &x.q_nope[(i * nh + h) * dn..(i * nh + h + 1) * dn];
135            let qp = &x.q_pe[(i * nh + h) * dr..(i * nh + h + 1) * dr];
136            for t in 0..visible {
137                let mut s = 0.0f32;
138                let kn = &k_nope[t * dn..(t + 1) * dn];
139                for p in 0..dn {
140                    s += qn[p] * kn[p];
141                }
142                let kp = &x.k_pe[t * dr..(t + 1) * dr];
143                for p in 0..dr {
144                    s += qp[p] * kp[p];
145                }
146                scores[t] = s * scale;
147            }
148            softmax(&mut scores[..visible]);
149            let o = &mut out[(i * nh + h) * dv..(i * nh + h + 1) * dv];
150            for t in 0..visible {
151                let p = scores[t];
152                let vt = &v[t * dv..(t + 1) * dv];
153                for j in 0..dv {
154                    o[j] += p * vt[j];
155                }
156            }
157        }
158    }
159    out
160}
161
162/// Absorbed form (decode form): q̃_h = w_uk[h]ᵀ·q_nope_h (rank-space, kv_rank wide), scores are
163/// MQA dots against the raw latent rows [c_kv | k_pe] (kv_rank + d_rope wide), the attention
164/// output is accumulated in latent space (kv_rank wide) and decompressed once through w_uv.
165/// Identical result to `mla_attend_naive` by associativity + linearity (DESIGN.md §1.3).
166pub fn mla_attend_absorbed(d: &MlaDims, x: &MlaInputs) -> Vec<f32> {
167    check_shapes(d, x);
168    let (nh, dn, dr, dv, r) = (d.n_head, d.d_nope, d.d_rope, d.d_v, d.kv_rank);
169    let scale = d.scale();
170    let mut out = vec![0.0f32; x.t_q * nh * dv];
171
172    let mut q_lat = vec![0.0f32; r]; // absorbed query, rank space
173    let mut o_lat = vec![0.0f32; r]; // attention output, latent space
174    let mut scores = vec![0.0f32; x.t_kv];
175    for h in 0..nh {
176        let wuk = &x.w_uk[h * dn * r..(h + 1) * dn * r];
177        let wuv = &x.w_uv[h * dv * r..(h + 1) * dv * r];
178        for i in 0..x.t_q {
179            let visible = x.t_kv - x.t_q + i + 1;
180            let qn = &x.q_nope[(i * nh + h) * dn..(i * nh + h + 1) * dn];
181            let qp = &x.q_pe[(i * nh + h) * dr..(i * nh + h + 1) * dr];
182            // absorb: q_lat[l] = sum_p q_nope[p] * w_uk[h][p][l]
183            q_lat.iter_mut().for_each(|v| *v = 0.0);
184            for p in 0..dn {
185                let row = &wuk[p * r..(p + 1) * r];
186                let qv = qn[p];
187                for l in 0..r {
188                    q_lat[l] += qv * row[l];
189                }
190            }
191            // MQA scores against the 576-wide latent rows
192            for t in 0..visible {
193                let c = &x.c_kv[t * r..(t + 1) * r];
194                let mut s = 0.0f32;
195                for l in 0..r {
196                    s += q_lat[l] * c[l];
197                }
198                let kp = &x.k_pe[t * dr..(t + 1) * dr];
199                for p in 0..dr {
200                    s += qp[p] * kp[p];
201                }
202                scores[t] = s * scale;
203            }
204            softmax(&mut scores[..visible]);
205            // latent-space AV
206            o_lat.iter_mut().for_each(|v| *v = 0.0);
207            for t in 0..visible {
208                let p = scores[t];
209                let c = &x.c_kv[t * r..(t + 1) * r];
210                for l in 0..r {
211                    o_lat[l] += p * c[l];
212                }
213            }
214            // decompress once: out[j] = sum_l w_uv[h][j][l] * o_lat[l]
215            let o = &mut out[(i * nh + h) * dv..(i * nh + h + 1) * dv];
216            for j in 0..dv {
217                let row = &wuv[j * r..(j + 1) * r];
218                let mut acc = 0.0f32;
219                for l in 0..r {
220                    acc += row[l] * o_lat[l];
221                }
222                o[j] = acc;
223            }
224        }
225    }
226    out
227}
228
229// ---------------------------------------------------------------------------
230// RoPE: GLM-5.2 is `rope_interleave: true` == llama.cpp LLAMA_ROPE_TYPE_NORM.
231// memra ships only NEOX pairing; the permutation below maps NORM onto NEOX at
232// weight-load time (DESIGN.md §1.4). Both variants + the permutation live here
233// so the equivalence is a pinned, tested fact.
234// ---------------------------------------------------------------------------
235
236/// Interleaved ("NORM") rope over the first `n_dims` of `x`: pair (x[2j], x[2j+1]) rotated by
237/// theta_j = pos * base^(-2j/n_dims). Matches ggml GGML_ROPE_TYPE_NORM / HF interleaved.
238pub fn rope_interleaved(x: &mut [f32], n_dims: usize, pos: f32, base: f32) {
239    let half = n_dims / 2;
240    let theta_scale = base.powf(-2.0 / n_dims as f32);
241    let mut theta = pos;
242    for j in 0..half {
243        let (sin, cos) = theta.sin_cos();
244        let a = x[2 * j];
245        let b = x[2 * j + 1];
246        x[2 * j] = a * cos - b * sin;
247        x[2 * j + 1] = a * sin + b * cos;
248        theta *= theta_scale;
249    }
250}
251
252/// NEOX rope over the first `n_dims` of `x`: pair (x[j], x[j+half]) rotated by the same
253/// theta_j sequence. Matches memra's `rope_neox_f32` (kernels.cu) angle recurrence.
254pub fn rope_neox(x: &mut [f32], n_dims: usize, pos: f32, base: f32) {
255    let half = n_dims / 2;
256    let theta_scale = base.powf(-2.0 / n_dims as f32);
257    let mut theta = pos;
258    for j in 0..half {
259        let (sin, cos) = theta.sin_cos();
260        let a = x[j];
261        let b = x[j + half];
262        x[j] = a * cos - b * sin;
263        x[j + half] = a * sin + b * cos;
264        theta *= theta_scale;
265    }
266}
267
268/// The load-time permutation: source (interleaved-layout) index -> NEOX-layout index.
269/// pi(2j) = j, pi(2j+1) = j + n_dims/2. Applied to the rope rows of wq_b / wkv_a_mqa at load,
270/// it makes the existing NEOX kernel compute exactly the interleaved rotation (dot-product
271/// consumers only — which is all of them).
272pub fn norm_to_neox_perm(n_dims: usize) -> Vec<usize> {
273    let half = n_dims / 2;
274    let mut p = vec![0usize; n_dims];
275    for j in 0..half {
276        p[2 * j] = j;
277        p[2 * j + 1] = j + half;
278    }
279    p
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    /// xorshift64* — deterministic, no external crates.
287    struct Rng(u64);
288    impl Rng {
289        fn next_f32(&mut self) -> f32 {
290            self.0 ^= self.0 << 13;
291            self.0 ^= self.0 >> 7;
292            self.0 ^= self.0 << 17;
293            let v = (self.0.wrapping_mul(0x2545F4914F6CDD1D) >> 40) as u32;
294            (v as f32 / (1u32 << 24) as f32) * 2.0 - 1.0 // uniform [-1, 1)
295        }
296        fn fill(&mut self, n: usize, scale: f32) -> Vec<f32> {
297            (0..n).map(|_| self.next_f32() * scale).collect()
298        }
299    }
300
301    fn maxdiff(a: &[f32], b: &[f32]) -> f32 {
302        assert_eq!(a.len(), b.len());
303        a.iter()
304            .zip(b)
305            .map(|(x, y)| (x - y).abs())
306            .fold(0.0f32, f32::max)
307    }
308    fn maxabs(a: &[f32]) -> f32 {
309        a.iter().map(|x| x.abs()).fold(0.0f32, f32::max)
310    }
311
312    /// Build random inputs at unit-ish scale: weights ~ 1/sqrt(rank) so decompressed values and
313    /// scores stay O(1) and the f32 tolerance is meaningful.
314    fn random_case(
315        d: &MlaDims,
316        t_q: usize,
317        t_kv: usize,
318        seed: u64,
319    ) -> (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>) {
320        let mut rng = Rng(seed | 1);
321        let ws = 1.0 / (d.kv_rank as f32).sqrt();
322        (
323            rng.fill(t_q * d.n_head * d.d_nope, 1.0),
324            rng.fill(t_q * d.n_head * d.d_rope, 1.0),
325            rng.fill(t_kv * d.kv_rank, 1.0),
326            rng.fill(t_kv * d.d_rope, 1.0),
327            rng.fill(d.n_head * d.d_nope * d.kv_rank, ws),
328            rng.fill(d.n_head * d.d_v * d.kv_rank, ws),
329        )
330    }
331
332    fn run_case(d: &MlaDims, t_q: usize, t_kv: usize, seed: u64, tol: f32) {
333        let (q_nope, q_pe, c_kv, k_pe, w_uk, w_uv) = random_case(d, t_q, t_kv, seed);
334        let x = MlaInputs {
335            q_nope: &q_nope,
336            q_pe: &q_pe,
337            c_kv: &c_kv,
338            k_pe: &k_pe,
339            w_uk: &w_uk,
340            w_uv: &w_uv,
341            t_q,
342            t_kv,
343        };
344        let naive = mla_attend_naive(d, &x);
345        let absorbed = mla_attend_absorbed(d, &x);
346        let md = maxdiff(&naive, &absorbed);
347        let scale = maxabs(&naive).max(1.0);
348        assert!(
349            md <= tol * scale,
350            "naive vs absorbed disagree: maxdiff {md:.3e} (scale {scale:.3e}, rel {:.3e}) \
351             dims {d:?} t_q {t_q} t_kv {t_kv} seed {seed}",
352            md / scale
353        );
354        // sanity: outputs are finite and not trivially zero
355        assert!(naive.iter().all(|v| v.is_finite()));
356        assert!(maxabs(&naive) > 1e-6);
357    }
358
359    #[test]
360    fn naive_equals_absorbed_decode_t1() {
361        // t=1 decode against a populated cache, several synthetic shapes + seeds.
362        let shapes = [
363            MlaDims {
364                n_head: 4,
365                d_nope: 24,
366                d_rope: 8,
367                d_v: 32,
368                kv_rank: 64,
369            },
370            MlaDims {
371                n_head: 2,
372                d_nope: 16,
373                d_rope: 16,
374                d_v: 16,
375                kv_rank: 32,
376            },
377            // GLM-5.2 ratio at 1/8 scale: nope 24, rope 8, v 32, rank 64 handled above;
378            // an asymmetric case where d_v > d_nope (the GLM-5.2 signature, v 256 > nope 192):
379            MlaDims {
380                n_head: 3,
381                d_nope: 12,
382                d_rope: 4,
383                d_v: 20,
384                kv_rank: 48,
385            },
386        ];
387        for (i, d) in shapes.iter().enumerate() {
388            for seed in [7, 1234, 0xB1E55ED] {
389                run_case(d, 1, 17, seed + i as u64, 1e-5);
390            }
391        }
392    }
393
394    #[test]
395    fn naive_equals_absorbed_prefill_causal() {
396        // small prefill: t_q new tokens over t_kv-t_q past tokens, causal horizon per query.
397        let d = MlaDims {
398            n_head: 4,
399            d_nope: 24,
400            d_rope: 8,
401            d_v: 32,
402            kv_rank: 64,
403        };
404        run_case(&d, 5, 9, 42, 1e-5);
405        run_case(&d, 8, 8, 43, 1e-5); // pure prefill, no past
406        let d2 = MlaDims {
407            n_head: 2,
408            d_nope: 16,
409            d_rope: 16,
410            d_v: 16,
411            kv_rank: 32,
412        };
413        run_case(&d2, 3, 11, 44, 1e-5);
414    }
415
416    #[test]
417    fn naive_equals_absorbed_glm52_full_dims() {
418        // Full GLM-5.2 geometry (64 heads, 192/64/256, rank 512) — decode t=1, T=8.
419        // Wider accumulations (576-dot, rank-512 decompress) ⇒ slightly looser f32 tolerance.
420        run_case(&MlaDims::GLM52, 1, 8, 20260801, 1e-4);
421    }
422
423    #[test]
424    fn rope_norm_equals_permuted_neox() {
425        // DESIGN.md §1.4: permuting the rope dims at load time (pi(2j)=j, pi(2j+1)=j+half)
426        // makes the NEOX kernel compute the interleaved ("NORM") rotation. Verify:
427        //   permute(rope_interleaved(x)) == rope_neox(permute(x))
428        // for the GLM-5.2 rope width (64) at several positions, and that dot products between
429        // two identically-permuted roped vectors match the un-permuted interleaved dots.
430        let n_dims = 64;
431        let base = 8_000_000.0f32; // GLM-5.2 rope_theta
432        let perm = norm_to_neox_perm(n_dims);
433        let mut rng = Rng(99);
434        for pos in [0.0f32, 1.0, 17.0, 4096.0, 1_000_000.0] {
435            let x0: Vec<f32> = (0..n_dims).map(|_| rng.next_f32()).collect();
436            let y0: Vec<f32> = (0..n_dims).map(|_| rng.next_f32()).collect();
437
438            // path A: interleaved rope, then permute
439            let mut xa = x0.clone();
440            rope_interleaved(&mut xa, n_dims, pos, base);
441            let mut xa_p = vec![0.0f32; n_dims];
442            for (src, &dst) in perm.iter().enumerate() {
443                xa_p[dst] = xa[src];
444            }
445            // path B: permute, then neox rope
446            let mut xb = vec![0.0f32; n_dims];
447            for (src, &dst) in perm.iter().enumerate() {
448                xb[dst] = x0[src];
449            }
450            rope_neox(&mut xb, n_dims, pos, base);
451
452            assert!(
453                maxdiff(&xa_p, &xb) <= 1e-6,
454                "perm/rope orders disagree at pos {pos}"
455            );
456
457            // dot-product invariance (what attention actually consumes)
458            let mut ya = y0.clone();
459            rope_interleaved(&mut ya, n_dims, pos, base);
460            let dot_norm: f32 = xa.iter().zip(&ya).map(|(a, b)| a * b).sum();
461
462            let mut yb = vec![0.0f32; n_dims];
463            for (src, &dst) in perm.iter().enumerate() {
464                yb[dst] = y0[src];
465            }
466            rope_neox(&mut yb, n_dims, pos, base);
467            let dot_neox: f32 = xb.iter().zip(&yb).map(|(a, b)| a * b).sum();
468
469            assert!(
470                (dot_norm - dot_neox).abs() <= 1e-4 * dot_norm.abs().max(1.0),
471                "roped dot products diverge at pos {pos}: {dot_norm} vs {dot_neox}"
472            );
473        }
474    }
475}