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

197 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.