Skip to content

Intersect an intersection's mapped members at once unless two are unions - #6663

Merged
ondrejmirtes merged 2 commits into
2.3.xfrom
intersection-type-n-ary-intersect
Oct 3, 2026
Merged

ondrejmirtes merged 2 commits into
2.3.xfrom
intersection-type-n-ary-intersect

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

IntersectionType::traverse(), traverseSimultaneously() and the intersectTypes() helper (used by tryRemove() and friends) folded the mapped members back together two at a time. Each step re-reduces the growing intersection pairwise, so rebuilding n members costs O(n³) pair checks instead of the O(n²) of a single TypeCombinator::intersect() over all of them.

The fold exists so that two or more unions are not distributed over each other at once (a79f4ca, b26d08c). With at most one union operand there is nothing to multiply, so those intersections now go through the n-ary call. The native mirror in turbo-ext/src/IntersectionType.cpp gets the same change.

Why now

tests/bench/data/bug-15061.php got ~3x slower on 2.3.x with 77527df (Narrow falsey isset() on an optional constant-array offset, found by bisecting). The 26-offset isset() narrows the native mixed subject to (non-empty-array & hasOffset(...) ×25) | (ArrayAccess & …). Since that commit, the falsey narrowing removes hasOffset() of each optional key, and every removal rebuilds that 26-member intersection through the fold. 6888887 already won part of it back.

Numbers

User CPU, bin/phpstan analyse -l 8 <file> --debug, hyperfine, 12 runs, same session. Analysis cost is the file's time minus an empty file's:

base this PR
bug-15061, no extension 0.736 s 0.544 s (−26%)
bug-14462, no extension 0.281 s 0.288 s
bug-15061, turbo loaded 0.162 s 0.169 s

For reference, bug-15061 cost 0.44–0.47 s on 2.2.x. What's left of the gap is the extra falsey narrowing itself.

Self-analysis of src/Type (no extension, single process, 4 ABBA pairs) is neutral: mean user CPU 21.68 s → 21.67 s, with identical errors.

Verification

  • make tests passes with and without the extension loaded (22,485 tests, no expectation changes).
  • turbo-ext strict build, tests/smoke.php, tests/signature-parity.php and bin/side-by-side.php pass. generated/IntersectionType.h is regenerated for the new private PHP method.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JsHiTmsN24NMF7zvEhwbip

ondrejmirtes and others added 2 commits October 3, 2026 11:13
IntersectionType::traverse(), traverseSimultaneously() and the
intersectTypes() helper (tryRemove() and friends) folded the mapped members
back together two at a time. Every step re-reduces the growing intersection
pairwise, so rebuilding n members costs O(n^3) pair checks instead of the
O(n^2) of one TypeCombinator::intersect() over all of them.

The fold exists to keep two or more unions from being distributed over each
other at once (a79f4ca, b26d08c); with at most one union operand there
is nothing to multiply, so those intersections go through the n-ary call.

The isset() of 26 offsets in the bug-15061 bench narrows the native mixed
subject to (non-empty-array & hasOffset(...) x25) | (ArrayAccess & ...), and
since the falsey isset() narrowing removes hasOffset() of optional keys
(77527df), every subject rebuilds that intersection: without the native
extension the file's analysis time drops by 26%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsHiTmsN24NMF7zvEhwbip
@ondrejmirtes
ondrejmirtes force-pushed the intersection-type-n-ary-intersect branch from c9781eb to ac2a474 Compare October 3, 2026 09:13
@ondrejmirtes
ondrejmirtes merged commit ac2a474 into 2.3.x Oct 3, 2026
902 of 922 checks passed
@ondrejmirtes
ondrejmirtes deleted the intersection-type-n-ary-intersect branch October 3, 2026 09:50
$result = TypeCombinator::intersect($result, $types[$i]);
}
return $result;
return self::intersectOperands($types);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we instead pass all args into TypeCombinator::intersect() with a splat operator and do the optimization in the combinator instead?

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.

2 participants