Skip to content

Commit 696a0d2

Browse files
committed
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.
1 parent 03fb80d commit 696a0d2

3 files changed

Lines changed: 209 additions & 0 deletions

File tree

src/Type/TypeCombinator.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,6 +1602,56 @@ private static function finiteUnionMembers(UnionType $union): ?array
16021602
return $finiteTypeSet->getMembers();
16031603
}
16041604

1605+
/**
1606+
* Drops every plain UnionType operand that repeats an earlier one.
1607+
*
1608+
* Intersection is idempotent, so a repeated operand adds nothing - but the
1609+
* `A & (B | C)` distribution multiplies the operand list out one union at a time,
1610+
* so n copies of the same two-member union cost 2^n intersect() calls before the
1611+
* duplicates are finally recognized at the leaves. n accessory types sharing a
1612+
* union default base type - hasOffset() and hasOffsetValue(), whose base is
1613+
* `array|ArrayAccess` - reach exactly that, which is why isset() with many offsets
1614+
* used to grow exponentially.
1615+
*
1616+
* Only unions are deduplicated: they are the only operands the distribution
1617+
* multiplies, and the pairwise isSuperTypeOf() pass further down already drops
1618+
* repeated operands of every other kind without any blowup. Restricted to the exact
1619+
* UnionType class, like the finite fast path above - equals() ignores the variance
1620+
* strategy of a TemplateUnionType, so two of them that compare equal are still not
1621+
* interchangeable, and BenevolentUnionType keeps its dedicated handling too.
1622+
*
1623+
* @param list<Type> $types
1624+
* @return list<Type>
1625+
*/
1626+
private static function removeDuplicateUnions(array $types): array
1627+
{
1628+
$unions = [];
1629+
$result = [];
1630+
foreach ($types as $type) {
1631+
if (get_class($type) === UnionType::class) {
1632+
$isDuplicate = false;
1633+
foreach ($unions as $union) {
1634+
if (!$union->equals($type)) {
1635+
continue;
1636+
}
1637+
1638+
$isDuplicate = true;
1639+
break;
1640+
}
1641+
1642+
if ($isDuplicate) {
1643+
continue;
1644+
}
1645+
1646+
$unions[] = $type;
1647+
}
1648+
1649+
$result[] = $type;
1650+
}
1651+
1652+
return $result;
1653+
}
1654+
16051655
public static function intersect(Type ...$types): Type
16061656
{
16071657
if (self::$cacheEnabled ??= TurboExtensionEnabler::isTypeCombinatorCacheEnabled()) {
@@ -1675,6 +1725,12 @@ public static function doIntersect(Type ...$types): Type
16751725
return 0;
16761726
};
16771727
if ($unionTypesCount >= 2) {
1728+
$types = self::removeDuplicateUnions($types);
1729+
$typesCount = count($types);
1730+
if ($typesCount === 1) {
1731+
return $types[0];
1732+
}
1733+
16781734
usort($types, $sortTypes);
16791735
}
16801736
// transform A & (B | C) to (A & B) | (A & C)

tests/PHPStan/Type/TypeCombinatorTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,17 @@
6868
use function get_class;
6969
use function implode;
7070
use function is_string;
71+
use function microtime;
7172
use function sprintf;
7273
use const PHP_VERSION_ID;
7374

7475
class TypeCombinatorTest extends PHPStanTestCase
7576
{
7677

78+
private const EXPONENTIAL_BLOWUP_OFFSET_COUNT = 22;
79+
80+
private const EXPONENTIAL_BLOWUP_TIME_BUDGET_LIMIT = 5.0;
81+
7782
// Pin the runtime container so a foreign PhpVersion leaked by another test
7883
// can't flake the version-dependent data sets (dynamic-property handling of
7984
// final classes). See https://github.com/phpstan/phpstan/issues/14860
@@ -6547,4 +6552,68 @@ public static function dataContainsNull(): iterable
65476552
yield [new MixedType(), false];
65486553
}
65496554

6555+
/**
6556+
* Accessory types that share the same union default base type - hasOffset() and
6557+
* hasOffsetValue(), whose base is `array|ArrayAccess` - used to make intersect()
6558+
* distribute n identical two-member unions over each other, one at a time, for
6559+
* 2^n recursive calls. At the offset count below that is over a minute; the
6560+
* budget is three orders of magnitude above what the same call now costs.
6561+
*
6562+
* @return iterable<string, array{list<Type>, string}>
6563+
*/
6564+
public static function dataIntersectManyAccessoryTypesSharingAUnionBaseType(): iterable
6565+
{
6566+
$hasOffsetTypes = [];
6567+
$hasOffsetValueTypes = [];
6568+
$describedOffsets = [];
6569+
$describedOffsetValues = [];
6570+
for ($i = 0; $i < self::EXPONENTIAL_BLOWUP_OFFSET_COUNT; $i++) {
6571+
$offset = new ConstantStringType(sprintf('k%02d', $i));
6572+
$hasOffsetTypes[] = new HasOffsetType($offset);
6573+
$hasOffsetValueTypes[] = new HasOffsetValueType($offset, new StringType());
6574+
$describedOffsets[] = sprintf('hasOffset(\'k%02d\')', $i);
6575+
$describedOffsetValues[] = sprintf('hasOffsetValue(\'k%02d\', string)', $i);
6576+
}
6577+
6578+
yield 'hasOffset' => [
6579+
$hasOffsetTypes,
6580+
sprintf(
6581+
'(non-empty-array&%1$s)|(ArrayAccess&%1$s)',
6582+
implode('&', $describedOffsets),
6583+
),
6584+
];
6585+
6586+
// The array side degrades to oversized-array above 16 known offset values,
6587+
// which is unrelated to the blowup and unchanged by fixing it.
6588+
yield 'hasOffsetValue' => [
6589+
$hasOffsetValueTypes,
6590+
sprintf(
6591+
'(non-empty-array&oversized-array)|(ArrayAccess&%s)',
6592+
implode('&', $describedOffsetValues),
6593+
),
6594+
];
6595+
}
6596+
6597+
/**
6598+
* @param list<Type> $types
6599+
*/
6600+
#[DataProvider('dataIntersectManyAccessoryTypesSharingAUnionBaseType')]
6601+
public function testIntersectManyAccessoryTypesSharingAUnionBaseType(array $types, string $expectedTypeDescription): void
6602+
{
6603+
$startTime = microtime(true);
6604+
$result = TypeCombinator::intersect(...$types);
6605+
$elapsedTime = microtime(true) - $startTime;
6606+
6607+
$this->assertSame($expectedTypeDescription, $result->describe(VerbosityLevel::precise()));
6608+
$this->assertLessThan(self::EXPONENTIAL_BLOWUP_TIME_BUDGET_LIMIT, $elapsedTime);
6609+
}
6610+
6611+
public function testIntersectRepeatedUnions(): void
6612+
{
6613+
$union = new UnionType([new IntegerType(), new StringType()]);
6614+
$result = TypeCombinator::intersect($union, $union, $union);
6615+
6616+
$this->assertSame('int|string', $result->describe(VerbosityLevel::precise()));
6617+
}
6618+
65506619
}

tests/bench/data/bug-15061.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15061;
4+
5+
/**
6+
* Every isset() subject narrows the array to hasOffset(), and intersecting n of them
7+
* used to cost 2^n TypeCombinator::intersect() calls: each hasOffset() contributes the
8+
* same `array|ArrayAccess` default base type, and those n identical unions were
9+
* distributed over each other one at a time.
10+
*
11+
* @phpstan-type FooEntity array{
12+
* a?: string,
13+
* b?: string,
14+
* c?: string,
15+
* d?: string,
16+
* e?: string,
17+
* f?: string,
18+
* g?: string,
19+
* h?: string,
20+
* i?: string,
21+
* j?: string,
22+
* k?: string,
23+
* l?: string,
24+
* m?: string,
25+
* n?: string,
26+
* o?: string,
27+
* p?: string,
28+
* q?: string,
29+
* r?: string,
30+
* s?: string,
31+
* t?: string,
32+
* u?: string,
33+
* v?: string,
34+
* w?: string,
35+
* x?: string,
36+
* y?: string,
37+
* z?: string,
38+
* }
39+
*/
40+
final class TestClass
41+
{
42+
43+
public function __invoke(): void
44+
{
45+
/** @var array<string, FooEntity> $entities */
46+
$entities = [];
47+
48+
foreach ($entities as $entity) {
49+
$ok = isset(
50+
$entity['a'],
51+
$entity['b'],
52+
$entity['c'],
53+
$entity['d'],
54+
$entity['e'],
55+
$entity['f'],
56+
$entity['g'],
57+
$entity['h'],
58+
$entity['i'],
59+
$entity['j'],
60+
$entity['k'],
61+
$entity['l'],
62+
$entity['m'],
63+
$entity['n'],
64+
$entity['o'],
65+
$entity['p'],
66+
$entity['q'],
67+
$entity['r'],
68+
$entity['s'],
69+
$entity['t'],
70+
$entity['u'],
71+
$entity['v'],
72+
$entity['w'],
73+
$entity['x'],
74+
$entity['y'],
75+
$entity['z'],
76+
);
77+
78+
if (!$ok) {
79+
continue;
80+
}
81+
}
82+
}
83+
84+
}

0 commit comments

Comments
 (0)