metrale_model_layers/layers/
qsa.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The Qwen3.8-Flash-Next QSA indexer: sparse-attention token selection.
4//!
5//! Owner: model-layers (QSA).
6//! Invariants:
7//! - Keys are ingested in position order: `prefill_ingest` and
8//!   `decode_select` refuse a start position other than `ingested`.
9//! - At or below `inert_bound()` (`budget + ratio - 1`) visible tokens every
10//!   visible token is selected, so the dense path is used unchanged.
11//!
12//! Reference: `Qwen4ExpTextQSAIndexer`. The attention layer's input is
13//! projected to `n_heads` query heads plus one raw key per token. The visible
14//! prefix is grouped into `ratio`-token blocks whose keys are mean-pooled,
15//! k_layernormed and roped at the block's first position. Each query attends
16//! the top `budget / ratio` blocks by `sum_h relu(q_h . k_b) / sqrt(hd)`, plus
17//! the incomplete tail.
18//!
19//! Raw keys are ingested during prefill and decode. Decode selection gathers
20//! the selected K/V into a contiguous scratch that, through an identity block
21//! table, the paged decode attention reads as a cache. Prefill selection
22//! (`prefill_select`, in `qsa_select.rs`) overwrites the attention context of
23//! the rows past the bound. Selection takes a host top-k over scores read back
24//! from the device, so a layer with an indexer reports
25//! `decode_graph_unsupported`.
26
27use anyhow::{Context, Result};
28use metrale_gpu_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/// 2026-09-25: 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    /// 2026-09-25: Tokens whose raw keys are in `raw_keys` (contiguous from 0).
52    ingested: usize,
53    /// 2026-09-25: Complete `ratio`-token blocks pooled into `block_keys`.
54    pooled: usize,
55    /// 2026-09-25: Pages of the identity block table already uploaded to the
56    /// layer's `table_dev` by `decode_select`.
57    table_len: usize,
58    /// 2026-09-25: `[max_tokens, hd]` BF16: this sequence's raw indexer keys.
59    raw_keys: DevicePtr,
60    /// 2026-09-25: `[max_tokens/ratio, hd]` BF16: this sequence's pooled block keys.
61    block_keys: DevicePtr,
62}
63
64pub struct QsaIndexer {
65    // 2026-09-25: `[(n_heads+1)*hd, hidden]` BF16 row-major, then the two
66    // `[hd]` norm weights.
67    qk_proj_w: DevicePtr,
68    q_norm_w: DevicePtr,
69    k_norm_w: DevicePtr,
70
71    n_heads: u32,
72    hd: u32,
73    ratio: u32,
74    budget: u32,
75    block_topk: u32,
76    rot: u32,
77    theta: f32,
78    eps: f32,
79    hidden: u32,
80    nkv_attn: u32,
81    hd_attn: u32,
82    max_tokens: usize,
83
84    k_pool_k: KernelHandle,
85    k_qprep_k: KernelHandle,
86    k_score_k: KernelHandle,
87    k_gather_k: KernelHandle,
88    k_qprep_rows_k: KernelHandle,
89    k_score_rows_k: KernelHandle,
90    /// 2026-09-25: Tensor-core row scorer, bound with `try_kernel`: a null handle
91    /// on a target without it, and `prefill_select` then uses the scalar
92    /// `qsa_score_rows`.
93    k_score_rows_tc_k: KernelHandle,
94    k_prefill_attn_k: KernelHandle,
95
96    // 2026-09-25: Scratch shapes: `qk_scratch` [INGEST_SLAB, (n_heads+1)*hd]
97    // BF16; `q_post` [n_heads, hd] F32; `scores_dev` [max_tokens/ratio] F32;
98    // `sel_dev` [budget+ratio] i32; `k_scratch`/`v_scratch`
99    // [budget+ratio, nkv_attn, hd_attn] BF16; `table_dev`
100    // [ceil((budget+ratio)/8)] i32, enough for any block_size >= 8;
101    // `seq_len_dev` [1] i32.
102    qk_scratch: DevicePtr,
103    q_post: DevicePtr,
104    scores_dev: DevicePtr,
105    sel_dev: DevicePtr,
106    k_scratch: DevicePtr,
107    v_scratch: DevicePtr,
108    table_dev: DevicePtr,
109    seq_len_dev: DevicePtr,
110    /// 2026-09-25: The sequence's block table, `[ceil(max_tokens/8)]` i32,
111    /// uploaded from the caller's host slice on each `prefill_select` call.
112    prefill_table_dev: DevicePtr,
113}
114
115/// 2026-09-25: Prefill ingest GEMM slab (rows), bounding `qk_scratch`.
116const INGEST_SLAB: usize = 2048;
117
118impl QsaIndexer {
119    #[allow(clippy::too_many_arguments)]
120    pub fn new(
121        qk_proj_w: DevicePtr,
122        q_norm_w: DevicePtr,
123        k_norm_w: DevicePtr,
124        n_heads: usize,
125        hd: usize,
126        ratio: usize,
127        budget: usize,
128        rot: usize,
129        theta: f32,
130        eps: f32,
131        hidden: usize,
132        nkv_attn: usize,
133        hd_attn: usize,
134        gpu: &dyn GpuBackend,
135    ) -> Result<Self> {
136        anyhow::ensure!(
137            ratio > 0 && budget.is_multiple_of(ratio),
138            "QSA: budget % ratio != 0"
139        );
140        let max_tokens: usize = std::env::var("METRALE_QSA_MAX_TOKENS")
141            .ok()
142            .and_then(|v| v.parse().ok())
143            .unwrap_or(32768);
144        let block_topk = budget / ratio;
145        let qk_width = (n_heads + 1) * hd;
146        let sel_cap = budget + ratio;
147        Ok(Self {
148            qk_proj_w,
149            q_norm_w,
150            k_norm_w,
151            n_heads: n_heads as u32,
152            hd: hd as u32,
153            ratio: ratio as u32,
154            budget: budget as u32,
155            block_topk: block_topk as u32,
156            rot: rot as u32,
157            theta,
158            eps,
159            hidden: hidden as u32,
160            nkv_attn: nkv_attn as u32,
161            hd_attn: hd_attn as u32,
162            max_tokens,
163            k_pool_k: gpu.kernel("qsa_indexer", "qsa_block_pool")?,
164            k_qprep_k: gpu.kernel("qsa_indexer", "qsa_qprep")?,
165            k_score_k: gpu.kernel("qsa_indexer", "qsa_score")?,
166            k_gather_k: gpu.kernel("qsa_indexer", "qsa_gather")?,
167            k_qprep_rows_k: gpu.kernel("qsa_indexer", "qsa_qprep_rows")?,
168            k_score_rows_k: gpu.kernel("qsa_indexer", "qsa_score_rows")?,
169            k_score_rows_tc_k: crate::layers::try_kernel(gpu, "qsa_indexer", "qsa_score_rows_tc"),
170            k_prefill_attn_k: gpu.kernel("qsa_indexer", "qsa_prefill_attn")?,
171            qk_scratch: gpu.alloc(INGEST_SLAB * qk_width * 2)?,
172            q_post: gpu.alloc(n_heads * hd * 4)?,
173            scores_dev: gpu.alloc(max_tokens / ratio * 4)?,
174            sel_dev: gpu.alloc(sel_cap * 4)?,
175            k_scratch: gpu.alloc(sel_cap * nkv_attn * hd_attn * 2)?,
176            v_scratch: gpu.alloc(sel_cap * nkv_attn * hd_attn * 2)?,
177            table_dev: gpu.alloc(sel_cap.div_ceil(8) * 4)?,
178            seq_len_dev: gpu.alloc(4)?,
179            prefill_table_dev: gpu.alloc(max_tokens.div_ceil(8) * 4)?,
180        })
181    }
182
183    /// 2026-09-25: One sequence's indexer carry: counters + raw/pooled key buffers.
184    /// The launch scratch stays in the layer and is shared by every sequence.
185    pub fn new_seq_state(&self, gpu: &dyn GpuBackend) -> Result<QsaSeqState> {
186        let hd = self.hd as usize;
187        let ratio = self.ratio as usize;
188        Ok(QsaSeqState {
189            ingested: 0,
190            pooled: 0,
191            table_len: 0,
192            raw_keys: gpu.alloc(self.max_tokens * hd * 2)?,
193            block_keys: gpu.alloc(self.max_tokens / ratio * hd * 2)?,
194        })
195    }
196
197    /// 2026-09-25: Release one sequence's indexer carry.
198    ///
199    /// `QsaSeqState` holds bare `DevicePtr`s, so dropping the struct frees
200    /// nothing; the `max_tokens * hd * 2` raw-key and
201    /// `max_tokens/ratio * hd * 2` block-key buffers are freed only here.
202    ///
203    /// Idempotent: each pointer is nulled once its free is attempted, so a
204    /// second call (or a release after a partial failure) cannot double-free.
205    /// A failure on the first buffer still attempts the second; the first
206    /// error is returned.
207    pub fn release_seq_state(&self, st: &mut QsaSeqState, gpu: &dyn GpuBackend) -> Result<()> {
208        let mut first_err: Option<anyhow::Error> = None;
209        for p in [&mut st.raw_keys, &mut st.block_keys] {
210            if p.is_null() {
211                continue;
212            }
213            if let Err(e) = gpu.free(*p) {
214                first_err.get_or_insert(e);
215            }
216            *p = DevicePtr(0);
217        }
218        match first_err {
219            Some(e) => Err(e),
220            None => Ok(()),
221        }
222    }
223
224    /// 2026-09-25: The largest visible prefix whose selection is all-visible:
225    /// with at most `budget + ratio - 1` visible tokens there are at most
226    /// `budget / ratio` complete blocks, so every block is selected.
227    pub fn inert_bound(&self) -> usize {
228        (self.budget + self.ratio - 1) as usize
229    }
230
231    fn qk_width(&self) -> usize {
232        (self.n_heads as usize + 1) * self.hd as usize
233    }
234
235    /// 2026-09-25: Ingest `num_tokens` prefill tokens starting at `seq_start`:
236    /// project qk, store the raw keys, pool newly complete blocks.
237    /// `seq_start == 0` resets the counters. Errors when `seq_start` is not
238    /// the ingested count (a prefix-cache skip) or the total exceeds
239    /// `METRALE_QSA_MAX_TOKENS`.
240    pub fn prefill_ingest(
241        &self,
242        st: &mut QsaSeqState,
243        hidden: DevicePtr,
244        num_tokens: usize,
245        seq_start: usize,
246        gpu: &dyn GpuBackend,
247        stream: u64,
248    ) -> Result<()> {
249        if seq_start == 0 {
250            st.ingested = 0;
251            st.pooled = 0;
252        }
253        anyhow::ensure!(
254            seq_start == st.ingested,
255            "QSA: prefill chunk starts at {seq_start} but {} tokens are \
256             ingested — a prefix-cache skip bypassed the indexer. Serve \
257             qwen4_exp with the prefix cache disabled until QSA learns to \
258             re-ingest cached prefixes.",
259            st.ingested
260        );
261        anyhow::ensure!(
262            seq_start + num_tokens <= self.max_tokens,
263            "QSA: {} tokens exceeds METRALE_QSA_MAX_TOKENS={}",
264            seq_start + num_tokens,
265            self.max_tokens
266        );
267
268        let hd = self.hd as usize;
269        let qkw = self.qk_width();
270        let mut off = 0usize;
271        while off < num_tokens {
272            let ts = INGEST_SLAB.min(num_tokens - off);
273            ops::cublas_bf16_proj_dense(
274                hidden.offset((off) * self.hidden as usize * 2),
275                self.qk_proj_w,
276                self.qk_scratch,
277                ts as u32,
278                qkw as u32,
279                self.hidden,
280                stream,
281            )
282            .context("QSA qk projection (prefill)")?;
283            // 2026-09-25: Raw key = the last hd columns of each row.
284            gpu.copy_d2d_2d_async(
285                self.qk_scratch.offset(self.n_heads as usize * hd * 2),
286                qkw * 2,
287                st.raw_keys.offset((seq_start + off) * hd * 2),
288                hd * 2,
289                hd * 2,
290                ts,
291                stream,
292            )?;
293            off += ts;
294        }
295        st.ingested = seq_start + num_tokens;
296        self.pool_new_blocks(st, gpu, stream)
297    }
298
299    fn pool_new_blocks(
300        &self,
301        st: &mut QsaSeqState,
302        gpu: &dyn GpuBackend,
303        stream: u64,
304    ) -> Result<()> {
305        let complete = st.ingested / self.ratio as usize;
306        if complete > st.pooled {
307            ops::qsa_block_pool(
308                gpu,
309                self.k_pool_k,
310                st.raw_keys,
311                self.k_norm_w,
312                st.block_keys,
313                st.pooled as u32,
314                (complete - st.pooled) as u32,
315                self.ratio,
316                self.hd,
317                self.rot,
318                self.theta,
319                self.eps,
320                stream,
321            )?;
322            st.pooled = complete;
323        }
324        Ok(())
325    }
326
327    // 2026-09-25: `prefill_select` is in `qsa_select.rs`.
328
329    /// 2026-09-25: Decode-step ingest + selection for the token at `pos` (0-based;
330    /// `pos + 1` visible). `None` inside the inert bound (dense is exact).
331    /// Errors when `pos` is not the ingested count or reaches `max_tokens`.
332    #[allow(clippy::too_many_arguments)]
333    pub fn decode_select(
334        &self,
335        st: &mut QsaSeqState,
336        normed: DevicePtr,
337        pos: usize,
338        k_pool: DevicePtr,
339        v_pool: DevicePtr,
340        block_table_dev: DevicePtr,
341        block_size: u32,
342        gpu: &dyn GpuBackend,
343        stream: u64,
344    ) -> Result<Option<QsaSelection>> {
345        anyhow::ensure!(
346            pos == st.ingested,
347            "QSA: decode at pos {pos} but {} tokens ingested — the indexer \
348             cache lost sync (prefix-cache skip or a rewound sequence)",
349            st.ingested
350        );
351        anyhow::ensure!(
352            pos < self.max_tokens,
353            "QSA: pos {pos} >= METRALE_QSA_MAX_TOKENS"
354        );
355
356        let hd = self.hd as usize;
357        let qkw = self.qk_width();
358        ops::cublas_bf16_proj_dense(
359            normed,
360            self.qk_proj_w,
361            self.qk_scratch,
362            1,
363            qkw as u32,
364            self.hidden,
365            stream,
366        )
367        .context("QSA qk projection (decode)")?;
368        gpu.copy_d2d_async(
369            self.qk_scratch.offset(self.n_heads as usize * hd * 2),
370            st.raw_keys.offset(pos * hd * 2),
371            hd * 2,
372            stream,
373        )?;
374        st.ingested = pos + 1;
375        self.pool_new_blocks(st, gpu, stream)?;
376
377        let visible = pos + 1;
378        let complete = visible / self.ratio as usize;
379        if complete <= self.block_topk as usize {
380            return Ok(None); // 2026-09-25: all blocks are selected, so dense is exact.
381        }
382
383        ops::qsa_qprep(
384            gpu,
385            self.k_qprep_k,
386            self.qk_scratch,
387            self.q_norm_w,
388            self.q_post,
389            self.n_heads,
390            self.hd,
391            self.rot,
392            pos as u32,
393            self.theta,
394            self.eps,
395            stream,
396        )?;
397        ops::qsa_score(
398            gpu,
399            self.k_score_k,
400            self.q_post,
401            st.block_keys,
402            self.scores_dev,
403            complete as u32,
404            self.n_heads,
405            self.hd,
406            stream,
407        )?;
408
409        // 2026-09-25: Host top-k over the block scores. The D2H cannot be
410        // captured; a layer with an indexer reports `decode_graph_unsupported`.
411        let mut raw = vec![0u8; complete * 4];
412        gpu.copy_d2h_on_stream(self.scores_dev, &mut raw, stream)?;
413        let scores: Vec<f32> = raw
414            .chunks_exact(4)
415            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
416            .collect();
417        let mut order: Vec<u32> = (0..complete as u32).collect();
418        // 2026-09-25: Sort by (-score, index): ties go to the lower block index.
419        order.sort_by(|&a, &b| {
420            scores[b as usize]
421                .partial_cmp(&scores[a as usize])
422                .unwrap_or(std::cmp::Ordering::Equal)
423                .then(a.cmp(&b))
424        });
425        let mut blocks: Vec<u32> = order[..self.block_topk as usize].to_vec();
426        blocks.sort_unstable();
427
428        let ratio = self.ratio as usize;
429        let mut sel: Vec<i32> = Vec::with_capacity(self.budget as usize + ratio);
430        for b in &blocks {
431            let base = *b as i32 * self.ratio as i32;
432            for r in 0..self.ratio as i32 {
433                sel.push(base + r);
434            }
435        }
436        for t in complete * ratio..visible {
437            sel.push(t as i32);
438        }
439        let n_sel = sel.len() as u32;
440
441        let sel_bytes: Vec<u8> = sel.iter().flat_map(|v| v.to_le_bytes()).collect();
442        gpu.copy_h2d_async(&sel_bytes, self.sel_dev, stream)?;
443        ops::qsa_gather(
444            gpu,
445            self.k_gather_k,
446            k_pool,
447            v_pool,
448            block_table_dev,
449            self.sel_dev,
450            self.k_scratch,
451            self.v_scratch,
452            n_sel,
453            block_size,
454            self.nkv_attn,
455            self.hd_attn,
456            stream,
457        )?;
458
459        // 2026-09-25: Identity table + seq_len for the scratch-as-paged-cache view.
460        let pages = (n_sel as usize).div_ceil(block_size as usize);
461        if st.table_len < pages {
462            let ident: Vec<u8> = (0..pages as i32).flat_map(|v| v.to_le_bytes()).collect();
463            gpu.copy_h2d_async(&ident, self.table_dev, stream)?;
464            st.table_len = pages;
465        }
466        gpu.copy_h2d_async(&(n_sel as i32).to_le_bytes(), self.seq_len_dev, stream)?;
467
468        Ok(Some(QsaSelection {
469            k_scratch: self.k_scratch,
470            v_scratch: self.v_scratch,
471            table_dev: self.table_dev,
472            seq_len_dev: self.seq_len_dev,
473            n_sel,
474            max_blocks: pages as u32,
475        }))
476    }
477}