zbench-js 0.1.1 → 0.2.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/isolate.js CHANGED
@@ -1,74 +1,43 @@
1
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
2
  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
- }
3
+ import { Worker } from 'node:worker_threads';
4
+ /** Resolved next to this module, so it works from `dist` and from the sources alike. */
5
+ const entry = join(import.meta.dirname, import.meta.filename.endsWith('.ts') ? './worker.ts' : './worker.js');
25
6
  /**
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.
7
+ * Run one matrix in a fresh worker thread.
29
8
  */
30
- export async function runIsolated(spec, cwd, cli,
9
+ export async function runIsolated(spec,
31
10
  /** Seconds before the worker is killed. 0 waits forever. */
32
11
  timeout = 0) {
33
- const dir = await mkdtemp(join(tmpdir(), 'zbench-'));
34
- const specPath = join(dir, 'spec.json');
35
- const out = join(dir, 'results.json');
12
+ const worker = new Worker(entry, { workerData: spec, stdout: true, stderr: true });
13
+ let output = '';
14
+ let timedOut = false;
15
+ const collect = (chunk) => (output += chunk.toString());
16
+ worker.stdout.on('data', collect);
17
+ worker.stderr.on('data', collect);
18
+ const timer = timeout
19
+ ? setTimeout(() => {
20
+ timedOut = true;
21
+ void worker.terminate();
22
+ }, timeout * 1000)
23
+ : undefined;
36
24
  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
- });
25
+ const { promise, resolve, reject } = Promise.withResolvers();
26
+ worker.on('message', (results) => {
27
+ resolve(results);
28
+ // The results are in hand, so nothing the test left running is worth waiting on
29
+ void worker.terminate();
30
+ });
31
+ worker.on('error', reject);
32
+ worker.on('exit', code => {
33
+ if (timedOut)
34
+ reject(new Error(`timed out after ${timeout}s`));
35
+ else
36
+ reject(new Error(`worker exited with code ${code}\n${output.trim()}`));
64
37
  });
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'));
38
+ return promise;
70
39
  }
71
40
  finally {
72
- await rm(dir, { recursive: true, force: true });
41
+ clearTimeout(timer);
73
42
  }
74
43
  }
package/dist/measure.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Metric, type Test } from './config.js';
2
- import type { CaseResult } from './runner.js';
2
+ import type { CaseResult } from './types.js';
3
3
  import { type Stats } from './stats.js';
4
4
  import { type Timespan } from './units.js';
5
5
  /** One test's results for one flag combination: the unit a table is printed for. */
@@ -51,6 +51,8 @@ export interface Column {
51
51
  total?(cases: CaseResult[]): number | null;
52
52
  /** Aggregate columns are reported once per matrix instead of once per row */
53
53
  aggregate: boolean;
54
+ /** Whether the metric behind this column asked to be the one comparisons report. Never set on the built-ins. */
55
+ compare: boolean;
54
56
  }
55
57
  /**
56
58
  * The columns a matrix reports: the built-in timings, then whatever the test declared.
package/dist/measure.js CHANGED
@@ -88,6 +88,7 @@ export function columns(matrix) {
88
88
  format: duration,
89
89
  value: result => timing(result).mean || null,
90
90
  aggregate: false,
91
+ compare: false,
91
92
  },
92
93
  {
93
94
  label: 'ops/s',
@@ -95,6 +96,7 @@ export function columns(matrix) {
95
96
  format: sig,
96
97
  value: opsPerSecond,
97
98
  aggregate: false,
99
+ compare: false,
98
100
  },
99
101
  ];
100
102
  for (const metric of resolveMetrics(matrix)) {
@@ -105,6 +107,7 @@ export function columns(matrix) {
105
107
  value: result => caseValue(metric, result),
106
108
  total: cases => aggregateValue(metric, { ...matrix, cases }),
107
109
  aggregate: metric.aggregate,
110
+ compare: metric.compare,
108
111
  });
109
112
  }
110
113
  return list;
package/dist/refs.d.ts CHANGED
@@ -18,18 +18,11 @@ export interface RefOptions {
18
18
  root: string;
19
19
  /** Build even when the worktree is already at the right commit */
20
20
  rebuild?: boolean;
21
+ /** Called as preparation moves from one step to the next, so a caller can report where it is. */
21
22
  onStep?(message: string): void;
22
23
  }
23
24
  /** The root of the repository containing `from`. */
24
25
  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
26
  /** Check out one reference into a reusable worktree, build it, and stage the tests inside it. */
34
27
  export declare function prepareRef(name: string, options: RefOptions): Promise<Ref>;
35
28
  /** Remove every cached worktree. */
package/dist/refs.js CHANGED
@@ -39,55 +39,48 @@ async function copyTests(repo, dir, root) {
39
39
  });
40
40
  }
41
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.
42
+ * `git worktree add` takes a repository-wide lock, so two references being prepared at once would
43
+ * race over it. Checkouts queue up behind this; building, which is where nearly all of the time
44
+ * goes, does not. Nothing here is timed, so the overlap costs the measurements nothing.
47
45
  */
48
- export async function prepareRefs(names, options) {
46
+ let checkouts = Promise.resolve();
47
+ function serially(run) {
48
+ // Settled either way, so one reference failing to check out does not wedge the queue
49
+ const result = checkouts.then(run, run);
50
+ checkouts = result.catch(() => { });
51
+ return result;
52
+ }
53
+ /** Check out one reference into a reusable worktree, build it, and stage the tests inside it. */
54
+ export async function prepareRef(name, options) {
49
55
  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 });
56
+ // The working tree runs where it is, so there is nothing to check out, copy, or build
57
+ if (workingRefs.includes(name))
58
+ return { name, dir: repo, sha: null };
59
+ const sha = await git(repo, 'rev-parse', name + '^{commit}');
60
+ const dir = join(options.cache, slug(name));
61
+ const stamp = join(dir, '.zbench-ref');
62
+ const current = existsSync(stamp) ? (await readFile(stamp, 'utf-8')).trim() : null;
63
+ const build = current != sha || !!options.rebuild;
64
+ if (build) {
65
+ await serially(async () => {
66
+ options.onStep?.(`checking out ${sha.slice(0, 8)}`);
67
+ if (existsSync(dir)) {
68
+ await git(repo, 'worktree', 'remove', '--force', dir).catch(() => { });
69
+ await rm(dir, { recursive: true, force: true });
70
+ }
71
+ await mkdir(options.cache, { recursive: true });
72
+ await git(repo, 'worktree', 'add', '--detach', dir, sha);
73
+ });
72
74
  }
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;
75
+ // Even a cached worktree gets today's tests, since the ones it was built with are a release behind
76
+ options.onStep?.('staging tests');
77
+ await copyTests(repo, dir, options.root);
78
+ if (build) {
79
+ options.onStep?.('building');
82
80
  await execFile('sh', ['-c', options.build], { cwd: dir, maxBuffer: 1 << 26 });
83
81
  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;
82
+ }
83
+ return { name, dir, sha };
91
84
  }
92
85
  /** Remove every cached worktree. */
93
86
  export async function cleanRefs(cache) {
package/dist/report.d.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import type { Comparison, RefResults } from './compare.js';
2
2
  import type { Suite } from './config.js';
3
3
  import { type Matrix } from './measure.js';
4
- import type { CaseResult } from './runner.js';
4
+ import type { CaseResult } from './types.js';
5
5
  /** Print one matrix as a table, followed by its aggregate measurements. */
6
6
  export declare function reportMatrix(matrix: Matrix): void;
7
7
  /** Print every test's tables for a single state. */
8
8
  export declare function reportRun(suite: Suite, cases: CaseResult[]): void;
9
+ export interface ComparisonOptions {
10
+ /** Also report matrices where nothing beat the threshold */
11
+ unchanged?: boolean;
12
+ /** Also report matrices where fewer than two states produced numbers */
13
+ partial?: boolean;
14
+ }
9
15
  /** Print the delta tables that follow each state's own tables. */
10
- export declare function reportComparison(comparisons: Comparison[], refs: RefResults[]): void;
16
+ export declare function reportComparison(comparisons: Comparison[], refs: RefResults[], options?: ComparisonOptions): void;
11
17
  /** A one-line note about what the numbers were produced under. */
12
18
  export declare function reportEnvironment(cpu: number, mem: number, iterations: number, warmup: number, jobs: number): void;
13
19
  /** List the tests and configurations that would run, without running them. */
package/dist/report.js CHANGED
@@ -98,42 +98,75 @@ function factorText(row, index) {
98
98
  return dim(text);
99
99
  return styleText(better ? 'green' : 'red', text);
100
100
  }
101
+ /**
102
+ * One table holding every column of a matrix, grouped by state.
103
+ * Each state gets a value per column and a single factor, since the columns of a matrix are
104
+ * proportional and would otherwise repeat the same speedup.
105
+ */
106
+ function comparisonTable(names, cols, rows) {
107
+ if (!cols.length || !rows.length)
108
+ return;
109
+ io.table([
110
+ { name: indent + 'configuration', text: row => indent + row.configuration, grow: 0 },
111
+ ...names.flatMap((name, r) => [
112
+ { name, text: () => '', grow: 0 },
113
+ ...cols.map((column, c) => ({
114
+ name: column.label,
115
+ text: (row) => {
116
+ const { value } = row.cells[r][c];
117
+ return value === null ? dim('N/A') : column.format(value);
118
+ },
119
+ padStart: true,
120
+ grow: 0,
121
+ })),
122
+ ...(r == 0
123
+ ? []
124
+ : [{ name: '', text: (row) => factorText(row, r), padStart: true, grow: 0 }]),
125
+ ]),
126
+ ], { formatHead: dim }, rows);
127
+ }
101
128
  /** Print the delta tables that follow each state's own tables. */
102
- export function reportComparison(comparisons, refs) {
129
+ export function reportComparison(comparisons, refs, options = {}) {
103
130
  const names = refs.map(ref => ref.ref);
104
131
  let heading = null;
132
+ let unchanged = 0;
133
+ let partial = 0;
134
+ let shown = 0;
105
135
  for (const comparison of comparisons) {
106
- const rows = comparison.aggregate ? [comparison.aggregate] : comparison.rows;
107
- if (!rows.length)
136
+ if (comparison.reported < 2) {
137
+ if (!options.partial) {
138
+ partial++;
139
+ continue;
140
+ }
141
+ }
142
+ else if (!comparison.changed && !options.unchanged) {
143
+ unchanged++;
108
144
  continue;
109
- const label = comparison.test.name + (comparison.label ? dim(' ' + comparison.label) : '');
110
- if (label != heading) {
111
- console.log();
112
- console.log(bold((heading = label)));
113
145
  }
114
- console.log(' ' + dim(comparison.column.label) + (comparison.aggregate ? dim(' (aggregate)') : ''));
115
- io.table([
116
- { name: indent + 'configuration', text: row => indent + row.configuration, grow: 0 },
117
- {
118
- name: names[0],
119
- text: row => row.cells[0].value === null ? dim('N/A') : comparison.column.format(row.cells[0].value),
120
- padStart: true,
121
- grow: 0,
122
- },
123
- ...names.slice(1).flatMap((name, i) => [
124
- {
125
- name,
126
- text: (row) => {
127
- const cell = row.cells[i + 1];
128
- return cell.value === null ? dim('N/A') : comparison.column.format(cell.value);
129
- },
130
- padStart: true,
131
- grow: 0,
132
- },
133
- { name: '', text: (row) => factorText(row, i + 1), padStart: true, grow: 0 },
134
- ]),
135
- ], { formatHead: dim }, rows);
146
+ if (!comparison.rows.length && !comparison.aggregate)
147
+ continue;
148
+ console.log();
149
+ if (comparison.test != heading)
150
+ console.log(bold((heading = comparison.test).name));
151
+ if (comparison.label)
152
+ console.log(' ' + dim(comparison.label));
153
+ comparisonTable(names, comparison.columns, comparison.rows);
154
+ if (comparison.aggregate) {
155
+ if (comparison.columns.length)
156
+ console.log();
157
+ comparisonTable(names, comparison.aggregateColumns, [comparison.aggregate]);
158
+ }
159
+ shown++;
136
160
  }
161
+ const hidden = [
162
+ unchanged && `${unchanged} unchanged (--unchanged)`,
163
+ partial && `${partial} reported by one reference (--partial)`,
164
+ ].filter(Boolean);
165
+ if (!hidden.length)
166
+ return;
167
+ if (!shown)
168
+ console.log();
169
+ console.log(indent + dim(`${hidden.join(', ')}`));
137
170
  }
138
171
  /** A one-line note about what the numbers were produced under. */
139
172
  export function reportEnvironment(cpu, mem, iterations, warmup, jobs) {
package/dist/types.d.ts CHANGED
@@ -23,3 +23,21 @@ export interface TestModule<C = any, S = any> {
23
23
  /** Run once per configuration, after every iteration. */
24
24
  teardown?(config: C, state: S): unknown;
25
25
  }
26
+ /** The measurements taken for one test at one point in its matrix. */
27
+ export interface CaseResult {
28
+ /** The test's id, i.e. its path as written in the config */
29
+ test: string;
30
+ flags: Record<string, unknown>;
31
+ configuration: string;
32
+ value: Record<string, number>;
33
+ /** Milliseconds `setup` took */
34
+ setup: number;
35
+ /** Milliseconds each timed iteration took */
36
+ samples: number[];
37
+ /** Raw amounts summed over every timed iteration, keyed by quantity */
38
+ amounts: Amounts;
39
+ /** Present when the configuration was not run */
40
+ skipped?: string;
41
+ /** Present when the test threw */
42
+ error?: string;
43
+ }
package/dist/units.d.ts CHANGED
@@ -1,5 +1,7 @@
1
+ /** Timespans from shortest to longest. */
2
+ export declare const timespanOrder: readonly ["ns", "us", "ms", "s"];
1
3
  /** A unit of time a measurement can be expressed in. */
2
- export type Timespan = 'ns' | 'us' | 'ms' | 's';
4
+ export type Timespan = (typeof timespanOrder)[number];
3
5
  /** Milliseconds in each timespan, since every duration is measured in milliseconds. */
4
6
  export declare const timespans: {
5
7
  readonly ns: 0.000001;
@@ -7,8 +9,6 @@ export declare const timespans: {
7
9
  readonly ms: 1;
8
10
  readonly s: 1000;
9
11
  };
10
- /** Timespans from shortest to longest. */
11
- export declare const timespanOrder: readonly ["ns", "us", "ms", "s"];
12
12
  export declare function isTimespan(value: unknown): value is Timespan;
13
13
  /**
14
14
  * Units whose amounts are counted in bytes, so `unit: "MB"` on a byte count reports MB/s without a `scale`.
package/dist/units.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // SPDX-License-Identifier: LGPL-3.0-or-later
2
- /** Milliseconds in each timespan, since every duration is measured in milliseconds. */
3
- export const timespans = { ns: 1e-6, us: 1e-3, ms: 1, s: 1e3 };
4
2
  /** Timespans from shortest to longest. */
5
3
  export const timespanOrder = ['ns', 'us', 'ms', 's'];
4
+ /** Milliseconds in each timespan, since every duration is measured in milliseconds. */
5
+ export const timespans = { ns: 1e-6, us: 1e-3, ms: 1, s: 1e3 };
6
6
  export function isTimespan(value) {
7
7
  return typeof value == 'string' && value in timespans;
8
8
  }
@@ -0,0 +1,3 @@
1
+ import { type Test } from './config.js';
2
+ /** Whether a configuration fits in the budget, and why not when it doesn't. */
3
+ export declare function skipReason(configuration: Test['configurations'][number]): string | undefined;
package/dist/worker.js ADDED
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-or-later
2
+ import { parentPort, workerData } from 'node:worker_threads';
3
+ import { flagCombinations, loadSuite } from './config.js';
4
+ if (!parentPort)
5
+ throw new Error('zbench/worker is only meant to be run as a worker thread');
6
+ const config = workerData;
7
+ const suite = loadSuite(config.config);
8
+ const test = suite.tests.find(t => t.id == config.test);
9
+ if (!test)
10
+ throw new Error(`no test with id ${JSON.stringify(config.test)} in ${config.config}`);
11
+ const flagsConfig = Object.fromEntries(Object.entries(config.flags).map(([k, v]) => [k, [v]]));
12
+ /** Narrow a test's flags to the values the caller asked for, dropping the rest. */
13
+ function selectFlags(test) {
14
+ const flags = {};
15
+ for (const [name, values] of Object.entries(test.flags)) {
16
+ const wanted = flagsConfig?.[name];
17
+ flags[name] = wanted ? values.filter(v => wanted.some(w => Object.is(w, v))) : values;
18
+ }
19
+ return flagCombinations(flags);
20
+ }
21
+ /** Whether a configuration fits in the budget, and why not when it doesn't. */
22
+ export function skipReason(configuration) {
23
+ if (config.all)
24
+ return undefined;
25
+ if (config.cpu !== undefined && configuration.cpu > config.cpu)
26
+ return `needs cpu ${configuration.cpu}`;
27
+ if (config.mem !== undefined && configuration.mem > config.mem)
28
+ return `needs mem ${configuration.mem}`;
29
+ return undefined;
30
+ }
31
+ const module = (await import(test.file));
32
+ if (typeof module.test != 'function')
33
+ throw new Error(`${test.path} does not export a \`test\` function`);
34
+ const combinations = selectFlags(test);
35
+ const results = [];
36
+ const total = combinations.length * test.configurations.length;
37
+ let index = 0;
38
+ for (const flags of combinations) {
39
+ for (const configuration of test.configurations) {
40
+ config.onCase?.(test, flags, configuration.name, index++, total);
41
+ const skipped = skipReason(configuration);
42
+ if (skipped) {
43
+ results.push({
44
+ test: test.id,
45
+ flags,
46
+ configuration: configuration.name,
47
+ value: configuration.value,
48
+ setup: 0,
49
+ samples: [],
50
+ amounts: {},
51
+ skipped,
52
+ });
53
+ continue;
54
+ }
55
+ const testConfig = { ...flags, ...configuration.value };
56
+ const iterations = config.iterations ?? test.iterations;
57
+ const warmup = config.warmup ?? test.warmup;
58
+ const result = {
59
+ test: test.id,
60
+ flags,
61
+ configuration: configuration.name,
62
+ value: configuration.value,
63
+ setup: 0,
64
+ samples: [],
65
+ amounts: {},
66
+ };
67
+ // By key, not by metric: a key with both a per-configuration and an aggregate metric is one quantity
68
+ const quantities = [...new Set(test.metrics.map(metric => metric.key))];
69
+ const setupStart = performance.now();
70
+ let state;
71
+ try {
72
+ state = await module.setup?.(testConfig);
73
+ }
74
+ catch (e) {
75
+ result.error = `setup: ${e?.stack ?? e}`;
76
+ results.push(result);
77
+ continue;
78
+ }
79
+ result.setup = performance.now() - setupStart;
80
+ try {
81
+ for (let i = 0; i < warmup + iterations; i++) {
82
+ const timed = i >= warmup;
83
+ await module.before?.(testConfig, state);
84
+ globalThis.gc?.();
85
+ const start = performance.now();
86
+ const amounts = await module.test(testConfig, state);
87
+ const elapsed = performance.now() - start;
88
+ await module.after?.(testConfig, state);
89
+ if (!timed)
90
+ continue;
91
+ result.samples.push(elapsed);
92
+ for (const key of quantities) {
93
+ const amount = amounts?.[key] ?? configuration.value[key];
94
+ if (amount === undefined)
95
+ continue;
96
+ result.amounts[key] = (result.amounts[key] ?? 0) + amount;
97
+ }
98
+ }
99
+ }
100
+ catch (e) {
101
+ result.error = String(e?.stack ?? e);
102
+ }
103
+ finally {
104
+ try {
105
+ await module.teardown?.(testConfig, state);
106
+ }
107
+ catch (e) {
108
+ result.error ??= `teardown: ${e?.stack ?? e}`;
109
+ }
110
+ }
111
+ results.push(result);
112
+ }
113
+ }
114
+ parentPort.postMessage(results);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zbench-js",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Data-driven, reproducible performance testing",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -58,8 +58,9 @@
58
58
  "typescript-eslint": "^8.58.0"
59
59
  },
60
60
  "dependencies": {
61
- "ioium": "^1.8.0",
62
- "utilium": "^3.0.0"
61
+ "ioium": "^1.9.0",
62
+ "utilium": "^3.0.0",
63
+ "zod": "^4.5.4"
63
64
  },
64
65
  "engines": {
65
66
  "node": ">=22.18.0"
package/dist/pool.d.ts DELETED
@@ -1,5 +0,0 @@
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 DELETED
@@ -1,15 +0,0 @@
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/runner.d.ts DELETED
@@ -1,40 +0,0 @@
1
- import { type Test } from './config.js';
2
- import type { Amounts, TestModule } from './types.js';
3
- /** The measurements taken for one test at one point in its matrix. */
4
- export interface CaseResult {
5
- /** The test's id, i.e. its path as written in the config */
6
- test: string;
7
- flags: Record<string, unknown>;
8
- configuration: string;
9
- value: Record<string, number>;
10
- /** Milliseconds `setup` took */
11
- setup: number;
12
- /** Milliseconds each timed iteration took */
13
- samples: number[];
14
- /** Raw amounts summed over every timed iteration, keyed by quantity */
15
- amounts: Amounts;
16
- /** Present when the configuration was not run */
17
- skipped?: string;
18
- /** Present when the test threw */
19
- error?: string;
20
- }
21
- export interface RunOptions {
22
- /** Overrides the test's own iteration count */
23
- iterations?: number;
24
- warmup?: number;
25
- /** The machine's budget. Configurations asking for more are skipped. */
26
- cpu?: number;
27
- mem?: number;
28
- /** Run every configuration, whatever the budget says */
29
- all?: boolean;
30
- /** Restricts a flag to a subset of its declared values */
31
- flags?: Record<string, unknown[]>;
32
- /** Called before each configuration so callers can show progress */
33
- onCase?(test: Test, flags: Record<string, unknown>, configuration: string, index: number, total: number): void;
34
- }
35
- /** `import()` the test module and check that it is one. */
36
- export declare function loadTest(test: Test): Promise<TestModule>;
37
- /** Whether a configuration fits in the budget, and why not when it doesn't. */
38
- export declare function skipReason(configuration: Test['configurations'][number], options: RunOptions): string | undefined;
39
- /** Run every configuration of a test, once per flag combination. */
40
- export declare function runTest(test: Test, options?: RunOptions): Promise<CaseResult[]>;