diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15aae4b..5bac21d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,15 @@ jobs: run: | python3.12 -m pytest tests/test_pr37_regression.py -v --tb=short + - name: Run topic15 inliner regressions + run: | + python3.12 -m pytest \ + tests/test_inliner.py \ + tests/test_ir_call.py \ + tests/test_backend_call.py \ + tests/test_topic15_inline_case_report.py \ + -v --tb=short + - name: Generate test visualization page if: github.ref == 'refs/heads/main' run: | @@ -218,6 +227,14 @@ jobs: --json benchmark_reports/const_merge_report.json \ --markdown benchmark_reports/const_merge_report.md + # ── 3.1.2 课题15:函数内联 case 报告(A/B + 克隆/拒绝明细) ─────── + - name: Topic 15 inliner case report + run: | + mkdir -p benchmark_reports + python3.12 benchmarks/run_topic15_inline_case.py \ + --json benchmark_reports/inliner_report.json \ + --markdown benchmark_reports/inliner_report.md + # ── 3.2 DSL 用例编译 + 模拟基准 ──────────────────────────────────── - name: DSL case compilation benchmarks run: | @@ -363,6 +380,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/inliner_report.md ]; then + cat benchmark_reports/inliner_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/topic15_inline_feature.py b/benchmarks/cases/topic15_inline_feature.py new file mode 100644 index 0000000..ff0416f --- /dev/null +++ b/benchmarks/cases/topic15_inline_feature.py @@ -0,0 +1,110 @@ +"""Topic 15 function-inline feature case, built programmatically. + +The DSL and ONNX frontends never emit ``OpCode.CALL``, so this case is +constructed with :class:`IRBuilder` instead of a frontend source file. + +``build_program()`` (the eligible A/B case):: + + inc(x): # 3 instructions + c = 1 + t = x + c + return t + + main(): + a = 2 ; b = 3 + r1 = inc(a) # call site 0 -> clone namespace _inl0 + r2 = inc(b) # call site 1 -> clone namespace _inl1 + s = r1 + r2 + return s + +Expected after ``Inliner`` with the default fixed config: both CALLs are +gone, ``main`` gains two independent clones (``inc_entry_inl0`` / +``inc_entry_inl1``) with distinct renamed definitions in the ``_inl0`` / +``_inl1`` namespaces, every clone RETURN becomes ``br _inl{k}_cont`` +to the continuation block, and the callee ``inc`` still keeps its own +RETURN. Not running the pass must leave both CALLs in place. + +``build_rejected_program()`` (the conservative-rejection case):: + + heavy(p, v): # side-effect STORE + 9 instructions + store(p, v) + ... 6 constants ... + t = p + v + return t + + loopy(a): # body contains FOR/ENDFOR + for 0..4 ... + endfor + return a + + main(): + r1 = heavy(x, y) + r2 = loopy(x) + return r1 + r2 + +With ``InlinerConfig(max_instrs=4)`` both sites are refused +(``body_too_large`` for the oversized side-effecting callee, +``loop_body_unsupported`` for the loop-bodied one). The CALLs must stay in +place, one warning per site must be recorded, and the IR must be identical +afterwards. + +No claim is made about executing residual CALLs: the RISC-V CALL ABI is not +implemented in this branch. +""" + +from __future__ import annotations + +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Program + + +def build_program() -> Program: + """Eligible case: one callee, two rename-verified call sites.""" + b = IRBuilder() + x = b.make_value(name="x", dtype=DataType.FLOAT32) + b.new_function("inc", params=[x]) + b.new_block("entry") + one = b.load_const(1) + t = b.add(x, one) + b.ret(t) + + b.new_function("main") + b.new_block("entry") + a = b.make_const(2) + c = b.make_const(3) + r1 = b.call("inc", [a]) + r2 = b.call("inc", [c]) + s = b.add(r1, r2) + b.ret(s) + return b.program + + +def build_rejected_program() -> Program: + """Rejected case: oversized side-effecting callee + loop-bodied callee.""" + b = IRBuilder() + p = b.make_value(name="p", dtype=DataType.FLOAT32) + v = b.make_value(name="v", dtype=DataType.FLOAT32) + b.new_function("heavy", params=[p, v]) + b.new_block("entry") + b.store(p, v) + for _ in range(6): + b.load_const(1) + t = b.add(p, v) + b.ret(t) + + a = b.make_value(name="a", dtype=DataType.FLOAT32) + b.new_function("loopy", params=[a]) + b.new_block("entry") + b.for_loop(0, 4) + b.endfor() + b.ret(a) + + b.new_function("main") + b.new_block("entry") + x = b.make_const(1) + y = b.make_const(2) + r_heavy = b.call("heavy", [x, y]) + r_loopy = b.call("loopy", [x]) + s = b.add(r_heavy, r_loopy) + b.ret(s) + return b.program diff --git a/benchmarks/run_topic15_inline_case.py b/benchmarks/run_topic15_inline_case.py new file mode 100644 index 0000000..0461093 --- /dev/null +++ b/benchmarks/run_topic15_inline_case.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +"""Run the Topic 15 function-inline feature case and emit auditable reports. + +The DSL and ONNX frontends never emit ``OpCode.CALL``, so the case is built +programmatically (see ``benchmarks/cases/topic15_inline_feature.py``). The +report proves four separate facts on that deterministic IR: + +1. A/B: with the inliner off the two CALLs stay; with the inliner on (fixed + :class:`InlinerConfig`) both CALLs disappear; +2. each call site receives one independent clone: distinct ``_inl{k}`` block + and value namespaces, RETURN rewritten to ``br _inl{k}_cont``, + the callee's own RETURN preserved, and no ``IRVerifier`` ERROR left; +3. the transformation is deterministic: repeated runs produce identical IR + fingerprints; +4. the conservative rejection rules are real: an oversized side-effecting + callee and a loop-bodied callee keep their CALLs, the IR is unchanged and + every refusal is recorded as one warning. + +This is a deterministic feature/integration case, not a speedup claim. It +does not execute residual CALLs: the RISC-V CALL ABI is not implemented in +this branch. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import hashlib +import importlib.util +import json +import re +import statistics +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from scratchv.analysis.ir_verifier import ErrorLevel, IRVerifier +from scratchv.ir.types import BasicBlock, Function, OpCode, Program +from scratchv.optimizer.inliner import Inliner, InlinerConfig + +SCHEMA_VERSION = "topic15-inline-case/1" +DEFAULT_CASE = ( + Path(__file__).parent / "cases" / "topic15_inline_feature.py" +) +DEFAULT_JSON = Path("benchmark_reports/inliner_report.json") +DEFAULT_MARKDOWN = Path("benchmark_reports/inliner_report.md") +ELIGIBLE_CALLEE = "inc" +ELIGIBLE_CALL_SITES = 2 +REJECTED_CALL_SITES = 2 +REJECTED_MAX_INSTRS = 4 +_CLONE_SUFFIX = re.compile(r"_inl(\d+)") +HONESTY = ( + "Structural IR proof only. The DSL/ONNX frontends never emit CALL, so " + "the case is built programmatically; no execution-equivalence claim is " + "made because the RISC-V CALL ABI (prologue/epilogue, argument passing) " + "is not implemented in this branch -- residual CALLs are refused by the " + "backend or degraded to non-executable staging. Instruction counts are " + "static IR counts, not dynamic instruction totals or speedups, and the " + "inliner is opt-in and conservative (loops, oversize, recursion and " + "mixed return shapes are refused). Note: the IRVerifier label-existence " + "rule currently treats a residual CALL's function-name target as a block " + "label, so uninlined programs report one spurious ERROR per CALL; once " + "inlining removes every CALL the verifier reports no ERROR." +) + + +def default_inliner_config() -> InlinerConfig: + """Fixed config for the eligible A/B run (deterministic decisions).""" + return InlinerConfig( + max_instrs=32, + single_site_only=False, + growth_budget=256, + reject_loops=True, + allow_ret_drop=False, + max_rounds=4, + ) + + +def rejected_inliner_config() -> InlinerConfig: + """Config that refuses the oversized side-effecting callee.""" + return InlinerConfig(max_instrs=REJECTED_MAX_INSTRS) + + +def load_case_module(case_path: Path): + """Import the programmatic case module from *case_path*.""" + spec = importlib.util.spec_from_file_location( + "topic15_inline_feature", case_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import feature case: {case_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _iter_instructions(program: Program): + for func in program.functions: + for block in func.blocks: + for ins in block.instructions: + yield func, block, ins + + +def count_ir(program: Program) -> int: + return sum(1 for _ in _iter_instructions(program)) + + +def fingerprint(program: Program) -> str: + """Stable structural fingerprint (memory addresses never enter dump).""" + return hashlib.sha256(program.dump().encode("utf-8")).hexdigest() + + +def _function(program: Program, name: str) -> Function | None: + return next((f for f in program.functions if f.name == name), None) + + +def _body_size(func: Function) -> int: + return sum(len(b.instructions) for b in func.blocks) + + +def _clone_index(name: str) -> int | None: + match = _CLONE_SUFFIX.search(name) + return int(match.group(1)) if match else None + + +def _is_cont_block(name: str) -> bool: + return name.endswith("_cont") + + +def verifier_errors(program: Program) -> list[str]: + return [ + err.message + for err in IRVerifier(program).verify() + if err.level is ErrorLevel.ERROR + ] + + +def _duplicate_defined_names(func: Function) -> list[str]: + names = [p.name for p in func.params] + for block in func.blocks: + for ins in block.instructions: + if ins.dest is not None: + names.append(ins.dest.name) + seen: set[str] = set() + duplicates: list[str] = [] + for name in names: + if name in seen: + duplicates.append(name) + seen.add(name) + return sorted(set(duplicates)) + + +def _describe( + program: Program, + *, + callee: str | None = None, + stats: dict[str, int] | None = None, + warnings: list[str] | None = None, + pass_time_ms: float | None = None, +) -> dict[str, Any]: + """Summarize the (possibly transformed) program for the report.""" + blocks_by_index: dict[int, list[BasicBlock]] = {} + block_names: list[str] = [] + for func in program.functions: + for block in func.blocks: + block_names.append(block.name) + index = _clone_index(block.name) + if index is not None and not _is_cont_block(block.name): + blocks_by_index.setdefault(index, []).append(block) + + clones_detail: list[dict[str, Any]] = [] + clone_returns = 0 + for index in sorted(blocks_by_index): + dest_names: list[str] = [] + redirects: dict[str, str] = {} + returnless = True + for block in blocks_by_index[index]: + for ins in block.instructions: + if ins.opcode is OpCode.RETURN: + clone_returns += 1 + returnless = False + if ins.dest is not None: + dest_names.append(ins.dest.name) + last = block.instructions[-1] if block.instructions else None + if last is not None and last.opcode is OpCode.BR: + redirects[block.name] = last.target + clones_detail.append({ + "index": index, + "blocks": [b.name for b in blocks_by_index[index]], + "dest_names": dest_names, + "return_redirects": redirects, + "returnless": returnless, + }) + + calls = [ + ins for _f, _b, ins in _iter_instructions(program) + if ins.opcode is OpCode.CALL + ] + callee_func = _function(program, callee) if callee else None + returns_in_callee = 0 + if callee_func is not None: + returns_in_callee = sum( + 1 + for block in callee_func.blocks + for ins in block.instructions + if ins.opcode is OpCode.RETURN + ) + return { + "ir_instructions": count_ir(program), + "call_count": len(calls), + "call_targets": [ins.target for ins in calls], + "clone_count": len(blocks_by_index), + "clones": stats["inlined"] if stats is not None else 0, + "rejected": stats["rejected"] if stats is not None else 0, + "rounds": stats["rounds"] if stats is not None else 0, + "warnings": list(warnings or []), + "callee_body_size": ( + _body_size(callee_func) if callee_func is not None else None), + "returns_in_callee": returns_in_callee, + "clone_returns": clone_returns, + "clones_detail": clones_detail, + "block_names": block_names, + "duplicate_defined_names": { + func.name: _duplicate_defined_names(func) + for func in program.functions + }, + "verifier_errors": verifier_errors(program), + "pass_time_ms": ( + round(pass_time_ms, 4) if pass_time_ms is not None else None), + "fingerprint": fingerprint(program), + } + + +def _measure_uninlined(build: Callable[[], Program]) -> dict[str, Any]: + """Build the eligible case and never run the inliner.""" + return _describe(build(), callee=ELIGIBLE_CALLEE) + + +def _measure_inlined( + build: Callable[[], Program], repeats: int) -> dict[str, Any]: + """Build the eligible case and run the inliner *repeats* times.""" + program: Program | None = None + runner: Inliner | None = None + times: list[float] = [] + fingerprints: list[str] = [] + for _ in range(repeats): + program = build() + runner = Inliner(program, default_inliner_config()) + started = time.perf_counter() + runner.run() + times.append((time.perf_counter() - started) * 1000) + fingerprints.append(fingerprint(program)) + assert program is not None and runner is not None + described = _describe( + program, + callee=ELIGIBLE_CALLEE, + stats=runner.stats, + warnings=runner.warnings, + pass_time_ms=statistics.median(times), + ) + described["fingerprints"] = fingerprints + return described + + +def _measure_rejected( + build_rejected: Callable[[], Program]) -> dict[str, Any]: + """Run the inliner on the conservative-rejection case.""" + program = build_rejected() + before = fingerprint(program) + runner = Inliner(program, rejected_inliner_config()) + runner.run() + described = _describe( + program, + stats=runner.stats, + warnings=runner.warnings, + ) + described["max_instrs"] = REJECTED_MAX_INSTRS + described["dump_unchanged"] = fingerprint(program) == before + return described + + +def evaluate(case_path: Path, repeats: int) -> dict[str, Any]: + """Build the full report payload and run the hard invariants.""" + module = load_case_module(case_path) + off = _measure_uninlined(module.build_program) + on = _measure_inlined(module.build_program, repeats) + rejected = _measure_rejected(module.build_rejected_program) + + by_index = {d["index"]: d for d in on["clones_detail"]} + dests0 = set(by_index.get(0, {"dest_names": []})["dest_names"]) + dests1 = set(by_index.get(1, {"dest_names": []})["dest_names"]) + all_block_names = set(on["block_names"]) + clones_independent = ( + bool(dests0) and bool(dests1) + and not (dests0 & dests1) + and all(name.endswith("_inl0") for name in dests0) + and all(name.endswith("_inl1") for name in dests1) + and not any(on["duplicate_defined_names"].values()) + ) + returns_rewritten = ( + on["clone_returns"] == 0 + and len(on["clones_detail"]) == ELIGIBLE_CALL_SITES + and all(d["returnless"] for d in on["clones_detail"]) + and all( + len(d["return_redirects"]) == len(d["blocks"]) + and all(t in all_block_names + for t in d["return_redirects"].values()) + for d in on["clones_detail"] + ) + ) + rejected_call_count = REJECTED_CALL_SITES + hard_checks = { + "uninlined_keeps_both_calls": ( + off["call_count"] == ELIGIBLE_CALL_SITES), + "inlined_removes_all_calls": on["call_count"] == 0, + "inliner_clones_each_site": ( + on["clones"] == ELIGIBLE_CALL_SITES + and on["clone_count"] == ELIGIBLE_CALL_SITES + and on["rejected"] == 0 + ), + "clone_names_do_not_collide": clones_independent, + "clone_returns_rewritten_to_branches": returns_rewritten, + "callee_return_preserved": on["returns_in_callee"] == 1, + "verifier_clean_after_inline": on["verifier_errors"] == [], + "ir_grows_by_cloned_bodies": ( + on["clones"] == ELIGIBLE_CALL_SITES + and off["callee_body_size"] is not None + and on["ir_instructions"] - off["ir_instructions"] + == on["clones"] * off["callee_body_size"] + ), + "deterministic_across_runs": ( + len(on["fingerprints"]) == repeats + and len(set(on["fingerprints"])) == 1 + ), + "rejected_sites_keep_calls": ( + rejected["call_count"] == rejected_call_count + and rejected["rejected"] == rejected_call_count + and rejected["clones"] == 0 + ), + "rejected_warnings_recorded": ( + len(rejected["warnings"]) == rejected_call_count + and any("body_too_large" in w for w in rejected["warnings"]) + and any("loop_body_unsupported" in w + for w in rejected["warnings"]) + and all(w.startswith("inliner: skip") + for w in rejected["warnings"]) + ), + "rejected_program_unchanged": rejected["dump_unchanged"], + } + failed = sorted(name for name, ok in hard_checks.items() if not ok) + + return { + "schema_version": SCHEMA_VERSION, + "topic": "topic15-function-inline", + "generated_at": datetime.now(timezone.utc).isoformat(), + "case": str(case_path), + "eligible_call_sites": ELIGIBLE_CALL_SITES, + "config": dataclasses.asdict(default_inliner_config()), + "rejected_config": {"max_instrs": REJECTED_MAX_INSTRS}, + "runs": repeats, + "uninlined": off, + "inlined": on, + "rejected": rejected, + "hard_checks": hard_checks, + "hard_failures": failed, + "honesty": HONESTY, + } + + +def render_markdown(report: dict[str, Any]) -> str: + off, on, rejected = ( + report["uninlined"], report["inlined"], report["rejected"]) + cfg = report["config"] + total = len(report["hard_checks"]) + passed = total - len(report["hard_failures"]) + lines = [ + "# Topic 15 Function-Inline Feature Case", + "", + f"- Schema: `{report['schema_version']}`", + f"- Case: `{report['case']}` (programmatic IR; frontends never emit " + "CALL)", + f"- Generated: {report['generated_at']}", + f"- Fixed config: `max_instrs={cfg['max_instrs']}`, " + f"`single_site_only={cfg['single_site_only']}`, " + f"`growth_budget={cfg['growth_budget']}`, " + f"`reject_loops={cfg['reject_loops']}`, " + f"`max_rounds={cfg['max_rounds']}`; rejected branch uses " + f"`max_instrs={report['rejected_config']['max_instrs']}`", + f"- Hard checks: {'PASS' if not report['hard_failures'] else 'FAIL'} " + f"({passed}/{total})", + "", + "## A/B summary", + "", + "| Metric | inliner off | inliner on | delta |", + "|--------|------------:|-----------:|------:|", + f"| IR instructions | {off['ir_instructions']} | " + f"{on['ir_instructions']} | " + f"{on['ir_instructions'] - off['ir_instructions']:+d} |", + f"| CALL instructions | {off['call_count']} | {on['call_count']} | " + f"{on['call_count'] - off['call_count']:+d} |", + f"| Inlined call sites (clones) | {off['clones']} | {on['clones']} | " + f"{on['clones'] - off['clones']:+d} |", + f"| Rejected call sites | {off['rejected']} | {on['rejected']} | " + f"{on['rejected'] - off['rejected']:+d} |", + f"| Warnings | {len(off['warnings'])} | {len(on['warnings'])} | " + f"{len(on['warnings']) - len(off['warnings']):+d} |", + f"| Verifier ERRORs | {len(off['verifier_errors'])} | " + f"{len(on['verifier_errors'])} | " + f"{len(on['verifier_errors']) - len(off['verifier_errors']):+d} |", + f"| Pass time (ms, median) | n/a | " + f"{on['pass_time_ms']:.4f} | - |", + "", + "## Clone detail (inliner on)", + "", + ] + for detail in on["clones_detail"]: + rewrite = ", ".join( + f"`{block}` -> `{target}`" + for block, target in detail["return_redirects"].items() + ) or "none" + definitions = ", ".join( + f"`{name}`" for name in detail["dest_names"]) or "none" + blocks = ", ".join(f"`{name}`" for name in detail["blocks"]) or "none" + lines.append( + f"- clone `_inl{detail['index']}`: blocks {blocks}; " + f"renamed defs {definitions}; RETURN rewritten to {rewrite}" + ) + lines += [ + "", + "## Rejection detail (conservative v1, " + f"`max_instrs={rejected['max_instrs']}`)", + "", + f"- CALLs kept: {rejected['call_count']}; IR unchanged: " + f"{rejected['dump_unchanged']}; inlined={rejected['clones']}, " + f"rejected={rejected['rejected']}", + ] + lines += [f"- `{warning}`" for warning in rejected["warnings"]] + lines += [ + "", + "## Determinism", + "", + f"- inliner off fingerprint: `{off['fingerprint']}`", + "- inliner on fingerprints: " + + ", ".join(f"`{fp}`" for fp in on["fingerprints"]), + f"- rejected fingerprint: `{rejected['fingerprint']}`", + "", + "## 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/15-\345\207\275\346\225\260\345\206\205\350\201\224-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/15-\345\207\275\346\225\260\345\206\205\350\201\224-\345\274\200\345\217\221\346\226\207\346\241\243.md" new file mode 100644 index 0000000..7e716d5 --- /dev/null +++ "b/docs/topics/15-\345\207\275\346\225\260\345\206\205\350\201\224-\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -0,0 +1,712 @@ +# ScratchV 函数内联(IR 级)开发文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 涉及模块:`scratchv/ir/types.py`、`scratchv/ir/builder.py`、`scratchv/optimizer/inliner.py`(新增)、`scratchv/optimizer/__init__.py`、`scratchv/compiler.py`、`scratchv/main.py`、`scratchv/backend/instruction_select.py` +> 功能范围:IR 级内联 + 最小 CALL 表达;不含完整 ABI/多函数执行栈(详见《设计文档》4.7) +> 配套文档:同目录《设计文档.md》;课题来源 `docs/topics/15-函数内联.md` + +--- + +## 一、实现目标与改动清单 + +交付三件事: + +1. **IR 能表达 CALL**(`OpCode.CALL` + `IRBuilder.call`),且不改变任何既有指令的字段语义; +2. **Inliner pass 能变换**(合格调用点替换为独立克隆,拒绝规则量化且可复现); +3. **后端能最小降级或明确报错**(`jal ra, label`;ABI 未实现时的失败策略固定)。 + +改动顺序(强依赖,须按序提交): + +| 步骤 | 文件 | 内容 | +|------|------|------| +| 1 | `scratchv/ir/types.py` | 枚举末尾追加 `CALL`;新增 `is_call()` | +| 2 | `scratchv/ir/builder.py` | 新增 `IRBuilder.call()` | +| 3 | `tests/test_ir_call.py` | 锁定步骤 1/2 的契约 | +| 4 | `scratchv/optimizer/inliner.py` | 新增 `InlinerConfig`/`Inliner` | +| 5 | `scratchv/optimizer/__init__.py` | 导出 `Inliner`/`InlinerConfig` | +| 6 | `scratchv/compiler.py` | `CompilerConfig` 字段 + 管线集成 | +| 7 | `scratchv/main.py` | 4 个 CLI 参数 | +| 8 | `scratchv/backend/instruction_select.py` | `UnsupportedCallError` + `_select_call` | +| 9 | `tests/test_inliner.py`、`tests/test_backend_call.py` | 功能与回归用例 | + +--- + +## 二、接口契约(冻结) + +> 以下名称在课题 15 落地后不得更名/改签名;其他课题若需同名能力,须复用本契约或另起新名。 + +### 2.1 IR 层 + +| 契约 | 精确定义 | 位置 | +|------|----------|------| +| `OpCode.CALL` | 枚举成员,值为字符串 `"call"` | `scratchv/ir/types.py`,`OpCode` **末尾区段**(见 2.5) | +| `OpCode.is_call()` | `def is_call(self) -> bool: return self is OpCode.CALL` | 同上,判定方法区 | +| CALL 指令布局 | `dest: Value \| None`;`operands: list[Value]`(实参);`target: str`(callee 名);`attrs["argc"]: int`;`attrs["is_tail"]: bool`(默认 False) | 复用 `Instruction` 既有字段,**不加字段** | +| `IRBuilder.call` | `def call(self, callee: str, args: list[Value] \| None = None, has_ret: bool = True, dtype: DataType = DataType.FLOAT32, is_tail: bool = False) -> Value \| None` | `scratchv/ir/builder.py` | +| `IRBuilder.ret` | 既有:`def ret(self, val: Value \| None = None) -> Instruction` | 不变 | +| 不变式 | `attrs["argc"] == len(operands)`;`target` 命中 `Program.functions[].name` | 校验责任:Inliner(拒绝)+ 后续 verifier 扩展 | + +`call()` 行为(逐条): + +1. `args = list(args or [])`; +2. `dest = self.make_value(dtype=dtype) if has_ret else None`; +3. `self._emit(OpCode.CALL, dest, args, target=callee, argc=len(args), is_tail=is_tail)`; +4. `return dest`。 + +### 2.2 Pass 层 + +| 契约 | 精确定义 | +|------|----------| +| 模块 | `scratchv/optimizer/inliner.py` | +| `InlinerConfig` | `@dataclass`;字段:`max_instrs: int = 32`、`single_site_only: bool = False`、`growth_budget: int = 256`、`reject_loops: bool = True`、`allow_ret_drop: bool = False`、`max_rounds: int = 4` | +| `Inliner` | `def __init__(self, program: Program, config: InlinerConfig \| None = None)` | +| `Inliner.run` | `def run(self) -> int`:返回内联掉的调用点数(遵循 `_PassAdapter` 的“返回 changes”约定),原地修改 `program` | +| `Inliner.stats` | `@property def stats(self) -> dict[str, int]`;键固定为 `"inlined"`、`"rejected"`、`"rounds"` | +| pass 名 | `"inliner"`(`_PassAdapter` 名称,全小写连字符风格与既有 pass 一致) | +| 导出 | `scratchv/optimizer/__init__.py` 增加 `Inliner`、`InlinerConfig` 至 import 与 `__all__` | + +### 2.3 配置与 CLI + +| 契约 | 默认 | 位置 | +|------|------|------| +| `CompilerConfig.inline: bool` | `False` | `scratchv/compiler.py` | +| `CompilerConfig.inline_max_instrs: int` | `32` | 同上 | +| `CompilerConfig.inline_single_site: bool` | `False` | 同上 | +| `CompilerConfig.minimal_call_codegen: bool` | `False` | 同上 | +| `--inline` | `store_true` | `scratchv/main.py`,映射 `inline` | +| `--inline-max-instrs N` | `type=int, default=32` | 映射 `inline_max_instrs` | +| `--inline-single-site` | `store_true` | 映射 `inline_single_site` | +| `--minimal-call-codegen` | `store_true` | 映射 `minimal_call_codegen` | + +### 2.4 后端层 + +| 契约 | 精确定义 | 位置 | +|------|----------|------| +| `UnsupportedCallError` | `class UnsupportedCallError(NotImplementedError)`;消息固定以 `"CALL "` 开头 | `scratchv/backend/instruction_select.py` 模块级 | +| 构造 | `def __init__(self, program: Program, *, allow_uninlined_calls: bool = False)` | `InstructionSelector` | +| 选择器入口 | `_select_call(self, instr: Instruction) -> None`,由 `_select_instruction` 经 `instr.opcode.value == "call"` 动态分发 | 同上 | +| 降级产物 | `MV a{i} ← arg_i`(i=0..7)→ `JAL ra, ` → `MV dest ← a0` | 同上 | +| 报错策略 | 默认模式遇 CALL 即抛 `UnsupportedCallError`;`allow_uninlined_calls=True` 才降级 | 由 `CompilerConfig.minimal_call_codegen` 透传 | + +### 2.5 枚举扩展规约(与课题 28/29 防冲突) + +`scratchv/ir/types.py::OpCode` **只追加**,各区段按课题号顺序排列: + +``` +class OpCode(enum.Enum): + # ... 既有成员 ...(ADD..CONCAT,永不改动) + # ── Topic 15: interprocedural (2026-09-14) ── + CALL = "call" + # ── Topic 28: extended ops(预留,紧随 Topic 15 之后) ── + # ── Topic 29: SIMD ops(预留,紧随 Topic 28 之后) ── +``` + +硬性规则: + +1. 不插入既有区段、不重排、不改字符串值、不复用值(Enum alias 排查项); +2. `is_control_flow()` 的成员集合与行为**不变**;CALL 的能力查询走 `is_call()`; +3. 课题 28/29 后续新增 `OpCode`(例如 28 的 `SQRT`/`ABS`/`MIN`/`MAX`/`REM`、29 的 `VADD`/`VMUL` 等)必须追加在各自区段;本课题不预占这些名字的具体拼写,只冻结区段顺序; +4. `MachineOp.CALL`(`scratchv/backend/machine_types.py:58`)已存在,本课题**不新增** `MachineOp` 成员,也不修改其 emitter 行为。 + +--- + +## 三、IR 层实现 + +### 3.1 `scratchv/ir/types.py` + +在 `OpCode` 成员 `CONCAT = "concat"` 之后、`is_arith` 之前插入区段(保持其余代码不动): + +```python + CONCAT = "concat" + + # ── Topic 15: interprocedural (2026-09-14) ── + CALL = "call" + + def is_arith(self) -> bool: + ... +``` + +在 `is_control_flow()` 之后追加(不修改 `is_control_flow`): + +```python + def is_call(self) -> bool: + """True for the interprocedural call opcode (Topic 15).""" + return self is OpCode.CALL +``` + +### 3.2 `scratchv/ir/builder.py` + +在 `ret()` 附近追加: + +```python + def call(self, callee: str, args: list[Value] | None = None, + has_ret: bool = True, + dtype: DataType = DataType.FLOAT32, + is_tail: bool = False) -> Value | None: + """Emit a CALL. + + Returns the result Value when *has_ret* is True, else None. + """ + call_args = list(args or []) + dest = self.make_value(dtype=dtype) if has_ret else None + self._emit(OpCode.CALL, dest, call_args, + target=callee, argc=len(call_args), is_tail=is_tail) + return dest +``` + +### 3.3 `scratchv/ir/printer.py`(无改动,结论与理由) + +`Program.dump()` 已按 `opcode.value` + `operands` + `target` + `attrs` 通用打印,CALL 会自然输出为: + +``` +$r = call $x $y -> inc [argc=2] [is_tail=False] +``` + +`IRPrinter` 只是 `Program.dump()` 的薄封装,因此**不修改**;`tests/test_ir_call.py` 用文本断言锁定该格式。 + +--- + +## 四、Inliner Pass 算法 + +### 4.1 数据结构 + +```python +@dataclass +class InlinerConfig: + max_instrs: int = 32 # callee body_size 阈值(含终结符) + single_site_only: bool = False + growth_budget: int = 256 # 单个 callee 的全程序克隆指令总量 + reject_loops: bool = True # 拒绝含 FOR/ENDFOR 的 callee + allow_ret_drop: bool = False # 严格返回形态;True 允许丢弃返回值 + max_rounds: int = 4 +``` + +内部辅助(模块私有,不导出): + +- `_build_call_graph(program, callee_index) -> dict[str, set[str]]`:节点为函数名,边为“函数体内的 `CALL.target`(仅保留能命中 `callee_index` 的目标)”; +- `_recursive_functions(graph) -> set[str]`:对每个节点做 DFS,能回到自身者入集合(直接递归与互递归统一处理); +- `_collect_call_sites(func) -> list[tuple[BasicBlock, int, Instruction]]`:按块序、指令序收集 `opcode is OpCode.CALL` 的位置。 + +### 4.2 主流程(伪代码) + +``` +def run(self): + stats = {"inlined": 0, "rejected": 0, "rounds": 0} + callee_index = {f.name: f for f in program.functions} + graph = _build_call_graph(program, callee_index) + recursive = _recursive_functions(graph) + cloned_per_callee = defaultdict(int) # callee.name -> instructions cloned + + for round_no in range(1, config.max_rounds + 1): + changed = False + for caller in list(program.functions): + while True: # 同一函数内反复扫,吃透嵌套调用 + site = first not-yet-processed call site in caller # 见 4.3 + if site is None: break + block, idx, call = site + callee = callee_index.get(call.target) + ok, reason = self._check_eligible( + caller, site, callee_index, recursive, cloned_per_callee) + if not ok: + warn(f"inliner: skip {call.target} at " + f"{caller.name}.{block.name}[{idx}]: {reason}") + stats["rejected"] += 1 + mark site as processed # 不再重复计数 + continue + k = stats["inlined"] + self._inline_site(caller, site, callee, k) + cloned_per_callee[callee.name] += body_size(callee) + stats["inlined"] += 1 + changed = True + # end while + stats["rounds"] = round_no + if not changed: break + return stats["inlined"] +``` + +要点: + +- `k`(`inl{k}` 的实例号)等于“该次内联前的 `stats["inlined"]`”,因此实例号严格按“round → 函数声明序 → 块序 → 指令序”递增,可复现; +- `while True` + 重扫是为了让“刚克隆进 caller 的嵌套 CALL”在同轮被处理;`max_rounds` 仅作安全网; +- 既有 CALL 若本轮被拒绝(如阈值),不重复计数;实现上可用 `id(instr)` 集合记录已拒绝的调用点,避免死循环; +- `stats["rounds"]` 语义:实际执行的轮次数,至少为 1(首轮无变化即 `rounds == 1` 并退出)。 + +### 4.3 资格判定(固定顺序) + +``` +def _check_eligible(caller, (block, idx, call), callee_index, recursive, cloned): + callee = callee_index.get(call.target) + if callee is None: return False, "callee_not_found" + if not callee.blocks: return False, "empty_callee" + argc = call.attrs.get("argc") + if not isinstance(argc, int) or argc != len(call.operands): + return False, "malformed_call" + if call.attrs.get("is_tail", False): + return False, "tail_unsupported" + if len(call.operands) != len(callee.params): + return False, "argc_mismatch" + if callee.name in recursive: return False, "recursive_callee" + if config.reject_loops and _has_loop(callee): + return False, "loop_body_unsupported" + rets = [i for b in callee.blocks for i in b.instructions + if i.opcode is OpCode.RETURN] + valued = [r for r in rets if r.operands] + if not rets: return False, "missing_return" + if len(valued) > 1: return False, "multiple_valued_returns" + if valued and len(valued) != len(rets): + return False, "ret_arity_mismatch" + if valued and call.dest is None and not config.allow_ret_drop: + return False, "ret_arity_mismatch" + if not valued and call.dest is not None: + return False, "ret_arity_mismatch" + if undefined_operand(callee) is not None: + return False, f"undefined_operand (...)" + size = body_size(callee) + if size > config.max_instrs: return False, f"body_too_large ({size} > {config.max_instrs})" + if cloned[callee.name] + size > config.growth_budget: + return False, "growth_budget_exceeded" + if config.single_site_only and count_sites(callee) > 1: + return False, "multiple_call_sites" + return True, "" +``` + +结构合法性守卫(规则 8/10/11)的语义: + +- `missing_return`:callee 无任何 RETURN,克隆块没有终结符; +- `ret_arity_mismatch`(混合形态):`0 < len(valued) < len(rets)`,无值路径跳入续块时使用未定义的返回值(IR 无 PHI); +- `undefined_operand`:callee 中出现不在 params/locals/dests/globals 中的非常量操作数。典型来源是既有块内 DCE 删除跨块定义后遗留的悬空使用;克隆这类 callee 会把悬空值复制进调用者,因此直接拒绝(后端随后对残留 CALL fail-loud)。 + +### 4.4 单调用点内联 `_inline_site`(核心算法) + +输入:`caller`、当前块 `block`、CALL 下标 `idx`、CALL 指令 `call`、callee、实例号 `k`。 + +**步骤 1:预留续块名** +`cont_name = _unique_block_name(caller, f"{caller.name}_inl{k}_cont")`(只算名不建块;`_unique_block_name` 与 `Function.new_block` 同款重名消解逻辑:冲突追加 `_0`、`_1`)。续块延后创建,保证 `caller.blocks` 顺序为「原块 → 克隆块… → 续块」,与预期 dump 一致。 + +**步骤 2:建克隆块的块名映射** +对 `callee.blocks` 依序: + +``` +block_map[b.name] = caller.new_block(f"{callee.name}_{b.name}_inl{k}") +``` + +`Function.new_block()` 自带重名消解(`types.py:157`),故与调用者现有块永不冲突。 + +**步骤 3:建值重命名映射** +统一入口 `map_value(v)`: + +``` +if v.is_constant: return v # 常量共享 +if id(v) not in value_map: + value_map[id(v)] = Value(name=_unique(caller, f"{v.name}_inl{k}"), + dtype=v.dtype) +return value_map[id(v)] +``` + +形参→实参:先按对象同一性 `value_map[id(param)] = arg`;再对 callee 中“名字等于形参名”的 Value 做名字回退映射(兼容手工构造、Value 对象不共享的情况)。`_unique(caller, base)` 在调用者全命名空间(params + locals + 所有 dest/operand 名 + 已用克隆名)中查重,冲突追加 `_0`、`_1`、… + +**步骤 4:克隆指令** + +``` +for b in callee.blocks: # 保持块顺序 + nb = block_map[b.name] + for ins in b.instructions: + if ins.opcode is OpCode.RETURN: + if ins.operands: + ret_value = map_value(ins.operands[0]) # 单值返回(已判定) + nb.add(Instruction(OpCode.BR, target=cont_name)) + continue + nb.add(Instruction( + opcode=ins.opcode, + dest=map_value(ins.dest) if ins.dest else None, + operands=[map_value(v) for v in ins.operands], + attrs=dict(ins.attrs), + target=_rewrite_target(ins.target, block_map), + )) +``` + +- `_rewrite_target`:若 `target` 命中 `block_map` 则替换;`BR_IF` 的 `"t1,t2"` 逗号列表逐项替换(保留原顺序与空白归一); +- 克隆体内的 CALL:`target` 是**函数名**,不参与块名重写,保持原 callee 名,留给后续轮次再内联; +- `phi_nodes` 为空(IR 无 PHI opcode),不克隆;`locals` 不在 v1 处理(前端未使用)。 + +**步骤 5:CALL → `br`,切续块** + +``` +tail = block.instructions[idx + 1:] +block.instructions = block.instructions[:idx] + [ + Instruction(OpCode.BR, target=block_map[callee.blocks[0].name].name) +] +cont = caller.new_block(cont_name) # 克隆块之后创建,续块位于块列表末尾 +if tail: + cont.instructions.extend(tail) +else: + warnings.append(f"inliner: call at end of block {block.name} (invalid IR)") +``` + +**步骤 6:重写调用点 `dest` 的使用** + +``` +if call.dest is not None and ret_value is not None: + for bb in caller.blocks: + for ins in bb.instructions: + ins.operands = [ret_value if op is call.dest else op + for op in ins.operands] +``` + +- 按**对象同一性**替换,覆盖调用点之后的全部块(含刚迁入 `cont` 的指令); +- `call`(含其 `dest`)随步骤 5 从指令流移除;`$r` 成为孤儿对象,不再被引用; +- `allow_ret_drop=True` 且 `call.dest is None` 时,`ret_value` 保持定义但无使用者,交由后续 DCE 处理(v1 默认不启用)。 + +### 4.5 边界情形处置 + +| 情形 | 处置 | +|------|------| +| CALL 是块内最后一条指令(无终结符,非法 IR) | 仍内联,续块为空并 warning(见步骤 5) | +| CALL 位于块首(`idx == 0`) | 原块只剩一条 `br`,产生一跳空块;v1 不做 trampoline 消除 | +| callee 多条无值 RETURN | 全部重定向 `br -> cont`(测试用例 5) | +| callee 单块直线代码 | 克隆单块 + 续块,结构最简 | +| callee 内嵌 CALL 到另一个非递归 callee | 同函数重扫内联;跨调用者由轮次兜底 | +| 值名/块名冲突 | `_unique` 与 `Function.new_block` 双保险 | +| 嵌套内联产生的链式续块(`main_inl0_cont` 再分裂) | 允许;实例号保证块名唯一 | + +--- + +## 五、compiler.py 集成与 CLI + +### 5.1 `CompilerConfig`(新增字段,追加在 dataclass 末尾) + +```python + # ── Topic 15: inlining ── + inline: bool = False + inline_max_instrs: int = 32 + inline_single_site: bool = False + minimal_call_codegen: bool = False +``` + +### 5.2 优化管线(`CompilerDriver._run_optimizations` 末尾) + +```python + if self.config.inline: + from scratchv.optimizer.inliner import Inliner, InlinerConfig + pm.add(_PassAdapter("inliner", Inliner( + program, + InlinerConfig( + max_instrs=self.config.inline_max_instrs, + single_site_only=self.config.inline_single_site, + ), + ))) + + return pm.run(program) +``` + +> **位置约束与既有 DCE 缺陷(修正)**:Inliner 必须追加在既有全部 pass 之后(`compiler.py:370-380`),避免 Inliner 自己产生的克隆定义再被后续 pass(块内 DCE/peephole)破坏。但必须注意:既有 `DeadCodeEliminator` 是块内活跃性(`optimizer/dead_code.py:34-57`),DCE 在 Inliner **之前**就会删除“本块定义、跨块使用”的值;因此 `--inline --optimize basic|all` 对多块 callee 可能收到已被破坏的 callee。Inliner 通过规则 11 `undefined_operand` 拒绝这类 callee(保留 CALL,后端默认 fail-loud),从而不静默产出非法 IR。修复该 DCE 根因(把 uses 收集提升到函数级)不在本课题范围;在根因修复前,Inliner 不得前移,且多块 callee 的内联成功率会受该缺陷影响。 + +### 5.3 `optimize_level == "none"` 的保护 + +在 `CompilerDriver.compile` 第 3 步(优化入口)之前: + +```python + if self.config.inline and self.config.optimize_level == "none": + warnings.append("inliner requires --optimize basic|all; skipped") +``` + +`_run_optimizations` 只在 `optimize_level != "none"` 时被调用(`compiler.py:262`),因此该 warning 之后不会执行内联。 + +### 5.4 CLI(`main.py`) + +`build_arg_parser()` 的 “Topic module flags” 区追加: + +```python + parser.add_argument( + "--inline", action="store_true", + help="Run function inliner (Topic 15); requires --optimize basic|all", + ) + parser.add_argument( + "--inline-max-instrs", type=int, default=32, + help="Inline only callees with <= N instructions (default: 32)", + ) + parser.add_argument( + "--inline-single-site", action="store_true", + help="Only inline callees with a single call site", + ) + parser.add_argument( + "--minimal-call-codegen", action="store_true", + help="Lower uninlined CALLs without ABI (experimental; not executable)", + ) +``` + +`args_to_config()` 追加: + +```python + inline=args.inline, + inline_max_instrs=args.inline_max_instrs, + inline_single_site=args.inline_single_site, + minimal_call_codegen=args.minimal_call_codegen, +``` + +统计上报:内联次数通过 `_PassAdapter` 汇总进 `PassResult.message`(`[inliner] N change(s)`);拒绝明细在 `PassResult.warnings`,最终出现在 CLI 的 `note:` 行;`--dump-ir` 可见前后 IR。 + +--- + +## 六、后端最小 CALL lowering + +### 6.1 `scratchv/backend/instruction_select.py` + +模块级异常: + +```python +class UnsupportedCallError(NotImplementedError): + """Raised when a CALL cannot be lowered (ABI not implemented, Topic 15).""" +``` + +构造与状态: + +```python + def __init__(self, program: Program, *, + allow_uninlined_calls: bool = False): + self.program = program + self.allow_uninlined_calls = allow_uninlined_calls + self._current_function_name = "" + self._call_counter = 0 # 调用点唯一临时 vreg 命名 + ... +``` + +`_select_function()` 增加 `self._current_function_name = func.name`。 + +选择器: + +```python + def _select_call(self, instr: Instruction) -> None: + callee = instr.target or "" + if instr.attrs.get("is_tail", False): + raise UnsupportedCallError( + f"CALL {callee}: tail calls require ABI support (Topic 15)") + args = instr.operands + if len(args) > 8: + raise UnsupportedCallError( + f"CALL {callee}: {len(args)} args > 8 requires stack " + f"passing (ABI not implemented)") + if not self.allow_uninlined_calls: + raise UnsupportedCallError( + f"CALL {callee} in function " + f"'{self._current_function_name}': ABI support " + f"(prologue/epilogue, stack args) is not implemented; " + f"enable inlining (--inline) or set minimal_call_codegen=True") + # 两段式并行搬运:先读入唯一临时 vreg,再统一写 a0..a7 + self._call_counter += 1 + temps = [] + for i, _ in enumerate(args): + temp = f"_call_arg{i}_{self._call_counter}" + temps.append(temp) + self._emit(MachineOp.MV, MachineOperand.vreg(temp), + self._op(instr, i), comment=f"arg{i} -> tmp") + for i, temp in enumerate(temps): + self._emit(MachineOp.MV, MachineOperand.reg(f"a{i}"), + MachineOperand.vreg(temp), comment=f"tmp -> a{i}") + self._emit(MachineOp.JAL, MachineOperand.reg("ra"), + comment=callee) + if instr.dest is not None: + self._emit(MachineOp.MV, self._dst(instr), + MachineOperand.reg("a0"), comment="return a0") +``` + +(`_emit` 的签名是 `(op, dst, src1, src2, comment)`,故降级序列输出为 `mv tmp_i, arg_i` ×N → `mv a{i}, tmp_i` ×N → `jal ra, callee` → `mv dest, a0`。) + +说明: + +- **为何两段式**:单段 `mv a{i}, arg_i` 序列中,后续对 `a{i}` 的写可能覆盖前面参数的源寄存器。线性扫描分配器只对 vreg 建模,不知道物理 a 寄存器被写,源 vreg 可能恰好被分配到 a 寄存器(F6 评审项,`PYTHONHASHSEED` 相关复现见测试)。 +- **a0..a7 保留**:即便两段式,临时 vreg 本身仍可能被线性分配器分配到 a0..a7 并在第二阶段被覆盖。因此 `CompilerDriver._generate_riscv_linear` 在 `minimal_call_codegen=True` 且程序含 CALL 时,以 `ALL_REGS`(t0..t6 + s0..s11,不含 a0..a7)作为线性分配器的物理寄存器池;greedy 分配器本就只用 `ALL_REGS`。该保留只影响最小降级(不可执行)路径。 +- `MachineOp.JAL` + `dst=ra` + `comment=label` 经 `asm_emit.py:133-143` 输出 `jal ra, `;不新增 `MachineOp`; +- `MachineOp.CALL`(伪指令 `call`,GAS 展开为 `auipc+jalr`)保留既有行为,本课题不用它表示用户函数调用,避免 `call` 语义与 `jal ra` 的差异; +- 参数 >8、`is_tail=True` 两种情况**不受** `allow_uninlined_calls` 影响,始终报错。 + +### 6.2 `CompilerDriver._generate_riscv_linear` 透传 + +```python + selector = InstructionSelector( + program, + allow_uninlined_calls=self.config.minimal_call_codegen, + ) +``` + +`_generate_riscv_dag` 路径**不支持 CALL**:`use_dag_isel=True` 且程序含 CALL 时 `DAGBuilder` 直接抛出 `ValueError: No DAG builder for opcode: call`(fail-loud,测试锁定);建议改用线性路径。 + +### 6.3 ABI 未实现时的行为与报错策略(汇总) + +| 场景 | 默认(`minimal_call_codegen=False`) | `--minimal-call-codegen` | +|------|--------------------------------------|--------------------------| +| 内联后无 CALL | 正常生成 | 正常生成 | +| CALL 残留 | `UnsupportedCallError` → `CompileResult(success=False, errors=["Codegen error: ..."], warnings=[...])`,**不写输出文件**;失败结果携带 Inliner 拒绝原因,CLI 在 `Error:` 后打印 `note:` 行 | 输出两段式 `mv tmp`/`mv a{i}` / `jal ra, label` / `mv dest, a0` 汇编;LLVM 后端同样对 CALL 抛 `UnsupportedCallError` | +| 参数 >8 或 `is_tail=True` | 报错 | **同样报错**(无栈传参/尾调用实现) | +| 可执行性 | 不可执行(无 CALL 残留时也仅有单函数语义) | **不可执行**:无 prologue/epilogue、`ra`/`sp`/callee-saved 未保存、线性扫描全局单帧 | + +错误消息样式(测试断言用稳定性): + +``` +Codegen error: CALL inc in function 'main': ABI support (prologue/epilogue, +stack args) is not implemented; enable inlining (--inline) or set +minimal_call_codegen=True +``` + +LLVM 后端(`backend/llvm_codegen.py`)遇残留 CALL 同样抛 `UnsupportedCallError`(`CALL in function '': the LLVM backend does not lower CALL ...`),不再静默输出引用未定义值的非法 `.ll`。 + +--- + +## 七、测试文件与用例 + +### 7.1 文件与覆盖 + +| 文件 | 用例 | 覆盖契约 | +|------|------|----------| +| `tests/test_ir_call.py` | `test_opcode_value`、`test_call_layout`、`test_call_no_ret`、`test_call_dump`、`test_is_call` | 2.1:枚举值 `"call"`、`argc`/`is_tail`、`target`、dump 文本 | +| `tests/test_inliner.py::TestInlineBasic` | 设计文档测试 1、2 | 实参替换、多调用点独立、零残留 CALL | +| `tests/test_inliner.py::TestInlineReject` | 设计文档测试 3、4;阈值边界(`== max_instrs` 内联);`single_site_only`;argc/返回形态不匹配;`is_tail` | 判定顺序与 stats 计数 | +| `tests/test_inliner.py::TestInlineBlocks` | 设计文档测试 5;多块 callee;BR_IF 目标重写 | 块复制、终结符重定向 | +| `tests/test_backend_call.py` | 设计文档测试 6;>8 参数;`is_tail` | `UnsupportedCallError`、`jal ra, label` 文本 | + +补充回归(修复轮次新增):CALL 与 `--optimize basic|all` 的交互(DCE 不删、LICM 不外提)、混合/缺失返回形态、块名与函数名碰撞时不改写 CALL target、`undefined_operand` 拒绝被 DCE 破坏的多块 callee、两段式参数搬运的并行安全(含固定 `PYTHONHASHSEED` 子进程回放)、失败路径 warnings 透传与 CLI `note:` 打印、LLVM/DAG 后端遇 CALL 的 fail-loud 行为。 + +### 7.2 测试要点(示例:测试 1) + +```python +def test_single_site_substitutes_args(): + b = IRBuilder() + p_a = b.make_value(name="a") + p_b = b.make_value(name="b") + inc = b.new_function("inc", params=[p_a, p_b]) + b.new_block("entry") + t = b.add(p_a, p_b) + b.ret(t) + + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(2.0) + y = b.make_const(3.0) + r = b.call("inc", [x, y]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig(max_instrs=8)) + assert inl.run() == 1 + assert inl.stats["inlined"] == 1 and inl.stats["rejected"] == 0 + + instrs = [i for blk in main.blocks for i in blk.instructions] + assert all(i.opcode is not OpCode.CALL for i in instrs) + add = next(i for i in instrs if i.opcode is OpCode.ADD) + assert add.operands[0] is x and add.operands[1] is y # 对象同一性 + cont = next(blk for blk in main.blocks + if blk.name == "main_inl0_cont") + ret = cont.instructions[-1] + assert ret.opcode is OpCode.RETURN and ret.operands[0] is add.dest +``` + +(`b.new_function` 会切换 `current_func`,随后 `b.new_block` 即建在该函数上;续块按块名定位,不依赖块列表下标。) + +补充断言建议: + +- 每个测试末尾直接调用 `IRVerifier(program).verify()`,断言无 `error` 级问题(内联结果必须通过既有 IR 验证器); +- `stats` 键集合精确等于 `{"inlined", "rejected", "rounds"}`; +- 拒绝类用例额外断言“程序前后 dump 完全一致”(被拒绝 = 零副作用)。 + +### 7.3 后端用例(示例) + +```python +def test_minimal_call_asm(): + ... # 构造含 CALL 的程序 + sel = InstructionSelector(program, allow_uninlined_calls=True) + instrs = sel.run() + ops = [i.op.value for i in instrs] + assert "jal" in ops + assert "mv" in ops + asm = AsmEmitter(instrs).emit() + assert "jal ra, inc" in asm +``` + +--- + +## 八、验收标准 + +| 编号 | 标准 | 验证方式 | +|------|------|----------| +| A1 | `OpCode.CALL.value == "call"`;`IRBuilder.call` 布局与契约一致;dump 文本含 `-> inc [argc=2] [is_tail=False]` | `pytest tests/test_ir_call.py -v` | +| A2 | 合格程序内联后**零 CALL**;实参按对象同一性替换;多调用点克隆互不串扰 | `pytest tests/test_inliner.py -v` | +| A3 | 拒绝路径零副作用、计数与 warning 稳定(阈值/递归/尾调用/返回值形态/循环体/single-site) | 同上 + dump 前后一致断言 | +| A4 | 默认遇 CALL 报 `UnsupportedCallError`(消息含 `ABI` 与 callee 名);minimal 模式产物含 `jal ra, inc` 与 `mv`;>8 参数/尾调用恒报错 | `pytest tests/test_backend_call.py -v` | +| A5 | 内联结果通过既有 `IRVerifier`(无 error 级问题) | 各用例内嵌断言 | +| A6 | 全量回归不劣化:`make test` 全绿;`--inline` 关闭时输出与基线一致 | `make test`、`python .claude/harness/verify/run.py --level L2` | +| A7 | 量化:测试 1 内联后 `stats["inlined"]==1`;测试 2 `==2`;测试 3 `inlined==0, rejected==1`;克隆增长不超过 `growth_budget` | pytest 断言 | + +> 本课题**不**以 Spike/仿真执行多函数程序作为验收(见《设计文档》4.7)。 + +--- + +## 九、风险与回退 + +| 风险 | 影响 | 缓解 | 回退动作 | +|------|------|------|----------| +| 现有块内 DCE 误删跨块定义(DCE 在 Inliner 之前运行,非“规避”可解) | 被破坏的多块 callee 无法安全克隆 | 结构合法性守卫 `undefined_operand` 拒绝残缺 callee(保留 CALL;后端默认 fail-loud);测试显式断言操作数有定义。根治 DCE 为函数级 uses 不在本课题范围 | 若仍异常:`inline=False`(默认)即停用 | +| 块分裂破坏 CFG 假设(终结符、块名唯一) | verifier 报错 | RETURN 统一改 `br cont`;原块保留为前段;`new_block` 消解重名;每用例跑 IRVerifier | 关闭 `inline` | +| 值/块重名冲突 | 覆盖定义、SSA 破坏 | `_unique()` + `Function.new_block()` 双保险 | — | +| 递归/互递归未识别导致无限内联 | 编译不终止/代码爆炸 | 全图 DFS 求递归集合 + `max_rounds` 安全网 + `growth_budget` | 降低 `--inline-max-instrs` | +| 后端全局寄存器分配不支持跨函数 | 最小降级产物不可执行 | 默认禁止 CALL 落码(报错);执行类验收排除;参数两段式搬运 + 线性分配保留 a0..a7(见 6.1)避免字面搬运错误 | `minimal_call_codegen=False`(默认) | +| `is_control_flow()` 被误改 | CFG builder/verifier 行为漂移 | 契约冻结:只加 `is_call()`,不改既有谓词;回归 `make test` | `git revert` 该行 | +| 与课题 28/29 枚举冲突 | 合并冲突/语义混淆 | append-only 区段规约(2.5);本课题不新增 `MachineOp` | 调整区段顺序需两课题同步 | +| 内联导致代码膨胀、I$ 恶化(与课题动机相反) | 性能回退 | 阈值 + `growth_budget` + `single_site_only` 可调;后续用课题 12 指令计数评估 | 调小阈值或关闭 | + +**整体回退方案**:`CompilerConfig.inline` 与 `minimal_call_codegen` 默认均为 `False`;IR 前端不生成 CALL,因此删除 `optimizer/inliner.py`、枚举区段与相关 CLI 参数即可回到本课题前的状态,现有 DSL/ONNX 管线零感知。 + +--- + +## 十、里程碑建议(对应课题 12 周目标) + +| 周 | 目标 | 完成判据 | +|----|------|----------| +| W1-W2 | IR 表达:`OpCode.CALL` + `builder.call` + dump | `tests/test_ir_call.py` 全绿 | +| W3-W5 | Inliner 主流程:单调用点、参数替换、续块 | 设计文档测试 1/5 通过 | +| W6-W7 | 判定规则:阈值、递归、多调用点、预算 | 测试 2/3/4 通过 | +| W8-W9 | compiler/CLI 集成与拒绝 warning | `--inline --dump-ir` 可见前后 IR | +| W10 | 后端最小降级与报错策略 | 测试 6 通过 | +| W11-W12 | 回归 + L2 验证 + 文档 | `make test`、L2 全绿 | + +--- + +## 实现结果(2026-09-14 集成) + +> **集成 commit**:`6789a15`(`feat(topic15): add CALL opcode and conservative IR inliner`) +> **集成位置**:`Seven_big_summary` 上第 9 个 topic commit(顺序 … → 10 → **15** → 28 → 29) +> **集成后全量**:`PYTHONPATH=. python3.11 -m pytest tests/ -q` → **1011 passed / 13 xfailed / 20 xpassed / 0 failed** + +### 实现文件与要点 + +| 文件 | 要点 | +|------|------| +| `scratchv/ir/types.py` | `OpCode.CALL`(枚举末尾 `[Topic 15]` 分区) | +| `scratchv/ir/builder.py` | `IRBuilder.call()` | +| `scratchv/optimizer/inliner.py` | Inliner 主体(参数替换、续块、克隆预算、递归/尾调用拒绝) | +| `scratchv/optimizer/inline.py` | 兼容别名(转发) | +| `scratchv/optimizer/__init__.py` | 导出 | +| `scratchv/backend/instruction_select.py` | `_select_call`:残留 CALL 抛 `UnsupportedCallError`;`--minimal-call-codegen` 降级为 `mv/jal/mv` | +| `scratchv/compiler.py` | Inliner 固定为优化管线最后一步 | +| `scratchv/main.py` | 4 个 CLI 开关 | +| `tests/test_ir_call.py`、`tests/test_inliner.py`、`tests/test_backend_call.py` | 共 44 用例 | + +### 测试数字 + +| 口径 | 结果 | +|------|------| +| 定向(3 个测试文件) | 44 用例(含于下列全量) | +| 分支全量(cherry-pick 前) | 609 passed | +| 集成后全量 | 1011 passed / 13 xfailed / 20 xpassed / 0 failed | + +### 与本文档的偏差 / 未完成项 + +- **常量重命名策略**:改为对指令 dest 上的常量重命名,避免多调用点克隆时 SSA 重复定义(文档未细化此点)。 +- `single_site_only` 取经典“单调用点才内联”语义。 +- JAL comment 改为 `jal ra, inc` 形式。 +- warning 透传新增两处最小接线。 + +### 已知限制 + +- 完整 RISC-V ABI(参数传递 / 栈帧 / callee-saved)未实现。 +- `--minimal-call-codegen` 降级产物**不可执行**,仅用于反汇编 / 指令数用途。 +- DAG ISel 路径不支持 CALL。 diff --git "a/docs/topics/15-\345\207\275\346\225\260\345\206\205\350\201\224-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/15-\345\207\275\346\225\260\345\206\205\350\201\224-\350\256\276\350\256\241\346\226\207\346\241\243.md" new file mode 100644 index 0000000..684800b --- /dev/null +++ "b/docs/topics/15-\345\207\275\346\225\260\345\206\205\350\201\224-\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -0,0 +1,504 @@ +# ScratchV 函数内联(IR 级)技术设计文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 涉及模块:`scratchv/ir/types.py`(OpCode)、`scratchv/ir/builder.py`(IRBuilder)、`scratchv/optimizer/inliner.py`(新增 Inliner pass)、`scratchv/compiler.py`(Pass 编排)、`scratchv/main.py`(CLI)、`scratchv/backend/instruction_select.py`(最小 CALL lowering) +> 功能范围:`CALL`/`RETURN` 的 IR 表达、IR 级函数内联变换、最小 `jal ra, label` 降级;完整 RISC-V ABI(prologue/epilogue、栈参数、跨函数寄存器分配)不在本课题内 +> 课题来源:`docs/topics/15-函数内联.md` + +--- + +## 一、功能介绍 + +### 1.1 功能概述 + +函数内联(Function Inlining)把一次函数调用替换为被调函数的函数体副本,消除调用开销(JAL/JALR、栈帧管理),并把跨过程的常量、别名与死代码暴露给后续优化。本次调研确认的现状是: + +- `scratchv/ir/types.py` 已有 `Function`/`Program` 容器,但 `OpCode` **没有 `CALL`**,任何 IR 都无法表达“调用”这一动作; +- `scratchv/backend/instruction_select.py` 没有任何调用约定实现:没有 prologue/epilogue,`sp` 不调整、`ra` 不保存; +- `scratchv/backend/regalloc_linear.py` 对整棵程序做**全局扁平**线性扫描,不存在按函数分帧的寄存器分配; +- `Function.returns` 字段从未被任何前端填充(仅 `llvm_codegen.py` 读取)。 + +结论:**内联的前提是先把 CALL 表达出来**。本课题分两层交付: + +1. **表达能力**:新增 `OpCode.CALL`、`IRBuilder.call()`、IR dump 支持,以及后端“最小 CALL 降级”(`jal ra, label` + 寄存器搬移)。 +2. **变换能力**:新增 `Inliner` pass,把满足判定规则的调用点替换为被调函数体的重命名副本。 + +### 1.2 设计目标 + +- **表达一致**:CALL/RETURN 完全沿用现有三地址码 Instruction 结构(`dest`/`operands`/`target`/`attrs`),不引入新数据类。 +- **判定可预测**:内联规则全部量化(指令数阈值、调用点数量、代码膨胀预算),给定同一程序输出同一结果。 +- **变换可验证**:内联后调用者函数体内**零残留 CALL**、实参替换按对象同一性成立、多调用点各自独立。 +- **边界清晰**:只实现 IR 级内联 + 最小 CALL 表达;ABI/多函数执行栈显式排除,并在本文档写明依赖与后续步骤。 +- **可回退**:`CompilerConfig.inline` 默认关闭;IR 前端不产生 CALL,现有编译管线在默认配置下行为零变化。 +- **可扩展**:新增枚举值一律追加在枚举末尾的统一区段,为课题 28(扩展指令选择)、课题 29(SIMD 向量化)预留位置,避免冲突。 + +--- + +## 二、设计规范 + +### 2.1 CALL / RETURN 的 IR 语义 + +#### 2.1.1 定义(BNF 等价形式) + +``` +instruction ::= opcode [dest] {operand} [target] {attr} + +call_instr ::= "call" [dest] {operand} callee + [ "argc" "=" INT ] [ "is_tail" "=" ("true" | "false") ] + +ret_instr ::= "return" [operand] +callee ::= identifier ; 必须命中 Program.functions 中的某个 name +``` + +#### 2.1.2 元素表 + +| 元素 | 字段(`Instruction`) | 必填 | 精确语义 | +|------|----------------------|------|----------| +| `call` | `opcode=OpCode.CALL`,枚举值字符串为 `"call"` | 是 | 过程间调用 | +| `[dest]` | `dest: Value \| None` | 否 | 调用结果接收值;被调函数**无返回值时必须为 `None`** | +| `{operand}` | `operands: list[Value]` | 可空 | 实参,顺序与 `callee.params` 一一对应 | +| `callee` | `target: str` | 是 | 被调函数名,必须在 `Program.functions[].name` 中唯一命中 | +| `argc` | `attrs["argc"]: int` | 是 | 实参个数,必须恒等于 `len(operands)`;显式冗余用于校验 | +| `is_tail` | `attrs["is_tail"]: bool`,默认 `False` | 否 | 尾调用标记;v1 不支持 `True`(Inliner 拒绝、后端报错) | +| `return` | `opcode=OpCode.RETURN`(已有) | — | `operands` 长度 0(void)或 1(返回值) | + +#### 2.1.3 约束规则 + +- `OpCode.CALL` 必须追加在 `OpCode` 枚举**末尾**的新区段,不得插入既有区段(见 2.5 节)。 +- CALL 是**普通指令,不是基本块终结符**:调用点所在块必须在 CALL 之后仍以 `RETURN`/`BR`/`BR_IF` 结尾(沿用 IR 验证器既有规则)。 +- RETURN 必须位于基本块末尾(既有验证器规则)。 +- `dest` 与 callee 的返回形态必须一致(严格模式):callee 有值返回而 `dest is None` 视为非法(`ret_arity_mismatch`);`dest` 非空而 callee 为 void 同样非法。 +- 一个 callee 若存在**多于一条带操作数的 RETURN**,在 v1 视为“多返回值汇合点”,CALL 合法但内联被拒绝(IR 无 PHI 可用)。 +- callee 至少要有一条 RETURN(否则克隆块无终结符,拒绝原因 `missing_return`);带操作数与不带操作数的 RETURN **不得混用**(混用内联被拒绝,`ret_arity_mismatch`)。 +- callee 的所有非常量操作数必须有定义(params/locals/dests/globals),否则内联被拒绝(`undefined_operand`),避免把悬空使用克隆进调用者(见 4.4)。 +- `Function.returns` 在 v1 不作为判定依据;内联判定只扫描 callee 块内的 RETURN 指令。两者的最终一致性留给后续课题。 + +### 2.2 命名空间与重命名规则 + +内联按“每个调用点一份独立副本”进行,实例编号 `k`(`inl{k}`)由 pass 按“函数声明顺序 → 块顺序 → 指令顺序 → 轮次”确定性分配。 + +| 对象 | 规则 | 示例 | +|------|------|------| +| 克隆块 | `f"{callee.name}_{原块名}_inl{k}"`,经 `Function.new_block()` 保证与调用者现有块不重名(冲突自动 `_0`、`_1` 后缀) | `inc_entry_inl0` | +| 续块(call 后代码) | `f"{caller.name}_inl{k}_cont"` | `main_inl0_cont` | +| 克隆值(非常量) | `f"{原名}_inl{k}"`,并做调用者全命名空间唯一性检查 | `t_inl0` | +| 常量值 | **共享原对象**,不重命名(常量不作为 dest,SSA 唯一赋值规则不适用) | `$v_1` | +| 形参 | 不做名字映射,直接以**实参对象**替换(对象同一性优先,名字回退) | `$a → $x` | +| 函数名 | 不重命名;克隆体内嵌套 CALL 的 `target` 仍指向原 callee 名 | `inc` | + +### 2.3 内联判定规则 + +对每个调用点按下列**固定顺序**判定,首个命中即拒绝(保证 warning 文本与计数可复现): + +| 序号 | 条件 | 拒绝原因(warning 前缀;同时 `stats["rejected"] += 1`) | +|------|------|----------------------------------------| +| 1 | `target` 不在函数表中 | `callee_not_found` | +| 2 | callee 无基本块 | `empty_callee` | +| 3 | `attrs["argc"]` 缺失或 `!= len(operands)` | `malformed_call` | +| 4 | `attrs.get("is_tail")` 为 `True` | `tail_unsupported` | +| 5 | `len(operands) != len(callee.params)` | `argc_mismatch` | +| 6 | callee 属于递归函数集合(直接或互递归,见 4.3) | `recursive_callee` | +| 7 | `reject_loops=True` 且 callee 体含 `FOR`/`ENDFOR` | `loop_body_unsupported` | +| 8 | callee 不含任何 `RETURN` 指令 | `missing_return` | +| 9 | callee 有 >1 条带操作数的 RETURN | `multiple_valued_returns` | +| 10 | 返回形态不一致(带值/无值混合,或 `dest` 与 callee 形态不符,见 2.1.3) | `ret_arity_mismatch` | +| 11 | callee 存在无定义的非常量操作数(不在 params/locals/dests/globals 中) | `undefined_operand` | +| 12 | `body_size > max_instrs` | `body_too_large` | +| 13 | 已为该 callee 克隆的指令数 + `body_size > growth_budget` | `growth_budget_exceeded` | +| 14 | `single_site_only=True` 且该 callee 调用点数 >1 | `multiple_call_sites` | + +其中: + +- `body_size = Σ len(block.instructions)`,遍历 callee 的所有块,**包含块终结符**,不包含从未使用的 `phi_nodes`。 +- 指令数阈值默认 `max_instrs = 32`;代码膨胀预算默认 `growth_budget = 256`(单个 callee 的全程序克隆总量上限)。 +- 规则 8/10/11 是克隆前的**结构合法性守卫**:无 RETURN 的块在克隆后没有终结符;带值/无值混合返回会让无值路径跳入续块时使用未定义值;未定义操作数会被原样克隆进调用者(例如既有块内 DCE 删除跨块定义后遗留的悬空使用,见 4.4)。三者都必须在克隆前 fail-loud。 +- **单调用点策略**:默认(`single_site_only=False`)内联全部合格调用点,每个调用点生成一份**独立**的克隆与独立的重命名值;`single_site_only=True` 时,只要该 callee 的调用点数 >1,则**所有**调用点都拒绝(命中规则 14),与 CLI help 及测试一致。 +- **递归一律拒绝**(`recursive_callee`),v1 不提供“最多内联一层”的开关。 +- 被拒绝的调用点保留原 CALL 原样(不做任何修改),并计入 `stats["rejected"]` 与 `PassResult.warnings`。 + +### 2.4 合法与非法示例 + +**合法示例 1:基本调用(单返回值)** + +``` +fun $main( + .entry: + $x = load_const [value=2.0] + $y = load_const [value=3.0] + $r = call $x $y -> inc [argc=2] [is_tail=False] + return $r +) +fun $inc( + params: $a: f32, $b: f32 + .entry: + $t = add $a $b + return $t +) +``` + +内联后 `main` 的三块结构:`.entry`(常量 + `br -> inc_entry_inl0`)、`.inc_entry_inl0`(`$t_inl0 = add $x $y` + `br -> main_inl0_cont`)、`.main_inl0_cont`(`return $t_inl0`)。 + +**合法示例 2:void 调用 + 多条无值 RETURN(允许)** + +``` +fun $main( + .entry: + $x = load_const [value=1.0] + call $x -> report [argc=1] [is_tail=False] + return $x +) +fun $report( + params: $v: f32 + .entry: + $c = load_const [value=0.0] + br_if $c -> done1, done2 + .done1: + return + .done2: + return +) +``` + +**非法示例** + +1. 目标不存在:`$r = call $x -> missing [argc=1]`(rule 1 拒绝) +2. `argc` 与实参不符:`$r = call $x -> inc [argc=2]`(只有 1 个 operand,rule 3 拒绝) +3. 参数个数不符:callee `params: $a, $b`,调用 `call $x -> inc [argc=1]`(rule 5 拒绝) +4. 自递归:`fun $f` 体内 `$r = call $x -> f [argc=1]`(可表达,rule 6 拒绝内联) +5. 有值多 RETURN:两条 `return $t1` / `return $t2`(rule 8 拒绝) +6. 返回形态不一致:void callee 却写 `$r = call $v -> report [argc=1]`(rule 9 拒绝) +7. 尾调用标记:`$r = call $x -> inc [argc=1] [is_tail=True]`(rule 4 拒绝) +8. 循环体:callee 体内含 `for`/`endfor`(rule 7 拒绝) + +> 说明:非法示例 1~8 全部是**可表达**的 IR —— 本课题只要求 CALL 能被表达;是否可内联由 Inliner 判定并警告,而非 IR 层禁止。 + +### 2.5 枚举扩展规约(与课题 28/29 的边界) + +`scratchv/ir/types.py` 的 `OpCode` 采用**只追加(append-only)**策略: + +``` +class OpCode(enum.Enum): + ... # 既有成员,顺序与值永不改动 + CONCAT = "concat" + # ── Topic 15: interprocedural (2026-09-14) ── + CALL = "call" + # ── Topic 28: extended ops(预留,本课题不添加) ── + # ── Topic 29: SIMD ops(预留,本课题不添加) ── +``` + +- 禁止插入既有区段、禁止重排、禁止复用已有字符串值(避免 Enum alias)。 +- `is_control_flow()` 的成员集合**保持不变**(CALL 不是终结符,不能进入 CFG builder 的终结符判定);新增独立谓词 `OpCode.is_call()`。 +- 课题 28/29 各自在自己编号的区段末尾追加;跨课题引用新枚举时必须使用本规约的区段顺序。 + +--- + +## 三、测试设计 + +所有测试用 pytest,测试文件与用例映射见《开发文档》第七章。IR 输入既可用 `IRBuilder` 构造,也可直接构造 `Program/Function/BasicBlock`。以下 dump 采用 `Program.dump()` 语法;为便于阅读,值名手工给定。 + +### 测试用例 1:单调用点内联与实参替换 + +**文件**:`tests/test_inliner.py::TestInlineBasic::test_single_site_substitutes_args` + +**IR 输入**:同 2.4 合法示例 1(`main` 调用 `inc(x, y)`)。 + +**内联后预期结构**: + +``` +fun $main( + .entry: + $x = load_const [value=2.0] + $y = load_const [value=3.0] + br -> inc_entry_inl0 + .inc_entry_inl0: + $t_inl0 = add $x $y + br -> main_inl0_cont + .main_inl0_cont: + return $t_inl0 +) +fun $inc( + params: $a: f32, $b: f32 + .entry: + $t = add $a $b + return $t +) +``` + +**验证点**: +- 全程序**无 `OpCode.CALL`**; +- 克隆 `add` 的 `operands[0] is $x` 且 `operands[1] is $y`(对象同一性,非改名); +- `main` 中不再有任何指令引用原 `$r` 对象;`return` 的操作数为 `$t_inl0` 对象; +- `stats == {"inlined": 1, "rejected": 0, ...}`。 + +### 测试用例 2:多调用点各自独立 + +**文件**:`tests/test_inliner.py::TestInlineBasic::test_multi_site_independent_clones` + +**IR 输入**: + +``` +fun $main( + .entry: + $x = load_const [value=2.0] + $y = load_const [value=3.0] + $r1 = call $x $y -> inc [argc=2] [is_tail=False] + $r2 = call $x $x -> inc [argc=2] [is_tail=False] + $s = add $r1 $r2 + return $s +) +``` + +(callee `$inc` 同测试 1) + +**内联后预期结构**: + +``` +fun $main( + .entry: + $x = load_const [value=2.0] + $y = load_const [value=3.0] + br -> inc_entry_inl0 + .inc_entry_inl0: + $t_inl0 = add $x $y + br -> main_inl0_cont + .main_inl0_cont: + br -> inc_entry_inl1 + .inc_entry_inl1: + $t_inl1 = add $x $x + br -> main_inl1_cont + .main_inl1_cont: + $s = add $t_inl0 $t_inl1 + return $s +) +``` + +**验证点**: +- 无 CALL;`stats["inlined"] == 2`; +- 两份克隆的块名/值名互不相同(`inc_entry_inl0` vs `inc_entry_inl1`、`$t_inl0` vs `$t_inl1`); +- 第二次克隆的 `add` 两个操作数均为 `$x` 对象;第一次克隆为 `$x`、`$y`(互不串扰); +- `$s` 的两个操作数分别指向 `$t_inl0`、`$t_inl1`。 + +### 测试用例 3:阈值拒绝(CALL 保留) + +**文件**:`tests/test_inliner.py::TestInlineReject::test_body_too_large_keeps_call` + +**IR 输入**:callee 体 5 条指令(3×`load_const` + `add` + `return`),`InlinerConfig(max_instrs=4)`。 + +**预期输出**:程序与输入**逐指令相同**;`stats["inlined"] == 0`、`stats["rejected"] == 1`;`run()` 返回 0;warnings 中含 `body_too_large` 且包含 `5 > 4`。 + +**验证点**:CALL 仍在;被拒绝时不产生任何克隆块/克隆值;阈值边界 `body_size == max_instrs` 时**应内联**(另加边界断言)。 + +### 测试用例 4:递归拒绝(直接 + 互递归) + +**文件**:`tests/test_inliner.py::TestInlineReject::test_recursive_rejected` + +**IR 输入**: + +- 直接递归:`main` 调用 `f`,`f` 调用 `f`; +- 互递归:`p` 调用 `q`,`q` 调用 `p`,`main` 调用 `p`。 + +**预期输出**:所有调用点的 CALL 全部保留;`stats["inlined"] == 0`;warnings 出现 `recursive_callee`。 + +**验证点**:递归判定基于**完整调用图**(跨函数可达性),不依赖调用顺序;互递归两侧都不可被内联。 + +### 测试用例 5:void 多 RETURN 重定向与 BR_IF 目标重写 + +**文件**:`tests/test_inliner.py::TestInlineBlocks::test_void_multi_return_redirects` + +**IR 输入**:同 2.4 合法示例 2。 + +**内联后预期结构**: + +``` +fun $main( + .entry: + $x = load_const [value=1.0] + br -> report_entry_inl0 + .report_entry_inl0: + $c_inl0 = load_const [value=0.0] + br_if $c_inl0 -> report_done1_inl0, report_done2_inl0 + .report_done1_inl0: + br -> main_inl0_cont + .report_done2_inl0: + br -> main_inl0_cont + .main_inl0_cont: + return $x +) +``` + +**验证点**: +- 克隆体内**无 RETURN**,两条无值 RETURN 均变为 `br -> main_inl0_cont`; +- `br_if` 的两个目标被重写为克隆块名(`report_done1_inl0`、`report_done2_inl0`); +- 调用者 CALL 后代码完整迁移到续块,块终结符合法。 + +### 测试用例 6:后端最小 CALL 降级的报错与产物 + +**文件**:`tests/test_backend_call.py` + +**输入**:保留 CALL 的程序(测试 3 拒绝后的程序),`InstructionSelector`。 + +**预期输出**: +- 默认 `InstructionSelector(program)`:抛 `UnsupportedCallError`,消息含 `ABI` 与 `inc`; +- `InstructionSelector(program, allow_uninlined_calls=True)`:产出两段式参数搬运(2 个参数时:`MV t0,`、`MV t1,`、`MV a0,t0`、`MV a1,t1`)、`JAL ra,#inc`、`MV ,a0` 共 6 条 MachineInstr(临时 vreg 名唯一化,见 4.6);`AsmEmitter` 文本含 `jal ra, inc`; +- 参数 >8 或 `is_tail=True`:**即使开启 minimal 模式也报错**。 + +**验证点**:错误消息稳定可比对;最小降级只用于反汇编/指令数检查,不用于执行(见 4.7)。 + +--- + +## 四、修改模块与实现步骤 + +### 4.1 涉及文件 + +| 文件 | 现状 | 改动 | +|------|------|------| +| `scratchv/ir/types.py` | OpCode 无 CALL | 末尾追加 `CALL` + `is_call()` | +| `scratchv/ir/builder.py` | 无 call API | 新增 `IRBuilder.call()` | +| `scratchv/ir/printer.py` | 通用 dump | **无需修改**(`target`/`attrs` 已通用打印) | +| `scratchv/optimizer/inliner.py` | 不存在 | 新增 `Inliner`/`InlinerConfig` | +| `scratchv/optimizer/__init__.py` | 5 个 pass 导出 | 追加 `Inliner`/`InlinerConfig` | +| `scratchv/compiler.py` | 优化管线 5 步 | `CompilerConfig` 4 字段、`_run_optimizations` 集成 | +| `scratchv/main.py` | CLI | 4 个参数并接入 `args_to_config` | +| `scratchv/backend/instruction_select.py` | 无 CALL 处理 | `_select_call`、`UnsupportedCallError`、构造参数 | +| `scratchv/backend/asm_emit.py` | 已支持 `JAL`/`CALL` | **无需修改** | +| `tests/test_ir_call.py`、`tests/test_inliner.py`、`tests/test_backend_call.py` | 不存在 | 新增(详见开发文档第七章) | + +> 实际路径以仓库为准;本文档与开发文档使用相同名称,不引入抽象别名。 + +### 4.2 第一步:IR 表达(types.py / builder.py) + +1. `OpCode` 末尾按 2.5 规约追加 `CALL = "call"`,并在判定方法区新增 `is_call()`; +2. `IRBuilder.call(callee, args=None, has_ret=True, dtype=DataType.FLOAT32, is_tail=False) -> Value | None`:有返回值时创建 dest 并返回,无返回值时返回 `None`;`attrs={"argc": len(args), "is_tail": is_tail}`; +3. 用 `tests/test_ir_call.py` 锁定:枚举值、字段布局、dump 文本(`-> callee [argc=N] [is_tail=False]`)、`has_ret=False` 时 `dest is None`。 + +### 4.3 第二步:Inliner pass(`scratchv/optimizer/inliner.py`) + +算法骨架(细节与伪代码见《开发文档》第四章): + +1. 建立 `callee_index`(name → Function)与调用图; +2. DFS 求**递归函数集合**:`f` 可达自身即视为递归(含互递归的自环);调用 `f` 的调用点直接拒绝; +3. 对每个 caller 反复扫描合格调用点并内联,直到该函数无合格调用点;跨函数用轮次(默认 `max_rounds=4`)兜底嵌套克隆产生的 CALL; +4. 内联单点 = 深拷贝 callee 图 + 形参→实参映射 + 值重命名 + 块目标重写 + CALL 替换为 `br` + RETURN 重定向到续块 + 调用点 `dest` 使用重写。 + +### 4.4 第三步:compiler.py 集成 + +- `CompilerConfig` 新增 4 个字段:`inline=False`、`inline_max_instrs=32`、`inline_single_site=False`、`minimal_call_codegen=False`; +- Inliner 以 `_PassAdapter("inliner", Inliner(...))` 形式加入优化管线,且**置于管线最后一步**(在 constant-folding / dead-code / peephole / muladd / licm 之后)。 + +> **集成位置与既有 DCE 缺陷(重要修正)**:现有 `DeadCodeEliminator` 是**块内**活跃性(`dead_code.py:34` 只在本块收集 uses),会删除“本块定义、跨块使用”的值——这发生在 Inliner **之前**(常量折叠 → DCE → … → Inliner)。因此 Inliner 放在最后并不能规避该缺陷:被 DCE 破坏的 callee(定义已删、使用悬空)若被克隆,悬空使用会原样进入调用者。分支侧的处理是:内联前做结构合法性守卫(规则 8/10/11,尤其 `undefined_operand`),拒绝残缺 callee 并保留 CALL,由后端 fail-loud(默认 `UnsupportedCallError`),不静默产出非法 IR;Inliner 仍固定为管线最后一步(避免自己的克隆定义被后续 pass 处理为未定义)。根治需要把 DCE uses 提升为函数级(既有缺陷,见开发文档 §5.2),不属于本课题范围。 + +- 当 `optimize_level == "none"` 且 `inline=True` 时,不运行优化管线并追加 warning:`inliner requires --optimize basic|all; skipped`。 + +### 4.5 第四步:CLI(main.py) + +| 参数 | 类型 | 默认 | 映射到 | +|------|------|------|--------| +| `--inline` | store_true | False | `CompilerConfig.inline` | +| `--inline-max-instrs` | int | 32 | `CompilerConfig.inline_max_instrs` | +| `--inline-single-site` | store_true | False | `CompilerConfig.inline_single_site` | +| `--minimal-call-codegen` | store_true | False | `CompilerConfig.minimal_call_codegen` | + +### 4.6 第五步:后端最小 CALL lowering + +- `InstructionSelector.__init__(self, program, *, allow_uninlined_calls=False)`; +- `_select_call`: + - 参数 0..7 **两段式并行搬运**:先 `MV tmp_i, arg_i`(每个调用点唯一命名的临时 vreg),再 `MV a{i}, tmp_i`;顺序单段搬运会被寄存器分配后的 `a{i}` 写覆盖,见开发文档 §6.1。参数 >8 → `UnsupportedCallError`(栈传参未实现); + - `JAL ra, `(即 `jal ra, label`); + - 有 `dest` 时 `MV dest, a0`; + - 线性寄存器分配器不建模物理 a 寄存器写入,因此 `minimal_call_codegen=True` 且程序含 CALL 时,`CompilerDriver` 用 `ALL_REGS`(t/s 寄存器,不含 a0..a7)作为线性分配的物理寄存器池;greedy 分配器本就只用 `ALL_REGS`。 +- 默认 `allow_uninlined_calls=False`:CALL 残留即抛错,由 `CompilerDriver.compile` 既有的异常包装(`compiler.py:281-287`)转为 `CompileResult(success=False, errors=["Codegen error: ..."])`,且不写输出文件。 + +### 4.7 ABI 缺失时的执行限制说明(范围边界) + +以下限制是**本课题明确不解决**的,文档化以避免误用: + +1. **无 prologue/epilogue**:`sp` 从不调整,`ra` 从不入栈。被调函数执行 `ret` 时 `ra` 已被覆盖,跨函数执行不可靠。 +2. **无跨函数寄存器分配**:`regalloc_linear.py` 与 `register_alloc.py` 都把整程序当一张指令表分配同一套物理寄存器,不做 caller/callee-saved 建模;最小降级也没有在 `a0..a7`/`t0..t6`/`ra` 附近插入溢出保护。 +3. **仅支持寄存器传参**:8 个以上参数直接报错,不接受栈参数。 +4. **执行类验收不包含**:Spike/内置仿真器不用于多函数程序;本课题的验收止于 IR 结构、汇编文本与指令序列。 + +**后续步骤(不属于本课题)**:RISC-V calling convention 与 prologue/epilogue、`ra`/callee-saved 保存、按函数分帧的寄存器分配、栈传参与 sp 对齐、多返回值 PHI 汇合、callee 删除(dead function elimination)。 + +### 4.8 集成与回归测试 + +- 新增单测:`python3 -m pytest tests/test_ir_call.py tests/test_inliner.py tests/test_backend_call.py -v`; +- 全量回归:`make test`(`python3 -m pytest tests/ -v --tb=short`,现有 348+ 用例必须全绿); +- 项目验证:`python .claude/harness/verify/run.py --level L2`(commit 前); +- 默认配置零影响:DSL/ONNX 前端不产生 CALL,`--inline` 关闭时输出与改动前逐字节一致(可用既有基准脚本抽查)。 + +--- + +## 五、附录 + +### 5.1 内联前后 IR dump(测试用例 1) + +**内联前**(`--dump-ir` before 段等价内容): + +``` +fun $main( + .entry: + $x = load_const [value=2.0] + $y = load_const [value=3.0] + $r = call $x $y -> inc [argc=2] [is_tail=False] + return $r +) + +fun $inc( + params: $a: f32, $b: f32 + .entry: + $t = add $a $b + return $t +) +``` + +**内联后**: + +``` +fun $main( + .entry: + $x = load_const [value=2.0] + $y = load_const [value=3.0] + br -> inc_entry_inl0 + .inc_entry_inl0: + $t_inl0 = add $x $y + br -> main_inl0_cont + .main_inl0_cont: + return $t_inl0 +) + +fun $inc( + params: $a: f32, $b: f32 + .entry: + $t = add $a $b + return $t +) +``` + +(v1 不删除已无调用点的 `$inc`;删除属于后续 dead function elimination。) + +### 5.2 最小 CALL 降级汇编片段(测试用例 6) + +输入 IR:`$r = call $x $y -> inc [argc=2] [is_tail=False]` + +`allow_uninlined_calls=True` 时的 MachineInstr 序列(寄存器分配前): + +``` +mv t0, v_x # arg0 -> tmp +mv t1, v_y # arg1 -> tmp +mv a0, t0 # tmp -> a0 +mv a1, t1 # tmp -> a1 +jal ra, inc +mv v_r, a0 +``` + +(`t0`/`t1` 为实现分配的临时 vreg 名,实际名字形如 `_call_arg0_1`;两段式保证所有参数先读入临时值再写 `a0..a7`,避免寄存器分配后前置写覆盖源——见 4.6。ABI 未实现,此片段只能用于反汇编/指令数检查,不能实际执行;见 4.7。) + +### 5.3 参考资料 + +- 龙书《编译原理》第 9 章 Interprocedural Analysis(内联与过程间优化) +- LLVM `InlineCost.cpp` / `always_inline` 成本模型(阈值思想的工业实现) +- RISC-V psABI(整数调用约定:`a0..a7` 传参、`a0` 返回、`ra` 连接寄存器) +- ScratchV 课题文档:`docs/topics/04-IR优化器框架.md`、`docs/topics/12-指令计数统计器.md`、`docs/topics/17-寄存器分配.md` +- 下游课题:`docs/topics/28-扩展指令选择.md`、`docs/topics/29-SIMD向量化.md`(枚举扩展区段规约见 2.5) +- 项目初始化文档:`/root/Lab/GaoMD/ScratchV/ArcDes/init.md` diff --git a/scratchv/backend/instruction_select.py b/scratchv/backend/instruction_select.py index 26395d2..370b7da 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -13,13 +13,21 @@ ) +class UnsupportedCallError(NotImplementedError): + """Raised when a CALL cannot be lowered (ABI not implemented, Topic 15).""" + + class InstructionSelector: """Select RISC-V instructions for each IR instruction.""" - def __init__(self, program: Program): + def __init__(self, program: Program, *, + allow_uninlined_calls: bool = False): self.program = program + self.allow_uninlined_calls = allow_uninlined_calls + self._current_function_name = "" self._instructions: list[MachineInstr] = [] self._label_counter = 0 + self._call_counter = 0 def run(self) -> list[MachineInstr]: """Select instructions for all functions. @@ -37,6 +45,7 @@ def _fresh_label(self, prefix: str = "L") -> str: def _select_function(self, func: Function) -> None: # Function prologue label + self._current_function_name = func.name self._emit_label(func.name) for block in func.blocks: @@ -235,6 +244,42 @@ def _select_br_if(self, instr: Instruction) -> None: self._emit(MachineOp.BNEZ, cond, comment=true_target) self._emit(MachineOp.J, comment=false_target) + def _select_call(self, instr: Instruction) -> None: + callee = instr.target or "" + if instr.attrs.get("is_tail", False): + raise UnsupportedCallError( + f"CALL {callee}: tail calls require ABI support (Topic 15)") + args = instr.operands + if len(args) > 8: + raise UnsupportedCallError( + f"CALL {callee}: {len(args)} args > 8 requires stack " + f"passing (ABI not implemented)") + if not self.allow_uninlined_calls: + raise UnsupportedCallError( + f"CALL {callee} in function " + f"'{self._current_function_name}': ABI support " + f"(prologue/epilogue, stack args) is not implemented; " + f"enable inlining (--inline) or set minimal_call_codegen=True") + # Parallel-safe argument staging: read every argument into a fresh + # temporary first, then write the a{i} registers. A plain sequential + # "mv a{i}, arg" sequence can clobber a source that already lives in + # an a-register (register allocation only sees virtual registers). + self._call_counter += 1 + temps = [] + for i, _ in enumerate(args): + temp = f"_call_arg{i}_{self._call_counter}" + temps.append(temp) + self._emit(MachineOp.MV, MachineOperand.vreg(temp), + self._op(instr, i), comment=f"arg{i} -> tmp") + for i, temp in enumerate(temps): + self._emit(MachineOp.MV, MachineOperand.reg(f"a{i}"), + MachineOperand.vreg(temp), comment=f"tmp -> a{i}") + self._emit(MachineOp.JAL, MachineOperand.reg("ra"), + comment=callee) + if instr.dest is not None: + self._emit(MachineOp.MV, self._dst(instr), + MachineOperand.reg("a0"), comment="return a0") + def _select_return(self, instr: Instruction) -> None: if instr.operands: self._emit(MachineOp.MV, MachineOperand.reg("a0"), diff --git a/scratchv/backend/llvm_codegen.py b/scratchv/backend/llvm_codegen.py index be90352..eabdc7e 100644 --- a/scratchv/backend/llvm_codegen.py +++ b/scratchv/backend/llvm_codegen.py @@ -9,6 +9,7 @@ from scratchv.ir.types import ( OpCode, DataType, Value, Instruction, BasicBlock, Function, Program, ) +from scratchv.backend.instruction_select import UnsupportedCallError _TYPE_MAP = { @@ -173,6 +174,15 @@ def _is_first_block(self) -> bool: # ------------------------------------------------------------------ def _emit_instruction(self, instr: Instruction) -> None: + if instr.opcode is OpCode.CALL: + # No calling convention is modelled by this backend; emitting + # the call text would produce invalid .ll referencing undefined + # values. Fail loud instead, mirroring the RISC-V backend. + raise UnsupportedCallError( + f"CALL {instr.target} in function " + f"'{self._current_func}': the LLVM backend does not lower " + f"CALL (ABI not implemented); enable inlining (--inline) or " + f"use the RISC-V backend") handler = getattr(self, f"_emit_{instr.opcode.value}", None) if handler is None: ops = ' '.join(str(v.name) for v in instr.operands) diff --git a/scratchv/compiler.py b/scratchv/compiler.py index fa5459e..b314507 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -74,6 +74,12 @@ class CompilerConfig: enable_forwarding: bool = True branch_predictor: str = "always_not_taken" + # ── Topic 15: inlining ── + inline: bool = False + inline_max_instrs: int = 32 + inline_single_site: bool = False + minimal_call_codegen: bool = False + # ═══════════════════════════════════════════════════════════════════════════════ # PassManager @@ -296,9 +302,13 @@ def compile(self, input_path: str, output_path: str | None = None, # --- 3. Optimize --- opt_message = "" + if self.config.inline and self.config.optimize_level == "none": + warnings.append( + "inliner requires --optimize basic|all; skipped") if self.config.optimize_level != "none": opt_result = self._run_optimizations(program) opt_message = opt_result.message + warnings.extend(opt_result.warnings) ir_dump_after = "" if self.config.dump_ir: @@ -320,7 +330,7 @@ def compile(self, input_path: str, output_path: str | None = None, except Exception as e: return CompileResult( success=False, errors=[f"Codegen error: {e}"], - ir_dump=ir_dump, + ir_dump=ir_dump, warnings=warnings, ) # --- 5. Post-codegen passes --- @@ -413,6 +423,17 @@ def _run_optimizations(self, program) -> PassResult: pm.add(_PassAdapter("muladd-fusion", MulAddFusion(program))) pm.add(_PassAdapter("licm", LICM(program))) + if self.config.inline: + from scratchv.optimizer.inliner import Inliner, InlinerConfig + + pm.add(_PassAdapter("inliner", Inliner( + program, + InlinerConfig( + max_instrs=self.config.inline_max_instrs, + single_site_only=self.config.inline_single_site, + ), + ))) + return pm.run(program) # ── Internal: code generation ─────────────────────────────────────────── @@ -434,7 +455,10 @@ def _generate_riscv_linear(self, program) -> str: from scratchv.backend.register_alloc import RegisterAllocator from scratchv.backend.asm_emit import AsmEmitter - selector = InstructionSelector(program) + selector = InstructionSelector( + program, + allow_uninlined_calls=self.config.minimal_call_codegen, + ) machine_instrs = selector.run() # Linear-scan: skip greedy allocator, use liveness-driven allocator @@ -443,7 +467,15 @@ def _generate_riscv_linear(self, program) -> str: LinearScanAllocator, block_from_machine_instrs, ) ls_insts = block_from_machine_instrs(machine_instrs) - lsa = LinearScanAllocator() + phys_regs = None + if self.config.minimal_call_codegen and _program_has_call(program): + # Reserve a0..a7 for the CALL staging sequence: the allocator + # does not model the physical argument writes, so a vreg + # mapped to an argument register could be silently clobbered. + # The greedy allocator already only uses ALL_REGS. + from scratchv.backend.machine_types import ALL_REGS + phys_regs = list(ALL_REGS) + lsa = LinearScanAllocator(phys_regs=phys_regs) return lsa.emit(ls_insts) alloc = RegisterAllocator(machine_instrs, mode=self.config.reg_alloc) @@ -520,6 +552,17 @@ def _run_asm_passes(self, asm_text: str, warnings: list[str]) -> str: return asm_text +def _program_has_call(program) -> bool: + """Return True if any function of *program* contains a CALL.""" + from scratchv.ir.types import OpCode + return any( + ins.opcode is OpCode.CALL + for func in program.functions + for block in func.blocks + for ins in block.instructions + ) + + # ═══════════════════════════════════════════════════════════════════════════════ # _PassAdapter — wraps legacy passes that don't implement CompilerPass # ═══════════════════════════════════════════════════════════════════════════════ @@ -545,4 +588,5 @@ def run(self, input_data: Any) -> PassResult: data=input_data, changes=changes, message=f"{changes} change(s)", + warnings=list(getattr(self._legacy, "warnings", [])), ) diff --git a/scratchv/ir/builder.py b/scratchv/ir/builder.py index 5468962..6e82eb4 100644 --- a/scratchv/ir/builder.py +++ b/scratchv/ir/builder.py @@ -149,6 +149,20 @@ def ret(self, val: Value | None = None) -> Instruction: operands = [val] if val else [] return self._emit(OpCode.RETURN, operands=operands) + def call(self, callee: str, args: list[Value] | None = None, + has_ret: bool = True, + dtype: DataType = DataType.FLOAT32, + is_tail: bool = False) -> Value | None: + """Emit a CALL. + + Returns the result Value when *has_ret* is True, else None. + """ + call_args = list(args or []) + dest = self.make_value(dtype=dtype) if has_ret else None + self._emit(OpCode.CALL, dest, call_args, + target=callee, argc=len(call_args), is_tail=is_tail) + return dest + def relu(self, val: Value) -> Value: dest = self.make_value() self._emit(OpCode.RELU, dest, [val]) diff --git a/scratchv/ir/types.py b/scratchv/ir/types.py index 059f73d..c66863f 100644 --- a/scratchv/ir/types.py +++ b/scratchv/ir/types.py @@ -49,6 +49,9 @@ class OpCode(enum.Enum): RESHAPE = "reshape" CONCAT = "concat" + # ── Topic 15: interprocedural (2026-09-14) ── + CALL = "call" + def is_arith(self) -> bool: return self in (OpCode.ADD, OpCode.SUB, OpCode.MUL, OpCode.DIV) @@ -71,6 +74,10 @@ def is_control_flow(self) -> bool: OpCode.FOR, OpCode.ENDFOR, OpCode.BR, OpCode.BR_IF, OpCode.RETURN) + def is_call(self) -> bool: + """True for the interprocedural call opcode (Topic 15).""" + return self is OpCode.CALL + class DataType(enum.Enum): """Element data types.""" diff --git a/scratchv/main.py b/scratchv/main.py index 52feff5..617a902 100644 --- a/scratchv/main.py +++ b/scratchv/main.py @@ -104,6 +104,22 @@ 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( + "--inline", action="store_true", + help="Run function inliner (Topic 15); requires --optimize basic|all", + ) + parser.add_argument( + "--inline-max-instrs", type=int, default=32, + help="Inline only callees with <= N instructions (default: 32)", + ) + parser.add_argument( + "--inline-single-site", action="store_true", + help="Only inline callees with a single call site", + ) + parser.add_argument( + "--minimal-call-codegen", action="store_true", + help="Lower uninlined CALLs without ABI (experimental; not executable)", + ) # ── Cycle estimation ────────────────────────────────────────────── parser.add_argument( @@ -153,6 +169,10 @@ def args_to_config(args: argparse.Namespace) -> CompilerConfig: cycle_stats=args.cycle_stats, enable_forwarding=not args.no_forwarding, branch_predictor=args.branch_predictor, + inline=args.inline, + inline_max_instrs=args.inline_max_instrs, + inline_single_site=args.inline_single_site, + minimal_call_codegen=args.minimal_call_codegen, ) @@ -286,6 +306,8 @@ def main(argv: list[str] | None = None) -> int: else: for err in result.errors: print(f"Error: {err}", file=sys.stderr) + for w in result.warnings: + print(f" note: {w}", file=sys.stderr) return 1 diff --git a/scratchv/optimizer/__init__.py b/scratchv/optimizer/__init__.py index 42a86b5..2ab02c4 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 .inliner import Inliner, InlinerConfig __all__ = [ "ConstantFolder", @@ -10,4 +11,6 @@ "IRPeepholeOptimizer", "MulAddFusion", "LICM", + "Inliner", + "InlinerConfig", ] diff --git a/scratchv/optimizer/dead_code.py b/scratchv/optimizer/dead_code.py index 05c77ed..b2a8e10 100644 --- a/scratchv/optimizer/dead_code.py +++ b/scratchv/optimizer/dead_code.py @@ -67,4 +67,7 @@ def _is_side_effect(instr: Instruction) -> bool: OpCode.FOR, OpCode.ENDFOR, OpCode.ALLOCA, + # CALL may execute arbitrary side effects in the callee and + # must never be removed even when its result is unused. + OpCode.CALL, ) diff --git a/scratchv/optimizer/inline.py b/scratchv/optimizer/inline.py new file mode 100644 index 0000000..87c252a --- /dev/null +++ b/scratchv/optimizer/inline.py @@ -0,0 +1,10 @@ +"""Compatibility alias for the Topic 15 inliner module. + +The implementation lives in :mod:`scratchv.optimizer.inliner` (the frozen +interface contract); this module re-exports the public names so both +``scratchv.optimizer.inliner`` and ``scratchv.optimizer.inline`` work. +""" + +from .inliner import Inliner, InlinerConfig + +__all__ = ["Inliner", "InlinerConfig"] diff --git a/scratchv/optimizer/inliner.py b/scratchv/optimizer/inliner.py new file mode 100644 index 0000000..aad3476 --- /dev/null +++ b/scratchv/optimizer/inliner.py @@ -0,0 +1,369 @@ +"""Function inlining pass (Topic 15). + +Replaces eligible CALL sites with a renamed clone of the callee body. +Conservative v1: recursive callees, loops, oversized bodies and arity +mismatches are rejected and reported in :attr:`Inliner.warnings`. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import Optional + +from scratchv.ir.types import ( + BasicBlock, Function, Instruction, OpCode, Program, Value, +) + + +@dataclass +class InlinerConfig: + """Tunables for the inliner (all decisions are deterministic).""" + + max_instrs: int = 32 + single_site_only: bool = False + growth_budget: int = 256 + reject_loops: bool = True + allow_ret_drop: bool = False + max_rounds: int = 4 + + +def _body_size(func: Function) -> int: + return sum(len(b.instructions) for b in func.blocks) + + +def _has_loop(func: Function) -> bool: + return any( + ins.opcode in (OpCode.FOR, OpCode.ENDFOR) + for b in func.blocks for ins in b.instructions + ) + + +def _build_call_graph( + program: Program, + callee_index: dict[str, Function]) -> dict[str, set[str]]: + graph: dict[str, set[str]] = {f.name: set() for f in program.functions} + for func in program.functions: + for block in func.blocks: + for ins in block.instructions: + if ins.opcode is OpCode.CALL and ins.target in callee_index: + graph[func.name].add(ins.target) + return graph + + +def _recursive_functions(graph: dict[str, set[str]]) -> set[str]: + recursive: set[str] = set() + for start in graph: + seen: set[str] = set() + stack = [start] + while stack: + node = stack.pop() + for nxt in graph.get(node, ()): + if nxt == start: + recursive.add(start) + stack.clear() + break + if nxt not in seen: + seen.add(nxt) + stack.append(nxt) + return recursive + + +def _collect_call_sites( + func: Function) -> list[tuple[BasicBlock, int, Instruction]]: + sites: list[tuple[BasicBlock, int, Instruction]] = [] + for block in func.blocks: + for idx, ins in enumerate(block.instructions): + if ins.opcode is OpCode.CALL: + sites.append((block, idx, ins)) + return sites + + +def _unique_block_name(func: Function, base: str) -> str: + existing = {b.name for b in func.blocks} + candidate = base + i = 0 + while candidate in existing: + candidate = f"{base}_{i}" + i += 1 + return candidate + + +def _caller_names(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 v in ins.operands: + names.add(v.name) + return names + + +def _unique(used: set[str], base: str) -> str: + candidate = base + i = 0 + while candidate in used: + candidate = f"{base}_{i}" + i += 1 + used.add(candidate) + return candidate + + +def _rewrite_target(target: Optional[str], + block_map: dict[str, BasicBlock]) -> Optional[str]: + if target is None: + return None + if "," in target: + parts = [p.strip() for p in target.split(",")] + return ",".join( + block_map[p].name if p in block_map else p for p in parts) + return block_map[target].name if target in block_map else target + + +class Inliner: + """Inline eligible CALL sites inside ``program`` (in place).""" + + def __init__(self, program: Program, + config: Optional[InlinerConfig] = None): + self.program = program + self.config = config or InlinerConfig() + self._stats: dict[str, int] = { + "inlined": 0, "rejected": 0, "rounds": 0} + self.warnings: list[str] = [] + + @property + def stats(self) -> dict[str, int]: + return dict(self._stats) + + def run(self) -> int: + """Run the inliner; returns the number of inlined call sites.""" + self._stats = {"inlined": 0, "rejected": 0, "rounds": 0} + self.warnings = [] + + callee_index = {f.name: f for f in self.program.functions} + graph = _build_call_graph(self.program, callee_index) + recursive = _recursive_functions(graph) + cloned: dict[str, int] = defaultdict(int) + processed: set[int] = set() + + for round_no in range(1, self.config.max_rounds + 1): + changed = False + for caller in list(self.program.functions): + while True: + site = None + for candidate in _collect_call_sites(caller): + if id(candidate[2]) not in processed: + site = candidate + break + if site is None: + break + + block, idx, call = site + callee = callee_index.get(call.target) + ok, reason = self._check_eligible( + site, callee_index, recursive, cloned) + if not ok: + self.warnings.append( + f"inliner: skip {call.target} at " + f"{caller.name}.{block.name}[{idx}]: {reason}") + self._stats["rejected"] += 1 + processed.add(id(call)) + continue + + k = self._stats["inlined"] + self._inline_site(caller, block, idx, call, callee, k) + cloned[callee.name] += _body_size(callee) + self._stats["inlined"] += 1 + changed = True + + self._stats["rounds"] = round_no + if not changed: + break + + return self._stats["inlined"] + + def _count_sites(self, callee: Function) -> int: + return sum( + 1 + for func in self.program.functions + for block in func.blocks + for ins in block.instructions + if ins.opcode is OpCode.CALL and ins.target == callee.name + ) + + def _check_eligible( + self, + site: tuple[BasicBlock, int, Instruction], + callee_index: dict[str, Function], + recursive: set[str], + cloned: dict[str, int], + ) -> tuple[bool, str]: + _block, _idx, call = site + callee = callee_index.get(call.target) + if callee is None: + return False, "callee_not_found" + if not callee.blocks: + return False, "empty_callee" + + argc = call.attrs.get("argc") + if not isinstance(argc, int) or argc != len(call.operands): + return False, "malformed_call" + if call.attrs.get("is_tail", False): + return False, "tail_unsupported" + if len(call.operands) != len(callee.params): + return False, "argc_mismatch" + if callee.name in recursive: + return False, "recursive_callee" + if self.config.reject_loops and _has_loop(callee): + return False, "loop_body_unsupported" + + rets = [ + ins + for b in callee.blocks + for ins in b.instructions + if ins.opcode is OpCode.RETURN + ] + valued = [ins for ins in rets if ins.operands] + if not rets: + # A callee without RETURN cannot terminate the cloned blocks. + return False, "missing_return" + if len(valued) > 1: + return False, "multiple_valued_returns" + if valued and len(valued) != len(rets): + # Mixed valued/void returns: the void paths would jump to the + # continuation without defining the substituted return value. + return False, "ret_arity_mismatch" + if valued and call.dest is None and not self.config.allow_ret_drop: + return False, "ret_arity_mismatch" + if not valued and call.dest is not None: + return False, "ret_arity_mismatch" + + undefined = self._undefined_operand(callee) + if undefined is not None: + # Dangling uses (e.g. cross-block definitions removed by the + # block-local DCE) must not be cloned into the caller. + return False, f"undefined_operand ({undefined})" + + size = _body_size(callee) + if size > self.config.max_instrs: + return False, ( + f"body_too_large ({size} > {self.config.max_instrs})") + if cloned[callee.name] + size > self.config.growth_budget: + return False, "growth_budget_exceeded" + if self.config.single_site_only and self._count_sites(callee) > 1: + return False, "multiple_call_sites" + return True, "" + + def _undefined_operand(self, callee: Function) -> Optional[str]: + """Return the name of a callee operand that has no definition. + + The inliner clones callee instructions verbatim, so a callee that + already contains dangling uses (for example after the existing + block-local DCE removed a cross-block definition) would silently + propagate them into the caller. Such callees are rejected instead. + """ + defined_ids: set[int] = set() + defined_names: set[str] = set() + for val in self.program.global_values: + defined_ids.add(id(val)) + defined_names.add(val.name) + for val in list(callee.params) + list(callee.locals): + defined_ids.add(id(val)) + defined_names.add(val.name) + for block in callee.blocks: + for ins in block.instructions: + if ins.dest is not None: + defined_ids.add(id(ins.dest)) + defined_names.add(ins.dest.name) + for block in callee.blocks: + for ins in block.instructions: + for val in ins.operands: + if val.is_constant: + continue + if id(val) in defined_ids or val.name in defined_names: + continue + return val.name + return None + + def _inline_site(self, caller: Function, block: BasicBlock, idx: int, + call: Instruction, callee: Function, k: int) -> None: + cont_name = _unique_block_name(caller, f"{caller.name}_inl{k}_cont") + + block_map: dict[str, BasicBlock] = {} + for callee_block in callee.blocks: + block_map[callee_block.name] = caller.new_block( + f"{callee.name}_{callee_block.name}_inl{k}") + + used = _caller_names(caller) + value_map: dict[int, Value] = {} + callee_dests = { + id(ins.dest) + for b in callee.blocks + for ins in b.instructions + if ins.dest is not None + } + + def map_value(v: Value) -> Value: + if v.is_constant and id(v) not in callee_dests: + return v + if id(v) not in value_map: + value_map[id(v)] = Value( + name=_unique(used, f"{v.name}_inl{k}"), dtype=v.dtype) + return value_map[id(v)] + + for param, arg in zip(callee.params, call.operands): + value_map[id(param)] = arg + for callee_block in callee.blocks: + for ins in callee_block.instructions: + candidates = ([ins.dest] if ins.dest is not None else []) + candidates.extend(ins.operands) + for v in candidates: + if v.name == param.name and id(v) not in value_map: + value_map[id(v)] = arg + + ret_value: Optional[Value] = None + for callee_block in callee.blocks: + new_block = block_map[callee_block.name] + for ins in callee_block.instructions: + if ins.opcode is OpCode.RETURN: + if ins.operands: + ret_value = map_value(ins.operands[0]) + new_block.add(Instruction(OpCode.BR, target=cont_name)) + continue + new_block.add(Instruction( + opcode=ins.opcode, + dest=map_value(ins.dest) if ins.dest is not None else None, + operands=[map_value(v) for v in ins.operands], + attrs=dict(ins.attrs), + # CALL targets are function names, not block names and + # must never be rewritten by the block name map. + target=(ins.target if ins.opcode is OpCode.CALL + else _rewrite_target(ins.target, block_map)), + )) + + tail = block.instructions[idx + 1:] + block.instructions = block.instructions[:idx] + [ + Instruction( + OpCode.BR, + target=block_map[callee.blocks[0].name].name, + ) + ] + cont = caller.new_block(cont_name) + if tail: + cont.instructions.extend(tail) + else: + self.warnings.append( + f"inliner: call at end of block {block.name} (invalid IR)") + + if call.dest is not None and ret_value is not None: + for caller_block in caller.blocks: + for ins in caller_block.instructions: + ins.operands = [ + ret_value if op is call.dest else op + for op in ins.operands + ] diff --git a/scratchv/optimizer/licm.py b/scratchv/optimizer/licm.py index e332b22..4541199 100644 --- a/scratchv/optimizer/licm.py +++ b/scratchv/optimizer/licm.py @@ -103,11 +103,12 @@ def _find_matching_endfor(self, instrs: list[Instruction], start: int): def _is_invariant(self, instr: Instruction, variant_names: set[str], loop_defs: set[str]) -> bool: """Check if an instruction is loop-invariant.""" - # Control flow and store instructions are never invariant + # Control flow, store and call instructions are never invariant: + # hoisting a CALL would change how often the callee runs. if instr.opcode in ( OpCode.STORE, OpCode.BR, OpCode.BR_IF, OpCode.RETURN, OpCode.FOR, OpCode.ENDFOR, - OpCode.LABEL): + OpCode.LABEL, OpCode.CALL): return False # An instruction is invariant if all its operands are: # - constants, or diff --git a/tests/test_backend_call.py b/tests/test_backend_call.py new file mode 100644 index 0000000..0647709 --- /dev/null +++ b/tests/test_backend_call.py @@ -0,0 +1,368 @@ +"""Tests for minimal CALL lowering in the RISC-V backend (Topic 15).""" + +import os +import subprocess +import sys +import textwrap + +import pytest + +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.instruction_select import ( + InstructionSelector, UnsupportedCallError, +) +from scratchv.backend.machine_types import MachineOp, MachineOperand +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import OpCode + + +def _program_with_call(n_args: int = 2, is_tail: bool = False, + has_ret: bool = True): + b = IRBuilder() + params = [b.make_value(name=f"p{i}") for i in range(n_args)] + b.new_function("inc", params=params) + b.new_block("entry") + if params: + total = params[0] + for p in params[1:]: + total = b.add(total, p) + b.ret(total) + else: + b.ret() + + b.new_function("main") + b.new_block("entry") + args = [b.make_value(name=f"x{i}") for i in range(n_args)] + r = b.call("inc", args, has_ret=has_ret, is_tail=is_tail) + b.ret(r) + return b.program + + +def _program_with_8_arg_call(): + b = IRBuilder() + params = [b.make_value(name=f"p{i}") for i in range(8)] + b.new_function("callee", params=params) + b.new_block("entry") + total = params[0] + for p in params[1:]: + total = b.add(total, p) + b.ret(total) + + b.new_function("main") + b.new_block("entry") + args = [b.make_value(name=f"x{i}") for i in range(8)] + r = b.call("callee", args) + b.ret(r) + return b.program + + +def _build_inc_callee(b: IRBuilder) -> None: + a = b.make_value(name="a") + b_val = b.make_value(name="b") + b.new_function("inc", params=[a, b_val]) + b.new_block("entry") + t = b.add(a, b_val) + b.ret(t) + + +def _extract_arg_moves(asm: str): + """Split the staged CALL argument moves into (stage1, stage2) pairs.""" + stage1: list[tuple[str, str]] = [] + stage2: list[tuple[str, str]] = [] + for line in asm.splitlines(): + line = line.strip() + if not line.startswith("mv "): + continue + body, _, comment = line[3:].partition("#") + dst, src = (part.strip() for part in body.split(",")) + comment = comment.strip() + if comment.startswith("arg") and comment.endswith("-> tmp"): + stage1.append((dst, src)) + elif comment.startswith("tmp -> a"): + stage2.append((dst, src)) + return stage1, stage2 + + +def _simulate_moves(moves): + """Symbolically execute moves; returns final register -> value mapping.""" + values: dict[str, str] = {} + for dst, src in moves: + values[dst] = values.get(src, src) + return values + + +def _assert_args_preserved(asm: str, n_args: int = 8) -> None: + """Every argument must reach its a{i} register unclobbered.""" + stage1, stage2 = _extract_arg_moves(asm) + assert len(stage1) == n_args + assert len(stage2) == n_args + assert [dst for dst, _ in stage2] == [f"a{i}" for i in range(n_args)] + values = _simulate_moves(stage1 + stage2) + assert [values[f"a{i}"] for i in range(n_args)] == [ + src for _, src in stage1] + + +def _call_instr(program): + return next( + ins + for func in program.functions + for block in func.blocks + for ins in block.instructions + if ins.opcode is OpCode.CALL + ) + + +def test_default_raises_unsupported_call_error(): + program = _program_with_call() + sel = InstructionSelector(program) + with pytest.raises(UnsupportedCallError) as excinfo: + sel.run() + msg = str(excinfo.value) + assert msg.startswith("CALL ") + assert "ABI" in msg + assert "inc" in msg + assert "'main'" in msg + + +def test_minimal_call_asm(): + program = _program_with_call() + call = _call_instr(program) + + sel = InstructionSelector(program, allow_uninlined_calls=True) + instrs = sel.run() + ops = [i.op.value for i in instrs] + assert "jal" in ops + assert "mv" in ops + + jal_index = next( + idx for idx, i in enumerate(instrs) if i.op is MachineOp.JAL) + jal = instrs[jal_index] + assert jal.dst == MachineOperand.reg("ra") + assert jal.comment == "inc" + + moves = [ + i for i in instrs[:jal_index] + if i.op is MachineOp.MV and ( + i.comment.startswith("arg") or i.comment.startswith("tmp -> a")) + ] + assert len(moves) == 4 # 2 staging + 2 argument registers + stage1, stage2 = moves[:2], moves[2:] + + # Stage 1 reads the arguments into fresh temporaries ... + assert stage1[0].dst.kind == "vreg" + assert stage1[0].dst == MachineOperand.vreg("_call_arg0_1") + assert stage1[0].src1 == MachineOperand.vreg("x0") + assert stage1[1].dst == MachineOperand.vreg("_call_arg1_1") + assert stage1[1].src1 == MachineOperand.vreg("x1") + assert stage1[0].dst != stage1[1].dst + + # ... stage 2 only then writes the physical a-registers. + assert stage2[0].dst == MachineOperand.reg("a0") + assert stage2[0].src1 == stage1[0].dst + assert stage2[1].dst == MachineOperand.reg("a1") + assert stage2[1].src1 == stage1[1].dst + + ret_move = instrs[jal_index + 1] + assert ret_move.op is MachineOp.MV + assert ret_move.dst == MachineOperand.vreg(call.dest.name) + assert ret_move.src1 == MachineOperand.reg("a0") + + asm = AsmEmitter(instrs).emit() + assert "jal ra, inc" in asm + + +def test_minimal_call_arg_moves_are_parallel_safe(): + """Stage-1 must read every argument before any a-register is written.""" + program = _program_with_8_arg_call() + sel = InstructionSelector(program, allow_uninlined_calls=True) + instrs = sel.run() + jal_index = next( + idx for idx, i in enumerate(instrs) if i.op is MachineOp.JAL) + + moves = [ + i for i in instrs[:jal_index] + if i.op is MachineOp.MV and ( + i.comment.startswith("arg") or i.comment.startswith("tmp -> a")) + ] + assert len(moves) == 16 + for instr in moves[:8]: + assert instr.dst.kind == "vreg" # staging reads only + for instr in moves[8:]: + assert instr.dst.kind == "reg" + assert instr.dst.value.startswith("a") + assert instr.src1.kind == "vreg" # a-regs written from temps only + + +@pytest.mark.parametrize("seed", ["0", "1", "4"]) +def test_minimal_call_asm_preserves_args_under_fixed_hash_seed(seed, tmp_path): + """End-to-end asm must stage arguments without clobbering sources. + + The linear allocator's decisions depend on the Python hash seed, so the + reviewed failing seeds are replayed in a subprocess. + """ + script = tmp_path / "emit_call_asm.py" + script.write_text(textwrap.dedent( + """ + import sys + from scratchv.compiler import CompilerConfig, CompilerDriver + from scratchv.ir.builder import IRBuilder + + b = IRBuilder() + params = [b.make_value(name=f"p{i}") for i in range(8)] + b.new_function("callee", params=params) + b.new_block("entry") + total = params[0] + for p in params[1:]: + total = b.add(total, p) + b.ret(total) + + b.new_function("main") + b.new_block("entry") + args = [b.make_value(name=f"x{i}") for i in range(8)] + r = b.call("callee", args) + b.ret(r) + + driver = CompilerDriver(CompilerConfig( + minimal_call_codegen=True, reg_alloc="linear")) + sys.stdout.write(driver._generate_riscv_linear(b.program)) + """ + )) + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env = dict(os.environ, PYTHONHASHSEED=seed, PYTHONPATH=root) + proc = subprocess.run( + [sys.executable, str(script)], capture_output=True, text=True, + env=env, cwd=root, check=True, + ) + _assert_args_preserved(proc.stdout) + + +def test_minimal_void_call_has_no_return_move(): + program = _program_with_call(n_args=1, has_ret=False) + sel = InstructionSelector(program, allow_uninlined_calls=True) + instrs = sel.run() + jal_index = next( + idx for idx, i in enumerate(instrs) if i.op is MachineOp.JAL) + assert not any( + i.op is MachineOp.MV for i in instrs[jal_index + 1:]) + + +def test_more_than_8_args_always_raises(): + program = _program_with_call(n_args=9) + sel = InstructionSelector(program, allow_uninlined_calls=True) + with pytest.raises(UnsupportedCallError) as excinfo: + sel.run() + assert "args > 8" in str(excinfo.value) + + +def test_tail_call_always_raises(): + program = _program_with_call(is_tail=True) + sel = InstructionSelector(program, allow_uninlined_calls=True) + with pytest.raises(UnsupportedCallError) as excinfo: + sel.run() + assert "tail" in str(excinfo.value) + + +def test_driver_default_rejects_residual_call(): + from scratchv.compiler import CompilerConfig, CompilerDriver + + program = _program_with_call() + driver = CompilerDriver(CompilerConfig()) + with pytest.raises(UnsupportedCallError): + driver._generate_riscv_linear(program) + + +def test_driver_minimal_call_codegen_emits_jal(): + from scratchv.compiler import CompilerConfig, CompilerDriver + + program = _program_with_call() + driver = CompilerDriver(CompilerConfig( + minimal_call_codegen=True, reg_alloc="greedy")) + asm = driver._generate_riscv_linear(program) + assert "jal ra, inc" in asm + + +def test_compile_reports_codegen_error_and_writes_nothing(tmp_path): + from scratchv.compiler import CompilerConfig, CompilerDriver + + program = _program_with_call() + output = tmp_path / "out.s" + driver = CompilerDriver(CompilerConfig()) + driver._parse = lambda *args, **kwargs: program + + result = driver.compile("dummy.onnx", str(output)) + + assert result.success is False + assert len(result.errors) == 1 + assert result.errors[0].startswith("Codegen error:") + assert "ABI" in result.errors[0] + assert "inc" in result.errors[0] + assert not output.exists() + + +def test_compile_failure_carries_inliner_warnings(tmp_path): + """Rejected inlining reasons survive into the failed CompileResult.""" + from scratchv.compiler import CompilerConfig, CompilerDriver + + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + r = b.call("f", [a]) # direct recursion: inliner rejects + b.ret(r) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + rm = b.call("f", [x]) + b.ret(rm) + + output = tmp_path / "out.s" + driver = CompilerDriver(CompilerConfig( + optimize_level="basic", inline=True)) + driver._parse = lambda *args, **kwargs: b.program + + result = driver.compile("dummy.onnx", str(output)) + + assert result.success is False + assert any("recursive_callee" in w for w in result.warnings) + assert any("inliner: skip" in w for w in result.warnings) + assert "Codegen error" in result.errors[0] + assert not output.exists() + + +def test_llvm_backend_rejects_residual_call(): + from scratchv.backend.llvm_codegen import LLVMCodegen + + program = _program_with_call() + with pytest.raises(UnsupportedCallError) as excinfo: + LLVMCodegen(program).emit() + msg = str(excinfo.value) + assert "CALL inc" in msg + assert "LLVM" in msg + + +def test_llvm_backend_inlined_program_still_emits(): + from scratchv.backend.llvm_codegen import LLVMCodegen + + b = IRBuilder() + _build_inc_callee(b) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + r = b.call("inc", [x, y]) + b.ret(r) + + from scratchv.optimizer.inliner import Inliner, InlinerConfig + assert Inliner(b.program, InlinerConfig()).run() == 1 + text = LLVMCodegen(b.program).emit() + assert "UNSUPPORTED" not in text + + +def test_dag_isel_call_reports_error(): + """DAG ISel has no CALL builder; the driver surfaces a clear failure.""" + from scratchv.compiler import CompilerConfig, CompilerDriver + + program = _program_with_call() + driver = CompilerDriver(CompilerConfig(use_dag_isel=True)) + with pytest.raises(ValueError, match="call"): + driver._generate_riscv_dag(program) diff --git a/tests/test_inliner.py b/tests/test_inliner.py new file mode 100644 index 0000000..1546a85 --- /dev/null +++ b/tests/test_inliner.py @@ -0,0 +1,921 @@ +"""Tests for the Topic 15 IR inliner.""" + +from scratchv.analysis.ir_verifier import ErrorLevel, IRVerifier +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import OpCode +from scratchv.optimizer.inline import Inliner as InlinerAlias +from scratchv.optimizer.inliner import Inliner, InlinerConfig + + +def _build_inc_callee(b: IRBuilder): + a = b.make_value(name="a") + b_val = b.make_value(name="b") + inc = b.new_function("inc", params=[a, b_val]) + b.new_block("entry") + t = b.add(a, b_val) + b.ret(t) + return inc + + +def _build_inc_caller(b: IRBuilder, args_pairs): + main = b.new_function("main") + b.new_block("entry") + results = [] + for pair in args_pairs: + results.append(b.call("inc", list(pair))) + if len(results) == 1: + b.ret(results[0]) + else: + total = b.add(results[0], results[1]) + b.ret(total) + return main, results + + +def _assert_no_call(program) -> None: + assert all( + ins.opcode is not OpCode.CALL + for func in program.functions + for block in func.blocks + for ins in block.instructions + ) + + +def _assert_verifier_clean(program) -> None: + errors = [ + e for e in IRVerifier(program).verify() + if e.level is ErrorLevel.ERROR + ] + assert errors == [] + + +def _undefined_operands(func): + """Names of non-constant operands with no definition in *func*. + + ``IRVerifier`` treats undefined operands as implicit inputs (existing + repository defect), so tests assert this explicitly for F2/F3/F4. + """ + defined_ids = {id(v) for v in func.params} + defined_ids |= {id(v) for v in func.locals} + defined_names = {v.name for v in func.params} + defined_names |= {v.name for v in func.locals} + for block in func.blocks: + for ins in block.instructions: + if ins.dest is not None: + defined_ids.add(id(ins.dest)) + defined_names.add(ins.dest.name) + + offenders = [] + for block in func.blocks: + for ins in block.instructions: + for op in ins.operands: + if op.is_constant: + continue + if id(op) in defined_ids or op.name in defined_names: + continue + offenders.append(f"{func.name}.{block.name}: ${op.name}") + return offenders + + +def _assert_operands_defined(program) -> None: + offenders = [ + offender + for func in program.functions + for offender in _undefined_operands(func) + ] + assert offenders == [] + + +def _assert_function_operands_defined(func) -> None: + assert _undefined_operands(func) == [] + + +def _block_by_name(func, name): + return next(block for block in func.blocks if block.name == name) + + +class TestInlineBasic: + def test_alias_module_exports_same_class(self): + assert InlinerAlias is Inliner + + def test_single_site_substitutes_args(self): + b = IRBuilder() + _build_inc_callee(b) + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(2.0) + y = b.make_const(3.0) + r = b.call("inc", [x, y]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig(max_instrs=8)) + assert inl.run() == 1 + assert inl.stats["inlined"] == 1 + assert inl.stats["rejected"] == 0 + assert set(inl.stats) == {"inlined", "rejected", "rounds"} + + instrs = [i for blk in main.blocks for i in blk.instructions] + assert all(i.opcode is not OpCode.CALL for i in instrs) + add = next(i for i in instrs if i.opcode is OpCode.ADD) + assert add.operands[0] is x + assert add.operands[1] is y + cont = _block_by_name(main, "main_inl0_cont") + ret = cont.instructions[-1] + assert ret.opcode is OpCode.RETURN + assert ret.operands[0] is add.dest + _assert_verifier_clean(b.program) + + def test_multi_site_independent_clones(self): + b = IRBuilder() + _build_inc_callee(b) + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(2.0) + y = b.make_const(3.0) + r1 = b.call("inc", [x, y]) + r2 = b.call("inc", [x, x]) + s = b.add(r1, r2) + b.ret(s) + + inl = Inliner(b.program, InlinerConfig(max_instrs=8)) + assert inl.run() == 2 + assert inl.stats["inlined"] == 2 + assert inl.stats["rejected"] == 0 + + assert [blk.name for blk in main.blocks] == [ + "entry", "inc_entry_inl0", "main_inl0_cont", + "inc_entry_inl1", "main_inl1_cont", + ] + _assert_no_call(b.program) + + add0 = _block_by_name(main, "inc_entry_inl0").instructions[0] + add1 = _block_by_name(main, "inc_entry_inl1").instructions[0] + assert add0.opcode is OpCode.ADD + assert add1.opcode is OpCode.ADD + assert add0.dest is not add1.dest + assert add0.operands[0] is x and add0.operands[1] is y + assert add1.operands[0] is x and add1.operands[1] is x + + ret = _block_by_name(main, "main_inl1_cont").instructions[-1] + assert ret.opcode is OpCode.RETURN + assert ret.operands[0] is s + s_instr = next( + i for i in main.blocks[-1].instructions if i.dest is s) + assert s_instr.opcode is OpCode.ADD + assert s_instr.operands[0] is add0.dest + assert s_instr.operands[1] is add1.dest + _assert_verifier_clean(b.program) + + def test_multi_site_with_internal_constants_renamed(self): + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("scale", params=[a]) + b.new_block("entry") + c = b.load_const(2.0) + t = b.mul(a, c) + b.ret(t) + + b.new_function("main") + b.new_block("entry") + x = b.make_const(3.0) + r1 = b.call("scale", [x]) + r2 = b.call("scale", [x]) + s = b.add(r1, r2) + b.ret(s) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 2 + assert inl.stats["inlined"] == 2 + + clone0 = _block_by_name(b.program.functions[-1], "scale_entry_inl0") + clone1 = _block_by_name(b.program.functions[-1], "scale_entry_inl1") + c0 = clone0.instructions[0] + c1 = clone1.instructions[0] + assert c0.opcode is OpCode.LOAD_CONST + assert c1.opcode is OpCode.LOAD_CONST + assert c0.dest is not c + assert c1.dest is not c + assert c0.dest is not c1.dest + assert c0.dest.name == f"{c.name}_inl0" + assert c1.dest.name == f"{c.name}_inl1" + _assert_verifier_clean(b.program) + + def test_first_block_branches_to_clone(self): + b = IRBuilder() + _build_inc_callee(b) + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + b.ret(b.call("inc", [x, y])) + + inl = Inliner(b.program, InlinerConfig(max_instrs=8)) + assert inl.run() == 1 + entry = main.blocks[0] + br = entry.instructions[-1] + assert br.opcode is OpCode.BR + assert br.target == "inc_entry_inl0" + assert main.blocks[1].name == "inc_entry_inl0" + + +class TestInlineReject: + @staticmethod + def _build_reject_program(): + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + b.load_const(1.0) + b.load_const(2.0) + b.load_const(3.0) + t = b.add(a, a) + b.ret(t) + + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + r = b.call("f", [x]) + b.ret(r) + return b, main + + def test_body_too_large_keeps_call(self): + b, main = self._build_reject_program() + before = b.program.dump() + + inl = Inliner(b.program, InlinerConfig(max_instrs=4)) + assert inl.run() == 0 + assert inl.stats["inlined"] == 0 + assert inl.stats["rejected"] == 1 + assert b.program.dump() == before + assert len(main.blocks) == 1 + assert any("body_too_large" in w and "5 > 4" in w + for w in inl.warnings) + + def test_threshold_boundary_inlines(self): + b, _main = self._build_reject_program() + inl = Inliner(b.program, InlinerConfig(max_instrs=5)) + assert inl.run() == 1 + assert inl.stats["inlined"] == 1 + assert inl.stats["rejected"] == 0 + _assert_no_call(b.program) + _assert_verifier_clean(b.program) + + def test_recursive_rejected(self): + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + r = b.call("f", [a]) + b.ret(r) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + rm = b.call("f", [x]) + b.ret(rm) + before = b.program.dump() + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["inlined"] == 0 + assert inl.stats["rejected"] == 2 + assert b.program.dump() == before + assert any("recursive_callee" in w for w in inl.warnings) + + def test_mutual_recursion_rejected(self): + b = IRBuilder() + pa = b.make_value(name="a") + b.new_function("p", params=[pa]) + b.new_block("entry") + r_p = b.call("q", [pa]) + b.ret(r_p) + qa = b.make_value(name="a") + b.new_function("q", params=[qa]) + b.new_block("entry") + r_q = b.call("p", [qa]) + b.ret(r_q) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + r_m = b.call("p", [x]) + b.ret(r_m) + before = b.program.dump() + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["inlined"] == 0 + assert inl.stats["rejected"] == 3 + assert b.program.dump() == before + assert sum(1 for w in inl.warnings if "recursive_callee" in w) == 3 + + def test_single_site_only_inlines_single_site(self): + b = IRBuilder() + _build_inc_callee(b) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + r = b.call("inc", [x, y]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig(single_site_only=True)) + assert inl.run() == 1 + assert inl.stats["inlined"] == 1 + assert inl.stats["rejected"] == 0 + _assert_no_call(b.program) + + def test_single_site_only_rejects_multiple_sites(self): + b = IRBuilder() + _build_inc_callee(b) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + r1 = b.call("inc", [x, y]) + r2 = b.call("inc", [x, x]) + s = b.add(r1, r2) + b.ret(s) + before = b.program.dump() + + inl = Inliner(b.program, InlinerConfig(single_site_only=True)) + assert inl.run() == 0 + assert inl.stats["inlined"] == 0 + assert inl.stats["rejected"] == 2 + assert b.program.dump() == before + assert all("multiple_call_sites" in w for w in inl.warnings) + + def test_growth_budget_stops_cloning(self): + b = IRBuilder() + _build_inc_callee(b) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + r1 = b.call("inc", [x, y]) + r2 = b.call("inc", [x, x]) + s = b.add(r1, r2) + b.ret(s) + + inl = Inliner(b.program, InlinerConfig(growth_budget=2)) + assert inl.run() == 1 + assert inl.stats["inlined"] == 1 + assert inl.stats["rejected"] == 1 + assert any("growth_budget_exceeded" in w for w in inl.warnings) + + def test_argc_mismatch_rejected(self): + b = IRBuilder() + _build_inc_callee(b) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + r = b.call("inc", [x]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["rejected"] == 1 + assert any("argc_mismatch" in w for w in inl.warnings) + + def test_malformed_argc_rejected(self): + b = IRBuilder() + _build_inc_callee(b) + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + r = b.call("inc", [x, y]) + b.ret(r) + main.blocks[0].instructions[0].attrs["argc"] = 5 + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["rejected"] == 1 + assert any("malformed_call" in w for w in inl.warnings) + + def test_callee_not_found_rejected(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + r = b.call("missing", [x]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["rejected"] == 1 + assert any("callee_not_found" in w for w in inl.warnings) + + def test_ret_arity_mismatch_rejected(self): + b = IRBuilder() + v = b.make_value(name="v") + b.new_function("report", params=[v]) + b.new_block("entry") + b.ret() + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + r = b.call("report", [x]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["rejected"] == 1 + assert any("ret_arity_mismatch" in w for w in inl.warnings) + + def test_valued_callee_without_dest_rejected(self): + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + t = b.add(a, a) + b.ret(t) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + b.call("f", [x], has_ret=False) + b.ret(x) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["rejected"] == 1 + assert any("ret_arity_mismatch" in w for w in inl.warnings) + + def test_tail_call_rejected(self): + b = IRBuilder() + _build_inc_callee(b) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + r = b.call("inc", [x, y], is_tail=True) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["rejected"] == 1 + assert any("tail_unsupported" in w for w in inl.warnings) + + def test_loop_body_rejected(self): + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + b.for_loop(0, 4) + b.endfor() + b.ret(a) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + r = b.call("f", [x]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["rejected"] == 1 + assert any("loop_body_unsupported" in w for w in inl.warnings) + + def test_multiple_valued_returns_rejected(self): + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + t1 = b.add(a, a) + b.ret(t1) + b.new_block("other") + t2 = b.mul(a, a) + b.ret(t2) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + r = b.call("f", [x]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["rejected"] == 1 + assert any("multiple_valued_returns" in w for w in inl.warnings) + + def test_mixed_return_forms_rejected(self): + """One valued + one void RETURN must not be inlined (F3).""" + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + c = b.load_const(1.0) + t = b.add(a, a) + b.br_if(c, "r1", "r2") + b.new_block("r1") + b.ret(t) + b.new_block("r2") + b.ret() + b.new_function("main") + b.new_block("entry") + x = b.make_const(3.0) + r = b.call("f", [x]) + b.ret(r) + before = b.program.dump() + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["inlined"] == 0 + assert inl.stats["rejected"] == 1 + assert b.program.dump() == before + assert any("ret_arity_mismatch" in w for w in inl.warnings) + _assert_operands_defined(b.program) + + def test_callee_without_return_rejected(self): + """A callee with no RETURN would clone unterminated blocks (F3).""" + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("trap", params=[a]) + b.new_block("entry") + b.add(a, a) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + b.call("trap", [x], has_ret=False) + b.ret(x) + before = b.program.dump() + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 0 + assert inl.stats["inlined"] == 0 + assert inl.stats["rejected"] == 1 + assert b.program.dump() == before + assert any("missing_return" in w for w in inl.warnings) + _assert_operands_defined(b.program) + + +class TestInlineBlocks: + def test_void_multi_return_redirects(self): + b = IRBuilder() + v = b.make_value(name="v") + b.new_function("report", params=[v]) + b.new_block("entry") + c = b.load_const(0.0) + b.br_if(c, "done1", "done2") + b.new_block("done1") + b.ret() + b.new_block("done2") + b.ret() + + main = b.new_function("main") + b.new_block("entry") + x = b.load_const(1.0) + b.call("report", [x], has_ret=False) + b.ret(x) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 1 + assert inl.stats["inlined"] == 1 + assert inl.stats["rejected"] == 0 + _assert_no_call(b.program) + + clone_entry = _block_by_name(main, "report_entry_inl0") + assert all(i.opcode is not OpCode.RETURN + for i in clone_entry.instructions) + c_clone = clone_entry.instructions[0] + assert c_clone.opcode is OpCode.LOAD_CONST + assert c_clone.dest is not c + assert c_clone.dest.name == f"{c.name}_inl0" + br_if = clone_entry.instructions[1] + assert br_if.opcode is OpCode.BR_IF + assert br_if.target == "report_done1_inl0,report_done2_inl0" + + for name in ("report_done1_inl0", "report_done2_inl0"): + done = _block_by_name(main, name) + assert len(done.instructions) == 1 + assert done.instructions[0].opcode is OpCode.BR + assert done.instructions[0].target == "main_inl0_cont" + + cont = _block_by_name(main, "main_inl0_cont") + ret = cont.instructions[-1] + assert ret.opcode is OpCode.RETURN + assert ret.operands[0] is x + _assert_verifier_clean(b.program) + + def test_multi_block_valued_return(self): + b = IRBuilder() + v = b.make_value(name="v") + b.new_function("f", params=[v]) + b.new_block("entry") + c = b.load_const(1.0) + b.br_if(c, "left", "right") + b.new_block("left") + t1 = b.add(v, v) + b.br("join") + b.new_block("right") + b.mul(v, v) + b.br("join") + b.new_block("join") + b.ret(t1) + + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(3.0) + r = b.call("f", [x]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 1 + assert inl.stats["inlined"] == 1 + _assert_no_call(b.program) + + entry = _block_by_name(main, "f_entry_inl0") + br_if = entry.instructions[-1] + assert br_if.opcode is OpCode.BR_IF + assert br_if.target == "f_left_inl0,f_right_inl0" + + left = _block_by_name(main, "f_left_inl0") + add_clone = left.instructions[0] + assert add_clone.opcode is OpCode.ADD + assert add_clone.operands[0] is x and add_clone.operands[1] is x + assert left.instructions[-1].target == "f_join_inl0" + + right = _block_by_name(main, "f_right_inl0") + assert right.instructions[-1].target == "f_join_inl0" + + cont = _block_by_name(main, "main_inl0_cont") + ret = cont.instructions[-1] + assert ret.opcode is OpCode.RETURN + assert ret.operands[0] is add_clone.dest + _assert_verifier_clean(b.program) + + def test_nested_call_in_clone_is_inlined(self): + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("double", params=[a]) + b.new_block("entry") + t = b.add(a, a) + b.ret(t) + + m = b.make_value(name="m") + b.new_function("quad", params=[m]) + b.new_block("entry") + d = b.call("double", [m]) + q = b.call("double", [d]) + b.ret(q) + + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + r = b.call("quad", [x]) + b.ret(r) + + inl = Inliner(b.program, InlinerConfig(max_instrs=8)) + assert inl.run() == 3 + assert inl.stats["inlined"] == 3 + assert inl.stats["rejected"] == 0 + _assert_no_call(b.program) + _assert_verifier_clean(b.program) + _assert_operands_defined(b.program) + + def test_call_target_ignores_block_name_collision(self): + """A callee block named like the called function must not rewrite + the nested CALL target (F2).""" + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("g", params=[a]) + b.new_block("entry") + r = b.call("g", [a]) # recursive: stays a CALL + b.ret(r) + + fa = b.make_value(name="a") + b.new_function("f", params=[fa]) + b.new_block("g") # block name collides with function name g + rg = b.call("g", [fa]) + b.ret(rg) + + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + rm = b.call("f", [x]) + b.ret(rm) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 1 + assert inl.stats["inlined"] == 1 + assert not any("callee_not_found" in w for w in inl.warnings) + + # The clone of f's block "g" keeps the CALL to function "g". + clone_calls = [ + ins.target + for blk in main.blocks + for ins in blk.instructions + if ins.opcode is OpCode.CALL + ] + assert clone_calls == ["g"] + _assert_operands_defined(b.program) + + def test_call_at_end_of_block_warns(self): + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + t = b.add(a, a) + b.ret(t) + + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + b.call("f", [x]) + + inl = Inliner(b.program, InlinerConfig()) + assert inl.run() == 1 + assert any("call at end of block" in w for w in inl.warnings) + cont = _block_by_name(main, "main_inl0_cont") + assert cont.instructions == [] + + +class TestPipelineIntegration: + def test_optimizer_pipeline_inlines_and_reports(self): + from scratchv.compiler import CompilerConfig, CompilerDriver + + b = IRBuilder() + _build_inc_callee(b) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + r = b.call("inc", [x, y]) + b.ret(r) + + driver = CompilerDriver(CompilerConfig( + optimize_level="basic", inline=True, inline_max_instrs=8)) + result = driver._run_optimizations(b.program) + + assert result.changes == 1 + assert "[inliner]" in result.message + _assert_no_call(b.program) + + def test_optimizer_pipeline_surfaces_rejections(self): + from scratchv.compiler import CompilerConfig, CompilerDriver + + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + r = b.call("f", [a]) + b.ret(r) + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + rm = b.call("f", [x]) + b.ret(rm) + + driver = CompilerDriver(CompilerConfig( + optimize_level="basic", inline=True)) + result = driver._run_optimizations(b.program) + + assert result.changes == 0 + assert any("recursive_callee" in w for w in result.warnings) + assert any("inliner: skip" in w for w in result.warnings) + + def test_inline_requires_optimize_warning(self, tmp_path): + from scratchv.compiler import CompilerConfig, CompilerDriver + + driver = CompilerDriver(CompilerConfig( + optimize_level="none", inline=True)) + result = driver.compile( + input_path="", + output_path=str(tmp_path / "out.s"), + dsl_source="c = add(a, b)\nreturn c\n", + ) + + assert result.success + assert any("inliner requires" in w for w in result.warnings) + + def test_cli_flags_map_to_config(self): + from scratchv.main import args_to_config, build_arg_parser + + args = build_arg_parser().parse_args([ + "--inline", "--inline-max-instrs", "7", + "--inline-single-site", "--minimal-call-codegen", + "input.dsl", + ]) + config = args_to_config(args) + + assert config.inline is True + assert config.inline_max_instrs == 7 + assert config.inline_single_site is True + assert config.minimal_call_codegen is True + + def test_dce_keeps_call_with_unused_result(self): + """CALL has unknown side effects and must survive DCE (F1).""" + from scratchv.compiler import CompilerConfig, CompilerDriver + + b = IRBuilder() + p = b.make_value(name="p") + v = b.make_value(name="v") + b.new_function("side_effect", params=[p, v]) + b.new_block("entry") + b.store(p, v) + b.ret() + b.new_function("main") + b.new_block("entry") + x = b.make_const(1.0) + y = b.make_const(2.0) + b.call("side_effect", [x, y]) # result unused + b.ret(x) + + driver = CompilerDriver(CompilerConfig(optimize_level="basic")) + driver._run_optimizations(b.program) + + calls = [ + ins + for func in b.program.functions + for block in func.blocks + for ins in block.instructions + if ins.opcode is OpCode.CALL + ] + assert len(calls) == 1 + stores = [ + ins + for func in b.program.functions + for block in func.blocks + for ins in block.instructions + if ins.opcode is OpCode.STORE + ] + assert len(stores) == 1 + + def test_licm_does_not_hoist_call_out_of_loop(self): + """Calling once per iteration cannot become a loop invariant (F1).""" + from scratchv.compiler import CompilerConfig, CompilerDriver + + b = IRBuilder() + a = b.make_value(name="a") + b.new_function("f", params=[a]) + b.new_block("entry") + iv = b.for_loop(0, 4) + r = b.call("f", [a]) # recursive: CALL survives inlining + s = b.add(iv, r) + b.endfor() + b.ret(s) + + driver = CompilerDriver(CompilerConfig(optimize_level="all")) + driver._run_optimizations(b.program) + + instrs = b.program.functions[0].blocks[0].instructions + ops = [ins.opcode for ins in instrs] + for_index = ops.index(OpCode.FOR) + call_index = ops.index(OpCode.CALL) + endfor_index = ops.index(OpCode.ENDFOR) + assert for_index < call_index < endfor_index + + def test_multi_block_callee_damaged_by_block_local_dce_is_rejected(self): + """F4: DCE removes cross-block definitions before the inliner runs. + + The damaged callee must be rejected (no silent dangling clone) and + the caller must keep a well-formed CALL. + """ + from scratchv.compiler import CompilerConfig, CompilerDriver + + b = IRBuilder() + v = b.make_value(name="v") + b.new_function("g", params=[v]) + b.new_block("entry") + c = b.load_const(1.0) + b.br_if(c, "left", "join") + b.new_block("left") + t = b.add(v, v) + b.br("join") + b.new_block("join") + b.ret(t) + + main = b.new_function("main") + b.new_block("entry") + x = b.make_const(3.0) + r = b.call("g", [x]) + b.ret(r) + + driver = CompilerDriver(CompilerConfig( + optimize_level="basic", inline=True)) + result = driver._run_optimizations(b.program) + + assert any("undefined_operand" in w for w in result.warnings) + assert not any( + blk.name.startswith("g_") for blk in main.blocks) + assert len(main.blocks) == 1 + _assert_function_operands_defined(main) + + def test_cli_prints_inliner_notes_on_codegen_failure( + self, tmp_path, capsys, monkeypatch): + """F7: rejection reasons are visible when codegen fails.""" + from scratchv import main as main_module + from scratchv.compiler import CompileResult + + def fake_compile(self, *args, **kwargs): + return CompileResult( + success=False, + errors=["Codegen error: CALL f: ABI not implemented"], + warnings=["inliner: skip f at main.entry[0]: recursive_callee"], + ) + + monkeypatch.setattr( + main_module.CompilerDriver, "compile", fake_compile) + code = main_module.main([ + str(tmp_path / "in.dsl"), "-o", str(tmp_path / "out.s")]) + + assert code == 1 + stderr = capsys.readouterr().err + assert "Error: Codegen error" in stderr + assert "note: inliner: skip f" in stderr + assert "recursive_callee" in stderr diff --git a/tests/test_ir_call.py b/tests/test_ir_call.py new file mode 100644 index 0000000..78e9b3e --- /dev/null +++ b/tests/test_ir_call.py @@ -0,0 +1,79 @@ +"""Tests for OpCode.CALL and IRBuilder.call (Topic 15).""" + +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, OpCode + + +class TestCallOpcode: + def test_opcode_value(self): + assert OpCode.CALL.value == "call" + + def test_is_call(self): + assert OpCode.CALL.is_call() is True + assert OpCode.ADD.is_call() is False + assert OpCode.RETURN.is_call() is False + + def test_call_is_not_control_flow(self): + assert OpCode.CALL.is_control_flow() is False + assert OpCode.RETURN.is_control_flow() is True + + +class TestBuilderCall: + def test_call_layout(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + x = b.make_value(name="x") + y = b.make_value(name="y") + r = b.call("inc", [x, y]) + + instr = b.current_block.instructions[-1] + assert instr.opcode is OpCode.CALL + assert instr.dest is r + assert instr.operands == [x, y] + assert instr.target == "inc" + assert instr.attrs["argc"] == 2 + assert instr.attrs["is_tail"] is False + + def test_call_no_ret(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + x = b.make_value(name="x") + r = b.call("report", [x], has_ret=False) + + assert r is None + instr = b.current_block.instructions[-1] + assert instr.opcode is OpCode.CALL + assert instr.dest is None + assert instr.attrs["argc"] == 1 + + def test_call_dtype(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + r = b.call("f", dtype=DataType.INT32) + + assert r is not None + assert r.dtype is DataType.INT32 + assert b.current_block.instructions[-1].attrs["argc"] == 0 + + def test_call_is_tail_attr(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + b.call("f", is_tail=True) + assert b.current_block.instructions[-1].attrs["is_tail"] is True + + def test_call_dump(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + x = b.make_value(name="x") + y = b.make_value(name="y") + r = b.call("inc", [x, y]) + b.ret(r) + + dump = b.program.dump() + expected = f"${r.name} = call $x $y -> inc [argc=2] [is_tail=False]" + assert expected in dump diff --git a/tests/test_topic15_inline_case_report.py b/tests/test_topic15_inline_case_report.py new file mode 100644 index 0000000..571e005 --- /dev/null +++ b/tests/test_topic15_inline_case_report.py @@ -0,0 +1,193 @@ +"""Tests for the Topic 15 inliner feature case report. + +The report is the CI artifact that proves the inliner removes eligible CALL +sites with independent clones, keeps refused CALLs untouched, and produces +identical IR fingerprints across runs. The DSL/ONNX frontends never emit +CALL, so the case itself is programmatic IR. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from benchmarks import run_topic15_inline_case as runner +from benchmarks.cases.topic15_inline_feature import ( + build_program, + build_rejected_program, +) +from scratchv.analysis.ir_verifier import ErrorLevel, IRVerifier +from scratchv.ir.types import OpCode +from scratchv.optimizer.inliner import Inliner + +CASE = ( + Path(__file__).resolve().parents[1] + / "benchmarks" / "cases" / "topic15_inline_feature.py" +) + + +def _calls(program): + return [ + ins + for func in program.functions + for block in func.blocks + for ins in block.instructions + if ins.opcode is OpCode.CALL + ] + + +def _error_messages(program): + return [ + err.message + for err in IRVerifier(program).verify() + if err.level is ErrorLevel.ERROR + ] + + +def test_case_program_shape_and_verifier_clean(): + program = build_program() + calls = _calls(program) + assert [call.target for call in calls] == ["inc", "inc"] + assert all(call.attrs["argc"] == 1 for call in calls) + + # Known verifier defect: a residual CALL's function-name target is + # checked as a block label, so the uninlined program reports exactly one + # spurious ERROR per CALL. After inlining there must be none. + uninlined = _error_messages(program) + assert len(uninlined) == 2 + assert all("jump target 'inc' does not exist" in msg + for msg in uninlined) + + inliner = Inliner(program, runner.default_inliner_config()) + assert inliner.run() == 2 + assert _error_messages(program) == [] + + +def test_measure_ab_counts(): + off = runner._measure_uninlined(build_program) + on = runner._measure_inlined(build_program, repeats=1) + + assert off["call_count"] == 2 + assert off["clones"] == 0 and off["clone_count"] == 0 + assert on["call_count"] == 0 + assert on["clones"] == 2 and on["clone_count"] == 2 + assert on["rejected"] == 0 and on["warnings"] == [] + assert on["pass_time_ms"] is not None and on["pass_time_ms"] >= 0 + assert on["ir_instructions"] - off["ir_instructions"] == ( + on["clones"] * off["callee_body_size"]) + + +def test_clone_namespaces_and_returns_are_independent(): + on = runner._measure_inlined(build_program, repeats=1) + details = {detail["index"]: detail for detail in on["clones_detail"]} + assert set(details) == {0, 1} + + dests0 = set(details[0]["dest_names"]) + dests1 = set(details[1]["dest_names"]) + assert dests0 and dests1 and not (dests0 & dests1) + assert all(name.endswith("_inl0") for name in dests0) + assert all(name.endswith("_inl1") for name in dests1) + assert details[0]["returnless"] and details[1]["returnless"] + assert on["clone_returns"] == 0 + assert on["returns_in_callee"] == 1 + assert not any(on["duplicate_defined_names"].values()) + + for detail in details.values(): + assert len(detail["return_redirects"]) == len(detail["blocks"]) + for target in detail["return_redirects"].values(): + assert target in on["block_names"] + assert target.endswith("_cont") + + +def test_rejected_branch_keeps_calls_and_records_warnings(): + rejected = runner._measure_rejected(build_rejected_program) + + assert rejected["clones"] == 0 + assert rejected["rejected"] == 2 + assert rejected["call_count"] == 2 + assert rejected["dump_unchanged"] is True + assert len(rejected["warnings"]) == 2 + assert any("body_too_large (9 > 4)" in w for w in rejected["warnings"]) + assert any("loop_body_unsupported" in w for w in rejected["warnings"]) + assert all(w.startswith("inliner: skip") for w in rejected["warnings"]) + + +def test_evaluate_passes_all_hard_checks_and_is_deterministic(): + report = runner.evaluate(CASE, repeats=1) + assert report["schema_version"] == runner.SCHEMA_VERSION + assert report["topic"] == "topic15-function-inline" + assert report["hard_failures"] == [] + assert all(report["hard_checks"].values()) + assert report["honesty"] + + on = report["inlined"] + assert set(on["fingerprints"]) == {on["fingerprint"]} + second = runner.evaluate(CASE, repeats=1) + assert second["inlined"]["fingerprint"] == on["fingerprint"] + assert ( + second["rejected"]["fingerprint"] + == report["rejected"]["fingerprint"] + ) + + +def test_main_writes_json_and_markdown(tmp_path, capsys): + json_path = tmp_path / "report.json" + md_path = tmp_path / "report.md" + exit_code = runner.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"] == "topic15-function-inline" + assert data["inlined"]["call_count"] == 0 + assert data["rejected"]["rejected"] == 2 + + markdown = md_path.read_text() + assert "# Topic 15 Function-Inline Feature Case" in markdown + assert "## A/B summary" in markdown + assert "## Clone detail" in markdown + assert "## Rejection detail" in markdown + assert "## Hard checks" in markdown + assert "## Honesty" in markdown + assert capsys.readouterr().out + + +def test_main_rejects_missing_case(tmp_path): + with pytest.raises(SystemExit) as exc: + runner.main(["--case", str(tmp_path / "missing.py")]) + assert exc.value.code == 2 + + +def test_hard_check_gate_is_not_vacuous(monkeypatch, tmp_path): + """A no-op inliner must surface as hard failures and exit code 1.""" + + class NoopInliner: + def __init__(self, program, config=None): + self.program = program + self.stats = {"inlined": 0, "rejected": 0, "rounds": 0} + self.warnings = [] + + def run(self): + return 0 + + monkeypatch.setattr(runner, "Inliner", NoopInliner) + report = runner.evaluate(CASE, repeats=1) + assert "inlined_removes_all_calls" in report["hard_failures"] + assert "inliner_clones_each_site" in report["hard_failures"] + assert "clone_returns_rewritten_to_branches" in report["hard_failures"] + assert "ir_grows_by_cloned_bodies" in report["hard_failures"] + + exit_code = runner.main([ + "--case", str(CASE), + "--json", str(tmp_path / "report.json"), + "--markdown", str(tmp_path / "report.md"), + "--repeats", "1", + ]) + assert exit_code == 1