From 399c676bb537460eb49dd2a7cfe4c20e077a62ac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 12 Sep 2026 17:01:57 +0200 Subject: [PATCH 1/2] Settle the conditional targets before deriving the guards createConditionalExpressions() derived a guard for every differing key, and since the guard type became the branch set difference that costs a TypeCombinator::remove() of the two branch types - the most expensive step of a merge. A guard is only ever consumed paired with a target: a different key in the first target loop, any key in the second. So the targets are settled first with the cheap checks, the merge returns early when no key can be one, and the subtraction is skipped for a guard whose only possible target is its own key, which the target loop unsets from its own guard set anyway. The first target loop reuses the settled set instead of repeating the checks. Analysing Slevomat: 603.1s -> 591.6s user CPU, with byte-identical output. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LDqrN1pcN3qWc7xzKfkJaF --- src/Analyser/ScopeOps.php | 56 +++++++++++++++++++------ turbo-ext/src/ScopeOps.cpp | 85 +++++++++++++++++++++++++++++--------- 2 files changed, 108 insertions(+), 33 deletions(-) diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index ed77b3baa95..e9020ef1212 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -378,6 +378,42 @@ public static function createConditionalExpressions( { $newVariableTypes = $ourExpressionTypes; + // A guard is only ever consumed paired with a target: a *different* key in + // the first target loop below, any key in the second one. Deriving a guard + // costs a TypeCombinator::remove() of the two branch types - the most + // expensive step of the merge - so settle which keys can be targets with + // the cheap checks first, bail out when none can, and skip the subtraction + // for a guard no target can pair with. The first target loop reuses this + // set instead of repeating the checks. + $targets = []; + $hasUndefinedTarget = false; + foreach (array_keys($differingKeys) as $exprString) { + if (!array_key_exists($exprString, $newVariableTypes)) { + if ( + array_key_exists($exprString, $mergedExpressionTypes) + && !$mergedExpressionTypes[$exprString]->getExpr() instanceof VirtualNode + ) { + $hasUndefinedTarget = true; + } + continue; + } + $targetHolder = $newVariableTypes[$exprString]; + if ($targetHolder->getExpr() instanceof VirtualNode) { + continue; + } + if ( + array_key_exists($exprString, $mergedExpressionTypes) + && $mergedExpressionTypes[$exprString]->equals($targetHolder) + ) { + continue; + } + $targets[$exprString] = true; + } + if (!$hasUndefinedTarget && count($targets) === 0) { + return $conditionalExpressions; + } + $onlySelfIsTarget = !$hasUndefinedTarget && count($targets) === 1; + // When our-branch type is a subtype of their-branch type, the union // absorbs it (merged === their). Such a variable cannot be a *guard* — // its branch set difference below comes out empty — but it remains a @@ -441,6 +477,12 @@ public static function createConditionalExpressions( continue; } + if ($onlySelfIsTarget && array_key_exists($exprString, $targets)) { + // the sole target is this very key, which the target loop unsets from + // its own guard set - no pairing can use this guard + continue; + } + // The set difference between the branch types is the part of our type // the other branch cannot produce: observing it later proves this // branch was taken, even when the full branch types overlap - so a @@ -476,20 +518,8 @@ public static function createConditionalExpressions( $guardIsSuperTypeOfTheirExprCache = []; $theirExprIsSuperTypeOfGuardCache = []; - foreach (array_keys($differingKeys) as $exprString) { - if (!array_key_exists($exprString, $newVariableTypes)) { - continue; - } + foreach (array_keys($targets) as $exprString) { $holder = $newVariableTypes[$exprString]; - if ($holder->getExpr() instanceof VirtualNode) { - continue; - } - if ( - array_key_exists($exprString, $mergedExpressionTypes) - && $mergedExpressionTypes[$exprString]->equals($holder) - ) { - continue; - } $variableTypeGuards = $typeGuards; unset($variableTypeGuards[$exprString]); diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index 8f45bb469dd..614280f4673 100644 --- a/turbo-ext/src/ScopeOps.cpp +++ b/turbo-ext/src/ScopeOps.cpp @@ -476,6 +476,59 @@ class ScopeOps return zv::Val(); } + /* A guard is only ever consumed paired with a target: a *different* key + * in the first target loop below, any key in the second one. Deriving a + * guard costs a TypeCombinator::remove() of the two branch types - the + * most expensive step of the merge - so settle which keys can be targets + * with the cheap checks first, bail out when none can, and skip the + * subtraction for a guard no target can pair with. The first target loop + * reuses this set instead of repeating the checks. Mirrors the twin. */ + zv::ScratchTable targets(8); + bool hasUndefinedTarget = false; + for (auto diffEntry : differingKeys) { + zend_string *key = diffEntry.stringKeyOrNull(); + zend_ulong idx = diffEntry.indexKey(); + + zval *ourSlot = pt_ht_find(ours.table(), key, idx); + zval *mergedSlot = pt_ht_find(merged.table(), key, idx); + if (ourSlot == NULL) { + if (mergedSlot != NULL) { + zv::Ref mergedHolder = zv::Ref(mergedSlot).deref(); + if (UNEXPECTED(!pt_check_holder(mergedHolder.raw()))) { + return zv::Val(); + } + if (!instanceof_function(holderExpr(mergedHolder)->ce, virtualNodeCe)) { + hasUndefinedTarget = true; + } + } + continue; + } + zv::Ref holder = zv::Ref(ourSlot).deref(); + if (UNEXPECTED(!pt_check_holder(holder.raw()))) { + return zv::Val(); + } + if (instanceof_function(holderExpr(holder)->ce, virtualNodeCe)) { + continue; + } + if (mergedSlot != NULL) { + zv::Ref mergedHolder = zv::Ref(mergedSlot).deref(); + bool equal; + if (UNEXPECTED(!pt_holder_equals(mergedHolder.raw(), holder.raw(), &equal))) { + return zv::Val(); + } + if (equal) { + continue; + } + } + zval targetTrue; + ZVAL_TRUE(&targetTrue); + pt_ht_update(targets.table(), key, idx, &targetTrue); + } + if (!hasUndefinedTarget && targets.size() == 0) { + return zv::Arr::copyOfTable(conditional.table()); + } + bool onlySelfIsTarget = !hasUndefinedTarget && targets.size() == 1; + zv::ScratchTable guardsToExclude(8); zv::ScratchTable typeGuards(8); /* owns the remainder-typed holders created below; the scratch table @@ -587,6 +640,12 @@ class ScopeOps continue; } + if (onlySelfIsTarget && pt_ht_exists(targets.table(), key, idx)) { + /* the sole target is this very key, which the target loop unsets + * from its own guard set - no pairing can use this guard */ + continue; + } + /* the branch set difference — see the twin for why an unchanged * remainder falls back to the merged-type comparison */ zv::Val remainder = typeCombinatorRemove(holderType(holder), holderType(theirHolder)); @@ -641,32 +700,18 @@ class ScopeOps zv::ScratchTable guardIsConstantArrayCache(8); zv::Arr result; /* stays UNDEF until the first append duplicates the input */ - /* main loop: pair non-merged expressions with guards */ - for (auto diffEntry : differingKeys) { - zend_string *key = diffEntry.stringKeyOrNull(); - zend_ulong idx = diffEntry.indexKey(); + /* main loop: pair non-merged expressions with guards - the settled + * target set already carries the cheap checks */ + for (auto targetEntry : zv::TableRef(targets.table())) { + zend_string *key = targetEntry.stringKeyOrNull(); + zend_ulong idx = targetEntry.indexKey(); zval *ourSlot = pt_ht_find(ours.table(), key, idx); - if (ourSlot == NULL) { + if (UNEXPECTED(ourSlot == NULL)) { continue; } zv::Ref holder = zv::Ref(ourSlot).deref(); - if (instanceof_function(holderExpr(holder)->ce, virtualNodeCe)) { - continue; - } - zval *mergedSlot = pt_ht_find(merged.table(), key, idx); - if (mergedSlot != NULL) { - zv::Ref mergedHolder = zv::Ref(mergedSlot).deref(); - bool equal; - if (UNEXPECTED(!pt_holder_equals(mergedHolder.raw(), holder.raw(), &equal))) { - return zv::Val(); - } - if (equal) { - continue; - } - } - bool hasSelfGuard = pt_ht_exists(typeGuards.table(), key, idx); if (typeGuards.size() - (hasSelfGuard ? 1 : 0) == 0) { continue; From bff769cbf68b201c8a49341fdd515479ddc360b5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 12 Sep 2026 17:01:58 +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 5b4c21119a1..87de0d52d0f 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -22,7 +22,7 @@ final class TurboExtensionEnabler * version is the short SHA of the last commit touching turbo-ext/src/, * enforced by the phar.yml turbo-version job. */ - public const EXPECTED_EXTENSION_VERSION = 'f010107'; + public const EXPECTED_EXTENSION_VERSION = '399c676'; private static bool $typeCombinatorCacheEnabled = false;