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:

benchmarkbaseline nativebroad armresultemitted ILassembly
spectralnorm123.232 ms85.143 ms-30.9%184,086 -> 177,762 B121,998 -> 119,193 B
fannkuch187.486 ms230.478 ms+22.9%, reject178,984 -> 178,754 B117,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:

  1. proves the target dominates the latch by cutting the target and checking reachability from entry;
  2. constructs the natural-loop node set by walking predecessors from latch to header;
  3. rejects any outside entry to a non-header node;
  4. rejects a loop containing an opcode whose local-slot uses are not modeled;
  5. 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 num under 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-num write 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:

programdisabledenabledresultQBE ILQBE asmlinked __text
spectralnorm120.032 ms78.544 / 78.001 ms-34.6% to -35.0%, exact 1623647009184,086 -> 177,333 B (-3.67%)121,998 -> 118,266 B (-3.06%)33,052 -> 31,912 B (-1,140 B)
fannkuch182.379 ms186.354 msbyte-identical artifact; timing drift, exact 3000008629178,984 -> 178,984 B117,570 -> 117,570 Bidentical dylib hash
loop_nested12.197 ms11.806 msbyte-identical artifact; timing drift, exact 227,255 -> 27,255 B25,574 -> 25,574 Bidentical 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