From 456da7e2c67cd38c068bbf321ec53323fecfb0f6 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 14:12:04 +0200 Subject: [PATCH 01/50] Dispatch: dedupe candidate targets with a set, in_array was quadratic in the candidates --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index bda91c047..078448f7b 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -4949,6 +4949,8 @@ private function dynamicMethodZeroArgHelper(string $method, Type $retT, int $lin $targets = []; $erasedSyms = []; $distinct = []; + /** @var array $seenTargets */ + $seenTargets = []; $fallback = ''; foreach ($this->methodHolders($method) as $cn => $decl) { $cd = $this->classes[$cn]; @@ -4960,7 +4962,7 @@ private function dynamicMethodZeroArgHelper(string $method, Type $retT, int $lin if (!$this->hasEmittedFunction($full)) { continue; } $cands[] = $cd->name; $targets[$cd->name] = $full; - if (!\in_array($full, $distinct, true)) { $distinct[] = $full; } + if (!isset($seenTargets[$full])) { $seenTargets[$full] = true; $distinct[] = $full; } } if ($fallback === '' || $distinct === []) { $body .= " ret i64 0\n}\n\n"; @@ -9957,6 +9959,8 @@ private function emitMethodCallInner(\Compile\Mir\MethodCall_ $n): string // override (so `static::` binds to the real object's class). $targets = []; $distinct = []; + /** @var array $seenTargets */ + $seenTargets = []; $liveCands = []; // Symbols that are ERASED THUNKS (erasedEntry rewrote them): a thunk // already boxes its result to a cell, so the per-arm cell-boxing below @@ -9989,8 +9993,7 @@ private function emitMethodCallInner(\Compile\Mir\MethodCall_ $n): string if (!$this->hasEmittedFunction($full)) { continue; } $liveCands[] = $c; $targets[$c] = $full; - if (\Compile\Stats::$on) { \Compile\Stats::bump('dispatch.in_array_probes', \count($distinct)); } - if (!\in_array($full, $distinct, true)) { $distinct[] = $full; } + if (!isset($seenTargets[$full])) { $seenTargets[$full] = true; $distinct[] = $full; } } \Compile\Stats::bump('dispatch.arms_emitted', \count($distinct)); $fallbackFull = $this->lsbTarget($fallback, $mc->method, $static); From 85fcf5e2ceb1b2d80e447e9f390b5e1eabf62782 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 14:12:05 +0200 Subject: [PATCH 02/50] EmitLlvm: buffer function bodies and hoist fat bodies in memory instead of per-function temp files --- src/Compile/Mir/Passes/EmitLlvm.php | 65 ++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index ae28edb29..6f358e32a 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -203,6 +203,22 @@ private function rootSnapshot(string $phase, \Compile\Mir\Module $module, bool $ private int $switchCounter = 0; /** Sequence for private per-function raw text sink files. */ private int $functionTextCounter = 0; + private string $bodyPend = ''; + + private function bodyAppend(string $path, string $s): void + { + $this->bodyPend .= $s; + if (\strlen($this->bodyPend) >= 1048576) { $this->bodyFlush($path); } + } + + private function bodyFlush(string $path): void + { + if ($this->bodyPend === '') { return; } + if (!\Manticore\append_file_bytes($path, $this->bodyPend)) { + throw new \RuntimeException('EmitLlvm: cannot append staged body file ' . $path); + } + $this->bodyPend = ''; + } /** {@see EmitLlvmObjects::methodHolders}: method => [class => resolved holder]. * @var array> */ @@ -959,6 +975,7 @@ public function emit(Module $module): string $fileHoistThreshold = 262144; $fileHoistedBodies = 0; $fileHoistedBytes = 0; + $this->bodyPend = ''; if ($streaming && !\Manticore\write_file($bodyPath, '')) { throw new \RuntimeException('EmitLlvm: cannot create staged body file ' . $bodyPath); } @@ -1061,6 +1078,20 @@ public function emit(Module $module): string // both temporary paths immediately. $hoistedPath = $rawBodyPath . '.hoisted'; $h = new \Compile\Mir\HoistAllocas(); + $rawSz = \filesize($rawBodyPath); + $rawTxt = ($rawSz !== false && $rawSz <= 67108864) ? \file_get_contents($rawBodyPath) : false; + if ($rawTxt !== false) { + $hoistedTxt = $h->runFast($rawTxt); + unset($rawTxt); + $hoistedAllocas += $h->moved; + $fileHoistedBodies += 1; + $fileHoistedBytes += $rawBodyBytes; + $this->bodyAppend($bodyPath, $hoistedTxt . "\n\n"); + unset($hoistedTxt); + \Manticore\sys_unlink($rawBodyPath); + $bodyBytes += $rawBodyBytes; + } else { + $this->bodyFlush($bodyPath); if (!$h->runFile($rawBodyPath, $hoistedPath)) { throw new \RuntimeException('EmitLlvm: cannot hoist sink file ' . $rawBodyPath); } @@ -1084,6 +1115,7 @@ public function emit(Module $module): string \Manticore\sys_unlink($rawBodyPath); \Manticore\sys_unlink($hoistedPath); $bodyBytes += $rawBodyBytes; + } } elseif ($rawBodyBytes < $fileHoistThreshold) { // Small functions do not justify a filesystem round-trip. Keep // their bounded in-memory rewrite; only fat bodies use the file @@ -1092,9 +1124,7 @@ public function emit(Module $module): string $h = new \Compile\Mir\HoistAllocas(); $body = $h->runFast($body); $hoistedAllocas += $h->moved; - if (!\Manticore\append_file_bytes($bodyPath, $body)) { - throw new \RuntimeException('EmitLlvm: cannot append in-memory body'); - } + $this->bodyAppend($bodyPath, $body); unset($body); $bodyBytes += $rawBodyBytes; } else { @@ -1104,6 +1134,17 @@ public function emit(Module $module): string // copy. The old path materialized `$body`, then `run()` exploded // it into lines and joined a second full string. For a large // Doctrine function that creates a needless two-body peak. + if ($rawBodyBytes <= 67108864) { + $h = new \Compile\Mir\HoistAllocas(); + $body = $h->runFast($body); + $hoistedAllocas += $h->moved; + $fileHoistedBodies += 1; + $fileHoistedBytes += $rawBodyBytes; + $this->bodyAppend($bodyPath, $body); + unset($body); + $bodyBytes += $rawBodyBytes; + } else { + $this->bodyFlush($bodyPath); $fnPath = $bodyPath . '.fn.' . (string)$emitIndex; $hoistedPath = $fnPath . '.hoisted'; if (!\Manticore\write_file($fnPath, $body)) { @@ -1129,6 +1170,7 @@ public function emit(Module $module): string \Manticore\sys_unlink($fnPath); \Manticore\sys_unlink($hoistedPath); $bodyBytes += $nBody; + } } } else { $functionBodyChunks[] = $body; @@ -1276,6 +1318,18 @@ public function emit(Module $module): string // compiler lifetime; the emitted LLVM order and target ABI are stable. $appendStreamedBody = function (string $rawBody, string $label) use (&$bodyBytes, &$hoistedAllocas, &$fileHoistedBodies, &$fileHoistedBytes, $bodyPath): void { if ($rawBody === '') { return; } + if (\strlen($rawBody) <= 67108864) { + $h = new \Compile\Mir\HoistAllocas(); + $nBody = \strlen($rawBody); + $rawBody = $h->runFast($rawBody); + $hoistedAllocas += $h->moved; + $fileHoistedBodies += 1; + $fileHoistedBytes += $nBody; + $this->bodyAppend($bodyPath, $rawBody); + $bodyBytes += $nBody; + return; + } + $this->bodyFlush($bodyPath); $id = (string)(++$this->functionTextCounter); $rawPath = $bodyPath . '.helper.' . $id; $hoistedPath = $rawPath . '.hoisted'; @@ -1302,9 +1356,7 @@ public function emit(Module $module): string $extraBodies = $h->runFast($extraBodies); \Compile\Stats::line('IR: extra bodies hoisted'); $hoistedAllocas += $h->moved; - if (!\Manticore\append_file_bytes($bodyPath, $extraBodies)) { - throw new \RuntimeException('EmitLlvm: cannot append staged body file ' . $bodyPath); - } + $this->bodyAppend($bodyPath, $extraBodies); $bodyBytes += \strlen($extraBodies); unset($extraBodies); \Compile\Stats::line('IR: extra bodies appended'); @@ -1358,6 +1410,7 @@ public function emit(Module $module): string \Compile\Stats::step(' emit preamble', $statT, -1, -1); \Compile\Stats::line('IR: preamble ' . (string)\strlen($preamble) . ' bytes'); if ($streaming) { + $this->bodyFlush($bodyPath); $statT = \Compile\Stats::begin(' hoist allocas (streamed)'); $preambleBytes = \strlen($preamble); if ($spooled) { From 63f6e927195965f9a7a4711dbae420fe66adcc01 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 14:12:05 +0200 Subject: [PATCH 03/50] SplitModule: classify each global once and close parts over a worklist, same part text --- src/Compile/Mir/SplitModule.php | 185 ++++++++++++++++++++++++-------- 1 file changed, 141 insertions(+), 44 deletions(-) diff --git a/src/Compile/Mir/SplitModule.php b/src/Compile/Mir/SplitModule.php index 40dca934f..e2f9af42b 100644 --- a/src/Compile/Mir/SplitModule.php +++ b/src/Compile/Mir/SplitModule.php @@ -77,6 +77,18 @@ final class SplitModule /** @var array> */ private array $refsCache = []; + /** @var array global name => globalClass(), computed once */ + private array $gcls = []; + + /** @var array definition symbol => index in defOrder */ + private array $defPos = []; + + /** @var array global name => index in globalOrder */ + private array $gpos = []; + + /** @var array part => its own non-internal definitions in defOrder order */ + private array $partMine = []; + /** * @return string[] one complete module text per part */ @@ -165,6 +177,7 @@ public function run(string $ir, int $parts): array $this->sharedDefs = \count($shared); $this->internalDefs = \count($internal); + $this->indexModule($defOrder, $globalOrder, $globals); $assign = $this->assignParts($shared, $defOrder, $defSize, $defRefs, $internal, $parts); // Threaded through the part loop: the first part that reaches a // coalesced global defines it, every later part declares it. @@ -202,6 +215,42 @@ public function run(string $ir, int $parts): array return $out; } + /** + * Insert $v into the sorted tail of $a starting at $from. + * + * @param int[] $a + */ + private function sortedInsert(array &$a, int $from, int $v): void + { + $lo = $from; + $hi = \count($a); + while ($lo < $hi) { + $mid = ($lo + $hi) >> 1; + if ($a[$mid] < $v) { $lo = $mid + 1; } else { $hi = $mid; } + } + \array_splice($a, $lo, 0, [$v]); + } + + /** + * @param string[] $defOrder + * @param string[] $globalOrder + * @param array $globals + */ + private function indexModule(array $defOrder, array $globalOrder, array $globals): void + { + $this->gcls = []; + $this->gpos = []; + $k = 0; + foreach ($globalOrder as $g) { + $this->gcls[$g] = $this->globalClass($globals[$g]); + $this->gpos[$g] = $k; + $k = $k + 1; + } + $this->defPos = []; + $i = 0; + foreach ($defOrder as $s) { $this->defPos[$s] = $i; $i = $i + 1; } + } + /** * The `@.btl.` base-line globals (EmitLlvm::btPush), claimed up front so * no ordinary part owns one: every part declares them `external`, and one @@ -480,12 +529,16 @@ private function closePart(int $p, array $defOrder, array $defRefs, array $defSi array $assign, array $internal, array $globalOrder, array $globals, array &$owned): PartPlan { - /** @var string[] */ - $mine = []; - foreach ($defOrder as $s) { - if (isset($internal[$s])) { continue; } - if (($assign[$s] ?? -1) === $p) { $mine[] = $s; } + if ($p === 0) { + $this->partMine = []; + foreach ($defOrder as $s) { + if (isset($internal[$s])) { continue; } + $a = $assign[$s] ?? -1; + if ($a >= 0) { $this->partMine[$a][] = $s; } + } } + /** @var string[] */ + $mine = $this->partMine[$p] ?? []; /** @var array */ $refs = []; foreach ($mine as $s) { @@ -524,8 +577,12 @@ private function closePart(int $p, array $defOrder, array $defRefs, array $defSi } /** @var string[] */ $avail = []; - foreach ($defOrder as $s) { - if (!isset($availSet[$s])) { continue; } + /** @var int[] */ + $availPos = []; + foreach ($availSet as $s => $_) { $availPos[] = $this->defPos[$s]; } + \sort($availPos); + foreach ($availPos as $i) { + $s = $defOrder[$i]; $avail[] = $s; foreach ($defRefs[$s] as $r => $_) { $refs[$r] = true; } } @@ -546,7 +603,7 @@ private function closePart(int $p, array $defOrder, array $defRefs, array $defSi // (`@E__fqns` -> `@E__fqn_0`) and would otherwise be emitted without it. if ($p === 0) { foreach ($globalOrder as $g) { - if ($this->globalClass($globals[$g]) === 'strong') { $refs[$g] = true; } + if ($this->gcls[$g] === 'strong') { $refs[$g] = true; } } } // ⚠ The closure follows a global's INITIALIZER only where this part @@ -560,45 +617,84 @@ private function closePart(int $p, array $defOrder, array $defRefs, array $defSi $needG = []; /** @var array */ $ownG = []; - $progress = true; - while ($progress) { - $progress = false; - $changed = true; - while ($changed) { - $changed = false; - foreach ($defOrder as $s) { - if (!isset($internal[$s]) || isset($haveInternal[$s]) || !isset($refs[$s])) { continue; } - $haveInternal[$s] = true; - foreach ($defRefs[$s] as $r => $_) { $refs[$r] = true; } - $changed = true; - $progress = true; - } + // Worklist form of the joint fixpoint. It visits names in exactly the + // order the old defOrder/globalOrder sweeps did (a name reached ahead of + // the sweep cursor is taken in the same sweep, one behind it in the next), + // because `$refs` insertion order decides the order of the `declare`s. + /** @var int[] */ + $pendI = []; + /** @var int[] */ + $pendG = []; + foreach ($refs as $r => $_) { + $r = (string)$r; + if (isset($internal[$r])) { $pendI[] = $this->defPos[$r]; $haveInternal[$r] = false; } + elseif (isset($this->gcls[$r])) { $pendG[] = $this->gpos[$r]; $needG[$r] = false; } } - $changed = true; - while ($changed) { - $changed = false; - foreach ($globalOrder as $g) { - if (!isset($refs[$g]) || isset($needG[$g])) { continue; } - $needG[$g] = true; - $cls = $this->globalClass($globals[$g]); - if ($cls === 'coalesced') { - if (isset($owned[$g])) { continue; } - $owned[$g] = true; - $ownG[$g] = true; - } elseif ($cls === 'strong' && $p !== 0) { - // Part 0 defines every strong global; here it is a bare - // `external` with no initializer to follow. - continue; + while ($pendI !== [] || $pendG !== []) { + while ($pendI !== []) { + $cur = $pendI; + $pendI = []; + \sort($cur); + $idx = 0; + while ($idx < \count($cur)) { + $i = $cur[$idx]; + $idx = $idx + 1; + $sym = $defOrder[$i]; + $haveInternal[$sym] = true; + foreach ($defRefs[$sym] as $r => $_) { + if (isset($refs[$r])) { continue; } + $refs[$r] = true; + $r = (string)$r; + if (isset($internal[$r])) { + $haveInternal[$r] = false; + $j = $this->defPos[$r]; + if ($j > $i) { $this->sortedInsert($cur, $idx, $j); } else { $pendI[] = $j; } + } elseif (isset($this->gcls[$r])) { + $needG[$r] = false; + $pendG[] = $this->gpos[$r]; + } + } + } + } + while ($pendG !== []) { + $cur = $pendG; + $pendG = []; + \sort($cur); + $idx = 0; + while ($idx < \count($cur)) { + $i = $cur[$idx]; + $idx = $idx + 1; + $g = $globalOrder[$i]; + $needG[$g] = true; + $cls = $this->gcls[$g]; + if ($cls === 'coalesced') { + if (isset($owned[$g])) { continue; } + $owned[$g] = true; + $ownG[$g] = true; + } elseif ($cls === 'strong' && $p !== 0) { + continue; + } + foreach ($this->refsOf('global:' . $g, $globals[$g]) as $r => $_) { + if (isset($refs[$r])) { continue; } + $refs[$r] = true; + $r = (string)$r; + if (isset($internal[$r])) { + $haveInternal[$r] = false; + $pendI[] = $this->defPos[$r]; + } elseif (isset($this->gcls[$r])) { + $needG[$r] = false; + $j = $this->gpos[$r]; + if ($j > $i) { $this->sortedInsert($cur, $idx, $j); } else { $pendG[] = $j; } + } + } } - foreach ($this->refsOf('global:' . $g, $globals[$g]) as $r => $_) { $refs[$r] = true; } - $changed = true; - $progress = true; } } - } - foreach ($defOrder as $s) { - if (isset($haveInternal[$s])) { $mine[] = $s; } - } + /** @var int[] */ + $hp = []; + foreach ($haveInternal as $s => $_) { $hp[] = $this->defPos[$s]; } + \sort($hp); + foreach ($hp as $i) { $mine[] = $defOrder[$i]; } $plan = new PartPlan(); $plan->mine = $mine; $plan->avail = $avail; @@ -625,7 +721,7 @@ private function renderPart(PartPlan $plan, int $p, array $defHead, array $assig $gtext = ''; foreach ($globalOrder as $g) { $gl = $globals[$g]; - $cls = $this->globalClass($gl); + $cls = $this->gcls[$g]; if ($cls === 'local') { if (!isset($needG[$g])) { continue; } $gtext = $gtext . $this->promoteGlobal($gl) . "\n"; @@ -816,6 +912,7 @@ public function runFile(string $llPath, int $parts, string $outBase): array } $this->sharedDefs = \count($shared); $this->internalDefs = \count($internal); + $this->indexModule($defOrder, $globalOrder, $globals); $assign = $this->assignParts($shared, $defOrder, $defSize, $defRefs, $internal, $parts); // Threaded through the part loop: the first part that reaches a // coalesced global defines it, every later part declares it. From ac4de4d2c67af167ebcecc69c10762231985abd3 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 14:12:06 +0200 Subject: [PATCH 04/50] EscapeSummaries: pool write sets by hash and cache class ancestor sets instead of one set per SCC --- src/Compile/Mir/EscapeSummaries.php | 112 ++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 7 deletions(-) diff --git a/src/Compile/Mir/EscapeSummaries.php b/src/Compile/Mir/EscapeSummaries.php index 339569425..ceb8c7f92 100644 --- a/src/Compile/Mir/EscapeSummaries.php +++ b/src/Compile/Mir/EscapeSummaries.php @@ -68,8 +68,22 @@ final class EscapeSummaries private array $byMethodMemo = []; /** @var array class / interface → every class that is-a it */ private array $isAMemo = []; + /** @var array> class → every class / interface it is-a */ + private array $ancestorsMemo = []; /** @var array */ private array $pureNames = []; + /** @var array> slot sets by content: most functions write the same few */ + private array $poolSets = []; + /** @var array set hash → ids of the pooled sets with it (0 is reserved for 'no set') */ + private array $setBuckets = []; + private int $poolSeq = 0; + private int $lastPoolId = 0; + /** @var array function → id of its pooled write set */ + private array $writesId = []; + /** @var array callee write-set ids already queued by this walk */ + private array $ewSeenIds = []; + /** @var array> callee write sets met outside recording, tested without being merged */ + private array $ewCalleeSets = []; public static function fromModule(Module $module): self { @@ -166,7 +180,11 @@ public function windowMeets(array $window, ?Node $consumer, array $held): bool foreach ($window as $w) { $this->walk($w); } if ($consumer !== null) { $this->noteConsumer($consumer); } if (!$this->ewPark || $this->ewAny) { return true; } - return $this->keysMeet($held, $this->ewWrites); + if ($this->keysMeet($held, $this->ewWrites)) { return true; } + foreach ($this->ewCalleeSets as $set) { + if ($this->keysMeet($held, $set)) { return true; } + } + return false; } /** @@ -190,8 +208,12 @@ private function noteConsumer(Node $n): void $k = $n->kind; if ($k === Node::KIND_STORE_PROPERTY) { $saved = $this->ewWrites; + $savedSets = $this->ewCalleeSets; + $savedIds = $this->ewSeenIds; $this->noteStoreProperty(self::asStoreProperty($n)); $this->ewWrites = $saved; + $this->ewCalleeSets = $savedSets; + $this->ewSeenIds = $savedIds; return; } if ($k === Node::KIND_STORE_ELEMENT || $k === Node::KIND_ARRAY_ACCESS) { @@ -207,12 +229,49 @@ private function noteConsumer(Node $n): void $this->noteCall($n); } + /** + * One shared copy per distinct set of slots: a module of 130k functions otherwise holds + * 130k private copies of the same few sets. + * + * @param array $set + * @return array + */ + private function pooled(array $set): array + { + // Bucketed by an order-free hash and confirmed key by key: no canonical order, so no sort. + $n = \count($set); + $h = $n * 1000003; + foreach ($set as $k => $unused) { + $ks = (string)$k; + $len = \strlen($ks); + $h = $h + $len * 7919 + \ord($ks) * 131 + \ord(\substr($ks, $len >> 1, 1)); + } + $bucket = $this->setBuckets[$h] ?? []; + foreach ($bucket as $id) { + $cand = $this->poolSets[$id]; + if (\count($cand) !== $n) { continue; } + $same = true; + foreach ($set as $k => $unused) { + if (!isset($cand[$k])) { $same = false; break; } + } + if ($same) { $this->lastPoolId = $id; return $cand; } + } + $this->poolSeq = $this->poolSeq + 1; + $this->poolSets[$this->poolSeq] = $set; + $bucket[] = $this->poolSeq; + $this->setBuckets[$h] = $bucket; + $this->lastPoolId = $this->poolSeq; + return $set; + } + private function resetScratch(): void { $this->ewWrites = []; $this->ewAny = false; $this->ewPark = true; $this->ewWhy = ''; + $this->ewSeenIds = []; + $this->ewCalleeSets = []; } private function compute(Module $module): void @@ -257,7 +316,7 @@ private function compute(Module $module): void $callees[$name] = \array_keys($this->ewReads); $localPark[$name] = $this->ewPark; $localAny[$name] = $this->ewAny; - $localWrites[$name] = $this->ewWrites; + $localWrites[$name] = $this->pooled($this->ewWrites); $localWhy[$name] = $this->ewWhy; } // 2. Strongly connected components, callees first. Every member of a @@ -283,16 +342,33 @@ private function compute(Module $module): void if ((!$this->parkFree[$c] || $this->writesAny[$c]) && $why === '') { $why = 'via ' . $c; } if (!$this->parkFree[$c]) { $park = false; } if ($this->writesAny[$c]) { $any = true; } - foreach ($this->writes[$c] as $wk => $unused) { $writes[$wk] = true; } + // First set adopted whole (shared, not copied); later ones add only what is missing. + if ($writes === []) { $writes = $this->writes[$c]; continue; } + foreach ($this->writes[$c] as $wk => $unused) { if (!isset($writes[$wk])) { $writes[$wk] = true; } } } } + // The union only grows from its parts: a callee set of the same size IS the union. + $pooledWrites = null; + $pooledId = 0; + $total = \count($writes); + foreach ($scc as $m) { + foreach ($callees[$m] as $c) { + if (!isset($inScc[$c]) && \count($this->writes[$c]) === $total) { $pooledWrites = $this->writes[$c]; $pooledId = $this->writesId[$c] ?? 0; break 2; } + } + } + if ($pooledWrites === null) { $pooledWrites = $this->pooled($writes); $pooledId = $this->lastPoolId; } foreach ($scc as $m) { $this->parkFree[$m] = $park; $this->writesAny[$m] = $any; - $this->writes[$m] = $writes; + $this->writes[$m] = $pooledWrites; + $this->writesId[$m] = $pooledId; if (!$park || $any) { $this->why[$m] = $why; } + // Consumed: a component's members are never read again. + unset($callees[$m], $localWrites[$m], $localWhy[$m]); } } + $this->poolSets = []; + $this->setBuckets = []; \Compile\Stats::line('escape: fns=' . (string)\count($names) . ' sccs=' . (string)\count($sccs)); $this->ewReads = []; $this->ewWrites = []; @@ -533,7 +609,19 @@ private function noteCall(Node $n): void if ((!$this->parkFree[$t] || $this->writesAny[$t]) && $this->ewWhy === '') { $this->ewWhy = 'via ' . $t; } if (!$this->parkFree[$t]) { $this->ewPark = false; } if ($this->writesAny[$t]) { $this->ewAny = true; } - foreach ($this->writes[$t] as $prop => $unused) { $this->ewWrites[$prop] = true; } + if ($this->ewRecording) { + foreach ($this->writes[$t] as $prop => $unused) { $this->ewWrites[$prop] = true; } + } else { + // A finished summary: test it as it is, once per distinct pooled set, instead of + // copying every slot into this walk's set. + $wid = $this->writesId[$t] ?? 0; + if ($wid === 0) { + foreach ($this->writes[$t] as $prop => $unused) { $this->ewWrites[$prop] = true; } + } elseif (!isset($this->ewSeenIds[$wid])) { + $this->ewSeenIds[$wid] = true; + $this->ewCalleeSets[] = $this->writes[$t]; + } + } $off = isset($this->thisFirst[$t]) ? 1 : 0; $refs = $this->refParams[$t] ?? []; $i = 0; @@ -690,6 +778,16 @@ private function isAClasses(string $target): array } private function isA(string $name, string $target): bool + { + if (!isset($this->ancestorsMemo[$name])) { $this->ancestorsMemo[$name] = $this->ancestorsOf($name); } + return isset($this->ancestorsMemo[$name][$target]); + } + + /** + * Everything `$name` is-a, itself included: one walk per class instead of one per pair asked. + * @return array + */ + private function ancestorsOf(string $name): array { /** @var array $seen */ $seen = []; @@ -699,7 +797,6 @@ private function isA(string $name, string $target): bool $c = \array_pop($stack); if ($c === '' || isset($seen[$c])) { continue; } $seen[$c] = true; - if ($c === $target) { return true; } if (!isset($this->classes[$c])) { foreach ($this->interfaceAncestors[$c] ?? [] as $ia) { $stack[] = $ia; } continue; @@ -710,9 +807,10 @@ private function isA(string $name, string $target): bool // `ClassDef::$interfaces` is a bare `array`: its elements arrive erased. foreach ($cd->interfaces as $i) { $stack[] = (string)$i; } } - return false; + return $seen; } + /** * Every concrete class a method NAME can dispatch to, or [] when that is * unknowable: a class with `__call`, or a name a Closure answers. From 75d880bc9d069214bd01c6f3bb8b81240842feb8 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 14:12:06 +0200 Subject: [PATCH 05/50] Inference: scoped rescans and Monomorphize rounds, one closing NarrowReturns round, no builtin clones, MIR precision census under MANTICORE_STATS --- src/Compile/Mir/Passes/InferCalls.php | 16 ++-- src/Compile/Mir/Passes/InferScans.php | 21 ++++- src/Compile/Mir/Passes/InferTypes.php | 115 ++++++++++++++++++++--- src/Compile/Mir/Passes/Monomorphize.php | 31 +++++- src/Compile/Mir/Passes/NarrowReturns.php | 36 +++++-- src/Compile/Mir/PrecisionCensus.php | 80 ++++++++++++++++ src/Manticore/Main.php | 9 +- 7 files changed, 270 insertions(+), 38 deletions(-) create mode 100644 src/Compile/Mir/PrecisionCensus.php diff --git a/src/Compile/Mir/Passes/InferCalls.php b/src/Compile/Mir/Passes/InferCalls.php index 2a35938f5..c00475e50 100644 --- a/src/Compile/Mir/Passes/InferCalls.php +++ b/src/Compile/Mir/Passes/InferCalls.php @@ -1047,15 +1047,19 @@ private function inferMethodCall(MethodCall_ $node): Type */ private function erasedMagicCallReturn(string $method): ?Type { - $magic = null; + $known = $this->erasedMagicAnswers[$method] ?? null; + if ($known !== null) { return $known ? Type::cell() : null; } + $magic = false; foreach ($this->classes as $cd) { - if ($this->resolveMethodClass($cd->name, $method) !== '') { return null; } - if ($magic === null && $this->classDefinesMagic($cd->name, '__call')) { - // Any value may sit behind an erased receiver (a closure, null): __call answers only some. - $magic = Type::cell(); + if ($this->resolveMethodClass($cd->name, $method) !== '') { + $this->erasedMagicAnswers[$method] = false; + return null; } + $magic = $magic || $this->classDefinesMagic($cd->name, '__call'); } - return $magic; + $this->erasedMagicAnswers[$method] = $magic; + // Any value may sit behind an erased receiver (a closure, null): __call answers only some. + return $magic ? Type::cell() : null; } /** Whether `$class` (or an ancestor) implements `$iface`, transitively diff --git a/src/Compile/Mir/Passes/InferScans.php b/src/Compile/Mir/Passes/InferScans.php index f377a8b9b..2f559cca3 100644 --- a/src/Compile/Mir/Passes/InferScans.php +++ b/src/Compile/Mir/Passes/InferScans.php @@ -107,6 +107,8 @@ private function setPropType(\Compile\Mir\ClassDef $cd, string $prop, Type $t): $cur = $cd->propertyTypes[$prop] ?? null; if ($cur !== null && $cur->exactString() === $t->exactString()) { return false; } $cd->propertyTypes[$prop] = $t; + $this->retypedProps[$prop] = true; + $this->rescanProps[$prop] = true; $this->setInheritedPropType($cd, $prop, $t); if ($this->ctx !== null) { $this->ctx->changes->addProp($prop); } return true; @@ -689,9 +691,10 @@ private function scanStaticPropElemFromStores(Module $module): bool // ONE walk for every retyped slot — a walk per slot was the module // times the static properties that have stores. $changed = false; + $this->rescanTouched = []; foreach ($module->functions as $fn) { if (!$this->bodyHas($fn, Node::KIND_STATIC_PROP)) { continue; } - if ($this->retypeStaticPropNodes($fn->body, $targets)) { $changed = true; } + if ($this->retypeStaticPropNodes($fn->body, $targets)) { $changed = true; $this->rescanTouched[$fn->name] = true; } } if ($changed && $this->ctx !== null) { foreach ($targets as $g => $unused) { $this->ctx->changes->addProp($g); } @@ -1140,6 +1143,8 @@ private function scanCallSiteArrayElems(Module $module): bool */ private function scanDocListKeyPromote(Module $module): bool { + $this->rescanTouched = []; + $this->rescanProps = []; $cand = []; // "fn#idx" → true foreach ($module->functions as $fn) { if ($fn->isExtern || $fn->isPrelude) { continue; } @@ -1197,6 +1202,7 @@ private function scanDocListKeyPromote(Module $module): bool $newT = Type::assoc(Type::cell(), $elem === null ? Type::unknown() : $elem); if ($param->type->exactString() === $newT->exactString()) { continue; } $param->type = $newT; + $this->rescanTouched[$fn->name] = true; $changed = true; } } @@ -1209,7 +1215,7 @@ private function scanDocListKeyPromote(Module $module): bool if ($this->setPropType($cd, $prop, Type::assoc( Type::cell(), $elem === null ? Type::unknown() : $elem, - ))) { $changed = true; } + ))) { $this->rescanProps[$prop] = true; $changed = true; } } } return $changed; @@ -1726,6 +1732,7 @@ private function scanByRefCaptureWiden(Module $module): bool $byName = []; foreach ($module->functions as $f) { $byName[$f->name] = $f; } $changed = false; + $this->rescanTouched = []; foreach ($module->functions as $fn) { // A prelude body is linkonce_odr and shared across modules — never // specialize one from this module's capture sites. @@ -1761,6 +1768,8 @@ private function scanByRefCaptureWiden(Module $module): bool && !isset($this->byRefCaptureCellLocals[$clName][$pn])) { $this->byRefCaptureCellLocals[$clName][$pn] = true; $changed = true; + $this->rescanTouched[$fn->name] = true; + $this->rescanTouched[$clName] = true; } if ($this->byRefCaptureDisagrees($cl, $pn, $siteKind) || $this->byRefCaptureDisagrees($fn, $local, $siteKind)) { @@ -1768,10 +1777,14 @@ private function scanByRefCaptureWiden(Module $module): bool $this->byRefCaptureCellLocals[$fn->name][$local] = true; $this->boxParamAtEntry($fn, $local); $changed = true; + $this->rescanTouched[$fn->name] = true; + $this->rescanTouched[$clName] = true; } if (!isset($this->byRefCaptureCellLocals[$clName][$pn])) { $this->byRefCaptureCellLocals[$clName][$pn] = true; $changed = true; + $this->rescanTouched[$fn->name] = true; + $this->rescanTouched[$clName] = true; } continue; } @@ -1786,11 +1799,15 @@ private function scanByRefCaptureWiden(Module $module): bool $this->forcedCellElemLocals[$fn->name][$local] = true; $this->byRefCaptureElemLocals[$fn->name][$local] = true; $changed = true; + $this->rescanTouched[$fn->name] = true; + $this->rescanTouched[$clName] = true; } if (!isset($this->forcedCellElemLocals[$clName][$pn])) { $this->forcedCellElemLocals[$clName][$pn] = true; $this->byRefCaptureElemLocals[$clName][$pn] = true; $changed = true; + $this->rescanTouched[$fn->name] = true; + $this->rescanTouched[$clName] = true; } } } diff --git a/src/Compile/Mir/Passes/InferTypes.php b/src/Compile/Mir/Passes/InferTypes.php index f5522e37e..d0d27ae0b 100644 --- a/src/Compile/Mir/Passes/InferTypes.php +++ b/src/Compile/Mir/Passes/InferTypes.php @@ -297,6 +297,8 @@ private function functionsForScope(Module $module): array * @var array */ private array $rescanTouched = []; + /** @var array properties {@see setPropType} retyped since the last {@see inferAfterPropScan} */ + private array $retypedProps = []; /** @var array "fn#idx" → caller candidate params its erased sites forward */ private array $callArgForward = []; private string $callArgScanFn = ''; @@ -312,6 +314,26 @@ private function functionsForScope(Module $module): array * graph is one walk against the twelve full re-inferences it replaces. * @return array|null */ + /** + * Re-infer after a property scan: the readers of what it retyped, following a change only as far + * as types keep moving. A scan that changed something without naming a property re-infers all. + */ + private function inferAfterPropScan(Module $module, string $reason): void + { + $props = $this->retypedProps; + $this->retypedProps = []; + if ($props === []) { + $this->inferFunctionsForScope($module, $reason); + return; + } + $seed = []; + $graph = $this->depGraph($module); + foreach ($props as $p => $_) { + foreach ($graph->usersOfProp((string)$p) as $u => $__) { $seed[$u] = true; } + } + $this->inferPropagating($module, $reason, $seed); + } + private function rescanScope(Module $module): ?array { if ($this->rescanTouched === []) { return null; } @@ -323,6 +345,21 @@ private function rescanScope(Module $module): ?array return $out; } + /** + * Re-infer what a scan touched, and follow the change only as far as types keep moving + * ({@see inferPropagating}): the touched functions plus the readers of the properties it + * retyped. Whole-module rescans cost 2.3 s per pass on 130k functions for a handful of moves. + */ + private function rescanPropagating(Module $module, string $reason): void + { + $seed = $this->rescanTouched; + foreach ($this->rescanProps as $prop => $unused) { + foreach ($this->depGraph($module)->usersOfProp((string)$prop) as $u => $unused2) { $seed[$u] = true; } + } + $this->rescanProps = []; + $this->inferPropagating($module, $reason, $seed); + } + /** * The scoped pass: infer the seed wave, and whenever a function's observable * types move, pull its direct neighbours in ({@see @@ -417,6 +454,7 @@ private function inferPropagating(Module $module, string $reason, array $seed): if (\Compile\Stats::$on) { \Compile\Stats::bump('infer.rescan.' . $reason . '.calls', 1); \Compile\Stats::bump('infer.rescan.' . $reason . '.functions', \count($queue)); + \Compile\Stats::line(' propagate ' . $reason . ' seed=' . (string)\count($seed) . ' visited=' . (string)\count($queue)); } } @@ -438,6 +476,36 @@ public function reinferAfterNarrow(Module $module, array $narrowed): void $this->inferPropagating($module, 'narrow_light', $seed); } + /** + * A LIGHT round after {@see Monomorphize} spliced new functions `$seed` into + * the module: register them, then re-infer them and everything that calls + * them, with the tables of this instance's last {@see run}. No module scan + * runs; the next full inference covers these bodies. + * @param array $seed + */ + public function reinferAfterSplice(Module $module, array $seed): void + { + foreach ($module->functions as $fn) { + if (!isset($seed[$fn->name])) { continue; } + if (!isset($module->declaredReturnTypes[$fn->name])) { + $module->declaredReturnTypes[$fn->name] = $fn->returnType; + } + $this->sigs[$fn->name] = $fn->returnType; + $this->fnByName[$fn->name] = $fn; + if ($fn->returnType->kind === Type::KIND_UNKNOWN && !$fn->isExtern) { + $this->undeclaredReturnFns[$fn->name] = true; + } + } + $this->declaredReturns = $module->declaredReturnTypes; + $this->callGraph = null; + $this->bodyKinds = []; + $all = $seed; + foreach ($seed as $name => $unused) { + foreach ($this->depGraph($module)->neighbors($name) as $nb => $alsoUnused) { $all[$nb] = true; } + } + $this->inferPropagating($module, 'mono_light', $all); + } + /** * Every node type of every function in `$names`, as one string: equal * digests mean a re-inference moved nothing there. By Type INSTANCE: one id @@ -502,8 +570,9 @@ private function inferFunctionsForScope(Module $module, string $reason = 'other' } foreach ($functions as $fn) { $this->inferFunction($fn); $this->noteTypeChange($fn); } if (\Compile\Stats::$on) { - \Compile\Stats::bump('infer.rescan.' . $reason . '.ms', - \intdiv(\Compile\Stats::now() - $startNs, 1000000)); + $ms = \intdiv(\Compile\Stats::now() - $startNs, 1000000); + \Compile\Stats::bump('infer.rescan.' . $reason . '.ms', $ms); + \Compile\Stats::line(' rescan ' . $reason . ' fns=' . (string)\count($functions) . ' ms=' . (string)$ms); } } @@ -731,6 +800,9 @@ private function inferFunctionsForScope(Module $module, string $reason = 'other' /** @var array `Recv::method` → the classes whose body a virtual call can reach */ private array $virtualBodies = []; + /** @var array method name → is it answered by `__call` alone ({@see erasedMagicCallReturn}) */ + private array $erasedMagicAnswers = []; + /** @var array class/interface name → every class under it, itself included */ private array $subtypesOf = []; private bool $subtypeIndexBuilt = false; @@ -874,6 +946,7 @@ private function inferFunctionsForScope(Module $module, string $reason = 'other' public function run(Module $module): Module { + $tRun = \Compile\Stats::now(); $this->sigs = []; $this->callGraph = null; $this->rescanTouched = []; @@ -962,6 +1035,7 @@ public function run(Module $module): Module // rule the local promotion follows, and the same reason it is computed // up front rather than forced afterwards. $this->scanRefCellProps($module); + $tWalk = \Compile\Stats::now(); if ($this->scopeNames === null || $this->ctx === null) { foreach ($this->functionsForScope($module) as $fn) { $this->inferFunction($fn); @@ -970,6 +1044,7 @@ public function run(Module $module): Module } else { $this->inferScopedWorklist($module); } + $tPost = \Compile\Stats::now(); // A local array passed BY-REF to a callee that APPENDS a FOREIGN element // (`push_str(array &$a){ $a[]='tail'; }` over `[1,2,3]`) is really a // MIXED array: left alone, the callee is narrowed to the caller's @@ -982,7 +1057,7 @@ public function run(Module $module): Module // their own rebuild buffer is typed. Bounded: a seed only widens to cell. $guard = 0; while ($guard < 4 && $this->scanByRefElemWiden($module)) { - $this->inferFunctionsForScope($module, 'byref_elem', $this->rescanScope($module)); + $this->rescanPropagating($module, 'byref_elem'); $guard = $guard + 1; } // Call-site element inference: refine a bare-`array` param to vec[T] @@ -992,7 +1067,7 @@ public function run(Module $module): Module // element-used-as-key lands under a positional int. Externs (the // separately-linked stdlib) can't be specialized this way. if ($this->scanCallSiteArrayElems($module)) { - $this->inferFunctionsForScope($module, 'callsite_array', $this->rescanScope($module)); + $this->rescanPropagating($module, 'callsite_array'); } // A doc-declared `T[]` / `array` param handed a STRING-KEYED array by // some call site: move it to the tagged key channel instead of refusing @@ -1005,7 +1080,7 @@ public function run(Module $module): Module // guard is a backstop, not the termination argument. $docGuard = 0; while ($docGuard < 4 && $this->scanDocListKeyPromote($module)) { - $this->inferFunctionsForScope($module, 'doc_list_key'); + $this->rescanPropagating($module, 'doc_list_key'); $docGuard = $docGuard + 1; } // Property-type inference from a whole-array assignment: `$this->p = @@ -1013,8 +1088,9 @@ public function run(Module $module): Module // the DECLARED property type so a later `$this->p[$i]` / foreach reads a // tagged cell instead of a raw i64 (the property analogue of a typed // local literal). Runs before the element scan so the shape is set first. + $this->retypedProps = []; if ($this->scanPropTypeFromArrayAssign($module)) { - $this->inferFunctionsForScope($module, 'prop_type'); + $this->inferAfterPropScan($module, 'prop_type'); } // Property-element inference from stores: a bare-`array` property that // only ever receives element stores of ONE concrete scalar/object type @@ -1023,14 +1099,17 @@ public function run(Module $module): Module // sourced from a now-typed param is seen concrete, not unknown. Without // it the property element stays erased and a read-back bitcasts each raw // i64 to a garbage double. + $this->retypedProps = []; if ($this->scanPropElemFromStores($module)) { - $this->inferFunctionsForScope($module, 'prop_elem'); + $this->inferAfterPropScan($module, 'prop_elem'); } // The same, for a STATIC property. Its stores mostly sit outside the // declaring class, so neither the lowering-time AST scan nor the // instance scan above can reach them. + $this->retypedProps = []; if ($this->scanStaticPropElemFromStores($module)) { - $this->inferFunctionsForScope($module, 'static_prop_elem'); + $this->rescanProps = []; + $this->rescanPropagating($module, 'static_prop_elem'); } // By-ref param type inference: an UNTYPED (`cell`) `&$p` holds the // caller's RAW slot value (a string/array pointer), but a cell type @@ -1123,7 +1202,7 @@ public function run(Module $module): Module // A callee the call-site scans pinned to the pre-widening element // (`array_reverse($t)`) is re-observed against the widened local. if ($this->scanCallSiteArrayElems($module)) { - $this->inferFunctionsForScope($module, 'nested_elem_callsite_array'); + $this->rescanPropagating($module, 'nested_elem_callsite_array'); } $guard = $guard + 1; } @@ -1142,7 +1221,7 @@ public function run(Module $module): Module while ($guard < 4 && $this->scanByRefElemWiden($module)) { $this->inferFunctionsForScope($module, 'byref_elem_second'); if ($this->scanCallSiteArrayElems($module)) { - $this->inferFunctionsForScope($module, 'byref_elem_callsite_array'); + $this->rescanPropagating($module, 'byref_elem_callsite_array'); } $guard = $guard + 1; } @@ -1152,7 +1231,7 @@ public function run(Module $module): Module // capture site and the closure body have been typed once. $guard = 0; while ($guard < 4 && $this->scanByRefCaptureWiden($module)) { - $this->inferFunctionsForScope($module, 'byref_capture'); + $this->rescanPropagating($module, 'byref_capture'); $guard = $guard + 1; } // A local handed to a `mixed &` parameter is likewise one word two @@ -1185,7 +1264,7 @@ public function run(Module $module): Module // A retyped param captured `use (&$i)` is a cell at the capture // site now, and the closure's side of that word must follow. $captured = $retyped && $this->scanByRefCaptureWiden($module); - if ($captured) { $this->inferFunctionsForScope($module, 'byref_capture'); } + if ($captured) { $this->rescanPropagating($module, 'byref_capture'); } if (!$retyped && !$widened && !$captured) { break; } $rounds = $rounds + 1; if ($rounds > $cap) { @@ -1201,6 +1280,8 @@ public function run(Module $module): Module // When it changes a property type, re-run inference so the new // element type propagates into reads (`foreach $obj->prop as $x` // and the subsequent append now see obj elements → rc them). + $this->rescanTouched = []; + $this->rescanProps = []; $changed = $this->scanCtorPropContainers($module); // Second assoc-prop scan now that node types are inferred: a // string-keyed store whose key is a property read / method call @@ -1210,7 +1291,7 @@ public function run(Module $module): Module // the assoc path → assoc_cow reads past the 16-byte vec buffer. if ($this->scanAssocProps($module)) { $changed = true; } if ($changed) { - $this->inferFunctionsForScope($module, 'assoc_prop'); + $this->rescanPropagating($module, 'assoc_prop'); } // Second pass once capture sites are typed: closure bodies re-infer // with their capture locals seeded (see inferFunction) — a captured @@ -1265,7 +1346,7 @@ public function run(Module $module): Module // add as `unknown` and found no disagreement; this one sees the cell. $guard = 0; while ($guard < 4 && $this->scanByRefCaptureWiden($module)) { - $this->inferFunctionsForScope($module, 'byref_capture_post'); + $this->rescanPropagating($module, 'byref_capture_post'); $guard = $guard + 1; } if ($this->ctx !== null && $this->scopeNames === null) { $this->ctx->seeded = true; } @@ -1275,6 +1356,12 @@ public function run(Module $module): Module $module->inferGlobalVarTypes = $this->globalVarTypes; $module->inferByRefCaptureElemLocals = $this->byRefCaptureElemLocals; $module->markPassApplied(self::NAME); + if (\Compile\Stats::$on) { + \Compile\Stats::bump('infer.t.pre_scans_ms', \intdiv($tWalk - $tRun, 1000000)); + \Compile\Stats::bump('infer.t.walk_ms', \intdiv($tPost - $tWalk, 1000000)); + \Compile\Stats::bump('infer.t.post_ms', \intdiv(\Compile\Stats::now() - $tPost, 1000000)); + \Compile\Stats::bump('infer.t.runs', 1); + } return $module; } diff --git a/src/Compile/Mir/Passes/Monomorphize.php b/src/Compile/Mir/Passes/Monomorphize.php index f21e5954d..6d574bfa3 100644 --- a/src/Compile/Mir/Passes/Monomorphize.php +++ b/src/Compile/Mir/Passes/Monomorphize.php @@ -123,6 +123,12 @@ public function run(Module $module): Module return $module; } + /** The instance of round 1's full inference, whose tables the light rounds reuse. */ + private ?InferTypes $infer = null; + + /** Some round re-typed only its clones and their callers. */ + public bool $lightRoundRan = false; + /** One specialization round. Returns true if anything was specialized * (and the module re-typed), false at the fixpoint. */ private function runOnce(Module $module): bool @@ -139,7 +145,7 @@ private function runOnce(Module $module): bool $this->collectCalls($fn->body); } - // originalName → [ specializedFunctionDef, ... ] + /** @var array $clonesByOrig originalName → its specializations */ $clonesByOrig = []; $changed = false; @@ -181,8 +187,23 @@ private function runOnce(Module $module): bool // With the analysis context, so what a round moves — the clones, the // repointed callers — is in the change set the next scoped pass reads. if ($this->ctx !== null) { $this->ctx->refresh($module); } - $infer = new InferTypes(null, $this->ctx); - $infer->run($module); + // Round 1 specializes most of what will ever be specialized and gets a + // full inference; later rounds add a handful of clones, so re-infer those + // and what calls them. The module scans have not seen those bodies until + // the next full inference: {@see $lightRoundRan} makes NarrowReturns run one. + if ($this->infer === null || $this->ctx !== null) { + $this->infer = new InferTypes(null, $this->ctx); + $this->infer->run($module); + return true; + } + /** @var array $seed */ + $seed = []; + foreach ($clonesByOrig as $clones) { + foreach ($clones as $clone) { $seed[$clone->name] = true; } + } + foreach ($this->newClosureFns as $clFn) { $seed[$clFn->name] = true; } + $this->infer->reinferAfterSplice($module, $seed); + $this->lightRoundRan = true; return true; } @@ -850,6 +871,10 @@ private function isDefiniteElem(Type $t): bool private function isCandidate(FunctionDef $fn): bool { if ($fn->name === '__main') { return false; } + // A same-named codegen builtin shadows this body at every direct call; a `$mono$` + // renames the call, so the emitter never asks the builtin and the slow body runs. + if ($fn->name === 'array_key_exists' || $fn->name === 'in_array' || $fn->name === 'array_keys' + || $fn->name === 'array_values' || $fn->name === 'array_is_list') { return false; } if ($fn->isExtern || $fn->isGenerator) { return false; } if ($fn->ffiSymbol !== null) { return false; } // Already a specialization (`f$mono$…`) — its params are concrete, so diff --git a/src/Compile/Mir/Passes/NarrowReturns.php b/src/Compile/Mir/Passes/NarrowReturns.php index 319ece611..d20130dfa 100644 --- a/src/Compile/Mir/Passes/NarrowReturns.php +++ b/src/Compile/Mir/Passes/NarrowReturns.php @@ -60,11 +60,17 @@ final class NarrowReturns implements Pass * narrows every agreeing array return. */ /** - * The pass ended on a FULL inference over the module exactly as it leaves - * it: the last round narrowed nothing, so nothing moved after that run. A - * caller about to re-infer the unchanged module can skip it. + * A FULL inference ran during this pass, and only light rounds followed it: + * each of those re-inferred the callers of what it narrowed. A caller about + * to re-infer the module only to type it can skip that. */ - public bool $endedOnFullInfer = false; + public bool $moduleTyped = false; + + /** + * The incoming module has bodies no full inference has scanned (a light + * Monomorphize round), so the pass runs one even if nothing narrows. + */ + public bool $needsFullInfer = false; public function __construct(private bool $concreteOnly = false, private ?\Compile\Mir\AnalysisContext $analysis = null, private bool $targetedInfer = false) {} @@ -101,6 +107,11 @@ public function run(Module $module): Module // exactly as before — instead of a full inference per narrowed chain link. $light = null; $lastLight = false; + // Full inferences that may re-open the fixpoint after light rounds settle. + // What one finds is a handful of getters (29 of ~2,000 on Laravel), so + // more of them cost 15-30 s each for nothing. The early pass needs none: + // the pipeline re-infers right after it. + $closes = $this->concreteOnly ? 0 : 1; $max = \count($module->functions) + 2; while ($iters < $max) { $iters = $iters + 1; @@ -130,10 +141,16 @@ public function run(Module $module): Module // full inference and let the loop have another go; a second // no-change round after a FULL inference is the real fixpoint. // A light round is scoped the same way, so it closes the same way. - if (!$lastLight) { - if (!$this->targetedInfer || $this->analysis === null || $sawFull) { break; } - $closing = true; - $sawFull = true; + if ($lastLight && $this->analysis === null) { + if ($closes === 0) { break; } + $closes = $closes - 1; + } elseif (!$lastLight) { + $mustInfer = $this->needsFullInfer && !$this->moduleTyped; + if (!$mustInfer) { + if (!$this->targetedInfer || $this->analysis === null || $sawFull) { break; } + $closing = true; + $sawFull = true; + } } } else { $sawFull = false; @@ -142,7 +159,6 @@ public function run(Module $module): Module $inferT = \Compile\Stats::begin($stage); $light->reinferAfterNarrow($module, $narrowedNames); $lastLight = true; - $this->endedOnFullInfer = false; \Compile\Stats::step($stage, $inferT, -1, -1); continue; } @@ -178,7 +194,7 @@ public function run(Module $module): Module if ($this->analysis !== null) { $this->analysis->beginRound(); } $infer = new InferTypes($scope, $this->analysis); $infer->run($module); - $this->endedOnFullInfer = $scope === null; + $this->moduleTyped = $scope === null; $lastLight = false; $light = ($scope === null && $this->analysis === null) ? $infer : null; // THE HARNESS. `MANTICORE_INFER_DIFF=1` asks the one question three diff --git a/src/Compile/Mir/PrecisionCensus.php b/src/Compile/Mir/PrecisionCensus.php new file mode 100644 index 000000000..96975abf4 --- /dev/null +++ b/src/Compile/Mir/PrecisionCensus.php @@ -0,0 +1,80 @@ +functions as $fn) { + if ($fn->isExtern) { continue; } + $c->walk($fn->body); + foreach ($fn->params as $p) { + $params = $params + 1; + if (self::erased($p->type)) { $paramsErased = $paramsErased + 1; } + } + $returns = $returns + 1; + if (self::erased($fn->returnType)) { $returnsErased = $returnsErased + 1; } + } + $props = 0; + $propsErased = 0; + foreach ($module->classes as $cd) { + foreach ($cd->propertyTypes as $t) { + $props = $props + 1; + if (self::erased($t)) { $propsErased = $propsErased + 1; } + } + } + return 'precision: nodes=' . (string)$c->nodes + . ' cell=' . (string)$c->cell + . ' unknown=' . (string)$c->unknown + . ' arrays=' . (string)$c->arrays + . ' arraysErased=' . (string)$c->arraysErased + . ' params=' . (string)$params . '/' . (string)$paramsErased + . ' returns=' . (string)$returns . '/' . (string)$returnsErased + . ' props=' . (string)$props . '/' . (string)$propsErased; + } + + /** A `cell`, `unknown`, or an array whose element is either. */ + private static function erased(Type $t): bool + { + if ($t->kind === Type::KIND_CELL || $t->kind === Type::KIND_UNKNOWN) { return true; } + return $t->kind === Type::KIND_ARRAY && self::erasedElement($t); + } + + private static function erasedElement(Type $t): bool + { + $e = $t->element; + return $e === null || $e->kind === Type::KIND_CELL || $e->kind === Type::KIND_UNKNOWN; + } + + private function walk(Node $n): void + { + $this->nodes = $this->nodes + 1; + $t = $n->type; + if ($t->kind === Type::KIND_CELL) { + $this->cell = $this->cell + 1; + } elseif ($t->kind === Type::KIND_UNKNOWN) { + $this->unknown = $this->unknown + 1; + } elseif ($t->kind === Type::KIND_ARRAY) { + $this->arrays = $this->arrays + 1; + if (self::erasedElement($t)) { $this->arraysErased = $this->arraysErased + 1; } + } + foreach (Walk::children($n) as $c) { $this->walk($c); } + } +} diff --git a/src/Manticore/Main.php b/src/Manticore/Main.php index 30be8316a..a821d9f68 100644 --- a/src/Manticore/Main.php +++ b/src/Manticore/Main.php @@ -5400,9 +5400,9 @@ function lower_module(array &$sources, ?\Analyze\MirDiags $collect = null, array $narrowConcrete = new \Compile\Mir\Passes\NarrowReturns(true, $analysisContext, $worklistMode === 'on'); $module = $narrowConcrete->run($module); \Compile\Stats::step('NarrowReturns (concreteOnly)', $statT, \count($module->functions), -1); - // A narrowing pass that closed on a full inference leaves nothing for a - // second one to move: the module is the one that run just typed. - if (!$narrowConcrete->endedOnFullInfer) { + // A narrowing pass that ran a full inference leaves nothing for a second + // one to type: InferTypes #3 re-infers after InlineClosures anyway. + if (!$narrowConcrete->moduleTyped) { $statT = \Compile\Stats::begin('InferTypes #2'); $infer2 = new \Compile\Mir\Passes\InferTypes( ($analysisContext !== null && $worklistMode === 'on') @@ -5439,6 +5439,7 @@ function lower_module(array &$sources, ?\Analyze\MirDiags $collect = null, array $mono = new \Compile\Mir\Passes\Monomorphize($analysisContext); $module = $mono->run($module); \Compile\Stats::step('Monomorphize', $statT, \count($module->functions), -1); + $monoLight = $mono->lightRoundRan; $mono = null; \Manticore\Allocator::release('after-monomorphize'); // Fuse implode(explode()) split-join round-trips into one native @@ -5511,6 +5512,7 @@ function lower_module(array &$sources, ?\Analyze\MirDiags $collect = null, array \Manticore\Allocator::release('after-typecheck'); $statT = \Compile\Stats::begin('NarrowReturns (full)'); $narrow = new \Compile\Mir\Passes\NarrowReturns(false, $analysisContext, $worklistMode === 'on'); + $narrow->needsFullInfer = $monoLight; $module = $narrow->run($module); \Compile\Stats::step('NarrowReturns (full)', $statT, \count($module->functions), -1); $narrow = null; @@ -5614,6 +5616,7 @@ function lower_module(array &$sources, ?\Analyze\MirDiags $collect = null, array $module = $verify->run($module); \Compile\Stats::step('Verify', $statT, \count($module->functions), \count($module->classes)); $verify = null; + if (\Compile\Stats::reporting()) { \Compile\Stats::line(\Compile\Mir\PrecisionCensus::line($module)); } \Manticore\Allocator::release('before-emit'); if ($analysisContext !== null) { $finalScope = $analysisContext->scope(); From e81f5de043bc8ed1abd96c409f77325145384ef9 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 15:01:22 +0200 Subject: [PATCH 06/50] Design: one inference fixpoint with recorded dependencies, from the build-speed measurements --- docs/design/inference-fixpoint.md | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/design/inference-fixpoint.md diff --git a/docs/design/inference-fixpoint.md b/docs/design/inference-fixpoint.md new file mode 100644 index 000000000..4518e5da4 --- /dev/null +++ b/docs/design/inference-fixpoint.md @@ -0,0 +1,80 @@ +# Design: one inference fixpoint + +Status: **proposal**. Replaces the round structure of `InferTypes`, `NarrowReturns` and the inference rounds of `Monomorphize`, and is the foundation incremental compilation (#498) needs. + +_2026-11-07. Numbers are from `examples/symfony-app` (1154 files, 36k functions), macOS arm64, branch of PR #585, unless stated._ + +## The problem + +Inference is still the largest part of a large build: about 51 s of CPU reach the end of `Verify`, with six full `InferTypes` runs of 4-15 s each. A one-line edit pays all of it, because the object cache only skips clang. + +The cheap work is done (PR #585). What is left is flat in the profile and comes from the structure: analyses are separate passes that hand results to each other through whole-module re-runs. + +## What was measured + +| Experiment | Result | What it means | +|---|---|---| +| Callees-first SCC order for the walk | Same rounds, same narrow counts, 16 more `cell` nodes | Order alone cannot help: returns narrow in `NarrowReturns`, between runs, not during the walk | +| Existing scoped mode, `MANTICORE_WORKLIST=on` | ~55% more CPU, 218 more `cell` nodes | Do not build on it | +| `MANTICORE_INFER_DIFF`: full pass after a scoped one | 98→0 and 75→11→0→0 functions of 36k move, alongside 2, then 27, then 2 property retypes only the full pass finds | Divergence is narrow and appears to come through property slots | +| Fewer closing rounds in `NarrowReturns` | 137 fewer `cell` nodes, CPU down | More rounds were not buying precision | +| Interning shape `Type`s | CPU within noise, RSS not better | Not the bottleneck | + +Scoped mode targets 16-21k of 36k functions per round (`would-target`), so its static dependency edges are too wide to save time. They are keyed by bare method name, and dispatch on a common name reaches much of the module. + +## Root causes + +**1. Two directions at once.** Returns start erased and narrow downward; properties start narrow and widen to `cell`. Neither pass reaches a fixpoint of the other, hence the rounds. A descending analysis is also pessimistic: it can only keep what its first, least informed answer allowed. + +**2. `unknown` means two things:** "no information yet" and "erased". This also shows up as `[]` being `vec[unknown]`, and a loop-carried first append keeps that type on its base load even when every store is a string. + +**3. Static dependencies.** Edges are guessed from the shape of the code instead of recorded from what inference read. + +**4. Scans re-derive facts.** About 40 module scans walk bodies to find store patterns, many followed by a re-inference. They are facts about slots computed outside the analysis that consumes them. + +**5. In-place mutation during inference** (box-back stores). Earlier work found it makes a pass non-idempotent, which any cached or scoped result has to survive. + +## Design + +**Slots.** One table of everything crossing a function boundary: function return, parameter, property, static property, global, closure capture. Each holds a value in one lattice. + +**One ascending fixpoint.** Every slot starts at a new `bottom`, distinct from `unknown`, and only moves up by `join`; declared types bound it from above. There is no separate narrowing pass, because a return is simply the join of what its body returns. This is the optimistic style of SCCP: a recursive function's return can settle on a precise type a pessimistic start would never recover, so precision should improve rather than merely hold. + +**Recorded dependencies.** Inferring a function records each slot it reads. When a slot changes, exactly its recorded readers are re-queued, as in Salsa and rustc's query system. HHBBC, the closest precedent for PHP, also tracks dependencies per function (from memory, to be checked against its source before phase 2). Nothing is guessed from method names; a dispatch reads the slots of the candidates it actually resolved. + +**SCC order as a scheduler.** The queue is ordered callees first, by a graph that recorded reads refine. The experiment above showed no gain because returns did not settle during the walk; here they do. Order then should affect speed, not the result. + +**Scans become transfer functions.** A store to a property joins into that property's slot when the store is inferred, so each scan is removed as its fact moves into the walk. + +**Monomorphize requests clones from the same queue.** A clone is a new function with its own parameter and return slots. + +**Mutation moves out.** Box-back stores and similar rewrites run once after the fixpoint, from final types. Inference itself only writes node types and slots. + +**Summaries, then #498.** A function's inputs are the slots it read, so hashing body + inputs gives a cache key for its output and its written slots. Warm rebuilds then skip unchanged functions instead of re-running the program. + +## Plan + +Every phase lands alone and must pass the full suite and difftest, with `precision:` no worse and CPU no worse. + +1. **Split `bottom` from `unknown`.** Smallest change that can show the precision gain. Includes `[]` and the base of a first append. Needs a soundness check on by-ref and dynamic writes, and on the runtime element hint in `emitStoreElementUnified`. +2. **Record dependencies in shadow mode.** Build the read table next to the current code and report what a re-run moved that no recorded edge predicted. Target: zero, on symfony-app, Laravel and the demo. +3. **Move mutation out of inference.** +4. **Replace `NarrowReturns` with the fixpoint for returns,** using the queue. This should retire most full runs. +5. **Move scans into transfer functions** one at a time, most expensive first. Each is its own commit and can be measured alone. +6. **Bring `Monomorphize` onto the queue.** +7. **Function summaries and the cache,** with a warm == cold byte-identity test (#498). + +## Risks + +- **Monotonicity may not hold.** A retype that legitimately narrows something (a `declared` claim withdrawn, say) breaks termination. Phase 2 should count these; the fallback is a per-slot visit cap, and a capped slot goes to `cell`. +- **`unknown` is load-bearing.** `KIND_UNKNOWN` appears on 435 lines in the passes alone, some relying on "not yet known" and some on "erased". Phase 1 will show how entangled this is, and is the go/no-go point for the whole plan. +- **Too-narrow types are miscompiles,** not just slowdowns: a raw word read as a typed value. An optimistic start has more ways to be wrong than a pessimistic one if a store goes unseen, so the "unseen writer" channels (references, dynamic properties, `extract`, reflection) must force their slots to `cell` up front. +- **Long-lived branches conflict.** `InferTypes` is touched by most bug fixes, which is why each phase is small and merges on its own. + +## Expected gain + +An estimate, not a measurement: the six full runs become one walk plus re-queued readers, so inference could fall by half or more on symfony-app. Phase 2's shadow table will give a real number (total functions re-queued against 6 × 36k) before the expensive phases start. + +## What this does not fix + +Emit (about 20-25 s of CPU here), split and clang are unaffected, apart from what fewer boxes save. Most erased arrays stay erased: about half are in generated code that is erased by design, and in a sample of vendor locals 37% were truly heterogeneous. From 9f20e0d467c464a809380070fa3ecd1b5dbc7f7e Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 15:13:05 +0200 Subject: [PATCH 07/50] Design: inference time is post-walk scans and re-inference, not the walk; lead with semi-naive scans --- docs/design/inference-fixpoint.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/design/inference-fixpoint.md b/docs/design/inference-fixpoint.md index 4518e5da4..b014597c0 100644 --- a/docs/design/inference-fixpoint.md +++ b/docs/design/inference-fixpoint.md @@ -19,6 +19,10 @@ The cheap work is done (PR #585). What is left is flat in the profile and comes | `MANTICORE_INFER_DIFF`: full pass after a scoped one | 98→0 and 75→11→0→0 functions of 36k move, alongside 2, then 27, then 2 property retypes only the full pass finds | Divergence is narrow and appears to come through property slots | | Fewer closing rounds in `NarrowReturns` | 137 fewer `cell` nodes, CPU down | More rounds were not buying precision | | Interning shape `Type`s | CPU within noise, RSS not better | Not the bottleneck | +| Time inside each of the six full runs | Pre-scans 2 s, walk 6 s, post-walk 24 s in total | The walk over all functions costs ~1 s per run. **Almost all the time is after it** | +| Profile of `InferTypes::run` | `inferFunction` from post-walk re-inference 37%, from the walk 19%; scans ~40% | Re-inference triggered by scans and the scans themselves are the cost | +| Functions whose types change between runs | 257 to 4576 of ~34k (0.8-13%) | Scans mostly re-read bodies that did not change | +| Arrays with an `unknown` element | 17058 of 120940 erased (14%), 7004 of them literal `[]`; about 1071 local nodes have the same local concrete elsewhere | `bottom` has little to recover | Scoped mode targets 16-21k of 36k functions per round (`would-target`), so its static dependency edges are too wide to save time. They are keyed by bare method name, and dispatch on a common name reaches much of the module. @@ -26,7 +30,7 @@ Scoped mode targets 16-21k of 36k functions per round (`would-target`), so its s **1. Two directions at once.** Returns start erased and narrow downward; properties start narrow and widen to `cell`. Neither pass reaches a fixpoint of the other, hence the rounds. A descending analysis is also pessimistic: it can only keep what its first, least informed answer allowed. -**2. `unknown` means two things:** "no information yet" and "erased". This also shows up as `[]` being `vec[unknown]`, and a loop-carried first append keeps that type on its base load even when every store is a string. +**2. `unknown` means two things,** "no information yet" and "erased", but this is minor. `Type::joinElement` already treats an `unknown` element as an identity, so for array elements it behaves as a bottom today, and few nodes depend on the difference (see the table). **3. Static dependencies.** Edges are guessed from the shape of the code instead of recorded from what inference read. @@ -56,24 +60,28 @@ Scoped mode targets 16-21k of 36k functions per round (`would-target`), so its s Every phase lands alone and must pass the full suite and difftest, with `precision:` no worse and CPU no worse. -1. **Split `bottom` from `unknown`.** Smallest change that can show the precision gain. Includes `[]` and the base of a first append. Needs a soundness check on by-ref and dynamic writes, and on the runtime element hint in `emitStoreElementUnified`. -2. **Record dependencies in shadow mode.** Build the read table next to the current code and report what a re-run moved that no recorded edge predicted. Target: zero, on symfony-app, Laravel and the demo. -3. **Move mutation out of inference.** -4. **Replace `NarrowReturns` with the fixpoint for returns,** using the queue. This should retire most full runs. -5. **Move scans into transfer functions** one at a time, most expensive first. Each is its own commit and can be measured alone. +The first version of this plan started by splitting `bottom` from `unknown`. Measurement rejected that, and also the idea that fewer walks are the main saving: the walk is about 19% of a run. + +1. **Semi-naive scans.** Split each scan into per-function fact extraction and a reduce, and re-extract only from functions whose types changed since the last run. Needs no new architecture, and staged IR must stay byte-identical, which makes each step verifiable. Scans fold in module order and some keep the first observation, so the reduce must preserve that order. One scan at a time, most expensive first: `scanCallSiteArrayElems`, `scanByRefElemWiden`, `scanDocListKeyPromote`, `scanRefCellArgWiden`. +2. **Find why post-walk re-inference is so large.** It costs about twice the walk. Count, per reason, how many re-inferred functions actually changed type. +3. **Record dependencies in shadow mode.** Build the read table next to the current code and report what a re-run moved that no recorded edge predicted. Target: zero, on symfony-app, Laravel and the demo. +4. **Move mutation out of inference.** +5. **Replace `NarrowReturns` with the fixpoint for returns,** using the queue. 6. **Bring `Monomorphize` onto the queue.** 7. **Function summaries and the cache,** with a warm == cold byte-identity test (#498). +Phases 3-7 stay proposals: decide on them after 1 and 2 show what is left. Phases 3 and 7 are what #498 needs regardless of time saved. + ## Risks - **Monotonicity may not hold.** A retype that legitimately narrows something (a `declared` claim withdrawn, say) breaks termination. Phase 2 should count these; the fallback is a per-slot visit cap, and a capped slot goes to `cell`. -- **`unknown` is load-bearing.** `KIND_UNKNOWN` appears on 435 lines in the passes alone, some relying on "not yet known" and some on "erased". Phase 1 will show how entangled this is, and is the go/no-go point for the whole plan. +- **`unknown` is load-bearing.** `KIND_UNKNOWN` appears on 435 lines in the passes alone. An ascending fixpoint needs a real `bottom`, so this cost returns at phase 5. - **Too-narrow types are miscompiles,** not just slowdowns: a raw word read as a typed value. An optimistic start has more ways to be wrong than a pessimistic one if a store goes unseen, so the "unseen writer" channels (references, dynamic properties, `extract`, reflection) must force their slots to `cell` up front. - **Long-lived branches conflict.** `InferTypes` is touched by most bug fixes, which is why each phase is small and merges on its own. ## Expected gain -An estimate, not a measurement: the six full runs become one walk plus re-queued readers, so inference could fall by half or more on symfony-app. Phase 2's shadow table will give a real number (total functions re-queued against 6 × 36k) before the expensive phases start. +An estimate, not a measurement: scans are about 40% of the 32 s spent in full runs on symfony-app, so semi-naive scans could save up to roughly a third of that, and phase 2 may add to it. Laravel is more post-heavy, so it should gain more. The precision benefit of an optimistic start is unmeasured, and the `unknown` numbers above weaken it for array elements. ## What this does not fix From 4e20e666314cf21a464fbb6f30fe4596cc503d4f Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 15:53:34 +0200 Subject: [PATCH 08/50] InferTypes: a settled element-as-key body is not re-inferred, and a body whose signature held still wakes only its callees --- src/Compile/Mir/DependencyIndex.php | 15 ++++++++ src/Compile/Mir/Module.php | 7 ++++ src/Compile/Mir/Passes/InferTypes.php | 53 +++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/Compile/Mir/DependencyIndex.php b/src/Compile/Mir/DependencyIndex.php index 007b28af3..4cb7d1a6c 100644 --- a/src/Compile/Mir/DependencyIndex.php +++ b/src/Compile/Mir/DependencyIndex.php @@ -186,6 +186,21 @@ public function neighbors(string $name): array return $out; } + /** + * The functions `$name` calls, directly or through a dispatch on a bare name. + * @return array + */ + public function calleesOfFn(string $name): array + { + $out = []; + foreach (($this->callees[$name] ?? []) as $n => $_) { $out[$n] = true; } + foreach (($this->methodCallsOf[$name] ?? []) as $m => $_) { + foreach (($this->methodSyms[$m] ?? []) as $sym => $__) { $out[$sym] = true; } + } + unset($out[$name]); + return $out; + } + /** * The first wave of a scoped pass: what moved, what can see it directly, and * whoever names a retyped property. The rest is propagated by the pass itself. diff --git a/src/Compile/Mir/Module.php b/src/Compile/Mir/Module.php index 15720bed1..69727f192 100644 --- a/src/Compile/Mir/Module.php +++ b/src/Compile/Mir/Module.php @@ -309,6 +309,13 @@ final class Module * @var array> */ public array $inferByRefCellElemLocals = []; + + /** + * Function => body digest at which re-inference last left it unmoved, for the + * element-as-key re-inference, across {@see Passes\InferTypes} runs. + * @var array + */ + public array $inferAssocKeySettled = []; /** By-ref array params whose element channel became a cell because the body * hands an element to a by-ref sink of another kind ({@see * Passes\InferScans::scanRefCellArgWiden}); their callers' arrays follow. diff --git a/src/Compile/Mir/Passes/InferTypes.php b/src/Compile/Mir/Passes/InferTypes.php index d0d27ae0b..bd66bb67e 100644 --- a/src/Compile/Mir/Passes/InferTypes.php +++ b/src/Compile/Mir/Passes/InferTypes.php @@ -184,6 +184,13 @@ private function typeFingerprint(FunctionDef $fn): int return $acc; } + private function signatureFingerprint(FunctionDef $fn): int + { + $acc = $this->typeCode($fn->returnType); + foreach ($fn->params as $p) { $acc = $this->fpMix($acc, $this->typeCode($p->type)); } + return $acc; + } + private function fpMix(int $acc, int $v): int { return (($acc * 131) + $v) & 4503599627370495; @@ -434,15 +441,23 @@ private function inferPropagating(Module $module, string $reason, array $seed): } /** @var array $visits */ $visits = []; + $startNs = \Compile\Stats::now(); + $moved = 0; for ($i = 0; $i < \count($queue); $i = $i + 1) { $fn = $queue[$i]; unset($pending[$fn->name]); $visits[$fn->name] = ($visits[$fn->name] ?? 0) + 1; $before = $this->typeFingerprint($fn); + $sigBefore = $this->signatureFingerprint($fn); $this->inferFunction($fn); $this->noteTypeChange($fn); if ($this->typeFingerprint($fn) === $before) { continue; } - foreach ($graph->neighbors($fn->name) as $nb => $unused) { + $moved = $moved + 1; + // A signature that held still is invisible to callers: only the types + // this body passes down moved, and those reach its callees alone. + $next = $this->signatureFingerprint($fn) === $sigBefore + ? $graph->calleesOfFn($fn->name) : $graph->neighbors($fn->name); + foreach ($next as $nb => $unused) { if (isset($pending[$nb]) || ($visits[$nb] ?? 0) >= 4) { continue; } $nfn = $this->fnByName[$nb] ?? null; if ($nfn === null) { continue; } @@ -452,6 +467,7 @@ private function inferPropagating(Module $module, string $reason, array $seed): } } if (\Compile\Stats::$on) { + \Compile\Stats::line(' propagate ' . $reason . ' seed=' . (string)\count($seed) . ' visits=' . (string)\count($queue) . ' moved=' . (string)$moved . ' ms=' . (string)\intdiv(\Compile\Stats::now() - $startNs, 1000000)); \Compile\Stats::bump('infer.rescan.' . $reason . '.calls', 1); \Compile\Stats::bump('infer.rescan.' . $reason . '.functions', \count($queue)); \Compile\Stats::line(' propagate ' . $reason . ' seed=' . (string)\count($seed) . ' visited=' . (string)\count($queue)); @@ -525,6 +541,14 @@ private function bodyTypeDigest(Module $module, array $names): string return $acc; } + /** {@see bodyTypeDigest} of one function, hashed to fit a table that outlives the run. */ + private function bodyDigest(FunctionDef $fn): int + { + $this->digestIds = []; + $this->digestNode($fn->body); + return \crc32(\implode(',', $this->digestIds)); + } + /** @var int[] */ private array $digestIds = []; @@ -1165,11 +1189,34 @@ public function run(Module $module): Module $guard = 0; while ($guard < 4) { $targets = $this->untypedAssocKeyStoreFunctions($module); + // A body re-inference left unmoved earlier, and that still has those + // types, would only stay put again. These matches are mostly genuine + // vecs, so across runs nearly all of the work was this. + /** @var array $before */ + $before = []; + foreach ($module->functions as $fn) { + if (!isset($targets[$fn->name])) { continue; } + $d = $this->bodyDigest($fn); + if (($module->inferAssocKeySettled[$fn->name] ?? -1) === $d) { + unset($targets[$fn->name]); + } else { + $before[$fn->name] = $d; + } + } if (\count($targets) === 0) { break; } - $before = $this->bodyTypeDigest($module, $targets); $this->inferFunctionsForScope($module, 'assoc_key', $targets); $guard = $guard + 1; - if ($this->bodyTypeDigest($module, $targets) === $before) { break; } + $moved = false; + foreach ($module->functions as $fn) { + if (!isset($targets[$fn->name])) { continue; } + $d = $this->bodyDigest($fn); + if ($d === $before[$fn->name]) { + $module->inferAssocKeySettled[$fn->name] = $d; + } else { + $moved = true; + } + } + if (!$moved) { break; } } // Element erasure on a LOCAL: `$out = []` whose only clue is a store of an // already-CELL value (`$out[$k] = $v`). The pre-inference scan can't type a From e360623b7870954277d093dded1787fbbb932f89 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 15:51:24 +0200 Subject: [PATCH 09/50] stdlib_zlib_inflate_stream: stop printing total_in after a preset dictionary, zlib 1.4 changed it (Alpine now ships 1.4) --- .../aot/cases/stdlib_zlib_inflate_stream.php | 18 ++++---- .../expected/stdlib_zlib_inflate_stream.out | 44 +++++++++---------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/tests/aot/cases/stdlib_zlib_inflate_stream.php b/tests/aot/cases/stdlib_zlib_inflate_stream.php index a801ec341..8f553b8d6 100644 --- a/tests/aot/cases/stdlib_zlib_inflate_stream.php +++ b/tests/aot/cases/stdlib_zlib_inflate_stream.php @@ -73,11 +73,13 @@ $phpGzip = hex2bin('1f8b08000000000000134b4c4a0600c241243503000000'); $phpZlib = hex2bin('789c4b4c4a0600024d0127'); -function show(string $label, InflateContext $ctx, string $data, int $flush): void +// `read` is left out once a preset dictionary is involved: zlib 1.4 fixed total_in +// after Z_NEED_DICT, so the count depends on the libz underneath. +function show(string $label, InflateContext $ctx, string $data, int $flush, bool $read = true): void { $r = @inflate_add($ctx, $data, $flush); echo $label, ': ', var_export($r, true), ' status=', inflate_get_status($ctx), - ' read=', inflate_get_read_len($ctx), "\n"; + $read ? ' read=' . inflate_get_read_len($ctx) : '', "\n"; } // Output is every symbol whose bits have arrived, not every finished block. @@ -112,15 +114,15 @@ function show(string $label, InflateContext $ctx, string $data, int $flush): voi // A zlib header asking for a preset dictionary (FDICT). $z = hex2bin('78bb1a0b045dcb403015c06c003b200691'); // 'hello world hello' over dictionary 'hello world' -show('fdict none', inflate_init(ZLIB_ENCODING_DEFLATE), $z, ZLIB_SYNC_FLUSH); +show('fdict none', inflate_init(ZLIB_ENCODING_DEFLATE), $z, ZLIB_SYNC_FLUSH, false); $i = inflate_init(ZLIB_ENCODING_DEFLATE, ['dictionary' => 'hello worle']); -show('fdict wrong', $i, $z, ZLIB_SYNC_FLUSH); -show('fdict wrong again', $i, $z, ZLIB_SYNC_FLUSH); +show('fdict wrong', $i, $z, ZLIB_SYNC_FLUSH, false); +show('fdict wrong again', $i, $z, ZLIB_SYNC_FLUSH, false); $i = inflate_init(ZLIB_ENCODING_DEFLATE, ['dictionary' => 'hello world']); -show('fdict ok', $i, $z, ZLIB_SYNC_FLUSH); -show('fdict second stream', $i, $z, ZLIB_SYNC_FLUSH); +show('fdict ok', $i, $z, ZLIB_SYNC_FLUSH, false); +show('fdict second stream', $i, $z, ZLIB_SYNC_FLUSH, false); $i = inflate_init(ZLIB_ENCODING_DEFLATE, ['dictionary' => 'hello world']); -foreach (str_split($z) as $k => $b) { show("fdict byte $k", $i, $b, ZLIB_SYNC_FLUSH); } +foreach (str_split($z) as $k => $b) { show("fdict byte $k", $i, $b, ZLIB_SYNC_FLUSH, false); } show('dictionary unused', inflate_init(ZLIB_ENCODING_DEFLATE, ['dictionary' => 'xx']), $phpZlib, ZLIB_SYNC_FLUSH); // A zlib header naming a window larger than the inflater's is corrupt. diff --git a/tests/aot/expected/stdlib_zlib_inflate_stream.out b/tests/aot/expected/stdlib_zlib_inflate_stream.out index 15fa542d4..5bd46592e 100644 --- a/tests/aot/expected/stdlib_zlib_inflate_stream.out +++ b/tests/aot/expected/stdlib_zlib_inflate_stream.out @@ -33,28 +33,28 @@ finish: 'onetwothree' status=1 read=25 fill 8191: 8191 status=0 fill 8192: 8192 status=-5 fill 8193: 8193 status=0 -fdict none: false status=2 read=0 -fdict wrong: false status=2 read=0 -fdict wrong again: false status=2 read=0 -fdict ok: 'hello world hello' status=1 read=11 -fdict second stream: false status=2 read=0 -fdict byte 0: '' status=0 read=1 -fdict byte 1: '' status=0 read=2 -fdict byte 2: '' status=0 read=3 -fdict byte 3: '' status=0 read=4 -fdict byte 4: '' status=0 read=5 -fdict byte 5: '' status=-5 read=5 -fdict byte 6: '' status=0 read=6 -fdict byte 7: 'h' status=0 read=7 -fdict byte 8: '' status=0 read=8 -fdict byte 9: 'ello world' status=0 read=9 -fdict byte 10: ' ' status=0 read=10 -fdict byte 11: 'hello' status=0 read=11 -fdict byte 12: '' status=0 read=12 -fdict byte 13: '' status=0 read=13 -fdict byte 14: '' status=0 read=14 -fdict byte 15: '' status=0 read=15 -fdict byte 16: '' status=1 read=16 +fdict none: false status=2 +fdict wrong: false status=2 +fdict wrong again: false status=2 +fdict ok: 'hello world hello' status=1 +fdict second stream: false status=2 +fdict byte 0: '' status=0 +fdict byte 1: '' status=0 +fdict byte 2: '' status=0 +fdict byte 3: '' status=0 +fdict byte 4: '' status=0 +fdict byte 5: '' status=-5 +fdict byte 6: '' status=0 +fdict byte 7: 'h' status=0 +fdict byte 8: '' status=0 +fdict byte 9: 'ello world' status=0 +fdict byte 10: ' ' status=0 +fdict byte 11: 'hello' status=0 +fdict byte 12: '' status=0 +fdict byte 13: '' status=0 +fdict byte 14: '' status=0 +fdict byte 15: '' status=0 +fdict byte 16: '' status=1 dictionary unused: 'abc' status=1 read=11 zlib window9 into 8: false status=-3 read=2 zlib window9 into 9: 'hello' status=1 read=13 From 799702054db7cf7cdca4b83e1a78ca0f7874710e Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 15:51:24 +0200 Subject: [PATCH 10/50] stdlib_zlib_inflate_stream: stop printing total_in after a preset dictionary, zlib 1.4 changed it (Alpine now ships 1.4) --- .../aot/cases/stdlib_zlib_inflate_stream.php | 18 ++++---- .../expected/stdlib_zlib_inflate_stream.out | 44 +++++++++---------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/tests/aot/cases/stdlib_zlib_inflate_stream.php b/tests/aot/cases/stdlib_zlib_inflate_stream.php index a801ec341..8f553b8d6 100644 --- a/tests/aot/cases/stdlib_zlib_inflate_stream.php +++ b/tests/aot/cases/stdlib_zlib_inflate_stream.php @@ -73,11 +73,13 @@ $phpGzip = hex2bin('1f8b08000000000000134b4c4a0600c241243503000000'); $phpZlib = hex2bin('789c4b4c4a0600024d0127'); -function show(string $label, InflateContext $ctx, string $data, int $flush): void +// `read` is left out once a preset dictionary is involved: zlib 1.4 fixed total_in +// after Z_NEED_DICT, so the count depends on the libz underneath. +function show(string $label, InflateContext $ctx, string $data, int $flush, bool $read = true): void { $r = @inflate_add($ctx, $data, $flush); echo $label, ': ', var_export($r, true), ' status=', inflate_get_status($ctx), - ' read=', inflate_get_read_len($ctx), "\n"; + $read ? ' read=' . inflate_get_read_len($ctx) : '', "\n"; } // Output is every symbol whose bits have arrived, not every finished block. @@ -112,15 +114,15 @@ function show(string $label, InflateContext $ctx, string $data, int $flush): voi // A zlib header asking for a preset dictionary (FDICT). $z = hex2bin('78bb1a0b045dcb403015c06c003b200691'); // 'hello world hello' over dictionary 'hello world' -show('fdict none', inflate_init(ZLIB_ENCODING_DEFLATE), $z, ZLIB_SYNC_FLUSH); +show('fdict none', inflate_init(ZLIB_ENCODING_DEFLATE), $z, ZLIB_SYNC_FLUSH, false); $i = inflate_init(ZLIB_ENCODING_DEFLATE, ['dictionary' => 'hello worle']); -show('fdict wrong', $i, $z, ZLIB_SYNC_FLUSH); -show('fdict wrong again', $i, $z, ZLIB_SYNC_FLUSH); +show('fdict wrong', $i, $z, ZLIB_SYNC_FLUSH, false); +show('fdict wrong again', $i, $z, ZLIB_SYNC_FLUSH, false); $i = inflate_init(ZLIB_ENCODING_DEFLATE, ['dictionary' => 'hello world']); -show('fdict ok', $i, $z, ZLIB_SYNC_FLUSH); -show('fdict second stream', $i, $z, ZLIB_SYNC_FLUSH); +show('fdict ok', $i, $z, ZLIB_SYNC_FLUSH, false); +show('fdict second stream', $i, $z, ZLIB_SYNC_FLUSH, false); $i = inflate_init(ZLIB_ENCODING_DEFLATE, ['dictionary' => 'hello world']); -foreach (str_split($z) as $k => $b) { show("fdict byte $k", $i, $b, ZLIB_SYNC_FLUSH); } +foreach (str_split($z) as $k => $b) { show("fdict byte $k", $i, $b, ZLIB_SYNC_FLUSH, false); } show('dictionary unused', inflate_init(ZLIB_ENCODING_DEFLATE, ['dictionary' => 'xx']), $phpZlib, ZLIB_SYNC_FLUSH); // A zlib header naming a window larger than the inflater's is corrupt. diff --git a/tests/aot/expected/stdlib_zlib_inflate_stream.out b/tests/aot/expected/stdlib_zlib_inflate_stream.out index 15fa542d4..5bd46592e 100644 --- a/tests/aot/expected/stdlib_zlib_inflate_stream.out +++ b/tests/aot/expected/stdlib_zlib_inflate_stream.out @@ -33,28 +33,28 @@ finish: 'onetwothree' status=1 read=25 fill 8191: 8191 status=0 fill 8192: 8192 status=-5 fill 8193: 8193 status=0 -fdict none: false status=2 read=0 -fdict wrong: false status=2 read=0 -fdict wrong again: false status=2 read=0 -fdict ok: 'hello world hello' status=1 read=11 -fdict second stream: false status=2 read=0 -fdict byte 0: '' status=0 read=1 -fdict byte 1: '' status=0 read=2 -fdict byte 2: '' status=0 read=3 -fdict byte 3: '' status=0 read=4 -fdict byte 4: '' status=0 read=5 -fdict byte 5: '' status=-5 read=5 -fdict byte 6: '' status=0 read=6 -fdict byte 7: 'h' status=0 read=7 -fdict byte 8: '' status=0 read=8 -fdict byte 9: 'ello world' status=0 read=9 -fdict byte 10: ' ' status=0 read=10 -fdict byte 11: 'hello' status=0 read=11 -fdict byte 12: '' status=0 read=12 -fdict byte 13: '' status=0 read=13 -fdict byte 14: '' status=0 read=14 -fdict byte 15: '' status=0 read=15 -fdict byte 16: '' status=1 read=16 +fdict none: false status=2 +fdict wrong: false status=2 +fdict wrong again: false status=2 +fdict ok: 'hello world hello' status=1 +fdict second stream: false status=2 +fdict byte 0: '' status=0 +fdict byte 1: '' status=0 +fdict byte 2: '' status=0 +fdict byte 3: '' status=0 +fdict byte 4: '' status=0 +fdict byte 5: '' status=-5 +fdict byte 6: '' status=0 +fdict byte 7: 'h' status=0 +fdict byte 8: '' status=0 +fdict byte 9: 'ello world' status=0 +fdict byte 10: ' ' status=0 +fdict byte 11: 'hello' status=0 +fdict byte 12: '' status=0 +fdict byte 13: '' status=0 +fdict byte 14: '' status=0 +fdict byte 15: '' status=0 +fdict byte 16: '' status=1 dictionary unused: 'abc' status=1 read=11 zlib window9 into 8: false status=-3 read=2 zlib window9 into 9: 'hello' status=1 read=13 From b4dcda8cda6820fd2646100f9b0a0a72fc5c4930 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 16:14:31 +0200 Subject: [PATCH 11/50] InferTypes: compare element-as-key bodies by exact types, shape ids move on every inference --- src/Compile/Mir/Passes/InferTypes.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Compile/Mir/Passes/InferTypes.php b/src/Compile/Mir/Passes/InferTypes.php index bd66bb67e..5a2bdc78d 100644 --- a/src/Compile/Mir/Passes/InferTypes.php +++ b/src/Compile/Mir/Passes/InferTypes.php @@ -541,12 +541,22 @@ private function bodyTypeDigest(Module $module, array $names): string return $acc; } - /** {@see bodyTypeDigest} of one function, hashed to fit a table that outlives the run. */ + /** + * A hash of every node's exact type in one function, for a table that + * outlives the run. By content, not instance as {@see bodyTypeDigest} is: an + * inference rebuilds its shapes, so their ids move while the types do not. + */ private function bodyDigest(FunctionDef $fn): int { - $this->digestIds = []; - $this->digestNode($fn->body); - return \crc32(\implode(',', $this->digestIds)); + $acc = 0; + $this->exactDigestNode($fn->body, $acc); + return $acc; + } + + private function exactDigestNode(Node $n, int &$acc): void + { + $acc = $this->fpMix($acc, \crc32($n->type->exactString())); + foreach (Walk::children($n) as $c) { $this->exactDigestNode($c, $acc); } } /** @var int[] */ From e62183b4e55f06d74d599b6725087d726cb28aa8 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 16:26:09 +0200 Subject: [PATCH 12/50] Design: scans read other functions, so semi-naive scans need recorded dependencies first --- docs/design/inference-fixpoint.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/design/inference-fixpoint.md b/docs/design/inference-fixpoint.md index b014597c0..b54de8256 100644 --- a/docs/design/inference-fixpoint.md +++ b/docs/design/inference-fixpoint.md @@ -62,15 +62,15 @@ Every phase lands alone and must pass the full suite and difftest, with `precisi The first version of this plan started by splitting `bottom` from `unknown`. Measurement rejected that, and also the idea that fewer walks are the main saving: the walk is about 19% of a run. -1. **Semi-naive scans.** Split each scan into per-function fact extraction and a reduce, and re-extract only from functions whose types changed since the last run. Needs no new architecture, and staged IR must stay byte-identical, which makes each step verifiable. Scans fold in module order and some keep the first observation, so the reduce must preserve that order. One scan at a time, most expensive first: `scanCallSiteArrayElems`, `scanByRefElemWiden`, `scanDocListKeyPromote`, `scanRefCellArgWiden`. -2. **Find why post-walk re-inference is so large.** It costs about twice the walk. Count, per reason, how many re-inferred functions actually changed type. -3. **Record dependencies in shadow mode.** Build the read table next to the current code and report what a re-run moved that no recorded edge predicted. Target: zero, on symfony-app, Laravel and the demo. +1. **Trim post-walk re-inference.** Done in part: propagation visited 10-40 times more functions than moved, and a body whose signature held still now wakes only its callees; settled element-as-key bodies are skipped. Together about 15% of CPU on symfony-app. +2. **Record dependencies in shadow mode.** Build the read table next to the current code and report what a re-run moved that no recorded edge predicted. Target: zero, on symfony-app, Laravel and the demo. +3. **Semi-naive scans, on top of those dependencies.** Split each scan into per-function fact extraction and a reduce, re-extracting only where an input changed. Gating on the function's own types is not enough: `scanRefCellArgNode` reads the callee's parameter types and body, and `scanByRefElemWiden` reads a module-wide map, so a stale skip misses a widening to `cell`, which is a miscompile. Scans also fold in module order, and some keep the first observation, so the reduce must preserve that order. 4. **Move mutation out of inference.** 5. **Replace `NarrowReturns` with the fixpoint for returns,** using the queue. 6. **Bring `Monomorphize` onto the queue.** 7. **Function summaries and the cache,** with a warm == cold byte-identity test (#498). -Phases 3-7 stay proposals: decide on them after 1 and 2 show what is left. Phases 3 and 7 are what #498 needs regardless of time saved. +Phases 4-7 stay proposals: decide on them after 2 and 3 show what is left. Phases 2 and 7 are what #498 needs regardless of time saved. ## Risks @@ -81,7 +81,7 @@ Phases 3-7 stay proposals: decide on them after 1 and 2 show what is left. Phase ## Expected gain -An estimate, not a measurement: scans are about 40% of the 32 s spent in full runs on symfony-app, so semi-naive scans could save up to roughly a third of that, and phase 2 may add to it. Laravel is more post-heavy, so it should gain more. The precision benefit of an optimistic start is unmeasured, and the `unknown` numbers above weaken it for array elements. +An estimate, not a measurement: scans are about 40% of the 32 s spent in full runs on symfony-app, so semi-naive scans could save up to roughly a third of that. Laravel is more post-heavy, so it should gain more. The precision benefit of an optimistic start is unmeasured, and the `unknown` numbers above weaken it for array elements. ## What this does not fix From 61e51602e3e0ffde973cacd5b601875bcd9fd13b Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 16:52:20 +0200 Subject: [PATCH 13/50] NarrowReturns: the late pass closes on a full inference again, stopping early left properties narrower than their stores --- src/Compile/Mir/Passes/NarrowReturns.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Compile/Mir/Passes/NarrowReturns.php b/src/Compile/Mir/Passes/NarrowReturns.php index d20130dfa..db809dabf 100644 --- a/src/Compile/Mir/Passes/NarrowReturns.php +++ b/src/Compile/Mir/Passes/NarrowReturns.php @@ -107,11 +107,10 @@ public function run(Module $module): Module // exactly as before — instead of a full inference per narrowed chain link. $light = null; $lastLight = false; - // Full inferences that may re-open the fixpoint after light rounds settle. - // What one finds is a handful of getters (29 of ~2,000 on Laravel), so - // more of them cost 15-30 s each for nothing. The early pass needs none: - // the pipeline re-infers right after it. - $closes = $this->concreteOnly ? 0 : 1; + // The early pass need not close on a full inference: the pipeline re-infers + // right after it. The late one must. A module scan in a closing run can still + // retype a property, and stopping early leaves it narrower than its stores. + $closeOnFull = !$this->concreteOnly; $max = \count($module->functions) + 2; while ($iters < $max) { $iters = $iters + 1; @@ -142,8 +141,7 @@ public function run(Module $module): Module // no-change round after a FULL inference is the real fixpoint. // A light round is scoped the same way, so it closes the same way. if ($lastLight && $this->analysis === null) { - if ($closes === 0) { break; } - $closes = $closes - 1; + if (!$closeOnFull) { break; } } elseif (!$lastLight) { $mustInfer = $this->needsFullInfer && !$this->moduleTyped; if (!$mustInfer) { From 657c6e257d72b276b6bb2acf737b3057e952b497 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 16:52:20 +0200 Subject: [PATCH 14/50] MANTICORE_DEPS_SHADOW: report whether read dependencies predict body changes, and that the module reaches a fixpoint --- src/Compile/Mir/Passes/InferTypes.php | 1 + src/Compile/Mir/ShadowDeps.php | 276 ++++++++++++++++++++++++++ src/Manticore/Main.php | 3 + 3 files changed, 280 insertions(+) create mode 100644 src/Compile/Mir/ShadowDeps.php diff --git a/src/Compile/Mir/Passes/InferTypes.php b/src/Compile/Mir/Passes/InferTypes.php index 5a2bdc78d..491744a75 100644 --- a/src/Compile/Mir/Passes/InferTypes.php +++ b/src/Compile/Mir/Passes/InferTypes.php @@ -1412,6 +1412,7 @@ public function run(Module $module): Module $module->inferByRefCaptureCellLocals = $this->byRefCaptureCellLocals; $module->inferGlobalVarTypes = $this->globalVarTypes; $module->inferByRefCaptureElemLocals = $this->byRefCaptureElemLocals; + if ($this->scopeNames === null && \Compile\Mir\ShadowDeps::enabled()) { \Compile\Mir\ShadowDeps::observe($module); } $module->markPassApplied(self::NAME); if (\Compile\Stats::$on) { \Compile\Stats::bump('infer.t.pre_scans_ms', \intdiv($tWalk - $tRun, 1000000)); diff --git a/src/Compile/Mir/ShadowDeps.php b/src/Compile/Mir/ShadowDeps.php new file mode 100644 index 000000000..6ecc9b5b1 --- /dev/null +++ b/src/Compile/Mir/ShadowDeps.php @@ -0,0 +1,276 @@ +> slot => the functions reading it */ + private static array $reads = []; + /** @var array function => its nodes' kinds and exact types */ + private static array $bodies = []; + /** @var array function => its nodes' kinds alone */ + private static array $shapes = []; + /** @var array function => its return type */ + private static array $returns = []; + /** @var array function => its parameter types */ + private static array $params = []; + /** @var array `Class::prop` => its type */ + private static array $props = []; + /** @var array function symbol => bare method name */ + private static array $bareOf = []; + /** @var array class => every class below it, itself included */ + private static array $subtypes = []; + /** @var array */ + private static array $classes = []; + private static bool $seeded = false; + private static int $run = 0; + + /** @var string */ + private static string $collectBody = ''; + /** @var string */ + private static string $collectShape = ''; + + public static function enabled(): bool + { + return \getenv('MANTICORE_DEPS_SHADOW') === '1'; + } + + public static function observe(Module $module): void + { + self::$run = self::$run + 1; + self::indexClasses($module); + /** @var array $bodies */ + $bodies = []; + /** @var array $shapes */ + $shapes = []; + /** @var array $returns */ + $returns = []; + /** @var array $params */ + $params = []; + /** @var array> $reads */ + $reads = []; + foreach ($module->functions as $fn) { + if ($fn->isExtern) { continue; } + self::$collectBody = ''; + self::$collectShape = ''; + self::collect($fn->body, $fn->name, $reads); + $bodies[$fn->name] = self::$collectBody; + $shapes[$fn->name] = self::$collectShape; + $returns[$fn->name] = $fn->returnType->exactString(); + $ps = ''; + foreach ($fn->params as $p) { $ps .= $p->type->exactString() . ','; } + $params[$fn->name] = $ps; + } + /** @var array $props */ + $props = []; + foreach ($module->classes as $cd) { + foreach ($cd->propertyTypes as $name => $t) { + $props[$cd->name . '::' . (string)$name] = $t->exactString(); + } + } + if (self::$seeded) { self::report($bodies, $shapes, $returns, $params, $props); } + self::$seeded = true; + self::$bodies = $bodies; + self::$shapes = $shapes; + self::$returns = $returns; + self::$params = $params; + self::$props = $props; + self::$reads = $reads; + } + + /** + * @param array $bodies + * @param array $shapes + * @param array $returns + * @param array $params + * @param array $props + */ + private static function report(array $bodies, array $shapes, array $returns, array $params, array $props): void + { + /** @var array $predicted */ + $predicted = []; + $retMoved = 0; + $paramMoved = 0; + $propMoved = 0; + foreach ($returns as $name => $r) { + if (!isset(self::$returns[$name]) || self::$returns[$name] === $r) { continue; } + $retMoved = $retMoved + 1; + $predicted[$name] = true; + self::wake('r:' . $name, $predicted); + $bare = self::$bareOf[$name] ?? ''; + if ($bare !== '') { self::wake('rm:' . $bare, $predicted); } + } + foreach ($params as $name => $p) { + if (!isset(self::$params[$name]) || self::$params[$name] === $p) { continue; } + $paramMoved = $paramMoved + 1; + $predicted[$name] = true; + self::wake('r:' . $name, $predicted); + $bare = self::$bareOf[$name] ?? ''; + if ($bare !== '') { self::wake('rm:' . $bare, $predicted); } + } + foreach ($props as $key => $t) { + if ((self::$props[$key] ?? '') === $t) { continue; } + $propMoved = $propMoved + 1; + $cut = \strrpos($key, '::'); + if ($cut === false) { continue; } + $cls = \substr($key, 0, $cut); + $prop = \substr($key, $cut + 2); + self::wake('fw:' . $prop, $predicted); + foreach (self::related($cls) as $c) { self::wake('f:' . $c . '::' . $prop, $predicted); } + } + $actual = 0; + $mutated = 0; + $fresh = 0; + $missed = 0; + /** @var string[] $samples */ + $samples = []; + foreach ($bodies as $name => $b) { + if (!isset(self::$bodies[$name])) { $fresh = $fresh + 1; continue; } + if (self::$bodies[$name] === $b) { continue; } + if (self::$shapes[$name] !== $shapes[$name]) { $mutated = $mutated + 1; continue; } + $actual = $actual + 1; + if (isset($predicted[$name])) { continue; } + $missed = $missed + 1; + if (\count($samples) < 10) { $samples[] = $name . ' :: ' . self::firstDiff(self::$bodies[$name], $b); } + } + \Compile\Stats::line('deps run ' . (string)self::$run + . ': moved ret=' . (string)$retMoved . ' param=' . (string)$paramMoved . ' prop=' . (string)$propMoved + . ' | bodies changed=' . (string)$actual . ' mutated=' . (string)$mutated . ' new=' . (string)$fresh + . ' | predicted=' . (string)\count($predicted) . ' of ' . (string)\count($bodies) + . ' | MISSED=' . (string)$missed); + foreach ($samples as $s) { \Compile\Stats::line(' deps miss ' . $s); } + } + + private static function firstDiff(string $was, string $now): string + { + $a = \explode("\n", $was); + $b = \explode("\n", $now); + $n = \min(\count($a), \count($b)); + for ($i = 0; $i < $n; $i = $i + 1) { + if ($a[$i] !== $b[$i]) { return \substr($a[$i], 0, 70) . ' -> ' . \substr($b[$i], 0, 70); } + } + return '(length)'; + } + + /** @param array $out */ + private static function wake(string $slot, array &$out): void + { + foreach ((self::$reads[$slot] ?? []) as $fn => $unused) { $out[$fn] = true; } + } + + /** + * Classes sharing an inherited slot with `$cls`: itself, what is above it and + * what is below it. + * @return string[] + */ + private static function related(string $cls): array + { + $out = self::$subtypes[$cls] ?? [$cls]; + $c = self::$classes[$cls] ?? null; + while ($c !== null && $c->parent !== '') { + $out[] = $c->parent; + $c = self::$classes[$c->parent] ?? null; + } + return $out; + } + + private static function indexClasses(Module $module): void + { + self::$classes = $module->classes; + self::$subtypes = []; + self::$bareOf = []; + foreach ($module->classes as $cd) { + foreach ($cd->methodNames as $m => $unused) { self::$bareOf[$cd->name . '__' . (string)$m] = (string)$m; } + /** @var array $seen */ + $seen = []; + /** @var string[] $stack */ + $stack = [$cd->name]; + while ($stack !== []) { + $c = \array_pop($stack); + if ($c === '' || isset($seen[$c])) { continue; } + $seen[$c] = true; + self::$subtypes[$c][] = $cd->name; + $pcd = $module->classes[$c] ?? null; + if ($pcd === null) { continue; } + if ($pcd->parent !== '') { $stack[] = $pcd->parent; } + foreach ($pcd->interfaces as $i) { $stack[] = $i; } + } + } + } + + private static function owner(string $cls, string $method): string + { + $c = self::$classes[$cls] ?? null; + while ($c !== null) { + if (isset($c->methodNames[$method])) { return $c->name; } + $c = $c->parent !== '' ? (self::$classes[$c->parent] ?? null) : null; + } + return ''; + } + + /** @param array> $reads */ + private static function readMethod(string $cls, string $method, string $fn, array &$reads): void + { + $subs = self::$subtypes[$cls] ?? null; + if ($cls === '' || $subs === null) { $reads['rm:' . $method][$fn] = true; return; } + foreach ($subs as $sub) { + $o = self::owner($sub, $method); + if ($o !== '') { $reads['r:' . $o . '__' . $method][$fn] = true; } + } + } + + private static function objClass(Node $n): string + { + return $n->type->kind === Type::KIND_OBJ ? ($n->type->class ?? '') : ''; + } + + private static function asCall(Node $n): Call { return $n; } + private static function asMethodCall(Node $n): MethodCall_ { return $n; } + private static function asStaticCall(Node $n): StaticCall_ { return $n; } + private static function asNew(Node $n): NewObj { return $n; } + private static function asPropRead(Node $n): PropertyAccess_ { return $n; } + private static function asPropWrite(Node $n): StoreProperty { return $n; } + + /** @param array> $reads */ + private static function collect(Node $n, string $fn, array &$reads): void + { + self::$collectBody .= $n->kind . ':' . $n->type->exactString() . "\n"; + self::$collectShape .= $n->kind . "\n"; + $k = $n->kind; + if ($k === Node::KIND_CALL) { + $reads['r:' . self::asCall($n)->function][$fn] = true; + } elseif ($k === Node::KIND_METHOD_CALL) { + $mc = self::asMethodCall($n); + self::readMethod(self::objClass($mc->object), $mc->method, $fn, $reads); + } elseif ($k === Node::KIND_STATIC_CALL) { + $sc = self::asStaticCall($n); + self::readMethod($sc->class, $sc->method, $fn, $reads); + } elseif ($k === Node::KIND_NEW_OBJ) { + self::readMethod(self::asNew($n)->class, '__construct', $fn, $reads); + } elseif ($k === Node::KIND_PROPERTY_ACCESS) { + $pr = self::asPropRead($n); + self::readProp(self::objClass($pr->object), $pr->property, $fn, $reads); + } elseif ($k === Node::KIND_STORE_PROPERTY) { + $pw = self::asPropWrite($n); + self::readProp(self::objClass($pw->object), $pw->property, $fn, $reads); + } + foreach (Walk::children($n) as $c) { self::collect($c, $fn, $reads); } + } + + /** @param array> $reads */ + private static function readProp(string $cls, string $prop, string $fn, array &$reads): void + { + if ($cls === '') { $reads['fw:' . $prop][$fn] = true; return; } + $reads['f:' . $cls . '::' . $prop][$fn] = true; + } +} diff --git a/src/Manticore/Main.php b/src/Manticore/Main.php index a821d9f68..16cc99ed8 100644 --- a/src/Manticore/Main.php +++ b/src/Manticore/Main.php @@ -5515,6 +5515,9 @@ function lower_module(array &$sources, ?\Analyze\MirDiags $collect = null, array $narrow->needsFullInfer = $monoLight; $module = $narrow->run($module); \Compile\Stats::step('NarrowReturns (full)', $statT, \count($module->functions), -1); + // The report's last `deps run` line must move nothing: the pass above has to + // leave a true fixpoint, or a property stays narrower than what is stored in it. + if (\Compile\Mir\ShadowDeps::enabled()) { $module = (new \Compile\Mir\Passes\InferTypes(null, $analysisContext))->run($module); } $narrow = null; // NarrowReturns sharpened erased call results to objects: a string param they // reach needs its `__toString` cast, which the first check could not see. From 3023c66fce7532679471b4e75b9d0cd833b795ff Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 16:52:20 +0200 Subject: [PATCH 15/50] NarrowReturns: the late pass closes on a full inference again, stopping early left properties narrower than their stores --- src/Compile/Mir/Passes/NarrowReturns.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Compile/Mir/Passes/NarrowReturns.php b/src/Compile/Mir/Passes/NarrowReturns.php index d20130dfa..db809dabf 100644 --- a/src/Compile/Mir/Passes/NarrowReturns.php +++ b/src/Compile/Mir/Passes/NarrowReturns.php @@ -107,11 +107,10 @@ public function run(Module $module): Module // exactly as before — instead of a full inference per narrowed chain link. $light = null; $lastLight = false; - // Full inferences that may re-open the fixpoint after light rounds settle. - // What one finds is a handful of getters (29 of ~2,000 on Laravel), so - // more of them cost 15-30 s each for nothing. The early pass needs none: - // the pipeline re-infers right after it. - $closes = $this->concreteOnly ? 0 : 1; + // The early pass need not close on a full inference: the pipeline re-infers + // right after it. The late one must. A module scan in a closing run can still + // retype a property, and stopping early leaves it narrower than its stores. + $closeOnFull = !$this->concreteOnly; $max = \count($module->functions) + 2; while ($iters < $max) { $iters = $iters + 1; @@ -142,8 +141,7 @@ public function run(Module $module): Module // no-change round after a FULL inference is the real fixpoint. // A light round is scoped the same way, so it closes the same way. if ($lastLight && $this->analysis === null) { - if ($closes === 0) { break; } - $closes = $closes - 1; + if (!$closeOnFull) { break; } } elseif (!$lastLight) { $mustInfer = $this->needsFullInfer && !$this->moduleTyped; if (!$mustInfer) { From 8cacf22e2c08f3ebee26a452829c8d27ee7b00fe Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 17:02:44 +0200 Subject: [PATCH 16/50] Design: shadow-mode results, the fixpoint regression, and what HHBBC, opcache and Salsa actually do --- docs/design/inference-fixpoint.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/design/inference-fixpoint.md b/docs/design/inference-fixpoint.md index b54de8256..a378ed864 100644 --- a/docs/design/inference-fixpoint.md +++ b/docs/design/inference-fixpoint.md @@ -17,11 +17,12 @@ The cheap work is done (PR #585). What is left is flat in the profile and comes | Callees-first SCC order for the walk | Same rounds, same narrow counts, 16 more `cell` nodes | Order alone cannot help: returns narrow in `NarrowReturns`, between runs, not during the walk | | Existing scoped mode, `MANTICORE_WORKLIST=on` | ~55% more CPU, 218 more `cell` nodes | Do not build on it | | `MANTICORE_INFER_DIFF`: full pass after a scoped one | 98→0 and 75→11→0→0 functions of 36k move, alongside 2, then 27, then 2 property retypes only the full pass finds | Divergence is narrow and appears to come through property slots | -| Fewer closing rounds in `NarrowReturns` | 137 fewer `cell` nodes, CPU down | More rounds were not buying precision | | Interning shape `Type`s | CPU within noise, RSS not better | Not the bottleneck | | Time inside each of the six full runs | Pre-scans 2 s, walk 6 s, post-walk 24 s in total | The walk over all functions costs ~1 s per run. **Almost all the time is after it** | | Profile of `InferTypes::run` | `inferFunction` from post-walk re-inference 37%, from the walk 19%; scans ~40% | Re-inference triggered by scans and the scans themselves are the cost | | Functions whose types change between runs | 257 to 4576 of ~34k (0.8-13%) | Scans mostly re-read bodies that did not change | +| Precise reads in shadow mode (`MANTICORE_DEPS_SHADOW`) | Slots that moved between runs have 3-1880 recorded readers out of 34k, against 16-21k targeted by the name-keyed edges; 4-260 changed bodies were not predicted, among 7-1812 changed | Resolved edges cut the fan-out by an order of magnitude. Misses seen so far are mostly property readers, where a scan retyped the property after the walk | +| Stopping `NarrowReturns` after one closing inference | One more inference still moved 9 returns, 1 param and 5 properties; on `main` it moves nothing | Closing rounds are load-bearing, not waste: a property stays narrower than its stores. Fixed; shadow mode now asserts the module ends at a fixpoint | | Arrays with an `unknown` element | 17058 of 120940 erased (14%), 7004 of them literal `[]`; about 1071 local nodes have the same local concrete elsewhere | `bottom` has little to recover | Scoped mode targets 16-21k of 36k functions per round (`would-target`), so its static dependency edges are too wide to save time. They are keyed by bare method name, and dispatch on a common name reaches much of the module. @@ -44,9 +45,13 @@ Scoped mode targets 16-21k of 36k functions per round (`would-target`), so its s **One ascending fixpoint.** Every slot starts at a new `bottom`, distinct from `unknown`, and only moves up by `join`; declared types bound it from above. There is no separate narrowing pass, because a return is simply the join of what its body returns. This is the optimistic style of SCCP: a recursive function's return can settle on a precise type a pessimistic start would never recover, so precision should improve rather than merely hold. -**Recorded dependencies.** Inferring a function records each slot it reads. When a slot changes, exactly its recorded readers are re-queued, as in Salsa and rustc's query system. HHBBC, the closest precedent for PHP, also tracks dependencies per function (from memory, to be checked against its source before phase 2). Nothing is guessed from method names; a dispatch reads the slots of the candidates it actually resolved. +**Recorded dependencies.** Inferring a function records each slot it reads, against the resolved target. HHBBC does exactly this (`add_dependency` from `lookup_return_type`, with a `Dep` bitmask of what was read): a virtual call depends on a `FuncFamily`, the set of overrides, and is woken only when that family's cached return changes. Nothing is guessed from method names. -**SCC order as a scheduler.** The queue is ordered callees first, by a graph that recorded reads refine. The experiment above showed no gain because returns did not settle during the walk; here they do. Order then should affect speed, not the result. +**Read-only rounds, then an update step.** HHBBC's rounds (`analyze_iteratively`) analyse functions against a read-only index and apply results afterwards (`refine_return_info`, `update_func`), with the next round's work coming from `find_deps` on what changed. This also answers mutation during inference, and makes the walk parallelisable. SCC order is optional on top: opcache has none either (`zend_sort_op_arrays` is a stub), and our experiment showed none is needed to stop re-inferring. + +**Early cutoff on the slot value.** Readers wake only when a slot's value changes, compared structurally, with the direction asserted (HHBBC `strictlyMoreRefined`) and a per-slot limit that widens at the cap. Salsa calls this backdating. Hashing bodies is the wrong key. + +**Unseen writers, by what is unknown.** A write with a known property name and unknown receiver joins a per-name slot that every read of that name also reads; an unknown name (`$o->$n =`, `extract`, reflection `setValue`, a reference to a property) poisons every non-private slot to `cell`. This follows HHBBC's `mergeUnknownClass` and `mergeUnknown`. Slots start from the declared type, and only ones proven closed may start low. **Scans become transfer functions.** A store to a property joins into that property's slot when the store is inferred, so each scan is removed as its fact moves into the walk. @@ -54,7 +59,7 @@ Scoped mode targets 16-21k of 36k functions per round (`would-target`), so its s **Mutation moves out.** Box-back stores and similar rewrites run once after the fixpoint, from final types. Inference itself only writes node types and slots. -**Summaries, then #498.** A function's inputs are the slots it read, so hashing body + inputs gives a cache key for its output and its written slots. Warm rebuilds then skip unchanged functions instead of re-running the program. +**Summaries, then #498.** A function's inputs are the slots it read, so body hash + the values of those slots key its output. Because a fixpoint's path depends on order, cache final slot values and re-verify with a read-only round that must move nothing. Vendor and stdlib get a higher durability tier, as in Salsa, and are skipped unless their hash changes. Salsa validates lazily by pull; a whole-program compile has no demand order, so here it would be one verification round. ## Plan @@ -63,7 +68,7 @@ Every phase lands alone and must pass the full suite and difftest, with `precisi The first version of this plan started by splitting `bottom` from `unknown`. Measurement rejected that, and also the idea that fewer walks are the main saving: the walk is about 19% of a run. 1. **Trim post-walk re-inference.** Done in part: propagation visited 10-40 times more functions than moved, and a body whose signature held still now wakes only its callees; settled element-as-key bodies are skipped. Together about 15% of CPU on symfony-app. -2. **Record dependencies in shadow mode.** Build the read table next to the current code and report what a re-run moved that no recorded edge predicted. Target: zero, on symfony-app, Laravel and the demo. +2. **Record dependencies in shadow mode.** Started: `MANTICORE_DEPS_SHADOW=1` builds the read table from final node types and reports what a re-run moved that no edge predicted. Target: zero misses, on symfony-app, Laravel and the demo. Next: separate misses caused by a retype after the walk, add `FuncFamily`-style nodes, and read at lookup time rather than from final types. 3. **Semi-naive scans, on top of those dependencies.** Split each scan into per-function fact extraction and a reduce, re-extracting only where an input changed. Gating on the function's own types is not enough: `scanRefCellArgNode` reads the callee's parameter types and body, and `scanByRefElemWiden` reads a module-wide map, so a stale skip misses a widening to `cell`, which is a miscompile. Scans also fold in module order, and some keep the first observation, so the reduce must preserve that order. 4. **Move mutation out of inference.** 5. **Replace `NarrowReturns` with the fixpoint for returns,** using the queue. From 47a2f7dee368089a4ff94bfef69a314e911f5d71 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 17:07:53 +0200 Subject: [PATCH 17/50] MANTICORE_DEPS_SHADOW: split late retypes from misses, group misses by node kind --- src/Compile/Mir/ShadowDeps.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Compile/Mir/ShadowDeps.php b/src/Compile/Mir/ShadowDeps.php index 6ecc9b5b1..ac8538781 100644 --- a/src/Compile/Mir/ShadowDeps.php +++ b/src/Compile/Mir/ShadowDeps.php @@ -32,6 +32,8 @@ final class ShadowDeps private static array $subtypes = []; /** @var array */ private static array $classes = []; + /** @var array functions woken by what moved in the previous interval: a scan can retype a slot after the walk, so its readers move one run later */ + private static array $wokenBefore = []; private static bool $seeded = false; private static int $run = 0; @@ -132,6 +134,9 @@ private static function report(array $bodies, array $shapes, array $returns, arr $mutated = 0; $fresh = 0; $missed = 0; + /** @var array $missKinds */ + $missKinds = []; + $late = 0; /** @var string[] $samples */ $samples = []; foreach ($bodies as $name => $b) { @@ -140,14 +145,20 @@ private static function report(array $bodies, array $shapes, array $returns, arr if (self::$shapes[$name] !== $shapes[$name]) { $mutated = $mutated + 1; continue; } $actual = $actual + 1; if (isset($predicted[$name])) { continue; } + if (isset(self::$wokenBefore[$name])) { $late = $late + 1; continue; } $missed = $missed + 1; - if (\count($samples) < 10) { $samples[] = $name . ' :: ' . self::firstDiff(self::$bodies[$name], $b); } + $diff = self::firstDiff(self::$bodies[$name], $b); + $cut = \strpos($diff, ':'); + $kind = $cut === false ? '?' : \substr($diff, 0, $cut); + $missKinds[$kind] = ($missKinds[$kind] ?? 0) + 1; + if (\count($samples) < 14 && !\str_starts_with($name, '__mir_') && !\str_starts_with($name, '__mc_')) { $samples[] = $name . ' :: ' . $diff; } } \Compile\Stats::line('deps run ' . (string)self::$run . ': moved ret=' . (string)$retMoved . ' param=' . (string)$paramMoved . ' prop=' . (string)$propMoved . ' | bodies changed=' . (string)$actual . ' mutated=' . (string)$mutated . ' new=' . (string)$fresh . ' | predicted=' . (string)\count($predicted) . ' of ' . (string)\count($bodies) - . ' | MISSED=' . (string)$missed); + . ' | late=' . (string)$late . ' MISSED=' . (string)$missed . ' ' . \json_encode($missKinds)); + self::$wokenBefore = $predicted; foreach ($samples as $s) { \Compile\Stats::line(' deps miss ' . $s); } } From 38dc416615025788872498c3fa82cda5d2c453b8 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 17:17:49 +0200 Subject: [PATCH 18/50] Monomorphize: every round re-infers only its clones and callers, on the tables of the inference just before it --- src/Compile/Mir/Passes/Monomorphize.php | 13 ++++++------- src/Manticore/Main.php | 6 ++++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Compile/Mir/Passes/Monomorphize.php b/src/Compile/Mir/Passes/Monomorphize.php index 6d574bfa3..2c5d57ead 100644 --- a/src/Compile/Mir/Passes/Monomorphize.php +++ b/src/Compile/Mir/Passes/Monomorphize.php @@ -79,7 +79,8 @@ final class Monomorphize implements Pass /** @var string[] index-parallel to {@see $pendingRepointCalls}. */ private array $pendingRepointNames = []; - public function __construct(private ?\Compile\Mir\AnalysisContext $ctx = null) {} + /** @param InferTypes|null $infer the full inference the pipeline just ran, whose tables the rounds reuse */ + public function __construct(private ?\Compile\Mir\AnalysisContext $ctx = null, private ?InferTypes $infer = null) {} public function name(): string { return self::NAME; } @@ -123,8 +124,6 @@ public function run(Module $module): Module return $module; } - /** The instance of round 1's full inference, whose tables the light rounds reuse. */ - private ?InferTypes $infer = null; /** Some round re-typed only its clones and their callers. */ public bool $lightRoundRan = false; @@ -187,10 +186,10 @@ private function runOnce(Module $module): bool // With the analysis context, so what a round moves — the clones, the // repointed callers — is in the change set the next scoped pass reads. if ($this->ctx !== null) { $this->ctx->refresh($module); } - // Round 1 specializes most of what will ever be specialized and gets a - // full inference; later rounds add a handful of clones, so re-infer those - // and what calls them. The module scans have not seen those bodies until - // the next full inference: {@see $lightRoundRan} makes NarrowReturns run one. + // Re-infer the clones and what calls them, on the tables of the inference + // the pipeline ran just before this pass. The module scans have not seen + // those bodies until the next full inference: {@see $lightRoundRan} makes + // NarrowReturns run one, and it closes on a fixpoint. if ($this->infer === null || $this->ctx !== null) { $this->infer = new InferTypes(null, $this->ctx); $this->infer->run($module); diff --git a/src/Manticore/Main.php b/src/Manticore/Main.php index 16cc99ed8..ef623362d 100644 --- a/src/Manticore/Main.php +++ b/src/Manticore/Main.php @@ -5430,13 +5430,15 @@ function lower_module(array &$sources, ?\Analyze\MirDiags $collect = null, array // below re-derives each retargeted call's type from its overload. $module = (new \Compile\Mir\Passes\ResolveOverloads())->run($module); $statT = \Compile\Stats::begin('InferTypes #3'); - $module = (new \Compile\Mir\Passes\InferTypes(null, $analysisContext))->run($module); + $infer3 = new \Compile\Mir\Passes\InferTypes(null, $analysisContext); + $module = $infer3->run($module); \Compile\Stats::step('InferTypes #3', $statT, \count($module->functions), -1); // Specialize erased-array / polymorphic functions per call-site // argument shape (runs after InferTypes so call-arg types are known; // re-runs InferTypes internally when it specializes anything). $statT = \Compile\Stats::begin('Monomorphize'); - $mono = new \Compile\Mir\Passes\Monomorphize($analysisContext); + $mono = new \Compile\Mir\Passes\Monomorphize($analysisContext, $infer3); + $infer3 = null; $module = $mono->run($module); \Compile\Stats::step('Monomorphize', $statT, \count($module->functions), -1); $monoLight = $mono->lightRoundRan; From 000ef3c7bad7b5dd3cce28a157a5a089efdc6091 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 18:26:26 +0200 Subject: [PATCH 19/50] NarrowReturns: the late pass stops only when its last full inference moved no types, a scan can change facts after its walk --- src/Compile/Mir/Passes/NarrowReturns.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Compile/Mir/Passes/NarrowReturns.php b/src/Compile/Mir/Passes/NarrowReturns.php index db809dabf..55203b3a7 100644 --- a/src/Compile/Mir/Passes/NarrowReturns.php +++ b/src/Compile/Mir/Passes/NarrowReturns.php @@ -111,6 +111,7 @@ public function run(Module $module): Module // right after it. The late one must. A module scan in a closing run can still // retype a property, and stopping early leaves it narrower than its stores. $closeOnFull = !$this->concreteOnly; + $lastRunMoved = false; $max = \count($module->functions) + 2; while ($iters < $max) { $iters = $iters + 1; @@ -143,7 +144,10 @@ public function run(Module $module): Module if ($lastLight && $this->analysis === null) { if (!$closeOnFull) { break; } } elseif (!$lastLight) { - $mustInfer = $this->needsFullInfer && !$this->moduleTyped; + // Nothing narrowing is not yet a fixpoint: a scan in the last run + // can have changed facts after its walk. The last full run has to + // have moved nothing. + $mustInfer = $this->needsFullInfer && (!$this->moduleTyped || $lastRunMoved); if (!$mustInfer) { if (!$this->targetedInfer || $this->analysis === null || $sawFull) { break; } $closing = true; @@ -191,7 +195,9 @@ public function run(Module $module): Module // inference moves. if ($this->analysis !== null) { $this->analysis->beginRound(); } $infer = new InferTypes($scope, $this->analysis); + $typesBefore = $this->needsFullInfer ? $infer->fingerprintAll($module) : []; $infer->run($module); + $lastRunMoved = $this->needsFullInfer && $infer->fingerprintAll($module) !== $typesBefore; $this->moduleTyped = $scope === null; $lastLight = false; $light = ($scope === null && $this->analysis === null) ? $infer : null; From b4f0eff9d0b6d55dd76dd4132b5d68dc144edfb0 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Tue, 3 Nov 2026 19:40:47 +0200 Subject: [PATCH 20/50] Extract a dynamic method call whose receiver is not a local: the inline name chain re-emitted the receiver per candidate (3M IR lines in one Doctrine method) --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 2 +- .../cases/dyn_method_name_on_call_result.php | 17 +++++++++++++++++ .../expected/dyn_method_name_on_call_result.out | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/aot/cases/dyn_method_name_on_call_result.php create mode 100644 tests/aot/expected/dyn_method_name_on_call_result.out diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 078448f7b..f6eb42254 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -5413,7 +5413,7 @@ private function emitDynMethodCall(\Compile\Mir\DynProp_ $dp, \Compile\Mir\Invok } $canExtract = $argsOk && ($dirty === [] || $reEmitSafe) - && $recv->kind === Node::KIND_LOAD_LOCAL + && ($dirty === [] || $recv->kind === Node::KIND_LOAD_LOCAL) && ($recv->type->kind === Type::KIND_CELL || $recv->type->kind === Type::KIND_UNKNOWN || $recv->type->kind === Type::KIND_UNION) diff --git a/tests/aot/cases/dyn_method_name_on_call_result.php b/tests/aot/cases/dyn_method_name_on_call_result.php new file mode 100644 index 000000000..71bd872a4 --- /dev/null +++ b/tests/aot/cases/dyn_method_name_on_call_result.php @@ -0,0 +1,17 @@ +v; } + public function toBinary(): string { return 'bin:' . $this->v; } +} +final class U1 extends Uid {} +class Conv { + protected function cls(): string { return U1::class; } + public function run(string $v, bool $native): ?string { + $to = $native ? 'toRfc4122' : 'toBinary'; + return $this->cls()::fromString($v)->$to(); + } +} +$c = new Conv(); +echo $c->run('a', true), ' ', $c->run('b', false), "\n"; diff --git a/tests/aot/expected/dyn_method_name_on_call_result.out b/tests/aot/expected/dyn_method_name_on_call_result.out new file mode 100644 index 000000000..2679d4c11 --- /dev/null +++ b/tests/aot/expected/dyn_method_name_on_call_result.out @@ -0,0 +1 @@ +rfc:a bin:b From 4bd98338f0701102d8d8c1ea93790df14bde526e Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Tue, 3 Nov 2026 20:56:22 +0200 Subject: [PATCH 21/50] Share per-name arm helpers across dynamic-method spread fallbacks and erased spread method calls: one dispatch+unpack body per method name instead of one copy per shape/site --- src/Compile/Mir/Passes/EmitLlvm.php | 1 + src/Compile/Mir/Passes/EmitLlvmObjects.php | 194 ++++++++++++++++-- tests/aot/cases/dyn_spread_arm_helpers.php | 53 +++++ tests/aot/cases/erased_spread_method_call.php | 18 ++ tests/aot/cases/newdyn_spread_arm_helpers.php | 40 ++++ tests/aot/expected/dyn_spread_arm_helpers.out | 42 ++++ .../expected/erased_spread_method_call.out | 11 + .../expected/newdyn_spread_arm_helpers.out | 27 +++ 8 files changed, 373 insertions(+), 13 deletions(-) create mode 100644 tests/aot/cases/dyn_spread_arm_helpers.php create mode 100644 tests/aot/cases/erased_spread_method_call.php create mode 100644 tests/aot/cases/newdyn_spread_arm_helpers.php create mode 100644 tests/aot/expected/dyn_spread_arm_helpers.out create mode 100644 tests/aot/expected/erased_spread_method_call.out create mode 100644 tests/aot/expected/newdyn_spread_arm_helpers.out diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 6f358e32a..2a147af87 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -923,6 +923,7 @@ public function emit(Module $module): string $this->cloneErasedSym = ''; $this->dynmSyms = []; $this->dynInlSyms = []; + $this->dynArmSyms = []; $this->dynmExtraBodies = ''; $this->scmpSyms = []; $this->scmpExtraBodies = ''; diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index f6eb42254..f1f434ef3 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -5122,25 +5122,15 @@ private function dynamicMethodSpreadFallbackHelper(\Compile\Mir\DynProp_ $dp, ar $recv = new \Compile\Mir\LoadLocal('__mc_dyn_obj', $rt); $name = new \Compile\Mir\LoadLocal('__mc_dyn_name', Type::string_()); - $vecT = Type::vec(Type::cell()); $hdp = new \Compile\Mir\DynProp_($recv, $name, $dp->type); $hdp->scope = $dp->scope; $hdp->line = $dp->line; - $armArgs = []; - if ($fixedN < 0) { - $armArgs[] = new \Compile\Mir\Spread_(new \Compile\Mir\LoadLocal('__mc_dyn_args', $vecT), Type::cell()); - } else { - for ($i = 0; $i < $fixedN; $i++) { - $armArgs[] = new \Compile\Mir\ArrayAccess_(new \Compile\Mir\LoadLocal('__mc_dyn_args', $vecT), - new \Compile\Mir\IntConst($i, Type::int_()), Type::cell()); - } - } - $iv = new \Compile\Mir\Invoke_($hdp, $armArgs, $resT); + $iv = new \Compile\Mir\Invoke_($hdp, $this->dynSpreadArmArgs($fixedN), $resT); // Call the old inline implementation directly. Re-entering // emitDynMethodCall here would re-run candidate inference against the // synthetic nodes and can observe the helper's temporary LocalSlots as // if they were a real source call. - $body .= $this->emitDynMethodInlineFallback($hdp, $iv, $methods); + $body .= $this->emitDynMethodInlineFallback($hdp, $iv, $methods, $fixedN); $body .= $this->coerceToI64(); $result = $this->lastValue; $body .= ' ret i64 ' . $result . "\n}\n\n"; @@ -5171,6 +5161,124 @@ private function dynamicMethodSpreadFallbackHelper(\Compile\Mir\DynProp_ $dp, ar return $this->dynInlSyms[$key]; } + /** @return Node[] the arm's arguments, read out of the packed `__mc_dyn_args` local */ + private function dynSpreadArmArgs(int $fixedN): array + { + $vecT = Type::vec(Type::cell()); + $armArgs = []; + if ($fixedN < 0) { + $armArgs[] = new \Compile\Mir\Spread_(new \Compile\Mir\LoadLocal('__mc_dyn_args', $vecT), Type::cell()); + } else { + for ($i = 0; $i < $fixedN; $i++) { + $armArgs[] = new \Compile\Mir\ArrayAccess_(new \Compile\Mir\LoadLocal('__mc_dyn_args', $vecT), + new \Compile\Mir\IntConst($i, Type::int_()), Type::cell()); + } + } + return $armArgs; + } + + /** Set while an arm helper body is emitted: its own spread call must stay inline. */ + private bool $noSpreadRoute = false; + + /** @var array per-(name, receiver, arity) arm helper key => symbol, '' when not served */ + private array $dynArmSyms = []; + + /** + * One arm of {@see dynamicMethodSpreadFallbackHelper} as its own helper + * `i64 (i64 recv, ptr args)`: the virtual dispatch and argument unpack for + * ONE method name do not depend on the other names in the shape, so every + * shape helper that mentions the name shares this body instead of + * splicing a copy (~210 IR lines per arm, 1 850 arms per shape, 39 shapes). + * '' when the body is not closed; the caller then inlines the arm. + */ + private function dynamicMethodSpreadArmHelper(Type $rt, int $fixedN, string $m, Type $retT, int $srcArgc = -1): string + { + $recvKey = $rt->class ?? ''; + if ($rt->kind === Type::KIND_UNION) { + foreach ($rt->atoms as $at) { $recvKey .= ($at->class ?? '') . '|'; } + } + $key = 'inlarm|' . $rt->kind . ':' . $recvKey . '|' . (string)$fixedN . '|' . $retT->kind . ':' . ($retT->class ?? '') . '|' . $m . '|' . (string)$srcArgc; + if (isset($this->dynArmSyms[$key])) { return $this->dynArmSyms[$key]; } + $sym = '@' . $this->mirHelperSym('__mir_dynarm_' . (string)\count($this->dynArmSyms)); + $this->dynArmSyms[$key] = $sym; + + $oldSsa = $this->ssa; + $oldLocals = $this->locals; + $oldCf = $this->cf; + $oldArena = $this->arena; + $oldLast = $this->lastValue; + $oldLastType = $this->lastValueType; + $oldClassId = $this->classIdReg; + $oldRawRef = $this->rawRefCall; + $oldSpread = $this->spreadTail; + $oldPad = $this->lastPadArgs; + $oldVdResult = $this->vdResult; + $oldVdArm = $this->vdArmList; + $oldVdArgc = $this->vdSiteArgc; + $oldAbiDisabled = $this->dynamicMethodAbiDisabled; + $oldNoSpreadRoute = $this->noSpreadRoute; + $oldNeedsBacktrace = $this->rt->needsBacktrace; + $oldCellSinkOrd = $this->cellSinkOrd; + $oldCellSinkFn = $this->cellSinkFnOverride; + $oldCellProv = $this->cellProv; + + $this->ssa = new SsaBuilder(); + $this->ssa->reset(); + $this->resetCellGuardFrame(); + $this->cellSinkFnOverride = \ltrim($sym, '@'); + $this->locals = new LocalSlots(); + $this->cf = new ControlFlow(); + $this->arena = new ArenaContext(); + $this->rawRefCall = false; + $this->spreadTail = null; + $this->lastPadArgs = ''; + $this->vdResult = ''; + $this->vdArmList = ''; + $this->vdSiteArgc = 0; + $this->dynamicMethodAbiDisabled = true; + $this->noSpreadRoute = true; + + $objSlot = $this->ssa->allocReg(); + $argsSlot = $this->ssa->allocReg(); + $this->locals->slots['__mc_dyn_obj'] = $objSlot; + $this->locals->slots['__mc_dyn_args'] = $argsSlot; + $body = 'define hidden i64 ' . $sym . "(i64 %obj, ptr %args) {\nentry:\n"; + $body .= ' ' . $objSlot . " = alloca i64\n store i64 %obj, ptr " . $objSlot . "\n"; + $body .= ' ' . $argsSlot . " = alloca i64\n"; + $body .= " %argsI = ptrtoint ptr %args to i64\n store i64 %argsI, ptr " . $argsSlot . "\n"; + $recv = new \Compile\Mir\LoadLocal('__mc_dyn_obj', $rt); + $call = new \Compile\Mir\MethodCall_($recv, $m, $this->packDynVariadicArgs($recv, $m, $this->dynSpreadArmArgs($fixedN)), $retT); + $call->srcArgc = $srcArgc; + $body .= $this->emitMethodCall($call); + $body .= $this->boxToCell($retT); + $body .= ' ret i64 ' . $this->lastValue . "\n}\n\n"; + if ($this->irIsClosed($body)) { + $this->dynamicMethodHelpers[$key] = $body; + } else { + $this->dynArmSyms[$key] = ''; + } + + $this->noSpreadRoute = $oldNoSpreadRoute; + $this->ssa = $oldSsa; + $this->locals = $oldLocals; + $this->cf = $oldCf; + $this->arena = $oldArena; + $this->lastValue = $oldLast; + $this->lastValueType = $oldLastType; + $this->classIdReg = $oldClassId; + $this->rawRefCall = $oldRawRef; + $this->spreadTail = $oldSpread; + $this->lastPadArgs = $oldPad; + $this->vdResult = $oldVdResult; + $this->vdArmList = $oldVdArm; + $this->vdSiteArgc = $oldVdArgc; + $this->dynamicMethodAbiDisabled = $oldAbiDisabled; + $this->rt->needsBacktrace = $oldNeedsBacktrace; + $this->cellProv = $oldCellProv; + $this->cellSinkOrd = $oldCellSinkOrd; + $this->cellSinkFnOverride = $oldCellSinkFn; + return $this->dynArmSyms[$key]; + } /** Some operand of the dynamic call is not a local yet, and every such one * is a read {@see dynHoistableRead} can take once. */ private function dynOperandsNeedHoist(\Compile\Mir\DynProp_ $dp, \Compile\Mir\Invoke_ $iv): bool @@ -6140,7 +6248,7 @@ private function packDynVariadicArgs(Node $recv, string $m, array $args): array return $out; } - private function emitDynMethodInlineFallback(\Compile\Mir\DynProp_ $dp, \Compile\Mir\Invoke_ $iv, array $methods): string + private function emitDynMethodInlineFallback(\Compile\Mir\DynProp_ $dp, \Compile\Mir\Invoke_ $iv, array $methods, ?int $armFixedN = null): string { $recv = $dp->object; $nameNode = $dp->name; @@ -6160,6 +6268,21 @@ private function emitDynMethodInlineFallback(\Compile\Mir\DynProp_ $dp, \Compile $out .= ' ' . $eq . ' = icmp eq i32 ' . $cmp . ", 0\n"; $out .= ' br i1 ' . $eq . ', label %' . $hitL . ', label %' . $nextL . "\n"; $out .= $hitL . ":\n"; + $armSym = $armFixedN === null ? '' : $this->dynamicMethodSpreadArmHelper($recv->type, $armFixedN, (string)$m, $retT); + if ($armSym !== '') { + $ro = $this->ssa->allocReg(); + $out .= ' ' . $ro . ' = load i64, ptr ' . $this->locals->slots['__mc_dyn_obj'] . "\n"; + $ra = $this->ssa->allocReg(); + $out .= ' ' . $ra . ' = load i64, ptr ' . $this->locals->slots['__mc_dyn_args'] . "\n"; + $rp = $this->ssa->allocReg(); + $out .= ' ' . $rp . ' = inttoptr i64 ' . $ra . " to ptr\n"; + $rv = $this->ssa->allocReg(); + $out .= ' ' . $rv . ' = call i64 ' . $armSym . '(i64 ' . $ro . ', ptr ' . $rp . ")\n"; + $out .= ' store i64 ' . $rv . ', ptr ' . $res . "\n"; + $out .= ' br label %' . $endL . "\n"; + $out .= $nextL . ":\n"; + continue; + } // $recv keeps its type (single class OR union), so emitMethodCall // dispatches monomorphically or via the runtime class_id switch. $call = new \Compile\Mir\MethodCall_($recv, $m, $this->packDynVariadicArgs($recv, (string)$m, $iv->args), $retT); @@ -9318,6 +9441,8 @@ private function emitMethodCallChecked(\Compile\Mir\MethodCall_ $n): string private function emitMethodCallUnguarded(\Compile\Mir\MethodCall_ $n): string { + $sp = $this->emitErasedSpreadMethodCall($n); + if ($sp !== null) { return $sp; } $hm = $this->emitHmapCall($n); if ($hm !== null) { return $hm; } $depDiag = $this->deprecatedMethodDiag($this->staticClassOf($n->object), $n->method, $n->line); @@ -9325,6 +9450,49 @@ private function emitMethodCallUnguarded(\Compile\Mir\MethodCall_ $n): string return $this->emitMethodCallInner($n); } + /** + * `$erased->m(...$pack)` where many classes declare `m`: the per-class + * virtual dispatch and the per-arm unpack of the pack are the shared + * {@see dynamicMethodSpreadArmHelper} (symfony's `->m(...\func_get_args())` + * proxies spliced ~260 KB of IR each). Null = keep the inline path. + */ + private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?string + { + if ($this->noSpreadRoute || \count($n->args) !== 1 || $n->args[0]->kind !== Node::KIND_SPREAD) { return null; } + $rk = $n->object->type->kind; + if ($rk !== Type::KIND_CELL && $rk !== Type::KIND_UNKNOWN) { return null; } + $op = $this->asSpreadNode($n->args[0])->operand; + $pt = $op->type; + $erasedPack = $pt->kind === Type::KIND_UNKNOWN; + if (!$erasedPack) { + $el = $pt->element; + if (!$pt->isVec() || ($el !== null && $el->kind !== Type::KIND_CELL && $el->kind !== Type::KIND_UNKNOWN)) { return null; } + } + if ($op->kind !== Node::KIND_LOAD_LOCAL && $op->kind !== Node::KIND_STORE_LOCAL) { return null; } + if (\count($this->methodHolders($n->method)) < 8 || $this->dynMethodNameHasRefParam($n->method, -1)) { return null; } + $sym = $this->dynamicMethodSpreadArmHelper($n->object->type, -1, $n->method, Type::cell(), $n->srcArgc); + if ($sym === '') { return null; } + $out = $this->emitNode($n->object); + $out .= $this->coerceToI64(); + $recv = $this->lastValue; + $out .= $this->emitNode($op); + if ($erasedPack) { + $out .= $this->cellToPtr(); + } else { + $out .= $this->coerceToPtr(); + } + $argsP = $this->lastValue; + $r = $this->ssa->allocReg(); + $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $recv . ', ptr ' . $argsP . ")\n"; + $this->lastValue = $r; + $this->lastValueType = 'i64'; + $this->markCellOpaque($r); + if ($n->type->kind !== Type::KIND_CELL && $n->type->kind !== Type::KIND_UNKNOWN + && $n->type->kind !== Type::KIND_VOID) { + $out .= $this->unboxCellToType($n->type); + } + return $out; + } /** * `$r->m(...)` on an ERASED receiver when no class declares `m` but some * class has `__call`. The receiver is not necessarily one of those classes: diff --git a/tests/aot/cases/dyn_spread_arm_helpers.php b/tests/aot/cases/dyn_spread_arm_helpers.php new file mode 100644 index 000000000..f70063a7b --- /dev/null +++ b/tests/aot/cases/dyn_spread_arm_helpers.php @@ -0,0 +1,53 @@ +{$name}(...$args) on erased / union receivers: per-name shared arm helpers. +class A { + public function m0($a) { return "A.m0($a)"; } + public function m1($a, $b = 'd') { return "A.m1($a,$b)"; } + public function m2() { return "A.m2"; } + public function m3($a, $b) { return $a + $b; } + public function m4(...$r) { return "A.m4:" . implode(',', $r); } + public function m5(int $x) { return $x * 2; } + public function m6($a) { return "A.m6($a)"; } + public function m7($a) { return "A.m7($a)"; } + public function m8($a) { return "A.m8($a)"; } + public function m9($a) { return "A.m9($a)"; } + public function m10($a) { return "A.m10($a)"; } + public function m11($a) { return "A.m11($a)"; } + public function m12($a) { return "A.m12($a)"; } + public function m13($a) { return "A.m13($a)"; } + public function m14($a) { return "A.m14($a)"; } + public function m15($a) { return "A.m15($a)"; } + public function m16($a) { return "A.m16($a)"; } + public function m17($a) { return "A.m17($a)"; } + public function m18($a) { return "A.m18($a)"; } + public function m19($a) { return "A.m19($a)"; } +} +class B extends A { + public function m0($a) { return "B.m0($a)"; } + public function m2() { return "B.m2"; } +} +class C extends B { + public function m0($a) { return "C.m0($a)"; } + public function m1($a, $b = 'cd') { return "C.m1($a,$b)"; } +} +class M { + public function __call($n, $a) { return "M.__call($n," . implode(',', $a) . ")"; } + public function m0($a) { return "M.m0($a)"; } +} +function call($o, string $n, array $args) { + try { + return $o->{$n}(...$args); + } catch (\Throwable $e) { + return get_class($e) . ($e instanceof ArgumentCountError ? '' : ': ' . $e->getMessage()); + } +} +$objs = [new A, new B, new C, new M]; +foreach ($objs as $o) { + foreach ([['m0', [1]], ['m1', [1]], ['m1', [1, 2]], ['m2', []], ['m3', [1, 2]], ['m3', [1]], + ['m4', [1, 2, 3]], ['m5', [21]], ['m19', ['z']], ['nope', [1]]] as [$n, $a]) { + echo get_class($o), '->', $n, ': ', var_export(call($o, $n, $a), true), "\n"; + } +} +function viaUntyped($o, $n) { try { return $o->$n(...[5]); } catch (\Throwable $e) { return get_class($e); } } +echo viaUntyped(new A, 'm0'), "\n"; +echo viaUntyped(new C, 'm0'), "\n"; diff --git a/tests/aot/cases/erased_spread_method_call.php b/tests/aot/cases/erased_spread_method_call.php new file mode 100644 index 000000000..8a8722d27 --- /dev/null +++ b/tests/aot/cases/erased_spread_method_call.php @@ -0,0 +1,18 @@ +m(...func_get_args()) / (...$pack) over many unrelated classes with different params. +class P0 { public function m($a): string { return "P0.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P0.z"; } } +class P1 { public function m($a, $b = "d1"): string { return "P1.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P1.z"; } } +class P2 { public function m(int $a, string $b = "v"): string { return "P2.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P2.z"; } } +class P3 { public function m($a): string { return "P3.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P3.z"; } } +class P4 { public function m($a, $b = "d4"): string { return "P4.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P4.z"; } } +class P5 { public function m(int $a, string $b = "v"): string { return "P5.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P5.z"; } } +class P6 { public function m($a): string { return "P6.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P6.z"; } } +class P7 { public function m($a, $b = "d7"): string { return "P7.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P7.z"; } } +class P8 { public function m(int $a, string $b = "v"): string { return "P8.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P8.z"; } } +class P9 { public function m($a): string { return "P9.m(" . json_encode(func_get_args()) . ")"; } public function z() { return "P9.z"; } } +class Q { public function __call($n, $a) { return "Q.__call($n," . json_encode($a) . ")"; } } +class Proxy { private $t; public function __construct($t) { $this->t = $t; } public function init() { return $this->t; } + public function m() { return $this->init()->m(...\func_get_args()); } + public function viaPack($o, array $p) { return $o->m(...$p); } } +foreach (range(0, 9) as $i) { $c = "P$i"; $p = new Proxy(new $c); try { echo $p->m(1), " ", $p->viaPack(new $c, [3]), "\n"; } catch (\Throwable $e) { echo get_class($e), "\n"; } } +try { echo (new Proxy(new P0))->m(), "\n"; } catch (\Throwable $e) { echo get_class($e), "\n"; } diff --git a/tests/aot/cases/newdyn_spread_arm_helpers.php b/tests/aot/cases/newdyn_spread_arm_helpers.php new file mode 100644 index 000000000..ffc65234f --- /dev/null +++ b/tests/aot/cases/newdyn_spread_arm_helpers.php @@ -0,0 +1,40 @@ +getMessage()); } } +function mk2(string $c, $f, array $a) { try { $o = new $c($f, ...$a); return json_encode($o); } catch (\Throwable $e) { return get_class($e); } } +foreach ([["Plain",[]],["One",[5]],["One",[]],["Def",[]],["Def",[7]],["Def",[7,"x"]],["Def",["b"=>"nm"]],["Two",[3]],["Two",[3,"z"]],["Two",["y"=>"k","x"=>9]],["Child",[4]],["Str",["s"]],["Str",["s",2.5]],["G0",[1]],["G7",[1,2]],["G21",[3]]] as [$c,$a]) echo $c, ": ", mk($c, $a), "\n"; +echo mk2("Two", 1, []), mk2("Two", 1, ["q"]), mk2("G3", 1, [2]), "\n"; +function mk3(string $c, array $a) { try { return json_encode(new $c(...array_values($a))); } catch (\Throwable $e) { return get_class($e); } } +function mk4(string $c, $a) { try { return json_encode(new $c(...$a)); } catch (\Throwable $e) { return get_class($e); } } +foreach ([["Plain", []], ["One", [5]], ["One", []], ["Def", [7, "x"]], ["Two", [3, "z"]], ["Child", [4]], ["Str", ["s", 2.5]], ["G5", [1]], ["G20", [1, 2]]] as [$c, $a]) { + echo $c, ": ", mk3($c, $a), " ", mk4($c, $a), "\n"; +} +echo mk4("Two", ["y" => "k", "x" => 9]), mk4("Def", ["b" => "nm"]), "\n"; diff --git a/tests/aot/expected/dyn_spread_arm_helpers.out b/tests/aot/expected/dyn_spread_arm_helpers.out new file mode 100644 index 000000000..0de7044d9 --- /dev/null +++ b/tests/aot/expected/dyn_spread_arm_helpers.out @@ -0,0 +1,42 @@ +A->m0: 'A.m0(1)' +A->m1: 'A.m1(1,d)' +A->m1: 'A.m1(1,2)' +A->m2: 'A.m2' +A->m3: 3 +A->m3: 'ArgumentCountError' +A->m4: 'A.m4:1,2,3' +A->m5: 42 +A->m19: 'A.m19(z)' +A->nope: 'Error: Call to undefined method A::nope()' +B->m0: 'B.m0(1)' +B->m1: 'A.m1(1,d)' +B->m1: 'A.m1(1,2)' +B->m2: 'B.m2' +B->m3: 3 +B->m3: 'ArgumentCountError' +B->m4: 'A.m4:1,2,3' +B->m5: 42 +B->m19: 'A.m19(z)' +B->nope: 'Error: Call to undefined method B::nope()' +C->m0: 'C.m0(1)' +C->m1: 'C.m1(1,cd)' +C->m1: 'C.m1(1,2)' +C->m2: 'B.m2' +C->m3: 3 +C->m3: 'ArgumentCountError' +C->m4: 'A.m4:1,2,3' +C->m5: 42 +C->m19: 'A.m19(z)' +C->nope: 'Error: Call to undefined method C::nope()' +M->m0: 'M.m0(1)' +M->m1: 'M.__call(m1,1)' +M->m1: 'M.__call(m1,1,2)' +M->m2: 'M.__call(m2,)' +M->m3: 'M.__call(m3,1,2)' +M->m3: 'M.__call(m3,1)' +M->m4: 'M.__call(m4,1,2,3)' +M->m5: 'M.__call(m5,21)' +M->m19: 'M.__call(m19,z)' +M->nope: 'M.__call(nope,1)' +A.m0(5) +C.m0(5) diff --git a/tests/aot/expected/erased_spread_method_call.out b/tests/aot/expected/erased_spread_method_call.out new file mode 100644 index 000000000..2aa5a7dda --- /dev/null +++ b/tests/aot/expected/erased_spread_method_call.out @@ -0,0 +1,11 @@ +P0.m([1]) P0.m([3]) +P1.m([1]) P1.m([3]) +P2.m([1]) P2.m([3]) +P3.m([1]) P3.m([3]) +P4.m([1]) P4.m([3]) +P5.m([1]) P5.m([3]) +P6.m([1]) P6.m([3]) +P7.m([1]) P7.m([3]) +P8.m([1]) P8.m([3]) +P9.m([1]) P9.m([3]) +ArgumentCountError diff --git a/tests/aot/expected/newdyn_spread_arm_helpers.out b/tests/aot/expected/newdyn_spread_arm_helpers.out new file mode 100644 index 000000000..9cef6c4c7 --- /dev/null +++ b/tests/aot/expected/newdyn_spread_arm_helpers.out @@ -0,0 +1,27 @@ +Plain: {} +One: {"a":5} +One: ArgumentCountError +Def: {"a":1,"b":"d"} +Def: {"a":7,"b":"d"} +Def: {"a":7,"b":"x"} +Def: {"a":1,"b":"nm"} +Two: {"x":3,"y":"yy"} +Two: {"x":3,"y":"z"} +Two: {"x":9,"y":"k"} +Child: {"x":4,"y":"yy"} +Str: {"s":"s","f":1.5} +Str: {"s":"s","f":2.5} +G0: {"p":1,"q":0} +G7: {"p":1,"q":2} +G21: {"p":3,"q":21} +{"x":1,"y":"yy"}{"x":1,"y":"q"}{"p":1,"q":2} +Plain: {} {} +One: {"a":5} {"a":5} +One: ArgumentCountError ArgumentCountError +Def: {"a":7,"b":"x"} {"a":7,"b":"x"} +Two: {"x":3,"y":"z"} {"x":3,"y":"z"} +Child: {"x":4,"y":"yy"} {"x":4,"y":"yy"} +Str: {"s":"s","f":2.5} {"s":"s","f":2.5} +G5: {"p":1,"q":5} {"p":1,"q":5} +G20: {"p":1,"q":2} {"p":1,"q":2} +{"x":9,"y":"k"}{"a":1,"b":"nm"} From 892504c6f2fe8a0a8c737dd33b44f3e30f8ed8be Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Tue, 3 Nov 2026 19:54:57 +0200 Subject: [PATCH 22/50] new $class(...$pack): an untyped, erased or property pack takes the module constructor table instead of one inline arm per class; MANTICORE_DYNM_TRACE names the sites that still do not --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 42 +++++++++++++++++-- tests/aot/cases/new_dyn_spread_cell_pack.php | 16 +++++++ .../aot/expected/new_dyn_spread_cell_pack.out | 3 ++ ...ew_dyn_unknown_class_returns_null.expected | 2 + .../new_dyn_unknown_class_returns_null.php | 11 +++++ 5 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 tests/aot/cases/new_dyn_spread_cell_pack.php create mode 100644 tests/aot/expected/new_dyn_spread_cell_pack.out create mode 100644 tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.expected create mode 100644 tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.php diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index f1f434ef3..46c846bd8 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -255,6 +255,11 @@ private function emitNewDynObj(\Compile\Mir\NewDynObj $n): string $viaTable = $this->emitNewDynByTable($n, $namePtr); if ($viaTable !== null) { return $out . $viaTable; } + if (\getenv('MANTICORE_DYNM_TRACE') !== false) { + $ak = ''; + foreach ($n->args as $ta) { $ak .= ($ak === '' ? '' : ',') . $ta->kind . ':' . $ta->type->toString(); } + \error_log('NEWDYN chain ' . $this->frame->name . ' table=' . (string)\count($this->newDynTable()[2]) . ' args=' . $ak); + } $argRegs = []; $argKinds = []; @@ -531,21 +536,52 @@ private function emitNewDynByTable(\Compile\Mir\NewDynObj $n, string $namePtr): if ($count === 0) { return null; } $pack = null; $packElem = null; + $packCell = false; + $packErased = false; foreach ($n->args as $a) { if ($a->kind !== Node::KIND_SPREAD) { continue; } if (\count($n->args) !== 1) { return null; } $op = $this->asSpreadNode($a)->operand; $pt = $op->type; - if (!$pt->isVec() || $op->kind !== Node::KIND_LOAD_LOCAL) { return null; } + // An untyped `$parameters` (a cell) or a bare `array` (erased) rides as the pack the + // trampolines take, as in the dynamic-method table; the operand is read ONCE, before the + // lookup, so a plain property / element read is as good as a local. + $packCell = $pt->kind === Type::KIND_CELL; + $packErased = $pt->kind === Type::KIND_UNKNOWN; + $plain = $op->kind === Node::KIND_LOAD_LOCAL + || $op->kind === Node::KIND_PROPERTY_ACCESS || $op->kind === Node::KIND_ARRAY_ACCESS; + if ((!$pt->isVec() && !$packCell && !$packErased) || !$plain) { return null; } $el = $pt->element; - if ($el !== null && $el->kind !== Type::KIND_CELL && $el->kind !== Type::KIND_UNKNOWN) { + if (!$packCell && !$packErased && $el !== null && $el->kind !== Type::KIND_CELL && $el->kind !== Type::KIND_UNKNOWN) { $packElem = $el; } $pack = $op; } $out = ''; $fresh = $pack === null || $packElem !== null; - if ($pack !== null) { + if ($pack !== null && $packErased) { + $out .= $this->emitNode($pack); + $out .= $this->cellToPtr(); + } elseif ($pack !== null && $packCell) { + $out .= $this->emitNode($pack); + $out .= $this->coerceToI64(); + $packCellReg = $this->lastValue; + $out .= $this->cellTagIr($packCellReg); + $isArr = $this->ssa->allocReg(); + $out .= ' ' . $isArr . ' = icmp eq i64 ' . $this->cellTagReg . ", 7\n"; + $packGood = $this->ssa->allocLabel('newdyn.array'); + $packBad = $this->ssa->allocLabel('newdyn.nonarray'); + $out .= ' br i1 ' . $isArr . ', label %' . $packGood . ', label %' . $packBad . "\n"; + $out .= $packBad . ":\n"; + $out .= $this->emitNamedFatal('PHP Fatal error: Only arrays and Traversables can be unpacked'); + $out .= " unreachable\n"; + $out .= $packGood . ":\n"; + $payload = $this->ssa->allocReg(); + $out .= ' ' . $payload . ' = and i64 ' . $packCellReg . ', ' . (string)\Compile\MemoryAbi::CELL_PAYLOAD_MASK . "\n"; + $argsPtr = $this->ssa->allocReg(); + $out .= ' ' . $argsPtr . ' = inttoptr i64 ' . $payload . " to ptr\n"; + $this->lastValue = $argsPtr; + } elseif ($pack !== null) { $out .= $this->emitNode($pack); $out .= $this->coerceToPtr(); if ($packElem !== null) { diff --git a/tests/aot/cases/new_dyn_spread_cell_pack.php b/tests/aot/cases/new_dyn_spread_cell_pack.php new file mode 100644 index 000000000..30f28f119 --- /dev/null +++ b/tests/aot/cases/new_dyn_spread_cell_pack.php @@ -0,0 +1,16 @@ +a)"; } } +class P2 { public function __construct(public string $a, public string $b = 'dflt') {} public function d() { return "P2($this->a,$this->b)"; } } +class P3 { public function __construct(public $a, public $b, public $c) {} public function d() { return "P3(" . json_encode([$this->a, $this->b, $this->c]) . ")"; } } +class Maker { + public array $held = ['x', 'y']; + public function make($class, $args) { return new $class(...$args); } + public function makeTyped(string $class, array $args) { return new $class(...$args); } + public function makeHeld(string $class) { return new $class(...$this->held); } +} +$m = new Maker; +echo $m->make('P0', [])->d(), ' ', $m->make('P1', [7])->d(), ' ', $m->make('P2', ['q'])->d(), ' ', $m->make('P3', [1, 'two', [3]])->d(), "\n"; +echo $m->makeTyped('P1', [9])->d(), ' ', $m->makeTyped('P2', ['a', 'b'])->d(), "\n"; +echo $m->makeHeld('P2')->d(), "\n"; diff --git a/tests/aot/expected/new_dyn_spread_cell_pack.out b/tests/aot/expected/new_dyn_spread_cell_pack.out new file mode 100644 index 000000000..041db6ef5 --- /dev/null +++ b/tests/aot/expected/new_dyn_spread_cell_pack.out @@ -0,0 +1,3 @@ +P0 P1(7) P2(q,dflt) P3([1,"two",[3]]) +P1(9) P2(a,b) +P2(x,y) diff --git a/tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.expected b/tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.expected new file mode 100644 index 000000000..89bc0477b --- /dev/null +++ b/tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.expected @@ -0,0 +1,2 @@ +1 +Error: Class "NoSuchClass" not found diff --git a/tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.php b/tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.php new file mode 100644 index 000000000..28f668730 --- /dev/null +++ b/tests/aot/repro/wrong/new_dyn_unknown_class_returns_null.php @@ -0,0 +1,11 @@ +a, "\n"; +try { + var_dump(make('NoSuchClass')); +} catch (Error $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; +} From af7054f4046fca0a9a80ee665572dc83cac9ed6c Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Tue, 3 Nov 2026 20:39:02 +0200 Subject: [PATCH 23/50] new $class(...$pack): a call, cast or ternary pack takes the constructor table too, the owned temp given back after; the test carries a dynamic method call so the table is exercised --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 19 +++++++++++++++++-- tests/aot/cases/new_dyn_spread_cell_pack.php | 10 ++++++++++ .../aot/expected/new_dyn_spread_cell_pack.out | 3 +++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 46c846bd8..51321e779 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -257,7 +257,7 @@ private function emitNewDynObj(\Compile\Mir\NewDynObj $n): string if ($viaTable !== null) { return $out . $viaTable; } if (\getenv('MANTICORE_DYNM_TRACE') !== false) { $ak = ''; - foreach ($n->args as $ta) { $ak .= ($ak === '' ? '' : ',') . $ta->kind . ':' . $ta->type->toString(); } + foreach ($n->args as $ta) { $ak .= ($ak === '' ? '' : ',') . $ta->kind . ':' . $ta->type->toString() . ($ta->kind === Node::KIND_SPREAD ? '(' . $this->asSpreadNode($ta)->operand->kind . ')' : ''); } \error_log('NEWDYN chain ' . $this->frame->name . ' table=' . (string)\count($this->newDynTable()[2]) . ' args=' . $ak); } @@ -538,6 +538,7 @@ private function emitNewDynByTable(\Compile\Mir\NewDynObj $n, string $namePtr): $packElem = null; $packCell = false; $packErased = false; + $plain = true; foreach ($n->args as $a) { if ($a->kind !== Node::KIND_SPREAD) { continue; } if (\count($n->args) !== 1) { return null; } @@ -550,22 +551,32 @@ private function emitNewDynByTable(\Compile\Mir\NewDynObj $n, string $namePtr): $packErased = $pt->kind === Type::KIND_UNKNOWN; $plain = $op->kind === Node::KIND_LOAD_LOCAL || $op->kind === Node::KIND_PROPERTY_ACCESS || $op->kind === Node::KIND_ARRAY_ACCESS; - if ((!$pt->isVec() && !$packCell && !$packErased) || !$plain) { return null; } + if (!$pt->isVec() && !$packCell && !$packErased) { return null; } $el = $pt->element; if (!$packCell && !$packErased && $el !== null && $el->kind !== Type::KIND_CELL && $el->kind !== Type::KIND_UNKNOWN) { $packElem = $el; } $pack = $op; } + // A pack that is not a plain read (a call, a cast, a ternary) may be an owned temp: the operand + // is evaluated once, here, and given back after the construction by whatever flavor says so. + if ($pack !== null && !$plain && $packElem !== null) { return null; } + $ownFlavor = ''; + $ownReg = ''; $out = ''; $fresh = $pack === null || $packElem !== null; if ($pack !== null && $packErased) { $out .= $this->emitNode($pack); + $out .= $this->coerceToI64(); + $ownReg = $this->lastValue; + $ownFlavor = $plain ? '' : $this->freshRcArgFlavor($pack); $out .= $this->cellToPtr(); } elseif ($pack !== null && $packCell) { $out .= $this->emitNode($pack); $out .= $this->coerceToI64(); $packCellReg = $this->lastValue; + $ownReg = $packCellReg; + $ownFlavor = $plain ? '' : $this->freshRcArgFlavor($pack); $out .= $this->cellTagIr($packCellReg); $isArr = $this->ssa->allocReg(); $out .= ' ' . $isArr . ' = icmp eq i64 ' . $this->cellTagReg . ", 7\n"; @@ -583,6 +594,9 @@ private function emitNewDynByTable(\Compile\Mir\NewDynObj $n, string $namePtr): $this->lastValue = $argsPtr; } elseif ($pack !== null) { $out .= $this->emitNode($pack); + $ownFlavor = $plain ? '' : $this->freshRcArgFlavor($pack); + $out .= $this->coerceToI64(); + $ownReg = $this->lastValue; $out .= $this->coerceToPtr(); if ($packElem !== null) { $out .= $this->emitVecToCellArray($packElem); @@ -638,6 +652,7 @@ private function emitNewDynByTable(\Compile\Mir\NewDynObj $n, string $namePtr): $out .= ' ' . $ri . ' = ptrtoint ptr ' . $argsP . " to i64\n"; $out .= $this->rcReleaseReg($ri, 'veccell'); } + if ($ownFlavor !== '' && $ownReg !== '') { $out .= $this->rcReleaseReg($ownReg, $ownFlavor); } $res = $this->ssa->allocReg(); $out .= ' ' . $res . ' = load i64, ptr ' . $slot . "\n"; $this->lastValue = $res; diff --git a/tests/aot/cases/new_dyn_spread_cell_pack.php b/tests/aot/cases/new_dyn_spread_cell_pack.php index 30f28f119..ff624ede7 100644 --- a/tests/aot/cases/new_dyn_spread_cell_pack.php +++ b/tests/aot/cases/new_dyn_spread_cell_pack.php @@ -9,8 +9,18 @@ class Maker { public function make($class, $args) { return new $class(...$args); } public function makeTyped(string $class, array $args) { return new $class(...$args); } public function makeHeld(string $class) { return new $class(...$this->held); } + public function makeCall(string $class, array $args) { return new $class(...array_values($args)); } + public function makeCast(string $class, $v) { return new $class(...(array) $v); } + public function makeTern(string $class, bool $c, array $a, array $b) { return new $class(...($c ? $a : $b)); } } $m = new Maker; echo $m->make('P0', [])->d(), ' ', $m->make('P1', [7])->d(), ' ', $m->make('P2', ['q'])->d(), ' ', $m->make('P3', [1, 'two', [3]])->d(), "\n"; echo $m->makeTyped('P1', [9])->d(), ' ', $m->makeTyped('P2', ['a', 'b'])->d(), "\n"; echo $m->makeHeld('P2')->d(), "\n"; +echo $m->makeCall('P1', ['k' => 3])->d(), ' ', $m->makeCast('P1', 4)->d(), ' ', $m->makeCast('P2', ['u', 'v'])->d(), ' ', $m->makeTern('P2', true, ['t'], ['f'])->d(), ' ', $m->makeTern('P2', false, ['t'], ['f', 'g'])->d(), "\n"; +for ($i = 0; $i < 3; $i++) { $m->makeCall('P3', [$i, [$i], 'x']); } +echo "done\n"; +// a dynamic method call in the program is what makes the module carry constructor trampolines +class Echoer { public function say(...$a) { return implode('+', $a); } } +$meth = 'say'; +echo (new Echoer)->$meth(...['p', 'q']), "\n"; diff --git a/tests/aot/expected/new_dyn_spread_cell_pack.out b/tests/aot/expected/new_dyn_spread_cell_pack.out index 041db6ef5..478bd3ee6 100644 --- a/tests/aot/expected/new_dyn_spread_cell_pack.out +++ b/tests/aot/expected/new_dyn_spread_cell_pack.out @@ -1,3 +1,6 @@ P0 P1(7) P2(q,dflt) P3([1,"two",[3]]) P1(9) P2(a,b) P2(x,y) +P1(3) P1(4) P2(u,v) P2(t,dflt) P2(f,g) +done +p+q From d991cb82b7e2f286477a24f848d95eb5884ab371 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Tue, 3 Nov 2026 19:22:51 +0200 Subject: [PATCH 24/50] class_uses(): resolve the class name through the crc32 tree instead of a switch over every declaration (an Eloquent model calls it several times per attribute; /api/notes 6 to 17 rps) --- src/Compile/Mir/Passes/LowerFromAst.php | 15 ++++++++++----- src/Compile/Mir/Passes/LowerPrelude.php | 11 ++++++----- tests/aot/cases/class_uses_name_tree.php | 15 +++++++++++++++ tests/aot/expected/class_uses_name_tree.out | 13 +++++++++++++ 4 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 tests/aot/cases/class_uses_name_tree.php create mode 100644 tests/aot/expected/class_uses_name_tree.out diff --git a/src/Compile/Mir/Passes/LowerFromAst.php b/src/Compile/Mir/Passes/LowerFromAst.php index beab43ba6..d45f43d96 100644 --- a/src/Compile/Mir/Passes/LowerFromAst.php +++ b/src/Compile/Mir/Passes/LowerFromAst.php @@ -6425,7 +6425,9 @@ private function declName(\Parser\Ast\ClassDecl $d): string { return $d->name; } */ private function classUsesSrc(): string { - $arms = ''; + /** @var array $bodies lowercased name => the statement that answers it */ + $bodies = []; + $names = []; $decls = []; foreach ($this->classDecls as $d) { $decls[$d->name] = $d; } foreach ($this->traitTable as $d) { $decls[$d->name] = $d; } @@ -6435,13 +6437,16 @@ private function classUsesSrc(): string $tn = \ltrim($t, '\\'); $lit[] = "'" . \addcslashes($tn, "'\\") . "' => '" . \addcslashes($tn, "'\\") . "'"; } - $key = \addcslashes(\strtolower(\ltrim($name, '\\')), "'\\"); - $arms .= " case '" . $key . "': return [" . \implode(', ', $lit) . "];\n"; + $key = \strtolower(\ltrim($name, '\\')); + if (!isset($bodies[$key])) { $names[] = $key; } + $bodies[$key] = 'return [' . \implode(', ', $lit) . '];'; } + // O(log N) crc32 tree, not a switch: a model calls this several times per attribute. + $tree = $this->nameTreeSrc('$__k', $names, function (string $n) use ($bodies): string { return $bodies[$n]; }, ' '); return "function class_uses(object|string \$object_or_class, bool \$autoload = true): array|false {\n" . " \$n = \\is_object(\$object_or_class) ? \\get_class(\$object_or_class) : \$object_or_class;\n" - . " switch (\\strtolower(\\ltrim(\$n, '\\\\'))) {\n" . $arms - . " }\n return false;\n}\n"; + . " \$__k = \\strtolower(\\ltrim(\$n, '\\\\'));\n" . $tree + . " return false;\n}\n"; } /** diff --git a/src/Compile/Mir/Passes/LowerPrelude.php b/src/Compile/Mir/Passes/LowerPrelude.php index 0a622f61a..13faad979 100644 --- a/src/Compile/Mir/Passes/LowerPrelude.php +++ b/src/Compile/Mir/Passes/LowerPrelude.php @@ -1241,9 +1241,9 @@ private function reflAllocSrc(): string * leaf `return`s); no match falls out of the tree to what follows it. * * @param string[] $names - * @param string $leaf statement(s) for a match; `%s` is the quoted class name + * @param string|callable $leaf statement(s) for a match; `%s` is the quoted class name, or a callable taking the (unquoted) name */ - private function nameTreeSrc(string $var, array $names, string $leaf, string $pad): string + private function nameTreeSrc(string $var, array $names, $leaf, string $pad): string { /** @var array $groups crc32 => the names with it */ $groups = []; @@ -1263,7 +1263,7 @@ private function nameTreeSrc(string $var, array $names, string $leaf, string $pa * @param int[] $keys the crc32 values, ascending * @param array $groups */ - private function nameTreeRange(string $var, array $keys, array $groups, int $lo, int $hi, string $leaf, string $pad): string + private function nameTreeRange(string $var, array $keys, array $groups, int $lo, int $hi, $leaf, string $pad): string { if ($hi <= $lo) { return ''; } if ($hi - $lo <= 2) { @@ -1284,12 +1284,13 @@ private function nameTreeRange(string $var, array $keys, array $groups, int $lo, } /** @param string[] $names the names sharing one crc32 */ - private function nameGroupSrc(string $var, array $names, string $leaf, string $pad): string + private function nameGroupSrc(string $var, array $names, $leaf, string $pad): string { $o = ''; foreach ($names as $n) { $q = $this->dqBody($n); - $o .= $pad . 'if (' . $var . ' === "' . $q . '") { ' . \str_replace('%s', $q, $leaf) . " }\n"; + $stmt = \is_string($leaf) ? \str_replace('%s', $q, $leaf) : $leaf($n); + $o .= $pad . 'if (' . $var . ' === "' . $q . '") { ' . $stmt . " }\n"; } return $o; } diff --git a/tests/aot/cases/class_uses_name_tree.php b/tests/aot/cases/class_uses_name_tree.php new file mode 100644 index 000000000..f7e3750c9 --- /dev/null +++ b/tests/aot/cases/class_uses_name_tree.php @@ -0,0 +1,15 @@ + ', json_encode(@\class_uses($n)), "\n"; +} +echo json_encode(\class_uses(new B)), json_encode(\class_uses(new C)), "\n"; diff --git a/tests/aot/expected/class_uses_name_tree.out b/tests/aot/expected/class_uses_name_tree.out new file mode 100644 index 000000000..90177ed14 --- /dev/null +++ b/tests/aot/expected/class_uses_name_tree.out @@ -0,0 +1,13 @@ +'App\\Deep\\A' => {"App\\Deep\\T1":"App\\Deep\\T1"} +'app\\deep\\a' => {"App\\Deep\\T1":"App\\Deep\\T1"} +'\\App\\Deep\\A' => {"App\\Deep\\T1":"App\\Deep\\T1"} +'App\\Deep\\B' => {"App\\Deep\\T2":"App\\Deep\\T2","App\\Deep\\T1":"App\\Deep\\T1"} +'app\\deep\\b' => {"App\\Deep\\T2":"App\\Deep\\T2","App\\Deep\\T1":"App\\Deep\\T1"} +'App\\Deep\\C' => [] +'App\\Deep\\E' => {"App\\Deep\\T1":"App\\Deep\\T1"} +'App\\Deep\\T2' => {"App\\Deep\\T1":"App\\Deep\\T1"} +'App\\Deep\\T1' => [] +'Nope' => false +'App\\Deep\\Nope' => false +'' => false +{"App\\Deep\\T2":"App\\Deep\\T2","App\\Deep\\T1":"App\\Deep\\T1"}[] From ccce2f0fa6f99934fa97d8a8f6fc9bc00fcdd9ca Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Tue, 3 Nov 2026 21:21:58 +0200 Subject: [PATCH 25/50] Outline the erased-receiver member-call Error into one cold helper per method name instead of ~570 IR lines per site --- src/Compile/Mir/Passes/EmitLlvmBuiltins.php | 81 +++++++++++++++++++ src/Compile/Mir/Passes/EmitLlvmObjects.php | 8 +- tests/aot/cases/erased_member_call_error.php | 13 +++ .../aot/expected/erased_member_call_error.out | 8 ++ 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 tests/aot/cases/erased_member_call_error.php create mode 100644 tests/aot/expected/erased_member_call_error.out diff --git a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php index 0fbc24655..ae248437f 100644 --- a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php +++ b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php @@ -294,6 +294,7 @@ private function emitBuiltinDispatch(Call $c): ?string if ($name === '__mc_pool_start') { return $this->biPoolStart($args); } if ($name === '__mir_to_cell') { return $this->biToCell($args); } if ($name === '__mir_throw_error') { return $this->biThrowError($args); } + if ($name === '__mir_member_call_error') { return $this->biMemberCallError($args); } if ($name === '__mir_throw_type_error') { return $this->biThrowError($args, 'TypeError'); } if ($name === '__mir_throw_argcount_error') { return $this->biThrowError($args, 'ArgumentCountError'); } if ($name === '__mir_untag_str') { return $this->biUntagStr($args); } @@ -8325,6 +8326,86 @@ private function reflClassName(Node $arg): string * Static detection is not lost — the analyzer reports `undefined.constant` * for exactly this, and it runs closed-world over the whole source set. */ + /** + * `Call to undefined method C::m()` / `Call to a member function m() on ` + * for an erased receiver, as ONE cold helper per method name: the get_class / + * get_debug_type select chains and the Error construction were spliced + * (~570 IR lines) into every erased call site. + */ + private function biMemberCallError(array $args): string + { + $m = $this->reflLitStr($args[1]); + $out = $this->emitNode($args[0]); + $out .= $this->coerceToI64(); + $v = $this->lastValue; + $key = '__mir_mcerr|' . $m; + $sym = '@' . $this->mirHelperSym('__mir_mcerr_' . \dechex($this->fnvHash64($m))); + if (!isset($this->dynamicMethodHelpers[$key])) { + $this->dynamicMethodHelpers[$key] = ''; + $oldSsa = $this->ssa; + $oldLocals = $this->locals; + $oldCf = $this->cf; + $oldArena = $this->arena; + $oldLast = $this->lastValue; + $oldLastType = $this->lastValueType; + $oldVdResult = $this->vdResult; + $oldVdArm = $this->vdArmList; + $oldVdArgc = $this->vdSiteArgc; + $oldSpread = $this->spreadTail; + $oldPad = $this->lastPadArgs; + $oldNeedsBacktrace = $this->rt->needsBacktrace; + $oldCellProv = $this->cellProv; + $oldCellSinkOrd = $this->cellSinkOrd; + $oldCellSinkFn = $this->cellSinkFnOverride; + $this->ssa = new \Compile\Mir\SsaBuilder(); + $this->ssa->reset(); + $this->resetCellGuardFrame(); + $this->cellSinkFnOverride = \ltrim($sym, '@'); + $this->locals = new \Compile\Mir\LocalSlots(); + $this->cf = new \Compile\Mir\ControlFlow(); + $this->arena = new \Compile\Mir\ArenaContext(); + $this->spreadTail = null; + $this->lastPadArgs = ''; + $slot = $this->ssa->allocReg(); + $this->locals->slots['__mc_mce'] = $slot; + $body = 'define hidden i64 ' . $sym . "(i64 %v) noinline cold {\nentry:\n"; + $body .= ' ' . $slot . " = alloca i64\n store i64 %v, ptr " . $slot . "\n"; + $cell = Type::cell(); + $str = fn (string $x): Node => new \Compile\Mir\StringConst($x, Type::string_()); + $t = fn (): Node => new \Compile\Mir\LoadLocal('__mc_mce', $cell); + $call = fn (string $fn, array $a, Type $ty): Node => new \Compile\Mir\Call($fn, $a, $ty); + $missing = new \Compile\Mir\Ternary($call('is_object', [$t()], Type::bool_()), + new \Compile\Mir\Concat(new \Compile\Mir\Concat($str('Call to undefined method '), + $call('get_class', [$t()], Type::string_())), $str('::' . $m . '()')), + new \Compile\Mir\Concat($str('Call to a member function ' . $m . '() on '), + $call('get_debug_type', [$t()], Type::string_())), + Type::string_()); + $body .= $this->emitNode($call('__mir_throw_error', [$missing], $cell)); + $body .= $this->coerceToI64(); + $body .= ' ret i64 ' . $this->lastValue . "\n}\n\n"; + $this->dynamicMethodHelpers[$key] = $body; + $this->ssa = $oldSsa; + $this->locals = $oldLocals; + $this->cf = $oldCf; + $this->arena = $oldArena; + $this->lastValue = $oldLast; + $this->lastValueType = $oldLastType; + $this->vdResult = $oldVdResult; + $this->vdArmList = $oldVdArm; + $this->vdSiteArgc = $oldVdArgc; + $this->spreadTail = $oldSpread; + $this->lastPadArgs = $oldPad; + $this->rt->needsBacktrace = $oldNeedsBacktrace; + $this->cellProv = $oldCellProv; + $this->cellSinkOrd = $oldCellSinkOrd; + $this->cellSinkFnOverride = $oldCellSinkFn; + } + $r = $this->ssa->allocReg(); + $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $v . ")\n"; + $this->lastValue = $r; + $this->lastValueType = 'i64'; + return $out; + } private function biThrowError(array $args, string $cls = 'Error'): string { // The message is the ARGUMENT NODE, not a re-wrapped literal. It used to diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 51321e779..9dd7d1e7d 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -9590,13 +9590,7 @@ private function emitErasedMagicCall(\Compile\Mir\MethodCall_ $mc): string $argsArr = new \Compile\Mir\ArrayLit($elems, Type::vec($cell)); $magic = $this->anyClassDeclares('__call'); $isObj = $call('is_object', [$t()], Type::bool_()); - $missing = new \Compile\Mir\Ternary($isObj, - new \Compile\Mir\Concat(new \Compile\Mir\Concat($str('Call to undefined method '), - $call('get_class', [$t()], Type::string_())), $str('::' . $mc->method . '()')), - new \Compile\Mir\Concat($str('Call to a member function ' . $mc->method . '() on '), - $call('get_debug_type', [$t()], Type::string_())), - Type::string_()); - $rest = $err($missing); + $rest = $call('__mir_member_call_error', [$t(), $str($mc->method)], $cell); if ($magic) { $viaMagic = new \Compile\Mir\MethodCall_($t(), '__call', [$str($mc->method), $argsArr], $cell); $hasMagic = new \Compile\Mir\Ternary($isObj, $call('method_exists', [$t(), $str('__call')], Type::bool_()), diff --git a/tests/aot/cases/erased_member_call_error.php b/tests/aot/cases/erased_member_call_error.php new file mode 100644 index 000000000..aebc4f7e4 --- /dev/null +++ b/tests/aot/cases/erased_member_call_error.php @@ -0,0 +1,13 @@ +undefinedThing(); } catch (\Throwable $e) { return get_class($e) . ': ' . $e->getMessage(); } +} +function call2($o) { + try { return $o->otherName(1); } catch (\Throwable $e) { return get_class($e) . ': ' . $e->getMessage(); } +} +foreach ([new Plain, new Magic, null, 5, 1.5, 'str', [1], new stdClass] as $v) { + echo call($v, 'x'), ' | ', call2($v), "\n"; +} diff --git a/tests/aot/expected/erased_member_call_error.out b/tests/aot/expected/erased_member_call_error.out new file mode 100644 index 000000000..457b38caf --- /dev/null +++ b/tests/aot/expected/erased_member_call_error.out @@ -0,0 +1,8 @@ +Error: Call to undefined method Plain::undefinedThing() | Error: Call to undefined method Plain::otherName() +magic:undefinedThing | magic:otherName +Error: Call to a member function undefinedThing() on null | Error: Call to a member function otherName() on null +Error: Call to a member function undefinedThing() on int | Error: Call to a member function otherName() on int +Error: Call to a member function undefinedThing() on float | Error: Call to a member function otherName() on float +Error: Call to a member function undefinedThing() on string | Error: Call to a member function otherName() on string +Error: Call to a member function undefinedThing() on array | Error: Call to a member function otherName() on array +Error: Call to undefined method stdClass::undefinedThing() | Error: Call to undefined method stdClass::otherName() From ef4bbd2c9613c5b1549aa287daefb1afbbdab714 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 19:30:42 +0200 Subject: [PATCH 26/50] $erased->m(...f()): an owned pack also takes the shared arm helper, with by-ref holders binding the temp --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 12 ++++++++-- .../cases/erased_spread_method_fresh_pack.php | 22 +++++++++++++++++++ .../erased_spread_method_fresh_pack.out | 10 +++++++++ .../leak/array_literal_index_objects.expected | 1 + .../leak/array_literal_index_objects.php | 11 ++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/aot/cases/erased_spread_method_fresh_pack.php create mode 100644 tests/aot/expected/erased_spread_method_fresh_pack.out create mode 100644 tests/aot/repro/leak/array_literal_index_objects.expected create mode 100644 tests/aot/repro/leak/array_literal_index_objects.php diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 9dd7d1e7d..ce7191a83 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -9519,14 +9519,21 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin $el = $pt->element; if (!$pt->isVec() || ($el !== null && $el->kind !== Type::KIND_CELL && $el->kind !== Type::KIND_UNKNOWN)) { return null; } } - if ($op->kind !== Node::KIND_LOAD_LOCAL && $op->kind !== Node::KIND_STORE_LOCAL) { return null; } - if (\count($this->methodHolders($n->method)) < 8 || $this->dynMethodNameHasRefParam($n->method, -1)) { return null; } + // A by-ref parameter binds an element of a pack that is a variable, so that needs the + // caller's frame. An owned temp pack has no observer of the write. + $plainPack = $op->kind === Node::KIND_LOAD_LOCAL || $op->kind === Node::KIND_STORE_LOCAL; + if (\count($this->methodHolders($n->method)) < 8 || ($plainPack && $this->dynMethodNameHasRefParam($n->method, -1))) { return null; } $sym = $this->dynamicMethodSpreadArmHelper($n->object->type, -1, $n->method, Type::cell(), $n->srcArgc); if ($sym === '') { return null; } $out = $this->emitNode($n->object); $out .= $this->coerceToI64(); $recv = $this->lastValue; $out .= $this->emitNode($op); + // A pack that is not a plain read (`...\func_get_args()`) may be an owned temp, + // given back after the call by whatever flavor says so. + $ownFlavor = $plainPack ? '' : $this->freshRcArgFlavor($op); + $out .= $this->coerceToI64(); + $ownReg = $this->lastValue; if ($erasedPack) { $out .= $this->cellToPtr(); } else { @@ -9535,6 +9542,7 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin $argsP = $this->lastValue; $r = $this->ssa->allocReg(); $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $recv . ', ptr ' . $argsP . ")\n"; + if ($ownFlavor !== '') { $out .= $this->rcReleaseReg($ownReg, $ownFlavor); } $this->lastValue = $r; $this->lastValueType = 'i64'; $this->markCellOpaque($r); diff --git a/tests/aot/cases/erased_spread_method_fresh_pack.php b/tests/aot/cases/erased_spread_method_fresh_pack.php new file mode 100644 index 000000000..7889c9718 --- /dev/null +++ b/tests/aot/cases/erased_spread_method_fresh_pack.php @@ -0,0 +1,22 @@ +m(...f())` over many declaring classes: an owned pack is unpacked once and released. +interface I { public function m(...$a); } +class A implements I { public function m(...$a) { return 'A' . implode(',', $a); } } +class B implements I { public function m(...$a) { return 'B' . count($a); } } +class C implements I { public function m(...$a) { return 'C' . ($a[0] ?? 'd') . ($a[1] ?? 'e'); } } +class D implements I { public function m(...$a) { return 'D' . json_encode($a); } } +class E implements I { public function m(...$a) { return 'E'; } } +class F implements I { public function m(...$a) { return 'F' . ($a[0] ?? '-'); } } +class G implements I { public function m(...$a) { return 'G' . count($a); } } +class H implements I { public function m(...$a) { return 'H' . strrev((string)($a[0] ?? '')); } } +class R { public function m(&$x = 0, ...$a) { $x = 'changed'; return 'R' . $x . count($a); } } +function pick(int $i): object { return [new A, new B, new C, new D, new E, new F, new G, new H, new R][$i]; } +function pack1(): array { return [1, 2, 3]; } +function viaFuncGetArgs($o, $x = 0, $y = 0) { return $o->m(...\array_slice(\func_get_args(), 1)); } +for ($i = 0; $i < 9; $i++) { + echo pick($i)->m(...pack1()), ' ', pick($i)->m(...['x' . $i, 'y']), ' ', viaFuncGetArgs(pick($i), 7, 8), "\n"; +} +$kept = pick(0); +$before = memory_get_usage(); +for ($n = 0; $n < 200000; $n++) { $kept->m(...pack1()); } +echo memory_get_usage() - $before < 8000000 ? "flat\n" : "leak\n"; diff --git a/tests/aot/expected/erased_spread_method_fresh_pack.out b/tests/aot/expected/erased_spread_method_fresh_pack.out new file mode 100644 index 000000000..f6bb25514 --- /dev/null +++ b/tests/aot/expected/erased_spread_method_fresh_pack.out @@ -0,0 +1,10 @@ +A1,2,3 Ax0,y A7,8 +B3 B2 B2 +C12 Cx2y C78 +D[1,2,3] D["x3","y"] D[7,8] +E E E +F1 Fx5 F7 +G3 G2 G2 +H1 H7x H7 +Rchanged2 Rchanged1 Rchanged1 +flat diff --git a/tests/aot/repro/leak/array_literal_index_objects.expected b/tests/aot/repro/leak/array_literal_index_objects.expected new file mode 100644 index 000000000..42b509ed5 --- /dev/null +++ b/tests/aot/repro/leak/array_literal_index_objects.expected @@ -0,0 +1 @@ +flat diff --git a/tests/aot/repro/leak/array_literal_index_objects.php b/tests/aot/repro/leak/array_literal_index_objects.php new file mode 100644 index 000000000..0aa3c9075 --- /dev/null +++ b/tests/aot/repro/leak/array_literal_index_objects.php @@ -0,0 +1,11 @@ +> 20; +fwrite(STDERR, "grew {$mb} MB\n"); +echo $mb < 8 ? "flat\n" : "leak\n"; From fe306e3d9b39187d8a07b3b9bf2b37b8091947f7 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 19:47:04 +0200 Subject: [PATCH 27/50] $erased->m(...f()): the lowering's temp pack takes the shared arm helper even when a holder has a by-ref param --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index ce7191a83..88a9e02f8 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -9519,21 +9519,17 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin $el = $pt->element; if (!$pt->isVec() || ($el !== null && $el->kind !== Type::KIND_CELL && $el->kind !== Type::KIND_UNKNOWN)) { return null; } } - // A by-ref parameter binds an element of a pack that is a variable, so that needs the - // caller's frame. An owned temp pack has no observer of the write. - $plainPack = $op->kind === Node::KIND_LOAD_LOCAL || $op->kind === Node::KIND_STORE_LOCAL; - if (\count($this->methodHolders($n->method)) < 8 || ($plainPack && $this->dynMethodNameHasRefParam($n->method, -1))) { return null; } + if ($op->kind !== Node::KIND_LOAD_LOCAL && $op->kind !== Node::KIND_STORE_LOCAL) { return null; } + // A by-ref parameter binds an element of a pack held in a variable, which needs the + // caller's frame. The lowering's own temp for `...f()` has no observer of the write. + $tempPack = $op->kind === Node::KIND_STORE_LOCAL && \str_starts_with($op->name, '__fb_'); + if (\count($this->methodHolders($n->method)) < 8 || (!$tempPack && $this->dynMethodNameHasRefParam($n->method, -1))) { return null; } $sym = $this->dynamicMethodSpreadArmHelper($n->object->type, -1, $n->method, Type::cell(), $n->srcArgc); if ($sym === '') { return null; } $out = $this->emitNode($n->object); $out .= $this->coerceToI64(); $recv = $this->lastValue; $out .= $this->emitNode($op); - // A pack that is not a plain read (`...\func_get_args()`) may be an owned temp, - // given back after the call by whatever flavor says so. - $ownFlavor = $plainPack ? '' : $this->freshRcArgFlavor($op); - $out .= $this->coerceToI64(); - $ownReg = $this->lastValue; if ($erasedPack) { $out .= $this->cellToPtr(); } else { @@ -9542,7 +9538,6 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin $argsP = $this->lastValue; $r = $this->ssa->allocReg(); $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $recv . ', ptr ' . $argsP . ")\n"; - if ($ownFlavor !== '') { $out .= $this->rcReleaseReg($ownReg, $ownFlavor); } $this->lastValue = $r; $this->lastValueType = 'i64'; $this->markCellOpaque($r); From a0542168d213e287b50bc1d7793581335779575f Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 20:02:47 +0200 Subject: [PATCH 28/50] SplitModule: a big file-local table gets one definition per module instead of a copy per part (part text -21%, objects -21%) --- src/Compile/Mir/SplitModule.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Compile/Mir/SplitModule.php b/src/Compile/Mir/SplitModule.php index e2f9af42b..52735e559 100644 --- a/src/Compile/Mir/SplitModule.php +++ b/src/Compile/Mir/SplitModule.php @@ -73,6 +73,9 @@ final class SplitModule */ public bool $stable = false; + /** Size from which a file-local global gets one definition per module. */ + private const SHARED_LOCAL_BYTES = 4096; + /** Cached symbol references for definitions reused by several parts. */ /** @var array> */ private array $refsCache = []; @@ -735,7 +738,7 @@ private function renderPart(PartPlan $plan, int $p, array $defHead, array $assig // — every other part would then hold an unresolved external. // weak_odr is the same coalescing with no discard. if (isset($plan->ownG[$g])) { - $gtext = $gtext . $this->pinGlobal($gl) . "\n"; + $gtext = $gtext . $this->pinGlobal($this->promoteGlobal($gl)) . "\n"; continue; } $gtext = $gtext . $this->externGlobal($gl) . "\n"; @@ -1036,13 +1039,21 @@ private function globalName(string $line): string return $this->readSymbol($line, 1); } - /** 'local' (file-local), 'coalesced' (discardable if unused) or 'strong'. */ + /** + * 'local' (file-local), 'coalesced' (discardable if unused) or 'strong'. + * + * A big file-local table is coalesced too. Copied into every part that names it, + * three dispatch tables alone cost 3.2 MB per part. A small one stays local: the + * optimizer reads a short string's bytes, which a declaration would hide from it. + */ private function globalClass(string $line): string { $eq = \strpos($line, ' = '); if ($eq === false) { return 'strong'; } $rest = \substr($line, $eq + 3); - if (\str_starts_with($rest, 'internal ') || \str_starts_with($rest, 'private ')) { return 'local'; } + if (\str_starts_with($rest, 'internal ') || \str_starts_with($rest, 'private ')) { + return \strlen($line) >= self::SHARED_LOCAL_BYTES ? 'coalesced' : 'local'; + } if (\str_starts_with($rest, 'linkonce_odr ') || \str_starts_with($rest, 'weak_odr ') || \str_starts_with($rest, 'linkonce ') || \str_starts_with($rest, 'weak ')) { return 'coalesced'; } return 'strong'; From adc88ed5b312e93c5d0c462b9a938743b659692e Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 20:29:32 +0200 Subject: [PATCH 29/50] SplitModule: a definition of 1 MB or more goes unoptimized by default, clang spends 30% of its CPU on a handful of them --- src/Compile/Mir/SplitModule.php | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/Compile/Mir/SplitModule.php b/src/Compile/Mir/SplitModule.php index 52735e559..d64adf311 100644 --- a/src/Compile/Mir/SplitModule.php +++ b/src/Compile/Mir/SplitModule.php @@ -76,6 +76,8 @@ final class SplitModule /** Size from which a file-local global gets one definition per module. */ private const SHARED_LOCAL_BYTES = 4096; + private const OPTNONE_DEFAULT_KB = 1024; + /** Cached symbol references for definitions reused by several parts. */ /** @var array> */ private array $refsCache = []; @@ -327,9 +329,46 @@ private function promoteLarge(array $defOrder, array &$defHead, array $defSize, $promoted[$s] = \strlen($h); $defHead[$s] = \substr($h, 0, $at) . ' hidden ' . \substr($h, $at + 10); } + $this->markOversized($defOrder, $defSize, $defHead, $promoted); return $promoted; } + /** + * Leave a definition of at least {@see OPTNONE_DEFAULT_KB} KB unoptimized: + * clang's cost grows faster than a function's size, so a few giants dominate + * (78 definitions were 30% of clang's CPU on a Symfony app, and they are mostly + * generated dispatch). `MANTICORE_OPTNONE_KB=` moves the bar, 0 turns it off, + * and `-O3` asks for everything to be optimized. + * + * @param string[] $defOrder + * @param array $defSize + * @param array $defHead + * @param array $promoted symbol => length of its head in the file + */ + private function markOversized(array $defOrder, array $defSize, array &$defHead, array &$promoted): void + { + $env = \getenv('MANTICORE_OPTNONE_KB'); + $kb = $env === false ? self::OPTNONE_DEFAULT_KB : (int)$env; + if ($kb <= 0 || ($env === false && \Manticore\clang_opt_level() === '3')) { return; } + foreach ($defOrder as $s) { + if (($defSize[$s] ?? 0) < $kb * 1024) { continue; } + $h = $defHead[$s]; + if (\str_contains($h, 'alwaysinline')) { continue; } + $open = \strpos($h, '(', (int)\strpos($h, '@')); + if ($open === false) { continue; } + $depth = 0; + $n = \strlen($h); + for ($i = $open; $i < $n; $i = $i + 1) { + if ($h[$i] === '(') { $depth = $depth + 1; } + if ($h[$i] === ')') { $depth = $depth - 1; } + if ($depth === 0) { break; } + } + if ($i >= $n) { continue; } + if (!isset($promoted[$s])) { $promoted[$s] = \strlen($h); } + $defHead[$s] = \substr($h, 0, $i + 1) . ' noinline optnone' . \substr($h, $i + 1); + } + } + /** * Assign every shared definition to a part, charging each part for the From f8616e00a855636b70f4f1f79d00effdd87af9f9 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sat, 7 Nov 2026 20:59:58 +0200 Subject: [PATCH 30/50] $erased->m(...$pack): no by-ref veto, a pack element reaches a parameter by value on the inline path too --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 88a9e02f8..8b36a83f1 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -9520,10 +9520,10 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin if (!$pt->isVec() || ($el !== null && $el->kind !== Type::KIND_CELL && $el->kind !== Type::KIND_UNKNOWN)) { return null; } } if ($op->kind !== Node::KIND_LOAD_LOCAL && $op->kind !== Node::KIND_STORE_LOCAL) { return null; } - // A by-ref parameter binds an element of a pack held in a variable, which needs the - // caller's frame. The lowering's own temp for `...f()` has no observer of the write. - $tempPack = $op->kind === Node::KIND_STORE_LOCAL && \str_starts_with($op->name, '__fb_'); - if (\count($this->methodHolders($n->method)) < 8 || (!$tempPack && $this->dynMethodNameHasRefParam($n->method, -1))) { return null; } + // No by-ref veto: the site's only argument is the spread, and a pack element reaches a + // parameter by VALUE on the inline path as well ({@see emitSpreadFill}), so a by-ref + // parameter on some class changes nothing the helper could carry differently. + if (\count($this->methodHolders($n->method)) < 8) { return null; } $sym = $this->dynamicMethodSpreadArmHelper($n->object->type, -1, $n->method, Type::cell(), $n->srcArgc); if ($sym === '') { return null; } $out = $this->emitNode($n->object); From bc3016cc2f03ef6ecfc24d1ea36557737c58803e Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 10:45:02 +0200 Subject: [PATCH 31/50] Build: the compiler stops holding 3 GB beside clang: hash cache keys over 8 MB chunks instead of reading each part whole, free the splitter before clang, write big bodies straight to the staged file (Laravel tree peak 16.3 -> 13.7 GB) --- src/Compile/Mir/Passes/EmitLlvm.php | 8 ++++++++ src/Manticore/Main.php | 20 +++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 2a147af87..2082c5862 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -207,6 +207,14 @@ private function rootSnapshot(string $phase, \Compile\Mir\Module $module, bool $ private function bodyAppend(string $path, string $s): void { + if (\strlen($s) >= 1048576) { + // A big body goes straight to the file: buffering it copies it once more. + $this->bodyFlush($path); + if (!\Manticore\append_file_bytes($path, $s)) { + throw new \RuntimeException('EmitLlvm: cannot append staged body file ' . $path); + } + return; + } $this->bodyPend .= $s; if (\strlen($this->bodyPend) >= 1048576) { $this->bodyFlush($path); } } diff --git a/src/Manticore/Main.php b/src/Manticore/Main.php index ef623362d..f45e34186 100644 --- a/src/Manticore/Main.php +++ b/src/Manticore/Main.php @@ -951,11 +951,23 @@ function clang_id(): string function obj_cache_key(string $llPath, string $flags): string { - $h = \sha1_file($llPath); - if ($h === false) { + // A digest of 8 MB chunk digests: sha1_file() reads the whole part into one + // string, 65 parts of up to 170 MB that the compiler then holds while clang runs. + $f = \fopen($llPath, 'rb'); + if ($f === false) { return ''; } - return \sha1($flags . '|' . clang_id() . '|' . $h); + $h = ''; + while (!\feof($f)) { + $chunk = \fread($f, 8388608); + if ($chunk === false) { + \fclose($f); + return ''; + } + $h .= \sha1($chunk, true); + } + \fclose($f); + return \sha1($flags . '|' . clang_id() . '|' . \sha1($h)); } /** True when the cached object was placed at `$dest`. */ @@ -1384,6 +1396,8 @@ function assemble_ir_file_split( $splitter->sharedDefs, $splitter->internalDefs ); + // Its module index would otherwise stay resident beside every clang. + unset($splitter); $lto = thinlto_flags(); $objs = []; /** @var string[] */ From ea6524b519a1f53109067c22f22382e692c3e915 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 10:52:37 +0200 Subject: [PATCH 32/50] EmitLlvm: hoist and write the extra body tables piece by piece instead of joining them, and release each table once written; staged IR byte-identical --- src/Compile/Mir/Passes/EmitLlvm.php | 42 ++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 2082c5862..48c1e9f24 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -1303,23 +1303,31 @@ public function emit(Module $module): string if ($this->needsBagOfFn) { $extraBodies .= $this->emitBagOfFn(); } if ($this->needsBoxUnknownFn) { $extraBodies .= $this->emitBoxUnknownFn(); } $extraBodies .= $this->emitErasedIfaceFns(); - $extraBodies .= $this->vdExtraBodies; - $extraBodies .= $this->dynmExtraBodies; - $extraBodies .= $this->scmpExtraBodies; - $extraBodies .= $this->dynfExtraBodies; - $extraBodies .= $this->litTableBodies; + // The big tables stay separate pieces, in this order, each released once + // written: joining them held every one of them twice (185 MB on Laravel). + /** @var string[] $extraParts */ + $extraParts = [$extraBodies, $this->vdExtraBodies, $this->dynmExtraBodies, $this->scmpExtraBodies, + $this->dynfExtraBodies, $this->litTableBodies]; + $extraBodies = ''; $this->litTablesFlushed = true; - if ($this->needsInclResolveFn) { $extraBodies .= $this->emitInclResolveFn(); } + if ($this->needsInclResolveFn) { $extraParts[] = $this->emitInclResolveFn(); } // Erased fixed-property readers are generated lazily while ordinary // functions emit. Append each helper exactly once after the function // loop; callers then contain only a small call instead of a repeated // class-id switch. The preamble is emitted afterwards, so any runtime // demand raised by the helper is still visible to emitPreamble(). $this->rootSnapshot('post-lazy-helper-build', $module, true, $bodyBytes); - \Compile\Stats::line('IR: extra bodies joined ' . (string)\strlen($extraBodies) . ' bytes' + $extraBytes = 0; + foreach ($extraParts as $piece) { $extraBytes += \strlen($piece); } + \Compile\Stats::line('IR: extra bodies joined ' . (string)$extraBytes . ' bytes' . ' (vd ' . (string)\strlen($this->vdExtraBodies) . ', dynm ' . (string)\strlen($this->dynmExtraBodies) . ', dynf ' . (string)\strlen($this->dynfExtraBodies) . ', lit ' . (string)\strlen($this->litTableBodies) . ', helpers ' . (string)\count($this->dynamicMethodHelpers) . ')'); + $this->vdExtraBodies = ''; + $this->dynmExtraBodies = ''; + $this->scmpExtraBodies = ''; + $this->dynfExtraBodies = ''; + $this->litTableBodies = ''; if ($streaming) { // Helper bodies are compiler-owned text and can be numerous on Doctrine. // Do not join all of them into one temporary PHP string: stage, hoist, @@ -1362,12 +1370,18 @@ public function emit(Module $module): string $bodyBytes += $nBody; }; $h = new \Compile\Mir\HoistAllocas(); - $extraBodies = $h->runFast($extraBodies); - \Compile\Stats::line('IR: extra bodies hoisted'); - $hoistedAllocas += $h->moved; - $this->bodyAppend($bodyPath, $extraBodies); - $bodyBytes += \strlen($extraBodies); - unset($extraBodies); + $nParts = \count($extraParts); + for ($i = 0; $i < $nParts; $i = $i + 1) { + $piece = $extraParts[$i]; + $extraParts[$i] = ''; + if ($piece === '') { continue; } + $piece = $h->runFast($piece); + $hoistedAllocas += $h->moved; + $this->bodyAppend($bodyPath, $piece); + $bodyBytes += \strlen($piece); + $piece = ''; + } + unset($extraParts); \Compile\Stats::line('IR: extra bodies appended'); $this->drainLazyHelpers($appendStreamedBody); \Compile\Stats::line('IR: lazy helpers drained'); @@ -1379,6 +1393,8 @@ public function emit(Module $module): string . (string)$fileHoistThreshold . ')'); $this->rootSnapshot('post-extra-body-merge', $module, true, $bodyBytes); } else { + $extraBodies = \implode('', $extraParts); + unset($extraParts); $functionBodyChunks[] = $extraBodies; $bodyBytes += \strlen($extraBodies); // The buffered path needs the same drain. Streaming grew one and the From f7aea2f9b005259066a5cc998293f4c9e88551cc Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 11:37:05 +0200 Subject: [PATCH 33/50] Reflection and dynamic-method tables share one immortal string global per distinct name instead of one per row (symfony-demo: 360k strings, 37k distinct, 64 MB of IR) --- src/Compile/Mir/Passes/EmitLlvm.php | 11 +++++++++ src/Compile/Mir/Passes/EmitLlvmBuiltins.php | 25 +++++++++++++++++++ src/Compile/Mir/Passes/EmitLlvmRuntime.php | 27 ++++++--------------- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 48c1e9f24..fe2e8c434 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -204,6 +204,11 @@ private function rootSnapshot(string $phase, \Compile\Mir\Module $module, bool $ /** Sequence for private per-function raw text sink files. */ private int $functionTextCounter = 0; private string $bodyPend = ''; + /** @var array metadata string => its shared global, {@see EmitLlvmBuiltins::metaStrRef} */ + private array $metaStrSyms = []; + /** @var array */ + private array $metaStrTaken = []; + private string $metaStrDefs = ''; private function bodyAppend(string $path, string $s): void { @@ -939,6 +944,9 @@ public function emit(Module $module): string $this->dynfTables = []; $this->dynfExtraBodies = ''; $this->litTableBodies = ''; + $this->metaStrSyms = []; + $this->metaStrTaken = []; + $this->metaStrDefs = ''; $this->litTableCount = 0; $this->objTemplates = []; $this->litTablesFlushed = false; @@ -1432,6 +1440,9 @@ public function emit(Module $module): string $this->drainLazyHelpers(function (string $body, string $label) use (&$late): void { $late .= $body; }); $preamble .= $late; unset($late); + // Last: every table that names a shared metadata string is out by now. + $preamble .= $this->metaStrDefs; + $this->metaStrDefs = ''; \Compile\Stats::step(' emit preamble', $statT, -1, -1); \Compile\Stats::line('IR: preamble ' . (string)\strlen($preamble) . ' bytes'); if ($streaming) { diff --git a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php index ae248437f..16adb1a3d 100644 --- a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php +++ b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php @@ -4847,6 +4847,31 @@ private function strGlobalDef(string $sym, string $value, bool $empty = false): . (string)$len . ' x i8] c"' . $bytes . '\\00" }, align 8' . "\n"; } + /** + * The string bytes of one immortal global per distinct `$value`, named by its + * content so linkonce tables naming it stay identical across modules; defined + * once at the end of the preamble. Reflection and dynamic-method tables + * repeat the same names (a declaring class, a type, a parameter): on + * symfony-demo 360k strings had 37k distinct values, 64 MB against 6. + */ + private function metaStrRef(string $value): string + { + $sym = $this->metaStrSyms[$value] ?? ''; + if ($sym === '') { + $base = '@.mstr.' . (string)$this->fnvHash64($value); + $sym = $base; + $k = 0; + while (isset($this->metaStrTaken[$sym])) { + $k = $k + 1; + $sym = $base . '.' . (string)$k; + } + $this->metaStrTaken[$sym] = true; + $this->metaStrSyms[$value] = $sym; + $this->metaStrDefs .= $this->strGlobalDef($sym, $value); + } + return $this->strSymBytes($sym); + } + /** * is_int / is_string / … — runtime tag compare on a tagged cell * arg, else a compile-time constant from the static type. diff --git a/src/Compile/Mir/Passes/EmitLlvmRuntime.php b/src/Compile/Mir/Passes/EmitLlvmRuntime.php index 1c8bcb1df..6ae350a95 100644 --- a/src/Compile/Mir/Passes/EmitLlvmRuntime.php +++ b/src/Compile/Mir/Passes/EmitLlvmRuntime.php @@ -1960,8 +1960,7 @@ private function dynamicMethodTableForClass(\Compile\Mir\ClassDef $cls, string $ $i = 0; foreach ($cls->methodMeta as $mn => $mm) { if ($mn === '__construct' || $mn === '__call') { continue; } - $nameSym = '@.dynm.name.' . $id . '.' . (string)$i; - $defs .= $this->strGlobalDef($nameSym, $mn); + $nameRef = $this->metaStrRef((string)$mn); $trampFld = 'null'; if (\Compile\Mir\Passes\TrampolineSynth::invokable($mm)) { $decl = $mm->declaringClass !== '' ? $mm->declaringClass : $cls->name; @@ -1975,15 +1974,13 @@ private function dynamicMethodTableForClass(\Compile\Mir\ClassDef $cls, string $ // name and route the call to the old semantic fallback before trying // the class's __call handler. $declName = \ltrim($mm->declaringClass !== '' ? $mm->declaringClass : $cls->name, '\\'); - $declSym = '@.dynm.decl.' . $id . '.' . (string)$i; - $defs .= $this->strGlobalDef($declSym, $declName); $vis = match ($mm->visibility) { 'private' => \Compile\MemoryAbi::DYN_METHOD_VIS_PRIVATE, 'protected' => \Compile\MemoryAbi::DYN_METHOD_VIS_PROTECTED, default => \Compile\MemoryAbi::DYN_METHOD_VIS_PUBLIC, }; $rows[] = \Compile\Mir\RuntimeLibrary::dynamicMethodRow( - $this->strSymBytes($nameSym), $trampFld, $this->strSymBytes($declSym), $vis); + $nameRef, $trampFld, $this->metaStrRef($declName), $vis); $i = $i + 1; } // A class may have no ordinary fixed-shape methods but still need a @@ -2045,8 +2042,6 @@ private function rmetaMethodRows(array $methodMeta, string $clsName, ?\Compile\M $defs = ''; $i = 0; foreach ($methodMeta as $mn => $mm) { - $sym = '@.rmeta.m.' . $id . '.' . (string)$i; - $defs .= $this->strGlobalDef($sym, $mn); $pp = $this->rmetaParamTable($mm, $id, $i, $clsName); $defs .= $pp[0]; $mdecl = $mm->declaringClass !== '' ? $mm->declaringClass : $clsName; @@ -2055,20 +2050,16 @@ private function rmetaMethodRows(array $methodMeta, string $clsName, ?\Compile\M // Always a real string (empty when untyped), never a null pointer: // hasReturnType()/getReturnType() compare it to "" BY VALUE, and a // null pointer read back is 0, which is `!== ""` — a false positive. - $rsym = '@.rmeta.mret.' . $id . '.' . (string)$i; - $defs .= $this->strGlobalDef($rsym, $mm->returnType); - $retFld = $this->strSymBytes($rsym); - $dsym = '@.rmeta.mdecl.' . $id . '.' . (string)$i; - $defs .= $this->strGlobalDef($dsym, $mdecl); + $retFld = $this->metaStrRef($mm->returnType); $rows[] = \Compile\Mir\RuntimeLibrary::rmetaRow( - $this->strSymBytes($sym), + $this->metaStrRef((string)$mn), $this->memberFlags($mm->visibility, $mm->isStatic, $mm->isAbstract, $mm->isFinal, false) | $this->metaIsDeprecated($mm), $cls === null ? 'null' : $this->methodTrampField($cls, $mm, $mn), $this->methodArity($mm), \count($mm->params), $pp[1], - $ap[1], $ap[2], $retFld, $this->strSymBytes($dsym)); + $ap[1], $ap[2], $retFld, $this->metaStrRef($mdecl)); $i = $i + 1; } $pair = \Compile\Mir\RuntimeLibrary::rmetaTable('@.rmeta.mt.' . $id, $rows); @@ -2099,9 +2090,7 @@ private function rmetaParamTable(\Compile\Mir\MethodMeta $mm, string $id, int $m $defs = ''; $pi = 0; foreach ($mm->params as $pm) { - $nsym = '@.rmeta.pmn.' . $id . '.' . (string)$mi . '.' . (string)$pi; - $defs .= $this->strGlobalDef($nsym, $pm->name); - $names[] = $this->strSymBytes($nsym); + $names[] = $this->metaStrRef($pm->name); $hint = $pm->typeHint; if ($hint !== '' && $hint[0] === '?') { $hint = \substr($hint, 1); } $f = 0; @@ -2112,9 +2101,7 @@ private function rmetaParamTable(\Compile\Mir\MethodMeta $mm, string $id, int $m if ($pm->byRef) { $f = $f | \Compile\MemoryAbi::RMETA_PARAM_BYREF; } if ($pm->typeHint !== '') { $f = $f | \Compile\MemoryAbi::RMETA_PARAM_HAS_TYPE; - $tsym = '@.rmeta.pmt.' . $id . '.' . (string)$mi . '.' . (string)$pi; - $defs .= $this->strGlobalDef($tsym, $hint); - $types[] = $this->strSymBytes($tsym); + $types[] = $this->metaStrRef($hint); } else { $types[] = 'null'; } From 422685c35e0b213b2493df0a5c0f616abe17c19d Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 11:37:06 +0200 Subject: [PATCH 34/50] Erased property readers and writers ask one module predicate whether a class overloads __get/__set instead of listing every overloading class in each helper (~250 cases in each of ~8 800 helpers on symfony-demo) --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 48 +++++++++++++++++-- tests/aot/cases/magic_shared_default_arm.php | 22 +++++++++ .../aot/expected/magic_shared_default_arm.out | 12 +++++ 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 tests/aot/cases/magic_shared_default_arm.php create mode 100644 tests/aot/expected/magic_shared_default_arm.out diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 8b36a83f1..a8b2e5508 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -1680,12 +1680,12 @@ private function cellPropertyReadHelper(string $prop, array $fixed, array $enumA $bodies .= ' store i64 ' . $fv . ', ptr ' . $res . "\n"; $bodies .= ' br label %' . $end . "\n"; } + $magicLbl = ''; if (\count($magic) >= self::MAGIC_SHARED_MIN) { - $lbl = $this->ssa->allocLabel('pr.magic'); - foreach ($magic as $cname => $_decl) { - $switch .= ' i64 ' . (string)$this->classes[$cname]->classId . ', label %' . $lbl . "\n"; - } - $bodies .= $lbl . ":\n" . $this->magicDispatchCall('__get', $obj, $prop, '0'); + // Not one case per overloading class (~250 on symfony-demo, in each of + // ~4 400 readers): the default arm asks the module's one predicate. + $magicLbl = $this->ssa->allocLabel('pr.magic'); + $bodies .= $magicLbl . ":\n" . $this->magicDispatchCall('__get', $obj, $prop, '0'); $bodies .= ' store i64 ' . $this->lastValue . ', ptr ' . $res . "\n"; $bodies .= ' br label %' . $end . "\n"; $magic = []; @@ -1700,6 +1700,7 @@ private function cellPropertyReadHelper(string $prop, array $fixed, array $enumA } $switch .= " ]\n"; $out .= $switch . $bodies . $def . ":\n"; + if ($magicLbl !== '') { $out .= $this->magicHolderBranch('__get', $cid, $magicLbl); } if ($hasBag) { $out .= $this->emitBagReadByName($prop, $obj); $fv = $this->lastValue; @@ -2876,6 +2877,32 @@ private function magicDispatchFn(string $method): string return $sym; } + /** + * In a helper's default arm: branch to `$magicLbl` when the class `$cid` is one + * {@see magicDispatchFn} answers for, else fall through to what follows. + */ + private function magicHolderBranch(string $method, string $cid, string $magicLbl): string + { + $key = '__mc_magic_has_' . $method; + $sym = '@' . $this->mirHelperSym($key); + if (!isset($this->propertyReadHelpers[$key])) { + // linkonce_odr for the reason magicDispatchFn gives. + $out = 'define linkonce_odr i1 ' . $sym . "(i64 %cid) {\nentry:\n switch i64 %cid, label %no [\n"; + foreach ($this->methodHolders($method) as $cn => $_decl) { + $cd = $this->classes[$cn]; + if ($cd->isStruct || $cd->usesBag()) { continue; } + if ($this->isClosureClass($cn) || $this->isEnumClass($cn)) { continue; } + $out .= ' i64 ' . (string)$cd->classId . ", label %yes\n"; + } + $out .= " ]\nyes:\n ret i1 true\nno:\n ret i1 false\n}\n\n"; + $this->propertyReadHelpers[$key] = $out; + } + $h = $this->ssa->allocReg(); + $cont = $this->ssa->allocLabel('magic.no'); + return ' ' . $h . ' = call i1 ' . $sym . '(i64 ' . $cid . ")\n" + . ' br i1 ' . $h . ', label %' . $magicLbl . ', label %' . $cont . "\n" . $cont . ":\n"; + } + private function hasEmittedFunction(string $name): bool { return isset($this->definedFns[$this->mangle($name)]) @@ -4411,6 +4438,16 @@ private function cellPropertyWriteHelper(string $prop, string $within = '', bool $bodies .= $this->emitCellSlotStoreDropping($obj, $cd, $prop, $cellVal, $dropOld, $dropBoxed); $bodies .= ' br label %' . $end . "\n"; } + $magicLbl = ''; + if (\count($magic) >= self::MAGIC_SHARED_MIN && $within === '') { + // Every holder of `$prop` is a case above, so a class reaching the + // default arm overloads `__set` exactly when it is one of `$magic`: + // the module's one predicate stands in for a case per class. + $magicLbl = $this->ssa->allocLabel('pw.magic'); + $bodies .= $magicLbl . ":\n" . $this->magicDispatchCall('__set', $obj, $prop, $cellVal); + $bodies .= ' br label %' . $end . "\n"; + $magic = []; + } if (\count($magic) >= self::MAGIC_SHARED_MIN) { $lbl = $this->ssa->allocLabel('pw.magic'); $any = false; @@ -4437,6 +4474,7 @@ private function cellPropertyWriteHelper(string $prop, string $within = '', bool } $switch .= " ]\n"; $out .= $switch . $bodies . $def . ":\n"; + if ($magicLbl !== '') { $out .= $this->magicHolderBranch('__set', $cid, $magicLbl); } if ($hasBag) { $out .= $this->emitCellBagStoreByName($prop, $obj, $cellVal); } $out .= ' br label %' . $end . "\n"; $out .= $end . ":\n ret void\n}\n\n"; diff --git a/tests/aot/cases/magic_shared_default_arm.php b/tests/aot/cases/magic_shared_default_arm.php new file mode 100644 index 000000000..03f76c0cf --- /dev/null +++ b/tests/aot/cases/magic_shared_default_arm.php @@ -0,0 +1,22 @@ +x` read and write: classes overloading __get/__set, +// a class declaring x, a dynamic bag and a plain class all reach one shared helper. +class M1 { public array $log = []; public function __get(string $n): string { return 'M1:' . $n; } public function __set(string $n, mixed $v): void { $this->log[] = "M1 $n=$v"; } } +class M2 { public array $log = []; public function __get(string $n): string { return 'M2:' . $n; } public function __set(string $n, mixed $v): void { $this->log[] = "M2 $n=$v"; } } +class M3 extends M2 { public function __get(string $n): string { return 'M3:' . $n; } } +class Fixed { public mixed $x = 'fixed'; public function __get(string $n): string { return 'never'; } } +#[\AllowDynamicProperties] +class Plain { public int $y = 1; } + +/** @param list $objs */ +function run(array $objs): void { + foreach ($objs as $o) { + echo get_class($o), ' read=', var_export(isset($o->x) ? $o->x : ($o instanceof Plain ? 'unset' : $o->x), true), "\n"; + $o->x = 42; + echo get_class($o), ' after=', var_export($o->x, true); + if (property_exists($o, 'log')) { echo ' log=', json_encode($o->log); } + echo "\n"; + } +} +$bag = new stdClass; $bag->x = 'bag'; +run([new M1, new M2, new M3, new Fixed, $bag, new Plain]); diff --git a/tests/aot/expected/magic_shared_default_arm.out b/tests/aot/expected/magic_shared_default_arm.out new file mode 100644 index 000000000..bd5224fe4 --- /dev/null +++ b/tests/aot/expected/magic_shared_default_arm.out @@ -0,0 +1,12 @@ +M1 read='M1:x' +M1 after='M1:x' log=["M1 x=42"] +M2 read='M2:x' +M2 after='M2:x' log=["M2 x=42"] +M3 read='M3:x' +M3 after='M3:x' log=["M2 x=42"] +Fixed read='fixed' +Fixed after=42 +stdClass read='bag' +stdClass after=42 +Plain read='unset' +Plain after=42 From e81a2dba0b189fef36892992d6153f8d6b8d9217 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 12:58:01 +0200 Subject: [PATCH 35/50] Repros: a spread call binds an unknown named key positionally (#595) and names the constructor C::::construct() in ArgumentCountError (#596) --- .../wrong/spread_argcount_error_mangles_ctor_name.expected | 1 + .../repro/wrong/spread_argcount_error_mangles_ctor_name.php | 6 ++++++ .../spread_unknown_named_param_binds_positionally.expected | 1 + .../wrong/spread_unknown_named_param_binds_positionally.php | 6 ++++++ 4 files changed, 14 insertions(+) create mode 100644 tests/aot/repro/wrong/spread_argcount_error_mangles_ctor_name.expected create mode 100644 tests/aot/repro/wrong/spread_argcount_error_mangles_ctor_name.php create mode 100644 tests/aot/repro/wrong/spread_unknown_named_param_binds_positionally.expected create mode 100644 tests/aot/repro/wrong/spread_unknown_named_param_binds_positionally.php diff --git a/tests/aot/repro/wrong/spread_argcount_error_mangles_ctor_name.expected b/tests/aot/repro/wrong/spread_argcount_error_mangles_ctor_name.expected new file mode 100644 index 000000000..d95b464a3 --- /dev/null +++ b/tests/aot/repro/wrong/spread_argcount_error_mangles_ctor_name.expected @@ -0,0 +1 @@ +Too few arguments to function C8::__construct() diff --git a/tests/aot/repro/wrong/spread_argcount_error_mangles_ctor_name.php b/tests/aot/repro/wrong/spread_argcount_error_mangles_ctor_name.php new file mode 100644 index 000000000..8a5df222a --- /dev/null +++ b/tests/aot/repro/wrong/spread_argcount_error_mangles_ctor_name.php @@ -0,0 +1,6 @@ +__construct(...$args)` names the method `C8::::construct()` instead of `C8::__construct()` +// issue: #596 +class C8 { public function __construct(public int $need) {} } +function f(array $args, object $o): void { $o->__construct(...$args); } +try { f([], new C8(1)); } catch (\ArgumentCountError $e) { echo \preg_replace('/\(\).*/', '()', $e->getMessage()), "\n"; } diff --git a/tests/aot/repro/wrong/spread_unknown_named_param_binds_positionally.expected b/tests/aot/repro/wrong/spread_unknown_named_param_binds_positionally.expected new file mode 100644 index 000000000..5556ede2d --- /dev/null +++ b/tests/aot/repro/wrong/spread_unknown_named_param_binds_positionally.expected @@ -0,0 +1 @@ +Error: Unknown named parameter $zz diff --git a/tests/aot/repro/wrong/spread_unknown_named_param_binds_positionally.php b/tests/aot/repro/wrong/spread_unknown_named_param_binds_positionally.php new file mode 100644 index 000000000..919565e6d --- /dev/null +++ b/tests/aot/repro/wrong/spread_unknown_named_param_binds_positionally.php @@ -0,0 +1,6 @@ +m(...$args)` with a string key naming no parameter binds the value to the first parameter instead of throwing "Unknown named parameter" +// issue: #595 +class C1 { public function __construct(public int $a = 0) {} } +function f(array $args): void { $o = new C1(); $o->__construct(...$args); echo $o->a, "\n"; } +try { f(['zz' => 1]); echo "no error\n"; } catch (\Error $e) { echo get_class($e), ': ', $e->getMessage(), "\n"; } From 9ccbe2badd2652019c70c56e8c0dd35ceb2e4a4a Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 12:58:02 +0200 Subject: [PATCH 36/50] Erased member access stops inlining one arm per class at each site: a typed spread pack takes the shared arm helper (one per pack type; Monomorphize clones forwarders per pack shape, mysqli_result::__mcFetchObject was 13 MB), and an unknown-receiver property read calls the shared per-name reader (symfony-demo closures of 25 MB of __get arms) --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 84 +++++-------------- .../cases/erased_spread_method_typed_pack.php | 25 ++++++ .../erased_spread_method_typed_pack.out | 6 ++ 3 files changed, 51 insertions(+), 64 deletions(-) create mode 100644 tests/aot/cases/erased_spread_method_typed_pack.php create mode 100644 tests/aot/expected/erased_spread_method_typed_pack.out diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index a8b2e5508..4db3d0f73 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -2144,60 +2144,12 @@ private function emitRawPropByClassId(PropertyAccess_ $pa): string // ERASED, so "one class declares __get" says nothing about the class in // hand. Calling it anyway ran Ovl::__get with a Plain2 `$this` and read // that object's slot 0 as an array. Always dispatch on class_id. - // Dispatch on class_id: each holder reads (and boxes) its OWN slot. - $out .= $this->emitLoadClassId($objPtr); - $cid = $this->classIdReg; - $res = $this->ssa->allocReg(); - $out .= ' ' . $res . " = alloca i64\n"; - $end = $this->ssa->allocLabel('rp.end'); - $def = $this->ssa->allocLabel('rp.default'); - $switch = ' switch i64 ' . $cid . ', label %' . $def . " [\n"; - $bodies = ''; - /** @var array {@see canonArm} */ - $armSeen = []; - foreach ($hooks as $hc => $hsym) { - unset($magic[$hc]); - $lbl = $this->ssa->allocLabel('rp.hook'); - $switch .= ' i64 ' . (string)$this->classes[$hc]->classId . ', label %' . $lbl . "\n"; - $bodies .= $lbl . ":\n" . $this->emitHookGetCell($objPtr, $hsym); - $bodies .= ' store i64 ' . $this->lastValue . ', ptr ' . $res . "\n"; - $bodies .= ' br label %' . $end . "\n"; - } - foreach ($fixed as $cd) { - $lbl = $this->ssa->allocLabel('rp.case'); - $arm = $this->emitFixedPropLoad($objPtr, $cd, $prop); - $fv = $this->lastValue; - if ($fresh) { $arm .= $this->rcRetainReg($fv, 'cell'); } - $arm .= ' store i64 ' . $fv . ', ptr ' . $res . "\n"; - $arm .= ' br label %' . $end . "\n"; - $key = $this->canonArm($arm); - if (isset($armSeen[$key])) { - $switch .= ' i64 ' . (string)$cd->classId . ', label %' . $armSeen[$key] . "\n"; - continue; - } - $armSeen[$key] = $lbl; - $switch .= ' i64 ' . (string)$cd->classId . ', label %' . $lbl . "\n"; - $bodies .= $lbl . ":\n" . $arm; - } - foreach ($magic as $cname => $declCls) { - $lbl = $this->ssa->allocLabel('rp.magic'); - $switch .= ' i64 ' . (string)$this->classes[$cname]->classId . ', label %' . $lbl . "\n"; - $bodies .= $lbl . ":\n"; - $bodies .= $this->emitMagicGetCell($declCls, $objPtr, $prop); - $bodies .= ' store i64 ' . $this->lastValue . ', ptr ' . $res . "\n"; - $bodies .= ' br label %' . $end . "\n"; - } - $switch .= " ]\n"; - $out .= $switch . $bodies; - $out .= $def . ":\n"; - $out .= $this->emitBagReadByName($prop, $objPtr); - $fv = $this->lastValue; - if ($fresh) { $out .= $this->rcRetainReg($fv, 'cell'); } - $out .= ' store i64 ' . $fv . ', ptr ' . $res . "\n"; - $out .= ' br label %' . $end . "\n"; - $out .= $end . ":\n"; + // Dispatch on class_id: each holder reads (and boxes) its OWN slot. The switch is + // the shared per-name reader, not a copy per site: one symfony-demo closure read + // ~40 erased properties and spliced 25 MB of `__get` arms. + $sym = $this->cellPropReadHelperFor($prop, $fresh); $r = $this->ssa->allocReg(); - $out .= ' ' . $r . ' = load i64, ptr ' . $res . "\n"; + $out .= ' ' . $r . ' = call i64 ' . $sym . '(ptr ' . $objPtr . ")\n"; $this->lastValue = $r; $this->lastValueType = 'i64'; $this->markCellOpaque($r); @@ -4527,7 +4479,7 @@ private function holdersWithin(string $scls, array &$fixed, array &$magic, bool /** The shared reader symbol for `$prop`, built on demand. Unlike the inline * path this asks for one even when a single class declares the name: the * point is that the SITE stays a call, whatever the arm count. */ - private function cellPropReadHelperFor(string $prop): string + private function cellPropReadHelperFor(string $prop, bool $fresh = false): string { $enumArms = []; if ($prop === 'name' || $prop === 'value') { @@ -4542,6 +4494,7 @@ private function cellPropReadHelperFor(string $prop): string $enumArms, $this->magicPropHolders($prop, '__get'), $this->bagClassNames() !== [], + $fresh, ); } @@ -5251,12 +5204,12 @@ private function dynamicMethodSpreadFallbackHelper(\Compile\Mir\DynProp_ $dp, ar } /** @return Node[] the arm's arguments, read out of the packed `__mc_dyn_args` local */ - private function dynSpreadArmArgs(int $fixedN): array + private function dynSpreadArmArgs(int $fixedN, ?Type $packT = null): array { $vecT = Type::vec(Type::cell()); $armArgs = []; if ($fixedN < 0) { - $armArgs[] = new \Compile\Mir\Spread_(new \Compile\Mir\LoadLocal('__mc_dyn_args', $vecT), Type::cell()); + $armArgs[] = new \Compile\Mir\Spread_(new \Compile\Mir\LoadLocal('__mc_dyn_args', $packT ?? $vecT), Type::cell()); } else { for ($i = 0; $i < $fixedN; $i++) { $armArgs[] = new \Compile\Mir\ArrayAccess_(new \Compile\Mir\LoadLocal('__mc_dyn_args', $vecT), @@ -5280,13 +5233,14 @@ private function dynSpreadArmArgs(int $fixedN): array * splicing a copy (~210 IR lines per arm, 1 850 arms per shape, 39 shapes). * '' when the body is not closed; the caller then inlines the arm. */ - private function dynamicMethodSpreadArmHelper(Type $rt, int $fixedN, string $m, Type $retT, int $srcArgc = -1): string + private function dynamicMethodSpreadArmHelper(Type $rt, int $fixedN, string $m, Type $retT, int $srcArgc = -1, ?Type $packT = null): string { $recvKey = $rt->class ?? ''; if ($rt->kind === Type::KIND_UNION) { foreach ($rt->atoms as $at) { $recvKey .= ($at->class ?? '') . '|'; } } - $key = 'inlarm|' . $rt->kind . ':' . $recvKey . '|' . (string)$fixedN . '|' . $retT->kind . ':' . ($retT->class ?? '') . '|' . $m . '|' . (string)$srcArgc; + $key = 'inlarm|' . $rt->kind . ':' . $recvKey . '|' . (string)$fixedN . '|' . $retT->kind . ':' . ($retT->class ?? '') . '|' . $m . '|' . (string)$srcArgc + . ($packT === null ? '' : '|' . $packT->exactString()); if (isset($this->dynArmSyms[$key])) { return $this->dynArmSyms[$key]; } $sym = '@' . $this->mirHelperSym('__mir_dynarm_' . (string)\count($this->dynArmSyms)); $this->dynArmSyms[$key] = $sym; @@ -5336,7 +5290,7 @@ private function dynamicMethodSpreadArmHelper(Type $rt, int $fixedN, string $m, $body .= ' ' . $argsSlot . " = alloca i64\n"; $body .= " %argsI = ptrtoint ptr %args to i64\n store i64 %argsI, ptr " . $argsSlot . "\n"; $recv = new \Compile\Mir\LoadLocal('__mc_dyn_obj', $rt); - $call = new \Compile\Mir\MethodCall_($recv, $m, $this->packDynVariadicArgs($recv, $m, $this->dynSpreadArmArgs($fixedN)), $retT); + $call = new \Compile\Mir\MethodCall_($recv, $m, $this->packDynVariadicArgs($recv, $m, $this->dynSpreadArmArgs($fixedN, $packT)), $retT); $call->srcArgc = $srcArgc; $body .= $this->emitMethodCall($call); $body .= $this->boxToCell($retT); @@ -9553,16 +9507,18 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin $op = $this->asSpreadNode($n->args[0])->operand; $pt = $op->type; $erasedPack = $pt->kind === Type::KIND_UNKNOWN; - if (!$erasedPack) { - $el = $pt->element; - if (!$pt->isVec() || ($el !== null && $el->kind !== Type::KIND_CELL && $el->kind !== Type::KIND_UNKNOWN)) { return null; } - } + // A typed pack keys its own helper and is unpacked there as its type says: + // Monomorphize clones a forwarding method per pack shape (`assoc[string, cell]`, + // `vec[int]`), and each clone inlined one arm per class declaring the method + // (ContainerBuilder::createService's `$proxy->__construct(...$arguments)`: 14 MB). + if (!$erasedPack && !$pt->isArray()) { return null; } if ($op->kind !== Node::KIND_LOAD_LOCAL && $op->kind !== Node::KIND_STORE_LOCAL) { return null; } // No by-ref veto: the site's only argument is the spread, and a pack element reaches a // parameter by VALUE on the inline path as well ({@see emitSpreadFill}), so a by-ref // parameter on some class changes nothing the helper could carry differently. if (\count($this->methodHolders($n->method)) < 8) { return null; } - $sym = $this->dynamicMethodSpreadArmHelper($n->object->type, -1, $n->method, Type::cell(), $n->srcArgc); + $sym = $this->dynamicMethodSpreadArmHelper($n->object->type, -1, $n->method, Type::cell(), $n->srcArgc, + $erasedPack ? null : $pt); if ($sym === '') { return null; } $out = $this->emitNode($n->object); $out .= $this->coerceToI64(); diff --git a/tests/aot/cases/erased_spread_method_typed_pack.php b/tests/aot/cases/erased_spread_method_typed_pack.php new file mode 100644 index 000000000..1d50bebb1 --- /dev/null +++ b/tests/aot/cases/erased_spread_method_typed_pack.php @@ -0,0 +1,25 @@ +__construct(...$pack)` with a typed pack (Monomorphize clones the forwarder per +// pack shape): named keys, positional ints and a variadic. Error paths: #595, #596. +class C0 { public function __construct(public int $a = 0, public string $b = '') {} } +class C1 { public function __construct(public int $a = 0) {} } +class C2 { public function __construct(public string $b = 'x') {} } +class C3 { public function __construct(public int $a = 0, public int $c = 1) {} } +class C4 { public function __construct() {} } +class C5 { public function __construct(public ?int $a = null) {} } +class C6 { public array $rest = []; public function __construct(public int $a = 0, int ...$rest) { $this->rest = $rest; } } +class C7 { public function __construct(public float $a = 0.0) {} } +class C8 { public function __construct(public int $need) {} } + +function make(array $arguments, bool|object $tryProxy = true): object { + if (\is_object($tryProxy)) { $tryProxy->__construct(...$arguments); return $tryProxy; } + return new C0(); +} +function show(object $o): void { echo get_class($o), ' ', json_encode(get_object_vars($o)), "\n"; } + +show(make(['a' => 5, 'b' => 'hi'], new C0())); +show(make(['b' => 'only'], new C0())); +show(make([7, 8], new C3())); +show(make([1, 2, 3, 4], new C6())); +show(make(['b' => 'z'], new C2())); +show(make([2.5], new C7())); diff --git a/tests/aot/expected/erased_spread_method_typed_pack.out b/tests/aot/expected/erased_spread_method_typed_pack.out new file mode 100644 index 000000000..534e5e65d --- /dev/null +++ b/tests/aot/expected/erased_spread_method_typed_pack.out @@ -0,0 +1,6 @@ +C0 {"a":5,"b":"hi"} +C0 {"a":0,"b":"only"} +C3 {"a":7,"c":8} +C6 {"rest":[2,3,4],"a":1} +C2 {"b":"z"} +C7 {"a":2.5} From 536ce57e4754202af7f6f3a199e03d0ea850641e Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 15:24:12 +0200 Subject: [PATCH 37/50] Erased invokes with value arguments or plain cell variables share one dispatch helper per argument shape instead of splicing every candidate arm at each site; the call-site trace line is passed in, a variable goes over as its slot so a by-reference arm still writes it (MANTICORE_ERINV_TRACE names the sites that stay inline) --- src/Compile/Mir/Passes/EmitLlvm.php | 4 + src/Compile/Mir/Passes/EmitLlvmCalls.php | 245 +++++++++++++++++- .../aot/cases/erased_invoke_shared_helper.php | 33 +++ .../expected/erased_invoke_shared_helper.out | 19 ++ 4 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 tests/aot/cases/erased_invoke_shared_helper.php create mode 100644 tests/aot/expected/erased_invoke_shared_helper.out diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index fe2e8c434..000d706bc 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -4495,6 +4495,10 @@ private function argIsByRef(array $mask, int $pi, Node $a): bool private function btPush(string $display, int $line): string { if (!$this->rt->needsBacktrace) { return ''; } + if ($this->btLineOverride !== '' && $this->ssa === $this->btLineOverrideSsa) { + // Inside a shared helper: the call site's absolute line is its argument. + return $this->btPushInline($this->strLitId($this->pool->intern($display)), $this->btLineOverride, ''); + } $fn = $this->frame->name; if ($fn === '') { return $this->btPushInline($this->strLitId($this->pool->intern($display)), (string)$line, ''); diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index b63b28f4f..0944b3e59 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -1960,16 +1960,259 @@ private function emitErasedExplicitInvoke(Node $n, \Compile\Mir\MethodCall_ $mc) private function emitErasedInvoke(Invoke_ $n): string { + $shared = $this->erasedInvokeShareable($n); $out = $this->emitNode($n->callee); $out .= $this->coerceToI64(); $raw = $this->lastValue; /** @var array $owned */ $owned = []; $n = $this->hoistFreshInvokeArgs($n, $out, $owned); - $out .= $this->emitErasedInvokeArms($n, $raw); + if ($shared) { + $out .= $this->emitErasedInvokeShared($n, $raw); + } else { + $out .= $this->emitErasedInvokeArms($n, $raw); + } return $out . $this->releaseHoistedArgs($owned); } + /** + * Can this erased invoke run through {@see erasedInvokeHelper}? Every argument + * must be a value no by-reference parameter could bind to (a constant, a cast, + * a call result: nothing the caller would see written back) of a type whose box + * takes no reference, and there is no spread. + */ + private function erasedInvokeShareable(Invoke_ $n): bool + { + $why = $this->erasedInvokeInlineReason($n); + if ($why !== '' && \getenv('MANTICORE_ERINV_TRACE') === '1') { + \fwrite(\STDERR, 'erinv inline: ' . $why . ' @ ' . $this->frame->name . "\n"); + } + return $why === ''; + } + + /** Why `$n` keeps the inline dispatch, '' when the shared helper can serve it. */ + private function erasedInvokeInlineReason(Invoke_ $n): string + { + // `$r = &$f()` binds the address the arms hand back undereferenced. + if (\getenv('MANTICORE_ERINV_SHARED') === '0') { return 'off'; } + if ($n->srcArgc === -2) { return 'srcArgc-2'; } + if ($this->rawRefCall) { return 'ref-bind'; } + foreach ($n->args as $a) { + if ($a->kind === Node::KIND_SPREAD) { return 'spread'; } + if ($this->erinvSlotArg($a)) { continue; } + $k = $a->type->kind; + // `$this` cannot be reassigned, so no by-reference parameter can write it back. + $isThis = $a->kind === Node::KIND_LOAD_LOCAL && $a->name === 'this' && $k === Type::KIND_OBJ; + if (!$isThis && $this->isByRefAddressable($a)) { return 'lvalue ' . $a->kind . ':' . $k; } + // An object box takes no reference ({@see boxToCell}); an array box does. + if ($k !== Type::KIND_CELL && $k !== Type::KIND_UNKNOWN && $k !== Type::KIND_INT && $k !== Type::KIND_BOOL + && $k !== Type::KIND_FLOAT && $k !== Type::KIND_NULL && $k !== Type::KIND_STRING + && $k !== Type::KIND_OBJ) { return 'type ' . $k; } + } + return ''; + } + + /** + * A plain CELL local of this frame: the helper takes its slot itself, so an arm + * binding it by reference writes the caller's variable, as the inline arm does + * ({@see emitDynByRefArg} hands a cell lvalue's own slot over). A local with + * reference, alias, capture or global bookkeeping stays with the inline path. + */ + private function erinvSlotArg(Node $a): bool + { + if ($a->kind !== Node::KIND_LOAD_LOCAL || $a->type->kind !== Type::KIND_CELL) { return false; } + $nm = $a->name; + $l = $this->locals; + return isset($l->slots[$nm]) && !isset($l->refLocals[$nm]) && !isset($l->refParamTypes[$nm]) + && !isset($l->captureRefs[$nm]) && !isset($l->globalBacked[$nm]) && !isset($l->byRefCaptured[$nm]) + && !isset($l->unsetNames[$nm]) && !isset($l->aliasLocals[$nm]) && !isset($l->refCellTargets[$nm]) + && !isset($l->ownedBoxes[$nm]) && !isset($l->byRefParamWb[$nm]) && !isset($l->elemRefTargets[$nm]) + && !isset($l->elemRefBoxes[$nm]) && !isset($l->aliasBoxNames[$nm]); + } + + /** The call of the shared helper for `$n`; lastValue as {@see emitErasedInvokeArms} leaves it. */ + private function emitErasedInvokeShared(Invoke_ $n, string $raw): string + { + $out = ''; + /** @var string[] $argRegs */ + $argRegs = []; + $modes = ''; + foreach ($n->args as $a) { + if ($this->erinvSlotArg($a)) { + $argRegs[] = 'ptr ' . $this->locals->slots[$a->name]; + $modes .= 's'; + continue; + } + $out .= $this->emitNode($a); + $out .= $this->boxToCell($a->type); + $argRegs[] = 'i64 ' . $this->lastValue; + $modes .= 'v'; + } + $line = 'i64 0'; + if ($this->rt->needsBacktrace) { + $fn = $this->frame->name; + if ($fn === '') { + $line = 'i64 ' . (string)$n->line; + } else { + if (!isset($this->btBaseLine[$fn])) { + $this->btBaseLine[$fn] = $n->line; + $this->litTableBodies .= '@.btl.' . $this->mangle($fn) . ' = linkonce_odr constant i64 ' . (string)$n->line . "\n"; + } + $b = $this->ssa->allocReg(); + $out .= ' ' . $b . ' = load i64, ptr @.btl.' . $this->mangle($fn) . "\n"; + $abs = $this->ssa->allocReg(); + $out .= ' ' . $abs . ' = add i64 ' . $b . ', ' . (string)($n->line - $this->btBaseLine[$fn]) . "\n"; + $line = 'i64 ' . $abs; + } + } + $sym = $this->erasedInvokeHelper($modes, $n->srcArgc); + $r = $this->ssa->allocReg(); + $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $raw . ', ' . $line + . ($argRegs === [] ? '' : ', ' . \implode(', ', $argRegs)) . ")\n"; + $this->lastValue = $r; + $this->lastValueType = 'i64'; + if ($this->isCellScalarParam($n->type)) { + $out .= $this->unboxCellToType($n->type); + } + if ($n->type->kind === Type::KIND_CELL) { + $this->markCellOpaque($this->lastValue); + } + return $out; + } + + /** + * Does the dispatch depend on the calling scope? Only through a compile-time + * visibility check: callable-array names run without the method table, or an + * `__invoke` that is not public. + */ + private function erinvScopeMatters(): bool + { + if ($this->erinvScopeMemo !== null) { return $this->erinvScopeMemo; } + $matters = $this->callableArrayMethods !== [] && !$this->dynamicMethodMeta; + if (!$matters) { + foreach ($this->classes as $cd) { + $mm = $cd->methodMeta['__invoke'] ?? null; + if ($mm !== null && $mm->visibility !== 'public') { $matters = true; break; } + } + } + $this->erinvScopeMemo = $matters; + return $matters; + } + + private ?bool $erinvScopeMemo = null; + + /** Call-site line for {@see btPush} inside {@see erasedInvokeHelper}: a register, '' elsewhere. + * Only for that helper's own body, not for a helper built while it emits. */ + private string $btLineOverride = ''; + private ?SsaBuilder $btLineOverrideSsa = null; + + /** @var array argc|srcArgc|scope|trace => helper symbol */ + private array $erinvSyms = []; + + /** + * `i64 (i64 callee, i64 line, i64 arg...)`: the whole erased-invoke dispatch + * (function names, callable arrays, `__invoke`, closures) for `$argc` cell + * arguments, once per module instead of once per site: on symfony-demo 945 sites + * carried 388 MB of it, about 15% of the module. + */ + private function erasedInvokeHelper(string $modes, int $srcArgc): string + { + $argc = \strlen($modes); + $scope = $this->erinvScopeMatters() ? ($this->accessScope() ?? '?') : ''; + $key = $modes . '|' . (string)$srcArgc . '|' . $scope . '|' . ($this->rt->needsBacktrace ? 't' : ''); + if (isset($this->erinvSyms[$key])) { return $this->erinvSyms[$key]; } + $sym = '@' . $this->mirHelperSym('__mir_erinv_' . (string)\count($this->erinvSyms)); + $this->erinvSyms[$key] = $sym; + + $oldSsa = $this->ssa; + $oldLocals = $this->locals; + $oldCf = $this->cf; + $oldArena = $this->arena; + $oldLast = $this->lastValue; + $oldLastType = $this->lastValueType; + $oldClassId = $this->classIdReg; + $oldRawRef = $this->rawRefCall; + $oldSpread = $this->spreadTail; + $oldPad = $this->lastPadArgs; + $oldVdResult = $this->vdResult; + $oldVdArm = $this->vdArmList; + $oldVdArgc = $this->vdSiteArgc; + $oldCellSinkOrd = $this->cellSinkOrd; + $oldCellSinkFn = $this->cellSinkFnOverride; + $oldCellProv = $this->cellProv; + $oldBtLine = $this->btLineOverride; + $oldBtSsa = $this->btLineOverrideSsa; + + $this->ssa = new SsaBuilder(); + $this->btLineOverrideSsa = $this->ssa; + $this->ssa->reset(); + $this->resetCellGuardFrame(); + $this->cellSinkFnOverride = \ltrim($sym, '@'); + $this->locals = new LocalSlots(); + $this->cf = new ControlFlow(); + $this->arena = new ArenaContext(); + $this->rawRefCall = false; + $this->spreadTail = null; + $this->lastPadArgs = ''; + $this->vdResult = ''; + $this->vdArmList = ''; + $this->vdSiteArgc = 0; + $this->btLineOverride = '%line'; + + $params = 'i64 %callee, i64 %line'; + $body = ''; + /** @var Node[] $args */ + $args = []; + for ($i = 0; $i < $argc; $i = $i + 1) { + $name = '__mc_erinv_a' . (string)$i; + if ($modes[$i] === 's') { + // The caller's own slot: a by-reference arm binds the caller's variable. + $params .= ', ptr %a' . (string)$i; + $this->locals->slots[$name] = '%a' . (string)$i; + $args[] = new \Compile\Mir\LoadLocal($name, Type::cell()); + continue; + } + $params .= ', i64 %a' . (string)$i; + $slot = $this->ssa->allocReg(); + $body .= ' ' . $slot . " = alloca i64\n store i64 %a" . (string)$i . ', ptr ' . $slot . "\n"; + $this->locals->slots[$name] = $slot; + $args[] = new \Compile\Mir\LoadLocal($name, Type::cell()); + } + $cslot = $this->ssa->allocReg(); + $body .= ' ' . $cslot . " = alloca i64\n store i64 %callee, ptr " . $cslot . "\n"; + $this->locals->slots['__mc_erinv_callee'] = $cslot; + $inv = new Invoke_(new \Compile\Mir\LoadLocal('__mc_erinv_callee', Type::cell()), $args, Type::cell()); + $inv->srcArgc = $srcArgc; + $body .= $this->emitErasedInvokeArms($inv, '%callee'); + $text = 'define hidden i64 ' . $sym . '(' . $params . ") {\nentry:\n" . $body + . ' ret i64 ' . $this->lastValue . "\n}\n\n"; + if ($this->irIsClosed($text)) { + $this->dynamicMethodHelpers['erinv|' . $key] = $text; + } else { + throw new \RuntimeException('EmitLlvm: erased-invoke helper ' . $sym . ' does not close'); + } + + $this->btLineOverride = $oldBtLine; + $this->btLineOverrideSsa = $oldBtSsa; + $this->ssa = $oldSsa; + $this->locals = $oldLocals; + $this->cf = $oldCf; + $this->arena = $oldArena; + $this->lastValue = $oldLast; + $this->lastValueType = $oldLastType; + $this->classIdReg = $oldClassId; + $this->rawRefCall = $oldRawRef; + $this->spreadTail = $oldSpread; + $this->lastPadArgs = $oldPad; + $this->vdResult = $oldVdResult; + $this->vdArmList = $oldVdArm; + $this->vdSiteArgc = $oldVdArgc; + $this->cellProv = $oldCellProv; + $this->cellSinkOrd = $oldCellSinkOrd; + $this->cellSinkFnOverride = $oldCellSinkFn; + return $sym; + } + /** * Evaluate every argument that RUNS CODE (a call, an invoke, a `new`, a * concat, an array literal) once, after the callee, into a slot, and answer diff --git a/tests/aot/cases/erased_invoke_shared_helper.php b/tests/aot/cases/erased_invoke_shared_helper.php new file mode 100644 index 000000000..b2d25b440 --- /dev/null +++ b/tests/aot/cases/erased_invoke_shared_helper.php @@ -0,0 +1,33 @@ + [$v]); +run(new Inv()); +run([new Tool(), 'm']); +run(['Tool', 's']); +echo twice(4), ' ', (static fn (): mixed => ('twice')(21))(), "\n"; + +function lines(mixed $f): void { + try { $f('a'); } catch (\RuntimeException $e) { echo $e->getMessage(), ' at ', $e->getTrace()[1]['line'] ?? '?', "\n"; } + try { $f('b'); } catch (\RuntimeException $e) { echo $e->getMessage(), ' at ', $e->getTrace()[1]['line'] ?? '?', "\n"; } +} +lines('boom'); + + +// A plain cell variable goes over as the caller's slot: a by-reference closure writes it. +function viaSlot(mixed $f, mixed $v): mixed { $w = $v; $f($w); return $w; } +var_export(viaSlot(function (&$x) { $x = 'set'; }, 1)); echo "\n"; +var_export(viaSlot(function ($x) { $x = 'lost'; }, 2)); echo "\n"; +var_export(viaSlot(function (&$x) { $x[] = 'pushed'; }, [0])); echo "\n"; diff --git a/tests/aot/expected/erased_invoke_shared_helper.out b/tests/aot/expected/erased_invoke_shared_helper.out new file mode 100644 index 000000000..3a4372390 --- /dev/null +++ b/tests/aot/expected/erased_invoke_shared_helper.out @@ -0,0 +1,19 @@ +array ( + 0 => NULL, +) array ( + 0 => true, +) array ( + 0 => 3, +) +'inv:NULL' 'inv:true' 'inv:3' +'m:NULL' 'm:true' 'm:3' +'s:NULL' 's:true' 's:3' +8 42 +boom 'a' at 26 +boom 'b' at 26 +'set' +2 +array ( + 0 => 0, + 1 => 'pushed', +) From e664e442fa1702e090faa924fbbce51f7f5b635a Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 15:24:13 +0200 Subject: [PATCH 38/50] Repros: an erased invoke reports trace line 0 (#598), and a literal bound by reference corrupts the closure result (#597) --- .../erased_invoke_literal_to_byref_corrupts.expected | 2 ++ .../wrong/erased_invoke_literal_to_byref_corrupts.php | 8 ++++++++ .../repro/wrong/erased_invoke_trace_line_zero.expected | 1 + tests/aot/repro/wrong/erased_invoke_trace_line_zero.php | 8 ++++++++ 4 files changed, 19 insertions(+) create mode 100644 tests/aot/repro/wrong/erased_invoke_literal_to_byref_corrupts.expected create mode 100644 tests/aot/repro/wrong/erased_invoke_literal_to_byref_corrupts.php create mode 100644 tests/aot/repro/wrong/erased_invoke_trace_line_zero.expected create mode 100644 tests/aot/repro/wrong/erased_invoke_trace_line_zero.php diff --git a/tests/aot/repro/wrong/erased_invoke_literal_to_byref_corrupts.expected b/tests/aot/repro/wrong/erased_invoke_literal_to_byref_corrupts.expected new file mode 100644 index 000000000..7aad548c9 --- /dev/null +++ b/tests/aot/repro/wrong/erased_invoke_literal_to_byref_corrupts.expected @@ -0,0 +1,2 @@ +ret 'closure' +Error: {closure}(): Argument #1 ($x) could not be passed by reference diff --git a/tests/aot/repro/wrong/erased_invoke_literal_to_byref_corrupts.php b/tests/aot/repro/wrong/erased_invoke_literal_to_byref_corrupts.php new file mode 100644 index 000000000..4712b4d16 --- /dev/null +++ b/tests/aot/repro/wrong/erased_invoke_literal_to_byref_corrupts.php @@ -0,0 +1,8 @@ +getMessage()), "\n"; } +} +g(function (&$x) { $x = 5; return 'closure'; }); diff --git a/tests/aot/repro/wrong/erased_invoke_trace_line_zero.expected b/tests/aot/repro/wrong/erased_invoke_trace_line_zero.expected new file mode 100644 index 000000000..7ed6ff82d --- /dev/null +++ b/tests/aot/repro/wrong/erased_invoke_trace_line_zero.expected @@ -0,0 +1 @@ +5 diff --git a/tests/aot/repro/wrong/erased_invoke_trace_line_zero.php b/tests/aot/repro/wrong/erased_invoke_trace_line_zero.php new file mode 100644 index 000000000..e43bad7bb --- /dev/null +++ b/tests/aot/repro/wrong/erased_invoke_trace_line_zero.php @@ -0,0 +1,8 @@ +getTrace()[0]['line'] ?? '?', "\n"; } +} +lines('boom'); From b0ae68430b0e50acd643e735d7abe4b656dc6780 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 16:27:58 +0200 Subject: [PATCH 39/50] Erased invoke helper takes a variable argument by reference, as a &$x parameter receives it: the caller passes its address, so ref-cell, global and by-ref-param variables share the helper too (symfony-demo: inline dispatch 349 -> 67 MB, staged IR -12%, clang CPU -11%) --- src/Compile/Mir/Passes/EmitLlvmCalls.php | 58 +++++++++++++++---- .../aot/cases/erased_invoke_shared_helper.php | 4 ++ .../expected/erased_invoke_shared_helper.out | 2 + 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index cf35a2f04..f8ccd6c5b 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -2017,7 +2017,9 @@ private function erasedInvokeInlineReason(Invoke_ $n): string $k = $a->type->kind; // `$this` cannot be reassigned, so no by-reference parameter can write it back. $isThis = $a->kind === Node::KIND_LOAD_LOCAL && $a->name === 'this' && $k === Type::KIND_OBJ; - if (!$isThis && $this->isByRefAddressable($a)) { return 'lvalue ' . $a->kind . ':' . $k; } + if (!$isThis && $this->isByRefAddressable($a)) { + return 'lvalue ' . $a->kind . ':' . $k . ($a->kind === Node::KIND_LOAD_LOCAL ? ' ' . $this->erinvLocalFlags($a->name) : ''); + } // An object box takes no reference ({@see boxToCell}); an array box does. if ($k !== Type::KIND_CELL && $k !== Type::KIND_UNKNOWN && $k !== Type::KIND_INT && $k !== Type::KIND_BOOL && $k !== Type::KIND_FLOAT && $k !== Type::KIND_NULL && $k !== Type::KIND_STRING @@ -2037,11 +2039,37 @@ private function erinvSlotArg(Node $a): bool if ($a->kind !== Node::KIND_LOAD_LOCAL || $a->type->kind !== Type::KIND_CELL) { return false; } $nm = $a->name; $l = $this->locals; - return isset($l->slots[$nm]) && !isset($l->refLocals[$nm]) && !isset($l->refParamTypes[$nm]) - && !isset($l->captureRefs[$nm]) && !isset($l->globalBacked[$nm]) && !isset($l->byRefCaptured[$nm]) - && !isset($l->unsetNames[$nm]) && !isset($l->aliasLocals[$nm]) && !isset($l->refCellTargets[$nm]) - && !isset($l->ownedBoxes[$nm]) && !isset($l->byRefParamWb[$nm]) && !isset($l->elemRefTargets[$nm]) - && !isset($l->elemRefBoxes[$nm]) && !isset($l->aliasBoxNames[$nm]); + if (!isset($l->slots[$nm]) && $this->byRefGlobalCellOf($nm) === '') { return false; } + // The address {@see byRefAddrOf} answers must be that of a CELL: a by-reference + // parameter of another type, or a box the slot does not say it holds, is not one. + $rt = $l->refParamTypes[$nm] ?? null; + if ($rt !== null && $rt->kind !== Type::KIND_CELL) { return false; } + if ((isset($l->refCellTargets[$nm]) || isset($l->ownedBoxes[$nm])) && !isset($l->refLocals[$nm])) { return false; } + return !isset($l->captureRefs[$nm]) && !isset($l->byRefCaptured[$nm]) && !isset($l->unsetNames[$nm]) && !isset($l->aliasLocals[$nm]) + && !isset($l->byRefParamWb[$nm]) && !isset($l->elemRefTargets[$nm]) && !isset($l->elemRefBoxes[$nm]) + && !isset($l->aliasBoxNames[$nm]); + } + + /** For MANTICORE_ERINV_TRACE: the bookkeeping that keeps a local off the slot path. */ + private function erinvLocalFlags(string $nm): string + { + $l = $this->locals; + $f = []; + if (!isset($l->slots[$nm])) { $f[] = 'noslot'; } + if (isset($l->refLocals[$nm])) { $f[] = 'refLocals'; } + if (isset($l->refParamTypes[$nm])) { $f[] = 'refParamTypes'; } + if (isset($l->captureRefs[$nm])) { $f[] = 'captureRefs'; } + if (isset($l->globalBacked[$nm])) { $f[] = 'globalBacked'; } + if (isset($l->byRefCaptured[$nm])) { $f[] = 'byRefCaptured'; } + if (isset($l->unsetNames[$nm])) { $f[] = 'unsetNames'; } + if (isset($l->aliasLocals[$nm])) { $f[] = 'aliasLocals'; } + if (isset($l->refCellTargets[$nm])) { $f[] = 'refCellTargets'; } + if (isset($l->ownedBoxes[$nm])) { $f[] = 'ownedBoxes'; } + if (isset($l->byRefParamWb[$nm])) { $f[] = 'byRefParamWb'; } + if (isset($l->elemRefTargets[$nm])) { $f[] = 'elemRefTargets'; } + if (isset($l->elemRefBoxes[$nm])) { $f[] = 'elemRefBoxes'; } + if (isset($l->aliasBoxNames[$nm])) { $f[] = 'aliasBoxNames'; } + return \implode(',', $f); } /** The call of the shared helper for `$n`; lastValue as {@see emitErasedInvokeArms} leaves it. */ @@ -2053,8 +2081,9 @@ private function emitErasedInvokeShared(Invoke_ $n, string $raw): string $modes = ''; foreach ($n->args as $a) { if ($this->erinvSlotArg($a)) { - $argRegs[] = 'ptr ' . $this->locals->slots[$a->name]; - $modes .= 's'; + $out .= $this->byRefAddrOf($a); + $argRegs[] = 'i64 ' . $this->lastValue; + $modes .= 'r'; continue; } $out .= $this->emitNode($a); @@ -2179,10 +2208,15 @@ private function erasedInvokeHelper(string $modes, int $srcArgc): string $args = []; for ($i = 0; $i < $argc; $i = $i + 1) { $name = '__mc_erinv_a' . (string)$i; - if ($modes[$i] === 's') { - // The caller's own slot: a by-reference arm binds the caller's variable. - $params .= ', ptr %a' . (string)$i; - $this->locals->slots[$name] = '%a' . (string)$i; + if ($modes[$i] === 'r') { + // The caller's variable by reference, as a `&$x` parameter receives it: + // a by-value arm reads through it, a by-reference one binds it. + $params .= ', i64 %a' . (string)$i; + $slot = $this->ssa->allocReg(); + $body .= ' ' . $slot . " = alloca i64\n store i64 %a" . (string)$i . ', ptr ' . $slot . "\n"; + $this->locals->slots[$name] = $slot; + $this->locals->refLocals[$name] = true; + $this->locals->refParamTypes[$name] = Type::cell(); $args[] = new \Compile\Mir\LoadLocal($name, Type::cell()); continue; } diff --git a/tests/aot/cases/erased_invoke_shared_helper.php b/tests/aot/cases/erased_invoke_shared_helper.php index b2d25b440..960f85ea0 100644 --- a/tests/aot/cases/erased_invoke_shared_helper.php +++ b/tests/aot/cases/erased_invoke_shared_helper.php @@ -31,3 +31,7 @@ function viaSlot(mixed $f, mixed $v): mixed { $w = $v; $f($w); return $w; } var_export(viaSlot(function (&$x) { $x = 'set'; }, 1)); echo "\n"; var_export(viaSlot(function ($x) { $x = 'lost'; }, 2)); echo "\n"; var_export(viaSlot(function (&$x) { $x[] = 'pushed'; }, [0])); echo "\n"; +// The caller's variable is itself a by-reference parameter: the write reaches the outer one. +function viaRefParam(mixed &$v, mixed $f): void { $f($v); } +$outer = json_decode('"old"'); viaRefParam($outer, function (&$x) { $x = 'new'; }); echo $outer, "\n"; +$outer2 = json_decode('"kept"'); viaRefParam($outer2, function ($x) { $x = 'lost'; }); echo $outer2, "\n"; diff --git a/tests/aot/expected/erased_invoke_shared_helper.out b/tests/aot/expected/erased_invoke_shared_helper.out index 3a4372390..81d0c1194 100644 --- a/tests/aot/expected/erased_invoke_shared_helper.out +++ b/tests/aot/expected/erased_invoke_shared_helper.out @@ -17,3 +17,5 @@ array ( 0 => 0, 1 => 'pushed', ) +new +kept From 964f747fa9dd6e121de27f749ac467d615964064 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 17:55:54 +0200 Subject: [PATCH 40/50] SplitModule: the stable placement packs hash buckets into parts by weight instead of hashing straight to a part; an edit still only moves a bucket at a cut (symfony-app, one-function edit: 27/65 parts hit the cache, 15/65 before; symfony-demo largest part 127 -> 67 MB) --- src/Compile/Mir/SplitModule.php | 74 ++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/src/Compile/Mir/SplitModule.php b/src/Compile/Mir/SplitModule.php index d64adf311..620e21e02 100644 --- a/src/Compile/Mir/SplitModule.php +++ b/src/Compile/Mir/SplitModule.php @@ -421,12 +421,7 @@ private function assignParts(array $shared, array $defOrder, array $defSize, $this->internalComponents($defOrder, $defSize, $defRefs, $internal, $compOf, $compSize); if ($this->stable) { - /** @var array */ - $assign = []; - foreach ($shared as $s) { - $assign[$s] = (int)(\crc32($s) % $parts); - } - return $assign; + return $this->assignStable($shared, $defSize, $defRefs, $compOf, $compSize, $parts); } $bySize = []; foreach ($shared as $s) { $bySize[$s] = $defSize[$s] ?? 0; } @@ -466,6 +461,73 @@ private function assignParts(array $shared, array $defOrder, array $defSize, return $assign; } + /** Hash buckets the stable placement packs into parts. */ + private const STABLE_BUCKETS = 4096; + + /** + * Placement that an edit barely moves, for the object cache, and that still + * balances: a symbol's bucket is its name's hash, and the buckets, in index + * order, are cut into runs of about equal weight. A body that grows shifts + * the cuts by its growth, so only a bucket at a cut can change parts. + * + * Hashing straight to a part (`crc32 % parts`) ignored sizes and the internal + * bodies a definition drags along: on symfony-demo two of 65 parts came out + * at 99 and 127 MB against 34 on average, and clang's peak follows them. + * + * A bucket weighs its definitions plus each internal component they name, + * once per bucket: the component is copied into every part that names it. + * + * @param string[] $shared + * @param array $defSize + * @param array> $defRefs + * @param array $compOf + * @param array $compSize + * @return array symbol => part index + */ + private function assignStable(array $shared, array $defSize, array $defRefs, array $compOf, + array $compSize, int $parts): array + { + $nb = self::STABLE_BUCKETS; + /** @var int[] $weight */ + $weight = []; + for ($b = 0; $b < $nb; $b = $b + 1) { $weight[] = 0; } + /** @var array $bucketOf */ + $bucketOf = []; + /** @var array $seen bucket|component already weighed */ + $seen = []; + foreach ($shared as $s) { + $b = (int)(\crc32($s) % $nb); + $bucketOf[$s] = $b; + $w = $defSize[$s] ?? 0; + foreach ($defRefs[$s] ?? [] as $r => $_) { + $c = $compOf[$r] ?? null; + if ($c === null) { continue; } + $k = (string)$b . '|' . $c; + if (isset($seen[$k])) { continue; } + $seen[$k] = true; + $w = $w + ($compSize[$c] ?? 0); + } + $weight[$b] = $weight[$b] + $w; + } + $total = 0; + foreach ($weight as $w) { $total = $total + $w; } + /** @var int[] $partOf */ + $partOf = []; + $p = 0; + $cum = 0; + for ($b = 0; $b < $nb; $b = $b + 1) { + // Cut where the run passes its share; the bucket's midpoint decides which side it lands on. + // At most one cut per bucket, so a heavy bucket leaves no part empty. + if ($p < $parts - 1 && 2 * $cum + $weight[$b] > 2 * \intdiv($total * ($p + 1), $parts)) { $p = $p + 1; } + $partOf[] = $p; + $cum = $cum + $weight[$b]; + } + /** @var array */ + $assign = []; + foreach ($shared as $s) { $assign[$s] = $partOf[$bucketOf[$s]]; } + return $assign; + } + /** * Group the file-local definitions into components that cannot be split. * From 5464b981e1b6a864aac805a749968bcf0524a545 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 17:55:55 +0200 Subject: [PATCH 41/50] Erased property readers and writers are linkonce_odr under the module token instead of internal: the property tables name every one, and the split part holding them copied all 8 800 (51 MB on symfony-demo); clang peak per part 3.8 -> 2.4 GB --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index af953fa0f..5fa94791e 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -1658,7 +1658,10 @@ private function bagClassNames(): array private function cellPropertyReadHelper(string $prop, array $fixed, array $enumArms, array $magic, bool $hasBag, bool $fresh = false): string { $key = '__mc_prop_read_' . $this->mangle($prop) . ($fresh ? '__fresh' : ''); - $sym = '@manticore_' . $key; + // linkonce_odr under the module token, not internal: the module's property tables name + // every reader, and a split part copies an internal body with each body naming it, so + // the part holding the tables took all of them (51 MB on symfony-demo). + $sym = '@' . $this->mirHelperSym('manticore_' . $key); if (isset($this->propertyReadHelpers[$key])) { return $sym; } $oldSsa = $this->ssa; @@ -1674,7 +1677,7 @@ private function cellPropertyReadHelper(string $prop, array $fixed, array $enumA $this->cellSinkFnOverride = \ltrim($sym, '@'); $obj = '%obj'; $res = $this->ssa->allocReg(); - $out = 'define internal i64 ' . $sym . "(ptr %obj) {\nentry:\n"; + $out = 'define linkonce_odr i64 ' . $sym . "(ptr %obj) {\nentry:\n"; $out .= $this->emitLoadClassId($obj); $cid = $this->classIdReg; $out .= ' ' . $res . " = alloca i64\n"; @@ -4478,7 +4481,7 @@ private function cellPropertyWriteHelper(string $prop, string $within = '', bool $key = '__mc_prop_write_' . $this->mangle($prop) . ($roGuard ? '__ro' : '') . ($within === '' ? '' : '__in_' . $this->mangle($within)) . ($dropOld ? '__drop' : '') . ($dropBoxed ? 'b' : '') . ($hooks ? 'h' : ''); - $sym = '@manticore_' . $key; + $sym = '@' . $this->mirHelperSym('manticore_' . $key); if (isset($this->propertyReadHelpers[$key])) { return $sym; } $fixed = $this->fixedPropertyHolders($prop); @@ -4499,7 +4502,7 @@ private function cellPropertyWriteHelper(string $prop, string $within = '', bool $this->cellSinkFnOverride = \ltrim($sym, '@'); $obj = '%obj'; $cellVal = '%val'; - $out = 'define internal void ' . $sym . "(ptr %obj, i64 %val) {\nentry:\n"; + $out = 'define linkonce_odr void ' . $sym . "(ptr %obj, i64 %val) {\nentry:\n"; $out .= $this->emitLoadClassId($obj); $cid = $this->classIdReg; $end = $this->ssa->allocLabel('pw.end'); From 5c589b3d02882952dfaf5f5029f6dbd129c4e26b Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 18:47:42 +0200 Subject: [PATCH 42/50] Emit: literal tables and shared helpers are named by their content or cache key instead of a module-wide running number, and a virtual-dispatch thunk numbers its own registers: one edit renumbered every later table, helper and caller, so most split parts missed the object cache --- src/Compile/Mir/Passes/EmitLlvm.php | 28 +++++++++++++ src/Compile/Mir/Passes/EmitLlvmArrays.php | 46 +++++++++++++++++----- src/Compile/Mir/Passes/EmitLlvmCalls.php | 6 +-- src/Compile/Mir/Passes/EmitLlvmObjects.php | 18 +++++++-- src/Compile/Mir/SsaBuilder.php | 10 +++++ 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 20edf076b..f7855bdad 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -963,6 +963,9 @@ public function emit(Module $module): string $this->metaStrTaken = []; $this->metaStrDefs = ''; $this->litTableCount = 0; + $this->litTableSyms = []; + $this->litTableTaken = []; + $this->keyedHelperTaken = []; $this->objTemplates = []; $this->litTablesFlushed = false; $this->btBaseLine = []; @@ -2442,6 +2445,27 @@ protected function mirHelperSym(string $base): string return $base . '_m' . $this->modToken; } + /** @var array helper names {@see keyedHelperName} handed out in this module */ + private array $keyedHelperTaken = []; + + /** + * A shared helper's name from its cache key. A running number renamed every + * later helper whenever one was added, so a one-function edit changed every + * caller of those helpers and most split parts missed the object cache. + */ + private function keyedHelperName(string $prefix, string $key): string + { + $base = $prefix . \str_replace('-', 'n', (string)$this->fnvHash64($key)); + $name = $base; + $n = 0; + while (isset($this->keyedHelperTaken[$name])) { + $n = $n + 1; + $name = $base . '_' . (string)$n; + } + $this->keyedHelperTaken[$name] = true; + return $name; + } + private string $vdExtraBodies = ''; /** The module's shared erased-receiver clone ({@see EmitLlvmObjects::emitCloneErased}), once emitted. */ @@ -2497,6 +2521,10 @@ protected function mirHelperSym(string $base): string private array $classlessCandidatesMemo = []; private int $litTableCount = 0; + /** @var array prefix + initializer => its literal table symbol */ + private array $litTableSyms = []; + /** @var array */ + private array $litTableTaken = []; /** @var array class name => its instance template symbol, '' = none ({@see EmitLlvmObjects::objInitTemplate}) */ private array $objTemplates = []; /** `litTableBodies` is already in the output: a template minted now would never be defined. */ diff --git a/src/Compile/Mir/Passes/EmitLlvmArrays.php b/src/Compile/Mir/Passes/EmitLlvmArrays.php index e178428ab..236dc5541 100644 --- a/src/Compile/Mir/Passes/EmitLlvmArrays.php +++ b/src/Compile/Mir/Passes/EmitLlvmArrays.php @@ -1795,11 +1795,36 @@ private function litConstTable(ArrayLit $al, string $shape): string { $words = $this->litConstWords($al, $shape); if ($words === null) { return ''; } - $sym = '@.lit.' . (string)$this->litTableCount; + $init = '[' . (string)\count($words) . ' x i64] [' . \implode(', ', $words) . ']'; + $sym = $this->litTableSym('@.lit.', $init); + if ($sym[1]) { + $this->litTableBodies .= $sym[0] . ' = private unnamed_addr constant ' . $init . ", align 8\n"; + } + return $sym[0]; + } + + /** + * The symbol of a literal table, named by its content and shared by every + * literal with the same one; [symbol, true when it has to be defined now]. + * A running number renamed every later table when one literal was added, and + * each split part naming one of them missed the object cache. + * @return array{0: string, 1: bool} + */ + private function litTableSym(string $prefix, string $init): array + { + $k = $prefix . $init; + if (isset($this->litTableSyms[$k])) { return [$this->litTableSyms[$k], false]; } + $base = $prefix . (string)$this->fnvHash64($init); + $sym = $base; + $n = 0; + while (isset($this->litTableTaken[$sym])) { + $n = $n + 1; + $sym = $base . '.' . (string)$n; + } + $this->litTableTaken[$sym] = true; + $this->litTableSyms[$k] = $sym; $this->litTableCount = $this->litTableCount + 1; - $this->litTableBodies .= $sym . ' = private unnamed_addr constant [' . (string)\count($words) - . ' x i64] [' . \implode(', ', $words) . "], align 8\n"; - return $sym; + return [$sym, true]; } /** @@ -1873,11 +1898,14 @@ private function immortalLitPtr(ArrayLit $al, string $shape): string $all = []; foreach ($hdr as $h) { $all[] = 'i64 ' . $h; } foreach ($words as $w) { $all[] = $w; } - $sym = '@.ilit.' . (string)$this->litTableCount; - $this->litTableCount = $this->litTableCount + 1; - $this->litTableBodies .= $sym . ' = internal global [' . (string)\count($all) - . ' x i64] [' . \implode(', ', $all) . "], align 8\n"; - return 'getelementptr inbounds (i8, ptr ' . $sym . ', i64 8)'; + // Shared by content: the rc never reaches 1, so a writer copies first, and the hash + // index a lookup builds lazily is the same for the same entries. + $init = '[' . (string)\count($all) . ' x i64] [' . \implode(', ', $all) . ']'; + $sym = $this->litTableSym('@.ilit.', $init); + if ($sym[1]) { + $this->litTableBodies .= $sym[0] . ' = internal global ' . $init . ", align 8\n"; + } + return 'getelementptr inbounds (i8, ptr ' . $sym[0] . ', i64 8)'; } /** diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index f8ccd6c5b..2eca42c7f 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -1188,7 +1188,7 @@ private function dynfSpreadThunk(string $fname, int $numFixed, Type $spreadElem, } $exported = $this->dynfExporting($fname, $bit); $sym = $exported ? $this->dynfExportSym($fname, $bit) - : $this->mirHelperSym('__mir_dynfs_' . (string)\count($this->dynfThunks)); + : $this->mirHelperSym($this->keyedHelperName('__mir_dynfs_', $key)); $this->dynfThunks[$key] = $sym; $oldSsa = $this->ssa; @@ -1629,7 +1629,7 @@ private function dynfThunk(string $fname, int $argc, Type $rt): string } $exported = $this->dynfExporting($fname, $bit); $sym = $exported ? $this->dynfExportSym($fname, $bit) - : $this->mirHelperSym('__mir_dynft_' . (string)\count($this->dynfThunks)); + : $this->mirHelperSym($this->keyedHelperName('__mir_dynft_', $key)); // Registered BEFORE the body is built, for the reason dynmChainFn gives: // a half-built registry is the one state that emits two bodies for one // symbol. Removed again below if the body turns out not to be closed. @@ -2164,7 +2164,7 @@ private function erasedInvokeHelper(string $modes, int $srcArgc): string $scope = $this->erinvScopeMatters() ? ($this->accessScope() ?? '?') : ''; $key = $modes . '|' . (string)$srcArgc . '|' . $scope . '|' . ($this->rt->needsBacktrace ? 't' : ''); if (isset($this->erinvSyms[$key])) { return $this->erinvSyms[$key]; } - $sym = '@' . $this->mirHelperSym('__mir_erinv_' . (string)\count($this->erinvSyms)); + $sym = '@' . $this->mirHelperSym($this->keyedHelperName('__mir_erinv_', $key)); $this->erinvSyms[$key] = $sym; $oldSsa = $this->ssa; diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 5fa94791e..926acc8b0 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -5271,7 +5271,7 @@ private function dynamicMethodSpreadFallbackHelper(\Compile\Mir\DynProp_ $dp, ar $key = 'inl|' . $rk . ':' . $recvKey . '|' . (string)$fixedN . '|' . $resT->kind; foreach ($methods as $m => $mt) { $key .= '|' . (string)$m . ':' . $mt->kind . ':' . ($mt->class ?? ''); } if (isset($this->dynInlSyms[$key])) { return $this->dynInlSyms[$key]; } - $sym = '@' . $this->mirHelperSym('__mir_dyninl_' . (string)\count($this->dynInlSyms)); + $sym = '@' . $this->mirHelperSym($this->keyedHelperName('__mir_dyninl_', $key)); $this->dynInlSyms[$key] = $sym; $oldSsa = $this->ssa; @@ -5401,7 +5401,7 @@ private function dynamicMethodSpreadArmHelper(Type $rt, int $fixedN, string $m, $key = 'inlarm|' . $rt->kind . ':' . $recvKey . '|' . (string)$fixedN . '|' . $retT->kind . ':' . ($retT->class ?? '') . '|' . $m . '|' . (string)$srcArgc . ($packT === null ? '' : '|' . $packT->exactString()); if (isset($this->dynArmSyms[$key])) { return $this->dynArmSyms[$key]; } - $sym = '@' . $this->mirHelperSym('__mir_dynarm_' . (string)\count($this->dynArmSyms)); + $sym = '@' . $this->mirHelperSym($this->keyedHelperName('__mir_dynarm_', $key)); $this->dynArmSyms[$key] = $sym; $oldSsa = $this->ssa; @@ -6016,7 +6016,7 @@ private function dynmChainFn(array $clean, array $argTypes, int $line, int $argc $key .= '|a' . (string)$at->kind . ':' . ($at->class ?? ''); } if (isset($this->dynmSyms[$key])) { return $this->dynmSyms[$key]; } - $sym = $this->mirHelperSym('__mir_dynm_' . (string)\count($this->dynmSyms)); + $sym = $this->mirHelperSym($this->keyedHelperName('__mir_dynm_', $key)); // Registered BEFORE the body is built, for the reason // {@see emitVirtualDispatch} gives: a half-built registry is the one // state that would emit two bodies for one symbol. @@ -8805,7 +8805,7 @@ private function emitVirtualDispatch(string $thisArg, string $argList, array $ca $boxCell, $erasedSyms, $argOutTypes); $sym = $this->vdSyms[$key] ?? ''; if ($sym === '') { - $sym = $this->mirHelperSym('__mir_vdisp_' . (string)\count($this->vdSyms)); + $sym = $this->mirHelperSym($this->keyedHelperName('__mir_vdisp_', $key)); // Registered BEFORE the body is built: the body cannot reach this // shape again (it dispatches, it does not re-dispatch), but a // half-built registry is the one state that would emit two bodies @@ -8818,8 +8818,18 @@ private function emitVirtualDispatch(string $thisArg, string $argList, array $ca $params .= 'i64 %a' . (string)$i; $thunkArgs .= 'i64 %a' . (string)$i; } + // Its own counters, not the requesting frame's: built on those, the body + // took that frame's numbering and the frame lost registers only when it + // happened to ask first, so an edit anywhere renumbered later functions. + $oldSsa = $this->ssa; + $oldCid = $this->classIdReg; + $this->ssa = new SsaBuilder(); + $this->ssa->reset(); + $this->ssa->startAt(1000000000); $body = $this->emitVirtualDispatchInline('%a0', $thunkArgs, $cands, $targets, $fallback, $method, $boxCell, $erasedSyms, $argOutTypes); + $this->ssa = $oldSsa; + $this->classIdReg = $oldCid; // ⚠ A body is only a function if it is CLOSED. Some arms reach // helpers that hand back a register the SITE created — a spread pack // is one — and moving that block into a thunk leaves the reference diff --git a/src/Compile/Mir/SsaBuilder.php b/src/Compile/Mir/SsaBuilder.php index 69bf80d3d..26c858505 100644 --- a/src/Compile/Mir/SsaBuilder.php +++ b/src/Compile/Mir/SsaBuilder.php @@ -24,6 +24,16 @@ public function reset(): void $this->userLabels = []; } + /** + * Number this body's registers from `$id`: a helper body built while a frame + * emits takes a range that frame never reaches, so a register the frame + * created and the body still names cannot pass for one the body defines. + */ + public function startAt(int $id): void + { + $this->nextId = $id; + } + /** Fresh SSA register (`%rN`). */ public function allocReg(): string { From 680b0ebbb53abdb321db10084a0ce131cfdcb46d Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 19:36:10 +0200 Subject: [PATCH 43/50] Emit: class views number their own registers, the erased-invoke helper takes the call site file as well as its line, and other shared helpers no longer push a trace line relative to whichever frame asked first; MANTICORE_EMIT_REVERSE=1 emits functions in reverse to find bodies that depend on emission order --- src/Compile/Mir/Passes/EmitLlvm.php | 15 +++++++++++++-- src/Compile/Mir/Passes/EmitLlvmCalls.php | 6 ++++-- src/Compile/Mir/Passes/EmitLlvmRuntime.php | 12 ++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 3cdab96ea..6f47f1235 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -1059,6 +1059,10 @@ public function emit(Module $module): string // already copied the signature facts into $this->sigs, and no code below // this loop reads $module->functions again. $functionKeys = \array_keys($module->functions); + // MANTICORE_EMIT_REVERSE=1: emit in reverse. A body that comes out different + // depends on what was emitted before it, which is what makes one edit change + // functions far from it and miss the object cache; diff the two staged modules. + if (\getenv('MANTICORE_EMIT_REVERSE') === '1') { $functionKeys = \array_reverse($functionKeys); } $functionTotal = \count($functionKeys); foreach ($functionKeys as $functionKey) { $fn = $module->functions[$functionKey]; @@ -4620,8 +4624,15 @@ private function btPush(string $display, int $line): string { if (!$this->rt->needsBacktrace) { return ''; } if ($this->btLineOverride !== '' && $this->ssa === $this->btLineOverrideSsa) { - // Inside a shared helper: the call site's absolute line is its argument. - return $this->btPushInline($this->strLitId($this->pool->intern($display)), $this->btLineOverride, ''); + // Inside a shared helper: the call site's absolute line and file are its arguments. + return $this->btPushInline($this->strLitId($this->pool->intern($display)), $this->btLineOverride, '', '%file'); + } + if ($this->cellSinkFnOverride !== null) { + // Any other shared helper has no call site of its own (its nodes carry + // line 0); relative to the frame that first asked for it, the push named + // that frame's base line and changed with whoever asked first. + return $this->btPushInline($this->strLitId($this->pool->intern($display)), '0', '', + $this->strLitId($this->pool->intern($this->frameFile()))); } $fn = $this->frame->name; if ($fn === '') { diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index 140a0dfcd..5fd41ae0b 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -2092,6 +2092,7 @@ private function emitErasedInvokeShared(Invoke_ $n, string $raw): string $modes .= 'v'; } $line = 'i64 0'; + $file = 'ptr null'; if ($this->rt->needsBacktrace) { $fn = $this->frame->name; if ($fn === '') { @@ -2108,9 +2109,10 @@ private function emitErasedInvokeShared(Invoke_ $n, string $raw): string $line = 'i64 ' . $abs; } } + if ($this->rt->needsBacktrace) { $file = 'ptr ' . $this->strLitId($this->pool->intern($this->frameFile())); } $sym = $this->erasedInvokeHelper($modes, $n->srcArgc); $r = $this->ssa->allocReg(); - $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $raw . ', ' . $line + $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $raw . ', ' . $line . ', ' . $file . ($argRegs === [] ? '' : ', ' . \implode(', ', $argRegs)) . ")\n"; $this->lastValue = $r; $this->lastValueType = 'i64'; @@ -2202,7 +2204,7 @@ private function erasedInvokeHelper(string $modes, int $srcArgc): string $this->vdSiteArgc = 0; $this->btLineOverride = '%line'; - $params = 'i64 %callee, i64 %line'; + $params = 'i64 %callee, i64 %line, ptr %file'; $body = ''; /** @var Node[] $args */ $args = []; diff --git a/src/Compile/Mir/Passes/EmitLlvmRuntime.php b/src/Compile/Mir/Passes/EmitLlvmRuntime.php index 91f632ae0..01355a29f 100644 --- a/src/Compile/Mir/Passes/EmitLlvmRuntime.php +++ b/src/Compile/Mir/Passes/EmitLlvmRuntime.php @@ -2702,7 +2702,13 @@ private function dropRuntimeBody(): string foreach ($cls->propertyNames as $pn) { if (($cls->propertyTypes[$pn] ?? null) !== null) { $hasProps = true; break; } } + // The props, visit and compare views are functions of their own: each numbers + // its registers afresh, or every one took the counters the module's last + // function left behind, and one edit anywhere rewrote all of them. + $viewSsa = $this->ssa; if (($hasProps || $cls->usesBag()) && !$cls->isStruct) { + $this->ssa = new \Compile\Mir\SsaBuilder(); + $this->ssa->reset(); $pIr = $this->emitDeclaredPropsArray('%o', $cls->name, true); $pRes = $this->lastValue; // …then the DYNAMIC bag, in php's order: declared first, then @@ -2735,9 +2741,12 @@ private function dropRuntimeBody(): string // DESCRIPTOR_VISIT_FN_OFFSET}): what the json encoder reads, so // an object costs no map. $vsym = \Compile\Mir\RuntimeLibrary::visitFnSymbol((int)$id); + $this->ssa = new \Compile\Mir\SsaBuilder(); + $this->ssa->reset(); $defs .= 'define void ' . $vsym . "(ptr %o, ptr %ctx, ptr %cb) {\nentry:\n" . $this->emitDeclaredPropsVisit($cls->name) . " ret void\n}\n"; $visitFld = 'ptr ' . $vsym; + $this->ssa = $viewSsa; } // The COMPARE view and group ({@see \Compile\MemoryAbi:: // DESCRIPTOR_CMP_VIEW_FN_OFFSET}). Same derivation rule as the props @@ -2756,6 +2765,8 @@ private function dropRuntimeBody(): string } elseif (($hasProps || $cls->usesBag()) && !$cls->isStruct) { $keyed = $this->cmpKeyProps($cls); if ($keyed !== []) { $cmpGroup = \Compile\MemoryAbi::CMP_GROUP_KEYED; } + $this->ssa = new \Compile\Mir\SsaBuilder(); + $this->ssa->reset(); $cIr = $this->emitDeclaredPropsArray('%o', $cls->name, false, true); $cRes = $this->lastValue; if ($keyed === [] && $cls->usesBag()) { @@ -2775,6 +2786,7 @@ private function dropRuntimeBody(): string . ' %cvi = ptrtoint ptr ' . $cRes . " to i64\n" . " ret i64 %cvi\n}\n"; $cmpViewFld = 'ptr ' . $csym; + $this->ssa = $viewSsa; } // Reflection metadata — only for classes reflection can actually // reach ({@see ReflectAnalysis}). A class outside the set keeps From 4c04a52a6f4a2ace77dcc8bb8ddcc2faf365a333 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 21:49:35 +0200 Subject: [PATCH 44/50] Emit: a one-function edit now recompiles one split part (symfony-app: 64/65 parts from the object cache, 15 before). Shared helpers take their call site line and file as arguments (dispatch arms, the member-call Error, which also stores it in the Error), resolve caches and row/scope tables are named by their key, a virtual-dispatch key spells types instead of interning ids, a key always maps to one name, and emitMixedMagicCall keeps the nodes it wrapped alive: their spl_object_id was handed to later nodes, which then skipped their own __call check --- src/Compile/Mir/Passes/EmitLlvm.php | 17 ++++- src/Compile/Mir/Passes/EmitLlvmBuiltins.php | 13 +++- src/Compile/Mir/Passes/EmitLlvmCalls.php | 76 ++++++++++++++------- src/Compile/Mir/Passes/EmitLlvmObjects.php | 44 ++++++++---- 4 files changed, 108 insertions(+), 42 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 6f47f1235..c67306883 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -973,6 +973,7 @@ public function emit(Module $module): string $this->litTableSyms = []; $this->litTableTaken = []; $this->keyedHelperTaken = []; + $this->keyedHelperNames = []; $this->objTemplates = []; $this->litTablesFlushed = false; $this->btBaseLine = []; @@ -984,6 +985,7 @@ public function emit(Module $module): string $this->dynfIcmpEmitted = false; $this->dynfZeroEmitted = false; $this->dynfCacheSeq = 0; + $this->dynfCacheSeqByFn = []; $this->dynfIdTab = null; $this->needsInclResolveFn = false; $this->propOwnElem = []; @@ -2476,6 +2478,10 @@ protected function mirHelperSym(string $base): string */ private function keyedHelperName(string $prefix, string $key): string { + // The same key always gets the same name: a shape whose body did not close is + // asked for again later, and a fresh suffix then depended on emission order. + $full = $prefix . '|' . $key; + if (isset($this->keyedHelperNames[$full])) { return $this->keyedHelperNames[$full]; } $base = $prefix . \str_replace('-', 'n', (string)$this->fnvHash64($key)); $name = $base; $n = 0; @@ -2484,9 +2490,13 @@ private function keyedHelperName(string $prefix, string $key): string $name = $base . '_' . (string)$n; } $this->keyedHelperTaken[$name] = true; + $this->keyedHelperNames[$full] = $name; return $name; } + /** @var array prefix|key => the name {@see keyedHelperName} gave it */ + private array $keyedHelperNames = []; + private string $vdExtraBodies = ''; /** The module's shared erased-receiver clone ({@see EmitLlvmObjects::emitCloneErased}), once emitted. */ @@ -2557,6 +2567,8 @@ private function keyedHelperName(string $prefix, string $key): string private bool $dynfIcmpEmitted = false; private bool $dynfZeroEmitted = false; private int $dynfCacheSeq = 0; + /** @var array function => resolve-cache sites emitted in it so far */ + private array $dynfCacheSeqByFn = []; /** @var array{0:string,1:int,2:array}|null */ private ?array $dynfIdTab = null; /** Signature-only imports (their bodies live in another object). @@ -4625,14 +4637,13 @@ private function btPush(string $display, int $line): string if (!$this->rt->needsBacktrace) { return ''; } if ($this->btLineOverride !== '' && $this->ssa === $this->btLineOverrideSsa) { // Inside a shared helper: the call site's absolute line and file are its arguments. - return $this->btPushInline($this->strLitId($this->pool->intern($display)), $this->btLineOverride, '', '%file'); + return $this->btPushInline($this->strLitId($this->pool->intern($display)), $this->btLineOverride, '', $this->btFileOverride); } if ($this->cellSinkFnOverride !== null) { // Any other shared helper has no call site of its own (its nodes carry // line 0); relative to the frame that first asked for it, the push named // that frame's base line and changed with whoever asked first. - return $this->btPushInline($this->strLitId($this->pool->intern($display)), '0', '', - $this->strLitId($this->pool->intern($this->frameFile()))); + return $this->btPushInline($this->strLitId($this->pool->intern($display)), '0', '', 'null'); } $fn = $this->frame->name; if ($fn === '') { diff --git a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php index e704a7e83..4752a2739 100644 --- a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php +++ b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php @@ -8613,6 +8613,9 @@ private function biMemberCallError(array $args): string $oldCellProv = $this->cellProv; $oldCellSinkOrd = $this->cellSinkOrd; $oldCellSinkFn = $this->cellSinkFnOverride; + $oldBtLine = $this->btLineOverride; + $oldBtFile = $this->btFileOverride; + $oldBtSsa = $this->btLineOverrideSsa; $this->ssa = new \Compile\Mir\SsaBuilder(); $this->ssa->reset(); $this->resetCellGuardFrame(); @@ -8622,9 +8625,11 @@ private function biMemberCallError(array $args): string $this->arena = new \Compile\Mir\ArenaContext(); $this->spreadTail = null; $this->lastPadArgs = ''; + // The Error is the call site's, not that of the first frame asking for this helper. + $this->useSiteTraceParams(); $slot = $this->ssa->allocReg(); $this->locals->slots['__mc_mce'] = $slot; - $body = 'define hidden i64 ' . $sym . "(i64 %v) noinline cold {\nentry:\n"; + $body = 'define hidden i64 ' . $sym . "(i64 %v, i64 %btline, ptr %btfile) noinline cold {\nentry:\n"; $body .= ' ' . $slot . " = alloca i64\n store i64 %v, ptr " . $slot . "\n"; $cell = Type::cell(); $str = fn (string $x): Node => new \Compile\Mir\StringConst($x, Type::string_()); @@ -8655,9 +8660,13 @@ private function biMemberCallError(array $args): string $this->cellProv = $oldCellProv; $this->cellSinkOrd = $oldCellSinkOrd; $this->cellSinkFnOverride = $oldCellSinkFn; + $this->btLineOverride = $oldBtLine; + $this->btFileOverride = $oldBtFile; + $this->btLineOverrideSsa = $oldBtSsa; } + $site = $this->siteTraceArgs(0, $out); $r = $this->ssa->allocReg(); - $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $v . ")\n"; + $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $v . ', ' . $site . ")\n"; $this->lastValue = $r; $this->lastValueType = 'i64'; return $out; diff --git a/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index 5fd41ae0b..f21ac5897 100644 --- a/src/Compile/Mir/Passes/EmitLlvmCalls.php +++ b/src/Compile/Mir/Passes/EmitLlvmCalls.php @@ -1505,7 +1505,12 @@ private function dynfZeroSym(): string private function dynfCachedRow(string $keyP, string $tsym, int $count, ?string &$rowReg): string { $this->dynfExtraBodies .= $this->dynfBsearchFn() . $this->dynfIcmpFn(); - $cg = '@.dynf.rc.' . $this->mirHelperSym((string)$this->dynfCacheSeq); + // Named by the function holding the site and the site's ordinal in it: a + // module-wide sequence renamed every later cache whenever one site was added. + $owner = $this->cellSinkFnOverride ?? $this->frame->name; + $nth = $this->dynfCacheSeqByFn[$owner] ?? 0; + $this->dynfCacheSeqByFn[$owner] = $nth + 1; + $cg = '@.dynf.rc.' . $this->mirHelperSym($this->keyedHelperName('', $owner . '#' . (string)$nth)); $this->dynfCacheSeq = $this->dynfCacheSeq + 1; $this->dynfExtraBodies .= $cg . " = internal global ptr null\n\n"; $crow = $this->ssa->allocReg(); @@ -1733,7 +1738,7 @@ private function dynfTable(array $clean): array $rows[] = '{ ptr, ptr } { ptr ' . $this->litStr($this->dynfPublicName($fname)) . ', ptr ' . $fp . ' }'; } $n = \count($rows); - $tsym = '@.dynf.rows.' . $this->mirHelperSym((string)\count($this->dynfTables)); + $tsym = '@.dynf.rows.' . $this->mirHelperSym($this->keyedHelperName('', $key)); $this->dynfExtraBodies .= $tsym . ' = linkonce_odr constant [' . (string)$n . ' x { ptr, ptr }] [' . \implode(', ', $rows) . "]\n\n"; $this->dynfTables[$key] = [$tsym, $n]; @@ -2091,28 +2096,10 @@ private function emitErasedInvokeShared(Invoke_ $n, string $raw): string $argRegs[] = 'i64 ' . $this->lastValue; $modes .= 'v'; } - $line = 'i64 0'; - $file = 'ptr null'; - if ($this->rt->needsBacktrace) { - $fn = $this->frame->name; - if ($fn === '') { - $line = 'i64 ' . (string)$n->line; - } else { - if (!isset($this->btBaseLine[$fn])) { - $this->btBaseLine[$fn] = $n->line; - $this->litTableBodies .= '@.btl.' . $this->mangle($fn) . ' = linkonce_odr constant i64 ' . (string)$n->line . "\n"; - } - $b = $this->ssa->allocReg(); - $out .= ' ' . $b . ' = load i64, ptr @.btl.' . $this->mangle($fn) . "\n"; - $abs = $this->ssa->allocReg(); - $out .= ' ' . $abs . ' = add i64 ' . $b . ', ' . (string)($n->line - $this->btBaseLine[$fn]) . "\n"; - $line = 'i64 ' . $abs; - } - } - if ($this->rt->needsBacktrace) { $file = 'ptr ' . $this->strLitId($this->pool->intern($this->frameFile())); } + $site = $this->siteTraceArgs($n->line, $out); $sym = $this->erasedInvokeHelper($modes, $n->srcArgc); $r = $this->ssa->allocReg(); - $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $raw . ', ' . $line . ', ' . $file + $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $raw . ', ' . $site . ($argRegs === [] ? '' : ', ' . \implode(', ', $argRegs)) . ")\n"; $this->lastValue = $r; $this->lastValueType = 'i64'; @@ -2146,6 +2133,45 @@ private function erinvScopeMatters(): bool private ?bool $erinvScopeMemo = null; + /** + * `i64 line, ptr file` of this call site, for a shared helper that pushes the + * trace frames of the calls it makes: the frame's line and file, the site the + * enclosing helper was handed, or none inside a helper that has no site. + */ + private function siteTraceArgs(int $line, string &$out): string + { + // The file always goes over: a Throwable built in the helper stores it. + if ($this->btLineOverride !== '' && $this->ssa === $this->btLineOverrideSsa) { + return 'i64 ' . $this->btLineOverride . ', ptr ' . $this->btFileOverride; + } + if ($this->cellSinkFnOverride !== null) { + return 'i64 0, ptr ' . $this->strLitId($this->pool->intern("\0")); + } + $file = 'ptr ' . $this->strLitId($this->pool->intern($this->frameFile())); + if (!$this->rt->needsBacktrace) { return 'i64 ' . (string)$line . ', ' . $file; } + $fn = $this->frame->name; + if ($fn === '') { return 'i64 ' . (string)$line . ', ' . $file; } + if (!isset($this->btBaseLine[$fn])) { + $this->btBaseLine[$fn] = $line; + $this->litTableBodies .= '@.btl.' . $this->mangle($fn) . ' = linkonce_odr constant i64 ' . (string)$line . "\n"; + } + $b = $this->ssa->allocReg(); + $out .= ' ' . $b . ' = load i64, ptr @.btl.' . $this->mangle($fn) . "\n"; + $abs = $this->ssa->allocReg(); + $out .= ' ' . $abs . ' = add i64 ' . $b . ', ' . (string)($line - $this->btBaseLine[$fn]) . "\n"; + return 'i64 ' . $abs . ', ' . $file; + } + + /** Make the body being built take its call site as `%btline` / `%btfile` ({@see siteTraceArgs}). */ + private function useSiteTraceParams(): void + { + $this->btLineOverride = '%btline'; + $this->btFileOverride = '%btfile'; + $this->btLineOverrideSsa = $this->ssa; + } + + private string $btFileOverride = ''; + /** Call-site line for {@see btPush} inside {@see erasedInvokeHelper}: a register, '' elsewhere. * Only for that helper's own body, not for a helper built while it emits. */ private string $btLineOverride = ''; @@ -2186,6 +2212,7 @@ private function erasedInvokeHelper(string $modes, int $srcArgc): string $oldCellSinkFn = $this->cellSinkFnOverride; $oldCellProv = $this->cellProv; $oldBtLine = $this->btLineOverride; + $oldBtFile = $this->btFileOverride; $oldBtSsa = $this->btLineOverrideSsa; $this->ssa = new SsaBuilder(); @@ -2202,9 +2229,9 @@ private function erasedInvokeHelper(string $modes, int $srcArgc): string $this->vdResult = ''; $this->vdArmList = ''; $this->vdSiteArgc = 0; - $this->btLineOverride = '%line'; + $this->useSiteTraceParams(); - $params = 'i64 %callee, i64 %line, ptr %file'; + $params = 'i64 %callee, i64 %btline, ptr %btfile'; $body = ''; /** @var Node[] $args */ $args = []; @@ -2243,6 +2270,7 @@ private function erasedInvokeHelper(string $modes, int $srcArgc): string } $this->btLineOverride = $oldBtLine; + $this->btFileOverride = $oldBtFile; $this->btLineOverrideSsa = $oldBtSsa; $this->ssa = $oldSsa; $this->locals = $oldLocals; diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index aeaeb3ce0..739196072 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -1001,13 +1001,19 @@ private function emitThrowableCapture(string $obj, \Compile\Mir\NewObj $no, bool $lnOff = $cd->propertyOffset('traceLines'); $out = ''; // line = the `new` site line; file = the source path. + // Inside a shared helper handed its call site, the site is where it was thrown. + $site = $this->btLineOverride !== '' && $this->ssa === $this->btLineOverrideSsa; $lp = $this->ssa->allocReg(); $out .= ' ' . $lp . ' = getelementptr inbounds i8, ptr ' . $obj . ', i64 ' . (string)$lineOff . "\n"; - $out .= ' store i64 ' . (string)$no->line . ', ptr ' . $lp . "\n"; + $out .= ' store i64 ' . ($site && $no->line === 0 ? $this->btLineOverride : (string)$no->line) . ', ptr ' . $lp . "\n"; $fp = $this->ssa->allocReg(); $out .= ' ' . $fp . ' = getelementptr inbounds i8, ptr ' . $obj . ', i64 ' . (string)$fileOff . "\n"; $fstr = $this->ssa->allocReg(); - $out .= ' ' . $fstr . ' = ptrtoint ptr ' . $this->strLitId($this->pool->intern($this->frameFile())) . " to i64\n"; + // A shared helper with no site has no file either (built-in code, as php shows it): + // the file of whichever frame asked for it first named the wrong file elsewhere. + $fileLit = $site ? $this->btFileOverride + : $this->strLitId($this->pool->intern($this->cellSinkFnOverride !== null ? "\0" : $this->frameFile())); + $out .= ' ' . $fstr . ' = ptrtoint ptr ' . $fileLit . " to i64\n"; $out .= ' store i64 ' . $fstr . ', ptr ' . $fp . "\n"; if (!$withTrace) { return $out; } // Two packed vecs of the active frames, innermost first. @@ -5599,6 +5605,9 @@ private function dynamicMethodSpreadArmHelper(Type $rt, int $fixedN, string $m, $oldCellSinkOrd = $this->cellSinkOrd; $oldCellSinkFn = $this->cellSinkFnOverride; $oldCellProv = $this->cellProv; + $oldBtLine = $this->btLineOverride; + $oldBtFile = $this->btFileOverride; + $oldBtSsa = $this->btLineOverrideSsa; $this->ssa = new SsaBuilder(); $this->ssa->reset(); @@ -5620,7 +5629,9 @@ private function dynamicMethodSpreadArmHelper(Type $rt, int $fixedN, string $m, $argsSlot = $this->ssa->allocReg(); $this->locals->slots['__mc_dyn_obj'] = $objSlot; $this->locals->slots['__mc_dyn_args'] = $argsSlot; - $body = 'define hidden i64 ' . $sym . "(i64 %obj, ptr %args) {\nentry:\n"; + // The call site comes in, so a shared arm pushes the frame of whoever called it. + $this->useSiteTraceParams(); + $body = 'define hidden i64 ' . $sym . "(i64 %obj, ptr %args, i64 %btline, ptr %btfile) {\nentry:\n"; $body .= ' ' . $objSlot . " = alloca i64\n store i64 %obj, ptr " . $objSlot . "\n"; $body .= ' ' . $argsSlot . " = alloca i64\n"; $body .= " %argsI = ptrtoint ptr %args to i64\n store i64 %argsI, ptr " . $argsSlot . "\n"; @@ -5637,6 +5648,9 @@ private function dynamicMethodSpreadArmHelper(Type $rt, int $fixedN, string $m, } $this->noSpreadRoute = $oldNoSpreadRoute; + $this->btLineOverride = $oldBtLine; + $this->btFileOverride = $oldBtFile; + $this->btLineOverrideSsa = $oldBtSsa; $this->ssa = $oldSsa; $this->locals = $oldLocals; $this->cf = $oldCf; @@ -6580,7 +6594,7 @@ private function dynScopeRelated(string $scope): array $this->dynScopeRelTables[$scope] = ['null', 0]; return ['null', 0]; } - $sym = '@.dynd.rel.' . (string)\count($this->dynScopeRelTables); + $sym = '@.dynd.rel.' . $this->keyedHelperName('', 'rel|' . $scope); $this->dynfExtraBodies .= $sym . ' = private unnamed_addr constant [' . (string)\count($names) . ' x ptr] [' . \implode(', ', $names) . "]\n"; $this->dynScopeRelTables[$scope] = [$sym, \count($names)]; @@ -6656,7 +6670,8 @@ private function emitDynMethodInlineFallback(\Compile\Mir\DynProp_ $dp, \Compile $rp = $this->ssa->allocReg(); $out .= ' ' . $rp . ' = inttoptr i64 ' . $ra . " to ptr\n"; $rv = $this->ssa->allocReg(); - $out .= ' ' . $rv . ' = call i64 ' . $armSym . '(i64 ' . $ro . ', ptr ' . $rp . ")\n"; + $site = $this->siteTraceArgs(0, $out); + $out .= ' ' . $rv . ' = call i64 ' . $armSym . '(i64 ' . $ro . ', ptr ' . $rp . ', ' . $site . ")\n"; $out .= ' store i64 ' . $rv . ', ptr ' . $res . "\n"; $out .= ' br label %' . $endL . "\n"; $out .= $nextL . ":\n"; @@ -9223,11 +9238,11 @@ private function virtualDispatchKey(int $argc, array $cands, array $targets, str $k .= '@'; for ($i = 0; $i < $argc; $i++) { $t = $argOutTypes[$i] ?? null; - $k .= ($t === null ? '-' : (string)$t->id) . ','; + $k .= ($t === null ? '-' : $t->exactString()) . ','; } // The arms read an address differently from a value: two sites apart on it share no body. - foreach ($this->vdWiden as $wi => $wt) { $k .= 'w' . (string)$wi . ':' . (string)$wt->id . ','; } - foreach ($this->vdAddrLoad as $li => $lt) { $k .= 'l' . (string)$li . ':' . (string)$lt->id . ','; } + foreach ($this->vdWiden as $wi => $wt) { $k .= 'w' . (string)$wi . ':' . $wt->exactString() . ','; } + foreach ($this->vdAddrLoad as $li => $lt) { $k .= 'l' . (string)$li . ':' . $lt->exactString() . ','; } return $k; } /** A dispatch arm's result in a cell-typed call: an enum body returns its ordinal, and a cell holds the case singleton. */ @@ -10087,7 +10102,8 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin } $argsP = $this->lastValue; $r = $this->ssa->allocReg(); - $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $recv . ', ptr ' . $argsP . ")\n"; + $site = $this->siteTraceArgs($n->line, $out); + $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $recv . ', ptr ' . $argsP . ', ' . $site . ")\n"; $this->lastValue = $r; $this->lastValueType = 'i64'; $this->markCellOpaque($r); @@ -10104,7 +10120,9 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin * the receiver is spilled once and the arm is picked at run time instead * of dispatching blindly into an unrelated class's `__call`. */ - /** @var array MethodCall_ nodes already wrapped by {@see emitMixedMagicCall} */ + /** @var array MethodCall_ nodes already wrapped by {@see emitMixedMagicCall}, kept + * alive so their id names no later node: a freed node's id is handed out again, and the + * next call reusing it skipped its own magic check, depending on emission order. */ private array $mixedMagicDone = []; /** @var array */ @@ -10181,7 +10199,7 @@ private function emitMixedMagicCall(\Compile\Mir\MethodCall_ $mc, bool $boxObjec $viaMagic = new \Compile\Mir\MethodCall_($t(), '__call', [$str($mc->method), $argsArr], $cell); $plain = new \Compile\Mir\MethodCall_($t(), $mc->method, $mc->args, $mc->type); $plain->srcArgc = $mc->srcArgc; - $this->mixedMagicDone[\spl_object_id($plain)] = true; + $this->mixedMagicDone[\spl_object_id($plain)] = $plain; $out .= $this->emitNode(new \Compile\Mir\Ternary($magicOnly, $viaMagic, $plain, $cell)); // Both arms read the receiver through a local, a borrow: the fresh temp is this site's to give back. // `collect($x)->map(…)` takes this path wherever some class has `__call` and no `map`. @@ -10499,13 +10517,13 @@ private function emitMethodCallInnerBody(\Compile\Mir\MethodCall_ $n): string // cannot share one switch, and the arg emission below is already built // against the resolved callee's signature. Recorded in docs/ROADMAP.md. if ($mcStatic !== '' && isset($this->classes[$mcStatic]) && $mc->object->type->kind === Type::KIND_OBJ - && !isset($this->mixedMagicDone[\spl_object_id($mc)]) + && ($this->mixedMagicDone[\spl_object_id($mc)] ?? null) !== $mc && $this->resolveMethodClass($mcStatic, $mc->method) === '' && $this->resolveMethodClass($mcStatic, '__call') === '' && $this->descendantAnswers($mcStatic, $mc->method)) { return $this->emitMixedMagicCall($mc, true); } - if (!isset($this->classes[$mcStatic]) && !isset($this->mixedMagicDone[\spl_object_id($mc)]) + if (!isset($this->classes[$mcStatic]) && ($this->mixedMagicDone[\spl_object_id($mc)] ?? null) !== $mc && ($mc->object->type->kind === Type::KIND_CELL || $mc->object->type->kind === Type::KIND_UNKNOWN) && $this->anyClassDeclares($mc->method) && $this->magicOnlyClassExists($mc->method)) { return $this->emitMixedMagicCall($mc); From 0dbbca10b4ddb04ebf3b240da3ecbe602792d656 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Sun, 8 Nov 2026 10:27:17 +0200 Subject: [PATCH 45/50] $o->$name visibility: one shared deny table per magic and a small per-scope wrapper for what that scope may reach, instead of a copy of the whole table per scope (symfony-demo: 67 x 4.5 MB of IR) --- src/Compile/Mir/Passes/EmitLlvm.php | 3 + src/Compile/Mir/Passes/EmitLlvmObjects.php | 64 ++++++++++++++++++---- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index c67306883..6bd77a4b0 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -262,6 +262,8 @@ private function bodyFlush(string $path): void private array $propertyReadHelpers = []; /** @var array scope|magic => deny-helper symbol, '' when nothing is denied */ private array $propDenyHelpers = []; + /** @var array>> magic => class id => name => [message, visibility, declaring class] */ + private array $propDenyRows = []; /** @var array classes a lazy ghost's slots are read through */ private array $lazyHolders = []; /** The `$o->$name` an isset / `??` is testing: inaccessible reads as unset there, not an Error. */ @@ -770,6 +772,7 @@ public function emit(Module $module): string $this->hookPropertyHoldersCache = []; $this->propertyReadHelpers = []; $this->propDenyHelpers = []; + $this->propDenyRows = []; $this->dynamicMethodHelpers = []; $this->raiseRejected = []; $this->dynamicMethodAbiDisabled = false; diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 739196072..79f307d11 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -4316,24 +4316,69 @@ private function isRebindableThis(Node $obj): bool * ponytail: php calls the magic for an inaccessible name; here the slot is still * read directly, as before this check. Route denied names to the magic when a * program depends on it. + * + * The message does not depend on the scope, only whether it applies does, and a + * scope reaches few of the module's non-public properties. So one shared helper + * per magic answers every candidate, and a scope that may reach some of them gets + * a small wrapper answering 0 for those first: a copy of the whole table per + * scope was 4.5 MB of IR each on symfony-demo, 300 MB in all. */ private function propDenyHelper(string $scope, string $magic): string { $key = '__mc_prop_deny_' . $this->mangle($scope) . $magic; if (isset($this->propDenyHelpers[$key])) { return $this->propDenyHelpers[$key]; } + $shared = $this->propDenySharedHelper($magic); + if ($shared === '') { return $this->propDenyHelpers[$key] = ''; } + /** @var array> $allowed */ + $allowed = []; + foreach ($this->propDenyRows[$magic] as $cid => $props) { + foreach ($props as $p => $row) { + if (!$this->accessDenied($row[1], $row[2])) { $allowed[$cid][(string)$p] = '0'; } + } + } + if ($allowed === []) { return $this->propDenyHelpers[$key] = $shared; } + $sym = '@manticore_' . $key; + $this->emitPropDenyFn($key, $sym, $allowed, ' %r = tail call i64 ' . $shared . "(ptr %obj, ptr %key)\n ret i64 %r\n"); + return $this->propDenyHelpers[$key] = $sym; + } + + /** Every non-public property any scope may be denied, with php's message; '' when there is none. */ + private function propDenySharedHelper(string $magic): string + { + $key = '__mc_prop_deny' . $magic; + if (isset($this->propDenyHelpers[$key])) { return $this->propDenyHelpers[$key]; } $holders = $this->methodHolders($magic); + /** @var array> $rows class id => name => [message, visibility, declaring class] */ $rows = []; + /** @var array> $answers */ + $answers = []; foreach ($this->classes as $cn => $cd) { if (isset($holders[$cn])) { continue; } - foreach ($cd->propertyMeta as $p => $_pm) { - $msg = $cd->isShadowSlot((string)$p) ? '' : $this->propAccessError((string)$cn, (string)$p); - if ($msg !== '') { $rows[$cd->classId][(string)$p] = $msg; } - } - } + foreach ($cd->propertyMeta as $p => $pm) { + $p = (string)$p; + if ($cd->isShadowSlot($p) || $pm->isStatic || $pm->visibility === 'public' || $pm->visibility === '' + || $pm->declaringClass === '') { continue; } + // An ancestor's private property is invisible to the subclass, not forbidden. + if ($pm->visibility === 'private' && $this->accessCanon($pm->declaringClass) !== $this->accessCanon((string)$cn)) { continue; } + $msg = 'Cannot access ' . $pm->visibility . ' property ' . (string)$cn . '::$' . $p; + $rows[$cd->classId][$p] = [$msg, $pm->visibility, $pm->declaringClass]; + $answers[$cd->classId][$p] = 'ptrtoint (ptr ' . $this->litStr($msg) . ' to i64)'; + } + } + $this->propDenyRows[$magic] = $rows; if ($rows === []) { return $this->propDenyHelpers[$key] = ''; } + $sym = '@manticore_' . $key; + $this->emitPropDenyFn($key, $sym, $answers, " ret i64 0\n"); + return $this->propDenyHelpers[$key] = $sym; + } + /** + * `(ptr obj, ptr name) -> i64` answering `$rows[class id][name]`, else running `$miss`. + * @param array> $rows + */ + private function emitPropDenyFn(string $key, string $sym, array $rows, string $miss): void + { $this->rt->needsStrcmp = true; - $sym = '@manticore_' . $key; $oldSsa = $this->ssa; $oldClassId = $this->classIdReg; $this->ssa = new SsaBuilder(); @@ -4348,7 +4393,7 @@ private function propDenyHelper(string $scope, string $magic): string $lbl = $this->ssa->allocLabel('deny.class'); $switch .= ' i64 ' . (string)$cid . ', label %' . $lbl . "\n"; $bodies .= $lbl . ":\n"; - foreach ($props as $p => $msg) { + foreach ($props as $p => $answer) { $c = $this->ssa->allocReg(); $e = $this->ssa->allocReg(); $hit = $this->ssa->allocLabel('deny.hit'); @@ -4356,16 +4401,15 @@ private function propDenyHelper(string $scope, string $magic): string $bodies .= ' ' . $c . ' = call i32 @strcmp(ptr %key, ptr ' . $this->litStr((string)$p) . ")\n"; $bodies .= ' ' . $e . ' = icmp eq i32 ' . $c . ", 0\n"; $bodies .= ' br i1 ' . $e . ', label %' . $hit . ', label %' . $next . "\n"; - $bodies .= $hit . ":\n ret i64 ptrtoint (ptr " . $this->litStr($msg) . " to i64)\n"; + $bodies .= $hit . ":\n ret i64 " . $answer . "\n"; $bodies .= $next . ":\n"; } $bodies .= " br label %none\n"; } - $out .= $switch . " ]\n" . $bodies . "none:\n ret i64 0\n}\n\n"; + $out .= $switch . " ]\n" . $bodies . "none:\n" . $miss . "}\n\n"; $this->ssa = $oldSsa; $this->classIdReg = $oldClassId; $this->propertyReadHelpers[$key] = $out; - return $this->propDenyHelpers[$key] = $sym; } /** From 8b74c818c60a2b89e48693bea671cebdcf6e7dd7 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Mon, 9 Nov 2026 08:41:04 +0200 Subject: [PATCH 46/50] Mixed __call dispatch asks one predicate per method name instead of splicing is_object/method_exists/get_class at every site: with the wrapped nodes no longer mistaken for one another, every erased fluent call took it (symfony-demo staged IR 2691 -> 2509 MB) --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 81 ++++++++++++++++++++-- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 79f307d11..4ed1a03e1 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -10210,6 +10210,70 @@ private function descendantAnswers(string $class, string $method): bool * has `__call` goes through `__call` (as the all-magic reroute does); every * other receiver takes the ordinary dispatch. */ + /** + * `i64 (i64 cell)`: 1 when the value is an object whose class has `__call` and lacks + * `$method`, else 0 — {@see emitMixedMagicCall}'s run-time choice, one body per name. + */ + private function magicOnlyPredicate(string $method): string + { + $key = '__mir_magiconly|' . \strtolower($method); + $sym = '@' . $this->mirHelperSym($this->keyedHelperName('__mir_magiconly_', \strtolower($method))); + if (isset($this->dynamicMethodHelpers[$key])) { return $sym; } + $this->dynamicMethodHelpers[$key] = ''; + $oldSsa = $this->ssa; + $oldLocals = $this->locals; + $oldCf = $this->cf; + $oldArena = $this->arena; + $oldLast = $this->lastValue; + $oldLastType = $this->lastValueType; + $oldCellProv = $this->cellProv; + $oldCellSinkOrd = $this->cellSinkOrd; + $oldCellSinkFn = $this->cellSinkFnOverride; + $oldNeedsBacktrace = $this->rt->needsBacktrace; + $this->ssa = new SsaBuilder(); + $this->ssa->reset(); + $this->resetCellGuardFrame(); + $this->cellSinkFnOverride = \ltrim($sym, '@'); + $this->locals = new \Compile\Mir\LocalSlots(); + $this->cf = new \Compile\Mir\ControlFlow(); + $this->arena = new \Compile\Mir\ArenaContext(); + $slot = $this->ssa->allocReg(); + $this->locals->slots['__mc_mo'] = $slot; + $body = 'define hidden i64 ' . $sym . "(i64 %v) {\nentry:\n"; + $body .= ' ' . $slot . " = alloca i64\n store i64 %v, ptr " . $slot . "\n"; + $cell = Type::cell(); + $bool = Type::bool_(); + $str = fn (string $s): Node => new \Compile\Mir\StringConst($s, Type::string_()); + $t = fn (): Node => new \Compile\Mir\LoadLocal('__mc_mo', $cell); + $call = fn (string $f, array $args): Node => new \Compile\Mir\Call($f, $args, $bool); + $false = fn (): Node => new \Compile\Mir\BoolConst(false, $bool); + $true = fn (): Node => new \Compile\Mir\BoolConst(true, $bool); + $magicOnly = new \Compile\Mir\Ternary($call('is_object', [$t()]), + new \Compile\Mir\Ternary($call('method_exists', [$t(), $str('__call')]), + new \Compile\Mir\Ternary($call('method_exists', [$t(), $str($method)]), $false(), $true(), $bool), + $false(), $bool), + $false(), $bool); + $body .= $this->emitNode($magicOnly); + $body .= $this->coerceToI64(); + $nz = $this->ssa->allocReg(); + $body .= ' ' . $nz . ' = icmp ne i64 ' . $this->lastValue . ", 0\n"; + $w = $this->ssa->allocReg(); + $body .= ' ' . $w . ' = zext i1 ' . $nz . " to i64\n"; + $body .= ' ret i64 ' . $w . "\n}\n\n"; + $this->dynamicMethodHelpers[$key] = $body; + $this->ssa = $oldSsa; + $this->locals = $oldLocals; + $this->cf = $oldCf; + $this->arena = $oldArena; + $this->lastValue = $oldLast; + $this->lastValueType = $oldLastType; + $this->cellProv = $oldCellProv; + $this->cellSinkOrd = $oldCellSinkOrd; + $this->cellSinkFnOverride = $oldCellSinkFn; + $this->rt->needsBacktrace = $oldNeedsBacktrace; + return $sym; + } + private function emitMixedMagicCall(\Compile\Mir\MethodCall_ $mc, bool $boxObject = false): string { $out = $this->emitNode($mc->object); @@ -10231,12 +10295,17 @@ private function emitMixedMagicCall(\Compile\Mir\MethodCall_ $mc, bool $boxObjec $call = fn (string $f, array $args): Node => new \Compile\Mir\Call($f, $args, $bool); $false = fn (): Node => new \Compile\Mir\BoolConst(false, $bool); $true = fn (): Node => new \Compile\Mir\BoolConst(true, $bool); - // is_object && method_exists(__call) && !method_exists(method) - $magicOnly = new \Compile\Mir\Ternary($call('is_object', [$t()]), - new \Compile\Mir\Ternary($call('method_exists', [$t(), $str('__call')]), - new \Compile\Mir\Ternary($call('method_exists', [$t(), $str($mc->method)]), $false(), $true(), $bool), - $false(), $bool), - $false(), $bool); + // is_object && method_exists(__call) && !method_exists(method), once per method name: spliced into + // every site it was ~140 IR lines (a get_class chain per method_exists), 3 MB in one Symfony + // Configuration builder whose fluent calls all ride an erased receiver. + $pred = $this->magicOnlyPredicate($mc->method); + $pr = $this->ssa->allocReg(); + $out .= ' ' . $pr . ' = call i64 ' . $pred . '(i64 ' . $cellV . ")\n"; + $predName = '__emxp' . $tag; + $predSlot = $this->ssa->allocReg(); + $out .= ' ' . $predSlot . " = alloca i64\n store i64 " . $pr . ', ptr ' . $predSlot . "\n"; + $this->locals->slots[$predName] = $predSlot; + $magicOnly = new \Compile\Mir\LoadLocal($predName, Type::int_()); $elems = []; foreach ($mc->args as $a) { $elems[] = new \Compile\Mir\ArrayElement_(null, $a); } $argsArr = new \Compile\Mir\ArrayLit($elems, Type::vec($cell)); From a9c79b1cf7e2899d53f75f9816b9c7b907aaa0d5 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Mon, 9 Nov 2026 09:19:24 +0200 Subject: [PATCH 47/50] Mixed __call dispatch: the __call arm is one shared helper per arity that builds the argument array itself, instead of an erased __call dispatch and its checks at every site (symfony-demo staged IR 2509 -> 2445 MB, one Configuration builder 3.1 -> 0.9 MB) --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 146 +++++++++++++++++- tests/aot/cases/mixed_magic_call_shared.php | 20 +++ .../aot/expected/mixed_magic_call_shared.out | 8 + 3 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 tests/aot/cases/mixed_magic_call_shared.php create mode 100644 tests/aot/expected/mixed_magic_call_shared.out diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 4ed1a03e1..761d0f69c 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -10274,6 +10274,90 @@ private function magicOnlyPredicate(string $method): string return $sym; } + /** Can {@see magicCallHelper} take this call's arguments: no spread, and each a value whose box takes no reference. */ + private function magicCallShareable(\Compile\Mir\MethodCall_ $mc): bool + { + foreach ($mc->args as $a) { + if ($a->kind === Node::KIND_SPREAD) { return false; } + $k = $a->type->kind; + if ($k !== Type::KIND_CELL && $k !== Type::KIND_UNKNOWN && $k !== Type::KIND_INT && $k !== Type::KIND_BOOL + && $k !== Type::KIND_FLOAT && $k !== Type::KIND_NULL && $k !== Type::KIND_STRING + && $k !== Type::KIND_OBJ) { return false; } + } + return true; + } + + /** + * `i64 (i64 recv, i64 name, i64 a0..a(n-1), i64 btline, ptr btfile)`: `$recv->__call($name, [$a0, …])` + * for {@see emitMixedMagicCall}, one body per arity. Spliced into each site, the erased `__call` + * dispatch and its argument checks were ~60 IR lines a call. + */ + private function magicCallHelper(int $argc): string + { + $key = '__mir_callmagic|' . (string)$argc; + $sym = '@' . $this->mirHelperSym('__mir_callmagic_' . (string)$argc); + if (isset($this->dynamicMethodHelpers[$key])) { return $sym; } + $this->dynamicMethodHelpers[$key] = ''; + $oldSsa = $this->ssa; + $oldLocals = $this->locals; + $oldCf = $this->cf; + $oldArena = $this->arena; + $oldLast = $this->lastValue; + $oldLastType = $this->lastValueType; + $oldCellProv = $this->cellProv; + $oldCellSinkOrd = $this->cellSinkOrd; + $oldCellSinkFn = $this->cellSinkFnOverride; + $oldNeedsBacktrace = $this->rt->needsBacktrace; + $oldBtLine = $this->btLineOverride; + $oldBtFile = $this->btFileOverride; + $oldBtSsa = $this->btLineOverrideSsa; + $this->ssa = new SsaBuilder(); + $this->ssa->reset(); + $this->resetCellGuardFrame(); + $this->cellSinkFnOverride = \ltrim($sym, '@'); + $this->locals = new \Compile\Mir\LocalSlots(); + $this->cf = new \Compile\Mir\ControlFlow(); + $this->arena = new \Compile\Mir\ArenaContext(); + $this->useSiteTraceParams(); + $cell = Type::cell(); + $params = 'i64 %o, i64 %n'; + $body = ''; + foreach (['o' => '%o', 'n' => '%n'] as $nm => $p) { + $s = $this->ssa->allocReg(); + $body .= ' ' . $s . " = alloca i64\n store i64 " . $p . ', ptr ' . $s . "\n"; + $this->locals->slots['__mc_cm_' . $nm] = $s; + } + $elems = []; + for ($i = 0; $i < $argc; $i = $i + 1) { + $params .= ', i64 %a' . (string)$i; + $s = $this->ssa->allocReg(); + $body .= ' ' . $s . " = alloca i64\n store i64 %a" . (string)$i . ', ptr ' . $s . "\n"; + $this->locals->slots['__mc_cm_a' . (string)$i] = $s; + $elems[] = new \Compile\Mir\ArrayElement_(null, new \Compile\Mir\LoadLocal('__mc_cm_a' . (string)$i, $cell)); + } + $call = new \Compile\Mir\MethodCall_(new \Compile\Mir\LoadLocal('__mc_cm_o', $cell), '__call', + [new \Compile\Mir\LoadLocal('__mc_cm_n', $cell), new \Compile\Mir\ArrayLit($elems, Type::vec($cell))], $cell); + $body .= $this->emitNode($call); + $body .= $this->coerceToI64(); + $text = 'define hidden i64 ' . $sym . '(' . $params . ", i64 %btline, ptr %btfile) {\nentry:\n" . $body + . ' ret i64 ' . $this->lastValue . "\n}\n\n"; + $this->dynamicMethodHelpers[$key] = $text; + $this->ssa = $oldSsa; + $this->locals = $oldLocals; + $this->cf = $oldCf; + $this->arena = $oldArena; + $this->lastValue = $oldLast; + $this->lastValueType = $oldLastType; + $this->cellProv = $oldCellProv; + $this->cellSinkOrd = $oldCellSinkOrd; + $this->cellSinkFnOverride = $oldCellSinkFn; + $this->rt->needsBacktrace = $oldNeedsBacktrace; + $this->btLineOverride = $oldBtLine; + $this->btFileOverride = $oldBtFile; + $this->btLineOverrideSsa = $oldBtSsa; + return $sym; + } + private function emitMixedMagicCall(\Compile\Mir\MethodCall_ $mc, bool $boxObject = false): string { $out = $this->emitNode($mc->object); @@ -10306,14 +10390,60 @@ private function emitMixedMagicCall(\Compile\Mir\MethodCall_ $mc, bool $boxObjec $out .= ' ' . $predSlot . " = alloca i64\n store i64 " . $pr . ', ptr ' . $predSlot . "\n"; $this->locals->slots[$predName] = $predSlot; $magicOnly = new \Compile\Mir\LoadLocal($predName, Type::int_()); - $elems = []; - foreach ($mc->args as $a) { $elems[] = new \Compile\Mir\ArrayElement_(null, $a); } - $argsArr = new \Compile\Mir\ArrayLit($elems, Type::vec($cell)); - $viaMagic = new \Compile\Mir\MethodCall_($t(), '__call', [$str($mc->method), $argsArr], $cell); - $plain = new \Compile\Mir\MethodCall_($t(), $mc->method, $mc->args, $mc->type); - $plain->srcArgc = $mc->srcArgc; - $this->mixedMagicDone[\spl_object_id($plain)] = $plain; - $out .= $this->emitNode(new \Compile\Mir\Ternary($magicOnly, $viaMagic, $plain, $cell)); + if (!$this->magicCallShareable($mc)) { + $elems = []; + foreach ($mc->args as $a) { $elems[] = new \Compile\Mir\ArrayElement_(null, $a); } + $argsArr = new \Compile\Mir\ArrayLit($elems, Type::vec($cell)); + $viaMagic = new \Compile\Mir\MethodCall_($t(), '__call', [$str($mc->method), $argsArr], $cell); + $plain = new \Compile\Mir\MethodCall_($t(), $mc->method, $mc->args, $mc->type); + $plain->srcArgc = $mc->srcArgc; + $this->mixedMagicDone[\spl_object_id($plain)] = $plain; + $out .= $this->emitNode(new \Compile\Mir\Ternary($magicOnly, $viaMagic, $plain, $cell)); + } else { + // The `__call` arm is one shared helper per arity ({@see magicCallHelper}); an argument + // that runs code is evaluated once, before the choice, as only one arm runs anyway. + /** @var array $owned */ + $owned = []; + $args = []; + foreach ($mc->args as $a) { + $args[] = $this->invokeArgRunsCode($a) ? $this->dynHoistOperand($a, $out, $owned, true) : $a; + } + $plain = new \Compile\Mir\MethodCall_($t(), $mc->method, $args, $mc->type); + $plain->srcArgc = $mc->srcArgc; + $plain->line = $mc->line; + $this->mixedMagicDone[\spl_object_id($plain)] = $plain; + $res = $this->ssa->allocReg(); + $out .= ' ' . $res . " = alloca i64\n"; + $isM = $this->ssa->allocReg(); + $out .= ' ' . $isM . ' = icmp ne i64 ' . $pr . ", 0\n"; + $magL = $this->ssa->allocLabel('mmc.magic'); + $plainL = $this->ssa->allocLabel('mmc.plain'); + $endL = $this->ssa->allocLabel('mmc.end'); + $out .= ' br i1 ' . $isM . ', label %' . $magL . ', label %' . $plainL . "\n" . $magL . ":\n"; + $regs = ''; + foreach ($args as $a) { + $out .= $this->emitNode($a); + $out .= $this->boxToCell($a->type); + $regs .= ', i64 ' . $this->lastValue; + } + $nameCell = $this->ssa->allocReg(); + $out .= ' ' . $nameCell . ' = call i64 @__manticore_box_ptr(ptr ' + . $this->strLitId($this->pool->intern($mc->method)) . ")\n"; + $site = $this->siteTraceArgs($mc->line, $out); + $mr = $this->ssa->allocReg(); + $out .= ' ' . $mr . ' = call i64 ' . $this->magicCallHelper(\count($args)) . '(i64 ' . $cellV + . ', i64 ' . $nameCell . $regs . ', ' . $site . ")\n"; + $out .= ' store i64 ' . $mr . ', ptr ' . $res . "\n br label %" . $endL . "\n" . $plainL . ":\n"; + $out .= $this->emitNode($plain); + $out .= $this->boxToCell($plain->type); + $out .= ' store i64 ' . $this->lastValue . ', ptr ' . $res . "\n br label %" . $endL . "\n" . $endL . ":\n"; + $lr = $this->ssa->allocReg(); + $out .= ' ' . $lr . ' = load i64, ptr ' . $res . "\n"; + $this->lastValue = $lr; + $this->lastValueType = 'i64'; + $this->markCellOpaque($lr); + $out .= $this->releaseHoistedArgs($owned); + } // Both arms read the receiver through a local, a borrow: the fresh temp is this site's to give back. // `collect($x)->map(…)` takes this path wherever some class has `__call` and no `map`. if ($recvOwned) { $out .= $this->rcReleaseReg($cellV, 'cell'); } diff --git a/tests/aot/cases/mixed_magic_call_shared.php b/tests/aot/cases/mixed_magic_call_shared.php new file mode 100644 index 000000000..6e7615073 --- /dev/null +++ b/tests/aot/cases/mixed_magic_call_shared.php @@ -0,0 +1,20 @@ +seen[] = $m; return $m . '(' . implode(',', $a) . ')'; } } +class Other { public function __call(string $m, array $a): mixed { return count($a); } } + +function pick(int $i): object { return match ($i % 3) { 0 => new Real(), 1 => new Proxy(), default => new Other() }; } +function suffix(int $i): string { return 'x' . $i; } + +for ($i = 0; $i < 6; $i++) { + $o = pick($i); + echo get_class($o), ': ', var_export($o->greet('w' . $i, 2), true), ' | ', var_export($o->greet(suffix($i)), true), "\n"; +} +$p = new Proxy(); +$q = pick(1); +echo $q->greet(str_repeat('a', 3)), ' ', json_encode($q->seen), "\n"; +$before = memory_get_usage(); +for ($i = 0; $i < 20000; $i++) { $o = pick($i); $o->greet(suffix($i), 1); } +echo memory_get_usage() - $before < 200000 ? "flat\n" : "grows\n"; diff --git a/tests/aot/expected/mixed_magic_call_shared.out b/tests/aot/expected/mixed_magic_call_shared.out new file mode 100644 index 000000000..a2d2cef3c --- /dev/null +++ b/tests/aot/expected/mixed_magic_call_shared.out @@ -0,0 +1,8 @@ +Real: 'hi w0 hi w0 ' | 'hi x0 ' +Proxy: 'greet(w1,2)' | 'greet(x1)' +Other: 2 | 1 +Real: 'hi w3 hi w3 ' | 'hi x3 ' +Proxy: 'greet(w4,2)' | 'greet(x4)' +Other: 2 | 1 +greet(aaa) ["greet"] +flat From a48d46e3ac8f74138a23838f7b40e078c1652343 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Mon, 9 Nov 2026 11:58:03 +0200 Subject: [PATCH 48/50] Release a temporary array literal indexed for one element: [new A, new B][$i] leaked both objects Only for an element read; hoisting a literal out of a write or by-ref chain miscompiled the compiler's own second generation. Fixes #591 --- src/Compile/Mir/Passes/SpillFreshBases.php | 2 ++ .../leak => cases}/array_literal_index_objects.php | 1 - .../cases/array_mutator_on_element_corrupts_copy.php | 11 +++++++++++ .../array_literal_index_objects.out} | 0 .../array_mutator_on_element_corrupts_copy.out | 2 ++ 5 files changed, 15 insertions(+), 1 deletion(-) rename tests/aot/{repro/leak => cases}/array_literal_index_objects.php (96%) create mode 100644 tests/aot/cases/array_mutator_on_element_corrupts_copy.php rename tests/aot/{repro/leak/array_literal_index_objects.expected => expected/array_literal_index_objects.out} (100%) create mode 100644 tests/aot/expected/array_mutator_on_element_corrupts_copy.out diff --git a/src/Compile/Mir/Passes/SpillFreshBases.php b/src/Compile/Mir/Passes/SpillFreshBases.php index b2f4f7112..f907c62fd 100644 --- a/src/Compile/Mir/Passes/SpillFreshBases.php +++ b/src/Compile/Mir/Passes/SpillFreshBases.php @@ -1400,6 +1400,8 @@ private function isFreshContainer(Node $b, int $want): bool return !$aa->probe && \Compile\Mir\Ownership::userOffsetGetBase($aa->array->type, $this->classDefs); } if ($k === Node::KIND_NEW_DYN_OBJ) { return true; } + // `[new A, new B][$i]`: the literal is as fresh as a call's array, and nothing else owns it (#591). + if ($k === Node::KIND_ARRAY_LIT && $want === 1 && $t->kind === Type::KIND_ARRAY) { return true; } if ($t->kind !== Type::KIND_OBJ) { return false; } return $k === Node::KIND_NEW_OBJ || $k === Node::KIND_CLONE; } diff --git a/tests/aot/repro/leak/array_literal_index_objects.php b/tests/aot/cases/array_literal_index_objects.php similarity index 96% rename from tests/aot/repro/leak/array_literal_index_objects.php rename to tests/aot/cases/array_literal_index_objects.php index 0aa3c9075..c9155b6a9 100644 --- a/tests/aot/repro/leak/array_literal_index_objects.php +++ b/tests/aot/cases/array_literal_index_objects.php @@ -1,6 +1,5 @@ range(1, $n)]; } +$a = mk(3); +$b = $a; +array_shift($a['q']); +array_pop($a['q']); +echo json_encode($a), ' ', json_encode($b), "\n"; +for ($i = 0; $i < 3; $i++) { $el = ['q' => [1, 2, 3]]; echo array_shift($el['q']), ' '; } +echo "\n"; diff --git a/tests/aot/repro/leak/array_literal_index_objects.expected b/tests/aot/expected/array_literal_index_objects.out similarity index 100% rename from tests/aot/repro/leak/array_literal_index_objects.expected rename to tests/aot/expected/array_literal_index_objects.out diff --git a/tests/aot/expected/array_mutator_on_element_corrupts_copy.out b/tests/aot/expected/array_mutator_on_element_corrupts_copy.out new file mode 100644 index 000000000..a5e2ba48e --- /dev/null +++ b/tests/aot/expected/array_mutator_on_element_corrupts_copy.out @@ -0,0 +1,2 @@ +{"q":[2]} {"q":[1,2,3]} +1 1 1 From 6600c0f512b1af07e76b8e86eafd14e52fba0c5b Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Mon, 9 Nov 2026 11:58:04 +0200 Subject: [PATCH 49/50] Copy-on-write the inner array before array_shift/pop/unshift/splice/sort on an element: $a['q'] mutated in place changed every copy of $a and every reuse of a literal Through the element's slot, as a & binding takes it, for an outer array whose elements are statically arrays (raw slot, never a reference box); other shapes keep the old path. Fixes #601 --- src/Compile/Mir/Passes/EmitLlvmBuiltins.php | 62 +++++++++++++++++++ ...array_mutator_on_element_corrupts_copy.php | 1 - 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php index 4752a2739..9b9485a21 100644 --- a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php +++ b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php @@ -8195,6 +8195,26 @@ private function takeSymbol(Call $c, string $which): string private function mutArrayCow(Node $arrNode, string $arr, bool $writeBack = true): string { $k = $arrNode->kind; + if ($k === Node::KIND_ARRAY_ACCESS && $this->elemMutAddressable($arrNode)) { + // An element (`array_shift($a['q'])`): the inner array is a value of the outer one, + // shared with a copy of it or with a literal. Take the element's slot the way a `&` + // binding does (that separates the outer array), copy the inner one if it is shared, + // and put the result back in the slot. Mutating the element in place changed every + // other holder of it (#601). + $out = (string)$this->byRefAddrOf($arrNode); + $slot = $this->ssa->allocReg(); + $out .= ' ' . $slot . ' = inttoptr i64 ' . $this->lastValue . " to ptr\n"; + $p = $this->ssa->allocReg(); + $out .= ' ' . $p . ' = load ptr, ptr ' . $slot . "\n"; + $cow = $this->ssa->allocReg(); + $out .= ' ' . $cow . ' = call ptr ' . $this->cowSymbolFor($arrNode->type, $arrNode) . '(ptr ' . $p . ")\n"; + $out .= ' store ptr ' . $cow . ', ptr ' . $slot . "\n"; + $this->elemMutNode = $arrNode; + $this->elemMutSlot = $slot; + $this->lastValue = $cow; + $this->lastValueType = 'ptr'; + return $out; + } if ($k !== Node::KIND_LOAD_LOCAL && $k !== Node::KIND_PROPERTY_ACCESS && $k !== Node::KIND_STATIC_PROP) { $this->lastValue = $arr; @@ -8251,9 +8271,46 @@ private function biArrayShift(Call $c): string } /** Whether {@see vecWriteBack} stores into a slot for this base, rather than dropping the buffer. */ + /** The element slot {@see mutArrayCow} separated, for the {@see vecWriteBack} that follows it. */ + private ?Node $elemMutNode = null; + private string $elemMutSlot = ''; + + /** + * Can an in-place array builtin work through this element's slot? The chain is + * evaluated again to find the slot, so it must have no effect: locals, properties + * and static properties indexed by locals or constants. Only an outer array whose + * elements are statically arrays: its slot holds the inner array raw and never a + * reference box, so the slot word IS the buffer. + */ + private function elemMutAddressable(Node $n): bool + { + if ($n->kind !== Node::KIND_ARRAY_ACCESS) { return false; } + $aa = $n; + if ($aa->refStored) { return false; } + $ot = $aa->array->type; + if ($ot->kind !== Type::KIND_ARRAY || $ot->element === null || $ot->element->kind !== Type::KIND_ARRAY) { return false; } + $ik = $aa->index->kind; + if ($ik !== Node::KIND_LOAD_LOCAL && $ik !== Node::KIND_INT_CONST && $ik !== Node::KIND_STRING_CONST) { return false; } + if (!$this->arrayElemAddressable($aa, false)) { return false; } + $b = $aa->array; + while (true) { + $bk = $b->kind; + if ($bk === Node::KIND_LOAD_LOCAL || $bk === Node::KIND_STATIC_PROP) { return true; } + if ($bk === Node::KIND_PROPERTY_ACCESS) { $b = $b->object; continue; } + if ($bk === Node::KIND_ARRAY_ACCESS) { + $bik = $b->index->kind; + if ($bik !== Node::KIND_LOAD_LOCAL && $bik !== Node::KIND_INT_CONST && $bik !== Node::KIND_STRING_CONST) { return false; } + $b = $b->array; + continue; + } + return false; + } + } + private function canVecWriteBack(Node $arrNode): bool { $k = $arrNode->kind; + if ($k === Node::KIND_ARRAY_ACCESS) { return $this->elemMutAddressable($arrNode); } if ($k === Node::KIND_LOAD_LOCAL) { return isset($this->locals->globalBacked[$arrNode->name]) || isset($this->locals->slots[$arrNode->name]); @@ -11345,6 +11402,11 @@ private function biSubstrCount(array $args): string * property storage (shared by array_unshift / future realloc ops). */ private function vecWriteBack(Node $arrNode, string $arr2, bool $asCell = false): string { + if ($arrNode->kind === Node::KIND_ARRAY_ACCESS && $this->elemMutNode === $arrNode) { + // Into the element slot mutArrayCow separated, in the outer array's repr; any other + // element base is a nested store, re-stored into its parent below. + return ' store ptr ' . $arr2 . ', ptr ' . $this->elemMutSlot . "\n"; + } if ($arrNode->kind === Node::KIND_LOAD_LOCAL) { $name = $arrNode->name; // Global-backed (`global $arr`): the (possibly realloced) buffer must diff --git a/tests/aot/cases/array_mutator_on_element_corrupts_copy.php b/tests/aot/cases/array_mutator_on_element_corrupts_copy.php index 57f746438..e7271dc67 100644 --- a/tests/aot/cases/array_mutator_on_element_corrupts_copy.php +++ b/tests/aot/cases/array_mutator_on_element_corrupts_copy.php @@ -1,6 +1,5 @@ range(1, $n)]; } $a = mk(3); $b = $a; From 3d040611eadfc8259d494f49fa2dab106961fb67 Mon Sep 17 00:00:00 2001 From: Taras Chornyi Date: Mon, 9 Nov 2026 11:58:04 +0200 Subject: [PATCH 50/50] Keep a call site's dispatch state across building the shared __call and magic-only helpers --- src/Compile/Mir/Passes/EmitLlvmObjects.php | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 761d0f69c..f88f3e8e7 100644 --- a/src/Compile/Mir/Passes/EmitLlvmObjects.php +++ b/src/Compile/Mir/Passes/EmitLlvmObjects.php @@ -10230,6 +10230,12 @@ private function magicOnlyPredicate(string $method): string $oldCellSinkOrd = $this->cellSinkOrd; $oldCellSinkFn = $this->cellSinkFnOverride; $oldNeedsBacktrace = $this->rt->needsBacktrace; + $oldClassId = $this->classIdReg; + $oldSpread = $this->spreadTail; + $oldPad = $this->lastPadArgs; + $oldVdResult = $this->vdResult; + $oldVdArm = $this->vdArmList; + $oldVdArgc = $this->vdSiteArgc; $this->ssa = new SsaBuilder(); $this->ssa->reset(); $this->resetCellGuardFrame(); @@ -10271,6 +10277,12 @@ private function magicOnlyPredicate(string $method): string $this->cellSinkOrd = $oldCellSinkOrd; $this->cellSinkFnOverride = $oldCellSinkFn; $this->rt->needsBacktrace = $oldNeedsBacktrace; + $this->classIdReg = $oldClassId; + $this->spreadTail = $oldSpread; + $this->lastPadArgs = $oldPad; + $this->vdResult = $oldVdResult; + $this->vdArmList = $oldVdArm; + $this->vdSiteArgc = $oldVdArgc; return $sym; } @@ -10311,6 +10323,16 @@ private function magicCallHelper(int $argc): string $oldBtLine = $this->btLineOverride; $oldBtFile = $this->btFileOverride; $oldBtSsa = $this->btLineOverrideSsa; + // Everything a method call leaves behind for its own site, as dynamicMethodSpreadArmHelper + // keeps it: the dispatch below would otherwise hand its argc, pads and result to the next + // site of the frame that asked for this helper. + $oldClassId = $this->classIdReg; + $oldRawRef = $this->rawRefCall; + $oldSpread = $this->spreadTail; + $oldPad = $this->lastPadArgs; + $oldVdResult = $this->vdResult; + $oldVdArm = $this->vdArmList; + $oldVdArgc = $this->vdSiteArgc; $this->ssa = new SsaBuilder(); $this->ssa->reset(); $this->resetCellGuardFrame(); @@ -10318,6 +10340,12 @@ private function magicCallHelper(int $argc): string $this->locals = new \Compile\Mir\LocalSlots(); $this->cf = new \Compile\Mir\ControlFlow(); $this->arena = new \Compile\Mir\ArenaContext(); + $this->rawRefCall = false; + $this->spreadTail = null; + $this->lastPadArgs = ''; + $this->vdResult = ''; + $this->vdArmList = ''; + $this->vdSiteArgc = 0; $this->useSiteTraceParams(); $cell = Type::cell(); $params = 'i64 %o, i64 %n'; @@ -10355,6 +10383,13 @@ private function magicCallHelper(int $argc): string $this->btLineOverride = $oldBtLine; $this->btFileOverride = $oldBtFile; $this->btLineOverrideSsa = $oldBtSsa; + $this->classIdReg = $oldClassId; + $this->rawRefCall = $oldRawRef; + $this->spreadTail = $oldSpread; + $this->lastPadArgs = $oldPad; + $this->vdResult = $oldVdResult; + $this->vdArmList = $oldVdArm; + $this->vdSiteArgc = $oldVdArgc; return $sym; }