metrale_model_arch/glm5next_dsa/
state.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Per-sequence DSA indexer cache: the `k_normed`, `gate` and `valid` rows the
4//! selector reads.
5//!
6//! Owner: model-arch (GLM-5.3 DSA).
7//! Invariants:
8//! - `len <= capacity`: `advance` and `sync_to` refuse to pass `capacity`, and `rewind_to`
9//!   only shrinks.
10//!
11//! The indexer keys and gates are projections of the hidden state (`indexer.wk`,
12//! `index_kpool_compress_gate`), which the MLA latent cache does not hold, so they get their
13//! own cache. It is a `LayerState`, allocated per sequence by `TransformerLayer::alloc_state`.
14//!
15//! # Flat, not paged
16//!
17//! `dsa_kpool_compress` reads `k[raw * D + d]` and `gate[raw * D + d]` at absolute
18//! positions, so this is one contiguous buffer per sequence. The MLA latent cache stays
19//! paged.
20
21use anyhow::{Result, bail};
22use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
23
24use super::Glm5NextDsaConfig;
25use super::select::DsaSelectGeometry;
26use metrale_model_layers::layer::LayerState;
27
28/// 2026-09-25: Rows a sequence's indexer cache holds: `cfg.max_context` rounded down to
29/// whole pools ([`dsa_capacity`]).
30///
31/// Each row costs `2 · index_head_dim` BF16 plus 1 B per DSA layer (513 B at
32/// `index_head_dim` 128, 5,643 B over GLM-5.3's 11 DSA layers), and every rank holds a full
33/// copy. `Glm5NextTextSkeleton::state_budget` charges the same bytes per token.
34pub fn max_dsa_context(cfg: &Glm5NextDsaConfig) -> usize {
35    dsa_capacity(cfg.max_context, cfg.index_kpool)
36}
37
38/// 2026-09-25: Indexer cache rows for `max_context` tokens, rounded down to whole pools (a
39/// trailing partial pool is not a pool).
40///
41/// The allocation ([`max_dsa_context`]) and the serve's per-sequence reserve
42/// (`seq_state_reserve::per_sequence_state_bytes`) both call it. `index_kpool` 0 is treated
43/// as 1, so it is total for callers that have not validated the config.
44pub fn dsa_capacity(max_context: usize, index_kpool: usize) -> usize {
45    let kpool = index_kpool.max(1);
46    (max_context / kpool) * kpool
47}
48
49/// 2026-09-25: Bytes one sequence's indexer cache takes for one DSA layer at `capacity` rows:
50/// `capacity * (4 * index_head_dim + 1)`, the sizes [`Glm5NextDsaState::alloc`] allocates
51/// for `k_normed`, `gate` and `valid`. The serve's per-sequence reserve uses it.
52pub fn indexer_state_bytes(capacity: usize, index_head_dim: usize) -> usize {
53    capacity * index_head_dim * 2   // 2026-09-25: k_normed and gate (BF16), then valid (u8)
54        + capacity * index_head_dim * 2
55        + capacity
56}
57
58/// 2026-09-25: One sequence's indexer cache for one DSA layer, allocated once at
59/// [`max_dsa_context`] rows and never grown.
60pub struct Glm5NextDsaState {
61    /// 2026-09-25: `[capacity, index_head_dim]` BF16 indexer keys, after the `k_norm`
62    /// LayerNorm.
63    pub k_normed: DevicePtr,
64    /// 2026-09-25: `[capacity, index_head_dim]` BF16 compress-gate projection.
65    pub gate: DevicePtr,
66    /// 2026-09-25: `[capacity]` u8 per-position validity.
67    pub valid: DevicePtr,
68    /// 2026-09-25: Tokens written so far. The selector reads `[0, len)`.
69    len: usize,
70    capacity: usize,
71    index_head_dim: usize,
72    /// 2026-09-25: Set by [`Self::free`], which then does nothing on a second call.
73    released: bool,
74}
75
76impl Glm5NextDsaState {
77    /// 2026-09-25: Reserve for the whole addressable context. `alloc_state` has no length
78    /// argument, so the cap, not the prompt, sizes this.
79    pub fn alloc(gpu: &dyn GpuBackend, cfg: &Glm5NextDsaConfig) -> Result<Self> {
80        cfg.validate()?;
81        let capacity = max_dsa_context(cfg);
82        let d = cfg.index_head_dim;
83        Ok(Self {
84            k_normed: gpu.alloc(capacity * d * 2)?,
85            gate: gpu.alloc(capacity * d * 2)?,
86            valid: gpu.alloc(capacity)?,
87            len: 0,
88            capacity,
89            index_head_dim: d,
90            released: false,
91        })
92    }
93
94    pub fn len(&self) -> usize {
95        self.len
96    }
97    pub fn is_empty(&self) -> bool {
98        self.len == 0
99    }
100    pub fn capacity(&self) -> usize {
101        self.capacity
102    }
103    /// 2026-09-25: Indexer key width. The aux-state codec (`aux_state.rs`) refuses a blob
104    /// taken at a different width.
105    pub fn index_head_dim(&self) -> usize {
106        self.index_head_dim
107    }
108
109    /// 2026-09-25: Byte offset of row `pos` in `k_normed` / `gate`.
110    pub fn row_offset(&self, pos: usize) -> usize {
111        pos * self.index_head_dim * 2
112    }
113
114    /// 2026-09-25: Fails when `n` more rows would pass `capacity`.
115    ///
116    /// `Glm5NextDsaLayer::indexer_forward` writes row `len()` before it advances, so it calls
117    /// this first: at `len == capacity` that row is past the end of every buffer.
118    pub fn ensure_room(&self, n: usize) -> Result<()> {
119        self.ensure_room_through(self.len + n)
120    }
121
122    /// 2026-09-25: The same refusal for an absolute end row.
123    ///
124    /// Before a graph replay the counter may still be ahead of the sequence (a rejected
125    /// draft; `sync_to` fixes it after the replay), so the replay check
126    /// (`check_replay_room`) asks about the end row `seq_len + k` instead.
127    pub fn ensure_room_through(&self, end: usize) -> Result<()> {
128        if end > self.capacity {
129            bail!(
130                "DSA indexer cache: {end} tokens exceeds the {} rows reserved for this \
131                 sequence. The indexer cache is sized from --max-seq-len at sequence \
132                 creation and never grows; raise --max-seq-len (and re-check the memory \
133                 budget) to serve a longer context.",
134                self.capacity
135            );
136        }
137        Ok(())
138    }
139
140    /// 2026-09-25: Advance after writing `n` rows at `[len, len + n)`. Fails, without
141    /// changing `len`, when that would pass `capacity`.
142    pub fn advance(&mut self, n: usize) -> Result<()> {
143        self.ensure_room(n)?;
144        self.len += n;
145        Ok(())
146    }
147
148    /// 2026-09-25: Rewind to `n` rows after a rejected speculative draft.
149    ///
150    /// The rows in `[n, len)` are left in the cache but become unreachable: the selector reads
151    /// `[0, len)` and the next write starts at `n`, so they are overwritten before anything
152    /// can select over them. Only shrinks — growing is `advance`'s job, and a request to
153    /// "rewind" forward would mean the caller lost track of where the sequence is.
154    pub fn rewind_to(&mut self, n: usize) -> Result<()> {
155        if n > self.len {
156            bail!(
157                "DSA indexer rewind to {n} from {}: rewind only shrinks; a forward 'rewind' \
158                 means the caller lost the sequence position",
159                self.len
160            );
161        }
162        self.len = n;
163        Ok(())
164    }
165
166    /// 2026-09-25: Put the counter where `decode_k` would have left it, for a step served by
167    /// a replayed CUDA graph. `seq_len` is the sequence length before this step's `k` rows.
168    ///
169    /// The same reconcile `decode_k` does on entry: the counter is rewound to `seq_len` when
170    /// it is ahead (a rejected draft), a counter behind `seq_len` is an error, and then it
171    /// advances by `k`.
172    pub fn sync_to(&mut self, seq_len: usize, k: usize) -> Result<()> {
173        match self.len.cmp(&seq_len) {
174            std::cmp::Ordering::Greater => self.rewind_to(seq_len)?,
175            std::cmp::Ordering::Less => bail!(
176                "DSA indexer cache holds {} tokens but the replayed step starts at {seq_len} \
177                 — rows are MISSING, not merely stale.",
178                self.len
179            ),
180            std::cmp::Ordering::Equal => {}
181        }
182        self.advance(k)
183    }
184
185    /// 2026-09-25: Plan a selection over everything cached so far.
186    pub fn geometry(&self, cfg: &Glm5NextDsaConfig, q_rows: usize) -> Result<DsaSelectGeometry> {
187        DsaSelectGeometry::plan(cfg, self.len, q_rows)
188    }
189
190    /// 2026-09-25: Release the per-sequence device buffers.
191    ///
192    /// Takes `&mut self` because callers hold the state behind a `dyn` state object. A second
193    /// call does nothing (`released` is set first), since `release_state` on the layer and the
194    /// MTP proposer's `free_state` can both reach one state. After the three frees succeed the
195    /// pointers are nulled and `len` is 0.
196    pub fn free(&mut self, gpu: &dyn GpuBackend) -> Result<()> {
197        if self.released {
198            return Ok(());
199        }
200        self.released = true;
201        for p in [self.k_normed, self.gate, self.valid] {
202            gpu.free(p)?;
203        }
204        self.k_normed = DevicePtr(0);
205        self.gate = DevicePtr(0);
206        self.valid = DevicePtr(0);
207        self.len = 0;
208        Ok(())
209    }
210}
211
212impl LayerState for Glm5NextDsaState {
213    fn as_any(&self) -> &dyn std::any::Any {
214        self
215    }
216    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
217        self
218    }
219}
220
221#[cfg(test)]
222mod tests;