spark_model/layers/glm5next_dsa/
aux_state.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Marconi aux state for the DSA indexer — the half of a GLM-5.3 sequence that
4//! a KV-only prefix-cache hit cannot reconstruct.
5//!
6//! # Why a blob and not a rewind
7//!
8//! [`Glm5NextDsaState`] is append-only with a host cursor, so `rewind_to` is an
9//! exact restore *within a live sequence*: the rows are still there and only the
10//! cursor moved. A prefix-cache hit is not that. It hands the prefix to a
11//! sequence whose indexer buffer was never written, so there is nothing to
12//! rewind to — and the rows cannot be recomputed either, because `k_normed` and
13//! `gate` are projections of the *hidden* state (`indexer.wk`,
14//! `index_kpool_compress_gate`) and `wk · hidden` cannot be inverted out of the
15//! rank-512 MLA latent (see the module doc on [`super::state`]). The rows have
16//! to travel with the snapshot.
17//!
18//! # The precedent this copies
19//!
20//! `layers/qsa_snapshot.rs` does exactly this for qwen4_exp's QSA indexer:
21//! serialize the ingested keys plus the cursor, and on restore re-derive by
22//! kernel whatever is cheaper to recompute than to carry. DSA has nothing in
23//! the second category — pooling happens inside the selector kernel each step
24//! and is never stored — so the blob is the whole reachable cache and nothing
25//! else.
26//!
27//! # 🔴 Fail-closed, deliberately
28//!
29//! Every rejection here is a hard `Err`, never a skip. A DSA restore that
30//! silently applied a *stale but correctly sized* blob would select over another
31//! sequence's keys and return a wrong answer under HTTP 200 — the A25/A55 shape.
32//! The size and geometry checks below are what make a mismatched blob
33//! unrepresentable rather than merely unlikely.
34//!
35//! # Cost
36//!
37//! `len * (4 * index_head_dim + 1)` bytes per DSA layer — 513 B/token/layer at
38//! GLM-5.3's `index_head_dim = 128`, 5,643 B/token across its 11 text DSA
39//! layers. The blob carries `len` rows, NOT `capacity`: a 4K prefix costs 4K
40//! rows even on a serve declaring `--max-seq-len 131072`.
41
42use anyhow::{Result, ensure};
43use spark_runtime::gpu::GpuBackend;
44
45use super::state::Glm5NextDsaState;
46
47/// `[len u64][index_head_dim u64]`, little-endian.
48const HEADER_BYTES: usize = 16;
49
50/// Bytes a blob occupies for `len` rows at `index_head_dim`.
51///
52/// Mirrors [`super::state::indexer_state_bytes`] but over `len`, not
53/// `capacity` — the snapshot carries what was written, not what was reserved.
54fn blob_bytes(len: usize, index_head_dim: usize) -> usize {
55    HEADER_BYTES + len * (index_head_dim * 4 + 1)
56}
57
58impl Glm5NextDsaState {
59    /// Serialize the reachable indexer cache `[0, len)` for a Marconi snapshot.
60    ///
61    /// Only `len` rows are copied. The rows in `[len, capacity)` are either
62    /// never written or already unreachable (`rewind_to` leaves stale rows
63    /// behind on purpose), so carrying them would be both wasteful and a way to
64    /// resurrect state the sequence has disowned.
65    pub fn snapshot_blob(&self, gpu: &dyn GpuBackend, stream: u64) -> Result<Vec<u8>> {
66        let len = self.len();
67        let d = self.index_head_dim();
68        let key_bytes = len * d * 2;
69
70        let mut blob = vec![0u8; blob_bytes(len, d)];
71        blob[..8].copy_from_slice(&(len as u64).to_le_bytes());
72        blob[8..16].copy_from_slice(&(d as u64).to_le_bytes());
73
74        if len > 0 {
75            let (k_off, g_off, v_off) = self.blob_offsets(len, d);
76            gpu.copy_d2h_on_stream(self.k_normed, &mut blob[k_off..k_off + key_bytes], stream)?;
77            gpu.copy_d2h_on_stream(self.gate, &mut blob[g_off..g_off + key_bytes], stream)?;
78            gpu.copy_d2h_on_stream(self.valid, &mut blob[v_off..v_off + len], stream)?;
79        }
80        Ok(blob)
81    }
82
83    /// Restore a snapshot's indexer rows into this (freshly allocated) state.
84    ///
85    /// 🔴 Refuses rather than repairs. A blob that is truncated, geometrically
86    /// different, internally inconsistent, or longer than this sequence's
87    /// reservation is an `Err`; the caller (`apply_aux_states`) propagates it
88    /// with `?` and the prefix-cache hit fails loudly instead of serving from a
89    /// half-written indexer.
90    pub fn restore_blob(&mut self, blob: &[u8], gpu: &dyn GpuBackend, stream: u64) -> Result<()> {
91        ensure!(
92            blob.len() >= HEADER_BYTES,
93            "DSA aux blob truncated: {} bytes, need at least {HEADER_BYTES} for the header",
94            blob.len()
95        );
96        let len = u64::from_le_bytes(blob[..8].try_into().unwrap()) as usize;
97        let d = u64::from_le_bytes(blob[8..16].try_into().unwrap()) as usize;
98
99        let want_d = self.index_head_dim();
100        ensure!(
101            d == want_d,
102            "DSA aux blob index_head_dim {d} != this layer's {want_d} — the snapshot was \
103             taken under a different model geometry"
104        );
105        ensure!(
106            blob.len() == blob_bytes(len, d),
107            "DSA aux blob size mismatch: {} bytes for {len} rows at head_dim {d}, expected {}",
108            blob.len(),
109            blob_bytes(len, d)
110        );
111        // Capacity is `--max-seq-len`-derived and can legitimately differ between
112        // the serve that wrote the snapshot and the one restoring it (a tiered or
113        // spilled blob outlives a process). A shorter reservation is a refusal,
114        // not a truncation: the rows past `capacity` have nowhere to land and a
115        // clamped `len` would select over a prefix while MLA held the full
116        // context — the wrong answer `advance` already refuses to produce.
117        self.ensure_room_through(len)?;
118
119        let key_bytes = len * d * 2;
120        if len > 0 {
121            let (k_off, g_off, v_off) = self.blob_offsets(len, d);
122            gpu.copy_h2d_async(&blob[k_off..k_off + key_bytes], self.k_normed, stream)?;
123            gpu.copy_h2d_async(&blob[g_off..g_off + key_bytes], self.gate, stream)?;
124            gpu.copy_h2d_async(&blob[v_off..v_off + len], self.valid, stream)?;
125        }
126        // Cursor last, and through the existing guarded pair rather than a new
127        // setter: an early-returning `?` above must leave the state at the
128        // length it already had, so a failed restore cannot look like a partial
129        // success to `decode_k`'s lockstep check.
130        self.rewind_to(0)?;
131        self.advance(len)?;
132        Ok(())
133    }
134
135    /// `(k_normed, gate, valid)` byte offsets within a blob of `len` rows.
136    fn blob_offsets(&self, len: usize, d: usize) -> (usize, usize, usize) {
137        let key_bytes = len * d * 2;
138        (
139            HEADER_BYTES,
140            HEADER_BYTES + key_bytes,
141            HEADER_BYTES + 2 * key_bytes,
142        )
143    }
144}
145
146#[cfg(test)]
147mod tests;