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;