Consume unchanged effect-free subtrees during convergence passes - #6251
Open
ondrejmirtes wants to merge 1 commit into
Open
Consume unchanged effect-free subtrees during convergence passes#6251ondrejmirtes wants to merge 1 commit into
ondrejmirtes wants to merge 1 commit into
Conversation
A fixpoint re-walk (loop body convergence, backward-goto replay, by-ref closure convergence) re-walked the whole body every round. Each pass now chains the previous pass's storage as its baseline and keeps the full ExpressionResult of every expression it walks in a per-site frame: an expression whose stored result is effect-free (the walk derived no scope and carries no throw/impure points whose recorded scopes would replay stale state into catch merges) and whose read state - variables and tracked expression holders - did not change since the pass that walked it is consumed instead of re-walked. Only the loop-carried parts of the body re-walk. The final walk is untouched: it runs outside the pass mode on the outer storage. A consumption skips the subtree's recorded emissions, which would leave the fixpoint pass's recording incomplete and stop it from replacing the final walk. So every walked result also tags its emission segment [recording, start, end) and a consumption splices that segment from the pass that last walked the subtree into the consuming pass's recording - only a segment-less consumption gaps the pass and clears its replay candidacy. The spliced scopes agree with the replay on everything the consumed subtree reads - exactly what the consume gate certifies - and the chained pass storages carry the matching before-scopes. Consumption, tagging and frame stores apply only to walks carrying the pass's own storage: the old-world pricing walks that resolveType() starts for short-circuit operators re-enter processExprNode() with a fresh throwaway storage - they emit nowhere, so they may consume stored results but must not splice segments (a mispriced splice duplicated emissions in the pass recording), store results, or tag brackets. The site and pass frames live on NodeScopeResolver like the storage stack; the public processNodes()/processStmtNodes() entries suspend them, so a fresh walk an extension starts mid-analysis does not inherit the interrupted walk's convergence state. Unlike the originating branch, no recording clearing is needed: full results live only in site-scoped frames that pop when the site's convergence loop finishes, so a segment can never outlive its recording. (adapted from commits 4a25082 and 3e9d22e) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8
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.
Third extraction chunk from the single-pass analyser branch (follow-up to #6249).
Convergence passes re-walked the whole loop body every round. Now each pass chains the previous pass's storage as its baseline and keeps the full
ExpressionResultof every expression it walks in a per-site frame: an expression whose stored result is effect-free (no scope derived, no throw/impure points) and whose read state is unchanged (variables and tracked expression holders, both attribute-cached per node) is consumed instead of re-walked — only the loop-carried parts of the body re-walk.To keep #6249's replay firing on consuming passes, every walked result tags its emission segment
[recording, start, end)and a consumption splices that segment from the pass that last walked the subtree into the consuming pass's recording; only a segment-less consumption gaps the pass and clears its replay candidacy.One subtlety: the old-world pricing walks that
resolveType()starts for short-circuit operators re-enterprocessExprNode()mid-pass with a fresh throwaway storage. They emit nowhere, so they may consume stored results but must not splice, store, or tag — keying those operations on the pass's own storage identity fixed emission duplication the naive version produced.On PHPStan's own handler-heavy code, ~29% of pass expression walks are consumed. The CPU effect is modest — the expensive subtrees (calls with throw/impure points) fail the effect-free gate by construction, so what is consumable is also cheap to re-walk:
--debug src, 3+3 interleaved)Analysis output is byte-identical on full self-analysis with the feature on vs off; full test suite,
make phpstan, and CS are green. No turbo-ext changes (the per-site frames are plain PHP state; the shadowed storage class is untouched).🤖 Generated with Claude Code
https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8