🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

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

Closed
ondrejmirtes wants to merge 8 commits into
2.2.xfrom
class-registry-lru
Closed

LRU-cap the per-class reflection registries and resolved local type aliases#5988
ondrejmirtes wants to merge 8 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
ondrejmirtes and others added 3 commits July 3, 2026 23:40
…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
Full-scale validation on a 25k-file project showed that evicting located
reflections from the reflector cache is unsafe: live reflections resolve
parents/traits lazily through the reflector, and BetterReflection's
circular-reference detection assumes that memoization is stable. With the
default cap the run failed with non-ignorable "Reflection error: Circular
reference to class <trait>" errors in trait-heavy class graphs, and the
constant re-location thrashed memory (17.93 GB vs 14.12 GB baseline).

Keep the reflector unbounded (upstream behavior) and keep the LRU only in the
two provider layers, which create fresh ClassReflection wrappers on re-entry
and reuse the reflector's located reflections — no re-parse, no cycle risk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF
Full-scale validation on a 25k-file project: with 10 parallel workers each
worker's class working set far exceeds any slice-tuned cap, so an evicting
registry cap thrashes (17.93 GB vs 14.12 GB baseline) while a cap large
enough to be safe never evicts and saves nothing (14.11 GB at 32768 vs 14.08
with the registry LRU disabled). The provider-layer LRU therefore ships
disabled by default as an opt-in knob for single-process / small-worker-count
usage, where the earlier measurements showed it enables eviction cascades.

resolvedLocalTypeAliasesCountMax stays at 2048: safe and neutral-to-positive
at every measured scale (the per-file access locality keeps its hot set small).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF
@ondrejmirtes

Copy link
Copy Markdown
Member Author

Full-scale validation results (25k-file project, 10 workers) — PR reworked accordingly

Ran the compiled PR phar on the whole ShipMonk backend. Baselines: pre-session 2.2.x (d62655c) = 14.40 GB; current 2.2.x (709654e, with #5984/#5985/#5986) = 14.12 GB, 68 errors.

PR phar configuration Used memory errors
original defaults (registry 2048, aliases 2048) 17.93 GB aborted: 4× non-ignorable Reflection error: Circular reference to class <trait>
registry LRU disabled, aliases 2048 14.08 GB 68 ✓
registry 32768, aliases 2048 14.11 GB 68 ✓
registry disabled, aliases 8192 + memberCacheKeysMax 16384 14.43 GB 68 ✓

Findings, now reflected in the branch:

  1. Evicting from MemoizingReflector is unsafe — live reflections resolve parents/traits lazily through the reflector, and BetterReflection's circular-reference detection assumes stable memoization; eviction produced the circular-reference errors in trait-heavy graphs. Fixed: the reflector is unbounded again; the LRU remains only in the two provider layers (fresh ClassReflection wrappers on re-entry, reflector reflections reused — no re-parse, no cycle risk).
  2. Per-worker class working sets at scale defeat slice-tuned registry caps: 2048 thrashes catastrophically (+3.8 GB — the U-curve's left side: evicted graphs recreated while old copies are pinned by sibling caches), while a safe cap (32k) never evicts and saves nothing. reflectionRegistryCountMax now defaults to 0 (opt-in) — useful for single-process/small-worker setups, off in the configuration most people run.
  3. resolvedLocalTypeAliasesCountMax stays at 2048: safe and neutral-to-positive at every measured scale (per-file locality keeps its hot set small; raising it to 8192 only increased retention). Same logic vindicates the merged memberCacheKeysMax: 2048 — raising it to 16384 cost +0.35 GB.

Net honest assessment at full scale: with the safe configuration this PR is ≈ −0.04 GB vs current 2.2.x — the alias LRU carries what value there is; the registry LRU is an opt-in knob backed by single-process measurements (−8 MB there, enables eviction cascades). Happy to split the alias part back out or close if preferred.

🤖 Generated with Claude Code

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