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;