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}