Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
Structured ordinary-loop numeric phis
Status: accepted isolated candidate on codex/perf-structured-loop-phis, based on
the composed record-template and structured self-TCO work (0cc0289d). No
runtime, object, frame, semantic mcode, or persistent target-IR format changes.
Motivation
The native backend already assigns exact pointer-free local slots to QBE temps.
QBE then inserts SSA phis and keeps those values in registers across ordinary
backedges. The old eligibility rule rejected every slot in a function if the
function contained any inline call, even when the call was confined to a cold
arithmetic-panic branch which could never rejoin the loop.
That is the dominant spectralnorm shape. The inliner has already expanded
sn_a into each row loop, but a dead/cold get plus the explicit numeric-error
path remains. sum, the induction, and every scratch therefore went through
tagged frame slots on every inner iteration.
Falsification arms
The first arm simply removed the whole-function call rejection. It proved the lever but was too broad:
| benchmark | baseline native | broad arm | result | emitted IL | assembly |
|---|---|---|---|---|---|
| spectralnorm | 123.232 ms | 85.143 ms | -30.9% | 184,086 -> 177,762 B | 121,998 -> 119,193 B |
| fannkuch | 187.486 ms | 230.478 ms | +22.9%, reject | 178,984 -> 178,754 B | 117,570 -> 136,441 B |
Both programs returned their exact golden results. The fannkuch regression is backend register pressure: promoting every typed scratch in its large nested permutation CFG expands spills and assembly even though IL is essentially flat. This arm has been deleted.
Narrow candidate
The replacement constructs a temporary final-index CFG only for functions which have ordinary inline calls but no handler or dispatcher-only invoke. For each backward edge it:
- proves the target dominates the latch by cutting the target and checking reachability from entry;
- constructs the natural-loop node set by walking predecessors from latch to header;
- rejects any outside entry to a non-header node;
- rejects a loop containing an opcode whose local-slot uses are not modeled;
- selects a slot only if the loop both defines it and has a path which reads the header value before the next definition.
The selected slot is therefore a loop-header value identity with an entry and backedge definition, rather than every typed physical slot in the function. The existing exact write-type and integer-range facts then choose the lane:
- exact bounded integer: raw signed-int register;
- semantic
numunder nan64: raw double register; - bool or non-raw exact immediate: tagged pointer-free register.
QBE remains responsible for physical phi insertion. The established native suspension liveness pass is authoritative: only values live at a cold continuation are encoded and spilled to the retained, GC-walkable frame before unwinding. On a normal call return the copied frame pointer is refreshed while the pointer-free QBE values remain valid.
Captured slots remain explicit cells. Handlers, invoke/apply dispatcher seams, unknown loop operations, irreducible/multi-entry loops, and functions above 2,048 final instructions reject conservatively. A function with more than six eligible loop phis rejects as a whole. This target-independent register budget avoids the demonstrated fannkuch spill cliff without choosing a benchmark- specific subset.
The six-phi cutoff is an empirical profitability guard, not a semantic theorem or a claim about one host’s exact register file. It is deliberately below the common allocatable integer/FP register budgets of the project’s native targets, leaves room for the frame/context/array arithmetic temps around each loop, and makes selection identical across QBE targets. A future backend-specific pressure model could admit more values, but the conservative cutoff costs only missed optimization and prevents the already-observed spill/code-size cliff.
Exact decline conditions
No ordinary-loop phi is selected when any of these is true:
- the function has a disruption handler, dispatcher
invoke/apply, no inline call to recover from, more than 2,048 final instructions, or more than six otherwise-eligible phis; - the candidate backedge target does not dominate the latch;
- reverse predecessor closure finds an outside entry at a non-header node;
- any instruction in the natural loop has an unmodeled local-slot use shape;
- the slot is not both defined in the loop and read on a header path before its next definition;
- the slot is an argument, captured/address-taken cell, or lacks exact
pointer-free
int/bool/nan64-numwrite type; - a proposed integer lane lacks the existing whole-slot int32 bound (it may still use a tagged or raw-double lane when that exact type permits it).
Memory and code tradeoff
There is no persistent target or runtime data. The CFG, predecessor arrays, natural-loop sets, and value sets are compiler-temporary. Compiler time and temporary memory are not campaign gates, although the analysis is function-size bounded so the self-host compiler still builds within its existing worker limit.
At runtime the selected values occupy ordinary native registers/spill slots. The Pit frame layout and heap allocation are unchanged. The successful broad spectralnorm arm reduced generated IL and assembly, while the rejected broad fannkuch arm demonstrated why the six-phi pressure gate is necessary.
Final controlled results:
| program | disabled | enabled | result | QBE IL | QBE asm | linked __text |
|---|---|---|---|---|---|---|
| spectralnorm | 120.032 ms | 78.544 / 78.001 ms | -34.6% to -35.0%, exact 1623647009 | 184,086 -> 177,333 B (-3.67%) | 121,998 -> 118,266 B (-3.06%) | 33,052 -> 31,912 B (-1,140 B) |
| fannkuch | 182.379 ms | 186.354 ms | byte-identical artifact; timing drift, exact 3000008629 | 178,984 -> 178,984 B | 117,570 -> 117,570 B | identical dylib hash |
| loop_nested | 12.197 ms | 11.806 ms | byte-identical artifact; timing drift, exact 2 | 27,255 -> 27,255 B | 25,574 -> 25,574 B | identical dylib hash |
Mach-O file length is page/alignment dominated and remains 87,632 bytes for
both spectralnorm dylibs; the meaningful linked code delta is the 1,140-byte
__text reduction. The two affected row functions each shrink by 504 bytes;
the other functions retain their exact size. Runtime frame bytes, heap bytes,
allocation traffic, and persistent metadata are unchanged.
Correctness gates
The focused compiler suite currently passes 105/105. New exact tests require:
- a raw-double accumulator and raw-int induction across cold call regions;
- rejection of a function with more than six carried values;
- rejection of a multi-entry/irreducible candidate region;
- rejection of a captured loop-carried binding.
native_suspend_loop_phi.ce is the moving-GC/suspension oracle. Its array load
retains the cold numeric-error call shape while its continuing loop carries a
raw double and raw integer. Under the native suspension runner it must return
exactly 50,000 after repeated backedge suspensions and collections. With a
reduction budget of 64 and PIT_SUSP_TEST_GC=1, it returned 50,000 after 48
suspensions and 48 moving collections (maximum native depth 1). Its ordinary
native execution returned the same value.
Tooling evidence
The first restored-candidate spectralnorm repeat crashed during cold shop boot
in mcode_lower_func_node, from compile_boot while compiling an internal shop
actor. No benchmark dylib had run. This is the campaign’s already-observed
cold self-host realization fault; the exact retry compiled the same candidate
dylib hash and returned the correct result at 78.001 ms. It is not counted as
an optimization failure, but it remains high-value tooling work because it
turns a controlled A/B into a several-minute recovery/retry.
Focused compiler tests pass 105/105. The full default suite passes 1,918/1,918. The first sandboxed full run reached 1,901 passes but denied six local loopback binds; the unrestricted rerun was clean. Deterministic fuzz passes 3,733/3,733 at seed 20260713.
Source: plans/archive/perf-2026-07/perf-structured-loop-phis.md