Commit 74234ee

Browse files
jasnelladuh95
authored andcommitted
benchmark: add --analyze mode to compare.js
Add an --analyze flag that performs statistical analysis directly after benchmarks complete, eliminating the need for R and compare.R. When --analyze is specified, compare.js collects the rate data during the run and prints a statistical summary table instead of CSV output. The table matches the format of compare.R: improvement percentage, significance stars (* p<0.05, ** p<0.01, *** p<0.001), and confidence intervals at three risk levels. Also adds a --max-regression N option that causes the compare.js to exit with 1 (error) when the `--new` is N% slower. Useful for CI use to detect regressions. Uses the histogram API's welchTest() and cohensD() methods introduced in the previous commit. Benchmark rates are scaled to integers for HdrHistogram recording; the --scale option (default 1000) controls the multiplier for precision. Usage: node benchmark/compare.js --old ./node-old --new ./node-new \ --analyze url Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 9e8e908 commit 74234ee

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

β€Žbenchmark/_benchmark_progress.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function getTime(diff) {
2525
// A run is an item in the job queue: { binary, filename, iter }
2626
// A config is an item in the subqueue: { binary, filename, iter, configs }
2727
classBenchmarkProgress{
28-
constructor(queue,benchmarks){
28+
constructor(queue,benchmarks,options={}){
2929
this.queue=queue;// Scheduled runs.
3030
this.benchmarks=benchmarks;// Filenames of scheduled benchmarks.
31+
this.analyze=!!options.analyze;// stdout is not piped, but unused.
3132
this.completedRuns=0;// Number of completed runs.
3233
this.scheduledRuns=queue.length;// Number of scheduled runs.
3334
// Time when starting to run benchmarks.
@@ -107,7 +108,10 @@ class BenchmarkProgress {
107108
}
108109

109110
updateProgress(){
110-
if(!process.stderr.isTTY||process.stdout.isTTY){
111+
// Progress renders on stderr when stdout is piped (not a TTY).
112+
// In --analyze mode, stdout is the terminal but is unused during
113+
// the run, so treat it the same as piped.
114+
if(!process.stderr.isTTY||(process.stdout.isTTY&&!this.analyze)){
111115
return;
112116
}
113117
readline.clearLine(process.stderr);

β€Žbenchmark/compare.jsβ€Ž

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
1313
Run each benchmark in the <category> directory many times using two different
1414
node versions. More than one <category> directory can be specified.
1515
The output is formatted as csv, which can be processed using for
16-
example 'compare.R'.
16+
example 'compare.R'. Use --analyze to perform statistical analysis
17+
directly without R.
1718
1819
--new ./new-node-binary new node binary (required)
1920
--old ./old-node-binary old node binary (required)
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2425
repeated)
2526
--set variable=value set benchmark variable (can be repeated)
2627
--no-progress don't show benchmark progress indicator
28+
--analyze perform statistical analysis after benchmarks
29+
complete (Welch's t-test, effect size) instead
30+
of printing csv output
31+
--scale 1000 rate-to-integer multiplier for histogram
32+
precision when using --analyze (default: 1000)
33+
--max-regression N exit with code 1 if any statistically
34+
significant regression exceeds N% (implies
35+
--analyze)
2736
2837
Examples:
2938
--set CPUSET=0 Runs benchmarks on CPU core 0.
3039
--set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2.
3140
3241
Note: The CPUSET format should match the specifications of the 'taskset' command
33-
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress']});
42+
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress','analyze']});
3443

3544
if(!cli.optional.new||!cli.optional.old){
3645
cli.abort(cli.usage);
3746
}
3847

3948
constbinaries=['old','new'];
4049
construns=cli.optional.runs ? parseInt(cli.optional.runs,10) : 30;
50+
constmaxRegression=cli.optional['max-regression'] ?
51+
parseFloat(cli.optional['max-regression']) :
52+
0;
53+
constanalyze=!!cli.optional.analyze||maxRegression>0;
54+
constscale=cli.optional.scale ? parseInt(cli.optional.scale,10) : 1000;
4155
constbenchmarks=cli.benchmarks();
4256

4357
if(benchmarks.length===0){
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) {
4660
return;
4761
}
4862

63+
// When --analyze is set, collect results for statistical analysis.
64+
constresults=analyze ? newMap() : null;
65+
4966
// Create queue from the benchmarks list such both node versions are tested
5067
// `runs` amount of times each.
5168
// Note: BenchmarkProgress relies on this order to estimate
@@ -61,15 +78,17 @@ for (const filename of benchmarks) {
6178
}
6279
// queue.length = binary.length * runs * benchmarks.length
6380

64-
// Print csv header
65-
console.log('"binary","filename","configuration","rate","time"');
81+
// Print csv header (unless analyzing inline).
82+
if(!analyze){
83+
console.log('"binary","filename","configuration","rate","time"');
84+
}
6685

6786
constkStartOfQueue=0;
6887

6988
constshowProgress=!cli.optional['no-progress'];
7089
letprogress;
7190
if(showProgress){
72-
progress=newBenchmarkProgress(queue,benchmarks);
91+
progress=newBenchmarkProgress(queue,benchmarks,{ analyze });
7392
progress.startQueue(kStartOfQueue);
7493
}
7594

@@ -99,11 +118,20 @@ if (showProgress) {
99118
conf+=` ${key}=${inspect(data.conf[key])}`;
100119
}
101120
conf=conf.slice(1);
102-
// Escape quotes (") for correct csv formatting
103-
conf=conf.replace(/"/g,'""');
104121

105-
console.log(`"${job.binary}","${job.filename}","${conf}",`+
106-
`${data.rate},${data.time}`);
122+
if(analyze){
123+
// Collect results for post-run analysis.
124+
constname=`${job.filename}${conf}`;
125+
if(!results.has(name)){
126+
results.set(name,{old: [],new: []});
127+
}
128+
results.get(name)[job.binary].push(data.rate);
129+
}else{
130+
// Escape quotes (") for correct csv formatting
131+
conf=conf.replace(/"/g,'""');
132+
console.log(`"${job.binary}","${job.filename}","${conf}",`+
133+
`${data.rate},${data.time}`);
134+
}
107135
if(showProgress){
108136
// One item in the subqueue has been completed.
109137
progress.completeConfig(data);
@@ -125,6 +153,199 @@ if (showProgress) {
125153
// If there are more benchmarks execute the next
126154
if(i+1<queue.length){
127155
recursive(i+1);
156+
}elseif(analyze){
157+
printAnalysis(results,scale,maxRegression);
128158
}
129159
});
130160
})(kStartOfQueue);
161+
162+
functionprintAnalysis(results,scale,maxRegression){
163+
const{ createHistogram }=require('node:perf_hooks');
164+
165+
// Build per-benchmark histograms and run statistical tests.
166+
constrows=[];
167+
letmaxNameLen=0;
168+
169+
letskipped=0;
170+
171+
for(const[name,{old: oldRates,new: newRates}]ofresults){
172+
if(oldRates.length<2||newRates.length<2){
173+
skipped++;
174+
continue;
175+
}
176+
177+
consthOld=createHistogram({figures: 3});
178+
consthNew=createHistogram({figures: 3});
179+
180+
for(constrofoldRates)hOld.record(Math.max(1,Math.round(r*scale)));
181+
for(constrofnewRates)hNew.record(Math.max(1,Math.round(r*scale)));
182+
183+
constoldMean=oldRates.reduce((a,b)=>a+b,0)/oldRates.length;
184+
constnewMean=newRates.reduce((a,b)=>a+b,0)/newRates.length;
185+
constimprovement=((newMean-oldMean)/oldMean)*100;
186+
187+
// Query the three confidence levels. The p-value and t-statistic
188+
// are the same regardless of the confidence level, so we extract
189+
// them from the first result.
190+
constw95=hOld.welchTest(hNew,{confidence: 0.95});
191+
constw99=hOld.welchTest(hNew,{confidence: 0.99});
192+
constw999=hOld.welchTest(hNew,{confidence: 0.999});
193+
194+
// Significance stars matching compare.R convention.
195+
letstars='';
196+
if(w95.pValue<0.001)stars='***';
197+
elseif(w95.pValue<0.01)stars=' **';
198+
elseif(w95.pValue<0.05)stars=' *';
199+
200+
// Confidence intervals expressed as percentage of the old mean.
201+
constciPct=(w)=>{
202+
consthalf=
203+
(w.confidenceInterval.upper-w.confidenceInterval.lower)/2;
204+
return(half/(oldMean*scale))*100;
205+
};
206+
207+
rows.push({
208+
name,
209+
stars,
210+
improvement,
211+
ci95: ciPct(w95),
212+
ci99: ciPct(w99),
213+
ci999: ciPct(w999),
214+
pValue: w95.pValue,
215+
});
216+
217+
if(name.length>maxNameLen)maxNameLen=name.length;
218+
}
219+
220+
// Print header.
221+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
222+
constrpad=(s,n)=>' '.repeat(Math.max(0,n-s.length))+s;
223+
224+
console.log(`${pad('',maxNameLen)} confidence`+
225+
` improvement accuracy (*) (**) (***)`);
226+
227+
for(constrowofrows){
228+
constimp=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
229+
console.log(
230+
`${pad(row.name,maxNameLen)}${pad(row.stars,10)}`+
231+
` ${rpad(imp,11)}`+
232+
` Β±${row.ci95.toFixed(2)}%`+
233+
` Β±${row.ci99.toFixed(2)}%`+
234+
` Β±${row.ci999.toFixed(2)}%`,
235+
);
236+
}
237+
238+
if(skipped>0){
239+
console.log('');
240+
console.log(
241+
`Note: ${skipped} configuration${skipped===1 ? ' was' : 's were'}`+
242+
` skipped because Welch's t-test requires at least 2 samples per`+
243+
` binary. Use --runs 2 or higher.`,
244+
);
245+
}
246+
247+
// --- Bar chart visualization ---
248+
printChart(rows,maxNameLen);
249+
250+
console.log('');
251+
console.log(
252+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n`+
253+
`Use --scale to adjust precision if needed.\n`,
254+
);
255+
console.log(
256+
`Be aware that when doing many comparisons the risk of a false-positive\n`+
257+
`result increases. In this case, there are ${rows.length} comparisons, `+
258+
`you can thus\nexpect the following amount of false-positive results:\n`+
259+
` ${(rows.length*0.05).toFixed(2)} false positives, when considering `+
260+
`a 5% risk acceptance (*, **, ***),\n`+
261+
` ${(rows.length*0.01).toFixed(2)} false positives, when considering `+
262+
`a 1% risk acceptance (**, ***),\n`+
263+
` ${(rows.length*0.001).toFixed(2)} false positives, when considering `+
264+
`a 0.1% risk acceptance (***)`,
265+
);
266+
267+
// Gate: exit with error if any significant regression exceeds the limit.
268+
if(maxRegression>0){
269+
constfailures=rows.filter(
270+
(r)=>r.stars.trim()!==''&&r.improvement<-maxRegression,
271+
);
272+
if(failures.length>0){
273+
console.log('');
274+
console.log(
275+
`FAIL: ${failures.length} benchmark${failures.length===1 ? '' : 's'}`+
276+
` showed a statistically significant regression exceeding`+
277+
` ${maxRegression}%:`,
278+
);
279+
for(constfoffailures){
280+
console.log(` ${f.name}${f.improvement.toFixed(2)}%`);
281+
}
282+
process.exitCode=1;
283+
}
284+
}
285+
}
286+
287+
functionprintChart(rows,maxNameLen){
288+
if(rows.length===0)return;
289+
290+
// Determine the chart scale from the data. The bar region covers
291+
// the range [-maxAbs, +maxAbs] so the zero line sits in the center.
292+
constbarWidth=40;
293+
consthalfWidth=barWidth/2;
294+
letmaxAbs=0;
295+
for(constrowofrows){
296+
constextent=Math.abs(row.improvement)+row.ci95;
297+
if(extent>maxAbs)maxAbs=extent;
298+
}
299+
if(maxAbs===0)maxAbs=1;
300+
301+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
302+
303+
// Scale axis labels.
304+
constaxisLeft=`-${maxAbs.toFixed(1)}%`;
305+
constaxisRight=`+${maxAbs.toFixed(1)}%`;
306+
constaxisCenter='0%';
307+
308+
// Print axis header.
309+
constlabelPad=maxNameLen+5;
310+
constleftLabel=' '.repeat(labelPad)+
311+
axisLeft+
312+
' '.repeat(Math.max(0,halfWidth-axisLeft.length-Math.floor(axisCenter.length/2)))+
313+
axisCenter+
314+
' '.repeat(Math.max(0,halfWidth-Math.ceil(axisCenter.length/2)-axisRight.length))+
315+
axisRight;
316+
console.log('');
317+
console.log(leftLabel);
318+
319+
for(constrowofrows){
320+
constimp=row.improvement;
321+
constci=row.ci95;
322+
323+
// Position of the improvement value in the bar region [0, barWidth].
324+
constcenter=halfWidth;
325+
constimpPos=center+(imp/maxAbs)*halfWidth;
326+
327+
// CI extent in bar positions.
328+
constciLeft=center+((imp-ci)/maxAbs)*halfWidth;
329+
constciRight=center+((imp+ci)/maxAbs)*halfWidth;
330+
331+
// Build the bar character by character.
332+
constchars=[];
333+
for(letx=0;x<barWidth;x++){
334+
constpos=x+0.5;// Center of this character cell.
335+
if(x===Math.floor(center)){
336+
chars.push('|');
337+
}elseif((imp>=0&&pos>center&&pos<=impPos)||
338+
(imp<0&&pos<center&&pos>=impPos)){
339+
chars.push(row.stars ? '\u2588' : '\u2593');// solid or dark shade
340+
}elseif(pos>=ciLeft&&pos<=ciRight){
341+
chars.push('\u2591');// Light shade for CI region
342+
}else{
343+
chars.push(' ');
344+
}
345+
}
346+
347+
constlabel=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
348+
constsig=row.stars.trim();
349+
console.log(`${pad(row.name,maxNameLen)}${chars.join('')}${label}${sig}`);
350+
}
351+
}

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 74234ee

Browse files
jasnelladuh95
authored andcommitted
benchmark: add --analyze mode to compare.js
Add an --analyze flag that performs statistical analysis directly after benchmarks complete, eliminating the need for R and compare.R. When --analyze is specified, compare.js collects the rate data during the run and prints a statistical summary table instead of CSV output. The table matches the format of compare.R: improvement percentage, significance stars (* p<0.05, ** p<0.01, *** p<0.001), and confidence intervals at three risk levels. Also adds a --max-regression N option that causes the compare.js to exit with 1 (error) when the `--new` is N% slower. Useful for CI use to detect regressions. Uses the histogram API's welchTest() and cohensD() methods introduced in the previous commit. Benchmark rates are scaled to integers for HdrHistogram recording; the --scale option (default 1000) controls the multiplier for precision. Usage: node benchmark/compare.js --old ./node-old --new ./node-new \ --analyze url Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 9e8e908 commit 74234ee

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

β€Žbenchmark/_benchmark_progress.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function getTime(diff) {
2525
// A run is an item in the job queue: { binary, filename, iter }
2626
// A config is an item in the subqueue: { binary, filename, iter, configs }
2727
classBenchmarkProgress{
28-
constructor(queue,benchmarks){
28+
constructor(queue,benchmarks,options={}){
2929
this.queue=queue;// Scheduled runs.
3030
this.benchmarks=benchmarks;// Filenames of scheduled benchmarks.
31+
this.analyze=!!options.analyze;// stdout is not piped, but unused.
3132
this.completedRuns=0;// Number of completed runs.
3233
this.scheduledRuns=queue.length;// Number of scheduled runs.
3334
// Time when starting to run benchmarks.
@@ -107,7 +108,10 @@ class BenchmarkProgress {
107108
}
108109

109110
updateProgress(){
110-
if(!process.stderr.isTTY||process.stdout.isTTY){
111+
// Progress renders on stderr when stdout is piped (not a TTY).
112+
// In --analyze mode, stdout is the terminal but is unused during
113+
// the run, so treat it the same as piped.
114+
if(!process.stderr.isTTY||(process.stdout.isTTY&&!this.analyze)){
111115
return;
112116
}
113117
readline.clearLine(process.stderr);

β€Žbenchmark/compare.jsβ€Ž

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
1313
Run each benchmark in the <category> directory many times using two different
1414
node versions. More than one <category> directory can be specified.
1515
The output is formatted as csv, which can be processed using for
16-
example 'compare.R'.
16+
example 'compare.R'. Use --analyze to perform statistical analysis
17+
directly without R.
1718
1819
--new ./new-node-binary new node binary (required)
1920
--old ./old-node-binary old node binary (required)
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2425
repeated)
2526
--set variable=value set benchmark variable (can be repeated)
2627
--no-progress don't show benchmark progress indicator
28+
--analyze perform statistical analysis after benchmarks
29+
complete (Welch's t-test, effect size) instead
30+
of printing csv output
31+
--scale 1000 rate-to-integer multiplier for histogram
32+
precision when using --analyze (default: 1000)
33+
--max-regression N exit with code 1 if any statistically
34+
significant regression exceeds N% (implies
35+
--analyze)
2736
2837
Examples:
2938
--set CPUSET=0 Runs benchmarks on CPU core 0.
3039
--set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2.
3140
3241
Note: The CPUSET format should match the specifications of the 'taskset' command
33-
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress']});
42+
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress','analyze']});
3443

3544
if(!cli.optional.new||!cli.optional.old){
3645
cli.abort(cli.usage);
3746
}
3847

3948
constbinaries=['old','new'];
4049
construns=cli.optional.runs ? parseInt(cli.optional.runs,10) : 30;
50+
constmaxRegression=cli.optional['max-regression'] ?
51+
parseFloat(cli.optional['max-regression']) :
52+
0;
53+
constanalyze=!!cli.optional.analyze||maxRegression>0;
54+
constscale=cli.optional.scale ? parseInt(cli.optional.scale,10) : 1000;
4155
constbenchmarks=cli.benchmarks();
4256

4357
if(benchmarks.length===0){
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) {
4660
return;
4761
}
4862

63+
// When --analyze is set, collect results for statistical analysis.
64+
constresults=analyze ? newMap() : null;
65+
4966
// Create queue from the benchmarks list such both node versions are tested
5067
// `runs` amount of times each.
5168
// Note: BenchmarkProgress relies on this order to estimate
@@ -61,15 +78,17 @@ for (const filename of benchmarks) {
6178
}
6279
// queue.length = binary.length * runs * benchmarks.length
6380

64-
// Print csv header
65-
console.log('"binary","filename","configuration","rate","time"');
81+
// Print csv header (unless analyzing inline).
82+
if(!analyze){
83+
console.log('"binary","filename","configuration","rate","time"');
84+
}
6685

6786
constkStartOfQueue=0;
6887

6988
constshowProgress=!cli.optional['no-progress'];
7089
letprogress;
7190
if(showProgress){
72-
progress=newBenchmarkProgress(queue,benchmarks);
91+
progress=newBenchmarkProgress(queue,benchmarks,{ analyze });
7392
progress.startQueue(kStartOfQueue);
7493
}
7594

@@ -99,11 +118,20 @@ if (showProgress) {
99118
conf+=` ${key}=${inspect(data.conf[key])}`;
100119
}
101120
conf=conf.slice(1);
102-
// Escape quotes (") for correct csv formatting
103-
conf=conf.replace(/"/g,'""');
104121

105-
console.log(`"${job.binary}","${job.filename}","${conf}",`+
106-
`${data.rate},${data.time}`);
122+
if(analyze){
123+
// Collect results for post-run analysis.
124+
constname=`${job.filename}${conf}`;
125+
if(!results.has(name)){
126+
results.set(name,{old: [],new: []});
127+
}
128+
results.get(name)[job.binary].push(data.rate);
129+
}else{
130+
// Escape quotes (") for correct csv formatting
131+
conf=conf.replace(/"/g,'""');
132+
console.log(`"${job.binary}","${job.filename}","${conf}",`+
133+
`${data.rate},${data.time}`);
134+
}
107135
if(showProgress){
108136
// One item in the subqueue has been completed.
109137
progress.completeConfig(data);
@@ -125,6 +153,199 @@ if (showProgress) {
125153
// If there are more benchmarks execute the next
126154
if(i+1<queue.length){
127155
recursive(i+1);
156+
}elseif(analyze){
157+
printAnalysis(results,scale,maxRegression);
128158
}
129159
});
130160
})(kStartOfQueue);
161+
162+
functionprintAnalysis(results,scale,maxRegression){
163+
const{ createHistogram }=require('node:perf_hooks');
164+
165+
// Build per-benchmark histograms and run statistical tests.
166+
constrows=[];
167+
letmaxNameLen=0;
168+
169+
letskipped=0;
170+
171+
for(const[name,{old: oldRates,new: newRates}]ofresults){
172+
if(oldRates.length<2||newRates.length<2){
173+
skipped++;
174+
continue;
175+
}
176+
177+
consthOld=createHistogram({figures: 3});
178+
consthNew=createHistogram({figures: 3});
179+
180+
for(constrofoldRates)hOld.record(Math.max(1,Math.round(r*scale)));
181+
for(constrofnewRates)hNew.record(Math.max(1,Math.round(r*scale)));
182+
183+
constoldMean=oldRates.reduce((a,b)=>a+b,0)/oldRates.length;
184+
constnewMean=newRates.reduce((a,b)=>a+b,0)/newRates.length;
185+
constimprovement=((newMean-oldMean)/oldMean)*100;
186+
187+
// Query the three confidence levels. The p-value and t-statistic
188+
// are the same regardless of the confidence level, so we extract
189+
// them from the first result.
190+
constw95=hOld.welchTest(hNew,{confidence: 0.95});
191+
constw99=hOld.welchTest(hNew,{confidence: 0.99});
192+
constw999=hOld.welchTest(hNew,{confidence: 0.999});
193+
194+
// Significance stars matching compare.R convention.
195+
letstars='';
196+
if(w95.pValue<0.001)stars='***';
197+
elseif(w95.pValue<0.01)stars=' **';
198+
elseif(w95.pValue<0.05)stars=' *';
199+
200+
// Confidence intervals expressed as percentage of the old mean.
201+
constciPct=(w)=>{
202+
consthalf=
203+
(w.confidenceInterval.upper-w.confidenceInterval.lower)/2;
204+
return(half/(oldMean*scale))*100;
205+
};
206+
207+
rows.push({
208+
name,
209+
stars,
210+
improvement,
211+
ci95: ciPct(w95),
212+
ci99: ciPct(w99),
213+
ci999: ciPct(w999),
214+
pValue: w95.pValue,
215+
});
216+
217+
if(name.length>maxNameLen)maxNameLen=name.length;
218+
}
219+
220+
// Print header.
221+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
222+
constrpad=(s,n)=>' '.repeat(Math.max(0,n-s.length))+s;
223+
224+
console.log(`${pad('',maxNameLen)} confidence`+
225+
` improvement accuracy (*) (**) (***)`);
226+
227+
for(constrowofrows){
228+
constimp=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
229+
console.log(
230+
`${pad(row.name,maxNameLen)}${pad(row.stars,10)}`+
231+
` ${rpad(imp,11)}`+
232+
` Β±${row.ci95.toFixed(2)}%`+
233+
` Β±${row.ci99.toFixed(2)}%`+
234+
` Β±${row.ci999.toFixed(2)}%`,
235+
);
236+
}
237+
238+
if(skipped>0){
239+
console.log('');
240+
console.log(
241+
`Note: ${skipped} configuration${skipped===1 ? ' was' : 's were'}`+
242+
` skipped because Welch's t-test requires at least 2 samples per`+
243+
` binary. Use --runs 2 or higher.`,
244+
);
245+
}
246+
247+
// --- Bar chart visualization ---
248+
printChart(rows,maxNameLen);
249+
250+
console.log('');
251+
console.log(
252+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n`+
253+
`Use --scale to adjust precision if needed.\n`,
254+
);
255+
console.log(
256+
`Be aware that when doing many comparisons the risk of a false-positive\n`+
257+
`result increases. In this case, there are ${rows.length} comparisons, `+
258+
`you can thus\nexpect the following amount of false-positive results:\n`+
259+
` ${(rows.length*0.05).toFixed(2)} false positives, when considering `+
260+
`a 5% risk acceptance (*, **, ***),\n`+
261+
` ${(rows.length*0.01).toFixed(2)} false positives, when considering `+
262+
`a 1% risk acceptance (**, ***),\n`+
263+
` ${(rows.length*0.001).toFixed(2)} false positives, when considering `+
264+
`a 0.1% risk acceptance (***)`,
265+
);
266+
267+
// Gate: exit with error if any significant regression exceeds the limit.
268+
if(maxRegression>0){
269+
constfailures=rows.filter(
270+
(r)=>r.stars.trim()!==''&&r.improvement<-maxRegression,
271+
);
272+
if(failures.length>0){
273+
console.log('');
274+
console.log(
275+
`FAIL: ${failures.length} benchmark${failures.length===1 ? '' : 's'}`+
276+
` showed a statistically significant regression exceeding`+
277+
` ${maxRegression}%:`,
278+
);
279+
for(constfoffailures){
280+
console.log(` ${f.name}${f.improvement.toFixed(2)}%`);
281+
}
282+
process.exitCode=1;
283+
}
284+
}
285+
}
286+
287+
functionprintChart(rows,maxNameLen){
288+
if(rows.length===0)return;
289+
290+
// Determine the chart scale from the data. The bar region covers
291+
// the range [-maxAbs, +maxAbs] so the zero line sits in the center.
292+
constbarWidth=40;
293+
consthalfWidth=barWidth/2;
294+
letmaxAbs=0;
295+
for(constrowofrows){
296+
constextent=Math.abs(row.improvement)+row.ci95;
297+
if(extent>maxAbs)maxAbs=extent;
298+
}
299+
if(maxAbs===0)maxAbs=1;
300+
301+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
302+
303+
// Scale axis labels.
304+
constaxisLeft=`-${maxAbs.toFixed(1)}%`;
305+
constaxisRight=`+${maxAbs.toFixed(1)}%`;
306+
constaxisCenter='0%';
307+
308+
// Print axis header.
309+
constlabelPad=maxNameLen+5;
310+
constleftLabel=' '.repeat(labelPad)+
311+
axisLeft+
312+
' '.repeat(Math.max(0,halfWidth-axisLeft.length-Math.floor(axisCenter.length/2)))+
313+
axisCenter+
314+
' '.repeat(Math.max(0,halfWidth-Math.ceil(axisCenter.length/2)-axisRight.length))+
315+
axisRight;
316+
console.log('');
317+
console.log(leftLabel);
318+
319+
for(constrowofrows){
320+
constimp=row.improvement;
321+
constci=row.ci95;
322+
323+
// Position of the improvement value in the bar region [0, barWidth].
324+
constcenter=halfWidth;
325+
constimpPos=center+(imp/maxAbs)*halfWidth;
326+
327+
// CI extent in bar positions.
328+
constciLeft=center+((imp-ci)/maxAbs)*halfWidth;
329+
constciRight=center+((imp+ci)/maxAbs)*halfWidth;
330+
331+
// Build the bar character by character.
332+
constchars=[];
333+
for(letx=0;x<barWidth;x++){
334+
constpos=x+0.5;// Center of this character cell.
335+
if(x===Math.floor(center)){
336+
chars.push('|');
337+
}elseif((imp>=0&&pos>center&&pos<=impPos)||
338+
(imp<0&&pos<center&&pos>=impPos)){
339+
chars.push(row.stars ? '\u2588' : '\u2593');// solid or dark shade
340+
}elseif(pos>=ciLeft&&pos<=ciRight){
341+
chars.push('\u2591');// Light shade for CI region
342+
}else{
343+
chars.push(' ');
344+
}
345+
}
346+
347+
constlabel=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
348+
constsig=row.stars.trim();
349+
console.log(`${pad(row.name,maxNameLen)}${chars.join('')}${label}${sig}`);
350+
}
351+
}

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 74234ee

Browse files
jasnelladuh95
authored andcommitted
benchmark: add --analyze mode to compare.js
Add an --analyze flag that performs statistical analysis directly after benchmarks complete, eliminating the need for R and compare.R. When --analyze is specified, compare.js collects the rate data during the run and prints a statistical summary table instead of CSV output. The table matches the format of compare.R: improvement percentage, significance stars (* p<0.05, ** p<0.01, *** p<0.001), and confidence intervals at three risk levels. Also adds a --max-regression N option that causes the compare.js to exit with 1 (error) when the `--new` is N% slower. Useful for CI use to detect regressions. Uses the histogram API's welchTest() and cohensD() methods introduced in the previous commit. Benchmark rates are scaled to integers for HdrHistogram recording; the --scale option (default 1000) controls the multiplier for precision. Usage: node benchmark/compare.js --old ./node-old --new ./node-new \ --analyze url Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 9e8e908 commit 74234ee

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

β€Žbenchmark/_benchmark_progress.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function getTime(diff) {
2525
// A run is an item in the job queue: { binary, filename, iter }
2626
// A config is an item in the subqueue: { binary, filename, iter, configs }
2727
classBenchmarkProgress{
28-
constructor(queue,benchmarks){
28+
constructor(queue,benchmarks,options={}){
2929
this.queue=queue;// Scheduled runs.
3030
this.benchmarks=benchmarks;// Filenames of scheduled benchmarks.
31+
this.analyze=!!options.analyze;// stdout is not piped, but unused.
3132
this.completedRuns=0;// Number of completed runs.
3233
this.scheduledRuns=queue.length;// Number of scheduled runs.
3334
// Time when starting to run benchmarks.
@@ -107,7 +108,10 @@ class BenchmarkProgress {
107108
}
108109

109110
updateProgress(){
110-
if(!process.stderr.isTTY||process.stdout.isTTY){
111+
// Progress renders on stderr when stdout is piped (not a TTY).
112+
// In --analyze mode, stdout is the terminal but is unused during
113+
// the run, so treat it the same as piped.
114+
if(!process.stderr.isTTY||(process.stdout.isTTY&&!this.analyze)){
111115
return;
112116
}
113117
readline.clearLine(process.stderr);

β€Žbenchmark/compare.jsβ€Ž

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
1313
Run each benchmark in the <category> directory many times using two different
1414
node versions. More than one <category> directory can be specified.
1515
The output is formatted as csv, which can be processed using for
16-
example 'compare.R'.
16+
example 'compare.R'. Use --analyze to perform statistical analysis
17+
directly without R.
1718
1819
--new ./new-node-binary new node binary (required)
1920
--old ./old-node-binary old node binary (required)
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2425
repeated)
2526
--set variable=value set benchmark variable (can be repeated)
2627
--no-progress don't show benchmark progress indicator
28+
--analyze perform statistical analysis after benchmarks
29+
complete (Welch's t-test, effect size) instead
30+
of printing csv output
31+
--scale 1000 rate-to-integer multiplier for histogram
32+
precision when using --analyze (default: 1000)
33+
--max-regression N exit with code 1 if any statistically
34+
significant regression exceeds N% (implies
35+
--analyze)
2736
2837
Examples:
2938
--set CPUSET=0 Runs benchmarks on CPU core 0.
3039
--set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2.
3140
3241
Note: The CPUSET format should match the specifications of the 'taskset' command
33-
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress']});
42+
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress','analyze']});
3443

3544
if(!cli.optional.new||!cli.optional.old){
3645
cli.abort(cli.usage);
3746
}
3847

3948
constbinaries=['old','new'];
4049
construns=cli.optional.runs ? parseInt(cli.optional.runs,10) : 30;
50+
constmaxRegression=cli.optional['max-regression'] ?
51+
parseFloat(cli.optional['max-regression']) :
52+
0;
53+
constanalyze=!!cli.optional.analyze||maxRegression>0;
54+
constscale=cli.optional.scale ? parseInt(cli.optional.scale,10) : 1000;
4155
constbenchmarks=cli.benchmarks();
4256

4357
if(benchmarks.length===0){
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) {
4660
return;
4761
}
4862

63+
// When --analyze is set, collect results for statistical analysis.
64+
constresults=analyze ? newMap() : null;
65+
4966
// Create queue from the benchmarks list such both node versions are tested
5067
// `runs` amount of times each.
5168
// Note: BenchmarkProgress relies on this order to estimate
@@ -61,15 +78,17 @@ for (const filename of benchmarks) {
6178
}
6279
// queue.length = binary.length * runs * benchmarks.length
6380

64-
// Print csv header
65-
console.log('"binary","filename","configuration","rate","time"');
81+
// Print csv header (unless analyzing inline).
82+
if(!analyze){
83+
console.log('"binary","filename","configuration","rate","time"');
84+
}
6685

6786
constkStartOfQueue=0;
6887

6988
constshowProgress=!cli.optional['no-progress'];
7089
letprogress;
7190
if(showProgress){
72-
progress=newBenchmarkProgress(queue,benchmarks);
91+
progress=newBenchmarkProgress(queue,benchmarks,{ analyze });
7392
progress.startQueue(kStartOfQueue);
7493
}
7594

@@ -99,11 +118,20 @@ if (showProgress) {
99118
conf+=` ${key}=${inspect(data.conf[key])}`;
100119
}
101120
conf=conf.slice(1);
102-
// Escape quotes (") for correct csv formatting
103-
conf=conf.replace(/"/g,'""');
104121

105-
console.log(`"${job.binary}","${job.filename}","${conf}",`+
106-
`${data.rate},${data.time}`);
122+
if(analyze){
123+
// Collect results for post-run analysis.
124+
constname=`${job.filename}${conf}`;
125+
if(!results.has(name)){
126+
results.set(name,{old: [],new: []});
127+
}
128+
results.get(name)[job.binary].push(data.rate);
129+
}else{
130+
// Escape quotes (") for correct csv formatting
131+
conf=conf.replace(/"/g,'""');
132+
console.log(`"${job.binary}","${job.filename}","${conf}",`+
133+
`${data.rate},${data.time}`);
134+
}
107135
if(showProgress){
108136
// One item in the subqueue has been completed.
109137
progress.completeConfig(data);
@@ -125,6 +153,199 @@ if (showProgress) {
125153
// If there are more benchmarks execute the next
126154
if(i+1<queue.length){
127155
recursive(i+1);
156+
}elseif(analyze){
157+
printAnalysis(results,scale,maxRegression);
128158
}
129159
});
130160
})(kStartOfQueue);
161+
162+
functionprintAnalysis(results,scale,maxRegression){
163+
const{ createHistogram }=require('node:perf_hooks');
164+
165+
// Build per-benchmark histograms and run statistical tests.
166+
constrows=[];
167+
letmaxNameLen=0;
168+
169+
letskipped=0;
170+
171+
for(const[name,{old: oldRates,new: newRates}]ofresults){
172+
if(oldRates.length<2||newRates.length<2){
173+
skipped++;
174+
continue;
175+
}
176+
177+
consthOld=createHistogram({figures: 3});
178+
consthNew=createHistogram({figures: 3});
179+
180+
for(constrofoldRates)hOld.record(Math.max(1,Math.round(r*scale)));
181+
for(constrofnewRates)hNew.record(Math.max(1,Math.round(r*scale)));
182+
183+
constoldMean=oldRates.reduce((a,b)=>a+b,0)/oldRates.length;
184+
constnewMean=newRates.reduce((a,b)=>a+b,0)/newRates.length;
185+
constimprovement=((newMean-oldMean)/oldMean)*100;
186+
187+
// Query the three confidence levels. The p-value and t-statistic
188+
// are the same regardless of the confidence level, so we extract
189+
// them from the first result.
190+
constw95=hOld.welchTest(hNew,{confidence: 0.95});
191+
constw99=hOld.welchTest(hNew,{confidence: 0.99});
192+
constw999=hOld.welchTest(hNew,{confidence: 0.999});
193+
194+
// Significance stars matching compare.R convention.
195+
letstars='';
196+
if(w95.pValue<0.001)stars='***';
197+
elseif(w95.pValue<0.01)stars=' **';
198+
elseif(w95.pValue<0.05)stars=' *';
199+
200+
// Confidence intervals expressed as percentage of the old mean.
201+
constciPct=(w)=>{
202+
consthalf=
203+
(w.confidenceInterval.upper-w.confidenceInterval.lower)/2;
204+
return(half/(oldMean*scale))*100;
205+
};
206+
207+
rows.push({
208+
name,
209+
stars,
210+
improvement,
211+
ci95: ciPct(w95),
212+
ci99: ciPct(w99),
213+
ci999: ciPct(w999),
214+
pValue: w95.pValue,
215+
});
216+
217+
if(name.length>maxNameLen)maxNameLen=name.length;
218+
}
219+
220+
// Print header.
221+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
222+
constrpad=(s,n)=>' '.repeat(Math.max(0,n-s.length))+s;
223+
224+
console.log(`${pad('',maxNameLen)} confidence`+
225+
` improvement accuracy (*) (**) (***)`);
226+
227+
for(constrowofrows){
228+
constimp=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
229+
console.log(
230+
`${pad(row.name,maxNameLen)}${pad(row.stars,10)}`+
231+
` ${rpad(imp,11)}`+
232+
` Β±${row.ci95.toFixed(2)}%`+
233+
` Β±${row.ci99.toFixed(2)}%`+
234+
` Β±${row.ci999.toFixed(2)}%`,
235+
);
236+
}
237+
238+
if(skipped>0){
239+
console.log('');
240+
console.log(
241+
`Note: ${skipped} configuration${skipped===1 ? ' was' : 's were'}`+
242+
` skipped because Welch's t-test requires at least 2 samples per`+
243+
` binary. Use --runs 2 or higher.`,
244+
);
245+
}
246+
247+
// --- Bar chart visualization ---
248+
printChart(rows,maxNameLen);
249+
250+
console.log('');
251+
console.log(
252+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n`+
253+
`Use --scale to adjust precision if needed.\n`,
254+
);
255+
console.log(
256+
`Be aware that when doing many comparisons the risk of a false-positive\n`+
257+
`result increases. In this case, there are ${rows.length} comparisons, `+
258+
`you can thus\nexpect the following amount of false-positive results:\n`+
259+
` ${(rows.length*0.05).toFixed(2)} false positives, when considering `+
260+
`a 5% risk acceptance (*, **, ***),\n`+
261+
` ${(rows.length*0.01).toFixed(2)} false positives, when considering `+
262+
`a 1% risk acceptance (**, ***),\n`+
263+
` ${(rows.length*0.001).toFixed(2)} false positives, when considering `+
264+
`a 0.1% risk acceptance (***)`,
265+
);
266+
267+
// Gate: exit with error if any significant regression exceeds the limit.
268+
if(maxRegression>0){
269+
constfailures=rows.filter(
270+
(r)=>r.stars.trim()!==''&&r.improvement<-maxRegression,
271+
);
272+
if(failures.length>0){
273+
console.log('');
274+
console.log(
275+
`FAIL: ${failures.length} benchmark${failures.length===1 ? '' : 's'}`+
276+
` showed a statistically significant regression exceeding`+
277+
` ${maxRegression}%:`,
278+
);
279+
for(constfoffailures){
280+
console.log(` ${f.name}${f.improvement.toFixed(2)}%`);
281+
}
282+
process.exitCode=1;
283+
}
284+
}
285+
}
286+
287+
functionprintChart(rows,maxNameLen){
288+
if(rows.length===0)return;
289+
290+
// Determine the chart scale from the data. The bar region covers
291+
// the range [-maxAbs, +maxAbs] so the zero line sits in the center.
292+
constbarWidth=40;
293+
consthalfWidth=barWidth/2;
294+
letmaxAbs=0;
295+
for(constrowofrows){
296+
constextent=Math.abs(row.improvement)+row.ci95;
297+
if(extent>maxAbs)maxAbs=extent;
298+
}
299+
if(maxAbs===0)maxAbs=1;
300+
301+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
302+
303+
// Scale axis labels.
304+
constaxisLeft=`-${maxAbs.toFixed(1)}%`;
305+
constaxisRight=`+${maxAbs.toFixed(1)}%`;
306+
constaxisCenter='0%';
307+
308+
// Print axis header.
309+
constlabelPad=maxNameLen+5;
310+
constleftLabel=' '.repeat(labelPad)+
311+
axisLeft+
312+
' '.repeat(Math.max(0,halfWidth-axisLeft.length-Math.floor(axisCenter.length/2)))+
313+
axisCenter+
314+
' '.repeat(Math.max(0,halfWidth-Math.ceil(axisCenter.length/2)-axisRight.length))+
315+
axisRight;
316+
console.log('');
317+
console.log(leftLabel);
318+
319+
for(constrowofrows){
320+
constimp=row.improvement;
321+
constci=row.ci95;
322+
323+
// Position of the improvement value in the bar region [0, barWidth].
324+
constcenter=halfWidth;
325+
constimpPos=center+(imp/maxAbs)*halfWidth;
326+
327+
// CI extent in bar positions.
328+
constciLeft=center+((imp-ci)/maxAbs)*halfWidth;
329+
constciRight=center+((imp+ci)/maxAbs)*halfWidth;
330+
331+
// Build the bar character by character.
332+
constchars=[];
333+
for(letx=0;x<barWidth;x++){
334+
constpos=x+0.5;// Center of this character cell.
335+
if(x===Math.floor(center)){
336+
chars.push('|');
337+
}elseif((imp>=0&&pos>center&&pos<=impPos)||
338+
(imp<0&&pos<center&&pos>=impPos)){
339+
chars.push(row.stars ? '\u2588' : '\u2593');// solid or dark shade
340+
}elseif(pos>=ciLeft&&pos<=ciRight){
341+
chars.push('\u2591');// Light shade for CI region
342+
}else{
343+
chars.push(' ');
344+
}
345+
}
346+
347+
constlabel=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
348+
constsig=row.stars.trim();
349+
console.log(`${pad(row.name,maxNameLen)}${chars.join('')}${label}${sig}`);
350+
}
351+
}

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 74234ee

Browse files
jasnelladuh95
authored andcommitted
benchmark: add --analyze mode to compare.js
Add an --analyze flag that performs statistical analysis directly after benchmarks complete, eliminating the need for R and compare.R. When --analyze is specified, compare.js collects the rate data during the run and prints a statistical summary table instead of CSV output. The table matches the format of compare.R: improvement percentage, significance stars (* p<0.05, ** p<0.01, *** p<0.001), and confidence intervals at three risk levels. Also adds a --max-regression N option that causes the compare.js to exit with 1 (error) when the `--new` is N% slower. Useful for CI use to detect regressions. Uses the histogram API's welchTest() and cohensD() methods introduced in the previous commit. Benchmark rates are scaled to integers for HdrHistogram recording; the --scale option (default 1000) controls the multiplier for precision. Usage: node benchmark/compare.js --old ./node-old --new ./node-new \ --analyze url Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 9e8e908 commit 74234ee

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

β€Žbenchmark/_benchmark_progress.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function getTime(diff) {
2525
// A run is an item in the job queue: { binary, filename, iter }
2626
// A config is an item in the subqueue: { binary, filename, iter, configs }
2727
classBenchmarkProgress{
28-
constructor(queue,benchmarks){
28+
constructor(queue,benchmarks,options={}){
2929
this.queue=queue;// Scheduled runs.
3030
this.benchmarks=benchmarks;// Filenames of scheduled benchmarks.
31+
this.analyze=!!options.analyze;// stdout is not piped, but unused.
3132
this.completedRuns=0;// Number of completed runs.
3233
this.scheduledRuns=queue.length;// Number of scheduled runs.
3334
// Time when starting to run benchmarks.
@@ -107,7 +108,10 @@ class BenchmarkProgress {
107108
}
108109

109110
updateProgress(){
110-
if(!process.stderr.isTTY||process.stdout.isTTY){
111+
// Progress renders on stderr when stdout is piped (not a TTY).
112+
// In --analyze mode, stdout is the terminal but is unused during
113+
// the run, so treat it the same as piped.
114+
if(!process.stderr.isTTY||(process.stdout.isTTY&&!this.analyze)){
111115
return;
112116
}
113117
readline.clearLine(process.stderr);

β€Žbenchmark/compare.jsβ€Ž

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
1313
Run each benchmark in the <category> directory many times using two different
1414
node versions. More than one <category> directory can be specified.
1515
The output is formatted as csv, which can be processed using for
16-
example 'compare.R'.
16+
example 'compare.R'. Use --analyze to perform statistical analysis
17+
directly without R.
1718
1819
--new ./new-node-binary new node binary (required)
1920
--old ./old-node-binary old node binary (required)
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2425
repeated)
2526
--set variable=value set benchmark variable (can be repeated)
2627
--no-progress don't show benchmark progress indicator
28+
--analyze perform statistical analysis after benchmarks
29+
complete (Welch's t-test, effect size) instead
30+
of printing csv output
31+
--scale 1000 rate-to-integer multiplier for histogram
32+
precision when using --analyze (default: 1000)
33+
--max-regression N exit with code 1 if any statistically
34+
significant regression exceeds N% (implies
35+
--analyze)
2736
2837
Examples:
2938
--set CPUSET=0 Runs benchmarks on CPU core 0.
3039
--set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2.
3140
3241
Note: The CPUSET format should match the specifications of the 'taskset' command
33-
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress']});
42+
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress','analyze']});
3443

3544
if(!cli.optional.new||!cli.optional.old){
3645
cli.abort(cli.usage);
3746
}
3847

3948
constbinaries=['old','new'];
4049
construns=cli.optional.runs ? parseInt(cli.optional.runs,10) : 30;
50+
constmaxRegression=cli.optional['max-regression'] ?
51+
parseFloat(cli.optional['max-regression']) :
52+
0;
53+
constanalyze=!!cli.optional.analyze||maxRegression>0;
54+
constscale=cli.optional.scale ? parseInt(cli.optional.scale,10) : 1000;
4155
constbenchmarks=cli.benchmarks();
4256

4357
if(benchmarks.length===0){
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) {
4660
return;
4761
}
4862

63+
// When --analyze is set, collect results for statistical analysis.
64+
constresults=analyze ? newMap() : null;
65+
4966
// Create queue from the benchmarks list such both node versions are tested
5067
// `runs` amount of times each.
5168
// Note: BenchmarkProgress relies on this order to estimate
@@ -61,15 +78,17 @@ for (const filename of benchmarks) {
6178
}
6279
// queue.length = binary.length * runs * benchmarks.length
6380

64-
// Print csv header
65-
console.log('"binary","filename","configuration","rate","time"');
81+
// Print csv header (unless analyzing inline).
82+
if(!analyze){
83+
console.log('"binary","filename","configuration","rate","time"');
84+
}
6685

6786
constkStartOfQueue=0;
6887

6988
constshowProgress=!cli.optional['no-progress'];
7089
letprogress;
7190
if(showProgress){
72-
progress=newBenchmarkProgress(queue,benchmarks);
91+
progress=newBenchmarkProgress(queue,benchmarks,{ analyze });
7392
progress.startQueue(kStartOfQueue);
7493
}
7594

@@ -99,11 +118,20 @@ if (showProgress) {
99118
conf+=` ${key}=${inspect(data.conf[key])}`;
100119
}
101120
conf=conf.slice(1);
102-
// Escape quotes (") for correct csv formatting
103-
conf=conf.replace(/"/g,'""');
104121

105-
console.log(`"${job.binary}","${job.filename}","${conf}",`+
106-
`${data.rate},${data.time}`);
122+
if(analyze){
123+
// Collect results for post-run analysis.
124+
constname=`${job.filename}${conf}`;
125+
if(!results.has(name)){
126+
results.set(name,{old: [],new: []});
127+
}
128+
results.get(name)[job.binary].push(data.rate);
129+
}else{
130+
// Escape quotes (") for correct csv formatting
131+
conf=conf.replace(/"/g,'""');
132+
console.log(`"${job.binary}","${job.filename}","${conf}",`+
133+
`${data.rate},${data.time}`);
134+
}
107135
if(showProgress){
108136
// One item in the subqueue has been completed.
109137
progress.completeConfig(data);
@@ -125,6 +153,199 @@ if (showProgress) {
125153
// If there are more benchmarks execute the next
126154
if(i+1<queue.length){
127155
recursive(i+1);
156+
}elseif(analyze){
157+
printAnalysis(results,scale,maxRegression);
128158
}
129159
});
130160
})(kStartOfQueue);
161+
162+
functionprintAnalysis(results,scale,maxRegression){
163+
const{ createHistogram }=require('node:perf_hooks');
164+
165+
// Build per-benchmark histograms and run statistical tests.
166+
constrows=[];
167+
letmaxNameLen=0;
168+
169+
letskipped=0;
170+
171+
for(const[name,{old: oldRates,new: newRates}]ofresults){
172+
if(oldRates.length<2||newRates.length<2){
173+
skipped++;
174+
continue;
175+
}
176+
177+
consthOld=createHistogram({figures: 3});
178+
consthNew=createHistogram({figures: 3});
179+
180+
for(constrofoldRates)hOld.record(Math.max(1,Math.round(r*scale)));
181+
for(constrofnewRates)hNew.record(Math.max(1,Math.round(r*scale)));
182+
183+
constoldMean=oldRates.reduce((a,b)=>a+b,0)/oldRates.length;
184+
constnewMean=newRates.reduce((a,b)=>a+b,0)/newRates.length;
185+
constimprovement=((newMean-oldMean)/oldMean)*100;
186+
187+
// Query the three confidence levels. The p-value and t-statistic
188+
// are the same regardless of the confidence level, so we extract
189+
// them from the first result.
190+
constw95=hOld.welchTest(hNew,{confidence: 0.95});
191+
constw99=hOld.welchTest(hNew,{confidence: 0.99});
192+
constw999=hOld.welchTest(hNew,{confidence: 0.999});
193+
194+
// Significance stars matching compare.R convention.
195+
letstars='';
196+
if(w95.pValue<0.001)stars='***';
197+
elseif(w95.pValue<0.01)stars=' **';
198+
elseif(w95.pValue<0.05)stars=' *';
199+
200+
// Confidence intervals expressed as percentage of the old mean.
201+
constciPct=(w)=>{
202+
consthalf=
203+
(w.confidenceInterval.upper-w.confidenceInterval.lower)/2;
204+
return(half/(oldMean*scale))*100;
205+
};
206+
207+
rows.push({
208+
name,
209+
stars,
210+
improvement,
211+
ci95: ciPct(w95),
212+
ci99: ciPct(w99),
213+
ci999: ciPct(w999),
214+
pValue: w95.pValue,
215+
});
216+
217+
if(name.length>maxNameLen)maxNameLen=name.length;
218+
}
219+
220+
// Print header.
221+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
222+
constrpad=(s,n)=>' '.repeat(Math.max(0,n-s.length))+s;
223+
224+
console.log(`${pad('',maxNameLen)} confidence`+
225+
` improvement accuracy (*) (**) (***)`);
226+
227+
for(constrowofrows){
228+
constimp=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
229+
console.log(
230+
`${pad(row.name,maxNameLen)}${pad(row.stars,10)}`+
231+
` ${rpad(imp,11)}`+
232+
` Β±${row.ci95.toFixed(2)}%`+
233+
` Β±${row.ci99.toFixed(2)}%`+
234+
` Β±${row.ci999.toFixed(2)}%`,
235+
);
236+
}
237+
238+
if(skipped>0){
239+
console.log('');
240+
console.log(
241+
`Note: ${skipped} configuration${skipped===1 ? ' was' : 's were'}`+
242+
` skipped because Welch's t-test requires at least 2 samples per`+
243+
` binary. Use --runs 2 or higher.`,
244+
);
245+
}
246+
247+
// --- Bar chart visualization ---
248+
printChart(rows,maxNameLen);
249+
250+
console.log('');
251+
console.log(
252+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n`+
253+
`Use --scale to adjust precision if needed.\n`,
254+
);
255+
console.log(
256+
`Be aware that when doing many comparisons the risk of a false-positive\n`+
257+
`result increases. In this case, there are ${rows.length} comparisons, `+
258+
`you can thus\nexpect the following amount of false-positive results:\n`+
259+
` ${(rows.length*0.05).toFixed(2)} false positives, when considering `+
260+
`a 5% risk acceptance (*, **, ***),\n`+
261+
` ${(rows.length*0.01).toFixed(2)} false positives, when considering `+
262+
`a 1% risk acceptance (**, ***),\n`+
263+
` ${(rows.length*0.001).toFixed(2)} false positives, when considering `+
264+
`a 0.1% risk acceptance (***)`,
265+
);
266+
267+
// Gate: exit with error if any significant regression exceeds the limit.
268+
if(maxRegression>0){
269+
constfailures=rows.filter(
270+
(r)=>r.stars.trim()!==''&&r.improvement<-maxRegression,
271+
);
272+
if(failures.length>0){
273+
console.log('');
274+
console.log(
275+
`FAIL: ${failures.length} benchmark${failures.length===1 ? '' : 's'}`+
276+
` showed a statistically significant regression exceeding`+
277+
` ${maxRegression}%:`,
278+
);
279+
for(constfoffailures){
280+
console.log(` ${f.name}${f.improvement.toFixed(2)}%`);
281+
}
282+
process.exitCode=1;
283+
}
284+
}
285+
}
286+
287+
functionprintChart(rows,maxNameLen){
288+
if(rows.length===0)return;
289+
290+
// Determine the chart scale from the data. The bar region covers
291+
// the range [-maxAbs, +maxAbs] so the zero line sits in the center.
292+
constbarWidth=40;
293+
consthalfWidth=barWidth/2;
294+
letmaxAbs=0;
295+
for(constrowofrows){
296+
constextent=Math.abs(row.improvement)+row.ci95;
297+
if(extent>maxAbs)maxAbs=extent;
298+
}
299+
if(maxAbs===0)maxAbs=1;
300+
301+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
302+
303+
// Scale axis labels.
304+
constaxisLeft=`-${maxAbs.toFixed(1)}%`;
305+
constaxisRight=`+${maxAbs.toFixed(1)}%`;
306+
constaxisCenter='0%';
307+
308+
// Print axis header.
309+
constlabelPad=maxNameLen+5;
310+
constleftLabel=' '.repeat(labelPad)+
311+
axisLeft+
312+
' '.repeat(Math.max(0,halfWidth-axisLeft.length-Math.floor(axisCenter.length/2)))+
313+
axisCenter+
314+
' '.repeat(Math.max(0,halfWidth-Math.ceil(axisCenter.length/2)-axisRight.length))+
315+
axisRight;
316+
console.log('');
317+
console.log(leftLabel);
318+
319+
for(constrowofrows){
320+
constimp=row.improvement;
321+
constci=row.ci95;
322+
323+
// Position of the improvement value in the bar region [0, barWidth].
324+
constcenter=halfWidth;
325+
constimpPos=center+(imp/maxAbs)*halfWidth;
326+
327+
// CI extent in bar positions.
328+
constciLeft=center+((imp-ci)/maxAbs)*halfWidth;
329+
constciRight=center+((imp+ci)/maxAbs)*halfWidth;
330+
331+
// Build the bar character by character.
332+
constchars=[];
333+
for(letx=0;x<barWidth;x++){
334+
constpos=x+0.5;// Center of this character cell.
335+
if(x===Math.floor(center)){
336+
chars.push('|');
337+
}elseif((imp>=0&&pos>center&&pos<=impPos)||
338+
(imp<0&&pos<center&&pos>=impPos)){
339+
chars.push(row.stars ? '\u2588' : '\u2593');// solid or dark shade
340+
}elseif(pos>=ciLeft&&pos<=ciRight){
341+
chars.push('\u2591');// Light shade for CI region
342+
}else{
343+
chars.push(' ');
344+
}
345+
}
346+
347+
constlabel=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
348+
constsig=row.stars.trim();
349+
console.log(`${pad(row.name,maxNameLen)}${chars.join('')}${label}${sig}`);
350+
}
351+
}

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 74234ee

Browse files
jasnelladuh95
authored andcommitted
benchmark: add --analyze mode to compare.js
Add an --analyze flag that performs statistical analysis directly after benchmarks complete, eliminating the need for R and compare.R. When --analyze is specified, compare.js collects the rate data during the run and prints a statistical summary table instead of CSV output. The table matches the format of compare.R: improvement percentage, significance stars (* p<0.05, ** p<0.01, *** p<0.001), and confidence intervals at three risk levels. Also adds a --max-regression N option that causes the compare.js to exit with 1 (error) when the `--new` is N% slower. Useful for CI use to detect regressions. Uses the histogram API's welchTest() and cohensD() methods introduced in the previous commit. Benchmark rates are scaled to integers for HdrHistogram recording; the --scale option (default 1000) controls the multiplier for precision. Usage: node benchmark/compare.js --old ./node-old --new ./node-new \ --analyze url Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 9e8e908 commit 74234ee

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

β€Žbenchmark/_benchmark_progress.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function getTime(diff) {
2525
// A run is an item in the job queue: { binary, filename, iter }
2626
// A config is an item in the subqueue: { binary, filename, iter, configs }
2727
classBenchmarkProgress{
28-
constructor(queue,benchmarks){
28+
constructor(queue,benchmarks,options={}){
2929
this.queue=queue;// Scheduled runs.
3030
this.benchmarks=benchmarks;// Filenames of scheduled benchmarks.
31+
this.analyze=!!options.analyze;// stdout is not piped, but unused.
3132
this.completedRuns=0;// Number of completed runs.
3233
this.scheduledRuns=queue.length;// Number of scheduled runs.
3334
// Time when starting to run benchmarks.
@@ -107,7 +108,10 @@ class BenchmarkProgress {
107108
}
108109

109110
updateProgress(){
110-
if(!process.stderr.isTTY||process.stdout.isTTY){
111+
// Progress renders on stderr when stdout is piped (not a TTY).
112+
// In --analyze mode, stdout is the terminal but is unused during
113+
// the run, so treat it the same as piped.
114+
if(!process.stderr.isTTY||(process.stdout.isTTY&&!this.analyze)){
111115
return;
112116
}
113117
readline.clearLine(process.stderr);

β€Žbenchmark/compare.jsβ€Ž

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
1313
Run each benchmark in the <category> directory many times using two different
1414
node versions. More than one <category> directory can be specified.
1515
The output is formatted as csv, which can be processed using for
16-
example 'compare.R'.
16+
example 'compare.R'. Use --analyze to perform statistical analysis
17+
directly without R.
1718
1819
--new ./new-node-binary new node binary (required)
1920
--old ./old-node-binary old node binary (required)
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2425
repeated)
2526
--set variable=value set benchmark variable (can be repeated)
2627
--no-progress don't show benchmark progress indicator
28+
--analyze perform statistical analysis after benchmarks
29+
complete (Welch's t-test, effect size) instead
30+
of printing csv output
31+
--scale 1000 rate-to-integer multiplier for histogram
32+
precision when using --analyze (default: 1000)
33+
--max-regression N exit with code 1 if any statistically
34+
significant regression exceeds N% (implies
35+
--analyze)
2736
2837
Examples:
2938
--set CPUSET=0 Runs benchmarks on CPU core 0.
3039
--set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2.
3140
3241
Note: The CPUSET format should match the specifications of the 'taskset' command
33-
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress']});
42+
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress','analyze']});
3443

3544
if(!cli.optional.new||!cli.optional.old){
3645
cli.abort(cli.usage);
3746
}
3847

3948
constbinaries=['old','new'];
4049
construns=cli.optional.runs ? parseInt(cli.optional.runs,10) : 30;
50+
constmaxRegression=cli.optional['max-regression'] ?
51+
parseFloat(cli.optional['max-regression']) :
52+
0;
53+
constanalyze=!!cli.optional.analyze||maxRegression>0;
54+
constscale=cli.optional.scale ? parseInt(cli.optional.scale,10) : 1000;
4155
constbenchmarks=cli.benchmarks();
4256

4357
if(benchmarks.length===0){
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) {
4660
return;
4761
}
4862

63+
// When --analyze is set, collect results for statistical analysis.
64+
constresults=analyze ? newMap() : null;
65+
4966
// Create queue from the benchmarks list such both node versions are tested
5067
// `runs` amount of times each.
5168
// Note: BenchmarkProgress relies on this order to estimate
@@ -61,15 +78,17 @@ for (const filename of benchmarks) {
6178
}
6279
// queue.length = binary.length * runs * benchmarks.length
6380

64-
// Print csv header
65-
console.log('"binary","filename","configuration","rate","time"');
81+
// Print csv header (unless analyzing inline).
82+
if(!analyze){
83+
console.log('"binary","filename","configuration","rate","time"');
84+
}
6685

6786
constkStartOfQueue=0;
6887

6988
constshowProgress=!cli.optional['no-progress'];
7089
letprogress;
7190
if(showProgress){
72-
progress=newBenchmarkProgress(queue,benchmarks);
91+
progress=newBenchmarkProgress(queue,benchmarks,{ analyze });
7392
progress.startQueue(kStartOfQueue);
7493
}
7594

@@ -99,11 +118,20 @@ if (showProgress) {
99118
conf+=` ${key}=${inspect(data.conf[key])}`;
100119
}
101120
conf=conf.slice(1);
102-
// Escape quotes (") for correct csv formatting
103-
conf=conf.replace(/"/g,'""');
104121

105-
console.log(`"${job.binary}","${job.filename}","${conf}",`+
106-
`${data.rate},${data.time}`);
122+
if(analyze){
123+
// Collect results for post-run analysis.
124+
constname=`${job.filename}${conf}`;
125+
if(!results.has(name)){
126+
results.set(name,{old: [],new: []});
127+
}
128+
results.get(name)[job.binary].push(data.rate);
129+
}else{
130+
// Escape quotes (") for correct csv formatting
131+
conf=conf.replace(/"/g,'""');
132+
console.log(`"${job.binary}","${job.filename}","${conf}",`+
133+
`${data.rate},${data.time}`);
134+
}
107135
if(showProgress){
108136
// One item in the subqueue has been completed.
109137
progress.completeConfig(data);
@@ -125,6 +153,199 @@ if (showProgress) {
125153
// If there are more benchmarks execute the next
126154
if(i+1<queue.length){
127155
recursive(i+1);
156+
}elseif(analyze){
157+
printAnalysis(results,scale,maxRegression);
128158
}
129159
});
130160
})(kStartOfQueue);
161+
162+
functionprintAnalysis(results,scale,maxRegression){
163+
const{ createHistogram }=require('node:perf_hooks');
164+
165+
// Build per-benchmark histograms and run statistical tests.
166+
constrows=[];
167+
letmaxNameLen=0;
168+
169+
letskipped=0;
170+
171+
for(const[name,{old: oldRates,new: newRates}]ofresults){
172+
if(oldRates.length<2||newRates.length<2){
173+
skipped++;
174+
continue;
175+
}
176+
177+
consthOld=createHistogram({figures: 3});
178+
consthNew=createHistogram({figures: 3});
179+
180+
for(constrofoldRates)hOld.record(Math.max(1,Math.round(r*scale)));
181+
for(constrofnewRates)hNew.record(Math.max(1,Math.round(r*scale)));
182+
183+
constoldMean=oldRates.reduce((a,b)=>a+b,0)/oldRates.length;
184+
constnewMean=newRates.reduce((a,b)=>a+b,0)/newRates.length;
185+
constimprovement=((newMean-oldMean)/oldMean)*100;
186+
187+
// Query the three confidence levels. The p-value and t-statistic
188+
// are the same regardless of the confidence level, so we extract
189+
// them from the first result.
190+
constw95=hOld.welchTest(hNew,{confidence: 0.95});
191+
constw99=hOld.welchTest(hNew,{confidence: 0.99});
192+
constw999=hOld.welchTest(hNew,{confidence: 0.999});
193+
194+
// Significance stars matching compare.R convention.
195+
letstars='';
196+
if(w95.pValue<0.001)stars='***';
197+
elseif(w95.pValue<0.01)stars=' **';
198+
elseif(w95.pValue<0.05)stars=' *';
199+
200+
// Confidence intervals expressed as percentage of the old mean.
201+
constciPct=(w)=>{
202+
consthalf=
203+
(w.confidenceInterval.upper-w.confidenceInterval.lower)/2;
204+
return(half/(oldMean*scale))*100;
205+
};
206+
207+
rows.push({
208+
name,
209+
stars,
210+
improvement,
211+
ci95: ciPct(w95),
212+
ci99: ciPct(w99),
213+
ci999: ciPct(w999),
214+
pValue: w95.pValue,
215+
});
216+
217+
if(name.length>maxNameLen)maxNameLen=name.length;
218+
}
219+
220+
// Print header.
221+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
222+
constrpad=(s,n)=>' '.repeat(Math.max(0,n-s.length))+s;
223+
224+
console.log(`${pad('',maxNameLen)} confidence`+
225+
` improvement accuracy (*) (**) (***)`);
226+
227+
for(constrowofrows){
228+
constimp=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
229+
console.log(
230+
`${pad(row.name,maxNameLen)}${pad(row.stars,10)}`+
231+
` ${rpad(imp,11)}`+
232+
` Β±${row.ci95.toFixed(2)}%`+
233+
` Β±${row.ci99.toFixed(2)}%`+
234+
` Β±${row.ci999.toFixed(2)}%`,
235+
);
236+
}
237+
238+
if(skipped>0){
239+
console.log('');
240+
console.log(
241+
`Note: ${skipped} configuration${skipped===1 ? ' was' : 's were'}`+
242+
` skipped because Welch's t-test requires at least 2 samples per`+
243+
` binary. Use --runs 2 or higher.`,
244+
);
245+
}
246+
247+
// --- Bar chart visualization ---
248+
printChart(rows,maxNameLen);
249+
250+
console.log('');
251+
console.log(
252+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n`+
253+
`Use --scale to adjust precision if needed.\n`,
254+
);
255+
console.log(
256+
`Be aware that when doing many comparisons the risk of a false-positive\n`+
257+
`result increases. In this case, there are ${rows.length} comparisons, `+
258+
`you can thus\nexpect the following amount of false-positive results:\n`+
259+
` ${(rows.length*0.05).toFixed(2)} false positives, when considering `+
260+
`a 5% risk acceptance (*, **, ***),\n`+
261+
` ${(rows.length*0.01).toFixed(2)} false positives, when considering `+
262+
`a 1% risk acceptance (**, ***),\n`+
263+
` ${(rows.length*0.001).toFixed(2)} false positives, when considering `+
264+
`a 0.1% risk acceptance (***)`,
265+
);
266+
267+
// Gate: exit with error if any significant regression exceeds the limit.
268+
if(maxRegression>0){
269+
constfailures=rows.filter(
270+
(r)=>r.stars.trim()!==''&&r.improvement<-maxRegression,
271+
);
272+
if(failures.length>0){
273+
console.log('');
274+
console.log(
275+
`FAIL: ${failures.length} benchmark${failures.length===1 ? '' : 's'}`+
276+
` showed a statistically significant regression exceeding`+
277+
` ${maxRegression}%:`,
278+
);
279+
for(constfoffailures){
280+
console.log(` ${f.name}${f.improvement.toFixed(2)}%`);
281+
}
282+
process.exitCode=1;
283+
}
284+
}
285+
}
286+
287+
functionprintChart(rows,maxNameLen){
288+
if(rows.length===0)return;
289+
290+
// Determine the chart scale from the data. The bar region covers
291+
// the range [-maxAbs, +maxAbs] so the zero line sits in the center.
292+
constbarWidth=40;
293+
consthalfWidth=barWidth/2;
294+
letmaxAbs=0;
295+
for(constrowofrows){
296+
constextent=Math.abs(row.improvement)+row.ci95;
297+
if(extent>maxAbs)maxAbs=extent;
298+
}
299+
if(maxAbs===0)maxAbs=1;
300+
301+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
302+
303+
// Scale axis labels.
304+
constaxisLeft=`-${maxAbs.toFixed(1)}%`;
305+
constaxisRight=`+${maxAbs.toFixed(1)}%`;
306+
constaxisCenter='0%';
307+
308+
// Print axis header.
309+
constlabelPad=maxNameLen+5;
310+
constleftLabel=' '.repeat(labelPad)+
311+
axisLeft+
312+
' '.repeat(Math.max(0,halfWidth-axisLeft.length-Math.floor(axisCenter.length/2)))+
313+
axisCenter+
314+
' '.repeat(Math.max(0,halfWidth-Math.ceil(axisCenter.length/2)-axisRight.length))+
315+
axisRight;
316+
console.log('');
317+
console.log(leftLabel);
318+
319+
for(constrowofrows){
320+
constimp=row.improvement;
321+
constci=row.ci95;
322+
323+
// Position of the improvement value in the bar region [0, barWidth].
324+
constcenter=halfWidth;
325+
constimpPos=center+(imp/maxAbs)*halfWidth;
326+
327+
// CI extent in bar positions.
328+
constciLeft=center+((imp-ci)/maxAbs)*halfWidth;
329+
constciRight=center+((imp+ci)/maxAbs)*halfWidth;
330+
331+
// Build the bar character by character.
332+
constchars=[];
333+
for(letx=0;x<barWidth;x++){
334+
constpos=x+0.5;// Center of this character cell.
335+
if(x===Math.floor(center)){
336+
chars.push('|');
337+
}elseif((imp>=0&&pos>center&&pos<=impPos)||
338+
(imp<0&&pos<center&&pos>=impPos)){
339+
chars.push(row.stars ? '\u2588' : '\u2593');// solid or dark shade
340+
}elseif(pos>=ciLeft&&pos<=ciRight){
341+
chars.push('\u2591');// Light shade for CI region
342+
}else{
343+
chars.push(' ');
344+
}
345+
}
346+
347+
constlabel=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
348+
constsig=row.stars.trim();
349+
console.log(`${pad(row.name,maxNameLen)}${chars.join('')}${label}${sig}`);
350+
}
351+
}

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 74234ee

Browse files
jasnelladuh95
authored andcommitted
benchmark: add --analyze mode to compare.js
Add an --analyze flag that performs statistical analysis directly after benchmarks complete, eliminating the need for R and compare.R. When --analyze is specified, compare.js collects the rate data during the run and prints a statistical summary table instead of CSV output. The table matches the format of compare.R: improvement percentage, significance stars (* p<0.05, ** p<0.01, *** p<0.001), and confidence intervals at three risk levels. Also adds a --max-regression N option that causes the compare.js to exit with 1 (error) when the `--new` is N% slower. Useful for CI use to detect regressions. Uses the histogram API's welchTest() and cohensD() methods introduced in the previous commit. Benchmark rates are scaled to integers for HdrHistogram recording; the --scale option (default 1000) controls the multiplier for precision. Usage: node benchmark/compare.js --old ./node-old --new ./node-new \ --analyze url Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 9e8e908 commit 74234ee

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

β€Žbenchmark/_benchmark_progress.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function getTime(diff) {
2525
// A run is an item in the job queue: { binary, filename, iter }
2626
// A config is an item in the subqueue: { binary, filename, iter, configs }
2727
classBenchmarkProgress{
28-
constructor(queue,benchmarks){
28+
constructor(queue,benchmarks,options={}){
2929
this.queue=queue;// Scheduled runs.
3030
this.benchmarks=benchmarks;// Filenames of scheduled benchmarks.
31+
this.analyze=!!options.analyze;// stdout is not piped, but unused.
3132
this.completedRuns=0;// Number of completed runs.
3233
this.scheduledRuns=queue.length;// Number of scheduled runs.
3334
// Time when starting to run benchmarks.
@@ -107,7 +108,10 @@ class BenchmarkProgress {
107108
}
108109

109110
updateProgress(){
110-
if(!process.stderr.isTTY||process.stdout.isTTY){
111+
// Progress renders on stderr when stdout is piped (not a TTY).
112+
// In --analyze mode, stdout is the terminal but is unused during
113+
// the run, so treat it the same as piped.
114+
if(!process.stderr.isTTY||(process.stdout.isTTY&&!this.analyze)){
111115
return;
112116
}
113117
readline.clearLine(process.stderr);

β€Žbenchmark/compare.jsβ€Ž

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
1313
Run each benchmark in the <category> directory many times using two different
1414
node versions. More than one <category> directory can be specified.
1515
The output is formatted as csv, which can be processed using for
16-
example 'compare.R'.
16+
example 'compare.R'. Use --analyze to perform statistical analysis
17+
directly without R.
1718
1819
--new ./new-node-binary new node binary (required)
1920
--old ./old-node-binary old node binary (required)
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2425
repeated)
2526
--set variable=value set benchmark variable (can be repeated)
2627
--no-progress don't show benchmark progress indicator
28+
--analyze perform statistical analysis after benchmarks
29+
complete (Welch's t-test, effect size) instead
30+
of printing csv output
31+
--scale 1000 rate-to-integer multiplier for histogram
32+
precision when using --analyze (default: 1000)
33+
--max-regression N exit with code 1 if any statistically
34+
significant regression exceeds N% (implies
35+
--analyze)
2736
2837
Examples:
2938
--set CPUSET=0 Runs benchmarks on CPU core 0.
3039
--set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2.
3140
3241
Note: The CPUSET format should match the specifications of the 'taskset' command
33-
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress']});
42+
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress','analyze']});
3443

3544
if(!cli.optional.new||!cli.optional.old){
3645
cli.abort(cli.usage);
3746
}
3847

3948
constbinaries=['old','new'];
4049
construns=cli.optional.runs ? parseInt(cli.optional.runs,10) : 30;
50+
constmaxRegression=cli.optional['max-regression'] ?
51+
parseFloat(cli.optional['max-regression']) :
52+
0;
53+
constanalyze=!!cli.optional.analyze||maxRegression>0;
54+
constscale=cli.optional.scale ? parseInt(cli.optional.scale,10) : 1000;
4155
constbenchmarks=cli.benchmarks();
4256

4357
if(benchmarks.length===0){
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) {
4660
return;
4761
}
4862

63+
// When --analyze is set, collect results for statistical analysis.
64+
constresults=analyze ? newMap() : null;
65+
4966
// Create queue from the benchmarks list such both node versions are tested
5067
// `runs` amount of times each.
5168
// Note: BenchmarkProgress relies on this order to estimate
@@ -61,15 +78,17 @@ for (const filename of benchmarks) {
6178
}
6279
// queue.length = binary.length * runs * benchmarks.length
6380

64-
// Print csv header
65-
console.log('"binary","filename","configuration","rate","time"');
81+
// Print csv header (unless analyzing inline).
82+
if(!analyze){
83+
console.log('"binary","filename","configuration","rate","time"');
84+
}
6685

6786
constkStartOfQueue=0;
6887

6988
constshowProgress=!cli.optional['no-progress'];
7089
letprogress;
7190
if(showProgress){
72-
progress=newBenchmarkProgress(queue,benchmarks);
91+
progress=newBenchmarkProgress(queue,benchmarks,{ analyze });
7392
progress.startQueue(kStartOfQueue);
7493
}
7594

@@ -99,11 +118,20 @@ if (showProgress) {
99118
conf+=` ${key}=${inspect(data.conf[key])}`;
100119
}
101120
conf=conf.slice(1);
102-
// Escape quotes (") for correct csv formatting
103-
conf=conf.replace(/"/g,'""');
104121

105-
console.log(`"${job.binary}","${job.filename}","${conf}",`+
106-
`${data.rate},${data.time}`);
122+
if(analyze){
123+
// Collect results for post-run analysis.
124+
constname=`${job.filename}${conf}`;
125+
if(!results.has(name)){
126+
results.set(name,{old: [],new: []});
127+
}
128+
results.get(name)[job.binary].push(data.rate);
129+
}else{
130+
// Escape quotes (") for correct csv formatting
131+
conf=conf.replace(/"/g,'""');
132+
console.log(`"${job.binary}","${job.filename}","${conf}",`+
133+
`${data.rate},${data.time}`);
134+
}
107135
if(showProgress){
108136
// One item in the subqueue has been completed.
109137
progress.completeConfig(data);
@@ -125,6 +153,199 @@ if (showProgress) {
125153
// If there are more benchmarks execute the next
126154
if(i+1<queue.length){
127155
recursive(i+1);
156+
}elseif(analyze){
157+
printAnalysis(results,scale,maxRegression);
128158
}
129159
});
130160
})(kStartOfQueue);
161+
162+
functionprintAnalysis(results,scale,maxRegression){
163+
const{ createHistogram }=require('node:perf_hooks');
164+
165+
// Build per-benchmark histograms and run statistical tests.
166+
constrows=[];
167+
letmaxNameLen=0;
168+
169+
letskipped=0;
170+
171+
for(const[name,{old: oldRates,new: newRates}]ofresults){
172+
if(oldRates.length<2||newRates.length<2){
173+
skipped++;
174+
continue;
175+
}
176+
177+
consthOld=createHistogram({figures: 3});
178+
consthNew=createHistogram({figures: 3});
179+
180+
for(constrofoldRates)hOld.record(Math.max(1,Math.round(r*scale)));
181+
for(constrofnewRates)hNew.record(Math.max(1,Math.round(r*scale)));
182+
183+
constoldMean=oldRates.reduce((a,b)=>a+b,0)/oldRates.length;
184+
constnewMean=newRates.reduce((a,b)=>a+b,0)/newRates.length;
185+
constimprovement=((newMean-oldMean)/oldMean)*100;
186+
187+
// Query the three confidence levels. The p-value and t-statistic
188+
// are the same regardless of the confidence level, so we extract
189+
// them from the first result.
190+
constw95=hOld.welchTest(hNew,{confidence: 0.95});
191+
constw99=hOld.welchTest(hNew,{confidence: 0.99});
192+
constw999=hOld.welchTest(hNew,{confidence: 0.999});
193+
194+
// Significance stars matching compare.R convention.
195+
letstars='';
196+
if(w95.pValue<0.001)stars='***';
197+
elseif(w95.pValue<0.01)stars=' **';
198+
elseif(w95.pValue<0.05)stars=' *';
199+
200+
// Confidence intervals expressed as percentage of the old mean.
201+
constciPct=(w)=>{
202+
consthalf=
203+
(w.confidenceInterval.upper-w.confidenceInterval.lower)/2;
204+
return(half/(oldMean*scale))*100;
205+
};
206+
207+
rows.push({
208+
name,
209+
stars,
210+
improvement,
211+
ci95: ciPct(w95),
212+
ci99: ciPct(w99),
213+
ci999: ciPct(w999),
214+
pValue: w95.pValue,
215+
});
216+
217+
if(name.length>maxNameLen)maxNameLen=name.length;
218+
}
219+
220+
// Print header.
221+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
222+
constrpad=(s,n)=>' '.repeat(Math.max(0,n-s.length))+s;
223+
224+
console.log(`${pad('',maxNameLen)} confidence`+
225+
` improvement accuracy (*) (**) (***)`);
226+
227+
for(constrowofrows){
228+
constimp=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
229+
console.log(
230+
`${pad(row.name,maxNameLen)}${pad(row.stars,10)}`+
231+
` ${rpad(imp,11)}`+
232+
` Β±${row.ci95.toFixed(2)}%`+
233+
` Β±${row.ci99.toFixed(2)}%`+
234+
` Β±${row.ci999.toFixed(2)}%`,
235+
);
236+
}
237+
238+
if(skipped>0){
239+
console.log('');
240+
console.log(
241+
`Note: ${skipped} configuration${skipped===1 ? ' was' : 's were'}`+
242+
` skipped because Welch's t-test requires at least 2 samples per`+
243+
` binary. Use --runs 2 or higher.`,
244+
);
245+
}
246+
247+
// --- Bar chart visualization ---
248+
printChart(rows,maxNameLen);
249+
250+
console.log('');
251+
console.log(
252+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n`+
253+
`Use --scale to adjust precision if needed.\n`,
254+
);
255+
console.log(
256+
`Be aware that when doing many comparisons the risk of a false-positive\n`+
257+
`result increases. In this case, there are ${rows.length} comparisons, `+
258+
`you can thus\nexpect the following amount of false-positive results:\n`+
259+
` ${(rows.length*0.05).toFixed(2)} false positives, when considering `+
260+
`a 5% risk acceptance (*, **, ***),\n`+
261+
` ${(rows.length*0.01).toFixed(2)} false positives, when considering `+
262+
`a 1% risk acceptance (**, ***),\n`+
263+
` ${(rows.length*0.001).toFixed(2)} false positives, when considering `+
264+
`a 0.1% risk acceptance (***)`,
265+
);
266+
267+
// Gate: exit with error if any significant regression exceeds the limit.
268+
if(maxRegression>0){
269+
constfailures=rows.filter(
270+
(r)=>r.stars.trim()!==''&&r.improvement<-maxRegression,
271+
);
272+
if(failures.length>0){
273+
console.log('');
274+
console.log(
275+
`FAIL: ${failures.length} benchmark${failures.length===1 ? '' : 's'}`+
276+
` showed a statistically significant regression exceeding`+
277+
` ${maxRegression}%:`,
278+
);
279+
for(constfoffailures){
280+
console.log(` ${f.name}${f.improvement.toFixed(2)}%`);
281+
}
282+
process.exitCode=1;
283+
}
284+
}
285+
}
286+
287+
functionprintChart(rows,maxNameLen){
288+
if(rows.length===0)return;
289+
290+
// Determine the chart scale from the data. The bar region covers
291+
// the range [-maxAbs, +maxAbs] so the zero line sits in the center.
292+
constbarWidth=40;
293+
consthalfWidth=barWidth/2;
294+
letmaxAbs=0;
295+
for(constrowofrows){
296+
constextent=Math.abs(row.improvement)+row.ci95;
297+
if(extent>maxAbs)maxAbs=extent;
298+
}
299+
if(maxAbs===0)maxAbs=1;
300+
301+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
302+
303+
// Scale axis labels.
304+
constaxisLeft=`-${maxAbs.toFixed(1)}%`;
305+
constaxisRight=`+${maxAbs.toFixed(1)}%`;
306+
constaxisCenter='0%';
307+
308+
// Print axis header.
309+
constlabelPad=maxNameLen+5;
310+
constleftLabel=' '.repeat(labelPad)+
311+
axisLeft+
312+
' '.repeat(Math.max(0,halfWidth-axisLeft.length-Math.floor(axisCenter.length/2)))+
313+
axisCenter+
314+
' '.repeat(Math.max(0,halfWidth-Math.ceil(axisCenter.length/2)-axisRight.length))+
315+
axisRight;
316+
console.log('');
317+
console.log(leftLabel);
318+
319+
for(constrowofrows){
320+
constimp=row.improvement;
321+
constci=row.ci95;
322+
323+
// Position of the improvement value in the bar region [0, barWidth].
324+
constcenter=halfWidth;
325+
constimpPos=center+(imp/maxAbs)*halfWidth;
326+
327+
// CI extent in bar positions.
328+
constciLeft=center+((imp-ci)/maxAbs)*halfWidth;
329+
constciRight=center+((imp+ci)/maxAbs)*halfWidth;
330+
331+
// Build the bar character by character.
332+
constchars=[];
333+
for(letx=0;x<barWidth;x++){
334+
constpos=x+0.5;// Center of this character cell.
335+
if(x===Math.floor(center)){
336+
chars.push('|');
337+
}elseif((imp>=0&&pos>center&&pos<=impPos)||
338+
(imp<0&&pos<center&&pos>=impPos)){
339+
chars.push(row.stars ? '\u2588' : '\u2593');// solid or dark shade
340+
}elseif(pos>=ciLeft&&pos<=ciRight){
341+
chars.push('\u2591');// Light shade for CI region
342+
}else{
343+
chars.push(' ');
344+
}
345+
}
346+
347+
constlabel=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
348+
constsig=row.stars.trim();
349+
console.log(`${pad(row.name,maxNameLen)}${chars.join('')}${label}${sig}`);
350+
}
351+
}

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 74234ee

Browse files
jasnelladuh95
authored andcommitted
benchmark: add --analyze mode to compare.js
Add an --analyze flag that performs statistical analysis directly after benchmarks complete, eliminating the need for R and compare.R. When --analyze is specified, compare.js collects the rate data during the run and prints a statistical summary table instead of CSV output. The table matches the format of compare.R: improvement percentage, significance stars (* p<0.05, ** p<0.01, *** p<0.001), and confidence intervals at three risk levels. Also adds a --max-regression N option that causes the compare.js to exit with 1 (error) when the `--new` is N% slower. Useful for CI use to detect regressions. Uses the histogram API's welchTest() and cohensD() methods introduced in the previous commit. Benchmark rates are scaled to integers for HdrHistogram recording; the --scale option (default 1000) controls the multiplier for precision. Usage: node benchmark/compare.js --old ./node-old --new ./node-new \ --analyze url Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 9e8e908 commit 74234ee

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

β€Žbenchmark/_benchmark_progress.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function getTime(diff) {
2525
// A run is an item in the job queue: { binary, filename, iter }
2626
// A config is an item in the subqueue: { binary, filename, iter, configs }
2727
classBenchmarkProgress{
28-
constructor(queue,benchmarks){
28+
constructor(queue,benchmarks,options={}){
2929
this.queue=queue;// Scheduled runs.
3030
this.benchmarks=benchmarks;// Filenames of scheduled benchmarks.
31+
this.analyze=!!options.analyze;// stdout is not piped, but unused.
3132
this.completedRuns=0;// Number of completed runs.
3233
this.scheduledRuns=queue.length;// Number of scheduled runs.
3334
// Time when starting to run benchmarks.
@@ -107,7 +108,10 @@ class BenchmarkProgress {
107108
}
108109

109110
updateProgress(){
110-
if(!process.stderr.isTTY||process.stdout.isTTY){
111+
// Progress renders on stderr when stdout is piped (not a TTY).
112+
// In --analyze mode, stdout is the terminal but is unused during
113+
// the run, so treat it the same as piped.
114+
if(!process.stderr.isTTY||(process.stdout.isTTY&&!this.analyze)){
111115
return;
112116
}
113117
readline.clearLine(process.stderr);

β€Žbenchmark/compare.jsβ€Ž

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
1313
Run each benchmark in the <category> directory many times using two different
1414
node versions. More than one <category> directory can be specified.
1515
The output is formatted as csv, which can be processed using for
16-
example 'compare.R'.
16+
example 'compare.R'. Use --analyze to perform statistical analysis
17+
directly without R.
1718
1819
--new ./new-node-binary new node binary (required)
1920
--old ./old-node-binary old node binary (required)
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2425
repeated)
2526
--set variable=value set benchmark variable (can be repeated)
2627
--no-progress don't show benchmark progress indicator
28+
--analyze perform statistical analysis after benchmarks
29+
complete (Welch's t-test, effect size) instead
30+
of printing csv output
31+
--scale 1000 rate-to-integer multiplier for histogram
32+
precision when using --analyze (default: 1000)
33+
--max-regression N exit with code 1 if any statistically
34+
significant regression exceeds N% (implies
35+
--analyze)
2736
2837
Examples:
2938
--set CPUSET=0 Runs benchmarks on CPU core 0.
3039
--set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2.
3140
3241
Note: The CPUSET format should match the specifications of the 'taskset' command
33-
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress']});
42+
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress','analyze']});
3443

3544
if(!cli.optional.new||!cli.optional.old){
3645
cli.abort(cli.usage);
3746
}
3847

3948
constbinaries=['old','new'];
4049
construns=cli.optional.runs ? parseInt(cli.optional.runs,10) : 30;
50+
constmaxRegression=cli.optional['max-regression'] ?
51+
parseFloat(cli.optional['max-regression']) :
52+
0;
53+
constanalyze=!!cli.optional.analyze||maxRegression>0;
54+
constscale=cli.optional.scale ? parseInt(cli.optional.scale,10) : 1000;
4155
constbenchmarks=cli.benchmarks();
4256

4357
if(benchmarks.length===0){
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) {
4660
return;
4761
}
4862

63+
// When --analyze is set, collect results for statistical analysis.
64+
constresults=analyze ? newMap() : null;
65+
4966
// Create queue from the benchmarks list such both node versions are tested
5067
// `runs` amount of times each.
5168
// Note: BenchmarkProgress relies on this order to estimate
@@ -61,15 +78,17 @@ for (const filename of benchmarks) {
6178
}
6279
// queue.length = binary.length * runs * benchmarks.length
6380

64-
// Print csv header
65-
console.log('"binary","filename","configuration","rate","time"');
81+
// Print csv header (unless analyzing inline).
82+
if(!analyze){
83+
console.log('"binary","filename","configuration","rate","time"');
84+
}
6685

6786
constkStartOfQueue=0;
6887

6988
constshowProgress=!cli.optional['no-progress'];
7089
letprogress;
7190
if(showProgress){
72-
progress=newBenchmarkProgress(queue,benchmarks);
91+
progress=newBenchmarkProgress(queue,benchmarks,{ analyze });
7392
progress.startQueue(kStartOfQueue);
7493
}
7594

@@ -99,11 +118,20 @@ if (showProgress) {
99118
conf+=` ${key}=${inspect(data.conf[key])}`;
100119
}
101120
conf=conf.slice(1);
102-
// Escape quotes (") for correct csv formatting
103-
conf=conf.replace(/"/g,'""');
104121

105-
console.log(`"${job.binary}","${job.filename}","${conf}",`+
106-
`${data.rate},${data.time}`);
122+
if(analyze){
123+
// Collect results for post-run analysis.
124+
constname=`${job.filename}${conf}`;
125+
if(!results.has(name)){
126+
results.set(name,{old: [],new: []});
127+
}
128+
results.get(name)[job.binary].push(data.rate);
129+
}else{
130+
// Escape quotes (") for correct csv formatting
131+
conf=conf.replace(/"/g,'""');
132+
console.log(`"${job.binary}","${job.filename}","${conf}",`+
133+
`${data.rate},${data.time}`);
134+
}
107135
if(showProgress){
108136
// One item in the subqueue has been completed.
109137
progress.completeConfig(data);
@@ -125,6 +153,199 @@ if (showProgress) {
125153
// If there are more benchmarks execute the next
126154
if(i+1<queue.length){
127155
recursive(i+1);
156+
}elseif(analyze){
157+
printAnalysis(results,scale,maxRegression);
128158
}
129159
});
130160
})(kStartOfQueue);
161+
162+
functionprintAnalysis(results,scale,maxRegression){
163+
const{ createHistogram }=require('node:perf_hooks');
164+
165+
// Build per-benchmark histograms and run statistical tests.
166+
constrows=[];
167+
letmaxNameLen=0;
168+
169+
letskipped=0;
170+
171+
for(const[name,{old: oldRates,new: newRates}]ofresults){
172+
if(oldRates.length<2||newRates.length<2){
173+
skipped++;
174+
continue;
175+
}
176+
177+
consthOld=createHistogram({figures: 3});
178+
consthNew=createHistogram({figures: 3});
179+
180+
for(constrofoldRates)hOld.record(Math.max(1,Math.round(r*scale)));
181+
for(constrofnewRates)hNew.record(Math.max(1,Math.round(r*scale)));
182+
183+
constoldMean=oldRates.reduce((a,b)=>a+b,0)/oldRates.length;
184+
constnewMean=newRates.reduce((a,b)=>a+b,0)/newRates.length;
185+
constimprovement=((newMean-oldMean)/oldMean)*100;
186+
187+
// Query the three confidence levels. The p-value and t-statistic
188+
// are the same regardless of the confidence level, so we extract
189+
// them from the first result.
190+
constw95=hOld.welchTest(hNew,{confidence: 0.95});
191+
constw99=hOld.welchTest(hNew,{confidence: 0.99});
192+
constw999=hOld.welchTest(hNew,{confidence: 0.999});
193+
194+
// Significance stars matching compare.R convention.
195+
letstars='';
196+
if(w95.pValue<0.001)stars='***';
197+
elseif(w95.pValue<0.01)stars=' **';
198+
elseif(w95.pValue<0.05)stars=' *';
199+
200+
// Confidence intervals expressed as percentage of the old mean.
201+
constciPct=(w)=>{
202+
consthalf=
203+
(w.confidenceInterval.upper-w.confidenceInterval.lower)/2;
204+
return(half/(oldMean*scale))*100;
205+
};
206+
207+
rows.push({
208+
name,
209+
stars,
210+
improvement,
211+
ci95: ciPct(w95),
212+
ci99: ciPct(w99),
213+
ci999: ciPct(w999),
214+
pValue: w95.pValue,
215+
});
216+
217+
if(name.length>maxNameLen)maxNameLen=name.length;
218+
}
219+
220+
// Print header.
221+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
222+
constrpad=(s,n)=>' '.repeat(Math.max(0,n-s.length))+s;
223+
224+
console.log(`${pad('',maxNameLen)} confidence`+
225+
` improvement accuracy (*) (**) (***)`);
226+
227+
for(constrowofrows){
228+
constimp=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
229+
console.log(
230+
`${pad(row.name,maxNameLen)}${pad(row.stars,10)}`+
231+
` ${rpad(imp,11)}`+
232+
` Β±${row.ci95.toFixed(2)}%`+
233+
` Β±${row.ci99.toFixed(2)}%`+
234+
` Β±${row.ci999.toFixed(2)}%`,
235+
);
236+
}
237+
238+
if(skipped>0){
239+
console.log('');
240+
console.log(
241+
`Note: ${skipped} configuration${skipped===1 ? ' was' : 's were'}`+
242+
` skipped because Welch's t-test requires at least 2 samples per`+
243+
` binary. Use --runs 2 or higher.`,
244+
);
245+
}
246+
247+
// --- Bar chart visualization ---
248+
printChart(rows,maxNameLen);
249+
250+
console.log('');
251+
console.log(
252+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n`+
253+
`Use --scale to adjust precision if needed.\n`,
254+
);
255+
console.log(
256+
`Be aware that when doing many comparisons the risk of a false-positive\n`+
257+
`result increases. In this case, there are ${rows.length} comparisons, `+
258+
`you can thus\nexpect the following amount of false-positive results:\n`+
259+
` ${(rows.length*0.05).toFixed(2)} false positives, when considering `+
260+
`a 5% risk acceptance (*, **, ***),\n`+
261+
` ${(rows.length*0.01).toFixed(2)} false positives, when considering `+
262+
`a 1% risk acceptance (**, ***),\n`+
263+
` ${(rows.length*0.001).toFixed(2)} false positives, when considering `+
264+
`a 0.1% risk acceptance (***)`,
265+
);
266+
267+
// Gate: exit with error if any significant regression exceeds the limit.
268+
if(maxRegression>0){
269+
constfailures=rows.filter(
270+
(r)=>r.stars.trim()!==''&&r.improvement<-maxRegression,
271+
);
272+
if(failures.length>0){
273+
console.log('');
274+
console.log(
275+
`FAIL: ${failures.length} benchmark${failures.length===1 ? '' : 's'}`+
276+
` showed a statistically significant regression exceeding`+
277+
` ${maxRegression}%:`,
278+
);
279+
for(constfoffailures){
280+
console.log(` ${f.name}${f.improvement.toFixed(2)}%`);
281+
}
282+
process.exitCode=1;
283+
}
284+
}
285+
}
286+
287+
functionprintChart(rows,maxNameLen){
288+
if(rows.length===0)return;
289+
290+
// Determine the chart scale from the data. The bar region covers
291+
// the range [-maxAbs, +maxAbs] so the zero line sits in the center.
292+
constbarWidth=40;
293+
consthalfWidth=barWidth/2;
294+
letmaxAbs=0;
295+
for(constrowofrows){
296+
constextent=Math.abs(row.improvement)+row.ci95;
297+
if(extent>maxAbs)maxAbs=extent;
298+
}
299+
if(maxAbs===0)maxAbs=1;
300+
301+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
302+
303+
// Scale axis labels.
304+
constaxisLeft=`-${maxAbs.toFixed(1)}%`;
305+
constaxisRight=`+${maxAbs.toFixed(1)}%`;
306+
constaxisCenter='0%';
307+
308+
// Print axis header.
309+
constlabelPad=maxNameLen+5;
310+
constleftLabel=' '.repeat(labelPad)+
311+
axisLeft+
312+
' '.repeat(Math.max(0,halfWidth-axisLeft.length-Math.floor(axisCenter.length/2)))+
313+
axisCenter+
314+
' '.repeat(Math.max(0,halfWidth-Math.ceil(axisCenter.length/2)-axisRight.length))+
315+
axisRight;
316+
console.log('');
317+
console.log(leftLabel);
318+
319+
for(constrowofrows){
320+
constimp=row.improvement;
321+
constci=row.ci95;
322+
323+
// Position of the improvement value in the bar region [0, barWidth].
324+
constcenter=halfWidth;
325+
constimpPos=center+(imp/maxAbs)*halfWidth;
326+
327+
// CI extent in bar positions.
328+
constciLeft=center+((imp-ci)/maxAbs)*halfWidth;
329+
constciRight=center+((imp+ci)/maxAbs)*halfWidth;
330+
331+
// Build the bar character by character.
332+
constchars=[];
333+
for(letx=0;x<barWidth;x++){
334+
constpos=x+0.5;// Center of this character cell.
335+
if(x===Math.floor(center)){
336+
chars.push('|');
337+
}elseif((imp>=0&&pos>center&&pos<=impPos)||
338+
(imp<0&&pos<center&&pos>=impPos)){
339+
chars.push(row.stars ? '\u2588' : '\u2593');// solid or dark shade
340+
}elseif(pos>=ciLeft&&pos<=ciRight){
341+
chars.push('\u2591');// Light shade for CI region
342+
}else{
343+
chars.push(' ');
344+
}
345+
}
346+
347+
constlabel=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
348+
constsig=row.stars.trim();
349+
console.log(`${pad(row.name,maxNameLen)}${chars.join('')}${label}${sig}`);
350+
}
351+
}

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 74234ee

Browse files
jasnelladuh95
authored andcommitted
benchmark: add --analyze mode to compare.js
Add an --analyze flag that performs statistical analysis directly after benchmarks complete, eliminating the need for R and compare.R. When --analyze is specified, compare.js collects the rate data during the run and prints a statistical summary table instead of CSV output. The table matches the format of compare.R: improvement percentage, significance stars (* p<0.05, ** p<0.01, *** p<0.001), and confidence intervals at three risk levels. Also adds a --max-regression N option that causes the compare.js to exit with 1 (error) when the `--new` is N% slower. Useful for CI use to detect regressions. Uses the histogram API's welchTest() and cohensD() methods introduced in the previous commit. Benchmark rates are scaled to integers for HdrHistogram recording; the --scale option (default 1000) controls the multiplier for precision. Usage: node benchmark/compare.js --old ./node-old --new ./node-new \ --analyze url Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 9e8e908 commit 74234ee

3 files changed

Lines changed: 288 additions & 26 deletions

File tree

β€Žbenchmark/_benchmark_progress.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function getTime(diff) {
2525
// A run is an item in the job queue: { binary, filename, iter }
2626
// A config is an item in the subqueue: { binary, filename, iter, configs }
2727
classBenchmarkProgress{
28-
constructor(queue,benchmarks){
28+
constructor(queue,benchmarks,options={}){
2929
this.queue=queue;// Scheduled runs.
3030
this.benchmarks=benchmarks;// Filenames of scheduled benchmarks.
31+
this.analyze=!!options.analyze;// stdout is not piped, but unused.
3132
this.completedRuns=0;// Number of completed runs.
3233
this.scheduledRuns=queue.length;// Number of scheduled runs.
3334
// Time when starting to run benchmarks.
@@ -107,7 +108,10 @@ class BenchmarkProgress {
107108
}
108109

109110
updateProgress(){
110-
if(!process.stderr.isTTY||process.stdout.isTTY){
111+
// Progress renders on stderr when stdout is piped (not a TTY).
112+
// In --analyze mode, stdout is the terminal but is unused during
113+
// the run, so treat it the same as piped.
114+
if(!process.stderr.isTTY||(process.stdout.isTTY&&!this.analyze)){
111115
return;
112116
}
113117
readline.clearLine(process.stderr);

β€Žbenchmark/compare.jsβ€Ž

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
1313
Run each benchmark in the <category> directory many times using two different
1414
node versions. More than one <category> directory can be specified.
1515
The output is formatted as csv, which can be processed using for
16-
example 'compare.R'.
16+
example 'compare.R'. Use --analyze to perform statistical analysis
17+
directly without R.
1718
1819
--new ./new-node-binary new node binary (required)
1920
--old ./old-node-binary old node binary (required)
@@ -24,20 +25,33 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] <category> ...
2425
repeated)
2526
--set variable=value set benchmark variable (can be repeated)
2627
--no-progress don't show benchmark progress indicator
28+
--analyze perform statistical analysis after benchmarks
29+
complete (Welch's t-test, effect size) instead
30+
of printing csv output
31+
--scale 1000 rate-to-integer multiplier for histogram
32+
precision when using --analyze (default: 1000)
33+
--max-regression N exit with code 1 if any statistically
34+
significant regression exceeds N% (implies
35+
--analyze)
2736
2837
Examples:
2938
--set CPUSET=0 Runs benchmarks on CPU core 0.
3039
--set CPUSET=0-2 Specifies that benchmarks should run on CPU cores 0 to 2.
3140
3241
Note: The CPUSET format should match the specifications of the 'taskset' command
33-
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress']});
42+
`,{arrayArgs: ['set','filter','exclude'],boolArgs: ['no-progress','analyze']});
3443

3544
if(!cli.optional.new||!cli.optional.old){
3645
cli.abort(cli.usage);
3746
}
3847

3948
constbinaries=['old','new'];
4049
construns=cli.optional.runs ? parseInt(cli.optional.runs,10) : 30;
50+
constmaxRegression=cli.optional['max-regression'] ?
51+
parseFloat(cli.optional['max-regression']) :
52+
0;
53+
constanalyze=!!cli.optional.analyze||maxRegression>0;
54+
constscale=cli.optional.scale ? parseInt(cli.optional.scale,10) : 1000;
4155
constbenchmarks=cli.benchmarks();
4256

4357
if(benchmarks.length===0){
@@ -46,6 +60,9 @@ if (benchmarks.length === 0) {
4660
return;
4761
}
4862

63+
// When --analyze is set, collect results for statistical analysis.
64+
constresults=analyze ? newMap() : null;
65+
4966
// Create queue from the benchmarks list such both node versions are tested
5067
// `runs` amount of times each.
5168
// Note: BenchmarkProgress relies on this order to estimate
@@ -61,15 +78,17 @@ for (const filename of benchmarks) {
6178
}
6279
// queue.length = binary.length * runs * benchmarks.length
6380

64-
// Print csv header
65-
console.log('"binary","filename","configuration","rate","time"');
81+
// Print csv header (unless analyzing inline).
82+
if(!analyze){
83+
console.log('"binary","filename","configuration","rate","time"');
84+
}
6685

6786
constkStartOfQueue=0;
6887

6988
constshowProgress=!cli.optional['no-progress'];
7089
letprogress;
7190
if(showProgress){
72-
progress=newBenchmarkProgress(queue,benchmarks);
91+
progress=newBenchmarkProgress(queue,benchmarks,{ analyze });
7392
progress.startQueue(kStartOfQueue);
7493
}
7594

@@ -99,11 +118,20 @@ if (showProgress) {
99118
conf+=` ${key}=${inspect(data.conf[key])}`;
100119
}
101120
conf=conf.slice(1);
102-
// Escape quotes (") for correct csv formatting
103-
conf=conf.replace(/"/g,'""');
104121

105-
console.log(`"${job.binary}","${job.filename}","${conf}",`+
106-
`${data.rate},${data.time}`);
122+
if(analyze){
123+
// Collect results for post-run analysis.
124+
constname=`${job.filename}${conf}`;
125+
if(!results.has(name)){
126+
results.set(name,{old: [],new: []});
127+
}
128+
results.get(name)[job.binary].push(data.rate);
129+
}else{
130+
// Escape quotes (") for correct csv formatting
131+
conf=conf.replace(/"/g,'""');
132+
console.log(`"${job.binary}","${job.filename}","${conf}",`+
133+
`${data.rate},${data.time}`);
134+
}
107135
if(showProgress){
108136
// One item in the subqueue has been completed.
109137
progress.completeConfig(data);
@@ -125,6 +153,199 @@ if (showProgress) {
125153
// If there are more benchmarks execute the next
126154
if(i+1<queue.length){
127155
recursive(i+1);
156+
}elseif(analyze){
157+
printAnalysis(results,scale,maxRegression);
128158
}
129159
});
130160
})(kStartOfQueue);
161+
162+
functionprintAnalysis(results,scale,maxRegression){
163+
const{ createHistogram }=require('node:perf_hooks');
164+
165+
// Build per-benchmark histograms and run statistical tests.
166+
constrows=[];
167+
letmaxNameLen=0;
168+
169+
letskipped=0;
170+
171+
for(const[name,{old: oldRates,new: newRates}]ofresults){
172+
if(oldRates.length<2||newRates.length<2){
173+
skipped++;
174+
continue;
175+
}
176+
177+
consthOld=createHistogram({figures: 3});
178+
consthNew=createHistogram({figures: 3});
179+
180+
for(constrofoldRates)hOld.record(Math.max(1,Math.round(r*scale)));
181+
for(constrofnewRates)hNew.record(Math.max(1,Math.round(r*scale)));
182+
183+
constoldMean=oldRates.reduce((a,b)=>a+b,0)/oldRates.length;
184+
constnewMean=newRates.reduce((a,b)=>a+b,0)/newRates.length;
185+
constimprovement=((newMean-oldMean)/oldMean)*100;
186+
187+
// Query the three confidence levels. The p-value and t-statistic
188+
// are the same regardless of the confidence level, so we extract
189+
// them from the first result.
190+
constw95=hOld.welchTest(hNew,{confidence: 0.95});
191+
constw99=hOld.welchTest(hNew,{confidence: 0.99});
192+
constw999=hOld.welchTest(hNew,{confidence: 0.999});
193+
194+
// Significance stars matching compare.R convention.
195+
letstars='';
196+
if(w95.pValue<0.001)stars='***';
197+
elseif(w95.pValue<0.01)stars=' **';
198+
elseif(w95.pValue<0.05)stars=' *';
199+
200+
// Confidence intervals expressed as percentage of the old mean.
201+
constciPct=(w)=>{
202+
consthalf=
203+
(w.confidenceInterval.upper-w.confidenceInterval.lower)/2;
204+
return(half/(oldMean*scale))*100;
205+
};
206+
207+
rows.push({
208+
name,
209+
stars,
210+
improvement,
211+
ci95: ciPct(w95),
212+
ci99: ciPct(w99),
213+
ci999: ciPct(w999),
214+
pValue: w95.pValue,
215+
});
216+
217+
if(name.length>maxNameLen)maxNameLen=name.length;
218+
}
219+
220+
// Print header.
221+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
222+
constrpad=(s,n)=>' '.repeat(Math.max(0,n-s.length))+s;
223+
224+
console.log(`${pad('',maxNameLen)} confidence`+
225+
` improvement accuracy (*) (**) (***)`);
226+
227+
for(constrowofrows){
228+
constimp=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)} %`;
229+
console.log(
230+
`${pad(row.name,maxNameLen)}${pad(row.stars,10)}`+
231+
` ${rpad(imp,11)}`+
232+
` Β±${row.ci95.toFixed(2)}%`+
233+
` Β±${row.ci99.toFixed(2)}%`+
234+
` Β±${row.ci999.toFixed(2)}%`,
235+
);
236+
}
237+
238+
if(skipped>0){
239+
console.log('');
240+
console.log(
241+
`Note: ${skipped} configuration${skipped===1 ? ' was' : 's were'}`+
242+
` skipped because Welch's t-test requires at least 2 samples per`+
243+
` binary. Use --runs 2 or higher.`,
244+
);
245+
}
246+
247+
// --- Bar chart visualization ---
248+
printChart(rows,maxNameLen);
249+
250+
console.log('');
251+
console.log(
252+
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n`+
253+
`Use --scale to adjust precision if needed.\n`,
254+
);
255+
console.log(
256+
`Be aware that when doing many comparisons the risk of a false-positive\n`+
257+
`result increases. In this case, there are ${rows.length} comparisons, `+
258+
`you can thus\nexpect the following amount of false-positive results:\n`+
259+
` ${(rows.length*0.05).toFixed(2)} false positives, when considering `+
260+
`a 5% risk acceptance (*, **, ***),\n`+
261+
` ${(rows.length*0.01).toFixed(2)} false positives, when considering `+
262+
`a 1% risk acceptance (**, ***),\n`+
263+
` ${(rows.length*0.001).toFixed(2)} false positives, when considering `+
264+
`a 0.1% risk acceptance (***)`,
265+
);
266+
267+
// Gate: exit with error if any significant regression exceeds the limit.
268+
if(maxRegression>0){
269+
constfailures=rows.filter(
270+
(r)=>r.stars.trim()!==''&&r.improvement<-maxRegression,
271+
);
272+
if(failures.length>0){
273+
console.log('');
274+
console.log(
275+
`FAIL: ${failures.length} benchmark${failures.length===1 ? '' : 's'}`+
276+
` showed a statistically significant regression exceeding`+
277+
` ${maxRegression}%:`,
278+
);
279+
for(constfoffailures){
280+
console.log(` ${f.name}${f.improvement.toFixed(2)}%`);
281+
}
282+
process.exitCode=1;
283+
}
284+
}
285+
}
286+
287+
functionprintChart(rows,maxNameLen){
288+
if(rows.length===0)return;
289+
290+
// Determine the chart scale from the data. The bar region covers
291+
// the range [-maxAbs, +maxAbs] so the zero line sits in the center.
292+
constbarWidth=40;
293+
consthalfWidth=barWidth/2;
294+
letmaxAbs=0;
295+
for(constrowofrows){
296+
constextent=Math.abs(row.improvement)+row.ci95;
297+
if(extent>maxAbs)maxAbs=extent;
298+
}
299+
if(maxAbs===0)maxAbs=1;
300+
301+
constpad=(s,n)=>s+' '.repeat(Math.max(0,n-s.length));
302+
303+
// Scale axis labels.
304+
constaxisLeft=`-${maxAbs.toFixed(1)}%`;
305+
constaxisRight=`+${maxAbs.toFixed(1)}%`;
306+
constaxisCenter='0%';
307+
308+
// Print axis header.
309+
constlabelPad=maxNameLen+5;
310+
constleftLabel=' '.repeat(labelPad)+
311+
axisLeft+
312+
' '.repeat(Math.max(0,halfWidth-axisLeft.length-Math.floor(axisCenter.length/2)))+
313+
axisCenter+
314+
' '.repeat(Math.max(0,halfWidth-Math.ceil(axisCenter.length/2)-axisRight.length))+
315+
axisRight;
316+
console.log('');
317+
console.log(leftLabel);
318+
319+
for(constrowofrows){
320+
constimp=row.improvement;
321+
constci=row.ci95;
322+
323+
// Position of the improvement value in the bar region [0, barWidth].
324+
constcenter=halfWidth;
325+
constimpPos=center+(imp/maxAbs)*halfWidth;
326+
327+
// CI extent in bar positions.
328+
constciLeft=center+((imp-ci)/maxAbs)*halfWidth;
329+
constciRight=center+((imp+ci)/maxAbs)*halfWidth;
330+
331+
// Build the bar character by character.
332+
constchars=[];
333+
for(letx=0;x<barWidth;x++){
334+
constpos=x+0.5;// Center of this character cell.
335+
if(x===Math.floor(center)){
336+
chars.push('|');
337+
}elseif((imp>=0&&pos>center&&pos<=impPos)||
338+
(imp<0&&pos<center&&pos>=impPos)){
339+
chars.push(row.stars ? '\u2588' : '\u2593');// solid or dark shade
340+
}elseif(pos>=ciLeft&&pos<=ciRight){
341+
chars.push('\u2591');// Light shade for CI region
342+
}else{
343+
chars.push(' ');
344+
}
345+
}
346+
347+
constlabel=`${row.improvement>=0 ? '+' : ''}${row.improvement.toFixed(2)}%`;
348+
constsig=row.stars.trim();
349+
console.log(`${pad(row.name,maxNameLen)}${chars.join('')}${label}${sig}`);
350+
}
351+
}

0 commit comments

Comments
Β (0)