spark_model/layers/glm5next_dsa/layer.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `Glm5NextDsaLayer` — the DSA block: NoPE MLA attention over indexer-selected tokens.
4//!
5//! Decode, end to end:
6//!
7//! ```text
8//! hidden ─┬─ q_a_proj ─ RMSNorm ─┬─ q_absorb ────────────── Q (latent space)
9//! │ └─ indexer.wq_b ────────── q_idx ─┐
10//! ├─ indexer.wk ─ LayerNorm(w,b) ─ state.k_normed ──────────┤
11//! ├─ compress_gate ─────────────── state.gate ──────────────┼─ select_tokens
12//! ├─ weights_proj ──────────────── head weights ────────────┘ │
13//! └─ kv_a_proj ─ RMSNorm ─ FP8 ─── paged latent cache │
14//! ▼
15//! glm5next_dsa_mla_decode_fp8 (gather)
16//! ```
17//!
18//! # 🪤 Four silent-wrong-answer traps this file exists to hold
19//!
20//! * **Two RMSNorm kernels differ only by a `+1`.** `rms_norm` computes
21//! `x * rms * (1 + w)`; `rms_norm_vanilla` computes `x * rms * w`. Same signature, same
22//! shapes. GLM is plain, so every norm here takes the *vanilla* entry point.
23//! * **`indexer.k_norm` is an `nn.LayerNorm` with a bias**, not an RMSNorm at all — mean
24//! subtraction plus a bias term. It takes `nllb_layernorm_bf16(x, w, b, …)`.
25//! * **`weights_proj` output must already carry `index_heads^-0.5`.** `dsa_index_scores`
26//! does not apply it. Folded into the weight at load — see [`Glm5NextDsaWeights`].
27//! * **Q must be absorbed into latent space before it reaches the decode kernel.** The
28//! kernel dots Q against the 512-dim latent directly, so `q_absorb` is `q_b_proj`
29//! pre-multiplied by `kv_b_proj`'s K half. A raw `q_b_proj` is the right shape per head
30//! (256 vs 512 is not) but the wrong space.
31
32use anyhow::{Context, Result, bail};
33use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
34use spark_runtime::kernel_args::KernelLaunch;
35use spark_runtime::kv_cache::PagedKvCache;
36
37use super::attend::{DsaDecodeInputs, DsaDecodePaging, Glm5NextDsaDecodeKernel, decode_attention};
38use super::select::{DsaSelectInputs, DsaSelectScratch, select_tokens};
39use super::state::Glm5NextDsaState;
40use super::{Glm5NextDsaConfig, Glm5NextDsaKernels};
41use crate::layer::{ForwardContext, LayerState, TransformerLayer};
42
43/// GEMM launch: `C[M, N] = A[M, K] @ B[N, K]^T`. Grid `(ceil(N/16), ceil(M/16))`,
44/// block `(16, 16)` — one thread per output element.
45fn gemm(
46 gpu: &dyn GpuBackend,
47 k: KernelHandle,
48 gemv: KernelHandle,
49 batchm: KernelHandle,
50 a: DevicePtr,
51 b: DevicePtr,
52 c: DevicePtr,
53 m: usize,
54 n: usize,
55 kk: usize,
56 stream: u64,
57) -> Result<()> {
58 // 🔴 Wide (prefill) shapes go to cuBLASLt — `dsa_proj` was 14.9% of prefill on the
59 // scalar tile GEMM. Same numerics boundary as the KDA block; see
60 // `glm5next_layer::cublas_wide_proj` for why M > DENSE_GEMV_BATCHM_MAX_M is the safe
61 // cut and why decode and the speculative verify cannot reach it.
62 if m > crate::layers::ops::DENSE_GEMV_BATCHM_MAX_M as usize
63 && crate::layers::glm5next_layer::cublas_wide_proj()
64 {
65 return crate::layers::ops::cublas_bf16_proj_dense(
66 a, b, c, m as u32, n as u32, kk as u32, stream,
67 );
68 }
69 // M=1 decode -> GEMV; M=2..8 (a K-token verify sweep) -> ONE weight read for all rows;
70 // wider -> the tile GEMM. `ops::dense_mm_bf16` owns the policy and the grid coupling.
71 crate::layers::ops::dense_mm_bf16(
72 gpu,
73 &crate::layers::ops::DenseMmKernels {
74 gemm: k,
75 gemv,
76 batchm,
77 },
78 a,
79 b,
80 c,
81 m,
82 n,
83 kk,
84 stream,
85 )
86}
87
88/// Every kernel a DSA block launches, beyond the selection set.
89#[derive(Clone, Copy)]
90pub struct Glm5NextDsaLayerKernels {
91 /// `C = A @ B^T`, BF16 out.
92 pub gemm: KernelHandle,
93 /// Same, FP32 out — the selector wants `q_idx` and the head weights in FP32.
94 pub gemm_f32: KernelHandle,
95 /// M=1 twins of the two above. `gemv_f32` may be a 0 handle on a target that predates
96 /// `dense_gemv_bf16_fp32out`; `gemm` refuses nothing and falls back to the tile arm.
97 pub gemv: KernelHandle,
98 pub gemv_f32: KernelHandle,
99 /// 🔴 `dense_gemv_bf16_batchm` — `2 ..= 8` rows in ONE weight sweep, the arm that makes a
100 /// K-token verify pay for q_a/q_b/kv_a/kv_b/o once instead of K times. `0` = unavailable.
101 pub gemv_batchm: KernelHandle,
102 /// 🪤 **vanilla** = `x * rms * w`. The other `rms_norm` adds 1 to the weight.
103 pub rms_norm: KernelHandle,
104 /// RMSNorm + FP8 + paged slot write, GLM-target.
105 pub latent_write: KernelHandle,
106}
107
108impl Glm5NextDsaLayerKernels {
109 pub fn resolve(gpu: &dyn GpuBackend) -> Result<Self> {
110 Ok(Self {
111 // 🪤 Module is "gemm", NOT the file stem. `common/KERNEL.toml` [modules] maps
112 // `dense_gemm_bf16 = "gemm"`, and an unlisted .cu takes its stem — so the two
113 // conventions coexist and only the TOML says which applies. Guessing the stem
114 // here resolved to nothing and would have failed at first construction.
115 gemm: gpu.kernel("gemm", "dense_gemm_bf16")?,
116 gemm_f32: gpu.kernel("gemm", "dense_gemm_bf16_f32out")?,
117 gemv: gpu.kernel("gemv", "dense_gemv_bf16")?,
118 // 🪤 try_kernel, not kernel: this entry point had ZERO Rust callers before
119 // 2026-08-28, so a target that never compiled it must fall back, not refuse.
120 gemv_f32: crate::layers::try_kernel(gpu, "gemv", "dense_gemv_bf16_fp32out"),
121 gemv_batchm: crate::layers::try_kernel(
122 gpu,
123 "dense_gemv_bf16_batchm",
124 "dense_gemv_bf16_batchm",
125 ),
126 rms_norm: gpu.kernel("rms_norm_vanilla", "rms_norm_vanilla")?,
127 latent_write: gpu
128 .kernel("glm5next_mla_latent_write", "glm5next_mla_latent_write_fp8")?,
129 })
130 }
131}
132
133/// One DSA block's weights, already sharded for this rank.
134pub struct Glm5NextDsaWeights {
135 // ── MLA ──
136 pub q_a_proj: DevicePtr,
137 pub q_a_layernorm: DevicePtr,
138 /// `[local_heads * kv_lora_rank, q_lora_rank]` BF16 — `q_b_proj` **absorbed** through
139 /// `kv_b_proj`'s K half, so Q arrives in latent space. See the module header.
140 pub q_absorb: DevicePtr,
141 pub kv_a_proj: DevicePtr,
142 pub kv_a_layernorm: DevicePtr,
143 /// `[hidden, local_heads * kv_lora_rank]` BF16, row-parallel — all-reduced by the caller.
144 ///
145 /// 🪤 **Absorbed**, not the raw checkpoint `o_proj`: the decode kernel leaves its output
146 /// in the 512-dim LATENT space, so the projection carries `kv_b_proj`'s V half folded in.
147 /// The raw weight is `local_heads * v_head_dim` wide — half of this — and feeding the
148 /// latent to it reads 2x past every row rather than merely computing the wrong thing.
149 pub o_absorb: DevicePtr,
150 // ── indexer (REPLICATED across ranks; see `tp`) ──
151 pub wk: DevicePtr,
152 pub k_norm_weight: DevicePtr,
153 /// 🪤 REQUIRED. `k_norm` is a LayerNorm; a `.weight`-only bind silently drops the
154 /// mean subtraction and the bias.
155 pub k_norm_bias: DevicePtr,
156 pub compress_gate: DevicePtr,
157 pub wq_b: DevicePtr,
158 /// 🪤 Pre-multiplied by `index_heads^-0.5` at load — `dsa_index_scores` does not scale.
159 pub weights_proj: DevicePtr,
160 /// `[index_kpool, index_head_dim]` **FP32**. 🪤 BF16 on disk; upconverted at load.
161 pub ape: DevicePtr,
162}
163
164/// The PREFILL selector runs once for the whole row group instead of once per token.
165/// ON by default; `AVAROK_DSA_SELECT_ROWS=0` is the kill-switch back to per-row selection.
166///
167/// 🔴 EXPLICIT PREFILL ONLY. The batched pass runs when — and only when — the caller states
168/// `is_prefill`; see [`batch_select_enabled`] for the whole table. Nothing in
169/// `ForwardContext` implies it. `decode_step` is false for prefill AND for a speculative
170/// verify, and `graph_capture` is false for prefill AND for an EAGER verify: `verify_a`
171/// hard-codes `graph_capture: false`, and `verify_b/c/c2/d/fused` take it from `use_graphs`,
172/// which is false under `AVAROK_GLM_VERIFY_GRAPHS=0`, under high-speed swap, and under
173/// `AVAROK_LORA_EAGER`. **Both eager and graphed verification keep the original per-row
174/// path.**
175///
176/// `!graph_capture` is required SEPARATELY, for its own reason rather than as a proxy for
177/// "not verify": `select_rows_batched` performs host copy/H2D work (`copy_h2d` of `q_pos`),
178/// which is `CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED` inside a recording stream.
179///
180/// Selection MEMBERSHIP is unaffected by the widened pool walk, and the kernels are
181/// untouched. `dsa_index_scores` already carries the row axis on `gridDim.y` with a per-row
182/// `q_pos[Q]`, and its candidacy test (`dsa_indexer.cu`) is *"a pool is a candidate only when
183/// it is complete AND its LAST token is visible to this query"* — `pool_valid[p] && end_c <=
184/// q_pos[r]`. Both terms are per-row or per-pool; neither reads the scalar `P`. So widening
185/// `P` from row `r`'s own pool count to the group's selects the same pools in the same order
186/// for row `r`: every extra pool is either incomplete or ends past `q_pos[r]`, scores
187/// `-FLT_MAX`, and `dsa_topk_pools` excludes it under a TOTAL order (score DESC, then pool
188/// index ASC — unique, so the top-`select_k` prefix is unique).
189///
190/// 🔴 Equal membership was NOT equal placement, and that distinction cost a review cycle.
191/// `dsa_expand_selection` writes the visible tail at `select_k * KP`, and `select_k` is a
192/// per-pass scalar: the original batched geometry planned it once from the group's FINAL
193/// cache length, so every earlier row's tail slid forward relative to its serial twin. The
194/// production attention (`glm5next_dsa_mla_decode_fp8`) splits the selection row into
195/// `NUM_WARPS = 8` slices, runs a per-warp online softmax and merges across warps, so a
196/// slid token is folded by the MERGE instead of by its warp's serial loop. **The original
197/// batched geometry was shown to ALTER warp FP reduction grouping** — measured on the real
198/// kernel, 14 of 18 crossing configurations differ by up to 2 BF16 ulp over 64 draws each
199/// (a single draw is byte-identical, which is why one probe proved nothing: the kernel
200/// accumulates in FP32 and writes BF16). Over a 9,000-token prefill at `PREFILL_ROWS = 16`,
201/// 1,920 of 8,999 rows moved their tail base and 42 moved a real token across a warp slice.
202///
203/// The correction is per-row geometry inside `dsa_expand_selection`:
204/// `row_select_k = min(select_k, (q_pos[r] + 1) / KP)`, applied to both the pool loop and
205/// the tail base. It **preserves serial selection-slot geometry** and is a no-op at
206/// `q_rows == 1`, so the serial and replay-safe paths are untouched. Exact selector parity
207/// was restored: real-kernel serial-vs-batched comparison over 2,397 rows went from 1,533
208/// mismatching rows to **0**.
209///
210/// Measured on the 9K prefill probe, n3+n4, fresh container, first request after launch:
211/// **146.073 s -> 134.001 s, -12.072 s / -8.26 %**, with the canonical six reproducing the
212/// sealed reference **6/6** and the sealed p9000 hash `4187fe63fa78d8b4` unchanged
213/// (2026-09-06, `scripts/glm53-dsa-promote/{gate-6probe.sh,ADJUDICATION.md}`).
214///
215/// The four `[max_rows]` twins are allocated only when this is on, so the kill-switch arm
216/// keeps the pre-batching heap layout byte for byte. That matters here: this workspace is
217/// the one where merely making an allocation unconditionally was itself enough to move the
218/// model's sampled output (see `bt`/`sl` below, and A55).
219/// Whether ONE selection pass covers the whole row group, or each row selects on its own.
220///
221/// Extracted from `decode_k` so the choice is a table a test can drive, not a boolean buried
222/// in a 300-line function. Every term is load-bearing:
223///
224/// * `workspace_ready` — the four `[max_rows]` twins exist. False under
225/// `AVAROK_DSA_SELECT_ROWS=0`, which is what keeps that arm's heap layout byte for byte.
226/// * `is_prefill` — stated by the caller. NOTHING in `ForwardContext` implies it:
227/// `decode_step` is false for prefill AND verify, and `graph_capture` is false for prefill
228/// AND for an eager verify (`verify_a` hard-codes it; `verify_b/c/c2/d/fused` take it from
229/// `use_graphs`, false under `AVAROK_GLM_VERIFY_GRAPHS=0`, high-speed swap, or
230/// `AVAROK_LORA_EAGER`).
231/// * `!graph_capture` — independent of the above, and kept for its own reason:
232/// `select_rows_batched` issues a host `copy_h2d`, which is
233/// `CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED` inside a recording stream.
234/// * `k > 1` — a single row has nothing to batch, and there is no behaviour to change.
235pub(crate) fn batch_select_enabled(
236 workspace_ready: bool,
237 is_prefill: bool,
238 graph_capture: bool,
239 k: usize,
240) -> bool {
241 workspace_ready && is_prefill && !graph_capture && k > 1
242}
243
244pub(crate) fn dsa_select_rows_enabled() -> bool {
245 std::env::var("AVAROK_DSA_SELECT_ROWS").as_deref() != Ok("0")
246}
247
248/// Scratch reused across decode steps. Allocated once per layer.
249pub struct Glm5NextDsaWorkspace {
250 q_a: DevicePtr,
251 q_resid: DevicePtr,
252 q_abs: DevicePtr,
253 kv_a: DevicePtr,
254 q_idx: DevicePtr,
255 head_weights: DevicePtr,
256 q_pos: DevicePtr,
257 q_mask: DevicePtr,
258 /// `[max_rows, ...]` twins of `q_idx` / `head_weights` / `q_pos` / `q_mask`, used only
259 /// by the batched prefill selector. NULL when `AVAROK_DSA_SELECT_ROWS=0` — see
260 /// [`dsa_select_rows_enabled`] for why they are not allocated unconditionally.
261 q_idx_rows: DevicePtr,
262 head_weights_rows: DevicePtr,
263 q_pos_rows: DevicePtr,
264 q_mask_rows: DevicePtr,
265 slot: DevicePtr,
266 attn_out: DevicePtr,
267 /// Block table for the paged gather, `[max_dsa_context]` i32. PERSISTENT.
268 /// 🔴 This used to be a `gpu.alloc` + `gpu.free` on EVERY DSA layer of EVERY
269 /// decode token — 11 allocs + 11 frees per token. `cuMemAlloc` serialises against
270 /// the driver, and nsys (2026-08-28) charged the alloc/copy/free cluster ~98 us of
271 /// GPU-idle per DSA layer, 1.08 ms of an 79 ms step.
272 bt: DevicePtr,
273 /// Sequence length for the paged gather, one i32. PERSISTENT, same reason.
274 sl: DevicePtr,
275 /// Capacity of `bt` in ENTRIES, so the forward can refuse rather than overrun it.
276 bt_cap: usize,
277 /// Widest verify this scratch can serve. `1` on the serial decode path.
278 max_rows: usize,
279 /// `[index_head_dim]` BF16 staging for the indexer row, at a FIXED address.
280 ///
281 /// 🔴 The projections used to write straight into `k_normed`/`gate` at
282 /// `offset(pos * D * 2)` — a host-computed address, which a captured graph freezes at
283 /// the capture-time row. Under capture they land here and `dsa_indexer_store` places
284 /// them from a device-side `pos`. Same arithmetic, one extra 256-byte copy.
285 stage_k: DevicePtr,
286 stage_gate: DevicePtr,
287 /// `[5]` i32 selector geometry, written on device once per step by `dsa_write_geom`.
288 geom_dev: DevicePtr,
289 select: DsaSelectScratch,
290}
291
292impl Glm5NextDsaWorkspace {
293 /// `max_rows` is the widest speculative verify this workspace serves. The projection
294 /// buffers, `attn_out` and the selector's OUTPUT row scale with it; the indexer staging
295 /// rows, the block table and the selector's within-pass temporaries stay per-row,
296 /// because [`Glm5NextDsaLayer::decode_k`] still SELECTS one token at a time.
297 pub fn new(gpu: &dyn GpuBackend, cfg: &Glm5NextDsaConfig, max_rows: usize) -> Result<Self> {
298 let rows = max_rows.max(1);
299 // Sized at the DSA context cap so a growing sequence never reallocates.
300 // `q_rows = rows`: selection still runs one row at a time, but its OUTPUT is
301 // `[max_rows, out_width]` so a K-row verify can attend every row in one launch.
302 let geom =
303 super::select::DsaSelectGeometry::plan(cfg, super::state::max_dsa_context(cfg), rows)?;
304 let bt_cap = super::state::max_dsa_context(cfg).max(1);
305 let persist = std::env::var("AVAROK_GLM_DSA_ALLOC_PER_STEP").as_deref() != Ok("1");
306 let batch_select = dsa_select_rows_enabled();
307 Ok(Self {
308 q_a: gpu.alloc(rows * (cfg.q_lora_rank * 2))?,
309 q_resid: gpu.alloc(rows * (cfg.q_lora_rank * 2))?,
310 q_abs: gpu.alloc(rows * (cfg.local_heads * cfg.kv_lora_rank * 2))?,
311 kv_a: gpu.alloc(rows * (cfg.kv_lora_rank * 2))?,
312 q_idx: gpu.alloc(cfg.index_heads * cfg.index_head_dim * 4)?,
313 head_weights: gpu.alloc(cfg.index_heads * 4)?,
314 q_pos: gpu.alloc(4)?,
315 q_idx_rows: if batch_select {
316 gpu.alloc(rows * cfg.index_heads * cfg.index_head_dim * 4)?
317 } else {
318 DevicePtr(0)
319 },
320 head_weights_rows: if batch_select {
321 gpu.alloc(rows * cfg.index_heads * 4)?
322 } else {
323 DevicePtr(0)
324 },
325 q_pos_rows: if batch_select {
326 gpu.alloc(rows * 4)?
327 } else {
328 DevicePtr(0)
329 },
330 q_mask_rows: if batch_select {
331 // Same "one real query position per row" the scalar `q_mask` encodes,
332 // written once so the batched pass never needs a per-step H2D for it.
333 let p = gpu.alloc(rows)?;
334 gpu.memset_async(p, 1, rows, 0)?;
335 gpu.synchronize(0)?;
336 p
337 } else {
338 DevicePtr(0)
339 },
340 q_mask: {
341 // Decode always presents one real query position. Set ONCE — writing it per
342 // token cost a blocking H2D per DSA layer and made the step uncapturable.
343 let p = gpu.alloc(1)?;
344 gpu.memset_async(p, 1, 1, 0)?;
345 gpu.synchronize(0)?;
346 p
347 },
348 slot: gpu.alloc(8)?,
349 attn_out: gpu.alloc(rows * (cfg.local_heads * cfg.kv_lora_rank * 2))?,
350 // One entry per cached token is the worst case (block_size == 1), so the
351 // DSA context cap bounds it for every block size.
352 //
353 // 🔴 Allocated ONLY when `AVAROK_GLM_DSA_PERSIST_BT=1`. Not a micro-optimisation:
354 // making these two allocations UNCONDITIONALLY — even leaving them unused —
355 // is by itself enough to change the model's sampled output (measured t27,
356 // 2026-08-28). See A55 and the note at the use site.
357 bt: if persist {
358 gpu.alloc(bt_cap * 4)?
359 } else {
360 DevicePtr(0)
361 },
362 // 🔴 ANOMALIES A65: `[rows]`, NOT one. `attend_rows` runs ONE launch for all
363 // k rows and `glm5next_dsa_mla_decode` reads `seq_lens[blockIdx.y]`, so a
364 // single i32 here left every row past the first reading past the allocation.
365 sl: if persist {
366 gpu.alloc(rows * 4)?
367 } else {
368 DevicePtr(0)
369 },
370 bt_cap,
371 max_rows: rows,
372 stage_k: gpu.alloc(cfg.index_head_dim * 2)?,
373 stage_gate: gpu.alloc(cfg.index_head_dim * 2)?,
374 geom_dev: gpu.alloc(5 * 4)?,
375 select: DsaSelectScratch::alloc(gpu, cfg, &geom)?,
376 })
377 }
378}
379
380pub struct Glm5NextDsaLayer {
381 pub cfg: Glm5NextDsaConfig,
382 pub weights: Glm5NextDsaWeights,
383 pub kernels: Glm5NextDsaLayerKernels,
384 pub select_kernels: Glm5NextDsaKernels,
385 pub decode_kernel: Glm5NextDsaDecodeKernel,
386 pub workspace: Glm5NextDsaWorkspace,
387 /// Index in the MODEL stack (0..num_hidden_layers). Diagnostics only.
388 pub layer_idx: usize,
389 /// Index in the KV POOL — the running ordinal over KV-cache-consuming layers,
390 /// which for GLM-5.3 is 0..11 over the sparse layers, not 0..45.
391 ///
392 /// 🪤 These two are NOT interchangeable. The pool is sized to
393 /// `ModelConfig::num_attention_layers()`; indexing it with `layer_idx` reads
394 /// past the end of the allocation on every layer after the first.
395 pub attn_layer_idx: usize,
396 pub rms_eps: f32,
397 /// FP8 latent-cache scale. Reads and writes must agree; the write takes `1/scale`.
398 pub kv_scale: f32,
399 /// Persistent block-table buffers instead of a `gpu.alloc`/`gpu.free` per DSA layer per
400 /// token. ON by default; `AVAROK_GLM_DSA_ALLOC_PER_STEP=1` restores the old path.
401 pub persist_bt: bool,
402}
403
404impl Glm5NextDsaLayer {
405 /// Project `hidden` into the indexer cache at position `pos`, then advance.
406 ///
407 /// Writes `k_normed` and `gate` **directly into the state rows** rather than through a
408 /// staging buffer: the selector reads `k[raw * D + d]` over the whole context, so the
409 /// cache is the natural destination and a copy would buy nothing.
410 pub fn indexer_forward(
411 &self,
412 gpu: &dyn GpuBackend,
413 hidden: DevicePtr,
414 state: &mut Glm5NextDsaState,
415 // Some(pos) => write through the FIXED staging row and let `dsa_indexer_store`
416 // place it from this device-side position. None => the host-offset path.
417 pos_dev: Option<DevicePtr>,
418 stream: u64,
419 ) -> Result<()> {
420 // 🔴 BEFORE any write. Everything below writes into row `state.len()`; past the cap
421 // that row is off the end of the buffer, and the resulting sticky CUDA 700 kills the
422 // whole context, not just this request. A62.
423 state.ensure_room(1)?;
424 let d = self.cfg.index_head_dim;
425 let pos = state.len();
426 let off = state.row_offset(pos);
427 let w = &self.workspace;
428 let (k_dst, gate_dst) = match pos_dev {
429 Some(_) => (w.stage_k, w.stage_gate),
430 None => (state.k_normed.offset(off), state.gate.offset(off)),
431 };
432
433 // k_raw -> the state row, then LayerNorm in place.
434 gemm(
435 gpu,
436 self.kernels.gemm,
437 self.kernels.gemv,
438 self.kernels.gemv_batchm,
439 hidden,
440 self.weights.wk,
441 k_dst,
442 1,
443 d,
444 self.cfg.hidden,
445 stream,
446 )?;
447 // 🪤 LayerNorm WITH BIAS, in place, one row.
448 KernelLaunch::new(gpu, self.select_kernels.k_norm)
449 .grid([1, 1, 1])
450 .block([d.min(1024) as u32, 1, 1])
451 .shared_mem((d.min(1024) * 4) as u32)
452 .arg_ptr(k_dst)
453 .arg_ptr(self.weights.k_norm_weight)
454 .arg_ptr(self.weights.k_norm_bias)
455 .arg_u32(1)
456 .arg_u32(d as u32)
457 .arg_f32(self.rms_eps)
458 .launch(stream)?;
459
460 gemm(
461 gpu,
462 self.kernels.gemm,
463 self.kernels.gemv,
464 self.kernels.gemv_batchm,
465 hidden,
466 self.weights.compress_gate,
467 gate_dst,
468 1,
469 d,
470 self.cfg.hidden,
471 stream,
472 )?;
473
474 // Per-head selector weights, FP32 straight out of the GEMM, from the LAYER INPUT.
475 // `weights_proj` is `[index_heads, hidden]` and the reference is
476 // `weights_proj(hidden) * index_heads**-0.5`, with the scale already folded into the
477 // weight at load (`build.rs` transform 2). Computed here rather than in
478 // `select_and_attend` for the plain reason that this is the function that HAS
479 // `hidden`; `select_and_attend` does not, which is how it came to read `q_resid`
480 // instead and overrun it by 5120 bytes. See A55.
481 gemm(
482 gpu,
483 self.kernels.gemm_f32,
484 self.kernels.gemv_f32,
485 // No FP32-out batchm twin exists; the selector's two sites stay on gemv/tile.
486 KernelHandle(0),
487 hidden,
488 self.weights.weights_proj,
489 self.workspace.head_weights,
490 1,
491 self.cfg.index_heads,
492 self.cfg.hidden,
493 stream,
494 )?;
495
496 match pos_dev {
497 // 🔴 Placement and the validity mark both from a DEVICE position — a memset at
498 // `valid.offset(pos)` is one more host-baked address a graph would freeze.
499 Some(pd) => {
500 KernelLaunch::new(gpu, self.select_kernels.indexer_store)
501 .grid([1, 1, 1])
502 .block([d.min(1024) as u32, 1, 1])
503 .arg_ptr(w.stage_k)
504 .arg_ptr(w.stage_gate)
505 .arg_ptr(pd)
506 .arg_ptr(state.k_normed)
507 .arg_ptr(state.gate)
508 .arg_ptr(state.valid)
509 .arg_u32(d as u32)
510 .launch(stream)?;
511 }
512 // Validity is per position and this one is real.
513 None => gpu.memset_async(state.valid.offset(pos), 1, 1, stream)?,
514 }
515 state.advance(1)
516 }
517
518 /// Everything after the indexer write: selector inputs and the selection for ONE query
519 /// row, into row `row` of the workspace's selection scratch.
520 ///
521 /// 🔴 SELECTION stays per-row inside a K-token verify: the selector's geometry, its
522 /// top-k over `[0, len)` and its visibility test are all functions of THIS token's
523 /// position, and the indexer cache grows by one row between them. The gather-ATTEND
524 /// does not — every row reads the same cache with its own index row — so it is hoisted
525 /// out to one K-row launch in `attend_rows`. Three serial launches of 32 head-blocks
526 /// each left most of the GPU idle: 7.13 ms/step of the K=3 budget (nsys 2026-08-29).
527 #[allow(clippy::too_many_arguments)]
528 fn select_row(
529 &self,
530 gpu: &dyn GpuBackend,
531 row: usize,
532 state: &Glm5NextDsaState,
533 q_pos_dev: DevicePtr,
534 replay_safe: bool,
535 stream: u64,
536 ) -> Result<()> {
537 let w = &self.workspace;
538 let geom = state.geometry(&self.cfg, 1)?;
539
540 // Selector Q and head weights, FP32 straight out of the GEMM.
541 gemm(
542 gpu,
543 self.kernels.gemm_f32,
544 self.kernels.gemv_f32,
545 // No FP32-out batchm twin exists; the selector's two sites stay on gemv/tile.
546 KernelHandle(0),
547 w.q_resid.offset(row * self.cfg.q_lora_rank * 2),
548 self.weights.wq_b,
549 w.q_idx,
550 1,
551 self.cfg.index_heads * self.cfg.index_head_dim,
552 self.cfg.q_lora_rank,
553 stream,
554 )?;
555 // 🔴 `head_weights` is NOT computed here any more — see `indexer_forward`. It used to
556 // be, from `w.q_resid` with `K = cfg.hidden`, which was wrong twice over: the
557 // reference projects the LAYER INPUT (`gen_dsa_indexer_golden.py`:
558 // `weights_proj(hidden) * NH**-0.5`), and `q_resid` is only `[q_lora_rank] = 1536`
559 // BF16, so reading 4096 of them ran **5120 bytes past the end of the allocation**.
560 // That out-of-bounds read was ANOMALIES A55: the head weights were a function of
561 // whatever the allocator had placed after `q_resid`, which is why the model's output
562 // moved when the heap moved, when allocations were zeroed, and when an unrelated
563 // buffer was added. Found by red-zoning the allocator and bisecting the guard bands.
564
565 let inputs = DsaSelectInputs {
566 k_normed: state.k_normed,
567 gate: state.gate,
568 valid: state.valid,
569 ape: self.weights.ape,
570 q: w.q_idx,
571 weights: w.head_weights,
572 q_pos: q_pos_dev,
573 // Always 1 for a decode step; written once at workspace alloc, never per token.
574 q_mask: w.q_mask,
575 first_key: 0,
576 geom_dev: if replay_safe {
577 w.geom_dev
578 } else {
579 DevicePtr::NULL
580 },
581 };
582 // Under capture the grid and the shared-memory request go to the context CEILING and
583 // the live extents come off `geom_dev`, so ONE graph serves every context length.
584 let launch = if replay_safe {
585 super::select::DsaSelectLaunch::Ceiling {
586 max_pools: super::select::contiguous_pool_count(
587 self.cfg.index_kpool,
588 super::state::max_dsa_context(&self.cfg),
589 ),
590 }
591 } else {
592 super::select::DsaSelectLaunch::Exact
593 };
594 let t = crate::layers::glm5next_layer::profile::start();
595 // Row `row` of the `[max_rows, out_width]` selection scratch. The kernels still run
596 // one query row (`q_rows == 1`), they just land in this row's slot, so `attend_rows`
597 // can read all K index rows in one launch.
598 select_tokens(
599 gpu,
600 &self.select_kernels,
601 &self.cfg,
602 &geom,
603 &inputs,
604 &w.select.row(row, &self.cfg),
605 launch,
606 stream,
607 )?;
608 use crate::layers::glm5next_layer::profile;
609 profile::end(profile::DSA_SELECT, t, gpu, stream);
610 Ok(())
611 }
612
613 /// The selection for ALL `k` query rows in ONE pass — the prefill twin of
614 /// [`Self::attend_rows`]. On by default; disabled by `AVAROK_DSA_SELECT_ROWS=0`.
615 ///
616 /// Per 9,000-token prefill this replaces 4 x 9,000 x 11 single-row launches. Measured
617 /// at `6228baa2` (nsys s3-candcap, n3+n4): `dsa_topk_pools` and `dsa_expand_selection`
618 /// each run **grid(1,1,1)** — one block, 48 SMs idle — 99,000 times for 5.773 s and
619 /// 5.071 s, with `dsa_index_scores` a further 4.211 s. 15.06 s of a 147.8 s prefill
620 /// spent at ~2 % occupancy. Each row's block does exactly the work its own launch did;
621 /// only the launch count changes.
622 ///
623 /// Exactness, in three parts, all of them properties the kernels already have:
624 /// 1. `q_pos` is `[Q]` and the candidacy test reads `q_pos[r]`, so causality is
625 /// per-row. A pool that ends past row `r` is not a candidate for row `r`.
626 /// 2. `pool_valid[p]` gates completeness, so the in-progress pool is excluded for
627 /// every row exactly as it is today.
628 /// 3. Non-candidates score `-FLT_MAX` and `dsa_topk_pools` selects under a total
629 /// order over (score, pool index), so a longer `P` walk reaches the identical
630 /// top-`select_k` set AND order.
631 ///
632 /// Widening `P` to the group's pool count therefore cannot change any row's selection.
633 ///
634 /// The `q_idx` projections stay per-row: there is no FP32-out `batchm` twin, and they
635 /// are 2.765 s of `dense_gemv_bf16_fp32out` that this lane does not claim.
636 #[allow(clippy::too_many_arguments)]
637 fn select_rows_batched(
638 &self,
639 gpu: &dyn GpuBackend,
640 k: usize,
641 state: &Glm5NextDsaState,
642 q_pos_host: &[i32],
643 stream: u64,
644 ) -> Result<()> {
645 let w = &self.workspace;
646 // `q_rows = k`, and `len` is the cache length AFTER all k indexer writes — which is
647 // the point: `P` is the group's final pool count and each row masks itself back down
648 // to its own horizon via `q_pos[r]`.
649 let geom = state.geometry(&self.cfg, k)?;
650 let idx_row = self.cfg.index_heads * self.cfg.index_head_dim;
651 // 🔴 ONE GEMM for all k rows, not k GEMVs. This loop used to run an M=1 GEMV per
652 // row — at 256 prefill rows x 11 DSA layers, 2,816 launches per chunk, each one
653 // sweeping the ENTIRE `wq_b` weight to produce a single row. 256 full weight reads
654 // where one suffices.
655 //
656 // It was written that way for a concrete reason, recorded in the comment this
657 // replaces: "no FP32-out batchm twin exists". `q_idx_rows` is FP32, and Atlas's
658 // batched-M kernel only has a BF16-out form, so `batchm` was passed as
659 // `KernelHandle(0)` and `dense_mm_bf16` had no batched tier to route to. The twin
660 // now exists — `cublas_bf16_proj_dense_f32_out` — so the reason is gone.
661 //
662 // Shapes line up with no repacking: `q_resid` is a contiguous `[k, q_lora_rank]`
663 // BF16 block (the loop indexed it by `row * q_lora_rank`) and `q_idx_rows` a
664 // contiguous `[k, idx_row]` FP32 one, which is exactly `out[M,N] = act[M,K] @ Wt`.
665 //
666 // 🪤 PREFILL ONLY, and not by inference: `select_rows_batched` is reached solely
667 // through `batch_select`, which requires `is_prefill && !graph_capture && k > 1`.
668 // Decode and the speculative verify go through `select_row`, whose M=1 GEMV is
669 // untouched — so the bit-exact tiers still produce what they always did. This does
670 // reassociate, and the DSA indexer feeds a top-k over KV positions, so a tie near
671 // the selection boundary could pick a different token: that is why the arm is
672 // measured on long-context needle recall, not just on throughput.
673 if crate::layers::glm5next_layer::dsa_batch_qidx() {
674 crate::layers::ops::cublas_bf16_proj_dense_f32_out(
675 w.q_resid,
676 self.weights.wq_b,
677 w.q_idx_rows,
678 k as u32,
679 idx_row as u32,
680 self.cfg.q_lora_rank as u32,
681 stream,
682 )?;
683 } else {
684 for row in 0..k {
685 gemm(
686 gpu,
687 self.kernels.gemm_f32,
688 self.kernels.gemv_f32,
689 // Same as `select_row`: no FP32-out batchm twin exists.
690 KernelHandle(0),
691 w.q_resid.offset(row * self.cfg.q_lora_rank * 2),
692 self.weights.wq_b,
693 w.q_idx_rows.offset(row * idx_row * 4),
694 1,
695 idx_row,
696 self.cfg.q_lora_rank,
697 stream,
698 )?;
699 }
700 }
701 let q_pos_bytes: Vec<u8> = q_pos_host.iter().flat_map(|p| p.to_le_bytes()).collect();
702 gpu.copy_h2d(&q_pos_bytes, w.q_pos_rows)?;
703 let inputs = DsaSelectInputs {
704 k_normed: state.k_normed,
705 gate: state.gate,
706 valid: state.valid,
707 ape: self.weights.ape,
708 q: w.q_idx_rows,
709 weights: w.head_weights_rows,
710 q_pos: w.q_pos_rows,
711 q_mask: w.q_mask_rows,
712 first_key: 0,
713 // Host geometry only. The device-geometry path is decode-only and
714 // `select_tokens` refuses it at `q_rows > 1`.
715 geom_dev: DevicePtr::NULL,
716 };
717 let t = crate::layers::glm5next_layer::profile::start();
718 // The BASE of the `[max_rows, out_width]` scratch, not a row slice: the kernels
719 // carry the row axis themselves, so rows 0..k land in their own slots and
720 // `attend_rows` reads all k exactly as before.
721 select_tokens(
722 gpu,
723 &self.select_kernels,
724 &self.cfg,
725 &geom,
726 &inputs,
727 &w.select,
728 super::select::DsaSelectLaunch::Exact,
729 stream,
730 )?;
731 crate::layers::glm5next_layer::profile::end(
732 crate::layers::glm5next_layer::profile::DSA_SELECT,
733 t,
734 gpu,
735 stream,
736 );
737 Ok(())
738 }
739
740 /// The gather-attend for ALL `rows` query rows in ONE launch.
741 ///
742 /// Every row reads the same paged latent cache with its own selection row, its own
743 /// `seq_len` and its own block-table row, so `gridDim.y` carries the row axis and the
744 /// per-row arithmetic is untouched — bit-identical to the serial launches it replaces.
745 /// `q_abs`, `attn_out`, the metadata's `seq_len`/`block_table` and the selection scratch
746 /// are all `[rows, ...]` at the SAME strides the kernel indexes.
747 #[allow(clippy::too_many_arguments)]
748 fn attend_rows(
749 &self,
750 gpu: &dyn GpuBackend,
751 rows: usize,
752 state: &Glm5NextDsaState,
753 kv_cache: &PagedKvCache,
754 block_table_dev: DevicePtr,
755 seq_lens_dev: DevicePtr,
756 paging: &DsaDecodePaging,
757 stream: u64,
758 ) -> Result<()> {
759 use crate::layers::glm5next_layer::profile;
760 let w = &self.workspace;
761 // Only `out_width` and the `q_rows == num_seqs` agreement are read here.
762 let geom = state.geometry(&self.cfg, rows)?;
763 let paging = DsaDecodePaging {
764 num_seqs: rows,
765 ..*paging
766 };
767 let t = profile::start();
768 let pool = kv_cache.k_pool_ptr(self.attn_layer_idx);
769 decode_attention(
770 gpu,
771 self.decode_kernel,
772 &self.cfg,
773 &geom,
774 &paging,
775 &DsaDecodeInputs {
776 q: w.q_abs,
777 k_cache: pool,
778 v_cache: pool, // absorbed NoPE MLA: K and V are the same latent
779 out: w.attn_out,
780 block_tables: block_table_dev,
781 seq_lens: seq_lens_dev,
782 sel_indices: w.select.tokens(),
783 k_scale: self.kv_scale,
784 v_scale: self.kv_scale,
785 },
786 stream,
787 )?;
788 profile::end(profile::DSA_ATTEND, t, gpu, stream);
789 Ok(())
790 }
791 /// ONE drafter CONTEXT row: the KV latent and the indexer entry, with no query, no
792 /// selection and no attend.
793 ///
794 /// The MTP drafter's context rows only have to EXIST in these two caches — their block
795 /// output is discarded. Both caches are pure functions of the row's own input, exactly as
796 /// the Qwen drafter prefill exploits, so a context row costs `kv_a` + `latent_write` + the
797 /// indexer's `wk`, not a decode step. No MoE, no `o_proj`, no `lm_head`.
798 ///
799 /// 🪤 `seq_len` is BOTH the row's KV slot and its RoPE position (the indexer takes its
800 /// position from `state.len()`), so the drafter's row space must stay DENSE — every pair
801 /// key from 0 up must have been written. That is what `prefill_drafter` + the catch-up
802 /// feed are for.
803 #[allow(clippy::too_many_arguments)]
804 pub fn write_kv_row(
805 &self,
806 hidden: DevicePtr,
807 state: &mut dyn LayerState,
808 kv_cache: &mut PagedKvCache,
809 seq_len: usize,
810 block_table: &mut Vec<u32>,
811 ctx: &ForwardContext,
812 stream: u64,
813 ) -> Result<()> {
814 let gpu = ctx.gpu;
815 let st = state
816 .as_any_mut()
817 .downcast_mut::<Glm5NextDsaState>()
818 .ok_or_else(|| {
819 anyhow::anyhow!("Glm5NextDsaLayer got a state that is not Glm5NextDsaState")
820 })?;
821 match st.len().cmp(&seq_len) {
822 std::cmp::Ordering::Greater => st.rewind_to(seq_len)?,
823 std::cmp::Ordering::Less => bail!(
824 "DSA layer {}: indexer cache holds {} rows but the drafter is at {seq_len} — \
825 rows are MISSING, not merely stale.",
826 self.layer_idx,
827 st.len()
828 ),
829 std::cmp::Ordering::Equal => {}
830 }
831 let w = &self.workspace;
832 gemm(
833 gpu,
834 self.kernels.gemm,
835 self.kernels.gemv,
836 self.kernels.gemv_batchm,
837 hidden,
838 self.weights.kv_a_proj,
839 w.kv_a,
840 1,
841 self.cfg.kv_lora_rank,
842 self.cfg.hidden,
843 stream,
844 )?;
845 let block_size = kv_cache.config().block_size;
846 let logical = seq_len / block_size;
847 let physical = *block_table.get(logical).ok_or_else(|| {
848 anyhow::anyhow!(
849 "DSA layer {}: block table has {} entries, needs logical block {logical} for \
850 drafter row {seq_len}",
851 self.layer_idx,
852 block_table.len()
853 )
854 })? as usize;
855 let slot = (physical * block_size + seq_len % block_size) as i64;
856 gpu.copy_h2d(&slot.to_le_bytes(), w.slot)?;
857 KernelLaunch::new(gpu, self.kernels.latent_write)
858 .grid([1, 1, 1])
859 .block([self.cfg.kv_lora_rank as u32, 1, 1])
860 .arg_ptr(w.kv_a)
861 .arg_ptr(self.weights.kv_a_layernorm)
862 .arg_ptr(kv_cache.k_pool_ptr(self.attn_layer_idx))
863 .arg_ptr(w.slot)
864 .arg_u32(self.cfg.kv_lora_rank as u32)
865 .arg_f32(self.rms_eps)
866 .arg_f32(1.0 / self.kv_scale)
867 .launch(stream)?;
868 self.indexer_forward(gpu, hidden, st, None, stream)
869 }
870
871 /// K tokens of one sequence: the projections batched, selection and attention NOT.
872 ///
873 /// The weight-heavy halves — `q_a`, the absorbed `q_b`, `kv_a` and the `o_absorb` output
874 /// projection — sweep their weights ONCE for all K rows (1,290 MB/rank/token between them).
875 /// Everything between them is a function of the individual token's position: the paged KV
876 /// slot, the indexer row, the selector geometry over `[0, len)` and the gather-attend.
877 ///
878 /// 🔴 Bit-identical to K serial [`TransformerLayer::decode`] calls, which is the
879 /// requirement: an accepted draft must be the token the unspeculated engine would have
880 /// emitted. `ops::dense_mm_bf16` reproduces each row's K-iteration order and reduction tree,
881 /// and `rms_norm_vanilla`'s grid is the token axis.
882 ///
883 /// 🪤 REFUSES a SCALAR (`num_seqs == 1`) `attn_metadata` at k > 1. Those scalars — position,
884 /// KV slot, seq len — describe ONE token, so K rows sharing them would write K queries into
885 /// the same paged slot and select over the same position: a wrong answer with no shape error.
886 ///
887 /// 🔴 It ACCEPTS a K-ROW `attn_metadata` (`num_seqs == k`), which is what the graphed verify
888 /// paths (`verify_b`/`verify_c`) already upload: positions `[k]` u32, slot `[k]` i64, seq_len
889 /// `[k]` i32, block_table `[k][max_blocks_per_seq]` i32, all at stable device addresses
890 /// written BEFORE capture or replay. Row `r` reads element `r` of each. Without this the
891 /// layer fell through to its own per-row `copy_h2d`, and an H2D on a capturing stream fails
892 /// with CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED — which is why the K-token verify was eager.
893 /// At `k == 1` every row offset is 0, so that path is unchanged byte for byte.
894 #[allow(clippy::too_many_arguments)]
895 pub fn decode_k(
896 &self,
897 hidden: DevicePtr,
898 k: usize,
899 state: &mut dyn LayerState,
900 kv_cache: &mut PagedKvCache,
901 seq_len: usize,
902 block_table: &mut Vec<u32>,
903 ctx: &ForwardContext,
904 stream: u64,
905 // TRUE only on a prefill sub-chunk. Passed explicitly by
906 // [`crate::layers::glm5next_layer::Glm5NextLayer::forward_k`]'s two call sites; it
907 // is NOT inferable from the context. `decode_step` is false for prefill AND for a
908 // speculative verify, and `graph_capture` is false for prefill AND for an EAGER
909 // verify — `verify_a` hard-codes `graph_capture: false`, and `verify_b/c/c2/d` set
910 // it from `use_graphs`, which is false under `AVAROK_GLM_VERIFY_GRAPHS=0`, under
911 // high-speed swap, and under `AVAROK_LORA_EAGER`. See `select_rows_batched`.
912 is_prefill: bool,
913 ) -> Result<()> {
914 use crate::layers::glm5next_layer::profile;
915 // Captured before the KV borrows below, for the block-table trim at the
916 // upload site. See the comment there (ANOMALIES A58).
917 let bt_block_size = kv_cache.block_size().max(1);
918 let st = state
919 .as_any_mut()
920 .downcast_mut::<Glm5NextDsaState>()
921 .ok_or_else(|| {
922 anyhow::anyhow!("Glm5NextDsaLayer got a state that is not Glm5NextDsaState")
923 })?;
924 // 🔴 The indexer stream must advance in lockstep with the KV cache — a drift selects
925 // over the wrong context. Two drifts are possible and they are NOT symmetric:
926 //
927 // * AHEAD (`len > seq_len`) is the speculative-verify reject. The K rows of a verify
928 // were written, the sequence rolled back to the accepted prefix, and the rows past
929 // it are now unreachable: the selector reads `[0, len)` and the next write starts
930 // at `seq_len`, so they are overwritten before anything can select over them.
931 // Rewind and continue — this is the KV cache's own semantics for rejected slots,
932 // and making it self-healing here is why no rollback callback has to reach into
933 // eleven DSA layers.
934 // * BEHIND (`len < seq_len`) means rows were never written. Nothing can repair that,
935 // so it stays a hard error.
936 match st.len().cmp(&seq_len) {
937 std::cmp::Ordering::Greater => st.rewind_to(seq_len)?,
938 std::cmp::Ordering::Less => bail!(
939 "DSA layer {}: indexer cache holds {} tokens but the sequence is at {} — \
940 rows are MISSING, not merely stale. The indexer stream must advance in \
941 lockstep with the KV cache.",
942 self.layer_idx,
943 st.len(),
944 seq_len
945 ),
946 std::cmp::Ordering::Equal => {}
947 }
948 if k == 0 || k > self.workspace.max_rows {
949 bail!(
950 "DSA layer {}: a {k}-token verify does not fit a workspace built for {}",
951 self.layer_idx,
952 self.workspace.max_rows
953 );
954 }
955 // A K-row pass may use `attn_metadata` ONLY when it carries one entry per row.
956 if k > 1
957 && let Some(m) = ctx.attn_metadata.as_ref()
958 && m.num_seqs as usize != k
959 && ctx.decode_step
960 {
961 bail!(
962 "DSA layer {}: a {k}-row pass cannot share attn_metadata describing {} \
963 token(s) — its position and KV slot describe a single token",
964 self.layer_idx,
965 m.num_seqs
966 );
967 }
968 // 🪤 Guarded on `num_seqs == k`, NOT on `decode_step`. A chunked prefill also passes
969 // metadata through this call at k == 1, and its `num_seqs` is the chunk width — so a
970 // one-token chunk is the only case where the two could be confused, and `decode_step`
971 // still separates them there.
972 let rowwise_meta = (k > 1)
973 .then_some(ctx.attn_metadata.as_ref())
974 .flatten()
975 .filter(|m| m.num_seqs as usize == k);
976 let gpu = ctx.gpu;
977 let w = &self.workspace;
978 let t_proj = crate::layers::glm5next_layer::profile::start();
979
980 // ── q path ──
981 gemm(
982 gpu,
983 self.kernels.gemm,
984 self.kernels.gemv,
985 self.kernels.gemv_batchm,
986 hidden,
987 self.weights.q_a_proj,
988 w.q_a,
989 k,
990 self.cfg.q_lora_rank,
991 self.cfg.hidden,
992 stream,
993 )?;
994 // 🪤 vanilla: x * rms * w, no `1 +`.
995 KernelLaunch::new(gpu, self.kernels.rms_norm)
996 // 🪤 `rms_norm_vanilla`'s grid IS the token axis, so k rows is one launch doing
997 // block-for-block what k launches did — bit-identical.
998 .grid([k as u32, 1, 1])
999 .block([256, 1, 1])
1000 .arg_ptr(w.q_a)
1001 .arg_ptr(self.weights.q_a_layernorm)
1002 .arg_ptr(w.q_resid)
1003 .arg_u32(self.cfg.q_lora_rank as u32)
1004 .arg_f32(self.rms_eps)
1005 .launch(stream)?;
1006 // Q absorbed into latent space in one GEMM.
1007 gemm(
1008 gpu,
1009 self.kernels.gemm,
1010 self.kernels.gemv,
1011 self.kernels.gemv_batchm,
1012 w.q_resid,
1013 self.weights.q_absorb,
1014 w.q_abs,
1015 k,
1016 self.cfg.local_heads * self.cfg.kv_lora_rank,
1017 self.cfg.q_lora_rank,
1018 stream,
1019 )?;
1020
1021 // ── kv path: latent -> FP8 -> paged slot ──
1022 gemm(
1023 gpu,
1024 self.kernels.gemm,
1025 self.kernels.gemv,
1026 self.kernels.gemv_batchm,
1027 hidden,
1028 self.weights.kv_a_proj,
1029 w.kv_a,
1030 k,
1031 self.cfg.kv_lora_rank,
1032 self.cfg.hidden,
1033 stream,
1034 )?;
1035 let mut attend_bt = DevicePtr::NULL;
1036 let mut attend_sl = DevicePtr::NULL;
1037 // Row 0 allocates the shared bt/sl scratch on the per-step-alloc path; it is freed
1038 // after the attend, which reads it. ANOMALIES A65.
1039 let mut owns_scratch = false;
1040 let mut attend_paging: Option<DsaDecodePaging> = None;
1041 // 🔴 PREFILL ONLY, and said so EXPLICITLY. `!ctx.graph_capture` does NOT mean
1042 // "prefill": `verify_a` builds its context with `graph_capture: false` outright, and
1043 // `verify_b/c/c2/d/fused` set it from `use_graphs`, which is false whenever
1044 // `AVAROK_GLM_VERIFY_GRAPHS=0`, high-speed swap is engaged, or `AVAROK_LORA_EAGER` is
1045 // set. `!ctx.decode_step` does not separate them either — a verify sets it false too.
1046 // So the caller states it. An eager K-row verify would otherwise silently take a path
1047 // that was measured and qualified on prefill alone.
1048 //
1049 // `!ctx.graph_capture` is KEPT as a second, independent condition rather than
1050 // replaced: `select_rows_batched` does a host `copy_h2d` of `q_pos`, which is
1051 // CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED inside a recording stream. Both must hold.
1052 //
1053 // The buffers are NULL under `AVAROK_DSA_SELECT_ROWS=0`, so this is false on that arm
1054 // and the heap layout is unchanged there.
1055 let batch_select =
1056 batch_select_enabled(w.q_idx_rows.0 != 0, is_prefill, ctx.graph_capture, k);
1057 let mut batch_q_pos: Vec<i32> = Vec::with_capacity(if batch_select { k } else { 0 });
1058 for row in 0..k {
1059 let pos = seq_len + row;
1060 let block_size = kv_cache.config().block_size;
1061 // 🔴 Every per-step scalar this layer needs — position, KV slot, seq_len, block
1062 // table — is ALREADY uploaded once per decode step by `decode_a` into
1063 // `attn_metadata`, at stable addresses, BEFORE any graph capture or replay. Reading
1064 // those pointers instead of doing our own `copy_h2d` removes FIVE blocking H2Ds
1065 // (each one a `cuStreamSynchronize`) per DSA layer per token — 55 stream drains on
1066 // this model — and is what makes the decode step capturable at all: an H2D inside a
1067 // capturing stream fails with CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED.
1068 //
1069 // 🪤 The two encodings must agree byte for byte, and they do: `positions` is the
1070 // u32 `seq_len` (same bits as our i32), `slot` the same i64 `block*block_size +
1071 // seq_len % block_size`, `seq_len` the same i32 `seq_len + 1`, and `block_table`
1072 // the same ids as i32 rather than u32.
1073 // 🪤 ONLY on a real decode step — `prefill_default` calls this same `decode` per
1074 // token with the prefill context, where these are arrays or NULL. See
1075 // `ForwardContext::decode_step`.
1076 let meta = if ctx.decode_step {
1077 ctx.attn_metadata.as_ref()
1078 } else {
1079 rowwise_meta
1080 };
1081 // Row strides into the K-row arrays. At k == 1 every one of these is 0.
1082 let bt_stride = meta.map_or(0, |m| m.max_blocks_per_seq as usize) * 4;
1083 let slot_dev = match meta {
1084 Some(m) => m.slot.offset(row * 8),
1085 None => {
1086 let logical = pos / block_size;
1087 let physical = *block_table.get(logical).ok_or_else(|| {
1088 anyhow::anyhow!(
1089 "DSA layer {}: block table has {} entries, needs logical block \
1090 {logical} for position {pos}",
1091 self.layer_idx,
1092 block_table.len()
1093 )
1094 })? as usize;
1095 let slot = (physical * block_size + pos % block_size) as i64;
1096 gpu.copy_h2d(&slot.to_le_bytes(), w.slot)?;
1097 w.slot
1098 }
1099 };
1100 KernelLaunch::new(gpu, self.kernels.latent_write)
1101 .grid([1, 1, 1])
1102 .block([self.cfg.kv_lora_rank as u32, 1, 1])
1103 .arg_ptr(w.kv_a.offset(row * self.cfg.kv_lora_rank * 2))
1104 .arg_ptr(self.weights.kv_a_layernorm)
1105 .arg_ptr(kv_cache.k_pool_ptr(self.attn_layer_idx))
1106 .arg_ptr(slot_dev)
1107 .arg_u32(self.cfg.kv_lora_rank as u32)
1108 .arg_f32(self.rms_eps)
1109 .arg_f32(1.0 / self.kv_scale)
1110 .launch(stream)?;
1111
1112 // ── indexer stream, then select + gather-attend ──
1113 use crate::layers::glm5next_layer::profile;
1114 profile::end(profile::DSA_PROJ, t_proj, gpu, stream);
1115 let t = profile::start();
1116 // Replay-safe placement only while a graph is RECORDING. An eager step keeps the
1117 // host-offset path, so the shipping numbers and byte-identity are untouched.
1118 let replay_safe = ctx.graph_capture
1119 && meta.is_some()
1120 && self.select_kernels.indexer_store.0 != 0
1121 && self.select_kernels.write_geom.0 != 0;
1122 let pos_dev = if replay_safe {
1123 meta.map(|m| m.positions.offset(row * 4))
1124 } else {
1125 None
1126 };
1127 self.indexer_forward(
1128 gpu,
1129 hidden.offset(row * self.cfg.hidden * 2),
1130 st,
1131 pos_dev,
1132 stream,
1133 )?;
1134 profile::end(profile::DSA_INDEXER, t, gpu, stream);
1135
1136 let (q_pos_dev, bt_dev_meta, sl_dev_meta) = match meta {
1137 Some(m) => (
1138 m.positions.offset(row * 4),
1139 Some(m.block_table.offset(row * bt_stride)),
1140 Some(m.seq_len.offset(row * 4)),
1141 ),
1142 None => {
1143 let qp = pos as i32;
1144 gpu.copy_h2d(&qp.to_le_bytes(), w.q_pos)?;
1145 (w.q_pos, None, None)
1146 }
1147 };
1148 let (d_bt, d_sl) = match (bt_dev_meta, sl_dev_meta) {
1149 // The step-scoped upload already holds both; nothing to copy.
1150 (Some(b), Some(l)) => (b, l),
1151 _ => {
1152 // Upload only the prefix the gather can index. The paged gather reads
1153 // `block_table[pos / block_size]` for `pos` in `[0, seq_len + k)` (the
1154 // `block_table.get(logical)` site above), so every entry past
1155 // `(seq_len + k) / block_size + 1` is dead weight on the wire.
1156 //
1157 // 🔴 ANOMALIES A58: it was also a silent kill switch for the GLM drafter.
1158 // `Glm5NextMtpHead::alloc_state` pre-claims its whole private pool up front
1159 // (`max_seq_len / 16 + 2` entries — a mid-decode allocation inside a captured
1160 // region is not an option), so at `--max-seq-len 262144` it presented 16,386
1161 // entries for a 20-token sequence against a `bt_cap` of 16,384. `bt_cap` is
1162 // `max_dsa_context`, a count of TOKENS used as a count of BLOCKS — the two
1163 // collide at 16,384. Every propose then bailed, the drafter produced nothing,
1164 // and acceptance read exactly `p1 = 0.000` while the target kept verifying
1165 // correctly and emitting byte-identical output. Measured 2026-08-30: healthy
1166 // at 196,608, dead at 262,144, output identical on both.
1167 let bt_used = {
1168 let needed = bt_entries_needed(seq_len, k, bt_block_size);
1169 &block_table[..needed.min(block_table.len())]
1170 };
1171 let bt: Vec<u8> = bt_used.iter().flat_map(|b| b.to_le_bytes()).collect();
1172 if bt_used.len() > w.bt_cap {
1173 anyhow::bail!(
1174 "DSA layer {}: block table needs {} entries for seq_len {} + {} rows \
1175 but the persistent buffer holds {}. This is a BLOCK count against a buffer \
1176 sized by max_dsa_context (a TOKEN count); do not write past the allocation.",
1177 self.layer_idx,
1178 bt_used.len(),
1179 seq_len,
1180 k,
1181 w.bt_cap
1182 );
1183 }
1184 // Persistent `w.bt`/`w.sl` instead of a `gpu.alloc` + `gpu.free` per DSA layer per
1185 // token: worth a measured 1.1 ms/token (nsys 2026-08-28 — 11 x ~98 us of GPU idle
1186 // for the alloc/copy/free cluster). Kill switch `AVAROK_GLM_DSA_ALLOC_PER_STEP=1`.
1187 //
1188 // 🪤 This was gated OFF for most of a day because turning it on changed the model's
1189 // output — which turned out to be ANOMALIES A55 and not this code at all: the DSA
1190 // indexer was reading 5120 bytes past `q_resid`, so the answer depended on what the
1191 // allocator had put next. With that fixed the two settings are byte-identical, and
1192 // the whole engine is layout-independent (verified by 4 KB poisoned guard bands on
1193 // 3431 allocations producing the same completions as no guard bands at all).
1194 // 🔴 ANOMALIES A65. The deferred `attend_rows` reads `seq_lens[row]` and
1195 // `block_tables + row * max_blocks_per_seq`, so these two buffers outlive
1196 // the row that wrote them. `sl` is `[k]` and each row writes its OWN slot;
1197 // the block table is uploaded once and shared with a row stride of ZERO
1198 // (see `max_blocks_per_seq` below) — the k rows ARE one sequence, so they
1199 // genuinely share one table. Writing a single-row `sl`/`bt` per row left
1200 // every row past the first attending over another row's (or no) memory.
1201 // `bt_entries_needed(seq_len, k, ..)` does not depend on `row`, so the
1202 // table's length is the same on every pass and re-uploading it is a no-op.
1203 let (d_bt, d_sl) = if self.persist_bt {
1204 (w.bt, w.sl)
1205 } else if row == 0 {
1206 (gpu.alloc(bt.len().max(4))?, gpu.alloc(k * 4)?)
1207 } else {
1208 // Row 0 owns the scratch; later rows write their own `sl` slot into it.
1209 (attend_bt, attend_sl)
1210 };
1211 gpu.copy_h2d(&bt, d_bt)?;
1212 gpu.copy_h2d(&((pos + 1) as i32).to_le_bytes(), d_sl.offset(row * 4))?;
1213 (d_bt, d_sl)
1214 }
1215 };
1216 let owns_bt = bt_dev_meta.is_none();
1217
1218 let paging = DsaDecodePaging {
1219 num_seqs: 1,
1220 num_q_heads: self.cfg.local_heads,
1221 num_kv_heads: 1,
1222 // 🔴 THIS IS A KERNEL ARGUMENT, so a CUDA graph BAKES IT IN at capture time.
1223 // `block_table.len()` grows every time the sequence crosses a block boundary,
1224 // so a captured graph replayed at a longer context keeps walking the capture's
1225 // block count — right answer for the first few tokens, wrong one after. Take
1226 // the metadata's ceiling, which verify_b/verify_c hold CONSTANT at
1227 // `self.max_blocks_per_seq` and zero-pad every uploaded row out to.
1228 // Without this the graphed K=3 verify diverged from eager on exactly the long
1229 // probes (pyadd/open128/open512) and matched on the 32-token ones.
1230 // 🔴 ANOMALIES A65: ZERO on the no-metadata path, which is the ROW STRIDE
1231 // the kernel applies to `block_tables`. All k rows of this call are the same
1232 // sequence and share the one table uploaded above, so a stride of 0 is the
1233 // correct sharing — `block_table.len()` walked row 1 off the end of a
1234 // single-row buffer. (The metadata path really does carry k padded rows.)
1235 max_blocks_per_seq: match meta {
1236 Some(m) => m.max_blocks_per_seq as usize,
1237 None => 0,
1238 },
1239 block_size,
1240 cache_stride_bytes: (block_size * self.cfg.kv_lora_rank) as u64,
1241 };
1242 if replay_safe {
1243 // S is exactly the `seq_len + 1` the attention metadata already holds, which is
1244 // `st.len()` after the indexer advance. Nothing about the pass is host-decided.
1245 KernelLaunch::new(gpu, self.select_kernels.write_geom)
1246 .grid([1, 1, 1])
1247 .block([1, 1, 1])
1248 .arg_ptr(d_sl)
1249 .arg_ptr(w.geom_dev)
1250 .arg_u32(self.cfg.index_kpool as u32)
1251 .arg_u32(self.cfg.index_topk as u32)
1252 .arg_u32(super::select::topk_tile() as u32)
1253 .launch(stream)?;
1254 }
1255 if batch_select {
1256 // `indexer_forward` left THIS row's head weights in the scalar slot; stash
1257 // them at row stride so the one batched pass below can read `weights[r*H]`.
1258 gpu.copy_d2d_async(
1259 w.head_weights,
1260 w.head_weights_rows.offset(row * self.cfg.index_heads * 4),
1261 self.cfg.index_heads * 4,
1262 stream,
1263 )?;
1264 batch_q_pos.push(pos as i32);
1265 } else {
1266 self.select_row(gpu, row, st, q_pos_dev, replay_safe, stream)?;
1267 }
1268 // The attend needs the BASE of the K-row metadata, not this row's slice: it
1269 // carries the row axis on `gridDim.y`. On the no-metadata path k is 1 and these
1270 // are the single-row `w.bt`/`w.sl`, so the base IS the row.
1271 if row == 0 {
1272 attend_bt = d_bt;
1273 attend_sl = d_sl;
1274 attend_paging = Some(paging);
1275 owns_scratch = owns_bt && !self.persist_bt;
1276 }
1277 }
1278
1279 // ── ONE selection pass for all K rows (default; off under ...SELECT_ROWS=0) ──
1280 // AFTER the row loop, so every row's indexer write is already in the cache. That
1281 // ordering is what makes the hoist exact rather than merely cheaper: row `r` gates
1282 // on `end_c <= q_pos[r]`, so rows appended after it stay invisible to it, and the
1283 // pools this pass compresses over are the group's final set.
1284 if batch_select && !batch_q_pos.is_empty() {
1285 self.select_rows_batched(gpu, k, st, &batch_q_pos, stream)?;
1286 }
1287
1288 // ── ONE gather-attend for all K rows ──
1289 if let Some(paging) = attend_paging {
1290 self.attend_rows(gpu, k, st, kv_cache, attend_bt, attend_sl, &paging, stream)?;
1291 }
1292 // 🔴 ANOMALIES A65: freed HERE, not in the row loop. The attend above reads both
1293 // buffers, so freeing them per row handed it memory that had already been released.
1294 if owns_scratch {
1295 gpu.free(attend_bt)?;
1296 gpu.free(attend_sl)?;
1297 }
1298
1299 // ── output projection, row-parallel: the caller all-reduces ──
1300 let t_proj = profile::start();
1301 gemm(
1302 gpu,
1303 self.kernels.gemm,
1304 self.kernels.gemv,
1305 self.kernels.gemv_batchm,
1306 w.attn_out,
1307 self.weights.o_absorb,
1308 hidden,
1309 k,
1310 self.cfg.hidden,
1311 self.cfg.local_heads * self.cfg.kv_lora_rank,
1312 stream,
1313 )?;
1314 profile::end(profile::DSA_PROJ, t_proj, gpu, stream);
1315 Ok(())
1316 }
1317}
1318
1319impl TransformerLayer for Glm5NextDsaLayer {
1320 fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn LayerState>> {
1321 Ok(Box::new(Glm5NextDsaState::alloc(gpu, &self.cfg)?))
1322 }
1323
1324 /// Release what `alloc_state` allocated — ANOMALIES A76. Reached by the non-composite
1325 /// paths that hold a bare `Glm5NextDsaLayer`; the composite `Glm5NextLayer` has its
1326 /// own, identical, override. Type-driven so a non-DSA state can never be freed here.
1327 fn release_state(&self, state: &mut dyn LayerState, gpu: &dyn GpuBackend) -> Result<()> {
1328 if let Some(dsa) = state.as_any_mut().downcast_mut::<Glm5NextDsaState>() {
1329 dsa.free(gpu)?;
1330 }
1331 Ok(())
1332 }
1333
1334 /// The replay's writes end at `seq_len + k`; the buffer ends at `capacity`. A62.
1335 fn check_replay_room(&self, state: &dyn LayerState, seq_len: usize, k: usize) -> Result<()> {
1336 state
1337 .as_any()
1338 .downcast_ref::<Glm5NextDsaState>()
1339 .ok_or_else(|| {
1340 anyhow::anyhow!("Glm5NextDsaLayer got a state that is not Glm5NextDsaState")
1341 })?
1342 .ensure_room_through(seq_len + k)
1343 .with_context(|| {
1344 format!("DSA replay pre-check (before launch_graph, seq_len {seq_len} + k {k})")
1345 })
1346 }
1347
1348 /// The indexer cache length is the one thing this layer keeps on the host. A replayed
1349 /// graph writes the next row (the store kernel reads its position from device memory)
1350 /// but never calls `decode`, so the counter has to be advanced here or the NEXT eager
1351 /// step plans its selection over a stale length — and `decode`'s own lockstep check
1352 /// would fire.
1353 fn sync_replayed_step(
1354 &self,
1355 state: &mut dyn LayerState,
1356 seq_len: usize,
1357 k: usize,
1358 ) -> Result<()> {
1359 state
1360 .as_any_mut()
1361 .downcast_mut::<Glm5NextDsaState>()
1362 .ok_or_else(|| {
1363 anyhow::anyhow!("Glm5NextDsaLayer got a state that is not Glm5NextDsaState")
1364 })?
1365 .sync_to(seq_len, k)
1366 }
1367
1368 #[allow(clippy::too_many_arguments)]
1369 fn decode(
1370 &self,
1371 hidden: DevicePtr,
1372 _residual: DevicePtr,
1373 state: &mut dyn LayerState,
1374 kv_cache: &mut PagedKvCache,
1375 seq_len: usize,
1376 block_table: &mut Vec<u32>,
1377 _disk_block_ids: &mut Vec<u32>,
1378 _disk_last_offloaded_per_layer: &mut Vec<u32>,
1379 ctx: &ForwardContext,
1380 stream: u64,
1381 ) -> Result<()> {
1382 self.decode_k(
1383 hidden,
1384 1,
1385 state,
1386 kv_cache,
1387 seq_len,
1388 block_table,
1389 ctx,
1390 stream,
1391 // A single-token decode, never a prefill sub-chunk. Moot at k == 1, stated anyway.
1392 false,
1393 )
1394 }
1395}
1396
1397#[cfg(test)]
1398mod tests;
1399
1400/// Block-table entries the paged gather can index for `k` query rows starting at
1401/// `seq_len`, plus one entry of slack.
1402///
1403/// The gather reads `block_table[pos / block_size]` for `pos` in `[0, seq_len + k)`,
1404/// so the highest index touched is `(seq_len + k - 1) / block_size`. Everything above
1405/// that is never read — see the A58 note at the upload site for why uploading it
1406/// anyway was a silent kill switch for the GLM drafter.
1407pub(super) fn bt_entries_needed(seq_len: usize, k: usize, block_size: usize) -> usize {
1408 (seq_len + k) / block_size.max(1) + 2
1409}
1410
1411#[cfg(test)]
1412mod bt_trim_tests {
1413 use super::bt_entries_needed;
1414
1415 /// The A58 reproducer, in arithmetic: the GLM drafter pre-claims
1416 /// `max_seq_len / 16 + 2` entries, so at `--max-seq-len 262144` it hands 16,386
1417 /// against a `bt_cap` of 16,384 — for a 20-token sequence. Trimmed, it needs 3.
1418 #[test]
1419 fn a58_short_sequence_at_262k_declared_context() {
1420 let pool = 262_144 / 16 + 2;
1421 assert_eq!(pool, 16_386, "the pre-claimed pool that overran bt_cap");
1422 assert!(pool > 16_384, "and it is over the persistent buffer");
1423 assert_eq!(bt_entries_needed(20, 3, 16), 3);
1424 assert!(bt_entries_needed(20, 3, 16) <= 16_384);
1425 }
1426
1427 /// Every position the gather can touch must be inside the trim.
1428 #[test]
1429 fn trim_covers_every_indexable_position() {
1430 for &(seq_len, k, bs) in &[
1431 (0usize, 1usize, 16usize),
1432 (1, 1, 16),
1433 (15, 1, 16),
1434 (16, 1, 16),
1435 (17, 4, 16),
1436 (4095, 4, 16),
1437 (131_072, 3, 16),
1438 (262_143, 4, 16),
1439 (1000, 1, 64),
1440 ] {
1441 let n = bt_entries_needed(seq_len, k, bs);
1442 let highest = (seq_len + k).saturating_sub(1) / bs;
1443 assert!(
1444 highest < n,
1445 "seq_len={seq_len} k={k} bs={bs}: highest index {highest} not < {n}"
1446 );
1447 }
1448 }
1449
1450 /// The trim must stay far under the persistent buffer for any sequence DSA can
1451 /// actually select over (`max_dsa_context` = 16,384 tokens).
1452 #[test]
1453 fn trim_fits_the_persistent_buffer_across_the_dsa_window() {
1454 assert!(bt_entries_needed(16_384, 4, 16) <= 16_384);
1455 }
1456
1457 /// block_size 0 must not divide by zero.
1458 #[test]
1459 fn zero_block_size_does_not_panic() {
1460 assert_eq!(bt_entries_needed(8, 1, 0), 11);
1461 }
1462}