Repository navigation
Remove a union of values in one pass, and key types by value instead of by description - #6427
Merged
Merged
Conversation
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
force-pushed
the
type-operation-perf
branch
from
September 12, 2026 15:13
bd65a6c to
878c339
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.
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 intonon-empty-arrayand 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, andObjectType::subtract()stops normalising a union thatchangeSubtractedType()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.remove(Enum|null, union of 250 cases)remove(Enum, union of 250 cases)remove(250 strings, 125 strings)remove(250 strings, 250 strings)Two hot paths built a description only to use it as a key
RecursionGuard::run()keys every operation it guards withdescribe(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 alldescribe()calls in a profile of analysing Slevomat.ObjectTypenow remembers it.UnionType::getFiniteTypes()uniqued its members by description. They are finite types, soFiniteTypeSetalready 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/Typeandtests/PHPStan/Rulesall green at both commits.🤖 Generated with Claude Code
https://claude.ai/code/session_01LDqrN1pcN3qWc7xzKfkJaF