Skip to content

flatten deep BooleanOr chains in TypeSpecifier to avoid O(n^2) recursion#5324

Merged
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
SanderMuller:perf/flatten-boolean-or-typespecifier
Mar 29, 2026
Merged

flatten deep BooleanOr chains in TypeSpecifier to avoid O(n^2) recursion#5324
ondrejmirtes merged 1 commit intophpstan:2.1.xfrom
SanderMuller:perf/flatten-boolean-or-typespecifier

Conversation

@SanderMuller
Copy link
Copy Markdown
Contributor

Problem

Long || chains like 'A' === $x || 'B' === $x || ... || 'Z' === $x (common in validation logic, WordPress tag checks, etc.) cause quadratic performance in TypeSpecifier::specifyTypesInCondition().

PHP's left-associative AST turns an 80-arm chain into a depth-80 tree. At each level, filterByFalseyValue($expr->left) recurses into specifyTypesInCondition() on the left subtree, which makes its own filterByFalseyValue() call, and so on. The call count grows as O(n^2).

BooleanOrHandler::processExpr already has a BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4 limit for this reason, but TypeSpecifier had no equivalent.

On tests/bench/data/bug-14207.php (153 lines, 80+ || arms): ~3,000ms to analyse, with 20k filterByFalseyValue and 40k specifyTypesInCondition calls.

Solution

When depth exceeds 8, flatten the chain into leaf expressions and process each against the original scope in O(n):

  • Falsey context: union all arms' SpecifiedTypes
  • Truthy context: intersect all arms' normalized SpecifiedTypes

Trade-offs

  • Each arm is evaluated against the original scope instead of a progressively narrowed scope (where previous arms are known false). For the typical pattern of independent === comparisons this produces identical results, but interdependent conditions across arms lose some precision.
  • Conditional expression holders (processBooleanSureConditionalTypes / processBooleanNotSureConditionalTypes) are not computed for the flattened path, so cross-arm conditional type narrowing is lost for deep chains.
  • Threshold of 8 is a pragmatic choice, high enough that most real code uses the precise recursive path, low enough to catch pathological cases well before they become expensive. The existing BooleanOrHandler uses 4; we use a higher value here since the TypeSpecifier path produces richer type information worth preserving for moderate chains.

Impact

bug-14207 (80+ || chain): 3,056ms → 1,529ms (-50%) (local benchmark)

@phpstan-bot
Copy link
Copy Markdown
Collaborator

You've opened the pull request against the latest branch 2.2.x. PHPStan 2.2 is not going to be released for months. If your code is relevant on 2.1.x and you want it to be released sooner, please rebase your pull request and change its target to 2.1.x.

@SanderMuller SanderMuller changed the base branch from 2.2.x to 2.1.x March 29, 2026 08:07
@SanderMuller SanderMuller force-pushed the perf/flatten-boolean-or-typespecifier branch from 5660bcb to f8fcdc1 Compare March 29, 2026 08:08
@staabm
Copy link
Copy Markdown
Contributor

staabm commented Mar 29, 2026

please add your benchmark in tests/bench/data

@SanderMuller
Copy link
Copy Markdown
Contributor Author

please add your benchmark in tests/bench/data

phpstan-src/tests/bench/data/bug-14207.php is used to benchmark it

@SanderMuller
Copy link
Copy Markdown
Contributor Author

please add your benchmark in tests/bench/data

CI benchmark results
(https://github.com/phpstan/phpstan-src/actions/runs/23704757474/job/69054642710?pr=5324 and
https://github.com/phpstan/phpstan-src/actions/runs/23704757474/job/69054642695?pr=5324):

Benchmark PR Baseline Change
bug-14207 1.233s 3.235s -61.9%
bug-10772 385ms 511ms -24.6%
bug-8146b 815ms 1.015s -19.8%
bug-7901 694ms 858ms -19.2%
bug-8147 532ms 657ms -19.1%
bug-9690 258ms 319ms -18.9%
bug-14207-and 741ms 912ms -18.8%
bug-8215 666ms 819ms -18.7%
bug-11913 141ms 172ms -18.0%
bug-12159 180ms 218ms -17.4%
bug-7140 26ms 31ms -16.6%
bug-7903 1.670s 2.000s -16.5%
bug-8503 196ms 233ms -15.9%
bug-14319 701ms 831ms -15.6%
bug-7581 292ms 346ms -15.6%
bug-13933 315ms 373ms -15.5%
bug-10979 696ms 822ms -15.3%
bug-5081 2.217s 2.609s -15.0%
bug-1388 85ms 100ms -14.8%
bug-12800 1.141s 1.338s -14.8%
bug-6265 156ms 182ms -14.7%
bug-8146a 113ms 132ms -14.4%
bug-11263 623ms 727ms -14.3%
bug-12671 484ms 564ms -14.2%
bug-1447 19ms 22ms -14.1%
bug-13310 45ms 52ms -14.1%
bug-6948 33ms 39ms -13.7%
bug-11297 1.405s 1.625s -13.5%
bug-10538 981ms 1.133s -13.4%
bug-13685 15ms 17ms -12.0%
bug-6442 2.8ms 3.2ms -11.2%
bug-6936 63ms 70ms -10.1%
bug-13352 2.722s 3.008s -9.5%
bug-7214 2.4ms 2.7ms -9.4%
bug-10147 0.7ms 0.8ms -8.4%
bug-3686 11ms 12ms -7.6%
bug-11283 858ms 919ms -6.7%
bug-7637 20ms 21ms -6.8%
bug-13218 22ms 23ms -6.5%
bug-12787 6.6ms 7.0ms -5.1%
bug-5231_2 14ms 15ms -5.0%
bug-4308 2.9ms 3.1ms -4.5%
bug-4300 5.9ms 6.2ms -4.8%
bug-5390 1.5ms 1.4ms +3.1%
bug-5231 17ms 16ms +1.1%


// For deep BooleanOr chains, flatten and process all arms at once
// to avoid O(n^2) recursive filterByFalseyValue calls
if (BooleanAndHandler::getBooleanExpressionDepth($expr) > 8) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should reference the same constant the other places do

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. What do you think about 4 vs 8 as the threshold?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's okay

@SanderMuller SanderMuller force-pushed the perf/flatten-boolean-or-typespecifier branch from f8fcdc1 to 56511e8 Compare March 29, 2026 12:07
@ondrejmirtes ondrejmirtes merged commit 79212f5 into phpstan:2.1.x Mar 29, 2026
655 of 656 checks passed
@ondrejmirtes
Copy link
Copy Markdown
Member

Thank you.

@ondrejmirtes
Copy link
Copy Markdown
Member

BTW I hope this will all be obsolete once I finish #5224. Feel free to read the description.

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.

4 participants