Skip to content

Commit f5cfc76

Browse files
Merge pull request #177 from ember-tooling/nvp/bench-summary
Make it easier to get a summary of the bench results locally
2 parents 8920564 + 0d774d6 commit f5cfc76

5 files changed

Lines changed: 192 additions & 0 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
node_modules/
22
package-lock.json
3+
4+
bench-output.txt
5+
bench-comment.md
6+
bench-results.json

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"lint:package": "pnpm publint",
2727
"bench": "node --expose-gc tests/parser.bench.mjs",
2828
"bench:compare": "node scripts/bench-compare.mjs",
29+
"bench:summary": "./scripts/local-bench-summary.sh",
2930
"test": "vitest run"
3031
},
3132
"dependencies": {

scripts/format-bench-cli.mjs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/* eslint-disable n/no-process-exit */
2+
/**
3+
* Format benchmark JSON results as a CLI-friendly summary table.
4+
*
5+
* Environment variables:
6+
* BENCH_JSON_OUTPUT - Path to the JSON bench results
7+
*/
8+
9+
import { readFileSync } from 'node:fs';
10+
11+
const jsonPath = process.env.BENCH_JSON_OUTPUT;
12+
13+
if (!jsonPath) {
14+
console.error('BENCH_JSON_OUTPUT not set');
15+
process.exit(1);
16+
}
17+
18+
let json;
19+
20+
try {
21+
json = JSON.parse(readFileSync(jsonPath, 'utf8'));
22+
} catch (e) {
23+
console.error(`Could not read ${jsonPath}: ${e.message}`);
24+
process.exit(1);
25+
}
26+
27+
function formatTime(ns) {
28+
if (ns >= 1e6) return `${(ns / 1e6).toFixed(2)} ms`;
29+
if (ns >= 1e3) return `${(ns / 1e3).toFixed(2)} µs`;
30+
31+
return `${ns.toFixed(2)} ns`;
32+
}
33+
34+
function deltaEmoji(pct) {
35+
const abs = Math.abs(pct);
36+
37+
if (abs < 1) return '⚪';
38+
if (pct <= -5) return '🟢';
39+
if (pct >= 5) return '🔴';
40+
41+
return '🟡';
42+
}
43+
44+
// Group control/experiment pairs
45+
const pairs = new Map();
46+
47+
for (const trial of json.benchmarks || []) {
48+
for (const r of trial.runs || []) {
49+
if (!r.stats) continue;
50+
51+
const m = r.name.match(/^(.+)\s+\((control|experiment)\)$/);
52+
53+
if (!m) continue;
54+
55+
const [, key, role] = m;
56+
57+
if (!pairs.has(key)) pairs.set(key, {});
58+
59+
pairs.get(key)[role] = r.stats;
60+
}
61+
}
62+
63+
if (pairs.size === 0) {
64+
console.log('No comparison data found.');
65+
process.exit(0);
66+
}
67+
68+
// Build rows — use median (p50) which is far more robust to outliers from
69+
// CPU frequency scaling, GC pauses, and other system noise than the mean.
70+
const rows = [];
71+
72+
for (const [name, { control, experiment }] of pairs) {
73+
if (!control || !experiment) continue;
74+
75+
const ctrlVal = control.p50 ?? control.avg;
76+
const expVal = experiment.p50 ?? experiment.avg;
77+
const delta = ((expVal - ctrlVal) / ctrlVal) * 100;
78+
79+
rows.push({ name, control: ctrlVal, experiment: expVal, delta });
80+
}
81+
82+
if (rows.length === 0) {
83+
console.log('No comparison data found.');
84+
process.exit(0);
85+
}
86+
87+
// Calculate column widths
88+
const nameW = Math.max('Benchmark'.length, ...rows.map((r) => r.name.length));
89+
const ctrlW = Math.max('Control (p50)'.length, ...rows.map((r) => formatTime(r.control).length));
90+
const expW = Math.max(
91+
'Experiment (p50)'.length,
92+
...rows.map((r) => formatTime(r.experiment).length)
93+
);
94+
const deltaW = Math.max(
95+
'Δ'.length,
96+
...rows.map((r) => {
97+
const sign = r.delta > 0 ? '+' : '';
98+
99+
return `${sign}${r.delta.toFixed(1)}%`.length;
100+
})
101+
);
102+
103+
// Print table
104+
const pad = (s, w, right) => (right ? s.padStart(w) : s.padEnd(w));
105+
106+
console.log();
107+
console.log(
108+
` ${pad('Benchmark', nameW)} ${pad('Control (p50)', ctrlW, true)} ${pad('Experiment (p50)', expW, true)} ${pad('Δ', deltaW, true)}`
109+
);
110+
console.log(
111+
` ${'─'.repeat(nameW)} ${'─'.repeat(ctrlW)} ${'─'.repeat(expW)} ${'─'.repeat(deltaW)}`
112+
);
113+
114+
for (const row of rows) {
115+
const emoji = deltaEmoji(row.delta);
116+
const sign = row.delta > 0 ? '+' : '';
117+
const deltaStr = `${sign}${row.delta.toFixed(1)}%`;
118+
119+
console.log(
120+
`${emoji} ${pad(row.name, nameW)} ${pad(formatTime(row.control), ctrlW, true)} ${pad(formatTime(row.experiment), expW, true)} ${pad(deltaStr, deltaW, true)}`
121+
);
122+
}
123+
124+
console.log();
125+
console.log('🟢 faster · 🔴 slower · 🟡 within 5% · ⚪ within 1%');
126+
console.log();

scripts/local-bench-summary.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env bash
2+
3+
# Check CPU tuning on Linux — poor settings cause massive variance
4+
hw_warnings=""
5+
6+
if [ -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then
7+
gov=$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor)
8+
if [ "$gov" != "performance" ]; then
9+
hw_warnings+="⚠️ CPU governor is '$gov' — benchmark results will be noisy.
10+
Fix with: sudo cpupower frequency-set -g performance
11+
"
12+
fi
13+
fi
14+
15+
if [ -f /sys/devices/system/cpu/cpufreq/boost ]; then
16+
boost=$(cat /sys/devices/system/cpu/cpufreq/boost)
17+
if [ "$boost" = "1" ]; then
18+
hw_warnings+="⚠️ CPU boost is enabled — frequency varies with thermals.
19+
Fix with: echo 0 | sudo tee /sys/devices/system/cpu/cpufreq/boost
20+
"
21+
fi
22+
fi
23+
24+
if [ -n "$hw_warnings" ]; then
25+
echo ""
26+
echo "$hw_warnings"
27+
fi
28+
29+
export BENCH_JSON_OUTPUT=./bench-results.json
30+
31+
pnpm bench:compare
32+
33+
echo ""
34+
echo "━━━ Summary ━━━"
35+
node scripts/format-bench-cli.mjs
36+
37+
if [ -n "$hw_warnings" ]; then
38+
echo "$hw_warnings"
39+
fi

tests/parser.bench.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,28 @@ const PARSERS = [
8989

9090
const SIZES = ['small', 'medium', 'large'];
9191

92+
// ---------------------------------------------------------------------------
93+
// JIT warm-up — parse every fixture with both parsers so V8 compiles and
94+
// optimises the hot paths before any measurement begins. Without this, the
95+
// first-to-run parser pays the JIT compilation cost, creating order bias.
96+
// ---------------------------------------------------------------------------
97+
98+
const WARMUP_ROUNDS = 5;
99+
100+
for (const { type, ext, experimentParse, controlParse } of PARSERS) {
101+
for (const size of SIZES) {
102+
const code = FIXTURES[type][size];
103+
const opts = { ...PARSE_OPTIONS, filePath: `${size}${ext}` };
104+
105+
for (let i = 0; i < WARMUP_ROUNDS; i++) {
106+
experimentParse(code, opts);
107+
controlParse?.(code, opts);
108+
}
109+
}
110+
}
111+
112+
globalThis.gc?.();
113+
92114
for (const { type, ext, experimentParse, controlParse } of PARSERS) {
93115
for (const size of SIZES) {
94116
const code = FIXTURES[type][size];

0 commit comments

Comments
 (0)