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;