cove_bench/stats.rs
1//! What a series of timings says, and whether two series differ.
2//!
3//! [Issue #179](https://github.com/myuon/cove/issues/179) records that this
4//! repository could not write the sentence a refactor most wants to write —
5//! "no statistically meaningful regression" — because `cove-bench` reported
6//! `{min, mean, max}` and nothing else. Three numbers are not a claim; they
7//! are three numbers a reader eyeballs against a band they remember.
8//!
9//! # Why the median and the quartiles
10//!
11//! A wall-time series has a floor and no ceiling. The machine cannot run the
12//! benchmark faster than the machine runs it, and it can always run it slower
13//! — a descheduled turn, a migration between cores, a neighbour waking up. So
14//! the failure mode of a benchmark timing is a sample that is too *large*, and
15//! the mean is the statistic that moves furthest when one arrives. The median
16//! does not move at all until half the samples are affected, which is the
17//! property that makes it the right summary of a series taken on a machine
18//! that is not perfectly quiet.
19//!
20//! That is the argument from the shape of the failure, and it is the one to
21//! rely on. The argument from skew is weaker than it looks, and it is recorded
22//! here because it was checked rather than assumed. The only distributions
23//! this repository has written down are the nine rows of the
24//! calling-convention matrix, taken at nine samples a row on a quiet machine
25//! and reported as `{median, min, max}`. That table measured the backend
26//! ADR 0034 deleted and went with it; `docs/VM_ARCHITECTURE.md` restates the
27//! counts below, and the table itself is in git history at commit `6e90085`.
28//! If those series were reliably right-skewed then `max - median` would
29//! exceed `median - min` on most of them. It exceeds it on three rows, falls
30//! short on five, and ties on one. So "benchmark timings are right-skewed" is
31//! not a claim this data supports and is not what justifies the median here.
32//! What justifies it is that one bad sample must not be able to move the
33//! number a decision is made on — and that argument does not need the skew,
34//! because a statistic robust to an outlier in either direction is what a
35//! decision wants either way.
36//!
37//! Two caveats on that check, because it is weaker than a real one. Three
38//! order statistics are not a distribution, and nine samples put both
39//! extremes deep in the tails where they are noisiest. A stronger check is
40//! now possible and was not before: `wall_ns.samples` records every timing, so
41//! whoever next takes a run on a quiet machine can look at the actual shape
42//! rather than at its extremes.
43//!
44//! The mean, the minimum and the maximum are all still reported. The mean
45//! because [ADR 0012](../../../docs/adr/0012-performance-gate-and-native-backend.md)
46//! says wall time is reported as `{min, mean, max}` and a reader of that
47//! format must keep finding it; the extremes because they are the cheapest
48//! way to see that a series went wrong, and a summary that hides the one
49//! run that took four times as long is worse than no summary.
50//!
51//! # Why a comparison, and not just a spread
52//!
53//! A spread on one run says how noisy that run was. It does not say whether
54//! this build is slower than that build, which is the actual question, and
55//! [issue #126](https://github.com/myuon/cove/issues/126) is the reason it
56//! has to be asked against a *fixed commit* rather than against the parent:
57//! three changes each individually inside the noise summed to 19%. A
58//! comparison against whatever ran last cannot see that, and a comparison
59//! against a recorded baseline can.
60//!
61//! So [`Comparison`] takes the baseline's samples and this run's samples and
62//! answers with the shift between their medians and an interval around it.
63//! The verdict is read off the interval: an interval that excludes zero is a
64//! difference that cleared the noise, and one that contains zero is not — and
65//! in that case the interval's own width is the honest bound on what the
66//! change could have cost, which is the number to quote instead of claiming
67//! there was no effect.
68
69use std::collections::BTreeMap;
70
71/// How many samples a side needs before a comparison will call anything.
72///
73/// Six, and the reason is exact rather than a rule of thumb. The
74/// distribution-free interval for a median is a pair of order statistics, and
75/// the widest one available is the whole range: `[min, max]` fails to cover
76/// the true median only when every sample falls on the same side of it, which
77/// for `n` samples has probability `2 * (1/2)^n`. Asking for 95% confidence
78/// therefore asks for `2^(n-1) >= 20`, which first holds at `n = 6`. Below
79/// six samples there is no 95% statement to be made about one median, let
80/// alone about the difference of two, so a comparison that made one would be
81/// inventing it.
82///
83/// This is a floor and not a recommendation. `docs/VM_ARCHITECTURE.md` takes
84/// its tables at fifteen.
85pub const MIN_SAMPLES: usize = 6;
86
87/// How many resamples the interval below is built from.
88///
89/// Ten thousand is enough that the 2.5th and 97.5th percentiles of the
90/// resampled statistic are stable to well under a tenth of a percent, and it
91/// costs microseconds at the sample counts this harness produces.
92const RESAMPLES: usize = 10_000;
93
94/// The confidence the reported interval carries.
95pub const CONFIDENCE: f64 = 0.95;
96
97/// The seed the resampling starts from.
98///
99/// Fixed, so that the same two series always produce the same interval and
100/// the same verdict. A tool that answered differently on a rerun of
101/// identical data would be asking to be rerun until it agreed.
102const SEED: u64 = 0x0000_0C0F_FEE5_EED0;
103
104/// A series of samples and the order statistics read off it.
105///
106/// Holds the samples twice: sorted, because everything below is an order
107/// statistic, and in the order the run took them, because that is what the
108/// report carries and a baseline is only useful if the samples themselves
109/// survive into it — a summary cannot be compared against a later run with
110/// anything better than arithmetic on summaries.
111///
112/// The two copies exist for a reason a sorted one alone cannot serve.
113/// [Issue #205](https://github.com/myuon/cove/issues/205) asks whether a
114/// run-to-run disagreement is drift *within* a suite or *between* two of
115/// them, and a sorted array cannot answer it: whether the slow samples were
116/// the first three or the last three is exactly the information sorting
117/// throws away. Nothing in this file reads the run order — every statistic
118/// below is an order statistic and the bootstrap resamples with replacement
119/// — so keeping it costs one vector and buys a question that could not be
120/// asked before.
121pub struct Stats {
122 sorted: Vec<u64>,
123 /// The same samples, in the order the run took them. Reported; not read.
124 taken: Vec<u64>,
125 mean: u64,
126}
127
128impl Stats {
129 /// Summarizes `samples`, which must not be empty.
130 pub fn of(samples: &[u64]) -> Stats {
131 assert!(!samples.is_empty(), "a series has at least one sample");
132 let taken = samples.to_vec();
133 let mut sorted = taken.clone();
134 sorted.sort_unstable();
135 let sum: u128 = sorted.iter().map(|&n| u128::from(n)).sum();
136 let mean = (sum / sorted.len() as u128) as u64;
137 Stats {
138 sorted,
139 taken,
140 mean,
141 }
142 }
143
144 /// The samples, in ascending order.
145 pub fn samples(&self) -> &[u64] {
146 &self.sorted
147 }
148
149 pub fn min(&self) -> u64 {
150 self.sorted[0]
151 }
152
153 pub fn max(&self) -> u64 {
154 self.sorted[self.sorted.len() - 1]
155 }
156
157 /// The arithmetic mean, truncated. Kept because ADR 0012 names it.
158 pub fn mean(&self) -> u64 {
159 self.mean
160 }
161
162 pub fn median(&self) -> f64 {
163 quantile(&self.sorted, 0.5)
164 }
165
166 pub fn p25(&self) -> f64 {
167 quantile(&self.sorted, 0.25)
168 }
169
170 pub fn p75(&self) -> f64 {
171 quantile(&self.sorted, 0.75)
172 }
173
174 /// The interquartile range: the width of the middle half of the series.
175 ///
176 /// This is the spread to read. Unlike `max - min` it does not grow just
177 /// because the series got longer and so had more chances to catch one
178 /// bad sample.
179 pub fn iqr(&self) -> f64 {
180 self.p75() - self.p25()
181 }
182
183 /// The summary, as a JSON object.
184 ///
185 /// `min`, `mean` and `max` are first and keep their names, because ADR
186 /// 0012 describes this object as `{min, mean, max}` and a reader written
187 /// against that description must keep working.
188 pub fn to_json(&self) -> String {
189 format!(
190 "{{\"min\":{},\"mean\":{},\"max\":{},\"p25\":{:.1},\"median\":{:.1},\"p75\":{:.1},\"iqr\":{:.1}}}",
191 self.min(),
192 self.mean(),
193 self.max(),
194 self.p25(),
195 self.median(),
196 self.p75(),
197 self.iqr(),
198 )
199 }
200
201 /// The summary with every sample beside it.
202 ///
203 /// What makes a recorded run a baseline rather than a memory of one: a
204 /// comparison needs the samples, not a summary of them, because the
205 /// interval it reports is built by resampling them.
206 ///
207 /// **In the order the run took them**, which is strictly more than a
208 /// sorted array says and costs a reader who wanted the sorted one a
209 /// `sort`. Nothing that compares two runs is affected — every statistic
210 /// here is an order statistic and [`Comparison::of`] sorts what it is
211 /// given — and what it buys is that a reader can see *when* in a series a
212 /// slow sample arrived, which is the difference between a machine that
213 /// drifted and a benchmark that is noisy.
214 pub fn to_json_with_samples(&self) -> String {
215 let mut json = self.to_json();
216 json.pop();
217 let samples: Vec<String> = self.taken.iter().map(u64::to_string).collect();
218 json.push_str(&format!(",\"samples\":[{}]}}", samples.join(",")));
219 json
220 }
221}
222
223/// The `p`th quantile of an ascending series, by linear interpolation
224/// between the two order statistics that bracket it.
225///
226/// This is the definition NumPy and R use by default (R's type 7), chosen
227/// because it is the one a reader who reaches for another tool to check this
228/// one will get from it. At `p = 0.5` it is the ordinary median: the middle
229/// sample of an odd series, the average of the two middle samples of an even
230/// one.
231pub fn quantile(sorted: &[u64], p: f64) -> f64 {
232 assert!(!sorted.is_empty(), "a series has at least one sample");
233 let h = (sorted.len() - 1) as f64 * p;
234 let lower = h.floor() as usize;
235 let upper = h.ceil() as usize;
236 let below = sorted[lower] as f64;
237 let above = sorted[upper] as f64;
238 below + (h - lower as f64) * (above - below)
239}
240
241/// What a comparison concluded.
242#[derive(Clone, Copy, PartialEq, Eq, Debug)]
243pub enum Verdict {
244 /// One side has fewer than [`MIN_SAMPLES`] samples, so no interval is
245 /// available and nothing is claimed. The shift is still reported, as a
246 /// number to look at rather than a number to act on.
247 Underpowered,
248 /// The interval contains zero. The difference, whatever it is, is not
249 /// separable from the noise of these two runs.
250 InsideTheNoise,
251 /// The interval lies entirely above zero: this run is slower.
252 Regression,
253 /// The interval lies entirely below zero: this run is faster.
254 Improvement,
255}
256
257impl Verdict {
258 pub fn as_str(self) -> &'static str {
259 match self {
260 Verdict::Underpowered => "underpowered",
261 Verdict::InsideTheNoise => "inside the noise",
262 Verdict::Regression => "regression",
263 Verdict::Improvement => "improvement",
264 }
265 }
266}
267
268/// This run's median against a baseline's, and how sure of the difference the
269/// two series allow anyone to be.
270#[derive(Clone, Copy, Debug)]
271pub struct Comparison {
272 pub baseline_median_ns: f64,
273 pub median_ns: f64,
274 /// The shift between the two medians, as a percentage of the baseline's.
275 /// Positive is slower.
276 pub delta_pct: f64,
277 /// The interval around `delta_pct`, at [`CONFIDENCE`].
278 pub low_pct: f64,
279 pub high_pct: f64,
280 pub verdict: Verdict,
281}
282
283impl Comparison {
284 /// Compares two series of wall times.
285 ///
286 /// The interval is a percentile bootstrap on the relative shift between
287 /// the medians: resample each side with replacement, take the two
288 /// medians, and record `(current - baseline) / baseline`. The 2.5th and
289 /// 97.5th percentiles of ten thousand such records are the interval.
290 ///
291 /// A bootstrap rather than a `t` test because nothing here is normal and
292 /// the statistic is a median; a percentile bootstrap rather than a rank
293 /// test because what a refactor needs is not only "is there a
294 /// difference" but "how large could it be", and the interval answers
295 /// both while a `p` value answers only the first.
296 pub fn of(baseline: &[u64], current: &[u64]) -> Comparison {
297 let mut base_sorted = baseline.to_vec();
298 base_sorted.sort_unstable();
299 let mut current_sorted = current.to_vec();
300 current_sorted.sort_unstable();
301
302 let baseline_median_ns = quantile(&base_sorted, 0.5);
303 let median_ns = quantile(¤t_sorted, 0.5);
304 let delta_pct = if baseline_median_ns > 0.0 {
305 100.0 * (median_ns - baseline_median_ns) / baseline_median_ns
306 } else {
307 0.0
308 };
309
310 // A zero baseline median has no relative shift to report, and a
311 // series shorter than the floor has no interval; both are
312 // `Underpowered`, which is this type's way of saying "the number
313 // beside this is not a claim".
314 if baseline.len() < MIN_SAMPLES || current.len() < MIN_SAMPLES || baseline_median_ns <= 0.0
315 {
316 return Comparison {
317 baseline_median_ns,
318 median_ns,
319 delta_pct,
320 low_pct: f64::NAN,
321 high_pct: f64::NAN,
322 verdict: Verdict::Underpowered,
323 };
324 }
325
326 let mut shifts = Vec::with_capacity(RESAMPLES);
327 let mut rng = Rng::new(SEED);
328 let mut base_draw = vec![0u64; base_sorted.len()];
329 let mut current_draw = vec![0u64; current_sorted.len()];
330 for _ in 0..RESAMPLES {
331 for slot in base_draw.iter_mut() {
332 *slot = base_sorted[rng.below(base_sorted.len())];
333 }
334 for slot in current_draw.iter_mut() {
335 *slot = current_sorted[rng.below(current_sorted.len())];
336 }
337 base_draw.sort_unstable();
338 current_draw.sort_unstable();
339 let b = quantile(&base_draw, 0.5);
340 let c = quantile(¤t_draw, 0.5);
341 shifts.push(if b > 0.0 { 100.0 * (c - b) / b } else { 0.0 });
342 }
343 shifts.sort_by(|a, b| a.partial_cmp(b).expect("no sample is NaN"));
344
345 let tail = (1.0 - CONFIDENCE) / 2.0;
346 let low_pct = percentile_f64(&shifts, tail);
347 let high_pct = percentile_f64(&shifts, 1.0 - tail);
348 let verdict = if low_pct > 0.0 {
349 Verdict::Regression
350 } else if high_pct < 0.0 {
351 Verdict::Improvement
352 } else {
353 Verdict::InsideTheNoise
354 };
355
356 Comparison {
357 baseline_median_ns,
358 median_ns,
359 delta_pct,
360 low_pct,
361 high_pct,
362 verdict,
363 }
364 }
365
366 /// The comparison as one line of the harness's JSON output.
367 ///
368 /// `kind` is `comparison` rather than the kind of the row compared, so
369 /// that a reader filtering on `kind` keeps finding exactly the rows it
370 /// was finding; `of` says which kind this line is about.
371 pub fn to_json(self, benchmark: &str, of: &str, backend: &str) -> String {
372 let interval = if self.verdict == Verdict::Underpowered {
373 "\"ci_low_pct\":null,\"ci_high_pct\":null".to_string()
374 } else {
375 format!(
376 "\"ci_low_pct\":{:.2},\"ci_high_pct\":{:.2}",
377 self.low_pct, self.high_pct
378 )
379 };
380 format!(
381 "{{\"benchmark\":\"{}\",\"kind\":\"comparison\",\"of\":\"{}\",\"backend\":\"{}\",\"baseline_median_ns\":{:.1},\"median_ns\":{:.1},\"delta_pct\":{:.2},{},\"confidence\":{},\"verdict\":\"{}\"}}",
382 benchmark,
383 of,
384 backend,
385 self.baseline_median_ns,
386 self.median_ns,
387 self.delta_pct,
388 interval,
389 CONFIDENCE,
390 self.verdict.as_str(),
391 )
392 }
393}
394
395/// The `p`th percentile of an ascending series of `f64`, by the same
396/// interpolation [`quantile`] uses.
397fn percentile_f64(sorted: &[f64], p: f64) -> f64 {
398 let h = (sorted.len() - 1) as f64 * p;
399 let lower = h.floor() as usize;
400 let upper = h.ceil() as usize;
401 sorted[lower] + (h - lower as f64) * (sorted[upper] - sorted[lower])
402}
403
404/// SplitMix64, which is four lines and needs no dependency.
405///
406/// The resampling wants a stream that is the same every time and does not
407/// want cryptographic quality; this is the standard seeding generator for
408/// exactly that job.
409struct Rng(u64);
410
411impl Rng {
412 fn new(seed: u64) -> Rng {
413 Rng(seed)
414 }
415
416 fn next_u64(&mut self) -> u64 {
417 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
418 let mut z = self.0;
419 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
420 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
421 z ^ (z >> 31)
422 }
423
424 /// A uniform index below `n`. The modulo bias is under one part in 2^58
425 /// for the sample counts this harness produces.
426 fn below(&mut self, n: usize) -> usize {
427 (self.next_u64() % n as u64) as usize
428 }
429}
430
431// ------------------------------------------------------------ the baseline
432
433/// Which row of a report a comparison is about.
434///
435/// The benchmark, the kind of measurement, and the backend, which is exactly
436/// what ADR 0019 requires every number here to carry — two rows that agree on
437/// all three are the same measurement taken twice, and no two rows of one
438/// report agree on all three.
439#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
440pub struct RowKey {
441 pub benchmark: String,
442 pub kind: String,
443 pub backend: String,
444}
445
446/// A previous run of this harness, read back as the samples it recorded.
447pub struct Baseline {
448 rows: BTreeMap<RowKey, Vec<u64>>,
449}
450
451impl Baseline {
452 /// Reads a baseline from this harness's own JSONL output.
453 ///
454 /// This parses the format this file writes and nothing else. It is not a
455 /// JSON parser and does not pretend to be one: it looks for the three
456 /// string fields that name a row and for the sample array inside
457 /// `wall_ns`, and it skips any line that does not carry all four. Lines
458 /// that legitimately do not — an `unsupported` refusal, a
459 /// `trace_overhead` ratio, a `comparison` from an earlier run — are
460 /// therefore skipped rather than treated as errors.
461 ///
462 /// A baseline written by an older build, before `samples` was reported,
463 /// parses as empty. That is the wanted behaviour: comparing against
464 /// summaries would mean inventing the spread that made them.
465 pub fn parse(text: &str) -> Result<Baseline, String> {
466 let mut rows = BTreeMap::new();
467 for line in text.lines() {
468 let line = line.trim();
469 if line.is_empty() {
470 continue;
471 }
472 let (Some(benchmark), Some(kind), Some(backend)) = (
473 string_field(line, "benchmark"),
474 string_field(line, "kind"),
475 string_field(line, "backend"),
476 ) else {
477 continue;
478 };
479 let Some(samples) = wall_samples(line) else {
480 continue;
481 };
482 if samples.is_empty() {
483 continue;
484 }
485 rows.insert(
486 RowKey {
487 benchmark,
488 kind,
489 backend,
490 },
491 samples,
492 );
493 }
494 if rows.is_empty() {
495 return Err(
496 "no rows with a `wall_ns.samples` array; a baseline must be the JSON output \
497of a `cove-bench` run new enough to record its samples"
498 .to_string(),
499 );
500 }
501 Ok(Baseline { rows })
502 }
503
504 /// How many rows the baseline carries.
505 pub fn len(&self) -> usize {
506 self.rows.len()
507 }
508
509 /// The samples recorded for one row, if the baseline has it.
510 ///
511 /// A row the baseline does not have is not an error: benchmarks are added
512 /// and the VM learns to run ones it previously refused, and a baseline
513 /// taken before either is still a baseline for every row it does have.
514 pub fn samples(&self, benchmark: &str, kind: &str, backend: &str) -> Option<&[u64]> {
515 self.rows
516 .get(&RowKey {
517 benchmark: benchmark.to_string(),
518 kind: kind.to_string(),
519 backend: backend.to_string(),
520 })
521 .map(Vec::as_slice)
522 }
523}
524
525/// The value of `"<name>":"..."` in a line this harness wrote.
526fn string_field(line: &str, name: &str) -> Option<String> {
527 let needle = format!("\"{name}\":\"");
528 let start = line.find(&needle)? + needle.len();
529 let rest = &line[start..];
530 let end = rest.find('"')?;
531 Some(rest[..end].to_string())
532}
533
534/// The `samples` array inside this line's `wall_ns` object.
535///
536/// Anchored on `wall_ns` so that a future field carrying samples of its own
537/// cannot be read as if it were the wall time.
538fn wall_samples(line: &str) -> Option<Vec<u64>> {
539 let start = line.find("\"wall_ns\":{")?;
540 let rest = &line[start..];
541 let end = rest.find('}')?;
542 let object = &rest[..end];
543 let needle = "\"samples\":[";
544 let list_start = object.find(needle)? + needle.len();
545 let list = &object[list_start..];
546 let list_end = list.find(']')?;
547 let list = &list[..list_end];
548 if list.trim().is_empty() {
549 return Some(Vec::new());
550 }
551 list.split(',')
552 .map(|item| item.trim().parse::<u64>().ok())
553 .collect()
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 /// A deterministic series with a known median and a known spread.
561 ///
562 /// Every test below runs on synthetic data, and that is deliberate: the
563 /// question these tests ask is whether the estimator recovers an answer
564 /// that is known in advance, and a real timing series has no known
565 /// answer to recover. A machine under load cannot make any of these
566 /// fail.
567 fn series(centre: f64, spread_pct: f64, n: usize, seed: u64) -> Vec<u64> {
568 let mut rng = Rng::new(seed);
569 (0..n)
570 .map(|_| {
571 // Uniform on [-spread, +spread] about the centre.
572 let unit = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
573 let jitter = (unit * 2.0 - 1.0) * spread_pct / 100.0;
574 (centre * (1.0 + jitter)).round() as u64
575 })
576 .collect()
577 }
578
579 #[test]
580 fn quantiles_are_the_textbook_ones() {
581 let odd: Vec<u64> = (1..=9).collect();
582 assert_eq!(quantile(&odd, 0.5), 5.0);
583 assert_eq!(quantile(&odd, 0.25), 3.0);
584 assert_eq!(quantile(&odd, 0.75), 7.0);
585 assert_eq!(quantile(&odd, 0.0), 1.0);
586 assert_eq!(quantile(&odd, 1.0), 9.0);
587
588 let even: Vec<u64> = vec![1, 2, 3, 4];
589 assert_eq!(quantile(&even, 0.5), 2.5);
590 assert_eq!(quantile(&even, 0.25), 1.75);
591 assert_eq!(quantile(&even, 0.75), 3.25);
592
593 let one = vec![42];
594 assert_eq!(quantile(&one, 0.5), 42.0);
595 assert_eq!(quantile(&one, 0.25), 42.0);
596 }
597
598 #[test]
599 fn the_median_ignores_the_slow_run_and_the_mean_does_not() {
600 // Nine samples at 100, and one run that took ten times as long --
601 // the shape of the failure a wall-time series actually has.
602 let clean: Vec<u64> = vec![100; 9];
603 let polluted: Vec<u64> = vec![100, 100, 100, 100, 100, 100, 100, 100, 100, 1000];
604
605 assert_eq!(Stats::of(&clean).median(), 100.0);
606 assert_eq!(Stats::of(&polluted).median(), 100.0);
607
608 assert_eq!(Stats::of(&clean).mean(), 100);
609 assert_eq!(Stats::of(&polluted).mean(), 190);
610 }
611
612 /// Issue #205 needs to know *when* a slow sample arrived, and a sorted
613 /// array cannot say. The order statistics stay what they were.
614 #[test]
615 fn the_report_carries_the_series_in_the_order_it_was_taken() {
616 let stats = Stats::of(&[30, 10, 20]);
617 assert!(
618 stats
619 .to_json_with_samples()
620 .contains("\"samples\":[30,10,20]"),
621 "{}",
622 stats.to_json_with_samples()
623 );
624 assert_eq!(stats.median(), 20.0);
625 assert_eq!(stats.min(), 10);
626 assert_eq!(stats.max(), 30);
627 }
628
629 #[test]
630 fn stats_keeps_what_adr_0012_named() {
631 let stats = Stats::of(&[30, 10, 20]);
632 assert_eq!(stats.min(), 10);
633 assert_eq!(stats.max(), 30);
634 assert_eq!(stats.mean(), 20);
635 assert_eq!(stats.samples(), &[10, 20, 30]);
636 let json = stats.to_json();
637 assert!(
638 !json.contains("samples"),
639 "the summary alone carries no samples: {json}"
640 );
641 for field in ["\"min\":10", "\"mean\":20", "\"max\":30"] {
642 assert!(json.contains(field), "{json} is missing {field}");
643 }
644 for field in ["\"p25\":", "\"median\":", "\"p75\":", "\"iqr\":"] {
645 assert!(json.contains(field), "{json} is missing {field}");
646 }
647 }
648
649 #[test]
650 fn the_iqr_is_the_middle_half() {
651 // A tight middle with one outlier on each side: the range doubles,
652 // the interquartile range does not move.
653 let tight: Vec<u64> = vec![100, 101, 102, 103, 104, 105, 106, 107, 108];
654 let mut wide = tight.clone();
655 wide[0] = 1;
656 wide[8] = 500;
657 assert_eq!(Stats::of(&tight).iqr(), Stats::of(&wide).iqr());
658 assert!(Stats::of(&wide).max() - Stats::of(&wide).min() > 400);
659 }
660
661 #[test]
662 fn a_run_against_itself_is_inside_the_noise() {
663 let baseline = series(100_000.0, 6.0, 15, 1);
664 let current = series(100_000.0, 6.0, 15, 2);
665 let comparison = Comparison::of(&baseline, ¤t);
666 assert_eq!(comparison.verdict, Verdict::InsideTheNoise);
667 assert!(
668 comparison.low_pct <= 0.0 && comparison.high_pct >= 0.0,
669 "the interval {:.2}..{:.2} should contain zero",
670 comparison.low_pct,
671 comparison.high_pct
672 );
673 }
674
675 #[test]
676 fn a_twenty_percent_regression_clears_the_band() {
677 let baseline = series(100_000.0, 6.0, 15, 3);
678 let current = series(120_000.0, 6.0, 15, 4);
679 let comparison = Comparison::of(&baseline, ¤t);
680 assert_eq!(comparison.verdict, Verdict::Regression);
681 assert!(
682 (comparison.delta_pct - 20.0).abs() < 5.0,
683 "the shift should be near 20%, was {:.2}%",
684 comparison.delta_pct
685 );
686 assert!(
687 comparison.low_pct > 0.0,
688 "the interval {:.2}..{:.2} should exclude zero",
689 comparison.low_pct,
690 comparison.high_pct
691 );
692 assert!(
693 comparison.low_pct <= comparison.delta_pct
694 && comparison.delta_pct <= comparison.high_pct,
695 "the interval {:.2}..{:.2} should bracket the shift {:.2}",
696 comparison.low_pct,
697 comparison.high_pct,
698 comparison.delta_pct
699 );
700 }
701
702 #[test]
703 fn a_twenty_percent_improvement_clears_the_band_the_other_way() {
704 let baseline = series(100_000.0, 6.0, 15, 5);
705 let current = series(80_000.0, 6.0, 15, 6);
706 let comparison = Comparison::of(&baseline, ¤t);
707 assert_eq!(comparison.verdict, Verdict::Improvement);
708 assert!(comparison.high_pct < 0.0);
709 }
710
711 #[test]
712 fn a_one_percent_shift_under_six_percent_noise_is_not_a_claim() {
713 // This is the case issue #126 is made of: a change smaller than the
714 // band `docs/VM_ARCHITECTURE.md` records for `arith`. Fifteen
715 // samples a side must not call it.
716 let baseline = series(100_000.0, 6.0, 15, 7);
717 let current = series(101_000.0, 6.0, 15, 8);
718 let comparison = Comparison::of(&baseline, ¤t);
719 assert_eq!(comparison.verdict, Verdict::InsideTheNoise);
720 }
721
722 #[test]
723 fn enough_samples_resolve_what_few_cannot() {
724 // The same 3% shift under the same 6% noise: not resolvable at
725 // fifteen samples a side, resolvable at two hundred. This is what
726 // `--iterations` buys, and it is why the flag is the answer to "how
727 // many runs" rather than a second concept beside it.
728 let few = Comparison::of(
729 &series(100_000.0, 6.0, 15, 9),
730 &series(103_000.0, 6.0, 15, 10),
731 );
732 assert_eq!(few.verdict, Verdict::InsideTheNoise);
733
734 let many = Comparison::of(
735 &series(100_000.0, 6.0, 200, 11),
736 &series(103_000.0, 6.0, 200, 12),
737 );
738 assert_eq!(many.verdict, Verdict::Regression);
739 }
740
741 #[test]
742 fn below_the_floor_nothing_is_claimed() {
743 // One sample a side and a 50% difference: still not a claim, because
744 // one sample has no spread and an interval built from it would be a
745 // fabrication. This is the shape `--iterations 1` has, which is what
746 // CI runs.
747 let one = Comparison::of(&[100_000], &[150_000]);
748 assert_eq!(one.verdict, Verdict::Underpowered);
749 assert!((one.delta_pct - 50.0).abs() < 1e-9);
750 assert!(one.low_pct.is_nan() && one.high_pct.is_nan());
751
752 // Five is still below the floor; six is the first that is not.
753 let five = Comparison::of(
754 &series(100_000.0, 6.0, 5, 13),
755 &series(150_000.0, 6.0, 5, 14),
756 );
757 assert_eq!(five.verdict, Verdict::Underpowered);
758 let six = Comparison::of(
759 &series(100_000.0, 6.0, 6, 15),
760 &series(150_000.0, 6.0, 6, 16),
761 );
762 assert_eq!(six.verdict, Verdict::Regression);
763 }
764
765 #[test]
766 fn the_floor_is_where_a_ninety_five_percent_statement_first_exists() {
767 // `[min, max]` misses the median with probability 2 * (1/2)^n. The
768 // floor is the smallest `n` for which that is at most 5%.
769 let covers = |n: u32| 1.0 - 2.0 * 0.5_f64.powi(n as i32) >= CONFIDENCE;
770 assert!(!covers(MIN_SAMPLES as u32 - 1));
771 assert!(covers(MIN_SAMPLES as u32));
772 }
773
774 #[test]
775 fn the_same_data_always_gives_the_same_answer() {
776 let baseline = series(100_000.0, 6.0, 15, 17);
777 let current = series(104_000.0, 6.0, 15, 18);
778 let first = Comparison::of(&baseline, ¤t);
779 let second = Comparison::of(&baseline, ¤t);
780 assert_eq!(first.verdict, second.verdict);
781 assert_eq!(first.low_pct.to_bits(), second.low_pct.to_bits());
782 assert_eq!(first.high_pct.to_bits(), second.high_pct.to_bits());
783 }
784
785 #[test]
786 fn order_does_not_change_the_answer() {
787 let baseline = series(100_000.0, 6.0, 15, 19);
788 let mut shuffled = baseline.clone();
789 shuffled.reverse();
790 let current = series(112_000.0, 6.0, 15, 20);
791 let straight = Comparison::of(&baseline, ¤t);
792 let reversed = Comparison::of(&shuffled, ¤t);
793 assert_eq!(straight.low_pct.to_bits(), reversed.low_pct.to_bits());
794 assert_eq!(straight.high_pct.to_bits(), reversed.high_pct.to_bits());
795 }
796
797 #[test]
798 fn a_baseline_round_trips_through_the_report_format() {
799 let samples = vec![7u64, 3, 5, 9];
800 let stats = Stats::of(&samples);
801 let line = format!(
802 "{{\"benchmark\":\"field\",\"kind\":\"vm\",\"backend\":\"vm\",\"iterations\":4,\"wall_ns\":{},\"fuel_spent\":1,\"ok\":true}}",
803 stats.to_json_with_samples()
804 );
805 let baseline = Baseline::parse(&line).expect("the line parses");
806 assert_eq!(baseline.len(), 1);
807 // In the order the run took them, not sorted: the report carries the
808 // series as it happened, and a comparison sorts what it is given.
809 assert_eq!(
810 baseline.samples("field", "vm", "vm"),
811 Some([7u64, 3, 5, 9].as_slice())
812 );
813 assert_eq!(baseline.samples("field", "interpreter", "ast"), None);
814 }
815
816 #[test]
817 fn lines_without_samples_are_skipped_rather_than_failing() {
818 let text = "\
819{\"benchmark\":\"pure\",\"kind\":\"unsupported\",\"backend\":\"vm\",\"what\":\"a `spawn`\",\"ok\":false}
820{\"benchmark\":\"pure\",\"kind\":\"trace_overhead\",\"backend\":\"vm\",\"untraced_wall_ns\":1,\"traced_wall_ns\":2,\"overhead_ratio\":2.0}
821
822{\"benchmark\":\"pure\",\"kind\":\"vm\",\"backend\":\"vm\",\"wall_ns\":{\"min\":1,\"mean\":2,\"max\":3,\"samples\":[1,2,3]},\"ok\":true}";
823 let baseline = Baseline::parse(text).expect("one row parses");
824 assert_eq!(baseline.len(), 1);
825 assert_eq!(
826 baseline.samples("pure", "vm", "vm"),
827 Some([1u64, 2, 3].as_slice())
828 );
829 }
830
831 #[test]
832 fn a_baseline_from_a_build_that_recorded_no_samples_is_refused() {
833 let old = "{\"benchmark\":\"pure\",\"kind\":\"vm\",\"backend\":\"vm\",\"iterations\":5,\"wall_ns\":{\"min\":1,\"mean\":2,\"max\":3},\"ok\":true}";
834 assert!(Baseline::parse(old).is_err());
835 assert!(Baseline::parse("").is_err());
836 }
837
838 #[test]
839 fn an_underpowered_comparison_reports_no_interval() {
840 let json = Comparison::of(&[10], &[20]).to_json("pure", "vm", "vm");
841 assert!(json.contains("\"verdict\":\"underpowered\""));
842 assert!(json.contains("\"ci_low_pct\":null"));
843 assert!(json.contains("\"kind\":\"comparison\""));
844 assert!(json.contains("\"of\":\"vm\""));
845 }
846}