Skip to content

Make closure signature and static variable inference cheaper, fix benchmark regressions - #6623

Merged
ondrejmirtes merged 9 commits into
2.3.xfrom
closure-inference-perf
Sep 28, 2026
Merged

ondrejmirtes merged 9 commits into
2.3.xfrom
closure-inference-perf

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Speeds up the two bleeding-edge features added in the last days, closureSignaturesFromUsages and staticVariablesFromUsages. On Slevomat they cost about +5% user CPU over the toggles being off.

Commits

  • Collect the markers of an array literal or union once for all its items. 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.
  • Do not build the value type of an array sent to plain mixed. Returning a large constant array from an : array function built the union of all its values, only to observe nothing in observeSend() and let everything escape in observeClosureSend().
  • Walk a by-ref closure's body once per state it is invoked from. Each invocation of a closure with followed use (&$x) walked its body again. A data provider calling $room(...) 362 times walked it 1,297 times.
    • The walks are kept in the walk's ExpressionResultStorage, keyed by the template argument frame, until-fixpoint, native types promoted, and the entered scope (equals()).
  • Replay the last walk inferring static variables as the second pass. Pass 2 walked the statements after static $x again with the converged types, which the last convergence walk had just used. The Enum trait's static $cache methods did this for every enum.
    • The replay only happens when the body observed no template argument or closure signature (empty constraints) and no closure or class follows the first static statement.

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*.xml committed on 2026-08-09, 1,033 commits ago. It fails the same way on every recent PR. The two biggest regressions were bisected, with bin/phpstan analyse -l 8 <file> --debug per-file times:

  • bug-12671 (0.88 s → 2.1 s): e606ca20e3 "Merge unions of constant arrays with many keys instead of degrading" removed the 62-key cap. Two fixes:
    • Skip keys-superset checks of arrays differing in constant scalar values. A pair differing in two single constant scalars (or in one, when the key counts differ) can't pass isKeysSupersetOf() in either direction. Previously this was 654K calls, none of which merged.
    • Intersect finite benevolent unions by identity too. A benevolent union of ~890 constant strings narrowed by a falsy check was distributed member by member.
  • bug-15061 (0.84 s → 1.78 s): 77527df087 "Narrow falsey isset() on an optional constant-array offset". Fix:
    • Answer the isset() conjunction fold's left operand once per ask. 26 isset subjects re-folded the conjunction chain 700 times.

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:

file 2026-08-09 baseline commit 2.3.x this PR
bug-12671 0.81 s 1.90 s 1.28 s
bug-15061 0.77 s 1.53 s 1.04 s
bug-14462, bug-7581, bug-10538, bug-14972-concat unchanged (per-commit check, within noise)

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):

user CPU t pairs faster
first two commits −0.14% −0.6 4/6
all four −1.01% −4.5 6/6
2.3.x with both toggles off −5.35% −19.9 6/6

Per file (turbo): LimitedCreditsCalculatorTest 4.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_map per-element callback typing, plus the static-variable convergence walks themselves. Both are the features working as designed.

Verification

  • Full suite green with PHP and with turbo loaded, re-run after the benchmark commits.
  • turbo-ext/tests/walk-trace.php identical (647,845 lines).
  • Smoke, side-by-side and signature-parity pass.
  • Slevomat reports identical errors across all legs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y5roddukgqaSAB2BT765UX

ondrejmirtes and others added 9 commits September 28, 2026 10:39
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 ondrejmirtes changed the title Make closure signature and static variable inference cheaper Make closure signature and static variable inference cheaper, fix benchmark regressions Sep 28, 2026
@ondrejmirtes
ondrejmirtes merged commit 7c94037 into 2.3.x Sep 28, 2026
895 of 909 checks passed
@ondrejmirtes
ondrejmirtes deleted the closure-inference-perf branch September 28, 2026 12:57
liamduckett pushed a commit to liamduckett/phpstan-src that referenced this pull request Sep 28, 2026
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