spark_model/layers/glm5next_dsa_ref/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! GLM-5.3-Flash **DSA (DeepSeek Sparse Attention) + kpool indexer CPU reference** β€” Slice 8.
3//!
4//! Design artifact, not a production path. Nothing here runs on GPU and no checkpoint tensor is
5//! bound. Its only job is to pin the equations in Atlas-shaped code against goldens produced by
6//! HuggingFace `transformers` **5.16.1** itself, before a CUDA kernel is written.
7//!
8//! The indexer is proven **before** the MLA on purpose: a wrong top-k still produces perfectly
9//! plausible attention output, so an MLA test built on a broken selection passes and poisons
10//! everything downstream.
11//!
12//! # Traps this module encodes
13//!
14//! * πŸͺ€ **`k_norm` is a `nn.LayerNorm`, not an RMSNorm** β€” it subtracts the mean **and** it has a
15//!   **bias**. Every other norm in GLM-5.3 is an RMSNorm without bias. The checkpoint carries
16//!   `indexer.k_norm.bias`, which is the tell; a loader that binds only `.weight` silently drops
17//!   it, and mean-subtraction is invisible in shapes.
18//! * πŸͺ€ **The pool softmax runs over the POOL-SLOT axis, per channel** β€” `softmax(dim=2)` over
19//!   `[pool, slot, head_dim]`. It is not a softmax over `head_dim` and not over pools. Getting
20//!   the axis wrong still yields a well-formed weighted average.
21//! * πŸͺ€ **Pooling starts at the FIRST VALID TOKEN, not at slot 0.** With left padding
22//!   `[P, P, A, B, C, D]`, pool 0 is `[A, B, C, D]`. Pools are formed from `first_key + offset`.
23//! * πŸͺ€ **A pool is valid only if ALL `kpool` slots are valid.** A trailing partial pool is
24//!   never a pool; it is handled by the separate tail append. So a 7-token sequence has
25//!   **one** pool, not two.
26//! * πŸͺ€ **NoPE: `qk_rope_head_dim = 0`.** `kv_a_proj_with_mqa` emits `kv_lora_rank + 0`, and the
27//!   `k_rot` slice is zero-width β€” a no-op copy, not a padded RoPE. Do not inherit DeepSeek's
28//!   assumption that the rope section exists.
29//! * πŸ”΄ **`-1` is the invalid sentinel and the destination must be FULLY written.** vLLM's day-0
30//!   GLM DSA bug was a `torch.empty` top-k buffer whose tail was never written when the valid
31//!   pool count fell below the budget, so uninitialised memory became "token indices". Every
32//!   function here writes all `out_width` entries unconditionally.
33
34/// Indexer + MLA geometry, read from the checkpoint config β€” never defaulted.
35#[derive(Clone, Copy, Debug)]
36pub struct DsaDims {
37    pub hidden: usize,
38    /// Indexer heads (`index_n_heads`), NOT the MLA head count.
39    pub index_heads: usize,
40    /// Indexer head dim (`index_head_dim`), NOT the MLA head dim.
41    pub index_head_dim: usize,
42    pub index_kpool: usize,
43    pub index_topk: usize,
44    pub always_select_tail: bool,
45    pub q_lora_rank: usize,
46    // ── MLA ──
47    pub heads: usize,
48    pub kv_lora_rank: usize,
49    pub qk_nope_head_dim: usize,
50    /// **Zero** on GLM-5.3-Flash. Kept explicit so a nonzero value is a loud change.
51    pub qk_rope_head_dim: usize,
52    pub v_head_dim: usize,
53}
54
55impl DsaDims {
56    pub fn qk_head_dim(&self) -> usize {
57        self.qk_nope_head_dim + self.qk_rope_head_dim
58    }
59    /// Pools selected per query: `index_topk / index_kpool`, capped by how many pools exist.
60    pub fn select_k(&self, n_pools: usize) -> usize {
61        (self.index_topk / self.index_kpool).min(n_pools)
62    }
63    /// Width of the emitted index row. The tail adds `kpool - 1` slots.
64    pub fn out_width(&self) -> usize {
65        self.index_topk
66            + if self.always_select_tail {
67                self.index_kpool - 1
68            } else {
69                0
70            }
71    }
72    pub fn is_nope(&self) -> bool {
73        self.qk_rope_head_dim == 0
74    }
75}
76
77/// The invalid-index sentinel. Chosen by the reference implementation, not by us.
78pub const INVALID: i32 = -1;
79
80#[inline]
81fn sigmoid(x: f32) -> f32 {
82    1.0 / (1.0 + (-x).exp())
83}
84
85/// `nn.LayerNorm` over the trailing `d`: mean-subtract, variance-normalise, then `w * x + b`.
86///
87/// πŸͺ€ Not an RMSNorm. The mean subtraction and the bias are both real, and both are invisible
88/// from tensor shapes β€” `indexer.k_norm.bias` existing in the checkpoint is the only tell.
89pub fn layer_norm(x: &[f32], w: &[f32], b: &[f32], d: usize, eps: f32) -> Vec<f32> {
90    let mut out = vec![0.0f32; x.len()];
91    for (row_in, row_out) in x.chunks_exact(d).zip(out.chunks_exact_mut(d)) {
92        let mean = row_in.iter().sum::<f32>() / d as f32;
93        let var = row_in.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / d as f32;
94        let inv = 1.0 / (var + eps).sqrt();
95        for i in 0..d {
96            row_out[i] = (row_in[i] - mean) * inv * w[i] + b[i];
97        }
98    }
99    out
100}
101
102/// RMSNorm without bias β€” the norm every OTHER GLM module uses.
103pub fn rms_norm(x: &[f32], w: &[f32], d: usize, eps: f32) -> Vec<f32> {
104    let mut out = vec![0.0f32; x.len()];
105    for (row_in, row_out) in x.chunks_exact(d).zip(out.chunks_exact_mut(d)) {
106        let inv = 1.0 / (row_in.iter().map(|v| v * v).sum::<f32>() / d as f32 + eps).sqrt();
107        for i in 0..d {
108            row_out[i] = row_in[i] * inv * w[i];
109        }
110    }
111    out
112}
113
114/// `y = x @ w^T` for `x: [m, k]`, `w: [n, k]` (torch `Linear` layout), no bias.
115pub fn linear(x: &[f32], m: usize, k: usize, w: &[f32], n: usize) -> Vec<f32> {
116    let mut out = vec![0.0f32; m * n];
117    for row in 0..m {
118        for col in 0..n {
119            let mut acc = 0.0f32;
120            for i in 0..k {
121                acc += x[row * k + i] * w[col * k + i];
122            }
123            out[row * n + col] = acc;
124        }
125    }
126    out
127}
128
129/// The compressed k-pool candidates.
130pub struct Pools {
131    /// `[n_pools, index_head_dim]` β€” softmax-weighted average of the pool's keys.
132    pub keys: Vec<f32>,
133    /// `[n_pools, index_kpool]` β€” raw token index per slot, [`INVALID`] where the slot is not real.
134    pub indices: Vec<i32>,
135    /// `[n_pools]` β€” a pool counts only when EVERY slot is valid.
136    pub valid: Vec<u8>,
137    pub n_pools: usize,
138}
139
140/// Build the pools. `k`/`gate` are `[seq, index_head_dim]`, `valid` is `[seq]`.
141///
142/// Mirrors `Glm5NextTextIndexer.get_pooled_states`.
143pub fn pool_states(
144    k: &[f32],
145    gate: &[f32],
146    valid: &[u8],
147    ape: &[f32],
148    dims: DsaDims,
149    seq: usize,
150) -> Pools {
151    let (d, kp) = (dims.index_head_dim, dims.index_kpool);
152    let n_pools = seq.div_ceil(kp);
153    // πŸͺ€ Pooling starts at the first VALID token, so left padding is skipped rather than pooled.
154    let first_key = valid.iter().position(|v| *v != 0).unwrap_or(seq) as i64;
155
156    let mut keys = vec![0.0f32; n_pools * d];
157    let mut indices = vec![INVALID; n_pools * kp];
158    let mut pvalid = vec![0u8; n_pools];
159    let mut logits = vec![0.0f32; kp];
160
161    for p in 0..n_pools {
162        let mut slot_valid = [false; 64];
163        let mut slot_idx = [0usize; 64];
164        let mut all = true;
165        for s in 0..kp {
166            let raw = first_key + (p * kp + s) as i64;
167            let in_range = raw >= 0 && (raw as usize) < seq;
168            let ok = in_range && valid[raw as usize] != 0;
169            slot_valid[s] = ok;
170            slot_idx[s] = if in_range { raw as usize } else { 0 };
171            all &= ok;
172            indices[p * kp + s] = if ok { raw as i32 } else { INVALID };
173        }
174        pvalid[p] = all as u8;
175
176        // πŸͺ€ softmax over the SLOT axis, independently per channel.
177        for dd in 0..d {
178            let mut mx = f32::NEG_INFINITY;
179            for s in 0..kp {
180                logits[s] = if slot_valid[s] {
181                    gate[slot_idx[s] * d + dd] + ape[s * d + dd]
182                } else {
183                    f32::NEG_INFINITY
184                };
185                mx = mx.max(logits[s]);
186            }
187            let mut sum = 0.0f32;
188            for s in 0..kp {
189                let e = if logits[s] == f32::NEG_INFINITY {
190                    0.0
191                } else {
192                    (logits[s] - mx).exp()
193                };
194                logits[s] = e;
195                sum += e;
196            }
197            // A fully invalid pool softmaxes to NaN in torch; HF calls `nan_to_num`, i.e. 0.
198            let inv = if sum > 0.0 { 1.0 / sum } else { 0.0 };
199            let mut acc = 0.0f32;
200            for s in 0..kp {
201                if slot_valid[s] {
202                    acc += logits[s] * inv * k[slot_idx[s] * d + dd];
203                }
204            }
205            keys[p * d + dd] = acc;
206        }
207    }
208    // πŸ”΄ HF returns `pool_keys[:, keep]` with `keep = pool_valid.any(0)`: pools invalid for
209    // EVERY batch element are dropped from the axis. This shrinks `n_pools`, and `select_k` is
210    // derived from the COMPACTED count β€” so the selection budget is data-dependent, not a pure
211    // function of sequence length. A 7-token sequence has ONE pool and a budget of ONE.
212    let keep: Vec<usize> = (0..n_pools).filter(|p| pvalid[*p] != 0).collect();
213    if keep.len() == n_pools {
214        return Pools {
215            keys,
216            indices,
217            valid: pvalid,
218            n_pools,
219        };
220    }
221    let mut ck = vec![0.0f32; keep.len() * d];
222    let mut ci = vec![INVALID; keep.len() * kp];
223    let mut cv = vec![0u8; keep.len()];
224    for (j, p) in keep.iter().enumerate() {
225        ck[j * d..(j + 1) * d].copy_from_slice(&keys[p * d..(p + 1) * d]);
226        ci[j * kp..(j + 1) * kp].copy_from_slice(&indices[p * kp..(p + 1) * kp]);
227        cv[j] = pvalid[*p];
228    }
229    Pools {
230        keys: ck,
231        indices: ci,
232        valid: cv,
233        n_pools: keep.len(),
234    }
235}
236
237/// The original pool ids that survive HF's `keep = pool_valid.any(0)` compaction.
238///
239/// Derived from padding and sequence length alone, so a caller can compute it before launching
240/// anything β€” which is why the GPU path computes the full grid and then gathers.
241pub fn kept_pools(valid: &[u8], dims: DsaDims, seq: usize) -> Vec<i32> {
242    let kp = dims.index_kpool;
243    let n_pools = seq.div_ceil(kp);
244    let first_key = valid.iter().position(|v| *v != 0).unwrap_or(seq) as i64;
245    (0..n_pools)
246        .filter(|p| {
247            (0..kp).all(|s| {
248                let raw = first_key + (p * kp + s) as i64;
249                raw >= 0 && (raw as usize) < seq && valid[raw as usize] != 0
250            })
251        })
252        .map(|p| p as i32)
253        .collect()
254}
255
256/// Per-(query, pool) index score. Mirrors the `matmul β†’ relu β†’ head-weighted sum` chain.
257///
258/// `q` is `[q_rows, index_heads, index_head_dim]`, `weights` is `[q_rows, index_heads]` and is
259/// expected to ALREADY carry the `index_heads^-0.5` factor.
260pub fn index_scores(
261    q: &[f32],
262    weights: &[f32],
263    pools: &Pools,
264    dims: DsaDims,
265    q_rows: usize,
266) -> Vec<f32> {
267    let (h, d, p) = (dims.index_heads, dims.index_head_dim, pools.n_pools);
268    let scale = (d as f32).powf(-0.5);
269    let mut out = vec![0.0f32; q_rows * p];
270    for r in 0..q_rows {
271        for pp in 0..p {
272            let mut acc = 0.0f32;
273            for hh in 0..h {
274                let mut dot = 0.0f32;
275                for dd in 0..d {
276                    dot += q[(r * h + hh) * d + dd] * pools.keys[pp * d + dd];
277                }
278                // ReLU AFTER the scale. relu(sΒ·x) == sΒ·relu(x) for s > 0, so the two orders
279                // agree β€” but only because the scale is positive; keep it explicit.
280                acc += weights[r * h + hh] * (scale * dot).max(0.0);
281            }
282            out[r * p + pp] = acc;
283        }
284    }
285    out
286}
287
288/// Which keys a query at `q_pos` may see: causal **and** not padding.
289pub fn visible(valid_keys: &[u8], q_pos: usize, key_idx: usize) -> bool {
290    key_idx <= q_pos && valid_keys[key_idx] != 0
291}
292
293/// Select up to `select_k` pools per query.
294///
295/// πŸ”΄ **Deterministic tiebreak: higher score first, then SMALLER pool index.** The reference uses
296/// `torch.topk`, whose tie order is implementation-defined, so the reference's own pool
297/// *identities* are not a legal target on a tied row β€” only the selected *set*, and only when the
298/// tie does not straddle the cutoff. This function pins a total order so Atlas is reproducible
299/// regardless.
300pub fn topk_pools(
301    scores: &[f32],
302    valid_candidates: &[u8],
303    n_pools: usize,
304    q_rows: usize,
305    select_k: usize,
306) -> Vec<i32> {
307    let mut out = vec![INVALID; q_rows * select_k];
308    let mut buf: Vec<(f32, usize)> = Vec::with_capacity(n_pools);
309    for r in 0..q_rows {
310        buf.clear();
311        for p in 0..n_pools {
312            let s = if valid_candidates[r * n_pools + p] != 0 {
313                scores[r * n_pools + p]
314            } else {
315                f32::MIN
316            };
317            buf.push((s, p));
318        }
319        buf.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap().then(a.1.cmp(&b.1)));
320        for (j, (_, p)) in buf.iter().take(select_k).enumerate() {
321            out[r * select_k + j] = *p as i32;
322        }
323    }
324    out
325}
326
327/// Expand selected pools into raw token indices, append the visible tail, pad with [`INVALID`].
328///
329/// πŸ”΄ The output is **fully written** for all `out_width` entries on every row, unconditionally.
330/// That is the structural answer to vLLM's day-0 defect, where a `torch.empty` buffer's tail
331/// stayed uninitialised whenever the valid pool count fell below the budget and uninitialised
332/// memory was read back as token indices.
333#[allow(clippy::too_many_arguments)]
334pub fn expand_selection(
335    selected: &[i32],
336    pools: &Pools,
337    valid_candidates: &[u8],
338    valid_keys: &[u8],
339    q_positions: &[usize],
340    q_mask: &[u8],
341    dims: DsaDims,
342    seq: usize,
343    select_k: usize,
344) -> Vec<i32> {
345    let (kp, width) = (dims.index_kpool, dims.out_width());
346    let q_rows = q_positions.len();
347    let mut out = vec![INVALID; q_rows * width];
348
349    let first_key = valid_keys.iter().position(|v| *v != 0).unwrap_or(seq) as i64;
350    for r in 0..q_rows {
351        let row = &mut out[r * width..(r + 1) * width];
352        if q_mask[r] == 0 {
353            continue; // already all-INVALID; a padded query selects nothing
354        }
355        let mut w = 0usize;
356        for j in 0..select_k {
357            let p = selected[r * select_k + j];
358            let ok = p >= 0 && valid_candidates[r * pools.n_pools + p as usize] != 0;
359            for s in 0..kp {
360                row[w] = if ok {
361                    pools.indices[p as usize * kp + s]
362                } else {
363                    INVALID
364                };
365                w += 1;
366            }
367        }
368        if dims.always_select_tail {
369            // The in-progress (incomplete) pool, as RAW indices.
370            let vis_count = (0..seq)
371                .filter(|k| visible(valid_keys, q_positions[r], *k))
372                .count();
373            let tail_count = vis_count % kp;
374            let tail_start = first_key + vis_count as i64 - tail_count as i64;
375            for t in 0..kp - 1 {
376                let idx = tail_start + t as i64;
377                let ok = t < tail_count
378                    && idx >= 0
379                    && (idx as usize) < seq
380                    && visible(valid_keys, q_positions[r], idx as usize);
381                row[w] = if ok { idx as i32 } else { INVALID };
382                w += 1;
383            }
384        }
385        debug_assert_eq!(
386            w,
387            width.min(select_k * kp + if dims.always_select_tail { kp - 1 } else { 0 })
388        );
389        // Everything past `w` is already INVALID from the initial fill β€” never uninitialised.
390    }
391    out
392}
393
394/// Turn an index row into the boolean visibility mask the attention consumes.
395///
396/// Mirrors HF's `scatter_add` + `.ne(0)`: **duplicate indices collapse**, so a repeated token is
397/// attended once, not twice. Out-of-range and [`INVALID`] entries are dropped.
398pub fn topk_to_mask(topk: &[i32], q_rows: usize, width: usize, kv_len: usize) -> Vec<u8> {
399    let mut mask = vec![0u8; q_rows * kv_len];
400    for r in 0..q_rows {
401        for j in 0..width {
402            let i = topk[r * width + j];
403            if i >= 0 && (i as usize) < kv_len {
404                mask[r * kv_len + i as usize] = 1;
405            }
406        }
407    }
408    mask
409}
410
411/// NoPE MLA over a per-query selected key set.
412///
413/// `q`: `[q_rows, heads, qk_head_dim]`, `k`: `[kv_len, heads, qk_head_dim]`,
414/// `v`: `[kv_len, heads, v_head_dim]`, `mask`: `[q_rows, kv_len]`.
415///
416/// πŸͺ€ NoPE means `qk_head_dim == qk_nope_head_dim` and there is **no rope section to skip**.
417/// The scale is `qk_head_dim^-0.5` over the FULL head dim, which on GLM-5.3 equals
418/// `qk_nope_head_dim^-0.5` only because the rope part is zero-width β€” do not hardcode either.
419pub fn mla_masked_attention(
420    q: &[f32],
421    k: &[f32],
422    v: &[f32],
423    mask: &[u8],
424    dims: DsaDims,
425    q_rows: usize,
426    kv_len: usize,
427) -> Vec<f32> {
428    let (h, qd, vd) = (dims.heads, dims.qk_head_dim(), dims.v_head_dim);
429    let scale = (qd as f32).powf(-0.5);
430    let mut out = vec![0.0f32; q_rows * h * vd];
431    for r in 0..q_rows {
432        for hh in 0..h {
433            // Online (flash-style) softmax: one pass, no [kv_len] score buffer.
434            let mut m = f32::NEG_INFINITY;
435            let mut l = 0.0f32;
436            let mut acc = vec![0.0f32; vd];
437            for kk in 0..kv_len {
438                if mask[r * kv_len + kk] == 0 {
439                    continue;
440                }
441                let mut dot = 0.0f32;
442                for dd in 0..qd {
443                    dot += q[(r * h + hh) * qd + dd] * k[(kk * h + hh) * qd + dd];
444                }
445                let s = dot * scale;
446                let m_new = m.max(s);
447                let corr = if m == f32::NEG_INFINITY {
448                    0.0
449                } else {
450                    (m - m_new).exp()
451                };
452                let p = (s - m_new).exp();
453                l = l * corr + p;
454                for dd in 0..vd {
455                    acc[dd] = acc[dd] * corr + p * v[(kk * h + hh) * vd + dd];
456                }
457                m = m_new;
458            }
459            let inv = if l > 0.0 { 1.0 / l } else { 0.0 };
460            for dd in 0..vd {
461                out[(r * h + hh) * vd + dd] = acc[dd] * inv;
462            }
463        }
464    }
465    out
466}
467
468/// Expand the compressed latent into per-head K and V.
469///
470/// πŸͺ€ NoPE: `k_rot` is zero-width, so the `key_states[..., nope:]` copy HF performs is a no-op.
471/// Reproducing it as a padded RoPE section would change `qk_head_dim` and the scale with it.
472pub fn expand_kv(kv_c: &[f32], w_kv_b: &[f32], dims: DsaDims, seq: usize) -> (Vec<f32>, Vec<f32>) {
473    let (h, nope, vd, r) = (
474        dims.heads,
475        dims.qk_nope_head_dim,
476        dims.v_head_dim,
477        dims.kv_lora_rank,
478    );
479    assert_eq!(dims.qk_rope_head_dim, 0, "expand_kv is the NoPE path");
480    let wide = linear(kv_c, seq, r, w_kv_b, h * (nope + vd));
481    let mut k = vec![0.0f32; seq * h * nope];
482    let mut v = vec![0.0f32; seq * h * vd];
483    for t in 0..seq {
484        for hh in 0..h {
485            let src = t * h * (nope + vd) + hh * (nope + vd);
486            k[(t * h + hh) * nope..(t * h + hh) * nope + nope]
487                .copy_from_slice(&wide[src..src + nope]);
488            v[(t * h + hh) * vd..(t * h + hh) * vd + vd]
489                .copy_from_slice(&wide[src + nope..src + nope + vd]);
490        }
491    }
492    (k, v)
493}
494
495/// Sigmoid re-exported for the microtest's convenience; the DSA path itself does not gate.
496pub fn sigmoid_f32(x: f32) -> f32 {
497    sigmoid(x)
498}
499
500#[cfg(test)]
501mod tests;