From d1aee39487c241836fdcc4a1a00a5f1a6e383381 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:29:07 +0000 Subject: [PATCH 1/3] Drop repeated `UnionType` operands in `TypeCombinator::intersect()` before distributing them * `TypeCombinator::doIntersect()` now calls a new `removeDuplicateUnions()` before the `A & (B | C)` distribution, so n copies of the same union no longer get multiplied out into 2^n recursive `intersect()` calls. Restricted to the exact `UnionType` class, like the finite fast path right above it: `equals()` ignores a `TemplateUnionType`'s variance strategy, and `BenevolentUnionType` keeps its dedicated handling. * The blowup was reached through `TypeCombinator::doIntersect()`'s accessory-base-type branch: `HasOffsetType` and `HasOffsetValueType` both return `array|ArrayAccess` from `getDefaultBaseType()`, so intersecting n of them intersected n identical two-member unions. `isset()` (and `!empty()`, which narrows through `!isset()`) with 26 array offsets went from "does not finish" to 1.6 s. * Probed the sibling accessory types: every other `getDefaultBaseType()` returns a non-union (`string`, `array`, `ObjectWithoutClassType`), so `HasOffsetValueType` is `HasOffsetType`'s only twin here - it is covered by the same fix and by its own test. `array_key_exists()` chains, `isset()` on property fetches (`HasPropertyType`) and `??` chains were measured and were never affected. * Added `TypeCombinatorTest::testIntersectManyAccessoryTypesSharingAUnionBaseType` (both accessory types, asserting the unchanged result plus a wall-clock budget: 64 s before, 4 ms after), `testIntersectRepeatedUnions`, and `tests/bench/data/bug-15061.php` with the reported reproducer. --- src/Type/TypeCombinator.php | 56 +++++++++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 69 +++++++++++++++++++ tests/bench/data/bug-15061.php | 84 +++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 tests/bench/data/bug-15061.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 838df16e51..fe8c33e025 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1602,6 +1602,56 @@ private static function finiteUnionMembers(UnionType $union): ?array return $finiteTypeSet->getMembers(); } + /** + * Drops every plain UnionType operand that repeats an earlier one. + * + * Intersection is idempotent, so a repeated operand adds nothing - but the + * `A & (B | C)` distribution multiplies the operand list out one union at a time, + * so n copies of the same two-member union cost 2^n intersect() calls before the + * duplicates are finally recognized at the leaves. n accessory types sharing a + * union default base type - hasOffset() and hasOffsetValue(), whose base is + * `array|ArrayAccess` - reach exactly that, which is why isset() with many offsets + * used to grow exponentially. + * + * Only unions are deduplicated: they are the only operands the distribution + * multiplies, and the pairwise isSuperTypeOf() pass further down already drops + * repeated operands of every other kind without any blowup. Restricted to the exact + * UnionType class, like the finite fast path above - equals() ignores the variance + * strategy of a TemplateUnionType, so two of them that compare equal are still not + * interchangeable, and BenevolentUnionType keeps its dedicated handling too. + * + * @param list $types + * @return list + */ + private static function removeDuplicateUnions(array $types): array + { + $unions = []; + $result = []; + foreach ($types as $type) { + if (get_class($type) === UnionType::class) { + $isDuplicate = false; + foreach ($unions as $union) { + if (!$union->equals($type)) { + continue; + } + + $isDuplicate = true; + break; + } + + if ($isDuplicate) { + continue; + } + + $unions[] = $type; + } + + $result[] = $type; + } + + return $result; + } + public static function intersect(Type ...$types): Type { if (self::$cacheEnabled ??= TurboExtensionEnabler::isTypeCombinatorCacheEnabled()) { @@ -1675,6 +1725,12 @@ public static function doIntersect(Type ...$types): Type return 0; }; if ($unionTypesCount >= 2) { + $types = self::removeDuplicateUnions($types); + $typesCount = count($types); + if ($typesCount === 1) { + return $types[0]; + } + usort($types, $sortTypes); } // transform A & (B | C) to (A & B) | (A & C) diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index b4aec71755..aab6067d63 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -68,12 +68,17 @@ use function get_class; use function implode; use function is_string; +use function microtime; use function sprintf; use const PHP_VERSION_ID; class TypeCombinatorTest extends PHPStanTestCase { + private const EXPONENTIAL_BLOWUP_OFFSET_COUNT = 22; + + private const EXPONENTIAL_BLOWUP_TIME_BUDGET_LIMIT = 5.0; + // Pin the runtime container so a foreign PhpVersion leaked by another test // can't flake the version-dependent data sets (dynamic-property handling of // final classes). See https://github.com/phpstan/phpstan/issues/14860 @@ -6547,4 +6552,68 @@ public static function dataContainsNull(): iterable yield [new MixedType(), false]; } + /** + * Accessory types that share the same union default base type - hasOffset() and + * hasOffsetValue(), whose base is `array|ArrayAccess` - used to make intersect() + * distribute n identical two-member unions over each other, one at a time, for + * 2^n recursive calls. At the offset count below that is over a minute; the + * budget is three orders of magnitude above what the same call now costs. + * + * @return iterable, string}> + */ + public static function dataIntersectManyAccessoryTypesSharingAUnionBaseType(): iterable + { + $hasOffsetTypes = []; + $hasOffsetValueTypes = []; + $describedOffsets = []; + $describedOffsetValues = []; + for ($i = 0; $i < self::EXPONENTIAL_BLOWUP_OFFSET_COUNT; $i++) { + $offset = new ConstantStringType(sprintf('k%02d', $i)); + $hasOffsetTypes[] = new HasOffsetType($offset); + $hasOffsetValueTypes[] = new HasOffsetValueType($offset, new StringType()); + $describedOffsets[] = sprintf('hasOffset(\'k%02d\')', $i); + $describedOffsetValues[] = sprintf('hasOffsetValue(\'k%02d\', string)', $i); + } + + yield 'hasOffset' => [ + $hasOffsetTypes, + sprintf( + '(non-empty-array&%1$s)|(ArrayAccess&%1$s)', + implode('&', $describedOffsets), + ), + ]; + + // The array side degrades to oversized-array above 16 known offset values, + // which is unrelated to the blowup and unchanged by fixing it. + yield 'hasOffsetValue' => [ + $hasOffsetValueTypes, + sprintf( + '(non-empty-array&oversized-array)|(ArrayAccess&%s)', + implode('&', $describedOffsetValues), + ), + ]; + } + + /** + * @param list $types + */ + #[DataProvider('dataIntersectManyAccessoryTypesSharingAUnionBaseType')] + public function testIntersectManyAccessoryTypesSharingAUnionBaseType(array $types, string $expectedTypeDescription): void + { + $startTime = microtime(true); + $result = TypeCombinator::intersect(...$types); + $elapsedTime = microtime(true) - $startTime; + + $this->assertSame($expectedTypeDescription, $result->describe(VerbosityLevel::precise())); + $this->assertLessThan(self::EXPONENTIAL_BLOWUP_TIME_BUDGET_LIMIT, $elapsedTime); + } + + public function testIntersectRepeatedUnions(): void + { + $union = new UnionType([new IntegerType(), new StringType()]); + $result = TypeCombinator::intersect($union, $union, $union); + + $this->assertSame('int|string', $result->describe(VerbosityLevel::precise())); + } + } diff --git a/tests/bench/data/bug-15061.php b/tests/bench/data/bug-15061.php new file mode 100644 index 0000000000..86911e59e4 --- /dev/null +++ b/tests/bench/data/bug-15061.php @@ -0,0 +1,84 @@ + $entities */ + $entities = []; + + foreach ($entities as $entity) { + $ok = isset( + $entity['a'], + $entity['b'], + $entity['c'], + $entity['d'], + $entity['e'], + $entity['f'], + $entity['g'], + $entity['h'], + $entity['i'], + $entity['j'], + $entity['k'], + $entity['l'], + $entity['m'], + $entity['n'], + $entity['o'], + $entity['p'], + $entity['q'], + $entity['r'], + $entity['s'], + $entity['t'], + $entity['u'], + $entity['v'], + $entity['w'], + $entity['x'], + $entity['y'], + $entity['z'], + ); + + if (!$ok) { + continue; + } + } + } + +} From e896fe974368ceea60a43a81f6364e71604e3d53 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 8 Aug 2026 09:02:03 +0000 Subject: [PATCH 2/3] Drop the intersect() timing test, the benchmark data file already pins it tests/bench/data/bug-15061.php measures the same blowup end to end, so the wall-clock budget in the unit test only adds a flaky duplicate. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Type/TypeCombinatorTest.php | 69 ----------------------- 1 file changed, 69 deletions(-) diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index aab6067d63..b4aec71755 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -68,17 +68,12 @@ use function get_class; use function implode; use function is_string; -use function microtime; use function sprintf; use const PHP_VERSION_ID; class TypeCombinatorTest extends PHPStanTestCase { - private const EXPONENTIAL_BLOWUP_OFFSET_COUNT = 22; - - private const EXPONENTIAL_BLOWUP_TIME_BUDGET_LIMIT = 5.0; - // Pin the runtime container so a foreign PhpVersion leaked by another test // can't flake the version-dependent data sets (dynamic-property handling of // final classes). See https://github.com/phpstan/phpstan/issues/14860 @@ -6552,68 +6547,4 @@ public static function dataContainsNull(): iterable yield [new MixedType(), false]; } - /** - * Accessory types that share the same union default base type - hasOffset() and - * hasOffsetValue(), whose base is `array|ArrayAccess` - used to make intersect() - * distribute n identical two-member unions over each other, one at a time, for - * 2^n recursive calls. At the offset count below that is over a minute; the - * budget is three orders of magnitude above what the same call now costs. - * - * @return iterable, string}> - */ - public static function dataIntersectManyAccessoryTypesSharingAUnionBaseType(): iterable - { - $hasOffsetTypes = []; - $hasOffsetValueTypes = []; - $describedOffsets = []; - $describedOffsetValues = []; - for ($i = 0; $i < self::EXPONENTIAL_BLOWUP_OFFSET_COUNT; $i++) { - $offset = new ConstantStringType(sprintf('k%02d', $i)); - $hasOffsetTypes[] = new HasOffsetType($offset); - $hasOffsetValueTypes[] = new HasOffsetValueType($offset, new StringType()); - $describedOffsets[] = sprintf('hasOffset(\'k%02d\')', $i); - $describedOffsetValues[] = sprintf('hasOffsetValue(\'k%02d\', string)', $i); - } - - yield 'hasOffset' => [ - $hasOffsetTypes, - sprintf( - '(non-empty-array&%1$s)|(ArrayAccess&%1$s)', - implode('&', $describedOffsets), - ), - ]; - - // The array side degrades to oversized-array above 16 known offset values, - // which is unrelated to the blowup and unchanged by fixing it. - yield 'hasOffsetValue' => [ - $hasOffsetValueTypes, - sprintf( - '(non-empty-array&oversized-array)|(ArrayAccess&%s)', - implode('&', $describedOffsetValues), - ), - ]; - } - - /** - * @param list $types - */ - #[DataProvider('dataIntersectManyAccessoryTypesSharingAUnionBaseType')] - public function testIntersectManyAccessoryTypesSharingAUnionBaseType(array $types, string $expectedTypeDescription): void - { - $startTime = microtime(true); - $result = TypeCombinator::intersect(...$types); - $elapsedTime = microtime(true) - $startTime; - - $this->assertSame($expectedTypeDescription, $result->describe(VerbosityLevel::precise())); - $this->assertLessThan(self::EXPONENTIAL_BLOWUP_TIME_BUDGET_LIMIT, $elapsedTime); - } - - public function testIntersectRepeatedUnions(): void - { - $union = new UnionType([new IntegerType(), new StringType()]); - $result = TypeCombinator::intersect($union, $union, $union); - - $this->assertSame('int|string', $result->describe(VerbosityLevel::precise())); - } - } From 2cd33ec69b137bf94a16304807422a6156b36485 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 8 Aug 2026 09:02:10 +0000 Subject: [PATCH 3/3] Do not collect the same accessory default base type twice Deduplicate where the duplicate operands are built instead of dropping repeated unions after the fact in doIntersect(): every accessory type contributes its getDefaultBaseType(), and hasOffset()/hasOffsetValue() both return `array|ArrayAccess`, so n of them handed intersect() n identical two-member unions to distribute over each other - 2^n recursive calls. Skipping a base type that an earlier accessory already contributed is sound because intersection is idempotent, and it keeps the rest of doIntersect() untouched. The reproducer from the issue goes from over three minutes (timeout) to 2.1 s, against 4.3 s for the previous placement. Co-Authored-By: Claude Opus 5 --- src/Type/TypeCombinator.php | 72 ++++++++----------------------------- 1 file changed, 15 insertions(+), 57 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index fe8c33e025..6f36f1ceec 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1602,56 +1602,6 @@ private static function finiteUnionMembers(UnionType $union): ?array return $finiteTypeSet->getMembers(); } - /** - * Drops every plain UnionType operand that repeats an earlier one. - * - * Intersection is idempotent, so a repeated operand adds nothing - but the - * `A & (B | C)` distribution multiplies the operand list out one union at a time, - * so n copies of the same two-member union cost 2^n intersect() calls before the - * duplicates are finally recognized at the leaves. n accessory types sharing a - * union default base type - hasOffset() and hasOffsetValue(), whose base is - * `array|ArrayAccess` - reach exactly that, which is why isset() with many offsets - * used to grow exponentially. - * - * Only unions are deduplicated: they are the only operands the distribution - * multiplies, and the pairwise isSuperTypeOf() pass further down already drops - * repeated operands of every other kind without any blowup. Restricted to the exact - * UnionType class, like the finite fast path above - equals() ignores the variance - * strategy of a TemplateUnionType, so two of them that compare equal are still not - * interchangeable, and BenevolentUnionType keeps its dedicated handling too. - * - * @param list $types - * @return list - */ - private static function removeDuplicateUnions(array $types): array - { - $unions = []; - $result = []; - foreach ($types as $type) { - if (get_class($type) === UnionType::class) { - $isDuplicate = false; - foreach ($unions as $union) { - if (!$union->equals($type)) { - continue; - } - - $isDuplicate = true; - break; - } - - if ($isDuplicate) { - continue; - } - - $unions[] = $type; - } - - $result[] = $type; - } - - return $result; - } - public static function intersect(Type ...$types): Type { if (self::$cacheEnabled ??= TurboExtensionEnabler::isTypeCombinatorCacheEnabled()) { @@ -1725,12 +1675,6 @@ public static function doIntersect(Type ...$types): Type return 0; }; if ($unionTypesCount >= 2) { - $types = self::removeDuplicateUnions($types); - $typesCount = count($types); - if ($typesCount === 1) { - return $types[0]; - } - usort($types, $sortTypes); } // transform A & (B | C) to (A & B) | (A & C) @@ -2140,7 +2084,21 @@ public static function doIntersect(Type ...$types): Type $accessoryBaseTypes = null; break; } - $accessoryBaseTypes[] = $type->getDefaultBaseType(); + // Accessory types share their default base type: every string accessory + // returns `string`, hasOffset() and hasOffsetValue() both return + // `array|ArrayAccess`. Adding the same base type again narrows nothing - + // intersection is idempotent - but the intersect() below distributes + // `A & (B | C)` one union at a time, so n copies of `array|ArrayAccess` + // would cost 2^n recursive calls before the duplicates are recognized at + // the leaves. That is why isset() with many offsets used to grow + // exponentially: each offset contributes one hasOffset(). + $baseType = $type->getDefaultBaseType(); + foreach ($accessoryBaseTypes as $addedBaseType) { + if ($addedBaseType->equals($baseType)) { + continue 2; + } + } + $accessoryBaseTypes[] = $baseType; } if ($accessoryBaseTypes !== null) { // Accessory types never stand alone — supply the base type they refine.