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/LICENSE.md +157 -0
- package/README.md +195 -0
- package/dist/capabilities.d.ts +15 -0
- package/dist/capabilities.js +47 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +278 -0
- package/dist/compare.d.ts +51 -0
- package/dist/compare.js +80 -0
- package/dist/config.d.ts +110 -0
- package/dist/config.js +175 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/isolate.d.ts +27 -0
- package/dist/isolate.js +74 -0
- package/dist/measure.d.ts +59 -0
- package/dist/measure.js +111 -0
- package/dist/pool.d.ts +5 -0
- package/dist/pool.js +15 -0
- package/dist/refs.d.ts +36 -0
- package/dist/refs.js +107 -0
- package/dist/report.d.ts +14 -0
- package/dist/report.js +156 -0
- package/dist/runner.d.ts +40 -0
- package/dist/runner.js +121 -0
- package/dist/stats.d.ts +19 -0
- package/dist/stats.js +30 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +2 -0
- package/dist/units.d.ts +44 -0
- package/dist/units.js +78 -0
- package/package.json +76 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
import * as io from 'ioium/node';
|
|
4
|
+
import { writeFile } from 'node:fs/promises';
|
|
5
|
+
import { join, relative, resolve } from 'node:path';
|
|
6
|
+
import { parseArgs, styleText } from 'node:util';
|
|
7
|
+
import { resolveCapabilities } from './capabilities.js';
|
|
8
|
+
import { compare } from './compare.js';
|
|
9
|
+
import { findConfig, flagCombinations, flagLabel, loadSuite } from './config.js';
|
|
10
|
+
import { runIsolated, runWorker } from './isolate.js';
|
|
11
|
+
import { cleanRefs, prepareRefs, repoRoot } from './refs.js';
|
|
12
|
+
import { reportComparison, reportEnvironment, reportList, reportRun } from './report.js';
|
|
13
|
+
import { mapPool } from './pool.js';
|
|
14
|
+
import { runTest } from './runner.js';
|
|
15
|
+
const usage = `Usage: zbench [options] [filter...]
|
|
16
|
+
|
|
17
|
+
Run the performance tests named by the config file. Filters match a test's name or path.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
-c, --config <path> Config file. Discovered by default.
|
|
21
|
+
-n, --iterations <n> Timed runs per configuration.
|
|
22
|
+
-w, --warmup <n> Untimed runs before the timed ones.
|
|
23
|
+
-R, --ref <ref> Benchmark a git reference. Repeatable; the first one is the baseline.
|
|
24
|
+
Use "." for the working tree as it is.
|
|
25
|
+
-f, --flag <name=json> Restrict a flag to one value. Repeatable.
|
|
26
|
+
-t, --threshold <pct> Smallest change worth coloring. [1]
|
|
27
|
+
-T, --timeout <s> Seconds a matrix may take before it is killed and reported N/A. [300]
|
|
28
|
+
0 waits forever. A reference with a pathological regression can
|
|
29
|
+
otherwise hold the whole comparison open.
|
|
30
|
+
-a, --all Run every configuration, ignoring cpu/mem requirements.
|
|
31
|
+
-J, --jobs <n> Matrices to time at once. [one per hardware thread, 1 with --no-isolate]
|
|
32
|
+
Concurrent matrices contend for the machine, which widens the ± column;
|
|
33
|
+
pass -J 1 for the quietest numbers.
|
|
34
|
+
-l, --list List what would run, then exit.
|
|
35
|
+
-j, --json <path> Write the raw results as JSON.
|
|
36
|
+
--cpu <n> Override the detected CPU level.
|
|
37
|
+
--mem <n> Override the detected memory level.
|
|
38
|
+
--no-isolate Run in this process instead of one child per matrix.
|
|
39
|
+
--build <cmd> Command that makes a reference's worktree runnable.
|
|
40
|
+
--rebuild Rebuild reference worktrees even when they are up to date.
|
|
41
|
+
--clean Remove cached reference worktrees, then exit.
|
|
42
|
+
-q, --quiet Only print results.
|
|
43
|
+
-h, --help Show this message.
|
|
44
|
+
`;
|
|
45
|
+
const { values: opts, positionals: filters } = parseArgs({
|
|
46
|
+
options: {
|
|
47
|
+
config: { short: 'c', type: 'string' },
|
|
48
|
+
iterations: { short: 'n', type: 'string' },
|
|
49
|
+
warmup: { short: 'w', type: 'string' },
|
|
50
|
+
ref: { short: 'R', type: 'string', multiple: true, default: [] },
|
|
51
|
+
flag: { short: 'f', type: 'string', multiple: true, default: [] },
|
|
52
|
+
threshold: { short: 't', type: 'string' },
|
|
53
|
+
timeout: { short: 'T', type: 'string' },
|
|
54
|
+
all: { short: 'a', type: 'boolean' },
|
|
55
|
+
jobs: { short: 'J', type: 'string' },
|
|
56
|
+
list: { short: 'l', type: 'boolean' },
|
|
57
|
+
json: { short: 'j', type: 'string' },
|
|
58
|
+
cpu: { type: 'string' },
|
|
59
|
+
mem: { type: 'string' },
|
|
60
|
+
isolate: { type: 'boolean', default: true },
|
|
61
|
+
build: { type: 'string' },
|
|
62
|
+
rebuild: { type: 'boolean' },
|
|
63
|
+
clean: { type: 'boolean' },
|
|
64
|
+
quiet: { short: 'q', type: 'boolean' },
|
|
65
|
+
help: { short: 'h', type: 'boolean' },
|
|
66
|
+
worker: { type: 'string' },
|
|
67
|
+
},
|
|
68
|
+
allowPositionals: true,
|
|
69
|
+
});
|
|
70
|
+
if (opts.help) {
|
|
71
|
+
console.log(usage);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
if (opts.worker) {
|
|
75
|
+
await runWorker(opts.worker);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
function fail(message) {
|
|
79
|
+
io.error(message);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
function number(value, name) {
|
|
83
|
+
if (value === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
const parsed = Number(value);
|
|
86
|
+
if (!Number.isFinite(parsed))
|
|
87
|
+
fail(`--${name} expects a number, got ${JSON.stringify(value)}`);
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
/** Parse `-f name=json`, falling back to the raw string when it isn't valid JSON. */
|
|
91
|
+
function parseFlagOverrides(entries) {
|
|
92
|
+
const overrides = {};
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const at = entry.indexOf('=');
|
|
95
|
+
if (at < 0)
|
|
96
|
+
fail(`--flag expects name=value, got ${JSON.stringify(entry)}`);
|
|
97
|
+
const name = entry.slice(0, at);
|
|
98
|
+
const raw = entry.slice(at + 1);
|
|
99
|
+
let value;
|
|
100
|
+
try {
|
|
101
|
+
value = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
value = raw;
|
|
105
|
+
}
|
|
106
|
+
(overrides[name] ??= []).push(value);
|
|
107
|
+
}
|
|
108
|
+
return overrides;
|
|
109
|
+
}
|
|
110
|
+
const configPath = opts.config ? resolve(opts.config) : findConfig();
|
|
111
|
+
if (!configPath)
|
|
112
|
+
fail('no config file found; pass --config or add one at tests/perf/config.json');
|
|
113
|
+
let suite;
|
|
114
|
+
try {
|
|
115
|
+
suite = loadSuite(configPath);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
fail(e.message);
|
|
119
|
+
}
|
|
120
|
+
if (filters.length) {
|
|
121
|
+
const matches = (test) => filters.some(f => test.name.toLowerCase().includes(f.toLowerCase()) || test.path.includes(f));
|
|
122
|
+
suite.tests = suite.tests.filter(matches);
|
|
123
|
+
if (!suite.tests.length)
|
|
124
|
+
fail(`no test matches ${filters.map(f => JSON.stringify(f)).join(', ')}`);
|
|
125
|
+
}
|
|
126
|
+
if (opts.list) {
|
|
127
|
+
reportList(suite);
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
const cache = join(await repoRoot().catch(() => process.cwd()), '.zbench');
|
|
131
|
+
if (opts.clean) {
|
|
132
|
+
await cleanRefs(cache);
|
|
133
|
+
io.info('removed cached worktrees');
|
|
134
|
+
process.exit(0);
|
|
135
|
+
}
|
|
136
|
+
const flagOverrides = parseFlagOverrides(opts.flag);
|
|
137
|
+
const { cpu, mem } = resolveCapabilities({ cpu: number(opts.cpu, 'cpu'), mem: number(opts.mem, 'mem') });
|
|
138
|
+
const threshold = number(opts.threshold, 'threshold') ?? 1;
|
|
139
|
+
const timeout = number(opts.timeout, 'timeout') ?? 300;
|
|
140
|
+
const iterations = number(opts.iterations, 'iterations');
|
|
141
|
+
const warmup = number(opts.warmup, 'warmup');
|
|
142
|
+
const cli = resolve(import.meta.dirname, 'cli.js');
|
|
143
|
+
const refNames = opts.ref.length ? opts.ref : ['.'];
|
|
144
|
+
// Concurrent work in one process would interleave the tests' own awaits into each other's timings
|
|
145
|
+
const jobs = Math.max(1, number(opts.jobs, 'jobs') ?? (opts.isolate ? Math.round((navigator.hardwareConcurrency || 0) / 2) || 1 : 1));
|
|
146
|
+
if (jobs > 1 && !opts.isolate)
|
|
147
|
+
fail('--jobs above 1 needs process isolation, so it cannot be used with --no-isolate');
|
|
148
|
+
/** Overwrite the progress line, if there is one to overwrite. */
|
|
149
|
+
const progress = {
|
|
150
|
+
active: false,
|
|
151
|
+
show(message) {
|
|
152
|
+
if (opts.quiet || !process.stdout.isTTY)
|
|
153
|
+
return;
|
|
154
|
+
this.clear();
|
|
155
|
+
process.stdout.write(styleText('gray', message));
|
|
156
|
+
this.active = true;
|
|
157
|
+
},
|
|
158
|
+
clear() {
|
|
159
|
+
if (!this.active)
|
|
160
|
+
return;
|
|
161
|
+
process.stdout.clearLine(0);
|
|
162
|
+
process.stdout.cursorTo(0);
|
|
163
|
+
this.active = false;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
/** Every (test, flag combination) pair that will run, i.e. one table each. */
|
|
167
|
+
function* planned(suite) {
|
|
168
|
+
for (const test of suite.tests) {
|
|
169
|
+
const narrowed = Object.fromEntries(Object.entries(test.flags).map(([name, values]) => [
|
|
170
|
+
name,
|
|
171
|
+
flagOverrides[name] ? values.filter(v => flagOverrides[name].some(w => Object.is(w, v))) : values,
|
|
172
|
+
]));
|
|
173
|
+
for (const flags of flagCombinations(narrowed))
|
|
174
|
+
yield [test, flags];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function runRef(ref, repo) {
|
|
178
|
+
const config = ref.sha ? join(ref.dir, relative(repo, suite.file)) : suite.file;
|
|
179
|
+
const matrices = [...planned(suite)];
|
|
180
|
+
let done = 0;
|
|
181
|
+
const results = await mapPool(matrices, jobs, async ([test, flags]) => {
|
|
182
|
+
const label = [test.name, flagLabel(flags)].filter(Boolean).join(' ');
|
|
183
|
+
progress.show(`[${done + 1}/${matrices.length}] ${label}${ref.sha ? ` @ ${ref.name}` : ''}...`);
|
|
184
|
+
try {
|
|
185
|
+
return opts.isolate
|
|
186
|
+
? await runIsolated({ config, test: test.id, flags, iterations, warmup, cpu, mem, all: opts.all }, ref.dir, cli, timeout)
|
|
187
|
+
: await runTest(test, {
|
|
188
|
+
iterations,
|
|
189
|
+
warmup,
|
|
190
|
+
cpu,
|
|
191
|
+
mem,
|
|
192
|
+
all: opts.all,
|
|
193
|
+
flags: Object.fromEntries(Object.entries(flags).map(([k, v]) => [k, [v]])),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
// A matrix that cannot run is one reference's problem, not the whole comparison's:
|
|
198
|
+
// report it as N/A and let the references that did run still be compared
|
|
199
|
+
return test.configurations.map((configuration) => ({
|
|
200
|
+
test: test.id,
|
|
201
|
+
flags,
|
|
202
|
+
configuration: configuration.name,
|
|
203
|
+
value: configuration.value,
|
|
204
|
+
setup: 0,
|
|
205
|
+
samples: [],
|
|
206
|
+
amounts: {},
|
|
207
|
+
error: String(e?.message ?? e),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
done++;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
progress.clear();
|
|
215
|
+
return results.flat();
|
|
216
|
+
}
|
|
217
|
+
if (!opts.quiet)
|
|
218
|
+
reportEnvironment(cpu, mem, iterations ?? 5, warmup ?? 1, jobs);
|
|
219
|
+
const repo = await repoRoot().catch(() => process.cwd());
|
|
220
|
+
let prepared;
|
|
221
|
+
try {
|
|
222
|
+
prepared = await prepareRefs(refNames, {
|
|
223
|
+
cache,
|
|
224
|
+
build: opts.build ?? suite.build,
|
|
225
|
+
root: suite.root,
|
|
226
|
+
rebuild: opts.rebuild,
|
|
227
|
+
onStep: message => progress.show(message + '...'),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
progress.clear();
|
|
232
|
+
fail(`could not prepare references: ${e.stderr || e.message}`);
|
|
233
|
+
}
|
|
234
|
+
progress.clear();
|
|
235
|
+
const refs = [];
|
|
236
|
+
for (const ref of prepared) {
|
|
237
|
+
if (prepared.length > 1 && !opts.quiet) {
|
|
238
|
+
console.log();
|
|
239
|
+
console.log(styleText(['bold', 'underline'], ref.name) + (ref.sha ? styleText('gray', ' ' + ref.sha.slice(0, 8)) : ''));
|
|
240
|
+
}
|
|
241
|
+
const cases = await runRef(ref, repo);
|
|
242
|
+
reportRun(suite, cases);
|
|
243
|
+
refs.push({ ref: ref.name, cases });
|
|
244
|
+
}
|
|
245
|
+
if (refs.length > 1) {
|
|
246
|
+
console.log();
|
|
247
|
+
console.log(styleText(['bold', 'underline'], `Change vs ${refs[0].ref}`));
|
|
248
|
+
reportComparison(compare(suite, refs, threshold), refs);
|
|
249
|
+
}
|
|
250
|
+
if (opts.json) {
|
|
251
|
+
await writeFile(opts.json, JSON.stringify({
|
|
252
|
+
node: process.version,
|
|
253
|
+
platform: `${process.platform}/${process.arch}`,
|
|
254
|
+
capabilities: { cpu, mem },
|
|
255
|
+
refs,
|
|
256
|
+
}, null, '\t'));
|
|
257
|
+
}
|
|
258
|
+
const failed = refs.flatMap(r => r.cases).filter(c => c.error);
|
|
259
|
+
if (failed.length) {
|
|
260
|
+
// One failing code path usually fails in every configuration, so report each distinct message once
|
|
261
|
+
const byMessage = new Map();
|
|
262
|
+
for (const c of failed) {
|
|
263
|
+
const message = c.error.split('\n')[0].trim();
|
|
264
|
+
const where = [c.test, flagLabel(c.flags), c.configuration].filter(Boolean).join(' · ');
|
|
265
|
+
if (!byMessage.has(message))
|
|
266
|
+
byMessage.set(message, []);
|
|
267
|
+
byMessage.get(message).push(where);
|
|
268
|
+
}
|
|
269
|
+
console.log();
|
|
270
|
+
for (const [message, where] of byMessage) {
|
|
271
|
+
io.error(`${message} (${where.length} configuration${where.length == 1 ? '' : 's'})`);
|
|
272
|
+
for (const at of where.slice(0, 4))
|
|
273
|
+
console.error(styleText('gray', ' ' + at));
|
|
274
|
+
if (where.length > 4)
|
|
275
|
+
console.error(styleText('gray', ` ...and ${where.length - 4} more`));
|
|
276
|
+
}
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Column } from './measure.js';
|
|
2
|
+
import type { CaseResult } from './runner.js';
|
|
3
|
+
import type { Suite, Test } from './config.js';
|
|
4
|
+
/** Results for one of the states being compared. */
|
|
5
|
+
export interface RefResults {
|
|
6
|
+
/** How the state was named on the command line, e.g. `main` or `v3.2.1` */
|
|
7
|
+
ref: string;
|
|
8
|
+
cases: CaseResult[];
|
|
9
|
+
}
|
|
10
|
+
/** One value alongside how noisy the run that produced it was. */
|
|
11
|
+
export interface Cell {
|
|
12
|
+
value: number | null;
|
|
13
|
+
/** Relative standard deviation of the timings, as a percentage */
|
|
14
|
+
noise: number;
|
|
15
|
+
missing: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** How one state's value relates to the baseline's. */
|
|
18
|
+
export interface Delta {
|
|
19
|
+
/** Speedup: always `> 1` when better, whichever direction the column improves in */
|
|
20
|
+
factor: number | null;
|
|
21
|
+
/** Percentage change of the raw value, signed */
|
|
22
|
+
change: number | null;
|
|
23
|
+
/** Whether the change stands out from run-to-run variance */
|
|
24
|
+
significant: boolean;
|
|
25
|
+
better: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface ComparisonRow {
|
|
28
|
+
configuration: string;
|
|
29
|
+
/** Indexed the same as the states being compared, baseline first */
|
|
30
|
+
cells: Cell[];
|
|
31
|
+
deltas: Delta[];
|
|
32
|
+
}
|
|
33
|
+
export interface Comparison {
|
|
34
|
+
test: Test;
|
|
35
|
+
/** Flag combination, empty when the test has no flags */
|
|
36
|
+
label: string;
|
|
37
|
+
column: Column;
|
|
38
|
+
rows: ComparisonRow[];
|
|
39
|
+
/** Present for aggregate metrics, which have one value per state rather than one per row */
|
|
40
|
+
aggregate?: ComparisonRow;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Compare a cell against the baseline.
|
|
44
|
+
* A change is only called out when it is bigger than both `threshold` and the two runs' combined noise.
|
|
45
|
+
*/
|
|
46
|
+
export declare function delta(baseline: Cell, other: Cell, higherIsBetter: boolean, threshold: number): Delta;
|
|
47
|
+
/**
|
|
48
|
+
* Line up every state's results against the baseline, one comparison per matrix column.
|
|
49
|
+
* `ops/s` is left out because it is just the reciprocal of `avg`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function compare(suite: Suite, refs: RefResults[], threshold: number): Comparison[];
|
package/dist/compare.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
import { columns, matrices, timing } from './measure.js';
|
|
3
|
+
import { combinedNoise } from './stats.js';
|
|
4
|
+
function key(result) {
|
|
5
|
+
return `${result.test}\0${JSON.stringify(result.flags)}\0${result.configuration}`;
|
|
6
|
+
}
|
|
7
|
+
function cell(column, result) {
|
|
8
|
+
if (!result || result.skipped || result.error)
|
|
9
|
+
return { value: null, noise: 0, missing: true };
|
|
10
|
+
return { value: column.value(result), noise: timing(result).rsd, missing: false };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Compare a cell against the baseline.
|
|
14
|
+
* A change is only called out when it is bigger than both `threshold` and the two runs' combined noise.
|
|
15
|
+
*/
|
|
16
|
+
export function delta(baseline, other, higherIsBetter, threshold) {
|
|
17
|
+
if (baseline.value === null || other.value === null || !baseline.value)
|
|
18
|
+
return { factor: null, change: null, significant: false, better: false };
|
|
19
|
+
const factor = higherIsBetter ? other.value / baseline.value : baseline.value / other.value;
|
|
20
|
+
const change = ((other.value - baseline.value) / baseline.value) * 100;
|
|
21
|
+
const noise = combinedNoise(baseline.noise, other.noise);
|
|
22
|
+
return {
|
|
23
|
+
factor,
|
|
24
|
+
change,
|
|
25
|
+
significant: Math.abs(change) > Math.max(threshold, noise),
|
|
26
|
+
better: factor > 1,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Line up every state's results against the baseline, one comparison per matrix column.
|
|
31
|
+
* `ops/s` is left out because it is just the reciprocal of `avg`.
|
|
32
|
+
*/
|
|
33
|
+
export function compare(suite, refs, threshold) {
|
|
34
|
+
const [baseline, ...others] = refs;
|
|
35
|
+
if (!baseline || !others.length)
|
|
36
|
+
return [];
|
|
37
|
+
const indexes = refs.map(ref => new Map(ref.cases.map(result => [key(result), result])));
|
|
38
|
+
const comparisons = [];
|
|
39
|
+
for (const test of suite.tests) {
|
|
40
|
+
for (const matrix of matrices(test, baseline.cases)) {
|
|
41
|
+
for (const column of columns(matrix).filter(c => c.label != 'ops/s')) {
|
|
42
|
+
const rows = matrix.cases.map(result => {
|
|
43
|
+
const cells = indexes.map(index => cell(column, index.get(key(result))));
|
|
44
|
+
return {
|
|
45
|
+
configuration: result.configuration,
|
|
46
|
+
cells,
|
|
47
|
+
deltas: cells.map(c => delta(cells[0], c, column.higherIsBetter, threshold)),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
comparisons.push({
|
|
51
|
+
test,
|
|
52
|
+
label: matrix.label,
|
|
53
|
+
column,
|
|
54
|
+
rows: column.aggregate ? [] : rows,
|
|
55
|
+
aggregate: column.aggregate ? aggregateRow(matrix, column, refs, threshold) : undefined,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return comparisons;
|
|
61
|
+
}
|
|
62
|
+
/** The matrix-wide value for each state, compared the same way a row is. */
|
|
63
|
+
function aggregateRow(matrix, column, refs, threshold) {
|
|
64
|
+
const flags = JSON.stringify(matrix.flags);
|
|
65
|
+
const cells = refs.map((ref) => {
|
|
66
|
+
const cases = ref.cases.filter(r => r.test == matrix.test.id && JSON.stringify(r.flags) == flags);
|
|
67
|
+
const total = column.total?.(cases) ?? null;
|
|
68
|
+
const noise = cases.filter(c => c.samples.length).map(c => timing(c).rsd);
|
|
69
|
+
return {
|
|
70
|
+
value: total,
|
|
71
|
+
noise: noise.length ? noise.reduce((sum, v) => sum + v, 0) / noise.length : 0,
|
|
72
|
+
missing: total === null,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
configuration: 'aggregate',
|
|
77
|
+
cells,
|
|
78
|
+
deltas: cells.map(c => delta(cells[0], c, column.higherIsBetter, threshold)),
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type Timespan } from './units.js';
|
|
2
|
+
/** A measurement is either derived at an inferred timespan (`true`) or at an explicit one. */
|
|
3
|
+
export type MeasureValue = boolean | Timespan;
|
|
4
|
+
/**
|
|
5
|
+
* How to turn one of a test's quantities into a reported measurement.
|
|
6
|
+
* The quantity's amount per iteration comes from what `test` returns, falling back to the configuration value.
|
|
7
|
+
*/
|
|
8
|
+
export interface MeasureSpec {
|
|
9
|
+
/** `<unit>`s per `<timespan>` for each configuration, e.g. `MB/s` */
|
|
10
|
+
throughput?: MeasureValue;
|
|
11
|
+
/** `<unit>`s per `<timespan>` across every configuration in the matrix */
|
|
12
|
+
aggregate_throughput?: MeasureValue;
|
|
13
|
+
/** `<timespan>` per `<unit>` for each configuration, e.g. `ms/entry` */
|
|
14
|
+
cost?: MeasureValue;
|
|
15
|
+
/** `<timespan>` per `<unit>` across every configuration in the matrix */
|
|
16
|
+
aggregate_cost?: MeasureValue;
|
|
17
|
+
/** What the amount is counted in. Defaults to the quantity's key. */
|
|
18
|
+
unit?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Multiplier from the raw amount to `unit`.
|
|
21
|
+
* Defaults to 1, except for byte units (`MB`, `MiB`, ...), where amounts are taken to be bytes.
|
|
22
|
+
*/
|
|
23
|
+
scale?: number;
|
|
24
|
+
}
|
|
25
|
+
/** One point in a test's matrix. */
|
|
26
|
+
export interface ConfigurationSpec {
|
|
27
|
+
/** Defaults to the configuration's values, e.g. `size=128, entries=1000` */
|
|
28
|
+
name?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Minimum CPU level needed to run this configuration.
|
|
31
|
+
* Levels are logarithmic: `n + 1` costs about twice as much as `n`.
|
|
32
|
+
*/
|
|
33
|
+
cpu?: number;
|
|
34
|
+
/** Minimum memory level needed to run this configuration. Also logarithmic. */
|
|
35
|
+
mem?: number;
|
|
36
|
+
/** Passed to the test, and the source of measurement amounts. */
|
|
37
|
+
value: Record<string, number>;
|
|
38
|
+
}
|
|
39
|
+
export interface TestSpec {
|
|
40
|
+
/** Resolved relative to the config file */
|
|
41
|
+
path: string;
|
|
42
|
+
/** Defaults to `path` */
|
|
43
|
+
name?: string;
|
|
44
|
+
measure?: Record<string, MeasureSpec>;
|
|
45
|
+
configurations: ConfigurationSpec[];
|
|
46
|
+
/** Flags from the suite's pool to apply, or flags declared inline. The matrix runs once per combination. */
|
|
47
|
+
flags?: string[] | Record<string, unknown[]>;
|
|
48
|
+
iterations?: number;
|
|
49
|
+
warmup?: number;
|
|
50
|
+
}
|
|
51
|
+
export interface SuiteSpec {
|
|
52
|
+
/** Timed runs per configuration. @default 5 */
|
|
53
|
+
iterations?: number;
|
|
54
|
+
/** Untimed runs before the timed ones. @default 1 */
|
|
55
|
+
warmup?: number;
|
|
56
|
+
/** Flag pool that tests can draw from by name. */
|
|
57
|
+
flags?: Record<string, unknown[]>;
|
|
58
|
+
/** Shell command that makes a git reference's worktree runnable. */
|
|
59
|
+
build?: string;
|
|
60
|
+
/** Directory copied into a reference's worktree so every reference runs today's tests. Defaults to the config file's directory. */
|
|
61
|
+
root?: string;
|
|
62
|
+
tests: TestSpec[];
|
|
63
|
+
}
|
|
64
|
+
/** Config files are looked for at these paths, in order, relative to the working directory. */
|
|
65
|
+
export declare const configPaths: string[];
|
|
66
|
+
export declare const defaultBuild = "npm install --no-audit --no-fund && npm run build";
|
|
67
|
+
/** A single reported column, derived from one `MeasureSpec` entry. */
|
|
68
|
+
export interface Metric {
|
|
69
|
+
/** The quantity this is derived from */
|
|
70
|
+
key: string;
|
|
71
|
+
kind: 'throughput' | 'cost';
|
|
72
|
+
/** Whether this collapses the whole matrix into one number */
|
|
73
|
+
aggregate: boolean;
|
|
74
|
+
/** `null` means infer per matrix */
|
|
75
|
+
span: Timespan | null;
|
|
76
|
+
scale: number;
|
|
77
|
+
unit: string;
|
|
78
|
+
higherIsBetter: boolean;
|
|
79
|
+
}
|
|
80
|
+
/** A test with its defaults filled in and its measurements flattened into metrics. */
|
|
81
|
+
export interface Test {
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
/** Absolute path to the module */
|
|
85
|
+
file: string;
|
|
86
|
+
/** Path as written, for display */
|
|
87
|
+
path: string;
|
|
88
|
+
metrics: Metric[];
|
|
89
|
+
configurations: Required<Pick<ConfigurationSpec, 'name' | 'cpu' | 'mem' | 'value'>>[];
|
|
90
|
+
flags: Record<string, unknown[]>;
|
|
91
|
+
iterations: number;
|
|
92
|
+
warmup: number;
|
|
93
|
+
}
|
|
94
|
+
export interface Suite {
|
|
95
|
+
/** Absolute path to the config file */
|
|
96
|
+
file: string;
|
|
97
|
+
/** Absolute path to the directory copied into reference worktrees */
|
|
98
|
+
root: string;
|
|
99
|
+
build: string;
|
|
100
|
+
tests: Test[];
|
|
101
|
+
}
|
|
102
|
+
/** Find the config file, starting from `from` and walking up to the filesystem root. */
|
|
103
|
+
export declare function findConfig(from?: string): string | null;
|
|
104
|
+
/** Parse and validate an already-loaded suite. `file` is used to resolve test paths. */
|
|
105
|
+
export declare function parseSuite(data: unknown, file: string): Suite;
|
|
106
|
+
export declare function loadSuite(file: string): Suite;
|
|
107
|
+
/** Every combination of the given flags, in declaration order. Always at least one (possibly empty) combination. */
|
|
108
|
+
export declare function flagCombinations(flags: Record<string, unknown[]>): Record<string, unknown>[];
|
|
109
|
+
/** A stable, human-readable label for a flag combination. Empty when there are no flags. */
|
|
110
|
+
export declare function flagLabel(flags: Record<string, unknown>): string;
|