spark_model/speculative/verify_key.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Batched-verify graph key: the canonical depth→slot assignment and the key
4//! bytes derived from it. ONE ordering rule, shared by the scheduler that
5//! dispatches the batch (`mtp_dcut::plan`, `mtp_step`) and the model that
6//! builds the CUDA-graph cache key (`verify_e2::verify_batched_graph_key`).
7//!
8//! # The measured defect (nsys + A/B on dgx2, binary `b508679e4`)
9//!
10//! The batched-verify graph cache keys on the per-row `(ssm slot, depth k)`
11//! pairs in batch order, because a capture bakes each row's pool state
12//! addresses AND the depth-run launch structure. D-Cut re-ranks WHICH
13//! sequence gets WHICH depth every step, so the key space was the set of
14//! ARRANGEMENTS of the step's depth multiset over the batch's slots:
15//!
16//! ```text
17//! n=8, the three multisets actually observed:
18//! 8!/(5!·2!·1!) + 8!/(4!·4!) + 8!/(6!·2!) = 168 + 70 + 28 = 266 keys
19//! ```
20//!
21//! against `VERIFY_BATCHED_GRAPH_CAP` = 32. Measured key counts: n=2 → 2,
22//! n=4 → 10, **n=8 → 160-253**, n=16 → 1 (D-Cut is off above width 8). nsys
23//! at C=8: 149 captures in 167 steps (89% of steps), `cuGraphInstantiate` +
24//! `cuGraphExecDestroy` + `cuGraphDestroy` = 23.2 ms/step ≈ 20% of the step;
25//! GPU busy 96.3% → 77.2%. A/B at C=8: control 78.89 tok/s vs 84.35 with
26//! D-Cut off (+6.9%, key count 253 → 1) — and that leg also LOSES the row
27//! pruning, so the thrash alone costs more than 6.9%.
28//!
29//! # The fix: canonical depth→slot assignment
30//!
31//! D-Cut's ranking chooses HOW MANY drafts survive at each depth (the
32//! multiset) — that is where its row saving comes from. It also chooses WHO
33//! gets them, and that half is what multiplies the key space. So the multiset
34//! stays confidence-chosen and the ARRANGEMENT becomes a pure function of the
35//! batch: depths descending are paired with slots ascending. The key is then
36//! determined by (slot set, depth multiset) alone — at n=8 the 266 observed
37//! arrangements collapse to the 3 multisets that produced them (worst case
38//! over all reachable shapes: multisets of size 8 over depths {2,3,4} =
39//! C(10,2) = 45, versus 3^8 = 6561 arrangements).
40//!
41//! ★ The two orderings RECONCILE instead of fighting. The dispatch needs
42//! depths descending (equal depths must form contiguous runs — the batched
43//! conv+WY fast path launches once per run,
44//! `trait_decode_batched_conv_gdn_multi.rs`) and the SSM batched arms need
45//! slots ascending in batch order (`ssm_batched_recurrent.rs`,
46//! `decode_step.rs`, `mtp_step.rs`). Under the confidence-chosen arrangement
47//! those two demands are in direct conflict: a ragged batch sorted
48//! deepest-first scrambles the slot order, so each depth run gets an
49//! arbitrary SUBSET of the pool slots and the consecutive-slot precondition
50//! fails. Pairing depths-descending with slots-ascending makes the two orders
51//! THE SAME order. A depth run owns a consecutive slot block only when the
52//! selected pool slots are themselves consecutive; the model checks actual
53//! pointers and declines the batched fast path when fragmentation leaves gaps.
54//!
55//! Correctness: which sequence gets which depth is a pure PERFORMANCE choice.
56//! Every batchable sequence enters the step with exactly `ladder_nd` drafts
57//! (`mtp_step` truncates the surplus), each assigned depth is in
58//! `1..=ladder_nd` drafts, and a verify of a shorter draft prefix is the same
59//! math on fewer rows. Σ rows is unchanged, so the row budget and chunking
60//! are unchanged. What is NOT free to change is the pairing between a batch
61//! POSITION and the slot whose pointers the graph baked there — hence one
62//! ordering rule, used by both the dispatch and the key.
63//!
64//! Kill switch `ATLAS_NO_CANONICAL_VERIFY_KEY` (PRESENCE — house convention,
65//! `=0` is NOT off) restores the pre-canonical behaviour: each sequence keeps
66//! its own confidence-chosen depth and the batch is sorted deepest-first,
67//! ssm-slot second.
68//!
69//! # The width gate: it only pays where the key space explodes
70//!
71//! Collapsing the key space is not free — forcing the assignment overrides
72//! D-Cut's confidence pairing and re-shapes the depth runs — and the key
73//! space only explodes at the TOP of D-Cut's width range. Measured key
74//! counts against `VERIFY_BATCHED_GRAPH_CAP` = 32: n=2 → 2, n=4 → 10,
75//! **n=8 → 160-253**, n=16 → 1. At n=2 and n=4 there is essentially nothing
76//! to collapse, and the A/B says so — see [`CANONICAL_KEY_MIN_WIDTH`], which
77//! is the ONE threshold and carries the table. Below it the pre-canonical
78//! assignment is restored BYTE-IDENTICALLY; at/above it the canonical
79//! assignment applies. [`canonical_assignment`] is the single gate; call
80//! sites never re-derive it.
81
82/// Canonical assignment ON unless `ATLAS_NO_CANONICAL_VERIFY_KEY` is present.
83/// Read once per process.
84pub fn canonical_verify_key_enabled() -> bool {
85 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
86 *ON.get_or_init(|| std::env::var_os("ATLAS_NO_CANONICAL_VERIFY_KEY").is_none())
87}
88
89/// Batch WIDTH (sequences) at or above which the canonical depth→slot
90/// assignment is applied. Below it [`verify_batch_order`] /
91/// [`verify_batch_permutation`] take their `canonical = false` arm, which is
92/// the pre-canonical (pre-PR-#552) behaviour byte for byte: each sequence
93/// keeps its own confidence-chosen depth and the batch sorts deepest-first,
94/// ssm-slot second, ties on input index (a stable
95/// `sort_by_key(|(a, k)| (Reverse(k), slot))`, exactly what both call sites
96/// used before).
97///
98/// Default **8**, from a same-binary same-session A/B on dgx2 (ladder-38
99/// round 7, tip `e0b845f11`) with ONE variable — the kill switch
100/// `ATLAS_NO_CANONICAL_VERIFY_KEY=1`. tok/s, higher is better:
101///
102/// ```text
103/// C | canonical ON | canonical OFF | verdict
104/// ----+-------------------------+------------------------+------------------
105/// 2 | 30.09 | 30.83 | costs -2.4%
106/// 4 | 64.00 (round 7: 65.56) | 68.11 (round 6, no it) | costs ~-3.7%
107/// 8 | 110.63 | 106.48 | GAINS +3.9%
108/// 16 | 203.50 | 203.44 | no effect
109/// 32 | 291.50 | 291.52 | no effect
110/// 64 | 387.62 | 386.99 | no effect
111/// 128 | 477.55 | 477.69 | no effect
112/// ```
113///
114/// The shape of that table follows the key counts (module docs): the
115/// collapse pays exactly where the arrangement space is large. Above width 8
116/// the gate is inert in either direction — D-Cut is off there
117/// (`dcut_width_cap`), `ks` is uniform, and both arms reduce to "sort by
118/// slot" (pinned by `uniform_depths_are_identical_under_both_arms`), which
119/// is why the C>=16 rungs move by <= 0.2% either way.
120///
121/// Hypothesis for the cost below 8, recorded but NOT load-bearing for this
122/// threshold (the threshold is the measurement, not the mechanism): forcing
123/// the assignment makes the two-launch batched GDN conv+WY fast path decline
124/// more often, i.e. `n*(2k-1)` launches per layer instead of 2 — 768 vs 96
125/// per step at n=2, k=4 over 48 GDN layers. PR #553's rate telemetry under
126/// `ATLAS_MTP_ACCEPT_DEBUG` reports that decline rate directly.
127pub const CANONICAL_KEY_MIN_WIDTH: usize = 8;
128
129/// Sweep the threshold without a rebuild: `ATLAS_CANONICAL_KEY_MIN_WIDTH=<n>`
130/// (VALUE-parsed; 0 = canonical at every width, a value above the widest
131/// batch = never). Unset or unparseable ⇒ [`CANONICAL_KEY_MIN_WIDTH`].
132/// Parsed once per process, like `dcut_width_cap`.
133pub fn canonical_key_min_width() -> usize {
134 static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
135 *N.get_or_init(|| min_width_from_env(std::env::var_os(ENV_MIN_WIDTH)))
136}
137
138/// The env var name, named once so the parser and its tests cannot drift.
139const ENV_MIN_WIDTH: &str = "ATLAS_CANONICAL_KEY_MIN_WIDTH";
140
141/// Pure parse of [`ENV_MIN_WIDTH`] — the I/O lives in
142/// [`canonical_key_min_width`] so the policy is testable without touching
143/// process env (which a `OnceLock` would latch anyway).
144fn min_width_from_env(raw: Option<std::ffi::OsString>) -> usize {
145 raw.and_then(|v| v.into_string().ok())
146 .and_then(|v| v.trim().parse::<usize>().ok())
147 .unwrap_or(CANONICAL_KEY_MIN_WIDTH)
148}
149
150/// **THE GATE** — the one decision "does this batch get the canonical
151/// depth→slot assignment?". Both seams ask this and nothing else:
152/// `mtp_dcut::plan` (which decides order AND assignment) and `mtp_step`
153/// (permutation only), each passing the FULL batch width so the two can
154/// never disagree — `plan` gates on `batchable.len()`, and a chunked
155/// dispatch must use that same width, not the chunk's.
156///
157/// `n` is the batch width in SEQUENCES. All logic lives in
158/// `canonical_assignment_at`; this is only the env binding (SBIO).
159pub fn canonical_assignment(n: usize) -> bool {
160 canonical_assignment_at(n, canonical_key_min_width(), canonical_verify_key_enabled())
161}
162
163/// The gate policy, with its two environment inputs INJECTED (SBIO): the
164/// resolved threshold and whether the kill switch is CLEAR. The kill switch
165/// dominates — once `ATLAS_NO_CANONICAL_VERIFY_KEY` is set, no width and no
166/// `ATLAS_CANONICAL_KEY_MIN_WIDTH` value can turn the assignment back on.
167///
168/// Split out because `OnceLock`-latched env cannot be moved from a test, and
169/// a policy nobody can exercise is a policy nobody has checked.
170fn canonical_assignment_at(n: usize, min_width: usize, kill_switch_clear: bool) -> bool {
171 kill_switch_clear && n >= min_width
172}
173
174/// Dispatch ORDER for one verify batch — the permutation only.
175///
176/// `slots[i]` / `ks[i]` describe batch member `i` in the caller's arbitrary
177/// order (`ks[i]` = that member's ROW count, drafts+1). `order[p]` is the
178/// input index dispatched at position `p`.
179///
180/// * `canonical = true` — sort by ssm slot ASCENDING. `ks` is unread: under
181/// the canonical assignment the depths are already descending along that
182/// order, so slot order IS depth order and there is nothing to trade off.
183/// * `canonical = false` — the kill-switch path: deepest first, ssm slot
184/// second (today's behaviour, where the two demands genuinely conflict).
185///
186/// Ties break on input index, so the result is a deterministic function of
187/// the inputs — a graph key must never depend on sort instability. Callers
188/// that build the batch in ascending active-sequence index therefore agree
189/// on the order of slot-less (`usize::MAX`) members.
190///
191/// Idempotent under `canonical = true`, so a chunked caller may re-apply it
192/// to a contiguous sub-range of an already-ordered batch.
193pub fn verify_batch_permutation(slots: &[usize], ks: &[usize], canonical: bool) -> Vec<usize> {
194 assert_eq!(
195 slots.len(),
196 ks.len(),
197 "verify_batch_permutation: slots/ks mismatch"
198 );
199 let n = slots.len().min(ks.len());
200 let mut order: Vec<usize> = (0..n).collect();
201 if canonical {
202 order.sort_by_key(|&i| (slots[i], i));
203 } else {
204 order.sort_by_key(|&i| (std::cmp::Reverse(ks[i]), slots[i], i));
205 }
206 order
207}
208
209/// Order one verify batch AND assign its depths — the planner's entry point
210/// (`mtp_dcut::plan`), the one place a sequence's verify depth is decided.
211///
212/// Returns `(order, depths)` where `order` is [`verify_batch_permutation`]
213/// and `depths[p]` is the row count position `p` verifies.
214///
215/// * `canonical = true` — the depth MULTISET is re-paired onto the ordered
216/// batch, deepest onto the lowest slot. `depths[p]` is therefore NOT
217/// generally `ks[order[p]]`; the multiset is preserved exactly, only the
218/// pairing is re-made. Both dispatch invariants then hold by construction:
219/// slots non-decreasing in `p` (the SSM consecutive-slot precondition) and
220/// depths non-increasing in `p` (the contiguous depth-run precondition).
221/// * `canonical = false` — each member keeps its own depth.
222///
223/// Because a caller must TRUNCATE each sequence's drafts to the depth it was
224/// assigned, this must be called exactly once per batch; downstream stages
225/// that only need the batch in dispatch order use
226/// [`verify_batch_permutation`], which cannot disturb an assignment.
227pub fn verify_batch_order(
228 slots: &[usize],
229 ks: &[usize],
230 canonical: bool,
231) -> (Vec<usize>, Vec<usize>) {
232 let order = verify_batch_permutation(slots, ks, canonical);
233 let depths: Vec<usize> = if canonical {
234 let mut d: Vec<usize> = ks[..order.len()].to_vec();
235 d.sort_unstable_by(|a, b| b.cmp(a));
236 d
237 } else {
238 order.iter().map(|&i| ks[i]).collect()
239 };
240 (order, depths)
241}
242
243/// The batched-verify CUDA-graph cache key for one batch: the `(ssm slot,
244/// row count)` pairs in DISPATCH order, then a wy-tables-present sentinel.
245///
246/// Every SSM pointer a capture bakes (h/conv state, rollback intermediates,
247/// WY table contents) is a pure function of the pair at that batch position,
248/// and the depth-run launch structure is a pure function of the depth
249/// sequence — so the key must carry both, in order. All other captured
250/// addresses (hidden/logits/scratch/meta) are fixed buffers refreshed
251/// pre-replay. The sentinel keeps a table-less capture from ever replaying a
252/// table-full step or vice versa.
253///
254/// Pairs arrive in the order [`verify_batch_order`] produced, so with
255/// canonicalization on the key is a pure function of (slot set, depth
256/// multiset, sentinel) — the whole point of this module.
257pub fn verify_graph_key(pairs: &[(u32, u32)], wy_tables_null: bool) -> Vec<u32> {
258 let mut key: Vec<u32> = Vec::with_capacity(2 * pairs.len() + 1);
259 for &(slot, k) in pairs {
260 key.push(slot);
261 key.push(k);
262 }
263 key.push(u32::MAX - u32::from(wy_tables_null));
264 key
265}
266
267#[cfg(test)]
268#[path = "verify_key_tests.rs"]
269mod tests;