Skip to content

Commit 295c6ff

Browse files
jasnelladuh95
authored andcommitted
lib: implement bench/reporters
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode PR-URL: #65606 Reviewed-By: Filip Skokan <panva.ip@gmail.com> Reviewed-By: Robert Nagy <ronagy@icloud.com> Reviewed-By: Vinícius Lourenço Claro Cardoso <contact@viniciusl.com.br> Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
1 parent 815971b commit 295c6ff

11 files changed

Lines changed: 476 additions & 0 deletions

File tree

doc/api/bench.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,66 @@ system load can all affect results. Keep raw samples when comparing results and
6565
investigate noisy or skewed distributions rather than treating a confidence
6666
interval as a pass/fail threshold.
6767

68+
## Benchmark reporters
69+
70+
The built-in reporters are available from the scheme-only
71+
`node:bench/reporters` module:
72+
73+
```mjs
74+
import { json, spec } from 'node:bench/reporters';
75+
```
76+
77+
```cjs
78+
const { json, spec } = require('node:bench/reporters');
79+
```
80+
81+
Reporter values can be passed directly to `stream.compose()`:
82+
83+
```mjs
84+
import { bench, run } from 'node:bench';
85+
import { spec } from 'node:bench/reporters';
86+
import process from 'node:process';
87+
88+
bench('example', (b) => {
89+
b.start();
90+
doWork();
91+
b.end(1);
92+
});
93+
94+
run().compose(spec).pipe(process.stdout);
95+
```
96+
97+
The `spec` reporter buffers results and outputs a concise table containing the
98+
sample count, mean rate, 95% confidence interval for the mean, median rate, and
99+
warnings. A coefficient of variation above 5% is reported as `noisy`, and an
100+
absolute skewness above 1 is reported as `skewed`. The exact human-readable
101+
format is subject to change.
102+
103+
The `json` reporter emits every lifecycle record as newline-delimited JSON.
104+
BigInt values, including `duration_ns`, are encoded as decimal strings. Errors
105+
are represented using their `name`, `message`, `stack`, `code`, `cause`, and
106+
`errors` properties. As required by JSON, non-finite numbers are encoded as
107+
`null`.
108+
109+
Custom reporters use the same composition contract. They can be transforms or
110+
functions accepted by `stream.compose()`. The composed readable can be piped to
111+
any writable destination:
112+
113+
```mjs
114+
import { run } from 'node:bench';
115+
import process from 'node:process';
116+
117+
async function* names(source) {
118+
for await (const { type, data } of source) {
119+
if (type === 'bench:complete') {
120+
yield `${data.name}\n`;
121+
}
122+
}
123+
}
124+
125+
run().compose(names).pipe(process.stdout);
126+
```
127+
68128
## `bench([name][, options], fn)`
69129

70130
<!-- YAML

lib/bench/reporters.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use strict';
2+
3+
const {
4+
ObjectDefineProperties,
5+
ReflectConstruct,
6+
} = primordials;
7+
const { emitExperimentalWarning } = require('internal/util');
8+
9+
let json;
10+
let specFn;
11+
12+
emitExperimentalWarning('Benchmarks');
13+
14+
ObjectDefineProperties(module.exports, {
15+
__proto__: null,
16+
json: {
17+
__proto__: null,
18+
configurable: true,
19+
enumerable: true,
20+
get() {
21+
json ??= require('internal/bench_runner/reporter/json');
22+
return json;
23+
},
24+
},
25+
spec: {
26+
__proto__: null,
27+
configurable: true,
28+
enumerable: true,
29+
value: function spec() {
30+
specFn ??= require('internal/bench_runner/reporter/spec');
31+
return ReflectConstruct(specFn, arguments);
32+
},
33+
},
34+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
const {
4+
ArrayIsArray,
5+
ArrayPrototypePush,
6+
JSONStringify,
7+
ObjectKeys,
8+
SafeWeakSet,
9+
String,
10+
} = primordials;
11+
const { isError } = require('internal/util');
12+
13+
function toJSONValue(value, seen) {
14+
if (typeof value === 'bigint') return String(value);
15+
if (value === null || typeof value !== 'object') return value;
16+
if (seen.has(value)) return '[Circular]';
17+
seen.add(value);
18+
19+
if (isError(value)) {
20+
const result = {
21+
__proto__: null,
22+
name: value.name,
23+
message: value.message,
24+
stack: value.stack,
25+
code: value.code,
26+
};
27+
if (value.cause !== undefined) {
28+
result.cause = toJSONValue(value.cause, seen);
29+
}
30+
if (value.errors !== undefined) {
31+
result.errors = toJSONValue(value.errors, seen);
32+
}
33+
return result;
34+
}
35+
36+
if (ArrayIsArray(value)) {
37+
const result = [];
38+
for (let i = 0; i < value.length; i++) {
39+
ArrayPrototypePush(result, toJSONValue(value[i], seen));
40+
}
41+
return result;
42+
}
43+
44+
const result = { __proto__: null };
45+
const keys = ObjectKeys(value);
46+
for (let i = 0; i < keys.length; i++) {
47+
const key = keys[i];
48+
result[key] = toJSONValue(value[key], seen);
49+
}
50+
return result;
51+
}
52+
53+
function stringify(record) {
54+
return JSONStringify(record === undefined ? null :
55+
toJSONValue(record, new SafeWeakSet()));
56+
}
57+
58+
async function* jsonReporter(source) {
59+
for await (const record of source) {
60+
yield `${stringify(record)}\n`;
61+
}
62+
}
63+
64+
module.exports = jsonReporter;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
'use strict';
2+
3+
const {
4+
ArrayPrototypeJoin,
5+
ArrayPrototypePush,
6+
JSONStringify,
7+
MathAbs,
8+
NumberIsFinite,
9+
NumberPrototypeToFixed,
10+
NumberPrototypeToPrecision,
11+
ObjectEntries,
12+
String,
13+
StringPrototypeReplaceAll,
14+
} = primordials;
15+
const Transform = require('internal/streams/transform');
16+
17+
const kHeader =
18+
'benchmark | samples | mean rate | 95% CI | median rate | warning\n';
19+
20+
function escapeCell(value) {
21+
let result = String(value);
22+
result = StringPrototypeReplaceAll(result, '\r', '\\r');
23+
result = StringPrototypeReplaceAll(result, '\n', '\\n');
24+
return StringPrototypeReplaceAll(result, '|', '\\|');
25+
}
26+
27+
function formatName(data) {
28+
const name = escapeCell(data.name);
29+
const entries = ObjectEntries(data.params);
30+
if (entries.length === 0) return name;
31+
32+
const params = [];
33+
for (let i = 0; i < entries.length; i++) {
34+
const { 0: key, 1: value } = entries[i];
35+
ArrayPrototypePush(
36+
params, `${escapeCell(key)}=${escapeCell(JSONStringify(value))}`);
37+
}
38+
return `${name} [${ArrayPrototypeJoin(params, ', ')}]`;
39+
}
40+
41+
function formatRate(rate) {
42+
if (!NumberIsFinite(rate)) return '-';
43+
44+
const absolute = MathAbs(rate);
45+
let divisor = 1;
46+
let prefix = '';
47+
if (absolute >= 1e9) {
48+
divisor = 1e9;
49+
prefix = 'G';
50+
} else if (absolute >= 1e6) {
51+
divisor = 1e6;
52+
prefix = 'M';
53+
} else if (absolute >= 1e3) {
54+
divisor = 1e3;
55+
prefix = 'k';
56+
}
57+
58+
const scaled = rate / divisor;
59+
const formatted = MathAbs(scaled) > 0 && MathAbs(scaled) < 1 ?
60+
NumberPrototypeToPrecision(scaled, 3) :
61+
NumberPrototypeToFixed(scaled, 2);
62+
return `${formatted}${prefix} ops/s`;
63+
}
64+
65+
function formatWarning(summary) {
66+
const warnings = [];
67+
if (NumberIsFinite(summary.coefficientOfVariation) &&
68+
summary.coefficientOfVariation > 0.05) {
69+
ArrayPrototypePush(warnings, 'noisy');
70+
}
71+
if (NumberIsFinite(summary.skewness) && MathAbs(summary.skewness) > 1) {
72+
ArrayPrototypePush(warnings, 'skewed');
73+
}
74+
return ArrayPrototypeJoin(warnings, ', ');
75+
}
76+
77+
function formatResult(data) {
78+
const name = formatName(data);
79+
if (data.skip !== undefined) {
80+
const reason = typeof data.skip === 'string' && data.skip.length > 0 ?
81+
`: ${escapeCell(data.skip)}` : '';
82+
return `${name} | 0 | - | - | - | skipped${reason}\n`;
83+
}
84+
if (data.error !== undefined) {
85+
const message = data.error?.message ?? data.error;
86+
return `${name} | ${data.samples.length} | - | - | - | ` +
87+
`error: ${escapeCell(message)}\n`;
88+
}
89+
90+
const { confidenceInterval, median } = data.summary;
91+
return `${name} | ${data.samples.length} | ` +
92+
`${formatRate(data.summary.mean)} | ` +
93+
`[${formatRate(confidenceInterval.lower)}, ` +
94+
`${formatRate(confidenceInterval.upper)}] | ` +
95+
`${formatRate(median)} | ${formatWarning(data.summary)}\n`;
96+
}
97+
98+
class SpecReporter extends Transform {
99+
#diagnostics = [];
100+
#reported = false;
101+
#results = [];
102+
103+
constructor() {
104+
super({ __proto__: null, writableObjectMode: true });
105+
}
106+
107+
#format(summary = undefined) {
108+
let output = kHeader;
109+
for (let i = 0; i < this.#results.length; i++) {
110+
output += formatResult(this.#results[i]);
111+
}
112+
for (let i = 0; i < this.#diagnostics.length; i++) {
113+
output += `diagnostic: ${escapeCell(this.#diagnostics[i].message)}\n`;
114+
}
115+
if (summary !== undefined) {
116+
const { completed, failed, skipped } = summary.counts;
117+
output += `\n${completed} completed, ${failed} failed, ` +
118+
`${skipped} skipped\n`;
119+
}
120+
return output;
121+
}
122+
123+
_transform({ type, data }, _encoding, callback) {
124+
switch (type) {
125+
case 'bench:complete':
126+
ArrayPrototypePush(this.#results, data);
127+
break;
128+
case 'bench:diagnostic':
129+
ArrayPrototypePush(this.#diagnostics, data);
130+
break;
131+
case 'bench:summary':
132+
this.#reported = true;
133+
callback(null, this.#format(data));
134+
return;
135+
}
136+
callback();
137+
}
138+
139+
_flush(callback) {
140+
if (this.#reported ||
141+
(this.#results.length === 0 && this.#diagnostics.length === 0)) {
142+
callback();
143+
return;
144+
}
145+
callback(null, this.#format());
146+
}
147+
}
148+
149+
module.exports = SpecReporter;

lib/internal/bootstrap/realm.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ const legacyWrapperList = new SafeSet([
125125
// Modules that can only be imported via the node: scheme.
126126
const schemelessBlockList = new SafeSet([
127127
'bench',
128+
'bench/reporters',
128129
'dtls',
129130
'ffi',
130131
'sea',

test/module-hooks/test-module-hooks-builtin-require.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const { registerHooks } = require('module');
1212

1313
const schemelessBlockList = new Set([
1414
'bench',
15+
'bench/reporters',
1516
'sea',
1617
'test',
1718
'test/reporters',

test/module-hooks/test-module-hooks-load-builtin-require.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ hook.deregister();
3636
// stripped for internal lookups should not get passed into the hooks.
3737
const schemelessBlockList = new Set([
3838
'bench',
39+
'bench/reporters',
3940
'sea',
4041
'test',
4142
'test/reporters',
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Flags: --no-warnings
2+
'use strict';
3+
4+
const common = require('../common');
5+
const assert = require('assert');
6+
const { Writable } = require('stream');
7+
const { finished } = require('stream/promises');
8+
const { bench, run } = require('node:bench');
9+
10+
bench('completed', { samples: 1 }, (b) => {
11+
b.start();
12+
process.hrtime.bigint();
13+
b.end(1);
14+
});
15+
bench.skip('skipped', { samples: 1 }, common.mustNotCall());
16+
17+
async function* customReporter(source) {
18+
for await (const { type, data } of source) {
19+
if (type !== 'bench:complete') continue;
20+
yield `${data.name}:${data.skip === undefined ? 'completed' : 'skipped'}\n`;
21+
}
22+
}
23+
24+
let output = '';
25+
const destination = new Writable({
26+
write(chunk, _encoding, callback) {
27+
output += chunk;
28+
callback();
29+
},
30+
});
31+
32+
run().compose(customReporter).pipe(destination);
33+
34+
(async () => {
35+
await finished(destination);
36+
assert.strictEqual(output, 'completed:completed\nskipped:skipped\n');
37+
})().then(common.mustCall());

0 commit comments

Comments
 (0)