perf: add benchmark harness for validate and generate_and_validate - #121
Conversation
dduugg
left a comment
There was a problem hiding this comment.
Approving. The shipped-code change is clean, and the harness is well built — in a couple of places the defenses are stronger than the description claims. Reviewed by splitting it five ways: the library instrumentation, the harness binary, the scripts, the tests, and an audit of the numbers themselves.
Most of what follows is about the description and the precision of the instrument, not defects in the code. Since every later branch gets justified against this ruler, those seemed worth getting right now.
Verified — what held up
- The
in_scoperefactor is safe, for the specific reason this pattern usually breaks: no closure contains a bare?. Each either returns theResultvalue or has?applied afterin_scope()returns, so nothing returns from the closure instead of the function.cache_persist's closure borrowscacheimmutably, so it's still movable intoSelfafterward. - Span hygiene: all new spans are
level = "debug"withskip_all. Grepping all 15 span names acrosssrc/(not just the diff) confirms no collisions remain after theownership_validate/validator_validaterename. - The phase spans in the quoted table are disjoint, not nested, so the 81% / 17% figures are trustworthy.
config_load,cache_init,project_build,cache_persistopen and close in sequence insideRunner::new;per_file_queryopens later invalidate_files, afterRunner::newhas returned. Worth noting the generic "nested spans are inclusive" caveat atcodeowners-perf.rs:121does not apply here — but it will apply to anyvalidate_allbreakdown you quote later, whereownership_validate⊃validator_validate⊃validate_file_ownership/file_to_owners. Those percentages would double-count. - The unused project build is real.
validate_files(runner.rs:145-195) reads onlyself.run_configandself.config;self.ownershipappears nowhere in it. One caveat for whoever writes that optimization:Runner::newalso persists the disk cache at:108, so skipping the build changes cache warmth for later invocations — not read on this path, but not side-effect-free either. - Skip-not-shrink works, and no
.take()/min()silently reduces a case. Confirmed against the fixture:validate_files_100/1000/2000correctly reportskippedwith a reason. - The word-splitting bug class is structurally impossible, not merely avoided: there's no subprocess boundary for timed work at all. The only
Command::newisgit_output()for metadata. That's a stronger guarantee than the description claims. - The ~0.2s
generate_and_validateceiling checks out two ways.gv − validate_all= 3,342 − 3,176 = 166ms. Andgenerate + validate_all − gv= 2,054ms, which independently reproducesproject_build's 2,073ms from the phase table — a nice cross-check that the shared cost is theRunner::newyou'd expect. - Corpus precedence, the smoke-scale banner, the dirty-CODEOWNERS refusal,
perf/results/being gitignored, and the corpus/commit-mismatch refusal all verified by running them against the committed fixture. No absolute paths or usernames leaked anywhere in the diff. - Tests: 8/8 pass, and the run leaves the tree clean. Skip logic, dirty-corpus refusal, restore-on-drop, and compare-refusal are all pinned non-vacuously, on tempdir copies with
--corpuspassed explicitly.
The per-file cost is mostly re-done setup
The most actionable thing I found, and I think it changes the optimization plan rather than just the description.
per_file_query calls team_for_file_from_codeowners once per file (runner.rs:167). That function (runner/api.rs:76) wraps each single path into a one-element slice and delegates to the batch API teams_for_files_from_codeowners, whose first line (runner/api.rs:64) is config_from_run_config(run_config)?.
So a 1,000-file changeset parses the config 1,000 times, plus re-resolves the CODEOWNERS path 1,000 times — and all of it is charged to per_file_query, which is why config_load reads 0ms and looks free. It isn't; it's just being billed to the wrong span.
The batch function already takes &[String] and does that setup once. Calling it once with all paths, instead of N times with one path, looks like it captures a large share of the 9.5ms/file without touching the query logic at all.
The cost model is affine, not proportional
"9.5ms per file, linearly" is right as a marginal rate — marginals between your four points are 9.68, 9.58, 10.24 ms/file, and a least-squares fit gives 2,043ms fixed + 9.89ms/file, predicting all four points within 0.5-3.3%. The 22s projection for 2,000 files matches.
But there's a ~2.05s fixed floor, which is ~92% of generate's entire 2,220ms. So the per-file average is never 9.5ms — it's 2,123ms at n=1, 30.8ms at n=100, 11.0ms at n=2,000. For the common CI case (a PR touching a handful of files) essentially all the time is fixed overhead, and the per-file rate is nearly irrelevant. That's a different optimization target than the per-file re-parse, and possibly a more valuable one — worth stating as "9.5ms/file marginal plus ~2.0s fixed" so the series doesn't optimize the wrong end.
Precision: can this ruler resolve 3-8%?
Not yet, for two of the cases, and it's currently impossible to tell for the rest:
validate_all_coldcan't resolve anything useful. Your own 6.0-9.1s range is a 3,040ms spread against a 3-8% target of 182-485ms — noise roughly 6x the largest effect being hunted. Recommend excluding it fromcompareoutput or marking it explicitly non-comparable.- Warm variance is unknowable from this PR. The harness stores
runs_msandmedian_ms(codeowners-perf.rs:118-120) but the write-up publishes onlybest. Min-of-3 is a biased estimator with no dispersion attached. Reporting median + min + max (and--runs 10when it matters) would make the ruler's own precision visible — arguably the one number a ruler PR has to include. [profile.release]is at cargo defaults (debug = trueonly — nolto, nocodegen-units). Your own pks#53 measured that exact config as 8x worse run-to-run variance (±0.084s → ±0.010s). It matters more here than there: baseline and candidate are separate builds, so codegen nondeterminism contaminates the A/B comparison directly rather than just widening error bars.Cargo.tomlisn't in this diff so I couldn't comment inline, but tightening it before generating baselines seems worth doing.- In-process measurement omits a cost the real CLI pays. All cases share one process, and
teams_by_github_team_nameis#[memoize]d process-globally (ownership/codeowners_file_parser.rs:67), so the warmup run pays the team-file parse and no timed run ever does. Every published number is missing something a per-invocation CLI pays every time. Process-per-run would close it; at minimum worth a documented caveat. (Mechanism confirmed; magnitude I can't estimate without your corpus.)
Description details
- "Three commits" — the head has four.
69feb9d("measure gv with explicit paths") came after, and the baseline table also omits thegv_files_100/gv_files_1000cases it added. - The phase breakdown and the headline table are different sessions. The breakdown totals 12,063ms for
validate_files_1000; the table lists 11,706ms. Both fine individually, but they read as one dataset. - The guards are credited to the wrong files. "
compare.shrefuses…" and "run.shprints…" — both behaviors live insrc/bin/codeowners-perf.rs(banner at:497, refusal at:611-621). The scripts are ~20-line wrappers that build andexec;compare.sh's only own logic is an arg-count check. The behavior exists exactly as described, it's just implemented in the binary — worth saying "the harness" so a reader doesn't go looking in the shell.
Absolute numbers I could not verify without your 130k-file corpus; everything above is internal consistency plus code reading. Nothing here blocks merge.
| } | ||
|
|
||
| // Held for the whole run; restores the corpus CODEOWNERS on drop. | ||
| let _guard = CodeownersGuard::acquire(&corpus, &config)?; |
There was a problem hiding this comment.
The guard's scope means cases 2-N don't see the corpus they claim to. This is the one finding I'd want addressed (or documented) before trusting a full-suite run on a real monorepo.
CodeownersGuard is acquired here, outside the for case in selected loop at :518, so the snapshot is restored once when cmd_run returns — after every case has run. generate is case #1 in CASES and rewrites the on-disk CODEOWNERS via runner::generate. So validate_all, gv, gv_files_*, validate_all_cold and validate_files_* all validate the freshly-generated file, not the corpus's committed one.
On any corpus where the committed CODEOWNERS has drifted from what the config would generate — precisely the state the validator exists to detect — that silently zeroes validation_errors for every case after the first, and makes their error-formatting and diff work artificially cheap. So it distorts the timings too, not just the counts.
The "corpus repo was left clean" claim is accurate about the end state; it doesn't hold for what cases 2-N observe mid-run. It doesn't show up on the committed fixture only because that fixture's generated output happens to be byte-identical to its tracked file — a property of the fixture, not a guarantee the harness enforces. --case <one> avoids it, since generate never runs.
Cheapest fix is probably to acquire/drop the guard per case inside the loop, so each case starts from the pristine file.
| Ok(Some(_)) => {} | ||
| Ok(None) => unowned_files.push(file_path), | ||
| Err(err) => io_errors.push(format!("{}: {}", file_path, err)), | ||
| debug_span!("per_file_query").in_scope(|| { |
There was a problem hiding this comment.
This is where the 9.5ms/file goes, and it explains why config_load reports 0ms.
The loop calls team_for_file_from_codeowners once per file. That function (runner/api.rs:76) wraps the single path into a one-element slice and delegates to the batch API teams_for_files_from_codeowners — whose first line (runner/api.rs:64) is config_from_run_config(run_config)?, followed by resolve_codeowners_file_path.
So for validate_files_1000 the config is parsed 1,000 times and the CODEOWNERS path re-resolved 1,000 times, and all of it lands inside this per_file_query span. Meanwhile the config_load span wraps only the single load in Runner::new, which is why the breakdown shows config_load 0ms and makes config parsing look free. It isn't — it's being billed to per_file_query, inside the 81%.
Two implications:
- The phase table slightly misdirects: some meaningful part of that 81% is repeated setup, not the per-file CODEOWNERS matching the name implies.
- The fix is already available —
teams_for_files_from_codeownerstakes&[String]and does the setup once. Calling it a single time withfiltered_pathsinstead of N times with one path each looks like it recovers a large share of the per-file cost without touching the matching logic.
Not a defect in this PR (the span is faithfully placed around the loop). Flagging it because it changes what the next optimization should target.
| if status == "skipped" { | ||
| assert!(case["skip_reason"].is_string(), "skipped case must give a reason"); | ||
| } else { | ||
| assert!(!case["runs_ms"].as_array().unwrap().is_empty()); |
There was a problem hiding this comment.
The suite can't catch the harness recording garbage timings — which is the exact failure mode the README's zsh story is about.
This asserts only that runs_ms is non-empty. If the timing mechanism regressed to always producing zero (an Instant reused, elapsed() on the wrong instant, a timed loop that stops running the case), runs_ms would be [0] — still non-empty — and all 8 tests would stay green. A harness reporting confident zeros is worse than one that crashes, since the numbers get pasted into a PR body.
The asymmetry is what caught my eye: :72-73 do value-check the corpus fields (tracked_files > 0, codeowners_lines > 0), so the pattern is already established here — it just isn't applied to the measurements themselves.
Also, file_count is never read by any test (it appears only in the synthetic JSON at :229). The "each case asserts it built exactly N paths" guarantee therefore rests entirely on the assert_eq! at codeowners-perf.rs:426 — and that assert can't currently fail anyway, since the skip check at :406-419 already guarantees case.files <= pool.len() before .take() runs. So that guard is a tautology with no test-side net.
Two lines would close both: assert!(case["best_ms"].as_u64().unwrap() > 0) for a non-skipped case, and assert_eq!(case["file_count"], 1) on validate_files_1.
Noting also that median() is only exercised via pre-computed JSON — every real-run test uses --runs 1 --warmup 0, so the actual sort/median path never sees more than one sample.
| .DS_Store | ||
| /tmp | ||
| **/project-file-cache.json | ||
| # Local benchmark output. perf/baseline.json is committed on purpose. |
There was a problem hiding this comment.
This comment contradicts both the code and the README sitting next to it: no perf/baseline.json is tracked anywhere in this PR, and perf/README.md explicitly says the opposite — that there is deliberately no committed baseline, because it would embed a local absolute path and invite the cross-corpus comparison the guards exist to prevent.
Reads like a rationale from an earlier version of the plan. Something like "so a local baseline export can't be committed by accident" would match what the line actually does.
Adds explicit span names so a profiler can attribute time to a specific phase instead of a function. Two spans were both called "validate" (Ownership::validate and Validator::validate) and collapsed into one another in any aggregation; mapper construction, the validator sub-steps and the runner's cache/config work had no spans at all. New spans: config_load, cache_init, cache_persist, per_file_query, mapper_build, validate_invalid_team, validate_file_ownership, validate_codeowners_file, file_to_owners. All spans are level=debug, so they cost nothing without a subscriber that enables them. No logic change: the cache_init and cache_persist blocks are wrapped in in_scope closures that propagate the same errors as before, and generating a CODEOWNERS for a 130k-file repo produces byte-identical output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a `codeowners-perf` binary plus perf/run.sh and perf/compare.sh so performance claims can be checked rather than asserted. It runs eight named cases, records wall-clock and per-phase timings, and emits JSON that can be diffed across branches. Deliberately not wired into CI: shared runners are too noisy for 2-20s wall-clock comparisons and have no large corpus. This is a local tool. Corpus resolution is --corpus, then $CODEOWNERS_PERF_CORPUS, then the committed tests/fixtures/valid_project. No path to any specific monorepo is stored in the repo. Guards against measuring nothing, all of which are failure modes hit while profiling this by hand: - The fixture default is smoke-test scale (28 files), so run.sh prints a loud banner under 1000 files and every report records corpus size. - compare.sh refuses to diff reports from different corpora or corpus commits, and warns when the machine differs. - Cases needing more files than the corpus has are reported as skipped with a reason, never silently shrunk. Each case asserts it built exactly the number of paths it asked for. - generate/gv write the corpus CODEOWNERS, so it is snapshotted and restored, and the harness refuses to start if that file already has uncommitted changes. No committed baseline: wall-clock numbers are machine-specific, and a committed report would embed a local absolute path. perf/results/ is gitignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tests that the harness works, not that anything is fast: case listing, JSON shape, skip-with-reason for undersized corpora, corpus CODEOWNERS restored after a run, refusal on a dirty corpus, and compare rejecting mismatched corpora. Timings are machine-dependent, so nothing here asserts a duration. These run on the committed fixture and finish in under a second, which keeps the harness from rotting silently even though it never runs in CI for measurement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The case list covered `gv` with no paths and `validate` with paths, but not `gv <paths>` — which is both the likely real pre-commit invocation and the only path-taking form that is semantically equivalent to a full validate. That gap matters. `validate <paths>` resolves ownership by reading the CODEOWNERS file, so it cannot detect a stale CODEOWNERS, an annotation naming a nonexistent team, or a file owned two ways. `gv <paths>` regenerates first, so it can. Measuring only the former would credit an optimization for speeding up a check that does less work than the one people rely on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of #121 raised that publishing `best` alone hides whether a delta is resolvable. min-of-N is a biased estimator with no dispersion attached, so a 3% "win" on a case that swings 40% between runs reads exactly like a real one — which is how a machine drift of ~35% mid-sweep produced three convincing but entirely fake wins earlier in this series. - compare now reports observed run-to-run spread per case and marks any delta smaller than it **within noise**. This generalizes the validate_all_cold problem (3s spread against sub-500ms effects) instead of special-casing it. - Pins codegen-units=1 and lto="thin" for the release profile. Baseline and candidate are separate builds, so codegen nondeterminism contaminates an A/B comparison directly rather than just widening error bars. Full release build goes to ~30s wall, which is an acceptable price for numbers that mean something. - Documents two things the numbers do not include: per-invocation setup is undercounted, because teams_by_github_team_name is memoized process-globally so only the warmup run pays the team-file parse; and the per-file cases are affine (~2.0s fixed + ~9.9ms/file), so on a small changeset essentially all the time is fixed cost and the per-file rate is nearly irrelevant. - Documents which phase spans are disjoint and which nest, since quoting nested ones together as shares of a total double-counts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks — this was a genuinely useful review, and the two findings I'd single out both changed what happens next rather than just the prose. Pushed Fixed in codePrecision is now reported, not just I took this over excluding
The memoize gap is documented. Nesting is documented. Fixed in the descriptionCommit count (five now), the guards credited to The affine cost model is now stated as "~2.0s fixed + ~9.9ms/file." This is the finding I think matters most, and I had it wrong. Quoting a flat 9.5ms/file implies the per-file term is the target; your fit shows that for a PR touching a handful of files essentially all the time is the fixed project build. The per-file average is ~2,100ms at n=1. Where it wentYour observation that the per-file cost is mostly re-done setup — One thing worth flagging since you raised the cache-warmth caveat on skipping the project build: I built that optimization, and then closed it (#123). |
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>
* Set up performance measurement for `pks check` 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> * Guard measure.sh against reporting numbers that mean nothing 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> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
tracingspans at phase boundaries so a win can be attributed to a specific phase rather than to a function.Why the harness lands on its own first
Every later branch is one optimization idea, measured against this same ruler. If the harness shipped alongside the first optimization, there would be nothing to compare the first optimization against.
It has already earned its keep several times over, mostly by killing ideas:
generate_and_validatedouble-generation I expected to be worth ~0.9s has a 166ms ceiling. Dropped from near the top of the list to last.mapper_buildmeasures 0ms, so "build mappers once instead of twice" is a code-quality change, not a perf one — and the follow-on idea of precomputing the vendored-gem map and package sort was abandoned without being written, because all of it lives inside that 0ms.open/statsyscalls, not bytes.generatecontrol case "improved" 36% on a branch that cannot touch it.That last one is why this PR now reports dispersion rather than just
best.What the phase decomposition buys us
The reason to instrument rather than just time the process. One case, on a 130k-file monorepo:
Those five spans are disjoint — they open and close in sequence — so the percentages are sound.
perf/README.mddocuments which spans nest (ownership_validate⊃validator_validate⊃validate_file_ownership⊃file_to_owners), because quoting those together as shares of one total would double-count.Two caveats on that table worth stating plainly:
config_loadreading 0ms is an artifact, not good news.per_file_querycalls a helper that wraps each path in a one-element slice and delegates to the batch API, whose first act is to reload the config. So a 1000-file run parses the config 1000 times and re-resolves the CODEOWNERS path 1000 times — all billed toper_file_query. (perf: batch the CODEOWNERS query sogv <paths>is 4x faster #124 fixes this by calling the batch function once.)Cost model: affine, not proportional
"9.5ms per file" is the correct marginal rate, but the per-file cases fit ~2.0s fixed + ~9.9ms/file. So the per-file average is ~2,100ms at one file and ~11ms at two thousand.
For the common CI case — a PR touching a handful of files — essentially all of the time is the fixed project build and the per-file rate is nearly irrelevant. Both terms need quoting or the series optimizes the wrong end.
Reporting precision
compareprints the observed run-to-run spread per case and marks any delta smaller than it within noise:This generalizes the
validate_all_coldproblem rather than special-casing it: that case swings ~3s between runs, which is larger than most effects worth hunting, so it is useful as a guard against warm-only wins and not as a number to optimize against.Cargo.tomlalso pinscodegen-units = 1andlto = "thin". Baseline and candidate are separate builds, so codegen nondeterminism contaminates an A/B comparison directly rather than merely widening error bars. Full release build goes to ~30s wall.Two things the numbers still do not include, both documented in
perf/README.md:teams_by_github_team_nameis#[memoize]d process-globally, so the warmup run pays the team-file parse and no timed run ever does. A real CLI invocation pays it every time. Published numbers are a floor for single-shot cost.validate_all_coldcannot resolve small effects, per above.Reviewer guidance
Five commits, ordered so the shipped-code change is reviewable on its own:
gv <paths>cases — closing a gap where the harness measuredvalidate <paths>but not the path-taking form that is actually equivalent to a full validate.On the library commit
Two spans were both named
validate(Ownership::validateandValidator::validate) and collapsed into each other in any aggregation; mapper construction, the validator sub-steps, and the runner's config/cache work had no spans at all.All spans are
level = "debug", so they cost nothing without a subscriber that enables them. The only non-attribute changes arecache_initandcache_persistbeing wrapped inin_scopeclosures — note no closure contains a bare?, so none of them can return from the closure instead of the function.Verified: generating a CODEOWNERS for a 130,934-file repo produces byte-identical output, and the corpus repo was left clean.
Guards against measuring nothing
All of these live in
src/bin/codeowners-perf.rs;perf/run.shandperf/compare.share ~20-line wrappers that build andexec.Command::newis for git metadata.)comparerefuses to diff reports from different corpora or corpus commits, and warns when the machine differs. A fixture-measured branch against a monorepo-measured baseline would otherwise read as a 1000x speedup.generate/gvwrite the corpus CODEOWNERS, so it is snapshotted and restored, and the harness refuses to start if that file already has uncommitted changes.Corpus configuration
--corpus, then$CODEOWNERS_PERF_CORPUS, then the committedtests/fixtures/valid_project. No path to any specific monorepo is stored in the repo.The fixture default is a genuine smoke test — 28 files, 41-line CODEOWNERS, single-digit milliseconds. It proves the harness works and is useless for comparison, so
run.shprints a loud banner under 1,000 tracked files and every report records corpus size.Not wired into CI
Shared runners are too noisy for 2-20s wall-clock comparisons and have no corpus.
cargo testcovers the harness mechanics (8 tests, sub-second, on the committed fixture) so it cannot rot silently, but it never measures. Tradeoff: perf regressions are caught only when someone runs the harness deliberately.No committed baseline, which departs from the original plan: wall-clock numbers are machine-specific, so a committed report would invite exactly the invalid comparison the guards prevent — and it would embed a local absolute path.
perf/results/is gitignored.Baseline for the record
Not committed, for the reasons above. macOS/aarch64, 11 cpus; corpus of 130,934 tracked files, 91,206 owned, 17,981 CODEOWNERS lines. Best of 3, warm cache unless noted.
gv_files_*were added by a later commit and measured separately (see #124).generatevalidate_allgvvalidate_all_coldvalidate_files_1validate_files_100validate_files_1000validate_files_2000Cross-check:
generate + validate_all − gv= 2,054ms, which independently reproducesproject_build's 2,073ms from the phase table.Verification
🤖 Generated with Claude Code