Skip to content

Fail the benchmark job when performance regresses - #73

Open
Kayvan-Zahiri wants to merge 2 commits into
Forest-Neurotech:mainfrom
Kayvan-Zahiri:regression-gate
Open

Fail the benchmark job when performance regresses#73
Kayvan-Zahiri wants to merge 2 commits into
Forest-Neurotech:mainfrom
Kayvan-Zahiri:regression-gate

Conversation

@Kayvan-Zahiri

Copy link
Copy Markdown

Closes #16.

The benchmark job saves results and uploads them as an artifact, but nothing compares them, so a slowdown can land without failing anything. This adds the comparison.

make benchmark-compare runs the suite and exits non-zero when the median regresses past BENCHMARK_REGRESSION_THRESHOLD (default median:25%). The GPU job now calls it and restores the previous run from the Actions cache first. Caches from the default branch are readable by pull requests, so a PR is compared against the last baseline recorded on main.

Two things I checked rather than assumed:

  • Missing baseline has to be handled. --benchmark-compare-fail without a loadable comparison is a usage error: ERROR: --benchmark-compare-fail requires valid --benchmark-compare, exit 4. That would break the first run and every cache miss, so the target records a baseline instead of comparing when .benchmarks is empty. Verified under /bin/sh, since recipes do not use my shell.
  • The gate actually fires. On a scratch benchmark, an unchanged function passed, and making it ~3x slower failed with Field 'median' has failed PercentageRegressionCheck: 213.38 > 25.0 and exit 1.

I could not run the GPU job itself, so the threshold is a starting point rather than a measured one. 25% is deliberately loose: the runner is shared, and a tighter bound would fail on neighbours rather than on the change. It is a make variable so you can tighten it once you see real numbers, and I am happy to change the default or the comparison field if you would rather gate on min.

The CI benchmark job saved results and uploaded them, but never compared
them, so a slowdown could land without anything noticing.

Adds a benchmark-compare target that runs the suite and exits non-zero when
the median regresses past a threshold, and points the GPU job at it, with the
previous baseline restored from the Actions cache. Caches from the default
branch are readable by pull requests, so a PR is compared against the last
run recorded on main.

The target records a baseline instead of comparing when no saved run exists.
That case needs handling: --benchmark-compare-fail without a loadable
comparison is a usage error and exits 4, which would break the first run and
every cache miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVVbEALa5xcixrx3C3r5Xz
@qodo-code-review

qodo-code-review Bot commented Sep 7, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Later pull request commits evade checks 🐞 Bug ≡ Correctness ⭐ New
Description
The new make benchmark-compare step is hosted in a workflow whose pull_request.types omits
synchronize. After a pull request is opened or marked ready, any regression added by a later push
is never evaluated by this gate unless someone manually dispatches the workflow.
Code

.github/workflows/test_gpu.yml[R89-90]

      - name: Run CUDA benchmark
-        run: make benchmark
+        run: make benchmark-compare
Evidence
The workflow accepts only opened, reopened, and ready-for-review pull-request events, while the
newly added regression gate runs solely as a step in that workflow. Consequently, pushes to an
already-open pull request do not invoke the comparison.

.github/workflows/test_gpu.yml[7-10]
.github/workflows/test_gpu.yml[89-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The benchmark regression gate does not run when new commits are pushed to an existing pull request because the workflow excludes the `synchronize` event.

## Issue Context
The comparison step therefore only evaluates the commit present when the pull request is opened, reopened, or marked ready, unless the workflow is manually dispatched.

## Fix Focus Areas
- .github/workflows/test_gpu.yml[7-10]
- .github/workflows/test_gpu.yml[89-90]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Cumulative slowdowns can pass the gate ✓ Resolved 🐞 Bug ≡ Correctness
Description
actions/cache saves the entire .benchmarks directory under a pull-request SHA, while
benchmark-compare autosaves the pull request's result into that same directory. A rerun or later
eligible run on the same pull-request ref restores that current-ref cache before default-branch
caches, so each sub-threshold slowdown can be compared with the preceding pull-request result and a
cumulative regression beyond 25% can pass.
Code

.github/workflows/test_gpu.yml[R85-87]

+          key: mach-benchmark-baseline-${{ matrix.os }}-${{ github.sha }}
+          restore-keys: |
+            mach-benchmark-baseline-${{ matrix.os }}-
Evidence
The workflow runs for both main pushes and multiple pull-request event types, caches the complete
.benchmarks directory under the current SHA, and falls back to the same key prefix. The Make
target then writes the current benchmark into that cached directory with --benchmark-autosave, so
a successful pull-request run becomes restorable comparison data for later runs on that pull-request
ref.

.github/workflows/test_gpu.yml[3-20]
.github/workflows/test_gpu.yml[79-90]
Makefile[87-95]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Pull-request benchmark output is saved into the same cache namespace used to restore the main-branch baseline. Subsequent runs can therefore compare against an earlier pull-request result rather than the latest successful main result, allowing cumulative regressions to evade the threshold.

## Issue Context
Use restore-only caching for pull requests. Save a clean benchmark baseline only after a successful default-branch benchmark run, ensuring pull-request autosaved results never enter caches that future comparisons can restore as baselines.

## Fix Focus Areas
- .github/workflows/test_gpu.yml[79-90]
- Makefile[87-95]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Maintainers lose regression diagnostics ✓ Resolved 🐞 Bug ◔ Observability ⭐ New
Description
make benchmark-compare propagates pytest's regression exit status into the Run CUDA benchmark
step, while the plot and upload steps retain the default success-only condition. When the gate
fires, Actions skips both plots and upload-artifact, leaving maintainers without the generated
benchmark JSON and visual diagnostics for the failing run.
Code

.github/workflows/test_gpu.yml[R89-90]

      - name: Run CUDA benchmark
-        run: make benchmark
+        run: make benchmark-compare
Evidence
The Make recipe returns pytest's nonzero comparison status, and the workflow places both plot
commands and artifact upload after that failing step without an always() or equivalent condition.
GitHub Actions therefore skips every diagnostic step when a regression is detected.

Makefile[90-95]
.github/workflows/test_gpu.yml[89-105]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A detected benchmark regression fails the comparison step before the workflow can plot or upload the failing benchmark results.

## Issue Context
Preserve the nonzero job result while ensuring diagnostic generation and artifact upload execute after a regression failure. Guard those steps appropriately so unrelated earlier failures do not create misleading secondary errors.

## Fix Focus Areas
- .github/workflows/test_gpu.yml[89-105]
- Makefile[90-95]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This changes CI workflow behavior and benchmark gating logic, with cache-baseline semantics and failure conditions that warrant a careful single-pass review; it is not dense enough for extended.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 921b655

Results up to commit 0de9577 ⚖️ Balanced


🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Cumulative slowdowns can pass the gate 🐞 Bug ≡ Correctness
Description
actions/cache saves the entire .benchmarks directory under a pull-request SHA, while
benchmark-compare autosaves the pull request's result into that same directory. A rerun or later
eligible run on the same pull-request ref restores that current-ref cache before default-branch
caches, so each sub-threshold slowdown can be compared with the preceding pull-request result and a
cumulative regression beyond 25% can pass.
Code

.github/workflows/test_gpu.yml[R85-87]

+          key: mach-benchmark-baseline-${{ matrix.os }}-${{ github.sha }}
+          restore-keys: |
+            mach-benchmark-baseline-${{ matrix.os }}-
Evidence
The workflow runs for both main pushes and multiple pull-request event types, caches the complete
.benchmarks directory under the current SHA, and falls back to the same key prefix. The Make
target then writes the current benchmark into that cached directory with --benchmark-autosave, so
a successful pull-request run becomes restorable comparison data for later runs on that pull-request
ref.

.github/workflows/test_gpu.yml[3-20]
.github/workflows/test_gpu.yml[79-90]
Makefile[87-95]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Pull-request benchmark output is saved into the same cache namespace used to restore the main-branch baseline. Subsequent runs can therefore compare against an earlier pull-request result rather than the latest successful main result, allowing cumulative regressions to evade the threshold.

## Issue Context
Use restore-only caching for pull requests. Save a clean benchmark baseline only after a successful default-branch benchmark run, ensuring pull-request autosaved results never enter caches that future comparisons can restore as baselines.

## Fix Focus Areas
- .github/workflows/test_gpu.yml[79-90]
- Makefile[87-95]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread .github/workflows/test_gpu.yml Outdated
Comment on lines 89 to +90
- name: Run CUDA benchmark
run: make benchmark
run: make benchmark-compare

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Later pull request commits evade checks 🐞 Bug ≡ Correctness

The new make benchmark-compare step is hosted in a workflow whose pull_request.types omits
synchronize. After a pull request is opened or marked ready, any regression added by a later push
is never evaluated by this gate unless someone manually dispatches the workflow.
Agent Prompt
## Issue description
The benchmark regression gate does not run when new commits are pushed to an existing pull request because the workflow excludes the `synchronize` event.

## Issue Context
The comparison step therefore only evaluates the commit present when the pull request is opened, reopened, or marked ready, unless the workflow is manually dispatched.

## Fix Focus Areas
- .github/workflows/test_gpu.yml[7-10]
- .github/workflows/test_gpu.yml[89-90]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread .github/workflows/test_gpu.yml
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 0de9577

A pull request that saved its own benchmark into the restorable cache would
become the baseline for the next run on that same ref, so a series of
sub-threshold slowdowns could each pass while compounding past the threshold.
Restore is now read-only and only a push to main saves.

The plot and upload steps also ran on success only, so a regression skipped
exactly the artifacts needed to diagnose it. They now run whenever results
exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVVbEALa5xcixrx3C3r5Xz
@Kayvan-Zahiri

Copy link
Copy Markdown
Author

Two of the three were real and are fixed in 921b655.

Cache poisoning. Correct, and it would have made the gate useless over time. A pull request saved .benchmarks under its own SHA with a shared restore prefix, so the next run on that ref restored the PR's own result and compared against it. Four sub-threshold slowdowns could compound past the threshold and every one would pass. Restore is now actions/cache/restore and only a push to main runs actions/cache/save, so nothing a PR produces can ever be restored as a baseline.

Lost diagnostics. Also correct: the plot and upload steps were success-only, so a regression skipped exactly the artifacts you would want to look at. They now run on always() && hashFiles('.benchmarks/**') != '', which keeps them on failure without firing when there is nothing to upload.

The synchronize point I have left alone deliberately. It is right that pull_request.types is [opened, reopened, ready_for_review], so the gate only sees a PR's first commit and a later push can slip a regression past it. But adding synchronize makes every push to every PR run the whole GPU job, not just this step, and that is a cost decision on your runners rather than mine to make in a first contribution. Happy to add it in this PR if you want it.

Still untested against real hardware: I have no GPU runner, so the 25% default is reasoned rather than measured.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance regression test for pytest-benchmark

1 participant