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;