Fix two more super-linear analysis-time channels in straight-line code#6064
Merged
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh8bVRSqtiLRW7ifukHdW8
Rules that synthesize an expression node (clone with TypeExpr operands in InvalidBinaryOperationRule/InvalidUnaryOperationRule, the asserted expression in AssertRuleHelper) and ask the scope for its type suspended their callback fiber waiting for a before-scope that NodeScopeResolver can never store - the node is not part of the walked AST. Each such fiber stayed parked until the end of the function, so in operator-heavy straight-line code (issue 14972's variant A) the pending list grew by ~6 fibers per statement, every storeBeforeScope() call scanned all of them, and the parked fibers starved the reuse pool, forcing a fresh Fiber (and C stack) per callback. - ExpressionResultStorage::$pendingFibers is now keyed by the requested expression's spl_object_id(), so resolving a stored before-scope touches only the fibers waiting for that expression instead of scanning all of them. - FiberScope::getType()/getNativeType() answer TypeExpr directly - its type is scope-independent by construction. - The rules above ask via getScopeType(), which resolves against the ask-time scope - exactly what the end-of-function fallback resumed the parked fiber with, minus the parking. 800 concat-assignment statements (tests/bench/data/bug-14972-concat.php) drop from 3.8s to 1.6s user time, and the marginal per-statement cost is now linear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh8bVRSqtiLRW7ifukHdW8
Three refinements to the scope-invalidation scans, mirrored in the turbo extension's ScopeOps.cpp: - A tracked plain variable cannot contain more than itself, so with $requireMoreCharacters it can never match and the AST containment walk is skipped. This covers the '$this' invalidation that runs for every impure method call, where the substring gate is unusable - previously it walked every tracked expression. - The blanket '__phpstan' exclusion from the substring gate now exempts virtual-node wrappers whose printers include all children verbatim (PossiblyImpureCallExpr, AlwaysRememberedExpr) via keyMayHideSubExpressions(). PossiblyImpureCallExpr entries accumulate one per impure call and previously slow-pathed through the containment walk on every invalidation. - invalidateMethodsOnExpression() applies the same gate before re-printing a tracked method call's receiver, instead of printing it for every entry on every property assignment. 800 sequential impure-call assignments (tests/bench/data/impure-call-columns.php) drop from 1.5s to 1.2s user time with a near-linear tail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh8bVRSqtiLRW7ifukHdW8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh8bVRSqtiLRW7ifukHdW8
ondrejmirtes
force-pushed
the
worktree-issue-14972-perf
branch
from
July 18, 2026 20:37
e755af9 to
92a8d9c
Compare
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.
Follow-up to #6063 (issue phpstan/phpstan#14972). After the conditional-expression scan fixes, two more mechanisms kept straight-line code super-linear. Both are replicated by generated 800-statement files and now analyzed with linear (or near-linear) marginal cost per statement.
1. Fibers parked forever by rule-synthesized nodes
Issue 14972's variant A (
$out .= "row: " . $a[$i] . … ;×800) was still at 3.8s with a ~2.7× doubling ratio, and the scope tables stayed tiny — different mechanism entirely.InvalidBinaryOperationRule(and siblings) clone the operator node, replace its operands withTypeExpr, and ask$scope->getType($clone). On aFiberScopethat suspends the rule-callback fiber waiting for a before-scope that can never arrive, because the synthesized node is not part of the ASTNodeScopeResolverwalks. Consequences per statement: ~6 fibers parked until end of function, everystoreBeforeScope()scanning the whole pending list (O(N²) identity compares), and a starved fiber pool allocating a freshFiber(own C stack) for nearly every rule callback.ExpressionResultStorage::$pendingFibersis now keyed byspl_object_id()of the requested expression — resolving a before-scope touches only the fibers waiting for it. This also protects third-party rules that ask about synthesized nodes.FiberScope::getType()/getNativeType()answerTypeExprdirectly (scope-independent by construction).InvalidBinaryOperationRule,InvalidUnaryOperationRuleandAssertRuleHelperask about their own synthesized nodes viagetScopeType()— semantically identical to what the end-of-function fallback resumed the parked fiber with, minus the parking.2. Invalidation-gate holes hit on every impure call
A new reproducer (
$aN = $this->compute($N);×800) exposed a quadratic staircase inshouldInvalidateExpression():'$this'with$requireMoreCharacters, where the compositional substring gate is off — so every tracked expression, including every plain variable, went through the AST containment walk. A plain variable cannot contain more than itself, so it now short-circuits.PossiblyImpureCallExprentries (one accumulated per impure call) always slow-pathed the walk because their keys contain__phpstan. Their printer is compositional (children printed verbatim), so the newkeyMayHideSubExpressions()exempts them (andAlwaysRememberedExpr) from the blanket exclusion.invalidateMethodsOnExpression()applies the same gate before re-printing every tracked method call's receiver on every property assignment.Both mirrored in the turbo extension's
ScopeOps.cpp, with the follow-up expected-version bump.Numbers (user CPU, N=100/200/400/800)
Bench guards:
tests/bench/data/bug-14972-concat.php,tests/bench/data/impure-call-columns.php.Verification
PHPSTAN_TURBO=0src/Typeself-analysismake phpstanclean; turbo strict build,smoke.php,signature-parity.php,side-by-side.php --checkall passNote for merging: a rebase-merge or squash-merge rewrites the SHA that
TurboExtensionEnabler::EXPECTED_EXTENSION_VERSIONpoints at (the bump commit expects0ceb9aa), and will need the same post-merge correction as last time.🤖 Generated with Claude Code
https://claude.ai/code/session_01Gh8bVRSqtiLRW7ifukHdW8