metrale_model_arch/glm5next_dsa/
layer.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: `Glm5NextDsaLayer`, one GLM-5.3 DSA block: the projections, the indexer
4//! cache write, token selection and the NoPE MLA gather-attend over the selected tokens.
5//!
6//! Owner: model-arch (GLM-5.3 DSA).
7//! Invariants:
8//! - `decode_k` fails before its first launch when the indexer cache is behind the sequence
9//!   (`len < seq_len`) or `k` is 0 or above the workspace's `max_rows`. An indexer cache
10//!   ahead of the sequence is rewound to `seq_len` first.
11//! - `indexer_forward` checks that the indexer cache has room (`ensure_room`) before it
12//!   writes.
13//!
14//! Decode, end to end:
15//!
16//! ```text
17//! hidden ─┬─ q_a_proj ─ RMSNorm ─┬─ q_absorb ────────────── Q (latent space)
18//!         │                      └─ indexer.wq_b ────────── q_idx  ─┐
19//!         ├─ indexer.wk ─ LayerNorm(w,b) ─ state.k_normed ──────────┤
20//!         ├─ compress_gate ─────────────── state.gate ──────────────┼─ select_tokens
21//!         ├─ weights_proj ──────────────── head weights ────────────┘        │
22//!         └─ kv_a_proj ─ RMSNorm ─ FP8 ─── paged latent cache                │
23//!                                                                            ▼
24//!                                            glm5next_dsa_mla_decode_fp8 (gather)
25//! ```
26//!
27//! # Kernel choices the shapes do not check
28//!
29//! * The norms use `rms_norm_vanilla`, `x * rms * w`. The `rms_norm` kernel (module `norm`)
30//!   computes `x * rms * (1 + w)` with the same signature.
31//! * `indexer.k_norm` is a LayerNorm with a bias: `nllb_layernorm_bf16(x, w, b, …)`.
32//! * `weights_proj` carries `index_heads^-0.5` from load ([`Glm5NextDsaWeights`]), because
33//!   `dsa_index_scores` does not apply it.
34//! * Q reaches the decode kernel as `q_absorb`: `q_b_proj` multiplied through `kv_b_proj`'s
35//!   K half, so it is in the `kv_lora_rank`-wide latent space the kernel dots against. The
36//!   raw `q_b_proj` is `qk_head_dim` (256) per head.
37
38use anyhow::{Context, Result, bail};
39use metrale_cache::kv_cache::PagedKvCache;
40use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
41use metrale_gpu_runtime::kernel_args::KernelLaunch;
42
43use super::attend::{DsaDecodeInputs, DsaDecodePaging, Glm5NextDsaDecodeKernel, decode_attention};
44use super::select::{DsaSelectInputs, select_tokens};
45use super::state::Glm5NextDsaState;
46use super::{Glm5NextDsaConfig, Glm5NextDsaKernels};
47use metrale_model_layers::layer::{ForwardContext, LayerState, TransformerLayer};
48
49mod decode_k;
50mod proj_gemm;
51mod rows;
52mod workspace;
53
54use proj_gemm::gemm;
55pub use workspace::Glm5NextDsaWorkspace;
56pub(crate) use workspace::batch_select_enabled;
57
58/// 2026-09-25: The projection, norm and latent-write kernels of a DSA block. The selection
59/// kernels are in `Glm5NextDsaKernels`, the decode kernel in `attend`.
60#[derive(Clone, Copy)]
61pub struct Glm5NextDsaLayerKernels {
62    /// 2026-09-25: `dense_gemm_bf16`, `C = A @ B^T`, BF16 out.
63    pub gemm: KernelHandle,
64    /// 2026-09-25: The same with FP32 out, for the selector's `q_idx` and head weights.
65    pub gemm_f32: KernelHandle,
66    /// 2026-09-25: M = 1 kernels for `gemm` and `gemm_f32`. `gemv_f32` is `KernelHandle(0)`
67    /// on a target without `dense_gemv_bf16_fp32out`, and `dense_mm_bf16` then uses the tile
68    /// GEMM.
69    pub gemv: KernelHandle,
70    pub gemv_f32: KernelHandle,
71    /// 2026-09-25: `dense_gemv_bf16_batchm`: 2..=`DENSE_GEMV_BATCHM_MAX_M` rows in one pass
72    /// over the weight; `KernelHandle(0)` when the target lacks it.
73    pub gemv_batchm: KernelHandle,
74    /// 2026-09-25: `rms_norm_vanilla`; see the module header.
75    pub rms_norm: KernelHandle,
76    /// 2026-09-25: `glm5next_mla_latent_write_fp8`: RMSNorm, FP8 quantisation and the paged
77    /// slot write of the KV latent.
78    pub latent_write: KernelHandle,
79}
80
81impl Glm5NextDsaLayerKernels {
82    pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
83        Ok(Self {
84            // 2026-09-25: `kernels/gb10/common/KERNEL.toml` `[modules]` maps
85            // `dense_gemm_bf16 = "gemm"` and `dense_gemv_bf16 = "gemv"`; an unlisted `.cu`
86            // file's module is its file stem.
87            gemm: gpu.kernel("gemm", "dense_gemm_bf16")?,
88            gemm_f32: gpu.kernel("gemm", "dense_gemm_bf16_f32out")?,
89            gemv: gpu.kernel("gemv", "dense_gemv_bf16")?,
90            gemv_f32: metrale_model_layers::layers::try_kernel(
91                gpu,
92                "gemv",
93                "dense_gemv_bf16_fp32out",
94            ),
95            gemv_batchm: metrale_model_layers::layers::try_kernel(
96                gpu,
97                "dense_gemv_bf16_batchm",
98                "dense_gemv_bf16_batchm",
99            ),
100            rms_norm: gpu.kernel("rms_norm_vanilla", "rms_norm_vanilla")?,
101            latent_write: gpu
102                .kernel("glm5next_mla_latent_write", "glm5next_mla_latent_write_fp8")?,
103        })
104    }
105}
106
107/// 2026-09-25: One DSA block's weights on the device, already sharded for this rank.
108pub struct Glm5NextDsaWeights {
109    pub q_a_proj: DevicePtr,
110    pub q_a_layernorm: DevicePtr,
111    /// 2026-09-25: `[local_heads * kv_lora_rank, q_lora_rank]` BF16: `q_b_proj` absorbed
112    /// through `kv_b_proj`'s K half, so Q arrives in latent space.
113    pub q_absorb: DevicePtr,
114    pub kv_a_proj: DevicePtr,
115    pub kv_a_layernorm: DevicePtr,
116    /// 2026-09-25: `[hidden, local_heads * kv_lora_rank]` BF16, row-parallel; the caller
117    /// all-reduces the output.
118    ///
119    /// `o_proj` with `kv_b_proj`'s V half folded in (`build::absorb_o`), because the decode
120    /// kernel's output is in latent space, `kv_lora_rank` per head. The checkpoint `o_proj`
121    /// is `v_head_dim` per head (256 against 512 on GLM-5.3).
122    pub o_absorb: DevicePtr,
123    // 2026-09-25: The indexer weights below are replicated on every rank (`tp.rs`).
124    pub wk: DevicePtr,
125    pub k_norm_weight: DevicePtr,
126    pub k_norm_bias: DevicePtr,
127    pub compress_gate: DevicePtr,
128    pub wq_b: DevicePtr,
129    /// 2026-09-25: Multiplied by `index_heads^-0.5` at load; `dsa_index_scores` does not
130    /// apply it.
131    pub weights_proj: DevicePtr,
132    /// 2026-09-25: `[index_kpool, index_head_dim]` FP32; the checkpoint stores BF16.
133    pub ape: DevicePtr,
134}
135
136pub struct Glm5NextDsaLayer {
137    pub cfg: Glm5NextDsaConfig,
138    pub weights: Glm5NextDsaWeights,
139    pub kernels: Glm5NextDsaLayerKernels,
140    pub select_kernels: Glm5NextDsaKernels,
141    pub decode_kernel: Glm5NextDsaDecodeKernel,
142    pub workspace: Glm5NextDsaWorkspace,
143    /// 2026-09-25: Index in the model's layer stack; used only in error messages.
144    pub layer_idx: usize,
145    /// 2026-09-25: Index into the KV pool (`kv_cache.k_pool_ptr`): the loader counts DSA
146    /// layers only, so it runs 0..11 on GLM-5.3 while `layer_idx` runs to 45. The MTP drafter
147    /// uses 0.
148    pub attn_layer_idx: usize,
149    pub rms_eps: f32,
150    /// 2026-09-25: FP8 latent-cache scale. The decode reads with it and the latent write
151    /// takes `1/scale`.
152    pub kv_scale: f32,
153    /// 2026-09-25: Use the workspace's `bt`/`sl` rather than allocating them per call. The
154    /// loaders set it true unless `METRALE_GLM_DSA_ALLOC_PER_STEP=1`.
155    pub persist_bt: bool,
156}
157
158impl Glm5NextDsaLayer {
159    /// 2026-09-25: Project `hidden` into indexer cache row `state.len()`, then advance by one.
160    ///
161    /// With `pos_dev`, `k_normed` and `gate` go to the workspace staging rows and
162    /// `dsa_indexer_store` places them at the device-side position; without it they are
163    /// written straight into the cache row. Also leaves this row's selector head weights in
164    /// the workspace. Fails before any write when the cache is full.
165    pub fn indexer_forward(
166        &self,
167        gpu: &dyn GpuBackend,
168        hidden: DevicePtr,
169        state: &mut Glm5NextDsaState,
170        pos_dev: Option<DevicePtr>,
171        stream: u64,
172    ) -> Result<()> {
173        // 2026-09-25: Checked before any write: everything below writes row `state.len()`,
174        // which is past the end of the buffers when the cache is full.
175        state.ensure_room(1)?;
176        let d = self.cfg.index_head_dim;
177        let pos = state.len();
178        let off = state.row_offset(pos);
179        let w = &self.workspace;
180        let (k_dst, gate_dst) = match pos_dev {
181            Some(_) => (w.stage_k, w.stage_gate),
182            None => (state.k_normed.offset(off), state.gate.offset(off)),
183        };
184
185        gemm(
186            gpu,
187            self.kernels.gemm,
188            self.kernels.gemv,
189            self.kernels.gemv_batchm,
190            hidden,
191            self.weights.wk,
192            k_dst,
193            1,
194            d,
195            self.cfg.hidden,
196            stream,
197        )?;
198        KernelLaunch::new(gpu, self.select_kernels.k_norm)
199            .grid([1, 1, 1])
200            .block([d.min(1024) as u32, 1, 1])
201            .shared_mem((d.min(1024) * 4) as u32)
202            .arg_ptr(k_dst)
203            .arg_ptr(self.weights.k_norm_weight)
204            .arg_ptr(self.weights.k_norm_bias)
205            .arg_u32(1)
206            .arg_u32(d as u32)
207            .arg_f32(self.rms_eps)
208            .launch(stream)?;
209
210        gemm(
211            gpu,
212            self.kernels.gemm,
213            self.kernels.gemv,
214            self.kernels.gemv_batchm,
215            hidden,
216            self.weights.compress_gate,
217            gate_dst,
218            1,
219            d,
220            self.cfg.hidden,
221            stream,
222        )?;
223
224        // 2026-09-25: Selector head weights from the layer input, FP32 out. `weights_proj` is
225        // `[index_heads, hidden]`; the reference computes
226        // `weights_proj(hidden) * index_heads**-0.5`
227        // (`glm5next_dsa_ref/gen_dsa_indexer_golden.py`), and the scale is already in the
228        // weight (`build.rs` transform 2).
229        gemm(
230            gpu,
231            self.kernels.gemm_f32,
232            self.kernels.gemv_f32,
233            // 2026-09-25: No FP32-out batched GEMV kernel exists.
234            KernelHandle(0),
235            hidden,
236            self.weights.weights_proj,
237            self.workspace.head_weights,
238            1,
239            self.cfg.index_heads,
240            self.cfg.hidden,
241            stream,
242        )?;
243
244        self.store_indexer_row(gpu, state, pos_dev, pos, d, stream)
245    }
246
247    /// 2026-09-25: The selector query projection and the selection for one query row, written
248    /// to row `row` of the selection output.
249    ///
250    /// `decode_k` calls it right after that row's indexer write, so the geometry is planned
251    /// at the row's own cache length; `attend_rows` then attends all rows in one launch.
252    #[allow(clippy::too_many_arguments)]
253    fn select_row(
254        &self,
255        gpu: &dyn GpuBackend,
256        row: usize,
257        state: &Glm5NextDsaState,
258        q_pos_dev: DevicePtr,
259        replay_safe: bool,
260        stream: u64,
261    ) -> Result<()> {
262        let w = &self.workspace;
263        let geom = state.geometry(&self.cfg, 1)?;
264
265        // 2026-09-25: The selector query `q_idx`, FP32 out.
266        gemm(
267            gpu,
268            self.kernels.gemm_f32,
269            self.kernels.gemv_f32,
270            // 2026-09-25: No FP32-out batched GEMV kernel exists.
271            KernelHandle(0),
272            w.q_resid.offset(row * self.cfg.q_lora_rank * 2),
273            self.weights.wq_b,
274            w.q_idx,
275            1,
276            self.cfg.index_heads * self.cfg.index_head_dim,
277            self.cfg.q_lora_rank,
278            stream,
279        )?;
280        // 2026-09-25: `head_weights` comes from `indexer_forward`, which has the layer input.
281
282        let inputs = DsaSelectInputs {
283            k_normed: state.k_normed,
284            gate: state.gate,
285            valid: state.valid,
286            ape: self.weights.ape,
287            q: w.q_idx,
288            weights: w.head_weights,
289            q_pos: q_pos_dev,
290            // 2026-09-25: All 1s, set once in `Glm5NextDsaWorkspace::new`.
291            q_mask: w.q_mask,
292            first_key: 0,
293            geom_dev: if replay_safe {
294                w.geom_dev
295            } else {
296                DevicePtr::NULL
297            },
298        };
299        // 2026-09-25: On the replay-safe path the grid and the shared-memory request are set
300        // at the context ceiling and the live extents come from `geom_dev`, so one graph
301        // serves every context length.
302        let launch = if replay_safe {
303            super::select::DsaSelectLaunch::Ceiling {
304                max_pools: super::select::contiguous_pool_count(
305                    self.cfg.index_kpool,
306                    super::state::max_dsa_context(&self.cfg),
307                ),
308            }
309        } else {
310            super::select::DsaSelectLaunch::Exact
311        };
312        let t = crate::glm5next_layer::profile::start();
313        // 2026-09-25: Row `row` of the `[max_rows, out_width]` selection output. The kernels
314        // run one query row (`q_rows == 1`) into this row's slot, so `attend_rows` reads all
315        // rows in one launch.
316        select_tokens(
317            gpu,
318            &self.select_kernels,
319            &self.cfg,
320            &geom,
321            &inputs,
322            &w.select.row(row, &self.cfg),
323            launch,
324            stream,
325        )?;
326        use crate::glm5next_layer::profile;
327        profile::end(profile::DSA_SELECT, t, gpu, stream);
328        Ok(())
329    }
330
331    /// 2026-09-25: The gather-attend for all `rows` query rows in one launch.
332    ///
333    /// Grid y is the row. Each row reads the paged latent cache through its own `q_abs` row,
334    /// selection row and `seq_lens` entry, and the block-table row at stride
335    /// `max_blocks_per_seq` (0 when the rows share one table).
336    #[allow(clippy::too_many_arguments)]
337    fn attend_rows(
338        &self,
339        gpu: &dyn GpuBackend,
340        rows: usize,
341        state: &Glm5NextDsaState,
342        kv_cache: &PagedKvCache,
343        block_table_dev: DevicePtr,
344        seq_lens_dev: DevicePtr,
345        paging: &DsaDecodePaging,
346        stream: u64,
347    ) -> Result<()> {
348        use crate::glm5next_layer::profile;
349        let w = &self.workspace;
350        // 2026-09-25: `decode_attention` reads only `out_width` and `q_rows` from it.
351        let geom = state.geometry(&self.cfg, rows)?;
352        let paging = DsaDecodePaging {
353            num_seqs: rows,
354            ..*paging
355        };
356        let t = profile::start();
357        let pool = kv_cache.k_pool_ptr(self.attn_layer_idx);
358        decode_attention(
359            gpu,
360            self.decode_kernel,
361            &self.cfg,
362            &geom,
363            &paging,
364            &DsaDecodeInputs {
365                q: w.q_abs,
366                k_cache: pool,
367                v_cache: pool, // 2026-09-25: absorbed NoPE MLA: K and V are the same latent
368                out: w.attn_out,
369                block_tables: block_table_dev,
370                seq_lens: seq_lens_dev,
371                sel_indices: w.select.tokens(),
372                k_scale: self.kv_scale,
373                v_scale: self.kv_scale,
374            },
375            stream,
376        )?;
377        profile::end(profile::DSA_ATTEND, t, gpu, stream);
378        Ok(())
379    }
380
381    /// 2026-09-26: `decode_k`'s check that the indexer cache is in lockstep with the KV
382    /// cache: rewinds `st` when it is ahead of `seq_len`, fails when it is behind.
383    fn check_lockstep(&self, st: &mut Glm5NextDsaState, seq_len: usize) -> Result<()> {
384        // 2026-09-25: The indexer cache must advance in lockstep with the KV cache.
385        //
386        // * Ahead (`len > seq_len`) follows a rejected speculative draft: rewinding to
387        //   `seq_len` makes the rows past it unreachable (the selector reads `[0, len)`) and
388        //   the next write overwrites them.
389        // * Behind (`len < seq_len`) means rows were never written, which is an error.
390        match st.len().cmp(&seq_len) {
391            std::cmp::Ordering::Greater => st.rewind_to(seq_len)?,
392            std::cmp::Ordering::Less => bail!(
393                "DSA layer {}: indexer cache holds {} tokens but the sequence is at {} — \
394                 rows are MISSING, not merely stale. The indexer stream must advance in \
395                 lockstep with the KV cache.",
396                self.layer_idx,
397                st.len(),
398                seq_len
399            ),
400            std::cmp::Ordering::Equal => {}
401        }
402        Ok(())
403    }
404}
405
406impl TransformerLayer for Glm5NextDsaLayer {
407    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn LayerState>> {
408        Ok(Box::new(Glm5NextDsaState::alloc(gpu, &self.cfg)?))
409    }
410
411    /// 2026-09-25: Frees a `Glm5NextDsaState`; any other state type is left alone. The
412    /// composite `Glm5NextLayer` has its own override that does the same.
413    fn release_state(&self, state: &mut dyn LayerState, gpu: &dyn GpuBackend) -> Result<()> {
414        if let Some(dsa) = state.as_any_mut().downcast_mut::<Glm5NextDsaState>() {
415            dsa.free(gpu)?;
416        }
417        Ok(())
418    }
419
420    #[allow(clippy::too_many_arguments)]
421    fn decode(
422        &self,
423        hidden: DevicePtr,
424        _residual: DevicePtr,
425        state: &mut dyn LayerState,
426        kv_cache: &mut PagedKvCache,
427        seq_len: usize,
428        block_table: &mut Vec<u32>,
429        _disk_block_ids: &mut Vec<u32>,
430        _disk_last_offloaded_per_layer: &mut Vec<u32>,
431        ctx: &ForwardContext,
432        stream: u64,
433    ) -> Result<()> {
434        self.decode_k(
435            hidden,
436            1,
437            state,
438            kv_cache,
439            seq_len,
440            block_table,
441            ctx,
442            stream,
443            // 2026-09-25: `is_prefill`: a single-token decode is not a prefill sub-chunk.
444            false,
445        )
446    }
447}
448
449impl metrale_model_layers::layer::LayerCapabilities for Glm5NextDsaLayer {}
450impl metrale_model_layers::layer::LayerWeightSetup for Glm5NextDsaLayer {}
451impl metrale_model_layers::layer::LayerWriteOnAccept for Glm5NextDsaLayer {}
452
453impl metrale_model_layers::layer::LayerGraphHooks for Glm5NextDsaLayer {
454    /// 2026-09-25: Fails when a replay writing indexer rows up to `seq_len + k` would pass the
455    /// cache capacity; the error carries the context "DSA replay pre-check".
456    fn check_replay_room(&self, state: &dyn LayerState, seq_len: usize, k: usize) -> Result<()> {
457        state
458            .as_any()
459            .downcast_ref::<Glm5NextDsaState>()
460            .ok_or_else(|| {
461                anyhow::anyhow!("Glm5NextDsaLayer got a state that is not Glm5NextDsaState")
462            })?
463            .ensure_room_through(seq_len + k)
464            .with_context(|| {
465                format!("DSA replay pre-check (before launch_graph, seq_len {seq_len} + k {k})")
466            })
467    }
468
469    /// 2026-09-25: A replayed graph writes the indexer rows (positions from device memory)
470    /// without running `decode`, so this moves the host-side row counter to where `decode_k`
471    /// would have left it (`Glm5NextDsaState::sync_to`).
472    fn sync_replayed_step(
473        &self,
474        state: &mut dyn LayerState,
475        seq_len: usize,
476        k: usize,
477    ) -> Result<()> {
478        state
479            .as_any_mut()
480            .downcast_mut::<Glm5NextDsaState>()
481            .ok_or_else(|| {
482                anyhow::anyhow!("Glm5NextDsaLayer got a state that is not Glm5NextDsaState")
483            })?
484            .sync_to(seq_len, k)
485    }
486}
487
488impl metrale_model_layers::layer::LayerAuxState for Glm5NextDsaLayer {}
489impl metrale_model_layers::layer::LayerSplitPrefill for Glm5NextDsaLayer {}
490impl metrale_model_layers::circuit_exec::CircuitBindings for Glm5NextDsaLayer {}
491
492#[cfg(test)]
493mod tests;
494
495#[cfg(test)]
496mod bt_trim_tests;