zbench-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js ADDED
@@ -0,0 +1,175 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { isTimespan, unitScale } from './units.js';
5
+ /** Config files are looked for at these paths, in order, relative to the working directory. */
6
+ export const configPaths = ['tests/perf/config.json', 'tests/perf.json', 'tests/perf.config.json', 'zbench.json'];
7
+ export const defaultBuild = 'npm install --no-audit --no-fund && npm run build';
8
+ class ConfigError extends Error {
9
+ constructor(path, message) {
10
+ super(`${path}: ${message}`);
11
+ this.name = 'ConfigError';
12
+ }
13
+ }
14
+ function check(condition, path, message) {
15
+ if (!condition)
16
+ throw new ConfigError(path, message);
17
+ }
18
+ function isRecord(value) {
19
+ return typeof value == 'object' && value !== null && !Array.isArray(value);
20
+ }
21
+ /** Find the config file, starting from `from` and walking up to the filesystem root. */
22
+ export function findConfig(from = process.cwd()) {
23
+ for (let dir = resolve(from);; dir = dirname(dir)) {
24
+ for (const candidate of configPaths) {
25
+ const path = join(dir, candidate);
26
+ if (existsSync(path))
27
+ return path;
28
+ }
29
+ if (dir == dirname(dir))
30
+ return null;
31
+ }
32
+ }
33
+ function parseMeasureValue(value, path) {
34
+ if (value === undefined || value === false)
35
+ return undefined;
36
+ if (value === true)
37
+ return null;
38
+ check(isTimespan(value), path, `expected true, false, or a timespan ("ns", "us", "ms", "s")`);
39
+ return value;
40
+ }
41
+ function parseMetrics(measure, path) {
42
+ if (measure === undefined)
43
+ return [];
44
+ check(isRecord(measure), path, 'expected an object');
45
+ const metrics = [];
46
+ for (const [key, raw] of Object.entries(measure)) {
47
+ const at = `${path}.${key}`;
48
+ check(isRecord(raw), at, 'expected an object');
49
+ const spec = raw;
50
+ const unit = spec.unit ?? key;
51
+ check(typeof unit == 'string', `${at}.unit`, 'expected a string');
52
+ check(spec.scale === undefined || typeof spec.scale == 'number', `${at}.scale`, 'expected a number');
53
+ const scale = spec.scale ?? unitScale(unit);
54
+ for (const kind of ['throughput', 'cost']) {
55
+ for (const aggregate of [false, true]) {
56
+ const field = aggregate ? `aggregate_${kind}` : kind;
57
+ const span = parseMeasureValue(spec[field], `${at}.${field}`);
58
+ if (span === undefined)
59
+ continue;
60
+ metrics.push({ key, kind, aggregate, span, scale, unit, higherIsBetter: kind == 'throughput' });
61
+ }
62
+ }
63
+ check(metrics.some(m => m.key == key), at, 'declares no measurements; set at least one of throughput, cost, aggregate_throughput, aggregate_cost');
64
+ }
65
+ return metrics;
66
+ }
67
+ function defaultName(value) {
68
+ return Object.entries(value)
69
+ .map(([k, v]) => `${k}=${v}`)
70
+ .join(', ');
71
+ }
72
+ function parseFlags(flags, pool, path) {
73
+ if (flags === undefined)
74
+ return undefined;
75
+ if (Array.isArray(flags)) {
76
+ const selected = {};
77
+ for (const name of flags) {
78
+ check(typeof name == 'string', path, 'expected an array of flag names');
79
+ check(name in pool, path, `no flag named ${JSON.stringify(name)} is declared by the suite`);
80
+ selected[name] = pool[name];
81
+ }
82
+ return selected;
83
+ }
84
+ check(isRecord(flags), path, 'expected an array of flag names or an object of flag values');
85
+ const inline = {};
86
+ for (const [name, values] of Object.entries(flags)) {
87
+ check(Array.isArray(values) && values.length > 0, `${path}.${name}`, 'expected a non-empty array of values');
88
+ inline[name] = values;
89
+ }
90
+ return inline;
91
+ }
92
+ /** Parse and validate an already-loaded suite. `file` is used to resolve test paths. */
93
+ export function parseSuite(data, file) {
94
+ const dir = dirname(resolve(file));
95
+ const spec = Array.isArray(data) ? { tests: data } : data;
96
+ check(isRecord(spec), 'config', 'expected an array of tests or an object with a `tests` array');
97
+ check(Array.isArray(spec.tests), 'config.tests', 'expected an array');
98
+ const pool = {};
99
+ if (spec.flags !== undefined) {
100
+ check(isRecord(spec.flags), 'config.flags', 'expected an object of flag values');
101
+ for (const [name, values] of Object.entries(spec.flags)) {
102
+ check(Array.isArray(values) && values.length > 0, `config.flags.${name}`, 'expected a non-empty array of values');
103
+ pool[name] = values;
104
+ }
105
+ }
106
+ const iterations = spec.iterations ?? 5;
107
+ const warmup = spec.warmup ?? 1;
108
+ const tests = spec.tests.map((test, i) => {
109
+ const at = `config.tests[${i}]`;
110
+ check(isRecord(test), at, 'expected an object');
111
+ check(typeof test.path == 'string', `${at}.path`, 'expected a string');
112
+ check(Array.isArray(test.configurations), `${at}.configurations`, 'expected an array');
113
+ const configurations = test.configurations.map((config, j) => {
114
+ const cat = `${at}.configurations[${j}]`;
115
+ check(isRecord(config), cat, 'expected an object');
116
+ check(isRecord(config.value), `${cat}.value`, 'expected an object');
117
+ for (const [k, v] of Object.entries(config.value))
118
+ check(typeof v == 'number', `${cat}.value.${k}`, 'expected a number');
119
+ return {
120
+ name: config.name ?? defaultName(config.value),
121
+ cpu: config.cpu ?? 0,
122
+ mem: config.mem ?? 0,
123
+ value: config.value,
124
+ };
125
+ });
126
+ check(configurations.length > 0, `${at}.configurations`, 'expected at least one configuration');
127
+ return {
128
+ // The name, so one file can back several tests that differ only in their matrix
129
+ id: test.name ?? test.path,
130
+ name: test.name ?? test.path,
131
+ file: isAbsolute(test.path) ? test.path : resolve(dir, test.path),
132
+ path: test.path,
133
+ metrics: parseMetrics(test.measure, `${at}.measure`),
134
+ configurations,
135
+ flags: parseFlags(test.flags, pool, `${at}.flags`) ?? {},
136
+ iterations: test.iterations ?? iterations,
137
+ warmup: test.warmup ?? warmup,
138
+ };
139
+ });
140
+ // Results are keyed by id, so a duplicate would silently merge two tests' numbers
141
+ const seen = new Set();
142
+ for (const test of tests) {
143
+ check(!seen.has(test.id), `config.tests`, `more than one test is named ${JSON.stringify(test.id)}`);
144
+ seen.add(test.id);
145
+ }
146
+ return {
147
+ file: resolve(file),
148
+ root: spec.root ? resolve(dir, spec.root) : dir,
149
+ build: spec.build ?? defaultBuild,
150
+ tests,
151
+ };
152
+ }
153
+ export function loadSuite(file) {
154
+ let data;
155
+ try {
156
+ data = JSON.parse(readFileSync(file, 'utf-8'));
157
+ }
158
+ catch (e) {
159
+ throw new ConfigError(file, e.message);
160
+ }
161
+ return parseSuite(data, file);
162
+ }
163
+ /** Every combination of the given flags, in declaration order. Always at least one (possibly empty) combination. */
164
+ export function flagCombinations(flags) {
165
+ let combinations = [{}];
166
+ for (const [name, values] of Object.entries(flags))
167
+ combinations = combinations.flatMap(base => values.map(value => ({ ...base, [name]: value })));
168
+ return combinations;
169
+ }
170
+ /** A stable, human-readable label for a flag combination. Empty when there are no flags. */
171
+ export function flagLabel(flags) {
172
+ return Object.entries(flags)
173
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
174
+ .join(', ');
175
+ }
@@ -0,0 +1,10 @@
1
+ export * from './capabilities.js';
2
+ export * from './compare.js';
3
+ export * from './config.js';
4
+ export * from './measure.js';
5
+ export * from './refs.js';
6
+ export * from './report.js';
7
+ export * from './runner.js';
8
+ export * from './stats.js';
9
+ export * from './types.js';
10
+ export * from './units.js';
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ export * from './capabilities.js';
3
+ export * from './compare.js';
4
+ export * from './config.js';
5
+ export * from './measure.js';
6
+ export * from './refs.js';
7
+ export * from './report.js';
8
+ export * from './runner.js';
9
+ export * from './stats.js';
10
+ export * from './types.js';
11
+ export * from './units.js';
@@ -0,0 +1,27 @@
1
+ import { type CaseResult } from './runner.js';
2
+ /** Everything a child process needs to run exactly one matrix. */
3
+ export interface WorkerSpec {
4
+ /** Path to the config file, as seen from the child's working directory */
5
+ config: string;
6
+ /** The test's id */
7
+ test: string;
8
+ /** The single flag combination to run */
9
+ flags: Record<string, unknown>;
10
+ iterations?: number;
11
+ warmup?: number;
12
+ cpu?: number;
13
+ mem?: number;
14
+ all?: boolean;
15
+ /** Where the child writes its results */
16
+ out: string;
17
+ }
18
+ /** Run one matrix and write the results where the parent expects them. */
19
+ export declare function runWorker(specPath: string): Promise<void>;
20
+ /**
21
+ * Run one matrix in a fresh process.
22
+ * Every matrix gets its own heap and its own module graph, which is what keeps one configuration
23
+ * from warming up (or poisoning) the next, and what lets a git reference's own build be loaded.
24
+ */
25
+ export declare function runIsolated(spec: Omit<WorkerSpec, 'out'>, cwd: string, cli: string,
26
+ /** Seconds before the worker is killed. 0 waits forever. */
27
+ timeout?: number): Promise<CaseResult[]>;
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { loadSuite } from './config.js';
7
+ import { runTest } from './runner.js';
8
+ /** Run one matrix and write the results where the parent expects them. */
9
+ export async function runWorker(specPath) {
10
+ const spec = JSON.parse(await readFile(specPath, 'utf-8'));
11
+ const suite = loadSuite(spec.config);
12
+ const test = suite.tests.find(t => t.id == spec.test);
13
+ if (!test)
14
+ throw new Error(`no test with id ${JSON.stringify(spec.test)} in ${spec.config}`);
15
+ const results = await runTest(test, {
16
+ iterations: spec.iterations,
17
+ warmup: spec.warmup,
18
+ cpu: spec.cpu,
19
+ mem: spec.mem,
20
+ all: spec.all,
21
+ flags: Object.fromEntries(Object.entries(spec.flags).map(([k, v]) => [k, [v]])),
22
+ });
23
+ await writeFile(spec.out, JSON.stringify(results));
24
+ }
25
+ /**
26
+ * Run one matrix in a fresh process.
27
+ * Every matrix gets its own heap and its own module graph, which is what keeps one configuration
28
+ * from warming up (or poisoning) the next, and what lets a git reference's own build be loaded.
29
+ */
30
+ export async function runIsolated(spec, cwd, cli,
31
+ /** Seconds before the worker is killed. 0 waits forever. */
32
+ timeout = 0) {
33
+ const dir = await mkdtemp(join(tmpdir(), 'zbench-'));
34
+ const specPath = join(dir, 'spec.json');
35
+ const out = join(dir, 'results.json');
36
+ try {
37
+ await writeFile(specPath, JSON.stringify({ ...spec, out }));
38
+ // Output is buffered rather than inherited: with several workers running it would interleave,
39
+ // and it is only worth reading when one of them fails
40
+ let output = '';
41
+ let timedOut = false;
42
+ const code = await new Promise((resolve, reject) => {
43
+ const extra = [];
44
+ if (!process.features.typescript)
45
+ extra.push('--disable-warning=ExperimentalWarning');
46
+ const child = spawn(process.execPath, [...process.execArgv, ...extra, cli, '--worker', specPath], {
47
+ cwd,
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ });
50
+ const collect = (chunk) => (output += chunk.toString());
51
+ child.stdout.on('data', collect);
52
+ child.stderr.on('data', collect);
53
+ const timer = timeout
54
+ ? setTimeout(() => {
55
+ timedOut = true;
56
+ child.kill('SIGKILL');
57
+ }, timeout * 1000)
58
+ : undefined;
59
+ child.on('error', reject);
60
+ child.on('close', c => {
61
+ clearTimeout(timer);
62
+ resolve(c ?? 1);
63
+ });
64
+ });
65
+ if (timedOut)
66
+ throw new Error(`timed out after ${timeout}s`);
67
+ if (code != 0)
68
+ throw new Error(`worker exited with code ${code}\n${output.trim()}`);
69
+ return JSON.parse(await readFile(out, 'utf-8'));
70
+ }
71
+ finally {
72
+ await rm(dir, { recursive: true, force: true });
73
+ }
74
+ }
@@ -0,0 +1,59 @@
1
+ import { type Metric, type Test } from './config.js';
2
+ import type { CaseResult } from './runner.js';
3
+ import { type Stats } from './stats.js';
4
+ import { type Timespan } from './units.js';
5
+ /** One test's results for one flag combination: the unit a table is printed for. */
6
+ export interface Matrix {
7
+ test: Test;
8
+ flags: Record<string, unknown>;
9
+ /** Empty when the test has no flags */
10
+ label: string;
11
+ cases: CaseResult[];
12
+ }
13
+ /** A metric with its timespan settled, so every row in a matrix shares one column heading. */
14
+ export interface ResolvedMetric extends Metric {
15
+ span: Timespan;
16
+ /** Column heading, e.g. `MB/s` or `ms/entry` */
17
+ label: string;
18
+ }
19
+ /** Split a test's results into one matrix per flag combination, in the order they were run. */
20
+ export declare function matrices(test: Test, cases: CaseResult[]): Matrix[];
21
+ /** Cases that actually produced samples. */
22
+ export declare function ran(matrix: Matrix): CaseResult[];
23
+ /**
24
+ * Settle each metric's timespan against the matrix as a whole.
25
+ * Inferring per row would give each row a different heading, so the totals decide it once.
26
+ */
27
+ export declare function resolveMetrics(matrix: Matrix): ResolvedMetric[];
28
+ /** A metric's value for one configuration, or `null` when there is nothing to divide. */
29
+ export declare function caseValue(metric: ResolvedMetric, result: CaseResult): number | null;
30
+ /** A metric's value across the whole matrix. */
31
+ export declare function aggregateValue(metric: ResolvedMetric, matrix: Matrix): number | null;
32
+ /** Timing statistics for a case, in milliseconds. */
33
+ export declare function timing(result: CaseResult): Stats;
34
+ /** Iterations per second, the metric every test has whether or not it declares one. */
35
+ export declare function opsPerSecond(result: CaseResult): number | null;
36
+ /**
37
+ * A reported quantity, resolved against one matrix.
38
+ * Tables and comparisons both work in terms of these, so a column is defined once and reported the same way everywhere.
39
+ */
40
+ export interface Column {
41
+ label: string;
42
+ higherIsBetter: boolean;
43
+ format(value: number): string;
44
+ /** `null` for a row the column does not apply to */
45
+ value(result: CaseResult): number | null;
46
+ /**
47
+ * The column's value across a set of cases, when it has one.
48
+ * The cases are a parameter rather than the matrix the column came from, so a comparison can
49
+ * evaluate one state's column against another state's results without re-inferring the timespan.
50
+ */
51
+ total?(cases: CaseResult[]): number | null;
52
+ /** Aggregate columns are reported once per matrix instead of once per row */
53
+ aggregate: boolean;
54
+ }
55
+ /**
56
+ * The columns a matrix reports: the built-in timings, then whatever the test declared.
57
+ * Aggregate metrics come last since they hold a single number rather than a column of them.
58
+ */
59
+ export declare function columns(matrix: Matrix): Column[];
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { flagLabel } from './config.js';
3
+ import { stats } from './stats.js';
4
+ import { duration, inferCostSpan, inferThroughputSpan, sig, timespans } from './units.js';
5
+ /** Split a test's results into one matrix per flag combination, in the order they were run. */
6
+ export function matrices(test, cases) {
7
+ const byFlags = new Map();
8
+ for (const result of cases) {
9
+ if (result.test != test.id)
10
+ continue;
11
+ const key = JSON.stringify(result.flags);
12
+ let matrix = byFlags.get(key);
13
+ if (!matrix)
14
+ byFlags.set(key, (matrix = { test, flags: result.flags, label: flagLabel(result.flags), cases: [] }));
15
+ matrix.cases.push(result);
16
+ }
17
+ return [...byFlags.values()];
18
+ }
19
+ /** Cases that actually produced samples. */
20
+ export function ran(matrix) {
21
+ return matrix.cases.filter(c => c.samples.length > 0);
22
+ }
23
+ /** The scaled amount and elapsed milliseconds a metric is computed from, for one case. */
24
+ function terms(metric, result) {
25
+ return [(result.amounts[metric.key] ?? 0) * metric.scale, result.samples.reduce((sum, v) => sum + v, 0)];
26
+ }
27
+ /** The same terms, summed across the whole matrix. */
28
+ function totals(metric, matrix) {
29
+ let amount = 0, ms = 0;
30
+ for (const result of ran(matrix)) {
31
+ const [a, t] = terms(metric, result);
32
+ amount += a;
33
+ ms += t;
34
+ }
35
+ return [amount, ms];
36
+ }
37
+ function compute(kind, span, amount, ms) {
38
+ if (!ms || !amount)
39
+ return null;
40
+ return kind == 'throughput' ? (amount * timespans[span]) / ms : ms / timespans[span] / amount;
41
+ }
42
+ /**
43
+ * Settle each metric's timespan against the matrix as a whole.
44
+ * Inferring per row would give each row a different heading, so the totals decide it once.
45
+ */
46
+ export function resolveMetrics(matrix) {
47
+ return matrix.test.metrics.map(metric => {
48
+ const [amount, ms] = totals(metric, matrix);
49
+ const span = metric.span
50
+ ?? (!amount || !ms
51
+ ? 'ms'
52
+ : metric.kind == 'throughput'
53
+ ? inferThroughputSpan(amount, ms)
54
+ : inferCostSpan(amount, ms));
55
+ return {
56
+ ...metric,
57
+ span,
58
+ label: metric.kind == 'throughput' ? `${metric.unit}/${span}` : `${span}/${metric.unit}`,
59
+ };
60
+ });
61
+ }
62
+ /** A metric's value for one configuration, or `null` when there is nothing to divide. */
63
+ export function caseValue(metric, result) {
64
+ return compute(metric.kind, metric.span, ...terms(metric, result));
65
+ }
66
+ /** A metric's value across the whole matrix. */
67
+ export function aggregateValue(metric, matrix) {
68
+ return compute(metric.kind, metric.span, ...totals(metric, matrix));
69
+ }
70
+ /** Timing statistics for a case, in milliseconds. */
71
+ export function timing(result) {
72
+ return stats(result.samples);
73
+ }
74
+ /** Iterations per second, the metric every test has whether or not it declares one. */
75
+ export function opsPerSecond(result) {
76
+ const { mean } = timing(result);
77
+ return mean ? 1000 / mean : null;
78
+ }
79
+ /**
80
+ * The columns a matrix reports: the built-in timings, then whatever the test declared.
81
+ * Aggregate metrics come last since they hold a single number rather than a column of them.
82
+ */
83
+ export function columns(matrix) {
84
+ const list = [
85
+ {
86
+ label: 'avg',
87
+ higherIsBetter: false,
88
+ format: duration,
89
+ value: result => timing(result).mean || null,
90
+ aggregate: false,
91
+ },
92
+ {
93
+ label: 'ops/s',
94
+ higherIsBetter: true,
95
+ format: sig,
96
+ value: opsPerSecond,
97
+ aggregate: false,
98
+ },
99
+ ];
100
+ for (const metric of resolveMetrics(matrix)) {
101
+ list.push({
102
+ label: metric.label,
103
+ higherIsBetter: metric.higherIsBetter,
104
+ format: sig,
105
+ value: result => caseValue(metric, result),
106
+ total: cases => aggregateValue(metric, { ...matrix, cases }),
107
+ aggregate: metric.aggregate,
108
+ });
109
+ }
110
+ return list;
111
+ }
package/dist/pool.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Map over `items` with at most `limit` running at once, keeping the results in input order.
3
+ * A `limit` of 1 is a plain serial loop.
4
+ */
5
+ export declare function mapPool<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
package/dist/pool.js ADDED
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ /**
3
+ * Map over `items` with at most `limit` running at once, keeping the results in input order.
4
+ * A `limit` of 1 is a plain serial loop.
5
+ */
6
+ export async function mapPool(items, limit, fn) {
7
+ const results = new Array(items.length);
8
+ let next = 0;
9
+ const worker = async () => {
10
+ for (let i = next++; i < items.length; i = next++)
11
+ results[i] = await fn(items[i], i);
12
+ };
13
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
14
+ return results;
15
+ }
package/dist/refs.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /** Names that mean "whatever is in the working tree right now", which is never built or checked out. */
2
+ export declare const workingRefs: string[];
3
+ /** A state to benchmark: either the working tree or a git worktree checked out at some reference. */
4
+ export interface Ref {
5
+ /** As given on the command line */
6
+ name: string;
7
+ /** Where the tests run */
8
+ dir: string;
9
+ /** `null` for the working tree */
10
+ sha: string | null;
11
+ }
12
+ export interface RefOptions {
13
+ /** Where worktrees are kept between runs */
14
+ cache: string;
15
+ /** Shell command that makes a worktree runnable */
16
+ build: string;
17
+ /** Directory holding the tests, copied into each worktree so every reference runs the same tests */
18
+ root: string;
19
+ /** Build even when the worktree is already at the right commit */
20
+ rebuild?: boolean;
21
+ onStep?(message: string): void;
22
+ }
23
+ /** The root of the repository containing `from`. */
24
+ export declare function repoRoot(from?: string): Promise<string>;
25
+ /**
26
+ * Check out every reference and make it runnable.
27
+ *
28
+ * Checkout is serial because `git worktree add` takes a repository-wide lock, but building is not,
29
+ * and building is where nearly all of the time goes. Nothing here is timed, so the concurrency
30
+ * costs the measurements nothing.
31
+ */
32
+ export declare function prepareRefs(names: string[], options: RefOptions): Promise<Ref[]>;
33
+ /** Check out one reference into a reusable worktree, build it, and stage the tests inside it. */
34
+ export declare function prepareRef(name: string, options: RefOptions): Promise<Ref>;
35
+ /** Remove every cached worktree. */
36
+ export declare function cleanRefs(cache: string): Promise<void>;
package/dist/refs.js ADDED
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { execFile as execFileCallback } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { basename, join, relative, resolve } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ const execFile = promisify(execFileCallback);
8
+ /** Names that mean "whatever is in the working tree right now", which is never built or checked out. */
9
+ export const workingRefs = ['.', 'working', 'wip'];
10
+ async function git(repo, ...args) {
11
+ const { stdout } = await execFile('git', ['-C', repo, ...args], { maxBuffer: 1 << 26 });
12
+ return stdout.trim();
13
+ }
14
+ /** The root of the repository containing `from`. */
15
+ export async function repoRoot(from = process.cwd()) {
16
+ return await git(from, 'rev-parse', '--show-toplevel');
17
+ }
18
+ function slug(ref) {
19
+ return ref.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'ref';
20
+ }
21
+ /**
22
+ * Copy the tests into a worktree.
23
+ *
24
+ * The tests have to come from the working tree rather than the checkout, since an old reference
25
+ * predates them; they have to be copied rather than symlinked, since Node resolves bare imports
26
+ * from a file's real path and a symlink would reach the working tree's `node_modules` instead of
27
+ * the worktree's.
28
+ */
29
+ async function copyTests(repo, dir, root) {
30
+ const target = join(dir, relative(repo, root));
31
+ if (resolve(target) == resolve(root))
32
+ return;
33
+ await cp(root, target, {
34
+ recursive: true,
35
+ filter: source => {
36
+ const name = basename(source);
37
+ return name != 'node_modules' && !name.startsWith('.');
38
+ },
39
+ });
40
+ }
41
+ /**
42
+ * Check out every reference and make it runnable.
43
+ *
44
+ * Checkout is serial because `git worktree add` takes a repository-wide lock, but building is not,
45
+ * and building is where nearly all of the time goes. Nothing here is timed, so the concurrency
46
+ * costs the measurements nothing.
47
+ */
48
+ export async function prepareRefs(names, options) {
49
+ const repo = await repoRoot();
50
+ const staged = [];
51
+ for (const name of names) {
52
+ if (workingRefs.includes(name)) {
53
+ staged.push({ name, dir: repo, sha: null, build: false });
54
+ continue;
55
+ }
56
+ const sha = await git(repo, 'rev-parse', name + '^{commit}');
57
+ const dir = join(options.cache, slug(name));
58
+ const stamp = join(dir, '.zbench-ref');
59
+ const current = existsSync(stamp) ? (await readFile(stamp, 'utf-8')).trim() : null;
60
+ if (current == sha && !options.rebuild) {
61
+ staged.push({ name, dir, sha, build: false });
62
+ continue;
63
+ }
64
+ if (existsSync(dir)) {
65
+ await git(repo, 'worktree', 'remove', '--force', dir).catch(() => { });
66
+ await rm(dir, { recursive: true, force: true });
67
+ }
68
+ await mkdir(options.cache, { recursive: true });
69
+ options.onStep?.(`checking out ${name} (${sha.slice(0, 8)})`);
70
+ await git(repo, 'worktree', 'add', '--detach', dir, sha);
71
+ staged.push({ name, dir, sha, build: true });
72
+ }
73
+ const building = staged.filter(ref => ref.build).map(ref => ref.name);
74
+ if (building.length)
75
+ options.onStep?.(`building ${building.join(', ')}`);
76
+ await Promise.all(staged.map(async ({ dir, sha, build }) => {
77
+ if (sha === null)
78
+ return;
79
+ await copyTests(repo, dir, options.root);
80
+ if (!build)
81
+ return;
82
+ await execFile('sh', ['-c', options.build], { cwd: dir, maxBuffer: 1 << 26 });
83
+ await writeFile(join(dir, '.zbench-ref'), sha + '\n');
84
+ }));
85
+ return staged.map(({ name, dir, sha }) => ({ name, dir, sha }));
86
+ }
87
+ /** Check out one reference into a reusable worktree, build it, and stage the tests inside it. */
88
+ export async function prepareRef(name, options) {
89
+ const [ref] = await prepareRefs([name], options);
90
+ return ref;
91
+ }
92
+ /** Remove every cached worktree. */
93
+ export async function cleanRefs(cache) {
94
+ const repo = await repoRoot();
95
+ if (!existsSync(cache))
96
+ return;
97
+ const listed = await git(repo, 'worktree', 'list', '--porcelain');
98
+ for (const line of listed.split('\n')) {
99
+ if (!line.startsWith('worktree '))
100
+ continue;
101
+ const dir = line.slice('worktree '.length);
102
+ if (resolve(dir).startsWith(resolve(cache)))
103
+ await git(repo, 'worktree', 'remove', '--force', dir);
104
+ }
105
+ await rm(cache, { recursive: true, force: true });
106
+ await git(repo, 'worktree', 'prune');
107
+ }
@@ -0,0 +1,14 @@
1
+ import type { Comparison, RefResults } from './compare.js';
2
+ import type { Suite } from './config.js';
3
+ import { type Matrix } from './measure.js';
4
+ import type { CaseResult } from './runner.js';
5
+ /** Print one matrix as a table, followed by its aggregate measurements. */
6
+ export declare function reportMatrix(matrix: Matrix): void;
7
+ /** Print every test's tables for a single state. */
8
+ export declare function reportRun(suite: Suite, cases: CaseResult[]): void;
9
+ /** Print the delta tables that follow each state's own tables. */
10
+ export declare function reportComparison(comparisons: Comparison[], refs: RefResults[]): void;
11
+ /** A one-line note about what the numbers were produced under. */
12
+ export declare function reportEnvironment(cpu: number, mem: number, iterations: number, warmup: number, jobs: number): void;
13
+ /** List the tests and configurations that would run, without running them. */
14
+ export declare function reportList(suite: Suite): void;