Skip to content

Remove a union of values in one pass, and key types by value instead of by description - #6427

Merged
ondrejmirtes merged 2 commits into
2.2.xfrom
type-operation-perf
Sep 12, 2026
Merged

ondrejmirtes merged 2 commits into
2.2.xfrom
type-operation-perf

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Two independent costs in the type system, both pre-existing and both hit hard by any codebase with large enums.

Removing a union peeled it one member at a time

Peeling is what keeps the result precise — removing one member can narrow the type so that removing the next achieves more, and an unguarded whole-union path loses that (it turned four *NEVER* expectations into non-empty-array and friends). But it is quadratic whenever removing a member rebuilds the from-type, and subtracting a sealed hierarchy from its own type rebuilds the whole subtraction once per member.

The whole union is now offered first only where the subtraction is a set difference and no order of removals can sharpen it: a sealed hierarchy, or a union — whose members each weigh the same question again, so a member that needs the removals one at a time still gets them that way. Alongside that, UnionType::tryRemove() takes a complete finite value set minus a union of values as one difference, and ObjectType::subtract() stops normalising a union that changeSubtractedType() only flattens and throws away.

The describe(precise()) that keyed the matched subtypes goes with it — an allowed subtype is dropped as it matches, so the matches cannot repeat and never needed keying.

micro-benchmark before after
remove(Enum|null, union of 250 cases) 149.15 ms 0.489 ms
remove(Enum, union of 250 cases) 145.89 ms 0.284 ms
remove(250 strings, 125 strings) 6.06 ms 0.028 ms
remove(250 strings, 250 strings) 8.11 ms 0.047 ms

Two hot paths built a description only to use it as a key

RecursionGuard::run() keys every operation it guards with describe(VerbosityLevel::value()), and that level resolves the class name through the ReflectionProvider — two round-trips per guarded call, for a string that never changes. It was 28.8% of all describe() calls in a profile of analysing Slevomat. ObjectType now remembers it.

UnionType::getFiniteTypes() uniqued its members by description. They are finite types, so FiniteTypeSet already keys them by the value they stand for, which is what the uniquing wants; floats have no such key — equals() does not agree with value identity for them — and keep describing themselves.

Measurements

Analysing Slevomat, turbo on, forward+reverse passes to cancel session drift: 591.15s → 589.12s user CPU (−0.34%), with byte-identical output. The end-to-end number is modest on 2.2.x because it does not yet make the expensive subtraction calls that 2.3.x's conditional guards do — the micro-benchmarks above are what any project with big enums actually pays.

Tests: tests/PHPStan/Analyser, tests/PHPStan/Type and tests/PHPStan/Rules all green at both commits.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LDqrN1pcN3qWc7xzKfkJaF

ondrejmirtes and others added 2 commits September 12, 2026 17:11
Removing a UnionType peeled it one member at a time. Peeling is what keeps
the result precise - removing one member can narrow the type so that removing
the next achieves more - but it is quadratic whenever removing a member
rebuilds the from-type, and subtracting a sealed hierarchy from its own type
rebuilds the whole subtraction once per member.

The whole union is now offered first where the subtraction is a set
difference and no order of removals can sharpen it: a sealed hierarchy, or a
union, whose members each weigh the same question again so that a member
needing them one at a time still gets them that way. UnionType::tryRemove()
takes a complete finite value set minus a union of values as one difference,
and ObjectType::subtract() stops normalising a union that changeSubtractedType()
only flattens and throws away - the describe() that keyed the matches goes
with it, an allowed subtype being dropped as it matches so the matches cannot
repeat.

Removing a 250-case enum from its own type: 149ms -> 0.5ms. Removing 125 of
250 constant strings: 6.1ms -> 0.03ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LDqrN1pcN3qWc7xzKfkJaF
Two hot paths built a description only to use it as a key.

RecursionGuard::run() keys every operation it guards with
describe(VerbosityLevel::value()), and that level resolves the class name
through the ReflectionProvider - two round-trips per guarded call, for a
string that never changes. ObjectType now remembers it.

UnionType::getFiniteTypes() uniqued its members by description. They are
finite types, so FiniteTypeSet keys them by the value they stand for, which
is what the uniquing wants; floats have no such key, equals() not agreeing
with value identity for them, and keep describing themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LDqrN1pcN3qWc7xzKfkJaF
@ondrejmirtes
ondrejmirtes merged commit a88f398 into 2.2.x Sep 12, 2026
454 of 456 checks passed
@ondrejmirtes
ondrejmirtes deleted the type-operation-perf branch September 12, 2026 15:15
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