Skip to content

Use pairwise TypeCombinator::intersect folding for conditional expression holders to avoid exponential union distribution#5482

Merged
ondrejmirtes merged 6 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-vj244vy
Apr 16, 2026
Merged

Use pairwise TypeCombinator::intersect folding for conditional expression holders to avoid exponential union distribution#5482
ondrejmirtes merged 6 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-vj244vy

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

PHPStan hangs when analysing code with wildcard constant types (Foo::CATEGORY_*) in array shapes combined with repeated equality checks that narrow the type. This was a regression introduced in 2.1.47 by commit 52704a4 which added "Pass 2: Supertype match" for conditional expression resolution.

Changes

  • Changed src/Analyser/MutatingScope.php (line ~3269): replaced the N-ary TypeCombinator::intersect(...array_map(..., $expressions)) call with a pairwise fold that intersects holder types two at a time
  • Added regression test tests/PHPStan/Analyser/data/bug-14475.php and integration test method testBug14475 in AnalyserIntegrationTest.php
  • Added benchmark test tests/bench/data/bug-14475.php

Analogous cases probed

  • Searched all TypeCombinator::intersect(...) call sites in src/Analyser/ — no other N-ary intersect calls with dynamically-sized arrays of potential UnionTypes were found
  • TypeCombinator::intersect($resultType, ...$accessories) calls at lines 4130/4183 in MutatingScope.php are bounded by MAX_ACCESSORIES_LIMIT and not affected
  • Other intersect(...) calls in src/Type/TypeUtils.php, src/Reflection/Type/IntersectionType*.php, etc. are bounded by PHP syntax or reflection constraints

Root cause

When filterBySpecifiedTypes() processes matched conditional expressions, it collects all matching ConditionalExpressionHolder objects for each expression string. The Pass 2 supertype matching (introduced in 52704a4) caused more holders to match than Pass 1's exact matching.

For the reproduction case ($input['category'] with 25 possible constant values and 5 equality checks), 5 holders matched, each containing a UnionType of ~24 constant strings (each missing a different narrowed value).

The original code called TypeCombinator::intersect(union_24a, union_24b, union_24c, union_24d, union_24e). Due to the distributive law in intersect (A & (B|C)(A&B) | (A&C)), this recursively expanded to 24^5 ≈ 8 million intersect operations, causing the hang.

The fix changes this to pairwise folding: intersect(intersect(intersect(intersect(union_24a, union_24b), union_24c), union_24d), union_24e). Each pairwise intersection reduces the union to its common members before proceeding, so the total work is roughly 2424 + 2324 + 2224 + 2124 ≈ 2,160 operations — a ~3,600x improvement.

Intersection is associative, so pairwise folding produces the same result as N-ary intersection.

Test

  • Integration test: testBug14475 in AnalyserIntegrationTest — reproduces the exact code from the issue (array shape with wildcard constant types, repeated equality checks) and verifies analysis completes without errors
  • Benchmark test: tests/bench/data/bug-14475.php — same reproduction case for performance regression tracking

Fixes phpstan/phpstan#14475

phpstan-bot and others added 2 commits April 16, 2026 09:34
…ession holders to avoid exponential union distribution

- Change N-ary `TypeCombinator::intersect(...$allHolderTypes)` to pairwise
  folding in `MutatingScope::filterBySpecifiedTypes()` when processing
  matched conditional expressions
- The N-ary call caused exponential blowup via the distributive law
  (A & (B|C) -> (A&B)|(A&C)) when multiple holders had large UnionTypes
  (e.g. 24^5 = ~8M combinations with 5 holders of 24 constant strings)
- Pairwise folding produces the same result but reduces each step to at
  most N*M comparisons, where M shrinks after each intersection
- Regression introduced in 52704a4 (Pass 2: Supertype match for
  conditional expressions) which allowed more holders to match
- Add regression test and benchmark for phpstan/phpstan#14475
@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-vj244vy branch from 3bb7c25 to 7241b96 Compare April 16, 2026 07:58
@ondrejmirtes
Copy link
Copy Markdown
Member

ondrejmirtes commented Apr 16, 2026

Performance benchmarks

All measurements use --debug (single-process) on the same machine. "2.1.x" is the merge-base commit (e484f01770), "PR" is the branch tip.

Bench file Root cause 2.1.x PR Speedup
bug-14475.php N-ary intersect() of conditional expression holder types 27.5s 2.3s 12×
in-array-intersect-blowup.php N-ary intersect() of per-array guaranteed value unions in !in_array() 15.4s 2.1s
phpdoc-intersection-blowup.php N-ary intersect() of union type aliases in PHPDoc A&B&C&D&E 17.4s 2.1s
implode-optional-keys-blowup.php ¹ implode() calling getAllArrays() — 2^N objects for N optional keys 9.2s 2.2s
finite-types-optional-keys-blowup.php ¹ getFiniteTypes() calling getAllArrays() — 2^N objects 5.2s 1.1s
flatten-types-optional-keys-blowup.php ¹ flattenTypes() calling getAllArrays() — 2^N objects 7.4s 1.1s

¹ Measured with the getAllArrays() power-set limit raised from 10 to 20 on both branches to expose the underlying algorithmic issue. At the current limit of 10 the CALCULATE_SCALARS_LIMIT bail-out masks the problem. The fixes eliminate the 2^N ConstantArrayType allocation entirely by processing keys incrementally with early bail-out, making it safe to raise the limit in the future.

Summary of all fixes

Pairwise TypeCombinator::intersect folding (prevents M^N distributive-law blowup):

  • MutatingScope::filterBySpecifiedTypes() — conditional expression holders
  • InArrayFunctionTypeSpecifyingExtension::computeNeedleNarrowingType() — per-array guaranteed value types
  • TypeNodeResolver::resolveIntersectionTypeNode() — PHPDoc intersection of type aliases
  • IntersectionType::intersectTypes(), traverse(), traverseSimultaneously() — preventive
  • TypeCombinator::mergeIntersectionsForUnion() — preventive
  • IntersectionTypeMethodReflection::getVariants(), getThrowType() — preventive
  • IntersectionTypePropertyReflection 4 property type methods — preventive

Avoid 2^N getAllArrays() expansion (incremental key processing with early bailout):

  • ImplodeFunctionReturnTypeExtension::inferConstantType()
  • ConstantArrayType::getFiniteTypes()
  • TypeUtils::flattenTypes()

ondrejmirtes and others added 4 commits April 16, 2026 13:50
…sion

Process keys incrementally instead of generating all power-set variants
upfront via `getAllArrays()`. For each key, fork partial results for
optional keys and bail early when the count exceeds
`CALCULATE_SCALARS_LIMIT`. This avoids allocating 2^N
`ConstantArrayType` objects for N optional keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Types()`

Process keys incrementally instead of generating all power-set variants
upfront via `getAllArrays()`. For each key, fork partial
`ConstantArrayTypeBuilder` instances for optional keys and finite value
variants, bailing early when the count exceeds `CALCULATE_SCALARS_LIMIT`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…)` expansion

Estimate the total power-set variant count before calling
`getAllArrays()`. When a `ConstantArrayType` has more than ~14 optional
keys (16384+ variants), return the type as-is instead of expanding.
Also apply pairwise `TypeCombinator::intersect` folding in the
combination loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-vj244vy branch from 259ee72 to 4d74d2a Compare April 16, 2026 11:52
@ondrejmirtes ondrejmirtes merged commit 4390ece into phpstan:2.1.x Apr 16, 2026
128 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-vj244vy branch April 16, 2026 11:52
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.

2 participants