Skip to main content

cove_bench/
main.rs

1//! `cove-bench`: the interpreter performance-gate harness for ADR 0012.
2//!
3//! This is not a `cove` subcommand and makes no promise of a stable CLI
4//! surface. It loads the package under `benches/` and runs each benchmark's
5//! entry directly against `cove-runtime` -- the same crate `cove run` and
6//! `cove test` are built on -- so it measures the interpreter itself rather
7//! than shelling out and re-paying parse and resolve on every sample.
8//!
9//! Every Host a benchmark reaches is the same deterministic fake `cove test`
10//! already grants by default (see `crates/cove-cli/src/test.rs`): a
11//! `console` that writes into a sink nobody reads, a `clock` whose
12//! `VirtualTime` never advances on its own, and every other host answering
13//! from empty in-memory state. Nothing here touches the network or the real
14//! filesystem, which is what keeps it hermetic and non-flaky.
15//!
16//! The one exception is the `startup` benchmark, which measures a real
17//! process: it spawns the `cove` binary built alongside this one and times
18//! the whole exec-to-exit span, because process creation and binary loading
19//! are exactly what an in-process measurement cannot see.
20//!
21//! # Two backends
22//!
23//! [ADR 0019](../../../docs/adr/0019-executable-ir-and-vm.md) says every
24//! number this harness reports must say which backend produced it, because a
25//! `fuel_spent` or an `instructions` figure carries no meaning on its own --
26//! it is only ever a fact about the backend that produced it. So every
27//! measurement below carries a `backend`, and every benchmark is measured on
28//! all of them.
29//!
30//! There were four backends here at different points in this file's history:
31//! the interpreter; the executable-IR VM ADR 0019 introduced; an experimental
32//! eight-byte-word frame over that same IR, added so the comparison [issue
33//! #212](https://github.com/myuon/cove/issues/212) asked for could be made
34//! within one benchmark binary rather than across two builds; and the
35//! linear-memory backend
36//! [ADR 0034](../../../docs/adr/0034-one-physical-word-stack.md) decided as
37//! the VM's replacement. ADR 0034's completion condition 8 is that the
38//! replacement becomes the production path and "the predecessor executable
39//! IR, Vm, FrameVm, admits mechanism, duplicate heap and migration machinery
40//! are deleted" -- and that has now happened. Two backends are left.
41//!
42//! `ast` is the tree-walking interpreter, and it is the oracle: it runs every
43//! construct the language has, straight off the checked program, with no
44//! lowering and no admission predicate of its own. `vm` is the linear-memory
45//! backend, and it is the production path: `cove_ir` has no admission
46//! predicate either, but for the opposite reason -- a construct it has not
47//! been taught is a gap in the replacement rather than a program a subset
48//! declines, so a benchmark it cannot lower **fails this suite** instead of
49//! being reported as an experiment's subset not having reached it yet.
50//!
51//! `cove_ir::lower_entry` slices by reachability -- what one entry reaches --
52//! which is what `cove run --backend vm` lowers, so the lowering is timed
53//! once per benchmark and reported under that benchmark's name, apart from
54//! every execution: lowering happens once per program and execution happens
55//! for as long as the program does. That separation is the compile/lower
56//! breakdown [issue #111](https://github.com/myuon/cove/issues/111) asked
57//! for.
58//!
59//! # Output
60//!
61//! One JSON object per line on stdout, in the order the benchmarks are
62//! listed here — which is not necessarily the order they were timed in; see
63//! `--sample-order` below:
64//!
65//! ```text
66//! {"benchmark":"pure","kind":"lowering","backend":"vm","iterations":<u32>,"wall_ns":<series>,"functions":<usize>,"ok":<bool>}
67//! ... and one `vm` lowering line for each of the other benchmarks
68//! {"benchmark":"pure","kind":"interpreter","backend":"ast","iterations":<u32>,"wall_ns":<series>,"fuel_spent":<u64>,"fuel_per_sec":<f64>,"heap_peak_bytes":<summary>,"host_calls":<u64>,"irreversible_writes":<u64>,"instructions":<u64|null>,"ok":<bool>}
69//! {"benchmark":"pure","kind":"vm","backend":"vm", ...the same fields...}
70//! {"benchmark":"pure","kind":"trace_overhead","backend":"ast","untraced_wall_ns":<u64>,"traced_wall_ns":<u64>,"overhead_ratio":<f64>}
71//! {"benchmark":"pure","kind":"trace_overhead","backend":"vm", ...the same fields...}
72//! {"benchmark":"hostheavy", ...the same lines...}
73//! ... and the same for each of `arith`, `arrayget`, `field`, `method`,
74//! `call`, `chars`, and `callback`
75//! {"benchmark":"startup","kind":"process","backend":"ast","iterations":<u32>,"wall_ns":<series>,"ok":<bool>}
76//! {"benchmark":"startup","kind":"process","backend":"vm", ...the same fields...}
77//! ```
78//!
79//! `instructions` is how many instructions the run executed, and `null` on
80//! the interpreter, which has none. It sits beside the wall time because ADR
81//! 0029 makes an exact count repeatable where an absolute is not: a `wall_ns`
82//! regression a rebuild did not cause and an `instructions` count that moved
83//! with it are one finding, and a `wall_ns` regression whose `instructions`
84//! count did not move at all is a different one -- the count says whether
85//! `vm` got slower per instruction or started running more of them.
86//!
87//! where `<summary>` and `<series>` are
88//!
89//! ```text
90//! <summary> = {"min":<u64>,"mean":<u64>,"max":<u64>,"p25":<f64>,"median":<f64>,"p75":<f64>,"iqr":<f64>}
91//! <series>  = {...the same fields...,"samples":[<u64>, ...]}
92//! ```
93//!
94//! `min`, `mean` and `max` are the three ADR 0012 named and they still mean
95//! what they meant. The quartiles are what
96//! [issue #179](https://github.com/myuon/cove/issues/179) asks for: a spread
97//! a regression claim can be stated against instead of a band the reader is
98//! expected to remember. `crates/cove-bench/src/stats.rs` says why the median
99//! and the interquartile range rather than the mean and a standard deviation.
100//!
101//! `samples` is every timing the run took, on the wall-time series alone,
102//! **in the order the run took them**. It is what turns a recorded run into a
103//! baseline: a summary can only be compared against another summary by
104//! arithmetic that invents the spread it needs, and the samples do not have to
105//! be invented. The order is what says *when* a slow sample arrived, which is
106//! the difference between a machine that drifted through a series and a
107//! benchmark that is noisy in it; nothing that compares two runs reads it,
108//! because every statistic here is an order statistic.
109//!
110//! A benchmark the linear-memory lowering cannot lower reports that instead
111//! of its `vm` lines:
112//!
113//! ```text
114//! {"benchmark":"chars","kind":"unsupported","backend":"vm","what":"<what the lowering said>","ok":false}
115//! ```
116//!
117//! and it **fails the suite**: `cove_ir` has no admission predicate, so a
118//! construct it has not been taught is a gap in the backend ADR 0034 makes
119//! the production one rather than a program a subset declines. `ast` never
120//! reports this line -- the interpreter runs the checked program directly,
121//! with no lowering of its own to refuse anything.
122//!
123//! `kind` keeps the value it has always had for the interpreter's rows, so a
124//! reader of the older format still finds exactly the rows it was reading and
125//! does not silently start counting the others. `backend` is what now says
126//! which of the two produced a number.
127//!
128//! `ok` is `false` when a benchmark's entry returned `Err`, a backend itself
129//! failed, the lowering was refused, or (for `startup`) the spawned process
130//! exited non-zero. A caller comparing two backends, or either against a
131//! recorded baseline, should refuse numbers from a run that is not `ok`; this
132//! harness's own `--baseline` does, and compares no row that is not `ok`.
133//!
134//! # Comparing against a recorded run
135//!
136//! `--baseline <path>` reads a file of the output above and adds one line per
137//! row it recognizes:
138//!
139//! ```text
140//! {"benchmark":"field","kind":"comparison","of":"vm","backend":"vm","baseline_median_ns":<f64>,"median_ns":<f64>,"delta_pct":<f64>,"ci_low_pct":<f64|null>,"ci_high_pct":<f64|null>,"confidence":0.95,"verdict":"<verdict>"}
141//! ```
142//!
143//! `kind` is `comparison` rather than the kind of the row compared, again so
144//! that a reader filtering on `kind` keeps finding what it was finding; `of`
145//! is the kind this line is about. The verdict is one of `regression`,
146//! `improvement`, `inside the noise`, or `underpowered`, and it is read off
147//! the interval: an interval that excludes zero cleared the noise and one
148//! that contains it did not. A summary of the whole comparison goes to
149//! stderr, so stdout stays one JSON object per line.
150//!
151//! **The baseline is a fixed commit, not the parent.** That is the discipline
152//! [issue #126](https://github.com/myuon/cove/issues/126) exists to enforce:
153//! three changes each individually inside the noise summed to a 19%
154//! regression, and only a comparison against a commit far enough back could
155//! have seen it.
156//!
157//! ```text
158//! git worktree add /tmp/base <the fixed commit>
159//! cargo build --release -p cove-cli -p cove-bench   # in /tmp/base
160//! /tmp/base/target/release/cove-bench --iterations 15 > /tmp/base.jsonl
161//! cargo build --release -p cove-cli -p cove-bench   # here
162//! ./target/release/cove-bench --iterations 15 --baseline /tmp/base.jsonl
163//! ```
164//!
165//! **Bracket the variant, do not pair it.** Run the base binary, then the
166//! variant, then the base binary *again*, and quote the variant against the
167//! mean of the two base runs. The two base runs' disagreement with each other
168//! is the measurement's own error bar, it costs one extra run, and it should
169//! be quoted beside the result -- where it is as large as the effect, that is
170//! the result.
171//!
172//! **Compare one row against itself. Never the largest row of the suite.**
173//! Twenty-two suites in which nothing under test changed, measured for
174//! [issue #205](https://github.com/myuon/cove/issues/205), put a single row's
175//! disagreement with itself at 0.5% to 0.8% in the middle and 2% to 3% at the
176//! 90th percentile. The *largest* disagreement over the suite's twenty-one
177//! rows is a different statistic with a different distribution: on that same
178//! null its median is about 4% and it reaches 15%.
179//! `docs/VM_ARCHITECTURE.md`'s earlier "7.4% against itself" was that
180//! statistic, so it is not the error bar for any row and no row should be read
181//! against it.
182//!
183//! **Two rows are not evidence at the few-percent level, and never were.**
184//! `benches`/`lowering` times a 0.13 ms lowering and `startup` times a
185//! spawned process; between them they carry the suite's largest null shift
186//! two thirds of the time. Read the execution rows.
187//!
188//! Nothing about this makes a comparison across two machines, two build
189//! profiles, or two busy afternoons meaningful. It compares the samples it is
190//! given; whether they were taken on a quiet machine is the reader's to
191//! answer, and it is the assumption every table in
192//! `docs/VM_ARCHITECTURE.md` rests on.
193//!
194//! A regression verdict does not fail the process. ADR 0012's argument for
195//! gating no wall-clock number in CI is unaffected by this: the exit code
196//! still reports correctness alone.
197//!
198//! # The mechanism benchmarks
199//!
200//! `pure`, `hostheavy`, and `startup` are ADR 0012's, and measure a program.
201//! `arith`, `arrayget`, `field`, `method`, `call`, and `chars` are issue
202//! #104's, and measure one mechanism each: every one of them is the same
203//! 2,000,000-iteration loop with exactly one thing added, so the difference
204//! between two of them is what that thing costs. `arith` is the loop alone;
205//! `arrayget` adds an indexed read and the `Option` it answers; `field` adds
206//! a struct field; `method` adds a call around that field; `call` adds a call
207//! with no receiver; and `chars` is the per-character scan `examples/cq`
208//! spends nearly all of its time in.
209//!
210//! `callback` is issue #193's, and belongs to the same family: 2,000,000
211//! invocations again, but of a closure reached through a higher-order
212//! builtin — `filter`, over an array, with the callback a helper builds over
213//! one capture. It is read beside `call`, which makes the same number of
214//! entries into a body through the call instruction instead, so the
215//! difference between the two is what re-entering the evaluator from inside
216//! a builtin costs. That route had no row before, which is why the per-call
217//! argument vector #184 removed from the builtin path survived on the
218//! callback path with nothing to price it.
219//!
220//! They exist because a wall-clock number for a whole program says how slow
221//! it is and not what is slow about it. They do not replace the application
222//! measurement in `examples/cq/README.md`; they are what makes it readable.
223//!
224//! This tool asserts no thresholds of its own; see ADR 0012 for why wall-clock
225//! numbers are not gated in CI, and for the thresholds a human applies when
226//! reading a `--iterations`-heavy local run.
227//!
228//! # Running it
229//!
230//! ```text
231//! cargo build --release --workspace
232//! ./target/release/cove-bench --iterations 1      # what CI runs, for correctness
233//! ./target/release/cove-bench --iterations 15     # a real local measurement
234//! ./target/release/cove-bench --iterations 15 --sample-order blocked
235//! ./target/release/cove-bench --matrix --backend ast,vm --iterations 9
236//! ```
237//!
238//! **`--release` is the profile to measure under.** The workspace also defines
239//! `[profile.bench-stable]`, which is `release` with `codegen-units = 1`, and
240//! it is *not* the one to reach for: it was added to test whether one codegen
241//! unit per crate would stop module boundaries being a performance variable
242//! ([issue #179](https://github.com/myuon/cove/issues/179)), it was measured
243//! against a never-executed `Inst` variant, and it did not -- the spurious
244//! shift came out larger under it than under plain `release`, for 44% to 96%
245//! more build time. `docs/VM_ARCHITECTURE.md`, "What `codegen-units = 1` was
246//! measured to be worth", is the round. It stays defined so that result can be
247//! reproduced; nothing selects it.
248//!
249//! Optimized in both cases. The benchmarks are sized to be measurable in an
250//! optimized build, so an unoptimized one does not run them uniformly slower
251//! in some way that could be divided back out — it runs them for minutes.
252//!
253//! **`--iterations` is how many samples a benchmark's series has**, and there
254//! is deliberately no second flag beside it: the runs the spread is computed
255//! over and the runs the harness performs are the same runs. So a run at
256//! `--iterations 1` reports a series of one, whose median is its only sample
257//! and whose interquartile range is zero — which is exactly what CI wants and
258//! costs it nothing, and is why it stays at one. Six is the fewest samples
259//! any comparison here will draw a conclusion from, and
260//! `docs/VM_ARCHITECTURE.md` takes its tables at fifteen.
261//!
262//! **`--sample-order` is when those samples are taken**, and the default is
263//! `round-robin`: one sample of every row, then a second of every row, so
264//! that each row's series is spread over the whole suite rather than taken at
265//! one instant of it. `blocked` is the order this harness used before
266//! [issue #205](https://github.com/myuon/cove/issues/205) — every sample of a
267//! row before the next row starts — and it is kept so the round that changed
268//! the default can be reproduced. Neither costs more than the other: the
269//! suite takes the same 564 seconds, runs the same runs, and reports the same
270//! fields. At `--iterations 1` the two are the same sequence, so CI is
271//! unaffected.
272//!
273//! Reading one backend against another is what the output is arranged for:
274//! the `wall_ns` medians of one benchmark are the comparison, and the
275//! `fuel_spent` beside them is not, because ADR 0019 makes fuel
276//! backend-specific and says so. `instructions` is not either, for a simpler
277//! reason: it is `null` on `ast`, which has none, so with only `ast` and
278//! `vm` left there is no second lowered backend's count to divide `vm`'s
279//! by. It stays beside `wall_ns` anyway, for the reason given above -- an
280//! exact count is worth reading run over run even with nothing beside it to
281//! ratio it against.
282
283use std::path::{Path, PathBuf};
284use std::process::{Command, ExitCode};
285use std::rc::Rc;
286use std::sync::Arc;
287use std::time::{Duration, Instant};
288
289use cove_diag::SourceMap;
290use cove_runtime::interp::Interpreter;
291use cove_runtime::{
292    Budget, Cancellation, Clock, Console, Database, Documents, Env, Files, Grants, HeapStats,
293    HostRegistry, JsonlSink, Limits, NullSink, Process, ProcessLog, RecordingBackend, Runtime,
294    TraceHeader, TraceSink, Value, ValueCapture, VirtualTime, Vm,
295};
296use cove_sema::package::Package;
297use cove_sema::resolve::Program;
298use cove_sema::HostSchemas;
299
300mod stats;
301
302use stats::{Baseline, Comparison, Stats, Verdict};
303
304/// How many times each benchmark runs when `--iterations` is not given.
305///
306/// This claimed the whole harness finishes in well under a second, and it
307/// was not true of any run anyone made: CI asked for three iterations of an
308/// unoptimized build and spent 82% of its pipeline waiting. No count fixes
309/// that, because the benchmarks are sized for an optimized build and three
310/// iterations of one without optimization take minutes. So CI builds the
311/// harness optimized and asks for one, and this default is what a local
312/// reader gets who wants a first look rather than a measurement. ADR 0012
313/// says why no number here is gated.
314const DEFAULT_ITERATIONS: u32 = 5;
315
316/// The benchmarks the suite runs, in the order their rows are reported.
317const BENCHMARKS: [&str; 9] = [
318    "pure",
319    "hostheavy",
320    "arith",
321    "arrayget",
322    "field",
323    "method",
324    "call",
325    "chars",
326    "callback",
327];
328
329/// The order the suite takes its samples in.
330///
331/// This changes nothing about *what* is measured -- the same rows run the
332/// same number of times either way, each row's report is the same shape, and
333/// a whole suite takes the same 564 seconds under either -- only *when* each
334/// sample is taken. `docs/VM_ARCHITECTURE.md`, "What the measurement itself
335/// costs", is the round that measured which one to prefer, and by how little.
336#[derive(Clone, Copy, PartialEq, Eq, Debug)]
337enum SampleOrder {
338    /// Every sample of one row, then every sample of the next.
339    ///
340    /// The order this harness used until [issue
341    /// #205](https://github.com/myuon/cove/issues/205), and the reason a short
342    /// row was the least reliable thing in the suite: `pure` on the VM runs in
343    /// about 1.4 ms, so fifteen samples of it are twenty milliseconds of
344    /// measurement taken at one instant of a suite that lasts nine and a half
345    /// minutes. Whatever the machine was doing in that instant is the whole of
346    /// that row's answer, and nothing in the row's own spread can say so.
347    ///
348    /// Kept because the round that replaced it was run against it, and a
349    /// result nobody can reproduce is a result nobody can check.
350    Blocked,
351    /// One sample of every row, then a second of every row, and so on.
352    ///
353    /// The same total work, rearranged so that each row's series is spread
354    /// over the whole suite instead of one instant of it. A machine that
355    /// drifts over the suite then drifts *through* every row's series rather
356    /// than between one row's series and another's, so the median of a series
357    /// is a summary of the session rather than of a moment in it, and the
358    /// interquartile range beside it starts including the drift instead of
359    /// being blind to it.
360    RoundRobin,
361}
362
363/// The order a run uses when `--sample-order` is not given.
364///
365/// Round-robin, on this evidence: five suites an arm, interleaved on one
366/// machine, one unmodified binary against itself. Over the eighteen rows the
367/// order actually governs, the median disagreement between two suites fell
368/// from 0.61% to 0.45% and its 90th percentile from 1.97% to 1.67%, thirteen
369/// of eighteen rows improved, and the suite took the same time. That is a
370/// quarter of the noise and not a fix; it is the default because it costs
371/// nothing, not because it settles anything.
372///
373/// A run at `--iterations 1` -- which is what CI does -- takes exactly the
374/// same samples in exactly the same sequence under either order, because one
375/// pass over the rows *is* one sample of each.
376const DEFAULT_SAMPLE_ORDER: SampleOrder = SampleOrder::RoundRobin;
377
378fn main() -> ExitCode {
379    // The benchmarks run Cove entries, so they run on the stack the runtime
380    // sizes for that, the same as `cove run` does. Measuring an interpreter
381    // on a stack it would not be given is measuring something else.
382    match cove_runtime::on_cove_stack(bench) {
383        Ok(code) => code,
384        Err(error) => {
385            eprintln!("cove-bench: could not start the thread the benchmarks run on: {error}");
386            ExitCode::FAILURE
387        }
388    }
389}
390
391/// Runs every benchmark and reports each one as a line of JSON.
392fn bench() -> ExitCode {
393    let iterations = parse_iterations();
394    let order = match parse_sample_order() {
395        Ok(order) => order,
396        Err(message) => {
397            eprintln!("cove-bench: {message}");
398            return ExitCode::FAILURE;
399        }
400    };
401
402    // Read before anything is measured, so a baseline that does not exist or
403    // that a build too old to record its samples produced is a failure before
404    // the machine has spent minutes on a run nobody can read.
405    let baseline = match load_baseline() {
406        Ok(baseline) => baseline,
407        Err(message) => {
408            eprintln!("cove-bench: {message}");
409            return ExitCode::FAILURE;
410        }
411    };
412
413    let (sources, package, program) = match load_benches() {
414        Ok(loaded) => loaded,
415        Err(message) => {
416            eprintln!("cove-bench: {message}");
417            return ExitCode::FAILURE;
418        }
419    };
420    let sources = Arc::new(sources);
421    let program = Arc::new(program);
422
423    if std::env::args().any(|argument| argument == "--matrix") {
424        if baseline.is_some() {
425            eprintln!(
426                "cove-bench: `--baseline` compares the benchmark suite, not `--matrix`; ignoring it"
427            );
428        }
429        return matrix(&package, &program, &sources, iterations, order);
430    }
431
432    let mut ok = true;
433    let mut compared: Vec<Compared> = Vec::new();
434
435    // One linear-memory lowering per benchmark: `cove_ir::lower_entry` lowers
436    // what one entry reaches, which is what `cove run --backend vm` lowers,
437    // and lowering is paid once per program rather than once per run. Every
438    // one of them happens before any execution is timed.
439    let mut linear: Vec<LinearLowering> = Vec::new();
440    for name in BENCHMARKS {
441        let (module, entry) = match entry_for(&package, &program, name) {
442            Ok((module, entry, _)) => (module, entry),
443            // Reported once, by the loop below that resolves every row of
444            // every backend against the same lookup.
445            Err(_) => continue,
446        };
447        match bench_linear_lowering(&program, &sources, name, module, entry, iterations) {
448            Ok(report) => {
449                println!("{}", report.to_json());
450                compare(
451                    baseline.as_ref(),
452                    &mut compared,
453                    name,
454                    "lowering",
455                    "vm",
456                    &report.wall_ns,
457                );
458                linear.push(report);
459            }
460            Err(diagnostics) => {
461                let what = diagnostics
462                    .first()
463                    .map(|d| d.message.clone())
464                    .unwrap_or_else(|| "the lowering failed and said nothing".to_string());
465                println!(
466                    "{}",
467                    NotLowered {
468                        benchmark: name,
469                        what: &what
470                    }
471                    .to_json()
472                );
473                eprintln!("cove-bench: `benches/{name}` does not lower to the linear IR: {what}");
474                // A gap in the backend ADR 0034 makes the production one,
475                // and not an experiment declining a program: it fails.
476                ok = false;
477            }
478        }
479    }
480
481    // Every row this run will time, resolved before any of them is timed.
482    // An entry that does not exist, or a benchmark the lowering refused, is
483    // a fact about the suite rather than about the machine, and finding it
484    // out halfway through would put an error message inside somebody's
485    // series.
486    let mut rows: Vec<Row> = Vec::new();
487    for name in BENCHMARKS {
488        for backend in [Backend::Ast, Backend::Vm] {
489            // The linear-memory program this row runs, and `None` on `ast`.
490            // An `vm` row whose lowering failed was already reported above
491            // as a [`NotLowered`] line, so it is skipped here rather than
492            // reported twice.
493            let ir = match backend {
494                Backend::Vm => {
495                    let Some(lowering) = linear.iter().find(|lowering| lowering.benchmark == name)
496                    else {
497                        continue;
498                    };
499                    Some(&lowering.program)
500                }
501                Backend::Ast => None,
502            };
503            match Row::resolve(&package, &program, name, backend, ir) {
504                Ok(row) => rows.push(row),
505                Err(message) => {
506                    eprintln!("cove-bench: benchmark `{name}` on {backend}: {message}");
507                    ok = false;
508                }
509            }
510        }
511    }
512
513    take_samples(&program, &sources, &mut rows, iterations, order);
514
515    for row in &rows {
516        let report = row.report(iterations);
517        ok &= report.ok;
518        println!("{}", report.to_json());
519        // A run that did not pass is not a measurement of anything, so it is
520        // not compared: the module docs say a caller should refuse numbers
521        // from a run that is not `ok`, and this is that caller.
522        if report.ok {
523            compare(
524                baseline.as_ref(),
525                &mut compared,
526                row.name,
527                row.backend.kind(),
528                &row.backend.to_string(),
529                &report.wall_ns,
530            );
531        }
532        println!(
533            "{}",
534            bench_trace_overhead(&program, &sources, row, iterations).to_json()
535        );
536    }
537
538    for backend in [Backend::Ast, Backend::Vm] {
539        match bench_startup(iterations, backend) {
540            Ok(report) => {
541                ok &= report.ok;
542                println!("{}", report.to_json());
543                if report.ok {
544                    compare(
545                        baseline.as_ref(),
546                        &mut compared,
547                        "startup",
548                        "process",
549                        &backend.to_string(),
550                        &report.wall_ns,
551                    );
552                }
553            }
554            Err(message) => {
555                eprintln!("cove-bench: startup on {backend}: {message}");
556                ok = false;
557            }
558        }
559    }
560
561    if baseline.is_some() {
562        summarize(&compared);
563    }
564
565    if ok {
566        ExitCode::SUCCESS
567    } else {
568        ExitCode::FAILURE
569    }
570}
571
572/// Which backend produced a number.
573///
574/// ADR 0019 requires every number this harness reports to say so, because the
575/// two are not interchangeable: `fuel_spent` is defined per backend, and a
576/// wall-clock figure that did not name its backend would be a comparison
577/// missing half of itself.
578#[derive(Clone, Copy, PartialEq, Eq, Debug)]
579enum Backend {
580    /// The tree-walking interpreter, which is the oracle: it runs every
581    /// construct the language has, straight off the checked program, with no
582    /// lowering of its own and nothing to refuse.
583    Ast,
584    /// The linear-memory backend of ADR 0034, over `cove_ir`.
585    ///
586    /// The production path. ADR 0034 replaced the executable IR, the
587    /// lowering, the VM, and — once the replacement had proven itself —
588    /// an experimental eight-byte-word frame that existed only to make the
589    /// two backends' comparison measurable within one build; its completion
590    /// condition 8 was to delete all of that once this backend took over,
591    /// which is why `vm` is the only lowered backend left here.
592    ///
593    /// It has no admission predicate. `cove_ir` refuses nothing on purpose —
594    /// a construct it has not been taught is a gap in the lowering rather
595    /// than a program the backend declines — so a `vm` row that is missing
596    /// is a bug, and this harness reports one as a failure.
597    Vm,
598}
599
600impl Backend {
601    /// The value of the `kind` field, which keeps the string the interpreter's
602    /// rows have always carried so that a reader of the older format finds
603    /// exactly the rows it was reading and no more.
604    fn kind(self) -> &'static str {
605        match self {
606            Backend::Ast => "interpreter",
607            Backend::Vm => "vm",
608        }
609    }
610
611    /// The name `--backend` accepts for this backend, and the one a row is
612    /// reported under.
613    fn parse(name: &str) -> Option<Backend> {
614        match name {
615            "ast" => Some(Backend::Ast),
616            "vm" => Some(Backend::Vm),
617            _ => None,
618        }
619    }
620}
621
622impl std::fmt::Display for Backend {
623    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
624        f.write_str(match self {
625            Backend::Ast => "ast",
626            Backend::Vm => "vm",
627        })
628    }
629}
630
631/// What lowering one benchmark to `cove_ir` cost, and the program every
632/// `vm` measurement of that benchmark runs.
633///
634/// One per benchmark rather than one for the package, because that is the
635/// unit `cove_ir::lower_entry` lowers: it lowers what one entry reaches and
636/// stubs the rest, which is what `cove run --backend vm` asks for, so the
637/// row is named for the benchmark it lowered rather than for the package.
638struct LinearLowering {
639    benchmark: &'static str,
640    iterations: u32,
641    wall_ns: Stats,
642    /// How many entries the function table has.
643    ///
644    /// A slice still gives every declaration of the package a table entry,
645    /// and the ones the entry did not reach are stubs, so this is the count
646    /// for the thing that was timed rather than a count of what the entry
647    /// actually calls.
648    functions: usize,
649    program: Arc<cove_ir::Program>,
650}
651
652impl LinearLowering {
653    fn to_json(&self) -> String {
654        format!(
655            "{{\"benchmark\":\"{}\",\"kind\":\"lowering\",\"backend\":\"vm\",\"iterations\":{},\"wall_ns\":{},\"functions\":{},\"ok\":true}}",
656            self.benchmark,
657            self.iterations,
658            self.wall_ns.to_json_with_samples(),
659            self.functions,
660        )
661    }
662}
663
664/// Lowers one benchmark's entry to `cove_ir` `iterations` times.
665///
666/// Timed apart from execution, because lowering is paid once per program and
667/// execution for as long as the program runs. The schemas are the shipped
668/// ones and no others,
669/// which is the set `cove_sema::Compiler::new()` checked this package
670/// against — `cove run` passes the same, and a lowering that read a different
671/// set would be lowering a different program.
672fn bench_linear_lowering(
673    program: &Program,
674    sources: &SourceMap,
675    benchmark: &'static str,
676    module: &str,
677    entry: &str,
678    iterations: u32,
679) -> Result<LinearLowering, Vec<cove_diag::Diagnostic>> {
680    let schemas = HostSchemas::new();
681    let mut wall_ns = Vec::with_capacity(iterations as usize);
682    let mut last = None;
683    for _ in 0..iterations {
684        let started = Instant::now();
685        let lowered = cove_ir::lower_entry(program, sources, &schemas, module, entry)?;
686        wall_ns.push(started.elapsed().as_nanos() as u64);
687        last = Some(lowered);
688    }
689    let lowered = last.expect("`--iterations` is a positive integer, so one lowering happened");
690    Ok(LinearLowering {
691        benchmark,
692        iterations,
693        wall_ns: Stats::of(&wall_ns),
694        functions: lowered.functions.len(),
695        program: Arc::new(lowered),
696    })
697}
698
699/// One benchmark the linear-memory lowering could not lower, and what it said.
700///
701/// `cove_ir` has no admission predicate and no `Unsupported` type of its
702/// own — its module docs say a construct it has not been taught is a bug in
703/// the lowering rather than a program it declines — so this is a gap in the
704/// backend ADR 0034 makes the production one, and it **fails the suite** for
705/// that reason. `ast` has no counterpart to this row: the interpreter runs
706/// the checked program directly and never refuses one.
707struct NotLowered<'a> {
708    benchmark: &'static str,
709    what: &'a str,
710}
711
712impl NotLowered<'_> {
713    fn to_json(&self) -> String {
714        format!(
715            "{{\"benchmark\":\"{}\",\"kind\":\"unsupported\",\"backend\":\"vm\",\"what\":\"{}\",\"ok\":false}}",
716            self.benchmark,
717            escape(self.what),
718        )
719    }
720}
721
722/// Escapes what a JSON string may not carry literally.
723///
724/// The construct a refusal names is written for a person and can hold a
725/// backtick, a quote, or a backslash; the rest of this file's fields are
726/// numbers and fixed identifiers, which is why this is the only place that
727/// needs it.
728fn escape(text: &str) -> String {
729    text.chars()
730        .flat_map(|c| match c {
731            '"' => vec!['\\', '"'],
732            '\\' => vec!['\\', '\\'],
733            c => vec![c],
734        })
735        .collect()
736}
737
738/// Reads `--iterations <n>` from the process arguments, falling back to
739/// [`DEFAULT_ITERATIONS`] when it is absent or not a positive integer.
740fn parse_iterations() -> u32 {
741    let args: Vec<String> = std::env::args().skip(1).collect();
742    let mut i = 0;
743    while i < args.len() {
744        if args[i] == "--iterations" {
745            if let Some(value) = args.get(i + 1).and_then(|v| v.parse::<u32>().ok()) {
746                if value > 0 {
747                    return value;
748                }
749            }
750            eprintln!("cove-bench: `--iterations` needs a positive integer; using the default");
751            return DEFAULT_ITERATIONS;
752        }
753        i += 1;
754    }
755    DEFAULT_ITERATIONS
756}
757
758/// Reads `--sample-order <blocked|round-robin>` from the process arguments.
759///
760/// Unlike `--iterations`, a value this does not recognize is an error rather
761/// than a fallback: the two orders disagree with each other by more than most
762/// changes this repository measures, so a run that silently used the other
763/// one would be a measurement of the wrong thing under the right name.
764fn parse_sample_order() -> Result<SampleOrder, String> {
765    let args: Vec<String> = std::env::args().skip(1).collect();
766    let mut i = 0;
767    while i < args.len() {
768        if args[i] == "--sample-order" {
769            return match args.get(i + 1).map(String::as_str) {
770                Some("blocked") => Ok(SampleOrder::Blocked),
771                Some("round-robin") => Ok(SampleOrder::RoundRobin),
772                Some(other) => Err(format!(
773                    "`--sample-order` is `blocked` or `round-robin`, not `{other}`"
774                )),
775                None => Err("`--sample-order` needs `blocked` or `round-robin`".to_string()),
776            };
777        }
778        i += 1;
779    }
780    Ok(DEFAULT_SAMPLE_ORDER)
781}
782
783// ------------------------------------------------------- comparing two runs
784
785/// Reads `--baseline <path>`, if it was given.
786///
787/// A previous run's own JSON output is the baseline format, which is what
788/// makes the fixed-commit discipline
789/// [issue #126](https://github.com/myuon/cove/issues/126) argues for a
790/// two-command exercise: record the suite once on the commit being measured
791/// against, keep the file, and pass it to every run afterwards. Three changes
792/// each individually inside the noise summed to 19% there, and no comparison
793/// against the parent alone could have seen it.
794fn load_baseline() -> Result<Option<Baseline>, String> {
795    let args: Vec<String> = std::env::args().skip(1).collect();
796    let mut i = 0;
797    while i < args.len() {
798        if args[i] == "--baseline" {
799            let path = args
800                .get(i + 1)
801                .ok_or_else(|| "`--baseline` needs a path to a recorded run".to_string())?;
802            let text = std::fs::read_to_string(path)
803                .map_err(|e| format!("cannot read the baseline `{path}`: {e}"))?;
804            let baseline = Baseline::parse(&text)
805                .map_err(|why| format!("`{path}` is not a baseline: {why}"))?;
806            eprintln!(
807                "cove-bench: comparing against `{path}`, which has {} rows",
808                baseline.len()
809            );
810            return Ok(Some(baseline));
811        }
812        i += 1;
813    }
814    Ok(None)
815}
816
817/// One row that had a baseline to be read against.
818struct Compared {
819    /// How the row is named in the summary: the benchmark and the backend.
820    row: String,
821    comparison: Comparison,
822}
823
824/// Emits the comparison for one row, when there is a baseline and it has the
825/// row.
826///
827/// A row the baseline does not have produces nothing at all. It is not an
828/// error -- benchmarks get added, and `cove_ir` learns to lower ones it used
829/// to refuse -- but it is also not a comparison, and a line saying "no
830/// change" for a row that was never measured before would be the worst of
831/// both.
832fn compare(
833    baseline: Option<&Baseline>,
834    compared: &mut Vec<Compared>,
835    benchmark: &str,
836    kind: &str,
837    backend: &str,
838    current: &Stats,
839) {
840    let Some(baseline) = baseline else {
841        return;
842    };
843    let Some(recorded) = baseline.samples(benchmark, kind, backend) else {
844        return;
845    };
846    let comparison = Comparison::of(recorded, current.samples());
847    println!("{}", comparison.to_json(benchmark, kind, backend));
848    compared.push(Compared {
849        row: format!("{benchmark}/{backend}"),
850        comparison,
851    });
852}
853
854/// Ends a compared run with the sentence the JSON above is the evidence for.
855///
856/// On stderr, because stdout is the machine-readable stream and a reader
857/// piping it into a file should not find prose in it. This asserts nothing
858/// and fails nothing: ADR 0012 argues that wall-clock numbers are not gated,
859/// and a verdict computed here is one for a person to act on rather than a
860/// threshold this process enforces. The exit code still reflects correctness
861/// alone.
862fn summarize(compared: &[Compared]) {
863    if compared.is_empty() {
864        eprintln!("cove-bench: no row of this run had a counterpart in the baseline");
865        return;
866    }
867
868    let count = |wanted: Verdict| {
869        compared
870            .iter()
871            .filter(|row| row.comparison.verdict == wanted)
872            .count()
873    };
874    eprintln!(
875        "cove-bench: {} rows compared: {} regression(s), {} improvement(s), {} inside the noise, {} underpowered",
876        compared.len(),
877        count(Verdict::Regression),
878        count(Verdict::Improvement),
879        count(Verdict::InsideTheNoise),
880        count(Verdict::Underpowered),
881    );
882
883    for row in compared {
884        if matches!(
885            row.comparison.verdict,
886            Verdict::Regression | Verdict::Improvement
887        ) {
888            eprintln!(
889                "cove-bench:   {} {:+.2}% [{:+.2}, {:+.2}] -- {}",
890                row.row,
891                row.comparison.delta_pct,
892                row.comparison.low_pct,
893                row.comparison.high_pct,
894                row.comparison.verdict.as_str(),
895            );
896        }
897    }
898
899    // The widest interval that did not clear zero is the honest bound on what
900    // this run could be hiding, and it is the number a "no meaningful
901    // regression" sentence should quote. Without it the sentence claims the
902    // change had no effect, which is not what an interval containing zero
903    // says.
904    let widest = compared
905        .iter()
906        .filter(|row| row.comparison.verdict == Verdict::InsideTheNoise)
907        .max_by(|a, b| {
908            let width = |row: &Compared| row.comparison.high_pct - row.comparison.low_pct;
909            width(a)
910                .partial_cmp(&width(b))
911                .expect("an interval that cleared the floor is not NaN")
912        });
913    if let Some(widest) = widest {
914        eprintln!(
915            "cove-bench: the widest interval that did not clear zero is {} [{:+.2}, {:+.2}]; \
916a regression larger than that would have been seen",
917            widest.row, widest.comparison.low_pct, widest.comparison.high_pct,
918        );
919    }
920    if count(Verdict::Underpowered) > 0 {
921        eprintln!(
922            "cove-bench: an underpowered row has fewer than {} samples on one side; \
923`--iterations {}` or more is what makes it a claim",
924            stats::MIN_SAMPLES,
925            stats::MIN_SAMPLES,
926        );
927    }
928}
929
930// ------------------------------------------------ the calling-convention matrix
931
932/// The rows of the calling-convention matrix, and what each one is.
933///
934/// [Issue #123](https://github.com/myuon/cove/issues/123) asks what the typed
935/// three-stack convention costs at each of its boundaries, so
936/// `benches/convention/main.cove` writes `benches/arith`'s loop out again for
937/// each of them and changes exactly one thing between two rows: how the
938/// turn's `i` reaches the arithmetic that consumes it. The first row is the
939/// baseline the rest are read against.
940///
941/// Eight of the nine are the shapes #123 names. `conv_fresh` is a control
942/// rather than one of them: `conv_host`'s callback has to be written at its
943/// call site, because it reads the turn's `i` and a capture is a snapshot,
944/// so that row builds a closure per turn as well as crossing the Host
945/// boundary. This is the row that tells the two apart.
946const MATRIX: [(&str, &str); 9] = [
947    ("conv_local", "a settled scalar local"),
948    ("conv_var", "the same local, rooted for a `var` argument"),
949    ("conv_static", "a static declared call"),
950    ("conv_fnvalue", "a declared function used as a value"),
951    ("conv_closure", "a closure call"),
952    ("conv_capture", "a captured scalar"),
953    ("conv_generic", "a scalar crossing to generic `Value`"),
954    ("conv_fresh", "a closure built per turn, called here"),
955    ("conv_host", "a Host callback, and the reentry that runs it"),
956];
957
958/// How many turns each row of the matrix takes. Every entry writes the same
959/// literal, and the table below divides by it to report a cost per turn.
960const MATRIX_TURNS: u64 = 2_000_000;
961
962/// Runs the matrix and prints it as a table.
963///
964/// A table rather than the JSON the rest of this harness emits, because this
965/// is a diagnostic somebody reads rather than a gate something compares.
966///
967/// It ran on the predecessor VM alone until ADR 0034 added the replacement
968/// beside it to ask its completion condition 9's question -- whether the
969/// replacement's calling convention costs more than the one it replaced. The
970/// predecessor is gone now, so `vm` runs alone by default, for the reason the interpreter never joined it
971/// there: what this measures is a calling convention, and the interpreter
972/// does not have one -- it has an environment chain, which is a different
973/// thing. `--backend` still takes a comma-separated list, and
974/// `--backend ast,vm` reads that different question **in one run** -- which
975/// is the only place ADR 0029 says such a ratio may be read.
976///
977/// This does not run under `cove-bench` with no arguments, and that is
978/// deliberate: eight two-million-turn loops on top of the suite would double
979/// what every push waits for, to answer a question nobody asked on that push.
980/// One row of the calling-convention matrix on one backend, and the samples
981/// taken of it.
982struct MatrixRow<'a> {
983    name: &'static str,
984    what: &'static str,
985    backend: Backend,
986    module: &'a str,
987    entry: &'a str,
988    allow: Vec<String>,
989    ir: Option<&'a Arc<cove_ir::Program>>,
990    samples: Vec<u64>,
991    instructions: u64,
992    ok: bool,
993}
994
995/// Reads `--backend <list>` for the matrix, defaulting to `vm` alone.
996///
997/// A value it does not recognize is an error rather than a fallback, for the
998/// reason `--sample-order` gives: a run that silently measured something else
999/// is a measurement of the wrong thing under the right name.
1000fn parse_matrix_backends() -> Result<Vec<Backend>, String> {
1001    let args: Vec<String> = std::env::args().skip(1).collect();
1002    let mut i = 0;
1003    while i < args.len() {
1004        if args[i] == "--backend" {
1005            let Some(list) = args.get(i + 1) else {
1006                return Err("`--backend` needs a comma-separated list of backends".to_string());
1007            };
1008            let mut backends = Vec::new();
1009            for name in list.split(',') {
1010                match Backend::parse(name) {
1011                    Some(backend) if backends.contains(&backend) => {
1012                        return Err(format!("`{backend}` is named twice in `--backend`"))
1013                    }
1014                    Some(backend) => backends.push(backend),
1015                    None => return Err(format!("`--backend` takes `ast` or `vm`, not `{name}`")),
1016                }
1017            }
1018            if backends.is_empty() {
1019                return Err("`--backend` needs at least one backend".to_string());
1020            }
1021            return Ok(backends);
1022        }
1023        i += 1;
1024    }
1025    Ok(vec![Backend::Vm])
1026}
1027
1028fn matrix(
1029    package: &Package,
1030    program: &Arc<Program>,
1031    sources: &Arc<SourceMap>,
1032    iterations: u32,
1033    order: SampleOrder,
1034) -> ExitCode {
1035    let backends = match parse_matrix_backends() {
1036        Ok(backends) => backends,
1037        Err(message) => {
1038            eprintln!("cove-bench: {message}");
1039            return ExitCode::FAILURE;
1040        }
1041    };
1042
1043    // A row name after `--matrix` runs that row alone, which is what a
1044    // profiler wants: `samply record -- cove-bench --matrix conv_host`
1045    // records one row rather than eight.
1046    let only: Option<String> = std::env::args()
1047        .skip_while(|argument| argument != "--matrix")
1048        .nth(1)
1049        .filter(|argument| !argument.starts_with("--"));
1050    let wanted = |name: &str| only.as_deref().is_none_or(|only| only == name);
1051
1052    // One linear-memory lowering per row, because that is the unit
1053    // `cove_ir::lower_entry` lowers. All of them before anything is timed.
1054    let mut linear: Vec<(&'static str, Arc<cove_ir::Program>)> = Vec::new();
1055    if backends.contains(&Backend::Vm) {
1056        for (name, _) in MATRIX.iter() {
1057            if !wanted(name) {
1058                continue;
1059            }
1060            let (module, entry, _) = match entry_for(package, program, name) {
1061                Ok(found) => found,
1062                Err(message) => {
1063                    eprintln!("cove-bench: {message}");
1064                    return ExitCode::FAILURE;
1065                }
1066            };
1067            match cove_ir::lower_entry(program, sources, &HostSchemas::new(), module, entry) {
1068                Ok(lowered) => linear.push((name, Arc::new(lowered))),
1069                Err(diagnostics) => {
1070                    let what = diagnostics
1071                        .first()
1072                        .map(|d| d.message.as_str())
1073                        .unwrap_or("the lowering failed and said nothing");
1074                    eprintln!("cove-bench: `{name}` does not lower to the linear IR: {what}");
1075                    return ExitCode::FAILURE;
1076                }
1077            }
1078        }
1079    }
1080
1081    println!(
1082        "the calling-convention matrix, {} backend(s), {iterations} iteration(s) \
1083of {MATRIX_TURNS} turns each",
1084        backends
1085            .iter()
1086            .map(Backend::to_string)
1087            .collect::<Vec<_>>()
1088            .join(", "),
1089    );
1090
1091    // The matrix is read as ratios *between* its rows, so the order it takes
1092    // its samples in matters to it more than it does to the suite: a row
1093    // measured minutes after the row it is divided by carries whatever the
1094    // machine did in between into the quotient. So it obeys `--sample-order`
1095    // too, which is why every row is opened before any of them is timed and
1096    // nothing is printed until all of them are finished. With more than one
1097    // backend the same argument covers the ratio between two backends of one
1098    // row, which is what interleaves them here rather than running a backend
1099    // at a time.
1100    let mut rows: Vec<MatrixRow> = Vec::new();
1101    for (name, what) in MATRIX.iter() {
1102        if !wanted(name) {
1103            continue;
1104        }
1105        let (module, entry, allow) = match entry_for(package, program, name) {
1106            Ok(found) => found,
1107            Err(message) => {
1108                eprintln!("cove-bench: {message}");
1109                return ExitCode::FAILURE;
1110            }
1111        };
1112        for &backend in &backends {
1113            rows.push(MatrixRow {
1114                name,
1115                what,
1116                backend,
1117                module,
1118                entry,
1119                allow: allow.clone(),
1120                ir: match backend {
1121                    Backend::Vm => linear
1122                        .iter()
1123                        .find(|(row, _)| row == name)
1124                        .map(|(_, program)| program),
1125                    Backend::Ast => None,
1126                },
1127                samples: Vec::with_capacity(iterations as usize),
1128                instructions: 0,
1129                ok: true,
1130            });
1131        }
1132    }
1133
1134    let sample = |row: &mut MatrixRow| {
1135        let measurement = run_once(
1136            program,
1137            sources,
1138            row.module,
1139            row.entry,
1140            &row.allow,
1141            Arc::new(NullSink),
1142            row.backend,
1143            row.ir,
1144        );
1145        row.samples.push(measurement.wall.as_nanos() as u64);
1146        row.instructions = measurement.instructions.unwrap_or(0);
1147        if let Some(message) = measurement.failure {
1148            eprintln!(
1149                "cove-bench: matrix row `{}` on {} did not pass: {message}",
1150                row.name, row.backend
1151            );
1152            row.ok = false;
1153        }
1154    };
1155    match order {
1156        SampleOrder::Blocked => {
1157            for row in rows.iter_mut() {
1158                for _ in 0..iterations {
1159                    sample(row);
1160                }
1161            }
1162        }
1163        SampleOrder::RoundRobin => {
1164            for _ in 0..iterations {
1165                for row in rows.iter_mut() {
1166                    sample(row);
1167                }
1168            }
1169        }
1170    }
1171
1172    let mut ok = true;
1173    // One block per backend, so that a default run reads exactly as it always
1174    // has and a two-backend run is two tables rather than one table whose
1175    // neighbouring lines are not comparable.
1176    for &backend in &backends {
1177        println!("\n{backend}:");
1178        println!(
1179            "row                 median       min       max  spread vs base   \
1180instructions per turn   ns/turn  what"
1181        );
1182        let mut baseline = 0.0f64;
1183        for row in rows.iter_mut().filter(|row| row.backend == backend) {
1184            let (name, what, instructions) = (row.name, row.what, row.instructions);
1185            ok &= row.ok;
1186            let samples = &mut row.samples;
1187            samples.sort_unstable();
1188
1189            let median = stats::quantile(samples, 0.5) / 1e6;
1190            let min = samples[0] as f64 / 1e6;
1191            let max = samples[samples.len() - 1] as f64 / 1e6;
1192            if baseline == 0.0 && name == MATRIX[0].0 {
1193                baseline = median;
1194            }
1195            // A single row asked for by name has no baseline beside it, and a
1196            // ratio against a row that did not run would be a number made up.
1197            let against = if baseline > 0.0 {
1198                format!("{:.2}x", median / baseline)
1199            } else {
1200                "-".to_string()
1201            };
1202            println!(
1203                "{:<14} {:>8.2}ms {:>8.2}ms {:>8.2}ms {:>6.1}% {:>7} {:>14} {:>8.1} {:>8.1}  {}",
1204                name,
1205                median,
1206                min,
1207                max,
1208                100.0 * (max - min) / median,
1209                against,
1210                instructions,
1211                instructions as f64 / MATRIX_TURNS as f64,
1212                median * 1e6 / MATRIX_TURNS as f64,
1213                what
1214            );
1215        }
1216    }
1217
1218    if ok {
1219        ExitCode::SUCCESS
1220    } else {
1221        ExitCode::FAILURE
1222    }
1223}
1224
1225/// The `benches/` package, rooted next to this crate.
1226fn benches_root() -> PathBuf {
1227    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benches")
1228}
1229
1230fn load_benches() -> Result<(SourceMap, Package, Program), String> {
1231    let root = benches_root();
1232    let mut sources = SourceMap::new();
1233    let package = cove_sema::package::load(&root, &mut sources).map_err(|items| {
1234        format!(
1235            "`{}` does not load:\n{}",
1236            root.display(),
1237            render_all(&sources, &items)
1238        )
1239    })?;
1240    // Both halves of the check, because the lowering reads what the second
1241    // one settled and a program that was only resolved carries none of it.
1242    // A benchmark measured against that program would be measuring a
1243    // lowering `cove run` never produces.
1244    let program = cove_sema::Compiler::new()
1245        .compile(&package)
1246        .map_err(|items| {
1247            format!(
1248                "`{}` does not check:\n{}",
1249                root.display(),
1250                render_all(&sources, &items)
1251            )
1252        })?;
1253    Ok((sources, package, program))
1254}
1255
1256fn render_all(sources: &SourceMap, items: &[cove_diag::Diagnostic]) -> String {
1257    items
1258        .iter()
1259        .map(|item| cove_diag::render(sources, item))
1260        .collect::<Vec<_>>()
1261        .join("\n")
1262}
1263
1264/// The module, function name, and granted capabilities for `[run.<name>]`,
1265/// looked up the way `cove run` looks up a run.
1266fn entry_for<'a>(
1267    package: &'a Package,
1268    program: &Program,
1269    name: &str,
1270) -> Result<(&'a str, &'a str, Vec<String>), String> {
1271    let run = package
1272        .config
1273        .runs
1274        .get(name)
1275        .ok_or_else(|| format!("`benches/cove.toml` has no `[run.{name}]` table"))?;
1276    let (module, entry) = run
1277        .entry_parts()
1278        .ok_or_else(|| format!("`[run.{name}] entry` must be a qualified function"))?;
1279    if program.lookup_fn(module, entry).is_none() {
1280        return Err(format!(
1281            "`[run.{name}] entry` refers to `{}`, which `benches/` does not declare",
1282            run.entry
1283        ));
1284    }
1285    Ok((module, entry, run.allow.clone()))
1286}
1287
1288/// The same deterministic fakes `cove test` grants by default (see
1289/// `crates/cove-cli/src/test.rs`), always chosen here: a Host-heavy
1290/// benchmark measures dispatch, grant checks, and budget accounting through
1291/// them, never real I/O latency and never the network.
1292fn fake_hosts(allow: Vec<String>) -> HostRegistry {
1293    let mut hosts = HostRegistry::new(Grants::new(allow));
1294    hosts.register(Box::new(Console::new(std::io::sink(), std::io::sink())));
1295    hosts.register(Box::new(Env::new(Default::default())));
1296    hosts.register(Box::new(Documents::in_memory(Default::default())));
1297    hosts.register(Box::new(Clock::virtual_clock(VirtualTime::new())));
1298    hosts.register(Box::new(Files::in_memory(Default::default())));
1299    hosts.register(Box::new(Process::recorded(
1300        Vec::new(),
1301        Default::default(),
1302        ProcessLog::new(),
1303    )));
1304    hosts.register(Box::new(Database::recorded(Default::default())));
1305    hosts
1306}
1307
1308/// What one run of a benchmark's entry measured.
1309struct RunMeasurement {
1310    wall: Duration,
1311    fuel_spent: u64,
1312    host_calls: u64,
1313    irreversible_writes: u64,
1314    heap: HeapStats,
1315    /// How many instructions the run executed, on a lowered backend. `None`
1316    /// on the interpreter, which has none -- the same distinction `cove run
1317    /// --stats` makes, and for the same reason.
1318    ///
1319    /// The figure a rebuild cannot move, which is why it is reported beside
1320    /// the wall time rather than only inside `--matrix`: ADR 0029 makes an
1321    /// exact count repeatable where an absolute is not, and ADR 0034 asks for
1322    /// instruction counts by name if a gate fails. A ratio with a count
1323    /// beside it says whether a backend was slower per instruction or simply
1324    /// ran more of them.
1325    instructions: Option<u64>,
1326    /// `Some(message)` when the entry returned `Err` or the interpreter
1327    /// itself failed; `None` when it passed.
1328    failure: Option<String>,
1329}
1330
1331/// Builds a fresh registry, budget, and backend -- exactly what `cove run`
1332/// builds for one run -- and calls `module.entry` once under `trace`.
1333///
1334/// `ir` is `Some` on a `vm` row and `None` on an `ast` one; everything
1335/// either backend is given is built the same way and given to both, so the
1336/// difference between two measurements is the backend and nothing around it.
1337#[allow(clippy::too_many_arguments)]
1338fn run_once(
1339    program: &Arc<Program>,
1340    sources: &Arc<SourceMap>,
1341    module: &str,
1342    entry: &str,
1343    allow: &[String],
1344    trace: Arc<dyn TraceSink>,
1345    backend: Backend,
1346    ir: Option<&Arc<cove_ir::Program>>,
1347) -> RunMeasurement {
1348    let mut hosts = fake_hosts(allow.to_vec());
1349    hosts.set_budget(Budget::with_cancellation(
1350        Limits::default(),
1351        Cancellation::new(),
1352    ));
1353    hosts.set_trace(trace.clone());
1354
1355    let hosts = Arc::new(hosts);
1356    let runtime =
1357        Runtime::new(Arc::clone(program), Arc::clone(sources), hosts.clone()).with_trace(trace);
1358
1359    let started = Instant::now();
1360    if backend == Backend::Vm {
1361        let ir = ir.expect("a `vm` row was resolved against the linear lowering");
1362        let mut vm = Vm::new(&runtime, &hosts, ir);
1363        let outcome = vm.run_entry(module, entry, Vec::<Rc<str>>::new());
1364        // `heap_words` is the heap region's size, free blocks included, and
1365        // not the peak live set the object heaps report. The two answer
1366        // different questions and this harness does not pretend otherwise:
1367        // see [`ExecutionReport::heap_peak_bytes`].
1368        let heap = HeapStats {
1369            peak_bytes: vm.heap_words() * 8,
1370            allocated_bytes: vm.allocated_words() * 8,
1371            ..HeapStats::default()
1372        };
1373        let instructions = Some(vm.instructions());
1374        let wall = started.elapsed();
1375        return finish(&runtime, wall, heap, instructions, outcome);
1376    }
1377    // `ast`, the only backend left: it runs the checked program directly,
1378    // with no lowered form and no instruction count to report.
1379    let mut interpreter = Interpreter::new(&runtime);
1380    let outcome = interpreter.run_entry(module, entry, Vec::<Rc<str>>::new());
1381    let wall = started.elapsed();
1382    finish(&runtime, wall, interpreter.heap_stats(), None, outcome)
1383}
1384
1385/// Reads the counters a run leaves behind and says whether it passed.
1386///
1387/// Shared by every backend, so that what a measurement is made of does not
1388/// depend on which evaluator produced it.
1389fn finish(
1390    runtime: &Runtime,
1391    wall: Duration,
1392    heap: HeapStats,
1393    instructions: Option<u64>,
1394    outcome: Result<Value, cove_runtime::RuntimeError>,
1395) -> RunMeasurement {
1396    let (fuel_spent, host_calls) = runtime
1397        .hosts()
1398        .with_budget(|budget| (budget.fuel_spent(), budget.host_calls()))
1399        .unwrap_or((0, 0));
1400    let irreversible_writes = runtime.hosts().irreversible_writes();
1401
1402    let failure = match outcome {
1403        Ok(value) => entry_err_message(&value),
1404        Err(error) => Some(error.message),
1405    };
1406
1407    RunMeasurement {
1408        wall,
1409        fuel_spent,
1410        host_calls,
1411        irreversible_writes,
1412        heap,
1413        instructions,
1414        failure,
1415    }
1416}
1417
1418/// `Some(message)` when `value` is the `Err` an entry returned; `None` for
1419/// `Ok` or an entry that returns bare `()`.
1420fn entry_err_message(value: &Value) -> Option<String> {
1421    Some(
1422        value
1423            .err_payload()?
1424            .first()
1425            .map(ToString::to_string)
1426            .unwrap_or_default(),
1427    )
1428}
1429
1430/// One benchmark's execution report on one backend: wall time, fuel spent,
1431/// and the heap's peak live bytes.
1432///
1433/// `fuel_spent` is the backend's own normalized work counter, unaffected by
1434/// machine noise and comparable only against itself: ADR 0019 says an
1435/// instruction is not an AST node and there is no honest mapping between
1436/// them, so the two backends' fuel figures are two measurements and not one
1437/// comparison. `wall_ns` is what compares them.
1438struct ExecutionReport {
1439    benchmark: &'static str,
1440    backend: Backend,
1441    iterations: u32,
1442    wall_ns: Stats,
1443    fuel_spent: u64,
1444    fuel_per_sec: f64,
1445    /// The largest live set a collection measured, on the backends that
1446    /// collect an object heap.
1447    ///
1448    /// **On `vm` this is a different statistic under the same name**, and
1449    /// there is no honest way to make it the same one: that backend has no
1450    /// object heap to take a live set of, it has a heap *region*, and what it
1451    /// can answer is how many words of it the run held. So the `vm` row
1452    /// reports the region's size in bytes — free blocks included — and the
1453    /// `ast` row reports what it always did. Read the `vm` figure against
1454    /// itself and not against the row above it.
1455    heap_peak_bytes: Stats,
1456    host_calls: u64,
1457    irreversible_writes: u64,
1458    /// How many instructions the run executed, or `None` on the interpreter.
1459    instructions: Option<u64>,
1460    ok: bool,
1461}
1462
1463impl ExecutionReport {
1464    fn to_json(&self) -> String {
1465        format!(
1466            "{{\"benchmark\":\"{}\",\"kind\":\"{}\",\"backend\":\"{}\",\"iterations\":{},\"wall_ns\":{},\"fuel_spent\":{},\"fuel_per_sec\":{:.1},\"heap_peak_bytes\":{},\"host_calls\":{},\"irreversible_writes\":{},\"instructions\":{},\"ok\":{}}}",
1467            self.benchmark,
1468            self.backend.kind(),
1469            self.backend,
1470            self.iterations,
1471            self.wall_ns.to_json_with_samples(),
1472            self.fuel_spent,
1473            self.fuel_per_sec,
1474            self.heap_peak_bytes.to_json(),
1475            self.host_calls,
1476            self.irreversible_writes,
1477            match self.instructions {
1478                Some(instructions) => instructions.to_string(),
1479                None => "null".to_string(),
1480            },
1481            self.ok,
1482        )
1483    }
1484}
1485
1486/// One benchmark on one backend, and the samples taken of it so far.
1487///
1488/// The series is a field rather than a local of the loop that fills it
1489/// because [`SampleOrder::RoundRobin`] leaves and comes back: a row is opened
1490/// once, sampled at whatever points in the suite the order says, and read
1491/// only when every row is finished.
1492struct Row<'a> {
1493    name: &'static str,
1494    backend: Backend,
1495    module: &'a str,
1496    entry: &'a str,
1497    allow: Vec<String>,
1498    /// The linear-memory program this row runs, on a `vm` row and nowhere
1499    /// else. Lowered per entry, because that is what `cove_ir` lowers.
1500    ir: Option<&'a Arc<cove_ir::Program>>,
1501    wall_ns: Vec<u64>,
1502    heap_peak: Vec<u64>,
1503    fuel_spent: u64,
1504    host_calls: u64,
1505    irreversible_writes: u64,
1506    instructions: Option<u64>,
1507    ok: bool,
1508}
1509
1510impl<'a> Row<'a> {
1511    /// Looks the benchmark's entry up, without running anything.
1512    fn resolve(
1513        package: &'a Package,
1514        program: &Program,
1515        name: &'static str,
1516        backend: Backend,
1517        ir: Option<&'a Arc<cove_ir::Program>>,
1518    ) -> Result<Row<'a>, String> {
1519        let (module, entry, allow) = entry_for(package, program, name)?;
1520        Ok(Row {
1521            name,
1522            backend,
1523            module,
1524            entry,
1525            allow,
1526            ir,
1527            wall_ns: Vec::new(),
1528            heap_peak: Vec::new(),
1529            fuel_spent: 0,
1530            host_calls: 0,
1531            irreversible_writes: 0,
1532            instructions: None,
1533            ok: true,
1534        })
1535    }
1536
1537    /// Runs the benchmark once more and keeps what that run measured.
1538    ///
1539    /// The counters are assignments rather than accumulations because they
1540    /// are exact and every run produces the same ones: a benchmark that ran a
1541    /// different number of instructions on its ninth sample than on its first
1542    /// would be a different benchmark, and that is what `fuel_spent` being
1543    /// identical across a table is there to prove.
1544    fn sample(&mut self, program: &Arc<Program>, sources: &Arc<SourceMap>) {
1545        let measurement = run_once(
1546            program,
1547            sources,
1548            self.module,
1549            self.entry,
1550            &self.allow,
1551            Arc::new(NullSink),
1552            self.backend,
1553            self.ir,
1554        );
1555        self.wall_ns.push(measurement.wall.as_nanos() as u64);
1556        self.heap_peak.push(measurement.heap.peak_bytes);
1557        self.fuel_spent = measurement.fuel_spent;
1558        self.host_calls = measurement.host_calls;
1559        self.irreversible_writes = measurement.irreversible_writes;
1560        self.instructions = measurement.instructions;
1561        if let Some(message) = measurement.failure {
1562            eprintln!(
1563                "cove-bench: benchmark `{}` on {} did not pass: {message}",
1564                self.name, self.backend
1565            );
1566            self.ok = false;
1567        }
1568    }
1569
1570    /// What the row measured, once every sample of it has been taken.
1571    fn report(&self, iterations: u32) -> ExecutionReport {
1572        let wall = Stats::of(&self.wall_ns);
1573        let fuel_per_sec = if wall.mean() > 0 {
1574            self.fuel_spent as f64 / (wall.mean() as f64 / 1e9)
1575        } else {
1576            0.0
1577        };
1578        ExecutionReport {
1579            benchmark: self.name,
1580            backend: self.backend,
1581            iterations,
1582            wall_ns: wall,
1583            fuel_spent: self.fuel_spent,
1584            fuel_per_sec,
1585            heap_peak_bytes: Stats::of(&self.heap_peak),
1586            host_calls: self.host_calls,
1587            irreversible_writes: self.irreversible_writes,
1588            instructions: self.instructions,
1589            ok: self.ok,
1590        }
1591    }
1592}
1593
1594/// Fills every row's series, in the order `order` asks for.
1595///
1596/// Both orders run exactly the same runs exactly as many times. What differs
1597/// is when: [`SampleOrder::Blocked`] finishes a row before it starts the next
1598/// one, so a row's whole series is taken in one span of the suite, and
1599/// [`SampleOrder::RoundRobin`] takes one sample of every row per pass, so
1600/// each row's series is spread across the whole of it.
1601fn take_samples(
1602    program: &Arc<Program>,
1603    sources: &Arc<SourceMap>,
1604    rows: &mut [Row<'_>],
1605    iterations: u32,
1606    order: SampleOrder,
1607) {
1608    match order {
1609        SampleOrder::Blocked => {
1610            for row in rows.iter_mut() {
1611                for _ in 0..iterations {
1612                    row.sample(program, sources);
1613                }
1614            }
1615        }
1616        SampleOrder::RoundRobin => {
1617            for _ in 0..iterations {
1618                for row in rows.iter_mut() {
1619                    row.sample(program, sources);
1620                }
1621            }
1622        }
1623    }
1624}
1625
1626/// Compares one benchmark run untraced against the same run under a real
1627/// [`JsonlSink`] writing nowhere: the difference is tracing's own cost, not
1628/// the cost of whatever the sink's destination happens to be.
1629struct TraceOverheadReport {
1630    benchmark: &'static str,
1631    backend: Backend,
1632    untraced_wall_ns: u64,
1633    traced_wall_ns: u64,
1634    overhead_ratio: f64,
1635}
1636
1637impl TraceOverheadReport {
1638    fn to_json(&self) -> String {
1639        format!(
1640            "{{\"benchmark\":\"{}\",\"kind\":\"trace_overhead\",\"backend\":\"{}\",\"untraced_wall_ns\":{},\"traced_wall_ns\":{},\"overhead_ratio\":{:.3}}}",
1641            self.benchmark,
1642            self.backend,
1643            self.untraced_wall_ns,
1644            self.traced_wall_ns,
1645            self.overhead_ratio
1646        )
1647    }
1648}
1649
1650/// Times one row untraced and then traced, back to back.
1651///
1652/// This one stays blocked whatever `--sample-order` says, and deliberately:
1653/// what it reports is the *ratio* of two series of the same work, so the two
1654/// have to be taken as close together as they can be. Spreading them apart
1655/// would put the machine's drift between the numerator and the denominator,
1656/// which is the mistake the flag exists to avoid making everywhere else.
1657fn bench_trace_overhead(
1658    program: &Arc<Program>,
1659    sources: &Arc<SourceMap>,
1660    row: &Row<'_>,
1661    iterations: u32,
1662) -> TraceOverheadReport {
1663    let (module, entry, allow, ir) = (row.module, row.entry, &row.allow, row.ir);
1664
1665    let mut untraced = Vec::with_capacity(iterations as usize);
1666    for _ in 0..iterations {
1667        let m = run_once(
1668            program,
1669            sources,
1670            module,
1671            entry,
1672            allow,
1673            Arc::new(NullSink),
1674            row.backend,
1675            ir,
1676        );
1677        untraced.push(m.wall.as_nanos() as u64);
1678    }
1679
1680    let mut traced = Vec::with_capacity(iterations as usize);
1681    for _ in 0..iterations {
1682        let header = TraceHeader {
1683            // The backend this measurement is of, which is the one the
1684            // recording would have been made on had it been kept. ADR 0026
1685            // makes a recording name the backend that made it, and the two
1686            // evaluators this file measures are the two a recording can
1687            // name.
1688            backend: match row.backend {
1689                Backend::Ast => RecordingBackend::Ast,
1690                Backend::Vm => RecordingBackend::Vm,
1691            },
1692            values: ValueCapture::Redacted,
1693            entry: format!("{module}.{entry}"),
1694            args: Vec::new(),
1695        };
1696        let sink: Arc<dyn TraceSink> = Arc::new(JsonlSink::new(std::io::sink(), header));
1697        let m = run_once(
1698            program,
1699            sources,
1700            module,
1701            entry,
1702            allow,
1703            sink,
1704            row.backend,
1705            ir,
1706        );
1707        traced.push(m.wall.as_nanos() as u64);
1708    }
1709
1710    let untraced_mean = Stats::of(&untraced).mean();
1711    let traced_mean = Stats::of(&traced).mean();
1712    let overhead_ratio = if untraced_mean > 0 {
1713        traced_mean as f64 / untraced_mean as f64
1714    } else {
1715        1.0
1716    };
1717
1718    TraceOverheadReport {
1719        benchmark: row.name,
1720        backend: row.backend,
1721        untraced_wall_ns: untraced_mean,
1722        traced_wall_ns: traced_mean,
1723        overhead_ratio,
1724    }
1725}
1726
1727/// Process-level startup: spawns the real `cove` binary and times the whole
1728/// exec-to-exit span, which is what an in-process measurement cannot see.
1729struct StartupReport {
1730    backend: Backend,
1731    iterations: u32,
1732    wall_ns: Stats,
1733    ok: bool,
1734}
1735
1736impl StartupReport {
1737    fn to_json(&self) -> String {
1738        format!(
1739            "{{\"benchmark\":\"startup\",\"kind\":\"process\",\"backend\":\"{}\",\"iterations\":{},\"wall_ns\":{},\"ok\":{}}}",
1740            self.backend,
1741            self.iterations,
1742            self.wall_ns.to_json_with_samples(),
1743            self.ok
1744        )
1745    }
1746}
1747
1748/// The `cove` binary built alongside this one.
1749///
1750/// `cove-bench` is not run through `cargo test`, so `CARGO_BIN_EXE_cove` is
1751/// not set; the two binaries land in the same target directory whether the
1752/// workspace was built with `cargo build --workspace` or one crate at a
1753/// time, so this binary's own directory is where to look.
1754fn cove_binary() -> Result<PathBuf, String> {
1755    let exe =
1756        std::env::current_exe().map_err(|e| format!("cannot read this binary's own path: {e}"))?;
1757    let dir = exe
1758        .parent()
1759        .ok_or_else(|| "this binary has no parent directory".to_string())?;
1760    let name = if cfg!(windows) { "cove.exe" } else { "cove" };
1761    let path = dir.join(name);
1762    if !path.is_file() {
1763        return Err(format!(
1764            "`{}` does not exist; run `cargo build -p cove-cli` first",
1765            path.display()
1766        ));
1767    }
1768    Ok(path)
1769}
1770
1771/// The startup a `--backend vm` process pays is the one this measures, which
1772/// is the point of measuring it here rather than in-process: the lowering is
1773/// part of what a `vm` run costs before it does any work, and a process is
1774/// where every such cost is paid at once.
1775fn bench_startup(iterations: u32, backend: Backend) -> Result<StartupReport, String> {
1776    let cove = cove_binary()?;
1777    let root = benches_root();
1778
1779    let mut wall_ns = Vec::with_capacity(iterations as usize);
1780    let mut ok = true;
1781
1782    for _ in 0..iterations {
1783        let started = Instant::now();
1784        let status = Command::new(&cove)
1785            .arg("run")
1786            .arg("startup")
1787            .arg("--backend")
1788            .arg(backend.to_string())
1789            .current_dir(&root)
1790            .status()
1791            .map_err(|e| format!("cannot run `{}`: {e}", cove.display()))?;
1792        wall_ns.push(started.elapsed().as_nanos() as u64);
1793        ok &= status.success();
1794    }
1795
1796    Ok(StartupReport {
1797        backend,
1798        iterations,
1799        wall_ns: Stats::of(&wall_ns),
1800        ok,
1801    })
1802}