metrale_model_arch/glm5next_kda_ref/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: CPU reference for GLM-5.3-Flash KDA (Kimi Delta Attention).
4//!
5//! Owner: model-arch (GLM-5.3-Flash KDA reference).
6//! Invariants:
7//! - Nothing here runs on a GPU or reads a checkpoint; production code does not call it.
8//! - `lower_bound` and `rms_eps` are arguments; the reference has no built-in value for either.
9//!
10//! It is checked against `kda_golden.json`, which `gen_kda_golden.py` produces by calling the
11//! `transformers` `glm5_next` functions (`Glm5NextTextForgetGate`, `Glm5NextTextRMSNormGated`,
12//! `l2norm`, `recurrent_kimi_delta_attention`, `chunk_kimi_delta_attention`).
13//!
14//! KDA differs from the Qwen GDN path (`compute_gdn_gates` in
15//! `kernels/gb10/common/ssm_preprocess.cu`) in four ways:
16//! 1. The decay is per (head, key channel), `[T, H, head_dim]`; GDN writes one value per head.
17//! 2. The decay comes from a low-rank projection, `f_a: hidden -> head_dim`,
18//!    `f_b: head_dim -> H*head_dim`; GDN reads it from the fused `BA` projection.
19//! 3. The gate is bounded, `lower_bound * sigmoid(exp(A_log) * (g + dt_bias))`; GDN computes
20//!    `exp(-exp(A_log) * softplus(a + dt_bias))`.
21//! 4. The output gate is low-rank `g_a`/`g_b`; GDN takes a full-rank `Z` from its fused `QKVZ`
22//!    projection, and a KDA checkpoint has no `Z` tensor.
23//!
24//! # Layout conventions
25//!
26//! * `q`/`k`/`v`/`gate`: `[T, H, D]`, row-major, index `((t * H) + h) * D + d`.
27//! * `beta`: `[T, H]`, index `t * H + h`.
28//! * `dt_bias`: `[H * D]`, per channel.
29//! * `a_log`: `[H]`, per head.
30//! * `state`: `[H, D_k, D_v]`, index `(h * D_k + kd) * D_v + vd`.
31//! * Weights follow the torch `Linear` convention `[out, in]`, row-major.
32
33/// 2026-09-25: KDA geometry. The low-rank width of both `f_a`/`f_b` and `g_a`/`g_b` is
34/// `head_dim`.
35#[derive(Clone, Copy, Debug)]
36pub struct KdaDims {
37    pub hidden: usize,
38    pub heads: usize,
39    pub head_dim: usize,
40    pub tokens: usize,
41}
42
43#[inline]
44fn sigmoid(x: f32) -> f32 {
45    1.0 / (1.0 + (-x).exp())
46}
47
48/// 2026-09-25: `x / sqrt(sum(x^2) + eps)` over the trailing dimension, not `x / max(norm, eps)`;
49/// the two differ for small-norm rows. The GPU `l2_norm_bf16` uses the same form.
50pub fn l2norm_rows(x: &[f32], d: usize, eps: f32) -> Vec<f32> {
51    let mut out = vec![0.0f32; x.len()];
52    for (row_in, row_out) in x.chunks_exact(d).zip(out.chunks_exact_mut(d)) {
53        let inv = 1.0 / (row_in.iter().map(|v| v * v).sum::<f32>() + eps).sqrt();
54        for (o, i) in row_out.iter_mut().zip(row_in) {
55            *o = i * inv;
56        }
57    }
58    out
59}
60
61/// 2026-09-25: `y = x @ w^T` for `x: [m, k]`, `w: [n, k]` (torch `Linear` weight layout), no bias.
62pub fn linear(x: &[f32], m: usize, k: usize, w: &[f32], n: usize) -> Vec<f32> {
63    let mut out = vec![0.0f32; m * n];
64    for row in 0..m {
65        for col in 0..n {
66            let mut acc = 0.0f32;
67            for i in 0..k {
68                acc += x[row * k + i] * w[col * k + i];
69            }
70            out[row * n + col] = acc;
71        }
72    }
73    out
74}
75
76/// 2026-09-25: The bounded GLM forget gate:
77///
78/// `gate[t, h, d] = lower_bound * sigmoid(exp(a_log[h]) * (g_lowrank[t, h*D + d] + dt_bias[h*D + d]))`
79///
80/// `a_log` is per head and `dt_bias` per channel, so they are separate arguments.
81pub fn bounded_gate(
82    g_lowrank: &[f32],
83    dt_bias: &[f32],
84    a_log: &[f32],
85    dims: KdaDims,
86    lower_bound: f32,
87) -> Vec<f32> {
88    let (h_n, d) = (dims.heads, dims.head_dim);
89    let mut out = vec![0.0f32; dims.tokens * h_n * d];
90    for t in 0..dims.tokens {
91        for h in 0..h_n {
92            let decay = a_log[h].exp();
93            for dd in 0..d {
94                let ch = h * d + dd;
95                let g = g_lowrank[t * h_n * d + ch] + dt_bias[ch];
96                out[(t * h_n + h) * d + dd] = lower_bound * sigmoid(decay * g);
97            }
98        }
99    }
100    out
101}
102
103/// 2026-09-25: The unbounded Qwen GDN gate, used by the tests to show the two laws diverge.
104///
105/// `compute_gdn_gates` stores `exp(g)`; this returns `g`, as the KDA recurrence takes the
106/// exponential itself.
107pub fn unbounded_gdn_gate(g_raw: f32, dt_bias: f32, a_log: f32) -> f32 {
108    let x = g_raw + dt_bias;
109    // 2026-09-25: softplus(x), linear above 20.
110    let softplus = if x > 20.0 { x } else { (1.0 + x.exp()).ln() };
111    -a_log.exp() * softplus
112}
113
114/// 2026-09-25: Decode formulation: one token at a time, carrying `state`.
115///
116/// ```text
117/// S <- S * diag(exp(g_t))          decay along the key axis, per channel
118/// delta <- (v_t - S^T k_t) * beta_t
119/// S <- S + k_t (x) delta
120/// o_t <- S^T q_t
121/// ```
122///
123/// `q`/`k` are L2-normalised and `q` scaled by `1/sqrt(head_dim)` inside this function, as the
124/// golden's `use_qk_l2norm_in_kernel=True` run does. Pass raw post-conv q/k/v.
125pub fn kda_recurrent(
126    q: &[f32],
127    k: &[f32],
128    v: &[f32],
129    gate: &[f32],
130    beta: &[f32],
131    dims: KdaDims,
132    state: &mut [f32],
133) -> Vec<f32> {
134    let d = dims.head_dim;
135    let qn = l2norm_rows(q, d, 1e-6);
136    let kn = l2norm_rows(k, d, 1e-6);
137    kda_recurrent_prenorm(&qn, &kn, v, gate, beta, dims, state)
138}
139
140/// 2026-09-25: Same recurrence, but `q`/`k` are already L2-normalised, the input the
141/// `kda_recurrent` GPU kernel gets from `causal_conv1d_update_l2norm`.
142///
143/// Passing normalised vectors to [`kda_recurrent`] instead would normalise them again: nearly a
144/// no-op in fp32, but on bf16-rounded vectors it undoes the rounding error in the norm, so the
145/// reference would no longer match the kernel's input.
146pub fn kda_recurrent_prenorm(
147    qn: &[f32],
148    kn: &[f32],
149    v: &[f32],
150    gate: &[f32],
151    beta: &[f32],
152    dims: KdaDims,
153    state: &mut [f32],
154) -> Vec<f32> {
155    let (h_n, d, t_n) = (dims.heads, dims.head_dim, dims.tokens);
156    let scale = 1.0 / (d as f32).sqrt();
157
158    let mut out = vec![0.0f32; t_n * h_n * d];
159    let mut delta = vec![0.0f32; d];
160    for t in 0..t_n {
161        for h in 0..h_n {
162            let base = (t * h_n + h) * d;
163            let s = &mut state[h * d * d..(h + 1) * d * d];
164
165            for kd in 0..d {
166                let decay = gate[base + kd].exp();
167                for vd in 0..d {
168                    s[kd * d + vd] *= decay;
169                }
170            }
171            let b = beta[t * h_n + h];
172            for vd in 0..d {
173                let mut kv = 0.0f32;
174                for kd in 0..d {
175                    kv += s[kd * d + vd] * kn[base + kd];
176                }
177                delta[vd] = (v[base + vd] - kv) * b;
178            }
179            for kd in 0..d {
180                let kk = kn[base + kd];
181                for vd in 0..d {
182                    s[kd * d + vd] += kk * delta[vd];
183                }
184            }
185            for vd in 0..d {
186                let mut acc = 0.0f32;
187                for kd in 0..d {
188                    acc += s[kd * d + vd] * qn[base + kd] * scale;
189                }
190                out[base + vd] = acc;
191            }
192        }
193    }
194    out
195}
196
197/// 2026-09-25: Prefill formulation: the chunked (WY-style) delta rule with a per-channel decay
198/// mask, checked against the golden's `chunk_kimi_delta_attention` output. `tokens % chunk != 0`
199/// is handled by zero-padding.
200pub fn kda_chunked(
201    q: &[f32],
202    k: &[f32],
203    v: &[f32],
204    gate: &[f32],
205    beta: &[f32],
206    dims: KdaDims,
207    chunk: usize,
208    state: &mut [f32],
209) -> Vec<f32> {
210    let d = dims.head_dim;
211    // 2026-09-25: L2 runs on the real tokens; padding happens after, in the prenorm call.
212    let qn = l2norm_rows(q, d, 1e-6);
213    let kn = l2norm_rows(k, d, 1e-6);
214    kda_chunked_prenorm(&qn, &kn, v, gate, beta, dims, chunk, state)
215}
216
217/// 2026-09-25: Same chunked formulation, but `q`/`k` are already L2-normalised, as the GPU
218/// prefill's `l2_norm_bf16` leaves them (in bf16). Separate from [`kda_chunked`] for the reason
219/// given on [`kda_recurrent_prenorm`].
220#[allow(clippy::too_many_arguments)]
221pub fn kda_chunked_prenorm(
222    qn: &[f32],
223    kn: &[f32],
224    v: &[f32],
225    gate: &[f32],
226    beta: &[f32],
227    dims: KdaDims,
228    chunk: usize,
229    state: &mut [f32],
230) -> Vec<f32> {
231    let (h_n, d, t_n) = (dims.heads, dims.head_dim, dims.tokens);
232    let scale = 1.0 / (d as f32).sqrt();
233    let pad = (chunk - t_n % chunk) % chunk;
234    let tt = t_n + pad;
235    let n_chunks = tt / chunk;
236
237    let mut out = vec![0.0f32; t_n * h_n * d];
238    for h in 0..h_n {
239        // 2026-09-25: Per-head padded views. Pad rows are zero, so their k/v contributions vanish
240        // and their decay adds 0 to the cumulative sum.
241        let mut qh = vec![0.0f32; tt * d];
242        let mut kh = vec![0.0f32; tt * d];
243        let mut vh = vec![0.0f32; tt * d];
244        let mut gh = vec![0.0f32; tt * d];
245        let mut bh = vec![0.0f32; tt];
246        for t in 0..t_n {
247            let src = (t * h_n + h) * d;
248            for dd in 0..d {
249                qh[t * d + dd] = qn[src + dd] * scale;
250                kh[t * d + dd] = kn[src + dd];
251                vh[t * d + dd] = v[src + dd];
252                gh[t * d + dd] = gate[src + dd];
253            }
254            bh[t] = beta[t * h_n + h];
255        }
256
257        let s = &mut state[h * d * d..(h + 1) * d * d];
258        for c in 0..n_chunks {
259            let off = c * chunk;
260            // 2026-09-25: Cumulative decay within the chunk, per channel.
261            let mut gc = vec![0.0f32; chunk * d];
262            for i in 0..chunk {
263                for dd in 0..d {
264                    let prev = if i == 0 { 0.0 } else { gc[(i - 1) * d + dd] };
265                    gc[i * d + dd] = prev + gh[(off + i) * d + dd];
266                }
267            }
268            let dmask = |i: usize, j: usize, dd: usize| (gc[i * d + dd] - gc[j * d + dd]).exp();
269
270            // 2026-09-25: attn[i][j] = -sum_dd k_beta[i][dd] * k[j][dd] * exp(gc[i][dd] - gc[j][dd]),
271            // j < i.
272            let mut attn = vec![0.0f32; chunk * chunk];
273            for i in 0..chunk {
274                for j in 0..i {
275                    let mut acc = 0.0f32;
276                    for dd in 0..d {
277                        acc += kh[(off + i) * d + dd]
278                            * bh[off + i]
279                            * kh[(off + j) * d + dd]
280                            * dmask(i, j, dd);
281                    }
282                    attn[i * chunk + j] = -acc;
283                }
284            }
285            // 2026-09-25: Forward substitution: attn[i, :i] += attn[i, :i] @ attn[:i, :i].
286            for i in 1..chunk {
287                let row: Vec<f32> = (0..i).map(|j| attn[i * chunk + j]).collect();
288                for j in 0..i {
289                    let mut acc = 0.0f32;
290                    for (m, r) in row.iter().enumerate() {
291                        acc += r * attn[m * chunk + j];
292                    }
293                    attn[i * chunk + j] = row[j] + acc;
294                }
295            }
296            for i in 0..chunk {
297                attn[i * chunk + i] = 1.0;
298            }
299
300            // 2026-09-25: value = attn @ v_beta; k_cumdecay = attn @ (k_beta * exp(gc)).
301            let mut value = vec![0.0f32; chunk * d];
302            let mut k_cumdecay = vec![0.0f32; chunk * d];
303            for i in 0..chunk {
304                for dd in 0..d {
305                    let (mut av, mut ak) = (0.0f32, 0.0f32);
306                    for j in 0..chunk {
307                        let a = attn[i * chunk + j];
308                        av += a * vh[(off + j) * d + dd] * bh[off + j];
309                        ak += a * kh[(off + j) * d + dd] * bh[off + j] * gc[j * d + dd].exp();
310                    }
311                    value[i * d + dd] = av;
312                    k_cumdecay[i * d + dd] = ak;
313                }
314            }
315
316            // 2026-09-25: v_new = value - k_cumdecay @ S.
317            let mut v_new = vec![0.0f32; chunk * d];
318            for i in 0..chunk {
319                for vd in 0..d {
320                    let mut vp = 0.0f32;
321                    for kd in 0..d {
322                        vp += k_cumdecay[i * d + kd] * s[kd * d + vd];
323                    }
324                    v_new[i * d + vd] = value[i * d + vd] - vp;
325                }
326            }
327
328            // 2026-09-25: out = (q * exp(gc)) @ S + attn_intra @ v_new.
329            for i in 0..chunk {
330                if off + i >= t_n {
331                    continue;
332                }
333                let dst = ((off + i) * h_n + h) * d;
334                for vd in 0..d {
335                    let mut acc = 0.0f32;
336                    for kd in 0..d {
337                        acc += qh[(off + i) * d + kd] * gc[i * d + kd].exp() * s[kd * d + vd];
338                    }
339                    out[dst + vd] = acc;
340                }
341                for j in 0..=i {
342                    let mut intra = 0.0f32;
343                    for dd in 0..d {
344                        intra += qh[(off + i) * d + dd] * kh[(off + j) * d + dd] * dmask(i, j, dd);
345                    }
346                    for vd in 0..d {
347                        out[dst + vd] += intra * v_new[j * d + vd];
348                    }
349                }
350            }
351
352            // 2026-09-25: S <- S * exp(gc_last) + sum_i k[i] * exp(gc_last - gc[i]) (x) v_new[i].
353            let last = (chunk - 1) * d;
354            for kd in 0..d {
355                let gl = gc[last + kd];
356                for vd in 0..d {
357                    s[kd * d + vd] *= gl.exp();
358                }
359                for i in 0..chunk {
360                    let w = kh[(off + i) * d + kd] * (gl - gc[i * d + kd]).exp();
361                    for vd in 0..d {
362                        s[kd * d + vd] += w * v_new[i * d + vd];
363                    }
364                }
365            }
366        }
367    }
368    out
369}
370
371/// 2026-09-25: Gated RMSNorm over the trailing `d`, FP32, with a sigmoid gate. `eps` is the
372/// caller's `rms_norm_eps`.
373pub fn rms_norm_gated(x: &[f32], weight: &[f32], gate: &[f32], d: usize, eps: f32) -> Vec<f32> {
374    let mut out = vec![0.0f32; x.len()];
375    for i in (0..x.len()).step_by(d) {
376        let var = x[i..i + d].iter().map(|v| v * v).sum::<f32>() / d as f32;
377        let inv = 1.0 / (var + eps).sqrt();
378        for dd in 0..d {
379            out[i + dd] = x[i + dd] * inv * weight[dd] * sigmoid(gate[i + dd]);
380        }
381    }
382    out
383}
384
385/// 2026-09-25: Weights for one KDA layer, reference only. Torch `Linear` layout `[out, in]`
386/// throughout. There is no `Z` tensor: the output gate is `g_a`/`g_b` and the decay source is
387/// `f_a`/`f_b`.
388pub struct KdaWeights<'a> {
389    pub w_f_a: &'a [f32],
390    pub w_f_b: &'a [f32],
391    pub dt_bias: &'a [f32],
392    pub a_log: &'a [f32],
393    pub w_b: &'a [f32],
394    pub w_g_a: &'a [f32],
395    pub w_g_b: &'a [f32],
396    pub o_norm_w: &'a [f32],
397    pub w_o: &'a [f32],
398}
399
400/// 2026-09-25: Reference for one KDA layer from post-conv q/k/v to the `o_proj` output. The short
401/// conv is not included; the golden's inputs are post-conv.
402#[allow(clippy::too_many_arguments)]
403pub fn kda_reference_layer(
404    hidden: &[f32],
405    q: &[f32],
406    k: &[f32],
407    v: &[f32],
408    w: &KdaWeights<'_>,
409    dims: KdaDims,
410    lower_bound: f32,
411    rms_eps: f32,
412    state: &mut [f32],
413) -> Vec<f32> {
414    let (t_n, h_n, d, hid) = (dims.tokens, dims.heads, dims.head_dim, dims.hidden);
415
416    let f_a = linear(hidden, t_n, hid, w.w_f_a, d);
417    let g_lowrank = linear(&f_a, t_n, d, w.w_f_b, h_n * d);
418    let gate = bounded_gate(&g_lowrank, w.dt_bias, w.a_log, dims, lower_bound);
419
420    let beta: Vec<f32> = linear(hidden, t_n, hid, w.w_b, h_n)
421        .iter()
422        .map(|x| sigmoid(*x))
423        .collect();
424
425    let core = kda_recurrent(q, k, v, &gate, &beta, dims, state);
426
427    let g_a = linear(hidden, t_n, hid, w.w_g_a, d);
428    let out_gate = linear(&g_a, t_n, d, w.w_g_b, h_n * d);
429    let normed = rms_norm_gated(&core, w.o_norm_w, &out_gate, d, rms_eps);
430
431    linear(&normed, t_n, h_n * d, w.w_o, hid)
432}
433
434#[cfg(test)]
435mod tests;