Skip to content

Add TypeTraverser::mapMemoized() - #6652

Merged
staabm merged 25 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-6ifasb5
Oct 5, 2026
Merged

staabm merged 25 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-6ifasb5

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The array shape symfony/ux-css generates for css() has ~840 keys, 126 of them typed with a nested shape alias. Checking one method parameter against it took ~19s (37s with the shape and call in one file).

A type alias resolves to a single Type instance, so a shape that uses one alias in many offsets is a DAG. PHPStan walked it as a tree:

  • TypeTraverser::map(): each of ~37 traversals of the parameter type visited 1,085,040 nodes, but only 11,064 distinct Type objects.
  • ConstantArrayType::checkOurKeys(): ran accepts() 122,815 times for ~9k distinct (accepting, accepted) value-type pairs. It also called VerbosityLevel::getRecommendedLevelByType() (a traversal of its own) for every offset, even when no error message was built.

This PR removes the repeated work. The issue's nested reproducer goes from 18.55s to 1.34s without turbo-ext.

Changes

  • TypeTraverser::mapMemoized() (src/Type/TypeTraverser.php): like map(), but the callback runs once per Type instance. Results are keyed by spl_object_id(), and the type is stored next to its result so its id can't be reused during the traversal. It's opt-in because some callbacks count occurrences (e.g. TypeCombinator's constant-array value count) or depend on traversal order.
  • Switched to mapMemoized(), for callbacks that don't depend on position or occurrence count:
    • TemplateTypeHelper::resolveToBounds() / resolveToDefaults()
    • MutatingScope::transformStaticType(), PhpDocsResolver::transformStaticType()
    • CalledOnTypeUnresolvedMethodPrototypeReflection / CalledOnTypeUnresolvedPropertyPrototypeReflection::transformStaticType()
    • RuleLevelHelper::transformAcceptedType() / transformCommonType()
    • UnresolvableTypeHelper, LocalTypeAliasesCheck::hasErrorType() (both stop at the first finding)
    • MissingTypehintCheck, GenericObjectTypeCheck, ConditionalReturnTypeRuleHelper, GenericCallableRuleHelper
  • TemplateTypeHelper::toArgument(): this callback has state (owned templates), so it can't use mapMemoized(). It caches results by instance only while no templates are owned yet. That keeps the exact semantics.
  • MissingTypehintCheck: returns distinct descriptions and distinct (class, template list) pairs. Without this, one type could get the same error twice (e.g. array&callable(array): array in bug-14549.php), and with memoization the count would depend on whether duplicates share an instance.
  • GenericCallableRuleHelper: found while checking whether this callback was safe to memoize. It did $errors = $this->templateTypeCheck->check(...) inside the traversal, so only the last callable's template errors survived. It now merges them.
  • ObjectShapeType::accepts(): same lazy verbosity. It had the same eager getRecommendedLevelByType() call per property. CallableTypeHelper, IntersectionType and TemplateTypeArgumentStrategy already compute it only on failure.
  • TypeTraverserInstanceofVisitor: also treats mapMemoized() callbacks as traversal callbacks, for the instanceof *Type API rule.
  • turbo-ext: all of the above is ported to the native mirrors: TypeTraverser.cpp (memo slot, mapMemoized, pt_type_traverser_map_memoized()), TemplateTypeHelper.cpp, PhpDocsResolver.cpp, MutatingScope.cpp, UnresolvableTypeHelper.cpp, CalledOnType*PrototypeReflection.cpp, ConstantArrayType.cpp, ObjectShapeType.cpp, TypeTraverserInstanceofVisitor.cpp. generated/TypeTraverser.h is regenerated, and tests/smoke.php has new mapMemoized() coverage.

Probed and left as is:

  • ConstantArrayType::isSuperTypeOf() and the recursive Type methods (hasTemplateOrLateResolvableType(), getReferencedTemplateTypes()). They were ~10–15% of what remains and are not traversals.
  • TemplateTypeHelper::removeFinalByKeywordOverrides() and the other map() call sites were not hot for this workload.

Root cause

Shared Type instances (type aliases, and nested shapes built from them) form a DAG. Every whole-type traversal and every per-offset comparison walked it as a tree, so the work grew with the number of paths, not the number of distinct types. The affected locations are all call sites listed above, plus ConstantArrayType::checkOurKeys() and ObjectShapeType::accepts(), which also did a verbosity traversal per offset or property even when accepting.

Test

  • tests/bench/data/bug-15348.php: a synthetic version of the workload (300 keys plus 50 offsets using a nested shape alias, passed from one method to another). 5.9s before, 1.1s after.
  • IncompatiblePhpDocTypeRuleTest::testGenericCallablesInOneType: two invalid generic callables in one @param now report both errors. Before the fix it fails, reporting only the second.
  • ApiInstanceofTypeRuleTest: the data file has a mapMemoized() callback, and an instanceof after it is still reported.
  • MissingMethodParameterTypehintRuleTest::testBug14549: the duplicated identical error is now reported once.
  • Full make tests and make phpstan pass. With turbo-ext loaded: make tests and tests/smoke.php pass, side-by-side.php and signature-parity.php show no new failures, and --error-format=raw output is byte-identical with the extension on and off.

Note for the maintainer: commits touching turbo-ext/src/ need the follow-up make bump-turbo commit once this lands.

Fixes phpstan/phpstan#15348

🤖 Generated with Claude Code

@staabm

staabm commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

phpstan-src/bin/phpstan analyse -c phpstan-nested.neon --debug -v of the phpstan/phpstan#15348 reproducer

on 2.3.x@339e91e25d0dde9ad137a091de05fddc29102161

/Users/staabm/workspace/sf-ux-css-phpstan-reproducer/src/Ui/Button.php
                                                                                                                        
 [OK] No errors                                                                                                                                                                                                                                 

Elapsed time: 12.64 seconds
Peak memory: 50 MB

this PR:

/Users/staabm/workspace/sf-ux-css-phpstan-reproducer/src/Ui/Button.php

                                                                                                                        
 [OK] No errors                                                                                                         
                                                                                                                        

Elapsed time: 1.20 seconds
Peak memory: 50 MB

@staabm

staabm commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

//cc @SanderMuller

Comment thread src/Type/TypeTraverser.php Outdated
$self = new self($cb);
$self->memo = [];

return $self->mapInternal($type);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we clear the memo before returning, so we don't leave references which might prevent garbage collection

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Still running; I'll wait for the completion notification.

Comment on lines -163 to -167
[
'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.',
46,
MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP,
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why do we lose this error?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Nothing is lost: the error you asked about was an exact copy of the one above it. The line number, message and tip were identical, so the user just saw the same line twice.

Where the two copies came from: for array&callable(array): array, getIterableTypesWithMissingValueTypehint() drops the array part of the intersection and walks callable(array): array. That callable has two bare array types, one as the parameter and one as the return type. Each produced the description iterable type array, and the rule turns each description into its own error.

Why this PR removes it: getIterableTypesWithMissingValueTypehint() now returns each description only once. Without that, the number of errors would depend on whether the two occurrences happen to be the same Type object, which is invisible to the user. I checked this on the PR branch with the deduplication taken out:

Method Before the PR PR without dedup PR
@param callable(array): array $a 2 identical errors 2 identical errors 1 error
@param callable(BareArr): BareArr $a (alias BareArr = array) 2 identical errors 1 error (alias is one shared object, so it's visited once) 1 error

With the dedup, both forms report one error.

What I pushed: commit aa697c8f2, which adds MissingMethodParameterTypehintRuleTest::testRepeatedIterableTypeReportedOnce with the data file missing-iterable-value-type-repeated.php. It covers both rows of the table. It passes with the dedup; without it, the inline case reports twice and the alias case once.

make tests and make phpstan both pass, and make cs reported no problems.

@SanderMuller SanderMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this against its base 339e91e25, at head 1aaafecd9.

The fix works. I ran the reproducer with analyse -c phpstan-nested.neon --debug, from a source checkout without turbo, on PHP 8.5.10. Over 3 runs the base took 12.83-13.58 s and this PR took 1.10-1.52 s, with identical output. With turbo built from this branch it took 0.79 s, in one run on PHP 8.5.8.

Confirmed

  1. GenericObjectTypeCheck now depends on instance sharing, which is the problem the MissingTypehintCheck commit fixed. getGenericTypes() collects each GenericObjectType instance once, and the check builds one error per collected type. With @template T of int on Box and @phpstan-type BadBox Box<string>, at level 9:

    @param base this PR
    array{a: Box<string>, b: Box<string>} 2 errors 2 errors
    array{a: BadBox, b: BadBox} 2 errors 1 error
    callable(BadBox): BadBox 2 errors 1 error

    Deduplicating the messages here, as MissingTypehintCheck now does, would make the inline and alias forms behave the same.

  2. The deduplication also changes real projects. On Tempest (33 packages, level 8), the PR reports 2797 lines instead of 2816. All 19 missing lines were exact duplicates of a line that is still reported, for example @param Closure(array): array $callback in core/src/PublishesFiles.php.

    A baseline generated today has count: 2 for each of them, and it then fails with ignore.count. I showed that in phpstan/phpstan#15350. This could use a line in the release notes.

  3. CI compiles the native port but never runs it. The "Compile Turbo Extension" jobs build the 10 changed .cpp files and 3 headers. Then they stop on the version check, because the build reports a4139ac and the enabler expects 76fefc6. The "Run with Turbo Extension" jobs, including the differential tests, were skipped. The turbo E2E jobs fail because the extension stays off.

    The integration-tests and extension-tests jobs were skipped too, because they need the turbo-artifact job. This PR removes duplicate errors, so those downstream expectations are the ones most likely to change.

    I built the extension from this branch with EXPECTED_EXTENSION_VERSION set to a4139ac locally:

    • turbo-ext/tests/smoke.php reports ALL OK.
    • The raw output with and without the extension is byte-identical on the reproducer, Tempest and both probes above. The inline and alias counts match too.

    The description says to run make bump-turbo once this lands. A bump commit in the PR itself would let CI run the native tests and the downstream tests before the merge.

Checked and not a problem

  • RuleLevelHelper::transformAcceptedType() sets $checkForUnion from inside the callback. It only changes from false to true, so memoizing a repeated instance cannot change the result.
  • The toArgument() cache stores and reads only while $ownedTemplates is empty. That list only grows, so a stored result never saw an owned template.
  • The checkOurKeys() cache keeps $otherValueType alive, and $valueType belongs to the shape. The skipped and() for a yes without reasons does not change the result.
  • @staabm's question about freeing the memo is answered by 1aaafecd9, which sets it to null before returning. That thread is still open.

Design call

#6649 also makes getRecommendedLevelByType() lazy in checkOurKeys(), in a different way. The two PRs change the same lines.

Performance

On Tempest, with a cold result cache and no turbo, there is no difference:

wall (3 runs) user CPU
base 6.10 / 5.79 / 5.78 s 32.08 / 32.14 / 31.72 s
this PR 5.78 / 5.62 / 5.77 s 31.79 / 31.43 / 31.99 s

Peak memory was the same: 90 MB main process, 160-164 MB per worker on both. The reproducer with --debug -v stayed at 52 MB on both. The result cache is 24 KB smaller, because of the removed duplicates.

CI

  • The turbo compile, version check, phpize and turbo E2E reds come from the missing bump (point 3). The phpize job says built extension reports: a4139ac, enabler expects: 76fefc6.
  • PHPStan (7.4, *) fails on an unmatched ignore for the deprecated ResultCacheMetaExtension in ResultCacheManager. #6649 has the same error, so it comes from the base.
  • Integration tests (ubuntu-latest) fails in testWarmAnalysisDoesNotRewriteUnchangedResultCache, which failed in 5 of 12 local runs on an earlier 2.3.x for me, without this PR.
  • Both mutation jobs were cancelled by a runner shutdown.
  • Benchmark :: Test also fails on #6641.

Not verified

  • I did not probe ConditionalReturnTypeRuleHelper, UnresolvableTypeHelper and LocalTypeAliasesCheck for the same instance-sharing effect as in point 1.
  • I did not run phpbench.
  • I did not run the turbo build on Linux or Windows, only on macOS arm64 with PHP 8.5.8.

@clxmstaab
clxmstaab force-pushed the create-pull-request/patch-6ifasb5 branch 3 times, most recently from 1ca79aa to 913a302 Compare October 2, 2026 09:39
@staabm
staabm force-pushed the create-pull-request/patch-6ifasb5 branch from 913a302 to 1a7b36b Compare October 2, 2026 09:45
@clxmstaab
clxmstaab force-pushed the create-pull-request/patch-6ifasb5 branch from 1a7b36b to 6db7f59 Compare October 2, 2026 09:46
Comment thread src/Rules/MissingTypehintCheck.php
Comment thread src/Rules/MissingTypehintCheck.php
@ondrejmirtes

Copy link
Copy Markdown
Member

BTW "memoize" is a weird word and we don't have it anywhere in the current @api. Maybe mapCached would be a better name for a method?

@ondrejmirtes

Copy link
Copy Markdown
Member

Or maybe not, because we're not caching it on a disk...

@SanderMuller

Copy link
Copy Markdown
Contributor

@ondrejmirtes "Cached" does not have to mean disk. In PHPStan it already means memory just as often: $cache, $methodCache, $cachedDescriptions and $sortedTypesCache are all in-memory. Memoizing is one kind of caching: the result of a function is stored for its input. Here the input is the Type instance, and the cache lives for one traversal. So mapCached() and mapMemoized() are both correct.

"Memoize" is not in the @api, but it is in src: MemoizingContainer and IssetHandler::memoizeSubjectTypes(). Note that mapMemoized() has no @api tag on the current head, while map() has one. The contract matters more than the word. A callback that counts occurrences or depends on the position gets a different result, and only the docblock says so.

Three things changed since my review, at 849343fb4:

  • The rebase resolved the checkOurKeys() conflict with Lazier verbosity in ConstantArrayType->checkOurKeys() #6649 by taking this PR's version. That drops the lazy $verbosity ??= from Lazier verbosity in ConstantArrayType->checkOurKeys() #6649. getRecommendedLevelByType() now runs for every result that is not a yes without reasons, also when no message is built. That happens for a result without reasons when $type is not a constant array. Keeping the ??= next to the new cache and the early continue would keep both savings.
  • GenericObjectTypeCheck::getGenericTypes() is unchanged, so point 1 of my review still applies. The new test in 849343fb4 covers MissingTypehintCheck only.
  • CI still has no bump commit, so the native tests, integration-tests and extension-tests are still skipped. The new "Turbo Extension Lint" red is at TypeCombinator.cpp:821, from 33de4ac62 on the base, not from this PR.

@staabm
staabm force-pushed the create-pull-request/patch-6ifasb5 branch from 849343f to 5cd9345 Compare October 2, 2026 11:29
@staabm

staabm commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

local measures suggest this lazy construction is no longer necessary after this PR. it does not make a measurable difference anymore.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

add a new phpstan rule which covers TypeTraverser::map and TypeTraverser::mapMemoized.

it should ensure that TypeTraverser::mapMemoized cannot be used in cases where the caller violates the contract documented in the phpdoc.

it should also ensure that cases which fit the TypeTraverser::mapMemoized phpdoc contract but still use regular TypeTraverser::map will be guided to use TypeTraverser::mapMemoized instead

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I added the requested rule and pushed it in three commits. make tests and make phpstan pass, and make cs is clean.

The new rule: PHPStan\Build\TypeTraverserMapMemoizedRule

It's in build/PHPStan/Build/ and registered in build/phpstan.neon. It looks at every TypeTraverser::map() and TypeTraverser::mapMemoized() call whose callback is a closure or an arrow function. A callback is treated as depending on where, or how many times, a Type instance occurs when it does any of these:

  • writes to a property, or declares a static variable;
  • appends to ($x[] = …), increments, or compound-assigns a variable captured by reference;
  • assigns a non-constant value to a variable captured by reference, e.g. $errors = array_merge($errors, …);
  • passes a variable captured by reference to a call;
  • reads a variable captured by reference that it also writes. That covers caches and state such as toArgument()'s $ownedTemplates. Reading a flag that is only ever set to a constant is allowed when the mapped type is thrown away (the call is a plain statement), because then only the side effects matter. That is the usual if ($found) return $type; early exit.

Setting a flag to a constant ($found = true) and writing or unsetting by key ($names[$name] = true) are allowed, because doing them again changes nothing.

The rule reports in both directions:

  • mapMemoized() with a callback like that: error phpstan.typeTraverserMapMemoized, which lists the reasons and points to map().
  • map() with a callback that has none of them: error phpstan.typeTraverserMap, which points to mapMemoized(). It comes with an automatic fix that renames the call.

Callbacks passed as a variable or a TypeTraverserCallable object can't be inspected, so the rule skips them. Like MemoizationPropertyRule, it skips tests/. Coverage is in tests/PHPStan/Build/TypeTraverserMapMemoizedRuleTest.php, including a fix test.

What the rule found in src/

mapMemoized() callbacks that broke the contract (commit b6876e954) now collect by key or by flag:

  • MissingTypehintCheck: descriptions are keyed by the description itself, replacing array_merge + append + array_unique.
  • UnresolvableTypeHelper: reasons are keyed by the reason. The turbo mirror already deduplicated, so only its comments changed.
  • ConditionalReturnTypeRuleHelper and GenericObjectTypeCheck: collected types are keyed by spl_object_id() of the Type. With that, map() and mapMemoized() give the same result.
  • GenericCallableRuleHelper: errors are collected per callable instance and merged after the traversal.
  • LocalTypeAliasesCheck: the callback only sets $foundCircular / $foundInvalid, and the error is built after the traversal. The reported error is the same as before.

38 map() calls with callbacks that fit the contract were switched to mapMemoized() (commit 2e00d53c9). 21 of them are in turbo-shadowed classes, so their .cpp mirrors were ported too: pt_type_traverser_map[_of] became pt_type_traverser_map_memoized[_of], and VerbosityLevel.cpp's traverseFor() got a memoized flag. I read each callback before converting it. The ones that compare by identity (TemplateTypeHelper::resolveTemplateTypes(), ResolvedFunctionVariantWithOriginal, IntersectionType) give the same result for the same instance, so they're safe to memoize.

Verification

  • Without turbo: make tests passes (22438 tests, 106 skipped), and so do make phpstan (with the new rule active) and make cs.
  • Turbo build: the strict build compiled with no warnings. I temporarily set EXPECTED_EXTENSION_VERSION to the built 2e00d53 and did not commit that.
    • tests/smoke.php printed ALL OK, signature-parity.php reported OK, and the full test suite passed with the extension loaded.
    • --error-format=raw output on src/Rules, src/Type/Generic and the bug-15348 bench file was byte-identical with and without the extension.
  • side-by-side.php: reports 54 generated/*.h is stale entries, all for classes this PR doesn't touch, such as TrinaryLogic.h. I didn't check them against the base branch. Since the method signatures this change touches are unchanged, they're probably caused by this environment rather than by the PR.
  • Bench file: tests/bench/data/bug-15348.php still runs in about 1.3–1.8s; the machine was busy during the measurement.

Still open

  • Duplicate errors from type aliases: in GenericObjectTypeCheck, a type alias used twice is reported once, while the same type written out twice is still reported twice. These are identical errors on one line; deduplicating them is left out of this change.
  • Turbo bump: the commits touch turbo-ext/src/, so a make bump-turbo commit is still needed before CI's native and downstream jobs run.


public function getNodeType(): string
{
return FileNode::class;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hook this method on StaticCall

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

TypeTraverserMapMemoizedRule now hooks on StaticCall instead of FileNode. I pushed it as commit 0a92921b9. make tests, make phpstan and make cs all pass.

What changed:

  • getNodeType() returns StaticCall::class.
  • processNode() first checks that the call is TypeTraverser::map() or mapMemoized(), then skips files under tests/ as before.
  • The file-wide search for calls and the separate pass over statements to find calls whose result is unused are both gone.
  • Whether the result is unused now comes from $scope->isInFirstLevelStatement().

Arrow function bodies: I checked isInFirstLevelStatement() with a small probe rule. It is true for a call that is a statement on its own and false for calls in an assignment, a return, an argument or a for condition. It is also true for the body of an arrow function (fn () => TypeTraverser::mapMemoized(...)), even though that result is used. The scope can't tell an arrow function body apart from a statement inside a nested closure.

So a callback that reads a flag would be allowed with mapMemoized() in an arrow function body when it shouldn't be. The rule can miss such a case there, but it never reports a false error. No such call exists in src/ today, and I left a comment next to the check. Treating arrow function bodies as non-first-level in PHPStan itself would fix this, but it would change what UselessFunctionReturnValueRule and other rules that use the same check report. I didn't make that change here.

The existing TypeTraverserMapMemoizedRuleTest cases, including the fix test, pass unchanged.

throw new ShouldNotHappenException();
}
$genericObjectTypes[] = $resolvedType;
$genericObjectTypes[spl_object_id($type)] = $resolvedType;

@staabm staabm Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

instead of forcing this code into mapMemoized, which seem to require changing to spl_object_id, just leave it to map instead and undo the changes in this file.

we should skip such code in TypeTraverserMapMemoizedRule

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I made the change you asked for and pushed it in two commits.

12306ca79 – GenericObjectTypeCheck: getGenericTypes() is back to the base version. It uses map() and collects with $genericObjectTypes[] = ... again, without spl_object_id() or array_values().

580f63b78 – the rule now leaves this pattern alone: TypeTraverserMapMemoizedRule treats a write to a by-reference variable keyed by spl_object_id(...) (e.g. $types[spl_object_id($type)] = $type) as depending on where or how many times a type occurs. Such a callback is flagged in mapMemoized() and never suggested for map() → mapMemoized(). The rule test data has both cases.

With that rule active, make phpstan also flagged the two other places in the PR that used this pattern, so I moved both back to map():

  • ConditionalReturnTypeRuleHelper is reverted to its base version.
  • GenericCallableRuleHelper now uses map() and appends each callable's errors to $allErrors. It still keeps the fix where every callable's template errors are reported, not just the last callable's, and testGenericCallablesInOneType still passes.

Rebase: someone pushed "Hook TypeTraverserMapMemoizedRule on StaticCall" (0a92921b9) to the branch while I worked. I rebased onto it with no conflicts and re-ran the rule test, the PhpDoc and Generics rule tests, make phpstan and make cs, all of which passed.

Verification: make tests (22438 tests, 106 skipped), make phpstan and make cs all passed. That full test run was done before the rebase.

None of the three files I changed have a turbo-ext mirror, so no .cpp changes were needed.

Still open: the existing open points are unchanged, including the make bump-turbo commit.

Comment thread src/Rules/Api/TypeTraverserMapMemoizedRule.php Outdated
/** @var list<StaticCall> $calls */
$calls = $nodeFinder->find($node->getNodes(), fn (Node $node): bool => $this->isTraverserCall($node));
if (count($calls) === 0) {
if ($this->skipTests && str_starts_with($this->fileHelper->normalizePath($scope->getFile()), $this->fileHelper->normalizePath(dirname(__DIR__, 3) . '/tests'))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

remove this dedicated "tests handling".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I removed the last special handling of tests/ and pushed the change as commit a4377e258.

What was left: the rule's own tests/ skip (the FileHelper / $skipTests code your comment points at) was already gone. Commit 1fa5521ae removed it when the rule moved to src/Rules/Api/. But that commit added a path-based ignore for phpstanApi.typeTraverserMap under tests/* in build/phpstan.neon, so the special case had only moved to a different place.

What changed:

  • Ignore removed: that entry is gone from build/phpstan.neon.
  • Three test files switched to mapMemoized(): without the ignore, make phpstan reported the map() calls in UnresolvedTemplateArgumentTypeTest (line 143), LateResolvableArrayShapeTypeTest (line 224) and TypeCombinatorTest (line 5902). None of the three callbacks keeps any state: each one only replaces a type, or sorts array keys, and then keeps traversing. So mapMemoized() returns the same result. I switched them the way the rule suggests instead of adding inline ignores. Despite the earlier explanation, these tests check type traversal in general, not map() specifically.

Checks: make phpstan reports no errors and make cs reports no problems. make tests passes (22438 tests, 106 skipped).

make bump-turbo still needs to run before merging. This commit doesn't touch turbo-ext/, but earlier commits on the branch do.

Comment on lines +129 to +132
->fixNode($call, static function (StaticCall $node): StaticCall {
$node->name = new Identifier('mapMemoized');
return $node;
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

drop auto-fixing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I removed the auto-fix from TypeTraverserMapMemoizedRule and pushed it as commit 523729f04.

  • The rule: the map() → mapMemoized() suggestion (phpstanApi.typeTraverserMap) no longer comes with a fix that renames the call. The message is unchanged, and the rule still reports in both directions.
  • Tests: I removed testFix() from TypeTraverserMapMemoizedRuleTest, along with its expected-output file type-traverser-map-memoized.php.fixed. The rest of the test is unchanged and still passes.

make tests passes (22437 tests, 106 skipped), make phpstan reports no errors and make cs reports no problems.

This commit doesn't touch turbo-ext/. Earlier commits on the branch do, so the make bump-turbo commit is still needed.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

revert changes to ConstantArrayType

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I reverted the ConstantArrayType changes and pushed them as commit f2fa936cd.

What changed:

  • src/Type/Constant/ConstantArrayType.php: checkOurKeys() is back to the base branch version (86c58fc7a). That removes the per-offset accepts() cache keyed by spl_object_id() and the early continue. The lazy $verbosity ??= from Lazier verbosity in ConstantArrayType->checkOurKeys() #6649 is back.
  • turbo-ext/src/ConstantArrayType.cpp: the native mirror is reverted the same way, so it matches the PHP version again.

Neither file differs from the base branch now.

Checks:

  • make tests passes (22437 tests, 106 skipped).
  • make phpstan reports no errors.
  • Without turbo, the tests/bench/data/bug-15348.php bench file now takes about 2.5s, up from about 1.1–1.8s before this revert. The mapMemoized() changes still cover most of the speedup.
  • I didn't re-run the issue's reproducer, and I didn't build the turbo extension to test the reverted .cpp file.

The ObjectShapeType::accepts() lazy-verbosity change from this PR is still in, since the review only asked about ConstantArrayType. The make bump-turbo commit is still needed, because this commit touches turbo-ext/src/ like earlier ones on the branch.

phpstan-bot and others added 9 commits October 5, 2026 09:12
TypeTraverser::map() and mapMemoized() are @api, so extensions get the same
guidance. Tests keep using map() where they exercise it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The callbacks in the three affected tests do not depend on where or how
many times a Type instance occurs, so they use mapMemoized() like src/ does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule and applying mapMemoized() across the codebase move to a separate pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hapes

Keep TypeTraverser::map() everywhere else, including the toArgument() cache,
until mapMemoized() is applied to the whole codebase in a separate pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eHelper

This fix moves to a separate pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It is not needed for big array shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@clxmstaab
clxmstaab force-pushed the create-pull-request/patch-6ifasb5 branch from 3fb7155 to b13d051 Compare October 5, 2026 07:12
@staabm staabm changed the title Add TypeTraverser::mapMemoized() and compare shared array shape offset values once in ConstantArrayType::accepts() Add TypeTraverser::mapMemoized() Oct 5, 2026

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

undo the changes to ConstantArrayType as the version before this PR was faster

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I processed this review but have nothing to report.

1 similar comment
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I processed this review but have nothing to report.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

undo the changes to ConstantArrayType as the version before this PR was faster

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I processed this review but have nothing to report.

@staabm

staabm commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

before this PR (no turbo)

➜  sf-ux-css-phpstan-reproducer git:(main) ✗ ../phpstan-src/bin/phpstan analyse -c phpstan-nested.neon -vv --debug 
Result cache not used because of debug mode.
/Users/m.staab/dvl/sf-ux-css-phpstan-reproducer/src/Ui/Button.php
Result cache is saved.
                                                                                                                        
 [OK] No errors                                                                                                         
                                                                                                                        
Elapsed time: 7.84 seconds
Peak memory: 50 MB

after this PR: (no turbo)

➜  sf-ux-css-phpstan-reproducer git:(main) ✗ ../phpstan-src/bin/phpstan analyse -c phpstan-nested.neon -vv --debug
Result cache not used because of debug mode.
/Users/m.staab/dvl/sf-ux-css-phpstan-reproducer/src/Ui/Button.php
Result cache is saved.

 [OK] No errors                                                                                                         

Elapsed time: 2.46 seconds
Peak memory: 50 MB

after this PR: (with turbo)

➜  sf-ux-css-phpstan-reproducer git:(main) ✗ ../phpstan-src/bin/phpstan analyse -c phpstan-nested.neon -vv       
Result cache not used because the cache file does not exist.
 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%  1 sec/1 sec 

Result cache is saved.

 [OK] No errors                                                                                                         

Elapsed time: 1.74 seconds
Peak memory: 20 MB (main process), 56 MB (the forked worker)

//cc @Kocal

@Kocal

Kocal commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Awesome, thanks! 🤩

@staabm
staabm merged commit 6351afb into phpstan:2.3.x Oct 5, 2026
537 of 538 checks passed
@staabm
staabm deleted the create-pull-request/patch-6ifasb5 branch October 5, 2026 07:57
Kocal added a commit to Kocal/phpstan-src that referenced this pull request Oct 6, 2026
Type aliases resolve to a single Type instance, so the array shape symfony/ux-css generates (847 keys, 144 of them typed with a nested shape alias) is a DAG, not a tree. `hasTemplateOrLateResolvableType()`, `getReferencedClasses()` and `getReferencedTemplateTypes()` on `ConstantArrayType` recursed into every offset on every call, so each call walked the shared nested shape once per offset that used it. On the reproducer, one analysis made 10.8M, 857k and 429k calls to them respectively (Blackfire, with phpstan-src#6652 applied); after this change all three drop below Blackfire's pruning threshold.

The fix memoizes the three results on the `ConstantArrayType` instance, since types are immutable; `getReferencedTemplateTypes()` keeps the result for the last composed variance it was asked with. The turbo-ext mirror (`ConstantArrayType.cpp`) gets the same three memos, and `ConstantArrayTypeBuilder.cpp`, which hardcoded the `ConstantArrayType` property slot numbers, now reads them from the generated header since the new properties shift those slots.

Numbers from https://github.com/Kocal/sf-ux-css-phpstan-reproducer, `phpstan-nested.neon` with `--debug`, ranges over 6 interleaved runs with the first discarded:

| Config | Before | After |
|---|---|---|
| 2.3.x, no turbo | 12.39-12.54 s | 11.55-11.65 s |
| 2.3.x + phpstan#6652, no turbo | 3.26-3.30 s | 2.36-2.38 s |
| 2.3.x, turbo | 5.69-5.85 s | 5.50-5.62 s |

Peak memory is unchanged (50 MB). The reproducer's 1-level config and PHPStan analysing its own `src/Type` (27.4-27.8 s, 316 -> 318 MB) stay within noise.

`make tests` passes with and without the extension loaded, `make phpstan` and `make cs` pass, turbo's `smoke.php`, `side-by-side.php` and `signature-parity.php` pass, and `--error-format=raw` output is byte-identical with the extension on and off.

Refs phpstan/phpstan#15348
Kocal added a commit to Kocal/phpstan-src that referenced this pull request Oct 6, 2026
`ConditionalReturnTypeRuleHelper::check()` walks every parameter type, out type, closure-this type and the return type with `TypeTraverser::map()` to collect `ConditionalType` and `ConditionalTypeForParameter` instances. Both are late-resolvable types, so a type whose `hasTemplateOrLateResolvableType()` is false can't contain one, and the traversal now gets skipped for those. When it does run, nothing changes: it still collects every occurrence without memoization, so this is independent from phpstan#6652, which keeps `map()` here on purpose.

On the symfony/ux-css array shape (847 keys, 144 of them a nested shape alias), this traversal visits the shared nested shape once per offset using it. With phpstan#6671, `hasTemplateOrLateResolvableType()` is memoized on `ConstantArrayType`, so the check costs almost nothing there.

For every `Type` whose `traverse()` visits children, `hasTemplateOrLateResolvableType()` checks those children too, except callable/closure parameter default values and `ObjectWithoutClassType`'s subtracted type, neither of which can hold a conditional type in a declared signature. I logged every traversal that found a conditional type while `hasTemplateOrLateResolvableType()` was false; that gave 0 cases over the full test suite and PHPStan's self-analysis.

Measured with the https://github.com/Kocal/sf-ux-css-phpstan-reproducer reproducer, `phpstan-nested.neon`, `--debug`, 6 interleaved runs with the first discarded:

| | before | after |
|---|---|---|
| 2.3.x, no turbo | 12.50-12.61 s | 12.12-12.17 s |
| 2.3.x + phpstan#6652 + phpstan#6671, no turbo | 2.58-2.65 s | 2.11-2.13 s |
| 2.3.x, turbo | 5.65-5.84 s | 5.46-5.50 s |

`make tests` passes with and without the turbo extension, `make phpstan` and `make cs` pass.

Refs phpstan/phpstan#15348
Kocal added a commit to Kocal/phpstan-src that referenced this pull request Oct 6, 2026
`GenericObjectTypeCheck::getGenericTypes()` walks the whole PHPDoc type with `TypeTraverser::map()` to collect every `GenericObjectType` and `GenericStaticType`. It now returns early when the type references no class (`getReferencedClasses() === []`) and `hasTemplateOrLateResolvableType()` is false. A generic object type always references its class, so the first check alone would miss nothing from that side. The second condition covers what `getReferencedClasses()` does not see: a template type's default (e.g. `@template T = Box<int, string, bool>`, then `@param T`) and late-resolvable types; both make `hasTemplateOrLateResolvableType()` true. When the traversal does run, nothing changes: it still collects every occurrence with `map()`, so this is independent from phpstan#6652.

This matters on the symfony/ux-css array shape (847 keys, 144 of them a nested shape alias, no class anywhere), where the traversal was visiting the shared nested shape once per offset using it. With phpstan#6671, both `getReferencedClasses()` and `hasTemplateOrLateResolvableType()` are memoized on `ConstantArrayType`, so the check is nearly free there.

`IncompatiblePhpDocTypeRuleTest::testGenericObjectTypeInTemplateDefault` covers the template default case. It passes before and after this change, and fails if the guard only checks `getReferencedClasses()`.

As a safety check, I temporarily logged every traversal that found a generic type while the guard would have skipped it: 0 cases out of 1623 traversals that found one, over the full test suite and PHPStan's self-analysis.

Numbers from https://github.com/Kocal/sf-ux-css-phpstan-reproducer with `phpstan-nested.neon`, `--debug`, 6 interleaved runs, first discarded:

| Scenario | Before | After |
| --- | --- | --- |
| 2.3.x without turbo | 12.98-13.21 s | 12.79-12.85 s |
| 2.3.x + phpstan#6652 + phpstan#6671 + phpstan#6672 without turbo | 2.14-2.26 s | 1.66-1.71 s |
| 2.3.x with turbo | 5.90-6.07 s | 5.65-5.69 s |

On ordinary code the guard costs nothing measurable: PHPStan analysing its own `src/Type` (about 28 s) calls it 683 times, the guard costs about 1 ms in total and skips about 0.75 ms of traversal.

`make tests` passes with and without the turbo extension, `make phpstan` and `make cs` pass.

Refs phpstan/phpstan#15348
Kocal added a commit to Kocal/phpstan-src that referenced this pull request Oct 6, 2026
…e's key index

In `ConstantArrayType::recursiveHasOffsetValueType()`, a constant offset (`ConstantStringType` / `ConstantIntegerType`) whose value isn't in the key index used to fall through to a scan calling `isSuperTypeOf()` on every key of the array. A non-template constant key can only answer "no" there, since its `isSuperTypeOf()` compares values of the same class, so those keys are now skipped. Template keys are still compared, and the unsealed-extras handling after the scan is unchanged. The turbo-ext mirror (`ConstantArrayType.cpp`) gets the same change.

This matters when a literal array is passed to a parameter typed with a large shape, like the symfony/ux-css `CssStyles` shape (847 keys): `checkOurKeys()` asks the literal array's `hasOffsetValueType()` for every key of the shape, and each miss used to compare against every key of the literal array.

As a safety check, I temporarily logged the result of every skipped comparison over the full test suite and PHPStan's self-analysis: 5.6M skipped comparisons, all of them "no".

The baseline grows by one more `instanceof ConstantStringType` in `ConstantArrayType.php` (5 -> 6), on purpose: the skip relies on the exact class, since only then does `isSuperTypeOf()` compare values.

Benchmarked with 50 calls to `CssRuntime::css()` using literal arrays against the nested shape from the [reproducer](https://github.com/Kocal/sf-ux-css-phpstan-reproducer), `--debug`, interleaved runs:

| Build | Before | After |
| --- | --- | --- |
| On top of phpstan#6652, phpstan#6671, phpstan#6672, phpstan#6673 + the `FunctionCallParametersCheck` reordering from the sibling PR (no turbo) | 1.56-1.59 s | 1.43-1.49 s |
| Plain 2.3.x (no turbo) | 1 min 12 s | 1 min 12 s |
| Plain 2.3.x (turbo) | 22.11-22.68 s | 22.07-22.55 s |
| PHPStan analysing its own `src/Type` | 28.13-28.29 s | 28.08-28.71 s |

The gain only shows up on top of the other PRs: elsewhere the walks they remove dominate.

`make tests` passes with and without the extension, `make phpstan` and `make cs` pass, turbo's `smoke.php`, `side-by-side.php` and `signature-parity.php` pass, and `--error-format=raw` output is byte-identical with the extension on and off.

Refs phpstan/phpstan#15348
Kocal added a commit to Kocal/phpstan-src that referenced this pull request Oct 6, 2026
…s one

In `FunctionCallParametersCheck::check()`, each argument's resolved parameter type and original parameter type were both passed to `UnresolvableTypeHelper::getUnresolvableType()`, and only afterwards did the code check whether the resolved type actually had an unresolvable type. The original type is only reported as an error when the resolved type has an unresolvable type and the original one does not, so walking the original type was wasted work whenever the resolved type was fine. This reorders the check to walk the resolved type first and only then the original type, with the same reordering applied to the return type check a few lines below. Both calls are pure, so the reported errors are unchanged.

`getUnresolvableType()` walks the whole type, which gets expensive on large array shapes. The `CssStyles` shape from symfony/ux-css has 847 keys, 144 of them a nested shape alias; every call site using it as a parameter type paid for two full walks of that shape, and the second one was almost never needed. TwigStan, which compiles templates to PHP, would produce such calls for every `css()` in a template.

Benchmark: 50 calls to `CssRuntime::css()` with literal arrays (3 to 8 keys, up to two nested conditions like `_hover` or `md`) spread over 10 classes, analysed against the reproducer's nested shape ([sf-ux-css-phpstan-reproducer](https://github.com/Kocal/sf-ux-css-phpstan-reproducer), `phpstan-nested.neon`), `--debug`, interleaved runs:

| Scenario | Before | After |
|---|---|---|
| 2.3.x, no turbo | 1 min 12 s | 48.5-49.1 s |
| 2.3.x, turbo | 21.99-22.16 s | 14.43-14.54 s |
| 2.3.x + phpstan#6652 + phpstan#6671 + phpstan#6672 + phpstan#6673, no turbo | 2.05-2.11 s | 1.74-1.75 s |

`make tests` passes with and without the turbo extension, `make phpstan` and `make cs` pass.

Refs phpstan/phpstan#15348
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.

symfony/ux-css slow on big array shape

5 participants