Skip to content

Replay the recorded fixpoint pass instead of repeating the final loop walk - #6249

Merged
ondrejmirtes merged 3 commits into
2.2.xfrom
loop-convergence
Aug 23, 2026
Merged

ondrejmirtes merged 3 commits into
2.2.xfrom
loop-convergence

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Second extraction chunk from the single-pass analyser branch (follow-up to #6248).

Loop convergence walks the body repeatedly until the entry scope reaches a fixpoint, then walks it once more with the real node callback. When the final walk's entry scope equals the last pass's entry, that final walk reproduces the pass exactly — walking is deterministic in the entry scope. This PR records the last pass's (node, scope) emissions and its expression-result storage, and when the equality holds, replays the recording through the real callback instead of walking again.

  • RecordingNodeCallback records raw walk scopes — recording pays no callback-scope construction and no storage binding (short-circuited in callNodeCallback); pairs are wrapped via toNodeCallbackScope() only at replay time.
  • Replay sites: while (cond + body), do-while (body only — its condition walks stay real), non-unrolled foreach, and closure by-ref convergence.
  • isReplayableConvergenceBody() excludes bodies with context-sensitive constructs (nested loops, labels, class-likes) where deep vs top-level analysis differs.
  • ExpressionResultStorage::mergeResults() adopts the recorded pass's stored results into the surrounding storage (PHP twin + turbo mirror, smoke-covered).

Benchmarks (user CPU, 3+3 interleaved legs, toggled within one checkout):

corpus before after Δ
self-analysis (--debug src) 77.99s 75.63s −3.0%
WordPress core (e2e corpus, level 0) 173.47s 167.97s −3.2%

Analysis output is unchanged (full test suite; turbo on/off self-analysis output byte-identical).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8

Adopts every before-scope the other storage stored, overwriting existing
entries - a finished convergence pass's stores answer for the final walk
its replay replaces. Mirrored in the native extension.
… walk

Both convergence break paths guarantee the final walk's entry scope
equals the last pass's entry, making the final walk an exact repetition
of that pass. Convergence passes now record their (node, scope) emissions
(RecordingNodeCallback); when the recorded pass's entry is the fixpoint,
the final walk is replaced by replaying the recording through the real
callback and adopting the pass's storage and statement result. The
replay binds the merged storage for its whole duration, so the recorded
scopes answer rule asks from the stored before-scopes the same way the
repeated walk's per-emission binding would.

Applies to While, Do-While, Foreach (non-unrolled finals) and the by-ref
closure convergence, whose replay runs through the gathering callback on
the pass's own entry scope - the gathering filter compares the
anonymous-function reflection by identity. A body containing constructs
that analyse differently at deep context than in the top-level final
(nested loop/label fixpoints run only at top level, statement-level
classes are skipped at deep context) is not replayed; For keeps its real
final walk (it applies inferForLoopExpressions there, which the passes
do not). Do-While replays the body only - its condition walks stay real.

(adapted from commit e0a32b7)
@ondrejmirtes

Copy link
Copy Markdown
Member Author

Some examples of what this PR skips and where it changes nothing.

The shape of the optimization

A top-level loop has always been analysed as N convergence passes (silent, NoopNodeCallback — no rules fire) followed by one more full walk with the real node callback so that rules and collectors see the body under the settled scope. That last walk re-runs all type inference over the body — method call resolution, narrowing, scope threading — purely to re-produce scopes that are state-equal to what the last silent pass already computed.

This PR records the last pass's (node, scope) emissions and, when the final walk's entry scope equals() the recorded pass's entry, replays the recording through the real callback instead of walking again. Rules fire on the same nodes with state-equal scopes; one full inference walk of the loop body disappears.

Where it fires

$total = 0;
$names = [];
foreach ($items as $item) {
    if ($item->isActive()) {
        $total += $item->getPrice();
    }
    $names[] = $item->getName();
}
  • pass 1 (silent): entry has $total = 0, $names = array{}; body walked, exit types widen.
  • pass 2 (silent): entry has the merged/generalized types (int, list<string>); body walked; exit entry-scope equals pass 2's entry → fixpoint.
  • final: previously a third full inference walk of the body with rules attached. Now: the final entry scope equals pass 2's entry, so pass 2's recording is replayed — every MethodCall resolution, += arithmetic, offset-assign inference in the body happened twice instead of three times, and the rules observed exactly what they would have.

The same applies to while (condition and body are replayed):

while ($row = $stmt->fetch()) {
    $data[] = process($row);
}

and to do-while bodies (its trailing condition walks stay real, they run on the exit scope, not the entry). Closure by-ref-use convergence uses the same mechanism:

$sum = 0;
$fn = function () use (&$sum) { $sum += $this->next(); };

The saving scales with body size × rule count: big loop bodies over rich APIs (WordPress's wp-includes loops, PHPStan's own NodeScopeResolver loops) are precisely where the redundant final walk was most expensive — hence the −3% user CPU on both corpora.

Where it deliberately does what it always did

Nested loops in the body (also labels and class-likes) — the body is not replayable:

foreach ($matrix as $row) {
    foreach ($row as $cell) {   // ← outer body excluded
        $out[] = $cell * 2;
    }
}

A nested loop is analysed differently deep vs. top-level (during the outer loop's silent passes the inner loop is walked once at deep context; in the real final walk it runs its own convergence). The recorded emissions would not match what the final walk produces, so isReplayableConvergenceBody() refuses and the final walk runs as before. Closures inside the body are fine — their statements aren't traversed inline.

Unrolled foreach over a known constant array — PHPStan analyses each iteration exactly, there is no fixpoint pass to reuse:

foreach (['a', 'b', 'c'] as $letter) { ... }

Loops that never converge cleanly — the entry-scope equality gate fails and the real final walk runs:

$x = 0;
while (cond()) {
    $x = [$x];   // ever-deepening type, generalization keeps shifting the entry
}

for loops — not a replay site at all (inferForLoopExpressions needs the real final walk).

Why the replay is safe

Walking is deterministic in the entry scope. The replay only fires when the final walk's entry scope is equals()-identical to the recorded pass's entry, so the recorded scopes are state-equal to what the repeated walk would emit — no rule, collector, or extension can observe the difference. Backing that up empirically: analysis output is byte-identical on full self-analysis and on WordPress core, with the whole test suite green.

@ondrejmirtes
ondrejmirtes merged commit 4177ab7 into 2.2.x Aug 23, 2026
752 of 761 checks passed
@ondrejmirtes
ondrejmirtes deleted the loop-convergence branch August 23, 2026 20:41
ondrejmirtes added a commit that referenced this pull request Aug 24, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes added a commit that referenced this pull request Aug 24, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes added a commit that referenced this pull request Aug 24, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes added a commit that referenced this pull request Aug 30, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes added a commit that referenced this pull request Aug 30, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes added a commit that referenced this pull request Aug 31, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes added a commit that referenced this pull request Aug 31, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes added a commit that referenced this pull request Sep 1, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
ondrejmirtes added a commit that referenced this pull request Sep 2, 2026
The branch's own fixpoint-replay commits were dropped during the rebase
over the merged extraction chunks (#6249); this restores the replay in
upstream's final form - raw-recorded pairs wrapped at replay time, the
RecordingNodeCallback short-circuit in callNodeCallback - woven into the
branch's handler shapes (ambient storage push around pass walks, the
deferred While_ statement callback, the on-demand falsey cond
re-pricing). replayRecording() binds the storage through the scope's
push/pop like every other branch-side ambient binding. The consume
mechanism (#6251, closed unmerged) is gone entirely: convergence
behavior on this branch is now byte-for-byte upstream's algorithm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
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