Skip to content

Commit 7323695

Browse files
jasnelladuh95
authored andcommitted
lib,benchmark: address multiple review issues
Refs: #65606 (comment) Refs: #65606 (comment) Refs: #65606 (comment) PR-URL: #65631 Reviewed-By: Filip Skokan <panva.ip@gmail.com>
1 parent 468ca1b commit 7323695

8 files changed

Lines changed: 164 additions & 30 deletions

File tree

benchmark/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ Rscript benchmark/compare.R < compare-node-bench.csv
8080

8181
Pass `--analyze` to run the same Welch analysis inline. `--max-regression N`
8282
implies `--analyze` and makes the command fail only when the Holm-Bonferroni
83-
adjusted p-value is below 0.05 and the full 95% confidence interval is worse
84-
than `-N%`. Requiring both conditions prevents a noisy point estimate from
85-
failing a regression gate.
83+
adjusted one-sided p-value against the `N%` threshold is below 0.05 and the full
84+
95% confidence interval is worse than `-N%`. Requiring both conditions prevents
85+
a noisy point estimate from failing a regression gate.
8686

8787
```console
8888
./node benchmark/compare-node-bench.js \

benchmark/_node-bench-analysis.js

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,19 @@ function holmAdjust(pValues) {
3131
return adjusted;
3232
}
3333

34+
function thresholdPValue(oldRates, newHistogram, scale, maxRegression) {
35+
const factor = 1 - maxRegression / 100;
36+
if (factor <= 0) return 1;
37+
const thresholdHistogram = createRateHistogram(
38+
oldRates.map((rate) => rate * factor), scale, 3);
39+
const result = thresholdHistogram.welchTest(newHistogram);
40+
if (Number.isNaN(result.pValue)) return 1;
41+
return result.tStatistic > 0 ?
42+
result.pValue / 2 : 1 - result.pValue / 2;
43+
}
44+
3445
function isRegressionFailure(row, maxRegression) {
35-
return row.pAdjusted < 0.05 &&
46+
return row.pThresholdAdjusted < 0.05 &&
3647
row.improvement + row.ci95 < -maxRegression;
3748
}
3849

@@ -80,22 +91,32 @@ function analyzeCompare(samples, scale, maxRegression) {
8091
result.confidenceInterval.lower) / 2;
8192
return (half / (oldMean * scale)) * 100;
8293
};
83-
rows.push({
94+
const row = {
8495
ci95: ciPercent(w95),
8596
ci99: ciPercent(w99),
8697
ci999: ciPercent(w999),
8798
improvement,
8899
name,
89100
pValue: Number.isNaN(w95.pValue) ? 1 : w95.pValue,
90101
stars,
91-
});
102+
};
103+
if (maxRegression !== undefined) {
104+
row.pThreshold = thresholdPValue(
105+
oldRates, newHistogram, scale, maxRegression);
106+
}
107+
rows.push(row);
92108
}
93109

94110
const adjusted = holmAdjust(rows.map(({ pValue }) => pValue));
111+
const thresholdAdjusted = maxRegression === undefined ? null :
112+
holmAdjust(rows.map(({ pThreshold }) => pThreshold));
95113
let underpowered = 0;
96114
for (let index = 0; index < rows.length; index++) {
97115
const row = rows[index];
98116
row.pAdjusted = adjusted[index];
117+
if (thresholdAdjusted !== null) {
118+
row.pThresholdAdjusted = thresholdAdjusted[index];
119+
}
99120
row.inconclusive = maxRegression > 0 &&
100121
row.stars.trim() === '' &&
101122
row.ci95 > maxRegression;
@@ -146,8 +167,13 @@ function analyzeCompare(samples, scale, maxRegression) {
146167
`After Holm-Bonferroni correction across ${rows.length} comparison` +
147168
`${rows.length === 1 ? '' : 's'}, ${significant} remain` +
148169
`${significant === 1 ? 's' : ''} significant at 5%.`,
149-
'--max-regression uses the corrected values.',
150170
);
171+
if (maxRegression !== undefined) {
172+
output.push(
173+
`For --max-regression, one-sided p-values against the ` +
174+
`${maxRegression}% threshold were corrected separately.`,
175+
);
176+
}
151177

152178
if (maxRegression > 0 && underpowered > 0) {
153179
output.push('');
@@ -159,21 +185,22 @@ function analyzeCompare(samples, scale, maxRegression) {
159185
);
160186
}
161187

162-
const failures = maxRegression > 0 ?
188+
const failures = maxRegression !== undefined ?
163189
rows.filter((row) => isRegressionFailure(row, maxRegression)) : [];
164190
if (failures.length > 0) {
165191
output.push('');
166192
output.push(
167193
`FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` +
168194
` regressed by more than ${maxRegression}% (the 95% interval excludes ` +
169-
`the threshold and significance is family-wise corrected across ` +
195+
`the threshold and its one-sided test is family-wise corrected across ` +
170196
`${rows.length} comparisons):`,
171197
);
172198
for (const failure of failures) {
173199
output.push(
174200
` ${failure.name} ${failure.improvement.toFixed(2)}% ` +
175201
`(95% CI up to ${(failure.improvement + failure.ci95).toFixed(2)}%, ` +
176-
`adjusted p=${failure.pAdjusted.toExponential(2)})`,
202+
`adjusted threshold p=` +
203+
`${failure.pThresholdAdjusted.toExponential(2)})`,
177204
);
178205
}
179206
}

benchmark/compare-node-bench.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ async function main() {
3636
const runs = parseInteger(cli.optional.runs, 30, '--runs', 1);
3737
const warmup = parseInteger(cli.optional.warmup, 0, '--warmup', 0);
3838
const scale = parseInteger(cli.optional.scale, 1000, '--scale', 1);
39+
const hasMaxRegression = cli.optional['max-regression'] !== undefined;
3940
const maxRegression = parseNumber(
4041
cli.optional['max-regression'], 0, '--max-regression', 0);
41-
const analyze = !!cli.optional.analyze || maxRegression > 0;
42+
const analyze = !!cli.optional.analyze || hasMaxRegression;
4243
const options = {
4344
namePattern: cli.optional['name-pattern'],
4445
nodeArgs: cli.optional['node-arg'],
@@ -96,7 +97,8 @@ async function main() {
9697
}
9798

9899
if (analyze) {
99-
const result = analyzeCompare(rows, scale, maxRegression);
100+
const result = analyzeCompare(
101+
rows, scale, hasMaxRegression ? maxRegression : undefined);
100102
process.stdout.write(result.output);
101103
if (result.failed) process.exitCode = 1;
102104
return;

doc/api/bench.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,11 @@ samples, but their samples are discarded. An exception, rejection, timeout,
349349
abort, missing timing call, or duplicate timing call stops the current
350350
benchmark. Later benchmarks continue to run.
351351

352+
After a timeout or abort, the runner briefly waits for asynchronous benchmark
353+
work to settle before continuing. If it remains pending, all later benchmarks
354+
that were selected to run fail without running so that their measurements
355+
cannot overlap with that work.
356+
352357
For each warmup and measured callback, the runner subscribes to the configured
353358
diagnostics channels. Each publication queues a context diagnostic whose
354359
`message` is `{ name, message }`, containing the string channel name and the

doc/contributing/writing-and-running-benchmarks.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -721,8 +721,9 @@ Both parallel tools support inline analysis. `scatter-node-bench.js --analyze`
721721
uses the same `--xaxis`, `--category`, and `--no-chart` interface described for
722722
`scatter.js`. `compare-node-bench.js --analyze` performs Welch's t-test, while
723723
`--max-regression N` adds a corrected regression gate. The gate requires both a
724-
Holm-Bonferroni-adjusted p-value below 0.05 and a 95% confidence interval lying
725-
entirely beyond `-N%`; the point estimate alone cannot fail the command.
724+
Holm-Bonferroni-adjusted one-sided p-value against the `N%` threshold below 0.05
725+
and a 95% confidence interval lying entirely beyond `-N%`; the point estimate
726+
alone cannot fail the command.
726727
Scatter analysis reduces aggregated configurations to one value per outer
727728
process and uses disjoint process sets for consecutive Mann-Whitney comparisons
728729
so configurations sharing a process are not treated as independent samples.

lib/internal/bench_runner/harness.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const {
6464
} = require('internal/bench_runner/benchmarks_stream');
6565

6666
const { bigint: hrtime } = process.hrtime;
67+
const kCancellationGracePeriod = 100;
6768
const kHookNames = ['after', 'afterEach', 'before', 'beforeEach'];
6869
const kIsCliRunner = getOptionValue('--bench');
6970
let nextRunId = 0;
@@ -76,6 +77,20 @@ function eventLoopTurn() {
7677
return new Promise((resolve) => setImmediate(resolve));
7778
}
7879

80+
async function settlesPromptly(work) {
81+
const timeout = PromiseWithResolvers();
82+
const timer = setTimeout(
83+
() => timeout.resolve(false), kCancellationGracePeriod);
84+
try {
85+
return await SafePromiseRace([
86+
PromisePrototypeThen(work, () => true, () => true),
87+
timeout.promise,
88+
]);
89+
} finally {
90+
clearTimeout(timer);
91+
}
92+
}
93+
7994
function createAbortError(signal) {
8095
return new AbortError(undefined, { __proto__: null, cause: signal.reason });
8196
}
@@ -86,6 +101,7 @@ function createTimeoutError(benchmark) {
86101
}
87102

88103
class Harness {
104+
#abortReason = null;
89105
#autoRun;
90106
#buildPromises = [];
91107
#duplicateErrors = new SafeMap();
@@ -581,6 +597,14 @@ class Harness {
581597
}
582598

583599
async #executeSuite(suite) {
600+
if (this.#abortReason !== null) {
601+
await this.#completeSubtree(suite, this.#abortReason);
602+
suite.finished = true;
603+
suite.completion.resolve();
604+
if (!suite.isRoot) suite.emitDestroy();
605+
return;
606+
}
607+
584608
if (suite.buildError !== null) {
585609
await this.#diagnostic(suite.buildError, suite.loc, 'error', suite);
586610
await this.#completeSubtree(suite, suite.buildError);
@@ -614,7 +638,7 @@ class Harness {
614638
}
615639
}
616640

617-
if (active) {
641+
if (active && this.#abortReason === null) {
618642
const failure = await this.#runSuiteHooks(suite, 'after');
619643
if (failure !== null) {
620644
await this.#diagnostic(
@@ -711,6 +735,13 @@ class Harness {
711735
} catch {
712736
// Preserve the error that stopped benchmark execution.
713737
}
738+
if (!await settlesPromptly(work) && this.#abortReason === null) {
739+
this.#abortReason = new AbortError(
740+
'The benchmark run was aborted because asynchronous work did not ' +
741+
'settle after cancellation',
742+
{ __proto__: null, cause: error },
743+
);
744+
}
714745
throw error;
715746
} finally {
716747
if (timer !== undefined) clearTimeout(timer);
@@ -833,6 +864,7 @@ class Harness {
833864
return;
834865
}
835866

867+
if (this.#abortReason !== null) forcedError = this.#abortReason;
836868
if (forcedError !== undefined) {
837869
this.success = false;
838870
this.counts.failed++;

test/parallel/test-bench-errors.js

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,21 @@ bench('invalid operations', options, (b) => {
2727
bench('throws', options, () => {
2828
throw new Error('benchmark failure');
2929
});
30-
bench('timeout', { samples: 1, timeout: 10 }, async () => {
31-
await new Promise(() => {});
32-
});
30+
let lateTimeoutActive = false;
3331
bench('late timeout', { samples: 1, timeout: 5 }, async (b) => {
34-
b.start();
35-
await setTimeout(30);
36-
b.end(1);
32+
lateTimeoutActive = true;
33+
try {
34+
b.start();
35+
await setTimeout(30);
36+
b.end(1);
37+
} finally {
38+
lateTimeoutActive = false;
39+
}
3740
});
41+
bench('after late timeout', options, common.mustCall((b) => {
42+
assert.strictEqual(lateTimeoutActive, false);
43+
complete(b);
44+
}));
3845

3946
const signal = AbortSignal.abort(new Error('stop'));
4047
bench('aborted', { samples: 1, signal }, () => {});
@@ -48,6 +55,11 @@ function complete(b) {
4855
bench('duplicate', { samples: 1, params: { value: 1 } }, complete);
4956
bench('duplicate', { samples: 1, params: { value: 1 } }, complete);
5057
bench('continues', options, complete);
58+
bench('timeout', { samples: 1, timeout: 10 }, async () => {
59+
await new Promise(() => {});
60+
});
61+
bench('after unsettled timeout', options, common.mustNotCall());
62+
bench.skip('skipped after unsettled timeout', options, common.mustNotCall());
5163

5264
const completions = [];
5365
const sampleNames = [];
@@ -57,13 +69,13 @@ stream.on('bench:complete', (result) => completions.push(result));
5769
stream.on('bench:sample', (sample) => sampleNames.push(sample.name));
5870
stream.on('bench:summary', (result) => { summary = result; });
5971
stream.on('end', common.mustCall(() => {
60-
assert.strictEqual(completions.length, 13);
72+
assert.strictEqual(completions.length, 16);
6173
assert.deepStrictEqual(summary.counts, {
6274
__proto__: null,
63-
completed: 2,
64-
failed: 11,
65-
skipped: 0,
66-
total: 13,
75+
completed: 3,
76+
failed: 12,
77+
skipped: 1,
78+
total: 16,
6779
});
6880
assert.strictEqual(summary.success, false);
6981

@@ -92,12 +104,18 @@ stream.on('end', common.mustCall(() => {
92104
'ERR_OPERATION_FAILED');
93105
assert.strictEqual(byName.get('late timeout')[0].error.code,
94106
'ERR_OPERATION_FAILED');
107+
assert.strictEqual(byName.get('after late timeout')[0].error, undefined);
95108
assert.strictEqual(byName.get('aborted')[0].error.code, 'ABORT_ERR');
96109

97110
const duplicates = byName.get('duplicate');
98111
assert.strictEqual(duplicates[0].error, undefined);
99112
assert.match(duplicates[1].error.message, /duplicate benchmark identity/);
100113
assert.strictEqual(byName.get('continues')[0].error, undefined);
114+
const unsettled = byName.get('after unsettled timeout')[0].error;
115+
assert.strictEqual(unsettled.code, 'ABORT_ERR');
116+
assert.strictEqual(unsettled.cause.code, 'ERR_OPERATION_FAILED');
117+
assert.strictEqual(
118+
byName.get('skipped after unsettled timeout')[0].skip, true);
101119
setTimeout(40).then(common.mustCall(() => {
102120
assert.strictEqual(sampleNames.includes('late timeout'), false);
103121
}));

0 commit comments

Comments
 (0)