Skip to content

Skip reading source files on a warm cache hit via mtime + length - #59

Open
perryqh wants to merge 2 commits into
mainfrom
perf/mtime-cache-fastpath
Open

Skip reading source files on a warm cache hit via mtime + length#59
perryqh wants to merge 2 commits into
mainfrom
perf/mtime-cache-fastpath

Conversation

@perryqh

@perryqh perryqh commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

1.43× faster on pks check, measured against current main in a single hyperfine run.

What it does

process_files_with_cache is the largest phase of pks check, and half of it is work we can avoid: for every file we open it, read it in full, and MD5 it, purely to compare a digest against the cache entry.

This records (mtime_ns, len) alongside the digest and settles the common case — nothing changed since the last run — with one stat.

The digest stays the authority. If no stat is recorded, or the stat moved, we fall back to reading and hashing exactly as before. An entry whose contents match but whose stat moved (a git checkout, a touch) is repaired in place so the next run takes the fast path.

Measured

51,513-file application, warm cache, both binaries in one hyperfine run:

main this branch
wall clock 4.945 s ± 0.118 3.450 s ± 0.117 1.43× faster
user time 7.534 s 6.877 s −8.7%
system time 12.927 s 8.495 s −34%

Phase breakdown:

phase main this branch
process_files_with_cache 3.817 s 1.862 s
directory walk 1.387 s 1.408 s
autoload-path glob 1.043 s 1.021 s
run checkers 0.565 s 0.578 s

An earlier batch on a busier machine measured the same pair at 1.30×. Both are valid single-batch A/B runs; the ratio moves with load because the phases this doesn't touch are a larger share of the total when the machine is quiet.

The system-time drop is the stable signal, and it's the mechanism: this phase is syscall-bound, not CPU-bound. It removes 51,513 file opens and full reads per run. An earlier attempt to speed the same phase up by parsing JSON faster (from_readerfrom_slice) changed nothing measurable, which is what pointed at the syscalls.

Coarse filesystems are detected, not assumed away

Trusting (mtime, len) is only sound where the filesystem timestamps finely enough to notice a write. At one-second granularity — some Docker bind mounts on macOS, NFS, SMB, FAT — a same-length edit inside the same second keeps both fields, and a stat-only check would serve the stale entry.

Rather than probe the filesystem or assume a platform, SourceStat::of reads the value it already has: a non-zero sub-second component proves the filesystem tracks sub-second time, so an edit at any other instant would have moved the mtime. A zero component means it can't tell us, so the stat is discarded and the digest carries the entry.

fine.rb      mtime_ns=1787343439414352315  sub-second=414352315  -> trust
coarse.rb    mtime_ns=1787000000000000000  sub-second=        0  -> use digest

No new branches were needed at the call sites — None already meant "no usable stat, use the digest". Both ways of being wrong fail safe:

outcome
Coarse filesystem nothing trusted → fast path never engages. Correct, just not faster.
Fine filesystem, mtime exactly on a second boundary 1-in-10⁹ → one extra hash. Measured across 20,003 real files: zero occurrences.

Cost of the check: 1.00 ± 0.02 against the same branch without it — unmeasurable.

I did consider git's "racily clean" rule (record when the entry was written; re-hash anything whose mtime falls in a granularity window of it). It doesn't transfer cleanly — git's index timestamp comes from the same filesystem as the files, whereas our write time would come from a fine-grained clock and the mtime from a coarse one, comparing incommensurable units. The sub-second check is cheaper and self-calibrating.

The hole that remains

The check proves the filesystem would have moved the mtime. It can't prove nobody moved it back. Tools that preserve timestamps — rsync -t, tar -p, cp -p, unzip, some backup and container-image flows — can install different content carrying an mtime from elsewhere; if both that mtime and the length match what was cached, the fast path serves a stale entry.

Reaching it needs the replacement to match in mtime and byte length, which in practice means restoring a near-identical copy of what was already there. It also isn't specific to this design — make, ccache and every other mtime-driven cache share it, which is why they all document touch as the way to force a rebuild. Closing it would mean always hashing, i.e. giving up the entire win.

Documented on SourceStat rather than left implicit, along with the escape hatches that already exist: --no-cache and pks delete-cache.

packwerk cache compatibility

This adds a key to the cache JSON, so I treated "does this break packwerk?" as blocking. It doesn't. Checked against packwerk 3.3.0:

fed to packwerk 3.3.0's deserializer result
packwerk's own format
packwerk's format + source_stat
what pks writes today NoMethodError
  1. Its deserializer ignores unknown keys. Cache::CacheContents.deserialize uses plain hash access and never enumerates keys — there's no T::Struct.from_hash, which would have been strict.
  2. They don't share a directory. packwerk reads tmp/cache/packwerk/<md5>; pks writes tmp/cache/packwerk/zeitwerk/<md5>. On the app I measured: 51,708 files under zeitwerk/, and the only top-level file is constant_resolver.json, also pks's.
  3. The formats were never interchangeable — row 3 above. pks nests unresolved_references under processed_file and names the fields differently. That predates this PR.

So test_compatible_with_packwerk doesn't test compatibility with packwerk; it round-trips pks's own format. Left alone — renaming it is a separate change — but it shouldn't be read as a guarantee.

Belt and braces regardless: source_stat is #[serde(default, skip_serializing_if = "Option::is_none")], so an entry without it still deserializes and is still honored via the digest.

Note

Pre-existing interaction, not caused by this PR: packwerk's bust_cache! does FileUtils.rm_rf(tmp/cache/packwerk), which deletes pks's zeitwerk/ subdirectory too. Running packwerk after a packwerk.yml or inflections change forces a full pks cold rebuild. Performance only, not correctness.

Failure modes closed by construction

  • A cache entry cannot exist without a digest. CacheLookup holds what is knowable without opening the file (cache path, stat); read_contents consumes it to produce the EmptyCacheEntry that write takes. So write needs no digest check — the only way to hold its argument is to have read the file. An entry carrying a placeholder digest would never match, making that file permanently uncacheable and silently slow; there is no state in which one can be built.
  • The in-place repair warns on failure instead of discarding the error. The result stays correct either way, but a persistent failure (unwritable cache dir, full disk) would otherwise leave every run re-hashing with no clue why.
  • That repair only fires when there's a stat worth recording. Without the guard, a filesystem yielding None every run would never match and would rewrite the entire cache every time — turning a read-mostly cache into a full rewrite of itself.

Tests

tests/cache_stat_fastpath_test.rs, ten cases.

Before this branch, no test in the repo exercised a warm cache at all — every fixture ships cache: false. These paths weren't under-tested, they were untested.

The fast path: stats are recorded · warm output matches cold · an edit invalidates · a same-length edit invalidates (the case a length-only check would miss) · a whole-second mtime is not trusted.

Fallback and repair: a stat-less packwerk-style entry is honored then upgraded in place · a stale stat with a matching digest is repaired · a malformed source_stat (five shapes: not an object, wrong type, missing field, null, negative) degrades to the digest without panicking.

Other commands: pks update on a warm cache — the highest-consequence path, since update writes package_todo.yml, so a stale entry persists a wrong answer rather than printing one · the experimental parser, which uses tmp/cache/packwerk/experimental but shares this implementation, so a regression confined there would be invisible.

Uses common::Fixture from #57 rather than a local copy helper.

Each test was verified to fail

A test never seen failing isn't yet evidence of anything. Injecting a bug that makes the cache always hit fails 7 of the 10:

test test_update_is_correct_on_a_warm_cache ... FAILED
test test_edited_file_invalidates_cache ... FAILED
test test_stale_stat_with_matching_digest_is_repaired ... FAILED
test test_experimental_parser_uses_the_fast_path_correctly ... FAILED
test test_entry_without_stat_is_still_valid ... FAILED
test test_same_length_edit_invalidates_cache ... FAILED
test test_whole_second_mtime_is_not_trusted ... FAILED

The granularity guard and the repair path were separately confirmed to fail with their own checks removed.

Review follow-ups

  • write's digest precondition was a runtime check. Now structural — see "Failure modes closed by construction" above. populate_digest/digest() are gone along with the error path, so the suggested test for that path has nothing left to assert.
  • The mtime test shelled out to touch. Replaced with File::set_modified (stable since 1.75, toolchain is 1.97.1) — no dev-dependency, no external binary, works on Windows.
  • Both mutation checks re-verified after the refactor: removing the sub-second guard still fails test_whole_second_mtime_is_not_trusted; forcing the fast path to always hit still fails the same 7 of 10.

Verification

  • cargo test268 passing (258 + 10 new)
  • cargo clippy --all-targets --all-features and cargo fmt --all -- --check — clean
  • cargo doc --no-deps — clean
  • On the 51k-file application: check and check --no-cache produce identical output, and so do this branch and main
  • Across the 30 fixture apps with a packwerk.yml: 29 byte-identical. The 30th is app_with_monkey_patches, which trips the pre-existing nondeterministic duplicate-constant panic in both binaries — the unmodified binary names a different constant run to run.

🤖 Generated with Claude Code

`process_files_with_cache` is the largest phase of `pks check`, and half of it is
work we can avoid: for every file we open it, read it in full, and MD5 it, purely
to compare a digest against the cache entry.

Record (mtime_ns, len) alongside the digest and settle the common case -- nothing
changed since the last run -- with one `stat`. The digest remains the authority:
if no stat is recorded, or the stat moved, we fall back to reading and hashing
exactly as before. An entry whose contents match but whose stat moved (a git
checkout, a `touch`) is repaired in place so the next run takes the fast path.

MEASURED on a 51,513-file application, A/B against main in one hyperfine run:

  main             4.945s +/- 0.118
  this branch      3.450s +/- 0.117    1.43x faster

  user time        7.534s -> 6.877s
  system time     12.927s -> 8.495s    (-34%)

An earlier batch on a busier machine measured the same pair at 1.30x. Both are
valid single-batch comparisons; the ratio moves with load because the phases this
does not touch are a larger share when the machine is quiet. The system-time drop
is the stable signal, and it is the mechanism: this phase is syscall-bound, not
CPU-bound. An earlier attempt to speed the same phase up by parsing JSON faster
(from_reader -> from_slice) changed nothing measurable, which is what pointed at
the syscalls.

## Coarse filesystems are detected, not assumed away

Trusting (mtime, len) is only sound where the filesystem timestamps finely enough
to notice a write. At one-second granularity -- some Docker bind mounts on macOS,
NFS, SMB, FAT -- a same-length edit inside the same second keeps both fields, and
a stat-only check would serve the stale entry.

Rather than probe or assume a platform, `SourceStat::of` reads the value it
already has: a non-zero sub-second component proves the filesystem tracks
sub-second time, so an edit at any other instant would have moved the mtime. A
zero component means it cannot tell us, so the stat is discarded and the digest
carries the entry. This needed no new branches at the call sites -- `None`
already meant "no usable stat" -- and both ways of being wrong fail safe:

- Coarse filesystem: nothing is trusted, the fast path never engages. Correct,
  just not faster.
- Fine filesystem, mtime landing exactly on a second boundary: a 1-in-10^9
  coincidence costing one extra hash. Measured across 20,003 files of a real
  Rails application: zero occurrences.

Cost of the check itself: 1.00x +/- 0.02 against the same branch without it.

It narrows rather than closes the window, and the type's docs say so. A
millisecond-granularity filesystem is trusted, so two same-length writes inside
one millisecond would still be missed -- six orders of magnitude tighter, and
needing machine-speed edits to reach. Also documented: mtimes that are copied
rather than set by writing (rsync -t, tar -p, cp -p) can carry a timestamp from
elsewhere; every mtime-driven cache shares that hole, which is why they all
document `touch` as the way to force a rebuild.

## packwerk compatibility

Verified against packwerk 3.3.0, and the concern turned out to be misplaced:

- Its `Cache::CacheContents.deserialize` uses plain hash access and never
  enumerates keys, so an unknown key is invisible to it. Ran its logic against a
  packwerk-format entry carrying `source_stat`: reads fine.
- The tools do not share a directory. packwerk reads `tmp/cache/packwerk/<md5>`;
  pks writes `tmp/cache/packwerk/zeitwerk/<md5>`.
- The formats were never interchangeable. Feeding packwerk what pks writes today
  raises `NoMethodError: undefined method 'map' for nil`. That predates this
  change, so `test_compatible_with_packwerk` does not test what its name claims;
  it round-trips pks's own format. Left alone, but it is not a guarantee.

Regardless, `source_stat` is `#[serde(default, skip_serializing_if)]`, so an
entry without one still deserializes and is still honored via the digest.

## Failure modes closed by construction

- `EmptyCacheEntry` holds `Option<String>` rather than an empty string meaning
  "not computed", private behind `digest()`. `write` errors instead of persisting
  a placeholder, which would have produced an entry that never matches -- making
  that file permanently uncacheable and silently slow.
- The in-place repair warns on failure rather than discarding the error. The
  result stays correct either way, but a persistent failure (unwritable cache
  dir, full disk) would otherwise leave every run re-hashing with no clue why.
- That repair only fires when there is a stat worth recording. Without the guard,
  a filesystem yielding `None` every run would never match and would rewrite the
  entire cache every time.

## Tests

tests/cache_stat_fastpath_test.rs, ten cases. Note that before this change *no
test in the repo exercised a warm cache at all* -- every fixture ships
`cache: false` -- so these paths were untested rather than under-tested.

The fast path: stats are recorded; warm output matches cold; an edit invalidates;
a *same-length* edit invalidates (the case a length-only check would miss); a
whole-second mtime is not trusted.

Fallback and repair: a stat-less packwerk-style entry is honored then upgraded; a
stale stat with a matching digest is repaired in place; a malformed `source_stat`
(five shapes) degrades to the digest without panicking.

Other commands: `pks update` on a warm cache -- the highest-consequence path,
since update *writes* package_todo.yml and a stale entry persists a wrong answer
rather than printing one -- and the experimental parser, which uses a different
cache subdirectory but shares this implementation.

Each was verified to fail rather than assumed to pass: injecting a bug that makes
the cache always hit fails 7 of the 10, and the granularity and repair guards
were separately confirmed to fail with their own checks removed.

Uses `common::Fixture` from #57 rather than a local copy helper.

Verified: `check` and `check --no-cache` produce identical output on the 51k-file
application, as do this branch and main. Across the 30 fixture apps with a
packwerk.yml, 29 are byte-identical; the 30th is app_with_monkey_patches, which
trips the pre-existing nondeterministic duplicate-constant panic in both binaries.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a stat-based “fast path” for per-file cache validation to avoid hashing unchanged files, and pins the expected behaviors with integration tests.

Changes:

  • Introduces SourceStat and defers content-digest computation until needed.
  • Updates PerFileCache::get to prefer stat equality and fall back to digest validation.
  • Adds a comprehensive integration test suite covering invalidation, coarse mtimes, malformed cache entries, and update behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
tests/cache_stat_fastpath_test.rs New integration tests that exercise the stat fast path and its safety fallbacks.
src/packs/caching/per_file_cache.rs Implements the stat fast path and cache-entry refresh behavior; persists source_stat.
src/packs/caching/mod.rs Adds SourceStat and refactors EmptyCacheEntry to lazily compute the content digest.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/cache_stat_fastpath_test.rs Outdated
Comment thread src/packs/caching/per_file_cache.rs Outdated
Comment thread src/packs/caching/per_file_cache.rs Outdated
Three review comments on #59.

**`write`'s digest precondition was enforced at runtime.** `EmptyCacheEntry`
held an `Option<String>` digest, and `write` errored if it was absent -- a
contract a caller could still violate. Split the type instead: `CacheLookup`
holds what is knowable without reading the file (cache path, stat), and
`read_contents` consumes it to produce an `EmptyCacheEntry`, which now carries a
plain `String`. `EmptyCacheEntry` is only reachable by reading the file, so
`write` cannot be handed one without a digest and needs no check.

This also removes the error path a second comment asked for a test on. There is
no longer a state to test: `populate_digest` and `digest()` are gone, and the
invalid entry cannot be constructed.

**The mtime test shelled out to `touch`.** Replaced with
`File::set_modified`, stable since 1.75 and well under the 1.97.1 toolchain, so
no dev-dependency and no reliance on an external binary.

Re-verified both mutation checks after the refactor: removing the sub-second
guard still fails `test_whole_second_mtime_is_not_trusted`, and forcing the fast
path to always hit still fails the same 7 of 10. `cargo test` 268 passing,
clippy/fmt/doc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@perryqh
perryqh marked this pull request as ready for review August 26, 2026 16:01
@perryqh
perryqh requested a review from a team as a code owner August 26, 2026 16:01
@perryqh
perryqh requested a lite review from Copilot August 26, 2026 16:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/packs/caching/per_file_cache.rs:140

  • source_stat is deserialized as Option<SourceStat>, so any malformed source_stat value (wrong shape/type) will cause deserialization of the entire CacheEntry to fail and the entry to be treated as corrupt. That forces a full reparse/rehash even when file_contents_digest and processed_file are still valid. If the intent is to “degrade to digest” when source_stat is malformed, consider a tolerant deserialize_with that treats invalid values as None while still loading the rest of the entry.
    /// Absent in entries written by packwerk, or by versions of pks before the
    /// stat fast path existed. `serde(default)` keeps those entries readable;
    /// they simply fall back to comparing the content digest.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_stat: Option<SourceStat>,

Comment on lines +272 to 276
let lookup = CacheLookup::new(
&cache_path,
&PathBuf::from(
"tests/fixtures/simple_app/packs/foo/app/services/foo/bar.rb",
),
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

2 participants