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;