Skip to content

Use LruCache for the other three least-recently-used caches - #6240

Open
SanderMuller wants to merge 2 commits into
phpstan:2.2.xfrom
SanderMuller:lrucache-adopt-existing-caches
Open

Use LruCache for the other three least-recently-used caches#6240
SanderMuller wants to merge 2 commits into
phpstan:2.2.xfrom
SanderMuller:lrucache-adopt-existing-caches

Conversation

@SanderMuller

Copy link
Copy Markdown
Contributor

Follow-up to #5928, which introduced LruCache for the parser's file-contents memo. staabm asked in #5928 (comment) whether other places could use it - three could, and here they are.

site what it was doing
PhpClassReflectionExtension::touchMemberCacheKey() an order map over four member caches; set() returning the evicted keys is what lets all four drop the same key
FileTypeMapper::$memoryCache touch-on-hit plus a count-bounded eviction loop and a manual $memoryCacheCount, which count() replaces
UsefulTypeAliasResolver::$resolvedLocalTypeAliases the same touch-on-hit and count bound

Two deliberate non-changes:

  • FileTypeMapper::$resolvedPhpDocBlockCache is left alone. It does not touch entries on a hit, so despite sitting next to an LRU it evicts in insertion order. Moving it would change its eviction policy, which wants measuring on its own rather than riding along in a refactor.
  • nameScopeMapMemoryCacheCountMax: 0 keeps its odd meaning. In the other three caches 0 means "no limit"; here the eviction loop ran before the insertion, so 0 emptied the cache and put a single entry back - a one-entry cache. The constructor maps it to new LruCache(1) so nobody who set that parameter to 0 gets an unbounded cache instead. Happy to normalise it to "no limit" in a separate PR if you would rather the four behaved alike; it is a behaviour change, so I did not fold it in.

Verification:

  • Full suite 21334 tests, self-analysis clean, phpcs clean.
  • Same errors reported before and after on an identical analysis (102 error lines over src/Rules + src/Type/Php, sorted diff clean).
  • Same cache footprint, which is the part output equality cannot show: 1168 name-scope map misses, 2 local type alias misses and 910 member-cache evictions, identical on both sides, counted with probes at the miss and eviction sites.

No new tests: these are behaviour-preserving conversions of existing caches, and LruCache itself has LruCacheTest from #5928.

phpstan#5928 introduced LruCache for the parser's file-contents memo. Three more caches
were doing the same bookkeeping by hand, so they now use it as well:

- PhpClassReflectionExtension's shared member-cache order, where set() returning
  the evicted keys is what lets the four member maps drop the same key
- FileTypeMapper's name-scope map memo, whose manual entry counter count() replaces
- UsefulTypeAliasResolver's resolved local type aliases

FileTypeMapper's cache treated a configured maximum of 0 as "keep one entry"
rather than "no limit" as the others do, because its eviction loop ran before the
insertion. That is preserved as-is; normalising it would change behaviour for
anyone who set the parameter to 0.

FileTypeMapper's resolvedPhpDocBlockCache is deliberately left alone: it does not
touch entries on a hit, so it evicts in insertion order rather than by use, and
moving it to LruCache would change its eviction policy rather than just its shape.

Behaviour unchanged on an identical analysis: same errors reported, and the same
cache footprint - 1168 name-scope map misses, 2 local type alias misses and 910
member-cache evictions before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ondrejmirtes

Copy link
Copy Markdown
Member

Please provide time/memory measurements. This is only worth it if it leads to improvements.

@ondrejmirtes

Copy link
Copy Markdown
Member

Of course the LruCache refacoring is nice but should be separste from introducing the cache to new places.

The four limits are only read where the LruCache is constructed, so holding them
as promoted properties keeps per-instance state nobody reads and suggests the
classes consult them later. Plain constructor parameters instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Double-checked this before it gets reviewed, and it earned two additions.

Eviction is now actually exercised. The footprint numbers in the description came from a run with the shipped limits, where the type-alias cache only ever held two entries - so its eviction path was never touched. Re-ran the differential with the limits turned down, and with them at 0:

limits errors name-scope misses member evictions
ftm=3, alias=1, member=2 2.2.x 43 207 1381
this branch 43 207 1381
ftm=0, alias=0, member=0 2.2.x 43 237 0
this branch 43 237 0

The second row is the one that matters for nameScopeMapMemoryCacheCountMax: 0: 237 misses on both sides. Had I passed the 0 straight through as "no limit", this branch would have shown far fewer misses, so that mapping is measured rather than argued. The type-alias eviction I forced separately on a fixture of 12 classes with local @phpstan-type aliases at resolvedLocalTypeAliasesCountMax: 1 - 12 misses and identical output on both sides.

Second commit: the limits are no longer properties. All four - including CachedParser::$cachedSourceBytesMax from #5928 - are read only where the LruCache is constructed, so keeping them promoted left per-instance state nobody reads and implied the classes consult them later. They are plain constructor parameters now.

Also checked and found nothing: no reference to the replaced properties survives anywhere in src/ or tests/, and the bounds are equivalent rather than off by one - the old code inserted and then evicted while over the limit, LruCache evicts before inserting, and both settle at exactly the configured maximum.

CI: the three reds are the two Symplify integration jobs (composer 404 on the TomasVotruba/ecs zipball) and the Benchmark job, whose only failing case is bug-11283.php, already failing on 2.2.x itself - it showed +136% there earlier today against +72% here. Full suite 21334, self-analysis and phpcs clean.

@SanderMuller

SanderMuller commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

To be clear on the intent: this adds no caching and was not aimed at performance. It re-uses the LruCache from #5928 in the three places that were hand-rolling the same touch-and-evict logic, which is what @staabm asked for in #5928 (comment). Four hand-rolled LRUs become one with a unit test, net -23 lines.

Measurements anyway, base 8e60b1adc against bc70f8e53, symfony 6.4 + doctrine vendor tree (3862 files, level 5), 5 interleaved rounds, medians:

base this branch
cold peak 2211.8 MB 2211.8 MB 0.00%
cold CPU (user) 160.61 s 159.63 s -0.61%
warm peak 393.1 MB 393.1 MB 0.00%

The time deltas sit inside spreads several times their size, so read it as unchanged - no gain, no regression. The hit/miss footprint is identical too (1168 name-scope map misses, 910 member-cache evictions on both sides), which is the point: same caches, same behaviour, less code.

If that cleanup is not worth carrying the abstraction, close it and I will not argue - the class stays in from #5928 either way.

Two side notes. The second commit (the limits stop being properties, since they are only read where the cache is constructed) is a separate concern - happy to split it out. And FileTypeMapper::$resolvedPhpDocBlockCache, which I left alone because it evicts in insertion order rather than by use, is the one place where switching to LruCache does change something measurable: PHPDoc resolutions 12480 -> 12217 (-2.1%) on that corpus, identical output. Separate PR if you want it.

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