metrale_model_arch/glm5next_dsa/layer/decode_k.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: `Glm5NextDsaLayer::decode_k`, `k` consecutive tokens of one sequence: the
4//! shared projections, then per row the latent write, the indexer write and the selection,
5//! then one gather-attend and the output projection.
6//!
7//! Owner: model-arch (GLM-5.3 DSA).
8//! Invariants:
9//! - The lockstep check (`check_lockstep`) and the `k` and metadata checks run before the
10//! first launch.
11
12use anyhow::{Result, bail};
13use metrale_cache::kv_cache::PagedKvCache;
14use metrale_gpu_runtime::gpu::DevicePtr;
15use metrale_gpu_runtime::kernel_args::KernelLaunch;
16use metrale_model_layers::layer::{ForwardContext, LayerState};
17
18use super::super::attend::DsaDecodePaging;
19use super::super::state::Glm5NextDsaState;
20use super::{Glm5NextDsaLayer, batch_select_enabled, gemm};
21
22impl Glm5NextDsaLayer {
23 /// 2026-09-25: `k` consecutive tokens of one sequence, from position `seq_len`.
24 ///
25 /// `q_a_proj`, `q_absorb`, `kv_a_proj` and the `o_absorb` output projection each run once
26 /// for all `k` rows. Per row, in order: the latent write to the row's paged slot, the
27 /// indexer write, and (unless the batched selector runs) that row's selection. After the
28 /// rows: the batched selection when `batch_select_enabled` holds, then one `attend_rows`
29 /// launch.
30 ///
31 /// Row `r`'s position, KV slot, `seq_len` and block table come from element `r` of
32 /// `ctx.attn_metadata` on a decode step, or when `k > 1` and the metadata has exactly `k`
33 /// rows; otherwise they are computed here and copied to the device. On a decode step with
34 /// `k > 1`, metadata with another row count is an error.
35 #[allow(clippy::too_many_arguments)]
36 pub fn decode_k(
37 &self,
38 hidden: DevicePtr,
39 k: usize,
40 state: &mut dyn LayerState,
41 kv_cache: &mut PagedKvCache,
42 seq_len: usize,
43 block_table: &mut Vec<u32>,
44 ctx: &ForwardContext,
45 stream: u64,
46 // 2026-09-25: True only for a prefill sub-chunk, as `Glm5NextLayer::forward_k` passes
47 // it; see `batch_select_enabled`.
48 is_prefill: bool,
49 ) -> Result<()> {
50 use crate::glm5next_layer::profile;
51 let bt_block_size = kv_cache.block_size().max(1);
52 let st = state
53 .as_any_mut()
54 .downcast_mut::<Glm5NextDsaState>()
55 .ok_or_else(|| {
56 anyhow::anyhow!("Glm5NextDsaLayer got a state that is not Glm5NextDsaState")
57 })?;
58 self.check_lockstep(st, seq_len)?;
59 if k == 0 || k > self.workspace.max_rows {
60 bail!(
61 "DSA layer {}: a {k}-token verify does not fit a workspace built for {}",
62 self.layer_idx,
63 self.workspace.max_rows
64 );
65 }
66 if k > 1
67 && let Some(m) = ctx.attn_metadata.as_ref()
68 && m.num_seqs as usize != k
69 && ctx.decode_step
70 {
71 bail!(
72 "DSA layer {}: a {k}-row pass cannot share attn_metadata describing {} \
73 token(s) — its position and KV slot describe a single token",
74 self.layer_idx,
75 m.num_seqs
76 );
77 }
78 let rowwise_meta = (k > 1)
79 .then_some(ctx.attn_metadata.as_ref())
80 .flatten()
81 .filter(|m| m.num_seqs as usize == k);
82 let gpu = ctx.gpu;
83 let w = &self.workspace;
84 let t_proj = crate::glm5next_layer::profile::start();
85
86 gemm(
87 gpu,
88 self.kernels.gemm,
89 self.kernels.gemv,
90 self.kernels.gemv_batchm,
91 hidden,
92 self.weights.q_a_proj,
93 w.q_a,
94 k,
95 self.cfg.q_lora_rank,
96 self.cfg.hidden,
97 stream,
98 )?;
99 KernelLaunch::new(gpu, self.kernels.rms_norm)
100 // 2026-09-25: `rms_norm_vanilla` runs one block per row, so one launch covers all
101 // k rows.
102 .grid([k as u32, 1, 1])
103 .block([256, 1, 1])
104 .arg_ptr(w.q_a)
105 .arg_ptr(self.weights.q_a_layernorm)
106 .arg_ptr(w.q_resid)
107 .arg_u32(self.cfg.q_lora_rank as u32)
108 .arg_f32(self.rms_eps)
109 .launch(stream)?;
110 gemm(
111 gpu,
112 self.kernels.gemm,
113 self.kernels.gemv,
114 self.kernels.gemv_batchm,
115 w.q_resid,
116 self.weights.q_absorb,
117 w.q_abs,
118 k,
119 self.cfg.local_heads * self.cfg.kv_lora_rank,
120 self.cfg.q_lora_rank,
121 stream,
122 )?;
123
124 gemm(
125 gpu,
126 self.kernels.gemm,
127 self.kernels.gemv,
128 self.kernels.gemv_batchm,
129 hidden,
130 self.weights.kv_a_proj,
131 w.kv_a,
132 k,
133 self.cfg.kv_lora_rank,
134 self.cfg.hidden,
135 stream,
136 )?;
137 let mut attend_bt = DevicePtr::NULL;
138 let mut attend_sl = DevicePtr::NULL;
139 // 2026-09-25: On the host path without `persist_bt`, row 0 allocates the shared bt/sl
140 // buffers; they are freed after the attend, which reads them.
141 let mut owns_scratch = false;
142 let mut attend_paging: Option<DsaDecodePaging> = None;
143 // 2026-09-25: `workspace_ready` is whether the batched-selector buffers were
144 // allocated (they are NULL under `METRALE_DSA_SELECT_ROWS=0`).
145 let batch_select =
146 batch_select_enabled(w.q_idx_rows.0 != 0, is_prefill, ctx.graph_capture, k);
147 let mut batch_q_pos: Vec<i32> = Vec::with_capacity(if batch_select { k } else { 0 });
148 for row in 0..k {
149 let pos = seq_len + row;
150 let block_size = kv_cache.config().block_size;
151 // 2026-09-25: With `meta`, the row's position, KV slot, `seq_len` and block table
152 // are read from device arrays and nothing is copied from the host; without it they
153 // are computed here and copied host-to-device.
154 let meta = if ctx.decode_step {
155 ctx.attn_metadata.as_ref()
156 } else {
157 rowwise_meta
158 };
159 let bt_stride = meta.map_or(0, |m| m.max_blocks_per_seq as usize) * 4;
160 let slot_dev = match meta {
161 Some(m) => m.slot.offset(row * 8),
162 None => {
163 let logical = pos / block_size;
164 let physical = *block_table.get(logical).ok_or_else(|| {
165 anyhow::anyhow!(
166 "DSA layer {}: block table has {} entries, needs logical block \
167 {logical} for position {pos}",
168 self.layer_idx,
169 block_table.len()
170 )
171 })? as usize;
172 let slot = (physical * block_size + pos % block_size) as i64;
173 gpu.copy_h2d(&slot.to_le_bytes(), w.slot)?;
174 w.slot
175 }
176 };
177 KernelLaunch::new(gpu, self.kernels.latent_write)
178 .grid([1, 1, 1])
179 .block([self.cfg.kv_lora_rank as u32, 1, 1])
180 .arg_ptr(w.kv_a.offset(row * self.cfg.kv_lora_rank * 2))
181 .arg_ptr(self.weights.kv_a_layernorm)
182 .arg_ptr(kv_cache.k_pool_ptr(self.attn_layer_idx))
183 .arg_ptr(slot_dev)
184 .arg_u32(self.cfg.kv_lora_rank as u32)
185 .arg_f32(self.rms_eps)
186 .arg_f32(1.0 / self.kv_scale)
187 .launch(stream)?;
188
189 use crate::glm5next_layer::profile;
190 profile::end(profile::DSA_PROJ, t_proj, gpu, stream);
191 let t = profile::start();
192 // 2026-09-25: Replay-safe placement (device-side position and geometry) only while
193 // capturing a graph, with metadata, and with both `dsa_indexer_store` and
194 // `dsa_write_geom` resolved; otherwise the host-offset path.
195 let replay_safe = ctx.graph_capture
196 && meta.is_some()
197 && self.select_kernels.indexer_store.0 != 0
198 && self.select_kernels.write_geom.0 != 0;
199 let pos_dev = if replay_safe {
200 meta.map(|m| m.positions.offset(row * 4))
201 } else {
202 None
203 };
204 self.indexer_forward(
205 gpu,
206 hidden.offset(row * self.cfg.hidden * 2),
207 st,
208 pos_dev,
209 stream,
210 )?;
211 profile::end(profile::DSA_INDEXER, t, gpu, stream);
212
213 let (q_pos_dev, bt_dev_meta, sl_dev_meta) = match meta {
214 Some(m) => (
215 m.positions.offset(row * 4),
216 Some(m.block_table.offset(row * bt_stride)),
217 Some(m.seq_len.offset(row * 4)),
218 ),
219 None => {
220 let qp = pos as i32;
221 gpu.copy_h2d(&qp.to_le_bytes(), w.q_pos)?;
222 (w.q_pos, None, None)
223 }
224 };
225 let (d_bt, d_sl) = match (bt_dev_meta, sl_dev_meta) {
226 // 2026-09-25: The metadata already holds both; nothing to copy.
227 (Some(b), Some(l)) => (b, l),
228 _ => {
229 // 2026-09-25: Upload only the prefix the gather can index
230 // ([`bt_entries_needed`]), not the caller's whole table.
231 let bt_used = {
232 let needed = bt_entries_needed(seq_len, k, bt_block_size);
233 &block_table[..needed.min(block_table.len())]
234 };
235 let bt: Vec<u8> = bt_used.iter().flat_map(|b| b.to_le_bytes()).collect();
236 if bt_used.len() > w.bt_cap {
237 anyhow::bail!(
238 "DSA layer {}: block table needs {} entries for seq_len {} + {} rows \
239 but the persistent buffer holds {}. This is a BLOCK count against a buffer \
240 sized by max_dsa_context (a TOKEN count); do not write past the allocation.",
241 self.layer_idx,
242 bt_used.len(),
243 seq_len,
244 k,
245 w.bt_cap
246 );
247 }
248 // 2026-09-25: `attend_rows` reads these buffers after the row loop, so they
249 // outlive the row that wrote them. Each row writes its own `sl[row]`. The
250 // block table is the same for every row (the rows are one sequence, and
251 // `bt_entries_needed` does not depend on `row`), and it is read with a row
252 // stride of 0 (`max_blocks_per_seq` below).
253 let (d_bt, d_sl) = if self.persist_bt {
254 (w.bt, w.sl)
255 } else if row == 0 {
256 (gpu.alloc(bt.len().max(4))?, gpu.alloc(k * 4)?)
257 } else {
258 // 2026-09-25: Row 0 allocated these; later rows write their own `sl`
259 // slot into them.
260 (attend_bt, attend_sl)
261 };
262 gpu.copy_h2d(&bt, d_bt)?;
263 gpu.copy_h2d(&((pos + 1) as i32).to_le_bytes(), d_sl.offset(row * 4))?;
264 (d_bt, d_sl)
265 }
266 };
267 let owns_bt = bt_dev_meta.is_none();
268
269 let paging = DsaDecodePaging {
270 num_seqs: 1,
271 num_q_heads: self.cfg.local_heads,
272 num_kv_heads: 1,
273 // 2026-09-25: The kernel's block-table row stride, and a kernel argument that a
274 // captured graph fixes. With metadata it is the metadata's
275 // `max_blocks_per_seq`, the stride of its per-row tables; without, all rows
276 // share the one table uploaded above, so the stride is 0.
277 max_blocks_per_seq: match meta {
278 Some(m) => m.max_blocks_per_seq as usize,
279 None => 0,
280 },
281 block_size,
282 cache_stride_bytes: (block_size * self.cfg.kv_lora_rank) as u64,
283 };
284 if replay_safe {
285 // 2026-09-25: `dsa_write_geom` reads S from `d_sl`, this row's `seq_len` entry
286 // in the metadata.
287 KernelLaunch::new(gpu, self.select_kernels.write_geom)
288 .grid([1, 1, 1])
289 .block([1, 1, 1])
290 .arg_ptr(d_sl)
291 .arg_ptr(w.geom_dev)
292 .arg_u32(self.cfg.index_kpool as u32)
293 .arg_u32(self.cfg.index_topk as u32)
294 .arg_u32(super::super::select::topk_tile() as u32)
295 .launch(stream)?;
296 }
297 if batch_select {
298 // 2026-09-25: `indexer_forward` left this row's head weights in the single-row
299 // slot; copy them to row `row` for the batched pass, which reads `weights[r*H]`.
300 gpu.copy_d2d_async(
301 w.head_weights,
302 w.head_weights_rows.offset(row * self.cfg.index_heads * 4),
303 self.cfg.index_heads * 4,
304 stream,
305 )?;
306 batch_q_pos.push(pos as i32);
307 } else {
308 self.select_row(gpu, row, st, q_pos_dev, replay_safe, stream)?;
309 }
310 // 2026-09-25: The attend takes row 0's pointers, the base of the per-row arrays,
311 // and indexes rows itself on grid y.
312 if row == 0 {
313 attend_bt = d_bt;
314 attend_sl = d_sl;
315 attend_paging = Some(paging);
316 owns_scratch = owns_bt && !self.persist_bt;
317 }
318 }
319
320 // 2026-09-25: The batched selection runs after the row loop, so every row's indexer
321 // write is in the cache. Row `r` only takes pools that end at or before `q_pos[r]`,
322 // so the rows written after it do not change its selection.
323 if batch_select && !batch_q_pos.is_empty() {
324 self.select_rows_batched(gpu, k, st, &batch_q_pos, stream)?;
325 }
326
327 if let Some(paging) = attend_paging {
328 self.attend_rows(gpu, k, st, kv_cache, attend_bt, attend_sl, &paging, stream)?;
329 }
330 // 2026-09-25: Freed after the attend, which reads both buffers.
331 if owns_scratch {
332 gpu.free(attend_bt)?;
333 gpu.free(attend_sl)?;
334 }
335
336 let t_proj = profile::start();
337 gemm(
338 gpu,
339 self.kernels.gemm,
340 self.kernels.gemv,
341 self.kernels.gemv_batchm,
342 w.attn_out,
343 self.weights.o_absorb,
344 hidden,
345 k,
346 self.cfg.hidden,
347 self.cfg.local_heads * self.cfg.kv_lora_rank,
348 stream,
349 )?;
350 profile::end(profile::DSA_PROJ, t_proj, gpu, stream);
351 Ok(())
352 }
353}
354
355/// 2026-09-25: Block-table entries to upload for `k` query rows starting at `seq_len`.
356///
357/// Row `r` attends tokens below its `seq_len` of `seq_len + r + 1`, so the gather reads
358/// `block_table[t / block_size]` only for `t < seq_len + k`: at most index
359/// `(seq_len + k - 1) / block_size`. This returns at least one entry more. A `block_size`
360/// of 0 is treated as 1.
361pub(super) fn bt_entries_needed(seq_len: usize, k: usize, block_size: usize) -> usize {
362 (seq_len + k) / block_size.max(1) + 2
363}