Skip to content

Commit 1ed1379

Browse files
Merge pull request #353 from marius-bughiu/marius-bughiu-fix-issue-351-benchmark-flag-noise-guard
fix(ci): flag a benchmark row on a 3-sigma bar, not a 1-sigma sum
2 parents 04020bf + 5a52b6d commit 1ed1379

6 files changed

Lines changed: 668 additions & 161 deletions

File tree

.github/workflows/benchmarks.yml

Lines changed: 35 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -305,181 +305,57 @@ jobs:
305305
retention-days: 30
306306

307307
# ----- PR path: post the same-runner A/B comment -----
308+
#
309+
# The body is built by a script rather than inline here, because what it decides —
310+
# which rows are flagged as a regression — is a statistical judgement that has to be
311+
# testable. Inline in this file it was neither runnable nor pinned, and it shipped a
312+
# guard that flagged 13 of 757 rows on a diff with zero IL in it (#351).
313+
# `scripts/benchmark_comment.js --self-test` runs on every PR from `ci.yml` and pins
314+
# the rule against the real measurements from that run.
315+
- name: Build benchmark comment
316+
if: github.event_name == 'pull_request' && steps.merge.outputs.have_head == 'true' && steps.merge.outputs.have_base == 'true'
317+
env:
318+
# A row is flagged when it moves past this ratio AND the gap exceeds
319+
# ALERT_NOISE_SIGMAS times the two measurements' combined standard deviation,
320+
# added in quadrature. Both defaults live in the script; they are repeated here
321+
# because this is where someone tuning them will look first.
322+
ALERT_THRESHOLD_RATIO: '1.10'
323+
ALERT_NOISE_SIGMAS: '3'
324+
MISSING_SHARDS: ${{ steps.merge.outputs.missing_shards }}
325+
run: >-
326+
node scripts/benchmark_comment.js
327+
--pr /tmp/pr-report-full.json
328+
--base /tmp/base-report-full.json
329+
--out /tmp/benchmark-comment.md
330+
308331
- name: Post benchmark comment
309332
if: github.event_name == 'pull_request' && steps.merge.outputs.have_head == 'true' && steps.merge.outputs.have_base == 'true'
310333
uses: actions/github-script@v7
311334
env:
312-
ALERT_THRESHOLD_RATIO: '1.10'
313335
MISSING_SHARDS: ${{ steps.merge.outputs.missing_shards }}
314336
with:
315337
github-token: ${{ secrets.GITHUB_TOKEN }}
316338
script: |
317339
const fs = require('fs');
318-
319-
const pr = JSON.parse(fs.readFileSync('/tmp/pr-report-full.json', 'utf8'));
320-
const base = JSON.parse(fs.readFileSync('/tmp/base-report-full.json', 'utf8'));
321-
322-
// Index base results by full benchmark name.
323-
const baseMap = new Map();
324-
for (const b of base.Benchmarks) {
325-
baseMap.set(b.FullName, b.Statistics);
340+
// The one definition of the marker, imported rather than copied: the string
341+
// must match what the generated body carries and what the search below looks
342+
// for, and a second copy here is a silent way for those to drift apart.
343+
// github-script resolves a relative require against the workspace root.
344+
const { COMMENT_MARKER: marker } = require('./scripts/benchmark_comment.js');
345+
346+
const body = fs.readFileSync('/tmp/benchmark-comment.md', 'utf8');
347+
// Catches a truncated or empty build step, which would otherwise post a blank
348+
// comment and orphan the previous one.
349+
if (!body.includes(marker)) {
350+
core.setFailed('The generated comment is missing its marker; posting it would orphan the previous one.');
351+
return;
326352
}
327353
328-
const formatNs = (ns) => {
329-
if (ns == null) return 'n/a';
330-
if (ns < 1000) return `${ns.toFixed(1)} ns`;
331-
if (ns < 1_000_000) return `${(ns/1000).toFixed(2)} μs`;
332-
if (ns < 1_000_000_000) return `${(ns/1_000_000).toFixed(2)} ms`;
333-
return `${(ns/1_000_000_000).toFixed(2)} s`;
334-
};
335-
336-
const threshold = parseFloat(process.env.ALERT_THRESHOLD_RATIO);
337-
const thresholdPct = ((threshold - 1) * 100).toFixed(0);
338-
// Each entry: { name, isHasher, prCell, stdCell, baseCell, deltaCell, flag }
339-
// where flag is 'regression' | 'improvement' | null.
340-
const entries = [];
341-
let regressions = 0;
342-
let improvements = 0;
343-
let errored = 0;
344-
345-
for (const b of pr.Benchmarks) {
346-
const name = b.FullName;
347-
// BenchmarkDotNet emits a benchmark with null Statistics and empty
348-
// Measurements when that case errored or was not run (a throwing
349-
// [GlobalSetup], a cancelled shard, an OOM, ...). Surface it as a row
350-
// rather than dereferencing null — one errored case must never crash
351-
// the aggregate job and red-X the whole PR gate.
352-
if (b.Statistics == null) {
353-
errored++;
354-
const baseStats = baseMap.get(name);
355-
entries.push({
356-
name,
357-
isHasher: /Hasher/.test(name),
358-
prCell: '⚠️ errored',
359-
stdCell: 'n/a',
360-
baseCell: baseStats ? formatNs(baseStats.Mean) : 'n/a',
361-
deltaCell: 'no measurements',
362-
flag: null,
363-
});
364-
continue;
365-
}
366-
const prMean = b.Statistics.Mean;
367-
const prStdDev = b.Statistics.StandardDeviation;
368-
const baseStats = baseMap.get(name);
369-
let deltaCell = baseMap.has(name) ? '⚠️ base errored' : '🆕 new';
370-
let flag = null;
371-
if (baseStats) {
372-
const baseMean = baseStats.Mean;
373-
const baseStdDev = baseStats.StandardDeviation;
374-
const ratio = prMean / baseMean;
375-
const pct = (ratio - 1) * 100;
376-
const sign = pct >= 0 ? '+' : '';
377-
deltaCell = `${sign}${pct.toFixed(1)}%`;
378-
// Flag only when the change clears the threshold AND exceeds the
379-
// combined run-to-run noise of both measurements.
380-
const beyondNoise = Math.abs(prMean - baseMean) > (prStdDev + baseStdDev);
381-
if (ratio >= threshold && beyondNoise) {
382-
deltaCell += ' ⚠️';
383-
flag = 'regression';
384-
regressions++;
385-
} else if (ratio <= (1 / threshold) && beyondNoise) {
386-
deltaCell += ' ✅';
387-
flag = 'improvement';
388-
improvements++;
389-
}
390-
}
391-
entries.push({
392-
name,
393-
// Hasher throughput benchmarks (StringHasherBenchmark,
394-
// IntegerHasherBenchmark) get their own section; everything else
395-
// is a collection benchmark.
396-
isHasher: /Hasher/.test(name),
397-
prCell: formatNs(prMean),
398-
stdCell: formatNs(prStdDev),
399-
baseCell: baseStats ? formatNs(baseStats.Mean) : 'n/a',
400-
deltaCell,
401-
flag,
402-
});
403-
}
404-
405-
let subtitle;
406-
if (regressions > 0) {
407-
subtitle = `${regressions} regression${regressions === 1 ? '' : 's'} ⚠️ vs main` +
408-
(improvements > 0 ? `, ${improvements} improvement${improvements === 1 ? '' : 's'} ✅` : '') +
409-
` (rows past ±${thresholdPct}% beyond noise).`;
410-
} else if (improvements > 0) {
411-
subtitle = `No regressions — ${improvements} improvement${improvements === 1 ? '' : 's'} ✅ vs main.`;
412-
} else {
413-
subtitle = `No significant change vs main (all within ±${thresholdPct}% or noise).`;
414-
}
415-
if (errored > 0) {
416-
subtitle += ` ⚠️ ${errored} benchmark${errored === 1 ? '' : 's'} errored (no measurements) — see the table.`;
417-
}
418-
419-
const baseSha = (process.env.BASE_SHA || '').slice(0, 7);
420-
const footer = `<sub>Same-runner A/B (sharded ${process.env.SHARD_TOTAL || ''}-way): main (\`${baseSha}\`) and this PR were built and benchmarked back-to-back on the same runner per shard, so hardware variance cancels out. ⚠️ = PR mean ≥ +${thresholdPct}% slower than main and beyond combined std-dev; ✅ = correspondingly faster.</sub>`;
421-
422-
const header = ['| Benchmark | This PR | StdDev | main | Δ |', '|---|---:|---:|---:|---:|'];
423-
const toRow = (e) => `| \`${e.name}\` | ${e.prCell} | ${e.stdCell} | ${e.baseCell} | ${e.deltaCell} |`;
424-
425-
// A collapsible section per benchmark family, collapsed by default so
426-
// the (often large) tables stay below the fold.
427-
const section = (title, list) => {
428-
if (list.length === 0) return [];
429-
return [
430-
'<details>',
431-
`<summary><b>${title}</b> (${list.length})</summary>`,
432-
'',
433-
...header,
434-
...list.map(toRow),
435-
'</details>',
436-
'',
437-
];
438-
};
439-
440-
// Highlights: only the flagged rows stay above the fold so reviewers
441-
// see what actually moved without expanding either table.
442-
const flagged = entries.filter(e => e.flag);
443-
const highlights = flagged.length === 0 ? [] : [
444-
'**Highlights**',
445-
'',
446-
...header,
447-
...flagged.map(toRow),
448-
'',
449-
];
450-
451-
const collections = entries.filter(e => !e.isHasher);
452-
const hashers = entries.filter(e => e.isHasher);
453-
454-
// A partial merge otherwise reads exactly like a complete one: the tables are
455-
// well-formed and simply have fewer rows, so a silently-dropped shard looks
456-
// like a clean report. Say it above the fold, before any numbers.
457354
const missingShards = (process.env.MISSING_SHARDS || '').trim();
458-
const incomplete = missingShards.length === 0 ? [] : [
459-
`> [!WARNING]`,
460-
`> **Incomplete report.** Shard(s) \`${missingShards}\` produced no measurements, so every`,
461-
`> benchmark class packed onto them is missing from the tables below — including, possibly,`,
462-
`> the ones this PR changed. Treat the comparison as partial rather than as a clean run.`,
463-
'',
464-
];
465355
if (missingShards.length > 0) {
466356
core.warning(`Benchmark comparison is missing shard(s) ${missingShards}.`);
467357
}
468358
469-
const marker = '<!-- celerity-benchmarks-comment -->';
470-
const body = [
471-
marker,
472-
'## Benchmarks',
473-
'',
474-
...incomplete,
475-
subtitle,
476-
'',
477-
...highlights,
478-
...section('Collections', collections),
479-
...section('Hashers', hashers),
480-
footer,
481-
].join('\n');
482-
483359
const { data: comments } = await github.rest.issues.listComments({
484360
owner: context.repo.owner,
485361
repo: context.repo.repo,

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ jobs:
143143
# The benchmark relevance gate decides whether an expensive sharded A/B run happens at
144144
# all, so the C# lexer it rests on is pinned here rather than only exercised in the
145145
# workflow it gates — where a wrong answer costs either runner hours or an unmeasured
146-
# regression, and neither failure announces itself.
146+
# regression, and neither failure announces itself. The comment builder is pinned
147+
# alongside it for the same reason: it decides which rows are called a regression, and
148+
# a guard that is too permissive is indistinguishable from a clean report (#351).
147149
benchmark-gate:
148150
name: benchmark-gate
149151
runs-on: ubuntu-latest
@@ -156,6 +158,9 @@ jobs:
156158
- name: Pin the comment-stripping lexer
157159
run: node scripts/benchmark_relevant_changes.js --self-test
158160

161+
- name: Pin the regression-flag rule
162+
run: node scripts/benchmark_comment.js --self-test
163+
159164
aot-publish:
160165
name: aot-publish (linux-x64, ${{ matrix.tfm }})
161166
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,15 @@ All notable changes to Celerity are documented here. This project follows [Keep
2626
- A "Documentation links" section in `CONTRIBUTING.md` covering the slug rule and how to look an anchor up rather than guess it. Closes [#339](https://github.com/marius-bughiu/Celerity/issues/339).
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).
29+
- `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).
2931

3032
### Fixed
3133

3234
- 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).
3335
- 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).
3436
- 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).
37+
- 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).
3538

3639
- `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`.
3740
- Corrected `RadixSort.ArgSort` XML documentation: only its `ReadOnlySpan<int>` overload rejects `indices` that shares storage with `keys`. Documentation only.

CONTRIBUTING.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,15 @@ 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`. Rows that move by more than ±10% *and* beyond the combined standard deviation of both measurements are flagged; the flags are advisory, so a noisy row does not fail the job. 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 **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.
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:
8989

9090
- **It supersedes itself.** Pushing to a PR cancels that PR's in-flight benchmark run rather than stacking another eight-runner matrix behind it; only the newest numbers are ever read. Pushes to `main` are keyed per commit instead, so none is ever cancelled and the published history has no gaps.
9191
- **It is skipped when the diff cannot move a number.** [`scripts/benchmark_relevant_changes.js`](scripts/benchmark_relevant_changes.js) gates the PR path: a diff that touches only documentation, only the test / fuzz / AOT-smoke projects, or only comments inside `.cs` files does not buy a three-hour A/B run. The gate is one-directional — anything it cannot prove inert (an added or deleted file, a `.csproj`, a git command that fails) runs the suite — and it never applies to `main`, so a wrongly-skipped PR is still measured on merge. Run it yourself with `node scripts/benchmark_relevant_changes.js <base> <head>`.
9292
- **Shard *i* means the same slice on both sides.** The base run replays the class list the head resolved instead of packing its own. Shard membership comes from bin-packing over the benchmark class list, so a PR that *adds* a benchmark class would otherwise pack the two sides differently and could pair a light head slice with a heavy base one.
93+
- **A flag is evidence, not a verdict.** Even at 3σ, two rows in a ~750-row run still flagged on a diff whose IL was byte-identical to `main`: a case whose two builds land in different code or data layouts shifts by tens of percent with a tight spread on both sides, and nothing inside a single A/B pass separates that from a real change. Re-run before acting on a flag near the run's published p95. The rule lives in [`scripts/benchmark_comment.js`](scripts/benchmark_comment.js), which documents how the 3σ bar was calibrated; `node scripts/benchmark_comment.js --self-test` pins it against the measurements it was chosen against.
9394

9495
If a change is motivated by performance, include before/after numbers from a local Release run in the PR description — the CI job is a guardrail, not a precision instrument. Numbers without `-c Release` are not useful — BenchmarkDotNet refuses to run in Debug.
9596

0 commit comments

Comments
 (0)