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