spark_model/layers/attn_v41/
decode.rs

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