metrale_model_layers/mtp_carry.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Carry the MTP drafter's KV from one turn of a session to the
4//! next, so a warm turn adopts the previous turn's drafter rows and appends
5//! only the new span, plus the SSM snapshot restore threshold.
6//!
7//! On a warm turn the reused prefix is not recomputed, so the whole-prompt
8//! hidden capture does not cover the prompt and the drafter's prompt prefill
9//! does not run (the model's `ensure_drafter_context`). Rebuilding it instead
10//! was measured 2026-07-21 on GB10 at 1136 ms for 11,947 rows, about as long
11//! as the warm TTFT of 1134 ms.
12//!
13//! Conventions:
14//! - Drafter row `r` holds a pair key `k`: `(embed(t_{k+1}), hidden_k)` at
15//! RoPE `k + 1`. Rows are compacted while RoPE stays in sequence space, so
16//! a skipped key leaves no hole.
17//! - `mtp_prefill_hidden` row `i` holds `hidden_i`, by absolute position.
18//!
19//! Owner: model-layers (MTP drafter).
20//! Invariants:
21//! - [`carry_armed`] is false whenever `mtp_max_seqs() > 1`.
22//! - [`CarriedDrafter::usable_by`] returns `None` unless the request's session
23//! hash is non-zero and equals the entry's.
24//! - [`StoreRange::visible_to`] returns `(0, 0)` unless the reader's ticket is
25//! non-zero and owns the range, and [`stamped_merge`] never extends a range
26//! written by another owner.
27
28use metrale_gpu_runtime::gpu::DevicePtr;
29
30/// 2026-09-25: Minimum snapshot depth, in matched tokens, for restoring an SSM
31/// snapshot; a shallower snapshot is not restored. 0 removes the floor.
32///
33/// A restore skips recomputing the matched prefix, so the drafter's prompt
34/// prefill does not run, and a request with no earlier turn in its session has
35/// nothing to carry. Measured 2026-07-28 at C=1, warm against caching off:
36/// 99 matched tokens -9.7%, 219 +9.8%, 349 +7.5%, 629 +10.6%.
37///
38/// A serve sets the value from `--marconi-min-tokens` through
39/// [`set_marconi_min_tokens`] before building the model, so
40/// `METRALE_MARCONI_MIN_TOKENS` is used only when nothing set it first.
41pub fn marconi_min_tokens() -> usize {
42 *MARCONI_MIN.get_or_init(|| {
43 std::env::var("METRALE_MARCONI_MIN_TOKENS")
44 .ok()
45 .and_then(|v| v.parse::<usize>().ok())
46 .unwrap_or(DEFAULT_MARCONI_MIN_TOKENS)
47 })
48}
49
50/// 2026-09-25: The default of `--marconi-min-tokens` and of the env fallback.
51pub const DEFAULT_MARCONI_MIN_TOKENS: usize = 256;
52
53static MARCONI_MIN: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
54
55/// 2026-09-25: Pin the restore threshold. The first write or read fixes it
56/// for the process; a later call changes nothing and returns false, so the
57/// caller can warn that its value was not applied.
58pub fn set_marconi_min_tokens(v: usize) -> bool {
59 MARCONI_MIN.set(v).is_ok()
60}
61
62/// 2026-09-25: Whether the carry is armed: configured on (`cfg.carry`) and MTP
63/// not in multi-sequence mode. It takes `multi_seq` as an argument so tests can
64/// set it; [`crate::speculative::mtp_max_seqs`] caches its env read.
65///
66/// The default cap is 32 (4 with `METRALE_NO_MTP_K_LADDER`), so `multi_seq` is
67/// true and the carry is off unless the dispatch cap (`--mtp-max-seqs`) is at most 1,
68/// whatever `cfg.carry` says. Report this, not `cfg.carry`, as the carry's state.
69pub fn carry_armed_with(cfg: crate::drafter_context::DrafterContext, multi_seq: bool) -> bool {
70 cfg.carry && !multi_seq
71}
72
73/// 2026-09-25: [`carry_armed_with`] against the live dispatch cap.
74pub fn carry_armed(cfg: crate::drafter_context::DrafterContext) -> bool {
75 carry_armed_with(cfg, crate::speculative::mtp_multi_seq_mode())
76}
77
78pub fn mtp_carry_drafter_enabled(levers: &crate::layers::ops::ModelLevers) -> bool {
79 carry_armed(levers.drafter)
80}
81
82/// 2026-09-25: `METRALE_MTP_CARRY_DEBUG=1` logs one line per carry store and
83/// per adopt decision.
84pub fn mtp_carry_debug() -> bool {
85 std::env::var("METRALE_MTP_CARRY_DEBUG").ok().as_deref() == Some("1")
86}
87
88/// 2026-09-25: The drafter KV of a finished turn, held in the model's single
89/// carry slot for the next turn of the same session.
90pub struct CarriedDrafter {
91 /// 2026-09-25: Drafter KV blocks moved out of the finished sequence's
92 /// proposer state (`DraftProposer::take_drafter_kv`), so freeing that
93 /// state does not release them.
94 pub block_table: Vec<u32>,
95 /// 2026-09-25: Drafter rows resident in those blocks.
96 pub rows: usize,
97 /// 2026-09-25: Sequence-space pair key of the newest resident row.
98 pub last_pair_key: Option<usize>,
99 /// 2026-09-25: The finished sequence's tokens. [`Self::usable_by`] keeps
100 /// only the rows within their common prefix with a new prompt; a common
101 /// prefix does not show that the rows belong to the new request.
102 pub tokens: Vec<u32>,
103 /// 2026-09-25: `SequenceState::session_hash` of the sequence that left
104 /// these rows. The slot holds whichever sequence finished last, so
105 /// adoption also requires this to match.
106 pub session_hash: u64,
107}
108
109impl CarriedDrafter {
110 /// 2026-09-25: Length of the common prefix of `self.tokens` and `prompt`.
111 pub fn common_prefix_len(&self, prompt: &[u32]) -> usize {
112 self.tokens
113 .iter()
114 .zip(prompt.iter())
115 .take_while(|(a, b)| a == b)
116 .count()
117 }
118
119 /// 2026-09-25: Whether these rows belong to `session_hash`. A zero hash
120 /// never matches: an unstamped request has no session to check. The SSM
121 /// snapshot pool's `session_matches` instead accepts 0.
122 pub fn session_matches(&self, session_hash: u64) -> bool {
123 session_hash != 0 && self.session_hash == session_hash
124 }
125
126 /// 2026-09-25: The `(rows, last_pair_key)` of this entry that `prompt` may
127 /// adopt, or `None`. `None` unless [`Self::session_matches`].
128 ///
129 /// Pair key `k` consumed `tokens[0..=k + 1]`, so keys up to the common
130 /// prefix length minus 2 survive. The rows above that key are dropped from
131 /// the tail, one row per key. When the tail had key gaps the returned key
132 /// can be above the newest surviving row's real key, which only makes the
133 /// append start later.
134 pub fn usable_by(&self, prompt: &[u32], session_hash: u64) -> Option<(usize, usize)> {
135 if !self.session_matches(session_hash) {
136 return None;
137 }
138 let k = self.last_pair_key?;
139 if self.rows == 0 {
140 return None;
141 }
142 let common = self.common_prefix_len(prompt);
143 // 2026-09-25: Pair key 0 survives only if tokens[0..=1] are common.
144 let max_key = common.checked_sub(2)?;
145 let key = k.min(max_key);
146 let dropped = k - key;
147 let rows = self.rows.checked_sub(dropped)?;
148 if rows == 0 { None } else { Some((rows, key)) }
149 }
150}
151
152/// 2026-09-25: The warm-turn append after `last_pair_key` for a prompt of
153/// `prompt_len` tokens, reading hidden rows `[hidden_lo, hidden_hi)`.
154///
155/// - `first_key` is `last_pair_key + 1`, raised to `hidden_lo` when the
156/// hidden store starts later; the skipped keys leave no hole (module docs).
157/// - `rows` covers pair keys `first_key ..= prompt_len - 2`.
158///
159/// `None` when no key is missing, or when the store does not cover
160/// `[first_key, prompt_len - 2]`.
161pub fn plan_append(
162 last_pair_key: usize,
163 prompt_len: usize,
164 hidden_lo: usize,
165 hidden_hi: usize,
166) -> Option<AppendPlan> {
167 // 2026-09-25: Pair keys run 0 ..= prompt_len - 2.
168 let last_key_needed = prompt_len.checked_sub(2)?;
169 let first_key = (last_pair_key + 1).max(hidden_lo);
170 if first_key > last_key_needed {
171 return None;
172 }
173 // 2026-09-25: Pair key k reads hidden row k; `hidden_hi` is exclusive.
174 if hidden_hi <= last_key_needed || hidden_lo > first_key {
175 return None;
176 }
177 Some(AppendPlan {
178 first_key,
179 rows: last_key_needed - first_key + 1,
180 })
181}
182
183#[derive(Debug, PartialEq, Eq)]
184pub struct AppendPlan {
185 pub first_key: usize,
186 pub rows: usize,
187}
188
189/// 2026-09-25: Address of hidden row `pos` in a `[capacity, hidden_size]`
190/// BF16 store starting at `base`.
191pub fn hidden_row_offset(base: DevicePtr, pos: usize, hidden_size: usize) -> DevicePtr {
192 base.offset(pos * hidden_size * 2)
193}
194
195/// 2026-09-25: The interval of `mtp_prefill_hidden` rows written, and the
196/// sequence ticket that wrote them. The buffer is model-level and indexed by
197/// absolute position, so the interval alone cannot say whose rows it holds.
198///
199/// Owner 0 never matches: it marks an unclaimed range, and it is the ticket of
200/// a `SequenceState` not built by the model's `alloc_sequence`, which draws
201/// tickets from 1.
202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203pub struct StoreRange {
204 /// 2026-09-25: The writer's ticket (`SequenceState::mtp_store_gen`); 0
205 /// means unclaimed.
206 pub owner: u64,
207 /// 2026-09-25: First absolute position written.
208 pub lo: usize,
209 /// 2026-09-25: One past the last absolute position written.
210 pub hi: usize,
211}
212
213impl StoreRange {
214 /// 2026-09-25: Nothing claimed.
215 pub const EMPTY: Self = Self {
216 owner: 0,
217 lo: 0,
218 hi: 0,
219 };
220
221 /// 2026-09-25: The interval `reader_gen` may read, or `(0, 0)` when the
222 /// rows belong to another ticket or `reader_gen` is 0. [`plan_append`]
223 /// refuses `(0, 0)` like any interval that does not cover its span.
224 pub fn visible_to(self, reader_gen: u64) -> (usize, usize) {
225 if reader_gen != 0 && self.owner == reader_gen {
226 (self.lo, self.hi)
227 } else {
228 (0, 0)
229 }
230 }
231}
232
233/// 2026-09-25: Record a write of `[start, start + count)` by `writer_gen`. The
234/// same non-zero owner merges it with [`merge_interval`]; any other owner, or
235/// an unclaimed range, is replaced by the write. Merging across owners would
236/// leave another sequence's rows inside the interval.
237///
238/// The same-owner case relies on [`merge_interval`] replacing on a gap, which
239/// `merge_interval_replaces_on_a_gap` pins.
240pub fn stamped_merge(cur: StoreRange, writer_gen: u64, start: usize, count: usize) -> StoreRange {
241 if cur.owner != 0 && cur.owner == writer_gen {
242 let (lo, hi) = merge_interval((cur.lo, cur.hi), start, count);
243 StoreRange {
244 owner: writer_gen,
245 lo,
246 hi,
247 }
248 } else {
249 StoreRange {
250 owner: writer_gen,
251 lo: start,
252 hi: start + count,
253 }
254 }
255}
256
257/// 2026-09-25: Merge a write of `[start, start + count)` into the interval
258/// `[lo, hi)`. An overlapping or abutting write extends it; a disjoint write
259/// replaces it, because one interval cannot describe two runs and claiming
260/// the gap would mark unwritten rows valid.
261pub fn merge_interval(cur: (usize, usize), start: usize, count: usize) -> (usize, usize) {
262 let (lo, hi) = cur;
263 let (ns, ne) = (start, start + count);
264 if hi > lo && ns <= hi && ne >= lo {
265 (lo.min(ns), hi.max(ne))
266 } else {
267 (ns, ne)
268 }
269}
270
271/// 2026-09-25: Result of a carry attempt, for logging and tests.
272#[derive(Debug, PartialEq, Eq)]
273pub enum CarryOutcome {
274 Adopted {
275 rows: usize,
276 appended: usize,
277 first_key: usize,
278 },
279 NoCarry,
280 PrefixMismatch {
281 common: usize,
282 entry_rows: usize,
283 },
284 /// 2026-09-25: No append plan because another non-zero ticket owns the
285 /// stored hidden rows; otherwise the same refusal as `NoHiddens`.
286 ForeignHiddens {
287 /// 2026-09-25: The ticket that owns the rows.
288 owner: u64,
289 /// 2026-09-25: The ticket that wanted to read them.
290 expected: u64,
291 },
292 /// 2026-09-25: The slot held another session's rows, or this request has
293 /// no session. `PrefixMismatch` is instead an entry of this session whose
294 /// prefix did not match.
295 ForeignSession {
296 entry_session: u64,
297 prompt_session: u64,
298 },
299 NoHiddens,
300}
301
302impl std::fmt::Display for CarryOutcome {
303 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 match self {
305 CarryOutcome::Adopted {
306 rows,
307 appended,
308 first_key,
309 } => write!(
310 f,
311 "adopted rows={rows} appended={appended} first_key={first_key}"
312 ),
313 CarryOutcome::NoCarry => write!(f, "no carried state"),
314 CarryOutcome::PrefixMismatch { common, entry_rows } => {
315 write!(
316 f,
317 "prefix mismatch (common={common} entry_rows={entry_rows})"
318 )
319 }
320 CarryOutcome::ForeignSession {
321 entry_session,
322 prompt_session,
323 } => write!(
324 f,
325 "foreign session (entry={entry_session:#x} prompt={prompt_session:#x})"
326 ),
327 CarryOutcome::ForeignHiddens { owner, expected } => write!(
328 f,
329 "hidden rows belong to sequence gen {owner}, not {expected}"
330 ),
331 CarryOutcome::NoHiddens => write!(f, "hidden store does not cover the append span"),
332 }
333 }
334}
335
336#[cfg(test)]
337#[path = "mtp_carry_tests.rs"]
338mod tests;