metrale_model_arch/glm5next_dsa/select.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GLM-5.3 DSA token selection: launch geometry, scratch, and the launcher for
4//! the selection kernels.
5//!
6//! Owner: model-arch (GLM-5.3 DSA).
7//! Invariants:
8//! - [`DsaSelectGeometry::plan`] returns `Ok` only when `select_k <= topk_np2`.
9//! - `select_tokens` checks [`DsaSelectScratch::fits`] before its first launch and fails
10//! when the pass needs more scratch than was allocated.
11//!
12//! ```text
13//! k_normed, gate, valid, ape -> dsa_kpool_compress -> pool keys / indices / valid
14//! q, weights, q_pos -> dsa_index_scores -> [Q, P] scores + candidacy
15//! -> dsa_topk_pools -> [Q, select_k] pool ids
16//! -> dsa_expand_selection -> [Q, out_width] token ids
17//! ```
18//!
19//! # Shared memory does not grow with the context
20//!
21//! `dsa_topk_pools` walks the pool axis in tiles of [`topk_tile`] pools and keeps a running
22//! best list of one tile, so its shared memory ([`topk_smem_for_tile`]) is fixed. The one
23//! limit left is `select_k` of at most one tile, checked in [`DsaSelectGeometry::plan`]: at
24//! `index_topk` 2048 and `index_kpool` 4, `select_k` is 512 against a 2,048-pool tile. The
25//! context is bounded by the indexer cache (`state::max_dsa_context`).
26//!
27//! # No compaction pass
28//!
29//! [`crate::glm5next_dsa_ref::kept_pools`] keeps pool `p` only when every one of its slots
30//! is in range and valid, counting from the first valid token. Over a contiguous cache with
31//! no padding that set is the prefix `0 .. seq / kpool` ([`contiguous_pool_count`], checked
32//! against `kept_pools` in `tests`), so the launcher uses the full arrays in place and
33//! `dsa_compact_pools` is not launched. A left-padded batch would need the compaction;
34//! [`DsaSelectGeometry::plan`] handles contiguous caches only.
35
36use anyhow::{Result, bail};
37use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
38use metrale_gpu_runtime::kernel_args::KernelLaunch;
39
40use super::{Glm5NextDsaConfig, Glm5NextDsaKernels};
41
42/// 2026-09-25: Shared-memory ceiling the top-k select is budgeted against; the same value
43/// as `SMEM_CEILING` in `examples/dsa_indexer_microtest.rs`.
44pub const TOPK_SMEM_CEILING: usize = 49_152;
45
46/// 2026-09-25: Threads per block for `dsa_index_scores`, and the least shared memory it is
47/// given, in bytes (`select_tokens` requests `max(SCORES_BLOCK, 4 * index_heads)`).
48const SCORES_BLOCK: u32 = 128;
49/// 2026-09-25: Threads per block for `dsa_topk_pools` and `dsa_expand_selection`.
50const ROW_BLOCK: u32 = 256;
51
52/// 2026-09-25: Tile width `dsa_topk_pools` walks the pool axis in.
53///
54/// The block holds two tiles, the running best list and the candidate tile, of `[f32, i32]`
55/// pairs: `16 ยท T` bytes. This is the largest power of two `T` that fits
56/// [`TOPK_SMEM_CEILING`]: 2,048.
57///
58/// `Glm5NextDsaLayer::decode_k` passes this value to `dsa_write_geom` as `tile`; the kernel
59/// has no copy of it.
60pub fn topk_tile() -> usize {
61 let mut t = 2usize;
62 while t * 2 * 16 <= TOPK_SMEM_CEILING {
63 t *= 2;
64 }
65 t
66}
67
68/// 2026-09-25: Shared memory one `dsa_topk_pools` block needs for a tile of `t` pools.
69pub fn topk_smem_for_tile(t: usize) -> usize {
70 t * 2 * 8
71}
72
73/// 2026-09-25: Pools kept over a contiguous, unpadded cache of `seq` tokens.
74///
75/// A pool needs all `kpool` slots, so the trailing partial pool is not a pool. Checked
76/// against `glm5next_dsa_ref::kept_pools` in `tests`.
77pub fn contiguous_pool_count(kpool: usize, seq: usize) -> usize {
78 seq / kpool
79}
80
81/// 2026-09-25: Launch geometry for one selection pass, computed by [`Self::plan`] before any
82/// launch.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct DsaSelectGeometry {
85 /// 2026-09-25: Tokens resident in the indexer cache.
86 pub seq: usize,
87 /// 2026-09-25: Query rows in this pass.
88 pub q_rows: usize,
89 /// 2026-09-25: Pools `dsa_kpool_compress` writes, including the trailing partial one.
90 pub n_pools_full: usize,
91 /// 2026-09-25: Complete pools, the prefix the later kernels read.
92 pub n_pools: usize,
93 /// 2026-09-25: Pools selected per query.
94 pub select_k: usize,
95 /// 2026-09-25: Emitted index-row width.
96 pub out_width: usize,
97 /// 2026-09-25: Tile width the top-k select walks the pool axis in: [`topk_tile`], or the
98 /// next power of two (at least 2) at or above `n_pools` when that is smaller.
99 pub topk_np2: usize,
100 /// 2026-09-25: Shared memory `dsa_topk_pools` needs, in bytes.
101 pub topk_smem: usize,
102 /// 2026-09-25: Channels per compress block.
103 pub index_head_dim: usize,
104 pub index_heads: usize,
105 pub index_kpool: usize,
106}
107
108impl DsaSelectGeometry {
109 /// 2026-09-25: Plan a selection over a contiguous, unpadded cache.
110 ///
111 /// Fails when `cfg` fails validation, when `q_rows` or `seq` is 0, or when `select_k`
112 /// exceeds `topk_np2`.
113 pub fn plan(cfg: &Glm5NextDsaConfig, seq: usize, q_rows: usize) -> Result<Self> {
114 cfg.validate()?;
115 if q_rows == 0 {
116 bail!("DSA select: q_rows must be > 0");
117 }
118 if seq == 0 {
119 bail!("DSA select: seq must be > 0");
120 }
121 let kp = cfg.index_kpool;
122 let n_pools = contiguous_pool_count(kp, seq);
123 // 2026-09-25: `n_pools == 0` (fewer than `index_kpool` tokens) is planned, not
124 // refused: `select_k` is 0, `select_tokens` skips scoring and top-k, and
125 // `dsa_expand_selection` emits the visible tail, which is every token. The reference
126 // `glm5next_dsa_ref::expand_selection` gives the same row
127 // (`sub_pool_selection_is_dense_over_the_visible_tokens`).
128 // `topk_np2` is the tile, smaller when the context is shorter than one tile.
129 let tile = topk_tile();
130 let topk_np2 = n_pools.next_power_of_two().max(2).min(tile);
131 let topk_smem = topk_smem_for_tile(topk_np2);
132 let select_k = cfg.select_k(n_pools);
133 if select_k > topk_np2 {
134 // 2026-09-25: The running best list is one tile, so it cannot hold more winners.
135 // At GLM-5.3's index_topk 2048 and kpool 4, select_k is at most 512.
136 bail!(
137 "DSA select: select_k {select_k} exceeds the {topk_np2}-pool top-k tile \
138 ({TOPK_SMEM_CEILING} B shared-memory ceiling, index_topk={} \
139 index_kpool={kp}). Raise the ceiling or lower index_topk.",
140 cfg.index_topk,
141 );
142 }
143 Ok(Self {
144 seq,
145 q_rows,
146 n_pools_full: seq.div_ceil(kp),
147 n_pools,
148 select_k,
149 out_width: cfg.out_width(),
150 topk_np2,
151 topk_smem,
152 index_head_dim: cfg.index_head_dim,
153 index_heads: cfg.index_heads,
154 index_kpool: kp,
155 })
156 }
157
158 /// 2026-09-25: Bytes of each scratch region this pass writes, in [`DsaSelectScratch`]
159 /// field order: pool keys (f32), pool indices (i32), pool validity (u8), scores (f32),
160 /// candidacy (u8), selected pools (i32).
161 fn scratch_bytes(&self) -> [usize; 6] {
162 [
163 self.n_pools_full * self.index_head_dim * 4,
164 self.n_pools_full * self.index_kpool * 4,
165 self.n_pools_full,
166 self.q_rows * self.n_pools * 4,
167 self.q_rows * self.n_pools,
168 self.q_rows * self.select_k * 4,
169 ]
170 }
171}
172
173/// 2026-09-25: Device-side inputs to a selection pass, all owned by the caller.
174#[derive(Debug, Clone, Copy)]
175pub struct DsaSelectInputs {
176 /// 2026-09-25: `[seq, index_head_dim]` BF16 indexer keys, after the `k_norm` LayerNorm.
177 pub k_normed: DevicePtr,
178 /// 2026-09-25: `[seq, index_head_dim]` BF16 `index_kpool_compress_gate` projection.
179 pub gate: DevicePtr,
180 /// 2026-09-25: `[seq]` u8 per-key validity.
181 pub valid: DevicePtr,
182 /// 2026-09-25: `[index_kpool, index_head_dim]` f32 APE table. The checkpoint stores BF16;
183 /// `build_dsa_weights` uploads it as f32.
184 pub ape: DevicePtr,
185 /// 2026-09-25: `[q_rows, index_heads, index_head_dim]` f32.
186 pub q: DevicePtr,
187 /// 2026-09-25: `[q_rows, index_heads]` f32, already carrying the `index_heads^-0.5`
188 /// factor, which `dsa_index_scores` does not apply.
189 pub weights: DevicePtr,
190 /// 2026-09-25: `[q_rows]` i32 absolute position of each query.
191 pub q_pos: DevicePtr,
192 /// 2026-09-25: `[q_rows]` u8; a row whose entry is 0 selects nothing and stays all `-1`.
193 pub q_mask: DevicePtr,
194 /// 2026-09-25: Index of the first valid key; pooling starts here, so left padding is
195 /// skipped.
196 pub first_key: i32,
197 /// 2026-09-25: `[5]` i32 device geometry written by `dsa_write_geom`, or NULL for the
198 /// scalar path.
199 ///
200 /// A captured graph fixes every scalar argument, while S, the pool counts, the tile and
201 /// `select_k` grow with the context; the kernels read them from here when it is non-null.
202 /// Only with `q_rows == 1`: `select_tokens` refuses a ceiling launch otherwise.
203 pub geom_dev: DevicePtr,
204}
205
206/// 2026-09-25: How a pass is launched: exactly, or at the context ceiling so one graph serves
207/// any length.
208///
209/// `Ceiling` requires `DsaSelectInputs::geom_dev` and `q_rows == 1` (`select_tokens` refuses
210/// otherwise). The kernels read their row strides (`out`/`valid_cand` stride `P`, `selected`
211/// stride `select_k`) from the same device geometry, which is sound only because every
212/// `r * stride` is then `0 * stride`.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum DsaSelectLaunch {
215 /// 2026-09-25: This step's exact grid, from the host geometry.
216 Exact,
217 /// 2026-09-25: Grid and shared memory fixed at `max_pools`; live extents come from
218 /// `geom_dev`.
219 Ceiling { max_pools: usize },
220}
221
222/// 2026-09-25: Scratch the selection kernels write through, allocated once and reused across
223/// steps.
224///
225/// Sized from the largest geometry the caller will run; [`Self::fits`] refuses a pass that
226/// would outgrow it.
227#[derive(Debug, Clone, Copy)]
228pub struct DsaSelectScratch {
229 pool_keys: DevicePtr,
230 pool_indices: DevicePtr,
231 pool_valid: DevicePtr,
232 scores: DevicePtr,
233 valid_cand: DevicePtr,
234 selected: DevicePtr,
235 /// 2026-09-25: `[q_rows, out_width]` i32 token ids, `-1` where nothing was selected. The
236 /// result of the pass; `dsa_expand_selection` writes every slot of each row.
237 tokens: DevicePtr,
238 capacity: [usize; 6],
239 tokens_bytes: usize,
240}
241
242impl DsaSelectScratch {
243 /// 2026-09-25: Allocate for `geom`, the largest pass the caller will run
244 /// (`Glm5NextDsaWorkspace::new` plans it at `max_dsa_context`).
245 pub fn alloc(
246 gpu: &dyn GpuBackend,
247 cfg: &Glm5NextDsaConfig,
248 geom: &DsaSelectGeometry,
249 ) -> Result<Self> {
250 let capacity = geom.scratch_bytes();
251 let tokens_bytes = geom.q_rows * cfg.out_width() * 4;
252 Ok(Self {
253 pool_keys: gpu.alloc(capacity[0])?,
254 pool_indices: gpu.alloc(capacity[1])?,
255 pool_valid: gpu.alloc(capacity[2])?,
256 scores: gpu.alloc(capacity[3])?,
257 valid_cand: gpu.alloc(capacity[4])?,
258 selected: gpu.alloc(capacity[5])?,
259 tokens: gpu.alloc(tokens_bytes)?,
260 capacity,
261 tokens_bytes,
262 })
263 }
264
265 /// 2026-09-25: `[q_rows, out_width]` i32 selection produced by the last pass.
266 pub fn tokens(&self) -> DevicePtr {
267 self.tokens
268 }
269
270 /// 2026-09-25: The same scratch with `tokens` pointing at row `row`.
271 ///
272 /// The per-row selector (`q_rows == 1`) writes each row's result into its own slot of
273 /// the `[max_rows, out_width]` output, which the attention reads in one launch. The
274 /// other regions are temporaries of one pass and are shared.
275 pub fn row(&self, row: usize, cfg: &Glm5NextDsaConfig) -> Self {
276 Self {
277 tokens: self.tokens.offset(row * cfg.out_width() * 4),
278 tokens_bytes: self.tokens_bytes - row * cfg.out_width() * 4,
279 ..*self
280 }
281 }
282
283 /// 2026-09-25: Whether `geom` fits what was allocated. `select_tokens` checks it on every
284 /// pass, so a pass larger than the allocation is an error rather than an overrun.
285 pub fn fits(&self, cfg: &Glm5NextDsaConfig, geom: &DsaSelectGeometry) -> Result<()> {
286 let want = geom.scratch_bytes();
287 for (i, (w, c)) in want.iter().zip(self.capacity.iter()).enumerate() {
288 if w > c {
289 bail!(
290 "DSA select: scratch region {i} needs {w} B but only {c} B was \
291 reserved ({} tokens, {} pools, {} query rows)",
292 geom.seq,
293 geom.n_pools,
294 geom.q_rows
295 );
296 }
297 }
298 let want_tokens = geom.q_rows * cfg.out_width() * 4;
299 if want_tokens > self.tokens_bytes {
300 bail!(
301 "DSA select: selection output needs {want_tokens} B but only {} B was \
302 reserved",
303 self.tokens_bytes
304 );
305 }
306 Ok(())
307 }
308
309 pub fn free(self, gpu: &dyn GpuBackend) -> Result<()> {
310 for p in [
311 self.pool_keys,
312 self.pool_indices,
313 self.pool_valid,
314 self.scores,
315 self.valid_cand,
316 self.selected,
317 self.tokens,
318 ] {
319 gpu.free(p)?;
320 }
321 Ok(())
322 }
323}
324
325mod launch;
326pub use launch::select_tokens;
327
328#[cfg(test)]
329mod tests;