Skip to content

Commit c62ff23

Browse files
committed
benchmark: fix max-regressions detection in compare.js
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode
1 parent 29c517f commit c62ff23

1 file changed

Lines changed: 82 additions & 7 deletions

File tree

benchmark/compare.js

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,27 @@ if (showProgress) {
159159
});
160160
})(kStartOfQueue);
161161

162+
// Holm-Bonferroni step-down adjustment. Controls the probability of *any*
163+
// false positive across the whole comparison set, which is what a pass/fail
164+
// gate needs: an uncorrected suite of 169 comparisons at 5% has a 99.98%
165+
// chance of flagging something that is not there. Uniformly more powerful
166+
// than plain Bonferroni, and makes no assumption about independence.
167+
function holmAdjust(pValues) {
168+
const order = pValues
169+
.map((p, i) => ({ p, i }))
170+
.sort((a, b) => a.p - b.p);
171+
const m = order.length;
172+
const adjusted = new Array(m);
173+
let running = 0;
174+
for (let k = 0; k < m; k++) {
175+
// Step down, enforcing monotonicity so an adjusted value can never be
176+
// smaller than one belonging to a more significant raw p-value.
177+
running = Math.max(running, Math.min(1, (m - k) * order[k].p));
178+
adjusted[order[k].i] = running;
179+
}
180+
return adjusted;
181+
}
182+
162183
function printAnalysis(results, scale, maxRegression) {
163184
const { createHistogram } = require('node:perf_hooks');
164185

@@ -217,6 +238,25 @@ function printAnalysis(results, scale, maxRegression) {
217238
if (name.length > maxNameLen) maxNameLen = name.length;
218239
}
219240

241+
// Adjust for the size of the comparison set. The raw p-value answers "is
242+
// this one benchmark different", but a suite is read as a whole, so the
243+
// relevant question is "is anything here different".
244+
const adjusted = holmAdjust(rows.map((r) => r.pValue));
245+
for (let i = 0; i < rows.length; i++) rows[i].pAdjusted = adjusted[i];
246+
247+
// A comparison can only rule out an effect it was able to resolve. Where no
248+
// threshold has been given there is no definition of "worth detecting", so
249+
// nothing is claimed. `maxRegression` is exactly such a declaration, so it
250+
// is reused rather than inventing a second constant.
251+
const resolution = maxRegression > 0 ? maxRegression : null;
252+
let underpowered = 0;
253+
for (const row of rows) {
254+
row.inconclusive = resolution !== null &&
255+
row.stars.trim() === '' &&
256+
row.ci95 > resolution;
257+
if (row.inconclusive) underpowered++;
258+
}
259+
220260
// Print header.
221261
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
222262
const rpad = (s, n) => ' '.repeat(Math.max(0, n - s.length)) + s;
@@ -231,7 +271,8 @@ function printAnalysis(results, scale, maxRegression) {
231271
` ${rpad(imp, 11)}` +
232272
` ±${row.ci95.toFixed(2)}%` +
233273
` ±${row.ci99.toFixed(2)}%` +
234-
` ±${row.ci999.toFixed(2)}%`,
274+
` ±${row.ci999.toFixed(2)}%` +
275+
`${row.inconclusive ? ' (inconclusive)' : ''}`,
235276
);
236277
}
237278

@@ -252,6 +293,7 @@ function printAnalysis(results, scale, maxRegression) {
252293
`Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n` +
253294
`Use --scale to adjust precision if needed.\n`,
254295
);
296+
const anyFamilyWise = rows.filter((r) => r.pAdjusted < 0.05).length;
255297
console.log(
256298
`Be aware that when doing many comparisons the risk of a false-positive\n` +
257299
`result increases. In this case, there are ${rows.length} comparisons, ` +
@@ -261,23 +303,56 @@ function printAnalysis(results, scale, maxRegression) {
261303
` ${(rows.length * 0.01).toFixed(2)} false positives, when considering ` +
262304
`a 1% risk acceptance (**, ***),\n` +
263305
` ${(rows.length * 0.001).toFixed(2)} false positives, when considering ` +
264-
`a 0.1% risk acceptance (***)`,
306+
`a 0.1% risk acceptance (***)\n` +
307+
`\nThe stars above are per-benchmark and uncorrected. Adjusting for the ` +
308+
`size of\nthis comparison set (Holm-Bonferroni), ${anyFamilyWise} ` +
309+
`comparison${anyFamilyWise === 1 ? '' : 's'} remain${anyFamilyWise === 1 ? 's' : ''} ` +
310+
`significant at 5%.\n--max-regression uses the corrected values.`,
265311
);
266312

267-
// Gate: exit with error if any significant regression exceeds the limit.
313+
// Gate: exit with error if any regression is shown to exceed the limit.
268314
if (maxRegression > 0) {
315+
if (underpowered > 0) {
316+
console.log('');
317+
console.log(
318+
`Note: ${underpowered} of ${rows.length} comparison` +
319+
`${rows.length === 1 ? '' : 's'} could not resolve an effect as ` +
320+
`small as ${maxRegression}%, and are marked (inconclusive). They are ` +
321+
`not\nevidence of no regression -- the samples are too noisy to tell. ` +
322+
`Raise --runs,\nor pin cores with --set CPUSET, to narrow them.`,
323+
);
324+
}
325+
326+
// Two conditions, both required.
327+
//
328+
// The confidence interval must lie entirely beyond the threshold. A small
329+
// p-value only says the effect is not exactly zero; claiming it exceeds
330+
// `maxRegression` is a statement about magnitude, so the interval has to
331+
// exclude that magnitude. Testing the point estimate instead systematically
332+
// fires on the noisiest benchmarks, because a large point estimate is
333+
// easiest to obtain when the interval is wide.
334+
//
335+
// The p-value must also survive adjustment for the size of the comparison
336+
// set, so that a suite of hundreds of benchmarks does not fail purely
337+
// because one of them drifted.
269338
const failures = rows.filter(
270-
(r) => r.stars.trim() !== '' && r.improvement < -maxRegression,
339+
(r) => r.pAdjusted < 0.05 && r.improvement + r.ci95 < -maxRegression,
271340
);
341+
272342
if (failures.length > 0) {
273343
console.log('');
274344
console.log(
275345
`FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` +
276-
` showed a statistically significant regression exceeding` +
277-
` ${maxRegression}%:`,
346+
` regressed by more than ${maxRegression}%` +
347+
` (interval excludes the threshold,\n` +
348+
`family-wise corrected across ${rows.length} comparisons):`,
278349
);
279350
for (const f of failures) {
280-
console.log(` ${f.name} ${f.improvement.toFixed(2)}%`);
351+
console.log(
352+
` ${f.name} ${f.improvement.toFixed(2)}% ` +
353+
`(95% CI up to ${(f.improvement + f.ci95).toFixed(2)}%, ` +
354+
`adjusted p=${f.pAdjusted.toExponential(2)})`,
355+
);
281356
}
282357
process.exitCode = 1;
283358
}

0 commit comments

Comments
 (0)