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}