The Optimizer
How elephc folds constants, prunes control flow, and eliminates dead code before code generation.
Source: src/optimize/
elephc optimizes at two levels. The AST layer performs PHP-aware rewrites and declaration pruning before lowering; the EIR backend then runs value-, block-, and dominance-aware passes over the lowered module. This page focuses on the AST layer.
EIR then runs its own passes. After that module reaches its ordinary optimization
fixed point, an ownership finalizer may transfer a fresh checked-integer box
through an assignment copy. The finalizer, and why it stays outside the inliner
and the checked-integer sink, is documented in
Fresh Mixed Assignment Transfer.
--ir-opt=off retains the original copy path.
Today the AST optimizer is split into six passes:
fold_constants_for_target(program, target)runs before type checkingpropagate_constants(program, mixed_storage_locals, buffer_read_sites)runs after successful type checkingprune_constant_control_flow(program, binding_decision_spans)runs after propagation and warning collectionnormalize_control_flow(program, binding_decision_spans)runs after pruning and rewrites structurally equivalent control-flow shells into simpler AST shapeseliminate_dead_code(program, binding_decision_spans)runs after normalization and removes leftover unreachable or non-observable statements from the already-normalized ASTprune_unreachable_declarations(program, check_result, options)runs after DCE and removes unreachable functions, classes, and methods while reconciling checker metadata
The extra arguments from the third pass on are the checker’s local-binding decisions
(CheckResult::local_binding_decision_spans()). Those decisions are keyed BY SPAN and a cloned AST
node keeps the original’s span, so no pass may duplicate a node carrying one: it would hand a
single checker decision to two syntactic sites. Two passes clone today, and both consult the set and
veto themselves — DCE’s tail-sinking, and the single-case switch rewrite reached from the prune and
normalize phases (which otherwise materializes a switch default body into both branches of a
synthesized if). propagate_constants takes the mixed-storage local NAMES for a related reason:
it must not substitute a literal for a read of a local the checker boxed as mixed.
It also consumes CheckResult::buffer_read_sites: indexed reads proven to use
native buffer storage at every checker observation of that source span. Those
reads cannot invoke PHP warning handlers, so propagation preserves unrelated
global facts across them. Bounds failures remain observable, and evaluating the
receiver or index can still invalidate facts. Ambiguous spans retain no proof.
That split matters. Some rewrites are always safe on syntax alone, while others should only happen after diagnostics have already seen the checked program.
The pre-check target fold resolves function_exists() guards using known string
assignments, constants, and concatenations. Its narrow string-fact environment
substitutes only availability arguments, leaving ordinary variable reads intact
for the checker. It shares the propagation write-invalidation and reference
tracking helpers, isolates callable scopes, and discards uncertain facts at
control-flow boundaries. Target-dependent branches can then be pruned before
the checker rejects unavailable builtins.
When pruning removes a namespaced function polyfill, the second target fold
rechecks unqualified calls and first-class callable targets against the surviving
declarations. The name resolver records their permitted global fallback before
canonicalizing names, so an available builtin can be selected without stripping
namespaces from explicit references. Retained declarations still take precedence;
qualified calls and use function imports never acquire this fallback. Rebound
calls use the resolver’s ordinary builtin alias rewrites.
Alongside those six passes, the optimizer also builds lightweight local effect summaries. These summaries answer two questions conservatively:
- does this expression have observable side effects?
- can this expression throw?
That effect information is what lets later pruning and dead-code elimination stay more precise around try / catch, callable aliases, and non-trivial control-flow merges.
Why optimize at the AST level
AST optimization remains the cheapest high-value place for PHP-semantic rewrites. The checked, pruned tree is then lowered to EIR, where backend passes handle transformations that require value identity, basic blocks, or dominance.
This gives us a few immediate wins:
- less work for codegen
- smaller and clearer generated assembly
- fewer runtime helper calls for expressions whose result is already known
- a conservative place to prune dead branches without committing to backend-specific machinery
Examples:
<?php
echo 2 ** 3;
echo "hello " . "world";
echo (int)"42";
By the time codegen sees this, it can already emit constants instead of calling runtime helpers such as pow, __rt_concat, or numeric string conversion paths.
Pass 1: Constant folding
fold_constants() walks the AST recursively and rewrites expressions whose result is statically decidable from their children alone.
Current folding coverage includes:
- scalar arithmetic:
+,-,*,/,%,**, keeping PHP’s result types and overflow rules —6 / 3staysint(2)while7 / 2becomesfloat(3.5),2 ** 3staysint(8), and results that leave thei64range (PHP_INT_MAX + 1,-PHP_INT_MIN,PHP_INT_MIN / -1) promote to float. Operations PHP turns into a runtime error (% 0,/ 0, a negative shift count) are left unfolded so the error still happens. - bitwise and shift ops on integers, including PHP’s out-of-width shift results (
1 << 64is0,-1 >> 64is-1) - unary
-,!, and~ - string-literal concatenation with
. - comparisons (
==,!=,===,!==,<,>,<=,>=) through a reimplementation of PHP 8’szend_compare()insrc/optimize/fold/compare.rs. Integers are compared as integers rather than throughf64, numeric strings are classified with PHP’sis_numeric_string()grammar, and a number against a non-numeric string follows PHP 8’s stringify-and-compare rule. A float against a non-numeric string is left unfolded because the answer depends on float-to-string formatting. - logical
&&/||when both sides are scalar constants - spaceship
<=> ??, ternary, andmatchwhen the selected result is already known- scalar indexed and associative array-literal reads such as
[2, 9][0]and["a" => 2]["a"]when every literal entry is scalar. Keys are normalized with PHP’s array-key rules first (falseand0are the same slot,"1"is the integer1,nullis""), and duplicate normalized keys are last-wins. A float key PHP would report as a lossy implicit conversion is left unfolded so the deprecation still fires. - scalar casts such as
(int)"42"or(bool)"0". String-to-number casts use PHP’s leading-numeric-prefix grammar, so(int)"12abc"is12and(float)"INF"is0— PHP’s numeric strings have noINF,NAN, hexadecimal, or_separator forms. - recursive folding inside:
- function and method bodies
- closures and arrow functions
- default parameter values
- property defaults
- constant declarations
Example
<?php
$x = (2 < 3) ? (2 ** 3) : (3 ** 4);
echo $x . "\n";
After folding, the AST is effectively:
<?php
$x = 8;
echo $x . "\n";
Pass 2: Local constant propagation
propagate_constants() runs after type checking, when the checker has already validated the original program structure.
This pass is still intentionally local and conservative. Today it focuses on:
- straight-line scalar local assignments such as:
$x = 2;$y = 3;echo $x ** $y;
- simple
ifmerges where every fallthrough branch agrees on the same scalar value - conservative
switchmerges when all possible exit paths agree on the same scalar value - known-subject
switchmerges that only simulate the selected entry and its fallthrough suffix switchbodies and labels propagated from the facts they can actually see: each label after the writes of the labels before it, and each body with the writes of every label and of every body that can fall into it invalidated. The exit simulation runsdefaultat its source position among the cases, so falling off a case reaches the default only when the default is written after that case. Acontinuein a body leaves the switch likebreak, and a body that may leave the switch from inside anifortrymakes the exit assume every body’s writes. The terminal-effect analysis (termination::switch_terminal_effect) walks the bodies in the same order, and the single-caseifrewrite keeps a switch whosebreakis nested.- conservative
try/catchmerges when every reachable fallthrough handler path agrees on the same scalar value - non-throwing
trybodies that keep unreachablecatchwrites out of the post-tryconstant environment - recognizing uniform scalar assignment outcomes from local merge expressions such as
?:andmatch - recognizing scalar locals introduced by destructuring fixed scalar array literals with
list(...)/[...] = [...] - preserving untouched scalar locals across simple loops when a conservative local write analysis can prove the loop only mutates other variables, including simple nested
switch,try/catch/finally,foreach, other simple nested loop statements, local array writes like$items[] = $i/$items[0] = $i, local property writes like$box->last = $i/$box->items[] = $i, and targeted invalidations likeunset($tmp), while also retaining stable scalar values introduced byforinit clauses - local loop path summaries for known
while(false),do...while(false),while(true)/for(;;)break exits, and branch-local loop exits that agree on scalar values - array-literal facts for heap-backed locals: a local assigned an all-scalar indexed/associative array literal (up to 64 entries) carries the literal as a fact,
$a[<const>]reads fold through the same helper as inline literal access,list(...) = $aunpacks element facts, and$b = $acopies the fact because PHP array assignment is a COW value-semantics snapshot - targeted invalidation grounded in the memory model: a statement or expression removes only the locals it can actually write. Known local writes and
unset($var)stay exact;unset($a[0])kills only$a; warning-capable array reads invalidate top-level global facts because a handler can modify them, while proven present literal reads and checker-proven buffer reads preserve unrelated facts; a call kills its by-ref argument roots (user function/method signatures come from a program pre-scan, builtin signatures from the registry) plus, at top level only, everything when the callee can writeglobalstorage (tracked transitively by the callable-effects fixed point). Callback-invoking builtins (call_user_func,usort,array_walk, …) treat their arguments like an unknown callee’s - a reference-volatility ledger backing those targeted rules: every reference-exposure point (
$t = &$sand its lvalue roots, by-refforeacharray roots, by-ref closure captures,global/staticdeclarations, by-ref arguments to user callees,ptr($x)address-taking, request superglobals) marks the name so it never carries a fact again - never substituting a constant into a by-ref argument position, which must stay an lvalue
- re-running constant folding on expressions after substitutions are made
- propagating into nested bodies conservatively without trying to solve full data-flow across loops or general path-sensitive CFGs
Example
<?php
if ($argc > 0) {
$base = 2;
} else {
$base = 2;
}
echo $base ** 3;
After propagation, the later echo effectively becomes:
<?php
echo 8.0;
That means later passes never need to emit the runtime pow path.
Pass 3: Post-check control-flow pruning
prune_constant_control_flow() runs only after type checking succeeds. This pass is allowed to remove dead branches and dead statements because diagnostics have already seen the checked structure.
Current pruning coverage includes:
if/elseif/elsechains with constant conditionswhile (false)do { ... } while (false)reduced to a single execution of the bodyfor (...; false; ...), preserving theinitclause but removing dead loop/update workmatchexpressions whose subject and patterns are statically decidable- shadowed
matcharms and duplicate arm patterns removed when earlier arms already own the same exact pattern entries switchpruning when early case prefixes are provably impossible- unreachable statements after:
returnthrowbreakcontinue
- dead code after exhaustive
if/else - dead code after conservative exhaustive
switch ... default - pure expression statements whose result is unused
- pure dead subexpressions inside:
- ternaries
??- short-circuit
&&/||
A pass must not delete a body’s last yield
PHP decides generator-ness syntactically, at declaration. The checker records
that on FunctionSig::is_generator from the source body, before any later pass
runs. Named functions and methods pass that checker bit to lowering; closures
rebuild it from the optimized body in closure_signature_from_ast. A declared : Generator return is not a
substitute: function factory(): Generator { return inner(); } declares the type
without containing yield, and it is an ordinary function (issue #1086). A body
that held a yield is still a generator when no yield can run, so
function g(): iterable { while (false) { yield 1; } } returns an empty
Generator.
The bit does not replace the token in the body. Generator source and the body-scan
fallback still look for a yield, and a pass that removes every one of them changes
the body the coroutine is lowered from. When constant folding took the dead branch
with the token, the function returned a boxed null, valid() answered true
forever, and foreach never terminated (issues #673 and #1085). Pre-check target
folding is earlier still: it runs before the checker records the bit, so deleting
the last yield there would make the declaration look ordinary.
optimize::generator_bodies::rewrite_preserving_yield keeps the original body when
a rewrite would leave it with no yield at all. Only a body whose yields are all
removed gives up that pass. The walk is paid by every body; the clone only by
bodies that contain a yield.
The rewrites that delete a yield do it for different reasons, so each has its own call:
- Pre-check target folding (
fold_callable_body) can fold a target-availability condition such asPHP_OS_FAMILY === "Windows"and take ayieldwith that branch, beforeis_generatorexists. - Pass 3, pruning (which Pass 4 runs again over the same bodies) folds a
condition it can read as a literal —
while (false),if (false). Named functions and methods reach the guard throughprune_function_body(), closures through theClosurearm ofprune_expr(). - Pass 5, dead-code elimination rewrites an
ifchain as a whole.if (false) { ... } elseif (false) { yield 1; }leaves pruning with the guard engaged and the body restored; DCE then collapses the chain and takes the token with it. Named functions reach the guard indce_stmt_in_source_mode, methods throughdce_method()anddce_method_without_context().
Checking for a yield after a pass is not enough: prune_function_body()’s own
trailing-return guard did exactly that and was defeated the same way. A yield
after return in the same block is kept by the same helper on the propagation
boundary, which stops rewriting at the first terminator.
Pass 4: Control-flow normalization
normalize_control_flow() runs after the pruning pass. At this point the AST already has constant-dead branches removed, so the job becomes “reshape the remaining control flow into simpler but equivalent forms” rather than “decide which branch is dead”.
Current normalization coverage includes:
- empty
ifdef,if,switch, and degeneratetryshells - single-path conditionals such as:
if ($cond) {} else { ... }→if (!$cond) { ... }- nested single-path
ifchains collapsed into one condition with&&
ifstatements whosethenandelsebodies normalize to the same block collapsed into “evaluate the condition only if observable, then run the shared block once”elseifchains canonicalized into nestedelse { if (...) { ... } }form- adjacent
ifchain heads with identical bodies merged into oneif ($a || $b) { ... }shape - adjacent
ifchain tails with identical fallback merged into oneif (!$a && $b) { ... } else { ... }shape - longer
ifchains repeatedly normalized until these shapes saturate - adjacent
switchcases with identical bodies merged into a single multi-pattern case - pure fallthrough
switchlabels folded into the next non-empty case body - single live
switchcases rewritten toifwhen the loose comparison can be reconstructed safely - adjacent
catchclauses with the same body and variable merged into a single deduplicated, stably ordered multi-type catch - constant
switchexecution materialized into the exact statement tail that would run, preserving fallthrough andbreak - non-throwing
try/catchsimplification - outer
finallyblocks folded into a single innertrywhen they wrap exactly one innertrythat does not already have its ownfinally - safe hoisting of non-throwing, fallthrough prefixes out of
tryblocks - conservative flattening of
try/finallywhen thetrybody cannot throw, falls through, and cannot leave early: areturn,break, orcontinuenested in a branch of the body still runsfinallyunder PHP, so such a body keeps its shell (the same rule gates DCE’s flattening and its sinking of a tail intofinally)
The second generation of the pass (control-flow normalization v2) adds shell canonicalizations that matter to the CFG-aware EIR passes (loop analysis, LICM, branch simplification), all of which move AST nodes rather than clone them so the span-keyed checker decisions stay singular:
if (!c) { A } else { B }swapped intoif (c) { B } else { A }, unless theelseis a loneif(the canonical nestedelseifchain, whose head/tail merges key on that shape)for (init; test;)without an update clause hoisted intoinit; while (test)(for (;;)becomeswhile (true)), becausecontinuereaches the test directly in both formsdo { ... } while (true)rewritten towhile (true) { ... }- leading
if (g) { break; } [else { E }]guards folded into the loop test:while (c) { if (g) break; rest }becomeswhile (c && !g) { rest }(while (true)/for (;;)become plainwhile (!g)), with the guard’selsebody leading the remaining body; the fold repeats while the body still starts with such a guard, andforloops with an update clause receive the same test without changing shape - an endless loop that ends in
if (g) { break; }rotated intodo { body } while (!g), refused when the body carries acontinuetargeting that loop (it skips the guard today but would reach the rotated test); nested loops andswitchbodies raise thecontinuelevel the check looks for - trailing terminators that transfer exactly where falling off the block would are dropped: a
continueending a loop body, thebreakending the body that runs last in aswitch(thedefaultbody when it is written last, else the last case; adefaultwritten between cases keeps itsbreak, since EIR lowering places it at its source position), and a barereturn;ending a function or method body. The walk follows only the tail path — the last statement, then recursively the last statement of eachif/ifdef/trybranch — and never enters loops,switchbodies, orfinallyblocks; a shell whose branch was emptied is re-pruned so it collapses like fresh input. By-reference-returning functions and generators keep theirreturn;, and abreak-only last case is kept when adefaultfollows it
A generator must still look like one
Constant propagation, pruning, and dead-code elimination rewrite callable bodies through
optimize::generator_bodies::rewrite_preserving_yield, which keeps the original body when a
rewrite would leave it with NO yield at all. Pruning’s call sites are described under Pass 3;
dead-code elimination uses the same helper because it runs later and can delete a yield the
prune guard has just restored.
Named functions and methods keep the checker’s FunctionSig::is_generator bit from the source
body. Lowering also scans their rewritten bodies and can classify them as generators when that
bit is false or unavailable. Closures take a different path: closure_signature_from_ast
rechecks the optimized body with body_contains_yield during EIR lowering. Preserving a yield
therefore also preserves their generator classification.
PHP decides whether a declaration is a generator syntactically, before any folding, and the
type checker does the same — it types g() as Generator from the yield it can see. These
passes run after checking, and both the propagation and pruning walks stop at a block’s first
terminator, so an unreachable yield was deleted. The two classifications then disagreed: the
caller still drove a Generator while EIR lowering saw an ordinary function, and the driving
foreach spun forever on a boxed null (issue #673).
Both of PHP’s ways to spell an immediately-complete generator hit it, and they are destroyed by different passes:
function g() { if (false) { yield 1; } return; } // constant-branch pruning
function g() { return; yield; } // the stop-at-terminator rule
Keeping the un-rewritten body is the conservative answer: the retained yield is unreachable by
construction, so the cost is one unexecuted statement in a body that is now lowered as the
coroutine it is — and only a body whose every yield is dead pays it.
It is also whole-body: one dead yield blocks propagation, pruning, and dead-code elimination
in that callable. A surgical “keep one yield, apply the rest” would be tighter (issue #1325).
Dead-code elimination is wrapped by the same helper. The checker bit preserves generator
classification for named callables, and the body scan can add it; closure classification is
derived from the optimized body. The coroutine body still has to contain a yield. DCE runs after
pruning and collapses shapes the
prune guard has just put back: a constant elseif chain is the case that still deleted the
last yield with the pruning guard alone (issue #1085). Reverting that pass’s whole body is
the same trade propagation and pruning already make, and only a callable whose every yield
is dead pays it.
The invariant covers both paths: named callables retain their check-time classification with an
additive body scan, closures are classified again from the optimized body, and a pass that
rewrites either body must not delete its last yield.
Example
<?php
try {
echo "a";
throw new Exception("boom");
} catch (Exception $e) {
echo "b";
}
The leading echo "a"; is known not to throw, so the optimizer can hoist it out of the try and leave only the actually-throwing tail protected by the handler.
Pass 5: Dead-code elimination
eliminate_dead_code() now runs after normalization. At this point the AST has already had constant-dead branches removed and redundant control-flow shells compacted, so the job becomes “drop the leftovers” rather than “reshape the program”.
Current dead-code-elimination coverage includes:
- unreachable statements after:
returnthrowbreakcontinue
- statements after exhaustive
try/catchandtry/finallyexits - unreachable
catchpaths when the post-DCEtrybody can no longer throw, or when its exact and constrained throwable domains cannot match that handler - exact thrown-class routing for explicit
throw new Class, statically proven arithmetic failures, and fixed-point summaries of direct user functions and exact-receiver methods; unresolved calls, late-bound instance dispatch, dynamic operands, and external constructors retain an unknownThrowabledomain - source-order handler subtraction for unknown throws, including the PHP
Throwable = Exception | Errorroot partition, so a later handler is removed once earlier catches exhaust its remaining domain without assuming arbitrary interfaces or open class families are closed - caught-variable domains preserved through nested
tryblocks and simple local aliases/reassignments, allowingthrow $eto retain the incoming exact or constrained class while writes through unknown paths invalidate that fact conservatively - shadowed
catchclauses whose exception types are already fully covered by earlier handlers, including all later handlers aftercatch (Throwable ...) - implicit destructor execution routed through that same typed model, so a
__destructthat throws keeps the handler it can actually reach (see “Destructor throws” below) - shadowed
switchpatterns whose match points are already covered by earlier case labels, including full-case removal or fallthrough-body merging when no entry pattern remains - internal
ifregions pruned when outer pure variable guards or strict boolean checks already determine a nested branch outcome, with guard invalidation on relevant local writes to stay conservative - guard-based pruning now also understands simple pure
&&/||combinations, so contradictions likeif ($a && $b) { if (!$a || !$b) ... }can be removed without needing constant folding first - loose equality and safe relational-comparison complements now feed the same guard model, so nested checks like
$x == 0followed by$x != 0, or$x > 10followed by$x <= 10, can be pruned when the outer branch proves the contradiction - integer range facts accumulate from
$x <op> intbranches only after$xhas a proven integer domain (anintparameter, a completed exact-inttyped local declaration, or an exact-int guard), so transitive bounds like$x > 10proving$x > 5, strict-int contradictions outside the interval, and impossibleswitch ($x)int cases can be pruned without treating nullable ints, floats, NaN, or PHP string comparisons as discrete integers - cross-variable relational and strict-equality atoms (
$x === $y,$y > $x, …) are recorded with safe complements and operand-swapped forms; an exact int / point-range fact on one side derives the corresponding structural var/int atom, andStrictEqsubstitution installs the same full exact, truthiness, point-range, and integer-domain facts as a direct literal guard, while non-equality relations strengthen the other side’s range only with a proven integer domain - pure, non-throwing
whileandforconditions extend the path-local guards at body entry with taken-true polarity; loop-carried body/update writes are invalidated through the shared write model and sequential writes clear the facts before later body statements.do...whiledeliberately does not receive this strengthening because its first body execution precedes the condition - strict scalar guards now feed the same pruning: after checks like
$x === null,$x === 0, or$x === "", nested regions that contradict the exact known value can be removed - negative branches of strict scalar checks now contribute exclusion facts too, so
elsepaths after checks like$x === 0or the true path of$x !== nullcan prune nested contradictions without needing a full exact replacement value - the same strict scalar guard machinery now covers exact floats as well as PHP-falsy strings like
""and"0", so nested truthiness checks and strict literal contradictions can be pruned when those values are already known or excluded - outer exact scalar guards can now also prune impossible
switchentries: when aswitch ($x)subject or aswitch (true|false)guard pattern is already decided by surrounding strict checks, dead leading cases are dropped before the remaining switch body is analyzed, and the CFG-lite pass can also drop laterswitchblocks that no longer have any reachable predecessor after an exact entry is chosen - cumulative false guards in
if/elseifchains can now prune later impossible branches and unreachableelsesuffixes before codegen, instead of carrying logically dead tails through the rest of the pipeline switch (true|false)now applies the same cumulative guard idea across case fallthrough: later guard-like cases and thedefaultcan be pruned when earlier no-match paths already force an exhaustive outcome- direct-entry
switch (true|false)case bodies can also inherit cumulative no-match guards from earlier non-fallthrough cases, so nested contradictions inside later case bodies are pruned while fallthrough remains conservative - multi-pattern
switch (true|false)cases now participate in that same cumulative reasoning, so an exhaustive label set inside one case can remove later dead cases and thedefault - exact scalar guards now drive the same pruning inside ordinary
switch ($x)multi-pattern cases: impossible labels inside one case are dropped, and if a surviving later label is guaranteed to match, later dead cases anddefaultare removed as well - excluded scalar guards now also prune ordinary
switch ($x)entries, so outer facts like$x !== 1can remove deadcase 1:labels even when the exact runtime value of$xis still unknown - truthiness facts now also feed ordinary
switch ($x)pruning forcase true/case false: cumulative no-match paths can eliminate dead boolean cases and even remove a deaddefaultonce the remaining truthiness paths are fully covered - that same truthiness pruning now preserves earlier
Unknownmulti-pattern entries as reachable CFG entry points, so we do not over-prune preceding case bodies while still removing dead boolean suffixes anddefault - truthiness facts also prune scalar literal labels of the opposite truthiness in ordinary
switch ($x), so truthy/falsy outer guards can remove deadcase 0,case "",case null, or analogous truthy literal labels inside mixed multi-pattern switches switch (true|false)cases using single guard-like patterns can feed the same internal region pruning inside the selected case body, again with local-write invalidation to stay conservativecatchandfinallybodies now invalidate outer guard facts only for locals written on the relevant pre-handler paths, so nested pruning there stays sound without discarding unrelated guard facts- throw-path invalidation for
switchnow consults the CFG-lite reachable block set, so writes in impossible case bodies do not unnecessarily kill catch-body guards, while reachable case writes before athrowstill invalidate them - catch-side guard invalidation is now path- and exception-type-aware: writes that only happen on non-throwing paths or paths throwing into a different handler no longer block pruning inside the selected
catch, while call-aware by-reference writes performed by the throwing instruction itself still invalidate the affected locals - finally-entry guard invalidation separates normal/throw/return/break transfers from unconditional
exit/diepaths, which PHP terminates without runningfinally; branch-local writes on exit-only paths therefore no longer discard unrelated facts in the finally body - a
switchwhosedefaultis written between cases keeps its shape in both the normalization and DCE passes (bodies are still optimized): every structuralswitchrewrite modelsdefaultas the body that runs last, while EIR lowering places it at its source position, where a fallthroughdefaultcontinues into the next case; the parser gives an emptydefault:written before a case an empty synthetic no-op carrying the label’s span, so it can be ordered like any other body - statements following a
switchwithoutdefaultstay after the switch, so the no-match path (and a last case falling off the switch) still runs them without cloning them into every exit path; a tail carrying a loopbreak/continuestays after the switch as well, where those statements keep targeting the loop - condition-only empty
if/elseifchains reduced to just the observable condition checks that still matter - empty
elseifbodies in the middle of a live chain folded into the minimum negated guard needed for later branches - trailing block tails sunk into
ifandifdeffallthrough branches, so later statements are only retained on paths that can still reach them - trailing block tails sunk into
switchsuffixes when later code is reached deterministically either by falling off the final reachable path or by exiting a case viabreak - trailing block tails sunk into
try/catchfallthrough paths, and intofinallyonly in the conservative case where every pre-finally path must still fall through - trailing empty
switchlabels dropped when they no longer lead to reachable work - pure expression statements whose result is unused
- pure expression statements that become exposed by earlier normalization
The current path-aware DCE work uses small path-outcome helpers for if, ifdef, switch, and try, all speaking the same local tail-path vocabulary (falls through, breaks, no tail, unknown). That lets tail-sinking and shell collapsing share one reachability model instead of duplicating ad-hoc logic per statement shape.
The first dead-code-elimination v3 slices also start moving some of that reasoning onto a tiny CFG-lite layer. Today that covers switch, if, and try/catch/finally: branch bodies are lowered to small basic-block graphs and their tail reachability is classified from successor edges instead of only from hand-written scans. It is still AST-local, not a full function CFG, but it is the first step toward block-aware DCE.
Example
<?php
if (true) {
echo "kept\n";
} else {
echo pow(3, 4) . "\n";
}
After pruning and normalization, the dead branch disappears entirely. The final dead-code pass then has less structural noise to inspect, and codegen never emits the pow path.
examples/exception-routing/ shows the exception-aware side on a whole program: an order pipeline with a ten-class exception hierarchy writes ten catch clauses, and four of them can never run (a handler disjoint from everything the try can throw, a handler that cannot match a narrowed throw $e, a parent clause after handlers for all of its thrown children, and a child clause shadowed by its parent). elephc --emit-ir examples/exception-routing/main.php keeps six handlers, and the two classes and two functions that only the dead clauses referenced never reach EIR, because declaration reachability (Pass 6) runs after this pass.
AST-only optimizer entry points seed the standard SPL exception parent tree from the same table as the checker. This lets an Exception handler shadow a later RuntimeException handler even without checker metadata, while explicit source or checker hierarchy facts remain authoritative.
Pass 6: Declaration reachability
prune_unreachable_declarations() runs after AST DCE and before EIR lowering.
The implementation in src/optimize/reachability/ scans top-level executable
roots, declaration contracts and bodies, prelude inventory groups, and exported
functions, then follows function, class, method, and extern edges to a fixed
point. Unreachable user declarations and compiler-prelude declarations are
removed before EIR can lower them. A reachable include-loaded
FunctionVariantGroup expands to every concrete variant that its runtime
dispatcher may select.
The scanner deliberately widens the keep-set for PHP-observable dynamic lookup:
eval and unknown function calls retain free functions, unknown method lookup
retains methods on live classes, and unserialize, dynamic class names, and
Reflection retain class-like declarations conservatively. Literal
function_exists, class_exists, method_exists, and class-string
property_exists probes retain the named declaration instead of triggering a
global widening. The same shared argument planner maps reordered named arguments
before introspection targets, callbacks, or argument-dependent builtin link
requirements are inspected. Registry parameters with a callable type or
structural callback-slot metadata add callable edges, including named arguments
and conservative dynamic-spread fallback. Explicit prelude requests —
--with-pdo, --with-mysqli, --with-tz, and --with-image — root their
complete inventory group; --with-crypto only force-links the bridge, and
--web is demand-pruned from its executable bootstrap roots.
Dynamic hazards are accumulated from top-level executable code and from declarations reached through executable calls; hazards hidden in dead bodies do not widen the graph. Interface-required methods have a separate structural reachability state: their symbols and static dependencies remain available for vtable metadata, but dynamic operations in those bodies widen the graph only if an executable edge also reaches the method. Compiler-owned prelude methods may likewise identify private closure dispatch that cannot name user declarations.
Receiver tracking is a conservative may-analysis. Assignments union known
classes, opaque writes forget the affected receiver, and interprocedural
global or by-reference mutation turns calls through the aliased variable into
wildcard method edges. A literal $GLOBALS['name'] access applies that rule to
the matching top-level variable; a computed $GLOBALS[$key] makes every tracked
receiver name opaque. This models PHP’s aliasing conservatively for declaration
retention, but does not implement the still-unsupported $GLOBALS runtime alias
storage itself. Likewise, an expression call such as ($value)() intentionally
sets both the dynamic-function and dynamic-method hazards because the value may
be a function, closure, or callable array. Keeping every method of every live
class in that case is a documented precision cost, not a correctness defect.
Two-element array literals are treated as possible callable arrays even when
they are ordinary data, for the same reason: the resulting over-retention is
safe, while rejecting a runtime callable would not be.
Compiler-invoked protocol methods (Iterator, IteratorAggregate,
Countable, ArrayAccess, JsonSerializable) gain behavioral edges from the
operations whose lowering can invoke them: foreach, count(), object offset
access, json_encode(), the iterator builtins, and IteratorIterator
construction. A statically known receiver contributes a class-qualified edge;
an opaque receiver widens only the corresponding protocol method names, while
recursive JSON encoding conservatively uses the jsonSerialize method name.
Declared scalar/array locals and positive is_array() branch guards suppress
impossible object protocol edges. A dynamic lookup inside a protocol body therefore widens the
keep-set only when such an operation can execute it, just as it would in an
ordinary reachable method. User interfaces that lowering does not invoke stay
structural, so an unused Runner::run() body does not retain sibling methods.
Late-static construction is also a family-wide runtime edge. A reachable
new static(...) roots the lexical class and each checker-known descendant that
could be selected by the called-class id. Their constructor bodies and signatures
participate in the fixed point, so an override cannot disappear and callable or
by-reference constructor parameters retain the same dependencies as direct calls.
Compiler-internal declaration factories are scanned at their semantic call
site. In particular, the runtime class selected by PDO::prepare roots the
instantiable PDOStatement subclass family, while other non-literal
__elephc_new_without_constructor calls fall back to the global dynamic-class
hazard. This coupling must stay synchronized with PDO’s validation and lowering
until the dependency is represented in shared builtin metadata.
Pruning the AST alone would be ineffective because EIR lowering reads flattened
methods from CheckResult. The pass therefore filters method_decls and all
related method maps, resolves inherited implementations through
method_impl_classes, scans trait-imported bodies from each consuming class’s
flattened declarations, rebuilds instance and static vtable slots in survivor
order, and keeps every shared virtual slot on the whole inheritance lineage once
any occupant survives, so a mid-chain parent:: call cannot renumber a
grandparent-typed dispatch. It also removes dead extern schemas and link
requirements, and keeps the checked metadata synchronized with the remaining AST. Conditional builtin requirements
are rescanned from normalized parameter-order arguments before a bridge or system
library is removed.
Linker dead stripping is deliberately secondary. Linux user functions already
live in separate text sections. The macOS runtime object uses
.subsections_via_symbols to discard independent __rt_* helpers, but the
generated user object does not: its address-taken callable labels and contiguous
metadata are not yet modeled as independently rooted Mach-O atoms. Declaration
reachability is therefore responsible for removing user functions and methods
on macOS. Enabling user-object atom splitting requires explicit relocation and
metadata roots plus callable regression coverage; applying the runtime-object
strategy directly can produce dangling callable descriptors.
EIR tail recursion
src/ir_passes/tail_call.rs runs once after the inliner/per-function fixed point
and before ownership finalizers. It discovers direct Call instructions whose
result reaches Return without further work, including scalar local-slot or
block-parameter forwarding through shared return joins. Numeric owner retirement
and scratch resets must occur in the call block so a new edge cannot bypass them.
Iterative strongly connected component analysis identifies self and mutual tail
cycles without consuming the compiler’s recursive call stack.
Each original function keeps its name, signature and ABI prologue. Mutual
components clone the other bodies once into that function, remapping block,
instruction, value and local IDs. tail_initial loads the incoming ABI slots;
tail_rebind_<name> accepts typed block parameters, resets fresh local storage,
stores the new parameters and branches to that body’s entry. Tail calls become
Br to the appropriate rebinding block. Argument evaluation and parallel block
moves precede parameter stores, preserving swaps and named argument order.
The backend emits the declared function.entry first, including when this pass
appended it after the original blocks.
The frame proof requires scalar boundaries and local storage, no references,
variadics, handlers, persistent locals or implicit frame readers. Checked
arithmetic’s boxed numeric argument roots are a bounded exception: each root
must have one store/push/pop/clear sequence and a proven numeric producer, with
slot and unwind-record balance verified on every normal CFG path. Integer ABI coercions are emitted while those owners
are live; the existing cleanup then executes before the branch. Arbitrary
Mixed owners cannot pass this proof because their cleanup can invoke PHP.
Eval or backtrace snapshots disable the transform module-wide. Mutual cloning
is capped at 16 bodies and 4096 instructions per entry.
All local load effects are restored because parameter immutability proofs from the old call graph no longer hold. The ordinary scalar passes then optimize the new CFG before ownership finalization. The transform and its resulting branches are target-independent. See the compilation guide for eligibility and a PHP example.
EIR bounded mixed counters
src/ir_passes/bounded_mixed_counter.rs runs after checked-integer sinking and
before canonical loop optimization. It finds closed function-local integer
counter graphs, scalarizes a private trial and requires Mem2Reg to eliminate
every addressable load/store of the candidate slot. IntegerRange must then
turn every introduced checked update into a plain integer add/subtract before
the trial replaces the original function.
The proof preserves the physical ABI of eligible by-value, nonvariadic mixed
call parameters by rematerializing their integer arguments at the existing call
boundary. It does not authorize a different closure call or change dispatch.
Executable main, parameter slots, references, dynamic eval, suspension,
exception handlers, escaping values and uncertain integer/type facts are
excluded. See Bounded mixed counters.
The backend’s frame-local checked numeric cells are a separate storage optimization and do not require this pass to run.
Lowering also uses a discarded trial to decide whether a potentially promoting
local can retain scalar storage when the body is lowered again. A counter whose
seed is checked arithmetic on another counter ($j = $i + 1) is retried jointly
with that counter when it fails alone, and a proven seeded counter is replayed
with its seed recorded as an int (LoweringContext::int_seed_bindings), so its
loop reads it as an integer exactly as after $j = 0. For instance
methods, src/ir_lower/numeric_storage.rs::readonly_calls() uses the ordinary
method-signature resolver and accepts only a concrete user-class receiver with a
final class or final method. Variadic, generator and by-reference-return methods
are excluded. Physical arity and reference flags must match the signature; the
receiver at operand zero is excluded and only by-value argument positions qualify.
This records storage observations without changing call effects or dispatch.
Within that storage trial only, method arguments are detached as scalar copies
and unchanged integer join parameters are folded before loop/range analysis.
Every parallel incoming edge must agree on the same non-self value with identical
I64/PHP int/nonheap metadata, and that value must strictly dominate the join.
The shared rewrite helpers preserve value identity and edge arguments. Missing
entry values, disagreeing edges or uncertain metadata reject the fold. The trial
is discarded after every counter update is proven nonoverflowing; it authorizes
ordinary lowering again, including with IR optimization disabled, rather than
installing speculative trial instructions in the executable function. Integer
storage and buffer bounds remain independent proofs.
EIR buffer resolution hoisting
src/ir_passes/buffer_access.rs runs once after the module fixed point (and
after tail recursion), then the scalar passes run again. Every buffer_get and
buffer_set normally resolves its generation-safe handle through
__rt_buffer_resolve before the bounds check. In a loop whose natural body
cannot free, allocate or rebind a buffer, that answer cannot change between
iterations, so the pass inserts one buffer_resolve into the preheader and turns
each access with a loop-invariant handle into buffer_get_resolved or
buffer_set_resolved.
The resolution (with the handle slot load it reads, for a local handle) goes at
the earliest preheader position that gives the answer the end of the preheader
would: after the handle’s own definition and after the last preheader
instruction that fails the loop-body allowlist below or names the handle slot as
storage. The pass runs after LICM has moved the loop-invariant loads into the
preheader, so appending the call at the end would keep a hoisted float, such as
a float $dt parameter, live across __rt_buffer_try_resolve. SysV x86_64 has
no callee-saved XMM register, so the allocator would leave that float in a frame
slot and reload it on every iteration; resolving first leaves it call-free and
eligible for a caller-saved XMM register.
The pass also recognizes a zero-initialized, unit-step induction parameter whose
loop continuation is a strict signed comparison against buffer_len of that
same handle. When the continuation block dominates an access using the exact
induction value and the access is not in the loop header itself, the resolved
instruction receives a true immediate. Header accesses keep the bounds check
because they may precede a header-local continuation comparison. This proof also
accepts a dominating cached length value. When the cached length and the access
use different loads of one local handle slot, that slot must not escape by
reference or be written anywhere in the function. It deliberately refuses
inclusive comparisons, unrelated or other-buffer bounds, negative starts,
decreasing or non-unit recurrences, and derived indexes. The handle identity is
the same HandleSource used for descriptor hoisting, so a length from another
buffer cannot justify removing an access check.
The hoisted buffer_resolve calls __rt_buffer_try_resolve, which applies the
same registry, activity and generation checks but answers 0 instead of failing.
The resolved access tests the descriptor before its bounds check, so a stale
handle still raises the use-after-free fatal at the access, and a loop that never
runs its body never reports anything. A loop qualifies only when every
instruction in its body is on a default-deny allowlist: buffer element accesses,
packed field reads and writes, local loads, stores to scalar locals, concat
resets, and instructions whose effects only read state or fatal. That last group
includes direct user function and method calls once effect refinement has proven
the callee’s whole summary read-only; every other call, and user-code releases,
allocations, warnings, output and throws, disqualify the loop, because each can run user code
(a callee, a destructor, an error handler, an output callback) that frees a buffer
through an alias. The handle must be an SSA value defined outside the loop
or a load_local of a slot that nothing in the loop writes and no by-reference
alias reaches. Generators, functions with exception handlers or dynamic eval,
and modules that call pcntl_async_signals() (whose backend dispatches signal
handlers after every instruction) are skipped.
In modules that allow property reuse, buffer acquire/release are no-op word
operations, and plain named reads on $this can throw without changing buffers
or running PHP. These operations also preserve descriptor validity. Before
descriptor hoisting, buffer_property_loops relocates a stable buffer-property
read from the header or its unconditional body entry into a preheader. A
body-only read gets a taken-only entry behind a duplicate of the scalar loop
condition, preserving zero-trip uninitialized-property behavior. The read keeps
its original instruction id and lexical scope. Calls, property writes, frees,
conditional reads, faulting prefixes and live-out header SSA values prevent
property motion. See examples/buffer-property-loop/main.php.
Raw packed-record addresses are shared before loop accesses become resolved forms. This preserves reuse of an address checked before the loop by an equivalent access in the body, including when its property handle was already cached outside the loop. Remaining straight-line descriptors are shared after loop resolution.
Inside a rewritten loop, packed buffer_get_resolved instructions with the same
descriptor and index compute the same record address, so a dominated access
reuses its dominator’s result. Index operands are compared after seeing through
block parameters whose every incoming edge passes the same value, which scalar
promotion leaves at each join. Scalar element reads are not shared: a
buffer_set_resolved between them can change the loaded value.
Codegen always tests a resolved descriptor for zero. It omits the descriptor
length load and unsigned comparison only for the true-immediate form. Every
other resolved or unresolved access uses one unsigned index < length
comparison; a negative signed index has a large unsigned representation and
therefore reaches the same bounds fatal without a separate signed test.
Straight-line reuse of buffer property reads
After the loops, src/ir_passes/buffer_property_reuse.rs handles the repeated
$this->slots[$i] accesses of straight-line code and reads left inside loops
whose property handle could not be hoisted. Each such access
reads the property, calls __rt_buffer_resolve and recomputes the record
address. Three rewrites share that work, each only across a clean region: every
instruction on any path from the earliest instruction either access depends on
to the later one must keep buffers, buffer-typed properties and the matched
local slots unchanged. The classifier is default-deny: the loop allowlist minus
every opcode that can run user code (even a call whose effect summary is
read-only, since summaries drop the callee’s local effects and matched slots are
not proven free of address escapes), plus
the no-op acquire and release of a buffer handle and named property reads of
$this refined to reads_heap, may_throw. Only the method receiver is trusted,
as a candidate and inside a region: effect refinement drops the null-receiver
warning of any other receiver (a nullable one, or a non-nullable parameter a
dynamic call passed null), and that warning can run a user error handler.
Modules where a class declares __get, or where the eval bridge can declare one,
skip the phase, so no trusted read dispatches to user code. A property store or
unset on any object
(another reference may alias the receiver), buffer_free(), a call, a release,
an allocation, a warning or output ends the region.
- A dominated buffer-typed
prop_getof the same property on a matching receiver in the same inlined class scope (Function::inlined_scopes) is replaced by the dominating read. A read that did not throw first cannot throw the second time, because nothing changed the property. - A dominated packed
buffer_getof the same property-read handle and a matching index reuses the dominating record address. - The remaining accesses of one property-read handle that a leading access
dominates share one
buffer_resolveinserted right before that leader and becomebuffer_get_resolvedorbuffer_set_resolved. A stale handle still dies with the use-after-free fatal at the leader.
Receivers and indexes match when they are one SSA value (seen through block
parameters that every edge feeds the same value), equal integer constants, or
loads of one stable slot: a by-value PHP local that only load_local and
store_local name, which the region does not write. With no user code in the
region, nothing can reach such a slot through a by-reference alias. A buffer
acquire left with nothing but its release is dropped with it. Local buffer
handles in straight-line code keep their per-access form.
Packed field reads and writes (prop_get/prop_set on a packed record) also get
their exact effects during effect refinement (reads_heap for a read;
reads_heap, writes_heap and may_fatal for a write, which may copy a whole
nested record), count as concat-inert, and consume their operands by value for the
by-reference alias analysis, so the loop index and the locals they store stay in
SSA. See the compilation guide.
Effect summaries: purity and may_throw
The optimizer now maintains a small local effect-analysis layer that sits underneath the pruning and dead-code-elimination passes.
Current coverage includes:
- known pure / non-throwing builtins such as
strlen() - registry builtins with argument-sensitive shared effects, such as a typed
count(array)read versus a dynamiccount(mixed)fallback - user-defined functions whose bodies are themselves pure / non-throwing
- user-defined static methods with the same conservative summary inference
- instance methods on
$this,new Class,new self,new parent, andnew static: fixed constructions plus final/private targets stay exact, while virtual calls union every concrete override in the checked class hierarchy - named and literal-dynamic property reads on bounded receivers: untyped slots
are ordinary reads, typed slots can throw when uninitialized, and property
hooks or
__getinherit their callable summaries - indexed and associative literal reads: a proven-present offset is an ordinary read, a proven miss keeps its PHP warning but is not mislabeled catchable, and runtime-dependent offsets retain the conservative barrier
- direct closure calls and local closure aliases
- named first-class callables and expr-calls on those callables
- callable aliases that survive merges through:
if/elsetry/catch/finallyswitch
- callable-producing expressions such as:
- ternaries
??matchwhen every surviving branch agrees on the same callable effect
The AST analysis is intentionally syntax-bounded. After lowering, a second
target-independent fixed point uses checked EIR types and the complete class
table to refine direct calls, virtual calls, and property reads. This gives the
EIR optimizer the same safety distinctions without asking the AST pass to
reconstruct checker state. A runtime eval bridge invalidates closed-world
subclass expansion in both layers; exact fixed constructions and statically
bound final/private targets remain eligible for refinement.
Example
<?php
$f = match ($mode) {
1 => strlen(...),
default => strlen(...),
};
try {
echo $f("abc");
} catch (Exception $e) {
echo pow(2, 8);
}
Because every match arm produces the same known pure / non-throwing callable, the optimizer can prove that the catch path is dead and avoid emitting the pow branch at all.
Destructor throws
PHP runs __destruct implicitly, and a destructor that throws raises a perfectly ordinary
catchable exception. The site that retires the last owner of an object is therefore a throw
site, even though nothing at that site is written as a throw. The exception-flow analysis
models those sites so a catch that a destructor can actually reach is never pruned.
The model is deliberately bounded and conservative:
- It is gated on a program-wide destructor summary. A closed program without a throwing destructor keeps that summary empty. Opaque eval and unsummarized destructor declarations make it conservative instead of assuming there are no implicit throws.
- The summary is the union of every summarized
__destructbody, so a destructor that throws one exact class still routes through ordinary first-match handler matching: acatchfor a disjoint class is still removed. - Destructor enumeration is proven, not assumed. A
__destructsupplied by a trait, declared without a body, declared inside a conditional branch, or declared inside a closure body never reaches the summary collector, so the summary widens to the unknownThrowabledomain instead of silently claiming the program has no destructor throws. A reachableevalwidens it the same way: its source is opaque, so it can define a class with a throwing destructor that no AST in the program mentions. That check reads the SOURCE, so a capability forced on from the command line with noevalcall or'eval'string anywhere leaves nothing to find. - A
new C()temporary names an exact runtime class, so its own destructor resolves through the parent chain precisely, and a subclass source routes the subclass override rather than the parent’s destructor.new self()andnew parent()are early-bound and stay exact;new static()is not, because late static binding can instantiate a subclass whose destructor throws something else. A value of a declared type can likewise name a subclass, so object,mixed,iterable, andcallablepositions use the program-wide summary. Declared scalar types contribute nothing, which is the precision that keeps scalar-returning callees from resurrecting a handler. - Destroying an object also retires the instance storage it owns: declared, inherited,
and promoted properties, and the container children they hold. “This class declares no
__destruct” is not a proof that its children are quiet, so that term is dropped only when the whole layout is provably non-heap scalar. A class with dynamic property storage (#[\AllowDynamicProperties], inherited or declared, or eval-visible property storage) has no such layout proof at all: an instance can hold an object under a name no declaration mentions. - The attributed sites are: call and constructor operands, a retired call receiver, a discarded
expression-statement or
echovalue, a retired callable literal, a write that rebinds storage (includingunset), and a callable frame’s own scope teardown. - A callable literal written at a call site is created and retired there, so it retires
what it binds: explicit
usecaptures by value and by reference, an arrow function’s implicit captures (which the AST does not list, so any arrow function is conservative), the implicit$thisa non-static closure written inside a class body binds, and the receiver a first-class callable binds. A(new C())->m(...)receiver is exact. Nonliteral callees retain conservative call summaries, including their possible retirement. - Borrowed views are not assumed harmless. A variable passed as an argument can become the last owned reference as soon as another argument or the callee rebinds its source, and this pass has no ownership proof to the contrary, so it uses the gated program-wide summary there.
The scope-cleanup proof
A callable frame drops its remaining owners when it returns, so by default every callable summary unions the program-wide destructor summary. That default is sound but coarse: it would erase all exact-class distinction in any program with two unrelated destructor types, and would charge a purely scalar helper for a destructor it can never touch.
The summary is therefore omitted for a callable whose frame is proven to own nothing destructible. The proof is deliberately narrow and has two halves, both required:
- Signature: every parameter is declared with a non-heap scalar type and passed by value, and there is no variadic parameter. An untyped parameter is not proven, because it can hold an object.
- Body: no statement binds unproven frame storage. Named-local increments are a scalar-only
exception. Assignments,
list()unpacking,foreachloop variables,global,static, acatchvariable,extract(),eval(), and any call with an lvalue argument (a possible by-reference out-parameter) all refuse the proof, as does any statement or expression form the whitelist has not been taught. Tracking which local holds what would need a dataflow this AST pass does not have, and getting that wrong would be unsound rather than merely imprecise.
Two things are deliberately outside this term. $this is not charged to the callee frame: the
receiver is owned by the caller, and a (new C())->m() temporary is already attributed to the
call site, so charging it twice would gain nothing and make every instance method unprovable.
And an object’s own property storage is retired when the OBJECT is destroyed, not when one of
its methods returns, so it belongs to the exact-class retirement term above rather than here.
The bound is honest rather than complete: a callable containing any rebinding write keeps unioning the program-wide summary at that write, because rebinding retires whatever the target previously held and this pass cannot say what that was.
The precision cost is concentrated in programs with possible destructor throws, including opaque eval or unsummarized declarations. Unproven retirement sites retain the corresponding handlers.
Why there are six passes
If elephc removed whole branches before type checking, it could accidentally hide useful diagnostics.
For example, imagine:
<?php
if (false) {
$x = "hello";
$x = 123;
}
From an optimization point of view that block is dead. From a compiler UX point of view, it may still be valuable for the checker and warning passes to see it before any aggressive pruning happens.
So the current rule is:
- fold obvious pure scalar expressions early
- propagate known scalar locals only after checking
- prune larger dead control-flow only after checking
- normalize the remaining control-flow into simpler equivalent shapes
- run structural dead-code cleanup only after those earlier passes have already simplified the tree
- prune whole declarations only after diagnostics and statement-level DCE have seen the complete program
Conservatism and side effects
The optimizer is intentionally conservative about what counts as “pure” or “non-throwing”.
It now recognizes a useful subset of call expressions precisely, but it still does not assume purity for broad dynamic operations such as:
- unknown function or method calls
- runtime-computed method names, mixed receivers, eval-defined classes, and instance targets outside the checked closed-world hierarchy
- object creation
- runtime-computed property names when hooks/dynamic storage may intervene, and array offsets whose presence cannot be proven
- buffer allocation
- increment/decrement
throw
That conservatism is why the pass is safe to run by default: if an expression could have runtime behavior and elephc cannot prove otherwise with its local summaries, the optimizer prefers to keep it.
Eval as a dynamic barrier
eval() is the strongest AST-level invalidation case. Its argument is evaluated
normally, but the call itself is observable, may warn/throw/fatal, and can read,
write, create, or unset caller-visible variables and dynamic symbols. Constant
propagation therefore returns Invalidation::All; effect analysis must never
classify eval as pure, even when its return value is unused.
This remains true for a literal source string during AST optimization. A later target-independent planner can lower an eligible literal to EIR and omit the physical interpreter/scope barrier, but the AST optimizer does not speculate across that boundary. The separation keeps type and propagation facts safe while still allowing the backend to produce bridge-free native code. See Eval Runtime Architecture.
Pipe operator optimizations
PHP 8.5’s pipe operator |> is implemented as a dedicated ExprKind::Pipe
node rather than a BinOp, which lets the optimizer reason about it as a
first-class call site rather than an opaque binary operation. Three layers of
optimization apply, end to end:
Effect modelling
ExprKind::Pipe { value, callable } in src/optimize/effects.rs combines
the effects of value, the effects of callable, and the effects of
invoking callable via expr_call_effect. Because the per-target call
effect already collapses to Effect::PURE when the callable is a
first-class callable referencing a pure built-in (strlen, strtoupper, …),
a pure pipe expression statement is observably dead and the DCE pass
removes it. No extra wrapping with with_side_effects() is applied — that
would mask the precise effect of the target and defeat DCE.
Constant folding for pure pipes
src/optimize/fold/pipes.rs::try_fold_pure_pipe is called from
fold_expr’s Pipe branch. When the value is a literal scalar (or
literal array, for predicates) and the callable is FirstClassCallable( Function(name)) referencing a whitelisted pure built-in, the pipe folds to
a literal at compile time. Examples:
"hello" |> strlen(...)→53.7 |> floor(...)→3.0"hello" |> strtoupper(...) |> strrev(...)→"OLLEH"(each stage folds and feeds the next)5 |> is_int(...)→true5 |> gettype(...)→"integer"
The whitelist is intentionally narrow: only built-ins whose Rust
implementation is byte-equivalent to PHP for the literal types we accept.
Non-ASCII strings, NaN/infinity floats, and i64::MIN for abs fall
back to the runtime call so PHP semantics are preserved.
First-class-callable short-circuit (codegen-side)
Tracked from the optimizer’s perspective because the type checker’s
first_class_callable_targets map is mirrored into the codegen Context
and used to bypass the closure wrapper at call sites. $cb = foo(...)
followed by $x |> $cb lowers to a direct bl _fn_foo instead of an
indirect call through the FCC wrapper. The same short-circuit fires for
$cb(args) outside the pipe operator — array_map, call_user_func, or
plain $cb() calls all benefit.
self::method(...) and parent::method(...) are pre-resolved to
Named(class) at FCC variable storage time, so the short-circuit applies
to them too. static::method(...) keeps its Static receiver in storage
and the call site re-uses the caller-scope’s __elephc_fcc_called_class_id
/ __elephc_called_class_id / __elephc_fcc_this / $this chain — the
exact same chain the closure wrapper would consult, just without the
wrapper trampoline.
Dead-wrapper stubbing
When a first-class callable is assigned to a local and every read of that
local is short-circuited (the variable never reaches emit_variable,
emit_closure_call’s fallback path, call_user_func, array_map,
Fiber::new, etc.), the deferred wrapper body is replaced by a tiny
mov #0; ret stub. The address load at the assignment site stays
resolvable, the slot still receives a value, and the binary loses the
~50–100 instructions of dead prologue/body/epilogue per uninvoked
wrapper. Context::mark_fcc_used is the central hook every “this FCC
value escapes” path calls.
What the optimizer does not do yet
The current optimizer is still intentionally local. It does not yet implement:
- full fixed-point/basic-block constant propagation across arbitrary loops and general path merges
- object/property facts, nested-array facts, and per-class constructor effect summaries beyond the current array-literal facts and unioned by-ref signatures
- exact exception inference for unresolved/dynamic calls, open instance-dispatch sets, and runtime operand types beyond the current explicit-throw, exact-callable, and statically proven operator cases
- control-flow normalization that reasons across sibling statements (merging adjacent
ifstatements on the same pure condition, for instance), which needs the reference-volatility ledger the DCE guard state carries - backend-specific peephole cleanup
- elimination of the
adrp/add/sturinstruction triple at the FCC assignment site when the wrapper is stubbed (the stub address still gets loaded and stored even though both are dead)
Those remain roadmap items for later optimization work.
Note that register allocation is no longer on this list: the EIR backend now
runs a linear-scan register allocator (src/ir_passes/), described in
The IR. It is a backend pass over EIR rather than an AST-level
optimization, so it lives outside src/optimize/.
The allocator admits caller-saved homes only across instructions on a default-deny
clobber allowlist. Packed field reads and concrete integer, float, or boolean
field writes qualify because their lowering touches only the fixed result and
documented scratch registers. A nested packed-record write does not qualify:
its word-copy loop consumes secondary and tertiary scratch registers, and a
ptr<T> source first calls the non-null runtime guard. The false field payload
check is safe for this purpose because its error branch throws and never returns
to the guarded store.
During EIR lowering, src/ir_lower/loop_closures/ checks a loop’s condition,
update, and complete body before emitting its closure calls. Identity and the
numeric domain are separate proofs. Both apply with EIR optimization enabled or disabled.
The existing direct path in for and while still requires an invariant local
identity, by-value scalar captures, an exact positional argument list, and a
scalar-or-Mixed signature without references or source variadics. Its arguments are
literals or locals that the condition, update, and body never write, and those
locals must already have physical I64 or F64 storage. Declared parameter
types must match without coercion. Nested loops, assignment expressions,
increments, and continue paths are part of that identity scan. A type seen on
the first iteration is not proof of a later representation.
A second path can direct-call a known local closure when an argument local is
written. A fixed point over the straight-line condition, body, and update must
show that every argument and the closure result stay in PHP int|float. The
seed is the entry slot: logical int with I64 storage, or logical float
with F64 storage. A Mixed label is unknown and blocks this path. Checked
integer arithmetic widens the domain to int|float; that domain is not an I64
proof and does not emit a truncating conversion. The call uses the ordinary
Mixed operand ABI of Op::Call, including by-value captures and the usual
owner cleanup. Locals in the proof whose final slot is Mixed are stored and
loaded as Mixed from the first store, so a later float, null sentinel, or
signed zero is not read back through an integer cast. The admission is recorded
per call span. One admitted call does not admit a different call of the same
local. Bindings retained by the numeric proof carry a separate marker, including
during final-storage replay. An unadmitted call on such a binding keeps descriptor
invocation instead of falling back to the unwritten-scalar gate.
Both paths can retain the compiler-generated func_args::HIDDEN_ARGS_PARAM
collector used by frame metadata, including eval-originated backtraces. This
parameter does not count toward public arity. Its generated name, physical
parameter position, array representation, and spanless ownership prologue must
match the compiler contract. The collector may only be loaded into its owning
parameter shadow; any other read, write, or escape rejects the target. The
ordinary call binder still creates and passes the array with its normal
ownership. User variadics, argument introspection, and other array values do
not qualify. The collector seeds no numeric fact and does not broaden another
call’s admission.
The numeric path requires an untyped Mixed signature, no references, source variadics,
generators, named arguments, spreads, or omitted defaults, and captures that are
absent or by-value int or float. The lowered body must be one block whose
opcodes are in the closed primitive allowlist and have no throw, fatal, or
warning effects. A branch, nested loop, break, continue, unmodeled
assignment, or expression argument such as $value + 0 keeps descriptor
invocation. do/while does not use this path. Fresh bindings created in a
condition do not bypass the identity gate. Refined call effects do not bypass
the opcode allowlist.
IR-level transformations also no longer live here. The EIR backend runs a
fixed-point pass driver (src/ir_passes/driver.rs) after lowering, starting with
identity arithmetic folding (x + 0, x * 1, x ^ x, …) and local peephole
patterns (box/unbox cancellation, scalar load/store forwarding, paired
acquire/release cancellation, string-literal concat folding, redundant
move/borrow cleanup), then scalar local promotion into SSA values with block
parameters at joins and loop back edges, then immutable concrete numeric-local
classification and integer-sink specialization for checked add/subtract/multiply.
Immutable-load classification also covers concrete float locals. It refines integer
loads only in functions with checked integer arithmetic and float loads only in
functions with float arithmetic, so identical generic bodies stay identical for
shape merging. It accepts an
unwritten by-value parameter or one entry-block store that dominates every load,
and rejects by-reference aliases, dynamic eval, other storage representations,
and any unrecognized slot operation. Those passes make proven-stable integer and
float local loads pure and replace transient boxed Mixed arithmetic with
allocation-free ichecked_*_to_int operations only when every use observes an
integer. IntegerRange then propagates inclusive signed 64-bit intervals through
constants, branch comparisons, block arguments, masks, shifts, and integer
arithmetic. A php_rel_cmp of an integer against a checked X - c (c >= 1)
leaves the integer at most PHP_INT_MAX - 1 on the edges where it is below the
bound (src/ir_passes/integer_range/checked_bound.rs): the bound overflows only to
a float at or below -2^63, which no converted integer is below. The mirrored
X + c bound leaves the integer at least PHP_INT_MIN + 1 when it is above it.
Nothing tighter is claimed and no edge is pruned. It recognizes constant-step loop-carried parameters in natural loops
and combines their initial value, comparison bound, and update step to constrain
induction variables on the loop body and back edge. Basic recurrence recognition
is shared with canonical loop optimization, which exposes invariant header values,
coalesces counters with identical overflow semantics, and simplifies integer tests.
LICM moves invariant bounds and step materializations from blocks that dominate
every loop latch. Conditional constants stay near varying uses unless they
accompany an invariant computation. Branch cleanup composes SSA forwarding
arguments and merges single-predecessor loop update blocks. See
Canonical Loop Optimization.
CFG states store only nonredundant path facts. Immutable scalar literals are read from their defining instructions, and an absent scalar integer fact denotes the full signed domain. A full-domain boxed fact remains explicit because it also proves the integer runtime tag. This avoids copying literal and unconstrained scalar entries through every edge of large prelude functions. Fact availability uses an iterative numbering of the existing dominator tree, so pruning each value at a CFG edge does not walk the full dominator chain.
Boolean casts normalize to [0, 1]; sharing the I64 representation with integers
does not make them identity conversions. Integer-sink specialization and numeric
chain fusion require an actual PHP int cast, never a bool cast. Loop-invariant
expression analysis uses an iterative postorder walk and caches unknown results
as well as proven intervals, so deep expressions and shared unsupported graphs
do not cause recursive stack growth or exponential reevaluation.
At joins, a fact survives only if every reachable incoming state provides it;
losing a shift bound invalidates any earlier narrow result interval. Unsupported
comparison predicates leave both edges reachable. Induction summaries require
all edges from each latch to the header to agree, including parallel conditional
and switch edges. Boxed result narrowing is validated as one conservative batch
instead of cloning and validating the whole function for each operation.
Direct static-local assignments remain boxed because their emitter does not
rebox scalar values for Mixed storage, unlike static-local initialization.
The shared boxed_narrowing policy also checks the cast target and storage type:
array casts still require a boxed cell, typed reference stores must retain their
numeric coercions, and static-property, global, and extern stores remain boxed
when the function-local pass cannot establish their complete storage contract.
Scalar casts and representation-polymorphic observations can still narrow.
PhpRelCmp retains boxed operands because its lowering requires runtime tags.
Spaceship narrowing is restricted to numeric or runtime-tagged counterparts,
preserving boxed boolean/null truthiness comparisons. Loose equality compares
integer-tagged boxed payloads with scalar integers exactly, without rounding
through double precision before or after specialization.
With --null-repr=sentinel, boxed integer results also stay boxed when their
proven interval can contain PHP_INT_MAX - 1: narrowing that payload would make
scalar consumers reinterpret an ordinary integer as null. Constant folding applies
the same payload restriction and retains null predicates over ambiguous scalar
sentinel bits. Tagged mode keeps the full integer range available for narrowing;
already-scalar checked integer sinks preserve their existing representation.
Constant folding uses the same consumer policy for both integer and floating-point
results, so a later constant fold cannot bypass a range pass’s representation guard.
Block arguments and other terminator uses keep their declared representation;
direct returns can narrow only to the function’s exact PHP scalar return type.
Checked add, subtract, and multiply instructions become ordinary scalar EIR only
when the complete operand interval proves that every possible result remains in
the signed 64-bit range. The proof uses wider intermediate arithmetic and fails
closed for unknown values, unsupported CFG shapes, exception handlers, invalid
shifts, or incomplete boxed-value use shapes. Every unproven operation keeps its
checked PHP overflow-to-float path. This pass runs after CheckedIntSink, so it
can also remove proven-safe checked integer-sink operations without weakening the
original boxed semantics.
After IntegerRange, CheckedNumericChain may fuse a left-associated
add/subtract/multiply chain whose Mixed intermediates are used only by the next
operation, the final integer cast, and removable Release instructions into
ICheckedNumericChainToInt; its in-range path stays in i64 registers, while the
first signed overflow promotes the exact accumulator and operand, finishes the
remaining suffix in double, and then uses the existing PHP float-to-int conversion.
Per-block constant folding then collapses
operations whose operands are all compile-time constants (5 * 5 → 25,
0 < 5 → true) into a single constant — which, composed with the peephole’s
scalar load/store forwarding, propagates constants through EIR value ids and
local slots — then dominance-aware common-subexpression elimination that reuses a
pure computation already available on every path (per-block and cross-block via a
dominator-tree value numbering), then loop-invariant code motion that hoists pure
loop-invariant computations into loop preheaders, followed by CFG-aware dead
instruction elimination for unused pure result-producing EIR instructions, CFG-aware dead
store elimination
for store_local writes to scalar PHP local slots that are never read before
being overwritten, branch simplification (constant-condition cond_br/switch
folding, empty-block jump threading, and unreachable-block neutralization), and
concat scratch-state optimization (src/ir_passes/concat_reset.rs), which
removes or coalesces statement-boundary concat_reset operations when CFG
dataflow proves _concat_off is already at the frame base or post-dominated by
another reset before any scratch allocation, call, output, exception, or eval
path can observe it. These
are rewrites the AST optimizer cannot express well because they need value
identity, basic blocks, liveness, or dominance; see
Optimization Passes.