Skip to content

Leave the function-call stack out of the closure type cache key - #6997

Open
gnutix wants to merge 1 commit into
phpstan:2.3.xfrom
gnutix:closure-cache-key-call-stack
Open

gnutix wants to merge 1 commit into
phpstan:2.3.xfrom
gnutix:closure-cache-key-call-stack

Conversation

@gnutix

@gnutix gnutix commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

ClosureTypeResolver caches a closure's type per closure node, under a key built from the scope's free-variable slice (MutatingScope::getClosureScopeCacheKey()) and the parameter types it is walked with (closureContextCacheKey()). The scope part also included the function-call stack: its depth and every frame's parameter type.

That key part dates from fd31156 (2024). There, the stack was the only part of the key that stood for how the closure's parameters were typed. Since then, closureContextCacheKey() has keyed on the callable parameter types themselves, and the stack no longer adds anything:

  • The body is walked on a scope with an empty stack: enterAnonymousFunctionWithoutReflection() and enterArrowFunctionWithoutReflection() create it with []. So nothing inside the body reads the outer stack.
  • The only other reader is buildParametersAndAcceptors(). When the caller passes no parameter types, it takes the innermost frame's parameter type as the callable the closure is passed to. What that yields is the callable parameters, which the key already has.

So the stack only made the same closure, typed the same way, miss the cache when it was asked from different places: from inside the call it is passed to (its argument's walk) and from outside it (the call's return type, rules asking for the argument). Each miss is another walk of the body.

This change leaves the stack out of the key. One distinction the stack still made is kept explicitly: a /free marker for a closure typed with no context at all ($contextFree, where the inferred signature is used). Without it, such a closure could share a key with one its caller typed with the same parameter types.

Effect

Closure body walks (closureTypeBodyWalks, PHPSTAN_TEMPLATE_CLAMP_STATS), with output identical before and after:

Analysis Before After
self-analysis of src/ 565 275 (−51%)
tests/PHPStan/Analyser/nsrt/ 360 289 (−20%)

Wall time of the self-analysis did not move measurably: those 290 walks are a small share of it, and the machine was shared, so run-to-run spread (up to ±20%) was larger than any effect.

#6996 (array_reduce() carry) walks each callback once per fixed-point pass, and its return type extension asks for the same passes as its parameter closure type extension, from outside the call. With this change those asks hit the cache: 11 → 7 body walks per array_reduce() call, and 36 → 15 for a reduce nested in a reduce.

On a file of 150 functions with four array_reduce() closures each (user CPU, 4 alternating pairs, without Turbo), #6996 alone takes 14.53 s and #6996 with this change 10.80 s (−26%).

Test

AnalyserIntegrationTest::testClosureInGenericCallIsWalkedOncePerTyping() analyses a closure and an arrow function passed to a generic function, and expects 2 body walks. It fails without the change (4 walks) and passes with it.

Turbo

MutatingScope and ClosureTypeResolver are shadowed, so the same change is in turbo-ext/src/MutatingScope.cpp (getClosureScopeCacheKey()) and turbo-ext/src/ClosureTypeResolver.cpp (closureContextCacheKey() and its three callers), with generated/ClosureTypeResolver.h regenerated.

  • make build-turbo, tests/smoke.php: ALL OK. tests/signature-parity.php: OK. bin/side-by-side.php: all classes paired.
  • The full suite passes with the extension loaded and without it: 23,564 tests, PHP 8.5.12. The new test passes with both too.
  • --error-format=raw output is byte-identical with the extension on and off, for the self-analysis of src/ and for nsrt/.

🤖 Generated with Claude Code

A closure's body is walked on a scope with an empty function-call stack,
and what the stack contributes to the closure's type when the caller
passes no parameter types - the innermost parameter's callable type -
is already in the key as the callable parameters. Keying on the stack
as well made the same closure, typed the same way, miss the cache when
it was asked from inside the call it is passed to and again from
outside it, as an argument's own walk and the call's return type do.

The contextFree flag takes the stack's remaining job: it tells apart a
closure typed with no context at all from one typed by its caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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