Skip to content

Describe array shapes once while sorting a union, memoize a union's loosely equal types - #6973

Merged
ondrejmirtes merged 2 commits into
2.3.xfrom
faster-loose-equal-switch
Nov 6, 2026
Merged

ondrejmirtes merged 2 commits into
2.3.xfrom
faster-loose-equal-switch

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

tests/bench/data/bug-7581.php got 47 % slower with 9d55d96 ("Narrow loose comparisons by the values each side can loosely equal"). Counting only its own analysis (net instructions, see below): 3.98 G on 4563fe2, 5.82 G on 9d55d96. This PR takes it to 4.99 G and keeps the inferred types and errors unchanged.

Cause

The benchmark is a switch ($tag) over 33 case 'Ax': arms inside foreach ($parsed as $tag => $val). Since 9d55d96, $tag == 'Ax' narrows $tag (int|string) to 'Ax' inside each arm; before, $tag stayed int|string. That is correct and more precise, but it changes what the branch merges do:

  • Every merge of an arm into the switch's accumulated scope now pairs $tag and $data in both directions: $tag='Ak' ⇒ $data, and $data=… ⇒ $tag. It creates 325 conditional expressions instead of 54. Each pair runs isSuperTypeOf() and TypeCombinator::remove() over unions of about 33 array shapes. ConditionalExpressionHolder::getKey() also describes such a union.
  • UnionTypeHelper::sortTypes(), which describe() uses, orders constant arrays by their description. Its comparator described both sides of every comparison, so each shape was described O(log n) times. This was the largest single part of the added time.
  • getPossiblyLooselyEqualType() / getAlwaysLooselyEqualType() of the int|string key were rebuilt from scratch for the same union instance: 99 times in IdenticalNarrowingHelper::createLooselyEqualTypes() (both branches, three loop passes) and again in resolveEqualType().

Changes

  • Describe each array shape once while sorting a union. sortTypes() keeps the descriptions of constant arrays in a table for the duration of the sort. The other branches still call describe() directly, because memoizing their cheap descriptions cost more than it saved (+0.7 % on and-chain-resolve-type-blowup). The native mirror in turbo-ext/src/UnionTypeHelper.cpp makes the same describe() calls.
  • Memoize the loosely equal types of a union per PHP version. UnionType keeps both results per version id, like its other memos ($cachedDescriptions, $isNull, …). UnionType.cpp mirrors it, with the two new property slots in the generated headers of UnionType, TemplateUnionType and TemplateBenevolentUnionType.

For reviewers: the first commit memoizes describe() inside UnionTypeHelper::sortTypes(). It keeps the existing describe-based ordering of constant arrays, which CLAUDE.md discourages ("never sort, compare, or transform types via describe()"). It does not add a new use of describe() for ordering, but it does make that ordering cheaper to keep. You may prefer to key the sort by the types instead, for example by a ConstantArrayType comparison of key and value types. That would change the order union members are described in, so it would be a separate change.

The remaining ~1 G over 4563fe2 is the extra conditional-expression work that comes with the more precise narrowing. That is mostly ConstantArrayType::isSuperTypeOf() pairs inside UnionType::isSuperTypeOf() / tryRemove(). Cutting it means making comparisons between unions of array shapes sub-quadratic, which is out of scope here.

Instruction counts

Measured with Valgrind cachegrind through sweep.sh: PHP 8.5.12 with OPcache, no turbo extension, a level 8 config including bleedingEdge.neon. Net = instructions for the file minus instructions for an empty file at the same commit, so boot is excluded. All 86 files in tests/bench/data were measured on bcca2b08a (2.3.x) and this branch. Every file not listed changed by less than ±0.2 %.

File 2.3.x This PR Change
bug-7581.php 5.838 G 4.987 G −14.6 %
bug-14462.php 3.101 G 2.929 G −5.5 %
bug-15061.php 6.425 G 6.227 G −3.1 %
bug-8146a.php 1.870 G 1.859 G −0.6 %
bug-10538.php 20.146 G 20.047 G −0.5 %
bug-8147.php 3.821 G 3.832 G +0.3 %
finite-types-optional-keys-blowup.php 0.258 G 0.259 G +0.3 %
phpdoc-intersection-blowup.php 0.740 G 0.742 G +0.3 %
big-constant-int-union.php 0.542 G 0.544 G +0.3 %
bug-7901.php 5.534 G 5.554 G +0.4 %
hash-key-lookup.php 3.637 G 3.651 G +0.4 %
and-chain-truthy-blowup.php 0.395 G 0.397 G +0.4 %
or-chain-falsey-blowup.php 0.384 G 0.386 G +0.4 %

For reference, bug-7581.php was 3.977 G on 4563fe2 and 5.821 G on 9d55d96. By commit: the sortTypes change takes it to 5.36 G and the union memo to 4.99 G. The +0.3–0.4 % files come from the per-sort memo table; none of them changed more than that.

Wall-clock times

php8.5 bin/phpstan analyse -c <config> --debug --no-progress --memory-limit=-1 <file>, run from each side's own checkout. tests/bench/phpstan.neon does not exist on 2.3.x, so the config is the one sweep.sh generates (bleedingEdge.neon, level 8, inferPrivatePropertyTypeFromConstructor, a separate tmpDir per side). Each side got two warm-up runs, then 11 runs per side alternating base/branch, and the table shows the median. A first pass with hyperfine (2 warm-ups, 7 runs, all base runs then all branch runs) drifted by up to ±5–10 % between the two blocks on this VM, so these are the interleaved numbers. The machine is an Amp orb: Intel Xeon @ 2.60 GHz, 16 vCPUs, PHP 8.5.12 CLI without OPcache, no turbo extension.

File 2.3.x This PR Change
bug-7581.php 1.567 s 1.445 s −7.7 %
bug-14462.php 1.177 s 1.147 s −2.6 %
bug-15061.php 1.540 s 1.510 s −2.0 %
bug-8146a.php 1.035 s 1.019 s −1.5 %
bug-10538.php 3.348 s 3.350 s +0.1 %
hash-key-lookup.php 1.136 s 1.168 s +2.8 %
bug-7901.php 1.379 s 1.369 s −0.7 %
bug-8147.php 1.250 s 1.249 s −0.1 %

hash-key-lookup's +2.8 % is larger than its +0.4 % instruction change and within the run-to-run spread seen on this machine.

Tests

  • make tests: green except DirectoryWalkerTest::testListingsSeeAddedAndRemovedFiles, which fails intermittently on unchanged code and passed on rerun.
  • The full suite with the rebuilt turbo extension loaded (PHP_INI_SCAN_DIR): green except ResultCacheManagerCollectedDataTest::testFileChangedAndChangedBackDuringTheAnalysisIsAnalysedAgain. It also fails intermittently on unchanged 2.3.x (3 of 20 runs under load) and passed on rerun.
  • make phpstan and make cs: green.
  • turbo-ext checks:
    • turbo-ext/tests/smoke.php: ALL OK.
    • turbo-ext/tests/signature-parity.php: OK.
    • php8.5 turbo-ext/bin/side-by-side.php: OK. The generated headers are current under PHP 8.5.
  • Raw error output on bug-7581.php and three other bench files is identical to 2.3.x, and identical with and without the extension.

usort() compares a member with several others, and the comparator
described both sides of every comparison. Constant arrays are ordered by
their description, so sorting a union of many array shapes described
each shape O(log n) times, which dominated describe() of such unions.
The comparator now reads those descriptions from a table filled once per
shape for the sort.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-e5a2-75fe-9eeb-fa489813ac89
A loose comparison asks the other side for the values it can, or always
does, loosely equal, once for its type and once per narrowed branch, and
again in every pass over a loop body. A union like the int|string key of
an array is the same instance in all those places, so the result is now
kept on the union.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-e5a2-75fe-9eeb-fa489813ac89
@ondrejmirtes
ondrejmirtes merged commit c985b6b into 2.3.x Nov 6, 2026
293 checks passed
@ondrejmirtes
ondrejmirtes deleted the faster-loose-equal-switch branch November 6, 2026 16:34
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.

1 participant