Files
BFM-decomp/docs/gcc-2.7.2-map/regalloc.md
T
Drew T 8ba3b82307 feat(phase-24): T7 — 3 Fable5-cracked giants ×134 (func_801392FC/8013A530/8013AF20); fleet 65.64->65.75%
- func_801392FC (182, close=2->MATCH): CROSS-BB COMBINE LAW (a backedge-crossing count is a variable,
  not a foldable (s16)load; reorg steals the sll into the delay slot) + VOLATILE-FRAME-PARITY (volatile
  count-load avoids the cse-common -> combine keep-load -> stale-allocno reload slot). cookbook §36.
- func_8013A530 (204, close=10->MATCH): the RC-6 'reload knot' WAS the pins; the $0-ADD OPAQUE COPY
  (iVar7 = fc + zr, zr=$0) assembles to addu $v1,$a1,$zero but doesn't reserve the reg + 3 zero-byte
  levers. regalloc.md RC-12/RC-13, §36.
- func_8013AF20 (185, close=15->MATCH): the 'impossible' const-hoist order was libgpu addPrim's 24-bit
  BITFIELD store (store_fixed_bit_field emits 0x00ffffff first) + delete the inherited barrier; P_TAG
  lifted to engine_types.h (byte-neutral). loop.md, RC-7, §36.
- banked via the §35 recipe (fix_arity_callers --any-proto -> sig_unify -> harvest_verify ->
  dedup_propagate --recover). clean fleet check-all 136/136, dedup 1806/0, 0 NON_MATCHING.
- SESSION: 7 giants ×134 total (1 R14-free, 2 Opus-solo, 4 Opus->Fable5); fleet 65.48 -> 65.75%.
  gcc-2.7.2 reference tree extended (expmed.c/mips.md/... added). Giant queue (§G) stored for next session.
2026-07-04 04:14:43 -06:00

34 KiB
Raw Blame History

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.


§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 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 (= regno order) is the tie-break for K2, the qty tie-break (local-alloc.c:1745 return q1 - q2), the allocno tie-break (global.c:616 return v1 - v2), and the spill-slot order (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 allocno_compare (per-allocno, REG_LIVE_LENGTH from flow/sched1) and local-alloc.c:1727 QTY_CMP_PRI (per-qty, birth→death insn indices). Higher density allocates FIRST. It is int-truncated; exact ties fall back to creation order (K1). Shrinking a live range raises priority — refs are per-insn-mention, so moving a def later (or a last use earlier) is 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 find_free_reg, over the qty's birth–death window regs_live_at).
  • K4 — Call-crossing ⇒ callee-saved only. global.c:917-922 / local-alloc.c:2205-2209: an allocno/qty with calls_crossed > 0 excludes ALL of call_used_reg_set ($v0–$t9). PSX gcc has flag_caller_saves off → no save/restore fallback. Whether a value crosses a call is decided purely by def/last-use placement in the (post-sched1) insn stream.
  • K5 — Two-pass frugality + preferences. global.c:900 find_reg: pass 0 excludes regs not in regs_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 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 records qty_phys_copy_sugg (local-alloc.c:1905-1945), and suggested qtys are allocated before all others (:1593-1625, fewest-suggestions-first).
  • K6 — The eviction rules ("something else spills"). global.c:1105-1160: an allocno that finds no free reg may KICK OUT everything local-alloc put in some call-clobbered reg, iff local_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 order_regs_for_reload sorts spill candidates by total pseudo refs; the least-referenced reg is confiscated and its pseudos go to retry_global_alloc/stack.
  • K7 — Spill slots. reload1.c:779 assigns slots by looping pseudos in regno order (= K1 creation order) through alter_reg (:2471): one fresh slot per spilled pseudo (never shared for from_reg == -1), total_size = MAX(inherent, reg_max_ref_width); if a narrow pseudo (HImode u16) is ever referenced via subreg:SI, reg_max_ref_width=4 ≠ 2 → assign_stack_local(size, -1) → function.c:697-700: align/round to BIGGEST_ALIGNMENT/8 = 8 (mips.h:1271) → the 8-rounded slot (0x20, 0x28, …). A pseudo with reg_equiv_constant/reg_equiv_memory_loc gets 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) — 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-3932 unused+call_used first, then unused callee-saved, then by ascending use count hard_reg_use_compare :3822). In arg/temp-busy functions that is $t0 — hence the lhu/sh $t0 tell. Explicitly-used regs (from register __asm__ pins!) get uses += large+1 AND bad_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): the sw back 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) → the lw/lhu order 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 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 .lreg identical. Swapping the two declarations u16 local_40; u16 local_38; → 15-off with EXACTLY the four slot accesses flipped 0x20↔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-slot must 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-use addiu $sp address 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 allocno_compare density (K2) + find_reg eviction (K6); live lengths written by sched1 (sched.c:5067).
  • 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): moving local_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 the a3 param to the stack (sw $a3,0x20($sp)/lw $a1,0x20($sp) in the diff; dispositions in dumps/exp2.i.greg: base has no 88 in … + 77 in 30; exp2 has 88 in 23 + 77 gone). match_one 11 → 137 mismatched. Read the direction from the dumps, not by guessing: .lreg lines Register N used R times across L insns give you every contender's (R, L); compute floor_log2(R)*R/L to 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 on local_40 but flipped the allocation when applied to local_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).
  • 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 (no regs_explicitly_used side 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 combine_regs tying (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_regs refuses 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:

  1. The init copy. pinned = expr computes DIRECTLY into the hard reg (no copy). pinned = other_var emits a real move — it dissolves only if local-alloc can put other_var in 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 save move $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.
  2. Reload-pool poisoning (byte-verified, dumps/exp3.c): regs_explicitly_used[r] → bad_spill_regs (reload1.c:3900-3915). A dead top-of-function pin of $8 flipped the dump from "Spilling reg 8" to "Spilling reg 9" — every lhu/sh $t0 became $t1 function-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).
  3. Pass-0 availability shift: an explicit $sN sets regs_ever_live[N] → $sN is "already dirty" in find_reg pass 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.
  4. 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 pCval its $s1 reuse → 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_alloc indexes births/deaths by insn number, so ANY source edit (barrier, temp, reorder) moves REG_DEAD notes → different ties → different regs_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 update_equiv_regs + reload1.c:2471 alter_reg no-slot path. A single-set pseudo equal to a constant/stable-MEM gets REG_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). &sp_buffer (an addiu $sp,off value) is such a constant.
  • The call killer: validate_equiv_mem (local-alloc.c:583) 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], pass buf) — the address stays a rematerializable constant, addiu $sp re-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_LENGTH is doubled (local-alloc.c:1153) — 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 != 1 fails the update_equiv_regs gate) — 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 (: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.
  • 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 try_split) → mips.md:3208 large_int define_split turns li BIG into lui + ori = reg_n_sets==2 → fails the single-set gate (local-alloc.c:1021, 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] at update_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 the lw.
  • Store back to slot missing: delete_output_reload :7274 — the pseudo was dead. Your extra sw means 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 lw order before one insn: operand order (emit_reload_insns :5977) — swap the source operands.
  • $t0/$t1 alternation flipped mid-function: round-robin last_spill_reg state (: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 has t2 = 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

  1. 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).
  2. Wrong value spilled / param evicted? → dump .lreg, compute densities, shift the marginal def/use (RC-2). Iterate — 1 statement at a time.
  3. $s-order permuted? → equal densities: decl-order swap; else pins per target map (RC-3), ONE at a time with the RC-5 checklist.
  4. Stray/missing move? → tie analysis (RC-4), or single-variable rewrite for cross-block (RC-9).
  5. Reload artifact diffs? → almost always upstream; use RC-8 to find WHICH upstream cause.
  6. 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-1c exp1c_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.greg shows reg88 gained in 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 $8 pin 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 — through minus/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_far minus regs_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 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/: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; regs_used_so_far pre-seeded with call-used regs, global.c:352-355). 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); 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→rA and copy rC=rA as TWO live regs, compares reading rA, arithmetic reading rC. A pseudo copy int c = a; is destroyed by canon (cse.c:826 make_regs_eqv head-promotion + canon_reg rewriting every pseudo use); with a pinned, 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-identical addu $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 #APP markers 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) affects not only the spill-reg identity but the reload-driven allocation retry: an allocno that failed main find_reg (here: the div-magic Need 1 reg of class LO_REG/MD_REGS spill path) is re-allocated by retry_global_alloc with bad_spill_regs forbidden. Seed: $v1/$v0 pins → 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 + a Spilling reg N line 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 lh is hoisted by sched1 (backward list scheduler; blocking insn N for 1 cycles mem-unit hazard against the sh; 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 as x > (int)mem mirrors 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) — 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.