diff --git a/benchmarks/test_regalloc/bench_cnn.py b/benchmarks/test_regalloc/bench_cnn.py index b2b47cf..0568ec1 100644 --- a/benchmarks/test_regalloc/bench_cnn.py +++ b/benchmarks/test_regalloc/bench_cnn.py @@ -16,8 +16,9 @@ from scratchv.backend.regalloc_linear_v1_5 import ( LinearScanAllocator, block_from_machine_instrs, - _INT_REGS, ) +from scratchv.backend.machine_types import ALL_REGS +from scratchv.backend.riscv_encoder import RISCVAEncoder from scratchv.backend.register_alloc import RegisterAllocator from scratchv.standalone.compare_codegen import count_riscv_instrs @@ -56,30 +57,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 [] # --------------------------------------------------------------------------- @@ -185,7 +169,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 +199,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,7 +206,6 @@ 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) @@ -246,8 +227,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": len(alloc._spill_slots), + "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, @@ -266,7 +252,7 @@ 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) @@ -313,7 +299,7 @@ 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") @@ -343,11 +329,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,17 +345,25 @@ 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" diff --git a/benchmarks/test_regalloc/bench_dense.py b/benchmarks/test_regalloc/bench_dense.py index cbf72b1..cc4fd22 100644 --- a/benchmarks/test_regalloc/bench_dense.py +++ b/benchmarks/test_regalloc/bench_dense.py @@ -60,33 +60,30 @@ 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 - ) - 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": len(alloc._spill_slots), + "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, } diff --git a/benchmarks/test_regalloc/bench_simple.py b/benchmarks/test_regalloc/bench_simple.py index fcfbe16..c36e580 100644 --- a/benchmarks/test_regalloc/bench_simple.py +++ b/benchmarks/test_regalloc/bench_simple.py @@ -60,15 +60,12 @@ 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) @@ -79,9 +76,14 @@ def bench_allocate( "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": len(alloc._spill_slots), + "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, diff --git a/benchmarks/test_regalloc/regalloc.md b/benchmarks/test_regalloc/regalloc.md index f22a995..460e75f 100644 --- a/benchmarks/test_regalloc/regalloc.md +++ b/benchmarks/test_regalloc/regalloc.md @@ -123,7 +123,7 @@ 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` | @@ -141,19 +141,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` | `len(alloc._spill_slots)` | 分配的唯一栈槽数 | +| `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 侧)**: @@ -185,10 +186,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 溢出统计 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..e320665 100644 --- "a/docs/topic17_benchmark\346\226\207\346\241\243.md" +++ "b/docs/topic17_benchmark\346\226\207\346\241\243.md" @@ -123,7 +123,7 @@ 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` | @@ -141,9 +141,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` | `len(alloc._spill_slots)` | 分配的唯一栈槽数 | +| `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()` 设置 | 该项是否通过断言 | @@ -185,7 +190,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 到 `"-"` diff --git a/scratchv/backend/instruction_select.py b/scratchv/backend/instruction_select.py index 26395d2..ab8adb1 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -56,6 +56,14 @@ def _emit(self, op: MachineOp, dst=None, src1=None, src2=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_label(self, name: str) -> None: self._instructions.append( MachineInstr(MachineOp.LABEL, comment=name)) @@ -146,15 +154,14 @@ def _select_softmax(self, instr: Instruction) -> None: 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)) @@ -237,8 +244,11 @@ 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_move( + MachineOperand.reg("a0"), + self._op(instr, 0), + comment="return value", + ) self._emit(MachineOp.JALR, MachineOperand.reg("zero"), MachineOperand.reg("ra"), comment="ret") @@ -289,8 +299,7 @@ def _select_sigmoid(self, instr: Instruction) -> None: 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, @@ -315,8 +324,7 @@ 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, @@ -332,8 +340,7 @@ 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") @@ -362,6 +369,5 @@ def _select_maxpool(self, instr: Instruction) -> None: 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..143884e 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 +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] @@ -201,6 +218,12 @@ 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([], {}, {}) # ------------------------------------------------------------------ # Live interval computation @@ -257,7 +280,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 +305,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 +341,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 +441,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 +477,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 +558,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 # ------------------------------------------------------------------ @@ -531,6 +580,12 @@ def report(self) -> str: parts.append("Linear Scan Register Allocation Report") parts.append(f" Virtual registers allocated: {total}") parts.append(f" Stack spill slots used: {spilled}") + parts.append(f" 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 +616,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 +631,7 @@ def block_from_machine_instrs( operands=operands, defines=defines, uses=uses, - comment=mi.comment, + comment=comment, )) return result @@ -620,21 +659,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 +691,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 +699,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..bd8127e 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, 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] @@ -206,8 +221,13 @@ 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([], {}, {}) # ------------------------------------------------------------------ # Live interval computation @@ -262,7 +282,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 +307,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 +349,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 +440,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 +452,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 +516,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 +526,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 +707,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 # ------------------------------------------------------------------ @@ -687,8 +729,13 @@ def report(self) -> str: parts.append("Linear Scan Register Allocation Report") parts.append(f" Virtual registers allocated: {total}") parts.append(f" Stack spill slots used: {spilled}") + parts.append(f" 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 +766,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 +781,7 @@ def block_from_machine_instrs( operands=operands, defines=defines, uses=uses, - comment=mi.comment, + comment=comment, )) return result @@ -776,18 +807,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 +845,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 +853,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..91386de --- /dev/null +++ b/scratchv/backend/regalloc_metrics.py @@ -0,0 +1,44 @@ +"""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 the allocator's reserved comments, so ordinary model + loads/stores using ``sp`` are not conflated with register-allocation + events. 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 any( + marker in content + for marker in ("# spill ", "# evict ", "# store redefined ") + ): + spill_stores += 1 + if content.startswith("lw ") and "# 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..64fb09b --- /dev/null +++ b/scratchv/backend/regalloc_rewrite.py @@ -0,0 +1,291 @@ +"""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}") + 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" + ) + 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 + + 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 + } + 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}") + 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() + 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)) + + 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}" + ) + 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..3428ed6 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,7 @@ 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] = {} def run(self) -> list[MachineInstr]: if self.mode == "naive": @@ -87,48 +93,96 @@ def _allocate_naive(self) -> list[MachineInstr]: 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)" + mem = f"{slot}({STACK_BASE})" self._emit(MachineInstr( MachineOp.SW, - MachineOperand.reg(mem), dst if dst else MachineOperand.reg("zero"), + MachineOperand.reg(mem), comment=f"spill {instr.dst.value}", )) 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 for instr in self.instructions: if instr.op == MachineOp.LABEL: 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]) - - self._emit(MachineInstr(instr.op, dst, src1, src2, instr.comment)) + semantics = get_machine_semantics(instr.op) + operands = [instr.dst, instr.src1, instr.src2] + resolved = list(operands) + reserved: set[str] = set() + + # 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) + self._emit(allocated) + + _, 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) 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 +193,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 +214,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}", + )) + + 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}", + )) + def _get_spill_slot(self, vreg_name: str) -> int: if vreg_name not in self._spill_slots: self._next_spill -= 4 @@ -215,7 +321,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..2020838 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: @@ -177,11 +183,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 +204,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,6 +220,15 @@ def _expand_pseudo(self, line: str) -> list[str]: op = tokens[0].lower() + # 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 @@ -222,20 +238,40 @@ def _expand_pseudo(self, line: str) -> list[str]: 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"j {end_label}", + f"{then_label}:", f"addi {rd}, {rs1}, 0", - f".__max_end_{n}:", + f"{end_label}:", ] # Branch-with-immediate: beq/bne/blt/bge rs1, imm, label @@ -249,6 +285,11 @@ def _expand_pseudo(self, line: str) -> list[str]: 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 +317,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") @@ -444,14 +493,31 @@ def _encode_line( fixup = ("b", label) word = _b_type(rs1, rs2, 0, F3_BGE) elif op == "bnez": + if len(operands) != 2: + raise ValueError( + "bnez expects exactly 2 operands: register and label" + ) 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": + elif op == "j": + if len(operands) != 1: + raise ValueError("j expects exactly 1 operand: label") label = operands[0] fixup = ("j", label) word = _j_type(0, 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(rd, 0) elif op == "jalr": rd = _reg_num(operands[0]) rs1 = _reg_num(operands[1]) @@ -499,6 +565,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 +579,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) diff --git a/scratchv/simulator/tinyfive.py b/scratchv/simulator/tinyfive.py index 310d47e..7689761 100644 --- a/scratchv/simulator/tinyfive.py +++ b/scratchv/simulator/tinyfive.py @@ -32,6 +32,20 @@ def _tinyfive_read_i32_compat(machine_obj, addr: int) -> 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/tests/test_regalloc_metrics.py b/tests/test_regalloc_metrics.py new file mode 100644 index 0000000..dd025c3 --- /dev/null +++ b/tests/test_regalloc_metrics.py @@ -0,0 +1,67 @@ +"""Topic17 acceptance tests for pressure and spill metric alignment.""" + +from pathlib import Path +from types import SimpleNamespace + +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 t2, -4(sp) # spill value +lw t2, -4(sp) # reload value +sw t3, -8(sp) # evict other +""" + + 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, [f"r{i}" for i in range(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 + + +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_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] diff --git a/tests/test_regalloc_p1.py b/tests/test_regalloc_p1.py new file mode 100644 index 0000000..89d429d --- /dev/null +++ b/tests/test_regalloc_p1.py @@ -0,0 +1,330 @@ +"""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) + + +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") diff --git a/tests/test_regalloc_pseudo.py b/tests/test_regalloc_pseudo.py new file mode 100644 index 0000000..1d9376a --- /dev/null +++ b/tests/test_regalloc_pseudo.py @@ -0,0 +1,655 @@ +"""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 + + +ALLOCATOR_MODULES = (regalloc_linear, regalloc_linear_v1_5) + + +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 + + +@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 len(pseudo_alloc._spill_slots) == len(expanded_alloc._spill_slots) + 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_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 len(pseudo_alloc._spill_slots) == len(expanded_alloc._spill_slots) + 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"} + + +@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 len(pseudo_alloc._spill_slots) == len(expanded_alloc._spill_slots) + + +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_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")