Skip to content

Speed up the falsey isset() fold over many optional offsets - #6971

Merged
ondrejmirtes merged 6 commits into
2.3.xfrom
bug-15061-isset-fold
Nov 6, 2026
Merged

ondrejmirtes merged 6 commits into
2.3.xfrom
bug-15061-isset-fold

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Makes tests/bench/data/bug-15061.php fast again after 77527df "Narrow falsey isset() on an optional constant-array offset", without changing what that narrowing infers.

Cause

!isset($a['k']) on an optional, non-nullable key now removes HasOffsetType('k') from the subject. For the bench's 26-argument isset() on an all-optional 26-key shape, that gives every step of the falsey conjunction fold a removal for $entity. The removal grows into (non-empty-array&hasOffset(a..i))|(ArrayAccess&hasOffset(a..i)), and it is applied at every step. With bleedingEdge, the branch-union augment also turns the subject into the union of the per-key shapes. Profiling showed that the extra time went mostly into these generic paths, not into that union of shapes:

  • TypeCombinator::doRemove() removed a union from mixed one member at a time. It asked each member about the mixed the earlier members left, which for hasOffset() means MixedType::isOffsetAccessible() over everything already subtracted, once per member. That is quadratic. Without bleedingEdge this is the dominant cost (the subject's native type is mixed).
  • The fold computed every step twice. It asked each step for its falsey scope, and then the next step wrapped it in a fresh memo and asked again.
  • IntersectionType::tryRemove() intersected the members again even when removing changed none of them.
  • When removing a union from a union changed no member, doRemove() peeled the removed union off member by member anyway. That repeats the same removals, and ObjectType::isSuperTypeOf() describes the whole union of shapes for its cache key along the way.
  • ObjectType::isOffsetAccessible() asked about the extra offset-accessible classes even for an ArrayAccess object. array|ArrayAccess is the default base type of hasOffset(), so every such comparison paid for it.
  • TypeCombinator::processArrayAccessoryTypes() asked an intersection for its iterable value type once per hasOffset() member.

The extra conditional-expression guards for $ok are about 1% of the cost.

Changes

One commit per change, each with its turbo-ext mirror:

  1. Remove a union from mixed by subtracting its members at once. mixed is the top type, so removing a member is exactly subtracting it, unless it is subtracted already. Members that mixed already subtracts are skipped, and the rest are subtracted together. The skip keeps mixed-subtract.php unchanged: without it, re-unioning array{} into the subtracted array<mixed, mixed> describes it as array<mixed>.
  2. Answer each step of the isset() conjunction fold once. Each step is memoized where it is built, and the same memo serves both asks.
  3. Keep an intersection when removing a type changes none of its members.
  4. Do not check the extra offset-accessible classes of an ArrayAccess object. This cannot change any result. The old code was isInstanceOf(ArrayAccess)->or(isExtraOffsetAccessibleClass()). TrinaryLogic::or() takes the maximum, and yes is the maximum, so when the first operand is yes the result is yes whatever the second one says. isExtraOffsetAccessibleClass() only reads class reflection and has no side effects, so skipping the call changes nothing else either. The C++ mirror returns early the same way.
  5. Ask an intersection for its iterable value type once per union of arrays. The value is the same for every hasOffset() member, so it is now fetched lazily, once per array type.
  6. Do not peel a union off a union none of whose members changed. A union fromType already removed the whole union from each member. Removing a part of it cannot change a member that removing all of it did not change.

Overlap with another PR

Commit 1 overlaps with ac73303e0 "Subtract a union from plain mixed at once" in another PR. Both change how TypeCombinator::doRemove() removes a union from mixed. That version was deliberately narrowed to plain mixed minus a plain union, because a broader one changed a description in mixed-subtract.php. This one applies to any non-template mixed (with or without subtracted types) and keeps that test unchanged by skipping members the subtracted type already covers. Whichever of the two lands second should be rebased onto the other.

Numbers

Net instruction counts are file minus _empty.php, measured with cachegrind via a sweep of all 86 tests/bench/data files (bleedingEdge, level 8, opcache on, JIT off, PHP 8.4.27). Before is origin/2.3.x bcca2b0, after is this branch. Only the files listed changed by 1% or more. No file got slower by more than 0.03%.

File Before After Change
bug-15061.php 6.530 G 4.086 G −37.4%
bug-14319.php 1.699 G 1.439 G −15.3%
bug-14972.php 11.752 G 10.811 G −8.0%
impure-call-columns.php 5.945 G 5.473 G −7.9%
wordpress-user.php 4.223 G 3.942 G −6.7%
bug-6265.php 1.837 G 1.741 G −5.2%
bug-12159.php 3.623 G 3.434 G −5.2%
bug-13352.php 14.498 G 13.878 G −4.3%
bug-14624.php 17.393 G 16.670 G −4.2%
bug-11283.php 2.335 G 2.264 G −3.1%
bug-14207.php 1.529 G 1.486 G −2.9%

Per commit on bug-15061.php (measured before rebasing onto bcca2b0, same method): 6.53 → 6.53 (commit 1 only matters without bleedingEdge; there it measured 10.55 → 5.48 G) → 5.63 → 5.41 → 4.91 → 4.54 → 4.09 G. For reference, head with 77527df reverted measures 3.23 G, and this branch with 77527df reverted measures 2.56 G. So the narrowing itself still costs about 1.5 G, down from 3.3 G. Most of what remains is TypeCombinator re-normalizing the growing hasOffset() intersection at each fold step, plus the branch-union work the narrowing actually does.

Wall-clock times

Median of 7 runs after 2 warm-up runs, using hyperfine with php bin/phpstan analyse -c <config> --debug <file>. tests/bench/phpstan.neon does not exist on 2.3.x, so the config includes conf/bleedingEdge.neon with level 8, like BenchCase. Machine: an Amp orb (E2B sandbox VM, Intel Xeon @ 2.60GHz, 16 vCPUs), PHP 8.4.27 NTS without the turbo extension. Each time includes about 1 s of boot. The run-to-run spread on this VM is about ±0.1 s, so only the bug-15061.php difference is clearly beyond noise. The instruction counts above are the reliable measure.

File Before After Change
bug-15061.php 1.642 s ± 0.098 1.215 s ± 0.100 −26.0%
bug-14319.php 1.124 s ± 0.081 1.087 s ± 0.069 −3.3%
bug-14972.php 2.401 s ± 0.134 2.393 s ± 0.151 −0.3%
impure-call-columns.php 1.818 s ± 0.110 1.694 s ± 0.131 −6.8%
wordpress-user.php 1.408 s ± 0.130 1.506 s ± 0.100 +7.0%
bug-6265.php 1.057 s ± 0.122 1.206 s ± 0.057 +14.0%
bug-12159.php 1.236 s ± 0.070 1.263 s ± 0.088 +2.1%
bug-13352.php 3.104 s ± 0.191 3.199 s ± 0.281 +3.1%
bug-14624.php 3.090 s ± 0.194 2.883 s ± 0.209 −6.7%
bug-11283.php 1.310 s ± 0.071 1.284 s ± 0.079 −2.0%
bug-14207.php 1.047 s ± 0.117 1.108 s ± 0.082 +5.8%

Tests

  • Full paratest suite in PHP mode: green (23472 tests).
  • Full paratest suite with turbo-ext/phpstan_turbo.so loaded: green apart from two file-timing tests, DirectoryWalkerTest::testListingsSeeAddedAndRemovedFiles and ResultCacheManagerCollectedDataTest::testFileChangedAndChangedBackDuringTheAnalysisIsAnalysedAgain. Each failed once under the 16-process load, in different runs, and passes 3/3 when run alone. They are unrelated to this change.
  • turbo-ext/tests/smoke.php: ALL OK. turbo-ext/tests/signature-parity.php: OK.
  • turbo-ext/bin/side-by-side.php: method parity passes. It also reports 59 generated headers as stale, and an untouched 2.3.x checkout reports the same 59 in this environment.
  • The bench file and dumpType() / dumpNativeType() checks of the falsey and truthy branches give byte-identical output to 2.3.x, with and without bleedingEdge, and with the turbo extension on and off.
  • phpcs and PHPStan (phpstan.neon.dist) are clean on the changed files.

Removing a union peeled its members one at a time. From mixed, each
member was first asked whether it is a supertype of the mixed the
earlier members left, which walks everything subtracted so far - for a
hasOffset() member that is isOffsetAccessible() of a mixed subtracting
a whole intersection of hasOffset() types, once per member. The falsey
isset() of the bench's 26-key shape removes such unions from the
subject's native mixed type, so that cost grew quadratically with the
key count.

mixed is the top type, so removing a member is exactly subtracting it
unless it is subtracted already, and the members of a union are
distinct, so only what mixed subtracted before can cover one. The
members mixed does not subtract yet are now subtracted together, with
no change in the inferred types of the test suite or the bench.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-ed02-7078-a72e-a1e527d957a3
The fold of a multi-subject isset() in a non-true context asked every
step's narrowing for the step's falsey scope, then the next step wrapped
it in a fresh memo and asked it again as its left operand - so every
step was computed twice. Each step is now memoized where it is built
and the same memo serves both asks.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-ed02-7078-a72e-a1e527d957a3
IntersectionType::tryRemove() intersected the members again after
removing the type from each of them, even when no member changed -
re-reducing every pair of members only to arrive at the same
intersection. Removing hasOffset('k') from an intersection of many
other hasOffset() types never changes a member, and the falsey isset()
of many optional offsets does that once per offset.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-ed02-7078-a72e-a1e527d957a3
…ject

ObjectType::isOffsetAccessible() is yes for an ArrayAccess object
whatever the extra classes say, but it asked about each of them anyway.
Every hasOffset() compared with an ArrayAccess object - the default
base type of hasOffset() is array|ArrayAccess - paid for it.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-ed02-7078-a72e-a1e527d957a3
processArrayAccessoryTypes() turned each hasOffset() member of an array
intersection into hasOffsetValue() with the intersection's iterable
value type, asking for it once per member. It is the same for every
member, and the intersection computes it from all of its members, so a
union with an array of n hasOffset() members cost O(n^2).

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-ed02-7078-a72e-a1e527d957a3
Removing a union from a union first removes the whole of it from each
member. When no member changed, doRemove() went on to remove the members
of the removed union one at a time anyway - repeating the same removals
from every member, and asking each removed member about the whole union
in between, which describes it for ObjectType's cache key. Removing a
part of what changed no member cannot change one either.

The falsey isset() of many optional offsets removes the intersection of
their hasOffset() types from the union of shapes each such check leaves.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-ed02-7078-a72e-a1e527d957a3
@ondrejmirtes
ondrejmirtes merged commit 076ddba into 2.3.x Nov 6, 2026
1037 of 1043 checks passed
@ondrejmirtes
ondrejmirtes deleted the bug-15061-isset-fold branch November 6, 2026 16:24
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