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.rssrc/parser/ast/expr.rssrc/parser/ast/stmt.rssrc/parser/ast/operators.rssrc/parser/ast/types.rssrc/parser/ast/oop.rssrc/parser/ast/ffi.rssrc/ir/src/ir_lower/src/codegen/src/codegen_support/abi/src/codegen_support/runtime/emitters.rssrc/optimize/effects.rsandsrc/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 throughRuntimeCallor specialized opcodes. - Source spans must survive lowering for diagnostics, source maps, and
--emit-irreadability. - 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
PhpType | IrType | Notes |
|---|---|---|
Int | I64 | Signed 64-bit integer storage. |
Float | F64 | 64-bit floating-point storage. |
Str | Str | (ptr, len) pair; ownership may be transient, owned, borrowed, or persistent. |
Bool | I64 | 0 or 1, with php_type = Bool. |
Void | Void when no value is materialized; I64 null sentinel only when a null value is stored or boxed. | |
Never | Void | Never materialized; produced paths end in Return, Throw, Fatal, or Unreachable. |
Iterable | Heap(Iterable) | Type-erased array or traversable object pointer. |
Mixed | Heap(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 value | Storage 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. |
Callable | I64 | Callable 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) | I64 | Raw native pointer; optional type tag remains in php_type. |
Resource(kind) | I64 | Runtime/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 expression | source of the element type |
|---|---|
| a nested array literal | the nested literal’s own stamp, recursively |
| a spread | the spread source’s element type, widened to Mixed when it is Void/Never |
| a local variable | ctx.local_types |
| a constant or class constant | the resolved constant/enum-case metadata |
| a call to a user or extern function | ctx.functions / ctx.extern_functions, the declared return type |
| a call to a builtin | ctx.builtin_call_types, keyed by the call’s own span |
| a method, static-method, property, or element read | the matching *_expr_type_for_ir resolver |
| anything else | infer_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.
TypeExpr | EIR relevance |
|---|---|
Int, Float, Bool, Str, Void, Never, Iterable | Lower 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.
CType | EIR storage |
|---|---|
Int, Bool, Ptr, TypedPtr(_), Callable | I64 |
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. |
Float | F64 |
F32 | F64; ExternAbiType::Float32 rounds the argument to a single before the call and widens the return after it. |
Str | I64 native C string pointer; PHP strings are converted at the boundary. |
Void | Void |
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,
}
| State | Meaning |
|---|---|
NonHeap | Plain scalar or native handle with no release operation. |
Owned | The value owns cleanup responsibility. |
Borrowed | The value aliases storage owned elsewhere. |
MaybeOwned | CFG merge or dynamic path where ownership must be resolved before cleanup. |
Persistent | Static data, interned/static string, or runtime-persistent value that must not be released. |
Moved | Value has transferred ownership and cannot be used again except by validator diagnostics. |
Ownership-producing operations:
| Operation | Ownership result |
|---|---|
| Scalar constants | NonHeap |
| Static string literal | Persistent unless persisted/copied into heap |
| Runtime allocation | Owned |
| Local/global/static load | Slot 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:
| Op | Operand | Result | Effects | Lowering |
|---|---|---|---|---|
Acquire | refcounted/string/callable value | Void or retained value alias | REFCOUNT_OP, maybe WRITES_HEAP | __rt_incref, string persist/retain, callable descriptor retain if added |
Acquire + Bool immediate | same | same | same | same; the immediate marks a lifetime pin and only opts the pair out of acquire/release cancellation |
Release | owned value | Void | REFCOUNT_OP, maybe WRITES_HEAP, destruction may throw and debug may fatal | __rt_decref_any, __rt_heap_free_safe, callable descriptor release |
Move | any value | same type | pure validator operation | no machine instruction |
Borrow | value with live owner | same type | pure validator operation | no machine instruction |
EnsureOwned | fresh transient Str argument (., interpolation, scalar-to-string; never a builtin view) | Str, same payload, owned | REFCOUNT_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 |
ReleaseUnlessAliases | owned argument, call result | Void | REFCOUNT_OP, WRITES_HEAP, READS_HEAP | releases 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:
| Discipline | What the backend does | What lowering emits |
|---|---|---|
| Moving store | The 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 store | The 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_valueretains 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_intand friends),__rt_str_persistcopies 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:
| Helper | Discipline |
|---|---|
__rt_array_set_mixed, __rt_array_push_refcounted | Independent: the value is incref’d into the element unconditionally. |
__rt_array_set_* from a concrete value into a Mixed-element array | Moving: codegen boxes first, and the boxing path releases the box it made. |
__rt_hash_set | Moving 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 anarray<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,
}
| Effect | Required when |
|---|---|
reads_local / writes_local | Local, hidden temp, ref cell, static local, or global alias slot access. |
reads_heap / writes_heap | Array/hash/object/mixed/callable/buffer/generator/fiber state is observed or mutated. |
reads_global / writes_global | PHP globals, static properties, constants, runtime global symbols, or JSON error state are observed or mutated. |
reads_fs / writes_fs | Filesystem, streams, stat cache, directory, or path APIs are used. |
reads_process / writes_process | Environment, time, process execution, argv, sleep, or platform state are used. |
output | echo, print, printf, var_dump, print_r, passthru/system output, or stdout/stderr writes occur. |
alloc_heap | Runtime heap allocation may occur. |
alloc_concat | Concat scratch buffer may be used. |
refcount_op | Refcount or callable descriptor lifetime may change. |
may_throw | A catchable exception may be raised. |
may_fatal | A PHP fatal/value error/unhandled match/bounds/null pointer path may terminate. |
may_warn | A PHP warning or diagnostic may be emitted. |
may_deopt | Dynamic 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_newreceiver 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.rsand descriptor metadata. - Extern calls: conservative unless the extern declaration later gains explicit purity metadata.
- Typed runtime calls: from
RuntimeFnId/RuntimeCallTargetmetadata; 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 (
Nonefor void) - PHP type metadata
- ownership result
- effects
- source span
Constants and Data
| Op | Operands | Result | Effects | Lowering |
|---|---|---|---|---|
ConstI64(value) | none | I64 | pure | target immediate or data load |
ConstF64(data_id) | none | F64 | pure | data-section load |
ConstStr(data_id) | none | Str | pure | label plus length |
ConstNull | none | I64 or Void context dependent | pure | zero/null sentinel |
ConstBool(value) | none | I64 | pure | 0/1 |
ConstClassName(class_id) | none | Str | pure | static class-string |
ConstEnumCase(enum_id, case_id) | none | object or scalar | may allocate if object case | enum metadata/runtime path |
DataAddr(data_id) | none | I64 | pure | address materialization |
Locals, Globals, and Static Storage
| Op | Operands | Result | Effects |
|---|---|---|---|
LoadLocal(slot) | none | slot type | reads_local |
StoreLocal(slot, value) | value | Void | writes_local, maybe refcount_op |
UnsetLocal(slot) | none | Void | writes_local |
ZeroLocalSlot(slot) | none | Void | writes_local |
LoadRefCell(slot) | none | value or address | reads_local, maybe reads_heap |
StoreRefCell(slot, value) | value | Void | writes_local, maybe writes_heap, refcount_op |
LoadGlobal(name) | none | declared type | reads_global |
StoreGlobal(name, value) | value | Void | writes_global, maybe refcount_op |
LoadStaticLocal(slot) | none | slot type | reads_global |
StoreStaticLocal(slot, value) | value | Void | writes_global, maybe refcount_op |
InitStaticLocal(slot, value) | value | Void | reads_global, writes_global, maybe allocation/refcount effects |
LoadStaticProperty(class, property) | none | property type | reads_global, maybe may_deopt for late static |
StoreStaticProperty(class, property, value) | value | Void | writes_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
| Op | Operands | Result | Effects |
|---|---|---|---|
IAdd, ISub, IMul | I64, I64 | I64 | pure |
IDiv | I64, I64 | F64 or I64 depending on PHP operator context | may_fatal on invalid divisor |
ISDiv | I64, I64 | I64 | may_fatal |
ISMod | I64, I64 | I64 | may_fatal |
IPow | I64, I64 | I64 or F64 per PHP result | pure or libcall |
INeg, IBitNot | I64 | I64 | pure |
IBitAnd, IBitOr, IBitXor, IShl, IShrA | I64, I64 | I64 | pure |
FAdd, FSub, FMul, FDiv, FPow, FNeg | F64 operands | F64 | pure or libcall |
MixedNumericBinop(add|sub|mul) | mixed/union operands | mixed/scalar | reads_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
| Op | Operands | Result | Effects |
|---|---|---|---|
ICmp(predicate) | I64, I64 | I64 bool | pure |
FCmp(predicate) | F64, F64 | I64 bool | pure |
StrEq, StrCmp, StrLooseEq | Str, Str | I64 or compare int | reads_heap only if non-static bytes require helper reads |
StrictEq, StrictNotEq | typed values | I64 bool | pure for scalars, reads_heap for mixed/refcounted |
LooseEq, LooseNotEq, Spaceship | typed values | I64 or compare int | may coerce, reads_heap, may_warn, may_deopt |
IsNull, IsTruthy, IsEmpty | typed value | I64 bool | pure for scalars, reads_heap for mixed/containers |
InstanceOf | value, target metadata | I64 bool | reads_heap, reads_global, maybe may_deopt |
Conversions and Boxing
| Op | From | To | Effects |
|---|---|---|---|
IToF | I64 | F64 | pure |
FToI | F64 | I64 | pure PHP truncation |
IToStr, FToStr, BoolToStr | scalar | Str | alloc_concat or static string |
StrToI, StrToF, StrToNumber | Str | scalar or mixed numeric | reads bytes, maybe may_warn |
ResourceToStr | I64 resource | Str | may allocate, may warn |
Cast(to_php_type) | typed value | matching IR type | PHP cast effects |
MixedBox | non-mixed value | Heap(Mixed) | alloc_heap, maybe refcount_op |
MixedClone | Heap(Mixed) | owned Heap(Mixed) value read | reads_heap, alloc_heap, refcount_op |
MixedUnbox(expected) | Heap(Mixed) | expected storage | reads_heap, may_fatal |
MixedTagOf | Heap(Mixed) | I64 | reads_heap |
ArrayToMixed, HashToMixed | array/hash | Heap(Mixed) | alloc_heap, refcount_op |
ArrayToPointerWords | owned array bound to a pointer-array slot | Heap(Array) with raw ?ptr<T> words | reads_heap, writes_heap, alloc_heap, refcount_op |
MixedCastBool, MixedCastInt, MixedCastFloat, MixedCastString | Heap(Mixed) | scalar/string | reads_heap, maybe alloc_concat, may_warn |
Strings
| Op | Operands | Result | Effects |
|---|---|---|---|
StrConcat | Str, Str | Str | alloc_concat |
StrAppend | Str, borrowed Str; destination local slot | owned Str | reads_local, writes_local, reads_heap, writes_heap, alloc_heap, refcount_op, may_fatal |
StrLen | Str | I64 | pure |
StrPersist | Str | Str | alloc_heap, refcount_op when copy needed |
StrCharAt | Str, I64 | Str | may_fatal, alloc_concat |
StrInterpolate | string parts and values | Str | combined value effects, alloc_concat |
ConcatReset | none | Void | writes_global |
WriteStrStdout | Str | Void | output |
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
| Op | Operands | Result | Effects |
|---|---|---|---|
ArrayNew(element_type, capacity) | none | Heap(Array) | alloc_heap |
HashNew(key_type, value_type, capacity) | none | Heap(Hash) | alloc_heap |
ArrayLen, HashLen | container | I64 | reads_heap |
ArrayGet | array, index | element type | reads_heap, may_warn, maybe may_fatal |
HashGet | hash, key | value type | reads_heap, may_warn, maybe may_fatal |
ArrayGetForWrite | array, index (I64) | retained boxed Mixed cell or typed borrowed element | reads_heap, writes_heap, writes_local, alloc_heap, refcount_op, may_warn, maybe may_fatal |
HashGetForWrite | hash, key | retained boxed Mixed cell or typed borrowed value | reads_heap, writes_heap, writes_local, alloc_heap, refcount_op, may_warn, maybe may_fatal |
ArraySet | array, index, value | Void | writes_heap, maybe alloc_heap, refcount_op |
HashSet | hash, key, value | Void | writes_heap, maybe alloc_heap, refcount_op |
ArrayPush, HashAppend | container, value | Void | writes_heap, maybe alloc_heap, refcount_op |
ArrayEnsureUnique, HashEnsureUnique | container | same container | reads_heap, maybe alloc_heap, refcount_op |
ArrayCloneShallow, HashCloneShallow | container | same kind | alloc_heap, refcount_op |
ArrayUnion, HashUnion, ArrayHashUnion, HashArrayUnion | containers | container | reads_heap, alloc_heap, refcount_op |
ArrayToHash | array | hash | reads_heap, alloc_heap |
ArrayKeyExists, OffsetExists | container, key | I64 bool | reads_heap |
OffsetUnset | container, key | Void | writes_heap, refcount_op |
ListUnpack | array value, slot list | Void | reads_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
| Op | Operands | Result | Effects |
|---|---|---|---|
IterStart | iterable value | iterator state | reads_heap, maybe alloc_heap, may_deopt |
IterCurrentKey | iterator state | key value | reads_heap, maybe may_deopt |
IterCurrentValue | iterator state | value | reads_heap, maybe may_deopt |
IterNext | iterator state | I64 bool | reads_heap, writes_heap, maybe may_deopt |
IterEnd | iterator state | Void | maybe refcount_op |
IteratorMethodCall | object, method metadata | typed value | method effects, may_deopt |
SplRuntimeCall | runtime helper, args | typed value | helper 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
| Op | Operands | Result | Effects |
|---|---|---|---|
ObjectNew(class_id, args) | constructor args | Heap(Object) | alloc_heap, constructor effects |
DynamicObjectNew(class_expr, fallback, required_parent, args) | class-string and args | Heap(Object) | reads_global, alloc_heap, may_fatal, may_deopt |
PropGet(class_id, property) | object | property type | reads_heap, maybe may_deopt |
PropGetForWrite(property) | object | property type, borrowed | reads_heap, writes_heap, alloc_heap, refcount_op, may_throw, may_warn, may_deopt |
PropSet(class_id, property, value) | object, value | Void | writes_heap, refcount_op, maybe may_deopt |
PackedFieldMixedToInt, PackedFieldMixedToFloat, PackedFieldMixedToBool | boxed value bound for a packed or extern struct field, TypeError message prefix | raw I64, F64 or bool payload: only the field’s own runtime type passes (a float field also widens an int), anything else throws | reads_heap, alloc_heap, may_throw |
DynamicPropGet, DynamicPropSet | object, property string/value | value or Void | reads_heap/writes_heap, may_deopt, maybe may_warn |
NullsafePropGet, NullsafeMethodCall | nullable object and metadata | nullable result | branch plus underlying op effects |
MethodLookup | object, method id | callable entry | reads_heap, reads_global, may_deopt |
MethodCall | object, method id, args | return type | method effects |
StaticMethodCall | receiver metadata, method id, args | return type | method effects, maybe may_deopt for late static |
ClassConstant | receiver | Str | pure for fixed receivers, may_deopt for static |
ScopedConstantGet | receiver, constant name | typed value | reads_global, maybe enum/object effects |
ClassAttrNames, ClassAttrArgs, ClassGetAttributes | class metadata | arrays/objects | alloc_heap, reads_global |
InstanceOfDynamic | value, runtime class-string | I64 bool | reads_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
| Op | Operands | Result | Effects |
|---|---|---|---|
Call(function_id, args) | normalized args | return type | callee effect summary |
FunctionVariantCall(group, args) (reserved) | normalized args | return type | retained 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 args | construct-specific return | compiler-resident construct effect summary |
RuntimeCall(target, args) | normalized typed operands | target-declared return | descriptor/typed-target effect summary |
ExternCall(name, args) | C ABI args | extern return | conservative FFI effects |
ClosureNew | captures | I64 callable descriptor | alloc_heap, refcount_op |
ClosureCall | descriptor/local callable, args | return type | target effect summary or conservative |
ExprCall | runtime callee expression, args | return type | conservative dynamic callable effects |
FirstClassCallableNew | target metadata, optional object receiver | I64 callable descriptor | alloc_heap, refcount_op |
CallableArrayNew | receiver/class/function parts | callable descriptor or array | alloc_heap, refcount_op |
CallableDescriptorInvoke | descriptor, normalized mixed args | Heap(Mixed) or typed value | descriptor effect summary |
PipeCall | value, callable | callable return | evaluates 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:
- Evaluate arguments in PHP source order.
- Preserve named/spread checks at the PHP-observable point.
- Store required hidden temporaries before ABI materialization.
- Materialize ABI parameters in callee signature order in the backend.
- Avoid temp preevaluation for ref-like and mutating parameters.
Eval and Dynamic Symbols
| Op | Operands | Result | Effects |
|---|---|---|---|
EvalLiteralCall | literal data id plus lowered call operands | Heap(Mixed) | conservative call effects until literal planning refines the path |
EvalScopeGet | scope handle plus global-name immediate | boxed value | reads_heap, may_fatal |
EvalScopeSet | scope handle and value plus global-name immediate | Void | reads_heap, writes_heap, refcount_op, may_fatal |
EvalFunctionCall | normalized positional values plus dynamic-function metadata | Heap(Mixed) | conservative dynamic-call effects |
EvalFunctionCallArray | argument-array value plus function-name data id | Heap(Mixed) | conservative dynamic-call effects |
EvalFunctionExists, EvalClassExists, EvalConstantExists | symbol-name data id | I64 bool | reads_global |
EvalConstantFetch | constant-name data id | Heap(Mixed) | global/heap reads, heap/refcount writes, may_fatal |
EvalObjectNew | constructor arguments plus dynamic-class metadata | object or Mixed | conservative construction/call effects |
EvalStaticMethodCall | normalized arguments plus target data id | typed or Mixed | conservative 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
| Op | Operands | Result | Effects |
|---|---|---|---|
PtrCast(target_type) | pointer value | I64 | pure type metadata change |
PtrRead(width) | pointer | I64 | reads_heap/native memory, may_fatal |
PtrWrite(width) | pointer, value | Void | writes_heap/native memory, may_fatal |
PtrReadString, PtrWriteString | pointer and string/len | string or int | native memory effects, maybe alloc_heap, may_fatal |
PtrOffset | pointer, byte offset | I64 pointer | pure arithmetic, but php_type tag preserved |
PtrCheckNonnull | pointer | Void | may_fatal |
BufferNew(element_type, len) | I64 length | Heap(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 |
BufferLen | buffer handle | I64 | resolves generation/activity, then reads_heap; may_fatal on stale or invalid handles |
BufferGet, BufferSet | buffer handle, index, value | typed value or Void | resolve generation/activity before payload access; reads_heap/writes_heap, bounds may_fatal |
BufferResolve | buffer handle | I64 descriptor | optimizer-only; resolves generation/activity WITHOUT faulting and answers 0 for a stale or invalid handle; reads_heap |
BufferGetResolved, BufferSetResolved | descriptor, index, value; optional true immediate | typed value or Void | optimizer-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 |
BufferFree | local buffer handle | Void | invalidates the descriptor before releasing the payload and clears the local; writes_heap, may_fatal on stale aliases |
PackedFieldGet, PackedFieldSet | packed pointer, field metadata | typed value or Void | raw memory effects |
ExternGlobalLoad, ExternGlobalStore | global metadata | typed value or Void | external 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
| Op | Operands | Result | Effects |
|---|---|---|---|
EchoValue, PrintValue, WriteStdout | value | Void or I64 for print | output, value conversion effects |
VarDump, PrintR | value | Void or string/int per builtin | output or alloc_concat |
ErrorSuppressBegin, ErrorSuppressEnd | none | Void | diagnostic state effects |
Warn(message_id) | args | Void | may_warn, output/diagnostic effect |
ThrowException | exception object | terminator path | may_throw, writes_global exception state |
TryPushHandler, TryPopHandler | handler slot metadata | Void | writes_local, writes_global exception stack |
CatchBind | exception object, optional local | Void | writes_local, ownership transfer |
FinallyEnter, FinallyExit | finally state | Void | control-flow bookkeeping effects |
FiberRuntimeCall | helper, args | typed value | runtime effects, may_throw, may_fatal |
GeneratorNew | frame metadata | Heap(Object) or generator handle | alloc_heap |
GeneratorYield | key, value | suspend/resume value | writes_heap, may_throw, suspension effect |
GeneratorYieldFrom | iterable | yielded values | iterator/generator effects |
GeneratorReturn | value | Void | writes 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
| Op | Operands | Result | Effects |
|---|---|---|---|
IncludeOnceMark(label) | none | Void | writes_global |
IncludeOnceGuard(label) | none | branch condition | reads_global, writes_global |
FunctionVariantDispatch(group) | none | Void | pure metadata for dispatcher emission |
FunctionVariantMark(name, variant) | none | Void | writes_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,
}
| Terminator | Rules |
|---|---|
Br | Destination arg count and types must match destination block params. |
CondBr | cond must be I64 bool/truthiness value. Both destinations receive matching args. |
Switch | Cases preserve PHP switch/match semantics. Fallthrough for switch is represented by block structure, not implicit terminator behavior. |
Return | Transfers ownership of returned owned value to caller. Never functions cannot return normally. |
Throw | Transfers exception object to runtime exception state and does not fall through. |
Fatal | Ends program path; no cleanup after fatal unless explicitly represented before it. |
GeneratorSuspend | Captures generator frame state and resumes at resume. |
Unreachable | Only 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, orunreachable. - 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
- Every block belongs to exactly one function.
- Every block has exactly one terminator.
- Terminators appear only as the block terminator.
- Every
ValueIdis defined exactly once. - Every
InstIdbelongs to exactly one block. - Every operand references an existing value in the same function.
- 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.
- Destination block argument count matches destination block parameter count.
- Destination block argument types match destination block parameter types.
- Entry block has no block parameters.
IrTypeandphp_typeare compatible for every value.- Void instructions cannot be used as operands.
Never-typed PHP operations end in non-returning control flow.
Opcode Rules
- Integer ops accept
I64operands only. - Float ops accept
F64operands only. - String ops accept
Stroperands only unless the opcode explicitly casts. - Array ops require
Heap(Array). - Hash ops require
Heap(Hash). - Object property ops require
Heap(Object(_)),Heap(Mixed), or a dynamic receiver opcode that can validate at runtime. - Mixed ops require
Heap(Mixed)orHeap(Union). - Callable descriptor ops require
PhpType::Callablemetadata even when the storage type isI64. - Pointer ops require
PhpType::Pointer(_)metadata. - Buffer ops require
Heap(Buffer(_)), exceptBufferGetResolvedandBufferSetResolved, whose first operand is theI64descriptor of aBufferResolve. Their optional immediate may only betrue, meaning the optimizer proved the exact index nonnegative and below that descriptor’s logical length.
Ownership Rules
- Every owned value is consumed exactly once on each reachable path.
Release,Move, andReturnconsume ownership.- Returning an owned value transfers ownership to the caller.
- Returning a borrowed value requires an
AcquirebeforeReturn. Borrowmust not outlive the source owner.Movedvalues cannot be read.Persistentvalues cannot be released.- CFG joins must merge ownership explicitly. If incoming ownership states do
not match, the joined value is
MaybeOwned. MaybeOwnedvalues must be resolved before unconditional cleanup.- Exception, early return, break, continue, fatal, and generator suspend paths must have balanced cleanup for values whose cleanup is still required.
Effect Rules
- Pure operations may not set hidden memory, output, diagnostic, allocation, or control-flow effects.
- Operations with
output,may_warn,may_throw,may_fatal,writes_*,alloc_*, orrefcount_opare observable. - Passes cannot move
may_throwormay_fataloperations across other observable operations unless they prove equivalent PHP behavior. alloc_concatoperations cannot cross statement-boundary concat resets.writes_globalinvalidates assumptions about globals, statics, runtime global symbols, JSON error state, and include-once/function-variant state.writes_heapinvalidates 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 tonopand its result uses are redirected tox.xwas 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 toconst_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/NamedArgTempslot. Aload_localof a slot with a known resident value folds to it; astore_localof 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
acquirewhose result is used exactly once, by itsrelease, drops both. The single-use guard makes this refcount-neutral on every path regardless of distance between the two ops. Anacquirecarrying 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)internsa ++ binto the data pool and becomes a singleconst_strmarkedpersistentso 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
$argcslot; 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_brwhose condition resolves to a constant (const_bool,const_i64via PHP truthiness, orconst_null) becomes an unconditionalbrto the taken edge. Aswitchon aconst_i64/const_boolscrutinee folds to abrto the matching case (or the default). Awhile (true)loop, for example, lowers to a constantcond_brthat 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
Unreachableand their instructions rewritten tonop.
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:
- Forward clean-state elimination tracks whether
_concat_offis guaranteed to equalconcat_base_offseton entry to each block and across each instruction. Function entry starts clean;try_push_handlertargets start dirty because exception unwinding can arrive from a dirty statement inside thetryregion;concat_resetestablishes 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 (includingalloc_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. Anyconcat_resetreached in a clean state is neutralized tonop, removing statement-boundary resets inside arithmetic-only and concrete-array loop bodies while keeping any required reset before the loop. - Backward unobserved-reset coalescing tracks whether every path from a
program point is guaranteed to hit another
concat_resetbefore any non-inert instruction,may_throw/may_fatalinstruction, or function exit. An earlierconcat_resetwhose outgoing paths all hit a later reset without intervening observation is neutralized tonop.
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
.asciipayloads, 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 themL_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
gettypefrom 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_fillelement tags.value_php_typeis read and branched on at over a hundred lowering sites, andruntime_value_tagis 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 isBool). Soclamp<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
intinstantiation to aboolsurvivor 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
MergeFunctionsanalogue, 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:Floatlowers toF64whileInt/Boollower toI64.--report-genericsreports 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 surface | EIR contract |
|---|---|
MagicConstant::Dir, File, Function, Class, Method, Namespace, Trait | Must be lowered before EIR to ordinary constants. Raw MagicConstant values are rejected by the validator. |
CastType::Int, Float, String, Bool, Array | Selects Cast(to_php_type) or a specific scalar/string/array conversion opcode. |
StaticReceiver::Named, Self_, Static, Parent | Stored in receiver metadata. Named, Self_, and Parent usually resolve before backend lowering; Static may require late-static runtime behavior. |
InstanceOfTarget::Name | Fixed class/interface target metadata. |
InstanceOfTarget::Expr | Dynamic target expression; emits runtime class-string/object target checks. |
CallableTarget::Function | First-class callable or callable-array metadata for a function. |
CallableTarget::StaticMethod | First-class callable or callable-array metadata for a static method and receiver. |
CallableTarget::Method | First-class callable metadata for an object-bound method. |
UseKind::Class, Function, Const | Name resolver consumes these before EIR; residual UseDecl is metadata/no-op. |
AttributeGroup / Attribute | Carried on declaration metadata for reflection/attribute builtins; attribute argument expressions lower as metadata values where needed. |
EnumCaseDecl | Enum metadata plus optional scalar/object case value. |
TraitUse / TraitAdaptation | Type checking/name resolution flatten traits before EIR method lowering; metadata can be retained for diagnostics. |
ClassProperty / PropertyHooks | Class metadata, property layout, readonly/final/static flags, hooks, defaults, and attributes. |
ClassConst | Class/interface/trait constant metadata and value expression. |
ClassMethod | Separate Function plus method metadata and vtable/static dispatch metadata. |
CType::Int, IntWidth, Float, F32, Str, Bool, Void, Ptr, TypedPtr, Callable | Extern ABI metadata. ExternAbiType drives ExternCall argument and return normalization only; extern globals and callback trampolines still see the PHP type. |
ExternParam | Extern function parameter metadata. |
ExternField | Extern class/struct field offset/type metadata. |
PackedField | Packed class field offset/type metadata. |
Expression Variants
ExprKind | Lowering contract |
|---|---|
StringLiteral | ConstStr; ownership Persistent; source span retained. |
IntLiteral | ConstI64; php_type = Int. |
FloatLiteral | ConstF64; data pool entry for exact bits. |
Variable | LoadLocal, LoadGlobal, LoadStaticLocal, or ref-cell load depending on resolved storage. |
BinaryOp | Lower left then right in source order; dispatch by BinOp and operand PHP types. |
InstanceOf | Lower value and target; fixed target uses metadata, expression target uses dynamic class-string/runtime lookup. |
BoolLiteral | ConstBool; php_type = Bool. |
Null | ConstNull; materialized as PHP null sentinel or void context. |
Negate | Lower inner; emit INeg, FNeg, or mixed numeric conversion/op. |
Not | Lower inner; emit truthiness conversion and boolean negation. |
BitNot | Lower inner; emit integer/string/mixed bit-not semantics. |
Throw | Lower exception expression; emit Throw terminator or ThrowException path. |
ErrorSuppress | Emit suppress begin/end around inner expression; preserve inner result and effects except diagnostics. |
Print | Lower value, write stdout, return ConstI64(1). |
NullCoalesce | Build null-check CFG preserving short-circuiting. |
Pipe | Lower value first, then callable expression, then single-argument call. |
Assignment | Lower prelude, write target exactly once, preserve expression result via result_target/temp when present. |
PreIncrement | Load local, increment, store, return new value. |
PostIncrement | Load local, save old value, increment, store, return old value. |
PreDecrement | Load local, decrement, store, return new value. |
PostDecrement | Load local, save old value, decrement, store, return old value. |
FunctionCall | If 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. |
ArrayLiteral | Allocate indexed array and insert elements in source order, including spread handling. |
ArrayLiteralAssoc | Allocate hash table and insert key/value pairs in source order; empty hash keeps mixed key/value metadata. |
Match | Lower subject once; build strict-comparison arm CFG; default absence may Fatal via match-unhandled runtime. |
ArrayAccess | Lower base/index; dispatch to array/hash/mixed/object ArrayAccess/buffer access as type requires. |
Ternary | Lower 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). |
ShortTernary | Lower value once; truthy path reuses value; false path lowers default. |
Cast | Lower expression then emit Cast/specific conversion op. |
Closure | Emit callable descriptor, capture metadata, and deferred closure function body. |
NamedArg | Consumed by call argument planning; standalone lowering lowers value only for diagnostics/fallback. |
Spread | Consumed by call/array literal lowering; standalone lowering lowers inner expression only for diagnostics/fallback. |
ClosureCall | Load callable local/descriptor and emit closure/descriptor invocation. |
ExprCall | Lower callee expression once; dispatch runtime string, callable array, callable descriptor, closure, invokable object, or fatal path. |
ConstRef | Use resolved constant metadata; non-literal constants lower their stored expression. |
NewObject | Emit object allocation and constructor call for fixed class. |
NewDynamicObject | Lower class-string expression; runtime class lookup, parent constraint check, object allocation, constructor call. |
PropertyAccess | Fixed property read or mixed/stdClass property runtime read. |
DynamicPropertyAccess | Lower object and property expression; dynamic property runtime read. |
NullsafePropertyAccess | Null-check object; null path returns null, object path emits property read. |
NullsafeDynamicPropertyAccess | Null-check object; object path emits dynamic property read. |
StaticPropertyAccess | Fixed or late-static storage read. |
MethodCall | Lower receiver and args; emit method dispatch/vtable/descriptor path. |
NullsafeMethodCall | Null-check receiver; null path returns null, object path emits method call. |
StaticMethodCall | Resolve receiver metadata or late-static dispatch; emit static method call. |
FirstClassCallable | Emit descriptor/wrapper metadata for function, static method, or method target; object-method targets carry the evaluated receiver as the FirstClassCallableNew operand. |
This | Load current object receiver. |
PtrCast | Lower expression; preserve pointer bits and change php_type metadata. |
BufferNew | Lower length and emit BufferNew. |
ClassConstant | Fixed receiver emits class-string constant; static uses late-bound class runtime path. |
ScopedConstantAccess | Resolve class constant or enum case metadata; emit scalar/object result. |
NewScopedObject | Allocate self, parent, or late-static object and call constructor. |
MagicConstant | Must not reach EIR; validator rejects it because magic constants are lowered earlier. |
Yield | Emit GeneratorYield; codegen records the key/value and suspends the stackful coroutine through __rt_gen_suspend. |
YieldFrom | Emit GeneratorYieldFrom for generator delegation; array delegation is expanded into an iterator loop that re-yields each entry. |
Binary Operators
BinOp | EIR lowering |
|---|---|
Add, Sub, Mul, Div, Mod, Pow | Numeric scalar or mixed numeric op with PHP coercions. |
Concat | String coercions followed by StrConcat. |
Eq, NotEq | Loose equality op, negated for NotEq. |
StrictEq, StrictNotEq | Strict type-aware equality op, negated for StrictNotEq. |
Lt, Gt, LtEq, GtEq | Ordered comparison with PHP coercions. |
And, Or | Short-circuit CFG. |
Xor | Truthiness of both operands, no short-circuit after both evaluated. |
BitAnd, BitOr, BitXor, ShiftLeft, ShiftRight | Integer/string/mixed bitwise lowering per PHP type rules. |
Spaceship | Three-way comparison op. |
NullCoalesce | Same CFG semantics as ExprKind::NullCoalesce. |
Statement Variants
StmtKind | Lowering contract |
|---|---|
Echo | Lower expression and emit stdout write. |
Assign | Lower RHS, store to local/global/ref cell, update ownership. |
RefAssign | Bind target ref cell to source storage; no value copy. |
If | Lower condition and branch CFG, including elseif clauses and optional else. |
IfDef | Should be removed by conditional pass; residual form lowers selected/resolved body or validator reports unresolved conditional. |
While | Header/body/exit CFG with break/continue targets. |
DoWhile | Body executes before condition; loop CFG with break/continue targets. |
For | Lower optional init, condition, update, body with source-order semantics. |
ArrayAssign | Load array local, ensure COW uniqueness, write index, store/update ownership. |
NestedArrayAssign | Lower non-local target chain, preserve intermediate side effects, perform final write. |
ArrayPush | Load array local, ensure uniqueness/growth, append value. |
TypedAssign | Lower RHS, enforce/check declared type if needed, store local. |
Foreach | Lower iterable once; build iterator loop, key/value binding, by-ref/by-value semantics, cleanup. |
Switch | Lower subject once; build case/default CFG preserving fallthrough and break. |
Include | Should not reach EIR after resolver; validator/compiler error if unresolved. |
IncludeOnceMark | Emit include-once marker metadata/global state update. |
IncludeOnceGuard | Emit guard branch around body using include-once state. |
Throw | Lower expression and emit throw terminator/path. |
Synthetic | Lower contained statements in order. |
Try | Create try handler slot, protected body, catch landing blocks, optional finally edges. |
Break | Branch to resolved loop/switch target after required cleanup. |
Continue | Branch to resolved loop update/header target after required cleanup. |
ExprStmt | Lower expression, discard result, release owned discarded value. |
NamespaceDecl | Should be flattened by name resolver; residual statement is metadata/no-op. |
NamespaceBlock | Should be flattened; residual body lowers in order. |
UseDecl | Should be resolved by name resolver; residual statement is metadata/no-op. |
FunctionDecl | Emit/register separate Function; declaration statement itself is no-op at runtime. |
FunctionVariantGroup | Register include-variant dispatch metadata. |
FunctionVariantDispatch | Declare an include-loaded function variant group for dispatcher emission. |
FunctionVariantMark | Emit variant marker state. |
Return | Lower optional expression, perform cleanup, emit Return. |
ConstDecl | Record constant metadata; value expression retained for ConstRef. |
ListUnpack | Lower RHS array, load elements, assign locals. |
Global | Bind local names to global storage aliases. |
StaticVar | Emit guarded static initialization and local alias/load. |
ClassDecl | Record class metadata; lower methods separately. |
EnumDecl | Record enum metadata and cases. |
PackedClassDecl | Record packed layout metadata. |
InterfaceDecl | Record interface metadata. |
TraitDecl | Record trait metadata; trait flattening should already be type-checked. |
PropertyAssign | Lower object and value, perform property write. |
StaticPropertyAssign | Lower value, perform static property write. |
StaticPropertyArrayPush | Load static property array/hash, ensure uniqueness, append, store. |
StaticPropertyArrayAssign | Load static property array/hash, ensure uniqueness, indexed/key write, store. |
PropertyArrayPush | Load object property array/hash, ensure uniqueness, append, store property. |
PropertyArrayAssign | Load object property array/hash, ensure uniqueness, indexed/key write, store property. |
ExternFunctionDecl | Record extern function metadata for ExternCall; no runtime statement. |
ExternClassDecl | Record extern class/struct layout metadata. |
ExternGlobalDecl | Record 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 category | EIR modeling |
|---|---|
| diagnostics | warnings, fatal messages, heap-debug fatals |
| strings | conversions, concat, comparisons, hashing/string builtins |
| callables | descriptor release, callable introspection, runtime invocation |
| system | argv, time/date, JSON, regex, process execution, match-unhandled |
| exceptions | throw/rethrow, cleanup frames, class/interface matching |
| generators | generator frame/state/send/throw/return helpers |
| arrays/hash/mixed/gc | heap allocation, COW, hash operations, Mixed boxing, refcount, GC |
| SPL | runtime-managed containers and iterator helpers |
| objects | stdClass, mixed property/array access, JSON object encode |
| buffers | buffer allocation, length, bounds, freed-buffer checks |
| I/O | streams, filesystem, stat/path helpers |
| pointers | C string conversion and pointer string helpers |
| fibers | stack allocation, switch, start/resume/suspend/throw/getters |
| eval scope | materialized boxed scope access without requiring the interpreter |
| eval bridge | Magician 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.targetIrTypeandphp_type- call signatures and normalized argument plans
LocalSlotmetadataDataPool- 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:
- 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. - 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. - 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. - 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:
| Class | aarch64 int | aarch64 float | x86_64 int | x86_64 float |
|---|---|---|---|---|
| Caller-saved | x12–x15 | d16–d23 | rsi,rdi,r8,r9 | xmm2–xmm7 |
| Callee-saved | x21–x28 | d8–d14 | rbx | (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,PhpTypemapping helpersvalue.rs:ValueId,Value,ValueDef,Ownershipinstr.rs:Instruction,Op,InstId, operand/result helperseffects.rs:Effectsbitset/struct and common constructorsblock.rs:BlockId,BasicBlock,Terminator, switch case recordsfunction.rs:Function,FunctionParam,LocalSlot, flagsmodule.rs:Module, data pool, metadata records, extern declarationsbuilder.rs: checked construction API that assigns values/instructionsvalidator.rs: structural, opcode, dominance, ownership, and effect checksprint.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, andStmtKindlowering catalogue - validator requirements sufficient for Phase 02