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}