Skip to content

Performance: super-linear analysis time when a variable is assigned across many === narrowing branches #14869

Description

@SanderMuller

Feature request

Assigning a variable inside many if ($x === <const>) $v = …; branches makes PHPStan's analysis time grow super-linearly in the number of branches. It's a common shape (lexers, state machines, value mappers, factories, generated dispatch tables), so a large such function makes PHPStan disproportionately slow on that one file. I'd like analysis of this shape to stay roughly linear in the number of branches.

Reproduction (single file, level 8, PHP 8.5, PHPStan 2.2.x-dev, result cache cold):

<?php
// generate: php -r '$n=400; $c="<?php\nfunction f(int \$x): string {\n \$v=0;\n"; for($i=0;$i<$n;$i++){$c.=" if (\$x === $i) \$v = \"$i\";\n";} $c.=" return (string) \$v;\n}\n"; file_put_contents("repro.php",$c);'
function f(int $x): string {
    $v = 0;
    if ($x === 0) $v = "0";
    if ($x === 1) $v = "1";
    // ... one such branch per constant ...
    return (string) $v;
}
N (branches) analysis CPU
25 0.46 s
100 0.79 s
400 20.6 s

100 → 400 is 4x the input for ~26x the time. (I didn't use the playground because the analysis exceeds its time limit at these sizes; the one-liner above reproduces it locally.)

What it is NOT (discriminated at N=400): it's specifically the interaction of constant-value === narrowing with assigning a variable, not union-building, not narrowing in general, and not instanceof:

variant CPU
if ($x === $i) $v = … (narrow + assign) 20.6 s the slow case
if (rand()) $v = … (same $v union, no narrowing) 1.9 s union-building is fine
if ($x === $i) return $i; (narrowing, no $v union) 0.6 s narrowing alone is fine
if ($x instanceof C$i) $v = … (instanceof, not ===) 2.7 s instanceof narrowing is fine

Root cause (instrumented TypeCombinator call counts): a call-count explosion in the conditional-expression machinery, not per-call work. On the reproducer, TypeCombinator::intersect is called 2.69M times at N=200 and 21.4M times at N=400 (~8x for 2x input), each on ~3 tiny args. Each === branch records a conditional relating $v to $x, and applying the accumulated conditionals re-intersects $v's growing type once per narrowing op, compounding to a super-linear number of small calls. (It's the conditional application, not holder storage: capping the per-target conditional holders had no effect on the time.)

Related shapes in the same family, for completeness:

  • Literal-union subject !== chain. With /** @param 1|2|…|N $x */ and if ($x !== 1 && $x !== 2 && … && $x !== N), analysis is ~O(N^2.9) (≈7 s at N=200), here dominated by TypeCombinator::remove (≈11M calls at N=200 on ~2-member unions). The plain-string &&/|| version of this is already handled (the BooleanAnd/Or flattening optimisation, guarded by and-chain-truthy-blowup.php / or-chain-falsey-blowup.php); a literal-union subject appears to defeat that path.
  • Large match (true) constant chain. match (true) { $x === 0 => …, $x === 1 => …, … } is ~O(N²) (≈5.7 s at N=400), while match ($value) over literal arms is fine (~0.8 s); it's the per-arm narrowing of the subject in match (true) that accumulates.

Did PHPStan help you today? Did it make you happy in any way?

Yes. This came out of profiling PHPStan against a few real codebases, and along the way it caught a number of genuine type issues. The reproducers and call-count numbers above are offered in the hope they make this one easy to dig into. Thanks for the tool.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions