Skip to content

Commit e69b8eb

Browse files
committed
benchmark: add --csv option to compare.js with --analyze
Add a `--csv {filename}` option to benchmark/compare.js to capture the CSV when the `--analyze` option is used Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode PR-URL: #65922 Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Robert Nagy <ronagy@icloud.com> Reviewed-By: Filip Skokan <panva.ip@gmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent 6193e15 commit e69b8eb

3 files changed

Lines changed: 123 additions & 42 deletions

File tree

benchmark/compare.js

Lines changed: 60 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const { spawn, fork } = require('node:child_process');
4+
const { closeSync, openSync, writeSync } = require('node:fs');
45
const { inspect } = require('util');
56
const path = require('path');
67
const CLI = require('./_cli.js');
@@ -27,7 +28,9 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2728
--no-progress don't show benchmark progress indicator
2829
--analyze perform statistical analysis after benchmarks
2930
complete (Welch's t-test, effect size) instead
30-
of printing csv output
31+
of printing csv output to stdout
32+
--csv filename write csv output to filename (can be combined
33+
with --analyze). Use - to write to stdout.
3134
--scale 1000 rate-to-integer multiplier for histogram
3235
precision when using --analyze (default: 1000)
3336
--max-regression N exit with code 1 if any statistically
@@ -60,6 +63,16 @@ if (benchmarks.length === 0) {
6063
return;
6164
}
6265

66+
const cvsToStdout = cli.optional.csv === '-';
67+
const csvFd = cli.optional.csv === undefined || cvsToStdout ?
68+
null :
69+
openSync(cli.optional.csv, 'w');
70+
const outputCsv = !analyze || csvFd !== null || cvsToStdout;
71+
72+
function writeCsv(line) {
73+
writeSync(csvFd || process.stdout.fd, `${line}\n`);
74+
}
75+
6376
// When --analyze is set, collect results for statistical analysis.
6477
const results = analyze ? new Map() : null;
6578

@@ -78,17 +91,19 @@ for (const filename of benchmarks) {
7891
}
7992
// queue.length = binary.length * runs * benchmarks.length
8093

81-
// Print csv header (unless analyzing inline).
82-
if (!analyze) {
83-
console.log('"binary","filename","configuration","rate","time"');
94+
// Print csv header unless only analyzing inline.
95+
if (outputCsv) {
96+
writeCsv('"binary","filename","configuration","rate","time"');
8497
}
8598

8699
const kStartOfQueue = 0;
87100

88-
const showProgress = !cli.optional['no-progress'];
101+
const showProgress = !cli.optional['no-progress'] && !cvsToStdout;
89102
let progress;
90103
if (showProgress) {
91-
progress = new BenchmarkProgress(queue, benchmarks, { analyze });
104+
progress = new BenchmarkProgress(queue, benchmarks, {
105+
analyze: analyze || csvFd !== null,
106+
});
92107
progress.startQueue(kStartOfQueue);
93108
}
94109

@@ -126,11 +141,13 @@ if (showProgress) {
126141
results.set(name, { old: [], new: [] });
127142
}
128143
results.get(name)[job.binary].push(data.rate);
129-
} else {
144+
}
145+
146+
if (outputCsv) {
130147
// Escape quotes (") for correct csv formatting
131-
conf = conf.replace(/"/g, '""');
132-
console.log(`"${job.binary}","${job.filename}","${conf}",` +
133-
`${data.rate},${data.time}`);
148+
const csvConf = conf.replace(/"/g, '""');
149+
writeCsv(`"${job.binary}","${job.filename}","${csvConf}",` +
150+
`${data.rate},${data.time}`);
134151
}
135152
if (showProgress) {
136153
// One item in the subqueue has been completed.
@@ -153,8 +170,9 @@ if (showProgress) {
153170
// If there are more benchmarks execute the next
154171
if (i + 1 < queue.length) {
155172
recursive(i + 1);
156-
} else if (analyze) {
157-
printAnalysis(results, scale, maxRegression);
173+
} else {
174+
if (csvFd !== null) closeSync(csvFd);
175+
if (analyze) printAnalysis(results, scale, maxRegression);
158176
}
159177
});
160178
})(kStartOfQueue);
@@ -261,41 +279,41 @@ function printAnalysis(results, scale, maxRegression) {
261279
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
262280
const rpad = (s, n) => ' '.repeat(Math.max(0, n - s.length)) + s;
263281

264-
console.log(`${pad('', maxNameLen)} confidence` +
265-
` improvement accuracy (*) (**) (***)`);
282+
writeSync(process.stdout.fd, `${pad('', maxNameLen)} confidence` +
283+
` improvement accuracy (*) (**) (***)\n`);
266284

267285
for (const row of rows) {
268286
const imp = `${row.improvement >= 0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
269-
console.log(
270-
`${pad(row.name, maxNameLen)} ${pad(row.stars, 10)}` +
287+
writeSync(process.stdout.fd,
288+
`${pad(row.name, maxNameLen)} ${pad(row.stars, 10)}` +
271289
` ${rpad(imp, 11)}` +
272290
` ±${row.ci95.toFixed(2)}%` +
273291
` ±${row.ci99.toFixed(2)}%` +
274292
` ±${row.ci999.toFixed(2)}%` +
275-
`${row.inconclusive ? ' (inconclusive)' : ''}`,
293+
`${row.inconclusive ? ' (inconclusive)' : ''}\n`,
276294
);
277295
}
278296

279297
if (skipped > 0) {
280-
console.log('');
281-
console.log(
282-
`Note: ${skipped} configuration${skipped === 1 ? ' was' : 's were'}` +
298+
writeSync(process.stdout.fd, '\n');
299+
writeSync(process.stdout.fd,
300+
`Note: ${skipped} configuration${skipped === 1 ? ' was' : 's were'}` +
283301
` skipped because Welch's t-test requires at least 2 samples per` +
284-
` binary. Use --runs 2 or higher.`,
302+
` binary. Use --runs 2 or higher.\n`,
285303
);
286304
}
287305

288306
// --- Bar chart visualization ---
289307
printChart(rows, maxNameLen);
290308

291-
console.log('');
292-
console.log(
293-
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n` +
294-
`Use --scale to adjust precision if needed.\n`,
309+
writeSync(process.stdout.fd, '\n');
310+
writeSync(process.stdout.fd,
311+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n` +
312+
`Use --scale to adjust precision if needed.\n\n`,
295313
);
296314
const anyFamilyWise = rows.filter((r) => r.pAdjusted < 0.05).length;
297-
console.log(
298-
`Be aware that when doing many comparisons the risk of a false-positive\n` +
315+
writeSync(process.stdout.fd,
316+
`Be aware that when doing many comparisons the risk of a false-positive\n` +
299317
`result increases. In this case, there are ${rows.length} comparisons, ` +
300318
`you can thus\nexpect the following amount of false-positive results:\n` +
301319
` ${(rows.length * 0.05).toFixed(2)} false positives, when considering ` +
@@ -307,19 +325,19 @@ function printAnalysis(results, scale, maxRegression) {
307325
`\nThe stars above are per-benchmark and uncorrected. Adjusting for the ` +
308326
`size of\nthis comparison set (Holm-Bonferroni), ${anyFamilyWise} ` +
309327
`comparison${anyFamilyWise === 1 ? '' : 's'} remain${anyFamilyWise === 1 ? 's' : ''} ` +
310-
`significant at 5%.\n--max-regression uses the corrected values.`,
328+
`significant at 5%.\n--max-regression uses the corrected values.\n`,
311329
);
312330

313331
// Gate: exit with error if any regression is shown to exceed the limit.
314332
if (maxRegression > 0) {
315333
if (underpowered > 0) {
316-
console.log('');
317-
console.log(
318-
`Note: ${underpowered} of ${rows.length} comparison` +
334+
writeSync(process.stdout.fd, '\n');
335+
writeSync(process.stdout.fd,
336+
`Note: ${underpowered} of ${rows.length} comparison` +
319337
`${rows.length === 1 ? '' : 's'} could not resolve an effect as ` +
320338
`small as ${maxRegression}%, and are marked (inconclusive). They are ` +
321339
`not\nevidence of no regression -- the samples are too noisy to tell. ` +
322-
`Raise --runs,\nor pin cores with --set CPUSET, to narrow them.`,
340+
`Raise --runs,\nor pin cores with --set CPUSET, to narrow them.\n`,
323341
);
324342
}
325343

@@ -340,18 +358,18 @@ function printAnalysis(results, scale, maxRegression) {
340358
);
341359

342360
if (failures.length > 0) {
343-
console.log('');
344-
console.log(
345-
`FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` +
361+
writeSync(process.stdout.fd, '\n');
362+
writeSync(process.stdout.fd,
363+
`FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` +
346364
` regressed by more than ${maxRegression}%` +
347365
` (interval excludes the threshold,\n` +
348-
`family-wise corrected across ${rows.length} comparisons):`,
366+
`family-wise corrected across ${rows.length} comparisons):\n`,
349367
);
350368
for (const f of failures) {
351-
console.log(
352-
` ${f.name} ${f.improvement.toFixed(2)}% ` +
369+
writeSync(process.stdout.fd,
370+
` ${f.name} ${f.improvement.toFixed(2)}% ` +
353371
`(95% CI up to ${(f.improvement + f.ci95).toFixed(2)}%, ` +
354-
`adjusted p=${f.pAdjusted.toExponential(2)})`,
372+
`adjusted p=${f.pAdjusted.toExponential(2)})\n`,
355373
);
356374
}
357375
process.exitCode = 1;
@@ -388,8 +406,8 @@ function printChart(rows, maxNameLen) {
388406
axisCenter +
389407
' '.repeat(Math.max(0, halfWidth - Math.ceil(axisCenter.length / 2) - axisRight.length)) +
390408
axisRight;
391-
console.log('');
392-
console.log(leftLabel);
409+
writeSync(process.stdout.fd, '\n');
410+
writeSync(process.stdout.fd, `${leftLabel}\n`);
393411

394412
for (const row of rows) {
395413
const imp = row.improvement;
@@ -421,6 +439,6 @@ function printChart(rows, maxNameLen) {
421439

422440
const label = `${row.improvement >= 0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
423441
const sig = row.stars.trim();
424-
console.log(`${pad(row.name, maxNameLen)} ${chars.join('')} ${label} ${sig}`);
442+
writeSync(process.stdout.fd, `${pad(row.name, maxNameLen)} ${chars.join('')} ${label} ${sig}\n`);
425443
}
426444
}

doc/contributing/writing-and-running-benchmarks.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ module, you can use the `--filter` option:_
416416
--set variable=value set benchmark variable (can be repeated)
417417
--no-progress don't show benchmark progress indicator
418418
--analyze perform statistical analysis inline (no R needed)
419+
--csv filename write csv output to filename (can be combined
420+
with --analyze)
419421
--scale 1000 rate multiplier for --analyze precision
420422
--max-regression N exit with code 1 if any significant regression
421423
exceeds N% (implies --analyze)
@@ -429,6 +431,14 @@ The simplest way to get statistical results is to pass `--analyze`:
429431
node benchmark/compare.js --old ./node-main --new ./node-pr-5134 --analyze string_decoder
430432
```
431433

434+
Use `--csv` to retain the raw benchmark results. If you pass both `--csv` and
435+
`--analyze`, both the raw results and the analysis are printed:
436+
437+
```bash
438+
node benchmark/compare.js --old ./node-main --new ./node-pr-5134 \
439+
--analyze --csv compare-pr-5134.csv string_decoder
440+
```
441+
432442
This runs the benchmarks and prints the analysis directly:
433443

434444
```console
@@ -438,6 +448,13 @@ string_decoder/string-decoder.js n=2500000 chunkLen=16 inLen=128 encoding='utf8'
438448
...
439449
```
440450

451+
Use `-csv -` to output the raw results to stdout with the analysis.
452+
453+
```bash
454+
node benchmark/compare.js --old ./node-main --new ./node-pr-5134 \
455+
--analyze --csv - string_decoder
456+
```
457+
441458
The `--analyze` mode uses the histogram API's `welchTest()` method to perform
442459
the same Welch's t-test that the R script uses. Benchmark rates are scaled to
443460
integers for the histogram (controlled by `--scale`, default 1000). With the
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use strict';
2+
3+
require('../common');
4+
5+
const assert = require('node:assert');
6+
const { spawnSyncAndExitWithoutError } = require('../common/child_process');
7+
const { readFileSync } = require('node:fs');
8+
const path = require('node:path');
9+
const tmpdir = require('../common/tmpdir');
10+
11+
const compare = path.resolve(__dirname, '../../benchmark/compare.js');
12+
13+
tmpdir.refresh();
14+
15+
const csv = tmpdir.resolve('compare.csv');
16+
spawnSyncAndExitWithoutError(process.execPath, [
17+
compare,
18+
'--old', process.execPath,
19+
'--new', process.execPath,
20+
'--runs', '1',
21+
'--filter', 'buffer-compare-offset.js',
22+
'--set', 'method=offset',
23+
'--set', 'size=16',
24+
'--set', 'n=1',
25+
'--no-progress',
26+
'--analyze',
27+
'--csv', csv,
28+
'buffers',
29+
], {
30+
encoding: 'utf8',
31+
timeout: 30_000,
32+
}, {
33+
stderr: '',
34+
stdout(stdout) {
35+
assert.match(stdout, /confidence\s+improvement\s+accuracy/);
36+
assert.doesNotMatch(stdout, /"binary","filename"/);
37+
},
38+
});
39+
40+
const lines = readFileSync(csv, 'utf8').trim().split('\n');
41+
const filename = path.join('buffers', 'buffer-compare-offset.js');
42+
assert.strictEqual(lines[0],
43+
'"binary","filename","configuration","rate","time"');
44+
assert.strictEqual(lines.length, 3);
45+
assert(lines[1].startsWith(`"old","${filename}",`));
46+
assert(lines[2].startsWith(`"new","${filename}",`));

0 commit comments

Comments
 (0)