Repository navigation
Add TypeTraverser::mapMemoized() - #6652
Conversation
|
on 2.3.x@339e91e25d0dde9ad137a091de05fddc29102161 this PR: |
|
//cc @SanderMuller |
| $self = new self($cb); | ||
| $self->memo = []; | ||
|
|
||
| return $self->mapInternal($type); |
There was a problem hiding this comment.
can we clear the memo before returning, so we don't leave references which might prevent garbage collection
There was a problem hiding this comment.
Still running; I'll wait for the completion notification.
| [ | ||
| 'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.', | ||
| 46, | ||
| MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, | ||
| ], |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
-
GenericObjectTypeChecknow depends on instance sharing, which is the problem theMissingTypehintCheckcommit fixed.getGenericTypes()collects eachGenericObjectTypeinstance once, and the check builds one error per collected type. With@template T of intonBoxand@phpstan-type BadBox Box<string>, at level 9:@parambase this PR array{a: Box<string>, b: Box<string>}2 errors 2 errors array{a: BadBox, b: BadBox}2 errors 1 error callable(BadBox): BadBox2 errors 1 error Deduplicating the messages here, as
MissingTypehintChecknow does, would make the inline and alias forms behave the same. -
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 $callbackincore/src/PublishesFiles.php.A baseline generated today has
count: 2for each of them, and it then fails withignore.count. I showed that in phpstan/phpstan#15350. This could use a line in the release notes. -
CI compiles the native port but never runs it. The "Compile Turbo Extension" jobs build the 10 changed
.cppfiles and 3 headers. Then they stop on the version check, because the build reportsa4139acand the enabler expects76fefc6. The "Run with Turbo Extension" jobs, including the differential tests, were skipped. The turbo E2E jobs fail because the extension stays off.The
integration-testsandextension-testsjobs were skipped too, because they need theturbo-artifactjob. 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_VERSIONset toa4139aclocally:turbo-ext/tests/smoke.phpreportsALL 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-turboonce 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$checkForUnionfrom 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$ownedTemplatesis empty. That list only grows, so a stored result never saw an owned template. - The
checkOurKeys()cache keeps$otherValueTypealive, and$valueTypebelongs to the shape. The skippedand()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 deprecatedResultCacheMetaExtensioninResultCacheManager. #6649 has the same error, so it comes from the base.Integration tests (ubuntu-latest)fails intestWarmAnalysisDoesNotRewriteUnchangedResultCache, 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 :: Testalso fails on #6641.
Not verified
- I did not probe
ConditionalReturnTypeRuleHelper,UnresolvableTypeHelperandLocalTypeAliasesCheckfor 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.
1ca79aa to
913a302
Compare
913a302 to
1a7b36b
Compare
1a7b36b to
6db7f59
Compare
|
BTW "memoize" is a weird word and we don't have it anywhere in the current |
|
Or maybe not, because we're not caching it on a disk... |
|
@ondrejmirtes "Cached" does not have to mean disk. In PHPStan it already means memory just as often: "Memoize" is not in the Three things changed since my review, at
|
849343f to
5cd9345
Compare
local measures suggest this lazy construction is no longer necessary after this PR. it does not make a measurable difference anymore. |
staabm
left a comment
There was a problem hiding this comment.
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
|
I added the requested rule and pushed it in three commits. The new rule:
|
|
|
||
| public function getNodeType(): string | ||
| { | ||
| return FileNode::class; |
There was a problem hiding this comment.
hook this method on StaticCall
There was a problem hiding this comment.
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()returnsStaticCall::class.processNode()first checks that the call isTypeTraverser::map()ormapMemoized(), then skips files undertests/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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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():
ConditionalReturnTypeRuleHelperis reverted to its base version.GenericCallableRuleHelpernow usesmap()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, andtestGenericCallablesInOneTypestill 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.
| /** @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'))) { |
There was a problem hiding this comment.
remove this dedicated "tests handling".
There was a problem hiding this comment.
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 phpstanreported themap()calls inUnresolvedTemplateArgumentTypeTest(line 143),LateResolvableArrayShapeTypeTest(line 224) andTypeCombinatorTest(line 5902). None of the three callbacks keeps any state: each one only replaces a type, or sorts array keys, and then keeps traversing. SomapMemoized()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, notmap()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.
| ->fixNode($call, static function (StaticCall $node): StaticCall { | ||
| $node->name = new Identifier('mapMemoized'); | ||
| return $node; | ||
| }) |
There was a problem hiding this comment.
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()fromTypeTraverserMapMemoizedRuleTest, along with its expected-output filetype-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
left a comment
There was a problem hiding this comment.
revert changes to ConstantArrayType
|
I reverted the What changed:
Neither file differs from the base branch now. Checks:
The |
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>
3fb7155 to
b13d051
Compare
TypeTraverser::mapMemoized() and compare shared array shape offset values once in ConstantArrayType::accepts()TypeTraverser::mapMemoized()
staabm
left a comment
There was a problem hiding this comment.
undo the changes to ConstantArrayType as the version before this PR was faster
|
I processed this review but have nothing to report. |
1 similar comment
|
I processed this review but have nothing to report. |
staabm
left a comment
There was a problem hiding this comment.
undo the changes to ConstantArrayType as the version before this PR was faster
|
I processed this review but have nothing to report. |
|
before this PR (no turbo) after this PR: (no turbo) after this PR: (with turbo) //cc @Kocal |
|
Awesome, thanks! 🤩 |
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
`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
`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
…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
…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
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
Typeinstance, 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 distinctTypeobjects.ConstantArrayType::checkOurKeys(): ranaccepts()122,815 times for ~9k distinct (accepting, accepted) value-type pairs. It also calledVerbosityLevel::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): likemap(), but the callback runs once perTypeinstance. Results are keyed byspl_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.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,GenericCallableRuleHelperTemplateTypeHelper::toArgument(): this callback has state (owned templates), so it can't usemapMemoized(). 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): arrayinbug-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 eagergetRecommendedLevelByType()call per property.CallableTypeHelper,IntersectionTypeandTemplateTypeArgumentStrategyalready compute it only on failure.TypeTraverserInstanceofVisitor: also treatsmapMemoized()callbacks as traversal callbacks, for theinstanceof *TypeAPI rule.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.his regenerated, andtests/smoke.phphas newmapMemoized()coverage.Probed and left as is:
ConstantArrayType::isSuperTypeOf()and the recursiveTypemethods (hasTemplateOrLateResolvableType(),getReferencedTemplateTypes()). They were ~10–15% of what remains and are not traversals.TemplateTypeHelper::removeFinalByKeywordOverrides()and the othermap()call sites were not hot for this workload.Root cause
Shared
Typeinstances (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, plusConstantArrayType::checkOurKeys()andObjectShapeType::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@paramnow report both errors. Before the fix it fails, reporting only the second.ApiInstanceofTypeRuleTest: the data file has amapMemoized()callback, and aninstanceofafter it is still reported.MissingMethodParameterTypehintRuleTest::testBug14549: the duplicated identical error is now reported once.make testsandmake phpstanpass. With turbo-ext loaded:make testsandtests/smoke.phppass,side-by-side.phpandsignature-parity.phpshow no new failures, and--error-format=rawoutput is byte-identical with the extension on and off.Note for the maintainer: commits touching
turbo-ext/src/need the follow-upmake bump-turbocommit once this lands.Fixes phpstan/phpstan#15348
🤖 Generated with Claude Code