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;