spark_model/speculative/
ladder.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! K-vs-batch ladder (task #35): per-step draft count as a function of the
4//! number of active sequences.
5//!
6//! Fixed K=4 (3 drafts) over n >= 8 sequences MEASURED as a collapse to a
7//! ~55 tok/s plateau at every C (cap=16 sweep, 2026-07-28): n*(K+1) verify
8//! rows of SUPERLINEAR per-sequence GDN plus graph-key churn. The ladder
9//! shrinks the per-sequence draft count as concurrency grows so the verify
10//! row total stays small while the weight-read amortization of the batched
11//! verify keeps growing with n:
12//! n <= 4 -> 3 drafts (4 rows/seq, today's proven regime, bit-for-bit),
13//! n <= 8 -> 3 drafts (4 rows/seq, R = 32),
14//! n <= 16 -> 1 draft (2 rows/seq, R = 32 at n=16 — single chunk; wave 19,
15//!            taken back from 16:2 when the measured p1 fell below the
16//!            rung's break-even — see the default-ladder comment),
17//! n <= 32 -> 1 draft (2 rows/seq, R = 64 at n=32).
18//!
19//! ★ This module is the STATIC ladder and the floor. Since wave 28 the n=16
20//! rung is chosen at RUNTIME from the observed accept statistics by
21//! `spark_server::scheduler::adaptive_rung` — a static value cannot be right
22//! for both traffic regimes, because the second-token conditional accept is
23//! bimodal (~0.54 prose / 0.877 tool-shaped) and moves the break-even across
24//! the rung. `ATLAS_MTP_STATIC_RUNG` (PRESENCE) pins the static value here;
25//! so does an explicit `ATLAS_MTP_K_LADDER`.
26//!
27//! ★ The depth step-down that used to sit at n>4 was an artifact of the
28//! `mtp_step` chunk cap, NOT of GDN depth cost: `rows=4` was capped at 4
29//! sequences, so an 8-wide batch ran TWO serialized 4-wide verify forwards
30//! (2x the weight reads per step). Every "8:3 collapses" measurement
31//! (57.9 on 2026-07-28, and 62.6 when re-measured this session) recorded
32//! that chunking, not depth-3 at width 8. Raising the cap to the row-buffer
33//! bound makes the true 8-wide K=4 step the BEST measured point at C=8.
34//! ★ The n=16 rung took three rounds to earn its place, and its history is
35//! the record of a COST curve, not of a depth curve. It measured a loss
36//! (16:1 -> 128.4 vs a 131.9 MTP-off control) after the three eager-cost
37//! fixes (`b93982d9` k-parameterized cross-seq GDN conv/WY, `a83627a2`
38//! propose widened to n=16, `fa373bf4` batched Phase-A bootstrap), then
39//! exact PARITY (131.93 vs 131.42) after the accept lift (`36d340a0`
40//! per-sequence drafter prefill lifted p1 at n=16 to 0.797, making
41//! break-even 1.797x against a measured ~1.79x). `296b9674`'s three
42//! per-row verify cuts took the implied cost to ~1.55x, and the SAME 16:1
43//! shape then measured **152.01 tok/s over two serves against a
44//! same-session MTP-off control of 131.40 (+15.7%)**, p1 0.78-0.86,
45//! tok_step ~1.83.
46//! ★ The historical "depth at n=16 is dead" numbers (16:2 -> 94.1, 16:3 ->
47//! 120.76) recorded the CHUNK CAP, not depth — the same artifact class as
48//! the 8:3 story below (rows=3/4 chunks were hardcoded to 8 seqs, so a
49//! 16-wide depth batch ran TWO serialized 8-wide verifies). With the cap
50//! derived from the row budget (fixer r2 2026-07-30), TRUE single-chunk
51//! 16:2 measures **194-196 tok/s at C=16 vs a 184-185 same-session 16:1
52//! control (+6%)** with D-Cut OFF (tok_step 2.44-2.50, p1 0.81-0.85) — but
53//! **176-179 (-4%) with D-Cut pruning AT DEPTH** (ragged pruning at nd=2
54//! fragments the GDN runs and sheds winning drafts). The wave-11
55//! implementer grid re-measured the full depth set at n=16 on one binary:
56//! 16:2 = 195.0/187.6 > 16:3 = 190.9/187.9 > 16:1 = 184.5/184.1 (K=4's
57//! extra 16 rows cost more than the +0.46 tok/step they buy; p3_cond
58//! unstable 0.51-0.75). **16:2 is therefore the DEFAULT rung**, paired with
59//! the D-Cut-at-depth policy that makes it safe: `mtp_dcut::dcut_width_cap`
60//! holds pruning to batches of <= 8 sequences, so the n=16 verify runs the
61//! exact uniform single-chunk [3; 16] shape that measured the win, while
62//! the C=8 D-Cut win (+2.6%) is untouched.
63//!
64//! At n<=8, measured C=8 on binary 9bef3b49 (this ladder + the raised
65//! chunk cap), one fresh serve per config: 8:3 95.84 (range 94.9-96.6,
66//! 8 reps) > 8:2 93.30 (92.5-94.0) — disjoint, reproduced on a second
67//! serve at 95.68. Accept telemetry at n=8: p1 0.793, tok_step 2.606
68//! (vs 0.780 / 2.301 at 8:2).
69//!
70//! Overrides:
71//! * `ATLAS_MTP_K_LADDER="4:3,8:2,16:1"` — comma-separated `n_max:drafts`
72//!   steps, VALUE-parsed once per process. Draft counts clamp to
73//!   `[1, num_drafts]` (the CLI `--num-drafts` remains the ceiling, so
74//!   `"4:4,..."` parses to the full configured draft count).
75//! * `ATLAS_NO_MTP_K_LADDER` — PRESENCE check (house convention, `=0` is
76//!   NOT off): disables the ladder entirely (fixed `num_drafts` at every n)
77//!   AND drops the [`super::mtp_max_seqs`] default back to 4, restoring the
78//!   pre-ladder adaptive policy (batched K=4 MTP at C<=4, MTP-off above).
79
80/// PRESENCE check for `ATLAS_NO_MTP_K_LADDER`. Read once per process.
81pub fn mtp_ladder_disabled() -> bool {
82    static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
83    *OFF.get_or_init(|| std::env::var_os("ATLAS_NO_MTP_K_LADDER").is_some())
84}
85
86/// Parsed ladder steps `(n_max, drafts)`, ascending by `n_max`. Falls back
87/// to the default ladder when `ATLAS_MTP_K_LADDER` is unset or unparseable
88/// (a malformed value must not silently disable speculation).
89fn parse_ladder(value: &str) -> Option<Vec<(usize, usize)>> {
90    let mut steps = Vec::new();
91    for part in value.split(',') {
92        let (n, k) = part.trim().split_once(':')?;
93        steps.push((n.trim().parse().ok()?, k.trim().parse().ok()?));
94    }
95    if steps.is_empty() {
96        return None;
97    }
98    steps.sort_by_key(|&(n, _)| n);
99    Some(steps)
100}
101
102fn mtp_ladder_steps() -> &'static [(usize, usize)] {
103    static STEPS: std::sync::OnceLock<Vec<(usize, usize)>> = std::sync::OnceLock::new();
104    STEPS.get_or_init(|| {
105        let parsed = std::env::var("ATLAS_MTP_K_LADDER")
106            .ok()
107            .and_then(|value| parse_ladder(&value));
108        // Default ladder: 3 drafts up to the n=8 rung, then TWO drafts at
109        // n<=16 (the 16:2 rung), then one at n<=32.
110        //
111        // 16:2 (2026-07-30, wave-11 implementer grid + fixer r2, one binary,
112        // one serve boot per ladder): the depth grid at n=16, truly measured
113        // for the first time after the chunk-cap fix (88539cf7), is ordered
114        // 16:2 (195.0/187.6) > 16:3 (190.9/187.9) > 16:1 (184.5/184.1) at
115        // C=16 — +5.7% best-vs-best for 16:2, reproducing fixer r2's
116        // 194.4-196.0 band; tok_step 2.483 at p1 0.859, single-chunk
117        // `n=16 k_drafts=2` telemetry. The historical "depth at n=16 is
118        // dead" numbers (16:2 -> 94.1, 16:3 -> 120.76) were the 8-seq chunk
119        // cap, not depth (module doc ★). The rung REQUIRES the
120        // D-Cut-at-depth policy (`mtp_dcut::dcut_width_cap`, prune only at
121        // <= 8 sequences): with pruning active at n=16 the same shape LOSES
122        // -4% (176.6-179.4). It is inert at n<=8 by construction (the n<=8
123        // rung matches first).
124        //
125        // 24:2 / 32:2 (wave 11): the 96-row verify envelope makes depth at
126        // n<=32 a SINGLE chunk (24 x 3 = 72 rows, 32 x 3 = 96 rows), so
127        // `ATLAS_MTP_K_LADDER="4:3,8:3,16:2,24:2,32:2"` is now a measurable
128        // shape. NOT default: 32:2 is a PROJECTION so far (~277 tok/s at
129        // C=32 from the measured n=16 K=3 verify cost — +50% rows for
130        // +26.5% step time — vs the 269.3 bar); the default flips only on a
131        // measured win, like every rung before it.
132        //
133        // 32:1 (2026-07-30, spec at n=32): the wave-9 native-bs32 profile
134        // shows the n=32 plain step is 97.6% GPU-busy at 160 ms with FFN /
135        // projections FLAT in rows and the GDN state term per-STEP, so K=2's
136        // ~1.8 tok/step (p1~0.8) amortizes the whole step — the one level
137        // with NO MTP multiplier while every C<=16 level enjoys one. R =
138        // 32 x 2 = 64 rows = the widened VERIFY_ROW_CAP/meta/logits/stash
139        // envelope (verify_e). Explicit rung (not last-step fallthrough) so
140        // the shape is visible in `ATLAS_MTP_K_LADDER` terms; dispatch above
141        // 16 additionally needs the `mtp_max_seqs` default raised to 32
142        // (below).
143        //
144        // The 8:2 step-down was an ARTIFACT of the `mtp_step` chunk cap,
145        // not of depth: with `rows=4` capped at 4 sequences, an 8-wide
146        // batch ran TWO serialized 4-wide verify forwards, which is what
147        // the "8:3 collapses" numbers (57.9, and 62.6 measured this
148        // session) recorded. With the cap raised to the row-buffer bound
149        // (R = n*k <= 32, so 8 seqs x 4 rows fits exactly) a true 8-wide
150        // K=4 verify MEASURES 95.84 tok/s at C=8 vs 93.30 for 8:2 on the
151        // same binary — disjoint ranges (94.9-96.6 vs 92.5-94.0), two
152        // independent serves. tok_step 2.606 vs 2.301 (+13.3%) for a
153        // verify step ~11% more expensive.
154        //
155        // 16:1 (wave 19, 2026-07-31, dgx1, tip 19b365c2, ONE binary,
156        // env-toggled arms, one fresh serve per arm, 3 scored reps each, every
157        // rep token-matched at 16384 completion tokens): the full depth grid
158        // at n=16 is MONOTONE DECREASING in depth, so the 16:2 rung lost the
159        // lead it held since wave 11 —
160        //   16:1  181.63  (182.50 / 181.27 / 181.13)  tok_step 1.715
161        //   16:2  172.70  (173.23 / 171.50 / 173.36)  tok_step 2.13
162        //   16:3  152.97  (152.50 / 152.76 / 153.65)  tok_step 2.27
163        // 16:1 beats 16:2 by +5.2% with DISJOINT ranges, and is the first
164        // C=16 number in the campaign to CLEAR the vLLM bar (178.72, same
165        // box, wave 17). 16:4 is not a shape: `can_batch_verify` admits only
166        // rows in 2..=4, so 3 drafts is the depth ceiling at any width.
167        //
168        // ★ Nothing regressed — the rung is a function of accept rate, and
169        // the accept rate MOVED. The wave-11 grid picked 16:2 at p1 0.859 /
170        // tok_step 2.483; the same shape on this tip and this workload
171        // measures p1 0.70-0.79 / tok_step 2.13. The rung's break-even is
172        // exactly the tok_step ratio against the step-cost ratio.
173        //
174        // ★★ CORRECTION (wave 28, 2026-08-01). Wave 19 fitted
175        // `step = F + c*R` over the depth grid and derived F = 65.8 ms /
176        // c = 2.70 ms per row-sequence, i.e. a 16:2-over-16:1 COST RATIO of
177        // **1.306**. That number is WRONG. Wave 27 measured the step cost
178        // directly from `mtp_accept_debug` tok_step and decode-only
179        // throughput on one binary across four workloads and reads
180        // **1.17-1.26** — depth is CHEAPER than the fit claimed, drifting up
181        // slowly with sequence length, which is why this rung sits far
182        // closer to break-even than the fit implied. Do not re-use the
183        // 65.8/2.70 model to justify a rung; re-measure.
184        //
185        // ★★ SUPERSEDED AS A STATIC RUNG (wave 28). The break-even is a
186        // function of the SECOND-token conditional accept `p2_cond`, and
187        // `p2_cond` is BIMODAL BY TRAFFIC: ~0.54 on prose vs 0.877 on
188        // tool-shaped function-call text, measured on the SAME two serve
189        // boots. Token ratio 1.19-1.23 (prose, below cost -> 16:1 wins by
190        // 1.2-2.4%) vs 1.424 (tool-shaped, far above cost -> 16:2 wins by
191        // 7.9%). Output length is NOT the regime variable — the length
192        // sweep is flat. So NO static value of this rung is right for both
193        // regimes, and the shipped decision is made at RUNTIME from the
194        // observed accept statistics by
195        // `spark_server::scheduler::adaptive_rung`, which reads this rung as
196        // its floor and may lift n in 9..=16 to 2 drafts. This entry stays
197        // the static default (and the value under
198        // `ATLAS_MTP_STATIC_RUNG`). Restore the old static rung with
199        // `ATLAS_MTP_K_LADDER="4:3,8:3,16:2,32:1"` — an explicit ladder also
200        // pins adaptation off — and re-run the grid after any accept lift.
201        //
202        // ★ The per-row term DOMINATES at n=16 — 129.5 of the 197.3 ms at
203        // 16:2, and still 86.3 of 151.1 ms at 16:1 — so the wave-15 step
204        // model's "fixed 82.7 ms is about half the n=16 step" is WRONG here:
205        // the measured fixed cost is 65.8 ms, only 33% of the 16:2 step.
206        // Cutting c is the standing lever, and it is what would let a deeper
207        // rung pay again.
208        parsed.unwrap_or_else(|| vec![(4, 3), (8, 3), (16, 1), (32, 1)])
209    })
210}
211
212fn ladder_drafts_from_steps(steps: &[(usize, usize)], n_active: usize, num_drafts: usize) -> usize {
213    if num_drafts == 0 {
214        return 0;
215    }
216    steps
217        .iter()
218        .find(|&&(n_max, _)| n_active <= n_max)
219        .or(steps.last())
220        .map(|&(_, k)| k.clamp(1, num_drafts))
221        .unwrap_or(num_drafts)
222}
223
224/// The per-step draft count for `n_active` concurrent sequences.
225///
226/// `num_drafts` is the configured ceiling (CLI `--num-drafts`); the return
227/// value is always in `[1, num_drafts]` (or 0 when `num_drafts` is 0, i.e.
228/// speculation off). Ladder disabled -> fixed `num_drafts` (pre-ladder
229/// behavior). `n_active` beyond the last ladder step uses the last step's
230/// draft count (the cap gates dispatch anyway).
231pub fn mtp_ladder_drafts(n_active: usize, num_drafts: usize) -> usize {
232    if num_drafts == 0 {
233        return 0;
234    }
235    if mtp_ladder_disabled() {
236        return num_drafts;
237    }
238    ladder_drafts_from_steps(mtp_ladder_steps(), n_active, num_drafts)
239}
240
241/// SSOT for the multi-sequence MTP cap (`ATLAS_MTP_MAX_SEQS`; default 32
242/// with the K-vs-batch ladder, 4 under `ATLAS_NO_MTP_K_LADDER`).
243/// Value-parsed, not presence-checked. Lives beside the ladder (moved from
244/// `speculative.rs`, originally `scheduler/mod.rs`) because the two are one
245/// policy: the model-side single-sequence MTP structures (catchup ring,
246/// refeed labels, carry slot) gate on the same value the scheduler gates
247/// dispatch on.
248///
249/// The cap IS the adaptive per-concurrency policy: the scheduler gates
250/// dispatch on `active.len() <= mtp_max_seqs()`. Per-step K comes from
251/// [`mtp_ladder_drafts`] (task #35): `4:3,8:3,16:1,32:1` — 3 drafts through
252/// n=8 (matrix 2026-07-28: C=8 95.84 at 8:3 vs 93.30 at 8:2 on the same
253/// binary, and 73.5 MTP-off), then 1 draft through n=16 (wave 19
254/// 2026-07-31: C=16 181.9 at 16:1 vs 172.70 at 16:2 on one binary, disjoint
255/// ranges — the wave-11 grid's 16:2 lead does not survive the drop in p1
256/// from 0.859 to ~0.72), then 1 draft through n=32 (2026-07-30, the
257/// native-bs32 rung — R = 64 verify rows).
258/// `ATLAS_NO_MTP_K_LADDER` (presence) restores fixed K=4 + cap 4 — the
259/// dafd990d adaptive policy. Set `ATLAS_MTP_MAX_SEQS=1` to restore
260/// single-sequence-only.
261pub fn mtp_max_seqs() -> usize {
262    static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
263    *N.get_or_init(|| {
264        std::env::var("ATLAS_MTP_MAX_SEQS")
265            .ok()
266            .and_then(|v| v.parse().ok())
267            // Default 32 (2026-07-30, spec at n=32 — the 32:1 ladder rung).
268            // The raise is inert at n<=16: the cap only gates dispatch
269            // above 16 and the `32:1` rung only matches above 16, so every
270            // measured C<=16 code path is unchanged. Set
271            // `ATLAS_MTP_MAX_SEQS=16` to restore the wave-9 cap (spec off
272            // above n=16).
273            // History: default 16 (finalizer matrix 2026-07-29) — it was 8
274            // for three rounds because spec at n=16 measured a LOSS (128.4
275            // at 16:1) and then a PARITY (131.93 vs a 131.42 MTP-off
276            // control): the verify step cost ~1.79x a plain batch-16 decode
277            // step against a break-even of 1.797x at p1 0.797. `296b9674`'s
278            // three per-row verify cuts (wide LM-head arm, one-launch gated
279            // RMS norm, fused BA+gates) bought ~0.20x of that cost — and
280            // the same 16:1 shape MEASURES 152.01 tok/s over two serves
281            // against a same-session MTP-off control of 131.40 (+15.7%),
282            // with p1 0.78-0.86 and tok_step ~1.83.
283            // Pre-ladder baseline (cap=4, binary 472ed410): C=1 25.55
284            // (1.80x vLLM) · C=2 35.35 (1.27x) · C=4 54.1 (1.01x) ·
285            // C=8/16 MTP-off 73.5/131.0.
286            .unwrap_or(if mtp_ladder_disabled() { 4 } else { 32 })
287    })
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    // Default-ladder shape (env-independent as long as the test process
295    // does not set ATLAS_MTP_K_LADDER / ATLAS_NO_MTP_K_LADDER — CI does not).
296    #[test]
297    fn default_ladder_holds_depth_to_the_cap() {
298        assert_eq!(mtp_ladder_drafts(1, 3), 3);
299        assert_eq!(mtp_ladder_drafts(4, 3), 3);
300        assert_eq!(mtp_ladder_drafts(5, 3), 3);
301        assert_eq!(mtp_ladder_drafts(8, 3), 3);
302        // The 16:1 rung (wave 19): ONE draft (K=2, 2 rows/seq) through n=16.
303        // 16:2 held this rung from wave 11 until its accept rate fell:
304        // re-measured on one binary with env-toggled arms, 16:1 reads
305        // 181.27-182.50 against 16:2's 171.50-173.36 (+5.4%, disjoint), and
306        // clears the 178.72 vLLM bar. Restore 16:2 with
307        // ATLAS_MTP_K_LADDER="4:3,8:3,16:2,32:1".
308        assert_eq!(mtp_ladder_drafts(9, 3), 1);
309        assert_eq!(mtp_ladder_drafts(16, 3), 1);
310        // The 32:1 rung (2026-07-30): ONE draft (K=2) through n=32 — the
311        // native-bs32 regime, R = 64 verify rows.
312        assert_eq!(mtp_ladder_drafts(17, 3), 1);
313        assert_eq!(mtp_ladder_drafts(32, 3), 1);
314        // Beyond the last step: last step's value (the cap — default 32 —
315        // gates dispatch, so n>32 never speculates at defaults).
316        assert_eq!(mtp_ladder_drafts(64, 3), 1);
317        // --num-drafts stays the ceiling: 16:2 clamps to 1 at num_drafts=1.
318        assert_eq!(mtp_ladder_drafts(16, 1), 1);
319    }
320
321    #[test]
322    fn depth_at_width_env_rungs_parse_shape() {
323        let steps = parse_ladder("32:2, 4:3,8:3, 16:2,24:2").unwrap();
324        assert_eq!(steps, [(4, 3), (8, 3), (16, 2), (24, 2), (32, 2)]);
325        // 24:2 = 24 x 3 = 72 rows; 32:2 = 32 x 3 = 96 rows — both a single
326        // chunk under VERIFY_ROW_BUDGET = 96 (mtp_dcut::chunk_ranges).
327        assert_eq!(ladder_drafts_from_steps(&steps, 17, 3), 2);
328        assert_eq!(ladder_drafts_from_steps(&steps, 24, 3), 2);
329        assert_eq!(ladder_drafts_from_steps(&steps, 25, 3), 2);
330        assert_eq!(ladder_drafts_from_steps(&steps, 32, 3), 2);
331    }
332
333    // A step-down ladder must still be honored when asked for explicitly
334    // (the 8:2 shape stays reachable via ATLAS_MTP_K_LADDER).
335    #[test]
336    fn explicit_steps_are_honored() {
337        let steps = parse_ladder("4:3,8:2").unwrap();
338        assert_eq!(ladder_drafts_from_steps(&steps, 4, 3), 3);
339        assert_eq!(ladder_drafts_from_steps(&steps, 5, 3), 2);
340        assert_eq!(ladder_drafts_from_steps(&steps, 8, 3), 2);
341        assert_eq!(ladder_drafts_from_steps(&steps, 9, 3), 2);
342    }
343
344    #[test]
345    fn malformed_ladder_is_rejected_as_a_unit() {
346        assert_eq!(parse_ladder(""), None);
347        assert_eq!(parse_ladder("4:3,broken,8:2"), None);
348        assert_eq!(parse_ladder("4:three"), None);
349    }
350
351    #[test]
352    fn ladder_clamps_to_configured_ceiling() {
353        // num_drafts=1 caps every step at 1; num_drafts=0 means spec off.
354        assert_eq!(mtp_ladder_drafts(2, 1), 1);
355        assert_eq!(mtp_ladder_drafts(2, 0), 0);
356    }
357}