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}