Skip to content

Skip rewriting an unchanged result cache - #6432

Open
zonuexe wants to merge 3 commits into
phpstan:2.2.xfrom
zonuexe:prototype-skip-unchanged-result-cache
Open

Skip rewriting an unchanged result cache#6432
zonuexe wants to merge 3 commits into
phpstan:2.2.xfrom
zonuexe:prototype-skip-unchanged-result-cache

Conversation

@zonuexe

@zonuexe zonuexe commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

This avoids rewriting the result cache after a warm analysis when no files need to be analysed and the cache contents are unchanged.

The cache write is skipped only when:

  • the restored cache metadata and non-analysed dependency files are unchanged;
  • the analysis is not a full analysis and has no files to analyse;
  • errors, locally ignored errors, ignore lines, collected data, package dependencies, exported nodes, project extension files, and stub files are unchanged;
  • the result cache file still exists.

Any detected change continues through the existing atomic-save path.

The atomic-save E2E test now verifies both behaviours: an unchanged warm run preserves the existing cache file, while a run after a source change atomically replaces it.

Motivation

This follows the profiling reported in this discussion comment.

On a fully warm run of the profiled project, no files required analysis, but PHPStan still rewrote a 146 MB result cache. SPX attributed approximately 1.1 seconds of traced process time to ResultCacheManager::process() and save().

Benchmark

PHPStan 2.2.14, PHP 8.5.10, warm result cache, no files analysed, six measured runs per variant:

Stock This change
Wall-clock median 3.62 s 2.955 s
Improvement 0.665 s (18.4%)

All outputs were byte-identical. The cache modification time remained unchanged in all six runs with this change.

Tests

vendor/bin/phpunit --group exec tests/PHPStan/Command/ResultCacheInfoCommandTest.php
php -d memory_limit=450M bin/phpstan analyse src/Analyser/ResultCache/ResultCacheManager.php tests/PHPStan/Command/ResultCacheInfoCommandTest.php --no-progress
  • PHPUnit: 6 tests, 48 assertions
  • PHPStan: no errors
  • The atomic-save E2E scenario was also run locally

This implementation and its benchmark were prepared with Cursor Agent (GPT-5.6 Sol), including adversarial reviews by Grok and GPT-5.6 Sol Medium.

zonuexe and others added 2 commits September 13, 2026 15:58
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

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

Do we work with the file modification timestamp somewhere?

(Not writing the file would make the file age faster)

@zonuexe

zonuexe commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

No. Cache expiration is based on lastFullAnalysisTime stored inside the result cache, not on the cache file modification time. Skipping the rewrite therefore does not change when the periodic full analysis runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zonuexe
zonuexe marked this pull request as ready for review September 13, 2026 08:45
@staabm

staabm commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Nice - I can reproduce a ~10% faster finish on warm runs.

@SanderMuller please review

@SanderMuller

Copy link
Copy Markdown
Contributor

Reviewed 6a243c4 against 395468a. The last fixup only adds two bashunit assertions; src/ and tests/ are byte-identical to the previous head, which is where I ran the timings.

Does it keep the cache correct? I built a probe project and ran a mutation matrix on both branches. Analysed files: warm, body edit, new file, deleted leaf file, deleted file with dependents, config change, touch-only. Scanned files: body edit, symbol removed, used method renamed, deleted, restored. Every real change rewrites the cache on the PR. Three runs skip it, and in each of those nothing changed. A repeat warm run, a touch with the same content, and a file restored to the content the cache already holds. I decoded both caches at every step, normalising lastFullAnalysisTime and phpstanVersion. The two branches produce the same cache content at every step, and the same errors.

I also ran the 93 scenarios of the result-cache-e2e-tests matrix locally on both branches. 14 fail here, and the same 14 fail on the base. I did not apply the PHPStanErrorPatch.patch the CI job applies first, which accounts for some of them. The base fails two more, result-cache-atomic-save and result-cache-scanned-exported-nodes, which are the two the PR adds assertions to. So the e2e additions fail without the fix. The unit test does too, because the base reports the cache as saved where the test expects the new message.

Gate on the head: make phpstan no errors, full suite 21419 tests green, phpcs clean on both touched files. No changed class carries #[ShadowedByTurboExtension].

Performance: confirmed, and a bit larger than staabm's number on my corpus. Warm A/B on a 4524-file project with a 75 MB result cache, one warmed cache per arm, three interleaved rounds. Wall medians 1.74s base against 1.55s, CPU 1.42s against 1.24s, so -11% and -13%. 3/3 rounds with no overlap in the spreads. The cache file's mtime stayed put across the PR arm's timed runs and moved on every base run. The warm error output is identical, 3265 errors on both.

On @staabm's mtime question. For PHPStan itself the answer above is right, and I traced it. The periodic full analysis compares time() - $data['lastFullAnalysisTime'] against skipResultCacheIfOlderThanDays, reading the timestamp out of the cache. Nothing in src/ stats the cache file. There is a second reading though. systemd-tmpfiles and the macOS periodic scripts reap /tmp on mtime. A cache that warm runs no longer touch can now be reaped under a daily-analysed project. Before, every run kept it young. The cost is one full analysis, so this is a trade-off to accept or not, rather than a defect. Your call.

CI. Mutation Testing on 8.3 and 8.4 also fails on #6430 and #6425 from the same day, so that is the base. Octoscan I cannot pin on the base. It passed on the previous head b0d397f, which carried the same workflow edit, and on the 14 other lint-workflows runs since 2026-09-12. The Run octoscan step itself succeeded; what failed is Upload SARIF file to GitHub, 30 seconds after "Uploading results" and with no error text. That reads like a transient upload rather than a finding about the workflow change, but a re-run would settle it.

I am not the maintainer, so the merge call is yours.

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