Menu

MIR — Manticore's mid-level IR

Status: shipped. Overview of the mid-level IR and the pass pipeline.

MIR is the typed, tree-shaped IR between the AST and LLVM. Every analysis, transform, and lowering step operates on it. This doc is the map: data structures, the type lattice, the node taxonomy, the pass pipeline, and the memory-management contract that turns escape analysis into concrete retain/release/free/arena ops.

PHP → Lexer → Parser → AST → LowerFromAst → MIR → …passes… → EmitLlvm → LLVM IR → clang → binary

Source lives under src/Compile/Mir/. Codegen (EmitLlvm*) consumes MIR but is not part of it.


1. Shape and philosophy#


2. Top-level structures#

TypeFileRole
ModuleModule.phpWhole-program unit: functions, classes, enums, interface/trait names, closure-capture counts, module-level global cells, and a passesApplied set.
FunctionDefFunctionDef.phpOne function: name, params, return type, body Block. Flags: returnsByRef, isPrelude, isExtern (declare-only stdlib import), isGenerator, and the #[Ffi\Symbol] carriers ffiSymbol / ffiParamCTypes / ffiRetCType / ffiWeak / ffiVariadicFixed / ffiRetUnsigned / ffiLibrary. Aggregate effects filled by InferEffects.
ClassDef / EnumDef / Paramresp.Layout descriptors and parameters.
Node (abstract)Node.phpIR node base: kind, type, plus effects (InferEffects), allocKind (InferAllocKind), and line (source line for diagnostics).

Module global cells (globalNames/globalDefaults, globalVarNames) back static props, static $x locals, and global $x — stored as parallel arrays, not a map of objects, because the self-host backend mishandles that.

passesApplied (markPassApplied / hasPassApplied) lets passes assert preconditions and powers dump-mir --after=<pass>.


3. Type lattice (Type.php)#

HHIR-inspired: scalar primitives, one unified array kind, object-by-class, a tagged-union cell, and a static object union.

KindConstructorNotes
void null bool int float stringType::void(), int_(), …scalars
arrayType::vec(el), Type::assoc(key,val)ONE array kind. A vec has no explicit key (key===null, implicit int); an assoc has a string key. Packed-vs-hashed is a runtime detail, not a static kind. Discriminate via isVec() / isAssoc() / isArray().
array + fieldsType::record(fields, el)Record shape: a string-key literal's per-field types in insertion order. Representationally identical to assoc[string, el] — only fields is extra, only isRecord() readers see it. Any merge / element mutation drops it back to a plain assoc.
obj<Class>Type::obj(cls)object pointer. Type::generator(v,k) is obj<Generator> with yielded value/key in element/key.
closureType::closure()
cellType::cell(atoms)NaN-boxed tagged union; atoms narrows what it can hold. Type::numericCell() = `intfloatcell whose arithmetic may promote at runtime (the cell-arith path); a plain cell keeps the integer path (isNumericCell()`).
unionType::union(arms)Static object union (`BC). Same repr as a bare object ptr (all arms are ptr, no boxing); a method call dispatches on the runtimeclass_id. ONLY all-object, ≤6 distinct classes form — otherwise degrades tounknown. One class collapses toobj<…>. Inert until a consumer opts in; every other consumer seesunknown`.
unknownType::unknown()raw i64, no static info.

Merges. unionWith() joins types at control-flow merges: same kind → refined join, else unknown. Object arms lift to a static union. Arrays join element- and key-wise so a loop back-edge that appends a typed value does not reset vec[string] to vec[unknown]; a null key (vec) joined with a string key lifts to the string key. Never collapse two known types to unknown when a refinement exists — that's a load-bearing principle.

toString() renders vec[…] / assoc[k, v] / obj<C> / cell{a|b} / a|b — golden-stable, used by dump-mir.


4. Node taxonomy (Node.php + Nodes.php)#

kind constants live on Node; concrete subclasses in Nodes.php, grouped:

Notable payloads worth knowing:

Walk.php = generic child traversal / rewrite helpers; NodeClone.php = deep copy (used by Monomorphize and inlining); Dump.php = the dump-mir printer.


5. Pass pipeline#

Every pass implements Pass (Pass.php): name(), requires() (declared dependency pass names), run(Module): Module. Each stamps markPassApplied. requires() is declarative; the actual valid order is the fixed chain in lower_module() (src/Manticore/Main.php; compile_via_mir then runs EmitLlvm, and CompileCommand drives both). Run order:

#PassFileWhat it does
1LowerFromAstLowerFromAst.phpAST → MIR. Seeds types as unknown, stamps line. Injects prelude sources, resolves stdlib externs (isExtern), marks generators, wires FFI #[Symbol].
1bNarrowScalarGuardsNarrowScalarGuards.phpif (is_int($x)) over a cell $x reads an unboxed copy of it inside the guarded branch.
2ConstFoldConstFold.phpFold constant expressions (interface_exists/trait_exists too).
3DeadStoreDeadStore.phpDead-store elimination.
4InferTypesInferTypes.phpThe big type-inference pass, split across InferTypes / InferNodes / InferScans / InferCalls / InferNarrow (~300 KB together). Refines every node's Type; sets Foreach_ iterator dispatch.
4bVivifyRefArgsVivifyRefArgs.phpDefine the locals whose ONLY definition is a by-reference argument position (preg_match($re, $s, $m) — php creates $m as NULL and the callee writes through it).
4cResolveMethodFccResolveMethodFcc.phpAfter the first inference the receiver of a first-class-callable $r->m(...) has a class: rebuild the closure with the method's own parameters (by-ref, variadic, defaults) so every argument is forwarded. A receiver that stays erased keeps the placeholder.
5NarrowReturns(preMono=true)NarrowReturns.phpNarrow concrete, param-independent bare-array returns early so call-site fusion sees a concrete element. Then re-run InferTypes.
6InlineClosuresInlineClosures.phpInline captureless arrow closures at known invoke sites; fuse array_map/filter/reduce over a concrete array + literal closure into a native typed loop. Then re-run InferTypes.
6bResolveOverloadsResolveOverloads.phpRetarget a call to a concretely-typed overload (e.g. a string-only variant of a function whose canonical return is `arraystringnull`) when every argument is concretely typed and matches exactly. Then re-run InferTypes.
7MonomorphizeMonomorphize.phpSpecialize erased-array / polymorphic functions per call-site shape (<name>$mono$<key>); repoints calls; re-runs InferTypes internally when it specializes.
8FuseSplitJoinFuseSplitJoin.phpFuse an implode(explode(…)) round-trip into one native str_replace — no intermediate array.
9TypeCheckTypeCheck.phpAn array-REPRESENTATION conflict is FATAL, because it means the callee walks the buffer at the wrong type. The full checker (arg/return array-ness, arithmetic on a provably non-numeric string constant) runs too and is equally fatal; MANTICORE_TYPECHECK=0 narrows it back to reprOnly.
10NarrowReturnsNarrowReturns.phpFull post-Mono return narrowing.
11CheckTypeDefsCheckTypeDefs.phpThe #[TypeDef] soundness gate: refuse every site that would observe an erased value AS AN OBJECT. Runs before any memory pass, since a boxed cell downstream has already lost the marker.
12ReflectAnalysisReflectAnalysis.phpDecide which classes carry reflection metadata. Fails OPEN — one unresolvable name puts every class back in (MANTICORE_REFLECT_REPORT=1 to see why).
13DemoteCharLocalsDemoteCharLocals.phpA $s[$i] only compared to a one-char literal or passed to ord() becomes a byte read, not a fresh 1-char string. Before the memory passes, so rc never sees strings that are no longer created.
14InferEffectsInferEffects.phpFill each node's Effects + the function aggregate (§6).
15InferAllocKindInferAllocKind.phpEscape analysis → allocKind on allocating nodes (§6).
16ApplyMemoryModeApplyMemoryMode.phpOverlay the --memory mode (rc/arena/hybrid) onto the verdicts (§6).
16bSpillFreshBasesSpillFreshBases.phpSpill a fresh temp that is an element / property base into a local, so the base has an owner for the drop (a Manticore\Ds typed-array read the emitter runs in place stays a borrow, see NbufInline).
17InsertMemoryOpsInsertMemoryOps.phpMaterialize MemoryOp_ nodes (retain/release/cow/arena_enter/leave) from the verdicts.
17bOwnershipFlowOwnershipFlow.phpPer-program-point ownership of rc locals (Empty / Own / Borrow, dataflow in Flow/*): explicit drop / retain ops, container moves, return drops and moves.
18VerifyVerify.phpSanity gate before codegen.

Then EmitLlvm (not a Pass) emits LLVM IR.


6. Memory-management contract#

The reason MIR carries effect/alloc metadata: EmitLlvm must consume explicit memory ops, never invent retain/release from ad-hoc feature handlers. Four staged steps drive it.

Step 3 — Effects (Effects.php, filled by InferEffects). Per-node memory effect set, unioned into the function aggregate: alloc (fresh heap value), escape (outlives the frame — return/throw/store- to-heap), throw (may unwind), callUnknown (opaque callee), storeHeap (writes into a heap slot). retain/release stay false here — they're vocabulary reserved for step 5.

Step 4 — AllocationKind (AllocationKind.php, decided by InferAllocKind). Where an allocating value lives / how it's reclaimed:

Soundness rule: default to RcHeap; downgrade to NoRefcount/Arena only when non-escape is proven. Over-marking RcHeap is merely slower; under-marking is a use-after-free once step 5 acts on it.

Step 5a — MemoryMode (MemoryMode.php, applied by ApplyMemoryMode). --memory=<rc|arena|hybrid> maps the verdict to a strategy. Default is hybrid: confined → Arena, escaping → RcHeap. rc: confined → NoRefcount, escaping → RcHeap. arena: everything → Arena (escaping needs a runtime bypass guard to stay UAF-safe).

Step 5b — MemoryOps (MemoryOp_, inserted by InsertMemoryOps). Concrete ops EmitLlvm lowers: op ∈ retain/release/cow/root/arena_enter/arena_leave; flavor ∈ string/vec/assoc/obj/cell (the heap family the runtime helper dispatches on); target = the acted-on value (or null for whole-frame arena enter/leave).

Arena arrays: the flat-scalar non-escaping array arena path is a live feature (default on, MANTICORE_ARENA_ARRAYS). See memory.md.


7. Inspecting MIR#

bin/manticore dump-mir prog.php            # MIR after the full pipeline
bin/manticore dump-mir prog.php --prelude  # include the Throwable hierarchy
bin/manticore dump-mir prog.php --effects  # annotate each op with inferred effects
bin/manticore dump-ast prog.php            # the AST it lowered from
bin/manticore dump-llvm-mir prog.php       # MIR pipeline + EmitLlvm → LLVM IR
bin/manticore dump-sig prog.php            # the exported symbol table
bin/manticore analyze prog.php             # static diagnostics, no codegen

The printer is Dump.php; type rendering is Type::toString().

⚠ dump-mir --after=<pass> is not implemented — dump-mir always prints the state after the full pipeline. Landing it is a roadmap item.

⚠ The dump-* commands do not link the stdlib, so a call into it resolves as unknown. When that matters, read the final binary.


8. Reference files#

ConcernFile
Node base + kindssrc/Compile/Mir/Node.php
Concrete nodessrc/Compile/Mir/Nodes.php
Type latticesrc/Compile/Mir/Type.php
Module / FunctionDefsrc/Compile/Mir/Module.php, FunctionDef.php
Effects / AllocationKind / MemoryModesrc/Compile/Mir/{Effects,AllocationKind,MemoryMode}.php
Traversal / clone / dumpsrc/Compile/Mir/{Walk,NodeClone,Dump}.php
Passessrc/Compile/Mir/Passes/*.php
Pipeline wiringsrc/Manticore/Main.php (lower_module, compile_via_mir)
Memory ABI (offsets, tags, rc encoding)src/Compile/MemoryAbi.php

Related design docs: type-system-v2.md, monomorphization.md, generators-and-pointers.md, ../memory.md.

Edit this page on GitHub