Skip to content

Memoize the ExprHandler lookup by Expr class in specifyTypesInCondition() and processExprNode() - #5999

Merged
ondrejmirtes merged 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-zlv54xw
Jul 4, 2026
Merged

Memoize the ExprHandler lookup by Expr class in specifyTypesInCondition() and processExprNode()#5999
ondrejmirtes merged 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-zlv54xw

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Analysing deep !== / === chains got ~27–30 percentage points slower after the TypeSpecifier→ExprHandler dispatch refactor (commits bb18f722ebc128b84527), as reported for the bug-14207-and.php and and-chain-truthy-blowup.php benches. That refactor moved specifyTypesInCondition() onto the same container-tag dispatch already used by MutatingScope::resolveType(): for every call it iterates all services tagged phpstan.exprHandler and calls supports() on each until one matches. Because specifyTypesInCondition() (and processExprNode()) run once per arm of a boolean chain, deep chains pay this whole linear handler sweep on every arm.

The fix memoizes the resolved handler per Expr class-string so the sweep runs at most once per class, turning the per-arm dispatch into an O(1) array lookup. Inferred types are unchanged — this is a pure dispatch optimisation.

Changes

  • src/Analyser/TypeSpecifier.php: add a class-string<Expr> => ExprHandler|false cache ($exprHandlersByClass) and use it in specifyTypesInCondition(). On a cache miss the handler is resolved with the original supports() scan and stored; false records "no handler matched" (default narrowing).
  • src/Analyser/NodeScopeResolver.php: apply the identical memoization to the parallel dispatch in processExprNode(), which walks each expression node during AST traversal (also once per boolean-chain arm).
  • tests/PHPStan/Analyser/nsrt/bug-14919.php: regression test locking in that deep !==/=== chains still narrow identically through the memoized dispatch (BooleanAnd/BooleanOr, truthy and falsey branches, plus the bug-14207 assertType('string', …) shape).

Root cause

The regression is a dispatch cost, not an algorithmic one: three sites resolve an ExprHandler by a linear getServicesByTag() + supports() scan over ~40 handlers. MutatingScope::resolveType() already had this pattern before the regression window (since a11b86dc8, 2026-03-07) and is shielded by per-scope result memoization, so it was not the regression. The June 2026 refactor added the same pattern to the far hotter specifyTypesInCondition() path (and it shares the processExprNode() path), where it is invoked per boolean-chain arm with no memoization — hence the per-arm linear cost. Caching the handler by Expr class removes the repeated sweep. It is sound because, at both dispatch sites, the only handlers whose supports() inspects more than the class (the first-class-callable handlers) are already filtered/transformed out before the loop, so a given Expr class always maps to the same handler.

Parallel dispatch sites audited:

  • TypeSpecifier::specifyTypesInCondition() — fixed (the reported regression).
  • NodeScopeResolver::processExprNode() — fixed (same pattern, same boolean-chain hot path).
  • MutatingScope::resolveType() — probed; already memoized per scope via resolvedTypes, measured no benefit, and MutatingScope is not a singleton service, so left unchanged.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14919.php asserts the narrowed types of deep !== (BooleanAnd) and ===/|| (BooleanOr) chains in both branches, guarding that the memoized handler lookup keeps producing the correct handler and identical narrowing.
  • Existing suites re-run green with the change: NodeScopeResolverTest (1671), TypeSpecifierTest (114), LegacyNodeScopeResolverTest, Rules/Comparison (423), AnalyserIntegrationTest (184), and full make phpstan self-analysis.
  • Behaviour is unchanged; the perf recovery is covered by the existing tests/bench/data/bug-14207-and.php and and-chain-truthy-blowup.php benches. A local amplified A/B (20 functions × 100-arm !== chains) measured ~5.9s → ~4.0s (~30% faster), matching the reported regression.

Fixes phpstan/phpstan#14919

…ndition()` and `processExprNode()`

- `TypeSpecifier::specifyTypesInCondition()` and `NodeScopeResolver::processExprNode()` dispatched to the matching `ExprHandler` by scanning every service tagged `phpstan.exprHandler` and calling `supports()` on each until one matched. Both run once per arm of a boolean chain, so deep `!==`/`===` chains paid the full linear handler sweep per arm.
- Cache the resolved handler keyed by the `Expr` class-string (`false` when none matches, meaning default narrowing). After the initial scan for a class, subsequent calls do an O(1) array lookup instead of re-running the `supports()` sweep. This recovers the ~30pp regression on `bug-14207-and.php` and `and-chain-truthy-blowup.php` introduced when `specifyTypesInCondition` was moved onto the `ExprHandler` dispatch.
- The lookup is class-deterministic because the only handlers whose `supports()` depends on more than the class are the first-class-callable ones, and both dispatch sites filter/transform first-class callables away before reaching the handler loop.
- Probed the third parallel dispatch site, `MutatingScope::resolveType()`: its results are already memoized per scope in `resolvedTypes` (keyed by expression string), so the handler sweep is not on the hot path there — a per-class cache measured no benefit, and `MutatingScope` is not a singleton, so it was left unchanged.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for nsrt test here, it's already covered by a file in tests/bench/data.

Also what might be a problem is that not all ExprHandlers cleanly map to a class. For example FirstClassCallableFuncCallHandler has $expr instanceof FuncCall && $expr->isFirstClassCallable().

So if this is cached first and then a FuncCall (which isn't a first-class callable) is processed, a wrong handler might be passed.

phpstan-bot and others added 2 commits July 4, 2026 19:41
Call-like handlers (FuncCall/MethodCall/StaticCall/New_) select on
isFirstClassCallable(), so the Expr class does not uniquely determine the
matching handler. Restrict the per-class handler cache to non-call-like
expressions, where every handler's supports() is a pure instanceof check,
so a class always maps to the same handler. This keeps the deep boolean
chain speed-up (those arms are never call-likes) without risking a wrong
handler being served from the cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deep !==/=== chain behaviour is already exercised by
tests/bench/data/bug-14207-and.php and and-chain-truthy-blowup.php.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Both commits pushed. Here's a summary of how I addressed the review.

Review point 1 — "not all ExprHandlers cleanly map to a class" (correctness)

The reviewer is right. I audited all ~70 handlers' supports() methods: the only ones that read more than the Expr class are the 8 that call isFirstClassCallable(), and those cover exactly the four Expr\CallLike classes — FuncCall, MethodCall, StaticCall, New_. For every other class, supports() is a pure instanceof check (including the ScalarHandler/CastHandler sub-class exclusions, which are still class-determined), so a class always maps to the same handler.

The original PR argued this was safe because both changed sites filter first-class-callables before the loop. But that relies on distant upstream code and is exactly the fragility the reviewer distrusted (and it genuinely is not true for the unmemoized MutatingScope::resolveType() loop, where FirstClassCallableFuncCallHandler can match a FuncCall).

Fix: exclude Expr\CallLike from the cache entirely. Call-likes always take the original supports() scan; only class-determined expressions are memoized. This makes the invariant local and self-evident — no reasoning about upstream filters required. I extracted the logic into a resolveExprHandler() helper in both TypeSpecifier and NodeScopeResolver to avoid duplicating the scan.

The perf recovery is fully preserved: the reported regression is deep !==/=== chains, whose arms are BinaryOp/Variable/scalar nodes — never call-likes.

Review point 2 — "no need for nsrt test"

Removed tests/PHPStan/Analyser/nsrt/bug-14919.php; the behaviour is already covered by tests/bench/data/bug-14207-and.php and and-chain-truthy-blowup.php.

Verification

  • make phpstan — no errors
  • make tests — 17524 passing (96 environment-skipped)
  • phpcs — clean

Committed as two logical changes and pushed to create-pull-request/patch-zlv54xw.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error on PHP 7.4: Cannot use ::class with dynamic class name

$expr::class (::class on an object variable) is only supported since
PHP 8.0. Replace it with get_class($expr) in the memoized ExprHandler
lookups so the code parses and runs on PHP 7.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed the review comment.

The problem: $expr::class (using ::class on an object variable) is a PHP 8.0+ feature. Since PHPStan's source must run on PHP 7.4, this caused the compile error "Cannot use ::class with dynamic class name".

The fix: Replaced all four occurrences of $expr::class with get_class($expr) — two in TypeSpecifier::resolveExprHandler() and two in NodeScopeResolver::resolveExprHandler(). The behavior is identical (both yield the fully-qualified class-string used as the cache key), just PHP 7.4-compatible.

Verification:

  • make phpstan — no errors
  • TypeSpecifierTest — 114 passing
  • NodeScopeResolverTest — 1671 passing

Committed as a single logical change and pushed to create-pull-request/patch-zlv54xw.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make cs-fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. The review asked me to run make cs-fix.

What it fixed: cs-fix detected 4 style errors (2 per file) — the get_class() calls I added in the previous commit (replacing $expr::class for PHP 7.4 compatibility) were missing their use function get_class; import statements. cs-fix added the imports in alphabetical order to both NodeScopeResolver.php and TypeSpecifier.php.

Verification:

  • make cs-fix — 4 errors fixed, 0 remaining
  • make phpstan — no errors

Committed and pushed to create-pull-request/patch-zlv54xw.

@ondrejmirtes
ondrejmirtes merged commit a9cdf41 into phpstan:2.2.x Jul 4, 2026
670 of 672 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-zlv54xw branch July 4, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants