diff --git a/docs/design/inference-fixpoint.md b/docs/design/inference-fixpoint.md new file mode 100644 index 00000000..a378ed86 --- /dev/null +++ b/docs/design/inference-fixpoint.md @@ -0,0 +1,93 @@ +# 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 | +| 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. + +## 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", 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. + +**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, 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. + +**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. + +**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 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 + +Every phase lands alone and must pass the full suite and difftest, with `precision:` no worse and CPU no worse. + +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.** 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. +6. **Bring `Monomorphize` onto the queue.** +7. **Function summaries and the cache,** with a warm == cold byte-identity test (#498). + +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 + +- **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. 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: 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 + +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. diff --git a/src/Compile/Mir/DependencyIndex.php b/src/Compile/Mir/DependencyIndex.php index 007b28af..4cb7d1a6 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 d38f9461..52ecc32a 100644 --- a/src/Compile/Mir/Module.php +++ b/src/Compile/Mir/Module.php @@ -316,6 +316,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/EmitLlvm.php b/src/Compile/Mir/Passes/EmitLlvm.php index 8f774f1a..6bd77a4b 100644 --- a/src/Compile/Mir/Passes/EmitLlvm.php +++ b/src/Compile/Mir/Passes/EmitLlvm.php @@ -203,6 +203,35 @@ 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 = ''; + /** @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 + { + 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); } + } + + 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> */ @@ -233,6 +262,8 @@ private function rootSnapshot(string $phase, \Compile\Mir\Module $module, bool $ 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. */ @@ -741,6 +772,7 @@ public function emit(Module $module): string $this->hookPropertyHoldersCache = []; $this->propertyReadHelpers = []; $this->propDenyHelpers = []; + $this->propDenyRows = []; $this->dynamicMethodHelpers = []; $this->raiseRejected = []; $this->dynamicMethodAbiDisabled = false; @@ -937,7 +969,14 @@ public function emit(Module $module): string $this->dynfTables = []; $this->dynfExtraBodies = ''; $this->litTableBodies = ''; + $this->metaStrSyms = []; + $this->metaStrTaken = []; + $this->metaStrDefs = ''; $this->litTableCount = 0; + $this->litTableSyms = []; + $this->litTableTaken = []; + $this->keyedHelperTaken = []; + $this->keyedHelperNames = []; $this->objTemplates = []; $this->litTablesFlushed = false; $this->btBaseLine = []; @@ -949,6 +988,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 = []; @@ -982,6 +1022,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); } @@ -1023,6 +1064,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]; @@ -1084,6 +1129,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); } @@ -1107,6 +1166,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 @@ -1115,9 +1175,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 { @@ -1127,6 +1185,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)) { @@ -1152,6 +1221,7 @@ public function emit(Module $module): string \Manticore\sys_unlink($fnPath); \Manticore\sys_unlink($hoistedPath); $bodyBytes += $nBody; + } } } else { $functionBodyChunks[] = $body; @@ -1275,23 +1345,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, @@ -1299,6 +1377,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'; @@ -1322,14 +1412,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; - if (!\Manticore\append_file_bytes($bodyPath, $extraBodies)) { - throw new \RuntimeException('EmitLlvm: cannot append staged body file ' . $bodyPath); + $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 = ''; } - $bodyBytes += \strlen($extraBodies); - unset($extraBodies); + unset($extraParts); \Compile\Stats::line('IR: extra bodies appended'); $this->drainLazyHelpers($appendStreamedBody); \Compile\Stats::line('IR: lazy helpers drained'); @@ -1341,6 +1435,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 @@ -1378,9 +1474,13 @@ 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) { + $this->bodyFlush($bodyPath); $statT = \Compile\Stats::begin(' hoist allocas (streamed)'); $preambleBytes = \strlen($preamble); if ($spooled) { @@ -2371,6 +2471,35 @@ 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 + { + // 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; + while (isset($this->keyedHelperTaken[$name])) { + $n = $n + 1; + $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. */ @@ -2426,6 +2555,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. */ @@ -2437,6 +2570,8 @@ protected function mirHelperSym(string $base): 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). @@ -4503,6 +4638,16 @@ 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 and file are its arguments. + 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', '', 'null'); + } $fn = $this->frame->name; if ($fn === '') { return $this->btPushInline($this->strLitId($this->pool->intern($display)), (string)$line, '', diff --git a/src/Compile/Mir/Passes/EmitLlvmArrays.php b/src/Compile/Mir/Passes/EmitLlvmArrays.php index 080b9d88..e9f98863 100644 --- a/src/Compile/Mir/Passes/EmitLlvmArrays.php +++ b/src/Compile/Mir/Passes/EmitLlvmArrays.php @@ -1830,11 +1830,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]; } /** @@ -1908,11 +1933,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/EmitLlvmBuiltins.php b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php index cf8056de..9b9485a2 100644 --- a/src/Compile/Mir/Passes/EmitLlvmBuiltins.php +++ b/src/Compile/Mir/Passes/EmitLlvmBuiltins.php @@ -4967,6 +4967,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. @@ -8170,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; @@ -8226,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]); @@ -8588,6 +8670,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(); @@ -8597,9 +8682,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_()); @@ -8630,9 +8717,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; @@ -11311,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/src/Compile/Mir/Passes/EmitLlvmCalls.php b/src/Compile/Mir/Passes/EmitLlvmCalls.php index 023d9bd4..f21ac589 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; @@ -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(); @@ -1629,7 +1634,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. @@ -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]; @@ -1963,6 +1968,7 @@ private function emitErasedExplicitInvoke(Node $n, \Compile\Mir\MethodCall_ $mc) private function emitErasedInvoke(Invoke_ $n): string { + $shared = $this->erasedInvokeShareable($n); $out = $this->emitNode($n->callee); // The `[$c, $m]` of `$c::$m()` has no other owner. Only an array: a generator keeps borrowing // the closure that made it, and an erased result does not say whether it is one. @@ -1972,7 +1978,11 @@ private function emitErasedInvoke(Invoke_ $n): string /** @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); + } $out .= $this->releaseHoistedArgs($owned); if ($calleeFlavor !== '' && \str_starts_with($raw, '%')) { $resReg = $this->lastValue; @@ -1984,6 +1994,303 @@ private function emitErasedInvoke(Invoke_ $n): string return $out; } + /** + * 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 . ($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 + && $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; + 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. */ + private function emitErasedInvokeShared(Invoke_ $n, string $raw): string + { + $out = ''; + /** @var string[] $argRegs */ + $argRegs = []; + $modes = ''; + foreach ($n->args as $a) { + if ($this->erinvSlotArg($a)) { + $out .= $this->byRefAddrOf($a); + $argRegs[] = 'i64 ' . $this->lastValue; + $modes .= 'r'; + continue; + } + $out .= $this->emitNode($a); + $out .= $this->boxToCell($a->type); + $argRegs[] = 'i64 ' . $this->lastValue; + $modes .= 'v'; + } + $site = $this->siteTraceArgs($n->line, $out); + $sym = $this->erasedInvokeHelper($modes, $n->srcArgc); + $r = $this->ssa->allocReg(); + $out .= ' ' . $r . ' = call i64 ' . $sym . '(i64 ' . $raw . ', ' . $site + . ($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; + + /** + * `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 = ''; + 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($this->keyedHelperName('__mir_erinv_', $key)); + $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; + $oldBtFile = $this->btFileOverride; + $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->useSiteTraceParams(); + + $params = 'i64 %callee, i64 %btline, ptr %btfile'; + $body = ''; + /** @var Node[] $args */ + $args = []; + for ($i = 0; $i < $argc; $i = $i + 1) { + $name = '__mc_erinv_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; + } + $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->btFileOverride = $oldBtFile; + $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/src/Compile/Mir/Passes/EmitLlvmObjects.php b/src/Compile/Mir/Passes/EmitLlvmObjects.php index 2789629e..f88f3e8e 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. @@ -1667,7 +1673,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; @@ -1683,7 +1692,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"; @@ -1736,12 +1745,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 = []; @@ -1756,6 +1765,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; @@ -2199,60 +2209,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); @@ -2942,6 +2904,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)]) @@ -4328,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(); @@ -4360,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'); @@ -4368,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; } /** @@ -4666,7 +4698,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); @@ -4687,7 +4719,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'); @@ -4722,6 +4754,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; @@ -4748,6 +4790,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"; @@ -4800,7 +4843,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') { @@ -4815,6 +4858,7 @@ private function cellPropReadHelperFor(string $prop): string $enumArms, $this->magicPropHolders($prop, '__get'), $this->bagClassNames() !== [], + $fresh, ); } @@ -5332,6 +5376,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]; @@ -5343,7 +5389,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"; @@ -5451,7 +5497,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; @@ -5543,12 +5589,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), @@ -5572,15 +5618,16 @@ 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)); + $sym = '@' . $this->mirHelperSym($this->keyedHelperName('__mir_dynarm_', $key)); $this->dynArmSyms[$key] = $sym; $oldSsa = $this->ssa; @@ -5602,6 +5649,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(); @@ -5623,12 +5673,14 @@ 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"; $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); @@ -5640,6 +5692,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; @@ -6195,7 +6250,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. @@ -6583,7 +6638,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)]; @@ -6659,7 +6714,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"; @@ -9130,7 +9186,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 @@ -9143,8 +9199,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 @@ -9216,11 +9282,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. */ @@ -10054,10 +10120,11 @@ 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 @@ -10065,7 +10132,8 @@ private function emitErasedSpreadMethodCall(\Compile\Mir\MethodCall_ $n): ?strin // NAME let one by-ref constructor anywhere in the program keep every `->__construct(...$a)` // inline: PDOStatement::makeObject spliced ~5 000 arms and a 419 KiB -O0 frame. 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(); @@ -10078,7 +10146,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); @@ -10095,7 +10164,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 */ @@ -10139,6 +10210,189 @@ 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; + $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(); + $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; + $this->classIdReg = $oldClassId; + $this->spreadTail = $oldSpread; + $this->lastPadArgs = $oldPad; + $this->vdResult = $oldVdResult; + $this->vdArmList = $oldVdArm; + $this->vdSiteArgc = $oldVdArgc; + 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; + // 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(); + $this->cellSinkFnOverride = \ltrim($sym, '@'); + $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'; + $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; + $this->classIdReg = $oldClassId; + $this->rawRefCall = $oldRawRef; + $this->spreadTail = $oldSpread; + $this->lastPadArgs = $oldPad; + $this->vdResult = $oldVdResult; + $this->vdArmList = $oldVdArm; + $this->vdSiteArgc = $oldVdArgc; + return $sym; + } + private function emitMixedMagicCall(\Compile\Mir\MethodCall_ $mc, bool $boxObject = false): string { $out = $this->emitNode($mc->object); @@ -10160,20 +10414,71 @@ 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); - $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)] = true; - $out .= $this->emitNode(new \Compile\Mir\Ternary($magicOnly, $viaMagic, $plain, $cell)); + // 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_()); + 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'); } @@ -10490,13 +10795,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); @@ -10943,6 +11248,8 @@ private function emitMethodCallInnerBody(\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 @@ -10975,8 +11282,7 @@ private function emitMethodCallInnerBody(\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); diff --git a/src/Compile/Mir/Passes/EmitLlvmRuntime.php b/src/Compile/Mir/Passes/EmitLlvmRuntime.php index 27d2bdf1..01355a29 100644 --- a/src/Compile/Mir/Passes/EmitLlvmRuntime.php +++ b/src/Compile/Mir/Passes/EmitLlvmRuntime.php @@ -1998,8 +1998,7 @@ private function dynamicMethodTableForClass(\Compile\Mir\ClassDef $cls, string $ foreach ($byLower as $mn) { $mm = $cls->methodMeta[$mn]; 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; @@ -2013,15 +2012,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 @@ -2083,8 +2080,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; @@ -2093,20 +2088,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); @@ -2137,9 +2128,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; @@ -2150,9 +2139,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'; } @@ -2715,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 @@ -2748,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 @@ -2769,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()) { @@ -2788,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 diff --git a/src/Compile/Mir/Passes/InferCalls.php b/src/Compile/Mir/Passes/InferCalls.php index 5e23d49f..006f14e9 100644 --- a/src/Compile/Mir/Passes/InferCalls.php +++ b/src/Compile/Mir/Passes/InferCalls.php @@ -1119,15 +1119,19 @@ private function subclassAnswersByMagicCall(string $class, string $method): bool */ 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/InferTypes.php b/src/Compile/Mir/Passes/InferTypes.php index d941d9ef..0412ec11 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; @@ -436,15 +443,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; } @@ -454,6 +469,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)); @@ -478,6 +494,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 @@ -497,6 +543,24 @@ private function bodyTypeDigest(Module $module, array $names): string return $acc; } + /** + * 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 + { + $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[] */ private array $digestIds = []; @@ -542,10 +606,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)); - \Compile\Stats::line(' rescan ' . $reason . ' fns=' . (string)\count($functions) . ' ' - . (string)\intdiv(\Compile\Stats::now() - $startNs, 1000000) . 'ms'); + $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); } } @@ -773,6 +836,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; @@ -1165,11 +1231,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 @@ -1361,6 +1450,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/Passes/Monomorphize.php b/src/Compile/Mir/Passes/Monomorphize.php index 37450508..4e81c1b5 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,6 +124,10 @@ public function run(Module $module): Module return $module; } + + /** 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 +144,7 @@ private function runOnce(Module $module): bool $this->collectCalls($fn->body); } - // originalName → [ specializedFunctionDef, ... ] + /** @var array $clonesByOrig originalName → its specializations */ $clonesByOrig = []; $changed = false; @@ -181,8 +186,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); + // 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); + 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; } diff --git a/src/Compile/Mir/Passes/NarrowReturns.php b/src/Compile/Mir/Passes/NarrowReturns.php index 0eb1f2e8..b4bf342a 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; + // 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; + $lastRunMoved = false; $max = \count($module->functions) + 2; while ($iters < $max) { $iters = $iters + 1; @@ -135,10 +146,18 @@ 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 (!$closeOnFull) { break; } + } elseif (!$lastLight) { + // 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; + $sawFull = true; + } } } else { $sawFull = false; @@ -147,7 +166,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; } @@ -182,8 +200,10 @@ 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); - $this->endedOnFullInfer = $scope === null; + $lastRunMoved = $this->needsFullInfer && $infer->fingerprintAll($module) !== $typesBefore; + $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/Passes/SpillFreshBases.php b/src/Compile/Mir/Passes/SpillFreshBases.php index b2f4f711..f907c62f 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/src/Compile/Mir/PrecisionCensus.php b/src/Compile/Mir/PrecisionCensus.php new file mode 100644 index 00000000..96975abf --- /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/Compile/Mir/ShadowDeps.php b/src/Compile/Mir/ShadowDeps.php new file mode 100644 index 00000000..ac853878 --- /dev/null +++ b/src/Compile/Mir/ShadowDeps.php @@ -0,0 +1,287 @@ +> 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 = []; + /** @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; + + /** @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 array $missKinds */ + $missKinds = []; + $late = 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; } + if (isset(self::$wokenBefore[$name])) { $late = $late + 1; continue; } + $missed = $missed + 1; + $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) + . ' | late=' . (string)$late . ' MISSED=' . (string)$missed . ' ' . \json_encode($missKinds)); + self::$wokenBefore = $predicted; + 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/Compile/Mir/SplitModule.php b/src/Compile/Mir/SplitModule.php index 40dca934..620e21e0 100644 --- a/src/Compile/Mir/SplitModule.php +++ b/src/Compile/Mir/SplitModule.php @@ -73,10 +73,27 @@ 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; + + private const OPTNONE_DEFAULT_KB = 1024; + /** Cached symbol references for definitions reused by several parts. */ /** @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 +182,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 +220,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 @@ -275,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 @@ -330,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; } @@ -375,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. * @@ -480,12 +633,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 +681,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 +707,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 +721,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 +825,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"; @@ -639,7 +839,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"; @@ -816,6 +1016,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. @@ -939,13 +1140,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'; diff --git a/src/Compile/Mir/SsaBuilder.php b/src/Compile/Mir/SsaBuilder.php index 69bf80d3..26c85850 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 { diff --git a/src/Manticore/Main.php b/src/Manticore/Main.php index 0f77d35f..b7daf3aa 100644 --- a/src/Manticore/Main.php +++ b/src/Manticore/Main.php @@ -960,11 +960,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`. */ @@ -1396,6 +1408,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[] */ @@ -5491,9 +5505,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') @@ -5521,15 +5535,18 @@ 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; $mono = null; \Manticore\Allocator::release('after-monomorphize'); // Fuse implode(explode()) split-join round-trips into one native @@ -5602,8 +5619,12 @@ 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); + // 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. @@ -5705,6 +5726,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(); diff --git a/tests/aot/cases/array_literal_index_objects.php b/tests/aot/cases/array_literal_index_objects.php new file mode 100644 index 00000000..c9155b6a --- /dev/null +++ b/tests/aot/cases/array_literal_index_objects.php @@ -0,0 +1,10 @@ +> 20; +fwrite(STDERR, "grew {$mb} MB\n"); +echo $mb < 8 ? "flat\n" : "leak\n"; diff --git a/tests/aot/cases/array_mutator_on_element_corrupts_copy.php b/tests/aot/cases/array_mutator_on_element_corrupts_copy.php new file mode 100644 index 00000000..e7271dc6 --- /dev/null +++ b/tests/aot/cases/array_mutator_on_element_corrupts_copy.php @@ -0,0 +1,10 @@ + 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/cases/erased_invoke_shared_helper.php b/tests/aot/cases/erased_invoke_shared_helper.php new file mode 100644 index 00000000..960f85ea --- /dev/null +++ b/tests/aot/cases/erased_invoke_shared_helper.php @@ -0,0 +1,37 @@ + [$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"; +// 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/cases/erased_spread_method_fresh_pack.php b/tests/aot/cases/erased_spread_method_fresh_pack.php new file mode 100644 index 00000000..7889c971 --- /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/cases/erased_spread_method_typed_pack.php b/tests/aot/cases/erased_spread_method_typed_pack.php new file mode 100644 index 00000000..1d50bebb --- /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/cases/magic_shared_default_arm.php b/tests/aot/cases/magic_shared_default_arm.php new file mode 100644 index 00000000..03f76c0c --- /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/cases/mixed_magic_call_shared.php b/tests/aot/cases/mixed_magic_call_shared.php new file mode 100644 index 00000000..6e761507 --- /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/cases/stdlib_zlib_inflate_stream.php b/tests/aot/cases/stdlib_zlib_inflate_stream.php index a30c01df..5d852288 100644 --- a/tests/aot/cases/stdlib_zlib_inflate_stream.php +++ b/tests/aot/cases/stdlib_zlib_inflate_stream.php @@ -73,6 +73,8 @@ $phpGzip = hex2bin('1f8b08000000000000134b4c4a0600c241243503000000'); $phpZlib = hex2bin('789c4b4c4a0600024d0127'); +// `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); diff --git a/tests/aot/expected/array_literal_index_objects.out b/tests/aot/expected/array_literal_index_objects.out new file mode 100644 index 00000000..42b509ed --- /dev/null +++ b/tests/aot/expected/array_literal_index_objects.out @@ -0,0 +1 @@ +flat 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 00000000..a5e2ba48 --- /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 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 00000000..81d0c119 --- /dev/null +++ b/tests/aot/expected/erased_invoke_shared_helper.out @@ -0,0 +1,21 @@ +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', +) +new +kept 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 00000000..f6bb2551 --- /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/expected/erased_spread_method_typed_pack.out b/tests/aot/expected/erased_spread_method_typed_pack.out new file mode 100644 index 00000000..534e5e65 --- /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} 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 00000000..bd5224fe --- /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 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 00000000..a2d2cef3 --- /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 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 00000000..7aad548c --- /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 00000000..4712b4d1 --- /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 00000000..7ed6ff82 --- /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 00000000..e43bad7b --- /dev/null +++ b/tests/aot/repro/wrong/erased_invoke_trace_line_zero.php @@ -0,0 +1,8 @@ +getTrace()[0]['line'] ?? '?', "\n"; } +} +lines('boom'); 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 00000000..d95b464a --- /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 00000000..8a5df222 --- /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 00000000..5556ede2 --- /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 00000000..919565e6 --- /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"; }