metrale_model_arch/attn_v41/
decode.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// provenance-id: 526f6e616c6420522e205374657369616b
3
4//! 2026-09-25: The single-token decode step split into a host half
5//! (`decode_prep`: the per-token uploads) and a capturable device half
6//! (`decode_body`), the projection and attend-and-project bodies both paths
7//! share, and the check that says whether a layer's decode can be captured
8//! into a CUDA graph.
9//!
10//! Owner: model-arch, DeepSeek-V4.1.
11//! Invariants:
12//! - `decode_prep` and `decode_body` refuse kv-source and index-source layers.
13
14use anyhow::{Context, Result, ensure};
15use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
16use metrale_gpu_runtime::kernel_args::KernelLaunch;
17
18use super::{
19    AttnMat, AttnV41, AttnV41LayerState, AttnV41LayerWeights, SharedV41, upload_i32_async,
20};
21use crate::deepseek_v41_ref::attn::window_topk_idxs;
22use metrale_model_layers::layers::ops::{kquant_mmvq_pair_w, kquant_q8_1_rows};
23
24/// 2026-09-25: `METRALE_DS41_SPARSE_SPLIT`, read once: the blocks per (token,
25/// head) that `attn_v41_sparse_attn` splits the output dimension across,
26/// 1..=8, default 2 (other values fall back to 2). Every block recomputes the
27/// same scores and sums its outputs in the same order, so the bytes do not
28/// depend on the value.
29fn sparse_split() -> u32 {
30    static V: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
31    *V.get_or_init(|| {
32        std::env::var("METRALE_DS41_SPARSE_SPLIT")
33            .ok()
34            .and_then(|v| v.parse().ok())
35            .filter(|&s| (1..=8).contains(&s))
36            .unwrap_or(2)
37    })
38}
39
40impl AttnV41 {
41    /// 2026-09-25: q (low-rank, normed, up-projected, rotated) and kv (one
42    /// latent row per token, normed, rotated, fp8-rounded) from the normed
43    /// input `x`. Device work only: the positions are read from `pos` /
44    /// `head_pos`.
45    pub(super) fn q_kv_projections(
46        &self,
47        gpu: &dyn GpuBackend,
48        w: &AttnV41LayerWeights,
49        x: DevicePtr,
50        m: usize,
51        yarn: bool,
52        stream: u64,
53    ) -> Result<()> {
54        let c = &self.cfg;
55        let (nh, hd, dim) = (c.n_heads, c.head_dim, c.dim);
56        // 2026-09-25: `wq_a` and `wkv` read the same input: one q8_1
57        // quantisation and one launch for both when both are Q2_K and m <= 8.
58        if let (AttnMat::Q2K(a), AttnMat::Q2K(kv), true) = (w.wq_a, w.wkv, m <= 8) {
59            kquant_q8_1_rows(
60                gpu,
61                self.k.q8_rows,
62                x,
63                self.a_q8,
64                m as u32,
65                dim as u32,
66                stream,
67            )?;
68            kquant_mmvq_pair_w(
69                gpu,
70                self.k.mmvq_q2k_pair_w,
71                (a, self.qr_raw, c.q_rank as u32),
72                (kv, self.kv_raw, hd as u32),
73                self.a_q8,
74                dim as u32,
75                m as u32,
76                stream,
77            )?;
78        } else {
79            self.gemm(gpu, x, w.wq_a, self.qr_raw, m, c.q_rank, dim, stream)?;
80            self.gemm(gpu, x, w.wkv, self.kv_raw, m, hd, dim, stream)?;
81        }
82        self.rmsnorm(
83            gpu,
84            false,
85            self.qr_raw,
86            w.q_norm,
87            self.qr,
88            m,
89            c.q_rank,
90            stream,
91        )?;
92        self.gemm(gpu, self.qr, w.wq_b, self.q, m, nh * hd, c.q_rank, stream)?;
93        self.rope(gpu, self.q, self.head_pos, m * nh, hd, yarn, false, stream)?;
94        self.rmsnorm(gpu, false, self.kv_raw, w.kv_norm, self.kv, m, hd, stream)?;
95        self.rope(gpu, self.kv, self.pos, m, hd, yarn, false, stream)?;
96        self.act_quant(gpu, self.kv, m * hd, stream)
97    }
98
99    /// 2026-09-25: Sparse attention over the selection in `idx` (`topk` entries
100    /// a token, -1 = absent), the inverse rotation and the grouped output
101    /// projection into `self.out`. Device work only.
102    #[allow(clippy::too_many_arguments)]
103    pub(super) fn attend_and_project(
104        &self,
105        gpu: &dyn GpuBackend,
106        w: &AttnV41LayerWeights,
107        rows_a: DevicePtr,
108        rows_a_len: usize,
109        rows_b: Option<DevicePtr>,
110        idx: DevicePtr,
111        topk: usize,
112        m: usize,
113        yarn: bool,
114        stream: u64,
115    ) -> Result<()> {
116        let c = &self.cfg;
117        let (nh, hd, dim) = (c.n_heads, c.head_dim, c.dim);
118        let scale = (hd as f32).powf(-0.5);
119        KernelLaunch::new(gpu, self.k.sparse_attn)
120            .grid([m as u32, nh as u32, sparse_split()])
121            .block([256, 1, 1])
122            .arg_ptr(self.q)
123            .arg_ptr(rows_a)
124            .arg_ptr(rows_b.unwrap_or(rows_a))
125            .arg_u32(rows_a_len as u32)
126            .arg_ptr(idx)
127            .arg_ptr(w.sink)
128            .arg_ptr(self.o)
129            .arg_u32(nh as u32)
130            .arg_u32(hd as u32)
131            .arg_u32(topk as u32)
132            .arg_f32(scale)
133            .launch(stream)?;
134        // 2026-09-25: `self.o` keeps the pre-rotation output (the reference's
135        // `sa_o`, returned as `AttnV41Run::o`); the inverse rotation runs on a copy.
136        let o_copy = self.o_rot;
137        gpu.copy_d2d_async(self.o, o_copy, m * nh * hd * 2, stream)?;
138        self.rope(gpu, o_copy, self.head_pos, m * nh, hd, yarn, true, stream)?;
139
140        // 2026-09-25: og[t, g*o_rank + r] = o_g . wo_a[g*o_rank + r]
141        if let (AttnMat::Q2K(blocks), true) = (w.wo_a, m <= 8) {
142            self.wo_a_grouped(gpu, blocks, o_copy, m, stream)?;
143        } else {
144            self.wo_a_per_group(gpu, w, o_copy, m, stream)?;
145        }
146        self.gemm(
147            gpu,
148            self.og,
149            w.wo_b,
150            self.out,
151            m,
152            dim,
153            c.groups * c.o_rank,
154            stream,
155        )?;
156        Ok(())
157    }
158
159    /// 2026-09-25: The per-group `wo_a` path (bf16 `wo_a`, or m > 8): slice the
160    /// group's columns out of the rotated attention output, project, scatter
161    /// into `og`.
162    pub(super) fn wo_a_per_group(
163        &self,
164        gpu: &dyn GpuBackend,
165        w: &AttnV41LayerWeights,
166        o_copy: DevicePtr,
167        m: usize,
168        stream: u64,
169    ) -> Result<()> {
170        let c = &self.cfg;
171        let (nh, hd) = (c.n_heads, c.head_dim);
172        let gw = c.gw();
173        for g in 0..c.groups {
174            KernelLaunch::new(gpu, self.k.slice_cols)
175                .grid([m as u32, 1, 1])
176                .block([256, 1, 1])
177                .arg_ptr(o_copy)
178                .arg_ptr(self.slice_in)
179                .arg_u32((nh * hd) as u32)
180                .arg_u32((g * gw) as u32)
181                .arg_u32(gw as u32)
182                .launch(stream)?;
183            self.gemm(
184                gpu,
185                self.slice_in,
186                w.wo_a.at_rows(g * c.o_rank, gw),
187                self.slice_out,
188                m,
189                c.o_rank,
190                gw,
191                stream,
192            )?;
193            KernelLaunch::new(gpu, self.k.scatter_cols)
194                .grid([m as u32, 1, 1])
195                .block([256, 1, 1])
196                .arg_ptr(self.slice_out)
197                .arg_ptr(self.og)
198                .arg_u32((c.groups * c.o_rank) as u32)
199                .arg_u32((g * c.o_rank) as u32)
200                .arg_u32(c.o_rank as u32)
201                .launch(stream)?;
202        }
203        Ok(())
204    }
205
206    /// 2026-09-25: Whether this layer's single-token step can be captured into
207    /// a CUDA graph: false on kv and index sources, whose forward decides the
208    /// compressor group and the index top-k on the host mid-forward (position
209    /// parity, a score download). Every other layer is kernels and copies once
210    /// its per-token inputs are read from the device.
211    pub fn decode_capturable(&self, w: &AttnV41LayerWeights) -> bool {
212        !w.role.is_kv_source && !w.role.is_index_source
213    }
214
215    /// 2026-09-25: The `topk` a captured step launches `sparse_attn` with: the
216    /// widest selection of the layer's class, so the launch argument is
217    /// constant; `decode_prep_role` pads the selection with -1. A -1 entry adds
218    /// an exact zero to its thread's denominator sum and nothing to the output,
219    /// so the result equals the eager step's bit for bit.
220    pub fn decode_fixed_topk(&self, w: &AttnV41LayerWeights) -> usize {
221        self.decode_fixed_topk_of(w.role.ratio > 0)
222    }
223
224    fn decode_fixed_topk_of(&self, ratio_positive: bool) -> usize {
225        let c = &self.cfg;
226        if ratio_positive {
227            (c.window + c.index_topk).min(2048)
228        } else {
229            c.window
230        }
231    }
232
233    /// 2026-09-25: Forget what the captured decode step last uploaded; the next
234    /// [`Self::decode_prep`] re-uploads. Call after any eager `forward` (it
235    /// writes `pos` / `head_pos` / `idx_dev` for itself) and at a new step.
236    pub fn invalidate_decode_uploads(&mut self) {
237        self.decode_pos = None;
238        self.decode_idx = None;
239        self.decode_idx_win = None;
240    }
241
242    /// 2026-09-25: The selection buffer a captured step reads for a layer of
243    /// this class: `idx_dev` for ratio > 0, `idx_dev_win` for ratio 0.
244    fn decode_idx_of(&self, ratio_positive: bool) -> DevicePtr {
245        if ratio_positive {
246            self.idx_dev
247        } else {
248            self.idx_dev_win
249        }
250    }
251
252    /// 2026-09-25: The host half of a capturable layer's single-token step at
253    /// `start_pos`: the position into `pos` / `head_pos` and the padded
254    /// selection (window slots, then the shared index selection, then -1) into
255    /// the class's selection buffer, each only when it differs from what is
256    /// already there. Returns the compressed rows the captured `sparse_attn`
257    /// reads (`None` on ratio-0 layers): a pointer the graph bakes, which the
258    /// callers compare on every replay.
259    pub fn decode_prep(
260        &mut self,
261        w: &AttnV41LayerWeights,
262        shared: &SharedV41,
263        gpu: &dyn GpuBackend,
264        start_pos: usize,
265        stream: u64,
266    ) -> Result<Option<DevicePtr>> {
267        ensure!(
268            self.decode_capturable(w),
269            "attn_v41: decode_prep on a kv/index source layer"
270        );
271        self.decode_prep_role(w.role.ratio > 0, shared, gpu, start_pos, stream)
272    }
273
274    /// 2026-09-25: [`Self::decode_prep`] by selection class: `ratio_positive`
275    /// layers read the window plus the shared index selection from `idx_dev`,
276    /// the others the window alone from `idx_dev_win`; both classes can be
277    /// prepared for one multi-layer capture. Fails for `start_pos` outside
278    /// `1..max_seq`.
279    pub fn decode_prep_role(
280        &mut self,
281        ratio_positive: bool,
282        shared: &SharedV41,
283        gpu: &dyn GpuBackend,
284        start_pos: usize,
285        stream: u64,
286    ) -> Result<Option<DevicePtr>> {
287        let c = &self.cfg;
288        ensure!(
289            start_pos >= 1 && start_pos < c.max_seq,
290            "attn_v41: position {start_pos} outside the decode range 1..{}",
291            c.max_seq
292        );
293        if self.decode_pos != Some(start_pos) {
294            let p = start_pos as i32;
295            upload_i32_async(gpu, self.pos, &[p], stream)?;
296            upload_i32_async(gpu, self.head_pos, &vec![p; c.n_heads], stream)?;
297            self.decode_pos = Some(start_pos);
298        }
299        let fixed = self.decode_fixed_topk_of(ratio_positive);
300        let (mut idx, topk) = window_topk_idxs(c.window, 1, start_pos);
301        debug_assert_eq!(topk, c.window);
302        let rows_b = if ratio_positive {
303            ensure!(
304                shared.topk_idxs.len() == shared.topk,
305                "attn_v41: shared index selection is {} for one token x {}",
306                shared.topk_idxs.len(),
307                shared.topk
308            );
309            idx.extend_from_slice(&shared.topk_idxs);
310            Some(
311                shared
312                    .compress_kv
313                    .context("compressed layer before any kv source published")?,
314            )
315        } else {
316            None
317        };
318        ensure!(
319            idx.len() <= fixed,
320            "attn_v41: selection {} exceeds the fixed {fixed}",
321            idx.len()
322        );
323        idx.resize(fixed, -1);
324        let (buf, cache) = if ratio_positive {
325            (self.idx_dev, &mut self.decode_idx)
326        } else {
327            (self.idx_dev_win, &mut self.decode_idx_win)
328        };
329        if cache.as_deref() != Some(&idx[..]) {
330            upload_i32_async(gpu, buf, &idx, stream)?;
331            *cache = Some(idx);
332        }
333        Ok(rows_b)
334    }
335
336    /// 2026-09-25: The device half of a capturable layer's single-token step:
337    /// the projections, the window ring write at `pos % window` (slot read on
338    /// the device), sparse attention over the padded selection and the output
339    /// projection into the returned buffer. No host work and no per-token
340    /// scalar arguments, so a captured graph replays for any position once
341    /// `decode_prep` has updated the device inputs.
342    pub fn decode_body(
343        &self,
344        gpu: &dyn GpuBackend,
345        w: &AttnV41LayerWeights,
346        st: &AttnV41LayerState,
347        x: DevicePtr,
348        rows_b: Option<DevicePtr>,
349        stream: u64,
350    ) -> Result<DevicePtr> {
351        let c = &self.cfg;
352        ensure!(
353            self.decode_capturable(w),
354            "attn_v41: decode_body on a kv/index source layer"
355        );
356        ensure!(
357            (w.role.ratio > 0) == rows_b.is_some(),
358            "attn_v41: compressed rows {} on a ratio-{} layer",
359            if rows_b.is_some() { "given" } else { "missing" },
360            w.role.ratio
361        );
362        let yarn = w.role.ratio > 0;
363        self.q_kv_projections(gpu, w, x, 1, yarn, stream)?;
364        KernelLaunch::new(gpu, self.k.ring_put)
365            .grid([1, 1, 1])
366            .block([256, 1, 1])
367            .arg_ptr(self.kv)
368            .arg_ptr(st.window)
369            .arg_ptr(self.pos)
370            .arg_u32(c.window as u32)
371            .arg_u32(c.head_dim as u32)
372            .launch(stream)?;
373        self.attend_and_project(
374            gpu,
375            w,
376            st.window,
377            c.window,
378            rows_b,
379            self.decode_idx_of(yarn),
380            self.decode_fixed_topk(w),
381            1,
382            yarn,
383            stream,
384        )?;
385        Ok(self.out)
386    }
387}