Set up performance measurement for pks check - #53
Conversation
a2d3077 to
4008a93
Compare
dduugg
left a comment
There was a problem hiding this comment.
Approving with nits. I fetched the head, ran cargo build --release, and smoke-tested dev/measure.sh against tests/fixtures/simple_app — it runs end-to-end and produces a correct phase table, so the tooling works rather than merely looking right.
Verified
set -euo pipefailin both scripts. Norm -rfanywhere — the onlyrmis a guardedrm tmp/packs_benchmarks.mdbehind an-fcheck, so there's no unset-variable path-deletion hazard.PKS_APP/PKS_ROOT/PKS_BINall error clearly before use (emptyPKS_APP, missingpackwerk.yml, non-executable binary).- Phase-breakdown parsing matches the real subscriber format in
logger.rs(0.000116875s DEBUG file:line: message); the awk field-skip logic is correct and I confirmed it live. It is fragile to a tracing-format change with no test coverage, but that's a reasonable tradeoff for a dev-only script. - macOS/BSD portability is fine — no
date -d,sed -i,grep -P, orreadlink -f; thesed -E/awkusage is portable. - All four new trace points sit on paths that always execute for the thing they measure (checked
get_all_violations,check_all,packs::check,main.rs) — no early return skips a trace and silently understates a phase. FmtSpan::ACTIVEinlogger.rs(pre-existing) only affectstracing::span!/#[instrument]spans, and there are none insrc/, so it doesn't interleave extra lines into the phase table.
The one substantive point is the [profile.dist] interaction, inline on Cargo.toml. Two cosmetic nits also inline.
| # without which changes worth a few percent cannot be told apart from noise. | ||
| [profile.release] | ||
| lto = "thin" | ||
| codegen-units = 1 |
There was a problem hiding this comment.
This makes "No behavior changes" in the description slightly imprecise, and it's worth a line in the body.
[profile.dist] immediately below uses inherits = "release" and explicitly overrides only lto = "thin". So adding codegen-units = 1 here propagates into dist too, where it previously picked up cargo's default of 16. That changes the build configuration of the shipped cargo-dist artifacts, not just local benchmarking.
No runtime-behavior change, and ci.yml's test job doesn't build --release so there's no CI-time impact there — the cost is release/dist build time. Landing it is fine (arguably an improvement, since it aligns dist with what you measured). The description just shouldn't describe it as inert.
| # --ignore-failure: these commands exit non-zero when they find violations, which | ||
| # is the normal state of an application worth benchmarking. Combined with `set -e` | ||
| # above, omitting it would abort the run and discard the results. | ||
| hyperfine --ignore-failure --warmup=2 --runs=3 --export-markdown tmp/bm.md \ |
There was a problem hiding this comment.
Cosmetic: this script never checks command -v hyperfine before use, whereas measure.sh:48 does and prints an actionable brew install hyperfine. Under set -euo pipefail a missing hyperfine here just aborts with the shell's own exit-127 "command not found". Functional, just less friendly than its sibling.
| # `hyperfine --ignore-failure` cannot distinguish "exited 1 because it found | ||
| # violations" from "could not be executed", and happily reports 0.0 us for a | ||
| # binary that does not exist. Check before measuring nothing. | ||
| if [ ! -x "$PKS_BIN" ]; then |
There was a problem hiding this comment.
Noting the residual gap rather than the one you already handled — the comment above plus this -x guard covers "binary doesn't exist" well.
What --ignore-failure still can't distinguish is "exited 1 because it found violations" (expected) from "panicked at exit 101" on a binary that does exist and is executable. Both get timed as valid runs. Since the purpose of this script is validating later perf changes, a change that panics on every invocation would report a fast, clean-looking mean rather than failing.
Cheap hardening if you want it: assert a known-good exit code set, or grep the captured output for panicked at. Not worth blocking on.
Review points on #53, plus ideas borrowed from rubyatscale/codeowners-rs#121, which builds the same kind of harness and systematizes the failure modes. The theme is that a measurement tool's worst failure is a plausible number, not an error. Four guards: - Refuse to time a binary that does not work. `hyperfine --ignore-failure` is needed because `pks check` exits 1 on violations, but it also treats a panic (101) or an internal error (2) as a valid run -- so a change that broke the tool outright would report a fast, clean-looking mean. Now probes once first, accepts only 0 or 1, and greps for `panicked at`. Verified against tests/fixtures/app_with_monkey_patches, which panics: refused, panic printed. - Warn loudly under 1000 files. The phases this exists to compare scale with codebase size; on a fixture they are all startup cost. My own smoke test printed "19.3 ms +/- 3.0 ms" for a 9-file fixture, which looks like a measurement and is not one. - Report the noise floor next to the mean, so a delta can be judged against it rather than assumed real. Also states that this is *within-batch* spread and understates between-session drift -- an unchanged binary measured 5.1s and 8.1s on the same machine hours apart, which is larger than most effects worth hunting. The guidance is to A/B two builds in one hyperfine run. - Record provenance: corpus file count, pack count, commit, and whether the corpus is dirty, plus the pks commit and branch. A mean without the corpus it came from is not comparable to anything, and mixing two was previously silent. Also adds the `command -v hyperfine` check to run_benchmarks.sh, which measure.sh already had. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three addressed, and the
Worth noting that fixture panics on
On the wider point. Your framing — that the tool would report "a fast, clean-looking mean" for a broken binary — is the thing I'd underweighted. A measurement tool's worst failure mode is a plausible number, not an error, because a plausible number gets published. @perryqh pointed me at codeowners-rs#121, which systematizes exactly that, so I borrowed three more guards:
Your point about the awk parsing being fragile to a tracing-format change with no test coverage stands and I've left it — but it's now less load-bearing, since a format change would produce an empty phase table and the script errors on that rather than printing zeros. Also appreciate the check that all four trace points sit on paths that always execute. That was the failure mode I'd have found hardest to notice: a trace that gets skipped by an early return silently understates its phase, and the number still looks reasonable. |
Groundwork for a series of performance changes. No behavior changes. - `[profile.release]` was left at cargo defaults (lto = false, codegen-units = 16), so `cargo build --release` -- what dev/run_benchmarks.sh measures -- was less optimized than the shipped `dist` build. Now thin LTO + one codegen unit. Measured on a 51k-file app: 5.289s -> 5.127s, and run variance drops from +/-0.084s to +/-0.010s. Fat LTO was measured too and is worse on both axes (5.433s, 42s build vs 27s), so thin stays. - Add `dev/measure.sh`: hyperfine mean plus a per-phase table derived from the `--debug` tracing already in the tool. - Add trace points around the previously untraced tail after the checkers finish, so dropping the reference vector, diffing package_todo.yml, writing output, and final teardown are each attributable instead of appearing as one unexplained gap before process exit. - dev/run_benchmarks.sh: honor PKS_ROOT/PKS_BIN instead of hardcoding a sibling ../pks checkout, and drop the single-file benchmark (that command is buggy and slated for removal, so we shouldn't track a number for it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review points on #53, plus ideas borrowed from rubyatscale/codeowners-rs#121, which builds the same kind of harness and systematizes the failure modes. The theme is that a measurement tool's worst failure is a plausible number, not an error. Four guards: - Refuse to time a binary that does not work. `hyperfine --ignore-failure` is needed because `pks check` exits 1 on violations, but it also treats a panic (101) or an internal error (2) as a valid run -- so a change that broke the tool outright would report a fast, clean-looking mean. Now probes once first, accepts only 0 or 1, and greps for `panicked at`. Verified against tests/fixtures/app_with_monkey_patches, which panics: refused, panic printed. - Warn loudly under 1000 files. The phases this exists to compare scale with codebase size; on a fixture they are all startup cost. My own smoke test printed "19.3 ms +/- 3.0 ms" for a 9-file fixture, which looks like a measurement and is not one. - Report the noise floor next to the mean, so a delta can be judged against it rather than assumed real. Also states that this is *within-batch* spread and understates between-session drift -- an unchanged binary measured 5.1s and 8.1s on the same machine hours apart, which is larger than most effects worth hunting. The guidance is to A/B two builds in one hyperfine run. - Record provenance: corpus file count, pack count, commit, and whether the corpus is dirty, plus the pks commit and branch. A mean without the corpus it came from is not comparable to anything, and mixing two was previously silent. Also adds the `command -v hyperfine` check to run_benchmarks.sh, which measure.sh already had. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2f2ea9b to
c17af17
Compare
Groundwork for a series of performance changes to
pks check. This branch only makes the tool measurable and fixes the release profile.Note
Stacked on #52. The base is
bump-rust-toolchain-1.97.1, so the diff above shows only the measurement work. GitHub will retarget this tomainautomatically once #52 merges.Why
There was no way to answer "did that make it faster, and which phase moved?" without a lot of manual setup.
dev/run_benchmarks.shcompares against packwerk but reports only a total, and assumed a specific directory layout.What's here
1.
[profile.release]was never configured. It sat at cargo defaults —lto = false,codegen-units = 16— socargo build --release, which is whatdev/run_benchmarks.shmeasures, was less optimized than the shippeddistbuild. Now thin LTO and one codegen unit.Measured on a 51,513-file application:
lto = "thin"lto = "fat"Fat LTO is slower to run and 55% slower to build, so thin it is — which also matches the existing
distprofile. The numbers are recorded as a comment inCargo.tomlso this doesn't get re-litigated.The variance drop matters as much as the mean: ±0.084 s → ±0.010 s. Several changes I want to measure next are worth 3–8%, which is not distinguishable from noise at the old variance.
Important
This is not entirely inert, contrary to what this description originally said. Thanks @dduugg for catching it.
[profile.dist]usesinherits = "release"and overrides onlylto, socodegen-units = 1propagates intodist, where it previously picked up cargo's default of 16. That changes the build configuration of the shipped release artifacts.No runtime-behavior change, and
ci.yml's test job doesn't build--releaseso there's no CI-time cost. The price isdistbuild time. I think it's right to land — it aligns the shipped binary with what was actually measured, rather than benchmarking one configuration and shipping another — but it is a deliberate change todist, not a side effect.2.
dev/measure.sh— hyperfine mean plus a per-phase table derived from the--debugtracing already in the tool. No new instrumentation was needed for the phase breakdown; the tracing subscriber already timestamps every span.3. Four trace points around the tail after the checkers finish. Dropping the reference vector, diffing
package_todo.yml, writing output, and final teardown were previously one unexplained gap before process exit. They're now attributable — which immediately paid off: a gap I had estimated at 0.435 s turned out to be 0.120 s, and the "optimization" I was about to write for it would have been worth ~12 ms.4.
dev/run_benchmarks.sh— honorsPKS_ROOT/PKS_BINinstead of hardcoding a sibling../pkscheckout, createstmp/if missing, checks for hyperfine, and errors clearly if the binary isn't built. Also drops the single-file benchmark block, since that command is buggy and slated for removal and we shouldn't defend a number for it.Guards against reporting numbers that mean nothing
Added in review, partly borrowed from rubyatscale/codeowners-rs#121, which builds the same kind of harness and systematizes these failure modes. The theme: a measurement tool's worst failure is a plausible number, not an error.
--ignore-failureis required becausepks checkexits 1 on violations — but it also accepts a panic (101) or internal error (2) as a valid run, so a change that broke the tool outright would report a fast, clean-looking mean. Now probes once, accepts only 0 or 1, and greps forpanicked at. Verified againsttests/fixtures/app_with_monkey_patches, which panics: refused, with the panic printed.19.3 ms ± 3.0 msfor a 9-file fixture — that looks like a measurement and isn't one.Verification
cargo test— 257 passingcargo clippy --all-targets --all-features— cleancargo fmt --all -- --check— cleandev/measure.shexercised against a 50k-file application, a 9-file fixture (warning fires), a panicking fixture (refused), a missing binary (refused), a missingpackwerk.yml(refused), and a slash-containing default label.Smoke-testing caught three real bugs during review that a passing-looking run had hidden: the default label is the branch name and contains
/, which broke the JSON export path;grep-filtering hyperfine's output swallowed its error message so failures printed nothing; andpipefailaborted the script after the phase table becausepks checkexits 1 on violations.🤖 Generated with Claude Code