Repository navigation
Make closure signature and static variable inference cheaper, fix benchmark regressions - #6623
Merged
Merged
Conversation
collectAbsorbed() collected the markers of the whole result for every input carrying any, so an array literal whose items are results of stored closures (a data provider calling `$item(...)` two thousand times) traversed the whole array type once per item, at every nesting level. collectAbsorbedInto() collects the result's markers once for all inputs of a literal or of a ternary/match/`??` union. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX
Sending an array to `array` (a function's return type, a property) recursed into getIterableValueType() of the sent array - for a large constant array the union of all its values - only to observe nothing in observeSend() and to let everything escape in observeClosureSend(). Outside a call argument observeSend() skips plain mixed key and value targets, and observeClosureSend() lets the closures in the array itself escape. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX
Every invocation of a closure whose by-ref uses are followed walked its body - until the by-ref variables converge while observing - even when an earlier invocation in the same pass entered it in the same state: a data provider invoking `$room(...)` 362 times walked the body 1297 times. The walks are kept in the ExpressionResultStorage of the walk they run in, keyed by the closure, the template argument frame, whether they ran until the fixpoint, whether native types are promoted, and the entered scope. An invocation entering an equal scope takes the stored exit scope and throw points and applies them to its own scope and call, as the walk would. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX
A body with `static` variables walked the statements from the first `static` one until their types converged, and then once more in the second pass with the converged types - which the last walk had just used. The `Enum` trait's `static $cache` methods did it for every enum using it. The last walk is made with the returned types and conditional expressions on every way out of the loop. When the body observed no template argument or closure signature (its constraints are empty - nothing to resolve, so a walk that observes computes what one that does not observe computes) and no closure or class follows the first `static` statement (the second pass walks their bodies resolving their own template arguments), the second pass replays that walk's recording and takes its results instead of walking. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX
Reverts the PHP 7.4 leg of the benchmark (#6121). PHP 7.4 and 8.5 run the same code paths again since the fibers were replaced by NodeCallbackScope, so the downgraded run measured nothing the 8.5 one does not. The benchmark is back to one Baseline and one Test job on PHP 8.5 comparing against tests/bench/storage/baseline.xml, with the PHPBench attributes. The container cache change of #6121 stays, and so do the `// lint` comments on the data files, which RequiredPhpVersionCommentTest asks of them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX
Since unions of constant arrays with many keys stopped degrading (e606ca2), reduceArrays() asks isKeysSupersetOf() in both directions of every pair of arrays sharing a key - a union of n same-shaped records costs n² checks of their value types. The bench's bug-12671 made 654K of them for a few dozen unions of state tuples, none of which merged. Each array's keys holding a single constant scalar are collected once per call. Two such values that differ are disjoint, and isKeysSupersetOf() of arrays without real unsealed extras lets at most one key's values be unrelated (only when both have the same number of keys, two or more) - a pair differing in more is skipped without asking. Merged arrays drop their collected values. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX
intersect() of two unions of finite, mutually disjoint values takes their identity-keyed set intersection instead of distributing one over the other - but only for the exact UnionType class. A benevolent union of constant strings (the values of a large constant array read with an unknown key) narrowed by a falsy check was distributed member by member: 890 constant strings times the seven falsy values, then a union rebuilt from the result, for every condition of a conditional expression holder. A benevolent union takes the fast path too: it is the operand the distribution walks first, so it goes first here and the result keeps the benevolence the distribution would give it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX
A multi-subject isset() in a non-true context folds its subjects into a chain of conjunctions, each one's left operand being the conjunction folded so far. Every step and every conjunction asked it again with the same scope and context, re-folding every subject before it - 700 specifyConjunction() calls for the 26 subjects of the bench's bug-15061. The left operand of each conjunction answers each (scope, context) pair once; SpecifiedTypes are immutable, so the answer is shared. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX
ondrejmirtes
force-pushed
the
closure-inference-perf
branch
from
September 28, 2026 11:31
7504020 to
7c94037
Compare
liamduckett
pushed a commit
to liamduckett/phpstan-src
that referenced
this pull request
Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Speeds up the two bleeding-edge features added in the last days,
closureSignaturesFromUsagesandstaticVariablesFromUsages. On Slevomat they cost about +5% user CPU over the toggles being off.Commits
ArrayHandler::collectAbsorbedItems()collected the markers of the whole array type once per closure-carrying item. A data provider with 2,000$item(...)results in a nested literal became quadratic.mixed. Returning a large constant array from an: arrayfunction built the union of all its values, only to observe nothing inobserveSend()and let everything escape inobserveClosureSend().use (&$x)walked its body again. A data provider calling$room(...)362 times walked it 1,297 times.ExpressionResultStorage, keyed by the template argument frame, until-fixpoint, native types promoted, and the entered scope (equals()).static $xagain with the converged types, which the last convergence walk had just used. TheEnumtrait'sstatic $cachemethods did this for every enum.staticstatement.All four are ported to turbo-ext.
Benchmark slowdowns since the phpbench baseline (2026-08-09)
The phpbench check compares against
tests/bench/storage/baseline*.xmlcommitted on 2026-08-09, 1,033 commits ago. It fails the same way on every recent PR. The two biggest regressions were bisected, withbin/phpstan analyse -l 8 <file> --debugper-file times:e606ca20e3"Merge unions of constant arrays with many keys instead of degrading" removed the 62-key cap. Two fixes:isKeysSupersetOf()in either direction. Previously this was 654K calls, none of which merged.77527df087"Narrow falsey isset() on an optional constant-array offset". Fix:The benchmark is also back to PHP 8.5 only (Run the benchmark on PHP 8.5 only again, reverting the 7.4 leg of #6121). 7.4 and 8.5 run the same code paths again since NodeCallbackScope replaced the fibers.
Measurements
Bench files, analysis time only (hyperfine wall minus the same bootstrap in all legs), PHP mode:
The remaining difference to the baseline commit is general drift across many later commits, not one mechanism.
Slevomat, locally built phar + turbo, 6 interleaved ABBA pairs, user CPU vs. 2.3.x (dda82d9):
Per file (turbo):
LimitedCreditsCalculatorTest4.05 s → 2.56 s.TravelCalendarOccupationCalculatorTest(repro, PHP mode): 4.7 s → 2.9 s.The remaining overhead is mostly per-invocation re-typing of stored closures, multiplied by
array_mapper-element callback typing, plus the static-variable convergence walks themselves. Both are the features working as designed.Verification
turbo-ext/tests/walk-trace.phpidentical (647,845 lines).🤖 Generated with Claude Code
https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX