Skip to content

perf: make validate <files> fast (22.7s -> 15ms on a 2000-file changeset) - #123

Closed
perryqh wants to merge 2 commits into
perf/harnessfrom
perf/o1-o2-combined
Closed

perf: make validate <files> fast (22.7s -> 15ms on a 2000-file changeset)#123
perryqh wants to merge 2 commits into
perf/harnessfrom
perf/o1-o2-combined

Conversation

@perryqh

@perryqh perryqh commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • validate <files> — the path documented as "fast mode for git hooks" — was the slowest way to use the tool on any real changeset. On a 130k-file monorepo a 2000-file run took 22.7s. It now takes 15ms.
  • Two independent causes, both removals rather than rewrites. Stacked on perf: add benchmark harness for validate and generate_and_validate #121 (the harness).

The two causes

1. The whole project was built for nothing. validate <files> resolves ownership from the config and the CODEOWNERS file alone — it never touches Runner's Project. But it went through Runner::new, which walks every tracked file first. That is a ~2.1s fixed cost on every invocation, including a single-file pre-commit hook.

2. The CODEOWNERS file was re-parsed once per path. The loop called team_for_file_from_codeowners per file. That helper wraps the path in a one-element slice and hands it to the batch query, which reloads the config and re-reads and re-parses the entire 17,981-line CODEOWNERS every time — parse_codeowners_entries is not memoized, unlike teams_by_github_team_name directly beside it. That is ~9.5ms per path, linear: ~20s of the 22.7s was re-parsing the same file 2000 times.

The fix for (2) is not a new abstraction. The batch function already accepts a slice and already parallelizes across it; it was simply being called N times with one path instead of once with N.

Measured

Corpus: 130,934 tracked files, 91,206 owned, 17,981-line CODEOWNERS. macOS/aarch64, 11 cpus, best of 3, warm cache.

Case Before After Speedup
validate 1 file 3,354 ms 9 ms 373x
validate 100 files 4,286 ms 9 ms 476x
validate 1,000 files 13,117 ms 12 ms 1093x
validate 2,000 files 22,730 ms 15 ms 1515x

The result is now flat in the number of files, which is the real point — the linear term is gone, not just reduced.

Measured independently on their own branches, so the two causes are attributable:

Branch 2,000 files What it isolates
baseline 22,730 ms
perf/o1-batch-codeowners-query 3,324 ms Batching alone: linear term gone, 3.3s project-build floor remains
perf/o2-skip-project-build 18,098 ms Build skip alone: floor gone, per-file term remains
this branch 15 ms Both

Correctness

This is the main risk in a change like this, so it was checked directly rather than inferred:

  • 807 mixed paths through both binaries — owned files, paths that are glob-filtered out, paths matching unowned_globs, absolute paths, and paths absent from CODEOWNERS. stdout, stderr and exit codes byte-identical.
  • Full suite green, including all 12 existing validate_files tests and the absolute-path case.
  • Corpus repo left clean.

Unowned files are still reported using the caller's original path string and in input order, so absolute paths render exactly as before. Note the query is keyed by project-relative path, so the original string is carried alongside rather than recomputed.

Two behavior changes, deliberately not hidden

  1. IO error granularity. The old loop could attribute a failure to one specific path (path/x.rb: <error>). A batched read succeeds or fails for the whole set, so the error is no longer per-path. This was open question [Experiment] Integrate error-stack for better error and stacktrace reporting #2 in the plan; if per-path attribution matters to a caller, the batch signature needs to be fallible per item.
  2. The fast path no longer creates or persists the cache, so it does not warm it for a following command, and it cannot surface project-build IO errors. --no-cache is a no-op there. This was open question feature parity: owner metadata key in package.json #1, and it is the one thing here I would not merge without your call.

Not included, and why

Measured and rejected — recording these so nobody re-attempts them:

Idea Result
Build mappers once instead of twice Flat. mapper_build is 0ms; the noise floor on this machine is ±2%. Kept on a separate branch as a simplification, not a perf change.
Precompute vendored-gem map / package sort Not implemented. All of it lives inside that same 0ms.
Avoid generating the CODEOWNERS file twice in gv Not implemented. validate_codeowners_file is 82ms — the entire available prize, not the ~900ms originally assumed.
Index CODEOWNERS entries instead of scanning Unnecessary. With batching the per-file term is already ~0.

🤖 Generated with Claude Code

perryqh and others added 2 commits August 20, 2026 11:59
validate <files> resolves ownership from the config and the CODEOWNERS file
only — it never touches Runner's Project. But it went through Runner::new,
which walks every tracked file in the repo first. On a 130k-file monorepo
that is a ~2.1s fixed cost paid for nothing, on the code path documented as
"fast mode for git hooks".

Extracts validate_files into a free function and routes api::validate to it
directly when paths are given, mirroring the bypass for_file already uses
for single-file queries.

Tradeoff, deliberately not hidden: the fast path no longer creates or
persists the cache, so it does not warm it for a following command, and it
cannot surface project-build IO errors. --no-cache is a no-op there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Combines O1 (one batched CODEOWNERS query instead of one per path) with O2
(skip the project build for validate <files>). The two wins are independent:
O2 removes the ~2.1s fixed project build, O1 removes the ~9.5ms/file linear
term. Together they take validate on 1000 files from 13.1s to 0.17s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@perryqh

perryqh commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Closing: this optimizes a path that is not semantically equivalent to validate, so making it faster is not a win.

validate <paths> resolves ownership by reading the CODEOWNERS file, which means it validates a derived artifact against itself. It cannot detect the main thing validate exists to detect. Reproduced on a clean fixture:

Scenario validate validate <path>
Annotation changed to a different team, CODEOWNERS not regenerated exit 1, shows the required diff exit 0 — passes with the wrong owner
Annotation names a team that doesn't exist exit 1 exit 0
File owned two ways (annotation + team glob) exit 1 exit 0

The existing test test_validate_only_checks_codeowners_file documents this behavior explicitly: "It does NOT check file annotations or other ownership sources… This is why generate-and-validate should be used for accuracy."

Background: the file-list path came from #82. #89 patched the unowned-file false positive by adding the owned_globs/unowned_globs filter, but the parity gap above was never closed. At the time, the measured benefit of validating only the supplied files was described as minimal — the check was not the slow hook — and zenpayroll stopped passing files from lefthook as a result.

The useful half survives separately. The two optimizations here split along exactly the correctness line:

  • Batching the CODEOWNERS query also speeds up generate-and-validate <paths>, which is correct because it regenerates before validating: 12.7s → 2.35s (5.4x) on 1000 paths in a 130k-file repo. Worth landing on its own.
  • Skipping the project build only helps validate <paths>, and additionally stops the cache being warmed. No value while that path shouldn't be used.

Follow-ups, in order:

  1. Close the parity gap — have validate <paths> resolve ownership through the mappers for the given paths rather than reading CODEOWNERS. Then it is worth optimizing.
  2. Land the batching win against generate-and-validate <paths>.

Branches perf/o1-batch-codeowners-query, perf/o2-skip-project-build and perf/o1-o2-combined are left in place as measurement artifacts; the harness in #121 reproduces all the numbers above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant