spark_model/model/mtp_carry.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Carry the MTP drafter's KV across turns of a session (ON by default; see
4//! [`crate::model::drafter_context`] for the switch and the coupling).
5//!
6//! # The defect this closes
7//!
8//! The drafter is prompt-prefilled only on a COLD turn. On a WARM turn the
9//! target reuses a cached prefix, so `try_mtp_prefill_capture` never sees a
10//! chunk starting at 0, `mtp_prefill_capture_len` stays 0, the propose-site
11//! guard `captured >= prompt_len` fails, and `prefill_drafter` is SKIPPED.
12//! Proposer state is per-request, so the drafter then starts EMPTY and gains
13//! one row per decoded token: measured **142 drafter KV rows at sequence
14//! position 10,098**, and **987 of 1007 scored MLPerf-edge samples are warm**.
15//! Measured cost of that blindness: **+0.079 p1 / +0.089 p2_uncond**, about
16//! **+10% accepted tokens per verify step**, de-confounded from SSM warm
17//! restore (which is only +0.0070 p1 on its own).
18//!
19//! # Why NOT just re-run the whole-prompt drafter prefill on warm turns
20//!
21//! Measured on GB10 2026-07-21: `prefill_drafter` over 11,947 rows costs
22//! **1136 ms**, of which the `fc` GEMM alone is 874 ms. Warm TTFT on the same
23//! rig is 1134 ms, so a full warm-turn rebuild roughly DOUBLES TTFT to buy
24//! ~10% of decode. On the scored workload (turns average ~71 output tokens,
25//! ~3.7 s of generation) that trades ~370 ms of decode for ~1136 ms of TTFT —
26//! a net wall-clock LOSS on the metric Atlas currently wins 1.80x. The two
27//! per-row loops are only 7.6% of it, so batching them does not rescue it, and
28//! `dense_gemm_tc` measured 21% SLOWER than the scalar kernel at this shape.
29//!
30//! # The mechanism
31//!
32//! A turn's prompt is a strict extension of the previous turn's full sequence
33//! — that is exactly why the prefix cache hits. So the drafter rows the
34//! previous turn already built ARE the rows this turn needs; only the tail is
35//! missing. This module keeps the previous turn's drafter KV alive in a
36//! single model-level slot and appends only the new span.
37//!
38//! ★ WHY ONE SLOT IS SAFE, correctly stated. This used to read "MTP is
39//! concurrency-1: every spec path is gated `active.len() == 1`". That is FALSE
40//! and has been since the ladder campaign: the scheduler dispatches MTP
41//! whenever `active.len() <= mtp_max_seqs()`, and that cap defaults to 32
42//! (`speculative/ladder.rs`; `scheduler/phase_continue_prefills/spec_mixing.rs`
43//! documents the same staleness). Only the n-gram and self-speculative lanes
44//! still require `active.len() == 1`.
45//!
46//! What actually makes one slot safe is narrower and is enforced:
47//! [`carry_armed_with`] force-disables the carry whenever the dispatch cap is
48//! above 1, so the slot is only ever live in single-sequence mode. Even there,
49//! ADMISSION is not gated by that cap — several sequences can be admitted and
50//! prefill concurrently — which is why the shared hidden interval carries an
51//! ownership stamp ([`StoreRange`]) rather than relying on a concurrency claim.
52//!
53//! Conventions, which is where this code kills people:
54//! * drafter row `r` holds pair key `k` = `(embed(t_{k+1}), hidden_k)`, RoPE
55//! `k + 1`. Rows are COMPACTED (dense slots) while RoPE stays in sequence
56//! space, so key gaps are already the norm — a partial append is safe.
57//! * `mtp_prefill_hidden` row `i` holds `hidden_i`. (The catch-up ring uses
58//! the OTHER convention — label `n` holds `hidden_{n-1}`. Do not mix them;
59//! that off-by-one was live until `d9984089`.)
60//!
61//! Correctness note, and its LIMIT. For the token emitted by one step, drafter
62//! KV cannot corrupt output: the target verifies every draft, so a wrong or
63//! missing row costs acceptance, not correctness. That argument covers one
64//! step and does not extend to the RECURRENT state, because the accepted-draft
65//! count selects between numerically distinct state paths (full accept keeps
66//! the verify kernel's own state; a reject restores from the batched
67//! intermediate; neither is bit-equal to an M=1 decode). A drafter fed by a
68//! DIFFERENT request therefore moves acceptance, and acceptance moves the SSM
69//! state every later token is decoded from. Validity below is consequently
70//! about three things, not two: not wasting the lever, not reading another
71//! sequence's hiddens, and not adopting another SESSION's rows at all.
72
73use spark_runtime::gpu::DevicePtr;
74
75/// Carry the drafter's KV across turns instead of rebuilding (or, before this
76/// existed, skipping) it on every warm turn. **ON by default.**
77///
78/// Inseparable from the drafter prefill, which owns the hidden buffer this
79/// path reads: the call site is nested inside `!mtp_prefill_hidden.is_null()`,
80/// so carry alone is inert, and prefill without carry is a measured −927
81/// ms/turn loss. [`crate::model::drafter_context`] resolves both together and
82/// is the single source of truth for the policy and its kill switch.
83/// Minimum MATCHED prefix (tokens) before the Marconi SSM snapshot skip is worth
84/// taking. Below this, take the KV-only path instead.
85///
86/// # Why a floor exists at all
87///
88/// Restoring an SSM snapshot skips the target's prefill, so
89/// `mtp_prefill_capture_len` stays 0, the `captured >= prompt_len` guard at
90/// `speculative.rs:194` fails, `prefill_drafter` is skipped and the drafter
91/// starts EMPTY (the defect this module's carry closes for SAME-SESSION turns —
92/// but a fresh request matching only a shared chat-template preamble has no
93/// previous turn to carry from, so carry cannot fire).
94///
95/// Measured at C=1, identical-prompt reps (full-prompt hits), warm reps vs
96/// caching-off, 2026-07-28:
97/// ```text
98/// 99 matched tokens 23.85 vs 26.4 -9.7% LOSS
99/// 219 matched tokens 24.15 vs 22.0 +9.8% WIN
100/// 349 matched tokens 23.55 vs 21.9 +7.5%
101/// 629 matched tokens 22.45 vs 20.3 +10.6%
102/// ```
103/// The crossover is SHARP, between ~99 and ~219. 256 sits inside the win region
104/// and is block-aligned (16 x 16-token blocks). On preamble-only traffic the
105/// penalty was -6.8% at C=1 and -9.2% at C=2, and inert by C=4.
106///
107/// `ATLAS_MARCONI_MIN_TOKENS=<n>` overrides; 0 restores the previous
108/// always-restore behaviour.
109pub fn marconi_min_tokens() -> usize {
110 *MARCONI_MIN.get_or_init(|| {
111 std::env::var("ATLAS_MARCONI_MIN_TOKENS")
112 .ok()
113 .and_then(|v| v.parse::<usize>().ok())
114 .unwrap_or(DEFAULT_MARCONI_MIN_TOKENS)
115 })
116}
117
118/// The shipped threshold, and the only place it is written down.
119pub const DEFAULT_MARCONI_MIN_TOKENS: usize = 256;
120
121static MARCONI_MIN: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
122
123/// Pin the restore threshold from `--marconi-min-tokens`, before anything
124/// reads it.
125///
126/// ★ WHY A SETTER AND NOT JUST THE ENV VAR. A KAT gate needs this value to be
127/// part of its RECORD, and only recipe keys reach a record — an env var cannot,
128/// so a run configured by `ATLAS_MARCONI_MIN_TOKENS` could not state that it
129/// had been. Issue #936 measured a sharded BFCL draw disagreeing with the same
130/// draw run whole on 12 of 995 samples through cross-request SSM snapshot
131/// reuse; setting this high closes the consumer side and takes that to 2. A
132/// configuration that fixes a correctness property is worthless if a record
133/// cannot say it was used.
134///
135/// First writer wins, and a later call is IGNORED rather than panicking: the
136/// value is a process-wide constant once anything has read it, and a serve
137/// startup that set it twice would otherwise abort a running server over a
138/// duplicate flag. Returns whether this call is the one that set it, so the
139/// caller can warn if it lost the race — which means something read the
140/// threshold before serve configured it, and the flag silently did nothing.
141pub fn set_marconi_min_tokens(v: usize) -> bool {
142 MARCONI_MIN.set(v).is_ok()
143}
144
145/// Is the carry ARMED, given how it is configured and whether MTP is
146/// dispatching multiple sequences?
147///
148/// Pure, so the rule can be tested; `mtp_max_seqs()` caches its env read in a
149/// `OnceLock` and a unit test cannot flip it. The env is read by the callers
150/// below, at the boundary.
151///
152/// ★ CONFIGURED IS NOT ARMED, and conflating the two cost a night of GPU on
153/// 2026-09-07. `ATLAS_MTP_MAX_SEQS` defaults to 32, so `multi_seq` is true on
154/// an unconfigured serve and the carry is INERT no matter what
155/// `DrafterContext` says. Anything that reports the carry's state to a human
156/// must report THIS, not `cfg.carry`.
157pub fn carry_armed_with(
158 cfg: crate::model::drafter_context::DrafterContext,
159 multi_seq: bool,
160) -> bool {
161 // Force-off in multi-seq MTP mode: the carry slot is single-sequence by
162 // design. NOT because MTP is concurrency-1 — it is not, the cap defaults
163 // to 32 — but because THIS check is what keeps the slot out of multi-seq
164 // mode in the first place. See `speculative::mtp_multi_seq_mode`.
165 cfg.carry && !multi_seq
166}
167
168/// [`carry_armed_with`] against the live dispatch cap.
169pub fn carry_armed(cfg: crate::model::drafter_context::DrafterContext) -> bool {
170 carry_armed_with(cfg, crate::speculative::mtp_multi_seq_mode())
171}
172
173pub fn mtp_carry_drafter_enabled(levers: &crate::layers::ops::ModelLevers) -> bool {
174 carry_armed(levers.drafter)
175}
176
177/// `ATLAS_MTP_CARRY_DEBUG=1` — one line per adopt/carry decision. Cheap (no
178/// device reads, no syncs), but still off by default so timed legs stay quiet.
179pub fn mtp_carry_debug() -> bool {
180 std::env::var("ATLAS_MTP_CARRY_DEBUG").ok().as_deref() == Some("1")
181}
182
183/// The drafter KV of a finished turn, held for the next turn of the same
184/// session. Single slot: the carry is force-disabled outside single-sequence
185/// dispatch (see [`carry_armed_with`] — NOT because "MTP never runs at
186/// concurrency > 1", which is false; the cap defaults to 32), and one slot
187/// keeps block ownership trivially safe (the blocks are owned here, or by a
188/// live sequence, never both).
189pub struct CarriedDrafter {
190 /// Drafter KV blocks, moved out of the finished sequence's proposer state
191 /// so `free_state` does not release them.
192 pub block_table: Vec<u32>,
193 /// Drafter rows resident in those blocks.
194 pub rows: usize,
195 /// Sequence-space pair key of the newest resident row.
196 pub last_pair_key: Option<usize>,
197 /// The token sequence that produced these rows. `hidden_i` is a pure
198 /// function of `tokens[0..=i]`, so the COMMON PREFIX with a later prompt
199 /// bounds which rows that prompt may adopt — see [`Self::usable_by`],
200 /// which truncates to that bound rather than demanding full equality.
201 ///
202 /// Prefix agreement is a bound, NOT an identity. Two unrelated requests
203 /// rendered through one chat template agree on hundreds of tokens, so this
204 /// field cannot answer "are these rows mine"; [`Self::session_hash`] does.
205 pub tokens: Vec<u32>,
206 /// The session that produced these rows, copied from
207 /// `SequenceState::session_hash` at deposit.
208 ///
209 /// Without it the slot is a cross-request channel: it is MODEL-level (one
210 /// slot for the whole engine, `types.rs`), it is filled by whichever
211 /// sequence finished last, and prefix truncation alone admits any prompt
212 /// sharing two leading tokens — which every templated request does.
213 pub session_hash: u64,
214}
215
216impl CarriedDrafter {
217 /// Length of the common prefix of `self.tokens` and `prompt`.
218 pub fn common_prefix_len(&self, prompt: &[u32]) -> usize {
219 self.tokens
220 .iter()
221 .zip(prompt.iter())
222 .take_while(|(a, b)| a == b)
223 .count()
224 }
225
226 /// Do these rows belong to `session_hash`?
227 ///
228 /// The admission gate. Prefix agreement bounds WHICH rows are numerically
229 /// reusable; this decides whether the entry may be reused AT ALL.
230 ///
231 /// ★ `0` REFUSES. A zero hash means the scheduler stamped no session, so
232 /// there is nothing to verify ownership against. This deliberately differs
233 /// from `SsmSnapshot::session_matches`, which treats 0 as "legacy tracking
234 /// off, allow"; the closer precedent is the sibling single-slot in this
235 /// same subsystem, whose `owns_capture` stamp requires a non-zero
236 /// generation for the identical reason — blind beats poisoned.
237 pub fn session_matches(&self, session_hash: u64) -> bool {
238 session_hash != 0 && self.session_hash == session_hash
239 }
240
241 /// How much of this entry `prompt` may adopt.
242 ///
243 /// Refuses outright unless [`Self::session_matches`]; the prefix rules
244 /// below only ever NARROW an entry this session already owns.
245 ///
246 /// Pair key `k` consumed `tokens[0..=k + 1]`, so a key is usable exactly
247 /// when the prompt agrees with those tokens. Requiring the WHOLE entry to
248 /// match is too strict in practice: a chat template can re-tokenize the
249 /// assistant/user boundary, so the tail of the previous turn's sequence
250 /// need not reappear verbatim in the next turn's prompt (measured on the
251 /// 27B rig — full-match adoption reported `prefix mismatch` on every warm
252 /// turn). Truncating instead of refusing keeps the ~12k rows that DO
253 /// match and loses only the handful that do not.
254 ///
255 /// Rows are append-only in increasing key order, so dropping `d` rows from
256 /// the TAIL drops the `d` highest keys. `last_pair_key` is then clamped to
257 /// `L - 2`, which can only OVERSTATE the surviving row's true key when the
258 /// tail had gaps — and overstating merely starts the append later, i.e.
259 /// costs coverage, never correctness. Rows beyond the returned count are
260 /// overwritten by the append or never read (the drafter reads `seq_len`
261 /// rows).
262 ///
263 /// Returns `(rows, last_pair_key)` to adopt, or `None` when nothing is
264 /// usable.
265 pub fn usable_by(&self, prompt: &[u32], session_hash: u64) -> Option<(usize, usize)> {
266 if !self.session_matches(session_hash) {
267 return None;
268 }
269 let k = self.last_pair_key?;
270 if self.rows == 0 {
271 return None;
272 }
273 let common = self.common_prefix_len(prompt);
274 // Need at least tokens[0..=1] in common for pair key 0 to survive.
275 let max_key = common.checked_sub(2)?;
276 let key = k.min(max_key);
277 let dropped = k - key;
278 let rows = self.rows.checked_sub(dropped)?;
279 if rows == 0 { None } else { Some((rows, key)) }
280 }
281}
282
283/// Where a warm-turn append must start, given the carried state and the new
284/// prompt, and where its hiddens must come from.
285///
286/// * `first_key` — the first pair key to write. `last_pair_key + 1` normally;
287/// clamped up to `hidden_lo` when the hidden store does not reach back that
288/// far. Skipping keys leaves no hole: rows are compacted, RoPE carries the
289/// position, and a gap is already the steady-state shape of this row space.
290/// * `rows` — how many pair keys get written: `first_key ..= prompt_len - 2`.
291///
292/// Returns `None` when there is nothing to append (the drafter already covers
293/// the prompt) or when the hidden store cannot reach the first needed row.
294pub fn plan_append(
295 last_pair_key: usize,
296 prompt_len: usize,
297 hidden_lo: usize,
298 hidden_hi: usize,
299) -> Option<AppendPlan> {
300 // Pair keys run 0 ..= prompt_len - 2 for a prompt of `prompt_len` tokens.
301 let last_key_needed = prompt_len.checked_sub(2)?;
302 let first_key = (last_pair_key + 1).max(hidden_lo);
303 if first_key > last_key_needed {
304 return None;
305 }
306 // Pair key k reads hidden row k, so the store must cover
307 // [first_key, last_key_needed]; hidden_hi is exclusive.
308 if hidden_hi <= last_key_needed || hidden_lo > first_key {
309 return None;
310 }
311 Some(AppendPlan {
312 first_key,
313 rows: last_key_needed - first_key + 1,
314 })
315}
316
317#[derive(Debug, PartialEq, Eq)]
318pub struct AppendPlan {
319 pub first_key: usize,
320 pub rows: usize,
321}
322
323/// Byte offset of hidden row `pos` in a `[capacity, hidden_size]` BF16 store.
324pub fn hidden_row_offset(base: DevicePtr, pos: usize, hidden_size: usize) -> DevicePtr {
325 base.offset(pos * hidden_size * 2)
326}
327
328/// The hidden-row interval, and WHOSE rows they are.
329///
330/// `mtp_prefill_hidden` is one model-level buffer indexed by ABSOLUTE sequence
331/// position, with no per-sequence dimension, so an interval alone cannot say
332/// who wrote the rows it covers. `gen` is that missing half.
333///
334/// ★ `gen == 0` NEVER MATCHES. It is the state of a range nothing has claimed,
335/// and of every `SequenceState` built outside `alloc_sequence` (the mock and
336/// test fakes, which draw no ticket). Treating it as a wildcard would reopen
337/// the hole for exactly those constructors.
338#[derive(Clone, Copy, Debug, PartialEq, Eq)]
339pub struct StoreRange {
340 /// The sequence generation that wrote these rows. 0 = unclaimed.
341 /// Named `owner` because `gen` is a reserved keyword in edition 2024.
342 pub owner: u64,
343 /// First absolute position written.
344 pub lo: usize,
345 /// One past the last absolute position written.
346 pub hi: usize,
347}
348
349impl StoreRange {
350 /// Nothing claimed.
351 pub const EMPTY: Self = Self {
352 owner: 0,
353 lo: 0,
354 hi: 0,
355 };
356
357 /// The interval `reader_gen` may read, or `(0, 0)` when these rows belong
358 /// to someone else.
359 ///
360 /// `(0, 0)` rather than an error because `plan_append` already refuses an
361 /// interval that does not cover the span it needs — an empty interval
362 /// covers nothing, so a foreign range degrades to the existing
363 /// `CarryOutcome::NoHiddens` with no new control flow.
364 pub fn visible_to(self, reader_gen: u64) -> (usize, usize) {
365 if reader_gen != 0 && self.owner == reader_gen {
366 (self.lo, self.hi)
367 } else {
368 (0, 0)
369 }
370 }
371}
372
373/// Merge a write into the interval, or take it over.
374///
375/// Same owner: [`merge_interval`], unchanged. Different owner (or an unclaimed
376/// range): the writer REPLACES the interval with its own write and stamps it.
377///
378/// ★ REPLACE IS THE WHOLE POINT. Without it, `merge_interval` extends across
379/// owners whenever the new chunk starts below the other sequence's high-water
380/// mark, producing one interval whose low half is another request's rows —
381/// which the `alloc_sequence` reset cannot catch, because the reset happened
382/// before that other sequence wrote.
383///
384/// Coupled to `merge_interval`'s replace-on-a-gap behaviour for the SAME-owner
385/// case: if that is ever relaxed to span a gap, this inherits the defect. It is
386/// pinned by `merge_interval_replaces_on_a_gap`.
387pub fn stamped_merge(cur: StoreRange, writer_gen: u64, start: usize, count: usize) -> StoreRange {
388 if cur.owner != 0 && cur.owner == writer_gen {
389 let (lo, hi) = merge_interval((cur.lo, cur.hi), start, count);
390 StoreRange {
391 owner: writer_gen,
392 lo,
393 hi,
394 }
395 } else {
396 StoreRange {
397 owner: writer_gen,
398 lo: start,
399 hi: start + count,
400 }
401 }
402}
403
404/// Merge a write of `[start, start + count)` into a single contiguous validity
405/// interval `[lo, hi)`. Overlapping or abutting writes extend it; a disjoint
406/// write REPLACES it, because one interval cannot describe two islands and
407/// silently claiming the gap would hand the drafter another turn's hiddens.
408pub fn merge_interval(cur: (usize, usize), start: usize, count: usize) -> (usize, usize) {
409 let (lo, hi) = cur;
410 let (ns, ne) = (start, start + count);
411 if hi > lo && ns <= hi && ne >= lo {
412 (lo.min(ns), hi.max(ne))
413 } else {
414 (ns, ne)
415 }
416}
417
418/// Result of a carry attempt, for logging and tests.
419#[derive(Debug, PartialEq, Eq)]
420pub enum CarryOutcome {
421 Adopted {
422 rows: usize,
423 appended: usize,
424 first_key: usize,
425 },
426 NoCarry,
427 PrefixMismatch {
428 common: usize,
429 entry_rows: usize,
430 },
431 /// The hidden rows in the store belong to another sequence. Reported for
432 /// the debug log only — control flow degrades to `NoHiddens`, since an
433 /// invisible interval covers nothing.
434 ForeignHiddens {
435 /// The generation that owns the rows.
436 owner: u64,
437 /// The generation that wanted to read them.
438 expected: u64,
439 },
440 /// The slot held another session's rows (or this request carries no
441 /// session stamp). Distinct from `PrefixMismatch` on purpose: a prefix
442 /// mismatch is a re-tokenized turn boundary and is expected, while this is
443 /// the cross-request channel being refused, and reading one as the other
444 /// is how it stayed open.
445 ForeignSession {
446 entry_session: u64,
447 prompt_session: u64,
448 },
449 NoHiddens,
450}
451
452impl std::fmt::Display for CarryOutcome {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 match self {
455 CarryOutcome::Adopted {
456 rows,
457 appended,
458 first_key,
459 } => write!(
460 f,
461 "adopted rows={rows} appended={appended} first_key={first_key}"
462 ),
463 CarryOutcome::NoCarry => write!(f, "no carried state"),
464 CarryOutcome::PrefixMismatch { common, entry_rows } => {
465 write!(
466 f,
467 "prefix mismatch (common={common} entry_rows={entry_rows})"
468 )
469 }
470 CarryOutcome::ForeignSession {
471 entry_session,
472 prompt_session,
473 } => write!(
474 f,
475 "foreign session (entry={entry_session:#x} prompt={prompt_session:#x})"
476 ),
477 CarryOutcome::ForeignHiddens { owner, expected } => write!(
478 f,
479 "hidden rows belong to sequence gen {owner}, not {expected}"
480 ),
481 CarryOutcome::NoHiddens => write!(f, "hidden store does not cover the append span"),
482 }
483 }
484}
485
486#[cfg(test)]
487#[path = "mtp_carry_tests.rs"]
488mod tests;