Skip to content

Store the result cache in a framed serialize() format instead of a var_export'd PHP file - #5982

Open
SanderMuller wants to merge 2 commits into
phpstan:2.2.xfrom
SanderMuller:result-cache-serialize
Open

Store the result cache in a framed serialize() format instead of a var_export'd PHP file#5982
SanderMuller wants to merge 2 commits into
phpstan:2.2.xfrom
SanderMuller:result-cache-serialize

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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 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 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 exportedNodes alone, 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, 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

Re-measured on current 2.2.x in 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.

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, 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:

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, 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:

  • 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 error formats.

Both directions cost one cold run, same as any release that bumps the cache version.

unserialize() is used without an allowed_classes list: 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

  • Output byte-identical to base: 3267 raw error lines, cold and warm, on the first project. Cache use confirmed separately, since identical output is also what a cache that is never restored would produce.
  • 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 during container construction for local environment reasons unrelated to the cache.
  • Full test suite (21323 tests), self-analysis and coding standard clean.

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).

@ondrejmirtes

Copy link
Copy Markdown
Member

I just merged #5981, please try out latest 2.2.x-dev on real-world projects. Please note this needs bleeding edge enabled.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

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. restore() still includes the var_export'd file, which retains the file's compiled op_arrays and interned strings for the process lifetime. unserialize() produces only the values, so that retention goes away. That read-side saving is independent of the streaming change (which only touched save()): the warm-run main-process peak drop I measured (about -21% on a large doctrine/symfony project, similar on a large Laravel one) is entirely this effect and still applies on 2.2.4.

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.

@staabm

staabm commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

#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

@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from ee5f26c to 80d339d Compare July 4, 2026 06:56
@SanderMuller

Copy link
Copy Markdown
Contributor Author

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 serialize(). So it doesn't introduce the multi-file behaviour that reverted #5981, and setups that persist just the single result cache file keep working unchanged. I've rebased it onto current 2.2.x, so it's no longer conflicting.

The motivation is the read side. On current 2.2.x restore() still does $data = require $cacheFilePath;, and requiring a multi-megabyte PHP file keeps its compiled op_arrays and interned strings resident for the whole process; unserialize() produces just the values, so that retention goes away.

The trade-off against 2.2.4's streaming save: serialize() builds the whole string in memory before writing, giving up the streaming that keeps the save-side peak low. Whether that nets out positive on the overall peak depends on whether the analysis peak or the save peak dominates for a given project, so I don't want to lean on my earlier figure (it was measured against the pre-streaming baseline). I'm happy to run a fresh before/after on a large project against current 2.2.x so there's a real number to decide on, or to close this if the format is settled for now. Whichever you prefer.

@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from 80d339d to c13bc67 Compare July 4, 2026 20:06
@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from c13bc67 to a8151ff Compare August 12, 2026 20:06
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Rebased onto current 2.2.x — it had drifted 334 commits behind, so the CI signal on it was meaningless. Applied cleanly, no conflicts, diff unchanged at +44/-94 in one file.

Re-verified rather than assuming the rebase was inert:

  • Full suite green (21306), self-analysis clean, phpcs clean.
  • Real cold/warm cycle on a scratch project: the cache is written as <?php return; ?>a:11:{...} and the warm run reuses it (--fail-without-result-cache exits 0).
  • The downgrade guard the SERIALIZED_FILE_PREFIX comment claims actually holds: includeing the cache file from an older PHPStan returns NULL and echoes 0 bytes, so a downgrade degrades to a silent full analysis rather than dumping megabytes to stdout.

The perf numbers in the description are from early July and I have not re-measured them on current 2.2.x; say the word if you want a fresh set before reviewing.

Heads-up on an overlap: #6190 also rewrites paths inside ResultCacheManager. Whichever lands first, the other needs a rebase — happy to sequence them however you prefer.

@staabm

staabm commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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.
@SanderMuller
SanderMuller force-pushed the result-cache-serialize branch from a8151ff to c27d505 Compare August 19, 2026 08:47
@SanderMuller SanderMuller changed the title Store the result cache with serialize() instead of a var_export'd PHP file Store the result cache in a framed serialize() format instead of a var_export'd PHP file Aug 19, 2026
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>
Comment on lines +3 to +11
// 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);
}

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.

is this a bug/case independent of the format change and needs a separate PR?

@SanderMuller

Copy link
Copy Markdown
Contributor Author

@staabm update, and a defect I found while re-checking it.

On the remeasure gate

php/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 var_export() 2.5x-4.6x faster (4.18x on a flat 2000-element array, 2.51x on a 140 KB string needing escapes throughout). That is the base side's save path.

This PR's pitch is the read side - includeing the cache file retains its compiled op_arrays and interned strings for the process lifetime, unserialize() produces only the values - which 23189 does not touch. So it can move the CPU column for base's save, not the memory column that is the reason for the change. It also targets master, so PHP 8.6 at the earliest, while PHPStan runs on 7.4-8.5 today.

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 fixed

Re-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 null/false; is_array($data) then passed and the first missing section surfaced far from the cause:

[TypeError] ResultCacheManager::isMetaDifferent(): Argument #1 ($cachedMeta)
must be of type array, false given, called in ResultCacheManager.php on line 289

Base degrades gracefully for the same corruption, because an incomplete var_export'd file is a ParseError that restore() already catches:

Result cache not used because an error occurred while loading the cache file: Unclosed '(' on line 111

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, Result cache not used because an error occurred while loading the cache file: Expected a 47693 byte frame, read 4935 bytes. and continue with a full analysis, while an intact cache still restores.

e2e/result-cache-truncated pins it: with the fix the second run exits 0, without it exit 1 with the TypeError above.

Re-verified rather than assumed, on current 2.2.x

  • Upgrade path: an old var_export cache read by this branch is discarded as corrupted, then a full analysis.
  • Downgrade path: this branch's cache read by 2.2.x is discarded as corrupted with 0 serialized bytes reaching stdout, so the <?php return; ?> guard does what its comment claims.
  • Output identical between base and this branch, cold and warm - 102 error lines each on a src/Rules + src/Type/Php run, sorted diff clean, and the warm run confirmed as restoring rather than reanalysing.
  • Full suite 21323 tests, self-analysis clean, phpcs clean on the touched file.

What I did not do

I 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 TomasVotruba/ecs zipball, the same failure I see on an unrelated PR of mine, so not attributable here. A fresh run is in flight for the fix commit.

The #6190 overlap still stands - whichever of the two lands first, the other needs a rebase.

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Followed up on the one open item - here is the fresh measurement, on current 2.2.x and on this branch as it stands.

Setup

A vendor tree built only from public packages, so you can rebuild it: symfony/console, symfony/http-kernel, symfony/dependency-injection, symfony/framework-bundle, symfony/validator, symfony/serializer, symfony/form, symfony/messenger, symfony/mailer (all ^6.4), doctrine/orm ^2.20, doctrine/dbal ^3.9, doctrine/collections ^2.2, twig/twig ^3, monolog/monolog ^3, guzzlehttp/guzzle ^7 - 3862 PHP files, analysed at level 5 with 4 worker processes, 4819 reported errors. Base 9e4cf9d53, this branch de278ebb3. macOS arm64, PHP 8.5.8, 3 interleaved rounds, memory_get_peak_usage(true) of the main process as reported by -vvv. Every figure below repeated identically in all three rounds.

Warm - the case this PR is about

base this branch
peak, pure restore 393.1 MB 208.0 MB -47.1%
peak, OS maxrss 413.9 MB 236.1 MB -43.0%
peak, restore + 1 changed file 393.1 MB 242.0 MB -38.4%
CPU (user), pure restore 1.01 s 0.76 s -24.8%
wall, pure restore 1.29 s 1.03 s -20.2%
wall, restore + 1 changed file 3.03 s 2.77 s -8.6%

The maxrss row is there because a drop in memory_get_peak_usage() is not worth much on its own - the OS-level number moves with it, so this is really less memory and not just less allocator bookkeeping.

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

base this branch
peak 2232.3 MB 2211.8 MB -0.9%
CPU (user) 153.50 s 153.31 s -0.1%
wall 43.75 s 43.71 s -0.1%

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 gate

I instrumented the save phase on both sides:

save phase share of a ~44 s cold run
base, var_export streamed 0.152 s 0.35%
this branch, framed serialize 0.091 s 0.21%

23189 makes var_export 2.5x-4.6x faster, so at best it removes something like 0.1 s from a 44 s run, on PHP 8.6 and later only. It cannot move either column that decides this PR. I would not hold the decision for it - though I am still happy to re-run this table against a patched build if you want it on the record.

Numbers were taken with the machine otherwise idle for this work; the three-round repetition is there because it is a shared machine.

@SanderMuller
SanderMuller requested a review from staabm August 19, 2026 14:34
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.

3 participants