Skip to content

perf(lint): make the recipe _target_ guard filesystem-cheap - #283

Merged
haonan3 merged 5 commits into
mainfrom
perf/recipe-targets-hook-index
Jul 31, 2026
Merged

perf(lint): make the recipe _target_ guard filesystem-cheap#283
haonan3 merged 5 commits into
mainfrom
perf/recipe-targets-hook-index

Conversation

@CjhHa1

@CjhHa1 CjhHa1 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

scripts/check_recipe_targets.py resolves each _target_ by probing candidate module paths on disk. With 2286 targets that is roughly 10k stat calls, and nearly all of them are negative lookups (asking for files that do not exist).

On a local checkout this is invisible. On a checkout that lives on a network filesystem it dominates everything else: on CephFS the hook takes 5m36s, of which only 0.7s is CPU — the rest is metadata round trips. Because the hook carries always_run: true, every commit and every push pays it, even for changes that cannot possibly affect a recipe.

What changed

  • One os.walk builds a dotted path -> file index up front, so the 2286 lookups become dict hits instead of filesystem probes.
  • File contents are read through a thread pool. The workload is pure I/O latency, so this costs nothing on local disk and hides round trips on a network mount.
  • Only the modules a recipe actually names get read and AST-parsed (previously an lru_cache gave the same effect; preserved).
  • The hook now triggers on \.(ya?ml|py)$ instead of always_run. A _target_ can only be stranded by a recipe edit or a Python rename/removal, so nothing else needs to trigger the scan. The scan itself is still full-tree (pass_filenames: false), and pre-commit run --all-files in CI is unaffected.

Measurements

Back to back on the same CephFS checkout, and on a local-disk checkout of the same commit:

before after
CephFS 5m36.6s 25.9s
local disk 0.210s 0.202s

Correctness

The rewrite preserves the original resolution rules: longest module prefix wins, a .py module shadows a package of the same name (the opposite of Python's own import machinery, but what this check has always done), only the first attribute after the module is checked, the explicit pkg.__init__.Symbol spelling still resolves, and vendored trees stay resolvable (SKIP_PARTS applies to recipe discovery only, exactly as before).

Old and new verdicts were compared over 1863 well-formed dotted paths — every real target plus mutations covering renamed symbols, renamed intermediate modules, case changes, package-only paths, __init__ spellings at every depth, paths into vendored trees, deeper attribute chains and pure nonsense. Zero mismatches: 547 resolve, 1316 do not.

One deliberate difference

Paths with an empty component, e.g. unirl.algorithms..cppo.CPPO, were silently accepted before, because Path.joinpath() discards empty parts. Python cannot import such a path (ModuleNotFoundError), so this was a false negative — the guard would have waved through a recipe that fails at launch. They are now reported. No such path exists in the tree today; the change only removes a blind spot.

Known gaps in the new trigger condition

Both are consequences of gating on files: instead of always_run, and both are still caught by CI's pre-commit run --all-files:

  • A commit that only deletes .py files skips the hook at the pre-commit stage, since pre-commit's get_staged_files() filters with --diff-filter=ACMRTUXB. The pre-push stage has no such filter, so it still runs there before anything leaves the machine.
  • A change touching only vendored Python skips both local stages, because the config's top-level exclude removes those paths before this hook's files filter is evaluated. This is latent: no recipe target currently points into a vendored tree.

If either is judged too loose, dropping just the .pre-commit-config.yaml hunk restores always_run and leaves the 13x speedup intact.

Behavior note

A module file that cannot be read now raises instead of being reported as an unresolved target. Both are fail-closed, and a traceback naming the file and errno is more actionable than unresolved _target_ 'x.y.Z' when the real problem is a permission or I/O error.

Test Plan

  • python3 scripts/check_recipe_targets.py reports the same 2286 unirl _target_ paths resolve.
  • Old-vs-new equivalence over 1863 well-formed dotted paths: 0 mismatches
  • Injecting a dead _target_ produces byte-identical stderr and exit code 1 under both implementations
  • pre-commit run check-recipe-targets --all-files passes
  • Hook is skipped for a non-recipe change (--files README.md) and runs for a .py change
  • ruff check and ruff format pass

Maintainer addendum (2026-07-31, haonan3)

Three commits pushed on top (AI-assisted; I reviewed the diff):

Verification: python lint/check_recipe_targets.py → 2310 resolve (same count as main's prober, now incl. the 6 experimental.* targets); old-vs-new equivalence over 139 unique real targets + mutations = 695 verdicts, 0 mismatches; dead-target injection → both exit 1, byte-identical stderr; full SKIP=no-commit-to-branch pre-commit run --all-files green (16 hooks, both guards firing from lint/).

check_recipe_targets.py probed candidate module paths per target, so the 2286
targets cost ~10k stat calls, nearly all of them negative lookups. That is
invisible on local disk but brutal on a network checkout: on CephFS the hook
took 5m36s, of which only 0.7s was CPU. Since it carries always_run, every
commit and every push paid it.

Walk the tree once into a dotted-path -> file index, read file contents through
a thread pool, and parse only the modules a recipe actually names. Same output,
same exit codes: an old-vs-new comparison over 933 dotted paths (the real
targets plus mutations covering renamed symbols, renamed intermediate modules,
package-only paths, vendored trees and deeper attribute chains) agrees on all
of them, 446 resolving and 487 not.

CephFS 5m36s -> 25.9s back to back on the same checkout; local disk is
unchanged at ~0.2s. Also gate the hook on yaml/py edits rather than always_run,
since only those can strand a _target_; `--all-files` CI still runs it.
- Index each package initializer under both ``pkg`` and ``pkg.__init__``. The old
  probe accepted the explicit spelling (``unirl.__init__.__getattr__`` resolved),
  and dropping it was an unintended behavior change.
- Only index directory chains and module stems that are valid identifiers, so a
  file such as ``foo.bar.py`` cannot fabricate a dotted path the old probe could
  never have reached.
- Drop a redundant ``str()`` around the ``ast.parse`` filename; it accepts any
  os.PathLike and decodes it to str anyway.
- Correct two claims in the comments: this check prefers a module over a package
  of the same name, which is the opposite of Python's own import machinery, and
  the measured CephFS runtime is ~26s rather than the ~15s first estimated.

Differential corpus grown from 933 to 1863 well-formed dotted paths, now covering
``__init__`` spellings at every depth: still zero mismatches against the old
implementation.
@github-actions github-actions Bot added the wip Draft / work in progress label Jul 31, 2026
@CjhHa1
CjhHa1 marked this pull request as ready for review July 31, 2026 08:01
@github-actions github-actions Bot added need review Ready and waiting for review and removed wip Draft / work in progress labels Jul 31, 2026
haonan3 added 3 commits July 31, 2026 16:25
…ook-index

# Conflicts:
#	.pre-commit-config.yaml
The index gate hardcoded PACKAGE = "unirl" from before main broadened
_TARGET_RE to unirl|experimental (#210), so experimental.* targets were
extracted but could never resolve. Derive the regex from PACKAGES so the
accepted roots and the index can't drift apart again.
scripts/ kept accumulating non-guard files because its name promised
generic tooling space (#158's ep_verify/, #210's verify script). Name
the folder after its real contract instead: lint/ holds exactly the
scripts wired into .pre-commit-config.yaml, and CLAUDE.md now states
the positive rule (verification harness results are quoted in the PR
Test Plan, not committed).
@haonan3

haonan3 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@CjhHa1 heads-up — I pushed three maintainer commits onto this branch (AI-assisted, reviewed by me). Explanation below; happy to split any of it back out if you'd rather keep this PR minimal.

1. 4bc557c5 — why CI was red, and the fix. This branch predates #210. When #210 merged, main broadened _TARGET_RE to (?:unirl|experimental) and SCAN_DIRS gained experimental, but the new index still gated on PACKAGE = "unirl" ("the only package _TARGET_RE accepts" — true at your base, no longer on main). On the merge preview the regex extracted experimental.* targets that could never enter the index → the 6 unresolved _target_ CI failures. The old prober resolved them because it joins paths from ROOT without caring about the top-level package. Fix: PACKAGES = ("unirl", "experimental") is now the single source — the regex alternation is derived from it and the walk gates on it, so the two can't drift apart again. (Worth noting: the 1863-path equivalence run in the PR description was measured on a pre-#210 tree, so this class of target didn't exist yet — no fault of the methodology.)

2. 071af5df — merged origin/main in (merge, not rebase, so your history is untouched). The .pre-commit-config.yaml conflict is resolved to keep both your files: \.(ya?ml|py)$ gating and #279's new check-experimental-boundaries hook.

3. 14b29205 — folded in the planned scripts/lint/ rename. After #284/#279, scripts/ held exactly the two pre-commit guards; we've been planning to name the folder after its real contract so it stops attracting ad-hoc verify harnesses (see #284). Both guards now live in lint/, the two hook entry: lines and the experimental/README.md references are updated, and CLAUDE.md now states the folder contract. Your script content is otherwise untouched (one ruff-format line collapse rode along).

Verification on the merged tree (local disk checkout):

  • python lint/check_recipe_targets.pycheck-recipe-targets: 2310 recipe _target_ paths resolve. — the same 2310 main's prober reports, now including the 6 formerly-failing experimental.* targets.
  • Old-vs-new per-target equivalence: 139 unique real targets × mutation variants (dead final attr, dead mid-module, chopped attr, deeper chain) = 695 verdicts, 0 mismatches.
  • Dead-target injection (unirl.nope.Nope + experimental.nope.Nope): both implementations exit 1 with byte-identical stderr.
  • SKIP=no-commit-to-branch pre-commit run --all-files → all 16 hooks pass, both guards firing from lint/.

@haonan3
haonan3 merged commit b149901 into main Jul 31, 2026
10 of 12 checks passed
@CjhHa1
CjhHa1 deleted the perf/recipe-targets-hook-index branch August 2, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

need review Ready and waiting for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants