From 7a0dd7034b6af882deff524b0b6912d1f1c854a7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 23:00:07 +0200 Subject: [PATCH 1/9] Memoize ExprHandler dispatch in MutatingScope::resolveType() resolveType() re-scanned every tagged handler with a linear supports() sweep on each call - 2.3M supports() calls for 56K dispatches measured on a small self-analysis slice. NodeScopeResolver and TypeSpecifier already memoized this dispatch by Expr class in private copies; extract the memo into a shared ExprHandlerRegistry and use it in all three places. --- src/Analyser/ExprHandlerRegistry.php | 61 ++++++++++++++++++++++++++++ src/Analyser/MutatingScope.php | 8 +--- src/Analyser/NodeScopeResolver.php | 39 +----------------- src/Analyser/TypeSpecifier.php | 40 +----------------- 4 files changed, 67 insertions(+), 81 deletions(-) create mode 100644 src/Analyser/ExprHandlerRegistry.php diff --git a/src/Analyser/ExprHandlerRegistry.php b/src/Analyser/ExprHandlerRegistry.php new file mode 100644 index 0000000000..8f19f6fd32 --- /dev/null +++ b/src/Analyser/ExprHandlerRegistry.php @@ -0,0 +1,61 @@ +, ExprHandler|false> */ + private array $exprHandlersByClass = []; + + public function __construct(private Container $container) + { + } + + /** + * @return ExprHandler|null + */ + public function resolve(Expr $expr): ?ExprHandler + { + if (!$expr instanceof Expr\CallLike) { + $cached = $this->exprHandlersByClass[get_class($expr)] ?? null; + if ($cached !== null) { + return $cached === false ? null : $cached; + } + } + + $matchedHandler = null; + /** @var ExprHandler $exprHandler */ + foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { + if (!$exprHandler->supports($expr)) { + continue; + } + + $matchedHandler = $exprHandler; + break; + } + + if (!$expr instanceof Expr\CallLike) { + $this->exprHandlersByClass[get_class($expr)] = $matchedHandler ?? false; + } + + return $matchedHandler; + } + +} diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index bc65d25f41..a4d5061535 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1059,12 +1059,8 @@ private function resolveType(string $exprString, Expr $node): Type return $this->expressionTypes[$exprString]->getType(); } - /** @var ExprHandler $exprHandler */ - foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { - if (!$exprHandler->supports($node)) { - continue; - } - + $exprHandler = $this->container->getByType(ExprHandlerRegistry::class)->resolve($node); + if ($exprHandler !== null) { return $exprHandler->resolveType($this, $node); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b979b61b7a..bd7fb192d6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -198,18 +198,7 @@ class NodeScopeResolver /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; - /** - * Memoizes which ExprHandler handles each Expr class so processExprNode() does not - * re-scan every tagged handler on each call. Keyed by Expr class-string; false means - * no handler matched (default handling). - * - * Call-likes (Expr\CallLike) are never memoized: their handlers select on - * isFirstClassCallable(), which the Expr class alone does not determine. Every other - * handler's supports() is a pure instanceof check, so the class fully determines it. - * - * @var array, ExprHandler|false> - */ - private array $exprHandlersByClass = []; + private ?ExprHandlerRegistry $exprHandlerRegistry = null; /** @var array */ private array $earlyTerminatingMethodNames; @@ -2833,31 +2822,7 @@ public function processExprNode( */ private function resolveExprHandler(Expr $expr): ?ExprHandler { - // Call-likes are excluded from the cache: their handlers select on - // isFirstClassCallable(), so the Expr class does not uniquely determine the handler. - if (!$expr instanceof Expr\CallLike) { - $cached = $this->exprHandlersByClass[get_class($expr)] ?? null; - if ($cached !== null) { - return $cached === false ? null : $cached; - } - } - - $matchedHandler = null; - /** @var ExprHandler $exprHandler */ - foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { - if (!$exprHandler->supports($expr)) { - continue; - } - - $matchedHandler = $exprHandler; - break; - } - - if (!$expr instanceof Expr\CallLike) { - $this->exprHandlersByClass[get_class($expr)] = $matchedHandler ?? false; - } - - return $matchedHandler; + return ($this->exprHandlerRegistry ??= $this->container->getByType(ExprHandlerRegistry::class))->resolve($expr); } /** diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 73c56dbf23..86a4ac3a56 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -60,19 +60,7 @@ final class TypeSpecifier /** @var StaticMethodTypeSpecifyingExtension[][]|null */ private ?array $staticMethodTypeSpecifyingExtensionsByClass = null; - /** - * Memoizes which ExprHandler handles each Expr class so specifyTypesInCondition() - * does not re-scan every tagged handler (a linear supports() sweep) on each call. - * This matters for deep boolean chains, where specifyTypesInCondition() runs once per arm. - * Keyed by Expr class-string; false means no handler matched (default narrowing). - * - * Call-likes (Expr\CallLike) are never memoized: their handlers select on - * isFirstClassCallable(), which the Expr class alone does not determine. Every other - * handler's supports() is a pure instanceof check, so the class fully determines it. - * - * @var array, ExprHandler|false> - */ - private array $exprHandlersByClass = []; + private ?ExprHandlerRegistry $exprHandlerRegistry = null; /** * @param FunctionTypeSpecifyingExtension[] $functionTypeSpecifyingExtensions @@ -119,31 +107,7 @@ public function specifyTypesInCondition( */ private function resolveExprHandler(Expr $expr): ?ExprHandler { - // Call-likes are excluded from the cache: their handlers select on - // isFirstClassCallable(), so the Expr class does not uniquely determine the handler. - if (!$expr instanceof Expr\CallLike) { - $cached = $this->exprHandlersByClass[get_class($expr)] ?? null; - if ($cached !== null) { - return $cached === false ? null : $cached; - } - } - - $matchedHandler = null; - /** @var ExprHandler $exprHandler */ - foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { - if (!$exprHandler->supports($expr)) { - continue; - } - - $matchedHandler = $exprHandler; - break; - } - - if (!$expr instanceof Expr\CallLike) { - $this->exprHandlersByClass[get_class($expr)] = $matchedHandler ?? false; - } - - return $matchedHandler; + return ($this->exprHandlerRegistry ??= $this->container->getByType(ExprHandlerRegistry::class))->resolve($expr); } /** @internal */ From 97c33cd7f5a9f83abea7e6d7b31916ab31efd96c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 11 Jul 2026 23:15:58 +0200 Subject: [PATCH 2/9] cs --- src/Analyser/NodeScopeResolver.php | 1 - src/Analyser/TypeSpecifier.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index bd7fb192d6..362f28115d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -175,7 +175,6 @@ use function array_slice; use function array_values; use function count; -use function get_class; use function in_array; use function is_array; use function is_int; diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 86a4ac3a56..b3115b3957 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -44,7 +44,6 @@ use function array_map; use function array_merge; use function count; -use function get_class; use function in_array; use function strtolower; use function substr; From 2ad01f16d9284dfffdd66bd35900cc3a7f07fdd4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 11 Jul 2026 23:23:24 +0200 Subject: [PATCH 3/9] inline single line call --- src/Analyser/NodeScopeResolver.php | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 362f28115d..afd0338e75 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2793,7 +2793,7 @@ public function processExprNode( $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); - $exprHandler = $this->resolveExprHandler($expr); + $exprHandler = ($this->exprHandlerRegistry ??= $this->container->getByType(ExprHandlerRegistry::class))->resolve($expr); if ($exprHandler !== null) { return $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); } @@ -2814,16 +2814,6 @@ public function processExprNode( ); } - /** - * Resolves the ExprHandler for the given expression, memoizing the result by Expr class. - * - * @return ExprHandler|null - */ - private function resolveExprHandler(Expr $expr): ?ExprHandler - { - return ($this->exprHandlerRegistry ??= $this->container->getByType(ExprHandlerRegistry::class))->resolve($expr); - } - /** * @param 'get'|'set' $hookName * @return InternalThrowPoint[] From bc82041346b1fc0a289bb8dfad761566195a066f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 11 Jul 2026 23:24:09 +0200 Subject: [PATCH 4/9] simplify --- src/Analyser/TypeSpecifier.php | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index b3115b3957..354f3fb7cc 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -91,7 +91,7 @@ public function specifyTypesInCondition( return (new SpecifiedTypes([], []))->setRootExpr($expr); } - $exprHandler = $this->resolveExprHandler($expr); + $exprHandler = ($this->exprHandlerRegistry ??= $this->container->getByType(ExprHandlerRegistry::class))->resolve($expr); if ($exprHandler !== null) { return $exprHandler->specifyTypes($this, $scope, $expr, $context); } @@ -99,16 +99,6 @@ public function specifyTypesInCondition( return $this->specifyDefaultTypes($scope, $expr, $context); } - /** - * Resolves the ExprHandler for the given expression, memoizing the result by Expr class. - * - * @return ExprHandler|null - */ - private function resolveExprHandler(Expr $expr): ?ExprHandler - { - return ($this->exprHandlerRegistry ??= $this->container->getByType(ExprHandlerRegistry::class))->resolve($expr); - } - /** @internal */ public function isNormalCountCall(FuncCall $countFuncCall, Type $typeToCount, Scope $scope): TrinaryLogic { From f1a523951a375b087862698efbf239a66e1fdf8f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 11 Jul 2026 23:40:36 +0200 Subject: [PATCH 5/9] simplify ExprHandlerRegistry --- src/Analyser/ExprHandlerRegistry.php | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Analyser/ExprHandlerRegistry.php b/src/Analyser/ExprHandlerRegistry.php index 8f19f6fd32..2dbdc7e319 100644 --- a/src/Analyser/ExprHandlerRegistry.php +++ b/src/Analyser/ExprHandlerRegistry.php @@ -11,17 +11,12 @@ * Resolves which ExprHandler handles a given Expr, memoizing the result by * Expr class so dispatch does not re-scan every tagged handler (a linear * supports() sweep) on each call. - * - * Call-likes (Expr\CallLike) are never memoized: their handlers select on - * isFirstClassCallable(), which the Expr class alone does not determine. Every - * other handler's supports() is a pure instanceof check, so the class fully - * determines it. */ #[AutowiredService] final class ExprHandlerRegistry { - /** @var array, ExprHandler|false> */ + /** @var array|false> */ private array $exprHandlersByClass = []; public function __construct(private Container $container) @@ -33,11 +28,14 @@ public function __construct(private Container $container) */ public function resolve(Expr $expr): ?ExprHandler { - if (!$expr instanceof Expr\CallLike) { - $cached = $this->exprHandlersByClass[get_class($expr)] ?? null; - if ($cached !== null) { - return $cached === false ? null : $cached; - } + $cacheKey = get_class($expr); + if ($expr instanceof Expr\CallLike) { + $cacheKey .= '|' . $expr->isFirstClassCallable(); + } + + $cached = $this->exprHandlersByClass[$cacheKey] ?? null; + if ($cached !== null) { + return $cached === false ? null : $cached; } $matchedHandler = null; @@ -51,9 +49,7 @@ public function resolve(Expr $expr): ?ExprHandler break; } - if (!$expr instanceof Expr\CallLike) { - $this->exprHandlersByClass[get_class($expr)] = $matchedHandler ?? false; - } + $this->exprHandlersByClass[$cacheKey] = $matchedHandler ?? false; return $matchedHandler; } From 301fa359d0b747b704fe8ab97ca15157503994c2 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 12 Jul 2026 00:05:11 +0200 Subject: [PATCH 6/9] Update ExprHandlerRegistry.php --- src/Analyser/ExprHandlerRegistry.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Analyser/ExprHandlerRegistry.php b/src/Analyser/ExprHandlerRegistry.php index 2dbdc7e319..58e9b0772e 100644 --- a/src/Analyser/ExprHandlerRegistry.php +++ b/src/Analyser/ExprHandlerRegistry.php @@ -31,6 +31,9 @@ public function resolve(Expr $expr): ?ExprHandler $cacheKey = get_class($expr); if ($expr instanceof Expr\CallLike) { $cacheKey .= '|' . $expr->isFirstClassCallable(); + if ($expr instanceof Expr\New_) { + $cacheKey .= '|' . get_class($expr->class); + } } $cached = $this->exprHandlersByClass[$cacheKey] ?? null; From 024cd585720678b16f51c80b2c0715339b57730e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 12 Jul 2026 08:35:01 +0200 Subject: [PATCH 7/9] ExprHandlerRegistry handling is static to ease shadowing via phpstanturbo --- src/Analyser/ExprHandlerRegistry.php | 19 +++++++++---------- src/Analyser/MutatingScope.php | 2 +- src/Analyser/NodeScopeResolver.php | 4 +--- src/Analyser/TypeSpecifier.php | 4 +--- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandlerRegistry.php b/src/Analyser/ExprHandlerRegistry.php index 58e9b0772e..95d7489771 100644 --- a/src/Analyser/ExprHandlerRegistry.php +++ b/src/Analyser/ExprHandlerRegistry.php @@ -6,6 +6,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; use function get_class; +use function spl_object_id; /** * Resolves which ExprHandler handles a given Expr, memoizing the result by @@ -16,17 +17,13 @@ final class ExprHandlerRegistry { - /** @var array|false> */ - private array $exprHandlersByClass = []; - - public function __construct(private Container $container) - { - } + /** @var array|false>> */ + private static array $exprHandlersByClass = []; /** * @return ExprHandler|null */ - public function resolve(Expr $expr): ?ExprHandler + public static function resolve(Expr $expr, Container $container): ?ExprHandler { $cacheKey = get_class($expr); if ($expr instanceof Expr\CallLike) { @@ -36,14 +33,16 @@ public function resolve(Expr $expr): ?ExprHandler } } - $cached = $this->exprHandlersByClass[$cacheKey] ?? null; + $containerId = spl_object_id($container); + self::$exprHandlersByClass[$containerId] ??= []; + $cached = self::$exprHandlersByClass[$containerId][$cacheKey] ?? null; if ($cached !== null) { return $cached === false ? null : $cached; } $matchedHandler = null; /** @var ExprHandler $exprHandler */ - foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { + foreach ($container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { if (!$exprHandler->supports($expr)) { continue; } @@ -52,7 +51,7 @@ public function resolve(Expr $expr): ?ExprHandler break; } - $this->exprHandlersByClass[$cacheKey] = $matchedHandler ?? false; + self::$exprHandlersByClass[$containerId][$cacheKey] = $matchedHandler ?? false; return $matchedHandler; } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a4d5061535..60c3326ae3 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1059,7 +1059,7 @@ private function resolveType(string $exprString, Expr $node): Type return $this->expressionTypes[$exprString]->getType(); } - $exprHandler = $this->container->getByType(ExprHandlerRegistry::class)->resolve($node); + $exprHandler = ExprHandlerRegistry::resolve($node, $this->container); if ($exprHandler !== null) { return $exprHandler->resolveType($this, $node); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index afd0338e75..cb407c048a 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -197,8 +197,6 @@ class NodeScopeResolver /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; - private ?ExprHandlerRegistry $exprHandlerRegistry = null; - /** @var array */ private array $earlyTerminatingMethodNames; @@ -2793,7 +2791,7 @@ public function processExprNode( $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); - $exprHandler = ($this->exprHandlerRegistry ??= $this->container->getByType(ExprHandlerRegistry::class))->resolve($expr); + $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); if ($exprHandler !== null) { return $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 354f3fb7cc..3bc9039336 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -59,8 +59,6 @@ final class TypeSpecifier /** @var StaticMethodTypeSpecifyingExtension[][]|null */ private ?array $staticMethodTypeSpecifyingExtensionsByClass = null; - private ?ExprHandlerRegistry $exprHandlerRegistry = null; - /** * @param FunctionTypeSpecifyingExtension[] $functionTypeSpecifyingExtensions * @param MethodTypeSpecifyingExtension[] $methodTypeSpecifyingExtensions @@ -91,7 +89,7 @@ public function specifyTypesInCondition( return (new SpecifiedTypes([], []))->setRootExpr($expr); } - $exprHandler = ($this->exprHandlerRegistry ??= $this->container->getByType(ExprHandlerRegistry::class))->resolve($expr); + $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); if ($exprHandler !== null) { return $exprHandler->specifyTypes($this, $scope, $expr, $context); } From 84c3095256cff3ca7bc47d4f64af084da0725e45 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 12 Jul 2026 09:00:06 +0200 Subject: [PATCH 8/9] use weak --- src/Analyser/ExprHandlerRegistry.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandlerRegistry.php b/src/Analyser/ExprHandlerRegistry.php index 95d7489771..c146bc42ef 100644 --- a/src/Analyser/ExprHandlerRegistry.php +++ b/src/Analyser/ExprHandlerRegistry.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; +use WeakMap; use function get_class; use function spl_object_id; @@ -17,8 +18,8 @@ final class ExprHandlerRegistry { - /** @var array|false>> */ - private static array $exprHandlersByClass = []; + /** @var WeakMap|false>>|null */ + private static ?WeakMap $exprHandlersByClass = null; /** * @return ExprHandler|null @@ -33,9 +34,9 @@ public static function resolve(Expr $expr, Container $container): ?ExprHandler } } - $containerId = spl_object_id($container); - self::$exprHandlersByClass[$containerId] ??= []; - $cached = self::$exprHandlersByClass[$containerId][$cacheKey] ?? null; + self::$exprHandlersByClass ??= new WeakMap(); + self::$exprHandlersByClass[$container] ??= []; + $cached = self::$exprHandlersByClass[$container][$cacheKey] ?? null; if ($cached !== null) { return $cached === false ? null : $cached; } @@ -51,7 +52,7 @@ public static function resolve(Expr $expr, Container $container): ?ExprHandler break; } - self::$exprHandlersByClass[$containerId][$cacheKey] = $matchedHandler ?? false; + self::$exprHandlersByClass[$container][$cacheKey] = $matchedHandler ?? false; return $matchedHandler; } From 39518b8839a708f2ae9c9a9fef74072daa822593 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 12 Jul 2026 09:06:24 +0200 Subject: [PATCH 9/9] Revert "use weak" This reverts commit 84c3095256cff3ca7bc47d4f64af084da0725e45. --- src/Analyser/ExprHandlerRegistry.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandlerRegistry.php b/src/Analyser/ExprHandlerRegistry.php index c146bc42ef..95d7489771 100644 --- a/src/Analyser/ExprHandlerRegistry.php +++ b/src/Analyser/ExprHandlerRegistry.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; -use WeakMap; use function get_class; use function spl_object_id; @@ -18,8 +17,8 @@ final class ExprHandlerRegistry { - /** @var WeakMap|false>>|null */ - private static ?WeakMap $exprHandlersByClass = null; + /** @var array|false>> */ + private static array $exprHandlersByClass = []; /** * @return ExprHandler|null @@ -34,9 +33,9 @@ public static function resolve(Expr $expr, Container $container): ?ExprHandler } } - self::$exprHandlersByClass ??= new WeakMap(); - self::$exprHandlersByClass[$container] ??= []; - $cached = self::$exprHandlersByClass[$container][$cacheKey] ?? null; + $containerId = spl_object_id($container); + self::$exprHandlersByClass[$containerId] ??= []; + $cached = self::$exprHandlersByClass[$containerId][$cacheKey] ?? null; if ($cached !== null) { return $cached === false ? null : $cached; } @@ -52,7 +51,7 @@ public static function resolve(Expr $expr, Container $container): ?ExprHandler break; } - self::$exprHandlersByClass[$container][$cacheKey] = $matchedHandler ?? false; + self::$exprHandlersByClass[$containerId][$cacheKey] = $matchedHandler ?? false; return $matchedHandler; }