Flatten deep BooleanOr chains in resolveType() to avoid O(n^2) scope narrowing#5992
Conversation
…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>
|
Benchmarks belong to tests/bench/data, I'll regenerate the baseline once this is merged. |
|
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>
|
Added 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. |
|
Thank you! |
A single
||with manyinstanceof(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 aBooleanOrby taking the type of the left operand and then re-narrowing the whole left chain withfilterByFalseyValue()before it looks at the right operand. Because the left operand is itself aBooleanOr, this recurses, and each level re-narrows the entire chain below it. Fornarms 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 andmergeWith()is negligible here; the dominant cost is the recursive re-narrowing inresolveType()described above.)This flattens the chain in
resolveType()and iterates the arms once, threading the falsey scope arm by arm: the chain istrueif any arm istruegiven the previous arms arefalse,falseif every arm isfalse, andboolotherwise. It only takes this path past the existingBOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH, and mirrors thespecifyTypesForFlattenedBooleanOr()that already exists on the type-specifying side.Measured on this base (opcache off, single process):
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 innsrt/bug-14477.phpcovers both the boolean type of the chain and the truthy-branch narrowing.Closes phpstan/phpstan#14477