metrale_speculative/adaptive_rung.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Adaptive MTP draft count for batch widths 9..=16: one or two
4//! drafts, chosen from the observed accept statistics, with the static ladder
5//! (`metrale_model_layers::speculative::mtp_ladder_drafts`) as the floor.
6//!
7//! Owner: speculative.
8//! Invariants:
9//! - `drafts_for` never returns fewer drafts than the static ladder, and
10//! returns exactly the ladder's count outside 9..=16, when
11//! `RungParams::disabled`, when `METRALE_NO_MTP_K_LADDER` is set, or when
12//! `num_drafts < 2`.
13//! - `observe` changes no state for widths outside 9..=16 or when disabled.
14//!
15//! With `p1` the first-draft accept rate and `p2_cond` the second-draft accept
16//! rate given an accepted first draft, two drafts yield `token_ratio(p1,
17//! p2_cond) = 1 + p1*p2_cond/(1 + p1)` times the tokens per verify step of one
18//! draft. The controller moves to two drafts when the smoothed ratio reaches
19//! `enter` and back to one when it falls below `leave` (`next_state`). The
20//! estimates are EWMAs seeded with their first sample. With the default
21//! tiered verify pools the scheduler clamps the lift back to one draft
22//! (`spec_capacity::clamp_drafts_to_slot_capacity`; see that module).
23//!
24//! At one draft nothing proposes a second token, so `p2_cond` cannot be
25//! observed there. The controller then runs two drafts for a flush (a probe)
26//! only on evidence or after a long backstop; see `drafts_for`. Measured
27//! 2026-08-01 on dgx1 at C=16 from flush timestamps: a steady flush took
28//! 1.16 s and the flush that entered a probe 2.90 s, so a probe cost about
29//! 1.74 s.
30
31use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
32
33/// 2026-09-25: The batch widths the controller adapts. Every other width
34/// keeps the static ladder's count.
35const BAND: std::ops::RangeInclusive<usize> = 9..=16;
36
37/// 2026-09-25: Move to two drafts when the smoothed token ratio is at or
38/// above this. Overridden by `METRALE_MTP_RUNG_ENTER`.
39const ENTER: f64 = 1.32;
40/// 2026-09-25: Move back to one draft when the smoothed token ratio falls
41/// below this. Overridden by `METRALE_MTP_RUNG_LEAVE`.
42const LEAVE: f64 = 1.30;
43/// 2026-09-25: EWMA weight of the `p1` and `p2_cond` estimates the decision
44/// reads (effective window `1/ALPHA` = 2 flushes). Overridden by
45/// `METRALE_MTP_RUNG_ALPHA`, capped at 1.
46const ALPHA: f64 = 0.5;
47/// 2026-09-25: EWMA weight of the `p1` estimate the probe trigger reads
48/// (effective window about 7 flushes). It is slower than [`ALPHA`] so that
49/// flush-to-flush noise in `p1` does not buy probes. Overridden by
50/// `METRALE_MTP_RUNG_ALPHA_SLOW`, capped at 1.
51const ALPHA_SLOW: f64 = 0.15;
52/// 2026-09-25: Backstop probe interval in flushes, for a `p2_cond` drift at
53/// constant `p1` that [`P1_TRIGGER`] cannot see. With the probe and flush
54/// times measured 2026-08-01 (1.74 s and 1.16 s, module doc), 2048 flushes is
55/// one probe per about 40 minutes, about 0.07% of the time. Overridden by
56/// `METRALE_MTP_RUNG_PROBE_TICKS`.
57const PROBE_TICKS: u64 = 2048;
58/// 2026-09-25: Probe when the slow `p1` EWMA has risen this far above its
59/// value at the last depth flush. Overridden by `METRALE_MTP_RUNG_P1_TRIGGER`.
60const P1_TRIGGER: f64 = 0.08;
61
62/// 2026-09-25: The controller's thresholds, fixed for the life of one
63/// `AdaptiveRung`.
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub struct RungParams {
66 /// 2026-09-25: Pins the static ladder. `from_env` sets it when
67 /// `METRALE_MTP_STATIC_RUNG` is present (any value) or the serve runs an explicit
68 /// ladder (`speculative::mtp_ladder_pinned`: `--mtp-k-ladder` or
69 /// `METRALE_MTP_K_LADDER`): an operator who spells out the rungs gets exactly those
70 /// rungs.
71 pub disabled: bool,
72 pub enter: f64,
73 pub leave: f64,
74 pub alpha: f64,
75 pub alpha_slow: f64,
76 pub probe_ticks: u64,
77 pub p1_trigger: f64,
78}
79
80impl RungParams {
81 /// 2026-09-25: The compiled constants, with adaptation on.
82 pub const DEFAULTS: Self = Self {
83 disabled: false,
84 enter: ENTER,
85 leave: LEAVE,
86 alpha: ALPHA,
87 alpha_slow: ALPHA_SLOW,
88 probe_ticks: PROBE_TICKS,
89 p1_trigger: P1_TRIGGER,
90 };
91
92 /// 2026-09-25: Reads the environment. A tunable whose value does not
93 /// parse as a finite positive number keeps its constant (`tunable`).
94 pub fn from_env() -> Self {
95 Self {
96 disabled: std::env::var_os("METRALE_MTP_STATIC_RUNG").is_some()
97 || metrale_model_layers::speculative::mtp_ladder_pinned(),
98 enter: tunable("METRALE_MTP_RUNG_ENTER", ENTER),
99 leave: tunable("METRALE_MTP_RUNG_LEAVE", LEAVE),
100 alpha: tunable("METRALE_MTP_RUNG_ALPHA", ALPHA).min(1.0),
101 alpha_slow: tunable("METRALE_MTP_RUNG_ALPHA_SLOW", ALPHA_SLOW).min(1.0),
102 probe_ticks: tunable("METRALE_MTP_RUNG_PROBE_TICKS", PROBE_TICKS as f64) as u64,
103 p1_trigger: tunable("METRALE_MTP_RUNG_P1_TRIGGER", P1_TRIGGER),
104 }
105 }
106}
107
108/// 2026-09-25: `var` parsed as `f64` when it is finite and positive,
109/// otherwise `default`.
110fn tunable(var: &str, default: f64) -> f64 {
111 std::env::var(var)
112 .ok()
113 .and_then(|v| v.parse::<f64>().ok())
114 .filter(|v| v.is_finite() && *v > 0.0)
115 .unwrap_or(default)
116}
117
118/// 2026-09-25: Expected tokens per verify step at two drafts relative to one:
119/// `(1 + p1 + p1*p2_cond) / (1 + p1)`. Returns 1.0 when `p1 <= 0` or either
120/// input is not finite.
121pub fn token_ratio(p1: f64, p2_cond: f64) -> f64 {
122 if p1 <= 0.0 || !p1.is_finite() || !p2_cond.is_finite() {
123 return 1.0;
124 }
125 1.0 + p1 * p2_cond / (1.0 + p1)
126}
127
128/// 2026-09-25: The second-draft conditional accept implied by a flush at two
129/// drafts, where `mean_na = p1 + p1*p2_cond`: `(mean_na - p1) / p1`, clamped
130/// to `[0, 1]`. Deeper drafts are not modelled. `None` when `p1 <= 0`.
131pub fn p2_cond_from(p1: f64, mean_na: f64) -> Option<f64> {
132 (p1 > 0.0).then(|| ((mean_na - p1) / p1).clamp(0.0, 1.0))
133}
134
135/// 2026-09-25: The next state from the current one and the smoothed token
136/// ratio; `true` means two drafts.
137pub fn next_state(at_depth: bool, tr: f64, p: &RungParams) -> bool {
138 if at_depth {
139 tr >= p.leave
140 } else {
141 tr >= p.enter
142 }
143}
144
145struct Ctl {
146 p1: AtomicU64,
147 /// 2026-09-25: Slow-EWMA `p1`, read only by the probe trigger in
148 /// `drafts_for`.
149 p1_slow: AtomicU64,
150 p2: AtomicU64,
151 seeded_p1: AtomicBool,
152 seeded_p1_slow: AtomicBool,
153 seeded_p2: AtomicBool,
154 at_depth: AtomicBool,
155 tick: AtomicU64,
156 last_probe: AtomicU64,
157 /// 2026-09-25: Slow-EWMA `p1` at the last depth flush (`k_drafts >= 2`).
158 p1_at_decision: AtomicU64,
159 flips: AtomicU64,
160}
161
162/// 2026-09-25: The controller. Each scheduler context owns one
163/// (`SchedCtx::rung`), so accept history is not shared between contexts.
164pub struct AdaptiveRung {
165 params: RungParams,
166 ctl: Ctl,
167 /// 2026-09-25: The last `engaged` passed to `note_width_regime`; starts
168 /// `true`.
169 width_engaged: AtomicBool,
170 /// 2026-09-25: Number of changes of `width_engaged`.
171 width_flips: AtomicU64,
172}
173
174fn ewma_a(cell: &AtomicU64, seeded: &AtomicBool, sample: f64, a: f64) -> f64 {
175 let prev = f64::from_bits(cell.load(Ordering::Relaxed));
176 let next = if seeded.swap(true, Ordering::Relaxed) {
177 a * sample + (1.0 - a) * prev
178 } else {
179 sample
180 };
181 cell.store(next.to_bits(), Ordering::Relaxed);
182 next
183}
184
185impl AdaptiveRung {
186 pub const fn new(params: RungParams) -> Self {
187 Self {
188 params,
189 ctl: Ctl {
190 p1: AtomicU64::new(0),
191 p1_slow: AtomicU64::new(0),
192 p2: AtomicU64::new(0),
193 seeded_p1: AtomicBool::new(false),
194 seeded_p1_slow: AtomicBool::new(false),
195 seeded_p2: AtomicBool::new(false),
196 at_depth: AtomicBool::new(false),
197 tick: AtomicU64::new(0),
198 last_probe: AtomicU64::new(0),
199 p1_at_decision: AtomicU64::new(0),
200 flips: AtomicU64::new(0),
201 },
202 width_engaged: AtomicBool::new(true),
203 width_flips: AtomicU64::new(0),
204 }
205 }
206
207 pub fn from_env() -> Self {
208 Self::new(RungParams::from_env())
209 }
210
211 pub fn params(&self) -> &RungParams {
212 &self.params
213 }
214
215 /// 2026-09-25: `true` while the controller's state is two drafts.
216 pub fn at_depth(&self) -> bool {
217 self.ctl.at_depth.load(Ordering::Relaxed)
218 }
219
220 /// 2026-09-25: Number of state changes so far.
221 pub fn flips(&self) -> u64 {
222 self.ctl.flips.load(Ordering::Relaxed)
223 }
224
225 pub fn width_engaged(&self) -> bool {
226 self.width_engaged.load(Ordering::Relaxed)
227 }
228
229 pub fn width_flips(&self) -> u64 {
230 self.width_flips.load(Ordering::Relaxed)
231 }
232
233 /// 2026-09-25: Feeds one accept-statistics flush at batch width `n`:
234 /// `k_drafts` is the flush's largest draft depth, `p1` its first-draft
235 /// accept rate, `mean_na` its mean accepted drafts per verify. The
236 /// scheduler's `AcceptBuckets::record` is the caller; this type keeps no
237 /// accept counters of its own.
238 pub fn observe(&self, n: usize, k_drafts: usize, p1: f64, mean_na: f64) {
239 let (p, c) = (&self.params, &self.ctl);
240 if p.disabled || !BAND.contains(&n) {
241 return;
242 }
243 let p1_e = ewma_a(&c.p1, &c.seeded_p1, p1, p.alpha);
244 let p1_slow = ewma_a(&c.p1_slow, &c.seeded_p1_slow, p1, p.alpha_slow);
245 let tick = c.tick.fetch_add(1, Ordering::Relaxed) + 1;
246 if k_drafts >= 2 {
247 // 2026-09-25: A depth flush observes p2_cond and resets both probe
248 // triggers read by `drafts_for` (`last_probe`, `p1_at_decision`).
249 c.last_probe.store(tick, Ordering::Relaxed);
250 c.p1_at_decision.store(p1_slow.to_bits(), Ordering::Relaxed);
251 if let Some(p2) = p2_cond_from(p1, mean_na) {
252 let p2_e = ewma_a(&c.p2, &c.seeded_p2, p2, p.alpha);
253 let tr = token_ratio(p1_e, p2_e);
254 let was = c.at_depth.load(Ordering::Relaxed);
255 let now = next_state(was, tr, p);
256 if now != was {
257 c.at_depth.store(now, Ordering::Relaxed);
258 let flips = c.flips.fetch_add(1, Ordering::Relaxed) + 1;
259 tracing::info!(
260 "MTP rung n={n} -> k_drafts={} (token_ratio={tr:.4} p1={p1_e:.3} \
261 p2_cond={p2_e:.3} enter={:.3} leave={:.3} tick={tick} flips={flips})",
262 if now { 2 } else { 1 },
263 p.enter,
264 p.leave,
265 );
266 }
267 }
268 }
269 }
270
271 /// 2026-09-25: The draft count for a batch of `n_active`: the static
272 /// ladder's count, raised to `min(2, num_drafts)` inside the adapted band
273 /// while the state is two drafts or a probe is due.
274 pub fn drafts_for(&self, n_active: usize, num_drafts: usize) -> usize {
275 let (p, c) = (&self.params, &self.ctl);
276 let base = metrale_model_layers::speculative::mtp_ladder_drafts(n_active, num_drafts);
277 if p.disabled
278 || metrale_model_layers::speculative::mtp_ladder_disabled()
279 || num_drafts < 2
280 || !BAND.contains(&n_active)
281 {
282 return base;
283 }
284 // 2026-09-25: A probe is due when p2_cond has never been observed,
285 // when the slow p1 has risen by `p1_trigger` since the last depth
286 // flush, or when `probe_ticks` flushes have passed since it. Only a
287 // rise counts: `token_ratio` increases with p1, so a fall cannot make
288 // two drafts newly worthwhile.
289 let tick = c.tick.load(Ordering::Relaxed);
290 let p1_moved = f64::from_bits(c.p1_slow.load(Ordering::Relaxed))
291 - f64::from_bits(c.p1_at_decision.load(Ordering::Relaxed))
292 >= p.p1_trigger;
293 let probing = !c.seeded_p2.load(Ordering::Relaxed)
294 || p1_moved
295 || tick.saturating_sub(c.last_probe.load(Ordering::Relaxed)) >= p.probe_ticks;
296 if c.at_depth.load(Ordering::Relaxed) || probing {
297 return 2.min(num_drafts).max(base);
298 }
299 base
300 }
301
302 /// 2026-09-25: Records the width decision the scheduler already took:
303 /// `engaged` is `n_active <= cap`, where `cap` is the run's
304 /// `mtp_max_seqs` lever (32 unless overridden). It logs one INFO line per
305 /// change and counts changes in `width_flips`; it decides nothing.
306 pub fn note_width_regime(&self, n_active: usize, engaged: bool, cap: usize) {
307 if self.width_engaged.swap(engaged, Ordering::Relaxed) == engaged {
308 return;
309 }
310 let flips = self.width_flips.fetch_add(1, Ordering::Relaxed) + 1;
311 if engaged {
312 tracing::info!(
313 "speculation ENGAGED at width n={n_active} (dispatch cap {cap}) — flips={flips}"
314 );
315 } else {
316 tracing::info!(
317 "speculation DISENGAGED at width n={n_active} > dispatch cap {cap}: this width \
318 plain-decodes (--mtp-max-seqs or MODEL.toml mtp_max_seqs raises the cap; the verify pools grow with it) \
319 — flips={flips}"
320 );
321 }
322 }
323}
324
325#[cfg(test)]
326#[path = "adaptive_rung_tests.rs"]
327mod tests;