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