metrale_speculative/
mtp_gate.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The MTP runtime gate: it chooses, step by step, between the MTP
4//! verify step and plain decode by the delivered throughput (emitted tokens
5//! per second of step wall time) each mode measured over windows of
6//! `WINDOW_STEPS` steps.
7//!
8//! Owner: speculative.
9//! Invariants:
10//! - The mode changes only in `arbitrate`, and only when both modes have an
11//!   estimate, the other mode's is not stale, and it beat the current mode by
12//!   the margin in `SWITCH_DWELL_WINDOWS` consecutive arbitrations.
13//! - A window never mixes power-of-two batch-width buckets: `note_width`
14//!   discards the partial window before a step from a new bucket is added.
15//!
16//! Policy:
17//! - Run the current mode, and fold each closed window into that mode's
18//!   tok/s EWMA and deviation EWMA (`ModeStats::update`).
19//! - In Serial mode, probe MTP for one window after `reprobe_tokens`
20//!   recorded tokens; in Mtp mode, refresh the serial estimate after
21//!   `serial_refresh_tokens`.
22//! - A depth-regime change (factor `REMEASURE_DEPTH_FACTOR`, floor
23//!   `REMEASURE_DEPTH_FLOOR`, `maybe_remeasure`) or a change of
24//!   power-of-two batch-width bucket (`note_width`) marks both estimates
25//!   stale and brings the next probe forward. A stale other-mode estimate
26//!   cannot win a switch.
27//! - A plain decode step over n sequences is charged n tokens, and a verify
28//!   step the tokens all its sequences emitted, so both modes are measured as
29//!   whole-batch throughput.
30//!
31//! With `--mtp-gate force` (or `METRALE_MTP_GATE_FORCE`) the scheduler builds
32//! no gate and runs the MTP step whenever speculation is eligible.
33
34use std::time::Duration;
35
36mod driver;
37
38/// 2026-09-25: A context depth that has grown or shrunk by this factor since
39/// the last measurement marks both estimates stale.
40const REMEASURE_DEPTH_FACTOR: usize = 2;
41/// 2026-09-25: Both depths are raised to this floor before the comparison,
42/// so contexts below it never change regime among themselves.
43const REMEASURE_DEPTH_FLOOR: usize = 512;
44/// 2026-09-25: Steps per throughput window.
45const WINDOW_STEPS: usize = 16;
46/// 2026-09-25: Consecutive arbitrations the other mode must win by the
47/// margin before the gate switches to it.
48const SWITCH_DWELL_WINDOWS: usize = 2;
49/// 2026-09-25: EWMA weight of a new window in the per-mode tok/s estimate
50/// (effective window about 3 windows).
51const TPS_ALPHA: f64 = 0.3;
52/// 2026-09-25: The switch margin is the larger of this fraction of the
53/// current mode's estimate and half the sum of the two deviation EWMAs.
54const MARGIN_REL_FLOOR: f64 = 0.05;
55
56fn env_usize(name: &str, default: usize) -> usize {
57    std::env::var(name)
58        .ok()
59        .and_then(|v| v.parse().ok())
60        .unwrap_or(default)
61}
62
63/// 2026-09-25: Tokens between MTP probes in Serial mode:
64/// `METRALE_MTP_GATE_REPROBE`, default 256 (the same default as
65/// `METRALE_DFLASH_ADAPTIVE_REPROBE`).
66fn reprobe_tokens() -> usize {
67    env_usize("METRALE_MTP_GATE_REPROBE", 256)
68}
69
70/// 2026-09-25: Tokens between serial-estimate refreshes in Mtp mode:
71/// `METRALE_MTP_GATE_REFRESH`, default 1024. 2026-09-26: per sequence of the
72/// batch-width bucket, so the refresh comes after the same number of steps at
73/// every width (`MtpGate::event_interval`).
74fn serial_refresh_tokens() -> usize {
75    env_usize("METRALE_MTP_GATE_REFRESH", 1024)
76}
77
78/// 2026-09-25: The spec-entry verify pin, in tokens after `</think>`, from
79/// the value of `METRALE_SPEC_ENTRY_PIN`: 8 when unset or unparseable, and
80/// `0` disables it. While any active sequence is within it, the scheduler
81/// runs the MTP verify step even when the gate chose plain decode.
82///
83/// The serial (one-row) and verify (batch-K) forwards can pick different
84/// low-margin tokens at T=0, and the gate picks between them by wall-clock
85/// throughput, so without the pin the first tokens of an answer would depend
86/// on the gate's measurements. Measured 2026-07-07/08: every observed flip
87/// between the two forwards fell within 7 tokens of speculation entry; 8
88/// adds one token of margin.
89///
90/// `METRALE_DFLASH_RESUME_GUARD` is checked earlier, in
91/// `spec_dispatch_eligible`: outside `<think>`, a sequence with fewer tokens
92/// after `</think>` than the guard does not reach the gate at all.
93pub(crate) fn parse_entry_pin_tokens(env: Option<&str>) -> u32 {
94    env.and_then(|v| v.parse().ok()).unwrap_or(8)
95}
96
97/// 2026-09-25: The pin width from `METRALE_SPEC_ENTRY_PIN`. The scheduler
98/// stores it in `SchedLevers::spec_entry_pin_tokens`.
99pub fn entry_pin_tokens_from_env() -> u32 {
100    parse_entry_pin_tokens(std::env::var("METRALE_SPEC_ENTRY_PIN").ok().as_deref())
101}
102
103/// 2026-09-25: Whether the spec-entry pin overrides a Serial gate decision
104/// for this step. `min_post_think_emitted` is the minimum over the active
105/// batch, so one entering sequence pins the whole (already spec-eligible)
106/// batch. `pin_tokens` is the run's [`entry_pin_tokens_from_env`] value.
107pub fn entry_pin_forces_verify(min_post_think_emitted: u32, pin_tokens: u32) -> bool {
108    min_post_think_emitted < pin_tokens
109}
110
111/// 2026-09-25: Whether one sequence may take the speculative path this step.
112/// Never with `suppress_tool_call` or `disable_mtp`, and never inside
113/// `<think>` unless `spec_think` (`METRALE_DFLASH_SPEC_THINK=1`), for MTP and
114/// DFlash alike. Otherwise it needs `resume_guard` emitted tokens: counted
115/// after `</think>`, except inside `<think>` (reachable only with
116/// `spec_think`), where the whole output counts.
117pub fn spec_dispatch_eligible(
118    inside_thinking: bool,
119    post_think_emitted: u32,
120    output_len: u32,
121    suppress_tool_call: bool,
122    disable_mtp: bool,
123    spec_think: bool,
124    resume_guard: u32,
125    dflash_raw_argmax: bool,
126) -> bool {
127    if suppress_tool_call || disable_mtp {
128        return false;
129    }
130    // 2026-09-25: Batch-K verify can pick a different low-margin token than
131    // serial decode at T=0 (see `parse_entry_pin_tokens`).
132    if inside_thinking && !spec_think {
133        return false;
134    }
135    if dflash_raw_argmax && !spec_think {
136        return post_think_emitted >= resume_guard;
137    }
138    if inside_thinking {
139        output_len >= resume_guard
140    } else {
141        post_think_emitted >= resume_guard
142    }
143}
144
145/// 2026-09-25: The step the gate wants the scheduler to run next.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum GateStep {
148    /// 2026-09-25: Plain decode (Serial mode, or a serial refresh from Mtp).
149    MeasureDecode,
150    /// 2026-09-25: MTP step (Mtp mode, or a probe from Serial).
151    MeasureVerify,
152}
153
154/// 2026-09-25: A mode switch, handed to the scheduler once
155/// (`take_fresh_decision`).
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum GateDecision {
158    /// 2026-09-25: Switched to Mtp. The scheduler does nothing: the next MTP
159    /// step bootstraps from empty drafts.
160    KeepMtp,
161    /// 2026-09-25: Switched to Serial. The scheduler clears pending drafts
162    /// and synchronises the secondary stream.
163    DisableMtp,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167enum Mode {
168    Mtp,
169    Serial,
170}
171
172#[derive(Default)]
173struct ModeStats {
174    /// 2026-09-25: Delivered-throughput EWMA (tokens/sec), `None` until the
175    /// first window.
176    tps: Option<f64>,
177    /// 2026-09-25: EWMA of |window tps − updated tps|, for the switch margin.
178    dev: f64,
179    /// 2026-09-25: Set by a depth or width regime change; cleared by the next
180    /// window folded in.
181    stale: bool,
182    /// 2026-09-26: Windows folded in so far, so an arbitration can tell a new
183    /// measurement from a repeat of the last one.
184    windows: u64,
185}
186
187impl ModeStats {
188    /// 2026-09-25: Folds one closed window into the estimate. With `replace`
189    /// (a probe window, or the first window after the estimate went stale)
190    /// the window replaces the estimate and `dev` is halved: blending a
191    /// possibly drifted baseline would add the drift to `dev`. Consecutive
192    /// windows of the running mode blend as an EWMA.
193    fn update(&mut self, window_tps: f64, replace: bool) {
194        match (self.tps, replace) {
195            (None, _) | (_, true) => {
196                self.tps = Some(window_tps);
197                self.dev *= 0.5;
198            }
199            (Some(prev), false) => {
200                let next = (1.0 - TPS_ALPHA) * prev + TPS_ALPHA * window_tps;
201                self.dev = (1.0 - TPS_ALPHA) * self.dev + TPS_ALPHA * (window_tps - next).abs();
202                self.tps = Some(next);
203            }
204        }
205        self.stale = false;
206        self.windows += 1;
207    }
208}
209
210/// 2026-09-25: The gate. The scheduler builds one when MTP is on and the gate
211/// is not forced off, and records each gated plain-decode or MTP step in it;
212/// steps run under a pin are not recorded.
213pub struct MtpGate {
214    /// 2026-09-25: `reprobe_tokens`, read once.
215    reprobe: usize,
216    /// 2026-09-25: `serial_refresh_tokens`, read once.
217    refresh: usize,
218    mode: Mode,
219    /// 2026-09-25: True while the gate runs the other mode for a probe.
220    probing: bool,
221    /// 2026-09-25: Windows remaining in the current probe.
222    probe_windows_left: usize,
223    mtp: ModeStats,
224    serial: ModeStats,
225    // 2026-09-25: Accumulators of the open window, for whichever mode ran.
226    win_tokens: f64,
227    win_wall: f64,
228    win_steps: usize,
229    /// 2026-09-25: Consecutive arbitrations in which the other mode beat the
230    /// current one by more than the margin. 2026-09-26: Only arbitrations on a
231    /// new measurement of the other mode count.
232    losing_windows: usize,
233    /// 2026-09-26: The other mode's `windows` at the last counted loss.
234    loss_other_windows: u64,
235    /// 2026-09-25: Tokens recorded outside probes since the last probe or
236    /// switch; a regime change raises it to the event interval.
237    tokens_since_event: usize,
238    observed_depth: usize,
239    measured_at_depth: usize,
240    /// 2026-09-25: Power-of-two batch-width bucket of the last recorded step
241    /// (0 before any). See [`Self::note_width`].
242    width_regime: usize,
243    fresh: Option<GateDecision>,
244    /// 2026-09-26: Steps still to be left out of every window after a
245    /// batch-width bucket change ([`Self::note_width`]).
246    settle_steps: usize,
247    /// 2026-09-25: Depth-regime changes seen by this gate.
248    regime_reprobes: usize,
249}
250
251impl MtpGate {
252    /// 2026-09-25: Tracks the batch-width bucket (`next_power_of_two`).
253    /// Throughput at different widths is not comparable, so a bucket change
254    /// discards the open window, marks both estimates stale and brings the
255    /// next probe forward, as [`Self::maybe_remeasure`] does for depth.
256    /// Widths within one bucket are treated as the same regime.
257    fn note_width(&mut self, width: usize) {
258        let regime = width.next_power_of_two();
259        if regime == self.width_regime {
260            return;
261        }
262        if self.width_regime != 0 {
263            tracing::info!(
264                "MTP gate: batch-width regime changed ({} -> {regime}); partial window \
265                 discarded, baselines stale, will re-probe on cadence",
266                self.width_regime,
267            );
268            self.mtp.stale = true;
269            self.serial.stale = true;
270            self.win_tokens = 0.0;
271            self.win_wall = 0.0;
272            self.win_steps = 0;
273            self.losing_windows = 0;
274            // 2026-09-26: The interval of the new bucket, which the Mtp-mode
275            // refresh scales with.
276            self.width_regime = regime;
277            self.tokens_since_event = self.tokens_since_event.max(self.event_interval());
278            // 2026-09-26: A sequence that joined (or left) is bootstrapping its
279            // drafts and the batch is still reshaping, so the next window's
280            // steps would not measure the new regime's steady state.
281            self.settle_steps = WINDOW_STEPS;
282        }
283        self.width_regime = regime;
284    }
285
286    fn other(m: Mode) -> Mode {
287        match m {
288            Mode::Mtp => Mode::Serial,
289            Mode::Serial => Mode::Mtp,
290        }
291    }
292
293    /// 2026-09-25: Tokens recorded outside probes before the next probe.
294    /// 2026-09-26: In Mtp mode the refresh interval is per sequence: it scales
295    /// with the batch-width bucket, because a step at width W records W times
296    /// the tokens. Unscaled, the one-window serial refresh came every
297    /// 1024 / (W * tokens per step) steps, a third of all steps at W = 16 (the
298    /// `serial` share of the MTP Done lines). Serial mode's MTP re-probe keeps
299    /// its token interval, so a gate that chose serial re-tests MTP as often.
300    fn event_interval(&self) -> usize {
301        match self.mode {
302            Mode::Mtp => self.refresh * self.width_regime.max(1),
303            Mode::Serial => self.reprobe,
304        }
305    }
306
307    fn stats_mut(&mut self, m: Mode) -> &mut ModeStats {
308        match m {
309            Mode::Mtp => &mut self.mtp,
310            Mode::Serial => &mut self.serial,
311        }
312    }
313
314    fn record_step(&mut self, wall: Duration, tokens: usize) {
315        // 2026-09-26: The settle steps after a width change count toward
316        // nothing, not even the probe cadence.
317        if self.settle_steps > 0 {
318            self.settle_steps -= 1;
319            return;
320        }
321        self.win_tokens += tokens as f64;
322        self.win_wall += wall.as_secs_f64();
323        self.win_steps += 1;
324        if !self.probing {
325            self.tokens_since_event += tokens;
326        }
327        if self.win_steps >= WINDOW_STEPS {
328            self.close_window();
329        } else if !self.probing
330            && self.tokens_since_event >= self.event_interval()
331            && !self.stats_mut(self.mode).stale
332        {
333            // 2026-09-25: A probe is due: close the window early so the
334            // probe starts on empty accumulators. 2026-09-26: Not while the
335            // running mode's estimate is stale: the window that replaces it
336            // must be a full one, or one step decides the next arbitration.
337            // A partial window shorter than half a window is dropped rather
338            // than blended in.
339            if self.win_steps < WINDOW_STEPS / 2 {
340                self.win_tokens = 0.0;
341                self.win_wall = 0.0;
342                self.win_steps = 0;
343            }
344            self.close_window();
345        }
346    }
347
348    fn close_window(&mut self) {
349        let ran = if self.probing {
350            Self::other(self.mode)
351        } else {
352            self.mode
353        };
354        if self.win_wall > 0.0 && self.win_steps > 0 {
355            let window_tps = self.win_tokens / self.win_wall;
356            let replace = self.probing || self.stats_mut(ran).stale;
357            self.stats_mut(ran).update(window_tps, replace);
358        }
359        self.win_tokens = 0.0;
360        self.win_wall = 0.0;
361        self.win_steps = 0;
362
363        if self.probing {
364            self.probe_windows_left = self.probe_windows_left.saturating_sub(1);
365            if self.probe_windows_left == 0 {
366                self.probing = false;
367                // 2026-09-26: Reset before arbitrating, which may make a
368                // confirming probe due at once.
369                self.tokens_since_event = 0;
370                self.arbitrate();
371            }
372            return;
373        }
374
375        if self.tokens_since_event >= self.event_interval() {
376            self.probing = true;
377            self.probe_windows_left = 1;
378            return;
379        }
380        self.arbitrate();
381    }
382
383    /// 2026-09-25: Compares the two estimates with the margin and switches
384    /// after [`SWITCH_DWELL_WINDOWS`] consecutive losses.
385    fn arbitrate(&mut self) {
386        let (Some(mtp), Some(serial)) = (self.mtp.tps, self.serial.tps) else {
387            return;
388        };
389        // 2026-09-25: A stale other-mode estimate predates a depth or width
390        // regime change. Wait for the early probe to refresh it rather than
391        // switch on it.
392        let other_stale = match self.mode {
393            Mode::Mtp => self.serial.stale,
394            Mode::Serial => self.mtp.stale,
395        };
396        if other_stale {
397            return;
398        }
399        let (cur, other, other_dev) = match self.mode {
400            Mode::Mtp => (mtp, serial, self.serial.dev),
401            Mode::Serial => (serial, mtp, self.mtp.dev),
402        };
403        let margin = (MARGIN_REL_FLOOR * cur).max(0.5 * (self.dev_of(self.mode) + other_dev));
404        if other > cur + margin {
405            // 2026-09-26: A loss counts toward the dwell only on a measurement
406            // of the other mode newer than the last counted loss's: two
407            // arbitrations against one probe window are one sample, and one
408            // noisy serial window (140-180 tok/s at C=4 on GB10) switched the
409            // gate. After a loss short of the dwell, the confirming probe is
410            // due at once.
411            let other_windows = match self.mode {
412                Mode::Mtp => self.serial.windows,
413                Mode::Serial => self.mtp.windows,
414            };
415            if self.losing_windows > 0 && other_windows == self.loss_other_windows {
416                return;
417            }
418            self.losing_windows += 1;
419            self.loss_other_windows = other_windows;
420            if self.losing_windows < SWITCH_DWELL_WINDOWS {
421                self.tokens_since_event = self.tokens_since_event.max(self.event_interval());
422            }
423            if self.losing_windows >= SWITCH_DWELL_WINDOWS {
424                let to = Self::other(self.mode);
425                tracing::info!(
426                    "MTP gate: switching {:?} -> {:?} (current {cur:.1} tok/s vs other \
427                     {other:.1} tok/s, margin {margin:.1}, depth={})",
428                    self.mode,
429                    to,
430                    self.observed_depth,
431                );
432                self.mode = to;
433                self.losing_windows = 0;
434                self.tokens_since_event = 0;
435                self.measured_at_depth = self.observed_depth;
436                self.fresh = Some(match to {
437                    Mode::Mtp => GateDecision::KeepMtp,
438                    Mode::Serial => GateDecision::DisableMtp,
439                });
440            }
441        } else {
442            self.losing_windows = 0;
443        }
444    }
445
446    fn dev_of(&self, m: Mode) -> f64 {
447        match m {
448            Mode::Mtp => self.mtp.dev,
449            Mode::Serial => self.serial.dev,
450        }
451    }
452
453    pub fn mtp_tps_debug(&self) -> Option<f64> {
454        self.mtp.tps
455    }
456    pub fn serial_tps_debug(&self) -> Option<f64> {
457        self.serial.tps
458    }
459    pub fn in_serial_mode(&self) -> bool {
460        self.mode == Mode::Serial
461    }
462    pub fn regime_reprobe_count(&self) -> usize {
463        self.regime_reprobes
464    }
465    pub fn is_probing(&self) -> bool {
466        self.probing
467    }
468}
469
470#[cfg(test)]
471mod tests;