Skip to content

Reuse the scope factory of the other flavour - #6114

Merged
ondrejmirtes merged 1 commit into
2.2.xfrom
scope-factory-twin
Jul 27, 2026
Merged

Reuse the scope factory of the other flavour#6114
ondrejmirtes merged 1 commit into
2.2.xfrom
scope-factory-twin

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

LazyInternalScopeFactory::create() memoizes the nine services it resolves out of the container with ??=. Those memos never survived, because toFiberFactory() and toMutatingFactory() returned a freshly constructed factory on every call — and scopes switch flavour constantly (MutatingScope::toFiberScope(), FiberScope::toMutatingScope()).

Every switch therefore built a factory that re-read two services in its constructor and then resolved nine more on its first create().

Found while profiling with the turbo extension enabled (SPX, SPX_FP_LIMIT=20000) for something unrelated — the container showing up with 1.6M getByType calls is what gave it away.

Effect on a self-analysis of src/Type

before after
LazyInternalScopeFactory::__construct 192.8K 791
MemoizingContainer::getByType 1.6M 94.7K
getParameter / getService / getExtensionsCollection 192.9K each ~800 each
total userland calls 200.9M 196.7M

Benchmark

Interleaved A/B, pair order alternated (ABBA), user CPU, result cache cleared before every run, turbo extension enabled, machine otherwise idle:

slice base this branch delta pairs won paired t
src 63.38s 61.78s −2.53% 8/8 18.4
src/Type 22.20s 21.87s −1.46% 9/10 6.2

Correctness

The factory is immutable apart from those memo fields, so returning a shared instance where a new one used to be built is equivalent — the two flavours differ only in the $fiber flag. The twin is paired both ways, so a scope round-tripping between flavours reuses the original rather than allocating a third factory.

--error-format=raw output over src is unchanged, and tests/PHPStan/Analyser (2869 tests) plus tests/PHPStan/Rules/Methods, Rules/Variables, Node and Type (3819 tests) pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2

create() memoizes the services it resolves out of the container, but
toFiberFactory()/toMutatingFactory() returned a freshly constructed factory
every time, so those memos never survived a scope switching flavour — and
scopes switch constantly.

On a self-analysis of src/Type that was 192.8K factory constructions, each
re-reading two services in the constructor and then resolving nine more on
its first create(): 1.6M MemoizingContainer::getByType calls where a few
thousand suffice. Pairing the two factories brings the constructions down to
791 and getByType to 94.7K.

The pair is held one way strongly and the other way weakly. ScopeFactory
makes one of these factories per analysed file, closing over that file's
node callback, so a strong cycle between the two would keep every file's
callback — and everything it captures — alive for the whole run: PHPStan
disables the cycle collector, and nothing else would ever free them. Peak
RSS of a src self-analysis measured 678MB before, 1061MB with a strong
cycle, and 678MB with the weak back-reference.

Interleaved A/B on a quiet machine, user CPU, result cache cleared, turbo
extension enabled: -1.40% on src/Type (21.58s -> 21.28s, 6/8 pairs, paired
t=3.0); peak RSS over the same pairs is unchanged (-0.35%, t=0.3).
Analysis output is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2
@ondrejmirtes
ondrejmirtes merged commit 2999379 into 2.2.x Jul 27, 2026
737 of 742 checks passed
@ondrejmirtes
ondrejmirtes deleted the scope-factory-twin branch July 27, 2026 20:16
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