From 1239adfaa1a0d79e495c6209d7d54fed5c05db3e Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 14:46:07 +0000 Subject: [PATCH 1/2] Describe each array shape once while sorting a union usort() compares a member with several others, and the comparator described both sides of every comparison. Constant arrays are ordered by their description, so sorting a union of many array shapes described each shape O(log n) times, which dominated describe() of such unions. The comparator now reads those descriptions from a table filled once per shape for the sort. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-e5a2-75fe-9eeb-fa489813ac89 --- src/Type/UnionTypeHelper.php | 12 +++++- turbo-ext/src/UnionTypeHelper.cpp | 61 ++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/Type/UnionTypeHelper.php b/src/Type/UnionTypeHelper.php index b537ca2af82..bbd90c1970b 100644 --- a/src/Type/UnionTypeHelper.php +++ b/src/Type/UnionTypeHelper.php @@ -12,6 +12,7 @@ use function count; use function is_float; use function is_nan; +use function spl_object_id; use function strcasecmp; use function usort; use const PHP_INT_MIN; @@ -31,7 +32,11 @@ public static function sortTypes(array $types): array return $types; } - usort($types, static function (Type $a, Type $b): int { + // usort() compares a member with many others - an array shape, whose description + // is costly, is described once + $arrayDescriptions = []; + + usort($types, static function (Type $a, Type $b) use (&$arrayDescriptions): int { if ($a instanceof NullType) { return 1; } elseif ($b instanceof NullType) { @@ -136,7 +141,10 @@ public static function sortTypes(array $types): array return 1; } - return self::compareStrings($a->describe(VerbosityLevel::value()), $b->describe(VerbosityLevel::value())); + return self::compareStrings( + $arrayDescriptions[spl_object_id($a)] ??= $a->describe(VerbosityLevel::value()), + $arrayDescriptions[spl_object_id($b)] ??= $b->describe(VerbosityLevel::value()), + ); } if ($a->isString()->yes() && $b->isString()->yes()) { diff --git a/turbo-ext/src/UnionTypeHelper.cpp b/turbo-ext/src/UnionTypeHelper.cpp index c323595ebb2..3a51776d446 100644 --- a/turbo-ext/src/UnionTypeHelper.cpp +++ b/turbo-ext/src/UnionTypeHelper.cpp @@ -9,7 +9,8 @@ * original positions that PHP 8 usort() applies), so the members come out * in exactly the order the twin produces, comparator calls included — the * comparator describes members through their own describe() and the - * shadowed VerbosityLevel's singletons. + * shadowed VerbosityLevel's singletons, an array shape once per sort, as + * the twin's $arrayDescriptions memo does. * * The shadowed classes the comparator tests (NullType, ConstantBooleanType, * ConstantIntegerType, ConstantFloatType, IntegerRangeType, IntegerType, @@ -46,7 +47,14 @@ class UnionTypeHelper if (zend_hash_num_elements(source) > PT_UTH_SORT_LIMIT) return zv::Val::copyOf(zv::Ref(types)); /* usort() separates its by-reference argument: a private copy */ zv::Arr sorted = zv::Arr::adoptTable(zend_array_dup(source)); + /* the twin's $arrayDescriptions: each array shape described once; + * a describe() inside the sort can sort a nested union, which + * brings its own */ + zv::Arr descriptions = zv::Arr::create(0); + HashTable *outerDescriptions = currentDescriptions; + currentDescriptions = descriptions.table(); zend_hash_sort(sorted.table(), compare, 1); + currentDescriptions = outerDescriptions; if (UNEXPECTED(EG(exception))) return zv::Val(); return zv::Val(std::move(sorted)); } @@ -63,6 +71,10 @@ class UnionTypeHelper } private: + /* the twin's $arrayDescriptions of the sort in progress, keyed by object + * handle and level — zend_hash_sort() passes the comparator no context */ + static inline thread_local HashTable *currentDescriptions = nullptr; + /* the bucket comparator usort() runs: the closure's answer normalized * to -1/0/1 (ZEND_THREEWAY_COMPARE), ties broken by the original * positions zend_hash_sort() stored in Z_EXTRA (PHP 8's stable sort); @@ -113,7 +125,7 @@ class UnionTypeHelper return false; } if (aAccessory) { - if (bAccessory) return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_VALUE, out); + if (bAccessory) return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_VALUE, false, out); out = 1; return true; } @@ -227,7 +239,7 @@ class UnionTypeHelper if ((instanceof_function(a->ce, pt_ce_callable_type) || instanceof_function(a->ce, pt_ce_closure_type)) && (instanceof_function(b->ce, pt_ce_callable_type) || instanceof_function(b->ce, pt_ce_closure_type))) { - return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_VALUE, out); + return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_VALUE, false, out); } zend_long aConstantArray = pt_type_op_trinary(a, PT_OP_IS_CONSTANT_ARRAY, 0, NULL); @@ -254,7 +266,7 @@ class UnionTypeHelper out = 1; return true; } - return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_VALUE, out); + return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_VALUE, true, out); } } @@ -263,27 +275,48 @@ class UnionTypeHelper if (aString == PT_TRI_YES) { zend_long bString = pt_type_op_trinary(b, PT_OP_IS_STRING, 0, NULL); if (UNEXPECTED(bString < 0)) return false; - if (bString == PT_TRI_YES) return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_PRECISE, out); + if (bString == PT_TRI_YES) return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_PRECISE, false, out); } - return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_TYPE_ONLY, out); + return compareDescriptions(a, b, PT_VERBOSITY_LEVEL_TYPE_ONLY, false, out); } /* self::compareStrings($a->describe($level), $b->describe($level)) for - * one of the shadowed VerbosityLevel's singletons; false = pending - * exception */ - [[nodiscard]] static bool compareDescriptions(zend_object *a, zend_object *b, zend_long level, zend_long &out) + * one of the shadowed VerbosityLevel's singletons, through the twin's + * $arrayDescriptions memo when memoized; false = pending exception */ + [[nodiscard]] static bool compareDescriptions(zend_object *a, zend_object *b, zend_long level, bool memoized, zend_long &out) { zval *levelZv = pt_verbosity_level_singleton(level); if (UNEXPECTED(levelZv == NULL)) return false; - zv::Val aDescription = describe(a, levelZv); - if (UNEXPECTED(aDescription.isUndef())) return false; - zv::Val bDescription = describe(b, levelZv); - if (UNEXPECTED(bDescription.isUndef())) return false; - out = compareStrings(Z_STR_P(aDescription.raw()), Z_STR_P(bDescription.raw())); + if (!memoized) { + zv::Val aDescription = describe(a, levelZv); + if (UNEXPECTED(aDescription.isUndef())) return false; + zv::Val bDescription = describe(b, levelZv); + if (UNEXPECTED(bDescription.isUndef())) return false; + out = compareStrings(Z_STR_P(aDescription.raw()), Z_STR_P(bDescription.raw())); + return true; + } + zend_string *aDescription = memoizedDescription(a, level, levelZv); + if (UNEXPECTED(aDescription == NULL)) return false; + zend_string *bDescription = memoizedDescription(b, level, levelZv); + if (UNEXPECTED(bDescription == NULL)) return false; + out = compareStrings(aDescription, bDescription); return true; } + /* $arrayDescriptions[spl_object_id($type)] ??= $type->describe($level), + * borrowed from the sort's table; NULL = pending exception */ + static zend_string *memoizedDescription(zend_object *type, zend_long level, zval *levelZv) + { + zend_ulong key = ((zend_ulong) type->handle << 3) | (zend_ulong) level; + zval *found = zend_hash_index_find(currentDescriptions, key); + if (found != NULL) return Z_STR_P(found); + zv::Val description = describe(type, levelZv); + if (UNEXPECTED(description.isUndef())) return NULL; + zval owned = description.take(); + return Z_STR_P(zend_hash_index_add_new(currentDescriptions, key, &owned)); + } + /* $type->describe($level), a string; UNDEF = pending exception */ static zv::Val describe(zend_object *type, zval *level) { From 4d99ee44bf40f501ed3e1adbf99b80463527b400 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 14:46:07 +0000 Subject: [PATCH 2/2] Memoize the loosely equal types of a union per PHP version A loose comparison asks the other side for the values it can, or always does, loosely equal, once for its type and once per narrowed branch, and again in every pass over a loop body. A union like the int|string key of an array is the same instance in all those places, so the result is now kept on the union. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-e5a2-75fe-9eeb-fa489813ac89 --- src/Type/UnionType.php | 16 ++++++ turbo-ext/src/UnionType.cpp | 51 +++++++++++++++++-- .../generated/TemplateBenevolentUnionType.h | 12 ++--- turbo-ext/src/generated/TemplateUnionType.h | 12 ++--- turbo-ext/src/generated/UnionType.h | 24 ++++++--- 5 files changed, 93 insertions(+), 22 deletions(-) diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 3df4945517f..283be4e74e5 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -94,6 +94,12 @@ class UnionType implements CompoundType private ?TrinaryLogic $isCallable = null; + /** @var array */ + private array $possiblyLooselyEqualTypes = []; + + /** @var array */ + private array $alwaysLooselyEqualTypes = []; + /** * @api * @param list $types @@ -1819,6 +1825,16 @@ public function hasTemplateOrLateResolvableType(): bool return false; } + public function getPossiblyLooselyEqualType(PhpVersion $phpVersion): Type + { + return $this->possiblyLooselyEqualTypes[$phpVersion->getVersionId()] ??= $this->toValueSet()->getPossiblyLooselyEqualType($phpVersion); + } + + public function getAlwaysLooselyEqualType(PhpVersion $phpVersion): Type + { + return $this->alwaysLooselyEqualTypes[$phpVersion->getVersionId()] ??= $this->toValueSet()->getAlwaysLooselyEqualType($phpVersion); + } + public function toValueSet(): ValueSet { $valueSet = ValueSet::createEmpty(); diff --git a/turbo-ext/src/UnionType.cpp b/turbo-ext/src/UnionType.cpp index 8b96a40c81c..02b06c82711 100644 --- a/turbo-ext/src/UnionType.cpp +++ b/turbo-ext/src/UnionType.cpp @@ -5,9 +5,10 @@ * native BenevolentUnionType and the PHP TemplateUnionType extend it — their * constructors call parent::__construct(), so the constructor is a proper * method), implementing PHPStan\Type\CompoundType. State is the twin's - * eight private properties, declared typed property slots in the twin's - * declaration order — the six memos ($sortedTypesCache, $cachedDescriptions, - * $finiteTypeSet, $finiteTypes, $isNull, $isCallable) first, the promoted + * ten private properties, declared typed property slots in the twin's + * declaration order — the eight memos ($sortedTypesCache, $cachedDescriptions, + * $finiteTypeSet, $finiteTypes, $isNull, $isCallable, + * $possiblyLooselyEqualTypes, $alwaysLooselyEqualTypes) first, the promoted * $types and $normalized after them — so the std object handlers do * GC/clone and a PHP subclass's own properties follow them. The one trait * the twin uses (NonGeneralizableTypeTrait) comes from the shared registrar @@ -952,6 +953,35 @@ class UnionType * union of the members with their constant values generalized, every * other level the sorted members joined with '|'; an owned string, UNDEF * = pending exception */ + /* $this->{memo}[$phpVersion->getVersionId()] ??= $this->toValueSet()->{method}($phpVersion) + * — getPossiblyLooselyEqualType() / getAlwaysLooselyEqualType(); + * UNDEF = pending exception */ + zv::Val memoizedLooselyEqualType(uint32_t slotIndex, zval *phpVersion, const char *method, size_t methodLength) const + { + zv::Val versionId = pt_php_version_get_version_id(phpVersion); + if (UNEXPECTED(versionId.isUndef())) return zv::Val(); + zend_long key = zval_get_long(versionId.raw()); + zval *cache = OBJ_PROP_NUM(self, slotIndex); + if (EXPECTED(Z_TYPE_P(cache) == IS_ARRAY)) { + zval *cached = zend_hash_index_find(Z_ARRVAL_P(cache), key); + if (cached != NULL && Z_TYPE_P(cached) != IS_NULL) return zv::Val::copyOf(zv::Ref(cached)); + } + zv::Val valueSet = pt_type_call(self, PT_LC("tovalueset"), 0, NULL); + if (UNEXPECTED(valueSet.isUndef())) return zv::Val(); + zv::Val type = pt_type_call(Z_OBJ_P(valueSet.raw()), method, methodLength, 1, phpVersion); + if (UNEXPECTED(type.isUndef())) return zv::Val(); + + cache = OBJ_PROP_NUM(self, slotIndex); + if (Z_TYPE_P(cache) != IS_ARRAY) { + zv::Ref(cache).assign(zv::Val(zv::Arr::create(1))); + } + SEPARATE_ARRAY(cache); + zval copy; + ZVAL_COPY(©, type.raw()); + zend_hash_index_update(Z_ARRVAL_P(cache), key, ©); + return type; + } + zv::Val describe(zval *level) const { /* $level->getLevelValue() — the shadowing VerbosityLevel's slot, or @@ -2989,8 +3019,19 @@ PT_MINIT_REGISTRATION(pt_register_union_type) ptdecl::UnionType::declareClass(cls); /* mirrors toValueSet() */ cls.method(sigs::toValueSet, [](INTERNAL_FUNCTION_PARAMETERS) { pt_value_set_of_members(INTERNAL_FUNCTION_PARAM_PASSTHRU, false); }); + /* mirror getPossiblyLooselyEqualType() and getAlwaysLooselyEqualType(), memoized per PHP version */ + cls.method(sigs::getPossiblyLooselyEqualType, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *phpVersion; + if (!zp::parse(execute_data, phpVersion)) RETURN_THROWS(); + PT_RETURN_VAL(PT_THIS.memoizedLooselyEqualType(slots::possiblyLooselyEqualTypes, phpVersion, PT_LC("getpossiblylooselyequaltype"))); + }); + cls.method(sigs::getAlwaysLooselyEqualType, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *phpVersion; + if (!zp::parse(execute_data, phpVersion)) RETURN_THROWS(); + PT_RETURN_VAL(PT_THIS.memoizedLooselyEqualType(slots::alwaysLooselyEqualTypes, phpVersion, PT_LC("getalwayslooselyequaltype"))); + }); cls.classConstantValue("EQUAL_UNION_CLASSES", pt_ut_equal_union_classes); - /* the slots PT_UT_PROP_*: the six memo properties first, the promoted + /* the slots PT_UT_PROP_*: the eight memo properties first, the promoted * $types and $normalized after them */ cls.privateTypedPropertyDefaultNull("sortedTypesCache", MAY_BE_ARRAY); cls.privateTypedArrayPropertyDefaultEmpty("cachedDescriptions"); @@ -2998,6 +3039,8 @@ PT_MINIT_REGISTRATION(pt_register_union_type) cls.privateTypedPropertyDefaultNull("finiteTypes", MAY_BE_ARRAY); cls.privateTypedClassPropertyDefaultNull("isNull", ptcls::trinaryLogic); cls.privateTypedClassPropertyDefaultNull("isCallable", ptcls::trinaryLogic); + cls.privateTypedArrayPropertyDefaultEmpty("possiblyLooselyEqualTypes"); + cls.privateTypedArrayPropertyDefaultEmpty("alwaysLooselyEqualTypes"); cls.privateTypedProperty("types", MAY_BE_ARRAY); cls.privateTypedProperty("normalized", MAY_BE_BOOL); diff --git a/turbo-ext/src/generated/TemplateBenevolentUnionType.h b/turbo-ext/src/generated/TemplateBenevolentUnionType.h index cdbcf1af813..89814d6c14f 100644 --- a/turbo-ext/src/generated/TemplateBenevolentUnionType.h +++ b/turbo-ext/src/generated/TemplateBenevolentUnionType.h @@ -10,12 +10,12 @@ namespace ptdecl::TemplateBenevolentUnionType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t name = 8; -inline constexpr uint32_t scope = 9; -inline constexpr uint32_t strategy = 10; -inline constexpr uint32_t variance = 11; -inline constexpr uint32_t bound = 12; -inline constexpr uint32_t default_ = 13; +inline constexpr uint32_t name = 10; +inline constexpr uint32_t scope = 11; +inline constexpr uint32_t strategy = 12; +inline constexpr uint32_t variance = 13; +inline constexpr uint32_t bound = 14; +inline constexpr uint32_t default_ = 15; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/generated/TemplateUnionType.h b/turbo-ext/src/generated/TemplateUnionType.h index bca0ba21e46..436c58ba4f3 100644 --- a/turbo-ext/src/generated/TemplateUnionType.h +++ b/turbo-ext/src/generated/TemplateUnionType.h @@ -10,12 +10,12 @@ namespace ptdecl::TemplateUnionType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t name = 8; -inline constexpr uint32_t scope = 9; -inline constexpr uint32_t strategy = 10; -inline constexpr uint32_t variance = 11; -inline constexpr uint32_t bound = 12; -inline constexpr uint32_t default_ = 13; +inline constexpr uint32_t name = 10; +inline constexpr uint32_t scope = 11; +inline constexpr uint32_t strategy = 12; +inline constexpr uint32_t variance = 13; +inline constexpr uint32_t bound = 14; +inline constexpr uint32_t default_ = 15; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/generated/UnionType.h b/turbo-ext/src/generated/UnionType.h index f7b46d6ed73..a3af2bb89c8 100644 --- a/turbo-ext/src/generated/UnionType.h +++ b/turbo-ext/src/generated/UnionType.h @@ -16,8 +16,10 @@ inline constexpr uint32_t finiteTypeSet = 2; inline constexpr uint32_t finiteTypes = 3; inline constexpr uint32_t isNull = 4; inline constexpr uint32_t isCallable = 5; -inline constexpr uint32_t types = 6; -inline constexpr uint32_t normalized = 7; +inline constexpr uint32_t possiblyLooselyEqualTypes = 6; +inline constexpr uint32_t alwaysLooselyEqualTypes = 7; +inline constexpr uint32_t types = 8; +inline constexpr uint32_t normalized = 9; } // namespace slot inline void declareClass(reg::Class &cls) @@ -34,6 +36,8 @@ inline void declareProperties(reg::Class &cls) cls.property("finiteTypes", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL | MAY_BE_ARRAY); cls.property("isNull", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL, "PHPStan\\TrinaryLogic"); cls.property("isCallable", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL, "PHPStan\\TrinaryLogic"); + cls.property("possiblyLooselyEqualTypes", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedEmptyArray, MAY_BE_ARRAY); + cls.property("alwaysLooselyEqualTypes", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedEmptyArray, MAY_BE_ARRAY); cls.property("types", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, MAY_BE_ARRAY); cls.property("normalized", ZEND_ACC_PRIVATE, reg::PropertyKind::Typed, MAY_BE_BOOL); } @@ -276,8 +280,10 @@ inline constexpr char strings[] = "toNativePhpTypeNode\0" /* 3839 */ "notBenevolentPickFromTypes\0" /* 3859 */ "hasTemplateOrLateResolvableType\0" /* 3886 */ - "PHPStan\\Type\\ValueSet\0" /* 3918 */ - "toValueSet"; /* 3940 */ + "getPossiblyLooselyEqualType\0" /* 3918 */ + "getAlwaysLooselyEqualType\0" /* 3946 */ + "PHPStan\\Type\\ValueSet\0" /* 3972 */ + "toValueSet"; /* 3994 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, MAY_BE_ARRAY), /* __construct $types */ reg::packed(6, MAY_BE_BOOL, reg::NoString, false, false, 17), /* __construct $normalized */ @@ -536,7 +542,11 @@ inline constexpr reg::PackedArg args[] = { reg::packed(3713, MAY_BE_CALLABLE), /* notBenevolentPickFromTypes $getValues */ reg::packed(35, MAY_BE_ARRAY), /* notBenevolentPickFromTypes return */ reg::packed(35, MAY_BE_BOOL), /* hasTemplateOrLateResolvableType return */ - reg::packed(35, 0, 3918), /* toValueSet return */ + reg::packed(1803, 0, 1814), /* getPossiblyLooselyEqualType $phpVersion */ + reg::packed(35, 0, 54), /* getPossiblyLooselyEqualType return */ + reg::packed(1803, 0, 1814), /* getAlwaysLooselyEqualType $phpVersion */ + reg::packed(35, 0, 54), /* getAlwaysLooselyEqualType return */ + reg::packed(35, 0, 3972), /* toValueSet return */ }; using Sig = reg::Sig; } // namespace sigtab @@ -696,7 +706,9 @@ inline constexpr sigtab::Sig toPhpDocNode = { { 3785 /* toPhpDocNode */, 0, 252, inline constexpr sigtab::Sig toNativePhpTypeNode = { { 3839 /* toNativePhpTypeNode */, 1, 253, 1, reg::NoArg, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig notBenevolentPickFromTypes = { { 3859 /* notBenevolentPickFromTypes */, 1, 254, 1, 255, ZEND_ACC_PRIVATE } }; inline constexpr sigtab::Sig hasTemplateOrLateResolvableType = { { 3886 /* hasTemplateOrLateResolvableType */, 0, 256, 0, 256, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig toValueSet = { { 3940 /* toValueSet */, 0, 257, 0, 257, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getPossiblyLooselyEqualType = { { 3918 /* getPossiblyLooselyEqualType */, 1, 257, 1, 258, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getAlwaysLooselyEqualType = { { 3946 /* getAlwaysLooselyEqualType */, 1, 259, 1, 260, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig toValueSet = { { 3994 /* toValueSet */, 0, 261, 0, 261, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::UnionType