spark_model/layers/glm5next_kda_ref/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3-Flash **KDA (Kimi Delta Attention) CPU reference** — Slice 2B.
4//!
5//! Design artifact, **not** a production path. Nothing here runs on GPU, nothing here is wired
6//! into a forward pass, and no checkpoint tensor is bound. Its only job is to prove the GLM KDA
7//! equations in Atlas-shaped code against golden vectors produced by HuggingFace itself, before a
8//! single CUDA kernel is written.
9//!
10//! # Why this exists
11//!
12//! GLM KDA is **not** a thin parameterization of Atlas's Qwen GDN (`layers::qwen3_ssm`). Four
13//! structural items are genuinely new:
14//!
15//! 1. **Decay is per (head, key-channel)**, not scalar-per-head. Atlas's `compute_gdn_gates`
16//!    writes `gate_out[num_tokens, num_v_heads]`; KDA needs `[T, H, head_dim]`.
17//! 2. **The decay source is a low-rank projection** `f_a: hidden -> head_dim`,
18//!    `f_b: head_dim -> H*head_dim`. Atlas derives its scalar from the fused `BA` projection.
19//! 3. **The gate law is bounded**: `lower_bound * sigmoid(exp(A_log) * (g + dt_bias))`, versus
20//!    Atlas's unbounded `exp(-exp(A_log) * softplus(a + dt_bias))`.
21//! 4. **The output gate is low-rank** `g_a`/`g_b`. Atlas takes a full-rank `Z` out of its fused
22//!    `QKVZ` projection; the KDA checkpoint has no `Z` tensor at all.
23//!
24//! See `docs/glm5next/KDA-VS-QWEN-GDN.md` for the full REUSE/ADAPT/NEW table.
25//!
26//! # Provenance of the goldens
27//!
28//! `kda_golden.json` is generated by `gen_kda_golden.py` (kept beside it), which calls the real
29//! `transformers` **5.16.1** `glm5_next` module — `Glm5NextTextForgetGate`,
30//! `Glm5NextTextRMSNormGated`, `l2norm`, `recurrent_kimi_delta_attention`,
31//! `chunk_kimi_delta_attention`. No equation is re-derived on the Python side either.
32//!
33//! `transformers` **5.16.0 contains no `glm5_next` at all** even though the checkpoint declares
34//! that version; 5.16.1 is the first source-bearing release.
35//!
36//! # Config values are read, never defaulted
37//!
38//! `gate_lower_bound`, `rms_norm_eps` and `hidden_act` must all come from the checkpoint config.
39//! vLLM happens to agree with HF on this checkpoint only by coincidence: it looks up the legacy
40//! key `lower_bound` while the checkpoint stores `gate_lower_bound`, and falls back to a default
41//! of `-5.0` that happens to match. Atlas must not inherit that.
42//!
43//! # Layout conventions
44//!
45//! * `q`/`k`/`v`/`gate`: `[T, H, D]`, row-major, index `((t * H) + h) * D + d`.
46//! * `beta`: `[T, H]`, index `t * H + h`.
47//! * `dt_bias`: `[H * D]` — per **channel**.
48//! * `a_log`: `[H]` — per **head**.
49//! * `state`: `[H, D_k, D_v]`, index `(h * D_k + kd) * D_v + vd`.
50//! * Weights follow the torch `Linear` convention `[out, in]`, row-major.
51
52/// KDA geometry. Production is `hidden 4096 / heads 64 / head_dim 128`; the low-rank width for
53/// both `f_a`/`f_b` and `g_a`/`g_b` equals `head_dim`.
54#[derive(Clone, Copy, Debug)]
55pub struct KdaDims {
56    pub hidden: usize,
57    pub heads: usize,
58    pub head_dim: usize,
59    pub tokens: usize,
60}
61
62#[inline]
63fn sigmoid(x: f32) -> f32 {
64    1.0 / (1.0 + (-x).exp())
65}
66
67/// `x / sqrt(sum(x^2) + eps)` over the trailing dimension.
68///
69/// Deliberately **not** `x / max(norm, eps)`. HF's `l2norm` adds `eps` *inside* the square root to
70/// match the original FLA triton kernel, and the vLLM kernels do the same
71/// (`b_q / tl.sqrt(tl.sum(b_q * b_q) + 1e-6)`). The two forms differ for small-norm rows.
72pub fn l2norm_rows(x: &[f32], d: usize, eps: f32) -> Vec<f32> {
73    let mut out = vec![0.0f32; x.len()];
74    for (row_in, row_out) in x.chunks_exact(d).zip(out.chunks_exact_mut(d)) {
75        let inv = 1.0 / (row_in.iter().map(|v| v * v).sum::<f32>() + eps).sqrt();
76        for (o, i) in row_out.iter_mut().zip(row_in) {
77            *o = i * inv;
78        }
79    }
80    out
81}
82
83/// `y = x @ w^T` for `x: [m, k]`, `w: [n, k]` (torch `Linear` weight layout), no bias.
84pub fn linear(x: &[f32], m: usize, k: usize, w: &[f32], n: usize) -> Vec<f32> {
85    let mut out = vec![0.0f32; m * n];
86    for row in 0..m {
87        for col in 0..n {
88            let mut acc = 0.0f32;
89            for i in 0..k {
90                acc += x[row * k + i] * w[col * k + i];
91            }
92            out[row * n + col] = acc;
93        }
94    }
95    out
96}
97
98/// The bounded GLM forget gate, item 3 of the four NEW structural items.
99///
100/// `gate[t, h, d] = lower_bound * sigmoid(exp(a_log[h]) * (g_lowrank[t, h*D + d] + dt_bias[h*D + d]))`
101///
102/// `a_log` is per **head**; `dt_bias` is per **channel**. Getting that asymmetry wrong is the
103/// single highest-risk line in the port, and it is why the two are separate arguments here.
104pub fn bounded_gate(
105    g_lowrank: &[f32],
106    dt_bias: &[f32],
107    a_log: &[f32],
108    dims: KdaDims,
109    lower_bound: f32,
110) -> Vec<f32> {
111    let (h_n, d) = (dims.heads, dims.head_dim);
112    let mut out = vec![0.0f32; dims.tokens * h_n * d];
113    for t in 0..dims.tokens {
114        for h in 0..h_n {
115            let decay = a_log[h].exp();
116            for dd in 0..d {
117                let ch = h * d + dd;
118                let g = g_lowrank[t * h_n * d + ch] + dt_bias[ch];
119                out[(t * h_n + h) * d + dd] = lower_bound * sigmoid(decay * g);
120            }
121        }
122    }
123    out
124}
125
126/// Unbounded Qwen-GDN gate, kept only so the microtest can show the two laws diverge.
127///
128/// Atlas's `compute_gdn_gates` stores `exp(g)`; this returns `g` itself, matching the KDA
129/// convention where the exponential is taken inside the recurrence.
130pub fn unbounded_gdn_gate(g_raw: f32, dt_bias: f32, a_log: f32) -> f32 {
131    let x = g_raw + dt_bias;
132    // HF: softplus(x) with the `x > 20` linear shortcut, beta = 1.
133    let softplus = if x > 20.0 { x } else { (1.0 + x.exp()).ln() };
134    -a_log.exp() * softplus
135}
136
137/// Decode formulation: one token at a time, carrying `state`.
138///
139/// ```text
140/// S <- S * diag(exp(g_t))          decay along the KEY axis, per channel
141/// delta <- (v_t - S^T k_t) * beta_t
142/// S <- S + k_t (x) delta
143/// o_t <- S^T q_t
144/// ```
145///
146/// `q`/`k` are l2-normalised and `q` scaled by `1/sqrt(head_dim)` **inside** this function,
147/// matching `use_qk_l2norm_in_kernel=True` on the HF side. Pass raw post-conv q/k/v.
148pub fn kda_recurrent(
149    q: &[f32],
150    k: &[f32],
151    v: &[f32],
152    gate: &[f32],
153    beta: &[f32],
154    dims: KdaDims,
155    state: &mut [f32],
156) -> Vec<f32> {
157    let d = dims.head_dim;
158    let qn = l2norm_rows(q, d, 1e-6);
159    let kn = l2norm_rows(k, d, 1e-6);
160    kda_recurrent_prenorm(&qn, &kn, v, gate, beta, dims, state)
161}
162
163/// Same recurrence, but `q`/`k` are **already L2-normalised**.
164///
165/// This is Atlas's contract, where `causal_conv1d_update_l2norm` fuses conv + SiLU + L2
166/// upstream, and it is what the `kda_recurrent` GPU kernel consumes. Keep it separate
167/// rather than passing pre-normalised vectors into [`kda_recurrent`]: re-normalising an
168/// already-unit vector is nearly a no-op in fp32 (it scales by `1/sqrt(1+1e-6)`), but on a
169/// bf16-rounded vector — whose norm is off by ~0.4% — it silently RESTORES the norm the
170/// rounding destroyed, which makes the reference disagree with the kernel by ~1e-5 and
171/// looks exactly like a kernel bug.
172pub fn kda_recurrent_prenorm(
173    qn: &[f32],
174    kn: &[f32],
175    v: &[f32],
176    gate: &[f32],
177    beta: &[f32],
178    dims: KdaDims,
179    state: &mut [f32],
180) -> Vec<f32> {
181    let (h_n, d, t_n) = (dims.heads, dims.head_dim, dims.tokens);
182    let scale = 1.0 / (d as f32).sqrt();
183
184    let mut out = vec![0.0f32; t_n * h_n * d];
185    let mut delta = vec![0.0f32; d];
186    for t in 0..t_n {
187        for h in 0..h_n {
188            let base = (t * h_n + h) * d;
189            let s = &mut state[h * d * d..(h + 1) * d * d];
190
191            for kd in 0..d {
192                let decay = gate[base + kd].exp();
193                for vd in 0..d {
194                    s[kd * d + vd] *= decay;
195                }
196            }
197            let b = beta[t * h_n + h];
198            for vd in 0..d {
199                let mut kv = 0.0f32;
200                for kd in 0..d {
201                    kv += s[kd * d + vd] * kn[base + kd];
202                }
203                delta[vd] = (v[base + vd] - kv) * b;
204            }
205            for kd in 0..d {
206                let kk = kn[base + kd];
207                for vd in 0..d {
208                    s[kd * d + vd] += kk * delta[vd];
209                }
210            }
211            for vd in 0..d {
212                let mut acc = 0.0f32;
213                for kd in 0..d {
214                    acc += s[kd * d + vd] * qn[base + kd] * scale;
215                }
216                out[base + vd] = acc;
217            }
218        }
219    }
220    out
221}
222
223/// Prefill formulation: chunked (WY-style) delta rule, mirroring HF's
224/// `chunk_kimi_delta_attention`. Included at reference level because the per-channel decay mask is
225/// exactly the part that a scalar-decay GDN kernel cannot express — proving it here is cheaper
226/// than proving it in CUDA.
227///
228/// Handles `tokens % chunk != 0` by zero-padding, the same way HF does.
229pub fn kda_chunked(
230    q: &[f32],
231    k: &[f32],
232    v: &[f32],
233    gate: &[f32],
234    beta: &[f32],
235    dims: KdaDims,
236    chunk: usize,
237    state: &mut [f32],
238) -> Vec<f32> {
239    let d = dims.head_dim;
240    // l2norm runs on the REAL tokens, then the result is padded — HF pads after normalising.
241    let qn = l2norm_rows(q, d, 1e-6);
242    let kn = l2norm_rows(k, d, 1e-6);
243    kda_chunked_prenorm(&qn, &kn, v, gate, beta, dims, chunk, state)
244}
245
246/// Same chunked formulation, but `q`/`k` are **already L2-normalised** — Atlas's contract, where
247/// the conv path (fused on decode, `l2_norm_bf16` on prefill) has already normalised them.
248///
249/// Split out for the same reason as [`kda_recurrent_prenorm`]: re-normalising an already-unit
250/// vector is nearly a no-op in fp32 but on a bf16-rounded vector it RESTORES the norm the
251/// rounding destroyed, which makes the reference disagree with the kernel and looks exactly
252/// like a kernel bug. Atlas's prefill L2 writes **bf16**, so this path is always the bf16 case.
253#[allow(clippy::too_many_arguments)]
254pub fn kda_chunked_prenorm(
255    qn: &[f32],
256    kn: &[f32],
257    v: &[f32],
258    gate: &[f32],
259    beta: &[f32],
260    dims: KdaDims,
261    chunk: usize,
262    state: &mut [f32],
263) -> Vec<f32> {
264    let (h_n, d, t_n) = (dims.heads, dims.head_dim, dims.tokens);
265    let scale = 1.0 / (d as f32).sqrt();
266    let pad = (chunk - t_n % chunk) % chunk;
267    let tt = t_n + pad;
268    let n_chunks = tt / chunk;
269
270    let mut out = vec![0.0f32; t_n * h_n * d];
271    for h in 0..h_n {
272        // Per-head padded views. Pad rows are zero, which makes their k_beta/v_beta contributions
273        // vanish and their cumulative decay a no-op.
274        let mut qh = vec![0.0f32; tt * d];
275        let mut kh = vec![0.0f32; tt * d];
276        let mut vh = vec![0.0f32; tt * d];
277        let mut gh = vec![0.0f32; tt * d];
278        let mut bh = vec![0.0f32; tt];
279        for t in 0..t_n {
280            let src = (t * h_n + h) * d;
281            for dd in 0..d {
282                qh[t * d + dd] = qn[src + dd] * scale;
283                kh[t * d + dd] = kn[src + dd];
284                vh[t * d + dd] = v[src + dd];
285                gh[t * d + dd] = gate[src + dd];
286            }
287            bh[t] = beta[t * h_n + h];
288        }
289
290        let s = &mut state[h * d * d..(h + 1) * d * d];
291        for c in 0..n_chunks {
292            let off = c * chunk;
293            // Cumulative decay within the chunk, per channel.
294            let mut gc = vec![0.0f32; chunk * d];
295            for i in 0..chunk {
296                for dd in 0..d {
297                    let prev = if i == 0 { 0.0 } else { gc[(i - 1) * d + dd] };
298                    gc[i * d + dd] = prev + gh[(off + i) * d + dd];
299                }
300            }
301            let dmask = |i: usize, j: usize, dd: usize| (gc[i * d + dd] - gc[j * d + dd]).exp();
302
303            // attn[i][j] = -sum_dd k_beta[i][dd] * k[j][dd] * exp(gc[i][dd] - gc[j][dd]), j < i.
304            let mut attn = vec![0.0f32; chunk * chunk];
305            for i in 0..chunk {
306                for j in 0..i {
307                    let mut acc = 0.0f32;
308                    for dd in 0..d {
309                        acc += kh[(off + i) * d + dd]
310                            * bh[off + i]
311                            * kh[(off + j) * d + dd]
312                            * dmask(i, j, dd);
313                    }
314                    attn[i * chunk + j] = -acc;
315                }
316            }
317            // Forward substitution: attn[i, :i] += attn[i, :i] @ attn[:i, :i].
318            for i in 1..chunk {
319                let row: Vec<f32> = (0..i).map(|j| attn[i * chunk + j]).collect();
320                for j in 0..i {
321                    let mut acc = 0.0f32;
322                    for (m, r) in row.iter().enumerate() {
323                        acc += r * attn[m * chunk + j];
324                    }
325                    attn[i * chunk + j] = row[j] + acc;
326                }
327            }
328            for i in 0..chunk {
329                attn[i * chunk + i] = 1.0;
330            }
331
332            // value = attn @ v_beta ;  k_cumdecay = attn @ (k_beta * exp(gc))
333            let mut value = vec![0.0f32; chunk * d];
334            let mut k_cumdecay = vec![0.0f32; chunk * d];
335            for i in 0..chunk {
336                for dd in 0..d {
337                    let (mut av, mut ak) = (0.0f32, 0.0f32);
338                    for j in 0..chunk {
339                        let a = attn[i * chunk + j];
340                        av += a * vh[(off + j) * d + dd] * bh[off + j];
341                        ak += a * kh[(off + j) * d + dd] * bh[off + j] * gc[j * d + dd].exp();
342                    }
343                    value[i * d + dd] = av;
344                    k_cumdecay[i * d + dd] = ak;
345                }
346            }
347
348            // v_new = value - k_cumdecay @ S
349            let mut v_new = vec![0.0f32; chunk * d];
350            for i in 0..chunk {
351                for vd in 0..d {
352                    let mut vp = 0.0f32;
353                    for kd in 0..d {
354                        vp += k_cumdecay[i * d + kd] * s[kd * d + vd];
355                    }
356                    v_new[i * d + vd] = value[i * d + vd] - vp;
357                }
358            }
359
360            // out = (q * exp(gc)) @ S  +  attn_intra @ v_new
361            for i in 0..chunk {
362                if off + i >= t_n {
363                    continue;
364                }
365                let dst = ((off + i) * h_n + h) * d;
366                for vd in 0..d {
367                    let mut acc = 0.0f32;
368                    for kd in 0..d {
369                        acc += qh[(off + i) * d + kd] * gc[i * d + kd].exp() * s[kd * d + vd];
370                    }
371                    out[dst + vd] = acc;
372                }
373                for j in 0..=i {
374                    let mut intra = 0.0f32;
375                    for dd in 0..d {
376                        intra += qh[(off + i) * d + dd] * kh[(off + j) * d + dd] * dmask(i, j, dd);
377                    }
378                    for vd in 0..d {
379                        out[dst + vd] += intra * v_new[j * d + vd];
380                    }
381                }
382            }
383
384            // S <- S * exp(gc_last) + sum_i k[i] * exp(gc_last - gc[i]) (x) v_new[i]
385            let last = (chunk - 1) * d;
386            for kd in 0..d {
387                let gl = gc[last + kd];
388                for vd in 0..d {
389                    s[kd * d + vd] *= gl.exp();
390                }
391                for i in 0..chunk {
392                    let w = kh[(off + i) * d + kd] * (gl - gc[i * d + kd]).exp();
393                    for vd in 0..d {
394                        s[kd * d + vd] += w * v_new[i * d + vd];
395                    }
396                }
397            }
398        }
399    }
400    out
401}
402
403/// Gated RMSNorm over the trailing `d`, strict FP32, sigmoid gate.
404///
405/// `eps` is `rms_norm_eps` from the checkpoint config (1e-5 for GLM-5.3-Flash). vLLM never passes
406/// it and relies on `FusedRMSNormGated`'s default, which coincides here — do not rely on that.
407pub fn rms_norm_gated(x: &[f32], weight: &[f32], gate: &[f32], d: usize, eps: f32) -> Vec<f32> {
408    let mut out = vec![0.0f32; x.len()];
409    for i in (0..x.len()).step_by(d) {
410        let var = x[i..i + d].iter().map(|v| v * v).sum::<f32>() / d as f32;
411        let inv = 1.0 / (var + eps).sqrt();
412        for dd in 0..d {
413            out[i + dd] = x[i + dd] * inv * weight[dd] * sigmoid(gate[i + dd]);
414        }
415    }
416    out
417}
418
419/// Weights for one KDA layer, reference-only. Torch `Linear` layout `[out, in]` throughout.
420///
421/// Note what is **absent** relative to Atlas's Qwen GDN: there is no `Z` tensor. The output gate is
422/// `g_a`/`g_b`, and the decay source is `f_a`/`f_b`. Neither has a slot in Atlas's fused
423/// `QKVZ` + `BA` layout, so weight binding is not a rename map.
424pub struct KdaWeights<'a> {
425    pub w_f_a: &'a [f32],
426    pub w_f_b: &'a [f32],
427    pub dt_bias: &'a [f32],
428    pub a_log: &'a [f32],
429    pub w_b: &'a [f32],
430    pub w_g_a: &'a [f32],
431    pub w_g_b: &'a [f32],
432    pub o_norm_w: &'a [f32],
433    pub w_o: &'a [f32],
434}
435
436/// End-to-end reference for one KDA layer, from post-conv q/k/v to `o_proj` output.
437///
438/// The short conv is intentionally excluded: it is REUSE against Atlas's existing fused
439/// conv+SiLU+L2 kernel, and folding it in would couple two independent checks.
440#[allow(clippy::too_many_arguments)]
441pub fn kda_reference_layer(
442    hidden: &[f32],
443    q: &[f32],
444    k: &[f32],
445    v: &[f32],
446    w: &KdaWeights<'_>,
447    dims: KdaDims,
448    lower_bound: f32,
449    rms_eps: f32,
450    state: &mut [f32],
451) -> Vec<f32> {
452    let (t_n, h_n, d, hid) = (dims.tokens, dims.heads, dims.head_dim, dims.hidden);
453
454    let f_a = linear(hidden, t_n, hid, w.w_f_a, d);
455    let g_lowrank = linear(&f_a, t_n, d, w.w_f_b, h_n * d);
456    let gate = bounded_gate(&g_lowrank, w.dt_bias, w.a_log, dims, lower_bound);
457
458    let beta: Vec<f32> = linear(hidden, t_n, hid, w.w_b, h_n)
459        .iter()
460        .map(|x| sigmoid(*x))
461        .collect();
462
463    let core = kda_recurrent(q, k, v, &gate, &beta, dims, state);
464
465    let g_a = linear(hidden, t_n, hid, w.w_g_a, d);
466    let out_gate = linear(&g_a, t_n, d, w.w_g_b, h_n * d);
467    let normed = rms_norm_gated(&core, w.o_norm_w, &out_gate, d, rms_eps);
468
469    linear(&normed, t_n, h_n * d, w.w_o, hid)
470}
471
472#[cfg(test)]
473mod tests;