ci: unblock and harden the benchmark job - #437
David Levy (dlevy-msft-sql) wants to merge 45 commits into
Conversation
Benchmark Results (main vs PR)Click to expand benchstat outputGenerated by CI — commit 5d3839c |
34150a7 to
8852fd3
Compare
There was a problem hiding this comment.
Pull request overview
This PR unblocks the benchmarks CI job and makes its regression gate more reliable by pinning the benchstat tool version and hardening the parsing/validation logic around benchstat output.
Changes:
- Pin
golang.org/x/perf/cmd/benchstatto a pre-Go-1.26 commit to prevent toolchain breakage and avoid silent output-format drift. - Make the regression gate fail closed when
benchstatoutput is empty or unrecognized, instead of reporting success. - Fix regression/improvement detection to handle integer percentages and avoid
set -eo pipefailaborts when no matches occur.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
.github/workflows/pr-validation.yml:210
grep -Euses POSIX ERE;\S/\sare not defined there (they’re PCRE). On some runners this will be treated as literalS/s, making the row-detection regex fail and the gate behave incorrectly. Use POSIX whitespace classes instead.
This issue also appears in the following locations of the same file:
- line 221
- line 234
if ! ROWS=$(grep_ok -cE '^\S+\s+.+(±|~|[+-][0-9])' bench_diff.txt); then
.github/workflows/pr-validation.yml:221
- This regex also uses
\S/\swithgrep -E, which is not portable ERE and can silently stop matching. Switch to POSIX classes ([^[:space:]]/[[:space:]]) so the improvement notice doesn’t depend on PCRE-only escapes.
if grep_ok -v '~' bench_diff.txt | grep -E '^\S+\s+.+\s+-[0-9]+(\.[0-9]+)?%'; then
.github/workflows/pr-validation.yml:234
- The regression-detection
grep_ok -Epattern uses\S/\s(PCRE-only) withgrep -E. If the pattern fails to match due to ERE semantics, regressions can be missed or the pipeline can behave unexpectedly. Use POSIX whitespace classes instead.
| grep_ok -E '^\S+\s+.+\s+\+[0-9]+(\.[0-9]+)?%' \
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
.github/workflows/pr-validation.yml:122
- The PR description says benchstat should be pinned (to avoid CI breakage when parsing its output). This step still installs
benchstat@latest, so the job can break again whengolang.org/x/perfchanges its Go version floor or output format.
- name: Install benchstat
shell: bash
# Before the benchmark runs, not after: they take ~46 minutes and this
# reaches outside the repo, so a fetch failure should surface at once.
run: |
go install golang.org/x/perf/cmd/benchstat@latest
command -v benchstat
9f6fcc0 to
2d15c1f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
.github/workflows/pr-validation.yml:239
- This
grep -Epattern uses GNU-only\S/\sescapes. Switching to POSIX character classes ([^[:space:]]/[[:space:]]) makes it robust across regex implementations without changing intent.
if grep_ok -v '~' bench_diff.txt | grep -E '^\S+\s+.+\s+-[0-9]+(\.[0-9]+)?%'; then
.github/workflows/pr-validation.yml:252
- This
grep -Epattern uses GNU-only\S/\sescapes. Prefer POSIX character classes so the regression gate’s parsing doesn’t depend on GNU grep extensions.
| grep_ok -E '^\S+\s+.+\s+\+[0-9]+(\.[0-9]+)?%' \
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #437 +/- ##
==========================================
- Coverage 82.17% 82.13% -0.05%
==========================================
Files 35 35
Lines 7065 7065
==========================================
- Hits 5806 5803 -3
- Misses 993 995 +2
- Partials 266 267 +1 🚀 New features to boost your workflow:
|
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The parser can still accept shifted CSV columns and silently miss a regression.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (1)
Resolved since last review (1)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The parser rejects benchstat’s actual filename header, causing the required benchmark check to fail.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (1)
Resolved since last review (1)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The parser can retain a stale unit across table preambles and incorrectly clear a regression.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (1)
Resolved since last review (1)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Short malformed benchmark rows can still be silently discarded, allowing the regression gate to report success on unrecognized output.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Resolved since last review (1)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde
Parse validated measurement shape on the truncated-row and blank-delta paths but not on a full-width row with a nonblank delta. Such a row became an insignificant Row, so Unmeasured counted the key as compared and a flagged benchmark that never re-ran cleared the gate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e6388e79-ee7f-4b63-8242-9a01d2ea7fde


The
benchmarksjob was failing on every branch in the repo, and fixing that surfaced four further defects in the same job. All of them share a shape: a step that could not do its work reported success, or failed with no way to tell why.Touches
.github/workflows/pr-validation.ymland addsinternal/benchgate, a small Go command that holds the regression-gate logic. Section 5 explains why the gate moved out of Bash.benchmarksis a required check onmain, so both directions matter: a false failure blocks every merge, and a false pass lets a regression through unnoticed.1. The job failed on every branch
golang.org/x/perfmoved itsgodirective to1.26.0on 2026-08-19 inebcb4798430d. Nothing in our code changed. Confirmed the same failing step across unrelated branches — dependabot, release-please, dev/saurabh/fix-tx-query-hang.Worth understanding why only that step broke, since the benchmark runs on either side of it were fine. Same toolchain, same directory:
go build ./...go.mod→go 1.25.0go test -bench ...go.mod→go 1.25.0go install golang.org/x/perf/cmd/benchstat@latestgo.mod→go 1.26.0go install pkg@versionruns in an empty main module, so ourgodirective is irrelevant and the tool's own requirement governs. That asymmetry is permanent and cannot be designed away — so this PR makes it cheap instead.Fix: install
benchstatin its own step immediately afterSetup go, rather than 46 minutes into the job. Measured from a real run:Failure now surfaces at about minute one.
benchstatstays on@latest: with the fail-fast ordering plus the fail-closed gate below, a pin buys nothing that isn't already covered, and@latestsurfaces upstream breakage while it is still cheap to fix.#439moved this job to Go 1.27, so the original incompatibility is resolved either way.2. The regression gate failed open
Check for regressionsinferred "no regression" from "my regex matched nothing". Those are different statements and it could not tell them apart. There was a guard, but it tested whetherbench_diff.txtexists, not whether it contains anything.Running the shipped step against fixtures, before this PR:
+22%instead of+22.00%bench_diff.txtbenchstat: no data for bench_old.txtTwo of those were live gaps, not hypothetical drift: the regex hardcoded a decimal point, and an empty file cleared the
[ ! -f ]guard.Fix: the gate now parses benchstat's
-format=csvoutput withencoding/csvand refuses to report "clean" from output it could not interpret. Unreadable rows, header drift and an empty comparison are all errors.3. A clean sweep of improvements aborted the job
Found while testing #2.
REGRESSED=$(...)aborts underset -eo pipefailwhen the finalgrepmatches nothing. Typical runs contain small+0.38%entries so the grep matched and this stayed hidden, but a PR where every benchmark improved or was~failed with no message.The first attempt at this wrapped the whole pipeline in
|| true, which @copilot-pull-request-reviewer correctly flagged as reintroducing the very fail-open being fixed — it also swallowedgrepexit 2 and anyawkfailure.Fix: superseded by section 5. "No regressions" is now a value the gate returns, not the absence of a pattern match, so the distinction that caused this cannot arise.
4. SQL Server never started, and nothing said so
Run 32585542809 failed with nothing but
Process completed with exit code 1. Three gaps hid the cause:> /dev/null 2>&1, so the failure carried no diagnosis.Show SQL Server logs on failureexisted only in thebuildjob, so no container logs were captured either.Fix: the
buildjob already gets the first and third right, so this applies that existing pattern tobenchmarksrather than inventing one, and keeps warmup output in a file that is tailed on failure.Container memory: the loop was commented as 60 seconds, but
sqlcmdburns ~8s per attempt when nothing is listening, so it actually waited 295 seconds and SQL 2025 still had not started. The cause wasdocker run -m 2GB— the documented minimum for SQL Server 2022 and later, with no headroom. Both jobs now run-m 4GBwithMSSQL_MEMORY_LIMIT_MB=3072.Microsoft's container memory guidance requires
MSSQL_MEMORY_LIMIT_MBto sit below the container limit, and recommends reserving 10-20% of it for the OS and auxiliary processes. 4096 MiB with a 3072 MiB engine limit leaves 1024 MiB, or 25%. Left unset the engine takes 80% of the cgroup limit (3277 MB), so the explicit value is marginally tighter than the default.ubuntu-lateston a public repo has 16 GB, so 4 GiB does not crowd the toolchain.The readiness comment now describes the loop in attempts rather than wall time. The real bound is 30 × (connect timeout + 2s), and the abort message is what an operator reads when the job fails, so claiming 60 seconds sends them after the wrong problem.
OOM diagnostics: a cgroup memory kill reaches the test process as connection resets and unexpected EOF. In a TDS driver suite that is indistinguishable from a protocol bug. The
Show SQL Server logs on failurestep in both jobs now reports container state before dumping the logs, and names the memory case:Exit 137 without the OOM flag is a warning rather than an error, since nothing in these jobs sends SIGKILL deliberately. Both readiness loops
exit 1into this same step, so a kill during startup and a kill mid-run are both covered.5. The gate moved from Bash to Go
The gate started as a shell script embedded in YAML. Over five review iterations it accumulated nine defects, every one found by review rather than by the job itself:
|| truemaskinggrepexit 2cp ... 2>/dev/null || trueswallowing real copy errors^Benchmark(Parent/Sub)$matching nothing —go testsplits-benchat/, so a sub-benchmark must be addressed through its parentSetBytesrenders it as-20% sec/opand+25% B/s, and any-positive-delta called the second one a regressiongeomeanreaching the selector as if it were a benchmarkawk -F,is not a CSV parser —"Foo/size=1,024-4"shifted fields, silently dropping a regression while the row count stayed healthyEach individual fix was reachable in Bash. The pattern was not: this is a required check whose failure mode is silence, and it had no tests, so every defect above would have shipped green.
internal/benchgateis that logic as a Go package, and the workflow step is now 61 lines of YAML that builds and calls it:Parseencoding/csv, unit-keyed; fails closed on header drift and unreadable deltasRegressions/ImprovementsSelectorregexp.QuoteMeta, excludesgeomeanConfirm/UnmeasuredReview of the Go version then found three more fail-open defects, all fixed here. Each is worth naming, because all three are the same bug class this PR set out to remove — inferring "clean" from an absence:
4cb4b23).Regressionsgated onlowerIsBetter, so a significant-20% B/scollapse could not be reported. I had fixed the false-positive direction of Message queue implementation misses some data and messages #4 and left the false-negative direction open. The threshold now applies in both directions.4cb4b23). Skipping an unreadable delta only failed closed when every row was unreadable; one clean row alongside a malformed regression left a non-empty result and reported success. That is Scale and precision are zero for all datetime types #8's exact failure mode. An unreadable delta under a recognised table is now a parse error.20e03bf). BecauseSelectoraddresses a flagged sub-benchmark through its parent, the recheck returns rows for siblings. If the candidate itself never produced a comparable row,Confirmreturned empty and the gate treated it as runner noise — on a clean exit.Unmeasurednow reports flagged keys absent from the recheck, and those fail as unevaluated. Presence counts regardless of significance: a flagged row coming back~genuinely is noise; only absence is unevaluated.Testing
go test ./internal/benchgate/runs with everything else. 99.4% of statements; the only uncovered statement isos.Exit(run(os.Args[1:], os.Stdout)), which has no logic in it —run(args []string, out io.Writer) intwas extracted precisely so dispatch, usage and error reporting are all exercised.The regression cases from the original Bash harness are preserved as Go tests, alongside one for each defect above:
The one-sided-benchmark handling was checked against benchstat itself rather than assumed, since making unreadable deltas fatal would otherwise hard-fail every PR that adds or removes a benchmark:
benchstat truncates those rows to 3 and 5 fields, so they are dropped on field count and never reach the delta logic.
TestParseSkipsOneSidedBenchmarkspins that shape so a future benchstat change surfaces as a test failure instead of a CI outage.Readiness logic verified both ways:
Also confirmed
benchstat@latestinstalls cleanly underGOTOOLCHAIN=go1.27.0, reproduced the original failure undergo1.25.7first, and checked by parsing the YAML that both jobs now carry the log step and only onego installremains.The OOM diagnostic is still Bash, so it is still tested by extraction from the YAML and run under
-eo pipefailagainst a stubbeddocker: an OOM kill, exit 137 without the flag, a healthy running container, a zero and a non-numeric memory limit, anddocker ps/inspect/logseach failing — 11 cases, all passing, noset -eaborts. The extractor also asserts the logic is identical in both jobs. Then end-to-end against a real engine: a container held at a 16 MiB cap until the kernel killed it renderstrue 137 exited 16777216, confirming the field order and that.HostConfig.Memoryis bytes.actionlintis clean,go vetis clean, andgolangci-lint run ./internal/benchgate/reports 0 issues.Timing
Worst case is a regression that reproduces: baseline pass, PR pass, then both again for confirmation. Measured on run 33399741848 — job 49m23s, baseline pass 23m57s, PR pass 23m52s — so confirmation adds about 48 minutes for a worst case near 97.
timeout-minutesis 120 and the confirmation-timeoutis 25m.Corrections
Two claims I made earlier in this PR were wrong and are worth flagging for anyone reading the thread history:
benchmarksis not a required check, so a red result would never block a merge. It is one of the 14 required contexts onmain. That premise made me under-weight the reviewer's point about untested confirmation logic, which was correct.BenchmarkSelectdeadlocks past one iteration. It does not — the handler loops, and it completed in a 46.1s run. Its behaviour when run alone is still unexplained, so it stays out ofBENCH_PATTERNuntil it is understood.