Skip to content

Commit 6650e31

Browse files
Merge pull request #354 from marius-bughiu/marius-bughiu-fix-issue-351-benchmark-flag-noise-guard
fix(ci): count a base-side benchmark failure, and stop calling the run's spread a noise floor
2 parents 9b26992 + 6192c54 commit 6650e31

3 files changed

Lines changed: 75 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,15 @@ All notable changes to Celerity are documented here. This project follows [Keep
2727
- `scripts/benchmark_relevant_changes.js` — a CI gate that skips the sharded benchmark run on a pull request whose diff cannot move a measured number: documentation, the test / fuzz / AOT-smoke projects, or comments inside `.cs` files. It skips only what it can prove inert and never applies to `main`. Closes [#335](https://github.com/marius-bughiu/Celerity/issues/335).
2828
- A `--shard-dry-run` switch on the benchmarks runner that resolves a shard's class list without measuring anything. Closes [#300](https://github.com/marius-bughiu/Celerity/issues/300).
2929
- `scripts/benchmark_comment.js` — the pull-request benchmark comment, moved out of `benchmarks.yml` so the rule deciding which rows count as a regression is runnable and testable, with a `--self-test` wired into `ci.yml`. Closes [#351](https://github.com/marius-bughiu/Celerity/issues/351).
30-
- That comment now publishes the run's own **measured noise floor** — the p50, p90 and p95 of |Δ| across every paired row, of which any one pull request changes only a handful — so a flag can be read against the drift it arrived in instead of an assumed one. Closes [#351](https://github.com/marius-bughiu/Celerity/issues/351).
30+
- That comment now publishes the run's own **observed spread** — the p50, p90 and p95 of |Δ| across every paired row — so a flag can be read against the run it arrived in instead of an assumed floor. Closes [#351](https://github.com/marius-bughiu/Celerity/issues/351).
3131

3232
### Fixed
3333

3434
- Pushing to a pull request now supersedes that PR's in-flight benchmark run instead of stacking another eight-runner matrix behind it, so `CI` and `Coverage` no longer queue behind superseded perf runs. Pushes to `main` are keyed per commit and never cancelled. Closes [#319](https://github.com/marius-bughiu/Celerity/issues/319).
3535
- A benchmark shard no longer times out on a pull request that adds a benchmark class: the `main` base now replays the class list the PR head resolved, so shard *i* is the same slice on both sides. The job budget was also resized to the measured slices, which the suite had outgrown. Closes [#300](https://github.com/marius-bughiu/Celerity/issues/300).
3636
- A benchmark comparison that is missing a shard now says so in the PR comment, instead of reading exactly like a complete run. Closes [#300](https://github.com/marius-bughiu/Celerity/issues/300).
3737
- The PR benchmark comment no longer cries wolf: its noise guard is now a **** bar over the two measurements' combined standard deviation, added in quadrature, rather than a 1σ sum. Replayed over a run whose library IL was byte-identical to `main`, that cuts flagged rows from **13 to 2** while detecting the same regressions on every benchmark precise enough to resolve one. Closes [#351](https://github.com/marius-bughiu/Celerity/issues/351).
38+
- A benchmark whose `main` side failed to measure, or whose two means cannot be compared at all, is now counted in that comment's warning total. Previously such a row appeared in the table while the summary line still read "No significant change vs main". Closes [#351](https://github.com/marius-bughiu/Celerity/issues/351).
3839

3940
- `PartialSort.TopK` now throws `ArgumentException` when its `destination` overlaps its `source`, instead of silently returning a wrong answer and writing to the source it documents as untouched. Disjoint slices of one array are still accepted, matching `RadixSort` and `CountingSort`.
4041
- Corrected `RadixSort.ArgSort` XML documentation: only its `ReadOnlySpan<int>` overload rejects `indices` that shares storage with `keys`. Documentation only.

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ dotnet run -c Release -- --filter '*' # run everything with the default (slow, h
8282

8383
Results are parsed by [`benchmark-action/github-action-benchmark`](https://github.com/benchmark-action/github-action-benchmark) and:
8484

85-
- **On a PR**: a comment is posted with the same-runner A/B comparison vs `main`. A row is flagged when it moves past ±10% *and* the gap exceeds **** of the two measurements' combined standard deviation (added in quadrature); the flags are advisory, so a noisy row does not fail the job. The comment also publishes that run's **measured noise floor** — the p50, p90 and p95 of |Δ| across every paired row, of which any one PR changes only a handful — so a flag can be read against the drift it arrived in rather than against an assumed one. If any shard failed to report, the comment says so above the fold — a partial comparison is otherwise indistinguishable from a clean one.
85+
- **On a PR**: a comment is posted with the same-runner A/B comparison vs `main`. A row is flagged when it moves past ±10% *and* the gap exceeds **** of the two measurements' combined standard deviation (added in quadrature); the flags are advisory, so a noisy row does not fail the job. The comment also publishes that run's **observed spread** — the p50, p90 and p95 of |Δ| across every paired row — so a flag can be read against the run it arrived in rather than against an assumed floor. On a typical PR that spread is almost all runner drift, but a change to a shared primitive moves many rows at once and raises those figures itself, so it is not a floor the PR cannot have caused. If any shard failed to report, the comment says so above the fold — a partial comparison is otherwise indistinguishable from a clean one.
8686
- **On a push to `main`**: the new measurement is appended to the `gh-pages`-stored history powering the dashboard at <https://marius-bughiu.github.io/Celerity/dev/bench/>.
8787

8888
Three things about the run are worth knowing before you wonder why it did or did not happen:

scripts/benchmark_comment.js

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// (8 regressions, 5 improvements) out of 757, four of them on `Dictionary<,>` / `HashSet<>`
1313
// baseline arms whose code is the same on both sides by definition (#351).
1414
//
15-
// The measured noise floor of that run, which is the reason no purely per-row rule can
15+
// The measured spread of that run, which is the reason no purely per-row rule can
1616
// reach zero: |Δ| has a p50 of 0.7% and a p90 of 6.2%, but 43 rows exceed ±10%,
1717
// 16 exceed ±25% and the worst is 222%. The distribution is sharply peaked with a fat tail
1818
// of individually unstable cases — sensitive to code and data layout, which differs
@@ -47,7 +47,7 @@
4747
// sides (`SmallDictionaryBenchmark.Dictionary_Remove(ItemCount: 64)`, +33.1% with 3.4% and
4848
// 3.5% relative SD). Both launches of each side agree closely, so nothing inside a single
4949
// A/B pass can tell it from a real change; only re-measuring against a fresh pair of builds
50-
// can. That residual is why the comment publishes the run's measured noise floor rather
50+
// can. That residual is why the comment publishes the run's own observed spread rather
5151
// than presenting a flag as a verdict.
5252
//
5353
// Usage:
@@ -114,7 +114,7 @@ function classifyDelta(prMean, prStdDev, baseMean, baseStdDev, thresholdRatio, n
114114

115115
// Nearest-rank percentile over an already-sorted ascending array: the reported value is
116116
// always an |Δ| some row actually exhibited, never an interpolation between two rows. That
117-
// is the right choice for a noise floor — "p95 is 10.3%" should name an observed
117+
// is the right choice for a published spread — "p95 is 10.3%" should name an observed
118118
// measurement — but it does mean p50 is not the textbook median on an even-length sample,
119119
// which is why nothing below calls it one.
120120
function percentile(sorted, p) {
@@ -123,12 +123,23 @@ function percentile(sorted, p) {
123123
return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))];
124124
}
125125

126-
// The run's own noise floor, measured rather than assumed (#351). Any one pull request
127-
// changes a handful of the ~750 tracked cases, so the spread of the whole delta
128-
// distribution is dominated by what the runner did between the head slice and the base
129-
// slice. Publishing it on every comment means the threshold can be argued about against
130-
// evidence, and a run that was unusually hostile says so instead of looking clean.
131-
function noiseProfile(deltaPercents) {
126+
// The run's own delta distribution, measured rather than assumed (#351). On a typical pull
127+
// request — a handful of the ~750 tracked cases touched — this is a noise floor: almost
128+
// every row in it is the runner's own drift between the head slice and the base slice.
129+
//
130+
// It is not *only* that, which is why neither this function nor the footer it feeds calls
131+
// it a noise floor. A change to a shared primitive or to the benchmark configuration moves
132+
// many rows at once, and those real effects sit in the same sample. Deriving the floor from
133+
// a control group instead was considered and rejected: the obvious controls are the BCL
134+
// baseline arms, but a layout shift moves those too, so they would understate the tail
135+
// while looking authoritative. Reporting the distribution and saying what is in it is the
136+
// honest version.
137+
//
138+
// `noiseSigmas` and `ALERT_NOISE_SIGMAS` keep their names on purpose. That guard is scaled
139+
// by the two measurements' own standard deviations, which really are per-row measurement
140+
// noise — a different quantity from the run-wide distribution measured here, and one the
141+
// word describes correctly.
142+
function spreadProfile(deltaPercents) {
132143
if (deltaPercents.length === 0) return null;
133144
const sorted = [...deltaPercents].sort((a, b) => a - b);
134145
return {
@@ -199,10 +210,21 @@ function buildComment(prReport, baseReport, options = {}) {
199210
const prMean = b.Statistics.Mean;
200211
const prStdDev = b.Statistics.StandardDeviation;
201212
const baseStats = baseMap.get(name);
202-
let deltaCell = baseMap.has(name) ? '⚠️ base errored' : '🆕 new';
213+
let deltaCell;
203214
let flag = null;
204215

205-
if (baseStats) {
216+
if (!baseStats) {
217+
// Present in the base report but with no statistics means the base side of this pair
218+
// errored, so there is nothing to compare against. Counted, because a subtitle
219+
// reading "No significant change" over a row that could not be measured is the same
220+
// false reassurance a too-permissive guard gives.
221+
if (baseMap.has(name)) {
222+
errored++;
223+
deltaCell = '⚠️ base errored';
224+
} else {
225+
deltaCell = '🆕 new';
226+
}
227+
} else {
206228
const baseMean = baseStats.Mean;
207229
// A row whose ratio cannot mean anything is reported as such rather than rendered as
208230
// `+Infinity%` and flagged. It is counted with the errored rows because it is the
@@ -263,18 +285,22 @@ function buildComment(prReport, baseReport, options = {}) {
263285
`measurements' combined standard deviation; ✅ = correspondingly faster.</sub>`,
264286
];
265287

266-
const noise = noiseProfile(deltaPercents);
267-
if (noise) {
268-
// Stated as what it is: a floor, not a verdict. A reviewer comparing a flagged row
269-
// against p95 can see immediately whether it stands out from the run it came in.
288+
const spread = spreadProfile(deltaPercents);
289+
if (spread) {
290+
// Named for what it is rather than for what it usually is. On a narrow change this
291+
// reads as the run's noise floor; on a broad one it moves with the change itself, and
292+
// a reviewer told "this is drift" would talk themselves out of a real regression.
270293
// Reported as percentiles, not as a mean and a "median": each figure is the |Δ| of an
271294
// actual row (see `percentile`), and naming them p50/p90/p95 says so.
272295
footer.push('');
273296
footer.push(
274-
`<sub>**Measured noise floor for this run** — |Δ| across all ${noise.n} paired rows, of which a pull ` +
275-
`request changes only a handful, so the rest is the runner's own drift: p50 ` +
276-
`${noise.p50.toFixed(1)}%, p90 ${noise.p90.toFixed(1)}%, p95 ${noise.p95.toFixed(1)}% ` +
277-
`(nearest-rank). Read a flag near that p95 with suspicion, and re-run before acting on it.</sub>`
297+
`<sub>**Observed spread of this run** — |Δ| across all ${spread.n} paired rows: p50 ` +
298+
`${spread.p50.toFixed(1)}%, p90 ${spread.p90.toFixed(1)}%, p95 ${spread.p95.toFixed(1)}% ` +
299+
`(nearest-rank). A pull request usually touches a handful of these, so this is mostly the ` +
300+
`runner's own drift between the two slices — but a change to a shared primitive moves many ` +
301+
`rows at once and would raise these figures itself, so read it as the run's spread rather ` +
302+
`than as a floor the PR cannot have caused. A flag that does not stand out against it is ` +
303+
`worth re-running before acting on.</sub>`
278304
);
279305
}
280306

@@ -318,7 +344,7 @@ function buildComment(prReport, baseReport, options = {}) {
318344
...footer,
319345
].join('\n');
320346

321-
return { body, entries, regressions, improvements, errored, noise, missingShards, thresholdRatio, noiseSigmas };
347+
return { body, entries, regressions, improvements, errored, spread, missingShards, thresholdRatio, noiseSigmas };
322348
}
323349

324350
// ---- self-test ------------------------------------------------------------------------
@@ -416,13 +442,13 @@ function selfTest() {
416442
// 117.8) = 1614.7 against a 665.3 gap.
417443
check('3σ clears PooledCelerityDictionary_Lookup', classifyDelta(5372.3, 525.2, 4707.0, 117.8, 1.10, 3), null);
418444

419-
// ---- noise profile ----
420-
const profile = noiseProfile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
421-
check('noise profile', [profile.n, profile.p50, profile.p90, profile.p95], [10, 5, 9, 10]);
422-
check('noise profile of nothing', noiseProfile([]), null);
445+
// ---- spread profile ----
446+
const profile = spreadProfile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
447+
check('spread profile', [profile.n, profile.p50, profile.p90, profile.p95], [10, 5, 9, 10]);
448+
check('spread profile of nothing', spreadProfile([]), null);
423449
// Nearest-rank, so every reported figure is a value that appears in the sample. Pinned
424450
// because the footer names the statistics after this definition.
425-
check('percentiles are order statistics, not interpolations', noiseProfile([1, 2]).p50, 1);
451+
check('percentiles are order statistics, not interpolations', spreadProfile([1, 2]).p50, 1);
426452

427453
// ---- comment composition ----
428454
const prReport = {
@@ -432,6 +458,7 @@ function selfTest() {
432458
{ FullName: 'ABenchmark.A_Noisy(ItemCount: 8)', Statistics: statsFor(120, 40) },
433459
{ FullName: 'ABenchmark.A_New(ItemCount: 8)', Statistics: statsFor(50, 1) },
434460
{ FullName: 'ABenchmark.A_Errored(ItemCount: 8)', Statistics: null, Measurements: [] },
461+
{ FullName: 'ABenchmark.A_BaseErrored(ItemCount: 8)', Statistics: statsFor(100, 1) },
435462
{ FullName: 'StringHasherBenchmark.Fnv1a(Length: 8)', Statistics: statsFor(10, 0.1) },
436463
],
437464
};
@@ -441,25 +468,32 @@ function selfTest() {
441468
{ FullName: 'ABenchmark.A_Steady(ItemCount: 8)', Statistics: statsFor(100, 1) },
442469
{ FullName: 'ABenchmark.A_Noisy(ItemCount: 8)', Statistics: statsFor(100, 40) },
443470
{ FullName: 'ABenchmark.A_Errored(ItemCount: 8)', Statistics: statsFor(100, 1) },
471+
// Present, but the base side of this pair produced no statistics.
472+
{ FullName: 'ABenchmark.A_BaseErrored(ItemCount: 8)', Statistics: null },
444473
{ FullName: 'StringHasherBenchmark.Fnv1a(Length: 8)', Statistics: statsFor(10, 0.1) },
445474
],
446475
};
447476

448477
const built = buildComment(prReport, baseReport, { baseSha: 'abcdef1234', shardTotal: '8' });
449478
check('regressions', built.regressions, 1);
450479
check('improvements', built.improvements, 0);
451-
check('errored', built.errored, 1);
480+
check('errored', built.errored, 2);
452481
check('marker', built.body.startsWith(COMMENT_MARKER), true);
453482
check('highlights hold only the flagged row', (built.body.match(/A_Slow/g) || []).length, 2);
454483
check('a noisy row is not flagged', built.body.includes('A_Noisy(ItemCount: 8)` | 120.0 ns | 40.0 ns | 100.0 ns | +20.0% |'), true);
455484
check('a new row is reported as new', built.body.includes('🆕 new'), true);
456485
check('an errored row is reported', built.body.includes('⚠️ errored'), true);
486+
// An unusable comparison must reach the count, not just the table: a subtitle reading
487+
// "No significant change" over a pair that could not be compared is false reassurance.
488+
check('a base-side failure is reported', built.body.includes('⚠️ base errored'), true);
489+
check('a base-side failure reaches the subtitle', built.body.includes('2 benchmarks produced no usable comparison'), true);
457490
check('hashers get their own section', built.body.includes('<summary><b>Hashers</b> (1)</summary>'), true);
458-
check('collections section excludes hashers', built.body.includes('<summary><b>Collections</b> (5)</summary>'), true);
459-
// The errored row has no delta, so it is outside the noise profile; the new row has no
460-
// base and is outside it too. Four rows remain.
461-
check('noise profile counts only paired rows', built.noise.n, 4);
462-
check('noise floor is published', built.body.includes('**Measured noise floor for this run**'), true);
491+
check('collections section excludes hashers', built.body.includes('<summary><b>Collections</b> (6)</summary>'), true);
492+
// The errored row has no delta, the base-errored row has no base statistics, and the new
493+
// row has no base at all. Four rows remain.
494+
check('spread profile counts only paired rows', built.spread.n, 4);
495+
check('the spread is published', built.body.includes('**Observed spread of this run**'), true);
496+
check('the spread is not called a floor', /noise floor/i.test(built.body), false);
463497
check('no incomplete banner by default', built.body.includes('[!WARNING]'), false);
464498

465499
const partial = buildComment(prReport, baseReport, { missingShards: '3, 5' });
@@ -479,7 +513,7 @@ function selfTest() {
479513
check('zero sigmas leaves the ratio gate alone', [noGuard.regressions, noGuard.improvements], [2, 0]);
480514
check('zero sigmas is not replaced by the default', noGuard.noiseSigmas, 0);
481515

482-
// A degenerate base must not poison the published noise floor.
516+
// A degenerate base must not poison the published percentiles.
483517
const degenerate = buildComment(
484518
{ Benchmarks: [
485519
{ FullName: 'ABenchmark.A_Ok(ItemCount: 8)', Statistics: statsFor(102, 1) },
@@ -495,7 +529,7 @@ function selfTest() {
495529
] },
496530
{},
497531
);
498-
check('a non-finite delta is kept out of the noise profile', [degenerate.noise.n, degenerate.noise.p95.toFixed(1)], [1, '2.0']);
532+
check('a non-finite delta is kept out of the spread profile', [degenerate.spread.n, degenerate.spread.p95.toFixed(1)], [1, '2.0']);
499533
// A zero base makes the ratio Infinity, a zero PR mean makes it 0. Both used to clear a
500534
// gate; neither may now be reported as a change.
501535
check('a broken comparison is never flagged', [degenerate.regressions, degenerate.improvements], [0, 0]);
@@ -517,7 +551,7 @@ function selfTest() {
517551
// An empty base (every shard's base run produced nothing) must still render.
518552
const noBase = buildComment(prReport, { Benchmarks: [] }, {});
519553
check('an empty base still renders', noBase.regressions, 0);
520-
check('an empty base reports every row as new', (noBase.body.match(/🆕 new/g) || []).length, 5);
554+
check('an empty base reports every row as new', (noBase.body.match(/🆕 new/g) || []).length, 6);
521555

522556
// ---- settings ----
523557
// Unset means "use the calibrated default"; a value that would quietly change what a
@@ -608,14 +642,14 @@ function main() {
608642
console.log(`Wrote ${outPath}: ${result.entries.length} row(s), ${result.regressions} regression(s), ` +
609643
`${result.improvements} improvement(s), ${result.errored} errored ` +
610644
`(±${((result.thresholdRatio - 1) * 100).toFixed(0)}% and ${result.noiseSigmas}σ).`);
611-
if (result.noise) {
612-
console.log(`Measured noise floor over ${result.noise.n} paired row(s): p50 ` +
613-
`${result.noise.p50.toFixed(1)}%, p90 ${result.noise.p90.toFixed(1)}%, p95 ${result.noise.p95.toFixed(1)}%.`);
645+
if (result.spread) {
646+
console.log(`Observed spread over ${result.spread.n} paired row(s): p50 ` +
647+
`${result.spread.p50.toFixed(1)}%, p90 ${result.spread.p90.toFixed(1)}%, p95 ${result.spread.p95.toFixed(1)}%.`);
614648
}
615649
}
616650

617651
if (require.main === module) {
618652
main();
619653
}
620654

621-
module.exports = { buildComment, classifyDelta, formatNs, isComparable, noiseProfile, readSetting, COMMENT_MARKER };
655+
module.exports = { buildComment, classifyDelta, formatNs, isComparable, spreadProfile, readSetting, COMMENT_MARKER };

0 commit comments

Comments
 (0)