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}