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}