Skip to content

Short-circuit ScopeOps scans over conditional expressions#6063

Merged
ondrejmirtes merged 2 commits into
2.2.xfrom
worktree-issue-14972-perf
Jul 18, 2026
Merged

Short-circuit ScopeOps scans over conditional expressions#6063
ondrejmirtes merged 2 commits into
2.2.xfrom
worktree-issue-14972-perf

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Closes phpstan/phpstan#14972

Straight-line code with many distinct tracked sub-expressions made every statement scan the whole accumulated scope state, turning analysis quadratic with a heavy constant. Each plain $vN = $a[N]; assignment adds ~16 ConditionalExpressionHolder entries (from AssignHandler's falsey-scalar decomposition), and two per-statement operations then scanned all of it:

  • ScopeOps::matchConditionalExpressions() — called via filterBySpecifiedTypes() on every expression statement from processStmtNode(), scanning every holder twice even with an empty specified-expressions set, which provably can never match anything (the ConditionalExpressionHolder constructor enforces at least one condition). Now returns immediately.
  • ScopeOps::invalidateExpressionEntries() — called on every assignVariable(), running shouldInvalidateExpression() for every tracked expression, holder target, and holder condition, and re-printing each target expression via nodeKey(). Now applies the compositional-key substring gate directly on the stored table keys as a prefilter (the conditional-expressions map key doubles as the printed target key, and a holder's array key embeds all its condition keys verbatim), and rebuilds holder lists lazily so untouched entries keep sharing the original arrays.

Both changes are mirrored in the turbo extension's ScopeOps.cpp, with the follow-up expected-version bump.

Numbers

tests/bench/data/bug-14972.php (800 sequential distinct-key assignments, the issue's variant C generator):

N before after (PHP) after (turbo)
100 0.59s 0.56s 0.50s
200 0.73s 0.64s 0.56s
400 1.46s 0.82s 0.67s
800 4.77s 1.36s 0.89s

The marginal per-statement cost is now linear under the extension and near-linear in PHP (the remaining tail is the copy-on-write scope-map duplication the issue also identifies, which would need persistent maps).

The issue's variant A (concat-heavy statements) only improves from 5.3s to 4.6s — its dominant cost is a different channel, not the ScopeOps scans.

Verification

  • Full test suite green with the extension shadowing active and with PHPSTAN_TURBO=0
  • Analysis output byte-identical between both modes on the repro files and a src/Type self-analysis
  • make phpstan clean; turbo strict build, smoke.php, signature-parity.php, and side-by-side.php --check all pass

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gh8bVRSqtiLRW7ifukHdW8

ondrejmirtes and others added 2 commits July 18, 2026 20:39
Straight-line code with many distinct tracked sub-expressions made every
statement scan the whole accumulated scope state, turning analysis
quadratic with a heavy constant (6058 in the phpstan/phpstan repro):

- matchConditionalExpressions() now returns immediately when nothing is
  specified: every holder has at least one condition (enforced by the
  ConditionalExpressionHolder constructor), so nothing can match, yet the
  per-statement call from processStmtNode() scanned every holder twice.
- invalidateExpressionEntries() applies the compositional-key substring
  gate of shouldInvalidateExpression() directly on the stored table keys,
  skipping whole entries and holders without per-expression calls. The
  conditional-expressions map key doubles as the target's node key, so the
  target expression is no longer re-printed per entry per invalidation,
  and holder lists are rebuilt lazily so untouched entries keep sharing
  the original arrays.

Same changes mirrored in the turbo extension's ScopeOps.cpp; analysis
output is byte-identical with the extension on and off, and the full test
suite passes in both modes.

800 sequential `$vN = $a[N];` assignments (tests/bench/data/bug-14972.php)
drop from 4.8s to 1.4s (PHP) / 0.9s (turbo); the marginal per-statement
cost is now linear under the extension and near-linear in PHP.

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
ondrejmirtes force-pushed the worktree-issue-14972-perf branch from 31d2d41 to fc99f5d Compare July 18, 2026 19:40
@ondrejmirtes
ondrejmirtes merged commit a0cf67b into 2.2.x Jul 18, 2026
179 of 181 checks passed
@ondrejmirtes
ondrejmirtes deleted the worktree-issue-14972-perf branch July 18, 2026 19:41
@kojiromike

Copy link
Copy Markdown

Wow, that was fast, thanks!

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.

Performance: super-linear (≈quadratic) analysis time in straight-line code with many distinct sub-expressions

2 participants