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;