spark_model/layers/glm5next_dsa/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3-Flash **DSA (DeepSeek Sparse Attention) production surface**.
4//!
5//! Scoped to `LibertAIDAI/GLM-5.3-Flash-NVFP4@9e0d74e3`.
6//!
7//! The CUDA kernels already exist and are numerically proven against HF 5.16.1 on
8//! real weights โ€” `kernels/gb10/common/dsa_indexer.cu`, gated by
9//! `examples/dsa_indexer_microtest.rs` (GATE 4 kpool indexer, GATE 5 NoPE MLA over
10//! the selected tokens). What was missing, and is what this module adds, is the
11//! **production** surface: kernel resolution and geometry that a real layer can
12//! bind, rather than an example wiring pointers by hand.
13//!
14//! The CPU reference in [`crate::layers::glm5next_dsa_ref`] stays the source of
15//! truth for the equations. Nothing here re-derives them.
16//!
17//! # Shape of the pipeline
18//!
19//! ```text
20//! k,gate,valid,ape -> kpool_compress -> pool keys/indices/valid
21//!                  -> index_scores  -> [Q, P] scores + candidate validity
22//!                  -> topk_pools    -> [Q, select_k] pool ids
23//!                  -> expand_selection -> [Q, out_width] token ids (-1 = invalid)
24//!                  -> NoPE MLA restricted to those tokens
25//! ```
26//!
27//! # ๐Ÿชค Traps carried from Slice 8 (do not re-derive)
28//!
29//! * `indexer.k_norm` is a **`nn.LayerNorm`** โ€” mean-subtracting, **with a bias** โ€”
30//!   not an RMSNorm. `indexer.k_norm.bias` existing in the checkpoint is the only
31//!   tell; every other norm in GLM-5.3 is a bias-free RMSNorm.
32//! * The pool softmax runs over the **pool-slot axis, per channel**, not over
33//!   `head_dim` and not over pools.
34//! * Pooling starts at the **first valid token**, so left padding is skipped rather
35//!   than pooled.
36//! * A pool counts only if **every** `kpool` slot is valid โ€” a trailing partial pool
37//!   is not a pool.
38//! * **NoPE**: `qk_rope_head_dim == 0`, so the `k_rot` slice is zero-width. See the
39//!   `rope > 0` guards in `qwen3_attention` โ€” a NoPE checkpoint carries no
40//!   `wkv_a_rope` and leaves `rope_theta` unset.
41//! * The `-1` sentinel destination must be **fully written**. vLLM's day-0 GLM bug
42//!   was a `torch.empty` top-k buffer whose tail was never written, so uninitialised
43//!   memory became "token indices".
44
45use anyhow::{Result, bail};
46use avarok_core::config::ModelConfig;
47use spark_runtime::gpu::{GpuBackend, KernelHandle};
48
49pub mod attend;
50pub mod aux_state;
51pub mod binding;
52pub mod build;
53pub mod layer;
54pub mod select;
55pub mod state;
56pub mod tp;
57
58/// Module name the DSA kernels resolve from. Unlisted `.cu` files take their file
59/// stem as the module name, so `kernels/gb10/common/dsa_indexer.cu` is `dsa_indexer`.
60pub const DSA_MODULE: &str = "dsa_indexer";
61
62/// Module carrying the bias-bearing BF16 LayerNorm the indexer's `k_norm` needs. Lives in
63/// `common/`, so every target merges it; the name is the `.cu` file stem.
64pub const LAYERNORM_MODULE: &str = "nllb_encoder";
65
66/// `#define KV_LORA_DIM` in `kernels/gb10/common/mla_paged_decode{,_fp8}.cu`.
67///
68/// Mirrored here so the config can refuse a checkpoint the kernel cannot read.
69/// Changing the kernel without changing this constant is the bug this guards.
70pub const KERNEL_KV_LORA_DIM: usize = 512;
71
72/// `float lg[8]` in `dsa_kpool_compress` โ€” the most pool slots the compression
73/// kernel can hold. The kernel loops `s < KP && s < 8`, so a larger `index_kpool`
74/// is silently truncated rather than rejected. Mirrored here so config validation
75/// refuses it instead.
76pub const KERNEL_MAX_KPOOL: usize = 8;
77
78/// Every kernel the DSA path launches.
79///
80/// Resolved with `kernel()` (not `try_kernel`): a missing DSA entry point is a hard
81/// error, never a silent fallback onto a dense-attention path. A sparse layer that
82/// quietly runs dense is a correctness bug that looks like a performance bug.
83#[derive(Clone, Copy)]
84pub struct Glm5NextDsaKernels {
85    pub kpool_compress: KernelHandle,
86    pub compact_pools: KernelHandle,
87    pub index_scores: KernelHandle,
88    pub topk_pools: KernelHandle,
89    pub expand_selection: KernelHandle,
90    /// `indexer.k_norm`, which is an **`nn.LayerNorm` with a bias** โ€” not an RMSNorm.
91    ///
92    /// ๐Ÿชค Do NOT reach for an RMSNorm kernel here. A `.weight`-only norm silently drops
93    /// both the mean subtraction and the bias, and nothing about the shapes says so:
94    /// `k_norm.weight` and `k_norm.bias` are both `[index_head_dim]`. The binder already
95    /// lists the bias as REQUIRED for exactly this reason.
96    ///
97    /// โœ… No new kernel needed โ€” `common/nllb_encoder.cu` already carries an in-place
98    /// BF16 LayerNorm taking `(x, weight, bias, rows, dim, eps)`, and `common/` is merged
99    /// into every target. Found by grepping `kernels/` before scoping a build, per the
100    /// campaign's standing rule; this is the fifth thing that turned out to already exist.
101    pub k_norm: KernelHandle,
102    /// Derives this step's selector geometry ON DEVICE from `seq_len`, so a captured
103    /// graph replays over the live context instead of the capture-time one.
104    /// `try_kernel` โ€” without it the layer keeps the host-scalar path and graphs stay off.
105    pub write_geom: KernelHandle,
106    /// Places the staged indexer row at a DEVICE-side position and marks it valid.
107    /// The host `k_normed.offset(pos * D * 2)` it replaces was the other frozen scalar.
108    pub indexer_store: KernelHandle,
109    /// ๐Ÿ”ฌ ORACLE ONLY โ€” see [`MASKED_ATTN_MAX_KEYS`].
110    pub topk_to_mask: KernelHandle,
111    /// ๐Ÿ”ฌ ORACLE ONLY โ€” see [`MASKED_ATTN_MAX_KEYS`].
112    pub mla_masked_attn: KernelHandle,
113}
114
115/// ๐Ÿ”ด `dsa_mla_masked_attn` is an **oracle**, not a serve path. Resolved from source
116/// 2026-08-27; do not re-derive.
117///
118/// It stages the whole `[S]` score row in shared memory, so `4ยทS โ‰ค 49,152` caps it at
119/// **12,288 keys** โ€” a limit that does not shrink with sparsity, because the dense mask
120/// and not the selected set sets the footprint. GLM-5.3 advertises 262,144.
121///
122/// The production path gathers the selected tokens through the page table instead:
123///
124/// * HF `transformers` 5.16.1 builds the dense `[B, Q, kv_len]` mask and sets
125///   `_supports_flash_attn = False`, saying so in its own docstring โ€” *"cannot be mapped
126///   to FA without a custom kernel that can select on a per indices bases per row"*. The
127///   mask is pure set membership (`scatter_add(...).ne(0)`, duplicates collapse, no
128///   additive weighting), so a per-row gather is **exactly equivalent**, not an
129///   approximation.
130/// * vLLM ships that kernel (FlashMLA sparse / FlashInfer paged MLA), and the SM121
131///   backend serving our own frozen oracle is the SM90 NoPE sparse-MLA path over a plain
132///   bf16 paged cache.
133///
134/// โ‡’ Atlas's serve path is `mla_paged_decode{,_fp8}` โ€” block-table paged, online
135/// softmax, **no `S` term in shared memory** โ€” taught to walk a selected-index row
136/// instead of `0..seq_len`. That kernel variant is NOT yet written; until it is, DSA
137/// decode has no production consumer and these two handles must stay test-only.
138pub const MASKED_ATTN_MAX_KEYS: usize = 12_288;
139
140impl Glm5NextDsaKernels {
141    pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
142        Ok(Self {
143            kpool_compress: gpu.kernel(DSA_MODULE, "dsa_kpool_compress")?,
144            compact_pools: gpu.kernel(DSA_MODULE, "dsa_compact_pools")?,
145            index_scores: gpu.kernel(DSA_MODULE, "dsa_index_scores")?,
146            topk_pools: gpu.kernel(DSA_MODULE, "dsa_topk_pools")?,
147            expand_selection: gpu.kernel(DSA_MODULE, "dsa_expand_selection")?,
148            k_norm: gpu.kernel(LAYERNORM_MODULE, "nllb_layernorm_bf16")?,
149            write_geom: crate::layers::try_kernel(gpu, DSA_MODULE, "dsa_write_geom"),
150            indexer_store: crate::layers::try_kernel(gpu, DSA_MODULE, "dsa_indexer_store"),
151            topk_to_mask: gpu.kernel(DSA_MODULE, "dsa_topk_to_mask")?,
152            mla_masked_attn: gpu.kernel(DSA_MODULE, "dsa_mla_masked_attn")?,
153        })
154    }
155}
156
157/// DSA geometry for one layer, read from the checkpoint config โ€” never defaulted.
158///
159/// Head counts are **per-rank local** for the MLA side and **full** for the indexer,
160/// which is replicated. See [`tp`] for why.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct Glm5NextDsaConfig {
163    pub hidden: usize,
164    // โ”€โ”€ indexer (replicated) โ”€โ”€
165    pub index_heads: usize,
166    pub index_head_dim: usize,
167    pub index_kpool: usize,
168    pub index_topk: usize,
169    pub always_select_tail: bool,
170    // โ”€โ”€ MLA โ”€โ”€
171    /// Attention heads **this rank owns**.
172    pub local_heads: usize,
173    pub q_lora_rank: usize,
174    pub kv_lora_rank: usize,
175    pub qk_nope_head_dim: usize,
176    /// **Zero** on GLM-5.3. Kept explicit so a nonzero value is a loud change.
177    pub qk_rope_head_dim: usize,
178    pub v_head_dim: usize,
179    /// Longest context a sequence's indexer cache is reserved for, in tokens โ€” the serve's
180    /// `--max-seq-len`. Not a kernel limit (the top-k select is tiled); an ALLOCATION, and
181    /// the biggest per-sequence one GLM-5.3 makes. See [`state::max_dsa_context`].
182    pub max_context: usize,
183}
184
185impl Glm5NextDsaConfig {
186    /// `config` carries per-rank-local attention head counts: `serve_phases::topology`
187    /// divides `num_attention_heads` by `tp_size` before any loader runs.
188    pub fn from_config(config: &ModelConfig) -> Result<Self> {
189        let c = Self {
190            hidden: config.hidden_size,
191            index_heads: config.index_n_heads,
192            index_head_dim: config.index_head_dim,
193            index_kpool: config.index_kpool,
194            index_topk: config.index_topk,
195            always_select_tail: config.index_kpool_always_select_tail,
196            local_heads: config.num_attention_heads,
197            q_lora_rank: config.q_lora_rank,
198            kv_lora_rank: config.kv_lora_rank,
199            qk_nope_head_dim: config.qk_nope_head_dim,
200            qk_rope_head_dim: config.qk_rope_head_dim,
201            v_head_dim: config.v_head_dim,
202            // ๐Ÿ”ด `serve_max_seq_len` is set from `--max-seq-len` in serve_phases::topology.
203            // Zero means nobody set it (a unit test, a tool) โ€” fall back to the old fixed
204            // 16,384-token reservation rather than allocating for 1 M positions.
205            max_context: if config.serve_max_seq_len > 0 {
206                config.serve_max_seq_len
207            } else {
208                16_384
209            },
210        };
211        c.validate()?;
212        Ok(c)
213    }
214
215    pub fn qk_head_dim(&self) -> usize {
216        self.qk_nope_head_dim + self.qk_rope_head_dim
217    }
218    /// True when there is no RoPE section at all โ€” GLM-5.3.
219    pub fn is_nope(&self) -> bool {
220        self.qk_rope_head_dim == 0
221    }
222    /// Pools selected per query, capped by how many pools exist.
223    pub fn select_k(&self, n_pools: usize) -> usize {
224        (self.index_topk / self.index_kpool).min(n_pools)
225    }
226    /// Width of the emitted index row; the tail adds `kpool - 1` slots.
227    pub fn out_width(&self) -> usize {
228        self.index_topk
229            + if self.always_select_tail {
230                self.index_kpool - 1
231            } else {
232                0
233            }
234    }
235    /// KV latent cache width. **No rope section under NoPE**, so this is exactly
236    /// `kv_lora_rank` โ€” 512 for GLM-5.3, where DeepSeek-V4-Flash uses 576.
237    pub fn kv_cache_dim(&self) -> usize {
238        self.kv_lora_rank + self.qk_rope_head_dim
239    }
240
241    pub fn validate(&self) -> Result<()> {
242        if self.index_kpool == 0 || self.index_topk == 0 {
243            bail!(
244                "DSA needs index_kpool>0 and index_topk>0; got {}/{}",
245                self.index_kpool,
246                self.index_topk
247            );
248        }
249        if !self.index_topk.is_multiple_of(self.index_kpool) {
250            bail!(
251                "DSA: index_topk ({}) must be a multiple of index_kpool ({}) โ€” \
252                 the pool budget is index_topk/index_kpool",
253                self.index_topk,
254                self.index_kpool
255            );
256        }
257        // ๐Ÿ”ด CORRECTED 2026-08-27 (was 64). `dsa_kpool_compress` holds the pool
258        // logits in `float lg[8]` and loops `s < KP && s < 8`. A kpool in 9..=64
259        // therefore pools only the FIRST 8 slots while `pool_indices`/`pool_valid`
260        // are still written for all KP โ€” a silently wrong pooled key, no crash and
261        // no shape error. The prior bound of 64 admitted exactly that window. GLM's
262        // kpool is 4, so nothing shipped through the gap; the guard was simply
263        // describing a register budget the kernel does not have.
264        if self.index_kpool > KERNEL_MAX_KPOOL {
265            bail!(
266                "DSA: index_kpool {} exceeds the {}-slot bound dsa_kpool_compress keeps \
267                 in registers (`float lg[8]`); slots past it are silently dropped from \
268                 the pooled key while still counting as valid",
269                self.index_kpool,
270                KERNEL_MAX_KPOOL,
271            );
272        }
273        if self.kv_lora_rank == 0 {
274            bail!("DSA is MLA: kv_lora_rank must be > 0");
275        }
276        // ๐Ÿ”ด HARD ASSERTION, tied to a kernel constant.
277        //
278        // `glm-5.3-flash/nvfp4/glm5next_dsa_mla_decode.cu` hardcodes
279        // `#define GLM_KV_LORA_DIM 512` for the latent width, while taking the cache
280        // stride (`kv_cache_dim`) as a runtime argument. GLM-5.3 is correct only
281        // because its `kv_lora_rank` is ALSO 512 โ€” a coincidence, not a design.
282        //
283        // A GLM revision with a different latent would read the cache at the wrong
284        // width and produce plausible garbage with no crash, which is the exact
285        // failure class this campaign has already paid for twice (#341, #347). Fail
286        // at config time instead. If this ever fires, the fix is to parameterise
287        // `GLM_KV_LORA_DIM` in GLM's own decode kernel โ€” NOT to relax this check.
288        if self.kv_lora_rank != KERNEL_KV_LORA_DIM {
289            bail!(
290                "GLM-5.3 DSA: kv_lora_rank is {}, but glm5next_dsa_mla_decode hardcodes                  GLM_KV_LORA_DIM={}. The decode kernel would read the latent at the                  wrong width. Parameterise GLM_KV_LORA_DIM in kernels/gb10/\
291                 glm-5.3-flash/nvfp4/glm5next_dsa_mla_decode.cu before serving this checkpoint.",
292                self.kv_lora_rank,
293                KERNEL_KV_LORA_DIM,
294            );
295        }
296        if self.local_heads == 0 {
297            bail!("DSA: this rank owns zero attention heads");
298        }
299        Ok(())
300    }
301}