Skip to content

Flatten deep BooleanOr chains in resolveType() to avoid O(n^2) scope narrowing#5992

Merged
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
SanderMuller:perf/flatten-booleanor-resolvetype
Jul 4, 2026
Merged

Flatten deep BooleanOr chains in resolveType() to avoid O(n^2) scope narrowing#5992
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
SanderMuller:perf/flatten-booleanor-resolvetype

Conversation

@SanderMuller

Copy link
Copy Markdown
Contributor

A single || with many instanceof (or comparison) arms is very slow to analyse. On this base a 150-arm chain takes about 48s and a 100-arm chain about 9s.

BooleanOrHandler::resolveType() computes the boolean type of a BooleanOr by taking the type of the left operand and then re-narrowing the whole left chain with filterByFalseyValue() before it looks at the right operand. Because the left operand is itself a BooleanOr, this recurses, and each level re-narrows the entire chain below it. For n arms that is O(n^2) narrowing work stacked on top of the recursion, which is what makes it blow up.

(The issue attributes the cost to scope merging in processExpr(). I instrumented that path and mergeWith() is negligible here; the dominant cost is the recursive re-narrowing in resolveType() described above.)

This flattens the chain in resolveType() and iterates the arms once, threading the falsey scope arm by arm: the chain is true if any arm is true given the previous arms are false, false if every arm is false, and bool otherwise. It only takes this path past the existing BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH, and mirrors the specifyTypesForFlattenedBooleanOr() that already exists on the type-specifying side.

Measured on this base (opcache off, single process):

arms before after
100 8.6s 1.5s
150 47.7s 3.4s

Analysis output is byte-identical before and after on phpstan-src's own src/ (same errors, same counts), so this is a performance-only change. A regression test in nsrt/bug-14477.php covers both the boolean type of the chain and the truthy-branch narrowing.

Closes phpstan/phpstan#14477

…narrowing

resolveType() resolved the boolean type of a BooleanOr by recursing into the
left operand (getType($expr->left)) and re-narrowing the whole left chain with
filterByFalseyValue() at each level. For a chain of n arms that is O(n^2) (and
worse) narrowing work, so a single || with many instanceof/comparison arms made
analysis explode (locally, 150 arms went from ~70s to ~3.5s).

Flatten the chain and iterate the arms once, threading the falsey scope arm by
arm: the whole chain is true if any arm is true (given the previous arms are
false), false if every arm is false, and bool otherwise. This matches the
recursive result (analysis output is byte-identical) at O(n), mirroring the
existing specifyTypesForFlattenedBooleanOr() on the type-specifying side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ondrejmirtes

Copy link
Copy Markdown
Member

Benchmarks belong to tests/bench/data, I'll regenerate the baseline once this is merged.

@staabm

staabm commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Needs a bench test which reproduces slowness without the actual fix

or-chain-resolve-type-blowup.php is a 100-arm instanceof || chain that takes the
recursive resolveType() path without the flattening fix (about 8.5s here) and the
flattened path with it (about 1.3s). It sits alongside the existing
and-chain-truthy-blowup.php and or-chain-falsey-blowup.php benches, covering the
resolveType path at the default BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Added tests/bench/data/or-chain-resolve-type-blowup.php, alongside the existing and-chain-truthy-blowup.php and or-chain-falsey-blowup.php. It is a 100-arm instanceof || chain, which is what makes the per-level narrowing expensive enough to expose the resolveType() O(N^2) at the default BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH (no raised cap needed, unlike the falsey one).

Locally (single process, opcache off) it goes from about 8.5s without the fix to about 1.3s with it, and it reports no errors so the bench only measures analysis time. I left the baseline for you to regenerate on merge.

@ondrejmirtes
ondrejmirtes merged commit 035f580 into phpstan:2.2.x Jul 4, 2026
666 of 671 checks passed
@ondrejmirtes

Copy link
Copy Markdown
Member

Thank you!

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.

O(n²) scope merging in BooleanOrHandler::processExpr for deep || chains with instanceof

3 participants