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;