← All docs

The EIR Design

Specification for elephc's default intermediate representation between AST optimization and assembly emission.

Status: EIR is the canonical compiler IR and backend contract for v1.0. The diagnostic --emit-ir path lowers the checked and optimized AST into validated textual EIR, and normal executable/cdylib builds lower that same EIR into assembly.

Authoritative source audit for this spec:

  • src/types/model.rs
  • src/parser/ast/expr.rs
  • src/parser/ast/stmt.rs
  • src/parser/ast/operators.rs
  • src/parser/ast/types.rs
  • src/parser/ast/oop.rs
  • src/parser/ast/ffi.rs
  • src/ir/
  • src/ir_lower/
  • src/codegen/
  • src/codegen_support/abi/
  • src/codegen_support/runtime/emitters.rs
  • src/optimize/effects.rs and src/optimize/effects/

EIR is elephc’s intermediate representation. It sits between the AST-level optimizer and the assembly emitter, giving the compiler a function-wide control-flow and value model without replacing the hand-written assembly backend.

EIR is intentionally PHP-shaped. Arrays, hashes, Mixed boxing, callable descriptors, copy-on-write checks, fatal paths, exception paths, runtime calls, and exact source evaluation order are first-class compiler concepts. EIR is not a generic LLVM- or Cranelift-style IR.

Pipeline Position

Current default production path:

PHP source
  -> Lexer
  -> Parser
  -> Magic constants
  -> Conditional compilation
  -> Resolver
  -> NameResolver
  -> Autoload insertion
  -> AST constant folding
  -> Type checker / warnings
  -> AST optimizer passes
  -> AST -> EIR lowering
  -> EIR validation
  -> EIR optimization passes (fixed-point driver)
  -> EIR -> assembly backend
  -> runtime cache
  -> assembler / linker
  -> binary

The AST optimizer remains in front of EIR. It handles PHP-preserving rewrites that are naturally expressed over syntax: constant folding, local scalar propagation, control-flow pruning, control-flow normalization, and DCE. EIR adds what the AST cannot express well: value identity, basic blocks, block parameters, liveness, dominance, register placement, CSE, LICM, and inlining.

Backend Contract

Virtual instance, interface and late-static calls validate literal false and null parameters against the runtime implementation before ABI coercion. Ordinary signatures skip the literal guard lookup. A module-local parameter index caches candidates by method, staticness and argument count, including empty results, without resolving emitted method bodies. Ancestor traversal follows the complete chain with cycle detection; valid deep hierarchies do not lose argument checks.

Runtime callable invokers also check declared false and null value parameters before converting their boxed arguments to the callee ABI. Indexed and named containers use the same checks after normal argument binding. Diagnostics name the declaring method while dispatch retains the actual receiver or called class. Temporary source boxes and earlier converted arguments remain rooted until the call succeeds or unwinds; null has no pushed result slot during source cleanup.

The compiler lowers EIR through the target-aware assembly emitter in src/codegen/.

EIR preserves the hand-written assembly style while adding linear-scan register allocation plus a fixed-point IR optimization pass driver (see Optimization Passes).

Design Invariants

  • EIR preserves PHP source evaluation order separately from ABI parameter order.
  • EIR values are SSA: each value is defined exactly once.
  • Blocks use block parameters instead of phi nodes.
  • PHP runtime concepts remain explicit: Mixed, COW arrays/hashes, callables, exception/fatal behavior, and ownership operations are not hidden inside a generic “call”.
  • EIR is target-aware through metadata and the selected Target, but it does not hardcode ARM64/x86_64 register names or stack layouts.
  • src/codegen_support/abi/ remains authoritative for physical ABI lowering.
  • Runtime helpers remain outside the EIR Module; EIR references them through RuntimeCall or specialized opcodes.
  • Source spans must survive lowering for diagnostics, source maps, and --emit-ir readability.
  • Builder-created effect summaries are conservative. A pass may refine effects only when it can prove PHP-visible behavior is preserved.

Type System

EIR uses a small storage lattice. PHP type details that share the same storage class are preserved in Value.php_type and opcode metadata.

pub enum IrType {
    I64,
    F64,
    Str,
    Heap(IrHeapKind),
    Void,
}

pub enum IrHeapKind {
    Array,
    Hash,
    Object(String),
    Mixed,
    Iterable,
    Union,
    Buffer(Box<PhpType>),
    Packed(String),
}

IrType is a storage contract, not a replacement for PhpType. Every value also carries the original or inferred PHP type:

pub struct Value {
    pub ir_type: IrType,
    pub php_type: PhpType,
    pub def: ValueDef,
    pub ownership: Ownership,
}

PHP Type Mapping

PhpTypeIrTypeNotes
IntI64Signed 64-bit integer storage.
FloatF6464-bit floating-point storage.
StrStr(ptr, len) pair; ownership may be transient, owned, borrowed, or persistent.
BoolI640 or 1, with php_type = Bool.
VoidVoid when no value is materialized; I64 null sentinel only when a null value is stored or boxed.
NeverVoidNever materialized; produced paths end in Return, Throw, Fatal, or Unreachable.
IterableHeap(Iterable)Type-erased array or traversable object pointer.
MixedHeap(Mixed)Pointer to runtime tagged Mixed cell.
Array(inner)Heap(Array)Indexed array heap object; element type stays in php_type.
AssocArray { key, value }Heap(Hash)Hash table heap object; key/value types stay in php_type.
Buffer(inner)Heap(Buffer(inner))Opaque generation/index handle resolved through the static buffer descriptor registry; not PHP array COW storage.
Narrow(width)never a valueStorage width of a buffer<T> element (buffer<i32>, buffer<u8>, buffer<f32>); only appears inside Buffer(inner). Element reads and writes carry its value type (Int or Float); packed fields keep their width in PackedFieldInfo::width instead.
CallableI64Callable descriptor address; ownership is tracked separately and released via callable descriptor runtime.
Object(name)Heap(Object(name))Runtime object pointer and class metadata.
Packed(name)Heap(Packed(name))Pointer/layout handle for packed POD data.
Pointer(tag)I64Raw native pointer; optional type tag remains in php_type.
Resource(kind)I64Runtime/native resource handle; optional kind remains in php_type.
Union(members)Heap(Union)Runtime representation is boxed like Mixed; member set stays in php_type.

Callable, Str, and refcounted heap values can be owned even when their storage type is not Heap(...). Ownership is a separate value property.

Because the element and key/value types “stay in php_type”, an array literal’s stamp is load-bearing: ArrayGet/HashGet read an element back in the shape the container’s php_type claims. A stamp that does not match what was actually stored is not a lost optimization but a miscompile — a container stamped array<string, int> over a hash really holding array<string> returns the inner array’s pointer read back as an integer.

Two functions compute that stamp, array_literal_element_type_for_ir for an indexed literal and assoc_array_literal_value_type_for_ir for an associative one, and both must stay context-aware for every element shape they can meet — including a nested array literal, which they each type by recursing into the same pair of functions. The context-free infer_expr_type_syntactic is only a last-resort fallback: it cannot see a local’s type and answers Int for any variable, so reaching it for an element that a local reaches through fabricates the stamp. Keep the two functions’ element-shape arms in step; an arm present in one and missing in the other is a bug that only shows up in the nesting order that routes through the incomplete one.

Array Literal Element Types

Array(inner) and AssocArray { key, value } carry an element type in php_type, and array-literal lowering converts every element it inserts to match that stamp. The stamp is load-bearing: a wrong one does not produce a diagnostic, it produces a conversion. array_literal_element_type_for_ir and its associative twin assoc_array_literal_value_type_for_ir decide it per element:

element expressionsource of the element type
a nested array literalthe nested literal’s own stamp, recursively
a spreadthe spread source’s element type, widened to Mixed when it is Void/Never
a local variablectx.local_types
a constant or class constantthe resolved constant/enum-case metadata
a call to a user or extern functionctx.functions / ctx.extern_functions, the declared return type
a call to a builtinctx.builtin_call_types, keyed by the call’s own span
a method, static-method, property, or element readthe matching *_expr_type_for_ir resolver
anything elseinfer_expr_type_syntactic
The builtin row is the one with a history. A builtin is in neither function map,
so before issue #1096 it fell through to infer_expr_type_syntactic, which
answers a call from a hand-written allowlist of builtin names and returns Int
for every name it does not list. [array_slice($a, 0, 2)] was stamped
array<int>; lowering converted the returned array to match, and (int) of a
non-empty array is 1, so the element read back as int(1) with no warning.
The same fallback made every bool-returning builtin outside the allowlist
int(1)/int(0). Adding one non-call element widened the merge and hid it.
The checker has already asked the registry contract about that exact call and
recorded the answer under the call’s own span, which is the node lowering is
looking at. Keying on the span is safe here for the reason it is not safe for a
callable: call_user_func($f, ...) records the OUTER call’s span, so a different
callee can sit under it — see “Two maps of builtin call results” in
the type checker. A synthesized node (line 0) keeps the
syntactic fallback rather than risk sharing a key with an unrelated prelude call.
A scalar result is taken precisely; anything else is stamped Mixed. That map
holds the checker’s type, while the value the call produces is typed by
resolve_registry_builtin_result_type, which overrides the checker for a Declared
or Shared result contract and re-derives its own for a Checked one whose runtime
target rejects the checked type against the operands it was really given. A stamp
that disagrees is harmless only where the push CONVERTS, and
coerce_array_literal_element_to_storage_type converts exactly Int, Bool,
Float and Str; a heap stamp is stored as-is. array_map()’s hook answers
array<int> for a callback declared : int while its EIR result is a boxed Mixed
cell, so taking the checked type verbatim read that box’s header as an array.
Mixed is the one element type that is right whatever the call’s own contract
answers — a concrete value is boxed into it on the way in, a Mixed value is stored
as-is — and it is what a literal mixing a builtin call with a plain element already
merged to.

Missing array elements at lazy merges

A typed array describes its populated elements; a read can still produce null. AST pruning retains a merge whose selected arm/default is an array read until EIR, rather than replacing the entire merge with its raw read. Materialized branch types therefore include the missing-value alternative before choosing storage for match, ternary, short-ternary and coalescing results. Object/container reads keep their concrete getter and nullable merge storage preserves their null payload. A merge-bound raw scalar or callable read uses the boxed getter so absence is distinguished before conversion, including a float miss that an ordinary warned typed read represents as zero. Already-tagged integer getters keep their existing null-aware path. Receiver/key evaluation and warnings still occur once. Explicit casts and string offsets keep their usual lowering.

When a nullable object/callable merge returns through a concrete payload ABI, MixedUnbox takes an independent payload reference before the owning merge cell is released. This prevents a clean-looking return value from leaking its box.

Unpacking uses a separate storage projection for a direct container read. Its implicit null alternative does not turn native indexed storage into a hash; the unpack operation still rejects an actual null value at runtime. Literal, positional-call and boxed-binder classification share that projection, including error-suppressed reads, so argument binding cannot replace the unpack diagnostic with an arity error. Ordinary literal elements retain the nullable logical type because inserting a missed read is valid and must store PHP null.

A nullable receiver’s null path still evaluates the offset once and honors the normal/silent warning flag. Undefined roots and native nullable containers can carry a raw null word, so the null arm retires the original owning source and creates a canonical boxed-null receiver for the dynamic getter ABI. That receiver is parked in the in-flight cleanup chain while offset evaluation or warning delivery can throw. The getter retires it normally; its proven-null payload has no user destructor, so detaching that non-owning cleanup record afterwards is safe.

Parsed Type Expressions

TypeExpr maps into PhpType during type checking before EIR lowering. EIR does not store syntactic type expressions except where metadata needs to point back to a source declaration.

TypeExprEIR relevance
Int, Float, Bool, Str, Void, Never, IterableLower through the resolved PhpType.
Ptr(Option<Name>)Lowers to PhpType::Pointer(_), then IrType::I64.
Buffer(inner)Lowers to PhpType::Buffer(_), then Heap(Buffer(_)).
Named(name)Resolved to object, packed class, enum, interface-compatible object, or builtin type before EIR.
Nullable(inner)Lowered as Union([inner, Void]) or the checker-approved nullable runtime shape.
Union(members)Lowered as PhpType::Union(_), represented as Heap(Union).

C Type Mapping

Extern declarations use CType. EIR preserves the C-facing type in ExternDecl metadata so the IR backend can use the correct C ABI helpers: the PHP type decides the storage, and the parallel ExternAbiType keeps the declared C width so the backend can normalize registers at the call boundary.

CTypeEIR storage
Int, Bool, Ptr, TypedPtr(_), CallableI64
IntWidth(_) (i8..u32, u64)I64; the width lives in ExternAbiType and only drives sign/zero extension of returns and caller-side extension of narrow arguments.
FloatF64
F32F64; ExternAbiType::Float32 rounds the argument to a single before the call and widens the return after it.
StrI64 native C string pointer; PHP strings are converted at the boundary.
VoidVoid

Module Structure

pub struct Module {
    pub target: Target,
    pub functions: Vec<Function>,
    pub class_methods: Vec<Function>,
    pub closures: Vec<Function>,
    pub fiber_wrappers: Vec<Function>,
    pub callback_wrappers: Vec<Function>,
    pub extern_callback_trampolines: Vec<Function>,
    pub runtime_callable_invokers: Vec<Function>,
    pub data: DataPool,
    pub extern_decls: Vec<ExternDecl>,
    pub class_table: ClassTable,
    pub enum_table: EnumTable,
    pub interface_table: InterfaceTable,
    pub packed_layouts: PackedLayoutTable,
    pub required_runtime_features: RuntimeFeatures,
}

The separate function vectors mirror current deferred codegen surfaces in src/codegen/context.rs: closures, fiber wrappers, callback wrappers, extern callback trampolines, and runtime callable invokers. Phase 02 may store them in one functions vector with flags, but the semantic distinction must remain representable.

pub struct Function {
    pub id: FunctionId,
    pub name: String,
    pub params: Vec<FunctionParam>,
    pub return_type: IrType,
    pub return_php_type: PhpType,
    pub locals: Vec<LocalSlot>,
    pub blocks: Vec<BasicBlock>,
    pub values: Vec<Value>,
    pub instructions: Vec<Instruction>,
    pub entry: BlockId,
    pub source_signature: Option<FunctionSigRef>,
    pub flags: FunctionFlags,
}

pub struct BasicBlock {
    pub id: BlockId,
    pub name: String,
    pub params: Vec<ValueId>,
    pub instructions: Vec<InstId>,
    pub terminator: Terminator,
}

pub struct LocalSlot {
    pub name: Option<String>,
    pub ir_type: IrType,
    pub php_type: PhpType,
    pub kind: LocalKind,
}

pub enum LocalKind {
    PhpLocal,
    GlobalAlias,
    StaticLocal,
    RefCell,
    HiddenTemp,
    BorrowedTemp,
    OwnedTemp,
    TryHandler,
    ClosureCapture,
    NamedArgTemp,
    IteratorState,
    GeneratorState,
    EvalContext,
    EvalScope,
    EvalGlobalScope,
}

LocalSlot exists even in SSA form because PHP locals, globals, statics, references, try handlers, and hidden temporaries have addressable storage or observable lifetime behavior. BorrowedTemp is transform-only storage that never owns the value written into it and is therefore excluded from epilogue cleanup; OwnedTemp carries cleanup-owning temporaries. The three eval slot kinds hold the optional interpreter context, activation scope, and program-global scope. Fully native literal eval paths do not declare those slots.

Value Ownership

Ownership mirrors the current HeapOwnership model but is attached to every SSA value, not only context locals.

pub enum Ownership {
    NonHeap,
    Owned,
    Borrowed,
    MaybeOwned,
    Persistent,
    Moved,
}
StateMeaning
NonHeapPlain scalar or native handle with no release operation.
OwnedThe value owns cleanup responsibility.
BorrowedThe value aliases storage owned elsewhere.
MaybeOwnedCFG merge or dynamic path where ownership must be resolved before cleanup.
PersistentStatic data, interned/static string, or runtime-persistent value that must not be released.
MovedValue has transferred ownership and cannot be used again except by validator diagnostics.

Ownership-producing operations:

OperationOwnership result
Scalar constantsNonHeap
Static string literalPersistent unless persisted/copied into heap
Runtime allocationOwned
Local/global/static loadSlot metadata determines Owned, Borrowed, or MaybeOwned
Borrow(value)Borrowed
Move(value)Transfers source to Moved, result keeps prior ownership
Acquire(value)Produces or records an owned retain
Return(owned)Transfers ownership to caller

Ownership operations:

OpOperandResultEffectsLowering
Acquirerefcounted/string/callable valueVoid or retained value aliasREFCOUNT_OP, maybe WRITES_HEAP__rt_incref, string persist/retain, callable descriptor retain if added
Acquire + Bool immediatesamesamesamesame; the immediate marks a lifetime pin and only opts the pair out of acquire/release cancellation
Releaseowned valueVoidREFCOUNT_OP, maybe WRITES_HEAP, destruction may throw and debug may fatal__rt_decref_any, __rt_heap_free_safe, callable descriptor release
Moveany valuesame typepure validator operationno machine instruction
Borrowvalue with live ownersame typepure validator operationno machine instruction
EnsureOwnedfresh transient Str argument (., interpolation, scalar-to-string; never a builtin view)Str, same payload, ownedREFCOUNT_OP, WRITES_HEAP__rt_str_claim: retags a CONCAT_TEMP_HEAP_KIND block as an owned string in place, leaves scratch and literals untouched; emitted before a user call that may return the argument
ReleaseUnlessAliasesowned argument, call resultVoidREFCOUNT_OP, WRITES_HEAP, READS_HEAPreleases the argument unless its payload pointer equals the result’s: boxed Mixed pairs, and owned Str arguments (claimed or acquired) of a user call

Strings are not modeled as generic heap pointers because their ABI is (ptr, len), but string ownership still participates in validator checks.

Store disciplines

A store instruction either moves its value operand into the slot or leaves the slot holding something the operand does not own. Lowering has to know which, because the two need opposite EIR around the store:

DisciplineWhat the backend doesWhat lowering emits
Moving storeThe slot keeps the operand’s own pointer.An owning temporary is moved in as-is; a borrowed value gets an Acquire first, or the slot dangles once the borrow’s owner releases.
Independent-value storeThe slot gets something of its own — a fresh box, a retained pointer, or a payload word read out of the operand.An owning temporary gets a Release after the store, or its reference is never consumed and leaks; a borrowed value is left alone, or the extra retain leaks on top of the slot’s.

Which one applies is a property of the pair of representations, not of the slot alone. A store is an independent-value store when it crosses the boxed/unboxed boundary in either direction:

  • Boxing (concrete value into a Mixed/Union slot): __rt_mixed_from_value retains the child into a new cell.
  • Narrowing (Mixed value into a Str, Int, Bool, Float, Object, or tagged-scalar slot): the cast reads a payload out of the box (__rt_mixed_cast_int and friends), __rt_str_persist copies the string, and the object arm increfs the unboxed pointer on its own. Nothing that lands in the slot holds the box.

Container element writes follow the same question, answered per helper rather than per slot type:

HelperDiscipline
__rt_array_set_mixed, __rt_array_push_refcountedIndependent: the value is incref’d into the element unconditionally.
__rt_array_set_* from a concrete value into a Mixed-element arrayMoving: codegen boxes first, and the boxing path releases the box it made.
__rt_hash_setMoving when the value can hand over its own reference (value_can_own_mixed_box_source), independent otherwise.

Getting the pair wrong is silent: the program’s output is unchanged, and only --gc-stats or --heap-debug shows the drift. Issue #1041 was one of each — a typed static property narrowed a += result out of its box without releasing it, and a property array element retained one without releasing it — both one 40-byte Mixed cell per execution.

Borrowed property and indexed-read results may be stabilized with a provisional Acquire before an owning receiver is released. Its consumer must either transfer that owner or emit a matching Release; scalar casts and calls with a proven-independent result cannot alias the input and therefore release it after consumption. The retain must not be removed merely because final slot typing prunes the receiver release: a later call argument can still mutate the receiver local before the read is consumed. ABI materialization follows the same balance rule. Mixed-to-string parameters are converted into owned EIR values before the backend, allowing the call’s alias summary to transfer or release them normally; the conversion must not stay hidden inside register materialization because a string result can be an interior slice of its argument. ABI-created boxed-Mixed arguments use explicit post-call cleanup slots, including for constructors, and an exact pointer check transfers the box when a passthrough result reuses it.

Before lowering releases an owning call-argument temporary, the checker provides a conservative return-to-parameter alias summary for each source function and method. A proven-fresh result permits cleanup of unrelated arguments; a direct passthrough protects only the returned parameter. Unknown calls, indirect storage reads, and every possible descendant override merge to the conservative result, so a type-compatible true alias remains live. Registry builtins use the BuiltinResultOwnership value from their shared semantic descriptor; extern calls retain a conservative fallback. The descriptor can distinguish fresh, borrowed, independent, explicit argument-alias, and may-alias storage, including scratch-backed results that are not fresh heap blocks. That contract feeds direct-call cleanup, optimizer reasoning, and summaries for source wrappers.

Property publication and throwing cleanup

For a receiver whose property storage is known only at runtime, lowering keeps an owning source temporary explicit through PropSet or DynamicPropSet. Each backend arm borrows or retains the representation it stores. EIR releases the source after publication, so a Stringable source’s destructor observes the new property value. Scalar narrowing paths that adopt a box retire that box in the shared property materializer; receiver dispatch must not repeat that retirement.

An owning nested receiver, such as the read of $h->next in $h->next->s = make_label(), has a separate in-flight cleanup pin. These pins do not acquire references: they park the existing owner for exceptional cleanup. The source pin is detached after a successful store and before releasing the source. The receiver pin remains registered throughout that release, because a source destructor can throw after the field has already been published. Only after source cleanup returns is the receiver pin detached and the receiver’s normal release emitted. Detaching both pins immediately after the store would leak the nested receiver whenever the source destructor throws; retaining the source pin during its release would instead expose the same source to cleanup twice.

Borrowed receivers and sources keep their existing owners. A source whose reference moves into a known slot follows that slot’s transfer discipline rather than receiving an additional post-store release. These rules live in src/ir_lower/stmt/instance_property_writes.rs and the shared property-store materializer, using target-aware runtime cleanup on every supported target.

A reference cell must be as wide as what the callee may write

InvokerRefArg aliases a caller local so a callee can write back through it. The cell is the local’s own frame storage, which keeps the write cheap and keeps every alias of the local in agreement — but it also means the callee writes at the slot’s declared type. Whether that is sound depends on which by-reference form the argument is bound to, and the two forms differ:

  • A NAMED by-reference parameter (function f(&$v)) declares what it writes, and the checker holds the caller’s local to a compatible type. Both sides agree on the representation, so the local keeps concrete storage and the marker points straight at it.
  • A by-reference VARIADIC tail (function f(&...$items)) declares nothing. The callee writes through $items[n], an untyped element of an array<mixed>, so PHP lets it store a value of any type into a local currently holding something else. $p = 1; f($p); against $items[0] = "s" is ordinary PHP.

Pointing the marker at concrete storage in the second case is silent corruption rather than a diagnostic: the string is written into a slot sized and typed for an I64, and the next read hands back the string’s pointer as an integer. So the variadic form promotes the local to a boxed Mixed reference cell BEFORE taking its address (promote_local_mixed_ref_cell, the same helper PDO::bindColumn() and bindParam() use), and the callee’s write lands in storage wide enough for whatever it chose.

The widening is confined to that form deliberately. Applying it to a named by-reference parameter hands the callee a box pointer where it expects the value itself, which is the same corruption with the sides reversed. src/ir_lower/expr/descriptor_args.rs keeps the two entry points separate for that reason, and only the by-reference variadic tail in src/ir_lower/expr/variadic_args.rs reaches the widening one.

The cell also OWNS what it holds, the same way a plain slot does, so a write through it has to release the occupant it replaces. The callee’s $items[n] = ... does not reach __rt_array_set_mixed, which does exactly that for an ordinary slot: the element is an invoker ref-cell marker, so the array setter recognises the marker tag and transfers the fresh boxed Mixed handle straight into the caller’s cell (emit_mixed_array_set_ref_marker_writeback_* in src/codegen/lower_inst/arrays.rs). That hand-written transfer was a bare store, which orphaned the previous box and the payload it pinned on every call — one block per call in the reported shape, two once the replaced value was itself a heap string. It now reads the old handle, stores the new one, and releases the old, in that order: releasing first would free the box a self-assignment is about to store back.

The concrete-source arm of the same write-back still does not release its occupant. It is selected by any non-Mixed source tag, and for a Str-tagged cell the occupant can be a .rodata pointer that the ownership analysis knows not to free and this slot-typed cleanup does not model, so releasing there would trade a leak for a free of read-only memory. That reason does not cover the array- and object-tagged cells the same arm also serves, which do own heap storage and do still leak it; releasing those needs the tag-aware __rt_heap_free_safe treatment rather than a blanket decref, and is left for its own change.

A third gap bounds both: this write-back emitter is reached only when the assigned value was freshly boxed at the store, which is every CONCRETE source type. A Mixed or union-typed right-hand side ($items[0] = $m;, or $items[0] = $items[1];) skips it and calls __rt_array_set_mixed directly, and that helper has no marker-tag check: it decrefs the slot’s occupant — the marker itself — and stores an ordinary box, severing the alias so the caller never sees the write. It is memory-safe, because __rt_mixed_free_deep routes the marker’s tag to a box-only free and never follows the payload into the caller’s frame, but it is a silent semantic divergence from PHP.

Non-string by-value lifetime-tracked compiled PHP returns also carry an internal ownership marker in x15 on AArch64 or r11 on x86_64. A Call result with MaybeOwned metadata spills the marker immediately, and a forwarding epilogue restores it after cleanup. Descriptor invokers consume the marker by retaining a borrowed result or transferring an owned result into the boxed return cell. This is a private compiler protocol; the public C ABI still returns one owned raw cell and exposes no ownership marker. Typed string returns consume this marker. When php_return_status is set, restore_concat_offset_after_nested_call calls emit_branch_if_owned for PhpType::Str, which tests x15 on AArch64 and r11 on x86_64.

Effects

Each instruction and terminator carries an Effects summary. The builder assigns a conservative opcode or semantic-descriptor default; after the complete module is lowered, an effect-refinement fixed point may replace the summary on explicitly refinable direct calls, named static calls (including inherited implementations), instance calls, and property reads. Forwarding static receivers and unresolved closure, descriptor, or pipe targets retain conservative defaults until their target is proven by a lowering-specific contract. Final effects are immutable after validation and remain PHP-observable.

Caller-visible summaries omit ConcatReset: it restores frame-private scratch bookkeeping rather than PHP state. The instruction itself retains writes_global so intra-function passes preserve its ordering. Genuine PHP-global writes, output, allocation, and exception effects continue to flow into call summaries.

pub struct Effects {
    pub reads_local: bool,
    pub writes_local: bool,
    pub reads_heap: bool,
    pub writes_heap: bool,
    pub reads_global: bool,
    pub writes_global: bool,
    pub reads_fs: bool,
    pub writes_fs: bool,
    pub reads_process: bool,
    pub writes_process: bool,
    pub output: bool,
    pub alloc_heap: bool,
    pub alloc_concat: bool,
    pub refcount_op: bool,
    pub may_throw: bool,
    pub may_fatal: bool,
    pub may_warn: bool,
    pub may_deopt: bool,
}
EffectRequired when
reads_local / writes_localLocal, hidden temp, ref cell, static local, or global alias slot access.
reads_heap / writes_heapArray/hash/object/mixed/callable/buffer/generator/fiber state is observed or mutated.
reads_global / writes_globalPHP globals, static properties, constants, runtime global symbols, or JSON error state are observed or mutated.
reads_fs / writes_fsFilesystem, streams, stat cache, directory, or path APIs are used.
reads_process / writes_processEnvironment, time, process execution, argv, sleep, or platform state are used.
outputecho, print, printf, var_dump, print_r, passthru/system output, or stdout/stderr writes occur.
alloc_heapRuntime heap allocation may occur.
alloc_concatConcat scratch buffer may be used.
refcount_opRefcount or callable descriptor lifetime may change.
may_throwA catchable exception may be raised.
may_fatalA PHP fatal/value error/unhandled match/bounds/null pointer path may terminate.
may_warnA PHP warning or diagnostic may be emitted.
may_deoptDynamic behavior depends on runtime type, dynamic dispatch, dynamic callback, dynamic class, or object magic behavior.

Effect sources:

  • Scalar arithmetic and comparison: hardcoded by opcode.
  • Registry builtins: from BuiltinSemantics::effects, either a fixed bitset or a shared resolver over normalized argument types.
  • User functions/methods/closures: from a whole-module monotone fixed point over lowered bodies. Direct calls inherit the target summary; virtual instance calls union every concrete implementation reachable from the checked receiver type, while a fixed object_new receiver remains exact. A runtime eval bridge keeps non-exact class dispatch conservative because it can register new subclasses.
  • Array/hash reads: typed opcodes distinguish ordinary/silent reads from warning-producing misses; they do not claim a catchable exception merely because a PHP warning is observable.
  • Property reads: checked class layouts distinguish declared untyped slots, typed-slot initialization errors, missing-property warnings, synthetic property hooks, and __get. Runtime-computed names retain warning/deopt and any reachable typed-slot or magic-method effects.
  • Callable aliases and first-class callables: from src/optimize/effects/calls.rs and descriptor metadata.
  • Extern calls: conservative unless the extern declaration later gains explicit purity metadata.
  • Typed runtime calls: from RuntimeFnId / RuntimeCallTarget metadata; concrete helper symbols are selected only after EIR validation.

Pure means no flags set. A pure operation may be CSE’d or removed if its result is unused. Any operation with may_throw, may_fatal, may_warn, output, writes_*, alloc_*, or refcount_op is observable unless a later pass proves otherwise.

may_throw is reserved for catchable PHP exceptions. A warning, fatal path, or dynamic deoptimization remains observable through its own flag but does not by itself make a surrounding catch reachable. Unknown calls and reads keep the conservative union; refinement only removes flags proved impossible from checked module metadata.

Instruction Set

Each instruction has:

  • opcode
  • operands
  • result type (None for void)
  • PHP type metadata
  • ownership result
  • effects
  • source span

Constants and Data

OpOperandsResultEffectsLowering
ConstI64(value)noneI64puretarget immediate or data load
ConstF64(data_id)noneF64puredata-section load
ConstStr(data_id)noneStrpurelabel plus length
ConstNullnoneI64 or Void context dependentpurezero/null sentinel
ConstBool(value)noneI64pure0/1
ConstClassName(class_id)noneStrpurestatic class-string
ConstEnumCase(enum_id, case_id)noneobject or scalarmay allocate if object caseenum metadata/runtime path
DataAddr(data_id)noneI64pureaddress materialization

Locals, Globals, and Static Storage

OpOperandsResultEffects
LoadLocal(slot)noneslot typereads_local
StoreLocal(slot, value)valueVoidwrites_local, maybe refcount_op
UnsetLocal(slot)noneVoidwrites_local
ZeroLocalSlot(slot)noneVoidwrites_local
LoadRefCell(slot)nonevalue or addressreads_local, maybe reads_heap
StoreRefCell(slot, value)valueVoidwrites_local, maybe writes_heap, refcount_op
LoadGlobal(name)nonedeclared typereads_global
StoreGlobal(name, value)valueVoidwrites_global, maybe refcount_op
LoadStaticLocal(slot)noneslot typereads_global
StoreStaticLocal(slot, value)valueVoidwrites_global, maybe refcount_op
InitStaticLocal(slot, value)valueVoidreads_global, writes_global, maybe allocation/refcount effects
LoadStaticProperty(class, property)noneproperty typereads_global, maybe may_deopt for late static
StoreStaticProperty(class, property, value)valueVoidwrites_global, maybe refcount_op, may_deopt

UnsetLocal and ZeroLocalSlot both clear a slot at its own storage type and differ only in the bit pattern, which is what each caller needs. UnsetLocal writes the tagged-null SENTINEL, for a slot whose NAME still resolves to it: a later Mixed read of that slot has to see PHP null. ZeroLocalSlot writes literal zeros, for a slot whose name is being ABANDONED by a local-binding kill or retype: nothing reads it again and the only remaining consumer is the frame epilogue’s cleanup, which recognises zero (__rt_heap_free_safe walks past a null pointer) and would misread the sentinel as a live pointer. Zero is also what both prologues already write into cleanup-tracked slots, so an abandoned slot ends in the state one whose store never ran is already in.

Integer, Float, and Bitwise Operations

OpOperandsResultEffects
IAdd, ISub, IMulI64, I64I64pure
IDivI64, I64F64 or I64 depending on PHP operator contextmay_fatal on invalid divisor
ISDivI64, I64I64may_fatal
ISModI64, I64I64may_fatal
IPowI64, I64I64 or F64 per PHP resultpure or libcall
INeg, IBitNotI64I64pure
IBitAnd, IBitOr, IBitXor, IShl, IShrAI64, I64I64pure
FAdd, FSub, FMul, FDiv, FPow, FNegF64 operandsF64pure or libcall
MixedNumericBinop(add|sub|mul)mixed/union operandsmixed/scalarreads_heap, maybe alloc_heap, may_warn, may_fatal, may_deopt

BinOp::Div follows PHP division behavior and may produce float. BinOp::Pow preserves PHP exponentiation result rules.

Comparison and Truthiness

OpOperandsResultEffects
ICmp(predicate)I64, I64I64 boolpure
FCmp(predicate)F64, F64I64 boolpure
StrEq, StrCmp, StrLooseEqStr, StrI64 or compare intreads_heap only if non-static bytes require helper reads
StrictEq, StrictNotEqtyped valuesI64 boolpure for scalars, reads_heap for mixed/refcounted
LooseEq, LooseNotEq, Spaceshiptyped valuesI64 or compare intmay coerce, reads_heap, may_warn, may_deopt
IsNull, IsTruthy, IsEmptytyped valueI64 boolpure for scalars, reads_heap for mixed/containers
InstanceOfvalue, target metadataI64 boolreads_heap, reads_global, maybe may_deopt

Conversions and Boxing

OpFromToEffects
IToFI64F64pure
FToIF64I64pure PHP truncation
IToStr, FToStr, BoolToStrscalarStralloc_concat or static string
StrToI, StrToF, StrToNumberStrscalar or mixed numericreads bytes, maybe may_warn
ResourceToStrI64 resourceStrmay allocate, may warn
Cast(to_php_type)typed valuematching IR typePHP cast effects
MixedBoxnon-mixed valueHeap(Mixed)alloc_heap, maybe refcount_op
MixedCloneHeap(Mixed)owned Heap(Mixed) value readreads_heap, alloc_heap, refcount_op
MixedUnbox(expected)Heap(Mixed)expected storagereads_heap, may_fatal
MixedTagOfHeap(Mixed)I64reads_heap
ArrayToMixed, HashToMixedarray/hashHeap(Mixed)alloc_heap, refcount_op
ArrayToPointerWordsowned array bound to a pointer-array slotHeap(Array) with raw ?ptr<T> wordsreads_heap, writes_heap, alloc_heap, refcount_op
MixedCastBool, MixedCastInt, MixedCastFloat, MixedCastStringHeap(Mixed)scalar/stringreads_heap, maybe alloc_concat, may_warn

Strings

OpOperandsResultEffects
StrConcatStr, StrStralloc_concat
StrAppendStr, borrowed Str; destination local slotowned Strreads_local, writes_local, reads_heap, writes_heap, alloc_heap, refcount_op, may_fatal
StrLenStrI64pure
StrPersistStrStralloc_heap, refcount_op when copy needed
StrCharAtStr, I64Strmay_fatal, alloc_concat
StrInterpolatestring parts and valuesStrcombined value effects, alloc_concat
ConcatResetnoneVoidwrites_global
WriteStrStdoutStrVoidoutput

Concat-buffer operations are statement-boundary-sensitive. Lowering must emit statement boundaries so EIR passes do not reorder alloc_concat operations across a reset point.

Consuming local string append

StrAppend carries the destination LocalSlot as its immediate. Its left operand is the adjacent load of that local’s existing string owner. On success the helper consumes that owner and returns exactly one replacement owner; the next instruction must store the result into the same slot, without another acquire or old-value release. The validator checks this adjacency, owned result, plain string storage, and exclusive SSA uses, including terminator uses. Allocation failure keeps the old local owner valid until the native boundary unwinds.

Lowering admits $s .= $rhs and $s = $s . $rhs only for initialized plain local strings. Parameters, captures, references, globals, extern storage, eval-visible state and mixed/retyped storage retain ordinary assignment. RHS must be a string literal or an initialized plain string local, which may be a borrowed parameter. RHS evaluation finishes before the transaction and cannot call or mutate a PHP value. Other RHS expressions retain ordinary evaluation and exception handling. Generic StrConcat continues to preserve both source values and use concat scratch.

The owner proof also checks the final frame storage after the body is lowered. If a later writable capture or other operation widens an admitted destination, lowering restores the body-entry checkpoint and lowers that body again with consuming append disabled. Ordinary concat and assignment then emit their existing acquires and releases for the widened slot. The validator remains strict; no consuming transaction can target boxed Mixed storage. Nested closures and interned data from the discarded lowering are restored with the same checkpoint.

The append helper uses the allocator’s payload size as capacity and the returned string pair as logical length. A unique owned buffer reuses spare capacity when RHS does not overlap it. Otherwise the helper copies into independent storage, using doubled capacity for unique growth. A required copy instead uses the old logical length as its growth basis, so retained snapshots cannot repeatedly double unused capacity. Both paths reserve at least the required length and 16 bytes. Near the size limit it uses the checked required size. Length and header-size checks precede payload access. Shared, borrowed, literal and overlapping inputs remain unchanged; the consumed old reference is retired only after copying. Fresh mbstring/INI origin metadata is installed before any changed byte is stored; an empty append preserves the existing origin. These operations do not allocate concat scratch. Spare capacity trades additional reserved memory for linear copy volume across repeated appends.

Arrays and Hashes

OpOperandsResultEffects
ArrayNew(element_type, capacity)noneHeap(Array)alloc_heap
HashNew(key_type, value_type, capacity)noneHeap(Hash)alloc_heap
ArrayLen, HashLencontainerI64reads_heap
ArrayGetarray, indexelement typereads_heap, may_warn, maybe may_fatal
HashGethash, keyvalue typereads_heap, may_warn, maybe may_fatal
ArrayGetForWritearray, index (I64)retained boxed Mixed cell or typed borrowed elementreads_heap, writes_heap, writes_local, alloc_heap, refcount_op, may_warn, maybe may_fatal
HashGetForWritehash, keyretained boxed Mixed cell or typed borrowed valuereads_heap, writes_heap, writes_local, alloc_heap, refcount_op, may_warn, maybe may_fatal
ArraySetarray, index, valueVoidwrites_heap, maybe alloc_heap, refcount_op
HashSethash, key, valueVoidwrites_heap, maybe alloc_heap, refcount_op
ArrayPush, HashAppendcontainer, valueVoidwrites_heap, maybe alloc_heap, refcount_op
ArrayEnsureUnique, HashEnsureUniquecontainersame containerreads_heap, maybe alloc_heap, refcount_op
ArrayCloneShallow, HashCloneShallowcontainersame kindalloc_heap, refcount_op
ArrayUnion, HashUnion, ArrayHashUnion, HashArrayUnioncontainerscontainerreads_heap, alloc_heap, refcount_op
ArrayToHasharrayhashreads_heap, alloc_heap
ArrayKeyExists, OffsetExistscontainer, keyI64 boolreads_heap
OffsetUnsetcontainer, keyVoidwrites_heap, refcount_op
ListUnpackarray value, slot listVoidreads_heap, writes_local

Ordinary ArrayGet/HashGet reads of boxed Mixed values materialize an independent zval cell, preserving PHP value semantics. Resource payloads are the intentional exception: the read retains the existing resource cell so aliases share the cursor and close/destructor lifetime, as PHP resources do. Nested assignments use the explicit *GetForWrite operations with a Mixed result for their parent read instead: the root container is COW-normalized and stored back first, then the selected stored cell is returned with a retained reference. Typed entries are promoted to boxed storage in place. The following RuntimeCall writer can therefore publish copy-on-write replacements back into the owning slot without making ordinary reads alias. The dynamic MixedArrayGetForWrite equivalent additionally COW-normalizes a runtime indexed array or associative hash, republishes a possibly split container in its cloned owning Mixed cell, and detaches the selected zval; the ordinary dynamic read clones that cell without mutating storage.

All mutating operations must preserve copy-on-write. The builder emits ArrayEnsureUnique/HashEnsureUnique before mutation unless prior ownership proofs make it unnecessary.

Associative literal spreads

An associative literal that interleaves explicit keys, spreads, and bare values is kept as an ordered ArrayLiteralMixed entry list until EIR lowering. Lowering allocates the destination hash once and visits entries in source order: keyed entries use HashSet, spread entries use HashSpread, and a bare value uses the runtime hash-append path. A bare value after a spread must get its integer key at run time because the spread’s length is not known while parsing; assigning the parser’s static cursor could overwrite a key contributed by the spread.

Spreading a hash copies its values in insertion order, preserving string keys and renumbering integer keys according to PHP unpacking. Spreading an indexed array first emits ArrayToHash, which consumes the array reference it receives and first copies each source slot with its original runtime value tag. Refcounted payloads are retained and strings are persisted. This spread path requests a Mixed-valued result, so ArrayToHash then calls __rt_hash_to_mixed, which boxes each unboxed entry, including scalar values, in a separate Mixed cell. HashSpread then copies that promoted hash into the destination. Lowering always acquires a reference for the promotion to consume, so a borrowed source (a local, a global, a property, a static) keeps the reference it started with. The promoted hash is released after HashSpread, and an owning temporary source (a call result, a nested literal) is released too, since nothing else owns it once the promotion has consumed the acquired reference. This keeps COW owners intact and avoids both double releases and leaked temporary arrays.

ArrayCloneShallow also carries the one aliasing case a by-reference builtin cannot resolve at runtime: a $replacement that IS the receiver. array_splice($a, 1, 1, $a) lowers both arguments to loads of the same slot, so the backend would pass one pointer twice and the insertion would re-read slots the removal had already overwritten ([1,2,3] came back as [1,1,1,3] instead of PHP’s [1,1,2,3,3], issue #676). ir_lower::expr::array_splice::snapshot_array_splice_self_replacement decides that on the LOWERED OPERANDS, which are already in parameter order, and emits an ArrayCloneShallow for the replacement operand before the call — codegen then sees two distinct arrays and a call that cannot alias pays no runtime compare. Operands rather than source arguments because every spelling then costs one test: positional, named in any order, and a named $replacement with $length omitted all arrive at the same operand slot, and so do the hidden temporaries a property, a static property or a container element is rewritten into. Two loads of one local slot — or one ref cell, which is how a by-reference parameter reaches the caller’s array — are the same array, and a &$x binding is followed through the function’s AliasLocalRefCell instructions, an over-approximation whose only cost when wrong is a redundant copy. The clone is an owned value, so the ordinary temporary-release machinery frees it; the rewrite runs before the receiver-widening one so the copy holds the pre-splice payload rather than a re-boxed version of it. codegen::lower_inst::arrays::lower_array_clone_shallow is the backend side, and this is its first user: until issue #676 the opcode was declared and validated but never emitted, so every target rejected it as an unsupported feature.

With a typed result, ArrayGetForWrite and HashGetForWrite are also the read side of that rule for a container element that is about to be mutated through an alias — today, the source of a by-reference foreach (issue #580). Unlike the Mixed form, the typed form takes no reference for the caller; it separates the receiver, then splits the element from any co-owner and stores the separated container back into the receiver’s element slot, so the result is owned by the parent and unique. That is what lets foreach ($a[0] as &$v) and foreach ($h['a'] as &$v) write through to their sources: the plain retaining read left the element shared, and IterStart’s own copy-on-write split then gave the loop a private copy to mutate and discard.

The two differ only in how they address the element slot. ArrayGetForWrite scales an integer key into the indexed payload, so it requires an I64 key. HashGetForWrite cannot compute an address, so it takes the matching entry’s address from __rt_hash_get (returned in x4 on AArch64, r8 on x86_64, null on a miss) and splits the container that entry holds; string and integer keys are both fine, since the lookup normalizes them. Both require an array or hash element, each split with its own runtime helper, and both keep the plain read’s missing-key warning and null-container sentinel. Every other shape — a Mixed element in particular, whose read can materialize a fresh box instead of the slot’s own storage — keeps the retaining read. The receiver split only happens for a receiver that came from a local slot, since the new container has to be published somewhere.

Because the result is borrowed, the parent’s element slot is its only owner, and the loop body can drop that parent ($a = [], unset($a)) while the iterator is still running. The by-reference foreach therefore takes a lifetime pin on the source — an Acquire marked with a Bool(true) immediate — emitted after IterStart, and releases it on every exit: the loop’s own exit block for normal termination and break, and emit_innermost_loop_cleanups for break N, return, and throw. The ordering is load-bearing in both directions. Earlier than IterStart and the extra reference makes that instruction’s __rt_array_ensure_unique split, handing the loop the private copy this whole mechanism exists to avoid; later than the last exit and the element outlives the program’s need for it. PHP gets the same effect for free: its by-reference foreach holds a reference to the iterated array itself.

PropGetForWrite is the same rule for an object PROPERTY receiver (issue #642). It splits the property’s container and stores the separated container back into the property slot, returning it borrowed. The property case was not merely losing writes like the element case: PropGet retains, so the container sat at refcount 2, IterStart’s split CONSUMED that reference, and the loop-exit release consumed a second one — taking the property’s own container to refcount 0 and freeing storage the property still pointed at. Taking no reference at all is what removes both halves: refcount 1 leaves IterStart nothing to copy, and a borrowed result has no exit release. The op covers array and hash property slots whether or not the property is declared with a type, and covers a slot promoted to a reference cell by $r = &$o->x — there the container lives inside the cell, so the split reads and republishes through the cell and every alias of the reference sees the result.

The receiver has to outlive the loop, because the borrowed container’s only owner is the property slot inside it. A receiver that names stable backing storage does that by itself: a variable or $this, optionally followed by a chain of plain declared object properties ($o->inner->x). Any OTHER receiver is a temporary the loop borrows through — an array element ($arr[0]->x), a call result ($o->get()->x), an intermediate get hook or __get returning a fresh object — and dropping it when the read takes its borrow frees the container the loop is about to iterate. Those receivers are held by the loop instead, released on the exit block and, through the loop frame, on every break, return and throw that skips it (issue #690). The gate is therefore about LIFETIME, not about spelling; Mixed and nullable receivers, packed fields and hooked or undeclared property slots still keep the retaining read.

A runtime-named property ($o->$n) takes the same path once its name has folded to a literal, which is the ordinary case: the slot the backend resolves is the one the static spelling reaches. A name still unresolved at lowering time keeps the retaining read, because the backend’s slot resolution needs it.

A receiver that is null at RUN TIME — an element read that missed, whose result still carries the element’s declared type — raises PHP’s Attempt to modify property "x" on null Error rather than being dereferenced. That is not a detail of the split: PHP evaluates a by-reference foreach source in a WRITE context, where a null receiver is fatal instead of the warning a plain read produces. It is also what lets the read describe the property’s SLOT type instead of a nullable read type, since this path never answers null.

The frontend gate in src/ir_lower/expr/property_fetch_for_write.rs and the lowering in src/codegen/lower_inst/objects/property_fetch_for_write.rs classify slots from the same ClassInfo metadata, and the backend has NO fallback: a slot it cannot split is a hard unsupported error rather than a plain read. A plain read there would be unsound, because the result is already marked Borrowed — skipping the split would leave the loop borrowing a shared container whose split inside IterStart consumes the reference the property itself holds. Failing loudly keeps the two sides from drifting apart silently.

Iterables, SPL, and Foreach

OpOperandsResultEffects
IterStartiterable valueiterator statereads_heap, maybe alloc_heap, may_deopt
IterCurrentKeyiterator statekey valuereads_heap, maybe may_deopt
IterCurrentValueiterator statevaluereads_heap, maybe may_deopt
IterNextiterator stateI64 boolreads_heap, writes_heap, maybe may_deopt
IterEnditerator stateVoidmaybe refcount_op
IteratorMethodCallobject, method metadatatyped valuemethod effects, may_deopt
SplRuntimeCallruntime helper, argstyped valuehelper effect summary

foreach over arrays/hashes uses container-specific iterator operations. foreach over objects uses iterator dispatch metadata and may call user code.

Objects, Classes, Enums, and Attributes

OpOperandsResultEffects
ObjectNew(class_id, args)constructor argsHeap(Object)alloc_heap, constructor effects
DynamicObjectNew(class_expr, fallback, required_parent, args)class-string and argsHeap(Object)reads_global, alloc_heap, may_fatal, may_deopt
PropGet(class_id, property)objectproperty typereads_heap, maybe may_deopt
PropGetForWrite(property)objectproperty type, borrowedreads_heap, writes_heap, alloc_heap, refcount_op, may_throw, may_warn, may_deopt
PropSet(class_id, property, value)object, valueVoidwrites_heap, refcount_op, maybe may_deopt
PackedFieldMixedToInt, PackedFieldMixedToFloat, PackedFieldMixedToBoolboxed value bound for a packed or extern struct field, TypeError message prefixraw I64, F64 or bool payload: only the field’s own runtime type passes (a float field also widens an int), anything else throwsreads_heap, alloc_heap, may_throw
DynamicPropGet, DynamicPropSetobject, property string/valuevalue or Voidreads_heap/writes_heap, may_deopt, maybe may_warn
NullsafePropGet, NullsafeMethodCallnullable object and metadatanullable resultbranch plus underlying op effects
MethodLookupobject, method idcallable entryreads_heap, reads_global, may_deopt
MethodCallobject, method id, argsreturn typemethod effects
StaticMethodCallreceiver metadata, method id, argsreturn typemethod effects, maybe may_deopt for late static
ClassConstantreceiverStrpure for fixed receivers, may_deopt for static
ScopedConstantGetreceiver, constant nametyped valuereads_global, maybe enum/object effects
ClassAttrNames, ClassAttrArgs, ClassGetAttributesclass metadataarrays/objectsalloc_heap, reads_global
InstanceOfDynamicvalue, runtime class-stringI64 boolreads_global, reads_heap, may_deopt

Class, interface, trait, enum, packed-class, property, method, constant, and attribute declarations primarily contribute metadata to the module. Method and closure bodies lower as normal Function values.

For a closure whose body is exactly return <expr>;, direct_closure_return_expr_type in src/ir_lower/function.rs determines the return storage type before lowering that body. When the expression is a direct user-function call, it looks up the callee in the functions signature map instead of taking infer_expr_type_syntactic’s Int fallback. It then uses eir_user_function_return_type, the same resolver as ordinary call lowering. For an inferred array or associative-array return, that resolver widens the element or value type to Mixed whenever the callee has any untyped, by-value parameter, even if the returned container does not use that parameter. This keeps the closure’s return slot aligned with ordinary call lowering. Builtin calls use the checker’s result recorded for their call span.

Calls and Callables

OpOperandsResultEffects
Call(function_id, args)normalized argsreturn typecallee effect summary
FunctionVariantCall(group, args) (reserved)normalized argsreturn typeretained in the IR vocabulary and analyses; current lowering uses Call to the public include-variant dispatcher, and codegen has no lowering arm for this op
LanguageConstructCall(name, args)normalized argsconstruct-specific returncompiler-resident construct effect summary
RuntimeCall(target, args)normalized typed operandstarget-declared returndescriptor/typed-target effect summary
ExternCall(name, args)C ABI argsextern returnconservative FFI effects
ClosureNewcapturesI64 callable descriptoralloc_heap, refcount_op
ClosureCalldescriptor/local callable, argsreturn typetarget effect summary or conservative
ExprCallruntime callee expression, argsreturn typeconservative dynamic callable effects
FirstClassCallableNewtarget metadata, optional object receiverI64 callable descriptoralloc_heap, refcount_op
CallableArrayNewreceiver/class/function partscallable descriptor or arrayalloc_heap, refcount_op
CallableDescriptorInvokedescriptor, normalized mixed argsHeap(Mixed) or typed valuedescriptor effect summary
PipeCallvalue, callablecallable returnevaluates value before callable invocation

Call argument rules are not reimplemented in each opcode. EIR lowering consumes the shared semantic planner in src/types/call_args/ and preserves the observable order:

  1. Evaluate arguments in PHP source order.
  2. Preserve named/spread checks at the PHP-observable point.
  3. Store required hidden temporaries before ABI materialization.
  4. Materialize ABI parameters in callee signature order in the backend.
  5. Avoid temp preevaluation for ref-like and mutating parameters.

Eval and Dynamic Symbols

OpOperandsResultEffects
EvalLiteralCallliteral data id plus lowered call operandsHeap(Mixed)conservative call effects until literal planning refines the path
EvalScopeGetscope handle plus global-name immediateboxed valuereads_heap, may_fatal
EvalScopeSetscope handle and value plus global-name immediateVoidreads_heap, writes_heap, refcount_op, may_fatal
EvalFunctionCallnormalized positional values plus dynamic-function metadataHeap(Mixed)conservative dynamic-call effects
EvalFunctionCallArrayargument-array value plus function-name data idHeap(Mixed)conservative dynamic-call effects
EvalFunctionExists, EvalClassExists, EvalConstantExistssymbol-name data idI64 boolreads_global
EvalConstantFetchconstant-name data idHeap(Mixed)global/heap reads, heap/refcount writes, may_fatal
EvalObjectNewconstructor arguments plus dynamic-class metadataobject or Mixedconservative construction/call effects
EvalStaticMethodCallnormalized arguments plus target data idtyped or Mixedconservative dynamic-call effects

EvalLiteralCall does not by itself require the interpreter. The target- independent planner in src/eval_aot.rs can lower the fragment to an internal EIR function, synchronize supported locals directly, use only the core eval-scope helpers, or retain interpreter fallback. See Eval Runtime Architecture.

Externs, Pointers, Buffers, and Packed Data

OpOperandsResultEffects
PtrCast(target_type)pointer valueI64pure type metadata change
PtrRead(width)pointerI64reads_heap/native memory, may_fatal
PtrWrite(width)pointer, valueVoidwrites_heap/native memory, may_fatal
PtrReadString, PtrWriteStringpointer and string/lenstring or intnative memory effects, maybe alloc_heap, may_fatal
PtrOffsetpointer, byte offsetI64 pointerpure arithmetic, but php_type tag preserved
PtrCheckNonnullpointerVoidmay_fatal
BufferNew(element_type, len)I64 lengthHeap(Buffer)allocates a separate payload and publishes a generation-safe handle; alloc_heap, may_fatal. Lowering converts a non-int length (checked int arithmetic, mixed, int|float) first, dispatching a boxed value on its runtime tag (TypePredicate): an int payload is cast exactly, a float payload is truncated with the silent FToI, and any other value is cast both to int and to float. The count becomes -1 unless IToF of it gives the same float back and it is not PHP_INT_MAX (where numeric strings saturate), so a fraction, NaN, infinity or an out-of-range value fails the runtime length validation; each path converts only the representation it runs with, so none raises a cast warning after EIR passes narrow the box to a raw scalar
BufferLenbuffer handleI64resolves generation/activity, then reads_heap; may_fatal on stale or invalid handles
BufferGet, BufferSetbuffer handle, index, valuetyped value or Voidresolve generation/activity before payload access; reads_heap/writes_heap, bounds may_fatal
BufferResolvebuffer handleI64 descriptoroptimizer-only; resolves generation/activity WITHOUT faulting and answers 0 for a stale or invalid handle; reads_heap
BufferGetResolved, BufferSetResolveddescriptor, index, value; optional true immediatetyped value or Voidoptimizer-only forms of BufferGet/BufferSet behind a hoisted BufferResolve; a zero descriptor always raises the use-after-free fatal at the access; absent immediate performs the usual unsigned bounds check, while true records a same-buffer loop proof and omits that check; reads_heap or writes_heap, may_fatal
BufferFreelocal buffer handleVoidinvalidates the descriptor before releasing the payload and clears the local; writes_heap, may_fatal on stale aliases
PackedFieldGet, PackedFieldSetpacked pointer, field metadatatyped value or Voidraw memory effects
ExternGlobalLoad, ExternGlobalStoreglobal metadatatyped value or Voidexternal state effects

Pointer and extern memory effects are conservative because they can alias data outside PHP’s heap.

Buffer properties on the method receiver can also supply a loop-invariant handle. In a loop without calls, property writes, frees or other invalidating operations, the buffer pass relocates a guaranteed named read of $this and resolves its handle in the preheader. Buffer acquire/release instructions are word operations and do not block this proof. A header read already executes on zero-trip loops. A body-only read instead gets a taken-only entry block behind a duplicate of the scalar loop condition, so an uninitialized property stays unread when the loop never enters. The property instruction keeps its original id, source span and lexical scope. Conditional reads, faulting prefixes, non-scalar header computations and header SSA values used after the loop retain their ordinary form. Generators, exception handlers, eval, asynchronous signals and modules with magic getters retain the existing conservative gates.

Output, Diagnostics, Exceptions, Fibers, and Generators

OpOperandsResultEffects
EchoValue, PrintValue, WriteStdoutvalueVoid or I64 for printoutput, value conversion effects
VarDump, PrintRvalueVoid or string/int per builtinoutput or alloc_concat
ErrorSuppressBegin, ErrorSuppressEndnoneVoiddiagnostic state effects
Warn(message_id)argsVoidmay_warn, output/diagnostic effect
ThrowExceptionexception objectterminator pathmay_throw, writes_global exception state
TryPushHandler, TryPopHandlerhandler slot metadataVoidwrites_local, writes_global exception stack
CatchBindexception object, optional localVoidwrites_local, ownership transfer
FinallyEnter, FinallyExitfinally stateVoidcontrol-flow bookkeeping effects
FiberRuntimeCallhelper, argstyped valueruntime effects, may_throw, may_fatal
GeneratorNewframe metadataHeap(Object) or generator handlealloc_heap
GeneratorYieldkey, valuesuspend/resume valuewrites_heap, may_throw, suspension effect
GeneratorYieldFromiterableyielded valuesiterator/generator effects
GeneratorReturnvalueVoidwrites generator return state

Yield and YieldFrom are ordinary EIR instructions inside a generator body. The backend emits that body as a normal Mixed-returning function on a dedicated stackful coroutine stack. GeneratorYield records the boxed key/value and calls __rt_gen_suspend; resuming supplies the instruction result. GeneratorYieldFrom drives a generator delegate through __rt_gen_delegate, while array delegation is expanded into an iterator loop during EIR lowering.

Include and Resolver Artifacts

OpOperandsResultEffects
IncludeOnceMark(label)noneVoidwrites_global
IncludeOnceGuard(label)nonebranch conditionreads_global, writes_global
FunctionVariantDispatch(group)noneVoidpure metadata for dispatcher emission
FunctionVariantMark(name, variant)noneVoidwrites_global marker state

Plain Include should not reach EIR after resolver in normal operation. If it does, lowering must produce a compiler error, not silently emit incomplete IR.

Terminators

Every block ends with exactly one terminator. Terminators own control-flow effects that cannot be represented by an instruction followed by a fallthrough.

pub enum Terminator {
    Br { target: BlockId, args: Vec<ValueId> },
    CondBr {
        cond: ValueId,
        then_target: BlockId,
        then_args: Vec<ValueId>,
        else_target: BlockId,
        else_args: Vec<ValueId>,
    },
    Switch {
        scrutinee: ValueId,
        cases: Vec<SwitchCase>,
        default: BlockId,
        default_args: Vec<ValueId>,
    },
    Return { value: Option<ValueId> },
    Throw { value: ValueId },
    Fatal { message: DataId },
    GeneratorSuspend {
        key: Option<ValueId>,
        value: Option<ValueId>,
        resume: BlockId,
        resume_args: Vec<ValueId>,
    },
    Unreachable,
}
TerminatorRules
BrDestination arg count and types must match destination block params.
CondBrcond must be I64 bool/truthiness value. Both destinations receive matching args.
SwitchCases preserve PHP switch/match semantics. Fallthrough for switch is represented by block structure, not implicit terminator behavior.
ReturnTransfers ownership of returned owned value to caller. Never functions cannot return normally.
ThrowTransfers exception object to runtime exception state and does not fall through.
FatalEnds program path; no cleanup after fatal unless explicitly represented before it.
GeneratorSuspendCaptures generator frame state and resumes at resume.
UnreachableOnly allowed after proof from Never, fatal, throw, or impossible branch.

try/catch/finally is represented as explicit handler instructions and CFG edges to landing blocks. The validator treats active exception regions as part of the control-flow contract.

Textual Format

The textual format is printer-only. It is for --emit-ir, tests, debugging, and review. There is no parser requirement in the v0.24.x track.

Required properties:

  • deterministic function order
  • deterministic block order
  • stable value numbering per function
  • source spans shown when available
  • effects printed when non-pure
  • ownership printed for values whose ownership is not obvious
  • heap subkind and PHP type shown for heap/boxed values

Using --emit-ir

--emit-ir is a diagnostic output mode:

cargo run -- --emit-ir examples/hello/main.php

The compiler runs the normal frontend order through type checking and AST optimization, lowers the optimized AST to EIR, validates the module, prints the textual format to stdout, and exits before runtime-cache preparation, assembly generation, assembling, or linking. If the source declares #[Export] functions, the cdylib call-graph safety validator also runs before the EIR is printed.

--emit-ir, --emit-asm, and --check are mutually exclusive because each is a terminal output mode for the same source file.

Example:

module target=macos-aarch64 {
  data str[0] = "hello\n"

  function main() -> Void flags(main) {
    entry:
      v0: Str php=string own=persistent = const_str str[0]
      echo v0                              ; effects: output
      return
  }
}

Example with block parameters:

function sum_to(p0: I64 php=int) -> I64 {
  entry:
    v0: I64 php=int = const_i64 0
    v1: I64 php=int = const_i64 1
    br loop(v1, v0)

  loop(i: I64 php=int, acc: I64 php=int):
    v2: I64 php=bool = icmp sle i, p0
    cond_br v2, body(i, acc), exit(acc)

  body(i: I64 php=int, acc: I64 php=int):
    v3: I64 php=int = iadd acc, i
    v4: I64 php=int = iadd i, 1
    br loop(v4, v3)

  exit(result: I64 php=int):
    return result
}

Textual syntax requirements:

  • Function header: function <name>(params) -> <IrType> flags(...).
  • Block header: <label>(params):.
  • Instruction: vN: <IrType> php=<PhpType> own=<Ownership> = <op> ....
  • Void instruction: <op> ....
  • Terminator: return, br, cond_br, switch, throw, fatal, generator_suspend, or unreachable.
  • Effects comment: ; effects: reads_heap, writes_heap, may_throw.
  • Source span comment: ; span: line:col.

Validator

Validation has two modes:

  • Structural mode: cheap, runs after builder operations and every pass that declares itself applicable to the current function.
  • Full mode: structural + dominance + ownership + effects, runs before printing for tests and before EIR backend codegen.

Structural Rules

  1. Every block belongs to exactly one function.
  2. Every block has exactly one terminator.
  3. Terminators appear only as the block terminator.
  4. Every ValueId is defined exactly once.
  5. Every InstId belongs to exactly one block.
  6. Every operand references an existing value in the same function.
  7. Every use is dominated by its definition, unless the value is a destination block parameter supplied by all incoming branches. Cross-block dominance is not required for uses in blocks unreachable from the entry, even when the definition is also unreachable. Same-block definition order, valid value IDs, operand types, and the prohibition on void operands still apply; reachable uses retain the full dominance check.
  8. Destination block argument count matches destination block parameter count.
  9. Destination block argument types match destination block parameter types.
  10. Entry block has no block parameters.
  11. IrType and php_type are compatible for every value.
  12. Void instructions cannot be used as operands.
  13. Never-typed PHP operations end in non-returning control flow.

Opcode Rules

  1. Integer ops accept I64 operands only.
  2. Float ops accept F64 operands only.
  3. String ops accept Str operands only unless the opcode explicitly casts.
  4. Array ops require Heap(Array).
  5. Hash ops require Heap(Hash).
  6. Object property ops require Heap(Object(_)), Heap(Mixed), or a dynamic receiver opcode that can validate at runtime.
  7. Mixed ops require Heap(Mixed) or Heap(Union).
  8. Callable descriptor ops require PhpType::Callable metadata even when the storage type is I64.
  9. Pointer ops require PhpType::Pointer(_) metadata.
  10. Buffer ops require Heap(Buffer(_)), except BufferGetResolved and BufferSetResolved, whose first operand is the I64 descriptor of a BufferResolve. Their optional immediate may only be true, meaning the optimizer proved the exact index nonnegative and below that descriptor’s logical length.

Ownership Rules

  1. Every owned value is consumed exactly once on each reachable path.
  2. Release, Move, and Return consume ownership.
  3. Returning an owned value transfers ownership to the caller.
  4. Returning a borrowed value requires an Acquire before Return.
  5. Borrow must not outlive the source owner.
  6. Moved values cannot be read.
  7. Persistent values cannot be released.
  8. CFG joins must merge ownership explicitly. If incoming ownership states do not match, the joined value is MaybeOwned.
  9. MaybeOwned values must be resolved before unconditional cleanup.
  10. Exception, early return, break, continue, fatal, and generator suspend paths must have balanced cleanup for values whose cleanup is still required.

Effect Rules

  1. Pure operations may not set hidden memory, output, diagnostic, allocation, or control-flow effects.
  2. Operations with output, may_warn, may_throw, may_fatal, writes_*, alloc_*, or refcount_op are observable.
  3. Passes cannot move may_throw or may_fatal operations across other observable operations unless they prove equivalent PHP behavior.
  4. alloc_concat operations cannot cross statement-boundary concat resets.
  5. writes_global invalidates assumptions about globals, statics, runtime global symbols, JSON error state, and include-once/function-variant state.
  6. writes_heap invalidates reads of possibly aliasing arrays, hashes, objects, mixed cells, iterators, buffers, generators, fibers, and callable descriptors.

Optimization Passes

After lowering and the one-shot module validation, EIR runs a fixed-point pass driver (src/ir_passes/driver.rs) before codegen. The driver is where IR-level transformations live — the rewrites the AST optimizer could not express because they need value identity, basic blocks, or dominance.

A pass implements the IrPass trait: a stable name() and a run(&mut Function, &mut DataPool) -> bool that mutates the function in place and reports whether it changed anything. The DataPool is the module’s shared literal pool, threaded through so passes that materialize new constants (the peephole string-literal fold interns the joined string) can do so; passes that need no new literals ignore it. The driver runs the applicable registered passes over each function-like body (functions, methods, closures, trampolines, invokers) repeatedly until a full sweep reports no change, capped at a fixed iteration budget. Candidate-driven passes can cheaply declare themselves inapplicable to a function; skipped passes perform neither analysis nor the debug-build post-pass validation. A pass that opts into run_after_fixed_point is removed from this sweep.

The representation-changing pipeline runs to a module-level fixed point: optimize_module partitions those post-fixed-point passes out, then optimize_module_to_fixed_point interleaves the cross-function small-function inliner with the remaining per-function passes and repeats the round until neither layer changes anything (capped by a separate module-iteration budget). Interleaving lets the two feed each other — inlined bodies expose new constants and dead code for the per-function passes, and the simplified functions expose new (smaller) inline candidates. The first round reproduces the prior “inline once, then optimize” behavior, so later rounds only optimize further and never change semantics. After optimize_module_to_fixed_point returns, the partitioned ownership finalizer runs on its own. See Fresh Mixed Assignment Transfer.

In debug and test builds (debug_assertions), the driver re-validates the function with validate_function after every pass that runs and panics — naming the offending pass — if it produced malformed IR. A pass skipped by its applicability predicate cannot mutate the function. The same builds panic if a pass set fails to converge within the cap, which only happens for a non-converging pass bug. Both guards compile out of --release, where hitting the cap simply stops and proceeds with the current IR.

Mutating passes share the use-rewriting helper replace_all_uses (src/ir_passes/rewrite.rs), which redirects every use of a value — across instruction operands and all terminator slots — to a replacement, leaving definitions (block parameters and instruction results) untouched.

Identity Arithmetic Folding

The identity arithmetic transform (src/ir_passes/identity_arith.rs) folds algebraic identities on integer and float arithmetic/bitwise operations using two dominance-safe, validator-clean rewrites:

  • Fold-to-operand: when the result equals an existing operand x (x + 0, x * 1, x | 0, x << 0, x & x, x / 1, x * 1.0, …), the instruction is neutralized to nop and its result uses are redirected to x. x was already an operand, so it dominates every use.
  • Fold-to-zero: when the result is the integer 0 (x ^ x, x - x, x * 0, x & 0, x % 1), the instruction is rewritten in place to const_i64 0, keeping the same result value id so no use-rewrite is needed.

Only PHP-equivalent identities are folded. Integer x / 0 and x % 0 are left to trap at runtime, and float additive-zero and x * 0.0 are excluded because signed zero and NaN make them observable. Fold-to-operand chains within one sweep (a = x + 0; b = a * 1) are resolved transitively so a neutralized, dead value is never used as a replacement target.

Peephole Patterns

The peephole transform (src/ir_passes/peephole/) applies local rewrites to the shape of lowered EIR. Each pattern collects rewrite intents into a shared accumulator (a fold-to-operand RAUW map, instructions to neutralize, and str_concat instructions to convert to interned const_str), and a single apply phase commits them, sharing replace_all_uses, resolve_chains, and neutralize_to_nop with the identity pass.

  • Box/unbox cancellation — unbox(box(x)) → x, only for scalar (NonHeap) payloads with matching ir/php type, so boxing a heap value (where unbox extracts a borrowed reference) is never folded.
  • Redundant move/borrow — these pure forwarding ops fold to their operand only when the result shares the operand’s ownership and type, so RAUW cannot shift cleanup responsibility. (Current lowering does not emit them; the rewrite keeps them correct if it ever does.)
  • Load/store forwarding and dead stores — a per-block value-numbering tracks the value resident in each scalar (NonHeap) PhpLocal/HiddenTemp/ BorrowedTemp/NamedArgTemp slot. A load_local of a slot with a known resident value folds to it; a store_local of the resident value is dropped. Any instruction naming the slot (unset, ref-cell promote/alias/release/store) invalidates it, and state resets at block boundaries — writes through aliases are never crossed. By-ref locals use ref cells, not plain load/store, so plain scalar slots are not aliased.
  • Paired acquire/release cancellation — an acquire whose result is used exactly once, by its release, drops both. The single-use guard makes this refcount-neutral on every path regardless of distance between the two ops. An acquire carrying an immediate is a lifetime pin and is exempt: its result is deliberately never read, because the reference exists so the value survives an interval in which another owner may release it, and that raised refcount is exactly what the program observes.
  • String-literal concat folding — str_concat(const_str a, const_str b) interns a ++ b into the data pool and becomes a single const_str marked persistent so cleanup never frees the literal. Nested concats converge across driver sweeps.

Scalar Promotion Pass

After the peephole pass, src/ir_passes/mem2reg.rs promotes eligible scalar PHP locals into SSA values. It replaces their loads and stores, and passes loop-carried or joined values through block parameters and incoming branch arguments. The eligibility and register-allocation boundaries are described in Scalar Local Promotion.

Immutable Integer-Local Loads

The immutable-local transform (src/ir_passes/immutable_local_loads.rs) refines the effect of a concrete integer load_local from a frame read to pure only when the slot cannot change for the lifetime of the function. It accepts two closed-world cases:

  • an unwritten, non-reference, non-variadic integer parameter, or the top-level $argc slot; and
  • a concrete integer PHP local with exactly one store in the entry block, where that store dominates every load and the entry block has no predecessor.

Hidden temporaries, Mixed storage, reference cells, static locals, multiple or non-entry stores, and any alias or otherwise unknown local-slot operation fail closed. The pass changes only the load’s effect metadata; its result value, storage type, and ownership are unchanged. This gives CSE and LICM a sound way to reuse or hoist integer reads that lowering represents through frame slots.

The analysis starts from effectful integer loads that could actually be refined. Already-pure loads and functions without eligible loads return before dominance analysis. The default issue-623 pipeline schedules this pass only for functions that also contain boxed checked integer arithmetic, keeping unrelated functions out of both the analysis and its debug-build validation.

Checked Integer Sink Specialization

The checked integer sink transform (src/ir_passes/checked_int_sink.rs) removes the transient Mixed allocation from checked integer add, subtract, and multiply when the producer’s complete use graph observes only a PHP integer. It rewrites ichecked_add/ichecked_sub/ichecked_mul to the corresponding ichecked_*_to_int operation, changes the result to non-owning I64, redirects integer casts to that value, and removes the now-redundant acquire/release scaffolding.

The proof accepts explicit integer casts, stores into concrete integer locals or integer ref cells, and unpinned acquire/release chains. A terminator use, output, call, comparison, Mixed store, lifetime pin, or unknown opcode rejects the whole candidate, preserving the original boxed result whenever overflow promotion can still be observed. Before running the full use-graph proof, the driver requires an integer cast/store whose value traces through transparent acquires to checked arithmetic. The pass also returns before building its use graph when a function contains no checked-arithmetic candidate.

A final phase (src/ir_passes/checked_int_sink/bound_comparisons.rs) rewrites php_rel_cmp(L, ichecked_sub(X, c)) Slt (or the mirrored Sgt with the bound on the left) to icmp against a cast-mode ichecked_sub_to_int when L is an integer, c is a literal in 1..=1024 and every other use of the bound is a release. PHP evaluates an overflowing X - c as (double)X - (double)c, which for such c rounds to exactly -2^63, and the cast mode converts that to PHP_INT_MIN; L < PHP_INT_MIN and PHP’s (double)L < -2^63 are both false for every integer. A non-strict comparison is left alone: (double)L <= -2^63 holds for every L within 512 of PHP_INT_MIN.

Codegen keeps the in-range path in scalar registers. On signed overflow it recomputes the operation as a double and applies the shared PHP float-to-int conversion, so removing the box does not change PHP’s overflow semantics. The typed opcode is implemented for macOS AArch64, Linux AArch64, and Linux x86_64. FreshMixedTransfer runs only after this specialization has finished, so a later ownership rewrite cannot hand the sink a new integer-only use graph. See Fresh Mixed Assignment Transfer.

Scalar Buffer Element Increments

++ and -- of one element of a scalar buffer<T> (an integer width, float or f32) lower in src/ir_lower/stmt/buffer_element_updates.rs to a typed buffer_get, one step and a typed buffer_set, in both IR modes. The parser writes a statement update as $b[$k] = (new = ++(old = $b[$k])) and an expression update as old = $b[$k]; new = old + 1; $b[$k] = new over generated locals; lowered generically, the working local is mixed because an int ++ may promote to float, so each update boxed its seed, ran str_inc_dec and copied the result with mixed_clone (or boxed new through ichecked_add).

An int element steps with ichecked_add_to_int/ichecked_sub_to_int and the true cast immediate: in range it is one flag-setting add, and on overflow it recomputes the double and applies the shared PHP float-to-int conversion, which is what buffer_set stored from the boxed float before. A float element uses fadd/fsub with 1.0. The statement paths decide from the loaded handle or the receiver’s property type, the same facts that select buffer_set; the expression path decides at the element read, after the prelude has stabilized the receiver and the key, from the type load_local will give the receiver (a nullable receiver keeps the generic path), then reads and steps the element where the prelude did, before the write loads its handle or receiver, so user code run by the read is seen by the write as before. The key, the receiver lease, the bounds and freed-buffer checks and the width conversion of the store are the ordinary write path’s. Only the value of a prefix expression on an int element stays a checked mixed (ichecked_add, emitted after the store), because PHP’s prefix value is the promoted float past the integer range; the store beside it is still typed. Loops of such updates now pass the buffer_access allowlist, so their handle resolution is hoisted like any other buffer loop.

Bounded Mixed Counters

src/ir_passes/bounded_mixed_counter.rs considers ordinary Mixed locals updated by increment/decrement and seeded with concrete PHP integers, or with checked or mixed +, -, * of raw integers and of the counters proven with them ($j = $i + 1). A seed producer becomes the matching ichecked_*_to_int on the trial and, like an update, must be rewritten to unchecked arithmetic. A comparison of a counter with a checked +/- of raw integers ($i < $n - 1) stays a php_rel_cmp, since the bound may be an overflowed float; IntegerRange bounds the counter through it only on the outcome the overflow direction cannot produce. A complete storage/use scan excludes aliases, other types, pointer escapes, parameters, static/global storage, eval, suspension and exceptional CFGs. Direct-call consumers are accepted only at physical, by-value mixed parameter positions recorded from the module before function optimization. The snapshot uses the backend’s PHP canonical name keys and first-match priority (free functions before closures), retaining an ineligible first target instead of selecting a later one. Call targets, signatures, closure eligibility and return representations remain unchanged.

The pass scalarizes a private trial function, removes transparent numeric owner copies with the shared rewrite helpers, and requires successful SSA promotion. The existing integer-range analysis must rewrite every new checked update to an unchecked integer operation before the trial can replace the original function. Strict integer bounds can establish that the update fits even at a runtime limit of PHP_INT_MAX or PHP_INT_MIN. An inclusive bound, an unrelated loop condition, a late type change or a missing proof keeps the boxed path and PHP float promotion. The analysis is capped at 16 candidate locals in functions of at most 4096 instructions. It runs only with IR optimization enabled and uses the existing scalar and call-argument backends for every supported target.

Boxed Numeric Chain Fusion

src/ir_passes/checked_numeric_chain.rs fuses compatible checked arithmetic chains that are consumed only as integers, avoiding intermediate Mixed boxes. It runs after checked integer sink specialization and before constant folding.

Constant Folding

The constant folding transform (src/ir_passes/const_fold.rs) folds operations whose operands are all compile-time constants into a single const_* instruction, rewriting the instruction in place and keeping its result value id (no use-rewrite needed). A single forward scan over the instruction table tracks the constant carried by each value — constants in SSA are program-wide, so one sweep discovers constant operands and collapses chains like (2 + 3) * 4 at once. It folds integer iadd/isub/imul, bitwise and/or/xor, in-range (0..=63) ishl/ishr_a, unary ineg/ibit_not, float fadd/fsub/fmul/fneg, signed icmp, and the is_null/is_truthy predicates.

Each fold reproduces exactly what the op’s lowering computes at runtime, so the compiled result is unchanged: integers wrap at 64 bits (matching native add/sub/mul), shifts fold only for in-range counts, and floats use exact IEEE-754. The trapping integer division/modulo, float division (PHP’s DivisionByZeroError versus IEEE infinity), and NaN-sensitive fcmp are left unfolded, the same conservatism as identity arithmetic.

Propagation through local slots is realized by composition with the peephole’s scalar load/store value-numbering, which forwards a constant stored to a local onto its later load_local uses; this pass then folds the resulting constant-operand operation. Together, under the fixed-point driver, they form per-block constant propagation over EIR value ids and local slots. Constants surfaced by identity arithmetic feed it too ($argc * 0 → const_i64 0 → downstream folds), and dead constant producers are cleaned up by dead instruction elimination.

Common Subexpression Elimination

The common subexpression elimination transform (src/ir_passes/cse.rs) removes a pure computation whose identical predecessor already dominates it, redirecting the redundant result to the earlier value (RAUW) and neutralizing it to nop. It covers per-block and cross-block redundancy in one dominator-tree value-numbering traversal: a scoped hash table maps each pure instruction’s key (op, result type, immediate, canonicalized operands) to the value that first computed it. Because blocks are visited in dominator-tree preorder, the table holds exactly the definitions that dominate the current block — its own earlier instructions plus those of dominating blocks — so a match is always a dominating value, making the redirect dominance-safe. Entries a block inserts are removed when its whole subtree is done.

Only pure (Effects::PURE) instructions that have at least one operand and whose result is NonHeap or Persistent are eligible: purity makes the value a function of its operands alone (no memory/state dependence, no fault), and the ownership restriction keeps the rewrite refcount-neutral — the same value class dead-instruction elimination is allowed to drop. Since SSA operands are equal-by-value, identical pure ops on identical operand values compute identical results. Nullary constant and address materializations (const_*, data_addr) are deliberately not deduplicated: they are cheaper to rematerialize at each use than to keep live across the span, so CSE only targets computations. Functions with exception handlers are skipped: their handler blocks are reachable through implicit edges absent from the terminator graph, so a terminator-graph dominator can be bypassed at runtime by a throw, which would make a cross-block redirect unsound — the same restriction branch simplification uses. CSE uses the Dominance Analysis and shares cfg::has_exception_handlers with branch simplification.

Loop-Invariant Code Motion

The loop-invariant code motion transform (src/ir_passes/licm.rs) moves a pure computation whose operands do not change across a loop out of the loop body into the loop preheader, so it runs once instead of per iteration. It builds the loop forest on the dominator tree, then for each loop grows the invariant set to a fixed point: an instruction is invariant when each operand is defined by another instruction being hoisted from the same loop or has a definition that dominates the preheader.

Only pure (Effects::PURE) instructions with a NonHeap/Persistent result are eligible. Purity means the value depends only on the operands and the op neither reads mutable state nor faults, so evaluating it once in the preheader, unconditionally even when its original block ran only on some iterations, is safe (no speculation hazard); the ownership bound keeps the move refcount-neutral. Standalone nullary constant/address materializations move only from blocks that dominate every loop latch, including mandatory bounds and steps consumed by varying comparisons and updates. Conditional materializations stay near their uses to avoid inflating loop live sets and zero-trip entry work; dependencies of an otherwise invariant computation still accompany that computation. This profitability restriction does not apply to proven-immutable local loads. Hoisted instructions follow dependency order rather than instruction table IDs, which can differ after CFG rewrites. Loops are processed innermost-first with moves applied immediately, so a value invariant in several nested loops reaches the outermost preheader in one run. Instructions are relocated between blocks’ instruction lists and their result ValueDefs (block + index) are recomputed once at the end so the value table matches the new layout. Loops without a detected preheader, and functions with exception handlers, are skipped. Scalar locals promoted to SSA can now expose invariant expressions across loop back edges; remaining slot-backed values retain their memory-read restrictions.

Raw integer-slot checked arithmetic retains its original execution point because its overflow path can terminate the program. Only explicit integer-cast overflow conversions are safe to speculate; proven non-overflowing operations can move after integer range analysis removes their checks.

Canonical Loop Optimization

induction.rs recognizes scalar integer header parameters with a preheader value and a common nonzero constant-step update on every latch. Parameters must have both I64 storage and PHP Int metadata. Booleans and other PHP types represented as I64 are deliberately excluded from induction summaries and counter coalescing; ordinary interval propagation and comparison-edge proofs still apply to them. Integer range analysis uses the same recognition before proving overflow safety. loop_optimize.rs first replaces self-carried scalar parameters with their preheader inputs, then coalesces equivalent inductions with equal initial values, steps, and overflow semantics. Checked recurrences require matching conversion modes and a retained check that dominates the removed check, preserving fatal ordering. The pass removes duplicate header parameters and edge arguments, canonicalizes reversed integer comparisons, and folds integer self-comparisons. Recognition alone never licenses replacing checked arithmetic with wrapping arithmetic.

Branch simplification composes SSA arguments through empty forwarding blocks only when their parameters do not escape the outgoing branch. Each parallel incoming edge is substituted independently, and forwarding cycles remain intact. loop_cfg.rs merges an unconditional successor with a single predecessor only within the same innermost loop. Loop headers survive, and shared continue targets are not duplicated. Parameter uses are redirected before moving instructions; removed parameters become unused Nop definitions so IDs remain stable. Instruction ValueDef positions are repaired after merging. These rewrites skip exception handlers, and loop merging and induction coalescing also skip suspended generators.

Dead Instruction Elimination

The dead instruction elimination transform (src/ir_passes/dead_inst.rs) removes result-producing instructions whose values are not live over the CFG and whose effect metadata says they are pure. It computes liveness with successor live-in sets, initializes each block’s backward walk with those live-out values plus terminator uses, and then neutralizes dead instructions to nop.

Neutralization preserves instruction/result value table slots, so the validator does not need value renumbering or block-list surgery. Read-only, allocation, mutation, refcounting, output, warning, fatal, throw, and deopt-capable instructions stay intact; dead read elimination is deferred until a later pass can prove equivalent PHP and ownership behavior. Dead chains in one block collapse during the backward walk; chains that cross block boundaries converge through the fixed-point pass driver after liveness is recomputed.

Dead Store Elimination

The dead store elimination transform (src/ir_passes/dead_store.rs) removes store_local instructions whose stored value is never read on any path before the slot is overwritten or the function exits. Unlike dead instruction elimination, which works at SSA-value granularity, this pass reasons about local slots: it runs a backward dataflow that gens a slot at each load_local and kills it at each store_local, iterating to a fixed point so live-out is the union of successor live-in sets. A store is dead when its slot is not live immediately after it, so an earlier store with no intervening read also dies.

The pass is restricted to slots that are (1) ordinary PhpLocals, (2) of a non-refcounted storage type (!php_type_needs_lifetime_tracking), (3) named only by plain load_local/store_local, and (4) never address-escaped by reference. The refcounting restriction is the key correctness boundary: assignment lowering wraps a refcounted slot’s store with separate acquire/release instructions and releases the prior occupant, so dropping the store_local alone would leak the acquired value. Scalar slots carry no such ownership ops and their scope-exit cleanup is a no-op, so removing a dead scalar store is refcount-neutral. Any other slot-naming op (ref-cell promote/alias/release, unset_local, zero_local_slot, static-local or global access) makes a slot ineligible because it could read or alias the slot in a way the pass does not model.

Condition (4) is subtle: a by-reference call argument (new Box($v) for a public int &$value constructor) or a by-reference closure capture (use (&$x)) is lowered as an ordinary load_local, and codegen later resolves that argument value back to its defining load_local and passes the slot’s address — so the callee reads or mutates the slot through an alias that forward load_local-only liveness cannot see. Because a single-function pass has no callee signatures (which parameters are by-reference), the pass uses a conservative default-deny allowlist: a slot is excluded whenever any of its load_local results is consumed by an instruction that is not a proven value-only consumer (arithmetic, comparison, cast, output, store, string, or refcount op). Operand zero of prop_get and method_call is a value-only receiver on every lowering route. Later method operands remain conservative, even when they repeat the receiver value. Other call operands, object construction, closure capture, property/array access, and any future opcode are treated as possible by-reference escapes. Feeding a load into a value-only op first is safe because codegen only traces a direct load_local back to a slot.

This complements the peephole pass’s per-block, value-equality store forwarding (which drops a store of the value already resident): dead store elimination is liveness-based and crosses block boundaries, so it removes a store of a different value whose result is never observed. Stores are neutralized to nop; a pure value left feeding a removed store is then cleaned up by dead instruction elimination on a later driver sweep.

Branch Simplification

The branch simplification transform (src/ir_passes/branch_simplify.rs) simplifies the CFG in four ways:

  • Constant-condition folding — a cond_br whose condition resolves to a constant (const_bool, const_i64 via PHP truthiness, or const_null) becomes an unconditional br to the taken edge. A switch on a const_i64/const_bool scrutinee folds to a br to the matching case (or the default). A while (true) loop, for example, lowers to a constant cond_br that this fold collapses.
  • Empty-block jump threading: compose each incoming edge’s arguments through non-entry blocks containing only unused Nops and an unconditional branch. Parameters must not escape the forwarding terminator, and cyclic forwarding chains remain unchanged.
  • Loop block merging: fuse single-predecessor unconditional successors within the same innermost loop, preserving headers and shared update targets.
  • Unreachable-block neutralization — blocks no longer reachable from the entry have their terminator set to Unreachable and their instructions rewritten to nop.

Like the other passes, unreachable blocks are neutralized in place rather than physically removed. The validator requires block.id == index. Cross-block dominance is checked only for reachable uses; dead continuations still undergo value-ID, type, and same-block definition-order checks. Neutralizing clears every terminator and instruction operand so the block stays valid, while the block, value, and instruction table slots keep their indices. This avoids renumbering and, crucially, keeps try handler block-id tokens (encoded in try_push_handler immediates) correct. Functions that use any exception-handling opcode are skipped wholesale, because their handler blocks are reachable through implicit edges absent from the terminator graph, so terminator-only reachability could wrongly neutralize a live handler. Forwarding parameters are substituted on each incoming edge; parameters with escaping uses keep their defining block. Merged block definitions receive updated placement metadata. Cross-block cascades converge through the fixed-point driver.

Concat Scratch-State Optimization

The concat scratch-state transform (src/ir_passes/concat_reset.rs) removes or coalesces statement-boundary concat_reset instructions when CFG dataflow proves _concat_off is already at the frame base or post-dominated by a later reset across concat-inert operations. Every function prologue captures _concat_off into its stack frame’s concat_base_offset, and concat_reset rewinds _concat_off to that frame base; AST-to-EIR lowering already emits acquire / str_persist for any string that outlives a statement before emitting the original statement-boundary concat_reset.

The pass runs two greatest-fixed-point analyses over reachable blocks:

  1. Forward clean-state elimination tracks whether _concat_off is guaranteed to equal concat_base_offset on entry to each block and across each instruction. Function entry starts clean; try_push_handler targets start dirty because exception unwinding can arrive from a dirty statement inside the try region; concat_reset establishes the clean state; instructions on an audited concat-inert allowlist (scalar and checked arithmetic, destructor-free local/container operations, by-value concrete array iterators, and non-string scalar coercions) preserve it; and any instruction that can allocate, read, modify, or observe concat scratch (including alloc_concat, bool_to_str, output, user/runtime/extern calls, eval, Mixed-to-string coercions, and potentially destructor-bearing stores or releases on objects, callables, or resources) marks the state dirty. Any concat_reset reached in a clean state is neutralized to nop, removing statement-boundary resets inside arithmetic-only and concrete-array loop bodies while keeping any required reset before the loop.
  2. Backward unobserved-reset coalescing tracks whether every path from a program point is guaranteed to hit another concat_reset before any non-inert instruction, may_throw / may_fatal instruction, or function exit. An earlier concat_reset whose outgoing paths all hit a later reset without intervening observation is neutralized to nop.

Small-Function Inlining

src/ir_passes/inline.rs is a cross-function, module-level pass (not a member of the per-function IrPass set) that splices a small callee’s body into its caller at the call site. The original call is removed; the callee’s blocks are transplanted with fresh ids, arguments are bound into the remapped parameter slots with store_local, the caller block jumps into the transplanted entry, and each callee return becomes a br to a fresh continuation block that carries the result through a block parameter.

A callee is inlined only when it is at most 24 non-nop instructions, has a 0-parameter entry block, contains no exception-handling ops, is not a generator/fiber wrapper, and is non-recursive — directly or mutually, via a call-graph cycle analysis that excludes any function reachable from itself (a per-caller fuel cap backstops termination). Eligibility is further restricted to a provably ownership-safe destructor-free boundary and body (scalars, strings, and arrays/unions of destructor-free types; no by-ref/variadic params and no ref-cell/static/global/capture locals).

Correctness across the boundary is preserved without an explicit epilogue: the splice replaces return with br, bypassing the callee’s implicit codegen epilogue cleanup, so the transplant reproduces that cleanup’s per-slot decisions — parameter slots and directly-returned slots become epilogue-excluded BorrowedTemp (matching the callee, whose argument is borrowed and whose return ownership is moved to the caller), while ordinary refcounted internal locals stay PhpLocal and are still freed by the host epilogue. The destructor-free restriction makes the one residual difference — deferring those internal frees to the host epilogue — unobservable (no __destruct, no array identity), so reference-counting and copy-on-write behaviour are byte-for-byte preserved. Two call-site guards complete correctness: arguments must bind to parameter slots without coercion (matching storage types, so spread/named-boxed-mixed and int↔float sites stay ordinary calls), and any string argument must come from a non-scratch source (const_str/load_local), because the spliced body runs the callee’s statement-boundary concat-buffer reset in the host frame and would otherwise free an in-flight scratch string argument. Call-site name resolution uses snapshots taken before mutation, so the rewrite loop never aliases the module while holding a function. Current lowering emits call with a Data immediate, including calls to public include-variant dispatchers. The inliner’s resolver also retains support for reserved function_variant_call references; that opcode is not emitted by the current AST-to-EIR path.

Small instance methods also participate through src/ir_passes/inline/methods.rs. Its MethodTargets snapshot resolves a method_call only for a plain non-nullable user-class receiver when the class or method is final, or the method has no vtable slot. An instantiated generic receiver with a vtable slot keeps a real call because variance can change its actual instantiation. The borrowed $this parameter is the only object exemption from the ordinary boundary rules. The body must be frame-independent, and literal parameter guards, by-reference arguments, variadics and backtrace-visible method frames retain their calls. A splice checks the receiver for null after argument binding, except when it is the host method’s own $this. Transplanted method locals receive generated names so they cannot enter the caller’s PHP variable scope.

Each transplanted instruction keeps its lexical class in Function::inlined_scopes when that class differs from the host’s lexical_class. Codegen reads this through the per-instruction FunctionContext::lexical_class(), preserving private-property resolution even in another class or a free function. Passes copying a body must carry these entries to the corresponding new instruction IDs; the inliner and tail-call splicer both do so. Passes moving instructions keep their IDs, and property-read reuse requires equal scope entries before equating separate prop_get values.

Fresh Mixed Assignment Transfer

FreshMixedTransfer (src/ir_passes/fresh_mixed_transfer.rs) is the ownership finalizer. It implements IrPass, and run_after_fixed_point makes optimize_module partition it out of optimize_module_to_fixed_point. The inliner and the per-function passes, including checked integer sink specialization, run to the module fixed point first. optimize_module then runs the finalizer phase once, through the ordinary per-function sweep. A change inside that phase repeats the finalizer until a sweep reports no change. The repetition stays inside the finalizer: the inliner and the checked-integer sink have already finished. --ir-opt=off skips optimize_module, so the copy lowered for the assignment stays in the module.

The rewrite removes a MixedClone of a fresh owned ICheckedAdd, ICheckedSub, ICheckedMul, or dynamic MixedNumericBinop add/sub/mul result. The dynamic helpers always box a new integer or float result, including PHP overflow promotion; Pow and other producers remain outside this proof. The source must be an owned Heap(Mixed) value with exactly two uses: the clone, and the next non-nop instruction in the same block, which must be a Release of the source with no immediate. The producer has to precede the clone in that block. The clone result is also an owned Heap(Mixed) value. Uses of the clone result are redirected to the source, and the clone and that release become nop. The arithmetic operation stays in place, so an overflow still promotes through the boxed result. Borrowed sources, acquired sources, shared sources, any other extra observation, and a release in another block keep the copy.

That order is required. While the clone is present it is a Mixed use, so the sink leaves the producer boxed. Removing the clone earlier would make the remaining integer cast or store look like an integer-only use graph, and the next sink sweep would rewrite ICheckedAdd / ICheckedSub / ICheckedMul to the corresponding IChecked*ToInt result. The transfer therefore runs where that rewrite can no longer see it. The same split is summarized in Optimization and codegen controls.

Identical Lowered-Body Folding

Monomorphization leaves duplication behind: a T-independent generic function instantiated once per concrete object class lowers to the same machine code every time, under a different name. elephc does not merge those bodies in EIR. An EIR-level merge compares a projection of the program, and every projection used so far — structural identity under a collapsed object shape, with nominal-use refusals bolted on — kept missing ways the backend consults the exact type after the projection claimed two bodies equal: property slot offsets keyed by the receiver’s class, get_object_vars baking the lexical class id, __toString casts resolved through a Cast, static-local storage symbols derived from the instantiated name, and class-naming error strings. Getting each of those wrong is a silent miscompilation, so the projection approach was removed entirely.

The active mechanism is emission-time folding (src/codegen/block_emit.rs). After the EIR is final, every candidate body — free functions and class methods whose name carries a type-argument list — is lowered alone into a fresh emitter with its own literal pool, so the text depends only on the body and the module’s read-only context, never on label counters or caches the whole-module emission had reached. The isolated lowering’s text AND its data section are shape-normalized:

  • every symbol naming the candidate (entry, epilogue and exceptional-cleanup suffixes, the raw name the source-map marker quotes) is replaced by a placeholder — except inside .ascii payloads, where the candidate’s own name is real string data whose bytes the program can print;
  • internal labels are renamed by definition order (the Linux assembler spells them .L_eir_… where Apple spells them L_eir_…);
  • the isolated data section’s emitted text is appended to the comparison, so two bodies whose literal pools intern different contents under the same label are refused — this is what keeps a folded gettype from returning the wrong word.

Bodies whose normalized text is byte-equal share one emitted body: the survivor keeps the full body and each victim’s entry becomes a jump to it (emit_folded_body). The victim keeps its EIR function, so direct calls still resolve the instantiation’s own parameter types and pick their own coercion from them — the property that makes an int/bool fold sound where a name redirect would not be. The victim’s .globl entry stays addressable through the thunk, so exports, vtables, callables and reflection all keep resolving it.

Two state classes are refused up front: a body with a StaticLocal or GlobalAlias local (its storage symbol is derived from the instantiated name, and the erasure would fuse two counters into one), and the usual wrapper shapes (main, generators, closures, synthetic and invoker bodies).

Because the comparison runs on the backend’s final output, it is sound by observation: any lowering that consults the exact type — a boxing tag, a cast’s resolved symbol, a class id, a class-naming error string — shows up in the emitted text or data and refuses the fold. The guard cannot rot as the lowering grows. What it costs is compile time (every candidate is lowered twice) and IR memory (duplicate functions are not freed), while the emitted size win is what remains: identical bodies collapse to one body plus a jump each.

Class-naming error data makes whole method slots unfoldable in practice: a typed property read, isset, and an array append all carry the uninitialized-access fatal (Typed property Box<User>::$value …), and a dynamic property access on a mixed receiver carries the lexical-class private-property message. Constructors and class-independent stores carry no such data and fold. --report-generics measures the residual opportunity per slot (the assembly oracle), so a template’s folding headroom is visible rather than guessed.

Why the earlier EIR shape merge was rejected

The earlier EIR proposal used shape() and has_nominal_use to decide which type information could be erased. Those mechanisms were removed: EIR shape equality did not prove that the backend would emit equivalent code. The active merge compares normalized assembly bodies, including type-dependent tags and lowering choices.

Int/Bool/Float illustrate why the rejected shape approach was insufficient. The backend stores scalars in raw untagged I64 slots and bakes the runtime value tag only when boxing a value into a Mixed cell (codegen_support/value_boxing.rs), reading it from the exact PhpType: an int boxes with tag 0, a bool with tag 3. The tag is not an EIR immediate, so two bodies differing only by the scalar’s static type compare equal under a collapsed shape while emitting different boxing code — the survivor’s tag would be applied to the other instantiation.

The blocker is wider than the boxing tag, and it splits into two classes:

  • Constant divergence — the same lowering arm bakes a different constant: the boxing tag above, static-store tags, a search’s expected_tag, array_fill element tags. value_php_type is read and branched on at over a hundred lowering sites, and runtime_value_tag is baked at about ninety codegen points.
  • Structural divergence — a DIFFERENT lowering arm is selected from the exact type: truthiness as a cast destination (conversions.rs: result_php_type == Bool ⇒ lower_is_truthy), and comparison-arm selection (comparisons.rs: a comparison is lowered as truthiness when either side is Bool). So clamp<T>(T $v): T { if ($v == 0) … } compares equal under a collapsed shape while emitting different comparison code, with no boxing anywhere.

A class-2 divergence cannot be repaired by supplying a tag at runtime: the fix would be to emit BOTH arms under a runtime branch, which reintroduces the dynamic-type dispatch the AOT thesis exists to delete. And the distinction is load-bearing PHP semantics, not a lowering artifact: the runtime must remember that a stored false is a bool, and raw 0 stringifies as "0" for an int and "" for a bool (strings.rs).

Not every exact-type consultation implies different machine code — an FFI (Int, Bool) pair forwards the payload, and raw int and bool call operands materialize identically — but the sites that DO differ are the ones the EIR cannot see. Two further constraints close the door on a naive widening:

  • Identical bodies do not imply interchangeable signatures. A direct call resolves the callee’s parameter types at codegen time and picks its coercion from them, so redirecting an int instantiation to a bool survivor would change the CALLER’s coercion even when the callee’s instructions happen to match. The object merge is safe precisely because object coercion is class-independent; a scalar merge is not. Concrete PHP signatures must therefore be retained independently of shared machine-code ownership.
  • The only sound widening is post-lowering identical-body folding: compare what the backend actually emits — not the EIR, not a projection of it — so the differing boxing tag, a resolved cast symbol and the per-function cleanup obligations are all visible. That is the LLVM MergeFunctions analogue, and it is what elephc implements (the section above): candidates are lowered into isolated emitters, the emitted text and data are shape-normalized, and byte-equal bodies fold into one body plus a jump while the victim’s EIR function — and therefore its concrete signature for caller-side coercion — stays intact. The measured win is bounded to the int/bool pair: Float lowers to F64 while Int/Bool lower to I64. --report-generics reports the foldable count measured against the emitted output, so the measurement cannot rot as the lowering grows: a future scalar-consulting lowering shows up as a refusal, not as a silent miscompile.

Dominance Analysis

src/ir_passes/dominance.rs is a read-only sidecar analysis (like liveness, not a driver transform) that builds each function’s dominator tree, the foundation for the dominance-aware cross-block passes that follow (common-subexpression elimination, natural-loop detection, loop-invariant code motion).

compute_dominance uses the Cooper–Harvey–Kennedy iterative algorithm: it walks reachable blocks in reverse postorder and recomputes each block’s immediate dominator as the intersection of its already-processed predecessors’ idoms — a two-finger walk over postorder numbers — until a fixed point. It converges for arbitrary CFGs and is fast on the small functions EIR produces.

The resulting DominanceInfo answers immediate_dominator, reflexive dominates / strictly_dominates, dominator-tree children (top-down traversal), nearest_common_dominator, and is_reachable. Only blocks reachable from the entry participate; unreachable blocks (which branch simplification neutralizes in place but leaves in the table) are excluded from the tree and answer false/None. The internal idom table is self-rooted at the entry so the intersect and dominance walks terminate without special cases. The analysis uses the shared cfg::predecessors helper.

Loop Analysis

src/ir_passes/loops.rs is a read-only sidecar analysis that builds the function’s natural-loop forest on top of the dominator tree, the foundation for loop-invariant code motion and other loop optimizations.

compute_loops(func, &dominance) first finds back edges — CFG edges latch -> header whose target dominates their source — so loop detection is a thin layer over dominance. Back edges sharing one header form a single [NaturalLoop] with multiple latches. The loop body is the header plus every block that can reach a latch without passing through the header, found by a backward walk over reachable predecessors that stops at the header.

Each NaturalLoop exposes its header, latches, sorted blocks (with a binary-search contains), parent and depth in the nesting forest, and its preheader. Nesting is by block-set containment: loop A is nested in B when A’s header lies in B’s body, and the immediate parent is the smallest such B; the lowerer emits reducible CFGs, so loops are properly nested or disjoint. LoopInfo additionally answers innermost_loop, loop_depth, is_loop_header, and back_edges per block/function.

A preheader is detected as the unique reachable out-of-loop predecessor of the header whose only successor is the header. PHP loops initially lower to slot-based CFGs, and scalar promotion may later add header parameters and back-edge arguments. The init block that branches into the header is a natural preheader; when entry into the loop is shared between blocks or conditional, no preheader exists, so passes either skip the loop or create one when needed.

AST Lowering Catalogue

Lowering must cover every variant in src/parser/ast/expr.rs and src/parser/ast/stmt.rs. The catalogue below is exhaustive for the current source tree.

Supporting AST Metadata

These supporting AST enums and records do not always lower to standalone EIR instructions, but they must be represented in metadata or consumed by lowering.

AST surfaceEIR contract
MagicConstant::Dir, File, Function, Class, Method, Namespace, TraitMust be lowered before EIR to ordinary constants. Raw MagicConstant values are rejected by the validator.
CastType::Int, Float, String, Bool, ArraySelects Cast(to_php_type) or a specific scalar/string/array conversion opcode.
StaticReceiver::Named, Self_, Static, ParentStored in receiver metadata. Named, Self_, and Parent usually resolve before backend lowering; Static may require late-static runtime behavior.
InstanceOfTarget::NameFixed class/interface target metadata.
InstanceOfTarget::ExprDynamic target expression; emits runtime class-string/object target checks.
CallableTarget::FunctionFirst-class callable or callable-array metadata for a function.
CallableTarget::StaticMethodFirst-class callable or callable-array metadata for a static method and receiver.
CallableTarget::MethodFirst-class callable metadata for an object-bound method.
UseKind::Class, Function, ConstName resolver consumes these before EIR; residual UseDecl is metadata/no-op.
AttributeGroup / AttributeCarried on declaration metadata for reflection/attribute builtins; attribute argument expressions lower as metadata values where needed.
EnumCaseDeclEnum metadata plus optional scalar/object case value.
TraitUse / TraitAdaptationType checking/name resolution flatten traits before EIR method lowering; metadata can be retained for diagnostics.
ClassProperty / PropertyHooksClass metadata, property layout, readonly/final/static flags, hooks, defaults, and attributes.
ClassConstClass/interface/trait constant metadata and value expression.
ClassMethodSeparate Function plus method metadata and vtable/static dispatch metadata.
CType::Int, IntWidth, Float, F32, Str, Bool, Void, Ptr, TypedPtr, CallableExtern ABI metadata. ExternAbiType drives ExternCall argument and return normalization only; extern globals and callback trampolines still see the PHP type.
ExternParamExtern function parameter metadata.
ExternFieldExtern class/struct field offset/type metadata.
PackedFieldPacked class field offset/type metadata.

Expression Variants

ExprKindLowering contract
StringLiteralConstStr; ownership Persistent; source span retained.
IntLiteralConstI64; php_type = Int.
FloatLiteralConstF64; data pool entry for exact bits.
VariableLoadLocal, LoadGlobal, LoadStaticLocal, or ref-cell load depending on resolved storage.
BinaryOpLower left then right in source order; dispatch by BinOp and operand PHP types.
InstanceOfLower value and target; fixed target uses metadata, expression target uses dynamic class-string/runtime lookup.
BoolLiteralConstBool; php_type = Bool.
NullConstNull; materialized as PHP null sentinel or void context.
NegateLower inner; emit INeg, FNeg, or mixed numeric conversion/op.
NotLower inner; emit truthiness conversion and boolean negation.
BitNotLower inner; emit integer/string/mixed bit-not semantics.
ThrowLower exception expression; emit Throw terminator or ThrowException path.
ErrorSuppressEmit suppress begin/end around inner expression; preserve inner result and effects except diagnostics.
PrintLower value, write stdout, return ConstI64(1).
NullCoalesceBuild null-check CFG preserving short-circuiting.
PipeLower value first, then callable expression, then single-argument call.
AssignmentLower prelude, write target exactly once, preserve expression result via result_target/temp when present.
PreIncrementLoad local, increment, store, return new value.
PostIncrementLoad local, save old value, increment, store, return old value.
PreDecrementLoad local, decrement, store, return new value.
PostDecrementLoad local, save old value, decrement, store, return old value.
FunctionCallIf extern: ExternCall; if registry builtin: descriptor-emitted EIR primitives/graphs or typed RuntimeCall; otherwise Call/variant dispatch after shared argument planning. Compiler-resident constructs use LanguageConstructCall; literal eval() becomes EvalLiteralCall so later lowering can choose native, scope-only, or interpreter execution.
ArrayLiteralAllocate indexed array and insert elements in source order, including spread handling.
ArrayLiteralAssocAllocate hash table and insert key/value pairs in source order; empty hash keeps mixed key/value metadata.
MatchLower subject once; build strict-comparison arm CFG; default absence may Fatal via match-unhandled runtime.
ArrayAccessLower base/index; dispatch to array/hash/mixed/object ArrayAccess/buffer access as type requires.
TernaryLower condition, then branch CFG; result block joins branch values through a hidden merge temp typed from the arms. Arms of one buffer<T> type keep the handle word instead of a boxed mixed temp, and a throw arm of such a merge ends its branch with Unreachable (ShortTernary, Match and NullCoalesce merge the same way).
ShortTernaryLower value once; truthy path reuses value; false path lowers default.
CastLower expression then emit Cast/specific conversion op.
ClosureEmit callable descriptor, capture metadata, and deferred closure function body.
NamedArgConsumed by call argument planning; standalone lowering lowers value only for diagnostics/fallback.
SpreadConsumed by call/array literal lowering; standalone lowering lowers inner expression only for diagnostics/fallback.
ClosureCallLoad callable local/descriptor and emit closure/descriptor invocation.
ExprCallLower callee expression once; dispatch runtime string, callable array, callable descriptor, closure, invokable object, or fatal path.
ConstRefUse resolved constant metadata; non-literal constants lower their stored expression.
NewObjectEmit object allocation and constructor call for fixed class.
NewDynamicObjectLower class-string expression; runtime class lookup, parent constraint check, object allocation, constructor call.
PropertyAccessFixed property read or mixed/stdClass property runtime read.
DynamicPropertyAccessLower object and property expression; dynamic property runtime read.
NullsafePropertyAccessNull-check object; null path returns null, object path emits property read.
NullsafeDynamicPropertyAccessNull-check object; object path emits dynamic property read.
StaticPropertyAccessFixed or late-static storage read.
MethodCallLower receiver and args; emit method dispatch/vtable/descriptor path.
NullsafeMethodCallNull-check receiver; null path returns null, object path emits method call.
StaticMethodCallResolve receiver metadata or late-static dispatch; emit static method call.
FirstClassCallableEmit descriptor/wrapper metadata for function, static method, or method target; object-method targets carry the evaluated receiver as the FirstClassCallableNew operand.
ThisLoad current object receiver.
PtrCastLower expression; preserve pointer bits and change php_type metadata.
BufferNewLower length and emit BufferNew.
ClassConstantFixed receiver emits class-string constant; static uses late-bound class runtime path.
ScopedConstantAccessResolve class constant or enum case metadata; emit scalar/object result.
NewScopedObjectAllocate self, parent, or late-static object and call constructor.
MagicConstantMust not reach EIR; validator rejects it because magic constants are lowered earlier.
YieldEmit GeneratorYield; codegen records the key/value and suspends the stackful coroutine through __rt_gen_suspend.
YieldFromEmit GeneratorYieldFrom for generator delegation; array delegation is expanded into an iterator loop that re-yields each entry.

Binary Operators

BinOpEIR lowering
Add, Sub, Mul, Div, Mod, PowNumeric scalar or mixed numeric op with PHP coercions.
ConcatString coercions followed by StrConcat.
Eq, NotEqLoose equality op, negated for NotEq.
StrictEq, StrictNotEqStrict type-aware equality op, negated for StrictNotEq.
Lt, Gt, LtEq, GtEqOrdered comparison with PHP coercions.
And, OrShort-circuit CFG.
XorTruthiness of both operands, no short-circuit after both evaluated.
BitAnd, BitOr, BitXor, ShiftLeft, ShiftRightInteger/string/mixed bitwise lowering per PHP type rules.
SpaceshipThree-way comparison op.
NullCoalesceSame CFG semantics as ExprKind::NullCoalesce.

Statement Variants

StmtKindLowering contract
EchoLower expression and emit stdout write.
AssignLower RHS, store to local/global/ref cell, update ownership.
RefAssignBind target ref cell to source storage; no value copy.
IfLower condition and branch CFG, including elseif clauses and optional else.
IfDefShould be removed by conditional pass; residual form lowers selected/resolved body or validator reports unresolved conditional.
WhileHeader/body/exit CFG with break/continue targets.
DoWhileBody executes before condition; loop CFG with break/continue targets.
ForLower optional init, condition, update, body with source-order semantics.
ArrayAssignLoad array local, ensure COW uniqueness, write index, store/update ownership.
NestedArrayAssignLower non-local target chain, preserve intermediate side effects, perform final write.
ArrayPushLoad array local, ensure uniqueness/growth, append value.
TypedAssignLower RHS, enforce/check declared type if needed, store local.
ForeachLower iterable once; build iterator loop, key/value binding, by-ref/by-value semantics, cleanup.
SwitchLower subject once; build case/default CFG preserving fallthrough and break.
IncludeShould not reach EIR after resolver; validator/compiler error if unresolved.
IncludeOnceMarkEmit include-once marker metadata/global state update.
IncludeOnceGuardEmit guard branch around body using include-once state.
ThrowLower expression and emit throw terminator/path.
SyntheticLower contained statements in order.
TryCreate try handler slot, protected body, catch landing blocks, optional finally edges.
BreakBranch to resolved loop/switch target after required cleanup.
ContinueBranch to resolved loop update/header target after required cleanup.
ExprStmtLower expression, discard result, release owned discarded value.
NamespaceDeclShould be flattened by name resolver; residual statement is metadata/no-op.
NamespaceBlockShould be flattened; residual body lowers in order.
UseDeclShould be resolved by name resolver; residual statement is metadata/no-op.
FunctionDeclEmit/register separate Function; declaration statement itself is no-op at runtime.
FunctionVariantGroupRegister include-variant dispatch metadata.
FunctionVariantDispatchDeclare an include-loaded function variant group for dispatcher emission.
FunctionVariantMarkEmit variant marker state.
ReturnLower optional expression, perform cleanup, emit Return.
ConstDeclRecord constant metadata; value expression retained for ConstRef.
ListUnpackLower RHS array, load elements, assign locals.
GlobalBind local names to global storage aliases.
StaticVarEmit guarded static initialization and local alias/load.
ClassDeclRecord class metadata; lower methods separately.
EnumDeclRecord enum metadata and cases.
PackedClassDeclRecord packed layout metadata.
InterfaceDeclRecord interface metadata.
TraitDeclRecord trait metadata; trait flattening should already be type-checked.
PropertyAssignLower object and value, perform property write.
StaticPropertyAssignLower value, perform static property write.
StaticPropertyArrayPushLoad static property array/hash, ensure uniqueness, append, store.
StaticPropertyArrayAssignLoad static property array/hash, ensure uniqueness, indexed/key write, store.
PropertyArrayPushLoad object property array/hash, ensure uniqueness, append, store property.
PropertyArrayAssignLoad object property array/hash, ensure uniqueness, indexed/key write, store property.
ExternFunctionDeclRecord extern function metadata for ExternCall; no runtime statement.
ExternClassDeclRecord extern class/struct layout metadata.
ExternGlobalDeclRecord extern global metadata for load/store.

Runtime Boundary

EIR does not emit runtime helper assembly. It references runtime helpers by symbol and category through RuntimeCall or specialized opcodes.

Runtime effect categories:

Runtime categoryEIR modeling
diagnosticswarnings, fatal messages, heap-debug fatals
stringsconversions, concat, comparisons, hashing/string builtins
callablesdescriptor release, callable introspection, runtime invocation
systemargv, time/date, JSON, regex, process execution, match-unhandled
exceptionsthrow/rethrow, cleanup frames, class/interface matching
generatorsgenerator frame/state/send/throw/return helpers
arrays/hash/mixed/gcheap allocation, COW, hash operations, Mixed boxing, refcount, GC
SPLruntime-managed containers and iterator helpers
objectsstdClass, mixed property/array access, JSON object encode
buffersbuffer allocation, length, bounds, freed-buffer checks
I/Ostreams, filesystem, stat/path helpers
pointersC string conversion and pointer string helpers
fibersstack allocation, switch, start/resume/suspend/throw/getters
eval scopematerialized boxed scope access without requiring the interpreter
eval bridgeMagician context, dynamic parsing/execution, symbol registration, and native callback hooks

RuntimeFeatures remains the mechanism for optional runtime categories such as regex, eval_scope, and eval_bridge. EIR lowering must set required runtime features when it emits operations that need optional helpers. A literal eval that is fully lowered to native EIR leaves both eval runtime features disabled.

Target-Aware Backend Boundary

EIR does not choose physical registers, spill slots, callee-saved preservation, stack alignment, syscall numbers, object file directives, or symbol decoration. Those remain in src/codegen_support/abi/ and platform helpers.

The EIR backend consumes:

  • Module.target
  • IrType and php_type
  • call signatures and normalized argument plans
  • LocalSlot metadata
  • DataPool
  • runtime feature requirements

Then it materializes:

  • ABI argument registers/stack slots
  • frame layout and hidden slots
  • target-specific branch/call instructions
  • runtime helper calls
  • source-map comments
  • instruction comments at the repository-required column

Scalar Local Promotion

src/ir_passes/mem2reg.rs replaces repeated load_local and store_local instructions on eligible PHP scalar locals with SSA values. It adds block parameters where values live into a CFG join or loop header, and each incoming edge supplies its predecessor’s current value. Loads that can run before any explicit store remain in memory. Definite-store analysis starts from the greatest fixed point, so a value written before a loop remains eligible when the back edge only reads it.

Promotion requires ordinary integer, boolean, or float local storage with only plain load/store accesses. Address escapes, by-reference uses, global and static slots, refcounted values, nonstandard slot operations, eval scope access, and functions with implicit exception-handler edges stay slot-backed. This pass runs in the fixed-point EIR optimizer before register allocation. Both --ir-opt=off and --regalloc=stack remain available for comparison.

Register Allocation

The src/ir_passes/ module runs a linear-scan register allocator (Poletto-Sarkar) over each function before backend lowering. It is the default; --regalloc=stack (or ELEPHC_REGALLOC=stack) selects the original spill-everything path, which keeps every SSA value in a stack slot.

The pass has four stages:

  1. Liveness (liveness.rs): backward dataflow to a fixed point producing per-block live-in/live-out value sets. Block parameters are definitions at block entry; branch arguments are uses at the predecessor’s terminator. A predecessor worklist propagates only newly live values, so each value crosses each edge at most once even after LICM hoists many materializations.
  2. Intervals (intervals.rs): blocks are numbered in reverse postorder and each value gets one contiguous [start, end] live interval. A value live across edges or a loop back-edge spans the intervening positions.
  3. Scan (regalloc.rs): intervals are walked in start order against an active set; a free register from the matching pool is assigned, otherwise the use-weighted spill heuristic evicts the cheapest interval. Integer and float values draw from separate pools.
  4. Frame integration (codegen/frame.rs): the allocation is stored in the frame layout, each used callee-saved register gets a save slot, and the value-access chokepoints (load_value_to_result, load_value_to_reg, store_result_value) read and write registers instead of slots. Scalar block parameters and their branch arguments can also receive register homes.

Caller-saved reuse for non-call-crossing intervals

The allocator distinguishes two register classes. A value whose live range never crosses a clobber point — any instruction or terminator whose lowering emits a call or touches a caller-saved register — is call-free and prefers a caller-saved register, which needs no prologue save/restore. A value that does live across a clobber point uses a callee-saved register, which survives calls. src/ir_passes/clobber.rs holds the audited allowlist of volatile-safe opcodes (constants, integer/float arithmetic, comparisons, scalar conversions, and statement-boundary concat resets); it is safe-by-default, so an unlisted opcode merely forgoes the caller-saved optimization rather than risking a clobbered value. The caller-saved pools are disjoint from every register those volatile-safe lowerings touch:

Classaarch64 intaarch64 floatx86_64 intx86_64 float
Caller-savedx12–x15d16–d23rsi,rdi,r8,r9xmm2–xmm7
Callee-savedx21–x28d8–d14rbx(none)

An edge carrying a lifetime-tracked block parameter may call a runtime retain while copying its arguments. The predecessor terminator and destination entry therefore count as clobber points. Scalar values carried alongside that heap parameter use callee-saved registers or spill slots until the copy completes.

This is especially valuable on x86_64, where the callee-saved integer pool is just rbx and there are no callee-saved XMM registers at all: call-free integer and float values can now use the caller-saved pools instead of always spilling. On x86_64 r14 and r15 are still never allocated — they are used as scratch by hand-written runtime routines and shared heap-marker codegen without ABI-compliant save/restore. A float that lives across a call on x86_64 still spills, because no XMM register survives the call.

The spill heuristic is use-weighted: under pressure the allocator evicts the interval with the lowest use count, breaking ties toward the furthest end (the classic furthest-use rule), so frequently-used “hot” values keep their registers.

The prologue saves and the epilogue restores exactly the callee-saved registers the allocator used; caller-saved registers need neither.

Only single-word NonHeap scalars (I64, F64) are register-eligible, including live block parameters and branch arguments. Unused block parameters stay spilled: edge copies still write them, and their zero-length intervals would otherwise allow them to share and overwrite a live parameter’s register at the same block entry.

Generators and functions containing exception handlers fall back to all-spilled. The allocator’s reachability and liveness use explicit terminator edges, so they can omit a scalar’s uses after a catch or finally block. Stack homes preserve those values until the analyses account for implicit exception edges, including parameters introduced by inlining rather than local promotion.

Phase 02 Implementation Contract

The src/ir/ module should implement at least:

  • types.rs: IrType, IrHeapKind, PhpType mapping helpers
  • value.rs: ValueId, Value, ValueDef, Ownership
  • instr.rs: Instruction, Op, InstId, operand/result helpers
  • effects.rs: Effects bitset/struct and common constructors
  • block.rs: BlockId, BasicBlock, Terminator, switch case records
  • function.rs: Function, FunctionParam, LocalSlot, flags
  • module.rs: Module, data pool, metadata records, extern declarations
  • builder.rs: checked construction API that assigns values/instructions
  • validator.rs: structural, opcode, dominance, ownership, and effect checks
  • print.rs: deterministic textual printer

Phase 02 does not need AST lowering or assembly codegen. Its unit tests can construct modules by hand and validate/print them.

Closure Audit

This specification closes the first EIR roadmap item because it defines:

  • the EIR type lattice and exact mapping from every current PhpType
  • module/function/block/value/local structures
  • instruction families and representative opcodes for all current expression, statement, builtin, runtime, ownership, exception, generator, fiber, pointer, buffer, packed, object, callable, and include/resolver surfaces
  • all terminators, including generator suspension
  • the effect lattice and effect sources
  • ownership states and ownership validation rules
  • textual format requirements and examples
  • an exhaustive current ExprKind, BinOp, and StmtKind lowering catalogue
  • validator requirements sufficient for Phase 02