[FIX] RA: register allocator spill/reload closed loop via linear scan#17
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates ScratchV’s RISC-V register allocation pipeline to support a liveness-driven spill→reload cycle in the linear-scan allocator, adjusts compiler defaults/routing to use linear-scan directly, and adds stress/regression DSL tests targeting loop back-edges and high vreg pressure.
Changes:
- Add spill-store and reload insertion machinery to
LinearScanAllocator, plus a newemit()one-shot entry point. - Fix the “linear” compiler path to run linear-scan on raw virtual-register machine instructions (not on already-greedily-allocated code); set the default
reg_allocto"linear". - Add stress tests for loop back-edge correctness and 32-vreg pressure; adjust assembler
lwparsing to accept bothoffset(base)andbase(offset)syntaxes.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/stress/reg_pressure_loop.meta.json | New metadata for loop back-edge RA regression stress test. |
| tests/stress/reg_pressure_loop.dsl | New DSL program stressing loop header LABEL + accumulator across iterations. |
| tests/stress/reg_pressure_32.meta.json | New metadata for 32-vreg pressure spill/reload stress test. |
| tests/stress/reg_pressure_32.dsl | New DSL program creating peak liveness to force spilling. |
| scratchv/compiler.py | Default reg_alloc to linear; linear pipeline now runs linear-scan directly via emit(). |
| scratchv/backend/riscv_encoder.py | Extend lw operand parsing to support alternate memory operand syntax; add parsing logic. |
| scratchv/backend/register_alloc.py | Remove greedy allocator’s LABEL-boundary _flush_regs() behavior. |
| scratchv/backend/regalloc_linear.py | Implement spill/reload scheduling and emit() entry point for linear-scan allocation. |
Comments suppressed due to low confidence (2)
scratchv/backend/regalloc_linear.py:370
- Reload positions are only recorded for
use_pos > current.start. For read-modify-write instructions where a vreg is both used and defined at the same instruction index (e.g.addi iv, iv, 1),use_poscan equalcurrent.start, and the allocator will omit the reload that must happen before the instruction reads the old value.
# Record reload at every future use of the spilled vreg
for use_pos in spill_interval.uses:
if use_pos > current.start:
self._reloads.setdefault(use_pos, []).append(
(spill_interval.vreg, slot))
scratchv/backend/regalloc_linear.py:382
- In the
spill current intervalpath, the allocator unconditionally pickstemp_reg = self.phys_regs[0]and assigns it tocurrent.vregeven though the whole reason we’re in this branch is that all registers are occupied. This will overwrite whichever still-live vreg currently ownsphys_regs[0]and produce incorrect code.
To spill the current interval safely, you need either (a) a dedicated scratch register that is never allocated, or (b) an explicit eviction step that stores some live register to its slot (before the instruction) and then reuses that freed register as the temporary.
# Otherwise, spill the current interval
slot = self._get_spill_slot(current.vreg)
# Assign a temporary register for the definition instruction
temp_reg = self.phys_regs[0]
self.alloc_map[current.vreg] = temp_reg
self._spilled.add(current.vreg)
self.spill_code.setdefault(current.start, []).append(
f" sw {temp_reg}, {slot}(sp) # spill {current.vreg}"
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if current.end <= spill_interval.end: | ||
| # Spill the farthest interval | ||
| # Spill the farthest active interval (victim) | ||
| slot = self._get_spill_slot(spill_interval.vreg) | ||
| active.pop(spill_idx) | ||
| # Emit store after the definition point | ||
| self.spill_code.append( | ||
| (spill_interval.start, "sw", | ||
| f"{spill_reg}, {slot}(sp) # spill {spill_interval.vreg}") | ||
| self.spill_code.setdefault(spill_interval.start, []).append( | ||
| f" sw {spill_reg}, {slot}(sp) # spill {spill_interval.vreg}" | ||
| ) | ||
| # Remove stale mapping so codegen won't use the freed register | ||
| if spill_interval.vreg in self.alloc_map: | ||
| del self.alloc_map[spill_interval.vreg] | ||
| self._spilled.add(spill_interval.vreg) |
| mem_op = operands[1] | ||
| if "(" in mem_op: | ||
| before = mem_op[:mem_op.index("(")] | ||
| after = mem_op[mem_op.index("(") + 1:mem_op.index(")")] | ||
| if before in REG_MAP or before.startswith("x"): | ||
| # Compiler syntax: lw rd, rs1(offset) | ||
| rs1 = _reg_num(before) | ||
| offset = self._parse_imm(after) if after else 0 | ||
| else: | ||
| # Standard syntax: lw rd, offset(rs1) | ||
| offset = self._parse_imm(before) if before else 0 | ||
| rs1 = _reg_num(after) | ||
| else: | ||
| offset, rs1 = 0, 0 |
| def _evict_for_reload( | ||
| self, rename: dict[str, str], used: set[str], current_pos: int, | ||
| ) -> str: | ||
| """Evict a live register to make room for a reload. | ||
|
|
||
| Picks the vreg whose interval ends farthest away, generates a | ||
| spill store to its stack slot, and records future reloads for | ||
| its remaining uses. | ||
| """ | ||
| farthest_vreg: str | None = None | ||
| farthest_end = -1 | ||
| for vreg, preg in rename.items(): | ||
| if preg not in used: | ||
| continue | ||
| interval = self._vreg_interval.get(vreg) | ||
| if interval is not None and interval.end > farthest_end: | ||
| farthest_end = interval.end | ||
| farthest_vreg = vreg | ||
|
|
||
| if farthest_vreg is None: | ||
| return self.phys_regs[0] | ||
|
|
||
| evicted_reg = rename[farthest_vreg] | ||
| slot = self._get_spill_slot(farthest_vreg) | ||
| self._spilled.add(farthest_vreg) | ||
|
|
||
| # Emit spill store BEFORE the reload (evictions go before reloads) | ||
| self._evictions.setdefault(current_pos, []).append( | ||
| f" sw {evicted_reg}, {slot}(sp)" | ||
| f" # evict {farthest_vreg} for reload" | ||
| ) | ||
|
|
||
| # Record future reloads for remaining uses of the evicted vreg | ||
| interval = self._vreg_interval.get(farthest_vreg) | ||
| if interval is not None: | ||
| for use_pos in interval.uses: | ||
| if use_pos > current_pos: | ||
| self._reloads.setdefault(use_pos, []).append( | ||
| (farthest_vreg, slot)) | ||
|
|
||
| del rename[farthest_vreg] | ||
| return evicted_reg |
Implement liveness-driven spill→reload cycle in LinearScanAllocator: - spill() now records reload positions for every future use of a spilled vreg; removes stale alloc_map entries after freeing a reg - get_allocated_code() interleaves lw reloads before uses and sw spills after definitions - New emit() one-shot entry point: intervals → allocate → codegen - Fix compiler.py linear mode: process raw vreg instructions directly instead of re-scanning already-allocated physical registers - Remove broken _flush_regs() at LABEL boundaries in greedy allocator that tore live ranges across labels and caused infinite loops Fix reload register selection and add spill eviction: - _pick_reload_reg() now filters by actual liveness at current instruction position using interval.contains(current_pos), preventing stale rename snapshots from occupying reload slots - New _evict_for_reload() picks farthest-end vreg to evict when all registers are genuinely occupied, with spill store emitted before the reload lw in get_allocated_code() Fix RISC-V encoder lw dual-syntax compatibility: - Support compiler-emitted format "lw rd, rs1(offset)" alongside standard "lw rd, offset(rs1)", mirroring existing sw handling Add stress tests for spill/reload path: - reg_pressure_32: 32 simultaneously-live vregs via fan-in tree, exceeding the 27-register linear scan pool - reg_pressure_loop: 10-iteration loop with 6-chain intermediates and cross-iteration accumulator, validates LABEL does not corrupt vreg mappings (regression test for _flush_regs() removal) Signed-off-by: Seven Gao <799889633@qq.com>
67eb684 to
f2223e4
Compare
修复了两个问题,
附修改review
RA_commit_architect_review.md
ai建议的修改:
pr-17.md
Implement liveness-driven spill→reload cycle in LinearScanAllocator: