Switch CI benchmarks to relative continuous benchmarking#4580
Switch CI benchmarks to relative continuous benchmarking#4580alexeyr-ci2 wants to merge 2 commits into
Conversation
WalkthroughThe PR adds same-runner base/head benchmarking, repeated per-route k6 samples with median aggregation, baseline-aware Bencher modes, sample confirmation for alerts, and workflow plumbing for base SHA resolution and result artifacts. ChangesRelative benchmark execution
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Workflow
participant PhaseAction
participant BenchmarkServer
participant BenchScript
participant Bencher
Workflow->>PhaseAction: run base phase
PhaseAction->>BenchmarkServer: build and start target
PhaseAction->>BenchScript: execute repeated samples
BenchScript-->>PhaseAction: write benchmark results
Workflow->>PhaseAction: run head phase
PhaseAction->>BenchmarkServer: build and start target
PhaseAction->>BenchScript: execute repeated samples
BenchScript-->>Bencher: provide head and baseline artifacts
Bencher-->>Workflow: classify confirmed or unconfirmed alerts
Possibly related issues
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review: Switch CI benchmarks to relative continuous benchmarkingOverviewThis PR replaces the cross-runner statistical (t-test) benchmark comparison with relative continuous benchmarking: each suite job now builds/benchmarks both the resolved comparison base ref and the head ref on the same runner (via the new Code quality
Potential issues / minor observations (non-blocking)
Test coverageSolid — SecurityNo new secrets, no new third-party actions, and the new Overall this is a well-reasoned, thoroughly tested infrastructure change with clear rationale and honest trade-off documentation. No blocking issues found. |
|
The initial run results show very unimpressive stability, must be solved before merging. Should also include detected regressions/improvements being rerun to confirm. |
|
@alexeyr-ci2 Agreed — the initial run was unusable (15 🔴/🟢 flags on an infra-only diff, up to +149% p50). I root-caused it off-CI, fixed it, and added the rerun-to-confirm you asked for, in 75543d4. Root cause (validated, not guessed): k6's keep-alive connections pin to whichever Puma worker process accepts them, and Linux's LIFO accept wakeup skews reconnect placement — so the 10 VUs' split across the 3 workers drifts randomly within a run (I observed splits as bad as 9/1/0, i.e. one worker idle for the whole sample). Each 30s sample therefore lands in a random queueing "mode". That one mechanism explains all three patterns in the initial run: stable-RPS/wild-p50 on saturated SSR routes, coupled RPS+p50 swings on the fast client-side routes, and the vegeta node-renderer suite (no Puma in front) staying ±5%. Reproduced in a CI-topology Linux container (4 cores, k6 pinned to core 0, Puma 3×3 on cores 1–3, puma 6.6.1, k6 v2.0.0), 8×10s runs per mode:
38% run-to-run spread against 25% thresholds is exactly the false-flag generator we saw. What changed:
Acceptance: this PR's own benchmark run is an A/A test (base and head run identical app code), so the new tables should show no 🔴/🟢 flags and small deltas. I'll post the comparison against the initial run when the suites finish — and per the same standard, this doesn't merge unless the run actually demonstrates the improvement. |
Review: Switch CI benchmarks to relative continuous benchmarkingOverall this is a very well-executed PR — extensive inline documentation, thoughtful edge-case handling, and matching spec coverage (branch-args, bencher_report sample confirmation, CLI orchestration, table rendering). I read through the composite action, the workflow diffs, and the full What it does
Strengths
Minor observations (non-blocking)
VerificationI read the full diff (27 files) and the resulting |
Greptile SummaryThis PR changes CI benchmarking to compare base and head on the same runner. The main changes are:
Confidence Score: 4/5The main-push alert handoff needs a small fix before merging.
benchmarks/lib/track_benchmarks/cli.rb Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Detect as detect-changes
participant Suite as benchmark-suite.yml
participant Phase as benchmark-suite-phase
participant Track as track_benchmarks.rb
participant Bencher as Bencher
Detect->>Suite: base_sha and suite matrix
Suite->>Phase: benchmark base ref with HEAD harness
Phase-->>Suite: base results in runner temp
Suite->>Phase: benchmark head ref with HEAD harness
Phase-->>Suite: head results in workspace
Suite->>Track: base and head result files
Track->>Bencher: upload base baseline branch
Track->>Bencher: upload head comparison run
Track->>Track: downgrade overlapping sample alerts
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Detect as detect-changes
participant Suite as benchmark-suite.yml
participant Phase as benchmark-suite-phase
participant Track as track_benchmarks.rb
participant Bencher as Bencher
Detect->>Suite: base_sha and suite matrix
Suite->>Phase: benchmark base ref with HEAD harness
Phase-->>Suite: base results in runner temp
Suite->>Phase: benchmark head ref with HEAD harness
Phase-->>Suite: head results in workspace
Suite->>Track: base and head result files
Track->>Bencher: upload base baseline branch
Track->>Bencher: upload head comparison run
Track->>Track: downgrade overlapping sample alerts
|
Review: Switch CI benchmarks to relative continuous benchmarkingReviewed the diff ( OverviewThis is a well-engineered, thoroughly-documented rework of the benchmark CI pipeline: same-runner base-vs-head comparison instead of cross-runner statistical trend tracking, plus median-of-N sampling and a sample-confirmation gate to kill noise. The PR description's root-cause analysis of the k6 keep-alive/Puma-worker-affinity noise source ( Strengths
Issue found (see inline comment)
Minor / non-blocking observations
Overall this looks like a solid, well-validated improvement to a genuinely noisy CI signal. The one correctness issue found is CI-only in impact and easy to fix. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 75543d47df
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Stability verdict: A/A run on the fixed harness is cleanRun 29187847192 (head 75543d4) completed all four suites. Like the initial run, this is an A/A test — base and head run identical app code — so every flag would be a false positive. Before/after:
Requesting hosted CI next (workflow changes trip the generator-sensitivity gate). Note the |
|
+ci-run-hosted |
Hosted CI RequestedTriggered 9 workflow(s) for View progress in the Actions tab. |
Each benchmark suite job now builds and benchmarks BOTH the comparison base ref and the head ref on the SAME runner (new benchmark-suite-phase composite action, invoked twice), then submits the pair to Bencher per https://bencher.dev/docs/how-to/track-benchmarks/#relative-continuous-benchmarking: - base results go to a throwaway per-run baseline branch (--start-point-reset; unique per run AND suite/shard so concurrent shards can never anchor to each other's data); - head results go to the event's branch, anchored to that baseline (--start-point <baseline> --start-point-reset) and compared with percentage thresholds (25%, one-sided per measure) stated per run via --thresholds-reset. The side-by-side comparison replaces the statistical t-test baseline built from main's cross-runner history, which was noise-dominated on shared GitHub-hosted runners (#4071, false positives #4038-#4044). The comparison base is resolved once in detect-changes: the PR merge ref's first parent (falling back to the recorded base SHA), the pre-push main head for pushes, and the merge-base with main for dispatches. The confirmation-rerun pipeline re-runs the whole relative comparison on a fresh runner; the start-point-hash retry fallback is gone along with the start-point-hash itself. The local dedicated-hardware runner keeps statistical trend tracking (BencherRunner's default mode) — long-term trends now live on its testbed, since relative runs reset the shared-runner series each time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…medians, confirm flags The initial relative run flagged 15 routes as significant on an infra-only diff (base and head ran identical runtime code) with swings up to +149% p50. Three fixes, each targeting a validated noise source: 1. k6 noConnectionReuse (the dominant source). A keep-alive connection pins to whichever Puma worker accepted it, and Linux's LIFO accept wakeup skews reconnect placement, so the 10 VUs' per-worker split drifts within a run (splits as bad as 9/1/0 observed) and each 30s sample lands in a random queueing mode. Reproduced on a CI-topology testbed (Linux container, 4 cores, k6 pinned to core 0, Puma 3 workers x 3 threads on cores 1-3, puma 6.6.1, k6 v2.0.0): run-to-run spread was 38% RPS / 36% p50 with keep-alive vs 9.3% / 9.2% with per-request connections. Loopback connect cost is excluded from http_req_duration and tcp_tw_reuse=2 recycles TIME_WAIT ports (verified clean at 7.7k conn/s, 12x the busiest route). 2. Median-of-3 sampling. Each route now runs BENCHMARK_SAMPLES (CI: 3) k6 samples of 12s instead of one 30s run; the median per measure ships to Bencher, so one disturbed sample can no longer set the route's number. Per-sample values ride in the display sidecar. 3. Sample confirmation (reruns for detected changes). A Bencher boundary crossing must reproduce across every sample - base and head sample ranges disjoint - or the report downgrades it: the summary table shows an unbolded warning marker instead of a significant flag, the alert leaves the regression/candidate path, and the exit code normalizes. Pairs without samples on both sides fail open to today's behavior. Both phases now also run the HEAD ref's stashed benchmarks/ tree instead of each phase's checkout: the harness is the measuring instrument, and running two different copies would turn any harness change (like this one) into a phantom base-vs-head delta. App-side inputs (bin/prod*, gems, packs, renderer config, protocol version) still come from each phase's checkout; bench-node-renderer.rb resolves those through GITHUB_WORKSPACE so the stashed copy reads the phase's workspace. The vegeta node-renderer suite (fixed-rate, no Puma in front) was already stable and keeps its single-run behavior. Local run-local-benchmark.rb trend runs keep single-sample 30s defaults; the connection change steps their absolute series once (noted in LOCAL_BENCHMARK.md). Verification: 348 benchmark specs pass (20 new: sampling aggregation, sample-confirmation semantics, downgraded rendering, exit normalization, CLI wiring); RuboCop, actionlint, and Prettier clean; end-to-end bench.rb run with real k6 against a local server produced the suffixed per-sample artifacts, correct medians, and the samples sidecar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
75543d4 to
9e430a4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9e430a435d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Pro Node Renderer Benchmark Summary
▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/actions/benchmark-suite-phase/action.yml:
- Around line 335-375: Add the GitHub Actions `if: ${{ always() }}` condition to
the “Stop benchmark target (${{ inputs.phase }})” step so its cleanup script
runs even when the preceding benchmark execution fails. Keep the existing
process-group termination and port validation logic unchanged.
- Around line 87-105: Validate the PHASE input in the benchmark checkout step
before it is used or written to GITHUB_ENV, accepting only base or head and
rejecting all other values, including newline-containing input. Anchor the
validation to the existing PHASE environment variable and preserve the current
checkout and cleanup flow for valid values.
In `@benchmarks/lib/bencher_report.rb`:
- Around line 254-278: Update overlapping_measures to resolve each measure
pair’s Boundary and retain confirmation only when every comparable head sample
crosses the same effective limit; do not rely solely on disjoint head/base
ranges. Preserve the existing range check as an optimization if useful, handle
the lower-limit example so 95 prevents confirmation, and add a spec covering
this disjoint-but-not-all-crossing case.
In `@benchmarks/spec/bencher_report_spec.rb`:
- Around line 453-461: Update the fixture in the “matches sample measure keys
across slug/name normalization” example to use distinct measure slug and
display-name values from alerting_report. Populate the sample maps with the
appropriate slug and name representations so the expectation verifies matching
through measure.name rather than only case-folding.
- Around line 444-451: Update the example around report.significance to first
assert that the parsed "/foo: Core" rps result with :lower direction is
classified as :improvement, then retain the existing assertion that it becomes
:unconfirmed after confirmed is applied. This must verify the intended
improvement-to-unconfirmed transition rather than only its final state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3d8edc9a-c3dd-44f2-833f-178f5a40e51b
📒 Files selected for processing (27)
.github/actions/benchmark-suite-phase/action.yml.github/workflows/benchmark-suite.yml.github/workflows/benchmark.ymlbenchmarks/LOCAL_BENCHMARK.mdbenchmarks/bench-node-renderer.rbbenchmarks/bench.rbbenchmarks/k6.tsbenchmarks/lib/bencher_report.rbbenchmarks/lib/bencher_runner.rbbenchmarks/lib/benchmark_table.rbbenchmarks/lib/bmf_helpers.rbbenchmarks/lib/track_benchmarks/bencher_run.rbbenchmarks/lib/track_benchmarks/branch_args.rbbenchmarks/lib/track_benchmarks/cli.rbbenchmarks/lib/track_benchmarks/config.rbbenchmarks/lib/track_benchmarks/confirmation.rbbenchmarks/lib/track_benchmarks/summary.rbbenchmarks/spec/bench_spec.rbbenchmarks/spec/bencher_report_spec.rbbenchmarks/spec/bencher_runner_spec.rbbenchmarks/spec/benchmark_table_spec.rbbenchmarks/spec/bmf_collector_spec.rbbenchmarks/spec/track_benchmarks/branch_args_spec.rbbenchmarks/spec/track_benchmarks/cli_spec.rbbenchmarks/spec/track_benchmarks/summary_spec.rbbenchmarks/spec/track_benchmarks_spec.rbbenchmarks/track_benchmarks.rb
🚧 Files skipped from review as they are similar to previous changes (21)
- benchmarks/spec/track_benchmarks/branch_args_spec.rb
- benchmarks/k6.ts
- benchmarks/spec/benchmark_table_spec.rb
- benchmarks/bench-node-renderer.rb
- benchmarks/lib/track_benchmarks/bencher_run.rb
- benchmarks/lib/track_benchmarks/summary.rb
- benchmarks/spec/bmf_collector_spec.rb
- benchmarks/spec/track_benchmarks/summary_spec.rb
- benchmarks/spec/bencher_runner_spec.rb
- benchmarks/lib/track_benchmarks/config.rb
- .github/workflows/benchmark.yml
- benchmarks/lib/benchmark_table.rb
- benchmarks/track_benchmarks.rb
- benchmarks/LOCAL_BENCHMARK.md
- benchmarks/spec/track_benchmarks/cli_spec.rb
- benchmarks/lib/bmf_helpers.rb
- .github/workflows/benchmark-suite.yml
- benchmarks/bench.rb
- benchmarks/lib/bencher_runner.rb
- benchmarks/lib/track_benchmarks/branch_args.rb
- benchmarks/lib/track_benchmarks/cli.rb
Core Benchmark Summary
▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · |
Pro (shard 2/2) Benchmark Summary
▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · |
Pro (shard 1/2) Benchmark Summary
▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · |
Summary
Closes #3492.
Switches the benchmark workflow from statistical continuous benchmarking (t-test thresholds against main's cross-runner history) to relative continuous benchmarking: each suite job now builds and benchmarks both the comparison base ref and the head ref on the same runner, then compares them directly. This removes the cross-runner variance that made shared-runner alerts noise-dominated (#4071, false positives #4038–#4044).
benchmark-suite-phasecomposite action runs one ref end to end (checkout + clean → install/build → assets → DB seed → server → bench → stop server);benchmark-suite.ymlinvokes it twice per job. Servers start undersetsidso each phase can kill its whole process tree and free the port for the next phase.track_benchmarks.rbnow makes two Bencher runs: base results to a throwaway per-run baseline branch (base-<run_id>-<suite-slug>,--start-point-reset; unique per suite/shard so concurrent shards can't anchor to each other's data), then head results anchored to it (--start-point <baseline> --start-point-reset --thresholds-reset) with percentage thresholds (25% one-sided per measure) instead of the t-test. The--start-point-hashretry fallback is gone with the hash itself.base.shalag from Fix CI rerun mapping for split dummy app tests (#3909) #3919),event.beforefor main pushes, merge-base with main for dispatches. Only resolved when suites will actually run.BENCHMARK_REGRESSION_ISSUES_ENABLED) re-runs the whole base/head comparison on a fresh runner. Worth revisiting enabling it now that the cross-runner noise source is gone.BencherRunner's default mode). Long-term dashboards now live on its testbed — relative runs reset the shared-runner series each run.Stability (round 2 — response to the initial run)
The initial run flagged 15 routes 🔴/🟢 on an infra-only diff with swings up to +149% p50 — pure noise. Root cause was found, reproduced off-CI, and fixed; detected changes now also have to survive built-in reruns before they flag.
k6
noConnectionReuse: true— the dominant noise source. A keep-alive connection is pinned to whichever Puma worker process accepted it, and Linux's LIFO accept-queue wakeup skews reconnect placement, so the 10 VUs' per-worker split drifts randomly within a run (splits as bad as 9/1/0 observed — a whole worker idle for the sample). Each 30s sample landed in a random queueing "mode": stable-RPS/wild-p50 on saturated SSR routes, coupled RPS/p50 swings on fast routes, and the one suite with no Puma in front (node renderer, vegeta) rock-stable — all three initial-run patterns explained. Reproduced in a CI-topology Linux container (4 cores; k6 pinned to core 0; Puma 3 workers × 3 threads on cores 1–3; puma 6.6.1; k6 v2.0.0):noConnectionReuseConnect cost is excluded from
http_req_duration, andtcp_tw_reuse=2recycles loopback TIME_WAIT ports (verified clean at 7.7k conn/s — 12× the busiest route).Median-of-3 sampling — each route runs
BENCHMARK_SAMPLES(CI: 3) k6 samples of 12s instead of a single 30s run; the median per measure ships to Bencher, so one disturbed sample can no longer set a route's number. Per-sample values ride in the display sidecar.Sample confirmation (rerun-to-confirm) — a Bencher boundary crossing only stands if it reproduced across every sample (base and head sample ranges disjoint). Otherwise the summary table shows an unbolded⚠️ instead of 🔴/🟢, the alert leaves the regression/candidate path, and the exit code normalizes. Pairs without ≥2 numeric samples on both sides fail open to single-sample behavior. The fresh-runner confirmation pipeline for main pushes is unchanged and now consumes only sample-confirmed alerts.
One instrument for both phases — both phases now execute the HEAD ref's stashed
benchmarks/tree (a new "Stash head benchmark harness" step +harness-diraction input), not each phase's checkout. The harness is the measuring instrument; running two different copies would turn any harness change (like this PR) into a phantom base-vs-head delta. App-side inputs (bin/prod*, gems, packs, renderer config, node-renderer protocol version) still come from each phase's own checkout —bench-node-renderer.rbresolves those throughGITHUB_WORKSPACE.Acceptance for this PR: its own benchmark run is an A/A test (base and head run identical app code), so the summary tables should show no 🔴/🟢 flags (at most⚠️ downgrades) and small deltas. Do not merge on a run that still looks like the initial one.
Trade-offs
run-local-benchmark.rb, unchanged single-sample 30s defaults) re-baselines within a few runs — noted inLOCAL_BENCHMARK.md.Workflow Change Audit
GITHUB_BASE_SHAenv removed from the tracking step;GH_TOKENadded to the detect-changes base-resolution step (existingGITHUB_TOKEN).bencherdev/bencher@v0.6.2,actions/*,grafana/setup-k6-action@v1,pnpm/action-setup@v6.benchmarks/torunner.temp;harness-dir/BENCHMARK_SAMPLES/BENCHMARK_BASELINE_DISPLAY_JSONare constants. Untrusted inputs are still passed viaenv:/action inputs, never interpolated into scripts.Verification
bundle exec rspec benchmarks/spec) — 20 new for sampling aggregation, sample-confirmation semantics (disjoint vs overlapping ranges, fail-open),benchmarks/;actionlintclean; Prettier clean on changed files.noConnectionReuseA/B, 8×10s runs each; concurrent- and sequential-connect worker-split probes; port-churn stress at 7.7k conn/s with 0 failed requests.bench.rbrun (real k6, local server,BENCHMARK_SAMPLES=3): per-sample suffixed artifacts written, medians correctly selected, samples sidecar populated, statuses summed.bencherbinary produced exactly the two-command sequence from the Bencher docs (baseline--start-point-resetupload, then percentage-threshold comparison run).benchmarklabel so the new relative flow exercises itself on this run.🤖 Generated with Claude Code
Summary by CodeRabbit
Confidence note
bundle exec rspec benchmarks/spec, re-run green on the rebased head 9e430a4); RuboCop / actionlint / Prettier clean; A/A benchmark run 29187847192 on 75543d4 — 0 flagged rows across all 4 suites (initial run: 36), max |Δ| 18.5% (was 202%), 0 cells ≥ 25%; all 9 optimized hosted CI workflows green on 75543d4 (Generator, Playwright E2E, Integration, Pro Integration, Pro Package, RSpec gem, JS unit, Assets Precompile, Lint);required-pr-gategreen;script/pr-merge-ledger 4580 --strict→complete_allowed: true(changelog:not_user_visible; codex P1 dispositionednot_applicablewith evidence).