spark_model/layers/
qsa.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The Qwen3.8-Flash-Next QSA indexer — decode-side sparse-attention
4//! selection (#753 phase G).
5//!
6//! Reference: `Qwen4ExpTextQSAIndexer`. The attention layer's INPUT (the
7//! hc_pre mixed output) is projected to 4 query heads + 1 raw key per token;
8//! the visible prefix is grouped into 4-token blocks whose keys are
9//! mean-pooled, k_layernormed and roped at the block's first position; each
10//! query attends the top-512 blocks by `sum_h relu(q_h . k_b)/sqrt(128)`,
11//! plus the incomplete tail. At or below `budget + ratio - 1` (2051) visible
12//! tokens the selection is PROVABLY all-visible — the inert regime the port
13//! served in until now.
14//!
15//! v1 SCOPE (decode-side): raw keys are ingested during prefill and decode;
16//! selection runs at DECODE steps once the visible prefix exceeds the inert
17//! bound, and feeds the EXISTING paged decode attention through a gathered
18//! contiguous scratch + identity block table. Prefill queries beyond the
19//! inert bound still run dense (a one-time WARN documents the divergence;
20//! per-query prefill selection is stage 2). Single sequence, BF16 KV only.
21//!
22//! CUDA graphs: selection does a host top-k on the scores (D2H), which can
23//! never sit inside a captured graph — a layer carrying an indexer vetoes
24//! decode-graph capture entirely (graphs measured speed-NEUTRAL on GB10, so
25//! this costs nothing).
26
27use anyhow::{Context, Result};
28use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
29
30use crate::layers::ops;
31
32#[path = "qsa_select.rs"]
33mod qsa_select;
34#[path = "qsa_snapshot.rs"]
35mod qsa_snapshot;
36#[cfg(test)]
37#[path = "qsa_tests.rs"]
38mod tests;
39
40/// One decode step's selection: contiguous NHD `k/v` scratch + identity table.
41pub struct QsaSelection {
42    pub k_scratch: DevicePtr,
43    pub v_scratch: DevicePtr,
44    pub table_dev: DevicePtr,
45    pub seq_len_dev: DevicePtr,
46    pub n_sel: u32,
47    pub max_blocks: u32,
48}
49
50pub struct QsaSeqState {
51    /// Tokens whose raw keys are in `raw_keys` (contiguous from 0).
52    ingested: usize,
53    /// Complete 4-token blocks pooled into `block_keys`.
54    pooled: usize,
55    /// Identity block table upload done (needs block_size, known lazily).
56    table_len: usize,
57    /// [max_tokens, hd] BF16 — this sequence's raw indexer keys.
58    raw_keys: DevicePtr,
59    /// [max_tokens/ratio, hd] BF16 — this sequence's pooled block keys.
60    block_keys: DevicePtr,
61}
62
63pub struct QsaIndexer {
64    qk_proj_w: DevicePtr, // [ (n_heads+1)*hd, hidden ] BF16 row-major
65    q_norm_w: DevicePtr,  // [hd]
66    k_norm_w: DevicePtr,  // [hd]
67
68    n_heads: u32,
69    hd: u32,
70    ratio: u32,
71    budget: u32,
72    block_topk: u32,
73    rot: u32,
74    theta: f32,
75    eps: f32,
76    hidden: u32,
77    nkv_attn: u32,
78    hd_attn: u32,
79    max_tokens: usize,
80
81    k_pool_k: KernelHandle,
82    k_qprep_k: KernelHandle,
83    k_score_k: KernelHandle,
84    k_gather_k: KernelHandle,
85    k_qprep_rows_k: KernelHandle,
86    k_score_rows_k: KernelHandle,
87    /// Tensor-core split-q scorer. `try_kernel` — absent on any target
88    /// whose shadow predates it, which falls back to the scalar path.
89    k_score_rows_tc_k: KernelHandle,
90    k_prefill_attn_k: KernelHandle,
91
92    qk_scratch: DevicePtr, // [INGEST_SLAB, (n_heads+1)*hd] BF16
93    q_post: DevicePtr,     // [n_heads, hd] F32
94    scores_dev: DevicePtr, // [max_tokens/ratio] F32
95    sel_dev: DevicePtr,    // [budget + ratio] i32
96    k_scratch: DevicePtr,  // [budget+ratio, nkv_attn, hd_attn] BF16
97    v_scratch: DevicePtr,
98    table_dev: DevicePtr,   // [ceil((budget+ratio)/8)] i32 (any block_size >= 8)
99    seq_len_dev: DevicePtr, // [1] i32
100    /// The sequence's REAL block table, uploaded per prefill-select call —
101    /// chunk-0 metadata carries no device table (cache-skip attention is
102    /// contiguous), so the host Vec is the source of truth.
103    prefill_table_dev: DevicePtr, // [ceil(max_tokens/8)] i32
104}
105
106/// Prefill ingest GEMM slab (rows), bounding `qk_scratch`.
107const INGEST_SLAB: usize = 2048;
108
109impl QsaIndexer {
110    #[allow(clippy::too_many_arguments)]
111    pub fn new(
112        qk_proj_w: DevicePtr,
113        q_norm_w: DevicePtr,
114        k_norm_w: DevicePtr,
115        n_heads: usize,
116        hd: usize,
117        ratio: usize,
118        budget: usize,
119        rot: usize,
120        theta: f32,
121        eps: f32,
122        hidden: usize,
123        nkv_attn: usize,
124        hd_attn: usize,
125        gpu: &dyn GpuBackend,
126    ) -> Result<Self> {
127        anyhow::ensure!(
128            ratio > 0 && budget.is_multiple_of(ratio),
129            "QSA: budget % ratio != 0"
130        );
131        let max_tokens: usize = std::env::var("ATLAS_QSA_MAX_TOKENS")
132            .ok()
133            .and_then(|v| v.parse().ok())
134            .unwrap_or(32768);
135        let block_topk = budget / ratio;
136        let qk_width = (n_heads + 1) * hd;
137        let sel_cap = budget + ratio;
138        Ok(Self {
139            qk_proj_w,
140            q_norm_w,
141            k_norm_w,
142            n_heads: n_heads as u32,
143            hd: hd as u32,
144            ratio: ratio as u32,
145            budget: budget as u32,
146            block_topk: block_topk as u32,
147            rot: rot as u32,
148            theta,
149            eps,
150            hidden: hidden as u32,
151            nkv_attn: nkv_attn as u32,
152            hd_attn: hd_attn as u32,
153            max_tokens,
154            k_pool_k: gpu.kernel("qsa_indexer", "qsa_block_pool")?,
155            k_qprep_k: gpu.kernel("qsa_indexer", "qsa_qprep")?,
156            k_score_k: gpu.kernel("qsa_indexer", "qsa_score")?,
157            k_gather_k: gpu.kernel("qsa_indexer", "qsa_gather")?,
158            k_qprep_rows_k: gpu.kernel("qsa_indexer", "qsa_qprep_rows")?,
159            k_score_rows_k: gpu.kernel("qsa_indexer", "qsa_score_rows")?,
160            k_score_rows_tc_k: crate::layers::try_kernel(gpu, "qsa_indexer", "qsa_score_rows_tc"),
161            k_prefill_attn_k: gpu.kernel("qsa_indexer", "qsa_prefill_attn")?,
162            qk_scratch: gpu.alloc(INGEST_SLAB * qk_width * 2)?,
163            q_post: gpu.alloc(n_heads * hd * 4)?,
164            scores_dev: gpu.alloc(max_tokens / ratio * 4)?,
165            sel_dev: gpu.alloc(sel_cap * 4)?,
166            k_scratch: gpu.alloc(sel_cap * nkv_attn * hd_attn * 2)?,
167            v_scratch: gpu.alloc(sel_cap * nkv_attn * hd_attn * 2)?,
168            table_dev: gpu.alloc(sel_cap.div_ceil(8) * 4)?,
169            seq_len_dev: gpu.alloc(4)?,
170            prefill_table_dev: gpu.alloc(max_tokens.div_ceil(8) * 4)?,
171        })
172    }
173
174    /// The largest visible prefix whose selection is provably all-visible.
175    /// One sequence's indexer carry: counters + raw/pooled key buffers
176    /// (per-seq CONTENT; launch scratch stays layer-owned — steps serialize).
177    pub fn new_seq_state(&self, gpu: &dyn GpuBackend) -> Result<QsaSeqState> {
178        let hd = self.hd as usize;
179        let ratio = self.ratio as usize;
180        Ok(QsaSeqState {
181            ingested: 0,
182            pooled: 0,
183            table_len: 0,
184            raw_keys: gpu.alloc(self.max_tokens * hd * 2)?,
185            block_keys: gpu.alloc(self.max_tokens / ratio * hd * 2)?,
186        })
187    }
188
189    /// Release one sequence's indexer carry.
190    ///
191    /// `QsaSeqState` holds bare `DevicePtr`s, so dropping the struct frees
192    /// nothing. Without this every finished sequence left
193    /// `max_tokens * hd * 2` (raw) + `max_tokens/ratio * hd * 2` (pooled)
194    /// bytes on the device for EACH full-attention layer — at 200K context
195    /// and 12 such layers, ~739 MB per request. On unified memory that is
196    /// invisible to RSS and to `nvidia-smi`, so it surfaced only as the host
197    /// running out of RAM with no process to blame.
198    ///
199    /// Idempotent: each pointer is nulled as it is freed, so a second call
200    /// (or a release after a partial failure) cannot double-free. A failure
201    /// on the first buffer still attempts the second — leaking the rest
202    /// because the first free failed is the bug this exists to prevent.
203    pub fn release_seq_state(&self, st: &mut QsaSeqState, gpu: &dyn GpuBackend) -> Result<()> {
204        let mut first_err: Option<anyhow::Error> = None;
205        for p in [&mut st.raw_keys, &mut st.block_keys] {
206            if p.is_null() {
207                continue;
208            }
209            if let Err(e) = gpu.free(*p) {
210                first_err.get_or_insert(e);
211            }
212            *p = DevicePtr(0);
213        }
214        match first_err {
215            Some(e) => Err(e),
216            None => Ok(()),
217        }
218    }
219
220    pub fn inert_bound(&self) -> usize {
221        (self.budget + self.ratio - 1) as usize
222    }
223
224    fn qk_width(&self) -> usize {
225        (self.n_heads as usize + 1) * self.hd as usize
226    }
227
228    /// Ingest `num_tokens` prefill tokens starting at `seq_start`: project
229    /// qk, park the raw keys, pool freshly complete blocks. `seq_start == 0`
230    /// resets the sequence (single-seq v1, PLE-style).
231    pub fn prefill_ingest(
232        &self,
233        st: &mut QsaSeqState,
234        hidden: DevicePtr,
235        num_tokens: usize,
236        seq_start: usize,
237        gpu: &dyn GpuBackend,
238        stream: u64,
239    ) -> Result<()> {
240        if seq_start == 0 {
241            st.ingested = 0;
242            st.pooled = 0;
243        }
244        anyhow::ensure!(
245            seq_start == st.ingested,
246            "QSA: prefill chunk starts at {seq_start} but {} tokens are \
247             ingested — a prefix-cache skip bypassed the indexer. Serve \
248             qwen4_exp with the prefix cache disabled until QSA learns to \
249             re-ingest cached prefixes.",
250            st.ingested
251        );
252        anyhow::ensure!(
253            seq_start + num_tokens <= self.max_tokens,
254            "QSA: {} tokens exceeds ATLAS_QSA_MAX_TOKENS={}",
255            seq_start + num_tokens,
256            self.max_tokens
257        );
258
259        let hd = self.hd as usize;
260        let qkw = self.qk_width();
261        let mut off = 0usize;
262        while off < num_tokens {
263            let ts = INGEST_SLAB.min(num_tokens - off);
264            ops::cublas_bf16_proj_dense(
265                hidden.offset((off) * self.hidden as usize * 2),
266                self.qk_proj_w,
267                self.qk_scratch,
268                ts as u32,
269                qkw as u32,
270                self.hidden,
271                stream,
272            )
273            .context("QSA qk projection (prefill)")?;
274            // Raw key = the last hd columns of each row.
275            gpu.copy_d2d_2d_async(
276                self.qk_scratch.offset(self.n_heads as usize * hd * 2),
277                qkw * 2,
278                st.raw_keys.offset((seq_start + off) * hd * 2),
279                hd * 2,
280                hd * 2,
281                ts,
282                stream,
283            )?;
284            off += ts;
285        }
286        st.ingested = seq_start + num_tokens;
287        self.pool_new_blocks(st, gpu, stream)
288    }
289
290    fn pool_new_blocks(
291        &self,
292        st: &mut QsaSeqState,
293        gpu: &dyn GpuBackend,
294        stream: u64,
295    ) -> Result<()> {
296        let complete = st.ingested / self.ratio as usize;
297        if complete > st.pooled {
298            ops::qsa_block_pool(
299                gpu,
300                self.k_pool_k,
301                st.raw_keys,
302                self.k_norm_w,
303                st.block_keys,
304                st.pooled as u32,
305                (complete - st.pooled) as u32,
306                self.ratio,
307                self.hd,
308                self.rot,
309                self.theta,
310                self.eps,
311                stream,
312            )?;
313            st.pooled = complete;
314        }
315        Ok(())
316    }
317
318    // `prefill_select`: see `qsa_select.rs` (#[path] child, ≤500 LoC split).
319
320    /// Decode-step ingest + selection for the token at `pos` (0-based;
321    /// `pos + 1` visible). `None` inside the inert bound (dense is exact).
322    #[allow(clippy::too_many_arguments)]
323    pub fn decode_select(
324        &self,
325        st: &mut QsaSeqState,
326        normed: DevicePtr,
327        pos: usize,
328        k_pool: DevicePtr,
329        v_pool: DevicePtr,
330        block_table_dev: DevicePtr,
331        block_size: u32,
332        gpu: &dyn GpuBackend,
333        stream: u64,
334    ) -> Result<Option<QsaSelection>> {
335        anyhow::ensure!(
336            pos == st.ingested,
337            "QSA: decode at pos {pos} but {} tokens ingested — the indexer \
338             cache lost sync (prefix-cache skip or a rewound sequence)",
339            st.ingested
340        );
341        anyhow::ensure!(
342            pos < self.max_tokens,
343            "QSA: pos {pos} >= ATLAS_QSA_MAX_TOKENS"
344        );
345
346        let hd = self.hd as usize;
347        let qkw = self.qk_width();
348        // qk GEMV for this token; row 0 of the scratch.
349        ops::cublas_bf16_proj_dense(
350            normed,
351            self.qk_proj_w,
352            self.qk_scratch,
353            1,
354            qkw as u32,
355            self.hidden,
356            stream,
357        )
358        .context("QSA qk projection (decode)")?;
359        gpu.copy_d2d_async(
360            self.qk_scratch.offset(self.n_heads as usize * hd * 2),
361            st.raw_keys.offset(pos * hd * 2),
362            hd * 2,
363            stream,
364        )?;
365        st.ingested = pos + 1;
366        self.pool_new_blocks(st, gpu, stream)?;
367
368        let visible = pos + 1;
369        let complete = visible / self.ratio as usize;
370        if complete <= self.block_topk as usize {
371            return Ok(None); // provably all-visible: dense path is exact
372        }
373
374        // q prep + block scores.
375        ops::qsa_qprep(
376            gpu,
377            self.k_qprep_k,
378            self.qk_scratch,
379            self.q_norm_w,
380            self.q_post,
381            self.n_heads,
382            self.hd,
383            self.rot,
384            pos as u32,
385            self.theta,
386            self.eps,
387            stream,
388        )?;
389        ops::qsa_score(
390            gpu,
391            self.k_score_k,
392            self.q_post,
393            st.block_keys,
394            self.scores_dev,
395            complete as u32,
396            self.n_heads,
397            self.hd,
398            stream,
399        )?;
400
401        // Host top-k over the block scores (D2H — decode graphs are vetoed
402        // whenever an indexer is present, so this is never inside a capture).
403        let mut raw = vec![0u8; complete * 4];
404        gpu.copy_d2h_on_stream(self.scores_dev, &mut raw, stream)?;
405        let scores: Vec<f32> = raw
406            .chunks_exact(4)
407            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
408            .collect();
409        let mut order: Vec<u32> = (0..complete as u32).collect();
410        // torch.topk returns the k largest, ties broken by LOWER index —
411        // sort by (-score, index) and take the first k for identical sets.
412        order.sort_by(|&a, &b| {
413            scores[b as usize]
414                .partial_cmp(&scores[a as usize])
415                .unwrap_or(std::cmp::Ordering::Equal)
416                .then(a.cmp(&b))
417        });
418        let mut blocks: Vec<u32> = order[..self.block_topk as usize].to_vec();
419        blocks.sort_unstable();
420
421        let ratio = self.ratio as usize;
422        let mut sel: Vec<i32> = Vec::with_capacity(self.budget as usize + ratio);
423        for b in &blocks {
424            let base = *b as i32 * self.ratio as i32;
425            for r in 0..self.ratio as i32 {
426                sel.push(base + r);
427            }
428        }
429        for t in complete * ratio..visible {
430            sel.push(t as i32);
431        }
432        let n_sel = sel.len() as u32;
433
434        let sel_bytes: Vec<u8> = sel.iter().flat_map(|v| v.to_le_bytes()).collect();
435        gpu.copy_h2d_async(&sel_bytes, self.sel_dev, stream)?;
436        ops::qsa_gather(
437            gpu,
438            self.k_gather_k,
439            k_pool,
440            v_pool,
441            block_table_dev,
442            self.sel_dev,
443            self.k_scratch,
444            self.v_scratch,
445            n_sel,
446            block_size,
447            self.nkv_attn,
448            self.hd_attn,
449            stream,
450        )?;
451
452        // Identity table + seq_len for the scratch-as-paged-cache view.
453        let pages = (n_sel as usize).div_ceil(block_size as usize);
454        if st.table_len < pages {
455            let ident: Vec<u8> = (0..pages as i32).flat_map(|v| v.to_le_bytes()).collect();
456            gpu.copy_h2d_async(&ident, self.table_dev, stream)?;
457            st.table_len = pages;
458        }
459        gpu.copy_h2d_async(&(n_sel as i32).to_le_bytes(), self.seq_len_dev, stream)?;
460
461        Ok(Some(QsaSelection {
462            k_scratch: self.k_scratch,
463            v_scratch: self.v_scratch,
464            table_dev: self.table_dev,
465            seq_len_dev: self.seq_len_dev,
466            n_sel,
467            max_blocks: pages as u32,
468        }))
469    }
470}