diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15aae4b..784ef49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,14 @@ jobs: run: | python3.12 -m pytest tests/test_pr37_regression.py -v --tb=short + - name: Run topic17 register-allocation regressions + run: | + python3.12 -m pytest \ + tests/test_regalloc_topic17.py \ + tests/test_pr37_regression.py \ + tests/test_topic17_regalloc_case_report.py \ + -v --tb=short + - name: Generate test visualization page if: github.ref == 'refs/heads/main' run: | @@ -218,6 +226,14 @@ jobs: --json benchmark_reports/const_merge_report.json \ --markdown benchmark_reports/const_merge_report.md + # ── 3.1.2 课题17:寄存器分配 case 报告(greedy/linear A/B + RV32 执行等价) ── + - name: Topic 17 register-allocation case report + run: | + mkdir -p benchmark_reports + python3.12 benchmarks/run_topic17_regalloc_case.py \ + --json benchmark_reports/regalloc_case_report.json \ + --markdown benchmark_reports/regalloc_case_report.md + # ── 3.2 DSL 用例编译 + 模拟基准 ──────────────────────────────────── - name: DSL case compilation benchmarks run: | @@ -363,6 +379,9 @@ jobs: if [ -f benchmark_reports/const_merge_report.md ]; then cat benchmark_reports/const_merge_report.md >> $GITHUB_STEP_SUMMARY fi + if [ -f benchmark_reports/regalloc_case_report.md ]; then + cat benchmark_reports/regalloc_case_report.md >> $GITHUB_STEP_SUMMARY + fi echo "" >> $GITHUB_STEP_SUMMARY if [ -f benchmark_reports/github_summary.md ]; then cat benchmark_reports/github_summary.md >> $GITHUB_STEP_SUMMARY diff --git a/benchmarks/cases/topic17_regalloc_feature.dsl b/benchmarks/cases/topic17_regalloc_feature.dsl new file mode 100644 index 0000000..00615f7 --- /dev/null +++ b/benchmarks/cases/topic17_regalloc_feature.dsl @@ -0,0 +1,10 @@ +# Topic 17 register-allocation feature case (linear scan v1.5 + frame layout). +# Deterministic short loop: i = 0..5, t = i*i, k = t + i -> k = 30. +# Deliberately low-pressure: the loop-carried values are force-spilled across +# basic blocks, but no block ever fills the physical register pool, so neither +# reload-time eviction nor high-pressure spilling is exercised. +for i = 0, 6 + t = mul(i, i) + k = add(t, i) +endfor +return k diff --git a/benchmarks/run_topic17_regalloc_case.py b/benchmarks/run_topic17_regalloc_case.py new file mode 100644 index 0000000..95b8e77 --- /dev/null +++ b/benchmarks/run_topic17_regalloc_case.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +"""Run one Topic 17 register-allocation feature case and emit auditable CI reports. + +The report proves three separate facts: + +1. the configured compiler pipeline accepts both ``reg_alloc="greedy"`` and + ``reg_alloc="linear"`` for the same deterministic low-pressure DSL case and + both products are assemblable RISC-V; +2. the linear path emits a real frame: spill/reload code for the loop-carried + values, a balanced prologue/epilogue adjustment, and no frame-relative + spill access outside the allocated frame; +3. the RV32 emulator executes both products to identical architectural state + on the observed registers. + +The case is deliberately low-pressure: it validates the frame/ABI plumbing +without exercising register-pool exhaustion, reload-time eviction or +high-pressure spilling. This is a deterministic feature/integration case, not +a real-workload speedup claim; ``linear`` remains Stage 1 opt-in and its +block-local force-spill strategy is expected to trade dynamic instructions for +frame traffic. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import statistics +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from scratchv.backend._asm_parser import parse_asm +from scratchv.backend.riscv_encoder import assemble_to_binary +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.simulator.rv32_emulator import REG_ID, RV32Emulator + +SCHEMA_VERSION = "topic17-regalloc-case/1" +DEFAULT_CASE = ( + Path(__file__).parent / "cases" / "topic17_regalloc_feature.dsl" +) +DEFAULT_JSON = Path("benchmark_reports/regalloc_case_report.json") +DEFAULT_MARKDOWN = Path("benchmark_reports/regalloc_case_report.md") +#: Expected ``a0`` of the deterministic case: i = 0..5, k = i * i + i -> 30. +EXPECTED_RESULT = 30 +#: Registers that must be identical in both products: the return value and the +#: stack pointer (the linear path's frame must be balanced at retirement). +OBSERVED_REGISTERS = ("x2", "x10") + +_BARE_VREG = re.compile(r"(? int: + """Number of real instructions (labels and directives excluded).""" + return sum( + 1 + for line in parse_asm(asm) + if line.opcode is not None and not line.is_directive + ) + + +def _sp_adjustments(parsed) -> tuple[list[int], list[int]]: + """``(prologue, epilogue)`` ``addi sp, sp, ±N`` immediate lists.""" + prologue: list[int] = [] + epilogue: list[int] = [] + for line in parsed: + if line.opcode != "addi" or len(line.operands) != 3: + continue + dst, src, imm = (operand.strip() for operand in line.operands) + if dst != "sp" or src != "sp": + continue + try: + value = int(imm) + except ValueError: + continue + (prologue if value < 0 else epilogue).append(value) + return prologue, epilogue + + +def _spill_offsets(parsed) -> list[int]: + """Offsets of every ``lw``/``sw`` access through ``sp``.""" + offsets: list[int] = [] + for line in parsed: + if line.opcode not in ("lw", "sw"): + continue + for operand in line.operands: + match = _SP_OPERAND.match(operand.strip()) + if match is not None: + offsets.append(int(match.group(1))) + return offsets + + +def _comment_counts(parsed) -> dict[str, int]: + counts = {name: 0 for name in _INSERTED_MARKERS.values()} + for line in parsed: + comment = line.comment or "" + for prefix, name in _INSERTED_MARKERS.items(): + if comment.startswith(prefix): + counts[name] += 1 + return counts + + +def check_hygiene(asm: str) -> dict[str, Any]: + """Lightweight assembly hygiene: no vreg/SPILL_ leftovers, valid operands, + and the product must be accepted by the repository encoder.""" + issues: list[str] = [] + body = "\n".join(line.split("#", 1)[0] for line in asm.splitlines()) + if "SPILL_" in body: + issues.append("SPILL_ marker in assembly") + if _BARE_VREG.search(body): + issues.append("bare virtual register in assembly") + if _NEG_OFFSET.search(body): + issues.append("negative sp offset in assembly") + + for line in parse_asm(asm): + if line.opcode not in _REG_ONLY_OPS: + continue + for operand in (operand.strip() for operand in line.operands): + if not _REG_NAME.match(operand): + issues.append( + f"{line.opcode}: non-register operand {operand!r}") + break + + assembles = False + assemble_error: Optional[str] = None + try: + assemble_to_binary(asm) + assembles = True + except Exception as exc: # fail-loudly contract: record, do not crash + assemble_error = f"{type(exc).__name__}: {exc}" + issues.append(f"assemble_to_binary rejected the product: {exc}") + return { + "clean": not issues, + "issues": issues, + "assembles": assembles, + "assemble_error": assemble_error, + } + + +def run_asm(asm: str) -> dict[str, Any]: + """Assemble and execute *asm*; return register state and counters.""" + try: + binary = assemble_to_binary(asm) + emulator = RV32Emulator() + emulator.load_code(bytes(binary)) + dynamic = emulator.run(max_instr=10000) + except Exception as exc: + return {"error": f"{type(exc).__name__}: {exc}"} + return { + "backend": "rv32-emulator", + "registers": {f"x{i}": emulator.regs[i] for i in range(32)}, + "a0": emulator.regs[REG_ID["a0"]], + "sp": emulator.regs[REG_ID["sp"]], + "dynamic_instructions": dynamic, + } + + +def _failed_measurement(mode: str, errors: list[str]) -> dict[str, Any]: + """Uniform measurement shape for a failed compilation.""" + return { + "mode": mode, + "compile_success": False, + "errors": list(errors), + "compile_time_ms": 0.0, + "runs": 0, + "distinct_asm": 0, + "deterministic": False, + "asm_sha256": None, + "asm_instructions": 0, + "asm_lines": 0, + "spill_accesses": 0, + "spill_offsets": [], + "reload_count": 0, + "writeback_count": 0, + "eviction_count": 0, + "frame": {"prologue_offsets": [], "epilogue_offsets": []}, + "frame_size": 0, + "hygiene": { + "clean": False, + "issues": list(errors), + "assembles": False, + "assemble_error": None, + }, + "execution": None, + "asm_head": [], + } + + +def measure_allocator( + case_path: Path, mode: str, repeats: int) -> dict[str, Any]: + """Compile the case *repeats* times with *mode* and collect A/B metrics.""" + source = case_path.read_text() + texts: list[str] = [] + times: list[float] = [] + errors: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + for i in range(repeats): + driver = CompilerDriver(CompilerConfig(reg_alloc=mode)) + started = time.perf_counter() + result = driver.compile( + f"topic17-regalloc-{mode}.dsl", + str(Path(tmp) / f"{mode}_{i}.s"), + dsl_source=source, + ) + times.append((time.perf_counter() - started) * 1000) + if not result.success: + errors.extend(result.errors or ["compilation failed"]) + break + texts.append(result.output_text) + + if not texts: + return _failed_measurement( + mode, errors or ["compilation produced no output"]) + + asm = texts[0] + parsed = parse_asm(asm) + prologue, epilogue = _sp_adjustments(parsed) + spill_offsets = _spill_offsets(parsed) + comment_counts = _comment_counts(parsed) + hygiene = check_hygiene(asm) + execution = run_asm(asm) if hygiene["assembles"] else { + "error": "assembly rejected before execution", + } + return { + "mode": mode, + "compile_success": True, + "errors": errors, + "compile_time_ms": round(statistics.median(times), 4), + "runs": len(texts), + "distinct_asm": len(set(texts)), + "deterministic": len(set(texts)) == 1, + "asm_sha256": hashlib.sha256(asm.encode("utf-8")).hexdigest(), + "asm_instructions": count_asm(asm), + "asm_lines": len(asm.splitlines()), + "spill_accesses": len(spill_offsets), + "spill_offsets": spill_offsets, + "reload_count": comment_counts["reload_count"], + "writeback_count": comment_counts["writeback_count"], + "eviction_count": comment_counts["eviction_count"], + "frame": { + "prologue_offsets": prologue, + "epilogue_offsets": epilogue, + }, + "frame_size": -sum(prologue) if prologue else 0, + "hygiene": hygiene, + "execution": execution, + "asm_head": asm.splitlines()[:12], + } + + +def evaluate(case_path: Path, repeats: int) -> dict[str, Any]: + """Build the full report payload and run the hard invariants.""" + runs = max(repeats, 2) + greedy = measure_allocator(case_path, "greedy", runs) + linear = measure_allocator(case_path, "linear", runs) + + def a0(measured: dict[str, Any]) -> Optional[int]: + execution = measured.get("execution") + return execution.get("a0") if isinstance(execution, dict) else None + + def sp(measured: dict[str, Any]) -> Optional[int]: + execution = measured.get("execution") + return execution.get("sp") if isinstance(execution, dict) else None + + registers_greedy = ( + (greedy.get("execution") or {}).get("registers") or {}) + registers_linear = ( + (linear.get("execution") or {}).get("registers") or {}) + prologue = linear["frame"]["prologue_offsets"] + epilogue = linear["frame"]["epilogue_offsets"] + frame_size = linear["frame_size"] + + hard_checks = { + "greedy_compile_succeeds": bool(greedy["compile_success"]), + "linear_compile_succeeds": bool(linear["compile_success"]), + "allocators_produce_different_code": ( + bool(greedy["compile_success"]) + and bool(linear["compile_success"]) + and greedy["asm_sha256"] != linear["asm_sha256"] + ), + "linear_emits_spill_code": linear["spill_accesses"] > 0, + "linear_frame_balanced": ( + bool(prologue) and sum(prologue) + sum(epilogue) == 0 + ), + "linear_spill_offsets_inside_frame": ( + frame_size > 0 + and all( + 0 <= offset and offset + 4 <= frame_size + for offset in linear["spill_offsets"] + ) + ), + "linear_hygiene_clean": bool(linear["hygiene"]["clean"]), + "linear_assembles_to_binary": bool(linear["hygiene"]["assembles"]), + "linear_allocation_deterministic": bool(linear["deterministic"]), + "execution_matches_expected": ( + a0(greedy) == EXPECTED_RESULT and a0(linear) == EXPECTED_RESULT + ), + "observed_registers_identical": ( + bool(registers_greedy) + and bool(registers_linear) + and all( + registers_greedy.get(reg) == registers_linear.get(reg) + for reg in OBSERVED_REGISTERS + ) + ), + "stack_pointer_balanced": ( + sp(greedy) == RV32Emulator.STACK_TOP + and sp(linear) == RV32Emulator.STACK_TOP + ), + } + failed = sorted(name for name, ok in hard_checks.items() if not ok) + + return { + "schema_version": SCHEMA_VERSION, + "topic": "topic17-regalloc", + "generated_at": datetime.now(timezone.utc).isoformat(), + "case": str(case_path), + "expected_result": EXPECTED_RESULT, + "observed_registers": list(OBSERVED_REGISTERS), + "config": {"optimize_level": "none", "modes": ["greedy", "linear"]}, + "runs": runs, + "greedy": greedy, + "linear": linear, + "hard_checks": hard_checks, + "hard_failures": failed, + "honesty": ( + "Deterministic feature case executed by the repository RV32 " + "emulator. The linear-scan allocator is still Stage 1 opt-in " + "(`--reg-alloc linear`; default `greedy`) and force-spills " + "cross-block values, so its product is expected to contain more " + "dynamic instructions and frame traffic; no performance claim is " + "made. Dynamic-instruction numbers are emulator counts, not " + "hardware cycles. The case is deliberately low-pressure (no " + "register-pool exhaustion, no reload-time eviction) so it " + "validates frame/ABI plumbing, not the high-pressure spill paths." + ), + } + + +def render_markdown(report: dict[str, Any]) -> str: + greedy, linear = report["greedy"], report["linear"] + dyn_greedy = (greedy.get("execution") or {}).get( + "dynamic_instructions", "n/a") + dyn_linear = (linear.get("execution") or {}).get( + "dynamic_instructions", "n/a") + a0_greedy = (greedy.get("execution") or {}).get("a0", "n/a") + a0_linear = (linear.get("execution") or {}).get("a0", "n/a") + prologue = linear["frame"]["prologue_offsets"] + epilogue = linear["frame"]["epilogue_offsets"] + frame_note = ( + f"prologue addi sp, sp, {min(prologue)}, epilogue addi sp, sp, " + f"{max(epilogue)}" if prologue else "no frame adjustment" + ) + lines = [ + "# Topic 17 Register-Allocation Feature Case", + "", + f"- Schema: `{report['schema_version']}`", + f"- Case: `{report['case']}` (expected `a0 == " + f"{report['expected_result']}`)", + f"- Generated: {report['generated_at']}", + f"- Hard checks: " + f"{'PASS' if not report['hard_failures'] else 'FAIL'} " + f"({len(report['hard_checks']) - len(report['hard_failures'])}" + f"/{len(report['hard_checks'])})", + "", + "## A/B summary (greedy vs linear)", + "", + "| Metric | greedy | linear | note |", + "|--------|-------:|-------:|------|", + f"| ASM instructions | {greedy['asm_instructions']} | " + f"{linear['asm_instructions']} | linear force-spills cross-block " + f"values |", + f"| `sp(...)` load/store accesses | {greedy['spill_accesses']} | " + f"{linear['spill_accesses']} | frame traffic |", + f"| reload / writeback / evict | {greedy['reload_count']} / " + f"{greedy['writeback_count']} / {greedy['eviction_count']} | " + f"{linear['reload_count']} / {linear['writeback_count']} / " + f"{linear['eviction_count']} | allocator-inserted code |", + f"| Frame size (bytes) | 0 | {linear['frame_size']} | " + f"{frame_note} |", + f"| Compile time (ms, median) | {greedy['compile_time_ms']:.4f} | " + f"{linear['compile_time_ms']:.4f} | wall clock, not a claim |", + f"| Dynamic instructions (emulator) | {dyn_greedy} | {dyn_linear} | " + f"emulator counts, not cycles |", + f"| `a0` result | {a0_greedy} | {a0_linear} | equal |", + "", + "## Frame evidence (linear)", + "", + f"- Prologue/epilogue: {prologue} / {epilogue} -> " + f"frame_size={linear['frame_size']}", + f"- Spill offsets: {linear['spill_offsets']} inside " + f"[0, {linear['frame_size']})", + f"- Hygiene: {'clean' if linear['hygiene']['clean'] else 'issues'} " + f"(assembles_to_binary={linear['hygiene']['assembles']})", + "- First lines:", + "", + "```asm", + *linear["asm_head"], + "```", + "", + "## Execution (RV32 emulator)", + "", + f"- Observed registers compared: " + f"{', '.join(report['observed_registers'])}", + f"- greedy: a0={a0_greedy}, sp=" + f"{(greedy.get('execution') or {}).get('sp', 'n/a')}", + f"- linear: a0={a0_linear}, sp=" + f"{(linear.get('execution') or {}).get('sp', 'n/a')}", + "", + "## Hard checks", + "", + ] + for name, ok in report["hard_checks"].items(): + lines.append(f"- [{'x' if ok else ' '}] {name}") + lines += [ + "", + "## Honesty", + "", + report["honesty"], + "", + ] + return "\n".join(lines) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", type=Path, default=DEFAULT_CASE) + parser.add_argument("--json", type=Path, default=DEFAULT_JSON) + parser.add_argument("--markdown", type=Path, default=DEFAULT_MARKDOWN) + parser.add_argument("--repeats", type=int, default=5) + args = parser.parse_args(argv) + if args.repeats < 1: + parser.error("--repeats must be positive") + if not args.case.is_file(): + parser.error(f"feature case not found: {args.case}") + + report = evaluate(args.case, args.repeats) + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(report, indent=2) + "\n") + args.markdown.parent.mkdir(parents=True, exist_ok=True) + args.markdown.write_text(render_markdown(report) + "\n") + print(render_markdown(report)) + if report["hard_failures"]: + print("HARD FAILURES: " + ", ".join(report["hard_failures"])) + return 1 + print(f"reports written: {args.json}, {args.markdown}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/test_regalloc/bench_cnn.py b/benchmarks/test_regalloc/bench_cnn.py index b2b47cf..9eb75b5 100644 --- a/benchmarks/test_regalloc/bench_cnn.py +++ b/benchmarks/test_regalloc/bench_cnn.py @@ -13,7 +13,7 @@ import sys import time -from scratchv.backend.regalloc_linear_v1_5 import ( +from scratchv.backend.regalloc_linear import ( LinearScanAllocator, block_from_machine_instrs, _INT_REGS, diff --git a/benchmarks/test_regalloc/bench_dense.py b/benchmarks/test_regalloc/bench_dense.py index cbf72b1..74d7f33 100644 --- a/benchmarks/test_regalloc/bench_dense.py +++ b/benchmarks/test_regalloc/bench_dense.py @@ -12,7 +12,7 @@ import sys import time -from scratchv.backend.regalloc_linear_v1_5 import LinearScanAllocator, LsInstruction +from scratchv.backend.regalloc_linear import LinearScanAllocator, LsInstruction def _gen_block( @@ -63,7 +63,7 @@ def bench_allocate( spill_counts = [] for _ in range(repeats): - alloc = LinearScanAllocator(phys_regs=phys_regs) + alloc = LinearScanAllocator(phys_regs=phys_regs, strict=False) t0 = time.perf_counter() alloc.allocate(alloc.compute_live_intervals(block)) t1 = time.perf_counter() @@ -71,7 +71,7 @@ def bench_allocate( spill_counts.append(len(alloc._spill_slots)) # Final run for stable stats - alloc = LinearScanAllocator(phys_regs=phys_regs) + alloc = LinearScanAllocator(phys_regs=phys_regs, strict=False) alloc.allocate(alloc.compute_live_intervals(block)) code = alloc.get_allocated_code(block) reloads = sum( diff --git a/benchmarks/test_regalloc/bench_simple.py b/benchmarks/test_regalloc/bench_simple.py index fcfbe16..a3e02e8 100644 --- a/benchmarks/test_regalloc/bench_simple.py +++ b/benchmarks/test_regalloc/bench_simple.py @@ -12,7 +12,7 @@ import sys import time -from scratchv.backend.regalloc_linear_v1_5 import LinearScanAllocator, LsInstruction +from scratchv.backend.regalloc_linear import LinearScanAllocator, LsInstruction def _gen_block( diff --git "a/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\345\274\200\345\217\221\346\226\207\346\241\243.md" new file mode 100644 index 0000000..75212e6 --- /dev/null +++ "b/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -0,0 +1,889 @@ +# 课题17 寄存器分配 v1.5 收敛接入开发文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 代码基线:`/root/Lab/ScratchV` 工作区(`regalloc_linear_v1_5.py` 818 行;`compiler.py` 514 行) +> 关联设计文档:同目录 `设计文档.md`(算法/ABI/测试规范以设计文档为准,本文给出落地锚点与工程步骤) +> 目标:把 v1.5 修到**合法输入可执行对拍通过**并接入编译管线;不重写全局图着色,不做跨函数全局活跃分析 + +--- + +## 一、目标、范围与非目标 + +### 1.1 交付目标 + +| 编号 | 目标 | 对应设计文档 | +|:---:|------|------| +| G1 | 修复 v1.5 reload 别名 P0(合法输入算错) | 2.1.3 / 2.1.4 / 2.1.6 | +| G2 | v1.5 收敛为正本 `regalloc_linear.py`,接入 `compiler.py` 的 `--reg-alloc linear` 路径 | 4.2 / 4.8 | +| G3 | W9:callee-saved prologue/epilogue + 16 字节对齐栈帧 | 2.2 / 2.3 | +| G4 | `machine_types.ALL_REGS` 与线性分配器池一致性修正 | 2.1.2 / 4.7 | +| G5 | 测试与执行对拍(单元 + 端到端 + 汇编卫生) | 三、七、八 | +| G6 | 灰度发布:opt-in 先行,默认值一次翻转 | 九 | + +### 1.2 非目标(明确不做) + +- 不做全局图着色 / CFG 级活跃分析重写(跨块变量用 forced-spill 保守处理,见设计文档 2.5.3)。 +- 不做跨函数全局活跃分析;含 `CALL` 的函数只保证 s-regs/ra(W9 范围)。 +- 不修 greedy 的溢出语义(仅冻结行为、标注 legacy);不优化 naive。 +- 性能优化(成本感知 victim、spill 槽复用、rematerialization、FP 寄存器类)**只登记为后续项**(第十章)。 + +### 1.3 前置事实(复现于 2026-09-14) + +- v1.5 是孤岛:`compiler.py:408` 仍 `from scratchv.backend.regalloc_linear import ...`(v1.0)。 +- v1.5 别名实测:2 寄存器池、6 指令合法块 → `v5=2`(应为 7)。 +- v1.0 标签实测:`.label main` / `bnez a0 # .Lend` / `j # .Lend`(不可汇编)。 +- 复现命令见附录 B。 + +--- + +## 二、接口契约 + +> 以下名称均为最终落地名称;实现时不得改名(测试与文档依赖)。 + +### 2.1 Python API + +```python +# ── scratchv/backend/regalloc_linear.py(正本) ────────────────────────── + +class RegAllocError(RuntimeError): + """寄存器分配无法产生正确结果(fail-loudly 基类)。""" + +class RegisterAliasError(RegAllocError): + """违反别名不变量 I1/I2:同一物理寄存器被两个活跃 vreg 独占。""" + +class SpillFallbackError(RegAllocError): + """strict 模式下无法为 spilled vreg 取得 scratch/reload 寄存器。""" + +@dataclass +class LiveInterval: # 字段不变 + vreg: str + start: int + end: int + uses: set[int] + +@dataclass +class LsInstruction: # 字段不变 + id: int + opcode: str + operands: list[str] + defines: set[str] = field(default_factory=set) + uses: set[str] = field(default_factory=set) + comment: str = "" + def to_asm(self, rename: dict[str, str] | None = None) -> str: ... + +class LinearScanAllocator: + def __init__( + self, + phys_regs: list[str] | None = None, # 默认 machine_types.ALL_REGS(27) + *, + stack_base: int = 0, # 本块 spill 区相对函数帧底偏移(字节) + strict: bool = True, # True: 退化路径 raise;False: 压力场景计数降级 + pre_spilled: Collection[str] = (), # forced-spill 的 vreg(跨块变量) + ) -> None: ... + + def compute_live_intervals( + self, block: list[LsInstruction], + ) -> list[LiveInterval]: ... + + def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: ... + + # 新:分配 + 重命名 + 生成 spill/reload,返回可继续转换的指令序列 + def allocate_block(self, block: list[LsInstruction]) -> list[LsInstruction]: ... + + # 新:MachineInstr 级入口(block_from_machine_instrs → allocate_block → machine_instrs_from_block) + def emit_machine_instrs( + self, instrs: list[MachineInstr], + ) -> list[MachineInstr]: ... + + # 兼容保留:文本输出 = "\n".join(i.to_asm(...) for i in allocate_block(block)) + def emit(self, block: list[LsInstruction]) -> str: ... + def get_allocated_code(self, block: list[LsInstruction]) -> str: ... # 等价 emit + + def report(self) -> str: ... + + # 只读状态(帧分配器/测试使用) + @property + def spill_slots(self) -> dict[str, int]: ... # vreg -> 帧内偏移(非负) + @property + def spill_bytes(self) -> int: ... # 4 * len(spill_slots) + @property + def used_callee_saved(self) -> set[str]: ... # alloc_map 值 ∩ CALLEE_SAVED + +def block_from_machine_instrs(instrs: list[MachineInstr]) -> list[LsInstruction]: ... +def machine_instrs_from_block(block: list[LsInstruction]) -> list[MachineInstr]: ... + +# ── scratchv/backend/frame_layout.py(新建,W9) ───────────────────────── + +@dataclass +class FrameInfo: + frame_size: int # 16 字节对齐 + ra_offset: int | None # frame_size-4;无 call 时 None + saved_offsets: dict[str, int] # s-reg -> 帧内偏移(从高到低) + spill_base: int # 帧内 spill 区起点(本函数恒为 0,保留字段) + +class FunctionFrameAllocator: + def __init__( + self, + alloc_factory: Callable[[int], LinearScanAllocator] | None = None, + cross_block_policy: str = "force-spill", # 目前仅支持 "force-spill" + ) -> None: ... + + # 输入 = 一个模块的全部 MachineInstr;输出 = 已分配 + 含 prologue/epilogue + def allocate_program( + self, instrs: list[MachineInstr], + ) -> list[MachineInstr]: ... + +# ── scratchv/backend/machine_types.py(修正) ──────────────────────────── + +CALLER_SAVED: list[str] = ARG_REGS + TEMP_REGS # 新 +ALL_REGS: list[str] = CALLER_SAVED + CALLEE_SAVED # 27,顺序 = a0-a7,t0-t6,s0-s11 +GREEDY_REGS: list[str] = TEMP_REGS + CALLEE_SAVED # 19,冻结 legacy greedy 池 +REG_NUMS: dict[str, int] = {...} # 新:唯一寄存器编号表 + +@dataclass +class MachineOperand: + kind: str # "reg" | "imm" | "vreg" | "mem" + value: str | int + @staticmethod + def mem(offset: int, base: str = "sp") -> "MachineOperand": ... # 新 +``` + +### 2.2 配置字段 + +```python +# scratchv/compiler.py +@dataclass +class CompilerConfig: + ... + # 取值: "naive" | "greedy" | "linear"(过渡别名 "linear-v1.5" 归一化为 "linear") + # 阶段一默认: "greedy"(与 CLI 一致);阶段二默认: "linear" + reg_alloc: str = "greedy" +``` + +| 字段 | 合法值 | 语义 | 未知值处理 | +|------|--------|------|-----------| +| `reg_alloc` | `naive` / `greedy` / `linear` / `linear-v1.5`(别名) | `linear` 走 `FunctionFrameAllocator` + 正本线性扫描;其余走原 `RegisterAllocator` | **raise ValueError**(替换当前"非 naive 即 greedy"的静默兜底,锚点 `compiler.py:415`) | + +### 2.3 CLI 参数 + +```python +# scratchv/main.py:46-51 +parser.add_argument( + "--reg-alloc", + choices=["naive", "greedy", "linear", "linear-v1.5"], + default="greedy", # 阶段一;阶段二改为 "linear" + help="Register allocation strategy (default: greedy; " + "'linear' = basic-block linear scan, opt-in)", +) +``` + +- 主 opt-in 开关:**`--reg-alloc linear`**。 +- 过渡别名:**`--reg-alloc linear-v1.5`**(与 `linear` 等价,用于 CI 显式钉住新实现;`args_to_config` 归一化为 `"linear"` 并记录 deprecation warning)。 +- `args_to_config`(`main.py:135-156`)透传字段 `reg_alloc`,无需新增映射。 + +### 2.4 模块导出 + +```python +# scratchv/backend/__init__.py:15-17 → 改为 +from .regalloc_linear import ( + LinearScanAllocator, block_from_machine_instrs, machine_instrs_from_block, +) + +# scratchv/backend/regalloc_linear_v1_5.py → 过渡转发(保留一个版本周期) +from scratchv.backend.regalloc_linear import * # noqa: F401,F403 +``` + +### 2.5 数据契约 + +| 对象 | 契约 | +|------|------| +| `LsInstruction.opcode` | 原样保留 `MachineOp.value`;标签为 `".label"`;spill 指令为 `"lw"/"sw"` | +| 标签 | `LsInstruction(opcode=".label", operands=[name], comment=name)`;`to_asm` 必须输出 `name:` | +| 分支目标 | 存于 `LsInstruction.comment`;重命名不得改写 `comment` | +| spill 槽 | `spill_slots[v] ∈ [stack_base, stack_base + spill_bytes)`;文本形式 `offset(sp)`(非负) | +| `MachineOperand.mem` | `value = f"{offset}({base})"`;`AsmEmitter._fmt_op` 直接成型 `lw t0, 8(sp)` | +| 输出消解 | `allocate_block` 返回后任何操作数不得残留 vreg 或 `SPILL_` 前缀 | + +### 2.6 错误与诊断 + +| 异常 | 触发点 | 消息要求 | +|------|--------|----------| +| `RegisterAliasError` | `_occupied_at` 发现 `|OWN(r,p)|>1`;`allocate_block` 末尾发现同指令操作数寄存器混叠 | 含 position、寄存器名、两个 vreg 名 | +| `SpillFallbackError` | `_pick_reload_reg` / `_pick_scratch` 严格模式无可用寄存器且无合法 victim | 含 position、protected 集合 | +| `RegAllocError` | `allocate()` 池为空等非法状态 | 含池容量 | + +--- + +## 三、v1.5 reload 别名 P0 修复方案 + +### 3.1 最小复现(合法输入算错) + +**输入**(与 `tests/test_pr37_regression.py:21-34` 的 `_pressure_block` 相同): + +```python +block = [ + LsInstruction(0, "li", ["v0", "1"], defines={"v0"}), + LsInstruction(1, "li", ["v1", "2"], defines={"v1"}), + LsInstruction(2, "li", ["v2", "3"], defines={"v2"}), + LsInstruction(3, "add", ["v3", "v0", "v1"], defines={"v3"}, uses={"v0", "v1"}), + LsInstruction(4, "add", ["v4", "v2", "v3"], defines={"v4"}, uses={"v2", "v3"}), + LsInstruction(5, "add", ["v5", "v4", "v0"], defines={"v5"}, uses={"v4", "v0"}), +] +LinearScanAllocator(phys_regs=["t0", "t1"]).emit(block) +``` + +**v1.5 实测输出(裁剪)**: + +``` + lw t1, -4(sp) # reload v0 + add t0, t1, t1 # ← v0 与 v1 同为 t1:v3 = 2(应 3) + ... + lw t1, -4(sp) # reload v0 + lw t1, -12(sp) # reload v4 + lw t1, -4(sp) # reload v0 + add t0, t1, t1 # ← v5 = 2(应 7) +``` + +**期望**:`v3=3, v4=6, v5=7`,且任一指令中不同 vreg 的操作数寄存器两两不同。 + +### 3.2 根因链(函数 + 行号锚点) + +| # | 根因 | 锚点 | 说明 | +|:-:|------|------|------| +| R1 | 跨 vreg 复用 reload 寄存器 | `regalloc_linear_v1_5.py:453-454`(调用)+ `:547-548`(返回 `reuse_reg`) | 同一指令槽的多条 reload 值消费点相同,复用即覆盖 | +| R2 | `used` 判定把"纯定义起点"计为占用 | `:529-537` | `iv.contains(current_pos)` 对 `start==pos` 的纯定义也返回 True,迫使 eviction,放大 R1/R3 | +| R3 | victim 不校验寄存器独占性 | `:574-586` | 只查 `vreg in protect`,不查 `reg(victim)` 是否同时被 protected vreg 持有,可返回被保护寄存器 | +| R4 | 运行期驱逐 store 晚登记被丢弃 | `:429-432`(先发射)vs `:614-617`(后追加) | `_evictions[pos]` 在 reload 处理开始前已 flush,`_evict_for_reload` 追加的行永远不会输出 | +| R5 | 死代码静默 fallback | `:312-315`(`else phys_regs[0]`)、`:672-676`(scratch 全忙回落 cached/`phys_regs[0]`) | 上游不变量一旦被破坏就静默 clobber(评审 P1/P2) | + +### 3.3 修复实现:活跃区间归属判定(新增内部方法) + +在 `_pick_reload_reg` 之前(建议插入 `:506` 上方的 `# Code generation` 区段内)新增: + +```python +def _occupied_at(self, pos: int, inst: LsInstruction) -> dict[str, str]: + """返回指令执行前 (preg -> vreg) 的独占占用表;违反 I1 时抛 RegisterAliasError。""" + owners: dict[str, str] = {} + for v, r in self.alloc_map.items(): + if v in self._spilled: # 运行期已驱逐:alloc_map 可能仍是旧寄存器 + continue + if r not in self.phys_regs: # "SPILL_..." 标记不占寄存器 + continue + iv = self._vreg_interval.get(v) + if iv is None: + continue + occupies = ( + iv.start < pos < iv.end # 常规活跃 + or (iv.start == pos and v in inst.uses) # 读-改-写:定义即使用 + ) # 纯定义 start==pos 不占位(I3) + if occupies: + prev = owners.get(r) + if prev is not None and prev != v: + raise RegisterAliasError( + f"position {pos}: {prev} and {v} both claim {r}") + owners[r] = v + return owners +``` + +**替换点**:`get_allocated_code` 的 `live_regs` 拼装块(`:437-443`)改为 `owners = self._occupied_at(inst.id, inst)`;后续所有"寄存器是否忙"的判断一律基于 `owners`,不再基于 `rename + interval.contains`。**重定义 scratch 循环(`:471-496`)的 busy 集合同样改为 `set(owners) | set(loaded.values()) | {本指令已绑定寄存器}`**,避免 scratch 覆盖本指令仍需读取的操作数。 + +**def-at-p 豁免的必要性(实测)**:T1 用例在 2 寄存器池下,指令 4(`add v4, v2, v3`,v4 为纯定义)必须允许 reload v2 使用 `t1`(v4 的目标寄存器),否则会错误驱逐 v3/v0 并触发 R1/R3。R2 修复后该块**零驱逐**即可完成分配。 + +### 3.4 修复实现:victim 选择约束 + +新增/改写 `_evict_for_reload`(锚点 `:551-633`): + +```python +def _select_victim( + self, inst: LsInstruction, owners: dict[str, str], used: set[str], +) -> tuple[str, str] | None: + """返回 (victim_vreg, preg);无合法 victim 返回 None。""" + protected = inst.uses | inst.defines + best, best_end = None, -1 + for r, v in owners.items(): + if r not in used: # 只考虑真正占用的寄存器 + continue + if v in protected: # 约束 1:不得驱逐当前指令操作数 + continue + iv = self._vreg_interval.get(v) + if iv is None: + continue + if not any(u > inst.id for u in iv.uses): # 约束 2:必须有未来使用(否则驱逐无收益) + continue + if owners.get(r) != v: # 约束 3:独占性(I1 防御性复核) + continue + if iv.end > best_end: # 约束 4:evict farthest-end(保持 v1.5 启发式) + best, best_end = v, iv.end + if best is None: + return None + return best, self.alloc_map[best] +``` + +**调用语义**:`_evict_for_reload` 只做"选 victim → 内联发射 store → 登记未来 reload → 返回被释放寄存器",不再访问 `_evictions`(删除 `:614-617` 的 `self._evictions.setdefault` 追加,消除 R4)。无合法 victim 时:`strict=True` 抛 `SpillFallbackError`;`strict=False` 计数并沿用修正后的最近候选(仅压力测量场景)。 + +### 3.5 修复实现:reload 分配、去重与 store 内联 + +改写 `_pick_reload_reg`(锚点 `:506-549`)与 `get_allocated_code` reload 循环(`:446-464`): + +```python +# _pick_reload_reg 新签名(语义) +def _pick_reload_reg( + self, inst: LsInstruction, vreg: str, slot: int, + rename: dict[str, str], owners: dict[str, str], + loaded: dict[str, str], +) -> tuple[str, list[str]]: + """返回 (reload 目标寄存器, 需内联在 lw 之前的 store 文本行)。""" + # 1) 同 vreg 去重:本指令已为该 vreg 分配过 reload 寄存器则复用(无新 lw) + if vreg in loaded: + return loaded[vreg], [] + # 2) used = 独占占用 ∪ 本指令已分配的 reload 目标 ∪ 操作数已有绑定 + used = set(owners) + used.update(loaded.values()) + used.update( + rename[v] for v in (inst.uses | inst.defines) + if rename.get(v) in self.phys_regs + ) + # 3) 优先空闲 + for r in self.phys_regs: + if r not in used: + return r, [] + # 4) 驱逐(含 store 内联行) + picked = self._select_victim(inst, owners, used) + if picked is None: + raise SpillFallbackError(...) # strict;非 strict 走计数降级 + victim, r = picked + slot_v = self._get_spill_slot(victim) + stores = [f" sw {r}, {slot_v}(sp) # evict {victim} for reload"] + self._spilled.add(victim) + rename[victim] = f"SPILL_{victim}" + for u in sorted(self._vreg_interval[victim].uses): + if u > inst.id: + self._reloads.setdefault(u, []).append((victim, slot_v)) + return r, stores +``` + +```python +# get_allocated_code 内(替换 :446-464) +owners = self._occupied_at(inst.id, inst) +loaded: dict[str, str] = {} +for vreg, slot in self._reloads.get(inst.id, []): + reg, stores = self._pick_reload_reg(inst, vreg, slot, rename, owners, loaded) + lines.extend(stores) # 内联 store,绝不丢弃(R4) + if vreg not in loaded: + lines.append(f" lw {reg}, {slot}(sp) # reload {vreg}") + loaded[vreg] = reg + rename[vreg] = reg +``` + +**约束**:删除一切"跨 vreg 复用同一 reload 寄存器"的路径(R1);`loaded` 只允许同一 vreg 命中。此改动使 T1 块在不触发任何驱逐的情况下产出 2.4.1 的目标汇编。 + +### 3.6 修复实现:fail-loudly 与状态清理 + +| 锚点 | 现状 | 改为 | +|------|------|------| +| `:312-315` | `free_regs.pop(0) if free_regs else self.phys_regs[0]` 死代码 | `assert free_regs, "..."; reg = free_regs.pop(0)`;断言失败抛 `RegAllocError` | +| `:672-676` | scratch 全忙静默回落 `cached`/`phys_regs[0]` | `strict=True` 抛 `SpillFallbackError`;`strict=False` 计数 `self.fallback_count` 并在 `report()` 暴露 | +| `:283-292` | `allocate()` 未重置 `stack_slot`/`_scratch_cache` | 增加 `self.stack_slot = self.stack_base`、`self._scratch_cache.clear()`、`self.fallback_count = 0` | +| `:397-402` | `stack_slot -= 4`(负偏移) | 改为 `stack_slot += 4` 正偏移;`_get_spill_slot` 返回 `stack_base + 4*index` | +| `:784-788` | 未知 opcode 静默回落 `MachineOp.MV` | `raise RegAllocError(f"unsupported opcode {inst.opcode}")` | +| `:120-130` | `.label` 输出 `.label name`;跳转目标丢失 | `.label` → `name:`;`opcode ∈ {j, jal, call, beq, bne, blt, bge, bnez}` 且 `comment` 非空 → 目标追加为最后一个操作数 | + +### 3.7 `pre_spilled` 种子(forced-spill 支撑) + +在 `allocate()` 的状态清理(`:283-292`,**必须在清理之后**,否则会被 `clear()` 抹掉): + +```python +self._spilled = set(self.pre_spilled) +for v in self.pre_spilled: + self.alloc_map[v] = f"SPILL_{v}" + slot = self._get_spill_slot(v) + for i in self._vreg_interval[v].uses: # 每个使用点预登记重载 + self._reloads.setdefault(i, []).append((v, slot)) +``` + +纯定义(无使用)的 pre-spilled vreg 由 `get_allocated_code` 的重定义写回路径(`:466-496` 保留)负责 `sw`。**保留 v1.5 的 `SPILL_` 降级机制**(`:632`)——它是 B3 裸 vreg 泄漏的已验证修复。 + +### 3.8 修复后预期输出(T1,验收断言) + +``` + li t0, 1 + sw t0, 0(sp) # store redefined v0 + li t1, 2 + sw t0, 0(sp) # evict v0 + li t0, 3 + sw t0, 4(sp) # store redefined v2 + sw t0, 4(sp) # evict v2 + lw t0, 0(sp) # reload v0 + add t0, t0, t1 + lw t1, 4(sp) # reload v2 + add t1, t1, t0 + lw t0, 0(sp) # reload v0 + add t0, t1, t0 +``` + +验收:`v3=3, v4=6, v5=7`;无 `SPILL_`;无裸 vreg;同指令操作数寄存器互异。 + +--- + +## 四、compiler.py 接入与默认值裁决 + +### 4.1 现状锚点 + +| 锚点 | 内容 | +|------|------| +| `compiler.py:60` | `reg_alloc: str = "linear"`(库默认) | +| `main.py:49` | CLI 默认 `"greedy"` | +| `compiler.py:397-418` | `_generate_riscv_linear`:`linear` → v1.0 `lsa.emit()` 直出文本(绕过 AsmEmitter,B2/B4 根因) | +| `compiler.py:407-413` | `from scratchv.backend.regalloc_linear import LinearScanAllocator, block_from_machine_instrs` | +| `compiler.py:420-439` | DAG 路径:始终 `RegisterAllocator(mode=reg_alloc)` | + +### 4.2 模块收敛方案(v1.5 为正本) + +| 步骤 | 动作 | +|------|------| +| C1 | 将 `regalloc_linear_v1_5.py` 全文覆盖到 `regalloc_linear.py`(保留 `LinearScanAllocator/LsInstruction/LiveInterval/block_from_machine_instrs/machine_instrs_from_block` 名称) | +| C2 | 在正本上叠加第三章修复 + 第六章 `machine_types` 引用 | +| C3 | `regalloc_linear_v1_5.py` 改为转发模块(2.4 节),加 deprecation docstring | +| C4 | `backend/__init__.py:15-17` 指向正本;`__all__` 补 `RegAllocError/RegisterAliasError/SpillFallbackError` | +| C5 | 调用方 import 统一:`benchmarks/test_regalloc/bench_simple.py:15`、`bench_cnn.py:16`、`scratchv/backend/topic17_bottleneck_scenarios_v1_5.py:27` | +| C6 | CI 增加卫生检查:`grep -rn "regalloc_linear_v1_5" --include="*.py" scratchv tests | 除转发模块外为空` | + +### 4.3 默认值裁决(库与 CLI 一致) + +| 阶段 | `CompilerConfig.reg_alloc` | CLI `--reg-alloc` | 说明 | +|------|:---:|:---:|------| +| **阶段一(本次合并)** | `"greedy"` | `"greedy"` | 库默认从 `"linear"` 改为 `"greedy"`,与 CLI 对齐;`linear` 为 opt-in。避免未验收的实现被默认启用 | +| **阶段二(验收全绿后)** | `"linear"` | `"linear"` | **同一次提交**同时翻转两处默认值,灰度结束 | +| 永久保留 | `naive`/`greedy` 仍可选 | 同左 | `greedy` 标注 legacy(溢出语义不修);`naive` 不变 | + +**理由**:现状"库默认 linear(挂 v1.0 坏实现)+ CLI 默认 greedy"是最糟组合——Python API 用户静默拿到坏产物。阶段一必须先把两处对齐到保守值,再用 opt-in 验证新实现;验收后一次翻转,回滚只需 revert 那一个 commit。 + +### 4.4 接线代码(目标形态) + +```python +def _generate_riscv_linear(self, program) -> str: + from scratchv.backend.instruction_select import InstructionSelector + from scratchv.backend.asm_emit import AsmEmitter + from scratchv.backend.register_alloc import RegisterAllocator + + selector = InstructionSelector(program) + machine_instrs = selector.run() + + mode = "linear" if self.config.reg_alloc == "linear-v1.5" else self.config.reg_alloc + if mode not in ("naive", "greedy", "linear"): + raise ValueError(f"unknown reg_alloc mode: {self.config.reg_alloc!r}") + + if mode == "linear": + from scratchv.backend.frame_layout import FunctionFrameAllocator + allocated = FunctionFrameAllocator().allocate_program(machine_instrs) + return AsmEmitter(allocated).emit() + + alloc = RegisterAllocator(machine_instrs, mode=mode) + return AsmEmitter(alloc.run()).emit() +``` + +要点: + +1. `linear` 路径**必须经 `AsmEmitter`**(标签/`.globl`/`.type`/`.size`/分支目标),不得再用 `lsa.emit()` 直出文本(B2 根因)。 +2. `linear-v1.5` 归一化为 `linear`,不新增第二种分配器。 +3. 未知模式 raise(替换 `compiler.py:415` 的静默 greedy 兜底)。 +4. DAG 路径(`:420-439`)本轮不动。 + +### 4.5 greedy 兼容边界 + +- 选择 `greedy` 时行为与现状**逐字节一致**(除 `machine_types` 池常量改为 `GREEDY_REGS` 后仍为 19 个、同序)。 +- `register_alloc.py:176-190` 的"溢出后 `_vreg_map` 不更新"**不修**,在 docstring 标注 `legacy: spill path not reload-correct; use linear`。 + +--- + +## 五、W9 callee-saved prologue/epilogue 实现步骤 + +### 5.1 函数与块切分 + +```python +# scratchv/backend/frame_layout.py +def split_functions(instrs: list[MachineInstr]) -> list[FunctionChunk]: + # 规则:MachineOp.LABEL 且 comment 不以 "." 开头 → 新函数起点 + # 函数间其余指令(含 .-本地标签)归当前函数尾部 + +def split_blocks(instrs: list[MachineInstr]) -> list[BlockChunk]: + # 规则:MachineOp.LABEL 且 comment 以 "." 开头 → 新基本块起点 +``` + +与 `AsmEmitter.emit`(`asm_emit.py:96-109`)的判定保持同一规则,保证函数边界一致。 + +### 5.2 保存集合与 `has_call` + +```python +cross = cross_block_vregs(function.blocks) # 设计文档 2.5.3;所有块必须用同一集合 +block_outputs = [] +for block in function.blocks: + local = cross & {v for i in block.instrs for v in (i.defines | i.uses)} + alloc = alloc_factory(stack_base=base_k, pre_spilled=local) # 见 5.3 + block_outputs.append(alloc.emit_machine_instrs(block.instrs)) + local_slots = {s for v, s in alloc.spill_slots.items() + if v not in global_slots} + block.spill_bytes = 4 * len(local_slots) # 发射后的实际用量(含 reload 期驱逐) + +# 保存集合必须从实际发射结果收集(含 reload/scratch 在发射期动态选取的 +# s-reg);只看第一遍静态 alloc_map 会漏掉动态选中的 s0(评审 F3)。 +used_callee = callee_saved_regs(block_outputs) + +has_call = any( + i.op == MachineOp.CALL + or (i.op == MachineOp.JAL and i.comment and not i.comment.startswith(".")) + for i in function.instrs +) +saved = ["ra"] if has_call else [] # ra 在前,偏移从高到低 +saved += [r for r in CALLEE_SAVED if r in used_callee] +``` + +### 5.3 帧布局(发射即统计) + +块基址在发射循环中按前序块的实际用量推进:`base_k = global_bytes + Σ_{j list[MachineInstr]: + if info.frame_size == 0: + return [] + out = [addi("sp", "sp", -info.frame_size)] + if info.ra_offset is not None: + out.append(sw("ra", info.ra_offset)) + for r in CALLEE_SAVED: # 固定顺序,保证与 epilogue 对称 + if r in info.saved_offsets: + out.append(sw(r, info.saved_offsets[r])) + return out +``` + +插入位置:函数标签之后、第一条业务指令之前。 + +### 5.5 epilogue 发射(多返回点) + +- 扫描函数指令,遇到 `MachineOp.JALR` 且 `src2` 为 `ra`(即 `instruction_select._select_return` 的 ret,`:242-243`)时,在其前插入 `emit_epilogue(info)`。 +- `emit_epilogue` = 逆序加载 `saved_offsets` 各 s-reg → 加载 ra(若有)→ `addi sp, sp, frame_size`。 +- 首版不做共享 epilogue 标签(避免 `.size` 与回边跳转复杂度);若后续 ret 点 >3 再引入 `.L_epi`。 + +### 5.6 forced-spill 接线 + +```python +cross = cross_block_vregs(function.blocks) # 设计文档 2.5.3 +for block in function.blocks: + local = cross & {v for i in block for v in (i.defines | i.uses)} + alloc = alloc_factory(stack_base=base_k, pre_spilled=local) +``` + +### 5.7 与 AsmEmitter 的配合 + +- prologue/epilogue 只发射普通 `MachineInstr`,不触碰 LABEL;`AsmEmitter` 的 `.globl/.type/.size` 逻辑不变。 +- `.size fn, .-fn` 会包含 prologue/epilogue,符合 GAS 语义。 +- spill/reload 使用 `MachineOperand.mem(offset)`,`AsmEmitter._fmt_op`(`asm_emit.py:75-79`)输出 `8(sp)`,GAS 合法。 + +### 5.8 边界情况 + +| 情况 | 处理 | +|------|------| +| `frame_size == 0` | 不发射 prologue/epilogue | +| 函数无 s-reg 但有 spill | 只 `addi sp` + spill 槽 | +| 函数无 spill 但有 s-reg | 只保存/恢复 s-reg | +| 函数无 call | 不保存 ra(`ra_offset=None`) | +| 多个 ret | 每个 ret 前内联 epilogue | +| 空函数(仅标签) | 不发射 | + +--- + +## 六、machine_types / ALL_REGS 一致性修正 + +### 6.1 现状差异 + +| 常量 | 当前值 | 问题 | +|------|--------|------| +| `ALL_REGS`(`machine_types.py:172`) | `TEMP_REGS + CALLEE_SAVED` = 19 | 缺 `a0-a7`;`RegisterAllocator` 与线性分配器池不一致 | +| `_INT_REGS`(v1.5 `:30-37`) | `a0-a7 + t0-t6 + s0-s11` = 27 | 与 `ALL_REGS` 不同源,双份定义 | +| `_REG_NUMS`(v1.0 `:46-79`、v1.5 `:50-83`) | 两份相同大表 | 重复维护 | + +### 6.2 目标定义 + +```python +# machine_types.py(替换 :159-176 寄存器区) +CALLEE_SAVED: list[str] = ["s0", "s1", ..., "s11"] # 不变 +TEMP_REGS: list[str] = ["t0", ..., "t6"] # 不变 +ARG_REGS: list[str] = ["a0", ..., "a7"] # 不变 +CALLER_SAVED: list[str] = ARG_REGS + TEMP_REGS # 新 +ALL_REGS: list[str] = CALLER_SAVED + CALLEE_SAVED # 修正:19 → 27 +GREEDY_REGS: list[str] = TEMP_REGS + CALLEE_SAVED # 新:19,冻结 greedy +ZERO_REG/STACK_BASE # 不变 +REG_NUMS: dict[str, int] = {...} # 新:唯一编号表(含 x 别名) +``` + +### 6.3 调用方修改 + +| 文件:行 | 现状 | 动作 | +|---------|------|------| +| `register_alloc.py:58` | `self._reg_pool = {r: None for r in ALL_REGS}` | 改为 `GREEDY_REGS`(冻结 greedy 的 19 池行为) | +| `register_alloc.py:16-26` | re-export `ALL_REGS` 等 | 补 `CALLER_SAVED/GREEDY_REGS/REG_NUMS` | +| `register_alloc.py:30-41` | `__all__` | 同步补充 | +| `regalloc_linear.py`(正本) | `_INT_REGS`/`_REG_NUMS` 本地定义 | 删除本地表,`phys_regs` 默认 `ALL_REGS`;`_to_mop` 用 `REG_NUMS`(保留 `_REG_NUMS = REG_NUMS` 兼容别名) | +| `backend/__init__.py:1-4,26-43` | 导出旧集合 | 增加 `CALLER_SAVED/GREEDY_REGS/REG_NUMS` | + +### 6.4 一致性测试 + +```python +def test_machine_types_allocatable_sets_consistent(): + assert len(ALL_REGS) == 27 + assert ALL_REGS == ARG_REGS + TEMP_REGS + CALLEE_SAVED + assert GREEDY_REGS == TEMP_REGS + CALLEE_SAVED + assert set(ALL_REGS).isdisjoint({"zero", "ra", "sp", "gp", "tp", "fp"}) + from scratchv.backend import regalloc_linear as rl + assert rl._DEFAULT_PHYS_REGS == ALL_REGS # 单一事实源 +``` + +--- + +## 七、测试文件与用例 + +### 7.1 文件清单 + +| 文件 | 动作 | 职责 | +|------|------|------| +| `tests/test_regalloc_linear.py` | 扩充 | 区间/分配/别名/fail-loudly/标签文本/确定性 | +| `tests/test_pr37_regression.py` | 改 import(`:15-18`) | v1.5 既有 3 条回归(`SPILL_` 泄漏、重定义写回、`a_temp` 往返)改从正本导入;`importorskip` 改 `scratchv.backend.regalloc_linear` | +| `tests/test_regalloc_integration.py` | **新建** | 端到端:切分 → 分配 → AsmEmitter → assemble → simulate 对拍;callee-saved 跨调用 | +| `scratchv/ci/test_page.py:61` | 可选更新 | 新测试文件的中文标签映射 | + +### 7.2 单元用例(文件:用例名 → 断言) + +| 用例 | 断言 | +|------|------| +| `test_pressure_block_no_alias_and_semantics` | T1 块 mini-interpreter 得 `v3=3,v4=6,v5=7`;无别名/无泄漏 | +| `test_pure_definition_does_not_occupy_register` | `_occupied_at` 对纯定义返回空 owner | +| `test_reload_dedup_same_vreg` | `_reloads[p]` 重复登记只发一条 `lw` | +| `test_eviction_store_emitted_before_reload` | 强制驱逐场景中 `sw ... evict ... for reload` 出现在对应 `lw` 之前(关闭 R4) | +| `test_victim_never_aliases_protected_operand` | 构造 protected 与 victim 共寄存器场景,断言不选该 victim 或落 `SpillFallbackError` | +| `test_allocate_raises_instead_of_phys_regs0_fallback` | 空池输入抛 `RegAllocError`(关闭 R5 死代码) | +| `test_spilled_redefinition_writeback_stays_before_reload` | 保留 `test_pr37_regression.py:53-77` 语义 | +| `test_to_asm_emits_valid_labels_and_branch_targets` | `.label` → `name:`;`j/bnez` 目标正确 | +| `test_machine_operand_mem_round_trip` | `"4(sp)"` ↔ `MachineOperand.mem(4)` | +| `test_linear_scan_deterministic` | 两次分配 `alloc_map` 与输出逐字节一致 | +| `test_machine_types_allocatable_sets_consistent` | 见 6.4 | + +### 7.3 集成/对拍用例 + +| 用例 | 输入 | 预期 | +|------|------|------| +| `test_dsl_linear_end_to_end` | 含 `if/while` 的 DSL,`CompilerConfig(reg_alloc="linear")` | 汇编标签齐全;`assemble_to_binary` 成功;`ProfiledMachine` 结果 == 参考 | +| `test_dsl_linear_no_vreg_leak` | 同上(溢出版本,缩减池) | 非注释行无 `v\d+`、无 `SPILL_`;无 `-N(sp)` 负偏移 | +| `test_callee_saved_preserved_across_call` | 手工两函数程序(见设计文档测试 3) | `s0` 哨兵值不变;`ra` 返回正确;`frame_size%16==0` | +| `test_linear_opt_in_default_unchanged` | `CompilerConfig()` 默认 | `reg_alloc == "greedy"`(阶段一) | + +### 7.4 运行命令 + +```bash +pytest tests/test_regalloc_linear.py tests/test_pr37_regression.py \ + tests/test_regalloc_integration.py -q +pytest tests/ -q +python .claude/harness/verify/run.py --level L2 +python3.12 -m benchmarks.test_regalloc.bench_regalloc_linear # CI 现有步骤 +``` + +--- + +## 八、验收标准 + +**必须全部满足,方可执行阶段二默认值翻转:** + +| # | 标准 | 判定方式 | +|:-:|------|----------| +| A1 | 别名 P0 关闭:T1 块 `v3/v4/v5 = 3/6/7` | `test_pressure_block_no_alias_and_semantics` 通过 | +| A2 | 既有回归不退化 | `tests/test_pr37_regression.py` 3 条通过(改从正本导入) | +| A3 | 全量单测 | `pytest tests/ -q` 无新增失败(基线:合并前一次全绿记录) | +| A4 | 执行对拍 | ≥6 个 DSL 程序(直线 ≥3、分支/循环 ≥3)`linear` 路径:汇编成功 + 仿真结果精确等于参考 | +| A5 | 汇编卫生 | 对拍产物非注释行:无 `v\d+`、无 `SPILL_`、无 `-N(sp)`;所有跳转目标有同名标签 | +| A6 | ABI | `frame_size % 16 == 0`;每个实际使用的 s-reg 有 prologue `sw` + 每条 ret 前 `lw`;跨 call 哨兵测试通过 | +| A7 | 一致性 | `ALL_REGS == a0-a7+t0-t6+s0-s11`(27)、`GREEDY_REGS`(19);`_DEFAULT_PHYS_REGS == ALL_REGS` | +| A8 | 默认值 | 阶段一:库与 CLI 默认均为 `greedy`;`linear` opt-in 可用。阶段二:两处同时为 `linear` 且 A1-A7 通过 | +| A9 | 管线回归 | `python .claude/harness/verify/run.py --level L2` 退出码 0 | +| A10 | 性能(信息性,不设门槛) | 记录 `linear` vs `greedy` 指令数差;forced-spill 造成的循环访存增量写入结果报告 | + +--- + +## 九、风险与灰度 + +### 9.1 opt-in 开关(精确命名) + +| 层面 | 名称 | 值 | 说明 | +|------|------|-----|------| +| CLI | `--reg-alloc` | `linear` | **主 opt-in**;进入 `FunctionFrameAllocator` 新路径 | +| CLI | `--reg-alloc` | `linear-v1.5` | 过渡别名,等价 `linear`;CI 显式钉住;`args_to_config` 归一化并告警 | +| Python | `CompilerConfig.reg_alloc` | `"linear"` | 库级 opt-in | +| 分配器 | `LinearScanAllocator(..., strict=True, pre_spilled=…)` | — | 压力测量场景显式传 `strict=False` | + +### 9.2 分阶段计划 + +| 阶段 | 内容 | 出口条件 | +|:---:|------|----------| +| S0 | 合并 G1-G5;默认值统一 `greedy`;`linear` opt-in;CI 双模式跑批 | A1-A7、A9 通过 | +| S1 | 烘焙 ≥1 周:CI 对拍矩阵(linear vs greedy vs naive)出指令数/失败率报表 | 对拍全绿、无 P0 新问题 | +| S2 | 单提交翻转库 + CLI 默认值到 `linear` | A8(阶段二) | +| S3 | 删除 v1.0 残留与转发模块;更新 `docs/topics/17-寄存器分配.md` 源文件指向(评审 P7) | 无引用 `regalloc_linear_v1_5` | + +### 9.3 风险表 + +| 风险 | 概率 | 影响 | 缓解 | +|------|:---:|------|------| +| 别名修复改变压力指标(场景跑批断言 spill 数) | 高 | 低 | 指标改为信息性;更新 `topic17_bottleneck_scenarios_v1_5.py` 期望值 | +| 槽偏移符号变化(`-4(sp)` → `0(sp)`)影响测试/golden | 中 | 中 | 全仓 grep 偏移断言;统一在 S0 更新 | +| forced-spill 使循环变量退化为访存 | 高 | 中(性能) | 仅 `linear` opt-in;列入全局 liveness 后续项;A10 记录增量 | +| `ALL_REGS` 扩到 27 改变 greedy 行为 | 中 | 中 | greedy 池改 `GREEDY_REGS` 冻结;一致性测试锁死 | +| 多返回点内联 epilogue 代码膨胀 | 低 | 低 | ret 点 >3 再引入共享 epilogue 标签 | +| 转发模块与正本漂移 | 中 | 中 | C3 转发仅 `import *`;CI grep(C6);S3 删除 | +| 池为 2 等极端受限测试出现真不可分配 | 低 | 低 | `strict=True` fail-loudly,测试显式期望 `SpillFallbackError` | + +### 9.4 回滚 + +- 阶段一:`--reg-alloc greedy` 或 revert 整个 S0 提交。 +- 阶段二:revert 默认值翻转的单个 commit(`reg_alloc` 与 CLI default 同 commit,保证"库与 CLI 一致"不破)。 +- 转发模块保留一个版本周期,回滚不会造成 import 断裂。 + +--- + +## 十、后续项(明确不在本课题交付) + +1. 全局 liveness(CFG 级活跃分析)→ 消除 forced-spill 的循环访存代价(评审行动项:下一个大里程碑)。 +2. 成本感知 victim(`uses × loop_depth / span` 加权)替代纯 farthest-end。 +3. spill 槽复用 / coalescing(`slot_reuse=0%`,评审 P5)。 +4. rematerialization(常量与廉价重算值不落栈)。 +5. callee-saved 成本模型(围绕 call 显式建模 save/restore 收益)。 +6. FP 寄存器类接线(`_FP_REGS` 已定义未使用;需 register class 抽象)。 +7. `compute_live_intervals` 单遍化 + `inst.id` 连续性校验(评审 P3)。 +8. greedy 溢出语义修复或正式废弃。 + +--- + +## 附录 A:修改锚点速查表 + +| 文件:行 | 现状 | 动作 | 章节 | +|---------|------|------|------| +| `compiler.py:60` | 默认 `"linear"` | 改为 `"greedy"`(阶段一) | 4.3 | +| `compiler.py:407-418` | v1.0 直出文本 | 换 `FunctionFrameAllocator` + `AsmEmitter`;未知模式 raise | 4.4 | +| `main.py:46-51` | choices 无别名 | 加 `linear-v1.5`;默认阶段一 `greedy` | 2.3 | +| `regalloc_linear_v1_5.py:312-315` | 死 fallback | assert/raise | 3.6 | +| `regalloc_linear_v1_5.py:283-292` | 状态未全清 | 重置 `stack_slot/_scratch_cache/fallback_count` | 3.6 | +| `regalloc_linear_v1_5.py:397-402` | 负偏移 | 正偏移 `stack_base + 4×index` | 3.6 | +| `regalloc_linear_v1_5.py:429-432` | 先 flush evictions | 保留(分配期驱逐合法) | 3.2 | +| `regalloc_linear_v1_5.py:437-443` | `rename+contains` 拼 used | 换 `_occupied_at` | 3.3 | +| `regalloc_linear_v1_5.py:446-464` | 跨 vreg reuse | 去重 + 禁止复用 + 内联 store | 3.5 | +| `regalloc_linear_v1_5.py:506-549` | `_pick_reload_reg` | 新签名(返回 store 行) | 3.5 | +| `regalloc_linear_v1_5.py:551-633` | `_evict_for_reload` | 新 victim 约束;删除 `_evictions` 追加 | 3.4 | +| `regalloc_linear_v1_5.py:635-676` | 静默 scratch 回落 | strict 抛错 / 计数 | 3.6 | +| `regalloc_linear_v1_5.py:632` | `SPILL_` 降级 | 保留(B3 修复) | 3.7 | +| `regalloc_linear_v1_5.py:120-130` | 标签/目标文本 | 合法 `name:` 与分支目标 | 3.6 | +| `regalloc_linear_v1_5.py:784-788` | 未知 opcode → `MV` | raise | 3.6 | +| `machine_types.py:172` | `ALL_REGS` 19 | 27 + `CALLER_SAVED/GREEDY_REGS/REG_NUMS` | 6.2 | +| `register_alloc.py:58` | 池 = `ALL_REGS` | 池 = `GREEDY_REGS` | 6.3 | +| `backend/__init__.py:15-17,40` | 导出 v1.0 | 导出正本 + 新符号 | 2.4 | +| `tests/test_pr37_regression.py:15-18` | import v1_5 | import 正本 | 7.1 | + +## 附录 B:复现命令(2026-09-14) + +```bash +# v1.5 别名复现(Python ≥3.11) +cd /tmp/opencode && PYTHONPATH=/root/Lab/ScratchV python3.11 - <<'EOF' +from scratchv.backend.regalloc_linear_v1_5 import LinearScanAllocator, LsInstruction +block = [ + LsInstruction(0, "li", ["v0", "1"], defines={"v0"}), + LsInstruction(1, "li", ["v1", "2"], defines={"v1"}), + LsInstruction(2, "li", ["v2", "3"], defines={"v2"}), + LsInstruction(3, "add", ["v3", "v0", "v1"], defines={"v3"}, uses={"v0", "v1"}), + LsInstruction(4, "add", ["v4", "v2", "v3"], defines={"v4"}, uses={"v2", "v3"}), + LsInstruction(5, "add", ["v5", "v4", "v0"], defines={"v5"}, uses={"v4", "v0"}), +] +print(LinearScanAllocator(phys_regs=["t0", "t1"]).emit(block)) +EOF + +# v1.0 标签/分支目标丢失复现 +cd /tmp/opencode && PYTHONPATH=/root/Lab/ScratchV python3.11 - <<'EOF' +from scratchv.backend.machine_types import MachineInstr, MachineOp, MachineOperand +from scratchv.backend.regalloc_linear import block_from_machine_instrs, LinearScanAllocator +mi = [ + MachineInstr(MachineOp.LABEL, comment="main"), + MachineInstr(MachineOp.LI, MachineOperand.vreg("v1"), MachineOperand.immediate(1)), + MachineInstr(MachineOp.BNEZ, MachineOperand.vreg("v1"), comment=".Lend"), + MachineInstr(MachineOp.J, comment=".Lend"), + MachineInstr(MachineOp.LABEL, comment=".Lend"), + MachineInstr(MachineOp.JALR, MachineOperand.reg("zero"), MachineOperand.reg("ra"), comment="ret"), +] +print(LinearScanAllocator().emit(block_from_machine_instrs(mi))) +EOF +``` + +--- + +## 实现结果(2026-09-14 分支基线,评审修复后) + +> **分支提交**:功能 `65ca163`(`feat(topic17): converge linear-scan allocator, fix spill alias, add frame allocator`)、 +> 文档 `e40e9e6`,其后为评审修复提交(`fix(topic17): …` / `test(topic17): …`)。 +> **本分支全量**:`PYTHONPATH=. python3.11 -m pytest tests/ -q` → **710 passed / 0 failed**(评审修复前 692 passed)。 +> **说明**:旧稿引用的「集成 commit `7313a24` / 定向 30 passed / 集成后 1011 passed」来自集成分支, +> 在本分支不可复现(评审 F5);本分支基线以本节数字为准。 + +### 实现文件与要点 + +| 文件 | 要点 | +|------|------| +| `scratchv/backend/regalloc_linear.py` | v1.5 收敛:标签/分支目标、spill 别名(`_occupied_at` 去重、禁止跨 vreg reuse)、正偏移栈槽、strict 模式替静默 scratch 回落 | +| `scratchv/backend/frame_layout.py`(新增) | `FunctionFrameAllocator` | +| `scratchv/backend/machine_types.py` | 寄存器表扩展(27 regs + `CALLER_SAVED` / `GREEDY_REGS` / `REG_NUMS`) | +| `scratchv/backend/register_alloc.py` | greedy 池改为 `GREEDY_REGS` | +| `scratchv/backend/regalloc_linear_v1_5.py`、`scratchv/backend/topic17_bottleneck_scenarios_v1_5.py` | 收敛 / 转发 | +| `scratchv/backend/__init__.py` | 导出正本 + 新符号 | +| `scratchv/compiler.py` | 默认 `greedy` + `FunctionFrameAllocator` + `AsmEmitter` 接线 | +| `scratchv/main.py` | 新增 `linear-v1.5` 别名 | +| `benchmarks/test_regalloc/*.py` | 3 处适配 | +| `tests/test_regalloc_topic17.py`(新增 21 用例)、`tests/test_pr37_regression.py` | 定向覆盖 | + +### 测试数字 + +| 口径 | 结果 | +|------|------| +| 定向(`tests/test_regalloc_topic17.py` + `tests/test_pr37_regression.py`) | 42 passed(评审修复前 24 passed) | +| 分支全量(评审修复后) | 710 passed / 0 failed | +| 分支全量(评审修复前,`e40e9e6`) | 692 passed / 0 failed | +| 旧集成分支 `7313a24`(不在本分支历史中,无法复现) | 1011 passed / 13 xfailed / 20 xpassed(仅作背景) | + +### 与本文档的偏差 / 未完成项 + +- **默认仍为 `greedy`**,linear 为 opt-in(别名 `linear-v1.5`);阶段二“默认翻转 linear”未做(§9.2)。 +- v1.0 转发模块未删除(保留一个版本周期,§9.4)。 +- `AsmEmitter` 多函数 `.size` 标签问题为既有现象(未修)。 +- A4「≥6 个 DSL 程序(直线 ≥3、分支/循环 ≥3)执行对拍」未完全交付:`if/while` 程序受既有 + `instruction_select` 缺陷(常量操作数、`br_if` 比较丢失,评审 §4 E3)阻塞,端到端对拍目前仅覆盖 + for 循环(`test_linear_opt_in_end_to_end_forced_spill`);待 E3 立项修复后补齐直线/分支用例。 +- 2026-09-14 评审修复记录:F1 帧尺寸改用发射后实际 `spill_slots`;F2 reload 驱逐改用实时 owners、 + victim 排除已溢出者且仅驱逐一次;F3 保存集合取自发射结果;F6 DAG 路径模式校验 fail-loud; + F7 输出期 I2 校验(strict 模式)。 + +### 已知限制 + +- §十 后续项(全局 liveness、成本感知 victim、spill 槽复用、rematerialization、callee-saved 成本模型、FP 寄存器类、单遍 live interval、greedy 溢出语义修复)明确不在本课题交付。 +- linear-scan 溢出到栈依赖既有机制;如需默认翻转必须单独评审并附带执行对拍证据。 diff --git "a/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\350\256\276\350\256\241\346\226\207\346\241\243.md" new file mode 100644 index 0000000..2e03b4e --- /dev/null +++ "b/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -0,0 +1,647 @@ +# ScratchV 课题17 寄存器分配(基本块内线性扫描)技术设计文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 涉及模块:`scratchv/backend/regalloc_linear.py`(线性扫描分配器正本,以 v1.5 为收敛基线)、`scratchv/backend/frame_layout.py`(新建:函数栈帧 / prologue / epilogue)、`scratchv/backend/machine_types.py`(寄存器组 / 操作数 / 操作码)、`scratchv/backend/asm_emit.py`(汇编发射)、`scratchv/compiler.py`(管线接线)、`scratchv/main.py`(CLI) +> 功能范围:基本块内线性扫描寄存器分配的正确性收敛(溢出 / 重载 / 别名禁止)、RISC-V 调用约定(callee-saved prologue/epilogue,对应 W9)、编译管线接入(W10)、执行对拍验证(W11)、文档收敛(W12) + +--- + +## 一、功能介绍 + +### 1.1 功能概述 + +寄存器分配把指令选择输出的**无限虚拟寄存器**(vreg)映射到**有限物理寄存器**,并在寄存器压力超过物理池容量时插入溢出(spill)与重载(reload)代码,使程序在受限寄存器资源下仍能正确执行。 + +``` +指令选择输出: ADD v3, v1, v2 ← 虚拟寄存器 (无穷多个) + ↓ + 寄存器分配 (本课题) + ↓ + 汇编输出: add t0, t1, t2 ← 物理寄存器 (27 个可分配) +``` + +本课题包含四个子功能: + +1. **活跃区间与线性扫描分配**:在基本块内计算每个 vreg 的 `[start, end)` 活跃区间,按 Poletto & Sarkar 线性扫描算法分配物理寄存器。 +2. **溢出与重载代码生成**:物理池耗尽时按"最晚结束"启发式驱逐一个活跃区间,`sw` 存栈、`lw` 取回;被溢出 vreg 的后续重定义必须写回槽位。 +3. **函数栈帧与 callee-saved 保存(W9)**:为每个函数计算栈帧,保存/恢复 `ra` 与实际使用的 `s0-s11`;spill 槽全部落在函数帧内(16 字节对齐)。 +4. **管线接入与验证(W10/W11)**:以 `--reg-alloc linear` 选项接入 `CompilerDriver`,输出经 `AsmEmitter` 生成合法 GNU 汇编,并可用 `riscv_encoder` + `ProfiledMachine` 执行对拍。 + +**现状实测基线(2026-09-14,复现命令见开发文档附录 B)**: + +| 编号 | 实测现象 | 证据(file:line / 实测输出) | +|------|----------|------------------------------| +| B1 | 库默认 `reg_alloc="linear"` 与 CLI 默认 `"greedy"` 不一致 | `compiler.py:60` vs `main.py:49` | +| B2 | v1.0 `linear` 输出无合法标签、分支目标全丢:实测产出 `.label main`、`bnez a0 # .Lend`、`j # .Lend` | `regalloc_linear.py:116-126`(`to_asm` 把 label/目标当普通操作数) | +| B3 | v1.0 驱逐后裸 vreg 泄漏:`spill()` 直接 `del alloc_map[vreg]`,后续定义/使用无重载路径 | `regalloc_linear.py:363-365` | +| B4 | v1.5(818 行)是孤岛模块:没有任何编译路径 import 它,`compiler.py:408` 仍挂 v1.0 | `compiler.py:407-413`;评审 `pr-37.md` 第六节 | +| B5 | v1.5 reload 寄存器别名 P0:合法 6 指令块在 2 寄存器池下算错(实测 v5=2,应为 7;中间 `v3`、`v4` 亦错) | `regalloc_linear_v1_5.py:446-464`、`506-549`;实测输出见 2.4.2 | +| B6 | 全后端无 prologue/epilogue、无 ABI 约定:`s0-s11` 与 caller-saved 同池自由分配但无保存/恢复 | v1.5 无相关代码;评审 `pr-37.md` P6 | +| B7 | greedy 溢出映射不更新:`_assign_reg` 驱逐后 `_vreg_map` 仍指向被占寄存器,且无 reload | `register_alloc.py:176-190` | +| B8 | `machine_types.ALL_REGS`(19)与线性分配器实际池 `_INT_REGS`(27)不一致,缺 `a0-a7` | `machine_types.py:172` vs `regalloc_linear_v1_5.py:30-37` | + +> 本课题**不改写**全局图着色、**不做**跨函数全局活跃分析;性能优化(成本感知 victim、槽复用、rematerialization)只列为后续项,不参与本轮交付。 + +### 1.2 设计目标 + +- **正确性优先**:合法输入经 `linear` 路径生成的汇编必须满足三项硬约束——(a) 可汇编(无非法标签/裸 vreg/`SPILL_` 泄漏);(b) 可执行(sp 帧合法、callee-saved 合规);(c) 执行结果与解释器参考值逐位一致。 +- **单一正本**:`regalloc_linear_v1_5.py` 的内容收敛为 `regalloc_linear.py` 正本;v1.0 实现退役;版本号不再进文件名(吸收 `pr-37.md` 第四节结论)。 +- **ABI 合规(W9)**:被调函数保持 `s0-s11` 与 `ra`;栈帧 16 字节对齐;spill 槽全部在帧内。 +- **最小侵入**:`naive`/`greedy` 保留为兼容模式,行为冻结(greedy 不修溢出语义,仅标注 legacy)。 +- **可验证**:≥3 组测试覆盖压力块、溢出+重载语义、多函数 callee-saved;验证点包含执行对拍与汇编卫生检查。 + +--- + +## 二、设计规范 + +### 2.1 线性扫描算法定义 + +#### 2.1.1 活跃区间(Live Interval) + +记基本块 `B = [I₀, I₁, …, I_{n-1}]`,`defines(I)` / `uses(I)` 分别为指令 `I` 定义/使用的 vreg 集合。 + +``` +start(v) = min { i | v ∈ defines(Iᵢ) } # v 在块内的首次定义 + 0 # 若 v 无定义(live-in 参数) +end(v) = max { i + 1 | v ∈ uses(Iᵢ) } # 最后一次使用之后(半开) + start(v) + 1 # 若 v 从未被使用(纯定义) +uses(v) = { i | v ∈ uses(Iᵢ) } +区间排序键 = (start, end, vreg) # 确定性(禁止依赖 set 迭代序) +``` + +| 规则 | 说明 | 约束 | +|------|------|------| +| 半开区间 | `v` 在 `[start, end)` 内活跃;`end` 位置本身不占用 | 区间 `[s,e)` 与 `[e,t)` **不**重叠 | +| 凸包包络 | 同一 vreg 多次定义取首次定义到末次使用的最小区间 | 宁可保守,不得漏分配 | +| live-in | 块内无定义、只有使用 → `start=0` | 跨块值必须由 forced-spill 机制承接(见 2.5.3) | +| 纯定义 | 无任何使用 → `end=start+1` | 可与起点为 `start+1` 的区间共享寄存器 | + +**合法示例(T1 压力块区间)**: + +| vreg | start | end | uses | +|------|:---:|:---:|------| +| v0 | 0 | 6 | {3, 5} | +| v1 | 1 | 4 | {3} | +| v2 | 2 | 5 | {4} | +| v3 | 3 | 5 | {4} | +| v4 | 4 | 6 | {5} | +| v5 | 5 | 6 | {} | + +#### 2.1.2 分配规则 + +``` +allocate(intervals): # intervals 已按 (start, end, vreg) 排序 + active ← [] # [(interval, preg)],按 end 递增 + free ← list(phys_regs) + for current in intervals: + expire(active, current.start, free) # end <= current.start 者释放寄存器 + if free ≠ []: + r ← free.pop(0) + alloc_map[current.vreg] ← r + active.append((current, r)) + else: + victim ← select_victim(current, active) # 见 2.1.3 + evict(victim, current.start) # sw victim_reg, slot(sp) + alloc_map[victim.vreg] ← "SPILL_" + victim.vreg + alloc_map[current.vreg] ← victim.reg + active.remove(victim); active.append((current, victim.reg)) +``` + +**约束**: + +- 物理池 `phys_regs` 默认 = `ALL_REGS`(27 个:`a0-a7 + t0-t6 + s0-s11`),排除 `x0/ra/sp/gp/tp`。 +- `expire` 条件为 `interval.end <= current.start`(半开区间语义)。 +- 同一 `preg` 只分配给活跃区间互不重叠的 vreg 序列;此不变量是后续一切重命名正确性的前提。 +- 若 `active` 为空仍无空闲寄存器,属非法状态(池容量为 0),必须 fail-loudly。 + +#### 2.1.3 溢出规则(Evict Farthest-End) + +当物理池耗尽时,在 `active` 中选择 **end 最远**的区间作为 victim(`pr-37.md` 确认 v1.3.1 起采用 evict 而非 self-spill,理由:self-spill 需要把 current 的新值写进一个临时寄存器,而此时所有寄存器都被占用,必然 clobber 活值)。 + +``` +select_victim(current, active): + return argmax_{iv ∈ active} iv.end # 平局按 iv.vreg 字典序(确定性) +``` + +**victim 选择约束(本课题新增,修复 B5 的关键)**: + +``` +victim(v, p) ⟺ v ∈ active + ∧ v ∉ SPILLED # 已溢出者寄存器不含有效值,不得再次驱逐 + ∧ iv(v).end > p + ∧ ∃ u ∈ uses(v): u > p # victim 必须有未来使用,重载才有意义 + ∧ v ∉ (uses(I_p) ∪ defines(I_p)) # 不得驱逐当前指令自身操作数 + ∧ OWN(reg(v), p) = {v} # 该寄存器在 p 处必须被 v 独占(I1) +``` + +**驱逐动作**:在 `current.start` 位置插入 `sw victim_reg, slot(victim)(sp)`;将 `victim` 标记为 `SPILL_`;对 `uses(victim)` 中所有 `> p` 的位置登记重载 `(victim, slot)`。**所有驱逐 store 必须在对应 reload 之前发射,不得因记录时机晚于发射时机而被丢弃(B5 根因 4)。** + +#### 2.1.4 重载规则(Reload) + +``` +reload(v, p): # v 已 SPILL 且 p ∈ uses(v) + if v 已在 p 处重载: return 已分配的寄存器(去重,不重复发 lw) + used ← 占用判定(p) ∪ 本指令已分配的 reload 目标寄存器 + r ← 首个 r ∈ phys_regs 且 r ∉ used + if r 不存在: + victim ← 约束内 select_victim(p) + if victim 不存在: raise RegAllocError + 发射 sw victim_reg, slot(victim)(sp) # 内联在 lw 之前,绝不延后 + r ← victim_reg;mark_spilled(victim);登记 victim 的未来重载 + 发射 lw r, slot(v)(sp) + 将 (v → r) 作为本指令内的**瞬时绑定**加入绑定表 +``` + +**约束**: + +- **禁止跨 vreg 复用 reload 寄存器**:同一指令槽内多条 reload 一律各自占用独立寄存器;仅允许**同一 vreg 的重复登记去重**。v1.5 的 `reuse_reg` 机制(`regalloc_linear_v1_5.py:547-548`)假设"一次 lw 的值立刻被消费",但多条 reload 的消费点是同一条指令,前一条 reload 的值尚未被消费就被覆盖,构成别名(B5 根因 1)。 +- **纯定义不占位**:区间起点 `start == p` 且 `v` 在 `I_p` 中只被定义(`v ∉ uses(I_p)`)时,`v` 的寄存器在 `I_p` **执行前**不算占用,可以作为 reload 目标(B5 根因 2;这也是 2 寄存器池下 T1 能通过的必要条件)。 +- **目标寄存器不得与当前指令任一操作数寄存器相同**(I4);写目标与自身源相同是合法的(如 `add t0, t0, t1`)。 + +#### 2.1.5 spill 槽管理 + +``` +slot(v) = stack_base + 4 × index(v) # index ∈ [0, count),非负,sp 相对偏移 +stack_base = Σ_{j < block_index} spill_bytes(block_j) # 由函数帧分配器下发 +spill_bytes(block) = 4 × |_spill_slots(block)| +``` + +| 规则 | 说明 | +|------|------| +| 帧内定位 | 槽位地址必须落在 `[sp, sp + frame_size)` 内(`sp` 为 `addi sp, sp, -frame_size` 之后的值) | +| 独占槽 | 一个 vreg 一个槽,本课题**不做**槽复用(P5,后续项) | +| 重定义写回 | 被溢出 vreg 每次被重新定义后,立即 `sw` 写回本槽;否则后续 reload 读到过期值 | +| 重载后写回 | 重载得到的值若在本指令内被重新定义,写回发生在指令执行之后(scratch 路径) | +| 状态清理 | `allocate()` 必须把 `stack_slot` 重置为 `stack_base`、清空 `_scratch_cache`,保证同一分配器实例多次分配结果确定 | + +#### 2.1.6 别名禁止规则(核心不变量) + +在位置 `p`(指令 `I_p` 执行前)定义: + +``` +occupies(v, p) ⟺ v ∉ SPILLED + ∧ alloc_map[v] ∈ phys_regs + ∧ ( iv(v).start < p < iv(v).end + ∨ (iv(v).start == p ∧ v ∈ uses(I_p)) ) # 读-改-写同指令 +OWN(r, p) = { v | occupies(v, p) ∧ alloc_map[v] = r } +``` + +**不变量**: + +| 编号 | 不变量 | 违反后果 | +|:---:|--------|----------| +| I1 | `∀ p, r: |OWN(r, p)| ≤ 1` —— 任一物理寄存器在任一程序点至多被一个活跃 vreg 独占 | 两个活跃 vreg 同名 → 算错 | +| I2 | 同一条指令内,不同 vreg 的操作数重命名后的物理寄存器两两不同 | `add t0, t1, t1`(v1.5 实测)把两个源混叠 | +| I3 | 纯定义(def-at-p)在执行前不占位 | 无谓驱逐 / 迫使 reload 复用 → 触发别名 | +| I4 | reload 目标寄存器 ∉ 当前指令操作数寄存器集合 | 覆盖即将被读取的值 | + +违反 I1/I2 时抛出 `RegisterAliasError`(`RegAllocError` 子类),**禁止静默返回 `phys_regs[0]`**。 + +### 2.2 ABI 约定 + +#### 2.2.1 寄存器职责 + +| 寄存器 | ABI 名 | 职责 | 分配池 | +|--------|--------|------|:---:| +| x0 | zero | 恒 0 | 否 | +| x1 | ra | 返回地址;函数内出现 call 时由 prologue 保存 | 否 | +| x2 | sp | 栈指针,16 字节对齐 | 否 | +| x3/x4 | gp/tp | 保留 | 否 | +| x5-x7, x28-x31 | t0-t6 | 临时寄存器,caller-saved | **是**(优先) | +| x10-x17 | a0-a7 | 参数/返回值;caller-saved | **是** | +| x8-x9, x18-x27 | s0-s11 | 被调函数保存(callee-saved) | **是**(压力高时) | + +- **caller-saved**:`a0-a7`、`t0-t6`。跨函数调用不保值,调用方需自行保存。 +- **callee-saved**:`s0-s11`。本函数的 prologue 中 `sw`、epilogue 中 `lw`,保证调用方视角不变。 +- **返回值约定**:`instruction_select._select_return`(`instruction_select.py:238-243`)把返回值放入固定 `a0`,再 `jalr zero, ra`。`a0` 是固定物理寄存器操作数(`kind="reg"`),不参与重命名。 +- **CALL 限制(本课题)**:当前 IR/指令选择尚不生成 `MachineOp.CALL`,跨调用活跃的 caller-saved 值不做特殊处理;W9 只交付"被调函数保持 s-regs/ra"。跨调用 liveness 属全局 RA(后续项)。 + +#### 2.2.2 栈帧布局 + +``` +高地址 ┌────────────────────────────┐ ← 调用前的 sp(= 新 sp + frame_size) + │ ra 保存位(若含 call) │ frame_size - 4 + │ s-reg 保存位(倒序) │ frame_size - 8, -12, ... + │ …对齐填充… │ + │ spill 区(块 2) │ [base₂, base₂+bytes₂) + │ spill 区(块 1) │ [base₁, base₁+bytes₁) + │ spill 区(块 0) │ [0, bytes₀) +低地址 └────────────────────────────┘ ← 新 sp(= 旧 sp - frame_size) +``` + +``` +frame_size = align16( Σ_blocks spill_bytes(block) + 4 × |saved| ) +saved = used_callee_saved ∪ ({ "ra" } if has_call else ∅) +align16(x) = ((x + 15) // 16) × 16 +``` + +**约束**: + +- `frame_size == 0` 时(无溢出、无 s-reg、无 call)**不发射** prologue/epilogue。 +- spill 区最高偏移 `< Σ spill_bytes`,保存区最低偏移 `≥ Σ spill_bytes`,二者不重叠(由 `frame_size` 公式保证)。 +- 所有 `lw/sw` 的偏移为正的 `offset(sp)` 形式;`-N(sp)`(sp 之下)为非法(会被下一次 `addi sp` 的帧覆盖)。 + +### 2.3 prologue / epilogue 结构 + +**Prologue**(紧随函数标签 `fn:` 之后): + +``` +fn: + addi sp, sp, -frame_size + sw ra, frame_size-4(sp) # 仅当 has_call + sw s0, frame_size-8(sp) # 对每个 used s-reg:从高到低排列 + sw s1, frame_size-12(sp) + ... +``` + +**Epilogue**(在每个 `jalr zero, ra`(ret)之前内联): + +``` + lw s1, frame_size-12(sp) # 与 prologue 逆序 + lw s0, frame_size-8(sp) + lw ra, frame_size-4(sp) # 仅当 has_call + addi sp, sp, frame_size + jalr zero, ra +``` + +| 规则 | 说明 | +|------|------| +| 保存集合 | 只保存"该函数发射结果中实际出现/被写到的 s-reg"(含 reload/scratch 在发射期动态选取的寄存器),不无条件保存 12 个;不得只看静态 `alloc_map`(否则 F3:动态选中 s0 不保存) | +| ra 判定 | `has_call = ∃ MachineOp.CALL`,或 `∃ MachineOp.JAL` 且目标标签不以 `.` 开头(函数调用) | +| 多返回点 | 每个 ret 点前**内联**完整 epilogue(首版不引入共享 epilogue 标签,简单且不会破坏 `.size` 计算) | +| 16 字节对齐 | RISC-V psABI 要求;插入填充字节 | +| 标签配合 | 函数标签(不以 `.` 开头)由 `AsmEmitter` 负责 `.globl/.type/.size`;局部块标签以 `.` 开头,不触发函数边界 | + +### 2.4 合法 / 非法分配示例 + +#### 2.4.1 合法示例(T1 压力块,池 = `[t0, t1]`) + +输入块: + +``` +0: li v0, 1 +1: li v1, 2 +2: li v2, 3 +3: add v3, v0, v1 +4: add v4, v2, v3 +5: add v5, v4, v0 +``` + +分配结果(2.1.1 区间表 + 2.1.2 规则):`v0→SPILL_v0`、`v1→t1`、`v2→SPILL_v2`、`v3→t0`、`v4→t1`、`v5→t0`;v0 槽 = 0、v2 槽 = 4(帧内正偏移,`spill_bytes = 8`)。 + +**分配后汇编(修复目标)**: + +``` + li t0, 1 + sw t0, 0(sp) # store redefined v0 + li t1, 2 + sw t0, 0(sp) # evict v0 + li t0, 3 + sw t0, 4(sp) # store redefined v2 + sw t0, 4(sp) # evict v2 + lw t0, 0(sp) # reload v0 + add t0, t0, t1 # v3 = v0 + v1 = 3 + lw t1, 4(sp) # reload v2 + add t1, t1, t0 # v4 = v2 + v3 = 6 + lw t0, 0(sp) # reload v0 + add t0, t1, t0 # v5 = v4 + v0 = 7 +``` + +验证点:任一指令中不同 vreg 的操作数寄存器两两不同;`v3=3, v4=6, v5=7`。 + +#### 2.4.2 非法示例 A:v1.5 reload 别名(B5 实测输出) + +同一输入块在 v1.5(`phys_regs=["t0","t1"]`)下的**实测输出**: + +``` + li t0, 1 + sw t0, -4(sp) # store redefined v0 + li t1, 2 + sw t0, -4(sp) # evict v0 + li t0, 3 + sw t0, -8(sp) # store redefined v2 + sw t0, -8(sp) # evict v2 + lw t1, -4(sp) # reload v0 + add t0, t1, t1 ← 非法:v0 与 v1 均绑定 t1(违反 I2),v3 = 2(应 3) + lw t1, -8(sp) # reload v2 + add t0, t1, t0 ← v4 = 5(应 6) + sw t0, -12(sp) # store redefined v4 + lw t1, -4(sp) # reload v0 + lw t1, -12(sp) # reload v4 ← 覆盖 v0 的 t1(跨 vreg 复用,违反 I1) + lw t1, -4(sp) # reload v0 ← 覆盖 v4 的 t1(跨 vreg 复用,违反 I1) + add t0, t1, t1 ← v5 = 1 + 1 = 2(应 7) +``` + +**根因链(详见开发文档第三章)**: + +1. `reuse_reg` 跨 vreg 复用(`regalloc_linear_v1_5.py:547-548` + 调用点 `:453-454`)违反 I1/I2; +2. `used` 把"纯定义起点"计为占用(`:529-537`),使本应空闲的寄存器触发驱逐,放大问题; +3. `_evict_for_reload` 不校验 victim 寄存器是否被 protected vreg 共享(`:574-586`),可返回被保护的寄存器; +4. 运行期驱逐 store 追加到已发射过的 `_evictions[pos]`(`:614-617` vs `:429-432`),store 行被丢弃。 + +同时注意:v1.5 的槽偏移为 `-4(sp)` 等 sp 之下地址,在真实执行中会被下一次帧调整覆盖,属非法布局(本设计统一改为帧内正偏移)。 + +#### 2.4.3 非法示例 B:v1.0 标签 / 分支目标丢失(B2 实测输出) + +``` + .label main # main ← 非法伪指令;且 main 未定义 + li a0, 1 + li a1, 2 + bnez a0 # .Lend ← 目标成为注释,分支目标丢失 + add a2, a0, a1 + j # .Lend ← 空操作数跳转 + .label .Lend # .Lend ← 非法标签定义 + jalr zero, ra # ret +``` + +根因:v1.0 的文本路径绕过 `AsmEmitter`,`to_asm()`(`regalloc_linear.py:116-126`)不认识 `.label` 伪指令与"目标在 comment 中"的跳转约定。合法形式应为 `main:` / `.Lend:` / `bnez a0, .Lend` / `j .Lend`。 + +#### 2.4.4 非法示例 C:v1.0 驱逐泄漏裸 vreg(B3) + +v1.0 `spill()` 对 victim 执行 `del self.alloc_map[vreg]`(`regalloc_linear.py:363-365`),既无 `SPILL_` 降级标记也无重定义写回路径;后续 `to_asm(rename)` 对该 vreg 原样输出 → 汇编中出现裸 `v0`。修复方式:v1.5 的 `SPILL_` 降级 + 重定义写回(`regalloc_linear_v1_5.py:466-496`、`:632`),正本沿用。 + +### 2.5 编译管线数据流与接口约束 + +#### 2.5.1 数据流 + +``` +InstructionSelector.run() + → list[MachineInstr] + → FunctionFrameAllocator.allocate_program() + ① split_functions():不以 "." 开头的 LABEL 开启新函数 + ② split_blocks():以 "." 开头的 LABEL 开启新基本块 + ③ 计算跨块 vreg 集(2.5.3) + ④ 逐块 LinearScanAllocator(pre_spilled=…).emit_machine_instrs() + (块基址按前序块的实际 spill_bytes 递增,见 2.1.5) + ⑤ 从发射结果汇总 spill_bytes / s-reg 保存集合 → frame_size + ⑥ 插入 prologue / epilogue + → AsmEmitter.emit() + → GNU 汇编文本 +``` + +#### 2.5.2 LsInstruction 与 MachineInstr 的往返约束 + +| 元素 | 表示 | 往返规则 | +|------|------|----------| +| 函数/块标签 | `MachineInstr(MachineOp.LABEL, comment=name)` ↔ `LsInstruction(opcode=".label", operands=[name], comment=name)` | `to_asm` 必须输出 `name:`;转换回 MachineInstr 必须还原 `MachineOp.LABEL` | +| 分支/跳转目标 | 目标保存在 `MachineInstr.comment` | 重命名只作用于操作数;`comment` 原样保留,禁止被 spill/reload 注记覆盖 | +| spill/reload | `LsInstruction(opcode="lw"/"sw", operands=[reg, "off(sp)"])` | 转换回 MachineInstr 时经 `MachineOperand.mem(offset, base)` 表达 | +| 立即数/物理寄存器 | `MachineOperand(kind="imm"/"reg")` | 保持不变;物理寄存器不参与重命名 | +| vreg | `MachineOperand(kind="vreg", value=name)` | 分配后必须全部消解;输出中残留任一 vreg 视为错误 | + +#### 2.5.3 跨块变量的保守处理(forced-spill) + +基本块内线性扫描**不**做 CFG 级活跃分析(本课题边界)。对同一函数内跨块存活的 vreg(在块 A 定义、块 B 使用;或在多个块定义)一律强制驻留内存:分配阶段把他们加入 `pre_spilled`,不分配物理寄存器;每次使用前 `lw`、每次重新定义后 `sw`。 + +``` +cross_block_vregs(function) = + { v | v 出现在 ≥2 个块中 } ∪ { v | v 在某块无定义但被使用 } ∪ { v | v 在 ≥2 个块中被定义 } +``` + +该策略对任意 CFG(含回边循环)**保守正确**:跨块值始终可从槽中恢复;代价是循环变量每轮访存,性能影响明确列为后续项(全局 liveness 立项后消除)。 + +--- + +## 三、测试设计 + +验证环境:`python3.11+`;执行对拍使用 `scratchv.backend.riscv_encoder.assemble_to_binary` + `scratchv.simulator.tinyfive.ProfiledMachine`(TinyFive 不可用时 skip,单元级对拍用测试内置解释器)。 + +### 测试用例 1:压力块溢出 / 重载语义(执行对拍) + +**文件**:`tests/test_regalloc_linear.py::test_pressure_block_no_alias_and_semantics` + +**输入**:2.4.1 的 6 指令块,`LinearScanAllocator(phys_regs=["t0","t1"])`。 + +**预期输出**:`allocate_block()` 返回序列经测试内置解释器执行后 `v5 == 7`(且 `v3 == 3`、`v4 == 6`);汇编中无 `SPILL_`、无裸 `v0`(忽略注释);每条指令的不同 vreg 操作数寄存器两两不同。 + +**验证点**: + +1. 语义对拍:mini-interpreter 求值 == Python 参考值 7; +2. I2 别名检查:`add t0, t1, t1` 这类输出必须为零; +3. 无泄漏:`SPILL_` 与裸 vreg 均不出现。 + +### 测试用例 2:溢出处重定义写回(溢出 + 重载边界) + +**文件**:`tests/test_regalloc_linear.py::test_spilled_redefinition_writeback_before_reload` + +**输入**: + +``` +0: li v0, 1 # v0 首次定义 +1: li v1, 2 +2: li v2, 3 +3: add v3, v0, v1 # v0 在指令 2 后已被驱逐 +4: li v0, 9 # v0 无读重定义 +5: add v4, v0, v2 +``` + +**预期输出**:`v4 == 12`;指令 4 之后必须出现 `sw …, slot(v0)(sp) # store redefined v0`,且该写回早于后续任何 `# reload v0`。 + +**验证点**: + +1. 语义:v0 新值 9 经槽位传递,v4 = 9 + 3 = 12; +2. 顺序:`store redefined` 行号 < 其后首个 `reload v0` 行号; +3. 去重:同一位置 v0 只出现一条 `lw`。 + +### 测试用例 3:多函数 callee-saved 保存/恢复(W9) + +**文件**:`tests/test_regalloc_integration.py::test_callee_saved_preserved_across_call` + +**输入**:手工构造的两函数 MachineInstr 程序(不依赖 IR call 生成): + +``` +main: # 函数 1 + li a0, 7 + call foo # 进入被调函数 + jalr zero, ra # ret + +foo: # 函数 2:在受限池 [t0, t1, s0] 下触发 s0 分配 + <17 个并发活跃 vreg 的直线块,结果留在 vreg r 中> + mv a0, r + jalr zero, ra +``` + +**预期输出**: + +1. `foo` 的 prologue 含 `addi sp, sp, -N`(`N % 16 == 0`)与 `sw s0, …(sp)`;每条 ret 路径前含 `lw s0, …(sp)`; +2. 执行对拍:调用前把 `s0` 置哨兵值 `0x5A5A`,`main` 结束后 `s0 == 0x5A5A`;`a0 == 17 个 vreg 块的参考结果`; +3. `ra` 在 `foo` 返回后仍能回到 `main` 的下一指令(无栈破坏)。 + +**验证点**:`used_callee_saved == {"s0"}`;帧对齐;寄存器值保持;控制流返回正确。 + +### 测试用例 4:端到端 DSL(标签 / 分支 / 循环回归 + 执行对拍) + +**文件**:`tests/test_regalloc_integration.py::test_dsl_linear_end_to_end` + +**输入**:含 `if/elif/else` 与 `while` 的 DSL 程序(例如课题模板的三例之一),经 + +```python +CompilerDriver(CompilerConfig(backend="riscv", reg_alloc="linear")).compile( + input_path, dsl_source=SRC) +``` + +**预期输出**: + +1. 汇编含函数标签 `main:` 与块标签 `.:`;所有分支/跳转目标都能在文本中找到同名标签定义; +2. `assemble_to_binary(asm)` 不抛异常; +3. `ProfiledMachine` 执行结果 == DSL 解释参考值(整数精确相等,含循环 5 次以上的用例); +4. 跨块变量(如循环计数器)表现为块边界的 `lw/sw`(forced-spill 生效)。 + +**验证点**:B2 标签回归关闭;B5/B3 泄漏与别名关闭;多块控制流正确;执行对拍通过。 + +### 单元测试清单(与上述用例同批交付) + +| 测试名 | 断言 | +|--------|------| +| `test_pure_definition_does_not_occupy_register` | def-at-p 的寄存器可作为 reload 目标 | +| `test_eviction_store_emitted_before_reload` | 运行期驱逐 store 不丢失(无 `_evictions` 晚登记问题) | +| `test_victim_never_aliases_protected_operand` | victim 寄存器被 protected vreg 共享时不可选 | +| `test_reload_dedup_same_vreg` | 同一位置 v1.5 的双重 `_reloads` 只发一条 `lw` | +| `test_allocate_raises_instead_of_phys_regs0_fallback` | 池空异常路径抛 `RegAllocError`(B5 根因 5) | +| `test_machine_types_allocatable_sets_consistent` | `ALL_REGS == a0-a7+t0-t6+s0-s11`(27),`GREEDY_REGS` 19,无特殊寄存器混入 | +| `test_to_asm_emits_valid_labels_and_branch_targets` | `.label` → `name:`;`j`/`bnez` 目标拼装正确 | +| `test_machine_operand_mem_round_trip` | `"8(sp)"` ↔ `MachineOperand.mem(8)` 往返稳定 | +| `test_linear_scan_deterministic` | 同一输入两次分配得到完全相同的 `alloc_map` 与输出 | + +--- + +## 四、修改模块与实现步骤 + +### 4.1 涉及文件 + +| 文件 | 角色 | 本课题动作 | +|------|------|-----------| +| `scratchv/backend/regalloc_linear.py` | 正本(当前 v1.0) | **用 v1.5 内容替换**,再叠加本设计的别名修复、归属判定、strict 模式、`stack_base`/`pre_spilled` | +| `scratchv/backend/regalloc_linear_v1_5.py` | 过渡别名 | 改为 `from .regalloc_linear import *`(一段过渡期后删除) | +| `scratchv/backend/frame_layout.py` | **新建** | 函数切分、forced-spill 集、帧布局、prologue/epilogue | +| `scratchv/backend/machine_types.py` | 寄存器组 | `CALLER_SAVED`、`ALL_REGS`(27)、`GREEDY_REGS`(19)、`REG_NUMS`、`MachineOperand.mem` | +| `scratchv/backend/register_alloc.py` | greedy/naive | 池改用 `GREEDY_REGS` 冻结行为;标注 legacy | +| `scratchv/backend/asm_emit.py` | 发射 | 无需改(`mem` 操作数经 `_fmt_op` 直接成型);如保留文本路径则配合 `to_asm` | +| `scratchv/backend/__init__.py` | 导出 | 更新正本导出与 `__all__` | +| `scratchv/compiler.py` | 管线 | `_generate_riscv_linear` 接入新路径;默认值裁决(第四章) | +| `scratchv/main.py` | CLI | `--reg-alloc` choices 增加 `linear-v1.5` 过渡别名;默认值裁决 | +| `tests/test_regalloc_linear.py` / `tests/test_pr37_regression.py` | 测试 | 扩充 / 改用正本 import | +| `tests/test_regalloc_integration.py` | **新建** | 端到端与执行对拍 | + +(注:实际路径以仓库为准;上表按 2026-09-14 工作区状态标注。) + +### 4.2 收敛策略:以 v1.5 为正本 + +1. **文件收敛**:`git mv` 不保留双份——把 `regalloc_linear_v1_5.py` 全文覆盖到 `regalloc_linear.py`;`regalloc_linear_v1_5.py` 降级为二行转发模块(`from scratchv.backend.regalloc_linear import *`,并写 deprecation 注释)。 +2. **符号收敛**:`LinearScanAllocator`、`LsInstruction`、`LiveInterval`、`block_from_machine_instrs`、`machine_instrs_from_block` 名称不变,保证 `backend/__init__.py`、`test_pr37_regression.py`、`benchmarks/test_regalloc/*` 无需立即修改。 +3. **调用方收敛**:`compiler.py`、`benchmarks/test_regalloc/bench_simple.py`、`bench_cnn.py`、`topic17_bottleneck_scenarios_v1_5.py` 的 import 统一指向正本;v1.0 专属测试语义更新或删除。 +4. **删除时机**:别名模块保留一个小版本周期;CI 增加 grep 禁止新代码 import `regalloc_linear_v1_5`。 + +### 4.3 P0 别名修复(核心,详见开发文档第三章) + +- 新增 `_occupied_at(pos, inst)` 归属判定,替换 `get_allocated_code` 中按 `rename + interval.contains` 拼装的 `live_regs`; +- 删除跨 vreg `reuse_reg`;reload 同位置按 vreg 去重; +- `_evict_for_reload` 增加 victim 约束(2.1.3)与 protected 寄存器集合校验; +- 运行期驱逐 store 内联到 `lw` 之前; +- `allocate()` 死代码 fallback(`phys_regs[0]`)改为 `RegAllocError`;`_pick_scratch` 全忙时按 strict 抛错/按压力场景计数降级。 + +### 4.4 溢出 / 重载与槽管理强化 + +- `_get_spill_slot` 改为 `stack_base + 4×index`(正偏移、帧内); +- `allocate()` 重置 `stack_slot`/`_scratch_cache`; +- 支持 `pre_spilled` 种子:`alloc_map[v]="SPILL_"+v`,按 uses/defs 预登记 reload 与写回; +- 文本输出与 `allocate_block` 输出共用同一份中间表示(禁止两份逻辑漂移)。 + +### 4.5 跨块变量 forced-spill(2.5.3) + +- `frame_layout.cross_block_vregs(blocks)` 计算集合; +- 每块分配时传 `pre_spilled=cross ∩ block_vregs`; +- 验收用例 4 覆盖含回边循环的 DSL。 + +### 4.6 W9 栈帧与 callee-saved + +- `FunctionFrameAllocator.allocate_program()`:逐块发射并按实际槽位推进块基址,再由发射结果汇总帧布局(不可用第一遍统计值,见 2.1.5/2.3); +- `emit_prologue` / `emit_epilogue` 按 2.3 模板; +- `ra` 保存仅在 `has_call`;保存集合 = 实际分配的 s-reg 并集。 + +### 4.7 machine_types / ALL_REGS 一致性 + +- `ALL_REGS = CALLER_SAVED + CALLEE_SAVED`(27,顺序与线性分配器池一致); +- `GREEDY_REGS = TEMP_REGS + CALLEE_SAVED`(19)冻结 greedy 行为; +- `REG_NUMS` 集中到 `machine_types`,分配器模块删除重复表; +- 一致性断言入测试。 + +### 4.8 管线接入(compiler.py / main.py) + +``` +_generate_riscv_linear(program): + selector → machine_instrs + if reg_alloc ∈ {"linear", "linear-v1.5"}: + allocated = FunctionFrameAllocator().allocate_program(machine_instrs) + return AsmEmitter(allocated).emit() + else: # naive / greedy 原路径不变 + RegisterAllocator(machine_instrs, mode=...).run() → AsmEmitter +``` + +- 默认值裁决:第一阶段库与 CLI 统一为 `"greedy"`(库从 `"linear"` 改为 `"greedy"`,CLI 保持);`linear` 为 opt-in。验收全绿后第二阶段一次提交同时翻转为 `"linear"`。 +- DAG 路径(`_generate_riscv_dag`)本轮不动,保持 greedy。 + +### 4.9 集成与回归测试 + +- `pytest tests/ -q` 全量回归;新增 `tests/test_regalloc_integration.py`; +- `python .claude/harness/verify/run.py --level L2`; +- `python3.12 -m benchmarks.test_regalloc.bench_regalloc_linear`(CI 现有步骤)在收敛后 import 正本仍可运行; +- 记录 `linear` vs `greedy` 的指令数差(信息性,不作门槛)。 + +--- + +## 五、附录 + +### 5.1 分配前后汇编示例(T1 压力块) + +**分配前**(LsInstruction): + +``` +li v0, 1 +li v1, 2 +li v2, 3 +add v3, v0, v1 +add v4, v2, v3 +add v5, v4, v0 +``` + +**区间 / 分配**:见 2.1.1 与 2.4.1。 + +**分配后(修复目标,见 2.4.1)**;**v1.5 实测(非法,见 2.4.2)**。 + +### 5.2 prologue / epilogue 完整示例 + +函数 `foo` 使用 `s0`、含 call、有 2 个 spill 槽(`spill_bytes = 8`): + +``` +frame_size = align16(Σspill + 4×|saved|) = align16(8 + 4×2) = align16(16) = 16 +saved = ["ra", "s0"] → ra: 16-4 = 12(sp), s0: 16-8 = 8(sp) +spill 区 [0, 8) → 槽 0: 0(sp), 槽 4: 4(sp) +重叠检查: max(spill) = 4 < min(saved) = 8 ✓ +``` + +完整输出: + +``` +foo: + addi sp, sp, -16 + sw ra, 12(sp) + sw s0, 8(sp) + # 函数体:spill 槽 0(sp) / 4(sp) 上做 lw/sw + ... + lw s0, 8(sp) + lw ra, 12(sp) + addi sp, sp, 16 + jalr zero, ra +``` + +若 `Σspill=8, |saved|=3`,则 `frame_size = align16(20) = 32`,保存位为 28(sp)/24(sp)/20(sp),spill 区仍在 [0, 8),无重叠。 + +### 5.3 参考资料 + +- Poletto & Sarkar (1999), *Linear Scan Register Allocation*, ACM TOPLAS. +- 龙书第 8.8 节:Register Allocation. +- RISC-V psABI: +- 本仓课题文档:`docs/topics/17-寄存器分配.md`;评审结论:`/root/Lab/GaoMD/ScratchV/Review/CI-Review/pr-37.md` +- 配套开发文档:`开发文档.md`(同目录,含接口契约、逐行锚点、灰度与验收) diff --git a/scratchv/backend/__init__.py b/scratchv/backend/__init__.py index 035aeb7..e0ced2e 100644 --- a/scratchv/backend/__init__.py +++ b/scratchv/backend/__init__.py @@ -1,6 +1,7 @@ from .machine_types import ( MachineOp, MachineOperand, MachineInstr, - CALLEE_SAVED, TEMP_REGS, ARG_REGS, ALL_REGS, STACK_BASE, ZERO_REG, + CALLEE_SAVED, TEMP_REGS, ARG_REGS, CALLER_SAVED, + ALL_REGS, GREEDY_REGS, REG_NUMS, STACK_BASE, ZERO_REG, ) from ._asm_parser import ( ParsedAsmLine, parse_line, parse_asm, lines_to_asm, classify_def_use, @@ -13,8 +14,11 @@ from .asm_peephole import AsmPeepholeOptimizer from .const_merge import merge_constants from .regalloc_linear import ( - LinearScanAllocator, block_from_machine_instrs, machine_instrs_from_block, + LinearScanAllocator, LsInstruction, LiveInterval, + RegAllocError, RegisterAliasError, SpillFallbackError, + block_from_machine_instrs, machine_instrs_from_block, ) +from .frame_layout import FunctionFrameAllocator, FrameInfo from .inst_scheduler import ( InstructionScheduler, parse_instructions, machine_instrs_from_scheduled, ) @@ -26,8 +30,8 @@ __all__ = [ # machine types "MachineOp", "MachineOperand", "MachineInstr", - "CALLEE_SAVED", "TEMP_REGS", "ARG_REGS", "ALL_REGS", - "STACK_BASE", "ZERO_REG", + "CALLEE_SAVED", "TEMP_REGS", "ARG_REGS", "CALLER_SAVED", + "ALL_REGS", "GREEDY_REGS", "REG_NUMS", "STACK_BASE", "ZERO_REG", # shared asm parser "ParsedAsmLine", "parse_line", "parse_asm", "lines_to_asm", "classify_def_use", @@ -37,7 +41,10 @@ "InstructionSelector", "RegisterAllocator", "AsmEmitter", "beautify_asm", "count_instructions", "AsmPeepholeOptimizer", "merge_constants", - "LinearScanAllocator", "block_from_machine_instrs", "machine_instrs_from_block", + "LinearScanAllocator", "LsInstruction", "LiveInterval", + "RegAllocError", "RegisterAliasError", "SpillFallbackError", + "block_from_machine_instrs", "machine_instrs_from_block", + "FunctionFrameAllocator", "FrameInfo", "InstructionScheduler", "parse_instructions", "machine_instrs_from_scheduled", "ExtendedInstructionSelector", ] diff --git a/scratchv/backend/frame_layout.py b/scratchv/backend/frame_layout.py new file mode 100644 index 0000000..a1b49c8 --- /dev/null +++ b/scratchv/backend/frame_layout.py @@ -0,0 +1,389 @@ +"""Function-level stack frame layout and callee-saved handling (topic 17 W9). + +Splits a machine instruction program into functions and basic blocks, runs +the linear-scan allocator per block with cross-block values forced to memory, +then computes a 16-byte aligned frame and inserts ``ra``/``s-reg`` +save/restore code around the function body. + +Frame layout (low to high addresses):: + + sp + 0 spill area (block 0, block 1, ...) + sp + spill_bytes saved s-regs (in CALLEE_SAVED order) + sp + frame_size - 4 saved ra (when the function contains a call) + +Usage:: + + from scratchv.backend.frame_layout import FunctionFrameAllocator + allocated = FunctionFrameAllocator().allocate_program(machine_instrs) +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Callable, Optional + +from scratchv.backend.machine_types import ( + CALLEE_SAVED, + REG_NUMS, + MachineInstr, + MachineOp, + MachineOperand, +) +from scratchv.backend.regalloc_linear import LinearScanAllocator, RegAllocError + +_MEM_OPERAND_RE = re.compile(r"^(-?\d+)\((\w+)\)$") + +# Comments the linear-scan allocator attaches to inserted spill/reload code. +_SPILL_COMMENT_MARKERS = ("reload ", "evict ", "store redefined ") + + +def align16(size: int) -> int: + """Round *size* up to a multiple of 16 bytes.""" + return ((size + 15) // 16) * 16 + + +@dataclass +class FrameInfo: + """Layout of one function's stack frame.""" + + frame_size: int + ra_offset: Optional[int] + saved_offsets: dict[str, int] = field(default_factory=dict) + spill_base: int = 0 + + +@dataclass +class FunctionChunk: + """A function split out of the flat program stream.""" + + name: Optional[str] + label: Optional[MachineInstr] + instrs: list[MachineInstr] = field(default_factory=list) + + +def _is_function_label(instr: MachineInstr) -> bool: + return ( + instr.op == MachineOp.LABEL + and bool(instr.comment) + and not instr.comment.startswith(".") + ) + + +def _is_block_label(instr: MachineInstr) -> bool: + return ( + instr.op == MachineOp.LABEL + and bool(instr.comment) + and instr.comment.startswith(".") + ) + + +def _operands_of(instr: MachineInstr): + return (instr.dst, instr.src1, instr.src2) + + +def _vregs_of(instr: MachineInstr) -> set[str]: + out: set[str] = set() + for op in _operands_of(instr): + if op is not None and op.kind == "vreg": + name = str(op.value) + if name not in REG_NUMS: # physical names never allocate + out.add(name) + return out + + +def split_functions( + instrs: list[MachineInstr], +) -> tuple[list[MachineInstr], list[FunctionChunk]]: + """Split a program into preamble instructions and function chunks. + + A ``LABEL`` whose name does not start with ``.`` starts a new function. + Instructions before the first function label form the preamble (returned + unchanged by the frame allocator). + """ + preamble: list[MachineInstr] = [] + functions: list[FunctionChunk] = [] + current: Optional[FunctionChunk] = None + + for instr in instrs: + if _is_function_label(instr): + current = FunctionChunk( + name=instr.comment, label=instr, instrs=[]) + functions.append(current) + elif current is None: + preamble.append(instr) + else: + current.instrs.append(instr) + + return preamble, functions + + +def split_blocks(instrs: list[MachineInstr]) -> list[list[MachineInstr]]: + """Split function body instructions into basic blocks at ``.`` labels.""" + blocks: list[list[MachineInstr]] = [] + current: list[MachineInstr] = [] + + for instr in instrs: + if _is_block_label(instr): + if current: + blocks.append(current) + current = [instr] + else: + current.append(instr) + if current: + blocks.append(current) + return blocks + + +def cross_block_vregs(blocks: list[list[MachineInstr]]) -> set[str]: + """Vregs that appear in more than one basic block (forced to memory).""" + counts: dict[str, int] = {} + for block in blocks: + seen = set() + for instr in block: + seen |= _vregs_of(instr) + for v in seen: + counts[v] = counts.get(v, 0) + 1 + return {v for v, c in counts.items() if c >= 2} + + +def _is_ret(instr: MachineInstr) -> bool: + if instr.op != MachineOp.JALR: + return False + for op in _operands_of(instr): + if op is not None and op.kind == "reg" and str(op.value) == "ra": + return True + return False + + +def _has_call(instrs: list[MachineInstr]) -> bool: + for instr in instrs: + if instr.op == MachineOp.CALL: + return True + if (instr.op == MachineOp.JAL and instr.comment + and not instr.comment.startswith(".")): + return True + return False + + +def _sw(reg: str, offset: int, comment: str = "") -> MachineInstr: + return MachineInstr( + MachineOp.SW, MachineOperand.reg(reg), + MachineOperand.mem(offset), comment=comment, + ) + + +def _lw(reg: str, offset: int, comment: str = "") -> MachineInstr: + return MachineInstr( + MachineOp.LW, MachineOperand.reg(reg), + MachineOperand.mem(offset), comment=comment, + ) + + +def emit_prologue(info: FrameInfo) -> list[MachineInstr]: + """``addi sp`` + ``sw ra`` / ``sw s-reg`` for a non-empty frame.""" + if info.frame_size == 0: + return [] + out = [MachineInstr( + MachineOp.ADDI, MachineOperand.reg("sp"), + MachineOperand.reg("sp"), + MachineOperand.immediate(-info.frame_size), + )] + if info.ra_offset is not None: + out.append(_sw("ra", info.ra_offset, comment="save ra")) + for reg in CALLEE_SAVED: + if reg in info.saved_offsets: + out.append(_sw(reg, info.saved_offsets[reg], + comment=f"save {reg}")) + return out + + +def emit_epilogue(info: FrameInfo) -> list[MachineInstr]: + """Inverse of :func:`emit_prologue` (inserted before every ret).""" + if info.frame_size == 0: + return [] + out: list[MachineInstr] = [] + for reg in reversed(CALLEE_SAVED): + if reg in info.saved_offsets: + out.append(_lw(reg, info.saved_offsets[reg], + comment=f"restore {reg}")) + if info.ra_offset is not None: + out.append(_lw("ra", info.ra_offset, comment="restore ra")) + out.append(MachineInstr( + MachineOp.ADDI, MachineOperand.reg("sp"), + MachineOperand.reg("sp"), + MachineOperand.immediate(info.frame_size), + )) + return out + + +class FunctionFrameAllocator: + """Allocate registers per block and lay out a frame per function. + + Parameters + ---------- + alloc_factory: + Optional factory producing a ``LinearScanAllocator`` for + ``stack_base=`` and ``pre_spilled=``. Used by + tests and pressure experiments to constrain the register pool. + cross_block_policy: + Only ``"force-spill"`` is supported (values live across blocks are + kept in their spill slots). + """ + + def __init__( + self, + alloc_factory: Optional[Callable[..., LinearScanAllocator]] = None, + cross_block_policy: str = "force-spill", + ) -> None: + if cross_block_policy != "force-spill": + raise ValueError( + f"unsupported cross_block_policy: {cross_block_policy!r}") + self._alloc_factory = alloc_factory + self.cross_block_policy = cross_block_policy + self.last_frame_info: dict[str, FrameInfo] = {} + + def _make_allocator( + self, stack_base: int, pre_spilled: set[str], + slot_hints: dict[str, int], + ) -> LinearScanAllocator: + if self._alloc_factory is None: + return LinearScanAllocator( + stack_base=stack_base, pre_spilled=pre_spilled, + slot_hints=slot_hints) + return self._alloc_factory( + stack_base=stack_base, pre_spilled=pre_spilled, + slot_hints=slot_hints) + + def allocate_program( + self, instrs: list[MachineInstr], + ) -> list[MachineInstr]: + """Allocate a whole program and insert prologue/epilogue code.""" + preamble, functions = split_functions(instrs) + out: list[MachineInstr] = list(preamble) + for func in functions: + out.extend(self._allocate_function(func)) + return out + + def _allocate_function( + self, func: FunctionChunk, + ) -> list[MachineInstr]: + blocks = split_blocks(func.instrs) + cross = cross_block_vregs(blocks) + + # Function-wide slots for cross-block values: every block must agree + # on where a forced-spilled vreg lives. + global_slots = {v: 4 * i for i, v in enumerate(sorted(cross))} + global_bytes = 4 * len(global_slots) + + # Emit every block once. A block's spill area starts after the + # areas actually consumed by the previous blocks (including the + # slots that reload-time eviction adds during emission), so the + # frame size below is the upper bound of the emitted code instead of + # a pass-1 estimate that pass 2 can outgrow (F1). + block_outputs: list[list[MachineInstr]] = [] + total_local = 0 + for block in blocks: + base = global_bytes + total_local + local = cross & {v for i in block for v in _vregs_of(i)} + alloc = self._make_allocator( + stack_base=base, pre_spilled=local, + slot_hints=global_slots) + block_out = alloc.emit_machine_instrs(block) + block_outputs.append(block_out) + local_slots = { + slot for v, slot in alloc.spill_slots.items() + if v not in global_slots + } + expected = {base + 4 * i for i in range(len(local_slots))} + if local_slots != expected: + raise RegAllocError( + f"frame layout for {func.name!r}: block spill slots " + f"{sorted(local_slots)} do not tile its region " + f"[{base}, {base + 4 * len(local_slots)})") + total_local += 4 * len(local_slots) + + spill_bytes = global_bytes + total_local + # The saved set is collected from the emitted instructions (not from + # the static allocation map): reload targets and scratch registers + # are picked dynamically during emission and may land on a + # callee-saved register that pass 1 never assigned (F3). + used_callee = self._callee_saved_regs(block_outputs) + has_call = _has_call(func.instrs) + saved = ["ra"] if has_call else [] + saved += [r for r in CALLEE_SAVED if r in used_callee] + frame_size = align16(spill_bytes + 4 * len(saved)) + + save_area_start = frame_size - 4 * len(saved) + if spill_bytes > save_area_start: + raise RegAllocError( + f"frame layout for {func.name!r}: spill area " + f"[0, {spill_bytes}) overlaps save area " + f"[{save_area_start}, {frame_size})") + self._check_spill_bounds(block_outputs, spill_bytes) + + info = FrameInfo( + frame_size=frame_size, + ra_offset=(frame_size - 4) if has_call else None, + saved_offsets={ + r: frame_size - 4 * (i + 1) + for i, r in enumerate(saved) if r != "ra" + }, + ) + if func.name: + self.last_frame_info[func.name] = info + + if func.label is not None: + out: list[MachineInstr] = [func.label] + else: + out = [] + out.extend(emit_prologue(info)) + + for block_out in block_outputs: + for instr in block_out: + if _is_ret(instr): + out.extend(emit_epilogue(info)) + out.append(instr) + return out + + @staticmethod + def _callee_saved_regs( + block_outputs: list[list[MachineInstr]], + ) -> set[str]: + """Callee-saved registers mentioned by the emitted function body. + + Conservative (a register only read is saved too): the prologue store + is harmless in that case, while missing one for a dynamically chosen + reload/scratch target corrupts the caller's state. + """ + used: set[str] = set() + for block_out in block_outputs: + for instr in block_out: + for op in _operands_of(instr): + if (op is not None and op.kind == "reg" + and str(op.value) in CALLEE_SAVED): + used.add(str(op.value)) + return used + + @staticmethod + def _check_spill_bounds( + block_outputs: list[list[MachineInstr]], + spill_bytes: int, + ) -> None: + """Every allocator-inserted spill/reload access must stay in the + spill area ``[0, spill_bytes)`` (fail loudly on layout drift).""" + for block_out in block_outputs: + for instr in block_out: + if not instr.comment.startswith(_SPILL_COMMENT_MARKERS): + continue + for op in _operands_of(instr): + if op is None or op.kind != "mem": + continue + match = _MEM_OPERAND_RE.match(str(op.value)) + if match is None or match.group(2) != "sp": + continue + offset = int(match.group(1)) + if offset < 0 or offset + 4 > spill_bytes: + raise RegAllocError( + f"spill access {op.value} outside spill area " + f"[0, {spill_bytes}) in {instr!r}") diff --git a/scratchv/backend/machine_types.py b/scratchv/backend/machine_types.py index cebf64b..f4feec9 100644 --- a/scratchv/backend/machine_types.py +++ b/scratchv/backend/machine_types.py @@ -9,7 +9,8 @@ from scratchv.backend.machine_types import ( MachineOp, MachineOperand, MachineInstr, - CALLEE_SAVED, TEMP_REGS, ARG_REGS, ALL_REGS, STACK_BASE, ZERO_REG, + CALLEE_SAVED, TEMP_REGS, ARG_REGS, ALL_REGS, GREEDY_REGS, + REG_NUMS, STACK_BASE, ZERO_REG, ) """ @@ -101,9 +102,9 @@ class MachineOp(enum.Enum): @dataclass class MachineOperand: - """A register or immediate operand.""" + """A register, immediate, or memory operand.""" - kind: str # "reg", "imm", "vreg" + kind: str # "reg", "imm", "vreg", "mem" value: str | int @staticmethod @@ -121,8 +122,13 @@ def reg(name: str) -> "MachineOperand": """Create a physical register operand.""" return MachineOperand("reg", name) + @staticmethod + def mem(offset: int, base: str = "sp") -> "MachineOperand": + """Create a memory operand, formatted as ``offset(base)``.""" + return MachineOperand("mem", f"{offset}({base})") + def __repr__(self) -> str: - if self.kind == "imm": + if self.kind in ("imm", "mem"): return str(self.value) return f"%{self.value}" @@ -168,8 +174,52 @@ def __repr__(self) -> str: # Argument / return-value registers ARG_REGS: list[str] = ["a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7"] -# All allocatable integer registers (19 total) -ALL_REGS: list[str] = TEMP_REGS + CALLEE_SAVED +# Caller-saved registers: arguments + temporaries +CALLER_SAVED: list[str] = ARG_REGS + TEMP_REGS + +# All allocatable integer registers (27 total): +# a0-a7 + t0-t6 + s0-s11, order shared with the linear-scan allocator pool. +ALL_REGS: list[str] = CALLER_SAVED + CALLEE_SAVED + +# Legacy greedy allocator pool (19): temporaries + callee-saved. Frozen so +# that the greedy allocation order is unchanged by the ALL_REGS correction. +GREEDY_REGS: list[str] = TEMP_REGS + CALLEE_SAVED + +# Canonical RISC-V register-number table (includes x-aliases and fp). +REG_NUMS: dict[str, int] = { + "x0": 0, "zero": 0, + "ra": 1, "x1": 1, + "sp": 2, "x2": 2, + "gp": 3, "x3": 3, + "tp": 4, "x4": 4, + "t0": 5, "x5": 5, + "t1": 6, "x6": 6, + "t2": 7, "x7": 7, + "s0": 8, "fp": 8, "x8": 8, + "s1": 9, "x9": 9, + "a0": 10, "x10": 10, + "a1": 11, "x11": 11, + "a2": 12, "x12": 12, + "a3": 13, "x13": 13, + "a4": 14, "x14": 14, + "a5": 15, "x15": 15, + "a6": 16, "x16": 16, + "a7": 17, "x17": 17, + "s2": 18, "x18": 18, + "s3": 19, "x19": 19, + "s4": 20, "x20": 20, + "s5": 21, "x21": 21, + "s6": 22, "x22": 22, + "s7": 23, "x23": 23, + "s8": 24, "x24": 24, + "s9": 25, "x25": 25, + "s10": 26, "x26": 26, + "s11": 27, "x27": 27, + "t3": 28, "x28": 28, + "t4": 29, "x29": 29, + "t5": 30, "x30": 30, + "t6": 31, "x31": 31, +} # Special-purpose registers STACK_BASE: str = "sp" diff --git a/scratchv/backend/regalloc_linear.py b/scratchv/backend/regalloc_linear.py index 5cc645f..8c901ec 100644 --- a/scratchv/backend/regalloc_linear.py +++ b/scratchv/backend/regalloc_linear.py @@ -1,36 +1,52 @@ """Linear Scan Register Allocator for RISC-V. Implements a basic-block-level linear scan register allocation algorithm -with proper live interval computation and spill code generation. +with proper live interval computation, spill/reload code generation, and a +strict no-alias invariant at every program point. + +This module is the topic-17 converged principal source (the former +``regalloc_linear_v1_5.py`` implementation, with the reload alias P0 fixed). +It exposes three error classes for fail-loudly behaviour: + +* ``RegAllocError`` -- base class for impossible allocation states. +* ``RegisterAliasError`` -- two live vregs claim the same physical register. +* ``SpillFallbackError`` -- strict mode cannot find a reload/scratch register. Usage:: from scratchv.backend.regalloc_linear import LinearScanAllocator allocator = LinearScanAllocator() - intervals = allocator.compute_live_intervals(block_instructions) - allocator.allocate(intervals) - result = allocator.get_allocated_code() + allocated = allocator.allocate_block(block_instructions) """ from __future__ import annotations +import re from dataclasses import dataclass, field -from typing import Optional +from typing import Collection, Optional + +from scratchv.backend.machine_types import ( + ALL_REGS, + CALLEE_SAVED, + REG_NUMS, + MachineInstr, + MachineOp, + MachineOperand, +) # --------------------------------------------------------------------------- # RISC-V register definitions # --------------------------------------------------------------------------- -# Allocatable integer registers (excludes x0/zero, sp, gp, tp, ra) -_INT_REGS = [ - # Argument/temp registers (caller-saved) - "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", # x10-x17 - "t0", "t1", "t2", "t3", "t4", "t5", "t6", # x5-x7, x28-x31 - # Saved registers (callee-saved) - "s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", # x8-x9, x18-x23 - "s8", "s9", "s10", "s11", # x24-x27 -] +# Compatibility alias: the allocatable integer register pool is the single +# source of truth in ``machine_types.ALL_REGS`` (27 = a0-a7 + t0-t6 + s0-s11). +_INT_REGS: list[str] = list(ALL_REGS) + +_DEFAULT_PHYS_REGS: list[str] = ALL_REGS + +# Compatibility alias for the canonical register-number table. +_REG_NUMS: dict[str, int] = REG_NUMS _FP_REGS = [ "f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", @@ -40,43 +56,40 @@ "f24", "f25", "f26", "f27", "f28", "f29", "f30", "f31", ] -_DEFAULT_PHYS_REGS = _INT_REGS - -# Standard register map -_REG_NUMS: dict[str, int] = { - "x0": 0, "zero": 0, - "ra": 1, "x1": 1, - "sp": 2, "x2": 2, - "gp": 3, "x3": 3, - "tp": 4, "x4": 4, - "t0": 5, "x5": 5, - "t1": 6, "x6": 6, - "t2": 7, "x7": 7, - "s0": 8, "fp": 8, "x8": 8, - "s1": 9, "x9": 9, - "a0": 10, "x10": 10, - "a1": 11, "x11": 11, - "a2": 12, "x12": 12, - "a3": 13, "x13": 13, - "a4": 14, "x14": 14, - "a5": 15, "x15": 15, - "a6": 16, "x16": 16, - "a7": 17, "x17": 17, - "s2": 18, "x18": 18, - "s3": 19, "x19": 19, - "s4": 20, "x20": 20, - "s5": 21, "x21": 21, - "s6": 22, "x22": 22, - "s7": 23, "x23": 23, - "s8": 24, "x24": 24, - "s9": 25, "x25": 25, - "s10": 26, "x26": 26, - "s11": 27, "x27": 27, - "t3": 28, "x28": 28, - "t4": 29, "x29": 29, - "t5": 30, "x30": 30, - "t6": 31, "x31": 31, -} +# Jump/branch mnemonics whose label target is carried in ``comment`` for the +# machine path and re-materialised as the final operand by ``to_asm``. +_BRANCH_TARGET_OPS = frozenset({ + "j", "jal", "call", "beq", "bne", "blt", "bge", "bnez", +}) + +_MEM_OPERAND_RE = re.compile(r"^(-?\d+)\((\w+)\)$") + +# Machine ops whose first operand slot is NOT a destination: branches and +# jumps carry a condition/target there, stores carry the value, and calls +# define nothing. Getting this wrong makes the allocator treat a live-in +# branch condition as a fresh definition (stale write-back + missing reload). +_NO_DST_OPS = frozenset({ + MachineOp.BEQ, MachineOp.BNE, MachineOp.BLT, MachineOp.BGE, + MachineOp.BNEZ, MachineOp.J, MachineOp.JAL, MachineOp.JALR, + MachineOp.SW, MachineOp.FSW, MachineOp.FSD, + MachineOp.CALL, MachineOp.LABEL, +}) + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +class RegAllocError(RuntimeError): + """Register allocation cannot produce a correct result (fail loudly).""" + + +class RegisterAliasError(RegAllocError): + """Invariant I1/I2 violated: two live vregs claim one physical register.""" + + +class SpillFallbackError(RegAllocError): + """Strict mode found no scratch/reload register for a spilled vreg.""" # --------------------------------------------------------------------------- @@ -92,15 +105,16 @@ class LsInstruction: id: Unique index within the basic block. opcode: - Instruction mnemonic (e.g. "add", "lw", "sw"). + Instruction mnemonic (e.g. "add", "lw", "sw"); labels use ".label". operands: - List of operand strings (register names, immediates). + List of operand strings (register names, immediates, ``off(sp)``). defines: Set of virtual register names written by this instruction. uses: Set of virtual register names read by this instruction. comment: - Optional comment string. + Optional comment string. For branches/jumps it carries the label + target, and for labels it carries the label name. """ id: int opcode: str @@ -114,14 +128,29 @@ def __repr__(self) -> str: f"def={self.defines}, use={self.uses})") def to_asm(self, rename: Optional[dict[str, str]] = None) -> str: - """Emit this instruction as assembly after register renaming.""" + """Emit this instruction as assembly after register renaming. + + * ``.label`` instructions render as ``name:``. + * Jump/branch instructions re-materialise their label target from + ``comment`` as the final operand (``j .Lend`` / ``bnez a0, .Lend``). + """ + if self.opcode == ".label": + name = self.operands[0] if self.operands else self.comment + return f"{name}:" + ops = self.operands[:] if rename: ops = [rename.get(o, o) for o in ops] + + target: Optional[str] = None + if self.opcode in _BRANCH_TARGET_OPS and self.comment: + target = self.comment + parts = [f" {self.opcode}"] - if ops: - parts.append(" " + ", ".join(ops)) - if self.comment: + rendered = ops + ([target] if target is not None else []) + if rendered: + parts.append(" " + ", ".join(rendered)) + if self.comment and target is None: parts.append(f" # {self.comment}") return "".join(parts) @@ -139,7 +168,7 @@ class LiveInterval: vreg: Virtual register name. start: - Instruction index of the first definition. + Instruction index of the first definition (0 for live-in values). end: Instruction index of the last use (exclusive bound). uses: @@ -155,7 +184,7 @@ def overlaps(self, other: "LiveInterval") -> bool: return self.start < other.end and other.start < self.end def contains(self, pos: int) -> bool: - """Check if a position is within this interval.""" + """Check if a position is within this interval (half-open).""" return self.start <= pos < self.end def __repr__(self) -> str: @@ -172,35 +201,83 @@ class LinearScanAllocator: Parameters ---------- phys_regs: - List of physical register names available for allocation. - Defaults to all integer registers (excluding special-purpose regs). + Physical register names available for allocation. Defaults to + ``machine_types.ALL_REGS`` (27 integer registers). + stack_base: + Byte offset of this block's spill area relative to the function + frame base. Slots are assigned as ``stack_base + 4 * index``. + strict: + ``True`` (default): degenerate paths raise ``RegAllocError``. + ``False``: pressure-measurement mode, counts fallbacks instead. + pre_spilled: + Vregs forced to memory (cross-block values). Each use gets a + reload and each redefinition is written back to the slot. Attributes ---------- - stack_slot: - Current stack slot offset (negative, grows downward). alloc_map: - Mapping from virtual register to assigned physical register. + vreg -> physical register, or ``"SPILL_"`` marker. spill_code: - List of spill load/store instructions inserted during allocation. + position -> spill write-back store lines. """ - def __init__(self, phys_regs: Optional[list[str]] = None): + def __init__( + self, + phys_regs: Optional[list[str]] = None, + *, + stack_base: int = 0, + strict: bool = True, + pre_spilled: Collection[str] = (), + slot_hints: Optional[dict[str, int]] = None, + ): self.phys_regs: list[str] = ( - phys_regs if phys_regs is not None + list(phys_regs) if phys_regs is not None else list(_DEFAULT_PHYS_REGS) ) - self.stack_slot: int = 0 + self.stack_base: int = int(stack_base) + self.strict: bool = bool(strict) + self.pre_spilled: tuple[str, ...] = tuple(pre_spilled) + # Function-wide slot assignments (cross-block forced-spill values) + # must be identical in every block, so callers pin them here. + self._slot_hints: dict[str, int] = dict(slot_hints or {}) + + self.stack_slot: int = self.stack_base self.alloc_map: dict[str, str] = {} self.spill_code: dict[int, list[str]] = {} # pos -> [sw asm lines] - self._spill_slots: dict[str, int] = {} # vreg -> slot offset + self._spill_slots: dict[str, int] = {} # vreg -> frame offset self._reloads: dict[int, list[tuple[str, int]]] = ( {} # pos -> [(vreg, slot), ...] ) self._spilled: set[str] = set() self._intervals: list[LiveInterval] = [] self._vreg_interval: dict[str, LiveInterval] = {} - self._evictions: dict[int, list[str]] = {} # pos -> sw lines emitted before reload + self._evictions: dict[int, list[str]] = {} # pos -> sw lines + self._eviction_events: dict[int, list[tuple[str, int, str]]] = ( + {} # pos -> [(victim, slot, preg)] allocation-time evictions + ) + self.peak_active: int = 0 + self.peak_real_pressure: int = 0 + self._scratch_cache: dict[str, str] = {} + self.fallback_count: int = 0 + + # ------------------------------------------------------------------ + # Read-only state (frame allocator / tests) + # ------------------------------------------------------------------ + + @property + def spill_slots(self) -> dict[str, int]: + """vreg -> non-negative frame offset.""" + return dict(self._spill_slots) + + @property + def spill_bytes(self) -> int: + """Bytes of spill area consumed by this block.""" + return 4 * len(self._spill_slots) + + @property + def used_callee_saved(self) -> set[str]: + """Callee-saved registers actually assigned by this allocator.""" + return {r for r in self.alloc_map.values() if r in CALLEE_SAVED} # ------------------------------------------------------------------ # Live interval computation @@ -209,18 +286,11 @@ def __init__(self, phys_regs: Optional[list[str]] = None): def compute_live_intervals( self, block: list[LsInstruction], ) -> list[LiveInterval]: - """Compute live intervals for all virtual registers in a basic block. - - Parameters - ---------- - block: - List of LsInstruction objects in instruction order. + """Compute live intervals for all virtual registers in a block. - Returns - ------- - List of LiveInterval objects sorted by start position. + Returns intervals sorted by ``(start, end, vreg)`` so allocation is + deterministic regardless of set iteration order. """ - # Collect all virtual register names vregs: set[str] = set() for inst in block: vregs |= inst.defines @@ -231,33 +301,25 @@ def compute_live_intervals( for vreg in vregs: start = -1 end = -1 - uses = set() + uses: set[int] = set() for inst in block: - if vreg in inst.defines: - if start == -1: - start = inst.id + if vreg in inst.defines and start == -1: + start = inst.id if vreg in inst.uses: uses.add(inst.id) end = max(end, inst.id + 1) - if vreg in inst.defines and vreg in inst.uses: - # define and use in same instruction - uses.add(inst.id) - if start == -1: - start = inst.id - end = max(end, inst.id + 1) if start == -1: start = 0 # live-in parameter - if end == -1: - end = start + 1 + end = start + 1 # pure definition intervals.append(LiveInterval( vreg=vreg, start=start, end=end, uses=uses, )) - return sorted(intervals, key=lambda iv: iv.start) + return sorted(intervals, key=lambda iv: (iv.start, iv.end, iv.vreg)) # ------------------------------------------------------------------ # Linear scan allocation @@ -266,14 +328,8 @@ def compute_live_intervals( def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: """Perform linear scan register allocation. - Parameters - ---------- - intervals: - Sorted list of live intervals (by start position). - - Returns - ------- - Mapping from virtual register name to physical register name. + Returns a mapping from virtual register name to physical register + name (or ``"SPILL_"`` for spilled vregs). """ self.alloc_map.clear() self.spill_code.clear() @@ -281,33 +337,51 @@ def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: self._reloads.clear() self._spilled.clear() self._evictions.clear() + self._eviction_events.clear() + self._scratch_cache.clear() + self.fallback_count = 0 + self.stack_slot = self.stack_base + self.peak_active = 0 + self.peak_real_pressure = 0 + + intervals = sorted( + intervals, key=lambda iv: (iv.start, iv.end, iv.vreg)) self._intervals = intervals self._vreg_interval = {iv.vreg: iv for iv in intervals} - # Active list: (interval, phys_reg) sorted by increasing end + # Seed forced-spill values (cross-block vregs): every use reloads, + # every redefinition is written back by the codegen path. + for v in sorted(self.pre_spilled): + self._spilled.add(v) + self.alloc_map[v] = f"SPILL_{v}" + slot = self._get_spill_slot(v) + iv = self._vreg_interval.get(v) + if iv is not None: + for use_pos in sorted(iv.uses): + self._reloads.setdefault(use_pos, []).append((v, slot)) + active: list[tuple[LiveInterval, str]] = [] free_regs: list[str] = list(self.phys_regs) for interval in intervals: - # Expire old intervals + if interval.vreg in self._spilled: + continue # pre-spilled (forced-spill) values stay in memory self._expire_old_intervals(active, interval.start, free_regs) if free_regs: - # Assign a free register reg = free_regs.pop(0) - self.alloc_map[interval.vreg] = reg - active.append((interval, reg)) else: - # Need to spill - spill = self.spill(interval, active, free_regs) - if spill is not None: - # Spill freed a register - reg = ( - free_regs.pop(0) if free_regs - else self.phys_regs[0] - ) - self.alloc_map[interval.vreg] = reg - active.append((interval, reg)) + self._spill_for_allocation(interval, active, free_regs) + reg = free_regs.pop(0) + self.alloc_map[interval.vreg] = reg + active.append((interval, reg)) + + current_active = len(active) + if current_active > self.peak_active: + self.peak_active = current_active + current_pressure = current_active + len(self._spilled) + if current_pressure > self.peak_real_pressure: + self.peak_real_pressure = current_pressure return dict(self.alloc_map) @@ -324,73 +398,62 @@ def _expire_old_intervals(self, active: list[tuple[LiveInterval, str]], else: i += 1 - def spill(self, current: LiveInterval, - active: list[tuple[LiveInterval, str]], - free_regs: list[str]) -> Optional[str]: - """Select a register to spill and emit spill code. - - Chooses the active interval with the farthest end position to spill. - Records reload positions for the spilled interval so that - ``get_allocated_code`` can insert ``lw`` before each future use. - - Returns - ------- - The physical register freed by spilling, or None if current is spilled. + def _spill_for_allocation( + self, + current: LiveInterval, + active: list[tuple[LiveInterval, str]], + free_regs: list[str], + ) -> None: + """Evict the farthest-ending active interval to free its register. + + Reloads are registered for every use of the victim. Because codegen + replays the block linearly from the final spill state, uses that + precede the eviction point also need a reload; the slot is kept + current by the definition write-back (defined victims) or by the + entry store emitted for live-in victims. The freed register is + appended to *free_regs*. """ if not active: - return None + raise RegAllocError( + "register allocation: no physical register available for " + f"{current.vreg} at position {current.start} " + f"(pool size {len(self.phys_regs)})" + ) - # Find the active interval with the farthest end spill_idx = 0 farthest_end = active[0][0].end - for i, (interval, _) in enumerate(active): if interval.end > farthest_end: farthest_end = interval.end spill_idx = i - spill_interval, spill_reg = active[spill_idx] - - # Only spill if the current interval ends earlier - if current.end <= spill_interval.end: - # Spill the farthest active interval (victim) - slot = self._get_spill_slot(spill_interval.vreg) - active.pop(spill_idx) - self._evictions.setdefault(current.start, []).append( - f" sw {spill_reg}, {slot}(sp) # evict {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) - # 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)) - free_regs.append(spill_reg) - return spill_reg - - # 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}" + victim, victim_reg = active.pop(spill_idx) + slot = self._get_spill_slot(victim.vreg) + self._eviction_events.setdefault(current.start, []).append( + (victim.vreg, slot, victim_reg) ) - for use_pos in current.uses: - if use_pos > current.start: - self._reloads.setdefault(use_pos, []).append( - (current.vreg, slot)) + self.alloc_map[victim.vreg] = f"SPILL_{victim.vreg}" + self._spilled.add(victim.vreg) + # Register reloads for every use: codegen replays the block linearly + # from the final (post-allocation) spill state, so uses that precede + # the eviction point also need a reload. The slot is kept current by + # the definition write-back (defined victims) or by the entry store + # emitted for live-in victims. + for use_pos in sorted(victim.uses): + self._reloads.setdefault(use_pos, []).append( + (victim.vreg, slot)) + free_regs.append(victim_reg) return None def _get_spill_slot(self, vreg: str) -> int: - """Get or allocate a stack slot for a virtual register.""" + """Get or allocate a non-negative frame offset for a vreg.""" + hint = self._slot_hints.get(vreg) + if hint is not None: + self._spill_slots[vreg] = hint + return hint if vreg not in self._spill_slots: - self.stack_slot -= 4 self._spill_slots[vreg] = self.stack_slot + self.stack_slot += 4 return self._spill_slots[vreg] # ------------------------------------------------------------------ @@ -398,126 +461,296 @@ def _get_spill_slot(self, vreg: str) -> int: # ------------------------------------------------------------------ def emit(self, block: list[LsInstruction]) -> str: - """Main entry point: allocate registers and emit assembly. + """Main entry point: allocate registers and emit assembly text.""" + return self.get_allocated_code(block) - Computes live intervals, runs linear-scan allocation, then - generates assembly with spill stores and reloads interleaved. + def get_allocated_code(self, block: list[LsInstruction]) -> str: + """Emit allocated assembly text with spill/reload instructions.""" + allocated = self.allocate_block(block) + return "\n".join(inst.to_asm() for inst in allocated) + + def allocate_block( + self, block: list[LsInstruction], + ) -> list[LsInstruction]: + """Allocate + rename a block, returning a new instruction sequence. + + The returned sequence contains the original instructions with + physical register operands plus inserted ``lw``/``sw`` spill and + reload instructions. No operand retains a vreg or ``SPILL_`` + prefix. """ intervals = self.compute_live_intervals(block) self.allocate(intervals) - return self.get_allocated_code(block) - - def get_allocated_code(self, block: list[LsInstruction]) -> str: - """Generate allocated assembly with spill stores and reloads. + return self._build_allocated_block(block) + + def emit_machine_instrs( + self, instrs: list[MachineInstr], + ) -> list[MachineInstr]: + """MachineInstr-level entry point for a single basic block.""" + return machine_instrs_from_block( + self.allocate_block(block_from_machine_instrs(instrs)) + ) - Walks the instruction block in order. Before each instruction - that uses a spilled vreg, a reload ``lw`` is inserted. After - each instruction that defines a spilled vreg, a spill ``sw`` - is inserted. - """ - lines: list[str] = [] + def _build_allocated_block( + self, block: list[LsInstruction], + ) -> list[LsInstruction]: + out: list[LsInstruction] = [] rename: dict[str, str] = dict(self.alloc_map) + first_def: dict[str, int] = {} for inst in block: - # Emit eviction spill stores before reloads at this position - if inst.id in self._evictions: - lines.extend(self._evictions[inst.id]) - - # Insert reloads before the instruction - if inst.id in self._reloads: - # Vregs used/defined by this instruction must not be evicted - # by _evict_for_reload, otherwise inst.to_asm() would get - # an unresolved vreg name. - protected: set[str] = inst.uses | inst.defines - for vreg, slot in self._reloads[inst.id]: - reload_reg = self._pick_reload_reg(rename, inst.id, protected) - lines.append( - f" lw {reload_reg}, {slot}(sp)" - f" # reload {vreg}" - ) - rename[vreg] = reload_reg - - lines.append(inst.to_asm(rename)) - - # Insert spill stores after the instruction - if inst.id in self.spill_code: - lines.extend(self.spill_code[inst.id]) - - return "\n".join(lines) - - def _pick_reload_reg(self, rename: dict[str, str], current_pos: int, - protected_vregs: set[str] | None = None) -> str: - """Pick a free physical register for a reload ``lw``. - - Filters *rename* by actual liveness at *current_pos* so that - registers held by already-expired vregs are considered free. - If all registers are genuinely occupied, evicts the one whose - interval ends farthest away. - - *protected_vregs* are excluded from eviction — typically the - current instruction's own uses/defines — since evicting them - would leave the instruction with an unresolved vreg name. - """ - used: set[str] = set() - for vreg, preg in rename.items(): - interval = self._vreg_interval.get(vreg) - if interval is None or interval.contains(current_pos): - used.add(preg) - for reg in self.phys_regs: - if reg not in used: - return reg - return self._evict_for_reload(rename, used, current_pos, protected_vregs) + for d in inst.defines: + if d not in first_def: + first_def[d] = inst.id + + # Allocation-time evictions. For a victim defined in this block the + # definition write-back keeps its slot current, so no store is needed + # (and its allocation-time register may be stale). A live-in victim + # has no write-back, so its incoming value is captured at block entry + # before any scratch/reload can reuse the register. + entry_id = block[0].id if block else 0 + for evict_pos in sorted(self._eviction_events): + for victim, slot, victim_reg in self._eviction_events[evict_pos]: + if victim in first_def: + continue + line = f" sw {victim_reg}, {slot}(sp) # evict {victim}" + out.append(_parse_line(line, entry_id)) + self._evictions.setdefault(entry_id, []).append(line) + rename[victim] = f"SPILL_{victim}" - def _evict_for_reload( - self, rename: dict[str, str], used: set[str], current_pos: int, - protected_vregs: set[str] | None = None, - ) -> str: - """Evict a live register to make room for a reload. + for inst in block: + owners = self._occupied_at(inst.id, inst) + loaded: dict[str, str] = {} + + # Reloads: dedup per vreg within one instruction slot, never + # share a reload register across different vregs. + for vreg, slot in self._reloads.get(inst.id, []): + reg, stores = self._pick_reload_reg( + inst, vreg, slot, rename, owners, loaded) + for line in stores: + out.append(_parse_line(line, inst.id)) + if vreg not in loaded: + out.append(LsInstruction( + inst.id, "lw", [reg, f"{slot}(sp)"], + comment=f"reload {vreg}", + )) + loaded[vreg] = reg + rename[vreg] = reg + + busy = set(owners) | set(loaded.values()) + + # Write back redefinitions of spilled vregs. + for d in sorted(inst.defines): + if d not in self._spilled: + continue + slot = self._spill_slots.get(d, self.stack_base) + cur = rename.get(d) + if cur is None or str(cur).startswith("SPILL_"): + cur = self._pick_scratch(d, busy=busy) + rename[d] = cur + busy.add(cur) + self.spill_code.setdefault(inst.id, []).append( + f" sw {cur}, {slot}(sp) # store redefined {d}" + ) + + renamed_ops = [rename.get(o, o) for o in inst.operands] + if self.strict: + # I2 backstop; the non-strict pressure-measurement mode + # deliberately counts fallbacks instead of raising. + self._check_operand_aliases(inst, renamed_ops) + out.append(LsInstruction( + inst.id, inst.opcode, renamed_ops, + defines=set(inst.defines), uses=set(inst.uses), + comment=inst.comment, + )) + + for line in self.spill_code.get(inst.id, []): + out.append(_parse_line(line, inst.id)) - Picks the vreg whose interval ends farthest away, generates a - spill store to its stack slot, and records future reloads for - its remaining uses. + return out - Vregs in *protected_vregs* are excluded from eviction — they are - needed by the instruction at *current_pos* and evicting them - would produce unresolved vreg names in the output. + def _check_operand_aliases( + self, inst: LsInstruction, renamed_ops: list[str], + ) -> None: + """Enforce invariant I2 on one emitted instruction. + + Distinct vreg *source* operands must end up in pairwise distinct + physical registers. Repeating the same vreg is allowed; a pure + definition may share its register with one source (invariant I3: + ``lw t0, …; add t0, t0, t1``). Raises ``RegisterAliasError`` + otherwise; this is the output-time backstop for dynamic eviction + bugs such as the stale-owner double eviction (F2/F7). """ - protect = protected_vregs or set() - farthest_vreg: str | None = None - farthest_end = -1 - for vreg, preg in rename.items(): - if preg not in used: + seen: dict[str, str] = {} + for orig, new in zip(inst.operands, renamed_ops): + if orig not in inst.uses: continue - if vreg in protect: + prev = seen.get(new) + if prev is not None and prev != orig: + raise RegisterAliasError( + f"position {inst.id}: {prev} and {orig} both map to " + f"{new} (invariant I2 violated)") + seen[new] = orig + + def _occupied_at(self, pos: int, inst: LsInstruction) -> dict[str, str]: + """Return the exclusive ``preg -> vreg`` ownership map at *pos*. + + A vreg occupies its register when its interval strictly spans the + position, or when it is read-modify-written by the instruction at + *pos*. A pure definition at ``start == pos`` does not occupy the + register (invariant I3), so reloads may target it. + + Raises ``RegisterAliasError`` if two live vregs claim one register + (invariant I1). + """ + owners: dict[str, str] = {} + for v, r in self.alloc_map.items(): + if v in self._spilled: 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 + if r not in self.phys_regs: + continue + iv = self._vreg_interval.get(v) + if iv is None: + continue + if iv.start == pos: + # At the interval start a pure definition does not occupy + # its register yet (invariant I3); a live-in value (synthetic + # start 0 without a definition here) or a read-modify-write + # does. + occupies = not (v in inst.defines and v not in inst.uses) + else: + occupies = iv.start < pos < iv.end + if not occupies: + continue + prev = owners.get(r) + if prev is not None and prev != v: + raise RegisterAliasError( + f"position {pos}: {prev} and {v} both claim {r}") + owners[r] = v + return owners + + def _select_victim( + self, inst: LsInstruction, owners: dict[str, str], used: set[str], + ) -> Optional[tuple[str, str]]: + """Select an evictable (vreg, preg); return None if none qualifies. + + Constraints: the register must actually be in use, the vreg must + not be an operand of the current instruction, the vreg must not + already be spilled, the vreg must have a future use (otherwise + eviction buys nothing), and the register must be exclusively owned. + Ties break by vreg name for determinism. The victim's register is + taken from the live ownership map (``owners``) rather than from the + possibly stale ``alloc_map`` (F2). + """ + protected = inst.uses | inst.defines + best: Optional[str] = None + best_reg: Optional[str] = None + best_end = -1 + for r, v in owners.items(): + if r not in used: + continue + if v in protected or v in self._spilled: + continue + iv = self._vreg_interval.get(v) + if iv is None: + continue + if not any(u > inst.id for u in iv.uses): + continue + if iv.end > best_end or (iv.end == best_end + and best is not None and v < best): + best, best_reg, best_end = v, r, iv.end + if best is None or best_reg is None: + return None + return best, best_reg + + def _pick_reload_reg( + self, + inst: LsInstruction, + vreg: str, + slot: int, + rename: dict[str, str], + owners: dict[str, str], + loaded: dict[str, str], + ) -> tuple[str, list[str]]: + """Pick a reload target register. + + Returns ``(register, store_lines_to_emit_before_the_lw)``. A vreg + already reloaded in this instruction reuses its binding (no second + ``lw``); different vregs never share a reload register (invariant + I2/I4). + """ + if vreg in loaded: + return loaded[vreg], [] + + # Invariant I4: never target a register the instruction itself uses + # as a physical operand (e.g. ``mv a0, …`` / ``add …, a0, …``). + used = set(owners) | set(loaded.values()) | { + o for o in inst.operands if o in REG_NUMS} + + for r in self.phys_regs: + if r not in used: + return r, [] + + picked = self._select_victim(inst, owners, used) + if picked is None: + if self.strict: + raise SpillFallbackError( + "regalloc: cannot find a reload register for " + f"{vreg} at position {inst.id}: all registers are held " + f"by active values or by the instruction operands " + f"{sorted(inst.uses | inst.defines)}" + ) + self.fallback_count += 1 + if loaded: + return next(iter(loaded.values())), [] + for r in self.phys_regs: + if owners.get(r) not in (inst.uses | inst.defines): + return r, [] + return self.phys_regs[0], [] + + victim, reg = picked + slot_v = self._get_spill_slot(victim) + stores = [ + f" sw {reg}, {slot_v}(sp) # evict {victim} for reload" + ] + self._spilled.add(victim) + self.alloc_map[victim] = f"SPILL_{victim}" + rename[victim] = f"SPILL_{victim}" + # The victim's register stops being owned as soon as its value is + # stored; refresh the ownership map so a second reload in the same + # instruction cannot evict the same (already spilled) victim (F2). + owners.pop(reg, None) + iv = self._vreg_interval.get(victim) + if iv is not None: + for u in sorted(iv.uses): + if u > inst.id: + self._reloads.setdefault(u, []).append((victim, slot_v)) + return reg, stores + + def _pick_scratch(self, vreg: str, busy: set[str]) -> str: + """Pick a scratch register for a spilled vreg definition. + + *busy* holds every register that must not be clobbered at this + point (live owners, reload targets, previously chosen scratches). + """ + candidate = self._scratch_cache.get(vreg) + if candidate is not None and candidate not in busy: + return candidate + for reg in self.phys_regs: + if reg not in busy: + self._scratch_cache[vreg] = reg + return reg + if self.strict: + raise SpillFallbackError( + "regalloc: no scratch register available for redefined " + f"spilled vreg {vreg}: busy={sorted(busy)}" + ) + self.fallback_count += 1 + if candidate is not None: + return candidate + reg = self.phys_regs[0] + self._scratch_cache[vreg] = reg + return reg # ------------------------------------------------------------------ # Report @@ -531,59 +764,86 @@ def report(self) -> str: parts.append("Linear Scan Register Allocation Report") parts.append(f" Virtual registers allocated: {total}") parts.append(f" Stack spill slots used: {spilled}") + parts.append(f" Peak active (phys regs mapped): {self.peak_active}") + parts.append( + f" Peak real pressure (incl. self-spilled): " + f"{self.peak_real_pressure}") parts.append( f" Physical registers available: {len(self.phys_regs)}" ) + if self.fallback_count: + parts.append(f" Non-strict fallbacks: {self.fallback_count}") if self._spill_slots: - parts.append(" Spill details:") + parts.append(" Spill details (frame-relative, non-negative):") for vreg, slot in self._spill_slots.items(): parts.append(f" {vreg}: sp+{slot}") return "\n".join(parts) # --------------------------------------------------------------------------- -# Helper: convert MachineInstr list to LsInstruction list +# Helpers: spill line <-> LsInstruction, MachineInstr conversion # --------------------------------------------------------------------------- +def _parse_line(line: str, inst_id: int) -> LsInstruction: + """Convert an internally generated spill/reload line to LsInstruction.""" + if "#" in line: + body, comment = line.split("#", 1) + comment = comment.strip() + else: + body, comment = line, "" + tokens = body.strip().split(None, 1) + opcode = tokens[0] + operands = ( + [o.strip() for o in tokens[1].split(",") if o.strip()] + if len(tokens) > 1 else [] + ) + return LsInstruction(inst_id, opcode, operands, comment=comment) + + +def _to_mop(s: str) -> MachineOperand: + """Convert an operand string to a MachineOperand (exact-match table).""" + if s in REG_NUMS: + return MachineOperand.reg(s) + mem = _MEM_OPERAND_RE.match(s) + if mem is not None: + return MachineOperand.mem(int(mem.group(1)), mem.group(2)) + try: + return MachineOperand.immediate(int(s)) + except ValueError: + return MachineOperand.vreg(s) + + def block_from_machine_instrs( instrs: list, # list of MachineInstr ) -> list[LsInstruction]: """Convert MachineInstr list to LsInstruction list. - Parameters - ---------- - instrs: - List of MachineInstr objects from register_alloc module. - - Returns - ------- - List of LsInstruction objects ready for linear scan allocator. + Physical-register operands (including operands mis-typed as ``vreg`` + whose names are real registers, e.g. ``sp``) are not treated as + allocatable virtual registers. """ result = [] for i, mi in enumerate(instrs): defines: set[str] = set() uses: set[str] = set() operands: list[str] = [] + has_dst = mi.op not in _NO_DST_OPS - for op in (mi.dst, mi.src1, mi.src2): + for idx, op in enumerate((mi.dst, mi.src1, mi.src2)): if op is None: continue op_str = str(op).lstrip("%") - if op.kind == "vreg": - # For the destination operand position - if op is mi.dst: + if op.kind == "vreg" and op_str not in REG_NUMS: + if has_dst and idx == 0: defines.add(op_str) - operands.append(op_str) else: uses.add(op_str) - operands.append(op_str) - else: - operands.append(op_str) + operands.append(op_str) if mi.op.value == ".label": + name = mi.comment or (operands[0] if operands else "") result.append(LsInstruction( - id=i, opcode=".label", operands=[mi.comment], - comment=mi.comment, + id=i, opcode=".label", operands=[name], comment=name, )) else: result.append(LsInstruction( @@ -603,54 +863,29 @@ def machine_instrs_from_block( ) -> list: # list of MachineInstr """Convert LsInstruction list back to MachineInstr list. - This is the reverse of ``block_from_machine_instrs`` and enables the - linear-scan allocator's output to be consumed by ``AsmEmitter``. - - Parameters - ---------- - block: - List of LsInstruction objects (possibly after register renaming). - - Returns - ------- - List of MachineInstr objects. + Enables the linear-scan allocator's output to be consumed by + ``AsmEmitter``. Branch targets stay in ``comment``; memory operands + (``off(sp)``) become ``MachineOperand.mem``. """ - from scratchv.backend.machine_types import MachineInstr, MachineOp, MachineOperand - result = [] for inst in block: if inst.opcode == ".label": - result.append(MachineInstr( - MachineOp.LABEL, comment=inst.comment, - )) + name = inst.comment or (inst.operands[0] if inst.operands else "") + result.append(MachineInstr(MachineOp.LABEL, comment=name)) continue - # Resolve opcode try: mop = MachineOp(inst.opcode) - except ValueError: - mop = MachineOp.MV # fallback - - # Build operands - def _to_mop(s: str) -> MachineOperand: - if s.startswith("x") or s.startswith("a") or s.startswith("t") or \ - s.startswith("s") or s.startswith("f") or s in ("zero", "ra", "sp", "gp", "tp", "fp"): - return MachineOperand.reg(s) - try: - return MachineOperand.immediate(int(s)) - except ValueError: - return MachineOperand.vreg(s) - - dst = None - src1 = None - src2 = None + except ValueError as exc: + raise RegAllocError( + f"unsupported opcode {inst.opcode!r} in linear-scan " + "output" + ) from exc + ops = [_to_mop(o) for o in inst.operands] - if len(ops) >= 1: - dst = ops[0] - if len(ops) >= 2: - src1 = ops[1] - if len(ops) >= 3: - src2 = ops[2] + dst = ops[0] if len(ops) >= 1 else None + src1 = ops[1] if len(ops) >= 2 else None + src2 = ops[2] if len(ops) >= 3 else None result.append(MachineInstr(mop, dst, src1, src2, inst.comment)) diff --git a/scratchv/backend/regalloc_linear_v1_5.py b/scratchv/backend/regalloc_linear_v1_5.py index c5f6af0..22d5e65 100644 --- a/scratchv/backend/regalloc_linear_v1_5.py +++ b/scratchv/backend/regalloc_linear_v1_5.py @@ -1,818 +1,23 @@ -"""Linear Scan Register Allocator for RISC-V. +"""Deprecated transitional alias for :mod:`scratchv.backend.regalloc_linear`. -Implements a basic-block-level linear scan register allocation algorithm -with proper live interval computation and spill code generation. - -Usage:: - - from scratchv.backend.regalloc_linear_v1_5 import LinearScanAllocator - allocator = LinearScanAllocator() - intervals = allocator.compute_live_intervals(block_instructions) - allocator.allocate(intervals) - result = allocator.get_allocated_code() +The topic-17 linear-scan allocator converged into ``regalloc_linear``; this +module forwards every legacy import for one release cycle and will be +removed afterwards. New code must import from +``scratchv.backend.regalloc_linear`` directly. """ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Optional - -from scratchv.backend.machine_types import ( - MachineInstr, MachineOp, MachineOperand, +from scratchv.backend.regalloc_linear import * # noqa: F401,F403 +from scratchv.backend.regalloc_linear import ( # noqa: F401 + LinearScanAllocator, + LiveInterval, + LsInstruction, + RegAllocError, + RegisterAliasError, + SpillFallbackError, + block_from_machine_instrs, + machine_instrs_from_block, + _DEFAULT_PHYS_REGS, + _FP_REGS, + _INT_REGS, + _REG_NUMS, ) - - -# --------------------------------------------------------------------------- -# RISC-V register definitions -# --------------------------------------------------------------------------- - -# Allocatable integer registers (excludes x0/zero, sp, gp, tp, ra) -_INT_REGS = [ - # Argument/temp registers (caller-saved) - "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", # x10-x17 - "t0", "t1", "t2", "t3", "t4", "t5", "t6", # x5-x7, x28-x31 - # Saved registers (callee-saved) - "s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", # x8-x9, x18-x23 - "s8", "s9", "s10", "s11", # x24-x27 -] - -_FP_REGS = [ - "f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", - "f8", "f9", "f10", "f11", "f12", "f13", "f14", "f15", - "f16", "f17", "f18", "f19", - "f20", "f21", "f22", "f23", - "f24", "f25", "f26", "f27", "f28", "f29", "f30", "f31", -] - -_DEFAULT_PHYS_REGS = _INT_REGS - -# Standard register map -_REG_NUMS: dict[str, int] = { - "x0": 0, "zero": 0, - "ra": 1, "x1": 1, - "sp": 2, "x2": 2, - "gp": 3, "x3": 3, - "tp": 4, "x4": 4, - "t0": 5, "x5": 5, - "t1": 6, "x6": 6, - "t2": 7, "x7": 7, - "s0": 8, "fp": 8, "x8": 8, - "s1": 9, "x9": 9, - "a0": 10, "x10": 10, - "a1": 11, "x11": 11, - "a2": 12, "x12": 12, - "a3": 13, "x13": 13, - "a4": 14, "x14": 14, - "a5": 15, "x15": 15, - "a6": 16, "x16": 16, - "a7": 17, "x17": 17, - "s2": 18, "x18": 18, - "s3": 19, "x19": 19, - "s4": 20, "x20": 20, - "s5": 21, "x21": 21, - "s6": 22, "x22": 22, - "s7": 23, "x23": 23, - "s8": 24, "x24": 24, - "s9": 25, "x25": 25, - "s10": 26, "x26": 26, - "s11": 27, "x27": 27, - "t3": 28, "x28": 28, - "t4": 29, "x29": 29, - "t5": 30, "x30": 30, - "t6": 31, "x31": 31, -} - - -# --------------------------------------------------------------------------- -# Instruction representation -# --------------------------------------------------------------------------- - -@dataclass -class LsInstruction: - """An instruction for the linear scan allocator. - - Attributes - ---------- - id: - Unique index within the basic block. - opcode: - Instruction mnemonic (e.g. "add", "lw", "sw"). - operands: - List of operand strings (register names, immediates). - defines: - Set of virtual register names written by this instruction. - uses: - Set of virtual register names read by this instruction. - comment: - Optional comment string. - """ - id: int - opcode: str - operands: list[str] = field(default_factory=list) - defines: set[str] = field(default_factory=set) - uses: set[str] = field(default_factory=set) - comment: str = "" - - def __repr__(self) -> str: - return (f"LsInstruction({self.id}, {self.opcode}, " - f"def={self.defines}, use={self.uses})") - - def to_asm(self, rename: Optional[dict[str, str]] = None) -> str: - """Emit this instruction as assembly after register renaming.""" - ops = self.operands[:] - if rename: - ops = [rename.get(o, o) for o in ops] - parts = [f" {self.opcode}"] - if ops: - parts.append(" " + ", ".join(ops)) - if self.comment: - parts.append(f" # {self.comment}") - return "".join(parts) - - -# --------------------------------------------------------------------------- -# Live interval -# --------------------------------------------------------------------------- - -@dataclass -class LiveInterval: - """Live interval for a single virtual register in a basic block. - - Attributes - ---------- - vreg: - Virtual register name. - start: - Instruction index of the first definition. - end: - Instruction index of the last use (exclusive bound). - uses: - Set of instruction indices where this vreg is used. - """ - vreg: str - start: int - end: int - uses: set[int] = field(default_factory=set) - - def overlaps(self, other: "LiveInterval") -> bool: - """Check if two intervals overlap.""" - return self.start < other.end and other.start < self.end - - def contains(self, pos: int) -> bool: - """Check if a position is within this interval.""" - return self.start <= pos < self.end - - def __repr__(self) -> str: - return f"LiveInterval({self.vreg}, [{self.start}, {self.end}))" - - -# --------------------------------------------------------------------------- -# Linear scan allocator -# --------------------------------------------------------------------------- - -class LinearScanAllocator: - """Linear scan register allocator for RISC-V. - - Parameters - ---------- - phys_regs: - List of physical register names available for allocation. - Defaults to all integer registers (excluding special-purpose regs). - - Attributes - ---------- - stack_slot: - Current stack slot offset (negative, grows downward). - alloc_map: - Mapping from virtual register to assigned physical register. - spill_code: - List of spill load/store instructions inserted during allocation. - """ - - def __init__(self, phys_regs: Optional[list[str]] = None): - self.phys_regs: list[str] = ( - phys_regs if phys_regs is not None - else list(_DEFAULT_PHYS_REGS) - ) - self.stack_slot: int = 0 - self.alloc_map: dict[str, str] = {} - self.spill_code: dict[int, list[str]] = {} # pos -> [sw asm lines] - self._spill_slots: dict[str, int] = {} # vreg -> slot offset - self._reloads: dict[int, list[tuple[str, int]]] = ( - {} # pos -> [(vreg, slot), ...] - ) - self._spilled: set[str] = set() - self._intervals: list[LiveInterval] = [] - self._vreg_interval: dict[str, LiveInterval] = {} - self._evictions: dict[int, list[str]] = {} # pos -> sw lines emitted before reload - self.peak_active: int = 0 # max simultaneously live intervals seen (phys regs assigned) - self.peak_real_pressure: int = 0 # max simultaneously live intervals including self-spilled - self._scratch_cache: dict[str, str] = {} # vreg -> last scratch reg for reload memory - - # ------------------------------------------------------------------ - # Live interval computation - # ------------------------------------------------------------------ - - def compute_live_intervals( - self, block: list[LsInstruction], - ) -> list[LiveInterval]: - """Compute live intervals for all virtual registers in a basic block. - - Parameters - ---------- - block: - List of LsInstruction objects in instruction order. - - Returns - ------- - List of LiveInterval objects sorted by start position. - """ - # Collect all virtual register names - vregs: set[str] = set() - for inst in block: - vregs |= inst.defines - vregs |= inst.uses - - intervals: list[LiveInterval] = [] - - for vreg in vregs: - start = -1 - end = -1 - uses = set() - - for inst in block: - if vreg in inst.defines: - if start == -1: - start = inst.id - # defines/uses are both captured here; a vreg that is both - # defined and used in the same instruction is handled by the - # uses branch (end/uses updated identically), so a separate - # define-and-use block would be redundant. - if vreg in inst.uses: - uses.add(inst.id) - end = max(end, inst.id + 1) - - if start == -1: - start = 0 # live-in parameter - - if end == -1: - end = start + 1 - - intervals.append(LiveInterval( - vreg=vreg, start=start, end=end, uses=uses, - )) - - return sorted(intervals, key=lambda iv: iv.start) - - # ------------------------------------------------------------------ - # Linear scan allocation - # ------------------------------------------------------------------ - - def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: - """Perform linear scan register allocation. - - Parameters - ---------- - intervals: - Sorted list of live intervals (by start position). - - Returns - ------- - Mapping from virtual register name to physical register name. - """ - self.alloc_map.clear() - self.spill_code.clear() - self._spill_slots.clear() - self._reloads.clear() - self._spilled.clear() - self._evictions.clear() - self._intervals = intervals - self._vreg_interval = {iv.vreg: iv for iv in intervals} - self.peak_active = 0 - self.peak_real_pressure = 0 - - # Active list: (interval, phys_reg) sorted by increasing end - active: list[tuple[LiveInterval, str]] = [] - free_regs: list[str] = list(self.phys_regs) - - for interval in intervals: - # Expire old intervals - self._expire_old_intervals(active, interval.start, free_regs) - - if free_regs: - # Assign a free register - reg = free_regs.pop(0) - self.alloc_map[interval.vreg] = reg - active.append((interval, reg)) - else: - # Need to spill - spill = self.spill(interval, active, free_regs) - if spill is not None: - # Spill freed a register - reg = ( - free_regs.pop(0) if free_regs - else self.phys_regs[0] - ) - self.alloc_map[interval.vreg] = reg - active.append((interval, reg)) - - # Track peak pressure - current_active = len(active) - if current_active > self.peak_active: - self.peak_active = current_active - current_pressure = current_active + len(self._spilled) - if current_pressure > self.peak_real_pressure: - self.peak_real_pressure = current_pressure - - return dict(self.alloc_map) - - def _expire_old_intervals(self, active: list[tuple[LiveInterval, str]], - current_pos: int, - free_regs: list[str]) -> None: - """Remove intervals from active list that have ended.""" - i = 0 - while i < len(active): - interval, reg = active[i] - if interval.end <= current_pos: - free_regs.append(reg) - active.pop(i) - else: - i += 1 - - def spill(self, current: LiveInterval, - active: list[tuple[LiveInterval, str]], - free_regs: list[str]) -> Optional[str]: - """Select a register to spill and emit spill code. - - Chooses the active interval with the farthest end position to spill. - Records reload positions for the spilled interval so that - ``get_allocated_code`` can insert ``lw`` before each future use. - - Returns - ------- - The physical register freed by spilling, or None if current is spilled. - """ - if not active: - return None - - # Find the active interval with the farthest end - spill_idx = 0 - farthest_end = active[0][0].end - - for i, (interval, _) in enumerate(active): - if interval.end > farthest_end: - farthest_end = interval.end - spill_idx = i - - spill_interval, spill_reg = active[spill_idx] - - # No free register is available, so spill the active interval that - # ends farthest away and hand its register to *current*. - # - # The classic linear-scan rule prefers self-spilling *current* when it - # outlives every active interval (current.end > spill_interval.end). - # But self-spilling *current* would require writing current's freshly - # defined value into a transient register, and ALL registers are - # occupied here -- so a naive self-spill writes into phys_regs[0] and - # clobbers the live value that register already holds (data - # corruption). Evicting the farthest-ending active interval avoids - # that: *current* outlives it, so *current* keeps the register after - # the victim expires, and the victim is simply reloaded on demand. - slot = self._get_spill_slot(spill_interval.vreg) - active.pop(spill_idx) - self._evictions.setdefault(current.start, []).append( - f" sw {spill_reg}, {slot}(sp) # evict {spill_interval.vreg}" - ) - # Remove stale mapping so codegen won't use the freed register - self.alloc_map[spill_interval.vreg] = f"SPILL_{spill_interval.vreg}" - self._spilled.add(spill_interval.vreg) - # 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)) - free_regs.append(spill_reg) - return spill_reg - - def _get_spill_slot(self, vreg: str) -> int: - """Get or allocate a stack slot for a virtual register.""" - if vreg not in self._spill_slots: - self.stack_slot -= 4 - self._spill_slots[vreg] = self.stack_slot - return self._spill_slots[vreg] - - # ------------------------------------------------------------------ - # Code generation - # ------------------------------------------------------------------ - - def emit(self, block: list[LsInstruction]) -> str: - """Main entry point: allocate registers and emit assembly. - - Computes live intervals, runs linear-scan allocation, then - generates assembly with spill stores and reloads interleaved. - """ - intervals = self.compute_live_intervals(block) - self.allocate(intervals) - return self.get_allocated_code(block) - - def get_allocated_code(self, block: list[LsInstruction]) -> str: - """Generate allocated assembly with spill stores and reloads. - - Walks the instruction block in order. Before each instruction - that uses a spilled vreg, a reload ``lw`` is inserted. After - each instruction that defines a spilled vreg, a spill ``sw`` - is inserted. - """ - lines: list[str] = [] - rename: dict[str, str] = dict(self.alloc_map) - - for inst in block: - # Emit eviction spill stores before reloads at this position - if inst.id in self._evictions: - lines.extend(self._evictions[inst.id]) - - # Physical registers currently live here (i.e. mapped in - # *rename* and active at this position). Both reload targets - # and spilled-vreg scratches must avoid these. - live_regs: set[str] = set() - for vreg, preg in rename.items(): - if preg not in self.phys_regs: - continue # SPILL_ marker, holds no physical register - iv = self._vreg_interval.get(vreg) - if iv is None or iv.contains(inst.id): - live_regs.add(preg) - - # Insert reloads before the instruction - reload_reg: str | None = None - if inst.id in self._reloads: - # Vregs used/defined by this instruction must not be evicted - # by _evict_for_reload, otherwise inst.to_asm() would get - # an unresolved vreg name. - protected: set[str] = inst.uses | inst.defines - for vreg, slot in self._reloads[inst.id]: - reload_reg = self._pick_reload_reg( - rename, inst.id, protected, reuse_reg=reload_reg) - lines.append( - f" lw {reload_reg}, {slot}(sp)" - f" # reload {vreg}" - ) - rename[vreg] = reload_reg - live_regs.add(reload_reg) - # A reload register lands in the live set above; if the - # instruction later re-defines a spilled vreg, its scratch - # must not collide with a register that still feeds this - # instruction. - - # For spilled vregs defined here, pick a scratch register. - # A spilled vreg being re-defined (define+use, e.g. ``v = v op v``) - # must both read the reloaded old value and then write the new - # value back to its stack slot, otherwise the freshly computed - # value is lost and a later reload reads a stale slot. - for d in inst.defines: - if d not in self._spilled: - continue - # d is a spilled vreg that this instruction redefines. Its - # fresh value must be stored back to the slot so that a later - # reload observes the new value. (Membership in self._spilled - # is the right test — checking rename[d] for a literal - # "SPILL_" prefix is not robust, because an earlier - # redefinition already coerced rename[d] to a physical - # register, hiding later redefinitions of the same vreg.) - slot = self._spill_slots.get(d, 0) - cur = rename.get(d) - if cur is None or str(cur).startswith("SPILL_"): - # Not yet in a physical register this instruction can - # write into; pick a scratch and let to_asm route the - # definition here. - cur = self._pick_scratch(d, busy=live_regs) - rename[d] = cur - live_regs.add(cur) - # spill_code is emitted AFTER inst.to_asm(), at which point - # rename[d] holds the freshly computed value, so storing it - # back now is safe (no intervening clobber). - self.spill_code.setdefault(inst.id, []).append( - f" sw {cur}, {slot}(sp)" - f" # store redefined {d}" - ) - - lines.append(inst.to_asm(rename)) - - # Insert spill stores after the instruction - if inst.id in self.spill_code: - lines.extend(self.spill_code[inst.id]) - - return "\n".join(lines) - - def _pick_reload_reg(self, rename: dict[str, str], current_pos: int, - protected_vregs: set[str] | None = None, - reuse_reg: str | None = None) -> str: - """Pick a free physical register for a reload ``lw``. - - Filters *rename* by actual liveness at *current_pos* so that - registers held by already-expired vregs are considered free. - If all registers are genuinely occupied, evicts the one whose - interval ends farthest away. - - *protected_vregs* are excluded from eviction — typically the - current instruction's own uses/defines — since evicting them - would leave the instruction with an unresolved vreg name. - - *reuse_reg* is an optional register already selected as a reload - target earlier in the *same* instruction slot. A reload register - only lives for the duration of its own ``lw`` (the value is - consumed by the following instruction), so it is safe for several - reloads within one slot to share a single physical register. This - is the key safety net that prevents the fallback path in - ``_evict_for_reload`` from ever needing to hand out an occupied - register. - """ - used: set[str] = set() - for vreg, preg in rename.items(): - # ``SPILL_`` markers do not occupy a physical register, so they - # must not be treated as "used" and must never be evicted. - if preg not in self.phys_regs: - continue - interval = self._vreg_interval.get(vreg) - if interval is None or interval.contains(current_pos): - used.add(preg) - for reg in self.phys_regs: - if reg not in used: - return reg - # All physical registers are live at this position. First try to - # reuse a reload register already chosen earlier in this *same* - # instruction slot: that register only holds a transient ``lw`` - # result that has since been consumed, so overwriting it with the - # next reload is safe and needs no eviction. Only when no such - # reuse is available do we fall through to eviction. - if reuse_reg is not None: - return reuse_reg - return self._evict_for_reload(rename, used, current_pos, protected_vregs, reuse_reg) - - def _evict_for_reload( - self, rename: dict[str, str], used: set[str], current_pos: int, - protected_vregs: set[str] | None = None, - reuse_reg: str | None = None, - ) -> 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. - - Vregs in *protected_vregs* are excluded from eviction — they are - needed by the instruction at *current_pos* and evicting them - would produce unresolved vreg names in the output. - - *reuse_reg* mirrors the argument to ``_pick_reload_reg``: it is a - reload register already handed out earlier in this same - instruction slot and is a safe last-resort target because its - prior ``lw`` value has already been consumed. - """ - protect = protected_vregs or set() - farthest_vreg: str | None = None - farthest_end = -1 - for vreg, preg in rename.items(): - # Skip entries that no longer hold a physical register (e.g. a - # previously spilled/evicted vreg marked ``SPILL_``). - if preg not in self.phys_regs: - continue - if preg not in used: - continue - if vreg in protect: - continue - interval = self._vreg_interval.get(vreg) - if interval is not None and interval.end > farthest_end: - farthest_end = interval.end - farthest_vreg = vreg - - # No eligible victim: every live register is protected by the current - # instruction (its own uses/defines), so reloading via an evicted - # register would corrupt the instruction. If a reload register was - # already handed out for this instruction slot, reuse it: its prior - # ``lw`` value has already been consumed, so a second ``lw`` into the - # same register is safe. Otherwise this is a degenerate input where a - # single instruction simultaneously references more operands than the - # target ISA can express — silently returning an occupied register - # (as the previous ``self.phys_regs[0]`` fallback did) would clobber a - # still-live value and silently corrupt output, so fail loudly instead. - if farthest_vreg is None: - if reuse_reg is not None: - return reuse_reg - raise RuntimeError( - "regalloc: cannot reload a spilled register at position " - f"{current_pos}: all live physical registers are held by the " - "instruction's own operands and no reload register is " - "reusable. Input references more simultaneously live vregs " - "than the physical pool provides." - ) - - 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)) - - # Demote the vreg to a spilled marker instead of deleting it from the - # rename map. Keeping the ``SPILL_`` marker means later definitions - # trigger the scratch-rename path in get_allocated_code, and later - # uses trigger a reload -- the vreg never silently "disappears" from - # the map (which previously leaked unrenamed vregs into the assembly). - rename[farthest_vreg] = f"SPILL_{farthest_vreg}" - return evicted_reg - - def _pick_scratch(self, vreg: str, busy: set[str] | None = None) -> str: - """Pick a scratch register for a spilled vreg definition. - - Uses a cache so the same vreg tends to get the same scratch reg, - reducing redundant stores in tight loops. - - *busy* is the set of physical registers already in use at this - point of code generation (reload targets, live vregs, previously - chosen scratches within the same instruction). Without it the - scratch could collide with a register used as a reload target or - held by an active vreg, silently clobbering that value. If the - cache's preferred register is busy, fall back to any free one. - """ - busy = busy or set() - candidate = self._scratch_cache.get(vreg) - if candidate is not None and candidate not in busy: - return candidate - for reg in self.phys_regs: - if reg not in busy: - self._scratch_cache[vreg] = reg - return reg - # No *unbusy* register exists — every physical register in the pool is - # either held by a still-live vreg or already the target of a reload in - # this same instruction. - # - # This only happens on inputs that put more simultaneously-live values - # at one program point than the physical pool provides. In the shipped - # pressure-measurement scenarios (``topic17_bottleneck_scenarios``) - # such blocks are multi-source *pressure dumps* whose operand lists - # deliberately exceed the RISC-V 3-operand limit and are documented as - # "not executable semantics" — they exercise spill metrics, not a - # post-clobber code path. For those we fall back to re-using the - # cached scratch for this vreg (the register it was most recently tied - # to), which the immediate ``sw`` store in ``get_allocated_code`` makes - # transient. For *executable/legal* input the invariant that forces a - # free register is established by ``_evict_for_reload`` before reloads - # are emitted, so this fallback is never reached on allocatable input. - if candidate is not None: - return candidate - reg = self.phys_regs[0] - self._scratch_cache[vreg] = reg - return reg - - # ------------------------------------------------------------------ - # Report - # ------------------------------------------------------------------ - - def report(self) -> str: - """Return a string summary of the allocation result.""" - total = len(self.alloc_map) - spilled = len(self._spill_slots) - parts = [] - parts.append("Linear Scan Register Allocation Report") - parts.append(f" Virtual registers allocated: {total}") - parts.append(f" Stack spill slots used: {spilled}") - parts.append(f" Peak active (phys regs mapped): {self.peak_active}") - parts.append(f" Peak real pressure (incl. self-spilled): {self.peak_real_pressure}") - parts.append( - f" Physical registers available: {len(self.phys_regs)}" - ) - if self._spill_slots: - parts.append(" Spill details (slot offsets are negative: stack grows down, so `sp + offset` < 0):") - for vreg, slot in self._spill_slots.items(): - parts.append(f" {vreg}: sp+{slot}") - return "\n".join(parts) - - -# --------------------------------------------------------------------------- -# Helper: convert MachineInstr list to LsInstruction list -# --------------------------------------------------------------------------- - -def block_from_machine_instrs( - instrs: list, # list of MachineInstr -) -> list[LsInstruction]: - """Convert MachineInstr list to LsInstruction list. - - Parameters - ---------- - instrs: - List of MachineInstr objects from register_alloc module. - - Returns - ------- - List of LsInstruction objects ready for linear scan allocator. - """ - result = [] - for i, mi in enumerate(instrs): - defines: set[str] = set() - uses: set[str] = set() - operands: list[str] = [] - - for op in (mi.dst, mi.src1, mi.src2): - if op is None: - continue - op_str = str(op).lstrip("%") - if op.kind == "vreg": - # For the destination operand position - if op is mi.dst: - defines.add(op_str) - operands.append(op_str) - else: - uses.add(op_str) - operands.append(op_str) - else: - operands.append(op_str) - - if mi.op.value == ".label": - result.append(LsInstruction( - id=i, opcode=".label", operands=[mi.comment], - comment=mi.comment, - )) - else: - result.append(LsInstruction( - id=i, - opcode=mi.op.value, - operands=operands, - defines=defines, - uses=uses, - comment=mi.comment, - )) - - return result - - -def machine_instrs_from_block( - block: list[LsInstruction], -) -> list: # list of MachineInstr - """Convert LsInstruction list back to MachineInstr list. - - This is the reverse of ``block_from_machine_instrs`` and enables the - linear-scan allocator's output to be consumed by ``AsmEmitter``. - - Parameters - ---------- - block: - List of LsInstruction objects (possibly after register renaming). - - Returns - ------- - List of MachineInstr objects. - """ - result = [] - for inst in block: - if inst.opcode == ".label": - result.append(MachineInstr( - MachineOp.LABEL, comment=inst.comment, - )) - continue - - # Resolve opcode - try: - mop = MachineOp(inst.opcode) - except ValueError: - mop = MachineOp.MV # fallback - - # Build operands - def _to_mop(s: str) -> MachineOperand: - # Exact membership against the known register-name table, NOT - # prefix matching: a virtual register like ``%a_temp`` (stripped - # to ``a_temp``) starts with "a" but is not a physical register, - # and prefix matching would misclassify it as ``MachineOperand.reg``. - # ``_REG_NUMS`` holds every valid RISC-V physical register name - # (including ``x``-aliases, zero/ra/sp/gp/tp/fp). - if s in _REG_NUMS: - return MachineOperand.reg(s) - try: - return MachineOperand.immediate(int(s)) - except ValueError: - return MachineOperand.vreg(s) - - dst = None - src1 = None - src2 = None - ops = [_to_mop(o) for o in inst.operands] - if len(ops) >= 1: - dst = ops[0] - if len(ops) >= 2: - src1 = ops[1] - if len(ops) >= 3: - src2 = ops[2] - - result.append(MachineInstr(mop, dst, src1, src2, inst.comment)) - - return result \ No newline at end of file diff --git a/scratchv/backend/register_alloc.py b/scratchv/backend/register_alloc.py index 15d3e1b..bd45aae 100644 --- a/scratchv/backend/register_alloc.py +++ b/scratchv/backend/register_alloc.py @@ -2,7 +2,8 @@ Implements two strategies: 1. Naive: map every virtual register to a stack slot (load/store). -2. Greedy: simple local greedy allocator using callee-saved regs first. +2. Greedy: simple local greedy allocator using temp registers first. + Legacy: the spill path is not reload-correct; prefer ``--reg-alloc linear``. Machine instruction types (MachineOp, MachineOperand, MachineInstr) are defined in ``scratchv.backend.machine_types`` and re-exported here for @@ -17,9 +18,12 @@ ALL_REGS, ARG_REGS, CALLEE_SAVED, + CALLER_SAVED, + GREEDY_REGS, MachineInstr, MachineOp, MachineOperand, + REG_NUMS, STACK_BASE, TEMP_REGS, ZERO_REG, @@ -34,7 +38,10 @@ "CALLEE_SAVED", "TEMP_REGS", "ARG_REGS", + "CALLER_SAVED", "ALL_REGS", + "GREEDY_REGS", + "REG_NUMS", "STACK_BASE", "ZERO_REG", "RegisterAllocator", @@ -46,6 +53,10 @@ class RegisterAllocator: Mode 'naive': spill everything to stack, for maximum correctness. Mode 'greedy': simple local allocator using temp registers first. + + Legacy note: greedy's spill path does not reload spilled values and is + kept only for compatibility; use the linear-scan allocator for correct + spilling. """ def __init__(self, instructions: list[MachineInstr], mode: str = "greedy"): @@ -55,7 +66,7 @@ def __init__(self, instructions: list[MachineInstr], mode: str = "greedy"): self._spill_slots: dict[str, int] = {} # vreg_name -> stack offset self._next_spill = 0 # Track which physical registers are currently allocated - self._reg_pool: dict[str, Optional[str]] = {r: None for r in ALL_REGS} + self._reg_pool: dict[str, Optional[str]] = {r: None for r in GREEDY_REGS} self._output: list[MachineInstr] = [] def run(self) -> list[MachineInstr]: @@ -101,7 +112,7 @@ def _allocate_greedy(self) -> list[MachineInstr]: """Simple greedy allocator: assign physical registers to vregs.""" self._output = [] self._vreg_map.clear() - self._reg_pool = {r: None for r in ALL_REGS} + self._reg_pool = {r: None for r in GREEDY_REGS} for instr in self.instructions: if instr.op == MachineOp.LABEL: diff --git a/scratchv/backend/topic17_bottleneck_scenarios_v1_5.py b/scratchv/backend/topic17_bottleneck_scenarios_v1_5.py index 12d43a0..1781aeb 100644 --- a/scratchv/backend/topic17_bottleneck_scenarios_v1_5.py +++ b/scratchv/backend/topic17_bottleneck_scenarios_v1_5.py @@ -24,7 +24,7 @@ 多源指令改写为两两累加链(会显著降低同一位置的峰值压力),那是另一类 测试,不属于本框架的压力模型范围。 """ -from scratchv.backend.regalloc_linear_v1_5 import LinearScanAllocator, LsInstruction +from scratchv.backend.regalloc_linear import LinearScanAllocator, LsInstruction import random import inspect import re @@ -122,7 +122,10 @@ def _all_spill_lines(alloc): def run_scenario(name, category, desc, block_fn, phys_regs=None): """Run a single scenario and return all metrics.""" regs = phys_regs or PHYS_REGS - alloc = LinearScanAllocator(phys_regs=regs) + # Pressure-measurement mode: deliberately invalid multi-source blocks + # exceed the ISA operand limit, so degrade with counters instead of + # raising (see the module docstring). + alloc = LinearScanAllocator(phys_regs=regs, strict=False) try: block = _renumber(block_fn()) ivs = alloc.compute_live_intervals(block) diff --git a/scratchv/compiler.py b/scratchv/compiler.py index fa5459e..28beb8d 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -37,7 +37,8 @@ class CompilerConfig: Attributes: backend: ``"riscv"`` or ``"llvm"``. optimize_level: ``"none"``, ``"basic"``, or ``"all"``. - reg_alloc: ``"naive"`` or ``"greedy"`` (also ``"linear"``). + reg_alloc: ``"naive"``, ``"greedy"``, or ``"linear"`` + (``"linear-v1.5"`` is a transitional alias). dump_ir: Print IR dumps during compilation. verify: Run ONNX Runtime / numpy verification. rtol: Relative tolerance for verification. @@ -57,7 +58,9 @@ class CompilerConfig: backend: str = "riscv" optimize_level: str = "none" - reg_alloc: str = "linear" + # Stage 1 default: "greedy" (aligned with the CLI); "linear" is opt-in + # until the linear-scan path is fully baked, then both flip together. + reg_alloc: str = "greedy" dump_ir: bool = False verify: bool = False rtol: float = 1e-5 @@ -437,22 +440,47 @@ def _generate_riscv_linear(self, program) -> str: selector = InstructionSelector(program) machine_instrs = selector.run() - # Linear-scan: skip greedy allocator, use liveness-driven allocator - if self.config.reg_alloc == "linear": - from scratchv.backend.regalloc_linear import ( - LinearScanAllocator, block_from_machine_instrs, + mode = self.config.reg_alloc + if mode == "linear-v1.5": # transitional alias + mode = "linear" + if mode not in ("naive", "greedy", "linear"): + raise ValueError( + f"unknown reg_alloc mode: {self.config.reg_alloc!r} " + "(expected naive, greedy, or linear)" ) - ls_insts = block_from_machine_instrs(machine_instrs) - lsa = LinearScanAllocator() - return lsa.emit(ls_insts) - alloc = RegisterAllocator(machine_instrs, mode=self.config.reg_alloc) + if mode == "linear": + # Basic-block linear scan + W9 frame allocation, then the shared + # AsmEmitter so labels/.globl/.size/branch targets are preserved. + from scratchv.backend.frame_layout import FunctionFrameAllocator + allocated = FunctionFrameAllocator().allocate_program( + machine_instrs) + return AsmEmitter(allocated).emit() + + alloc = RegisterAllocator(machine_instrs, mode=mode) allocated = alloc.run() emitter = AsmEmitter(allocated) return emitter.emit() def _generate_riscv_dag(self, program) -> str: """DAG-based instruction selection pipeline.""" + # Same mode normalisation/validation as the non-DAG path: the DAG + # pipeline supports naive/greedy only, so an unsupported mode must + # fail loudly instead of silently degrading to greedy (F6). + mode = self.config.reg_alloc + if mode == "linear-v1.5": # transitional alias + mode = "linear" + if mode not in ("naive", "greedy", "linear"): + raise ValueError( + f"unknown reg_alloc mode: {self.config.reg_alloc!r} " + "(expected naive, greedy, or linear)" + ) + if mode == "linear": + raise ValueError( + "reg_alloc='linear' is not supported by the DAG instruction " + "selection path; use the non-DAG pipeline or 'greedy'" + ) + from scratchv_dag.selection_dag import DAGBuilder, DAGCombiner, DAGScheduler from scratchv.backend.register_alloc import RegisterAllocator from scratchv.backend.asm_emit import AsmEmitter @@ -466,7 +494,7 @@ def _generate_riscv_dag(self, program) -> str: scheduler = DAGScheduler(dag) machine_instrs = scheduler.run() - alloc = RegisterAllocator(machine_instrs, mode=self.config.reg_alloc) + alloc = RegisterAllocator(machine_instrs, mode=mode) allocated = alloc.run() emitter = AsmEmitter(allocated) diff --git a/scratchv/main.py b/scratchv/main.py index 52feff5..aaf4bdc 100644 --- a/scratchv/main.py +++ b/scratchv/main.py @@ -45,9 +45,12 @@ def build_arg_parser() -> argparse.ArgumentParser: # ── Register allocation ───────────────────────────────────────────── parser.add_argument( - "--reg-alloc", choices=["naive", "greedy", "linear"], + "--reg-alloc", + choices=["naive", "greedy", "linear", "linear-v1.5"], default="greedy", - help="Register allocation strategy (default: greedy)", + help="Register allocation strategy (default: greedy; " + "'linear' = basic-block linear scan, opt-in; " + "'linear-v1.5' = transitional alias for 'linear')", ) # ── Debug ─────────────────────────────────────────────────────────── @@ -134,10 +137,19 @@ def build_arg_parser() -> argparse.ArgumentParser: def args_to_config(args: argparse.Namespace) -> CompilerConfig: """Translate parsed CLI arguments to a CompilerConfig.""" + reg_alloc = args.reg_alloc + if reg_alloc == "linear-v1.5": + import warnings + warnings.warn( + "--reg-alloc linear-v1.5 is a transitional alias for 'linear'", + DeprecationWarning, + stacklevel=2, + ) + reg_alloc = "linear" return CompilerConfig( backend=args.backend, optimize_level=args.optimize, - reg_alloc=args.reg_alloc, + reg_alloc=reg_alloc, dump_ir=args.dump_ir, verify=args.verify, rtol=args.rtol, diff --git a/tests/test_pr37_regression.py b/tests/test_pr37_regression.py index 7601761..00b684a 100644 --- a/tests/test_pr37_regression.py +++ b/tests/test_pr37_regression.py @@ -1,8 +1,8 @@ """Regression tests for the register-allocation changes in PR #37. -The implementation under test is intentionally kept in the PR-specific -module. The module is not present on the pre-PR main branch, so this file is -skipped there and becomes active as soon as the PR is checked out by CI. +The implementation under test converged into +``scratchv.backend.regalloc_linear`` (topic 17); the former PR-specific +module is a forwarding alias. """ from __future__ import annotations @@ -13,8 +13,8 @@ regalloc = pytest.importorskip( - "scratchv.backend.regalloc_linear_v1_5", - reason="PR #37 register allocator is not present on this branch", + "scratchv.backend.regalloc_linear", + reason="linear-scan register allocator is not present on this branch", ) diff --git a/tests/test_regalloc_topic17.py b/tests/test_regalloc_topic17.py new file mode 100644 index 0000000..6e9d2d3 --- /dev/null +++ b/tests/test_regalloc_topic17.py @@ -0,0 +1,989 @@ +"""Topic 17 acceptance tests: linear-scan convergence, reload alias fix, W9. + +Coverage +-------- +* Reload alias P0 counterexamples: 3-instruction impossible block (fail + loudly), 6-instruction pressure block (must compute 3/6/7), and a + 7-instruction block that exercises runtime eviction. +* Spill + reload execution differential via a test-local mini interpreter. +* Assembly hygiene: no bare vregs, no ``SPILL_`` markers, no negative + ``sp`` offsets, all branch targets defined. +* W9: 16-byte aligned frames, callee-saved save/restore across execution. +* Pipeline wiring: stage-1 default stays ``greedy``; ``linear`` is opt-in + and its products go through ``AsmEmitter``. +""" + +from __future__ import annotations + +import re +import warnings + +import pytest + +from scratchv.backend import machine_types as mt +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.frame_layout import FunctionFrameAllocator +from scratchv.backend.machine_types import ( + MachineInstr, + MachineOp, + MachineOperand, +) +from scratchv.backend.regalloc_linear import ( + LinearScanAllocator, + LiveInterval, + LsInstruction, + RegAllocError, + RegisterAliasError, + SpillFallbackError, + block_from_machine_instrs, + machine_instrs_from_block, +) + +_BARE_VREG = re.compile(r"(? list[LsInstruction]: + """Two live inputs, one two-source add, a single-register pool.""" + return [ + LsInstruction(0, "li", ["v0", "1"], defines={"v0"}), + LsInstruction(1, "li", ["v1", "2"], defines={"v1"}), + LsInstruction(2, "add", ["v2", "v0", "v1"], + defines={"v2"}, uses={"v0", "v1"}), + ] + + +def _six_block() -> list[LsInstruction]: + """The T1 pressure block: v3=3, v4=6, v5=7 with a two-register pool.""" + return [ + LsInstruction(0, "li", ["v0", "1"], defines={"v0"}), + LsInstruction(1, "li", ["v1", "2"], defines={"v1"}), + LsInstruction(2, "li", ["v2", "3"], defines={"v2"}), + LsInstruction(3, "add", ["v3", "v0", "v1"], + defines={"v3"}, uses={"v0", "v1"}), + LsInstruction(4, "add", ["v4", "v2", "v3"], + defines={"v4"}, uses={"v2", "v3"}), + LsInstruction(5, "add", ["v5", "v4", "v0"], + defines={"v5"}, uses={"v4", "v0"}), + ] + + +def _seven_block() -> list[LsInstruction]: + """T1 plus v6 = v5 + v1; triggers runtime eviction of v2.""" + return _six_block() + [ + LsInstruction(6, "add", ["v6", "v5", "v1"], + defines={"v6"}, uses={"v5", "v1"}), + ] + + +def _redefine_block() -> list[LsInstruction]: + """v0 is redefined after being spilled; v4 = 9 + 3 = 12.""" + return [ + LsInstruction(0, "li", ["v0", "1"], defines={"v0"}), + LsInstruction(1, "li", ["v1", "2"], defines={"v1"}), + LsInstruction(2, "li", ["v2", "3"], defines={"v2"}), + LsInstruction(3, "add", ["v3", "v0", "v1"], + defines={"v3"}, uses={"v0", "v1"}), + LsInstruction(4, "li", ["v0", "9"], defines={"v0"}), + LsInstruction(5, "add", ["v4", "v0", "v2"], + defines={"v4"}, uses={"v0", "v2"}), + ] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _lines(allocated: list[LsInstruction]) -> list[str]: + return [inst.to_asm() for inst in allocated] + + +def _execute_allocated(orig_block, allocated): + """Mini-interpreter for allocated blocks (li/add/lw/sw). + + Returns ``(observed, regs, mem)`` where ``observed[vreg]`` is the value + produced by the vreg's last (corresponding) original definition. + """ + regs: dict[str, int] = {} + mem: dict[str, int] = {} + observed: dict[str, int] = {} + ptr = 0 + + for inst in allocated: + ops = inst.operands + if inst.opcode == "li": + regs[ops[0]] = int(ops[1]) + elif inst.opcode == "add": + regs[ops[0]] = regs[ops[1]] + regs[ops[2]] + elif inst.opcode == "lw": + regs[ops[0]] = mem.get(ops[1], 0) + elif inst.opcode == "sw": + mem[ops[1]] = regs[ops[0]] + else: # pragma: no cover - defensive + raise AssertionError(f"unexpected opcode {inst.opcode!r}") + + inserted = any( + inst.comment.startswith(marker) for marker in _INSERTED_MARKERS) + if not inserted: + orig = orig_block[ptr] + ptr += 1 + if len(orig.defines) == 1 and ops: + observed[next(iter(orig.defines))] = regs[ops[0]] + + assert ptr == len(orig_block), "allocated stream lost original instructions" + return observed, regs, mem + + +def _assert_hygiene(text: str) -> None: + body = "\n".join(line.split("#", 1)[0] for line in text.splitlines()) + assert "SPILL_" not in body + assert not _BARE_VREG.search(body), body + assert not _NEG_OFFSET.search(body), body + + +def _branch_targets(text: str) -> set[str]: + targets: set[str] = set() + for line in text.splitlines(): + parts = line.split("#", 1)[0].replace(",", " ").split() + if parts and parts[0] in _JUMP_OPS and len(parts) > 1: + targets.add(parts[-1]) + return targets + + +def _label_defs(text: str) -> set[str]: + return { + line.strip()[:-1] + for line in text.splitlines() + if line.strip().endswith(":") + } + + +# --------------------------------------------------------------------------- +# 1. Alias counterexamples +# --------------------------------------------------------------------------- + +class TestReloadAliasCounterexamples: + def test_six_instruction_pressure_block_semantics(self): + block = _six_block() + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + allocated = alloc.allocate_block(block) + observed, _, _ = _execute_allocated(block, allocated) + + assert observed["v3"] == 3 + assert observed["v4"] == 6 + assert observed["v5"] == 7 + + text = "\n".join(_lines(allocated)) + _assert_hygiene(text) + assert " sw " in text and " lw " in text + + def test_three_instruction_block_fails_loudly(self): + """A one-register pool cannot host a two-source add: raise, not alias.""" + alloc = LinearScanAllocator(phys_regs=["t0"]) + with pytest.raises(SpillFallbackError): + alloc.emit(_three_block()) + assert issubclass(SpillFallbackError, RegAllocError) + + def test_empty_pool_raises_regalloc_error(self): + alloc = LinearScanAllocator(phys_regs=[]) + with pytest.raises(RegAllocError): + alloc.emit(_three_block()) + + def test_alias_detection_raises_register_alias_error(self): + alloc = LinearScanAllocator(phys_regs=["t0"]) + alloc._vreg_interval = { + "x": LiveInterval("x", 0, 5, {4}), + "y": LiveInterval("y", 0, 5, {4}), + } + alloc.alloc_map = {"x": "t0", "y": "t0"} + inst = LsInstruction(2, "add", ["v9", "x", "y"], + defines={"v9"}, uses={"x", "y"}) + with pytest.raises(RegisterAliasError): + alloc._occupied_at(2, inst) + + def test_seven_instruction_block_semantics(self): + block = _seven_block() + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + allocated = alloc.allocate_block(block) + observed, _, _ = _execute_allocated(block, allocated) + + assert observed["v3"] == 3 + assert observed["v4"] == 6 + assert observed["v5"] == 7 + assert observed["v6"] == 9 + + text = "\n".join(_lines(allocated)) + _assert_hygiene(text) + # Every reload's slot was written earlier in the stream. + assert text.count("reload ") == 5 + assert re.search(r"store redefined v[0-9]", text) + + def test_spilled_redefinition_writeback_before_reload(self): + block = _redefine_block() + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + allocated = alloc.allocate_block(block) + observed, _, _ = _execute_allocated(block, allocated) + assert observed["v4"] == 12 + + lines = _lines(allocated) + redefine = next(i for i, line in enumerate(lines) if ", 9" in line) + writeback = next( + i for i in range(redefine + 1, len(lines)) + if "store redefined v0" in lines[i] + ) + reload_after = next( + i for i in range(writeback + 1, len(lines)) + if "reload v0" in lines[i] + ) + assert redefine < writeback < reload_after + + def test_reload_dedup_same_vreg_per_position(self): + block = _six_block() + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + alloc.allocate(alloc.compute_live_intervals(block)) + # Duplicate an existing registration: dedup must keep one lw. + alloc._reloads[5].append(alloc._reloads[5][0]) + lines = _lines(alloc._build_allocated_block(block)) + assert sum("reload v0" in line for line in lines) == 2 # pos 3 and 5 + + def test_pure_definition_does_not_occupy_register(self): + block = _six_block() + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + alloc.allocate(alloc.compute_live_intervals(block)) + owners = alloc._occupied_at(3, block[3]) + assert "v3" not in owners.values() # pure def at its start position + assert "v0" not in owners.values() # already spilled at allocation + assert "v1" in owners.values() + + def test_live_in_eviction_stores_incoming_value(self): + block = [ + LsInstruction(0, "li", ["v0", "1"], defines={"v0"}), + LsInstruction(1, "add", ["v3", "a", "b"], + defines={"v3"}, uses={"a", "b"}), + LsInstruction(2, "add", ["v4", "v0", "a"], + defines={"v4"}, uses={"v0", "a"}), + ] + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + lines = _lines(alloc.allocate_block(block)) + text = "\n".join(lines) + _assert_hygiene(text) + + # The live-in victim is captured at block entry, before its reloads. + store = next(i for i, l in enumerate(lines) if "evict a" in l) + assert " sw " in lines[store] + reload_after = next( + i for i in range(store + 1, len(lines)) if "reload a" in lines[i]) + assert store < reload_after + + def test_runtime_eviction_store_precedes_reload_and_skips_protected(self): + """Directly drive the runtime (reload-time) eviction path.""" + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + alloc._vreg_interval = { + "x": LiveInterval("x", 0, 10, {9}), + "y": LiveInterval("y", 0, 10, {9}), + "z": LiveInterval("z", 0, 10, {5}), + } + alloc.alloc_map = {"x": "t0", "y": "t1", "z": "SPILL_z"} + alloc._spilled = {"z"} + alloc._spill_slots = {"z": 0} + alloc.stack_slot = 4 + inst = LsInstruction(5, "add", ["v9", "z", "y"], + defines={"v9"}, uses={"z", "y"}) + + reg, stores = alloc._pick_reload_reg( + inst, "z", 0, dict(alloc.alloc_map), + owners={"t0": "x", "t1": "y"}, loaded={}, + ) + # `y` is a protected operand, so `x` (t0) must be the victim and its + # store must be emitted inline, before the caller's lw. + assert reg == "t0" + assert stores == [" sw t0, 4(sp) # evict x for reload"] + assert "x" in alloc._spilled + assert (("x", 4) in alloc._reloads.get(9, [])) + + +# --------------------------------------------------------------------------- +# 2. Assembly hygiene / labels / machine_types consistency +# --------------------------------------------------------------------------- + +class TestAssemblyHygiene: + def test_no_vreg_spill_or_negative_offsets(self): + for block in (_six_block(), _seven_block(), _redefine_block()): + text = LinearScanAllocator(phys_regs=["t0", "t1"]).emit(block) + _assert_hygiene(text) + assert not re.search(r"#\s*\d+\(", text) + + def test_machine_instrs_from_block_has_no_vregs(self): + block = _seven_block() + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + mi = machine_instrs_from_block(alloc.allocate_block(block)) + for instr in mi: + for op in (instr.dst, instr.src1, instr.src2): + if op is not None: + assert op.kind != "vreg", instr + + def test_mem_operand_round_trip(self): + inst = LsInstruction(0, "lw", ["t0", "8(sp)"], comment="reload v") + mi = machine_instrs_from_block([inst])[0] + assert mi.src1.kind == "mem" + assert mi.src1.value == "8(sp)" + assert "lw t0, 8(sp)" in AsmEmitter([mi]).emit() + + back = block_from_machine_instrs([mi])[0] + assert back.operands == ["t0", "8(sp)"] + + def test_labels_and_branch_targets_survive_linear_pipeline(self): + prog = [ + MachineInstr(MachineOp.LABEL, comment="main"), + MachineInstr(MachineOp.LI, MachineOperand.vreg("v0"), + MachineOperand.immediate(1)), + MachineInstr(MachineOp.BNEZ, MachineOperand.vreg("v0"), + comment=".Lend"), + MachineInstr(MachineOp.LI, MachineOperand.vreg("v1"), + MachineOperand.immediate(2)), + MachineInstr(MachineOp.J, comment=".Done"), + MachineInstr(MachineOp.LABEL, comment=".Lend"), + MachineInstr(MachineOp.LI, MachineOperand.vreg("v1"), + MachineOperand.immediate(3)), + MachineInstr(MachineOp.LABEL, comment=".Done"), + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), + MachineOperand.vreg("v1")), + MachineInstr(MachineOp.JALR, MachineOperand.reg("zero"), + MachineOperand.reg("ra"), comment="ret"), + ] + allocated = FunctionFrameAllocator().allocate_program(prog) + text = AsmEmitter(allocated).emit() + _assert_hygiene(text) + assert "main:" in text + assert ".Lend:" in text and ".Done:" in text + assert re.search(r"bnez \w+, \.Lend", text), text + assert re.search(r"j \.Done", text), text + + targets = _branch_targets(text) + assert targets == {".Lend", ".Done"} + assert targets <= _label_defs(text) + + from scratchv.backend.riscv_encoder import assemble_to_binary + assemble_to_binary(text) # must not raise + + def test_machine_types_allocatable_sets_consistent(self): + assert len(mt.ALL_REGS) == 27 + assert mt.ALL_REGS == mt.ARG_REGS + mt.TEMP_REGS + mt.CALLEE_SAVED + assert mt.CALLER_SAVED == mt.ARG_REGS + mt.TEMP_REGS + assert mt.GREEDY_REGS == mt.TEMP_REGS + mt.CALLEE_SAVED + assert len(mt.GREEDY_REGS) == 19 + assert set(mt.ALL_REGS).isdisjoint( + {"zero", "ra", "sp", "gp", "tp", "fp"}) + assert mt.REG_NUMS["s0"] == 8 and mt.REG_NUMS["sp"] == 2 + + from scratchv.backend import regalloc_linear as rl + assert rl._DEFAULT_PHYS_REGS == mt.ALL_REGS + assert rl._INT_REGS == mt.ALL_REGS + + +# --------------------------------------------------------------------------- +# 3. W9 frame allocation +# --------------------------------------------------------------------------- + +def _callee_saved_factory(*, stack_base, pre_spilled, slot_hints): + return LinearScanAllocator( + phys_regs=["t0", "t1", "s0"], + stack_base=stack_base, + pre_spilled=pre_spilled, + slot_hints=slot_hints, + strict=True, + ) + + +def _foo_program() -> list[MachineInstr]: + def li(dst, imm): + return MachineInstr(MachineOp.LI, dst, MachineOperand.immediate(imm)) + + def add(dst, a, b): + return MachineInstr(MachineOp.ADD, dst, a, b) + + return [ + MachineInstr(MachineOp.LABEL, comment="foo"), + li(MachineOperand.vreg("v0"), 5), + li(MachineOperand.vreg("v1"), 7), + li(MachineOperand.vreg("v2"), 9), + add(MachineOperand.vreg("v3"), + MachineOperand.vreg("v0"), MachineOperand.vreg("v1")), + add(MachineOperand.vreg("v4"), + MachineOperand.vreg("v3"), MachineOperand.vreg("v2")), + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), + MachineOperand.vreg("v4")), + MachineInstr(MachineOp.JALR, MachineOperand.reg("zero"), + MachineOperand.reg("ra"), comment="ret"), + ] + + +class TestFunctionFrameAllocator: + def test_frame_alignment_and_callee_saved_code(self): + fa = FunctionFrameAllocator( + alloc_factory=_callee_saved_factory) + allocated = fa.allocate_program(_foo_program()) + info = fa.last_frame_info["foo"] + + assert info.frame_size % 16 == 0 + assert info.saved_offsets == {"s0": info.frame_size - 4} + assert info.ra_offset is None # foo contains no call + + text = AsmEmitter(allocated).emit() + assert re.search(r"addi sp, sp, -%d" % info.frame_size, text) + assert re.search(r"sw s0, %d\(sp\)" % info.saved_offsets["s0"], text) + assert re.search(r"lw s0, %d\(sp\)" % info.saved_offsets["s0"], text) + # epilogue loads s0 before the ret + assert text.index("lw s0,") < text.index("jalr zero, ra") + _assert_hygiene(text) + + def test_callee_saved_preserved_across_execution(self): + from scratchv.backend.riscv_encoder import assemble_to_binary + from scratchv.simulator.rv32_emulator import RV32Emulator + + fa = FunctionFrameAllocator( + alloc_factory=_callee_saved_factory) + text = AsmEmitter(fa.allocate_program(_foo_program())).emit() + + emu = RV32Emulator() + emu.load_code(bytes(assemble_to_binary(text))) + emu.regs[8] = 0x5A5A # s0 sentinel + emu.run(max_instr=1000) + + assert emu.regs[10] == 21 # a0 = (5 + 7) + 9 + assert emu.regs[8] == 0x5A5A # s0 restored by the epilogue + assert emu.regs[2] == RV32Emulator.STACK_TOP # sp balanced + + +# --------------------------------------------------------------------------- +# 3b. Regression tests: F1 frame accounting, F2 stale owners, F3 dynamic s-reg +# --------------------------------------------------------------------------- + +def _machine_li(dst: str, imm: int) -> MachineInstr: + return MachineInstr(MachineOp.LI, MachineOperand.vreg(dst), + MachineOperand.immediate(imm)) + + +def _machine_mv(reg: str, vreg: str) -> MachineInstr: + return MachineInstr(MachineOp.MV, MachineOperand.reg(reg), + MachineOperand.vreg(vreg)) + + +def _machine_add(dst: str, a: str, b: str) -> MachineInstr: + return MachineInstr(MachineOp.ADD, MachineOperand.vreg(dst), + MachineOperand.vreg(a), MachineOperand.vreg(b)) + + +def _machine_label(name: str) -> MachineInstr: + return MachineInstr(MachineOp.LABEL, comment=name) + + +def _machine_j(target: str) -> MachineInstr: + return MachineInstr(MachineOp.J, comment=target) + + +def _machine_call(target: str) -> MachineInstr: + return MachineInstr(MachineOp.CALL, comment=target) + + +def _machine_ret() -> MachineInstr: + return MachineInstr(MachineOp.JALR, MachineOperand.reg("zero"), + MachineOperand.reg("ra"), comment="ret") + + +def _two_reg_factory(*, stack_base, pre_spilled, slot_hints): + return LinearScanAllocator( + phys_regs=["t0", "t1"], stack_base=stack_base, + pre_spilled=pre_spilled, slot_hints=slot_hints, strict=True, + ) + + +def _three_reg_factory(*, stack_base, pre_spilled, slot_hints): + return LinearScanAllocator( + phys_regs=["t0", "t1", "t2"], stack_base=stack_base, + pre_spilled=pre_spilled, slot_hints=slot_hints, strict=True, + ) + + +def _function_section(text: str, name: str) -> str: + """Lines of one function (its label plus body) up to the next function.""" + out: list[str] = [] + keep = False + for line in text.splitlines(): + stripped = line.strip() + if keep and stripped.endswith(":") and not stripped.startswith("."): + break + if stripped == f"{name}:": + keep = True + if keep: + out.append(line) + return "\n".join(out) + + +def _function_body(text: str, name: str) -> str: + """Function section without assembler directives (for the emulator).""" + lines = [ + line for line in _function_section(text, name).splitlines() + if not line.strip().startswith( + (".size", ".globl", ".type", ".text", ".align", ".section")) + ] + return "\n".join(lines) + "\n" + + +def _spill_accesses(instrs) -> list[tuple[str, int]]: + """All ``(comment, offset)`` of allocator-inserted ``(sp)`` accesses.""" + out: list[tuple[str, int]] = [] + for instr in instrs: + if not instr.comment.startswith(_INSERTED_MARKERS): + continue + for op in (instr.dst, instr.src1, instr.src2): + if op is not None and op.kind == "mem": + match = re.match(r"(-?\d+)\((\w+)\)$", str(op.value)) + if match and match.group(2) == "sp": + out.append((instr.comment, int(match.group(1)))) + return out + + +def _reload_pressure_program() -> list[MachineInstr]: + """F1 repro: pass-1 records 12 bytes, pass-2 reload eviction needs 16. + + The function contains a ``call`` so its frame also has to save ``ra``; + with a pass-1-sized frame the fourth spill slot collides with the saved + ``ra`` slot. + """ + prog = [_machine_label("foo")] + for i in range(5): + prog.append(_machine_li(f"v{i}", i + 1)) + for vreg in ("v1", "v0", "v1", "v2", "v3", "v4"): + prog.append(_machine_mv("a0", vreg)) + prog += [ + _machine_call("bar"), + _machine_ret(), + _machine_label("bar"), + _machine_ret(), + ] + return prog + + +def _double_reload_program() -> list[MachineInstr]: + """F2 repro: one instruction with two spilled sources and a full pool. + + ``d``, ``wA`` and ``wB`` fill the whole 3-register pool; both operands + of ``add d, v0, v1`` must be reloaded, each evicting a distinct victim. + """ + prog = [ + _machine_label("foo"), + _machine_li("v0", 11), + _machine_li("v1", 22), + _machine_j(".L1"), + _machine_label(".L1"), + _machine_li("d", 1), + _machine_li("w2", 5), + _machine_li("w3", 6), + _machine_mv("a1", "w2"), + _machine_mv("a1", "w3"), + _machine_li("wA", 7), + _machine_li("wB", 8), + _machine_add("d", "v0", "v1"), + _machine_mv("a1", "wA"), + _machine_mv("a1", "wB"), + _machine_mv("a1", "d"), + _machine_ret(), + ] + return prog + + +def _dynamic_sreg_program() -> list[MachineInstr]: + """F3 repro: 15 locals fill a0-t6, so the reload of the cross-block + ``v0`` lands on ``s0`` even though pass 1 never allocated it.""" + prog = [ + _machine_label("foo"), + _machine_li("v0", 1), + _machine_j(".L1"), + _machine_label(".L1"), + ] + for i in range(15): + prog.append(_machine_li(f"w{i}", i + 1)) + prog.append(_machine_mv("a0", "v0")) + for i in range(15): + prog.append(_machine_mv("a0", f"w{i}")) + prog.append(_machine_ret()) + return prog + + +def _call_multi_return_program() -> list[MachineInstr]: + """A caller with one ``call`` and two return points plus a callee.""" + return [ + _machine_label("main"), + _machine_li("v0", 1), + MachineInstr(MachineOp.BNEZ, MachineOperand.vreg("v0"), + comment=".Lret1"), + _machine_call("foo"), + _machine_ret(), + _machine_label(".Lret1"), + _machine_ret(), + _machine_label("foo"), + _machine_ret(), + ] + + +class TestFrameLayoutRegression: + def test_reload_eviction_slots_are_reserved_before_saved_ra(self): + """F1: the frame must cover pass-2 reload-eviction slots; the old + pass-1 accounting overlapped the fourth slot with the saved ra.""" + fa = FunctionFrameAllocator(alloc_factory=_two_reg_factory) + allocated = fa.allocate_program(_reload_pressure_program()) + info = fa.last_frame_info["foo"] + + assert info.frame_size == 32 + assert info.ra_offset == 28 + accesses = _spill_accesses(allocated) + assert accesses, "expected reload/eviction spill code" + assert max(off for _, off in accesses) == 12 # v4 eviction slot + assert all(off < info.ra_offset for _, off in accesses) + + text = AsmEmitter(allocated).emit() + assert "sw ra, 28(sp)" in text + assert f"lw ra, {info.ra_offset}(sp)" in text + + def test_spill_bounds_check_rejects_access_outside_the_spill_area(self): + """F1 backstop: emitted spill offsets must stay in [0, spill_bytes).""" + inside = MachineInstr(MachineOp.SW, MachineOperand.reg("t0"), + MachineOperand.mem(12), comment="reload v9") + FunctionFrameAllocator._check_spill_bounds([[inside]], 16) + + outside = MachineInstr(MachineOp.SW, MachineOperand.reg("t0"), + MachineOperand.mem(64), comment="reload v9") + with pytest.raises(RegAllocError): + FunctionFrameAllocator._check_spill_bounds([[outside]], 16) + + +class TestRuntimeEvictionRegression: + def test_double_spilled_operands_evict_distinct_victims(self): + """F2: a stale owners snapshot used to evict wB twice, clobbering + its slot and binding both reloads to one register.""" + fa = FunctionFrameAllocator(alloc_factory=_three_reg_factory) + allocated = fa.allocate_program(_double_reload_program()) + text = AsmEmitter(allocated).emit() + + assert text.count("evict wB") == 1 + assert "evict wA" in text + + add_line = next( + line for line in text.splitlines() + if re.match(r"\s*add\s", line)) + match = re.match(r"\s*add\s+(\w+),\s*(\w+),\s*(\w+)", add_line) + assert match is not None, add_line + assert match.group(2) != match.group(3) # I2: distinct sources + _assert_hygiene(text) + + def test_double_spilled_operands_execute_correctly(self): + """F2 end-to-end: ``d = v0 + v1`` must be 33, not 44.""" + from scratchv.backend.riscv_encoder import assemble_to_binary + from scratchv.simulator.rv32_emulator import RV32Emulator + + fa = FunctionFrameAllocator(alloc_factory=_three_reg_factory) + text = AsmEmitter(fa.allocate_program(_double_reload_program())).emit() + body = _function_body(text, "foo") + + emu = RV32Emulator() + emu.load_code(bytes(assemble_to_binary(body))) + emu.run(max_instr=1000) + + assert emu.regs[11] == 33 # a1 <- d = v0 + v1 = 11 + 22 + assert emu.regs[2] == RV32Emulator.STACK_TOP # sp balanced + + def test_select_victim_skips_spilled_and_uses_owners_register(self): + """F2 unit: already-spilled vregs are not evictable and the victim + register comes from the live ownership map, not ``alloc_map``.""" + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + alloc._vreg_interval = { + "x": LiveInterval("x", 0, 10, {9}), + "y": LiveInterval("y", 0, 10, {9}), + } + alloc.alloc_map = { + "x": "SPILL_x", # stale mapping: x still lives in t0 + "y": "t1", + } + alloc._spilled = {"y"} + inst = LsInstruction(5, "add", ["v9", "z", "w"], + defines={"v9"}, uses={"z", "w"}) + + picked = alloc._select_victim( + inst, owners={"t0": "x", "t1": "y"}, used={"t0", "t1"}) + assert picked == ("x", "t0") + + def test_reload_never_targets_an_instruction_operand_register(self): + """I4: a reload must not clobber a physical operand of the same + instruction (e.g. the ``a0`` of ``add v2, a0, v1``).""" + alloc = LinearScanAllocator(phys_regs=["a0", "t0"]) + alloc._vreg_interval = {"x": LiveInterval("x", 0, 10, {9})} + alloc.alloc_map = {"x": "t0"} + alloc._spill_slots = {"v1": 0} + alloc.stack_slot = 4 + inst = LsInstruction(3, "add", ["v2", "a0", "v1"], + defines={"v2"}, uses={"v1"}) + + reg, stores = alloc._pick_reload_reg( + inst, "v1", 0, {}, owners={"t0": "x"}, loaded={}) + assert reg == "t0" + assert stores == [" sw t0, 4(sp) # evict x for reload"] + assert "x" in alloc._spilled + + def test_operand_alias_check_raises_i2_for_distinct_sources(self): + """F7: output-time I2 check catches two distinct sources aliasing + while still allowing a definition to share a source register (I3).""" + alloc = LinearScanAllocator(phys_regs=["t0", "t1"]) + inst = LsInstruction(3, "add", ["v3", "v0", "v1"], + defines={"v3"}, uses={"v0", "v1"}) + + with pytest.raises(RegisterAliasError): + alloc._check_operand_aliases(inst, ["t0", "t1", "t1"]) + alloc._check_operand_aliases(inst, ["t0", "t0", "t1"]) + + +class TestDynamicCalleeSavedRegression: + def test_dynamically_selected_s_register_is_saved(self): + """F3: a reload target picked during emission must appear in the + generated save set (previously only the pass-1 alloc_map was used).""" + fa = FunctionFrameAllocator() # default 27-register pool + allocated = fa.allocate_program(_dynamic_sreg_program()) + info = fa.last_frame_info["foo"] + + assert "s0" in info.saved_offsets + offset = info.saved_offsets["s0"] + text = AsmEmitter(allocated).emit() + assert f"sw s0, {offset}(sp)" in text + assert f"lw s0, {offset}(sp)" in text + + mentioned = { + str(op.value) + for instr in allocated + for op in (instr.dst, instr.src1, instr.src2) + if op is not None and op.kind == "reg" + and str(op.value) in mt.CALLEE_SAVED + } + assert mentioned <= set(info.saved_offsets) + _assert_hygiene(text) + + def test_dynamically_selected_s_register_is_restored(self): + """F3 end-to-end: s0 sentinel survives the frame's execution.""" + from scratchv.backend.riscv_encoder import assemble_to_binary + from scratchv.simulator.rv32_emulator import RV32Emulator + + fa = FunctionFrameAllocator() + text = AsmEmitter(fa.allocate_program(_dynamic_sreg_program())).emit() + body = _function_body(text, "foo") + + emu = RV32Emulator() + emu.load_code(bytes(assemble_to_binary(body))) + emu.regs[8] = 0x5A5A # s0 sentinel + emu.run(max_instr=2000) + + assert emu.regs[8] == 0x5A5A # restored by the epilogue + assert emu.regs[10] == 15 # last mv a0, w14 + assert emu.regs[2] == RV32Emulator.STACK_TOP + + +class TestAllocatorEdgeCases: + def test_empty_block_allocates_to_empty(self): + alloc = LinearScanAllocator(phys_regs=["t0"]) + assert alloc.allocate_block([]) == [] + assert alloc.emit([]) == "" + + def test_single_instruction_block(self): + block = [LsInstruction(0, "li", ["v0", "1"], defines={"v0"})] + alloc = LinearScanAllocator(phys_regs=["t0"]) + assert _lines(alloc.allocate_block(block)) == [" li t0, 1"] + + def test_non_strict_mode_counts_fallbacks_instead_of_raising(self): + """Measurement mode degrades with counters (no I2 raise) instead of + aborting, as required by the pressure benchmarks.""" + alloc = LinearScanAllocator(phys_regs=["t0"], strict=False) + text = alloc.emit(_three_block()) + assert alloc.fallback_count > 0 + assert "SPILL_" not in text + + +class TestW9Acceptance: + def test_ra_saved_once_and_restored_before_every_ret(self): + """F4: ra is saved by the prologue of a calling function and + restored on each of its return paths.""" + fa = FunctionFrameAllocator() + allocated = fa.allocate_program(_call_multi_return_program()) + assert set(fa.last_frame_info) == {"main", "foo"} + assert all( + info.frame_size % 16 == 0 + for info in fa.last_frame_info.values()) + + main_info = fa.last_frame_info["main"] + assert main_info.ra_offset is not None + assert fa.last_frame_info["foo"].ra_offset is None + text = AsmEmitter(allocated).emit() + assert text.count("sw ra,") == 1 + assert text.count("lw ra,") == 2 # two ret points in main + + for _, offset in _spill_accesses(allocated): + assert offset < main_info.ra_offset + + def test_function_sections_use_their_own_frame_size(self): + """F4: multi-function programs get independent frames and each + epilogue balances its own prologue adjustment.""" + fa = FunctionFrameAllocator() + allocated = fa.allocate_program(_call_multi_return_program()) + text = AsmEmitter(allocated).emit() + + for name, info in fa.last_frame_info.items(): + section = _function_section(text, name) + if info.frame_size == 0: + assert "addi sp, sp" not in section + continue + assert f"addi sp, sp, -{info.frame_size}" in section + restores = section.count(f"addi sp, sp, {info.frame_size}") + assert restores == section.count("jalr zero, ra") + + def test_call_and_multi_return_path_executes_with_balanced_sp(self): + """F4: the fall-through/branch path through main's ret restores + ra and sp; the callee body does the same in isolation.""" + from scratchv.backend.riscv_encoder import assemble_to_binary + from scratchv.simulator.rv32_emulator import RV32Emulator + + fa = FunctionFrameAllocator() + allocated = fa.allocate_program(_call_multi_return_program()) + text = AsmEmitter(allocated).emit() + + for name in ("main", "foo"): + emu = RV32Emulator() + body = _function_body(text, name) + emu.load_code(bytes(assemble_to_binary(body))) + emu.run(max_instr=500) + assert emu.regs[2] == RV32Emulator.STACK_TOP + + def test_linear_scan_deterministic(self): + """F4: two allocations of the same input are byte-identical.""" + prog = _double_reload_program() + + def allocate_once(): + fa = FunctionFrameAllocator(alloc_factory=_three_reg_factory) + allocated = fa.allocate_program(prog) + snapshot = [ + (instr.op.value, instr.dst, instr.src1, instr.src2, + instr.comment) + for instr in allocated + ] + return snapshot, AsmEmitter(allocated).emit() + + first, first_text = allocate_once() + second, second_text = allocate_once() + assert first == second + assert first_text == second_text + + block = _seven_block() + a1 = LinearScanAllocator(phys_regs=["t0", "t1"]) + a1.allocate(a1.compute_live_intervals(block)) + a2 = LinearScanAllocator(phys_regs=["t0", "t1"]) + a2.allocate(a2.compute_live_intervals(block)) + assert a1.alloc_map == a2.alloc_map + + +# --------------------------------------------------------------------------- +# 4. Pipeline wiring / defaults +# --------------------------------------------------------------------------- + +class TestPipelineWiring: + def test_stage_one_default_is_greedy(self): + from scratchv.compiler import CompilerConfig + from scratchv.main import args_to_config, build_arg_parser + + assert CompilerConfig().reg_alloc == "greedy" + args = build_arg_parser().parse_args(["input.dsl"]) + assert args_to_config(args).reg_alloc == "greedy" + + def test_linear_v1_5_alias_normalizes_to_linear(self): + from scratchv.main import args_to_config, build_arg_parser + + args = build_arg_parser().parse_args( + ["input.dsl", "--reg-alloc", "linear-v1.5"]) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + config = args_to_config(args) + assert config.reg_alloc == "linear" + assert any( + issubclass(w.category, DeprecationWarning) for w in caught) + + def test_unknown_mode_raises(self): + from scratchv.compiler import CompilerConfig, CompilerDriver + + class _EmptyProgram: + functions: list = [] + + driver = CompilerDriver(CompilerConfig(reg_alloc="bogus")) + with pytest.raises(ValueError): + driver._generate_riscv_linear(_EmptyProgram()) + + def test_dag_path_rejects_linear_mode(self): + """F6: the DAG pipeline must not silently degrade linear to greedy.""" + from scratchv.compiler import CompilerConfig, CompilerDriver + + class _EmptyProgram: + functions: list = [] + + for mode in ("linear", "linear-v1.5"): + driver = CompilerDriver( + CompilerConfig(reg_alloc=mode, use_dag_isel=True)) + with pytest.raises(ValueError, match="DAG"): + driver._generate_riscv_dag(_EmptyProgram()) + + def test_dag_path_rejects_unknown_mode(self): + """F6: unknown modes raise on the DAG path like on the linear one.""" + from scratchv.compiler import CompilerConfig, CompilerDriver + + class _EmptyProgram: + functions: list = [] + + driver = CompilerDriver( + CompilerConfig(reg_alloc="bogus", use_dag_isel=True)) + with pytest.raises(ValueError, match="unknown reg_alloc mode"): + driver._generate_riscv_dag(_EmptyProgram()) + + def test_linear_opt_in_end_to_end_forced_spill(self, tmp_path): + from scratchv.backend.riscv_encoder import assemble_to_binary + from scratchv.compiler import CompilerConfig, CompilerDriver + from scratchv.simulator.rv32_emulator import RV32Emulator + + src = "for j = 0, 5\n k = add(j, j)\nendfor\nreturn k\n" + + def compile_and_run(mode: str): + result = CompilerDriver( + CompilerConfig(reg_alloc=mode)).compile( + "topic17.dsl", + output_path=str(tmp_path / f"{mode}.s"), + dsl_source=src, + ) + assert result.success, result.errors + emu = RV32Emulator() + emu.load_code(bytes(assemble_to_binary(result.output_text))) + emu.run(max_instr=10000) + return result.output_text, emu.regs[10] + + linear_text, linear_result = compile_and_run("linear") + greedy_text, greedy_result = compile_and_run("greedy") + + assert linear_result == greedy_result == 8 + _assert_hygiene(linear_text) + + # The linear path forces the cross-block loop variables to memory. + body = "\n".join( + line.split("#", 1)[0] for line in linear_text.splitlines()) + assert re.search(r"lw \w+, \d+\(sp\)", body) + assert re.search(r"sw \w+, \d+\(sp\)", body) + + # Every branch/jump target is defined in the emitted text. + assert _branch_targets(linear_text) <= _label_defs(linear_text) + assert _branch_targets(greedy_text) <= _label_defs(greedy_text) diff --git a/tests/test_topic17_regalloc_case_report.py b/tests/test_topic17_regalloc_case_report.py new file mode 100644 index 0000000..6eb3d38 --- /dev/null +++ b/tests/test_topic17_regalloc_case_report.py @@ -0,0 +1,142 @@ +"""Tests for the Topic 17 register-allocation feature case report. + +The report is the CI artifact that proves both allocator modes compile the +same deterministic DSL case, the linear path emits a real frame, and the RV32 +emulator executes both products to identical architectural state. The case +and its checks deliberately avoid the high-pressure shapes (register-pool +exhaustion, reload-time eviction) so the tests stay stable on this branch. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from benchmarks.run_topic17_regalloc_case import ( + EXPECTED_RESULT, + SCHEMA_VERSION, + evaluate, + main, + measure_allocator, +) +from scratchv.simulator.rv32_emulator import RV32Emulator + +CASE = ( + Path(__file__).resolve().parents[1] + / "benchmarks" / "cases" / "topic17_regalloc_feature.dsl" +) + + +def test_case_compiles_and_runs_for_both_allocators(): + for mode in ("greedy", "linear"): + measured = measure_allocator(CASE, mode, repeats=2) + assert measured["compile_success"], measured["errors"] + assert measured["execution"]["a0"] == EXPECTED_RESULT + assert measured["execution"]["sp"] == RV32Emulator.STACK_TOP + assert measured["execution"]["dynamic_instructions"] > 0 + assert measured["asm_instructions"] > 0 + + +def test_linear_hygiene_and_frame_evidence(): + linear = measure_allocator(CASE, "linear", repeats=2) + assert linear["hygiene"]["clean"], linear["hygiene"]["issues"] + assert linear["hygiene"]["assembles"] + + assert linear["spill_accesses"] > 0 + assert linear["frame_size"] > 0 + assert linear["frame"]["prologue_offsets"], "no prologue frame adjustment" + assert ( + sum(linear["frame"]["prologue_offsets"]) + + sum(linear["frame"]["epilogue_offsets"]) + == 0 + ) + assert all( + 0 <= offset and offset + 4 <= linear["frame_size"] + for offset in linear["spill_offsets"] + ) + # The low-pressure case must not depend on reload-time eviction (F2). + assert linear["eviction_count"] == 0 + + +def test_execution_equivalent_between_allocators(): + greedy = measure_allocator(CASE, "greedy", repeats=2) + linear = measure_allocator(CASE, "linear", repeats=2) + for reg in ("x2", "x10"): + assert ( + greedy["execution"]["registers"][reg] + == linear["execution"]["registers"][reg] + ) + assert greedy["execution"]["a0"] == EXPECTED_RESULT + assert linear["execution"]["a0"] == EXPECTED_RESULT + + +def test_linear_allocation_is_deterministic(): + first = measure_allocator(CASE, "linear", repeats=3) + second = measure_allocator(CASE, "linear", repeats=3) + assert first["deterministic"] + assert first["distinct_asm"] == 1 + assert second["deterministic"] + assert first["asm_sha256"] == second["asm_sha256"] + assert ( + first["execution"]["dynamic_instructions"] + == second["execution"]["dynamic_instructions"] + ) + + +def test_evaluate_passes_all_hard_checks(): + report = evaluate(CASE, repeats=2) + assert report["schema_version"] == SCHEMA_VERSION + assert report["topic"] == "topic17-regalloc" + assert report["runs"] >= 2 + assert report["hard_failures"] == [] + assert report["hard_checks"] and all(report["hard_checks"].values()) + assert report["honesty"] + + +def test_hard_check_gate_is_not_vacuous(monkeypatch): + """An injected linear failure must surface in ``hard_failures``.""" + from benchmarks import run_topic17_regalloc_case as module + + real_measure = module.measure_allocator + + def fake_measure(case_path, mode, repeats): + if mode == "linear": + return module._failed_measurement( + mode, ["injected allocator failure"]) + return real_measure(case_path, mode, repeats) + + monkeypatch.setattr(module, "measure_allocator", fake_measure) + report = module.evaluate(CASE, repeats=2) + assert "linear_compile_succeeds" in report["hard_failures"] + assert "execution_matches_expected" in report["hard_failures"] + assert "linear_allocation_deterministic" in report["hard_failures"] + + +def test_main_writes_json_and_markdown(tmp_path, capsys): + json_path = tmp_path / "report.json" + md_path = tmp_path / "report.md" + exit_code = main([ + "--case", str(CASE), + "--json", str(json_path), + "--markdown", str(md_path), + "--repeats", "2", + ]) + assert exit_code == 0 + data = json.loads(json_path.read_text()) + assert data["hard_failures"] == [] + assert data["topic"] == "topic17-regalloc" + assert data["observed_registers"] == ["x2", "x10"] + markdown = md_path.read_text() + assert "Topic 17 Register-Allocation Feature Case" in markdown + assert "A/B summary" in markdown + assert "Hard checks" in markdown + assert "Honesty" in markdown + assert capsys.readouterr().out + + +def test_main_rejects_missing_case(tmp_path): + with pytest.raises(SystemExit) as exc: + main(["--case", str(tmp_path / "missing.dsl")]) + assert exc.value.code == 2