metrale_model_arch/glm5next_dsa/attend.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GLM-5.3 DSA attention: the launcher for the selected-index NoPE MLA
4//! paged decode.
5//!
6//! Owner: model-arch (GLM-5.3 DSA).
7//! Invariants:
8//! - `decode_attention` launches only after `DsaDecodePaging::validate`
9//! passes, the selection has one query row per decoded sequence, and
10//! `kv_lora_rank` equals `KERNEL_KV_LORA_DIM`.
11//!
12//! It consumes what [`super::select`] produced: a `[q_rows, out_width]` i32 row
13//! of token ids with `-1` holes, and gathers exactly those tokens from the
14//! paged FP8 latent cache. `dsa_mla_masked_attn` is an oracle, not this path
15//! (see [`super::MASKED_ATTN_MAX_KEYS`]). When the selection row has no
16//! duplicates, the gather attends to the same tokens as the reference's masked
17//! attention, whose mask (`glm5next_dsa_ref::topk_to_mask`) is 0/1 set
18//! membership.
19//!
20//! # NoPE, and why neither DeepSeek-V4 MLA decode kernel would do
21//!
22//! GLM-5.3 is NoPE: the `glm5_next` config parser refuses `qk_rope_head_dim != 0`,
23//! so the latent is the whole cache token. `deepseek-v4-flash/nvfp4/mla_paged_decode.cu`
24//! declares `kv_cache_dim` and never reads it (it hardcodes `ROPE_DIM 64`);
25//! `mla_paged_decode_fp8.cu` in the same directory uses the runtime stride but
26//! then overwrites dims 448–511 with rope bytes read past the latent, which under
27//! NoPE belong to the next token. Hence a GLM-target kernel with no rope arm.
28
29use anyhow::{Result, bail};
30use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
31use metrale_gpu_runtime::kernel_args::KernelLaunch;
32
33use super::{Glm5NextDsaConfig, select::DsaSelectGeometry};
34
35/// 2026-09-25: Module name the DSA decode kernel resolves from. An unlisted `.cu`
36/// takes its file stem, and this one lives in the `glm-5.3-flash` target.
37pub const DSA_DECODE_MODULE: &str = "glm5next_dsa_mla_decode";
38
39/// 2026-09-25: Threads per block: `NUM_WARPS * WARP_SIZE` (8 × 32) in the kernel.
40const DECODE_BLOCK: u32 = 256;
41
42/// 2026-09-25: The selected-index MLA decode entry point.
43#[derive(Clone, Copy)]
44pub struct Glm5NextDsaDecodeKernel(KernelHandle);
45
46impl Glm5NextDsaDecodeKernel {
47 /// 2026-09-25: Resolved with `kernel()`, not `try_kernel`: a missing entry
48 /// point is an error, with no dense fallback.
49 pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
50 Ok(Self(gpu.kernel(
51 DSA_DECODE_MODULE,
52 "glm5next_dsa_mla_decode_fp8",
53 )?))
54 }
55}
56
57/// 2026-09-25: Everything the decode reads, all caller-owned.
58#[derive(Debug, Clone, Copy)]
59pub struct DsaDecodeInputs {
60 /// 2026-09-25: `[num_q_heads * kv_lora_rank]` BF16: this rank's absorbed
61 /// queries.
62 pub q: DevicePtr,
63 /// 2026-09-25: FP8 paged latent cache. In absorbed NoPE MLA, K and V are the
64 /// same buffer; both are taken so a caller may pass them separately.
65 pub k_cache: DevicePtr,
66 pub v_cache: DevicePtr,
67 /// 2026-09-25: `[num_q_heads * kv_lora_rank]` BF16 output.
68 pub out: DevicePtr,
69 /// 2026-09-25: `[num_seqs, max_blocks_per_seq]` i32.
70 pub block_tables: DevicePtr,
71 /// 2026-09-25: `[num_seqs]` i32.
72 pub seq_lens: DevicePtr,
73 /// 2026-09-25: `[num_seqs, out_width]` i32:
74 /// [`super::select::DsaSelectScratch::tokens`].
75 pub sel_indices: DevicePtr,
76 pub k_scale: f32,
77 pub v_scale: f32,
78}
79
80/// 2026-09-25: Paging geometry the decode needs and the selection does not.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct DsaDecodePaging {
83 pub num_seqs: usize,
84 pub num_q_heads: usize,
85 pub num_kv_heads: usize,
86 pub max_blocks_per_seq: usize,
87 pub block_size: usize,
88 pub cache_stride_bytes: u64,
89}
90
91impl DsaDecodePaging {
92 /// 2026-09-25: Refuses zero sequences, heads or `block_size`, a `block_size`
93 /// that is not a multiple of `index_kpool`, and any `num_kv_heads` but 1.
94 ///
95 /// The `index_kpool` rule is the selector's, not this kernel's: pools are
96 /// built over absolute positions, so a block that straddles a pool boundary
97 /// splits a pool across two pages. The per-token gather would not notice.
98 pub fn validate(&self, cfg: &Glm5NextDsaConfig) -> Result<()> {
99 if self.num_seqs == 0 || self.num_q_heads == 0 {
100 bail!(
101 "DSA decode: degenerate launch ({} seqs, {} heads)",
102 self.num_seqs,
103 self.num_q_heads
104 );
105 }
106 if self.block_size == 0 {
107 bail!("DSA decode: block_size must be > 0");
108 }
109 if !self.block_size.is_multiple_of(cfg.index_kpool) {
110 bail!(
111 "DSA decode: block_size {} is not a multiple of index_kpool {} — a pool \
112 would straddle a page boundary",
113 self.block_size,
114 cfg.index_kpool
115 );
116 }
117 if self.num_kv_heads != 1 {
118 bail!(
119 "DSA decode: MLA carries a single latent KV head, got {}",
120 self.num_kv_heads
121 );
122 }
123 Ok(())
124 }
125}
126
127/// 2026-09-25: Launch the selected-index decode. Enqueued on `stream`, not
128/// synchronised.
129///
130/// `geom.q_rows` must equal `paging.num_seqs`: one query row per sequence. A
131/// mismatch would index the selection rows with the wrong stride, so it is an
132/// error.
133pub fn decode_attention(
134 gpu: &dyn GpuBackend,
135 kernel: Glm5NextDsaDecodeKernel,
136 cfg: &Glm5NextDsaConfig,
137 geom: &DsaSelectGeometry,
138 paging: &DsaDecodePaging,
139 inputs: &DsaDecodeInputs,
140 stream: u64,
141) -> Result<()> {
142 paging.validate(cfg)?;
143 if geom.q_rows != paging.num_seqs {
144 bail!(
145 "DSA decode: selection has {} query rows but {} sequences are being decoded; \
146 the selection row stride would be wrong",
147 geom.q_rows,
148 paging.num_seqs
149 );
150 }
151
152 // 2026-09-25: The kernel tiles 512 latent dims across 32 lanes at 16 each.
153 // `Glm5NextDsaConfig::validate` checks the same bound; it is repeated here
154 // because the kernel itself would not notice a mismatch.
155 if cfg.kv_lora_rank != super::KERNEL_KV_LORA_DIM {
156 bail!(
157 "DSA decode: kv_lora_rank {} != kernel tiling {}",
158 cfg.kv_lora_rank,
159 super::KERNEL_KV_LORA_DIM
160 );
161 }
162
163 KernelLaunch::new(gpu, kernel.0)
164 .grid([paging.num_q_heads as u32, paging.num_seqs as u32, 1])
165 .block([DECODE_BLOCK, 1, 1])
166 .arg_ptr(inputs.q)
167 .arg_ptr(inputs.k_cache)
168 .arg_ptr(inputs.v_cache)
169 .arg_ptr(inputs.out)
170 .arg_ptr(inputs.block_tables)
171 .arg_ptr(inputs.seq_lens)
172 .arg_ptr(inputs.sel_indices)
173 .arg_u32(geom.out_width as u32)
174 .arg_u32(paging.max_blocks_per_seq as u32)
175 .arg_u32(paging.num_q_heads as u32)
176 .arg_u32(paging.num_kv_heads as u32)
177 .arg_u32(cfg.kv_lora_rank as u32)
178 .arg_u32(paging.block_size as u32)
179 // 2026-09-25: NoPE: the score scale is over the latent width, which is the
180 // whole cache token.
181 .arg_f32((cfg.kv_lora_rank as f32).powf(-0.5))
182 .arg_f32(inputs.k_scale)
183 .arg_f32(inputs.v_scale)
184 .arg_u64(paging.cache_stride_bytes)
185 .launch(stream)?;
186 Ok(())
187}
188
189#[cfg(test)]
190mod tests;