Skip to content

Memoize file contents by path in CachedParser to skip redundant reads - #5928

Open
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:cachedparser-memoize-file-contents
Open

Memoize file contents by path in CachedParser to skip redundant reads#5928
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:cachedparser-memoize-file-contents

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

CachedParser::parseFile() reads the whole file via FileReader::read() on every call, because the contents are the key of the AST cache. The same file is parsed many times (a trait file once per class that uses it), so the read repeats even when nothing changed. Measured on current 2.2.x with an instrumented build, cold: a large Laravel project does 143,613 parseFile() reads for 6,624 distinct files, with one hot trait file read 94,007 times; a large doctrine/symfony project does 24,155 reads for 6,251 distinct files.

This memoizes the contents by path, keyed by mtime and size, and skips the re-read when the file is unchanged. Following the direction of the recent cache work (LRU eviction, source-byte caps), the memo is bounded: total memoized source is capped at 512 KB (MEMOIZED_SOURCE_BYTES_LIMIT) with least-recently-used eviction, and files larger than the cap are never memoized. Hot trait files stay resident by definition, so the bound costs little: the read counts drop to 8,444 on the Laravel project (94% fewer) and 8,266 on the doctrine/symfony one (66% fewer).

Keying by size as well as mtime catches same-second edits that change the length in long-running processes (PHPStan Pro, fixer worker); filesize() is served from the stat cache populated by filemtime(), so it costs no extra syscall. A same-second, same-length edit is the remaining undetectable case, pinned in a test.

Effect, measured on Shopware v6.7.6.2 (9205 files, 9 workers, cold result cache), phars built from 2.2.x and from this branch:

2.2.x this PR
real 155.6 s 146.3 s
user 666.5 s 613.3 s
sys 103.9 s 54.4 s
total CPU 770.4 s 667.7 s (-13.3%)
FileReader::read() calls 1,970,713 54,149
bytes read 15,269 MB 217 MB
max RSS 851 MB 782 MB

Output is byte-identical. block input operations is 0 in both runs, so none of that 15 GB reaches the disk - the page cache serves it, and what the memo removes is the syscall round trips, the copy into a fresh userspace buffer and PHP's stream/string overhead. Re-reading a warm 929-byte file costs ~13.5 us per call against ~0.8 us for the stat pair the memo replaces it with, so the cost is per call rather than per byte.

A trait-light project sees much less: the same PR is flat on a Doctrine/Symfony application, and was within noise on the Laravel project this PR was originally measured against (sys 7.3 s -> 5.4 s, total CPU unchanged).

Memory: at most 512 KB per process (the earlier revision of this PR held contents unbounded, about +4 MB; that is gone). Sweeping the cap on Shopware shows the knee is at or below 64 KB - 64 KB already captures 98.0% of the achievable read reduction and every larger cap up to unbounded lands on the same 98.3% plateau - so 512 KB is on the plateau rather than at a cliff, and is 0.06% of the run's footprint.

Tests cover: unchanged file not re-read, size change detected with unchanged mtime, newer mtime re-read, oversized files never memoized, and LRU eviction at the byte cap.

@staabm

staabm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

How to reproduce/measure the performance improvement?

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Here's the A/B I used to measure it. Swap only CachedParser.php between this branch and its parent (bad7874ec), keep the same vendor, run cold (delete the tmp/cache dir before each run) and single-process, and compare sys and total CPU (user+sys) rather than wall. Wall is overlapped by the parallel workers and is too noisy on a shared machine to read a few-percent change from.

# in the phpstan checkout, swap just the one file between the two versions:
git show <ver>:src/Parser/CachedParser.php > src/Parser/CachedParser.php   # <ver> = this branch, then bad7874ec
# from the target project, cold + single-process, 3 reps:
rm -rf <tmpDir> && /usr/bin/time php <phpstan>/bin/phpstan analyse -l 8 --no-progress <paths>

The mechanism: parseFile() is called once per class that uses a given trait (and for other shared includes), so the same file's contents get read from disk many times in a single run. The memo keys the contents by path + mtime and reads each file once.

So the win tracks how much cross-file re-read redundancy a codebase has, which makes it corpus-dependent. Single-process, cold, 3 reps, median:

  • Tempest (multi-package framework, many files pulling the same shared base classes/traits): sys 2.62s → 1.79s (-32%), total CPU 34.9s → 34.0s (-2.6%), user flat. The saving is in read syscalls, as expected; user time doesn't move because parsing itself is unchanged.
  • rector-src (~1150 files, parses each roughly once): no measurable change (sys ~1.60s either way).

So it's a real win on high-fan-in codebases and neutral on low-redundancy ones. The content memo costs about 4MB regardless of whether the project benefits. If that always-paid cost is the concern, a bounded variant (memoize only files that get read more than once) would make the memory track the benefit; happy to do that if you'd prefer it.

The red CI check is the flaky RegressionBench wall-time assertion (it fails the same way on unrelated PRs that can't affect analysis time), not a real regression.

@SanderMuller
SanderMuller force-pushed the cachedparser-memoize-file-contents branch from 4fd4ed1 to 78d7299 Compare July 2, 2026 15:42
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Reworked and re-measured on top of the current 2.2.x (the LRU and source-byte-cap changes rewrote this file under the PR, so the numbers below are fresh, from instrumented builds, cold runs).

The call volume that motivates it: a large Laravel project does 143,613 parseFile() disk reads for 6,624 distinct files, with one hot trait file read 94,007 times; a large doctrine/symfony project does 24,155 reads for 6,251 distinct files. The contents are the AST-cache key, so the read happens on every call regardless of the AST cache policy.

Changes since your question:

  • the memo is now bounded like the AST cache: 512 KB total with LRU eviction, files above the cap never memoized. The +4 MB unbounded retention from the first revision is gone. Reads still drop 94% (143,613 to 8,444) on the Laravel project and 66% on the doctrine/symfony one, since the hot trait files stay resident.
  • keyed by mtime and size instead of mtime alone, so same-second edits that change the length are caught in long-running processes; filesize() comes from the stat cache, no extra syscall.

Measured effect on the current base (cold, single process, interleaved): sys 7.3 s to 5.4 s (-26%) on the Laravel project, every run with the change below every run without; total CPU and wall within noise. So this is a bounded syscall/IO reduction in the spirit of the recent duplicate-work PRs, not a latency win. Output stays byte-identical; the full test suite passes (17,565 tests).

@SanderMuller
SanderMuller force-pushed the cachedparser-memoize-file-contents branch from 78d7299 to cbf5fd4 Compare July 4, 2026 20:16
Comment thread src/Parser/CachedParser.php
@staabm
staabm force-pushed the cachedparser-memoize-file-contents branch from cbf5fd4 to 1922b3f Compare July 8, 2026 07:02
Comment on lines +133 to +137
clearstatcache(true, $file);
$mtime = @filemtime($file);
$size = @filesize($file);
if ($mtime === false || $size === false) {
return FileReader::read($file);

@staabm staabm Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this parts adds a additional cost for the common path (namely a file which does not contain a trait and will likely only be parsed a few times gets a perf penality).

the cache itself is mostly useful for traits. maybe we can optimize this cache for the trait case, without taking a perf hit for the common path which does not involve traits (I have no idea yet how this can/should work).

running this PR on phpstan-src does not yield a meaningful improvement yet in analysis time in my testing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

in addition my testing using make phpstan somehow shows, that this path is only taken for .stub files, when adding a echo "building cache $file \n"; in CachedParser->readFile

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The ".stub only" is a parallel-mode artifact, not the real picture. make phpstan runs the analysis in worker child processes, and only the main process's stdout reaches your terminal. The main process parses almost only stubs; the source files are parsed in the workers, whose stdout the parallel runner captures rather than prints. Counting parseFile calls per process on a cold run of phpstan-src:

  • main process: 10 .php, 186 .stub
  • all processes together: 71,978 .php, 586 .stub

So source files do go through readFile (~72k times), the workers' echo just isn't shown. Run with --debug (single process) and you'll see it directly: 68,174 .php reads in the one visible process.

On the common-path cost, fair point: for a file parsed once, the clearstatcache + filemtime + filesize is pure overhead. It's easy to only start memoizing (and doing the stat) from a path's second read, so single-parse files pay nothing. Happy to do that if you think it's worth keeping.

On who benefits, to be precise: the redundancy is high wherever many classes pull the same traits/base files, so the same file gets re-parsed a lot in one cold run: phpstan-src re-reads the average file ~31x, a fresh Laravel ~40x, a doctrine/symfony app ~3x. But it's a sys/IO reduction, not a CPU/latency win. Reads are only ~0.6-3.4% of cold CPU in my measurements, so dropping ~94% of them is a real cut in sys time and syscalls on cold runs of large high-fan-in projects, but it won't move total analysis time on phpstan-src meaningfully, and it does nothing for warm runs. That's the honest scope: a bounded cold-run IO win, largest on big high-fan-in codebases.

@SanderMuller
SanderMuller force-pushed the cachedparser-memoize-file-contents branch from 1922b3f to acaf364 Compare August 12, 2026 20:03
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Rebased onto current 2.2.x — it was 314 commits behind, so its CI status was stale. Applied cleanly this time (the two-hunk conflict from the byte-cap refinements is gone), diff unchanged at +161/-1.

Gates: full suite green (21308), CachedParserTest 11 tests / 52 assertions, self-analysis clean, phpcs clean.

The measurement in the description is from early July. This one is a cold-run IO win, so it is worth re-measuring on current 2.2.x before you spend time on it — tell me and I will post a fresh interleaved set.

@staabm

staabm commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Please check whether/how much impact has this PR on analyzing the shopware codebase (which also uses traits heavily)

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Measured it on Shopware. Short answer: -12.7% CPU, and the output is byte-identical. It is the biggest win I have measured for this PR, and your hunch about traits is exactly why.

Setup

shopware/shopware at v6.7.6.2 — the same ref the integration test uses — installed with composer install, composer run framework:schema:dump and php src/Core/DevOps/StaticAnalyze/phpstan-bootstrap.php, then analysed with its own phpstan.neon.dist (9205 files under src + tests, 839 reported errors). Two phars built from upstream/2.2.x and from this branch, alternated, cold result cache every run, CPU as user+sys.

run 2.2.x this PR
round 1 650.7s 568.7s
round 2 665.1s 579.6s
extra base run 674.4s
median 657.9s 574.1s

-12.7% (1.15x), and every PR run beat every base run. JSON output is identical between the two (same sha256, 423,440 bytes, 839 file errors + 2 errors), so nothing is being skipped.

Why it is so large here — the numbers behind it

Shopware is 107 trait definitions against 2322 use SomeTrait; statements, and parseFile() runs once per class using a trait. Instrumenting FileReader::read() in both phars, summed over the 9 worker processes of one full analysis:

2.2.x this PR
FileReader::read() calls 1,970,792 54,877
bytes read from disk 15,269 MB 217 MB

So analysing a 9205-file project currently reads 15 GB off disk; with the memoization it reads 217 MB. That is 36x fewer read calls and 70x fewer bytes, which is where the 12.7% comes from.

The PR still does 54,877 reads against roughly 20,800 distinct paths, because the memo is capped at MEMOIZED_SOURCE_BYTES_LIMIT (512 KB) with LRU eviction, so hot files get re-read after eviction. Raising that cap would shrink the remainder further — I have not tried it, and the current cap is deliberately conservative about memory.

Caveats

  • PHP 8.5 locally, whereas the integration test pins 8.4 for Shopware.
  • I used Shopware's own phpstan.neon.dist rather than the e2e/integration/shopware.neon wrapper (baseline + editor links), so absolute times will not line up with CI. Both sides used the identical config, so the delta is unaffected.
  • composer install needed --no-security-blocking locally: with no composer.lock committed, resolution picks versions that now carry advisories my Composer blocks and CI's did not at its last green run. Worth knowing independently of this PR — it means the Shopware integration job is one advisory away from failing to install.

For contrast, the same PR is roughly flat on a Doctrine/Symfony application I benchmark with, so trait-heavy is indeed the distinguishing factor. If you want, I can rerun with the CI shopware.neon wrapper or on 8.4 before you decide.

@staabm
staabm force-pushed the cachedparser-memoize-file-contents branch from acaf364 to 5db34aa Compare August 15, 2026 07:35

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why does this PR improve performance?
why is the OS kernel not able to properly cache the file-reads?
why is MEMOIZED_SOURCE_BYTES_LIMIT 512 KB? how did you come up with this concrete value?

@staabm

staabm commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Total CPU and wall are within noise, so this is a syscall/IO reduction, not a latency win.[10:43 Uhr]

how can I measure the number of syscalls a PHPStan run needs on macos to reproduce the before/after PR syscall counts?

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Good questions — the third one especially, because the honest answer was "convention, not measurement". I re-measured everything on Shopware (v6.7.6.2, 9205 files, 9 workers, cold result cache) with phars built from 2.2.x and from this branch.

First, a correction to my own description: "total CPU and wall are within noise" is out of date. That was measured on a Laravel project in July. On Shopware it is not noise:

2.2.x this PR
real 155.6 s 146.3 s
user 666.5 s 613.3 s
sys 103.9 s 54.4 s
total CPU 770.4 s 667.7 s (-13.3%)
max RSS 851 MB 782 MB

I will update the description.

1. Why does it improve performance?

parseFile() is called once per class using a trait, and it reads the whole file every time because the contents are the AST cache key. On Shopware that is 1,970,713 FileReader::read() calls for ~20,374 distinct paths, or 15.3 GB read; with the memo it is 54,149 calls / 217 MB.

Per avoided read (1,916,564 of them): 25.9 µs of sys time and 27.7 µs of user time. Sys time nearly halves, which is the syscall side; the user-time half is PHP's stream layer, the zend_string allocation for each result, and copying the bytes out of the page cache.

2. Why doesn't the OS page cache make this free?

It already does the part it can — and the measurement says so directly: block input operations is 0 in both runs. Nothing touches the disk in either case; all 15.3 GB comes from the page cache.

What the page cache cannot remove is the cost of asking for it: the open/fstat/read/close round trips, the copy from kernel pages into a fresh userspace buffer, and PHP's own stream-wrapper and string allocation on top.

That cost is dominated by the per-call overhead rather than the file size. Re-reading a 929-byte file 100k times, warm:

file_get_contents()      13450 ns/op
array lookup                10 ns/op
clearstatcache + 2 stats    841 ns/op   <- what the memo pays instead

An 11.8 KB file costs 15363 ns/op — 13x the bytes for 14% more time. So it is ~13 µs of fixed cost per call whatever the file, against ~0.8 µs for the stat pair the memo replaces it with. (That 13 µs is a floor measured in a tight loop where every cache is hot; in a real run, interleaved across 20k files, the observed cost is the ~54 µs above.)

3. Why 512 KB?

Honestly: because the surrounding cache work had just introduced byte caps with LRU eviction and I matched it. So I swept it. Read counts are deterministic, so they are the clean signal; CPU across a single run at each cap is noisy and I would not read anything into differences of a few seconds.

cap FileReader::read() calls share of the achievable reduction max RSS
off 1,970,713 803 MB
64 KB 58,828 98.0% 828 MB
256 KB 54,986 98.3% 816 MB
512 KB 54,149 98.3% 828 MB
1 MB 53,881 98.3% 788 MB
4 MB 52,842 98.3% 865 MB
unbounded 52,601 98.3% 864 MB

So the knee is at or below 64 KB, and everything from there up is the same plateau — 512 KB is comfortably on it but there is nothing special about that number. It is 0.06% of the ~800 MB the run uses anyway, so the cap is not what bounds memory here. I am happy to drop it to 64 KB (same result, smaller promise) or raise it; tell me which you prefer and I will change it and re-measure.

One thing the sweep exposed that I did not expect: even unbounded, reads stay at ~2.6x the distinct-path count (52,601 vs 20,374 summed over the workers). So something re-reads files beyond the memo's reach — most likely more than one CachedParser instance per process, each with its own memo. That is a separate potential win and not something this PR addresses.

4. Measuring syscalls on macOS

The two I actually used here, neither needing root:

  • /usr/bin/time -lsys time is the aggregate syscall cost, and block input operations tells you whether anything reached the disk (0 = all page cache). This is where the 103.9 s -> 54.4 s number comes from.
  • Counting at the PHP level — a static counter in FileReader::read() plus register_shutdown_function appending to a file, so each of the 9 workers reports its own count. Exact, portable, and it attributes the reads to the call site rather than to the process.

For real syscall counts there is sudo dtruss -c -f -- php ... (aggregate counts per syscall) or sudo fs_usage -w -f filesys -p <pid>. I could not verify either on this machine — SIP is enabled and I do not have passwordless sudo here — so I would rather not hand you a command I have not run. If you want those numbers I can get them and post the before/after.

CachedParser::parseFile() read the whole file via FileReader::read() on every
call, before the content-keyed node cache. The same file is parsed many times
(a trait file once per class that uses it - on Tempest, 73 498 parseFile calls
for 2 327 distinct files, 96.8% redundant reads; the 256-entry content cache
thrashes on hot traits), so the read is repeated even when nothing changed.

Memoize the contents by path, keyed by mtime, and skip the re-read when the
file is unchanged. clearstatcache() before the mtime check keeps this correct
in long-running processes (PHPStan Pro, fixer worker) where a file may be
edited between calls, so an edited file is always re-read and re-parsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@staabm
staabm force-pushed the cachedparser-memoize-file-contents branch from 5db34aa to e68c94f Compare August 15, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants