Benchmarks

A benchmark is a test that measures. It lives in a *.bench.* or *.benchmark.* file and gets bench from its context:

TypeScript
// src/parse.bench.ts
import { expect, test } from "runtime:test";
import { parse, parseFast } from "./parse.ts";

test("parseFast is faster", async ({ bench }) => {
  const results = await bench.compare(
    bench("parse", () => parse(input)),
    bench("parseFast", () => parseFast(input)),
  );
  expect(results.get("parseFast")).toBeFasterThan(results.get("parse"), { delta: 0.1 });
});
Shell
esdev bench
esdev bench parse

esdev bench takes the same flags and filters as esdev test, and runs one file at a time so that files do not slow each other down. esdev test does not run benchmark files.

The results are printed as a table. Values vary by machine and run:

TEXT
  name        ops/sec  mean  p75  p99  rme  samples
  parse       <value>  ...   ...  ...  ...  <count>  <comparison>
  parseFast   <value>  ...   ...  ...  ...  <count>  <comparison>

Measuring

bench(name, options?, fn) registers a benchmark. fn may be async; each call is awaited.

  • .run(options?) measures one benchmark and resolves to its result.

  • bench.compare(...benchmarks, options?) measures several, a sample of each in turn, and resolves to a Map of results by name.

OptionDefault
time500Milliseconds of samples, at least.
iterations10Samples, at least.
warmupTime100Milliseconds of warm-up before measuring.
warmupIterations5Warm-up samples, at least.

A benchmark's own options may also have beforeEach and afterEach, which run around each sample and are not timed.

A function too fast for the clock is timed in loops of calls, and each call's time is the loop's time divided by its count. clock.freeze() does not affect measurement.

Timing part of a call

The function is called with b. When it calls b.start() and b.end(), only the code between them is timed, so each call can prepare its own input:

TypeScript
bench("sort", (b) => {
  const data = shuffled(10_000);
  b.start();
  data.sort();
  b.end();
});

The section is timed on its own each call, so keep it long enough for the available clock resolution. A function must mark a section on every call or on none. The too short warning identifies sections that need more work per call.

Warnings

A result's warnings lists what makes it untrustworthy, and the table prints them beneath it:

WarningMeans
noisyThe mean is known only to more than ±5%. Measure for longer, or on a quieter machine.
as fast as calling an empty functionThe engine may have removed the work, because nothing uses its result.
the timed section is too shortThe section between b.start() and b.end() is too short for the clock to time.

Comparing with an earlier run

writeResult writes a benchmark's result to a file each time it is measured, and bench.from(name, path) reads it back as a benchmark that is not measured again. Committing the file makes the comparison repeatable:

TypeScript
import { env } from "runtime:process";

test("parse has not slowed down", async ({ bench }) => {
  const parse = () => parseConfig(input);
  if (env.WRITE_BENCH) {
    await bench("parse", { writeResult: "./bench/parse.json" }, parse).run();
    return;
  }
  const results = await bench.compare(
    bench("current", parse),
    bench.from("stored", "./bench/parse.json"),
  );
  expect(results.get("current")).not.toBeSlowerThan(results.get("stored"), { delta: 0.1 });
});
Shell
WRITE_BENCH=1 esdev bench   # store the result
esdev bench                 # compare against it

Paths are relative to the project root. Files are read and written with the test's own permissions, so --deny-write stops writeResult, and a test in a browser page can use neither. bench.from also takes a function that returns a result, for one stored elsewhere.

A stored result was measured on whatever machine wrote it. Compare against it on the same machine.

Results

nameThe benchmark's name.
samplesHow many samples were taken.
latencyMilliseconds per call: mean, min, max, p50, p75, p99, p999, sd, and rme, the 95% margin of the mean as a percentage.
throughputCalls per second: mean, min, max, p50 and rme.

warnings and stored (true for a result bench.from read) complete it.

With --reporter=json, each measured result is a line of its own: { "type": "bench", "file", "test", "result" }.

expect(a).toBeFasterThan(b, { delta }) and toBeSlowerThan compare two results' throughput. delta is the margin required, 0.1 being 10%, so that noise alone cannot pass or fail the test.

Last updated on
Edit this page