From 3d2122cbd1f6136ce71de781e2190d779638cf2d Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 20:01:09 +0000 Subject: [PATCH 1/2] Do the per-key work of array shapes once per shared value type Writing through a union offset gives an array shape one optional key per member of the offset, all holding the same value type. With 250 keys and a 250-member value type, every operation that walked the keys did the same work 250 times: - mergeWith() and legacyMergeWith() unioned each pair of value types once per key; they now union each distinct pair once, so the merged keys keep sharing one value type - isSuperTypeOf() against another shape called getOffsetValueType() and isSuperTypeOf() once per key; it now compares each pair of value types once - countConstantArrayValueTypes() traversed a shared value type once per key; it now traverses it once and counts it per occurrence - reduceArrays()' single-constant-scalar lookup inspected the shared value type once per key - getValuesArray() and getKeysArray() unioned every window of possible source values from scratch, quadratic in the number of keys; the window unions are now kept as a two-stack queue, linear in the number of keys The inferred types do not change. The turbo-ext mirrors get the same changes. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b24f-e4cc-7457-9324-5d29caf3c481 --- src/Type/Constant/ConstantArrayType.php | 68 ++++- src/Type/TypeCombinator.php | 37 ++- tests/PHPStan/Analyser/nsrt/array_values.php | 8 + tests/bench/data/enum-value-keyed-map.php | 279 +++++++++++++++++++ turbo-ext/src/ConstantArrayType.cpp | 132 ++++++++- turbo-ext/src/TypeCombinator.cpp | 80 ++++-- 6 files changed, 559 insertions(+), 45 deletions(-) create mode 100644 tests/bench/data/enum-value-keyed-map.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 75e1486a49c..3c1c72973cf 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -798,6 +798,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult } $results = []; + $valueResults = []; foreach ($this->keyTypes as $i => $keyType) { $hasOffset = $type->hasOffsetValueType($keyType); if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) { @@ -820,12 +821,26 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $results[] = IsSuperTypeOfResult::createMaybe(); } - $otherValueType = $type->getOffsetValueType($keyType); - if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { - [, $typeUnsealedValue] = $type->unsealed; - $otherValueType = $typeUnsealedValue; + // The offset value at one of $type's explicit keys depends only on + // the value type stored there, so keys sharing their value types on + // both sides (keys written through a union offset) compare them once. + $otherIndex = $type->getKeyIndex($keyType); + $valuePairKey = $otherIndex !== null + ? spl_object_id($this->valueTypes[$i]) . ':' . spl_object_id($type->valueTypes[$otherIndex]) + : null; + if ($valuePairKey !== null && array_key_exists($valuePairKey, $valueResults)) { + $isValueSuperType = $valueResults[$valuePairKey]; + } else { + $otherValueType = $type->getOffsetValueType($keyType); + if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { + [, $typeUnsealedValue] = $type->unsealed; + $otherValueType = $typeUnsealedValue; + } + $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); + if ($valuePairKey !== null) { + $valueResults[$valuePairKey] = $isValueSuperType; + } } - $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); if ($isValueSuperType->no()) { return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason)); } @@ -2559,6 +2574,18 @@ private function getKeysOrValuesArray(array $types, ?Type $unsealedSourceType): $optionalKeys = []; $maxIndex = 0; + // Position $i of the result can hold any source value from $i up to + // $maxIndex. Both ends of that window only move forward, so the + // window unions are kept as a two-stack queue: $suffixUnions[$j] is + // the union of $types[$j..$suffixEnd] and $tailUnion the union of + // the values added after $suffixEnd. Each value enters the suffix + // unions once, which keeps this linear in the number of keys instead + // of unioning every window from scratch. + $suffixUnions = []; + $suffixEnd = -1; + $tailUnion = null; + $tailEnd = -1; + foreach ($types as $i => $type) { $keyTypes[] = new ConstantIntegerType($i); @@ -2572,7 +2599,26 @@ private function getKeysOrValuesArray(array $types, ?Type $unsealedSourceType): if ($i === $maxIndex) { $valueTypes[] = $type; } else { - $valueTypes[] = TypeCombinator::union(...array_slice($types, $i, $maxIndex - $i + 1)); + $windowEnd = min($maxIndex, $count - 1); + if ($i === $windowEnd) { + $valueTypes[] = TypeCombinator::union($type); + } else { + if ($i > $suffixEnd) { + $suffixEnd = $windowEnd; + $suffixUnions = [$suffixEnd => $types[$suffixEnd]]; + for ($j = $suffixEnd - 1; $j >= $i; $j--) { + $suffixUnions[$j] = TypeCombinator::union($types[$j], $suffixUnions[$j + 1]); + } + $tailUnion = null; + $tailEnd = $suffixEnd; + } + for ($j = $tailEnd + 1; $j <= $windowEnd; $j++) { + $tailUnion = $tailUnion === null ? $types[$j] : TypeCombinator::union($tailUnion, $types[$j]); + } + $tailEnd = $windowEnd; + + $valueTypes[] = $tailUnion === null ? $suffixUnions[$i] : TypeCombinator::union($suffixUnions[$i], $tailUnion); + } if ($maxIndex >= $count) { $optionalKeys[] = $i; } @@ -3135,6 +3181,10 @@ public function mergeWith(self $otherArray): self $otherKeyIndexMap = $otherArray->getKeyIndexMap(); $processed = []; + // Keys written through a union offset share their value type: union + // each pair of value types once, which also keeps the merged keys + // sharing the result. + $mergedValues = []; foreach ($this->keyTypes as $i => $keyType) { $keyValue = $keyType->getValue(); @@ -3144,7 +3194,7 @@ public function mergeWith(self $otherArray): self if (array_key_exists($keyValue, $otherKeyIndexMap)) { $j = $otherKeyIndexMap[$keyValue]; $otherValueType = $otherArray->valueTypes[$j]; - $mergedValue = TypeCombinator::union($valueType, $otherValueType); + $mergedValue = $mergedValues[spl_object_id($valueType) . ':' . spl_object_id($otherValueType)] ??= TypeCombinator::union($valueType, $otherValueType); $optional = $this->isOptionalKey($i) || $otherArray->isOptionalKey($j); $keyTypes[] = $keyType; @@ -3218,6 +3268,8 @@ private function legacyMergeWith(self $otherArray): self { $valueTypes = $this->valueTypes; $optionalKeys = $this->optionalKeys; + // union each pair of value types once, see mergeWith() + $mergedValues = []; foreach ($this->keyTypes as $i => $keyType) { $otherIndex = $otherArray->getKeyIndex($keyType); if ($otherIndex === null) { @@ -3228,7 +3280,7 @@ private function legacyMergeWith(self $otherArray): self $optionalKeys[] = $i; } $otherValueType = $otherArray->valueTypes[$otherIndex]; - $valueTypes[$i] = TypeCombinator::union($valueTypes[$i], $otherValueType); + $valueTypes[$i] = $mergedValues[spl_object_id($valueTypes[$i]) . ':' . spl_object_id($otherValueType)] ??= TypeCombinator::union($valueTypes[$i], $otherValueType); } $optionalKeys = array_values(array_unique($optionalKeys)); diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index f965be52410..c2a285a700b 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1685,13 +1685,28 @@ private static function optimizeConstantArrays(array $types): array public static function countConstantArrayValueTypes(array $types): int { $constantArrayValuesCount = 0; + // A type shared by several positions (array shape keys written + // through a union offset all hold the same value type) is counted + // once per position, but traversed only once. The entries keep the + // visited types alive so that their object ids are not reused. + $countsByType = []; foreach ($types as $type) { - TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$constantArrayValuesCount): Type { + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$constantArrayValuesCount, &$countsByType): Type { + $id = spl_object_id($type); + if (array_key_exists($id, $countsByType)) { + $constantArrayValuesCount += $countsByType[$id][1]; + return $type; + } + + $countBefore = $constantArrayValuesCount; if ($type instanceof ConstantArrayType) { $constantArrayValuesCount += count($type->getValueTypes()); } - return $traverse($type); + $result = $traverse($type); + $countsByType[$id] = [$type, $constantArrayValuesCount - $countBefore]; + + return $result; }); } return $constantArrayValuesCount; @@ -1713,15 +1728,23 @@ private static function getConstantScalarValuesByKey(ConstantArrayType $array): $values = []; $valueTypes = $array->getValueTypes(); + // keys written through a union offset share one value type - inspect it once + $singleValues = []; foreach ($array->getKeyTypes() as $i => $keyType) { - if (!$valueTypes[$i]->isConstantScalarValue()->yes()) { - continue; + $id = spl_object_id($valueTypes[$i]); + if (!array_key_exists($id, $singleValues)) { + $singleValues[$id] = null; + if ($valueTypes[$i]->isConstantScalarValue()->yes()) { + $constantScalarValues = $valueTypes[$i]->getConstantScalarValues(); + if (count($constantScalarValues) === 1) { + $singleValues[$id] = [$constantScalarValues[0]]; + } + } } - $constantScalarValues = $valueTypes[$i]->getConstantScalarValues(); - if (count($constantScalarValues) !== 1) { + if ($singleValues[$id] === null) { continue; } - $values[$keyType->getValue()] = $constantScalarValues[0]; + $values[$keyType->getValue()] = $singleValues[$id][0]; } return $values; diff --git a/tests/PHPStan/Analyser/nsrt/array_values.php b/tests/PHPStan/Analyser/nsrt/array_values.php index 70e3666ec50..e9c5c8b4ae4 100644 --- a/tests/PHPStan/Analyser/nsrt/array_values.php +++ b/tests/PHPStan/Analyser/nsrt/array_values.php @@ -38,6 +38,14 @@ public function constantArrayType(): void assertType("list{0?: 'a'|'b'|'c', 1?: 'b'|'c', 2?: 'c'}", array_values($numbers)); } + /** + * @param array{a: 1, b?: 2, c: 3, d?: 4, e?: 5, f: 6} $shape + */ + public function optionalKeysBetweenRequiredKeys(array $shape): void + { + assertType('list{0: 1, 1: 2|3, 2: 3|4|5|6, 3?: 4|5|6, 4?: 5|6, 5?: 6}', array_values($shape)); + } + /** * @param array> $a */ diff --git a/tests/bench/data/enum-value-keyed-map.php b/tests/bench/data/enum-value-keyed-map.php new file mode 100644 index 00000000000..58bf2ab0c0b --- /dev/null +++ b/tests/bench/data/enum-value-keyed-map.php @@ -0,0 +1,279 @@ += 8.1 + +namespace BenchEnumValueKeyedMap; + +enum Code: string +{ + + case V000 = 'v000'; + case V001 = 'v001'; + case V002 = 'v002'; + case V003 = 'v003'; + case V004 = 'v004'; + case V005 = 'v005'; + case V006 = 'v006'; + case V007 = 'v007'; + case V008 = 'v008'; + case V009 = 'v009'; + case V010 = 'v010'; + case V011 = 'v011'; + case V012 = 'v012'; + case V013 = 'v013'; + case V014 = 'v014'; + case V015 = 'v015'; + case V016 = 'v016'; + case V017 = 'v017'; + case V018 = 'v018'; + case V019 = 'v019'; + case V020 = 'v020'; + case V021 = 'v021'; + case V022 = 'v022'; + case V023 = 'v023'; + case V024 = 'v024'; + case V025 = 'v025'; + case V026 = 'v026'; + case V027 = 'v027'; + case V028 = 'v028'; + case V029 = 'v029'; + case V030 = 'v030'; + case V031 = 'v031'; + case V032 = 'v032'; + case V033 = 'v033'; + case V034 = 'v034'; + case V035 = 'v035'; + case V036 = 'v036'; + case V037 = 'v037'; + case V038 = 'v038'; + case V039 = 'v039'; + case V040 = 'v040'; + case V041 = 'v041'; + case V042 = 'v042'; + case V043 = 'v043'; + case V044 = 'v044'; + case V045 = 'v045'; + case V046 = 'v046'; + case V047 = 'v047'; + case V048 = 'v048'; + case V049 = 'v049'; + case V050 = 'v050'; + case V051 = 'v051'; + case V052 = 'v052'; + case V053 = 'v053'; + case V054 = 'v054'; + case V055 = 'v055'; + case V056 = 'v056'; + case V057 = 'v057'; + case V058 = 'v058'; + case V059 = 'v059'; + case V060 = 'v060'; + case V061 = 'v061'; + case V062 = 'v062'; + case V063 = 'v063'; + case V064 = 'v064'; + case V065 = 'v065'; + case V066 = 'v066'; + case V067 = 'v067'; + case V068 = 'v068'; + case V069 = 'v069'; + case V070 = 'v070'; + case V071 = 'v071'; + case V072 = 'v072'; + case V073 = 'v073'; + case V074 = 'v074'; + case V075 = 'v075'; + case V076 = 'v076'; + case V077 = 'v077'; + case V078 = 'v078'; + case V079 = 'v079'; + case V080 = 'v080'; + case V081 = 'v081'; + case V082 = 'v082'; + case V083 = 'v083'; + case V084 = 'v084'; + case V085 = 'v085'; + case V086 = 'v086'; + case V087 = 'v087'; + case V088 = 'v088'; + case V089 = 'v089'; + case V090 = 'v090'; + case V091 = 'v091'; + case V092 = 'v092'; + case V093 = 'v093'; + case V094 = 'v094'; + case V095 = 'v095'; + case V096 = 'v096'; + case V097 = 'v097'; + case V098 = 'v098'; + case V099 = 'v099'; + case V100 = 'v100'; + case V101 = 'v101'; + case V102 = 'v102'; + case V103 = 'v103'; + case V104 = 'v104'; + case V105 = 'v105'; + case V106 = 'v106'; + case V107 = 'v107'; + case V108 = 'v108'; + case V109 = 'v109'; + case V110 = 'v110'; + case V111 = 'v111'; + case V112 = 'v112'; + case V113 = 'v113'; + case V114 = 'v114'; + case V115 = 'v115'; + case V116 = 'v116'; + case V117 = 'v117'; + case V118 = 'v118'; + case V119 = 'v119'; + case V120 = 'v120'; + case V121 = 'v121'; + case V122 = 'v122'; + case V123 = 'v123'; + case V124 = 'v124'; + case V125 = 'v125'; + case V126 = 'v126'; + case V127 = 'v127'; + case V128 = 'v128'; + case V129 = 'v129'; + case V130 = 'v130'; + case V131 = 'v131'; + case V132 = 'v132'; + case V133 = 'v133'; + case V134 = 'v134'; + case V135 = 'v135'; + case V136 = 'v136'; + case V137 = 'v137'; + case V138 = 'v138'; + case V139 = 'v139'; + case V140 = 'v140'; + case V141 = 'v141'; + case V142 = 'v142'; + case V143 = 'v143'; + case V144 = 'v144'; + case V145 = 'v145'; + case V146 = 'v146'; + case V147 = 'v147'; + case V148 = 'v148'; + case V149 = 'v149'; + case V150 = 'v150'; + case V151 = 'v151'; + case V152 = 'v152'; + case V153 = 'v153'; + case V154 = 'v154'; + case V155 = 'v155'; + case V156 = 'v156'; + case V157 = 'v157'; + case V158 = 'v158'; + case V159 = 'v159'; + case V160 = 'v160'; + case V161 = 'v161'; + case V162 = 'v162'; + case V163 = 'v163'; + case V164 = 'v164'; + case V165 = 'v165'; + case V166 = 'v166'; + case V167 = 'v167'; + case V168 = 'v168'; + case V169 = 'v169'; + case V170 = 'v170'; + case V171 = 'v171'; + case V172 = 'v172'; + case V173 = 'v173'; + case V174 = 'v174'; + case V175 = 'v175'; + case V176 = 'v176'; + case V177 = 'v177'; + case V178 = 'v178'; + case V179 = 'v179'; + case V180 = 'v180'; + case V181 = 'v181'; + case V182 = 'v182'; + case V183 = 'v183'; + case V184 = 'v184'; + case V185 = 'v185'; + case V186 = 'v186'; + case V187 = 'v187'; + case V188 = 'v188'; + case V189 = 'v189'; + case V190 = 'v190'; + case V191 = 'v191'; + case V192 = 'v192'; + case V193 = 'v193'; + case V194 = 'v194'; + case V195 = 'v195'; + case V196 = 'v196'; + case V197 = 'v197'; + case V198 = 'v198'; + case V199 = 'v199'; + case V200 = 'v200'; + case V201 = 'v201'; + case V202 = 'v202'; + case V203 = 'v203'; + case V204 = 'v204'; + case V205 = 'v205'; + case V206 = 'v206'; + case V207 = 'v207'; + case V208 = 'v208'; + case V209 = 'v209'; + case V210 = 'v210'; + case V211 = 'v211'; + case V212 = 'v212'; + case V213 = 'v213'; + case V214 = 'v214'; + case V215 = 'v215'; + case V216 = 'v216'; + case V217 = 'v217'; + case V218 = 'v218'; + case V219 = 'v219'; + case V220 = 'v220'; + case V221 = 'v221'; + case V222 = 'v222'; + case V223 = 'v223'; + case V224 = 'v224'; + case V225 = 'v225'; + case V226 = 'v226'; + case V227 = 'v227'; + case V228 = 'v228'; + case V229 = 'v229'; + case V230 = 'v230'; + case V231 = 'v231'; + case V232 = 'v232'; + case V233 = 'v233'; + case V234 = 'v234'; + case V235 = 'v235'; + case V236 = 'v236'; + case V237 = 'v237'; + case V238 = 'v238'; + case V239 = 'v239'; + case V240 = 'v240'; + case V241 = 'v241'; + case V242 = 'v242'; + case V243 = 'v243'; + case V244 = 'v244'; + case V245 = 'v245'; + case V246 = 'v246'; + case V247 = 'v247'; + case V248 = 'v248'; + case V249 = 'v249'; + +} + +/** + * Writing through a union offset gives the array 250 optional keys that all hold + * the same 250-case value type. Every merge of two such shapes unioned that value + * type once per key, and comparing two of them called getOffsetValueType() and + * isSuperTypeOf() once per key as well. + * + * `bin/phpstan analyse -l 8 --debug` with bleeding edge on this file: 0.82 s on 2.3.x, 0.26 s with the fix. + * + * @param array, int> $counts + */ +function pick(array $counts): void +{ + $missing = []; + foreach ($counts as $value => $count) { + if ($count < 2) { + $missing[$value] = Code::from($value); + } + } +} diff --git a/turbo-ext/src/ConstantArrayType.cpp b/turbo-ext/src/ConstantArrayType.cpp index 5276cdd4084..325b0224cab 100644 --- a/turbo-ext/src/ConstantArrayType.cpp +++ b/turbo-ext/src/ConstantArrayType.cpp @@ -32,6 +32,8 @@ #include "TypeTraits.h" #include "generated/ConstantArrayType.h" +#include + namespace slots = ptdecl::ConstantArrayType::slot; namespace sigs = ptdecl::ConstantArrayType::sig; @@ -371,6 +373,13 @@ static bool calculateScalarsLimit(zend_long &out) return found; } +/* spl_object_id($a) . ':' . spl_object_id($b) — the key under which the + * twin memoizes work on a pair of value types */ +static uint64_t valuePairKey(zval *a, zval *b) +{ + return ((uint64_t) Z_OBJ_HANDLE_P(a) << 32) | (uint64_t) Z_OBJ_HANDLE_P(b); +} + /* count($array) */ static zend_long arrayCount(zval *array) { @@ -1799,6 +1808,8 @@ class ConstantArrayType } zv::Arr results = zv::Arr::create(zend_hash_num_elements(Z_ARRVAL_P(k)) + 4); + /* value-type pair → position of its comparison in $results */ + std::unordered_map valueResults; for (zv::ArrayEntry entry : zv::ArrRef(k)) { zend_long i = (zend_long) entry.indexKey(); zval *keyType = entry.value().deref().raw(); @@ -1834,6 +1845,29 @@ class ConstantArrayType } } + zval *valueType = arrayIndexObject(v, i, "valueTypes"); + if (UNEXPECTED(valueType == NULL)) return zv::Val(); + + /* The offset value at one of $type's explicit keys depends only on + * the value type stored there, so keys sharing their value types on + * both sides (keys written through a union offset) compare them once. */ + NullableLong otherIndex; + if (UNEXPECTED(!ConstantArrayType(Z_OBJ_P(type)).getKeyIndex(keyType, otherIndex))) return zv::Val(); + uint64_t pairKey = 0; + if (!otherIndex.isNull) { + zval *typeV = slotOf(Z_OBJ_P(type), slots::valueTypes, "valueTypes"); + zval *otherValueAtKey = typeV != NULL ? arrayIndexObject(typeV, otherIndex.value, "valueTypes") : NULL; + if (UNEXPECTED(otherValueAtKey == NULL)) return zv::Val(); + pairKey = valuePairKey(valueType, otherValueAtKey); + auto memo = valueResults.find(pairKey); + if (memo != valueResults.end()) { + /* copied out first: the push may grow the table it points into */ + zv::Val memoized = zv::Val::copyOf(results.arrRef().findIndex(memo->second)); + results.push(std::move(memoized)); + continue; + } + } + zv::Val otherValueType = callType(Z_OBJ_P(type), PT_LC("getoffsetvaluetype"), 1, keyType); if (UNEXPECTED(otherValueType.isUndef())) return zv::Val(); bool isError; @@ -1843,13 +1877,14 @@ class ConstantArrayType if (UNEXPECTED(!unsealedPair(typeUnsealed, typeUnsealedKey, typeUnsealedValue))) return zv::Val(); otherValueType = zv::Val::copyOf(zv::Ref(typeUnsealedValue)); } - zval *valueType = arrayIndexObject(v, i, "valueTypes"); - if (UNEXPECTED(valueType == NULL)) return zv::Val(); zv::Val isValueSuperType = pt_type_op(Z_OBJ_P(valueType), PT_OP_IS_SUPER_TYPE_OF, 1, otherValueType.raw()); if (UNEXPECTED(isValueSuperType.isUndef())) return zv::Val(); zend_long valueSuper = pt_type_result_trinary(isValueSuperType.raw()); if (UNEXPECTED(valueSuper < 0)) return zv::Val(); if (valueSuper == PT_TRI_NO) return decorateReasons(isValueSuperType.raw(), offsetPrefixReasonCallback, keyType, NULL); + if (!otherIndex.isNull) { + valueResults.emplace(pairKey, results.arrRef().size()); + } results.push(std::move(isValueSuperType)); } @@ -4238,6 +4273,18 @@ class ConstantArrayType zv::Arr optionalKeysList = zv::Arr::create((uint32_t) count); zend_long maxIndex = 0; + /* Position $i of the result can hold any source value from $i up to + * $maxIndex. Both ends of that window only move forward, so the + * window unions are kept as a two-stack queue: $suffixUnions[$j] is + * the union of $types[$j..$suffixEnd] and $tailUnion (UNDEF = null) + * the union of the values added after $suffixEnd. Each value enters + * the suffix unions once, which keeps this linear in the number of + * keys instead of unioning every window from scratch. */ + zv::Arr suffixUnions = zv::Arr::empty(); + zend_long suffixEnd = -1; + zv::Val tailUnion; + zend_long tailEnd = -1; + for (zv::ArrayEntry entry : zv::ArrRef(types)) { zend_long i = (zend_long) entry.indexKey(); zv::Val keyType = pt_type_new_constant_integer(i); @@ -4258,11 +4305,50 @@ class ConstantArrayType if (i == maxIndex) { valueTypesList.push(entry.value()); } else { - /* TypeCombinator::union(...array_slice($types, $i, $maxIndex - $i + 1)) */ - zv::Arr slice = arraySlice(types, i, maxIndex - i + 1); - zv::Val unionType = combinatorSpread(PT_LC("union"), slice.table()); - if (UNEXPECTED(unionType.isUndef())) return zv::Val(); - valueTypesList.push(std::move(unionType)); + zend_long windowEnd = maxIndex < count - 1 ? maxIndex : count - 1; + if (i == windowEnd) { + zv::Args single{entry.value().deref().raw()}; + zv::Val unionType = pt_type_combinator_call(PT_LC("union"), 1, single); + if (UNEXPECTED(unionType.isUndef())) return zv::Val(); + valueTypesList.push(std::move(unionType)); + } else { + if (i > suffixEnd) { + suffixEnd = windowEnd; + suffixUnions = zv::Arr::create((uint32_t) (suffixEnd - i + 1)); + zval *last = arrayIndexObject(types, suffixEnd, "types"); + if (UNEXPECTED(last == NULL)) return zv::Val(); + suffixUnions.arrRef().setIndex((zend_ulong) suffixEnd, zv::Ref(last)); + for (zend_long j = suffixEnd - 1; j >= i; j--) { + zval *typeJ = arrayIndexObject(types, j, "types"); + if (UNEXPECTED(typeJ == NULL)) return zv::Val(); + zv::Val suffixUnion = combinator2(PT_LC("union"), typeJ, suffixUnions.arrRef().findIndex((zend_ulong) (j + 1)).raw()); + if (UNEXPECTED(suffixUnion.isUndef())) return zv::Val(); + suffixUnions.arrRef().setIndex((zend_ulong) j, zv::Ref(suffixUnion.raw())); + } + tailUnion = zv::Val(); + tailEnd = suffixEnd; + } + for (zend_long j = tailEnd + 1; j <= windowEnd; j++) { + zval *typeJ = arrayIndexObject(types, j, "types"); + if (UNEXPECTED(typeJ == NULL)) return zv::Val(); + if (tailUnion.isUndef()) { + tailUnion = zv::Val::copyOf(zv::Ref(typeJ)); + } else { + tailUnion = combinator2(PT_LC("union"), tailUnion.raw(), typeJ); + if (UNEXPECTED(tailUnion.isUndef())) return zv::Val(); + } + } + tailEnd = windowEnd; + + zval *suffixUnion = suffixUnions.arrRef().findIndex((zend_ulong) i).raw(); + if (tailUnion.isUndef()) { + valueTypesList.push(zv::Ref(suffixUnion)); + } else { + zv::Val unionType = combinator2(PT_LC("union"), suffixUnion, tailUnion.raw()); + if (UNEXPECTED(unionType.isUndef())) return zv::Val(); + valueTypesList.push(std::move(unionType)); + } + } if (maxIndex >= count) { optionalKeysList.push(zv::Val::integer(i)); } @@ -4968,6 +5054,11 @@ class ConstantArrayType zv::Val otherKeyIndexMap = ConstantArrayType(other).getKeyIndexMap(); if (UNEXPECTED(otherKeyIndexMap.isUndef())) return zv::Val(); zv::Arr processed = zv::Arr::create(zend_hash_num_elements(Z_ARRVAL_P(k))); + /* Keys written through a union offset share their value type: union + * each pair of value types once, which also keeps the merged keys + * sharing the result. Maps the pair to the position of its union in + * $valueTypes. */ + std::unordered_map mergedValues; for (zv::ArrayEntry entry : zv::ArrRef(k)) { zend_long i = (zend_long) entry.indexKey(); @@ -4985,8 +5076,16 @@ class ConstantArrayType zend_long j = Z_TYPE_P(jZv) == IS_LONG ? Z_LVAL_P(jZv) : -1; zval *otherValueType = arrayIndexObject(otherV, j, "valueTypes"); if (UNEXPECTED(otherValueType == NULL)) return zv::Val(); - zv::Val mergedValue = combinator2(PT_LC("union"), valueType, otherValueType); - if (UNEXPECTED(mergedValue.isUndef())) return zv::Val(); + uint64_t pairKey = valuePairKey(valueType, otherValueType); + auto memo = mergedValues.find(pairKey); + zv::Val mergedValue; + if (memo != mergedValues.end()) { + mergedValue = zv::Val::copyOf(valueTypesNew.arrRef().findIndex(memo->second)); + } else { + mergedValue = combinator2(PT_LC("union"), valueType, otherValueType); + if (UNEXPECTED(mergedValue.isUndef())) return zv::Val(); + mergedValues.emplace(pairKey, valueTypesNew.arrRef().size()); + } bool optional, otherOptional = false; if (UNEXPECTED(!thisIsOptionalKey(i, optional))) return zv::Val(); if (!optional && UNEXPECTED(!otherIsOptionalKey(otherArray, j, otherOptional))) return zv::Val(); @@ -5079,6 +5178,9 @@ class ConstantArrayType if (UNEXPECTED(otherL == NULL)) return zv::Val(); zv::Arr valueTypesNew = zv::Arr::adoptVal(zv::Val::copyOf(zv::Ref(v))); zv::Arr optionalKeysNew = zv::Arr::adoptVal(zv::Val::copyOf(zv::Ref(o))); + /* union each pair of value types once, see mergeWith(); maps the pair + * to the key whose value holds its union */ + std::unordered_map mergedValues; for (zv::ArrayEntry entry : zv::ArrRef(k)) { zend_long i = (zend_long) entry.indexKey(); NullableLong otherIndex; @@ -5095,8 +5197,16 @@ class ConstantArrayType zval *otherValueType = arrayIndexObject(otherV, otherIndex.value, "valueTypes"); zval *valueType = otherValueType != NULL ? arrayIndex(valueTypesNew.raw(), i, "valueTypes") : NULL; if (UNEXPECTED(valueType == NULL)) return zv::Val(); - zv::Val merged = combinator2(PT_LC("union"), valueType, otherValueType); - if (UNEXPECTED(merged.isUndef())) return zv::Val(); + uint64_t pairKey = valuePairKey(valueType, otherValueType); + auto memo = mergedValues.find(pairKey); + zv::Val merged; + if (memo != mergedValues.end()) { + merged = zv::Val::copyOf(valueTypesNew.arrRef().findIndex((zend_ulong) memo->second)); + } else { + merged = combinator2(PT_LC("union"), valueType, otherValueType); + if (UNEXPECTED(merged.isUndef())) return zv::Val(); + mergedValues.emplace(pairKey, i); + } valueTypesNew.separate(); zv::ArrRef(valueTypesNew.raw()).setIndex((zend_ulong) i, zv::Ref(merged.raw())); } diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 776fd8ca416..70f24719cd4 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -2882,14 +2882,25 @@ class TypeCombinator return zv::Val::adoptString(smart_str_extract(&signature)); } - /* countConstantArrayValueTypes()'s callback: `use (&$constantArrayValuesCount)` - * in the holder's first state slot, every ConstantArrayType's value - * count added, the type traversed */ - static void countValueTypesCallback(zval *count, zval *state1, uint32_t argc, zval *argv, zval *return_value) + /* countConstantArrayValueTypes()'s callback: `use (&$constantArrayValuesCount, + * &$countsByType)` in the holder's two state slots. A type seen before + * adds its memoized count without being traversed again; otherwise every + * ConstantArrayType's value count is added, the type traversed and its + * count memoized as [$type, $count] (the entry keeps the type alive, so + * its object id is not reused) */ + static void countValueTypesCallback(zval *count, zval *countsByType, uint32_t argc, zval *argv, zval *return_value) { - (void) state1; if (UNEXPECTED(!checkTraverseCallbackArguments(argc, argv))) return; zval *type = &argv[0]; + zend_ulong id = (zend_ulong) Z_OBJ_HANDLE_P(type); + zval *known = Z_TYPE_P(countsByType) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL_P(countsByType), id) : NULL; + if (known != NULL) { + ZVAL_LONG(count, Z_LVAL_P(count) + Z_LVAL_P(zend_hash_index_find(Z_ARRVAL_P(known), 1))); + ZVAL_COPY(return_value, type); + return; + } + + zend_long countBefore = Z_LVAL_P(count); if (isInstance(type, pt_ce_constant_array_type)) { zv::Val valueTypes = call(type, PT_LC("getvaluetypes")); if (UNEXPECTED(valueTypes.isUndef())) return; @@ -2898,6 +2909,17 @@ class TypeCombinator zv::Val traversed = pt_type_call_callable(&argv[1], 1, type); if (UNEXPECTED(traversed.isUndef())) return; + + zv::Arr entry = zv::Arr::create(2); + entry.push(zv::Ref(type)); + entry.push(zv::Val::integer(Z_LVAL_P(count) - countBefore)); + if (Z_TYPE_P(countsByType) != IS_ARRAY) { + array_init(countsByType); + } + SEPARATE_ARRAY(countsByType); + zval entryZv = zv::Val(std::move(entry)).take(); + zend_hash_index_update(Z_ARRVAL_P(countsByType), id, &entryZv); + traversed.intoReturnValue(return_value); } @@ -2905,7 +2927,11 @@ class TypeCombinator * exception */ [[nodiscard]] static zend_long countConstantArrayValueTypes(zval *types) { - zend_long constantArrayValuesCount = 0; + zval count; + ZVAL_LONG(&count, 0); + zval countsByType; + ZVAL_EMPTY_ARRAY(&countsByType); + zv::Val callback; for (zv::ArrayEntry entry : zv::ArrRef(types)) { /* TypeTraverser::map()'s `Type $type` parameter */ bool isType; @@ -2914,15 +2940,15 @@ class TypeCombinator zend_type_error("%s::map(): Argument #1 ($type) must be of type %s, %s given", ZSTR_VAL(pt_ce_type_traverser->name), ptcls::type, zend_zval_value_name(entry.value().raw())); return -1; } - zval count; - ZVAL_LONG(&count, constantArrayValuesCount); - zv::Val callback = pt_type_native_callback(countValueTypesCallback, &count, NULL); - if (UNEXPECTED(callback.isUndef())) return -1; + if (callback.isUndef()) { + callback = pt_type_native_callback(countValueTypesCallback, &count, &countsByType); + if (UNEXPECTED(callback.isUndef())) return -1; + } zv::Val mapped; if (UNEXPECTED(!pt_type_traverser_map(mapped.raw(), entry.value().raw(), callback.raw()))) return -1; - constantArrayValuesCount = Z_LVAL_P(pt_type_native_callback_state(callback.raw(), 0)); } - return constantArrayValuesCount; + if (callback.isUndef()) return 0; + return Z_LVAL_P(pt_type_native_callback_state(callback.raw(), 0)); } /* {{{ reduceArrays() */ @@ -2982,22 +3008,38 @@ class TypeCombinator PT_FAIL_IF_UNDEF(valueTypes); zv::Val keyTypes = call(array, PT_LC("getkeytypes")); PT_FAIL_IF_UNDEF(keyTypes); + /* keys written through a union offset share one value type - inspect + * it once: object id => [the single value] or null */ + zv::Arr singleValues = zv::Arr::create(0); for (zv::ArrayEntry keyEntry : zv::ArrRef(keyTypes.raw())) { zval *valueType = zend_hash_index_find(Z_ARRVAL_P(valueTypes.raw()), keyEntry.indexKey()); if (UNEXPECTED(valueType == NULL)) { zend_throw_error(NULL, "Undefined array key " ZEND_ULONG_FMT, keyEntry.indexKey()); return zv::Val(); } - zend_long isConstantScalar = callTrinary(valueType, PT_LC("isconstantscalarvalue")); - PT_FAIL_IF_NEG(isConstantScalar); - if (isConstantScalar != PT_TRI_YES) continue; - zv::Val constantScalarValues = call(valueType, PT_LC("getconstantscalarvalues")); - PT_FAIL_IF_UNDEF(constantScalarValues); - if (countOf(constantScalarValues.raw()) != 1) continue; + zend_ulong id = (zend_ulong) Z_OBJ_HANDLE_P(valueType); + zval *single = zend_hash_index_find(singleValues.table(), id); + if (single == NULL) { + zv::Val singleValue = zv::Val::null(); + zend_long isConstantScalar = callTrinary(valueType, PT_LC("isconstantscalarvalue")); + PT_FAIL_IF_NEG(isConstantScalar); + if (isConstantScalar == PT_TRI_YES) { + zv::Val constantScalarValues = call(valueType, PT_LC("getconstantscalarvalues")); + PT_FAIL_IF_UNDEF(constantScalarValues); + if (countOf(constantScalarValues.raw()) == 1) { + zv::Arr wrapped = zv::Arr::create(1); + wrapped.push(zv::Ref(indexOf(constantScalarValues.raw(), 0))); + singleValue = zv::Val(std::move(wrapped)); + } + } + singleValues.arrRef().setIndex(id, zv::Ref(singleValue.raw())); + single = zend_hash_index_find(singleValues.table(), id); + } + if (Z_TYPE_P(single) == IS_NULL) continue; zv::Val keyValue = call(keyEntry.value().raw(), PT_LC("getvalue")); PT_FAIL_IF_UNDEF(keyValue); zval value; - ZVAL_COPY(&value, indexOf(constantScalarValues.raw(), 0)); + ZVAL_COPY(&value, indexOf(single, 0)); if (UNEXPECTED(!symtableSet(values.table(), keyValue.raw(), &value))) return zv::Val(); } return zv::Val(std::move(values)); From 2d2944ddba63204ecdbc407f253c6efd4d4f083d Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 20:33:12 +0000 Subject: [PATCH 2/2] Leave the existing value checks in place around the shape memos isSuperTypeOf() records a comparison only after the `no` early return, and getConstantScalarValuesByKey() keeps its original checks and memoizes their outcome, so the lines carrying the pre-existing TrinaryLogic checks are unchanged. Mutation testing had flagged the moved `$typeUnsealedness->yes()` check: `$bothDefinite` rules out the maybe case there, so its `!no()` mutant cannot be told apart. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b24f-e4cc-7457-9324-5d29caf3c481 --- src/Type/Constant/ConstantArrayType.php | 23 +++++++-------- src/Type/TypeCombinator.php | 21 ++++++++------ turbo-ext/src/TypeCombinator.cpp | 37 ++++++++++++++----------- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 3c1c72973cf..06d1d75571b 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -829,21 +829,22 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult ? spl_object_id($this->valueTypes[$i]) . ':' . spl_object_id($type->valueTypes[$otherIndex]) : null; if ($valuePairKey !== null && array_key_exists($valuePairKey, $valueResults)) { - $isValueSuperType = $valueResults[$valuePairKey]; - } else { - $otherValueType = $type->getOffsetValueType($keyType); - if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { - [, $typeUnsealedValue] = $type->unsealed; - $otherValueType = $typeUnsealedValue; - } - $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); - if ($valuePairKey !== null) { - $valueResults[$valuePairKey] = $isValueSuperType; - } + $results[] = $valueResults[$valuePairKey]; + continue; } + + $otherValueType = $type->getOffsetValueType($keyType); + if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { + [, $typeUnsealedValue] = $type->unsealed; + $otherValueType = $typeUnsealedValue; + } + $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); if ($isValueSuperType->no()) { return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason)); } + if ($valuePairKey !== null) { + $valueResults[$valuePairKey] = $isValueSuperType; + } $results[] = $isValueSuperType; } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index c2a285a700b..28f649cb0e1 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1732,19 +1732,22 @@ private static function getConstantScalarValuesByKey(ConstantArrayType $array): $singleValues = []; foreach ($array->getKeyTypes() as $i => $keyType) { $id = spl_object_id($valueTypes[$i]); - if (!array_key_exists($id, $singleValues)) { - $singleValues[$id] = null; - if ($valueTypes[$i]->isConstantScalarValue()->yes()) { - $constantScalarValues = $valueTypes[$i]->getConstantScalarValues(); - if (count($constantScalarValues) === 1) { - $singleValues[$id] = [$constantScalarValues[0]]; - } + if (array_key_exists($id, $singleValues)) { + if ($singleValues[$id] !== null) { + $values[$keyType->getValue()] = $singleValues[$id][0]; } + continue; + } + $singleValues[$id] = null; + if (!$valueTypes[$i]->isConstantScalarValue()->yes()) { + continue; } - if ($singleValues[$id] === null) { + $constantScalarValues = $valueTypes[$i]->getConstantScalarValues(); + if (count($constantScalarValues) !== 1) { continue; } - $values[$keyType->getValue()] = $singleValues[$id][0]; + $singleValues[$id] = [$constantScalarValues[0]]; + $values[$keyType->getValue()] = $constantScalarValues[0]; } return $values; diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 70f24719cd4..92952bb0daf 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -3019,27 +3019,32 @@ class TypeCombinator } zend_ulong id = (zend_ulong) Z_OBJ_HANDLE_P(valueType); zval *single = zend_hash_index_find(singleValues.table(), id); - if (single == NULL) { - zv::Val singleValue = zv::Val::null(); - zend_long isConstantScalar = callTrinary(valueType, PT_LC("isconstantscalarvalue")); - PT_FAIL_IF_NEG(isConstantScalar); - if (isConstantScalar == PT_TRI_YES) { - zv::Val constantScalarValues = call(valueType, PT_LC("getconstantscalarvalues")); - PT_FAIL_IF_UNDEF(constantScalarValues); - if (countOf(constantScalarValues.raw()) == 1) { - zv::Arr wrapped = zv::Arr::create(1); - wrapped.push(zv::Ref(indexOf(constantScalarValues.raw(), 0))); - singleValue = zv::Val(std::move(wrapped)); - } + if (single != NULL) { + if (Z_TYPE_P(single) != IS_NULL) { + zv::Val keyValue = call(keyEntry.value().raw(), PT_LC("getvalue")); + PT_FAIL_IF_UNDEF(keyValue); + zval value; + ZVAL_COPY(&value, indexOf(single, 0)); + if (UNEXPECTED(!symtableSet(values.table(), keyValue.raw(), &value))) return zv::Val(); } - singleValues.arrRef().setIndex(id, zv::Ref(singleValue.raw())); - single = zend_hash_index_find(singleValues.table(), id); + continue; } - if (Z_TYPE_P(single) == IS_NULL) continue; + zval nullZv; + ZVAL_NULL(&nullZv); + singleValues.arrRef().setIndex(id, zv::Ref(&nullZv)); + zend_long isConstantScalar = callTrinary(valueType, PT_LC("isconstantscalarvalue")); + PT_FAIL_IF_NEG(isConstantScalar); + if (isConstantScalar != PT_TRI_YES) continue; + zv::Val constantScalarValues = call(valueType, PT_LC("getconstantscalarvalues")); + PT_FAIL_IF_UNDEF(constantScalarValues); + if (countOf(constantScalarValues.raw()) != 1) continue; + zv::Arr wrapped = zv::Arr::create(1); + wrapped.push(zv::Ref(indexOf(constantScalarValues.raw(), 0))); + singleValues.arrRef().setIndex(id, zv::Ref(wrapped.raw())); zv::Val keyValue = call(keyEntry.value().raw(), PT_LC("getvalue")); PT_FAIL_IF_UNDEF(keyValue); zval value; - ZVAL_COPY(&value, indexOf(single, 0)); + ZVAL_COPY(&value, indexOf(constantScalarValues.raw(), 0)); if (UNEXPECTED(!symtableSet(values.table(), keyValue.raw(), &value))) return zv::Val(); } return zv::Val(std::move(values));