Skip to content

Commit 71a30ae

Browse files
committed
fix(ci): stop the benchmark suite starving CI and publishing partial numbers
Three open issues on 2.4.0 turned out to be one lane: nothing rationed the most expensive workflow in the repository. Runs accumulated. benchmarks.yml declared no concurrency group, so five pushes over one review loop created five uncancelled eight-shard runs and left CI and Coverage queued for ~50 minutes behind numbers nobody would read. The workflow now supersedes its own in-flight run, keyed on the PR number; the main path is keyed on the commit SHA instead, so every commit is its own group, cancel-in-progress can never discard one, and the published series keeps every point. Closes #319. Runs overlapped. With three branches in flight every shard measured 1.75-2.0x its baseline and one hit the 120-minute cap - on two pull requests whose diffs were XML doc comments only. The quiet failure is the worse one: a shard that finishes under uneven contention still publishes its skewed delta. scripts/benchmark_relevant_changes.js now gates the PR path and does not run the suite when the diff cannot move a number. It is one-directional by construction (only documentation, the three projects Celerity.Benchmarks.csproj does not reference, and .cs files whose text is unchanged once comments are stripped can be skipped) and never applies to main, so a gate mistake costs a missing PR comment rather than an unseen regression. It correctly skips both pull requests the issue names and runs on every code change it was tested against. The issue's own first choice - a global serialize-everything concurrency group - was deliberately not taken: GitHub queues at most one pending run per group and cancels the older pending one, so serializing would silently drop runs. What ships is its option 2, which it rated cheapest and most obviously correct. Closes #335. The two sides of the A/B packed from different class lists. Greedy bin-packing is a function of the whole list and the PR head has a class main does not, so shard i was not the same slice on both sides and could pair a light head slice with a heavy base one. The base now replays the class list the head resolved, which makes it a subset of the head by construction: the pair is bounded by twice the head slice, the quantity the packer already balances. Option 4 of that issue ships alongside - a report missing a shard says so above the fold, since a partial comparison previously read exactly like a complete one. Closes #300. The comment-stripping rests on a real C# scanner rather than a //-prefix test, because // occurs inside literals and the verbatim / interpolated / raw forms desynchronise a guess; a --self-test pins it in a new benchmark-gate job. --shard-dry-run resolves a shard's class list without measuring, so the packing is inspectable without a multi-hour run.
1 parent 89026ce commit 71a30ae

9 files changed

Lines changed: 651 additions & 16 deletions

File tree

.github/workflows/benchmarks.yml

Lines changed: 120 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ on:
1212
- 'src/**'
1313
- '.github/workflows/benchmarks.yml'
1414

15+
# Only the newest run for a given pull request is meaningful — the dashboard and the
16+
# comparison comment both read the latest — so a push supersedes its predecessor rather
17+
# than stacking another eight-runner matrix behind it. Five pushes over one review loop
18+
# created five uncancelled runs and left `CI` and `Coverage`, the checks that actually
19+
# gate correctness, queued for ~50 minutes behind perf numbers nobody would read.
20+
#
21+
# The key is the PR number on the pull-request path and the commit SHA on the main path.
22+
# That makes every main push its own group, so `cancel-in-progress` can never discard one:
23+
# each commit's numbers are independently meaningful there, and a cancelled run would
24+
# leave a hole in the gh-pages time series.
25+
concurrency:
26+
group: benchmarks-${{ github.event.pull_request.number || github.sha }}
27+
cancel-in-progress: true
28+
1529
# The core suite is ~300 cases and, at full BenchmarkDotNet accuracy (Job.Default x2
1630
# launches, see CiConfig.cs), one pass takes ~3h. The PR path runs it TWICE on one
1731
# runner (PR head + main base, same-runner A/B, so hardware variance cancels), which
@@ -35,8 +49,49 @@ env:
3549
SHARD_TOTAL: '8'
3650

3751
jobs:
52+
# The `paths: src/**` trigger above is a path filter, not a semantic one: an XML
53+
# doc-comment edit is a `src/**` change and buys a full sharded A/B run for a diff with
54+
# zero IL in it. Two such PRs overlapping is what pushed every shard to 1.75-2.0x its
55+
# baseline and one of them into the timeout, so the cheapest correct fix is not
56+
# to run the suite when the diff provably cannot change what it measures.
57+
#
58+
# Pull requests only. A push to main always measures, so the gh-pages series never gains
59+
# a hole and a wrongly-skipped PR is still measured on merge — which caps the worst case
60+
# of a gate mistake at "the PR comment was missing", never "the regression was unseen".
61+
changes:
62+
name: relevance gate
63+
runs-on: ubuntu-latest
64+
timeout-minutes: 10
65+
66+
permissions:
67+
contents: read
68+
69+
outputs:
70+
run_benchmarks: ${{ steps.gate.outputs.run }}
71+
72+
steps:
73+
# fetch-depth: 0 so the script can resolve the merge base of the two event SHAs.
74+
- uses: actions/checkout@v4
75+
with:
76+
fetch-depth: 0
77+
78+
- name: Decide whether this diff can move a measured number
79+
id: gate
80+
run: |
81+
set -euo pipefail
82+
if [ "${{ github.event_name }}" != "pull_request" ]; then
83+
echo "Push to ${{ github.ref }} — always measured, so the published series has no gaps."
84+
echo "run=true" >> "$GITHUB_OUTPUT"
85+
exit 0
86+
fi
87+
node scripts/benchmark_relevant_changes.js \
88+
"${{ github.event.pull_request.base.sha }}" \
89+
"${{ github.event.pull_request.head.sha }}"
90+
3891
benchmark-shard:
3992
name: benchmark (shard ${{ matrix.shard }})
93+
needs: changes
94+
if: needs.changes.outputs.run_benchmarks == 'true'
4095
runs-on: ubuntu-latest
4196
# Measured at SHARD_TOTAL=6: slices ran 43-65 min, so head + base came to 86-130 min
4297
# and the heaviest shard overran this timeout. At 8 the same work divides further,
@@ -75,8 +130,14 @@ jobs:
75130
- name: Run head benchmarks (shard ${{ matrix.shard }})
76131
# working-directory must be the project folder, not src/. BenchmarkDotNet writes
77132
# its artifacts to ./BenchmarkDotNet.Artifacts relative to the process CWD.
133+
#
134+
# --shard-classes-out records the slice the packer resolved here, so the base run
135+
# below can replay it instead of packing its own. See that step for why.
78136
working-directory: src/Celerity.Benchmarks
79-
run: dotnet run --configuration Release -- --ci --shard "${SHARD_TOTAL}" "${{ matrix.shard }}"
137+
run: >-
138+
dotnet run --configuration Release --
139+
--ci --shard "${SHARD_TOTAL}" "${{ matrix.shard }}"
140+
--shard-classes-out /tmp/head-shard-classes.txt
80141
81142
- name: Stage head report
82143
run: |
@@ -91,11 +152,19 @@ jobs:
91152
# on THIS runner so hardware variance cancels (hosted runners vary 20-50%
92153
# run-to-run, so a stored cross-runner baseline would be noise-dominated).
93154
#
94-
# `--shard` is on main now, so the base tip honours it and measures only this
95-
# shard's slice — the transitional full-suite base run that this comment used to
96-
# warn about no longer happens. A cancellation here is therefore a real signal (the
97-
# slice genuinely exceeded the job timeout) rather than expected behaviour that will
98-
# self-heal, and should be investigated instead of dismissed.
155+
# The base REPLAYS the head's slice (--shard-classes) rather than packing its own.
156+
# Shard membership comes from greedy bin-packing over the benchmark class list, and
157+
# a PR that adds a benchmark class gives the two sides *different* lists — so shard
158+
# `i` was not the same slice on head and base, a job could draw a light head slice
159+
# and a heavy base slice, and the pair overran the timeout even though every
160+
# individual slice was well inside it. Replaying makes the base a subset of
161+
# the head by construction: the pair is bounded by twice the head slice, which is
162+
# the quantity the packer balances, and shard `i` compares like with like.
163+
#
164+
# A class the PR adds is simply absent from the base's own suite; the process says
165+
# so and skips it, and the comparison reports it as new rather than as a delta.
166+
# A cancellation here is a real signal (the slice genuinely exceeded the job
167+
# timeout) and should be investigated rather than dismissed.
99168
if: github.event_name == 'pull_request'
100169
working-directory: ${{ github.workspace }}
101170
run: |
@@ -105,8 +174,13 @@ jobs:
105174
echo "Benchmarking base (main) at $BASE_SHA, shard ${{ matrix.shard }}"
106175
git worktree add /tmp/base-tree "$BASE_SHA"
107176
cd /tmp/base-tree/src/Celerity.Benchmarks
108-
dotnet run --configuration Release -- --ci --shard "${SHARD_TOTAL}" "${{ matrix.shard }}"
109-
report=$(ls BenchmarkDotNet.Artifacts/results/*-report-full.json | head -n 1)
177+
dotnet run --configuration Release -- --ci --shard-classes /tmp/head-shard-classes.txt
178+
report=$(ls BenchmarkDotNet.Artifacts/results/*-report-full.json 2>/dev/null | head -n 1 || true)
179+
if [ -z "$report" ]; then
180+
echo "Base produced no report for shard ${{ matrix.shard }}: it shares no benchmark"
181+
echo "class with the head slice, so every case in it is new on this PR."
182+
exit 0
183+
fi
110184
cp "$report" "/tmp/reports/base-shard-${{ matrix.shard }}.json"
111185
echo "Staged base shard ${{ matrix.shard }}: $report"
112186
@@ -120,10 +194,11 @@ jobs:
120194

121195
aggregate:
122196
name: aggregate & report
123-
needs: benchmark-shard
124-
# Run even if some shards were cancelled/failed (e.g. the transitional base run
125-
# above) so partial results are still reported where possible.
126-
if: always()
197+
needs: [changes, benchmark-shard]
198+
# Run even if some shards were cancelled or failed so partial results are still
199+
# reported where possible — but say loudly which shards are missing (see the merge
200+
# step), because a partial report otherwise reads exactly like a complete one.
201+
if: always() && needs.changes.outputs.run_benchmarks == 'true'
127202
runs-on: ubuntu-latest
128203
timeout-minutes: 20
129204

@@ -148,6 +223,22 @@ jobs:
148223
run: |
149224
set -euo pipefail
150225
shopt -s nullglob
226+
227+
# Name the shards that did not report before anything else. `if: always()` means
228+
# this job runs on a partial matrix, and the merged report is then legitimately
229+
# missing whole benchmark classes — including, on the PR that motivated this,
230+
# part of the new collection's own numbers. Nothing downstream could tell.
231+
missing=""
232+
for s in $(seq 0 $((SHARD_TOTAL - 1))); do
233+
if [ ! -f "/tmp/reports/head-shard-${s}.json" ]; then
234+
missing="${missing}${missing:+, }${s}"
235+
fi
236+
done
237+
if [ -n "$missing" ]; then
238+
echo "::warning title=Incomplete benchmark report::Shard(s) ${missing} produced no report; the comparison below is missing every benchmark class packed onto them."
239+
fi
240+
echo "missing_shards=${missing}" >> "$GITHUB_OUTPUT"
241+
151242
head_files=(/tmp/reports/head-shard-*.json)
152243
if [ ${#head_files[@]} -eq 0 ]; then
153244
echo "No head shard reports found (all shards cancelled/failed) — nothing to report."
@@ -194,6 +285,7 @@ jobs:
194285
uses: actions/github-script@v7
195286
env:
196287
ALERT_THRESHOLD_RATIO: '1.10'
288+
MISSING_SHARDS: ${{ steps.merge.outputs.missing_shards }}
197289
with:
198290
github-token: ${{ secrets.GITHUB_TOKEN }}
199291
script: |
@@ -334,11 +426,27 @@ jobs:
334426
const collections = entries.filter(e => !e.isHasher);
335427
const hashers = entries.filter(e => e.isHasher);
336428
429+
// A partial merge otherwise reads exactly like a complete one: the tables are
430+
// well-formed and simply have fewer rows, so a silently-dropped shard looks
431+
// like a clean report. Say it above the fold, before any numbers.
432+
const missingShards = (process.env.MISSING_SHARDS || '').trim();
433+
const incomplete = missingShards.length === 0 ? [] : [
434+
`> [!WARNING]`,
435+
`> **Incomplete report.** Shard(s) \`${missingShards}\` produced no measurements, so every`,
436+
`> benchmark class packed onto them is missing from the tables below — including, possibly,`,
437+
`> the ones this PR changed. Treat the comparison as partial rather than as a clean run.`,
438+
'',
439+
];
440+
if (missingShards.length > 0) {
441+
core.warning(`Benchmark comparison is missing shard(s) ${missingShards}.`);
442+
}
443+
337444
const marker = '<!-- celerity-benchmarks-comment -->';
338445
const body = [
339446
marker,
340447
'## Benchmarks',
341448
'',
449+
...incomplete,
342450
subtitle,
343451
'',
344452
...highlights,

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,22 @@ jobs:
140140
- name: Check markdown anchors and relative links
141141
run: node scripts/check_doc_anchors.js
142142

143+
# The benchmark relevance gate decides whether an expensive sharded A/B run happens at
144+
# all, so the C# lexer it rests on is pinned here rather than only exercised in the
145+
# workflow it gates — where a wrong answer costs either runner hours or an unmeasured
146+
# regression, and neither failure announces itself.
147+
benchmark-gate:
148+
name: benchmark-gate
149+
runs-on: ubuntu-latest
150+
151+
steps:
152+
- uses: actions/checkout@v4
153+
with:
154+
filter: tree:0
155+
156+
- name: Pin the comment-stripping lexer
157+
run: node scripts/benchmark_relevant_changes.js --self-test
158+
143159
aot-publish:
144160
name: aot-publish (linux-x64, ${{ matrix.tfm }})
145161
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ All notable changes to Celerity are documented here. This project follows [Keep
2424
- `scripts/check_doc_anchors.js` — a CI guard that resolves every anchor link and relative file link across all tracked markdown, so a link that scrolls nowhere fails the build instead of shipping. Closes [#339](https://github.com/marius-bughiu/Celerity/issues/339).
2525
- A `--self-test` mode on that script, pinning the heading-slug rule against ids GitHub actually rendered, and a `doc-anchors` job in `ci.yml` that runs both modes on every PR. Closes [#339](https://github.com/marius-bughiu/Celerity/issues/339).
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).
27+
- `scripts/benchmark_relevant_changes.js` — a 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 is one-directional (anything it cannot prove inert runs the suite) and never applies to `main`, so a wrong answer costs a PR comment rather than an unmeasured regression. A `--self-test` mode pins its C# comment-stripping lexer, run by a new `benchmark-gate` job in `ci.yml`. Closes [#335](https://github.com/marius-bughiu/Celerity/issues/335).
28+
- A `--shard-dry-run` switch on the benchmarks runner that resolves a shard's class list and stops, so the packing can be inspected without a multi-hour measuring run. Closes [#300](https://github.com/marius-bughiu/Celerity/issues/300).
2729

2830
### Fixed
2931

32+
- Pushing to a pull request no longer stacks another eight-runner benchmark matrix behind the last — the workflow supersedes its own in-flight run. Five pushes over one review loop had left `CI` and `Coverage`, the checks that actually gate correctness, queued for ~50 minutes behind perf numbers nobody would read. Pushes to `main` are keyed per commit instead, so none is ever cancelled and the published history keeps every point. Closes [#319](https://github.com/marius-bughiu/Celerity/issues/319).
33+
- 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 rather than bin-packing its own, so shard *i* is the same slice on both sides and a job can no longer pair a light head slice with a heavy base one. Closes [#300](https://github.com/marius-bughiu/Celerity/issues/300).
34+
- A benchmark comparison that is missing a shard now says so above the fold in the PR comment and as a workflow warning. Previously a partial report was indistinguishable from a complete one — the tables were simply shorter. Closes [#300](https://github.com/marius-bughiu/Celerity/issues/300).
35+
3036
- `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`.
3137
- Eight documentation links pointed at anchors that do not exist: seven `CeleritySet` / `SwissSet` references in `docs/api/collections.md` and one in `CHANGELOG.md`. GitHub deletes `<`, `>` and `,` from a heading without substituting a separator, so `CeleritySet&lt;T, THasher&gt;` anchors as `#celeritysett-thasher`, not the `#celerityset-t-thasher` everyone writes. Closes [#339](https://github.com/marius-bughiu/Celerity/issues/339).
3238

CONTRIBUTING.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +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.
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.
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

88+
Three things about the run are worth knowing before you wonder why it did or did not happen:
89+
90+
- **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.
91+
- **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>`.
92+
- **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+
8894
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.
8995

9096
### The dashboard

0 commit comments

Comments
 (0)