metrale_model_arch/glm5next_dsa/
aux_state.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Snapshot and restore of the DSA indexer cache, the part of a GLM-5.3
4//! sequence that a KV-only prefix-cache hit cannot rebuild.
5//!
6//! Owner: model-arch (GLM-5.3 DSA).
7//! Invariants:
8//! - `restore_blob` checks the header, `index_head_dim`, total size and
9//!   capacity before any copy, and returns an error for any mismatch.
10//! - The cursor moves only after every copy is enqueued, so a failed restore
11//!   leaves `len` unchanged.
12//! - A blob is `HEADER_BYTES + len * (4 * index_head_dim + 1)` bytes: it
13//!   carries `len` rows, not `capacity`.
14//!
15//! # Why a blob and not a rewind
16//!
17//! `rewind_to` restores within a live sequence: the rows stay and only the
18//! cursor moves. A prefix-cache hit hands the prefix to a sequence whose
19//! indexer buffer was never written, and the rows cannot be rebuilt from the
20//! MLA latent: `k_normed` and `gate` are projections of the hidden state
21//! (`indexer.wk`, `index_kpool_compress_gate`). So the rows travel with the
22//! snapshot.
23//!
24//! `layers/qsa_snapshot.rs` in model-layers does the same for the QSA indexer,
25//! and re-pools its block keys on restore. The DSA state stores no pooled keys
26//! (pooling runs inside the selector kernel each step), so the blob is the
27//! whole reachable cache.
28//!
29//! Applying a mismatched blob would make the selector read another sequence's
30//! keys, which is why every mismatch is an error rather than a repair.
31//!
32//! # Cost
33//!
34//! `len * (4 * index_head_dim + 1)` bytes per DSA layer, 513 B per token and
35//! layer at `index_head_dim = 128`.
36
37use anyhow::{Result, ensure};
38use metrale_gpu_runtime::gpu::GpuBackend;
39
40use super::state::Glm5NextDsaState;
41
42/// 2026-09-25: `[len u64][index_head_dim u64]`, little-endian.
43const HEADER_BYTES: usize = 16;
44
45/// 2026-09-25: Bytes a blob occupies for `len` rows at `index_head_dim`: the
46/// per-row size of [`super::state::indexer_state_bytes`], over `len` rows, plus
47/// the header.
48fn blob_bytes(len: usize, index_head_dim: usize) -> usize {
49    HEADER_BYTES + len * (index_head_dim * 4 + 1)
50}
51
52impl Glm5NextDsaState {
53    /// 2026-09-25: Serialize the reachable indexer rows `[0, len)` for a
54    /// prefix-cache snapshot. Rows past `len` were never written or are
55    /// unreachable (`rewind_to` leaves them in place).
56    pub fn snapshot_blob(&self, gpu: &dyn GpuBackend, stream: u64) -> Result<Vec<u8>> {
57        let len = self.len();
58        let d = self.index_head_dim();
59        let key_bytes = len * d * 2;
60
61        let mut blob = vec![0u8; blob_bytes(len, d)];
62        blob[..8].copy_from_slice(&(len as u64).to_le_bytes());
63        blob[8..16].copy_from_slice(&(d as u64).to_le_bytes());
64
65        if len > 0 {
66            let (k_off, g_off, v_off) = self.blob_offsets(len, d);
67            gpu.copy_d2h_on_stream(self.k_normed, &mut blob[k_off..k_off + key_bytes], stream)?;
68            gpu.copy_d2h_on_stream(self.gate, &mut blob[g_off..g_off + key_bytes], stream)?;
69            gpu.copy_d2h_on_stream(self.valid, &mut blob[v_off..v_off + len], stream)?;
70        }
71        Ok(blob)
72    }
73
74    /// 2026-09-25: Restore a snapshot's indexer rows into this state.
75    ///
76    /// A blob that is truncated, of another `index_head_dim`, inconsistent with
77    /// its header, or longer than this sequence's reservation is an `Err`;
78    /// model-engine's `apply_aux_states` propagates it with `?`, so the
79    /// prefix-cache hit fails.
80    pub fn restore_blob(&mut self, blob: &[u8], gpu: &dyn GpuBackend, stream: u64) -> Result<()> {
81        ensure!(
82            blob.len() >= HEADER_BYTES,
83            "DSA aux blob truncated: {} bytes, need at least {HEADER_BYTES} for the header",
84            blob.len()
85        );
86        let len = u64::from_le_bytes(blob[..8].try_into().unwrap()) as usize;
87        let d = u64::from_le_bytes(blob[8..16].try_into().unwrap()) as usize;
88
89        let want_d = self.index_head_dim();
90        ensure!(
91            d == want_d,
92            "DSA aux blob index_head_dim {d} != this layer's {want_d} — the snapshot was \
93             taken under a different model geometry"
94        );
95        ensure!(
96            blob.len() == blob_bytes(len, d),
97            "DSA aux blob size mismatch: {} bytes for {len} rows at head_dim {d}, expected {}",
98            blob.len(),
99            blob_bytes(len, d)
100        );
101        // 2026-09-25: Capacity comes from the configured context, so it can
102        // differ from the state that took the snapshot. A longer blob is refused,
103        // not truncated: a clamped `len` would select over a prefix while the MLA
104        // cache holds the full context.
105        self.ensure_room_through(len)?;
106
107        let key_bytes = len * d * 2;
108        if len > 0 {
109            let (k_off, g_off, v_off) = self.blob_offsets(len, d);
110            gpu.copy_h2d_async(&blob[k_off..k_off + key_bytes], self.k_normed, stream)?;
111            gpu.copy_h2d_async(&blob[g_off..g_off + key_bytes], self.gate, stream)?;
112            gpu.copy_h2d_async(&blob[v_off..v_off + len], self.valid, stream)?;
113        }
114        // 2026-09-25: The cursor moves last, through `rewind_to` and `advance`,
115        // so an early `?` above leaves `len` unchanged and `decode_k`'s lockstep
116        // check never sees a partial restore.
117        self.rewind_to(0)?;
118        self.advance(len)?;
119        Ok(())
120    }
121
122    /// 2026-09-25: `(k_normed, gate, valid)` byte offsets within a blob of `len`
123    /// rows.
124    fn blob_offsets(&self, len: usize, d: usize) -> (usize, usize, usize) {
125        let key_bytes = len * d * 2;
126        (
127            HEADER_BYTES,
128            HEADER_BYTES + key_bytes,
129            HEADER_BYTES + 2 * key_bytes,
130        )
131    }
132}
133
134#[cfg(test)]
135mod tests;