# How to build a Go benchmark PR gate that actually fails > [!summary] TL;DR > One GitHub Actions job builds the base and head benchmark binaries, runs them interleaved (base, head, base, head) for 10 rounds at 200 ms, and fails when benchstat calls the delta real (p < 0.05) and at least +5%. benchstat exits 0 even on a +199.00% table, so an awk pass over its CSV output makes the verdict. The slow commit went red at +198.57% (p=0.000 n=10); its revert went green with ~ (p=0.782 n=10). ## Key points - The gate blocks only when benchstat says the delta is real (p < 0.05) and at least MIN_DELTA_PCT = 5%. A faster result is ignored. - benchstat exits 0 even when its table says +199.00% (checked with two made-up result files, the pinned version, Go 1.27.1), so the workflow parses benchstat's CSV output with awk. - One job builds both binaries and runs them on the same hosted VM, interleaved: base, head, base, head. ROUNDS=10 is both the ABAB round count and the sample count per side (n=10, the floor the benchstat docs recommend). - BENCHTIME is 200 ms; the whole job took about 35 seconds. Part 4's nightly suite can afford -count=20 -benchtime=5s on controlled hardware; this gate cannot, on every push. - benchgate is the noise gate: exit 1 when any benchmark's coefficient of variation is above CV_THRESHOLD=10. benchstat is the regression gate. - Verdict exit codes: 0 pass, 1 regression, 2 tool or setup error, 3 unstable sample. A regression wins over "unstable"; each finding becomes an ::error annotation on the PR. - The slow commit routes every addition through a helper marked //go:noinline, roughly 3x slower on purpose. - The demo PR targets a branch that already holds the workflow and the benchmark, because a PR against main has no base-side benchmark. - Run 1: Ints-4 went from 2.579µs to 7.700µs, +198.57% (p=0.000 n=10), benchgate CV 3.1%, red check (exit 1), on an AMD EPYC 7763 runner. - Run 2, a minute later on the same kind of hosted runner: ~ (p=0.782 n=10), CV 3.4%, exit 0. - benchgate v0.1.0 -baseline only prints the benchstat output; the exit code never looks at it. benchgate runs its own go test on the head tree, one after another, so it measures how noisy the runner was, not the interleaved samples. - The ± column in the benchstat table is the better noise indicator for the comparison itself. - A hosted gate cannot promise to catch a 5–10% regression; with n=10 on a shared runner, a change that small can flap: pass once, fail the next time. - Tools: benchstat v0.0.0-20260929162123-406019bb8b68, benchgate v0.1.0 from kakkoyun/benchlab. The workflow and both runs live on PR #5, a closed demo. actionlint had no complaints. Versions, links and commands checked on 2 October 2026; the runs were captured on 4 October 2026. ## Why base and head share one job [[resources/documentation/Go/Benchmarks/Go Benchmarks - Benchmark CI Noise|Part 4]] keeps the baseline from main in a cache, which suits a pinned runner. On a hosted runner, a cached baseline was measured on whatever VM ran the earlier job, and nothing promises that VM is the one judging your PR. This gate builds both binaries in one job and runs them on the same VM, taking turns: base, head, base, head. That interleaving (ABAB) spreads slow drift evenly over both sides. It does not remove noisy neighbours: ubuntu-latest is a shared machine, and SMT or frequency scaling cannot be turned off from inside it. ## The bench-gate workflow, verbatim The whole workflow: one job builds the base and head binaries, runs them ABAB, compares with benchstat, checks sample stability with benchgate, and fails on a regression that benchstat calls real. ```yaml # A PR gate that actually fails: companion to part 4 of the Go benchmarks series. # # One job builds the base and head benchmark binaries, runs them interleaved # (ABAB), and fails on a regression that benchstat calls real. benchgate rules # out an unstable sample first. name: bench-gate on: pull_request: permissions: contents: read env: BENCHSTAT_VERSION: v0.0.0-20260929162123-406019bb8b68 BENCHGATE_VERSION: v0.1.0 BENCH_PKG: ./internal/sum # ROUNDS is both the number of ABAB rounds and the sample count per side (n). ROUNDS: "10" BENCHTIME: 200ms # Block only when benchstat says the delta is real (p < 0.05) AND at least this large. MIN_DELTA_PCT: "5" # benchgate fails the sample when any benchmark's coefficient of variation is above this. CV_THRESHOLD: "10" jobs: bench-gate: runs-on: ubuntu-latest timeout-minutes: 10 steps: # pull_request checks out the merge commit, so HEAD^1 is the base tip and # HEAD is what would land. Depth 2 is enough to reach both. - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 2 persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.27.1" cache: false - name: Install benchstat and benchgate run: | export GOBIN="$RUNNER_TEMP/bin" go install "golang.org/x/perf/cmd/benchstat@${BENCHSTAT_VERSION}" go install "github.com/kakkoyun/benchlab/cmd/benchgate@${BENCHGATE_VERSION}" - name: Build base and head benchmark binaries run: | if [ "$(git rev-list --parents -n 1 HEAD | wc -w)" -ne 3 ]; then echo "::error::HEAD is not a merge commit; expected the pull_request merge ref" exit 2 fi mkdir -p "$RUNNER_TEMP/bench" git worktree add --detach "$RUNNER_TEMP/base" HEAD^1 echo "base: $(git rev-parse --short HEAD^1) head: $(git rev-parse --short HEAD)" go test -c -o "$RUNNER_TEMP/bench/head.test" "$BENCH_PKG" (cd "$RUNNER_TEMP/base" && go test -c -o "$RUNNER_TEMP/bench/base.test" "$BENCH_PKG") - name: Run base and head interleaved (ABAB) run: | pkgdir="${BENCH_PKG#./}" : > "$RUNNER_TEMP/bench/base.txt" : > "$RUNNER_TEMP/bench/head.txt" for round in $(seq 1 "$ROUNDS"); do for side in base head; do root="$RUNNER_TEMP/base" if [ "$side" = head ]; then root="$GITHUB_WORKSPACE"; fi echo "round $round: $side" (cd "$root/$pkgdir" && "$RUNNER_TEMP/bench/$side.test" \ -test.run='^ -test.bench=. -test.benchtime="$BENCHTIME" -test.count=1) \ >> "$RUNNER_TEMP/bench/$side.txt" done done - name: Compare with benchstat run: | cd "$RUNNER_TEMP/bench" benchstat="$RUNNER_TEMP/bin/benchstat" "$benchstat" base.txt head.txt "$benchstat" -format csv base.txt head.txt > compare.csv # Rows are "name,base,CI,head,CI,delta,P". Gate on the sec/op table only; the delta # column holds "~" when benchstat sees noise, so only a signed percentage can match. awk -F, -v floor="$MIN_DELTA_PCT" ' $1 == "" && NF > 1 { gate = ($2 == "sec/op"); next } gate && $1 != "geomean" && $4 != "" { compared++ } gate && $1 != "geomean" && $6 ~ /^\+[0-9.]+%$/ && substr($6, 2) + 0 >= floor { printf "%s %s (%s)\n", $1, $6, $7 > "regressions.txt" } END { print compared + 0 > "compared.count" } ' compare.csv touch regressions.txt - name: Check sample stability with benchgate run: | cd "$RUNNER_TEMP/bench" # Exit codes: 0 stable, 1 unstable (CV over threshold), 2 error. Record it # instead of failing here, so the verdict step can report everything at once. rc=0 (cd "$GITHUB_WORKSPACE" && "$RUNNER_TEMP/bin/benchgate" \ -pkg "$BENCH_PKG" -count "$ROUNDS" -benchtime "$BENCHTIME" \ -cv-threshold "$CV_THRESHOLD") || rc=$? echo "$rc" > benchgate.rc - name: Gate verdict run: | cd "$RUNNER_TEMP/bench" compared="$(cat compared.count)" benchgate_rc="$(cat benchgate.rc)" # Exit codes: 0 pass, 1 regression, 2 tool error, 3 unstable sample. if [ "$compared" -eq 0 ]; then echo "::error title=Nothing compared::benchstat found no benchmark present in both base and head" exit 2 fi status=0 case "$benchgate_rc" in 0) ;; 1) echo "::error title=Unstable sample::benchgate exit 1: a benchmark's CV is above ${CV_THRESHOLD}%; the run cannot support a verdict" status=3 ;; *) echo "::error title=benchgate failed::benchgate exit ${benchgate_rc}" exit 2 ;; esac if [ -s regressions.txt ]; then while read -r line; do echo "::error title=Benchmark regression::${line}" done < regressions.txt status=1 fi echo "verdict: exit ${status}" exit "$status" ``` How the steps work: - The env block holds every number you might argue about later: BENCHSTAT_VERSION, BENCHGATE_VERSION, BENCH_PKG, ROUNDS, BENCHTIME, MIN_DELTA_PCT, CV_THRESHOLD. MIN_DELTA_PCT is the regression floor, CV_THRESHOLD the noise ceiling. - The checkout uses fetch-depth: 2. On pull_request, GitHub checks out a merge commit, so HEAD^1 is the tip of the base branch and HEAD is what would land. Depth 2 reaches both. - The build step refuses to continue if HEAD does not have two parents, and prints both short hashes, which is how the logs confirmed the comparison was the right one (base 021b688, head fb2ae9c in run 1). - go test -c runs once per side, so there are two frozen test binaries and no rebuild between rounds. - The ABAB loop runs each binary ten times with -test.count=1, appending to base.txt and head.txt. - The compare step runs benchstat twice: once for the human-readable table, once with -format csv. The awk script reads rows shaped name,base,CI,head,CI,delta,P, looks only at the sec/op table, and keeps a row only if its delta is a signed +N% with N at or above the floor. When benchstat sees noise, the delta column holds ~, so noise can never match; a faster result starts with a minus and is ignored too. - The gate counts compared rows and writes the survivors to regressions.txt. benchgate's exit code is recorded instead of failed on, so the verdict step can report everything at once. ## Run 1: the slow commit goes red The benchstat tables for both runs, slow commit first, revert second: ```text Ints-4 2.579µ ± 1% 7.700µ ± 1% +198.57% (p=0.000 n=10) Ints-4 2.579µ ± 2% 2.576µ ± 1% ~ (p=0.782 n=10) ``` [Run 1](https://github.com/kakkoyun/benchlab/actions/runs/37187167739) failed, and it failed in the right step. GitHub deletes Actions logs after a retention period, so the key log lines are carried here; the log also names the CPU, an AMD EPYC 7763: ```text │ base.txt │ head.txt │ │ sec/op │ sec/op vs base │ Ints-4 2.579µ ± 1% 7.700µ ± 1% +198.57% (p=0.000 n=10) ``` The stability check: ```text benchgate: go test -bench=. -count=10 -benchtime=200ms ./internal/sum BenchmarkInts mean= 7816.3 ns/op cv= 3.1% ✓ VERDICT: PASS — all 1 benchmarks within CV threshold 10.0% ``` A CV of 3.1% is under the 10% ceiling, so the sample was trusted. The verdict step did the rest; the ##[error] line is the annotation from the awk output, and the last line is the red check: ```text ##[error]Ints-4 +198.57% (p=0.000 n=10) verdict: exit 1 ##[error]Process completed with exit code 1. ``` ## Run 2: the revert goes green The revert makes the head tree identical to the base, so [run 2](https://github.com/kakkoyun/benchlab/actions/runs/37187216023) is a null test: there is nothing to find, and the gate should find nothing. ```text │ base.txt │ head.txt │ │ sec/op │ sec/op vs base │ Ints-4 2.579µ ± 2% 2.576µ ± 1% ~ (p=0.782 n=10) ``` ~ means benchstat could not tell the sides apart, and benchgate reported cv= 3.4% ✓. The verdict step closed with: ```text verdict: exit 0 ``` ## What this gate does not promise - These runs do not show that the runner is quiet. They show that a 3x change is far outside whatever noise was there. - A hosted gate is weaker than one on bare metal but still catches the obvious regressions. It cannot promise to catch a 5–10% one: with n=10 on a shared runner, a change that small can flap, pass once and fail the next time. [[resources/documentation/Go/Benchmarks/Go Benchmarks - AB Testing Is the Wrong Model for CI|A/B is the wrong model for CI]] looks at what one pair per PR misses. - The CV numbers answer a reader question, whether -cv-threshold 5 always fails on hosted runners. In these two runs it would not have (3.1% and 3.4%). The author set 10, and two runs are a sample of two. - If a noisy run does trip the ceiling, the job fails closed with exit 3, a red check that is not a regression. That path was seen only on the author's laptop, where noise tripped it. Exit 2 was never seen on GitHub. - The runs are a single benchmark on one machine type, captured on 4 October 2026. Calibrate BENCHTIME, ROUNDS and the two thresholds on your own benchmarks first. The gate is only worth what that calibration says. - The dangerous deltas are small ones that look real, and a green or red check that is really measuring code layout. [[resources/documentation/Go/Benchmarks/Go Benchmarks - Three Questions Before You Trust a Benchmark|Three Questions Before You Trust a Benchmark]] opens with a CI regression that turned out to be a speedup. ## FAQ ### How does the workflow fail a PR on a benchmark regression? benchstat itself exits 0 even on a +199.00% delta. The compare step runs benchstat with -format csv, and an awk script keeps only sec/op rows whose delta is a signed +N% at or above MIN_DELTA_PCT (5). Survivors go to regressions.txt, and the verdict step exits 1. ### Why run base and head in the same job, interleaved? On a hosted runner, a cached baseline was measured on an earlier VM that may not be the one judging your PR. Building both binaries in one job and alternating base, head, base, head on the same VM spreads slow drift evenly over both sides. It does not remove noisy neighbours. ### What does benchgate check in this gate? Sample stability only. It exits 1 when any benchmark's CV is above -cv-threshold (10 here). At v0.1.0, -baseline only prints the benchstat output and never affects the exit code. benchstat is the regression gate, benchgate the noise gate. ### What do the gate's exit codes mean? 0 pass, 1 regression, 2 tool or setup error (nothing compared, benchgate failed, HEAD not a merge commit), 3 unstable sample. A regression wins over "unstable", and each finding becomes an ::error annotation on the PR. ### Can this gate catch a 5–10% regression on a hosted runner? It cannot promise that. With n=10 on a shared runner, a change that small can flap: pass once, fail the next time. The demo runs only show that a 3x change is far outside whatever noise was there. ### Would -cv-threshold 5 always fail on GitHub-hosted runners? No. In these two runs it would not have (CV 3.1% and 3.4%). The author set 10, and two runs are a sample of two. If a noisy run does trip the ceiling, the job fails closed with exit 3. ### How do I try this gate? Copy the workflow into a repository with a benchmark package, point BENCH_PKG at it, and push a branch holding the workflow and the benchmark. Open a PR against that branch with a deliberately slow commit, watch the check go red, then push a revert and watch it go green. actionlint had no complaints about the file. ## Related - [[resources/documentation/Go/Benchmarks/Go Benchmarks|Go Benchmarks]] (series hub) - [[resources/documentation/Go/Benchmarks/Go Benchmarks - Benchmark CI Noise|Benchmark CI Noise]] (the nightly CI this gate plugs into) - [[resources/documentation/Go/Benchmarks/Go Benchmarks - AB Testing Is the Wrong Model for CI|A/B Testing Is the Wrong Model for CI]] - [[resources/documentation/Go/Benchmarks/Go Benchmarks - Three Questions Before You Trust a Benchmark|Three Questions Before You Trust a Benchmark]] ## Sources - [Why Your Go Benchmarks Are Lying (GopherCon UK 2026, GoLab 2026): talk](https://kakkoyun.me/talks/why-your-go-benchmarks-are-lying/) - [Why Your Go Benchmarks Are Lying: series](https://kakkoyun.me/series/why-your-go-benchmarks-are-lying/) - [kakkoyun/benchlab at v0.1.0](https://github.com/kakkoyun/benchlab/tree/v0.1.0) - [benchlab PR #5: the workflow and both runs (closed demo)](https://github.com/kakkoyun/benchlab/pull/5) - [benchgate v0.1.0, cmd/benchgate/main.go: the -baseline output](https://github.com/kakkoyun/benchlab/blob/v0.1.0/cmd/benchgate/main.go#L146-L167) - [Run 1: the slow commit fails](https://github.com/kakkoyun/benchlab/actions/runs/37187167739) - [Run 2: the revert passes](https://github.com/kakkoyun/benchlab/actions/runs/37187216023)