Skip to content

Commit 2c335f8

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
1 parent dab3a73 commit 2c335f8

3 files changed

Lines changed: 119 additions & 35 deletions

File tree

‎benchmark/compare.js‎

Lines changed: 57 additions & 35 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,20 @@ 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+
if (csvFd === null) {
74+
writeSync(process.stdout.fd, `${line}\n`);
75+
} else {
76+
writeSync(csvFd, `${line}\n`);
77+
}
78+
}
79+
6380
// When --analyze is set, collect results for statistical analysis.
6481
const results = analyze ? new Map() : null;
6582

@@ -78,17 +95,19 @@ for (const filename of benchmarks) {
7895
}
7996
// queue.length = binary.length * runs * benchmarks.length
8097

81-
// Print csv header (unless analyzing inline).
82-
if (!analyze) {
83-
console.log('"binary","filename","configuration","rate","time"');
98+
// Print csv header unless only analyzing inline.
99+
if (outputCsv) {
100+
writeCsv('"binary","filename","configuration","rate","time"');
84101
}
85102

86103
const kStartOfQueue = 0;
87104

88-
const showProgress = !cli.optional['no-progress'];
105+
const showProgress = !cli.optional['no-progress'] && !cvsToStdout;
89106
let progress;
90107
if (showProgress) {
91-
progress = new BenchmarkProgress(queue, benchmarks, { analyze });
108+
progress = new BenchmarkProgress(queue, benchmarks, {
109+
analyze: analyze || csvFd !== null,
110+
});
92111
progress.startQueue(kStartOfQueue);
93112
}
94113

@@ -126,11 +145,13 @@ if (showProgress) {
126145
results.set(name, { old: [], new: [] });
127146
}
128147
results.get(name)[job.binary].push(data.rate);
129-
} else {
148+
}
149+
150+
if (outputCsv) {
130151
// Escape quotes (") for correct csv formatting
131-
conf = conf.replace(/"/g, '""');
132-
console.log(`"${job.binary}","${job.filename}","${conf}",` +
133-
`${data.rate},${data.time}`);
152+
const csvConf = conf.replace(/"/g, '""');
153+
writeCsv(`"${job.binary}","${job.filename}","${csvConf}",` +
154+
`${data.rate},${data.time}`);
134155
}
135156
if (showProgress) {
136157
// One item in the subqueue has been completed.
@@ -153,8 +174,9 @@ if (showProgress) {
153174
// If there are more benchmarks execute the next
154175
if (i + 1 < queue.length) {
155176
recursive(i + 1);
156-
} else if (analyze) {
157-
printAnalysis(results, scale, maxRegression);
177+
} else {
178+
if (csvFd !== null) closeSync(csvFd);
179+
if (analyze) printAnalysis(results, scale, maxRegression);
158180
}
159181
});
160182
})(kStartOfQueue);
@@ -261,40 +283,40 @@ function printAnalysis(results, scale, maxRegression) {
261283
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
262284
const rpad = (s, n) => ' '.repeat(Math.max(0, n - s.length)) + s;
263285

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

267289
for (const row of rows) {
268290
const imp = `${row.improvement >= 0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
269-
console.log(
291+
writeSync(process.stdout.fd,
270292
`${pad(row.name, maxNameLen)} ${pad(row.stars, 10)}` +
271293
` ${rpad(imp, 11)}` +
272294
` ±${row.ci95.toFixed(2)}%` +
273295
` ±${row.ci99.toFixed(2)}%` +
274296
` ±${row.ci999.toFixed(2)}%` +
275-
`${row.inconclusive ? ' (inconclusive)' : ''}`,
297+
`${row.inconclusive ? ' (inconclusive)' : ''}\n`,
276298
);
277299
}
278300

279301
if (skipped > 0) {
280-
console.log('');
281-
console.log(
302+
writeSync(process.stdout.fd, '\n');
303+
writeSync(process.stdout.fd,
282304
`Note: ${skipped} configuration${skipped === 1 ? ' was' : 's were'}` +
283305
` skipped because Welch's t-test requires at least 2 samples per` +
284-
` binary. Use --runs 2 or higher.`,
306+
` binary. Use --runs 2 or higher.\n`,
285307
);
286308
}
287309

288310
// --- Bar chart visualization ---
289311
printChart(rows, maxNameLen);
290312

291-
console.log('');
292-
console.log(
313+
writeSync(process.stdout.fd, '\n');
314+
writeSync(process.stdout.fd,
293315
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n` +
294-
`Use --scale to adjust precision if needed.\n`,
316+
`Use --scale to adjust precision if needed.\n\n`,
295317
);
296318
const anyFamilyWise = rows.filter((r) => r.pAdjusted < 0.05).length;
297-
console.log(
319+
writeSync(process.stdout.fd,
298320
`Be aware that when doing many comparisons the risk of a false-positive\n` +
299321
`result increases. In this case, there are ${rows.length} comparisons, ` +
300322
`you can thus\nexpect the following amount of false-positive results:\n` +
@@ -307,19 +329,19 @@ function printAnalysis(results, scale, maxRegression) {
307329
`\nThe stars above are per-benchmark and uncorrected. Adjusting for the ` +
308330
`size of\nthis comparison set (Holm-Bonferroni), ${anyFamilyWise} ` +
309331
`comparison${anyFamilyWise === 1 ? '' : 's'} remain${anyFamilyWise === 1 ? 's' : ''} ` +
310-
`significant at 5%.\n--max-regression uses the corrected values.`,
332+
`significant at 5%.\n--max-regression uses the corrected values.\n`,
311333
);
312334

313335
// Gate: exit with error if any regression is shown to exceed the limit.
314336
if (maxRegression > 0) {
315337
if (underpowered > 0) {
316-
console.log('');
317-
console.log(
338+
writeSync(process.stdout.fd, '\n');
339+
writeSync(process.stdout.fd,
318340
`Note: ${underpowered} of ${rows.length} comparison` +
319341
`${rows.length === 1 ? '' : 's'} could not resolve an effect as ` +
320342
`small as ${maxRegression}%, and are marked (inconclusive). They are ` +
321343
`not\nevidence of no regression -- the samples are too noisy to tell. ` +
322-
`Raise --runs,\nor pin cores with --set CPUSET, to narrow them.`,
344+
`Raise --runs,\nor pin cores with --set CPUSET, to narrow them.\n`,
323345
);
324346
}
325347

@@ -340,18 +362,18 @@ function printAnalysis(results, scale, maxRegression) {
340362
);
341363

342364
if (failures.length > 0) {
343-
console.log('');
344-
console.log(
365+
writeSync(process.stdout.fd, '\n');
366+
writeSync(process.stdout.fd,
345367
`FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` +
346368
` regressed by more than ${maxRegression}%` +
347369
` (interval excludes the threshold,\n` +
348-
`family-wise corrected across ${rows.length} comparisons):`,
370+
`family-wise corrected across ${rows.length} comparisons):\n`,
349371
);
350372
for (const f of failures) {
351-
console.log(
373+
writeSync(process.stdout.fd,
352374
` ${f.name} ${f.improvement.toFixed(2)}% ` +
353375
`(95% CI up to ${(f.improvement + f.ci95).toFixed(2)}%, ` +
354-
`adjusted p=${f.pAdjusted.toExponential(2)})`,
376+
`adjusted p=${f.pAdjusted.toExponential(2)})\n`,
355377
);
356378
}
357379
process.exitCode = 1;
@@ -388,8 +410,8 @@ function printChart(rows, maxNameLen) {
388410
axisCenter +
389411
' '.repeat(Math.max(0, halfWidth - Math.ceil(axisCenter.length / 2) - axisRight.length)) +
390412
axisRight;
391-
console.log('');
392-
console.log(leftLabel);
413+
writeSync(process.stdout.fd, '\n');
414+
writeSync(process.stdout.fd, `${leftLabel}\n`);
393415

394416
for (const row of rows) {
395417
const imp = row.improvement;
@@ -421,6 +443,6 @@ function printChart(rows, maxNameLen) {
421443

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

‎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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use strict';
2+
3+
require('../common');
4+
5+
const assert = require('node:assert');
6+
const { spawnSync } = require('node: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+
const result = spawnSync(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+
34+
assert.strictEqual(result.status, 0, result.stderr);
35+
assert.strictEqual(result.stderr, '');
36+
assert.match(result.stdout, /confidence\s+improvement\s+accuracy/);
37+
assert.doesNotMatch(result.stdout, /"binary","filename"/);
38+
39+
const lines = readFileSync(csv, 'utf8').trim().split('\n');
40+
const filename = path.join('buffers', 'buffer-compare-offset.js');
41+
assert.strictEqual(lines[0],
42+
'"binary","filename","configuration","rate","time"');
43+
assert.strictEqual(lines.length, 3);
44+
assert(lines[1].startsWith(`"old","${filename}",`));
45+
assert(lines[2].startsWith(`"new","${filename}",`));

0 commit comments

Comments
 (0)