docs(CuckooFilter): correct when IsFull clears #284
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Benchmarks | |
| on: | |
| push: | |
| branches: [ main ] | |
| paths: | |
| - 'src/**' | |
| - '.github/workflows/benchmarks.yml' | |
| pull_request: | |
| branches: [ main ] | |
| paths: | |
| - 'src/**' | |
| - '.github/workflows/benchmarks.yml' | |
| # The core suite is ~300 cases and, at full BenchmarkDotNet accuracy (Job.Default x2 | |
| # launches, see CiConfig.cs), one pass takes ~3h. The PR path runs it TWICE on one | |
| # runner (PR head + main base, same-runner A/B, so hardware variance cancels), which | |
| # blew past GitHub's 6h job ceiling and was cancelled on every PR (#217). Rather than | |
| # trade away measurement accuracy, we shard the suite across a parallel matrix: each | |
| # shard measures only its slice (head + base) on its own runner, so wall time is | |
| # ~3h / SHARD_TOTAL at full accuracy, and an aggregate job stitches the per-shard JSON | |
| # back together for the regression comment / gh-pages publish. | |
| # | |
| # The same-runner A/B invariant is preserved because sharding is by benchmark *class* | |
| # (Program.cs `--shard`): a class's PR-head and main-base measurements always run | |
| # together on the one shard runner, so the per-benchmark delta still cancels hardware. | |
| env: | |
| # Keep in sync with the matrix.shard list below. | |
| SHARD_TOTAL: '6' | |
| jobs: | |
| benchmark-shard: | |
| name: benchmark (shard ${{ matrix.shard }}) | |
| runs-on: ubuntu-latest | |
| # Steady state: head slice (~30 min) + base slice (~30 min) ~= 60 min. The one | |
| # transitional run of THIS PR is slower (see the base step) and may be cancelled | |
| # here — that is no worse than the status quo (the gate is cancelled on every PR | |
| # today) and self-heals the moment `--shard` lands on main. | |
| timeout-minutes: 120 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # Must enumerate 0 .. SHARD_TOTAL-1. | |
| shard: [0, 1, 2, 3, 4, 5] | |
| permissions: | |
| contents: read | |
| defaults: | |
| run: | |
| working-directory: src | |
| steps: | |
| # NOTE: do NOT add `filter: tree:0` here. A blobless partial clone makes the repo | |
| # advertise every commit as "have" while missing the underlying objects, which | |
| # breaks the base `git worktree add <base-sha>` below (and, in the aggregate job, | |
| # github-action-benchmark's own gh-pages fetch) with "missing blob object … did | |
| # not send all necessary objects" (#217). fetch-depth: 0 is required for the | |
| # base worktree. | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: 8.0.x | |
| - name: Run head benchmarks (shard ${{ matrix.shard }}) | |
| # working-directory must be the project folder, not src/. BenchmarkDotNet writes | |
| # its artifacts to ./BenchmarkDotNet.Artifacts relative to the process CWD. | |
| working-directory: src/Celerity.Benchmarks | |
| run: dotnet run --configuration Release -- --ci --shard "${SHARD_TOTAL}" "${{ matrix.shard }}" | |
| - name: Stage head report | |
| run: | | |
| set -euo pipefail | |
| mkdir -p /tmp/reports | |
| report=$(ls Celerity.Benchmarks/BenchmarkDotNet.Artifacts/results/*-report-full.json | head -n 1) | |
| cp "$report" "/tmp/reports/head-shard-${{ matrix.shard }}.json" | |
| echo "Staged head shard ${{ matrix.shard }}: $report" | |
| - name: Run base (main) benchmarks for this shard on the same runner | |
| # Same-runner A/B: build and benchmark the main tip back-to-back with the PR head | |
| # on THIS runner so hardware variance cancels (hosted runners vary 20-50% | |
| # run-to-run, so a stored cross-runner baseline would be noise-dominated). | |
| # | |
| # TRANSITIONAL: until this PR's `--shard` support lands on main, the base tip's | |
| # `--ci` does not understand `--shard` and runs the FULL ~3h suite here, so these | |
| # base steps will exceed the job timeout and be cancelled on THIS PR only. That | |
| # matches today's behaviour (the gate is already cancelled on every PR) and the | |
| # workflow self-heals once merged — every later PR's base honours `--shard`. | |
| if: github.event_name == 'pull_request' | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| set -euo pipefail | |
| git fetch origin main | |
| BASE_SHA=$(git rev-parse origin/main) | |
| echo "Benchmarking base (main) at $BASE_SHA, shard ${{ matrix.shard }}" | |
| git worktree add /tmp/base-tree "$BASE_SHA" | |
| cd /tmp/base-tree/src/Celerity.Benchmarks | |
| dotnet run --configuration Release -- --ci --shard "${SHARD_TOTAL}" "${{ matrix.shard }}" | |
| report=$(ls BenchmarkDotNet.Artifacts/results/*-report-full.json | head -n 1) | |
| cp "$report" "/tmp/reports/base-shard-${{ matrix.shard }}.json" | |
| echo "Staged base shard ${{ matrix.shard }}: $report" | |
| - name: Upload shard reports | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: benchmark-shard-${{ matrix.shard }} | |
| path: /tmp/reports/** | |
| if-no-files-found: error | |
| retention-days: 30 | |
| aggregate: | |
| name: aggregate & report | |
| needs: benchmark-shard | |
| # Run even if some shards were cancelled/failed (e.g. the transitional base run | |
| # above) so partial results are still reported where possible. | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Download all shard reports | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: benchmark-shard-* | |
| merge-multiple: true | |
| path: /tmp/reports | |
| - name: Merge shard reports | |
| id: merge | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| head_files=(/tmp/reports/head-shard-*.json) | |
| if [ ${#head_files[@]} -eq 0 ]; then | |
| echo "No head shard reports found (all shards cancelled/failed) — nothing to report." | |
| echo "have_head=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "have_head=true" >> "$GITHUB_OUTPUT" | |
| # Stitch every shard's joined report back into one: keep shard 0's metadata, | |
| # concatenate all shards' Benchmarks arrays. | |
| jq -s '.[0] * {Benchmarks: ([.[].Benchmarks] | add)}' "${head_files[@]}" > /tmp/pr-report-full.json | |
| echo "Merged $(jq '.Benchmarks | length' /tmp/pr-report-full.json) head benchmarks from ${#head_files[@]} shard(s)." | |
| base_files=(/tmp/reports/base-shard-*.json) | |
| if [ ${#base_files[@]} -gt 0 ]; then | |
| jq -s '.[0] * {Benchmarks: ([.[].Benchmarks] | add)}' "${base_files[@]}" > /tmp/base-report-full.json | |
| echo "have_base=true" >> "$GITHUB_OUTPUT" | |
| echo "Merged $(jq '.Benchmarks | length' /tmp/base-report-full.json) base benchmarks from ${#base_files[@]} shard(s)." | |
| else | |
| echo "have_base=false" >> "$GITHUB_OUTPUT" | |
| echo "No base shard reports (main push, or base run cancelled)." | |
| fi | |
| - name: Resolve base SHA (for the comment footer) | |
| if: github.event_name == 'pull_request' && steps.merge.outputs.have_head == 'true' | |
| run: | | |
| set -euo pipefail | |
| git fetch origin main | |
| echo "BASE_SHA=$(git rev-parse origin/main)" >> "$GITHUB_ENV" | |
| - name: Upload merged reports | |
| if: steps.merge.outputs.have_head == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: benchmark-results | |
| path: /tmp/*-report-full.json | |
| if-no-files-found: warn | |
| retention-days: 30 | |
| # ----- PR path: post the same-runner A/B comment ----- | |
| - name: Post benchmark comment | |
| if: github.event_name == 'pull_request' && steps.merge.outputs.have_head == 'true' && steps.merge.outputs.have_base == 'true' | |
| uses: actions/github-script@v7 | |
| env: | |
| ALERT_THRESHOLD_RATIO: '1.10' | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const pr = JSON.parse(fs.readFileSync('/tmp/pr-report-full.json', 'utf8')); | |
| const base = JSON.parse(fs.readFileSync('/tmp/base-report-full.json', 'utf8')); | |
| // Index base results by full benchmark name. | |
| const baseMap = new Map(); | |
| for (const b of base.Benchmarks) { | |
| baseMap.set(b.FullName, b.Statistics); | |
| } | |
| const formatNs = (ns) => { | |
| if (ns == null) return 'n/a'; | |
| if (ns < 1000) return `${ns.toFixed(1)} ns`; | |
| if (ns < 1_000_000) return `${(ns/1000).toFixed(2)} μs`; | |
| if (ns < 1_000_000_000) return `${(ns/1_000_000).toFixed(2)} ms`; | |
| return `${(ns/1_000_000_000).toFixed(2)} s`; | |
| }; | |
| const threshold = parseFloat(process.env.ALERT_THRESHOLD_RATIO); | |
| const thresholdPct = ((threshold - 1) * 100).toFixed(0); | |
| // Each entry: { name, isHasher, prCell, stdCell, baseCell, deltaCell, flag } | |
| // where flag is 'regression' | 'improvement' | null. | |
| const entries = []; | |
| let regressions = 0; | |
| let improvements = 0; | |
| let errored = 0; | |
| for (const b of pr.Benchmarks) { | |
| const name = b.FullName; | |
| // BenchmarkDotNet emits a benchmark with null Statistics and empty | |
| // Measurements when that case errored or was not run (a throwing | |
| // [GlobalSetup], a cancelled shard, an OOM, ...). Surface it as a row | |
| // rather than dereferencing null — one errored case must never crash | |
| // the aggregate job and red-X the whole PR gate. | |
| if (b.Statistics == null) { | |
| errored++; | |
| const baseStats = baseMap.get(name); | |
| entries.push({ | |
| name, | |
| isHasher: /Hasher/.test(name), | |
| prCell: '⚠️ errored', | |
| stdCell: 'n/a', | |
| baseCell: baseStats ? formatNs(baseStats.Mean) : 'n/a', | |
| deltaCell: 'no measurements', | |
| flag: null, | |
| }); | |
| continue; | |
| } | |
| const prMean = b.Statistics.Mean; | |
| const prStdDev = b.Statistics.StandardDeviation; | |
| const baseStats = baseMap.get(name); | |
| let deltaCell = baseMap.has(name) ? '⚠️ base errored' : '🆕 new'; | |
| let flag = null; | |
| if (baseStats) { | |
| const baseMean = baseStats.Mean; | |
| const baseStdDev = baseStats.StandardDeviation; | |
| const ratio = prMean / baseMean; | |
| const pct = (ratio - 1) * 100; | |
| const sign = pct >= 0 ? '+' : ''; | |
| deltaCell = `${sign}${pct.toFixed(1)}%`; | |
| // Flag only when the change clears the threshold AND exceeds the | |
| // combined run-to-run noise of both measurements. | |
| const beyondNoise = Math.abs(prMean - baseMean) > (prStdDev + baseStdDev); | |
| if (ratio >= threshold && beyondNoise) { | |
| deltaCell += ' ⚠️'; | |
| flag = 'regression'; | |
| regressions++; | |
| } else if (ratio <= (1 / threshold) && beyondNoise) { | |
| deltaCell += ' ✅'; | |
| flag = 'improvement'; | |
| improvements++; | |
| } | |
| } | |
| entries.push({ | |
| name, | |
| // Hasher throughput benchmarks (StringHasherBenchmark, | |
| // IntegerHasherBenchmark) get their own section; everything else | |
| // is a collection benchmark. | |
| isHasher: /Hasher/.test(name), | |
| prCell: formatNs(prMean), | |
| stdCell: formatNs(prStdDev), | |
| baseCell: baseStats ? formatNs(baseStats.Mean) : 'n/a', | |
| deltaCell, | |
| flag, | |
| }); | |
| } | |
| let subtitle; | |
| if (regressions > 0) { | |
| subtitle = `${regressions} regression${regressions === 1 ? '' : 's'} ⚠️ vs main` + | |
| (improvements > 0 ? `, ${improvements} improvement${improvements === 1 ? '' : 's'} ✅` : '') + | |
| ` (rows past ±${thresholdPct}% beyond noise).`; | |
| } else if (improvements > 0) { | |
| subtitle = `No regressions — ${improvements} improvement${improvements === 1 ? '' : 's'} ✅ vs main.`; | |
| } else { | |
| subtitle = `No significant change vs main (all within ±${thresholdPct}% or noise).`; | |
| } | |
| if (errored > 0) { | |
| subtitle += ` ⚠️ ${errored} benchmark${errored === 1 ? '' : 's'} errored (no measurements) — see the table.`; | |
| } | |
| const baseSha = (process.env.BASE_SHA || '').slice(0, 7); | |
| 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>`; | |
| const header = ['| Benchmark | This PR | StdDev | main | Δ |', '|---|---:|---:|---:|---:|']; | |
| const toRow = (e) => `| \`${e.name}\` | ${e.prCell} | ${e.stdCell} | ${e.baseCell} | ${e.deltaCell} |`; | |
| // A collapsible section per benchmark family, collapsed by default so | |
| // the (often large) tables stay below the fold. | |
| const section = (title, list) => { | |
| if (list.length === 0) return []; | |
| return [ | |
| '<details>', | |
| `<summary><b>${title}</b> (${list.length})</summary>`, | |
| '', | |
| ...header, | |
| ...list.map(toRow), | |
| '</details>', | |
| '', | |
| ]; | |
| }; | |
| // Highlights: only the flagged rows stay above the fold so reviewers | |
| // see what actually moved without expanding either table. | |
| const flagged = entries.filter(e => e.flag); | |
| const highlights = flagged.length === 0 ? [] : [ | |
| '**Highlights**', | |
| '', | |
| ...header, | |
| ...flagged.map(toRow), | |
| '', | |
| ]; | |
| const collections = entries.filter(e => !e.isHasher); | |
| const hashers = entries.filter(e => e.isHasher); | |
| const marker = '<!-- celerity-benchmarks-comment -->'; | |
| const body = [ | |
| marker, | |
| '## Benchmarks', | |
| '', | |
| subtitle, | |
| '', | |
| ...highlights, | |
| ...section('Collections', collections), | |
| ...section('Hashers', hashers), | |
| footer, | |
| ].join('\n'); | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| }); | |
| const existing = comments.find(c => c.body && c.body.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| core.info(`Updated comment ${existing.id}`); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body, | |
| }); | |
| core.info('Created new benchmarks comment'); | |
| } | |
| # ----- Main path: publish merged report to gh-pages ----- | |
| - name: Stage merged report for publish | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.merge.outputs.have_head == 'true' | |
| run: | | |
| set -euo pipefail | |
| mkdir -p src/Celerity.Benchmarks/BenchmarkDotNet.Artifacts/results | |
| cp /tmp/pr-report-full.json src/Celerity.Benchmarks/BenchmarkDotNet.Artifacts/results/ci-report-full.json | |
| - name: Publish to gh-pages | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.merge.outputs.have_head == 'true' | |
| uses: benchmark-action/github-action-benchmark@v1 | |
| with: | |
| name: Celerity Benchmarks | |
| tool: 'benchmarkdotnet' | |
| output-file-path: src/Celerity.Benchmarks/BenchmarkDotNet.Artifacts/results/ci-report-full.json | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| auto-push: true | |
| comment-on-alert: false | |
| comment-always: false | |
| summary-always: true | |
| fail-on-alert: false | |
| gh-pages-branch: gh-pages | |
| benchmark-data-dir-path: dev/bench | |
| - name: Sync custom dashboard to gh-pages | |
| # Runs only on main push (same gate as the action's auto-push). | |
| # Copies web/index.html + web/dev/bench/*.html into gh-pages, | |
| # leaving the action-managed data.js untouched. Idempotent. | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.merge.outputs.have_head == 'true' | |
| run: | | |
| set -euo pipefail | |
| git fetch origin gh-pages | |
| git worktree add /tmp/gh-pages gh-pages | |
| cp web/index.html /tmp/gh-pages/index.html | |
| mkdir -p /tmp/gh-pages/dev/bench | |
| cp web/dev/bench/index.html /tmp/gh-pages/dev/bench/index.html | |
| cp web/dev/bench/detail.html /tmp/gh-pages/dev/bench/detail.html | |
| cd /tmp/gh-pages | |
| if git diff --quiet && git diff --cached --quiet; then | |
| echo "No dashboard changes to sync" | |
| exit 0 | |
| fi | |
| git -c user.name="github-actions" -c user.email="github-actions@github.com" \ | |
| add index.html dev/bench/index.html dev/bench/detail.html | |
| git -c user.name="github-actions" -c user.email="github-actions@github.com" \ | |
| commit -m "Sync custom dashboard from ${GITHUB_SHA:0:7}" | |
| git push origin gh-pages |