From d9d1da60a43c69bf619e1e7b7bd8b24368002901 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 1 Oct 2026 13:43:54 +0200 Subject: [PATCH 1/2] Compute the verbosity level of an array shape key only when reporting an error `ConstantArrayType::checkOurKeys()` (used by `ConstantArrayType::accepts()`) calls `VerbosityLevel::getRecommendedLevelByType($valueType, $otherValueType)` for every key it checks. That call walks both value types completely, but its result is only used to describe the two types in the error message of a key whose value is rejected. For every accepted key the walk is wasted, and when a key's value is itself an array shape, the walk covers that whole nested shape too. This PR moves the computation so it only runs when a message is actually built. In PHP it becomes a closure called from the two places that build the message. The C++ twin in turbo-ext (`turbo-ext/src/ConstantArrayType.cpp`) gets the same treatment, with the call moved into `offsetReason()`. Error messages are unchanged. I added a phpbench scenario, `tests/bench/data/nested-array-shape-accepts.php`, with a 150-key shape where 40 keys refer to a nested 150-key shape, passed to a parameter of the same shape. Results (wall time, 3 alternating runs after a discarded warm-up): | Benchmark | Before | After | |---|---|---| | phpbench `nested-array-shape-accepts.php` | 231.2-231.7 ms | 207.6-208.8 ms | | [reproducer](https://github.com/Kocal/sf-ux-css-phpstan-reproducer) app, `phpstan-nested.neon`, without turbo extension | 23.03-23.25 s | 18.80-19.09 s | | same app, with turbo extension loaded | 8.16-8.24 s | 7.12-7.15 s | The reproducer is a small Symfony app that analyses, at level 6, one method taking the symfony/ux-css `CssStyles` shape (847 keys, 144 of them referring to a nested copy of the same shape) and passing it to one `css()` call. Same errors before and after (none). The rest of the phpbench suite stays within its thresholds, the largest slowdown being +1.3% (bug-14869.php, 259 ms vs 256 ms). `tests/PHPStan/Rules` and `tests/PHPStan/Type` pass with and without the turbo extension loaded, and `turbo-ext/tests/smoke.php` plus `turbo-ext/tests/signature-parity.php` pass too. This is independent from #6643 and #6645, which fix other redundant walks over the same kind of shape. ``` XDEBUG_MODE=off tests/vendor/bin/phpbench run --variant=nested-array-shape-accepts.php --report=aggregate ``` --- src/Type/Constant/ConstantArrayType.php | 10 +++--- .../bench/data/nested-array-shape-accepts.php | 28 ++++++++++++++++ turbo-ext/src/ConstantArrayType.cpp | 33 +++++++------------ 3 files changed, 44 insertions(+), 27 deletions(-) create mode 100644 tests/bench/data/nested-array-shape-accepts.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 57fb7769a61..9d8f20dd26b 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -718,13 +718,13 @@ private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult $result = $result->and($hasOffset); $otherValueType = $type->getOffsetValueType($keyType); - $verbosity = VerbosityLevel::getRecommendedLevelByType($valueType, $otherValueType); + $verbosity = static fn (): VerbosityLevel => VerbosityLevel::getRecommendedLevelByType($valueType, $otherValueType); $acceptsValue = $valueType->accepts($otherValueType, $strictTypes)->decorateReasons( static fn (string $reason) => sprintf( 'Offset %s (%s) does not accept type %s: %s', $keyType->describe(VerbosityLevel::precise()), - $valueType->describe($verbosity), - $otherValueType->describe($verbosity), + $valueType->describe($verbosity()), + $otherValueType->describe($verbosity()), $reason, ), ); @@ -733,8 +733,8 @@ private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult sprintf( 'Offset %s (%s) does not accept type %s.', $keyType->describe(VerbosityLevel::precise()), - $valueType->describe($verbosity), - $otherValueType->describe($verbosity), + $valueType->describe($verbosity()), + $otherValueType->describe($verbosity()), ), ]); } diff --git a/tests/bench/data/nested-array-shape-accepts.php b/tests/bench/data/nested-array-shape-accepts.php new file mode 100644 index 00000000000..6586a4887b4 --- /dev/null +++ b/tests/bench/data/nested-array-shape-accepts.php @@ -0,0 +1,28 @@ +css($styles); + } + +} diff --git a/turbo-ext/src/ConstantArrayType.cpp b/turbo-ext/src/ConstantArrayType.cpp index c46f4c8852c..c44edac342e 100644 --- a/turbo-ext/src/ConstantArrayType.cpp +++ b/turbo-ext/src/ConstantArrayType.cpp @@ -1702,12 +1702,10 @@ class ConstantArrayType if (UNEXPECTED(result.isUndef())) return zv::Val(); zv::Val otherValueType = callType(Z_OBJ_P(type), PT_LC("getoffsetvaluetype"), 1, keyType); if (UNEXPECTED(otherValueType.isUndef())) return zv::Val(); - zv::Val verbosity = pt_type_verbosity_recommended(valueType, otherValueType.raw()); - if (UNEXPECTED(verbosity.isUndef())) return zv::Val(); zv::Args args{otherValueType.raw(), strictTypes}; zv::Val acceptsValue = pt_type_op(Z_OBJ_P(valueType), PT_OP_ACCEPTS, 2, args); if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); - zv::Val captured = quadOf(keyType, valueType, verbosity.raw(), otherValueType.raw()); + zv::Val captured = tripleOf(keyType, valueType, otherValueType.raw()); acceptsValue = decorateReasons(acceptsValue.raw(), offsetReasonCallback, captured.raw(), NULL); if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); bool reasonless; @@ -1716,7 +1714,7 @@ class ConstantArrayType zend_long typeIsConstantArray = pt_type_op_trinary(Z_OBJ_P(type), PT_OP_IS_CONSTANT_ARRAY, 0, NULL); if (UNEXPECTED(typeIsConstantArray < 0)) return zv::Val(); if (typeIsConstantArray == PT_TRI_YES) { - acceptsValue = acceptsResultWithReason(acceptsValue.raw(), offsetReason(keyType, valueType, verbosity.raw(), otherValueType.raw(), NULL)); + acceptsValue = acceptsResultWithReason(acceptsValue.raw(), offsetReason(keyType, valueType, otherValueType.raw(), NULL)); if (UNEXPECTED(acceptsValue.isUndef())) return zv::Val(); } } @@ -6178,13 +6176,15 @@ class ConstantArrayType /* the reason of a rejected offset: 'Offset %s (%s) does not accept type * %s' with the key at the precise level and the value types at the * recommended one, a trailing reason after ': ' or a full stop */ - static zv::Val offsetReason(zval *keyType, zval *valueType, zval *verbosity, zval *otherValueType, zend_string *reason) + static zv::Val offsetReason(zval *keyType, zval *valueType, zval *otherValueType, zend_string *reason) { zv::Val keyDescription = describePrecise(keyType); if (UNEXPECTED(keyDescription.isUndef())) return zv::Val(); - zv::Val valueDescription = describeOf(valueType, verbosity); + zv::Val verbosity = pt_type_verbosity_recommended(valueType, otherValueType); + if (UNEXPECTED(verbosity.isUndef())) return zv::Val(); + zv::Val valueDescription = describeOf(valueType, verbosity.raw()); if (UNEXPECTED(valueDescription.isUndef())) return zv::Val(); - zv::Val otherDescription = describeOf(otherValueType, verbosity); + zv::Val otherDescription = describeOf(otherValueType, verbosity.raw()); if (UNEXPECTED(otherDescription.isUndef())) return zv::Val(); if (reason == NULL) { return zv::Val::adoptString(zend_strpprintf(0, "Offset %s (%s) does not accept type %s.", ZSTR_VAL(zv::Ref(keyDescription.raw()).asString()), ZSTR_VAL(zv::Ref(valueDescription.raw()).asString()), ZSTR_VAL(zv::Ref(otherDescription.raw()).asString()))); @@ -6192,7 +6192,7 @@ class ConstantArrayType return zv::Val::adoptString(zend_strpprintf(0, "Offset %s (%s) does not accept type %s: %s", ZSTR_VAL(zv::Ref(keyDescription.raw()).asString()), ZSTR_VAL(zv::Ref(valueDescription.raw()).asString()), ZSTR_VAL(zv::Ref(otherDescription.raw()).asString()), ZSTR_VAL(reason))); } - /* checkOurKeys()'s decorator: state0 = [keyType, valueType, verbosity, otherValueType] */ + /* checkOurKeys()'s decorator: state0 = [keyType, valueType, otherValueType] */ static void offsetReasonCallback(zval *captured, zval *state1, uint32_t argc, zval *argv, zval *return_value) { (void) state1; @@ -6200,10 +6200,9 @@ class ConstantArrayType if (UNEXPECTED(reason == NULL || Z_TYPE_P(captured) != IS_ARRAY)) return; zval *keyType = zend_hash_index_find(Z_ARRVAL_P(captured), 0); zval *valueType = zend_hash_index_find(Z_ARRVAL_P(captured), 1); - zval *verbosity = zend_hash_index_find(Z_ARRVAL_P(captured), 2); - zval *otherValueType = zend_hash_index_find(Z_ARRVAL_P(captured), 3); - ZEND_ASSERT(keyType != NULL && valueType != NULL && verbosity != NULL && otherValueType != NULL); - zv::Val result = offsetReason(keyType, valueType, verbosity, otherValueType, reason); + zval *otherValueType = zend_hash_index_find(Z_ARRVAL_P(captured), 2); + ZEND_ASSERT(keyType != NULL && valueType != NULL && otherValueType != NULL); + zv::Val result = offsetReason(keyType, valueType, otherValueType, reason); if (UNEXPECTED(result.isUndef())) return; result.intoReturnValue(return_value); } @@ -6398,16 +6397,6 @@ class ConstantArrayType return zv::Val(std::move(triple)); } - static zv::Val quadOf(zval *a, zval *b, zval *c, zval *d) - { - zv::Arr quad = zv::Arr::create(4); - quad.push(zv::Ref(a)); - quad.push(zv::Ref(b)); - quad.push(zv::Ref(c)); - quad.push(zv::Ref(d)); - return zv::Val(std::move(quad)); - } - /* !$result->yes() && count($result->reasons) === 0; false = pending * exception */ [[nodiscard]] static bool isNonYesWithoutReasons(zval *result, bool &out) From 97bd3bdffd4d0e26f95d457838fa9bcb86afbafa Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Thu, 1 Oct 2026 13:44:13 +0200 Subject: [PATCH 2/2] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 6aa515b13d6..2276b0f5669 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '0d41107'; + public const EXPECTED_EXTENSION_VERSION = 'd9d1da6'; private static bool $active = false;