diff --git a/.gitignore b/.gitignore index 0af9cbc..014af5d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ venv/ .claude/ .omo/ scratchv.egg-info/ -benchmark_reports/ +benchmark_reports/* +!benchmark_reports/topic17_fix_review.md # Build artifacts at root (generated, never commit) /output.bin diff --git a/benchmark_reports/topic17_fix_review.md b/benchmark_reports/topic17_fix_review.md new file mode 100644 index 0000000..8f5e0f3 --- /dev/null +++ b/benchmark_reports/topic17_fix_review.md @@ -0,0 +1,223 @@ +# Topic17 修改、测试与自查报告 + +> 主题:寄存器分配支持伪指令,并正确统计寄存器溢出 +> +> 分支:`topic17-pseudo-regalloc` +> +> 记录基线:`5de34a1` 及其之前已经进入项目的 Topic17 提交 +> +> 最近验证日期:2026-09-14 +> +> 状态:本地修改完成,尚未 commit/push + +## 1. 范围说明 + +本报告记录 Topic17 从实验性线性扫描分配器、benchmark、伪指令语义支持,到本轮正确性加固的相关修改。 + +- 本轮没有修改 `scratchv/backend/regalloc_cfg.py` 或其他 CFG 文件。 +- CFG 的正式集成及图算法由对应维护者负责;本工作只消费现有接口。 +- `tests/test_inst_counter.py` 的本地改动属于工作区原有修改,不计入本报告。 +- 当前可执行验证目标是 RV32IM;F/D 扩展伪指令保持显式拒绝,不宣称已经支持。 + +## 2. Topic17 历史阶段 + +| 阶段/提交 | 主要内容 | +|---|---| +| `0394e0a` | 增加 v1.3/v1.5 线性扫描实验实现、瓶颈场景及回归测试。 | +| `5975348` | 增加 simple、dense、CNN 三类寄存器分配 benchmark 及报告生成。 | +| `1a55f83` | 建立 MachineOp 语义表,支持伪指令 def/use,统一 spill/reload 指标并增加 P1 测试。 | +| `bae263c` | 将 `MAX` 的发射逻辑集中到 `_emit_max`,统一普通与扩展指令选择路径。 | +| `2766cf6` | 增加多种压力模型和寄存器溢出对比 benchmark。 | +| `5de34a1` | 增加伪指令分配、编码、TinyFive 执行的端到端测试和 benchmark。 | +| 当前本地修改 | 修复审查发现的正确性问题,强化 UT、benchmark、ABI 和编码范围校验。 | + +## 3. 已实现功能 + +### 3.1 统一机器指令语义 + +`scratchv/backend/machine_semantics.py` 为每个 `MachineOp` 明确定义: + +- 显式 `defs` / `uses`; +- 可使用立即数的位置; +- label、跳转目标、terminator 和 call 属性; +- call 的隐式定义与 caller-saved clobber 集合; +- 是否为伪指令及其物理寄存器需求。 + +线性扫描分配器、greedy/naive 分配器和 Machine IR 转换共用该语义,避免各自推断操作数字段。 + +### 3.2 RV32IM 伪指令支持 + +| 层级 | 指令 | 验证方式 | 状态 | +|---|---|---|---| +| Machine IR | `mv` | 分配 → 编码 → TinyFive 执行 | PASS | +| Machine IR | `li` | 小/大/边界立即数,分配 → 编码 → 执行 | PASS | +| Machine IR | `max` | 正负值、零立即数、临时寄存器场景 | PASS | +| Machine IR | `bnez` | taken/fallthrough 路径执行 | PASS | +| Machine IR | `j` | 跳转目标及不可达指令验证 | PASS | +| Machine IR | `call` | `jal ra` 展开、返回及 clobber 处理 | PASS | +| Machine IR | `.label` | 零字节结构标记和后续执行 | PASS | +| Assembler | `nop` | 与 `addi x0, x0, 0` 对比执行 | PASS | +| Assembler | `ret` | 与 `jalr x0, ra, 0` 对比执行 | PASS | +| 外部扩展 | `fabs.d`、`fneg.d`、`li.d`、`fmv.s` | RV32IM 编码器明确报错 | 按范围拒绝 | + +### 3.3 MAX 发射统一 + +- 普通指令选择与扩展指令选择共用 `_emit_max`。 +- 两个立即数直接折叠为 `li`。 +- 左侧立即数利用交换律移动到右侧。 +- 非零右侧立即数先物化到虚拟寄存器,再发射 `max`。 +- 新增全 Program 名称预留,避免内部 `__scratchv_max_rhs_N` 与用户值重名。 + +### 3.4 spill/reload 与寄存器分配修复 + +- naive 分配器为同一条二元指令的不同虚拟源分配不同 scratch register。 +- naive 目的寄存器不再进行无意义的旧值加载。 +- greedy 在控制流边界写回仍活跃值,taken 和 fallthrough 路径共享已初始化的 spill slot。 +- call 前保存仍存活且会被 ABI clobber 的虚拟值,call 后按需 reload。 +- 线性扫描和 greedy 都会识别显式物理寄存器定义,避免虚拟值被固定 `t0` 等操作数静默覆盖。 +- 自定义物理寄存器列表要求为有效、唯一的 RV32 整数可分配寄存器。 + +### 3.5 ABI 栈帧 + +新增 `scratchv/backend/abi_frame.py`,并接入生产编译路径: + +- 根据 spill slot 和实际使用的 callee-saved register 创建 16 字节对齐栈帧; +- 函数包含 call 时保存和恢复传入 `ra`; +- 保存和恢复实际使用的 `s0`–`s11`; +- 将分配阶段的负 spill offset 重定位到已保留的栈帧范围; +- frame 超过当前 12 位立即数安全上限时明确失败,避免静默截断。 + +### 3.6 编码器安全检查 + +`scratchv/backend/riscv_encoder.py` 新增: + +- I/S/U/B/J 类型立即数或偏移范围检查; +- branch/jump 偏移对齐检查; +- RV32 `srai` shift amount 范围检查; +- 十进制、十六进制、负十六进制共用立即数解析; +- 超范围输入抛出 `ValueError`,不再通过位掩码静默截断。 + +### 3.7 可审计的溢出指标 + +- allocator 插入的 store/load 分别带有 `[regalloc:spill]` 和 `[regalloc:reload]` 标记。 +- `spill_slots` 表示唯一栈槽数量。 +- `spill_stores` / `reg_spill_count` 表示静态 spill store 站点数量。 +- `reloads` 表示静态 reload load 站点数量。 +- `pressure_peak` 表示峰值活跃区间数量。 +- 普通模型内存访问或用户注释中的 `spill`、`reload` 字样不会污染统计。 + +## 4. 单元测试与端到端测试 + +| 文件 | 覆盖内容 | +|---|---| +| `tests/test_regalloc_pseudo.py` | 每条 RV32IM 伪指令的语义、分配、编码和 TinyFive 执行;外部扩展明确拒绝。 | +| `tests/test_regalloc_p1.py` | 高压力 spill/reload、随机直线程序对拍、call clobber、分支路径交接、物理寄存器冲突、naive/greedy 执行。 | +| `tests/test_regalloc_metrics.py` | spill slot/store/reload/pressure 指标定义及 CNN 验证条件。 | +| `tests/test_regalloc_pseudo_benchmark.py` | 伪指令 benchmark 覆盖矩阵、执行结果和报告序列化。 | +| `tests/test_regalloc_spill_compare.py` | 不同压力模型与不同寄存器数量下的溢出对比。 | +| `tests/test_abi_frame.py` | 栈帧重定位、callee-saved、嵌套 call 的 `ra` 保存恢复及超大 frame 拒绝。 | +| `tests/test_riscv_encoder_validation.py` | 立即数、内存 offset、控制转移 offset 和 shift amount 的范围检查。 | + +最终完整回归: + +```text +757 passed in 44.97s +``` + +## 5. Benchmark 完善 + +### 5.1 防止假通过 + +- simple/dense/CNN/pseudo benchmark 改用生产版 `scratchv.backend.regalloc_linear`。 +- simple/dense 不再使用不可编码的 `r0`–`rN` 假寄存器。 +- simple/dense 先由独立解释器计算参考 `a0`,再执行实际分配汇编进行对拍。 +- CNN 为 Machine IR live-in 注入确定性值,并由独立 Machine IR 解释器计算参考结果。 +- CNN 的线性扫描和 greedy 输出分别经过真实编码和 TinyFive 执行。 +- benchmark 的 PASS 同时要求统计条件、汇编合法和执行结果正确。 + +### 5.2 30 次运行结果 + +命令: + +```powershell +python -m benchmarks.test_regalloc.bench_regalloc_linear ` + --repeats 30 ` + --output-json benchmark_reports/regalloc_bench.json ` + --output-html benchmark_reports/regalloc_bench.html ` + --output-md benchmark_reports/regalloc_bench.md +``` + +| Benchmark | Mean(ms) | Vregs | Spill stores | Spill slots | Reloads | Pressure peak | 执行 | +|---|---:|---:|---:|---:|---:|---:|---| +| Simple Arithmetic | 0.021 | 5 | 0 | 0 | 0 | 3 | PASS | +| Dense Computation | 0.297 | 20 | 60 | 27 | 74 | 30 | PASS,`a0=59766` | +| CNN Integration | 0.272 | 30 | 0 | 0 | 0 | 11 | linear/greedy 均 PASS,`a0=1` | +| Pseudo Instructions | 0.056 | 7 | 0 | 0 | 0 | 3 | 9/9 PASS | + +报告位置: + +- `benchmark_reports/regalloc_bench.json` +- `benchmark_reports/regalloc_bench.html` +- `benchmark_reports/regalloc_bench.md` + +### 5.3 LLVM 对比边界 + +本次报告记录:ScratchV 静态指令 69,LLVM RV64FD 静态指令 1101,报告比值 15.96。 + +该数字只能描述当前两个生成路径的静态输出规模,不能直接证明 ScratchV 性能优于 LLVM:ScratchV CNN 路径是标量近似 Machine IR,而 LLVM 路径包含更完整的模型、浮点、ABI 和运行时工作。 + +## 6. Topic17 涉及的主要文件 + +### 后端实现 + +- `scratchv/backend/machine_semantics.py` +- `scratchv/backend/instruction_select.py` +- `scratchv/backend/register_alloc.py` +- `scratchv/backend/regalloc_linear.py` +- `scratchv/backend/regalloc_linear_v1_5.py` +- `scratchv/backend/regalloc_rewrite.py` +- `scratchv/backend/regalloc_metrics.py` +- `scratchv/backend/riscv_encoder.py` +- `scratchv/backend/abi_frame.py` +- `scratchv/compiler.py` + +### Benchmark + +- `benchmarks/test_regalloc/bench_simple.py` +- `benchmarks/test_regalloc/bench_dense.py` +- `benchmarks/test_regalloc/bench_cnn.py` +- `benchmarks/test_regalloc/bench_pseudo.py` +- `benchmarks/test_regalloc/bench_regalloc_linear.py` +- `benchmarks/bench_regalloc_spill_compare.py` +- `benchmarks/regalloc_spill_cases/` + +### 测试 + +- `tests/test_regalloc_pseudo.py` +- `tests/test_regalloc_p1.py` +- `tests/test_regalloc_metrics.py` +- `tests/test_regalloc_pseudo_benchmark.py` +- `tests/test_regalloc_spill_compare.py` +- `tests/test_abi_frame.py` +- `tests/test_riscv_encoder_validation.py` + +## 7. 自查结论 + +- 完整 pytest:PASS(757 项)。 +- 30 次寄存器分配 benchmark:四组全部 PASS。 +- `git diff --check`:PASS。 +- 当前本地 CFG 文件修改数:0。 +- 用户原有工作区文件和临时目录均保留。 + +## 8. 已知边界与后续事项 + +1. CFG 正式接口和后续集成不在本次修改范围内。 +2. 当前编码和模拟执行覆盖 RV32IM;F/D 扩展仍需要独立的寄存器类别、编码器和模拟器支持。 +3. 远距离 branch/jump 尚未自动生成跳板;当前行为是明确拒绝超范围偏移。 +4. 超过 2032 字节的栈帧尚未使用多指令地址生成;当前行为是明确失败,避免错误机器码。 +5. CNN benchmark 验证的是当前标量化 Machine IR 的寄存器分配正确性,不等同于完整 ONNX 数值精度验证。 +6. 合入前仍需由维护者确认 CFG PR 提供的接口稳定性,并在集成后重新运行本报告中的全部回归与 benchmark。 + +## 9. 本地状态 + +本文档和本轮修复目前只存在于本地工作区,尚未创建 commit,也尚未 push 到 GitHub PR。 diff --git a/benchmarks/bench_const_merge.py b/benchmarks/bench_const_merge.py index ddc37ee..0be8292 100644 --- a/benchmarks/bench_const_merge.py +++ b/benchmarks/bench_const_merge.py @@ -140,6 +140,7 @@ def bench_merge(asm_text: str, repeats: int = 50) -> dict: return { "benchmark_type": "synthetic", + "instruction_metric_scope": "source assembly (pre-pseudo-expansion)", "input_instructions": input_instructions, "output_instructions": output_instructions, "instruction_reduction": input_instructions - output_instructions, diff --git a/benchmarks/bench_regalloc_spill_compare.py b/benchmarks/bench_regalloc_spill_compare.py new file mode 100644 index 0000000..149bd27 --- /dev/null +++ b/benchmarks/bench_regalloc_spill_compare.py @@ -0,0 +1,488 @@ +"""Compare ScratchV and LLVM spill traffic on register-pressure DSL cases. + +The suite deliberately uses straight-line scalar programs with at most four +external inputs. That keeps LLVM stack-argument traffic and explicit DSL +allocas out of the measurement, so stack-relative loads/stores are a useful +proxy for register spills. +""" + +from __future__ import annotations + +import argparse +import json +import re +from collections.abc import Sequence +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +CASE_DIR = Path(__file__).with_name("regalloc_spill_cases") + +EXPECTED_SCRATCHV_SPILL: dict[str, bool] = { + "00_low_pressure_chain": False, + "01_wide_fanout_32": True, + "02_double_use_40": True, + "03_lifetime_holes_36": True, + "04_hot_cold_48": True, +} + +_STACK_ACCESS_RE = re.compile( + r"^\s*(?Psd|ld|sw|lw|fsd|fld|fsw|flw)\s+" + r"(?P[^,]+),\s*(?P-?\d+)\(sp\)" +) +_SAVED_REGS = frozenset( + {"ra", "fp", *(f"s{i}" for i in range(12)), *(f"fs{i}" for i in range(12))} +) +_STORE_OPS = frozenset({"sd", "sw", "fsd", "fsw"}) +_LOAD_OPS = frozenset({"ld", "lw", "fld", "flw"}) + +@dataclass(frozen=True) +class StackAccessStats: + """Stack accesses split into allocator traffic and ABI frame traffic.""" + + spill_slots: int = 0 + spill_stores: int = 0 + reloads: int = 0 + frame_saves: int = 0 + frame_restores: int = 0 + + @property + def spill_traffic(self) -> int: + """Return the total number of spill stores and reloads.""" + return self.spill_stores + self.reloads + + +@dataclass(frozen=True) +class BackendStats: + """Register-allocation measurements for one backend.""" + + instructions: int + virtual_registers: int | None + peak_live: int | None + physical_registers: int | None + stack: StackAccessStats + assembly: str + + def to_dict(self) -> dict[str, Any]: + """Return JSON-safe summary without embedding full assembly.""" + result = asdict(self) + result.pop("assembly") + result["stack"]["spill_traffic"] = self.stack.spill_traffic + return result + + +@dataclass(frozen=True) +class ComparisonResult: + """ScratchV/LLVM measurements for one DSL case.""" + + name: str + description: str + scratchv: BackendStats + llvm: BackendStats | None = None + llvm_error: str = "" + expect_scratchv_spill: bool | None = None + + @property + def scratchv_expectation_met(self) -> bool | None: + """Return whether ScratchV crossed the expected spill boundary.""" + + if self.expect_scratchv_spill is None: + return None + spilled = self.scratchv.stack.spill_stores > 0 + return spilled is self.expect_scratchv_spill + + @property + def spill_traffic_ratio(self) -> float | None: + """Return ScratchV spill traffic divided by LLVM spill traffic.""" + if self.llvm is None: + return None + llvm_traffic = self.llvm.stack.spill_traffic + scratchv_traffic = self.scratchv.stack.spill_traffic + if llvm_traffic == 0: + return float("inf") if scratchv_traffic else 1.0 + return scratchv_traffic / llvm_traffic + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-safe comparison summary.""" + ratio = self.spill_traffic_ratio + return { + "name": self.name, + "description": self.description, + "scratchv": self.scratchv.to_dict(), + "llvm": self.llvm.to_dict() if self.llvm is not None else None, + "llvm_error": self.llvm_error, + "expect_scratchv_spill": self.expect_scratchv_spill, + "scratchv_expectation_met": self.scratchv_expectation_met, + "spill_traffic_ratio": "inf" if ratio == float("inf") else ratio, + } + + +def discover_cases(case_dir: Path = CASE_DIR) -> list[Path]: + """Return the sorted DSL pressure cases in *case_dir*.""" + cases = sorted(case_dir.glob("*.dsl")) + if not cases: + raise ValueError(f"No DSL benchmark cases found in {case_dir}") + return cases + + +def _classify_stack_accesses( + assembly: str, + *, + exclude_abi_frame: bool, +) -> StackAccessStats: + """Classify anchored scalar stack accesses for either backend. + + The benchmark cases intentionally avoid stack arguments and explicit + memory operations, so remaining stack-relative scalar accesses are spill + traffic. LLVM frame saves/restores can be excluded explicitly. + """ + spill_offsets: set[int] = set() + spill_stores = reloads = frame_saves = frame_restores = 0 + + for line in assembly.splitlines(): + match = _STACK_ACCESS_RE.match(line) + if match is None: + continue + op = match.group("op") + reg = match.group("reg").strip() + offset = int(match.group("offset")) + + is_frame_access = ( + exclude_abi_frame + and op in {"sd", "ld", "fsd", "fld"} + and reg in _SAVED_REGS + ) + if is_frame_access: + if op in _STORE_OPS: + frame_saves += 1 + else: + frame_restores += 1 + continue + + spill_offsets.add(offset) + if op in _STORE_OPS: + spill_stores += 1 + elif op in _LOAD_OPS: + reloads += 1 + + return StackAccessStats( + spill_slots=len(spill_offsets), + spill_stores=spill_stores, + reloads=reloads, + frame_saves=frame_saves, + frame_restores=frame_restores, + ) + + +def classify_scratchv_stack_accesses(assembly: str) -> StackAccessStats: + """Count ScratchV integer, floating-point, and wide spill traffic.""" + + return _classify_stack_accesses(assembly, exclude_abi_frame=False) + + +def classify_llvm_stack_accesses(assembly: str) -> StackAccessStats: + """Count LLVM spill traffic while excluding ABI frame management.""" + + return _classify_stack_accesses(assembly, exclude_abi_frame=True) + + +def _peak_live(intervals: Sequence[Any]) -> int: + """Calculate maximum simultaneous live intervals.""" + if not intervals: + return 0 + first = min(interval.start for interval in intervals) + last = max(interval.end for interval in intervals) + return max( + sum(interval.start <= position < interval.end for interval in intervals) + for position in range(first, last) + ) + + +def _instruction_count(assembly: str) -> int: + """Count RISC-V instructions using the project's canonical counter.""" + from scratchv.standalone.compare_codegen import count_riscv_instrs + + count, _ = count_riscv_instrs(assembly) + return count + + +def compile_scratchv( + source: str, + phys_regs: list[str] | None = None, +) -> BackendStats: + """Compile a DSL source through ScratchV's current linear allocator.""" + from scratchv.backend.instruction_select import InstructionSelector + from scratchv.backend.regalloc_linear import ( + LinearScanAllocator, + block_from_machine_instrs, + ) + from scratchv.frontend.dsl_parser import DSLParser + + program = DSLParser().parse(source) + machine = InstructionSelector(program).run() + block = block_from_machine_instrs(machine) + allocator = LinearScanAllocator(phys_regs=phys_regs) + intervals = allocator.compute_live_intervals(block) + allocator.allocate(intervals) + assembly = allocator.get_allocated_code(block) + + stack = classify_scratchv_stack_accesses(assembly) + if stack.spill_slots != allocator.spill_slot_count: + raise RuntimeError( + "ScratchV spill metric mismatch: " + f"allocator={allocator.spill_slot_count}, assembly={stack.spill_slots}" + ) + return BackendStats( + instructions=_instruction_count(assembly), + virtual_registers=len(intervals), + peak_live=_peak_live(intervals), + physical_registers=len(allocator.phys_regs), + stack=stack, + assembly=assembly, + ) + + +def compile_llvm(source: str, opt_level: int = 2) -> BackendStats: + """Compile the same DSL source through LLVM's RISC-V backend.""" + from benchmarks.test_regalloc.bench_utils import llvmlite_ir_to_riscv + from scratchv.backend.llvm_codegen import LLVMCodegen + from scratchv.frontend.dsl_parser import DSLParser + + program = DSLParser().parse(source) + ir_text = LLVMCodegen(program).emit() + count, assembly, _ = llvmlite_ir_to_riscv( + ir_text, + features="+m,+f,+d", + opt_level=opt_level, + ) + return BackendStats( + instructions=count, + virtual_registers=None, + peak_live=None, + physical_registers=None, + stack=classify_llvm_stack_accesses(assembly), + assembly=assembly, + ) + + +def _description(source: str) -> str: + """Extract the first comment line as the case description.""" + for line in source.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + return stripped.lstrip("# ") + if stripped: + break + return "" + + +def run_case( + path: Path, + *, + llvm_opt_level: int = 2, + phys_regs: list[str] | None = None, +) -> ComparisonResult: + """Compile one DSL case with both backends.""" + source = path.read_text() + scratchv = compile_scratchv(source, phys_regs=phys_regs) + expected_spill = EXPECTED_SCRATCHV_SPILL.get(path.stem) + try: + llvm = compile_llvm(source, opt_level=llvm_opt_level) + except (ImportError, OSError, RuntimeError, ValueError) as exc: + return ComparisonResult( + name=path.stem, + description=_description(source), + scratchv=scratchv, + llvm_error=f"{type(exc).__name__}: {exc}", + expect_scratchv_spill=expected_spill, + ) + return ComparisonResult( + name=path.stem, + description=_description(source), + scratchv=scratchv, + llvm=llvm, + expect_scratchv_spill=expected_spill, + ) + + +def run_suite( + case_dir: Path = CASE_DIR, + *, + llvm_opt_level: int = 2, + phys_regs: list[str] | None = None, +) -> list[ComparisonResult]: + """Run all discovered DSL pressure cases.""" + return [ + run_case(path, llvm_opt_level=llvm_opt_level, phys_regs=phys_regs) + for path in discover_cases(case_dir) + ] + + +def _format_ratio(ratio: float | None) -> str: + if ratio is None: + return "n/a" + if ratio == float("inf"): + return "inf" + return f"{ratio:.2f}x" + + +def format_table(results: Sequence[ComparisonResult]) -> str: + """Render a compact console comparison table.""" + rows = [ + "Case Peak | ScratchV slots S/R/T | " + "LLVM slots S/R/T | Ratio | Expect", + "-" * 95, + ] + for result in results: + sv = result.scratchv.stack + if result.llvm is None: + llvm_text = " unavailable " + else: + ll = result.llvm.stack + llvm_text = ( + f"{ll.spill_slots:>3} {ll.spill_stores:>3}/" + f"{ll.reloads:<3}/{ll.spill_traffic:<3}" + ) + expectation = result.scratchv_expectation_met + expectation_text = ( + "n/a" if expectation is None else ("PASS" if expectation else "FAIL") + ) + rows.append( + f"{result.name:<24} {result.scratchv.peak_live or 0:>4} | " + f"{sv.spill_slots:>3} {sv.spill_stores:>3}/" + f"{sv.reloads:<3}/{sv.spill_traffic:<3} | " + f"{llvm_text:<19} | {_format_ratio(result.spill_traffic_ratio):>6} | " + f"{expectation_text}" + ) + return "\n".join(rows) + + +def format_markdown( + results: Sequence[ComparisonResult], + llvm_opt_level: int, +) -> str: + """Render a Markdown spill-comparison report.""" + lines = [ + "# DSL Register Spill Comparison", + "", + f"LLVM target: `riscv64-unknown-elf`, optimization level: `O{llvm_opt_level}`.", + "ScratchV uses the current default register set from `regalloc_linear.py`.", + "", + ( + "| Case | Peak live | ScratchV slots | ScratchV store/reload | " + "LLVM slots | LLVM store/reload | Traffic ratio | Expected boundary |" + ), + "|---|---:|---:|---:|---:|---:|---:|---:|", + ] + for result in results: + sv = result.scratchv.stack + if result.llvm is None: + llvm_slots = llvm_traffic = "n/a" + else: + ll = result.llvm.stack + llvm_slots = str(ll.spill_slots) + llvm_traffic = f"{ll.spill_stores}/{ll.reloads}" + expectation = result.scratchv_expectation_met + expectation_text = ( + "n/a" if expectation is None else ("PASS" if expectation else "FAIL") + ) + lines.append( + f"| {result.name} | {result.scratchv.peak_live} | " + f"{sv.spill_slots} | {sv.spill_stores}/{sv.reloads} | " + f"{llvm_slots} | {llvm_traffic} | " + f"{_format_ratio(result.spill_traffic_ratio)} | {expectation_text} |" + ) + lines.extend( + [ + "", + ( + "`store/reload` counts only allocator-related stack traffic. " + "ABI callee-saved frame saves/restores are excluded." + ), + ] + ) + return "\n".join(lines) + "\n" + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Compare ScratchV and LLVM spill traffic on DSL benchmarks", + ) + parser.add_argument( + "--cases", + type=Path, + default=CASE_DIR, + help="directory containing .dsl cases", + ) + parser.add_argument( + "--llvm-opt-level", + type=int, + choices=range(4), + default=2, + metavar="N", + help="LLVM optimization level (default: 2)", + ) + parser.add_argument( + "--phys-reg-count", + type=int, + default=0, + help="limit ScratchV physical registers; 0 uses the pipeline default", + ) + parser.add_argument( + "--json", + action="store_true", + help="write JSON to stdout instead of the text table", + ) + parser.add_argument("--json-output", type=Path, help="write JSON report to a file") + parser.add_argument("--markdown", type=Path, help="write Markdown report to a file") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI entry point.""" + args = _parser().parse_args(argv) + + phys_regs = None + if args.phys_reg_count: + from scratchv.backend.regalloc_linear import _INT_REGS + + if not 1 <= args.phys_reg_count <= len(_INT_REGS): + raise SystemExit(f"--phys-reg-count must be between 1 and {len(_INT_REGS)}") + phys_regs = list(_INT_REGS[: args.phys_reg_count]) + + results = run_suite( + args.cases, + llvm_opt_level=args.llvm_opt_level, + phys_regs=phys_regs, + ) + if not results: + raise SystemExit("No benchmark cases matched") + payload = { + "llvm_opt_level": args.llvm_opt_level, + "scratchv_phys_reg_count": results[0].scratchv.physical_registers, + "cases": [result.to_dict() for result in results], + } + json_text = json.dumps(payload, indent=2, sort_keys=True) + + if args.json: + print(json_text) + else: + print(format_table(results)) + for result in results: + if result.llvm_error: + print(f"LLVM unavailable for {result.name}: {result.llvm_error}") + + if args.json_output is not None: + args.json_output.write_text(json_text + "\n") + if args.markdown is not None: + args.markdown.write_text(format_markdown(results, args.llvm_opt_level)) + + llvm_failed = any(result.llvm is None for result in results) + expectation_failed = any( + result.scratchv_expectation_met is False for result in results + ) + return int(llvm_failed or expectation_failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/regalloc_spill_cases/00_low_pressure_chain.dsl b/benchmarks/regalloc_spill_cases/00_low_pressure_chain.dsl new file mode 100644 index 0000000..6dfb58f --- /dev/null +++ b/benchmarks/regalloc_spill_cases/00_low_pressure_chain.dsl @@ -0,0 +1,12 @@ +# Low-pressure dependency chain: values die quickly and should not spill. +t00 = add(a, b) +t01 = mul(t00, c) +t02 = sub(t01, d) +t03 = add(t02, a) +t04 = mul(t03, b) +t05 = sub(t04, c) +t06 = add(t05, d) +t07 = mul(t06, a) +t08 = sub(t07, b) +t09 = add(t08, c) +return t09 diff --git a/benchmarks/regalloc_spill_cases/01_wide_fanout_32.dsl b/benchmarks/regalloc_spill_cases/01_wide_fanout_32.dsl new file mode 100644 index 0000000..375d5c0 --- /dev/null +++ b/benchmarks/regalloc_spill_cases/01_wide_fanout_32.dsl @@ -0,0 +1,67 @@ +# Threshold pressure: 32 long-lived values feed a balanced reduction tree. +# Four recurrence chains keep every definition unique under LLVM O2. +v00 = add(a, b) +v01 = sub(c, d) +v02 = mul(a, c) +v03 = div(b, d) +v04 = add(v00, c) +v05 = sub(v01, d) +v06 = mul(v02, a) +v07 = div(v03, b) +v08 = sub(v04, d) +v09 = mul(v05, a) +v10 = add(v06, b) +v11 = div(v07, c) +v12 = mul(v08, a) +v13 = add(v09, b) +v14 = sub(v10, c) +v15 = div(v11, d) +v16 = add(v12, b) +v17 = sub(v13, c) +v18 = mul(v14, d) +v19 = div(v15, a) +v20 = sub(v16, c) +v21 = mul(v17, d) +v22 = add(v18, a) +v23 = div(v19, b) +v24 = mul(v20, d) +v25 = add(v21, a) +v26 = sub(v22, b) +v27 = div(v23, c) +v28 = add(v24, a) +v29 = sub(v25, b) +v30 = mul(v26, c) +v31 = div(v27, d) + +p00 = add(v00, v01) +p01 = add(v02, v03) +p02 = add(v04, v05) +p03 = add(v06, v07) +p04 = add(v08, v09) +p05 = add(v10, v11) +p06 = add(v12, v13) +p07 = add(v14, v15) +p08 = add(v16, v17) +p09 = add(v18, v19) +p10 = add(v20, v21) +p11 = add(v22, v23) +p12 = add(v24, v25) +p13 = add(v26, v27) +p14 = add(v28, v29) +p15 = add(v30, v31) +q00 = add(p00, p01) +q01 = add(p02, p03) +q02 = add(p04, p05) +q03 = add(p06, p07) +q04 = add(p08, p09) +q05 = add(p10, p11) +q06 = add(p12, p13) +q07 = add(p14, p15) +r00 = add(q00, q01) +r01 = add(q02, q03) +r02 = add(q04, q05) +r03 = add(q06, q07) +s00 = add(r00, r01) +s01 = add(r02, r03) +out = add(s00, s01) +return out diff --git a/benchmarks/regalloc_spill_cases/02_double_use_40.dsl b/benchmarks/regalloc_spill_cases/02_double_use_40.dsl new file mode 100644 index 0000000..5ebb8a8 --- /dev/null +++ b/benchmarks/regalloc_spill_cases/02_double_use_40.dsl @@ -0,0 +1,124 @@ +# Sustained pressure: 40 values are consumed once forward and once in reverse. +# The second use prevents the first reduction from ending their live ranges. +v00 = add(a, b) +v01 = sub(c, d) +v02 = mul(a, c) +v03 = div(b, d) +v04 = add(v00, c) +v05 = sub(v01, d) +v06 = mul(v02, a) +v07 = div(v03, b) +v08 = sub(v04, d) +v09 = mul(v05, a) +v10 = add(v06, b) +v11 = div(v07, c) +v12 = mul(v08, a) +v13 = add(v09, b) +v14 = sub(v10, c) +v15 = div(v11, d) +v16 = add(v12, b) +v17 = sub(v13, c) +v18 = mul(v14, d) +v19 = div(v15, a) +v20 = sub(v16, c) +v21 = mul(v17, d) +v22 = add(v18, a) +v23 = div(v19, b) +v24 = mul(v20, d) +v25 = add(v21, a) +v26 = sub(v22, b) +v27 = div(v23, c) +v28 = add(v24, a) +v29 = sub(v25, b) +v30 = mul(v26, c) +v31 = div(v27, d) +v32 = sub(v28, b) +v33 = mul(v29, c) +v34 = add(v30, d) +v35 = div(v31, a) +v36 = mul(v32, c) +v37 = add(v33, d) +v38 = sub(v34, a) +v39 = div(v35, b) + +f00 = add(v00, v01) +f01 = add(f00, v02) +f02 = add(f01, v03) +f03 = add(f02, v04) +f04 = add(f03, v05) +f05 = add(f04, v06) +f06 = add(f05, v07) +f07 = add(f06, v08) +f08 = add(f07, v09) +f09 = add(f08, v10) +f10 = add(f09, v11) +f11 = add(f10, v12) +f12 = add(f11, v13) +f13 = add(f12, v14) +f14 = add(f13, v15) +f15 = add(f14, v16) +f16 = add(f15, v17) +f17 = add(f16, v18) +f18 = add(f17, v19) +f19 = add(f18, v20) +f20 = add(f19, v21) +f21 = add(f20, v22) +f22 = add(f21, v23) +f23 = add(f22, v24) +f24 = add(f23, v25) +f25 = add(f24, v26) +f26 = add(f25, v27) +f27 = add(f26, v28) +f28 = add(f27, v29) +f29 = add(f28, v30) +f30 = add(f29, v31) +f31 = add(f30, v32) +f32 = add(f31, v33) +f33 = add(f32, v34) +f34 = add(f33, v35) +f35 = add(f34, v36) +f36 = add(f35, v37) +f37 = add(f36, v38) +f38 = add(f37, v39) + +r00 = mul(v39, v38) +r01 = add(r00, v37) +r02 = add(r01, v36) +r03 = add(r02, v35) +r04 = add(r03, v34) +r05 = add(r04, v33) +r06 = add(r05, v32) +r07 = add(r06, v31) +r08 = add(r07, v30) +r09 = add(r08, v29) +r10 = add(r09, v28) +r11 = add(r10, v27) +r12 = add(r11, v26) +r13 = add(r12, v25) +r14 = add(r13, v24) +r15 = add(r14, v23) +r16 = add(r15, v22) +r17 = add(r16, v21) +r18 = add(r17, v20) +r19 = add(r18, v19) +r20 = add(r19, v18) +r21 = add(r20, v17) +r22 = add(r21, v16) +r23 = add(r22, v15) +r24 = add(r23, v14) +r25 = add(r24, v13) +r26 = add(r25, v12) +r27 = add(r26, v11) +r28 = add(r27, v10) +r29 = add(r28, v09) +r30 = add(r29, v08) +r31 = add(r30, v07) +r32 = add(r31, v06) +r33 = add(r32, v05) +r34 = add(r33, v04) +r35 = add(r34, v03) +r36 = add(r35, v02) +r37 = add(r36, v01) +r38 = add(r37, v00) +out = add(f38, r38) +return out diff --git a/benchmarks/regalloc_spill_cases/03_lifetime_holes_36.dsl b/benchmarks/regalloc_spill_cases/03_lifetime_holes_36.dsl new file mode 100644 index 0000000..ac20a3a --- /dev/null +++ b/benchmarks/regalloc_spill_cases/03_lifetime_holes_36.dsl @@ -0,0 +1,137 @@ +# Lifetime-hole pressure: 36 anchors are touched, ignored, then reused later. +# Linear intervals span the quiet middle region; LLVM may split live ranges. +v00 = add(a, b) +v01 = sub(c, d) +v02 = mul(a, c) +v03 = div(b, d) +v04 = add(v00, c) +v05 = sub(v01, d) +v06 = mul(v02, a) +v07 = div(v03, b) +v08 = sub(v04, d) +v09 = mul(v05, a) +v10 = add(v06, b) +v11 = div(v07, c) +v12 = mul(v08, a) +v13 = add(v09, b) +v14 = sub(v10, c) +v15 = div(v11, d) +v16 = add(v12, b) +v17 = sub(v13, c) +v18 = mul(v14, d) +v19 = div(v15, a) +v20 = sub(v16, c) +v21 = mul(v17, d) +v22 = add(v18, a) +v23 = div(v19, b) +v24 = mul(v20, d) +v25 = add(v21, a) +v26 = sub(v22, b) +v27 = div(v23, c) +v28 = add(v24, a) +v29 = sub(v25, b) +v30 = mul(v26, c) +v31 = div(v27, d) +v32 = sub(v28, b) +v33 = mul(v29, c) +v34 = add(v30, d) +v35 = div(v31, a) + +e00 = add(v00, v01) +e01 = add(v02, v03) +e02 = add(v04, v05) +e03 = add(v06, v07) +e04 = add(v08, v09) +e05 = add(v10, v11) +e06 = add(v12, v13) +e07 = add(v14, v15) +e08 = add(v16, v17) +e09 = add(v18, v19) +e10 = add(v20, v21) +e11 = add(v22, v23) +e12 = add(v24, v25) +e13 = add(v26, v27) +e14 = add(v28, v29) +e15 = add(v30, v31) +e16 = add(v32, v33) +e17 = add(v34, v35) + +# Independent middle chain creates a region where the anchors are inactive. +g00 = add(a, d) +g01 = mul(g00, b) +g02 = sub(g01, c) +g03 = add(g02, a) +g04 = mul(g03, d) +g05 = sub(g04, b) +g06 = add(g05, c) +g07 = mul(g06, a) +g08 = sub(g07, d) +g09 = add(g08, b) +g10 = mul(g09, c) +g11 = sub(g10, a) +g12 = add(g11, d) +g13 = mul(g12, b) +g14 = sub(g13, c) +g15 = add(g14, a) +g16 = mul(g15, d) +g17 = sub(g16, b) +g18 = add(g17, c) +g19 = mul(g18, a) + +# Reuse anchors in reverse order after the lifetime hole. +q00 = add(v35, v34) +q01 = add(q00, v33) +q02 = add(q01, v32) +q03 = add(q02, v31) +q04 = add(q03, v30) +q05 = add(q04, v29) +q06 = add(q05, v28) +q07 = add(q06, v27) +q08 = add(q07, v26) +q09 = add(q08, v25) +q10 = add(q09, v24) +q11 = add(q10, v23) +q12 = add(q11, v22) +q13 = add(q12, v21) +q14 = add(q13, v20) +q15 = add(q14, v19) +q16 = add(q15, v18) +q17 = add(q16, v17) +q18 = add(q17, v16) +q19 = add(q18, v15) +q20 = add(q19, v14) +q21 = add(q20, v13) +q22 = add(q21, v12) +q23 = add(q22, v11) +q24 = add(q23, v10) +q25 = add(q24, v09) +q26 = add(q25, v08) +q27 = add(q26, v07) +q28 = add(q27, v06) +q29 = add(q28, v05) +q30 = add(q29, v04) +q31 = add(q30, v03) +q32 = add(q31, v02) +q33 = add(q32, v01) +q34 = add(q33, v00) + +er00 = add(e00, e01) +er01 = add(er00, e02) +er02 = add(er01, e03) +er03 = add(er02, e04) +er04 = add(er03, e05) +er05 = add(er04, e06) +er06 = add(er05, e07) +er07 = add(er06, e08) +er08 = add(er07, e09) +er09 = add(er08, e10) +er10 = add(er09, e11) +er11 = add(er10, e12) +er12 = add(er11, e13) +er13 = add(er12, e14) +er14 = add(er13, e15) +er15 = add(er14, e16) +er16 = add(er15, e17) +out0 = add(q34, er16) +out1 = add(out0, g19) +return out1 diff --git a/benchmarks/regalloc_spill_cases/04_hot_cold_48.dsl b/benchmarks/regalloc_spill_cases/04_hot_cold_48.dsl new file mode 100644 index 0000000..4a53d06 --- /dev/null +++ b/benchmarks/regalloc_spill_cases/04_hot_cold_48.dsl @@ -0,0 +1,134 @@ +# Hot/cold pressure: 48 cold values wait while a short-lived hot chain runs. +# This exposes spill choices that ignore use frequency and scheduling freedom. +v00 = add(a, b) +v01 = sub(c, d) +v02 = mul(a, c) +v03 = div(b, d) +v04 = add(v00, c) +v05 = sub(v01, d) +v06 = mul(v02, a) +v07 = div(v03, b) +v08 = sub(v04, d) +v09 = mul(v05, a) +v10 = add(v06, b) +v11 = div(v07, c) +v12 = mul(v08, a) +v13 = add(v09, b) +v14 = sub(v10, c) +v15 = div(v11, d) +v16 = add(v12, b) +v17 = sub(v13, c) +v18 = mul(v14, d) +v19 = div(v15, a) +v20 = sub(v16, c) +v21 = mul(v17, d) +v22 = add(v18, a) +v23 = div(v19, b) +v24 = mul(v20, d) +v25 = add(v21, a) +v26 = sub(v22, b) +v27 = div(v23, c) +v28 = add(v24, a) +v29 = sub(v25, b) +v30 = mul(v26, c) +v31 = div(v27, d) +v32 = sub(v28, b) +v33 = mul(v29, c) +v34 = add(v30, d) +v35 = div(v31, a) +v36 = mul(v32, c) +v37 = add(v33, d) +v38 = sub(v34, a) +v39 = div(v35, b) +v40 = add(v36, d) +v41 = sub(v37, a) +v42 = mul(v38, b) +v43 = div(v39, c) +v44 = sub(v40, a) +v45 = mul(v41, b) +v46 = add(v42, c) +v47 = div(v43, d) + +# Hot chain: each result has a short lifetime, while all vXX values stay live. +h00 = add(a, c) +h01 = mul(h00, b) +h02 = sub(h01, d) +h03 = add(h02, a) +h04 = mul(h03, c) +h05 = sub(h04, b) +h06 = add(h05, d) +h07 = mul(h06, a) +h08 = sub(h07, c) +h09 = add(h08, b) +h10 = mul(h09, d) +h11 = sub(h10, a) +h12 = add(h11, c) +h13 = mul(h12, b) +h14 = sub(h13, d) +h15 = add(h14, a) +h16 = mul(h15, c) +h17 = sub(h16, b) +h18 = add(h17, d) +h19 = mul(h18, a) +h20 = sub(h19, c) +h21 = add(h20, b) +h22 = mul(h21, d) +h23 = sub(h22, a) +h24 = add(h23, c) +h25 = mul(h24, b) +h26 = sub(h25, d) +h27 = add(h26, a) +h28 = mul(h27, c) +h29 = sub(h28, b) +h30 = add(h29, d) +h31 = mul(h30, a) + +out00 = add(h31, v00) +out01 = add(out00, v01) +out02 = add(out01, v02) +out03 = add(out02, v03) +out04 = add(out03, v04) +out05 = add(out04, v05) +out06 = add(out05, v06) +out07 = add(out06, v07) +out08 = add(out07, v08) +out09 = add(out08, v09) +out10 = add(out09, v10) +out11 = add(out10, v11) +out12 = add(out11, v12) +out13 = add(out12, v13) +out14 = add(out13, v14) +out15 = add(out14, v15) +out16 = add(out15, v16) +out17 = add(out16, v17) +out18 = add(out17, v18) +out19 = add(out18, v19) +out20 = add(out19, v20) +out21 = add(out20, v21) +out22 = add(out21, v22) +out23 = add(out22, v23) +out24 = add(out23, v24) +out25 = add(out24, v25) +out26 = add(out25, v26) +out27 = add(out26, v27) +out28 = add(out27, v28) +out29 = add(out28, v29) +out30 = add(out29, v30) +out31 = add(out30, v31) +out32 = add(out31, v32) +out33 = add(out32, v33) +out34 = add(out33, v34) +out35 = add(out34, v35) +out36 = add(out35, v36) +out37 = add(out36, v37) +out38 = add(out37, v38) +out39 = add(out38, v39) +out40 = add(out39, v40) +out41 = add(out40, v41) +out42 = add(out41, v42) +out43 = add(out42, v43) +out44 = add(out43, v44) +out45 = add(out44, v45) +out46 = add(out45, v46) +out47 = add(out46, v47) +return out47 diff --git a/benchmarks/regalloc_spill_cases/README.md b/benchmarks/regalloc_spill_cases/README.md new file mode 100644 index 0000000..0533e5a --- /dev/null +++ b/benchmarks/regalloc_spill_cases/README.md @@ -0,0 +1,51 @@ +# DSL Register-Spill Benchmark Suite + +This suite compares spill traffic produced from the same straight-line DSL +programs by: + +1. ScratchV's current `InstructionSelector` + `regalloc_linear.py` pipeline. +2. ScratchV's `LLVMCodegen` + LLVM's RISC-V backend (O2 by default). + +Run it from the repository root: + +```bash +python3 -m benchmarks.bench_regalloc_spill_compare +python3 -m benchmarks.bench_regalloc_spill_compare --json +python3 -m benchmarks.bench_regalloc_spill_compare \ + --json-output /tmp/spills.json --markdown /tmp/spills.md +``` + +Use `--phys-reg-count N` to draw a ScratchV pressure curve with fewer than the +pipeline's 19 default integer registers. Use `--llvm-opt-level 0..3` to compare +LLVM allocation modes. + +## Cases + +| Case | Pressure shape | Expected ScratchV boundary | Question answered | +|---|---|---:|---| +| `00_low_pressure_chain` | Short sequential live ranges | no spill | Does either backend spill below capacity? | +| `01_wide_fanout_32` | 32 values live before a balanced reduction | spill | What happens just above ScratchV's register limit? | +| `02_double_use_40` | 40 values consumed forward and in reverse | spill | How much traffic remains when LLVM must also spill? | +| `03_lifetime_holes_36` | Values used, idle for a region, then reused | spill | Can live-range splitting avoid whole-interval spills? | +| `04_hot_cold_48` | Cold values surround a frequently used hot chain | spill | Do use frequency and scheduling reduce spill traffic? | + +## Measurement rules + +- Cases use at most four external inputs, so LLVM stack-argument loads do not + contaminate the spill count. +- Cases contain no explicit memory operations or control-flow allocas. Other + stack-relative scalar loads/stores can therefore be treated as spill traffic. +- LLVM `sd`/`ld` and `fsd`/`fld` pairs for ABI callee-saved registers are + reported as frame management and excluded from spill traffic. +- ScratchV spill slots use the public `spill_slot_count` allocator property; + anchored `sw/lw/fsw/flw/sd/ld/fsd/fld` stack accesses are classified as + spill stores/reloads. +- Exact counts may change when instruction selection, scheduling, register sets, + LLVM versions, or allocation heuristics change. The suite asserts only the + intended pressure boundary, not today's ratios. + +The comparison is intentionally pipeline-level. ScratchV currently selects its +own arithmetic machine operations while LLVM uses the IR's floating-point type, +so the result includes register-class and instruction-scheduling effects in +addition to the allocator algorithm itself. This is the useful end-to-end gap, +but it should not be presented as an isolated algorithm-only comparison. diff --git a/benchmarks/test_regalloc/__init__.py b/benchmarks/test_regalloc/__init__.py index 287906d..4d5e8ab 100644 --- a/benchmarks/test_regalloc/__init__.py +++ b/benchmarks/test_regalloc/__init__.py @@ -2,12 +2,13 @@ """ Register Allocation Benchmarks for ScratchV. -This package contains three benchmark suites for the linear scan +This package contains four benchmark suites for the linear scan register allocator (``LinearScanAllocator``): 1. **bench1_simple** — Simple arithmetic (3-5 vregs, no spills) 2. **bench2_dense** — Dense computation (20+ vregs, triggers spilling) 3. **bench3_cnn** — CNN model integration (operations from ``models/graph/cnn.onnx``) +4. **bench_pseudo** — Per-pseudo allocation, encoding, and execution coverage Each suite measures allocation time, spill count, peak register pressure, and validates output correctness. diff --git a/benchmarks/test_regalloc/bench_cnn.py b/benchmarks/test_regalloc/bench_cnn.py index b2b47cf..8d12f5a 100644 --- a/benchmarks/test_regalloc/bench_cnn.py +++ b/benchmarks/test_regalloc/bench_cnn.py @@ -13,12 +13,21 @@ 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, ) +from scratchv.backend.machine_semantics import virtual_register_defs_uses +from scratchv.backend.machine_types import ( + ALL_REGS, + MachineInstr, + MachineOp, + MachineOperand, +) +from scratchv.backend.riscv_encoder import RISCVAEncoder +from scratchv.backend.abi_frame import apply_abi_frames from scratchv.backend.register_alloc import RegisterAllocator +from scratchv.backend.asm_emit import AsmEmitter from scratchv.standalone.compare_codegen import count_riscv_instrs @@ -45,6 +54,31 @@ def _compile_onnx(onnx_path: str) -> tuple: DeadCodeEliminator(program).run() machine = InstructionSelector(program).run() + # The scalarized CNN Machine IR names model inputs and initializers as + # live-ins. Materialize deterministic values so the allocator output is + # an executable benchmark program instead of assembly with undefined + # entry-register contents. + defined: set[str] = set() + used: set[str] = set() + for instruction in machine: + defs, uses = virtual_register_defs_uses(instruction) + defined.update(defs) + used.update(uses) + live_ins = sorted(used - defined) + initializers = [ + MachineInstr( + MachineOp.LI, + MachineOperand.vreg(name), + MachineOperand.immediate(1 + sum(map(ord, name)) % 5), + comment=f"benchmark live-in {name}", + ) + for name in live_ins + ] + insertion = 0 + while insertion < len(machine) and machine[insertion].op == MachineOp.LABEL: + insertion += 1 + machine[insertion:insertion] = initializers + vregs: set[str] = set() for mi in machine: for op in (mi.dst, mi.src1, mi.src2): @@ -56,30 +90,13 @@ def _compile_onnx(onnx_path: str) -> tuple: # --------------------------------------------------------------------------- # Assembly validation # --------------------------------------------------------------------------- -from benchmarks.test_regalloc.bench_utils import _KNOWN_OPS - - def _validate_asm(asm: str) -> list[str]: - """Check no unresolved vregs, valid opcodes.""" - errors: list[str] = [] - for lineno, line in enumerate(asm.splitlines(), start=1): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - content = stripped.lstrip() - if content.endswith(":") or not content: - continue - if "#" in content: - content = content[: content.index("#")].strip() - parts = content.split() - if not parts: - continue - if parts[0] not in _KNOWN_OPS: - errors.append(f"Line {lineno}: unknown opcode '{parts[0]}'") - for token in parts: - if token.startswith("v") and token[1:].isdigit(): - errors.append(f"Line {lineno}: unresolved vreg '{token}'") - return errors + """Run the emitted program through the real RV32IM encoder.""" + try: + RISCVAEncoder().assemble(asm) + except (IndexError, KeyError, TypeError, ValueError) as exc: + return [f"RISCVAEncoder: {type(exc).__name__}: {exc}"] + return [] # --------------------------------------------------------------------------- @@ -87,36 +104,146 @@ def _validate_asm(asm: str) -> list[str]: # --------------------------------------------------------------------------- -def _run_emulator(cnn_path: str) -> dict: - """Compile *cnn_path* via standalone pipeline, run through RV32Emulator.""" +def _interpret_machine(machine: list[MachineInstr]) -> int: + """Independently interpret the integer Machine IR and return ``a0``.""" + labels = { + instruction.comment: index + for index, instruction in enumerate(machine) + if instruction.op == MachineOp.LABEL + } + values: dict[str, int] = {"zero": 0, "x0": 0, "ra": len(machine)} + + def unsigned(value: int) -> int: + return value & 0xFFFFFFFF + + def signed(value: int) -> int: + value = unsigned(value) + return value if value < 0x80000000 else value - 0x100000000 + + def read(operand: MachineOperand | None) -> int: + if operand is None: + return 0 + if operand.kind == "imm": + return int(operand.value) + name = str(operand.value) + if name not in values: + raise ValueError(f"undefined Machine IR value: {name}") + return values[name] + + def write(operand: MachineOperand | None, value: int) -> None: + if operand is None or str(operand.value) in {"zero", "x0"}: + return + values[str(operand.value)] = unsigned(value) + + pc = 0 + steps = 0 + while 0 <= pc < len(machine) and steps < 10_000: + instruction = machine[pc] + steps += 1 + next_pc = pc + 1 + left = read(instruction.src1) + right = read(instruction.src2) + op = instruction.op + if op == MachineOp.LABEL: + pass + elif op in {MachineOp.LI, MachineOp.MV}: + write(instruction.dst, left) + elif op in {MachineOp.ADD, MachineOp.ADDI}: + write(instruction.dst, left + right) + elif op == MachineOp.SUB: + write(instruction.dst, left - right) + elif op == MachineOp.MUL: + write(instruction.dst, signed(left) * signed(right)) + elif op == MachineOp.DIV: + divisor = signed(right) + dividend = signed(left) + write(instruction.dst, -1 if divisor == 0 else int(dividend / divisor)) + elif op == MachineOp.REM: + divisor = signed(right) + dividend = signed(left) + quotient = 0 if divisor == 0 else int(dividend / divisor) + result = ( + dividend if divisor == 0 + else dividend - quotient * divisor + ) + write(instruction.dst, result) + elif op == MachineOp.MAX: + write(instruction.dst, max(signed(left), signed(right))) + elif op == MachineOp.SLT: + write(instruction.dst, int(signed(left) < signed(right))) + elif op == MachineOp.XOR: + write(instruction.dst, left ^ right) + elif op == MachineOp.AND: + write(instruction.dst, left & right) + elif op == MachineOp.SRAI: + write(instruction.dst, signed(left) >> (right & 31)) + elif op == MachineOp.BNEZ: + if read(instruction.dst) != 0: + next_pc = labels[instruction.comment] + elif op in {MachineOp.BEQ, MachineOp.BNE, MachineOp.BLT, MachineOp.BGE}: + comparisons = { + MachineOp.BEQ: left == right, + MachineOp.BNE: left != right, + MachineOp.BLT: signed(left) < signed(right), + MachineOp.BGE: signed(left) >= signed(right), + } + if comparisons[op]: + next_pc = labels[instruction.comment] + elif op == MachineOp.J: + next_pc = labels[instruction.comment] + elif op in {MachineOp.JAL, MachineOp.CALL}: + write(instruction.dst or MachineOperand.reg("ra"), pc + 1) + next_pc = labels[instruction.comment] + elif op == MachineOp.JALR: + if str(instruction.dst.value) in {"zero", "x0"}: + break + next_pc = read(instruction.src1) + read(instruction.src2) + else: + raise ValueError(f"unsupported CNN benchmark opcode: {op.value}") + pc = next_pc + else: + if steps >= 10_000: + raise RuntimeError("Machine IR reference interpreter did not terminate") + return values.get("a0", 0) & 0xFFFFFFFF + + +def _run_emulator(assembly: str, expected_a0: int) -> dict: + """Execute the allocated assembly itself and compare its return value.""" try: - from scratchv.standalone.onnx_to_riscv_standalone import ( - ONNXModel, - MemoryPlan, - CNNRISCVGenerator, - ) - from scratchv.simulator.rv32_emulator import RV32Emulator + from scratchv.simulator.tinyfive import ProfiledMachine except ImportError as e: return {"passed": False, "error": f"import error: {e}"} try: - model = ONNXModel.from_file(cnn_path) - memory = MemoryPlan() - memory.layout_weights(model.initializers) - if model.inputs: - inp = model.inputs[0] - el = 1 - for d in model.get_shape(inp.name): - el *= d - memory.alloc_workspace(inp.name, el) - - generator = CNNRISCVGenerator(model, memory) - code_bytes = generator.generate() - - emu = RV32Emulator() - emu.load_code(code_bytes) - emu.run(max_instr=100_000) - return {"passed": True, "error": ""} + harness = ( + "li sp, 8192\n" + "jal ra, main_graph\n" + "li a7, 0x5a5\n" + "j .bench_done\n" + + assembly + + "\n.bench_done:\nj .bench_done" + ) + binary = bytes(RISCVAEncoder().assemble(harness)) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + profile = ProfiledMachine(mem_size=16384) + if not profile.available: + raise RuntimeError("TinyFive is unavailable") + profile.load_binary(words, origin=0) + profile.run(instructions=len(words) + 16, start=0, strict=True) + actual = profile.get_reg(10) & 0xFFFFFFFF + returned = profile.get_reg(17) == 0x5A5 + return { + "passed": returned and actual == expected_a0, + "error": "" if returned and actual == expected_a0 else ( + f"allocated assembly returned={returned}, " + f"a0={actual}, expected={expected_a0}" + ), + "actual_a0": actual, + "expected_a0": expected_a0, + } except Exception as exc: return {"passed": False, "error": str(exc)[:120]} @@ -185,7 +312,7 @@ def _llvm_compare(cnn_path: str) -> dict: """ from scratchv.standalone.compare_codegen import _load_llvm, llvm_ir_to_riscv from scratchv.standalone.onnx_to_llvm_standalone import convert_onnx_to_llvm - from .bench_utils import llvmlite_ir_to_riscv + from benchmarks.test_regalloc.bench_utils import llvmlite_ir_to_riscv # lib = _load_llvm() ir = convert_onnx_to_llvm(cnn_path) @@ -215,8 +342,6 @@ def bench_allocate(cnn_path: str, phys_regs: list[str], repeats: int = 30) -> di block = block_from_machine_instrs(machine) times = [] - spill_counts = [] - # Warm up for _ in range(repeats): alloc = LinearScanAllocator(phys_regs=phys_regs) @@ -224,13 +349,15 @@ def bench_allocate(cnn_path: str, phys_regs: list[str], repeats: int = 30) -> di alloc.allocate(alloc.compute_live_intervals(block)) t1 = time.perf_counter() times.append(t1 - t0) - spill_counts.append(len(alloc._spill_slots)) # Final run for stable stats + assembly validation alloc = LinearScanAllocator(phys_regs=phys_regs) alloc.allocate(alloc.compute_live_intervals(block)) - code = alloc.get_allocated_code(block) + code = apply_abi_frames( + alloc.get_allocated_code(block), alloc.spill_slot_count + ) asm_errors = _validate_asm(code) + expected_a0 = _interpret_machine(machine) sv_cnt, sv_cats = count_riscv_instrs(code) # Greedy allocator baseline @@ -238,6 +365,11 @@ def bench_allocate(cnn_path: str, phys_regs: list[str], repeats: int = 30) -> di greedy = RegisterAllocator(machine, mode="greedy") greedy_out = greedy.run() greedy_time = time.perf_counter() - t0 + greedy_code = apply_abi_frames( + AsmEmitter(greedy_out).emit(), greedy.spill_slot_count + ) + greedy_errors = _validate_asm(greedy_code) + greedy_emu = _run_emulator(greedy_code, expected_a0) return { "mean_s": statistics.mean(times), @@ -246,8 +378,13 @@ def bench_allocate(cnn_path: str, phys_regs: list[str], repeats: int = 30) -> di "ir_inst_count": ir_count, "machine_instrs": len(machine), "vreg_count": len(alloc.alloc_map), - "reg_spill_count": spill_counts[-1], + "spill_slots": alloc.spill_slot_count, + "spill_stores": alloc.spill_store_count, + "reg_spill_count": alloc.spill_store_count, + "reloads": alloc.reload_load_count, "peak_active": alloc.peak_active, + "pressure_peak": alloc.pressure_peak, + "pressure_excess_peak": alloc.pressure_excess_peak, "asm_lines": len(code.splitlines()), "sv_static_instrs": sv_cnt, "sv_cats": sv_cats, @@ -256,8 +393,15 @@ def bench_allocate(cnn_path: str, phys_regs: list[str], repeats: int = 30) -> di "asm_valid": len(asm_errors) == 0, "greedy_time_s": greedy_time, "greedy_out_instrs": len(greedy_out), + "greedy_spill_slots": greedy.spill_slot_count, + "greedy_asm_valid": not greedy_errors, + "greedy_asm_errors": greedy_errors, + "greedy_emu_passed": greedy_emu["passed"], + "greedy_emu_error": greedy_emu.get("error", ""), "_report": alloc.report(), "_alloc": alloc, + "_assembly": code, + "expected_a0": expected_a0, } @@ -266,14 +410,22 @@ def run_bench( ) -> dict: """Entry point for the test suite runner.""" if phys_regs is None: - phys_regs = list(_INT_REGS) + phys_regs = list(ALL_REGS) stats = bench_allocate(cnn_path, phys_regs, repeats=repeats) - # Emulator verification (non-fatal) - emu = _run_emulator(cnn_path) + # Emulator verification is part of the end-to-end validity contract. + emu = _run_emulator( + stats.get("_assembly", ""), stats.get("expected_a0", 0) + ) stats["emu_passed"] = emu["passed"] stats["emu_error"] = emu.get("error", "") - stats["valid"] = stats["asm_valid"] + stats["actual_a0"] = emu.get("actual_a0") + stats["valid"] = ( + stats["asm_valid"] + and stats["emu_passed"] + and stats.get("greedy_asm_valid", True) + and stats.get("greedy_emu_passed", True) + ) # LLVM comparison (non-fatal) try: @@ -313,10 +465,10 @@ def main(): "cnn.onnx", ) - phys_regs = list(_INT_REGS) + phys_regs = list(ALL_REGS) print("=" * 60) - print("Benchmark 3 — CNN Model Integration And Comparation With LLVM Backend") + print("Benchmark 3 - CNN Model Integration And Comparison With LLVM Backend") print(f" Model: {os.path.basename(args.cnn_path)}") print("=" * 60) @@ -343,11 +495,11 @@ def main(): if not stats["asm_valid"]: for e in stats["asm_errors"][:3]: - print(f" ✗ {e}") + print(f" FAIL {e}") if not stats["emu_passed"]: - print(f" Emulator: ✗ {stats['emu_error']}") + print(f" Emulator: FAIL {stats['emu_error']}") else: - print(f" Emulator: ✓ passed") + print(" Emulator: PASS") # LLVM comparison output print() @@ -359,25 +511,33 @@ def main(): f" ScratchV LinearScan: {stats['sv_static_instrs']} instrs " f"{stats['sv_cat_buckets']}" ) - print(f" LLVM RV64IM: {stats['llvm_im_instrs']} instrs") - print( - f" LLVM RV64FD: {stats['llvm_fd_instrs']} instrs " - f"({stats['instr_ratio_fd']}x vs ScratchV) " - f"{stats['llvm_fd_cat_buckets']}" - ) + if stats["llvm_available"]: + print(f" LLVM RV64IM: {stats['llvm_im_instrs']} instrs") + print( + f" LLVM RV64FD: {stats['llvm_fd_instrs']} instrs " + f"({stats['instr_ratio_fd']}x vs ScratchV) " + f"{stats['llvm_fd_cat_buckets']}" + ) + print( + f" Spill (LLVM approx): {stats['llvm_spill_slots']} slots " + f"(frame save/restore {stats['llvm_frame_save']}/" + f"{stats['llvm_frame_restore']})" + ) + else: + print(f" LLVM: unavailable ({stats['llvm_error']})") print( - f" Spill (LLVM approx): {stats['llvm_spill_slots']} slots " - f"(frame save/restore {stats['llvm_frame_save']}/" - f"{stats['llvm_frame_restore']}); " - f"ScratchV (exact): reg_spill_count={stats['reg_spill_count']}" + " ScratchV regalloc: " + f"spill_slots={stats['spill_slots']}, " + f"spill_stores={stats['spill_stores']}, " + f"reloads={stats['reloads']}" ) - asm_ok = "PASS" if stats["asm_valid"] else "FAIL" + benchmark_ok = "PASS" if stats["valid"] else "FAIL" print( f"\n asm_valid={stats['asm_valid']}, " - f"reg_spill_count={stats['reg_spill_count']} [{asm_ok}]" + f"reg_spill_count={stats['reg_spill_count']} [{benchmark_ok}]" ) - return 0 if stats["asm_valid"] else 1 + return 0 if stats["valid"] else 1 if __name__ == "__main__": diff --git a/benchmarks/test_regalloc/bench_dense.py b/benchmarks/test_regalloc/bench_dense.py index cbf72b1..b164334 100644 --- a/benchmarks/test_regalloc/bench_dense.py +++ b/benchmarks/test_regalloc/bench_dense.py @@ -12,7 +12,9 @@ import sys import time -from scratchv.backend.regalloc_linear_v1_5 import LinearScanAllocator, LsInstruction +from scratchv.backend.regalloc_linear import LinearScanAllocator, LsInstruction +from scratchv.backend.machine_types import TEMP_REGS +from benchmarks.test_regalloc.bench_utils import validate_straight_line_allocation def _gen_block( @@ -20,7 +22,7 @@ def _gen_block( ) -> list[LsInstruction]: """Generate a high-register-pressure block.""" random.seed(seed) - ops = ["add", "sub", "mul", "and", "or", "xor", "sll", "srl"] + ops = ["add", "sub", "mul", "and", "xor"] vreg_names = [f"v{i}" for i in range(num_vregs)] insts = [] @@ -38,7 +40,7 @@ def _gen_block( ) # Phase 2: cross-reference dense ops — keeps many vregs live - for i in range(num_vregs, num_insts): + for i in range(num_vregs, num_insts - 1): src1 = random.choice(vreg_names) src2 = random.choice(vreg_names) dst = random.choice(vreg_names) @@ -52,6 +54,10 @@ def _gen_block( comment=f"dense op {i}", ) ) + answer = vreg_names[-1] + insts.append(LsInstruction( + id=len(insts), opcode="mv", operands=["a0", answer], uses={answer} + )) return insts @@ -60,45 +66,44 @@ def bench_allocate( ) -> dict: """Benchmark the full allocation pipeline under register pressure.""" times = [] - spill_counts = [] - for _ in range(repeats): alloc = LinearScanAllocator(phys_regs=phys_regs) t0 = time.perf_counter() alloc.allocate(alloc.compute_live_intervals(block)) t1 = time.perf_counter() times.append(t1 - t0) - spill_counts.append(len(alloc._spill_slots)) # Final run for stable stats alloc = LinearScanAllocator(phys_regs=phys_regs) alloc.allocate(alloc.compute_live_intervals(block)) code = alloc.get_allocated_code(block) - reloads = sum( - 1 for ln in code.splitlines() if ln.strip().startswith("lw ") and "reload" in ln - ) - + validation = validate_straight_line_allocation(block, code) return { "mean_s": statistics.mean(times), "stdev_s": statistics.stdev(times) if len(times) > 1 else 0, "vreg_count": len(alloc.alloc_map), - "spills": spill_counts[-1], - "reg_spill_count": spill_counts[-1], + "spills": alloc.spill_store_count, + "spill_slots": alloc.spill_slot_count, + "spill_stores": alloc.spill_store_count, + "reg_spill_count": alloc.spill_store_count, "peak_active": alloc.peak_active, + "pressure_peak": alloc.pressure_peak, + "pressure_excess_peak": alloc.pressure_excess_peak, "asm_lines": len(code.splitlines()), - "reloads": reloads, + "reloads": alloc.reload_load_count, "_report": alloc.report(), "_alloc": alloc, + **validation, } def run_bench(phys_regs: list[str] | None = None, repeats: int = 30) -> dict: """Entry point for the test suite runner.""" if phys_regs is None: - phys_regs = [f"r{i}" for i in range(5)] + phys_regs = list(TEMP_REGS[:5]) block = _gen_block(num_insts=80, num_vregs=30) stats = bench_allocate(block, phys_regs, repeats=repeats) - stats["valid"] = stats["spills"] > 0 + stats["valid"] = stats["spills"] > 0 and stats["execution_valid"] return stats @@ -111,7 +116,7 @@ def main(): ) args = parser.parse_args() - phys_regs = [f"r{i}" for i in range(5)] + phys_regs = list(TEMP_REGS[:5]) print("=" * 60) print("Benchmark 2 — Dense Computation (30 vregs / 5 phys regs)") diff --git a/benchmarks/test_regalloc/bench_pseudo.py b/benchmarks/test_regalloc/bench_pseudo.py new file mode 100644 index 0000000..da85e2c --- /dev/null +++ b/benchmarks/test_regalloc/bench_pseudo.py @@ -0,0 +1,356 @@ +"""Benchmark every RV32IM pseudo supported by the register-allocation path.""" + +from __future__ import annotations + +import argparse +import statistics +import time +from dataclasses import asdict, dataclass + +from scratchv.backend.machine_types import ( + ALL_REGS, + MachineInstr, + MachineOp, + MachineOperand, +) +from scratchv.backend.regalloc_linear import ( + LinearScanAllocator, + block_from_machine_instrs, +) +from scratchv.backend.riscv_encoder import RISCVAEncoder +from scratchv.simulator.tinyfive import ProfiledMachine + + +BENCHMARKED_MACHINE_PSEUDOS = frozenset( + { + MachineOp.MV, + MachineOp.LI, + MachineOp.MAX, + MachineOp.BNEZ, + MachineOp.J, + MachineOp.CALL, + MachineOp.LABEL, + } +) +BENCHMARKED_ASSEMBLER_PSEUDOS = frozenset({"nop", "ret"}) + + +@dataclass(frozen=True) +class MachinePseudoCase: + """One allocation, encoding, and execution case for a machine pseudo.""" + + name: str + opcode: MachineOp + instructions: tuple[MachineInstr, ...] + expected_a0: int + instruction_limit: int = 32 + + +@dataclass(frozen=True) +class AssemblerPseudoCase: + """One encoding and execution case for an assembler-only pseudo.""" + + name: str + assembly: str + expected_a0: int + instruction_limit: int = 32 + + +@dataclass(frozen=True) +class PseudoCaseResult: + """Serializable result for one pseudo benchmark case.""" + + name: str + opcode: str + layer: str + mean_s: float + stdev_s: float + encoded_instructions: int + virtual_registers: int + spill_slots: int + spill_stores: int + reloads: int + pressure_peak: int + expected_a0: int + actual_a0: int + assembly: str + + @property + def valid(self) -> bool: + """Return whether execution and allocation invariants hold.""" + + return ( + self.actual_a0 == self.expected_a0 + and self.spill_slots == 0 + and self.spill_stores == 0 + and self.reloads == 0 + and "%" not in self.assembly + ) + + def to_dict(self) -> dict[str, object]: + """Return JSON-safe metrics without embedding the full assembly.""" + + result = asdict(self) + result.pop("assembly") + result["valid"] = self.valid + return result + + +def _done_loop() -> tuple[MachineInstr, ...]: + return ( + MachineInstr(MachineOp.LABEL, comment=".done"), + MachineInstr(MachineOp.J, comment=".done"), + ) + + +def machine_pseudo_cases() -> tuple[MachinePseudoCase, ...]: + """Return one executable Machine IR case for every supported pseudo.""" + + v = MachineOperand.vreg + reg = MachineOperand.reg + imm = MachineOperand.immediate + return ( + MachinePseudoCase( + "mv", + MachineOp.MV, + ( + MachineInstr(MachineOp.LI, v("source"), imm(42)), + MachineInstr(MachineOp.MV, v("copy"), v("source")), + MachineInstr(MachineOp.MV, reg("a0"), v("copy")), + *_done_loop(), + ), + 42, + ), + MachinePseudoCase( + "li", + MachineOp.LI, + ( + MachineInstr(MachineOp.LI, v("constant"), imm(0x12345)), + MachineInstr(MachineOp.MV, reg("a0"), v("constant")), + *_done_loop(), + ), + 0x12345, + ), + MachinePseudoCase( + "max", + MachineOp.MAX, + ( + MachineInstr(MachineOp.LI, v("left"), imm(-4)), + MachineInstr(MachineOp.LI, v("right"), imm(-2)), + MachineInstr(MachineOp.MAX, v("result"), v("left"), v("right")), + MachineInstr(MachineOp.MV, reg("a0"), v("result")), + *_done_loop(), + ), + -2, + ), + MachinePseudoCase( + "bnez", + MachineOp.BNEZ, + ( + MachineInstr(MachineOp.LI, v("condition"), imm(1)), + MachineInstr(MachineOp.BNEZ, v("condition"), comment=".taken"), + MachineInstr(MachineOp.LI, reg("a0"), imm(1)), + MachineInstr(MachineOp.J, comment=".done"), + MachineInstr(MachineOp.LABEL, comment=".taken"), + MachineInstr(MachineOp.LI, reg("a0"), imm(2)), + *_done_loop(), + ), + 2, + ), + MachinePseudoCase( + "j", + MachineOp.J, + ( + MachineInstr(MachineOp.LI, reg("a0"), imm(0)), + MachineInstr(MachineOp.J, comment=".target"), + MachineInstr(MachineOp.LI, reg("a0"), imm(1)), + MachineInstr(MachineOp.LABEL, comment=".target"), + MachineInstr(MachineOp.LI, reg("a0"), imm(2)), + *_done_loop(), + ), + 2, + ), + MachinePseudoCase( + "call", + MachineOp.CALL, + ( + MachineInstr(MachineOp.LI, reg("a0"), imm(1)), + MachineInstr(MachineOp.CALL, comment=".callee"), + MachineInstr(MachineOp.ADDI, reg("a0"), reg("a0"), imm(10)), + MachineInstr(MachineOp.J, comment=".done"), + MachineInstr(MachineOp.LABEL, comment=".callee"), + MachineInstr(MachineOp.ADDI, reg("a0"), reg("a0"), imm(2)), + MachineInstr(MachineOp.JALR, reg("zero"), reg("ra"), imm(0)), + *_done_loop(), + ), + 13, + ), + MachinePseudoCase( + "label", + MachineOp.LABEL, + ( + MachineInstr(MachineOp.LABEL, comment=".entry"), + MachineInstr(MachineOp.LI, reg("a0"), imm(17)), + *_done_loop(), + ), + 17, + ), + ) + + +def assembler_pseudo_cases() -> tuple[AssemblerPseudoCase, ...]: + """Return executable cases for pseudos accepted only by the encoder.""" + + return ( + AssemblerPseudoCase( + "nop", + "li a0, 41\nnop\naddi a0, a0, 1\n.done:\nj .done", + 42, + ), + AssemblerPseudoCase( + "ret", + ( + "li a0, 1\n" + "jal ra, .callee\n" + "addi a0, a0, 10\n" + "j .done\n" + ".callee:\n" + "addi a0, a0, 2\n" + "ret\n" + ".done:\n" + "j .done" + ), + 13, + ), + ) + + +def _execute(binary: bytes, instruction_limit: int) -> int: + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=4096) + if not machine.available: + raise RuntimeError("TinyFive is required for pseudo benchmark execution") + machine.load_binary(words, origin=0) + machine.run(instructions=instruction_limit, start=0, strict=True) + return machine.get_reg(10) # a0 + + +def _run_machine_case(case: MachinePseudoCase, repeats: int) -> PseudoCaseResult: + times: list[float] = [] + final: tuple[LinearScanAllocator, str, bytes, int] | None = None + for _ in range(repeats): + allocator = LinearScanAllocator(phys_regs=list(ALL_REGS)) + block = block_from_machine_instrs(list(case.instructions)) + start = time.perf_counter() + intervals = allocator.compute_live_intervals(block) + allocator.allocate(intervals) + assembly = allocator.get_allocated_code(block) + binary = bytes(RISCVAEncoder().assemble(assembly)) + times.append(time.perf_counter() - start) + final = allocator, assembly, binary, len(intervals) + + assert final is not None + allocator, assembly, binary, virtual_registers = final + return PseudoCaseResult( + name=case.name, + opcode=case.opcode.value, + layer="machine", + mean_s=statistics.mean(times), + stdev_s=statistics.stdev(times) if len(times) > 1 else 0.0, + encoded_instructions=len(binary) // 4, + virtual_registers=virtual_registers, + spill_slots=allocator.spill_slot_count, + spill_stores=allocator.spill_store_count, + reloads=allocator.reload_load_count, + pressure_peak=allocator.pressure_peak, + expected_a0=case.expected_a0, + actual_a0=_execute(binary, case.instruction_limit), + assembly=assembly, + ) + + +def _run_assembler_case( + case: AssemblerPseudoCase, + repeats: int, +) -> PseudoCaseResult: + times: list[float] = [] + binary = b"" + for _ in range(repeats): + start = time.perf_counter() + binary = bytes(RISCVAEncoder().assemble(case.assembly)) + times.append(time.perf_counter() - start) + + return PseudoCaseResult( + name=case.name, + opcode=case.name, + layer="assembler", + mean_s=statistics.mean(times), + stdev_s=statistics.stdev(times) if len(times) > 1 else 0.0, + encoded_instructions=len(binary) // 4, + virtual_registers=0, + spill_slots=0, + spill_stores=0, + reloads=0, + pressure_peak=0, + expected_a0=case.expected_a0, + actual_a0=_execute(binary, case.instruction_limit), + assembly=case.assembly, + ) + + +def run_bench(repeats: int = 30) -> dict[str, object]: + """Benchmark and execute every supported RV32IM pseudo case.""" + + if repeats < 1: + raise ValueError("repeats must be at least 1") + results = [ + *(_run_machine_case(case, repeats) for case in machine_pseudo_cases()), + *(_run_assembler_case(case, repeats) for case in assembler_pseudo_cases()), + ] + all_times = [result.mean_s for result in results] + return { + "mean_s": statistics.mean(all_times), + "stdev_s": statistics.stdev(all_times) if len(all_times) > 1 else 0.0, + "vreg_count": sum(result.virtual_registers for result in results), + "spills": sum(result.spill_stores for result in results), + "spill_slots": sum(result.spill_slots for result in results), + "spill_stores": sum(result.spill_stores for result in results), + "reg_spill_count": sum(result.spill_stores for result in results), + "reloads": sum(result.reloads for result in results), + "peak_active": max(result.pressure_peak for result in results), + "pressure_peak": max(result.pressure_peak for result in results), + "pressure_excess_peak": 0, + "asm_lines": sum(result.encoded_instructions for result in results), + "case_count": len(results), + "machine_pseudos": sorted(op.value for op in BENCHMARKED_MACHINE_PSEUDOS), + "assembler_pseudos": sorted(BENCHMARKED_ASSEMBLER_PSEUDOS), + "cases": [result.to_dict() for result in results], + "valid": all(result.valid for result in results), + "_case_results": results, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="RV32IM pseudo benchmark") + parser.add_argument("--repeats", type=int, default=30) + args = parser.parse_args() + stats = run_bench(repeats=args.repeats) + + print("Pseudo Layer Mean(ms) Encoded Spill Result") + print("-" * 58) + for result in stats["_case_results"]: + status = "PASS" if result.valid else "FAIL" + print( + f"{result.name:<7} {result.layer:<11} " + f"{result.mean_s * 1000:>8.3f} " + f"{result.encoded_instructions:>8} " + f"{result.spill_stores:>6} {status}" + ) + return 0 if stats["valid"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/test_regalloc/bench_regalloc_linear.py b/benchmarks/test_regalloc/bench_regalloc_linear.py index 003f21f..582d562 100644 --- a/benchmarks/test_regalloc/bench_regalloc_linear.py +++ b/benchmarks/test_regalloc/bench_regalloc_linear.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run all 3 register allocation benchmarks and produce a report.""" +"""Run all register allocation benchmarks and produce a report.""" import argparse import datetime @@ -9,7 +9,7 @@ import time -from benchmarks.test_regalloc import bench_simple, bench_dense, bench_cnn +from benchmarks.test_regalloc import bench_cnn, bench_dense, bench_pseudo, bench_simple # --------------------------------------------------------------------------- @@ -23,7 +23,7 @@ def _make_html(results: dict, total_time: float) -> str: for name, r in results.items(): if not isinstance(r, dict): continue - v = "✓" if r.get("valid", True) else "✗" + v = "PASS" if r.get("valid", True) else "FAIL" c = "#22863a" if r.get("valid", True) else "#cb2431" ms = f"{r.get('mean_s', 0) * 1000:.3f}" sd = f"{r.get('stdev_s', 0) * 1000:.3f}" @@ -80,7 +80,7 @@ def _make_markdown(results: dict) -> str: continue ms = f"{r.get('mean_s', 0) * 1000:.3f}" sd = f"{r.get('stdev_s', 0) * 1000:.3f}" - v = "✓" if r.get("valid", True) else "✗" + v = "PASS" if r.get("valid", True) else "FAIL" lines.append( f"| {name} | {ms} | {sd} | {r.get('vreg_count', '-')} | " f"{r.get('reg_spill_count', r.get('spills', '-'))} | " @@ -105,7 +105,7 @@ def main(): args = parser.parse_args() print("=" * 60) - print(" ScratchV — Register Allocation Benchmark Suite") + print(" ScratchV - Register Allocation Benchmark Suite") print("=" * 60) t0 = time.perf_counter() @@ -117,7 +117,7 @@ def main(): print( f" 1. Simple: reg_spill_count={r1['reg_spill_count']}, " f"mean={r1['mean_s'] * 1000:.3f}ms " - f"{'✓' if r1.get('valid') else '✗'}" + f"{'PASS' if r1.get('valid') else 'FAIL'}" ) # Benchmark 2 — Dense (spill) @@ -126,7 +126,7 @@ def main(): print( f" 2. Dense: reg_spill_count={r2['reg_spill_count']}, " f"mean={r2['mean_s'] * 1000:.3f}ms " - f"{'✓' if r2.get('valid') else '✗'}" + f"{'PASS' if r2.get('valid') else 'FAIL'}" ) # Benchmark 3 — CNN Integration And Comparation With LLVM @@ -136,7 +136,15 @@ def main(): print( f" 3. CNN: reg_spill_count={r3['reg_spill_count']}, " f"mean={r3['mean_s'] * 1000:.3f}ms " - f"{'✓' if r3.get('valid') else '✗'}" + f"{'PASS' if r3.get('valid') else 'FAIL'}" + ) + + r4 = bench_pseudo.run_bench(repeats=args.repeats) + results["4. Pseudo Instructions"] = r4 + print( + f" 4. Pseudo: cases={r4['case_count']}, " + f"mean={r4['mean_s'] * 1000:.3f}ms " + f"{'PASS' if r4.get('valid') else 'FAIL'}" ) total_time = time.perf_counter() - t0 @@ -161,17 +169,17 @@ def main(): if isinstance(r, dict) }, } - with open(args.output_json, "w") as f: - json.dump(report, f, indent=2) + with open(args.output_json, "w", encoding="utf-8") as f: + json.dump(report, f, indent=2, ensure_ascii=False) print(f"\n JSON report: {args.output_json}") if args.output_html: - with open(args.output_html, "w") as f: + with open(args.output_html, "w", encoding="utf-8") as f: f.write(_make_html(results, total_time)) print(f" HTML report: {args.output_html}") if args.output_md: - with open(args.output_md, "w") as f: + with open(args.output_md, "w", encoding="utf-8") as f: f.write(_make_markdown(results)) print(f" Markdown: {args.output_md}") diff --git a/benchmarks/test_regalloc/bench_simple.py b/benchmarks/test_regalloc/bench_simple.py index fcfbe16..81db43d 100644 --- a/benchmarks/test_regalloc/bench_simple.py +++ b/benchmarks/test_regalloc/bench_simple.py @@ -12,7 +12,9 @@ import sys import time -from scratchv.backend.regalloc_linear_v1_5 import LinearScanAllocator, LsInstruction +from scratchv.backend.regalloc_linear import LinearScanAllocator, LsInstruction +from scratchv.backend.machine_types import TEMP_REGS +from benchmarks.test_regalloc.bench_utils import validate_straight_line_allocation def _gen_block( @@ -20,38 +22,33 @@ def _gen_block( ) -> list[LsInstruction]: """Generate a basic block with simple arithmetic using few vregs.""" random.seed(seed) - ops = ["add", "sub", "mul", "and", "or"] + ops = ["add", "sub", "mul", "and", "xor"] vreg_names = [f"v{i}" for i in range(num_vregs)] insts = [] - for i in range(num_insts): - if i < num_vregs: - dst = vreg_names[i] - pool = vreg_names[: max(i, 1)] - src1 = random.choice(pool) - src2 = random.choice(pool) - insts.append( - LsInstruction( - id=i, - opcode=random.choice(ops), - operands=[dst, src1, src2], - defines={dst}, - uses={src1, src2}, - ) - ) - else: - dst = random.choice(vreg_names) - src1 = random.choice(vreg_names) - src2 = random.choice(vreg_names) - insts.append( - LsInstruction( - id=i, - opcode=random.choice(ops), - operands=[dst, src1, src2], - defines={dst}, - uses={src1, src2} - {dst}, - ) - ) + for i, dst in enumerate(vreg_names): + insts.append(LsInstruction( + id=i, + opcode="addi", + operands=[dst, "zero", str(random.randint(1, 100))], + defines={dst}, + uses=set(), + )) + for i in range(num_vregs, max(num_vregs, num_insts - 1)): + dst = random.choice(vreg_names) + src1 = random.choice(vreg_names) + src2 = random.choice(vreg_names) + insts.append(LsInstruction( + id=i, + opcode=random.choice(ops), + operands=[dst, src1, src2], + defines={dst}, + uses={src1, src2}, + )) + answer = vreg_names[-1] + insts.append(LsInstruction( + id=len(insts), opcode="mv", operands=["a0", answer], uses={answer} + )) return insts @@ -60,41 +57,45 @@ def bench_allocate( ) -> dict: """Benchmark the full allocation pipeline.""" times = [] - spill_counts = [] - for _ in range(repeats): alloc = LinearScanAllocator(phys_regs=phys_regs) t0 = time.perf_counter() alloc.allocate(alloc.compute_live_intervals(block)) t1 = time.perf_counter() times.append(t1 - t0) - spill_counts.append(len(alloc._spill_slots)) # One final run for stable stats alloc = LinearScanAllocator(phys_regs=phys_regs) alloc.allocate(alloc.compute_live_intervals(block)) code = alloc.get_allocated_code(block) + validation = validate_straight_line_allocation(block, code) return { "mean_s": statistics.mean(times), "stdev_s": statistics.stdev(times) if len(times) > 1 else 0, "vreg_count": len(alloc.alloc_map), - "spills": spill_counts[-1], - "reg_spill_count": spill_counts[-1], + "spills": alloc.spill_store_count, + "spill_slots": alloc.spill_slot_count, + "spill_stores": alloc.spill_store_count, + "reg_spill_count": alloc.spill_store_count, + "reloads": alloc.reload_load_count, "peak_active": alloc.peak_active, + "pressure_peak": alloc.pressure_peak, + "pressure_excess_peak": alloc.pressure_excess_peak, "asm_lines": len(code.splitlines()), "_report": alloc.report(), "_alloc": alloc, + **validation, } def run_bench(phys_regs: list[str] | None = None, repeats: int = 50) -> dict: """Entry point for the test suite runner.""" if phys_regs is None: - phys_regs = [f"r{i}" for i in range(8)] + phys_regs = list(TEMP_REGS) block = _gen_block(num_insts=10, num_vregs=5) stats = bench_allocate(block, phys_regs, repeats=repeats) - stats["valid"] = stats["spills"] == 0 + stats["valid"] = stats["spills"] == 0 and stats["execution_valid"] return stats @@ -107,7 +108,7 @@ def main(): ) args = parser.parse_args() - phys_regs = [f"r{i}" for i in range(8)] + phys_regs = list(TEMP_REGS) print("=" * 60) print("Benchmark 1 — Simple Arithmetic (5 vregs / 8 phys regs)") diff --git a/benchmarks/test_regalloc/bench_utils.py b/benchmarks/test_regalloc/bench_utils.py index 922040c..6d6e494 100644 --- a/benchmarks/test_regalloc/bench_utils.py +++ b/benchmarks/test_regalloc/bench_utils.py @@ -194,6 +194,67 @@ def _op_categories(cats: dict[str, int]) -> dict[str, int]: _llvmlite_ready = False +def validate_straight_line_allocation(block, assembly: str) -> dict: + """Encode and execute a generated straight-line allocator benchmark. + + The input block is interpreted independently to obtain the expected + ``a0`` value. This prevents a benchmark from reporting PASS merely + because spill counters happen to match expectations. + """ + from scratchv.backend.riscv_encoder import RISCVAEncoder + from scratchv.simulator.tinyfive import ProfiledMachine + + values: dict[str, int] = {"zero": 0, "x0": 0} + + def value(name: str) -> int: + try: + return int(name, 0) + except ValueError: + if name not in values: + raise ValueError(f"undefined benchmark value: {name}") + return values[name] + + for inst in block: + operands = inst.operands + if inst.opcode == "addi": + values[operands[0]] = value(operands[1]) + value(operands[2]) + elif inst.opcode == "add": + values[operands[0]] = value(operands[1]) + value(operands[2]) + elif inst.opcode == "sub": + values[operands[0]] = value(operands[1]) - value(operands[2]) + elif inst.opcode == "mul": + values[operands[0]] = value(operands[1]) * value(operands[2]) + elif inst.opcode == "and": + values[operands[0]] = value(operands[1]) & value(operands[2]) + elif inst.opcode == "xor": + values[operands[0]] = value(operands[1]) ^ value(operands[2]) + elif inst.opcode == "mv": + values[operands[0]] = value(operands[1]) + else: + raise ValueError(f"unsupported benchmark opcode: {inst.opcode}") + values[operands[0]] &= 0xFFFFFFFF + + program = "li sp, 8192\n" + assembly + "\n.done:\nj .done" + binary = bytes(RISCVAEncoder().assemble(program)) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=16384) + if not machine.available: + raise RuntimeError("TinyFive is required for benchmark validation") + machine.load_binary(words, origin=0) + machine.run(instructions=len(words) + 2, start=0, strict=True) + expected = values.get("a0", 0) & 0xFFFFFFFF + actual = machine.get_reg(10) & 0xFFFFFFFF + return { + "execution_valid": actual == expected, + "expected_a0": expected, + "actual_a0": actual, + "encoded_instructions": len(binary) // 4, + } + + def llvmlite_ir_to_riscv( ir_text: str, features: str = "", diff --git a/benchmarks/test_regalloc/regalloc.md b/benchmarks/test_regalloc/regalloc.md index f22a995..21e1b12 100644 --- a/benchmarks/test_regalloc/regalloc.md +++ b/benchmarks/test_regalloc/regalloc.md @@ -6,13 +6,13 @@ **设计目标**: -- **正确性**:验证无溢出 / 有溢出两种场景的分配结果合法(无未解析 vreg、有效 opcode) +- **正确性**:验证无溢出 / 有溢出场景及每条受支持伪指令的分配、编码和执行结果 - **性能**:测量分配耗时(均值 / 标准差)、活跃区间峰值压力 - **可对比**:每项输出统一的 `reg_spill_count` 指标,支持回归对比和筛选 - **跨后端对比**:同一 ONNX 模型经 ScratchV 和 LLVM 两条路径编译,对比静态指令数、opcode 类别分布、溢出/帧操作数量 入口:`benchmarks/test_regalloc/bench_regalloc_linear.py` -单文件运行:直接执行 `bench_simple.py` / `bench_dense.py` / `bench_cnn.py` +单文件运行:直接执行 `bench_simple.py` / `bench_dense.py` / `bench_cnn.py` / `bench_pseudo.py` --- @@ -25,7 +25,8 @@ benchmarks/test_regalloc/ ├── bench_simple.py Benchmark 1 — 无溢出正确性 ├── bench_dense.py Benchmark 2 — 溢出正确性 ├── bench_cnn.py Benchmark 3 — CNN 集成 + LLVM 对比 -└── bench_regalloc_linear.py 运行器:汇总 3 路输出 → JSON / HTML / Markdown 报告 +├── bench_pseudo.py Benchmark 4 — 每条伪指令的分配、编码与模拟器执行 +└── bench_regalloc_linear.py 运行器:汇总 4 路输出 → JSON / HTML / Markdown 报告 ``` ### 2.1 统一接口 @@ -37,7 +38,7 @@ def run_bench(...) -> dict: """返回统一结构的统计 dict,必需键见 §4。""" ``` -运行器遍历三个 `run_bench()`,收集 dict 生成报告。 +运行器遍历四个 `run_bench()`,收集 dict 生成报告。 ### 2.2 数据流 @@ -66,7 +67,7 @@ def run_bench(...) -> dict: --- -## 3. 三项 Benchmark +## 3. 四项 Benchmark ### 3.1 Benchmark 1 — 简单算术(无溢出) @@ -123,13 +124,25 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) |------|-----| | 模型 | `models/graph/cnn.onnx`(可 CLI 覆盖) | | IR 指令 | 17 条(3×conv + 3×relu + 3×maxpool + 2×gemm + sigmoid + 2×reshape) | -| 物理寄存器 | `_INT_REGS`(28 个) | +| 物理寄存器 | `_INT_REGS`(19 个:`t0`–`t6`、`s0`–`s11`) | | ScratchV 输出 | ~57 条伪指令(mv/mul/add/slt/bnez…) | | LLVM 输出 | ~1099 条(RV64FD O2,真实循环展开) | -| 断言 | `asm_valid == True` | +| 断言 | `asm_valid == True` 且 `emu_passed == True` | > **注意**:ScratchV 侧输出 57 条**伪指令**(conv/maxpool 等语义级操作由仿真器实现),LLVM 侧输出 1099 条**自包含机器指令**(每个 conv 展开为 5 重嵌套循环的完整 RISC-V 指令序列)。`instr_ratio_fd ≈ 23.89x` 反映的是抽象层级差异而非优化能力差距,因此还提供 opcode **类别分布**作为跨层级可比指标。 +### 3.4 Benchmark 4 — 伪指令端到端覆盖 + +**文件**:`bench_pseudo.py` +**目的**:为每条当前支持的 RV32IM 伪指令建立独立 case,并验证寄存器分配、伪指令展开、编码及 TinyFive 执行结果。 + +| 层级 | 覆盖指令 | 校验 | +|------|----------|------| +| Machine IR | `mv/li/max/bnez/j/call/label` | 分配后无 vreg,编码成功,执行结果符合预期,无意外 spill/reload | +| Encoder | `nop/ret` | 展开、编码成功,执行结果符合预期 | + +浮点扩展伪指令 `fabs.d/fneg.d/li.d/fmv.s` 当前不属于 RV32IM 编码器支持范围,单测验证其会被明确拒绝。 + --- ## 4. 指标规范 @@ -141,19 +154,20 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) | `mean_s` | `float` | `perf_counter` 均值 | 单次分配耗时(秒) | | `stdev_s` | `float` | `stdev` | 耗时标准差 | | `vreg_count` | `int` | `len(alloc.alloc_map)` | 已分配的虚拟寄存器数 | -| `spills` | `int` | `len(alloc._spill_slots)` | 溢出 slot 数(别名) | -| `reg_spill_count` | `int` | 同上 | **统一溢出指标键**(接口规范) | -| `peak_active` | `int` | `alloc.peak_active` | 峰值同时活跃的物理寄存器数 | +| `spills` | `int` | `alloc.spill_store_count` | 静态 spill store 数(兼容键) | +| `spill_slots` | `int` | `alloc.spill_slot_count` | 分配的唯一栈槽数(公共只读接口) | +| `spill_stores` | `int` | `alloc.spill_store_count` | 生成汇编中的静态 spill store 数 | +| `reg_spill_count` | `int` | 同上 | **统一溢出事件指标键**(接口规范) | +| `reloads` | `int` | `alloc.reload_load_count` | 生成汇编中的静态 reload load 数 | +| `peak_active` | `int` | `alloc.peak_active` | 分配过程中映射到物理寄存器的峰值数 | +| `pressure_peak` | `int` | live interval 精确重叠扫描 | 峰值同时活跃的虚拟寄存器数 | +| `pressure_excess_peak` | `int` | `max(0, pressure_peak - 物理寄存器数)` | 峰值理论超额压力 | | `asm_lines` | `int` | `len(code.splitlines())` | 汇编输出行数 | | `valid` | `bool` | 由 `run_bench()` 设置 | 该项是否通过断言 | ### 4.2 Benchmark 特有键 -**bench_dense**: - -| 键 | 说明 | -|----|------| -| `reloads` | `lw ... # reload` 注释行数 | +**bench_dense**:使用上述通用的 `spill_slots`、`spill_stores`、`reloads` 和压力指标,不再把栈槽数与静态溢出事件混为一谈。 **bench_cnn (ScratchV 侧)**: @@ -167,6 +181,7 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) | `sv_cat_buckets` | `_op_categories()` | 6 类汇总(ALU/Load/Store/Branch/Mul/Other) | | `asm_errors` | `_validate_asm()` | 未解析 vreg / 未知 opcode 列表 | | `asm_valid` | `len(asm_errors) == 0` | 汇编合法性 | +| `emu_passed` | `_run_emulator()` | RV32 模拟器是否完整执行成功 | | `greedy_time_s` | Greedy allocator | Greedy分配器耗时(baseline) | | `greedy_out_instrs` | — | Greedy分配器输出指令数 | @@ -185,10 +200,12 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) ### 4.3 `reg_spill_count` 规范 -- **经过 regalloc 的路径**:直接取自 `alloc._spill_slots` 长度 → 精确值 +- **经过 regalloc 的路径**:取生成汇编中的静态 spill store 数;`spill_slots` 与 `reloads` 分开报告 - **不经过 regalloc 的路径**:LLVM 侧 `reg_spill_count` 是本路径的 ScratchV 精确值(0);LLVM 近似溢出独立为 `llvm_spill_slots`,不污染统一键 - **降级路径**:当 libLLVM 不可用时,`llvm_fd_instrs`/`llvm_spill_slots` 等键不存在于 dict 中,报告渲染 fallback 到 `"-"` +`reg_spill_count` 是静态代码中的 store site 数,不是运行时执行次数。循环内一次静态 spill 可能动态执行多次;要得到动态计数,需要在可执行仿真器中按运行轨迹统计。 + --- ## 5. LLVM 溢出统计 @@ -268,18 +285,19 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) ## 7. 使用方式 ```bash -# 运行全部 3 项 benchmark,生成三格式报告 -python -m benchmarks.test_regalloc.bench_regalloc_linear \ - --repeats 30 \ - --output-json report.json \ - --output-html report.html \ +# 运行全部 4 项 benchmark,生成三格式报告(Windows PowerShell) +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_regalloc_linear ` + --repeats 30 ` + --output-json report.json ` + --output-html report.html ` --output-md report.md # 单独运行某项 -python -m benchmarks.test_regalloc.bench_simple --repeats 100 -python -m benchmarks.test_regalloc.bench_dense --repeats 50 -python -m benchmarks.test_regalloc.bench_cnn \ +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_simple --repeats 100 +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_dense --repeats 50 +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_cnn ` --cnn-path models/graph/cnn.onnx --repeats 30 +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_pseudo --repeats 30 ``` --- diff --git "a/docs/topic17_AI\350\207\252\345\256\241\346\212\245\345\221\212.md" "b/docs/topic17_AI\350\207\252\345\256\241\346\212\245\345\221\212.md" new file mode 100644 index 0000000..cd2ee2b --- /dev/null +++ "b/docs/topic17_AI\350\207\252\345\256\241\346\212\245\345\221\212.md" @@ -0,0 +1,83 @@ +# Topic17 AI 自审报告:伪指令、寄存器泄露与执行正确性 + +## 1. 审查结论 + +本轮没有仅依靠“汇编文本看起来合理”作结论,而是按“语义表 → 分配 → spill/reload 重写 → 真实编码 → 执行结果”逐层验证。自审发现并修复了 5 类真实问题,其中最重要的是高压力 CFG 在 join 处读取错误寄存器,以及 TinyFive 验证适配层的 `LW` 只返回低 8 位。最终全量测试为 `555 passed`。 + +当前可以确认支持的整数伪指令范围是: + +| 伪指令 | 分配语义 | 真实指令转换 | 验证 | +|---|---|---|---| +| `mv rd, rs` | `rd=def`,`rs=use` | `addi rd, rs, 0` | 编码等价 + TinyFive 执行 | +| `li rd, imm` | `rd=def`,立即数不占寄存器 | 小立即数 `addi`;大立即数 `lui`/`addi` | RV32 边界执行 | +| `max rd, rs1, rs2` | `rd=def`,两源为 use;右侧仅允许寄存器或立即数 0 | `bge` + 两条 copy + `j` | 双路径、负数、源/目标重叠 | +| `bnez rs, label` | `rs=use`,terminator | `bne rs, x0, label` | 编码等价 + taken/not-taken | +| `j label` | 无寄存器,terminator | `jal x0, label` | 编码等价 + 执行 | +| `call label` | 隐式定义 `ra`,caller-saved clobber | 本地目标 `jal ra, label` | 编码等价 + ABI spill 检查 | +| label | 无 def/use | 真实汇编标签 | CFG/编码 | + +浮点伪指令目前只有分配语义元数据,不属于 RV32IM encoder 的可执行支持范围;本报告不把它们描述成“已经完整支持”。 + +## 2. 支持方式 + +### 2.1 单一语义来源 + +`scratchv/backend/machine_semantics.py` 为每个 `MachineOp` 显式记录 operand 的 def/use、立即数位置、控制流属性、隐式寄存器和 ABI clobber。两个 linear-scan 与 greedy 路径都读取同一份语义,避免对 `dst/src1/src2` 字段名进行猜测。模块还会检查是否有新增 opcode 漏填语义。 + +### 2.2 CFG 与活跃性 + +`scratchv/backend/regalloc_cfg.py` 按标签、条件分支、直接跳转和 fallthrough 恢复基本块,再迭代计算 `live_in/live_out`。当前仍使用保守的单段 live interval,不利用 lifetime hole,但不会因为值只在后继块使用就过早释放。 + +### 2.3 可执行 spill/reload + +`scratchv/backend/regalloc_rewrite.py` 同时跟踪 vreg 所在寄存器、寄存器 owner 和栈槽是否包含最新值。所有 source 先完成 materialize,再选择 destination。目标可以复用 source 寄存器;若 source 后续仍活跃,则先 `sw` 保存旧值。高压力 CFG 对 edge-live 值使用固定栈槽作为前驱边之间的共同位置,避免路径相关状态泄露到 join。 + +### 2.4 ABI 与真实编码 + +call 前仅保存 call 后仍活跃且位于 caller-saved 寄存器的值,call 后使对应 resident 映射失效。`scratchv/backend/riscv_encoder.py` 将整数伪指令展开成 RV32IM 机器指令,严格拒绝未知寄存器、未定义目标、`max` 非零立即数,以及没有空闲临时寄存器时的 branch-immediate 展开,避免静默覆盖活值。 + +## 3. 自审发现的问题与修复 + +1. **TinyFive `LW` 验证假失败**:依赖包在当前 NumPy 上对 `uint8` 移位会截断高 24 位。适配层现在绑定兼容 `LW`,直接按 little-endian signed i32 读取四字节,并增加 `0x12345678` store/load 回归。 +2. **双源 reload 冲突**:两个 spilled source 可能被装进同一物理寄存器。现在先保护全部 source,物理池不足时明确失败。 +3. **destination 复用仍存活 source**:两寄存器机器上,三操作数指令必须允许 rd 与某个 source 重叠。现在在覆盖前保存旧 source,再执行指令,后续按需 reload。 +4. **CFG join 错误映射**:某个分支上的定义曾被放到临时空闲寄存器,而 join 按全局映射读取另一个寄存器。现在定义保持全局 assignment,高压力 CFG 的 edge-live 值在每条前驱边规范化到栈槽;栈槽状态在块入口重新建立,不跨源代码顺序继承。 +5. **伪指令展开冲突/静默 clobber**:`max` 的内部标签可能和用户标签重名;branch-immediate 在所有 `t0`–`t6` 已使用时曾回退覆盖 `t6`。现在内部标签避让用户标签,无可用 scratch 时明确报错。 + +同时修复了同一 vreg 的纯重定义被误判为“需要保存旧值”的问题,保证 CNN 在 pressure peak 11、19 个物理寄存器时仍为 0 spill。 + +## 4. 寄存器泄露验证 + +这里的“泄露”指分配完成后仍出现 vreg 名称,而不是内存资源泄漏。验证采用三层防线: + +1. 对 `input_tensor`、`maximum_value` 等不符合 `v0` 正则的任意名称做 token 级检查,防止只检查 `%` 或 `v\d+` 漏报; +2. 检查 greedy 输出的每个 operand,不允许 `kind == "vreg"`; +3. 所有可执行汇编交给严格 encoder。未知寄存器不会被默认为某个物理寄存器,而是直接报错。 + +当前专项用例未发现分配后 vreg 泄露。 + +## 5. 验证矩阵与结果 + +- 伪指令与手写真实指令的二进制等价:`mv`、小/大 `li`、`bnez`、`j`、本地 `call`; +- TinyFive 执行:`mv`、RV32 全范围边界 `li`、`max` 双路径/负数/源目标别名、分支 taken/not-taken; +- 24 个随机直线程序:12 个固定 seed × 两个 linear-scan 版本,每个程序包含 6 个常量与 24 个随机 `add/sub/xor/and`,结果与 Python 的 RV32 模 2^32 参考语义一致; +- CFG 执行差分:两种分配器 × taken/not-taken,在两寄存器压力下结果均与参考一致; +- benchmark 对齐: + +| 场景 | 物理寄存器 | pressure peak | excess | spill slots | spill stores | reloads | 汇编有效 | +|---|---:|---:|---:|---:|---:|---:|---| +| Simple | 8 | 5 | 0 | 0 | 0 | 0 | 未执行编码(合成压力 IR) | +| Dense | 5 | 29 | 24 | 28 | 63 | 75 | 未执行编码(合成压力 IR) | +| CNN | 19 | 11 | 0 | 0 | 0 | 0 | 是 | + +全量:`555 passed`,`git diff --check` 无 whitespace error。 + +## 6. 尚未过度承诺的边界 + +- TinyFive 执行使用 ScratchV 自己的 encoder,能验证分配与模拟执行,但不是完全独立的工具链 oracle;合入前应再用 GNU RISC-V assembler/objdump 与 Spike 或 QEMU 做交叉验证。 +- `call` 仅支持 flat binary 内的本地 JAL 范围目标;外部符号、远调用和 relocation 未实现。 +- `max` 的立即数右操作数目前只支持 0,非零立即数会明确报错。 +- 浮点伪指令只有 def/use 元数据,RV32IM encoder/TinyFive 路径未证明其真实编码与执行。 +- linear-scan 使用保守单段 interval;spill 正确性已验证,但不是最优分配。 +- 任意手写 MachineInstr 若固定使用 `t0`/`x5` 等分配池内物理寄存器,仍需要 fixed-register interference 建模;当前正式 selector 只固定使用 `a0`、`zero`、`ra` 等不在 19 个分配寄存器池中的 ABI 寄存器。 +- greedy 当前是线性启发式,不是完整的路径敏感 CFG 分配器;P1 已保证 eviction/reload 与 call clobber 的基本正确性,但复杂分支应继续以 linear-scan 路径为主。 diff --git "a/docs/topic17_P1\345\256\236\347\216\260\346\212\245\345\221\212.md" "b/docs/topic17_P1\345\256\236\347\216\260\346\212\245\345\221\212.md" new file mode 100644 index 0000000..1618c1b --- /dev/null +++ "b/docs/topic17_P1\345\256\236\347\216\260\346\212\245\345\221\212.md" @@ -0,0 +1,89 @@ +# Topic17 P1 实现报告:CFG 活跃性与可执行溢出 + +## 1. 本阶段结论 + +P1 已完成两项 Wiki 交付目标: + +1. 线性分配路径按标签和 terminator 恢复机器基本块,计算 successor、predecessor、live-in 和 live-out,并把数据流结果用于活跃区间修正与块间值携带。 +2. greedy 分配器在寄存器被复用后会删除旧映射、插入 spill `sw`,并在旧值再次使用前插入 reload `lw`,不再出现“只有 store、没有 reload”的假溢出。 + +同时修复了 P0 和 AI 自审中暴露的执行错误:同一条双源指令的两个 spilled vreg 不再被依次 reload 到同一个寄存器;目标寄存器可以在先保存旧源值后安全复用仍存活的 source;高压力 CFG 的各条前驱边通过固定栈槽交接值,join 块不再读取错误的物理寄存器。若物理池本身小于指令要求的不同源寄存器数,分配器会明确失败,而不是生成静默错误结果。 + +## 2. CFG 与活跃性 + +`scratchv/backend/regalloc_cfg.py` 从扁平 `LsInstruction` 序列恢复基本块: + +- leader:入口、标签、terminator 后一条指令; +- 条件分支:目标边 + fallthrough 边; +- `j`/`jal`:已知直接目标边; +- `jalr`:间接目标,不猜测 successor; +- `call`:不是 terminator,保留 fallthrough。 + +每个基本块先计算局部 `uses` 和 `defines`,再迭代求解: + +```text +live_out[B] = union(live_in[S]),S 属于 successors[B] +live_in[B] = uses[B] union (live_out[B] - defines[B]) +``` + +当前分配器仍使用保守的单段 live interval。CFG 数据流保证穿过“本块没有局部 use”的值仍覆盖块边界;进一步利用 lifetime holes 复用寄存器属于后续优化,不影响本阶段正确性。 + +## 3. Spill 重写 + +`scratchv/backend/regalloc_rewrite.py` 统一服务两个 linear-scan 版本,实际跟踪: + +- `vreg -> resident register`; +- `register -> current owner`; +- 栈槽中是否保存了该 vreg 的最新值; +- 当前指令全部 source operand 的保护集合。 + +重写顺序为:保护所有当前源 → 为缺失源选择不同寄存器并 reload → 选择可与 source 重叠的目标寄存器(旧 source 仍存活时先保存)→ 发射指令 → 必要时写回栈槽。高压力 CFG 会在所有前驱边上把 edge-live 值规范化到固定栈槽,块入口不继承源代码顺序中的临时状态。这样既避免了“先 reload A 到 t0,再 reload B 到 t0,最后执行 `add t0,t0,t0`”,也避免了某个分支把定义写入临时寄存器、join 却按全局映射读取另一个寄存器。 + +## 4. CALL ABI + +`MachineOp.CALL` 使用语义表中的 caller-saved clobber 集:`ra`、`a0`–`a7`、`t0`–`t6`。 + +- linear:call 前只保存位于 clobbered 寄存器且 call 后仍存活的值;call 后使这些 resident 映射失效,后续按需 reload; +- greedy:采用相同 clobber 信息保存和失效映射; +- `s0`–`s11` 中的 live value 不产生 caller-save; +- 本地 `call label` 在 flat encoder 中展开为真实 `jal ra, label`,并复用严格的未定义标签检查。超过 JAL 范围的外部/远符号仍应交给支持 ELF relocation 的正式汇编链接流程。 + +## 5. Greedy 修复 + +修复点包括: + +- eviction 后删除旧的 `vreg -> physical register` 映射; +- source 再次出现时,从对应栈槽 reload; +- 当前指令的其他 source register 不参与 victim 选择; +- 最后一次使用后的寄存器及时释放; +- spill/reload 使用标准 `sw rs, offset(sp)` / `lw rd, offset(sp)` 操作数顺序; +- CNN 在 19 寄存器银行下不再因为简单循环复用制造假 spill。 + +## 6. 验证结果 + +| 场景 | 寄存器 | pressure peak | excess | slots | spill stores | reloads | +|---|---:|---:|---:|---:|---:|---:| +| Simple | 8 | 5 | 0 | 0 | 0 | 0 | +| Dense | 5 | 29 | 24 | 28 | 63 | 75 | +| CNN | 19 | 11 | 0 | 0 | 0 | 0 | + +专项测试覆盖: + +- if/else/join CFG 的 successor 与 live-in; +- if/else 两条路径在两寄存器压力下均与参考结果一致; +- 两寄存器高压程序在 TinyFive 上执行结果为 7; +- 24 个随机直线程序(12 seeds × 两种 linear-scan)与 Python RV32 参考语义一致; +- 一寄存器无法表达两个不同 source 时明确报错; +- caller-saved 值跨 call 的 store/reload; +- callee-saved 值跨 call 不产生额外访存; +- greedy 发生 eviction 后存在配对 reload; +- `call` 与 `jal ra,label` 编码一致。 +- `mv`、`li`、`max`、`bnez`、`j` 的编码/执行等价性及任意名称 vreg 不泄露; +- `li` 覆盖 RV32 有符号边界,`max` 覆盖目标与源重叠、内部标签冲突; +- TinyFive `LW` 四字节读取经过独立回归,避免验证器只读低 8 位造成假失败。 + +全量测试结果:`555 passed`。详细审查过程见 `docs/topic17_AI自审报告.md`。 + +## 7. 后续边界 + +P1 解决的是静态分配和静态 spill site 的正确性。运行时动态 spill 次数仍取决于循环执行次数,需要 Spike/QEMU/TinyFive 的执行轨迹计数。进一步优化方向包括 lifetime holes/split intervals、栈槽复用、成本感知 victim,以及正式 ELF relocation/链接支持。 diff --git "a/docs/topic17_benchmark\346\226\207\346\241\243.md" "b/docs/topic17_benchmark\346\226\207\346\241\243.md" index 4585fa6..9fed5ea 100644 --- "a/docs/topic17_benchmark\346\226\207\346\241\243.md" +++ "b/docs/topic17_benchmark\346\226\207\346\241\243.md" @@ -6,13 +6,13 @@ **设计目标**: -- **正确性**:验证无溢出 / 有溢出两种场景的分配结果合法(无未解析 vreg、有效 opcode) +- **正确性**:验证无溢出 / 有溢出场景及每条受支持伪指令的分配、编码和执行结果 - **性能**:测量分配耗时(均值 / 标准差)、活跃区间峰值压力 - **可对比**:每项输出统一的 `reg_spill_count` 指标,支持回归对比和筛选 - **跨后端对比**:同一 ONNX 模型经 ScratchV 和 LLVM 两条路径编译,对比静态指令数、opcode 类别分布、溢出/帧操作数量 入口:`benchmarks/test_regalloc/bench_regalloc_linear.py` -单文件运行:直接执行 `bench_simple.py` / `bench_dense.py` / `bench_cnn.py` +单文件运行:直接执行 `bench_simple.py` / `bench_dense.py` / `bench_cnn.py` / `bench_pseudo.py` --- @@ -25,7 +25,8 @@ benchmarks/test_regalloc/ ├── bench_simple.py Benchmark 1 — 无溢出正确性 ├── bench_dense.py Benchmark 2 — 溢出正确性 ├── bench_cnn.py Benchmark 3 — CNN 集成 + LLVM 对比 -└── bench_regalloc_linear.py 运行器:汇总 3 路输出 → JSON / HTML / Markdown 报告 +├── bench_pseudo.py Benchmark 4 — 每条伪指令的分配、编码与模拟器执行 +└── bench_regalloc_linear.py 运行器:汇总 4 路输出 → JSON / HTML / Markdown 报告 ``` ### 2.1 统一接口 @@ -37,7 +38,7 @@ def run_bench(...) -> dict: """返回统一结构的统计 dict,必需键见 §4。""" ``` -运行器遍历三个 `run_bench()`,收集 dict 生成报告。 +运行器遍历四个 `run_bench()`,收集 dict 生成报告。 ### 2.2 数据流 @@ -66,7 +67,7 @@ def run_bench(...) -> dict: --- -## 3. 三项 Benchmark +## 3. 四项 Benchmark ### 3.1 Benchmark 1 — 简单算术(无溢出) @@ -123,13 +124,25 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) |------|-----| | 模型 | `models/graph/cnn.onnx`(可 CLI 覆盖) | | IR 指令 | 17 条(3×conv + 3×relu + 3×maxpool + 2×gemm + sigmoid + 2×reshape) | -| 物理寄存器 | `_INT_REGS`(28 个) | +| 物理寄存器 | `_INT_REGS`(19 个:`t0`–`t6`、`s0`–`s11`) | | ScratchV 输出 | ~57 条伪指令(mv/mul/add/slt/bnez…) | | LLVM 输出 | ~1099 条(RV64FD O2,真实循环展开) | -| 断言 | `asm_valid == True` | +| 断言 | `asm_valid == True` 且 `emu_passed == True` | > **注意**:ScratchV 侧输出 57 条**伪指令**(conv/maxpool 等语义级操作由仿真器实现),LLVM 侧输出 1099 条**自包含机器指令**(每个 conv 展开为 5 重嵌套循环的完整 RISC-V 指令序列)。`instr_ratio_fd ≈ 23.89x` 反映的是抽象层级差异而非优化能力差距,因此还提供 opcode **类别分布**作为跨层级可比指标。 +### 3.4 Benchmark 4 — 伪指令端到端覆盖 + +**文件**:`bench_pseudo.py` +**目的**:为每条当前支持的 RV32IM 伪指令建立独立 case,并验证寄存器分配、伪指令展开、编码及 TinyFive 执行结果。 + +| 层级 | 覆盖指令 | 校验 | +|------|----------|------| +| Machine IR | `mv/li/max/bnez/j/call/label` | 分配后无 vreg,编码成功,执行结果符合预期,无意外 spill/reload | +| Encoder | `nop/ret` | 展开、编码成功,执行结果符合预期 | + +浮点扩展伪指令 `fabs.d/fneg.d/li.d/fmv.s` 当前不属于 RV32IM 编码器支持范围,单测验证其会被明确拒绝。 + --- ## 4. 指标规范 @@ -141,9 +154,14 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) | `mean_s` | `float` | `perf_counter` 均值 | 单次分配耗时(秒) | | `stdev_s` | `float` | `stdev` | 耗时标准差 | | `vreg_count` | `int` | `len(alloc.alloc_map)` | 已分配的虚拟寄存器数 | -| `spills` | `int` | `len(alloc._spill_slots)` | 溢出 slot 数(别名) | -| `reg_spill_count` | `int` | 同上 | **统一溢出指标键**(接口规范) | -| `peak_active` | `int` | `alloc.peak_active` | 峰值同时活跃的物理寄存器数 | +| `spills` | `int` | `alloc.spill_store_count` | 静态 spill store 数(兼容键) | +| `spill_slots` | `int` | `alloc.spill_slot_count` | 分配的唯一栈槽数(公共只读接口) | +| `spill_stores` | `int` | `alloc.spill_store_count` | 生成汇编中的静态 spill store 数 | +| `reg_spill_count` | `int` | 同上 | **统一溢出事件指标键**(接口规范) | +| `reloads` | `int` | `alloc.reload_load_count` | 生成汇编中的静态 reload load 数 | +| `peak_active` | `int` | `alloc.peak_active` | 分配过程中映射到物理寄存器的峰值数 | +| `pressure_peak` | `int` | CFG 修正后的 live interval 重叠扫描 | 峰值同时活跃的虚拟寄存器数 | +| `pressure_excess_peak` | `int` | `max(0, pressure_peak - 物理寄存器数)` | 峰值理论超额压力 | | `asm_lines` | `int` | `len(code.splitlines())` | 汇编输出行数 | | `valid` | `bool` | 由 `run_bench()` 设置 | 该项是否通过断言 | @@ -167,6 +185,7 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) | `sv_cat_buckets` | `_op_categories()` | 6 类汇总(ALU/Load/Store/Branch/Mul/Other) | | `asm_errors` | `_validate_asm()` | 未解析 vreg / 未知 opcode 列表 | | `asm_valid` | `len(asm_errors) == 0` | 汇编合法性 | +| `emu_passed` | `_run_emulator()` | RV32 模拟器是否完整执行成功 | | `greedy_time_s` | Greedy allocator | Greedy分配器耗时(baseline) | | `greedy_out_instrs` | — | Greedy分配器输出指令数 | @@ -185,7 +204,7 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) ### 4.3 `reg_spill_count` 规范 -- **经过 regalloc 的路径**:直接取自 `alloc._spill_slots` 长度 → 精确值 +- **经过 regalloc 的路径**:取生成汇编中的静态 spill store 数;`spill_slots` 与 `reloads` 分开报告 - **不经过 regalloc 的路径**:LLVM 侧 `reg_spill_count` 是本路径的 ScratchV 精确值(0);LLVM 近似溢出独立为 `llvm_spill_slots`,不污染统一键 - **降级路径**:当 libLLVM 不可用时,`llvm_fd_instrs`/`llvm_spill_slots` 等键不存在于 dict 中,报告渲染 fallback 到 `"-"` @@ -268,18 +287,19 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) ## 7. 使用方式 ```bash -# 运行全部 3 项 benchmark,生成三格式报告 -python -m benchmarks.test_regalloc.bench_regalloc_linear \ - --repeats 30 \ - --output-json report.json \ - --output-html report.html \ +# 运行全部 4 项 benchmark,生成三格式报告(Windows PowerShell) +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_regalloc_linear ` + --repeats 30 ` + --output-json report.json ` + --output-html report.html ` --output-md report.md # 单独运行某项 -python benchmarks/test_regalloc/bench_simple.py --repeats 100 -python benchmarks/test_regalloc/bench_dense.py --repeats 50 -python benchmarks/test_regalloc/bench_cnn.py \ +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_simple --repeats 100 +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_dense --repeats 50 +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_cnn ` --cnn-path models/graph/cnn.onnx --repeats 30 +& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_pseudo --repeats 30 ``` --- diff --git a/scratchv/backend/abi_frame.py b/scratchv/backend/abi_frame.py new file mode 100644 index 0000000..2f9deca --- /dev/null +++ b/scratchv/backend/abi_frame.py @@ -0,0 +1,76 @@ +"""RISC-V ABI stack-frame finalization for allocated assembly.""" + +from __future__ import annotations + +import re + +from scratchv.backend.machine_types import CALLEE_SAVED + + +def apply_abi_frames(assembly: str, spill_slot_count: int) -> str: + """Reserve spill storage and preserve used callee-saved registers. + + Register allocators use compact negative offsets while rewriting. This + final production-code step gives each emitted function a real, aligned + frame and rebases tagged allocator spill accesses into that frame. + """ + lines = assembly.splitlines() + function_starts = [ + index + for index, line in enumerate(lines) + if re.fullmatch(r"[A-Za-z_$][\w.$]*:", line.strip()) + and not line.strip().startswith(".") + ] + for position in reversed(range(len(function_starts))): + start = function_starts[position] + end = ( + function_starts[position + 1] + if position + 1 < len(function_starts) + else len(lines) + ) + body = lines[start + 1:end] + body_text = "\n".join(body) + saved = [ + reg + for reg in CALLEE_SAVED + if re.search(rf"(? 2032: + raise ValueError( + "RISC-V stack frame exceeds the encodable 2032-byte limit: " + f"{frame_size} bytes" + ) + + rewritten: list[str] = [] + for line in body: + if "[regalloc:" in line: + line = re.sub( + r"(-\d+)\(sp\)", + lambda match: f"{frame_size + int(match.group(1))}(sp)", + line, + ) + stripped = line.strip() + if stripped == "ret" or re.match( + r"jalr\s+(?:zero|x0)\s*,\s*(?:ra|x1)(?:\s*,\s*0)?$", + stripped, + ): + for slot, reg in reversed(list(enumerate(saved))): + rewritten.append(f" lw {reg}, {slot * 4}(sp) # ABI restore") + rewritten.append(f" addi sp, sp, {frame_size} # destroy stack frame") + rewritten.append(line) + + prologue = [f" addi sp, sp, -{frame_size} # create stack frame"] + prologue.extend( + f" sw {reg}, {slot * 4}(sp) # ABI save" + for slot, reg in enumerate(saved) + ) + lines[start + 1:end] = prologue + rewritten + + suffix = "\n" if assembly.endswith("\n") else "" + return "\n".join(lines) + suffix diff --git a/scratchv/backend/const_merge.py b/scratchv/backend/const_merge.py index 155a56b..0d0a3a2 100644 --- a/scratchv/backend/const_merge.py +++ b/scratchv/backend/const_merge.py @@ -413,7 +413,7 @@ def main() -> None: args = parser.parse_args() - with open(args.input, "r") as f: + with open(args.input, "r", encoding="utf-8") as f: asm_text = f.read() result, stats = merge_constants_detailed(asm_text) @@ -430,7 +430,7 @@ def main() -> None: ) if args.output: - with open(args.output, "w") as f: + with open(args.output, "w", encoding="utf-8") as f: f.write(result) else: print(result) diff --git a/scratchv/backend/inst_select_ext.py b/scratchv/backend/inst_select_ext.py index c7b138d..3482646 100644 --- a/scratchv/backend/inst_select_ext.py +++ b/scratchv/backend/inst_select_ext.py @@ -148,7 +148,7 @@ def _select_max(self, instr: Instruction) -> None: self._emit(MachineOp.FMAX_D, dst, a, b, comment="fmax.d") else: # Use existing MAX pseudo (base selector has this) - self._emit(MachineOp.MAX, dst, a, b, comment="max") + self._emit_max(dst, a, b, comment="max") # ------------------------------------------------------------------ # abs diff --git a/scratchv/backend/instruction_select.py b/scratchv/backend/instruction_select.py index 26395d2..e65f1bb 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -9,7 +9,9 @@ from scratchv.ir.types import Instruction, Function, Program from scratchv.backend.machine_types import ( - MachineInstr, MachineOp, MachineOperand, + MachineInstr, + MachineOp, + MachineOperand, ) @@ -20,6 +22,22 @@ def __init__(self, program: Program): self.program = program self._instructions: list[MachineInstr] = [] self._label_counter = 0 + self._max_temp_counter = 0 + self._reserved_vreg_names = self._collect_ir_value_names() + + def _collect_ir_value_names(self) -> set[str]: + """Reserve every user-visible IR name before creating temporaries.""" + names = {value.name for value in self.program.global_values} + for func in self.program.functions: + names.update(value.name for value in func.params) + names.update(value.name for value in func.returns) + names.update(value.name for value in func.locals) + for block in func.blocks: + for instr in (*block.phi_nodes, *block.instructions): + if instr.dest is not None: + names.add(instr.dest.name) + names.update(value.name for value in instr.operands) + return names def run(self) -> list[MachineInstr]: """Select instructions for all functions. @@ -47,18 +65,70 @@ def _select_function(self, func: Function) -> None: def _select_instruction(self, instr: Instruction) -> None: handler = getattr(self, f"_select_{instr.opcode.value}", None) if handler is None: - raise ValueError( - f"No instruction selection for opcode: {instr.opcode}") + raise ValueError(f"No instruction selection for opcode: {instr.opcode}") handler(instr) - def _emit(self, op: MachineOp, dst=None, src1=None, src2=None, - comment: str = "") -> None: - self._instructions.append( - MachineInstr(op, dst, src1, src2, comment)) + def _emit( + self, op: MachineOp, dst=None, src1=None, src2=None, comment: str = "" + ) -> None: + self._instructions.append(MachineInstr(op, dst, src1, src2, comment)) + + def _emit_move(self, dst: MachineOperand, src: MachineOperand, + comment: str = "") -> None: + """Emit a legal copy pseudo for either a register or an immediate.""" + if src.kind == "imm": + self._emit(MachineOp.LI, dst, src, comment=comment) + else: + self._emit(MachineOp.MV, dst, src, comment=comment) + + def _emit_max(self, dst: MachineOperand | None, + lhs: MachineOperand, rhs: MachineOperand, + comment: str = "") -> None: + """Emit a MAX pseudo in the canonical form accepted by the encoder. + + ``MAX`` is commutative, so an immediate left operand is first moved + to the right. The encoder accepts an immediate right operand only + when it is zero; other immediates are materialized in a fresh virtual + register before emitting the pseudo. + """ + if dst is None: + return + + if lhs.kind == "imm" and rhs.kind == "imm": + value = max(int(lhs.value), int(rhs.value)) + self._emit( + MachineOp.LI, + dst, + MachineOperand.immediate(value), + comment=comment, + ) + return + + if lhs.kind == "imm": + lhs, rhs = rhs, lhs + + if rhs.kind == "imm" and int(rhs.value) != 0: + while True: + self._max_temp_counter += 1 + temp_name = ( + f"__scratchv_max_rhs_{self._max_temp_counter}" + ) + if temp_name not in self._reserved_vreg_names: + break + self._reserved_vreg_names.add(temp_name) + rhs_reg = MachineOperand.vreg(temp_name) + self._emit( + MachineOp.LI, + rhs_reg, + rhs, + comment="materialize max rhs", + ) + rhs = rhs_reg + + self._emit(MachineOp.MAX, dst, lhs, rhs, comment=comment) def _emit_label(self, name: str) -> None: - self._instructions.append( - MachineInstr(MachineOp.LABEL, comment=name)) + self._instructions.append(MachineInstr(MachineOp.LABEL, comment=name)) def _op(self, instr: Instruction, idx: int): """Get an operand from an IR instruction as a machine operand.""" @@ -81,30 +151,41 @@ def _select_load_const(self, instr: Instruction) -> None: val = int(raw_val) dst = self._dst(instr) # LI pseudo-instruction (expands to addi x0, imm or lui+addi) - self._emit(MachineOp.LI, dst, - MachineOperand.immediate(int(val)), - comment=f"const {val}") + self._emit( + MachineOp.LI, + dst, + MachineOperand.immediate(int(val)), + comment=f"const {val}", + ) def _select_add(self, instr: Instruction) -> None: - self._emit(MachineOp.ADD, self._dst(instr), - self._op(instr, 0), self._op(instr, 1)) + self._emit( + MachineOp.ADD, self._dst(instr), self._op(instr, 0), self._op(instr, 1) + ) def _select_sub(self, instr: Instruction) -> None: - self._emit(MachineOp.SUB, self._dst(instr), - self._op(instr, 0), self._op(instr, 1)) + self._emit( + MachineOp.SUB, self._dst(instr), self._op(instr, 0), self._op(instr, 1) + ) def _select_mul(self, instr: Instruction) -> None: - self._emit(MachineOp.MUL, self._dst(instr), - self._op(instr, 0), self._op(instr, 1)) + self._emit( + MachineOp.MUL, self._dst(instr), self._op(instr, 0), self._op(instr, 1) + ) def _select_div(self, instr: Instruction) -> None: - self._emit(MachineOp.DIV, self._dst(instr), - self._op(instr, 0), self._op(instr, 1)) + self._emit( + MachineOp.DIV, self._dst(instr), self._op(instr, 0), self._op(instr, 1) + ) def _select_neg(self, instr: Instruction) -> None: # RISC-V: sub rd, x0, rs - self._emit(MachineOp.SUB, self._dst(instr), - MachineOperand.immediate(0), self._op(instr, 0)) + self._emit( + MachineOp.SUB, + self._dst(instr), + MachineOperand.immediate(0), + self._op(instr, 0), + ) def _select_exp(self, instr: Instruction) -> None: # exp(x) approximated as max(0, 1+x) for simplicity (pure RV32I) @@ -112,18 +193,22 @@ def _select_exp(self, instr: Instruction) -> None: dst = self._dst(instr) if dst is None: return - self._emit(MachineOp.ADDI, dst, src, - MachineOperand.immediate(1), - comment="exp approx: 1+x") - self._emit(MachineOp.MAX, dst, dst, - MachineOperand.immediate(0), - comment="relu clamp") + self._emit( + MachineOp.ADDI, + dst, + src, + MachineOperand.immediate(1), + comment="exp approx: 1+x", + ) + self._emit_max( + dst, dst, MachineOperand.immediate(0), comment="relu clamp" + ) def _select_relu(self, instr: Instruction) -> None: """ReLU(x) = max(x, 0). Use: max rd, rs, x0""" src = self._op(instr, 0) dst = self._dst(instr) - self._emit(MachineOp.MAX, dst, src, MachineOperand.immediate(0)) + self._emit_max(dst, src, MachineOperand.immediate(0)) def _select_gelu(self, instr: Instruction) -> None: # GELU approx: x * relu(x) / 2 (simplified, pure RV32IM) @@ -132,29 +217,25 @@ def _select_gelu(self, instr: Instruction) -> None: if dst is None: return tmp = MachineOperand.vreg("tmp_gelu") - self._emit(MachineOp.MAX, tmp, src, - MachineOperand.immediate(0), - comment="relu(x)") - self._emit(MachineOp.MUL, dst, src, tmp, - comment="x * relu(x)") - self._emit(MachineOp.DIV, dst, dst, - MachineOperand.immediate(2), - comment="/ 2") + self._emit_max( + tmp, src, MachineOperand.immediate(0), comment="relu(x)" + ) + self._emit(MachineOp.MUL, dst, src, tmp, comment="x * relu(x)") + self._emit(MachineOp.DIV, dst, dst, MachineOperand.immediate(2), comment="/ 2") def _select_softmax(self, instr: Instruction) -> None: # softmax ≈ identity (pure RV32I passthrough) src = self._op(instr, 0) dst = self._dst(instr) if dst and src: - self._emit(MachineOp.MV, dst, src, - comment="softmax passthrough") + self._emit_move(dst, src, comment="softmax passthrough") def _select_reshape(self, instr: Instruction) -> None: # Reshape is a no-op: just copy the value src = self._op(instr, 0) dst = self._dst(instr) if dst and src: - self._emit(MachineOp.MV, dst, src, comment="reshape") + self._emit_move(dst, src, comment="reshape") def _select_load(self, instr: Instruction) -> None: self._emit(MachineOp.LW, self._dst(instr), self._op(instr, 0)) @@ -168,8 +249,13 @@ def _select_alloca(self, instr: Instruction) -> None: size = raw_size dst = self._dst(instr) # Subtract from sp to allocate - self._emit(MachineOp.ADDI, dst, MachineOperand.vreg("sp"), - MachineOperand.immediate(-size), comment=f"alloca {size}") + self._emit( + MachineOp.ADDI, + dst, + MachineOperand.vreg("sp"), + MachineOperand.immediate(-size), + comment=f"alloca {size}", + ) def _select_for(self, instr: Instruction) -> None: """Begin a for loop: set up loop variable and branch to loop header.""" @@ -187,8 +273,9 @@ def _select_for(self, instr: Instruction) -> None: exit_label = self._fresh_label("loop_exit") # Initialize loop variable - self._emit(MachineOp.LI, iv, MachineOperand.immediate(start), - comment="loop init") + self._emit( + MachineOp.LI, iv, MachineOperand.immediate(start), comment="loop init" + ) # Branch to loop body # Store loop context for endfor to use @@ -215,8 +302,9 @@ def _select_endfor(self, instr: Instruction) -> None: iv = ctx["iv"] # Increment: addi iv, iv, 1 - self._emit(MachineOp.ADDI, iv, iv, MachineOperand.immediate(1), - comment="loop inc") + self._emit( + MachineOp.ADDI, iv, iv, MachineOperand.immediate(1), comment="loop inc" + ) # Jump back to header self._emit(MachineOp.J, comment=ctx["header"]) # Exit label @@ -237,26 +325,31 @@ def _select_br_if(self, instr: Instruction) -> None: def _select_return(self, instr: Instruction) -> None: if instr.operands: - self._emit(MachineOp.MV, MachineOperand.reg("a0"), - self._op(instr, 0), comment="return value") - self._emit(MachineOp.JALR, MachineOperand.reg("zero"), - MachineOperand.reg("ra"), comment="ret") + self._emit_move( + MachineOperand.reg("a0"), + self._op(instr, 0), + comment="return value", + ) + self._emit( + MachineOp.JALR, + MachineOperand.reg("zero"), + MachineOperand.reg("ra"), + comment="ret", + ) def _select_matmul(self, instr: Instruction) -> None: a_reg = self._op(instr, 0) b_reg = self._op(instr, 1) dst = self._dst(instr) if dst: - self._emit(MachineOp.MUL, dst, a_reg, b_reg, - comment="matmul: a * b") + self._emit(MachineOp.MUL, dst, a_reg, b_reg, comment="matmul: a * b") def _select_dot(self, instr: Instruction) -> None: a_reg = self._op(instr, 0) b_reg = self._op(instr, 1) dst = self._dst(instr) if dst: - self._emit(MachineOp.MUL, dst, a_reg, b_reg, - comment="dot: a * b") + self._emit(MachineOp.MUL, dst, a_reg, b_reg, comment="dot: a * b") def _select_label(self, instr: Instruction) -> None: self._emit_label(instr.target or "") @@ -274,37 +367,32 @@ def _select_sigmoid(self, instr: Instruction) -> None: # li dst, 1 → else clamp to 1 # keep: mv dst, src keep_label = self._fresh_label("sig_keep") - self._emit(MachineOp.SLT, - MachineOperand.vreg("t_sig"), - src, - MachineOperand.immediate(1), - comment="src < 1 ?") - self._emit(MachineOp.BNEZ, - MachineOperand.vreg("t_sig"), - comment=keep_label) - self._emit(MachineOp.LI, dst, - MachineOperand.immediate(1), - comment="clamp to 1") + self._emit( + MachineOp.SLT, + MachineOperand.vreg("t_sig"), + src, + MachineOperand.immediate(1), + comment="src < 1 ?", + ) + self._emit(MachineOp.BNEZ, MachineOperand.vreg("t_sig"), comment=keep_label) + self._emit(MachineOp.LI, dst, MachineOperand.immediate(1), comment="clamp to 1") # Branch over the mv done_label = self._fresh_label("sig_done") self._emit(MachineOp.J, comment=done_label) self._emit_label(keep_label) - self._emit(MachineOp.MV, dst, src, - comment="keep src") + self._emit_move(dst, src, comment="keep src") self._emit_label(done_label) # Now dst = min(src, 1). If src < 0, result = 0 - self._emit(MachineOp.SLT, - MachineOperand.vreg("t_sig2"), - MachineOperand.immediate(0), - src, - comment="0 < src ?") + self._emit( + MachineOp.SLT, + MachineOperand.vreg("t_sig2"), + MachineOperand.immediate(0), + src, + comment="0 < src ?", + ) zero_label = self._fresh_label("sig_zero") - self._emit(MachineOp.BNEZ, - MachineOperand.vreg("t_sig2"), - comment=zero_label) - self._emit(MachineOp.LI, dst, - MachineOperand.immediate(0), - comment="clamp to 0") + self._emit(MachineOp.BNEZ, MachineOperand.vreg("t_sig2"), comment=zero_label) + self._emit(MachineOp.LI, dst, MachineOperand.immediate(0), comment="clamp to 0") self._emit_label(zero_label) def _select_conv(self, instr: Instruction) -> None: @@ -315,15 +403,12 @@ def _select_conv(self, instr: Instruction) -> None: b_reg = self._op(instr, 2) if dst: # acc = bias (mv bias to dest) - self._emit(MachineOp.MV, dst, b_reg, - comment="acc = bias") + self._emit_move(dst, b_reg, comment="acc = bias") # tmp = x * w (MUL for MAC) tmp_vreg = MachineOperand.vreg("tmp_mac") - self._emit(MachineOp.MUL, tmp_vreg, x_reg, w_reg, - comment="tmp = x * w") + self._emit(MachineOp.MUL, tmp_vreg, x_reg, w_reg, comment="tmp = x * w") # dst = dst + tmp (acc += x*w) - self._emit(MachineOp.ADD, dst, dst, tmp_vreg, - comment="acc += x*w") + self._emit(MachineOp.ADD, dst, dst, tmp_vreg, comment="acc += x*w") def _select_gemm(self, instr: Instruction) -> None: """GEMM inline: real RISC-V MUL+ADD MAC.""" @@ -332,13 +417,10 @@ def _select_gemm(self, instr: Instruction) -> None: w_reg = self._op(instr, 1) b_reg = self._op(instr, 2) if dst: - self._emit(MachineOp.MV, dst, b_reg, - comment="acc = bias") + self._emit_move(dst, b_reg, comment="acc = bias") tmp_vreg = MachineOperand.vreg("tmp_gemm") - self._emit(MachineOp.MUL, tmp_vreg, a_reg, w_reg, - comment="tmp = a * w") - self._emit(MachineOp.ADD, dst, dst, tmp_vreg, - comment="acc += a*w") + self._emit(MachineOp.MUL, tmp_vreg, a_reg, w_reg, comment="tmp = a * w") + self._emit(MachineOp.ADD, dst, dst, tmp_vreg, comment="acc += a*w") def _select_maxpool(self, instr: Instruction) -> None: """MaxPool inline: RISC-V SLT + branch → max.""" @@ -348,20 +430,17 @@ def _select_maxpool(self, instr: Instruction) -> None: return # max(x, 0) using SLT + branch gt_label = self._fresh_label("mp_gt") - self._emit(MachineOp.SLT, - MachineOperand.vreg("t_mp"), - MachineOperand.immediate(0), - src, - comment="0 < x ?") - self._emit(MachineOp.BNEZ, - MachineOperand.vreg("t_mp"), - comment=gt_label) - self._emit(MachineOp.LI, dst, - MachineOperand.immediate(0), - comment="result = 0") + self._emit( + MachineOp.SLT, + MachineOperand.vreg("t_mp"), + MachineOperand.immediate(0), + src, + comment="0 < x ?", + ) + self._emit(MachineOp.BNEZ, MachineOperand.vreg("t_mp"), comment=gt_label) + self._emit(MachineOp.LI, dst, MachineOperand.immediate(0), comment="result = 0") done_label = self._fresh_label("mp_done") self._emit(MachineOp.J, comment=done_label) self._emit_label(gt_label) - self._emit(MachineOp.MV, dst, src, - comment="result = x") + self._emit_move(dst, src, comment="result = x") self._emit_label(done_label) diff --git a/scratchv/backend/machine_semantics.py b/scratchv/backend/machine_semantics.py new file mode 100644 index 0000000..c45dee0 --- /dev/null +++ b/scratchv/backend/machine_semantics.py @@ -0,0 +1,265 @@ +"""Central register semantics for machine instructions. + +The linear-scan allocators must reason about the *meaning* of operands, +not about the historical ``dst/src1/src2`` field names. This module is the +single source of truth for positional defs/uses and pseudo-instruction +metadata. Every ``MachineOp`` has an explicit entry so a newly added opcode +cannot silently inherit incorrect register semantics. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from scratchv.backend.machine_types import ARG_REGS, TEMP_REGS, MachineOp + +if TYPE_CHECKING: + from scratchv.backend.machine_types import MachineInstr + + +@dataclass(frozen=True) +class MachineOpSemantics: + """Register-allocation and emission metadata for one machine opcode. + + Operand positions use ``0=dst``, ``1=src1`` and ``2=src2``. An entry in + ``immediate_positions`` means that position may contain an immediate; a + virtual register in the same position is still treated according to + ``uses``. ``n_phys`` is the number of register operands needed after + pseudo expansion (not the number of emitted instructions), matching the + Topic17 terminology. + """ + + defs: tuple[int, ...] = () + uses: tuple[int, ...] = () + immediate_positions: tuple[int, ...] = () + is_terminator: bool = False + target_from_comment: bool = False + target_required: bool = False + implicit_defs: frozenset[str] = frozenset() + implicit_uses: frozenset[str] = frozenset() + clobbers: frozenset[str] = frozenset() + is_call: bool = False + n_phys: int = 0 + is_pseudo: bool = False + is_label: bool = False + + +_DEF_USE_USE = MachineOpSemantics(defs=(0,), uses=(1, 2), n_phys=3) +_DEF_USE = MachineOpSemantics(defs=(0,), uses=(1,), n_phys=2) +_STORE = MachineOpSemantics(uses=(0, 1), n_phys=2) +_NO_REGISTERS = MachineOpSemantics() + + +OP_SEM: dict[MachineOp, MachineOpSemantics] = { + MachineOp.ADD: _DEF_USE_USE, + MachineOp.ADDI: MachineOpSemantics( + defs=(0,), uses=(1,), immediate_positions=(2,), n_phys=2 + ), + MachineOp.SUB: _DEF_USE_USE, + MachineOp.MUL: _DEF_USE_USE, + MachineOp.DIV: _DEF_USE_USE, + MachineOp.SRAI: MachineOpSemantics( + defs=(0,), uses=(1,), immediate_positions=(2,), n_phys=2 + ), + MachineOp.XOR: _DEF_USE_USE, + MachineOp.AND: _DEF_USE_USE, + MachineOp.SLT: _DEF_USE_USE, + MachineOp.REM: _DEF_USE_USE, + MachineOp.LW: _DEF_USE, + MachineOp.SW: _STORE, + MachineOp.FLD: _DEF_USE, + MachineOp.FSD: _STORE, + # mv rd, rs -> addi rd, rs, 0 + MachineOp.MV: MachineOpSemantics( + defs=(0,), + uses=(1,), + n_phys=2, + is_pseudo=True, + ), + # li rd, imm -> addi rd, x0, imm or lui/addi for a large immediate + MachineOp.LI: MachineOpSemantics( + defs=(0,), + immediate_positions=(1,), + n_phys=1, + is_pseudo=True, + ), + # ScratchV's RV32IM max pseudo accepts a register rhs or immediate zero. + MachineOp.MAX: MachineOpSemantics( + defs=(0,), + uses=(1, 2), + immediate_positions=(2,), + n_phys=3, + is_pseudo=True, + ), + MachineOp.LABEL: MachineOpSemantics( + n_phys=0, + is_pseudo=True, + is_label=True, + ), + # bnez rs, label -> bne rs, x0, label + MachineOp.BNEZ: MachineOpSemantics( + uses=(0,), + is_terminator=True, + target_from_comment=True, + target_required=True, + n_phys=1, + is_pseudo=True, + ), + # j label -> jal x0, label + MachineOp.J: MachineOpSemantics( + is_terminator=True, + target_from_comment=True, + target_required=True, + n_phys=0, + is_pseudo=True, + ), + MachineOp.JALR: MachineOpSemantics( + defs=(0,), + uses=(1,), + immediate_positions=(2,), + is_terminator=True, + n_phys=2, + ), + MachineOp.JAL: MachineOpSemantics( + defs=(0,), + is_terminator=True, + target_from_comment=True, + target_required=True, + n_phys=1, + ), + MachineOp.BEQ: MachineOpSemantics( + uses=(0, 1), + is_terminator=True, + target_from_comment=True, + target_required=True, + n_phys=2, + ), + MachineOp.BNE: MachineOpSemantics( + uses=(0, 1), + is_terminator=True, + target_from_comment=True, + target_required=True, + n_phys=2, + ), + MachineOp.BLT: MachineOpSemantics( + uses=(0, 1), + is_terminator=True, + target_from_comment=True, + target_required=True, + n_phys=2, + ), + MachineOp.BGE: MachineOpSemantics( + uses=(0, 1), + is_terminator=True, + target_from_comment=True, + target_required=True, + n_phys=2, + ), + # The CFG-aware spill rewriter and greedy allocator use these ABI clobbers + # to preserve values that remain live across a call. + MachineOp.CALL: MachineOpSemantics( + target_from_comment=True, + target_required=True, + implicit_defs=frozenset({"ra"}), + clobbers=frozenset({"ra", *ARG_REGS, *TEMP_REGS}), + is_call=True, + n_phys=1, + is_pseudo=True, + ), + MachineOp.SECTION: _NO_REGISTERS, + MachineOp.GLOBL: _NO_REGISTERS, + MachineOp.SIZE: _NO_REGISTERS, + MachineOp.TYPE: _NO_REGISTERS, + MachineOp.SQRT_S: _DEF_USE, + MachineOp.SQRT_D: _DEF_USE, + MachineOp.FMIN_D: _DEF_USE_USE, + MachineOp.FMAX_D: _DEF_USE_USE, + MachineOp.FABS_D: MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True + ), + MachineOp.FNEG_D: MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True + ), + MachineOp.FADD_D: _DEF_USE_USE, + MachineOp.FSUB_D: _DEF_USE_USE, + MachineOp.FMUL_D: _DEF_USE_USE, + MachineOp.FDIV_D: _DEF_USE_USE, + MachineOp.FLT_D: _DEF_USE_USE, + MachineOp.FEQ_D: _DEF_USE_USE, + MachineOp.FCVT_S_D: _DEF_USE, + MachineOp.FCVT_D_S: _DEF_USE, + MachineOp.LI_D: MachineOpSemantics( + defs=(0,), immediate_positions=(1,), n_phys=1, is_pseudo=True + ), + MachineOp.FADD_S: _DEF_USE_USE, + MachineOp.FSUB_S: _DEF_USE_USE, + MachineOp.FMUL_S: _DEF_USE_USE, + MachineOp.FDIV_S: _DEF_USE_USE, + MachineOp.FMAX_S: _DEF_USE_USE, + MachineOp.FMIN_S: _DEF_USE_USE, + MachineOp.FLE_S: _DEF_USE_USE, + MachineOp.FLT_S: _DEF_USE_USE, + MachineOp.FEQ_S: _DEF_USE_USE, + MachineOp.FLW: _DEF_USE, + MachineOp.FSW: _STORE, + MachineOp.FMV_S: MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True + ), + MachineOp.FMV_S_X: _DEF_USE, +} + + +_MISSING_SEMANTICS = set(MachineOp) - set(OP_SEM) +if _MISSING_SEMANTICS: + missing = ", ".join(sorted(op.value for op in _MISSING_SEMANTICS)) + raise RuntimeError(f"missing machine semantics for: {missing}") + + +def get_machine_semantics(op: MachineOp) -> MachineOpSemantics: + """Return the explicit semantics for *op*.""" + + return OP_SEM[op] + + +def virtual_register_defs_uses( + instr: "MachineInstr", +) -> tuple[set[str], set[str]]: + """Collect virtual-register defs and uses according to opcode semantics.""" + + semantics = get_machine_semantics(instr.op) + operands = (instr.dst, instr.src1, instr.src2) + + def _names_at(positions: tuple[int, ...]) -> set[str]: + names: set[str] = set() + for position in positions: + operand = operands[position] + if operand is not None and operand.kind == "vreg": + names.add(str(operand.value)) + return names + + return _names_at(semantics.defs), _names_at(semantics.uses) + + +def linear_scan_operands(instr: "MachineInstr") -> tuple[list[str], str]: + """Return emitted operands and any remaining non-semantic comment. + + Branch targets historically live in ``MachineInstr.comment``. At the + linear-scan boundary they become real assembly operands so later comment + stripping cannot erase control-flow semantics. + """ + + operands = [ + str(operand).lstrip("%") + for operand in (instr.dst, instr.src1, instr.src2) + if operand is not None + ] + semantics = get_machine_semantics(instr.op) + comment = instr.comment + if semantics.target_from_comment: + if semantics.target_required and not comment: + raise ValueError(f"{instr.op.value} requires a target label") + if comment: + operands.append(comment) + comment = "" + return operands, comment diff --git a/scratchv/backend/regalloc_cfg.py b/scratchv/backend/regalloc_cfg.py new file mode 100644 index 0000000..251b169 --- /dev/null +++ b/scratchv/backend/regalloc_cfg.py @@ -0,0 +1,170 @@ +"""Control-flow and liveness analysis for machine-level register allocation. + +The linear allocators consume a flat ``LsInstruction`` stream. This module +recovers basic blocks from labels and terminators, builds successor edges, and +computes the conventional backward ``live_in``/``live_out`` data-flow sets. +It deliberately has no dependency on either allocator implementation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from scratchv.backend.machine_semantics import get_machine_semantics +from scratchv.backend.machine_types import MachineOp + + +_CONDITIONAL_BRANCHES = {"beq", "bne", "blt", "bge", "bnez"} +_DIRECT_JUMPS = {"j", "jal"} + + +@dataclass +class MachineBasicBlock: + """One recovered machine basic block and its liveness facts.""" + + name: str + instructions: list[Any] + start: int + end: int + successors: set[str] = field(default_factory=set) + predecessors: set[str] = field(default_factory=set) + uses: set[str] = field(default_factory=set) + defines: set[str] = field(default_factory=set) + live_in: set[str] = field(default_factory=set) + live_out: set[str] = field(default_factory=set) + + +@dataclass +class MachineCFG: + """Recovered control-flow graph for a flat machine instruction stream.""" + + blocks: list[MachineBasicBlock] + by_name: dict[str, MachineBasicBlock] + instruction_to_block: dict[int, str] + + +def _semantics(opcode: str): + try: + return get_machine_semantics(MachineOp(opcode)) + except ValueError: + return None + + +def _is_terminator(inst: Any) -> bool: + semantics = _semantics(inst.opcode) + return bool(semantics and semantics.is_terminator) + + +def _target(inst: Any) -> str | None: + semantics = _semantics(inst.opcode) + if not semantics or not semantics.target_from_comment: + return None + return inst.operands[-1] if inst.operands else None + + +def analyze_control_flow(instructions: list[Any]) -> MachineCFG: + """Split *instructions* into blocks and compute live-in/live-out sets. + + ``call`` is intentionally not a terminator: it has a fallthrough edge and + its ABI clobbers are handled by allocation/code generation, not the CFG. + Direct targets outside this stream (for example an external ``jal``) do + not create an internal successor edge. + """ + + if not instructions: + return MachineCFG([], {}, {}) + + leaders = {0} + for index, inst in enumerate(instructions): + if inst.opcode == ".label": + leaders.add(index) + if _is_terminator(inst) and index + 1 < len(instructions): + leaders.add(index + 1) + + starts = sorted(leaders) + blocks: list[MachineBasicBlock] = [] + instruction_to_block: dict[int, str] = {} + for ordinal, start_index in enumerate(starts): + stop_index = starts[ordinal + 1] if ordinal + 1 < len(starts) else len(instructions) + body = instructions[start_index:stop_index] + first = body[0] + if first.opcode == ".label": + name = first.operands[0] if first.operands else first.comment + else: + name = f".__ls_block_{ordinal}" + if not name: + name = f".__ls_block_{ordinal}" + block = MachineBasicBlock( + name=name, + instructions=body, + start=body[0].id, + end=body[-1].id + 1, + ) + for inst in body: + instruction_to_block[inst.id] = name + block.uses |= inst.uses - block.defines + block.defines |= inst.defines + blocks.append(block) + + by_name = {block.name: block for block in blocks} + if len(by_name) != len(blocks): + raise ValueError("duplicate machine basic-block label") + + for index, block in enumerate(blocks): + last = block.instructions[-1] + target = _target(last) + fallthrough = blocks[index + 1].name if index + 1 < len(blocks) else None + + if last.opcode in _CONDITIONAL_BRANCHES: + if target in by_name: + block.successors.add(target) + if fallthrough is not None: + block.successors.add(fallthrough) + elif last.opcode in _DIRECT_JUMPS: + if target in by_name: + block.successors.add(target) + elif last.opcode == "jalr": + pass # Indirect target / return: no statically known successor. + elif fallthrough is not None: + block.successors.add(fallthrough) + + for block in blocks: + for successor in block.successors: + by_name[successor].predecessors.add(block.name) + + changed = True + while changed: + changed = False + for block in reversed(blocks): + live_out = set().union( + *(by_name[name].live_in for name in block.successors) + ) if block.successors else set() + live_in = block.uses | (live_out - block.defines) + if live_in != block.live_in or live_out != block.live_out: + block.live_in = live_in + block.live_out = live_out + changed = True + + return MachineCFG(blocks, by_name, instruction_to_block) + + +def apply_cfg_liveness(intervals: list[Any], cfg: MachineCFG) -> list[Any]: + """Extend intervals to cover the block boundaries required by the CFG. + + The allocator still uses conservative single ranges, but those ranges now + include values carried through blocks even when a block contains no local + use. This is the safe first step before lifetime-hole/split-interval work. + """ + + by_vreg = {interval.vreg: interval for interval in intervals} + for block in cfg.blocks: + for vreg in block.live_in: + interval = by_vreg.get(vreg) + if interval is not None: + interval.start = min(interval.start, block.start) + for vreg in block.live_out: + interval = by_vreg.get(vreg) + if interval is not None: + interval.end = max(interval.end, block.end) + return sorted(intervals, key=lambda iv: (iv.start, iv.end, iv.vreg)) diff --git a/scratchv/backend/regalloc_linear.py b/scratchv/backend/regalloc_linear.py index 5cc645f..e91e704 100644 --- a/scratchv/backend/regalloc_linear.py +++ b/scratchv/backend/regalloc_linear.py @@ -17,20 +17,31 @@ from dataclasses import dataclass, field from typing import Optional +from scratchv.backend.machine_semantics import ( + get_machine_semantics, + linear_scan_operands, + virtual_register_defs_uses, +) +from scratchv.backend.machine_types import ALL_REGS, ARG_REGS +from scratchv.backend.regalloc_metrics import ( + count_spill_reload_sites, + peak_live_intervals, +) +from scratchv.backend.regalloc_cfg import ( + MachineCFG, + analyze_control_flow, + apply_cfg_liveness, +) +from scratchv.backend.regalloc_rewrite import rewrite_with_spills + # --------------------------------------------------------------------------- # 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 -] +# Canonical 19-register bank shared with the greedy allocator. Keep the +# private alias for compatibility with existing benchmark imports. +_INT_REGS = list(ALL_REGS) _FP_REGS = [ "f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", @@ -115,6 +126,12 @@ def __repr__(self) -> str: def to_asm(self, rename: Optional[dict[str, str]] = None) -> str: """Emit this instruction as assembly after register renaming.""" + if self.opcode == ".label": + label = self.operands[0] if self.operands else self.comment + if not label: + raise ValueError("machine label must have a name") + return f"{label}:" + ops = self.operands[:] if rename: ops = [rename.get(o, o) for o in ops] @@ -190,6 +207,15 @@ def __init__(self, phys_regs: Optional[list[str]] = None): phys_regs if phys_regs is not None else list(_DEFAULT_PHYS_REGS) ) + valid_regs = set(ALL_REGS) | set(ARG_REGS) + invalid = [reg for reg in self.phys_regs if reg not in valid_regs] + if invalid: + raise ValueError( + "linear regalloc supports only RV32 integer allocatable " + f"registers; invalid: {', '.join(invalid)}" + ) + if len(set(self.phys_regs)) != len(self.phys_regs): + raise ValueError("linear regalloc physical registers must be unique") self.stack_slot: int = 0 self.alloc_map: dict[str, str] = {} self.spill_code: dict[int, list[str]] = {} # pos -> [sw asm lines] @@ -201,6 +227,18 @@ def __init__(self, phys_regs: Optional[list[str]] = None): 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 + self.pressure_peak: int = 0 + self.pressure_excess_peak: int = 0 + self.spill_store_count: int = 0 + self.reload_load_count: int = 0 + self.cfg: MachineCFG = MachineCFG([], {}, {}) + + @property + def spill_slot_count(self) -> int: + """Return the number of unique stack slots reserved for spills.""" + + return len(self._spill_slots) # ------------------------------------------------------------------ # Live interval computation @@ -257,7 +295,8 @@ def compute_live_intervals( vreg=vreg, start=start, end=end, uses=uses, )) - return sorted(intervals, key=lambda iv: iv.start) + self.cfg = analyze_control_flow(block) + return apply_cfg_liveness(intervals, self.cfg) # ------------------------------------------------------------------ # Linear scan allocation @@ -281,8 +320,16 @@ def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: self._reloads.clear() self._spilled.clear() self._evictions.clear() + self.stack_slot = 0 self._intervals = intervals self._vreg_interval = {iv.vreg: iv for iv in intervals} + self.peak_active = 0 + self.pressure_peak = peak_live_intervals(intervals) + self.pressure_excess_peak = max( + 0, self.pressure_peak - len(self.phys_regs) + ) + self.spill_store_count = 0 + self.reload_load_count = 0 # Active list: (interval, phys_reg) sorted by increasing end active: list[tuple[LiveInterval, str]] = [] @@ -309,6 +356,8 @@ def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: self.alloc_map[interval.vreg] = reg active.append((interval, reg)) + self.peak_active = max(self.peak_active, len(active)) + return dict(self.alloc_map) def _expire_old_intervals(self, active: list[tuple[LiveInterval, str]], @@ -407,7 +456,7 @@ def emit(self, block: list[LsInstruction]) -> str: self.allocate(intervals) return self.get_allocated_code(block) - def get_allocated_code(self, block: list[LsInstruction]) -> str: + def _get_allocated_code_legacy(self, block: list[LsInstruction]) -> str: """Generate allocated assembly with spill stores and reloads. Walks the instruction block in order. Before each instruction @@ -443,7 +492,12 @@ def get_allocated_code(self, block: list[LsInstruction]) -> str: if inst.id in self.spill_code: lines.extend(self.spill_code[inst.id]) - return "\n".join(lines) + assembly = "\n".join(lines) + ( + self.spill_store_count, + self.reload_load_count, + ) = count_spill_reload_sites(assembly) + return assembly def _pick_reload_reg(self, rename: dict[str, str], current_pos: int, protected_vregs: set[str] | None = None) -> str: @@ -519,6 +573,16 @@ def _evict_for_reload( del rename[farthest_vreg] return evicted_reg + def get_allocated_code(self, block: list[LsInstruction]) -> str: + """Emit allocated code through the CFG-aware spill rewriter.""" + + assembly = rewrite_with_spills(self, block) + ( + self.spill_store_count, + self.reload_load_count, + ) = count_spill_reload_sites(assembly) + return assembly + # ------------------------------------------------------------------ # Report # ------------------------------------------------------------------ @@ -526,11 +590,17 @@ def _evict_for_reload( def report(self) -> str: """Return a string summary of the allocation result.""" total = len(self.alloc_map) - spilled = len(self._spill_slots) + spilled = self.spill_slot_count 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" Static spill stores: {self.spill_store_count}") + parts.append(f" Static reload loads: {self.reload_load_count}") + parts.append(f" Peak live-register pressure: {self.pressure_peak}") + parts.append( + f" Peak pressure above register bank: {self.pressure_excess_peak}" + ) parts.append( f" Physical registers available: {len(self.phys_regs)}" ) @@ -561,24 +631,8 @@ def block_from_machine_instrs( """ 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) + defines, uses = virtual_register_defs_uses(mi) + operands, comment = linear_scan_operands(mi) if mi.op.value == ".label": result.append(LsInstruction( @@ -592,7 +646,7 @@ def block_from_machine_instrs( operands=operands, defines=defines, uses=uses, - comment=mi.comment, + comment=comment, )) return result @@ -620,21 +674,29 @@ def machine_instrs_from_block( result = [] for inst in block: if inst.opcode == ".label": + label = inst.operands[0] if inst.operands else inst.comment result.append(MachineInstr( - MachineOp.LABEL, comment=inst.comment, + MachineOp.LABEL, comment=label, )) continue # Resolve opcode - try: - mop = MachineOp(inst.opcode) - except ValueError: - mop = MachineOp.MV # fallback - - # Build operands + mop = MachineOp(inst.opcode) + + # Move semantic branch/jump targets back to MachineInstr.comment, + # preserving the legacy MachineInstr representation on round-trip. + operand_strings = list(inst.operands) + comment = inst.comment + semantics = get_machine_semantics(mop) + if semantics.target_from_comment: + if semantics.target_required and not operand_strings: + raise ValueError(f"{mop.value} requires a target label") + if operand_strings: + comment = operand_strings.pop() + + # Build register/immediate 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"): + if s in _REG_NUMS: return MachineOperand.reg(s) try: return MachineOperand.immediate(int(s)) @@ -644,7 +706,7 @@ def _to_mop(s: str) -> MachineOperand: dst = None src1 = None src2 = None - ops = [_to_mop(o) for o in inst.operands] + ops = [_to_mop(o) for o in operand_strings] if len(ops) >= 1: dst = ops[0] if len(ops) >= 2: @@ -652,6 +714,6 @@ def _to_mop(s: str) -> MachineOperand: if len(ops) >= 3: src2 = ops[2] - result.append(MachineInstr(mop, dst, src1, src2, inst.comment)) + result.append(MachineInstr(mop, dst, src1, src2, comment)) return result diff --git a/scratchv/backend/regalloc_linear_v1_5.py b/scratchv/backend/regalloc_linear_v1_5.py index c5f6af0..f36933d 100644 --- a/scratchv/backend/regalloc_linear_v1_5.py +++ b/scratchv/backend/regalloc_linear_v1_5.py @@ -18,23 +18,32 @@ from typing import Optional from scratchv.backend.machine_types import ( - MachineInstr, MachineOp, MachineOperand, + ALL_REGS, ARG_REGS, MachineInstr, MachineOp, MachineOperand, ) +from scratchv.backend.machine_semantics import ( + get_machine_semantics, + linear_scan_operands, + virtual_register_defs_uses, +) +from scratchv.backend.regalloc_metrics import ( + count_spill_reload_sites, + peak_live_intervals, +) +from scratchv.backend.regalloc_cfg import ( + MachineCFG, + analyze_control_flow, + apply_cfg_liveness, +) +from scratchv.backend.regalloc_rewrite import rewrite_with_spills # --------------------------------------------------------------------------- # 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 -] +# Canonical 19-register bank shared with the greedy allocator. Keep the +# private alias for compatibility with existing benchmark imports. +_INT_REGS = list(ALL_REGS) _FP_REGS = [ "f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", @@ -119,6 +128,12 @@ def __repr__(self) -> str: def to_asm(self, rename: Optional[dict[str, str]] = None) -> str: """Emit this instruction as assembly after register renaming.""" + if self.opcode == ".label": + label = self.operands[0] if self.operands else self.comment + if not label: + raise ValueError("machine label must have a name") + return f"{label}:" + ops = self.operands[:] if rename: ops = [rename.get(o, o) for o in ops] @@ -194,6 +209,15 @@ def __init__(self, phys_regs: Optional[list[str]] = None): phys_regs if phys_regs is not None else list(_DEFAULT_PHYS_REGS) ) + valid_regs = set(ALL_REGS) | set(ARG_REGS) + invalid = [reg for reg in self.phys_regs if reg not in valid_regs] + if invalid: + raise ValueError( + "linear regalloc supports only RV32 integer allocatable " + f"registers; invalid: {', '.join(invalid)}" + ) + if len(set(self.phys_regs)) != len(self.phys_regs): + raise ValueError("linear regalloc physical registers must be unique") self.stack_slot: int = 0 self.alloc_map: dict[str, str] = {} self.spill_code: dict[int, list[str]] = {} # pos -> [sw asm lines] @@ -206,8 +230,19 @@ def __init__(self, phys_regs: Optional[list[str]] = None): 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.peak_real_pressure: int = 0 # compatibility alias for pressure_peak + self.pressure_peak: int = 0 + self.pressure_excess_peak: int = 0 + self.spill_store_count: int = 0 + self.reload_load_count: int = 0 self._scratch_cache: dict[str, str] = {} # vreg -> last scratch reg for reload memory + self.cfg: MachineCFG = MachineCFG([], {}, {}) + + @property + def spill_slot_count(self) -> int: + """Return the number of unique stack slots reserved for spills.""" + + return len(self._spill_slots) # ------------------------------------------------------------------ # Live interval computation @@ -262,7 +297,8 @@ def compute_live_intervals( vreg=vreg, start=start, end=end, uses=uses, )) - return sorted(intervals, key=lambda iv: iv.start) + self.cfg = analyze_control_flow(block) + return apply_cfg_liveness(intervals, self.cfg) # ------------------------------------------------------------------ # Linear scan allocation @@ -286,10 +322,18 @@ def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: self._reloads.clear() self._spilled.clear() self._evictions.clear() + self._scratch_cache.clear() + self.stack_slot = 0 self._intervals = intervals self._vreg_interval = {iv.vreg: iv for iv in intervals} self.peak_active = 0 - self.peak_real_pressure = 0 + self.pressure_peak = peak_live_intervals(intervals) + self.peak_real_pressure = self.pressure_peak + self.pressure_excess_peak = max( + 0, self.pressure_peak - len(self.phys_regs) + ) + self.spill_store_count = 0 + self.reload_load_count = 0 # Active list: (interval, phys_reg) sorted by increasing end active: list[tuple[LiveInterval, str]] = [] @@ -320,10 +364,6 @@ def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: 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]], @@ -415,7 +455,7 @@ def emit(self, block: list[LsInstruction]) -> str: self.allocate(intervals) return self.get_allocated_code(block) - def get_allocated_code(self, block: list[LsInstruction]) -> str: + def _get_allocated_code_legacy(self, block: list[LsInstruction]) -> str: """Generate allocated assembly with spill stores and reloads. Walks the instruction block in order. Before each instruction @@ -427,6 +467,7 @@ def get_allocated_code(self, block: list[LsInstruction]) -> str: rename: dict[str, str] = dict(self.alloc_map) for inst in block: + post_inst_spills: list[str] = [] # Emit eviction spill stores before reloads at this position if inst.id in self._evictions: lines.extend(self._evictions[inst.id]) @@ -490,7 +531,7 @@ def get_allocated_code(self, block: list[LsInstruction]) -> str: # 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( + post_inst_spills.append( f" sw {cur}, {slot}(sp)" f" # store redefined {d}" ) @@ -500,8 +541,14 @@ def get_allocated_code(self, block: list[LsInstruction]) -> str: # Insert spill stores after the instruction if inst.id in self.spill_code: lines.extend(self.spill_code[inst.id]) + lines.extend(post_inst_spills) - return "\n".join(lines) + assembly = "\n".join(lines) + ( + self.spill_store_count, + self.reload_load_count, + ) = count_spill_reload_sites(assembly) + return assembly def _pick_reload_reg(self, rename: dict[str, str], current_pos: int, protected_vregs: set[str] | None = None, @@ -675,6 +722,16 @@ def _pick_scratch(self, vreg: str, busy: set[str] | None = None) -> str: self._scratch_cache[vreg] = reg return reg + def get_allocated_code(self, block: list[LsInstruction]) -> str: + """Emit allocated code through the CFG-aware spill rewriter.""" + + assembly = rewrite_with_spills(self, block) + ( + self.spill_store_count, + self.reload_load_count, + ) = count_spill_reload_sites(assembly) + return assembly + # ------------------------------------------------------------------ # Report # ------------------------------------------------------------------ @@ -682,13 +739,18 @@ def _pick_scratch(self, vreg: str, busy: set[str] | None = None) -> str: def report(self) -> str: """Return a string summary of the allocation result.""" total = len(self.alloc_map) - spilled = len(self._spill_slots) + spilled = self.spill_slot_count 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" Static spill stores: {self.spill_store_count}") + parts.append(f" Static reload loads: {self.reload_load_count}") 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" Peak live-register pressure: {self.pressure_peak}") + parts.append( + f" Peak pressure above register bank: {self.pressure_excess_peak}" + ) parts.append( f" Physical registers available: {len(self.phys_regs)}" ) @@ -719,24 +781,8 @@ def block_from_machine_instrs( """ 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) + defines, uses = virtual_register_defs_uses(mi) + operands, comment = linear_scan_operands(mi) if mi.op.value == ".label": result.append(LsInstruction( @@ -750,7 +796,7 @@ def block_from_machine_instrs( operands=operands, defines=defines, uses=uses, - comment=mi.comment, + comment=comment, )) return result @@ -776,18 +822,27 @@ def machine_instrs_from_block( result = [] for inst in block: if inst.opcode == ".label": + label = inst.operands[0] if inst.operands else inst.comment result.append(MachineInstr( - MachineOp.LABEL, comment=inst.comment, + MachineOp.LABEL, comment=label, )) continue # Resolve opcode - try: - mop = MachineOp(inst.opcode) - except ValueError: - mop = MachineOp.MV # fallback - - # Build operands + mop = MachineOp(inst.opcode) + + # Move semantic branch/jump targets back to MachineInstr.comment, + # preserving the legacy MachineInstr representation on round-trip. + operand_strings = list(inst.operands) + comment = inst.comment + semantics = get_machine_semantics(mop) + if semantics.target_from_comment: + if semantics.target_required and not operand_strings: + raise ValueError(f"{mop.value} requires a target label") + if operand_strings: + comment = operand_strings.pop() + + # Build register/immediate 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 @@ -805,7 +860,7 @@ def _to_mop(s: str) -> MachineOperand: dst = None src1 = None src2 = None - ops = [_to_mop(o) for o in inst.operands] + ops = [_to_mop(o) for o in operand_strings] if len(ops) >= 1: dst = ops[0] if len(ops) >= 2: @@ -813,6 +868,6 @@ def _to_mop(s: str) -> MachineOperand: if len(ops) >= 3: src2 = ops[2] - result.append(MachineInstr(mop, dst, src1, src2, inst.comment)) + result.append(MachineInstr(mop, dst, src1, src2, comment)) - return result \ No newline at end of file + return result diff --git a/scratchv/backend/regalloc_metrics.py b/scratchv/backend/regalloc_metrics.py new file mode 100644 index 0000000..a074145 --- /dev/null +++ b/scratchv/backend/regalloc_metrics.py @@ -0,0 +1,41 @@ +"""Shared, explicitly named metrics for linear-scan register allocation.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + + +def peak_live_intervals(intervals: Iterable[Any]) -> int: + """Return the exact maximum number of overlapping half-open intervals.""" + + interval_list = list(intervals) + if not interval_list: + return 0 + starts = {interval.start for interval in interval_list} + return max( + sum( + interval.start <= position < interval.end + for interval in interval_list + ) + for position in starts + ) + + +def count_spill_reload_sites(assembly: str) -> tuple[int, int]: + """Count allocator-inserted static spill stores and reload loads. + + Counts are based on allocator-only tags, so a user comment containing + words such as ``spill`` or ``reload`` cannot alter benchmark results. + Dynamic execution counts are intentionally a separate metric. + """ + + spill_stores = 0 + reload_loads = 0 + for line in assembly.splitlines(): + content = line.strip() + if content.startswith("sw ") and "[regalloc:spill]" in content: + spill_stores += 1 + if content.startswith("lw ") and "[regalloc:reload]" in content: + reload_loads += 1 + return spill_stores, reload_loads diff --git a/scratchv/backend/regalloc_rewrite.py b/scratchv/backend/regalloc_rewrite.py new file mode 100644 index 0000000..98ed9bc --- /dev/null +++ b/scratchv/backend/regalloc_rewrite.py @@ -0,0 +1,323 @@ +"""Correct spill rewriting shared by the linear-scan allocator variants.""" + +from __future__ import annotations + +from typing import Any + +from scratchv.backend.machine_semantics import get_machine_semantics +from scratchv.backend.machine_types import MachineOp + + +def rewrite_with_spills(allocator: Any, instructions: list[Any]) -> str: + """Rewrite virtual operands, inserting executable spill/reload code. + + The allocation pass supplies preferred registers and pressure/victim + decisions. This final rewrite owns the actual register contents. In + particular it guarantees that distinct source vregs of one instruction + occupy distinct registers, canonicalizes live values to stack slots at + CFG boundaries, and invalidates ABI-clobbered registers across calls. + """ + + if not instructions: + return "" + if not allocator.phys_regs: + raise RuntimeError("regalloc: physical register pool is empty") + + # Allocation-time event lists are planning artifacts. Rebuilding the + # actual transfers here avoids stale same-position eviction/reload state. + allocator.spill_code.clear() + allocator._reloads.clear() + allocator._evictions.clear() + + cfg = allocator.cfg + block_by_instruction = { + inst.id: block for block in cfg.blocks for inst in block.instructions + } + block_first = {block.start: block for block in cfg.blocks} + block_last = {block.end - 1: block for block in cfg.blocks} + + all_defines = set().union(*(inst.defines for inst in instructions)) + # Once allocation contains a split/spilled interval, reloads can evict a + # nominally register-resident value on only one predecessor. Canonicalize + # every edge-live value through its stack slot in that case so all incoming + # paths agree at joins. The set is fixed before emission; deriving it from + # mutable rewrite state would make correctness depend on textual block + # order. + canonical_vregs: set[str] = set() + if allocator._spilled: + canonical_vregs = set().union( + *(block.live_out for block in cfg.blocks) + ) + locations: dict[str, str] = {} + reg_owner: dict[str, str] = {} + stack_current: set[str] = set() + rename: dict[str, str] = {} + lines: list[str] = [] + + def has_later_use(vreg: str, position: int, block: Any) -> bool: + interval = allocator._vreg_interval.get(vreg) + return bool( + (interval and any(use > position for use in interval.uses)) + or vreg in block.live_out + ) + + def forget_register(reg: str) -> None: + owner = reg_owner.pop(reg, None) + if owner is not None and locations.get(owner) == reg: + locations.pop(owner, None) + + def store_owner(reg: str, position: int, block: Any, reason: str) -> None: + owner = reg_owner.get(reg) + if owner is None or owner in stack_current: + return + if not has_later_use(owner, position, block): + return + slot = allocator._get_spill_slot(owner) + allocator._spilled.add(owner) + lines.append( + f" sw {reg}, {slot}(sp) # {reason} {owner} [regalloc:spill]" + ) + stack_current.add(owner) + + def claim_register(vreg: str, reg: str) -> None: + old = reg_owner.get(reg) + if old is not None and old != vreg: + locations.pop(old, None) + previous = locations.get(vreg) + if previous is not None and previous != reg: + reg_owner.pop(previous, None) + reg_owner[reg] = vreg + locations[vreg] = reg + rename[vreg] = reg + + def choose_register( + vreg: str, + position: int, + block: Any, + protected: set[str], + preferred: str | None = None, + ) -> str: + candidates = [] + if preferred in allocator.phys_regs: + candidates.append(preferred) + candidates.extend(reg for reg in allocator.phys_regs if reg not in candidates) + + # Keep the allocator's global assignment stable across CFG edges. + # Even if another register is currently dead, silently choosing it + # would make successor blocks read the value from the wrong place. + if preferred in candidates and preferred not in protected: + owner = reg_owner.get(preferred) + if owner is not None and has_later_use(owner, position, block): + store_owner(preferred, position, block, "evict") + forget_register(preferred) + return preferred + + for reg in candidates: + if reg in protected: + continue + owner = reg_owner.get(reg) + if owner is None or not has_later_use(owner, position, block): + forget_register(reg) + return reg + + victims = [reg for reg in candidates if reg not in protected] + if not victims: + raise RuntimeError( + "regalloc: instruction at position " + f"{position} needs more distinct source registers than the " + f"{len(allocator.phys_regs)}-register pool provides" + ) + + def next_use(reg: str) -> int: + owner = reg_owner.get(reg) + interval = allocator._vreg_interval.get(owner) if owner else None + future = [use for use in interval.uses if use > position] if interval else [] + return min(future) if future else 1 << 30 + + victim = max(victims, key=next_use) + store_owner(victim, position, block, "evict") + forget_register(victim) + return victim + + def store_live_out(block: Any, position: int) -> None: + for vreg in sorted(block.live_out): + # Values with a stable global physical assignment already have + # the same location on every edge. Only split/spilled values need + # the canonical stack hand-off between basic blocks. + if vreg not in canonical_vregs \ + and allocator.alloc_map.get(vreg) in allocator.phys_regs \ + and vreg not in allocator._spilled: + continue + reg = locations.get(vreg) + if reg is None or vreg in stack_current: + continue + slot = allocator._get_spill_slot(vreg) + allocator._spilled.add(vreg) + lines.append( + f" sw {reg}, {slot}(sp) # spill {vreg} at block boundary " + "[regalloc:spill]" + ) + stack_current.add(vreg) + + for inst in instructions: + block = block_by_instruction[inst.id] + if inst.id in block_first: + locations.clear() + reg_owner.clear() + rename.clear() + stack_current.clear() + stack_current.update(canonical_vregs & block.live_in) + # Non-spilled intervals keep one global physical assignment, so + # every predecessor agrees on their location. Spilled intervals + # cross an edge through their canonical stack slot instead. + for vreg in sorted(block.live_in): + preferred = allocator.alloc_map.get(vreg) + if preferred in allocator.phys_regs \ + and vreg not in canonical_vregs \ + and vreg not in allocator._spilled \ + and preferred not in reg_owner: + claim_register(vreg, preferred) + + semantics = None + try: + semantics = get_machine_semantics(MachineOp(inst.opcode)) + except ValueError: + pass + + # Physical operands are fixed constraints, not values available to + # the allocator. Preserve a live virtual value currently occupying + # an explicitly-written register before that instruction clobbers it. + physical_uses: set[str] = set() + physical_defs: set[str] = set() + if semantics is not None: + for position in semantics.uses: + if position < len(inst.operands): + operand = inst.operands[position] + if operand in allocator.phys_regs: + physical_uses.add(operand) + for position in semantics.defs: + if position < len(inst.operands): + operand = inst.operands[position] + if operand in allocator.phys_regs: + physical_defs.add(operand) + for reg in physical_defs: + store_owner(reg, inst.id, block, "spill before physical clobber") + + ordered_uses: list[str] = [] + for operand in inst.operands: + if operand in inst.uses and operand not in ordered_uses: + ordered_uses.append(operand) + # Keep malformed/custom LsInstruction tests deterministic too. + ordered_uses.extend(sorted(inst.uses - set(ordered_uses))) + + if len(ordered_uses) > len(allocator.phys_regs): + raise RuntimeError( + "regalloc: instruction at position " + f"{inst.id} has {len(ordered_uses)} distinct register uses, " + f"but only {len(allocator.phys_regs)} physical registers" + ) + + # Protect resident values for *all* sources before emitting any load. + # Otherwise loading the first spilled source could evict a second + # source whose value has not yet been consumed by the instruction. + protected: set[str] = { + locations[vreg] + for vreg in ordered_uses + if vreg in locations and reg_owner.get(locations[vreg]) == vreg + } + protected.update(physical_uses) + for vreg in ordered_uses: + resident = locations.get(vreg) + if resident is not None and reg_owner.get(resident) == vreg: + reg = resident + elif vreg not in stack_current and vreg not in all_defines: + preferred = allocator.alloc_map.get(vreg) + reg = choose_register(vreg, inst.id, block, protected, preferred) + else: + if vreg not in stack_current: + raise RuntimeError( + f"regalloc: value {vreg!r} has no resident register " + f"or initialized spill slot at position {inst.id}" + ) + preferred = allocator.alloc_map.get(vreg) + reg = choose_register(vreg, inst.id, block, protected, preferred) + slot = allocator._get_spill_slot(vreg) + lines.append( + f" lw {reg}, {slot}(sp) # reload {vreg} " + "[regalloc:reload]" + ) + claim_register(vreg, reg) + protected.add(reg) + + ordered_defines: list[str] = [] + for operand in inst.operands: + if operand in inst.defines and operand not in ordered_defines: + ordered_defines.append(operand) + ordered_defines.extend(sorted(inst.defines - set(ordered_defines))) + + # RISC-V reads sources before writing rd, so a destination may reuse + # any source register. If that source remains live, choose_register + # first writes its old value to the canonical spill slot; the already + # materialized source operand still names the same register for this + # instruction, and later uses reload the saved value. + definition_protected: set[str] = set(physical_uses) + for vreg in ordered_defines: + resident = locations.get(vreg) + if resident is not None and reg_owner.get(resident) == vreg: + # A pure redefinition may overwrite its own old value. Do + # not classify that overwrite as an eviction merely because + # the interval also contains uses of the newly defined value. + reg = resident + else: + preferred = allocator.alloc_map.get(vreg) + reg = choose_register( + vreg, inst.id, block, definition_protected, preferred + ) + claim_register(vreg, reg) + definition_protected.add(reg) + + is_last = inst.id in block_last + if is_last and semantics and semantics.is_terminator: + store_live_out(block, inst.id) + + # Calls fall through but clobber caller-saved registers. Save only + # values actually live afterwards and reload them lazily on demand. + if semantics and semantics.is_call: + for reg in list(reg_owner): + if reg in semantics.clobbers: + store_owner(reg, inst.id, block, "spill") + + lines.append(inst.to_asm(rename)) + + # The instruction has now overwritten its explicit physical + # destinations; any old virtual ownership is stale. + for reg in physical_defs: + forget_register(reg) + + for vreg in ordered_defines: + stack_current.discard(vreg) + + # Once allocation has split/spilled a vreg, its stack slot is the + # canonical value between reloads. Every later definition must update + # that slot before another instruction can evict the transient result. + for vreg in ordered_defines: + if vreg not in allocator._spilled or not has_later_use(vreg, inst.id, block): + continue + reg = locations[vreg] + slot = allocator._get_spill_slot(vreg) + lines.append( + f" sw {reg}, {slot}(sp) # store redefined {vreg} " + "[regalloc:spill]" + ) + stack_current.add(vreg) + forget_register(reg) + + if semantics and semantics.is_call: + for reg in list(reg_owner): + if reg in semantics.clobbers: + forget_register(reg) + + if is_last and not (semantics and semantics.is_terminator): + store_live_out(block, inst.id) + + return "\n".join(lines) diff --git a/scratchv/backend/register_alloc.py b/scratchv/backend/register_alloc.py index 15d3e1b..7fc2691 100644 --- a/scratchv/backend/register_alloc.py +++ b/scratchv/backend/register_alloc.py @@ -13,6 +13,11 @@ from typing import Optional +from scratchv.backend.machine_semantics import ( + get_machine_semantics, + virtual_register_defs_uses, +) + from scratchv.backend.machine_types import ( # noqa: F401 — re-export ALL_REGS, ARG_REGS, @@ -57,6 +62,13 @@ def __init__(self, instructions: list[MachineInstr], mode: str = "greedy"): # Track which physical registers are currently allocated self._reg_pool: dict[str, Optional[str]] = {r: None for r in ALL_REGS} self._output: list[MachineInstr] = [] + self._remaining_uses: dict[str, int] = {} + + @property + def spill_slot_count(self) -> int: + """Return the number of unique stack slots reserved for spills.""" + + return len(self._spill_slots) def run(self) -> list[MachineInstr]: if self.mode == "naive": @@ -67,68 +79,225 @@ def run(self) -> list[MachineInstr]: def _allocate_naive(self) -> list[MachineInstr]: """Spill every virtual register to the stack.""" self._output = [] + self._spill_slots.clear() + self._next_spill = 0 for instr in self.instructions: if instr.op == MachineOp.LABEL: self._emit(instr) continue - # Before: spill src operands that are vregs - src1 = self._resolve_src(instr.src1) - src2 = self._resolve_src(instr.src2) - dst = self._resolve_dst(instr.dst) + semantics = get_machine_semantics(instr.op) + operands = [instr.dst, instr.src1, instr.src2] + resolved = list(operands) + explicit_regs = { + str(operand.value) + for operand in operands + if operand is not None + and operand.kind == "reg" + and operand.value in ALL_REGS + } + scratch_regs = [ + reg for reg in ALL_REGS if reg not in explicit_regs + ] + scratch_by_vreg: dict[str, str] = {} + + def scratch_for(vreg: str) -> str: + if vreg in scratch_by_vreg: + return scratch_by_vreg[vreg] + used = set(scratch_by_vreg.values()) + try: + reg = next(reg for reg in scratch_regs if reg not in used) + except StopIteration as exc: + raise RuntimeError( + "naive regalloc: instruction needs more distinct " + "scratch registers than are available" + ) from exc + scratch_by_vreg[vreg] = reg + return reg + + # Materialize every distinct virtual source into its own scratch + # register. Reusing one fixed temporary silently overwrites the + # first source of binary instructions. + for position in semantics.uses: + operand = operands[position] + if operand is None or operand.kind != "vreg": + continue + vreg = str(operand.value) + reg = scratch_for(vreg) + slot = self._get_spill_slot(vreg) + self._emit(MachineInstr( + MachineOp.LW, + MachineOperand.reg(reg), + MachineOperand.reg(f"{slot}({STACK_BASE})"), + comment=f"reload {vreg} [regalloc:reload]", + )) + resolved[position] = MachineOperand.reg(reg) - if instr.dst and instr.dst.kind == "vreg": - dst = self._spill_operand(instr.dst) + # A destination never needs its old stack value unless the opcode + # also marks that same virtual register as a use. + for position in semantics.defs: + operand = operands[position] + if operand is None or operand.kind != "vreg": + continue + vreg = str(operand.value) + resolved[position] = MachineOperand.reg(scratch_for(vreg)) - self._emit(MachineInstr(instr.op, dst, src1, src2, instr.comment)) + self._emit(MachineInstr( + instr.op, + resolved[0], + resolved[1], + resolved[2], + instr.comment, + )) - # After: store dst back to stack if it's a vreg - if instr.dst and instr.dst.kind == "vreg": - v = instr.dst.value - assert isinstance(v, str) - slot = self._get_spill_slot(v) - mem = f"{STACK_BASE}({-slot})" if slot > 0 else "0(sp)" + stored: set[str] = set() + for position in semantics.defs: + operand = operands[position] + if operand is None or operand.kind != "vreg": + continue + vreg = str(operand.value) + if vreg in stored: + continue + stored.add(vreg) + reg = scratch_by_vreg[vreg] + slot = self._get_spill_slot(vreg) self._emit(MachineInstr( MachineOp.SW, - MachineOperand.reg(mem), - dst if dst else MachineOperand.reg("zero"), - comment=f"spill {instr.dst.value}", + MachineOperand.reg(reg), + MachineOperand.reg(f"{slot}({STACK_BASE})"), + comment=f"spill {vreg} [regalloc:spill]", )) return self._output def _allocate_greedy(self) -> list[MachineInstr]: - """Simple greedy allocator: assign physical registers to vregs.""" + """Allocate locally, with real spill reloads and block barriers.""" self._output = [] self._vreg_map.clear() + self._spill_slots.clear() + self._next_spill = 0 self._reg_pool = {r: None for r in ALL_REGS} - + self._remaining_uses = {} for instr in self.instructions: + _, uses = virtual_register_defs_uses(instr) + for vreg in uses: + self._remaining_uses[vreg] = self._remaining_uses.get(vreg, 0) + 1 + + fallthrough_boundaries = { + index - 1 + for index, instr in enumerate(self.instructions) + if index > 0 and instr.op == MachineOp.LABEL + } + + for index, instr in enumerate(self.instructions): if instr.op == MachineOp.LABEL: + self._vreg_map.clear() + self._reg_pool = {r: None for r in ALL_REGS} self._emit(instr) continue - src1 = self._resolve_src(instr.src1) - src2 = self._resolve_src(instr.src2) - dst = self._resolve_dst(instr.dst) - - # Allocate destination register - if instr.dst and instr.dst.kind == "vreg" \ - and instr.dst.value not in self._vreg_map: - v2 = instr.dst.value - assert isinstance(v2, str) - reg_name = self._assign_reg(v2) - dst = MachineOperand.reg(reg_name) - elif instr.dst and instr.dst.kind == "vreg": - v3 = instr.dst.value - assert isinstance(v3, str) - dst = MachineOperand.reg(self._vreg_map[v3]) + semantics = get_machine_semantics(instr.op) + operands = [instr.dst, instr.src1, instr.src2] + resolved = list(operands) + explicit_uses = { + str(operands[position].value) + for position in semantics.uses + if operands[position] is not None + and operands[position].kind == "reg" + and operands[position].value in ALL_REGS + } + explicit_defs = { + str(operands[position].value) + for position in semantics.defs + if operands[position] is not None + and operands[position].kind == "reg" + and operands[position].value in ALL_REGS + } + for phys_reg in explicit_defs: + owner = self._reg_pool[phys_reg] + if owner is not None and self._remaining_uses.get(owner, 0) > 0: + self._emit_spill(owner, phys_reg) + reserved: set[str] = set(explicit_uses) + + # Resolve every use first so a destination can safely alias a + # source whose last use is this instruction. + for position in semantics.uses: + operand = operands[position] + resolved[position] = self._resolve_src(operand, reserved) + resolved_operand = resolved[position] + if resolved_operand is not None and resolved_operand.kind == "reg" \ + and resolved_operand.value in ALL_REGS: + reserved.add(str(resolved_operand.value)) + + reusable = { + self._vreg_map[vreg] + for vreg in ( + str(operands[position].value) + for position in semantics.uses + if operands[position] is not None + and operands[position].kind == "vreg" + ) + if self._remaining_uses.get(vreg, 0) <= 1 + and vreg in self._vreg_map + } + + for position in semantics.defs: + operand = operands[position] + if position in semantics.uses: + continue + resolved[position] = self._resolve_dst( + operand, reserved - reusable + ) + + allocated = MachineInstr( + instr.op, resolved[0], resolved[1], resolved[2], instr.comment + ) + + # A call is not a CFG terminator, but it invalidates caller-saved + # mappings. Ordinary branches retain their stable global mapping; + # eager block-boundary flushing would manufacture spills even when + # peak pressure is below the register bank (the CNN case). + if semantics.is_call: + self._flush_clobbered(semantics.clobbers) + elif semantics.is_terminator: + defines, _ = virtual_register_defs_uses(instr) + if defines: + raise RuntimeError( + "greedy regalloc does not support a control-flow " + "terminator defining a virtual register" + ) + self._flush_regs() + self._emit(allocated) + + # A fixed physical destination overwrites any virtual value that + # happened to occupy that register. + for phys_reg in explicit_defs: + owner = self._reg_pool[phys_reg] + if owner is not None: + self._vreg_map.pop(owner, None) + self._reg_pool[phys_reg] = None - self._emit(MachineInstr(instr.op, dst, src1, src2, instr.comment)) + _, uses = virtual_register_defs_uses(instr) + defines, _ = virtual_register_defs_uses(instr) + for vreg in uses: + self._remaining_uses[vreg] -= 1 + if self._remaining_uses[vreg] == 0 and vreg not in defines: + self._release_vreg(vreg) + for vreg in defines: + if self._remaining_uses.get(vreg, 0) == 0: + self._release_vreg(vreg) + + # A label may be reached either by fallthrough or by another CFG + # edge. Canonicalize the fallthrough predecessor before the label + # so every incoming edge observes initialized spill slots. + if index in fallthrough_boundaries and not semantics.is_terminator: + self._flush_regs() return self._output - def _resolve_src(self, op: MachineOperand | None) -> MachineOperand | None: + def _resolve_src( + self, op: MachineOperand | None, avoid_regs: set[str] | None = None, + ) -> MachineOperand | None: if op is None: return None if op.kind == "imm": @@ -139,14 +308,17 @@ def _resolve_src(self, op: MachineOperand | None) -> MachineOperand | None: if op.value in self._vreg_map: r = self._vreg_map[op.value] # type: ignore[index] return MachineOperand.reg(r) - # Assign a register v = op.value assert isinstance(v, str) - reg = self._assign_reg(v) + reg = self._assign_reg( + v, reload=v in self._spill_slots, avoid_regs=avoid_regs + ) return MachineOperand.reg(reg) return op - def _resolve_dst(self, op: MachineOperand | None) -> MachineOperand | None: + def _resolve_dst( + self, op: MachineOperand | None, avoid_regs: set[str] | None = None, + ) -> MachineOperand | None: if op is None: return None if op.kind == "reg": @@ -157,52 +329,101 @@ def _resolve_dst(self, op: MachineOperand | None) -> MachineOperand | None: return MachineOperand.reg(r2) v = op.value assert isinstance(v, str) - reg = self._assign_reg(v) + reg = self._assign_reg(v, reload=False, avoid_regs=avoid_regs) return MachineOperand.reg(reg) return op - def _assign_reg(self, vreg_name: str) -> str: + def _assign_reg( + self, + vreg_name: str, + *, + reload: bool = False, + avoid_regs: set[str] | None = None, + ) -> str: """Assign a physical register to a virtual register.""" if vreg_name in self._vreg_map: return self._vreg_map[vreg_name] - # Find a free register + avoid = avoid_regs or set() for phys_reg, occupant in self._reg_pool.items(): - if occupant is None: + if occupant is None and phys_reg not in avoid: self._reg_pool[phys_reg] = vreg_name self._vreg_map[vreg_name] = phys_reg + if reload: + self._emit_reload(vreg_name, phys_reg) return phys_reg - # No free register: spill the one used longest ago (simple LRU) - lru_reg = TEMP_REGS[0] + # Pick an unprotected victim whose next use is farthest away. + candidates = [reg for reg in ALL_REGS if reg not in avoid] + if not candidates: + raise RuntimeError( + "greedy regalloc: instruction needs more simultaneous " + f"register operands than the {len(ALL_REGS)}-register bank" + ) + + def remaining(reg: str) -> int: + owner = self._reg_pool[reg] + return self._remaining_uses.get(owner or "", 0) + + lru_reg = min(candidates, key=remaining) lru_vreg = self._reg_pool[lru_reg] if lru_vreg: - # Spill: store to stack - slot = self._get_spill_slot(lru_vreg) - mem = f"{STACK_BASE}({-slot})" - self._emit(MachineInstr( - MachineOp.SW, MachineOperand.reg(mem), - MachineOperand.reg(lru_reg), - comment=f"spill {lru_vreg}", - )) + if self._remaining_uses.get(lru_vreg, 0) > 0: + self._emit_spill(lru_vreg, lru_reg) + self._vreg_map.pop(lru_vreg, None) self._reg_pool[lru_reg] = vreg_name self._vreg_map[vreg_name] = lru_reg + if reload: + self._emit_reload(vreg_name, lru_reg) return lru_reg def _flush_regs(self) -> None: """Spill all registers at basic block boundaries.""" for phys_reg, vreg_name in list(self._reg_pool.items()): if vreg_name is not None: - slot = self._get_spill_slot(vreg_name) # type: ignore[arg-type] - mem = f"{STACK_BASE}({-slot})" - self._emit(MachineInstr( - MachineOp.SW, MachineOperand.reg(mem), - MachineOperand.reg(phys_reg), - comment=f"spill {vreg_name}", - )) + if self._remaining_uses.get(vreg_name, 0) > 0: + self._emit_spill(vreg_name, phys_reg) self._reg_pool[phys_reg] = None self._vreg_map.clear() + def _flush_clobbered(self, clobbers: frozenset[str]) -> None: + """Canonicalize values held in ABI-clobbered registers before call.""" + for phys_reg in clobbers: + if phys_reg not in self._reg_pool: + continue + vreg_name = self._reg_pool[phys_reg] + if vreg_name is None: + continue + if self._remaining_uses.get(vreg_name, 0) > 0: + self._emit_spill(vreg_name, phys_reg) + self._vreg_map.pop(vreg_name, None) + self._reg_pool[phys_reg] = None + + def _release_vreg(self, vreg_name: str) -> None: + reg = self._vreg_map.pop(vreg_name, None) + if reg is not None and self._reg_pool.get(reg) == vreg_name: + self._reg_pool[reg] = None + + def _emit_spill(self, vreg_name: str, phys_reg: str) -> None: + slot = self._get_spill_slot(vreg_name) + mem = f"{slot}({STACK_BASE})" + self._emit(MachineInstr( + MachineOp.SW, + MachineOperand.reg(phys_reg), + MachineOperand.reg(mem), + comment=f"spill {vreg_name} [regalloc:spill]", + )) + + def _emit_reload(self, vreg_name: str, phys_reg: str) -> None: + slot = self._get_spill_slot(vreg_name) + mem = f"{slot}({STACK_BASE})" + self._emit(MachineInstr( + MachineOp.LW, + MachineOperand.reg(phys_reg), + MachineOperand.reg(mem), + comment=f"reload {vreg_name} [regalloc:reload]", + )) + def _get_spill_slot(self, vreg_name: str) -> int: if vreg_name not in self._spill_slots: self._next_spill -= 4 @@ -215,7 +436,7 @@ def _spill_operand(self, op: MachineOperand) -> MachineOperand: assert isinstance(v, str) slot = self._get_spill_slot(v) temp = MachineOperand.reg("t0") - mem = f"{STACK_BASE}({-slot})" if slot != 0 else "0(sp)" + mem = f"{slot}({STACK_BASE})" self._emit(MachineInstr(MachineOp.LW, temp, MachineOperand.reg(mem), comment=f"load {op.value}")) diff --git a/scratchv/backend/riscv_encoder.py b/scratchv/backend/riscv_encoder.py index d39ee4b..79c14d3 100644 --- a/scratchv/backend/riscv_encoder.py +++ b/scratchv/backend/riscv_encoder.py @@ -104,11 +104,17 @@ def _reg_num(name: str) -> int: name = name.strip().lstrip("%") if name in REG_MAP: return REG_MAP[name] + # Some legacy selectors spell the architectural zero register as the + # integer literal 0 in a register position. Accept only that numeric + # alias; every other unknown name is an unresolved/invalid register. + if name == "0": + return 0 # Handle stack-pointer offset syntax: "16(sp)", "-4(sp)" if "(" in name and ")" in name: base = name[name.index("(") + 1:name.index(")")] - return REG_MAP.get(base, 0) - return 0 + if base in REG_MAP: + return REG_MAP[base] + raise ValueError(f"unknown register: {name}") def _sext(val: int, bits: int) -> int: @@ -130,12 +136,16 @@ def _r_type(rd: int, rs1: int, rs2: int, def _i_type(rd: int, rs1: int, imm: int, funct3: int, opcode: RVOpcode = RVOpcode.OP_IMM) -> int: + if not -(1 << 11) <= imm <= (1 << 11) - 1: + raise ValueError(f"I-type immediate out of range: {imm}") return ((_sext(imm, 12) << 20) | (rs1 << 15) | (funct3 << 12) | (rd << 7) | opcode) def _s_type(rs1: int, rs2: int, imm: int, funct3: int) -> int: + if not -(1 << 11) <= imm <= (1 << 11) - 1: + raise ValueError(f"S-type immediate out of range: {imm}") imm = _sext(imm, 12) return ((imm >> 5) << 25) | (rs2 << 20) | (rs1 << 15) \ | (funct3 << 12) | ((imm & 0x1F) << 7) | RVOpcode.STORE @@ -143,6 +153,8 @@ def _s_type(rs1: int, rs2: int, imm: int, def _b_type(rs1: int, rs2: int, imm: int, funct3: int) -> int: + if imm % 2 or not -(1 << 12) <= imm <= (1 << 12) - 2: + raise ValueError(f"branch offset out of range or unaligned: {imm}") imm = _sext(imm, 13) b12 = (imm >> 12) & 1 b10_5 = (imm >> 5) & 0x3F @@ -154,11 +166,15 @@ def _b_type(rs1: int, rs2: int, imm: int, def _u_type(rd: int, imm: int) -> int: + if not -(1 << 19) <= imm <= (1 << 20) - 1: + raise ValueError(f"U-type immediate out of range: {imm}") return ((_sext(imm, 20) << 12) | (rd << 7) | RVOpcode.LUI) def _j_type(rd: int, imm: int) -> int: + if imm % 2 or not -(1 << 20) <= imm <= (1 << 20) - 2: + raise ValueError(f"jump offset out of range or unaligned: {imm}") imm = _sext(imm, 21) b20 = (imm >> 20) & 1 b10_1 = (imm >> 1) & 0x3FF @@ -177,11 +193,12 @@ def __init__(self): self.labels: dict[str, int] = {} # label -> instruction index self.pending_fixups: list[tuple[int, str, str]] = [] self._max_counter = 0 - self._temp_reg = 0 + self._temp_reg: int | None = None + self._reserved_labels: set[str] = set() # ── Pseudo-instruction expansion ────────────────────────────────── - def _find_free_temp(self, asm_text: str) -> int: + def _find_free_temp(self, asm_text: str) -> int | None: """Scan assembly text for used registers; return first free temp. Preference order: t6, t5, t4, t3, t2, t1, t0 (x31 down to x5). @@ -197,7 +214,7 @@ def _find_free_temp(self, asm_text: str) -> int: for r in [31, 30, 29, 28, 7, 6, 5]: if r not in used: return r - return 31 # fallback + return None def _expand_pseudo(self, line: str) -> list[str]: """Expand one possibly-pseudo line into standard RISC-V lines. @@ -213,31 +230,110 @@ def _expand_pseudo(self, line: str) -> list[str]: op = tokens[0].lower() + # Keep every RV32IM pseudo lowering in this pass. _encode_line() + # should only ever see real instructions, which makes it possible to + # test pseudo expansion independently from binary encoding. + if op == "mv": + if len(tokens) != 3: + raise ValueError("mv expects exactly 2 operands") + return [f"addi {tokens[1]}, {tokens[2]}, 0"] + + if op == "bnez": + if len(tokens) != 3: + raise ValueError( + "bnez expects exactly 2 operands: register and label" + ) + return [f"bne {tokens[1]}, x0, {tokens[2]}"] + + if op == "j": + if len(tokens) != 2: + raise ValueError("j expects exactly 1 operand: label") + return [f"jal x0, {tokens[1]}"] + + # A local ``call`` can be represented exactly by ``jal ra, label``. + # This produces a real executable instruction and uses the same strict + # label fixup/undefined-target checks as ordinary jumps. A future ELF + # relocator may choose the wider AUIPC/JALR sequence for far symbols. + if op == "call": + if len(tokens) != 2: + raise ValueError("call expects exactly one target label") + return [f"jal ra, {tokens[1]}"] + # li rd, imm -> addi rd, x0, imm (small values), otherwise the # canonical LUI/ADDI pair. A single RISC-V instruction cannot encode # an arbitrary 32-bit immediate; keeping a large ``li`` as one encoded # word silently drops its low 12 bits. - if op == "li" and len(tokens) >= 3: + if op == "li": + if len(tokens) != 3: + raise ValueError("li expects exactly 2 operands") rd = tokens[1] imm = self._parse_imm(tokens[2]) return self._expand_li(rd, imm) - # max rd, rs1, rs2 → 4-instruction sequence + # max rd, rs1, rs2 → branch-and-copy sequence. ``rs2`` may be + # an immediate in ScratchV IR; materialize it in the encoder's free + # temporary so both the comparison and false arm use the same value. if op == "max": - rd = tokens[1] if len(tokens) > 1 else "x0" - rs1 = tokens[2] if len(tokens) > 2 else "x0" - rs2 = tokens[3] if len(tokens) > 3 else "x0" - n = self._max_counter - self._max_counter += 1 + if len(tokens) != 4: + raise ValueError("max expects exactly 3 operands") + rd = tokens[1] + rs1 = tokens[2] + rs2 = tokens[3] + rhs = rs2 + if rs2 not in REG_MAP and not rs2.startswith("x") \ + and not rs2.startswith("%"): + immediate = self._parse_imm(rs2) + if immediate == 0: + rhs = "x0" + else: + raise ValueError( + "max immediate rhs currently supports only zero" + ) + while True: + n = self._max_counter + self._max_counter += 1 + then_label = f".__max_then_{n}" + end_label = f".__max_end_{n}" + if not {then_label, end_label} & self._reserved_labels: + self._reserved_labels.update({then_label, end_label}) + break return [ - f"bge {rs1}, {rs2}, .__max_then_{n}", - f"addi {rd}, x0, 0", - f"j .__max_end_{n}", - f".__max_then_{n}:", + f"bge {rs1}, {rhs}, {then_label}", + f"addi {rd}, {rhs}, 0", + f"jal x0, {end_label}", + f"{then_label}:", f"addi {rd}, {rs1}, 0", - f".__max_end_{n}:", + f"{end_label}:", ] + # These are genuine RISC-V pseudos used by assembly tooling even + # though the MachineOp layer currently emits JALR directly for return. + if op == "ret": + if len(tokens) != 1: + raise ValueError("ret expects no operands") + return ["jalr x0, ra, 0"] + + if op == "nop": + if len(tokens) != 1: + raise ValueError("nop expects no operands") + return ["addi x0, x0, 0"] + + # These pseudos belong to the optional floating-point backends. Fail + # explicitly instead of letting RV32IM verification appear to cover + # assembly that this encoder and its TinyFive path cannot execute. + extension_pseudos = { + "fabs.d": "D", + "fneg.d": "D", + "li.d": "D", + "fmv.s": "F", + } + if op in extension_pseudos: + extension = extension_pseudos[op] + raise ValueError( + f"{op} requires the RISC-V {extension} extension; " + "RISCVAEncoder currently supports RV32IM only" + ) + # Branch-with-immediate: beq/bne/blt/bge rs1, imm, label # → li xTEMP, imm; beq/bne/blt/bge rs1, xTEMP, label if op in ("beq", "bne", "blt", "bge"): @@ -245,10 +341,15 @@ def _expand_pseudo(self, line: str) -> list[str]: op2 = tokens[2].rstrip(",") if op2 not in REG_MAP and not op2.startswith("x") and not op2.startswith("%"): try: - imm = int(op2) + imm = self._parse_imm(op2) except ValueError: pass else: + if self._temp_reg is None: + raise ValueError( + "branch-immediate expansion needs a free " + "temporary register; t0-t6 are all in use" + ) temp = f"x{self._temp_reg}" label = tokens[3] return self._expand_li(temp, imm) + [ @@ -276,10 +377,18 @@ def _expand_li(rd: str, imm: int) -> list[str]: def assemble(self, asm_text: str) -> bytearray: """Assemble RISC-V assembly text to flat binary.""" + self.labels.clear() + self.pending_fixups.clear() + self._max_counter = 0 # Pre-scan: find a free temp register for pseudo expansion clean_text = "\n".join( line.split("#")[0] for line in asm_text.split("\n") ) + self._reserved_labels = { + line.strip()[:-1].strip() + for line in clean_text.splitlines() + if line.strip().endswith(":") + } self._temp_reg = self._find_free_temp(clean_text) lines = asm_text.strip().split("\n") @@ -370,8 +479,9 @@ def _encode_line( elif op == "srai": rd = _reg_num(operands[0]) rs1 = _reg_num(operands[1]) - shamt = self._parse_imm(operands[2]) & 0x1F - word = _i_type(rd, rs1, shamt | (0b0100000 << 5), F3_SRL_SRA) + shamt = self._parse_imm(operands[2]) + if not 0 <= shamt <= 31: + raise ValueError(f"RV32 shift amount out of range: {shamt}") # Shamt is encoded in lower 5 bits of the 12-bit immediate; # the upper 7 bits are 0100000 for SRAI. imm12 = shamt | (0b0100000 << 5) @@ -443,36 +553,22 @@ def _encode_line( label = operands[2] fixup = ("b", label) word = _b_type(rs1, rs2, 0, F3_BGE) - elif op == "bnez": - rs1 = _reg_num(operands[0]) - label = operands[1] - fixup = ("b", label) - word = _b_type(rs1, 0, 0, F3_BNE) - elif op == "j" or op == "jal": - label = operands[0] + elif op == "jal": + if len(operands) == 1: + rd = 1 + label = operands[0] + elif len(operands) == 2: + rd = _reg_num(operands[0]) + label = operands[1] + else: + raise ValueError("jal expects a label or rd, label") fixup = ("j", label) - word = _j_type(0, 0) + word = _j_type(rd, 0) elif op == "jalr": rd = _reg_num(operands[0]) rs1 = _reg_num(operands[1]) offset = self._parse_imm(operands[2]) if len(operands) > 2 else 0 word = _i_type(rd, rs1, offset, 0, RVOpcode.JALR) - elif op == "li": - raise ValueError("li must be expanded before encoding") - elif op == "mv": - rd = _reg_num(operands[0]) - rs = _reg_num(operands[1]) - word = _i_type(rd, rs, 0, F3_ADD_SUB) - elif op == "call": - if operands: - label = operands[0] - fixup = ("call", label) - word = _u_type(1, 0) - else: - word = _i_type(1, 1, 0, 0, RVOpcode.JALR) - fixup = ("runtime_call", "") - elif op == "ret": - word = _i_type(0, 1, 0, 0, RVOpcode.JALR) elif op == "lui": rd = _reg_num(operands[0]) imm = self._parse_imm(operands[1]) @@ -486,8 +582,6 @@ def _encode_line( else: imm = self._parse_imm(operands[2]) word = _i_type(rd, rs1, imm, F3_SLT) - elif op == "nop": - word = _i_type(0, 0, 0, F3_ADD_SUB) else: raise ValueError(f"Unknown instruction: {op}") @@ -499,6 +593,9 @@ def _apply_fixup(self, word: int, fixup: tuple, current_idx: int) -> int: if kind == "runtime_call": return word + if kind in ("b", "j") and label not in self.labels: + raise ValueError(f"undefined branch target: {label}") + target_idx = self.labels.get(label, current_idx) offset = target_idx - current_idx @@ -510,7 +607,8 @@ def _apply_fixup(self, word: int, fixup: tuple, current_idx: int) -> int: return _b_type(rs1, rs2, byte_offset, funct3) elif kind == "j": byte_offset = offset * 4 - return _j_type(0, byte_offset) + rd = (word >> 7) & 0x1F + return _j_type(rd, byte_offset) elif kind == "call": byte_offset = offset * 4 return _u_type(1, byte_offset >> 12) @@ -518,11 +616,11 @@ def _apply_fixup(self, word: int, fixup: tuple, current_idx: int) -> int: def _parse_imm(self, s: str) -> int: s = s.strip() - if s.startswith("0x"): - return int(s, 16) - if s.startswith("-"): - return int(s) - return int(s) + try: + return int(s, 0) + except ValueError: + # Preserve support for decimal strings with leading zeroes. + return int(s, 10) def _parse_mem(self, s: str) -> tuple[int, int]: """Parse memory operand like '16(sp)' -> (offset, rs1).""" diff --git a/scratchv/compiler.py b/scratchv/compiler.py index bd5648a..c6c4db6 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -410,12 +410,16 @@ def _generate_riscv_linear(self, program) -> str: ) ls_insts = block_from_machine_instrs(machine_instrs) lsa = LinearScanAllocator() - return lsa.emit(ls_insts) + assembly = lsa.emit(ls_insts) + from scratchv.backend.abi_frame import apply_abi_frames + return apply_abi_frames(assembly, lsa.spill_slot_count) alloc = RegisterAllocator(machine_instrs, mode=self.config.reg_alloc) allocated = alloc.run() emitter = AsmEmitter(allocated) - return emitter.emit() + assembly = emitter.emit() + from scratchv.backend.abi_frame import apply_abi_frames + return apply_abi_frames(assembly, alloc.spill_slot_count) def _generate_riscv_dag(self, program) -> str: """DAG-based instruction selection pipeline.""" @@ -436,7 +440,9 @@ def _generate_riscv_dag(self, program) -> str: allocated = alloc.run() emitter = AsmEmitter(allocated) - return emitter.emit() + assembly = emitter.emit() + from scratchv.backend.abi_frame import apply_abi_frames + return apply_abi_frames(assembly, alloc.spill_slot_count) # ── Internal: post-codegen passes ─────────────────────────────────────── diff --git a/scratchv/simulator/rv32_emulator.py b/scratchv/simulator/rv32_emulator.py index 5d0b1a5..98b9bc4 100644 --- a/scratchv/simulator/rv32_emulator.py +++ b/scratchv/simulator/rv32_emulator.py @@ -220,7 +220,7 @@ def read_i32(self, addr: int) -> int: return struct.unpack(" None: - self.mem[addr:addr + 4] = struct.pack(" None: if d["funct3"] == 0b010: # LW raw = bytes(self.mem[addr:addr + 4]) if len(raw) == 4: - self.regs[d["rd"]] = struct.unpack(" None: addr = self.regs[d["rs1"]] + d["imm"] val = self.regs[d["rs2"]] if d["funct3"] == 0b010: # SW - self.mem[addr:addr + 4] = struct.pack(" np.int32: return np.int32(int.from_bytes(raw, "little", signed=True)) +def _tinyfive_lw_compat(machine_obj, rd: int, imm: int, rs1: int) -> None: + """Execute LW without NumPy uint8 intermediate-overflow. + + TinyFive 1.0.0 implements ``LW`` separately from ``read_i32`` and shifts + ``numpy.uint8`` values in place. On current NumPy versions that truncates + every shifted byte, effectively loading only the least-significant byte. + Keep the workaround inside this adapter so verification observes RV32I + word-load semantics without modifying the installed dependency. + """ + address = int(machine_obj.x[rs1]) + int(imm) + machine_obj.x[rd] = _tinyfive_read_i32_compat(machine_obj, address) + machine_obj.ipc() + + class ProfiledMachine: """TinyFive machine wrapper for benchmark-quality RISC-V simulation. @@ -66,6 +80,7 @@ def _init_machine(self): _tinyfive_read_i32_compat, self._m, ) + self._m.LW = MethodType(_tinyfive_lw_compat, self._m) self._available = True except ImportError: self._available = False diff --git a/scratchv/standalone/onnx_to_riscv_standalone.py b/scratchv/standalone/onnx_to_riscv_standalone.py index 60665a2..5157506 100644 --- a/scratchv/standalone/onnx_to_riscv_standalone.py +++ b/scratchv/standalone/onnx_to_riscv_standalone.py @@ -2705,6 +2705,9 @@ def count_listing_instructions(asm_text: str) -> int: constant_merge_report = { "enabled": True, + "source_transform_path": "backend.const_merge public assembly pass", + "machine_codegen_path": "RISCVEmitter(compact_li32=True)", + "machine_metrics_are_public_pass_output": False, "used": merge_stats.total_changes > 0, "candidate_pairs": merge_stats.candidate_pairs, "merged_pairs": merge_stats.merged_pairs, diff --git a/tests/test_abi_frame.py b/tests/test_abi_frame.py new file mode 100644 index 0000000..fe2f642 --- /dev/null +++ b/tests/test_abi_frame.py @@ -0,0 +1,74 @@ +"""Production stack-frame finalization tests.""" + +import pytest + +from scratchv.backend.abi_frame import apply_abi_frames +from scratchv.backend.riscv_encoder import RISCVAEncoder + + +def test_frame_rebases_spills_and_preserves_callee_saved_and_ra(): + assembly = """\ +main: + sw t0, -4(sp) # spill value [regalloc:spill] + lw t1, -4(sp) # reload value [regalloc:reload] + add s0, t0, t1 + call helper + jalr zero, ra, 0 +helper: + jalr zero, ra, 0 +""" + + framed = apply_abi_frames(assembly, spill_slot_count=1) + + assert "addi sp, sp, -16" in framed + assert "sw ra, 0(sp) # ABI save" in framed + assert "sw s0, 4(sp) # ABI save" in framed + assert "sw t0, 12(sp) # spill value" in framed + assert "lw t1, 12(sp) # reload value" in framed + assert "lw ra, 0(sp) # ABI restore" in framed + assert "addi sp, sp, 16 # destroy stack frame" in framed + RISCVAEncoder().assemble(framed) + + +def test_framed_nested_call_returns_and_restores_callee_saved_register(): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + framed = apply_abi_frames( + "main:\n" + " addi s0, zero, 9\n" + " call helper\n" + " jalr zero, ra, 0\n" + "helper:\n" + " addi s0, zero, 3\n" + " jalr zero, ra, 0\n", + spill_slot_count=0, + ) + program = ( + "li sp, 4096\n" + "li s0, 77\n" + "jal ra, main\n" + "mv a0, s0\n" + "j .done\n" + + framed + + "\n.done:\nj .done" + ) + binary = bytes(RISCVAEncoder().assemble(program)) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=8192) + machine.load_binary(words, origin=0) + machine.run(instructions=len(words) + 8, start=0, strict=True) + + assert machine.get_reg(10) == 77 + + +def test_oversized_frame_fails_instead_of_emitting_truncated_offsets(): + with pytest.raises(ValueError, match="stack frame exceeds"): + apply_abi_frames( + "main:\n sw t0, -4(sp) # spill x [regalloc:spill]\n" + " jalr zero, ra, 0\n", + spill_slot_count=509, + ) diff --git a/tests/test_const_merge.py b/tests/test_const_merge.py index 3bc89be..945990b 100644 --- a/tests/test_const_merge.py +++ b/tests/test_const_merge.py @@ -1,13 +1,16 @@ """Tests for Constant Load Merge Optimizer.""" +import random + import pytest from scratchv.backend._asm_parser import ( ParsedAsmLine, canonical_reg, is_integer_reg, ) from scratchv.backend.const_merge import ( AsmInst, ConstantMergeStats, _insts_to_asm, _parse_asm, - merge_constants, merge_constants_detailed, + main, merge_constants, merge_constants_detailed, ) +from scratchv.backend.riscv_encoder import RISCVAEncoder class TestAsmInst: @@ -111,6 +114,25 @@ def test_empty_asm(self): result, changes = merge_constants("") assert changes == 0 + def test_cli_reads_and_writes_utf8_assembly( + self, tmp_path, monkeypatch + ): + source = tmp_path / "输入.s" + output = tmp_path / "输出.s" + source.write_text( + "# 中文注释\n lui t0, 1\n addi t0, t0, 2\n", + encoding="utf-8", + ) + monkeypatch.setattr( + "sys.argv", ["const_merge", str(source), "-o", str(output)] + ) + + main() + + result = output.read_text(encoding="utf-8") + assert "中文注释" in result + assert "li t0, 4098" in result + def test_no_changes_without_lui(self): asm = " add t0, t1, t2\n sub t3, t4, t5\n ret\n" result, changes = merge_constants(asm) @@ -147,6 +169,32 @@ def test_sign_extension_correct(self): assert "li" in result assert "2048" in result + def test_random_legal_pairs_preserve_rv32_execution(self): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + def execute(assembly: str) -> int: + program = assembly + "\n.done:\nj .done" + binary = bytes(RISCVAEncoder().assemble(program)) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=4096) + machine.load_binary(words, origin=0) + machine.run(instructions=len(words) + 2, start=0, strict=True) + return machine.get_reg(5) & 0xFFFFFFFF + + rng = random.Random(17) + for _ in range(64): + upper = rng.randrange(0, 1 << 20) + lower = rng.randrange(-2048, 2048) + before = f"lui t0, {upper}\naddi t0, t0, {lower}" + after, changes = merge_constants(before) + + assert changes == 1 + assert execute(after) == execute(before) + def test_rv32_result_is_normalized_to_signed_value(self): asm = " lui t0, 0x80000\n addi t0, t0, 0\n" result, changes = merge_constants(asm) diff --git a/tests/test_regalloc_metrics.py b/tests/test_regalloc_metrics.py new file mode 100644 index 0000000..dbe2ebe --- /dev/null +++ b/tests/test_regalloc_metrics.py @@ -0,0 +1,118 @@ +"""Topic17 acceptance tests for pressure and spill metric alignment.""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from benchmarks.test_regalloc import bench_cnn, bench_dense +from scratchv.backend.machine_types import ALL_REGS +from scratchv.backend.regalloc_metrics import ( + count_spill_reload_sites, + peak_live_intervals, +) + + +def test_peak_pressure_counts_overlap_instead_of_cumulative_spills(): + intervals = [ + SimpleNamespace(start=0, end=2), + SimpleNamespace(start=1, end=2), + SimpleNamespace(start=3, end=5), + SimpleNamespace(start=4, end=5), + ] + + assert peak_live_intervals(intervals) == 2 + + +def test_spill_metrics_ignore_ordinary_memory_operations(): + assembly = """\ +sw t0, 0(sp) # model store +lw t1, 0(sp) # model load +sw t4, 4(sp) # spill user-comment-only +lw t4, 4(sp) # reload user-comment-only +sw t2, -4(sp) # spill value [regalloc:spill] +lw t2, -4(sp) # reload value [regalloc:reload] +sw t3, -8(sp) # evict other [regalloc:spill] +""" + + assert count_spill_reload_sites(assembly) == (2, 1) + + +def test_dense_benchmark_separates_sites_slots_reloads_and_pressure(): + block = bench_dense._gen_block(num_insts=80, num_vregs=30) + stats = bench_dense.bench_allocate(block, list(ALL_REGS[:5]), 1) + + assert stats["reg_spill_count"] == stats["spill_stores"] + assert stats["spill_stores"] > stats["spill_slots"] > 0 + assert stats["reloads"] > 0 + assert stats["pressure_peak"] > 5 + assert stats["pressure_excess_peak"] == stats["pressure_peak"] - 5 + assert stats["execution_valid"] + assert stats["actual_a0"] == stats["expected_a0"] + + +def test_topic17_cnn_uses_19_regs_and_passes_real_assembly_validation(): + model = Path(__file__).parents[1] / "models" / "graph" / "cnn.onnx" + + stats = bench_cnn.bench_allocate(str(model), list(ALL_REGS), repeats=1) + + assert stats["asm_valid"], stats["asm_errors"] + assert len(stats["_alloc"].phys_regs) == 19 + assert stats["pressure_peak"] == 11 + assert stats["pressure_excess_peak"] == 0 + assert stats["spill_slots"] == 0 + assert stats["spill_stores"] == 0 + assert stats["reloads"] == 0 + assert stats["reg_spill_count"] == stats["spill_stores"] + + +def test_topic17_cnn_executes_the_allocated_assembly_it_measures(): + model = Path(__file__).parents[1] / "models" / "graph" / "cnn.onnx" + + stats = bench_cnn.run_bench(str(model), list(ALL_REGS), repeats=1) + + assert stats["emu_passed"], stats["emu_error"] + assert stats["actual_a0"] == stats["expected_a0"] + assert stats["greedy_asm_valid"], stats["greedy_asm_errors"] + assert stats["greedy_emu_passed"], stats["greedy_emu_error"] + + +def test_real_assembly_validation_rejects_unresolved_named_vreg(): + errors = bench_cnn._validate_asm("add layer1.bias, t0, t1") + + assert errors + assert "unknown register" in errors[0] + + +@pytest.mark.parametrize( + ("asm_valid", "emu_passed", "expected"), + [(True, True, True), (False, True, False), (True, False, False)], +) +def test_cnn_benchmark_requires_assembly_and_emulator_validity( + monkeypatch: pytest.MonkeyPatch, + asm_valid: bool, + emu_passed: bool, + expected: bool, +) -> None: + monkeypatch.setattr( + bench_cnn, + "bench_allocate", + lambda *_args, **_kwargs: { + "asm_valid": asm_valid, + "sv_static_instrs": 1, + }, + ) + monkeypatch.setattr( + bench_cnn, + "_run_emulator", + lambda *_args, **_kwargs: {"passed": emu_passed}, + ) + monkeypatch.setattr( + bench_cnn, + "_llvm_compare", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("offline")), + ) + + stats = bench_cnn.run_bench("unused.onnx", repeats=1) + + assert stats["valid"] is expected diff --git a/tests/test_regalloc_p1.py b/tests/test_regalloc_p1.py new file mode 100644 index 0000000..aac355a --- /dev/null +++ b/tests/test_regalloc_p1.py @@ -0,0 +1,434 @@ +"""Topic17 P1: CFG liveness, executable spills, calls, and greedy reloads.""" + +from __future__ import annotations + +import re +import random + +import pytest + +from scratchv.backend import regalloc_linear, regalloc_linear_v1_5 +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.machine_types import MachineInstr, MachineOp, MachineOperand +from scratchv.backend.register_alloc import RegisterAllocator +from scratchv.backend.riscv_encoder import RISCVAEncoder + + +ALLOCATOR_MODULES = (regalloc_linear, regalloc_linear_v1_5) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_linear_allocator_rejects_non_riscv_or_duplicate_registers( + allocator_module, +): + with pytest.raises(ValueError, match="RV32 integer"): + allocator_module.LinearScanAllocator(["r0"]) + with pytest.raises(ValueError, match="must be unique"): + allocator_module.LinearScanAllocator(["t0", "t0"]) + + +def _pressure_machine() -> list[MachineInstr]: + v = MachineOperand.vreg + imm = MachineOperand.immediate + return [ + MachineInstr(MachineOp.LI, v("v0"), imm(1)), + MachineInstr(MachineOp.LI, v("v1"), imm(2)), + MachineInstr(MachineOp.LI, v("v2"), imm(3)), + MachineInstr(MachineOp.ADD, v("v3"), v("v0"), v("v1")), + MachineInstr(MachineOp.ADD, v("v4"), v("v2"), v("v3")), + MachineInstr(MachineOp.ADD, v("v5"), v("v4"), v("v0")), + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), v("v5")), + ] + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_two_register_spill_rewrite_executes_with_distinct_sources(allocator_module): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + allocator = allocator_module.LinearScanAllocator(["t0", "t1"]) + assembly = allocator.emit( + allocator_module.block_from_machine_instrs(_pressure_machine()) + ) + program = "li sp, 2048\n" + assembly + "\n.done:\nj .done" + binary = RISCVAEncoder().assemble(program) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=4096) + machine.load_binary(words, origin=0) + machine.run(instructions=len(words) + 2, start=0, strict=True) + + assert machine.get_reg(10) == 7 # a0 + add_lines = [line for line in assembly.splitlines() if line.strip().startswith("add ")] + for line in add_lines: + operands = line.split("#", 1)[0].replace(",", " ").split()[1:] + assert operands[1] != operands[2] + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_impossible_source_pressure_fails_instead_of_clobbering(allocator_module): + block = allocator_module.block_from_machine_instrs(_pressure_machine()[:4]) + allocator = allocator_module.LinearScanAllocator(["t0"]) + + with pytest.raises(RuntimeError, match="distinct register uses"): + allocator.emit(block) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_destination_can_reuse_a_still_live_source_after_saving_it(allocator_module): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + v = MachineOperand.vreg + imm = MachineOperand.immediate + machine_ir = [ + MachineInstr(MachineOp.LI, v("left"), imm(3)), + MachineInstr(MachineOp.LI, v("right"), imm(4)), + # Both sources remain live after this definition. + MachineInstr(MachineOp.ADD, v("sum"), v("left"), v("right")), + MachineInstr(MachineOp.ADD, v("left_again"), v("left"), v("sum")), + MachineInstr(MachineOp.ADD, v("answer"), v("left_again"), v("right")), + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), v("answer")), + ] + allocator = allocator_module.LinearScanAllocator(["t0", "t1"]) + asm = allocator.emit(allocator_module.block_from_machine_instrs(machine_ir)) + binary = RISCVAEncoder().assemble( + "li sp, 2048\n" + asm + "\n.done:\nj .done" + ) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=4096) + machine.load_binary(words, origin=0) + machine.run(instructions=len(words) + 2, start=0, strict=True) + + assert machine.get_reg(10) == 14 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_randomized_straight_line_programs_match_reference(allocator_module): + """Execution-level differential check for allocation and vreg leakage.""" + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + opcodes = [MachineOp.ADD, MachineOp.SUB, MachineOp.XOR, MachineOp.AND] + evaluators = { + MachineOp.ADD: lambda a, b: a + b, + MachineOp.SUB: lambda a, b: a - b, + MachineOp.XOR: lambda a, b: a ^ b, + MachineOp.AND: lambda a, b: a & b, + } + v = MachineOperand.vreg + imm = MachineOperand.immediate + + for seed in range(12): + rng = random.Random(seed) + machine_ir: list[MachineInstr] = [] + values: dict[str, int] = {} + names: list[str] = [] + for index in range(6): + name = f"v{index}" + value = rng.randrange(0, 256) + machine_ir.append(MachineInstr(MachineOp.LI, v(name), imm(value))) + values[name] = value + names.append(name) + + for index in range(24): + left, right = rng.sample(names, 2) + opcode = rng.choice(opcodes) + name = f"tmp{index}" + result = evaluators[opcode](values[left], values[right]) & 0xFFFFFFFF + machine_ir.append(MachineInstr(opcode, v(name), v(left), v(right))) + values[name] = result + names.append(name) + + answer = names[-1] + machine_ir.append( + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), v(answer)) + ) + allocator = allocator_module.LinearScanAllocator(["t0", "t1", "t2"]) + asm = allocator.emit(allocator_module.block_from_machine_instrs(machine_ir)) + binary = RISCVAEncoder().assemble( + "li sp, 4096\n" + asm + "\n.done:\nj .done" + ) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + profile = ProfiledMachine(mem_size=8192) + profile.load_binary(words, origin=0) + profile.run(instructions=len(words) + 2, start=0, strict=True) + + assert profile.get_reg(10) & 0xFFFFFFFF == values[answer], seed + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_cfg_splits_targets_and_carries_live_value(allocator_module): + v = MachineOperand.vreg + imm = MachineOperand.immediate + machine = [ + MachineInstr(MachineOp.LABEL, comment="main"), + MachineInstr(MachineOp.LI, v("condition"), imm(1)), + MachineInstr(MachineOp.LI, v("carried"), imm(7)), + MachineInstr(MachineOp.BNEZ, v("condition"), comment=".then"), + MachineInstr(MachineOp.ADDI, v("else_value"), v("carried"), imm(1)), + MachineInstr(MachineOp.J, comment=".join"), + MachineInstr(MachineOp.LABEL, comment=".then"), + MachineInstr(MachineOp.ADDI, v("then_value"), v("carried"), imm(2)), + MachineInstr(MachineOp.LABEL, comment=".join"), + MachineInstr(MachineOp.MV, v("result"), v("carried")), + ] + allocator = allocator_module.LinearScanAllocator(["t0", "t1", "s0"]) + block = allocator_module.block_from_machine_instrs(machine) + allocator.compute_live_intervals(block) + cfg = allocator.cfg + + assert ".then" in cfg.by_name + assert ".join" in cfg.by_name + assert ".then" in cfg.blocks[0].successors + assert "carried" in cfg.by_name[".then"].live_in + assert "carried" in cfg.by_name[".join"].live_in + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +@pytest.mark.parametrize("condition, expected", [(0, 23), (1, 9)]) +def test_cfg_spill_handoff_executes_on_both_branch_paths( + allocator_module, condition, expected +): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + v = MachineOperand.vreg + imm = MachineOperand.immediate + machine_ir = [ + MachineInstr(MachineOp.LABEL, comment="main"), + MachineInstr(MachineOp.LI, v("carried_left"), imm(7)), + MachineInstr(MachineOp.LI, v("carried_right"), imm(9)), + MachineInstr(MachineOp.LI, v("condition"), imm(condition)), + MachineInstr(MachineOp.BNEZ, v("condition"), comment=".then"), + MachineInstr( + MachineOp.ADD, + v("branch_result"), + v("carried_left"), + v("carried_right"), + ), + MachineInstr(MachineOp.J, comment=".join"), + MachineInstr(MachineOp.LABEL, comment=".then"), + MachineInstr( + MachineOp.SUB, + v("branch_result"), + v("carried_right"), + v("carried_left"), + ), + MachineInstr(MachineOp.LABEL, comment=".join"), + MachineInstr( + MachineOp.ADD, + v("answer"), + v("branch_result"), + v("carried_left"), + ), + MachineInstr( + MachineOp.MV, MachineOperand.reg("a0"), v("answer") + ), + ] + allocator = allocator_module.LinearScanAllocator(["t0", "t1"]) + assembly = allocator.emit( + allocator_module.block_from_machine_instrs(machine_ir) + ) + binary = RISCVAEncoder().assemble( + "li sp, 2048\n" + assembly + "\n.done:\nj .done" + ) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + profile = ProfiledMachine(mem_size=4096) + profile.load_binary(words, origin=0) + profile.run(instructions=len(words) + 4, start=0, strict=True) + + assert profile.get_reg(10) == expected + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_call_spills_caller_saved_value_and_leaves_callee_saved_value(allocator_module): + v = MachineOperand.vreg + imm = MachineOperand.immediate + machine = [ + MachineInstr(MachineOp.LI, v("value"), imm(7)), + MachineInstr(MachineOp.CALL, comment="helper"), + MachineInstr(MachineOp.MV, v("result"), v("value")), + ] + + caller = allocator_module.LinearScanAllocator(["t0", "s0"]) + caller_asm = caller.emit(allocator_module.block_from_machine_instrs(machine)) + assert re.search(r"sw t0, .*# spill value", caller_asm) + assert re.search(r"lw t0, .*# reload value", caller_asm) + + callee = allocator_module.LinearScanAllocator(["s0", "t0"]) + callee_asm = callee.emit(allocator_module.block_from_machine_instrs(machine)) + assert "# spill value" not in callee_asm + assert "# reload value" not in callee_asm + + +def test_greedy_spill_is_reloaded_before_later_use(): + v = MachineOperand.vreg + imm = MachineOperand.immediate + instructions = [ + MachineInstr(MachineOp.LI, v(f"v{i}"), imm(i)) for i in range(20) + ] + instructions.extend( + MachineInstr(MachineOp.ADD, v(f"sum{i}"), v(f"v{i}"), v(f"v{(i + 1) % 20}")) + for i in range(20) + ) + + allocated = RegisterAllocator(instructions, mode="greedy").run() + asm = AsmEmitter(allocated).emit() + + assert "# spill v" in asm + assert "# reload v" in asm + assert not any( + operand.kind == "vreg" + for instr in allocated + for operand in (instr.dst, instr.src1, instr.src2) + if operand is not None + ) + RISCVAEncoder().assemble(asm) + + +def test_greedy_spill_reload_executes_correctly(): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + v = MachineOperand.vreg + imm = MachineOperand.immediate + instructions = [ + MachineInstr(MachineOp.LI, v(f"v{i}"), imm(i)) for i in range(20) + ] + instructions.append(MachineInstr(MachineOp.MV, v("acc"), v("v0"))) + instructions.extend( + MachineInstr(MachineOp.ADD, v("acc"), v("acc"), v(f"v{i}")) + for i in range(1, 20) + ) + instructions.append( + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), v("acc")) + ) + + asm = AsmEmitter(RegisterAllocator(instructions, mode="greedy").run()).emit() + binary = RISCVAEncoder().assemble( + "li sp, 2048\n" + asm + "\n.done:\nj .done" + ) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=4096) + machine.load_binary(words, origin=0) + machine.run(instructions=len(words) + 2, start=0, strict=True) + + assert machine.get_reg(10) == sum(range(20)) + + +def test_call_encodes_as_local_jal_and_rejects_missing_target(): + call = "call .helper\naddi a0, x0, 1\n.helper:\njalr x0, ra, 0" + direct = "jal ra, .helper\naddi a0, x0, 1\n.helper:\njalr x0, ra, 0" + + assert RISCVAEncoder().assemble(call) == RISCVAEncoder().assemble(direct) + with pytest.raises(ValueError, match="undefined branch target"): + RISCVAEncoder().assemble("call .missing") + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_explicit_physical_definition_does_not_clobber_live_vreg( + allocator_module, +): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + v = MachineOperand.vreg + reg = MachineOperand.reg + imm = MachineOperand.immediate + machine_ir = [ + MachineInstr(MachineOp.LI, v("saved"), imm(5)), + MachineInstr(MachineOp.ADDI, reg("t0"), reg("zero"), imm(9)), + MachineInstr(MachineOp.MV, reg("a0"), v("saved")), + ] + allocator = allocator_module.LinearScanAllocator(["t0", "t1"]) + assembly = allocator.emit( + allocator_module.block_from_machine_instrs(machine_ir) + ) + binary = RISCVAEncoder().assemble( + "li sp, 2048\n" + assembly + "\n.done:\nj .done" + ) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + profile = ProfiledMachine(mem_size=4096) + profile.load_binary(words, origin=0) + profile.run(instructions=len(words) + 2, start=0, strict=True) + + assert profile.get_reg(10) == 5 + + +@pytest.mark.parametrize("mode", ["naive", "greedy"]) +def test_legacy_allocators_execute_binary_operation_with_distinct_sources(mode): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + v = MachineOperand.vreg + reg = MachineOperand.reg + imm = MachineOperand.immediate + instructions = [ + MachineInstr(MachineOp.LI, v("left"), imm(5)), + MachineInstr(MachineOp.LI, v("right"), imm(2)), + MachineInstr(MachineOp.SUB, v("answer"), v("left"), v("right")), + MachineInstr(MachineOp.MV, reg("a0"), v("answer")), + ] + assembly = AsmEmitter(RegisterAllocator(instructions, mode=mode).run()).emit() + binary = RISCVAEncoder().assemble( + "li sp, 2048\n" + assembly + "\n.done:\nj .done" + ) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + profile = ProfiledMachine(mem_size=4096) + profile.load_binary(words, origin=0) + profile.run(instructions=len(words) + 2, start=0, strict=True) + + assert profile.get_reg(10) == 3 + + +def test_greedy_taken_branch_observes_initialized_spill_handoff(): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + v = MachineOperand.vreg + reg = MachineOperand.reg + imm = MachineOperand.immediate + instructions = [ + MachineInstr(MachineOp.LI, v("carried"), imm(10)), + MachineInstr(MachineOp.LI, v("condition"), imm(1)), + MachineInstr(MachineOp.BNEZ, v("condition"), comment=".join"), + MachineInstr(MachineOp.ADDI, v("unused"), v("carried"), imm(1)), + MachineInstr(MachineOp.LABEL, comment=".join"), + MachineInstr(MachineOp.MV, reg("a0"), v("carried")), + ] + assembly = AsmEmitter( + RegisterAllocator(instructions, mode="greedy").run() + ).emit() + binary = RISCVAEncoder().assemble( + "li sp, 2048\n" + assembly + "\n.done:\nj .done" + ) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + profile = ProfiledMachine(mem_size=4096) + profile.load_binary(words, origin=0) + profile.run(instructions=len(words) + 3, start=0, strict=True) + + assert profile.get_reg(10) == 10 diff --git a/tests/test_regalloc_pseudo.py b/tests/test_regalloc_pseudo.py new file mode 100644 index 0000000..4818359 --- /dev/null +++ b/tests/test_regalloc_pseudo.py @@ -0,0 +1,972 @@ +"""Register-allocation tests for machine pseudo-instructions.""" + +import pytest + +from scratchv.backend import regalloc_linear, regalloc_linear_v1_5 +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.machine_semantics import OP_SEM +from scratchv.backend.machine_types import ( + ALL_REGS, + MachineInstr, + MachineOp, + MachineOperand, +) +from scratchv.backend.riscv_encoder import RISCVAEncoder +from scratchv.ir.types import Program, Value + + +ALLOCATOR_MODULES = (regalloc_linear, regalloc_linear_v1_5) + +RV32IM_MACHINE_PSEUDOS = { + MachineOp.MV, + MachineOp.LI, + MachineOp.MAX, + MachineOp.BNEZ, + MachineOp.J, + MachineOp.CALL, +} +STRUCTURAL_MACHINE_PSEUDOS = {MachineOp.LABEL} +EXTERNAL_EXTENSION_PSEUDOS = { + MachineOp.FABS_D, + MachineOp.FNEG_D, + MachineOp.LI_D, + MachineOp.FMV_S, +} + + +def _run_rv32(assembly: str, instruction_limit: int = 16): + pytest.importorskip("tinyfive") + from scratchv.simulator.tinyfive import ProfiledMachine + + binary = RISCVAEncoder().assemble(assembly) + words = [ + int.from_bytes(binary[offset:offset + 4], "little") + for offset in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=4096) + machine.load_binary(words, origin=0) + machine.run(instructions=instruction_limit, start=0, strict=True) + return machine + + +def _allocate_and_run( + allocator_module, + instructions: list[MachineInstr], + *, + instruction_limit: int = 16, +): + """Run Machine IR through allocation, encoding, and TinyFive.""" + block = allocator_module.block_from_machine_instrs(instructions) + allocator = allocator_module.LinearScanAllocator(["t0", "t1", "t2"]) + assembly = allocator.emit(block) + binary = RISCVAEncoder().assemble(assembly) + machine = _run_rv32(assembly, instruction_limit=instruction_limit) + return machine, assembly, binary + + +def test_every_semantic_pseudo_has_an_explicit_target_disposition(): + semantic_pseudos = { + opcode for opcode, semantics in OP_SEM.items() if semantics.is_pseudo + } + + assert semantic_pseudos == ( + RV32IM_MACHINE_PSEUDOS + | STRUCTURAL_MACHINE_PSEUDOS + | EXTERNAL_EXTENSION_PSEUDOS + ) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_mv_full_pipeline_executes_after_register_allocation(allocator_module): + v = MachineOperand.vreg + instructions = [ + MachineInstr(MachineOp.LI, v("source"), MachineOperand.immediate(42)), + MachineInstr(MachineOp.MV, v("copy"), v("source")), + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), v("copy")), + MachineInstr(MachineOp.LABEL, comment=".done"), + MachineInstr(MachineOp.J, comment=".done"), + ] + + machine, assembly, _ = _allocate_and_run(allocator_module, instructions) + + assert "%" not in assembly + assert machine.get_reg(10) == 42 # a0 / x10 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_li_full_pipeline_executes_after_register_allocation(allocator_module): + v = MachineOperand.vreg + instructions = [ + MachineInstr( + MachineOp.LI, + v("constant"), + MachineOperand.immediate(0x12345), + ), + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), v("constant")), + MachineInstr(MachineOp.LABEL, comment=".done"), + MachineInstr(MachineOp.J, comment=".done"), + ] + + machine, _, binary = _allocate_and_run(allocator_module, instructions) + + assert len(binary) == 16 # large li is two words, then mv and j + assert machine.get_reg(10) == 0x12345 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_max_full_pipeline_executes_after_register_allocation(allocator_module): + v = MachineOperand.vreg + instructions = [ + MachineInstr(MachineOp.LI, v("left"), MachineOperand.immediate(-4)), + MachineInstr(MachineOp.LI, v("right"), MachineOperand.immediate(-2)), + MachineInstr(MachineOp.MAX, v("result"), v("left"), v("right")), + MachineInstr(MachineOp.MV, MachineOperand.reg("a0"), v("result")), + MachineInstr(MachineOp.LABEL, comment=".done"), + MachineInstr(MachineOp.J, comment=".done"), + ] + + machine, assembly, _ = _allocate_and_run(allocator_module, instructions) + + assert "max " in assembly + assert machine.get_reg(10) == -2 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +@pytest.mark.parametrize("condition, expected", [(0, 1), (5, 2)]) +def test_bnez_full_pipeline_executes_both_paths( + allocator_module, condition, expected +): + v = MachineOperand.vreg + imm = MachineOperand.immediate + instructions = [ + MachineInstr(MachineOp.LI, v("condition"), imm(condition)), + MachineInstr(MachineOp.LI, MachineOperand.reg("a0"), imm(0)), + MachineInstr(MachineOp.BNEZ, v("condition"), comment=".taken"), + MachineInstr(MachineOp.LI, MachineOperand.reg("a0"), imm(1)), + MachineInstr(MachineOp.J, comment=".done"), + MachineInstr(MachineOp.LABEL, comment=".taken"), + MachineInstr(MachineOp.LI, MachineOperand.reg("a0"), imm(2)), + MachineInstr(MachineOp.LABEL, comment=".done"), + MachineInstr(MachineOp.J, comment=".done"), + ] + + machine, _, _ = _allocate_and_run(allocator_module, instructions) + + assert machine.get_reg(10) == expected + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_j_full_pipeline_executes_without_fallthrough(allocator_module): + imm = MachineOperand.immediate + a0 = MachineOperand.reg("a0") + instructions = [ + MachineInstr(MachineOp.LI, a0, imm(0)), + MachineInstr(MachineOp.J, comment=".target"), + MachineInstr(MachineOp.LI, a0, imm(1)), + MachineInstr(MachineOp.LABEL, comment=".target"), + MachineInstr(MachineOp.LI, a0, imm(2)), + MachineInstr(MachineOp.LABEL, comment=".done"), + MachineInstr(MachineOp.J, comment=".done"), + ] + + machine, _, _ = _allocate_and_run(allocator_module, instructions) + + assert machine.get_reg(10) == 2 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_call_full_pipeline_jumps_links_and_returns(allocator_module): + imm = MachineOperand.immediate + a0 = MachineOperand.reg("a0") + instructions = [ + MachineInstr(MachineOp.LI, a0, imm(1)), + MachineInstr(MachineOp.CALL, comment=".callee"), + MachineInstr(MachineOp.ADDI, a0, a0, imm(10)), + MachineInstr(MachineOp.J, comment=".done"), + MachineInstr(MachineOp.LABEL, comment=".callee"), + MachineInstr(MachineOp.ADDI, a0, a0, imm(2)), + MachineInstr( + MachineOp.JALR, + MachineOperand.reg("zero"), + MachineOperand.reg("ra"), + imm(0), + ), + MachineInstr(MachineOp.LABEL, comment=".done"), + MachineInstr(MachineOp.J, comment=".done"), + ] + + machine, assembly, _ = _allocate_and_run(allocator_module, instructions) + + assert "call .callee" in assembly + assert machine.get_reg(1) == 8 # ra points after the call at PC=4 + assert machine.get_reg(10) == 13 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_label_full_pipeline_is_zero_bytes_and_execution_continues( + allocator_module, +): + instructions = [ + MachineInstr(MachineOp.LABEL, comment=".entry"), + MachineInstr( + MachineOp.LI, + MachineOperand.reg("a0"), + MachineOperand.immediate(17), + ), + MachineInstr(MachineOp.LABEL, comment=".done"), + MachineInstr(MachineOp.J, comment=".done"), + ] + + machine, _, binary = _allocate_and_run(allocator_module, instructions) + + assert len(binary) == 8 # li + j; both labels emit no machine word + assert machine.get_reg(10) == 17 + + +@pytest.mark.parametrize( + "opcode, operands, extension", + [ + (MachineOp.FABS_D, ("result", "source"), "D"), + (MachineOp.FNEG_D, ("result", "source"), "D"), + (MachineOp.LI_D, ("result", 1), "D"), + (MachineOp.FMV_S, ("result", "source"), "F"), + ], +) +def test_external_extension_pseudo_is_explicitly_rejected_by_rv32im_encoder( + opcode, operands, extension +): + dst = MachineOperand.vreg(operands[0]) + src = ( + MachineOperand.immediate(operands[1]) + if isinstance(operands[1], int) + else MachineOperand.vreg(operands[1]) + ) + instruction = MachineInstr(opcode, dst, src) + block = regalloc_linear.block_from_machine_instrs([instruction]) + assembly = regalloc_linear.LinearScanAllocator(["t0", "t1"]).emit(block) + + with pytest.raises( + ValueError, + match=rf"{opcode.value} requires the RISC-V {extension} extension", + ): + RISCVAEncoder().assemble(assembly) + + +def test_nop_assembler_pseudo_encodes_and_executes_as_addi_zero(): + pseudo = "li t0, 41\nnop\naddi t0, t0, 1\n.done:\nj .done" + expanded = ( + "li t0, 41\naddi x0, x0, 0\naddi t0, t0, 1\n.done:\n" + "jal x0, .done" + ) + + assert RISCVAEncoder().assemble(pseudo) == RISCVAEncoder().assemble( + expanded + ) + assert _run_rv32(pseudo).get_reg(5) == 42 + + +def test_ret_assembler_pseudo_encodes_and_executes_as_jalr(): + assert RISCVAEncoder().assemble("ret") == RISCVAEncoder().assemble( + "jalr x0, ra, 0" + ) + + machine = _run_rv32( + "li a0, 1\n" + "jal ra, .callee\n" + "addi a0, a0, 10\n" + "j .done\n" + ".callee:\n" + "addi a0, a0, 2\n" + "ret\n" + ".done:\n" + "j .done" + ) + + assert machine.get_reg(10) == 13 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_mv_has_explicit_def_use_semantics(allocator_module): + move = MachineInstr( + MachineOp.MV, + MachineOperand.vreg("copy"), + MachineOperand.vreg("source"), + ) + + converted = allocator_module.block_from_machine_instrs([move])[0] + + assert OP_SEM[MachineOp.MV].is_pseudo + assert converted.defines == {"copy"} + assert converted.uses == {"source"} + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_mv_allocates_like_addi(allocator_module): + source = MachineOperand.vreg("source") + copy = MachineOperand.vreg("copy") + pseudo = [ + MachineInstr(MachineOp.LI, source, MachineOperand.immediate(7)), + MachineInstr(MachineOp.MV, copy, source), + ] + expanded = [ + MachineInstr(MachineOp.LI, source, MachineOperand.immediate(7)), + MachineInstr( + MachineOp.ADDI, + copy, + source, + MachineOperand.immediate(0), + ), + ] + + pseudo_alloc = allocator_module.LinearScanAllocator(["t0", "t1"]) + pseudo_asm = pseudo_alloc.emit( + allocator_module.block_from_machine_instrs(pseudo) + ) + expanded_alloc = allocator_module.LinearScanAllocator(["t0", "t1"]) + expanded_asm = expanded_alloc.emit( + allocator_module.block_from_machine_instrs(expanded) + ) + + assert "%" not in pseudo_asm + assert "source" not in pseudo_asm + assert "copy" not in pseudo_asm + assert pseudo_alloc.spill_slot_count == expanded_alloc.spill_slot_count + assert RISCVAEncoder().assemble(pseudo_asm) == RISCVAEncoder().assemble( + expanded_asm + ) + + +def test_move_helper_uses_li_for_an_immediate_source(): + selector = InstructionSelector(Program()) + + selector._emit_move( + MachineOperand.vreg("copy"), + MachineOperand.immediate(42), + comment="constant copy", + ) + + assert selector._instructions == [ + MachineInstr( + MachineOp.LI, + MachineOperand.vreg("copy"), + MachineOperand.immediate(42), + comment="constant copy", + ) + ] + + +def test_max_helper_temporary_never_collides_with_any_program_value(): + program = Program() + program.global_values.append(Value("__scratchv_max_rhs_1")) + selector = InstructionSelector(program) + + selector._emit_max( + MachineOperand.vreg("result"), + MachineOperand.vreg("left"), + MachineOperand.immediate(5), + ) + + assert selector._instructions[0].dst == MachineOperand.vreg( + "__scratchv_max_rhs_2" + ) + + +def test_mv_executes_as_a_register_copy(): + machine = _run_rv32("li t0, 42\nmv t1, t0\n.done:\nj .done") + + assert machine.get_reg(6) == 42 # t1 / x6 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_li_defines_only_its_destination(allocator_module): + load_immediate = MachineInstr( + MachineOp.LI, + MachineOperand.vreg("constant"), + MachineOperand.immediate(0x12345), + ) + + converted = allocator_module.block_from_machine_instrs([load_immediate])[0] + + assert OP_SEM[MachineOp.LI].is_pseudo + assert OP_SEM[MachineOp.LI].immediate_positions == (1,) + assert converted.defines == {"constant"} + assert converted.uses == set() + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_li_small_immediate_matches_addi_pressure_and_encoding(allocator_module): + destination = MachineOperand.vreg("constant") + pseudo = [ + MachineInstr( + MachineOp.LI, + destination, + MachineOperand.immediate(7), + ) + ] + expanded = [ + MachineInstr( + MachineOp.ADDI, + destination, + MachineOperand.reg("x0"), + MachineOperand.immediate(7), + ) + ] + + pseudo_alloc = allocator_module.LinearScanAllocator(["t0"]) + pseudo_asm = pseudo_alloc.emit( + allocator_module.block_from_machine_instrs(pseudo) + ) + expanded_alloc = allocator_module.LinearScanAllocator(["t0"]) + expanded_asm = expanded_alloc.emit( + allocator_module.block_from_machine_instrs(expanded) + ) + + assert pseudo_alloc.spill_slot_count == expanded_alloc.spill_slot_count + assert RISCVAEncoder().assemble(pseudo_asm) == RISCVAEncoder().assemble( + expanded_asm + ) + + +def test_li_large_immediate_expands_to_two_real_instructions(): + pseudo = "li t0, 0x12345" + expanded = "lui t0, 18\naddi t0, t0, 837" + + encoded = RISCVAEncoder().assemble(pseudo) + + assert len(encoded) == 8 + assert encoded == RISCVAEncoder().assemble(expanded) + + +def test_li_large_immediate_executes_with_exact_value(): + machine = _run_rv32("li t0, 0x12345\n.done:\nj .done") + + assert machine.get_reg(5) == 0x12345 # t0 / x5 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_max_tracks_register_and_immediate_operands(allocator_module): + register_max = MachineInstr( + MachineOp.MAX, + MachineOperand.vreg("result"), + MachineOperand.vreg("left"), + MachineOperand.vreg("right"), + ) + immediate_max = MachineInstr( + MachineOp.MAX, + MachineOperand.vreg("left"), + MachineOperand.vreg("left"), + MachineOperand.immediate(0), + ) + + register_inst, immediate_inst = allocator_module.block_from_machine_instrs( + [register_max, immediate_max] + ) + + assert OP_SEM[MachineOp.MAX].is_pseudo + assert register_inst.defines == {"result"} + assert register_inst.uses == {"left", "right"} + assert immediate_inst.defines == {"left"} + assert immediate_inst.uses == {"left"} + + +def test_max_helper_keeps_supported_zero_immediate(): + selector = InstructionSelector(Program()) + + selector._emit_max( + MachineOperand.vreg("result"), + MachineOperand.vreg("left"), + MachineOperand.immediate(0), + comment="relu", + ) + + assert selector._instructions == [ + MachineInstr( + MachineOp.MAX, + MachineOperand.vreg("result"), + MachineOperand.vreg("left"), + MachineOperand.immediate(0), + comment="relu", + ) + ] + + +def test_max_helper_materializes_nonzero_immediate(): + selector = InstructionSelector(Program()) + + selector._emit_max( + MachineOperand.vreg("result"), + MachineOperand.vreg("left"), + MachineOperand.immediate(7), + comment="max", + ) + + temp = MachineOperand.vreg("__scratchv_max_rhs_1") + assert selector._instructions == [ + MachineInstr( + MachineOp.LI, + temp, + MachineOperand.immediate(7), + comment="materialize max rhs", + ), + MachineInstr( + MachineOp.MAX, + MachineOperand.vreg("result"), + MachineOperand.vreg("left"), + temp, + comment="max", + ), + ] + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_max_pseudo_and_expansion_have_equal_spill_pressure(allocator_module): + pseudo = [ + MachineInstr( + MachineOp.LI, + MachineOperand.vreg("left"), + MachineOperand.immediate(3), + ), + MachineInstr( + MachineOp.LI, + MachineOperand.vreg("right"), + MachineOperand.immediate(7), + ), + MachineInstr( + MachineOp.MAX, + MachineOperand.vreg("result"), + MachineOperand.vreg("left"), + MachineOperand.vreg("right"), + ), + ] + expanded = [ + allocator_module.LsInstruction( + 0, "li", ["left", "3"], defines={"left"} + ), + allocator_module.LsInstruction( + 1, "li", ["right", "7"], defines={"right"} + ), + allocator_module.LsInstruction( + 2, + "bge", + ["left", "right", ".max_then"], + uses={"left", "right"}, + ), + allocator_module.LsInstruction( + 3, + "addi", + ["result", "right", "0"], + defines={"result"}, + uses={"right"}, + ), + allocator_module.LsInstruction(4, "j", [".max_end"]), + allocator_module.LsInstruction(5, ".label", [".max_then"]), + allocator_module.LsInstruction( + 6, + "addi", + ["result", "left", "0"], + defines={"result"}, + uses={"left"}, + ), + allocator_module.LsInstruction(7, ".label", [".max_end"]), + ] + + pseudo_alloc = allocator_module.LinearScanAllocator(["t0", "t1"]) + pseudo_alloc.allocate( + pseudo_alloc.compute_live_intervals( + allocator_module.block_from_machine_instrs(pseudo) + ) + ) + expanded_alloc = allocator_module.LinearScanAllocator(["t0", "t1"]) + expanded_alloc.allocate(expanded_alloc.compute_live_intervals(expanded)) + + assert pseudo_alloc.spill_slot_count == expanded_alloc.spill_slot_count + + +def test_max_register_rhs_expands_to_copy_the_rhs_not_zero(): + pseudo = "max t2, t0, t1" + expanded = """\ +bge t0, t1, .__max_then_0 +addi t2, t1, 0 +j .__max_end_0 +.__max_then_0: +addi t2, t0, 0 +.__max_end_0: +""" + + assert RISCVAEncoder().assemble(pseudo) == RISCVAEncoder().assemble(expanded) + + +def test_max_rejects_nonzero_immediate_rhs_without_hidden_vreg_semantics(): + with pytest.raises(ValueError, match="supports only zero"): + RISCVAEncoder().assemble("max t2, t0, 7") + + +@pytest.mark.parametrize( + "left, right, expected", + [(2, 3, 3), (3, 2, 3), (-4, -2, -2), (7, 7, 7)], +) +def test_max_executes_for_both_control_flow_paths(left, right, expected): + machine = _run_rv32( + f"li t0, {left}\nli t1, {right}\nmax t2, t0, t1\n" + ".done:\nj .done" + ) + + assert machine.get_reg(7) == expected # t2 / x7 + + +@pytest.mark.parametrize( + "assembly, result_reg, expected", + [ + ("li t0, 2\nli t1, 3\nmax t0, t0, t1", 5, 3), + ("li t0, 3\nli t1, 2\nmax t1, t0, t1", 6, 3), + ], +) +def test_max_is_correct_when_destination_aliases_a_source( + assembly, result_reg, expected +): + machine = _run_rv32(assembly + "\n.done:\nj .done") + + assert machine.get_reg(result_reg) == expected + + +def test_max_internal_labels_do_not_collide_with_user_labels(): + machine = _run_rv32( + ".__max_then_0:\n" + "li t0, 2\n" + "li t1, 3\n" + "max t2, t0, t1\n" + ".done:\n" + "j .done" + ) + + assert machine.get_reg(7) == 3 + + +def test_branch_immediate_fails_instead_of_clobbering_a_busy_temp(): + all_temps_are_live_in_text = "\n".join( + [f"add t{i}, t{i}, t{i}" for i in range(7)] + + ["beq s0, 5, .done", ".done:", "j .done"] + ) + + with pytest.raises(ValueError, match="needs a free temporary register"): + RISCVAEncoder().assemble(all_temps_are_live_in_text) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_label_emits_gas_syntax_and_has_no_register_semantics(allocator_module): + label = MachineInstr(MachineOp.LABEL, comment=".target") + load = MachineInstr( + MachineOp.LI, + MachineOperand.vreg("value"), + MachineOperand.immediate(1), + ) + + block = allocator_module.block_from_machine_instrs([label, load]) + assembly = allocator_module.LinearScanAllocator(["t0"]).emit(block) + + assert OP_SEM[MachineOp.LABEL].is_label + assert block[0].defines == set() + assert block[0].uses == set() + assert assembly.splitlines()[0] == ".target:" + assert ".label" not in assembly + assert len(RISCVAEncoder().assemble(assembly)) == 4 + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_bnez_uses_condition_and_emits_target_operand(allocator_module): + machine = [ + MachineInstr( + MachineOp.LI, + MachineOperand.vreg("condition"), + MachineOperand.immediate(1), + ), + MachineInstr( + MachineOp.BNEZ, + MachineOperand.vreg("condition"), + comment=".taken", + ), + MachineInstr(MachineOp.LABEL, comment=".taken"), + ] + + block = allocator_module.block_from_machine_instrs(machine) + branch = block[1] + allocator = allocator_module.LinearScanAllocator(["t0"]) + intervals = allocator.compute_live_intervals(block) + assembly = allocator.emit(block) + condition = next(iv for iv in intervals if iv.vreg == "condition") + + assert OP_SEM[MachineOp.BNEZ].is_terminator + assert branch.defines == set() + assert branch.uses == {"condition"} + assert branch.operands == ["condition", ".taken"] + assert branch.comment == "" + assert condition.uses == {1} + assert condition.end == 2 + assert "bnez t0, .taken" in assembly + RISCVAEncoder().assemble(assembly) + + +def test_bnez_encoding_matches_bne_against_zero(): + pseudo = "bnez t0, .taken\naddi t1, x0, 0\n.taken:\naddi t1, x0, 1" + expanded = "bne t0, x0, .taken\naddi t1, x0, 0\n.taken:\naddi t1, x0, 1" + + assert RISCVAEncoder().assemble(pseudo) == RISCVAEncoder().assemble(expanded) + + +@pytest.mark.parametrize("condition, expected", [(0, 1), (5, 2)]) +def test_bnez_executes_taken_and_not_taken_paths(condition, expected): + machine = _run_rv32( + f"li t0, {condition}\n" + "li t1, 0\n" + "bnez t0, .taken\n" + "li t1, 1\n" + "j .done\n" + ".taken:\n" + "li t1, 2\n" + ".done:\n" + "j .done" + ) + + assert machine.get_reg(6) == expected # t1 / x6 + + +@pytest.mark.parametrize( + "assembly, message", + [ + ("bnez t0", "expects exactly 2 operands"), + ("bnez t0, .missing", "undefined branch target"), + ], +) +def test_bnez_rejects_missing_target_information(assembly, message): + with pytest.raises(ValueError, match=message): + RISCVAEncoder().assemble(assembly) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_j_emits_target_operand_without_register_pressure(allocator_module): + machine = [ + MachineInstr(MachineOp.J, comment=".target"), + MachineInstr( + MachineOp.LI, + MachineOperand.vreg("skipped"), + MachineOperand.immediate(0), + ), + MachineInstr(MachineOp.LABEL, comment=".target"), + ] + + block = allocator_module.block_from_machine_instrs(machine) + jump = block[0] + assembly = allocator_module.LinearScanAllocator(["t0"]).emit(block) + + assert OP_SEM[MachineOp.J].is_terminator + assert jump.defines == set() + assert jump.uses == set() + assert jump.operands == [".target"] + assert jump.comment == "" + assert assembly.splitlines()[0] == " j .target" + RISCVAEncoder().assemble(assembly) + + +def test_j_encoding_matches_jal_with_zero_destination(): + pseudo = "j .target\naddi t0, x0, 0\n.target:\naddi t0, x0, 1" + expanded = "jal x0, .target\naddi t0, x0, 0\n.target:\naddi t0, x0, 1" + + assert RISCVAEncoder().assemble(pseudo) == RISCVAEncoder().assemble(expanded) + + +def test_j_executes_without_falling_through(): + machine = _run_rv32( + "li t0, 0\n" + "j .target\n" + "li t0, 1\n" + ".target:\n" + "li t0, 2\n" + ".done:\n" + "j .done" + ) + + assert machine.get_reg(5) == 2 # t0 / x5 + + +@pytest.mark.parametrize( + "assembly, message", + [ + ("j", "expects exactly 1 operand"), + ("j .missing", "undefined branch target"), + ], +) +def test_j_rejects_missing_target_information(assembly, message): + with pytest.raises(ValueError, match=message): + RISCVAEncoder().assemble(assembly) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_jalr_tracks_link_definition_and_base_use(allocator_module): + jump = MachineInstr( + MachineOp.JALR, + MachineOperand.vreg("link"), + MachineOperand.vreg("base"), + MachineOperand.immediate(0), + ) + ret = MachineInstr( + MachineOp.JALR, + MachineOperand.reg("zero"), + MachineOperand.reg("ra"), + comment="ret", + ) + + jump_inst, ret_inst = allocator_module.block_from_machine_instrs( + [jump, ret] + ) + + assert jump_inst.defines == {"link"} + assert jump_inst.uses == {"base"} + assert ret_inst.defines == set() + assert ret_inst.uses == set() + assert RISCVAEncoder().assemble(ret_inst.to_asm()) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +@pytest.mark.parametrize( + "opcode", [MachineOp.BEQ, MachineOp.BNE, MachineOp.BLT, MachineOp.BGE] +) +def test_true_branch_operands_are_uses_and_target_is_emitted( + allocator_module, opcode +): + branch = MachineInstr( + opcode, + MachineOperand.vreg("left"), + MachineOperand.vreg("right"), + comment=".target", + ) + + converted = allocator_module.block_from_machine_instrs([branch])[0] + + assert converted.defines == set() + assert converted.uses == {"left", "right"} + assert converted.operands == ["left", "right", ".target"] + + +def test_call_metadata_records_abi_clobbers_without_calling_it_a_terminator(): + semantics = OP_SEM[MachineOp.CALL] + + assert semantics.is_call + assert not semantics.is_terminator + assert semantics.implicit_defs == {"ra"} + assert {"ra", "a0", "a7", "t0", "t6"} <= semantics.clobbers + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_default_register_bank_matches_canonical_19_register_bank( + allocator_module, +): + allocator = allocator_module.LinearScanAllocator() + + assert allocator.phys_regs == ALL_REGS + assert len(allocator.phys_regs) == 19 + + +def test_every_machine_opcode_has_explicit_semantics(): + assert set(OP_SEM) == set(MachineOp) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_pseudo_pipeline_leaks_no_arbitrary_virtual_register_names( + allocator_module, +): + v = MachineOperand.vreg + imm = MachineOperand.immediate + names = { + "input_tensor", + "weight_tensor", + "maximum_value", + "copied_value", + } + machine = [ + MachineInstr(MachineOp.LI, v("input_tensor"), imm(3)), + MachineInstr(MachineOp.LI, v("weight_tensor"), imm(7)), + MachineInstr( + MachineOp.MAX, + v("maximum_value"), + v("input_tensor"), + v("weight_tensor"), + ), + MachineInstr( + MachineOp.MV, v("copied_value"), v("maximum_value") + ), + MachineInstr( + MachineOp.BNEZ, v("copied_value"), comment=".taken" + ), + MachineInstr(MachineOp.J, comment=".done"), + MachineInstr(MachineOp.LABEL, comment=".taken"), + MachineInstr( + MachineOp.MV, MachineOperand.reg("a0"), v("copied_value") + ), + MachineInstr(MachineOp.LABEL, comment=".done"), + ] + allocator = allocator_module.LinearScanAllocator(["t0", "t1", "t2"]) + assembly = allocator.emit( + allocator_module.block_from_machine_instrs(machine) + ) + + assert not names & set(assembly.replace(",", " ").split()) + assert "%" not in assembly + RISCVAEncoder().assemble(assembly) + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_store_operands_are_uses_not_definitions(allocator_module): + store = MachineInstr( + MachineOp.SW, + MachineOperand.vreg("value"), + MachineOperand.vreg("address"), + ) + + converted = allocator_module.block_from_machine_instrs([store])[0] + + assert converted.defines == set() + assert converted.uses == {"value", "address"} + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +@pytest.mark.parametrize("opcode", [MachineOp.BNEZ, MachineOp.J]) +def test_control_pseudo_round_trip_preserves_target_comment( + allocator_module, opcode +): + condition = ( + MachineOperand.vreg("condition") if opcode is MachineOp.BNEZ else None + ) + original = MachineInstr(opcode, condition, comment=".target") + + block = allocator_module.block_from_machine_instrs([original]) + converted = allocator_module.machine_instrs_from_block(block)[0] + + assert converted == original + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_round_trip_does_not_misclassify_vreg_name_prefix(allocator_module): + block = [ + allocator_module.LsInstruction( + 0, + "mv", + ["a_temporary", "source"], + defines={"a_temporary"}, + uses={"source"}, + ) + ] + + converted = allocator_module.machine_instrs_from_block(block)[0] + + assert converted.dst == MachineOperand.vreg("a_temporary") + assert converted.src1 == MachineOperand.vreg("source") + + +@pytest.mark.parametrize("allocator_module", ALLOCATOR_MODULES) +def test_round_trip_rejects_unknown_opcode_instead_of_falling_back_to_mv( + allocator_module, +): + block = [allocator_module.LsInstruction(0, "not-an-op")] + + with pytest.raises(ValueError, match="not-an-op"): + allocator_module.machine_instrs_from_block(block) diff --git a/tests/test_regalloc_pseudo_benchmark.py b/tests/test_regalloc_pseudo_benchmark.py new file mode 100644 index 0000000..39f40a0 --- /dev/null +++ b/tests/test_regalloc_pseudo_benchmark.py @@ -0,0 +1,67 @@ +"""Coverage and execution tests for the per-pseudo benchmark.""" + +import json + +import pytest + +from benchmarks.test_regalloc import bench_pseudo, bench_regalloc_linear +from scratchv.backend.machine_types import MachineOp + + +EXPECTED_MACHINE_PSEUDOS = { + MachineOp.MV, + MachineOp.LI, + MachineOp.MAX, + MachineOp.BNEZ, + MachineOp.J, + MachineOp.CALL, + MachineOp.LABEL, +} + + +def test_benchmark_matrix_covers_every_supported_machine_pseudo() -> None: + cases = bench_pseudo.machine_pseudo_cases() + + assert bench_pseudo.BENCHMARKED_MACHINE_PSEUDOS == EXPECTED_MACHINE_PSEUDOS + assert {case.opcode for case in cases} == EXPECTED_MACHINE_PSEUDOS + assert len(cases) == len(EXPECTED_MACHINE_PSEUDOS) + for case in cases: + assert any(instr.op is case.opcode for instr in case.instructions) + + +def test_benchmark_matrix_covers_encoder_only_pseudos() -> None: + cases = bench_pseudo.assembler_pseudo_cases() + + assert bench_pseudo.BENCHMARKED_ASSEMBLER_PSEUDOS == {"nop", "ret"} + assert {case.name for case in cases} == {"nop", "ret"} + + +def test_every_pseudo_allocates_encodes_and_executes() -> None: + stats = bench_pseudo.run_bench(repeats=1) + + assert stats["case_count"] == 9 + assert stats["valid"] + assert stats["spill_slots"] == 0 + assert stats["spill_stores"] == 0 + assert stats["reloads"] == 0 + assert all(case["valid"] for case in stats["cases"]) + assert all(case["actual_a0"] == case["expected_a0"] for case in stats["cases"]) + + +def test_pseudo_benchmark_rejects_non_positive_repeats() -> None: + with pytest.raises(ValueError, match="at least 1"): + bench_pseudo.run_bench(repeats=0) + + +def test_pseudo_metrics_are_report_serializable() -> None: + stats = bench_pseudo.run_bench(repeats=1) + public_stats = { + key: value for key, value in stats.items() if not key.startswith("_") + } + results = {"4. Pseudo Instructions": public_stats} + + json.dumps(results) + html = bench_regalloc_linear._make_html(results, 0.0) + markdown = bench_regalloc_linear._make_markdown(results) + assert "4. Pseudo Instructions" in html + assert "4. Pseudo Instructions" in markdown diff --git a/tests/test_regalloc_spill_compare.py b/tests/test_regalloc_spill_compare.py new file mode 100644 index 0000000..d97e7ba --- /dev/null +++ b/tests/test_regalloc_spill_compare.py @@ -0,0 +1,104 @@ +"""Tests for the DSL register-spill comparison benchmark.""" + +from pathlib import Path + +import pytest + +from benchmarks.bench_regalloc_spill_compare import ( + CASE_DIR, + EXPECTED_SCRATCHV_SPILL, + StackAccessStats, + classify_llvm_stack_accesses, + classify_scratchv_stack_accesses, + compile_scratchv, + discover_cases, +) + + +def test_discover_cases_returns_the_complete_pressure_suite() -> None: + cases = discover_cases(CASE_DIR) + + assert [case.stem for case in cases] == [ + "00_low_pressure_chain", + "01_wide_fanout_32", + "02_double_use_40", + "03_lifetime_holes_36", + "04_hot_cold_48", + ] + + +def test_llvm_stack_classifier_excludes_abi_frame_saves() -> None: + asm = """ + fsd fs0, 24(sp) + sd ra, 16(sp) + fsw ft0, 12(sp) + flw ft1, 12(sp) + sw a0, 8(sp) + lw a1, 8(sp) + fld fs0, 24(sp) + ld ra, 16(sp) + """ + + assert classify_llvm_stack_accesses(asm) == StackAccessStats( + spill_slots=2, + spill_stores=2, + reloads=2, + frame_saves=2, + frame_restores=2, + ) + + +def test_scratchv_stack_classifier_covers_all_supported_widths() -> None: + asm = """ + sw t0, -4(sp) + lw t0, -4(sp) + fsw ft0, -8(sp) + flw ft0, -8(sp) + sd t1, -16(sp) + ld t1, -16(sp) + fsd ft1, -24(sp) + fld ft1, -24(sp) + # sw t2, -28(sp) + addi t0, t0, 1 # lw t3, -32(sp) + """ + + assert classify_scratchv_stack_accesses(asm) == StackAccessStats( + spill_slots=4, + spill_stores=4, + reloads=4, + ) + + +@pytest.mark.parametrize( + ("case_name", "expects_spill"), + [ + ("00_low_pressure_chain.dsl", False), + ("01_wide_fanout_32.dsl", True), + ("02_double_use_40.dsl", True), + ("03_lifetime_holes_36.dsl", True), + ("04_hot_cold_48.dsl", True), + ], +) +def test_cases_straddle_the_scratchv_spill_boundary( + case_name: str, + expects_spill: bool, +) -> None: + source = (CASE_DIR / case_name).read_text() + + result = compile_scratchv(source) + + assert (result.stack.spill_slots > 0) is expects_spill + assert result.peak_live is not None and result.peak_live > 0 + assert result.virtual_registers is not None and result.virtual_registers > 0 + assert result.physical_registers == 19 + + +def test_expected_spill_metadata_covers_every_case() -> None: + assert set(EXPECTED_SCRATCHV_SPILL) == { + case.stem for case in discover_cases(CASE_DIR) + } + + +def test_discover_cases_rejects_an_empty_directory(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="No DSL benchmark cases"): + discover_cases(tmp_path) diff --git a/tests/test_riscv_encoder_validation.py b/tests/test_riscv_encoder_validation.py new file mode 100644 index 0000000..cf64451 --- /dev/null +++ b/tests/test_riscv_encoder_validation.py @@ -0,0 +1,32 @@ +"""Validation tests for immediates that must not be silently truncated.""" + +import pytest + +from scratchv.backend.riscv_encoder import RISCVAEncoder, _b_type, _j_type + + +@pytest.mark.parametrize( + "assembly, message", + [ + ("addi a0, zero, 2048", "I-type immediate out of range"), + ("lw a0, 2048(sp)", "I-type immediate out of range"), + ("sw a0, -2049(sp)", "S-type immediate out of range"), + ("srai a0, a1, 32", "RV32 shift amount out of range"), + ], +) +def test_out_of_range_immediate_is_rejected(assembly, message): + with pytest.raises(ValueError, match=message): + RISCVAEncoder().assemble(assembly) + + +def test_branch_hex_immediate_uses_the_common_integer_parser(): + assert RISCVAEncoder().assemble("beq a0, 0x10, .done\n.done:\nnop") == ( + RISCVAEncoder().assemble("beq a0, 16, .done\n.done:\nnop") + ) + + +def test_control_transfer_helpers_reject_unencodable_offsets(): + with pytest.raises(ValueError, match="branch offset out of range"): + _b_type(10, 11, 4096, 0) + with pytest.raises(ValueError, match="jump offset out of range"): + _j_type(1, 1048576) diff --git a/tests/test_rv32_emulator.py b/tests/test_rv32_emulator.py new file mode 100644 index 0000000..b669529 --- /dev/null +++ b/tests/test_rv32_emulator.py @@ -0,0 +1,31 @@ +"""Regression tests for RV32 integer memory bit-pattern semantics.""" + +from scratchv.backend.riscv_encoder import RISCVAEncoder +from scratchv.simulator.rv32_emulator import REG_ID, RV32Emulator + + +def test_write_i32_accepts_any_rv32_bit_pattern() -> None: + emulator = RV32Emulator(mem_size=4096) + + emulator.write_i32(64, 0xFFFFFFFF) + + assert emulator.mem[64:68] == b"\xff\xff\xff\xff" + assert emulator.read_i32(64) == -1 + + +def test_sw_lw_round_trip_preserves_high_bit_values() -> None: + assembly = """\ +li sp, 1024 +li t0, -1 +sw t0, -4(sp) +lw t1, -4(sp) +ret +""" + binary = bytes(RISCVAEncoder().assemble(assembly)) + emulator = RV32Emulator(mem_size=4096) + emulator.load_code(binary) + + emulator.run(max_instr=16) + + assert emulator.regs[REG_ID["t1"]] == 0xFFFFFFFF + assert emulator.mem[1020:1024] == b"\xff\xff\xff\xff" diff --git a/tests/test_simulator.py b/tests/test_simulator.py index f32c708..ff3ce60 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -104,6 +104,21 @@ def test_executes_all_bytes_of_encoded_instruction_words(self): assert machine.instr_count == 2 assert machine.last_error is None + def test_lw_preserves_all_four_bytes(self): + binary = assemble_to_binary( + "li sp, 2048\nli x5, 0x12345678\nsw x5, -4(sp)\nlw x6, -4(sp)\n" + ) + words = [ + int.from_bytes(binary[i:i + 4], "little") + for i in range(0, len(binary), 4) + ] + machine = ProfiledMachine(mem_size=4096) + machine.load_binary(words, origin=0) + machine.run(instructions=len(words), start=0, strict=True) + + assert machine.read_mem_i32(2044) == 0x12345678 + assert machine.get_reg(6) == 0x12345678 + def test_large_li_expands_and_simulates_equivalently(self): before = assemble_to_binary("lui x5, 1\naddi x5, x5, 2\n") after = assemble_to_binary("li x5, 4098\n")