Skip to content

LRU-cap the per-class reflection registries and resolved local type aliases#5988

Open
ondrejmirtes wants to merge 6 commits into
2.2.xfrom
class-registry-lru
Open

LRU-cap the per-class reflection registries and resolved local type aliases#5988
ondrejmirtes wants to merge 6 commits into
2.2.xfrom
class-registry-lru

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Jul 3, 2026

Copy link
Copy Markdown
Member

Combines the two LRU PRs into one (per review preference; #5989 is closed in favour of this):

1. LRU-cap the per-class reflection registries

MemoizingReflectionProvider::$classes, BetterReflectionProvider::$classReflections and MemoizingReflector::$classReflections/$functionReflections grow with every distinct class/function touched (9k+ classes per worker on a large project) and are the canonical pins of the reflection object graphs — bounding downstream caches (e.g. #5986) frees little while these registries still hold every ClassReflection. Negative (null) lookup entries participate in the caps too. Configurable via cache.reflectionRegistryCountMax (default 2048, 0 disables).

2. LRU-cap resolved local type aliases

UsefulTypeAliasResolver::$resolvedLocalTypeAliases caches every resolved @phpstan-type local alias forever; resolved alias Types transitively pin ClassReflections and whole BetterReflection trees — measured as the single largest type-level pin. Configurable via cache.resolvedLocalTypeAliasesCountMax (default 2048, 0 disables).

Defaults from a cap sweep

2,358-file project, all LRU caps set to the sweep value, single process, error output identical (776) at every point:

cap elapsed end usage
512 101 s 936.5 MB
1024 93 s 912.5 MB
2048 88 s 880.5 MB
4096 84 s 894.5 MB
8192 92 s 930.6 MB
unlimited 98 s 930.5 MB

The curve is U-shaped: too-small caps are actively harmful (evicted entries are recreated as fresh object graphs while old copies are still pinned by sibling caches — duplicates accumulate), too-large caps converge to unbounded retention. Elapsed shows no trend across the range — the knob trades memory only.

Evicted entries are recomputed on demand; a fresh ClassReflection instance for the same class can exist after eviction — PHPStan compares class reflections by name, not identity, and error output is byte-identical with eviction active (89/776 errors, two configs). PHPStanTestCase passes 0 (unlimited) to keep test behavior bit-exact.

Full prototype series on the measured project (with #5986): −76 MB end usage / −74 MB peak (−8%) single-process.

Tests: ClassReflectionTest, LocalTypeAliasesRuleTest, CallMethodsRuleTest pass; phpcs clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF

ondrejmirtes and others added 3 commits July 3, 2026 21:51
MemoizingReflectionProvider::$classes, BetterReflectionProvider::$classReflections
and MemoizingReflector::$classReflections/$functionReflections grow with every
distinct class/function touched during the run (9k+ classes per worker on a
large project) and are the canonical pins of the reflection object graphs:
bounding downstream caches (member reflections, dependency lists) frees little
while these registries still hold every ClassReflection.

Cap each at 2048 entries with least-recently-used eviction (move-to-end on
hit, evict-first on insert). Negative (null) lookup entries participate in the
cap as well, so repeated misses cannot grow the maps unboundedly either.
Evicted entries are recomputed on the next request — for the providers this
means a fresh ClassReflection instance for the same class may be created
after eviction; PHPStan compares class reflections by name, not identity,
and error output on a 2,358-file project is byte-identical with eviction
active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF
Default 2048, 0 disables eviction. The default comes from a cap sweep on a
2,358-file project (all LRU caps set to the sweep value, single process,
error output identical at every point):

  cap        elapsed   end usage
  512        101 s     936.5 MB
  1024        93 s     912.5 MB
  2048        88 s     880.5 MB
  4096        84 s     894.5 MB
  8192        92 s     930.6 MB
  unlimited   98 s     930.5 MB

The curve is U-shaped: too-small caps are actively harmful (evicted entries
are recreated as new object graphs while the old ones are still pinned by
sibling caches, so duplicates accumulate), too-large caps converge to
unbounded retention. Elapsed time shows no trend across the whole range —
the knob trades memory only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF
Every resolved @phpstan-type local alias is cached forever, keyed by
Class::alias. Resolved alias Types transitively pin ClassReflections
(ObjectType::$classReflection, EnumCaseObjectType::$classReflection, ...) and
through them whole BetterReflection trees, so on alias-heavy codebases this
cache quietly retains a large share of the reflection universe. Retention-path
instrumentation on a large project identified it as the single largest
type-level pin: bounding it freed 34 MB end-of-run memory at 2,358 analysed
files — more than any reflection-side cache — with byte-identical error
output and no measurable time cost (interleaved A/B, 100 s vs 95 s).

Cap the cache at 2048 entries with least-recently-used eviction; evicted
aliases are re-resolved on demand. Global aliases are unaffected (bounded by
config).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF
@ondrejmirtes ondrejmirtes force-pushed the class-registry-lru branch 2 times, most recently from 76200ea to bf2f8f9 Compare July 3, 2026 21:05
ondrejmirtes and others added 2 commits July 3, 2026 23:29
Default 2048, 0 disables eviction. Chosen from a cap sweep on a 2,358-file
project (all LRU caps set to the sweep value, error output identical at every
point): memory is U-shaped in the cap — 936.5 MB at 512, 880.5 MB at 2048,
930.5 MB uncapped — because too-small caps recreate evicted object graphs as
duplicates while the old ones are still pinned by sibling caches, and
too-large caps converge to unbounded retention. Elapsed time shows no trend
across the whole range (84-101 s noise band).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF
@ondrejmirtes ondrejmirtes changed the title LRU-cap the per-class reflection registries LRU-cap the per-class reflection registries and resolved local type aliases Jul 3, 2026
…t stubBetterReflectionProvider definition

The derived container defines the provider as an explicit neon service, which
does not go through the attribute-based autowiring that supplies the parameter
to the main container's service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF
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.

1 participant