Store the result cache in a framed serialize() format instead of a var_export'd PHP file - #5982
Store the result cache in a framed serialize() format instead of a var_export'd PHP file#5982SanderMuller wants to merge 2 commits into
Conversation
|
I just merged #5981, please try out latest 2.2.x-dev on real-world projects. Please note this needs bleeding edge enabled. |
|
2.2.4's streaming save (ee9fe9e) addresses the save-side half of this: the peak from building the whole var_export string in memory is gone. This PR overlaps that half, so it needs rebasing, and the two approaches are mutually exclusive (the read format has to match the write format). Where they differ is the read side. So this is really a format choice: keep var_export + include (streamed on write), or switch to serialize + unserialize (lower read-side retention, no streaming needed on write since there is no giant string to build). It is your call which direction you prefer for this file, especially given you are actively working on it. If you want to pursue the serialize direction I will rebase onto 2.2.4 and re-measure both sides on current code; if you would rather keep the var_export format, I will close this. The transition either way is safe without a cache-version bump (old and new formats each fall through the existing corrupted-cache path), and the downgrade case is handled by prefixing the payload so an older PHPStan does not echo it. |
|
#5981 was reverted meanwhile, as it did not improve much and having more than 1 cache file might make trouble in exisiting setups which do not persist the whole temp-folder but just the single result cache file we have today |
ee5f26c to
80d339d
Compare
|
Thanks for the heads-up on #5981. Worth clarifying how this PR relates, since it's a different change: it keeps the single result cache file exactly as today, and only changes that one file's format from a var_export'd PHP file to The motivation is the read side. On current 2.2.x The trade-off against 2.2.4's streaming save: |
80d339d to
c13bc67
Compare
c13bc67 to
a8151ff
Compare
|
Rebased onto current Re-verified rather than assuming the rebase was inert:
The perf numbers in the description are from early July and I have not re-measured them on current Heads-up on an overlap: #6190 also rewrites paths inside |
|
after php/php-src#23189 landed, we should remeasure |
…r_export'd PHP file The result cache is written as a var_export'd PHP file and hydrated with `include`. Including a multi-megabyte PHP source has a hidden cost: its compiled op_arrays and interned strings stay retained for the process lifetime. This switches the file to serialize()/unserialize(), which produces only the values. The `errorsCallback`/`collectedDataCallback`/`exportedNodesCallback` closures existed to embed object graphs in the PHP file; `restore()` invoked all of them unconditionally right after the include, so plain entries are equivalent. The file is written frame by frame, and the array sections entry by entry, mirroring what the writer this replaces already did. That is not incidental: serializing the payload in one call holds the whole cache in memory twice over, and on the first project below that is 53 MB, 39 MB of it exportedNodes alone, so per-section streaming would not be enough either. The reading side is framed for the same reason, so restoring never holds the file and the values at once. Format: `<?php return; ?>` on the first line, then `name length` or `name* count` headers, each followed by length-prefixed serialized payloads. Each array entry is serialized as a single-element array so its key travels with it, which keeps string and integer keys distinct. ## Memory `memory_get_peak_usage(true)` on the main process, two projects, interleaved A/B. Every figure repeated across rounds and identical each time. | | | cold peak | warm peak | cache on disk | | --- | --- | --- | --- | --- | | doctrine/symfony, 4520 files | base | 154.5 MB | 320.1 MB | 39.6 MB | | | this change | 126.5 MB | 178.0 MB | 52.8 MB | | | | -18.1% | -44.4% | +33% | | Laravel-ecosystem vendor tree, 7701 files | base | 278.0 MB | 670.5 MB | 104.2 MB | | | this change | 219.0 MB | 330.0 MB | 132.3 MB | | | | -21.2% | -50.8% | +27% | A warm run that reanalyses a changed file peaks the same as a pure restore (178.0 MB against base's 320.1 MB on the first project), because the peak is the cache restore rather than the reanalysis. ## CPU Unchanged where the analysis dominates, cheaper where the cache does. First project, best of three: | | cold wall | cold CPU | warm wall | warm CPU | | --- | --- | --- | --- | --- | | base | 20.05s | 127.73s | 1.76s | 1.50s | | this change | 20.18s | 127.70s | 1.49s | 1.36s | The per-entry framing adds roughly 4500 serialize() calls on save and is invisible against a 128s analysis; the warm restore gets faster. ## Format transition No cache version bump is needed, and both directions were exercised with real builds: - upgrade: an old-format PHP file has no framed header, so it is discarded exactly like a corrupted cache file today (verbose notice unchanged, then a full analysis). - downgrade: the payload sits after `<?php return; ?>`, so an older PHPStan including the new file returns null immediately. Verified on a 55 MB cache file: it returns NULL and echoes 0 bytes to stdout. Without the prefix `include` would print the whole payload as inline text and wreck CI logs and machine-readable formats. ## Verification - Output byte-identical to base: 3267 raw error lines, cold and warm, on the first project. - All 22 result-cache e2e scenarios from the workflow run locally: 20 pass. The two that do not, result-cache-5 and result-cache-restore-without-reflection, fail identically on unmodified base; the latter fails in container construction for environment reasons unrelated to the cache. - Full test suite (21323 tests), self-analysis and coding standard clean. Measured on one machine (macOS, arm64). Retained-memory behaviour can differ on Linux, so the CI run is worth reading rather than assuming these ratios carry over.
a8151ff to
c27d505
Compare
A cache file that is this format but truncated - which is what a process killed during the save leaves behind, since the file is streamed to its final path - was read as far as it went and the missing frames returned as null. `is_array()` on the assembled array then passed, and the first missing section surfaced as a TypeError from isMetaDifferent() far away from the cause. Every format violation now throws, so restore() discards the file and analyses everything, which is what the var_export format did by way of a ParseError from the include. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // A process killed while the result cache is being written leaves a partial file at the final | ||
| // path, because the cache is streamed there rather than written atomically. Cutting the file | ||
| // inside the first section's payload is the shape that used to be read as a half-populated | ||
| // cache instead of a damaged one. | ||
| $file = __DIR__ . '/tmp/resultCache.php'; | ||
| $contents = file_get_contents($file); | ||
| if ($contents === false) { | ||
| throw new RuntimeException('No result cache at ' . $file); | ||
| } |
There was a problem hiding this comment.
is this a bug/case independent of the format change and needs a separate PR?
|
@staabm update, and a defect I found while re-checking it. On the remeasure gatephp/php-src#23189 is still open, so there is nothing to remeasure against yet. Worth knowing how much it can move this decision, though: I built and measured that PR while reviewing it, and it makes This PR's pitch is the read side - I am happy to remeasure whenever you want, either when it lands or against a locally patched build. Say which and I will run it. A defect, now fixedRe-checking the format handling turned up a real regression against base. The cache file is streamed to its final path, so a process killed during the save leaves a truncated file. With the framed reader, a truncation inside a value frame was read as far as it went and the missing frames came back as Base degrades gracefully for the same corruption, because an incomplete Every format violation now throws, so the file lands in that same handler. Eight truncation points from 100 bytes to 99.5% of the file all report, for example,
Re-verified rather than assumed, on current 2.2.x
What I did not doI have not re-run the memory and CPU table in the description; those figures predate today's rebase. I deliberately took no timings today because this machine is heavily contended and the numbers would not be worth publishing. That is the one open item, and it is the same one 23189 would want a rerun for anyway. CI note: the two red Symplify integration jobs before my push failed on a composer 404 for the The #6190 overlap still stands - whichever of the two lands first, the other needs a rebase. |
|
Followed up on the one open item - here is the fresh measurement, on current SetupA vendor tree built only from public packages, so you can rebuild it: Warm - the case this PR is about
The Note base's peak is the same 393.1 MB whether it reanalyses a file or not: on base the restore is the peak. On this branch it is not, which is why the changed-file figure sits above the pure-restore one. Cold - and a correction to the description
The description's -18% / -21% cold-peak claim does not reproduce here. On this corpus the cold peak is analysis-dominated (2.2 GB against a 60 MB cache), so the save is a rounding error and cold comes out flat. Those older figures came from projects where the cold peak was much closer to the cache size, so the honest version of that claim is "corpus-dependent, and flat where analysis dominates". I would rather say that here than leave a number in the description that does not hold generally. The warm figures, which are the point of the change, hold and are slightly better than what is written there. Cache on disk: 63,013,832 -> 77,148,189 bytes, +22.4%. Output identical: 4819 error lines on both, sorted diff clean, both warm runs confirmed as restoring rather than reanalysing. What this means for the php-src#23189 gateI instrumented the save phase on both sides:
23189 makes Numbers were taken with the machine otherwise idle for this work; the three-round repetition is there because it is a shared machine. |
What
The result cache is written as a var_export'd PHP file and hydrated with
include. Including a multi-megabyte PHP source has a hidden cost: its compiled op_arrays and interned strings stay retained for the process lifetime. This switches the file toserialize()/unserialize(), which produces only the values.The
errorsCallback/collectedDataCallback/exportedNodesCallbackclosures existed to embed object graphs in the PHP file;restore()invoked all of them unconditionally right after the include, so plain entries are equivalent.The file is written frame by frame, and the array sections entry by entry, mirroring what the writer this replaces already did. That part is load-bearing rather than incidental: serializing the payload in one call holds the whole cache in memory twice over, and on the first project below that is 53 MB, 39 MB of it
exportedNodesalone, so streaming per section would not be enough either. The reading side is framed for the same reason, so restoring never holds the file and the values at once.Format:
<?php return; ?>on the first line, thenname lengthorname* countheaders, each followed by length-prefixed serialized payloads. Each array entry is serialized as a single-element array so its key travels with it, which keeps string and integer keys distinct.Memory
Re-measured on current
2.2.xin this comment, on a corpus of public packages. The warm figures below hold (-47% peak there). The cold figures do not generalise: where analysis dominates the peak rather than the cache, cold comes out flat (-0.9%), so read the cold column below as specific to these two projects.memory_get_peak_usage(true)on the main process, two projects, interleaved A/B. Every figure repeated across rounds and identical each time.A warm run that reanalyses a changed file peaks the same as a pure restore (178.0 MB against base's 320.1 MB on the first project, with the run confirmed as restoring the cache and reanalysing one file), because the peak is the cache restore rather than the reanalysis.
CPU
Unchanged where the analysis dominates, cheaper where the cache does. First project, best of three:
The per-entry framing adds roughly 4500
serialize()calls on save, which is invisible against a 128s analysis, and the warm restore gets faster.Format transition
No cache version bump is needed, and both directions were exercised with real builds:
<?php return; ?>, so an older PHPStan including the new file returns null immediately. Verified on a 55 MB cache file: it returnsNULLand echoes 0 bytes to stdout. Without the prefixincludewould print the whole payload as inline text and wreck CI logs and machine-readable error formats.Both directions cost one cold run, same as any release that bumps the cache version.
unserialize()is used without anallowed_classeslist: the file is written by PHPStan itself into the project's tmpDir, and the previous format was included as executable PHP, so the trust boundary is unchanged. A hardcoded class list would risk silently discarding valid caches when the payload gains a class.Verification
result-cache-5andresult-cache-restore-without-reflection, fail identically on unmodified base; the latter fails during container construction for local environment reasons unrelated to the cache.Measured on one machine, macOS on arm64. Retained-memory behaviour can differ on Linux, so the CI run is worth reading rather than assuming these ratios carry over.
Note on the previous revision of this PR
The earlier version serialized the payload in a single call and claimed a 23% cold-peak improvement. Re-measuring showed the opposite: it regressed cold peak by 47% on the first project, because one-shot serialization discards the streaming save that the var_export writer deliberately performed. That is what the framing above fixes, and it is why the numbers here are better on both axes rather than trading one for the other.
Prior art: #5845 changed a different cache (
FileCacheStorage) with a CPU pitch and was closed; this targets the result cache file with a memory pitch, in the direction of the earlier retained-memory work (#5965, #5966, #5969).