Skip to content

Cut recomputation in loop convergence and two on-demand re-walks - #6765

Merged
ondrejmirtes merged 8 commits into
2.3.xfrom
recompute-cuts
Oct 20, 2026
Merged

ondrejmirtes merged 8 commits into
2.3.xfrom
recompute-cuts

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Five changes that cut what the engine recomputes. Each is mirrored in the turbo twins, and the analysis output is identical.

  • Later loop convergence passes re-walk only changed statements (foreach, then while / do-while / for). Each statement is compared with the previous pass's entry state. A statement that doesn't mention a variable whose state changed replays its recorded emissions and carries its recorded effect onto the current scope. Variables holding a closure that captures a changed variable by reference count as mentions of it.
  • The final walk replays what the last pass stands in for. Bodies holding a nested loop were walked in full by the final walk. Now only the statements whose walk differs at top level (the nested loops) are walked again; the rest replay through the real callback.
  • InvalidBinaryOperationRule stops re-pricing operations. It walked a synthetic copy of every binary operation and compound assignment, about half of all on-demand walks. When the checked operand types are the walked ones, the operation's stored type answers the question.
  • The post-foreach $it === [] || is_object($it) narrowing is composed through BooleanNarrowingHelper::specifyDisjunction(), not walked as a synthetic || with an is_object() call resolution. Equivalence was checked before switching: equal scopes for every case in self-analysis and the test suite, none falling back to the walk. The new ForeachEmptyOrObjectNarrowingHelper has a native twin.

Benchmark: full parallel self-analysis of phpstan-src, turbo on, ShipMonk DCD disabled locally in both legs, 4 ABBA pairs against 2.3.x e4f45f9b3e:

2.3.x This PR Difference
User CPU 52.39 s 51.08 s −2.5% (faster 4/4)
Wall 8.01 s 7.88 s −1.7% (faster 4/4)
Peak RSS 328 MB 328 MB same

Verification:

  • The full suite passes with turbo and without (22,539 tests).
  • Walk-trace is identical (664,923 lines).
  • Smoke, signature parity and clang-tidy pass.
  • The shared core is identical across PHP 8.3–8.6 NTS and ZTS.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X

ondrejmirtes and others added 8 commits October 20, 2026 08:55
Each convergence pass after the first compares every statement's entry
state with the previous pass's. A statement that mentions no variable
whose state differs has its recorded emissions replayed and its recorded
effect carried onto the current scope; once the entry state equals the
previous pass's, the rest of that pass stands. Variables holding a
closure that captures a differing variable by reference count as
mentions of it, since invoking the closure reads the variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
The convergence loops of while, do-while and for now go through
processLoopPassStmtNodes() like foreach: a later pass replays the
statements whose inputs did not change since the previous pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
A loop body holding a construct that walks differently at top level
than in a convergence pass (a nested loop runs its own convergence only
in the final walk) was walked in full by the final walk, although only
those statements differ. The passes of such bodies now record their
emissions too, and the final walk runs against the last pass like a
later pass does: a statement mentioning no variable whose state differs
from the pass's entry replays the pass's emissions through the real
callback, and statements holding such a construct are walked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
InvalidBinaryOperationRule priced every binary operation and compound
assignment a second time: it cloned the node with both operands replaced
by TypeExpr of the types to check and walked that synthetic node on
demand - three walks per operation, half of all on-demand walks in
self-analysis. When the types to check are the operand types the walk
used, the operation's own stored type answers the same question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
After a foreach that may not iterate, the entry scope narrowed by
`$iteratee === [] || is_object($iteratee)` merges into the exit scope.
The condition was built as a synthetic BooleanOr and walked on demand,
an is_object() call resolution included - 16K on-demand walks in
self-analysis. ForeachEmptyOrObjectNarrowingHelper composes the same
narrowing through BooleanNarrowingHelper::specifyDisjunction(): the
comparison from the iteratee's result, is_object() from its conditional
return type. The walk remains the fallback when a side cannot be
composed.

The helper runs once per loop exit and stays PHP; the native
ForeachHandler calls it.

Before switching, both were computed and compared for every narrowing
in self-analysis and the test suite: equal scopes throughout, none
falling back to the walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
The helper ForeachHandler calls after a loop that may not iterate gets
its native twin: narrowToEmptyOrObject() builds the disjunction and
composes its narrowing through the direct entries of
IdenticalNarrowingHelper, DefaultNarrowingHelper, ParametersAcceptor
selection and BooleanNarrowingHelper, with the twin's closures as
native closures over the same captures. Covered by walk-trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
@ondrejmirtes
ondrejmirtes merged commit c615841 into 2.3.x Oct 20, 2026
538 of 539 checks passed
@ondrejmirtes
ondrejmirtes deleted the recompute-cuts branch October 20, 2026 07:42
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