spark_model/layers/glm5next_dsa/
state.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Per-sequence DSA indexer state — the cache the selector reads every decode step.
4//!
5//! # Why this exists at all
6//!
7//! The indexer scores each pool from `k_normed` and `gate`, both projections of the
8//! *hidden* state (`indexer.wk`, `index_kpool_compress_gate`). Neither is recoverable
9//! from the MLA latent — `wk · hidden` cannot be inverted out of a rank-512 compression —
10//! so the indexer needs its own cache stream alongside the KV cache. HF does the same
11//! thing, keeping indexer state on a per-layer `DynamicIndexedLayer` via
12//! `past_key_values.update_indexer`.
13//!
14//! # No new subsystem
15//!
16//! `TransformerLayer::alloc_state` is called once per sequence and `LayerState` is an
17//! `Any` downcast hook — the same mechanism `qwen3_ssm` uses for recurrent state. This is
18//! that, with a bigger buffer.
19//!
20//! # 🪤 Flat, not paged
21//!
22//! `dsa_kpool_compress` and `dsa_index_scores` index `k[raw * D + d]` **linearly**, and
23//! pools are built over absolute positions from the first valid token. So this is one
24//! contiguous per-sequence buffer, not block-table paged. The MLA latent stays paged; only
25//! the indexer stream is flat.
26
27use anyhow::{Result, bail};
28use spark_runtime::gpu::{DevicePtr, GpuBackend};
29
30use super::Glm5NextDsaConfig;
31use super::select::DsaSelectGeometry;
32use crate::layer::LayerState;
33
34/// Longest context DSA can select over, in tokens.
35///
36/// 🟢 This used to be a KERNEL limit — `dsa_topk_pools` bitonic-sorted the whole padded pool
37/// axis in shared memory, capping the serve at 4,096 pools = **16,384 tokens** whatever
38/// `--max-seq-len` claimed (ANOMALIES A62). The select is tiled now, so the kernel imposes
39/// nothing and this is purely an ALLOCATION decision: how many rows of indexer cache each
40/// sequence reserves, which is `--max-seq-len` rounded down to a whole pool.
41///
42/// 🪤 It is charged per sequence per DSA layer at `2 · index_head_dim` BF16 + 1 B a token —
43/// 5,643 B/token across GLM-5.3's 11 text DSA layers, and the indexer is REPLICATED, so EP
44/// does not halve it. `Glm5NextSkeleton::state_budget` must carry the same number or the
45/// serve allocates past its own `--gpu-memory-utilization` (the A59 class of cliff).
46pub fn max_dsa_context(cfg: &Glm5NextDsaConfig) -> usize {
47    dsa_capacity(cfg.max_context, cfg.index_kpool)
48}
49
50/// THE authoritative indexer-capacity computation. Everything that needs to know how many
51/// rows a sequence's indexer cache holds calls this — [`max_dsa_context`] for the allocation
52/// side, and the serve's pre-model reserve for the budget side.
53///
54/// 🔴 It exists because there were two spellings. The allocation rounds down to whole pools;
55/// the budget (`Glm5NextTextSkeleton::state_budget`) is expressed per TOKEN, so multiplying it
56/// by a raw `--max-seq-len` charges a capacity the allocation never reserves. They agree for
57/// every `index_kpool`-multiple context — GLM-5.3 ships `index_kpool = 4`, so every power-of-two
58/// `--max-seq-len` masks it — and disagree by up to `index_kpool - 1` rows a layer otherwise.
59/// One function, so a config that does not divide evenly cannot make them drift.
60///
61/// Whole pools only: a trailing partial pool is not a pool (`contiguous_pool_count`).
62/// `index_kpool == 0` is refused by `Glm5NextDsaConfig::validate`; clamped here so this stays
63/// total for callers that have not validated yet (the reserve runs before the model exists).
64pub fn dsa_capacity(max_context: usize, index_kpool: usize) -> usize {
65    let kpool = index_kpool.max(1);
66    (max_context / kpool) * kpool
67}
68
69/// Bytes ONE sequence's indexer cache occupies for ONE DSA layer at `capacity` rows.
70///
71/// SSOT for the three allocations in [`Glm5NextDsaState::alloc`] and for the serve's
72/// per-sequence reserve. `= capacity * (4 * index_head_dim + 1)`, i.e. 513 B/token/layer at
73/// GLM-5.3's `index_head_dim = 128`. Replicated — EP does NOT halve it.
74pub fn indexer_state_bytes(capacity: usize, index_head_dim: usize) -> usize {
75    capacity * index_head_dim * 2   // k_normed, BF16
76        + capacity * index_head_dim * 2 // gate, BF16
77        + capacity // valid, u8
78}
79
80/// One sequence's indexer cache for one DSA layer.
81///
82/// Allocated once at sequence creation and never grown: [`max_dsa_context`] is a hard cap,
83/// so a fixed reservation is correct. At `index_head_dim = 128` that is `513 B` a token a
84/// layer — 8 MiB per layer (~92 MiB over the 11 text DSA layers) at a 16,384-token context,
85/// and 64 MiB per layer (~736 MiB) at 131,072.
86pub struct Glm5NextDsaState {
87    /// `[capacity, index_head_dim]` BF16 — LayerNorm'd indexer keys.
88    /// 🪤 `indexer.k_norm` is an `nn.LayerNorm` **with a bias**, not an RMSNorm. The bias
89    /// is applied when this is written; a `.weight`-only binder silently drops both the
90    /// mean subtraction and the bias.
91    pub k_normed: DevicePtr,
92    /// `[capacity, index_head_dim]` BF16 — the compress-gate projection.
93    pub gate: DevicePtr,
94    /// `[capacity]` u8 — per-position validity.
95    pub valid: DevicePtr,
96    /// Tokens written so far. The selector reads `[0, len)`.
97    len: usize,
98    capacity: usize,
99    index_head_dim: usize,
100    /// Set by [`Self::free`]. Both the drafter's `free_state` and the target
101    /// layer's now release DSA state, and the same state must never be freed
102    /// twice — mirrors `Glm5NextMtpProposerState::released`.
103    released: bool,
104}
105
106impl Glm5NextDsaState {
107    /// Reserve for the whole addressable context. `alloc_state` has no length argument, so
108    /// the cap — not the prompt — sizes this.
109    pub fn alloc(gpu: &dyn GpuBackend, cfg: &Glm5NextDsaConfig) -> Result<Self> {
110        cfg.validate()?;
111        let capacity = max_dsa_context(cfg);
112        let d = cfg.index_head_dim;
113        Ok(Self {
114            k_normed: gpu.alloc(capacity * d * 2)?,
115            gate: gpu.alloc(capacity * d * 2)?,
116            valid: gpu.alloc(capacity)?,
117            len: 0,
118            capacity,
119            index_head_dim: d,
120            released: false,
121        })
122    }
123
124    pub fn len(&self) -> usize {
125        self.len
126    }
127    pub fn is_empty(&self) -> bool {
128        self.len == 0
129    }
130    pub fn capacity(&self) -> usize {
131        self.capacity
132    }
133
134    /// Byte offset of row `pos` in `k_normed` / `gate`.
135    pub fn row_offset(&self, pos: usize) -> usize {
136        pos * self.index_head_dim * 2
137    }
138
139    /// Would `n` more rows fit? Ask BEFORE writing them, not after.
140    ///
141    /// 🔴 `advance` is too late on its own. `indexer_forward` GEMMs `k_normed` and `gate`
142    /// straight into row `len()` and only then advances, so at `len == capacity` the write
143    /// lands on row `capacity` — 256 B past `k_normed`/`gate` and 1 B past `valid`. CUDA
144    /// reports that asynchronously as `CUDA_ERROR_ILLEGAL_ADDRESS (700)` at the next
145    /// synchronize, and a 700 is **sticky**: every later CUDA call in the context fails, so
146    /// one over-length prompt takes the serve down for every subsequent request while
147    /// `/v1/models`, `/health` and `/health/live` all keep answering 200. Checking first
148    /// turns that into a plain per-request error. ANOMALIES **A62** (the overrun) and
149    /// **A60** (the non-recovering serve it explains).
150    pub fn ensure_room(&self, n: usize) -> Result<()> {
151        self.ensure_room_through(self.len + n)
152    }
153
154    /// The same refusal for an ABSOLUTE end position.
155    ///
156    /// 🔴 The graph-replay path knows where the sequence will END (`seq_len + k`) but not
157    /// where this counter currently sits: a rejected draft leaves it AHEAD, and `sync_to`
158    /// rewinds it only after the replay has already written. Asking in absolute terms is
159    /// what makes the check answerable before `launch_graph`. A62.
160    pub fn ensure_room_through(&self, end: usize) -> Result<()> {
161        if end > self.capacity {
162            bail!(
163                "DSA indexer cache: {end} tokens exceeds the {} rows reserved for this \
164                 sequence. The indexer cache is sized from --max-seq-len at sequence \
165                 creation and never grows; raise --max-seq-len (and re-check the memory \
166                 budget) to serve a longer context.",
167                self.capacity
168            );
169        }
170        Ok(())
171    }
172
173    /// Advance after writing `n` rows at `[len, len + n)`.
174    ///
175    /// Refuses rather than wrapping or truncating: past the reservation there is no row to
176    /// write, and a silently clamped length would select over a prefix while the MLA cache
177    /// held the full context — a wrong answer, not a crash.
178    pub fn advance(&mut self, n: usize) -> Result<()> {
179        self.ensure_room(n)?;
180        self.len += n;
181        Ok(())
182    }
183
184    /// Plan a selection over everything cached so far.
185    /// Rewind to `n` rows after a rejected speculative draft.
186    ///
187    /// The rows in `[n, len)` are left in the cache but become unreachable: the selector reads
188    /// `[0, len)` and the next write starts at `n`, so they are overwritten before anything
189    /// can select over them. Only shrinks — growing is `advance`'s job, and a request to
190    /// "rewind" forward would mean the caller lost track of where the sequence is.
191    pub fn rewind_to(&mut self, n: usize) -> Result<()> {
192        if n > self.len {
193            bail!(
194                "DSA indexer rewind to {n} from {}: rewind only shrinks; a forward 'rewind' \
195                 means the caller lost the sequence position",
196                self.len
197            );
198        }
199        self.len = n;
200        Ok(())
201    }
202
203    /// Put the counter where a RUN step would have left it, for a step served by a replayed
204    /// CUDA graph. `seq_len` is the sequence length before this step's `k` rows.
205    ///
206    /// 🔴 The same lockstep reconcile `decode_k` does on the eager path, and for the same
207    /// reason: a K-row verify writes K rows and the scheduler keeps only the accepted prefix,
208    /// so the counter is AHEAD by (k - accepted) whenever a draft was rejected. `decode_k`
209    /// rewinds on entry; a replay never calls it, so a plain `advance(k)` compounds that drift
210    /// every step. See ANOMALIES A56 — the drafter writes its indexer rows at `len()`, so the
211    /// drift moves those rows on top of ones the target selects over.
212    pub fn sync_to(&mut self, seq_len: usize, k: usize) -> Result<()> {
213        match self.len.cmp(&seq_len) {
214            std::cmp::Ordering::Greater => self.rewind_to(seq_len)?,
215            std::cmp::Ordering::Less => bail!(
216                "DSA indexer cache holds {} tokens but the replayed step starts at {seq_len} \
217                 — rows are MISSING, not merely stale.",
218                self.len
219            ),
220            std::cmp::Ordering::Equal => {}
221        }
222        self.advance(k)
223    }
224
225    pub fn geometry(&self, cfg: &Glm5NextDsaConfig, q_rows: usize) -> Result<DsaSelectGeometry> {
226        DsaSelectGeometry::plan(cfg, self.len, q_rows)
227    }
228
229    /// Release the per-sequence device buffers.
230    ///
231    /// Takes `&mut self` rather than `self` because the only caller reaches the
232    /// state through `&mut dyn ProposerState` and cannot move out of it. The
233    /// by-value signature this replaces was inherently call-once; the caller
234    /// (`Glm5NextMtpHead::free_state`) now owns that guard via
235    /// `Glm5NextMtpProposerState::released`.
236    ///
237    /// 🔴 Invariant L2 (slot reuse): every buffer freed here can be baked into a captured
238    /// CUDA graph, so before a slot is re-occupied its graphs must be destroyed AND these
239    /// pointers freed and nulled. `free_sequence` does both. ANOMALIES A56 put that teardown
240    /// in place; the invariant is slot reuse, not the order of the two blocks.
241    ///
242    /// Idempotent: two owners can now reach a DSA state — the drafter's
243    /// `free_state` and, since ANOMALIES A76, the target layer's — so a second
244    /// call is a no-op rather than a double `gpu.free`. The pointers are nulled
245    /// so a released state cannot be mistaken for a live one.
246    pub fn free(&mut self, gpu: &dyn GpuBackend) -> Result<()> {
247        if self.released {
248            return Ok(());
249        }
250        self.released = true;
251        for p in [self.k_normed, self.gate, self.valid] {
252            gpu.free(p)?;
253        }
254        self.k_normed = DevicePtr(0);
255        self.gate = DevicePtr(0);
256        self.valid = DevicePtr(0);
257        self.len = 0;
258        Ok(())
259    }
260}
261
262impl LayerState for Glm5NextDsaState {
263    fn as_any(&self) -> &dyn std::any::Any {
264        self
265    }
266    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
267        self
268    }
269}
270
271#[cfg(test)]
272mod tests;