Skip to main content

gam_math/
paired_timing.rs

1//! One way to measure "does A beat B", for every speed gate in this workspace.
2//!
3//! This module is test infrastructure that lives in the library because the
4//! integration tests of other crates measure with it, and a `#[cfg(test)]`
5//! item cannot cross a crate boundary (feature gating is banned in this
6//! workspace). No shipped artifact links it. The `paired_timing_report`
7//! example is its reachability root: a dead-code sweep keyed on the CLI and
8//! Python extension symbol tables once deleted `paired_interleaved`, every
9//! gate that called it and every hand opponent those gates raced, and a
10//! kept example is the root such a sweep honours.
11//!
12//! Fifteen separate timing harnesses across ten files were doing this in three
13//! different ways, and the differences decide whether a gate can tell a
14//! regression from a busy machine (issue #932, and #2470 for the duplication).
15//! Two of the fifteen interleaved the arms; thirteen did not.
16//!
17//! # Why the majority pattern cannot resolve what it asserts
18//!
19//! The `best_ns` family — thirteen copies — times each arm in a **separate
20//! call**: A is measured to completion (five rounds, minimum taken), and only
21//! then is B. Two things go wrong at once.
22//!
23//! * **The arms occupy different wall-clock windows.** Anything that drifts
24//!   between them — a neighbour job starting, a frequency ramp, cache or
25//!   branch-predictor state warmed by the first arm, first-touch page faults
26//!   amortised by the first arm — lands entirely in the ratio. Taking a minimum
27//!   rejects a transient *spike*; it does nothing about a systematic offset.
28//! * **A minimum is the order statistic most exposed to exactly that.** It is
29//!   the single most favourable draw for each arm, so a small constant advantage
30//!   to whichever arm ran second survives the minimum intact rather than
31//!   averaging out. And pairing two independently-minimised blocks throws away
32//!   the pairing that would have cancelled the drift in the first place.
33//!
34//! The consequence is not hypothetical. On the same tree, one such gate PASSED
35//! on a quiet node (whole suite 5.1 s) and FAILED at 1.62x against a 1.5x bar on
36//! a loaded one (same suite 219.1 s); another picked a **different loser on
37//! consecutive runs** of one tree, 9% then 3%. Both were then guarded off in
38//! debug builds, which hid the symptom without touching the cause: the harness
39//! cannot resolve a margin of a few percent, and most of these gates assert
40//! margins of a few percent.
41//!
42//! # What this does instead
43//!
44//! * **Interleave per repetition, not per block.** Each repetition times A and B
45//!   adjacent in time, so drift slower than one repetition is common to both
46//!   sides of that repetition's ratio and divides out.
47//! * **Randomise the order within each repetition.** If a first-versus-second
48//!   advantage exists at all, randomisation makes it cancel in expectation
49//!   instead of accruing to a fixed arm — and `PairedTiming::first_position_bias`
50//!   reports the residual so it is measured rather than assumed away.
51//! * **Report the distribution of PAIRED ratios**, not a ratio of aggregate
52//!   extrema. The per-repetition ratio is the quantity the claim is about; a
53//!   median of paired ratios is robust, and the spread of that distribution is
54//!   the gate's own resolution — which is the number you need in order to know
55//!   whether a 3% claim is assertable at all.
56//! * **Feed each iteration from the previous result.** A dependence chain
57//!   through `checksum` is what stops the optimizer hoisting or vectorising
58//!   across iterations; `black_box` alone permits both, and one of the replaced
59//!   harnesses relied on `black_box` with no data dependence.
60//!
61//! # `wins_fraction` is evidence, not a gate
62//!
63//! Lead a report with it: `wins = 1.00` over fifteen repetitions is a
64//! distribution-free sign test at `2^-15`, it does not depend on the resolution
65//! estimate, and that matters most exactly when the resolution estimate is the
66//! thing under suspicion. It settled a multinomial cell whose margin was only
67//! **1.6x** its own resolution — a comparison of ratio against resolution
68//! declined to certify that cell.
69//!
70//! **Do not put it in a bar.** It is a within-run confidence statement *at that
71//! host's noise level*, so it degrades in the opposite direction from the
72//! quantity it would be guarding. Measured on one 1.6% effect: `wins = 0.00` on
73//! a quiet node, `0.27 / 0.40 / 0.27` on a node ~30x noisier, and three runs of
74//! **identical code** giving `0.67 / 0.87 / 1.00` — while `median_ratio` stayed
75//! inside a 0.8% band. ANDed into a gate it can only manufacture failures on a
76//! busy runner. **Gate on `median_ratio`; report `wins` and `ratio_resolution`.**
77//!
78//! # A derived margin is sometimes zero
79//!
80//! "Derive the margin from the resolution" **cannot mean "add a margin."**
81//! Sometimes the resolution says none is warranted and the derived answer is the
82//! bar already there. A zero-margin bar that looked like a coin flip was in fact
83//! guarding a real regression at many times its own resolution, and the obvious
84//! 5% tolerance would have passed that regression silently.
85//!
86//! The converse case is just as sharp. One gate's cell sits ~1.5% below its
87//! opponent across six measurements on three nodes, with two candidate
88//! mechanisms measured and refuted; the estimator it replaced called that cell a
89//! comfortable pass at `1.043911`. **Fixing the estimator did not make the bar
90//! assertable — it made clear the old numbers were not measuring the quantity at
91//! all.** Whether such a cell keeps a strict bar is a contract decision, and it
92//! must be taken explicitly with a stated reason, never by widening a bar in the
93//! commit that measured it.
94//!
95//! # The arm must be large relative to one closure call
96//!
97//! This harness costs a closure call plus a `black_box` per iteration, and it
98//! calls the arm through a `&mut F`. That cost lands in **both** arms, so it
99//! cannot manufacture a winner on its own — with an equal per-call overhead
100//! `c`, a true ratio `b / a` is measured as `(b + c) / (a + c)`, which is
101//! monotone toward 1 and **never crosses it**.
102//!
103//! What it can do is let a *difference* in that overhead decide a small margin.
104//! The two arms are distinct closures wrapping distinct callees, so they need
105//! not inline identically, and the residual asymmetry is a fixed number of
106//! nanoseconds rather than a fraction of the arm.
107//!
108//! Measured: an SLS value/gradient/Hessian gate whose arm was **one row**
109//! (~43 ns) read `42.77 / 44.17 ns` under the old min-of-N harness and
110//! `90.25 / 87.50 ns` here — both arms roughly doubled, and the verdict changed
111//! sign. Solving `(44.17 + c) / (42.77 + c) = 0.9668` needs `c = -85 ns`, so a
112//! symmetric overhead cannot explain it; a ~4 ns asymmetry between the two
113//! closures can, because the quantity under test was only 1.4 ns.
114//!
115//! Batching that same gate to 64 rows per call (arm ~2685 ns, overhead under
116//! 2%) settles it in the opposite direction and unanimously:
117//! `median_ratio = 1.045250, wins = 1.00, resolution = 0.0092` — generated is
118//! 4.5% faster, a margin 4.9x its own resolution. The one-row reading was
119//! measuring the harness.
120//!
121//! **So make one arm call do a batch.** Every other gate migrated to this
122//! harness already did without anyone choosing it — a 512-row pass, a full
123//! Fisher sweep, a bundle — which is why they were unaffected. A single-row
124//! arm is the case that needs an explicit inner loop, sized so the per-call
125//! cost is under ~1% of the arm.
126//!
127//! `PairedTiming::summary` prints the per-arm `ns/iter` precisely so this is
128//! checkable: if those numbers are of the same order as a function call, the
129//! ratio is not measuring what it claims to.
130//!
131//! # Why not measure the arms separately and normalise afterwards
132//!
133//! Because it does not work, and it fails in **both** directions. `iperf2`
134//! measured this directly on `gam-solve::inner_fit_core_scaling`, a gate whose
135//! two arms genuinely cannot be interleaved — one fans out over the whole Rayon
136//! pool and the other is held serial by a guard, so external load hurts them
137//! unequally. They divided the ratio by the parallel headroom the machine was
138//! delivering at that moment, measured on an embarrassingly parallel kernel:
139//!
140//! * **Normaliser sampled once.** Headroom on four saturated cores bounced
141//!   `2.36 / 1.28 / 2.81` across three consecutive repetitions. At the `1.28`
142//!   sample a genuinely serial solve scores `1.0 / 1.28 = 0.78` and **passes** a
143//!   `0.5` bar — a false green in which the gate certifies the exact defect it
144//!   exists to catch.
145//! * **Normaliser as max over five repetitions** (the right estimator for a
146//!   capability, since interference only pushes an observed speedup down). Fixes
147//!   the false green, and then loaded runs score `0.44` and `0.52` against the
148//!   same `0.5` bar — red on working code.
149//!
150//! The underlying reason generalises past that one gate:
151//!
152//! > **A ratio whose two arms are measured at different times, on a machine
153//! > whose load moves on that timescale, cannot be normalised after the fact.**
154//! > Interleaving per repetition is not tidiness — it is what makes the arms
155//! > share machine state instead of sampling it twice.
156//!
157//! The same lane's control is the cleanest demonstration that the *measurement*
158//! rather than the *code* is what breaks: on one node, same four cores, back to
159//! back, the identical solve scored `2.91` / `3.43` idle and `1.94` under four
160//! spinners — straddling its own bar with nothing about the solver changed. A
161//! width sweep at 2/4/8/16/32 cores on dedicated allocations tracked the pool
162//! width at every width.
163//!
164//! # When the arms cannot be interleaved at all
165//!
166//! Some comparisons are between configurations that *want different machines* —
167//! different core counts, different memory pressure — and no amount of
168//! interleaving makes them share state. For those, take the confound away from
169//! the measurement instead of modelling it: `.config/nextest.toml` supports
170//!
171//! ```toml
172//! [[profile.default.overrides]]
173//! threads-required = 'num-test-threads'
174//! ```
175//!
176//! which reserves every runner slot so the test runs alone. It is already in use
177//! in this repository for exactly this reason. The general rule, which is worth
178//! more than the mechanism: **before building something to cancel an
179//! environmental confound, check whether the runner can remove the confound
180//! instead.**
181//!
182//! # Using it as a gate
183//!
184//! Open a `SpeedGate` (release profile only — the test decides), record one
185//! paired cell per contract with `SpeedGate::faster` or
186//! `SpeedGate::not_slower`, and `SpeedGate::finish`. The gate prints
187//! `PairedTiming::summary` for every cell whatever the outcome and asserts on
188//! `PairedTiming::median_ratio` alone; `wins_fraction` and
189//! `ratio_resolution` travel on the same line as evidence (see above for why
190//! `wins` must not be a bar). Arms of a few tens of nanoseconds go through
191//! `batched`, so the harness's own per-call cost is not what is measured.
192//!
193//! **Lead a report with `wins_fraction`, not the ratio.** It is the statistic that
194//! survives someone disbelieving the rest of the output. `wins == 1.0` over `n`
195//! repetitions is a sign test at `2⁻ⁿ` — 15 repetitions is `≈3e-5` — and it is
196//! **distribution-free**: it does not depend on `PairedTiming::ratio_resolution`
197//! being correctly characterised, which is the one number a skeptic can
198//! reasonably question. The ratio says *how much*; `wins` says *whether*. When
199//! the first real migration onto this harness reported `median_ratio=0.938934`
200//! with `wins=0.00`, it was the `wins` that settled a question two lanes had
201//! been arguing from opposite directions.
202//!
203//! # The design lesson, for the next gate
204//!
205//! `PairedTiming::first_position_bias` is here because of a specific failure:
206//! a fixed-order harness cannot separate a real 6% margin from a 6%
207//! first-versus-second offset, since **both** produce a stable ratio with noisy
208//! absolutes. The pre-existing answer was to run the whole gate a second time
209//! with the arms swapped and see whether the verdict flipped — which works, but
210//! only ever yields yes/no, costs a full second measurement, and has to be
211//! redone by hand every time anyone doubts it.
212//!
213//! Randomising the order and reporting the residual **apportions** the confound
214//! instead: on the measurement above, ordering contributed 0.0001 and the code
215//! contributed 0.061. Generalising:
216//!
217//! > **Report a confound as a measured field rather than eliminating it by
218//! > argument.** An argument that a confound was controlled has to be re-made,
219//! > and re-believed, by every later reader. A field in the output is checked
220//! > once and then simply read.
221//!
222//! That is the property to copy when building the next gate here, more than any
223//! particular statistic in this module.
224
225use std::hint::black_box;
226use std::time::Instant;
227
228/// Paired per-repetition timings for two implementations of one computation.
229///
230/// `a_ns[i]` and `b_ns[i]` were measured adjacent in time within repetition `i`,
231/// in an order chosen by the repetition's coin flip. `ratios[i]` is
232/// `b_ns[i] / a_ns[i]`, so a value **above 1 means A is faster** — the same
233/// orientation as the `hand_over_production` token these gates already print.
234#[derive(Clone, Debug)]
235pub struct PairedTiming {
236    /// Nanoseconds per iteration for arm A, one entry per repetition.
237    pub a_ns: Vec<f64>,
238    /// Nanoseconds per iteration for arm B, one entry per repetition.
239    pub b_ns: Vec<f64>,
240    /// `b_ns[i] / a_ns[i]`, one entry per repetition. Above 1 ⇒ A faster.
241    pub ratios: Vec<f64>,
242    /// `true` when arm A was timed first in that repetition.
243    pub a_went_first: Vec<bool>,
244}
245
246impl PairedTiming {
247    /// Median of the paired ratios — the headline estimate.
248    ///
249    /// The median rather than the mean because a single descheduled repetition
250    /// produces an arbitrarily large outlier in one direction only, and rather
251    /// than a minimum because a minimum is the statistic that preserves a
252    /// systematic offset (see the module docs).
253    pub fn median_ratio(&self) -> f64 {
254        median(&self.ratios)
255    }
256
257    /// Fraction of repetitions in which A was faster than B.
258    ///
259    /// This is the gate's honesty check. A median ratio of 1.06 with a wins
260    /// fraction of 1.0 is a real effect; the same 1.06 with 0.55 means the
261    /// repetitions disagree with each other and the point estimate is riding on
262    /// a few draws.
263    pub fn wins_fraction(&self) -> f64 {
264        if self.ratios.is_empty() {
265            return f64::NAN;
266        }
267        let wins = self
268            .a_ns
269            .iter()
270            .zip(self.b_ns.iter())
271            .filter(|(a, b)| a < b)
272            .count();
273        wins as f64 / self.a_ns.len() as f64
274    }
275
276    /// Half the central 90% span of the paired ratios, as a fraction of the
277    /// median — the gate's own **resolution**.
278    ///
279    /// A claimed margin smaller than this is not measurable by this harness at
280    /// this repetition count, and asserting it is asserting noise. Report it
281    /// beside the margin so the comparison is visible.
282    pub fn ratio_resolution(&self) -> f64 {
283        if self.ratios.len() < 2 {
284            return f64::NAN;
285        }
286        let mut sorted = self.ratios.clone();
287        sorted.sort_by(f64::total_cmp);
288        let lo = quantile_sorted(&sorted, 0.05);
289        let hi = quantile_sorted(&sorted, 0.95);
290        let med = median(&self.ratios);
291        if !med.is_finite() || med == 0.0 {
292            return f64::NAN;
293        }
294        ((hi - lo) / 2.0 / med).abs()
295    }
296
297    /// Median ratio among repetitions where A ran first, minus the median among
298    /// those where B ran first.
299    ///
300    /// This is the diagnostic the old harnesses could not produce, because they
301    /// never varied the order. A value near zero says position does not matter
302    /// on this host; a large value says the measurement is dominated by
303    /// whichever arm goes first, and **no ordering of a non-randomised harness
304    /// would have been trustworthy**. Returns `NaN` if either group is empty.
305    pub fn first_position_bias(&self) -> f64 {
306        let a_first: Vec<f64> = self
307            .ratios
308            .iter()
309            .zip(self.a_went_first.iter())
310            .filter(|(_, first)| **first)
311            .map(|(r, _)| *r)
312            .collect();
313        let b_first: Vec<f64> = self
314            .ratios
315            .iter()
316            .zip(self.a_went_first.iter())
317            .filter(|(_, first)| !**first)
318            .map(|(r, _)| *r)
319            .collect();
320        if a_first.is_empty() || b_first.is_empty() {
321            return f64::NAN;
322        }
323        median(&a_first) - median(&b_first)
324    }
325
326    /// One line carrying everything needed to audit the verdict, including the
327    /// numbers that would reveal the verdict as unsupported.
328    pub fn summary(&self, a_label: &str, b_label: &str) -> String {
329        format!(
330            "{a_label}={:.2} ns/iter {b_label}={:.2} ns/iter \
331             median_ratio={:.6} wins={:.2} resolution={:.4} position_bias={:.4} reps={}",
332            median(&self.a_ns),
333            median(&self.b_ns),
334            self.median_ratio(),
335            self.wins_fraction(),
336            self.ratio_resolution(),
337            self.first_position_bias(),
338            self.ratios.len(),
339        )
340    }
341}
342
343/// Time two implementations against each other, interleaved per repetition with
344/// a randomised order.
345///
346/// Each arm is a closure taking a perturbation seeded from the running checksum
347/// and returning a value folded back into it, so consecutive iterations carry a
348/// data dependence the optimizer cannot hoist across. Both closures must compute
349/// the SAME quantity — this measures speed and assumes agreement has already
350/// been established by a separate parity assertion.
351///
352/// `seed` fixes the order sequence, so a run is reproducible; vary it to check a
353/// verdict is not an artifact of one particular order sequence.
354///
355/// # Panics
356///
357/// If `reps` or `iterations` is zero, or if either arm's accumulated checksum is
358/// not finite — a non-finite checksum means the timed body degenerated (NaN
359/// short-circuits are often much faster) and the timing is meaningless.
360pub fn paired_interleaved<A, B>(
361    reps: usize,
362    iterations: usize,
363    seed: u64,
364    mut arm_a: A,
365    mut arm_b: B,
366) -> PairedTiming
367where
368    A: FnMut(f64) -> f64,
369    B: FnMut(f64) -> f64,
370{
371    assert!(reps > 0, "paired_interleaved needs at least one repetition");
372    assert!(
373        iterations > 0,
374        "paired_interleaved needs at least one iteration per repetition"
375    );
376
377    let mut a_ns = Vec::with_capacity(reps);
378    let mut b_ns = Vec::with_capacity(reps);
379    let mut ratios = Vec::with_capacity(reps);
380    let mut a_went_first = Vec::with_capacity(reps);
381    let mut rng = SplitMix64::new(seed);
382
383    for _ in 0..reps {
384        let a_first = rng.next_bool();
385        let (ta, tb) = if a_first {
386            let ta = time_arm(iterations, &mut arm_a);
387            let tb = time_arm(iterations, &mut arm_b);
388            (ta, tb)
389        } else {
390            let tb = time_arm(iterations, &mut arm_b);
391            let ta = time_arm(iterations, &mut arm_a);
392            (ta, tb)
393        };
394        ratios.push(tb / ta);
395        a_ns.push(ta);
396        b_ns.push(tb);
397        a_went_first.push(a_first);
398    }
399
400    PairedTiming {
401        a_ns,
402        b_ns,
403        ratios,
404        a_went_first,
405    }
406}
407
408/// Whether a wall-clock ratio measured in this build is about the SHIPPED
409/// codegen.
410///
411/// It is not the optimisation level: `[profile.test]` already carries
412/// `opt-level = 2`. It is codegen LAYOUT. `[profile.test.package.gam-models]`
413/// sets `codegen-units = 16` and the test profile carries no LTO, while
414/// `[profile.release]` is `codegen-units = 1` plus thin-LTO, and the whole
415/// margin of a compiled-vs-hand row kernel can be cross-CGU inlining. A ratio
416/// taken in the test profile therefore measures a different program than the
417/// one that ships, and a debug build measures fixed per-call overhead and
418/// nothing else. Every speed gate in this workspace opens only there.
419///
420/// That decision is made by the TEST that opens the gate, never by this
421/// module: test code may query its own build configuration, library code may
422/// not (`build.rs` bans `cfg!(debug_assertions)` outside test modules, because
423/// a library branch that only runs in one build configuration silently means
424/// something else in the other). A gate opened in the dev lane would assert
425/// about the wrong program, so the test returns before opening it:
426///
427/// ```text
428/// if cfg!(debug_assertions) {
429///     return; // dev lane: the codegen is not the shipped one
430/// }
431/// let mut gate = SpeedGate::open("RIGID-BERNOULLI-VGH-932");
432/// ```
433/// One speed gate: a named set of paired cells, each printed as it is
434/// measured and all asserted together at the end.
435///
436/// This is the ONE shape a wall-clock contract takes in this workspace, and
437/// its call site is the marker the release lane derives the gate population
438/// from: `scripts/speed_gates.py` walks the crates for every `#[test]` whose
439/// body calls `SpeedGate::open`, resolves each to an exact test path in the
440/// compiled release binary, runs exactly that set, and refuses a run in which
441/// any derived gate did not execute. A gate therefore cannot be forgotten by
442/// a name-prefix filter, cannot print `ok` having asserted nothing, and
443/// cannot assert in a lane whose codegen is not the shipped one.
444///
445/// # Shape of a gate
446///
447/// ```text
448/// // parity pins run in EVERY build, before the gate opens
449/// if cfg!(debug_assertions) {
450///     return; // dev lane: skip the measurement, its verdict is about the wrong program
451/// }
452/// let mut gate = SpeedGate::open("RIGID-BERNOULLI-VGH-932");
453/// let timing = paired_interleaved(15, 300_000, seed, production_arm, hand_arm);
454/// gate.faster("y=1", &timing, "production", "hand");
455/// gate.finish();
456/// ```
457///
458/// The profile check is the test's, not the gate's (see the module
459/// documentation above): a gate that is opened always asserts, and the dev
460/// lane does not pay for millions of timed iterations whose result it could
461/// not use because the test never opens one there.
462///
463/// # Two contracts, no third
464///
465/// * `SpeedGate::faster` — the #932 contract: A (the compiled lowering) must
466///   be strictly faster than B (the strongest hand path or the generic tower
467///   it specialises). Loss when `median_ratio() <= 1`.
468/// * `SpeedGate::not_slower` — for a cell whose two arms do the same work by
469///   construction and where no speed claim is made: A must not be measurably
470///   slower than B, where "measurably" is the measurement's OWN resolution,
471///   `PairedTiming::ratio_resolution`. Loss when
472///   `median_ratio() + ratio_resolution() < 1`. There is no chosen tolerance
473///   here: the instrument reports its noise floor, and that is the only
474///   denominator a parity bar can honestly be stated in.
475///
476/// A gate that is opened and dropped without `SpeedGate::finish` panics, and
477/// a gate finished with no cells panics: both are gates that verified nothing.
478pub struct SpeedGate {
479    token: &'static str,
480    cells: usize,
481    losses: Vec<String>,
482    finished: bool,
483}
484
485impl SpeedGate {
486    /// Open a gate. It always asserts; the test decides whether this build is
487    /// one whose verdict is meaningful before calling (see the type docs).
488    ///
489    /// `token` is the stable, grep-able prefix every cell line of this gate
490    /// is printed under (for example `RIGID-BERNOULLI-VGH-932`).
491    #[must_use]
492    pub fn open(token: &'static str) -> Self {
493        Self {
494            token,
495            cells: 0,
496            losses: Vec::new(),
497            finished: false,
498        }
499    }
500
501    fn record(&mut self, verdict: &str, cell: &str, timing: &PairedTiming, a: &str, b: &str) {
502        self.cells += 1;
503        eprintln!(
504            "{} {cell} {} verdict={verdict}",
505            self.token,
506            timing.summary(a, b),
507        );
508        if verdict != "pass" {
509            self.losses
510                .push(format!("{cell}: {} ({verdict})", timing.summary(a, b)));
511        }
512    }
513
514    /// Record a cell whose contract is "A is strictly faster than B".
515    pub fn faster(&mut self, cell: &str, timing: &PairedTiming, a: &str, b: &str) {
516        let verdict = if timing.median_ratio() > 1.0 {
517            "pass"
518        } else {
519            "FAIL: A must be faster than B"
520        };
521        self.record(verdict, cell, timing, a, b);
522    }
523
524    /// Record a cell whose contract is "A is not measurably slower than B",
525    /// measurable meaning beyond the paired measurement's own resolution.
526    pub fn not_slower(&mut self, cell: &str, timing: &PairedTiming, a: &str, b: &str) {
527        let verdict = if timing.median_ratio() + timing.ratio_resolution() >= 1.0 {
528            "pass"
529        } else {
530            "FAIL: A is slower than B beyond the measurement's resolution"
531        };
532        self.record(verdict, cell, timing, a, b);
533    }
534
535    /// Assert that every recorded cell met its contract, naming all that did
536    /// not. Consumes the gate.
537    pub fn finish(mut self) {
538        self.finished = true;
539        assert!(
540            self.cells > 0,
541            "{}: a speed gate finished with no measured cell verifies nothing",
542            self.token
543        );
544        assert!(
545            self.losses.is_empty(),
546            "{}: {} of {} cell(s) failed their speed contract:\n{}",
547            self.token,
548            self.losses.len(),
549            self.cells,
550            self.losses.join("\n"),
551        );
552    }
553}
554
555impl Drop for SpeedGate {
556    fn drop(&mut self) {
557        // SAFETY: a gate dropped without `finish()` verified nothing, and a
558        // test that reached this point would otherwise print `ok`; failing
559        // loudly is the whole contract, and the `panicking()` guard keeps an
560        // unwinding test from double-panicking.
561        if !self.finished && !std::thread::panicking() {
562            panic!(
563                "{}: a speed gate was opened and dropped without `finish()`; it asserted nothing",
564                self.token
565            );
566        }
567    }
568}
569
570/// Make one arm call evaluate `rows` rows, so the arm is large relative to the
571/// harness's per-call cost (see the module documentation: a single-row arm of a
572/// few tens of nanoseconds lets a few nanoseconds of closure-inlining asymmetry
573/// decide a small margin, and a 43 ns arm changed sign under batching).
574///
575/// The rows are independent of one another, as production's rows are: a data
576/// loop hands each row its own inputs and folds the results, and the processor
577/// overlaps consecutive rows, so what a batch measures is throughput. The
578/// first version of this adapter chained the rows instead -- row `i + 1` was
579/// perturbed by the fold of rows `0..=i` -- and that measured the latency of
580/// one input's path through the arm, not the arm's work: the tower-3 prune of
581/// the binomial coefficients, which does strictly less arithmetic than the
582/// full tower, went from a 1.56x win under one call per iteration to a 0.99
583/// loss under the chain, and two order-4 formulas within one instruction of
584/// each other in every class were ranked by where the nudged coefficient
585/// enters the expression.
586///
587/// Each row is perturbed by a distinct multiple of a negligible step, so no
588/// two rows of a batch share an input: a pure outlined arm's calls cannot be
589/// merged, and an inlined arm cannot be evaluated once for the batch. What
590/// this adapter cannot prevent is the hoisting of an inlined arm's
591/// fixture-invariant work out of the batch; an arm that is timed must be
592/// outlined (`#[inline(never)]`) or take its fixture through an opaque
593/// boundary, and that is the arm's responsibility, not the adapter's. The
594/// returned closure has the `FnMut(f64) -> f64` shape the harness times.
595pub fn batched<F: FnMut(f64) -> f64>(rows: usize, mut arm: F) -> impl FnMut(f64) -> f64 {
596    assert!(rows > 0, "a batched arm needs at least one row");
597    move |nudge| {
598        let mut fold = 0.0_f64;
599        for row in 0..rows {
600            fold += arm(nudge + row as f64 * 1e-18);
601        }
602        fold
603    }
604}
605
606/// Nanoseconds per iteration for one arm of one repetition.
607///
608/// The `checksum * 1e-18` perturbation is a feedback barrier, not a nudge: it
609/// makes iteration `n + 1` depend on the result of iteration `n`, which is what
610/// prevents the loop being hoisted or vectorised into something that is not the
611/// per-call cost being claimed. The scale is small enough that the arithmetic
612/// stays in the intended regime.
613fn time_arm<F: FnMut(f64) -> f64>(iterations: usize, arm: &mut F) -> f64 {
614    let mut checksum = 0.0_f64;
615    let started = Instant::now();
616    for _ in 0..iterations {
617        checksum += arm(black_box(checksum * 1e-18));
618    }
619    let elapsed = started.elapsed().as_secs_f64();
620    assert!(
621        black_box(checksum).is_finite(),
622        "timed arm accumulated a non-finite checksum, so the loop it timed is \
623         not the computation being compared"
624    );
625    elapsed * 1e9 / iterations as f64
626}
627
628fn median(values: &[f64]) -> f64 {
629    if values.is_empty() {
630        return f64::NAN;
631    }
632    let mut sorted = values.to_vec();
633    sorted.sort_by(f64::total_cmp);
634    let mid = sorted.len() / 2;
635    if sorted.len() % 2 == 1 {
636        sorted[mid]
637    } else {
638        0.5 * (sorted[mid - 1] + sorted[mid])
639    }
640}
641
642/// Linear-interpolated quantile of an already-sorted slice.
643fn quantile_sorted(sorted: &[f64], q: f64) -> f64 {
644    if sorted.is_empty() {
645        return f64::NAN;
646    }
647    if sorted.len() == 1 {
648        return sorted[0];
649    }
650    let pos = q.clamp(0.0, 1.0) * (sorted.len() - 1) as f64;
651    let lo = pos.floor() as usize;
652    let hi = pos.ceil() as usize;
653    let frac = pos - lo as f64;
654    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
655}
656
657/// SplitMix64 — a deterministic order sequence, so the interleave is randomised
658/// but a run is reproducible. Deliberately not a dependency: the harness must
659/// not be able to perturb the timing through an allocation or a dynamic call.
660struct SplitMix64(u64);
661
662impl SplitMix64 {
663    fn new(seed: u64) -> Self {
664        Self(seed)
665    }
666
667    fn next_u64(&mut self) -> u64 {
668        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
669        let mut z = self.0;
670        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
671        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
672        z ^ (z >> 31)
673    }
674
675    fn next_bool(&mut self) -> bool {
676        self.next_u64() & 1 == 1
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    /// Two arms doing identical work must land near ratio 1, and the harness
685    /// must say so through `wins_fraction` too: identical arms should win about
686    /// half the time, which is the signature a gate uses to recognise "no
687    /// measurable difference" rather than reading a point estimate of 1.02 as a
688    /// 2% win.
689    #[test]
690    fn identical_arms_report_no_winner() {
691        let work = |x: f64| {
692            let mut acc = x;
693            for i in 0..64 {
694                acc = acc.mul_add(1.000_001, (i as f64) * 1e-9);
695            }
696            acc
697        };
698        let timing = paired_interleaved(21, 2_000, 0xA5A5_1234, work, work);
699        let median = timing.median_ratio();
700        assert!(
701            (median - 1.0).abs() < 0.35,
702            "identical arms should sit near ratio 1: {}",
703            timing.summary("a", "b")
704        );
705        let wins = timing.wins_fraction();
706        assert!(
707            (0.15..=0.85).contains(&wins),
708            "identical arms should win about half the repetitions: {}",
709            timing.summary("a", "b")
710        );
711    }
712
713    /// A genuinely faster arm must be detected with every repetition agreeing —
714    /// the property that separates a real margin from one inside the noise.
715    #[test]
716    fn a_large_real_difference_is_detected_unanimously() {
717        let fast = |x: f64| {
718            let mut acc = x;
719            for i in 0..16 {
720                acc = acc.mul_add(1.000_001, (i as f64) * 1e-9);
721            }
722            acc
723        };
724        let slow = |x: f64| {
725            let mut acc = x;
726            for i in 0..256 {
727                acc = acc.mul_add(1.000_001, (i as f64) * 1e-9);
728            }
729            acc
730        };
731        let timing = paired_interleaved(15, 2_000, 0x5EED, fast, slow);
732        assert!(
733            timing.median_ratio() > 2.0,
734            "a 16x work difference must show as a large ratio: {}",
735            timing.summary("fast", "slow")
736        );
737        assert_eq!(
738            timing.wins_fraction(),
739            1.0,
740            "a large real difference must win EVERY repetition: {}",
741            timing.summary("fast", "slow")
742        );
743    }
744
745    /// The order must actually vary. A harness that believes it randomises but
746    /// does not is indistinguishable from the ones being replaced, and the
747    /// position-bias diagnostic would silently become `NaN`.
748    #[test]
749    fn both_orders_occur_and_position_bias_is_reportable() {
750        let work = |x: f64| x.mul_add(1.000_001, 1e-9);
751        let timing = paired_interleaved(20, 500, 7, work, work);
752        let a_first = timing.a_went_first.iter().filter(|f| **f).count();
753        assert!(
754            a_first > 0 && a_first < timing.a_went_first.len(),
755            "both arm orders must occur across repetitions, got {a_first} of {}",
756            timing.a_went_first.len()
757        );
758        assert!(
759            timing.first_position_bias().is_finite(),
760            "position bias must be reportable once both orders occur"
761        );
762    }
763
764    /// `ratio_resolution` is what tells a caller whether its bar is assertable.
765    /// It must be finite and positive on a real measurement, or the gate has no
766    /// way to know it is asserting inside its own noise.
767    #[test]
768    fn resolution_is_reported_and_positive() {
769        let work = |x: f64| x.mul_add(1.000_001, 1e-9);
770        let timing = paired_interleaved(15, 500, 99, work, work);
771        let resolution = timing.ratio_resolution();
772        assert!(
773            resolution.is_finite() && resolution > 0.0,
774            "resolution must be a usable number: {}",
775            timing.summary("a", "b")
776        );
777    }
778
779    /// The summary must carry the numbers that could overturn the verdict, not
780    /// just the verdict. A gate that prints only the ratio is how a 3% claim
781    /// with 6% resolution gets read as established.
782    #[test]
783    fn summary_carries_the_overturning_numbers() {
784        let work = |x: f64| x.mul_add(1.000_001, 1e-9);
785        let timing = paired_interleaved(9, 500, 3, work, work);
786        let line = timing.summary("production", "hand");
787        for field in [
788            "production=",
789            "hand=",
790            "median_ratio=",
791            "wins=",
792            "resolution=",
793            "position_bias=",
794            "reps=",
795        ] {
796            assert!(line.contains(field), "summary is missing {field}: {line}");
797        }
798    }
799
800    #[test]
801    #[should_panic(expected = "at least one repetition")]
802    fn zero_repetitions_is_refused_not_silently_empty() {
803        let work = |x: f64| x;
804        // Called as a bare statement. A discarding binding is banned in this
805        // workspace, and it would be the wrong shape regardless: this call is
806        // expected to panic, so there is no result to discard.
807        paired_interleaved(0, 10, 1, work, work);
808    }
809}