metrale_speculative/
spec_cost_plan.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-10-04: The measured planner of `--spec-cost-model measured`: draft depth by expected
4//! accepted tokens per joule, with tok/s as a constraint. Pure and deterministic: its output
5//! depends only on the table, the calibration, the batch width and the drafter's confidences;
6//! no clock is read. It chooses how many drafts are proposed and verified, never which token is
7//! emitted, so greedy output is unchanged on row-invariant tiers.
8//!
9//! - [`propose_depth`]: before drafting, the depth `K` for width `n` that maximises
10//!   `E[tokens] / E[joules]` over `0..=max_k`, among the depths whose `E[tokens] / E[ms]` is at
11//!   least `(1 - slack)` times depth 1's. `E[tokens]` per sequence is `1 + Σ_{j<=K} Π_{t<=j}
12//!   prior(t)`; a step costs `verify(n, K) + draft(n, K)` from the table. Above the serve's own
13//!   `mtp_max_seqs` (the width beyond which the scheduler plain-decodes every sequence
14//!   regardless, `sched.levers.mtp_max_seqs`), it returns `0` by construction: there is nothing
15//!   to search, since the scheduler will not dispatch a draft there no matter what this picks
16//!   (2026-10-04, found measuring the published throughput recipe at C=64/128: with the
17//!   default cap of 32, `metrale_sched_phase_seconds_count{phase="step_mtp"}` fired once across
18//!   a 91 s, 32129-token run — the batch plain-decoded almost throughout).
19//! - [`sequence_depths`]: after drafting, how many of each sequence's drafts to verify. Every
20//!   sequence starts at one draft (none when `K = 0`). Which sequence a further row goes to is
21//!   still decided greedily (by chained P(accept): a sequence's own marginal token gain is
22//!   non-increasing in its depth, since it is a running product of probabilities `<= 1`, so for
23//!   any total row count the globally-largest-gain-first order is the tokens-maximising
24//!   allocation — an exchange argument, not an approximation). But the total number of rows `R`
25//!   the batch spends is chosen by scanning every reachable `R`, not by stopping at the first one
26//!   whose tokens per joule does not improve: the pilot measured verify cost as non-monotone in
27//!   rows per sequence (n=1 cost MORE at 3 rows than at 4, on the dense 27B, 2026-10-04), so a
28//!   row that does not pay can still be worth adding when a cheaper row lies beyond it. The
29//!   drafts are already paid for, so a row's marginal cost is verify cost only; the table is
30//!   uniform in depth, so a batch of `R` rows costs the width's cells interpolated in `R / n`.
31//!
32//! Owner: speculative.
33//! Invariants:
34//! - [`sequence_depths`] never gives a sequence more drafts than it has, more than `k_max`, or
35//!   the batch more than `row_budget` draft rows; a batch with more sequences than
36//!   `row_budget` verifies no drafts at all (a plain decode step).
37//! - Ties keep the shallower choice ([`propose_depth`]) or the earlier sequence (the caller's
38//!   order, which is slot order).
39
40use super::{AcceptanceCalibration, Cell, CostTable};
41
42/// 2026-10-04: Expected tokens per sequence of a chain of `k` drafts at the position priors.
43fn prior_tokens(cal: &AcceptanceCalibration, k: usize) -> f64 {
44    let mut run = 1.0;
45    let mut total = 1.0;
46    for j in 1..=k {
47        run *= cal.prior(j);
48        total += run;
49    }
50    total
51}
52
53/// 2026-10-04: The propose depth for a batch of `n` sequences: see the module doc. `slack` is
54/// `--spec-cost-slack`, in `0..1`. A table without depth 1 has no reference rate, so its
55/// constraint is against depth 0. `mtp_max_seqs` is the serve's own dispatch cap
56/// (`sched.levers.mtp_max_seqs`, SSOT — never a restated default here); `n > mtp_max_seqs`
57/// returns `0` without consulting the table (see the module doc). Within the cap the search can
58/// also return `0` on its own terms (a draft row that costs a whole step — see the
59/// `no_drafting_when_it_is_neither_faster_nor_cheaper` test) — the two `0`s mean different
60/// things to a caller that is not wired to suspend MTP from the table's energy preference
61/// alone: it must re-check the cap itself to tell them apart (`mtp_step.rs` does).
62pub fn propose_depth(
63    table: &CostTable,
64    cal: &AcceptanceCalibration,
65    n: usize,
66    slack: f64,
67    mtp_max_seqs: usize,
68) -> usize {
69    if n > mtp_max_seqs {
70        return 0;
71    }
72    let at = |k: usize| -> (f64, Cell) {
73        let c = table.cell(n, k).expect("k <= max_k");
74        (n as f64 * prior_tokens(cal, k), c)
75    };
76    let reference = {
77        let (tok, c) = at(table.max_k().min(1));
78        tok / c.step_ms()
79    };
80    let mut best: Option<(usize, f64)> = None;
81    for k in 0..=table.max_k() {
82        let (tok, c) = at(k);
83        if tok / c.step_ms() < (1.0 - slack) * reference {
84            continue;
85        }
86        let per_joule = tok / c.step_j();
87        if best.is_none_or(|(_, b)| per_joule > b) {
88            best = Some((k, per_joule));
89        }
90    }
91    best.map_or(table.max_k().min(1), |(k, _)| k)
92}
93
94/// 2026-10-04: The verify cost of `rows` draft rows across `n` sequences, interpolated between
95/// the width's uniform-depth cells; the drafting cost of depth `k_max` is sunk and added whole.
96fn batch_cost(table: &CostTable, n: usize, rows: usize, k_max: usize) -> (f64, f64) {
97    let depth = rows as f64 / n as f64;
98    let (lo, hi) = (depth.floor() as usize, depth.ceil() as usize);
99    let (a, b) = (
100        table.cell(n, lo.min(table.max_k())).expect("in range"),
101        table.cell(n, hi.min(table.max_k())).expect("in range"),
102    );
103    let t = depth - lo as f64;
104    let draft = table.cell(n, k_max).expect("k_max <= max_k");
105    (
106        a.verify_ms + (b.verify_ms - a.verify_ms) * t + draft.draft_ms,
107        a.verify_j + (b.verify_j - a.verify_j) * t + draft.draft_j,
108    )
109}
110
111/// 2026-10-04: Drafts to verify per sequence: see the module doc. `drafts[i]` is sequence
112/// `i`'s draft confidences (top-1 log-probabilities, in draft order; its length is how many
113/// drafts it has; an empty slice with `known_drafts[i] > 0` means drafts without confidences).
114pub fn sequence_depths(
115    table: &CostTable,
116    cal: &AcceptanceCalibration,
117    confidences: &[&[f32]],
118    known_drafts: &[usize],
119    k_max: usize,
120    row_budget: usize,
121    slack: f64,
122) -> Vec<usize> {
123    let n = confidences.len();
124    assert_eq!(n, known_drafts.len(), "one draft count per sequence");
125    let k_max = k_max.min(table.max_k());
126    let cap: Vec<usize> = known_drafts.iter().map(|&d| d.min(k_max)).collect();
127    // 2026-10-04: P(draft j of sequence i accepted | drafts before it accepted).
128    let p = |i: usize, j: usize| -> f64 {
129        confidences[i]
130            .get(j - 1)
131            .map_or_else(|| cal.prior(j), |&lp| cal.p_given_lp(lp))
132    };
133    let mut depth: Vec<usize> = cap.iter().map(|&c| c.min(1)).collect();
134    let chain: Vec<f64> = (0..n)
135        .map(|i| if depth[i] == 1 { p(i, 1) } else { 1.0 })
136        .collect();
137    let base_tokens: f64 = n as f64
138        + (0..n)
139            .filter(|&i| depth[i] == 1)
140            .map(|i| chain[i])
141            .sum::<f64>();
142    let base_rows: usize = depth.iter().sum();
143    if n == 0 || base_rows > row_budget {
144        return vec![0; n];
145    }
146    // 2026-10-04: Every row beyond a sequence's mandatory first draft, with its marginal
147    // expected-token gain, in draft order per sequence. A sequence's own gains are
148    // non-increasing (see the module doc), so sorting all of them together, across every
149    // sequence, and taking a prefix of length `R - base_rows` is the tokens-maximising way to
150    // reach any total row count `R >= base_rows`.
151    struct Extra {
152        seq: usize,
153        depth: usize,
154        gain: f64,
155    }
156    let mut extra = Vec::new();
157    for i in 0..n {
158        let mut run = chain[i];
159        for j in (depth[i] + 1)..=cap[i] {
160            run *= p(i, j);
161            extra.push(Extra {
162                seq: i,
163                depth: j,
164                gain: run,
165            });
166        }
167    }
168    extra.sort_by(|a, b| {
169        b.gain
170            .partial_cmp(&a.gain)
171            .unwrap_or(std::cmp::Ordering::Equal)
172    });
173    let reference = {
174        let (ms, _) = batch_cost(table, n, base_rows, k_max);
175        base_tokens / ms
176    };
177    // 2026-10-04: The best total row count is found by scanning every reachable `R`, not by
178    // stopping at the first `R` whose tokens per joule does not improve: the table's cost is
179    // not guaranteed monotone in rows (see the module doc), so a row that does not pay can
180    // still be worth taking when a cheaper one lies beyond it. Ties keep the shallower `R`.
181    let max_take = (row_budget - base_rows).min(extra.len());
182    let mut best_rows = base_rows;
183    let mut best_per_joule = {
184        let (_, j) = batch_cost(table, n, base_rows, k_max);
185        base_tokens / j
186    };
187    let mut tokens = base_tokens;
188    for take in 1..=max_take {
189        tokens += extra[take - 1].gain;
190        let rows = base_rows + take;
191        let (ms, j) = batch_cost(table, n, rows, k_max);
192        if tokens / ms < (1.0 - slack) * reference {
193            continue;
194        }
195        let per_joule = tokens / j;
196        if per_joule > best_per_joule {
197            best_rows = rows;
198            best_per_joule = per_joule;
199        }
200    }
201    for e in &extra[..(best_rows - base_rows)] {
202        depth[e.seq] = e.depth;
203    }
204    depth
205}
206
207#[cfg(test)]
208#[path = "spec_cost_plan_tests.rs"]
209mod tests;