Repository navigation
Index already normalized union members and overwrite markers instead of scanning them - #6969
Merged
Merged
Conversation
doUnion() skips comparing two members that come from the same flattened union, but finding that out scanned every flattened union for each pair, so normalizing a union of k members cost O(k^3). Repeatedly subtracting classes from object (object~(C1|...|Ck) growing by one class per `||` arm) hit this on every step. The members are now indexed by object id once per union - only when the pairwise loop runs - and each pair is answered by a hash lookup. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-fc8f-7115-81cd-f19bb1fdbf46
Removing a union peels its members off one at a time, a full remove() per member, even when the value removed from is a constant none of them stands for. Deriving conditional guards from the branch set difference asks exactly that at every branch merge of a growing constant chain (remove(3, 0|1|2)). When the value has a FiniteTypeSet key and the union's set is complete, a key the set does not hold proves every member disjoint, so the value comes back unchanged without the per-member walk. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-fc8f-7115-81cd-f19bb1fdbf46
Each write that replaces a variable leaves a marker the previous replacing write of the same slot observes and kills. compileOverwrites() materialized, for every write, the list of every other write's marker it observes and kills, so a variable assigned W times cost O(W^2) in memory and time, and the backward walk then unset all W markers at each write. Only the markers are compiled now, by variable and slot. A write intersects the live set with the markers of its slots when it is reached, walking the smaller of the two, so the live sets, and with them what is reported, stay the same. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-fc8f-7115-81cd-f19bb1fdbf46
ondrejmirtes
force-pushed
the
faster-or-chain-bug-14869
branch
from
November 6, 2026 16:34
7fdc222 to
da9d917
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes
tests/bench/data/or-chain-resolve-type-blowup.phpandtests/bench/data/bug-14869.phpcheaper to analyse, with the same inferred types and errors. Three commits; each one changes the PHP class and its turbo-ext mirror together.Causes
TypeCombinator::doUnion(): skipping pairs of members that come from the same flattened union scanned every flattened union for every pair (isAlreadyNormalized()), so normalizing a union of k members cost O(k³).ObjectWithoutClassType::subtract()rebuildsobject~(C1|…|Ck)with a growing k at every||arm, so this took about 65 % of the or-chain benchmark. The cost predates 2.3.TypeCombinator::remove($ourType, $theirType). The number of conditional holders created is the same before and after that commit; I counted 43,439 holders after merges in bug-14869 and 0 in or-chain on both sides. The extra cost is theremove()calls themselves. In bug-14869, half of them areremove(k, 0|1|…|k-1), which removed the union one member at a time, with a fullremove()per member, only to returnkunchanged.VariableLivenessResolverfrom b1c223b:compileOverwrites()stored, for every write, every other write's overwrite marker twice (once as observed, once as killed). A variable assigned W times therefore cost O(W²) in memory and time, and the backward walk then unset all W markers at each write.Changes
doRemove(), when the value being removed from has aFiniteTypeSetkey and the union's set is complete, a key the set does not hold proves every member disjoint. The value then comes back unchanged without the per-member walk.Instruction counts
Valgrind cachegrind instructions for a full
bin/phpstan analyserun (level 8 + bleedingEdge, OPcache on, JIT off), minus the same run on an empty file at the same commit:Per commit, measured on the base before the rebase:
For reference, 2.2.17 measured 9,322.9 M for or-chain and 1,945.3 M for bug-14869 in the same setup. The rest of the bug-14869 gap comes from other commits.
Wall-clock time
bin/phpstan analyse -c <level 8 + bleedingEdge config> --debug --no-progress <file>, run with hyperfine: 2 warm-up runs, then 10 runs per side. The DI container cache was warm, and the result cache was cleared before every run (--prepare "bin/phpstan clear-result-cache"), so each run really analyses the file. Machine: Amp orb (E2B sandbox), 16 vCPU Intel Xeon @ 2.60 GHz; PHP 8.4.27 NTS, no turbo extension.tests/bench/phpstan.neondoesn't exist, so the config is the onesweep.shuses.The sandbox's wall clock varies by about ±10 % (bug-14869 ranges: 1.134–1.214 s vs 1.145–1.255 s). The 3.4 % instruction-count gain on bug-14869 is below that noise level.
Tests
make testspasses: 23,472 tests, both without and withphpstan_turboloaded (throughPHP_INI_SCAN_DIR).DirectoryWalkerTest::testReplacedDirectoryIsReadAgainflaked once with the extension before the rebase, then passed on reruns; the test is unrelated to this change.tests/smoke.phpALL OK,tests/signature-parity.phpOK (18,467 members compared),bin/side-by-side.phpmethod parity OK. I regenerated onlygenerated/TypeCombinator.handgenerated/VariableLivenessResolver.h. In this environment (PHP 8.4), side-by-side already reports 59 other generated headers as stale on 2.3.x itself.make phpstanreports no errors; phpcs is clean on the changed files.src/,tests/PHPStan/Analyser/nsrtand the DeadCode/Variables/Functions/Methods rule data (10,909 error lines) is byte-identical to 2.3.x. This holds both with and without the extension. The only turbo-mode difference is fourPHPStanTurbo\Runtimeclass.notFound lines, which disappear because the extension provides that class. I excluded files that don't parse on PHP 8.4, plusnsrt/native-types.php, which crashesMissingReturnRuleon 2.3.x too.