@@ -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+
162183function 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