Skip to content

[FIX] RA: register allocator spill/reload closed loop via linear scan#17

Merged
FeelTheBeats merged 1 commit into
ScratchV-Compiler:mainfrom
FeelTheBeats:seven_main2
Jul 26, 2026
Merged

[FIX] RA: register allocator spill/reload closed loop via linear scan#17
FeelTheBeats merged 1 commit into
ScratchV-Compiler:mainfrom
FeelTheBeats:seven_main2

Conversation

@FeelTheBeats

@FeelTheBeats FeelTheBeats commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

修复了两个问题,

  1. 寄存器分配时,ScratchV没有简单的活跃变量分析,导致寄存器spill到栈上时,不知道什么时候lw。
  2. 无脑使用flush在切换到新bb时,会将所有寄存器spill到栈上,这也是为什么发现了问题一
    附修改review
    RA_commit_architect_review.md
    ai建议的修改:
    pr-17.md

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

@FeelTheBeats FeelTheBeats changed the title Fix: register allocator spill/reload closed loop via linear scan [Fix] RA: register allocator spill/reload closed loop via linear scan Jul 5, 2026
@FeelTheBeats FeelTheBeats changed the title [Fix] RA: register allocator spill/reload closed loop via linear scan [FIX] RA: register allocator spill/reload closed loop via linear scan Jul 5, 2026
@watney1024
watney1024 requested review from Copilot and watney1024 July 26, 2026 07:07

@watney1024 watney1024 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 new emit() 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_alloc to "linear".
  • Add stress tests for loop back-edge correctness and 32-vreg pressure; adjust assembler lw parsing to accept both offset(base) and base(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_pos can equal current.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 interval path, the allocator unconditionally picks temp_reg = self.phys_regs[0] and assigns it to current.vreg even though the whole reason we’re in this branch is that all registers are occupied. This will overwrite whichever still-live vreg currently owns phys_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.

Comment on lines 355 to +365
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)
Comment on lines +255 to +268
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
Comment on lines +462 to +503
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>
@FeelTheBeats
FeelTheBeats merged commit e892e57 into ScratchV-Compiler:main Jul 26, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants