Skip to content

Commit 5aedeb8

Browse files
committed
Compare benchmarks against a pinned release baseline
The benchmark job in test_gpu.yml now fails when a median runtime is more than 25% slower than the benchmark JSON attached to the release named by BENCHMARK_BASELINE_TAG (v0.2.0). upload_benchmark from #77 attaches that JSON when a release is published, so bumping the tag after a release moves the baseline. A small ubuntu-22.04 job finds the one .json asset on the pinned release and hands it to the benchmark job as an artifact, because the GPU runner image was reported in 2025 to lack gh (actions/runner-images#12398). A tag with no release, or a release with more than one JSON asset, fails that job and the benchmark job does not run. A release with no JSON asset only warns, and the run is recorded without a check. Release runs skip the lookup and record without comparing, so a regression or a lookup problem cannot keep upload_benchmark from attaching results. The plot and upload steps run even when the check fails. pytest-benchmark passes benchmarks missing from the baseline without comparing them, so a follow-up step warns about those and fails when the run shares no benchmark with the baseline. The machine_info mismatch warning is shown without failing, because filterwarnings = error would otherwise abort the session.
1 parent d90076f commit 5aedeb8

4 files changed

Lines changed: 120 additions & 1 deletion

File tree

.github/workflows/test_gpu.yml

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ defaults:
2626
run:
2727
shell: bash
2828

29+
env:
30+
# Release whose attached benchmark JSON is the regression baseline.
31+
# Bump it after publishing a release that has benchmark results attached.
32+
BENCHMARK_BASELINE_TAG: v0.2.0
33+
2934
jobs:
3035
test-gpu:
3136
strategy:
@@ -55,7 +60,46 @@ jobs:
5560
run: make test
5661

5762

63+
benchmark-baseline:
64+
# Releases record results for upload_benchmark without a regression check
65+
if: github.event_name != 'release'
66+
# The GPU runner image was reported in 2025 to lack gh (actions/runner-images#12398)
67+
runs-on: ubuntu-22.04
68+
outputs:
69+
found: ${{ steps.download.outputs.found }}
70+
71+
steps:
72+
- name: Download benchmark baseline
73+
id: download
74+
env:
75+
GH_TOKEN: ${{ github.token }}
76+
run: |
77+
# A tag with no release fails here; a release without a benchmark JSON only warns
78+
assets=$(gh release view "$BENCHMARK_BASELINE_TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name') \
79+
|| { echo "::error::Could not read the release for BENCHMARK_BASELINE_TAG=$BENCHMARK_BASELINE_TAG"; exit 1; }
80+
json=$(grep -E '\.json$' <<< "$assets" || true)
81+
count=$(grep -c . <<< "$json" || true)
82+
if [ "$count" -gt 1 ]; then
83+
echo "::error::Release $BENCHMARK_BASELINE_TAG has more than one JSON asset: $(tr '\n' ' ' <<< "$json")"
84+
exit 1
85+
elif [ "$count" -eq 1 ]; then
86+
gh release download "$BENCHMARK_BASELINE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$json" --output benchmark-baseline.json
87+
echo "found=true" >> "$GITHUB_OUTPUT"
88+
else
89+
echo "Release $BENCHMARK_BASELINE_TAG has no benchmark JSON"
90+
fi
91+
92+
- name: Upload benchmark baseline
93+
if: steps.download.outputs.found == 'true'
94+
uses: actions/upload-artifact@v5
95+
with:
96+
name: benchmark-baseline
97+
path: benchmark-baseline.json
98+
5899
benchmark:
100+
needs: benchmark-baseline
101+
# Runs on releases, where benchmark-baseline is skipped, and when the baseline lookup succeeded
102+
if: ${{ !cancelled() && (github.event_name == 'release' || needs.benchmark-baseline.result == 'success') }}
59103
strategy:
60104
matrix:
61105
python-version: ["3.11"]
@@ -79,18 +123,54 @@ jobs:
79123
with:
80124
python-version: ${{ matrix.python-version }}
81125

126+
- name: Download benchmark baseline
127+
if: needs.benchmark-baseline.outputs.found == 'true'
128+
uses: actions/download-artifact@v6
129+
with:
130+
name: benchmark-baseline
131+
132+
- name: Warn about a missing benchmark baseline
133+
if: github.event_name != 'release' && needs.benchmark-baseline.outputs.found != 'true'
134+
run: |
135+
msg="Benchmark baseline missing: release $BENCHMARK_BASELINE_TAG has no benchmark JSON, so this run is not checked for regressions."
136+
echo "::warning::$msg"
137+
echo "$msg" >> "$GITHUB_STEP_SUMMARY"
138+
82139
- name: Run CUDA benchmark
83-
run: make benchmark
140+
# Without benchmark-baseline.json this records a run without comparing
141+
run: make benchmark-compare BENCHMARK_BASELINE=benchmark-baseline.json
142+
143+
- name: Check which benchmarks the baseline covers
144+
if: always() && hashFiles('benchmark-baseline.json') != '' && hashFiles('.benchmarks/*/*.json') != ''
145+
run: |
146+
uv run --no-sync python - <<'EOF'
147+
import glob, json
148+
149+
def names(file):
150+
return {b["fullname"] for b in json.load(open(file))["benchmarks"]}
151+
152+
current = names(glob.glob(".benchmarks/*/*.json")[0])
153+
baseline = names("benchmark-baseline.json")
154+
if not current & baseline:
155+
print("::error::No benchmark from this run is in the baseline, so nothing was checked for regressions")
156+
raise SystemExit(1)
157+
if current - baseline:
158+
print("::warning::Not in the baseline, so not checked for regressions: " + ", ".join(sorted(current - baseline)))
159+
EOF
84160
85161
- name: Plot benchmark (runtime)
162+
# Keep the results when the regression check fails the job
163+
if: always() && hashFiles('.benchmarks/**') != ''
86164
run:
87165
uv run --group compare tests/plot_benchmark.py --output .benchmarks/benchmark.png
88166

89167
- name: Plot benchmark (throughput)
168+
if: always() && hashFiles('.benchmarks/**') != ''
90169
run:
91170
uv run --group compare tests/plot_benchmark.py --points-per-second --output .benchmarks/benchmark_pps.png
92171

93172
- name: Upload benchmark results
173+
if: always() && hashFiles('.benchmarks/**') != ''
94174
uses: actions/upload-artifact@v5
95175
with:
96176
name: benchmark-results

BENCHMARKS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,28 @@ uv run --group compare tests/plot_benchmark.py --points-per-second --output asse
7272

7373
The benchmark job in our CI pipeline ([`test_gpu.yml`](https://github.com/Forest-Neurotech/mach/blob/main/.github/workflows/test_gpu.yml)) automatically runs these benchmarks (on a T4 GPU) across different commits and releases, providing continuous performance monitoring.
7474

75+
### Regression checking
76+
77+
`make benchmark-compare` runs the same benchmarks as `make benchmark` and fails when a median runtime is more than 25% slower than a saved pytest-benchmark JSON file:
78+
79+
```bash
80+
# Compare against a JSON saved under .benchmarks/ by an earlier run
81+
make benchmark-compare BENCHMARK_BASELINE=path/to/earlier-run.json
82+
83+
# Use a tighter threshold
84+
make benchmark-compare BENCHMARK_BASELINE=path/to/earlier-run.json BENCHMARK_REGRESSION_THRESHOLD=median:10%
85+
```
86+
87+
If the baseline file does not exist, the target records a run without comparing.
88+
Only benchmarks that also appear in the baseline are checked.
89+
In CI, the others are listed in a warning, and the job fails if no benchmark from the run is in the baseline.
90+
pytest-benchmark warns that `machine_info` is different when any field of it differs from the baseline, host name included, and still runs the comparison.
91+
92+
In CI, the baseline is the benchmark JSON attached to the GitHub release named by `BENCHMARK_BASELINE_TAG` in [`test_gpu.yml`](https://github.com/Forest-Neurotech/mach/blob/main/.github/workflows/test_gpu.yml), so every run compares against the same results until the tag is bumped.
93+
Publishing a release runs the benchmark without the regression check and attaches the results to that release; to make them the new baseline, set `BENCHMARK_BASELINE_TAG` to that release's tag.
94+
If the pinned release has no benchmark JSON, the benchmark job records its results without a regression check and shows a warning, also written to the job summary.
95+
If `BENCHMARK_BASELINE_TAG` names no release, the release has more than one JSON asset, or the lookup fails for another reason, the `benchmark-baseline` job fails and the benchmark job does not run.
96+
7597
## CUDA Optimizations
7698

7799
mach optimizes GPU memory access patterns to improve performance. For those interested in learning more about CUDA optimization, excellent resources include:

Makefile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,21 @@ benchmark: ## Runs benchmarking comparisons
8080
@echo "🚀 Running benchmarking comparisons"
8181
uv run --group test --group array --group compare pytest tests -v -s --benchmark-only --benchmark-histogram --benchmark-autosave --benchmark-save-data
8282

83+
# pytest-benchmark JSON to compare against (CI downloads it from a pinned release), and the
84+
# --benchmark-compare-fail expression that counts as a regression.
85+
BENCHMARK_BASELINE ?= benchmark-baseline.json
86+
BENCHMARK_REGRESSION_THRESHOLD ?= median:25%
87+
88+
.PHONY: benchmark-compare
89+
benchmark-compare: ## Runs benchmarks and fails if they regressed against BENCHMARK_BASELINE
90+
@if [ -f "$(BENCHMARK_BASELINE)" ]; then \
91+
echo "🚀 Comparing against $(BENCHMARK_BASELINE), failing on a $(BENCHMARK_REGRESSION_THRESHOLD) regression"; \
92+
uv run --group test --group array --group compare pytest tests -v -s --benchmark-only --benchmark-histogram --benchmark-autosave --benchmark-save-data --benchmark-compare="$(BENCHMARK_BASELINE)" --benchmark-compare-fail=$(BENCHMARK_REGRESSION_THRESHOLD); \
93+
else \
94+
echo "No baseline at $(BENCHMARK_BASELINE); recording a run without a regression check"; \
95+
$(MAKE) --no-print-directory benchmark; \
96+
fi
97+
8398
.PHONY: profile
8499
profile: ## Runs Python test with simple profiling. Recommend using Nsight Compute or Nsight Systems for more detailed profiling.
85100
@echo "Building with CUDA_PROFILE"

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ filterwarnings = [
217217
# vbeam warnings
218218
"ignore:point_position will be overwritten by the scan.:UserWarning",
219219
"ignore:Both point_position and scan are set. Scan will be used.:UserWarning",
220+
# benchmark-compare warns when any baseline machine_info field differs, host name included; show it without failing
221+
"default:Benchmark machine_info is different:UserWarning",
220222
]
221223
markers = [
222224
# see conftest.py for default-addition of cuda marker

0 commit comments

Comments
 (0)