From f23d80255f4dbf9977cda7c79c425b310b60e2b0 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:08:13 +0000 Subject: [PATCH 1/6] Remove a union from mixed by subtracting its members at once 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 --- src/Type/TypeCombinator.php | 21 +++++++++++++++++++++ turbo-ext/src/TypeCombinator.cpp | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index f42af28f3f2..6d03ed5cfe8 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -142,6 +142,27 @@ public static function doRemove(Type $fromType, Type $typeToRemove): Type } } + // mixed is the top type, so removing a union from it is exactly + // subtracting the members it does not subtract already, all of them at + // once. Peeling them one at a time would ask each member about the mixed + // the earlier members left, which walks everything they subtracted. + if ($fromType instanceof MixedType && !$fromType instanceof TemplateType) { + $subtractedType = $fromType->getSubtractedType(); + $remainingTypes = []; + foreach ($typeToRemove->getTypes() as $unionTypeToRemove) { + if ($subtractedType !== null && $subtractedType->isSuperTypeOf($unionTypeToRemove)->yes()) { + continue; + } + $remainingTypes[] = $unionTypeToRemove; + } + + if ($remainingTypes === []) { + return $fromType; + } + + return $fromType->subtract(count($remainingTypes) === 1 ? $remainingTypes[0] : new UnionType($remainingTypes)); + } + foreach ($typeToRemove->getTypes() as $unionTypeToRemove) { $fromType = self::remove($fromType, $unionTypeToRemove); if ($fromType instanceof NeverType) { diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 0f5f69d4ce0..3b97d3cbcd2 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -642,6 +642,38 @@ class TypeCombinator zv::Val types = getTypes(typeToRemove); PT_FAIL_IF_UNDEF(types); + + /* mixed is the top type: the members it does not subtract + * already are subtracted all at once */ + if (isInstance(fromType, pt_ce_mixed_type)) { + int isTemplate = isInstanceMap(fromType, PT_CLASS_TEMPLATE_TYPE); + PT_FAIL_IF_NEG(isTemplate); + if (!isTemplate) { + zv::Val subtractedType = call(fromType, PT_LC("getsubtractedtype")); + PT_FAIL_IF_UNDEF(subtractedType); + zv::Arr remainingTypes = zv::Arr::create(0); + for (zv::ArrayEntry entry : zv::ArrRef(types.raw())) { + zval *member = entry.value().deref().raw(); + if (!isNull(subtractedType)) { + zend_long covered = callResultTrinary(subtractedType.raw(), PT_LC("issupertypeof"), 1, member); + PT_FAIL_IF_NEG(covered); + if (covered == PT_TRI_YES) continue; + } + remainingTypes.push(zv::Val::copyOf(zv::Ref(member))); + } + uint32_t remainingCount = zv::ArrRef(remainingTypes.raw()).size(); + if (remainingCount == 0) return copy(fromType); + zv::Val subtract; + if (remainingCount == 1) { + subtract = copy(indexOf(remainingTypes.raw(), 0)); + } else { + subtract = unionType(std::move(remainingTypes), false); + PT_FAIL_IF_UNDEF(subtract); + } + return call(fromType, PT_LC("subtract"), 1, subtract.raw()); + } + } + zv::Val current = copy(fromType); for (zv::ArrayEntry entry : zv::ArrRef(types.raw())) { zv::Val next = remove(current.raw(), entry.value().raw()); From 769abffb3f72ca6ad571840f303b4f6a26837b28 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:08:13 +0000 Subject: [PATCH 2/6] Answer each step of the isset() conjunction fold once 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 --- src/Analyser/ExprHandler/IssetHandler.php | 16 ++++++++-------- turbo-ext/src/IssetHandler.cpp | 9 +++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 122fffaa642..0ad3a20cb62 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -218,7 +218,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $accExpr = new Isset_([$expr->vars[0]], $subjectAttributes); - $accTypes = $makeSubjectTypes($expr->vars[0], $varResults[0]); + $accTypes = self::memoizeSubjectTypes($makeSubjectTypes($expr->vars[0], $varResults[0])); $accTruthyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createTruthy())); $accFalseyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createFalsey())); @@ -228,10 +228,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $rightFalseyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createFalsey())); $leftExprNode = $accExpr; - $leftTypes = self::memoizeSubjectTypes($accTypes); + $leftTypes = $accTypes; $leftTruthyScope = $accTruthyScope; $leftFalseyScope = $accFalseyScope; - $accTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( + $accTypes = self::memoizeSubjectTypes(fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, $scope, $ctx, @@ -243,7 +243,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $rightExprNode, $rightTypes, static fn (): MutatingScope => $rightFalseyScope, - ); + )); $accExpr = new BooleanAnd($leftExprNode, $rightExprNode); $accTruthyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createTruthy())); $accFalseyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createFalsey())); @@ -267,10 +267,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } /** - * The narrowing of the conjunction folded so far: every later conjunction - * asks it again as its left operand, with the scopes and contexts it was - * already asked with - answered once per pair instead of re-folding every - * subject before it on each ask. + * The narrowing of the conjunction folded so far: the fold asks it for its + * falsey scope and the next conjunction asks it again as its left operand, + * with the scopes and contexts it was already asked with - answered once + * per pair instead of re-folding the subjects before it on each ask. * * @param Closure(MutatingScope, TypeSpecifierContext): SpecifiedTypes $types * @return Closure(MutatingScope, TypeSpecifierContext): SpecifiedTypes diff --git a/turbo-ext/src/IssetHandler.cpp b/turbo-ext/src/IssetHandler.cpp index 5085a8854fa..5083acee440 100644 --- a/turbo-ext/src/IssetHandler.cpp +++ b/turbo-ext/src/IssetHandler.cpp @@ -556,7 +556,8 @@ class IssetHandler if (UNEXPECTED(attributes.isUndef())) return zv::Val(); zv::Val accExpr = newIsset(firstVar, attributes.raw()); if (UNEXPECTED(accExpr.isUndef())) return zv::Val(); - zv::Val accTypes = pt_native_closure(&subjectTypesBody, handler, chainResults, expr, firstVar, firstResult); + zv::Val firstTypes = pt_native_closure(&subjectTypesBody, handler, chainResults, expr, firstVar, firstResult); + zv::Val accTypes = memoizeSubjectTypes(firstTypes.raw()); zv::Val accTruthyScope = applySpecifiedTypes(afterScope, callNarrowing(accTypes.raw(), afterScope, pt_type_specifier_context_create_truthy())); if (UNEXPECTED(accTruthyScope.isUndef())) return zv::Val(); zv::Val accFalseyScope = applySpecifiedTypes(afterScope, callNarrowing(accTypes.raw(), afterScope, pt_type_specifier_context_create_falsey())); @@ -584,14 +585,14 @@ class IssetHandler ZVAL_COPY_VALUE(&conjunctionCaptures[1], nodeScopeResolver); ZVAL_COPY_VALUE(&conjunctionCaptures[2], expr); ZVAL_COPY_VALUE(&conjunctionCaptures[3], leftExprNode.raw()); - zv::Val leftTypes = memoizeSubjectTypes(accTypes.raw()); - ZVAL_COPY_VALUE(&conjunctionCaptures[4], leftTypes.raw()); + ZVAL_COPY_VALUE(&conjunctionCaptures[4], accTypes.raw()); ZVAL_COPY_VALUE(&conjunctionCaptures[5], accTruthyScope.raw()); ZVAL_COPY_VALUE(&conjunctionCaptures[6], accFalseyScope.raw()); ZVAL_COPY_VALUE(&conjunctionCaptures[7], rightExprNode.raw()); ZVAL_COPY_VALUE(&conjunctionCaptures[8], rightTypes.raw()); ZVAL_COPY_VALUE(&conjunctionCaptures[9], rightFalseyScope.raw()); - accTypes = pt_native_closure_new(&conjunctionBody, 10, conjunctionCaptures); + zv::Val conjunctionTypes = pt_native_closure_new(&conjunctionBody, 10, conjunctionCaptures); + accTypes = memoizeSubjectTypes(conjunctionTypes.raw()); zv::Args andArgv{leftExprNode.raw(), rightExprNode.raw()}; accExpr = pt_type_new(PT_CLASS_BOOLEAN_AND_EXPR, 2, andArgv); From cb7c3792e59ba992d67a5308456f4ae60bd38491 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:08:14 +0000 Subject: [PATCH 3/6] Keep an intersection when removing a type changes none of its members 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 --- src/Type/IntersectionType.php | 18 +++++++++++++++++- turbo-ext/src/IntersectionType.cpp | 26 ++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 72340073543..3aab9fa85ba 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1771,7 +1771,23 @@ public function traverseSimultaneously(Type $right, callable $cb): Type public function tryRemove(Type $typeToRemove): ?Type { - return $this->intersectTypes(static fn (Type $type): Type => TypeCombinator::remove($type, $typeToRemove)); + $removedTypes = []; + $changed = false; + foreach ($this->types as $type) { + $removed = TypeCombinator::remove($type, $typeToRemove); + if (!$removed->equals($type)) { + $changed = true; + } + $removedTypes[] = $removed; + } + + // no member changed, so neither does the intersection - intersecting + // the same members again would only re-reduce every pair of them + if (!$changed) { + return $this; + } + + return self::intersectOperands($removedTypes); } public function arithmeticPower(Type $exponent, PhpVersion $phpVersion): Type diff --git a/turbo-ext/src/IntersectionType.cpp b/turbo-ext/src/IntersectionType.cpp index 1a0eafae53a..4dea955f32f 100644 --- a/turbo-ext/src/IntersectionType.cpp +++ b/turbo-ext/src/IntersectionType.cpp @@ -2131,12 +2131,30 @@ class IntersectionType traversed.intoReturnValue(return_value); } - /* TypeCombinator::remove($type, $typeToRemove) per member, intersected */ + /* TypeCombinator::remove($type, $typeToRemove) per member, intersected + * by intersectOperands() - $this when no member changed */ zv::Val tryRemove(zval *typeToRemove) const { - return intersectTypes([&](zval *type) -> zv::Val { - return combinator2(PT_LC("remove"), type, typeToRemove); - }); + zval *types = this->types(); + if (UNEXPECTED(types == NULL)) return zv::Val(); + zv::Val typesCopy = zv::Val::copyOf(zv::Ref(types)); + zv::Arr removedTypes = zv::Arr::create(zv::ArrRef(typesCopy.raw()).size()); + bool changed = false; + for (zv::ArrayEntry entry : zv::ArrRef(typesCopy.raw())) { + zval *type = entry.value().deref().raw(); + zv::Val removed = combinator2(PT_LC("remove"), type, typeToRemove); + if (UNEXPECTED(removed.isUndef())) return zv::Val(); + if (UNEXPECTED(!zv::Ref(removed.raw()).isObject())) { + zend_type_error("phpstan_turbo: TypeCombinator::remove() must return %s", ptcls::type); + return zv::Val(); + } + int equal = pt_type_call_is_true(Z_OBJ_P(removed.raw()), PT_LC("equals"), 1, type); + if (UNEXPECTED(equal < 0)) return zv::Val(); + if (equal == 0) changed = true; + removedTypes.push(std::move(removed)); + } + if (!changed) return thisValue(); + return intersectOperands(removedTypes.arrRef()); } /* every member's power intersected, unless a member's is an ErrorType, From d0c6a871c98963f09b01a620f30faf991e3bb134 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:08:14 +0000 Subject: [PATCH 4/6] Do not check the extra offset-accessible classes of an ArrayAccess object 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 --- src/Type/ObjectType.php | 9 ++++++--- turbo-ext/src/ObjectType.cpp | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 90cbb62f36b..bab94e27b97 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -1523,9 +1523,12 @@ private function isExtraOffsetAccessibleClass(): TrinaryLogic public function isOffsetAccessible(): TrinaryLogic { - return $this->isInstanceOf(ArrayAccess::class)->or( - $this->isExtraOffsetAccessibleClass(), - ); + $isArrayAccess = $this->isInstanceOf(ArrayAccess::class); + if ($isArrayAccess->yes()) { + return $isArrayAccess; + } + + return $isArrayAccess->or($this->isExtraOffsetAccessibleClass()); } public function isOffsetAccessLegal(): TrinaryLogic diff --git a/turbo-ext/src/ObjectType.cpp b/turbo-ext/src/ObjectType.cpp index 00372680ea3..1e81b93bad3 100644 --- a/turbo-ext/src/ObjectType.cpp +++ b/turbo-ext/src/ObjectType.cpp @@ -1910,6 +1910,7 @@ class ObjectType { zend_long arrayAccess = thisIsInstanceOfLiteral(PT_LC("ArrayAccess")); if (UNEXPECTED(arrayAccess < 0)) return -1; + if (arrayAccess == PT_TRI_YES) return arrayAccess; zend_long extra = isExtraOffsetAccessibleClass(); if (UNEXPECTED(extra < 0)) return -1; return arrayAccess > extra ? arrayAccess : extra; From 637a28fd8ca0ff504798a6290db779db22917a3a Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:08:14 +0000 Subject: [PATCH 5/6] Ask an intersection for its iterable value type once per union of arrays 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 --- src/Type/TypeCombinator.php | 6 +++++- turbo-ext/src/TypeCombinator.cpp | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 6d03ed5cfe8..b23c6eb3c87 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1176,6 +1176,9 @@ private static function processArrayAccessoryTypes(array $arrayTypes): array $isIterableAtLeastOnce[] = $arrayType->isIterableAtLeastOnce(); if ($arrayType instanceof IntersectionType) { + // asked once: the same for every hasOffset() member, and an + // intersection computes it from all of its members + $iterableValueType = null; foreach ($arrayType->getTypes() as $innerType) { if ($innerType instanceof TemplateType) { break; @@ -1184,7 +1187,8 @@ private static function processArrayAccessoryTypes(array $arrayTypes): array continue; } if ($innerType instanceof HasOffsetType) { - $innerType = new HasOffsetValueType($innerType->getOffsetType(), $arrayType->getIterableValueType()); + $iterableValueType ??= $arrayType->getIterableValueType(); + $innerType = new HasOffsetValueType($innerType->getOffsetType(), $iterableValueType); } if ($innerType instanceof HasOffsetValueType) { $accessoryTypes[sprintf('hasOffsetValue(%s)', $innerType->getOffsetType()->describe(VerbosityLevel::cache()))][$i] = $innerType; diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 3b97d3cbcd2..2e362f23175 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -1976,6 +1976,8 @@ class TypeCombinator if (isInstance(arrayType, pt_ce_intersection_type)) { zv::Val innerTypes = getTypes(arrayType); PT_FAIL_IF_UNDEF(innerTypes); + /* the same for every hasOffset() member, asked once */ + zv::Val valueType; for (zv::ArrayEntry entry : zv::ArrRef(innerTypes.raw())) { zv::Val innerType = copy(entry.value().raw()); int isTemplate = isInstanceMap(innerType.raw(), PT_CLASS_TEMPLATE_TYPE); @@ -1987,8 +1989,10 @@ class TypeCombinator if (isInstance(innerType.raw(), pt_ce_has_offset_type)) { zv::Val offsetType = pt_has_offset_type_get_offset_type(Z_OBJ_P(innerType.raw())); PT_FAIL_IF_UNDEF(offsetType); - zv::Val valueType = call(arrayType, PT_LC("getiterablevaluetype")); - PT_FAIL_IF_UNDEF(valueType); + if (valueType.isUndef()) { + valueType = call(arrayType, PT_LC("getiterablevaluetype")); + PT_FAIL_IF_UNDEF(valueType); + } innerType = hasOffsetValueType(offsetType.raw(), valueType.raw()); PT_FAIL_IF_UNDEF(innerType); } From 21d2d0bd40f1cb6e4f999f0ed674523e45f5c4cd Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:20:59 +0000 Subject: [PATCH 6/6] Do not peel a union off a union none of whose members changed 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 --- src/Type/TypeCombinator.php | 7 +++++++ turbo-ext/src/TypeCombinator.cpp | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index b23c6eb3c87..c1bab12d116 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -140,6 +140,13 @@ public static function doRemove(Type $fromType, Type $typeToRemove): Type if ($removed !== null) { return $removed; } + + // a union removed the whole union from each of its members and none + // of them changed - removing the members of it one at a time cannot + // change one either, peeling would only repeat that work + if ($fromType instanceof UnionType) { + return $fromType; + } } // mixed is the top type, so removing a union from it is exactly diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 2e362f23175..8de47bc74b1 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -638,6 +638,8 @@ class TypeCombinator zv::Val removed = call(fromType, PT_LC("tryremove"), 1, typeToRemove); PT_FAIL_IF_UNDEF(removed); if (!isNull(removed)) return removed; + /* no member of the union changed - peeling cannot change one */ + if (isInstance(fromType, pt_ce_union_type)) return copy(fromType); } zv::Val types = getTypes(typeToRemove);