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/README.md CHANGED
@@ -105,6 +105,11 @@ readable. The timespan is settled once per matrix, so a column has one heading.
105
105
  `"unit": "MB"` reports MB/s with no further arithmetic. `scale` sets the multiplier explicitly and
106
106
  overrides that.
107
107
 
108
+ `compare` narrows what a comparison reports. A test's measurements are all proportional to its
109
+ timings, so every one of them would show the same speedup; marking a single measurement with
110
+ `"compare": true` keeps the comparison table down to the numbers worth reading, and makes that
111
+ measurement the one the factor is computed from.
112
+
108
113
  ## Flags
109
114
 
110
115
  Flags are alternative code paths rather than points in the matrix: the whole matrix runs once per
@@ -141,11 +146,21 @@ zbench -R v3.2.1 -R main -R HEAD
141
146
  ```
142
147
 
143
148
  Each reference is checked out into a cached worktree under `.zbench/`, built, and run. The first is
144
- the baseline; every other column shows its value and a speedup factor that is `> 1` when better,
149
+ the baseline; every other reference gets its columns and a speedup factor that is `> 1` when better,
145
150
  whichever direction the column improves in. A change is only colored when it is larger than both
146
151
  `--threshold` (default 1%) and the two runs' combined noise — so a green number means the change
147
152
  outran the variance, and a gray one means it did not.
148
153
 
154
+ A matrix is one table, grouped by reference, with a column per measurement and a single factor for
155
+ each reference. The measurements of a matrix are proportional, so the factor is taken from the first
156
+ column rather than repeated across all of them; `"compare": true` on a measurement picks which one
157
+ that is and drops the rest.
158
+
159
+ Only the matrices worth reading are printed. A matrix where nothing beat the threshold is left out
160
+ unless you pass `--unchanged`, and one that fewer than two references could report — the rest being
161
+ `N/A` — is left out unless you pass `--partial`; either way the count of what was hidden is printed.
162
+ `--no-runs` drops each reference's own tables and reports nothing but the changes.
163
+
149
164
  Use `.` for the working tree as it is, without a checkout or a build.
150
165
 
151
166
  The tests themselves always come from the working tree and are copied into each worktree, so an old
@@ -155,14 +170,18 @@ runnable (default `npm install --no-audit --no-fund && npm run build`), `--rebui
155
170
 
156
171
  ## Isolation and concurrency
157
172
 
158
- Each matrix runs in a fresh process by default, so one configuration cannot warm up or poison the
159
- next, and a reference's own build is what gets loaded. `--no-isolate` runs everything in-process for
160
- debugging or profiling.
173
+ Every matrix runs in a fresh worker thread, so one configuration cannot warm up or poison the next,
174
+ and a reference's own build is what gets loaded.
175
+
176
+ `-J` sets how many matrices are timed at once, defaulting to half the number of hardware threads.
177
+ Concurrent matrices contend for the machine, which inflates absolute numbers and widens `±`;
178
+ the comparison factors hold up because every reference runs under the same contention,
179
+ but pass `-J 1` when the absolute numbers matter more.
161
180
 
162
- `-J` sets how many matrices are timed at once, defaulting to half the number of hardware threads. Concurrent
163
- matrices contend for the machine, which inflates absolute numbers and widens `±`; the comparison
164
- factors hold up because every reference runs under the same contention, but pass `-J 1` when the
165
- absolute numbers are the point.
181
+ References do not overlap: one is finished before the next one starts, so a reference's matrices all
182
+ meet the same contention rather than some of them sharing the machine with the reference they are
183
+ being compared against. `--overlap` lifts that and lets `-J` fill from every reference at once,
184
+ which finishes sooner at the cost of a noisier comparison.
166
185
 
167
186
  A matrix that crashes or outruns `--timeout` (default 300s) is reported as `N/A` rather than
168
187
  bringing down the run, so the references that did finish are still compared. This matters when the
@@ -183,9 +202,12 @@ minutes on the commit before it.
183
202
  -J, --jobs <n> Matrices to time at once.
184
203
  -l, --list List what would run, then exit.
185
204
  -j, --json <path> Write the raw results as JSON.
205
+ --overlap Let matrices from different references run at the same time.
206
+ --no-runs When comparing, skip each reference's own tables and only report changes.
207
+ --unchanged Also report matrices where nothing beat the threshold.
208
+ --partial Also report matrices where fewer than two references produced numbers.
186
209
  --cpu <n> Override the detected CPU level.
187
210
  --mem <n> Override the detected memory level.
188
- --no-isolate Run in this process instead of one child per matrix.
189
211
  --build <cmd> Command that makes a reference's worktree runnable.
190
212
  --rebuild Rebuild reference worktrees even when they are up to date.
191
213
  --clean Remove cached reference worktrees, then exit.
package/dist/cli.js CHANGED
@@ -1,17 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // SPDX-License-Identifier: LGPL-3.0-or-later
3
2
  import * as io from 'ioium/node';
4
3
  import { writeFile } from 'node:fs/promises';
5
4
  import { join, relative, resolve } from 'node:path';
5
+ import { performance } from 'node:perf_hooks';
6
6
  import { parseArgs, styleText } from 'node:util';
7
7
  import { resolveCapabilities } from './capabilities.js';
8
8
  import { compare } from './compare.js';
9
9
  import { findConfig, flagCombinations, flagLabel, loadSuite } from './config.js';
10
- import { runIsolated, runWorker } from './isolate.js';
11
- import { cleanRefs, prepareRefs, repoRoot } from './refs.js';
10
+ import { runIsolated } from './isolate.js';
11
+ import { cleanRefs, prepareRef, repoRoot, workingRefs } from './refs.js';
12
12
  import { reportComparison, reportEnvironment, reportList, reportRun } from './report.js';
13
- import { mapPool } from './pool.js';
14
- import { runTest } from './runner.js';
13
+ import { duration } from './units.js';
15
14
  const usage = `Usage: zbench [options] [filter...]
16
15
 
17
16
  Run the performance tests named by the config file. Filters match a test's name or path.
@@ -28,14 +27,19 @@ Options:
28
27
  0 waits forever. A reference with a pathological regression can
29
28
  otherwise hold the whole comparison open.
30
29
  -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]
30
+ -J, --jobs <n> Matrices to time at once. [one per two hardware threads]
32
31
  Concurrent matrices contend for the machine, which widens the ± column;
33
32
  pass -J 1 for the quietest numbers.
34
33
  -l, --list List what would run, then exit.
35
34
  -j, --json <path> Write the raw results as JSON.
35
+ --overlap Let matrices from different references run at the same time. By default
36
+ a reference is finished before the next one starts, so every matrix of a
37
+ reference meets the same contention.
38
+ --no-runs When comparing, skip each reference's own tables and only report changes.
39
+ --unchanged Also report matrices where nothing beat the threshold.
40
+ --partial Also report matrices where fewer than two references produced numbers.
36
41
  --cpu <n> Override the detected CPU level.
37
42
  --mem <n> Override the detected memory level.
38
- --no-isolate Run in this process instead of one child per matrix.
39
43
  --build <cmd> Command that makes a reference's worktree runnable.
40
44
  --rebuild Rebuild reference worktrees even when they are up to date.
41
45
  --clean Remove cached reference worktrees, then exit.
@@ -55,36 +59,40 @@ const { values: opts, positionals: filters } = parseArgs({
55
59
  jobs: { short: 'J', type: 'string' },
56
60
  list: { short: 'l', type: 'boolean' },
57
61
  json: { short: 'j', type: 'string' },
62
+ overlap: { type: 'boolean' },
63
+ runs: { type: 'boolean', default: true },
64
+ unchanged: { type: 'boolean' },
65
+ partial: { type: 'boolean' },
58
66
  cpu: { type: 'string' },
59
67
  mem: { type: 'string' },
60
- isolate: { type: 'boolean', default: true },
61
68
  build: { type: 'string' },
62
69
  rebuild: { type: 'boolean' },
63
70
  clean: { type: 'boolean' },
64
71
  quiet: { short: 'q', type: 'boolean' },
65
72
  help: { short: 'h', type: 'boolean' },
66
- worker: { type: 'string' },
67
73
  },
68
74
  allowPositionals: true,
75
+ allowNegative: true,
69
76
  });
70
77
  if (opts.help) {
71
78
  console.log(usage);
72
79
  process.exit(0);
73
80
  }
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
+ const dim = (text) => styleText('gray', text);
82
+ const ignore = () => { };
83
+ // Results go to the console directly, so silencing ioium leaves them in place while the job lines,
84
+ // the progress line and the incidental notices go away
85
+ if (opts.quiet) {
86
+ io.useOutput({ debug: ignore, log: ignore, info: ignore, warn: console.warn, error: console.error });
87
+ io.jobs.useDraw(ignore);
88
+ io.jobs.useClear(ignore);
81
89
  }
82
90
  function number(value, name) {
83
91
  if (value === undefined)
84
92
  return undefined;
85
93
  const parsed = Number(value);
86
94
  if (!Number.isFinite(parsed))
87
- fail(`--${name} expects a number, got ${JSON.stringify(value)}`);
95
+ io.exit(`--${name} expects a number, got ${JSON.stringify(value)}`);
88
96
  return parsed;
89
97
  }
90
98
  /** Parse `-f name=json`, falling back to the raw string when it isn't valid JSON. */
@@ -93,7 +101,7 @@ function parseFlagOverrides(entries) {
93
101
  for (const entry of entries) {
94
102
  const at = entry.indexOf('=');
95
103
  if (at < 0)
96
- fail(`--flag expects name=value, got ${JSON.stringify(entry)}`);
104
+ io.exit(`--flag expects name=value, got ${JSON.stringify(entry)}`);
97
105
  const name = entry.slice(0, at);
98
106
  const raw = entry.slice(at + 1);
99
107
  let value;
@@ -109,19 +117,19 @@ function parseFlagOverrides(entries) {
109
117
  }
110
118
  const configPath = opts.config ? resolve(opts.config) : findConfig();
111
119
  if (!configPath)
112
- fail('no config file found; pass --config or add one at tests/perf/config.json');
120
+ io.exit('no config file found; pass --config or add one at tests/perf/config.json');
113
121
  let suite;
114
122
  try {
115
123
  suite = loadSuite(configPath);
116
124
  }
117
125
  catch (e) {
118
- fail(e.message);
126
+ io.exit(e);
119
127
  }
120
128
  if (filters.length) {
121
129
  const matches = (test) => filters.some(f => test.name.toLowerCase().includes(f.toLowerCase()) || test.path.includes(f));
122
130
  suite.tests = suite.tests.filter(matches);
123
131
  if (!suite.tests.length)
124
- fail(`no test matches ${filters.map(f => JSON.stringify(f)).join(', ')}`);
132
+ io.exit(`no test matches ${filters.map(f => JSON.stringify(f)).join(', ')}`);
125
133
  }
126
134
  if (opts.list) {
127
135
  reportList(suite);
@@ -139,30 +147,8 @@ const threshold = number(opts.threshold, 'threshold') ?? 1;
139
147
  const timeout = number(opts.timeout, 'timeout') ?? 300;
140
148
  const iterations = number(opts.iterations, 'iterations');
141
149
  const warmup = number(opts.warmup, 'warmup');
142
- const cli = resolve(import.meta.dirname, 'cli.js');
143
150
  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
- };
151
+ const jobs = Math.max(1, number(opts.jobs, 'jobs') ?? (Math.round((navigator.hardwareConcurrency || 0) / 2) || 1));
166
152
  /** Every (test, flag combination) pair that will run, i.e. one table each. */
167
153
  function* planned(suite) {
168
154
  for (const test of suite.tests) {
@@ -174,78 +160,101 @@ function* planned(suite) {
174
160
  yield [test, flags];
175
161
  }
176
162
  }
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}` : ''}...`);
163
+ if (!opts.quiet)
164
+ reportEnvironment(cpu, mem, iterations ?? 5, warmup ?? 1, jobs);
165
+ const repo = await repoRoot().catch(() => process.cwd());
166
+ const refOptions = { cache, build: opts.build ?? suite.build, root: suite.root, rebuild: opts.rebuild };
167
+ const prepared = new Array(refNames.length);
168
+ // The working tree is used where it is, so the usual case has nothing to report and no job to run
169
+ if (refNames.every(name => workingRefs.includes(name))) {
170
+ prepared.splice(0, refNames.length, ...(await Promise.all(refNames.map(name => prepareRef(name, refOptions)))));
171
+ }
172
+ else {
173
+ const failures = [];
174
+ await io.jobs.runWithData({
175
+ concurrency: refNames.length,
176
+ jobStartText: dim('preparing'),
177
+ name: ({ name }) => name,
178
+ async run({ name, index }, progress) {
179
+ try {
180
+ prepared[index] = await prepareRef(name, { ...refOptions, onStep: m => progress(dim(m)) });
181
+ return { status: 'succeeded', text: dim('ready') };
182
+ }
183
+ catch (e) {
184
+ const text = String(e?.stderr || e?.message || e).trim();
185
+ const reason = text.split('\n').filter(Boolean).at(-1) ?? 'failed';
186
+ failures.push(`${name}: ${reason}`);
187
+ return { status: 'failed', text: reason };
188
+ }
189
+ },
190
+ }, refNames.map((name, index) => ({ name, index })));
191
+ // A reference that will not build has no numbers at all, so there is nothing left to compare
192
+ if (failures.length)
193
+ io.exit(`could not prepare references:\n ${failures.join('\n ')}`);
194
+ }
195
+ const plan = [...planned(suite)];
196
+ const queue = [];
197
+ // Every reference's matrices go into one queue, so a reference that finishes early does not leave
198
+ // the machine idle while a slow one is still running
199
+ for (const ref of prepared)
200
+ for (const [test, flags] of plan)
201
+ queue.push({ index: queue.length, ref, test, flags });
202
+ const results = new Array(queue.length);
203
+ const comparing = prepared.length > 1;
204
+ await io.jobs.runWithData({
205
+ concurrency: jobs,
206
+ jobStartText: 'running',
207
+ name: job => [job.test.name, flagLabel(job.flags)].filter(Boolean).join(' ')
208
+ + (comparing ? dim(' @ ' + job.ref.name) : ''),
209
+ group: opts.overlap ? undefined : job => job.ref,
210
+ async run(job) {
211
+ const config = job.ref.sha ? join(job.ref.dir, relative(repo, suite.file)) : suite.file;
212
+ const started = performance.now();
184
213
  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
- });
214
+ results[job.index] = await runIsolated({ config, test: job.test.id, flags: job.flags, iterations, warmup, cpu, mem, all: opts.all }, timeout);
215
+ return { status: 'succeeded', text: styleText('blue', duration(performance.now() - started)) };
195
216
  }
196
217
  catch (e) {
218
+ const error = String(e?.message ?? e);
197
219
  // A matrix that cannot run is one reference's problem, not the whole comparison's:
198
220
  // 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,
221
+ results[job.index] = job.test.configurations.map((configuration) => ({
222
+ test: job.test.id,
223
+ flags: job.flags,
202
224
  configuration: configuration.name,
203
225
  value: configuration.value,
204
226
  setup: 0,
205
227
  samples: [],
206
228
  amounts: {},
207
- error: String(e?.message ?? e),
229
+ error,
208
230
  }));
231
+ return { status: 'failed', text: error.split('\n')[0] };
209
232
  }
210
- finally {
211
- done++;
233
+ },
234
+ }, queue);
235
+ // The queue is reference-major, so each reference owns one contiguous run of the results
236
+ const refs = prepared.map((ref, i) => ({
237
+ ref: ref.name,
238
+ cases: results.slice(i * plan.length, (i + 1) * plan.length).flat(),
239
+ }));
240
+ // With one reference there is no comparison to fall back on, so its own tables are the whole report
241
+ if (opts.runs || !comparing) {
242
+ for (const [i, ref] of refs.entries()) {
243
+ if (comparing && !opts.quiet) {
244
+ const { sha } = prepared[i];
245
+ console.log();
246
+ console.log(styleText(['bold', 'underline'], ref.ref) + (sha ? dim(' ' + sha.slice(0, 8)) : ''));
212
247
  }
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)) : ''));
248
+ reportRun(suite, ref.cases);
240
249
  }
241
- const cases = await runRef(ref, repo);
242
- reportRun(suite, cases);
243
- refs.push({ ref: ref.name, cases });
244
250
  }
245
- if (refs.length > 1) {
251
+ if (comparing) {
246
252
  console.log();
247
253
  console.log(styleText(['bold', 'underline'], `Change vs ${refs[0].ref}`));
248
- reportComparison(compare(suite, refs, threshold), refs);
254
+ reportComparison(compare(suite, refs, threshold), refs, {
255
+ unchanged: opts.unchanged,
256
+ partial: opts.partial,
257
+ });
249
258
  }
250
259
  if (opts.json) {
251
260
  await writeFile(opts.json, JSON.stringify({
@@ -270,9 +279,9 @@ if (failed.length) {
270
279
  for (const [message, where] of byMessage) {
271
280
  io.error(`${message} (${where.length} configuration${where.length == 1 ? '' : 's'})`);
272
281
  for (const at of where.slice(0, 4))
273
- console.error(styleText('gray', ' ' + at));
282
+ console.error(dim(' ' + at));
274
283
  if (where.length > 4)
275
- console.error(styleText('gray', ` ...and ${where.length - 4} more`));
284
+ console.error(dim(` ...and ${where.length - 4} more`));
276
285
  }
277
286
  process.exit(1);
278
287
  }
package/dist/compare.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type Column } from './measure.js';
2
- import type { CaseResult } from './runner.js';
1
+ import { type Column, type Matrix } from './measure.js';
2
+ import type { CaseResult } from './types.js';
3
3
  import type { Suite, Test } from './config.js';
4
4
  /** Results for one of the states being compared. */
5
5
  export interface RefResults {
@@ -26,18 +26,31 @@ export interface Delta {
26
26
  }
27
27
  export interface ComparisonRow {
28
28
  configuration: string;
29
- /** Indexed the same as the states being compared, baseline first */
30
- cells: Cell[];
29
+ /** Indexed by state first (baseline first), then by column */
30
+ cells: Cell[][];
31
+ /**
32
+ * One per state, from the primary column.
33
+ * The metrics of a matrix are proportional to each other, so every column would report the
34
+ * same factor; taking it from one keeps the table free of repeated numbers.
35
+ */
31
36
  deltas: Delta[];
32
37
  }
38
+ /** One matrix, with every column it reports lined up across the states being compared. */
33
39
  export interface Comparison {
34
40
  test: Test;
35
41
  /** Flag combination, empty when the test has no flags */
36
42
  label: string;
37
- column: Column;
43
+ /** Columns reported once per configuration. The first is the one `deltas` come from. */
44
+ columns: Column[];
38
45
  rows: ComparisonRow[];
39
- /** Present for aggregate metrics, which have one value per state rather than one per row */
40
- aggregate?: ComparisonRow;
46
+ /** Columns reported once for the whole matrix rather than once per row */
47
+ aggregateColumns: Column[];
48
+ /** The single row those columns fill, when there are any */
49
+ aggregate: ComparisonRow | null;
50
+ /** Whether any row's factor beat both the threshold and the noise */
51
+ changed: boolean;
52
+ /** How many states produced at least one number here. Below 2 there is nothing to compare. */
53
+ reported: number;
41
54
  }
42
55
  /**
43
56
  * Compare a cell against the baseline.
@@ -45,7 +58,14 @@ export interface Comparison {
45
58
  */
46
59
  export declare function delta(baseline: Cell, other: Cell, higherIsBetter: boolean, threshold: number): Delta;
47
60
  /**
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`.
61
+ * The columns a comparison reports for a matrix.
62
+ * A test that marks any measurement with `compare` gets only those; otherwise everything but
63
+ * `ops/s`, which is just the reciprocal of `avg`.
64
+ */
65
+ export declare function comparedColumns(matrix: Matrix): Column[];
66
+ /**
67
+ * Line up every state's results against the baseline, one comparison per matrix.
68
+ * Every column of a matrix lands in the same table, so a speedup factor is reported once rather
69
+ * than once per measurement.
50
70
  */
51
71
  export declare function compare(suite: Suite, refs: RefResults[], threshold: number): Comparison[];
package/dist/compare.js CHANGED
@@ -9,13 +9,14 @@ function cell(column, result) {
9
9
  return { value: null, noise: 0, missing: true };
10
10
  return { value: column.value(result), noise: timing(result).rsd, missing: false };
11
11
  }
12
+ const noDelta = { factor: null, change: null, significant: false, better: false };
12
13
  /**
13
14
  * Compare a cell against the baseline.
14
15
  * A change is only called out when it is bigger than both `threshold` and the two runs' combined noise.
15
16
  */
16
17
  export function delta(baseline, other, higherIsBetter, threshold) {
17
18
  if (baseline.value === null || other.value === null || !baseline.value)
18
- return { factor: null, change: null, significant: false, better: false };
19
+ return noDelta;
19
20
  const factor = higherIsBetter ? other.value / baseline.value : baseline.value / other.value;
20
21
  const change = ((other.value - baseline.value) / baseline.value) * 100;
21
22
  const noise = combinedNoise(baseline.noise, other.noise);
@@ -27,8 +28,44 @@ export function delta(baseline, other, higherIsBetter, threshold) {
27
28
  };
28
29
  }
29
30
  /**
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`.
31
+ * The columns a comparison reports for a matrix.
32
+ * A test that marks any measurement with `compare` gets only those; otherwise everything but
33
+ * `ops/s`, which is just the reciprocal of `avg`.
34
+ */
35
+ export function comparedColumns(matrix) {
36
+ const all = columns(matrix);
37
+ const selected = all.filter(column => column.compare);
38
+ return selected.length ? selected : all.filter(column => column.label != 'ops/s');
39
+ }
40
+ /** Line up one configuration's cells across every state, with the primary column's delta. */
41
+ function row(configuration, cells, primary, threshold) {
42
+ return {
43
+ configuration,
44
+ cells,
45
+ deltas: primary
46
+ ? cells.map(c => delta(cells[0][0], c[0], primary.higherIsBetter, threshold))
47
+ : cells.map(() => noDelta),
48
+ };
49
+ }
50
+ /** The matrix-wide value for each state, compared the same way a row is. */
51
+ function aggregateRow(matrix, cols, refs, threshold) {
52
+ const flags = JSON.stringify(matrix.flags);
53
+ const cells = refs.map((ref) => {
54
+ const cases = ref.cases.filter(r => r.test == matrix.test.id && JSON.stringify(r.flags) == flags);
55
+ const noise = cases.filter(c => c.samples.length).map(c => timing(c).rsd);
56
+ // An aggregate spans the whole matrix, so its noise is the matrix's, not any one row's
57
+ const spread = noise.length ? noise.reduce((sum, v) => sum + v, 0) / noise.length : 0;
58
+ return cols.map((column) => {
59
+ const total = column.total?.(cases) ?? null;
60
+ return { value: total, noise: spread, missing: total === null };
61
+ });
62
+ });
63
+ return row('aggregate', cells, cols[0], threshold);
64
+ }
65
+ /**
66
+ * Line up every state's results against the baseline, one comparison per matrix.
67
+ * Every column of a matrix lands in the same table, so a speedup factor is reported once rather
68
+ * than once per measurement.
32
69
  */
33
70
  export function compare(suite, refs, threshold) {
34
71
  const [baseline, ...others] = refs;
@@ -38,43 +75,26 @@ export function compare(suite, refs, threshold) {
38
75
  const comparisons = [];
39
76
  for (const test of suite.tests) {
40
77
  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
- }
78
+ const selected = comparedColumns(matrix);
79
+ const perRow = selected.filter(column => !column.aggregate);
80
+ const aggregateColumns = selected.filter(column => column.aggregate);
81
+ const rows = perRow.length
82
+ ? matrix.cases.map(result => row(result.configuration, indexes.map(index => perRow.map(column => cell(column, index.get(key(result))))), perRow[0], threshold))
83
+ : [];
84
+ const aggregate = aggregateColumns.length ? aggregateRow(matrix, aggregateColumns, refs, threshold) : null;
85
+ const all = aggregate ? [...rows, aggregate] : rows;
86
+ comparisons.push({
87
+ test,
88
+ label: matrix.label,
89
+ columns: perRow,
90
+ rows,
91
+ aggregateColumns,
92
+ aggregate,
93
+ // The baseline's delta against itself is never significant, so this only counts real changes
94
+ changed: all.some(r => r.deltas.some(d => d.significant)),
95
+ reported: refs.filter((_, r) => all.some(row => row.cells[r].some(c => c.value !== null))).length,
96
+ });
58
97
  }
59
98
  }
60
99
  return comparisons;
61
100
  }
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
- }