metrale_model_arch/glm5next_dsa/mod.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GLM-5.3-Flash DSA (DeepSeek Sparse Attention): the kernel handles, the
4//! per-layer geometry and its validation.
5//!
6//! Owner: model-arch (GLM-5.3 DSA).
7//! Invariants:
8//! - A config returned by `Glm5NextDsaConfig::from_config` has passed `validate`:
9//! `index_topk` is a nonzero multiple of `index_kpool`, `index_kpool` is at most
10//! `KERNEL_MAX_KPOOL`, `kv_lora_rank` equals `KERNEL_KV_LORA_DIM`, and `local_heads` is
11//! nonzero.
12//!
13//! The checkpoint is the one `kernels/gb10/glm-5.3-flash/MODEL.toml` names
14//! (`LibertAIDAI/GLM-5.3-Flash-NVFP4`, revision `9e0d74e3`). The selection kernels are in
15//! `kernels/gb10/common/dsa_indexer.cu`, and `examples/dsa_indexer_microtest.rs` runs them
16//! on a GPU. [`crate::glm5next_dsa_ref`] is the CPU reference for the equations.
17//!
18//! # Shape of the pipeline
19//!
20//! ```text
21//! k,gate,valid,ape -> kpool_compress -> pool keys/indices/valid
22//! -> index_scores -> [Q, P] scores + candidate validity
23//! -> topk_pools -> [Q, select_k] pool ids
24//! -> expand_selection -> [Q, out_width] token ids (-1 = invalid)
25//! -> NoPE MLA restricted to those tokens
26//! ```
27//!
28//! # Checkpoint and kernel facts the code depends on
29//!
30//! * `indexer.k_norm` is a LayerNorm with a bias. `indexer.k_norm.bias` is the only norm
31//! bias among the checkpoint's text tensors
32//! (`crates/model-engine/tests/fixtures/glm53-nvfp4-9e0d74e3-structural.txt`).
33//! * The pool softmax runs over the pool-slot axis, per channel (`dsa_kpool_compress`).
34//! * Pooling starts at `first_key`. A pool counts only if every one of its `index_kpool`
35//! slots is valid, so a trailing partial pool is not a pool.
36//! * NoPE: `qk_rope_head_dim` is 0, so there is no rope section and no `wkv_a_rope`
37//! tensor.
38//! * `dsa_expand_selection` writes -1 over the whole index row before it stores any index,
39//! so every slot is written.
40
41use anyhow::{Result, bail};
42use metrale_config::ModelConfig;
43use metrale_gpu_runtime::gpu::{GpuBackend, KernelHandle};
44
45pub mod attend;
46pub mod aux_state;
47pub mod binding;
48pub mod build;
49pub mod layer;
50pub mod select;
51pub mod state;
52pub mod tp;
53
54/// 2026-09-25: Module name the DSA selection kernels resolve from. `dsa_indexer.cu` has no
55/// entry in `kernels/gb10/common/KERNEL.toml` `[modules]`, so its module is its file stem.
56pub const DSA_MODULE: &str = "dsa_indexer";
57
58/// 2026-09-25: Module of `nllb_layernorm_bf16`, the BF16 LayerNorm with a bias that the
59/// indexer's `k_norm` needs: `kernels/gb10/common/nllb_encoder.cu`, named by its file stem.
60pub const LAYERNORM_MODULE: &str = "nllb_encoder";
61
62/// 2026-09-25: `#define GLM_KV_LORA_DIM` in
63/// `kernels/gb10/glm-5.3-flash/nvfp4/glm5next_dsa_mla_decode.cu`, the latent width the
64/// decode kernel is compiled for. `Glm5NextDsaConfig::validate` and `attend::decode_attention`
65/// refuse a `kv_lora_rank` that differs.
66pub const KERNEL_KV_LORA_DIM: usize = 512;
67
68/// 2026-09-25: `float lg[8]` in `dsa_kpool_compress`, the most pool slots the compression
69/// kernel holds. The kernel loops `s < KP && s < 8`, so it would drop slots past 8 without
70/// an error; config validation refuses a larger `index_kpool` instead.
71pub const KERNEL_MAX_KPOOL: usize = 8;
72
73/// 2026-09-25: The DSA selection kernels, the indexer LayerNorm and the two oracle kernels.
74///
75/// All but `write_geom` and `indexer_store` are resolved with `kernel()`, so a missing
76/// entry point fails `resolve`; those two use `try_kernel` and may be `KernelHandle(0)`.
77#[derive(Clone, Copy)]
78pub struct Glm5NextDsaKernels {
79 pub kpool_compress: KernelHandle,
80 pub compact_pools: KernelHandle,
81 pub index_scores: KernelHandle,
82 pub topk_pools: KernelHandle,
83 pub expand_selection: KernelHandle,
84 /// 2026-09-25: `indexer.k_norm`, a LayerNorm with a bias: `nllb_layernorm_bf16`, in place,
85 /// taking `(x, weight, bias, rows, dim, eps)`. An RMSNorm kernel would drop both the mean
86 /// subtraction and the bias, and the shapes would not show it.
87 pub k_norm: KernelHandle,
88 /// 2026-09-25: Derives the selector geometry on the device from `seq_len`, so a captured
89 /// graph replays over the live context. When it or `indexer_store` is missing,
90 /// `Glm5NextDsaLayer::decode_k` does not take the replay-safe path.
91 pub write_geom: KernelHandle,
92 /// 2026-09-25: Copies the staged indexer row to a device-side position and marks it
93 /// valid.
94 pub indexer_store: KernelHandle,
95 /// 2026-09-25: Oracle only; no code under `src/` launches it. See [`MASKED_ATTN_MAX_KEYS`].
96 pub topk_to_mask: KernelHandle,
97 /// 2026-09-25: Oracle only; no code under `src/` launches it. See [`MASKED_ATTN_MAX_KEYS`].
98 pub mla_masked_attn: KernelHandle,
99}
100
101/// 2026-09-25: The most keys `dsa_mla_masked_attn` takes: it stages the `[S]` f32 score row
102/// in dynamic shared memory, and `4 · S <= 49,152` B gives 12,288.
103///
104/// `examples/glm5next_dsa_decode_gate.rs` checks the serve's decode kernel against it, and
105/// `examples/dsa_indexer_microtest.rs` launches it with `dsa_topk_to_mask`. The serve's
106/// decode is `glm5next_dsa_mla_decode_fp8` (`attend.rs`), which reads the selected
107/// tokens through the block table and has no `S`-sized shared-memory row.
108pub const MASKED_ATTN_MAX_KEYS: usize = 12_288;
109
110impl Glm5NextDsaKernels {
111 pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
112 Ok(Self {
113 kpool_compress: gpu.kernel(DSA_MODULE, "dsa_kpool_compress")?,
114 compact_pools: gpu.kernel(DSA_MODULE, "dsa_compact_pools")?,
115 index_scores: gpu.kernel(DSA_MODULE, "dsa_index_scores")?,
116 topk_pools: gpu.kernel(DSA_MODULE, "dsa_topk_pools")?,
117 expand_selection: gpu.kernel(DSA_MODULE, "dsa_expand_selection")?,
118 k_norm: gpu.kernel(LAYERNORM_MODULE, "nllb_layernorm_bf16")?,
119 write_geom: metrale_model_layers::layers::try_kernel(gpu, DSA_MODULE, "dsa_write_geom"),
120 indexer_store: metrale_model_layers::layers::try_kernel(
121 gpu,
122 DSA_MODULE,
123 "dsa_indexer_store",
124 ),
125 topk_to_mask: gpu.kernel(DSA_MODULE, "dsa_topk_to_mask")?,
126 mla_masked_attn: gpu.kernel(DSA_MODULE, "dsa_mla_masked_attn")?,
127 })
128 }
129}
130
131/// 2026-09-25: DSA geometry for one layer, from `ModelConfig`.
132///
133/// Attention head counts are per rank (`local_heads`); the indexer is replicated, so
134/// `index_heads` is the full count. See [`tp`].
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct Glm5NextDsaConfig {
137 pub hidden: usize,
138 pub index_heads: usize,
139 pub index_head_dim: usize,
140 pub index_kpool: usize,
141 pub index_topk: usize,
142 pub always_select_tail: bool,
143 /// 2026-09-25: Attention heads this rank owns.
144 pub local_heads: usize,
145 pub q_lora_rank: usize,
146 pub kv_lora_rank: usize,
147 pub qk_nope_head_dim: usize,
148 /// 2026-09-25: 0 on GLM-5.3 (NoPE). `build::absorb_q` and `build::absorb_o` refuse any
149 /// other value.
150 pub qk_rope_head_dim: usize,
151 pub v_head_dim: usize,
152 /// 2026-09-25: Tokens a sequence's indexer cache is sized for: `--max-seq-len`, or 16,384
153 /// when it is unset (`from_config`). [`state::max_dsa_context`] rounds it down to whole
154 /// pools.
155 pub max_context: usize,
156}
157
158impl Glm5NextDsaConfig {
159 /// 2026-09-25: `local_heads` is `config.num_attention_heads`, which the serve's
160 /// `serve_phases::topology` has already divided by `tp_size`. Returns the result of
161 /// `validate`.
162 pub fn from_config(config: &ModelConfig) -> Result<Self> {
163 let c = Self {
164 hidden: config.hidden_size,
165 index_heads: config.index_n_heads,
166 index_head_dim: config.index_head_dim,
167 index_kpool: config.index_kpool,
168 index_topk: config.index_topk,
169 always_select_tail: config.index_kpool_always_select_tail,
170 local_heads: config.num_attention_heads,
171 q_lora_rank: config.q_lora_rank,
172 kv_lora_rank: config.kv_lora_rank,
173 qk_nope_head_dim: config.qk_nope_head_dim,
174 qk_rope_head_dim: config.qk_rope_head_dim,
175 v_head_dim: config.v_head_dim,
176 // 2026-09-25: `serve_max_seq_len` is `--max-seq-len` (serve_phases::topology); 0
177 // means unset (a unit test, a tool), and the cache is then sized for 16,384 tokens
178 // rather than the checkpoint's 1,048,576 `max_position_embeddings`.
179 max_context: if config.serve_max_seq_len > 0 {
180 config.serve_max_seq_len
181 } else {
182 16_384
183 },
184 };
185 c.validate()?;
186 Ok(c)
187 }
188
189 pub fn qk_head_dim(&self) -> usize {
190 self.qk_nope_head_dim + self.qk_rope_head_dim
191 }
192 /// 2026-09-25: True when there is no RoPE section, as on GLM-5.3.
193 pub fn is_nope(&self) -> bool {
194 self.qk_rope_head_dim == 0
195 }
196 /// 2026-09-25: Pools selected per query, capped by how many pools exist.
197 pub fn select_k(&self, n_pools: usize) -> usize {
198 (self.index_topk / self.index_kpool).min(n_pools)
199 }
200 /// 2026-09-25: Width of the emitted index row: `index_topk`, plus `index_kpool - 1` tail
201 /// slots when `always_select_tail`.
202 pub fn out_width(&self) -> usize {
203 self.index_topk
204 + if self.always_select_tail {
205 self.index_kpool - 1
206 } else {
207 0
208 }
209 }
210 /// 2026-09-25: KV latent cache width. Under NoPE this is `kv_lora_rank`, 512 on GLM-5.3;
211 /// the DeepSeek-V4-Flash cache carries a rope tail and is 576 wide (`MLA_CACHE_DIM` in
212 /// `kernels/gb10/deepseek-v4-flash/nvfp4/mla_paged_decode.cu`).
213 pub fn kv_cache_dim(&self) -> usize {
214 self.kv_lora_rank + self.qk_rope_head_dim
215 }
216
217 pub fn validate(&self) -> Result<()> {
218 if self.index_kpool == 0 || self.index_topk == 0 {
219 bail!(
220 "DSA needs index_kpool>0 and index_topk>0; got {}/{}",
221 self.index_kpool,
222 self.index_topk
223 );
224 }
225 if !self.index_topk.is_multiple_of(self.index_kpool) {
226 bail!(
227 "DSA: index_topk ({}) must be a multiple of index_kpool ({}) — \
228 the pool budget is index_topk/index_kpool",
229 self.index_topk,
230 self.index_kpool
231 );
232 }
233 // 2026-09-25: `dsa_kpool_compress` holds the pool logits in `float lg[8]` and loops
234 // `s < KP && s < 8`, but writes `pool_indices`/`pool_valid` for all KP slots, so a
235 // larger kpool would pool only the first 8 slots with no error.
236 if self.index_kpool > KERNEL_MAX_KPOOL {
237 bail!(
238 "DSA: index_kpool {} exceeds the {}-slot bound dsa_kpool_compress keeps \
239 in registers (`float lg[8]`); slots past it are silently dropped from \
240 the pooled key while still counting as valid",
241 self.index_kpool,
242 KERNEL_MAX_KPOOL,
243 );
244 }
245 if self.kv_lora_rank == 0 {
246 bail!("DSA is MLA: kv_lora_rank must be > 0");
247 }
248 // 2026-09-25: `glm5next_dsa_mla_decode.cu` is compiled for `GLM_KV_LORA_DIM` 512 but
249 // takes the cache stride as a runtime argument, so a checkpoint with another latent
250 // width would be read at the wrong width with no error. Refused here instead.
251 if self.kv_lora_rank != KERNEL_KV_LORA_DIM {
252 bail!(
253 "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/\
254 glm-5.3-flash/nvfp4/glm5next_dsa_mla_decode.cu before serving this checkpoint.",
255 self.kv_lora_rank,
256 KERNEL_KV_LORA_DIM,
257 );
258 }
259 if self.local_heads == 0 {
260 bail!("DSA: this rank owns zero attention heads");
261 }
262 Ok(())
263 }
264}