diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e536dc6..ebafc415 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,9 @@ jobs: command -v clang command -v ld.lld command -v qemu-riscv32 + clang --version + ld.lld --version + qemu-riscv32 --version $PY -c 'from scratchv.backend.llvm_mca import check_version; print(check_version("llvm-mca"))' # Includes scheduling and standalone CNN execution regressions. diff --git a/CLAUDE.md b/CLAUDE.md index 6583cfbb..43dbfcf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,7 @@ docs/ ## 代码风格 -- Python 3.12+, type hints +- Python 3.8+ compatible, type hints;版本与注解规则以 `CONTRIBUTING.md` 为准。 +- 测试沿用现有 CI 流程;按用户要求移除 Python 3.8 专项测试。项目最低版本仍为 Python 3.8,代码必须兼容该版本。 - argparse CLI: `--json` (bool, stdout), `--json-output` (path), `--markdown` (path) - 报告工具: HTML/JSON/MD 三种格式,零外部可视化依赖 diff --git a/README.md b/README.md index 5cf2daec..bc0dc264 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **从 ONNX 到 RISC-V 机器码 — 一个你完全看得懂的 AI 编译器。** -[![Python](https://img.shields.io/badge/python-3.12+-blue)](https://python.org) +[![Python](https://img.shields.io/badge/python-3.8+-blue)](https://python.org) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![CI](https://github.com/ScratchV-Compiler/ScratchV/actions/workflows/ci.yml/badge.svg)](https://github.com/ScratchV-Compiler/ScratchV/actions) [![Docs](https://img.shields.io/badge/docs-课程站点-blue)](https://scratchv-compiler.github.io/ScratchV/docs/) diff --git a/benchmarks/bench_cnn_schedule.py b/benchmarks/bench_cnn_schedule.py index 4d53b704..094b15e5 100644 --- a/benchmarks/bench_cnn_schedule.py +++ b/benchmarks/bench_cnn_schedule.py @@ -272,12 +272,16 @@ def run_benchmark(model_path: Path = DEFAULT_MODEL, *, standalone_asm: Path | No } if any(value is False for value in primary["acceptance"].values()): primary["status"] = "failed" + try: + input_path = model_path.relative_to(ROOT).as_posix() + except ValueError: + input_path = str(model_path) return { "schema_version": 4, "benchmark_type": "cnn-scheduling-ab", "status": "passed" if all(c["status"] == "passed" for c in cases) else "failed", "validation_scope": "static_and_llvm" + ("_and_execution" if execute else ""), "whole_cnn_execution": primary["execution"]["status"], - "input": {"path": model_path.relative_to(ROOT).as_posix() if model_path.is_relative_to(ROOT) else str(model_path), + "input": {"path": input_path, "sha256": sha256(model_bytes), "bytes": len(model_bytes), "nodes": len(model.graph.node), "operators": dict(sorted(Counter(n.op_type for n in model.graph.node).items()))}, "environment": {"python": platform.python_version(), "onnx": onnx.__version__, diff --git a/benchmarks/bench_dsl_diagnostics.py b/benchmarks/bench_dsl_diagnostics.py index 5894c29a..e051773c 100644 --- a/benchmarks/bench_dsl_diagnostics.py +++ b/benchmarks/bench_dsl_diagnostics.py @@ -64,8 +64,12 @@ def parse_worker(args: argparse.Namespace) -> dict: from scratchv.frontend import dsl_extended parser_file = Path(dsl_extended.__file__).resolve() - if not parser_file.is_relative_to(args.worker_root): - raise RuntimeError(f"parser imported outside selected checkout: {parser_file}") + try: + parser_file.relative_to(args.worker_root) + except ValueError: + raise RuntimeError( + f"parser imported outside selected checkout: {parser_file}" + ) from None paths = sorted(args.cases.glob("*.dsl")) if not paths: raise ValueError(f"no DSL cases in {args.cases}") diff --git a/benchmarks/compare_schedule_disabled.py b/benchmarks/compare_schedule_disabled.py index dfedd8ef..aaaf6889 100644 --- a/benchmarks/compare_schedule_disabled.py +++ b/benchmarks/compare_schedule_disabled.py @@ -28,8 +28,10 @@ def collect(root: Path) -> dict: from scratchv.standalone.onnx_to_riscv_standalone import convert_onnx_to_riscv from scripts.run_topic06_benchmarks import run_simulation - if not Path(compiler.__file__).resolve().is_relative_to(root): - raise RuntimeError("Compiler imported outside the selected checkout") + try: + Path(compiler.__file__).resolve().relative_to(root) + except ValueError: + raise RuntimeError("Compiler imported outside the selected checkout") from None cases = sorted((root / "benchmarks/cases").glob("*.dsl")) cases += sorted((root / "tests/topic06/cases").rglob("*.dsl")) cases.append(root / "models/graph/cnn.onnx") diff --git a/benchmarks/generate_models.py b/benchmarks/generate_models.py index da68f93b..b206e691 100644 --- a/benchmarks/generate_models.py +++ b/benchmarks/generate_models.py @@ -4,6 +4,8 @@ Supported ops: Add, Mul, Sub, Div, Relu, MatMul, MaxPool, GeLU, Softmax, Neg, Exp """ +from __future__ import annotations + import os import numpy as np diff --git a/benchmarks/test_regalloc/bench_cnn.py b/benchmarks/test_regalloc/bench_cnn.py index df25ec8e..ffd23f91 100644 --- a/benchmarks/test_regalloc/bench_cnn.py +++ b/benchmarks/test_regalloc/bench_cnn.py @@ -6,6 +6,8 @@ verifies execution via the RV32 emulator. """ +from __future__ import annotations + import argparse import os import re diff --git a/benchmarks/test_regalloc/bench_dense.py b/benchmarks/test_regalloc/bench_dense.py index d5eec4f4..404bc587 100644 --- a/benchmarks/test_regalloc/bench_dense.py +++ b/benchmarks/test_regalloc/bench_dense.py @@ -5,6 +5,8 @@ registers than physical registers. """ +from __future__ import annotations + import argparse import os import random diff --git a/benchmarks/test_regalloc/bench_regalloc_linear.py b/benchmarks/test_regalloc/bench_regalloc_linear.py index a3b6a72b..52589da5 100644 --- a/benchmarks/test_regalloc/bench_regalloc_linear.py +++ b/benchmarks/test_regalloc/bench_regalloc_linear.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Run all register allocation benchmarks and produce a report.""" +from __future__ import annotations + import argparse import datetime import json diff --git a/benchmarks/test_regalloc/bench_simple.py b/benchmarks/test_regalloc/bench_simple.py index 64e46652..c1f305dc 100644 --- a/benchmarks/test_regalloc/bench_simple.py +++ b/benchmarks/test_regalloc/bench_simple.py @@ -5,6 +5,8 @@ physical registers are plentiful. """ +from __future__ import annotations + import argparse import os import random diff --git a/benchmarks/test_regalloc/bench_utils.py b/benchmarks/test_regalloc/bench_utils.py index 6d6e4942..39e84e53 100644 --- a/benchmarks/test_regalloc/bench_utils.py +++ b/benchmarks/test_regalloc/bench_utils.py @@ -1,6 +1,8 @@ # flake8: noqa """Common used benchmark utils for all benchmarks""" +from __future__ import annotations + # Common used constants _CALLEE_SAVED = { "ra", diff --git a/benchmarks/topic28/f32_sum.dsl b/benchmarks/topic28/f32_sum.dsl new file mode 100644 index 00000000..f2f4275f --- /dev/null +++ b/benchmarks/topic28/f32_sum.dsl @@ -0,0 +1,4 @@ +a = const(1.5, dtype:f32) +b = const(2.0, dtype:f32) +result = add(a, b) +return result diff --git a/benchmarks/topic28/f64_dot.dsl b/benchmarks/topic28/f64_dot.dsl new file mode 100644 index 00000000..c8426dfd --- /dev/null +++ b/benchmarks/topic28/f64_dot.dsl @@ -0,0 +1,8 @@ +a = const(1.5, dtype:f64) +b = const(2.0, dtype:f64) +c = const(3.0, dtype:f64) +d = const(4.0, dtype:f64) +left = mul(a, b) +right = mul(c, d) +result = add(left, right) +return result diff --git a/benchmarks/topic28/f64_sum.dsl b/benchmarks/topic28/f64_sum.dsl new file mode 100644 index 00000000..25961014 --- /dev/null +++ b/benchmarks/topic28/f64_sum.dsl @@ -0,0 +1,6 @@ +a = const(1.5, dtype:f64) +b = const(2.0, dtype:f64) +c = const(3.25, dtype:f64) +partial = add(a, b) +result = add(partial, c) +return result diff --git a/benchmarks/topic28/i32_div_rem.dsl b/benchmarks/topic28/i32_div_rem.dsl new file mode 100644 index 00000000..b0220390 --- /dev/null +++ b/benchmarks/topic28/i32_div_rem.dsl @@ -0,0 +1,6 @@ +a = const(-17, dtype:i32) +b = const(5, dtype:i32) +quotient = div(a, b) +remainder = rem(a, b) +result = add(quotient, remainder) +return result diff --git "a/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-README.md" "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-README.md" new file mode 100644 index 00000000..0591b8ae --- /dev/null +++ "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-README.md" @@ -0,0 +1,60 @@ +# Topic 28 使用指南 + +本课题完成整数算子扩展及标量 f32/f64 的 RISC-V F/D 指令选择。交付与完成条件见[交付与验收](28-交付与验收.md),详细合同见[技术设计](28-扩展指令选择-设计文档.md)。 + +## Python 版本 + +项目最低支持 Python 3.8,与安装配置一致,代码必须兼容该版本。现有 CI 使用 Python 3.12 执行功能测试;这是测试环境,不改变项目的最低版本要求。按用户要求移除 Python 3.8 专项测试。 + +## 支持的操作和类型 + +所有新增能力通过 `--extended-isel` 启用。 + +| 操作 | i32 | f32 | f64 | +|---|---|---|---| +| `const/add/sub/mul/div/neg` | RV32IM 整数指令 | F 扩展 `.s` | D 扩展 `.d` | +| `rem/mod` | 有符号 `rem`;`mod` 为别名 | 不支持 | 不支持 | +| `min/max/abs` | 整数指令序列 | `fmin.s/fmax.s/fabs.s` | `fmin.d/fmax.d/fabs.d` | +| `sqrt` | 不支持 | `fsqrt.s` | `fsqrt.d` | +| IR `load/store` 与 spill | `lw/sw`,4 字节 | `flw/fsw`,4 字节 | `fld/fsd`,8 字节 | +| 比较 `== != < > <= >=`、条件分支 | GPR 比较/分支 | FPR 比较,GPR 布尔结果 | FPR 比较,GPR 布尔结果 | +| 单值返回 | `a0` | `fa0` | `fa0` | + +DSL 常量写成 `const(1.5, dtype:f64)`;省略 dtype 时为 f32。IR 的类型系统位于 `scratchv/ir/types.py`,以 `DataType.FLOAT32/FLOAT64` 区分两种精度,仓库没有名为 `type_system.py` 的模块。ONNX 标量 `Add/Sub/Mul/Div/Sqrt/Min/Max` 保留 FLOAT/DOUBLE 类型。 + +| ISA | ABI | 类型 | +|---|---|---| +| `rv32im` | `ilp32` | i32 | +| `rv32imf` | `ilp32f` | i32、f32 | +| `rv32imfd` | `ilp32d` | i32、f32、f64 | + +`naive/greedy/linear` 三种分配器均支持 GPR/FPR 分类分配和按类型 spill。不同 dtype 不做隐式转换;不支持的 ISA/ABI/type 组合明确报错。 + +## 示例与验收 + +在已安装项目依赖的 Python 环境中,从仓库根目录运行: + +```bash +python -m scratchv.main benchmarks/topic28/i32_div_rem.dsl \ + --extended-isel --target-isa rv32im --target-abi ilp32 \ + --reg-alloc greedy -o /tmp/topic28_i32.s +python -m scratchv.main benchmarks/topic28/f64_sum.dsl \ + --extended-isel --target-isa rv32imfd --target-abi ilp32d \ + --reg-alloc linear -o /tmp/topic28_f64.s +clang --target=riscv32 -march=rv32imfd -mabi=ilp32d \ + -c /tmp/topic28_f64.s -o /tmp/topic28_f64.o + +SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests/test_topic28_acceptance.py +``` + +四个交付示例分别是 [i32 除法和余数](../../../benchmarks/topic28/i32_div_rem.dsl)(−5)、[f32 求和](../../../benchmarks/topic28/f32_sum.dsl)(3.5)、[f64 求和](../../../benchmarks/topic28/f64_sum.dsl)(6.75)、[f64 点积](../../../benchmarks/topic28/f64_dot.dsl)(15)。验收测试自动汇编、链接并通过 QEMU 执行,无需 RISC-V C 运行库。工具与完整回归命令见[验收说明](28-交付与验收.md#验收方法)。 + +## 数值语义与边界 + +- 整数除法向零截断,余数与被除数同号:`div(-17,5)=-3`,`mod(-17,5)=-2`。除零及 `INT32_MIN/-1` 使用 RV32M 定义,不插入陷阱。 +- 浮点算术和平方根固定使用 `rne`(最近值、相同距离取偶数)。DSL 的 `min/max` 使用 RISC-V 的 NaN、符号零及异常标志语义,不能用 Python `min/max` 直接替代。 +- ONNX 浮点 Min/Max 保留 NaN:任意输入为 NaN,结果就是 NaN,变长输入逐对保留这条规则;运算产生规范化的 quiet NaN,sNaN 设置 invalid 标志。非 NaN 输入继续使用 RISC-V 的符号零规则(Min 选择 −0,Max 选择 +0);本次 NaN 修复不改变这项既有目标约定。 +- 硬浮点交付产物是合法 F/D 汇编,执行验证使用 QEMU;内置 encoder/emulator 目前仅支持 RV32IM。 +- ONNX 硬浮点入口处理标量,支持至多一个标量浮点参数;每个函数的栈帧上限为 2032 字节。 +- ONNX initializer 与 graph input 同名时,当前接口冻结 initializer,不生成可覆盖参数;不提供运行时覆盖。这是既有入口政策,ONNX 规范允许实现选择是否提供覆盖。 +- `--extended-isel` 与 `--dag-isel`、LLVM 后端互斥。生产 linear 分配器为 `regalloc_linear.py`。 diff --git "a/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\344\272\244\344\273\230\344\270\216\351\252\214\346\224\266.md" "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\344\272\244\344\273\230\344\270\216\351\252\214\346\224\266.md" new file mode 100644 index 00000000..98ebba95 --- /dev/null +++ "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\344\272\244\344\273\230\344\270\216\351\252\214\346\224\266.md" @@ -0,0 +1,52 @@ +# Topic 28 交付与验收 + +更新:2026-10-14。交付按[原始任务](28-扩展指令选择.md)的六项要求组织。 + +## 要求与交付对应 + +| 原始要求 | 交付位置 | 验收依据 | +|---|---|---| +| 1. 分析已有算子映射 | [基础选择器](../../../scratchv/backend/instruction_select.py)、[设计](28-扩展指令选择-设计文档.md)、[支持列表](28-README.md#支持的操作和类型) | 原有整数路径及扩展映射有明确说明 | +| 2–3. div/mod/sqrt/min/max 映射 | [扩展选择器](../../../scratchv/backend/inst_select_ext.py)、[目标校验](../../../scratchv/backend/target_features.py) | i32 div/rem 与 F/D 数学指令真实执行 | +| 4. f64 寄存器、访存、算术 | [机器类型](../../../scratchv/backend/machine_types.py)、[分类分配](../../../scratchv/backend/allocation.py)、[栈帧/ABI](../../../scratchv/backend/abi_frame.py) | FPR、8 字节槽、fld/fsd、四则算术 | +| 5. IR 区分 f32/f64 | [类型系统](../../../scratchv/ir/types.py)、[IRBuilder](../../../scratchv/ir/builder.py)、[DSL](../../../scratchv/frontend/dsl_parser.py)、[ONNX](../../../scratchv/frontend/onnx_parser.py) | dtype 和 FLOAT/DOUBLE 贯通 | +| 6. 测试算子与类型 | [验收测试](../../../tests/test_topic28_acceptance.py)、[示例](../../../benchmarks/topic28) | 三种分配器,实际 ELF 执行结果和 ABI 检查 | +| 支持列表、类型与使用说明 | [使用指南](28-README.md)、[设计](28-扩展指令选择-设计文档.md) | 操作、类型、ISA/ABI 与运行命令 | + +原任务所称 type_system.py 对应本仓库现有的 `scratchv/ir/types.py`。 + +W1–W4 对应整数映射和边界;W5–W8 对应 F/D 类型、访存和四则算术;W9 对应 f64 六种比较及条件分支;W10 对应双精度求和、点积和 QEMU 验证;W11 采用硬件 sqrt/min/max;W12 对应使用文档。 + +## 验收方法 + +在已安装项目和测试依赖的 Python 环境中运行,PATH 需包含支持 RISC-V 的 Clang、LLD、qemu-riscv32;全量回归还需 readelf。 + +代码必须兼容 Python 3.8。功能回归沿用现有 CI 测试任务(解释器为 Python 3.12),并上传到 test-reports artifact;测试环境版本不改变项目的最低支持版本。 + +```bash +SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests/test_topic28_acceptance.py +SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py +``` + +验收入口为 99 项:四个交付 DSL 程序;整数商/余数、除零和溢出;DSL/ONNX 标量数学;F-only、D 上 f32、D 上 f64;f64 访存及六种比较的两条分支。结果以实际 ELF 返回的位模式为准,并检查 fflags、调用后的 sp/ra、GPR/FPR callee-saved 状态和 f32 NaN-boxing。 + +与算子、类型、合法汇编和调用正确性有关的回归保留,完整回归检查共享编译管线兼容性。 + + +## 测试保留范围 + +测试按本课题新增行为和实际修改的共享模块保留。Review 编号用于追溯缺陷;后续新增测试需对应下列合同,不再对每个场景重复展开全部配置。 + +| 保留层面 | 测试位置 | 展开原则 | +|---|---|---| +| 原始任务验收 | [acceptance](../../../tests/test_topic28_acceptance.py) | 99 项完整保留,覆盖三种分配器、整数和 F/D 目标、交付程序及真实 ABI | +| 算子映射、类型传播与拒绝边界 | [IR/前端](../../../tests/test_topic28_ir_frontend.py)、[整数选择](../../../tests/test_topic28_isel_integer.py)、[浮点选择](../../../tests/test_topic28_stage2.py)、[CLI](../../../tests/test_topic28_cli_integration.py)、[整数折叠](../../../tests/test_topic28_constant_folding.py)、[选择器单测](../../../tests/test_inst_select_ext.py) | 保留操作、类型、合法/非法输入;前端拒绝不重复乘上分配器和优化级别 | +| FPR 分配、spill、物理寄存器及混合调用 | [分配合同](../../../tests/test_topic28_stage3.py)、[物理定义](../../../tests/test_topic28_review_backend.py)、[固定源](../../../tests/test_topic28_review_fixed_uses.py)、[真实调用](../../../tests/test_topic28_fourth_review.py) | 分配器相关场景保留 naive/greedy/linear;单测检查槽宽度/对齐,ELF 检查返回位和调用状态 | +| DSL/ONNX 类型、常量与标量形状 | [输入边界](../../../tests/test_topic28_independent_boundaries.py)、[整数链](../../../tests/test_topic28_independent_integer.py)、[ONNX](../../../tests/test_topic28_review_onnx.py)、[原始编码](../../../tests/test_topic28_fifth_review.py)、[存储判别](../../../tests/test_topic28_sixth_review.py) | 保留输入形态、f32/f64、packed/unpacked/raw/external、重复字段等实际缺陷变体;编码问题采用代表编译配置 | +| 优化后的值、精度、fflags 和控制流 | [浮点优化](../../../tests/test_topic28_review_optimizations.py)、[DCE](../../../tests/test_topic28_overall_review.py)、[CFG/LICM](../../../tests/test_topic28_third_review.py) | 保留 none/basic/all 和显式重复 pass;浮点精度主矩阵保留三种分配器 × 三种级别;其他优化问题采用代表分配器 | +| 合法汇编、符号、函数大小及每函数栈帧 | [第四轮](../../../tests/test_topic28_fourth_review.py)、[第五轮](../../../tests/test_topic28_fifth_review.py)、[第六轮](../../../tests/test_topic28_sixth_review.py) | 保留符号、section、两条返回路径及帧大小边界;发射器自身问题不重复乘上优化配置 | +| 受影响的共享参照执行器 | [除法](../../../tests/test_topic28_independent_division.py)、[浮点参照](../../../tests/test_topic28_review_frontend_oracles.py) | 保留类型和逐位结果;数组用例仅检查本课题修改的参照函数兼容性,不表示后端支持张量计算 | + +verifier 开关的两条路径保留在拒绝边界测试中;正确执行用例采用固定开关。三个重复的编译/模拟调用用例已合并进原始验收和真实混合调用回归,额外的重复汇编步骤已删除,执行帮助函数仍负责汇编、链接和运行。 + +本次课题相关参数项由 5759 精简为 1793,保留用例的输入、类型、编码和预期值组合未删减。其他课题既有的 1682 项测试保持原样,完整仓库回归共 3475 项。 diff --git "a/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\344\273\243\347\240\201Review\346\212\245\345\221\212-2026-10-12.md" "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\344\273\243\347\240\201Review\346\212\245\345\221\212-2026-10-12.md" new file mode 100644 index 00000000..3c122c94 --- /dev/null +++ "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\344\273\243\347\240\201Review\346\212\245\345\221\212-2026-10-12.md" @@ -0,0 +1,652 @@ +# Topic 28 代码 Review 报告 + +Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-design`,提交 `2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1`。比较基线:`f3af99d`,即本工作区 `main` / `origin/main` 指向的提交。 + +**首次审查发现的 R1–R4,以及后续复审确认的 R5,均已修复。R5 修复后的全量回归为 3826 项通过、0 失败、0 跳过。** R1–R4 分别是:LLVM 报成功却生成无效文件、两个函数的同名变量被混淆、核对工具读错浮点常量、空输入返回上一轮指令。R5 是 ONNX Min/Max 把应返回的 NaN 算成正常数字,补测与修复见第 2 节。Python 3.8 兼容性修复见第 9 节;当时 3610 项测试通过,但没有正确检查 R5 的 ONNX 结果。 + +第 2 节先用例子解释每个错误,再说明原因、影响、与课题的关系、修复方法和测试结果。第 4–7 节保留对审查提交的逐文件评估,当前修复状态以第 2 节和第 8 节为准。代码行号指向审查时的版本;各次测试数字注明对应的修复阶段。复现脚本及原始 JSON 证据只作为本地调试资料,不纳入提交。 + +## 1. 范围、方法和验证结果 + +审查覆盖 topic28 的 4 个分支提交: + +| 提交 | 内容 | +|---|---| +| `d0147a3` | 初始指令选择设计 | +| `9b4b2a3` | 分阶段实施计划 | +| `3a681f4` | API 级设计 | +| `2dc976c` | 算子、类型和端到端实现 | + +使用 `git diff f3af99d..2dc976c` 检查这批提交具体改了哪些代码。 + +| 分类 | 文件数 | 新增行 | 删除行 | +|---|---:|---:|---:| +| 生产代码 `scratchv/` | 38 | 2875 | 722 | +| 测试及测试辅助代码 | 30 | 4748 | 92 | +| 文档 | 4 | 664 | 1 | +| DSL 交付示例 | 4 | 24 | 0 | +| CI | 1 | 3 | 0 | +| 合计 | **77** | **8314** | **815** | + +检查分为三步:先读代码,确认输入经过哪些处理;再运行已有测试;最后补充小例子,检查生成的 LLVM 文件能否被读取,或将 RISC-V 代码汇编、链接成可执行文件并运行。本地复现脚本记录的是旧代码怎样出错;仓库中的回归测试则检查修复后应该怎样工作。 + +### 1.1 已执行的回归 + +```bash +SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q \ + tests benchmarks/test_benchmark.py --junitxml=/tmp/topic28-review-pytest.xml +``` + +实际结果:**3475 passed,0 failed,0 skipped,1 warning,153.09 秒**。其中 `tests/test_topic28_acceptance.py` 的 99 项全部包含在这次执行中。唯一 warning 来自 TinyFive 对 `numpy.fix` 的弃用提示。 + +本机默认 `python3` 是没有 pytest 的 Python 3.14,因此实际使用 `/home/yu/workspace/ScratchV/.venv/bin/python`;QEMU 使用本机已有、从 Debian 包解出的可执行文件。实际命令如下: + +```bash +PATH=/tmp/scratchv-topic28-apply-dev-ltf7qwuh/tools/extracted/usr/bin:/home/yu/workspace/ScratchV/.venv/bin:$PATH \ +SCRATCHV_REQUIRE_RISCV_EXECUTION=1 \ +/home/yu/workspace/ScratchV/.venv/bin/python -m pytest -q \ + tests benchmarks/test_benchmark.py --junitxml=/tmp/topic28-review-pytest.xml +``` + +| 工具 | 本次版本 | +|---|---| +| Python | 3.12.14 | +| pytest | 9.1.1 | +| ONNX / NumPy | 1.23.0 / 2.5.3 | +| Clang / LLD | 18.1.8 / 18.1.8 | +| qemu-riscv32 | 10.2.1 | +| llvmlite | 0.49.0 | + +审查时还运行了本地复现脚本:6 个 LLVM 场景、6 个不同函数内部变量重名的场景、6 个浮点常量编码场景,以及 1 个空输入分配场景,都重现了错误。浮点常量的测试把被测函数和调用它的程序分别编译,用 Clang/LLD 链接后在 QEMU 中运行。这些结果对应审查提交 `2dc976c`,具体输入和结果见第 2 节;脚本和原始 JSON 只保留在本地。 + +以上验证需要项目/测试依赖、llvmlite、Clang、LLD 和 qemu-riscv32 位于环境中。原始回归的本地日志与 JUnit 位于 `/tmp/topic28-review-pytest.log`、`/tmp/topic28-review-pytest.xml`。R1 修复后的验收可运行仓库内的 `python -m pytest -q tests/test_llvm_input_types.py`。 + +### 1.2 这些验证能说明什么 + +这些结果说明报告中的输入确实能触发错误,以及修复通过了列出的测试。测试范围限于实际使用的 ONNX 模型、Python 版本和 RISC-V 工具链。本次没有测量性能,因此没有得出“更快”或“指令更少”的结论。仓库里没有 `CLAUDE.md` 引用的 `.claude/harness/verify/run.py`,所以未执行其中的 L2 检查;实际完成的是上述 pytest 和独立运行检查。 + +## 2. 已确认的问题及修复结果 + +R1–R4 的例子记录的是审查提交 `2dc976c` 的错误表现,四项均已修复。R5 来自后续复审,修复前版本为 `77bef84`。各节分别说明修复方法和测试结果。P1 表示应优先处理,P2 表示需要修复但触发条件较有限。 + +例子中的 `i32` 是 32 位整数,`f32`、`f64` 分别是单精度、双精度浮点数。IR 是编译器在读取输入后、生成目标代码前使用的中间表示。 + +| 编号 | 优先级 | 修复前发生了什么 | 什么情况下会遇到 | +|---|---|---|---| +| R1 | P1 | LLVM 报告编译成功,生成的文件却无法通过 LLVM 检查 | 使用整数常量或整数算术的 DSL/ONNX 输入 | +| R2 | P2 | 两个函数里的同名变量被当成同一个,导致编译失败 | 分别创建函数后合并,内部变量名重复 | +| R3 | P2 | 用来核对结果的执行器,把应为 1.0 的常量读成 0.0 | 浮点常量明确提供了原始二进制编码 | +| R4 | P2 | 上一次处理过指令后,空输入又返回了旧指令 | 重复使用同一个线性扫描寄存器分配器 | +| R5 | P2 | ONNX Min/Max 应返回 NaN,编译后却返回另一个正常数字 | 浮点 Min/Max 的输入中含有 NaN | + +### R1:LLVM 报告编译成功,生成的文件却不能用 + +#### 发生了什么 + +输入下面这个程序,要求计算两个整数的和: + +```text +a = const(7,dtype:i32) +b = const(3,dtype:i32) +r = add(a,b) +return r +``` + +编译器选择 LLVM 后端时,合理的结果有两种:支持这段整数计算,生成可用的代码;或者明确说明暂不支持,并返回失败。修复前却返回了 `success=True`,错误列表也是空的,生成的 `.ll` 文件无法被 LLVM 读取。 + +不启用 `use_extended_isel`,分别使用 `none/basic/all` 三个优化级别,可以看到这些错误语句: + +```llvm +; none +%v_1_1 = fadd i32 7, 0.0 +; basic / all +%v_3_1 = fadd i32 10, 0.0 +``` + +这里 `i32` 表示整数,`fadd` 却是浮点加法指令。也就是说,编译器把整数交给了只能处理浮点数的指令。优化器即使先把 `7+3` 算成 `10`,后端仍用错误的指令表示这个整数,文件依然无效。 + +用 `llvmlite.binding.parse_assembly(...).verify()` 检查,得到 `floating point constant invalid for type`:这条语句中的浮点常量不能用于指定的整数类型。[LLVM 对 fadd 的类型要求](https://llvm.org/docs/LangRef.html#fadd-instruction) + +#### 为什么会出错,和本课题有什么关系 + +本次修改让前端能够读入带类型的常量,也让 IR 正确保留整数类型。但 LLVM 生成代码的部分仍按浮点数处理常量和四则运算,前后两部分没有一起更新。 + +这些前端和 IR 代码由多个后端共用。因此,即使没有打开 `--extended-isel`,整数输入也能进入 LLVM。入口原来只拒绝 LLVM 不支持的 `rem/min/max/abs/sqrt` 等扩展操作,没有拦住这里的整数常量和整数加法。 + +相关位置:[dsl_parser.py](../../../scratchv/frontend/dsl_parser.py#L170) 读取带类型的 `const`;[builder.py](../../../scratchv/ir/builder.py#L105) 保留运算结果的类型;[onnx_parser.py](../../../scratchv/frontend/onnx_parser.py#L182) 把 Add/Mul 交给 IR 优化器。遗漏的适配位于 [llvm_codegen.py](../../../scratchv/backend/llvm_codegen.py#L221) 的算术输出,以及第 270 行的 `_emit_load_const`。 + +**课题要求扩展 RISC-V,不要求同时完成 LLVM 整数后端。但我们修改了共用的输入处理代码,就需要保证 LLVM 对不支持的输入明确报错。** 否则,调用者会相信“编译成功”,直到把文件交给后续工具才发现不能用。 + +#### 和旧代码相比,哪里变差了 + +还测试了一个通过 `onnx.checker.check_model` 检查的 ONNX 模型:两个 INT32 标量常量相加。这排除了“输入模型本身不合法”的解释。 + +| 同一个合法 ONNX INT32 Add 模型 | 旧代码 `f3af99d` 的输出能否被 LLVM 读取 | 审查代码 `2dc976c` 的输出能否被 LLVM 读取 | 两边编译器报告 | +|---|---|---|---| +| none | 不能 | 不能 | 都报告成功 | +| basic | 能 | 不能 | 都报告成功 | +| all | 能 | 不能 | 都报告成功 | + +旧代码在前端先用 Python 算出了常量结果,部分优化路径因此绕开了问题。本次把计算统一交给 IR 优化器后,原来能输出合法 LLVM 文本的 `basic/all` 路径也开始失败。 + +需要区分两件事:旧代码的 `none` 本来就有这个问题;旧代码的 `basic/all` 能通过 LLVM 解析,也只能说明文件格式有效,不能据此认定 ONNX 返回值的类型完全正确。 + +#### 我们怎么修复 + +原审查提出两种办法:补上正确的 LLVM 整数代码生成,或者明确拒绝当前不支持的操作。我们按本课题范围采用了第二种办法。 + +1. [compiler.py](../../../scratchv/compiler.py) 在读取输入之后、优化之前检查 LLVM 是否支持这些操作和类型。当前 `load_const/add/sub/mul/div/neg` 的 LLVM 实现只支持 f32/f64;遇到 i32/i64 就返回 `success=False`,说明不支持的操作、类型及所在函数和基本块。即使整数常量没有被使用、后续可能被优化掉,也在这里明确拒绝,三个优化级别的行为一致。 +2. [llvm_codegen.py](../../../scratchv/backend/llvm_codegen.py) 的 `emit()` 也执行同一检查,直接调用代码生成器时同样受保护。`save()` 先生成完整文本,再打开输出文件;输入被拒绝时,不新建文件,也不清空已有文件。 +3. 检查针对具体操作。已有的整数循环控制、比较、读写内存和返回仍可使用。前端继续保留正确的整数类型,IR 优化器继续遵守 RV32 的整数计算规则。 + +如果以后补 LLVM 整数除法,还需要单独处理除零和溢出。直接输出 `sdiv` 不能保证 RV32 在这些情况下的结果,因为 LLVM 将相应情况定义为未定义行为。[LLVM 对 sdiv 的要求](https://llvm.org/docs/LangRef.html#sdiv-instruction) + +#### 怎么证明修好了 + +[test_llvm_input_types.py](../../../tests/test_llvm_input_types.py) 共 38 项测试,首批 26 项,后来补充 12 项。测试检查整数输入明确失败且不写文件,已有文件保持原样,直接调用代码生成器也会拒绝不支持的输入。成功的 f32/f64 输出必须通过 LLVM 解析和验证;已有整数循环控制也要继续生成合法文件。 + +| 修复后的输入 | none | basic | all | +|---|---|---|---| +| 带 i32 类型的 DSL 常量及 Add | 报错,不写 `.ll` | 报错,不写 `.ll` | 报错,不写 `.ll` | +| 合法 ONNX 模型中的 INT32 常量 Add | 报错,不写 `.ll` | 报错,不写 `.ll` | 报错,不写 `.ll` | +| 合法 ONNX 模型中的 INT32 参数 Add/Sub/Mul/Div | 报错,不写 `.ll` | 报错,不写 `.ll` | 报错,不写 `.ll` | +| DSL/ONNX 的 f32、f64 常量及 Add | 成功,LLVM 检查通过 | 成功,LLVM 检查通过 | 成功,LLVM 检查通过 | + +新增的 12 项 ONNX 测试使用两个运行时整数参数,没有预先给定的常量,覆盖四则运算和三个优化级别。这样能单独检查整数算术问题,不会只测到“整数常量读错了”。所有模型都通过 ONNX checker 检查。 + +将同一份最终测试放到两个独立临时目录,分别加载修复前 `2dc976c` 和修复后 `492bcfb` 的生产代码,执行 `python -m pytest -q tests/test_llvm_input_types.py`,结果如下: + +| 相同的 38 项 R1 测试 | 通过 | 失败 | 跳过 | +|---|---|---|---| +| 修复前代码 `2dc976c` | 13 | 25 | 0 | +| 修复后代码 `492bcfb` | 38 | 0 | 0 | + +修复前通过的是 12 项浮点输出检查和 1 项整数循环控制测试;失败的 25 项涉及整数输入应被拒绝、失败时应保留已有文件。两边测试内容相同,并确认 Python 加载了各自目录的代码,没有回退当前工作区或混入 R2 修改。 + +**历史验证记录:**首批修复时,相关测试 **121 项通过**;全量回归 **3501 通过、0 失败、0 跳过**,耗时 154.62 秒。补测后,R1/R2 联合回归 **58 项通过**。全量测试的数字对应各次运行时已有的测试数量;本报告第 9 节给出后续兼容性验证结果。 + +生产代码的类型检查不依赖 llvmlite。llvmlite 只用于测试,确认成功生成的文件确实能被 LLVM 读取和验证。 + +### R2:两个函数里的同名变量,被编译器当成了同一个 + +#### 发生了什么 + +`f0` 和 `f1` 是两个不同的函数。它们各自有一个内部变量叫 `v_1`,这是合法的,就像两个函数都可以有自己的 `x`。问题在于,修复前编译器只记住了名字 `v_1`,没有记住它属于哪个函数。 + +最小例子是:用两个独立的 IRBuilder 分别创建 `f0`、`f1`。每个函数都申请一块局部内存(`alloca(1)`),存入常量,再读出并返回。两个构造器都从头编号,所以都会生成 `v_1/v_2/v_3`。把两个函数放进同一个 Program 后,`verify_ir(program)` 返回 `(True, [])`,确认这个输入合法。 + +| 两个函数的类型 | 应该发生什么 | 修复前 naive / greedy / linear 三种分配方式的结果 | +|---|---|---| +| `f0:f64`、`f1:f64` | 每个函数使用自己的局部内存 | 都失败:`local access has no layout entry: -8`,即找不到第一块局部内存的布局记录 | +| `f0:f64`、`f1:f32` | 同名变量分别属于各自函数,可以有不同类型 | 都失败:`virtual register v_2 has conflicting width or class`,即误以为同一个变量出现了两种类型 | + +#### 为什么会出错 + +局部内存记录用变量名作字典键。第一个函数的 `v_1` 记录在 `-8`,第二个函数的 `v_1` 记录在 `-16`;后写入的记录覆盖了前一条。第一处访问还指向 `-8`,但编译器已经找不到它对应的布局信息。 + +类型记录也只按变量名查找。处理 `f0` 的 `v_2` 时记下 f64,再处理 `f1` 的 `v_2` 时看到 f32,于是报“类型冲突”。实际上它们是两个变量,没有冲突。 + +相关位置:[inst_select_ext.py](../../../scratchv/backend/inst_select_ext.py#L245) 第 252–254 行记录局部内存;[allocation.py](../../../scratchv/backend/allocation.py#L71) 的 `collect_register_types` 收集类型。两处都没有区分同名值来自哪个函数。 + +旧的多函数测试没有发现问题,因为它们共用一个 IRBuilder,编号一直递增,两个函数拿到的名字不同。专门测寄存器不够用时保存数据到栈上(spill)的测试,也预先给变量名加了函数前缀,避开了重名情况。 + +#### 影响和课题关系 + +合法的多函数 IR 会编译失败。当前 DSL/ONNX 入口通常只生成一个函数,因此这个问题通常不会出现在普通单函数编译中。 + +课题没有要求调用者把所有函数的内部变量改成全程序唯一。我们新增的局部内存和类型记录需要正确区分函数,才能让已有的多函数输入继续工作,这属于本次实现需要处理的错误。 + +#### 我们怎么修复 + +在把不同函数的机器指令合到一起之前,先给它们的内部变量生成不会重复的后端名字。可以理解为给名字补上“属于哪个函数”的身份。调用者仍可在每个函数里使用 `v_1`。 + +1. [instruction_select.py](../../../scratchv/backend/instruction_select.py) 在每个函数完成指令选择后,为该函数所有虚拟寄存器的定义和使用统一换名。源 IR 值、函数参数、函数内加载的全局常量和临时值都使用同一套映射。新名字避开已有名字,并保留寄存器类别和位宽。 +2. [inst_select_ext.py](../../../scratchv/backend/inst_select_ext.py) 同时更新 `local_slots` 中的名字,完成后才处理下一个函数。局部内存、类型表、寄存器安排及 spill 记录因此能识别同一个变量,不会相互串用。 +3. 真正的栈空间仍由后续布局步骤按函数分别安排。换名只修改后端机器指令,调用者传入的 IR 保持原样;单函数程序的命名方式保持原样。重复运行同一个选择器,也会得到稳定的名字和局部内存记录。 + +只修改局部内存字典是不够的。如果类型表或寄存器分配仍只看原来的 `v_1`,另一个报错还会存在。 + +#### 怎么证明修好了 + +先新增 [test_topic28_function_scope.py](../../../tests/test_topic28_function_scope.py) 的 20 项测试,再修复代码。测试刻意使用两个独立构造器,让函数内部名字重复,同时覆盖 f64/f64 和 f64/f32。 + +| 同一份 20 项 R2 测试 | 通过 | 失败 | +|---|---|---| +| 修复前 | 1 | 19 | +| 修复后 | 20 | 0 | + +修复前唯一通过的是重复运行选择器时名字保持稳定的测试。失败项包括局部内存记录被覆盖、浮点类型被混淆,以及 naive 分配方式混用同名变量的 spill 记录。 + +测试还检查原始 IR 没被修改、新名字不会撞上用户已有名字、浮点位宽被保留,以及基础整数选择器也能正确区分函数。 + +执行测试覆盖两种精度组合和三种分配器,分别测试简单局部内存和 16 个同时存活的浮点值。后者会让寄存器不够用,必须将部分值保存到栈上。测试独立汇编、链接后,在 QEMU 中分别调用两个函数,检查返回值的二进制编码、浮点异常标志 `fflags`,以及调用后的栈和整数寄存器状态。 + +| 修复后的输入 | naive | greedy | linear | +|---|---|---|---| +| 两个 f64 函数,内部名字重复 | 编译及分别执行通过 | 编译及分别执行通过 | 编译及分别执行通过 | +| 一个 f64、一个 f32 函数,内部名字重复 | 编译及分别执行通过 | 编译及分别执行通过 | 编译及分别执行通过 | +| 内部名字重复,且寄存器不够用、需要 spill | 两个函数结果均正确 | 两个函数结果均正确 | 两个函数结果均正确 | + +**历史验证记录:**新增 20 项全部通过;连同已有的整体、栈和分配测试,**409 项通过**。当时的全量回归为 **3521 通过、0 失败、0 跳过**,耗时 156.36 秒。这次检查的是“不同函数的同名变量能否分开”,与已有的“大栈空间是否按函数计算”测试解决的是两个问题。 + +### R3:用来核对结果的执行器,读错了浮点常量 + +#### 发生了什么 + +项目中的 `IRTraceExecutor` 可以直接计算 IR 的结果,用来和编译后的程序核对。修复前,同一个常量在编译后的程序里是 1.0,在这个执行器里却成了 0.0。 + +例子如下: + +```python +b.ret(b.load_const(0.0, T.FLOAT32, bit_pattern=0x3f800000)) +``` + +这条常量同时带了两份信息:数值字段写着 `0.0`,`bit_pattern` 字段写着 `0x3f800000`。后者是浮点数的原始二进制编码,在 f32 中表示 **1.0**。 + +本次新增的规则明确规定:提供了 `bit_pattern` 时,以这个编码为准。因此这个合法 IR 应返回 1.0。把辅助数值字段故意写成 0.0,是为了检查每个读取入口是否遵守同一规则。 + +| 类型 | 编译后的程序在 QEMU 中实际返回 | IRTraceExecutor 返回 | +|---|---|---| +| f32 | `0x3f800000`,即 1.0 | `0x00000000`,即 0.0 | +| f64 | `0x3ff0000000000000`,即 1.0 | `0x0000000000000000`,即 0.0 | + +三种寄存器分配方式的编译结果都返回了正确值。所有 QEMU 场景的浮点异常标志 `fflags` 都是 0;这个例子只是把 1.0 读成了 0.0,没有涉及复杂的舍入或浮点异常。 + +#### 为什么会出错 + +编译器生成目标代码时已经优先读取 `bit_pattern`,核对结果的执行器却仍从旧的数值字段取值。于是,两边对同一个输入的理解不同。 + +相关位置:[types.py](../../../scratchv/ir/types.py#L118) 增加 `Value.bit_pattern` 并规定它优先;[rv32_emulator.py](../../../scratchv/simulator/rv32_emulator.py#L470) 的 `_resolve` 和第 577 行 `_op_load_const` 没有同步修改。一个入口负责读取运算中的常量,另一个负责执行常量加载,两处都需要修复。 + +#### 影响和课题关系 + +核对工具读错输入后,可能把正确的编译结果判为错误,也可能让某些错误没有被发现。本次 QEMU 验收没有用 `IRTraceExecutor` 作为预期结果来源,所以那些执行测试仍然通过。 + +本课题增加了浮点常量的表示方式,也更新了这个核对工具。它需要按同一规则读取常量,才能继续可靠地核对结果。这属于本次修改的配套修复;修复范围是读准常量,不需要把它改成完整的浮点硬件模拟器。 + +#### 我们怎么修复 + +两个读取入口使用同一个解码函数:先把编码按 uint32/uint64 保存,再用 NumPy `view` 把同一组二进制位解释成 f32/f64。这个操作只改变解释方式,不重新计算数值,因此能保留负零和 NaN 的原始编码。NaN 表示“非数值”,其编码里还可以包含额外信息。 + +1. 有明确编码时按编码读取;没有编码时,保留原来的 i32/f32/f64 数值转换。布尔值、非整数、超出位宽的编码,以及把浮点编码用于整数类型的输入,都会报错。 +2. 加载常量时先读指令属性中的编码,缺省时再读结果 Value 的编码。两处都提供但不一致时,在修改执行状态前报错。这和生成目标代码时的规则一致。 +3. 编码只写在加载指令上时,执行器记住这次定义的编码,后续返回或参与运算也使用它。记录在每次运行和每个函数开始时清空,避免重复运行或函数内重名串值。原始 IR 不被修改。 + +#### 怎么证明修好了 + +先新增 [test_ir_trace_constants.py](../../../tests/test_ir_trace_constants.py) 的 55 项测试,再修复两个入口。预期字节直接从给定的整数编码生成,不调用生产代码的解码函数。 + +测试覆盖普通有限数、编码为零但辅助数值非零、负零,以及 quiet/signaling NaN 的附加编码;同时覆盖编码只在指令上、只在 Value 上或两处一致的情况。还检查加载后返回、参与加法、非法编码、编码冲突,以及重复运行和两个函数使用同名常量。 + +| 同一份最终 55 项 R3 测试 | 通过 | 失败 | 跳过 | +|---|---|---|---| +| 修复前代码 `814dc9e`,在独立临时目录运行 | 3 | 52 | 0 | +| 修复后代码 | 55 | 0 | 0 | + +修复前通过的是 3 项没有明确编码的普通数值测试。修复后还检查了冲突报错时原有值不被覆盖,以及返回值的类型和原始字节;算术结果继续允许原有的 NumPy 标量形式。 + +**历史验证记录:**连同已有的核对、除法、CNN 和模拟器测试,**309 项通过**。当时的全量回归为 **3588 通过、0 失败、0 跳过**,耗时 152.77 秒。第一次扩大测试时因临时 QEMU 路径失效而失败;下载并解压 QEMU 10.2.1、恢复 PATH 后,执行测试全部通过,没有把工具缺失改成跳过测试。 + +### R4:重复使用分配器时,空输入带回了上一轮指令 + +#### 发生了什么 + +寄存器分配器负责给指令中的临时变量安排实际寄存器。新增的 `allocate_result()` 接口会返回安排后的指令、变量与寄存器的对应表,以及需要的栈空间。 + +修复前,如果重复使用同一个分配器,第一次传入一条指令,第二次传入空列表,第二次的结果仍包含第一条指令。可以理解为上一轮的结果没有清干净。 + +```python +a = LinearScanAllocator() +a.allocate_result([MI(O.LI, P.vreg("v"), P.immediate(7))]) +r = a.allocate_result([]) +``` + +这里第一轮的指令是“把数字 7 放入寄存器”。第二轮没有指令,应该返回空结果,实际却如下: + +| 第二轮返回的内容 | 应该是什么 | 修复前实际是什么 | +|---|---|---| +| 指令列表 `r.instructions` | 空列表 | 上一轮的 `[li %t0 7]` | +| 寄存器对应表 `r.register_map` | 空字典 | 空字典 | +| 栈上保存临时值的空间 `r.spill_bytes` | 0 字节 | 0 字节 | + +结果中的指令来自上一轮,配套信息却来自当前这轮。后续代码拿到这个结果,就无法把指令和说明对应起来。 + +#### 为什么会出错 + +代码遇到空输入时直接返回了,但清空已保存指令列表的语句写在后面,没有执行。寄存器表和栈布局已经在分配阶段重置,只有指令列表留下来了。 + +相关位置:[regalloc_linear.py](../../../scratchv/backend/regalloc_linear.py#L275) 新增的 `allocate_result()`;[regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py#L22) 第 22 行提前返回,而指令列表到第 103 行才重置。 + +#### 影响和课题关系 + +直接调用这个接口、并重复使用同一个分配器时,会收到旧指令。CompilerDriver 当前每次编译都会新建分配器,所以普通命令行编译通常不会遇到这一情况。 + +原始课题没有单独要求“分配器对象复用”这个功能,但我们为保留浮点类型信息新增了这个返回结果的接口。它返回的内容必须属于当前调用,因此这是新增代码的正确性问题,属于本次修复范围。 + +#### 我们怎么修复 + +在 [regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py) 的 `rewrite_with_spills()` 一开始就创建新的指令列表,然后才判断输入是否为空。`allocate_result()` 和返回汇编字符串的 `emit()` 都经过这里,两种调用方式都得到修复。 + +这里必须创建新列表:如果对原列表调用 `clear()`,调用者之前拿到的结果也会跟着被清空。现在每轮使用自己的列表,旧结果保留原内容;空输入得到空指令、空寄存器表、空 spill 槽和零字节空间,即 `AllocationResult([], {}, {}, 0)`。 + +#### 怎么证明修好了 + +先新增 [test_regalloc_result_reuse.py](../../../tests/test_regalloc_result_reuse.py) 的 16 项测试,在还没有修复的 `9d08814` 上运行,再修改生产代码。核心步骤是让同一个对象连续处理“有指令 → 空输入 → 有指令”。 + +测试覆盖 i32/f32/f64 和两种接口,还检查寄存器不够用、实际发生 spill 后的重置,新对象及连续空调用,两种接口交替使用,以及相同变量名从 f64 切换到 f32。 + +每次空调用都检查指令、寄存器表、spill 槽、字节数、栈偏移和保存/重载计数归零。下一次非空调用与全新分配器的结果比较,确认结果只属于本轮;同时检查之前返回的结果没有被修改。 + +| 同一份 16 项 R4 测试 | 通过 | 失败 | 跳过 | +|---|---|---|---| +| 修复前代码 `9d08814` | 2 | 14 | 0 | +| 修复后代码 | 16 | 0 | 0 | + +修复前通过的 2 项是新对象和连续空输入测试;其余 14 项都因空调用返回旧指令而失败。前后使用同一测试文件,运行命令为 `python -m pytest -q tests/test_regalloc_result_reuse.py`。 + +**R4 修复时的全量验证记录:**使用 `/home/yu/workspace/ScratchV/.venv/bin/python`,将 `/tmp/scratchv-r3-tools/extracted/usr/bin` 加入 PATH,运行 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py`,结果为 **3604 通过、0 失败、0 跳过**,耗时 154.05 秒。 + +各次完整回归仅有已有的 TinyFive `numpy.fix` 弃用警告,`git diff --check` 均通过。上述数字是各次代码修复时的运行记录;本次文字整理没有重新运行这些测试。原始复现脚本和 JSON 证据继续只保留在本地。 + + +### R5:ONNX Min/Max 把 NaN 算成了正常数字(2026-10-13 复审) + +#### 发生了什么 + +合法的 ONNX 标量模型计算 `Min(NaN, 2.0)` 或 `Max(NaN, 2.0)`,官方参考实现和 ONNX Runtime 返回 NaN,我们编译后的程序却返回 `2.0`。交换输入顺序也会出错。NaN 是浮点数允许存在的特殊值,表示没有得到有效数值;模型含有 NaN 不等于模型非法。 + +#### 为什么会出错,和课题有什么关系 + +[ONNX 前端](../../../scratchv/frontend/onnx_parser.py) 把 Min/Max 转成普通 IR min/max,[指令选择器](../../../scratchv/backend/inst_select_ext.py) 随后直接生成 RISC-V `fmin/fmax`。两边对普通数字的结果一致,但 NaN 规则不同:ONNX 官方参考实现使用 `numpy.minimum/maximum`,有一个 NaN 就返回 NaN;RISC-V 在只有一个 NaN 时选择另一个数字。指令合法、程序也能执行,却改变了原模型的结果。[ONNX Min 参考实现](https://raw.githubusercontent.com/onnx/onnx/v1.23.0/onnx/reference/ops/op_min.py)、[Max 参考实现](https://raw.githubusercontent.com/onnx/onnx/v1.23.0/onnx/reference/ops/op_max.py)、[RISC-V 浮点规范](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/f-st-ext.adoc) + +这属于课题新增 ONNX Min/Max 支持的正确性问题,优先级 P2。已在 f32/f64、三种分配器和多个优化配置下复现。普通 ONNX Min/Max 测试没有覆盖 NaN;另有 60 个常量编码测试虽然用了 NaN,却把选择正常数字当成了预期结果。用于核对 RISC-V 的 `minimum_number/maximum_number` 也会选择正常数字,不能作为 ONNX NaN 行为的独立对照。 + +#### 我们怎么修复 + +- [IRBuilder](../../../scratchv/ir/builder.py) 的 Min/Max 增加 `propagate_nan` 选项。ONNX 前端对每一步浮点 Min/Max 归约打开它,明确保留“输入含 NaN,结果也为 NaN”的要求;整数运算和 DSL 保持原来的规则。 +- 后端先执行 `fmin/fmax`,再通过两个输入与自身的浮点相等比较检查 NaN。输入都是正常数字时直接保留结果;含 NaN 时才执行浮点加法,得到规范化的 quiet NaN。不能无条件做这次加法:两个大数相加可能溢出,正负无穷大相加会引入 invalid,而原来的 Min/Max 不应产生这些额外异常。 +- [IRTraceExecutor](../../../scratchv/simulator/rv32_emulator.py) 同样读取这项 IR 标记,含 NaN 时返回 NaN。非 NaN 输入继续按既有 RISC-V 符号零规则处理,避免结果核对工具和实际程序不一致。本次修复针对 NaN 传播,不把符号零测试当作完整 ONNX 浮点语义一致的证明。 +- 新增 [test_topic28_onnx_nan.py](../../../tests/test_topic28_onnx_nan.py)。三个既有常量编码测试文件中的 NaN 预期改为 NaN,并增加官方参考实现对照;原始编码、直接返回时的位模式、ABI 和 qNaN/sNaN 异常标志检查都保留,没有把失败测试改成跳过。 +- 新测试由现有 Python 3.12 主环境的全量 pytest 自动运行,不新建流水线。 + +#### 怎么证明修好了 + +先写的 8 个实际执行测试在修复前全部失败,错误均为“预期 ONNX NaN,实际得到 2.0”;后端修复后 8 项全部通过。随后补写的 8 个 IR 执行器测试也先全部失败,再同步修复执行器。 + +专项测试扩展到 216 项,覆盖 Min/Max、f32/f64、两种输入顺序、三种分配器、`none/basic/all`、单输入和多个输入、运行时参数、NaN 原始编码及异常标志、无穷大和大数、寄存器压力、仅 F 目标,以及 DSL 和符号零回归。模型通过 ONNX checker;NaN 和普通数字结果与官方 ReferenceEvaluator 对照,再用 Clang/LLD 汇编链接和 QEMU 实际执行。NaN 按类别检查,运算输出的规范化编码和异常标志另外检查。 + +把同一份专项测试复制到临时导出的 `77bef84` 生产代码,确认导入来自该目录:修复前 **156 失败、60 通过**。60 项通过的是直接返回、两个 NaN、非 NaN 和保留 DSL/符号零规则的场景,不能证明单个 NaN 的传播正确。 + +第一次扩大到全量回归时还出现 60 项失败,全部来自上面三个文件对 ONNX NaN 的旧错误预期;确认官方参考结果后修正预期并重新执行。将这些修正后的 96 项相关测试也放到旧代码上运行,连同新专项测试得到 **216 失败、96 通过**,没有通过削弱检查让旧代码通过。 + +| 验证范围 | 修复前 `77bef84` | 修复后 | +|---|---|---| +| 新增专项 216 项 | 156 失败、60 通过 | 216 通过 | +| 专项加相关既有编码测试,共 312 项 | 216 失败、96 通过 | 312 通过,包含在全量回归中 | +| Python 3.12 全量 `tests benchmarks/test_benchmark.py` | 之前 3610 项通过,但 NaN 预期有误 | **3826 通过、0 失败、0 跳过,166.83 秒** | + +3.12 全量只有既有 TinyFive `numpy.fix` 弃用提示,设置了 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1`,没有跳过 RISC-V 执行。 + +另做 8 个 Min/Max、f32/f64、两种输入顺序的交叉核对,官方 ReferenceEvaluator、ONNX Runtime 1.30.0 与 QEMU 执行均返回 NaN,quiet NaN 的异常标志为 0。`git diff --check` 通过。 + +本地前后对照日志和 JUnit 保存在 `/tmp/topic28-onnx-nan-*`;不提交临时模型、执行产物、evidence 或 repro 文件。可复用的回归测试与修复代码纳入本次提交。 + +## 3. 为什么支持 f64 需要同时修改多个模块 + +旧代码已经有 `DataType.FLOAT64` 和部分双精度指令,但一个 f64 输入经过整个编译过程时,仍可能在中途被当成 f32、被安排到整数寄存器,或只保存了 4 字节。**要完成课题,需要让双精度值从输入到运行结果一直被正确处理。** 这就是为什么前端、类型、优化器、寄存器分配和输出代码都需要一起检查。 + +```mermaid +flowchart LR + A[读取输入中的类型和常量] --> B[运算结果保留正确类型] + B --> C[优化保持计算结果和约定的浮点行为] + C --> D[选择对应类型的机器指令] + D --> E[安排正确类别的寄存器并保留位宽] + E --> F[寄存器不够时保存完整的4或8字节] + F --> G[为每个函数安排栈空间和调用规则] + G --> H[生成合法汇编并实际运行检查] +``` + +每一步漏掉,都可能导致具体错误: + +| 如果撤掉的改动 | 直接后果 | +|---|---| +| 保留运算结果的类型 | 两个 f64 相加,结果却被标成 f32,后续按错误精度处理 | +| 保留常量原始编码,并用合法指令读取 | 负零或 NaN 的编码改变,或生成工具不认识的 `li.d` 指令 | +| 区分整数寄存器和浮点寄存器 | 浮点值被放到整数寄存器,或比较得到的布尔值被放到浮点寄存器 | +| 按类型保存栈上的临时值 | 8 字节的 f64 只保存 4 字节,丢失一半数据 | +| 统一安排函数的栈空间和调用规则 | 两份数据使用重叠空间,或函数返回时没有恢复栈和寄存器 | +| 保持浮点优化的正确性 | 优化前后得到不同的符号零、舍入结果或浮点异常标志 | +| 检查是否支持输入的类型和形状 | 不支持的输入被错误处理,编译器还报告成功 | + +f32 和 f64 使用同一组浮点寄存器(FPR),通过位宽区分;地址仍放在整数寄存器(GPR)中。加载、保存和运算需要选择对应精度的指令。f32 放入更宽的浮点寄存器时,也要遵守处理器规定的表示方式。[RISC-V F](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/f-st-ext.adoc)、[RISC-V D](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/d-st-ext.adoc) + +后面的逐文件评估使用四个标签: + +| 标记 | 意义 | +|---|---| +| **核心必需** | 课题直接要求的功能,例如新增算子和 f64 运算 | +| **集成必需** | 让这些功能在完整编译过程中正确工作所需的配套修改,例如保留类型、安排栈空间 | +| **合同必需** | 我们在设计中额外承诺了某种行为,例如保留 NaN 原始编码;保留这个承诺,就需要相应实现 | +| **可选配套** | 方便使用或维护的工具,例如统计和汇编美化 | + +这些标签判断的是需要实现什么能力。具体代码可以换一种写法,下面也列出了可替代或精简的部分。 + +## 4. 生产代码逐文件评估(审查时) + +### 4.1 类型系统、IR 和前端 + +| 文件 | 实际改动及为什么需要 | 判断与保留建议 | +|---|---|---| +| [ir/types.py](../../../scratchv/ir/types.py) | 新增 REM/MIN/MAX/ABS/SQRT;扩充 is_arith;增加浮点原始编码;未知 ONNX 类型明确报错。原有 FLOAT64 枚举未重新添加。 | 算子是**核心必需**;严格类型拒绝是**集成必需**;原始编码是**合同必需**。不要继续用未知类型默认 f32。 | +| [ir/builder.py](../../../scratchv/ir/builder.py) | add/sub/mul/div/neg 等保留输入 dtype;新算子做支持类型校验;load 可显式指定元素类型;生成临时名时避让外部名。 | **核心/集成必需**。否则双精度计算链中间丢类型,或用户值覆盖内部定义。类型逻辑值得保留;共享 LLVM 消费者需按 R1 补齐。 | +| [analysis/ir_verifier.py](../../../scratchv/analysis/ir_verifier.py) | 新操作签名:rem 仅 i32,sqrt 仅浮点,min/max/abs 限 i32/f32/f64;校验 bit_pattern 宽度及双份元数据一致性。 | **集成必需**。新 IR 必须有可核查合同;编码一致性是**合同必需**。不能放宽 verifier 接受旧融合伪 IR。 | +| [frontend/dsl_parser.py](../../../scratchv/frontend/dsl_parser.py) | typed const、mod→rem 归一化、新操作 dispatch;转换错误变成 DSLParseError。 | **核心必需**:需要用户入口表达新类型和操作。`const` 特判后 handlers 中的 `const` lambda 已不可达,可删作小清理;不影响主结论。 | +| [frontend/dsl_validator.py](../../../scratchv/frontend/dsl_validator.py) | 同步操作元数、dtype 关键字、数值格式和 i32 范围,复用词法合同。 | **集成必需**。不改则合法新语法先被拒绝,或无效常量混入后端。 | +| [frontend/dsl_lexical.py](../../../scratchv/frontend/dsl_lexical.py) | 提供共享 NUMBER/CONDITION;i32 常量按文本解析和范围检查,避免 float 中转及极长整数触发 Python digit-limit 错误。 | 共享词法是合理的**集成改动**;极长输入属于诊断稳健性的扩展,非 f64 运算的最低前提。模块仅 31 行,当前收益大于维护成本。 | +| [frontend/dsl_extended.py](../../../scratchv/frontend/dsl_extended.py) | 条件字面量按对端 dtype 构造;i32 条件用 Decimal 精确检查;mutable slot 继承初次赋值类型并禁止之后改变。 | **集成必需**。否则 i32 比较把小数截断,f64 变量通过默认 f32 局部槽读写。Decimal 不是唯一实现,但不能退回 int(float(text))。 | +| [frontend/onnx_parser.py](../../../scratchv/frontend/onnx_parser.py) | 保留 FLOAT/DOUBLE、真实 shape 和 scalar initializer 编码;统一 Add/Mul 的 IR 定义;实现 sqrt/abs/变长 min/max;预留图名,重置重复解析状态。 | 类型/算子是**核心必需**,shape/名称/状态是**集成必需**,位编码是**合同必需**。移除宿主 Add/Mul 折叠方向正确;R1 属于消费者适配遗漏,不能撤回类型修复。 | +| [frontend/onnx_bits.py](../../../scratchv/frontend/onnx_bits.py) | 从原始 protobuf wire 读取 float_data/double_data 的 packed/unpacked 位,不经过可能改变 sNaN 的 Python float;处理重复字段、未知字段、raw/external 存储判别。 | **合同必需,超出最小任务**。若承诺 sNaN/payload/fflags 精确保真,需要这类源编码通路;若只做普通有限数 f64,可不需要整个 wire 读取器。当前文档/测试已作强承诺,不宜直接删除。 | + +#### 必须保留的语义例子 + +`div(-17,5)=-3`、`rem(-17,5)=-2`;Python `//` 和 `%` 对负数的结果不同,所以不能直接用它们实现 RV32M 语义。除零、`INT32_MIN/-1` 也不能套用宿主异常行为。[RISC-V M 除法与余数](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/m-st-ext.adoc) + +前端/Builder 的同类型要求防止 `f64+f32` 在没有显式转换语义时被悄悄接受。对于以前把 free-variable f32 与 i32 循环变量混用的 DSL,这会改变接受行为。由于旧结果本就可能产生不一致 IR,这一收紧有理由;但应明确迁移方式,不能把被修改的旧测试视为混合类型兼容性已经验证。 + +`onnx_bits.py` 是最值得单独划定维护边界的扩展:它读取源存储,不负责 ONNX 类型推导,也不是替代官方 protobuf parser。现有代码只处理 Model→Graph→initializer 路径并按 descriptor 取字段编号,这种限制合理。重复字段、unknown groups 等测试有依据,但不是每个编码变体都需要再乘上所有分配器和优化级别。 + +### 4.2 目标能力、指令选择和机器合同 + +| 文件 | 实际改动及为什么需要 | 判断与保留建议 | +|---|---|---| +| [backend/target_features.py](../../../scratchv/backend/target_features.py) | 明确三组 ISA/ABI;float32 要 F,float64 要 D;拒绝不支持的目标。 | 目标能力判断是**核心必需**,否则输出在目标上无法运行。三组白名单是实现范围选择,并不意味着其他 ISA/ABI 组合都被 RISC-V 规范禁止。 | +| [backend/instruction_select.py](../../../scratchv/backend/instruction_select.py) | 为寄存器型指令物化立即数;统一 fresh vreg/label;多函数块标签隔离;显式函数 label_role;修正 FOR 的 step、边界及 overshoot。 | 立即数、唯一值身份是**集成必需**。循环改动是共享正确性修复,原任务没点名,但扩展控制流会经过这里。具体 step 支持是额外能力,必须有边界测试,当前有。 | +| [backend/inst_select_ext.py](../../../scratchv/backend/inst_select_ext.py) | 按 dtype 覆写正常 opcode handler;i32 div/rem/min/max/abs;f32/f64 四则、sqrt、符号操作、load/store、比较和返回;常量池;单参数 ABI;typed local slots;浮点定义检查。 | 主体是**核心必需**。删除旧未接入正常 opcode 的独立 f64 handler,改走普通 IR,是正确简化。typed local/参数/返回为**集成必需**。R2 说明作用域还没贯通。 | +| [backend/machine_types.py](../../../scratchv/backend/machine_types.py) | RegisterClass、bit_width、FloatConstant、LA/symbol、RNE、label_role、FPR 物理名/别名;补齐正常发射所需指令。 | 类别/宽度/目标指令是**核心必需**,RNE/label_role 是**集成或合同必需**。新增字段兼容旧调用形态合理;文件混用 CRLF/LF 是可顺手整理的格式问题。 | +| [backend/machine_semantics.py](../../../scratchv/backend/machine_semantics.py) | 每个位置注明 GPR/FPR;浮点比较 GPR dest、FPR sources;浮点访存地址 GPR;CALL 加入 FPR clobber;全流类别校验。 | **核心/集成必需**。活跃分析、spill 和分配必须对同一指令有一致解释,不能只按 mnemonic 开头有无 f 判断所有位置。 | +| [backend/symbols.py](../../../scratchv/backend/symbols.py) | 校验函数/内部标签/调用符号;避让 section 与 `.L` 导出限制;为 `$` 开头内部引用加引号。 | 输出合法符号是**集成必需**;覆盖极端名字是边界稳健性。独立模块避免 selector/emitter 各自猜规则,规模合理。 | + +#### 指令选择为什么需要替换,而不是在旧代码旁追加 + +旧整数 min 使用 `slt` 的 0/1 直接与差值做 AND,0/1 不是全位掩码。例如 `min(7,2)` 的旧序列得到 7。新实现先计算 `mask=0-cond`,得到 0 或 `0xffffffff`,再按位选择完整的输入。这是正确性修复,不能因为新序列有 5 条指令就认定它是多余改动。max 可以继续用正确的伪指令展开、分支或当前掩码序列;**必须正确选择整值,当前无分支方案不是唯一必要方案**。 + +旧 IR 中的整数常量可以直接变成 MachineOperand immediate,而 `add/sub/mul/div` 等需要寄存器源。`mul rd,7,3` 不能作为正常 RV32 汇编交给 Clang。新增统一 materialize helper 是真实硬件路径的必要修复;合法性修复增加静态指令数不等于性能回退。 + +浮点比较的目的寄存器是 GPR 布尔值,浮点数据本身仍在 FPR。`!=` 使用 feq 结果为零分支;`>`/`>=` 交换源后使用 flt/fle。无需发明不存在的浮点 branch 指令。这些映射合理。 + +`li.d` 被常量池 `.word/.quad + la + flw/fld` 代替,确保生成标准工具能处理的编码。保留 `-0.0` 和 NaN 时应按 `(dtype,bits)` 去重,不能按 Python 数值相等去重。对 f32 也可以用整数位物化加 `fmv.w.x` 等合法方案;使用常量池是统一、可靠的实现选择,而非唯一实现。 + +显式 `rne` 使目标运算不依赖调用者当前 frm;如果数值合同只写“遵循调用者舍入模式”,这段可以不同。但本分支明确承诺固定 RNE,因此舍入属性从 selector 到 rewrite/emitter 全程保留是**合同内必需**。[RISC-V F 舍入与状态](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/f-st-ext.adoc) + +### 4.3 分配、spill、局部内存和 ABI + +| 文件 | 实际改动及为什么需要 | 判断与保留建议 | +|---|---|---| +| [backend/allocation.py](../../../scratchv/backend/allocation.py) | RegisterType、4/8 字节 SpillSlot/Layout、结构化 AllocationResult;按指令验证 FPR 宽度、检查未分配虚拟寄存器。 | **核心/集成必需**。单一 spill_slot_count 无法表达混合宽度;保存字节大小、对齐和类别是正确方向。需要按 R2 补函数作用域。 | +| [backend/register_alloc.py](../../../scratchv/backend/register_alloc.py) | naive/greedy 分类选 GPR/FPR;按类型 load/store;保护固定物理 sources/defs;CALL 和 CFG 边界维护真实值;支持结构化结果。 | **核心必需**。三种对用户开放的分配模式都必须处理 f64,而非只选中一种才正确。源寄存器保护是**集成必需**,否则 reload 提前覆盖另一源。 | +| [backend/regalloc_linear.py](../../../scratchv/backend/regalloc_linear.py) | interval 携带类别/宽度;两个寄存器 bank;MachineInstr 元数据穿过 LsInstruction;分配结果继续走统一 emitter;新增 allocate_result。 | **核心必需**。线性扫描不能把 FPR 宽度/舍入属性丢在字符串转换中。结构化生产路径合理;复用状态遗漏见 R4。 | +| [backend/regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py) | spill/reload 按类型发射;保留 rounding/label_role;处理物理固定约束与边界 canonical slots;产出结构化指令。 | **核心/集成必需**。allocation 的首选物理位置不是运行时实际位置,最终重写必须维护真正寄存器内容。只更新 planner 而不更新 rewriter 不会得到正确产品。 | +| [backend/regalloc_linear_v1_5.py](../../../scratchv/backend/regalloc_linear_v1_5.py) | 实验分配器遇 FPR 明确拒绝,指向生产 linear。 | **集成边界保护**。原任务不要求同时扩展实验实现;5 行拒绝比静默输出错误更合理。完整 FPR 支持可留给该实验模块自身课题。 | +| [backend/abi_frame.py](../../../scratchv/backend/abi_frame.py) | 使用 byte layout;校验槽重叠/对齐;合并 local/spill/saved 区;按函数压缩布局;由 `.type` 识别函数;每条返回插恢复;限制直接可编码帧大小。 | **核心/集成必需**。f64 8 字节槽必须受整体布局约束;dot-prefixed 函数不能被当内部块。当前正则/注释协议可工作,但不是理想长期 API。 | +| [backend/regalloc_metrics.py](../../../scratchv/backend/regalloc_metrics.py) | spill/reload 计数加入 fsw/fsd/flw/fld。 | **可选配套**,不决定返回值;既然提供统计,应覆盖新访问类型,4 行改动合理。 | + +最小可行的 FPR 策略是只分配 `ft*`,在调用前 spill 活跃值,参数/返回走 `fa0`。这不需要本次同时实现 `fs*` 分配和保存恢复策略。当前选择牺牲部分跨调用性能,换取较小实现范围,符合标量交付目标。 + +使用 `ft*` 不代表可以忽略 CALL 的 FPR clobber:这些寄存器属于 caller-saved。sp 的对齐和 GPR callee-saved 保存也属于正常调用合同;产品与独立调用者分别编译再运行的测试,比只观察汇编含 `ret` 更有价值。[RISC-V psABI 调用与栈约定](https://riscv-non-isa.github.io/riscv-elf-psabi-doc/#_procedure_calling_convention) + +每函数 2032 字节上限是**当前直接立即数编码方案的限制**,不是 RISC-V 栈最大只能到 2032 字节。更大帧可通过物化常量和分步地址计算实现,原始任务没有要求本次完成;明确拒绝可以接受。按程序累计字节判断上限则不对,因此 `_function_slots` 按各函数实际引用压缩的方向正确。它解决容量问题,但没有解决 R2 的对象身份问题。 + +### 4.4 优化器:这里多数是 correctness 修复 + +| 文件 | 实际改动及为什么需要 | 判断与保留建议 | +|---|---|---| +| [optimizer/constant_folding.py](../../../scratchv/optimizer/constant_folding.py) | i32 逐步 32 位 wrap;整数除法向零截断;rem/min/max/abs;extended 模式保留浮点硬件运算。 | i32 是**核心/集成必需**;浮点保守策略是**合同必需**。宿主 double 并不证明 f32 逐步结果和异常标志相同。 | +| [optimizer/dead_code.py](../../../scratchv/optimizer/dead_code.py) | 使用集合扩至全函数及 phi;启用浮点语义时保留可设置 fflags 的算术。 | 跨块定义是**集成必需**;fflags 是**合同必需**。保守单轮保留可接受,不必本次实现完整可达性 DCE。 | +| [optimizer/peephole.py](../../../scratchv/optimizer/peephole.py) | 不再删除仍定义独立 IR 值的 add-zero;zero operand 保持 dtype、生成唯一名字;浮点禁止套整数恒等式。 | SSA/dtype/唯一身份是**集成必需**;浮点保护是**合同必需**。可以以后做正确的全函数 use replacement,不能恢复简单删定义。 | +| [optimizer/licm.py](../../../scratchv/optimizer/licm.py) | 修正多候选插入时的索引和顺序;限制纯数值/常量候选;不外提浮点算术;避免把访存/alloca 当纯运算。 | 索引/内存副作用修复是**集成必需**;零次循环不产生浮点异常的保护是**合同必需**。纯计算白名单比无 alias 证据便外提 load 更可靠。 | +| [optimizer/muladd_fusion.py](../../../scratchv/optimizer/muladd_fusion.py) | 保留注册接口,将错误的三源 ADD 伪融合改为 no-op。 | **必须停止旧错误变换**。保留兼容壳是合理选择;全局 no-op 不等于新实现了 FMA 优化。未来融合需合法 opcode、签名、lowering 和浮点 contraction 合同。 | + +以下例子解释为什么这些改动不能视为与 topic28 无关的“顺手重构”: + +1. **i32 中间溢出。**`(2147483647+1)/2` 必须先得到 `-2147483648`,再除以 2;无界 Python 整数一路折叠会得到相反符号。仅在最后包一层 i32 无法修复中间步骤。 +2. **f32 逐步舍入。**先 `16777216.0+1.0`,再减 `16777216.0`,f32 路径结果为 0;以宿主双精度连续折叠则可变成 1。 +3. **IEEE 非整数恒等式。**`Inf*0` 不能改成 0;`x+0` 也不能一概消去符号零和 signaling NaN 的行为。 +4. **未使用不等于无效果。**未使用的 `sqrt(-1)` 仍可设置 NV;在零次循环之外提前执行则增加了原程序没有的异常标志。此处可观察性来自本分支明确选择的 fflags 合同。[RISC-V F 浮点状态](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/f-st-ext.adoc) +5. **旧融合没有合法消费者。**旧 pass 把 `a*b+acc` 变成三源 `ADD [acc,a,b]`,而 selector 消费前两源。对 `2*3+4`,这甚至会算成 `4+2`;继续保留该转换没有正确性依据。 + +保守程度仍可优化:FNEG/FABS 等符号操作通常不设置 fflags,未用结果无需全部视为 effectful;某些静态可证明的浮点折叠也可能安全。但这些属于以后精化副作用模型,**现在用保守规则保证合同合理,全部禁用也不是理论上唯一必要的实现**。 + +### 4.5 编译入口、汇编输出和辅助工具 + +| 文件 | 实际改动及为什么需要 | 判断与保留建议 | +|---|---|---| +| [compiler.py](../../../scratchv/compiler.py) | extended 选择器接入真实管线;校验后端/开关互斥;优化前类型范围检查;向相关 pass 传浮点策略;统一 allocation/emitter/frame;提供分配统计;DSLParseError 返回结果错误。 | 接线和错误边界是**核心/集成必需**,否则新 selector 只是孤立单测可用。统计是**可选配套**。共享 LLVM 漏洞见 R1;逐个 isinstance 设置 policy 是暂时实现,长期可统一声明数值策略。 | +| [main.py](../../../scratchv/main.py) | 把原有 extended-isel CLI flag 真正传入 config;增加 target ISA/ABI 参数。 | **核心必需**。CLI 显示开关却不传入 driver,没有用户可用的新功能。 | +| [backend/asm_emit.py](../../../scratchv/backend/asm_emit.py) | 输出真实 F/D 操作、访存括号和舍入参数;常量池;拒绝 li.d/未知操作;显式函数角色、正确 `.size`、section 边界、符号检查。 | 浮点语法/数据是**核心必需**;函数边界/大小及错误处理是**集成必需**。未知指令不能继续只输出一行注释假装成功。 | +| [backend/asm_parser_for_beautifier.py](../../../scratchv/backend/asm_parser_for_beautifier.py) | 浮点四则/平方根允许 rm 操作数,比较和符号操作使用正确元数。 | 保留 `--beautify` 组合正常工作所需的**可选工具兼容配套**。不启用该工具不影响核心执行,但不能让合法新汇编被误诊断。 | +| [backend/asm_beautifier.py](../../../scratchv/backend/asm_beautifier.py) | 新操作解释模板。 | **可选配套**,可以拆提交;22 行不构成过度架构。 | +| [backend/cycle_estimator.py](../../../scratchv/backend/cycle_estimator.py) | 排除指示伪计数,识别 ft/fa/fs 名。 | **可选配套**。没有这些改动,常量池可能污染计数;新改动并不证明当前延迟模型已准确建模所有 F/D 处理器。 | +| [backend/riscv_encoder.py](../../../scratchv/backend/riscv_encoder.py) | 对浮点指令和伪指令明确报 F/D 不支持。 | **集成边界保护**。当前 encoder 是 RV32IM,拒绝比错误编码更正确。原任务“验证模拟器支持”可由外部真实执行满足,不强制本次重写内部浮点 encoder。 | +| [simulator/rv32_emulator.py](../../../scratchv/simulator/rv32_emulator.py) | 机器执行补正确 DIV/REM/SLT/SRAI;F/D 明确拒绝;IR 数值参照保留 dtype、增加新算子、停止未定义值归零和忽略控制流。 | 整数机器语义为**核心/集成必需**,F/D 拒绝为边界保护;IR 参照扩展是配套正确性。权威编码遗漏见 R3。 | +| [verification/division_reference.py](../../../scratchv/verification/division_reference.py) | 统一 i32 除法边界;浮点不再向分母加 epsilon,保留精度。 | **配套验证必需**。参照分母若加 epsilon,就会把除零/小数值行为改掉,无法核对产品。 | +| [verification/fp_reference.py](../../../scratchv/verification/fp_reference.py) | minimumNumber/maximumNumber 数值参照,处理单/双 NaN、符号零和精度;声明不模拟 fflags。 | 对选择的 min/max 数值合同是**配套验证必需**。清晰限制比称 NumPy 为完整硬件 oracle 更可靠。 | +| [verification/verifier.py](../../../scratchv/verification/verifier.py) | DSLInterpreter 加 typed const、新操作和相同除法/min/max 值语义;未定义名字报错。 | **配套验证必需**。否则测试的“expected”就不是目标数值语义。这里的 `_riscv_i32_rem` 与 IRTraceExecutor 完全重复,可以合并,重复的两份实现并非必须。 | + +#### 可以更小但不能失去的工程边界 + +ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线的实现成本选择;有测试保护时可以作为当前实现,但需要把这些标记当结构协议保护。后续应让 StackSlotRef 在最终布局前一直保留结构化身份。**不能只把注释删掉当美化,否则当前生产布局会失效。** + +`_record_allocation_stats` 重新调用 emitter 统计站点,与核心代码生成无关,可拆成独立观测改动。这些数值是 selected/allocated 阶段统计,不能把它们当最终 ABI/汇编 pass 后的完整动态成本。 + +单标量参数、三组目标、非张量形状、硬件 sqrt、固定 RNE,以及不启用 DAG/LLVM 的 extended selector 都是清晰的范围选择。若用户要求这些边界之外的能力,需要继续实现;当前不应据此要求把整个 RISC-V 后端一次扩展为通用框架。 + +## 5. 测试变更是否有必要,是否在掩盖回归(审查时) + +新增代码里,测试占了很大一部分。判断它们是否有必要,要看三个问题:是否检查了实际结果,预期结果是否独立于被测代码,以及不同配置是否真的会经过不同的处理路径。 + +### 5.1 新增测试和辅助文件 + +下表覆盖本次新增的全部 22 个测试/辅助文件。 + +| 文件 | 必要性评估 | +|---|---| +| [test_topic28_acceptance.py](../../../tests/test_topic28_acceptance.py) | **必须保留**:99 项,直接覆盖原始交付、三种分配器、真实 ABI 与执行。 | +| [test_topic28_ir_frontend.py](../../../tests/test_topic28_ir_frontend.py) | **必须保留**:新语法/IR 类型和拒绝边界,避免只测 selector 枚举。 | +| [test_topic28_isel_integer.py](../../../tests/test_topic28_isel_integer.py) | **必须保留**:div/rem/min/max/abs 和整数边界映射。 | +| [test_topic28_constant_folding.py](../../../tests/test_topic28_constant_folding.py) | **必须保留**:RV32 中间溢出、向零截断与除零保留硬件路径。 | +| [test_topic28_stage2.py](../../../tests/test_topic28_stage2.py) | **必须保留核心矩阵**:F/D 选择、常量池、比较及合法汇编;纯前端拒绝不必复制所有 allocator。 | +| [test_topic28_stage3.py](../../../tests/test_topic28_stage3.py) | **必须保留**:类别、宽度、spill 和真实浮点执行。低压力单测不能替代高压力 spill 验证。 | +| [test_topic28_cli_integration.py](../../../tests/test_topic28_cli_integration.py) | **必须保留**:开关和真实 driver 接入。但目前应补 R1 的“成功输出是否真合法”。 | +| [test_topic28_independent_integer.py](../../../tests/test_topic28_independent_integer.py) | **有必要**:独立整数运算链可发现单条指令正确、组合中间语义错误的情况。 | +| [test_topic28_independent_division.py](../../../tests/test_topic28_independent_division.py) | **有必要**:参照和机器除法不能共用同一个错误宿主算法而互相证明。 | +| [test_topic28_independent_boundaries.py](../../../tests/test_topic28_independent_boundaries.py) | **有必要**:scalar scope、无定义浮点源、i32 常量范围,与正常输出用例不同。 | +| [test_topic28_review_backend.py](../../../tests/test_topic28_review_backend.py) | **有必要**:固定物理寄存器 clobber、CFG 边界和非法 link-result,普通 DSL 路径不容易触发。 | +| [test_topic28_review_fixed_uses.py](../../../tests/test_topic28_review_fixed_uses.py) | **有必要**:只存在固定 source、没有高压力或 call 时的 relocation;与固定 dest clobber 不是同一问题。 | +| [test_topic28_review_frontend_oracles.py](../../../tests/test_topic28_review_frontend_oracles.py) | **有必要**:条件词法、类型化字面量、参照特殊值、不能忽略控制流。 | +| [test_topic28_review_onnx.py](../../../tests/test_topic28_review_onnx.py) | **有必要**:ONNX 类型、initializer、变长 Min/Max 和标量模型入口。 | +| [test_topic28_review_optimizations.py](../../../tests/test_topic28_review_optimizations.py) | **必须保留**:优化级别/显式 pass 下的浮点精度与优化安全性。 | +| [test_topic28_overall_review.py](../../../tests/test_topic28_overall_review.py) | **有必要**:跨块 DCE、unused FP flags、多函数标签/类型边界。当前多函数用例缺 R2 的独立构建重名。 | +| [test_topic28_third_review.py](../../../tests/test_topic28_third_review.py) | **有必要**:名称冲突、CFG 定义顺序、LICM/循环边界等共享管线问题。 | +| [test_topic28_fourth_review.py](../../../tests/test_topic28_fourth_review.py) | **有必要**:真实调用、源码位模式、物理约束、符号边界;属于强合同交付。 | +| [test_topic28_fifth_review.py](../../../tests/test_topic28_fifth_review.py) | **强合同配套**:protobuf 编码、label role、ELF size 和 per-function frame;不是最低 f64 任务的所有前提。 | +| [test_topic28_sixth_review.py](../../../tests/test_topic28_sixth_review.py) | **强合同配套**:重复字段/存储判别、常量编码;保留实际缺陷类别,避免重复乘无关配置。 | +| [topic28_runtime.py](../../../tests/topic28_runtime.py) | **必须保留真实 oracle 能力**:汇编、链接、QEMU,观察 raw bits/fflags/ABI;可重构实现,不能换成只检查字符串。 | +| [topic28_support.py](../../../tests/topic28_support.py) | **合理的去重配套**:统一编译/分配 setup,35 行不构成复杂测试框架。 | + +按 review 轮次命名有追溯意义,但会让后续维护者难以按模块寻找用例。未来可以迁入 frontend/isel/allocation/frame/optimization 分组,保留缺陷编号。重命名不是合并前的正确性前提。 + +### 5.2 既有测试修改 + +| 文件 | Review 判断 | +|---|---| +| [test_inst_select_ext.py](../../../tests/test_inst_select_ext.py) | 从旧开关/无定义默认 f32 输入转向 TargetFeatures 和明确 i32/FP definitions,符合新真实路径;也修正 STORE 操作数次序。合理,但旧构造参数被删除是 API 变化,应在文档写迁移说明。 | +| [test_ir_verifier.py](../../../tests/test_ir_verifier.py) | 增加新操作的独立签名期望,正确。不能仅从被测规格表生成同一份期望,否则验证失去独立性。当前保留独立矩阵。 | +| [test_optimizer_advanced.py](../../../tests/test_optimizer_advanced.py) | 从“删掉 add-zero 定义/生成三源 ADD”为成功,改为保留合法 IR 并 verifier 验证,是修正错误测试合同,不能恢复原断言。 | +| [test_llvm_codegen.py](../../../tests/test_llvm_codegen.py) | 将 `add(x,i)` 改为 `add(i,i)` 避开类型混用,理由可理解;但新测试没有验证 LLVM 解析合法性,无法替代跨后端适配验证。R1 是具体遗漏。 | +| [test_regalloc_pseudo.py](../../../tests/test_regalloc_pseudo.py) | 更新 FPR 元数据和 extension pseudo 集合,明确 li.d 拒绝。合理:错误扩展码不能再被当正常 RV32IM。 | +| [test_rv32_emulator.py](../../../tests/test_rv32_emulator.py) | 机器 DIV/REM/SLT/SRAI 补回归,是必要的执行验证。 | +| [test_verification.py](../../../tests/test_verification.py) | 新 dtype、除法及参照操作回归有必要;应再补 R3 权威编码。 | +| [test_compare_peephole.py](../../../tests/test_compare_peephole.py) | 用本次测量值验证报告渲染及聚合关系,替代非法旧汇编时代固定指令总数,合理。但同源聚合只证明展示一致,不能独立证明编译器没有性能退化;核心固定 micro/CNN 断言仍保留。 | + +审查时没有发现通过跳过失败测试、或删除关键结果检查来让测试通过的情况。但原有测试漏掉了四类输入:LLVM 成功输出是否真的有效、分别创建的函数是否会混用同名变量、核对工具是否读取常量的原始编码,以及同一个分配器能否在空调用时清掉旧结果。第 2 节记录了后来补上的测试和修复。 + +## 6. 文档、示例与 CI:哪些是必要配套 + +| 文件 | Review 判断 | +|---|---| +| [28-扩展指令选择-设计文档.md](28-扩展指令选择-设计文档.md) | 架构、数值合同和边界说明是必要配套;552 行 API 方案不是原任务最低交付所需。应区分目标 API 与已落地 API,见下文。 | +| [28-README.md](28-README.md) | 支持列表、目标参数、真实执行方式和限制,是原任务明确要求,必须保留。 | +| [28-交付与验收.md](28-交付与验收.md) | 要求到交付映射、强制 QEMU 命令有价值。本次确认 99 和 3475 的实际计数;历史从 5759 精简到 1793 的过程未在本次重新验证。 | +| [28-扩展指令选择.md](28-扩展指令选择.md) | 保留原始任务并增加链接,合理且必要性明确。 | +| [i32_div_rem.dsl](../../../benchmarks/topic28/i32_div_rem.dsl) | 原任务要求的整数示例,真实预期 -5。 | +| [f32_sum.dsl](../../../benchmarks/topic28/f32_sum.dsl) | 展示两种精度关系的合理配套,预期 3.5;并非只满足 f64 字面要求所必需。 | +| [f64_sum.dsl](../../../benchmarks/topic28/f64_sum.dsl) | 原任务要求的双精度求和,预期 6.75。 | +| [f64_dot.dsl](../../../benchmarks/topic28/f64_dot.dsl) | 标量乘加展开的双精度点积,预期 15;没有证明 IR DOT 或通用张量 f64 后端已支持。 | +| [.github/workflows/ci.yml](../../../.github/workflows/ci.yml) | 本次仅新增 Clang/LLD/QEMU 版本打印,属于可选可观测性配套。工具安装和 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1` 机制在基线就存在,不能算成本次新建设的 CI。 | + +设计中的 `TypeInfo`、`ScalarConstant`、`VirtualRegister`、`RegisterRef`、`MemoryRef`、`MachineFunction`、`TargetSpec`、`RegisterPolicy` 和各类专用 Error,与实际使用的 `Value/MachineOperand/MachineInstr/TargetFeatures/AllocationResult`、ValueError/CompileResult 并不完全对应。实际实现沿用平铺机器流和最终文本布局,**没有完整落地设计列出的结构化 API**。 + +建议把设计文档相关段落标成“目标接口/后续演进”,或增加“实际实现映射与差异”表。没必要为满足文档中自行增加的抽象,强行实现原任务不要求的整套新类型;但也不能让读者以为文档代码片段就是可调用 API。R2 正体现了平铺结构与“每函数身份”设计之间仍有差距。 + +其他明确边界:ONNX 标量且至多一个参数;initializer 与 input 同名时冻结 initializer;不同精度无隐式转换;内部 encoder/emulator 不支持 F/D,执行验证用 QEMU。这些限制已经在使用指南说明,不能把验收结论扩展到完整 ONNX 张量编译器。 + +## 7. 哪些不是原始任务的最低必需,可以简化或拆分 + +| 改动 | 能否缩减 | 缩减时必须满足的条件 | +|---|---|---| +| 全套 protobuf typed wire reader 及非常规字段回归 | 可单独拆提交/课题;不是普通有限数 f64 运算的最低前提 | 保留当前强合同就需要源编码通路;缩范围必须同步文档和验收,不可静默损失 sNaN/payload。 | +| 固定 RNE 和可观察 fflags 导致的全局浮点保守优化 | 可细化,不宜直接删 | 需要对每条变换证明精度、NaN/符号零、异常标志一致;原任务没要求完整软件浮点证明器。 | +| 统一使用浮点常量池 | 方案可替代 | 替代发射必须标准工具可汇编、按位正确、满足 f32 在 D 上表示要求。 | +| i32 max 无分支掩码序列 | 可换成正确 pseudo 展开或分支 | 保持全有符号范围正确;不能回到以差值符号/0-1 掩码错误选择的方案。 | +| allocation 统计、cycle estimator、beautifier 注释 | 可拆为工具配套 | 若仍开放这些工具组合,其输出必须识别 F/D,统计应明确阶段和模型范围。 | +| 五个 pass 类的 preserve_float_semantics 配置 | 可以改成统一 policy 接口 | 不要只保护 preset 而遗漏显式 `--passes`,也不要改一个 pass 后另一个仍破坏相同合同。 | +| 两份相同的 `_riscv_i32_rem` | 可以直接去重 | 提取进共享除法/余数参照模块,保持 dtype 与零除/溢出行为。 | +| 保留 MulAddFusion 注册壳和兼容属性 | 壳合理,部分无实际效果的 policy 分支可精简 | 不重新生成三源 ADD;保留 preset/显式 pass 名的兼容性。 | +| 552 行设计及轮次命名测试组织 | 可整理、建立实现映射 | 不丢数值合同和实际缺陷类别;设计草案不能冒充已实现接口。 | +| 新文件的 Python 版本适配 | 应与项目声明同步,属于兼容性债务 | `pyproject.toml`/CONTRIBUTING 写 3.8+,CI 用 3.12。实测 3.8 加载新 onnx_bits.py 在 `tuple[int,int]` 注解失败;基线的 asm_parser_for_beautifier 已有同类导入问题,所以不能全归因于 topic28。新增 target_features 还用了 removeprefix。要么补兼容,要么统一提高声明并验证。 | + +所谓“不是最低必需”,不意味着应把已经验证的保护删掉。更合理的处理是保持生产正确性,减少重复实现、清晰划分合同和提交边界。 + +## 8. 当前修复状态和后续建议 + +五个已确认错误都已修复:R1 对 LLVM 暂不支持的整数操作明确报错;R2 区分不同函数的同名变量;R3 按原始编码读取浮点常量;R4 每轮重新建立指令列表,空输入不再返回旧内容;R5 保留 ONNX 浮点 Min/Max 的 NaN 传播。每项都先补充测试,再修复代码,前后结果见第 2 节。R5 修复时的全量回归为 **3826 通过、0 失败、0 跳过**;代码兼容性修改见第 9 节。 + +后续仍建议补充文档中的接口说明:旧的 ExtendedInstructionSelector 构造参数如何迁移,设计草案中的接口与实际代码如何对应,以及当前点积示例只展示标量乘加、没有证明通用张量点积已支持。 + +本课题需要保留的能力是:**从输入到结果保持正确类型,选择真实的单/双精度指令,使用正确的寄存器,完整保存 8 字节的 f64,生成合法汇编,正确处理函数调用,并保证优化不改变约定的结果。** 对非常规 protobuf 编码的支持、统计和美化工具、部分更严格的浮点保证,可以单独划分维护范围。调整范围时,应同步说明和测试,让读者知道实际支持到哪里。 + +## 9. Python 3.8 兼容性修复(2026-10-13) + +后续复审发现,安装配置虽然写着 Python 3.8+,新增代码却用了较新版本的类型注解和字符串方法。用户要求按最低版本兼容,因此保留 3.8 的安装下界,并修改代码和版本说明。 + +**怎么修复:** + +- [target_features.py](../../../scratchv/backend/target_features.py) 改用 `typing.FrozenSet`,并在已验证的 ISA 字符串上用切片取扩展部分。[onnx_bits.py](../../../scratchv/frontend/onnx_bits.py) 改用 `typing.Tuple/Dict`,两者在 3.8 导入时不再报错。 +- 真实后端导入还会经过汇编解析器和调度器。解析器的注解改为 3.8 支持的类型;`SchedInst` 用显式构造函数保留只能通过关键字传入的 `target`,继续保持不可变对象、替换和原有位置匹配行为。 +- 一些既有测试和基准脚本也会在导入时计算新式注解,补上兼容注解或延迟求值保护。脚本里的 `Path.is_relative_to()` 换为 `relative_to()` 加异常处理,继续检查模块是否来自指定目录,以及模型路径如何展示。 +- 旧 ONNX 的枚举可能返回同值但不同实例,测试改为比较枚举值。旧 NumPy 在部分 CNN 测试中会把除法结果提升成 f64,测试夹具明确使用 f32 除数,保持原本要求的 FLOAT 模型;预期结果及执行检查保留。 +- README 和 CLAUDE 的版本说明统一为 Python 3.8+;代码保留兼容写法。测试使用 Python 3.12 主环境,结果上传到现有 test-reports artifact。 + +**当前测试安排(2026-10-14):**按用户要求,删除专门的最低版本测试文件、CI 环境及对应报告 artifact。项目仍要求兼容 Python 3.8;功能回归沿用现有 CI 流程,其 Python 3.12 解释器只是测试环境。 + +兼容性代码修改当时在 Python 3.12 的完整回归为 **3610 通过、0 失败、0 跳过,159.18 秒**,包含原始 99 项验收及 Clang/LLD/QEMU 实际执行,仅有既有 TinyFive `numpy.fix` 弃用提示。该数字记录当时的测试集合;后续测试增删以当前收集结果为准。 diff --git "a/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\345\244\215\345\256\241\346\212\245\345\221\212-2026-10-14.md" "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\345\244\215\345\256\241\346\212\245\345\221\212-2026-10-14.md" new file mode 100644 index 00000000..873a16f8 --- /dev/null +++ "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\345\244\215\345\256\241\346\212\245\345\221\212-2026-10-14.md" @@ -0,0 +1,149 @@ +# Topic 28 复审报告(2026-10-14) + +**本次确认两个功能问题:R7 是 LLVM 常量输出假成功或改变数值,优先级 P1;R6 是 ONNX 正负零处理与官方实现不一致,优先级 P2。另有两项 P3 规范/维护意见。** R1–R5 的修复仍然有效,但当前变更还不能宣称所有支持输入都正确。 + +使用 Matt 的 `code-review` 工程技能,由两个独立审查分别检查项目规范(Standards)和课题要求(Spec),主审再核对可疑结果。日期按 Asia/Shanghai。 + +**后续本地状态:S1、R7 已修复并通过验证,方案和结果见对应条目。S2、R6 仍待处理。后续修复仅作本地提交,未 push,也没有更新 PR。** 上面的审查结果针对固定提交 `37516a7`,下面新增的修复记录随本地提交保存。 + +## 范围与方法 + +固定基线:`f3af99d7cb6eab924fc59d0d2de851c4c3b23e7c`。固定被审提交:`37516a74136a661e65298375cbca275e0c99ae86`。 + +```bash +git diff f3af99d...37516a7 +git log f3af99d..37516a7 --oneline +``` + +覆盖 105 个文件,包括原始功能和后续 R1–R5、Python 3.8 修复。审查提交依次是 `d0147a3`、`9b4b2a3`、`3a681f4`、`2dc976c`、`492bcfb`、`814dc9e`、`9d08814`、`701cdc5`、`e061861`、`77bef84`、`37516a7`。审查阶段没有修改生产代码或测试;后续 R7 本地修复记录在对应条目。 + +课题依据是[原始六项要求与每周目标](28-扩展指令选择.md)、[实际支持范围](28-README.md)和[交付验收](28-交付与验收.md)。设计草案中的目标接口不直接当作必须实现的新功能。审查按当前 RV32 标量 i32/f32/f64、三种分配器、三组 ISA/ABI 的范围判断。 + +规范依据包括 `CONTRIBUTING.md`、`CLAUDE.md`、`docs/reference/CODING_STANDARDS.md` 及相关开发/架构说明。按技能要求,不重复列出格式、导入排序和类型检查工具已经负责的项目;代码重复等意见是维护建议,不等于算错。 + +## Standards + +### S1,P3:新增测试的组织方式与项目文档不一致(已修复) + +`docs/reference/CODING_STANDARDS.md` 的 Testing 要求 “Use pytest with class-based test organization”。审查时,[新 NaN 测试](../../../tests/test_topic28_onnx_nan.py)及多个新增 Topic 28 文件使用顶层测试函数。 + +这是明确的文档规则不一致,不影响运算结果。可按行为整理为测试类,或者正式调整规范,使其与项目实际采用的 pytest 写法一致。无需为此改变测试输入或预期。 + +#### 我们怎么修复(2026-10-14) + +将 27 个文件中的 225 个新增顶层测试函数归入 `Test...` 测试类,例如 `TestONNXMinMaxNaNPropagation`、`TestIntegerConstantFolding` 和 `TestFunctionValueScope`。同时整理 LLVM 输入类型、常量读取、分配器复用,以及既有文件中本轮新增的 RV32 运算和 IR 执行测试。原来已经使用测试类的 LLVM 常量测试保持原样。 + +参数化装饰器保留在各测试方法上,pytest fixture 仍通过方法参数注入;辅助函数继续放在模块层。没有增加或删除测试,没有修改输入、断言或 DSL/汇编字符串,也没有修改生产代码。 + +对比前后 Python AST,除增加测试类和方法的 `self` 参数外,测试逻辑、装饰器及其余模块代码一致。前后全库均收集到 **3999 项**,去掉新增类名后,每个参数化用例的身份及数量完全一致。 + +受影响的 27 个文件实际运行 **2146 通过、0 失败、0 跳过,90.03 秒**。使用现有 Python 3.12 环境,设置 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1`,包含真实 RISC-V 执行;只有既有 TinyFive `numpy.fix` 弃用提示。27 个文件的 Black 检查及 `git diff --check` 均通过。结果保存在 `/tmp/topic28-s1-regression.xml`;收集对比和整理脚本也仅保留在 `/tmp`,不纳入提交。 + +### S2,P3:整数 Min/Max 的两份生成逻辑重复 + +[整数 Min](../../../scratchv/backend/inst_select_ext.py#L340)和[整数 Max](../../../scratchv/backend/inst_select_ext.py#L386)都生成 `SLT → SUB → XOR → AND → XOR`,主要区别是最后一个 XOR 选择哪个输入。 + +对应 Matt 技能的 possible Duplicated Code,是判断性维护建议。目前没有发现这两段算错。可保留每个 opcode 的 handler,抽出共用的内部生成方法,避免以后修改掩码或临时寄存器时漏改一份。 + +本轴未确认新的版本兼容性问题。项目仍要求代码兼容 Python 3.8;本次功能回归使用现有 Python 3.12 测试环境。 + +## Spec + +### R7,P1:LLVM 接受新常量输入,却输出无效文件或错误数值(已在本地修复) + +#### 发生了什么 + +以下三个程序都只有一条常量定义,然后 `return a`。使用 `CompilerConfig(backend="llvm", optimize_level=...)`,不启用 extended selector。 + +| 常量定义 | 修复前实际结果 | 修复后实际结果 | +|---|---|---| +| `a = const(1.2,dtype:f32)` | `success=True`、空错误列表,LLVM 解析失败 | 解析、验证通过,返回正确 f32 编码 `0x3f99999a` | +| `a = const(1.0000000000000002,dtype:f64)` | LLVM 验证通过,实际返回 `1.0` | 返回 `1.0000000000000002`,编码为 `0x3ff0000000000001` | +| `a = const(-0.0,dtype:f64)` | LLVM 验证通过,实际返回正零 | 返回负零,编码为 `0x8000000000000000` | + +三项在 `none/basic/all` 下都独立复现,共 9 个场景。 + +第一项生成 `%v_1_1 = fadd float 1.200000e+00, 0.0`,当前 llvmlite/LLVM 报 `floating point constant invalid for type`。第二项应有位模式 `0x3ff0000000000001`,实际为 `0x3ff0000000000000`。第三项应有 `0x8000000000000000`,实际为 `0x0000000000000000`。 + +#### 为什么出错,为什么与本次变更有关 + +[DSL 前端](../../../scratchv/frontend/dsl_parser.py#L170)向共享编译入口开放了 typed const,[LLVM 类型检查](../../../scratchv/backend/llvm_codegen.py#L31)也明确允许 f32/f64。但旧的[常量格式化](../../../scratchv/backend/llvm_codegen.py#L625)使用默认 `:e`,只保留七位有效数字,不能精确表达这些浮点值;[常量加载](../../../scratchv/backend/llvm_codegen.py#L303)还用 `fadd 常量, +0.0` 构造常量,导致负零变为正零。 + +原始要求第 11–12 行要求添加双精度支持、区分 f32/f64。这里不是要求课题实现 LLVM 的全部功能,而是共享前端开放新输入后,接收它的后端必须正确处理或明确拒绝。精确导出的基线 `f3af99d` 对同一 typed const 语法明确返回失败;HEAD 变成接受输入后给出错误产物。不能据此声称基线其他浮点入口原来完全正确。 + +#### 审查时的修复建议 + +按目标精度和准确位模式输出 LLVM 常量,避免用浮点加零来复制常量;暂未支持的情况应返回失败。覆盖 f32 普通小数、f64 最低有效位、正负零及特殊值。测试先解析/验证,再实际执行并核对位模式:仅能通过 LLVM 验证,仍可能返回错误数值。 + +三项都经过 LLVM 解析/验证;解析成功的两个 f64 场景进一步通过原生 MCJIT 核对实际返回值。为在本机执行,只设置了模块的本机目标三元组和数据布局,没有修改生成的函数指令。主审也独立复核了相同结果。 + +#### 我们怎么修复(2026-10-14) + +- [llvm_codegen.py](../../../scratchv/backend/llvm_codegen.py) 按 f32/f64 编码常量,以 LLVM `bitcast` 直接表示原始位模式。加载常量也改用 `bitcast`,不再用浮点加零,因此普通小数、最低有效位和负零都能保留。 +- 常量有明确 `bit_pattern` 时优先读取编码;LOAD_CONST 的指令属性和结果 Value 都可以提供编码,两者冲突或编码无效时明确拒绝。ONNX initializer 和直接用作操作数的常量使用同一条精确表示路径。 +- 补测发现,旧的 `basic/all` 还会先用宿主精度折叠 f32 运算:`f32(16777217)-f32(16777216)` 应为 0,旧折叠却得到 1。[CompilerDriver](../../../scratchv/compiler.py) 的 LLVM 路径现在启用现有浮点语义保护,预设级别和显式 pass 均适用,让 LLVM 执行这些按声明精度计算的运算。浮点折叠等变换因此更保守;本次没有测量或声称性能提升。 +- 新增 [test_llvm_scalar_constants.py](../../../tests/test_llvm_scalar_constants.py),按测试类组织。保留 R1 的整数拒绝检查;本次没有修改 R6 的 ONNX 符号零行为。 + +#### 修复前后的验证结果 + +使用 Matt 的 `diagnosing-bugs` 和 `tdd` 技能,把上一轮已复核的三个错误固化为公开编译入口的回归测试。测试先解析/验证 LLVM,再实际执行;在 LLVM 中把浮点返回值的位模式转成整数后交给 Python,避免 Python 的浮点转换改变 NaN 编码。 + +初始 9 项测试覆盖三个原始场景 × `none/basic/all`,修复前全部失败。先修精确表示后,6 项通过、3 项负零测试仍失败;再去掉加零加载,9 项全部通过。随后原始编码的 32 项测试先全部失败,再修复编码读取;f32 舍入测试先为 2 失败、1 通过,再修复优化保护。 + +最终新专项共 179 项,覆盖 DSL/合法 ONNX、两种精度、三优化级别、普通小数、正负零、无穷大、NaN 编码、次正规数、最大有限数、舍入边界、编码来源/拒绝,以及显式优化 pass。把同一份测试复制到精确导出的 `37516a7`,并确认实际导入该目录的生产代码,修复前 **148 失败、31 通过**;当前本地修复后 **179 全部通过**。 + +| 验证范围 | 结果 | +|---|---| +| Python 3.12 LLVM 专项及原有 LLVM 测试 | 227 通过、0 失败、0 跳过 | +| Python 3.12 完整 `tests benchmarks/test_benchmark.py` | **4005 通过、0 失败、0 跳过,166.02 秒** | + +全量设置 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1`,实际包含 Clang/LLD/QEMU 执行;仅有既有 TinyFive `numpy.fix` 弃用提示。新测试文件通过 Black 格式检查,`git diff --check` 通过。 + +**测试范围调整(2026-10-14):**按用户要求,移除最低版本的专用测试和 CI 入口,代码仍必须兼容 Python 3.8。上面的 4005 项是调整前已完成的回归结果,其中包含本次删除的 6 项;功能测试沿用现有 CI 环境,R7 的 179 项专项测试保留。调整后收集到 3999 项测试,LLVM 专项及原有 LLVM 回归仍为 227 项全部通过;CI YAML 已检查。 + +前后日志和 JUnit 保存在 `/tmp/topic28-r7-*`。修复代码、测试及本报告纳入本次本地提交;evidence/repro 文件未改、未纳入提交。本次未推送,也未更新 PR。 + +### R6,P2:ONNX Min/Max 的正负零处理,让后续结果符号相反 + +#### 发生了什么 + +正零和负零相比较时数值相等,但它们参与后续浮点除法,结果符号不同。两个通过 ONNX checker 的合法标量模型得到: + +| 模型计算 | ONNX 官方参考实现和 ONNX Runtime | 编译后实际执行 | +|---|---|---| +| `1.0 / Min(-0.0, +0.0)` | 正无穷大 | 负无穷大 | +| `1.0 / Max(+0.0, -0.0)` | 负无穷大 | 正无穷大 | + +覆盖 Min/Max × f32/f64 × naive/greedy/linear × none/basic/all,**36 个真实 ELF/QEMU 场景都出现差异**。 + +#### 为什么出错,现有测试为什么通过 + +原始要求第 5 行要求增加 ONNX 算子支持,第 31 行包含 Min/Max。[浮点选择器](../../../scratchv/backend/inst_select_ext.py#L308)在非 NaN 情况仍直接使用 RISC-V `fmin/fmax`。它们把负零排在正零前面;本次测试中,两个 ONNX 实现都选择了相等输入中的第二个。 + +README 第 56 行已声明沿用目标的符号零约定,[现有符号零测试](../../../tests/test_topic28_onnx_nan.py#L206)也直接检查这个目标预期。因此测试通过,但它不能证明源 ONNX 模型的结果得到保留。R5 的 NaN 修复仍有效,这里报告的是另外一种输入。 + +准确限定:公开的 [ONNX Min 算子说明](https://onnx.ai/onnx/operators/onnx__Min.html)没有明文规定正负零相等时选哪一个。本项报告的是与[官方 Min 参考实现](https://raw.githubusercontent.com/onnx/onnx/v1.23.0/onnx/reference/ops/op_min.py)、[Max 参考实现](https://raw.githubusercontent.com/onnx/onnx/v1.23.0/onnx/reference/ops/op_max.py)及 ONNX Runtime 都不一致、并能改变后续结果的兼容性问题,不把它描述为违反某条明确写出的符号零规范。 + +#### 建议修复和补测 + +分别保留 ONNX 与 DSL 的浮点规则。在 ONNX 路径中处理相等的正负零选择,并补测两种输入顺序、多个输入和 Min/Max 后接 Div 的模型。预期应来自独立 ONNX 实现;DSL 可继续保持已经约定的 RISC-V 行为。 + +六项原始要求及 W9/W10 均有对应实现,本轴没有确认其他独立缺项或越界工作。 + +## 验证与产物 + +重新运行完整仓库回归: + +```bash +PATH=/home/yu/.cache/scratchv/riscv-tools/extracted/usr/bin:$PATH \ +SCRATCHV_REQUIRE_RISCV_EXECUTION=1 \ +/home/yu/workspace/ScratchV/.venv/bin/python -m pytest -q \ + tests benchmarks/test_benchmark.py \ + --junitxml=/tmp/topic28-review-2026-10-14-full.xml +``` + +结果 **3826 通过、0 失败、0 跳过,165.82 秒**;仅有既有 TinyFive `numpy.fix` 弃用提示。这个通过结果说明已有测试没有失败,不能排除上面独立探针确认的输入错误。当前没有 `.claude/harness/verify/run.py`,未执行不存在的 L2 入口。 + +日志与临时探针保留在本地:`/tmp/topic28-review-2026-10-14-full.*`、`/tmp/topic28_spec_zero_div.py`、`/tmp/topic28_spec_zero_div/`、`/tmp/topic28_spec_llvm_f64.py`、`/tmp/topic28_spec_llvm_f64/`。不提交 evidence、repro 脚本或执行产物。测试组织和重复逻辑意见不计入功能错误数。 + +审查时 R6/R7 尚未修复。Standards:2 项,最高 P3;Spec:2 项,最高 P1。两条审查轴分别计数,不跨轴合并或重新排序。后续 S1、R7 已修复,解决办法与验证结果写在对应问题后面;S2、R6 仍待处理。 diff --git "a/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251-\350\256\276\350\256\241\346\226\207\346\241\243.md" new file mode 100644 index 00000000..44a925b2 --- /dev/null +++ "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251-\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -0,0 +1,552 @@ +# 课题 28:扩展指令选择技术设计 + +扩展指令选择将带类型的 IR 转换为 RISC-V 机器指令。方案以数值类型为主线,连接前端解析、操作语义、机器表示、寄存器分配、内存布局和函数接口,使整数、单精度和双精度运算使用各自正确的数据通路。 + +## 设计目标与范围 + +设计面向 RV32 标量计算,覆盖 i32 的除法与余数,f32/f64 的四则算术与平方根,以及这些类型的最小值、最大值、取负、绝对值、访存、比较和条件分支。 + +整数运算采用 I/M 扩展,单精度和双精度浮点采用 F/D 扩展。函数接口以单个标量参数和单值返回为基础。软件浮点、向量化、通用张量布局和复杂参数传递属于独立的设计范围。 + +整体实现沿用“IR → 指令选择 → 寄存器分配 → 汇编表达”的编译流程。整数规则作为基础路径,浮点规则按明确的类型和目标能力接入;类型、值宽度和存储信息在各环节之间显式传递。 + +## 总体架构 + +```mermaid +flowchart TB + F[DSL与ONNX前端] --> I[类型明确的IR] + I --> O[保持数值语义的优化] + O --> S[目标指令选择] + S --> M[带类别和宽度的机器表示] + M --> C[ABI值传递与调用约束] + C --> R[寄存器分配及溢出处理] + R --> L[栈布局与入口出口处理] + L --> E[汇编表达] + T[目标能力描述] --> S + T --> C + T --> R + T --> L + T --> E +``` + +各模块之间交换结构化信息。指令选择产生机器操作和操作数约束,分配器提供物理寄存器映射及溢出位置,栈布局计算函数内各存储位置的最终偏移,汇编层负责表达已确定的指令与地址。 + +下面用 Python 类型标注表达接口设计,签名中的省略号代表由相应模块实现的行为。`Sequence` 表示有序输入,`Mapping` 表示只读查询;类型、目标描述和跨阶段结果作为不可变数据传递。接口名称围绕模块职责组织,内部算法可以在保持这些合同的前提下调整。 + +| 模块 | 实现职责 | 向后续环节提供的信息 | +|---|---|---| +| 前端与类型系统 | 解析类型,归一化算子,构造合法 IR | 类型明确的值、操作和常量编码 | +| 目标能力描述 | 统一表达 ISA、ABI 和可用精度 | 操作可用性、寄存器类别与调用约束 | +| 指令选择 | 根据操作和类型选择指令或等价序列 | 机器指令、虚拟寄存器、常量引用 | +| 机器表示 | 描述指令的定义、使用及操作数要求 | 寄存器类别、值宽度、控制流和副作用 | +| 函数接口 | 接入参数、返回及调用位置约束 | 物理位置搬运、固定约束和调用覆盖集合 | +| 寄存器分配 | 安排物理寄存器,保存与恢复溢出值 | 寄存器映射、存储槽和寄存器保存需求 | +| 栈布局 | 布局局部数据和保存区,处理入口与出口 | 具体偏移、栈调整和寄存器保存/恢复 | +| 汇编表达 | 输出指令、函数边界及常量数据 | 符合目标语法的汇编 | + +## 类型系统与前端处理 + +### 类型描述 + +类型描述包含数值类别、位宽、存储大小和自然对齐。位宽用于决定运算精度,存储大小和对齐用于安排常量、局部数据和溢出位置。 + +| 类型 | 数值表示 | 位宽 | 存储大小/对齐 | 数据寄存器类别 | +|---|---|---|---|---| +| i32 | 有符号整数 | 32 位 | 4/4 字节 | 通用寄存器 GPR | +| f32 | IEEE 754 binary32 | 32 位 | 4/4 字节 | 浮点寄存器 FPR | +| f64 | IEEE 754 binary64 | 64 位 | 8/8 字节 | 浮点寄存器 FPR | + +地址采用 RV32 通用寄存器表示。访存操作显式关联元素类型,局部存储声明记录元素类型与数量;地址的机器表示和所指数据的精度分别处理。这样,指向 f64 数据的地址使用 GPR,而读写数据使用 FPR。 + +类型系统提供统一查询和常量编码接口: + +```python +@dataclass(frozen=True) +class TypeInfo: + dtype: DataType + bit_width: int + size_bytes: int + alignment_bytes: int + +@dataclass(frozen=True) +class ScalarConstant: + dtype: DataType + bits: int + +def parse_dtype(name: str) -> DataType: ... +def type_info(dtype: DataType) -> TypeInfo: ... +def encode_literal(text: str, dtype: DataType) -> ScalarConstant: ... +def constant_from_bits(dtype: DataType, bits: int) -> ScalarConstant: ... +``` + +`parse_dtype` 接受 i32/int32、f32/float32、f64/float64,未知名称给出类型诊断。`type_info` 只返回本设计支持的三种数值类型的信息。`encode_literal` 将文本直接编码到声明精度,浮点文本采用 RNE,i32 文本要求处于有符号 32 位范围;编码后保存无符号位模式。`constant_from_bits` 接收已编码数据,要求 `0 <= bits < 2**bit_width`,不经过宿主浮点数转换,也不改变 NaN payload 或符号零。 + +### IR 构造规则 + +IR 构造集中使用操作签名来确定操作数和结果类型,前端仅负责将源表达式转换为相应操作。 + +| 操作 | 操作数要求 | 结果规则 | +|---|---|---| +| 加、减、乘、除、min、max | 两个同类型数值 | 继承操作数类型 | +| neg、abs | 一个数值 | 继承输入类型 | +| rem | 两个 i32 | i32 | +| sqrt | 一个 f32 或 f64 | 继承输入精度 | +| load | 地址及元素类型 | 元素类型 | +| store | 地址、元素类型及同类型数据 | 无数值结果 | +| 比较 | 两个同类型数值 | 逻辑结果,供条件分支使用 | + +操作构造先确认类型签名,再创建结果值。不同类型不做隐式转换;改变精度需要明确的转换语义。函数参数和返回值也使用相同的类型描述。 + +浮点常量除类型外,还保留其 IEEE 编码。文本常量按声明精度编码,模型中的浮点常量保留源编码;数值展示和机器数据表达分别使用各自的信息。 + +### IR 构造 API + +数值签名检查与指令构造分开:签名函数用于推导结果,构造器负责创建值、追加指令和维护函数/基本块归属。内存和控制流通过专门入口表达。 + +```python +def infer_numeric_result( + op: OpCode, inputs: Sequence[DataType] +) -> DataType: ... + +@dataclass(frozen=True) +class AddressRef: + base: Value + element_type: DataType + +class IRBuilder: + def new_function( + self, name: str, params: Sequence[Value] = (), + *, return_type: DataType | None = None, + ) -> Function: ... + def new_block(self, name: str) -> BasicBlock: ... + def position_at_end(self, block: BasicBlock) -> None: ... + def constant(self, literal: ScalarConstant) -> Value: ... + def binary(self, op: OpCode, lhs: Value, rhs: Value) -> Value: ... + def unary(self, op: OpCode, operand: Value) -> Value: ... + def alloca(self, count: int, *, dtype: DataType) -> AddressRef: ... + def load(self, address: AddressRef) -> Value: ... + def store(self, address: AddressRef, value: Value) -> Instruction: ... + def br(self, target: BasicBlock) -> Instruction: ... + def br_compare( + self, lhs: Value, predicate: ComparePredicate, rhs: Value, + true_block: BasicBlock, false_block: BasicBlock, + ) -> Instruction: ... + def ret(self, value: Value | None = None) -> Instruction: ... + def finish(self) -> Program: ... + +def validate_ir(program: Program) -> None: ... +``` + +`DataType` 使用 INT32/FLOAT32/FLOAT64 三个枚举成员,`OpCode` 表达归一化操作,`ComparePredicate` 包含 EQ/NE/LT/LE/GT/GE 六种关系。`Value` 记录名称、数值类型和可选的 `ScalarConstant`;`Instruction` 记录操作、输入、结果及来源位置。`Function` 保存参数、返回类型、基本块和局部存储声明,`Program` 保存函数与全局值。 + +`binary` 和 `unary` 先调用 `infer_numeric_result`,检查元数、同类型要求和操作允许的类型,再构造结果;add/div/sqrt 等便捷方法委托这两个入口。类型不匹配时不创建结果,也不追加指令。`constant` 将编码与声明类型绑定,不能只保存用于展示的 Python 数值。 + +`alloca` 要求元素数量为正整数,在当前函数登记局部对象,生成标记为地址的 i32 基址,并以 `AddressRef` 携带元素类型;不新增通用指针类型。`load` 从元素类型推导结果,`store` 要求数据与元素类型相同。IR 中也保留该元素类型,后续环节无需从地址值的 i32 类型反推数据精度。地址角色仅能由地址构造操作产生,普通 i32 数值不能直接用于访存。 + +构造器拥有正在建立的 `Program`,每个值和跳转目标必须属于正确的函数。`new_function` 接收零个或一个尚未绑定函数的参数声明,将其绑定到新函数;返回类型为单个数值类型或无返回值,`ret` 与其声明一致。`new_block` 创建并选中当前函数中的块,`position_at_end` 切换到同一函数中可追加指令的块。基本块终结后不再追加指令,`br` 和 `br_compare` 通过块引用保存目标。`finish` 调用 `validate_ir` 并交出完整程序,之后构造器不再修改该程序;验证还检查定义与使用、控制流及地址元数据。前端来源位置随值和指令保留,供诊断使用。 + +### DSL 与 ONNX 归一化 + +DSL 常量允许显式指定 i32、f32 或 f64,未标注的常量采用 f32。后续运算由操作数类型决定精度。例如: + +```text +x = const(1.5, dtype:f64) +y = const(2.0, dtype:f64) +z = add(x, y) +r = sqrt(z) +return r +``` + +这里的常量、加法、平方根和返回值均为 f64;精度在构造 IR 时确定,后端沿用该类型信息。 + +ONNX 的 INT32、FLOAT、DOUBLE 分别映射到 i32、f32、f64。前端先处理参数、initializer 和声明类型,再将标量 Add/Sub/Mul/Div/Sqrt/Min/Max 等操作转换为统一 IR。多输入 Min/Max 按输入顺序展开为同类型的二元操作;只有一个输入时直接传递该值。 + +标量形状在前端归一化后仍保留必要的元素类型。需要通用张量布局或广播的输入超出标量范围时,在进入目标选择前给出诊断。DSL 的 mod 归一化为 rem;ONNX 的算子按自身定义单独映射。 + +前端接口保持一致的 IR 输出: + +```python +class DSLParser: + def parse(self, text: str, filename: str | None = None) -> Program: ... + +class ONNXParser: + def parse(self, model_path: str) -> Program: ... +``` + +两个前端均通过 IR 构造 API 建立程序,在返回前完成类型和标量范围检查。DSL 文本常量调用 `encode_literal`,ONNX 已编码的浮点数据调用 `constant_from_bits`;ONNX 类型编号通过显式映射转换,未支持的编号给出诊断。多输入 Min/Max 的展开也调用 `binary`,使每一步继承相同的签名规则。 + +## 目标能力与数值语义 + +### 目标描述 + +编译入口建立一个目标描述,包含 ISA 扩展、ABI、浮点寄存器宽度和可用数值类型,并将其传给后端各模块。本设计采用以下目标组合: + +| ISA | ABI | 浮点寄存器宽度 | 可用数值类型 | +|---|---|---|---| +| RV32IM | ILP32 | 无浮点寄存器 | i32 | +| RV32IMF | ILP32F | 32 位 | i32、f32 | +| RV32IMFD | ILP32D | 64 位 | i32、f32、f64 | + +指令规则声明所需扩展。选择 f32 运算前确认 F 可用,选择 f64 运算前确认 D 可用;ABI 同时决定参数和返回值的传递方式。配置与运算不兼容时,在目标代码输出前停止转换。 + +目标描述由统一入口构造,派生属性不由调用方分别填写: + +```python +def make_target(isa: str, abi: str) -> TargetSpec: ... + +class TargetSpec: + isa: str + abi: str + xlen: int + flen: int + abi_flen: int + stack_alignment: int + + def require_operation(self, op: OpCode, dtype: DataType) -> None: ... + def register_class(self, dtype: DataType) -> RegisterClass: ... + def register_policy(self) -> RegisterPolicy: ... +``` + +`make_target` 规范化 ISA/ABI 名称,检查扩展依赖和组合,计算 XLEN、FLEN、ABI_FLEN 及栈对齐;无浮点时两个浮点宽度均为 0。`require_operation` 按操作、类型和所需扩展检查可用性。`RegisterClass` 只有 GPR/FPR,`register_class` 将数值类型映射到其中之一,地址固定使用 GPR。 + +`RegisterPolicy` 包含各类别的可分配集合、保留寄存器、调用者保存集合和被调用者保存集合,集合元素为带名称和类别的 `PhysicalRegister`。参数及返回寄存器按使用位置施加固定约束;栈指针等保留寄存器不参与普通值分配。同一个 `TargetSpec` 贯穿一次编译,不由各阶段再次推断目标。 + +### 整数与浮点语义 + +i32 采用固定位宽整数语义。除法向零截断,非零余数与被除数同号;除零和最小有符号整数除以 −1 的结果遵循 RV32M 定义,不额外插入陷阱。整数结果保持 32 位表示。 + +浮点四则算术与平方根显式采用 RNE,即最近值、相同距离取偶数。每次运算按其声明精度舍入,舍入选择作为机器指令的属性传递到汇编层。 + +min/max、符号操作和比较采用对应 F/D 指令的特殊值语义。min/max 对符号零、NaN 和 signaling NaN 的处理由目标规则定义;常量与访存保留源编码,计算结果及异常标志由实际浮点运算产生。 + +## 指令选择方案 + +### 选择流程 + +每条 IR 操作按以下顺序转换:取得操作与类型,确认目标能力,准备符合要求的操作数,再生成带类型信息的机器指令。规则使用操作本身及其操作数/结果类型,不依赖其他指令留下的类型状态。 + +简单运算直接通过“操作 + 类型”映射选择 opcode;需要展开的操作由对应规则生成等价序列。整数规则与浮点规则共享控制流、地址和函数结构处理,不为两种精度分别建立完整后端。 + +| IR 操作 | i32 | f32 | f64 | +|---|---|---|---| +| add/sub/mul/div | add/sub/mul/div | fadd.s/fsub.s/fmul.s/fdiv.s | fadd.d/fsub.d/fmul.d/fdiv.d | +| rem | rem | 类型不匹配 | 类型不匹配 | +| sqrt | 类型不匹配 | fsqrt.s | fsqrt.d | +| min/max | 有符号比较与值选择序列 | fmin.s/fmax.s | fmin.d/fmax.d | +| neg | 整数取负 | fneg.s | fneg.d | +| abs | 按符号选择原值或取负 | fabs.s | fabs.d | +| load/store | lw/sw | flw/fsw | fld/fsd | + +整数 min/max 用有符号比较确定大小,再通过值选择序列取得结果,比较过程不依赖可能溢出的减法。整数 abs 按符号处理结果,最小有符号整数的结果仍按 32 位表示。 + +整数指令需要寄存器操作数时,常量先转换为 GPR 中的值;浮点数据先进入对应精度的 FPR。地址准备使用统一的寻址处理,再交给加载或存储规则。 + +指令选择按程序入口和函数内入口分工: + +```python +@dataclass(frozen=True) +class SelectedModule: + target: TargetSpec + functions: tuple[MachineFunction, ...] + constants: tuple[ConstantEntry, ...] + +def select_program(program: Program, target: TargetSpec) -> SelectedModule: ... +def select_function( + function: Function, target: TargetSpec, constants: ConstantPool +) -> MachineFunction: ... + +class SelectionContext: + def new_vreg(self, dtype: DataType) -> VirtualRegister: ... + def new_address_reg(self) -> VirtualRegister: ... + def materialize(self, value: Value) -> VirtualRegister: ... + def emit(self, instruction: MachineInstruction) -> None: ... +``` + +`select_program` 接收已验证的 IR,建立本次编译专用的常量池,逐函数调用 `select_function`,最后返回 `SelectedModule`。其中 `functions` 为有序机器函数,`constants` 为冻结后的常量条目。`select_function` 保留函数签名、块间跳转、局部对象和来源位置,不将多函数压成丢失边界的指令列表。 + +每个 `SelectionContext` 属于一个函数,拥有 IR 值到虚拟寄存器/地址引用的映射、虚拟编号和输出块。`new_vreg` 由类型确定类别与宽度,`new_address_reg` 产生 32 位 GPR。`materialize` 将已有值或常量准备为其类型要求的寄存器,并在当前块追加必要指令;跨块复用必须满足定义支配使用,不能仅凭值名全局缓存加载结果。 + +操作规则先调用 `require_operation`,再按 `(op, dtype)` 分派。规则仅使用当前指令、目标和函数上下文;未支持的组合返回明确诊断,不生成默认指令。整数展开序列与浮点直接映射使用同一个 `emit`,每条产生的机器指令保留原 IR 的来源信息。 + +### 比较与条件分支 + +浮点比较先产生 GPR 中的 0 或 1,再由普通条件分支决定控制流。两种精度使用相同的谓词规则,仅选择不同的指令后缀: + +| 谓词 | 浮点比较方式 | 分支条件 | +|---|---|---| +| a == b | feq(a, b) | 结果非零 | +| a != b | feq(a, b) | 结果为零 | +| a < b | flt(a, b) | 结果非零 | +| a > b | flt(b, a) | 结果非零 | +| a <= b | fle(a, b) | 结果非零 | +| a >= b | fle(b, a) | 结果非零 | + +NaN 输入时,相等与有序比较为假,不等为真。feq 保持其 quiet comparison 语义,flt/fle 保持其 signaling comparison 语义;互换操作数构造大于或大于等于时沿用原指令的异常行为。 + +### 浮点常量 + +浮点常量集中放入只读常量区,以“类型与原始编码”作为标识,复用相同的常量。f32 以 4 字节数据表示,f64 以 8 字节数据表示,并分别满足自然对齐。 + +使用常量时,先取得 GPR 地址,再用 flw 或 fld 加载到 FPR。常量引用、标签和数据定义由同一常量管理模块生成,指令选择仅保存引用。 + +```python +class ConstantPool: + def intern(self, literal: ScalarConstant) -> ConstantRef: ... + def freeze(self) -> tuple[ConstantEntry, ...]: ... +``` + +`ConstantRef` 是池内唯一的符号引用;`ConstantEntry` 保存引用、类型、位模式、大小和对齐。`intern` 按 `(dtype, bits)` 去重,返回稳定引用;`freeze` 后不再接受新增条目。地址准备指令引用 `ConstantRef`,汇编层为相同引用生成唯一标签和数据定义。常量池作用域为一个模块,可被该模块的多个函数共享,不跨编译请求保留状态。 + +## 机器表示与寄存器分配 + +### 机器表示需要携带的信息 + +机器操作数区分寄存器、立即数、标签和内存引用。寄存器操作数同时记录类别和数值位宽;内存引用记录地址来源、元素类型或存储位置引用。局部数据和溢出位置在布局确定前使用符号引用。 + +每种机器操作声明结果定义、输入使用、操作数类别及是否涉及内存、调用、分支或浮点异常。分配器利用定义与使用计算活跃关系,汇编层利用操作数形式输出语法。 + +比较指令可以同时具有 FPR 输入和 GPR 输出,浮点访存也同时具有 GPR 地址和 FPR 数据,因此类别信息按操作数位置描述。 + +### 机器数据与指令描述 API + +机器表示保留控制流结构,操作数中的引用由所属函数或模块管理: + +| 数据对象 | 主要字段 | 约束 | +|---|---|---| +| `VirtualRegister` | id、reg_class、width_bits | id 在函数内唯一;f32/f64 分别为 32/64 位 FPR | +| `RegisterRef` | 虚拟或物理寄存器、使用宽度 | 表达单个寄存器操作数,类别与指令位置相符 | +| `StackSlotRef` | 函数内 slot_id | 局部与溢出槽共用唯一编号空间;布局前不保存数字偏移 | +| `StackSlot` | 引用、用途、size_bytes、alignment_bytes | 描述局部或溢出对象;大小与对齐明确 | +| `MemoryRef` | base、offset_bytes、element_type | base 为 `RegisterRef` 或 `StackSlotRef`;元素类型决定访问宽度 | +| `BlockRef` / `FunctionRef` | 所属函数内块 / 函数符号 | 分别用于分支目标和调用目标 | +| `MachineInstruction` | opcode、operands、rounding、origin | 操作数有序;舍入属性只用于支持它的指令 | +| `MachineFunction` | name、signature、blocks、locals | 保留函数签名、控制流及局部存储对象 | + +`MachineOperand` 是寄存器引用、立即数、内存引用、常量引用或控制流符号引用的联合。比较结果用 32 位 GPR 表示 0/1;地址计算结果同样是 32 位 GPR,但访存数据精度由 `MemoryRef.element_type` 决定。局部地址计算和访存均保留 `StackSlotRef`,待布局阶段统一解析。 + +```python +def instruction_info( + instruction: MachineInstruction, target: TargetSpec +) -> InstructionInfo: ... +``` + +`InstructionInfo` 返回寄存器定义、使用、逐位置操作数约束,以及读/写内存、写浮点异常标志、分支和调用等效果;调用还包含实际物理寄存器覆盖集合。每个 `OperandConstraint` 记录类别、宽度和可选的固定物理寄存器。分配器与机器优化共同使用该接口,指令打印字符串和注释不参与语义判断。`RoundingMode` 在本设计中使用 RNE,非舍入操作的 `rounding` 为 `None`。 + +### 分配与溢出 + +GPR 与 FPR 使用独立的可分配集合。f32 和 f64 共用一个 FPR 集合,不按精度划分互不相干的物理寄存器池;数值位宽用于决定搬运和溢出方式。 + +参数、返回、栈指针及其他 ABI 保留寄存器作为固定约束处理。分配器根据活跃关系安排其余物理寄存器,寄存器不足时选择值写入溢出位置,再在使用点恢复。 + +| 溢出值类型 | 槽大小/对齐 | 保存 | 恢复 | +|---|---|---|---| +| i32 | 4/4 字节 | sw | lw | +| f32 | 4/4 字节 | fsw | flw | +| f64 | 8/8 字节 | fsd | fld | + +一个值对应稳定的溢出位置。分配策略可以采用不同的保留或驱逐方法,但共用值类别、槽布局和搬运规则,避免每种策略分别判断存储精度。 + +分配结果向栈布局提供溢出位置、所使用的被调用者保存寄存器,以及最终机器指令。跨块传递依照活跃关系处理,使每个后继块在使用值前能从正确位置取得数据。 + +```python +@dataclass(frozen=True) +class AllocationResult: + function: MachineFunction + register_map: Mapping[VirtualRegister, PhysicalRegister] + spill_map: Mapping[VirtualRegister, StackSlotRef] + spill_slots: tuple[StackSlot, ...] + used_callee_saved: frozenset[PhysicalRegister] + +def compute_liveness(function: MachineFunction) -> LivenessInfo: ... +def spill_recipe(dtype: DataType) -> SpillRecipe: ... +def allocate_registers( + function: MachineFunction, target: TargetSpec, + *, strategy: AllocationStrategy, +) -> AllocationResult: ... +``` + +`LivenessInfo` 提供各块的 live-in/live-out 及指令位置的活跃值,依据 `instruction_info` 和控制流计算。`SpillRecipe` 提供槽大小、对齐、保存 opcode 和恢复 opcode,各分配策略共用该查询。`AllocationStrategy` 包含 NAIVE/GREEDY/LINEAR,只选择分配算法,不改变类型规则、调用覆盖规则或栈槽格式。 + +`AllocationResult` 包含已改写的机器函数、未溢出虚拟值的物理映射、虚拟值到溢出槽的映射、溢出槽列表和使用的被调用者保存寄存器。分配过程保留局部槽引用,在其编号之外生成溢出槽引用,同一个虚拟值重复溢出时复用同一槽。改写结果包含保存/恢复及其临时寄存器分配,不再含待分配虚拟寄存器;局部和溢出地址仍为符号引用。 + +分配过程检查固定物理操作数的干涉,并处理调用覆盖集合。跨调用值可以分配到允许的被调用者保存寄存器,或按类型写入槽后恢复;两种选择遵守相同活跃规则。物理映射用于后续查询,机器函数中的改写结果是汇编生成的依据,后续阶段不再次从映射重做分配。 + +## 栈布局与调用约定 + +### 每函数布局 + +前端建立局部存储描述,分配器建立溢出描述,函数接口处理提供寄存器保存需求。栈布局统一收集这些信息,为每个函数计算独立的地址安排。 + +| 布局区域 | 安排方式 | +|---|---| +| 寄存器保存区 | 根据函数实际需要保持的寄存器生成 | +| 局部数据区 | 按元素数量、大小和对齐排列 | +| 溢出区 | 按各值的类型与对齐排列 | +| 填充区 | 使整个栈帧满足 ABI 对齐要求 | + +各存储对象用同一种对齐分配规则安排: + +```text +对象偏移 = 向上对齐(已使用字节数, 对象对齐) +已使用字节数 = 对象偏移 + 对象大小 +栈帧大小 = 向上对齐(已使用字节数, ABI 栈对齐) +``` + +布局完成后,将机器表示中的栈位置引用替换为相对栈指针的偏移。栈调整和访存偏移满足目标指令的立即数范围;超出直接寻址范围的布局在汇编表达前给出诊断。 + +```python +@dataclass(frozen=True) +class RegisterSave: + register: PhysicalRegister + width_bits: int + slot: StackSlotRef + +@dataclass(frozen=True) +class FrameLayout: + function_name: str + size_bytes: int + slot_offsets: Mapping[StackSlotRef, int] + saved_registers: tuple[RegisterSave, ...] + +def plan_frame(allocation: AllocationResult, target: TargetSpec) -> FrameLayout: ... +def finalize_function( + allocation: AllocationResult, frame: FrameLayout, target: TargetSpec +) -> FinalMachineFunction: ... +``` + +`FrameLayout` 保存所属函数、总大小、`StackSlotRef → 字节偏移` 的映射和寄存器保存条目。`plan_frame` 汇总机器函数的局部槽、分配产生的溢出槽,以及被调用者寄存器和返回地址的保存需求,为保存条目另建不冲突的槽引用;所有槽均按调整后的 sp 为基准。各对象区间不得重叠,槽和整个栈帧分别满足自身与 ABI 的对齐。 + +寄存器保存条目记录物理寄存器、保存宽度和位置。GPR 按 XLEN 保存;硬浮点 ABI 的被调用者保存 FPR 按 ABI_FLEN 保存整个需要保持的寄存器状态,而值溢出仍按该值的 f32/f64 宽度安排。包含调用的函数按返回地址的活跃需求保存 ra。 + +`finalize_function` 用同一布局解析局部/溢出地址,加入入口栈调整、保存,以及每个返回出口的恢复和栈释放。它检查实际栈调整与每个最终访存偏移的可编码范围。结果 `FinalMachineFunction` 不含虚拟寄存器或栈槽引用,仅保留物理操作数和合法目标指令或伪指令;函数引用与常量符号留给汇编表达。 + +### 参数、返回与调用保护 + +入口将参数接入相应类别的虚拟值,返回处理将结果传到 ABI 规定的位置,搬运按数值精度选择: + +| 类型 | 单个参数/返回寄存器 | 同类别搬运形式 | +|---|---|---| +| i32 | a0 | mv | +| f32 | fa0 | fmv.s | +| f64 | fa0 | fmv.d | + +D 目标上的 f32 仍使用 4 字节内存表示,进入较宽浮点寄存器时遵循 NaN-boxing。按单精度执行加载和搬运,由目标指令建立正确的寄存器表示。 + +调用的机器语义声明可能被覆盖的寄存器。对处于调用者保存寄存器且跨调用仍活跃的值,在调用前保存,后续使用时按其类型恢复。函数自身使用的被调用者保存寄存器,在入口和每个返回出口分别保存与恢复。 + +函数入口统一分配栈帧,所有返回出口使用同一布局释放栈帧。栈指针在函数执行及调用边界保持 16 字节对齐。 + +函数接口约束在分配前显式进入机器表示,栈保存序列在布局后生成: + +```python +@dataclass(frozen=True) +class ScalarSignature: + param_type: DataType | None + return_type: DataType | None + +def scalar_abi(signature: ScalarSignature, target: TargetSpec) -> ABIContract: ... +def lower_abi(function: MachineFunction, target: TargetSpec) -> MachineFunction: ... +``` + +`ScalarSignature` 保存可选的参数类型和返回类型。`ABIContract` 返回参数/返回物理位置、按类型搬运形式、调用覆盖集合与栈对齐。零参数或无返回值对应空位置;不能将多个值悄悄压入单个位置。 + +`lower_abi` 保留原控制流,生成入口物理寄存器到虚拟值的搬运、返回值到规定位置的搬运,以及调用前后的参数/结果搬运。调用指令附带被调函数的 `ScalarSignature` 和覆盖集合;间接复杂参数传递不在此接口中扩展。固定位置只在这些使用点约束分配,不要求参数值在整个函数中一直占用 a0/fa0。该函数只处理值传递和调用约束,寄存器保存需求由分配及布局阶段确定。 + +## 优化与汇编衔接 + +整数常量折叠和等价变换使用统一的 32 位计算规则,包含除法与余数的边界行为。浮点算术保持声明精度的逐次运算,不以宿主语言结果直接替换目标运算。 + +优化器将浮点异常视为可观察行为。删除未使用的浮点计算、移动循环内计算或融合乘加时,需要同时保留执行条件、舍入次数和异常行为。只依据实数代数关系成立的变换不应用到本设计的浮点路径。 + +汇编层根据机器表示输出操作数类别、访存格式和舍入属性。四则浮点算术与平方根附加 rne;min/max、比较、符号操作和搬运按其各自的指令形式表达。 + +函数和基本块以不同角色管理,标签引用由对应符号生成。正文与只读常量分节表达,函数边界及大小信息从函数结构产生。后续汇编变换保留指令语义、数据区和符号引用。 + +```python +@dataclass(frozen=True) +class FinalMachineModule: + target: TargetSpec + functions: tuple[FinalMachineFunction, ...] + constants: tuple[ConstantEntry, ...] + +def emit_assembly(module: FinalMachineModule) -> str: ... +``` + +`FinalMachineModule` 包含目标描述、有序的 `FinalMachineFunction` 和冻结的常量条目。`emit_assembly` 检查操作数类别、舍入属性和符号引用是否满足目标形式,再输出函数、基本块、指令与只读数据;不补做指令选择、分配或栈布局。模块内每个引用有唯一符号身份,函数大小仅覆盖其代码范围。返回值为完整汇编文本,文件写入由编译入口负责。 + +## 编译入口与调用顺序 + +扩展路径提供一个接收类型明确 IR 的入口: + +```python +def compile_ir( + program: Program, *, target: TargetSpec, strategy: AllocationStrategy +) -> str: + validate_ir(program) + selected = select_program(program, target) + functions = [] + for function in selected.functions: + abi_function = lower_abi(function, target) + allocation = allocate_registers(abi_function, target, strategy=strategy) + frame = plan_frame(allocation, target) + functions.append(finalize_function(allocation, frame, target)) + module = FinalMachineModule( + target=target, functions=tuple(functions), constants=selected.constants, + ) + return emit_assembly(module) +``` + +IR 优化在该入口前执行,并在优化后的 IR 上重新验证类型与结构。各后端阶段接收上一阶段的结构化结果,返回新的结果,不改写调用方的 IR,也不复用上一函数的分配或栈布局状态。IR 构造器、常量池、函数选择上下文和分配过程在各自作用域内维护可变状态;交出的跨阶段结果不再由生产者修改。 + +例如,用 f64 常量构造除法与平方根,类型经以下接口传到目标代码: + +```python +builder = IRBuilder() +builder.new_function("compute", return_type=DataType.FLOAT64) +builder.new_block("entry") +x = builder.constant(encode_literal("9.0", DataType.FLOAT64)) +y = builder.constant(encode_literal("4.0", DataType.FLOAT64)) +quotient = builder.binary(OpCode.DIV, x, y) +result = builder.unary(OpCode.SQRT, quotient) +builder.ret(result) +assembly = compile_ir( + builder.finish(), + target=make_target("rv32imfd", "ilp32d"), + strategy=AllocationStrategy.LINEAR, +) +``` + +该调用序列中,IR 保留 f64 类型,选择得到双精度运算及常量引用,函数接口约束返回位置为 fa0,分配和布局完成寄存器及存储安排,汇编表达输出已确定的目标形式。 + +### 错误与诊断接口 + +模块在不能满足输入合同的位置抛出 `CompileError` 的具体子类,每个异常携带 `Diagnostic`。诊断记录阶段、消息,以及可获得的源文件/行列、函数、块和操作;机器指令通过 `origin` 延续 IR 来源。 + +| 错误类型 | 触发位置 | 诊断内容 | +|---|---|---| +| `IRValidationError` | 类型解析、常量编码、IR 构造与验证 | 未支持类型、错误元数、混合精度、地址/返回类型或标量范围不合法 | +| `TargetConfigError` | `make_target` | ISA、扩展依赖或 ABI 组合不合法 | +| `UnsupportedLoweringError` | 操作可用性检查及目标选择 | 操作与类型组合未支持,或目标缺少必要扩展 | +| `RegisterAllocationError` | 分配与溢出改写 | 固定寄存器约束或临时寄存器需求无法满足 | +| `FrameLayoutError` | 栈布局和最终地址解析 | 布局对象不合法,栈调整或访存偏移超出直接编码范围 | +| `MachineValidationError` | 机器信息查询及汇编表达 | 操作数类别、舍入属性、符号引用或阶段结果不合法 | + +构造 API 在失败前不追加该条指令,后端阶段失败时不交出部分结果。异常由外层编译入口统一转换为用户诊断,内部模块不吞掉异常、默认切换精度或把失败当作空输出。完整汇编文本生成后再执行输出写入,避免类型或目标错误留下半份文件。 + +## 实现依赖与顺序 + +实现按数据依赖推进,使每一步建立的表示能被下一步直接使用: + +1. 建立数值类型描述和操作签名,完成 DSL/ONNX 到类型明确 IR 的归一化;同时确定整数与浮点数值语义。 +2. 接入统一目标描述,整理整数选择规则,补充余数与整数值选择;整数常量处理沿用相同的 32 位语义。 +3. 扩充机器操作数的类别和宽度信息,加入常量引用;据此完成 f32/f64 算术、访存和比较规则。 +4. 将参数、返回及调用约束接入机器表示,再将寄存器类别、活跃关系和按类型搬运接入分配过程,形成统一的溢出位置描述。 +5. 汇总局部数据、溢出及寄存器保存需求,完成每函数栈布局、入口出口和最终地址解析。 +6. 将目标选择、分配、布局及汇编表达连接为一条编译路径,并将类型与浮点语义约束传递给前后各类优化。 + +## 规范依据 + +- [RISC-V M 扩展](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/m-st-ext.adoc):整数除法、余数及边界语义。 +- [RISC-V F 扩展](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/f-st-ext.adoc):单精度运算、舍入、比较及浮点异常。 +- [RISC-V D 扩展](https://raw.githubusercontent.com/riscv/riscv-isa-manual/main/src/unpriv/d-st-ext.adoc):双精度运算和较窄浮点值的寄存器表示。 +- [RISC-V psABI](https://riscv-non-isa.github.io/riscv-elf-psabi-doc/):参数、返回、寄存器保存责任和栈对齐。 diff --git "a/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251.md" "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251.md" index b0f607d6..f208cad5 100644 --- "a/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251.md" +++ "b/docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251.md" @@ -29,4 +29,10 @@ - **W9**:实现`float64`比较指令(`feq.d`, `flt.d`等)和条件分支。 - **W10**:编写测试用例:双精度浮点求和、点积等。验证模拟器支持。 - **W11**:为`sqrt`、`min`/`max`等添加指令选择(可使用库调用或硬件指令)。 -- **W12**:更新文档,撰写新算子、新类型的使用指南。 \ No newline at end of file +- **W12**:更新文档,撰写新算子、新类型的使用指南。 + +## 相关文档 + +- [技术设计:数值语义、架构与 API](28-扩展指令选择-设计文档.md) +- [使用指南:支持操作、类型与示例](28-README.md) +- [交付与验收:原始要求与测试入口](28-交付与验收.md) diff --git a/memory/memory.md b/memory/memory.md index bec21567..df062868 100644 --- a/memory/memory.md +++ b/memory/memory.md @@ -1,5 +1,13 @@ # ScratchV engineering memory +[2026-10-14] 项目规范要求 pytest 测试类组织;新增测试使用 Test... 类,辅助函数保持模块级。迁移既有测试时保留参数化装饰器、fixture 参数和多行 DSL/汇编字符串;对比 AST 与去掉类名后的收集用例,防止整理时改变逻辑或漏收集。适用于测试组织调整。 + +[2026-10-14] LLVM 浮点常量按声明精度编码后用 bitcast 表示,避免十进制格式丢失有效位及加零丢失负零;显式 bit_pattern 必须保留并验证。LLVM 验证通过仍可能算错,回归需实际执行并核对返回位模式。LLVM 优化同样不能用宿主精度绕过 f32 输入舍入,适用于 typed DSL、ONNX 常量及显式 pass。 + +[2026-10-13] ONNX 浮点 Min/Max 的 NaN 传播不同于 RISC-V fmin/fmax 选择正常数字的规则,IR 必须保留来源要求,不能只按同名算子映射。用 ONNX 官方参考实现核对合法模型,再实际执行目标程序;同时检查 qNaN/sNaN 标志、普通数/无穷大和既有 DSL 行为。NaN 补偿运算只能在 NaN 路径执行,否则额外加法会引入溢出或 invalid;值参照工具也要遵循同一 IR 标记和目标符号零约定。 + +[2026-10-14] 项目最低支持 Python 3.8,代码必须兼容该版本。按用户要求移除 Python 3.8 专项测试,功能回归沿用现有 CI 流程;CI 的 Python 3.12 解释器只是测试环境,不改变项目最低版本。要求 f32 的 ONNX 测试夹具应显式使用 f32 运算数,pybind 枚举比较值而非实例身份。适用于当前课题版本要求与测试约定。 + [2026-09-18] 课题 01 的解析成功和块数检查不能证明控制流执行正确;验收须核对比较契约、分支合流、循环变量更新与零次路径。当前 DSLInterpreter 的分支处理为空操作,不可作为控制流执行 oracle;未更新条件变量的旧 while 示例仅适合解析测试。 [2026-09-13] DSL benchmark 的详细日志按用例使用默认关闭的 details/summary 折叠,汇总表、失败检查和性能提示保留在外层;GitHub Markdown 与本地 HTML 均需支持,HTML 中的诊断源码必须转义。 diff --git a/scratchv/analysis/ir_verifier.py b/scratchv/analysis/ir_verifier.py index d640137f..f13fc333 100644 --- a/scratchv/analysis/ir_verifier.py +++ b/scratchv/analysis/ir_verifier.py @@ -15,7 +15,7 @@ from scratchv.analysis.cfg import build_cfg, compute_dominators from scratchv.analysis.cfg_validation import verify_cfg from scratchv.analysis.ir_diagnostics import IRContextLine, capture_ir_context, ir_fix_hint -from scratchv.ir.types import DataType, OpCode, Program +from scratchv.ir.types import DataType, OpCode, Program, valid_float_bit_pattern class ErrorLevel(enum.Enum): @@ -54,16 +54,21 @@ class OpcodeSpec: family: Optional[str] = None -# T: identical numeric types; F: identical floating point types. +# T: identical numeric types; F: identical floating point types; +# N: identical MIN/MAX/ABS types (i32/f32/f64); +# I32: all operands and the result are i32. OPCODE_SPECS = {} for _ops, _count, _family in ( ((OpCode.ADD, OpCode.SUB, OpCode.MUL, OpCode.DIV, OpCode.MATMUL, OpCode.DOT), 2, "T"), ((OpCode.NEG, OpCode.RELU, OpCode.MAXPOOL, OpCode.RESHAPE, OpCode.TRANSPOSE), 1, "T"), - ((OpCode.EXP, OpCode.GELU, OpCode.SIGMOID, OpCode.SOFTMAX), 1, "F"), + ((OpCode.EXP, OpCode.GELU, OpCode.SIGMOID, OpCode.SOFTMAX, OpCode.SQRT), 1, "F"), ((OpCode.CONV, OpCode.GEMM), 3, "T"), + ((OpCode.MIN, OpCode.MAX), 2, "N"), + ((OpCode.ABS,), 1, "N"), ): for _op in _ops: OPCODE_SPECS[_op] = OpcodeSpec(_count, _count, True, _family) +OPCODE_SPECS[OpCode.REM] = OpcodeSpec(2, 2, True, "I32") OPCODE_SPECS.update({ OpCode.CONCAT: OpcodeSpec(1, None, True, "T"), OpCode.LOAD_CONST: OpcodeSpec(0, 0, True), @@ -82,6 +87,11 @@ class OpcodeSpec: ))} _FLOATS = (DataType.FLOAT32, DataType.FLOAT64) _INTS = (DataType.INT32, DataType.INT64) +_MIN_MAX_ABS_TYPES = frozenset({ + DataType.INT32, + DataType.FLOAT32, + DataType.FLOAT64, +}) def _valid_constant(value, dtype): @@ -149,6 +159,10 @@ def _check_value(self, value, loc): self._emit("type-consistency", "invalid-dtype", f"invalid dtype: {value.dtype!r}", loc, value.name) elif value.is_constant and not _valid_constant(value.const_value, value.dtype): self._emit("type-consistency", "invalid-constant", "constant metadata is incompatible with dtype", loc, value.name) + if value.bit_pattern is not None and ( + not value.is_constant or not valid_float_bit_pattern(value.dtype, value.bit_pattern)): + self._emit("type-consistency", "invalid-bit-pattern", + "constant bit_pattern is incompatible with dtype", loc, value.name) def _define(self, definitions, ambiguous, value, loc, instruction=None): if value.name in definitions: @@ -363,9 +377,18 @@ def error(reason, message): values = inst.operands + ([inst.dest] if inst.dest is not None else []) if spec.family and values and all(isinstance(v.dtype, DataType) for v in values): types = [v.dtype for v in values] - if len(set(types)) != 1 or (spec.family == "F" and types[0] not in _FLOATS): + if (len(set(types)) != 1 + or (spec.family == "F" and types[0] not in _FLOATS) + or (spec.family == "N" and types[0] not in _MIN_MAX_ABS_TYPES) + or (spec.family == "I32" and types[0] != DataType.INT32)): error("signature-types", "incompatible types: " + ", ".join(t.value for t in types)) if inst.opcode == OpCode.LOAD_CONST and inst.dest is not None and isinstance(inst.dest.dtype, DataType): + bits = inst.attrs.get("bit_pattern") + if bits is not None: + if not valid_float_bit_pattern(inst.dest.dtype, bits): + error("load-bit-pattern", "LOAD_CONST bit_pattern is incompatible with result dtype") + elif inst.dest.bit_pattern is not None and bits != inst.dest.bit_pattern: + error("bit-pattern-disagreement", "LOAD_CONST bit_pattern differs from result metadata") value = inst.attrs.get("value") if not _valid_constant(value, inst.dest.dtype): error("load-constant", "LOAD_CONST value is missing or incompatible with result dtype") diff --git a/scratchv/backend/abi_frame.py b/scratchv/backend/abi_frame.py index 2f9decac..89f11923 100644 --- a/scratchv/backend/abi_frame.py +++ b/scratchv/backend/abi_frame.py @@ -3,23 +3,98 @@ from __future__ import annotations import re +from dataclasses import replace from scratchv.backend.machine_types import CALLEE_SAVED +from scratchv.backend.allocation import AllocationResult -def apply_abi_frames(assembly: str, spill_slot_count: int) -> str: +def _function_slots(body, spill_slots, local_slots): + """Compact only this function's referenced slots, retaining source offsets. + + Allocation statistics and temporary offsets cover the whole program. + Their offsets remain lookup keys; each function gets its own runtime frame. + """ + spill_offsets = set() + local_offsets = set() + for line in body: + if "[regalloc:local]" in line: + match = re.search(r"addi\s+\w+\s*,\s*sp\s*,\s*(-\d+)", line) + if match: + local_offsets.add(int(match.group(1))) + elif "[regalloc:" in line: + spill_offsets.update(int(offset) for offset in re.findall(r"(-\d+)\(sp\)", line)) + + def compact(offsets, slots, kind): + result = {} + size = 0 + for offset in sorted(offsets, reverse=True): + if offset not in slots: + raise ValueError(f"{kind} access has no layout entry: {offset}") + slot = slots[offset] + size = (size + slot.size + slot.alignment - 1) // slot.alignment * slot.alignment + result[offset] = replace(slot, offset=-size) + return result, size + + spills, spill_bytes = compact(spill_offsets, spill_slots, "spill") + locals_, local_bytes = compact(local_offsets, local_slots, "local") + return spills, spill_bytes, locals_, local_bytes + + +def apply_abi_frames(assembly: str, spill_slot_count: int | AllocationResult) -> str: """Reserve spill storage and preserve used callee-saved registers. Register allocators use compact negative offsets while rewriting. This final production-code step gives each emitted function a real, aligned frame and rebases tagged allocator spill accesses into that frame. """ + layout = spill_slot_count if isinstance(spill_slot_count, AllocationResult) else None + spill_bytes = layout.spill_bytes if layout is not None else spill_slot_count * 4 + if spill_bytes < 0: + raise ValueError("spill bytes must be nonnegative") + slots_by_offset = {} + local_slots = layout.local_slots if layout is not None else {} + local_bytes = layout.local_bytes if layout is not None else 0 + if local_bytes < 0: + raise ValueError("local bytes must be nonnegative") + if layout is not None: + occupied = set() + for slot in layout.spill_slots.values(): + if (slot.size not in {4, 8} or slot.alignment != slot.size + or slot.offset >= 0 or slot.offset % slot.alignment + or -slot.offset > spill_bytes): + raise ValueError("invalid spill slot size, alignment or offset") + addresses = set(range(slot.offset, slot.offset + slot.size)) + if occupied & addresses: + raise ValueError("overlapping spill slots") + occupied.update(addresses) + slots_by_offset[slot.offset] = slot + occupied.clear() + for slot in local_slots.values(): + if (slot.size <= 0 or slot.alignment not in {4, 8} + or slot.offset >= 0 or slot.offset % slot.alignment + or -slot.offset > local_bytes): + raise ValueError("invalid local stack layout") + addresses = set(range(slot.offset, slot.offset + slot.size)) + if occupied & addresses: + raise ValueError("overlapping local stack slots") + occupied.update(addresses) lines = assembly.splitlines() + # Emitted function metadata, rather than a user's label prefix, determines + # the role of dot-prefixed symbols. Keep the fallback for legacy raw input. + declared_functions = { + match.group(1) + for line in lines + if (match := re.fullmatch( + r"\.type\s+([^\s,]+)\s*,\s*@function", line.partition("#")[0].strip() + )) + } function_starts = [ index for index, line in enumerate(lines) - if re.fullmatch(r"[A-Za-z_$][\w.$]*:", line.strip()) - and not line.strip().startswith(".") + if re.fullmatch(r"[A-Za-z_.$][\w.$]*:", line.strip()) + and (line.strip()[:-1] in declared_functions if declared_functions + else not line.strip().startswith(".")) ] for position in reversed(range(len(function_starts))): start = function_starts[position] @@ -37,7 +112,17 @@ def apply_abi_frames(assembly: str, spill_slot_count: int) -> str: ] if re.search(r"^\s*(?:call\s|jal\s+ra\s*,)", body_text, re.MULTILINE): saved.insert(0, "ra") - payload = 4 * (spill_slot_count + len(saved)) + function_spills = slots_by_offset + local_by_offset = {slot.offset: slot for slot in local_slots.values()} + function_spill_bytes, function_local_bytes = spill_bytes, local_bytes + if layout is not None and len(function_starts) > 1: + (function_spills, function_spill_bytes, local_by_offset, + function_local_bytes) = _function_slots(body, slots_by_offset, local_by_offset) + alignment = max((slot.alignment for slot in [*function_spills.values(), *local_by_offset.values()]), default=4) + spill_end = (function_spill_bytes + alignment - 1) // alignment * alignment + local_end = (function_local_bytes + alignment - 1) // alignment * alignment + spill_base = (4 * len(saved) + alignment - 1) // alignment * alignment + payload = spill_base + local_end + spill_end if payload == 0: continue frame_size = (payload + 15) // 16 * 16 @@ -50,12 +135,29 @@ def apply_abi_frames(assembly: str, spill_slot_count: int) -> str: rewritten: list[str] = [] for line in body: if "[regalloc:" in line: + if "[regalloc:local]" in line: + def rebase_local(match): + offset = int(match.group(2)) + if offset not in local_by_offset: + raise ValueError(f"local access has no layout entry: {offset}") + return f"{match.group(1)}{spill_base + local_end + local_by_offset[offset].offset}" + line = re.sub(r"(addi\s+\w+\s*,\s*sp\s*,\s*)(-\d+)", rebase_local, line) + def rebase(match): + offset = int(match.group(1)) + if layout is not None and offset not in function_spills: + raise ValueError(f"spill access has no layout entry: {offset}") + # Preserve old integer assembly exactly. Mixed frames place + # the aligned spill region after saves, then tail padding. + base = (spill_base + local_end + spill_end if function_local_bytes or alignment == 8 + or any(slot.reg_class.value == "fpr" for slot in function_spills.values()) + else frame_size) + return f"{base + (function_spills[offset].offset if layout is not None else offset)}(sp)" line = re.sub( r"(-\d+)\(sp\)", - lambda match: f"{frame_size + int(match.group(1))}(sp)", + rebase, line, ) - stripped = line.strip() + stripped = line.partition("#")[0].strip() if stripped == "ret" or re.match( r"jalr\s+(?:zero|x0)\s*,\s*(?:ra|x1)(?:\s*,\s*0)?$", stripped, diff --git a/scratchv/backend/allocation.py b/scratchv/backend/allocation.py new file mode 100644 index 00000000..9cecc602 --- /dev/null +++ b/scratchv/backend/allocation.py @@ -0,0 +1,101 @@ +"""Typed register allocation results and byte-addressed spill storage.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from scratchv.backend.machine_semantics import validate_register_classes +from scratchv.backend.machine_types import MachineInstr, MachineOp, RegisterClass + + +@dataclass(frozen=True) +class RegisterType: + reg_class: RegisterClass = RegisterClass.GPR + bit_width: int = 32 + + def __post_init__(self) -> None: + if self.bit_width not in {32, 64} or (self.reg_class is RegisterClass.GPR and self.bit_width != 32): + raise ValueError("unsupported register class/width combination") + + @property + def load_op(self) -> MachineOp: + if self.reg_class is RegisterClass.GPR: + return MachineOp.LW + return MachineOp.FLW if self.bit_width == 32 else MachineOp.FLD + + @property + def store_op(self) -> MachineOp: + if self.reg_class is RegisterClass.GPR: + return MachineOp.SW + return MachineOp.FSW if self.bit_width == 32 else MachineOp.FSD + + +@dataclass(frozen=True) +class SpillSlot: + # Negative temporary offset, rebased by the ABI finalizer. + offset: int + size: int + alignment: int + reg_class: RegisterClass + + +@dataclass +class AllocationResult: + instructions: list[MachineInstr] + register_map: dict[str, str] + spill_slots: dict[str, SpillSlot] + spill_bytes: int + local_slots: dict[str, SpillSlot] = field(default_factory=dict) + local_bytes: int = 0 + + +class SpillLayout: + def __init__(self) -> None: + self.slots: dict[str, SpillSlot] = {} + self.size = 0 + + def reserve(self, name: str, value_type: RegisterType) -> SpillSlot: + existing = self.slots.get(name) + if existing is not None and (existing.size != value_type.bit_width // 8 + or existing.reg_class is not value_type.reg_class): + raise ValueError(f"conflicting spill slot metadata for {name}") + if name not in self.slots: + size = value_type.bit_width // 8 + self.size = (self.size + size + size - 1) // size * size + self.slots[name] = SpillSlot( + -self.size, size, size, value_type.reg_class, + ) + return self.slots[name] + + +def collect_register_types(instructions: list[MachineInstr]) -> dict[str, RegisterType]: + validate_register_classes(instructions) + result: dict[str, RegisterType] = {} + for instr in instructions: + widths = {"fcvt.s.d": (32, 64, None), "fcvt.d.s": (64, 32, None)}.get(instr.op.value) + if widths is None: + width = (64 if instr.op.value.endswith(".d") or instr.op in {MachineOp.FLD, MachineOp.FSD} + else 32) + widths = (width, width, width) + for position, operand in enumerate((instr.dst, instr.src1, instr.src2)): + if (operand is not None and operand.reg_class is RegisterClass.FPR + and operand.bit_width is not None and operand.bit_width != widths[position]): + raise ValueError(f"{instr.op.value} operand {position} has incompatible bit width") + for operand in (instr.dst, instr.src1, instr.src2): + if operand is None or operand.kind != "vreg": + continue + if operand.reg_class is RegisterClass.FPR and operand.bit_width is None: + raise ValueError(f"FPR virtual register {operand.value} is missing bit width") + value_type = RegisterType(operand.reg_class, operand.bit_width or 32) + previous = result.setdefault(str(operand.value), value_type) + if previous != value_type: + raise ValueError(f"virtual register {operand.value} has conflicting width or class") + return result + + +def validate_allocated(instructions: list[MachineInstr]) -> None: + validate_register_classes(instructions) + for instr in instructions: + for operand in (instr.dst, instr.src1, instr.src2): + if operand is not None and operand.kind == "vreg": + raise ValueError(f"unallocated virtual register: {operand.value}") diff --git a/scratchv/backend/asm_beautifier.py b/scratchv/backend/asm_beautifier.py index 1760166c..d403cb1e 100755 --- a/scratchv/backend/asm_beautifier.py +++ b/scratchv/backend/asm_beautifier.py @@ -125,6 +125,28 @@ "fsub.s": "{rd} = {rs1} - {rs2} (f32)", "fmul.s": "{rd} = {rs1} * {rs2} (f32)", "fdiv.s": "{rd} = {rs1} / {rs2} (f32)", + "fadd.d": "{rd} = {rs1} + {rs2} (f64)", + "fsub.d": "{rd} = {rs1} - {rs2} (f64)", + "fmul.d": "{rd} = {rs1} * {rs2} (f64)", + "fdiv.d": "{rd} = {rs1} / {rs2} (f64)", + "fsqrt.s": "{rd} = sqrt({rs1}) (f32)", + "fsqrt.d": "{rd} = sqrt({rs1}) (f64)", + "fmin.s": "{rd} = min({rs1}, {rs2}) (f32)", + "fmin.d": "{rd} = min({rs1}, {rs2}) (f64)", + "fmax.s": "{rd} = max({rs1}, {rs2}) (f32)", + "fmax.d": "{rd} = max({rs1}, {rs2}) (f64)", + "feq.s": "{rd} = ({rs1} == {rs2}) ? 1 : 0 (f32)", + "feq.d": "{rd} = ({rs1} == {rs2}) ? 1 : 0 (f64)", + "flt.s": "{rd} = ({rs1} < {rs2}) ? 1 : 0 (f32)", + "flt.d": "{rd} = ({rs1} < {rs2}) ? 1 : 0 (f64)", + "fle.s": "{rd} = ({rs1} <= {rs2}) ? 1 : 0 (f32)", + "fle.d": "{rd} = ({rs1} <= {rs2}) ? 1 : 0 (f64)", + "fabs.s": "{rd} = abs({rs}) (f32)", + "fabs.d": "{rd} = abs({rs}) (f64)", + "fneg.s": "{rd} = -{rs} (f32)", + "fneg.d": "{rd} = -{rs} (f64)", + "fmv.s": "{rd} = {rs} (f32)", + "fmv.d": "{rd} = {rs} (f64)", } ), "memory": MappingProxyType( diff --git a/scratchv/backend/asm_emit.py b/scratchv/backend/asm_emit.py index ecbfe27f..384afa7e 100644 --- a/scratchv/backend/asm_emit.py +++ b/scratchv/backend/asm_emit.py @@ -9,7 +9,10 @@ import re from scratchv.backend.machine_types import ( - MachineInstr, MachineOp, MachineOperand, + FloatConstant, MachineInstr, MachineOp, MachineOperand, +) +from scratchv.backend.symbols import ( + label_reference, validate_call_symbol, validate_function_symbol, validate_label_symbol, ) @@ -40,6 +43,7 @@ MachineOp.BGE: "bge", MachineOp.BNEZ: "bnez", MachineOp.LI: "li", + MachineOp.LA: "la", MachineOp.MV: "mv", MachineOp.CALL: "call", MachineOp.SQRT_S: "fsqrt.s", @@ -48,21 +52,23 @@ MachineOp.FMAX_D: "fmax.d", MachineOp.FABS_D: "fabs.d", MachineOp.FNEG_D: "fneg.d", + MachineOp.FNEG_S: "fneg.s", MachineOp.FADD_D: "fadd.d", MachineOp.FSUB_D: "fsub.d", MachineOp.FMUL_D: "fmul.d", MachineOp.FDIV_D: "fdiv.d", MachineOp.FLT_D: "flt.d", + MachineOp.FLE_D: "fle.d", MachineOp.FEQ_D: "feq.d", MachineOp.FCVT_S_D: "fcvt.s.d", MachineOp.FCVT_D_S: "fcvt.d.s", - MachineOp.LI_D: "li.d", MachineOp.FADD_S: "fadd.s", MachineOp.FSUB_S: "fsub.s", MachineOp.FMUL_S: "fmul.s", MachineOp.FDIV_S: "fdiv.s", MachineOp.FMAX_S: "fmax.s", MachineOp.FMIN_S: "fmin.s", + MachineOp.FABS_S: "fabs.s", MachineOp.FLE_S: "fle.s", MachineOp.FLT_S: "flt.s", MachineOp.FEQ_S: "feq.s", @@ -70,6 +76,7 @@ MachineOp.FLW: "flw", MachineOp.FSW: "fsw", MachineOp.FMV_S: "fmv.s", + MachineOp.FMV_D: "fmv.d", MachineOp.FMV_S_X: "fmv.s.x", } @@ -77,6 +84,8 @@ def _fmt_op(op: MachineOperand | None) -> str: if op is None: return "" + if op.kind == "symbol": + return label_reference(str(op.value)) # strip % from vreg names since we've resolved them return str(op).lstrip("%") @@ -98,67 +107,113 @@ def _comment_suffix(instr: MachineInstr) -> str: class AsmEmitter: """Emit RISC-V assembly text from machine instructions.""" - def __init__(self, instructions: list[MachineInstr]): + def __init__( + self, + instructions: list[MachineInstr], + constant_pool: tuple[FloatConstant, ...] | list[FloatConstant] = (), + ): self.instructions = instructions + self.constant_pool = tuple(constant_pool) def emit(self) -> str: """Produce a complete assembly source string.""" - lines = [ - ".text", - ".align 2", - ] - - in_function = False + lines: list[str] = [] + section_symbols = {".text", *( + f".{instr.comment}" for instr in self.instructions + if instr.op is MachineOp.SECTION + )} + if self.constant_pool: + section_symbols.add(".rodata") + for label in [*(entry.label for entry in self.constant_pool), *( + instr.target if instr.target is not None else instr.comment + for instr in self.instructions if instr.op is MachineOp.LABEL + )]: + validate_label_symbol(label, section_symbols) + if self.constant_pool: + lines.append(".section .rodata") + for entry in self.constant_pool: + alignment = 2 if entry.bit_width == 32 else 3 + directive = ".word" if entry.bit_width == 32 else ".quad" + digits = entry.bit_width // 4 + lines.extend([ + f".p2align {alignment}", + f"{entry.label}:", + f" {directive} 0x{entry.bit_pattern:0{digits}x}", + ]) + lines.extend([".text", ".align 2"]) + + function_name = None + current_section = ".text" + closed_function = None for instr in self.instructions: if instr.op == MachineOp.LABEL: label = instr.target if instr.target is not None else instr.comment - if not label.startswith("."): # function label - if in_function: - lines.append(f" .size {label}, .-{label}") + is_function = (instr.label_role == "function" if instr.label_role is not None + else not label.startswith(".")) + if is_function: + validate_function_symbol(label) + if function_name: + lines.append(f" .size {function_name}, .-{function_name}") lines.extend([ f" .globl {label}", f" .type {label}, @function", f"{label}:", ]) - in_function = True + function_name = label + closed_function = None else: + if closed_function: + raise ValueError(f"function {closed_function!r} cannot continue across output sections") lines.append(f"{label}:") elif instr.op == MachineOp.SECTION: - lines.append(f" .section .{instr.comment}") + section = f".{instr.comment}" + if section != current_section and function_name: + # Compute size before switching away from the function's + # section. ABI prologue/epilogue insertion stays inside it. + lines.append(f" .size {function_name}, .-{function_name}") + closed_function, function_name = function_name, None + lines.append(f" .section {section}") + current_section = section else: + if closed_function: + raise ValueError(f"function {closed_function!r} cannot continue across output sections") lines.append(f" {self._format_instr(instr)}") - if in_function and self.instructions: - last_label = None - for instr in reversed(self.instructions): - if instr.op == MachineOp.LABEL: - last_label = instr.target if instr.target is not None else instr.comment - break - if last_label and not last_label.startswith("."): - lines.append(f" .size {last_label}, .-{last_label}") + if function_name: + lines.append(f" .size {function_name}, .-{function_name}") lines.append("") return "\n".join(lines) def _format_instr(self, instr: MachineInstr) -> str: + if instr.op is MachineOp.LI_D: + raise ValueError( + "li.d is not a standard RISC-V instruction; use the " + "bit-exact floating constant pool" + ) op_name = _OP_NAMES.get(instr.op) if op_name is None: - return f" # {instr.op.value} {instr.comment}".strip() + raise ValueError(f"unsupported assembly opcode: {instr.op.value}") # Branch/jump/call target: structured target, fallback to comment target = instr.target if instr.target is not None else instr.comment + if instr.op is MachineOp.CALL: + validate_call_symbol(target) + elif instr.op in (MachineOp.J, MachineOp.JAL, MachineOp.BNEZ, + MachineOp.BEQ, MachineOp.BNE, MachineOp.BLT, MachineOp.BGE) and target: + target = label_reference(target) - if instr.op == MachineOp.LW and instr.dst and instr.src1: + if instr.op in (MachineOp.LW, MachineOp.FLW, MachineOp.FLD) and instr.dst and instr.src1: address = _memory_address(instr.src1) return ( - f" lw {_fmt_op(instr.dst)}, {address}" + f" {op_name} {_fmt_op(instr.dst)}, {address}" f"{_comment_suffix(instr)}" ) - if instr.op == MachineOp.SW and instr.dst and instr.src1: + if instr.op in (MachineOp.SW, MachineOp.FSW, MachineOp.FSD) and instr.dst and instr.src1: address = _memory_address(instr.src1) return ( - f" sw {_fmt_op(instr.dst)}, {address}" + f" {op_name} {_fmt_op(instr.dst)}, {address}" f"{_comment_suffix(instr)}" ) @@ -182,6 +237,8 @@ def _format_instr(self, instr: MachineInstr) -> str: for op in (instr.dst, instr.src1, instr.src2): if op is not None: operands.append(_fmt_op(op)) + if instr.rounding_mode: + operands.append(instr.rounding_mode) if operands: parts.append(" " + ", ".join(operands)) diff --git a/scratchv/backend/asm_parser_for_beautifier.py b/scratchv/backend/asm_parser_for_beautifier.py index 7e15040f..6e0a59f3 100755 --- a/scratchv/backend/asm_parser_for_beautifier.py +++ b/scratchv/backend/asm_parser_for_beautifier.py @@ -16,7 +16,7 @@ import re from dataclasses import dataclass, field -from typing import Literal, Optional +from typing import Dict, List, Literal, Optional, Tuple ParseStatus = Literal[ "valid", @@ -32,7 +32,7 @@ class InstructionSpec: min_operands: int max_operands: int - operand_roles: tuple[str, ...] = () + operand_roles: Tuple[str, ...] = () @dataclass class ParsedAsmLine: @@ -41,13 +41,13 @@ class ParsedAsmLine: label: Optional[str] = None opcode: Optional[str] = None operands_str: str = "" - operands: list[str] = field(default_factory=list) + operands: List[str] = field(default_factory=list) comment: Optional[str] = None lineno: int = 0 parse_status: ParseStatus = "valid" @property - def field_lengths(self) -> dict[str, int]: + def field_lengths(self) -> Dict[str, int]: """返回用于列宽统计的字段长度。""" return { @@ -60,14 +60,14 @@ def field_lengths(self) -> dict[str, int]: def _spec_group( opcodes: str, *roles: str, -) -> dict[str, InstructionSpec]: +) -> Dict[str, InstructionSpec]: """批量创建具有相同操作数角色的指令规格。""" spec = InstructionSpec(len(roles), len(roles), roles) return {opcode: spec for opcode in opcodes.split()} -INSTRUCTION_SPECS: dict[str, InstructionSpec] = { +INSTRUCTION_SPECS: Dict[str, InstructionSpec] = { # 三操作数的寄存器运算:rd, rs1, rs2。 **_spec_group( "add sub sll slt sltu xor srl sra or and " @@ -113,6 +113,17 @@ def _spec_group( ), } +# F/D arithmetic may append a static rounding mode. Symbol operations and +# comparisons do not; keeping these specs separate prevents false warnings. +for _suffix in ("s", "d"): + for _opcode in ("fadd", "fsub", "fmul", "fdiv"): + INSTRUCTION_SPECS[f"{_opcode}.{_suffix}"] = InstructionSpec(3, 4, ("rd", "rs1", "rs2", "rm")) + INSTRUCTION_SPECS[f"fsqrt.{_suffix}"] = InstructionSpec(2, 3, ("rd", "rs1", "rm")) + for _opcode in ("fmin", "fmax", "feq", "flt", "fle"): + INSTRUCTION_SPECS[f"{_opcode}.{_suffix}"] = InstructionSpec(3, 3, ("rd", "rs1", "rs2")) + for _opcode in ("fabs", "fneg", "fmv"): + INSTRUCTION_SPECS[f"{_opcode}.{_suffix}"] = InstructionSpec(2, 2, ("rd", "rs")) + # 汇编器指示不属于 CPU 指令,不进行操作数数量校验。 DIRECTIVES = frozenset( """ @@ -171,7 +182,7 @@ def _is_metadata_label(label: Optional[str]) -> bool: def _split_comment( line: str, -) -> tuple[str, Optional[str], bool]: +) -> Tuple[str, Optional[str], bool]: """在字符串之外查找行尾注释。""" in_string = False @@ -220,7 +231,7 @@ def _split_operands( text: str, *, allow_top_level_spaces: bool = False, -) -> Optional[list[str]]: +) -> Optional[List[str]]: """按顶层逗号拆分;格式异常时返回 None。 普通指令的单个操作数不允许包含顶层空格,以便识别缺失逗号的 @@ -230,8 +241,8 @@ def _split_operands( if not text: return [] - operands: list[str] = [] - current: list[str] = [] + operands: List[str] = [] + current: List[str] = [] in_string = False depth = 0 @@ -271,7 +282,7 @@ def _split_operands( def _split_label( code: str, -) -> tuple[Optional[str], str, bool]: +) -> Tuple[Optional[str], str, bool]: """提取可选标签并验证标签格式。""" stripped = code.strip() @@ -291,7 +302,7 @@ def _split_label( def _status_for( label: Optional[str], opcode: Optional[str], - operands: list[str], + operands: List[str], ) -> ParseStatus: """根据 opcode 和操作数数量确定解析状态。 @@ -400,7 +411,7 @@ def parse_asm_line( parse_status=_status_for(label, opcode, operands), ) -def parse_asm(asm_text: str) -> list[ParsedAsmLine]: +def parse_asm(asm_text: str) -> List[ParsedAsmLine]: """按原顺序解析完整汇编文本并记录行号。""" # 统一三种换行符后再逐行解析,raw 不包含换行符本身。 diff --git a/scratchv/backend/cycle_estimator.py b/scratchv/backend/cycle_estimator.py index 1f86d797..cb56f338 100644 --- a/scratchv/backend/cycle_estimator.py +++ b/scratchv/backend/cycle_estimator.py @@ -246,6 +246,8 @@ def _parse_asm_for_pipeline(asm_text: str) -> list[PipelineInst]: code = stripped.split("#")[0].strip() if not code: continue + if code.startswith("."): + continue m = _LINE_RE.match(stripped) if m is None: @@ -254,7 +256,7 @@ def _parse_asm_for_pipeline(asm_text: str) -> list[PipelineInst]: opcode = m.group("opcode") if opcode is None: continue - opcode = opcode.lower().lstrip(".") + opcode = opcode.lower() # Skip assembler directives if opcode.startswith(".") or opcode in ( @@ -347,6 +349,8 @@ def _looks_like_reg(s: str) -> bool: return True if _re.match(r'^f([0-9]|[12][0-9]|3[01])$', s): return True + if _re.fullmatch(r'f(?:t(?:[0-9]|1[01])|a[0-7]|s(?:[0-9]|1[01]))', s): + return True return False diff --git a/scratchv/backend/inst_select_ext.py b/scratchv/backend/inst_select_ext.py index 372ca1ed..1de73bdc 100644 --- a/scratchv/backend/inst_select_ext.py +++ b/scratchv/backend/inst_select_ext.py @@ -1,11 +1,11 @@ """Extended Instruction Selector for RISC-V Backend. Extends the base instruction selector with additional operations: -- sqrt (via library call if no hardware support) +- sqrt (hardware F/D instructions) - min/max (using RISC-V branchless or branch sequences) - abs (absolute value) - float64 (double-precision floating point) support -- div, rem, mod operations +- typed div and rem operations Usage:: @@ -16,139 +16,399 @@ from __future__ import annotations -from typing import Optional +import struct -# moved import above from scratchv.backend.instruction_select import InstructionSelector from scratchv.backend.machine_types import ( - MachineOp, MachineOperand, + FloatConstant, + MachineOp, + MachineOperand, + RegisterClass, ) -from scratchv.ir.types import DataType, Instruction, Program +from scratchv.backend.target_features import TargetFeatures +from scratchv.backend.machine_semantics import validate_register_classes +from scratchv.ir.types import DataType, Instruction, OpCode, Program, valid_float_bit_pattern + + +def validate_extended_types(program: Program) -> None: + """Reject unsupported element types/shapes before optimization or lowering. + + Shared IR can express i64 for other backends. The extended RV32 path + supports only i32/f32/f64, including unused values that DCE might hide. + """ + values = list(program.global_values) + parameter_ids = set() + for func in program.functions: + parameter_ids.update(id(value) for value in func.params) + values.extend([*func.params, *func.returns, *func.locals]) + for block in func.blocks: + for instr in (*block.phi_nodes, *block.instructions): + if instr.opcode is OpCode.LOAD_CONST: + bits = instr.attrs.get("bit_pattern") + if bits is not None: + if instr.dest is None or not valid_float_bit_pattern(instr.dest.dtype, bits): + raise ValueError("invalid LOAD_CONST bit_pattern for result dtype") + if instr.dest.bit_pattern is not None and instr.dest.bit_pattern != bits: + raise ValueError("constant bit_pattern differs from result metadata") + values.extend(instr.operands) + if instr.dest is not None: + values.append(instr.dest) + for value in values: + if value.dtype not in (DataType.INT32, DataType.FLOAT32, DataType.FLOAT64): + dtype = getattr(value.dtype, "value", repr(value.dtype)) + raise ValueError( + f"extended RISC-V backend does not support {dtype} value {value.name!r}; " + "supported element types are i32, f32, f64" + ) + for value in values: + if value.bit_pattern is not None and ( + not value.is_constant or not valid_float_bit_pattern(value.dtype, value.bit_pattern)): + raise ValueError(f"invalid constant bit_pattern for {value.dtype.value} value {value.name!r}") + if any(dimension != 1 for dimension in value.shape): + boundary = ("extended RISC-V ABI supports one scalar parameter only" + if id(value) in parameter_ids + else "extended RISC-V supports scalar shapes only") + raise ValueError(f"{boundary}: {value.name!r} has shape {value.shape}") class ExtendedInstructionSelector(InstructionSelector): """Extended instruction selector with additional RISC-V op support. Extends the base ``InstructionSelector`` to add: - - sqrt (via software library call or hardware F extension) + - sqrt (hardware F/D instructions) - min/max (branchless sequences) - abs (absolute value) - float64 (double-precision) operations - - div, rem, mod (integer) + - typed div and rem (integer) Parameters ---------- program: The ScratchV IR Program to select instructions for. - enable_fp64: - If True, enable float64 (D extension) support. - use_hardware_sqrt: - If True, use ``fsqrt.s``/``fsqrt.d`` (requires F/D extension). - If False, emit a library call to ``sqrtf``/``sqrt``. + features: + ISA/ABI requirements. Direct use defaults to RV32IMFD/ILP32D; + CompilerDriver always passes its explicit target. """ - def __init__(self, program: Program, *, - enable_fp64: bool = True, - use_hardware_sqrt: bool = False): + _TYPED_FP_OPS = frozenset({ + OpCode.ADD, + OpCode.SUB, + OpCode.MUL, + OpCode.DIV, + OpCode.REM, + OpCode.NEG, + OpCode.MIN, + OpCode.MAX, + OpCode.ABS, + OpCode.SQRT, + OpCode.LOAD, + OpCode.STORE, + OpCode.LOAD_CONST, + OpCode.BR_IF, + OpCode.RETURN, + }) + + def __init__( + self, + program: Program, + *, + features: TargetFeatures | None = None, + ): super().__init__(program) - self.enable_fp64 = enable_fp64 - self.use_hardware_sqrt = use_hardware_sqrt - self._current_dtype: Optional[DataType] = None + if features is None: + features = TargetFeatures("rv32imfd", "ilp32d") + self.features = features + self._constant_pool: list[FloatConstant] = [] + self._constant_labels: dict[tuple[DataType, int], str] = {} + self._fp_definitions: set[str] = set() + + @property + def constant_pool(self) -> tuple[FloatConstant, ...]: + return tuple(self._constant_pool) + + def run(self): + validate_extended_types(self.program) + self._validate_fp_definition_order() + self._constant_pool = [] + self._constant_labels = {} + self.local_slots = {} + self.local_bytes = 0 + instructions = super().run() + validate_register_classes(instructions) + return instructions + + def _validate_fp_definition_order(self) -> None: + """Keep CFG dominance checks when accepting definitions later in layout. + + Reuse the IR verifier's CFG normalization. This boundary checks only + FP data definitions; it does not require legacy GPR live-ins or memory + addresses to acquire new frontend declarations. + """ + from scratchv.analysis.ir_verifier import ErrorLevel, verify_ir + + data_names = set() + for func in self.program.functions: + for value in func.params: + if value.dtype in (DataType.FLOAT32, DataType.FLOAT64): + data_names.add(value.name) + for block in func.blocks: + for instr in block.instructions: + if instr.dest is not None and instr.opcode is not OpCode.ALLOCA: + if instr.dest.dtype in (DataType.FLOAT32, DataType.FLOAT64): + data_names.add(instr.dest.name) + operands = (instr.operands[1:] if instr.opcode in (OpCode.LOAD, OpCode.STORE) + else instr.operands) + data_names.update(value.name for value in operands + if value.dtype in (DataType.FLOAT32, DataType.FLOAT64)) + if data_names: + _, issues = verify_ir(self.program) + for issue in issues: + if (issue.level is ErrorLevel.ERROR and issue.value_name in data_names + and issue.rule in {"def-before-use", "ssa-validity"} + and issue.message != "value has no definition"): + raise ValueError(f"invalid floating machine definition: {issue}") # ------------------------------------------------------------------ # Base overrides # ------------------------------------------------------------------ def _select_instruction(self, instr: Instruction) -> None: - """Override to add dtype tracking.""" - if instr.dest is not None: - self._current_dtype = instr.dest.dtype + """Require typed rules and track floating machine definitions.""" + if ( + instr.opcode is not OpCode.ALLOCA + and self._is_float(instr) + and instr.opcode not in self._TYPED_FP_OPS + ): + raise ValueError( + f"{instr.opcode.value} has no typed floating-point " + "selection rule" + ) super()._select_instruction(instr) + if (instr.dest is not None and instr.opcode is not OpCode.ALLOCA + and instr.dest.dtype in (DataType.FLOAT32, DataType.FLOAT64)): + self._fp_definitions.add(instr.dest.name) + + def _select_function(self, func) -> None: + # A definition can dominate a use while appearing later in block layout. + # Include only sources with actual lowering rules, never ALLOCA/phi. + self._fp_definitions = { + value.name for value in [*func.params, *self.program.global_values] + if value.dtype in (DataType.FLOAT32, DataType.FLOAT64) + and (value in func.params or value.is_constant) + } + self._fp_definitions.update( + instr.dest.name for block in func.blocks for instr in block.instructions + if instr.dest is not None and instr.opcode in self._TYPED_FP_OPS + and instr.dest.dtype in (DataType.FLOAT32, DataType.FLOAT64) + ) + float_params = [value for value in func.params if value.dtype in (DataType.FLOAT32, DataType.FLOAT64)] + if float_params and (len(func.params) != 1 or any(dimension != 1 for dimension in func.params[0].shape)): + raise ValueError("extended hard-float ABI currently supports one scalar parameter only") + if func.params and not float_params: + if (len(func.params) != 1 + or any(dimension != 1 for dimension in func.params[0].shape)): + raise ValueError("extended integer ABI currently supports one scalar parameter only") + self._emit_label(func.name, is_function=True) + self._stack_offset = 0 + if float_params: + value = float_params[0] + width = 32 if value.dtype is DataType.FLOAT32 else 64 + self._require_fp_extension(OpCode.RETURN, value.dtype) + self._emit( + MachineOp.FMV_S if width == 32 else MachineOp.FMV_D, + MachineOperand.vreg(value.name, RegisterClass.FPR, width), + MachineOperand.reg("fa0", RegisterClass.FPR, width), + comment="scalar hard-float ABI parameter", + ) + self._fp_definitions.add(value.name) + elif func.params: + self._emit( + MachineOp.MV, MachineOperand.vreg(func.params[0].name), + MachineOperand.reg("a0"), comment="scalar integer ABI parameter", + ) + # ONNX scalar initializers are IR global definitions. Materialize their + # actual names before uses; the parser's separate LOAD_CONST temp does + # not define these globals in the machine register stream. + for value in self.program.global_values: + if value.is_constant and value.dtype in (DataType.FLOAT32, DataType.FLOAT64): + attrs = {"value": value.const_value} + if value.bit_pattern is not None: + attrs["bit_pattern"] = value.bit_pattern + self._select_load_const(Instruction( + OpCode.LOAD_CONST, value, attrs=attrs, + )) + for block in func.blocks: + self._emit_label(self._block_label(block.name)) + for instr in block.instructions: + self._select_instruction(instr) + + def _namespace_function_registers(self, start: int, function_index: int) -> dict[str, str]: + names = super()._namespace_function_registers(start, function_index) + # Rename this function's objects before the next function reuses IR names. + self.local_slots = { + names.get(name, name): slot for name, slot in self.local_slots.items() + } + return names + + def _select_alloca(self, instr: Instruction) -> None: + from scratchv.backend.allocation import SpillSlot + + width = 8 if instr.dest.dtype is DataType.FLOAT64 else 4 + size = int(instr.attrs.get("size", 1)) * width + if size <= 0: + raise ValueError("local stack allocation must be positive") + self.local_bytes = (self.local_bytes + size + width - 1) // width * width + offset = -self.local_bytes + self.local_slots[instr.dest.name] = SpillSlot(offset, size, width, RegisterClass.GPR) + self._emit( + MachineOp.ADDI, MachineOperand.vreg(instr.dest.name), + MachineOperand.reg("sp"), MachineOperand.immediate(offset), + comment="local stack address [regalloc:local]", + ) # ------------------------------------------------------------------ # sqrt # ------------------------------------------------------------------ def _select_sqrt(self, instr: Instruction) -> None: - """Select instruction for sqrt. + """Select the hardware F/D square-root path. - If hardware F extension is available, use ``fsqrt.s``. - Otherwise emit a library call to ``sqrtf`` (float) or ``sqrt`` - (double). + Targets without F/D are rejected; libm fallback is not delivered. """ - src = self._op(instr, 0) - dst = self._dst(instr) - dtype = instr.dest.dtype if instr.dest else DataType.FLOAT32 - - if self.use_hardware_sqrt: - if dtype == DataType.FLOAT64: - self._emit(MachineOp.SQRT_D, dst, src, - comment="fsqrt.d (hardware)") - else: - self._emit(MachineOp.SQRT_S, dst, src, - comment="fsqrt.s (hardware)") - else: - # Library call: argument in a0, result in a0 - if src and src.kind != "imm": - self._emit(MachineOp.MV, MachineOperand.reg("a0"), src, - comment="sqrt arg -> a0") - func = "sqrt" if dtype == DataType.FLOAT64 else "sqrtf" - self._emit(MachineOp.CALL, target=func) - if dst: - self._emit(MachineOp.MV, dst, MachineOperand.reg("a0"), - comment="sqrt result") + dtype = self._float_dtype(instr) + self._require_fp_extension(OpCode.SQRT, dtype) + op = MachineOp.SQRT_D if dtype is DataType.FLOAT64 else MachineOp.SQRT_S + self._emit( + op, + self._fp_dst(instr), + self._fp_op(instr, 0), + comment=op.value, + rounding_mode="rne", + ) # ------------------------------------------------------------------ # min / max # ------------------------------------------------------------------ + def _select_fp_min_max(self, instr: Instruction) -> None: + """Use minimumNumber/maximumNumber, optionally propagating ONNX NaNs. + + Quiet self-comparisons identify NaNs without setting NV for qNaN. + Only the NaN path adds the operands to produce a canonical quiet NaN; + adding finite values or opposite infinities would change fflags. + """ + dtype = self._float_dtype(instr) + self._require_fp_extension(instr.opcode, dtype) + double = dtype is DataType.FLOAT64 + if instr.opcode is OpCode.MIN: + op = MachineOp.FMIN_D if double else MachineOp.FMIN_S + else: + op = MachineOp.FMAX_D if double else MachineOp.FMAX_S + dst, lhs, rhs = self._fp_dst(instr), self._fp_op(instr, 0), self._fp_op(instr, 1) + self._emit(op, dst, lhs, rhs, comment=op.value) + if not instr.attrs.get("propagate_nan", False): + return + compare = MachineOp.FEQ_D if double else MachineOp.FEQ_S + lhs_number = self._fresh_vreg("minmax_lhs_number") + rhs_number = self._fresh_vreg("minmax_rhs_number") + both_numbers = self._fresh_vreg("minmax_both_numbers") + done = self._fresh_label("minmax_done") + self._emit(compare, lhs_number, lhs, lhs, comment="ONNX: lhs is not NaN") + self._emit(compare, rhs_number, rhs, rhs, comment="ONNX: rhs is not NaN") + self._emit(MachineOp.AND, both_numbers, lhs_number, rhs_number) + self._emit(MachineOp.BNEZ, both_numbers, target=done) + self._emit( + MachineOp.FADD_D if double else MachineOp.FADD_S, + dst, lhs, rhs, rounding_mode="rne", comment="ONNX: propagate NaN", + ) + self._emit_label(done) + def _select_min(self, instr: Instruction) -> None: """Select instruction for min(a, b). Integer min (branchless): - slt tmp, a, b # tmp = (a < b) - sub dst, b, a # diff = b - a - and tmp, tmp, dst # mask = tmp & diff - add dst, a, tmp # dst = a + mask + slt cond, a, b # cond = (a < b), either 0 or 1 + sub mask, x0, cond # mask = 0 or 0xffffffff + xor diff, a, b + and selected, diff, mask + xor dst, b, selected # a if a < b, otherwise b """ - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - - if (self.enable_fp64 and instr.dest - and instr.dest.dtype == DataType.FLOAT64): - # Use FMIN.D pseudo (expands to branchless sequence) - self._emit(MachineOp.FMIN_D, dst, a, b, comment="fmin.d") - else: - tmp = MachineOperand.vreg("_min_tmp1") - self._emit(MachineOp.SLT, tmp, a, b, comment="min: slt") - diff = MachineOperand.vreg("_min_tmp2") - self._emit(MachineOp.SUB, diff, b, a, comment="min: sub") - and_tmp = MachineOperand.vreg("_min_tmp3") + dtype = instr.dest.dtype if instr.dest is not None else None + if dtype in (DataType.FLOAT32, DataType.FLOAT64): + self._select_fp_min_max(instr) + elif dtype == DataType.INT32: + a = self._op(instr, 0) + b = self._op(instr, 1) + dst = self._dst(instr) + a = self._materialize_int_operand(a, prefix="min_lhs") + b = self._materialize_int_operand(b, prefix="min_rhs") + cond = self._fresh_vreg("min_cond") + mask = self._fresh_vreg("min_mask") + diff = self._fresh_vreg("min_diff") + selected = self._fresh_vreg("min_selected") + self._emit(MachineOp.SLT, cond, a, b, comment="min: a < b") self._emit( - MachineOp.AND, and_tmp, tmp, diff, comment="min: and" + MachineOp.SUB, + mask, + MachineOperand.reg("x0"), + cond, + comment="min: full-width mask", + ) + self._emit(MachineOp.XOR, diff, a, b, comment="min: differing bits") + self._emit( + MachineOp.AND, + selected, + diff, + mask, + comment="min: select differing bits", ) if dst: self._emit( - MachineOp.ADD, dst, a, and_tmp, comment="min result" + MachineOp.XOR, dst, b, selected, comment="min result" ) + else: + raise ValueError(f"min does not support {dtype.value if dtype else 'missing result'}") def _select_max(self, instr: Instruction) -> None: """Select instruction for max(a, b). - Uses the existing `max` pseudo-instruction from the base selector, - or a branchless sequence if not available. + Integer max uses the same full-width-mask selection scheme as min. """ - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - - if (self.enable_fp64 and instr.dest - and instr.dest.dtype == DataType.FLOAT64): - self._emit(MachineOp.FMAX_D, dst, a, b, comment="fmax.d") + dtype = instr.dest.dtype if instr.dest is not None else None + if dtype in (DataType.FLOAT32, DataType.FLOAT64): + self._select_fp_min_max(instr) + elif dtype == DataType.INT32: + a = self._op(instr, 0) + b = self._op(instr, 1) + dst = self._dst(instr) + a = self._materialize_int_operand(a, prefix="max_lhs") + b = self._materialize_int_operand(b, prefix="max_rhs") + cond = self._fresh_vreg("max_cond") + mask = self._fresh_vreg("max_mask") + diff = self._fresh_vreg("max_diff") + selected = self._fresh_vreg("max_selected") + self._emit(MachineOp.SLT, cond, a, b, comment="max: a < b") + self._emit( + MachineOp.SUB, + mask, + MachineOperand.reg("x0"), + cond, + comment="max: full-width mask", + ) + self._emit(MachineOp.XOR, diff, a, b, comment="max: differing bits") + self._emit( + MachineOp.AND, + selected, + diff, + mask, + comment="max: select differing bits", + ) + if dst: + self._emit( + MachineOp.XOR, dst, a, selected, comment="max result" + ) else: - # Use existing MAX pseudo (base selector has this) - self._emit_max(dst, a, b, comment="max") + raise ValueError(f"max does not support {dtype.value if dtype else 'missing result'}") # ------------------------------------------------------------------ # abs @@ -162,20 +422,29 @@ def _select_abs(self, instr: Instruction) -> None: xor dst, x, tmp # invert bits if negative sub dst, dst, tmp # add 1 if negative """ - src = self._op(instr, 0) - dst = self._dst(instr) - - if (self.enable_fp64 and instr.dest - and instr.dest.dtype == DataType.FLOAT64): - # fabs.d: clear the sign bit - self._emit(MachineOp.FABS_D, dst, src, comment="fabs.d") - else: - tmp1 = MachineOperand.vreg("_abs_tmp1") + dtype = instr.dest.dtype if instr.dest is not None else None + if dtype == DataType.FLOAT64: + self._require_fp_extension(OpCode.ABS, dtype) + self._emit( + MachineOp.FABS_D, self._fp_dst(instr), + self._fp_op(instr, 0), comment="fabs.d", + ) + elif dtype == DataType.FLOAT32: + self._require_fp_extension(OpCode.ABS, dtype) + self._emit( + MachineOp.FABS_S, self._fp_dst(instr), + self._fp_op(instr, 0), comment="fabs.s", + ) + elif dtype == DataType.INT32: + src = self._op(instr, 0) + dst = self._dst(instr) + src = self._materialize_int_operand(src, prefix="abs_src") + tmp1 = self._fresh_vreg("abs_sign") imm31 = MachineOperand.immediate(31) self._emit( MachineOp.SRAI, tmp1, src, imm31, comment="abs: srai 31" ) - tmp2 = MachineOperand.vreg("_abs_tmp2") + tmp2 = self._fresh_vreg("abs_xor") self._emit( MachineOp.XOR, tmp2, src, tmp1, comment="abs: xor" ) @@ -183,181 +452,313 @@ def _select_abs(self, instr: Instruction) -> None: self._emit( MachineOp.SUB, dst, tmp2, tmp1, comment="abs: sub" ) + else: + raise ValueError(f"abs does not support {dtype.value if dtype else 'missing result'}") # ------------------------------------------------------------------ - # div / rem / mod (integer) + # div / rem (integer) # ------------------------------------------------------------------ - def _select_idiv(self, instr: Instruction) -> None: - """Select instruction for integer division.""" - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - self._emit(MachineOp.DIV, dst, a, b, comment="div") - def _select_rem(self, instr: Instruction) -> None: """Select instruction for integer remainder.""" - a = self._op(instr, 0) - b = self._op(instr, 1) + dtype = instr.dest.dtype if instr.dest is not None else None + if dtype != DataType.INT32: + raise ValueError( + f"rem does not support {dtype.value if dtype else 'missing result'}" + ) + a = self._materialize_int_operand( + self._op(instr, 0), prefix="rem_lhs" + ) + b = self._materialize_int_operand( + self._op(instr, 1), prefix="rem_rhs" + ) dst = self._dst(instr) self._emit(MachineOp.REM, dst, a, b, comment="rem") - def _select_mod(self, instr: Instruction) -> None: - """Select instruction for modulo (synonym of rem for non-negative).""" - self._select_rem(instr) - # ------------------------------------------------------------------ - # float64 (D extension) + # Type-aware floating-point selection # ------------------------------------------------------------------ - def _select_load_f64(self, instr: Instruction) -> None: - """Load a 64-bit float from memory.""" - src = self._op(instr, 0) - dst = self._dst(instr) - self._emit(MachineOp.FLD, dst, src, comment="fld (load f64)") - - def _select_store_f64(self, instr: Instruction) -> None: - """Store a 64-bit float to memory.""" - val = self._op(instr, 0) - addr = self._op(instr, 1) - self._emit(MachineOp.FSD, val, addr, comment="fsd (store f64)") - - def _select_fadd_d(self, instr: Instruction) -> None: - """Add two float64 values.""" - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - self._emit(MachineOp.FADD_D, dst, a, b, comment="fadd.d") - - def _select_fsub_d(self, instr: Instruction) -> None: - """Subtract two float64 values.""" - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - self._emit(MachineOp.FSUB_D, dst, a, b, comment="fsub.d") - - def _select_fmul_d(self, instr: Instruction) -> None: - """Multiply two float64 values.""" - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - self._emit(MachineOp.FMUL_D, dst, a, b, comment="fmul.d") - - def _select_fdiv_d(self, instr: Instruction) -> None: - """Divide two float64 values.""" - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - self._emit(MachineOp.FDIV_D, dst, a, b, comment="fdiv.d") - - def _select_fcmp_l_d(self, instr: Instruction) -> None: - """Float64 less-than comparison.""" - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - self._emit(MachineOp.FLT_D, dst, a, b, comment="flt.d") - - def _select_fcmp_eq_d(self, instr: Instruction) -> None: - """Float64 equality comparison.""" - a = self._op(instr, 0) - b = self._op(instr, 1) - dst = self._dst(instr) - self._emit(MachineOp.FEQ_D, dst, a, b, comment="feq.d") - - def _select_fcvt_s_d(self, instr: Instruction) -> None: - """Convert float64 to float32.""" - src = self._op(instr, 0) - dst = self._dst(instr) - self._emit(MachineOp.FCVT_S_D, dst, src, comment="fcvt.s.d") - - def _select_fcvt_d_s(self, instr: Instruction) -> None: - """Convert float32 to float64.""" - src = self._op(instr, 0) - dst = self._dst(instr) - self._emit(MachineOp.FCVT_D_S, dst, src, comment="fcvt.d.s") - - def _select_load_const_f64(self, instr: Instruction) -> None: - """Load a float64 constant.""" - raw_val = instr.attrs.get("value", 0.0) - assert isinstance(raw_val, (int, float)) - dst = self._dst(instr) - # Emit as a load from a constant pool (simplified: use li + fcvt) - # For now, mark as a float64 constant load - self._emit(MachineOp.LI_D, dst, - MachineOperand.immediate(int(raw_val)), - comment=f"const f64 {raw_val}") - - # ------------------------------------------------------------------ - # Type-aware overrides for existing ops - # ------------------------------------------------------------------ + _FP_BINARY_OPS = { + OpCode.ADD: (MachineOp.FADD_S, MachineOp.FADD_D), + OpCode.SUB: (MachineOp.FSUB_S, MachineOp.FSUB_D), + OpCode.MUL: (MachineOp.FMUL_S, MachineOp.FMUL_D), + OpCode.DIV: (MachineOp.FDIV_S, MachineOp.FDIV_D), + } + + def _select_fp_binary(self, instr: Instruction) -> None: + dtype = self._float_dtype(instr) + self._require_fp_extension(instr.opcode, dtype) + single, double = self._FP_BINARY_OPS[instr.opcode] + op = double if dtype is DataType.FLOAT64 else single + self._emit( + op, + self._fp_dst(instr), + self._fp_op(instr, 0), + self._fp_op(instr, 1), + comment=op.value, + rounding_mode="rne", + ) def _select_add(self, instr: Instruction) -> None: - if self._is_fp64(instr): - self._select_fadd_d(instr) + if self._is_float(instr): + self._select_fp_binary(instr) else: super()._select_add(instr) def _select_sub(self, instr: Instruction) -> None: - if self._is_fp64(instr): - self._select_fsub_d(instr) + if self._is_float(instr): + self._select_fp_binary(instr) else: super()._select_sub(instr) def _select_mul(self, instr: Instruction) -> None: - if self._is_fp64(instr): - self._select_fmul_d(instr) + if self._is_float(instr): + self._select_fp_binary(instr) else: super()._select_mul(instr) def _select_div(self, instr: Instruction) -> None: - if self._is_fp64(instr): - self._select_fdiv_d(instr) - elif instr.dest and instr.dest.dtype == DataType.INT32: - self._select_idiv(instr) + if self._is_float(instr): + self._select_fp_binary(instr) else: super()._select_div(instr) def _select_load(self, instr: Instruction) -> None: - if self._is_fp64(instr): - self._select_load_f64(instr) - else: + if ( + instr.dest is None + or instr.dest.dtype not in (DataType.FLOAT32, DataType.FLOAT64) + ): super()._select_load(instr) + return + dtype = instr.dest.dtype + self._require_fp_extension(OpCode.LOAD, dtype) + op = MachineOp.FLD if dtype is DataType.FLOAT64 else MachineOp.FLW + self._emit( + op, + self._fp_dst(instr), + self._materialize_int_operand(self._op(instr, 0), prefix="fp_load_addr"), + comment=f"{op.value} (load {dtype.value})", + ) def _select_store(self, instr: Instruction) -> None: - if self._is_fp64(instr): - self._select_store_f64(instr) - else: + value = instr.operands[1] + if value.dtype not in (DataType.FLOAT32, DataType.FLOAT64): super()._select_store(instr) + return + dtype = value.dtype + self._require_fp_extension(OpCode.STORE, dtype) + op = MachineOp.FSD if dtype is DataType.FLOAT64 else MachineOp.FSW + # IR order is [address, value]; machine order is [value, address]. + self._emit( + op, + self._fp_op(instr, 1), + self._materialize_int_operand(self._op(instr, 0), prefix="fp_store_addr"), + comment=f"{op.value} (store {dtype.value})", + ) def _select_load_const(self, instr: Instruction) -> None: - if self._is_fp64(instr): - self._select_load_const_f64(instr) - else: + if ( + instr.dest is None + or instr.dest.dtype not in (DataType.FLOAT32, DataType.FLOAT64) + ): super()._select_load_const(instr) + return + dtype = instr.dest.dtype + self._require_fp_extension(OpCode.LOAD_CONST, dtype) + label = self._intern_float_constant(instr, dtype) + address = self._fresh_vreg("fp_const_addr") + self._emit( + MachineOp.LA, + address, + MachineOperand.symbol(label), + comment=f"address of {label}", + ) + load_op = MachineOp.FLD if dtype is DataType.FLOAT64 else MachineOp.FLW + self._emit( + load_op, + self._fp_dst(instr), + address, + comment=f"bit-exact {dtype.value} constant", + ) + self._fp_definitions.add(instr.dest.name) def _select_neg(self, instr: Instruction) -> None: - """Negate: for float64 use fneg.d, for int use sub x0 - x.""" - if self._is_fp64(instr): - src = self._op(instr, 0) - dst = self._dst(instr) - self._emit(MachineOp.FNEG_D, dst, src, comment="fneg.d") - else: + if not self._is_float(instr): super()._select_neg(instr) + return + dtype = self._float_dtype(instr) + self._require_fp_extension(OpCode.NEG, dtype) + op = MachineOp.FNEG_D if dtype is DataType.FLOAT64 else MachineOp.FNEG_S + self._emit( + op, self._fp_dst(instr), self._fp_op(instr, 0), + comment=op.value, + ) + + def _select_br_if(self, instr: Instruction) -> None: + if not ( + len(instr.operands) == 2 + and instr.operands[0].dtype in (DataType.FLOAT32, DataType.FLOAT64) + ): + super()._select_br_if(instr) + return + dtype = instr.operands[0].dtype + self._require_fp_extension(OpCode.BR_IF, dtype) + targets = (instr.target or ",").split(",") + true_target = self._block_label(targets[0].strip()) + false_target = self._block_label(targets[1].strip()) + lhs = self._fp_op(instr, 0) + rhs = self._fp_op(instr, 1) + predicate = instr.attrs.get("cmp_op") + suffix_ops = ( + (MachineOp.FEQ_S, MachineOp.FLT_S, MachineOp.FLE_S) + if dtype is DataType.FLOAT32 + else (MachineOp.FEQ_D, MachineOp.FLT_D, MachineOp.FLE_D) + ) + feq, flt, fle = suffix_ops + comparisons = { + "==": (feq, lhs, rhs, False), + "!=": (feq, lhs, rhs, True), + "<": (flt, lhs, rhs, False), + ">": (flt, rhs, lhs, False), + "<=": (fle, lhs, rhs, False), + ">=": (fle, rhs, lhs, False), + } + if predicate not in comparisons: + raise ValueError(f"unsupported comparison operator: {predicate}") + compare_op, first, second, invert = comparisons[predicate] + condition = self._fresh_vreg("fp_cmp") + self._emit(compare_op, condition, first, second, comment=str(predicate)) + if invert: + self._emit( + MachineOp.BEQ, + condition, + MachineOperand.reg("x0"), + target=true_target, + ) + else: + self._emit(MachineOp.BNEZ, condition, target=true_target) + self._emit(MachineOp.J, target=false_target) + + def _select_return(self, instr: Instruction) -> None: + if instr.operands and instr.operands[0].dtype in ( + DataType.FLOAT32, DataType.FLOAT64, + ): + self._require_fp_extension( + OpCode.RETURN, instr.operands[0].dtype, + ) + width = 32 if instr.operands[0].dtype is DataType.FLOAT32 else 64 + self._emit( + MachineOp.FMV_S if width == 32 else MachineOp.FMV_D, + MachineOperand.reg("fa0", RegisterClass.FPR, width), + self._fp_op(instr, 0), + comment="floating ABI return", + ) + self._emit( + MachineOp.JALR, + MachineOperand.reg("zero"), + MachineOperand.reg("ra"), + comment="return", + ) + return + super()._select_return(instr) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ - def _is_fp64(self, instr: Instruction) -> bool: - """Check if an instruction operates on float64 data.""" - if not self.enable_fp64: - return False - if instr.dest is not None and instr.dest.dtype == DataType.FLOAT64: - return True - for op in instr.operands: - if op.dtype == DataType.FLOAT64: - return True - return False + @staticmethod + def _is_float(instr: Instruction) -> bool: + values = instr.operands + ( + [instr.dest] if instr.dest is not None else [] + ) + return any( + value.dtype in (DataType.FLOAT32, DataType.FLOAT64) + for value in values + ) + + @staticmethod + def _float_dtype(instr: Instruction) -> DataType: + values = ( + ([instr.dest] if instr.dest is not None else []) + + instr.operands + ) + dtypes = {value.dtype for value in values} + if ( + len(dtypes) != 1 + or next(iter(dtypes), None) + not in (DataType.FLOAT32, DataType.FLOAT64) + ): + rendered = ", ".join(sorted(dtype.value for dtype in dtypes)) + raise ValueError( + "floating instruction requires one consistent dtype" + + (f": {rendered}" if rendered else "") + ) + return next(iter(dtypes)) + + def _require_fp_extension( + self, opcode: OpCode, dtype: DataType, + ) -> None: + self.features.require_float(opcode, dtype) + + def _fp_op(self, instr: Instruction, index: int) -> MachineOperand: + value = instr.operands[index] + if value.name not in self._fp_definitions: + raise ValueError( + f"{value.dtype.value} value {value.name!r} has no machine definition; " + "use LOAD_CONST, a scalar constant global, an ABI parameter " + "or a previously defined floating-point result" + ) + return MachineOperand.vreg( + value.name, RegisterClass.FPR, + 32 if value.dtype is DataType.FLOAT32 else 64, + ) + + @staticmethod + def _fp_dst(instr: Instruction) -> MachineOperand | None: + if instr.dest is None: + return None + return MachineOperand.vreg( + instr.dest.name, RegisterClass.FPR, + 32 if instr.dest.dtype is DataType.FLOAT32 else 64, + ) + + def _intern_float_constant( + self, instr: Instruction, dtype: DataType, + ) -> str: + raw_bits = instr.attrs.get("bit_pattern") + value_bits = instr.dest.bit_pattern if instr.dest is not None else None + if raw_bits is not None and value_bits is not None and raw_bits != value_bits: + raise ValueError("constant bit_pattern differs from result metadata") + if raw_bits is None: + raw_bits = value_bits + width = 32 if dtype is DataType.FLOAT32 else 64 + if raw_bits is not None: + if (isinstance(raw_bits, bool) or not isinstance(raw_bits, int) + or not 0 <= raw_bits < (1 << width)): + raise ValueError( + f"invalid {dtype.value} constant bit_pattern" + ) + bits = raw_bits + else: + raw_value = instr.attrs.get("value") + if not isinstance(raw_value, (int, float)): + raise ValueError(f"missing {dtype.value} constant value") + if dtype is DataType.FLOAT32: + bits = struct.unpack(">I", struct.pack(">f", float(raw_value)))[0] + else: + bits = struct.unpack(">Q", struct.pack(">d", float(raw_value)))[0] + key = (dtype, bits) + label = self._constant_labels.get(key) + if label is not None: + return label + index = sum( + entry.bit_width == width for entry in self._constant_pool + ) + label = self._claim_label(f".LC_{dtype.value}_{index}") + self._constant_labels[key] = label + self._constant_pool.append(FloatConstant(label, width, bits)) + return label @property def supported_ops(self) -> list[str]: @@ -371,17 +772,9 @@ def supported_ops(self) -> list[str]: ] extended_ops = [ "sqrt", "min", "max", "abs", - "idiv", "rem", "mod", - ] - fp64_ops = [ - "load_f64", "store_f64", "fadd_d", "fsub_d", "fmul_d", - "fdiv_d", "fcmp_l_d", "fcmp_eq_d", "fcvt_s_d", "fcvt_d_s", - "load_const_f64", + "rem", ] - return ( - base_ops + extended_ops - + (fp64_ops if self.enable_fp64 else []) - ) + return base_ops + extended_ops # New MachineOp entries for the extended selector are now defined directly diff --git a/scratchv/backend/instruction_select.py b/scratchv/backend/instruction_select.py index 4b15f8df..11c7870c 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -7,9 +7,11 @@ from __future__ import annotations +from dataclasses import replace from typing import Optional from scratchv.ir.types import Instruction, Function, Program +from scratchv.backend.symbols import validate_function_symbol from scratchv.backend.machine_types import ( MachineInstr, MachineOp, @@ -25,9 +27,11 @@ def __init__(self, program: Program): self._instructions: list[MachineInstr] = [] self._label_counter = 0 self._stack_offset = 0 - self._max_temp_counter = 0 + self._temp_counter = 0 self._loop_stack: list[dict] = [] self._reserved_vreg_names = self._collect_ir_value_names() + self._block_labels: dict[str, str] = {} + self._reserved_labels: set[str] = set() def _collect_ir_value_names(self) -> set[str]: """Reserve every user-visible IR name before creating temporaries.""" @@ -49,21 +53,112 @@ def run(self) -> list[MachineInstr]: Returns flat list of MachineInstrs. """ self._instructions = [] + self._label_counter = 0 + self._temp_counter = 0 + self._loop_stack = [] + self._reserved_vreg_names = self._collect_ir_value_names() for func in self.program.functions: + validate_function_symbol(func.name) + self._reserved_labels = {func.name for func in self.program.functions} + self._reserved_labels.update( + instr.target for func in self.program.functions + for block in func.blocks for instr in block.instructions + if instr.opcode.value == "label" and instr.target + ) + multiple_functions = len(self.program.functions) > 1 + block_labels = [] + for function_index, func in enumerate(self.program.functions): + # Numeric namespaces avoid concatenation collisions between user + # function/block names. Reserve all blocks before any pool or + # internal labels are generated; retain nonconflicting legacy names. + block_labels.append({ + block.name: self._claim_label( + f".Lsv_f{function_index}_b{block_index}" + if multiple_functions else f".{block.name}" + ) + for block_index, block in enumerate(func.blocks) + }) + for function_index, (func, labels) in enumerate(zip(self.program.functions, block_labels)): + self._block_labels = labels + start = len(self._instructions) self._select_function(func) + if multiple_functions: + self._namespace_function_registers(start, function_index) return self._instructions + def _namespace_function_registers(self, start: int, function_index: int) -> dict[str, str]: + """Give function-local vregs unique identities in the flat machine stream. + + Rewrite only machine operands, preserving the caller's IR names and + operand class/width. Fresh names avoid every source and temporary name. + Subclasses use the returned mapping to rename associated local objects. + """ + names: dict[str, str] = {} + + def scoped(operand: MachineOperand | None) -> MachineOperand | None: + if operand is None or operand.kind != "vreg": + return operand + name = str(operand.value) + if name not in names: + names[name] = str(self._fresh_vreg(f"f{function_index}").value) + return replace(operand, value=names[name]) + + for index in range(start, len(self._instructions)): + instruction = self._instructions[index] + self._instructions[index] = replace( + instruction, dst=scoped(instruction.dst), + src1=scoped(instruction.src1), src2=scoped(instruction.src2), + ) + return names + + def _claim_label(self, preferred: str) -> str: + """Allocate one symbol in the shared block/pool/internal namespace.""" + label = preferred + suffix = 0 + while label in self._reserved_labels: + suffix += 1 + label = f"{preferred}_{suffix}" + self._reserved_labels.add(label) + return label + def _fresh_label(self, prefix: str = "L") -> str: self._label_counter += 1 - return f".L{prefix}_{self._label_counter}" + return self._claim_label(f".L{prefix}_{self._label_counter}") + + def _fresh_vreg(self, prefix: str = "tmp") -> MachineOperand: + """Return a compiler-owned virtual register with a unique name. + + IR names are collected up front, so even deliberately adversarial + source names cannot alias a selector temporary. Generated names are + reserved as well, making the helper safe for every selector method. + """ + while True: + self._temp_counter += 1 + name = f"__scratchv_{prefix}_{self._temp_counter}" + if name not in self._reserved_vreg_names: + self._reserved_vreg_names.add(name) + return MachineOperand.vreg(name) + + def _materialize_int_operand( + self, + operand: MachineOperand, + *, + prefix: str, + ) -> MachineOperand: + """Materialize an integer immediate for a register-only instruction.""" + if operand.kind != "imm": + return operand + temp = self._fresh_vreg(prefix) + self._emit(MachineOp.LI, temp, operand, comment="materialize integer operand") + return temp def _select_function(self, func: Function) -> None: # Function prologue label - self._emit_label(func.name) + self._emit_label(func.name, is_function=True) self._stack_offset = 0 for block in func.blocks: - self._emit_label(f".{block.name}") + self._emit_label(self._block_label(block.name)) for instr in block.instructions: self._select_instruction(instr) @@ -74,9 +169,13 @@ def _select_instruction(self, instr: Instruction) -> None: handler(instr) def _emit(self, op: MachineOp, dst=None, src1=None, src2=None, - comment: str = "", target: Optional[str] = None) -> None: + comment: str = "", target: Optional[str] = None, + rounding_mode: Optional[str] = None) -> None: self._instructions.append( - MachineInstr(op, dst, src1, src2, comment, target)) + MachineInstr( + op, dst, src1, src2, comment, target, rounding_mode, + ) + ) def _emit_move(self, dst: MachineOperand, src: MachineOperand, comment: str = "") -> None: @@ -113,15 +212,7 @@ def _emit_max(self, dst: MachineOperand | None, lhs, rhs = rhs, lhs if rhs.kind == "imm" and int(rhs.value) != 0: - while True: - self._max_temp_counter += 1 - temp_name = ( - f"__scratchv_max_rhs_{self._max_temp_counter}" - ) - if temp_name not in self._reserved_vreg_names: - break - self._reserved_vreg_names.add(temp_name) - rhs_reg = MachineOperand.vreg(temp_name) + rhs_reg = self._fresh_vreg("max_rhs") self._emit( MachineOp.LI, rhs_reg, @@ -132,8 +223,11 @@ def _emit_max(self, dst: MachineOperand | None, self._emit(MachineOp.MAX, dst, lhs, rhs, comment=comment) - def _emit_label(self, name: str) -> None: - self._instructions.append(MachineInstr(MachineOp.LABEL, target=name)) + def _emit_label(self, name: str, *, is_function: bool = False) -> None: + self._instructions.append(MachineInstr( + MachineOp.LABEL, target=name, + label_role="function" if is_function else "block", + )) def _op(self, instr: Instruction, idx: int): """Get an operand from an IR instruction as a machine operand.""" @@ -168,32 +262,45 @@ def _select_load_const(self, instr: Instruction) -> None: ) def _select_add(self, instr: Instruction) -> None: - self._emit( - MachineOp.ADD, self._dst(instr), self._op(instr, 0), self._op(instr, 1) - ) + self._select_int_binary(instr, MachineOp.ADD) def _select_sub(self, instr: Instruction) -> None: - self._emit( - MachineOp.SUB, self._dst(instr), self._op(instr, 0), self._op(instr, 1) - ) + self._select_int_binary(instr, MachineOp.SUB) def _select_mul(self, instr: Instruction) -> None: + self._select_int_binary(instr, MachineOp.MUL) + + def _select_int_binary(self, instr: Instruction, op: MachineOp) -> None: + """Register-only integer operations cannot consume inline constants.""" + lhs = self._materialize_int_operand( + self._op(instr, 0), prefix=f"{op.value}_lhs" + ) + rhs = self._materialize_int_operand( + self._op(instr, 1), prefix=f"{op.value}_rhs" + ) self._emit( - MachineOp.MUL, self._dst(instr), self._op(instr, 0), self._op(instr, 1) + op, self._dst(instr), lhs, rhs ) def _select_div(self, instr: Instruction) -> None: + lhs = self._materialize_int_operand( + self._op(instr, 0), prefix="div_lhs" + ) + rhs = self._materialize_int_operand( + self._op(instr, 1), prefix="div_rhs" + ) self._emit( - MachineOp.DIV, self._dst(instr), self._op(instr, 0), self._op(instr, 1) + MachineOp.DIV, self._dst(instr), lhs, rhs ) def _select_neg(self, instr: Instruction) -> None: # RISC-V: sub rd, x0, rs + src = self._materialize_int_operand(self._op(instr, 0), prefix="neg_src") self._emit( MachineOp.SUB, self._dst(instr), - MachineOperand.immediate(0), - self._op(instr, 0), + MachineOperand.reg("x0"), + src, ) def _select_exp(self, instr: Instruction) -> None: @@ -225,12 +332,15 @@ def _select_gelu(self, instr: Instruction) -> None: dst = self._dst(instr) if dst is None: return - tmp = MachineOperand.vreg("tmp_gelu") + tmp = self._fresh_vreg("gelu_relu") self._emit_max( tmp, src, MachineOperand.immediate(0), comment="relu(x)" ) self._emit(MachineOp.MUL, dst, src, tmp, comment="x * relu(x)") - self._emit(MachineOp.DIV, dst, dst, MachineOperand.immediate(2), comment="/ 2") + divisor = self._materialize_int_operand( + MachineOperand.immediate(2), prefix="gelu_divisor" + ) + self._emit(MachineOp.DIV, dst, dst, divisor, comment="/ 2") def _select_softmax(self, instr: Instruction) -> None: # softmax ≈ identity (pure RV32I passthrough) @@ -247,11 +357,14 @@ def _select_reshape(self, instr: Instruction) -> None: self._emit_move(dst, src, comment="reshape") def _select_load(self, instr: Instruction) -> None: - self._emit(MachineOp.LW, self._dst(instr), self._op(instr, 0)) + address = self._materialize_int_operand(self._op(instr, 0), prefix="load_addr") + self._emit(MachineOp.LW, self._dst(instr), address) def _select_store(self, instr: Instruction) -> None: # Machine SW follows the standard order: value, address. - self._emit(MachineOp.SW, self._op(instr, 1), self._op(instr, 0)) + value = self._materialize_int_operand(self._op(instr, 1), prefix="store_value") + address = self._materialize_int_operand(self._op(instr, 0), prefix="store_addr") + self._emit(MachineOp.SW, value, address) def _select_alloca(self, instr: Instruction) -> None: raw_size = instr.attrs.get("size", 4) @@ -277,6 +390,10 @@ def _select_for(self, instr: Instruction) -> None: raw_end = instr.attrs.get("end", 0) assert isinstance(raw_end, int) end = raw_end + step = instr.attrs.get("step", 1) + if (isinstance(step, bool) or not isinstance(step, int) + or not 0 < step <= 0x7fff_ffff): + raise ValueError("FOR step must be a positive i32 integer") # Emit loop header label (will be patched) header_label = self._fresh_label("loop_header") @@ -294,15 +411,20 @@ def _select_for(self, instr: Instruction) -> None: self._loop_stack.append({ "iv": iv, "end": end, + "step": step, "header": header_label, "body": body_label, "exit": exit_label, }) + # Materialize the bound before the header so the comparison consumes + # two GPRs and the bound is available on every loop iteration. + end_val = self._materialize_int_operand( + MachineOperand.immediate(int(end)), prefix="loop_end" + ) self._emit_label(header_label) # Check condition: if iv >= end, exit - end_val = MachineOperand.immediate(int(end)) # type: ignore[arg-type] self._emit(MachineOp.BGE, iv, end_val, target=exit_label) self._emit_label(body_label) @@ -313,10 +435,22 @@ def _select_endfor(self, instr: Instruction) -> None: ctx = self._loop_stack.pop() iv = ctx["iv"] - # Increment: addi iv, iv, 1 - self._emit( - MachineOp.ADDI, iv, iv, MachineOperand.immediate(1), comment="loop inc" - ) + step = ctx["step"] + # With a positive step greater than one the last increment can overflow + # i32 before reaching the header. Such an overshoot ends the finite loop. + previous = None + if step > 1: + previous = self._fresh_vreg("loop_previous") + self._emit_move(previous, iv) + if step <= 2047: + self._emit(MachineOp.ADDI, iv, iv, MachineOperand.immediate(step), comment="loop inc") + else: + increment = self._materialize_int_operand( + MachineOperand.immediate(step), prefix="loop_step" + ) + self._emit(MachineOp.ADD, iv, iv, increment, comment="loop inc") + if previous is not None: + self._emit(MachineOp.BLT, iv, previous, target=ctx["exit"]) # Jump back to header self._emit(MachineOp.J, target=ctx["header"]) # Exit label @@ -338,10 +472,10 @@ def _select_br_if(self, instr: Instruction) -> None: ) if len(instr.operands) == 2 and "cmp_op" in instr.attrs: - # RISC-V branch comparisons require two registers. Constants - # already have LOAD_CONST definitions, so use those registers. - lhs = self._reg_op(instr, 0) - rhs = self._reg_op(instr, 1) + # Inline constants are valid IR operands too; do not assume every + # literal was accompanied by a LOAD_CONST definition. + lhs = self._materialize_int_operand(self._op(instr, 0), prefix="branch_lhs") + rhs = self._materialize_int_operand(self._op(instr, 1), prefix="branch_rhs") operator = instr.attrs["cmp_op"] branches = { "==": (MachineOp.BEQ, lhs, rhs), @@ -360,7 +494,7 @@ def _select_br_if(self, instr: Instruction) -> None: branch_op, first, second, target=true_target, ) elif len(instr.operands) == 1: - cond = self._op(instr, 0) + cond = self._materialize_int_operand(self._op(instr, 0), prefix="branch_cond") self._emit(MachineOp.BNEZ, cond, target=true_target) else: raise ValueError( @@ -368,8 +502,9 @@ def _select_br_if(self, instr: Instruction) -> None: ) self._emit(MachineOp.J, target=false_target) - @staticmethod - def _block_label(name: str) -> str: + def _block_label(self, name: str) -> str: + if name in self._block_labels: + return self._block_labels[name] if not name or name.startswith("."): return name return f".{name}" @@ -418,14 +553,15 @@ def _select_sigmoid(self, instr: Instruction) -> None: # li dst, 1 → else clamp to 1 # keep: mv dst, src keep_label = self._fresh_label("sig_keep") + below_one = self._fresh_vreg("sig_below_one") self._emit( MachineOp.SLT, - MachineOperand.vreg("t_sig"), + below_one, src, MachineOperand.immediate(1), comment="src < 1 ?", ) - self._emit(MachineOp.BNEZ, MachineOperand.vreg("t_sig"), target=keep_label) + self._emit(MachineOp.BNEZ, below_one, target=keep_label) self._emit(MachineOp.LI, dst, MachineOperand.immediate(1), comment="clamp to 1") # Branch over the mv done_label = self._fresh_label("sig_done") @@ -434,15 +570,16 @@ def _select_sigmoid(self, instr: Instruction) -> None: self._emit_move(dst, src, comment="keep src") self._emit_label(done_label) # Now dst = min(src, 1). If src < 0, result = 0 + above_zero = self._fresh_vreg("sig_above_zero") self._emit( MachineOp.SLT, - MachineOperand.vreg("t_sig2"), + above_zero, MachineOperand.immediate(0), src, comment="0 < src ?", ) zero_label = self._fresh_label("sig_zero") - self._emit(MachineOp.BNEZ, MachineOperand.vreg("t_sig2"), target=zero_label) + self._emit(MachineOp.BNEZ, above_zero, target=zero_label) self._emit(MachineOp.LI, dst, MachineOperand.immediate(0), comment="clamp to 0") self._emit_label(zero_label) @@ -456,7 +593,7 @@ def _select_conv(self, instr: Instruction) -> None: # acc = bias (mv bias to dest) self._emit_move(dst, b_reg, comment="acc = bias") # tmp = x * w (MUL for MAC) - tmp_vreg = MachineOperand.vreg("tmp_mac") + tmp_vreg = self._fresh_vreg("conv_mac") self._emit(MachineOp.MUL, tmp_vreg, x_reg, w_reg, comment="tmp = x * w") # dst = dst + tmp (acc += x*w) self._emit(MachineOp.ADD, dst, dst, tmp_vreg, comment="acc += x*w") @@ -469,7 +606,7 @@ def _select_gemm(self, instr: Instruction) -> None: b_reg = self._op(instr, 2) if dst: self._emit_move(dst, b_reg, comment="acc = bias") - tmp_vreg = MachineOperand.vreg("tmp_gemm") + tmp_vreg = self._fresh_vreg("gemm_mac") self._emit(MachineOp.MUL, tmp_vreg, a_reg, w_reg, comment="tmp = a * w") self._emit(MachineOp.ADD, dst, dst, tmp_vreg, comment="acc += a*w") @@ -481,14 +618,15 @@ def _select_maxpool(self, instr: Instruction) -> None: return # max(x, 0) using SLT + branch gt_label = self._fresh_label("mp_gt") + above_zero = self._fresh_vreg("maxpool_above_zero") self._emit( MachineOp.SLT, - MachineOperand.vreg("t_mp"), + above_zero, MachineOperand.immediate(0), src, comment="0 < x ?", ) - self._emit(MachineOp.BNEZ, MachineOperand.vreg("t_mp"), target=gt_label) + self._emit(MachineOp.BNEZ, above_zero, target=gt_label) self._emit(MachineOp.LI, dst, MachineOperand.immediate(0), comment="result = 0") done_label = self._fresh_label("mp_done") self._emit(MachineOp.J, target=done_label) diff --git a/scratchv/backend/llvm_codegen.py b/scratchv/backend/llvm_codegen.py index 3df95961..f7e991fc 100644 --- a/scratchv/backend/llvm_codegen.py +++ b/scratchv/backend/llvm_codegen.py @@ -6,8 +6,11 @@ from __future__ import annotations +import struct + from scratchv.ir.types import ( OpCode, DataType, Value, Instruction, BasicBlock, Function, Program, + valid_float_bit_pattern, ) @@ -24,6 +27,37 @@ _LLVM_I64 = "i64" +_FLOAT_ONLY_OPS = frozenset({ + OpCode.LOAD_CONST, OpCode.ADD, OpCode.SUB, OpCode.MUL, + OpCode.DIV, OpCode.NEG, +}) +_FLOAT_TYPES = frozenset({DataType.FLOAT32, DataType.FLOAT64}) + + +def validate_llvm_types(program: Program) -> None: + """Reject integer constants/arithmetic before floating-point lowering. + + Integer values remain valid in memory and control-flow operations. This + checks operation/type combinations rather than banning integer IR values. + """ + for function in program.functions: + for block in function.blocks: + for instruction in block.instructions: + if instruction.opcode not in _FLOAT_ONLY_OPS: + continue + values = list(instruction.operands) + if instruction.dest is not None: + values.insert(0, instruction.dest) + for value in values: + if value.dtype not in _FLOAT_TYPES: + raise ValueError( + "LLVM backend does not support " + f"{instruction.opcode.value} for {value.dtype.value}; " + "supported types: f32, f64 " + f"(function '{function.name}', block '{block.name}')" + ) + + class LLVMCodegen: """Translate ScratchV IR Program to LLVM IR text (.ll).""" @@ -45,6 +79,7 @@ def __init__(self, program: Program): def emit(self) -> str: """Produce complete LLVM IR module as text.""" + validate_llvm_types(self.program) self._lines = [] self._p("; LLVM IR generated by ScratchV") self._p('; ModuleID = "scratchv_module"') @@ -61,8 +96,9 @@ def emit(self) -> str: def save(self, path: str) -> None: """Write LLVM IR to a file.""" + llvm_ir = self.emit() with open(path, "w") as f: - f.write(self.emit()) + f.write(llvm_ir) # ------------------------------------------------------------------ # External declarations @@ -273,7 +309,16 @@ def _emit_load_const(self, instr: Instruction) -> None: assert isinstance(raw_val, (int, float)) val: float | int = raw_val ty = _llvm_type(instr.dest.dtype) if instr.dest else "float" - self._p(f" {dst} = fadd {ty} {_llvm_const_val(val, ty)}, 0.0") + raw_bits = instr.attrs.get("bit_pattern") + value_bits = instr.dest.bit_pattern if instr.dest is not None else None + if raw_bits is not None and value_bits is not None and raw_bits != value_bits: + raise ValueError("constant bit_pattern differs from result metadata") + integer_type, bits = _llvm_float_bits( + val, + ty, + bit_pattern=raw_bits if raw_bits is not None else value_bits, + ) + self._p(f" {dst} = bitcast {integer_type} {bits} to {ty}") def _emit_load(self, instr: Instruction) -> None: dst = self._dest(instr) @@ -585,11 +630,34 @@ def _llvm_const(val: Value) -> str: if val.const_value is not None: cv = val.const_value assert isinstance(cv, (float, int)) - return _llvm_const_val(cv, _llvm_type(val.dtype)) + return _llvm_const_val(cv, _llvm_type(val.dtype), bit_pattern=val.bit_pattern) return "0.0" -def _llvm_const_val(value: float | int, ty: str) -> str: +def _llvm_const_val( + value: float | int, ty: str, *, bit_pattern: int | None = None +) -> str: if ty in ("float", "double"): - return f"{float(value):e}" + integer_type, bits = _llvm_float_bits(value, ty, bit_pattern=bit_pattern) + return f"bitcast ({integer_type} {bits} to {ty})" + if bit_pattern is not None: + raise ValueError("bit_pattern requires an f32/f64 constant") return str(int(value)) + + +def _llvm_float_bits( + value: float | int, ty: str, *, bit_pattern: object = None +) -> tuple[str, int]: + """Encode the declared precision without rounding through decimal text.""" + integer_type = "i32" if ty == "float" else "i64" + if bit_pattern is not None: + dtype = DataType.FLOAT32 if ty == "float" else DataType.FLOAT64 + if not isinstance(bit_pattern, int) or not valid_float_bit_pattern( + dtype, bit_pattern + ): + raise ValueError(f"invalid {dtype.value} constant bit_pattern") + return integer_type, bit_pattern + number = float(value) + format_ = " addi rd, rs, 0 MachineOp.MV: MachineOpSemantics( defs=(0,), uses=(1,), n_phys=2, is_pseudo=True, + operand_classes=(_GPR, _GPR, None), ), # li rd, imm -> addi rd, x0, imm or lui/addi for a large immediate MachineOp.LI: MachineOpSemantics( @@ -83,6 +130,11 @@ class MachineOpSemantics: immediate_positions=(1,), n_phys=1, is_pseudo=True, + operand_classes=(_GPR, None, None), + ), + MachineOp.LA: MachineOpSemantics( + defs=(0,), n_phys=1, is_pseudo=True, + operand_classes=(_GPR, None, None), ), # ScratchV's RV32IM max pseudo accepts a register rhs or immediate zero. MachineOp.MAX: MachineOpSemantics( @@ -91,6 +143,7 @@ class MachineOpSemantics: immediate_positions=(2,), n_phys=3, is_pseudo=True, + operand_classes=(_GPR, _GPR, _GPR), ), MachineOp.LABEL: MachineOpSemantics( n_phys=0, @@ -105,6 +158,7 @@ class MachineOpSemantics: target_required=True, n_phys=1, is_pseudo=True, + operand_classes=(_GPR, None, None), ), # j label -> jal x0, label MachineOp.J: MachineOpSemantics( @@ -120,6 +174,7 @@ class MachineOpSemantics: immediate_positions=(2,), is_terminator=True, n_phys=2, + operand_classes=(_GPR, _GPR, None), ), MachineOp.JAL: MachineOpSemantics( defs=(0,), @@ -127,6 +182,7 @@ class MachineOpSemantics: target_from_comment=True, target_required=True, n_phys=1, + operand_classes=(_GPR, None, None), ), MachineOp.BEQ: MachineOpSemantics( uses=(0, 1), @@ -134,6 +190,7 @@ class MachineOpSemantics: target_from_comment=True, target_required=True, n_phys=2, + operand_classes=(_GPR, _GPR, None), ), MachineOp.BNE: MachineOpSemantics( uses=(0, 1), @@ -141,6 +198,7 @@ class MachineOpSemantics: target_from_comment=True, target_required=True, n_phys=2, + operand_classes=(_GPR, _GPR, None), ), MachineOp.BLT: MachineOpSemantics( uses=(0, 1), @@ -148,6 +206,7 @@ class MachineOpSemantics: target_from_comment=True, target_required=True, n_phys=2, + operand_classes=(_GPR, _GPR, None), ), MachineOp.BGE: MachineOpSemantics( uses=(0, 1), @@ -155,6 +214,7 @@ class MachineOpSemantics: target_from_comment=True, target_required=True, n_phys=2, + operand_classes=(_GPR, _GPR, None), ), # The CFG-aware spill rewriter and greedy allocator use these ABI clobbers # to preserve values that remain live across a call. @@ -162,7 +222,7 @@ class MachineOpSemantics: target_from_comment=True, target_required=True, implicit_defs=frozenset({"ra"}), - clobbers=frozenset({"ra", *ARG_REGS, *TEMP_REGS}), + clobbers=frozenset({"ra", *ARG_REGS, *TEMP_REGS, *FP_TEMP_REGS, *FP_ARG_REGS}), is_call=True, n_phys=1, is_pseudo=True, @@ -171,42 +231,62 @@ class MachineOpSemantics: MachineOp.GLOBL: _NO_REGISTERS, MachineOp.SIZE: _NO_REGISTERS, MachineOp.TYPE: _NO_REGISTERS, - MachineOp.SQRT_S: _DEF_USE, - MachineOp.SQRT_D: _DEF_USE, - MachineOp.FMIN_D: _DEF_USE_USE, - MachineOp.FMAX_D: _DEF_USE_USE, + MachineOp.SQRT_S: _FP_DEF_USE, + MachineOp.SQRT_D: _FP_DEF_USE, + MachineOp.FMIN_D: _FP_DEF_USE_USE, + MachineOp.FMAX_D: _FP_DEF_USE_USE, MachineOp.FABS_D: MachineOpSemantics( - defs=(0,), uses=(1,), n_phys=2, is_pseudo=True + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True, + operand_classes=(_FPR, _FPR, None), ), MachineOp.FNEG_D: MachineOpSemantics( - defs=(0,), uses=(1,), n_phys=2, is_pseudo=True + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True, + operand_classes=(_FPR, _FPR, None), + ), + MachineOp.FNEG_S: MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True, + operand_classes=(_FPR, _FPR, None), ), - MachineOp.FADD_D: _DEF_USE_USE, - MachineOp.FSUB_D: _DEF_USE_USE, - MachineOp.FMUL_D: _DEF_USE_USE, - MachineOp.FDIV_D: _DEF_USE_USE, - MachineOp.FLT_D: _DEF_USE_USE, - MachineOp.FEQ_D: _DEF_USE_USE, - MachineOp.FCVT_S_D: _DEF_USE, - MachineOp.FCVT_D_S: _DEF_USE, + MachineOp.FADD_D: _FP_DEF_USE_USE, + MachineOp.FSUB_D: _FP_DEF_USE_USE, + MachineOp.FMUL_D: _FP_DEF_USE_USE, + MachineOp.FDIV_D: _FP_DEF_USE_USE, + MachineOp.FLT_D: _FP_COMPARE, + MachineOp.FLE_D: _FP_COMPARE, + MachineOp.FEQ_D: _FP_COMPARE, + MachineOp.FCVT_S_D: _FP_DEF_USE, + MachineOp.FCVT_D_S: _FP_DEF_USE, MachineOp.LI_D: MachineOpSemantics( - defs=(0,), immediate_positions=(1,), n_phys=1, is_pseudo=True + defs=(0,), immediate_positions=(1,), n_phys=1, is_pseudo=True, + operand_classes=(_FPR, None, None), ), - MachineOp.FADD_S: _DEF_USE_USE, - MachineOp.FSUB_S: _DEF_USE_USE, - MachineOp.FMUL_S: _DEF_USE_USE, - MachineOp.FDIV_S: _DEF_USE_USE, - MachineOp.FMAX_S: _DEF_USE_USE, - MachineOp.FMIN_S: _DEF_USE_USE, - MachineOp.FLE_S: _DEF_USE_USE, - MachineOp.FLT_S: _DEF_USE_USE, - MachineOp.FEQ_S: _DEF_USE_USE, - MachineOp.FLW: _DEF_USE, - MachineOp.FSW: _STORE, + MachineOp.FADD_S: _FP_DEF_USE_USE, + MachineOp.FSUB_S: _FP_DEF_USE_USE, + MachineOp.FMUL_S: _FP_DEF_USE_USE, + MachineOp.FDIV_S: _FP_DEF_USE_USE, + MachineOp.FMAX_S: _FP_DEF_USE_USE, + MachineOp.FMIN_S: _FP_DEF_USE_USE, + MachineOp.FABS_S: MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True, + operand_classes=(_FPR, _FPR, None), + ), + MachineOp.FLE_S: _FP_COMPARE, + MachineOp.FLT_S: _FP_COMPARE, + MachineOp.FEQ_S: _FP_COMPARE, + MachineOp.FLW: _FP_LOAD, + MachineOp.FSW: _FP_STORE, MachineOp.FMV_S: MachineOpSemantics( - defs=(0,), uses=(1,), n_phys=2, is_pseudo=True + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True, + operand_classes=(_FPR, _FPR, None), + ), + MachineOp.FMV_D: MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, is_pseudo=True, + operand_classes=(_FPR, _FPR, None), + ), + MachineOp.FMV_S_X: MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, + operand_classes=(_FPR, _GPR, None), ), - MachineOp.FMV_S_X: _DEF_USE, } @@ -222,6 +302,34 @@ def get_machine_semantics(op: MachineOp) -> MachineOpSemantics: return OP_SEM[op] +def validate_register_classes(instructions: list["MachineInstr"]) -> None: + """Validate positional constraints and whole-stream vreg consistency.""" + vreg_classes: dict[str, RegisterClass] = {} + for instr in instructions: + semantics = get_machine_semantics(instr.op) + operands = (instr.dst, instr.src1, instr.src2) + for position, expected in enumerate(semantics.operand_classes): + operand = operands[position] + if operand is None or operand.kind not in {"reg", "vreg"}: + continue + actual = operand.reg_class + if operand.kind == "reg" and physical_register_class(str(operand.value)) is not actual: + raise ValueError(f"physical register {operand.value} has wrong register class") + if expected is not None and actual is not expected: + raise ValueError( + f"{instr.op.value} operand {position} requires " + f"{expected.value}, got {actual.value if actual else 'none'}" + ) + if operand.kind == "vreg" and actual is not None: + name = str(operand.value) + previous = vreg_classes.setdefault(name, actual) + if previous is not actual: + raise ValueError( + f"virtual register {name} requires both " + f"{previous.value} and {actual.value}" + ) + + def virtual_register_defs_uses( instr: "MachineInstr", ) -> tuple[set[str], set[str]]: diff --git a/scratchv/backend/machine_types.py b/scratchv/backend/machine_types.py index b132e8fd..5830ca4a 100644 --- a/scratchv/backend/machine_types.py +++ b/scratchv/backend/machine_types.py @@ -24,7 +24,7 @@ # MachineOp — RISC-V machine instruction opcodes # ═══════════════════════════════════════════════════════════════════════════════ -class MachineOp(enum.Enum): +class MachineOp(enum.Enum): """RISC-V machine instruction opcodes used by the compiler.""" # ALU ADD = "add" @@ -53,8 +53,9 @@ class MachineOp(enum.Enum): BGE = "bge" BNEZ = "bnez" # pseudo # Pseudo - LI = "li" - MV = "mv" + LI = "li" + LA = "la" + MV = "mv" CALL = "call" LABEL = ".label" # Directive @@ -68,12 +69,14 @@ class MachineOp(enum.Enum): FMIN_D = "fmin.d" FMAX_D = "fmax.d" FABS_D = "fabs.d" - FNEG_D = "fneg.d" + FNEG_D = "fneg.d" + FNEG_S = "fneg.s" FADD_D = "fadd.d" FSUB_D = "fsub.d" FMUL_D = "fmul.d" FDIV_D = "fdiv.d" - FLT_D = "flt.d" + FLT_D = "flt.d" + FLE_D = "fle.d" FEQ_D = "feq.d" FCVT_S_D = "fcvt.s.d" FCVT_D_S = "fcvt.d.s" @@ -85,46 +88,94 @@ class MachineOp(enum.Enum): FDIV_S = "fdiv.s" FMAX_S = "fmax.s" FMIN_S = "fmin.s" + FABS_S = "fabs.s" FLE_S = "fle.s" FLT_S = "flt.s" FEQ_S = "feq.s" FSQRT_S = "fsqrt.s" FLW = "flw" FSW = "fsw" - FMV_S = "fmv.s" - FMV_S_X = "fmv.s.x" + FMV_S = "fmv.s" + FMV_D = "fmv.d" + FMV_S_X = "fmv.s.x" + + +class RegisterClass(enum.Enum): + """Architectural register file required by an operand.""" + + GPR = "gpr" + FPR = "fpr" + + +_ROUNDING_MODE_OPS = frozenset({ + MachineOp.FADD_S, MachineOp.FADD_D, + MachineOp.FSUB_S, MachineOp.FSUB_D, + MachineOp.FMUL_S, MachineOp.FMUL_D, + MachineOp.FDIV_S, MachineOp.FDIV_D, + MachineOp.SQRT_S, MachineOp.SQRT_D, + MachineOp.FCVT_S_D, MachineOp.FCVT_D_S, +}) # ═══════════════════════════════════════════════════════════════════════════════ # MachineOperand — register or immediate operand # ═══════════════════════════════════════════════════════════════════════════════ -@dataclass -class MachineOperand: +@dataclass +class MachineOperand: """A register or immediate operand.""" - kind: str # "reg", "imm", "vreg" - value: str | int + kind: str # "reg", "imm", "vreg" + value: str | int + reg_class: RegisterClass | None = None + bit_width: int | None = None + + def __post_init__(self) -> None: + if self.kind in {"reg", "vreg"} and self.reg_class is None: + self.reg_class = RegisterClass.GPR + if self.kind not in {"reg", "vreg"} and self.reg_class is not None: + raise ValueError(f"{self.kind} operand cannot have a register class") + if self.bit_width is not None and ( + self.kind not in {"reg", "vreg"} or self.bit_width not in {32, 64} + ): + raise ValueError("register width must be 32 or 64 bits") + if self.reg_class is RegisterClass.GPR and self.bit_width == 64: + raise ValueError("RV32 GPR operands must be 32 bits") @staticmethod - def vreg(name: str) -> "MachineOperand": + def vreg( + name: str, + reg_class: RegisterClass = RegisterClass.GPR, + bit_width: int | None = None, + ) -> "MachineOperand": """Create a virtual register operand.""" - return MachineOperand("vreg", name) + return MachineOperand("vreg", name, reg_class, bit_width) @staticmethod - def immediate(val: int) -> "MachineOperand": + def immediate(val: int) -> "MachineOperand": """Create an immediate (constant) operand.""" - return MachineOperand("imm", val) + return MachineOperand("imm", val) + + @staticmethod + def symbol(name: str) -> "MachineOperand": + """Create a non-register symbolic assembly operand.""" + return MachineOperand("symbol", name) @staticmethod - def reg(name: str) -> "MachineOperand": + def reg( + name: str, + reg_class: RegisterClass = RegisterClass.GPR, + bit_width: int | None = None, + ) -> "MachineOperand": """Create a physical register operand.""" - return MachineOperand("reg", name) + return MachineOperand("reg", name, reg_class, bit_width) - def __repr__(self) -> str: - if self.kind == "imm": - return str(self.value) - return f"%{self.value}" + def __repr__(self) -> str: + if self.kind == "imm": + return str(self.value) + if self.kind == "symbol": + return str(self.value) + return f"%{self.value}" # ═══════════════════════════════════════════════════════════════════════════════ @@ -132,27 +183,63 @@ def __repr__(self) -> str: # ═══════════════════════════════════════════════════════════════════════════════ @dataclass -class MachineInstr: +class MachineInstr: """A machine-level instruction using virtual or physical registers.""" op: MachineOp dst: Optional[MachineOperand] = None src1: Optional[MachineOperand] = None src2: Optional[MachineOperand] = None - comment: str = "" - target: Optional[str] = None + comment: str = "" + target: Optional[str] = None + rounding_mode: Optional[str] = None + label_role: Optional[str] = None + + def __post_init__(self) -> None: + if self.label_role is not None: + if self.op is not MachineOp.LABEL or self.label_role not in {"function", "block"}: + raise ValueError("label_role requires a function or block LABEL") + if self.rounding_mode is not None: + self.rounding_mode = self.rounding_mode.lower() + if self.rounding_mode not in { + "rne", "rtz", "rdn", "rup", "rmm", "dyn", + }: + raise ValueError( + f"unsupported RISC-V rounding mode: {self.rounding_mode}" + ) + if self.op not in _ROUNDING_MODE_OPS: + raise ValueError( + f"{self.op.value} does not take a rounding mode" + ) def __repr__(self) -> str: parts = [self.op.value] for op in (self.dst, self.src1, self.src2): if op is not None: parts.append(str(op)) - if self.target: - parts.append(self.target) + if self.target: + parts.append(self.target) + if self.rounding_mode: + parts.append(self.rounding_mode) s = " ".join(parts) if self.comment: s += f" # {self.comment}" - return s + return s + + +@dataclass(frozen=True) +class FloatConstant: + """One bit-exact entry in a compilation unit's floating constant pool.""" + + label: str + bit_width: int + bit_pattern: int + + def __post_init__(self) -> None: + if self.bit_width not in {32, 64}: + raise ValueError("floating constant width must be 32 or 64 bits") + if not 0 <= self.bit_pattern < (1 << self.bit_width): + raise ValueError("floating constant bit pattern is out of range") # ═══════════════════════════════════════════════════════════════════════════════ @@ -172,7 +259,45 @@ def __repr__(self) -> str: ARG_REGS: list[str] = ["a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7"] # All allocatable integer registers (19 total) -ALL_REGS: list[str] = TEMP_REGS + CALLEE_SAVED +ALL_REGS: list[str] = TEMP_REGS + CALLEE_SAVED + +# Only caller-saved FP temporaries are allocated; fa*/fs* are ABI-owned. +FP_TEMP_REGS: list[str] = [f"ft{i}" for i in range(12)] +FP_ARG_REGS: list[str] = [f"fa{i}" for i in range(8)] + + +def canonical_register_name(name: str) -> str: + """Normalize architectural aliases when tracking physical ownership.""" + integer_names = ["zero", "ra", "sp", "gp", "tp", "t0", "t1", "t2", + "s0", "s1", *ARG_REGS, *CALLEE_SAVED[2:], *TEMP_REGS[3:]] + float_names = [*FP_TEMP_REGS[:8], "fs0", "fs1", *FP_ARG_REGS, + *(f"fs{i}" for i in range(2, 12)), *FP_TEMP_REGS[8:]] + aliases = {f"x{i}": alias for i, alias in enumerate(integer_names)} + aliases.update({f"f{i}": alias for i, alias in enumerate(float_names)}) + aliases["fp"] = "s0" + return aliases.get(name, name) + + +def physical_register_class(name: str) -> RegisterClass: + """Classify architectural names, including ABI aliases and memory bases.""" + import re + + memory = re.fullmatch(r"-?\d+\((\w+)\)", name) + if memory: + if physical_register_class(memory.group(1)) is not RegisterClass.GPR: + raise ValueError("memory address base must be a GPR") + return RegisterClass.GPR + if name in { + *ALL_REGS, *ARG_REGS, "zero", "ra", "sp", "gp", "tp", "fp", + *(f"x{i}" for i in range(32)), + }: + return RegisterClass.GPR + if name in { + *FP_TEMP_REGS, *FP_ARG_REGS, *(f"fs{i}" for i in range(12)), + *(f"f{i}" for i in range(32)), + }: + return RegisterClass.FPR + raise ValueError(f"unknown physical register: {name}") # Special-purpose registers STACK_BASE: str = "sp" diff --git a/scratchv/backend/regalloc_linear.py b/scratchv/backend/regalloc_linear.py index 0e8b58f8..810ef86a 100644 --- a/scratchv/backend/regalloc_linear.py +++ b/scratchv/backend/regalloc_linear.py @@ -22,7 +22,10 @@ linear_scan_operands, virtual_register_defs_uses, ) -from scratchv.backend.machine_types import ALL_REGS, ARG_REGS +from scratchv.backend.machine_types import ALL_REGS, ARG_REGS, FP_TEMP_REGS, RegisterClass, MachineInstr +from scratchv.backend.allocation import ( + AllocationResult, RegisterType, SpillLayout, collect_register_types, validate_allocated, +) from scratchv.backend.regalloc_metrics import ( count_spill_reload_sites, peak_live_intervals, @@ -120,6 +123,7 @@ class LsInstruction: uses: set[str] = field(default_factory=set) comment: str = "" target: Optional[str] = None + machine_instr: MachineInstr | None = None def __repr__(self) -> str: return (f"LsInstruction({self.id}, {self.opcode}, " @@ -133,6 +137,29 @@ def to_asm(self, rename: Optional[dict[str, str]] = None) -> str: raise ValueError("machine label must have a name") return f"{label}:" + if self.machine_instr is not None and ( + self.machine_instr.op.value in {"lw", "sw"} + or any( + operand is not None and operand.reg_class is RegisterClass.FPR + for operand in (self.machine_instr.dst, self.machine_instr.src1, self.machine_instr.src2) + ) + ): + from scratchv.backend.asm_emit import AsmEmitter + from scratchv.backend.machine_types import MachineInstr, MachineOperand + + operands = [] + for operand in (self.machine_instr.dst, self.machine_instr.src1, self.machine_instr.src2): + if operand is not None and operand.kind == "vreg": + operand = MachineOperand.reg( + (rename or {}).get(str(operand.value), str(operand.value)), + operand.reg_class, operand.bit_width, + ) + operands.append(operand) + return AsmEmitter([])._format_instr(MachineInstr( + self.machine_instr.op, *operands, self.machine_instr.comment, + self.machine_instr.target, self.machine_instr.rounding_mode, + )) + ops = self.operands[:] if rename: ops = [rename.get(o, o) for o in ops] @@ -168,6 +195,8 @@ class LiveInterval: start: int end: int uses: set[int] = field(default_factory=set) + reg_class: RegisterClass = RegisterClass.GPR + bit_width: int = 32 def overlaps(self, other: "LiveInterval") -> bool: """Check if two intervals overlap.""" @@ -235,6 +264,25 @@ def __init__(self, phys_regs: Optional[list[str]] = None): self.spill_store_count: int = 0 self.reload_load_count: int = 0 self.cfg: MachineCFG = MachineCFG([], {}, {}) + self._vreg_types: dict[str, RegisterType] = {} + self._spill_layout = SpillLayout() + self._allocated_instructions: list[MachineInstr] = [] + + def registers_for(self, vreg: str) -> list[str]: + return (FP_TEMP_REGS if self._vreg_types.get(vreg, RegisterType()).reg_class + is RegisterClass.FPR else self.phys_regs) + + def allocate_result(self, instructions: list) -> AllocationResult: + self.emit(block_from_machine_instrs(instructions)) + return self.allocation_result + + @property + def allocation_result(self) -> AllocationResult: + validate_allocated(self._allocated_instructions) + return AllocationResult( + self._allocated_instructions, dict(self.alloc_map), + dict(self._spill_layout.slots), self._spill_layout.size, + ) @property def spill_slot_count(self) -> int: @@ -261,6 +309,9 @@ def compute_live_intervals( List of LiveInterval objects sorted by start position. """ # Collect all virtual register names + self._vreg_types = collect_register_types([ + inst.machine_instr for inst in block if inst.machine_instr is not None + ]) vregs: set[str] = set() for inst in block: vregs |= inst.defines @@ -269,6 +320,7 @@ def compute_live_intervals( intervals: list[LiveInterval] = [] for vreg in vregs: + value_type = self._vreg_types.setdefault(vreg, RegisterType()) start = -1 end = -1 uses = set() @@ -295,6 +347,7 @@ def compute_live_intervals( intervals.append(LiveInterval( vreg=vreg, start=start, end=end, uses=uses, + reg_class=value_type.reg_class, bit_width=value_type.bit_width, )) self.cfg = analyze_control_flow(block) @@ -323,6 +376,9 @@ def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: self._spilled.clear() self._evictions.clear() self.stack_slot = 0 + self._spill_layout = SpillLayout() + for interval in intervals: + self._vreg_types[interval.vreg] = RegisterType(interval.reg_class, interval.bit_width) self._intervals = intervals self._vreg_interval = {iv.vreg: iv for iv in intervals} self.peak_active = 0 @@ -330,14 +386,23 @@ def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: self.pressure_excess_peak = max( 0, self.pressure_peak - len(self.phys_regs) ) + if any(interval.reg_class is RegisterClass.FPR for interval in intervals): + self.pressure_excess_peak = max( + max(0, peak_live_intervals([iv for iv in intervals if iv.reg_class is kind]) + - len(self.phys_regs if kind is RegisterClass.GPR else FP_TEMP_REGS)) + for kind in RegisterClass + ) self.spill_store_count = 0 self.reload_load_count = 0 # Active list: (interval, phys_reg) sorted by increasing end - active: list[tuple[LiveInterval, str]] = [] - free_regs: list[str] = list(self.phys_regs) + active_by_class: dict[RegisterClass, list] = {kind: [] for kind in RegisterClass} + free_by_class = {RegisterClass.GPR: list(self.phys_regs), + RegisterClass.FPR: list(FP_TEMP_REGS)} for interval in intervals: + active = active_by_class[interval.reg_class] + free_regs = free_by_class[interval.reg_class] # Expire old intervals self._expire_old_intervals(active, interval.start, free_regs) @@ -353,12 +418,12 @@ def allocate(self, intervals: list[LiveInterval]) -> dict[str, str]: # Spill freed a register reg = ( free_regs.pop(0) if free_regs - else self.phys_regs[0] + else self.registers_for(interval.vreg)[0] ) self.alloc_map[interval.vreg] = reg active.append((interval, reg)) - self.peak_active = max(self.peak_active, len(active)) + self.peak_active = max(self.peak_active, sum(map(len, active_by_class.values()))) return dict(self.alloc_map) @@ -425,7 +490,7 @@ def spill(self, current: LiveInterval, # Otherwise, spill the current interval slot = self._get_spill_slot(current.vreg) # Assign a temporary register for the definition instruction - temp_reg = self.phys_regs[0] + temp_reg = self.registers_for(current.vreg)[0] self.alloc_map[current.vreg] = temp_reg self._spilled.add(current.vreg) self.spill_code.setdefault(current.start, []).append( @@ -440,8 +505,9 @@ def spill(self, current: LiveInterval, def _get_spill_slot(self, vreg: str) -> int: """Get or allocate a stack slot for a virtual register.""" if vreg not in self._spill_slots: - self.stack_slot -= 4 - self._spill_slots[vreg] = self.stack_slot + slot = self._spill_layout.reserve(vreg, self._vreg_types.get(vreg, RegisterType())) + self._spill_slots[vreg] = slot.offset + self.stack_slot = -self._spill_layout.size return self._spill_slots[vreg] # ------------------------------------------------------------------ @@ -641,6 +707,7 @@ def block_from_machine_instrs( result.append(LsInstruction( id=i, opcode=".label", operands=[label_name], comment=mi.comment, target=label_name, + machine_instr=mi, )) else: result.append(LsInstruction( @@ -650,6 +717,7 @@ def block_from_machine_instrs( defines=defines, uses=uses, comment=comment, target=mi.target, + machine_instr=mi, )) return result @@ -672,7 +740,9 @@ def machine_instrs_from_block( ------- List of MachineInstr objects. """ - from scratchv.backend.machine_types import MachineInstr, MachineOp, MachineOperand + from scratchv.backend.machine_types import ( + MachineInstr, MachineOp, MachineOperand, physical_register_class, + ) result = [] for inst in block: @@ -680,6 +750,7 @@ def machine_instrs_from_block( label = inst.operands[0] if inst.operands else inst.comment result.append(MachineInstr( MachineOp.LABEL, comment=label, target=inst.target or label, + label_role=inst.machine_instr.label_role if inst.machine_instr is not None else None, )) continue @@ -698,7 +769,17 @@ def machine_instrs_from_block( comment = operand_strings.pop() # Build register/immediate operands. - def _to_mop(s: str) -> MachineOperand: + def _to_mop(s: str, position: int) -> MachineOperand: + original = inst.machine_instr + template = (original.dst, original.src1, original.src2)[position] if original is not None else None + if template is not None and s == str(template.value): + return MachineOperand(template.kind, template.value, template.reg_class, template.bit_width) + if template is not None and template.kind in {"reg", "vreg"}: + try: + physical_register_class(s) + except ValueError: + return MachineOperand.vreg(s, template.reg_class, template.bit_width) + return MachineOperand.reg(s, template.reg_class, template.bit_width) if s in _REG_NUMS: return MachineOperand.reg(s) try: @@ -709,7 +790,7 @@ def _to_mop(s: str) -> MachineOperand: dst = None src1 = None src2 = None - ops = [_to_mop(o) for o in operand_strings] + ops = [_to_mop(o, position) for position, o in enumerate(operand_strings)] if len(ops) >= 1: dst = ops[0] if len(ops) >= 2: @@ -717,6 +798,9 @@ def _to_mop(s: str) -> MachineOperand: if len(ops) >= 3: src2 = ops[2] - result.append(MachineInstr(mop, dst, src1, src2, comment, inst.target)) + result.append(MachineInstr( + mop, dst, src1, src2, comment, inst.target, + inst.machine_instr.rounding_mode if inst.machine_instr is not None else None, + )) return result diff --git a/scratchv/backend/regalloc_linear_v1_5.py b/scratchv/backend/regalloc_linear_v1_5.py index 81138f97..e3ee74b4 100644 --- a/scratchv/backend/regalloc_linear_v1_5.py +++ b/scratchv/backend/regalloc_linear_v1_5.py @@ -779,6 +779,11 @@ def block_from_machine_instrs( ------- List of LsInstruction objects ready for linear scan allocator. """ + from scratchv.backend.machine_types import RegisterClass + + if any(operand is not None and operand.reg_class is RegisterClass.FPR + for instr in instrs for operand in (instr.dst, instr.src1, instr.src2)): + raise ValueError("experimental regalloc_linear_v1_5 is GPR-only; use regalloc_linear for FPR allocation") result = [] for i, mi in enumerate(instrs): defines, uses = virtual_register_defs_uses(mi) diff --git a/scratchv/backend/regalloc_metrics.py b/scratchv/backend/regalloc_metrics.py index a0741452..8326a9b9 100644 --- a/scratchv/backend/regalloc_metrics.py +++ b/scratchv/backend/regalloc_metrics.py @@ -34,8 +34,8 @@ def count_spill_reload_sites(assembly: str) -> tuple[int, int]: reload_loads = 0 for line in assembly.splitlines(): content = line.strip() - if content.startswith("sw ") and "[regalloc:spill]" in content: + if content.startswith(("sw ", "fsw ", "fsd ")) and "[regalloc:spill]" in content: spill_stores += 1 - if content.startswith("lw ") and "[regalloc:reload]" in content: + if content.startswith(("lw ", "flw ", "fld ")) and "[regalloc:reload]" in content: reload_loads += 1 return spill_stores, reload_loads diff --git a/scratchv/backend/regalloc_rewrite.py b/scratchv/backend/regalloc_rewrite.py index 98ed9bc6..c0db942d 100644 --- a/scratchv/backend/regalloc_rewrite.py +++ b/scratchv/backend/regalloc_rewrite.py @@ -5,7 +5,8 @@ from typing import Any from scratchv.backend.machine_semantics import get_machine_semantics -from scratchv.backend.machine_types import MachineOp +from scratchv.backend.machine_types import FP_TEMP_REGS, MachineInstr, MachineOperand, MachineOp, canonical_register_name +from scratchv.backend.allocation import RegisterType def rewrite_with_spills(allocator: Any, instructions: list[Any]) -> str: @@ -18,6 +19,9 @@ def rewrite_with_spills(allocator: Any, instructions: list[Any]) -> str: CFG boundaries, and invalidates ABI-clobbered registers across calls. """ + # Every rewrite owns a new output list, even for empty input. Rebinding + # also preserves instruction lists already returned to callers. + allocator._allocated_instructions = [] if not instructions: return "" if not allocator.phys_regs: @@ -36,15 +40,61 @@ def rewrite_with_spills(allocator: Any, instructions: list[Any]) -> str: block_first = {block.start: block for block in cfg.blocks} block_last = {block.end - 1: block for block in cfg.blocks} + for inst in instructions: + try: + semantics = get_machine_semantics(MachineOp(inst.opcode)) + except ValueError: + continue + if semantics.is_terminator and inst.defines: + # A definition made by the jump cannot be stored before the + # instruction, and a store after it is unreachable. Like greedy, + # reject this unsupported edge-result contract instead of reading + # an uninitialized canonical slot in the successor. + raise RuntimeError( + "linear regalloc does not support a terminator defining a virtual register" + ) + + def physical_constraints(inst: Any) -> tuple[set[str], set[str]]: + """Find fixed register operands without confusing named virtuals.""" + try: + semantics = get_machine_semantics(MachineOp(inst.opcode)) + except ValueError: + return set(), set() + original = getattr(inst, "machine_instr", None) + + def collect(positions: tuple[int, ...], virtuals: set[str]) -> set[str]: + registers: set[str] = set() + for position in positions: + if position >= len(inst.operands): + continue + if original is not None: + operand = (original.dst, original.src1, original.src2)[position] + if operand is None or operand.kind != "reg": + continue + elif inst.operands[position] in virtuals: + continue + register = canonical_register_name(inst.operands[position]) + if register in allocator.phys_regs or register in FP_TEMP_REGS: + registers.add(register) + return registers + + return collect(semantics.uses, inst.uses), collect(semantics.defs, inst.defines) + + fixed_operands = {inst.id: physical_constraints(inst) for inst in instructions} all_defines = set().union(*(inst.defines for inst in instructions)) # Once allocation contains a split/spilled interval, reloads can evict a # nominally register-resident value on only one predecessor. Canonicalize # every edge-live value through its stack slot in that case so all incoming - # paths agree at joins. The set is fixed before emission; deriving it from - # mutable rewrite state would make correctness depend on textual block - # order. + # paths agree at joins. Explicit physical definitions can cause the same + # dynamic eviction even without pressure or CALL. Fixed physical uses + # can also relocate a destination away from its preferred register. + # Plan for both before emission: deriving this set from mutable rewrite + # state would make correctness depend on textual block order. canonical_vregs: set[str] = set() - if allocator._spilled: + if allocator._spilled or any( + inst.opcode == MachineOp.CALL.value or any(fixed_operands[inst.id]) + for inst in instructions + ): canonical_vregs = set().union( *(block.live_out for block in cfg.blocks) ) @@ -54,6 +104,35 @@ def rewrite_with_spills(allocator: Any, instructions: list[Any]) -> str: rename: dict[str, str] = {} lines: list[str] = [] + def bank_for(vreg: str) -> list[str]: + return allocator.registers_for(vreg) if hasattr(allocator, "registers_for") else allocator.phys_regs + + def transfer(vreg: str, reg: str, slot: int, comment: str, *, load: bool = False) -> None: + value_type = getattr(allocator, "_vreg_types", {}).get(vreg, RegisterType()) + op = value_type.load_op if load else value_type.store_op + lines.append(f" {op.value} {reg}, {slot}(sp) # {comment}") + allocator._allocated_instructions.append(MachineInstr( + op, MachineOperand.reg(reg, value_type.reg_class, value_type.bit_width), + MachineOperand.reg(f"{slot}(sp)"), comment=comment, + )) + + def append_instruction(inst: Any) -> None: + original = getattr(inst, "machine_instr", None) + if original is None: + lines.append(inst.to_asm(rename)) + return + operands = [] + for operand in (original.dst, original.src1, original.src2): + if operand is not None and operand.kind == "vreg": + operand = MachineOperand.reg(rename[str(operand.value)], operand.reg_class, operand.bit_width) + operands.append(operand) + allocated = MachineInstr(original.op, *operands, original.comment, + original.target, original.rounding_mode, original.label_role) + allocator._allocated_instructions.append(allocated) + # The legacy string API stays stable for GPR-only clients. Production + # typed emission consumes the structured instructions via AsmEmitter. + lines.append(inst.to_asm(rename)) + def has_later_use(vreg: str, position: int, block: Any) -> bool: interval = allocator._vreg_interval.get(vreg) return bool( @@ -74,9 +153,7 @@ def store_owner(reg: str, position: int, block: Any, reason: str) -> None: return slot = allocator._get_spill_slot(owner) allocator._spilled.add(owner) - lines.append( - f" sw {reg}, {slot}(sp) # {reason} {owner} [regalloc:spill]" - ) + transfer(owner, reg, slot, f"{reason} {owner} [regalloc:spill]") stack_current.add(owner) def claim_register(vreg: str, reg: str) -> None: @@ -98,9 +175,10 @@ def choose_register( preferred: str | None = None, ) -> str: candidates = [] - if preferred in allocator.phys_regs: + bank = bank_for(vreg) + if preferred in bank: candidates.append(preferred) - candidates.extend(reg for reg in allocator.phys_regs if reg not in candidates) + candidates.extend(reg for reg in bank if reg not in candidates) # Keep the allocator's global assignment stable across CFG edges. # Even if another register is currently dead, silently choosing it @@ -145,7 +223,7 @@ def store_live_out(block: Any, position: int) -> None: # the same location on every edge. Only split/spilled values need # the canonical stack hand-off between basic blocks. if vreg not in canonical_vregs \ - and allocator.alloc_map.get(vreg) in allocator.phys_regs \ + and allocator.alloc_map.get(vreg) in bank_for(vreg) \ and vreg not in allocator._spilled: continue reg = locations.get(vreg) @@ -153,10 +231,7 @@ def store_live_out(block: Any, position: int) -> None: continue slot = allocator._get_spill_slot(vreg) allocator._spilled.add(vreg) - lines.append( - f" sw {reg}, {slot}(sp) # spill {vreg} at block boundary " - "[regalloc:spill]" - ) + transfer(vreg, reg, slot, f"spill {vreg} at block boundary [regalloc:spill]") stack_current.add(vreg) for inst in instructions: @@ -172,7 +247,7 @@ def store_live_out(block: Any, position: int) -> None: # cross an edge through their canonical stack slot instead. for vreg in sorted(block.live_in): preferred = allocator.alloc_map.get(vreg) - if preferred in allocator.phys_regs \ + if preferred in bank_for(vreg) \ and vreg not in canonical_vregs \ and vreg not in allocator._spilled \ and preferred not in reg_owner: @@ -187,21 +262,9 @@ def store_live_out(block: Any, position: int) -> None: # Physical operands are fixed constraints, not values available to # the allocator. Preserve a live virtual value currently occupying # an explicitly-written register before that instruction clobbers it. - physical_uses: set[str] = set() - physical_defs: set[str] = set() - if semantics is not None: - for position in semantics.uses: - if position < len(inst.operands): - operand = inst.operands[position] - if operand in allocator.phys_regs: - physical_uses.add(operand) - for position in semantics.defs: - if position < len(inst.operands): - operand = inst.operands[position] - if operand in allocator.phys_regs: - physical_defs.add(operand) - for reg in physical_defs: - store_owner(reg, inst.id, block, "spill before physical clobber") + physical_uses, physical_defs = fixed_operands[inst.id] + for reg in physical_defs: + store_owner(reg, inst.id, block, "spill before physical clobber") ordered_uses: list[str] = [] for operand in inst.operands: @@ -242,10 +305,7 @@ def store_live_out(block: Any, position: int) -> None: preferred = allocator.alloc_map.get(vreg) reg = choose_register(vreg, inst.id, block, protected, preferred) slot = allocator._get_spill_slot(vreg) - lines.append( - f" lw {reg}, {slot}(sp) # reload {vreg} " - "[regalloc:reload]" - ) + transfer(vreg, reg, slot, f"reload {vreg} [regalloc:reload]", load=True) claim_register(vreg, reg) protected.add(reg) @@ -287,7 +347,7 @@ def store_live_out(block: Any, position: int) -> None: if reg in semantics.clobbers: store_owner(reg, inst.id, block, "spill") - lines.append(inst.to_asm(rename)) + append_instruction(inst) # The instruction has now overwritten its explicit physical # destinations; any old virtual ownership is stale. @@ -305,10 +365,7 @@ def store_live_out(block: Any, position: int) -> None: continue reg = locations[vreg] slot = allocator._get_spill_slot(vreg) - lines.append( - f" sw {reg}, {slot}(sp) # store redefined {vreg} " - "[regalloc:spill]" - ) + transfer(vreg, reg, slot, f"store redefined {vreg} [regalloc:spill]") stack_current.add(vreg) forget_register(reg) diff --git a/scratchv/backend/register_alloc.py b/scratchv/backend/register_alloc.py index 2bfe414c..b4094480 100644 --- a/scratchv/backend/register_alloc.py +++ b/scratchv/backend/register_alloc.py @@ -11,7 +11,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional + +from scratchv.backend.allocation import ( + AllocationResult, SpillLayout, collect_register_types, validate_allocated, +) from scratchv.backend.machine_semantics import ( get_machine_semantics, @@ -19,7 +23,10 @@ ) from scratchv.backend.machine_types import ( # noqa: F401 — re-export - ALL_REGS, + ALL_REGS, + FP_TEMP_REGS, + RegisterClass, + canonical_register_name, ARG_REGS, CALLEE_SAVED, MachineInstr, @@ -75,7 +82,10 @@ def __init__(self, instructions: list[MachineInstr], mode: str = "greedy"): # their physical register. Block-boundary flushes skip emitting a # redundant spill for these values, so loop-invariant operands are # not written back to memory on every back edge. - self._clean_slots: set[str] = set() + self._clean_slots: set[str] = set() + self._vreg_types = collect_register_types(instructions) + self._spill_layout = SpillLayout() + self._all_regs = ALL_REGS + FP_TEMP_REGS @property def spill_slot_count(self) -> int: @@ -83,11 +93,27 @@ def spill_slot_count(self) -> int: return len(self._spill_slots) - def run(self) -> list[MachineInstr]: - if self.mode == "naive": - return self._allocate_naive() - else: - return self._allocate_greedy() + def run(self) -> list[MachineInstr]: + if self.mode == "naive": + result = self._allocate_naive() + else: + result = self._allocate_greedy() + validate_allocated(result) + return result + + def allocate_result(self) -> AllocationResult: + return AllocationResult( + self.run(), self.register_map, + dict(self._spill_layout.slots), self._spill_layout.size, + ) + + def _registers_for(self, vreg: str) -> list[str]: + return (FP_TEMP_REGS if self._vreg_types[vreg].reg_class + is RegisterClass.FPR else ALL_REGS) + + def _physical(self, vreg: str, register: str) -> MachineOperand: + value_type = self._vreg_types[vreg] + return MachineOperand.reg(register, value_type.reg_class, value_type.bit_width) @property def register_map(self) -> dict[str, str]: @@ -98,7 +124,8 @@ def _allocate_naive(self) -> list[MachineInstr]: """Spill every virtual register to the stack.""" self._output = [] self._allocation_map.clear() - self._spill_slots.clear() + self._spill_slots.clear() + self._spill_layout = SpillLayout() self._next_spill = 0 for instr in self.instructions: if instr.op == MachineOp.LABEL: @@ -109,15 +136,12 @@ def _allocate_naive(self) -> list[MachineInstr]: operands = [instr.dst, instr.src1, instr.src2] resolved = list(operands) explicit_regs = { - str(operand.value) + canonical_register_name(str(operand.value)) for operand in operands if operand is not None and operand.kind == "reg" - and operand.value in ALL_REGS + and canonical_register_name(str(operand.value)) in self._all_regs } - scratch_regs = [ - reg for reg in ALL_REGS if reg not in explicit_regs - ] scratch_by_vreg: dict[str, str] = {} def scratch_for(vreg: str) -> str: @@ -125,13 +149,15 @@ def scratch_for(vreg: str) -> str: return scratch_by_vreg[vreg] used = set(scratch_by_vreg.values()) try: - reg = next(reg for reg in scratch_regs if reg not in used) + reg = next(reg for reg in self._registers_for(vreg) + if reg not in used and reg not in explicit_regs) except StopIteration as exc: raise RuntimeError( "naive regalloc: instruction needs more distinct " "scratch registers than are available" ) from exc - scratch_by_vreg[vreg] = reg + scratch_by_vreg[vreg] = reg + self._allocation_map.setdefault(vreg, reg) return reg # Materialize every distinct virtual source into its own scratch @@ -145,12 +171,12 @@ def scratch_for(vreg: str) -> str: reg = scratch_for(vreg) slot = self._get_spill_slot(vreg) self._emit(MachineInstr( - MachineOp.LW, - MachineOperand.reg(reg), + self._vreg_types[vreg].load_op, + self._physical(vreg, reg), MachineOperand.reg(f"{slot}({STACK_BASE})"), comment=f"reload {vreg} [regalloc:reload]", )) - resolved[position] = MachineOperand.reg(reg) + resolved[position] = self._physical(vreg, reg) # A destination never needs its old stack value unless the opcode # also marks that same virtual register as a use. @@ -159,15 +185,16 @@ def scratch_for(vreg: str) -> str: if operand is None or operand.kind != "vreg": continue vreg = str(operand.value) - resolved[position] = MachineOperand.reg(scratch_for(vreg)) + resolved[position] = self._physical(vreg, scratch_for(vreg)) self._emit(MachineInstr( instr.op, resolved[0], resolved[1], resolved[2], - instr.comment, - instr.target, + instr.comment, + instr.target, + instr.rounding_mode, )) stored: set[str] = set() @@ -182,8 +209,8 @@ def scratch_for(vreg: str) -> str: reg = scratch_by_vreg[vreg] slot = self._get_spill_slot(vreg) self._emit(MachineInstr( - MachineOp.SW, - MachineOperand.reg(reg), + self._vreg_types[vreg].store_op, + self._physical(vreg, reg), MachineOperand.reg(f"{slot}({STACK_BASE})"), comment=f"spill {vreg} [regalloc:spill]", )) @@ -202,9 +229,10 @@ def _allocate_greedy(self) -> list[MachineInstr]: self._output = [] self._vreg_map.clear() self._allocation_map.clear() - self._spill_slots.clear() + self._spill_slots.clear() + self._spill_layout = SpillLayout() self._next_spill = 0 - self._reg_pool = {r: None for r in ALL_REGS} + self._reg_pool = {r: None for r in self._all_regs} self._remaining_uses = {} self._clean_slots.clear() for instr in self.instructions: @@ -212,9 +240,9 @@ def _allocate_greedy(self) -> list[MachineInstr]: for vreg in uses: self._remaining_uses[vreg] = self._remaining_uses.get(vreg, 0) + 1 - # Values used before any definition are function live-ins. Reserve - # their entry registers up front so a later live-in cannot reuse a - # register whose earlier value is still supplied by the caller. + # Textual use-before-def can also mean a dominating definition in a + # later block layout. Only values with no definition anywhere are + # true live-ins whose entry registers must be reserved. defined: set[str] = set() live_ins: list[str] = [] seen_live_ins: set[str] = set() @@ -232,10 +260,18 @@ def _allocate_greedy(self) -> list[MachineInstr]: defs, _ = virtual_register_defs_uses(instr) defined.update(defs) for vreg in live_ins: - self._assign_reg(vreg) + if vreg not in defined: + self._assign_reg(vreg) self.cfg = build_cfg(MachineCFGAdapter("greedy", self.instructions)) self.liveness = analyze_liveness(self.cfg, MachineUseDefProvider()) + edge_live = set().union(*self.liveness.edge_live.values()) + # A predecessor encountered later will initialize this canonical + # slot at runtime. Reserve it now so an earlier-emitted successor + # reloads instead of inventing an uninitialized register live-in. + for vreg in live_ins: + if vreg in defined and vreg in edge_live: + self._get_spill_slot(vreg) live_after_by_instr: dict[int, frozenset[str]] = {} for node in self.cfg.nodes.values(): if isinstance(node.instructions, int): @@ -258,7 +294,7 @@ def _allocate_greedy(self) -> list[MachineInstr]: if instr.op == MachineOp.LABEL: if saw_executable_instruction: self._vreg_map.clear() - self._reg_pool = {r: None for r in ALL_REGS} + self._reg_pool = {r: None for r in self._all_regs} self._emit(instr) continue @@ -270,18 +306,18 @@ def _allocate_greedy(self) -> list[MachineInstr]: operands = [instr.dst, instr.src1, instr.src2] resolved = list(operands) explicit_uses = { - str(operands[position].value) + canonical_register_name(str(operands[position].value)) for position in semantics.uses if operands[position] is not None and operands[position].kind == "reg" - and operands[position].value in ALL_REGS + and canonical_register_name(str(operands[position].value)) in self._all_regs } explicit_defs = { - str(operands[position].value) + canonical_register_name(str(operands[position].value)) for position in semantics.defs if operands[position] is not None and operands[position].kind == "reg" - and operands[position].value in ALL_REGS + and canonical_register_name(str(operands[position].value)) in self._all_regs } for phys_reg in explicit_defs: owner = self._reg_pool[phys_reg] @@ -310,7 +346,7 @@ def _allocate_greedy(self) -> list[MachineInstr]: resolved[position] = self._resolve_src(operand, reserved) resolved_operand = resolved[position] if resolved_operand is not None and resolved_operand.kind == "reg" \ - and resolved_operand.value in ALL_REGS: + and resolved_operand.value in self._all_regs: reserved.add(str(resolved_operand.value)) reusable = { @@ -338,8 +374,9 @@ def _allocate_greedy(self) -> list[MachineInstr]: resolved[0], resolved[1], resolved[2], - instr.comment, - instr.target, + instr.comment, + instr.target, + instr.rounding_mode, ) # A call is not a CFG terminator, but it invalidates caller-saved @@ -398,13 +435,13 @@ def _resolve_src( if op.kind == "vreg": if op.value in self._vreg_map: r = self._vreg_map[op.value] # type: ignore[index] - return MachineOperand.reg(r) + return self._physical(str(op.value), r) v = op.value assert isinstance(v, str) reg = self._assign_reg( v, reload=v in self._spill_slots, avoid_regs=avoid_regs ) - return MachineOperand.reg(reg) + return self._physical(v, reg) return op def _resolve_dst( @@ -417,11 +454,11 @@ def _resolve_dst( if op.kind == "vreg": if op.value in self._vreg_map: r2 = self._vreg_map[op.value] # type: ignore[index] - return MachineOperand.reg(r2) + return self._physical(str(op.value), r2) v = op.value assert isinstance(v, str) reg = self._assign_reg(v, reload=False, avoid_regs=avoid_regs) - return MachineOperand.reg(reg) + return self._physical(v, reg) return op def _assign_reg( @@ -436,7 +473,9 @@ def _assign_reg( return self._vreg_map[vreg_name] avoid = avoid_regs or set() - for phys_reg, occupant in self._reg_pool.items(): + bank = self._registers_for(vreg_name) + for phys_reg in bank: + occupant = self._reg_pool[phys_reg] if occupant is None and phys_reg not in avoid: self._reg_pool[phys_reg] = vreg_name self._vreg_map[vreg_name] = phys_reg @@ -446,11 +485,11 @@ def _assign_reg( return phys_reg # Pick an unprotected victim whose next use is farthest away. - candidates = [reg for reg in ALL_REGS if reg not in avoid] + candidates = [reg for reg in bank if reg not in avoid] if not candidates: raise RuntimeError( "greedy regalloc: instruction needs more simultaneous " - f"register operands than the {len(ALL_REGS)}-register bank" + f"register operands than the {len(bank)}-register bank" ) def remaining(reg: str) -> int: @@ -514,8 +553,8 @@ def _emit_spill(self, vreg_name: str, phys_reg: str) -> None: slot = self._get_spill_slot(vreg_name) mem = f"{slot}({STACK_BASE})" self._emit(MachineInstr( - MachineOp.SW, - MachineOperand.reg(phys_reg), + self._vreg_types[vreg_name].store_op, + self._physical(vreg_name, phys_reg), MachineOperand.reg(mem), comment=f"spill {vreg_name} [regalloc:spill]", )) @@ -525,8 +564,8 @@ def _emit_reload(self, vreg_name: str, phys_reg: str) -> None: slot = self._get_spill_slot(vreg_name) mem = f"{slot}({STACK_BASE})" self._emit(MachineInstr( - MachineOp.LW, - MachineOperand.reg(phys_reg), + self._vreg_types[vreg_name].load_op, + self._physical(vreg_name, phys_reg), MachineOperand.reg(mem), comment=f"reload {vreg_name} [regalloc:reload]", )) @@ -534,8 +573,9 @@ def _emit_reload(self, vreg_name: str, phys_reg: str) -> None: def _get_spill_slot(self, vreg_name: str) -> int: if vreg_name not in self._spill_slots: - self._next_spill -= 4 - self._spill_slots[vreg_name] = self._next_spill + slot = self._spill_layout.reserve(vreg_name, self._vreg_types[vreg_name]) + self._spill_slots[vreg_name] = slot.offset + self._next_spill = -self._spill_layout.size return self._spill_slots[vreg_name] def _spill_operand(self, op: MachineOperand) -> MachineOperand: diff --git a/scratchv/backend/riscv_encoder.py b/scratchv/backend/riscv_encoder.py index 79c14d3e..39050a87 100644 --- a/scratchv/backend/riscv_encoder.py +++ b/scratchv/backend/riscv_encoder.py @@ -323,6 +323,7 @@ def _expand_pseudo(self, line: str) -> list[str]: # assembly that this encoder and its TinyFive path cannot execute. extension_pseudos = { "fabs.d": "D", + "fabs.s": "F", "fneg.d": "D", "li.d": "D", "fmv.s": "F", @@ -444,6 +445,13 @@ def _encode_line( op = tokens[0].lower() operands = tokens[1:] + if op.startswith("f") and op not in {"fence", "fence.i"}: + extension = "D" if op.endswith(".d") or ".d." in op or op in {"fld", "fsd"} else "F" + raise ValueError( + f"{op} requires the RISC-V {extension} extension; " + "RISCVAEncoder currently supports RV32IM only" + ) + fixup = None if op == "add": diff --git a/scratchv/backend/schedule_semantics.py b/scratchv/backend/schedule_semantics.py index be80dd21..693fb13d 100644 --- a/scratchv/backend/schedule_semantics.py +++ b/scratchv/backend/schedule_semantics.py @@ -278,9 +278,35 @@ class SchedInst: uses: frozenset[str] = frozenset() raw_line: str = "" region: int = 0 - target: str | None = field(default=None, kw_only=True) + target: str | None = None effects: InstructionEffects = field(init=False) + __match_args__ = ("id", "opcode", "operands", "defines", "uses", "raw_line", "region") + + def __init__( + self, + id: int, + opcode: str, + operands: tuple[str, ...] = (), + defines: frozenset[str] = frozenset(), + uses: frozenset[str] = frozenset(), + raw_line: str = "", + region: int = 0, + *, + target: str | None = None, + ) -> None: + # Python 3.8 has no keyword-only dataclass fields. Keep the public + # constructor's keyword-only target without changing frozen behavior. + object.__setattr__(self, "id", id) + object.__setattr__(self, "opcode", opcode) + object.__setattr__(self, "operands", operands) + object.__setattr__(self, "defines", defines) + object.__setattr__(self, "uses", uses) + object.__setattr__(self, "raw_line", raw_line) + object.__setattr__(self, "region", region) + object.__setattr__(self, "target", target) + self.__post_init__() + def __post_init__(self) -> None: object.__setattr__(self, "opcode", self.opcode.lower()) object.__setattr__(self, "operands", tuple(self.operands)) diff --git a/scratchv/backend/symbols.py b/scratchv/backend/symbols.py new file mode 100644 index 00000000..e5ab9906 --- /dev/null +++ b/scratchv/backend/symbols.py @@ -0,0 +1,44 @@ +"""The supported external function-symbol subset for RISC-V assembly.""" + +import re + + +# LLVM creates these section symbols even before an explicit section switch. +RESERVED_SECTION_SYMBOLS = frozenset({".text", ".data", ".bss", ".rodata"}) + + +def validate_label_symbol(name: str, section_symbols=()) -> None: + """Internal labels allow .L and $, but must be bare ASCII symbols.""" + if (not isinstance(name, str) + or not re.fullmatch(r"[A-Za-z_.$][A-Za-z0-9_.$]*", name) + or name == "." + or name in RESERVED_SECTION_SYMBOLS + or name in section_symbols): + raise ValueError(f"unsupported RISC-V label symbol: {name!r}") + + +def label_reference(name: str) -> str: + """Quote a leading dollar so LLVM parses a symbol expression, not a register.""" + validate_label_symbol(name) + return f'"{name}"' if name.startswith("$") else name + + +def validate_call_symbol(name: str) -> None: + """CALL requires a bare symbol in LLVM's RISC-V parser.""" + if (not isinstance(name, str) + or not re.fullmatch(r"(?:[A-Za-z_]|\.[A-Za-z_])[A-Za-z0-9_.]*", name) + or name in RESERVED_SECTION_SYMBOLS): + raise ValueError(f"unsupported RISC-V call symbol: {name!r}") + + +def validate_function_symbol(name: str) -> None: + # LLVM/GAS reserve .L* for assembler-local symbols; they cannot be exported + # with .globl even though the spelling is valid for an internal label. + if (not isinstance(name, str) + or not re.fullmatch(r"(?:[A-Za-z_]|\.[A-Za-z_])[A-Za-z0-9_.]*", name) + or name.startswith(".L") + or name in RESERVED_SECTION_SYMBOLS): + raise ValueError( + f"unsupported RISC-V function symbol: {name!r}; " + "use an ASCII identifier outside reserved sections and the .L local-symbol prefix" + ) diff --git a/scratchv/backend/target_features.py b/scratchv/backend/target_features.py new file mode 100644 index 00000000..04cfa5f4 --- /dev/null +++ b/scratchv/backend/target_features.py @@ -0,0 +1,38 @@ +"""ISA/ABI requirements for scalar RISC-V instruction selection.""" + +from dataclasses import dataclass +from typing import FrozenSet + +from scratchv.ir.types import DataType, OpCode + + +@dataclass(frozen=True) +class TargetFeatures: + isa: str = "rv32im" + abi: str = "ilp32" + + def __post_init__(self) -> None: + object.__setattr__(self, "isa", self.isa.lower()) + object.__setattr__(self, "abi", self.abi.lower()) + if (self.isa, self.abi) not in { + ("rv32im", "ilp32"), + ("rv32imf", "ilp32f"), + ("rv32imfd", "ilp32d"), + }: + raise ValueError( + f"unsupported RISC-V ISA/ABI combination: {self.isa} + {self.abi}" + ) + + @property + def extensions(self) -> FrozenSet[str]: + return frozenset(char.upper() for char in self.isa[4:]) + + def require_float(self, opcode: OpCode, dtype: DataType) -> None: + if dtype not in {DataType.FLOAT32, DataType.FLOAT64}: + raise ValueError(f"{opcode.value} requires an f32 or f64 dtype") + required = "D" if dtype is DataType.FLOAT64 else "F" + if required not in self.extensions: + raise ValueError( + f"{opcode.value} {dtype.value} requires the RISC-V {required} " + f"extension (target {self.isa}/{self.abi})" + ) diff --git a/scratchv/compiler.py b/scratchv/compiler.py index ffaa3a24..af55ff2c 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -55,11 +55,14 @@ class CompilerConfig: dump_ir: Print IR dumps during compilation. verify: Run ONNX Runtime / numpy verification. verify_ir: Validate shared IR at parse/pass/codegen boundaries. + target_isa: RISC-V ISA string used by extended selection. + target_abi: RISC-V ABI paired with target_isa. rtol: Relative tolerance for verification. atol: Absolute tolerance for verification. use_logger: Use structured logger instead of print(). log_level: Log level (DEBUG, INFO, WARNING, ERROR). use_dag_isel: Use DAG-based instruction selection. + use_extended_isel: Use the extended linear instruction selector. beautify_asm: Run assembly beautifier on output. peephole_asm: Run assembly-level peephole optimiser. const_merge: Run constant-load merge pass. @@ -80,6 +83,7 @@ class CompilerConfig: use_logger: bool = False log_level: str = "INFO" use_dag_isel: bool = False + use_extended_isel: bool = False beautify_asm: bool = False peephole_asm: bool = False const_merge: bool = False @@ -94,6 +98,8 @@ class CompilerConfig: passes: tuple[str, ...] | None = None disabled_passes: tuple[str, ...] = () verify_ir: bool = False + target_isa: str = "rv32im" + target_abi: str = "ilp32" # ═══════════════════════════════════════════════════════════════════════════════ @@ -164,6 +170,7 @@ class CompilerDriver: def __init__(self, config: CompilerConfig | None = None): self.config = config or CompilerConfig() self._last_register_map: dict[str, str] = {} + self._last_allocation_stats: dict[str, int] = {} self._assembly_report = OptimizationReport("assembly", (), 0, 0.0) # ── Public API ────────────────────────────────────────────────────────── @@ -206,12 +213,23 @@ def _compile( """ warnings: list[str] = [] self._last_register_map = {} + self._last_allocation_stats = {} self._assembly_report = OptimizationReport("assembly", (), 0, 0.0) if self.config.schedule and self.config.backend != "riscv": return CompileResult( success=False, errors=["--schedule requires the RISC-V backend"] ) + if self.config.use_extended_isel and self.config.backend != "riscv": + return CompileResult( + success=False, + errors=["--extended-isel requires the RISC-V backend"], + ) + if self.config.use_extended_isel and self.config.use_dag_isel: + return CompileResult( + success=False, + errors=["--extended-isel and --dag-isel are mutually exclusive"], + ) if ( self.config.schedule_strict or self.config.schedule_report ) and not self.config.schedule: @@ -252,7 +270,10 @@ def _compile( program = self._parse(input_path, dsl_source) except Exception as e: if use_dsl: - from scratchv.frontend.dsl_errors import DSLSyntaxError + from scratchv.frontend.dsl_errors import ( + DSLParseError, + DSLSyntaxError, + ) if isinstance(e, DSLSyntaxError): return CompileResult( @@ -260,12 +281,57 @@ def _compile( errors=[str(e)], diagnostics=[e], ) + if isinstance(e, DSLParseError): + return CompileResult( + success=False, + errors=[f"Parse error: {e}"], + ) raise return CompileResult( success=False, errors=[f"Parse error: {e}"], ) + if self.config.use_extended_isel: + from scratchv.backend.inst_select_ext import validate_extended_types + + try: + validate_extended_types(program) + except ValueError as exc: + return CompileResult(success=False, errors=[f"Codegen input error: {exc}"]) + else: + from scratchv.ir.types import OpCode + + extended_ops = { + OpCode.REM, OpCode.MIN, OpCode.MAX, OpCode.ABS, OpCode.SQRT, + } + used_extended_ops = sorted({ + instruction.opcode.value + for function in program.functions + for block in function.blocks + for instruction in block.instructions + if instruction.opcode in extended_ops + }) + if used_extended_ops: + return CompileResult( + success=False, + errors=[ + "--extended-isel is required for operations: " + + ", ".join(used_extended_ops) + ], + ) + + if self.config.backend == "llvm": + from scratchv.backend.llvm_codegen import validate_llvm_types + + # Check before optimization can fold away unsupported operations. + try: + validate_llvm_types(program) + except ValueError as exc: + return CompileResult( + success=False, errors=[f"Codegen input error: {exc}"], + ) + if self.config.verify_ir: self._check_ir(program, "after-parse") @@ -384,6 +450,7 @@ def _compile( "opt_message": opt_message, "cycle_report": cycle_report, "register_map": dict(self._last_register_map), + "allocation": dict(self._last_allocation_stats), "assembly": self._pass_stats(self._assembly_report), **schedule_stats, }, @@ -434,6 +501,22 @@ def _run_optimizations(self, program: Program) -> PipelineResult: passes=self.config.passes, disabled_passes=self.config.disabled_passes, ) + if self.config.use_extended_isel or self.config.backend == "llvm": + from scratchv.optimizer.constant_folding import ConstantFolder + from scratchv.optimizer.dead_code import DeadCodeEliminator + from scratchv.optimizer.licm import LICM + from scratchv.optimizer.muladd_fusion import MulAddFusion + from scratchv.optimizer.peephole import IRPeepholeOptimizer + + # Presets and explicit --passes pipelines share the same guard. + # Folding/peephole/DCE/LICM need the target IEEE rounding/fflags policy. + # LLVM also evaluates typed FP operations: host folding must not + # bypass binary32 operand rounding or change encoded constants. + # Fusion is now a registered no-op until a valid IR/lowering + # exists. Never relax verification to accept the old pseudo. + for pass_ in manager.passes: + if isinstance(pass_, (ConstantFolder, DeadCodeEliminator, IRPeepholeOptimizer, MulAddFusion, LICM)): + pass_.preserve_float_semantics = True manager.data_type = Program if self.config.verify_ir: manager.before_pass = lambda pass_, data: self._check_ir(data, f"before:{pass_.name}") @@ -485,11 +568,29 @@ def _generate_code(self, program) -> str: def _generate_riscv_linear(self, program) -> str: """Standard RISC-V pipeline.""" from scratchv.backend.asm_emit import AsmEmitter - from scratchv.backend.instruction_select import InstructionSelector from scratchv.backend.register_alloc import RegisterAllocator - selector = InstructionSelector(program) + constant_pool = () + if self.config.use_extended_isel: + from scratchv.backend.target_features import TargetFeatures + from scratchv.backend.inst_select_ext import ( + ExtendedInstructionSelector, + ) + + features = TargetFeatures( + isa=self.config.target_isa, + abi=self.config.target_abi, + ) + selector = ExtendedInstructionSelector( + program, features=features, + ) + else: + from scratchv.backend.instruction_select import InstructionSelector + + selector = InstructionSelector(program) machine_instrs = selector.run() + if self.config.use_extended_isel: + constant_pool = selector.constant_pool # Linear-scan path: allocate on *unallocated* MachineInstrs. # (Previously RegisterAllocator ran first with mode="linear", which @@ -502,11 +603,20 @@ def _generate_riscv_linear(self, program) -> str: ls_insts = block_from_machine_instrs(machine_instrs) lsa = LinearScanAllocator() - assembly = lsa.emit(ls_insts) + if self.config.use_extended_isel: + allocation = lsa.allocate_result(machine_instrs) + else: + lsa.emit(ls_insts) + allocation = lsa.allocation_result + assembly = AsmEmitter(allocation.instructions, constant_pool).emit() self._last_register_map = dict(lsa.alloc_map) + if self.config.use_extended_isel: + allocation.local_slots = dict(selector.local_slots) + allocation.local_bytes = selector.local_bytes from scratchv.backend.abi_frame import apply_abi_frames - return apply_abi_frames(assembly, lsa.spill_slot_count) + self._record_allocation_stats(allocation, machine_instrs) + return apply_abi_frames(assembly, allocation) mode = ( self.config.reg_alloc @@ -518,13 +628,34 @@ def _generate_riscv_linear(self, program) -> str: else "greedy" ) alloc = RegisterAllocator(machine_instrs, mode=mode) - allocated = alloc.run() + allocation = alloc.allocate_result() + if self.config.use_extended_isel: + allocation.local_slots = dict(selector.local_slots) + allocation.local_bytes = selector.local_bytes + allocated = allocation.instructions self._last_register_map = alloc.register_map - emitter = AsmEmitter(allocated) + emitter = AsmEmitter(allocated, constant_pool=constant_pool) assembly = emitter.emit() from scratchv.backend.abi_frame import apply_abi_frames - return apply_abi_frames(assembly, alloc.spill_slot_count) + self._record_allocation_stats(allocation, machine_instrs) + return apply_abi_frames(assembly, allocation) + + def _record_allocation_stats(self, allocation, selected_instructions) -> None: + from scratchv.backend.asm_emit import AsmEmitter + from scratchv.backend.machine_types import MachineOp + from scratchv.backend.regalloc_metrics import count_spill_reload_sites + + text = AsmEmitter(allocation.instructions).emit() + stores, loads = count_spill_reload_sites(text) + self._last_allocation_stats = { + "selected_instructions": sum(inst.op is not MachineOp.LABEL for inst in selected_instructions), + "allocated_instructions": sum(inst.op is not MachineOp.LABEL for inst in allocation.instructions), + "spill_bytes": allocation.spill_bytes, + "spill_slots": len(allocation.spill_slots), + "spill_stores": stores, + "reload_loads": loads, + } def _generate_riscv_dag(self, program) -> str: """DAG-based instruction selection pipeline.""" @@ -553,7 +684,8 @@ def _generate_riscv_dag(self, program) -> str: self._last_register_map = dict(lsa.alloc_map) from scratchv.backend.abi_frame import apply_abi_frames - return apply_abi_frames(assembly, lsa.spill_slot_count) + self._record_allocation_stats(lsa.allocation_result, machine_instrs) + return apply_abi_frames(assembly, lsa.allocation_result) mode = ( self.config.reg_alloc @@ -565,14 +697,16 @@ def _generate_riscv_dag(self, program) -> str: else "greedy" ) alloc = RegisterAllocator(machine_instrs, mode=mode) - allocated = alloc.run() + allocation = alloc.allocate_result() + allocated = allocation.instructions self._last_register_map = alloc.register_map emitter = AsmEmitter(allocated) assembly = emitter.emit() from scratchv.backend.abi_frame import apply_abi_frames - return apply_abi_frames(assembly, alloc.spill_slot_count) + self._record_allocation_stats(allocation, machine_instrs) + return apply_abi_frames(assembly, allocation) # ── Internal: post-codegen passes ─────────────────────────────────────── diff --git a/scratchv/frontend/dsl_extended.py b/scratchv/frontend/dsl_extended.py index 37996bff..e46f8065 100644 --- a/scratchv/frontend/dsl_extended.py +++ b/scratchv/frontend/dsl_extended.py @@ -22,14 +22,16 @@ from __future__ import annotations import re +from decimal import Decimal from typing import Optional # moved import above from scratchv.frontend.dsl_parser import DSLParser, DSLParseError from scratchv.frontend.dsl_errors import ErrorCollector from scratchv.frontend.dsl_validator import DSLValidator +from scratchv.frontend.dsl_lexical import CONDITION, NUMBER from scratchv.ir.builder import IRBuilder -from scratchv.ir.types import OpCode, Program, Value +from scratchv.ir.types import DataType, OpCode, Program, Value # --------------------------------------------------------------------------- @@ -48,20 +50,20 @@ def __init__(self, lhs: str, op: str, rhs: str): self.op = op.strip() self.rhs = rhs.strip() - def resolve(self, parser: ExtendedDSLParser) -> tuple[Value, str]: + def resolve(self, parser: ExtendedDSLParser) -> tuple[Value, str, Value]: """Resolve operands and return (lhs_val, operator, rhs_val).""" - lhs_number = _numeric_literal(self.lhs) - rhs_number = _numeric_literal(self.rhs) - if lhs_number is not None and rhs_number is None: + lhs_is_number = NUMBER.fullmatch(self.lhs) is not None + rhs_is_number = NUMBER.fullmatch(self.rhs) is not None + if lhs_is_number and not rhs_is_number: rhs_val = parser._resolve(self.rhs) lhs_val = parser.builder.load_const( - lhs_number, dtype=rhs_val.dtype, + _numeric_literal(self.lhs, rhs_val.dtype), dtype=rhs_val.dtype, ) else: lhs_val = parser._resolve(self.lhs) - if rhs_number is not None: + if rhs_is_number: rhs_val = parser.builder.load_const( - rhs_number, dtype=lhs_val.dtype, + _numeric_literal(self.rhs, lhs_val.dtype), dtype=lhs_val.dtype, ) else: rhs_val = parser._resolve(self.rhs) @@ -71,11 +73,18 @@ def __repr__(self) -> str: return f"CondExpr({self.lhs} {self.op} {self.rhs})" -def _numeric_literal(text: str) -> float | None: - try: - return float(text) - except ValueError: - return None +def _numeric_literal(text: str, dtype: DataType = DataType.FLOAT32) -> float | int: + """Parse a guard literal in its peer's type without float-to-int truncation.""" + if dtype is DataType.INT32: + # Decimal checks the exact source spelling, including exponents. Using + # int(float(text)) would silently truncate fractions or lose precision. + number = Decimal(text) + if not number.is_finite() or number != number.to_integral_value(): + raise DSLParseError(f"Invalid i32 condition literal: {text}") + if not -(1 << 31) <= number <= (1 << 31) - 1: + raise DSLParseError(f"i32 condition literal outside signed 32-bit range: {text}") + return int(number) + return float(text) # --------------------------------------------------------------------------- @@ -107,6 +116,7 @@ def __init__(self): self._while_stack: list[dict[str, str]] = [] self._slots: dict[str, Value] = {} self._initialized_slots: set[str] = set() + self._slot_types = {} # ----------------------------------------------------------------------- # Label generation @@ -169,6 +179,7 @@ def parse(self, text: str, *, filename: str | None = None) -> Program: self._while_stack = [] self._slots = {} self._initialized_slots = set() + self._slot_types = {} self.builder.new_function("main") self.builder.new_block("entry") @@ -369,12 +380,7 @@ def _parse_while_block(self, lines: list[str], start_idx: int) -> int: # Condition parsing # ----------------------------------------------------------------------- - _ATOM = r"(?:[^\W\d]\w*|[+-]?(?:\d+(?:\.\d*)?|\.\d+))" - _COND_PATTERN = re.compile( - rf"^(?:if|while)\s*\(\s*({_ATOM})\s*" - rf"(==|!=|<=|>=|<|>)\s*({_ATOM})\s*\)\s*:?\s*$", - re.UNICODE, - ) + _COND_PATTERN = CONDITION def _parse_condition(self, line: str) -> Optional[CondExpr]: """Attempt to parse a condition from if/while line. @@ -384,7 +390,7 @@ def _parse_condition(self, line: str) -> Optional[CondExpr]: m = self._COND_PATTERN.match(line) if not m: return None - return CondExpr(lhs=m.group(1), op=m.group(2), rhs=m.group(3)) + return CondExpr(lhs=m.group("lhs"), op=m.group("op"), rhs=m.group("rhs")) # ----------------------------------------------------------------------- # Comparison IR generation @@ -430,6 +436,10 @@ def _parse_line(self, line: str) -> None: slot = self._slots.get(name) value = self._vars.get(name) if slot is not None and value is not None: + previous = self._slot_types.setdefault(name, value.dtype) + if previous != value.dtype: + raise DSLParseError(f"mutable variable {name} cannot change dtype") + slot.dtype = value.dtype self.builder.store(slot, value) self._initialized_slots.add(name) diff --git a/scratchv/frontend/dsl_lexical.py b/scratchv/frontend/dsl_lexical.py new file mode 100644 index 00000000..f9622566 --- /dev/null +++ b/scratchv/frontend/dsl_lexical.py @@ -0,0 +1,31 @@ +"""One lexical contract for DSL validation and condition parsing.""" + +from __future__ import annotations + +import re + + +DECIMAL = r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?" +NUMBER = re.compile(rf"^[+-]?(?:{DECIMAL}|inf|nan)$", re.IGNORECASE) +INTEGER = re.compile(r"^[+-]?\d+$") +CONDITION_ATOM = rf"(?:[^\W\d]\w*|[+-]?{DECIMAL})" +CONDITION = re.compile( + rf"^(?Pif|while)\s*\(\s*(?P{CONDITION_ATOM})\s*" + rf"(?P==|!=|<=|>=|<|>)\s*(?P{CONDITION_ATOM})\s*\)\s*:?\s*$", + re.UNICODE, +) + + +def parse_i32_constant(text: str) -> int: + """Parse the typed const contract, without truncation or silent wrapping.""" + if INTEGER.fullmatch(text) is None: + raise ValueError("i32 constant requires an integer value") + # Remove leading zeros before int(): even an extremely long source + # spelling should produce our range diagnostic, not Python's digit limit. + digits = text.lstrip("+-").lstrip("0") or "0" + if len(digits) > 10: + raise ValueError("i32 constant outside signed 32-bit range") + value = int(digits) * (-1 if text.startswith("-") else 1) + if not -(1 << 31) <= value <= (1 << 31) - 1: + raise ValueError("i32 constant outside signed 32-bit range") + return value diff --git a/scratchv/frontend/dsl_parser.py b/scratchv/frontend/dsl_parser.py index 3d57e9cc..009bafc0 100644 --- a/scratchv/frontend/dsl_parser.py +++ b/scratchv/frontend/dsl_parser.py @@ -4,6 +4,12 @@ # comment name = add(a, b) name = mul(a, b) + name = const(1, dtype:i32) + name = rem(a, b) # mod(a, b) is an alias + name = min(a, b) + name = max(a, b) + name = abs(a) + name = sqrt(a) name = relu(x) name = matmul(a, b, rows:m, cols:n, inner:k) name = dot(a, b, len:N) @@ -21,9 +27,10 @@ import re from scratchv.ir.builder import IRBuilder -from scratchv.ir.types import Value, Program +from scratchv.ir.types import DataType, Value, Program from scratchv.frontend.dsl_errors import DSLParseError, ErrorCollector from scratchv.frontend.dsl_validator import DSLValidator +from scratchv.frontend.dsl_lexical import parse_i32_constant class DSLParser: @@ -45,8 +52,9 @@ def validate( @staticmethod def supported_operations() -> set[str]: return { - "add", "sub", "mul", "div", "neg", "exp", "relu", "gelu", - "dot", "matmul", "softmax", "maxpool", + "const", "add", "sub", "mul", "div", "rem", "mod", "min", + "max", "abs", "neg", "exp", "relu", "gelu", "dot", "matmul", + "sqrt", "softmax", "maxpool", } def parse(self, text: str, *, filename: str | None = None) -> Program: @@ -110,7 +118,12 @@ def _parse_line(self, line: str) -> None: args_text = m.group(3) args = [a.strip() for a in args_text.split(",") if a.strip()] - result = self._dispatch_op(op_name, args) + try: + result = self._dispatch_op(op_name, args) + except DSLParseError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise DSLParseError(str(exc)) from exc self._vars[dest_name] = result def _resolve(self, name: str) -> Value: @@ -152,13 +165,40 @@ def _parse_value(self, s: str) -> int | float | str: def _dispatch_op(self, op: str, args: list[str]) -> Value: plain, kwargs = self._parse_kwargs(args) + + if op == "const": + dtype_name = kwargs.get("dtype", "f32") + dtype = { + "i32": DataType.INT32, + "f32": DataType.FLOAT32, + "f64": DataType.FLOAT64, + }[dtype_name] + try: + value = ( + parse_i32_constant(plain[0]) + if dtype is DataType.INT32 + else float(plain[0]) + ) + except ValueError as exc: + raise DSLParseError( + f"Invalid {dtype.value} constant: {exc} ({plain[0]})" + ) from exc + return self.builder.load_const(value, dtype=dtype) + resolved = [self._resolve(a) for a in plain] handlers = { + "const": lambda: self.builder.load_const(0.0), # handled above "add": lambda: self.builder.add(resolved[0], resolved[1]), "sub": lambda: self.builder.sub(resolved[0], resolved[1]), "mul": lambda: self.builder.mul(resolved[0], resolved[1]), "div": lambda: self.builder.div(resolved[0], resolved[1]), + "rem": lambda: self.builder.rem(resolved[0], resolved[1]), + "mod": lambda: self.builder.rem(resolved[0], resolved[1]), + "min": lambda: self.builder.min(resolved[0], resolved[1]), + "max": lambda: self.builder.max(resolved[0], resolved[1]), + "abs": lambda: self.builder.abs(resolved[0]), + "sqrt": lambda: self.builder.sqrt(resolved[0]), "neg": lambda: self.builder.neg(resolved[0]), "exp": lambda: self.builder.exp(resolved[0]), "relu": lambda: self.builder.relu(resolved[0]), diff --git a/scratchv/frontend/dsl_validator.py b/scratchv/frontend/dsl_validator.py index ca0db586..22cf0554 100644 --- a/scratchv/frontend/dsl_validator.py +++ b/scratchv/frontend/dsl_validator.py @@ -7,6 +7,10 @@ from typing import Literal, Optional from scratchv.frontend.dsl_errors import ErrorCollector +from scratchv.frontend.dsl_lexical import ( + CONDITION as _CONDITION, NUMBER as _NUMBER, + parse_i32_constant, +) @dataclass(frozen=True) @@ -39,10 +43,17 @@ class OpSignature: OP_SIGNATURES: dict[str, OpSignature] = { + "const": OpSignature(1, frozenset({"dtype"})), "add": OpSignature(2), "sub": OpSignature(2), "mul": OpSignature(2), "div": OpSignature(2), + "rem": OpSignature(2), + "mod": OpSignature(2), + "min": OpSignature(2), + "max": OpSignature(2), + "abs": OpSignature(1), + "sqrt": OpSignature(1), "neg": OpSignature(1), "exp": OpSignature(1), "relu": OpSignature(1), @@ -64,13 +75,6 @@ class OpSignature: r"^for\s+(?P[^\s=]+)\s*=\s*(?P\d+)\s*,\s*" r"(?P\d+)\s*$", ) -_NUMBER = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$") -_CONDITION_ATOM = r"(?:[^\W\d]\w*|[+-]?(?:\d+(?:\.\d*)?|\.\d+))" -_CONDITION = re.compile( - rf"^(?Pif|while)\s*\(\s*{_CONDITION_ATOM}\s*" - rf"(?:==|!=|<=|>=|<|>)\s*{_CONDITION_ATOM}\s*\)\s*:?\s*$", - re.UNICODE, -) @dataclass @@ -259,6 +263,27 @@ def _validate_assignment( if missing: name = sorted(missing)[0] self._add(collector, line, args_col, raw, "E202", f"missing keyword argument '{name}'") + return + if op == "const": + if not positional or _NUMBER.fullmatch(positional[0]) is None: + self._add( + collector, line, args_col, raw, "E203", + "constant value must be numeric", + ) + return + dtype = kwargs.get("dtype", "f32") + if dtype not in {"i32", "f32", "f64"}: + dtype_col = raw.find(dtype, args_col - 1) + 1 + self._add( + collector, line, dtype_col, raw, "E203", + "'dtype' must be one of i32, f32, f64", + ) + elif dtype == "i32": + try: + parse_i32_constant(positional[0]) + except ValueError as exc: + value_col = raw.find(positional[0], args_col - 1) + 1 + self._add(collector, line, value_col, raw, "E203", str(exc)) def _close_block( self, closer: str, line: int, col: int, raw: str, diff --git a/scratchv/frontend/onnx_bits.py b/scratchv/frontend/onnx_bits.py new file mode 100644 index 00000000..74c41e8a --- /dev/null +++ b/scratchv/frontend/onnx_bits.py @@ -0,0 +1,121 @@ +"""Read typed initializer encodings before protobuf/Python float conversion. + +Only the ModelProto -> GraphProto -> TensorProto path is inspected here. +ONNX remains responsible for parsing the model and loading external data. +Wire format: https://protobuf.dev/programming-guides/encoding/ +""" + +from pathlib import Path +from typing import Dict, Tuple + + +def _varint(data: bytes, position: int) -> Tuple[int, int]: + value = 0 + for shift in range(0, 70, 7): + if position >= len(data): + raise ValueError("truncated ONNX protobuf varint") + byte = data[position] + position += 1 + value |= (byte & 127) << shift + if not byte & 128: + return value, position + raise ValueError("invalid ONNX protobuf varint") + + +def _field(data: bytes, position: int): + tag, position = _varint(data, position) + number, wire = tag >> 3, tag & 7 + start = position + if number == 0: + raise ValueError("invalid ONNX protobuf field number") + if wire == 0: + _, position = _varint(data, position) + elif wire in (1, 5): + position += 8 if wire == 1 else 4 + elif wire == 2: + size, start = _varint(data, position) + position = start + size + elif wire == 3: + # Unknown legacy groups remain legal protobuf fields. Skip them, + # including nested groups, without interpreting their numeric values. + while True: + child, child_wire, _, position = _field(data, position) + if child_wire == 4: + if child != number: + raise ValueError("mismatched ONNX protobuf group") + break + elif wire != 4: + raise ValueError(f"unsupported ONNX protobuf wire type: {wire}") + if position > len(data): + raise ValueError("truncated ONNX protobuf field") + return number, wire, data[start:position], position + + +def _fields(data: bytes): + """Yield field number, wire type and untouched payload bytes.""" + position = 0 + while position < len(data): + number, wire, payload, position = _field(data, position) + if wire == 4: + raise ValueError("unexpected ONNX protobuf end group") + yield number, wire, payload + + +def _int32(payload: bytes) -> int: + value, _ = _varint(payload, 0) + value &= 0xffffffff + return value - (1 << 32) if value & (1 << 31) else value + + +def typed_initializer_bits(model_path: str, onnx) -> Dict[Tuple[str, int], int]: + """Preserve packed/unpacked float_data and double_data scalar encodings. + + Reading the original file also avoids the pure-Python protobuf decoder's + float promotion. A serialized Python TensorProto is already too late. + raw_data/external_data continue through ONNX's byte-preserving loader. + """ + model_fields = onnx.ModelProto.DESCRIPTOR.fields_by_name + graph_fields = onnx.GraphProto.DESCRIPTOR.fields_by_name + tensor_fields = onnx.TensorProto.DESCRIPTOR.fields_by_name + result = {} + for number, wire, graph in _fields(Path(model_path).read_bytes()): + if number != model_fields["graph"].number or wire != 2: + continue + for number, wire, tensor in _fields(graph): + if number != graph_fields["initializer"].number or wire != 2: + continue + fields = list(_fields(tensor)) + # Only correctly typed wire fields participate in protobuf decoding. + # Singular strings/scalars use their final recognized occurrence. + name = "" + dtype = None + location = onnx.TensorProto.DEFAULT + has_raw = False + for number, wire, payload in fields: + if number == tensor_fields["name"].number and wire == 2: + name = payload.decode("utf-8") + elif number == tensor_fields["data_type"].number and wire == 0: + dtype = _int32(payload) + elif number == tensor_fields["data_location"].number and wire == 0: + value = _int32(payload) + # Unknown proto2 enum values are unknown fields, so do not + # overwrite the previous recognized data_location. + if value in (onnx.TensorProto.DEFAULT, onnx.TensorProto.EXTERNAL): + location = value + elif number == tensor_fields["raw_data"].number and wire == 2: + has_raw = True + # external_data is metadata; DEFAULT keeps using inline storage. + if has_raw or location == onnx.TensorProto.EXTERNAL: + continue + for field, field_dtype, size, fixed_wire in ( + ("float_data", onnx.TensorProto.FLOAT, 4, 5), + ("double_data", onnx.TensorProto.DOUBLE, 8, 1), + ): + if dtype != field_dtype: + continue + payload = b"".join(payload for number, wire, payload in fields + if number == tensor_fields[field].number + and wire in (2, fixed_wire)) + if len(payload) == size: + result[name, dtype] = int.from_bytes(payload, "little") + return result diff --git a/scratchv/frontend/onnx_parser.py b/scratchv/frontend/onnx_parser.py index b6af645a..003cee70 100644 --- a/scratchv/frontend/onnx_parser.py +++ b/scratchv/frontend/onnx_parser.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import replace + from scratchv.ir.types import DataType, Value, Program from scratchv.ir.builder import IRBuilder @@ -20,6 +22,7 @@ class ONNXParser: def __init__(self): self.builder = IRBuilder() self._value_map: dict[str, Value] = {} + self._value_shapes: dict[str, tuple[int, ...]] = {} def parse(self, model_path: str) -> Program: """Parse an ONNX model file and return an IR Program.""" @@ -32,7 +35,26 @@ def parse(self, model_path: str) -> Program: ) model = onnx.load(model_path) + from scratchv.frontend.onnx_bits import typed_initializer_bits + + typed_bits = typed_initializer_bits(model_path, onnx) graph = model.graph + self.builder = IRBuilder() + self._value_map = {} + # Inputs may be registered after initializer temporaries are emitted. + # Reserve the entire graph, including future outputs, up front. + self.builder.reserve_names( + value.name for value in [*graph.input, *graph.value_info, + *graph.output, *graph.initializer] + ) + self.builder.reserve_names( + name for node in graph.node for name in [*node.input, *node.output] + if name + ) + self._value_shapes = { + value.name: self._shape(value) + for value in [*graph.input, *graph.value_info, *graph.output] + } # Create IR function from ONNX graph func_name = graph.name or "main" @@ -42,20 +64,33 @@ def parse(self, model_path: str) -> Program: # Map ONNX initializers (constants) to IR values for init in graph.initializer: arr = onnx.numpy_helper.to_array(init) - dtype = DataType.from_onnx(init.data_type) + try: + dtype = DataType.from_onnx(init.data_type) + except ValueError as exc: + raise ONNXParseError(str(exc)) from exc val = self.builder.make_value(name=init.name, dtype=dtype) if arr.size == 1: + scalar = arr.item() + raw_bits = None + if dtype in (DataType.FLOAT32, DataType.FLOAT64): + raw_bits = typed_bits.get((init.name, init.data_type)) + if raw_bits is None: + # raw/external storage is loaded directly from bytes. + raw_bits = int.from_bytes(arr.astype( + " Program: for inp in graph.input: if inp.name in self._value_map: continue # already defined as initializer - dtype = DataType.FLOAT32 - if inp.type.tensor_type.elem_type: + try: dtype = DataType.from_onnx(inp.type.tensor_type.elem_type) + except ValueError as exc: + raise ONNXParseError(str(exc)) from exc val = self.builder.make_value(name=inp.name, dtype=dtype) # Infer shape from ONNX type - shape_dims = list(inp.type.tensor_type.shape.dim) - val.shape = tuple(d.dim_value for d in shape_dims) + val.shape = self._shape(inp) func.params.append(val) self._value_map[inp.name] = val @@ -86,13 +121,27 @@ def parse(self, model_path: str) -> Program: # Add return if we have outputs for o in graph.output: if o.name in self._value_map: - self.builder.ret(self._value_map[o.name]) + value = self._value_map[o.name] + if o.name in self._value_shapes and value.shape != self._value_shapes[o.name]: + # Preserve the declaration even for direct global returns, + # but do not overwrite the source's actual storage shape. + value = replace(value, shape=self._value_shapes[o.name]) + self.builder.ret(value) break else: self.builder.ret() return self.builder.program + @staticmethod + def _shape(value_info) -> tuple[int, ...]: + """Keep declared shapes, including unknown dimensions/rank, in IR.""" + tensor_type = value_info.type.tensor_type + if not tensor_type.HasField("shape"): + return (0,) # Unknown rank is not evidence of a scalar. + return tuple(d.dim_value if d.HasField("dim_value") else 0 + for d in tensor_type.shape.dim) + def _translate_node(self, node, output_names: set[str]) -> None: """Translate a single ONNX node to IR instructions.""" op_type = node.op_type @@ -117,6 +166,14 @@ def _define_outputs(self, outputs: list[str], if value is None: value = self.builder.make_value() for name in outputs: + if name in self._value_shapes: + shape = self._value_shapes[name] + if value.shape != shape and any(v is value for v in self._value_map.values()): + # Single-input Min/Max can alias an initializer/parameter. + # Its output annotation must not conceal an array source. + value = replace(value, shape=shape) + else: + value.shape = shape self._value_map[name] = value return value @@ -125,29 +182,15 @@ def _define_outputs(self, outputs: list[str], def _handle_add(self, node, inputs: list[Value], outputs: list[str]) -> None: a, b = inputs[0], inputs[1] - if a.is_constant and b.is_constant: - assert a.const_value is not None - assert b.const_value is not None - result = self.builder.make_value( - is_constant=True, - const_value=a.const_value + b.const_value, - ) - else: - result = self.builder.add(a, b) + # Emit a definition and let the typed folder apply target semantics. + # Host integer folding here would miss RV32 intermediate wraparound. + result = self.builder.add(a, b) self._define_outputs(outputs, result) def _handle_mul(self, node, inputs: list[Value], outputs: list[str]) -> None: a, b = inputs[0], inputs[1] - if a.is_constant and b.is_constant: - assert a.const_value is not None - assert b.const_value is not None - result = self.builder.make_value( - is_constant=True, - const_value=a.const_value * b.const_value, - ) - else: - result = self.builder.mul(a, b) + result = self.builder.mul(a, b) self._define_outputs(outputs, result) def _handle_sub(self, node, inputs: list[Value], @@ -212,6 +255,40 @@ def _handle_exp(self, node, inputs: list[Value], result = self.builder.exp(inputs[0]) self._define_outputs(outputs, result) + def _handle_sqrt(self, node, inputs: list[Value], + outputs: list[str]) -> None: + result = self.builder.sqrt(inputs[0]) + self._define_outputs(outputs, result) + + def _handle_min(self, node, inputs: list[Value], + outputs: list[str]) -> None: + self._reduce_min_max(inputs, outputs, "min") + + def _handle_max(self, node, inputs: list[Value], + outputs: list[str]) -> None: + self._reduce_min_max(inputs, outputs, "max") + + def _reduce_min_max(self, inputs: list[Value], outputs: list[str], + operator: str) -> None: + """Lower every variadic operand; a single input is the identity.""" + if not inputs: + raise ONNXParseError( + f"ONNX {operator.title()} requires at least one input" + ) + result = inputs[0] + reduce_pair = getattr(self.builder, operator) + for value in inputs[1:]: + result = reduce_pair( + result, value, + propagate_nan=result.dtype in (DataType.FLOAT32, DataType.FLOAT64), + ) + self._define_outputs(outputs, result) + + def _handle_abs(self, node, inputs: list[Value], + outputs: list[str]) -> None: + result = self.builder.abs(inputs[0]) + self._define_outputs(outputs, result) + def _handle_conv(self, node, inputs: list[Value], outputs: list[str]) -> None: x, w, b = inputs[0], inputs[1], inputs[2] diff --git a/scratchv/ir/builder.py b/scratchv/ir/builder.py index e49808de..c0ab656d 100644 --- a/scratchv/ir/builder.py +++ b/scratchv/ir/builder.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Iterable + from scratchv.ir.types import ( OpCode, DataType, @@ -10,9 +12,18 @@ BasicBlock, Function, Program, + valid_float_bit_pattern, ) +_MIN_MAX_ABS_TYPES = frozenset({ + DataType.INT32, + DataType.FLOAT32, + DataType.FLOAT64, +}) +_FLOAT_TYPES = frozenset({DataType.FLOAT32, DataType.FLOAT64}) + + class IRBuilder: """Tracks current function, block, and unique name counter.""" @@ -21,10 +32,19 @@ def __init__(self): self.current_func: Function | None = None self.current_block: BasicBlock | None = None self._name_counter = 0 + self._reserved_names: set[str] = set() + + def reserve_names(self, names: Iterable[str]) -> None: + """Reserve external names before generating any internal values.""" + self._reserved_names.update(names) def _fresh(self, prefix: str = "v") -> str: - self._name_counter += 1 - return f"{prefix}_{self._name_counter}" + while True: + self._name_counter += 1 + name = f"{prefix}_{self._name_counter}" + if name not in self._reserved_names: + self._reserved_names.add(name) + return name def _emit(self, opcode: OpCode, dest: Value | None = None, operands: list[Value] | None = None, @@ -36,6 +56,9 @@ def _emit(self, opcode: OpCode, dest: Value | None = None, operands=operands or [], attrs=attrs, target=target, ) + self.reserve_names(value.name for value in instr.operands) + if dest is not None: + self.reserve_names([dest.name]) if self.current_block is not None: self.current_block.add(instr) return instr @@ -47,6 +70,7 @@ def new_function( params: list[Value] | None = None, ) -> Function: func = Function(name=name, params=params or []) + self.reserve_names(value.name for value in func.params) self.program.add_function(func) self.current_func = func return func @@ -63,7 +87,9 @@ def make_value(self, name: str | None = None, dtype: DataType = DataType.FLOAT32, is_constant: bool = False, const_value: float | int | None = None) -> Value: - return Value(name=name or self._fresh(), dtype=dtype, + name = name or self._fresh() + self.reserve_names([name]) + return Value(name=name, dtype=dtype, is_constant=is_constant, const_value=const_value) def make_const( @@ -76,46 +102,110 @@ def make_const( # --- Instructions --- - def add(self, lhs: Value, rhs: Value) -> Value: - dest = self.make_value() - self._emit(OpCode.ADD, dest, [lhs, rhs]) + def _binary_arith( + self, opcode: OpCode, lhs: Value, rhs: Value, + supported_types: frozenset[DataType] | None = None, + **attrs, + ) -> Value: + if lhs.dtype != rhs.dtype: + raise ValueError( + f"{opcode.value} operands must have the same type" + ) + if supported_types is not None and lhs.dtype not in supported_types: + raise ValueError( + f"{opcode.value} does not support {lhs.dtype.value}" + ) + self.reserve_names([lhs.name, rhs.name]) + dest = self.make_value(dtype=lhs.dtype) + self._emit(opcode, dest, [lhs, rhs], **attrs) return dest - def sub(self, lhs: Value, rhs: Value) -> Value: - dest = self.make_value() - self._emit(OpCode.SUB, dest, [lhs, rhs]) + def _unary_arith( + self, opcode: OpCode, val: Value, + supported_types: frozenset[DataType] | None = None, + ) -> Value: + if supported_types is not None and val.dtype not in supported_types: + raise ValueError( + f"{opcode.value} does not support {val.dtype.value}" + ) + self.reserve_names([val.name]) + dest = self.make_value(dtype=val.dtype) + self._emit(opcode, dest, [val]) return dest + def add(self, lhs: Value, rhs: Value) -> Value: + return self._binary_arith(OpCode.ADD, lhs, rhs) + + def sub(self, lhs: Value, rhs: Value) -> Value: + return self._binary_arith(OpCode.SUB, lhs, rhs) + def mul(self, lhs: Value, rhs: Value) -> Value: - dest = self.make_value() - self._emit(OpCode.MUL, dest, [lhs, rhs]) - return dest + return self._binary_arith(OpCode.MUL, lhs, rhs) def div(self, lhs: Value, rhs: Value) -> Value: - dest = self.make_value() - self._emit(OpCode.DIV, dest, [lhs, rhs]) - return dest + return self._binary_arith(OpCode.DIV, lhs, rhs) + + def rem(self, lhs: Value, rhs: Value) -> Value: + return self._binary_arith( + OpCode.REM, lhs, rhs, frozenset({DataType.INT32}), + ) + + def min(self, lhs: Value, rhs: Value, *, propagate_nan: bool = False) -> Value: + """Minimum; ONNX requests NaN propagation, DSL selects the number.""" + return self._binary_arith( + OpCode.MIN, lhs, rhs, _MIN_MAX_ABS_TYPES, + **({"propagate_nan": True} if propagate_nan else {}), + ) + + def max(self, lhs: Value, rhs: Value, *, propagate_nan: bool = False) -> Value: + """Maximum; ONNX requests NaN propagation, DSL selects the number.""" + return self._binary_arith( + OpCode.MAX, lhs, rhs, _MIN_MAX_ABS_TYPES, + **({"propagate_nan": True} if propagate_nan else {}), + ) def neg(self, val: Value) -> Value: - dest = self.make_value() - self._emit(OpCode.NEG, dest, [val]) - return dest + return self._unary_arith(OpCode.NEG, val) + + def abs(self, val: Value) -> Value: + return self._unary_arith( + OpCode.ABS, val, _MIN_MAX_ABS_TYPES, + ) + + def sqrt(self, val: Value) -> Value: + return self._unary_arith(OpCode.SQRT, val, _FLOAT_TYPES) def exp(self, val: Value) -> Value: - dest = self.make_value() - self._emit(OpCode.EXP, dest, [val]) - return dest + return self._unary_arith( + OpCode.EXP, val, + _FLOAT_TYPES, + ) def load_const( self, val: float | int, dtype: DataType = DataType.FLOAT32, + *, bit_pattern: int | None = None, ) -> Value: + if bit_pattern is not None and not valid_float_bit_pattern(dtype, bit_pattern): + raise ValueError("bit_pattern requires a valid f32/f64 encoding") dest = self.make_value(dtype=dtype, is_constant=True, const_value=val) - self._emit(OpCode.LOAD_CONST, dest, value=val) + dest.bit_pattern = bit_pattern + attrs = {"value": val} + if bit_pattern is not None: + attrs["bit_pattern"] = bit_pattern + self._emit(OpCode.LOAD_CONST, dest, **attrs) return dest - def load(self, ptr: Value) -> Value: - dest = self.make_value() + def load( + self, ptr: Value, dtype: DataType | None = None, + ) -> Value: + """Load a value, explicitly preserving its element dtype. + + The current IR has no pointer type, so an omitted dtype inherits the + element dtype carried by the address-producing value. + """ + self.reserve_names([ptr.name]) + dest = self.make_value(dtype=dtype or ptr.dtype) self._emit(OpCode.LOAD, dest, [ptr]) return dest @@ -170,7 +260,7 @@ def ret(self, val: Value | None = None) -> Instruction: return self._emit(OpCode.RETURN, operands=operands) def relu(self, val: Value) -> Value: - dest = self.make_value() + dest = self.make_value(dtype=val.dtype) self._emit(OpCode.RELU, dest, [val]) return dest diff --git a/scratchv/ir/types.py b/scratchv/ir/types.py index 059f73d2..3b5c732c 100644 --- a/scratchv/ir/types.py +++ b/scratchv/ir/types.py @@ -19,8 +19,13 @@ class OpCode(enum.Enum): SUB = "sub" MUL = "mul" DIV = "div" + REM = "rem" + MIN = "min" + MAX = "max" # Arithmetic (unary) NEG = "neg" + ABS = "abs" + SQRT = "sqrt" EXP = "exp" # Memory / data LOAD = "load" @@ -50,7 +55,18 @@ class OpCode(enum.Enum): CONCAT = "concat" def is_arith(self) -> bool: - return self in (OpCode.ADD, OpCode.SUB, OpCode.MUL, OpCode.DIV) + return self in ( + OpCode.ADD, + OpCode.SUB, + OpCode.MUL, + OpCode.DIV, + OpCode.REM, + OpCode.MIN, + OpCode.MAX, + OpCode.NEG, + OpCode.ABS, + OpCode.SQRT, + ) def is_nn(self) -> bool: return self in ( @@ -85,7 +101,10 @@ def from_onnx(elem_type: int) -> DataType: 1: DataType.FLOAT32, 6: DataType.INT32, 7: DataType.INT64, 11: DataType.FLOAT64, } - return mapping.get(elem_type, DataType.FLOAT32) + try: + return mapping[elem_type] + except KeyError as exc: + raise ValueError(f"unsupported ONNX element type: {elem_type}") from exc @dataclass @@ -96,6 +115,16 @@ class Value: is_constant: bool = False const_value: Optional[Union[float, int]] = None shape: tuple[int, ...] = () + # Optional source encoding for FP constants; authoritative over host float + # conversion, which can quiet a binary32 signaling NaN. + bit_pattern: Optional[int] = None + + +def valid_float_bit_pattern(dtype: DataType, bits: object) -> bool: + """Whether an explicit FP storage encoding fits the value's precision.""" + width = 32 if dtype is DataType.FLOAT32 else 64 if dtype is DataType.FLOAT64 else None + return (width is not None and isinstance(bits, int) and not isinstance(bits, bool) + and 0 <= bits < (1 << width)) @dataclass diff --git a/scratchv/main.py b/scratchv/main.py index d1b831a6..8fcca2a5 100644 --- a/scratchv/main.py +++ b/scratchv/main.py @@ -149,7 +149,19 @@ def build_arg_parser() -> argparse.ArgumentParser: parser.add_argument( "--extended-isel", action="store_true", - help="Use extended instruction selector with fp64/sqrt/min/max/abs support (Topic 28)", + help="Use the extended instruction selector with fp64/sqrt/min/max/abs support", + ) + parser.add_argument( + "--target-isa", + choices=["rv32im", "rv32imf", "rv32imfd"], + default="rv32im", + help="RISC-V ISA for extended selection (default: rv32im)", + ) + parser.add_argument( + "--target-abi", + choices=["ilp32", "ilp32f", "ilp32d"], + default="ilp32", + help="RISC-V ABI for extended selection (default: ilp32)", ) # ── Cycle estimation ────────────────────────────────────────────── @@ -200,6 +212,9 @@ def args_to_config(args: argparse.Namespace) -> CompilerConfig: use_logger=args.log_level is not None, log_level=args.log_level or "INFO", use_dag_isel=args.dag_isel, + use_extended_isel=args.extended_isel, + target_isa=args.target_isa, + target_abi=args.target_abi, beautify_asm=args.beautify, peephole_asm=args.peephole_asm, const_merge=args.const_merge, diff --git a/scratchv/optimizer/constant_folding.py b/scratchv/optimizer/constant_folding.py index 197ab781..8e462208 100644 --- a/scratchv/optimizer/constant_folding.py +++ b/scratchv/optimizer/constant_folding.py @@ -7,16 +7,26 @@ from __future__ import annotations from scratchv.ir.types import ( - OpCode, Instruction, BasicBlock, Function, Program, + DataType, OpCode, Instruction, BasicBlock, Function, Program, ) from scratchv.pass_interface import OptimizationPass class ConstantFolder(OptimizationPass): - """Fold constant expressions in an IR Program.""" + """Fold constant expressions in an IR Program. + + ``preserve_float_semantics=True`` is required for real typed hard-float + targets: Python arithmetic does not model binary32 operand quantization, + target rounding modes, or fflags. The default retains the legacy stub + pipeline's historical behavior; CompilerDriver enables the safe policy + for extended instruction selection and LLVM, including explicit passes. + """ name = "constant-folding" + def __init__(self, *, preserve_float_semantics: bool = False): + self.preserve_float_semantics = preserve_float_semantics + def optimize(self, program: Program) -> int: """Run constant folding on all functions. Returns number of folds.""" return sum(self._fold_function(func) for func in program.functions) @@ -41,19 +51,50 @@ def _try_fold(self, instr: Instruction) -> Instruction | None: """Try to fold an instruction. Returns a replacement or None.""" if instr.opcode not in ( OpCode.ADD, OpCode.SUB, - OpCode.MUL, OpCode.DIV): + OpCode.MUL, OpCode.DIV, OpCode.REM, + OpCode.MIN, OpCode.MAX, OpCode.ABS): return None - if len(instr.operands) != 2: + expected_operands = 1 if instr.opcode is OpCode.ABS else 2 + if len(instr.operands) != expected_operands: return None - lhs, rhs = instr.operands - if not lhs.is_constant or not rhs.is_constant: + if any(not operand.is_constant for operand in instr.operands): return None - if lhs.const_value is None or rhs.const_value is None: + if any(operand.const_value is None for operand in instr.operands): return None - a, b = float(lhs.const_value), float(rhs.const_value) - result = self._compute(instr.opcode, a, b) + lhs = instr.operands[0] + dtype = instr.dest.dtype if instr.dest is not None else lhs.dtype + if self.preserve_float_semantics and any( + operand.dtype in {DataType.FLOAT32, DataType.FLOAT64} + for operand in [*instr.operands, *([instr.dest] if instr.dest else [])] + ): + # Keep the actual target operation. Even an exactly representable + # host result does not prove that operand conversion or fflags agree. + return None + if dtype is DataType.INT32: + values = [operand.const_value for operand in instr.operands] + if any( + isinstance(value, bool) or not isinstance(value, int) + for value in values + ): + return None + if instr.opcode is OpCode.ABS: + result = self._wrap_i32(abs(values[0])) + else: + result = self._compute_i32( + instr.opcode, + values[0], + values[1], + ) + else: + if instr.opcode in { + OpCode.REM, OpCode.MIN, OpCode.MAX, OpCode.ABS, + }: + return None + rhs = instr.operands[1] + a, b = float(lhs.const_value), float(rhs.const_value) + result = self._compute_float(instr.opcode, a, b) if result is None: return None @@ -61,6 +102,7 @@ def _try_fold(self, instr: Instruction) -> Instruction | None: if dest is not None: dest.is_constant = True dest.const_value = result + dest.bit_pattern = None return Instruction( opcode=OpCode.LOAD_CONST, @@ -69,7 +111,9 @@ def _try_fold(self, instr: Instruction) -> Instruction | None: ) @staticmethod - def _compute(opcode: OpCode, a: float, b: float) -> float | None: + def _compute_float( + opcode: OpCode, a: float, b: float, + ) -> float | None: mapping = { OpCode.ADD: a + b, OpCode.SUB: a - b, @@ -77,3 +121,33 @@ def _compute(opcode: OpCode, a: float, b: float) -> float | None: OpCode.DIV: a / b if b != 0 else None, } return mapping.get(opcode) + + @staticmethod + def _compute_i32(opcode: OpCode, a: int, b: int) -> int | None: + """Evaluate an i32 operation with RV32 two's-complement semantics.""" + if opcode in (OpCode.DIV, OpCode.REM): + if b == 0: + return None + quotient = abs(a) // abs(b) + if (a < 0) != (b < 0): + quotient = -quotient + result = quotient if opcode is OpCode.DIV else a - quotient * b + else: + mapping = { + OpCode.ADD: a + b, + OpCode.SUB: a - b, + OpCode.MUL: a * b, + OpCode.MIN: min(a, b), + OpCode.MAX: max(a, b), + } + result = mapping.get(opcode) + if result is None: + return None + + # RV32 integer arithmetic wraps modulo 2**32. + return ConstantFolder._wrap_i32(result) + + @staticmethod + def _wrap_i32(value: int) -> int: + value &= 0xFFFF_FFFF + return value if value < 0x8000_0000 else value - 0x1_0000_0000 diff --git a/scratchv/optimizer/dead_code.py b/scratchv/optimizer/dead_code.py index f2e54858..c84de128 100644 --- a/scratchv/optimizer/dead_code.py +++ b/scratchv/optimizer/dead_code.py @@ -7,7 +7,7 @@ from __future__ import annotations from scratchv.ir.types import ( - OpCode, Instruction, BasicBlock, Function, Program, + DataType, OpCode, Instruction, BasicBlock, Function, Program, ) from scratchv.pass_interface import OptimizationPass @@ -17,6 +17,9 @@ class DeadCodeEliminator(OptimizationPass): name = "dead-code-elim" + def __init__(self, *, preserve_float_semantics: bool = False): + self.preserve_float_semantics = preserve_float_semantics + def optimize(self, program: Program) -> int: """Run dead code elimination. @@ -25,21 +28,23 @@ def optimize(self, program: Program) -> int: return sum(self._eliminate_function(func) for func in program.functions) def _eliminate_function(self, func: Function) -> int: - return sum(self._eliminate_block(block) for block in func.blocks) + # A definition in one block may be consumed by another block or phi. + # This conservative one-pass DCE keeps every function-wide use; it + # does not attempt CFG reachability or iterative dead-chain removal. + used = { + operand.name + for block in func.blocks + for instr in (*block.phi_nodes, *block.instructions) + for operand in instr.operands + } + return sum(self._eliminate_block(block, used) for block in func.blocks) - def _eliminate_block(self, block: BasicBlock) -> int: + def _eliminate_block(self, block: BasicBlock, + used: set[str] | None = None) -> int: changes = 0 - # Collect all used value names - used: set[str | None] = set() - # Return values and branch targets are always live - for instr in block.instructions: - if instr.opcode in ( - OpCode.RETURN, OpCode.BR, - OpCode.BR_IF, OpCode.STORE, - OpCode.ENDFOR, OpCode.FOR): - used.add(instr.dest.name if instr.dest else None) - for op in instr.operands: - used.add(op.name) + if used is None: + used = {operand.name for instr in (*block.phi_nodes, *block.instructions) + for operand in instr.operands} # Filter: keep instructions with side effects or whose dest is used new_instrs: list[Instruction] = [] @@ -54,9 +59,14 @@ def _eliminate_block(self, block: BasicBlock) -> int: block.instructions = new_instrs return changes - @staticmethod - def _is_side_effect(instr: Instruction) -> bool: + def _is_side_effect(self, instr: Instruction) -> bool: """Check if an instruction has side effects and must be kept.""" + if self.preserve_float_semantics and instr.opcode.is_arith(): + values = [*instr.operands, *([instr.dest] if instr.dest else [])] + if any(value.dtype in (DataType.FLOAT32, DataType.FLOAT64) for value in values): + # Even an unused result may set fflags. Keep FP arithmetic + # conservatively; this does not make LOAD_CONST effectful. + return True return instr.opcode in ( OpCode.STORE, OpCode.RETURN, diff --git a/scratchv/optimizer/licm.py b/scratchv/optimizer/licm.py index 4d9b254e..37cd1ba4 100644 --- a/scratchv/optimizer/licm.py +++ b/scratchv/optimizer/licm.py @@ -13,7 +13,7 @@ from __future__ import annotations from scratchv.ir.types import ( - OpCode, Instruction, BasicBlock, Function, Program, + DataType, OpCode, Instruction, BasicBlock, Function, Program, ) from scratchv.pass_interface import OptimizationPass @@ -23,6 +23,9 @@ class LICM(OptimizationPass): name = "licm" + def __init__(self, *, preserve_float_semantics: bool = False): + self.preserve_float_semantics = preserve_float_semantics + def optimize(self, program: Program) -> int: """Run LICM on all functions. @@ -76,12 +79,14 @@ def _process_block(self, block: BasicBlock) -> int: variant_names.add(instr.dest.name) j += 1 - # Hoist: move invariant instructions before the FOR - for idx, instr in reversed(hoisted): + # Remove all candidates before inserting any: insertion would + # otherwise shift the indices of candidates still in the loop. + for idx, _ in reversed(hoisted): instrs.pop(idx) - instrs.insert(loop_start, instr) - loop_end += 1 # adjust for shift - changes += 1 + instrs[loop_start:loop_start] = [instr for _, instr in hoisted] + # The total length and ENDFOR's index are unchanged. Keep the + # original order and do not skip a following loop. + changes += len(hoisted) i = loop_end + 1 @@ -102,11 +107,17 @@ 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 - if instr.opcode in ( - OpCode.STORE, OpCode.BR, OpCode.BR_IF, - OpCode.RETURN, OpCode.FOR, OpCode.ENDFOR, - OpCode.LABEL): + # Only pure numeric operations and literal loads are candidates. + # An invariant pointer does not prove invariant memory contents, or + # that a LOAD/ALLOCA is safe to speculate out of a zero-trip loop. + if not (instr.opcode.is_arith() or instr.opcode is OpCode.LOAD_CONST): + return False + if self.preserve_float_semantics and instr.opcode.is_arith() and any( + value.dtype in (DataType.FLOAT32, DataType.FLOAT64) + for value in [*instr.operands, *([instr.dest] if instr.dest else [])] + ): + # An unused result may still set fflags. Moving the operation + # before the loop changes whether it executes at all. return False # An instruction is invariant if all its operands are: # - constants, or diff --git a/scratchv/optimizer/muladd_fusion.py b/scratchv/optimizer/muladd_fusion.py index ac79cfa1..6ea826bc 100644 --- a/scratchv/optimizer/muladd_fusion.py +++ b/scratchv/optimizer/muladd_fusion.py @@ -1,79 +1,27 @@ -"""Mul-Add fusion: combines mul+add into a single pseudo-operation. - -Detects the pattern: - tmp = mul(a, b) - sum = add(tmp, sum) - -This reduces temporary register pressure and can be exploited by -hardware with a fused multiply-add (FMA) instruction. - -The fused instruction is represented as MUL_ADD in IR: - dst = mul_add(a, b, acc) -""" +"""Registered compatibility pass; fusion requires a valid IR/lowering first.""" from __future__ import annotations -from scratchv.ir.types import OpCode, Instruction, BasicBlock, Program +from scratchv.ir.types import Program from scratchv.pass_interface import OptimizationPass class MulAddFusion(OptimizationPass): - """Combine consecutive mul+add instruction pairs.""" - - name = "muladd-fusion" - - def optimize(self, program: Program) -> int: - """Run mul-add fusion. Returns number of fusions performed.""" - return sum( - self._fuse_block(block) - for func in program.functions - for block in func.blocks - ) - - def _fuse_block(self, block: BasicBlock) -> int: - changes = 0 - instrs = block.instructions - i = 0 - while i < len(instrs) - 1: - mul = instrs[i] - add = instrs[i + 1] - - if self._matches_pattern(mul, add): - # Replace mul with fused instruction - a, b = mul.operands[0], mul.operands[1] - assert mul.dest is not None - if add.operands[1].name == mul.dest.name: - acc = add.operands[0] - else: - acc = add.operands[1] - fused = Instruction( - opcode=OpCode.ADD, # RV32IM has no native FMA - dest=add.dest, - operands=[acc, a, b], - ) - fused.attrs["fused_mul_add"] = True - instrs[i] = fused - # Remove the original add - instrs.pop(i + 1) - changes += 1 + """Preserve MUL/ADD until a fused opcode has a signature and lowering. - i += 1 + RV32IM has no integer multiply-add instruction. A three-operand ADD is + invalid IR, and both selectors consume only two sources. This pass stays + registered so presets/explicit pipelines remain compatible, but performs + no transformations. FP fusion would additionally require an explicit + contraction/rounding policy. The old constructor switch is retained for + callers, not as permission to emit the invalid legacy pseudo. + """ - return changes - - def _matches_pattern(self, mul: Instruction, add: Instruction) -> bool: - """Check if mul + add form a fusible pattern. + name = "muladd-fusion" - tmp = mul(a, b) - result = add(tmp, acc) or add(acc, tmp) - """ - if mul.opcode != OpCode.MUL: - return False - if add.opcode != OpCode.ADD: - return False - if mul.dest is None: - return False + def __init__(self, *, preserve_float_semantics: bool = False): + self.preserve_float_semantics = preserve_float_semantics - # The add must use the mul's result - mul_dest_name = mul.dest.name - return any(op.name == mul_dest_name for op in add.operands) + def optimize(self, program: Program) -> int: + """Return zero changes without inventing an unsupported instruction.""" + return 0 diff --git a/scratchv/optimizer/peephole.py b/scratchv/optimizer/peephole.py index 8ec7a7f9..ba557517 100644 --- a/scratchv/optimizer/peephole.py +++ b/scratchv/optimizer/peephole.py @@ -1,7 +1,6 @@ """Peephole optimization: eliminates redundant instruction patterns. Scans basic blocks for common redundant patterns: - - addi rd, rs, 0 → (delete, no-op) - mul rd, rs, 1 → mv rd, rs - mul rd, rs, 0 → li rd, 0 - j L immediately followed by L: → delete jump @@ -9,20 +8,39 @@ from __future__ import annotations -from scratchv.ir.types import OpCode, Instruction, BasicBlock, Program +from scratchv.ir.types import DataType, OpCode, Instruction, BasicBlock, Program, Value from scratchv.pass_interface import OptimizationPass class IRPeepholeOptimizer(OptimizationPass): - """Eliminate redundant instruction patterns in IR.""" + """Eliminate redundant instruction patterns in IR. + + Enable ``preserve_float_semantics`` for typed hard-float code. Integer + identity rewrites are not valid IEEE transformations: signed zeros, NaNs + and exception flags are observable. Legacy callers retain the old policy. + """ name = "ir-peephole" + def __init__(self, *, preserve_float_semantics: bool = False): + self.preserve_float_semantics = preserve_float_semantics + self._reserved_names: set[str] = set() + self._name_counter = 0 + def optimize(self, program: Program) -> int: """Run peephole optimization. Returns number of eliminated instructions. """ + self._reserved_names = {v.name for v in program.global_values} + self._name_counter = 0 + for func in program.functions: + self._reserved_names.update(v.name for v in [*func.params, *func.returns, *func.locals]) + for block in func.blocks: + for instr in [*block.phi_nodes, *block.instructions]: + self._reserved_names.update(v.name for v in instr.operands) + if instr.dest is not None: + self._reserved_names.add(instr.dest.name) return sum( self._optimize_block(block) for func in program.functions @@ -36,12 +54,16 @@ def _optimize_block(self, block: BasicBlock) -> int: while i < len(instrs): instr = instrs[i] - # Pattern 1: addi rd, rs, 0 → delete (no-op) - if self._is_addi_zero(instr): - instrs.pop(i) - changes += 1 + if self.preserve_float_semantics and self._uses_float(instr): + # Do not delete a definition, invent an i32 zero, or replace + # Inf * 0 with a constant that suppresses the invalid flag. + i += 1 continue + # ADD(value, 0) still defines a distinct IR Value. Retain it until + # a whole-function use-replacement pass can safely remove that + # definition; deleting it as a machine no-op leaves dangling uses. + # Pattern 2: mul rd, rs, 1 → mv rd, rs (use add rd, rs, x0) if self._is_mul_one(instr): dest = instr.dest @@ -77,14 +99,10 @@ def _optimize_block(self, block: BasicBlock) -> int: return changes - def _is_addi_zero(self, instr: Instruction) -> bool: - """Check for: add rd, rs, 0 (or add rd, rs, const where const=0).""" - if instr.opcode != OpCode.ADD: - return False - if len(instr.operands) < 2: - return False - rhs = instr.operands[1] - return rhs.is_constant and rhs.const_value == 0 + @staticmethod + def _uses_float(instr: Instruction) -> bool: + values = [*instr.operands, *([instr.dest] if instr.dest else [])] + return any(value.dtype in {DataType.FLOAT32, DataType.FLOAT64} for value in values) def _is_mul_one(self, instr: Instruction) -> bool: if instr.opcode != OpCode.MUL: @@ -115,6 +133,10 @@ def _is_jump_to_next(self, instrs: list[Instruction], i: int) -> bool: def _make_zero_operand(self, instr: Instruction): """Create a zero constant value.""" - from scratchv.ir.types import Value, DataType - return Value(name="_zero", dtype=DataType.INT32, - is_constant=True, const_value=0) + while True: + self._name_counter += 1 + name = f"__scratchv_peephole_zero_{self._name_counter}" + if name not in self._reserved_names: + self._reserved_names.add(name) + return Value(name=name, dtype=instr.operands[0].dtype, + is_constant=True, const_value=0) diff --git a/scratchv/simulator/rv32_emulator.py b/scratchv/simulator/rv32_emulator.py index 98b9bc42..9230dfe8 100644 --- a/scratchv/simulator/rv32_emulator.py +++ b/scratchv/simulator/rv32_emulator.py @@ -10,6 +10,33 @@ import struct import numpy as np +from scratchv.ir.types import DataType, valid_float_bit_pattern +from scratchv.verification.fp_reference import maximum_number, minimum_number +from scratchv.verification.division_reference import divide + + +def _riscv_i32_rem(lhs: np.ndarray, rhs: np.ndarray) -> np.ndarray: + """Return RV32 REM results, including divide-by-zero edge semantics.""" + left, right = np.broadcast_arrays( + np.asarray(lhs, dtype=np.int32), + np.asarray(rhs, dtype=np.int32), + ) + + def scalar_rem(a, b): + dividend = int(a) + divisor = int(b) + if divisor == 0: + return dividend + if dividend == -(1 << 31) and divisor == -1: + return 0 + quotient = abs(dividend) // abs(divisor) + if (dividend < 0) != (divisor < 0): + quotient = -quotient + return dividend - quotient * divisor + + result = np.vectorize(scalar_rem, otypes=[np.int64])(left, right) + return np.asarray(result, dtype=np.int32) + # ── Register file ───────────────────────────────────────────────────── @@ -249,6 +276,8 @@ def _execute(self, instr: int) -> None: return opcode = instr & 0x7F + if opcode in {0x07, 0x27, 0x43, 0x47, 0x4B, 0x4F, 0x53}: + raise ValueError("instruction requires the RISC-V F/D extension; RV32Emulator supports RV32IM only") self.pc += 4 # ── OP / OP-IMM ────────────────────────────────────────────────── @@ -265,10 +294,34 @@ def _execute(self, instr: int) -> None: elif f3 == 0b000 and f7 == 0b0000001: # MUL self.regs[d["rd"]] = (rs1_v * rs2_v) & 0xFFFFFFFF elif f3 == 0b100 and f7 == 0b0000001: # DIV - if rs2_v != 0: - self.regs[d["rd"]] = (rs1_v // rs2_v) & 0xFFFFFFFF - else: + dividend = _sext(rs1_v, 32) + divisor = _sext(rs2_v, 32) + if divisor == 0: self.regs[d["rd"]] = 0xFFFFFFFF + elif dividend == -(1 << 31) and divisor == -1: + self.regs[d["rd"]] = 0x80000000 + else: + quotient = abs(dividend) // abs(divisor) + if (dividend < 0) != (divisor < 0): + quotient = -quotient + self.regs[d["rd"]] = quotient & 0xFFFFFFFF + elif f3 == 0b110 and f7 == 0b0000001: # REM + dividend = _sext(rs1_v, 32) + divisor = _sext(rs2_v, 32) + if divisor == 0: + remainder = dividend + elif dividend == -(1 << 31) and divisor == -1: + remainder = 0 + else: + quotient = abs(dividend) // abs(divisor) + if (dividend < 0) != (divisor < 0): + quotient = -quotient + remainder = dividend - quotient * divisor + self.regs[d["rd"]] = remainder & 0xFFFFFFFF + elif f3 == 0b010 and f7 == 0b0000000: # SLT + self.regs[d["rd"]] = int( + _sext(rs1_v, 32) < _sext(rs2_v, 32) + ) elif f3 == 0b111 and f7 == 0b0000000: # AND self.regs[d["rd"]] = rs1_v & rs2_v elif f3 == 0b110 and f7 == 0b0000000: # OR @@ -289,6 +342,14 @@ def _execute(self, instr: int) -> None: self.regs[d["rd"]] = rs1_v | d["imm"] elif f3 == 0b100: # XORI self.regs[d["rd"]] = rs1_v ^ d["imm"] + elif ( + f3 == 0b101 + and (instr >> 25) & 0x7F == 0b0100000 + ): # SRAI + shamt = (instr >> 20) & 0x1F + self.regs[d["rd"]] = ( + _sext(rs1_v, 32) >> shamt + ) & 0xFFFFFFFF # ── LOAD ────────────────────────────────────────────────────────── elif opcode == 0b0000011: @@ -368,14 +429,29 @@ def _execute(self, instr: int) -> None: self.regs[0] = 0 +def _constant_array(value, dtype: DataType, bit_pattern=None) -> np.ndarray: + """Interpret explicit storage bits without a host floating-point conversion.""" + numpy_type = { + DataType.INT32: np.int32, + DataType.FLOAT32: np.float32, + DataType.FLOAT64: np.float64, + }.get(dtype, np.float32) + if bit_pattern is None: + return np.array(value, dtype=numpy_type) + if not valid_float_bit_pattern(dtype, bit_pattern): + raise ValueError(f"invalid constant bit_pattern for {dtype.value}") + storage_type = np.uint32 if dtype is DataType.FLOAT32 else np.uint64 + return np.array(bit_pattern, dtype=storage_type).view(numpy_type) + + # ── Trace executor (IR-based, for accurate verification) ────────────── class IRTraceExecutor: """Execute a ScratchV IR program directly using numpy. Walks the IR instructions in order and computes results with numpy. - This provides the ground-truth output of the compiler pipeline without - needing to go through RISC-V binary execution. + This is a straight-line, value-only reference, not a RISC-V execution + oracle: it does not model floating-point fflags or target rounding modes. """ def __init__( @@ -383,18 +459,23 @@ def __init__( initializers: dict[str, np.ndarray] | None = None): self.program = program self._values: dict[str, np.ndarray] = {} + # Preserve instruction-only encodings when resolving constant operands. + self._constant_bits: dict[str, int] = {} self._attrs: dict[str, dict] = {} self._initializers = initializers or {} def run(self, inputs: dict[str, np.ndarray]) -> np.ndarray: """Execute the program with given inputs. Returns the return value.""" self._values = dict(inputs) + self._constant_bits = {} for init_name, init_arr in self._initializers.items(): self._values[init_name] = init_arr output = None for func in self.program.functions: + # LOAD_CONST definitions belong to this function's value namespace. + self._constant_bits = {} for block in func.blocks: for instr in block.instructions: output = self._exec_instr(instr) @@ -405,14 +486,16 @@ def _exec_instr(self, instr): handler = getattr(self, f"_op_{op}", None) if handler is not None: return handler(instr) - return None + raise ValueError(f"Unsupported op in IR trace executor: {op}") def _resolve(self, val) -> np.ndarray: - if val.is_constant and val.const_value is not None: - return np.array(float(val.const_value), dtype=np.float32) + if val.is_constant: + bits = self._constant_bits.get(val.name, val.bit_pattern) + if bits is not None or val.const_value is not None: + return _constant_array(val.const_value, val.dtype, bits) if val.name in self._values: return self._values[val.name] - return np.array(0.0, dtype=np.float32) + raise ValueError(f"Undefined IR value: {val.name}") def _get_operands(self, instr) -> list[np.ndarray]: return [self._resolve(op) for op in instr.operands] @@ -440,7 +523,49 @@ def _op_mul(self, instr): def _op_div(self, instr): ops = self._get_operands(instr) - result = ops[0] / (ops[1] + 1e-8) + result = divide(ops[0], ops[1]) + if instr.dest: + self._values[instr.dest.name] = result + return result + + def _op_rem(self, instr): + ops = self._get_operands(instr) + result = _riscv_i32_rem(ops[0], ops[1]) + if instr.dest: + self._values[instr.dest.name] = result + return result + + def _op_min(self, instr): + ops = self._get_operands(instr) + result = minimum_number(ops[0], ops[1]) + if instr.attrs.get("propagate_nan", False): + result = np.where(np.isnan(ops[0]) | np.isnan(ops[1]), + np.asarray(np.nan, dtype=result.dtype), result) + if instr.dest: + self._values[instr.dest.name] = result + return result + + def _op_max(self, instr): + ops = self._get_operands(instr) + result = maximum_number(ops[0], ops[1]) + if instr.attrs.get("propagate_nan", False): + result = np.where(np.isnan(ops[0]) | np.isnan(ops[1]), + np.asarray(np.nan, dtype=result.dtype), result) + if instr.dest: + self._values[instr.dest.name] = result + return result + + def _op_abs(self, instr): + ops = self._get_operands(instr) + result = np.abs(ops[0]) + if instr.dest: + self._values[instr.dest.name] = result + return result + + def _op_sqrt(self, instr): + ops = self._get_operands(instr) + with np.errstate(invalid="ignore"): + result = np.sqrt(ops[0]) if instr.dest: self._values[instr.dest.name] = result return result @@ -474,10 +599,20 @@ def _op_sigmoid(self, instr): return result def _op_load_const(self, instr): - val = instr.attrs.get("value", 0) - result = np.array(float(val), dtype=np.float32) + bits = instr.attrs.get("bit_pattern") + value_bits = instr.dest.bit_pattern if instr.dest else None + if bits is not None and value_bits is not None and bits != value_bits: + raise ValueError("constant bit_pattern differs from result metadata") + if bits is None: + bits = value_bits + dtype = instr.dest.dtype if instr.dest else DataType.FLOAT32 + result = _constant_array(instr.attrs.get("value", 0), dtype, bits) if instr.dest: self._values[instr.dest.name] = result + if bits is not None: + self._constant_bits[instr.dest.name] = bits + else: + self._constant_bits.pop(instr.dest.name, None) return result def _op_reshape(self, instr): @@ -648,10 +783,10 @@ def _op_endfor(self, instr): return None def _op_br(self, instr): - return None + raise ValueError("Unsupported control flow in straight-line IR trace executor: br") def _op_br_if(self, instr): - return None + raise ValueError("Unsupported control flow in straight-line IR trace executor: br_if") def _op_label(self, instr): return None diff --git a/scratchv/verification/division_reference.py b/scratchv/verification/division_reference.py new file mode 100644 index 00000000..3bf83734 --- /dev/null +++ b/scratchv/verification/division_reference.py @@ -0,0 +1,34 @@ +"""Typed value-only division references; not an fflags/rounding oracle.""" + +from __future__ import annotations + +import numpy as np + + +def divide(lhs, rhs) -> np.ndarray: + """Preserve i32 DIV semantics or the operands' numpy floating precision. + + RV32 DIV truncates toward zero, returns -1 for a zero divisor and wraps + INT32_MIN/-1 to INT32_MIN. Do not use float conversion to compute its + quotient. Floating division uses the original denominator, canonicalizes + NaN values, and does not claim to model target exception flags or rm. + """ + left, right = np.broadcast_arrays(np.asarray(lhs), np.asarray(rhs)) + if left.dtype == np.int32 and right.dtype == np.int32: + def scalar_div(a, b): + dividend, divisor = int(a), int(b) + if divisor == 0: + return -1 + if dividend == -(1 << 31) and divisor == -1: + return -(1 << 31) + quotient = abs(dividend) // abs(divisor) + return -quotient if (dividend < 0) != (divisor < 0) else quotient + + result = np.vectorize(scalar_div, otypes=[np.int64])(left, right) + return np.asarray(result, dtype=np.int32) + + with np.errstate(divide="ignore", invalid="ignore", over="ignore", under="ignore"): + result = np.divide(left, right) + if np.issubdtype(result.dtype, np.floating): + result = np.where(np.isnan(result), np.asarray(np.nan, dtype=result.dtype), result) + return np.asarray(result) diff --git a/scratchv/verification/fp_reference.py b/scratchv/verification/fp_reference.py new file mode 100644 index 00000000..80dec2e0 --- /dev/null +++ b/scratchv/verification/fp_reference.py @@ -0,0 +1,43 @@ +"""Value-only minimumNumber/maximumNumber references for Topic 28. + +These numpy helpers preserve binary32/binary64 dtype, select the number when +only one input is NaN, canonicalize two NaNs and order -0 below +0. They do +not model signaling-NaN exceptions, fflags or non-default rounding modes; +Clang/LLD/QEMU execution remains the binary/exception oracle. +""" + +from __future__ import annotations + +import numpy as np + + +def _min_max_number(lhs, rhs, *, maximum: bool) -> np.ndarray: + left, right = np.broadcast_arrays(np.asarray(lhs), np.asarray(rhs)) + dtype = np.result_type(left.dtype, right.dtype) + if not np.issubdtype(dtype, np.floating): + operation = np.maximum if maximum else np.minimum + return np.asarray(operation(left, right)) + if dtype not in (np.dtype(np.float32), np.dtype(np.float64)): + raise ValueError(f"Unsupported minimumNumber/maximumNumber dtype: {dtype}") + left, right = left.astype(dtype, copy=False), right.astype(dtype, copy=False) + operation = np.fmax if maximum else np.fmin + result = operation(left, right) + both_nan = np.isnan(left) & np.isnan(right) + result = np.where(both_nan, np.asarray(np.nan, dtype=dtype), result) + both_zero = (left == 0) & (right == 0) + negative_zero = ( + np.signbit(left) & np.signbit(right) if maximum + else np.signbit(left) | np.signbit(right) + ) + zero = np.copysign(np.zeros_like(result), np.where(negative_zero, -1, 1)) + return np.asarray(np.where(both_zero, zero, result), dtype=dtype) + + +def minimum_number(lhs, rhs) -> np.ndarray: + """Return the smaller number, with IEEE/RISC-V NaN and signed-zero rules.""" + return _min_max_number(lhs, rhs, maximum=False) + + +def maximum_number(lhs, rhs) -> np.ndarray: + """Return the larger number, with IEEE/RISC-V NaN and signed-zero rules.""" + return _min_max_number(lhs, rhs, maximum=True) diff --git a/scratchv/verification/verifier.py b/scratchv/verification/verifier.py index 03d1508d..b214184b 100644 --- a/scratchv/verification/verifier.py +++ b/scratchv/verification/verifier.py @@ -12,6 +12,32 @@ import numpy as np from typing import Any +from scratchv.verification.fp_reference import maximum_number, minimum_number +from scratchv.verification.division_reference import divide + + +def _riscv_i32_rem(lhs: np.ndarray, rhs: np.ndarray) -> np.ndarray: + """Return RV32 REM results, including divide-by-zero edge semantics.""" + left, right = np.broadcast_arrays( + np.asarray(lhs, dtype=np.int32), + np.asarray(rhs, dtype=np.int32), + ) + + def scalar_rem(a, b): + dividend = int(a) + divisor = int(b) + if divisor == 0: + return dividend + if dividend == -(1 << 31) and divisor == -1: + return 0 + quotient = abs(dividend) // abs(divisor) + if (dividend < 0) != (divisor < 0): + quotient = -quotient + return dividend - quotient * divisor + + result = np.vectorize(scalar_rem, otypes=[np.int64])(left, right) + return np.asarray(result, dtype=np.int32) + # --------------------------------------------------------------------------- # ONNX Runtime adapter @@ -140,7 +166,8 @@ def _numpy_maxpool(inputs: list[np.ndarray], **attrs) -> np.ndarray: class DSLInterpreter: """Evaluate a DSL program on concrete input values. - This provides a ground-truth reference for verification. + This is a value-only numpy reference, not binary execution evidence. It + does not model fflags or target rounding modes. """ def __init__(self): @@ -199,7 +226,7 @@ def _resolve(self, name: str) -> np.ndarray: return np.array(val) except ValueError: pass - return np.array(0.0) + raise ValueError(f"Undefined DSL value: {name}") def _dispatch(self, op: str, args: list[str]) -> np.ndarray: plain = [] @@ -216,11 +243,28 @@ def _dispatch(self, op: str, args: list[str]) -> np.ndarray: resolved = [self._resolve(a) for a in plain] + if op == "const": + dtype = kwargs.get("dtype", "f32") + numpy_dtype = { + "i32": np.int32, + "f32": np.float32, + "f64": np.float64, + }.get(dtype) + if numpy_dtype is None: + raise ValueError(f"Unsupported const dtype: {dtype}") + return np.asarray(plain[0], dtype=numpy_dtype) + op_map = { "add": lambda: resolved[0] + resolved[1], "sub": lambda: resolved[0] - resolved[1], "mul": lambda: resolved[0] * resolved[1], - "div": lambda: resolved[0] / resolved[1], + "div": lambda: divide(resolved[0], resolved[1]), + "rem": lambda: _riscv_i32_rem(resolved[0], resolved[1]), + "mod": lambda: _riscv_i32_rem(resolved[0], resolved[1]), + "min": lambda: minimum_number(resolved[0], resolved[1]), + "max": lambda: maximum_number(resolved[0], resolved[1]), + "abs": lambda: np.abs(resolved[0]), + "sqrt": lambda: np.sqrt(resolved[0]), "neg": lambda: -resolved[0], "exp": lambda: np.exp(resolved[0]), "relu": lambda: np.maximum(resolved[0], 0.0), diff --git a/scripts/run_topic06_benchmarks.py b/scripts/run_topic06_benchmarks.py index a83452e8..7fb6d44c 100644 --- a/scripts/run_topic06_benchmarks.py +++ b/scripts/run_topic06_benchmarks.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import json import math diff --git a/tests/test_asm_beautifier_comments.py b/tests/test_asm_beautifier_comments.py index c0d37891..2ff4e73d 100755 --- a/tests/test_asm_beautifier_comments.py +++ b/tests/test_asm_beautifier_comments.py @@ -1,5 +1,7 @@ """Functional unit tests for semantic RISC-V instruction comments.""" +from __future__ import annotations + import pytest from scratchv.backend.asm_beautifier import ( diff --git a/tests/test_compare_peephole.py b/tests/test_compare_peephole.py index 6b2489f8..285a7278 100644 --- a/tests/test_compare_peephole.py +++ b/tests/test_compare_peephole.py @@ -318,8 +318,21 @@ def test_unified_html_contains_three_independent_benchmark_sections(): assert rendered.count("14个微型规则案例") == 1 assert rendered.count("23个 DSL 案例") == 1 assert rendered.count("cnn.onnx standalone A/B") == 1 - for value in (31, 22, 306, 305, 889, 865): + # Codegen may add required constant materialization. The renderer must + # display this run's actual measurements, not a historical instruction + # count that also accepted illegal register/immediate assembly. + values = ( + micro["summary"]["before_instructions"], + micro["summary"]["after_instructions"], + dsl.total_before, dsl.total_after, + cnn["instructions"]["baseline"], cnn["instructions"]["optimized"], + ) + for value in values: assert f">{value:,}<" in rendered + assert dsl.total_before == sum(case.before_total for case in dsl.cases) + assert dsl.total_after == sum(case.after_total for case in dsl.cases) + assert dsl.total_saved == sum(case.saved for case in dsl.cases) + assert dsl.total_before - dsl.total_after == dsl.total_saved > 0 assert "Commit:" not in rendered assert "类别" not in rendered assert "输入摘要" not in rendered @@ -356,7 +369,11 @@ def test_unified_summary_contains_dynamic_sections_and_case_details(): assert summary.startswith("## Topic 13 Peephole Benchmark") assert "| 微型汇编案例 | 14 | 31 | 22 | 9 | 29.032% |" in summary - assert "| DSL 案例 | 23 | 306 | 305 | 1 |" in summary + assert ( + f"| DSL 案例 | {len(dsl.cases)} | {dsl.total_before} | " + f"{dsl.total_after} | {dsl.total_saved} | " + f"{100.0 * dsl.total_saved / dsl.total_before:.3f}% |" + ) in summary assert "| CNN standalone | 1 | 889 | 865 | 24 |" in summary assert "| CNN 代码大小 | 3556 B | 3460 B | 96 B |" in summary assert summary.count("
") == 3 diff --git a/tests/test_dsl_control_flow_benchmark.py b/tests/test_dsl_control_flow_benchmark.py index ff0474c6..e39fd55e 100644 --- a/tests/test_dsl_control_flow_benchmark.py +++ b/tests/test_dsl_control_flow_benchmark.py @@ -1,5 +1,7 @@ """Exercise the Topic 01 benchmark through its public CLI.""" +from __future__ import annotations + import json import html import subprocess diff --git a/tests/test_dsl_diagnostics_benchmark.py b/tests/test_dsl_diagnostics_benchmark.py index 1ff45067..88d8fbd7 100644 --- a/tests/test_dsl_diagnostics_benchmark.py +++ b/tests/test_dsl_diagnostics_benchmark.py @@ -1,5 +1,7 @@ """Exercise the Topic 9 report through its real subprocess interface.""" +from __future__ import annotations + import json import subprocess import sys diff --git a/tests/test_inst_scheduler_llvm.py b/tests/test_inst_scheduler_llvm.py index f32b4a88..e0723a8f 100644 --- a/tests/test_inst_scheduler_llvm.py +++ b/tests/test_inst_scheduler_llvm.py @@ -1,5 +1,7 @@ """LLVM timing contract and standalone execution regressions.""" +from __future__ import annotations + from copy import deepcopy import json import shutil diff --git a/tests/test_inst_scheduler_standalone.py b/tests/test_inst_scheduler_standalone.py index 528b88b7..d4f7390b 100644 --- a/tests/test_inst_scheduler_standalone.py +++ b/tests/test_inst_scheduler_standalone.py @@ -59,7 +59,8 @@ def input_values(shape, seed): def fixed_tensor(name, values): # Powers-of-two scaling is exact; small products avoid int32 overflow. - return numpy_helper.from_array(np.asarray(values, dtype=np.float32) / 65536, name) + scaled = np.asarray(values, dtype=np.float32) / np.float32(65536) + return numpy_helper.from_array(scaled, name) def conv_reference(x, weights, bias, stride, pad): diff --git a/tests/test_inst_select_ext.py b/tests/test_inst_select_ext.py index 911f4fbe..f19ac9fb 100644 --- a/tests/test_inst_select_ext.py +++ b/tests/test_inst_select_ext.py @@ -2,8 +2,9 @@ import pytest from scratchv.backend.inst_select_ext import ExtendedInstructionSelector +from scratchv.backend.target_features import TargetFeatures from scratchv.ir.builder import IRBuilder -from scratchv.ir.types import Value, DataType # noqa: F401 +from scratchv.ir.types import DataType, Instruction, OpCode from scratchv.backend.register_alloc import MachineOp @@ -14,29 +15,30 @@ def test_creation(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - a = builder.make_value(name="a") - b = builder.make_value(name="b") + a = builder.make_value(name="a", dtype=DataType.INT32) + b = builder.make_value(name="b", dtype=DataType.INT32) c = builder.add(a, b) builder.ret(c) selector = ExtendedInstructionSelector(builder.program) - assert selector.enable_fp64 is True - assert selector.use_hardware_sqrt is False + assert selector.features == TargetFeatures("rv32imfd", "ilp32d") - def test_creation_fp64_disabled(self): + def test_f_target_rejects_double_precision(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") + builder.ret(builder.load_const(1.0, DataType.FLOAT64)) selector = ExtendedInstructionSelector( - builder.program, enable_fp64=False) - assert selector.enable_fp64 is False + builder.program, features=TargetFeatures("rv32imf", "ilp32f")) + with pytest.raises(ValueError, match="requires the RISC-V D extension"): + selector.run() def test_run_basic(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - a = builder.make_value(name="a") - b = builder.make_value(name="b") + a = builder.make_value(name="a", dtype=DataType.INT32) + b = builder.make_value(name="b", dtype=DataType.INT32) c = builder.add(a, b) builder.ret(c) @@ -50,7 +52,7 @@ def test_run_relu(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - a = builder.make_value(name="a") + a = builder.make_value(name="a", dtype=DataType.INT32) c = builder.relu(a) builder.ret(c) @@ -63,8 +65,8 @@ def test_run_sub(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - a = builder.make_value(name="a") - b = builder.make_value(name="b") + a = builder.make_value(name="a", dtype=DataType.INT32) + b = builder.make_value(name="b", dtype=DataType.INT32) c = builder.sub(a, b) builder.ret(c) @@ -85,17 +87,6 @@ def test_supported_ops(self): assert "max" in ops assert "abs" in ops - def test_supported_ops_no_fp64(self): - builder = IRBuilder() - builder.new_function("test") - builder.new_block("entry") - selector = ExtendedInstructionSelector( - builder.program, enable_fp64=False) - ops = selector.supported_ops - assert "add" in ops - # fp64 ops should still be in unsupported list - # (they're always defined, just not enabled) - class TestExtendedSelectorNeg: """Tests for neg instruction handling.""" @@ -104,7 +95,7 @@ def test_neg(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - a = builder.make_value(name="a") + a = builder.make_value(name="a", dtype=DataType.INT32) c = builder.neg(a) builder.ret(c) @@ -114,48 +105,54 @@ def test_neg(self): assert MachineOp.SUB in ops -class TestTypeDetection: - """Tests for float64 type detection.""" - - def test_is_fp64_int32(self): - from scratchv.ir.types import Instruction, OpCode - - v = Value(name="x", dtype=DataType.INT32) - instr = Instruction(opcode=OpCode.ADD, operands=[v], - dest=Value(name="y", dtype=DataType.INT32)) - +class TestExtendedSelectorNumericOps: + @pytest.mark.parametrize( + ("method", "dtype", "expected"), + [ + ("min", DataType.FLOAT32, MachineOp.FMIN_S), + ("min", DataType.FLOAT64, MachineOp.FMIN_D), + ("max", DataType.FLOAT32, MachineOp.FMAX_S), + ("max", DataType.FLOAT64, MachineOp.FMAX_D), + ("abs", DataType.FLOAT32, MachineOp.FABS_S), + ("abs", DataType.FLOAT64, MachineOp.FABS_D), + ], + ) + def test_float_min_max_abs_select_typed_instruction( + self, method, dtype, expected, + ): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - selector = ExtendedInstructionSelector(builder.program) - assert not selector._is_fp64(instr) + lhs = builder.load_const(4.0, dtype) + rhs = builder.load_const(2.0, dtype) + args = (lhs,) if method == "abs" else (lhs, rhs) + result = getattr(builder, method)(*args) + builder.ret(result) - def test_is_fp64_float64(self): - from scratchv.ir.types import Instruction, OpCode + instructions = ExtendedInstructionSelector(builder.program).run() - v = Value(name="x", dtype=DataType.FLOAT64) - instr = Instruction(opcode=OpCode.ADD, operands=[v], - dest=Value(name="y", dtype=DataType.FLOAT64)) + selected = next(item for item in instructions if item.op is expected) + assert selected.dst.value == result.name + assert selected.src1.value == lhs.name + if method != "abs": + assert selected.src2.value == rhs.name + def test_rem_rejects_non_i32_ir_even_without_verifier(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - selector = ExtendedInstructionSelector(builder.program) - assert selector._is_fp64(instr) - - def test_is_fp64_fp64_disabled(self): - from scratchv.ir.types import Instruction, OpCode - - v = Value(name="x", dtype=DataType.FLOAT64) - instr = Instruction(opcode=OpCode.ADD, operands=[v], - dest=Value(name="y", dtype=DataType.FLOAT64)) + lhs = builder.make_value(name="lhs", dtype=DataType.FLOAT32) + rhs = builder.make_value(name="rhs", dtype=DataType.FLOAT32) + builder.current_block.add( + Instruction( + OpCode.REM, + builder.make_value(name="result", dtype=DataType.FLOAT32), + [lhs, rhs], + ) + ) - builder = IRBuilder() - builder.new_function("test") - builder.new_block("entry") - selector = ExtendedInstructionSelector( - builder.program, enable_fp64=False) - assert not selector._is_fp64(instr) + with pytest.raises(ValueError, match="rem does not support f32"): + ExtendedInstructionSelector(builder.program).run() class TestLoadConst: @@ -165,7 +162,7 @@ def test_load_const_int(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - c = builder.load_const(42) + c = builder.load_const(42, dtype=DataType.INT32) builder.ret(c) selector = ExtendedInstructionSelector(builder.program) @@ -182,16 +179,16 @@ def test_load_store(self): builder = IRBuilder() builder.new_function("test") builder.new_block("entry") - ptr = builder.make_value(name="ptr") + ptr = builder.make_value(name="ptr", dtype=DataType.FLOAT32) loaded = builder.load(ptr) - builder.store(loaded, ptr) + builder.store(ptr, loaded) builder.ret(loaded) selector = ExtendedInstructionSelector(builder.program) instrs = selector.run() ops = [i.op for i in instrs if i.op != MachineOp.LABEL] - assert MachineOp.LW in ops - assert MachineOp.SW in ops + assert MachineOp.FLW in ops + assert MachineOp.FSW in ops if __name__ == "__main__": diff --git a/tests/test_ir_trace_constants.py b/tests/test_ir_trace_constants.py new file mode 100644 index 00000000..641eca2b --- /dev/null +++ b/tests/test_ir_trace_constants.py @@ -0,0 +1,203 @@ +"""R3: constant storage bits agree at load, operand and return boundaries.""" + +import sys + +import numpy as np +import pytest + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as T, Instruction, OpCode, Program, Value +from scratchv.simulator.rv32_emulator import IRTraceExecutor + + +PRECISIONS = [pytest.param(T.FLOAT32, id="f32"), pytest.param(T.FLOAT64, id="f64")] +STORAGE = {T.FLOAT32: (np.float32, 4), T.FLOAT64: (np.float64, 8)} +ENCODINGS = { + "one": (0x3F800000, 0x3FF0000000000000), + "positive_zero": (0x00000000, 0x0000000000000000), + "negative_zero": (0x80000000, 0x8000000000000000), + "quiet_nan": (0x7FC01234, 0x7FF8000000001234), + "signaling_nan": (0x7F801234, 0x7FF0000000001234), +} +TWO = {T.FLOAT32: 0x40000000, T.FLOAT64: 0x4000000000000000} +THREE = {T.FLOAT32: 0x40400000, T.FLOAT64: 0x4008000000000000} + + +def bits_for(case, dtype): + return ENCODINGS[case][dtype is T.FLOAT64] + + +def assert_storage(result, dtype, bits): + numpy_type, size = STORAGE[dtype] + assert isinstance(result, np.ndarray) + assert result.shape == () + assert result.dtype == numpy_type + # Expected bytes come from integer literals, not the production decoder. + assert result.tobytes() == bits.to_bytes(size, sys.byteorder) + + +def read_constant(executor, entry, value): + if entry == "operand": + return executor._resolve(value) + return executor._op_load_const( + Instruction( + OpCode.LOAD_CONST, + value, + attrs={"value": value.const_value, "bit_pattern": value.bit_pattern}, + ) + ) + + +def encoded_program(dtype, source): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + value = builder.load_const(0.0, dtype, bit_pattern=bits_for("one", dtype)) + instruction = builder.current_block.instructions[-1] + if source == "instruction": + value.bit_pattern = None + elif source == "value": + instruction.attrs.pop("bit_pattern") + return builder, value, instruction + + +class TestIRTraceConstantStorage: + @pytest.mark.parametrize("dtype", PRECISIONS) + @pytest.mark.parametrize("case", ENCODINGS) + @pytest.mark.parametrize("entry", ["load", "operand"]) + def test_explicit_constant_encoding_preserves_every_bit(self, dtype, case, entry): + host = ( + float("nan") + if case.endswith("nan") + else 1.0 if case == "positive_zero" else 0.0 + ) + bits = bits_for(case, dtype) + value = Value( + "encoded", dtype, is_constant=True, const_value=host, bit_pattern=bits + ) + executor = IRTraceExecutor(Program()) + + assert_storage(read_constant(executor, entry, value), dtype, bits) + + @pytest.mark.parametrize("dtype", PRECISIONS) + @pytest.mark.parametrize("source", ["instruction", "value", "both"]) + def test_return_uses_the_encoding_from_the_constant_definition(self, dtype, source): + builder, value, _ = encoded_program(dtype, source) + builder.ret(value) + assert verify_ir(builder.program) == (True, []) + + assert_storage( + IRTraceExecutor(builder.program).run({}), dtype, bits_for("one", dtype) + ) + + @pytest.mark.parametrize("dtype", PRECISIONS) + def test_arithmetic_reads_instruction_only_and_bare_value_encodings(self, dtype): + builder, left, _ = encoded_program(dtype, "instruction") + right = Value( + "global", dtype, is_constant=True, const_value=0.0, bit_pattern=TWO[dtype] + ) + builder.program.global_values.append(right) + builder.ret(builder.add(left, right)) + assert verify_ir(builder.program) == (True, []) + + result = IRTraceExecutor(builder.program).run({}) + assert_storage(np.asarray(result), dtype, THREE[dtype]) + + @pytest.mark.parametrize("dtype", PRECISIONS) + def test_repeated_runs_reload_encodings_and_do_not_override_bare_constants( + self, dtype + ): + builder, value, instruction = encoded_program(dtype, "instruction") + builder.ret(value) + executor = IRTraceExecutor(builder.program) + assert_storage(executor.run({}), dtype, bits_for("one", dtype)) + instruction.attrs["bit_pattern"] = TWO[dtype] + assert_storage(executor.run({}), dtype, TWO[dtype]) + + # Reuse the name without an encoded LOAD_CONST in the next execution. + builder.current_block.instructions.remove(instruction) + builder.program.global_values.append(value) + assert verify_ir(builder.program) == (True, []) + numpy_type, _ = STORAGE[dtype] + result = executor.run({value.name: np.array(9.0, dtype=numpy_type)}) + assert_storage(result, dtype, bits_for("positive_zero", dtype)) + + @pytest.mark.parametrize("dtype", PRECISIONS) + def test_constant_definitions_do_not_leak_between_functions(self, dtype): + first, value, _ = encoded_program(dtype, "instruction") + first.ret(value) + second = IRBuilder() + second.new_function("other") + second.new_block("entry") + constant = second.make_const(0.0, dtype) + constant.bit_pattern = TWO[dtype] + second.ret(constant) + assert value.name == constant.name + first.program.functions.extend(second.program.functions) + assert verify_ir(first.program) == (True, []) + + assert_storage(IRTraceExecutor(first.program).run({}), dtype, TWO[dtype]) + + @pytest.mark.parametrize("dtype", PRECISIONS) + def test_conflicting_instruction_and_value_encodings_fail_before_storing( + self, dtype + ): + _, value, instruction = encoded_program(dtype, "both") + instruction.attrs["bit_pattern"] = TWO[dtype] + executor = IRTraceExecutor(Program()) + previous = np.array(9.0) + executor._values[value.name] = previous + + with pytest.raises(ValueError, match="bit_pattern differs"): + executor._op_load_const(instruction) + + assert executor._values[value.name] is previous + + @pytest.mark.parametrize("dtype", PRECISIONS) + @pytest.mark.parametrize("invalid", ["negative", "too_wide", "boolean", "fraction"]) + @pytest.mark.parametrize("entry", ["load", "operand"]) + def test_malformed_explicit_encodings_are_rejected(self, dtype, invalid, entry): + _, size = STORAGE[dtype] + bits = { + "negative": -1, + "too_wide": 1 << (size * 8), + "boolean": True, + "fraction": 1.5, + }[invalid] + value = Value( + "invalid", dtype, is_constant=True, const_value=0.0, bit_pattern=bits + ) + + with pytest.raises(ValueError, match="bit_pattern"): + read_constant(IRTraceExecutor(Program()), entry, value) + + @pytest.mark.parametrize("entry", ["load", "operand"]) + def test_float_encoding_is_not_accepted_for_an_integer_constant(self, entry): + value = Value( + "invalid", T.INT32, is_constant=True, const_value=0, bit_pattern=0 + ) + + with pytest.raises(ValueError, match="bit_pattern"): + read_constant(IRTraceExecutor(Program()), entry, value) + + @pytest.mark.parametrize( + "dtype,host,numpy_type", + [ + (T.INT32, -7, np.int32), + (T.FLOAT32, 1.25, np.float32), + (T.FLOAT64, 1.0000000000000002, np.float64), + ], + ) + def test_unencoded_constants_keep_existing_numeric_and_dtype_behavior( + self, dtype, host, numpy_type + ): + value = Value("plain", dtype, is_constant=True, const_value=host) + expected = np.array(host, dtype=numpy_type) + executor = IRTraceExecutor(Program()) + executor._values[value.name] = np.array(99) + for entry in ("operand", "load"): + result = read_constant(executor, entry, value) + assert result.shape == () + assert result.dtype == numpy_type + assert result.tobytes() == expected.tobytes() diff --git a/tests/test_ir_verifier.py b/tests/test_ir_verifier.py index 8ccfc843..7333ab15 100644 --- a/tests/test_ir_verifier.py +++ b/tests/test_ir_verifier.py @@ -330,6 +330,10 @@ def test_a06_a11_repeat_readonly_and_name_identity(): ((O.ADD, O.SUB, O.MUL, O.DIV, O.MATMUL, O.DOT), (2, 2, True, "T")), ((O.NEG, O.RELU, O.MAXPOOL, O.RESHAPE, O.TRANSPOSE), (1, 1, True, "T")), ((O.EXP, O.GELU, O.SIGMOID, O.SOFTMAX), (1, 1, True, "F")), + ((O.SQRT,), (1, 1, True, "F")), + ((O.REM,), (2, 2, True, "I32")), + ((O.MIN, O.MAX), (2, 2, True, "N")), + ((O.ABS,), (1, 1, True, "N")), ((O.CONV, O.GEMM), (3, 3, True, "T")), ((O.CONCAT,), (1, None, True, "T")), ((O.LOAD_CONST, O.ALLOCA, O.FOR), (0, 0, True, None)), @@ -345,9 +349,13 @@ def test_a06_a11_repeat_readonly_and_name_identity(): def signature_program(opcode, dtype=None): + minimum, _, has_dest, family = _EXPECTED_SIGNATURES[opcode] if dtype is None: - dtype = D.INT32 if opcode in (O.FOR, O.BR_IF) else D.FLOAT32 - minimum, _, has_dest, _ = _EXPECTED_SIGNATURES[opcode] + dtype = ( + D.INT32 + if opcode in (O.FOR, O.BR_IF) or family == "I32" + else D.FLOAT32 + ) operands = [literal(1, dtype, f"c{i}") for i in range(minimum)] dest = V("result", dtype) if has_dest else None inst = I(opcode, dest, operands, attrs={"value": 1, "start": 0, "end": 2, "step": 1}) @@ -398,12 +406,20 @@ def test_signature_count_and_destination_boundaries(opcode, signature): assert any("operands, got" in i.message for i in verify_ir(p)[1]) -@pytest.mark.parametrize("opcode", [O.ADD, O.EXP], ids=["numeric-family", "float-family"]) +@pytest.mark.parametrize( + "opcode", [O.ADD, O.EXP, O.MIN, O.REM], + ids=["same-type-family", "float-family", "extended-numeric-family", "i32-family"], +) @pytest.mark.parametrize("dtype", list(D), ids=lambda dtype: dtype.value) def test_shared_type_families(opcode, dtype): p, _ = signature_program(opcode, dtype) passed, found = verify_ir(p) - valid = opcode == O.ADD or dtype in (D.FLOAT32, D.FLOAT64) + valid = ( + opcode == O.ADD + or opcode == O.EXP and dtype in (D.FLOAT32, D.FLOAT64) + or opcode == O.MIN and dtype in (D.INT32, D.FLOAT32, D.FLOAT64) + or opcode == O.REM and dtype is D.INT32 + ) assert passed is valid assert (not found) if valid else all(i.rule == "type-consistency" for i in found) diff --git a/tests/test_llvm_codegen.py b/tests/test_llvm_codegen.py index 2b222100..60c4b272 100644 --- a/tests/test_llvm_codegen.py +++ b/tests/test_llvm_codegen.py @@ -115,7 +115,7 @@ def test_emit_exp(self): def test_emit_multiple_blocks(self): dsl = """ for i = 0, 2 - y = add(x, i) + y = add(a, b) endfor return y """ diff --git a/tests/test_llvm_input_types.py b/tests/test_llvm_input_types.py new file mode 100644 index 00000000..b252a5b5 --- /dev/null +++ b/tests/test_llvm_input_types.py @@ -0,0 +1,183 @@ +"""LLVM input rejection and verified output regressions for Topic 28 R1.""" + +import numpy as np +import onnx +import pytest + +from scratchv.backend.llvm_codegen import LLVMCodegen +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.frontend.dsl_parser import DSLParser +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType + + +def compile_add(tmp_path, frontend, dtype, level): + """Compile the same scalar constant Add through either shared frontend.""" + output = tmp_path / "out.ll" + driver = CompilerDriver(CompilerConfig(backend="llvm", optimize_level=level)) + if frontend == "dsl": + source = ( + f"a = const(7,dtype:{dtype.value})\n" + f"b = const(3,dtype:{dtype.value})\n" + "r = add(a,b)\nreturn r" + ) + result = driver.compile("", str(output), dsl_source=source) + else: + numpy_type, onnx_type = { + DataType.INT32: (np.int32, onnx.TensorProto.INT32), + DataType.FLOAT32: (np.float32, onnx.TensorProto.FLOAT), + DataType.FLOAT64: (np.float64, onnx.TensorProto.DOUBLE), + }[dtype] + initializers = [ + onnx.numpy_helper.from_array(np.asarray(value, dtype=numpy_type), name) + for name, value in (("a", 7), ("b", 3)) + ] + graph = onnx.helper.make_graph( + [onnx.helper.make_node("Add", ["a", "b"], ["r"])], + "main", + [], + [onnx.helper.make_tensor_value_info("r", onnx_type, [])], + initializers, + ) + model = onnx.helper.make_model( + graph, + opset_imports=[onnx.helper.make_opsetid("", 13)], + ) + onnx.checker.check_model(model) + model_path = tmp_path / "add.onnx" + onnx.save(model, model_path) + result = driver.compile(str(model_path), str(output)) + return result, output + + +class TestLLVMInputTypes: + @pytest.mark.parametrize("frontend", ["dsl", "onnx"]) + @pytest.mark.parametrize("level", ["none", "basic", "all"]) + def test_llvm_rejects_i32_add_without_output(self, tmp_path, frontend, level): + result, output = compile_add(tmp_path, frontend, DataType.INT32, level) + + assert not result.success + assert "LLVM backend does not support load_const for i32" in result.errors[0] + assert result.output_text == "" + assert not output.exists() + + @pytest.mark.parametrize("operation", ["Add", "Sub", "Mul", "Div"]) + @pytest.mark.parametrize("level", ["none", "basic", "all"]) + def test_llvm_rejects_nonconstant_onnx_i32_arithmetic( + self, tmp_path, operation, level + ): + # Runtime parameters prevent constant folding from hiding arithmetic lowering. + inputs = [ + onnx.helper.make_tensor_value_info(name, onnx.TensorProto.INT32, []) + for name in ("a", "b") + ] + graph = onnx.helper.make_graph( + [onnx.helper.make_node(operation, ["a", "b"], ["r"])], + "main", + inputs, + [onnx.helper.make_tensor_value_info("r", onnx.TensorProto.INT32, [])], + ) + model = onnx.helper.make_model( + graph, + opset_imports=[onnx.helper.make_opsetid("", 13)], + ) + onnx.checker.check_model(model) + source = tmp_path / "arithmetic.onnx" + onnx.save(model, source) + output = tmp_path / "out.ll" + driver = CompilerDriver( + CompilerConfig( + backend="llvm", + optimize_level=level, + verify_ir=True, + ) + ) + + result = driver.compile(str(source), str(output)) + + assert not result.success + assert ( + f"LLVM backend does not support {operation.lower()} for i32" + in result.errors[0] + ) + assert result.output_text == "" + assert not output.exists() + + def test_llvm_rejects_unused_i32_before_optimization_and_preserves_output( + self, + tmp_path, + monkeypatch, + ): + driver = CompilerDriver( + CompilerConfig( + backend="llvm", + optimize_level="all", + verify_ir=True, + ) + ) + + def unexpected_optimization(program): + pytest.fail("unsupported LLVM input must be rejected before optimization") + + monkeypatch.setattr(driver, "_run_optimizations", unexpected_optimization) + output = tmp_path / "existing.ll" + output.write_bytes(b"existing output\x00") + result = driver.compile( + "", + str(output), + dsl_source="unused = const(7,dtype:i32)\nreturn 1.0", + ) + + assert not result.success + assert "load_const for i32" in result.errors[0] + assert output.read_bytes() == b"existing output\x00" + + @pytest.mark.parametrize("operation", ["add", "sub", "mul", "div", "neg"]) + def test_direct_llvm_codegen_rejects_integer_arithmetic_without_constants( + self, operation + ): + builder = IRBuilder() + lhs = builder.make_value("lhs", DataType.INT32) + rhs = builder.make_value("rhs", DataType.INT32) + builder.new_function("main", [lhs, rhs]) + builder.new_block("entry") + operands = [lhs] if operation == "neg" else [lhs, rhs] + builder.ret(getattr(builder, operation)(*operands)) + + with pytest.raises(ValueError, match=f"{operation} for i32"): + LLVMCodegen(builder.program).emit() + + def test_direct_llvm_save_preserves_file_on_unsupported_constant(self, tmp_path): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + builder.ret(builder.load_const(7, DataType.INT64)) + output = tmp_path / "existing.ll" + output.write_text("existing output") + + with pytest.raises(ValueError, match="load_const for i64"): + LLVMCodegen(builder.program).save(str(output)) + + assert output.read_text() == "existing output" + + @pytest.mark.parametrize("frontend", ["dsl", "onnx"]) + @pytest.mark.parametrize("level", ["none", "basic", "all"]) + @pytest.mark.parametrize("dtype", [DataType.FLOAT32, DataType.FLOAT64]) + def test_llvm_float_add_output_parses_and_verifies( + self, tmp_path, frontend, level, dtype + ): + llvm = pytest.importorskip("llvmlite.binding") + result, output = compile_add(tmp_path, frontend, dtype, level) + + assert result.success, result.errors + module = llvm.parse_assembly(result.output_text) + module.verify() + expected_type = "float" if dtype is DataType.FLOAT32 else "double" + assert f"define {expected_type} @main()" in str(module) + assert output.read_text() == result.output_text + + def test_llvm_integer_loop_control_still_parses_and_verifies(self): + llvm = pytest.importorskip("llvmlite.binding") + program = DSLParser().parse("for i = 0, 3\nendfor\nreturn 0") + + llvm.parse_assembly(LLVMCodegen(program).emit()).verify() diff --git a/tests/test_llvm_scalar_constants.py b/tests/test_llvm_scalar_constants.py new file mode 100644 index 00000000..56cd83b0 --- /dev/null +++ b/tests/test_llvm_scalar_constants.py @@ -0,0 +1,298 @@ +"""LLVM scalar constants must parse and retain their storage bits (R7).""" + +import ctypes + +import onnx +import pytest + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.backend.llvm_codegen import LLVMCodegen +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType + + +llvm = pytest.importorskip("llvmlite.binding") + + +def _return_bits(assembly, width): + """Observe raw bits before Python can convert or quiet a floating result.""" + floating_type = "float" if width == 32 else "double" + integer_type = f"i{width}" + wrapper = f""" +define {integer_type} @r7_return_bits() {{ +entry: + %value = call {floating_type} @main() + %bits = bitcast {floating_type} %value to {integer_type} + ret {integer_type} %bits +}} +""" + module = llvm.parse_assembly(assembly + wrapper) + module.verify() + llvm.initialize_native_target() + llvm.initialize_native_asmprinter() + # Adapt only the execution target; keep the generated function unchanged. + module.triple = llvm.get_default_triple() + target = llvm.Target.from_default_triple().create_target_machine() + module.data_layout = str(target.target_data) + with llvm.create_mcjit_compiler(module, target) as engine: + engine.finalize_object() + return_type = ctypes.c_uint32 if width == 32 else ctypes.c_uint64 + function = ctypes.CFUNCTYPE(return_type)( + engine.get_function_address("r7_return_bits") + ) + return function() + + +class TestLLVMScalarConstants: + @pytest.mark.parametrize("level", ("none", "basic", "all")) + @pytest.mark.parametrize( + "dtype,token,expected", + ( + ("f32", "1.2", 0x3F99999A), + ("f64", "1.0000000000000002", 0x3FF0000000000001), + ("f64", "-0.0", 0x8000000000000000), + ), + ) + def test_typed_dsl_constant_parses_and_returns_exact_bits( + self, tmp_path, level, dtype, token, expected + ): + output = tmp_path / "constant.ll" + result = CompilerDriver( + CompilerConfig(backend="llvm", optimize_level=level) + ).compile( + "", + str(output), + dsl_source=f"a = const({token},dtype:{dtype})\nreturn a\n", + ) + assert result.success, result.errors + assert output.read_text() == result.output_text + assert _return_bits(result.output_text, int(dtype[1:])) == expected + + @pytest.mark.parametrize("width", (32, 64)) + @pytest.mark.parametrize("source", ("instruction", "value", "both", "bare")) + @pytest.mark.parametrize( + "case", ("one", "negative_zero", "quiet_nan", "signaling_nan") + ) + def test_ir_constant_encoding_is_authoritative(self, width, source, case): + patterns = { + "one": (0x3F800000, 0x3FF0000000000000), + "negative_zero": (0x80000000, 0x8000000000000000), + "quiet_nan": (0x7FC01234, 0x7FF8000000001234), + "signaling_nan": (0x7F801234, 0x7FF0000000001234), + } + bits = patterns[case][width == 64] + dtype = DataType.FLOAT32 if width == 32 else DataType.FLOAT64 + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + if source == "bare": + value = builder.make_const(0.0, dtype) + value.bit_pattern = bits + else: + value = builder.load_const(0.0, dtype, bit_pattern=bits) + definition = builder.current_block.instructions[-1] + if source == "instruction": + value.bit_pattern = None + elif source == "value": + definition.attrs.pop("bit_pattern") + builder.ret(value) + assert verify_ir(builder.program) == (True, []) + assert _return_bits(LLVMCodegen(builder.program).emit(), width) == bits + + @pytest.mark.parametrize("level", ("none", "basic", "all")) + def test_f32_operands_are_rounded_before_constant_arithmetic(self, tmp_path, level): + # 16777217 rounds to 16777216 in binary32 before the subtraction. + source = ( + "a = const(16777217.0,dtype:f32)\n" + "b = const(16777216.0,dtype:f32)\n" + "r = sub(a,b)\nreturn r\n" + ) + result = CompilerDriver( + CompilerConfig(backend="llvm", optimize_level=level) + ).compile( + "", + str(tmp_path / "rounding.ll"), + dsl_source=source, + ) + assert result.success, result.errors + assert _return_bits(result.output_text, 32) == 0x00000000 + + @pytest.mark.parametrize("level", ("none", "basic", "all")) + @pytest.mark.parametrize( + "dtype,token,expected", + ( + ("f32", "0.1", 0x3DCCCCCD), + ("f32", "-0.0", 0x80000000), + ("f32", "0.0", 0x00000000), + ("f32", "inf", 0x7F800000), + ("f32", "-inf", 0xFF800000), + ("f32", "nan", 0x7FC00000), + ("f32", "1.401298464324817e-45", 0x00000001), + ("f32", "1.1754943508222875e-38", 0x00800000), + ("f32", "3.4028234663852886e38", 0x7F7FFFFF), + ("f32", "1.0000000596046448", 0x3F800000), + ("f32", "1.000000059604645", 0x3F800001), + ("f64", "0.1", 0x3FB999999999999A), + ("f64", "0.0", 0x0000000000000000), + ("f64", "inf", 0x7FF0000000000000), + ("f64", "-inf", 0xFFF0000000000000), + ("f64", "nan", 0x7FF8000000000000), + ("f64", "5e-324", 0x0000000000000001), + ("f64", "2.2250738585072014e-308", 0x0010000000000000), + ("f64", "1.7976931348623157e308", 0x7FEFFFFFFFFFFFFF), + ), + ) + def test_dsl_boundary_constant_returns_exact_bits( + self, tmp_path, level, dtype, token, expected + ): + result = CompilerDriver( + CompilerConfig(backend="llvm", optimize_level=level) + ).compile( + "", + str(tmp_path / "boundary.ll"), + dsl_source=f"a = const({token},dtype:{dtype})\nreturn a\n", + ) + assert result.success, result.errors + assert _return_bits(result.output_text, int(dtype[1:])) == expected + + @pytest.mark.parametrize("level", ("none", "basic", "all")) + @pytest.mark.parametrize("width", (32, 64)) + @pytest.mark.parametrize( + "case", + ( + "ordinary", + "positive_zero", + "negative_zero", + "infinity", + "negative_infinity", + "subnormal", + "quiet_nan", + "signaling_nan", + ), + ) + def test_onnx_scalar_initializer_keeps_original_bits( + self, tmp_path, level, width, case + ): + patterns = { + "ordinary": (0x3F99999A, 0x3FF0000000000001), + "positive_zero": (0x00000000, 0x0000000000000000), + "negative_zero": (0x80000000, 0x8000000000000000), + "infinity": (0x7F800000, 0x7FF0000000000000), + "negative_infinity": (0xFF800000, 0xFFF0000000000000), + "subnormal": (0x00000001, 0x0000000000000001), + "quiet_nan": (0x7FC01234, 0x7FF8000000001234), + "signaling_nan": (0x7F801234, 0x7FF0000000001234), + } + bits = patterns[case][width == 64] + tensor_type = onnx.TensorProto.FLOAT if width == 32 else onnx.TensorProto.DOUBLE + initializer = onnx.TensorProto( + name="a", + data_type=tensor_type, + raw_data=bits.to_bytes(width // 8, "little"), + ) + graph = onnx.helper.make_graph( + [], + "main", + [], + [onnx.helper.make_tensor_value_info("a", tensor_type, [])], + [initializer], + ) + model = onnx.helper.make_model( + graph, + opset_imports=[onnx.helper.make_opsetid("", 13)], + ir_version=9, + ) + onnx.checker.check_model(model) + path = tmp_path / "constant.onnx" + onnx.save(model, path) + result = CompilerDriver( + CompilerConfig(backend="llvm", optimize_level=level) + ).compile( + str(path), + str(tmp_path / "constant.ll"), + ) + assert result.success, result.errors + assert _return_bits(result.output_text, width) == bits + + @pytest.mark.parametrize( + "dtype,expected", + ( + ("f32", 0x80000000), + ("f64", 0x8000000000000000), + ), + ) + @pytest.mark.parametrize( + "passes", + ( + ("ir-peephole",), + ("constant-folding", "ir-peephole", "constant-folding"), + ), + ) + def test_explicit_passes_do_not_erase_negative_zero( + self, tmp_path, dtype, expected, passes + ): + source = ( + f"a = const(-0.0,dtype:{dtype})\n" + f"b = const(0.0,dtype:{dtype})\n" + "r = mul(a,b)\nreturn r\n" + ) + result = CompilerDriver( + CompilerConfig( + backend="llvm", + optimize_level="none", + passes=passes, + verify_ir=True, + ) + ).compile("", str(tmp_path / "passes.ll"), dsl_source=source) + assert result.success, result.errors + assert _return_bits(result.output_text, int(dtype[1:])) == expected + + @pytest.mark.parametrize("width", (32, 64)) + @pytest.mark.parametrize("source", ("instruction", "value", "bare")) + @pytest.mark.parametrize("invalid", ("negative", "too_wide", "boolean", "fraction")) + def test_invalid_encoding_is_rejected_without_overwriting_output( + self, tmp_path, width, source, invalid + ): + bits = { + "negative": -1, + "too_wide": 1 << width, + "boolean": True, + "fraction": 1.5, + }[invalid] + dtype = DataType.FLOAT32 if width == 32 else DataType.FLOAT64 + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + value = ( + builder.make_const(0.0, dtype) + if source == "bare" + else builder.load_const(0.0, dtype) + ) + if source == "instruction": + builder.current_block.instructions[-1].attrs["bit_pattern"] = bits + else: + value.bit_pattern = bits + builder.ret(value) + output = tmp_path / "existing.ll" + output.write_text("previous output") + with pytest.raises(ValueError, match="bit_pattern"): + LLVMCodegen(builder.program).save(str(output)) + assert output.read_text() == "previous output" + + @pytest.mark.parametrize("width", (32, 64)) + def test_conflicting_encodings_are_rejected_without_overwriting_output( + self, tmp_path, width + ): + dtype = DataType.FLOAT32 if width == 32 else DataType.FLOAT64 + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + value = builder.load_const(0.0, dtype, bit_pattern=0) + builder.current_block.instructions[-1].attrs["bit_pattern"] = 1 << (width - 1) + builder.ret(value) + output = tmp_path / "existing.ll" + output.write_text("previous output") + with pytest.raises(ValueError, match="bit_pattern differs"): + LLVMCodegen(builder.program).save(str(output)) + assert output.read_text() == "previous output" diff --git a/tests/test_optimizer_advanced.py b/tests/test_optimizer_advanced.py index 44c2bc9f..184d84c0 100644 --- a/tests/test_optimizer_advanced.py +++ b/tests/test_optimizer_advanced.py @@ -1,29 +1,33 @@ """Tests for advanced optimizer passes: peephole, muladd_fusion, LICM.""" from scratchv.ir.builder import IRBuilder -from scratchv.ir.types import OpCode +from scratchv.ir.types import DataType, OpCode from scratchv.optimizer.peephole import IRPeepholeOptimizer from scratchv.optimizer.muladd_fusion import MulAddFusion from scratchv.optimizer.licm import LICM +from scratchv.analysis.ir_verifier import verify_ir class TestIRPeepholeOptimizer: - def test_eliminate_addi_zero(self): + def test_keep_add_zero_result_definition(self): builder = IRBuilder() - builder.new_function("test") + function = builder.new_function("test") builder.new_block("entry") - a = builder.make_value(name="a") - c = builder.make_value(name="c") - # add a, 0 (no-op) - zero = builder.make_value(name="_z", is_constant=True, const_value=0) + a = builder.make_value(name="a", dtype=DataType.INT32) + function.params.append(a) + c = builder.make_value(name="c", dtype=DataType.INT32) + # ADD defines c even though its value equals a. + zero = builder.make_value(name="_z", dtype=DataType.INT32, + is_constant=True, const_value=0) builder._emit(OpCode.ADD, c, [a, zero]) builder.ret(c) opt = IRPeepholeOptimizer() - opt.optimize(builder.program) - # The addi 0 should have been removed + assert opt.optimize(builder.program) == 0 block = builder.program.functions[0].blocks[0] - assert all(i.opcode != OpCode.ADD for i in block.instructions) + assert block.instructions[0].opcode is OpCode.ADD + assert block.instructions[-1].operands[0] is c + assert verify_ir(builder.program) == (True, []) def test_eliminate_mul_one(self): builder = IRBuilder() @@ -65,13 +69,14 @@ def test_eliminate_mul_zero(self): class TestMulAddFusion: - def test_fuse_mul_add(self): + def test_keep_mul_add_without_valid_fused_opcode(self): builder = IRBuilder() - builder.new_function("test") + function = builder.new_function("test") builder.new_block("entry") - a = builder.make_value(name="a") - b = builder.make_value(name="b") - acc = builder.make_value(name="acc") + a = builder.make_value(name="a", dtype=DataType.INT32) + b = builder.make_value(name="b", dtype=DataType.INT32) + acc = builder.make_value(name="acc", dtype=DataType.INT32) + function.params.extend([a, b, acc]) tmp = builder.mul(a, b) result = builder.add(tmp, acc) @@ -79,14 +84,16 @@ def test_fuse_mul_add(self): opt = MulAddFusion() count = opt.optimize(builder.program) - assert count == 1 + assert count == 0 block = builder.program.functions[0].blocks[0] - # Should have one ADD (the fused one) and no separate MUL+ADD + # A valid MUL and binary ADD stay separate; no multiplier is dropped. adds = [i for i in block.instructions if i.opcode == OpCode.ADD] muls = [i for i in block.instructions if i.opcode == OpCode.MUL] assert len(adds) == 1 - assert len(muls) == 0 + assert len(muls) == 1 + assert adds[0].operands == [tmp, acc] + assert verify_ir(builder.program) == (True, []) def test_no_fuse_without_mul(self): builder = IRBuilder() @@ -108,8 +115,8 @@ def test_hoist_invariant(self): builder.new_function("test") builder.new_block("entry") - a = builder.make_value(name="a") - b = builder.make_value(name="b") + a = builder.make_value(name="a", dtype=DataType.INT32) + b = builder.make_value(name="b", dtype=DataType.INT32) # Loop with invariant mul inside iv = builder.for_loop(0, 10) # FOR @@ -138,7 +145,7 @@ def test_no_hoist_variant(self): iv = builder.for_loop(0, 10) # This add depends on iv (loop variant) → should not be hoisted - builder.add(iv, builder.load_const(1.0)) + builder.add(iv, builder.load_const(1, DataType.INT32)) builder.endfor() builder.ret() diff --git a/tests/test_regalloc_pseudo.py b/tests/test_regalloc_pseudo.py index 48183598..6d87426d 100644 --- a/tests/test_regalloc_pseudo.py +++ b/tests/test_regalloc_pseudo.py @@ -1,5 +1,7 @@ """Register-allocation tests for machine pseudo-instructions.""" +from typing import List + import pytest from scratchv.backend import regalloc_linear, regalloc_linear_v1_5 @@ -28,9 +30,13 @@ STRUCTURAL_MACHINE_PSEUDOS = {MachineOp.LABEL} EXTERNAL_EXTENSION_PSEUDOS = { MachineOp.FABS_D, + MachineOp.FABS_S, MachineOp.FNEG_D, MachineOp.LI_D, + MachineOp.LA, + MachineOp.FNEG_S, MachineOp.FMV_S, + MachineOp.FMV_D, } @@ -51,7 +57,7 @@ def _run_rv32(assembly: str, instruction_limit: int = 16): def _allocate_and_run( allocator_module, - instructions: list[MachineInstr], + instructions: List[MachineInstr], *, instruction_limit: int = 16, ): @@ -227,6 +233,7 @@ def test_label_full_pipeline_is_zero_bytes_and_execution_continues( "opcode, operands, extension", [ (MachineOp.FABS_D, ("result", "source"), "D"), + (MachineOp.FABS_S, ("result", "source"), "F"), (MachineOp.FNEG_D, ("result", "source"), "D"), (MachineOp.LI_D, ("result", 1), "D"), (MachineOp.FMV_S, ("result", "source"), "F"), @@ -235,14 +242,21 @@ def test_label_full_pipeline_is_zero_bytes_and_execution_continues( def test_external_extension_pseudo_is_explicitly_rejected_by_rv32im_encoder( opcode, operands, extension ): - dst = MachineOperand.vreg(operands[0]) + from scratchv.backend.machine_types import RegisterClass + + width = 64 if extension == "D" else 32 + dst = MachineOperand.vreg(operands[0], RegisterClass.FPR, width) src = ( MachineOperand.immediate(operands[1]) if isinstance(operands[1], int) - else MachineOperand.vreg(operands[1]) + else MachineOperand.vreg(operands[1], RegisterClass.FPR, width) ) instruction = MachineInstr(opcode, dst, src) block = regalloc_linear.block_from_machine_instrs([instruction]) + if opcode is MachineOp.LI_D: + with pytest.raises(ValueError, match="not a standard RISC-V instruction"): + regalloc_linear.LinearScanAllocator(["t0", "t1"]).emit(block) + return assembly = regalloc_linear.LinearScanAllocator(["t0", "t1"]).emit(block) with pytest.raises( diff --git a/tests/test_regalloc_result_reuse.py b/tests/test_regalloc_result_reuse.py new file mode 100644 index 00000000..d841949c --- /dev/null +++ b/tests/test_regalloc_result_reuse.py @@ -0,0 +1,147 @@ +"""Allocation outputs must describe only the current call on a reused allocator.""" + +from copy import deepcopy + +import pytest + +from scratchv.backend.allocation import AllocationResult +from scratchv.backend.machine_types import ( + FP_TEMP_REGS, + MachineInstr as MI, + MachineOp as O, + MachineOperand as P, + RegisterClass as RC, +) +from scratchv.backend.regalloc_linear import ( + LinearScanAllocator, + block_from_machine_instrs, +) + + +def _stream(kind, seed=7, count=1): + is_float = kind != "i32" + width = 64 if kind == "f64" else 32 + reg_class = RC.FPR if is_float else RC.GPR + + def value(index): + return P.vreg(f"v{index}", reg_class, width) + + load = O.FLD if width == 64 else O.FLW + add = (O.FADD_D if width == 64 else O.FADD_S) if is_float else O.ADD + move = (O.FMV_D if width == 64 else O.FMV_S) if is_float else O.MV + instructions = [ + ( + MI(load, value(index), P.reg(f"{8 * (seed + index)}(sp)")) + if is_float + else MI(O.LI, value(index), P.immediate(seed + index)) + ) + for index in range(count) + ] + instructions.extend( + MI(add, value(0), value(0), value(index)) for index in range(1, count) + ) + instructions.append( + MI( + move, + P.reg("fa0" if is_float else "a0", reg_class, width), + value(0), + ) + ) + return instructions + + +def _run(allocator, entry, instructions): + if entry == "allocate_result": + return allocator.allocate_result(instructions) + text = allocator.emit(block_from_machine_instrs(instructions)) + assert isinstance(text, str) + if instructions: + assert text + else: + assert text == "" + return allocator.allocation_result + + +def _assert_empty(allocator, result): + assert result == AllocationResult([], {}, {}, 0) + assert allocator.spill_slot_count == 0 + assert allocator.stack_slot == 0 + assert allocator.spill_store_count == 0 + assert allocator.reload_load_count == 0 + + +class TestRegisterAllocationResultReuse: + @pytest.mark.parametrize("entry", ["allocate_result", "emit"]) + @pytest.mark.parametrize("kind", ["i32", "f32", "f64"]) + def test_nonempty_empty_nonempty_results_belong_to_each_call(self, entry, kind): + allocator = LinearScanAllocator() + first = _run(allocator, entry, _stream(kind)) + assert first.instructions + assert set(first.register_map) == {"v0"} + snapshot = deepcopy(first) + + empty = _run(allocator, entry, []) + _assert_empty(allocator, empty) + # Clearing the allocator must not clear instructions held by its caller. + assert first == snapshot + + current_stream = _stream(kind, seed=11) + current = _run(allocator, entry, current_stream) + assert current == LinearScanAllocator().allocate_result(current_stream) + assert current != snapshot + assert first == snapshot + assert empty == AllocationResult([], {}, {}, 0) + + @pytest.mark.parametrize("entry", ["allocate_result", "emit"]) + def test_fresh_and_repeated_empty_calls_return_empty_results(self, entry): + allocator = LinearScanAllocator() + first = _run(allocator, entry, []) + _assert_empty(allocator, first) + _assert_empty(allocator, _run(allocator, entry, [])) + + _run(allocator, entry, _stream("i32")) + assert first == AllocationResult([], {}, {}, 0) + + @pytest.mark.parametrize("entry", ["allocate_result", "emit"]) + @pytest.mark.parametrize("kind", ["i32", "f32", "f64"]) + def test_empty_call_discards_spill_instructions_and_layout(self, entry, kind): + allocator = LinearScanAllocator(["t0", "t1"]) + count = 4 if kind == "i32" else len(FP_TEMP_REGS) + 1 + first = _run(allocator, entry, _stream(kind, count=count)) + assert first.spill_slots + assert first.spill_bytes > 0 + assert allocator.spill_store_count > 0 + assert allocator.reload_load_count > 0 + store = {"i32": O.SW, "f32": O.FSW, "f64": O.FSD}[kind] + assert any(instr.op is store for instr in first.instructions) + snapshot = deepcopy(first) + + _assert_empty(allocator, _run(allocator, entry, [])) + assert first == snapshot + + current_stream = _stream(kind, seed=11) + current = _run(allocator, entry, current_stream) + assert current == LinearScanAllocator(["t0", "t1"]).allocate_result( + current_stream + ) + assert current.spill_slots == {} + assert current.spill_bytes == 0 + assert first == snapshot + + @pytest.mark.parametrize("first_entry", ["allocate_result", "emit"]) + def test_switching_interfaces_and_widths_does_not_reuse_old_output( + self, first_entry + ): + allocator = LinearScanAllocator() + first = _run(allocator, first_entry, _stream("f64")) + snapshot = deepcopy(first) + other_entry = "emit" if first_entry == "allocate_result" else "allocate_result" + + empty = _run(allocator, other_entry, []) + _assert_empty(allocator, empty) + # Reuse the same virtual name with a different width in the next call. + current_stream = _stream("f32", seed=11) + current = _run(allocator, first_entry, current_stream) + assert current == LinearScanAllocator().allocate_result(current_stream) + assert first == snapshot + assert empty == AllocationResult([], {}, {}, 0) diff --git a/tests/test_rv32_emulator.py b/tests/test_rv32_emulator.py index b669529d..0363900e 100644 --- a/tests/test_rv32_emulator.py +++ b/tests/test_rv32_emulator.py @@ -29,3 +29,40 @@ def test_sw_lw_round_trip_preserves_high_bit_values() -> None: assert emulator.regs[REG_ID["t1"]] == 0xFFFFFFFF assert emulator.mem[1020:1024] == b"\xff\xff\xff\xff" + + +def _run_integer_assembly(assembly: str) -> RV32Emulator: + binary = bytes(RISCVAEncoder().assemble(assembly)) + emulator = RV32Emulator(mem_size=4096) + emulator.load_code(binary) + emulator.run(max_instr=32) + return emulator + + +class TestRV32IntegerArithmetic: + def test_div_and_rem_use_signed_riscv_semantics(self) -> None: + emulator = _run_integer_assembly( + """\ +li t0, -17 +li t1, 5 +div t2, t0, t1 +rem t3, t0, t1 +ret +""" + ) + + assert emulator.regs[REG_ID["t2"]] == (-3 & 0xFFFFFFFF) + assert emulator.regs[REG_ID["t3"]] == (-2 & 0xFFFFFFFF) + + def test_rem_divide_by_zero_and_srai_follow_rv32(self) -> None: + emulator = _run_integer_assembly( + """\ +li t0, -17 +rem t1, t0, zero +srai t2, t0, 31 +ret +""" + ) + + assert emulator.regs[REG_ID["t1"]] == (-17 & 0xFFFFFFFF) + assert emulator.regs[REG_ID["t2"]] == 0xFFFFFFFF diff --git a/tests/test_standalone_execution.py b/tests/test_standalone_execution.py index 18094326..8122e467 100644 --- a/tests/test_standalone_execution.py +++ b/tests/test_standalone_execution.py @@ -139,7 +139,8 @@ def input_values(shape, seed): def fixed_tensor(name, values): # Powers-of-two scaling is exact; small products avoid int32 overflow. - return numpy_helper.from_array(np.asarray(values, dtype=np.float32) / 65536, name) + scaled = np.asarray(values, dtype=np.float32) / np.float32(65536) + return numpy_helper.from_array(scaled, name) def conv_reference(x, weights, bias, stride, pad): diff --git a/tests/test_topic28_acceptance.py b/tests/test_topic28_acceptance.py new file mode 100644 index 00000000..90336d21 --- /dev/null +++ b/tests/test_topic28_acceptance.py @@ -0,0 +1,201 @@ +"""Original Topic 28 deliverables, executed by an independent RV32 caller. + +This is the bounded acceptance entry point. Defect regressions for introduced +or affected behavior are collected by the complete suite. +""" + +from pathlib import Path +import struct + +import onnx +import pytest +from onnx import helper + +from scratchv.compiler import CompilerDriver +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as T +from scratchv.main import args_to_config, build_arg_parser +from tests.topic28_runtime import observe_abi +from tests.topic28_support import MODES, TARGETS, compile_ir, configuration + + +EXAMPLES = Path(__file__).resolve().parents[1] / "benchmarks" / "topic28" + + +def bits(value, dtype): + if dtype is T.INT32: + return value + return int.from_bytes( + struct.pack("", 3.0, 2.0, True), + (">", 2.0, 3.0, False), + ("<=", 2.0, 2.0, True), + ("<=", 3.0, 2.0, False), + (">=", 2.0, 2.0, True), + (">=", 2.0, 3.0, False), + ], + ) + def test_f64_memory_comparison_and_branches( + self, mode, operator, a, b, condition, tmp_path + ): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + slot = builder.alloca(1, T.FLOAT64) + builder.store(slot, builder.load_const(a, T.FLOAT64)) + loaded = builder.load(slot) + assert loaded.dtype is T.FLOAT64 + builder.br_compare( + loaded, operator, builder.load_const(b, T.FLOAT64), "yes", "no" + ) + builder.new_block("yes") + builder.ret(builder.load_const(7.0, T.FLOAT64)) + builder.new_block("no") + builder.ret(builder.load_const(9.0, T.FLOAT64)) + assembly = compile_ir(builder.program, tmp_path, configuration(mode)) + assert "fld " in assembly and "fsd " in assembly + assert observe_abi(assembly, tmp_path, T.FLOAT64) == ( + bits(7.0 if condition else 9.0, T.FLOAT64), + 0, + ) diff --git a/tests/test_topic28_cli_integration.py b/tests/test_topic28_cli_integration.py new file mode 100644 index 00000000..c89971a4 --- /dev/null +++ b/tests/test_topic28_cli_integration.py @@ -0,0 +1,184 @@ +"""Compiler-driver integration tests for extended instruction selection.""" + +from pathlib import Path + +import pytest + +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.main import args_to_config, build_arg_parser +from scratchv.simulator.tinyfive import verify_assembly + + +class TestExtendedSelectorCLI: + def test_extended_isel_cli_flag_reaches_compiler_config(self): + args = build_arg_parser().parse_args(["input.dsl", "--extended-isel"]) + + config = args_to_config(args) + + assert config.use_extended_isel is True + + def test_extended_isel_rejects_llvm_backend(self, tmp_path: Path): + driver = CompilerDriver( + CompilerConfig(backend="llvm", use_extended_isel=True), + ) + + result = driver.compile( + "", + str(tmp_path / "out.ll"), + dsl_source="x = add(a, b)\nreturn x", + ) + + assert not result.success + assert result.errors == ["--extended-isel requires the RISC-V backend"] + + def test_extended_isel_rejects_dag_selector(self, tmp_path: Path): + driver = CompilerDriver( + CompilerConfig(use_dag_isel=True, use_extended_isel=True), + ) + + result = driver.compile( + "", + str(tmp_path / "out.s"), + dsl_source="x = add(a, b)\nreturn x", + ) + + assert not result.success + assert result.errors == [ + "--extended-isel and --dag-isel are mutually exclusive", + ] + + @pytest.mark.parametrize("reg_alloc", ["naive", "greedy", "linear"]) + def test_extended_isel_compiles_integer_remainder_end_to_end( + self, + tmp_path: Path, + reg_alloc: str, + ): + source = """ +a = const(7, dtype:i32) +b = const(3, dtype:i32) +r = rem(a, b) +return r +""" + driver = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + verify_ir=True, + reg_alloc=reg_alloc, + ), + ) + + result = driver.compile( + "", + str(tmp_path / "out.s"), + dsl_source=source, + ) + + assert result.success, result.errors + rem_line = next( + line + for line in result.output_text.splitlines() + if line.strip().startswith("rem ") + ) + assert "rem t" in rem_line + assert ", 7" not in rem_line + assert ", 3" not in rem_line + + @pytest.mark.parametrize( + ("expression", "expected"), + [ + ("rem(a, b)", -2), + ("min(a, b)", -17), + ("max(a, b)", 5), + ("abs(a)", 17), + ], + ) + def test_extended_integer_ops_execute_in_tinyfive( + self, + tmp_path: Path, + expression: str, + expected: int, + ): + pytest.importorskip("tinyfive") + source = """ +a = const(-17, dtype:i32) +b = const(5, dtype:i32) +r = {expression} +return r +""".format( + expression=expression + ) + driver = CompilerDriver( + CompilerConfig(use_extended_isel=True, verify_ir=True), + ) + + result = driver.compile( + "", + str(tmp_path / "out.s"), + dsl_source=source, + ) + execution = verify_assembly(result.output_text) + + assert result.success, result.errors + assert execution["success"], execution["error"] + assert execution["return_value"] == expected + + def test_extended_abs_int32_min_preserves_twos_complement_value( + self, + tmp_path: Path, + ): + pytest.importorskip("tinyfive") + source = """ +a = const(-2147483648, dtype:i32) +r = abs(a) +return r +""" + result = CompilerDriver( + CompilerConfig(use_extended_isel=True, verify_ir=True), + ).compile("", str(tmp_path / "out.s"), dsl_source=source) + execution = verify_assembly(result.output_text) + + assert result.success, result.errors + assert execution["success"], execution["error"] + assert execution["return_value"] == -(1 << 31) + + def test_extended_isel_reports_unsupported_integer_operation_type( + self, + tmp_path: Path, + ): + source = """ +a = const(7.0, dtype:f32) +b = const(3.0, dtype:f32) +r = rem(a, b) +return r +""" + driver = CompilerDriver(CompilerConfig(use_extended_isel=True)) + + result = driver.compile( + "", + str(tmp_path / "out.s"), + dsl_source=source, + ) + + assert not result.success + assert "rem does not support f32" in result.errors[0] + + def test_integer_extension_ops_require_explicit_feature_flag( + self, + tmp_path: Path, + ): + source = """ +a = const(-7, dtype:i32) +r = abs(a) +return r +""" + + result = CompilerDriver().compile( + "", + str(tmp_path / "out.s"), + dsl_source=source, + ) + + assert not result.success + assert result.errors == [ + "--extended-isel is required for operations: abs", + ] diff --git a/tests/test_topic28_constant_folding.py b/tests/test_topic28_constant_folding.py new file mode 100644 index 00000000..aed0a405 --- /dev/null +++ b/tests/test_topic28_constant_folding.py @@ -0,0 +1,102 @@ +"""Target-semantic constant-folding tests for extended integer operations.""" + +import pytest + +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, OpCode +from scratchv.optimizer.constant_folding import ConstantFolder + + +def _fold_binary(op: str, lhs: int, rhs: int): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + left = builder.load_const(lhs, DataType.INT32) + right = builder.load_const(rhs, DataType.INT32) + result = getattr(builder, op)(left, right) + builder.ret(result) + + changes = ConstantFolder().optimize(builder.program) + instruction = builder.program.functions[0].blocks[0].instructions[2] + return changes, instruction + + +def _fold_unary(op: str, value: int): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + source = builder.load_const(value, DataType.INT32) + result = getattr(builder, op)(source) + builder.ret(result) + + changes = ConstantFolder().optimize(builder.program) + instruction = builder.program.functions[0].blocks[0].instructions[1] + return changes, instruction + + +class TestIntegerConstantFolding: + @pytest.mark.parametrize( + ("lhs", "rhs", "expected"), + [ + (-7, 3, -2), + (7, -3, -2), + (-7, -3, 2), + (-(1 << 31), -1, -(1 << 31)), + ], + ) + def test_i32_division_folds_with_rv32_semantics(self, lhs, rhs, expected): + changes, instruction = _fold_binary("div", lhs, rhs) + + assert changes == 1 + assert instruction.opcode is OpCode.LOAD_CONST + assert instruction.attrs["value"] == expected + + @pytest.mark.parametrize( + ("lhs", "rhs", "expected"), + [ + (-7, 3, -1), + (7, -3, 1), + (-7, -3, -1), + (-(1 << 31), -1, 0), + ], + ) + def test_i32_remainder_folds_with_rv32_semantics(self, lhs, rhs, expected): + changes, instruction = _fold_binary("rem", lhs, rhs) + + assert changes == 1 + assert instruction.opcode is OpCode.LOAD_CONST + assert instruction.attrs["value"] == expected + + @pytest.mark.parametrize("op", ["div", "rem"]) + def test_i32_division_by_zero_is_not_folded(self, op): + changes, instruction = _fold_binary(op, 7, 0) + + assert changes == 0 + assert instruction.opcode is getattr(OpCode, op.upper()) + + @pytest.mark.parametrize( + ("op", "lhs", "rhs", "expected"), + [ + ("min", -(1 << 31), 7, -(1 << 31)), + ("min", 9, -4, -4), + ("max", (1 << 31) - 1, -1, (1 << 31) - 1), + ("max", -9, -4, -4), + ], + ) + def test_i32_min_max_are_constant_folded(self, op, lhs, rhs, expected): + changes, instruction = _fold_binary(op, lhs, rhs) + + assert changes == 1 + assert instruction.opcode is OpCode.LOAD_CONST + assert instruction.attrs["value"] == expected + + @pytest.mark.parametrize( + ("value", "expected"), + [(0, 0), (-19, 19), (-(1 << 31), -(1 << 31))], + ) + def test_i32_abs_is_constant_folded_with_rv32_wrap(self, value, expected): + changes, instruction = _fold_unary("abs", value) + + assert changes == 1 + assert instruction.opcode is OpCode.LOAD_CONST + assert instruction.attrs["value"] == expected diff --git a/tests/test_topic28_fifth_review.py b/tests/test_topic28_fifth_review.py new file mode 100644 index 00000000..14834b46 --- /dev/null +++ b/tests/test_topic28_fifth_review.py @@ -0,0 +1,454 @@ +"""Fifth-review regressions: source bytes, symbols, ELF metadata and ABI frames.""" + +import re +import struct +import subprocess + +import onnx +import numpy as np +import pytest +from onnx import TensorProto as TP, helper +from onnx.reference import ReferenceEvaluator + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.backend.abi_frame import apply_abi_frames +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.machine_types import ( + FloatConstant, + MachineInstr as MI, + MachineOp as O, + MachineOperand as P, + RegisterClass as RC, +) +from scratchv.compiler import CompilerDriver +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as T +from tests.topic28_runtime import observe +from tests.topic28_support import MODES, TARGETS, allocate, compile_ir, configuration + + +def length_field(number, payload): + # Write independently of the production wire reader. No Python FP values + # ever represent the original signaling/payload bits in this fixture. + def varint(value): + result = bytearray() + while value > 127: + result.append((value & 127) | 128) + value >>= 7 + result.append(value) + return bytes(result) + + return varint(number * 8 + 2) + varint(len(payload)) + payload + + +def typed_model(path, dtype, nt, raw, packed, operation, unknown_group_at=None): + width = 4 if dtype is T.FLOAT32 else 8 + tensor = TP(name="a", data_type=nt).SerializeToString() + field, wire = (4, 5) if width == 4 else (10, 1) + tensor += ( + length_field(field, raw.to_bytes(width, "little")) + if packed + else bytes([field * 8 + wire]) + raw.to_bytes(width, "little") + ) + # Unknown field 100 contains a nested group 101 and a varint value. + group = b"\xa3\x06\xab\x06\x08\x01\xac\x06\xa4\x06" + if unknown_group_at == "tensor": + tensor += group + output = "a" if operation == "return" else "out" + nodes = ( + [] if operation == "return" else [helper.make_node("Min", ["a", "b"], [output])] + ) + graph = helper.make_graph( + nodes, ".wire_model", [], [helper.make_tensor_value_info(output, nt, [])] + ).SerializeToString() + graph += length_field(5, tensor) + if operation != "return": + two = 0x40000000 if width == 4 else 0x4000000000000000 + graph += length_field( + 5, + TP( + name="b", data_type=nt, raw_data=two.to_bytes(width, "little") + ).SerializeToString(), + ) + if unknown_group_at == "graph": + graph += group + model = helper.make_model( + helper.make_graph([], "unused", [], []), + opset_imports=[helper.make_opsetid("", 13)], + ) + model.ClearField("graph") + path.write_bytes( + model.SerializeToString() + + length_field(7, graph) + + (group if unknown_group_at == "model" else b"") + ) + onnx.checker.check_model(str(path)) + assert raw.to_bytes(width, "little") in path.read_bytes() + + +class TestTopic28FifthReviewRegressions: + @pytest.mark.parametrize("target", TARGETS) + @pytest.mark.parametrize("packed", [False, True]) + @pytest.mark.parametrize("pattern", ["snan", "qnan", "negative_zero"]) + @pytest.mark.parametrize("operation", ["return", "Min"]) + def test_t12_typed_wire_bits_and_flags( + self, target, packed, pattern, operation, tmp_path + ): + dtype, nt, isa, abi = target + raw = ( + {"snan": 0xFF800123, "qnan": 0xFFC00123, "negative_zero": 0x80000000} + if dtype is T.FLOAT32 + else { + "snan": 0xFFF0000000000123, + "qnan": 0xFFF8000000000123, + "negative_zero": 0x8000000000000000, + } + )[pattern] + path = tmp_path / "input.onnx" + typed_model(path, dtype, nt, raw, packed, operation) + program = ONNXParser().parse(str(path)) + assert verify_ir(program) == (True, []) + assert program.global_values[0].bit_pattern == raw + result = CompilerDriver( + configuration("greedy", "all", True, isa=isa, abi=abi) + ).compile(str(path), str(tmp_path / "out.s")) + assert result.success, result.errors + canonical_nan = 0x7FC00000 if dtype is T.FLOAT32 else 0x7FF8000000000000 + if operation == "Min" and pattern != "negative_zero": + with np.errstate(invalid="ignore"): + assert np.isnan( + ReferenceEvaluator(onnx.load(path)).run(None, {})[0] + ).all() + expected = ( + (raw, 0) + if operation == "return" or pattern == "negative_zero" + else (canonical_nan, 0x10 if pattern == "snan" else 0) + ) + assert ( + observe( + result.output_text, + tmp_path, + dtype, + function=".wire_model", + isa=isa, + abi=abi, + separate=True, + ) + == expected + ) + + @pytest.mark.parametrize("target", [TARGETS[0], TARGETS[2]]) + @pytest.mark.parametrize("packed", [False, True]) + @pytest.mark.parametrize("location", ["model", "graph", "tensor"]) + def test_t12_unknown_protobuf_fields_preserve_source_bits( + self, target, packed, location, tmp_path + ): + dtype, nt, isa, abi = target + raw = 0x7F800123 if dtype is T.FLOAT32 else 0x7FF0000000000123 + path = tmp_path / "input.onnx" + typed_model(path, dtype, nt, raw, packed, "return", location) + result = CompilerDriver(configuration("greedy", isa=isa, abi=abi)).compile( + str(path), str(tmp_path / "out.s") + ) + assert result.success, result.errors + assert observe( + result.output_text, + tmp_path, + dtype, + function=".wire_model", + isa=isa, + abi=abi, + separate=True, + ) == (raw, 0) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("count", [1, 22]) + @pytest.mark.parametrize("block", ["next", ".next"]) + @pytest.mark.parametrize("function", ["main", ".role_model"]) + def test_t14_structured_blocks_do_not_create_frames( + self, mode, count, block, function, tmp_path + ): + instructions = [ + MI(O.LABEL, target=function, label_role="function"), + MI(O.LI, P.reg("s0"), P.immediate(99)), + ] + for index in range(count): + instructions.append(MI(O.LI, P.vreg(f"x{index}"), P.immediate(index + 1))) + instructions += [ + MI(O.J, target=block), + MI(O.LABEL, target=block, label_role="block"), + ] + value = P.vreg("x0") + for index in range(1, count): + dest = P.vreg(f"sum{index}") + instructions.append(MI(O.ADD, dest, value, P.vreg(f"x{index}"))) + value = dest + instructions += [ + MI(O.MV, P.reg("a0"), value), + MI(O.JALR, P.reg("zero"), P.reg("ra"), comment="return"), + ] + allocation = allocate(instructions, mode) + assert [ + (i.target, i.label_role) for i in allocation.instructions if i.op is O.LABEL + ] == [(function, "function"), (block, "block")] + assembly = apply_abi_frames( + AsmEmitter(allocation.instructions).emit(), allocation + ) + assert observe( + assembly, + tmp_path, + T.INT32, + function=function, + isa="rv32im", + abi="ilp32", + separate=True, + ) == (count * (count + 1) // 2, 0) + + @pytest.mark.parametrize("verify", [False, True]) + @pytest.mark.parametrize("extended", [False, True]) + @pytest.mark.parametrize("name", [".text", ".data", ".rodata", ".bss"]) + def test_t14_reserved_function_names_rejected_before_output( + self, verify, extended, name, tmp_path + ): + # Keep every name, selector and verifier path; allocation is never reached. + graph = helper.make_graph( + [], + name, + [], + [helper.make_tensor_value_info("a", TP.INT32, [])], + [TP(name="a", data_type=TP.INT32, raw_data=(7).to_bytes(4, "little"))], + ) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + onnx.checker.check_model(model) + path = tmp_path / "input.onnx" + onnx.save(model, path) + output = tmp_path / "out.s" + output.write_text("preserve") + result = CompilerDriver( + configuration("greedy", "all", verify, extended=extended) + ).compile(str(path), str(output)) + assert not result.success and any( + "function symbol" in error for error in result.errors + ) + assert output.read_text() == "preserve" + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("extended", [False, True]) + @pytest.mark.parametrize("verify", [False, True]) + @pytest.mark.parametrize("name", ["branch with space", "模型", "a,b"]) + def test_t14_unsafe_blocks_rejected_before_output( + self, mode, extended, verify, name, tmp_path + ): + b = IRBuilder() + b.new_function("main") + b.new_block(name) + b.ret(b.load_const(7, T.INT32)) + assert verify_ir(b.program) == (True, []) + output = tmp_path / "out.s" + output.write_text("preserve") + driver = CompilerDriver(configuration(mode, verify=verify, extended=extended)) + driver._parse = lambda *args: b.program + result = driver.compile("", str(output), dsl_source="") + assert not result.success and any( + "label symbol" in error for error in result.errors + ) + assert output.read_text() == "preserve" + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("extended", [False, True]) + @pytest.mark.parametrize("name", ["2branch", "x$y", ".Luser"]) + def test_t14_supported_internal_symbols_execute( + self, mode, extended, name, tmp_path + ): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + b.br(name) + b.new_block(name) + b.ret(b.load_const(7, T.INT32)) + assembly = compile_ir( + b.program, tmp_path, configuration(mode, extended=extended) + ) + assert observe(assembly, tmp_path, separate=True) == (7, 0) + + @pytest.mark.parametrize("name", [".text", ".data", ".bss", ".rodata"]) + def test_t14_reserved_call_targets_rejected(self, name): + allocation = allocate( + [ + MI(O.LABEL, target="main", label_role="function"), + MI(O.CALL, target=name), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ], + "greedy", + ) + with pytest.raises(ValueError, match="call symbol"): + AsmEmitter(allocation.instructions).emit() + + def test_t14_custom_section_cannot_be_a_label(self): + with pytest.raises(ValueError, match="label symbol"): + AsmEmitter( + [ + MI(O.SECTION, comment="custom"), + MI(O.LABEL, target=".custom", label_role="function"), + ] + ).emit() + + @pytest.mark.parametrize("extended", [False, True]) + def test_t14_elf_sizes_match_distinct_functions(self, extended, tmp_path): + b = IRBuilder() + for name, value in [("first", 5), ("second", 9)]: + b.new_function(name) + b.new_block("entry") + b.ret(b.load_const(value, T.INT32)) + assembly = compile_ir( + b.program, tmp_path, configuration("greedy", "all", True, extended=extended) + ) + for name, value in [("first", 5), ("second", 9)]: + path = tmp_path / name + path.mkdir() + assert observe(assembly, path, function=name, separate=True) == (value, 0) + obj = tmp_path / "first" / "product.o" + symbols = subprocess.check_output(["readelf", "-sW", str(obj)], text=True) + parsed = { + parts[-1]: (int(parts[1], 16), int(parts[2])) + for line in symbols.splitlines() + if len(parts := line.split()) >= 8 and parts[-1] in {"first", "second"} + } + sections = subprocess.check_output(["readelf", "-SW", str(obj)], text=True) + text = next( + line.split() + for line in sections.splitlines() + if re.search(r"\]\s+\.text\s", line) + ) + text_size = int(text[text.index(".text") + 4], 16) + assert parsed["first"][1] == parsed["second"][0] - parsed["first"][0] > 0 + assert parsed["second"][1] == text_size - parsed["second"][0] > 0 + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("dtype", [T.INT32, T.FLOAT32, T.FLOAT64]) + def test_t15_local_frames_do_not_inherit_other_functions( + self, mode, dtype, tmp_path + ): + b = IRBuilder() + for index in range(3): + b.new_function(f".frame{index}") + b.new_block("entry") + ptr = b.alloca(100 if dtype is T.FLOAT64 else 200, dtype) + b.store(ptr, b.load_const(5 + 4 * index, dtype)) + values = [b.load_const(value, dtype) for value in range(1, 25)] + b.br("use") + b.new_block("use") + total = values[0] + for value in values[1:]: + total = b.add(total, value) + b.ret(b.add(b.load(ptr, dtype), total)) + assembly = compile_ir(b.program, tmp_path, configuration(mode, "all", True)) + assert all( + int(size) <= 2032 for size in re.findall(r"addi sp, sp, -(\d+)", assembly) + ) + for index in range(3): + path = tmp_path / str(index) + path.mkdir() + value = 305 + 4 * index + expected = ( + value + if dtype is T.INT32 + else int.from_bytes( + struct.pack(" 2032 + assembly = apply_abi_frames( + AsmEmitter(allocation.instructions, pool).emit(), allocation + ) + for index in range(3): + path = tmp_path / str(index) + path.mkdir() + expected = count * (count + 1) // 2 + count * index + if dtype is not T.INT32: + expected = int.from_bytes( + struct.pack(" 0 else -2 + ) + + @pytest.mark.parametrize("level", LEVELS) + @pytest.mark.parametrize("input_value", [7, -7]) + def test_n4_integer_mul_add_keeps_both_operations( + self, level, input_value, tmp_path + ): + assert compile_memory(tmp_path, "n4", input_value, "greedy", True, level) == ( + 7 if input_value > 0 else -1 + ) + + @pytest.mark.parametrize( + "passes", [("ir-peephole",), ("ir-peephole", "ir-peephole")] + ) + def test_n3_explicit_identity_passes_are_safe(self, passes, tmp_path): + assert compile_memory(tmp_path, "n3", 7, "greedy", True, passes=passes) == 2 + + @pytest.mark.parametrize( + "passes", [("muladd-fusion",), ("muladd-fusion", "muladd-fusion")] + ) + def test_n4_explicit_fusion_passes_are_safe(self, passes, tmp_path): + assert compile_memory(tmp_path, "n4", 7, "greedy", True, passes=passes) == 7 diff --git a/tests/test_topic28_ir_frontend.py b/tests/test_topic28_ir_frontend.py new file mode 100644 index 00000000..cf5f4589 --- /dev/null +++ b/tests/test_topic28_ir_frontend.py @@ -0,0 +1,202 @@ +"""Extended instruction-selection IR and DSL coverage.""" + +from typing import Tuple + +import pytest + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.frontend.dsl_errors import DSLSyntaxError +from scratchv.frontend.dsl_parser import DSLParser +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import ( + BasicBlock, + DataType, + Function, + Instruction, + OpCode, + Program, + Value, +) + + +def _extension_program( + opcode: OpCode, + operand_types: Tuple[DataType, ...], + result_type: DataType, +) -> Program: + operands = [ + Value(f"arg{index}", dtype=dtype) for index, dtype in enumerate(operand_types) + ] + result = Value("result", dtype=result_type) + block = BasicBlock("entry") + block.add(Instruction(opcode, result, operands)) + block.add(Instruction(OpCode.RETURN, operands=[result])) + function = Function("main", params=operands, blocks=[block]) + program = Program() + program.add_function(function) + return program + + +class TestTypedIRFrontend: + def test_builder_propagates_arithmetic_result_types(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + lhs = builder.make_value("lhs", dtype=DataType.FLOAT64) + rhs = builder.make_value("rhs", dtype=DataType.FLOAT64) + + assert builder.add(lhs, rhs).dtype is DataType.FLOAT64 + assert builder.neg(lhs).dtype is DataType.FLOAT64 + + @pytest.mark.parametrize( + ("method", "opcode", "arity"), + [ + ("rem", OpCode.REM, 2), + ("min", OpCode.MIN, 2), + ("max", OpCode.MAX, 2), + ("abs", OpCode.ABS, 1), + ], + ) + def test_builder_constructs_integer_extension_ops(self, method, opcode, arity): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + values = [ + builder.make_value(f"arg{index}", dtype=DataType.INT32) + for index in range(arity) + ] + + result = getattr(builder, method)(*values) + + instruction = builder.current_block.instructions[-1] + assert instruction.opcode is opcode + assert instruction.dest is result + assert result.dtype is DataType.INT32 + + def test_builder_rejects_mixed_and_unsupported_types(self): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + i32 = builder.make_value("i32", dtype=DataType.INT32) + f32 = builder.make_value("f32", dtype=DataType.FLOAT32) + i64 = builder.make_value("i64", dtype=DataType.INT64) + + with pytest.raises(ValueError, match="add operands must have the same type"): + builder.add(i32, f32) + with pytest.raises(ValueError, match="rem does not support f32"): + builder.rem(f32, f32) + with pytest.raises(ValueError, match="min does not support i64"): + builder.min(i64, i64) + with pytest.raises(ValueError, match="abs does not support i64"): + builder.abs(i64) + + @pytest.mark.parametrize( + "dtype", [DataType.INT32, DataType.FLOAT32, DataType.FLOAT64] + ) + def test_builder_accepts_numeric_min_max_abs(self, dtype): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + lhs = builder.make_value("lhs", dtype=dtype) + rhs = builder.make_value("rhs", dtype=dtype) + + assert builder.min(lhs, rhs).dtype is dtype + assert builder.max(lhs, rhs).dtype is dtype + assert builder.abs(lhs).dtype is dtype + + def test_dsl_const_makes_integer_extension_ops_reachable(self): + program = DSLParser().parse( + """ + a = const(7, dtype:i32) + b = const(3, dtype:i32) + r = rem(a, b) + alias = mod(a, b) + lo = min(r, alias) + hi = max(r, alias) + magnitude = abs(hi) + return magnitude + """ + ) + instructions = program.functions[0].blocks[0].instructions + + assert [instruction.opcode for instruction in instructions] == [ + OpCode.LOAD_CONST, + OpCode.LOAD_CONST, + OpCode.REM, + OpCode.REM, + OpCode.MIN, + OpCode.MAX, + OpCode.ABS, + OpCode.RETURN, + ] + assert all( + instruction.dest is None or instruction.dest.dtype is DataType.INT32 + for instruction in instructions + ) + assert verify_ir(program) == (True, []) + + @pytest.mark.parametrize( + ("dtype", "expected"), + [ + ("i32", DataType.INT32), + ("f32", DataType.FLOAT32), + ("f64", DataType.FLOAT64), + ], + ) + def test_dsl_const_supports_extended_numeric_types(self, dtype, expected): + program = DSLParser().parse(f"value = const(1, dtype:{dtype})\nreturn value") + constant = program.functions[0].blocks[0].instructions[0] + + assert constant.opcode is OpCode.LOAD_CONST + assert constant.dest.dtype is expected + + @pytest.mark.parametrize( + "source", + [ + "value = const(1, dtype:i64)", + "value = const(1.5, dtype:i32)", + "value = const(nope, dtype:f32)", + ], + ) + def test_dsl_const_rejects_unsupported_or_invalid_values(self, source): + collector = DSLParser().validate(source) + assert [error.error_code for error in collector.errors] == ["E203"] + with pytest.raises(DSLSyntaxError): + DSLParser().parse(source) + + @pytest.mark.parametrize( + ("opcode", "arity", "dtypes"), + [ + (OpCode.REM, 2, (DataType.INT32,)), + (OpCode.MIN, 2, (DataType.INT32, DataType.FLOAT32, DataType.FLOAT64)), + (OpCode.MAX, 2, (DataType.INT32, DataType.FLOAT32, DataType.FLOAT64)), + (OpCode.ABS, 1, (DataType.INT32, DataType.FLOAT32, DataType.FLOAT64)), + ], + ) + def test_ir_verifier_accepts_extension_signatures(self, opcode, arity, dtypes): + for dtype in dtypes: + program = _extension_program(opcode, (dtype,) * arity, dtype) + assert verify_ir(program) == (True, []) + + @pytest.mark.parametrize( + ("opcode", "operands", "result"), + [ + (OpCode.REM, (DataType.FLOAT32, DataType.FLOAT32), DataType.FLOAT32), + (OpCode.MIN, (DataType.INT32, DataType.FLOAT32), DataType.INT32), + (OpCode.MAX, (DataType.INT64, DataType.INT64), DataType.INT64), + (OpCode.ABS, (DataType.INT64,), DataType.INT64), + ], + ) + def test_ir_verifier_rejects_unsupported_extension_signatures( + self, + opcode, + operands, + result, + ): + passed, issues = verify_ir(_extension_program(opcode, operands, result)) + assert not passed + assert any( + issue.rule == "type-consistency" + and issue.message.startswith("incompatible types:") + for issue in issues + ) diff --git a/tests/test_topic28_isel_integer.py b/tests/test_topic28_isel_integer.py new file mode 100644 index 00000000..b0c5ded0 --- /dev/null +++ b/tests/test_topic28_isel_integer.py @@ -0,0 +1,202 @@ +"""Focused tests for extended integer instruction selection.""" + +from __future__ import annotations + +import pytest + +from scratchv.backend.inst_select_ext import ExtendedInstructionSelector +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.machine_types import MachineInstr, MachineOp, MachineOperand +from scratchv.ir.types import ( + BasicBlock, + DataType, + Function, + Instruction, + OpCode, + Program, + Value, +) + + +I32_MIN = -(1 << 31) +I32_MAX = (1 << 31) - 1 +MASK32 = (1 << 32) - 1 + + +def _program_with(instruction: Instruction, *locals_: Value) -> Program: + block = BasicBlock("entry") + block.add(instruction) + function = Function("test", blocks=[block], locals=list(locals_)) + program = Program() + program.add_function(function) + return program + + +def _constant(name: str, value: int) -> Value: + return Value( + name=name, + dtype=DataType.INT32, + is_constant=True, + const_value=value, + ) + + +def _signed32(value: int) -> int: + value &= MASK32 + return value - (1 << 32) if value & (1 << 31) else value + + +def _execute_integer_sequence( + instructions: list[MachineInstr], initial: dict[str, int] +) -> dict[str, int]: + """Execute the small RV32I subset used by the selector sequences.""" + registers = {name: value & MASK32 for name, value in initial.items()} + + def read(operand: MachineOperand | None) -> int: + assert operand is not None + if operand.kind == "imm": + return int(operand.value) & MASK32 + if operand.kind == "reg" and operand.value in {"x0", "zero"}: + return 0 + return registers[str(operand.value)] + + for instruction in instructions: + if instruction.op in {MachineOp.LABEL, MachineOp.JALR}: + continue + if instruction.dst is None or instruction.dst.kind == "reg": + continue + dst = str(instruction.dst.value) + if instruction.op == MachineOp.LI: + result = read(instruction.src1) + elif instruction.op == MachineOp.SRAI: + result = _signed32(read(instruction.src1)) >> read(instruction.src2) + elif instruction.op == MachineOp.SLT: + result = int( + _signed32(read(instruction.src1)) < _signed32(read(instruction.src2)) + ) + elif instruction.op == MachineOp.SUB: + result = read(instruction.src1) - read(instruction.src2) + elif instruction.op == MachineOp.XOR: + result = read(instruction.src1) ^ read(instruction.src2) + elif instruction.op == MachineOp.AND: + result = read(instruction.src1) & read(instruction.src2) + else: + continue + registers[dst] = result & MASK32 + return registers + + +class TestIntegerInstructionSelection: + @pytest.mark.parametrize( + "selector_type", [InstructionSelector, ExtendedInstructionSelector] + ) + def test_div_materializes_both_constant_sources(self, selector_type: type) -> None: + lhs = _constant("lhs", -17) + rhs = _constant("rhs", 5) + dst = Value("quotient", DataType.INT32) + instruction = Instruction(OpCode.DIV, dst, [lhs, rhs]) + + instructions = selector_type(_program_with(instruction)).run() + div = next(item for item in instructions if item.op == MachineOp.DIV) + + assert div.src1 is not None and div.src1.kind == "vreg" + assert div.src2 is not None and div.src2.kind == "vreg" + assert [item.op for item in instructions].count(MachineOp.LI) == 2 + + def test_rem_materializes_constant_sources_before_register_only_rem(self) -> None: + lhs = _constant("lhs", -17) + rhs = _constant("rhs", 5) + dst = Value("remainder", DataType.INT32) + instruction = Instruction(OpCode.REM, dst, [lhs, rhs]) + selector = ExtendedInstructionSelector(_program_with(instruction)) + + instructions = selector.run() + rem = next(item for item in instructions if item.op == MachineOp.REM) + + assert rem.op == MachineOp.REM + assert rem.src1 is not None and rem.src1.kind == "vreg" + assert rem.src2 is not None and rem.src2.kind == "vreg" + rem_index = instructions.index(rem) + assert [item.op for item in instructions[rem_index - 2 : rem_index]] == [ + MachineOp.LI, + MachineOp.LI, + ] + + @pytest.mark.parametrize( + ("opcode", "lhs", "rhs", "expected"), + [ + (OpCode.MIN, I32_MIN, 0, I32_MIN), + (OpCode.MIN, 7, -9, -9), + (OpCode.MIN, I32_MAX, I32_MAX, I32_MAX), + (OpCode.MAX, I32_MAX, 0, I32_MAX), + (OpCode.MAX, -7, 9, 9), + (OpCode.MAX, I32_MIN, I32_MIN, I32_MIN), + ], + ) + def test_min_max_full_width_mask_sequences( + self, opcode: OpCode, lhs: int, rhs: int, expected: int + ) -> None: + left = Value("left", DataType.INT32) + right = Value("right", DataType.INT32) + result = Value("result", DataType.INT32) + instruction = Instruction(opcode, result, [left, right]) + selector = ExtendedInstructionSelector( + _program_with(instruction, left, right, result) + ) + + selector.run() + registers = _execute_integer_sequence( + selector._instructions, {"left": lhs, "right": rhs} + ) + + assert _signed32(registers["result"]) == expected + mask_builders = [ + item + for item in selector._instructions + if item.op == MachineOp.SUB and item.src1 == MachineOperand.reg("x0") + ] + assert len(mask_builders) == 1 + + @pytest.mark.parametrize( + ("value", "expected"), + [(0, 0), (19, 19), (-19, 19), (I32_MIN, I32_MIN)], + ) + def test_abs_sequence_preserves_rv32_twos_complement_semantics( + self, value: int, expected: int + ) -> None: + source = Value("source", DataType.INT32) + result = Value("result", DataType.INT32) + instruction = Instruction(OpCode.ABS, result, [source]) + selector = ExtendedInstructionSelector( + _program_with(instruction, source, result) + ) + + selector.run() + registers = _execute_integer_sequence(selector._instructions, {"source": value}) + + assert _signed32(registers["result"]) == expected + + def test_selector_temporaries_do_not_collide_with_ir_names(self) -> None: + adversarial_name = "__scratchv_abs_sign_1" + source = Value(adversarial_name, DataType.INT32) + result = Value("result", DataType.INT32) + instruction = Instruction(OpCode.ABS, result, [source]) + selector = ExtendedInstructionSelector( + _program_with(instruction, source, result) + ) + + selector.run() + generated_defs = { + str(item.dst.value) + for item in selector._instructions + if item.dst is not None and item.dst.kind == "vreg" + } + + assert adversarial_name not in generated_defs + + def test_extended_ir_surface_does_not_advertise_idiv_or_mod(self) -> None: + selector = ExtendedInstructionSelector(Program()) + + assert "rem" in selector.supported_ops + assert "idiv" not in selector.supported_ops + assert "mod" not in selector.supported_ops diff --git a/tests/test_topic28_onnx_nan.py b/tests/test_topic28_onnx_nan.py new file mode 100644 index 00000000..e86ec221 --- /dev/null +++ b/tests/test_topic28_onnx_nan.py @@ -0,0 +1,288 @@ +"""ONNX Min/Max must propagate NaN rather than select the numeric input.""" + +from __future__ import annotations + +import math +import struct + +import numpy as np +import pytest + +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.simulator.rv32_emulator import IRTraceExecutor +from tests.topic28_runtime import execute_fp + + +onnx = pytest.importorskip("onnx") +from onnx.reference import ReferenceEvaluator + + +def _model(tmp_path, operator, width, values, *, parameter=False): + dtype = np.float32 if width == 32 else np.float64 + tensor_type = onnx.TensorProto.FLOAT if width == 32 else onnx.TensorProto.DOUBLE + names = [f"input_{index}" for index in range(len(values))] + initializers = [ + onnx.numpy_helper.from_array(np.asarray(value, dtype=dtype), name) + for index, (name, value) in enumerate(zip(names, values)) + if not (parameter and index == 0) + ] + graph = onnx.helper.make_graph( + [onnx.helper.make_node(operator, names, ["output"])], + "main", + ( + [onnx.helper.make_tensor_value_info(names[0], tensor_type, [])] + if parameter + else [] + ), + [onnx.helper.make_tensor_value_info("output", tensor_type, [])], + initializers, + ) + model = onnx.helper.make_model( + graph, + opset_imports=[onnx.helper.make_opsetid("", 13)], + ir_version=9, + ) + onnx.checker.check_model(model) + path = tmp_path / "nan.onnx" + onnx.save(model, path) + return model, path + + +def _compile( + path, + tmp_path, + allocator="linear", + optimization="none", + *, + isa="rv32imfd", + abi="ilp32d", +): + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + reg_alloc=allocator, + verify_ir=True, + target_isa=isa, + target_abi=abi, + optimize_level=optimization, + ) + ).compile(str(path), str(tmp_path / "nan.s")) + assert result.success, result.errors + return result.output_text + + +def _float(bits, width): + return struct.unpack( + " 3", 9)]) + def test_r5_integer_control_flow_constant_stores_are_legal( + self, extended, condition, expected, tmp_path + ): + source = f"""a = const(1,dtype:i32) +r = const(0,dtype:i32) +if ({condition}): + r = const(7,dtype:i32) +else: + r = const(9,dtype:i32) +endif +return r +""" + result = CompilerDriver( + CompilerConfig( + reg_alloc="greedy", + use_extended_isel=extended, + verify_ir=True, + ) + ).compile("", str(tmp_path / "control.s"), dsl_source=source) + assert result.success, result.errors + assert execute_i32(result.output_text, tmp_path) == expected + + @pytest.mark.parametrize("alias", ["ft0", "f0"]) + def test_r8_report_literal_stream_allocates_after_physical_clobber(self, alias): + stream = [ + MI(O.LABEL, target="main"), + MI(O.LI, P.vreg("addr"), P.immediate(256)), + MI(O.FLD, _fp("value", 64), P.vreg("addr")), + MI(O.FMV_D, P.reg(alias, RC.FPR, 64), P.reg("fa0", RC.FPR, 64)), + MI(O.J, target=".next"), + MI(O.LABEL, target=".next"), + MI(O.FMV_D, P.reg("fa0", RC.FPR, 64), _fp("value", 64)), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ] + result = LinearScanAllocator().allocate_result(stream) + assert any( + "[regalloc:reload]" in instr.comment for instr in result.instructions + ) + + @pytest.mark.parametrize( + "kind,width,alias", + [ + ("integer", 32, "t1"), + ("integer", 32, "x6"), + ("float", 32, "ft0"), + ("float", 32, "f0"), + ("float", 64, "ft0"), + ("float", 64, "f0"), + ], + ) + @pytest.mark.parametrize("cfg", ["jump", "diamond_taken", "diamond_not_taken"]) + def test_r8_physical_definition_preserves_edge_value_bits( + self, kind, width, alias, cfg, tmp_path + ): + is_float = kind == "float" + operand = _fp("value", width) if is_float else P.vreg("value") + load = (O.FLW if width == 32 else O.FLD) if is_float else O.LW + move = (O.FMV_S if width == 32 else O.FMV_D) if is_float else O.MV + target = P.reg("fa0", RC.FPR, width) if is_float else P.reg("a0") + clobber = ( + MI(move, P.reg(alias, RC.FPR, width), P.reg("fa0", RC.FPR, width)) + if is_float + else MI(O.LI, P.reg(alias), P.immediate(99)) + ) + stream = [ + MI(O.LABEL, target="main"), + MI(O.LA, P.vreg("addr"), P.symbol("payload")), + MI(load, operand, P.vreg("addr")), + ] + if cfg == "jump": + stream.extend([clobber, MI(O.J, target=".join")]) + else: + stream.extend( + [ + MI( + O.BEQ if cfg == "diamond_taken" else O.BNE, + P.reg("zero"), + P.reg("zero"), + target=".overwrite", + ), + MI(O.J, target=".join"), + MI(O.LABEL, target=".overwrite"), + clobber, + MI(O.J, target=".join"), + ] + ) + stream.extend( + [ + MI(O.LABEL, target=".join"), + MI(move, target, operand), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ] + ) + allocator = LinearScanAllocator() + result = allocator.allocate_result(stream) + # Ensure this actually tests the explicitly clobbered allocation, not a + # value coincidentally living in another register. + assert allocator.alloc_map["value"] == ("ft0" if is_float else "t1") + bits = ( + int.from_bytes(struct.pack(" a", + "a > -2147483648", + "2147483647 > a", + "a < 3e0", + "3E+0 > a", + ], + ) + def test_r6_integer_guards_emit_actual_int_constants( + self, kind, condition, tmp_path + ): + source = guard_source(kind, condition) + program = ExtendedDSLParser().parse(source) + constants = [ + instr + for func in program.functions + for block in func.blocks + for instr in block.instructions + if instr.opcode is OpCode.LOAD_CONST + ] + assert all(instr.dest.dtype is DataType.INT32 for instr in constants) + assert all(type(instr.attrs["value"]) is int for instr in constants) + path = tmp_path / "r6.dsl" + path.write_text(source) + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + verify_ir=True, + ) + ).compile(str(path)) + assert result.success, result.errors + + @pytest.mark.parametrize("kind", ["if", "while"]) + @pytest.mark.parametrize( + "literal", + [ + "1.5", + "-1.5", + "2147483648", + "-2147483649", + "1.0000000000000000001", + "1e10000", + ], + ) + @pytest.mark.parametrize("left_literal", [False, True]) + def test_r6_integer_guards_reject_fraction_or_out_of_range( + self, kind, literal, left_literal + ): + condition = f"{literal} < a" if left_literal else f"a < {literal}" + with pytest.raises(DSLParseError, match="i32.*literal|i32.*range"): + ExtendedDSLParser().parse(guard_source(kind, condition)) + + @pytest.mark.parametrize("kind", ["if", "while"]) + @pytest.mark.parametrize("literal", ["1e-3", "1e+3", "-1E-3", "+1E+3"]) + @pytest.mark.parametrize("left_literal", [False, True]) + def test_r9_scientific_conditions_validator_and_parser_agree( + self, kind, literal, left_literal, tmp_path + ): + condition = f"{literal} < a" if left_literal else f"a < {literal}" + source = guard_source(kind, condition, "f64") + parser = ExtendedDSLParser() + assert not parser.validate(source).has_errors + parser.parse(source) + path = tmp_path / "r9.dsl" + path.write_text(source) + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + verify_ir=True, + target_isa="rv32imfd", + target_abi="ilp32d", + ) + ).compile(str(path)) + assert result.success, result.errors + + @pytest.mark.parametrize("kind", ["if", "while"]) + @pytest.mark.parametrize("literal", ["1e", "1e+", "--1e-3", "1.2.3"]) + def test_r9_invalid_scientific_conditions_rejected_by_both(self, kind, literal): + source = guard_source(kind, f"a < {literal}", "f64") + parser = ExtendedDSLParser() + assert parser.validate(source).has_errors + with pytest.raises(DSLParseError): + parser.parse(source) + + @pytest.mark.parametrize("implementation", ["dsl", "ir"]) + @pytest.mark.parametrize("dtype", ["f32", "f64"]) + def test_r7_sqrt_reference_is_defined_and_preserves_precision( + self, implementation, dtype + ): + source = f"a = const(4,dtype:{dtype})\nr = sqrt(a)\nreturn r" + result = run_reference(source, implementation) + assert result.dtype == np.dtype(dtype.replace("f", "float")) + assert float(result) == 2.0 + + @pytest.mark.parametrize("implementation", ["dsl", "ir"]) + @pytest.mark.parametrize("dtype", ["f32", "f64"]) + @pytest.mark.parametrize("op,left,right,expected", FP_MIN_MAX_CASES) + def test_r7_min_max_reference_number_nan_zero_and_precision( + self, + implementation, + dtype, + op, + left, + right, + expected, + ): + source = f"a = const({left},dtype:{dtype})\nb = const({right},dtype:{dtype})\nr = {op}(a,b)\nreturn r" + result = run_reference(source, implementation) + numpy_dtype = np.float32 if dtype == "f32" else np.float64 + assert result.dtype == numpy_dtype + if np.isnan(expected): + assert np.isnan(result) + else: + assert float(result) == float(numpy_dtype(expected)) + if expected == 0: + assert bool(np.signbit(result)) == bool(np.signbit(expected)) + + @pytest.mark.parametrize("implementation", ["dsl", "ir"]) + def test_r7_undefined_values_raise_instead_of_defaulting_to_zero( + self, implementation + ): + with pytest.raises(ValueError, match="[Uu]ndefined.*missing"): + run_reference("return missing", implementation) + + @pytest.mark.parametrize("implementation", ["dsl", "ir"]) + @pytest.mark.parametrize("dtype", [np.float32, np.float64]) + @pytest.mark.parametrize("op", ["min", "max"]) + def test_r7_min_max_reference_broadcast_arrays_preserve_all_element_bits( + self, implementation, dtype, op + ): + left = np.asarray([np.nan, 4, -0.0, 0.0, -0.0, 0.0], dtype=dtype) + right = np.asarray([4, np.nan, 0.0, -0.0, -0.0, 0.0], dtype=dtype) + expected = np.asarray( + ( + [4, 4, -0.0, -0.0, -0.0, 0.0] + if op == "min" + else [4, 4, 0.0, 0.0, -0.0, 0.0] + ), + dtype=dtype, + ) + source = f"r = {op}(a,b)\nreturn r" + if implementation == "dsl": + result = DSLInterpreter().run(source, {"a": left, "b": right}) + else: + result = IRTraceExecutor(DSLParser().parse(source)).run( + {"a": left, "b": right} + ) + unsigned = np.uint32 if dtype is np.float32 else np.uint64 + np.testing.assert_array_equal( + np.asarray(result).view(unsigned), expected.view(unsigned) + ) + + def test_r7_unknown_ir_opcode_raises_instead_of_silent_none(self): + program = DSLParser().parse("a = const(4)\nreturn a") + program.functions[0].blocks[0].instructions.insert( + 1, + Instruction( + OpCode.TRANSPOSE, + Value("unhandled"), + [Value("a")], + ), + ) + with pytest.raises(ValueError, match="Unsupported.*transpose"): + IRTraceExecutor(program).run({}) + + @pytest.mark.parametrize("opcode", [OpCode.BR, OpCode.BR_IF]) + def test_r7_straight_line_trace_rejects_cfg_instead_of_ignoring_branches( + self, opcode + ): + program = DSLParser().parse("a = const(4)\nreturn a") + with pytest.raises(ValueError, match="Unsupported control flow"): + IRTraceExecutor(program)._exec_instr(Instruction(opcode, target="next")) + + def test_r7_verify_dsl_is_still_only_reference_not_binary_evidence(self): + result = verify_dsl("a = const(4)\nr = sqrt(a)\nreturn r", {}) + assert float(result["expected"]) == 2.0 + # The existing API is a placeholder. It must not be cited as a QEMU run. + assert result["got"] is result["expected"] + + @pytest.mark.parametrize("width", [32, 64]) + @pytest.mark.parametrize("op,left,right,expected", FP_MIN_MAX_CASES) + def test_r7_min_max_reference_bits_match_qemu( + self, width, op, left, right, expected, tmp_path + ): + source = f"a = const({left},dtype:f{width})\nb = const({right},dtype:f{width})\nr = {op}(a,b)\nreturn r" + assembly = compile_source(source, tmp_path, width, "greedy") + bits, flags = execute_fp(assembly, tmp_path, width) + unsigned = np.uint32 if width == 32 else np.uint64 + for implementation in ("dsl", "ir"): + reference = np.asarray(run_reference(source, implementation)) + assert int(reference.view(unsigned)) == bits + # All NaNs in these source spellings are quiet; min/max raises no flags. + assert flags == 0 + + @pytest.mark.parametrize("width", [32, 64]) + @pytest.mark.parametrize("literal", ["4.0", "-0.0", "inf"]) + def test_r7_sqrt_reference_bits_match_qemu(self, width, literal, tmp_path): + source = f"a = const({literal},dtype:f{width})\nr = sqrt(a)\nreturn r" + bits, flags = execute_fp( + compile_source(source, tmp_path, width, "greedy"), tmp_path, width + ) + unsigned = np.uint32 if width == 32 else np.uint64 + for implementation in ("dsl", "ir"): + assert ( + int(np.asarray(run_reference(source, implementation)).view(unsigned)) + == bits + ) + assert flags == 0 + + @pytest.mark.parametrize("kind", ["if", "while"]) + @pytest.mark.parametrize("left_literal", [False, True]) + def test_r6_verified_integer_control_flow_executes_on_qemu( + self, kind, left_literal, tmp_path + ): + condition = "3 > a" if left_literal else "a < 3" + if kind == "if": + initial = 4 if left_literal else 1 + source = ( + f"a = const({initial},dtype:i32)\n" + "then_value = const(2,dtype:i32)\nelse_value = const(3,dtype:i32)\n" + f"if ({condition}):\n return then_value\nelse:\n return else_value\nendif\n" + ) + # Exercise both the taken and untaken path, with different return + # values so a reversed/ignored predicate cannot pass this assertion. + expected = 2 if initial < 3 else 3 + else: + source = "a = const(1,dtype:i32)\nstep = const(1,dtype:i32)\n" + source += f"while ({condition}):\n a = add(a,step)\nendwhile\nreturn a\n" + expected = 3 + assert ( + execute_i32(compile_source(source, tmp_path, 0, "greedy"), tmp_path) + == expected + ) + + @pytest.mark.parametrize("kind", ["if", "while"]) + @pytest.mark.parametrize("literal", ["1e-3", "1e+3", "-1E-3", "+1E+3"]) + @pytest.mark.parametrize("left_literal", [False, True]) + def test_r9_verified_scientific_conditions_execute_on_qemu( + self, kind, literal, left_literal, tmp_path + ): + condition = f"{literal} < a" if left_literal else f"a < {literal}" + source = ( + "a = const(1,dtype:f64)\n" + "then_value = const(2,dtype:f64)\nelse_value = const(3,dtype:f64)\n" + ) + if kind == "if": + source += f"if ({condition}):\n return then_value\nelse:\n return else_value\nendif\n" + else: + # True guards enter the body and return 2; false guards must skip it + # and return 3. This tests actual control flow rather than parser-only + # acceptance or two branches which return the same value. + source += f"while ({condition}):\n return then_value\nendwhile\nreturn else_value\n" + takes_body = float(literal) < 1.0 if left_literal else 1.0 < float(literal) + expected_bits = 0x4000000000000000 if takes_body else 0x4008000000000000 + bits, flags = execute_fp( + compile_source(source, tmp_path, 64, "greedy"), tmp_path, 64 + ) + assert bits == expected_bits + assert flags == 0 diff --git a/tests/test_topic28_review_onnx.py b/tests/test_topic28_review_onnx.py new file mode 100644 index 00000000..08d3acb4 --- /dev/null +++ b/tests/test_topic28_review_onnx.py @@ -0,0 +1,256 @@ +"""R2/R3 regressions: ONNX constants and variadic scalar Min/Max. + +Every generated model is checked by the installed ONNX checker. The execution +tests compare binary results and fflags, not merely compilation or assembly text. +""" + +from __future__ import annotations + +import struct + +import pytest + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.frontend.onnx_parser import ONNXParseError, ONNXParser +from scratchv.ir.types import DataType, OpCode +from scratchv.optimizer.constant_folding import ConstantFolder + + +onnx = pytest.importorskip("onnx") +TYPES = (("FLOAT", DataType.FLOAT32, 32), ("DOUBLE", DataType.FLOAT64, 64)) + + +def _model(tmp_path, nodes, constants, tensor_type, *, parameter=False): + graph = onnx.helper.make_graph( + nodes, + "main", + [onnx.helper.make_tensor_value_info("x", tensor_type, [])] if parameter else [], + [onnx.helper.make_tensor_value_info("y", tensor_type, [])], + [ + onnx.helper.make_tensor(name, tensor_type, [], [value]) + for name, value in constants.items() + ], + ) + model = onnx.helper.make_model(graph) + onnx.checker.check_model(model) + path = tmp_path / "review.onnx" + onnx.save(model, path) + return path + + +def _instructions(program): + return [ + instruction + for function in program.functions + for block in function.blocks + for instruction in block.instructions + ] + + +def _bits(value, width): + return int.from_bytes(struct.pack(" 127: + result.append((value & 127) | 128) + value >>= 7 + return bytes(result + bytes([value])) + + +def length_field(number, payload): + return varint(number * 8 + 2) + varint(len(payload)) + payload + + +def integer_field(number, value): + return varint(number * 8) + varint(value) + + +def typed_tensor(nt, raw, packed, name="a"): + width, field, wire = (4, 4, 5) if nt == TP.FLOAT else (8, 10, 1) + payload = raw.to_bytes(width, "little") + prefix = TP(name=name, data_type=nt).SerializeToString() + return prefix + ( + length_field(field, payload) if packed else varint(field * 8 + wire) + payload + ) + + +def write_model(path, nt, tensors, operation, *, split_graph=False): + output = "a" if operation == "return" else "out" + nodes = ( + [] + if operation == "return" + else [ + helper.make_node( + operation, ["a"] if operation == "Sqrt" else ["a", "b"], [output] + ) + ] + ) + graph = helper.make_graph( + nodes, "main", [], [helper.make_tensor_value_info(output, nt, [])] + ).SerializeToString() + data = b"".join(length_field(5, tensor) for tensor in tensors) + if operation == "Min": + two = 0x40000000 if nt == TP.FLOAT else 0x4000000000000000 + data += length_field( + 5, + TP( + name="b", + data_type=nt, + raw_data=two.to_bytes(4 if nt == TP.FLOAT else 8, "little"), + ).SerializeToString(), + ) + model = helper.make_model( + helper.make_graph([], "unused", [], []), + opset_imports=[helper.make_opsetid("", 13)], + ) + model.ClearField("graph") + path.write_bytes( + model.SerializeToString() + + ( + length_field(7, graph) + length_field(7, data) + if split_graph + else length_field(7, graph + data) + ) + ) + onnx.checker.check_model(str(path)) + + +def compile_model(path, tmp_path, mode, level, verify, target): + dtype, _, isa, abi = target + program = ONNXParser().parse(str(path)) + assert verify_ir(program) == (True, []) + result = CompilerDriver( + configuration(mode, level, verify, isa=isa, abi=abi) + ).compile(str(path), str(tmp_path / "out.s")) + assert result.success, result.errors + return program, observe(result.output_text, tmp_path, dtype, isa=isa, abi=abi) + + +def function_body(name, count, value, role): + result = [ + MI(O.LABEL, target=name, label_role=role), + MI(O.LI, P.reg("s0"), P.immediate(99)), + ] + result += [ + MI(O.LI, P.vreg(f"{name}_v{i}"), P.immediate(i + 1)) for i in range(count) + ] + total = P.vreg(f"{name}_v0") + for i in range(1, count): + dest = P.vreg(f"{name}_sum{i}") + result.append(MI(O.ADD, dest, total, P.vreg(f"{name}_v{i}"))) + total = dest + result += [ + MI(O.ADDI, P.reg("a0"), total, P.immediate(value)), + MI(O.JALR, P.reg("zero"), P.reg("ra"), comment="return"), + ] + return result + + +class TestTopic28SixthReviewRegressions: + @pytest.mark.parametrize("target", TARGETS) + @pytest.mark.parametrize("packed", [False, True]) + @pytest.mark.parametrize("operation", ["return", "Sqrt"]) + def test_t16_repeated_name_uses_final_initializer_identity( + self, target, packed, operation, tmp_path + ): + # Wire decoding precedes allocation. Keep both encodings and all targets; + # allocator and optimization coverage lives in the respective suites. + _, nt, _, _ = target + four, nine, two = ( + (0x40800000, 0x41100000, 0x40000000) + if nt == TP.FLOAT + else (0x4010000000000000, 0x4022000000000000, 0x4000000000000000) + ) + path = tmp_path / "input.onnx" + write_model( + path, + nt, + [ + typed_tensor(nt, four, packed), + typed_tensor(nt, nine, packed) + length_field(8, b"b"), + ], + operation, + ) + assert [t.name for t in onnx.load(path).graph.initializer] == ["a", "b"] + program, actual = compile_model(path, tmp_path, "greedy", "all", True, target) + assert {v.name: v.bit_pattern for v in program.global_values} == { + "a": four, + "b": nine, + } + assert actual == (four if operation == "return" else two, 0) + + @pytest.mark.parametrize("target", TARGETS) + @pytest.mark.parametrize("packed", [False, True]) + @pytest.mark.parametrize("operation", ["return", "Min"]) + @pytest.mark.parametrize("variant", ["default_metadata", "unknown_raw_wire"]) + def test_t12_storage_discriminator_preserves_typed_bits( + self, target, packed, operation, variant, tmp_path + ): + _, nt, _, _ = target + raw = 0x7F800123 if nt == TP.FLOAT else 0x7FF0000000000123 + tensor = typed_tensor(nt, raw, packed) + integer_field(14, TP.DEFAULT) + if variant == "default_metadata": + tensor += length_field( + 13, + onnx.StringStringEntryProto( + key="location", value="ignored.bin" + ).SerializeToString(), + ) + else: + tensor += integer_field( + 9, 1 + ) # Unknown varint, not length-delimited raw_data. + path = tmp_path / "input.onnx" + write_model(path, nt, [tensor], operation) + program, actual = compile_model(path, tmp_path, "greedy", "all", True, target) + assert program.global_values[0].bit_pattern == raw + canonical_nan = 0x7FC00000 if nt == TP.FLOAT else 0x7FF8000000000000 + if operation == "Min": + with np.errstate(invalid="ignore"): + assert np.isnan( + ReferenceEvaluator(onnx.load(path)).run(None, {})[0] + ).all() + assert actual == ((raw, 0) if operation == "return" else (canonical_nan, 0x10)) + + @pytest.mark.parametrize("target", TARGETS) + @pytest.mark.parametrize( + "variant", + [ + "renamed", + "unknown_name_wire", + "last_dtype", + "last_location", + "unknown_location_enum", + "unknown_location_wire", + "unknown_external_wire", + "merged_graph", + "wrapped_dtype", + "wrapped_location", + ], + ) + def test_t12_t16_singular_wire_and_graph_merge_semantics( + self, target, variant, tmp_path + ): + _, nt, _, _ = target + raw = 0xFF800123 if nt == TP.FLOAT else 0xFFF0000000000123 + tensor = typed_tensor(nt, raw, True, "old" if variant == "renamed" else "a") + if variant == "renamed": + tensor += length_field(8, b"a") + elif variant == "unknown_name_wire": + tensor += integer_field(8, 1) + elif variant == "last_dtype": + tensor = integer_field(2, TP.INT32) + tensor + elif variant == "last_location": + tensor += integer_field(14, TP.EXTERNAL) + integer_field(14, TP.DEFAULT) + tensor += length_field( + 13, + onnx.StringStringEntryProto( + key="location", value="ignored.bin" + ).SerializeToString(), + ) + elif variant == "unknown_location_enum": + tensor += integer_field(14, TP.DEFAULT) + integer_field(14, 99) + elif variant == "unknown_location_wire": + tensor += length_field(14, b"\x01") + elif variant == "unknown_external_wire": + tensor += integer_field(13, 1) + elif variant == "wrapped_dtype": + tensor += integer_field(2, (1 << 32) + nt) + elif variant == "wrapped_location": + tensor += integer_field(14, (1 << 32) + TP.DEFAULT) + tensor += length_field( + 13, + onnx.StringStringEntryProto( + key="location", value="ignored.bin" + ).SerializeToString(), + ) + path = tmp_path / "input.onnx" + write_model(path, nt, [tensor], "Min", split_graph=variant == "merged_graph") + program, actual = compile_model(path, tmp_path, "greedy", "all", True, target) + assert program.global_values[0].bit_pattern == raw + with np.errstate(invalid="ignore"): + assert np.isnan(ReferenceEvaluator(onnx.load(path)).run(None, {})[0]).all() + assert actual == (0x7FC00000 if nt == TP.FLOAT else 0x7FF8000000000000, 0x10) + + @pytest.mark.parametrize("target", TARGETS) + @pytest.mark.parametrize("storage", ["raw", "external"]) + def test_t12_actual_raw_and_external_storage_remain_bit_exact( + self, target, storage, tmp_path + ): + _, nt, _, _ = target + raw = 0xFF800123 if nt == TP.FLOAT else 0xFFF0000000000123 + data = raw.to_bytes(4 if nt == TP.FLOAT else 8, "little") + tensor = TP(name="a", data_type=nt) + if storage == "raw": + tensor.raw_data = data + tensor.data_location = TP.DEFAULT + tensor.external_data.add(key="location", value="ignored.bin") + else: + (tmp_path / "weights.bin").write_bytes(data) + tensor.data_location = TP.EXTERNAL + tensor.external_data.add(key="location", value="weights.bin") + # The real storage field must win over a subsequent unknown same-number + # field of a different wire type; this fixture has no typed numeric data. + wire = tensor.SerializeToString() + integer_field(9, 1) + path = tmp_path / "input.onnx" + write_model(path, nt, [wire], "return") + program, actual = compile_model(path, tmp_path, "greedy", "all", True, target) + assert program.global_values[0].bit_pattern == raw + assert actual == (raw, 0) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize( + "roles", ["explicit_normal", "explicit_dot", "legacy_normal"] + ) + @pytest.mark.parametrize("count", [1, 24]) + @pytest.mark.parametrize("sections", ["between", "trailing", "both", "same"]) + def test_t14_function_sizes_survive_section_switches_and_abi( + self, mode, roles, count, sections, tmp_path + ): + first, second = ( + (".first", ".second") if roles == "explicit_dot" else ("first", "second") + ) + role = None if roles == "legacy_normal" else "function" + instructions = function_body(first, count, 5, role) + if sections in {"between", "both", "same"}: + instructions.append( + MI(O.SECTION, comment="text" if sections == "same" else "text.hot") + ) + instructions += function_body(second, count, 9, role) + if sections in {"trailing", "both"}: + instructions.append(MI(O.SECTION, comment="text.unused")) + allocation = allocate(instructions, mode) + assembly = apply_abi_frames( + AsmEmitter(allocation.instructions).emit(), allocation + ) + for name, value in [(first, 5), (second, 9)]: + path = tmp_path / name + path.mkdir() + assert observe(assembly, path, T.INT32, name, "rv32im", "ilp32") == ( + count * (count + 1) // 2 + value, + 0, + ) + obj = tmp_path / first / "product.o" + symbols = subprocess.check_output( + [_required_tool("readelf"), "-sW", str(obj)], text=True + ) + parsed = { + parts[-1]: parts + for line in symbols.splitlines() + if len(parts := line.split()) >= 8 and parts[-1] in {first, second} + } + section_text = subprocess.check_output( + [_required_tool("readelf"), "-SW", str(obj)], text=True + ) + section_sizes = { + int(match[1]): int(match[2], 16) + for line in section_text.splitlines() + if ( + match := re.match( + r"\s*\[\s*(\d+)\]\s+\S+\s+PROGBITS\s+[0-9a-f]+\s+[0-9a-f]+\s+([0-9a-f]+)", + line, + ) + ) + } + for row in parsed.values(): + assert row[3:5] == ["FUNC", "GLOBAL"] and int(row[2]) > 0 + assert set(parsed) == {first, second} + a, b = parsed[first], parsed[second] + first_end = int(b[1], 16) if a[6] == b[6] else section_sizes[int(a[6])] + assert int(a[2]) == first_end - int(a[1], 16) + assert int(b[2]) == section_sizes[int(b[6])] - int(b[1], 16) + + def test_t14_function_body_cannot_silently_span_sections(self): + instructions = [ + MI(O.LABEL, target="main", label_role="function"), + MI(O.LI, P.reg("a0"), P.immediate(7)), + MI(O.SECTION, comment="text.hot"), + MI(O.LABEL, target=".next", label_role="block"), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ] + allocation = allocate(instructions, "greedy") + with pytest.raises(ValueError, match="cannot continue across output sections"): + AsmEmitter(allocation.instructions).emit() + + @pytest.mark.parametrize("label", ["$foo", "foo$bar", ".Lfoo", ".$foo"]) + @pytest.mark.parametrize("fallback", [False, True]) + @pytest.mark.parametrize("op", [O.J, O.JAL, O.BNEZ, O.BEQ, O.BNE, O.BLT, O.BGE]) + def test_t14_internal_dollar_branch_references_execute( + self, label, fallback, op, tmp_path + ): + operands = { + O.J: (), + O.JAL: (P.reg("zero"),), + O.BNEZ: (P.reg("t2"),), + O.BEQ: (P.reg("zero"), P.reg("zero")), + O.BNE: (P.reg("t2"), P.reg("zero")), + O.BLT: (P.reg("zero"), P.reg("t2")), + O.BGE: (P.reg("t2"), P.reg("zero")), + }[op] + jump = MI( + op, *operands, **({"comment": label} if fallback else {"target": label}) + ) + instructions = [ + MI(O.LABEL, target=".dollar", label_role="function"), + MI(O.LI, P.reg("t2"), P.immediate(1)), + jump, + MI(O.LI, P.reg("a0"), P.immediate(-1)), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + MI(O.LABEL, target=label, label_role="block"), + MI(O.LI, P.reg("a0"), P.immediate(7)), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ] + allocation = allocate(instructions, "greedy") + assembly = apply_abi_frames( + AsmEmitter(allocation.instructions).emit(), allocation + ) + assert observe(assembly, tmp_path, T.INT32, ".dollar", "rv32im", "ilp32") == ( + 7, + 0, + ) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("label", ["$pool", "pool$bits", ".Lpool", ".$pool"]) + @pytest.mark.parametrize("target", TARGETS) + def test_t14_internal_dollar_address_references_execute( + self, mode, label, target, tmp_path + ): + dtype, nt, isa, abi = target + width = 32 if nt == TP.FLOAT else 64 + raw = 0x7F800123 if width == 32 else 0x7FF0000000000123 + instructions = [ + MI(O.LABEL, target="main", label_role="function"), + MI(O.LA, P.vreg("address"), P.symbol(label)), + MI( + O.FLW if width == 32 else O.FLD, + P.reg("fa0", RC.FPR, width), + P.vreg("address"), + ), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ] + allocation = allocate(instructions, mode) + assembly = apply_abi_frames( + AsmEmitter( + allocation.instructions, [FloatConstant(label, width, raw)] + ).emit(), + allocation, + ) + assert observe(assembly, tmp_path, dtype, isa=isa, abi=abi) == (raw, 0) diff --git a/tests/test_topic28_stage2.py b/tests/test_topic28_stage2.py new file mode 100644 index 00000000..e62c74c6 --- /dev/null +++ b/tests/test_topic28_stage2.py @@ -0,0 +1,454 @@ +"""Topic 28 phase-2 typed floating-point selection contracts.""" + +from __future__ import annotations + +import math + +import pytest + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.target_features import TargetFeatures +from scratchv.backend.inst_select_ext import ExtendedInstructionSelector +from scratchv.backend.machine_semantics import validate_register_classes +from scratchv.backend.machine_types import ( + MachineInstr, + MachineOp, + MachineOperand, + RegisterClass, +) +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.frontend.dsl_parser import DSLParser +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Instruction, OpCode +from scratchv.main import args_to_config, build_arg_parser + + +def _features(dtype: DataType) -> TargetFeatures: + if dtype is DataType.FLOAT32: + return TargetFeatures("rv32imf", "ilp32f") + return TargetFeatures("rv32imfd", "ilp32d") + + +def _binary_selection(method: str, dtype: DataType): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + lhs = builder.load_const(4.0, dtype) + rhs = builder.load_const(2.0, dtype) + result = getattr(builder, method)(lhs, rhs) + instructions = ExtendedInstructionSelector( + builder.program, + features=_features(dtype), + ).run() + return result, next( + instruction + for instruction in instructions + if instruction.dst is not None and instruction.dst.value == result.name + ) + + +class TestFloatingPointInstructionSelection: + def test_sqrt_reaches_typed_ir_from_builder_and_dsl(self): + program = DSLParser().parse("x = const(4.0, dtype:f64)\ny = sqrt(x)\nreturn y") + sqrt = next( + instruction + for instruction in program.functions[0].blocks[0].instructions + if instruction.opcode is OpCode.SQRT + ) + assert sqrt.dest.dtype is DataType.FLOAT64 + assert verify_ir(program) == (True, []) + + builder = IRBuilder() + with pytest.raises(ValueError, match="sqrt does not support i32"): + builder.sqrt(builder.make_value("x", dtype=DataType.INT32)) + + def test_dsl_accepts_scientific_and_ieee_special_float_constants(self): + program = DSLParser().parse( + "a = const(1e-45, dtype:f32)\n" + "b = const(inf, dtype:f32)\n" + "c = const(nan, dtype:f64)\n" + "return a" + ) + values = [ + instruction.attrs["value"] + for instruction in program.functions[0].blocks[0].instructions + if instruction.opcode is OpCode.LOAD_CONST + ] + assert values[0] == 1e-45 + assert math.isinf(values[1]) + assert math.isnan(values[2]) + + @pytest.mark.parametrize( + ("method", "single", "double"), + [ + ("add", MachineOp.FADD_S, MachineOp.FADD_D), + ("sub", MachineOp.FSUB_S, MachineOp.FSUB_D), + ("mul", MachineOp.FMUL_S, MachineOp.FMUL_D), + ("div", MachineOp.FDIV_S, MachineOp.FDIV_D), + ], + ) + @pytest.mark.parametrize("dtype", [DataType.FLOAT32, DataType.FLOAT64]) + def test_float_arithmetic_is_typed_and_uses_static_rne( + self, + method, + single, + double, + dtype, + ): + _, selected = _binary_selection(method, dtype) + assert selected.op is (single if dtype is DataType.FLOAT32 else double) + assert selected.rounding_mode == "rne" + assert { + selected.dst.reg_class, + selected.src1.reg_class, + selected.src2.reg_class, + } == {RegisterClass.FPR} + + @pytest.mark.parametrize( + ("method", "single", "double"), + [ + ("min", MachineOp.FMIN_S, MachineOp.FMIN_D), + ("max", MachineOp.FMAX_S, MachineOp.FMAX_D), + ], + ) + @pytest.mark.parametrize("dtype", [DataType.FLOAT32, DataType.FLOAT64]) + def test_float_min_max_have_no_rounding_operand( + self, method, single, double, dtype + ): + _, selected = _binary_selection(method, dtype) + assert selected.op is (single if dtype is DataType.FLOAT32 else double) + assert selected.rounding_mode is None + + @pytest.mark.parametrize( + ("method", "single", "double", "has_rounding"), + [ + ("neg", MachineOp.FNEG_S, MachineOp.FNEG_D, False), + ("abs", MachineOp.FABS_S, MachineOp.FABS_D, False), + ("sqrt", MachineOp.SQRT_S, MachineOp.SQRT_D, True), + ], + ) + @pytest.mark.parametrize("dtype", [DataType.FLOAT32, DataType.FLOAT64]) + def test_float_unary_selection(self, method, single, double, has_rounding, dtype): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + source = builder.load_const(4.0, dtype) + result = getattr(builder, method)(source) + instructions = ExtendedInstructionSelector( + builder.program, + features=_features(dtype), + ).run() + selected = next( + instruction + for instruction in instructions + if instruction.dst is not None and instruction.dst.value == result.name + ) + assert selected.op is (single if dtype is DataType.FLOAT32 else double) + assert selected.dst.reg_class is RegisterClass.FPR + assert selected.src1.reg_class is RegisterClass.FPR + assert (selected.rounding_mode == "rne") is has_rounding + + @pytest.mark.parametrize( + ("dtype", "load_op", "store_op"), + [ + (DataType.FLOAT32, MachineOp.FLW, MachineOp.FSW), + (DataType.FLOAT64, MachineOp.FLD, MachineOp.FSD), + ], + ) + def test_float_load_store_preserve_ir_store_order(self, dtype, load_op, store_op): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + address = builder.make_value("address", dtype=dtype) + loaded = builder.load(address, dtype=dtype) + builder.store(address, loaded) + instructions = ExtendedInstructionSelector( + builder.program, + features=_features(dtype), + ).run() + load = next(item for item in instructions if item.op is load_op) + store = next(item for item in instructions if item.op is store_op) + assert load.dst.reg_class is RegisterClass.FPR + assert load.src1.reg_class is RegisterClass.GPR + assert store.dst.value == loaded.name + assert store.dst.reg_class is RegisterClass.FPR + assert store.src1.value == address.name + assert store.src1.reg_class is RegisterClass.GPR + + def test_float_memory_assembly_uses_offset_base_syntax(self): + assembly = AsmEmitter( + [ + MachineInstr( + MachineOp.FLW, + MachineOperand.reg("ft0", RegisterClass.FPR), + MachineOperand.reg("t0", RegisterClass.GPR), + ), + MachineInstr( + MachineOp.FSD, + MachineOperand.reg("ft1", RegisterClass.FPR), + MachineOperand.reg("sp", RegisterClass.GPR), + ), + ] + ).emit() + assert "flw ft0, 0(t0)" in assembly + assert "fsd ft1, 0(sp)" in assembly + + @pytest.mark.parametrize("predicate", ["==", "!=", "<", ">", "<=", ">="]) + @pytest.mark.parametrize("dtype", [DataType.FLOAT32, DataType.FLOAT64]) + def test_float_comparison_has_fpr_inputs_and_gpr_result(self, predicate, dtype): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + lhs = builder.load_const(4.0, dtype) + rhs = builder.load_const(2.0, dtype) + builder.br_compare(lhs, predicate, rhs, "yes", "no") + instructions = ExtendedInstructionSelector( + builder.program, + features=_features(dtype), + ).run() + comparisons = { + MachineOp.FEQ_S, + MachineOp.FEQ_D, + MachineOp.FLT_S, + MachineOp.FLT_D, + MachineOp.FLE_S, + MachineOp.FLE_D, + } + compare = next(item for item in instructions if item.op in comparisons) + assert compare.dst.reg_class is RegisterClass.GPR + assert compare.src1.reg_class is RegisterClass.FPR + assert compare.src2.reg_class is RegisterClass.FPR + assert compare.rounding_mode is None + + def test_constant_pool_is_bit_exact_and_distinguishes_signed_zero(self): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + builder.load_const(0.0, DataType.FLOAT32) + builder.load_const(-0.0, DataType.FLOAT32) + builder.load_const(0.0, DataType.FLOAT32) + builder.load_const(math.inf, DataType.FLOAT64) + selector = ExtendedInstructionSelector( + builder.program, + features=TargetFeatures("rv32imfd", "ilp32d"), + ) + instructions = selector.run() + assert [entry.bit_pattern for entry in selector.constant_pool] == [ + 0x00000000, + 0x80000000, + 0x7FF0000000000000, + ] + assembly = AsmEmitter( + instructions, + constant_pool=selector.constant_pool, + ).emit() + assert ".word 0x00000000" in assembly + assert ".word 0x80000000" in assembly + assert ".quad 0x7ff0000000000000" in assembly + assert "li.d" not in assembly + + def test_constant_pool_preserves_ieee_boundaries_and_nan_payloads(self): + builder = IRBuilder() + builder.new_function("test") + block = builder.new_block("entry") + patterns = ( + 0x00000001, # smallest positive subnormal + 0x00800000, # smallest positive normal + 0x7F7FFFFF, # largest finite value + 0x7F800000, # +Inf + 0xFF800000, # -Inf + 0x7FC00001, # quiet NaN payload + 0x7FA00002, # signaling NaN payload + ) + for index, bits in enumerate(patterns): + destination = builder.make_value( + f"nan{index}", + dtype=DataType.FLOAT32, + is_constant=True, + const_value=math.nan, + ) + block.add( + Instruction( + OpCode.LOAD_CONST, + destination, + attrs={"value": math.nan, "bit_pattern": bits}, + ) + ) + selector = ExtendedInstructionSelector( + builder.program, + features=TargetFeatures("rv32imf", "ilp32f"), + ) + selector.run() + assert tuple(entry.bit_pattern for entry in selector.constant_pool) == patterns + + @pytest.mark.parametrize( + ("onnx_dtype", "ir_dtype"), + [("FLOAT", DataType.FLOAT32), ("DOUBLE", DataType.FLOAT64)], + ) + def test_onnx_sqrt_preserves_float_dtype(self, tmp_path, onnx_dtype, ir_dtype): + onnx = pytest.importorskip("onnx") + tensor_type = getattr(onnx.TensorProto, onnx_dtype) + graph = onnx.helper.make_graph( + [onnx.helper.make_node("Sqrt", ["x"], ["y"])], + "sqrt", + [onnx.helper.make_tensor_value_info("x", tensor_type, [1])], + [onnx.helper.make_tensor_value_info("y", tensor_type, [1])], + ) + model = onnx.helper.make_model(graph) + path = tmp_path / "sqrt.onnx" + onnx.save(model, path) + program = ONNXParser().parse(str(path)) + sqrt = next( + instruction + for instruction in program.functions[0].blocks[0].instructions + if instruction.opcode is OpCode.SQRT + ) + assert sqrt.dest.dtype is ir_dtype + assert verify_ir(program) == (True, []) + + def test_target_isa_abi_combination_is_validated(self): + with pytest.raises(ValueError, match="ISA/ABI combination"): + TargetFeatures("rv32im", "ilp32d") + + def test_missing_target_extension_fails_before_selection(self): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + lhs = builder.make_value("lhs", dtype=DataType.FLOAT32) + rhs = builder.make_value("rhs", dtype=DataType.FLOAT32) + builder.add(lhs, rhs) + with pytest.raises(ValueError, match="requires the RISC-V F extension"): + ExtendedInstructionSelector( + builder.program, + features=TargetFeatures("rv32im", "ilp32"), + ).run() + + def test_unsupported_float_op_cannot_fall_back_to_integer_selection(self): + builder = IRBuilder() + builder.new_function("test") + builder.new_block("entry") + builder.relu(builder.make_value("x", dtype=DataType.FLOAT32)) + with pytest.raises(ValueError, match="no typed floating-point selection"): + ExtendedInstructionSelector( + builder.program, + features=TargetFeatures("rv32imf", "ilp32f"), + ).run() + + def test_machine_register_class_conflict_is_rejected(self): + instructions = [ + MachineInstr( + MachineOp.ADD, + MachineOperand.vreg("same", RegisterClass.GPR), + MachineOperand.vreg("lhs", RegisterClass.GPR), + MachineOperand.vreg("rhs", RegisterClass.GPR), + ), + MachineInstr( + MachineOp.FNEG_S, + MachineOperand.vreg("out", RegisterClass.FPR), + MachineOperand.vreg("same", RegisterClass.FPR), + ), + ] + with pytest.raises(ValueError, match="requires both gpr and fpr"): + validate_register_classes(instructions) + + def test_compiler_allocates_fpr_program_after_phase3(self, tmp_path): + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + target_isa="rv32imf", + target_abi="ilp32f", + ) + ).compile( + "", + str(tmp_path / "out.s"), + dsl_source=( + "a = const(1.5, dtype:f32)\n" + "b = const(2.0, dtype:f32)\n" + "r = add(a, b)\n" + "return r" + ), + ) + assert result.success, result.errors + assert "fadd.s ft" in result.output_text + assert "fmv.s fa0" in result.output_text + assert (tmp_path / "out.s").exists() + + def test_compiler_lowers_float_return_even_without_fp_arithmetic(self, tmp_path): + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + target_isa="rv32imf", + target_abi="ilp32f", + ) + ).compile( + "", + str(tmp_path / "out.s"), + dsl_source="x = const(1.5,dtype:f32)\nreturn x", + ) + assert result.success, result.errors + assert "fmv.s fa0" in result.output_text + + def test_float_return_requires_target_extension_before_phase3_boundary( + self, tmp_path + ): + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + target_isa="rv32im", + target_abi="ilp32", + ) + ).compile( + "", + str(tmp_path / "out.s"), + dsl_source="return input_value", + ) + assert not result.success + assert "requires the RISC-V F extension" in result.errors[0] + + def test_target_cli_options_reach_compiler_config(self): + args = build_arg_parser().parse_args( + [ + "input.dsl", + "--extended-isel", + "--target-isa", + "rv32imfd", + "--target-abi", + "ilp32d", + ] + ) + config = args_to_config(args) + assert (config.target_isa, config.target_abi) == ( + "rv32imfd", + "ilp32d", + ) + + def test_rounding_mode_is_rejected_on_non_rounding_operation(self): + with pytest.raises(ValueError, match="does not take a rounding mode"): + MachineInstr(MachineOp.FMIN_S, rounding_mode="rne") + + def test_assembly_emits_static_rounding_only_when_present(self): + f0 = MachineOperand.reg("ft0", RegisterClass.FPR) + f1 = MachineOperand.reg("ft1", RegisterClass.FPR) + f2 = MachineOperand.reg("ft2", RegisterClass.FPR) + assembly = AsmEmitter( + [ + MachineInstr(MachineOp.FADD_S, f0, f1, f2, rounding_mode="rne"), + MachineInstr(MachineOp.FMIN_S, f0, f1, f2), + ] + ).emit() + assert "fadd.s ft0, ft1, ft2, rne" in assembly + assert "fmin.s ft0, ft1, ft2\n" in assembly + + def test_legacy_li_d_cannot_reach_final_assembly(self): + with pytest.raises(ValueError, match="not a standard RISC-V instruction"): + AsmEmitter( + [ + MachineInstr( + MachineOp.LI_D, + MachineOperand.reg("f0", RegisterClass.FPR), + MachineOperand.immediate(1), + ) + ] + ).emit() diff --git a/tests/test_topic28_stage3.py b/tests/test_topic28_stage3.py new file mode 100644 index 00000000..ac400040 --- /dev/null +++ b/tests/test_topic28_stage3.py @@ -0,0 +1,563 @@ +"""Typed allocation, mixed frames and real RV32 cross-assembly acceptance.""" + +from __future__ import annotations + +import re +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from scratchv.backend.abi_frame import apply_abi_frames +from scratchv.backend.allocation import collect_register_types, validate_allocated +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.machine_types import ( + MachineInstr as MI, + MachineOp as O, + MachineOperand as P, + RegisterClass as RC, +) +from scratchv.backend.register_alloc import RegisterAllocator +from scratchv.backend.regalloc_linear import LinearScanAllocator +from scratchv.backend.riscv_encoder import RISCVAEncoder +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.simulator.rv32_emulator import RV32Emulator + + +def allocate(instructions, mode): + allocator = ( + LinearScanAllocator() + if mode == "linear" + else RegisterAllocator(instructions, mode) + ) + return ( + allocator.allocate_result(instructions) + if mode == "linear" + else allocator.allocate_result() + ) + + +def fp(name, width): + return P.vreg(name, RC.FPR, width) + + +def cross_assemble(assembly, tmp_path, width=64): + clang = shutil.which("clang") + assert clang, "Topic 28 acceptance requires clang with the RISC-V assembler" + source = tmp_path / "out.s" + source.write_text(assembly) + result = subprocess.run( + [ + clang, + "--target=riscv32", + f"-march=rv32im{'f' if width == 32 else 'fd'}", + f"-mabi=ilp32{'f' if width == 32 else 'd'}", + "-c", + str(source), + "-o", + str(tmp_path / "out.o"), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + "\n" + assembly + + +def execute_allocated(instructions, memory): + registers = {"zero": 0, "x0": 0, "sp": 4096} + memory = dict(memory) + + def read(operand): + return operand.value if operand.kind == "imm" else registers[str(operand.value)] + + def address(operand): + match = re.fullmatch(r"(-?\d+)\((\w+)\)", str(operand.value)) + return registers[match[2]] + int(match[1]) if match else read(operand) + + for instr in instructions: + if instr.op is O.LI: + registers[str(instr.dst.value)] = read(instr.src1) + elif instr.op in {O.FLW, O.FLD, O.LW}: + registers[str(instr.dst.value)] = memory[address(instr.src1)] + elif instr.op in {O.FSW, O.FSD, O.SW}: + memory[address(instr.src1)] = read(instr.dst) + elif instr.op in {O.FADD_S, O.FADD_D, O.ADD}: + registers[str(instr.dst.value)] = read(instr.src1) + read(instr.src2) + elif instr.op in {O.FMV_S, O.FMV_D, O.MV}: + registers[str(instr.dst.value)] = read(instr.src1) + return registers.get("fa0", registers.get("a0")) + + +class TestFloatingPointBackendIntegration: + @pytest.mark.parametrize("mode", ["naive", "greedy", "linear"]) + @pytest.mark.parametrize("width", [32, 64]) + @pytest.mark.parametrize("count", [12, 16]) + def test_pressure_spills_use_correct_width_and_preserve_values( + self, mode, width, count, tmp_path + ): + load = O.FLW if width == 32 else O.FLD + add = O.FADD_S if width == 32 else O.FADD_D + instructions = [MI(O.LABEL, target="main")] + for index in range(count): + instructions.extend( + [ + MI(O.LI, P.vreg(f"address{index}"), P.immediate(256 + index * 8)), + MI(load, fp(f"v{index}", width), P.vreg(f"address{index}")), + ] + ) + for index in range(1, count): + instructions.append( + MI( + add, + fp("v0", width), + fp("v0", width), + fp(f"v{index}", width), + rounding_mode="rne", + ) + ) + instructions.extend( + [ + MI( + O.FMV_S if width == 32 else O.FMV_D, + P.reg("fa0", RC.FPR, width), + fp("v0", width), + ), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ] + ) + allocation = allocate(instructions, mode) + validate_allocated(allocation.instructions) + fp_slots = [ + slot for slot in allocation.spill_slots.values() if slot.reg_class is RC.FPR + ] + assert all( + slot.size == width // 8 and slot.offset % slot.alignment == 0 + for slot in fp_slots + ) + if count > 12 or mode == "naive": + assert fp_slots + assert any( + instr.op is (O.FSW if width == 32 else O.FSD) + for instr in allocation.instructions + ) + assert any( + instr.op is load and "[regalloc:reload]" in instr.comment + for instr in allocation.instructions + ) + assembly = apply_abi_frames( + AsmEmitter(allocation.instructions).emit(), allocation + ) + assert all( + int(size) % 16 == 0 for size in re.findall(r"addi sp, sp, -(\d+)", assembly) + ) + cross_assemble(assembly, tmp_path, width) + # Execute the allocated stream with distinct memory values. This checks + # ownership/reloads, not just the shape of instructions in the output. + assert execute_allocated( + allocation.instructions, {256 + i * 8: float(i + 1) for i in range(count)} + ) == sum(range(1, count + 1)) + + def test_metadata_missing_width_conflict_and_wrong_physical_class_rejected(self): + with pytest.raises(ValueError, match="missing bit width"): + collect_register_types( + [MI(O.FNEG_S, P.vreg("out", RC.FPR), P.vreg("x", RC.FPR))] + ) + with pytest.raises(ValueError, match="conflicting width"): + collect_register_types( + [ + MI(O.FNEG_S, fp("x", 32), fp("a", 32)), + MI(O.FNEG_D, fp("x", 64), fp("b", 64)), + ] + ) + with pytest.raises(ValueError, match="wrong register class"): + validate_allocated([MI(O.FMV_S, P.reg("t0", RC.FPR), P.reg("ft0", RC.FPR))]) + + @pytest.mark.parametrize( + "mnemonic,extension", + [ + ("fadd.s", "F"), + ("fadd.d", "D"), + ("flw", "F"), + ("fld", "D"), + ("fsw", "F"), + ("fsd", "D"), + ("fmv.d", "D"), + ("fsqrt.s", "F"), + ("fcvt.s.d", "D"), + ("fcvt.d.s", "D"), + ], + ) + def test_builtin_encoder_rejects_fp_explicitly(self, mnemonic, extension): + with pytest.raises(ValueError, match=f"RISC-V {extension} extension"): + RISCVAEncoder().assemble(f"{mnemonic} ft0, ft1, ft2") + + def test_non_fp_fence_is_not_misreported_as_float_extension(self): + with pytest.raises(ValueError, match="Unknown instruction: fence"): + RISCVAEncoder().assemble("fence rw,rw") + + def test_builtin_emulator_rejects_floating_binary(self): + emulator = RV32Emulator(mem_size=4096) + emulator.load_code((0x53).to_bytes(4, "little")) + with pytest.raises(ValueError, match="F/D extension"): + emulator.run(max_instr=1) + + def test_function_sizes_use_previous_function_even_with_local_labels(self): + assembly = AsmEmitter( + [ + MI(O.LABEL, target="first"), + MI(O.LABEL, target=".exit"), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + MI(O.LABEL, target="second"), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ] + ).emit() + assert assembly.count(".size first, .-first") == 1 + assert assembly.count(".size second, .-second") == 1 + + @pytest.mark.parametrize("dtype", ["f32", "f64"]) + @pytest.mark.parametrize( + "case,expression,expected,flags", + [ + ("normal", "sqrt(a)", 0x40000000, 0), + ("signed_zero", "min(negative_zero,positive_zero)", 0x80000000, 0), + ("nan_number", "max(not_a_number,a)", 0x40800000, 0), + ("infinity", "div(a,positive_zero)", 0x7F800000, 8), + ("subnormal", "min(subnormal,a)", 1, 0), + ("rounding", "add(one,half_ulp)", 0x3F800000, 1), + ], + ) + def test_qemu_fp_binary_values_and_exception_flags( + self, dtype, case, expression, expected, flags, tmp_path + ): + qemu = shutil.which("qemu-riscv32") + linker = shutil.which("ld.lld") + if os.environ.get("SCRATCHV_REQUIRE_RISCV_EXECUTION") == "1" and ( + not qemu or not linker + ): + pytest.fail("required FP execution tools missing: qemu-riscv32 / ld.lld") + if not linker: + pytest.skip( + f"FP binary execution requires qemu-riscv32 and ld.lld; found qemu={qemu}, linker={linker}" + ) + is_double = dtype == "f64" + subnormal = "4.9406564584124654e-324" if is_double else "1.401298464324817e-45" + half_ulp = "1.1102230246251565e-16" if is_double else "5.960464477539063e-8" + source = ( + f"a = const(4.0,dtype:{dtype})\nnegative_zero = const(-0.0,dtype:{dtype})\n" + f"positive_zero = const(0.0,dtype:{dtype})\nnot_a_number = const(nan,dtype:{dtype})\n" + f"subnormal = const({subnormal},dtype:{dtype})\none = const(1.0,dtype:{dtype})\n" + f"half_ulp = const({half_ulp},dtype:{dtype})\nr = {expression}\nreturn r" + ) + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + reg_alloc="greedy", + target_isa="rv32imfd", + target_abi="ilp32d", + ) + ).compile("", str(tmp_path / "binary.s"), dsl_source=source) + assert result.success, result.errors + if is_double: + expected = { + "normal": 0x4000000000000000, + "signed_zero": 0x8000000000000000, + "nan_number": 0x4010000000000000, + "infinity": 0x7FF0000000000000, + "subnormal": 1, + "rounding": 0x3FF0000000000000, + }[case] + bits_check = ( + f"fsd fa0,0(sp)\nlw t0,4(sp)\nli t1,{(expected >> 32) & 0xFFFFFFFF}\n" + "bne t0,t1,.fail\nlw t0,0(sp)" + if is_double + else "fmv.x.w t0,fa0" + ) + harness = ( + ".text\n.globl _start\n_start:\naddi sp,sp,-16\ncsrw fflags,zero\ncall main\n" + f"{bits_check}\nli t1,{expected & 0xFFFFFFFF}\nbne t0,t1,.fail\n" + f"csrr t0,fflags\nli t1,{flags}\nbne t0,t1,.fail\nli a0,0\nj .exit\n" + ".fail:\nli a0,1\n.exit:\nli a7,93\necall\n" + ) + assembly = tmp_path / "execution.s" + assembly.write_text(result.output_text + harness) + binary = tmp_path / "execution" + linked = subprocess.run( + [ + shutil.which("clang"), + "--target=riscv32", + "-march=rv32imfd", + "-mabi=ilp32d", + "-nostdlib", + "-static", + "-fuse-ld=lld", + "-Wl,-e,_start", + str(assembly), + "-o", + str(binary), + ], + capture_output=True, + text=True, + ) + assert linked.returncode == 0, linked.stderr + if not qemu: + pytest.skip( + "FP executable linked successfully; execution requires qemu-riscv32 (not found)" + ) + execution = subprocess.run([qemu, str(binary)], capture_output=True, timeout=10) + assert execution.returncode == 0, f"{dtype}/{case}: {execution.stderr}" + + @pytest.mark.parametrize("dtype", ["FLOAT", "DOUBLE"]) + def test_onnx_scalar_parameter_and_initializer_reach_assembly( + self, dtype, tmp_path + ): + onnx = pytest.importorskip("onnx") + tensor_type = getattr(onnx.TensorProto, dtype) + graph = onnx.helper.make_graph( + [ + onnx.helper.make_node("Add", ["x", "c"], ["sum"]), + onnx.helper.make_node("Sqrt", ["sum"], ["y"]), + ], + "scalar", + [onnx.helper.make_tensor_value_info("x", tensor_type, [1])], + [onnx.helper.make_tensor_value_info("y", tensor_type, [1])], + [onnx.helper.make_tensor("c", tensor_type, [1], [4.0])], + ) + path = tmp_path / "scalar.onnx" + onnx.save(onnx.helper.make_model(graph), path) + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + reg_alloc="greedy", + target_isa="rv32imfd", + target_abi="ilp32d", + verify_ir=True, + ) + ).compile(str(path), str(tmp_path / "scalar.s")) + assert result.success, result.errors + suffix = "s" if dtype == "FLOAT" else "d" + assert ( + f"fadd.{suffix}" in result.output_text + and f"fsqrt.{suffix}" in result.output_text + ) + assert "scalar hard-float ABI parameter" in result.output_text + assert "bit-exact" in result.output_text + cross_assemble(result.output_text, tmp_path) + + @pytest.mark.parametrize( + "option", + ["peephole_asm", "const_merge", "beautify_asm", "count_instr", "schedule"], + ) + def test_optional_assembly_passes_preserve_fp_and_constant_pool( + self, option, tmp_path + ): + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + target_isa="rv32imfd", + target_abi="ilp32d", + **{option: True}, + ) + ).compile( + "", + str(tmp_path / "pass.s"), + dsl_source=( + "a = const(-0.0,dtype:f64)\nb = const(2.0,dtype:f64)\nr = add(a,b)\nreturn r" + ), + ) + assert result.success, result.errors + assert ".quad 0x8000000000000000" in result.output_text + assert re.search(r"fadd.d\s+ft\d+,\s*ft\d+,\s*ft\d+,\s*rne", result.output_text) + assert re.search(r"fmv.d\s+fa0", result.output_text) + cross_assemble(result.output_text, tmp_path) + + def test_mixed_frame_restores_every_return_and_checks_layout(self, tmp_path): + from scratchv.backend.allocation import ( + AllocationResult, + RegisterType, + SpillLayout, + SpillSlot, + ) + + layout = SpillLayout() + a = layout.reserve("a", RegisterType(RC.FPR, 32)) + b = layout.reserve("b", RegisterType(RC.FPR, 64)) + assert layout.reserve("b", RegisterType(RC.FPR, 64)) is b + allocation = AllocationResult([], {}, layout.slots, layout.size) + assembly = apply_abi_frames( + f"main:\n fsw ft0,{a.offset}(sp) # [regalloc:spill]\n" + f" fsd ft1,{b.offset}(sp) # [regalloc:spill]\n" + " beq a0,zero,.exit\n ret\n.exit:\n ret\n", + allocation, + ) + assert assembly.count("destroy stack frame") == 2 + cross_assemble(assembly, tmp_path) + with pytest.raises(ValueError, match="overlapping"): + apply_abi_frames( + "main:\nret", + AllocationResult( + [], + {}, + { + "a": SpillSlot(-8, 8, 8, RC.FPR), + "b": SpillSlot(-4, 4, 4, RC.FPR), + }, + 8, + ), + ) + with pytest.raises(ValueError, match="no layout entry"): + apply_abi_frames( + "main:\nfsw ft0,-12(sp) # [regalloc:spill]\nret", allocation + ) + + @pytest.mark.parametrize("dtype", ["f32", "f64"]) + @pytest.mark.parametrize("predicate", ["==", "!=", "<", ">", "<=", ">="]) + def test_comparison_cfg_and_both_returns_cross_assemble( + self, dtype, predicate, tmp_path + ): + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + reg_alloc="greedy", + target_isa="rv32imfd", + target_abi="ilp32d", + ) + ).compile( + "", + str(tmp_path / "branch.s"), + dsl_source=( + f"a = const(1.0,dtype:{dtype})\nb = const(2.0,dtype:{dtype})\n" + f"if (a {predicate} b):\n return a\nelse:\n return b\nendif" + ), + ) + assert result.success, result.errors + suffix = "s" if dtype == "f32" else "d" + assert re.search( + rf"f(?:eq|lt|le)\.{suffix}\s+[ts]\d+,\s*ft\d+,\s*ft\d+", result.output_text + ) + assert result.output_text.count(f"fmv.{suffix} fa0") == 2 + cross_assemble(result.output_text, tmp_path) + + @pytest.mark.parametrize("mode", ["naive", "greedy", "linear"]) + @pytest.mark.parametrize("width", [32, 64]) + def test_fp_user_store_keeps_gpr_address_and_fp_value(self, mode, width, tmp_path): + load, store = (O.FLW, O.FSW) if width == 32 else (O.FLD, O.FSD) + stream = [ + MI(O.LABEL, target="main"), + MI(O.LI, P.vreg("address"), P.immediate(256)), + MI(load, fp("value", width), P.vreg("address")), + MI(store, fp("value", width), P.vreg("address")), + MI(O.JALR, P.reg("zero"), P.reg("ra")), + ] + allocation = allocate(stream, mode) + stores = [ + instr + for instr in allocation.instructions + if instr.op is store and not instr.comment + ] + assert len(stores) == 1 + assert stores[0].dst.reg_class is RC.FPR and stores[0].src1.reg_class is RC.GPR + cross_assemble( + apply_abi_frames(AsmEmitter(allocation.instructions).emit(), allocation), + tmp_path, + ) + + @pytest.mark.parametrize( + "case,isa,abi", + [ + ("f32_sum", "rv32imf", "ilp32f"), + ("f64_dot", "rv32imfd", "ilp32d"), + ("i32_div_rem", "rv32im", "ilp32"), + ], + ) + def test_actual_cli_compiles_examples(self, case, isa, abi, tmp_path): + path = ( + Path(__file__).resolve().parents[1] + / "benchmarks" + / "topic28" + / f"{case}.dsl" + ) + output = tmp_path / "cli.s" + result = subprocess.run( + [ + sys.executable, + "-m", + "scratchv.main", + str(path), + "--extended-isel", + "--reg-alloc", + "greedy", + "--target-isa", + isa, + "--target-abi", + abi, + "-o", + str(output), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + result.stdout + cross_assemble(output.read_text(), tmp_path, 32 if isa == "rv32imf" else 64) + + def test_experimental_linear_allocator_explicitly_rejects_fpr(self): + from scratchv.backend.regalloc_linear_v1_5 import block_from_machine_instrs + + with pytest.raises(ValueError, match="GPR-only"): + block_from_machine_instrs([MI(O.FNEG_S, fp("out", 32), fp("in", 32))]) + + @pytest.mark.parametrize("dtype", ["f32", "f64"]) + @pytest.mark.parametrize("value", ["nan", "inf", "-inf"]) + @pytest.mark.parametrize("expression", ["min(a,b)", "max(a,b)", "abs(a)"]) + def test_fp_special_operands_never_take_integer_materialization( + self, dtype, value, expression, tmp_path + ): + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + reg_alloc="greedy", + target_isa="rv32imfd", + target_abi="ilp32d", + ) + ).compile( + "", + str(tmp_path / "special.s"), + dsl_source=( + f"a = const({value},dtype:{dtype})\nb = const(4.0,dtype:{dtype})\nr = {expression}\nreturn r" + ), + ) + assert result.success, result.errors + cross_assemble(result.output_text, tmp_path) + + @pytest.mark.parametrize("width", [32, 64]) + @pytest.mark.parametrize("renamed", [False, True]) + def test_linear_conversion_round_trip_preserves_fp_metadata_and_rounding( + self, width, renamed + ): + from scratchv.backend.regalloc_linear import ( + block_from_machine_instrs, + machine_instrs_from_block, + ) + + original = MI( + O.FADD_S if width == 32 else O.FADD_D, + fp("result", width), + fp("left", width), + fp("right", width), + rounding_mode="rne", + ) + block = block_from_machine_instrs([original]) + if renamed: + block[0].operands = ["ft0", "ft1", "ft2"] + result = machine_instrs_from_block(block)[0] + assert result.rounding_mode == "rne" + assert all( + operand.reg_class is RC.FPR and operand.bit_width == width + for operand in (result.dst, result.src1, result.src2) + ) + if renamed: + validate_allocated([result]) + else: + assert result == original diff --git a/tests/test_topic28_third_review.py b/tests/test_topic28_third_review.py new file mode 100644 index 00000000..e79cb16c --- /dev/null +++ b/tests/test_topic28_third_review.py @@ -0,0 +1,536 @@ +"""Third-review regressions checked with real RV32 execution.""" + +import struct +import subprocess + +import numpy as np +import onnx +import pytest +from onnx import TensorProto, helper, numpy_helper +from onnx.reference import ReferenceEvaluator + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.backend.inst_select_ext import ExtendedInstructionSelector +from scratchv.backend.machine_types import MachineOp +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as T, OpCode, Value +from scratchv.optimizer.licm import LICM +from tests.topic28_runtime import _qemu, _required_tool, execute_fp, execute_i32 + + +MODES = ("naive", "greedy", "linear") +LEVELS = ("none", "basic", "all") +TYPES = (T.INT32, T.FLOAT32, T.FLOAT64) +ONNX_TYPES = { + T.INT32: TensorProto.INT32, + T.FLOAT32: TensorProto.FLOAT, + T.FLOAT64: TensorProto.DOUBLE, +} +ARRAY_TYPES = {T.INT32: np.int32, T.FLOAT32: np.float32, T.FLOAT64: np.float64} + + +def configuration(mode="greedy", level="none", verify=True, *, extended=True, **kwargs): + return CompilerConfig( + use_extended_isel=extended, + reg_alloc=mode, + optimize_level=level, + verify_ir=verify, + target_isa="rv32imfd" if extended else "rv32im", + target_abi="ilp32d" if extended else "ilp32", + **kwargs, + ) + + +def compile_ir(program, tmp_path, config): + driver = CompilerDriver(config) + driver._parse = lambda *args: program + return driver.compile("", str(tmp_path / "out.s"), dsl_source="") + + +def bits(value, width): + return int.from_bytes(struct.pack(" str: + tool = shutil.which(name) + if tool is None: + pytest.fail(f"Topic 28 cross-assembly requires {name}") + return tool + + +def _qemu() -> str: + tool = shutil.which("qemu-riscv32") + # Reuse the review's locally extracted package, without a system install. + local = Path("/tmp/topic28-review-tools/extracted/usr/bin/qemu-riscv32") + if tool is None and local.is_file() and os.access(local, os.X_OK): + tool = str(local) + if tool is None: + if os.environ.get("SCRATCHV_REQUIRE_RISCV_EXECUTION") == "1": + pytest.fail("required execution tool missing: qemu-riscv32") + pytest.skip("binary execution requires qemu-riscv32 (not found)") + return tool + + +def execute(assembly, harness, tmp_path, isa="rv32imfd", abi="ilp32d", *, separate=False): + """Never patch product assembly; optionally compile caller independently.""" + qemu = _qemu() + clang, linker = _required_tool("clang"), _required_tool("ld.lld") + source = tmp_path / "product.s" + source.write_text(assembly if separate else assembly + "\n" + harness) + inputs = [str(source)] + if separate: + obj = tmp_path / "product.o" + assembled = subprocess.run([clang, "--target=riscv32", f"-march={isa}", + f"-mabi={abi}", "-c", str(source), "-o", str(obj)], + capture_output=True, text=True, timeout=20) + assert assembled.returncode == 0, assembled.stderr + caller = tmp_path / "caller.s" + caller.write_text(harness) + inputs = [str(obj), str(caller)] + binary = tmp_path / "execution" + linked = subprocess.run([clang, "--target=riscv32", f"-march={isa}", f"-mabi={abi}", + "-nostdlib", "-static", f"-fuse-ld={linker}", "-Wl,-e,_start", *inputs, + "-o", str(binary)], capture_output=True, text=True, timeout=20) + assert linked.returncode == 0, linked.stderr + run = subprocess.run([qemu, str(binary)], capture_output=True, timeout=2) + assert run.returncode == 0, (run.returncode, run.stderr, run.stdout) + return run.stdout + + +def caller_start(argument=None): + # tp is ABI-reserved, so unlike a temporary it survives the callee. + return (".text\n.globl _start\n_start:\naddi sp,sp,-32\nmv tp,sp\n" + + "".join(f"li s{i},{0x123400 + i}\n" for i in range(12)) + + (f"li a0,{argument}\n" if argument is not None else "")) + + +def check_caller(): + return ("bne sp,tp,.Lreview_bad_abi\n" + + "".join(f"li t0,{0x123400 + i}\nbne s{i},t0,.Lreview_bad_abi\n" for i in range(12))) + + +def write_exit(size=12): + return (f"li a0,1\nmv a1,sp\nli a2,{size}\nli a7,64\necall\n" + "li a0,0\nli a7,93\necall\n.Lreview_bad_abi:\nli a0,101\nli a7,93\necall\n") + + +def observe(assembly, tmp_path, dtype=T.INT32, *, argument=None, function="main", + isa="rv32imfd", abi="ilp32d", separate=False): + store = {T.INT32: "sw a0,0(sp)\nsw zero,4(sp)\n", + T.FLOAT32: "fsw fa0,0(sp)\nsw zero,4(sp)\n", + T.FLOAT64: "fsd fa0,0(sp)\n"}[dtype] + harness = (caller_start(argument) + ("csrw fflags,zero\n" if "f" in isa else "") + + f"call {function}\n" + check_caller() + store + + ("csrr t0,fflags\n" if "f" in isa else "li t0,0\n") + + "sw t0,8(sp)\n" + write_exit()) + data = execute(assembly, harness, tmp_path, isa, abi, separate=separate) + assert len(data) == 12 + width = 8 if dtype is T.FLOAT64 else 4 + return int.from_bytes(data[:width], "little", signed=dtype is T.INT32), int.from_bytes(data[8:], "little") + + +def observe_abi(assembly, path, dtype, function="main", isa="rv32imfd", abi="ilp32d"): + """Independent caller checks original sp/ra/GPR/fs before storing results. + + Product and caller are separate objects. On D, store the entire fa0 even + for f32 so a successful low-word result cannot hide incorrect NaN-boxing. + """ + fp, double = "f" in isa, "d" in isa + harness = ".text\n.globl _start\n_start:\naddi sp,sp,-64\nmv tp,sp\n" + harness += "".join(f"li s{i},{0x123400 + i}\n" for i in range(12)) + if fp: + harness += "la t0,.Ltopic28_seed\n" + harness += "".join(f"{'fld' if double else 'flw'} fs{i},0(t0)\n" for i in range(12)) + harness += "csrw fflags,zero\n" + harness += (f"call {function}\n.Ltopic28_after:\nbne sp,tp,.Ltopic28_bad\n" + "la t0,.Ltopic28_after\nbne ra,t0,.Ltopic28_bad\n") + harness += "".join(f"li t0,{0x123400 + i}\nbne s{i},t0,.Ltopic28_bad\n" for i in range(12)) + if fp: + for i in range(12): + harness += (f"{'fsd' if double else 'fsw'} fs{i},16(sp)\n" + "lw t0,16(sp)\nli t1,0x89abcdef\nbne t0,t1,.Ltopic28_bad\n") + if double: + harness += "lw t0,20(sp)\nli t1,0x3ff12345\nbne t0,t1,.Ltopic28_bad\n" + if dtype is T.INT32: + harness += "sw a0,0(sp)\nsw zero,4(sp)\n" + elif double: + harness += "fsd fa0,0(sp)\n" + else: + harness += "fsw fa0,0(sp)\nsw zero,4(sp)\n" + harness += ("csrr t0,fflags\n" if fp else "li t0,0\n") + harness += ("sw t0,8(sp)\nli a0,1\nmv a1,sp\nli a2,12\nli a7,64\necall\n" + "li a0,0\nli a7,93\necall\n.Ltopic28_bad:\nli a0,102\nli a7,93\necall\n") + if fp: + harness += ".section .rodata\n.p2align 3\n.Ltopic28_seed:\n.quad 0x3ff1234589abcdef\n" + output = execute(assembly, harness, path, isa, abi, separate=True) + assert len(output) == 12 + if dtype is T.FLOAT32 and double: + assert output[4:8] == b"\xff" * 4, "f32 return must be NaN-boxed on D" + width = 8 if dtype is T.FLOAT64 else 4 + return int.from_bytes(output[:width], "little", signed=dtype is T.INT32), int.from_bytes(output[8:], "little") + + +def _execute(assembly: str, harness: str, tmp_path: Path, + isa: str, abi: str) -> bytes: + if shutil.which("ld.lld") is None: + if os.environ.get("SCRATCHV_REQUIRE_RISCV_EXECUTION") == "1": + pytest.fail("required execution linker missing: ld.lld") + pytest.skip("ELF execution requires ld.lld") + output = execute(assembly, harness, tmp_path, isa, abi) + assert len(output) == 12, output + return output + + +def _store_bits(value: float, width: int) -> str: + bits = int.from_bytes(struct.pack("> 32}\nsw t0,4(sp)\n" + return result + + +def execute_fp(assembly: str, tmp_path: Path, width: int, + argument: float | None = None, *, + function: str = "main") -> tuple[int, int]: + """Return raw return bits and fflags; seed registers to expose undefined uses.""" + assert width in {32, 64} + load, store = ("flw", "fsw") if width == 32 else ("fld", "fsd") + seeds = _store_bits(9.0, width) + "".join( + f"{load} ft{index},0(sp)\n" for index in range(12) + ) + f"{load} fa0,0(sp)\n" + parameter = "" if argument is None else _store_bits(argument, width) + f"{load} fa0,0(sp)\n" + upper = "sw zero,4(sp)\n" if width == 32 else "" + harness = ( + ".text\n.globl _start\n_start:\naddi sp,sp,-16\n" + seeds + parameter + + f"csrw fflags,zero\ncall {function}\n" + f"{store} fa0,0(sp)\n" + upper + + "csrr t0,fflags\nsw t0,8(sp)\nli a0,1\nmv a1,sp\nli a2,12\nli a7,64\necall\n" + + "li a0,0\nli a7,93\necall\n" + ) + output = _execute(assembly, harness, tmp_path, "rv32imfd", "ilp32d") + return int.from_bytes(output[:width // 8], "little"), int.from_bytes(output[8:12], "little") + + +def execute_i32(assembly: str, tmp_path: Path, *, + argument: int | None = None, function: str = "main") -> int: + """Execute a RV32IM scalar function and return signed a0.""" + setup = "addi sp,sp,-16\n" + if argument is not None: + # Seed the pool and nearby frame to make missing ABI definitions + # observable, rather than accidentally inheriting the right value. + setup = ( + "addi sp,sp,-2048\naddi t0,sp,-1024\nli t1,512\n" + ".Lreview_clear:\nsw zero,0(t0)\naddi t0,t0,4\n" + "addi t1,t1,-1\nbnez t1,.Lreview_clear\n" + + "".join(f"li {reg},9\n" for reg in ( + *(f"t{i}" for i in range(7)), *(f"s{i}" for i in range(12)), + )) + f"li a0,{argument}\n" + ) + harness = ( + ".text\n.globl _start\n_start:\n" + setup + f"call {function}\n" + "sw a0,0(sp)\nsw zero,4(sp)\nsw zero,8(sp)\n" + "li a0,1\nmv a1,sp\nli a2,12\nli a7,64\necall\n" + "li a0,0\nli a7,93\necall\n" + ) + return int.from_bytes(_execute(assembly, harness, tmp_path, "rv32im", "ilp32")[:4], "little", signed=True) diff --git a/tests/topic28_support.py b/tests/topic28_support.py new file mode 100644 index 00000000..96b25de3 --- /dev/null +++ b/tests/topic28_support.py @@ -0,0 +1,35 @@ +"""Shared compilation and allocation setup for Topic 28 tests.""" + +from onnx import TensorProto as TP + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.backend.regalloc_linear import LinearScanAllocator +from scratchv.backend.register_alloc import RegisterAllocator +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.ir.types import DataType as T + +MODES = ("naive", "greedy", "linear") +LEVELS = ("none", "basic", "all") +TARGETS = ((T.FLOAT32, TP.FLOAT, "rv32imf", "ilp32f"), + (T.FLOAT32, TP.FLOAT, "rv32imfd", "ilp32d"), + (T.FLOAT64, TP.DOUBLE, "rv32imfd", "ilp32d")) + + +def configuration(mode, level="none", verify=True, *, extended=True, isa="rv32imfd", abi="ilp32d"): + return CompilerConfig(reg_alloc=mode, optimize_level=level, verify_ir=verify, + use_extended_isel=extended, target_isa=isa, target_abi=abi) + + +def compile_ir(program, path, config): + assert verify_ir(program) == (True, []) + driver = CompilerDriver(config) + driver._parse = lambda *args: program + result = driver.compile("", str(path / "out.s"), dsl_source="") + assert result.success, result.errors + assert verify_ir(program) == (True, []) + return result.output_text + + +def allocate(instructions, mode): + allocator = LinearScanAllocator() if mode == "linear" else RegisterAllocator(instructions, mode) + return allocator.allocate_result(instructions) if mode == "linear" else allocator.allocate_result()