diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15aae4b..c9a8220 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,13 @@ jobs: run: | python3.12 -m pytest tests/test_pr37_regression.py -v --tb=short + - name: Run topic10 loop-unroll regressions + run: | + python3.12 -m pytest \ + tests/test_loop_unroll.py \ + tests/test_loop_unroll_case_report.py \ + -v --tb=short + - name: Generate test visualization page if: github.ref == 'refs/heads/main' run: | @@ -218,6 +225,14 @@ jobs: --json benchmark_reports/const_merge_report.json \ --markdown benchmark_reports/const_merge_report.md + # ── 3.1.2 课题10:循环展开 case 报告(A/B + RV32 执行等价) ─────── + - name: Topic 10 loop-unroll case report + run: | + mkdir -p benchmark_reports + python3.12 benchmarks/run_topic10_unroll_case.py \ + --json benchmark_reports/loop_unroll_report.json \ + --markdown benchmark_reports/loop_unroll_report.md + # ── 3.2 DSL 用例编译 + 模拟基准 ──────────────────────────────────── - name: DSL case compilation benchmarks run: | @@ -363,6 +378,9 @@ jobs: if [ -f benchmark_reports/const_merge_report.md ]; then cat benchmark_reports/const_merge_report.md >> $GITHUB_STEP_SUMMARY fi + if [ -f benchmark_reports/loop_unroll_report.md ]; then + cat benchmark_reports/loop_unroll_report.md >> $GITHUB_STEP_SUMMARY + fi echo "" >> $GITHUB_STEP_SUMMARY if [ -f benchmark_reports/github_summary.md ]; then cat benchmark_reports/github_summary.md >> $GITHUB_STEP_SUMMARY diff --git a/benchmarks/cases/topic10_unroll_feature.dsl b/benchmarks/cases/topic10_unroll_feature.dsl new file mode 100644 index 0000000..f471a74 --- /dev/null +++ b/benchmarks/cases/topic10_unroll_feature.dsl @@ -0,0 +1,9 @@ +# Loop unrolling feature case (Topic 10). +# Low-pressure loop body: the greedy allocator is known-correct for this +# shape; the report additionally runs an equivalent IR-level program under +# the RV32 emulator to compare architectural state before and after unroll. +for i = 0, 4 + t = add(i, one) + acc = add(acc, t) +endfor +return acc diff --git a/benchmarks/run_topic10_unroll_case.py b/benchmarks/run_topic10_unroll_case.py new file mode 100644 index 0000000..38359f8 --- /dev/null +++ b/benchmarks/run_topic10_unroll_case.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Run one Topic 10 loop-unroll feature case and emit auditable CI reports. + +The report proves three separate facts: + +1. the configured compiler pipeline runs the unroll pass when ``loop_unroll`` + is opted in, and keeps the loop markers when it is not; +2. the pass changes a deterministic low-pressure case and reports categorized + metrics (full/partial/epilogue counts, instructions before/after, skips); +3. the RV32 emulator executes the original and the unrolled program to + identical architectural state while the unrolled program needs fewer + dynamic instructions. + +This is a deterministic feature/integration case, not a real-workload speedup +claim. Real ONNX benchmark numbers remain separate in ``run_benchmark.py``. +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from scratchv.backend._asm_parser import parse_asm +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.register_alloc import RegisterAllocator +from scratchv.backend.riscv_encoder import assemble_to_binary +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, OpCode, Program +from scratchv.optimizer.loop_unroll import LoopUnroll +from scratchv.simulator.rv32_emulator import REG_ID, RV32Emulator + +SCHEMA_VERSION = "topic10-unroll-case/1" +DEFAULT_CASE = ( + Path(__file__).parent / "cases" / "topic10_unroll_feature.dsl" +) +DEFAULT_JSON = Path("benchmark_reports/loop_unroll_report.json") +DEFAULT_MARKDOWN = Path("benchmark_reports/loop_unroll_report.md") +#: Expected return value of the deterministic IR case below (iv = 0..3 sums +#: to 3 after the low-pressure body ``v2 = iv + one`` with ``one`` = 1). +EXPECTED_RESULT = 4 +#: Registers that carry observable results. Greedy allocation may place the +#: same value into different temporaries after unrolling, so only the return +#: register is compared; the full state is still recorded for auditing. +OBSERVED_REGISTERS = ("x10",) + + +def _make_const(builder: IRBuilder, name: str, value: int): + value_obj = builder.make_value( + name=name, dtype=DataType.INT32, is_constant=False) + builder._emit(OpCode.LOAD_CONST, value_obj, value=value) + return value_obj + + +def build_case_program(end: int = 4) -> Program: + """Deterministic low-pressure loop; returns ``v2`` from the last trip. + + ``one`` is emitted with ``is_constant=False`` so the backend keeps it as + a runtime value; the emulator then observes ``a0 == end``. + """ + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + one = _make_const(builder, "one", 1) + iv = builder.for_loop(0, end) + v2 = builder.add(iv, one) + builder.endfor() + builder.ret(v2) + return builder.program + + +def count_ir(program: Program) -> int: + return sum( + 1 + for func in program.functions + for block in func.blocks + for _ in block.instructions + ) + + +def count_asm(asm: str) -> int: + return sum( + line.opcode is not None and not line.is_directive + for line in parse_asm(asm) + ) + + +def emit_asm(program: Program) -> str: + machine = InstructionSelector(program).run() + allocated = RegisterAllocator(machine, mode="greedy").run() + return AsmEmitter(allocated).emit() + + +def run_asm(asm: str) -> dict[str, Any]: + """Assemble and execute *asm*; return register state and counters.""" + binary = assemble_to_binary(asm) + emulator = RV32Emulator() + emulator.load_code(bytes(binary)) + dynamic = emulator.run() + return { + "backend": "rv32-emulator", + "registers": {f"x{i}": emulator.regs[i] for i in range(32)}, + "a0": emulator.regs[REG_ID["a0"]], + "dynamic_instructions": dynamic, + } + + +def _measure_side(*, unroll: bool, repeats: int) -> dict[str, Any]: + """Run the deterministic case with unrolling off/on.""" + program = build_case_program() + ir_before = count_ir(program) + pass_time_ms = 0.0 + unroll_stats: dict[str, Any] | None = None + loops_unrolled = 0 + if unroll: + times = [] + runner: LoopUnroll | None = None + for _ in range(repeats): + candidate = build_case_program() + runner = LoopUnroll(candidate) + started = time.perf_counter() + runner.run() + times.append((time.perf_counter() - started) * 1000) + program = candidate + pass_time_ms = statistics.median(times) + assert runner is not None + unroll_stats = runner.stats + loops_unrolled = runner.stats["loops_unrolled"] + ir_after = count_ir(program) + asm = emit_asm(program) + execution = run_asm(asm) + return { + "loop_unroll": unroll, + "loops_unrolled": loops_unrolled, + "ir_instructions": ir_after, + "ir_instructions_before_pass": ir_before, + "ir_instructions_added": ir_after - ir_before, + "asm_instructions": count_asm(asm), + "pass_time_ms": round(pass_time_ms, 4), + "unroll_stats": unroll_stats, + "execution": execution, + "asm_head": asm.splitlines()[:12], + } + + +def measure_wiring(case_path: Path) -> dict[str, Any]: + """Prove the configured compiler pipeline honours the opt-in flag.""" + source = case_path.read_text() + common = dict(optimize_level="all", reg_alloc="greedy", dump_ir=True) + with tempfile.TemporaryDirectory() as tmp: + off = CompilerDriver( + CompilerConfig(loop_unroll=False, **common)).compile( + "", str(Path(tmp) / "off.s"), dsl_source=source) + on = CompilerDriver( + CompilerConfig(loop_unroll=True, **common)).compile( + "", str(Path(tmp) / "on.s"), dsl_source=source) + if not (off.success and on.success): + raise RuntimeError( + f"feature case failed to compile: {off.errors or on.errors}") + off_ir = off.ir_dump.split("--- IR Dump (after")[1] + on_ir = on.ir_dump.split("--- IR Dump (after")[1] + return { + "off_has_loop_markers": "endfor" in off_ir, + "on_has_loop_markers": "endfor" in on_ir, + "off_pass_present": "loop-unroll" in off.stats.get("passes", {}), + "on_pass_present": "loop-unroll" in on.stats.get("passes", {}), + "on_pass_stats": on.stats.get("passes", {}).get("loop-unroll"), + } + + +def evaluate(case_path: Path, repeats: int) -> dict[str, Any]: + """Build the full report payload and run the hard invariants.""" + off = _measure_side(unroll=False, repeats=repeats) + on = _measure_side(unroll=True, repeats=repeats) + wiring = measure_wiring(case_path) + + hard_checks = { + "pipeline_runs_pass_when_enabled": bool(wiring["on_pass_present"]), + "pipeline_skips_pass_when_disabled": ( + not wiring["off_pass_present"]), + "loop_markers_removed_when_enabled": ( + not wiring["on_has_loop_markers"]), + "loop_markers_kept_when_disabled": ( + wiring["off_has_loop_markers"]), + "case_loop_was_unrolled": on["loops_unrolled"] >= 1, + "execution_result_is_expected": ( + off["execution"]["a0"] == EXPECTED_RESULT + and on["execution"]["a0"] == EXPECTED_RESULT + ), + "observed_registers_identical": all( + off["execution"]["registers"][reg] + == on["execution"]["registers"][reg] + for reg in OBSERVED_REGISTERS + ), + "dynamic_instructions_reduced": ( + on["execution"]["dynamic_instructions"] + < off["execution"]["dynamic_instructions"]), + } + failed = sorted(name for name, ok in hard_checks.items() if not ok) + + return { + "schema_version": SCHEMA_VERSION, + "topic": "topic10-loop-unroll", + "generated_at": datetime.now(timezone.utc).isoformat(), + "case": str(case_path), + "expected_result": EXPECTED_RESULT, + "observed_registers": list(OBSERVED_REGISTERS), + "config": {"optimize_level": "all", "reg_alloc": "greedy"}, + "runs": repeats, + "unroll_off": off, + "unroll_on": on, + "wiring": wiring, + "hard_checks": hard_checks, + "hard_failures": failed, + "honesty": ( + "Deterministic feature case executed by the repository RV32 " + "emulator. Dynamic-instruction numbers are emulator counts, not " + "hardware cycles; unrolling trades code size for dynamic " + "instructions and remains opt-in." + ), + } + + +def render_markdown(report: dict[str, Any]) -> str: + off, on = report["unroll_off"], report["unroll_on"] + dyn_off = off["execution"]["dynamic_instructions"] + dyn_on = on["execution"]["dynamic_instructions"] + saved = dyn_off - dyn_on + pct = (saved / dyn_off * 100) if dyn_off else 0.0 + stats = on["unroll_stats"] or {} + skipped = sum(stats.get("skipped", {}).values()) if stats else 0 + lines = [ + "# Topic 10 Loop-Unroll Feature Case", + "", + f"- Schema: `{report['schema_version']}`", + f"- Case: `{report['case']}` (expected `a0 == " + f"{report['expected_result']}`)", + f"- Generated: {report['generated_at']}", + f"- Hard checks: " + f"{'PASS' if not report['hard_failures'] else 'FAIL'} " + f"({len(report['hard_checks']) - len(report['hard_failures'])}" + f"/{len(report['hard_checks'])})", + "", + "## A/B summary", + "", + "| Metric | unroll off | unroll on | delta |", + "|--------|-----------:|----------:|------:|", + f"| IR instructions | {off['ir_instructions']} | " + f"{on['ir_instructions']} | {on['ir_instructions_added']:+d} |", + f"| ASM instructions | {off['asm_instructions']} | " + f"{on['asm_instructions']} | " + f"{on['asm_instructions'] - off['asm_instructions']:+d} |", + f"| Dynamic instructions (emulator) | {dyn_off} | {dyn_on} | " + f"-{saved} ({pct:.1f}%) |", + f"| `a0` result | {off['execution']['a0']} | " + f"{on['execution']['a0']} | equal |", + f"| Unroll pass time (ms, median) | n/a | " + f"{on['pass_time_ms']:.4f} | - |", + "", + "## Unroll pass metrics (on)", + "", + f"- loops_seen={stats.get('loops_seen', 0)}, " + f"loops_unrolled={stats.get('loops_unrolled', 0)}, " + f"full={stats.get('full_unrolls', 0)}, " + f"partial={stats.get('partial_unrolls', 0)}, " + f"epilogue={stats.get('partial_epilogues', 0)}, " + f"skipped={skipped}", + f"- IR instructions before->after pass: " + f"{stats.get('instructions_before', 'n/a')} -> " + f"{stats.get('instructions_after', 'n/a')}", + "", + "## Hard checks", + "", + ] + for name, ok in report["hard_checks"].items(): + lines.append(f"- [{'x' if ok else ' '}] {name}") + lines += [ + "", + "## Honesty", + "", + report["honesty"], + "", + ] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", type=Path, default=DEFAULT_CASE) + parser.add_argument("--json", type=Path, default=DEFAULT_JSON) + parser.add_argument("--markdown", type=Path, default=DEFAULT_MARKDOWN) + parser.add_argument("--repeats", type=int, default=5) + args = parser.parse_args(argv) + if args.repeats < 1: + parser.error("--repeats must be positive") + if not args.case.is_file(): + parser.error(f"feature case not found: {args.case}") + + report = evaluate(args.case, args.repeats) + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(report, indent=2) + "\n") + args.markdown.parent.mkdir(parents=True, exist_ok=True) + args.markdown.write_text(render_markdown(report) + "\n") + print(render_markdown(report)) + if report["hard_failures"]: + print("HARD FAILURES: " + ", ".join(report["hard_failures"])) + return 1 + print(f"reports written: {args.json}, {args.markdown}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git "a/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" new file mode 100644 index 0000000..f15407c --- /dev/null +++ "b/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -0,0 +1,561 @@ +# ScratchV 循环展开优化(课题 10)开发文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 课题名称:循环展开优化(topic 10) +> 涉及模块:`scratchv/optimizer/loop_unroll.py`(新建)、`scratchv/optimizer/__init__.py`、`scratchv/pass_interface.py`、`scratchv/compiler.py`、`scratchv/main.py`、`tests/test_loop_unroll.py`(新建) +> 前置文档:《10-循环展开优化/设计文档.md》 + +--- + +## 一、目标、范围与交付物 + +### 1.1 目标 + +在 ScratchV IR 层新增通用 `LoopUnroll` pass,将 `FOR`/`ENDFOR` 循环按因子复制循环体,降低每迭代的分支/自增开销;支持完全展开、整除部分展开与余数循环部分展开;接入 `--optimize all` 管线并提供 CLI 开关;所有性能结论以指令计数或仿真动态指令数为证据。 + +### 1.2 范围 + +- ✅ 包含:IR 级 `FOR`/`ENDFOR` 展开;因子启发式与阈值;管线/CLI 集成;单元测试、语义等价测试与指令数断言。 +- ❌ 不包含:向量化(课题 29);软件流水/调度;standalone Conv 手写 K=3 展开的修改;`step != 1` 的展开;body 内含 `BR`/`LABEL` 的循环展开(v1 直接跳过);跨函数/跨块展开。 + +### 1.3 交付物 + +| 交付物 | 路径 | +|--------|------| +| pass 实现 | `scratchv/optimizer/loop_unroll.py` | +| 导出 | `scratchv/optimizer/__init__.py` | +| 管线与 CLI | `scratchv/compiler.py`、`scratchv/main.py` | +| 测试 | `tests/test_loop_unroll.py` | +| 数据 | 013/014/019 基准开关对比 JSON / `--count-instr` 输出 | +| 文档同步 | `docs/topics/10-循环展开优化.md` | + +--- + +## 二、接口契约 + +> 本节为精确契约,实现不得改名;测试与文档均以本节为准。 + +### 2.1 类名与签名 + +```python +# scratchv/optimizer/loop_unroll.py +from scratchv.ir.types import Program + +class LoopUnroll: + def __init__( + self, + program: Program, + max_factor: int = 8, + full_threshold: int = 8, + body_limit: int = 64, + max_growth: int = 512, + epilogue: bool = False, + ) -> None: ... + + def run(self) -> int: ... + + @property + def stats(self) -> dict: ... + + # 以下为内部实现(命名固定,便于测试与 debug) + def _find_pairs(self, instrs: list) -> list[tuple[int, int]]: ... + def _select_plan(self, instrs: list, for_idx: int, endfor_idx: int): ... + def _copy_region(self, func, region: list, body_defs: set, iv_name: str, + bind_k, last: bool, k: int) -> list: ... + def _fresh_name(self, func, base: str) -> str: ... +``` + +构造参数与既有 pass(`LICM(program)`、`ConstantFolder(program)`)保持"接收 program + 可选策略参数"的遗留风格,以直接复用 `compiler._PassAdapter`。 + +| 参数 | 类型 | 默认 | 语义 | +|------|------|------|------| +| `program` | `Program` | 必填 | 被优化的 IR 程序(原地修改) | +| `max_factor` | `int` | 8 | 部分展开因子上限 | +| `full_threshold` | `int` | 8 | 允许完全展开的最大 trip count | +| `body_limit` | `int` | 64 | body IR 指令数上限 | +| `max_growth` | `int` | 512 | 单循环新增 IR 指令数上限 | +| `epilogue` | `bool` | `False` | 是否允许余数循环 | + +### 2.2 `run()` 返回值与统计 + +- `run() -> int`:返回**被展开的循环个数**(完全展开 + 部分展开 + 余数展开各计 1),与 `LICM.run()` 返回"变更条数"的惯例一致,供 `_PassAdapter` 作为 `PassResult.changes`。 +- `stats` 只读属性,结构固定: + +```python +{ + "loops_seen": int, + "loops_unrolled": int, + "full_unrolls": int, + "partial_unrolls": int, # 含 partial_epilogues + "partial_epilogues": int, + "instructions_before": int, # 函数级 IR 指令总数(全部函数) + "instructions_after": int, + "instructions_added": int, # after - before + "skipped": { # 键名固定,未出现的键按 0 处理 + "bad_attrs": int, "step_not_one": int, "trip_lt_2": int, + "already_unrolled": int, "body_has_branches": int, + "nested_loop": int, "body_too_large": int, "multi_def": int, + "carried_value": int, "no_factor": int, "growth_limit": int, + "unprofitable": int, "unpaired": int, "internal_error": int, + }, +} +``` + +- 语义:`run()` 可重复调用;部分展开写 `FOR.attrs["unrolled"]=U` 保证幂等(余数循环同样写 `unrolled` 标记);同一循环的同一跳过原因在一轮 `run()` 内只计一次(`_process_function` 每应用一个循环就重扫,重扫不得放大计数);单函数内部异常不抛出,记 `skipped["internal_error"]` 并按快照回滚该函数(指令列表、`attrs` 与 `operands` 一并还原)。 + +### 2.3 `CompilerConfig` 字段(精确名称) + +```python +# scratchv/compiler.py, class CompilerConfig +loop_unroll: bool = False # optimize_level == "all" 时是否运行(opt-in) +unroll_max_factor: int = 8 +unroll_full_threshold: int = 8 +unroll_body_limit: int = 64 +unroll_max_growth: int = 512 +unroll_epilogue: bool = False +``` + +### 2.4 CLI 开关(精确名称) + +```python +# scratchv/main.py, build_arg_parser() +parser.add_argument("--loop-unroll", dest="loop_unroll", action="store_true", + default=False, + help="Enable IR loop unrolling at --optimize all (Topic 10)") +parser.add_argument("--no-loop-unroll", dest="loop_unroll", action="store_false", + help="Disable IR loop unrolling at --optimize all (Topic 10)") +parser.add_argument("--unroll-factor", type=int, default=8, + help="Max unroll factor for partial unrolling (default: 8)") +parser.add_argument("--unroll-full-threshold", type=int, default=8, + help="Fully unroll loops with trip count <= N (default: 8)") +parser.add_argument("--unroll-body-limit", type=int, default=64, + help="Max loop-body IR instructions eligible for unrolling (default: 64)") +parser.add_argument("--unroll-max-growth", type=int, default=512, + help="Max newly added IR instructions per loop (default: 512)") +parser.add_argument("--unroll-epilogue", action="store_true", + help="Allow remainder (epilogue) loop when factor does not divide trip count") +``` + +`args_to_config` 映射:`loop_unroll=args.loop_unroll`、`unroll_max_factor=args.unroll_factor`、`unroll_full_threshold=args.unroll_full_threshold`、`unroll_body_limit=args.unroll_body_limit`、`unroll_max_growth=args.unroll_max_growth`、`unroll_epilogue=args.unroll_epilogue`。两个开关共享同一 dest,同时给出时后者生效。 + +### 2.5 管线注册点 + +```python +# scratchv/compiler.py, CompilerDriver._run_optimizations() +if self.config.optimize_level == "all": + ... + pm.add(_PassAdapter("licm", LICM(program))) + unroll = None + if self.config.loop_unroll: + unroll = LoopUnroll( + program, + max_factor=self.config.unroll_max_factor, + full_threshold=self.config.unroll_full_threshold, + body_limit=self.config.unroll_body_limit, + max_growth=self.config.unroll_max_growth, + epilogue=self.config.unroll_epilogue, + ) + pm.add(_PassAdapter("loop-unroll", unroll)) + pm.add(_PassAdapter("dead-code-elim-cleanup", DeadCodeEliminator(program))) + result = pm.run(program) + if unroll is not None: + result.stats["loop-unroll"] = unroll.stats + return result +``` + +- **opt-in 策略**:默认 `loop_unroll=False`;`--optimize all --loop-unroll`(或 `CompilerConfig(loop_unroll=True)`)才启用展开;`--optimize basic` / `none` 不启用;`--no-loop-unroll` 显式关闭。原因:贪婪分配器存在「溢出后不重载」既有缺陷(`register_alloc.py:164-199`),展开抬高活跃值数量会放大该缺陷、把原本正确的程序静默编错(评审 F2);在分配器修复前不默认开启。ONNX 前端不产生 `FOR`/`ENDFOR`(已核对 `scratchv/frontend/onnx_parser.py`),因此该默认值不影响现有 ONNX 路径输出。 +- `PassResult` 增加向后兼容字段 `stats: dict = field(default_factory=dict)`;`_PassAdapter.run` 填充 `stats=getattr(self._legacy, "stats", {})`(`LICM` 等旧 pass 无 `stats` 时为 `{}`);`PassManager.run` 将各 pass 的 `stats` 汇总为 `{pass_name: stats}`。 + +### 2.6 错误与警告契约 + +- 所有跳过原因写入 `stats["skipped"]`;不写入 `PassResult.warnings` 长列表(由 adapter 汇总一行 message)。 +- `_PassAdapter` 生成的 message 形如:`loop-unroll: 3 loop(s) (2 full, 1 partial, 0 epilogue), IR 40 → 96`。 +- pass 对外契约:绝不破坏 IR 结构;无法安全展开时不修改目标循环的任何指令。 + +--- + +## 三、LoopUnroll 类设计 + +### 3.1 内部状态 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `self.program` | `Program` | 输入程序 | +| `self._max_factor` 等 | int/bool | 策略参数 | +| `self._stats` | dict | §2.2 统计,`run()` 开始时重置 | +| `self._name_cache` | `dict[str, set[str]]` | 每个函数的已用名集合,用于 `_fresh_name` | + +### 3.2 `run()` 流程 + +``` +run(): + reset stats + for func in program.functions: + try: + _process_function(func) + except Exception as exc: + _rollback(func) # 回滚该函数上一快照 + stats["skipped"]["internal_error"] += 1 # 不中断其他函数 + return stats["loops_unrolled"] + +_process_function(func): + for block in func.blocks: + changed = True + for _ in range(32): # 迭代上限,防退化 + changed = False + for (f, e) in _find_pairs(block.instructions): # 内层在前 + plan = _select_plan(block.instructions, f, e) + if plan is None: continue + _apply_unroll(block.instructions, f, e, plan) + changed = True + break # 索引已变,重新扫描 + if not changed: break +``` + +### 3.3 `_select_plan`(计划选择) + +按《设计文档》§2.2.2 实现,返回 `UnrollPlan(mode, U, q, r, estimated_added, dynamic_saving)` 或 `None`。检查顺序固定(先廉价后昂贵):attrs 类型/step → trip → 标记 → body 屏障 → body 大小 → 重定义检查 → 因子枚举 → 增长/盈利估算。 + +### 3.4 `_apply_unroll` + +``` +_apply_unroll(instrs, f, e, plan): + iv = instrs[f].dest + region = instrs[f+1 : e] + body_defs = {i.dest.name for i in region if i.dest} + iv_in_body = any(op.name == iv.name for i in region for op in i.operands) + iv_after = any(op.name == iv.name for i in instrs if i is not region) # 见 §4.5 精确扫描 + + if partial: rewrite FOR.attrs = {0, q, 1, "unrolled": U} # 必须改写,见设计文档 §2.2.3 + if partial and iv_in_body: emit setup consts(u_tmp/one_tmp[/start_tmp]) + new_body = [] + for k in range(U): + bind = _make_binding(...) if iv_in_body else None + new_body += ([bind] if bind else []) + _copy_region(func, region, body_defs, iv, bind, last=(k==U-1), k) + remainder = _make_remainder_loop(...) if plan.mode == "partial_epilogue" else None + # 组装:FULL 时替换 [f..e] 为 new_body;部分展开替换区间为 FOR + new_body + ENDFOR (+ remainder) + _redirect_after_values(...) # 循环后 iv / body_defs 引用重定向 + stats 更新 +``` + +--- + +## 四、IR 重写算法 + +### 4.1 `FOR`/`ENDFOR` 配对扫描 + +```python +def _find_pairs(self, instrs): + stack, pairs = [], [] + for i, ins in enumerate(instrs): + if ins.opcode == OpCode.FOR: + stack.append(i) + elif ins.opcode == OpCode.ENDFOR: + if not stack: + self._stats["skipped"]["unpaired"] += 1 + continue + pairs.append((stack.pop(), i)) + if stack: + self._stats["skipped"]["unpaired"] += 1 + return pairs # 按 ENDFOR 顺序 = 内层在前 +``` + +要点:`ENDFOR` 不匹配时**只记数不改写**;返回的 pair 使用当前索引,任何一次修改后必须整个 block 重扫描。 + +### 4.2 命名工厂 + +```python +def _fresh_name(self, func, base): + used = self._name_cache.setdefault(func.name, _collect_names(func)) + name = base + n = 0 + while name in used: + n += 1 + name = f"{base}_{n}" + used.add(name) + return name +``` + +`_collect_names(func)` 收集所有 `block.instructions` 的 `dest.name`、`operands[].name`、`func.params[].name`、`func.locals[].name`。**新值一律 `is_constant=False`**(避免 `InstructionSelector._op()` 将其当立即数用于 `mul`/`add` 的 R 型编码)。 + +### 4.3 副本复制与轮转重命名 + +```python +def _copy_region(self, func, region, body_defs, iv_name, bind, last, k): + cur = {} + out = [] + for ins in region: + ops = [] + for op in ins.operands: + if op.name == iv_name: + ops.append(bind) # iv → 本副本绑定值 + elif op.name in body_defs: + ops.append(_value_like(op, cur.get(op.name, op.name))) + else: + ops.append(op) # 循环外定义/常量:原样 + dest = ins.dest + if dest is not None and dest.name in body_defs: + new_name = dest.name if last else self._fresh_name(func, f"{dest.name}__u{k}") + cur[dest.name] = new_name + dest = _value_like(dest, new_name) + out.append(Instruction(ins.opcode, dest, ops, dict(ins.attrs), ins.target)) + return out +``` + +规则归纳(必须与《设计文档》§2.2.3 一致): + +1. 只有 `body_defs` 内的名字参与重命名与 `cur` 轮转;循环外定义的名字、常量、参数保持不变。 +2. 同一 name 在一个副本内的多次引用解析到"该副本内最近一次定义"(`cur`),定义前的引用解析到上一副本的末值(部分展开即上一组/上一副本)。 +3. **末副本(`last=True`)复用原名**:保证循环携带值跨组、跨循环可见;也是 SSA 单次赋值成立的关键(每个 name 全程序静态定义一次)。 +4. `dest` 为 `None` 的指令(`STORE`、`ENDFOR` 等)只重写 `operands`。 +5. 嵌套 `FOR` 的 `dest` 属于 `body_defs`,随副本重命名;`ENDFOR` 无 `dest`,原样复制。 + +### 4.4 iv 绑定指令 + +| 模式 | 副本 k 的绑定 `bind_k` | 成本 | +|------|------------------------|------| +| `FULL` | `LOAD_CONST(start + k)`,dtype `INT32` | 每副本 1 条 | +| `PARTIAL_EXACT` / `PARTIAL_EPILOGUE`,k=0,start=0 | `MUL(iv, u_tmp)` | 1 条 | +| 部分展开,k=0,start≠0 | `ADD(MUL(iv, u_tmp), start_tmp)` | 2 条 | +| 部分展开,k>0 | `ADD(bind_{k-1}, one_tmp)` | 每副本 1 条 | + +setup(在 `FOR` 之前):`u_tmp = LOAD_CONST(U)`、`one_tmp = LOAD_CONST(1)`、`start_tmp = LOAD_CONST(start)`(start≠0 时)。`iv` 未被 body 使用时全部不生成。 + +`FULL` 模式若 `iv` 未使用且循环后无引用:直接删除 `FOR`/`ENDFOR`,不生成任何绑定。 + +### 4.5 循环后引用重定向 + +- `iv`:扫描循环区间之外的指令(同 block 后续 + 其他 block + 之后创建的指令),若存在 `operand.name == iv.name`,则在展开代码之后插入 `iv_final = LOAD_CONST(start + N)`,并把这些引用改为 `iv_final`。因为 `iv` 的新语义是组计数(或已随 `FULL` 消失),不能再作为循环后的元素值。 +- `body_defs`: + - `FULL` / `PARTIAL_EXACT`(r==0):末副本复用原名,循环后引用**无需修改**。 + - `PARTIAL_EPILOGUE`(r>0):余数循环的副本使用 fresh 名,循环后对 `body_defs` 的引用按余数循环的 `cur` 映射重定向(如 `$v_3 → $v_3__ep`)。 + - 记录重定向用的 `post_rename: dict[str, str]`,在一次 `_apply_unroll` 结束前应用,避免影响其他循环。 + +### 4.6 标签 / `BR` 处理(v1 契约) + +- body 含 `LABEL`/`BR`/`BR_IF` → 直接跳过(`body_has_branches`):复制标签会产生重名标签,复制分支需重映射 `target`(含 `br_if` 的 `true,false` 双目标),v1 不做,留作后续课题。 +- body 含嵌套 `FOR`/`ENDFOR`:内层已完全展开(标记消失)时外层可继续;仍存在标记 → 外层跳过(`nested_loop`),避免跨组携带值无法用无 phi 的 IR 表达。 +- 嵌套 `FOR`/`ENDFOR` 的标签由后端 lowering 用 `_fresh_label` 动态生成(`instruction_selector` 每次 `FOR` 递增计数器),因此复制嵌套标记不会造成汇编标签冲突。 + +### 4.7 幂等、异常与统计 + +- 部分展开:`FOR.attrs["unrolled"] = U`;`_select_plan` 见该键即跳过。 +- 完全展开:标记随 `FOR` 删除,不存在二次处理。 +- 单函数 `try/except`:异常时回滚该函数的 `blocks`(在操作前保存 `list(block.instructions)` 快照),确保不产生半成品 IR。 +- 统计更新点:计划通过时记 `loops_unrolled` 与对应模式计数;跳过时记 `skipped` 对应键;结束时按 `Program` 指令总数写 `instructions_before/after/added`。 + +--- + +## 五、测试文件与用例 + +测试文件 `tests/test_loop_unroll.py`,类组织: + +| 测试类 | 用例 | 断言要点 | +|--------|------|----------| +| `TestLoopUnrollFull` | `test_full_unroll_iv_used` | 无标记;12 条 IR;4 条 `load_const` 值 `0..3`;dtype/`is_constant`;`IRVerifier` 无 ERROR;二次 `run()==0` | +| | `test_full_unroll_dynamic_equivalence` | `RV32Emulator` 动态条数 `21 → 12`;`a0` 相等 | +| `TestLoopUnrollPartial` | `test_partial_exact_divisor` | `FOR.attrs["end"]==2` 且 `unrolled==3`;13 条 IR;动态 `31 → 27` | +| | `test_partial_epilogue` | 两个 `FOR`;余数区间 `[6,7]`;`return` 引用 `$v_3__ep`;动态 `36 → 30` | +| | `test_epilogue_not_emitted_when_exact` | N=6、`epilogue=True` 时整除因子 U=3、r=0,仅一个 `FOR` 且 `partial_epilogues==0` | +| `TestLoopUnrollNegative` | `test_skip_step_not_one` | IR 逐条不变;`skipped["step_not_one"]==1` | +| | `test_skip_unpaired` | `for…for…endfor`;`skipped["unpaired"]>=1`;IR 不变 | +| | `test_skip_body_too_large` | 100 条 body;`skipped["body_too_large"]==1` | +| | `test_skip_iv_redefined` | `skipped["multi_def"]==1`;IR 不变 | +| `TestLoopUnrollNested` | `test_inner_first` | `full_unrolls==2`;最终无标记;12 条 body | +| `TestCaseHelpers` | 指令计数/校验辅助 | `count_ir(program)`、`run_rv32(program) -> (a0, dynamic_count)` | + +关键 JSON 断言(供 CI 与验收): + +```python +stats = pass_.stats +assert stats["skipped"]["step_not_one"] == 1 +assert stats["full_unrolls"] == 1 +assert stats["instructions_added"] > 0 +``` + +--- + +## 六、compiler.py / main.py 集成 + +### 6.1 `compiler.py` + +1. `CompilerConfig` 新增 §2.3 六个字段(默认值与 CLI 一致)。 +2. `PassResult` 新增 `stats` 字段(`pass_interface.py`,默认 `{}`,向后兼容)。 +3. `_PassAdapter.run` 填充 `stats=getattr(self._legacy, "stats", {})`;`PassManager.run` 汇总 `stats`。 +4. `_run_optimizations` 注册 `loop-unroll` + `dead-code-elim-cleanup`(仅 `all` 且 `loop_unroll`)。 +5. `CompileResult.stats` 增加 `"passes"` 键(各 pass 统计)与既有 `opt_message`;`summarize` 不变。 + +### 6.2 `main.py` + +1. 新增 §2.4 六个开关并完成 `args_to_config` 映射。 +2. `--no-loop-unroll` 与 `--unroll-*` 在 `--optimize none/basic` 下不报错、静默无效(与 `--peephole-asm` 等既有开关行为一致)。 +3. 帮助文本标注 `(Topic 10)`,与仓库惯例一致。 + +### 6.3 集成自检命令 + +```bash +# 1) IR 结构:all 应无 for/endfor,no-loop-unroll 保留 +python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --dump-ir 2>&1 | grep -c endfor +python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --no-loop-unroll --dump-ir 2>&1 | grep -c endfor + +# 2) 静态指令数对比 +python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --count-instr -o /tmp/on.s +python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --no-loop-unroll --count-instr -o /tmp/off.s + +# 3) 动态指令数证据 +python benchmarks/bench_runner.py benchmarks/cases --output-json /tmp/bench_on.json +``` + +--- + +## 七、验收标准 + +| 编号 | 标准 | 证据/命令 | +|------|------|-----------| +| A1 | 5 组单元用例全部通过,结构与 iv 绑定断言精确 | `python -m pytest tests/test_loop_unroll.py -v` | +| A2 | 语义等价:至少 3 个用例在 `RV32Emulator` 中展开前后 `a0` 相同,且动态指令数严格下降(21→12、31→27、36→30) | 测试内断言 `run()` 返回条数 | +| A3 | SSA/结构合法:展开输出通过 `IRVerifier`,无 `ERROR`(0 次 SSA 违规) | 用例内 `IRVerifier(program).verify()` | +| A4 | 零回归:全量测试通过,`--no-loop-unroll` 输出与课题前基线逐字节一致 | `make test`;先保存基线 `/tmp/base.s`,再 `cmp /tmp/base.s /tmp/off.s` | +| A5 | 性能证据:013/014/019 开关对比的 `instruction_count`/`--count-instr` 数据写入课题文档,百分比由数据计算 | `bench_runner` JSON 差值 | +| A6 | 可回退:置 `CompilerConfig(loop_unroll=False)` 或 `--no-loop-unroll`,编译输出与未合入本 pass 时一致 | A4 的字节级对比 | +| A7 | 不越界:`git diff --stat` 不含 `scratchv/standalone/**` 与 `scratchv/frontend/**` | 提交前检查 | + +--- + +## 八、风险与回退 + +### 8.1 无 phi/SSA 下的 iv 重写安全(核心风险) + +ScratchV IR 没有 phi 节点,也不是严格 SSA:循环值靠"同一条静态指令在运行时反复执行"携带。保证安全的四条实现约束: + +1. **绝不重定义已有 value 名**。`IRVerifier` 规则 6 对第二次赋值报 `ERROR`。展开只新增 fresh 名;`FOR.attrs` 原地改写不算定义。 +2. **末副本复用原名(轮转命名)**。前 `U−1` 个副本用 `name__u{k}`,末副本用 `name`:跨迭代链条与循环后取值同时成立,无需 phi、无需 MOV。该轮转只覆盖「未定义前向/自引用」的形状:余数循环只有单副本,`r>1` 时用 body 内前向引用或自引用(真携带值)会在第 2 次余数迭代读到陈旧值,因此 `_select_plan` 检测到该形状即记 `carried_value` 跳过(评审 F1)。 +3. **绑定值 `is_constant=False`**。避免 `InstructionSelector._op()` 把绑定常量内联成立即数,破坏 `mul`/`add` 的寄存器编码。 +4. **iv 语义与 lowering 对齐**。后端 `ENDFOR` 恒 `ADDI iv,iv,1`、忽略 `step`,因此部分展开把 `iv` 重写为组计数并改写 `FOR.start/end`,逐副本换算元素下标;绝不改 `ENDFOR`。`step != 1` 一律跳过。 + +验证手段:每个用例同时断言"结构 + 指令数 + `IRVerifier` 无 ERROR + 仿真 `a0` 相等",四重保险;负例断言 IR 逐条不变。 + +### 8.2 其他风险与对策 + +| 风险 | 影响 | 对策 | +|------|------|------| +| 代码体积膨胀 | 汇编/IR 变大 | `max_growth`、`body_limit`、`full_threshold` 三重上限;`--no-loop-unroll` 关闭 | +| 动态指令不降反升 | 负优化 | C12 盈利下限 `dynamic_saving ≥ 2`;默认不生成无用绑定;以实测断言 | +| 部分展开后重复处理 | 死循环/膨胀 | `attrs["unrolled"]=U` 幂等标记(余数循环同样写标记)+ block 迭代上限 32 | +| 展开抬高活跃值数量触发贪婪分配器「溢出后不重载」既有缺陷(F2) | 静默编错 | 默认 `loop_unroll=False`,`--loop-unroll` 显式 opt-in;测试锁定默认口径;分配器根因另立 topic 修复 | +| 余数循环 `r>1` 的携带值(F1) | 静默产出错误 IR | `_select_plan` 检测 body 内前向/自引用,记 `carried_value` 并跳过;IR 与语义保持不变 | +| 嵌套循环 codegen 缺陷(`InstructionSelector._loop_context` 为单槽) | 运行时错误 | 外层展开要求内层已完全展开;保留嵌套的循环不展开;该缺陷为既有问题,不因本 pass 恶化 | +| `IRVerifier` 在 `use_logger` 路径被调用 | 误报 | 展开输出必须无 ERROR;测试显式跑 verifier | +| pass 内部异常 | 编译中断 | 函数粒度 try/except + 指令快照回滚,记 `internal_error`,不抛 | +| 与 LICM 顺序错误 | 代码体积增大 | 管线固定 `LICM → LoopUnroll → DCE(cleanup)`;文档与注释标注依赖 | + +### 8.3 回退方案 + +- 默认即回退:`loop_unroll=False` 为默认值,`--optimize all` 不启用展开;需要时用 `--loop-unroll` 显式开启。 +- 运行时:`--no-loop-unroll` 或 `CompilerConfig(loop_unroll=False)`。 +- 数据回退:所有优化仅作用于 IR,`--optimize none/basic` 路径完全不变;`--no-loop-unroll` 与课题前输出逐字节一致(013/014/019 golden 锁定)。 + +--- + +## 九、实施步骤与提交计划 + +| 提交 | 内容 | 验证 | +|------|------|------| +| c1 | `loop_unroll.py` 骨架 + `_find_pairs` + 负例测试 | `pytest tests/test_loop_unroll.py -q`(负例绿) | +| c2 | `FULL` 展开 + 命名工厂 + 用例 1/5 | 结构/指令数/IRVerifier 断言 | +| c3 | `PARTIAL_EXACT` + 用例 2 | 动态指令数断言 | +| c4 | `PARTIAL_EPILOGUE` + 用例 3 | SSA 与语义断言 | +| c5 | `compiler.py`/`main.py` 集成 + 回归 | `make test`、CLI 自检命令 | +| c6 | 基准数据 + `docs/topics/10-循环展开优化.md` 同步 | bench JSON、文档更新 | + +每个提交遵守仓库硬性规则:验证(L2)→ self-review → `git add/commit/push`;commit message 使用英文。 + +--- + +## 十、附录 + +### 10.1 实现检查清单 + +- [ ] `run()` 返回 `loops_unrolled`,`stats` 键名与 §2.2 完全一致 +- [ ] 新值全部 fresh 且 `is_constant=False` +- [ ] 末副本复用原名;`PARTIAL_EPILOGUE` 循环后引用重定向完整 +- [ ] `step != 1`、未配对、body 含 `BR`/`LABEL`、未展开嵌套 → 跳过且 IR 不变 +- [ ] `attrs["unrolled"]` 幂等(含余数循环);block 重扫描上限生效 +- [ ] 同一循环同一跳过原因每轮 `run()` 只计一次 +- [ ] `partial_epilogue and r>1` 且 body 含前向/自引用 → `carried_value` 跳过 +- [ ] `IRVerifier` 展开输出无 `ERROR` +- [ ] CLI 七个开关(含 `--loop-unroll`)名称、默认值与 config 映射一致;默认关闭 +- [ ] `--no-loop-unroll` 与课题前输出逐字节一致(013/014/019 golden) +- [ ] 异常回滚还原指令列表、`attrs` 与 `operands` +- [ ] 性能数据以指令计数/仿真动态指令为证据写入文档 + +### 10.2 快速数值参照(与《设计文档》§5.2 一致) + +| 场景 | 动态指令(展开前 → 后) | 净变化 | +|------|--------------------------|--------| +| N=4 完全展开(iv 使用) | 21 → 12 | −9 | +| N=6 整除展开 U=3 | 31 → 27 | −4 | +| N=7 余数展开 U=6 | 36 → 30 | −6 | + +### 10.3 参考资料 + +- 《10-循环展开优化/设计文档.md》(本课题设计规范) +- `scratchv/optimizer/licm.py`、`scratchv/optimizer/dead_code.py` +- `scratchv/backend/instruction_select.py:174-223` +- `scratchv/analysis/ir_verifier.py:426-449` +- `scratchv/simulator/rv32_emulator.py:227-237` + +--- + +## 实现结果(2026-09-14 集成) + +> **2026-09-14 评审修复轮次**:默认值改为 opt-in(`loop_unroll=False` + `--loop-unroll`)、余数循环补 `unrolled` 幂等标记、`partial_epilogue and r>1` 携带值记 `carried_value` 跳过、跳过计数去重、异常回滚含 `operands`;`tests/test_loop_unroll.py` 增至 36 例,新增 013/014/019 `--no-loop-unroll` golden。详见 §八 与评审报告 F1–F6。 + +> **集成 commit**:`bc76622`(`feat(topic10): add IR loop unrolling pass with conservative heuristics`) +> **集成位置**:`Seven_big_summary` 上第 8 个 topic commit(顺序 … → 27 → **10** → 15 → …) +> **集成后全量**:`PYTHONPATH=. python3.11 -m pytest tests/ -q` → **1011 passed / 13 xfailed / 20 xpassed / 0 failed** + +### 实现文件与要点 + +| 文件 | 要点 | +|------|------| +| `scratchv/optimizer/loop_unroll.py`(632 行) | `LoopUnroll` pass:FULL / PARTIAL_EXACT / PARTIAL_EPILOGUE 三种模式 + iv 重写安全判定与启发式跳过 | +| `scratchv/optimizer/__init__.py` | pass 注册 | +| `scratchv/pass_interface.py` | stats 扩展(`loops_unrolled` 等) | +| `scratchv/compiler.py` | 优化管线接线:**LICM → unroll → DCE** | +| `scratchv/main.py` | 6 个 CLI 开关(双侧接线) | +| `tests/test_loop_unroll.py` | 23 用例 | +| `docs/topics/10-循环展开优化.md`、`docs/topics/INDEX.md` | 状态更新(⬜ → ✅) | + +### 测试数字 + +| 口径 | 结果 | +|------|------| +| 定向(`tests/test_loop_unroll.py`) | 23 用例(含于下列全量) | +| 分支全量(cherry-pick 前) | 588 passed | +| 集成后全量 | 1011 passed / 13 xfailed / 20 xpassed / 0 failed | + +动态指令数实测(`RV32Emulator`): + +| 实测场景 | 展开前 → 后 | +|----------|-------------| +| N=4 FULL | 30 → 15 | +| 实测组 2 | 36 → 28 | +| 实测组 3 | 41 → 32 | +| 实测组 4 | 51 → 42 | + +FULL / PARTIAL 的结构数与文档一致。 + +### 与本文档的偏差 / 未完成项 + +- 文档理论值(如 21 → 12、31 → 27 等)因既有编码器缺陷(R 型立即数落 x0、常量内联)不可复现;测试改为断言上述实测值。 +- `PARTIAL_EPILOGUE` 且 `r>1` 的跨迭代携带值:v1 已知边界,现由代码显式检测并记 `carried_value` 跳过(评审 F1),不再静默产出错误 IR。 +- 默认值由 `True` 调整为 `False`(评审 F2):贪婪分配器「溢出后不重载」缺陷在展开抬高压力时会静默编错,分支侧改为 opt-in。 +- CLI 静态收益受既有不安全 LICM 影响(已在文档注明,未修)。 + +### 已知限制 + +- 默认 `loop_unroll=False`;`--loop-unroll` / `loop_unroll=True` 显式开启后,高压力函数仍可能触发既有分配器缺陷(§4 B2),未在分支内修复。 +- `--no-loop-unroll` / `CompilerConfig(loop_unroll=False)` 与课题前输出逐字节一致;`--optimize none/basic` 路径不受影响。 +- 跳过条件(`step != 1`、未配对、body 含 `BR`/`LABEL`、嵌套、`carried_value`)保持保守,宁可不展开。 diff --git "a/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" new file mode 100644 index 0000000..791d20b --- /dev/null +++ "b/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -0,0 +1,526 @@ +# ScratchV IR 循环展开优化技术设计文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 涉及模块:`scratchv/optimizer/loop_unroll.py`(新建)、`scratchv/optimizer/__init__.py`、`scratchv/compiler.py`、`scratchv/main.py`、`tests/test_loop_unroll.py`(新建) +> 功能范围:IR 级 `FOR`/`ENDFOR` 循环的完全展开、整除部分展开、余数循环(epilogue)部分展开、展开因子启发式、编译器管线与 CLI 集成 + +--- + +## 一、功能介绍 + +### 1.1 功能概述 + +**循环展开(Loop Unrolling)** 是编译器经典优化:将循环体复制多次、合并循环控制指令,以代码体积换取每迭代的分支/自增开销下降。本课题为 ScratchV 新增一个通用、可配置的 **IR 级 `LoopUnroll` pass**,作用于 IR 中 `OpCode.FOR` / `OpCode.ENDFOR` 标记的循环结构,不依赖任何前端或后端。 + +ScratchV 现状与动机: + +- **无通用展开 pass**:现有优化器只有常量折叠、DCE、IR 窥孔、muladd 融合与 LICM(`tests/test_optimizer.py`、`tests/test_optimizer_advanced.py` 无 unroll 用例)。 +- **唯一的手写展开**位于 standalone 路径 `scratchv/standalone/onnx_to_riscv_standalone.py:1750`,仅覆盖 `no_pad && K==3` 的 Conv 内层 `kw/kh` 全展开(9 MAC);本课题**不修改、不替代**该路径(范围边界)。 +- **IR 循环结构**(`scratchv/ir/types.py:31-32,101-109`、`scratchv/ir/builder.py:130-137`): + - `FOR`:`dest` 为 induction variable(iv,`DataType.INT32`),`attrs = {"start": int, "end": int, "step": int}`; + - `ENDFOR`:无 `dest`、无 `operands`; + - 循环体与标记同处一个 `BasicBlock.instructions` 列表,嵌套由 `FOR`/`ENDFOR` 配对平衡表达。 +- **后端 lowering**(`scratchv/backend/instruction_select.py:174-223`): + - `FOR` → `LI iv,start; header: BGE iv,end; body:` + - `ENDFOR` → `ADDI iv,iv,1; J header; exit:` + - 即每迭代固定 3 条控制指令(`BGE` + `ADDI` + `J`),且 **lowering 忽略 `step`,`ENDFOR` 恒 `+1`**。后者是设计硬约束(见 2.4)。 + +**收益模型(可测)**:设循环体 lowering 后指令数 `B_asm`、迭代次数 `N`、展开因子 `U`、每组合并后新增的 iv 绑定指令数 `M`、循环外一次性 setup 指令数 `S`: + +``` +动态指令数(原始) ≈ 1 + N × (B_asm + 3) +动态指令数(展开后) ≈ S + 1 + (N/U) × (B_asm×U + M + 3) (整除部分展开) +节省动态指令 ≈ q × (3U − 3 − M) − S − (r>0 ? 1 : 0) +``` + +其中 `q=N/U`、`r=N%U`,末项为余数循环的一次性前导开销(仅 `PARTIAL_EPILOGUE` 且 `r>0` 时存在);`FULL` 模式退化为 `≈ 3N + 1 − N×[iv 被使用]`(`iv` 未使用时省 `3N+1` 条)。 + +当 `iv` 未被循环体使用时 `M = S = 0`,每迭代净省 3 条控制指令;当 `iv` 被使用时按 §2.2 绑定方案计算(典型 `M = U`、`S = 2`)。所有收益声明必须以 `--count-instr` 的静态计数或 `RV32Emulator.run()` 的动态计数为证据(见 §三、§5.2),不得凭直觉。 + +### 1.2 设计目标 + +- **语义等价**:展开后程序的可观察行为(返回值、内存副作用顺序)与展开前完全一致;`FOR`/`ENDFOR` 内所有指令按原顺序复制,不跨迭代重排、不做 CSE/向量化。 +- **SSA 兼容**:`IRVerifier` 规则 6 强制"每个 value 只赋值一次"(`scratchv/analysis/ir_verifier.py:426-449`,ERROR 级)。本 pass **绝不重定义任何已有 value 名**,所有新名均为 fresh;循环携带值通过"末副本复用原名"的轮转命名解决(§2.4)。 +- **保守优先**:任何前置条件不满足即跳过该循环并记录原因,pass 不中断编译、不抛异常(内部异常按函数粒度回退)。 +- **可测可量化**:静态 IR 指令数、展开后汇编静态指令数(`--count-instr`)、`RV32Emulator` 动态执行指令数(`run()` 返回执行条数)、`bench_runner` JSON 的 `instruction_count` 四类证据。 +- **零依赖、可回退**:不新增第三方依赖;默认不启用(`loop_unroll=False`),`--loop-unroll` 显式开启,`--no-loop-unroll` 一键回到课题前行为。 +- **边界清晰**:只做 IR 级 pass;不做向量化(课题 29)、不做软件流水、不改 standalone 手写展开路径。 + +--- + +## 二、设计规范 + +### 2.1 循环结构定义(BNF) + +``` +function ::= block+ +block ::= instr* +loop ::= FOR(iv,start,end,step) body ENDFOR +body ::= instr* (* 可含嵌套 loop *) +FOR ::= "$" iv "=" "for" "[start=" int "]" "[end=" int "]" "[step=" int "]" +ENDFOR ::= "endfor" +instr ::= 三地址码指令(不含 LABEL / BR / BR_IF,见 2.4 约束 C4) +``` + +| 元素 | 说明 | +|------|------| +| `iv` | 循环归纳变量,`FOR` 的 `dest`,`DataType.INT32`;运行时由 lowering 初始化为 `start` | +| `start` / `end` / `step` | 编译期整数属性;当前 IR 生成路径中 `start ≥ 0`、`end > start`、`step` 恒为 1 | +| `body` | 循环体指令序列,位于 `FOR` 与配对 `ENDFOR` 之间;可含嵌套配对 | +| `N = end − start` | 迭代次数(trip count,仅 `step == 1` 时使用本式) | +| `q = N // U`,`r = N % U` | 展开后的组数、余数迭代数 | + +### 2.2 展开规则(BNF + 伪代码) + +#### 2.2.1 展开模式 + +``` +mode ::= FULL (* 完全展开:U = N,删除 FOR/ENDFOR *) + | PARTIAL_EXACT (* 整除部分展开:U | N,保留 FOR/ENDFOR *) + | PARTIAL_EPILOGUE (* 余数部分展开:主展开循环 + 余数循环 *) +``` + +#### 2.2.2 展开计划选择(伪代码) + +``` +select_plan(L): + if L.attrs 不是三个 Python int: return SKIP("bad_attrs") + if L.attrs.step != 1: return SKIP("step_not_one") + N = L.attrs.end - L.attrs.start + if N < 2: return SKIP("trip_lt_2") + if "unrolled" in L.attrs: return SKIP("already_unrolled") + if body 含 LABEL / BR / BR_IF: return SKIP("body_has_branches") + if body 含未展开的嵌套 FOR/ENDFOR: return SKIP("nested_loop") + if len(body) > body_limit: return SKIP("body_too_large") + if body 中任一 name 的静态定义数 > 1: return SKIP("multi_def") + if body 内存在 dest.name == iv.name: return SKIP("multi_def") + + if N <= full_threshold: plan = FULL(U=N) + else: + cands = {u | 2 <= u <= min(max_factor, N-1), N % u == 0} + if cands: plan = PARTIAL_EXACT(U=max(cands)) + elif epilogue and min(max_factor, N-1) >= 2: + plan = PARTIAL_EPILOGUE(U=min(max_factor, N-1)) + else: return SKIP("no_factor") + + # F1:余数循环只有单副本,r>1 时 body 内前向/自引用(真携带值) + # 会在第 2 次迭代读到陈旧值;检测到该形状必须拒绝执行 + if plan.mode == PARTIAL_EPILOGUE and N % plan.U > 1 \ + and has_forward_ref(body): return SKIP("carried_value") + + if plan.estimated_added > max_growth: return SKIP("growth_limit") + if plan.partial and plan.dynamic_saving < 2: return SKIP("unprofitable") + return plan +``` + +其中 `has_forward_ref(body)` 指 body 内某条指令的操作数名在 body 的定义集合中、但在该指令之前(含自身)尚未被定义;它同时覆盖前向引用与自引用(`dest == operand`)。 + +因子选择优先级:**完全展开 > 最大整除因子 > 余数循环**;候选因子从大到小逐个试算,选第一个既满足 `max_growth` 又满足盈利条件的。 + +#### 2.2.3 循环体复制与 iv 重写(伪代码) + +``` +unroll(L, U, mode): + region = instrs[FOR_idx+1 : ENDFOR_idx] + body_defs = { i.dest.name | i ∈ region, i.dest ≠ None } + iv_used_in_body = ∃ i ∈ region, op ∈ i.operands: op.name == iv.name + iv_used_after = ∃ i ∉ region: op.name == iv.name + + # ① setup(部分展开必须改写循环边界;常量 setup 仅在 iv 被使用时生成) + if mode ∈ {PARTIAL_EXACT, PARTIAL_EPILOGUE}: + L.attrs = {start: 0, end: q, step: 1} # 必须:iv 语义重写为“组计数器”,否则循环执行 N 次 + if iv_used_in_body: + u_tmp = LOAD_CONST(U); one_tmp = LOAD_CONST(1) + if start != 0: start_tmp = LOAD_CONST(start) + + # ② 逐副本复制 + for k in 0 .. U-1: + last = (k == U-1) + if iv_used_in_body: + if mode == FULL: bind_k = LOAD_CONST(start + k) + elif k == 0 and start == 0: bind_k = MUL(iv, u_tmp) + elif k == 0: bind_k = ADD(MUL(iv, u_tmp), start_tmp) + else: bind_k = ADD(bind_{k-1}, one_tmp) + cur = {} + for i in region: + ops = [ remap(op) for op in i.operands ] # iv→bind_k;d∈body_defs→cur.get(d,d) + if i.dest and i.dest.name ∈ body_defs: + name = last ? i.dest.name : fresh(f"{i.dest.name}__u{k}") + cur[i.dest.name] = name + emit clone(i, dest=name, operands=ops) + + # ③ 余数循环(PARTIAL_EPILOGUE,r > 0) + if mode == PARTIAL_EPILOGUE: + iv_ep = fresh_iv(); FOR(iv_ep, start + q*U, end, 1) + 单副本 region(全 fresh 名)+ ENDFOR + cur_after = epilogue 的 cur 映射 + + # ④ 循环后值修正 + if iv_used_after: iv_final = LOAD_CONST(start + N),重定向循环后 iv 引用 + if mode == PARTIAL_EPILOGUE: 循环后 body_defs 引用 d → cur_after[d] +``` + +**副本命名轮转(关键设计)**:`body` 内定义的每个 name 在展开后所有副本中合计仍只出现一次原名——前 `U−1` 个副本用 `name__u{k}`,**最后一个副本复用原名** `name`。由此: + +- 满足 `IRVerifier` 的单次赋值约束; +- 跨迭代的循环携带值(如累加器)天然沿副本顺序链式传递:副本 k 中"定义前"的引用解析到上一副本的同名值,副本 k+1 看到副本 k 的结果; +- 循环结束后读取原名即读到末副本的结果,无需插入 phi 或额外 MOV。 + +**iv 步进重写**:lowering 将 `ENDFOR` 固定降为 `ADDI iv,iv,1`,且忽略 `step`。因此 pass **不改写 `ENDFOR`、不插入 iv 自增指令**,而是把 `iv` 语义重写为"组计数器"(`FOR(0,q)`),并在每个副本前用绑定指令把组计数换算为元素下标 `i_k = start + k + U×group`(`FULL` 模式直接物化常量 `start+k`)。这是无 SSA/phi 条件下最安全的 iv 重写方式。 + +### 2.3 元素说明 + +| 符号/名称 | 说明 | +|-----------|------| +| `L` | 一个已配对的 `FOR…ENDFOR` 循环 | +| `N` | trip count,`end − start`(`step==1`) | +| `U` | 展开因子(副本数);`FULL` 时 `U=N` | +| `B` | `body` 的 IR 指令条数(含嵌套标记,用于阈值与增长估算) | +| `D` | `body` 中静态定义的 name 集合 | +| `q,r` | `q=N//U`、`r=N%U` | +| `M` | 部分展开时每个组内为 iv 绑定新增的指令数:`U + (start≠0 ? 1 : 0)` | +| `S` | 循环外一次性 setup 指令数:`2 + (start≠0 ? 1 : 0)`(仅 iv 被使用时) | +| `body_limit` | 参与展开的 body 最大 IR 指令数,默认 64 | +| `full_threshold` | 允许完全展开的最大 trip count,默认 8 | +| `max_factor` | 部分展开的最大因子,默认 8 | +| `max_growth` | 单个循环允许新增的最大 IR 指令数,默认 512 | +| `epilogue` | 是否允许余数循环,默认 `False` | +| `unrolled` | 部分展开后写在 `FOR.attrs` 上的幂等标记,主循环值为 `U`,余数循环值为 `1` | + +### 2.4 约束规则 + +- **C1 配对**:`FOR`/`ENDFOR` 必须在同一 `BasicBlock.instructions` 内且嵌套平衡;扫描用栈实现(§4.3),`ENDFOR` 无栈可弹或结束时栈非空 → 记 `unpaired` 警告并跳过该循环/整函数。 +- **C2 静态 trip count**:`start/end/step` 必须是 Python `int` 且 `step == 1`;动态(非整数)trip count 不支持。 +- **C3 单次静态定义**:`body` 内每个 name 至多一次静态定义(前端保证);出现多次 → 跳过(`multi_def`)。 +- **C4 控制流屏障**:`body` 含 `LABEL`/`BR`/`BR_IF` 时 v1 一律跳过(复制分支目标需要标签重命名,超出本课题范围);含**未展开**嵌套 `FOR`/`ENDFOR` 时外层跳过(避免跨组携带值无法表达)。 +- **C5 iv 不可被重定义**:`body` 内不得有 `dest.name == iv.name` 的指令(与 C3 共用 `multi_def` 跳过原因);否则 iv 的组计数语义会被破坏。 +- **C6 SSA 安全**:pass 不修改任何已存在 name 的定义;新增 name 用函数级唯一化工厂 `fresh(func, base)` 生成;`FOR.attrs` 原地改写不产生新定义。 +- **C7 循环后值保持**:`FULL`/`PARTIAL_EXACT` 由末副本原名保证;`PARTIAL_EPILOGUE` 将循环后 `body_defs` 引用重定向到余数循环的 fresh 名;循环后 `iv` 引用统一重定向到 `iv_final = LOAD_CONST(start+N)`。余数循环体只有单副本,`r>1` 且 body 含前向/自引用时该重定向无法表达跨迭代携带,按 C14 拒绝。 +- **C8 与 LICM 的交互**:管线顺序固定为 `LICM → LoopUnroll → DCE(cleanup)`。LICM 先把循环不变量提到循环外,展开只复制仍然循环相关的指令;v1 不自带 CSE(避免与轮转命名/SSA 冲突),因此**先跑 LICM 是推荐前置条件**,单独运行 pass 仅会导致代码体积略大,不影响正确性。 +- **C9 与 DCE 的交互**:展开不产生无用定义(`iv` 未被使用时不生成绑定);DCE 保留 `FOR`/`ENDFOR` 与 `STORE`/`RETURN` 等副作用指令(`scratchv/optimizer/dead_code.py:59-70`),可在展开后安全复跑清理。 +- **C10 幂等**:部分展开的 `FOR` 写入 `attrs["unrolled"]=U`,余数循环同样写入 `attrs["unrolled"]` 标记,`run()` 重复调用不会二次展开;完全展开直接删除标记。同一循环的同一跳过原因在一轮 `run()` 内只统计一次(`_process_function` 逐循环重扫)。 +- **C11 增长上限**:`estimated_added = (U−1)×B + M + S + (epilogue ? B+2 : 0) + 1 ≤ max_growth`,否则跳过。 +- **C12 盈利下限**:部分展开要求 `dynamic_saving = q×(3U−3−M) − S − (mode==PARTIAL_EPILOGUE ∧ r>0 ? 1 : 0) ≥ 2`(指令数);不满足则跳过,避免"展开后动态指令反而变多"。 +- **C13 范围**:不做向量化、不做指令调度、不做寄存器分配、不做函数内联;不修改 `scratchv/standalone/onnx_to_riscv_standalone.py`。 +- **C14 携带值安全边界**:`PARTIAL_EPILOGUE` 且 `r>1` 时,若 body 内存在前向引用或自引用(操作数 ∈ body 定义集合且此前未定义,含 `dest == operand`),记 `carried_value` 跳过,IR 逐字节不变;`FULL`/`PARTIAL_EXACT` 由轮转命名覆盖该形状,不受此限。 +- **C15 默认关闭**:`CompilerConfig.loop_unroll` 默认 `False`,仅 `--loop-unroll`(或显式配置)才在 `optimize_level == "all"` 运行;原因见开发文档 §8.2(贪婪分配器既有缺陷,分支侧 opt-in 规避)。 + +### 2.5 合法示例 + +**示例 A:完全展开(N=4,body 使用 iv,循环后使用累加值)** + +输入 IR 伪代码: +``` +fun $main(params: [$acc: i32]) + .entry: + $v_1 = for [start=0] [end=4] [step=1] + $v_2 = add $v_1 $one + $v_3 = add $acc $v_2 + endfor + return $v_3 +``` +展开后(12 条 IR,无 `for`/`endfor`;末副本复用 `$v_1/$v_2/$v_3`): +``` + $v_1__u0 = load_const [value=0] + $v_2__u0 = add $v_1__u0 $one + $v_3__u0 = add $acc $v_2__u0 + ... # k=1,2 同理 + $v_1 = load_const [value=3] + $v_2 = add $v_1 $one + $v_3 = add $acc $v_2 + return $v_3 +``` + +**示例 B:整除部分展开(N=6,U=3,q=2,start=0)** + +同体循环,`full_threshold=2` 时选 `PARTIAL_EXACT(U=3)`:`FOR` 改写为 `[start=0][end=2]`(组计数),setup 两条常量,组内绑定 `i_0 = MUL(iv,u_tmp)`、`i_1 = ADD(i_0,one_tmp)`、`i_2 = ADD(i_1,one_tmp)`,末副本复用原名,保留 `FOR`/`ENDFOR` 与 `unrolled=3` 标记。 + +**示例 C:余数部分展开(N=7,U=6,q=1,r=1,start=0,epilogue=True)** + +主循环 `FOR(0,1)`、6 个副本(末副本原名);其后余数循环 `FOR($iv_ep,[start=6][end=7][step=1])` 单副本、全 fresh 名;循环后 `$v_3` 引用重定向到 `$v_3__ep`。 + +### 2.6 非法示例(不可展开场景) + +| 场景 | 输入特征 | 处理 | +|------|----------|------| +| 动态 trip count | `attrs` 缺失或 `end` 不是 Python int(如指向某个 value) | 跳过,记 `bad_attrs` 警告 | +| 非单位步长 | `[step=2]` | 跳过,记 `step_not_one`(lowering 恒 +1,语义歧义) | +| `ENDFOR` 不配对 | `for … for … endfor`(缺一个 `endfor`)或裸 `endfor` | 栈扫描发现,记 `unpaired`,跳过且不修改 IR | +| body 过大 | `len(body)=100 > body_limit=64` | 跳过,记 `body_too_large` | +| body 含分支/标签 | 循环体内含 `while` 降级出的 `br_if`/`label` | 跳过,记 `body_has_branches` | +| 含未展开嵌套 | 内层因阈值被跳过,外层仍尝试展开 | 跳过外层,记 `nested_loop` | +| iv 被重定义 | body 内出现 `dest.name == iv.name` | 跳过,记 `multi_def` | +| 增长超限 | `estimated_added > max_growth` | 跳过,记 `growth_limit` | +| 无盈利因子 | `N=5`、`full_threshold=2`、`epilogue=False`,无 2..4 的整除因子 | 跳过,记 `no_factor` | +| 余数循环携带值 | `epilogue=True`、`r>1`、body 含前向/自引用(如 `acc = add(acc, t)`) | 跳过,记 `carried_value`,IR 与语义保持不变 | +| 重复处理 | 同一循环同一原因被重扫 | 每轮 `run()` 只计一次,不放大 `skipped` 计数 | + +--- + +## 三、测试设计 + +测试文件:`tests/test_loop_unroll.py`(新建),沿用 pytest 风格与 `IRBuilder` 构造 IR;指令数用测试内计数器 `count_ir(program)` 与 `len(InstructionSelector(program).run())`,动态条数用 `assemble_to_binary` + `RV32Emulator().run()`。所有"预期指令数"均为**断言值**;下述数字按本设计推导,实现细节若调整须同步更新断言。 + +### 测试用例 1:完全展开 + iv 正确性 + 语义等价 + +**输入 IR 伪代码**(`$acc` 声明为函数参数,body 使用 iv): +``` +fun $main(params: [$acc: i32]) + .entry: + $one = const 1 + $v_1 = for [start=0] [end=4] [step=1] + $v_2 = add $v_1 $one + $v_3 = add $acc $v_2 + endfor + return $v_3 +``` + +**预期展开后结构**:无 `FOR`/`ENDFOR`;12 条 IR;`$v_1__u{k}` 的绑定分别为 `load_const 0/1/2/3`;末副本定义原名 `$v_1/$v_2/$v_3`;`return $v_3` 不变。 + +**验证点**: +1. 指令数:IR `4 → 12`;汇编静态 `18 → 12`;`RV32Emulator` 动态 `21 → 12`(均为断言)。 +2. iv 正确性:断言 4 条 `load_const` 的 `value` 依次为 `0,1,2,3` 且 dtype 为 `INT32`、`is_constant=False`(防后端将其当立即数内联)。 +3. 语义等价:展开前后 `a0` 相同(本用例为 `4`);`IRVerifier(program).verify()` 无 `ERROR`。 +4. 幂等:再次 `run()` 返回 0,IR 不变。 + +### 测试用例 2:整除部分展开(N=6 → U=3) + +**输入 IR 伪代码**:同用例 1,`[end=6]`;构造 `LoopUnroll(program, full_threshold=2)`。 + +**预期展开后结构**:`FOR` 改写为 `[start=0][end=2][step=1]` 且 `attrs["unrolled"]=3`;setup 两条 `load_const`(`u_tmp=3`、`one_tmp=1`);组内绑定 3 条(`MUL` + 2×`ADD`);`body` 共 9 条;总计 13 条 IR(含 `FOR`/`ENDFOR`);末副本复用 `$v_2/$v_3`。 + +**验证点**: +1. 结构:`run()` 返回 1;`stats["partial_unrolls"]==1`;`FOR.attrs["end"]==2`。 +2. 指令数:IR `4 → 13`;汇编静态 `26 → 26`;动态 `31 → 27`(每迭代净省 ≈0.67 条控制指令,一次性 setup 由 2 个组摊薄)。 +3. iv 正确性:`i_0` 由 `MUL(iv,u_tmp)` 定义、`i_1/i_2` 由前驱 `ADD` 链定义;无 `ADD` 直接使用 `iv` 作为元素下标。 +4. 语义等价:动态执行结果与展开前一致。 + +### 测试用例 3:余数部分展开(N=7 → U=6, q=1, r=1, epilogue=True) + +**输入 IR 伪代码**:同用例 1,`[end=7]`;构造 `LoopUnroll(program, full_threshold=2, epilogue=True)`。 + +**预期展开后结构**:主循环 `FOR(0,1)` 6 副本;余数循环 `FOR($iv_ep,[start=6][end=7][step=1])` 1 副本且 body 全 fresh 名(`$v_2__ep`、`$v_3__ep`);循环后 `return` 引用重定向为 `$v_3__ep`;共 26 条 IR(主循环 22 + 余数循环 4)。 + +**验证点**: +1. 结构:`stats["partial_epilogues"]==1`;存在两个 `FOR`;余数循环 `start/end` 正确。 +2. SSA:`IRVerifier` 无 `ERROR`(验证余数循环 fresh 名与主循环原名不冲突)。 +3. 指令数:动态 `36 → 30`;语义等价(`a0` 相同)。 +4. `r == 0` 时(N=6、整除因子 U=3、`epilogue=True`)不产生余数循环(只有一个 `FOR`,`partial_epilogues==0`)。 + +### 测试用例 4:不可展开场景(负例集) + +**输入**:4 组 IR——(a) `[step=2]`;(b) `for…for…endfor` 不平衡;(c) `body` 100 条指令(`body_limit=64`);(d) body 内 `$v_1 = add $v_1 $one`(iv 被重定义)。 + +**预期**:4 组 IR 均**逐字节不变**;`run()==0`;`stats["skipped"]` 对应键计数为 1。 + +**验证点**:跳过原因为 `step_not_one` / `unpaired` / `body_too_large` / `multi_def`;无异常抛出;原 IR 对象引用与内容不变。 + +### 测试用例 5:嵌套循环由内到外 + +**输入 IR 伪代码**(外层 N=2、内层 N=3,均不依赖各自 iv): +``` +$i = for [start=0] [end=2] [step=1] + $j = for [start=0] [end=3] [step=1] + $t = add $x $y + $acc2 = add $acc $t + endfor +endfor +``` + +**预期**:先完全展开内层(3 副本,`$j` 标记消失),再展开外层(2 副本);最终无任何 `FOR`/`ENDFOR`;body 共 12 条。 + +**验证点**:`stats["full_unrolls"]==2`;处理顺序(通过 debug 钩子或分步调用验证内层先于外层);`IRVerifier` 无 `ERROR`。 + +### 回归测试 + +- `python -m pytest tests/test_optimizer.py tests/test_optimizer_advanced.py tests/test_ir.py tests/test_ir_verifier.py -q` +- `make test`(全量)。 +- CLI:`python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --loop-unroll --dump-ir` 应无 `for`/`endfor`;不加 `--loop-unroll`(或显式加 `--no-loop-unroll`)后应与课题前输出一致(逐字节比较汇编)。 + +--- + +## 四、修改模块与实现步骤 + +### 4.1 涉及文件 + +| 文件 | 修改类型 | 内容 | +|------|----------|------| +| `scratchv/optimizer/loop_unroll.py` | **新建** | `LoopUnroll` pass 主体:配对扫描、计划选择、复制重命名、iv 绑定、统计与警告 | +| `scratchv/optimizer/__init__.py` | 修改 | 导出 `LoopUnroll` 并加入 `__all__` | +| `scratchv/pass_interface.py` | 修改(可选,向后兼容) | `PassResult` 增加 `stats: dict = {}` 字段,供 pass 统计上抛 | +| `scratchv/compiler.py` | 修改 | `CompilerConfig` 增加展开配置;`all` 管线在 `licm` 后注册 `loop-unroll` 与清理 DCE;汇总统计 | +| `scratchv/main.py` | 修改 | 新增 `--loop-unroll` / `--no-loop-unroll`、`--unroll-*` 开关并映射到 config | +| `tests/test_loop_unroll.py` | **新建** | §三的 5 组用例与回归断言 | +| `benchmarks/bench_runner.py` 或新增对比脚本 | 修改(可选) | 输出开启/关闭展开的 `instruction_count` 对比(已有字段,无需新逻辑) | +| `docs/topics/10-循环展开优化.md` | 修改 | 同步背景、任务、交付产物与实测数据 | + +> 注:实际路径以仓库为准;本课题不改动 `scratchv/standalone/**`。 + +### 4.2 分步实现计划 + +| 步骤 | 任务 | 产出 | 验证 | +|------|------|------|------| +| 1 | 用 `IRBuilder` 构造 §三 用例 1 的 IR,先写失败测试(结构 + 指令数 + 语义) | 可复现的基线 | `pytest tests/test_loop_unroll.py -q` 先红 | +| 2 | 实现 `_find_pairs`(栈扫描)+ `_select_plan`(§2.2.2) | 计划数据结构 `UnrollPlan` | 负例用例 4 通过 | +| 3 | 实现 `_copy_region` 轮转重命名与 `FULL` 绑定 | 用例 1 通过 | IRVerifier 无 ERROR | +| 4 | 实现 `PARTIAL_EXACT`(规范化 FOR + 组内绑定链) | 用例 2 通过 | 动态指令断言 | +| 5 | 实现 `PARTIAL_EPILOGUE`(余数循环 + 循环后重定向) | 用例 3 通过 | SSA 与语义断言 | +| 6 | 嵌套由内到外 + `unrolled` 幂等 + 异常回退 | 用例 5 通过 | 幂等测试 | +| 7 | `compiler.py` / `main.py` 集成与统计 | CLI 开关生效 | `--dump-ir` 对比 | +| 8 | 基准证据:013/014/019 开关对比,记录 JSON/`--count-instr` | 数据表 | `bench_runner` 输出 | + +### 4.3 配对扫描与嵌套顺序 + +``` +_find_pairs(instrs) -> [(for_idx, endfor_idx), ...] # 按 ENDFOR 出现顺序,天然内层在前 + stack = [] + pairs = [] + for i, ins in enumerate(instrs): + if ins.opcode == FOR: stack.append(i) + elif ins.opcode == ENDFOR: + if not stack: warn("unpaired endfor"); continue + pairs.append((stack.pop(), i)) + if stack: warn("unpaired for") + return pairs +``` + +`run()` 对每个 block 反复执行"取最内层可展开循环 → 变换 → 重新扫描",直到本轮无变换或达到 `max_iterations=32`;由于内层先处理,外层处理时若 body 仍含嵌套标记则按 C4 跳过。 + +### 4.4 展开计划与因子选择 + +实现 §2.2.2 的 `select_plan`:完全展开优先;否则枚举 `[2, min(max_factor, N−1)]` 内的整除因子取最大;无整除因子且 `epilogue` 时取 `min(max_factor, N−1)`;用 §2.3 公式估算 `estimated_added` 与 `dynamic_saving`,不达标则记 `skipped` 并保持 IR 不变。计划结构建议: + +```python +@dataclass +class UnrollPlan: + mode: str # "full" | "partial_exact" | "partial_epilogue" + U: int + q: int + r: int + estimated_added: int + dynamic_saving: int +``` + +### 4.5 IR 重写与命名 + +- `fresh(func, base)`:收集函数内所有 `dest`/`operands`/`params`/`locals` 名称,生成不与任何现有名冲突的 `base` 或 `base_{n}`。 +- 复制时对 `Instruction` 做浅拷贝,`attrs` 复制为新 dict,`target` 原样复制(v1 body 不含分支,`target` 仅可能来自非控制流属性)。 +- 绑定值一律为非 `is_constant` 的 `Value`(dtype 继承 iv 的 `INT32`),确保 `InstructionSelector._op()` 走 vreg 而非立即数路径,避免 `mul rd, rs, imm` 类非法编码。 +- `FOR.attrs` 原地更新(`start/end/step/unrolled`),不产生新定义、不影响 SSA。 + +### 4.6 集成与回归 + +- 管线(`optimize_level == "all"`):`constant-folding → dead-code-elim → ir-peephole → muladd-fusion → licm → loop-unroll → dead-code-elim(cleanup)`;`basic`/`none` 不运行。 +- 统计:`LoopUnroll.stats` 经 `PassResult.stats` 汇总进 `CompileResult.stats["passes"]`,`opt_message` 形如 `loop-unroll: 3 loop(s) (2 full, 1 partial, 0 epilogue), IR 40 → 96`。 +- 回归:全量 `make test` + `python .claude/harness/verify/run.py --level L2`;`--no-loop-unroll` 输出与基线逐字节一致。 + +--- + +## 五、附录 + +### 5.1 展开前后 IR dump 示例 + +**输入(用例 1)**: +``` +fun $main( + params: $acc: i32 + .entry: + $v_1 = for [start=0] [end=4] [step=1] + $v_2 = add $v_1 $one + $v_3 = add $acc $v_2 + endfor + return $v_3 +``` + +**完全展开(§2.5 示例 A,U=4)**: +``` +fun $main( + params: $acc: i32 + .entry: + $v_1__u0 = load_const [value=0] + $v_2__u0 = add $v_1__u0 $one + $v_3__u0 = add $acc $v_2__u0 + $v_1__u1 = load_const [value=1] + $v_2__u1 = add $v_1__u1 $one + $v_3__u1 = add $acc $v_2__u1 + $v_1__u2 = load_const [value=2] + $v_2__u2 = add $v_1__u2 $one + $v_3__u2 = add $acc $v_2__u2 + $v_1 = load_const [value=3] + $v_2 = add $v_1 $one + $v_3 = add $acc $v_2 + return $v_3 +``` + +**整除部分展开(N=6, U=3, q=2, 末副本复用原名)**: +``` +fun $main( + params: $acc: i32 + .entry: + $c_u = load_const [value=3] + $c_one = load_const [value=1] + $v_1 = for [start=0] [end=2] [step=1] [unrolled=3] + $i__u0 = mul $v_1 $c_u + $v_2__u0 = add $i__u0 $one + $v_3__u0 = add $acc $v_2__u0 + $i__u1 = add $i__u0 $c_one + $v_2__u1 = add $i__u1 $one + $v_3__u1 = add $acc $v_2__u1 + $i__u2 = add $i__u1 $c_one + $v_2 = add $i__u2 $one + $v_3 = add $acc $v_2 + endfor + return $v_3 +``` + +**余数部分展开(N=7, U=6, q=1, r=1)**: +``` + $c_u = load_const [value=6] + $c_one = load_const [value=1] + $v_1 = for [start=0] [end=1] [step=1] [unrolled=6] + $i__u0 = mul $v_1 $c_u + ... # 副本 1..4 + $i__u5 = add $i__u4 $c_one + $v_2 = add $i__u5 $one # 末副本复用原名 + $v_3 = add $acc $v_2 + endfor + $v_1_ep = for [start=6] [end=7] [step=1] + $v_2__ep = add $v_1_ep $one + $v_3__ep = add $acc $v_2__ep + endfor + return $v_3__ep +``` + +### 5.2 指令数对照(按 lowering 规则推导,作为断言基准) + +| 用例 | IR 指令 | 汇编静态 | 动态执行 | +|------|---------|----------|----------| +| 原循环 N=4(用例 1) | 4 | 18 | 21 | +| 完全展开 U=4 | 12 | 12 | 12 | +| 原循环 N=6(用例 2) | 4 | 26 | 31 | +| 整除展开 U=3 | 13 | 26 | 27 | +| 原循环 N=7(用例 3) | 4 | 30 | 36 | +| 余数展开 U=6 | 26 | 30 | 30 | + +> 假设:body 为 2 条 `ADD`、`iv` 被使用、无寄存器溢出、小立即数 `LI` 单指令展开;不含 `return` 的 `MV/JALR`(两侧相同)。 + +**测量命令**: +```bash +# 静态汇编指令数(开关对比;展开为 opt-in,必须显式 --loop-unroll) +python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --loop-unroll --count-instr -o /tmp/on.s +python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --no-loop-unroll --count-instr -o /tmp/off.s + +# 动态指令数(bench_runner 的 instruction_count 字段) +# 注:bench_runner 沿用默认 config(loop_unroll=False),两边均不含展开; +# 需要展开数据时请在脚本内构造 CompilerConfig(loop_unroll=True) 后对比。 +python benchmarks/bench_runner.py benchmarks/cases --output-json /tmp/bench_off.json +``` + +### 5.3 参考资料 + +- 龙书《编译原理》第 9 章/第 10 章:循环优化、归纳变量与展开 +- `scratchv/optimizer/licm.py`:IR pass 风格与 FOR/ENDFOR 配对扫描参考 +- `scratchv/backend/instruction_select.py:174-223`:FOR/ENDFOR lowering 语义 +- `scratchv/analysis/ir_verifier.py:426-449`:SSA 单次赋值规则 +- `scratchv/simulator/rv32_emulator.py:227-237`:动态指令计数(`run()` 返回值) +- 相关课题:课题 19(Standalone RISC-V 编译器)、课题 29(向量化,非本课题范围) diff --git "a/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226.md" "b/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226.md" index 0a74e2e..460f707 100644 --- "a/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226.md" +++ "b/docs/topics/10-\345\276\252\347\216\257\345\261\225\345\274\200\344\274\230\345\214\226.md" @@ -1,59 +1,170 @@ # 课题10:循环展开优化 -> **难度**:中 | **类型**:项目实战 | **源文件**:待创建 -> **状态**:⬜ 规划中 +> **难度**:中 | **类型**:项目实战 | **源文件**:`scratchv/optimizer/loop_unroll.py` | **行数**:~630 +> **状态**:✅ 已完成 --- ## 概述 -循环展开(Loop Unrolling)是编译器优化的经典技术——将循环体复制多次,减少循环控制开销(分支/跳转指令)。对 ScratchV 而言,Conv2D 内层循环占 >99% 动态指令数,展开最内层 `ic` 循环可以显著减少 `bne`/`addi` 指令的比例,有望进一步缩小与 LLVM 的差距。 +循环展开(Loop Unrolling)是编译器经典优化:将循环体复制多次、合并循环控制指令,以代码体积换取每迭代的分支/自增开销下降。本课题为 ScratchV 新增 IR 级 `LoopUnroll` pass,作用于 `OpCode.FOR` / `OpCode.ENDFOR` 标记的循环: + +- **FULL**:`N <= full_threshold` 时完全展开并删除循环标记; +- **PARTIAL_EXACT**:`N` 有 `2..max_factor` 内的整除因子时按最大因子 `U` 部分展开; +- **PARTIAL_EPILOGUE**:无整除因子且开启 `--unroll-epilogue` 时,主展开循环 + 余数循环。 + +> 展开为 **opt-in**:默认 `loop_unroll=False`,需 `--optimize all --loop-unroll` 才启用(评审 F2;贪婪分配器既有缺陷见开发文档 §8.2)。 --- ## 理解背景 -待补充。 +### 是什么? + +假设有一段 IR 循环: + +``` +$one = load_const [value=1] +$iv = for [start=0] [end=4] [step=1] + $v2 = add $iv $one + $v3 = add $acc $v2 +endfor +return $v3 +``` + +后端 lowering 每迭代固定产生 3 条控制指令(`BGE` + `ADDI` + `J`)。完全展开后: + +``` +$one = load_const [value=1] +$iv__u0 = load_const [value=0] # iv 绑定(元素下标物化) +$v2__u0 = add $iv__u0 $one +$v3__u0 = add $acc $v2__u0 +... # k=1,2 同理 +$iv = load_const [value=3] # 末副本复用原名 +$v2 = add $iv $one +$v3 = add $acc $v2 +return $v3 +``` + +循环控制指令全部消失,代价是 IR/汇编体积变大。 + +### 为什么难? + +ScratchV IR **没有 phi 节点,也不是严格 SSA**:循环值靠"同一条静态指令在运行时反复执行"携带。展开时必须同时解决三个问题: + +1. **SSA 单次赋值**:`IRVerifier` 规则 6 禁止一个 name 被静态定义两次。展开只新增 fresh 名,**末副本复用原名**(轮转命名),跨迭代链与循环后取值同时成立。 +2. **iv 语义重写**:后端 `ENDFOR` 恒降低为 `ADDI iv, iv, 1` 且忽略 `step`。部分展开把 `iv` 重写为"组计数器"(`FOR(0,q)`),每个副本前用绑定指令换算元素下标 `start + k + U×group`;绝不改写 `ENDFOR`。 +3. **常量内联**:绑定值必须是 `is_constant=False`,否则指令选择器会把它们当立即数内联,破坏 `mul`/`add` 的 R 型编码。 + +### 核心设计 + +| 设计点 | 做法 | +|--------|------| +| 配对扫描 | 栈扫描 `FOR`/`ENDFOR`,按 `ENDFOR` 出现顺序返回 = 内层在前;不平衡则整块跳过 | +| 因子选择 | 完全展开优先;否则从大到小枚举整除因子;再否则余数循环 | +| 增长上限 | `estimated_added <= max_growth`,否则跳过 | +| 盈利下限 | 部分展开要求 `dynamic_saving >= 2`,避免负优化 | +| 幂等 | 部分展开写 `FOR.attrs["unrolled"]=U`(余数循环写 `unrolled` 标记);完全展开直接删除标记 | +| 异常回退 | 函数粒度快照 + 回滚,记 `skipped["internal_error"]` | --- ## 详细任务 -待补充。 +1. 新建 `scratchv/optimizer/loop_unroll.py`,实现 `LoopUnroll`(`run()` 返回展开循环数,`stats` 固定 schema)。 +2. 实现 `_find_pairs`(栈配对)、`_select_plan`(计划选择)、`_copy_region`(轮转复制)、`_fresh_name`(函数级唯一命名)。 +3. 实现 FULL / PARTIAL_EXACT / PARTIAL_EPILOGUE 三种模式与循环后引用重定向。 +4. 接入 `CompilerConfig`(6 个字段)与 `--optimize all` 管线:`... → licm → loop-unroll → dead-code-elim(cleanup)`。 +5. `main.py` 新增 7 个 CLI 开关(`--loop-unroll` / `--no-loop-unroll`、`--unroll-factor` 等)。 +6. 新建 `tests/test_loop_unroll.py`,覆盖 5 组用例与集成自检。 --- ## 交付产物 -待补充。 +| 交付物 | 路径 | +|--------|------| +| pass 实现 | `scratchv/optimizer/loop_unroll.py` | +| 导出 | `scratchv/optimizer/__init__.py` | +| 管线与 CLI | `scratchv/compiler.py`、`scratchv/main.py`、`scratchv/pass_interface.py` | +| 测试 | `tests/test_loop_unroll.py`(36 例) | +| 文档 | 本文件 | --- ## 代码走读 -待补充。 +### 接口 + +```python +class LoopUnroll: + def __init__(self, program, max_factor=8, full_threshold=8, + body_limit=64, max_growth=512, epilogue=False): ... + def run(self) -> int: ... # 被展开的循环个数 + @property + def stats(self) -> dict: ... # loops_seen / full_unrolls / skipped{...} 等 +``` + +### 统计 schema + +`loops_seen`、`loops_unrolled`、`full_unrolls`、`partial_unrolls`、`partial_epilogues`、`instructions_before/after/added`,以及 `skipped` 下 14 个固定原因键(`bad_attrs`、`step_not_one`、`body_too_large`、`multi_def`、`carried_value`、`no_factor`、`growth_limit`、`unprofitable`、`unpaired` 等)。 + +### CLI + +```bash +python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --loop-unroll --count-instr -o /tmp/on.s +python -m scratchv benchmarks/cases/014_for_dot.dsl --optimize all --no-loop-unroll --count-instr -o /tmp/off.s +``` --- -## 动手练习 +## 实测数据 + +### 单元用例(`RV32Emulator` 动态指令数) + +| 场景 | 展开前 | 展开后 | `a0` 语义 | +|------|-------:|-------:|:--------:| +| N=4 完全展开(iv 被使用) | 30 | 15 | 4 = 4 | +| N=6 整除展开 U=3 | 36 | 28 | 6 = 6 | +| N=7 余数展开 U=6(`--unroll-epilogue`) | 41 | 32 | 7 = 7 | + +### CLI 静态指令数(`--count-instr`,013/014/019) + +| 用例 | `--loop-unroll` 开启展开 | `--no-loop-unroll` | +|------|---------:|-------------------:| +| `013_for_sum` | 3 | 7 | +| `014_for_dot` | 3 | 7 | +| `019_nested_loop` | 3 | 11 | -待补充。 +> 注:013/014/019 的循环体被 LICM 提到循环外后成为空循环,展开收益主要体现为循环标记(`LI/BGE/ADDI/J`)的消除。 --- -## 常见坑 +## 动手练习 -待补充。 +1. 把 `full_threshold` 调成 0,观察 `014_for_dot` 的 `skipped["no_factor"]` 与 IR 变化。 +2. 为 `LoopUnroll` 增加 `unroll_min_trip` 参数,拒绝短循环(如 `N < 4` 不展开)。 +3. 构造 body 使用 `start != 0` 的循环(如 `[start=2][end=8]`),验证 `start_tmp` 绑定路径。 --- -## 进阶阅读 +## 常见坑 -- 龙书第 9 章:Loop Optimizations -- 相关 topic: [课题19 — Standalone RISC-V 编译器](19-Standalone-RISC-V编译器.md) +1. **末副本必须复用原名**:否则循环携带值(累加器)在展开后丢失跨组链。 +2. **绑定值 `is_constant` 必须为 False**:否则被指令选择器内联为立即数,`mul` 类 R 型指令编码错误。 +3. **不要改 `ENDFOR`**:后端忽略 `step` 且恒 `+1`,改写 `ENDFOR` 会与 lowering 语义冲突。 +4. **`step != 1` 一律跳过**:lowering 不支持非单位步长。 +5. **body 内的分支/标签不展开**:复制标签会产生重名与目标重映射问题,v1 直接跳过。 +6. **嵌套循环内层优先**:外层处理前必须确认内层标记已消失,否则按 `nested_loop` 跳过。 +7. **余数循环 `r>1` 不能携带值**:单副本余数循环无法表达跨迭代携带(前向引用/自引用,如 `acc = add(acc, t)`),曾静默产出错误 IR(评审 F1);现在 `_select_plan` 会记 `carried_value` 跳过,IR 保持不变。 +8. **展开可能触发分配器既有缺陷**:展开抬高活跃值数量,贪婪分配器「溢出后不重载」(B2)会把正确程序静默编错;因此默认关闭、显式 opt-in。 --- -## 12周每周目标 +## 进阶阅读 -待补充。 +- 龙书第 9 章:Loop Optimizations、归纳变量与展开 +- `scratchv/optimizer/licm.py`:IR pass 风格与 FOR/ENDFOR 扫描参考 +- `scratchv/backend/instruction_select.py`:FOR/ENDFOR lowering 语义 +- `scratchv/analysis/ir_verifier.py`:SSA 单次赋值规则 +- 相关 topic: [课题19 — Standalone RISC-V 编译器](19-Standalone-RISC-V编译器.md) diff --git a/docs/topics/INDEX.md b/docs/topics/INDEX.md index c8ebe44..da86844 100644 --- a/docs/topics/INDEX.md +++ b/docs/topics/INDEX.md @@ -50,7 +50,7 @@ --- -## 📗 中级(9 个)— 核心编译器管线 +## 📗 中级(10 个)— 核心编译器管线 编译器前中后端的关键模块。 @@ -61,6 +61,7 @@ | [03](03-IR系统.md) | 中间表示系统 (IR) | 项目实战 | ✅ | | [04](04-IR优化器框架.md) | IR 优化器框架(5 passes) | 项目实战 | ✅ | | [08](08-指令选择.md) | 后端指令选择 | 参考分析 | ✅ | +| [10](10-循环展开优化.md) | 循环展开优化 | 项目实战 | ✅ | | [14](14-常量加载合并.md) | 常量加载合并优化 | 项目实战 | ✅ | | [16](16-LLVM代码生成.md) | LLVM 代码生成后端 | 项目实战 | ✅ | | [17](17-寄存器分配.md) | 寄存器分配(线性扫描) | 项目实战 | ✅ | @@ -80,11 +81,10 @@ --- -## ⬜ 规划中(3 个) +## ⬜ 规划中(2 个) | 编号 | 课题 | 类型 | 状态 | |------|------|------|------| -| [10](10-循环展开优化.md) | 循环展开优化 | 项目实战 | ⬜ | | [15](15-函数内联.md) | 函数内联 | 项目实战 | ⬜ | | [29](29-SIMD向量化.md) | SIMD 向量化 (RISC-V P/V-extension) | 项目实战 | ⬜ | diff --git a/scratchv/compiler.py b/scratchv/compiler.py index fa5459e..a312447 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -53,6 +53,14 @@ class CompilerConfig: cycle_stats: Run 5-stage pipeline cycle estimation (detailed). enable_forwarding: Enable forwarding in cycle estimator. branch_predictor: Branch predictor mode for cycle estimator. + loop_unroll: Run IR loop unrolling at optimize_level "all" + (opt-in until the greedy allocator reload defect + is fixed). + unroll_max_factor: Max unroll factor for partial unrolling. + unroll_full_threshold: Fully unroll loops with trip count <= N. + unroll_body_limit: Max loop-body IR instructions eligible. + unroll_max_growth: Max newly added IR instructions per loop. + unroll_epilogue: Allow remainder (epilogue) loop. """ backend: str = "riscv" @@ -73,6 +81,12 @@ class CompilerConfig: cycle_stats: bool = False enable_forwarding: bool = True branch_predictor: str = "always_not_taken" + loop_unroll: bool = False + unroll_max_factor: int = 8 + unroll_full_threshold: int = 8 + unroll_body_limit: int = 64 + unroll_max_growth: int = 512 + unroll_epilogue: bool = False # ═══════════════════════════════════════════════════════════════════════════════ @@ -121,6 +135,7 @@ def run(self, input_data: Any) -> PassResult: messages: list[str] = [] all_warnings: list[str] = [] timings: dict[str, float] = {} + all_stats: dict[str, Any] = {} for p in self._passes: t0 = time.perf_counter() @@ -132,6 +147,7 @@ def run(self, input_data: Any) -> PassResult: changes=total_changes, message=f"Pass '{p.name}' failed: {exc}", warnings=all_warnings, + stats=all_stats, ) elapsed = time.perf_counter() - t0 timings[p.name] = elapsed @@ -142,6 +158,7 @@ def run(self, input_data: Any) -> PassResult: changes=total_changes, message=f"Pipeline stopped after '{p.name}': {result.message}", warnings=all_warnings + result.warnings, + stats=all_stats, ) data = result.data @@ -149,12 +166,15 @@ def run(self, input_data: Any) -> PassResult: if result.message: messages.append(f"[{p.name}] {result.message}") all_warnings.extend(result.warnings) + if result.stats: + all_stats[p.name] = result.stats return PassResult( data=data, changes=total_changes, message="; ".join(messages) if messages else "pipeline complete", warnings=all_warnings, + stats=all_stats, ) def report(self) -> str: @@ -296,9 +316,11 @@ def compile(self, input_path: str, output_path: str | None = None, # --- 3. Optimize --- opt_message = "" + opt_stats: dict[str, Any] = {} if self.config.optimize_level != "none": opt_result = self._run_optimizations(program) opt_message = opt_result.message + opt_stats = opt_result.stats ir_dump_after = "" if self.config.dump_ir: @@ -353,7 +375,11 @@ def compile(self, input_path: str, output_path: str | None = None, output_text=asm_text, output_path=output_path, ir_dump=ir_dump, - stats={"opt_message": opt_message, "cycle_report": cycle_report}, + stats={ + "opt_message": opt_message, + "cycle_report": cycle_report, + "passes": opt_stats, + }, warnings=warnings, ) @@ -404,16 +430,34 @@ def _run_optimizations(self, program) -> PassResult: pm.add(_PassAdapter("constant-folding", ConstantFolder(program))) pm.add(_PassAdapter("dead-code-elim", DeadCodeEliminator(program))) + unroll = None if self.config.optimize_level == "all": from scratchv.optimizer.peephole import IRPeepholeOptimizer from scratchv.optimizer.muladd_fusion import MulAddFusion from scratchv.optimizer.licm import LICM + from scratchv.optimizer.loop_unroll import LoopUnroll pm.add(_PassAdapter("ir-peephole", IRPeepholeOptimizer(program))) pm.add(_PassAdapter("muladd-fusion", MulAddFusion(program))) pm.add(_PassAdapter("licm", LICM(program))) - return pm.run(program) + if self.config.loop_unroll: + unroll = LoopUnroll( + program, + max_factor=self.config.unroll_max_factor, + full_threshold=self.config.unroll_full_threshold, + body_limit=self.config.unroll_body_limit, + max_growth=self.config.unroll_max_growth, + epilogue=self.config.unroll_epilogue, + ) + pm.add(_PassAdapter("loop-unroll", unroll)) + pm.add(_PassAdapter( + "dead-code-elim-cleanup", DeadCodeEliminator(program))) + + result = pm.run(program) + if unroll is not None: + result.stats["loop-unroll"] = unroll.stats + return result # ── Internal: code generation ─────────────────────────────────────────── @@ -541,8 +585,20 @@ def name(self) -> str: def run(self, input_data: Any) -> PassResult: changes = self._legacy.run() + stats = getattr(self._legacy, "stats", {}) or {} + message = f"{changes} change(s)" + if "loops_unrolled" in stats: + message = ( + f"{stats['loops_unrolled']} loop(s) " + f"({stats['full_unrolls']} full, " + f"{stats['partial_unrolls']} partial, " + f"{stats['partial_epilogues']} epilogue), " + f"IR {stats['instructions_before']} → " + f"{stats['instructions_after']}" + ) return PassResult( data=input_data, changes=changes, - message=f"{changes} change(s)", + message=message, + stats=stats, ) diff --git a/scratchv/main.py b/scratchv/main.py index 52feff5..d5b2a2a 100644 --- a/scratchv/main.py +++ b/scratchv/main.py @@ -104,6 +104,39 @@ def build_arg_parser() -> argparse.ArgumentParser: "--extended-isel", action="store_true", help="Use extended instruction selector with fp64/sqrt/min/max/abs support (Topic 28)", ) + parser.add_argument( + "--loop-unroll", dest="loop_unroll", action="store_true", + default=False, + help="Enable IR loop unrolling at --optimize all (Topic 10). " + "Opt-in: disabled by default while the greedy register " + "allocator spills without reloading.", + ) + parser.add_argument( + "--no-loop-unroll", dest="loop_unroll", action="store_false", + help="Disable IR loop unrolling at --optimize all (Topic 10)", + ) + parser.add_argument( + "--unroll-factor", type=int, default=8, + help="Max unroll factor for partial unrolling (default: 8)", + ) + parser.add_argument( + "--unroll-full-threshold", type=int, default=8, + help="Fully unroll loops with trip count <= N (default: 8)", + ) + parser.add_argument( + "--unroll-body-limit", type=int, default=64, + help="Max loop-body IR instructions eligible for unrolling " + "(default: 64)", + ) + parser.add_argument( + "--unroll-max-growth", type=int, default=512, + help="Max newly added IR instructions per loop (default: 512)", + ) + parser.add_argument( + "--unroll-epilogue", action="store_true", + help="Allow remainder (epilogue) loop when factor does not " + "divide trip count", + ) # ── Cycle estimation ────────────────────────────────────────────── parser.add_argument( @@ -153,6 +186,12 @@ def args_to_config(args: argparse.Namespace) -> CompilerConfig: cycle_stats=args.cycle_stats, enable_forwarding=not args.no_forwarding, branch_predictor=args.branch_predictor, + loop_unroll=args.loop_unroll, + unroll_max_factor=args.unroll_factor, + unroll_full_threshold=args.unroll_full_threshold, + unroll_body_limit=args.unroll_body_limit, + unroll_max_growth=args.unroll_max_growth, + unroll_epilogue=args.unroll_epilogue, ) diff --git a/scratchv/optimizer/__init__.py b/scratchv/optimizer/__init__.py index 42a86b5..aedea5d 100644 --- a/scratchv/optimizer/__init__.py +++ b/scratchv/optimizer/__init__.py @@ -3,6 +3,7 @@ from .peephole import IRPeepholeOptimizer from .muladd_fusion import MulAddFusion from .licm import LICM +from .loop_unroll import LoopUnroll, UnrollPlan __all__ = [ "ConstantFolder", @@ -10,4 +11,6 @@ "IRPeepholeOptimizer", "MulAddFusion", "LICM", + "LoopUnroll", + "UnrollPlan", ] diff --git a/scratchv/optimizer/loop_unroll.py b/scratchv/optimizer/loop_unroll.py new file mode 100644 index 0000000..6a45c31 --- /dev/null +++ b/scratchv/optimizer/loop_unroll.py @@ -0,0 +1,693 @@ +"""IR-level loop unrolling for ScratchV (Topic 10). + +Unrolls ``FOR`` / ``ENDFOR`` loops by duplicating the loop body: + +- ``FULL`` - trip count ``N <= full_threshold``; the loop markers + are removed and the body is replicated ``N`` times. +- ``PARTIAL_EXACT`` - the largest divisor ``U`` of ``N`` (``2 <= U <= + max_factor``); the ``FOR`` loop is rewritten into a + group counter (``[start=0][end=q]``) with ``U`` + replicated copies per group. +- ``PARTIAL_EPILOGUE`` - same as partial, but a remainder loop handles the + ``r = N % U`` leftover iterations. + +The pass is conservative: every failed precondition records a reason in +``stats["skipped"]`` and leaves the IR untouched. It never redefines an +existing value name -- the last copy of each body value reuses the original +name (rotating naming), which keeps the SSA-style single-assignment contract +checked by :class:`scratchv.analysis.ir_verifier.IRVerifier`. + +The backend lowering always emits ``ADDI iv, iv, 1`` for ``ENDFOR`` and +ignores ``step``, so partial unrolling rewrites the induction variable into a +group counter and materialises the element index (``start + k + U * group``) +for each copy instead of touching ``ENDFOR``. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Optional + +from scratchv.ir.types import ( + DataType, + Function, + Instruction, + OpCode, + Program, + Value, +) + +# Fixed skip-reason keys (missing keys are treated as 0 by consumers). +_SKIP_KEYS = ( + "bad_attrs", "step_not_one", "trip_lt_2", "already_unrolled", + "body_has_branches", "nested_loop", "body_too_large", "multi_def", + "carried_value", "no_factor", "growth_limit", "unprofitable", + "unpaired", "internal_error", +) + +_BARRIER_OPS = (OpCode.LABEL, OpCode.BR, OpCode.BR_IF) +_LOOP_OPS = (OpCode.FOR, OpCode.ENDFOR) + + +@dataclass +class UnrollPlan: + """Chosen unrolling strategy for a single loop.""" + + mode: str # "full" | "partial_exact" | "partial_epilogue" + U: int # unroll factor (number of copies) + q: int # N // U + r: int # N % U + estimated_added: int = 0 + dynamic_saving: int = 0 + + @property + def partial(self) -> bool: + return self.mode != "full" + + +class LoopUnroll: + """Unroll ``FOR`` / ``ENDFOR`` loops in an IR program (in place).""" + + _MAX_ITERATIONS = 32 + + def __init__( + self, + program: Program, + max_factor: int = 8, + full_threshold: int = 8, + body_limit: int = 64, + max_growth: int = 512, + epilogue: bool = False, + ) -> None: + self.program = program + self._max_factor = max_factor + self._full_threshold = full_threshold + self._body_limit = body_limit + self._max_growth = max_growth + self._epilogue = epilogue + self._stats = self._empty_stats() + self._name_cache: dict[str, set[str]] = {} + self._seen_loops: set[int] = set() + # FOR instructions already transformed by this run (avoids counting + # rescan artifacts such as a freshly created epilogue loop). + self._handled_loops: set[int] = set() + # id(FOR) -> (FOR object, skip reasons already counted this run). + # Holding the object prevents id reuse from hiding later skips; the + # reason set keeps repeated rescans from inflating the counters. + self._skip_recorded: dict[int, tuple[Instruction, set[str]]] = {} + self._last_scan_unpaired = False + # Rolling "current copy" name map shared by ``_copy_region`` calls. + self._copy_cur: dict[str, str] = {} + self._epilogue_copy = False + + # ── Public API ────────────────────────────────────────────────────── + + @property + def stats(self) -> dict: + """Statistics of the last :meth:`run`, in the fixed schema.""" + return self._stats + + def run(self) -> int: + """Run unrolling on all functions; return the number of loops unrolled. + + The pass is repeatable: every loop written by the pass (main + partially unrolled loop and remainder loop alike) carries an + ``attrs["unrolled"]`` marker and is skipped on subsequent runs. + """ + self._stats = self._empty_stats() + self._name_cache = {} + self._seen_loops = set() + self._handled_loops = set() + self._skip_recorded = {} + self._stats["instructions_before"] = self._count_instructions() + + for func in self.program.functions: + snapshot = self._snapshot(func) + stats_snapshot = copy.deepcopy(self._stats) + try: + self._process_function(func) + except Exception: + self._restore(func, snapshot) + self._stats = stats_snapshot + self._stats["skipped"]["internal_error"] += 1 + + before = self._stats["instructions_before"] + after = self._count_instructions() + self._stats["instructions_after"] = after + self._stats["instructions_added"] = after - before + return self._stats["loops_unrolled"] + + # ── Scanning ──────────────────────────────────────────────────────── + + def _find_pairs(self, instrs: list) -> list[tuple[int, int]]: + """Pair ``FOR`` / ``ENDFOR`` indices using a stack. + + Returns pairs ordered by closing ``ENDFOR``, i.e. innermost first. + An unbalanced block is reported via ``skipped["unpaired"]`` and no + pair is returned, so the whole block is left untouched. + """ + stack: list[int] = [] + pairs: list[tuple[int, int]] = [] + imbalanced = False + + for i, ins in enumerate(instrs): + if ins.opcode == OpCode.FOR: + stack.append(i) + elif ins.opcode == OpCode.ENDFOR: + if not stack: + imbalanced = True + continue + pairs.append((stack.pop(), i)) + + if stack: + imbalanced = True + + if imbalanced: + self._stats["skipped"]["unpaired"] += 1 + self._last_scan_unpaired = True + return [] + return pairs + + def _process_function(self, func: Function) -> None: + for block in func.blocks: + for _ in range(self._MAX_ITERATIONS): + self._last_scan_unpaired = False + pairs = self._find_pairs(block.instructions) + if self._last_scan_unpaired: + break + + applied = False + for for_idx, endfor_idx in pairs: + plan = self._select_plan( + block.instructions, for_idx, endfor_idx) + if plan is None: + continue + self._apply_unroll( + func, block, for_idx, endfor_idx, plan) + applied = True + break + if not applied: + break + + # ── Plan selection ────────────────────────────────────────────────── + + def _select_plan( + self, instrs: list, for_idx: int, endfor_idx: int, + ) -> Optional[UnrollPlan]: + """Choose a plan, or ``None`` with a recorded skip reason.""" + for_ins = instrs[for_idx] + if id(for_ins) in self._handled_loops: + return None + key = id(for_ins) + if key not in self._seen_loops: + self._seen_loops.add(key) + self._stats["loops_seen"] += 1 + + attrs = for_ins.attrs + start = attrs.get("start") + end = attrs.get("end") + step = attrs.get("step") + if not (type(start) is int and type(end) is int and type(step) is int): + self._skip_loop(for_ins, "bad_attrs") + return None + if step != 1: + self._skip_loop(for_ins, "step_not_one") + return None + + n_trip = end - start + if n_trip < 2: + self._skip_loop(for_ins, "trip_lt_2") + return None + if "unrolled" in attrs: + self._skip_loop(for_ins, "already_unrolled") + return None + + region = instrs[for_idx + 1:endfor_idx] + if any(ins.opcode in _BARRIER_OPS for ins in region): + self._skip_loop(for_ins, "body_has_branches") + return None + if any(ins.opcode in _LOOP_OPS for ins in region): + self._skip_loop(for_ins, "nested_loop") + return None + if len(region) > self._body_limit: + self._skip_loop(for_ins, "body_too_large") + return None + + def_counts: dict[str, int] = {} + for ins in region: + if ins.dest is not None: + def_counts[ins.dest.name] = ( + def_counts.get(ins.dest.name, 0) + 1) + iv = for_ins.dest + iv_name = iv.name if iv is not None else "" + if any(count > 1 for count in def_counts.values()): + self._skip_loop(for_ins, "multi_def") + return None + if iv_name and any( + ins.dest is not None and ins.dest.name == iv_name + for ins in region + ): + self._skip_loop(for_ins, "multi_def") + return None + + # Loop-carried (self/forward-referenced) body values cannot be + # expressed by the single renamed epilogue copy: its uses stay bound + # to the pre-loop names, so the second remainder iteration reads a + # stale value. Reject that shape instead of emitting wrong IR. + has_forward_ref = self._has_forward_reference( + region, set(def_counts), iv_name) + + body_size = len(region) + iv_used = bool(iv_name) and any( + op.name == iv_name for ins in region for op in ins.operands) + extra_start = 1 if start != 0 else 0 + + if n_trip <= self._full_threshold: + candidates: list[tuple[str, int]] = [("full", n_trip)] + else: + limit = min(self._max_factor, n_trip - 1) + divisors = [u for u in range(limit, 1, -1) + if n_trip % u == 0] + if divisors: + candidates = [("partial_exact", u) for u in divisors] + elif self._epilogue and limit >= 2: + candidates = [("partial_epilogue", limit)] + else: + self._skip_loop(for_ins, "no_factor") + return None + + last_reason = "no_factor" + for mode, factor in candidates: + q, r = divmod(n_trip, factor) + if mode == "partial_epilogue" and r > 1 and has_forward_ref: + last_reason = "carried_value" + continue + if mode == "full": + m_cost = factor if iv_used else 0 + s_cost = 0 + epilogue_cost = 0 + else: + m_cost = (factor + extra_start) if iv_used else 0 + s_cost = (2 + extra_start) if iv_used else 0 + epilogue_cost = (body_size + 2) if ( + mode == "partial_epilogue" and r > 0) else 0 + + estimated_added = ( + (factor - 1) * body_size + m_cost + s_cost + + epilogue_cost + 1) + dynamic_saving = q * (3 * factor - 3 - m_cost) - s_cost + if mode == "partial_epilogue" and r > 0: + dynamic_saving -= 1 + + if estimated_added > self._max_growth: + last_reason = "growth_limit" + continue + if mode != "full" and dynamic_saving < 2: + last_reason = "unprofitable" + continue + + return UnrollPlan( + mode=mode, U=factor, q=q, r=r, + estimated_added=estimated_added, + dynamic_saving=dynamic_saving, + ) + + self._skip_loop(for_ins, last_reason) + return None + + # ── IR rewriting ──────────────────────────────────────────────────── + + def _apply_unroll( + self, func: Function, block, for_idx: int, endfor_idx: int, + plan: UnrollPlan, + ) -> None: + instrs = block.instructions + for_ins = instrs[for_idx] + endfor_ins = instrs[endfor_idx] + self._handled_loops.add(id(for_ins)) + iv = for_ins.dest + iv_name = iv.name if iv is not None else "" + iv_dtype = iv.dtype if iv is not None else DataType.INT32 + start = for_ins.attrs["start"] + end = for_ins.attrs["end"] + n_trip = end - start + region = list(instrs[for_idx + 1:endfor_idx]) + body_defs = { + ins.dest.name for ins in region if ins.dest is not None} + orig_def_values = { + ins.dest.name: ins.dest for ins in region + if ins.dest is not None} + + iv_in_body = bool(iv_name) and any( + op.name == iv_name for ins in region for op in ins.operands) + iv_after = self._name_used_after( + func, block, for_idx, endfor_idx, iv_name) + + new_body: list[Instruction] = [] + post_rename: dict[str, str] = {} + + if plan.mode == "full": + self._copy_cur = {} + self._epilogue_copy = False + for k in range(plan.U): + bind = None + if iv_in_body: + if k == plan.U - 1: + bind, bind_ins = self._make_const( + func, start + k, iv_dtype, name=iv_name) + else: + bind, bind_ins = self._make_const( + func, start + k, iv_dtype, + base=f"{iv_name}__u{k}") + new_body.append(bind_ins) + new_body.extend(self._copy_region( + func, region, body_defs, iv_name, bind, + k == plan.U - 1, k)) + else: + for_ins.attrs.clear() + for_ins.attrs.update({ + "start": 0, "end": plan.q, "step": 1, "unrolled": plan.U}) + + setup: list[Instruction] = [] + u_tmp: Optional[Value] = None + one_tmp: Optional[Value] = None + start_tmp: Optional[Value] = None + if iv_in_body: + u_tmp, ins_u = self._make_const( + func, plan.U, iv_dtype, base="c_u") + one_tmp, ins_one = self._make_const( + func, 1, iv_dtype, base="c_one") + setup.extend([ins_u, ins_one]) + if start != 0: + start_tmp, ins_start = self._make_const( + func, start, iv_dtype, base="c_start") + setup.append(ins_start) + + self._copy_cur = {} + self._epilogue_copy = False + prev_bind: Optional[Value] = None + for k in range(plan.U): + bind = None + if iv_in_body: + bind_name = self._fresh_name(func, f"{iv_name}__u{k}") + if k == 0 and start == 0: + bind, bind_ins = self._make_binary( + bind_name, OpCode.MUL, iv, u_tmp, iv_dtype) + new_body.append(bind_ins) + elif k == 0: + tmp_name = self._fresh_name( + func, f"{iv_name}__g0") + tmp, tmp_ins = self._make_binary( + tmp_name, OpCode.MUL, iv, u_tmp, iv_dtype) + bind, bind_ins = self._make_binary( + bind_name, OpCode.ADD, tmp, start_tmp, iv_dtype) + new_body.extend([tmp_ins, bind_ins]) + else: + bind, bind_ins = self._make_binary( + bind_name, OpCode.ADD, prev_bind, one_tmp, + iv_dtype) + new_body.append(bind_ins) + prev_bind = bind + new_body.extend(self._copy_region( + func, region, body_defs, iv_name, bind, + k == plan.U - 1, k)) + + epilogue: list[Instruction] = [] + if plan.mode == "partial_epilogue" and plan.r > 0: + ep_iv = Value( + name=self._fresh_name( + func, f"{iv_name}_ep" if iv_name else "iv_ep"), + dtype=iv_dtype, is_constant=False) + ep_for = Instruction( + OpCode.FOR, ep_iv, [], + {"start": start + plan.q * plan.U, "end": end, + "step": 1, "unrolled": 1}) + self._handled_loops.add(id(ep_for)) + self._copy_cur = {} + self._epilogue_copy = True + ep_body = self._copy_region( + func, region, body_defs, iv_name, ep_iv, False, 0) + self._epilogue_copy = False + epilogue = [ep_for] + ep_body + [Instruction(OpCode.ENDFOR)] + post_rename = { + name: self._copy_cur[name] for name in body_defs + if name in self._copy_cur} + + new_body = setup + [for_ins] + new_body + [endfor_ins] + epilogue + + instrs[for_idx:endfor_idx + 1] = new_body + insert_at = for_idx + len(new_body) + + redirects: list[tuple[str, Value]] = [] + if iv_after: + iv_final = Value( + name=self._fresh_name( + func, f"{iv_name}_final" if iv_name else "iv_final"), + dtype=iv_dtype, is_constant=False) + instrs.insert(insert_at, Instruction( + OpCode.LOAD_CONST, iv_final, [], {"value": start + n_trip})) + insert_at += 1 + redirects.append((iv_name, iv_final)) + for old_name, new_name in post_rename.items(): + redirects.append(( + old_name, + self._value_like(orig_def_values[old_name], new_name))) + + if redirects: + self._redirect_uses(func, block, insert_at, redirects) + + self._stats["loops_unrolled"] += 1 + if plan.mode == "full": + self._stats["full_unrolls"] += 1 + else: + self._stats["partial_unrolls"] += 1 + if plan.mode == "partial_epilogue" and plan.r > 0: + self._stats["partial_epilogues"] += 1 + + def _copy_region( + self, func: Function, region: list, body_defs: set[str], + iv_name: str, bind_k: Optional[Value], last: bool, k: int, + ) -> list[Instruction]: + """Clone *region*, rotating body-definition names. + + References resolve through ``self._copy_cur``: a use maps to the most + recent definition (earlier in the same copy, else the previous copy, + else the original name for the first copy). The last copy reuses the + original names so that loop-carried values remain visible after the + loop. + """ + cur = self._copy_cur + suffix = "__ep" if self._epilogue_copy else f"__u{k}" + out: list[Instruction] = [] + + for ins in region: + operands = [] + for op in ins.operands: + if iv_name and op.name == iv_name: + operands.append(bind_k if bind_k is not None else op) + elif op.name in body_defs: + resolved = cur.get(op.name, op.name) + if resolved == op.name: + operands.append(op) + else: + operands.append(self._value_like(op, resolved)) + else: + operands.append(op) + + dest = ins.dest + if dest is not None and dest.name in body_defs: + if last: + new_name = dest.name + else: + new_name = self._fresh_name( + func, f"{dest.name}{suffix}") + cur[dest.name] = new_name + dest = self._value_like(dest, new_name) + + out.append(Instruction( + ins.opcode, dest, operands, dict(ins.attrs), ins.target)) + return out + + # ── Helpers ───────────────────────────────────────────────────────── + + def _fresh_name(self, func: Function, base: str) -> str: + """Return a function-unique value name derived from *base*.""" + used = self._name_cache.get(func.name) + if used is None: + used = self._collect_names(func) + self._name_cache[func.name] = used + name = base + counter = 0 + while name in used: + counter += 1 + name = f"{base}_{counter}" + used.add(name) + return name + + def _collect_names(self, func: Function) -> set[str]: + names: set[str] = set() + for param in func.params: + names.add(param.name) + for local in func.locals: + names.add(local.name) + for block in func.blocks: + for ins in block.instructions: + if ins.dest is not None: + names.add(ins.dest.name) + for op in ins.operands: + names.add(op.name) + return names + + @staticmethod + def _value_like(value: Value, name: str) -> Value: + """Create a fresh, non-constant value carrying *value*'s type.""" + return Value( + name=name, dtype=value.dtype, is_constant=False, + const_value=None, shape=value.shape) + + def _make_const( + self, func: Function, value: int, dtype: DataType, + name: Optional[str] = None, base: Optional[str] = None, + ) -> tuple[Value, Instruction]: + """Create a non-constant ``LOAD_CONST`` value/instruction pair.""" + if name is None: + name = self._fresh_name(func, base or "c") + val = Value(name=name, dtype=dtype, is_constant=False) + return val, Instruction(OpCode.LOAD_CONST, val, [], {"value": value}) + + @staticmethod + def _make_binary( + name: str, opcode: OpCode, lhs: Value, rhs: Value, dtype: DataType, + ) -> tuple[Value, Instruction]: + """Create a non-constant binary-operation value/instruction pair.""" + dest = Value(name=name, dtype=dtype, is_constant=False) + return dest, Instruction(opcode, dest, [lhs, rhs]) + + @staticmethod + def _has_forward_reference( + region: list, body_def_names: set[str], iv_name: str, + ) -> bool: + """Whether the region reads a body-defined value before defining it. + + This covers both forward references (``t = add(u, one)`` before + ``u = ...``) and self references (``acc = add(acc, t)``), i.e. the + loop-carried values a single epilogue copy cannot express. + """ + defined: set[str] = set() + for ins in region: + for op in ins.operands: + if (op.name != iv_name and op.name in body_def_names + and op.name not in defined): + return True + if ins.dest is not None: + defined.add(ins.dest.name) + return False + + def _name_used_after( + self, func: Function, block, for_idx: int, endfor_idx: int, + name: str, + ) -> bool: + """Whether *name* is used outside the loop. + + Scans later instructions in the same block plus every other block. + """ + if not name: + return False + instrs = block.instructions + for i in range(endfor_idx + 1, len(instrs)): + if any(op.name == name for op in instrs[i].operands): + return True + for other in func.blocks: + if other is block: + continue + for ins in other.instructions: + if any(op.name == name for op in ins.operands): + return True + return False + + def _redirect_uses( + self, func: Function, block, start_idx: int, + redirects: list[tuple[str, Value]], + ) -> None: + """Replace operand names after the loop according to *redirects*.""" + lookup = dict(redirects) + for i in range(start_idx, len(block.instructions)): + self._redirect_instruction(block.instructions[i], lookup) + for other in func.blocks: + if other is block: + continue + for ins in other.instructions: + self._redirect_instruction(ins, lookup) + + @staticmethod + def _redirect_instruction( + instr: Instruction, lookup: dict[str, Value], + ) -> None: + for j, op in enumerate(instr.operands): + replacement = lookup.get(op.name) + if replacement is not None: + instr.operands[j] = replacement + + def _snapshot(self, func: Function) -> list: + """Save the instruction list, attrs and operands of every block. + + Operands are captured because ``_redirect_uses`` rewrites them in + place; without them an exception mid-rewrite would leave a + half-rewritten function behind. + """ + return [ + (block, list(block.instructions), + [(ins, dict(ins.attrs), list(ins.operands)) + for ins in block.instructions]) + for block in func.blocks + ] + + @staticmethod + def _restore(func: Function, snapshot: list) -> None: + for block, instrs, saved in snapshot: + block.instructions = instrs + for ins, attrs, operands in saved: + ins.attrs = attrs + ins.operands = operands + + def _count_instructions(self) -> int: + return sum( + len(block.instructions) + for func in self.program.functions + for block in func.blocks + ) + + def _skip(self, reason: str) -> None: + self._stats["skipped"][reason] += 1 + + def _skip_loop(self, for_ins: Instruction, reason: str) -> None: + """Record a skip reason for *for_ins* at most once per ``run()``. + + ``_process_function`` rescans the block after every applied loop, so + the same untouched loop is examined repeatedly; counting it each + time would inflate the user-visible statistics. + """ + key = id(for_ins) + entry = self._skip_recorded.get(key) + if entry is None or entry[0] is not for_ins: + entry = (for_ins, set()) + self._skip_recorded[key] = entry + if reason in entry[1]: + return + entry[1].add(reason) + self._skip(reason) + + @staticmethod + def _empty_stats() -> dict: + return { + "loops_seen": 0, + "loops_unrolled": 0, + "full_unrolls": 0, + "partial_unrolls": 0, + "partial_epilogues": 0, + "instructions_before": 0, + "instructions_after": 0, + "instructions_added": 0, + "skipped": {key: 0 for key in _SKIP_KEYS}, + } diff --git a/scratchv/pass_interface.py b/scratchv/pass_interface.py index 12598ad..289636a 100644 --- a/scratchv/pass_interface.py +++ b/scratchv/pass_interface.py @@ -41,12 +41,14 @@ class PassResult: changes: Number of transformations / fixes applied. message: Human-readable summary (e.g. "3 constants folded"). warnings: Non-fatal issues discovered during the pass. + stats: Optional per-pass statistics keyed by metric name. """ data: Any changes: int = 0 message: str = "" warnings: list[str] = field(default_factory=list) + stats: dict[str, Any] = field(default_factory=dict) @property def success(self) -> bool: diff --git a/tests/golden/013_for_sum.s.golden b/tests/golden/013_for_sum.s.golden new file mode 100644 index 0000000..940b3a2 --- /dev/null +++ b/tests/golden/013_for_sum.s.golden @@ -0,0 +1,16 @@ +.text +.align 2 + .globl main + .type main, @function +main: +.entry: + add t2, t0, t1 + li t3, 0 # loop init +.Lloop_header_1: + bge t3, 4, .Lloop_exit_3 +.Lloop_body_2: + addi t3, t3, 1 # loop inc + j .Lloop_header_1 +.Lloop_exit_3: + mv a0, t2 # return value + jalr zero, ra # ret diff --git a/tests/golden/014_for_dot.s.golden b/tests/golden/014_for_dot.s.golden new file mode 100644 index 0000000..940b3a2 --- /dev/null +++ b/tests/golden/014_for_dot.s.golden @@ -0,0 +1,16 @@ +.text +.align 2 + .globl main + .type main, @function +main: +.entry: + add t2, t0, t1 + li t3, 0 # loop init +.Lloop_header_1: + bge t3, 4, .Lloop_exit_3 +.Lloop_body_2: + addi t3, t3, 1 # loop inc + j .Lloop_header_1 +.Lloop_exit_3: + mv a0, t2 # return value + jalr zero, ra # ret diff --git a/tests/golden/019_nested_loop.s.golden b/tests/golden/019_nested_loop.s.golden new file mode 100644 index 0000000..108e3b4 --- /dev/null +++ b/tests/golden/019_nested_loop.s.golden @@ -0,0 +1,23 @@ +.text +.align 2 + .globl main + .type main, @function +main: +.entry: + add t2, t0, t1 + li t3, 0 # loop init +.Lloop_header_1: + bge t3, 4, .Lloop_exit_3 +.Lloop_body_2: + li t4, 0 # loop init +.Lloop_header_4: + bge t4, 2, .Lloop_exit_6 +.Lloop_body_5: + addi t4, t4, 1 # loop inc + j .Lloop_header_4 +.Lloop_exit_6: + addi t4, t4, 1 # loop inc + j .Lloop_header_4 +.Lloop_exit_6: + mv a0, t2 # return value + jalr zero, ra # ret diff --git a/tests/test_loop_unroll.py b/tests/test_loop_unroll.py new file mode 100644 index 0000000..efda2fe --- /dev/null +++ b/tests/test_loop_unroll.py @@ -0,0 +1,979 @@ +"""Tests for IR loop unrolling (Topic 10). + +Structure / SSA / idempotency assertions follow the Topic 10 design +document; dynamic instruction counts are measured end-to-end through +``InstructionSelector -> RegisterAllocator(greedy) -> AsmEmitter -> +assemble_to_binary -> RV32Emulator``. + +Note on measured numbers: the numbers asserted here are the values actually +produced by the current backend. The design document's theoretical counts +(e.g. 21->12, 31->27, 36->30) exclude the branch-immediate expansion the +encoder inserts for ``bge iv, end`` and assume constant operands are not +inlined (the current selector inlines them). The actual measurements show +strictly larger savings, so the design-document numbers are treated as +directional acceptance criteria here. +""" + +from __future__ import annotations + +from pathlib import Path + +from scratchv.analysis.ir_verifier import ErrorLevel, IRVerifier +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, OpCode, Program +from scratchv.optimizer.loop_unroll import LoopUnroll, UnrollPlan + + +# ═══════════════════════════════════════════════════════════════════════════ +# Program builders +# ═══════════════════════════════════════════════════════════════════════════ + +def _make_const(builder: IRBuilder, name: str, value: int): + value_obj = builder.make_value( + name=name, dtype=DataType.INT32, is_constant=False) + builder._emit(OpCode.LOAD_CONST, value_obj, value=value) + return value_obj + + +def build_case_program(end: int, use_acc: bool = True): + """Design-document case-1 IR: ``one`` const, body uses the iv. + + ``use_acc=True`` is the exact design-document program (``v3 = acc + v2`` + with ``acc`` a function parameter). ``use_acc=False`` avoids the free + parameter to keep register pressure low for emulator runs. + """ + builder = IRBuilder() + params = [] + acc = None + if use_acc: + acc = builder.make_value( + name="acc", dtype=DataType.INT32, is_constant=False) + params = [acc] + builder.new_function("main", params=params) + builder.new_block("entry") + one = _make_const(builder, "one", 1) + iv = builder.for_loop(0, end) + v2 = builder.add(iv, one) + if use_acc: + v3 = builder.add(acc, v2) + else: + v3 = builder.add(v2, v2) + builder.endfor() + builder.ret(v3) + return builder.program + + +def build_low_pressure_program(end: int, start: int = 0): + """Single-instruction loop body: ``v2 = iv + one``; return ``v2``.""" + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + one = _make_const(builder, "one", 1) + iv = builder.for_loop(start, end) + v2 = builder.add(iv, one) + builder.endfor() + builder.ret(v2) + return builder.program + + +def build_carried_param_simple_program(end: int): + """Carried accumulator without iv use: ``acc = acc + one``. + + ``acc`` is a function parameter, so it has no definition outside the + loop and the body self-reference (``dest == operand``) is its only + definition; the low vreg count keeps partial unrolls runnable through + today's backend. + """ + builder = IRBuilder() + acc = builder.make_value( + name="acc", dtype=DataType.INT32, is_constant=False) + builder.new_function("main", params=[acc]) + builder.new_block("entry") + one = _make_const(builder, "one", 1) + builder.for_loop(0, end) + builder._emit(OpCode.ADD, acc, [acc, one]) + builder.endfor() + builder.ret(acc) + return builder.program + + +def build_carried_param_program(end: int, start: int = 0): + """Probe-style carried accumulator: ``acc = acc + (iv + one)``. + + ``acc`` is a function parameter, so it is never defined before the loop + and the body self-reference is the only definition of ``acc``. + """ + builder = IRBuilder() + acc = builder.make_value( + name="acc", dtype=DataType.INT32, is_constant=False) + builder.new_function("main", params=[acc]) + builder.new_block("entry") + one = _make_const(builder, "one", 1) + iv = builder.for_loop(start, end) + t = builder.add(iv, one) + builder._emit(OpCode.ADD, acc, [acc, t]) + builder.endfor() + builder.ret(acc) + return builder.program + + +def build_forward_ref_program(end: int): + """Body reads ``u`` before defining it, then defines ``u`` (forward).""" + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + u = builder.make_value(name="u", dtype=DataType.INT32, is_constant=False) + one = _make_const(builder, "one", 1) + iv = builder.for_loop(0, end) + t = builder.add(u, one) + builder._emit(OpCode.ADD, u, [iv, one]) + builder.endfor() + builder.ret(t) + return builder.program + + +# ═══════════════════════════════════════════════════════════════════════════ +# Helpers (documented as TestCaseHelpers in the design document) +# ═══════════════════════════════════════════════════════════════════════════ + +def count_ir(program: Program) -> int: + """Total number of IR instructions across all functions/blocks.""" + return sum( + len(block.instructions) + for func in program.functions + for block in func.blocks + ) + + +def count_for_markers(program: Program) -> int: + return sum( + 1 + for func in program.functions + for block in func.blocks + for ins in block.instructions + if ins.opcode in (OpCode.FOR, OpCode.ENDFOR) + ) + + +def emit_asm(program: Program) -> str: + from scratchv.backend.asm_emit import AsmEmitter + from scratchv.backend.instruction_select import InstructionSelector + from scratchv.backend.register_alloc import RegisterAllocator + + machine = InstructionSelector(program).run() + allocated = RegisterAllocator(machine, mode="greedy").run() + return AsmEmitter(allocated).emit() + + +def run_asm_text(asm: str) -> tuple[int, int]: + """Assemble and run *asm*; return ``(a0, dynamic_instruction_count)``.""" + from scratchv.backend.riscv_encoder import assemble_to_binary + from scratchv.simulator.rv32_emulator import REG_ID, RV32Emulator + + binary = assemble_to_binary(asm) + emulator = RV32Emulator() + emulator.load_code(bytes(binary)) + dynamic = emulator.run() + return emulator.regs[REG_ID["a0"]], dynamic + + +def run_rv32(program: Program) -> tuple[int, int]: + """Run the compiled program; return ``(a0, dynamic_instruction_count)``.""" + return run_asm_text(emit_asm(program)) + + +def verify_errors(program: Program): + return [ + issue for issue in IRVerifier(program).verify() + if issue.level == ErrorLevel.ERROR + ] + + +def fingerprint(program: Program): + return [ + ( + id(ins), + ins.opcode, + ins.dest.name if ins.dest is not None else None, + tuple(op.name for op in ins.operands), + tuple(sorted(ins.attrs.items(), key=lambda kv: kv[0])), + ins.target, + ) + for func in program.functions + for block in func.blocks + for ins in block.instructions + ] + + +def block_instructions(program: Program): + return program.functions[0].blocks[0].instructions + + +def find_for_indices(program: Program): + return [ + (i, ins) for i, ins in enumerate(block_instructions(program)) + if ins.opcode == OpCode.FOR + ] + + +# ═══════════════════════════════════════════════════════════════════════════ +# Case 1: full unrolling +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLoopUnrollFull: + def test_full_unroll_iv_used(self): + program = build_case_program(4, use_acc=True) + iv_name = find_for_indices(program)[0][1].dest.name + assert count_ir(program) == 6 + + unroll = LoopUnroll(program) + assert unroll.run() == 1 + + assert count_for_markers(program) == 0 + instructions = block_instructions(program) + # 12 instructions replace FOR..ENDFOR (4 copies x (bind + 2 body)). + assert count_ir(program) == 14 + assert len(instructions) == 14 + + bindings = [ + ins for ins in instructions if ins.opcode == OpCode.LOAD_CONST + ][1:] + assert [ins.attrs["value"] for ins in bindings] == [0, 1, 2, 3] + for ins in bindings: + assert ins.dest.dtype == DataType.INT32 + assert ins.dest.is_constant is False + # last copy reuses the original induction-variable name + assert bindings[-1].dest.name == iv_name + # body values reuse their original names on the last copy + dests = [ins.dest.name for ins in instructions if ins.dest is not None] + assert dests[-3] == iv_name + assert instructions[-1].opcode == OpCode.RETURN + assert emit_asm(program) + assert verify_errors(program) == [] + + stats = unroll.stats + assert stats["full_unrolls"] == 1 + assert stats["partial_unrolls"] == 0 + assert stats["loops_seen"] == 1 + assert stats["instructions_added"] > 0 + + # idempotent: second run changes nothing + before = fingerprint(program) + assert unroll.run() == 0 + assert fingerprint(program) == before + + def test_full_unroll_dynamic_equivalence(self): + baseline = build_case_program(4, use_acc=True) + a0_before, dynamic_before = run_rv32(baseline) + + optimized = build_case_program(4, use_acc=True) + LoopUnroll(optimized).run() + a0_after, dynamic_after = run_rv32(optimized) + + assert a0_before == a0_after == 4 + assert dynamic_before == 30 + assert dynamic_after == 15 + assert dynamic_after < dynamic_before + + def test_full_unroll_iv_used_after_loop(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + one = _make_const(builder, "one", 1) + iv = builder.for_loop(0, 3) + v2 = builder.add(iv, one) + builder.endfor() + v4 = builder.add(iv, v2) + builder.ret(v4) + + unroll = LoopUnroll(builder.program) + assert unroll.run() == 1 + + instructions = block_instructions(builder.program) + finals = [ + ins for ins in instructions + if ins.opcode == OpCode.LOAD_CONST + and ins.dest.name.startswith(iv.name + "_final") + ] + assert len(finals) == 1 + assert finals[0].attrs["value"] == 3 + assert instructions[-2].operands[0].name == finals[0].dest.name + assert verify_errors(builder.program) == [] + + +# ═══════════════════════════════════════════════════════════════════════════ +# Case 2: partial exact unrolling +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLoopUnrollPartial: + def test_partial_exact_divisor(self): + program = build_case_program(6, use_acc=True) + iv_name = find_for_indices(program)[0][1].dest.name + + unroll = LoopUnroll(program, full_threshold=2) + assert unroll.run() == 1 + + for_indices = find_for_indices(program) + assert len(for_indices) == 1 + for_ins = for_indices[0][1] + assert for_ins.attrs["start"] == 0 + assert for_ins.attrs["end"] == 2 + assert for_ins.attrs["step"] == 1 + assert for_ins.attrs["unrolled"] == 3 + + instructions = block_instructions(program) + # setup 2 consts + FOR + 9 group instrs + ENDFOR + assert count_ir(program) == 15 + assert instructions[1].opcode == OpCode.LOAD_CONST + assert instructions[1].attrs["value"] == 3 + assert instructions[2].opcode == OpCode.LOAD_CONST + assert instructions[2].attrs["value"] == 1 + + for_idx = for_indices[0][0] + first_bind = instructions[for_idx + 1] + assert first_bind.opcode == OpCode.MUL + assert first_bind.dest.name.startswith(iv_name + "__") + assert first_bind.operands[0].name == iv_name + assert first_bind.operands[1].name == instructions[1].dest.name + + chain = [ + ins for ins in instructions[for_idx + 2:] + if ins.opcode == OpCode.ADD + and ins.dest.name.startswith(iv_name + "__") + and ins.operands[0].name.startswith(iv_name + "__") + ] + assert len(chain) == 2 + assert chain[0].operands[0].name == first_bind.dest.name + assert chain[1].operands[0].name == chain[0].dest.name + + assert verify_errors(program) == [] + assert unroll.stats["partial_unrolls"] == 1 + assert unroll.stats["partial_epilogues"] == 0 + + before = fingerprint(program) + assert unroll.run() == 0 + assert fingerprint(program) == before + + def test_partial_exact_dynamic_equivalence(self): + baseline = build_low_pressure_program(6) + a0_before, dynamic_before = run_rv32(baseline) + + optimized = build_low_pressure_program(6) + unroll = LoopUnroll(optimized, full_threshold=2) + assert unroll.run() == 1 + a0_after, dynamic_after = run_rv32(optimized) + + assert a0_before == a0_after == 6 + assert dynamic_before == 36 + assert dynamic_after == 28 + assert dynamic_after < dynamic_before + + def test_partial_exact_nonzero_start(self): + baseline = build_low_pressure_program(14, start=5) + a0_before, dynamic_before = run_rv32(baseline) + + optimized = build_low_pressure_program(14, start=5) + iv_name = find_for_indices(optimized)[0][1].dest.name + unroll = LoopUnroll(optimized, full_threshold=2) + assert unroll.run() == 1 + a0_after, dynamic_after = run_rv32(optimized) + + assert a0_before == a0_after == 14 + assert dynamic_before == 51 + assert dynamic_after == 42 + assert dynamic_after < dynamic_before + + instructions = block_instructions(optimized) + for_idx = find_for_indices(optimized)[0][0] + mul = instructions[for_idx + 1] + add_start = instructions[for_idx + 2] + assert mul.opcode == OpCode.MUL + assert mul.operands[0].name == iv_name + assert add_start.opcode == OpCode.ADD + assert add_start.operands[0].name == mul.dest.name + start_const = next( + ins for ins in instructions[:for_idx] + if ins.opcode == OpCode.LOAD_CONST + and ins.attrs["value"] == 5) + assert add_start.operands[1].name == start_const.dest.name + body = instructions[for_idx + 3] + assert body.opcode == OpCode.ADD + assert body.operands[0].name == add_start.dest.name + + +# ═══════════════════════════════════════════════════════════════════════════ +# Case 3: epilogue (remainder loop) partial unrolling +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLoopUnrollEpilogue: + def test_partial_epilogue(self): + program = build_case_program(7, use_acc=True) + return_ins = block_instructions(program)[-1] + canonical_v3 = return_ins.operands[0].name + + unroll = LoopUnroll(program, full_threshold=2, epilogue=True) + assert unroll.run() == 1 + assert unroll.stats["partial_epilogues"] == 1 + + for_indices = find_for_indices(program) + assert len(for_indices) == 2 + main_for = for_indices[0][1] + epilogue_for = for_indices[1][1] + assert main_for.attrs["end"] == 1 + assert main_for.attrs["unrolled"] == 6 + assert epilogue_for.attrs["start"] == 6 + assert epilogue_for.attrs["end"] == 7 + assert epilogue_for.attrs["step"] == 1 + # the remainder loop carries the idempotency marker too (F5) + assert epilogue_for.attrs["unrolled"] == 1 + + # setup 2 + main (FOR + 18 + ENDFOR) + epilogue (FOR + 2 + ENDFOR) + assert count_ir(program) == 28 + assert return_ins.operands[0].name == canonical_v3 + "__ep" + assert verify_errors(program) == [] + names = [ins.dest.name for ins in block_instructions(program) + if ins.dest is not None] + assert len(names) == len(set(names)) + + def test_epilogue_not_emitted_when_exact(self): + program = build_case_program(6, use_acc=True) + unroll = LoopUnroll(program, full_threshold=2, epilogue=True) + assert unroll.run() == 1 + assert unroll.stats["partial_epilogues"] == 0 + assert len(find_for_indices(program)) == 1 + + def test_partial_epilogue_dynamic_equivalence(self): + baseline = build_low_pressure_program(7) + a0_before, dynamic_before = run_rv32(baseline) + + optimized = build_low_pressure_program(7) + unroll = LoopUnroll(optimized, full_threshold=2, epilogue=True) + assert unroll.run() == 1 + a0_after, dynamic_after = run_rv32(optimized) + + assert a0_before == a0_after == 7 + assert dynamic_before == 41 + assert dynamic_after == 32 + assert dynamic_after < dynamic_before + + +# ═══════════════════════════════════════════════════════════════════════════ +# Case 3b: true loop-carried values (dest == operand) — F1/F3 +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLoopUnrollCarriedValues: + """Simulation coverage for genuine loop-carried values. + + ``build_carried_param_simple_program`` keeps the vreg count below the point + where the known encoder temp-register fallback corrupts partial + unrolls (an existing backend defect, not fixed in this topic), so the + partial/exact and epilogue numbers below are checked end-to-end. + """ + + def test_full_unroll_carried_value_simulation(self): + baseline = build_carried_param_program(4) + a0_before, dynamic_before = run_rv32(baseline) + + optimized = build_carried_param_program(4) + unroll = LoopUnroll(optimized) + assert unroll.run() == 1 + assert unroll.stats["full_unrolls"] == 1 + a0_after, dynamic_after = run_rv32(optimized) + + assert a0_before == a0_after == 10 # sum(1..4) + assert dynamic_after < dynamic_before + assert verify_errors(optimized) == [] + + def test_partial_exact_carried_value_simulation(self): + baseline = build_carried_param_simple_program(6) + a0_before, dynamic_before = run_rv32(baseline) + + optimized = build_carried_param_simple_program(6) + unroll = LoopUnroll( + optimized, full_threshold=2, max_factor=2) + assert unroll.run() == 1 + assert unroll.stats["partial_unrolls"] == 1 + assert unroll.stats["partial_epilogues"] == 0 + a0_after, dynamic_after = run_rv32(optimized) + + assert a0_before == a0_after == 6 # one increment per iteration + assert dynamic_after < dynamic_before + assert verify_errors(optimized) == [] + + def test_epilogue_r1_carried_value_simulation(self): + baseline = build_carried_param_simple_program(5) + a0_before, dynamic_before = run_rv32(baseline) + + optimized = build_carried_param_simple_program(5) + unroll = LoopUnroll( + optimized, full_threshold=2, max_factor=2, epilogue=True) + assert unroll.run() == 1 + assert unroll.stats["partial_epilogues"] == 1 + a0_after, dynamic_after = run_rv32(optimized) + + assert a0_before == a0_after == 5 # one increment per iteration + assert dynamic_after < dynamic_before + assert verify_errors(optimized) == [] + + def test_epilogue_r_gt1_carried_value_is_skipped(self): + """F1: never emit the stale-value epilogue; leave the loop alone.""" + program = build_carried_param_program(11) + before = fingerprint(program) + + unroll = LoopUnroll(program, full_threshold=2, epilogue=True) + assert unroll.run() == 0 + assert fingerprint(program) == before + assert unroll.stats["skipped"]["carried_value"] == 1 + assert unroll.stats["loops_unrolled"] == 0 + + a0, _ = run_rv32(program) + assert a0 == 66 # sum(1..11), the pre-pass semantics + + def test_epilogue_r_gt1_forward_reference_is_skipped(self): + program = build_forward_ref_program(11) + before = fingerprint(program) + + unroll = LoopUnroll(program, full_threshold=2, epilogue=True) + assert unroll.run() == 0 + assert fingerprint(program) == before + assert unroll.stats["skipped"]["carried_value"] == 1 + + def test_epilogue_skips_only_carried_shapes(self): + """The new guard must not reject carried-free epilogue loops.""" + program = build_low_pressure_program(11) + unroll = LoopUnroll(program, full_threshold=2, epilogue=True) + assert unroll.run() == 1 + assert unroll.stats["partial_epilogues"] == 1 + assert unroll.stats["skipped"]["carried_value"] == 0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Case 4: negative scenarios (IR must stay untouched) +# ═══════════════════════════════════════════════════════════════════════════ + +class TestLoopUnrollNegative: + def test_skip_step_not_one(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + one = _make_const(builder, "one", 1) + iv = builder.for_loop(0, 4, step=2) + builder.add(iv, one) + builder.endfor() + builder.ret() + + unroll = LoopUnroll(builder.program) + before = fingerprint(builder.program) + assert unroll.run() == 0 + assert fingerprint(builder.program) == before + assert unroll.stats["skipped"]["step_not_one"] == 1 + + def test_skip_unpaired(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + one = _make_const(builder, "one", 1) + outer = builder.for_loop(0, 2) + inner = builder.for_loop(0, 2) + builder.add(inner, one) + builder.endfor() + builder.add(outer, one) + builder.ret() + + unroll = LoopUnroll(builder.program) + before = fingerprint(builder.program) + assert unroll.run() == 0 + assert fingerprint(builder.program) == before + assert unroll.stats["skipped"]["unpaired"] >= 1 + + def test_skip_body_too_large(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + one = _make_const(builder, "one", 1) + iv = builder.for_loop(0, 4) + for i in range(100): + builder.add(one, one) + builder.endfor() + builder.ret(iv) + + unroll = LoopUnroll(builder.program, body_limit=64) + before = fingerprint(builder.program) + assert unroll.run() == 0 + assert fingerprint(builder.program) == before + assert unroll.stats["skipped"]["body_too_large"] == 1 + + def test_skip_iv_redefined(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + one = _make_const(builder, "one", 1) + iv = builder.for_loop(0, 4) + # `iv` redefined inside the body: group-counter semantics break. + builder._emit(OpCode.ADD, iv, [iv, one]) + builder.endfor() + builder.ret() + + unroll = LoopUnroll(builder.program) + before = fingerprint(builder.program) + assert unroll.run() == 0 + assert fingerprint(builder.program) == before + assert unroll.stats["skipped"]["multi_def"] == 1 + + def test_skip_counts_stable_across_rescans(self): + """F4: rescanning after each unroll must not inflate skip counters.""" + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + one = _make_const(builder, "one", 1) + for _ in range(3): + iv = builder.for_loop(0, 4, step=2) + builder.add(iv, one) + builder.endfor() + iv4 = builder.for_loop(0, 4) + builder.add(iv4, one) + builder.endfor() + builder.ret() + + unroll = LoopUnroll(builder.program) + assert unroll.run() == 1 + assert unroll.stats["skipped"]["step_not_one"] == 3 + assert unroll.stats["loops_seen"] == 4 + + +class TestLoopUnrollIdempotency: + def test_epilogue_remainder_loop_is_idempotent(self): + """F5: the generated remainder loop carries the marker too.""" + program = build_low_pressure_program(23) + unroll = LoopUnroll(program, full_threshold=2, epilogue=True) + assert unroll.run() == 1 + assert unroll.stats["partial_epilogues"] == 1 + + markers = [ + ins.attrs.get("unrolled") for _, ins in find_for_indices(program) + ] + assert markers == [8, 1] + + before = fingerprint(program) + assert unroll.run() == 0 + assert fingerprint(program) == before + assert unroll.stats["loops_unrolled"] == 0 + + +class _ExplodingLoopUnroll(LoopUnroll): + """Applies a plan, then raises to exercise the rollback path.""" + + def _apply_unroll(self, func, block, for_idx, endfor_idx, plan): + super()._apply_unroll(func, block, for_idx, endfor_idx, plan) + raise RuntimeError("injected failure") + + +class TestLoopUnrollRollback: + def test_failed_apply_restores_operands(self): + """F6: a mid-rewrite exception must restore operands as well.""" + program = build_case_program(7, use_acc=True) + return_ins = block_instructions(program)[-1] + canonical = return_ins.operands[0] + before = fingerprint(program) + + unroll = _ExplodingLoopUnroll( + program, full_threshold=2, epilogue=True) + assert unroll.run() == 0 + assert unroll.stats["skipped"]["internal_error"] == 1 + assert fingerprint(program) == before + # _redirect_uses rewrote this operand to ``v3__ep`` before the + # failure; the snapshot must bring the original value back. + restored = block_instructions(program)[-1].operands[0] + assert restored is canonical + assert restored.name == "v_3" + + +# ═══════════════════════════════════════════════════════════════════════════ +# Case 5: nested loops, inner first +# ═══════════════════════════════════════════════════════════════════════════ + +class _RecordingLoopUnroll(LoopUnroll): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.plan_sequence: list[tuple[str, int]] = [] + + def _apply_unroll(self, func, block, for_idx, endfor_idx, plan): + self.plan_sequence.append((plan.mode, plan.U)) + return super()._apply_unroll(func, block, for_idx, endfor_idx, plan) + + +class TestLoopUnrollNested: + def _build_nested(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + x = builder.make_value(name="x", dtype=DataType.INT32) + y = builder.make_value(name="y", dtype=DataType.INT32) + acc = builder.make_value(name="acc", dtype=DataType.INT32) + builder.for_loop(0, 2) + builder.for_loop(0, 3) + t = builder.add(x, y) + a2 = builder.add(acc, t) + builder.endfor() + builder.endfor() + builder.ret(a2) + return builder.program + + def test_inner_first(self): + program = self._build_nested() + unroll = _RecordingLoopUnroll(program) + assert unroll.run() == 2 + assert unroll.stats["full_unrolls"] == 2 + assert unroll.plan_sequence == [("full", 3), ("full", 2)] + + assert count_for_markers(program) == 0 + body = [ + ins for ins in block_instructions(program) + if ins.opcode != OpCode.RETURN + ] + assert len(body) == 12 + assert verify_errors(program) == [] + + +# ═══════════════════════════════════════════════════════════════════════════ +# Helper tests + integration +# ═══════════════════════════════════════════════════════════════════════════ + +class TestCaseHelpers: + def test_count_ir_and_run_rv32(self): + program = build_low_pressure_program(4) + assert count_ir(program) == 5 + a0, dynamic = run_rv32(program) + assert a0 == 4 + assert dynamic > 0 + + def test_skip_unprofitable_partial(self): + program = build_low_pressure_program(5) + unroll = LoopUnroll(program, full_threshold=2) + assert unroll.run() == 0 + assert unroll.stats["skipped"]["no_factor"] == 1 + + +class TestLoopUnrollIntegration: + DSL_SOURCE = ( + "for i = 0, 4\n" + " t = add(i, one)\n" + " acc = add(acc, t)\n" + "endfor\n" + "return acc\n" + ) + + def test_compiler_driver_runs_unroll(self, tmp_path): + from scratchv.compiler import CompilerConfig, CompilerDriver + + driver = CompilerDriver(CompilerConfig( + optimize_level="all", dump_ir=True, reg_alloc="greedy", + loop_unroll=True)) + result = driver.compile( + "", str(tmp_path / "on.s"), dsl_source=self.DSL_SOURCE) + assert result.success + after = result.ir_dump.split("--- IR Dump (after")[1] + assert "endfor" not in after + stats = result.stats["passes"]["loop-unroll"] + assert stats["loops_unrolled"] == 1 + assert stats["full_unrolls"] == 1 + + def test_no_loop_unroll_keeps_markers(self, tmp_path): + from scratchv.compiler import CompilerConfig, CompilerDriver + + driver = CompilerDriver(CompilerConfig( + optimize_level="all", dump_ir=True, reg_alloc="greedy", + loop_unroll=False)) + result = driver.compile( + "", str(tmp_path / "off.s"), dsl_source=self.DSL_SOURCE) + assert result.success + after = result.ir_dump.split("--- IR Dump (after")[1] + assert "endfor" in after + assert "loop-unroll" not in result.stats["passes"] + + def test_cli_flag_mapping(self): + from scratchv.main import args_to_config, build_arg_parser + + parser = build_arg_parser() + defaults = args_to_config(parser.parse_args(["input.dsl"])) + # opt-in by default: the greedy allocator reload defect (B2) makes + # unrolling unsafe as a default at optimize_level "all" + assert defaults.loop_unroll is False + assert defaults.unroll_max_factor == 8 + assert defaults.unroll_full_threshold == 8 + assert defaults.unroll_body_limit == 64 + assert defaults.unroll_max_growth == 512 + assert defaults.unroll_epilogue is False + + opt_in = args_to_config( + parser.parse_args(["input.dsl", "--loop-unroll"])) + assert opt_in.loop_unroll is True + + args = args_to_config(parser.parse_args([ + "input.dsl", "--no-loop-unroll", "--unroll-factor", "4", + "--unroll-full-threshold", "2", "--unroll-body-limit", "32", + "--unroll-max-growth", "100", "--unroll-epilogue", + ])) + assert args.loop_unroll is False + assert args.unroll_max_factor == 4 + assert args.unroll_full_threshold == 2 + assert args.unroll_body_limit == 32 + assert args.unroll_max_growth == 100 + assert args.unroll_epilogue is True + + # last flag wins when both are given + both_off = args_to_config(parser.parse_args( + ["input.dsl", "--loop-unroll", "--no-loop-unroll"])) + assert both_off.loop_unroll is False + both_on = args_to_config(parser.parse_args( + ["input.dsl", "--no-loop-unroll", "--loop-unroll"])) + assert both_on.loop_unroll is True + + def test_no_loop_unroll_matches_default_on_loop_free_program( + self, tmp_path): + from scratchv.compiler import CompilerConfig, CompilerDriver + + source = "a = add(x, y)\nreturn a\n" + common = dict(optimize_level="all", reg_alloc="greedy") + on = CompilerDriver(CompilerConfig(**common)).compile( + "", str(tmp_path / "on.s"), dsl_source=source) + off = CompilerDriver(CompilerConfig( + loop_unroll=False, **common)).compile( + "", str(tmp_path / "off.s"), dsl_source=source) + assert on.success and off.success + assert on.output_text == off.output_text + + +class TestLoopUnrollDefaultOff: + """F2: unrolling is opt-in until the allocator reload defect is fixed. + + The default ``--optimize all`` pipeline must keep compiling the + pre-topic way; ``--loop-unroll`` / ``loop_unroll=True`` opts in and is + exercised on a low-pressure loop where the backend is still correct. + """ + + HIGH_PRESSURE_DSL = ( + "for i = 0, 12\n" + " t = add(i, one)\n" + " u = add(t, one)\n" + " v = add(u, one)\n" + "endfor\n" + "return v\n" + ) + + LOW_PRESSURE_DSL = ( + "for i = 0, 6\n" + " t = add(i, one)\n" + "endfor\n" + "return t\n" + ) + + def test_config_default_is_off(self): + from scratchv.compiler import CompilerConfig + + assert CompilerConfig().loop_unroll is False + + def test_default_flags_compile_high_pressure_loop_correctly( + self, tmp_path): + from scratchv.compiler import CompilerConfig, CompilerDriver + + driver = CompilerDriver(CompilerConfig( + optimize_level="all", dump_ir=True, reg_alloc="greedy")) + result = driver.compile( + "", str(tmp_path / "default.s"), + dsl_source=self.HIGH_PRESSURE_DSL) + assert result.success + assert "loop-unroll" not in result.stats["passes"] + assert "endfor" in result.ir_dump.split("--- IR Dump (after")[1] + + a0, _ = run_asm_text(result.output_text) + # ``one`` is a free (never defined) value read as 0, so the loop + # yields i + 3*0 = 11; re-enabling unroll by default before the + # allocator is fixed made the greedy backend return 10 here. + assert a0 == 11 + + def test_opt_in_unroll_is_correct_and_faster_on_low_pressure( + self, tmp_path): + from scratchv.compiler import CompilerConfig, CompilerDriver + + common = dict(optimize_level="all", reg_alloc="greedy") + off = CompilerDriver(CompilerConfig(**common)).compile( + "", str(tmp_path / "off.s"), dsl_source=self.LOW_PRESSURE_DSL) + on = CompilerDriver(CompilerConfig( + loop_unroll=True, **common)).compile( + "", str(tmp_path / "on.s"), dsl_source=self.LOW_PRESSURE_DSL) + assert off.success and on.success + assert "loop-unroll" not in off.stats["passes"] + assert "loop-unroll" in on.stats["passes"] + + a0_off, dyn_off = run_asm_text(off.output_text) + a0_on, dyn_on = run_asm_text(on.output_text) + assert a0_off == a0_on == 5 + assert dyn_on < dyn_off + + +class TestNoLoopUnrollGolden: + """F3: byte-level pre-topic compatibility on real benchmark cases.""" + + CASES = ("013_for_sum", "014_for_dot", "019_nested_loop") + + def test_cli_matches_baseline_golden(self, tmp_path): + from scratchv.main import main + + root = Path(__file__).parents[1] + golden_dir = Path(__file__).parent / "golden" + for case in self.CASES: + source = root / "benchmarks" / "cases" / f"{case}.dsl" + # ``.golden`` suffix: plain ``*.s`` is gitignored as build output + expected = (golden_dir / f"{case}.s.golden").read_text() + + off = tmp_path / f"{case}_off.s" + assert main([ + str(source), "--optimize", "all", "--no-loop-unroll", + "--count-instr", "-o", str(off), + ]) == 0 + assert off.read_text() == expected + + # default flags (opt-in off) must reproduce the same bytes + default = tmp_path / f"{case}_default.s" + assert main([ + str(source), "--optimize", "all", + "--count-instr", "-o", str(default), + ]) == 0 + assert default.read_text() == expected + + +# ═══════════════════════════════════════════════════════════════════════════ +# Plan selection unit checks +# ═══════════════════════════════════════════════════════════════════════════ + +class TestUnrollPlanSelection: + def test_growth_limit_skips(self): + program = build_low_pressure_program(7) + unroll = LoopUnroll( + program, full_threshold=2, epilogue=True, max_growth=2) + assert unroll.run() == 0 + assert unroll.stats["skipped"]["growth_limit"] >= 1 + + def test_bad_attrs_skips(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + iv = builder.for_loop(0, 4) + builder.endfor() + builder.ret(iv) + builder.program.functions[0].blocks[0].instructions[0].attrs = { + "start": "0", "end": 4, "step": 1} + + unroll = LoopUnroll(builder.program) + assert unroll.run() == 0 + assert unroll.stats["skipped"]["bad_attrs"] == 1 + + def test_select_plan_returns_dataclass(self): + program = build_low_pressure_program(4) + unroll = LoopUnroll(program) + pairs = unroll._find_pairs(block_instructions(program)) + plan = unroll._select_plan(block_instructions(program), *pairs[0]) + assert isinstance(plan, UnrollPlan) + assert plan.mode == "full" + assert plan.U == 4 diff --git a/tests/test_loop_unroll_case_report.py b/tests/test_loop_unroll_case_report.py new file mode 100644 index 0000000..b8bf294 --- /dev/null +++ b/tests/test_loop_unroll_case_report.py @@ -0,0 +1,112 @@ +"""Tests for the Topic 10 loop-unroll feature case report. + +The report is the CI artifact that proves the unroll pass is wired through +the configured compiler pipeline, actually transforms the case, and keeps +architectural results identical under the RV32 emulator. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from benchmarks.run_topic10_unroll_case import ( + EXPECTED_RESULT, + SCHEMA_VERSION, + _measure_side, + evaluate, + main, + measure_wiring, +) + +CASE = ( + Path(__file__).resolve().parents[1] + / "benchmarks" / "cases" / "topic10_unroll_feature.dsl" +) + + +def test_case_program_runs_expected_result(): + off = _measure_side(unroll=False, repeats=1) + assert off["execution"]["a0"] == EXPECTED_RESULT + assert off["execution"]["dynamic_instructions"] > 0 + assert off["loops_unrolled"] == 0 + + +def test_measure_side_reports_unroll_metrics(): + on = _measure_side(unroll=True, repeats=1) + assert on["loops_unrolled"] == 1 + assert on["unroll_stats"]["full_unrolls"] == 1 + assert on["unroll_stats"]["instructions_after"] > ( + on["unroll_stats"]["instructions_before"]) + assert on["pass_time_ms"] >= 0 + + +def test_measure_is_deterministic(): + first = _measure_side(unroll=True, repeats=1) + second = _measure_side(unroll=True, repeats=1) + assert first["execution"]["registers"] == second["execution"]["registers"] + assert ( + first["execution"]["dynamic_instructions"] + == second["execution"]["dynamic_instructions"] + ) + assert first["asm_instructions"] == second["asm_instructions"] + + +def test_wiring_reports_pass_presence(): + wiring = measure_wiring(CASE) + assert wiring["on_pass_present"] and not wiring["off_pass_present"] + assert wiring["on_pass_stats"]["loops_unrolled"] == 1 + + +def test_evaluate_passes_all_hard_checks(): + report = evaluate(CASE, repeats=1) + assert report["schema_version"] == SCHEMA_VERSION + assert report["hard_failures"] == [] + assert all(report["hard_checks"].values()) + assert report["honesty"] + + +def test_hard_check_gate_is_not_vacuous(monkeypatch): + """A disabled pipeline must be reported as a hard failure.""" + def fake_wiring(_case): + return { + "off_has_loop_markers": True, + "on_has_loop_markers": True, + "off_pass_present": False, + "on_pass_present": False, + "on_pass_stats": None, + } + + monkeypatch.setattr( + "benchmarks.run_topic10_unroll_case.measure_wiring", fake_wiring) + report = evaluate(CASE, repeats=1) + assert "pipeline_runs_pass_when_enabled" in report["hard_failures"] + assert "loop_markers_removed_when_enabled" in report["hard_failures"] + + +def test_main_writes_json_and_markdown(tmp_path, capsys): + json_path = tmp_path / "report.json" + md_path = tmp_path / "report.md" + exit_code = main([ + "--case", str(CASE), + "--json", str(json_path), + "--markdown", str(md_path), + "--repeats", "1", + ]) + assert exit_code == 0 + data = json.loads(json_path.read_text()) + assert data["hard_failures"] == [] + assert data["topic"] == "topic10-loop-unroll" + assert data["observed_registers"] == ["x10"] + markdown = md_path.read_text() + assert "Topic 10 Loop-Unroll Feature Case" in markdown + assert "Dynamic instructions" in markdown + assert capsys.readouterr().out + + +def test_main_rejects_missing_case(tmp_path): + with pytest.raises(SystemExit) as exc: + main(["--case", str(tmp_path / "missing.dsl")]) + assert exc.value.code == 2