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;