spark_model/layers/glm5next_dsa/
select.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3 DSA token selection โ€” the production launcher for the indexer pipeline.
4//!
5//! Scoped to `LibertAIDAI/GLM-5.3-Flash-NVFP4@9e0d74e3`.
6//!
7//! This is the `examples/dsa_indexer_microtest.rs` GATE-4 pipeline lifted out of the
8//! example and given a launcher a real layer can call. The kernels, their argument
9//! order and their numerics are already proven against HF 5.16.1 on real weights;
10//! nothing here re-derives them. What this module owns is the part the microtest did
11//! by hand and a layer cannot: **geometry, capacity and refusal**.
12//!
13//! ```text
14//! k_normed, gate, valid, ape  -> dsa_kpool_compress   -> pool keys / indices / valid
15//! q, weights, q_pos           -> dsa_index_scores     -> [Q, P] scores + candidacy
16//!                             -> dsa_topk_pools       -> [Q, select_k] pool ids
17//!                             -> dsa_expand_selection -> [Q, out_width] token ids
18//! ```
19//!
20//! # ๐ŸŸข The context ceiling this module used to impose is GONE
21//!
22//! `dsa_topk_pools` no longer sorts the whole pool axis in shared memory. It walks the
23//! pools in fixed `TOPK_TILE`-wide tiles, keeping a running best-`TOPK_TILE` list, so
24//! shared memory is a constant `16 ร— TOPK_TILE` bytes whatever the context. The result is
25//! bit-identical to the old whole-axis sort โ€” the comparator (score DESC, pool index ASC)
26//! is a total order over unique indices, so the top-`select_k` prefix is unique and
27//! merge-and-truncate cannot reach a different set or order.
28//!
29//! What survives is one requirement, checked in [`DsaSelectGeometry::plan`]:
30//! `select_k <= TOPK_TILE`. At `index_topk = 2048` and `index_kpool = 4` that is 512
31//! against 2,048. **DSA context is now bounded by the indexer cache allocation
32//! (`state::max_dsa_context`), not by this kernel.** ANOMALIES A62.
33//!
34//! # ๐Ÿชค Compaction is the identity here, and that is a derived fact, not an assumption
35//!
36//! [`crate::layers::glm5next_dsa_ref::kept_pools`] keeps pool `p` only when **every**
37//! one of its `kpool` slots is in range and valid, with pooling starting at the first
38//! valid token. Over a contiguous, unpadded cache โ€” every decode step at batch 1 โ€”
39//! that set is exactly the prefix `0 .. seq / kpool`, so the compacted array is a
40//! prefix of the full one and `dsa_compact_pools` would copy a buffer onto itself.
41//! This launcher therefore uses the full arrays in place and takes the prefix.
42//! `contiguous_pool_count` is proven equal to the reference for every sequence length
43//! in `tests`. A **left-padded** batch breaks the prefix property and genuinely needs
44//! the compaction arm โ€” not built, and [`DsaSelectGeometry::plan`] is documented as
45//! contiguous-only.
46
47use anyhow::{Result, bail};
48use spark_runtime::gpu::{DevicePtr, GpuBackend};
49use spark_runtime::kernel_args::KernelLaunch;
50
51use super::{Glm5NextDsaConfig, Glm5NextDsaKernels};
52
53/// Runtime shared-memory ceiling the top-k select is budgeted against, matching
54/// `SMEM_CEILING` in `examples/dsa_indexer_microtest.rs`.
55pub const TOPK_SMEM_CEILING: usize = 49_152;
56
57/// Threads per block for `dsa_index_scores`, and the bytes of shared memory it
58/// reduces through. Taken verbatim from the proven microtest launch.
59const SCORES_BLOCK: u32 = 128;
60/// Threads per block for `dsa_topk_pools` and `dsa_expand_selection`.
61const ROW_BLOCK: u32 = 256;
62
63/// Tile width `dsa_topk_pools` walks the pool axis in.
64///
65/// The block holds two tiles โ€” the running best list and the candidate tile โ€” each a
66/// `[f32, i32]` pair, so a tile costs `16 ร— T` bytes. The largest power of two under the
67/// 49,152 B ceiling is 3,072 โ†’ **2,048**. Bigger is better (the walk is
68/// `O(P/T ยท logยฒT)`), so this is the ceiling, not a taste.
69///
70/// ๐Ÿชค Mirrored by `dsa_write_geom`'s `tile` argument, which this module passes explicitly
71/// rather than duplicating as a `#define`, so the two cannot drift.
72pub fn topk_tile() -> usize {
73    let mut t = 2usize;
74    while t * 2 * 16 <= TOPK_SMEM_CEILING {
75        t *= 2;
76    }
77    t
78}
79
80/// Shared memory one `dsa_topk_pools` block needs for a tile of `t` pools.
81pub fn topk_smem_for_tile(t: usize) -> usize {
82    t * 2 * 8
83}
84
85/// Pools kept over a contiguous, unpadded cache of `seq` tokens.
86///
87/// A pool needs all `kpool` slots, so the trailing partial pool is not a pool. Proven
88/// against `glm5next_dsa_ref::kept_pools` in `tests`.
89pub fn contiguous_pool_count(kpool: usize, seq: usize) -> usize {
90    seq / kpool
91}
92
93/// Launch geometry for one selection pass โ€” every count the four kernels need, and
94/// every capacity check, decided before a single pointer is touched.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct DsaSelectGeometry {
97    /// Tokens resident in the indexer cache.
98    pub seq: usize,
99    /// Query rows selecting this pass (1 on a batch-1 decode step).
100    pub q_rows: usize,
101    /// Pools `dsa_kpool_compress` writes, including the trailing partial one.
102    pub n_pools_full: usize,
103    /// Pools that are real โ€” the prefix everything downstream reads.
104    pub n_pools: usize,
105    /// Pools selected per query.
106    pub select_k: usize,
107    /// Emitted index-row width.
108    pub out_width: usize,
109    /// Tile width the top-k select walks the pool axis in (clamped down below one tile).
110    pub topk_np2: usize,
111    /// Shared memory `dsa_topk_pools` needs, in bytes.
112    pub topk_smem: usize,
113    /// Channels per compress block.
114    pub index_head_dim: usize,
115    pub index_heads: usize,
116    pub index_kpool: usize,
117}
118
119impl DsaSelectGeometry {
120    /// Plan a selection over a **contiguous, unpadded** cache.
121    ///
122    /// Fails rather than truncates when the pool axis outgrows the top-k kernel's
123    /// shared-memory budget.
124    pub fn plan(cfg: &Glm5NextDsaConfig, seq: usize, q_rows: usize) -> Result<Self> {
125        cfg.validate()?;
126        if q_rows == 0 {
127            bail!("DSA select: q_rows must be > 0");
128        }
129        if seq == 0 {
130            bail!("DSA select: seq must be > 0");
131        }
132        let kp = cfg.index_kpool;
133        let n_pools = contiguous_pool_count(kp, seq);
134        // ๐Ÿ”ด `n_pools == 0` is a LEGAL regime, not a refusal. HF 5.16.1
135        // `Glm5NextTextIndexer.forward` has no short-sequence branch: below
136        // `index_kpool` tokens `pool_valid` is all-false, `keep = pool_valid.any(0)`
137        // empties the pool axis, and `select_k = min(index_topk // index_kpool, 0)`
138        // is 0 โ€” so nothing is selected and `append_visible_tail` supplies the raw
139        // visible tokens. Since every token of a sub-pool sequence is in the
140        // incomplete pool, that IS dense attention; the sparse path is unchanged
141        // from `seq >= index_kpool` on. `glm5next_dsa_ref::{kept_pools,
142        // expand_selection}` already model this; only this launcher refused it,
143        // which stopped the first forward at layer 3 (2026-08-28).
144        // ๐ŸŸข The pool axis no longer has to fit shared memory โ€” `dsa_topk_pools` walks it
145        // in tiles. `topk_np2` is the TILE, clamped down when the context is shorter than
146        // one tile so a small context still costs a small sort.
147        let tile = topk_tile();
148        let topk_np2 = n_pools.next_power_of_two().max(2).min(tile);
149        let topk_smem = topk_smem_for_tile(topk_np2);
150        let select_k = cfg.select_k(n_pools);
151        if select_k > topk_np2 {
152            // The running best list IS one tile, so it cannot hold more than a tile's
153            // worth of winners. Unreachable at GLM-5.3's index_topk=2048 / kpool=4
154            // (select_k 512 vs a 2,048 tile) โ€” a loud refusal, not a silent truncation,
155            // the day a config changes that.
156            bail!(
157                "DSA select: select_k {select_k} exceeds the {topk_np2}-pool top-k tile \
158                 ({TOPK_SMEM_CEILING} B shared-memory ceiling, index_topk={} \
159                 index_kpool={kp}). Raise the ceiling or lower index_topk.",
160                cfg.index_topk,
161            );
162        }
163        Ok(Self {
164            seq,
165            q_rows,
166            n_pools_full: seq.div_ceil(kp),
167            n_pools,
168            select_k,
169            out_width: cfg.out_width(),
170            topk_np2,
171            topk_smem,
172            index_head_dim: cfg.index_head_dim,
173            index_heads: cfg.index_heads,
174            index_kpool: kp,
175        })
176    }
177
178    /// Bytes of each scratch region this pass writes, in [`DsaSelectScratch`] order.
179    fn scratch_bytes(&self) -> [usize; 6] {
180        [
181            self.n_pools_full * self.index_head_dim * 4, // pool_keys   f32
182            self.n_pools_full * self.index_kpool * 4,    // pool_indices i32
183            self.n_pools_full,                           // pool_valid  u8
184            self.q_rows * self.n_pools * 4,              // scores      f32
185            self.q_rows * self.n_pools,                  // valid_cand  u8
186            self.q_rows * self.select_k * 4,             // selected    i32
187        ]
188    }
189}
190
191/// Device-side inputs to a selection pass. Every one is owned by the caller; this
192/// module allocates nothing but its own scratch.
193#[derive(Debug, Clone, Copy)]
194pub struct DsaSelectInputs {
195    /// `[seq, index_head_dim]` BF16 โ€” indexer keys, **already LayerNorm'd**.
196    /// ๐Ÿชค `indexer.k_norm` is an `nn.LayerNorm` with a bias, not an RMSNorm.
197    pub k_normed: DevicePtr,
198    /// `[seq, index_head_dim]` BF16 โ€” `index_kpool_compress_gate` projection.
199    pub gate: DevicePtr,
200    /// `[seq]` u8 โ€” per-key validity.
201    pub valid: DevicePtr,
202    /// `[index_kpool, index_head_dim]` **f32** โ€” the APE table.
203    /// ๐Ÿชค BF16 on disk, f32 to the kernel; the loader must upconvert. This is the
204    /// #347 dtype-mismatch class, so the width is stated here rather than inferred.
205    pub ape: DevicePtr,
206    /// `[q_rows, index_heads, index_head_dim]` f32.
207    pub q: DevicePtr,
208    /// `[q_rows, index_heads]` f32, **already carrying the `index_heads^-0.5`
209    /// factor** โ€” `dsa_index_scores` does not apply it.
210    pub weights: DevicePtr,
211    /// `[q_rows]` i32 โ€” absolute position of each query.
212    pub q_pos: DevicePtr,
213    /// `[q_rows]` u8 โ€” a zero row selects nothing and stays all `-1`.
214    pub q_mask: DevicePtr,
215    /// Index of the first valid key; pooling starts here so left padding is skipped.
216    pub first_key: i32,
217    /// `[5]` i32 DEVICE geometry (`dsa_write_geom`), or NULL for the scalar path.
218    ///
219    /// ๐Ÿ”ด Non-null is what makes a captured decode step replay correctly: S, the pool
220    /// count, the padded sort axis and `select_k` all grow with the context, and a graph
221    /// freezes every scalar it was captured with. Decode-only โ€” see `Replay` below.
222    pub geom_dev: DevicePtr,
223}
224
225/// How a pass is launched: exactly, or at the context ceiling so one graph serves any length.
226///
227/// ๐Ÿชค `Ceiling` REQUIRES `DsaSelectInputs::geom_dev` and `q_rows == 1`. The kernels read
228/// their row strides (`out`/`valid_cand` stride `P`, `selected` stride `select_k`) from the
229/// same varying geometry, which is only sound because every `r * stride` is `0 * stride`.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum DsaSelectLaunch {
232    /// This step's exact grid. The shipping eager path.
233    Exact,
234    /// Grid and shared memory fixed at `max_pools`; live extents come from `geom_dev`.
235    Ceiling { max_pools: usize },
236}
237
238/// Scratch the pipeline writes through, allocated once and reused across steps.
239///
240/// Sized from a worst-case geometry so a growing context never reallocates mid-serve;
241/// [`Self::fits`] refuses a pass that would outgrow it rather than overrunning.
242#[derive(Debug, Clone, Copy)]
243pub struct DsaSelectScratch {
244    pool_keys: DevicePtr,
245    pool_indices: DevicePtr,
246    pool_valid: DevicePtr,
247    scores: DevicePtr,
248    valid_cand: DevicePtr,
249    selected: DevicePtr,
250    /// `[q_rows, out_width]` i32 token ids, `-1` where nothing was selected. The
251    /// result of the pass; fully written by `dsa_expand_selection` on every path.
252    tokens: DevicePtr,
253    capacity: [usize; 6],
254    tokens_bytes: usize,
255}
256
257impl DsaSelectScratch {
258    /// Allocate for the worst case this layer will ever see.
259    pub fn alloc(
260        gpu: &dyn GpuBackend,
261        cfg: &Glm5NextDsaConfig,
262        geom: &DsaSelectGeometry,
263    ) -> Result<Self> {
264        let capacity = geom.scratch_bytes();
265        let tokens_bytes = geom.q_rows * cfg.out_width() * 4;
266        Ok(Self {
267            pool_keys: gpu.alloc(capacity[0])?,
268            pool_indices: gpu.alloc(capacity[1])?,
269            pool_valid: gpu.alloc(capacity[2])?,
270            scores: gpu.alloc(capacity[3])?,
271            valid_cand: gpu.alloc(capacity[4])?,
272            selected: gpu.alloc(capacity[5])?,
273            tokens: gpu.alloc(tokens_bytes)?,
274            capacity,
275            tokens_bytes,
276        })
277    }
278
279    /// `[q_rows, out_width]` i32 selection produced by the last pass.
280    pub fn tokens(&self) -> DevicePtr {
281        self.tokens
282    }
283
284    /// The same scratch with `tokens` pointing at row `row`.
285    ///
286    /// A K-row verify selects one row at a time (`q_rows == 1`) but attends all K rows in
287    /// one launch, so each pass has to land in its own slot of the `[max_rows, out_width]`
288    /// output instead of all of them overwriting row 0. Everything else in the scratch is a
289    /// within-pass temporary and is deliberately shared.
290    pub fn row(&self, row: usize, cfg: &Glm5NextDsaConfig) -> Self {
291        Self {
292            tokens: self.tokens.offset(row * cfg.out_width() * 4),
293            tokens_bytes: self.tokens_bytes - row * cfg.out_width() * 4,
294            ..*self
295        }
296    }
297
298    /// Whether `geom` fits what was allocated. Checked on every pass: a context that
299    /// grew past the reservation must fail loudly, not scribble past a buffer โ€” the
300    /// A25 recv-buffer class of bug.
301    pub fn fits(&self, cfg: &Glm5NextDsaConfig, geom: &DsaSelectGeometry) -> Result<()> {
302        let want = geom.scratch_bytes();
303        for (i, (w, c)) in want.iter().zip(self.capacity.iter()).enumerate() {
304            if w > c {
305                bail!(
306                    "DSA select: scratch region {i} needs {w} B but only {c} B was \
307                     reserved ({} tokens, {} pools, {} query rows)",
308                    geom.seq,
309                    geom.n_pools,
310                    geom.q_rows
311                );
312            }
313        }
314        let want_tokens = geom.q_rows * cfg.out_width() * 4;
315        if want_tokens > self.tokens_bytes {
316            bail!(
317                "DSA select: selection output needs {want_tokens} B but only {} B was \
318                 reserved",
319                self.tokens_bytes
320            );
321        }
322        Ok(())
323    }
324
325    pub fn free(self, gpu: &dyn GpuBackend) -> Result<()> {
326        for p in [
327            self.pool_keys,
328            self.pool_indices,
329            self.pool_valid,
330            self.scores,
331            self.valid_cand,
332            self.selected,
333            self.tokens,
334        ] {
335            gpu.free(p)?;
336        }
337        Ok(())
338    }
339}
340
341/// Run the four selection kernels, leaving `[q_rows, out_width]` token ids in
342/// [`DsaSelectScratch::tokens`].
343///
344/// Launch geometry and argument order are transcribed from the GATE-4 arm of
345/// `examples/dsa_indexer_microtest.rs`, which is the numerically-proven reference.
346/// The pass is enqueued on `stream` and **not** synchronised โ€” the caller sequences it
347/// with the attention that consumes the selection.
348#[allow(clippy::too_many_arguments)]
349pub fn select_tokens(
350    gpu: &dyn GpuBackend,
351    kernels: &Glm5NextDsaKernels,
352    cfg: &Glm5NextDsaConfig,
353    geom: &DsaSelectGeometry,
354    inputs: &DsaSelectInputs,
355    scratch: &DsaSelectScratch,
356    launch: DsaSelectLaunch,
357    stream: u64,
358) -> Result<()> {
359    scratch.fits(cfg, geom)?;
360
361    let d = geom.index_head_dim;
362    let kp = geom.index_kpool;
363
364    let ceiling = match launch {
365        DsaSelectLaunch::Exact => None,
366        DsaSelectLaunch::Ceiling { max_pools } => {
367            if inputs.geom_dev.0 == 0 {
368                bail!(
369                    "DSA select: a ceiling launch has no host geometry to fall back on and \
370                     needs `geom_dev`; passing NULL would run the frozen scalars."
371                );
372            }
373            if geom.q_rows != 1 {
374                bail!(
375                    "DSA select: ceiling launch is decode-only (q_rows must be 1, got {}). \
376                     At q_rows > 1 the row strides vary with the context and a replayed \
377                     graph would index the wrong rows.",
378                    geom.q_rows
379                );
380            }
381            Some(max_pools)
382        }
383    };
384    let gd = inputs.geom_dev;
385
386    // ๐Ÿ”ด Under a ceiling launch the kernels take their live extents from `geom_dev` and the
387    // scalar twins are dead โ€” but a CUDA graph BAKES every scalar it is handed. A dead scalar
388    // that still moves per step makes two captures of the same region differ, which is both
389    // noise in a capture-vs-capture diff and a live trap the day a kernel stops overriding
390    // one. Hand the ceiling: constant for the life of the graph, and what the grid already is.
391    let (seq_a, npools_a, np2_a, selk_a) = match ceiling {
392        // ๐ŸŸข The sort axis is the TILE now, so the ceiling launch's shared memory is the
393        // SAME constant the eager path uses at any context past one tile โ€” a graph captured
394        // at the ceiling replays a step of any length without over-requesting.
395        Some(m) => (
396            m * kp,
397            m,
398            m.next_power_of_two().max(2).min(topk_tile()),
399            cfg.select_k(m),
400        ),
401        None => (geom.seq, geom.n_pools, geom.topk_np2, geom.select_k),
402    };
403
404    // ๐Ÿชค Below `index_kpool` tokens there are no pools to score or sort, and a
405    // zero-extent grid is an illegal launch, not a no-op. Stages 2 and 3 are
406    // skipped; stage 1 still runs (`n_pools_full >= 1`) and stage 4 still runs,
407    // writing the whole row -1 and then appending the visible tail โ€” which is the
408    // entire selection in this regime.
409    // Under a ceiling launch stages 2 and 3 ALWAYS run: the grid is >= 1 whatever the
410    // context, and with a live pool count of zero every block returns โ€” the same nothing
411    // the host branch produces, but decided on device so a graph can replay it.
412    let has_pools = geom.n_pools > 0 || ceiling.is_some();
413
414    // โ”€โ”€ 1. pool compression โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
415    // Launched over the FULL pool count, exactly as the microtest does; the trailing
416    // partial pool is written, marked invalid, and never read downstream.
417    KernelLaunch::new(gpu, kernels.kpool_compress)
418        .grid([ceiling.map_or(geom.n_pools_full, |m| m + 1) as u32, 1, 1])
419        .block([d.min(1024) as u32, 1, 1])
420        .arg_ptr(inputs.k_normed)
421        .arg_ptr(inputs.gate)
422        .arg_ptr(inputs.valid)
423        .arg_ptr(inputs.ape)
424        .arg_ptr(scratch.pool_keys)
425        .arg_ptr(scratch.pool_indices)
426        .arg_ptr(scratch.pool_valid)
427        .arg_u32(seq_a as u32)
428        .arg_u32(d as u32)
429        .arg_u32(kp as u32)
430        .arg_i32(inputs.first_key)
431        .arg_ptr(gd)
432        .launch(stream)?;
433
434    // ๐Ÿชค No `dsa_compact_pools` launch: over a contiguous cache the kept set is the
435    // prefix `0..n_pools`, so compaction is a buffer-to-itself copy. See the module
436    // header โ€” a left-padded batch would need it.
437
438    // โ”€โ”€ 2. per-(query, pool) index scores โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
439    if has_pools {
440        KernelLaunch::new(gpu, kernels.index_scores)
441            .grid([
442                ceiling.unwrap_or(geom.n_pools) as u32,
443                geom.q_rows as u32,
444                1,
445            ])
446            .block([SCORES_BLOCK, 1, 1])
447            .shared_mem(SCORES_BLOCK)
448            .arg_ptr(inputs.q)
449            .arg_ptr(scratch.pool_keys)
450            .arg_ptr(inputs.weights)
451            .arg_ptr(scratch.pool_indices)
452            .arg_ptr(scratch.pool_valid)
453            .arg_ptr(inputs.valid)
454            .arg_ptr(inputs.q_pos)
455            .arg_ptr(scratch.scores)
456            .arg_ptr(scratch.valid_cand)
457            .arg_u32(geom.q_rows as u32)
458            .arg_u32(npools_a as u32)
459            .arg_u32(geom.index_heads as u32)
460            .arg_u32(d as u32)
461            .arg_u32(kp as u32)
462            .arg_u32(seq_a as u32)
463            .arg_f32((d as f32).powf(-0.5))
464            .arg_ptr(gd)
465            .launch(stream)?;
466
467        // โ”€โ”€ 3. top-k over pools โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
468        // Capacity was refused at plan time; this launch cannot overflow shared memory.
469        KernelLaunch::new(gpu, kernels.topk_pools)
470            .grid([geom.q_rows as u32, 1, 1])
471            .block([ROW_BLOCK, 1, 1])
472            .shared_mem(topk_smem_for_tile(np2_a) as u32)
473            .arg_ptr(scratch.scores)
474            .arg_ptr(scratch.selected)
475            .arg_u32(geom.q_rows as u32)
476            .arg_u32(npools_a as u32)
477            .arg_u32(np2_a as u32)
478            .arg_u32(selk_a as u32)
479            .arg_ptr(gd)
480            .launch(stream)?;
481    }
482
483    // โ”€โ”€ 4. expand pools to raw token ids โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
484    KernelLaunch::new(gpu, kernels.expand_selection)
485        .grid([geom.q_rows as u32, 1, 1])
486        .block([ROW_BLOCK, 1, 1])
487        .arg_ptr(scratch.selected)
488        .arg_ptr(scratch.pool_indices)
489        .arg_ptr(scratch.valid_cand)
490        .arg_ptr(inputs.valid)
491        .arg_ptr(inputs.q_pos)
492        .arg_ptr(inputs.q_mask)
493        .arg_ptr(scratch.tokens)
494        .arg_u32(geom.q_rows as u32)
495        .arg_u32(npools_a as u32)
496        .arg_u32(kp as u32)
497        .arg_u32(seq_a as u32)
498        .arg_u32(selk_a as u32)
499        .arg_u32(geom.out_width as u32)
500        .arg_i32(inputs.first_key)
501        .arg_i32(cfg.always_select_tail as i32)
502        .arg_ptr(gd)
503        .launch(stream)?;
504
505    Ok(())
506}
507
508#[cfg(test)]
509mod tests;