Skip to content

perf(perf): key the base arm on its merge base and carry it between runs (CLOUD-1331) - #825

Merged
wenzowski merged 2 commits into
mainfrom
claude/cloud-1331-perf-base-arm
Sep 2, 2026
Merged

wenzowski merged 2 commits into
mainfrom
claude/cloud-1331-perf-base-arm

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-1331.

perf pair built the merge base's release binary from nothing on every CI run. Measured from the job-logs API on runs 33586699312 (ee1c9f52) and 33584118886, mise run perf-gate was 13m41s and 13m30s of a 13.7 / 13.8 min job — the perf job is one of the two wall-clock poles of a pull request's checks, and almost all of it is one step.

What the measurement actually said, and why the prescribed reading could not answer

The row asked for a Compiling line count per arm. That count is 0 on both runs and discriminates nothing, because perf::build() passed cargo build --quiet, which suppresses every such line. A reading that answers 0 on a run compiling the whole closure twice, and would answer 0 again after any fix, is not a reading.

The decisive evidence is elsewhere and is stronger than the one the row expected:

  1. Swatinem/rust-cache does carry the bytes. Its printed cache paths are the whole of /home/runner/work/batten/batten/target — target/perf/** inside it — and the run restored 189 MB from v0-rust-perf-perf-Linux-x64-22abc94d-db6ecef6. Its post-job cleaner then walked target/perf/pair/base-tree/… (four ENOENT lines naming it), so the pair tree was in the save set.
  2. perf.rs deleted the base target directory itself, before every base build. out_dir() is target/perf/pair and remove_dir_alls it unconditionally; measure() put the base target at out.join("base-target") — inside the directory just wiped. Whatever the cache restored there was gone microseconds before build(&base_tree, …) ran.

So the arm was not merely un-restored, it was un-restorable at that path, and a cache key alone would have changed nothing.

The change

Three files, matching the row's §1 boundary exactly. No mise-tasks/ program and no .bats is touched; [profile.release] is untouched.

crates/batten/src/perf.rs

  • perf_dir() names the checkout-owned directory, distinguished from the per-run out_dir() by lifetime: everything under out_dir belongs to one run and is deleted at the start of the next; the base build is a fact about a commit and outlives every run that reads it.
  • base_target_dir() is target/perf/base-<merge-base-sha> — a sibling of pair/ rather than a child of it. The SHA in the path is the refusal: a directory built from another base does not answer to this name, so a stale arm cannot be measured as this one. The discriminator is structural rather than a check somebody has to remember to write.
  • base_arm_is_built() asks for a file, not a path that exists. This gate is killed routinely — land races it against main-watch, and the harness kills a foreground command at ~2 minutes — so a directory left by a build that never linked is the ordinary case, and reading it as "built" would hand hyperfine a path it cannot execute.
  • measure() skips build(&base_tree, …) entirely when that binary is present, and bails could-not-look if a build leaves none. The base tree is still materialised on the reuse path, because wired_command reads the base tree's own settings file to derive that arm's invocation.
  • build() drops --quiet, which is the measurement half rather than a taste: it is what makes the row's own acceptance reading takeable at all. Cargo writes progress to stderr, so perf-gate.sh's ^arm= contract over stdout is untouched.

.github/workflows/ci.yml (perf job)

  • A bare step resolves git merge-base origin/main HEAD into $GITHUB_OUTPUT. This is the one thing the step cannot get approximately right: perf pair resolves the same merge base, so a key built from github.event.pull_request.base.sha — the base branch tip — would name a directory perf.rs never writes, and the entry would miss forever while looking exactly like a hit. A silent no-op is the one failure a cache cannot report.
  • An actions/cache entry on target/perf/base-<sha>, keyed perf-base-<os>-<hashFiles('mise.lock', 'Cargo.lock')>-<sha>, with no restore-keys: a prefix hit would restore some other base's directory under this base's name, which is the stale arm the keyed path exists to refuse. Exact or nothing — a miss costs exactly the build this job pays today.
  • Swatinem/rust-cache key: perf is unchanged, and fix(ci): put the dev profile in the rust-cache key, and stop asserting one rule with all 103 #819's work on the debug side is not folded in here.
  • A bare step rather than a task, because ci-local-parity requires every mise run <task> a workflow names to be one verify runs, and a runner-side cache restore has no local counterpart to be.

Closure sharing between the two arms is not taken, per the row's own decision: the :606-608 eviction and target-dir lock concerns are real under verify, and a base built once per merge base and restored by every later run makes sharing unnecessary. A new merge base builds the base arm cold exactly once; that is the accepted cost.

The measurement itself is unchanged — both arms sampled back to back on one machine, the ratio still perf-compare's, the same run count and the same 1.30 threshold.

Tests, shown able to fail

crates/batten/tests/it/perf_pair.rs gains five cases: the different-SHA discriminator; reuse under the right SHA; a directory with no binary is not a built arm; the keyed directory is not inside the per-run wipe; and the anti-vacuity mirror — a cold checkout has no arm to reuse, so both arms build exactly as before.

The discriminator is shown red (CLOUD-418) by the mutation that is the natural wrong implementation. Dropping the key from base_target_dir (perf_dir.join("base-target")) gives:

Summary [0.130s] 10 tests run: 9 passed, 1 failed, 3902 skipped
   FAIL [0.125s] batten::it perf_pair::a_base_arm_from_another_merge_base_is_not_reused

Exactly one case reddens and the other nine stay green. Mutation reverted.

Acceptance still outstanding

The CI half is a measurement recorded on the row after this lands: Compiling lines per arm and the perf-gate step wall on the next perf run whose merge base matches its predecessor's, against the 13.5 min baseline. With --quiet gone that count now discriminates. If the reading does not fall, that is a finding on CLOUD-1331 rather than a reason to widen this PR.

One admission

The commit carries an Admits: block for .github/workflows/ci.yml (d7c27bde…), issued through batten override request / spend. .github/workflows/** is protected, and no batten verb, task or generated artifact writes a job's step list — the redirect's own remedy is "change it in a pull request", which is this. The articulation is in the commit message where a reviewer reads it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MeLn5JsAqyBuPhdU8QMwCb

@linear-code

linear-code Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
CLOUD-1331 `perf pair` builds the base arm's whole release closure from nothing on every CI run — the base binary is a pure function of the merge base, so the `perf` job pays 13.5 min for a build no run can reuse

Why

The perf job is one of the two wall-clock poles of a pull request's checks, and its cost is almost entirely one step.

**Measured 2026-09-02 from two **ci.yml runs on main-adjacent heads:

run job wall mise run perf-gate step
33586699312 (ee1c9f52) perf 13.7 min 13.5 min
33584118886 perf 13.8 min 13.5 min

For comparison the same runs' bats job was 10.4 / 8.7 min and the ci job 14.5 / 12.7 min. So perf is within a minute of the pole on both, and it is one step.

What the step does, read from the engine rather than the job. crates/batten/src/perf.rs::measure (:561) builds the head arm with build(repo, None, "head") at :582 — cargo build --quiet --release -p batten (build(), :454) into target/release. It then materialises the merge base at target/perf/base-tree (:604-605) and builds it with its own CARGO_TARGET_DIR at target/perf/base-target (:609-610), on a stated reason: sharing the main target dir "would make the two builds evict each other's artifacts on every lap, and would race the target-dir lock against whatever else verify is running." [profile.release] is lto = "thin", so each arm pays a thin-LTO compile of the whole dependency closure plus the crate.

Where the cache reaches and where it does not. .github/workflows/ci.yml:633-636 restores Swatinem/rust-cache under key: perf before mise run perf-gate. That action caches the dependency artifacts of the default target directory and drops workspace crates by design (CLOUD-840's reopen note, CLOUD-1225's cache-workspace-crates: false). So on a hit the HEAD arm still compiles batten under thin LTO, and the BASE arm — under target/perf/base-target, a second target directory — is what this row is about: whether the restore reaches it at all is unmeasured, and if it does not, the base arm compiles its entire closure cold on every run. The Compiling line count per arm in the job log (via the API, never gh run view --log, which CLOUD-1225 records as lossy for cargo output) is the reading that decides it.

The base arm is a pure function of the merge base. Its inputs are the merge-base SHA, the pinned toolchain and [profile.release]. main advances only by fast-forward to already-judged SHAs, so consecutive pull requests share a merge base for hours at a time, and every one of them rebuilds the identical binary. That is the CLOUD-840 class (bytes nothing restores) arriving on the release side.

Why the skip does not save this. CLOUD-875 widened perf pair's skip set to batten.toml and every path a policy row registers, correctly — a config-only change moved wired 5.8ms → 9.3ms while the gate reported nothing measured. The consequence is that most of this repository's traffic now pays the full pair: a retirement bundle touches batten.toml and policy/*.rego by mandate, so the skip fires on documentation changes and little else. The widening stands; the cost it exposed is this row's.

Refinement — Ready (reuse the base arm across runs)

Decided 2026-09-02, so the implementer does not choose: the base arm keeps its OWN target directory, keyed on the merge-base SHA, and is restored WHOLE from a cache keyed on that SHA plus the toolchain hash. Closure sharing between the two arms is NOT taken — the :606-608 eviction and lock concerns are real under verify, and a base that is built once per merge base and then restored by every later run makes sharing unnecessary. A new merge base builds the base arm cold exactly once; that is the accepted cost.

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • **Authority boundary (§1). **crates/batten/src/perf.rs (the base arm's target directory and the build it runs) and .github/workflows/ci.yml's perf job (what is restored and saved around mise run perf-gate). No mise-tasks/ program and no .bats is edited or added — perf-gate.sh and perf-compare.sh are governed and are CLOUD-1163 unit 10's to retire, not this row's to touch. [profile.release] is untouched.
  • Computable predicate (§2). On a run whose merge base equals a prior run's, the base arm compiles zero crates — the base binary is restored, keyed on the merge-base SHA and the toolchain, and perf.rs accepts it only under a directory named for that SHA so a stale arm from another base can never be measured as this one. On a run with a new merge base, the base arm builds once, cold, into its keyed directory, and that directory is saved for every later run on the same base. The measurement itself — both arms sampled back to back on one machine, the ratio decided by perf-compare — is unchanged.
  • Deliberately not in scope (§2). Narrowing the skip set back (CLOUD-875 decided it). The ci job's debug-profile compile (CLOUD-1225, CLOUD-840). The bats job. Any change to what is measured, how many runs, or the 1.30 threshold. Retiring perf-gate.sh/perf-compare.sh (CLOUD-1163 unit 10).
  • **Effect (§3). **write, as perf pair already is — it builds and writes under target/. The one new write is the restored base directory, which is target/perf/'s own.
  • Output and exit (§5). Unchanged. A restored base arm prints the same arm= records; a base that cannot be restored builds as today and says nothing new. Exit follows the 0/1/2/3 table — a corrupt or mis-keyed restore is could-not-look (2), never a measurement.
  • **Commit / bump (§6). **perf(perf) — no bump; perf releases nothing at any version.
  • **Test obligation (§7). **crates/batten/tests/it/perf_pair.rs is the tier; add the cases there, shown able to fail per CLOUD-418: a base directory keyed to a DIFFERENT SHA is refused and rebuilt (the discriminator — without it a stale arm passes as fresh); a base directory keyed to the right SHA is reused without spawning cargo; the anti-vacuity mirror is a first run with no cached arm building both exactly as before. The CI half is a MEASUREMENT recorded on this row before it is kept: Compiling lines per arm and the perf-gate step wall on a run whose merge base matches its predecessor's, against the 13.5 min above, read from the job log API.
  • Blockers (§8). None. relatedTo CLOUD-875 (the skip this cost sits behind), CLOUD-172 (the gate's own row), CLOUD-840 (the same class on the debug side), CLOUD-1225 (the compile term, and the lossy-log method note), CLOUD-398 (the job graph), CLOUD-1151 (the dispatch it rides in).

Acceptance

  • A second run on an unchanged merge base shows the base arm compiling zero crates in its job log, and the perf-gate step wall recorded against 13.5 min.
  • A run with a new merge base builds the base arm once; the next run on that base restores it and compiles zero crates for it.
  • perf_pair.rs refuses a base directory keyed to another SHA, and that case is shown red before the fix.
  • mise run perf-gate in verify behaves exactly as today on a machine with no cached arm.

Found while grooming the CI-cost dispatch: measured against the runs above, bats was not the pole and this job was.

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 36 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 80129cca-ff44-4ef8-b436-c959a0889cc1

📥 Commits

Reviewing files that changed from the base of the PR and between 6363a2e and 79dd7b2.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • crates/batten/src/perf.rs
  • crates/batten/tests/it/perf_pair.rs

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

`perf pair` built the merge base's release binary from nothing on every CI
run: 13.5 min of the `perf` job's 13.7 (CLOUD-1331, measured over runs
33586699312 and 33584118886).

The base binary is a pure function of the merge-base SHA, the toolchain and
`[profile.release]`, and `main` advances only by fast-forward to already-judged
SHAs — so consecutive pull requests share a merge base for hours and every one
of them was compiling the identical binary.

`rust-cache` was already carrying the bytes: its cache paths are the whole of
`target/`, `target/perf/**` included, and the post-job cleaner walked
`target/perf/pair/base-tree/...` on both measured runs. `perf::out_dir`
`remove_dir_all`s `target/perf/pair/` at the start of every run and the base
target directory lived inside it, so the restore was thrown away microseconds
before the base build ran. The key alone would not have been enough.

- `base_target_dir` is `target/perf/base-<merge-base-sha>`, a sibling of the
  per-run `pair/` rather than a child of it. The SHA in the path IS the
  refusal: a directory built from another base does not answer to this name,
  so a stale arm cannot be measured as this one.
- `measure` skips `build(&base_tree, ...)` entirely when that directory already
  holds a binary, and bails could-not-look if a build leaves none.
- `build` drops `--quiet`, which suppressed every `Compiling` line and made the
  row's own acceptance reading answer `0` on a run that compiled the closure
  twice. Cargo writes progress to stderr, so `perf-gate.sh`'s `^arm=` contract
  over stdout is untouched.
- The `perf` job restores and saves that directory under an `actions/cache`
  entry keyed on the same SHA plus the toolchain and lockfile hash, with no
  `restore-keys` — a prefix hit would put another base's build under this
  base's name.

The measurement is unchanged: both arms still built or restored, sampled back
to back on one machine, the ratio still `perf-compare`'s.

Refs: CLOUD-1331

Admits: d7c27bde12b7fe89dce932035c38e953b155f388b91bca1a449d43dee5739fe2
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: .github/workflows/ci.yml
Admits-head: ee1c9f5
Admits-epoch: 51eda204a7ea8a29b661ddfcd5867afc30106378a358de6e6d7f7b3b617edd2c
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: The base arm's keyed target directory would be built and then thrown away on every CI run. The engine half of this change (a SHA-keyed `target/perf/base-<sha>` outside the per-run wipe) is inert without a cache entry that carries that directory between runs, so the 13.5 min the `perf` job spends rebuilding the merge base — measured on runs 33586699312 and 33584118886 — is unrecovered, and the row's acceptance ("a second run on an unchanged merge base compiles zero crates for the base arm") is unreachable. Landing only the Rust half would ship a mechanism with nothing driving it.
Admits-answer-precondition: The class names no surface that can add a step to a workflow job: `.github/workflows/**` is protected precisely because CI's definition of green must change under review, and there is no batten verb, task or generated artifact that writes a job's step list. CLOUD-1331 §1 names `.github/workflows/ci.yml`'s `perf` job as this row's authority boundary, and the change is two steps around `mise run perf-gate` — a merge-base resolution and an `actions/cache` entry keyed on it. It lands in a draft pull request whose diff a reviewer reads before it can merge, which is the redirect's own stated remedy ("change it in a pull request").
Admits-answer-rejected-route: `config read first` does not apply: nothing in `batten.toml` decides which steps a workflow job runs, so reading the committed config answers a different question and leaves the job unchanged. `patch run first` was in fact taken — the edit was applied as a reviewed one-shot splice with a single-anchor assertion (refusing unless the anchor matched exactly once) rather than a freehand rewrite, and the result is `git diff`-visible as +38 lines in one job. What it could not do is make the write unnecessary; the protected path still had to receive the bytes, which is why this admission is being requested rather than avoided.
`no_artifact_name_reaches_the_core` caught the previous commit naming
`.github/workflows/ci.yml` and a cache action in `base_target_dir`'s rationale.
Non-negotiable rule 1: the core knows that a consumer's CI carries the keyed
directory between runs; which file wires that up is the consumer's, and saying
so here would make the crate un-adoptable for a consumer whose CI is spelled
some other way.

The reasoning survives without the paths, and the one thing a reader has to get
right is stated more directly than before: the base is `base_commit`'s merge
base, never a forge's base-branch tip, because keying on the latter names a
directory this function never writes.

Refs: CLOUD-1331
@wenzowski
wenzowski force-pushed the claude/cloud-1331-perf-base-arm branch from caf109e to 79dd7b2 Compare September 2, 2026 14:52
@wenzowski
wenzowski marked this pull request as ready for review September 2, 2026 14:53
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 79dd7b2 into main Sep 2, 2026
21 of 22 checks passed
@wenzowski
wenzowski deleted the claude/cloud-1331-perf-base-arm branch September 2, 2026 15:12
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.

1 participant