48 KiB
gcc-2.7.2 REGISTER ALLOCATION + RELOAD → residual→C-lever catalog
Pass group: local-alloc.c (2491) · global.c (1714) · reload.c (6204) · reload1.c (8626), in tools/reference/gcc-papermario/.
Produced: 2026-07-02 (Phase 23), by source read + -da RTL dumps + byte experiments via
tools/match_one.py <fn> --c FILE --asm-subdir asm/ov_SC01_077/nonmatchings/ov_SC01_077.
Prime exemplar: func_801571C4 (198 ins, .run/fable/func_801571C4.c, baseline 11-off); experiment variants in .run/gccmap/ (exp1_slotorder.c, exp1c_declorder.c, exp2_defmove.c), RTL dumps in .run/gccmap/dumps/.
How to use: read §A once (the 8 knobs), then triage a diff with §C (tells) → apply the §B lever for the matched class. A cheap agent should never need the gcc source again for these classes.
⚠️ SOURCE-VERSION AUDIT (Phase 29 SESSION-23, 2026-07-28) — READ BEFORE TRUSTING A CITATION
This file was written against
tools/reference/gcc-papermario/, which is gcc 2.8.1 — NOT our 2.7.2 (established Phase 23; vanilla 2.7.2 is staged attools/reference/gcc-2.7.2/). Line numbers throughout are therefore for the WRONG COMPILER, and the drift is large and non-uniform (combine_regs+103,allocate_reload_reg+377,choose_reload_regs+611) — big enough to land inside a different function. Re-derive withgrep -n '^symbol ('before citing anything here; do NOT rebase mechanically.The audit: all 184 citations and claims were re-derived against the real 2.7.2 source by 5 parallel agents; every REFUTED claim then went to an independent agent whose job was to refute the refutation, defaulting to upholding the map. Result: 119 CONFIRMED · 40 LINE-DRIFT (mechanism intact, number wrong) · 21 raised as REFUTED, of which 14 were OVERTURNED and only 7 stand · 4 unverifiable. So the model in this file is overwhelmingly sound — do not delete levers. The seven real corrections are marked [A23] inline below. Two were byte-proven against the real
cc1, not just read.The 7 corrections, in descending consequence:
- K4
flag_caller_savesis ON, not off (toplev.c:3387-3394 [2.7.2]sets it atoptimize >= 2; proven by compiling with/without-fno-caller-saveson the pinned cc1 → different code). A call-crossing value is NOT restricted to$s0-$s7-or-spill. Diagnostic that was missing: caller-save slots are 4-BYTE-PACKED (16,20,24,28) while reload spill slots are 8-ROUNDED (16,24,32) — misreading one for the other sends you to RC-1 and decl reordering, the wrong lever.- RC-7's premise is false:
&sp_bufferis NOT a "rematerializable constant".CONSTANT_P(rtl.h:237-240 [2.7.2]) admits only LABEL_REF/SYMBOL_REF/CONST_INT/CONST_DOUBLE/CONST/HIGH — a frame address is(plus (reg vsv) K), a PLUS, which never qualifies, so it gets a real slot +lw. Only SYMBOL_REF/CONST_INT equivalences get the no-slot remat. (Independently corroborated by a byte-test the same day: the cse_expr.md §2 remat recipe provably fails to dissolve a frame-address hoist seeded by a struct copy — see.run/near6/f132F40_v*.c.)- The "init MOVED to just before its use" pass does not exist in 2.7.2. That is 2.8.1-only (papermario
local-alloc.c:1236-1265 [2.8.1 pm]); 2.7.2'supdate_equiv_regssubstitutes the equivalence into the use and DELETES the init (local-alloc.c:1090-1116 [2.7.2]). The diagnostic built on it tests for a pass we do not have.- K2 refs are LOOP-DEPTH-WEIGHTED, not per-insn-mention:
flow.cdoesreg_n_refs[regno] += loop_depth. Hand-computed densities are wrong inside loops — read the.lregnumber instead.- K1: qty numbers come from BIRTH order, not regno order.
alloc_qty/next_qty++run during the forward block scan (local-alloc.c:284 [2.7.2]). Regno order governs allocnos and spill slots only.- RC-15/K2:
allocno_live_lengthis the DENOMINATOR (global.c:594-597 [2.7.2]) — priority is a density (refs per insn of life), so a LONGER live range LOWERS priority. The map had the sign of that term wrong.- Pins do NOT kill the sched S2 birthing boost (see §F below, and the same correction landed in
sched.mdthe same day).birthing_insn_p(sched.c:2469 [2.7.2]) tests onlyGET_CODE (SET_DEST (pat)) == REG— hard regs qualify; the discriminator isreg_n_sets == 1(:2490). Decisive:sched.c:423 [2.7.2]in the SAME FILE does add>= FIRST_PSEUDO_REGISTERwhen it wants pseudos only, so the omission at:2478is deliberate.Hand-verified 2.7.2 anchors (
grep -n '^sym ('):allocno_compare586 (priority expr 594-597, tie-breakreturn *v1 - *v2609) ·find_reg904 ·global_conflicts613 ·update_equiv_regs947 ·block_alloc1123 ·combine_regs1722 ·alloc_qty279 ·find_free_reg2072 ·alter_reg2309 ·allocate_reload_reg4706 ·choose_reload_regs4903 ·validate_equiv_mem559.
§A The decision machinery — 8 knobs everything below reduces to
Pass order (relevant slice): flow → combine → sched1 → local_alloc → global_alloc → reload → sched2 → dbr.
sched1 rewrites the life data regalloc consumes (sched.c:5067 [2.8.1 pm] REG_LIVE_LENGTH(regno) = sched_reg_live_length[regno]) — this is the exact channel by which instruction placement flips allocation (K2 below). Any scheduling lever (cookbook §30 birthing-boost, statement order) is therefore ALSO a regalloc lever, and vice versa.
- K1 — Pseudo creation order. Params get pseudos first, in PARAMETER-LIST order (
function.c:assign_parms); then user locals in DECLARATION order (pseudo made when the decl is expanded at block entry — NOT at first assignment; byte-proven, §B RC-1); compiler temporaries interleave at first need. Creation order is the tie-break for K2, the qty tie-break (local-alloc.c:1745 [2.8.1 pm]→2.7.2qty_compare_1,return q1 - q2), the allocno tie-break (global.c:616 [2.8.1 pm]→2.7.2 :609return *v1 - *v2), and the spill-slot order (K7). [A23] "creation order (= regno order)" is only half right: qty numbers are handed out byalloc_qty/next_qty++duringblock_alloc's FORWARD block scan (local-alloc.c:284 [2.7.2], scan starts:1165-1169) — i.e. BIRTH order, which equals regno order only when the pseudos are first touched in declaration order. Regno order genuinely governs allocnos (global.c:397 [2.7.2]) and spill slots (K7). - K2 — Density priority. Both allocators use the same formula:
pri = (int)(((double)(floor_log2(n_refs) * n_refs) / live_length) * 10000 * size)—global.c:594 [2.7.2] allocno_compare(per-allocno, REG_LIVE_LENGTH from flow/sched1) andlocal-alloc.c:1727 [2.8.1 pm] QTY_CMP_PRI(per-qty, birth→death insn indices). Higher density allocates FIRST. It isint-truncated; exact ties fall back to creation order (K1). Shrinking a live range raises priority — [A23]allocno_live_lengthis the DENOMINATOR (global.c:594-597 [2.7.2]), so priority is a density and a LONGER live range LOWERS it; that is why shrinking works. [A23] refs are LOOP-DEPTH-WEIGHTED, NOT per-insn-mention:flow.cdoesreg_n_refs[regno] += loop_depth, so a mention inside a loop counts for more than one. Hand-computing a density inside a loop gives the wrong answer — read the.lregRegister N used R times across L insnsline instead. Moving a def later (or a last use earlier) is still the lever. - K3 — Hard-reg scan order. MIPS defines no REG_ALLOC_ORDER (
config/mips/mips.h) → every scan is plain regno order:$v0,$v1,$a0..$a3,$t0..$t7,$s0..$s7,$t8,$t9,$fp. First fit wins. So: first callee-saved allocno →$s0, next →$s1, …; local temps → lowest free scratch (local-alloc.c:2176 [2.7.2] find_free_reg, over the qty's birth–death windowregs_live_at). - K4 — Call-crossing ⇒ callee-saved only.
global.c:917-922 [2.8.1 pm]/local-alloc.c:2205-2209 [2.7.2]: an allocno/qty withcalls_crossed > 0excludes ALL ofcall_used_reg_set($v0–$t9) on the first attempt.PSX gcc has[A23] FALSE —flag_caller_savesoff → no save/restore fallback.flag_caller_savesis ON for us.toplev.c:3387-3394 [2.7.2]sets it insideif (optimize >= 2)and we build-O2; byte-proven on the pinnedcc1(compiling a pressure-heavy function with vs without-fno-caller-savesgives different code; the-O2default emitssw $3/$8/…immediately beforejaland matchinglws after — artifacts in.run/regalloc_audit/A-machinery/{cs.c,on.s,off.s}). So whenCALLER_SAVE_PROFITABLE(REFS,CALLS)=4*calls < refs(regs.h:165 [2.7.2]) holds,global.c/local-alloc.cretry withaccept_call_clobbered=1and a call-crossing value CAN live in a caller-saved reg with save/restore around the call. [A23] THE DIAGNOSTIC THIS FILE WAS MISSING — caller-save slots are 4-BYTE-PACKED (0x10,0x14,0x18,0x1C), reload spill slots are 8-ROUNDED (0x10,0x18,0x20). A packed run ofsw/lwhugging ajalis a caller-save, NOT a spill: reading it as RC-1 and starting to reorder declarations pulls entirely the wrong lever. Whether a value crosses a call is still decided purely by def/last-use placement in the (post-sched1) insn stream. - K5 — Two-pass frugality + preferences.
global.c:900 [2.8.1 pm] find_reg: pass 0 excludes regs not inregs_used_so_far("never allocate a register for the FIRST time in pass 0",:945-948) and regs someone else prefers; pass 1 opens fresh regs. ⇒ gcc reuses already-dirty regs before opening a new callee-saved (minimal.mask). Copy preferences (global.c:1538 [2.7.2] set_preference, from moves to/from hard regs — param copies, retval copies, pinned-var copies) can override the scan-order winner within the class (:1000-1065). Local-alloc analog: a move touching a hard reg recordsqty_phys_copy_sugg(local-alloc.c:1905-1945 [2.8.1 pm]), and suggested qtys are allocated before all others (:1593-1625, fewest-suggestions-first). - K6 — The eviction rules ("something else spills").
global.c:1105-1160 [2.8.1 pm]: an allocno that finds no free reg may KICK OUT everything local-alloc put in some call-clobbered reg, ifflocal_reg_n_refs[r]/local_reg_live_length[r] < allocno_n_refs/allocno_live_length— scanning from the HIGHEST regno down. Reload's version:reload1.c:3840 [2.8.1 pm] order_regs_for_reloadsorts spill candidates by total pseudo refs; the least-referenced reg is confiscated and its pseudos go toretry_global_alloc/stack. - K7 — Spill slots.
reload1.c:779 [2.8.1 pm]assigns slots by looping pseudos in regno order (= K1 creation order) throughalter_reg(:2471): one fresh slot per spilled pseudo (never shared forfrom_reg == -1),total_size = MAX(inherent, reg_max_ref_width); if a narrow pseudo (HImode u16) is ever referenced viasubreg:SI,reg_max_ref_width=4 ≠ 2→assign_stack_local(size, -1)→function.c:697-700 [2.7.2]: align/round toBIGGEST_ALIGNMENT/8 = 8(mips.h:1271 [2.8.1 pm]) → the 8-rounded slot (0x20, 0x28, …). A pseudo withreg_equiv_constant/reg_equiv_memory_locgets no slot (rematerialized) — see RC-7. - K8 — Local vs global split & tying. local-alloc handles only pseudos with
REG_BASIC_BLOCK >= 0 && REG_N_DEATHS == 1(local-alloc.c:472 [2.8.1 pm]) — single-block, dies once. It TIES (coalesces) op0's dest into a dying input at the dest's FIRST set (:1360-1450→combine_regs :1825; conditions: dest has no qty yet, input dies here, classes/modes compatible). global.c has NO coalescing at all — only the K5 preferences. ⇒ any cross-block register sharing in the target means the ORIGINAL RTL had one pseudo there: your C must express it as ONE variable (or an in-place update), not a copy.
Reload's own knobs (all downstream consequences):
- Spill/reload reg identity = the first wholly-UNUSED call-clobbered reg in regno order (
order_regs_for_reload :3922-3932unused+call_used first, then unused callee-saved, then by ascending use counthard_reg_use_compare :3822). In arg/temp-busy functions that is $t0 — hence thelhu/sh $t0tell. Explicitly-used regs (fromregister __asm__pins!) getuses += large+1ANDbad_spill_regs(:3900-3915) → a pin on $t0 moves every reload artifact to $t1 function-wide (byte-verified, §B RC-5: "Spilling reg 8" → "Spilling reg 9"). - With ≥2 spill regs, per-insn choice is round-robin from
last_spill_reg(allocate_reload_reg :5083) — history-dependent: one extra/missing reload upstream rotates every later reload-reg choice. Don't chase these; fix the upstream count. - Inheritance: a later input reload of the same pseudo reuses the still-live spill reg with NO new
lw(choose_reload_regs :5514-5590,reg_last_reload_reg/reg_reloaded_contents; invalidated at labels/jumps and at any clobber of the spill reg). - delete_output_reload (
:7274): theswback to the slot is deleted when the pseudo is dead after the insn and unmentioned in between — why a final-use spill access can be load-only, and why a dead-at-end pseudo's re-set emits nothing (571C4 idiom 2). - Reload insns for one insn are emitted bucketed by type and operand number (
emit_reload_insns :5977) → thelw/lhuorder before an insn follows source operand order.
§B Residual-class catalog
RC-1 — Spill-slot offsets wrong / swapped (sh $t0,0x28 vs target 0x20) — STEERABLE
- Decision point:
reload1.c:779 [2.8.1 pm]slot loop (regno order) + K1 creation order. - Lever: reorder the DECLARATIONS of the spilled locals (and, for param-fed spills, know that the surviving pseudo order is the PARAMETER order). Statement/assignment order is a NO-OP for declared locals — pseudos exist before the first statement runs.
- Byte-proof (func_801571C4): swapping the two assignments
local_40 = a2; local_38 = a10;→ identical bytes (11-off, unchanged;exp1_slotorder.c); pseudo stats in.lregidentical. Swapping the two declarationsu16 local_40; u16 local_38;→ 15-off with EXACTLY the four slot accesses flipped0x20↔0x28(idx 20/23/97/145;exp1c_declorder.c). So: to move a spill slot, move the decl. - Slot SHAPE (8-rounding + $t0 access) — the companion lever (already proven in the 571C4 draft): a value the target accesses as
lhu/sh $t0, 8-rounded-slotmust be a plain u16/s16 local (a genuinely SPILLED pseudo, K7); a value accessed via$v0-line scratch regs is a local-alloc temp; a value with per-useaddiu $spaddress math is a source-level MEM (array/struct). Choosing the C shape chooses the class — a Ghidra-draft*(u16*)&sp[..]MEM shape can NEVER reproduce a spilled-pseudo access pattern.
RC-2 — Wrong value spilled / wrong value in a callee-saved ("$aN spilled", value stuck on stack) — STEERABLE, double-edged
- Decision point:
global.c:594 [2.7.2] allocno_comparedensity (K2) +find_regeviction (K6); live lengths written by sched1 (sched.c:5067 [2.8.1 pm]). - Lever: move the def later / the last use earlier (in source order) to SHRINK the pseudo's live range and raise its priority — or the reverse to lower it. Every insn the range crosses counts; crossing/not-crossing a call also flips K4 eligibility entirely.
- Byte-proof (func_801571C4,
exp2_defmove.c): movinglocal_40 = a2;from before to after the first call shrank reg88's range 94→89 insns → its priority rose past the marginal allocno → it flipped from SPILLED to $s7 and evicted thea3param to the stack (sw $a3,0x20($sp)/lw $a1,0x20($sp)in the diff; dispositions indumps/exp2.i.greg: base has no88 in …+77 in 30; exp2 has88 in 23+ 77 gone). match_one 11 → 137 mismatched. Read the direction from the dumps, not by guessing:.lreglinesRegister N used R times across L insnsgive you every contender's (R, L); computefloor_log2(R)*R/Lto see who is marginal and how far. A ~5% length change flipped this one. - Coupling warning: any construct that changes an insn's position for the scheduler (comma-defs, extra temps, barriers) changes L for every pseudo whose range spans it. This is why §30's zero-code
__asm__re-tie can still flip allocation: it adds a REF and (if placed inside a range) lengthens nothing but raises R. 571C4 idiom 2's margin note stands: the same dead-asm trick was priority-safe onlocal_40but flipped the allocation when applied tolocal_38— after every such edit, re-run match_one; check the dumps if it exploded.
RC-3 — Callee-saved ORDER permutation ($s0/$s1/$s2 assignments shuffled) — STEERABLE
- Decision point: allocation ORDER = descending K2 priority; each takes the lowest free callee-saved (K3), pass-0/1 (K5); ties → creation order (
global.c:616 [2.8.1 pm]). - Primary lever (byte-proven, cookbook §17):
register s32 v __asm__("$16..$23")pins on each call-crossing value, mapped from the target.s(flagship func_8012B8E4, ×134). Pins short-circuit the whole priority contest. - Zero-cost pre-check before pinning: if two contenders'
.lreg(refs, live-length) are IDENTICAL, the swap is a pure K1 tie — swap their declaration order instead of pinning (noregs_explicitly_usedside effects, RC-5). If densities differ, a decl swap does nothing (verified: unequal-density micro-case is order-stable) — use pins or RC-2 range shaping. - Diagnostic: allocation order is readable directly: in the target prologue, the FIRST-allocated allocno is the one in $s0, etc. Compare with your draft's
.greg"Register dispositions" line to see which pseudo won each hard reg.
RC-4 — Copy-coalescing: a move vanishes into an operand, or arithmetic routes through the copy's reg — STEERABLE
- Decision point:
local-alloc.c:1825 [2.7.2] combine_regstying (K8): at the dest's first set, if a source operand DIES there, they share a qty. - Lever (byte-proven, cookbook §25, func_80128ED8): (a) pin the preserve-copy to the target's register — a hard-reg dest can't be tied (
combine_regsrefuses hard↔hard and only suggests for hard↔pseudo) → the copy materializes separately, exactly where the target has it; (b) route the dependent arithmetic through a dead, target-scratch-pinned temp so the result lands in the target's scratch instead of the copy's reg. - Un-tying without pins: the tie needs (dest-first-set ∧ source-dies-here). Either give the source a later use (kills the REG_DEAD) or give the dest an earlier set (kills first-set). A dead second read of the source after the copy (
__asm__ volatile("" :: "r"(src))— zero code) breaks the tie at zero cost, but counts a ref (RC-2 margin check applies). - Reverse direction (target DID tie, your draft has an extra
move): express the two C variables as ONE (reuse the variable in place) so expand emits a single pseudo — per K8 global has no coalescing, so a cross-block "merge" can only come from a shared pseudo in the original source shape.
RC-5 — Pin side-effects: when a register __asm__ pin is byte-clean vs when it cascades — characterization (the §17 caveat map)
A pin makes every ref of the variable an explicit HARD reg from RTL-expansion onward. Four separable side-effect channels — check each against the target before pinning:
- The init copy.
pinned = exprcomputes DIRECTLY into the hard reg (no copy).pinned = other_varemits a realmove— it dissolves only if local-alloc can putother_varin the same reg (block-local, dies at the copy, window free → self-move deleted). If the source is call-crossing/multi-use, the move STAYS. ⇒ pin is byte-clean iff the target shows the same copy (e.g. the ABI param savemove $s0,$a0) or the source qualifies for the dissolve. The 571C4-P6/func_801770E0 "pin-copy insns the target lacks" cases are exactly pins whose init source was a value the target's allocator had tied. - Reload-pool poisoning (byte-verified,
dumps/exp3.c):regs_explicitly_used[r]→bad_spill_regs(reload1.c:3900-3915 [2.8.1 pm]). A dead top-of-function pin of$8flipped the dump from "Spilling reg 8" to "Spilling reg 9" — everylhu/sh $t0became$t1function-wide. ⇒ never pin the function's reload reg (usually $t0) unless the target shows non-$t0 reloads; conversely, if the TARGET reloads via $t1 while YOUR draft says $t0, the original source likely used $t0 explicitly — or another spill preceded (round-robin). - Pass-0 availability shift: an explicit
$sNsetsregs_ever_live[N]→$sNis "already dirty" infind_regpass 0 → OTHER allocnos that don't conflict may now grab it earlier, re-shuffling the whole callee-saved order. This is why over-pinning "2/3/9 winners" cascades (571C4 header): each pin re-ranks the remaining contest. - Range blocking: the pinned reg is live (for local-alloc) over the pinned var's def→last-use; if the target reuses that architectural reg for another value inside your pin's scope, that value is forced elsewhere (func_801770E0: scratch pins cost
pCvalits$s1reuse → fresh$s6+ frame change). Lever: narrow the pin's scope — inner-block{ register … }or reuse an already-dead pinned temp (§25's proven move). Verdict rule: pin exactly the registers whose target live ranges you can see, one at a time, re-running match_one after each; a pin that regresses the count is hitting channel 2/3/4 — remove it and treat via RC-2/RC-4 levers or RC-6.
RC-6 — Pressure-locked flat allocation (coalescing knife-edge) — INTRINSIC → decomp-permuter (with a stabilizing pin)
- What it is: straight-line, high-pressure code whose values chain through calls (
v = f(v,…)repeatedly) with register pressure ≈ available regs. Every dest ties to a dying input (K8), so the allocation is one long tie-chain;block_allocindexes births/deaths by insn number, so ANY source edit (barrier, temp, reorder) moves REG_DEAD notes → different ties → differentregs_used_so_far→ different global pass-0 → different reload order. The whole solution re-derives; there is no local fix. - Why it's intrinsic: the residual is not a mis-decision to steer — the regalloc is EXACT (func_801770E0, func_8014EA4C both have every register right) — it's that the remaining schedule diff cannot be touched without collapsing the allocation. The C-source space around the target is discontinuous.
- Route: decomp-permuter, after pinning ONE anchor value to stabilize the callee-saved core (byte-banked finding on func_801770E0:
register u32 param_2 __asm__("$18")holds param_2→$s2 so the permuter can perturb scheduling without the alloc cascading). Exemplars: func_801770E0 (53-off floor, all toolkit levers fail with cascades), func_8014EA4C residual-B (abs phi-copy, 22 formulations swept, every pressure-changing form triggers DSE→frame shrink). - Recognize it: match_one diff where registers/opcodes are all correct but a block of insns is PLACED differently, AND every small C edit changes 20+ instructions instead of 1-3. Two probes = enough; don't grind.
RC-7 — Rematerialization vs spill/hoist (per-use addiu $sp vs callee-saved base; constant re-li vs slot) — STEERABLE
- Decision points:
local-alloc.c:1007 [2.7.2] update_equiv_regs+reload1.c:2471 [2.8.1 pm] alter_regno-slot path. A single-set pseudo equal to a constant/stable-MEM getsREG_EQUIV→ if it fails to get a hard reg, reload substitutes the equivalence per-use (no slot, no lw:reg_equiv_constant/reg_equiv_memory_loc).[A23] FALSE, and load-bearing — a FRAME ADDRESS IS NEVER&sp_buffer(anaddiu $sp,offvalue) is such a constant.CONSTANT_P.rtl.h:237-240 [2.7.2]admits onlyLABEL_REF | SYMBOL_REF | CONST_INT | CONST_DOUBLE | CONST | HIGH.&sp_bufferis(plus (reg virtual-stack-vars) K)— a PLUS — so it fails theCONSTANT_Pgates atlocal-alloc.c:1031 [2.7.2]andreload1.c:566 [2.7.2], keeps only a REG_EQUAL note (never promoted to REG_EQUIV), and therefore gets a real slot +lwif it loses its register. OnlySYMBOL_REF/CONST_INTequivalences get the no-slot remat. (Corroborated by byte-test, same day:cse_expr.md§2's remat recipe demonstrably fails to dissolve a frame-address hoist seeded by a struct-copy source address — 47→40 mismatches, never 0, across three kill placements; ladder at.run/near6/f132F40_v1..v6.c.) Consequence: do not reach for RC-7 to make a frame address remat — that is a different mechanism from the constant case, and the sentence below about decayed arrays is the part that actually works. - The call killer:
validate_equiv_mem(local-alloc.c:583 [2.7.2]) rejects a MEM equivalence if ANY call occurs between the load and the reg's death (non-RTX_UNCHANGING_P) → a stack-local's VALUE loaded before a call can't be rematerialized from its home slot; it must hold a reg or spill to a NEW slot. Address-CONSTANTS survive calls; MEM-values don't. This asymmetry explains the whole class. - Lever (byte-proven, cookbook §17, func_8012B4B8 88→52): pass a stack buffer as a decayed array (
T buf[N], passbuf) — the address stays a rematerializable constant,addiu $spre-emitted per call, no callee-saved consumed. Any address-taken form (&struct,mtx.w,*(T*)arr) forces the address into a pseudo that crosses calls → callee-saved + bigger frame. - Priority side-effect to know: an equiv-reg's
REG_LIVE_LENGTHis doubled (local-alloc.c:1153 [2.8.1 pm]) — deliberately halving its K2 priority ("reload can always recreate it"). So a single-set address/constant var LOSES callee-saved races on purpose. If the target holds such a value in $sN anyway, your draft must defeat the equiv: give the var a second set (REG_N_SETS != 1fails theupdate_equiv_regsgate) — the same REG_N_SETS knob as §30's birthing-boost, one pass earlier.Also: a set-once-used-once non-block-local pseudo gets its init MOVED to just before its use ([A23] REMOVED — that pass does not exist in gcc 2.7.2. The cited:1230-1270,LIVE_LENGTH=2, calls_crossed=0) — if the target shows the init far from the use (or vice versa), this pass did/didn't fire; check REG_N_REFS==2.:1230-1270is papermario/2.8.1local-alloc.c:1236-1265 [2.8.1 pm]. Vanilla 2.7.2'supdate_equiv_regs(:947-1117) substitutes the equivalence into the use and DELETES the initializing insn (:1090-1116) — it never relocates it. So "init far from its use" tells you nothing about this pass; do not use it as a diagnostic. - 2-INSTRUCTION CONSTANTS get the second set FOR FREE → they escape the doubling (Phase 24 T7, byte-proven func_8013AF20, gdb-verified): sched1 pre-reload-splits every insn (
sched.c:4830 [2.7.2] try_split) → mips.md:3208large_intdefine_split turnsli BIGinto lui + ori =reg_n_sets==2→ fails the single-set gate (local-alloc.c:1021 [2.7.2], vanilla line) → no ×2 penalty. A lui+ori const (0x00ffffff) therefore out-prioritizes every same-ref 1-instruction const (addiu-able or lui-only, whose LL doubled) and allocates FIRST. When a preheader const-register contest looks "impossible by density", check the contenders' instruction counts before anything else (gdb:((short*)reg_n_sets)[N]atupdate_equiv_regs). Full chain: cookbook §36.
RC-8 — Reload artifacts: reg identity, missing/extra lw, missing sw, reload order — DETERMINED (fix upstream, don't chase)
These are pure consequences; a diff here means an UPSTREAM divergence (slot count/shape, block structure), except the listed direct levers:
- $t0 vs $tN identity: K6/RC-5(2). Deterministic given which regs the fn uses. If the whole function's reload regs are off-by-one ($t1 where $t0 expected), something in your draft explicitly uses $t0 (a pin) or uses one MORE simultaneous spill reg.
- Second load of a spilled var missing in the target: reload inheritance (
:5514) — the target's two uses sit in one extended-bb run with the spill reg untouched. If YOUR draft re-loads, your uses are separated by a label/jump or by a reload-reg-clobbering insn — restructure the branch (e.g. §16 shared-ret0 goto keeps a run unbroken) rather than fighting thelw. - Store back to slot missing:
delete_output_reload :7274— the pseudo was dead. Your extraswmeans your pseudo has a LATER ref the target's doesn't (often a duplicated use you added). Conversely 571C4 idiom 2 exploits it: a dead__asm__ volatile("" : "=r"(x))re-set emits nothing precisely because of this deletion. - Reload
lworder before one insn: operand order (emit_reload_insns :5977) — swap the source operands. - $t0/$t1 alternation flipped mid-function: round-robin
last_spill_regstate (:5083) — count reloads upstream of the flip; you have one extra/missing reload event earlier. Fix that; never pattern-match the rotation itself.
RC-9 — Cross-block copy the target folded (phi-style move your draft can't remove) — STEERABLE in C-shape, else permuter
- Mechanism: per K8, global-alloc cannot coalesce. If the target computes a value in-place across blocks (e.g. in-place negate for
abs) where your draft hast2 = cond ? -t1 : t1-shaped RTL, no allocator setting will merge them — the ORIGINAL C had one variable mutated in place. - Lever: rewrite as a single variable updated in place (
if (v < 0) v = -v;), matching def/use block structure to the target's. - Limit (the 8014EA4C wall): when every single-variable form ALSO perturbs frame/pressure (dead-store elimination of an init memcpy, frame shrink), the class degenerates to RC-6 → permuter. Probe 3-4 in-place forms; if each is frame-fragile, stop.
§C Diagnostic tells (read these off the match_one diff / dumps first)
| Tell | Meaning | Class |
|---|---|---|
lhu/sh $t0, N($sp), N in 8-rounded run (0x20,0x28,…) |
reload access to a SPILLED pseudo (u16 w/ subreg:SI widening) | RC-1 |
| same-slot accesses but offsets swapped vs target | pseudo creation (DECLARATION) order differs | RC-1 |
value accessed via $v0/$v1/$aN scratch |
local-alloc temp (single-block, dies once) — NOT a spill | RC-1/RC-4 |
per-use addiu $rX,$sp,off at each call |
rematerialized address constant (array-decay shape) | RC-7 |
sw $aN,slot / lw of an incoming param |
a param allocno LOST the callee-saved race (priority flip) | RC-2 |
| whole $s-assignment permuted, insns otherwise right | allocation-order permutation | RC-3 |
| copy emitted before its source's other use, use reads the copy's reg | local-alloc TIE (coalescing) | RC-4 |
your draft has move the target lacks (same block) |
target tied them / your pin blocked a tie | RC-4/RC-5 |
| ALL reload regs shifted $t0→$t1 | explicit use (pin) of $t0 poisoned the pool | RC-5 |
| pin added → 20+ insn regression | pass-0 shift / range blocking cascade | RC-5→RC-6 |
| regs+opcodes exact, block placement off, every edit explodes | pressure-lock | RC-6 |
target lacks a 2nd lw of a spilled var |
reload inheritance (unbroken run) | RC-8 |
target lacks the sw back to slot |
delete_output_reload (pseudo dead) | RC-8 |
move $vN,$sN-style copy into a call-crossing reg missing |
equiv-reg init got MOVED to its use / replaced | RC-7 |
Dump crib (get these free from cc.sh-style compile with -da): .lreg → Register N used R across L insns; crosses C calls (K2 inputs per pseudo); .greg header → ;; Register dispositions: <pseudo> in <hardreg> (who won what; missing pseudo = spilled), Spilling reg N. (reload reg identity), ;; N conflicts/preferences lines. Hard-reg numbers: 2=$v0 3=$v1 4-7=$a0-$a3 8-15=$t0-$t7 16-23=$s0-$s7 24/25=$t8/$t9 29=$sp 30=$fp/$s8 31=$ra.
§D Triage order for a "regalloc"-class near-miss
- Slot offsets off? → reorder declarations (RC-1). Slot access SHAPE wrong ($t0 vs $v0 vs addiu)? → change the C storage shape (RC-1/RC-7).
- Wrong value spilled / param evicted? → dump
.lreg, compute densities, shift the marginal def/use (RC-2). Iterate — 1 statement at a time. - $s-order permuted? → equal densities: decl-order swap; else pins per target map (RC-3), ONE at a time with the RC-5 checklist.
- Stray/missing
move? → tie analysis (RC-4), or single-variable rewrite for cross-block (RC-9). - Reload artifact diffs? → almost always upstream; use RC-8 to find WHICH upstream cause.
- Everything right but placement, edits explode? → RC-6: stabilizing pin + decomp-permuter. Stop hand-grinding after 2 probes.
§E Experiment record (reproducibility)
All vs func_801571C4 target, baseline draft .run/fable/func_801571C4.c = 11-off (prologue position ripple only).
- EXP-1
exp1_slotorder.c(swap assignments): 11-off, byte-identical diff → statement order is dead. EXP-1cexp1c_declorder.c(swap decls): 15-off, new mismatches EXACTLY idx 20/23/97/145 = the 0x20↔0x28 slot accesses. - EXP-2
exp2_defmove.c(def after call): 137-off;dumps/exp2.i.gregshows reg88 gainedin 23($s7) at live length 94→89 and the a3-param pseudo lost its reg (spilled;sw $a3,0x20($sp)in diff). - EXP-3
dumps/exp3.c(dead$8pin at fn top):.greg"Spilling reg 8" → "Spilling reg 9"; all reload accesses moved to $t1. - Micro tie-probe (
dumps/tie1.c/tie2.c): unequal-density pair is order-stable under statement swap (confirms decl-order lever only bites on exact K2 ties). Cited proofs from earlier sessions: func_8012B8E4 (§17 pins), func_80128ED8 (§25 tie pins), func_8012B4B8 (§17 array-decay), func_801770E0 + func_8014EA4C (RC-6 walls,.run/toolkit/), func_801571C4 header idioms 1-2 (.run/fable/).
§F Phase-24 T5b extension — preference mechanics + the S11/RC-6 verdict downgrade
RC-10 — The PREFERENCE CASCADE (why a scratch temp chases a specific arg reg) — read it, then steer around it
- set_preference (global.c) unwraps ONE expression level: for
(set DEST (op X ...))where the insn pairs a HARD reg with a pseudo, the pseudo gets a preference bit — throughminus/ashiftrt/any first-operand expression, not just copies. So(set (reg $a1) (ashiftrt t 16))makes t prefer $a1 (observed: the ratan2-arg extend temps,109 preferences: 5). - expand_preferences merges through DEATHS: when allocno A dies in an insn that SETS allocno B and they don't conflict, their preference sets IOR both ways. Preferences therefore flow BACKWARD through dying-def chains (arg-reg ← extend-temp ← variable ← the temps that died into it). A conflict between the two allocnos BLOCKS the merge (the pair-1 subu's operands conflict its dest — re-born later — so that link is naturally blocked; the final subu's operands don't).
- find_reg grant order: pass 0 scans only
regs_used_so_farminusregs_someone_prefers[allocno](prefs of LOWER-priority conflicting allocnos, pruned of each allocno's own hard-reg conflicts) — the "reuse already-dirty regs" frugality is why a free incoming arg reg ($a0 after an early param-copy death) gets grabbed by a high-priority scratch temp. Pass 1 opens fresh regs (plain regno scan). THEN copy-prefs and plain prefs OVERRIDE the first-fit if the preferred reg merely doesn't conflict. - Levers (byte-proven, func_8014E048): (1) keep the incoming arg regs BUSY through the contested windows via body-local param copies (sched.md S13) — a hard-reg conflict beats every preference; (2) rebalance a K2 density race that a dead-read disturbed by reading BOTH contestants in the one asm; (3) when the target shows a copy that cse would dissolve, use the asm-copy form (sched.md S13).
RC-6/S11 — VERDICT DOWNGRADED (Phase 24 T5b): "intrinsic → permuter" was map-incompleteness
func_8014E048 (35-off, "S11 LUID⊗alloc coupling, not source-steerable, permuter seed" — and still 28-off
after the §31-directed permuter) is MATCHED and whole-binary BANKED by composing: the S12 reused-s32-temp
fence + S13 body-local param copies + the asm-copy + an RC-4b target-scratch-pinned store temp + a two-input
dead-read fence + s32-with-(s16)-cast-at-def vars. Rule of thumb going forward: an "S11/RC-6 intrinsic"
verdict is trustworthy only after (a) auditing PINS (they kill the S2 boost and [A23] they do NOT kill the S2 boost — birthing_insn_p, 2.7.2 sched.c:2469 [2.7.2], tests only GET_CODE (SET_DEST (pat)) == REG, so HARD regs qualify; the real gate is reg_n_sets == 1 at :2490. Decisive: sched.c:423 [2.8.1 pm] in the same file DOES add >= FIRST_PSEUDO_REGISTER where it wants pseudos only, so the omission is deliberate. Same correction landed in sched.md §1.7/§S12 the same day. Check the SET COUNT, not the pin) and they add suggestion-ties,
(b) trying the S12 fence for any load-batching residual, (c) trying S13 for scratch-identity/wedge residuals.
True RC-6 (every edit explodes 20+ insns) still exists — but the class is SMALLER than Phase-21..23 believed.
The promoted-HI store-copy law also falls out: b[0] = (short)dx with dx a PSEUDO emits copy+sh (the HI temp
is real); with dx PINNED (hard reg) the subreg folds and the copy vanishes — another reason pinned drafts
diverge from the original's unpinned shape.
RC-11 — The 3-qty local-alloc SORT BUG + the zero-byte asm allocation toolkit (Phase 24 T5, cookbook §34) — STEERABLE (decoy qty / dummies)
local-alloc.c:1441-1463 [2.7.2]/:1494-1516: for a block with ≤3 local register quantities the unrolled compare switch compares fixed qty numbers (qty_compare(0,1),(1,2),(0,1)) but exchanges order slots — when pri(q1) is highest the third compare re-fires and undoes the first swap, so ≤3-qty blocks allocate in qty-CREATION order, not density order (≥4 qtys → qsort, correct). A genuine gcc-2.7.2 bug. Symptom: a low-density local grabs a reg the density order would deny. Fix: a zero-instruction decoy qty (asm("":"=r"(decoy):"r"(x)); asm("" :: "r"(decoy));) bumps the block to 4 qtys → the qsort path → correct density first-fit (find_reg, global.c:904 [2.7.2]; regs_used_so_far pre-seeded with call-used regs, global.c:352-355 [2.7.2]).
The zero-byte asm toolkit (allocation/schedule dials that emit NOTHING; the byte-gate certifies): input-only asm("" :: "r"(v)) floats to v's def (ref/density dial); multi-input asm("" :: "r"(a),"r"(b)) anchors at the latest def (lifetime-extender / joint-release → rank_for_schedule class rule sched.c:2385 [2.7.2]); def+use pair mints the decoy qty above. gdb-on-cc1 (find_free_reg/post_mark_life) settles the real qty order when hand-modeling stalls. Cite vanilla tools/reference/gcc-2.7.2/local-alloc.c.
§G Phase-24 T7 extension — the func_8013A530 clamp crack (RC-6 "reload-pressure" downgraded again; 3 new levers)
Exemplar: func_8013A530 (204 ins, close=10 "RC-6 reload-pressure" → MATCH, .run/t7/func_8013A530.c; dumps/variants .run/t7/fable/a530/). The "pressure" was three separable, steerable decisions:
RC-12 — The $0-ADD OPAQUE COPY: force a live move that neither cse nor combine can remove/reverse — STEERABLE (new tool)
- Problem shape: target keeps
load→rAandcopy rC=rAas TWO live regs, compares reading rA, arithmetic reading rC. A pseudo copyint c = a;is destroyed by canon (cse.c:826 [2.7.2] make_regs_eqvhead-promotion +canon_regrewriting every pseudo use); withapinned, combine instead REVERSES it (load absorbed into the pseudo via the added-sets path, the pin becomes the copy dest, 1 insn short). No plain-C spelling survives both. - Lever:
register int zr __asm__("$0"); c = a + zr;— RTL(plus rA (reg 0)): not a reg-reg set (no qty merge, no canon), not load-absorbable (no extend+plus pattern), assembles to the byte-identicaladdu $rC,$rA,$zero. $0 is fixed → no regs_explicitly_used/bad_spill side channels (unlike any real-reg pin). - Alternative for the same class: the S13 asm-copy (
asm("":"=r"(c):"0"(a)), reload materializes the tie-move) — but its#APPmarkers constrain placement (see below); the $0-add is a real, freely schedulable insn. Prefer $0-add.
RC-5 channel 2 EXTENDED — pins poison retry_global_alloc, moving registers function-wide where the pinned var is DEAD
regs_explicitly_used → bad_spill_regs(reload1.c:3900-15 [2.8.1 pm]) affects not only the spill-reg identity but the reload-driven allocation retry: an allocno that failed mainfind_reg(here: the div-magicNeed 1 reg of class LO_REG/MD_REGSspill path) is re-allocated byretry_global_allocwith bad_spill_regs forbidden. Seed:$v1/$v0pins → CASE1's 2nd-product "Register 178 now in 10" (mflo $t2); unpinned → "now in 3" (mflo $v1). Tell: a scratch-reg diff in a region where no pinned var is live + aSpilling reg Nline in.greg→ audit pins, not liveness.
RC-13 — Reload-birth vs pin/pseudo death is set by CONDITION OPERAND ORDER (sched1 backward + mem-unit hazard), not by allocation
- The clamp's after-store reload
lhis hoisted by sched1 (backward list scheduler;blocking insn N for 1 cyclesmem-unit hazard against thesh; boosted-group ties break toward higher uid) to the block TOP when the condition is spelled(int)mem < x(mem expands first, lowest uids) — birthing the reload INSIDE the previous value's live window → hard conflict → the "reload-pressure" mis-allocation. Spelling the SAME canonical slt asx > (int)memmirrors the uids (the else-arm's natural order) and keeps the lh below the killing insn → both values share the reg disjointly. Zero-byte, zero-semantic lever; check both spellings before any RC-6 verdict.
RC-11 toolkit CAVEAT — #APP blocks the maspsx/ASPSX-2.56 delay-slot hop
cc1 emits [lh;lh;addu;slt;beqz]; the target's [..;slt;beqz;addu-in-slot] + load-delay nop layout is made by maspsx hopping the eligible addu over the slt into the branch slot. Any zero-byte asm dummy between the addu and the branch emits #APP/#NO_APP and kills the hop (costs exactly 1 insn + a cascade). Placement rule: park density/lifetime dummies inside an arm, as a 2-input asm("" :: "r"(v),"r"(t)) anchored at a local def — never in a block tail the assembler must reorder. (Density effect proven: +1 ref lifted the allocno from 3/10 to 8/11 allocno_compare priority, flipping the $v1/$a0 grant order.)
local-alloc suggestion note (companion to K5) — a dying pinned reg SUGGESTS itself into the next dest
A compare/arith result qty whose insn kills a HARD (pinned) input records qty_phys_sugg; suggested qtys allocate FIRST trying only that reg (local-alloc.c:1593-1625 [2.8.1 pm]) — the else-compare's slt temp took the dying fc pin's $a1 over free $v0. A zero-byte keepalive read of the pinned var AFTER the insn removes the death → no suggestion → plain first-fit. Tell: a scratch result landing in an arg/pinned reg that dies in the same insn.
§H Phase-27 Fable5 wave — the reg_renumber-swap oracle + 3 more RC-6 downgrades (2026-07-15)
Three seed cracks (.run/giants/{func_8014D820,func_801670E4,func_8016CBC0}.fable.md) continuing the §F/§G theme — an "RC-6 intrinsic" verdict is usually map-incompleteness. The durable output is one diagnostic and two levers.
THE reg_renumber-SWAP ORACLE — discriminate RC-6 (allocation) from S3 (scheduling) in ONE gdb run
[A23] PRECONDITION — CHECK
.gregFIRST, or this oracle will hand you a confident wrong answer.reg_renumbermaps PSEUDOS ONLY (index ≥FIRST_PSEUDO_REGISTER, which is 68 on MIPS —config/mips/mips.h:1179 [2.7.2]). If the diff's contested registers are already hard at.gregtime they are structurally unreachable here, and a swap returns a large number that looks like a verdict and is not one. The check: grep the.gregRTL for the contested instruction.(reg/v:SI 6 a2)with6 < 68is a HARD register — an incoming parameter reg, aregister __asm__pin, or a local-alloc reuse — not something this oracle can move. Only(reg:SI 130)-style operands qualify. Second precondition — the contest must be NARROW. The swap is global acrossreg_renumber, so if the two registers serve many pseudos it destroys the allocations that were already right. Count them first; the harness prints the number it moved. Byte-measured onfunc_80176734(2026-07-28), baseline 13 mismatches: control swap$31↔$31reproduced 13 ✓ ·$a0↔$a2moved 17 pseudos → 345 ·$v1↔$a1moved 31 pseudos → 97. The.gregread then showed the destination was(reg/v:SI 6 a2)— hard, a reused incoming parameter register — so the true class was local-alloc tying (K8/RC-4), not RC-6 global allocation, and the lever is C-level lifetime shaping. Both preconditions failed silently. Mechanized harness (with its negative control):tools/oracle/reg_renumber_swap.sh.
The single highest-value tool of the wave. When a residual is "register identity AND an instruction reordering" you cannot tell from the diff whether the reorder is a scheduling decision (S3, C often can't move it) or a mere consequence of the register grant (fix the regs and the schedule follows). Test it directly: break at reload entry, patch reg_renumber (a short*; find its address in the cc1 map) to swap the two contested hard regs, let compilation finish, and diff. If the block goes byte-exact (schedule included), the residual is 100% ALLOCATION — the reorder was a consequence, route to the density/lifetime levers, NOT to a scheduling lever or the permuter's schedule mode. (func_801670E4: the seed called its dominant residual an S3 sched.c priority wall — "C cannot lower a priority"; the swap oracle emitted the store-before-load target byte-exact, proving it was pure register 2-coloring. Generalizable: store-before-load reordering is often a CONSEQUENCE of register identity, not a scheduling cause — swap-oracle it before invoking S3/S4.)
RC-14 — reused-load-temp SERIALIZATION (the MERGE pole; pin-free, cheap-Opus-applicable)
To reproduce a target that serializes two independent load-fed computations through the same 2 scratch regs (target reuses $v0/$v1 across dx and dz), reuse ONE set of named C temps (t0,t1) across both computations. Because gcc-2.7.2 has no coalescing (K8), the read-before-second-write anti-dependence forces the serial load schedule AND the register reuse. This is §45-Lever-A (merged accumulators) applied to load temps, and the MERGE pole of the SPLIT↔MERGE duality (§44-Lever-3 SPLITs a reused output pointer for per-tail first-fit; RC-14 MERGEs load temps for the shared-reg serial schedule). Byte-proven func_8014D820 block-0 261→110. Companion: route a param through a plain self = param local to land its callee-saved copy INSIDE the entry block (vs a copy-at-head) — measured 287→110.
RC-15 — the DENSITY DIAL across a floor_log2 boundary (the ref-count axis of RC-6)
global.c:594 [2.7.2] allocno_compare priority ≈ live_length << floor_log2(n_refs). When two allocnos tie on live_length and differ by one n_refs step that crosses a power-of-2 (7→8, 15→16 …), the multiplier doubles and the density order flips — deciding which wins the callee-saved reg by creation order. Dial it with an input-only anchor at the right loop depth: __asm__("":: "r"(v)) inside a loop_depth-2 body adds reg_n_refs += 2 (flow.c:2067 [2.7.2]), lifting the target var across the boundary. This is the ref-count-axis companion to §47's live-length slider, and it subsumes the "coalescing knife-edge" mislabel for callee-saved swaps (there is no coalescing in gcc-2.7.2, K8 — the "knife-edge" was always this density tie). Byte-proven func_8016CBC0 root-A byte-zero. (The #APP placement caveat from RC-11 applies: anchor at a local def inside an arm, never in a reorderable block tail.)
The local-vs-global allocation tie (a named RC-6 sub-class, honest wall for the C tier)
A reused scalar (2+ deaths → GLOBAL allocno) loses $v0 to a single-use sibling (1 death → LOCAL allocno) purely because local-alloc runs before global-alloc (local-alloc.c single-death gate), so the local grabs the low reg first and the global takes what's left — the opposite of the target's equally-valid 2-coloring. No pin-free C spelling reaches it (block-confined globalization has no legal spelling: cross-block → call-crossing; asm → #APP perturbation). This is a genuine residual for the cheap tier → decomp-permuter (its temp-merge randomizers are the only automated path) or an accepted honest stub. func_801670E4 region B (17 diffs) is the exemplar. But note it is a precisely-named allocator tie, not a "whole-function permutation" — the swap oracle tells you which you have.