Memoize file contents by path in CachedParser to skip redundant reads#5928
Memoize file contents by path in CachedParser to skip redundant reads#5928SanderMuller wants to merge 1 commit into
Conversation
|
How to reproduce/measure the performance improvement? |
|
Here's the A/B I used to measure it. Swap only # 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: 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:
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 |
4fd4ed1 to
78d7299
Compare
|
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 Changes since your question:
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). |
78d7299 to
cbf5fd4
Compare
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>
cbf5fd4 to
1922b3f
Compare
| clearstatcache(true, $file); | ||
| $mtime = @filemtime($file); | ||
| $size = @filesize($file); | ||
| if ($mtime === false || $size === false) { | ||
| return FileReader::read($file); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
CachedParser::parseFile()reads the whole file viaFileReader::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,613parseFile()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 byfilemtime(), so it costs no extra syscall. A same-second, same-length edit is the remaining undetectable case, pinned in a test.Effect (cold, single process, interleaved runs on the Laravel project): sys time 7.3 s -> 5.4 s (-26%, every run with the change below every run without). Total CPU and wall are within noise, so this is a syscall/IO reduction, not a latency win. Output is byte-identical. Memory: at most 512 KB per process (the earlier revision of this PR held contents unbounded, about +4 MB; that is gone).
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.