From d0147a391fecdec9f9dd7d252423fa9d6fb3f14b Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Mon, 28 Sep 2026 23:23:25 +0800 Subject: [PATCH 01/13] docs(topic28): add instruction selection design --- ...76\350\256\241\346\226\207\346\241\243.md" | 627 ++++++++++++++++++ ...07\344\273\244\351\200\211\346\213\251.md" | 6 +- 2 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 "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" 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 0000000..0eab9d5 --- /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,627 @@ +# ScratchV 扩展指令选择技术设计文档 + +> **文档版本**:v1.1 +> **编写日期**:2026-09-28 +> **最后修订**:2026-09-28(结合 PR #26 复审,纳入完整 F/D 路径、舍入、候选模式、性能与仿真设计) +> **课题名称**:课题 28——完善后端指令选择 +> **涉及模块**:IR/前端类型、扩展指令选择、机器语义、寄存器分配、ABI/汇编发射、常量折叠、性能估算和 CLI +> **目标 ISA**:整数路径 RV32IM;原生单/双精度浮点路径 RV32IMFD +> **文档状态**:待实现 + +--- + +## 一、功能介绍 + +### 1.1 背景 + +ScratchV 共享后端将 DSL 或 ONNX 前端产生的 IR 翻译成 RISC-V 汇编: + +```text +DSL / ONNX + ↓ +Program / Function / BasicBlock / Instruction + ↓ 指令选择 +MachineInstr(仍使用虚拟寄存器) + ↓ 寄存器分配 +使用 t0/s0/f0 等物理寄存器的 MachineInstr + ↓ 汇编发射 +RISC-V 汇编 +``` + +现有基础指令选择器支持 `add/sub/mul/div`、访存、分支以及若干神经网络操作,但对整数余数、常用数学操作和原生 f32/f64 浮点缺少完整的端到端支持。仓库已有 `inst_select_ext.py` 原型和部分 `MachineOp`,但前端、IR、寄存器分配、主编译管线和测试尚未贯通。 + +### 1.2 设计目标 + +- 在共享 IR 中表示 `rem/mod`、`sqrt`、`min/max`、`abs` 等操作。 +- 为 `i32` 实现合法的 RV32IM 指令选择。 +- 让 `f32/f64` 从 IR 类型传播到 F/D 扩展指令、浮点寄存器、4/8 字节访存和 ABI 返回值。 +- 明确浮点精度、舍入模式、NaN/零和异常标志语义。 +- 为“硬件指令 / 伪指令序列 / 库调用”多候选降低预留目标特性和成本选择接口。 +- 确保每条输出指令的操作数类型和寄存器类别合法,不只检查“出现了某个 opcode”。 +- 通过 `--extended-isel` 显式启用新能力,开关默认关闭,保持旧路径兼容。 +- 建立单元、集成、汇编合法性和语义测试。 +- 补充操作和数据类型支持文档。 + +### 1.3 非目标 + +- 不修改 `standalone/onnx_to_riscv_standalone.py` 的 CNN 直接机器码生成路径。 +- 不在本课题实现完整的 IEEE 754 软浮点库。 +- 不实现浮点向量化或 RISC-V V 扩展。 +- 不对 LLVM 后端重复实现指令选择;LLVM 路径继续由 LLVM 完成目标降低。 +- 不将 `transpose/concat` 未经数据布局证明地降低为“复制第一个指针”的 no-op。这两个算子不属于本课题交付范围。 +- 不实现完整的 DAG 指令选择或全局调度成本模型;只引入轻量候选模式选择接口。 + +### 1.4 当前实现与目标状态 + +以 2026-09-28 工作区为基线: + +| 能力 | 当前状态 | 目标状态 | +|---|---|---| +| `DataType.FLOAT32/FLOAT64` | 已定义,ONNX `FLOAT/DOUBLE` 已映射 | 保留并补齐构造、传播和验证 | +| 基础 `div` | 已选择为 `DIV`,但常量操作数可能直接落到寄存器位置 | `i32` 源始终是 GPR,`f32/f64` 源始终是 FPR | +| 扩展 IR opcode | `OpCode` 中缺失 | 新增 `REM/SQRT/MIN/MAX/ABS` | +| 扩展选择器 | `inst_select_ext.py` 有原型 | 与真实 IR、寄存器类和主管线贯通 | +| F/D 扩展 MachineOp | 部分已列出 | 补齐 f32/f64 指令,删除非标准 `li.d`,增加舍入信息 | +| 浮点寄存器 | 未实现,所有 vreg 均分配到 GPR | `f32/f64` vreg 均分配到 FPR | +| `flw/fsw/fld/fsd` 输出 | FP 路径未完整贯通 | 输出标准 `offset(base)` 格式 | +| `--extended-isel` | CLI 已声明,但 `CompilerConfig` 未保存 | 完整贯通 CLI 到 `CompilerDriver` | +| 测试 | 13 个用例主要检查对象创建和旧操作 | 直接覆盖扩展 IR、汇编和语义 | + +> 课题描述中的 `type_system.py` 在当前仓库中不存在,实际类型系统文件为 `scratchv/ir/types.py`。 + +--- + +## 二、语义与类型设计 + +### 2.1 新增 IR 操作 + +| IR opcode | 操作数 | 结果 | 允许类型 | 语义 | +|---|---:|---:|---|---| +| `REM` | 2 | 1 | `i32` | RISC-V 有符号余数 | +| `SQRT` | 1 | 1 | `f32` 或 `f64` | IEEE 754 单/双精度平方根 | +| `MIN` | 2 | 1 | 相同的 `i32`、`f32` 或 `f64` | 返回较小值 | +| `MAX` | 2 | 1 | 相同的 `i32`、`f32` 或 `f64` | 返回较大值 | +| `ABS` | 1 | 1 | `i32`、`f32` 或 `f64` | 绝对值 | + +DSL 同时接受 `rem(a, b)` 和 `mod(a, b)`,二者在第一版均降低为 `OpCode.REM`,遵循 RISC-V `rem` 语义,不声称与 Python `%` 在负数上一致。文档和测试必须显式说明这一点。 + +### 2.2 整数除法和余数语义 + +`DIV`/`REM` 使用 RV32M 有符号语义: + +- 商向 0 截断。 +- 非零余数与被除数同号。 +- 除数为 0 时不由选择器插入陷阱,保留 ISA 定义的结果。 +- `INT32_MIN / -1` 的溢出行为按 ISA 定义测试,选择器不插入额外分支。 + +### 2.3 类型传播 + +IRBuilder 不再为所有结果默认创建 `f32` 值。二元操作按以下规则生成结果: + +```text +require lhs.dtype == rhs.dtype +require dtype is supported by opcode +dest.dtype = lhs.dtype +``` + +一元操作的结果类型继承输入。`LOAD` 的结果类型由指针所指元素类型决定;在当前 IR 没有独立指针类型的情况下,`builder.load(ptr, dtype=...)` 需允许显式指定。 + +不做隐式 `i32↔f32`、`i32↔f64` 或 `f32↔f64` 转换。异构操作数在 IR 验证阶段报错,不由指令选择器根据“任意一个操作数的 dtype”猜测转换或指令。 + +### 2.4 DSL 类型入口 + +为了在不引入完整类型声明语法的情况下编写 `f32/f64` 用例,新增显式常量操作: + +```text +a = const(1.5, dtype:f64) +b = const(2.0, dtype:f64) +c = add(a, b) +return c +``` + +`dtype` 只接受 `i32`、`f32`、`i64`、`f64`;缺省值与现有字面量一致,为 `f32`。当前简单 DSL 的未声明名称仍是 `f32`,不在本课题扩展函数参数声明语法。 + +### 2.5 IR 验证签名 + +`analysis/ir_verifier.py` 的集中签名表增加: + +| 指令 | 签名 | +|---|---| +| `REM` | `(i32, i32) -> i32` | +| `SQRT` | `(F) -> F`,`F ∈ {f32, f64}` | +| `MIN/MAX` | `(T, T) -> T`,`T ∈ {i32, f32, f64}` | +| `ABS` | `(T) -> T`,`T ∈ {i32, f32, f64}` | + +验证器只报告错误,不修改 dtype,不插入 cast,不将混合类型或不支持的类型组合静默改为其他操作。 + +### 2.6 `DIV/REM/MOD` 表示决策 + +保留现有单一 `OpCode.DIV`,由已验证的结果 dtype 决定 `i32 -> div`、`f32 -> fdiv.s`、`f64 -> fdiv.d`。不新增与它重叠的 `IDIV` OpCode。 + +IR 只新增 `REM`;DSL `mod(a,b)` 是其语法别名,不新增独立 `MOD` OpCode。这样保持旧 `DIV` IR 兼容,同时避免两个语义完全相同的操作码进入所有下游 pass。 + +--- + +## 三、指令选择设计 + +### 3.1 总体结构 + +保留 `InstructionSelector` 作为 RV32IM 默认选择器,`ExtendedInstructionSelector` 继承它并覆写类型相关操作: + +```text +InstructionSelector + ├─ 现有 RV32IM 操作 + └─ 操作数合法化辅助方法 + ↑ +ExtendedInstructionSelector + ├─ i32 rem/min/max/abs + ├─ f32/f64 load/store/arithmetic + ├─ f32/f64 compare/min/max/abs/sqrt + ├─ TargetFeatures + SelectionPattern 候选选择 + └─ 按 dtype 选择 GPR/FPR 操作数 +``` + +不在 `supported_ops` 中声称一个 `OpCode` 中尚不存在、或者无法从前端构造的操作已受支持。 + +### 3.2 目标特性与候选模式 + +同一 IR 操作可能有多个合法降低方案。例如 `sqrt(f64)` 可以选择 `fsqrt.d`、libm `sqrt` 或未来的软件展开。为避免将选择政策散落在多个 `_select_*` 分支中,引入轻量接口: + +```python +@dataclass(frozen=True) +class TargetFeatures: + isa: str = "rv32im" + abi: str = "ilp32" + rounding_mode: str = "rne" + libm_available: bool = False + optimize_for: str = "balanced" # size / speed / balanced + +@dataclass(frozen=True) +class SelectionPattern: + name: str + opcode: OpCode + dtypes: tuple[DataType, ...] + required_extensions: frozenset[str] + estimated_cost: int + predicate: PatternPredicate + emit: PatternEmitter + priority: int = 0 +``` + +每个 `_select_*` 仍是现有 `getattr` dispatch 的稳定入口,但可从注册表中过滤“类型正确且目标特性满足”的 pattern,再按 `(estimated_cost, priority, name)` 确定性选择。初版不做全局 DAG 成本分析,也不让不合法候选参与比较。 + +### 3.3 整数指令降低 + +| IR | RV32IM 输出 | 备注 | +|---|---|---| +| `DIV i32` | `div rd, rs1, rs2` | `rs1/rs2` 必须是 GPR | +| `REM i32` | `rem rd, rs1, rs2` | `mod` DSL 别名也到该路径 | +| `ABS i32` | `srai/xor/sub` | 保留 `INT32_MIN` 的二进制补码结果 | +| `MIN i32` | `slt` + 掩码选择 | 不依赖 Zbb `min` | +| `MAX i32` | `slt` + 掩码选择 | 不依赖非 RV32IM 指令 | + +`min/max` 使用正确的全位掩码,不能将 `slt` 产生的 0/1 直接与差值做 `and`。一种无分支的 `min` 方案为: + +```asm +slt flag, a, b # flag = a < b +sub mask, x0, flag # mask = 0x00000000 or 0xffffffff +xor diff, a, b +and diff, diff, mask +xor dst, b, diff # flag ? a : b +``` + +`max` 交换选择项即可。如临时 vreg 由选择器生成,必须使用统一的 fresh-name 机制,不得固定使用 `_min_tmp1` 而与多条操作或用户名称冲突。 + +### 3.4 立即数合法化 + +RISC-V `add/sub/mul/div/rem` 都是寄存器-寄存器指令。选择器需区分: + +- 允许立即数的机器操作,如 `ADDI`、`SRAI`、`LI`。 +- 只允许寄存器的机器操作,如 `ADD`、`MUL`、`DIV`、`REM`、`FADD_S`、`FADD_D`。 + +对后一类操作,常量必须先由它自己的 `LOAD_CONST` 定义产生虚拟寄存器,或由选择器显式物化到 fresh vreg。不得输出: + +```asm +div t1, t0, 2 # 非法:第三个操作数不是寄存器 +``` + +### 3.5 `f32/f64` 算术和访存 + +| IR | RV32F 输出 | RV32D 输出 | 寄存器类别 | +|---|---|---|---| +| `LOAD` | `flw fd, offset(rs1)` | `fld fd, offset(rs1)` | `fd:FPR`, `rs1:GPR` | +| `STORE` | `fsw fs2, offset(rs1)` | `fsd fs2, offset(rs1)` | `fs2:FPR`, `rs1:GPR` | +| `ADD` | `fadd.s fd, fs1, fs2` | `fadd.d fd, fs1, fs2` | FPR/FPR/FPR | +| `SUB` | `fsub.s fd, fs1, fs2` | `fsub.d fd, fs1, fs2` | FPR/FPR/FPR | +| `MUL` | `fmul.s fd, fs1, fs2` | `fmul.d fd, fs1, fs2` | FPR/FPR/FPR | +| `DIV` | `fdiv.s fd, fs1, fs2` | `fdiv.d fd, fs1, fs2` | FPR/FPR/FPR | +| `NEG` | `fneg.s fd, fs1` | `fneg.d fd, fs1` | FPR/FPR | +| `ABS` | `fabs.s fd, fs1` | `fabs.d fd, fs1` | FPR/FPR | +| `MIN/MAX` | `fmin.s/fmax.s` | `fmin.d/fmax.d` | FPR/FPR/FPR | +| `SQRT` | `fsqrt.s fd, fs1` | `fsqrt.d fd, fs1` | FPR/FPR | + +`STORE` 的 IR 操作数顺序保持现有约定 `[address, value]`;降低到机器指令时显式映射为 `store value, offset(address)`,不得将 IR 的 operand 0 误当作待写入值。F/D 指令使用同一组 32 个 FPR;D 扩展使 FLEN=64,f32 值遵守 NaN-boxing 规则。 + +### 3.6 `f32/f64` 比较与分支 + +IR 继续使用现有 `BR_IF` 比较形式,按 dtype 选择 `.s/.d` 后缀,再按 `cmp_op` 生成比较: + +| `cmp_op` | 机器序列 | +|---|---| +| `==` | `feq.s gpr, fa, fb` / `feq.d gpr, fa, fb` | +| `!=` | `feq.s gpr, fa, fb` / `feq.d gpr, fa, fb`,再反转条件 | +| `<` | `flt.s gpr, fa, fb` / `flt.d gpr, fa, fb` | +| `>` | `flt.s gpr, fb, fa` / `flt.d gpr, fb, fa` | +| `<=` | `fle.s gpr, fa, fb` / `fle.d gpr, fa, fb` | +| `>=` | `fle.s gpr, fb, fa` / `fle.d gpr, fb, fa` | + +浮点比较结果写入 GPR,然后使用 `bnez` 或 `beq` 分支。这是一条同时涉及 FPR 源和 GPR 目标的重要测试路径。 + +### 3.7 `sqrt` 策略 + +具备 F/D 扩展时,f32 默认选择 `fsqrt.s`,f64 默认选择 `fsqrt.d`。不在第一版默认调用 `sqrtf/sqrt` 库函数,因为硬浮点 ABI 参数和返回值使用 `fa0`,而当前原型错误地使用 `a0` 和普通 `mv`。 + +如后续保留软件库回退,必须另设显式开关,并在调用前后遵守与精度匹配的硬浮点 ABI(`ilp32f/ilp32d`)、保存活跃 caller-saved FPR,不得与硬件路径共用 GPR `MV`。 + +### 3.8 浮点舍入、精度和特殊值 + +- 算术和平方根默认使用 `RNE`(round to nearest, ties to even),不依赖调用环境中未知的动态 `frm`。 +- `MachineInstr` 增加可选 `rounding_mode`,`AsmEmitter` 为需要舍入的 FP 指令输出静态 `rne`。`fmin/fmax`、比较和符号操作不附加 rm。 +- f32/f64 分别使用 IEEE 754 binary32/binary64,不允许先转为 Python `int` 或经过低精度十进制文本往返。 +- 浮点异常默认设置 `fcsr` 标志而不触发 trap。 +- `fmin/fmax` 按 RISC-V F/D `minimumNumber/maximumNumber` 语义处理 NaN,并将 `-0.0` 视为小于 `+0.0`;测试不直接以 Python `min/max` 作为这些边界的参考。 + +### 3.9 `f32/f64` 常量 + +RISC-V 没有标准 `li.s/li.d`。当前 `LI_D` 不能作为最终汇编输出,也不能先将 `1.5` 转成整数 `1`。 + +目标方案使用按宽度对齐的常量池:f32 使用 4 字节 `.word + flw`,f64 使用 8 字节 `.quad + fld`。例如: + +```asm +.section .rodata +.p2align 3 +.LC_f64_0: + .quad 0x3ff8000000000000 # 1.5 + +.text + la t0, .LC_f64_0 + fld f0, 0(t0) +``` + +选择器按 dtype 和 IEEE 754 位模式联合去重,`+0.0/-0.0`、NaN 位模式和 Inf 不得经过十进制文本往返而改变。常量池标签只在当前编译单元内可见。 + +### 3.10 常量折叠语义 + +`optimizer/constant_folding.py` 需同步识别 `REM` 及类型正确的 `DIV`。整数折叠不得直接使用 Python `//` 和 `%`,因为它们的负数语义与 RISC-V 不同。对非零除数: + +```text +q = trunc_toward_zero(a / b) # 用整数绝对值运算实现,不经过 float +r = a - q * b +``` + +除零折叠在初版保守跳过;`INT32_MIN / -1` 按 RV32 回绕结果处理。浮点常量折叠只有在能证明与目标 binary32/binary64 + RNE 相同时才启用,否则保留到目标指令。 + +--- + +## 四、寄存器类与溢出设计 + +### 4.1 寄存器类 + +新增机器寄存器类: + +```python +class RegisterClass(enum.Enum): + GPR = "gpr" + FPR = "fpr" +``` + +`MachineOperand` 对 `reg/vreg` 记录 `reg_class`,立即数和标签不需寄存器类。为保持旧代码兼容,未指定的现有整数操作数默认为 GPR。 + +`machine_semantics.py` 除 defs/uses 以外记录每个位置的类别约束,例如: + +```text +FADD_S/FADD_D: dst=FPR, src1=FPR, src2=FPR +FLW/FLD: dst=FPR, src1=GPR +FSW/FSD: operand0=FPR(value), operand1=GPR(address) +FLT_S/FLT_D: dst=GPR, src1=FPR, src2=FPR +``` + +同一 vreg 在不同指令中出现矛盾类别时立即报错,不将位模式在 GPR/FPR 间静默移动。 + +### 4.2 物理寄存器池 + +| 类别 | 初版可分配池 | 保留 | +|---|---|---| +| GPR | 现有 `t0-t6, s0-s11` | `zero/sp/ra/gp/tp`、ABI 参数寄存器 | +| FPR | `ft0-ft11` | `fa0-fa7` 作为参数/返回,`fs0-fs11` 留待 ABI 保存支持 | + +初版仅分配 caller-saved `ft*` 可避免错误使用未在 `abi_frame.py` 保存的 `fs*`。在无外部调用的默认硬件 `sqrt` 路径中可安全使用。 + +### 4.3 分配器策略 + +`naive` 和 `greedy` 分配器将分别维护 GPR/FPR 映射与空闲池。`linear` 分配器在支持寄存器类之前,遇到 FPR vreg 必须给出明确错误,不能生成错误汇编;本课题交付前再将线性扫描按类别分配补齐。 + +分配时必须保证: + +- GPR 和 FPR 名称空间分离。 +- 一条混合类指令的源/目标分别从正确池中取寄存器。 +- 活跃值穿越 `CALL` 时,caller-saved GPR/FPR 都需溢出或保存。 +- 目标值和源值同名时依据 machine semantics 处理,不重复加载。 + +### 4.4 溢出槽 + +| 值类型 | 槽大小 | 对齐 | 写回/加载 | +|---|---:|---:|---| +| GPR/i32 | 4 字节 | 4 | `sw/lw` | +| FPR/f32 | 4 字节 | 4 | `fsw/flw` | +| FPR/f64 | 8 字节 | 8 | `fsd/fld` | + +分配器不再只返回“槽位数”,而是返回总溢出字节数或带 size/alignment 的 frame layout。`abi_frame.py` 根据真实字节数建立 16 字节对齐栈帧。 + +### 4.5 ABI 返回值 + +- `i32` 返回值使用 `a0`。 +- `f32/f64` 返回值都使用 `fa0`,并由类型决定值的宽度。 +- FPR 间复制分别使用 `fmv.s/fmv.d`,不使用整数 `mv`。 +- 本课题不实现多返回值。 + +--- + +## 五、汇编发射与目标配置 + +### 5.1 访存格式 + +`AsmEmitter` 将 `FLW/FSW/FLD/FSD` 与 `LW/SW` 一样按内存操作数格式化: + +```asm +flw ft0, 0(t0) +fsw ft0, 4(sp) +fld ft0, 0(t0) +fsd ft0, 8(sp) +``` + +不允许通用三操作数路径输出 `fld ft0, t0`。`LOAD/STORE` 的地址操作数始终是 GPR,数据操作数由宽度和类型决定。 + +### 5.2 目标 ISA + +`CompilerConfig` 增加: + +```python +use_extended_isel: bool = False +target_isa: str = "rv32im" +target_abi: str = "ilp32" +fp_rounding_mode: str = "rne" +``` + +原生 `f32` 要求 F 扩展,原生 `f64` 要求 D 扩展;D 扩展依赖 F。推荐的完整配置为 `rv32imfd + ilp32d`,只用 f32 时可以使用 `rv32imf + ilp32f`。目标 ISA/ABI 与 IR 类型不匹配时,在代码生成前返回明确错误,不静默降级为整数指令。 + +### 5.3 编译器集成 + +```text +--extended-isel + ↓ +args.extended_isel + ↓ +CompilerConfig.use_extended_isel + ↓ +CompilerDriver._generate_riscv_linear() + ↓ +ExtendedInstructionSelector(program) +``` + +当 `backend=llvm` 时,`--extended-isel` 报错或显式忽略二者只能选一。本设计选择报错,避免用户误以为开关改变了 LLVM 后端。`--dag-isel` 和 `--extended-isel` 互斥。CLI 同时提供 `--target-isa`、`--target-abi` 和 `--fp-rounding-mode`,并在构造 `CompilerConfig` 时保留其值。 + +### 5.4 机器码编码器 + +当前 `riscv_encoder.py` 主要编码 RV32IM。本课题的必交付以“生成可被 GNU/LLVM RISC-V 汇编器接受的 `.s`”为边界;直接编码 F/D 扩展二进制属于后续扩展。 + +如用户对含 F/D 指令的汇编调用内置编码器,必须得到“当前编码器不支持 F/D 扩展”的明确错误,不得跳过未知指令产生不完整二进制。 + +--- + +## 六、修改模块 + +| 文件 | 修改类型 | 设计内容 | +|---|---|---| +| `scratchv/ir/types.py` | 修改 | 新增 `REM/SQRT/MIN/MAX/ABS`;保留 `FLOAT32/FLOAT64` 并贯通类型传播 | +| `scratchv/ir/builder.py` | 修改 | 新增 builder API;修正类型传播;支持显式 load dtype | +| `scratchv/frontend/dsl_validator.py` | 修改 | 增加新操作签名和 `const(..., dtype:...)` 校验 | +| `scratchv/frontend/dsl_parser.py` | 修改 | 解析扩展操作和类型常量 | +| `scratchv/frontend/onnx_parser.py` | 修改 | 将课题范围内 ONNX 算子和 `FLOAT/DOUBLE` 映射到同一类型化 IR | +| `scratchv/analysis/ir_verifier.py` | 修改 | 增加 opcode 签名与类型规则 | +| `scratchv/backend/instruction_select.py` | 修改 | 抽取操作数合法化、fresh vreg 和类别辅助逻辑 | +| `scratchv/backend/inst_select_ext.py` | 重构 | 实现真实可达的 F/D 扩展 handler,修正 min/store/constant/sqrt | +| `scratchv/backend/inst_patterns.py` | 新增 | 定义 `TargetFeatures/SelectionPattern` 及确定性候选排序 | +| `scratchv/backend/machine_types.py` | 修改 | 新增 `RegisterClass`、FPR 集合、F/D MachineOp 和舍入字段;移除最终 `LI_D` | +| `scratchv/backend/machine_semantics.py` | 修改 | 记录各操作数位置的 GPR/FPR 约束 | +| `scratchv/backend/register_alloc.py` | 修改 | 分类寄存器池、f32/f64 spill/reload、调用保存 | +| `scratchv/backend/regalloc_linear.py` | 修改 | 按寄存器类分别计算和分配区间 | +| `scratchv/backend/asm_emit.py` | 修改 | 正确格式化 FP 指令、静态舍入、访存和常量池 | +| `scratchv/backend/abi_frame.py` | 修改 | 按字节和对齐建立混合溢出栈帧 | +| `scratchv/optimizer/constant_folding.py` | 修改 | 按 RISC-V 语义折叠 `DIV/REM`,仅在可保证目标一致时折叠 FP | +| `scratchv/backend/cycle_estimator.py` | 修改 | 补齐 F/D 指令分类和延迟,用于稳定性能指标 | +| `scratchv/compiler.py` | 修改 | 增加配置并选择扩展后端 | +| `scratchv/main.py` | 修改 | 贯通扩展、ISA、ABI 和舍入 CLI 参数,校验互斥开关 | +| `tests/test_inst_select_ext.py` | 重写/扩展 | 覆盖真实扩展操作和负例 | +| `tests/test_*` | 扩展 | DSL、IR 验证、寄存器分配、CLI 和端到端测试 | + +--- + +## 七、测试设计 + +### 7.1 IR 和 DSL 测试 + +- `rem/mod/sqrt/min/max/abs` 能从 DSL 构造对应 IR。 +- `const(1.5, dtype:f32/f64)` 分别产生 `DataType.FLOAT32/FLOAT64`。 +- `f32 + f32 -> f32`、`f64 + f64 -> f64`,`i32 rem i32 -> i32`。 +- `f32 + f64`、`f64 + i32`、`i32 sqrt`、未知 dtype 被验证器拒绝。 +- ONNX `FLOAT/DOUBLE` 输入保持精度并到达与 DSL 相同的指令选择路径。 +- 新操作不影响现有 DSL 诊断位置和错误数上限。 + +### 7.2 指令选择单元测试 + +| 用例 | 必须断言 | +|---|---| +| `i32 div` | 产生 `DIV`,两个源均为 GPR vreg | +| `i32 rem` | 产生 `REM` | +| `i32 abs` | 序列和临时 vreg 唯一 | +| `i32 min/max` | 序列对正、负、相等值结果正确 | +| `f32/f64 add/sub/mul/div` | 分别产生 F/D 扩展指令,所有数据 vreg 为 FPR | +| `f32/f64 load/store` | 分别产生 `flw/fsw` 或 `fld/fsd`;地址为 GPR,数据为 FPR,store 顺序正确 | +| `f32/f64 compare` | 比较输入为 FPR,布尔结果为 GPR | +| `f32/f64 sqrt` | 默认产生 `fsqrt.s/fsqrt.d`,不产生错误的 `a0` 调用序列 | +| `f32/f64 const` | 保留完整 binary32/binary64 位模式,不出现 `li.s/li.d` | +| 多候选操作 | 只选择 ISA/ABI/类型条件满足的 pattern,同成本时结果确定 | + +仅断言 `supported_ops` 包含名称不算功能测试。 + +### 7.3 寄存器分配测试 + +- 整数指令只获得 GPR,F/D 算术指令只获得 FPR。 +- `flt.s/flt.d` 的目标是 GPR,两个源是 FPR。 +- 分别构造超过 12 个同时活跃 f32/f64 值,验证 `fsw/flw` 的 4 字节槽位和 `fsd/fld` 的 8 字节槽位。 +- 同时施加 GPR/FPR 压力,两类池不互相污染。 +- `naive/greedy/linear` 对同一 f32/f64 用例都不泄漏 vreg。 + +### 7.4 汇编合法性测试 + +使用可用的 RISC-V GNU 汇编器或 Clang 交叉目标检查: + +```bash +clang --target=riscv32 -march=rv32imfd -mabi=ilp32d \ + -c output.s -o output.o +``` + +CI 环境没有交叉工具时可标记为条件跳过,但必须同时有不依赖外部工具的文本结构测试,检查: + +- 无虚拟寄存器泄漏。 +- `div/rem` 不包含立即数源操作数。 +- `fadd.s/fadd.d` 不使用 `t*/s*/a*`。 +- `flw/fsw/fld/fsd` 使用 `offset(base)` 格式。 +- 需要舍入的 FP 指令显式带 `rne`,不需要 rm 的指令不多出操作数。 +- 输出中没有非标准 `li.s/li.d`。 + +### 7.5 语义测试 + +整数用例由现有 RV32 仿真器或独立参考函数验证: + +- 正数、负数、0、`INT32_MIN/MAX`。 +- `div/rem` 除零和溢出边界。 +- `min/max/abs` 的随机对比。 + +f32/f64 用例覆盖正常值、次正常值、±0、±Inf、NaN 以及中点舍入,并校验 `RNE`、`fmin/fmax` 的 NaN/有符号零语义。用例优先在支持 RV32F/D 的 QEMU 或 Spike 上运行;测试启动时先做能力探测并记录模拟器、版本、ISA 和 ABI。若 CI 不具备,将语义测试明确标为 skipped,但仍须完成汇编和 IEEE 754 常量位模式验证。不把当前仅支持 RV32IM 的内置编码器/仿真器误报为 FP 语义验证已通过。 + +### 7.6 CLI 与回归测试 + +- `args_to_config()` 保存 `--extended-isel`。 +- 开关关闭时仍使用基础选择器。 +- 开关开启时对 f32/f64 DSL 生成 F/D 扩展汇编。 +- `--target-isa/--target-abi/--fp-rounding-mode` 能到达选择器,不合法组合在生成前失败。 +- `--dag-isel --extended-isel` 和 `--backend llvm --extended-isel` 给出可操作错误。 +- 现有 backend、parser、IR verifier、regalloc 测试全部通过。 + +### 7.7 性能与稳定性指标 + +性能验收使用固定输入的静态指标,不使用易受 CI 噪声影响的编译 wall-clock 时间: + +- 记录指令选择后和寄存器分配后的指令数、FP 指令数、spill load/store 数及总 spill 字节。 +- 扩展 `cycle_estimator.py` 对 `fmin/fmax/compare/sqrt` 的分类与延迟表,并在固定管线配置下输出预估 cycles/CPI。 +- 开关关闭时,现有 RV32IM 基准的汇编文本和静态指标必须与基线一致。 +- 开关开启但程序不含扩展操作时,分配后指令数和 spill 数不得退化。 +- f32/f64 求和、点积、比较和 `sqrt/min/max` 微基准将上述数值写入 JSON;初版将其作为可复现基线,不设置未经硬件标定的绝对 cycles 门槛。 + +--- + +## 八、验收标准 + +课题完成需同时满足: + +1. DSL、ONNX 课题范围映射和 IRBuilder 可构造 `rem/mod/sqrt/min/max/abs` 及 f32/f64 算术用例。 +2. IR 验证器对新操作进行数量、结果和类型检查。 +3. `i32 div/rem` 只产生合法寄存器操作数。 +4. `f32/f64 add/sub/mul/div/load/store/neg/abs/min/max/sqrt` 分别产生对应 F/D 扩展指令。 +5. f32/f64 数据使用 FPR,地址和比较布尔结果使用 GPR。 +6. f32/f64 spill 分别使用 4/8 字节对齐的 `fsw/flw` 和 `fsd/fld`,栈帧继续保持 16 字节对齐。 +7. f32/f64 常量不丢失精度和位模式,输出不包含 `li.s/li.d`。 +8. 默认 FP 算术和平方根显式使用 `RNE`,NaN、±0 和异常标志语义有定向测试。 +9. `SelectionPattern` 仅从类型与目标特性合法的候选中确定性选择。 +10. 整数 `DIV/REM` 常量折叠与 RV32M 在负数和溢出边界上一致。 +11. `--extended-isel`、目标 ISA/ABI 和舍入配置真正到达指令选择路径。 +12. 至少一个整数除法/余数 DSL 程序和 f32/f64 求和或点积程序完成端到端编译。 +13. 生成汇编通过 RV32IMFD 交叉汇编器检查;模拟器测试通过能力探测后执行或明确跳过并记录环境。 +14. 固定微基准产生指令数、spill 和预估 cycles JSON;关闭开关时 RV32IM 基线完全不变。 +15. Topic 28 定向测试和现有回归测试全部通过。 +16. 用户文档列出支持操作、类型、ISA/ABI 要求、舍入、`mod` 语义和当前限制。 + +--- + +## 九、实现计划 + +### 阶段 1:基线与整数闭环 + +1. 为当前已知缺口添加失败测试,特别是非法立即数 `div` 和 CLI 开关丢失。 +2. 增加扩展 OpCode、IRBuilder、DSL 和 IR 验证规则。 +3. 实现 `rem/abs/min/max` 的 RV32IM 选择。 +4. 完成整数语义测试。 + +### 阶段 2:f32/f64 IR 与选择 + +1. 修正 IRBuilder 类型传播和显式 f32/f64 常量入口。 +2. 实现 `TargetFeatures/SelectionPattern`,再注册 F/D 算术、访存、比较、分支和返回模式。 +3. 实现 binary32/binary64 常量池和显式 `RNE`。 +4. 输出选择后 MachineInstr 的寄存器类别和舍入测试。 + +### 阶段 3:寄存器分配与 ABI + +1. 增加 `RegisterClass`、FPR 池和 machine semantics 类别约束。 +2. 扩展 naive/greedy/linear 分配器。 +3. 支持 4/8 字节 f32/f64 spill 和混合栈帧。 +4. 实现 `fa0` 返回和 FPR 复制。 + +### 阶段 4:集成与交付 + +1. 贯通 CLI/CompilerConfig/CompilerDriver。 +2. 补齐 `flw/fsw/fld/fsd`、常量池和指令格式化。 +3. 补齐常量折叠、性能计数和模拟器能力探测。 +4. 运行交叉汇编检查和完整回归。 +5. 更新支持列表、类型说明和使用示例。 + +--- + +## 十、风险与缓解 + +| 风险 | 影响 | 缓解方案 | +|---|---|---| +| 只扩展 selector,忘记 regalloc | 生成 `fadd.d s0,s1,s2` 等非法汇编 | 将寄存器类别作为 MachineOperand 的显式属性并写混合类测试 | +| 常量丢失精度 | f32/f64 结果错误 | 使用 binary32/binary64 位模式和常量池,禁止 `int(raw_val)` | +| spill 仍固定 4 字节 | f64 覆盖相邻槽位、错误对齐 | frame layout 记录 size/alignment,对 f32/f64 分别做高压力测试 | +| `mod` 语义混淆 | 负数用例与 Python 不同 | 文档明确定义为 RISC-V `rem` 别名并加负数测试 | +| Python 直接折叠除余 | 负数和溢出边界错误 | 用纯整数实现向 0 截断,与 RV32M 参考函数做属性测试 | +| 舍入模式依赖运行环境 | 同一程序在不同 `frm` 下结果不同 | 默认发射静态 `rne`,定向测试中改变 `frm` 验证不受影响 | +| 候选模式顺序不稳定 | 相同输入生成不同汇编 | 使用成本加显式稳定优先级,对注册顺序做置换测试 | +| 模拟器不支持 F/D 扩展 | 无法在内置环境执行 FP | 能力探测后分离结构测试、交叉汇编检查和 QEMU/Spike 语义测试 | +| 扩展开关被静默忽略 | 用户以为功能已开启 | CLI 集成测试直接检查选择器类型和输出 | +| 同时重构三个分配器引起回归 | 旧 RV32IM 管线受影响 | `RegisterClass.GPR` 作为兼容默认,分阶段提交并每阶段运行全量测试 | + +--- + +## 十一、最终交付物 + +- 扩展后的 IR、DSL 和类型验证实现。 +- 可达、类型正确且基于目标特性选择候选模式的 `ExtendedInstructionSelector`。 +- GPR/FPR 分类寄存器分配与 f32/f64 spill 支持。 +- 可被 RV32IMFD 工具链接受的汇编输出。 +- 整数除法/余数和 f32/f64 求和/点积测试程序。 +- Topic 28 单元、集成、语义和回归测试。 +- 包含固定配置、指令数、spill 和预估 cycles 的可复现 JSON 基准。 +- 支持的操作列表、数据类型、ISA/ABI、舍入语义和已知限制文档。 + +--- + +## 十二、参考依据 + +- [ScratchV PR #26: Extended Instruction Selection](https://github.com/ScratchV-Compiler/ScratchV/pull/26) +- [RISC-V Unprivileged ISA: M Extension](https://docs.riscv.org/reference/isa/v20240411/unpriv/m-st-ext.html) +- [RISC-V Unprivileged ISA: F Extension](https://docs.riscv.org/reference/isa/unpriv/f-st-ext.html) +- [RISC-V Unprivileged ISA: D Extension](https://docs.riscv.org/reference/isa/unpriv/d-st-ext.html) +- [RISC-V ELF 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 b0f607d..a38a075 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,8 @@ - **W9**:实现`float64`比较指令(`feq.d`, `flt.d`等)和条件分支。 - **W10**:编写测试用例:双精度浮点求和、点积等。验证模拟器支持。 - **W11**:为`sqrt`、`min`/`max`等添加指令选择(可使用库调用或硬件指令)。 -- **W12**:更新文档,撰写新算子、新类型的使用指南。 \ No newline at end of file +- **W12**:更新文档,撰写新算子、新类型的使用指南。 + +## 实现文档 + +- [技术设计文档](28-扩展指令选择-设计文档.md) From 9b4b2a3af42b5d2401f2e08774a1a848b5d0fe82 Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Wed, 30 Sep 2026 16:11:06 +0800 Subject: [PATCH 02/13] docs(topic28): consolidate phased implementation plan --- ...76\350\256\241\346\226\207\346\241\243.md" | 679 ++++++++++++++++-- ...07\344\273\244\351\200\211\346\213\251.md" | 4 +- 2 files changed, 619 insertions(+), 64 deletions(-) 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" index 0eab9d5..7a9cff8 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-\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" @@ -1,18 +1,20 @@ # ScratchV 扩展指令选择技术设计文档 -> **文档版本**:v1.1 +> **文档版本**:v1.2 > **编写日期**:2026-09-28 -> **最后修订**:2026-09-28(结合 PR #26 复审,纳入完整 F/D 路径、舍入、候选模式、性能与仿真设计) +> **最后修订**:2026-09-30(将三个阶段的设计与实施规划合并为单一文档) > **课题名称**:课题 28——完善后端指令选择 > **涉及模块**:IR/前端类型、扩展指令选择、机器语义、寄存器分配、ABI/汇编发射、常量折叠、性能估算和 CLI > **目标 ISA**:整数路径 RV32IM;原生单/双精度浮点路径 RV32IMFD -> **文档状态**:待实现 +> **文档状态**:阶段 1、2 已实施;阶段 3 待实施 --- -## 一、功能介绍 +## 阶段 1:总体设计与整数闭环 -### 1.1 背景 +### 一、功能介绍 + +#### 1.1 背景 ScratchV 共享后端将 DSL 或 ONNX 前端产生的 IR 翻译成 RISC-V 汇编: @@ -30,7 +32,7 @@ RISC-V 汇编 现有基础指令选择器支持 `add/sub/mul/div`、访存、分支以及若干神经网络操作,但对整数余数、常用数学操作和原生 f32/f64 浮点缺少完整的端到端支持。仓库已有 `inst_select_ext.py` 原型和部分 `MachineOp`,但前端、IR、寄存器分配、主编译管线和测试尚未贯通。 -### 1.2 设计目标 +#### 1.2 设计目标 - 在共享 IR 中表示 `rem/mod`、`sqrt`、`min/max`、`abs` 等操作。 - 为 `i32` 实现合法的 RV32IM 指令选择。 @@ -42,7 +44,7 @@ RISC-V 汇编 - 建立单元、集成、汇编合法性和语义测试。 - 补充操作和数据类型支持文档。 -### 1.3 非目标 +#### 1.3 非目标 - 不修改 `standalone/onnx_to_riscv_standalone.py` 的 CNN 直接机器码生成路径。 - 不在本课题实现完整的 IEEE 754 软浮点库。 @@ -51,7 +53,7 @@ RISC-V 汇编 - 不将 `transpose/concat` 未经数据布局证明地降低为“复制第一个指针”的 no-op。这两个算子不属于本课题交付范围。 - 不实现完整的 DAG 指令选择或全局调度成本模型;只引入轻量候选模式选择接口。 -### 1.4 当前实现与目标状态 +#### 1.4 当前实现与目标状态 以 2026-09-28 工作区为基线: @@ -71,9 +73,9 @@ RISC-V 汇编 --- -## 二、语义与类型设计 +### 二、语义与类型设计 -### 2.1 新增 IR 操作 +#### 2.1 新增 IR 操作 | IR opcode | 操作数 | 结果 | 允许类型 | 语义 | |---|---:|---:|---|---| @@ -85,7 +87,7 @@ RISC-V 汇编 DSL 同时接受 `rem(a, b)` 和 `mod(a, b)`,二者在第一版均降低为 `OpCode.REM`,遵循 RISC-V `rem` 语义,不声称与 Python `%` 在负数上一致。文档和测试必须显式说明这一点。 -### 2.2 整数除法和余数语义 +#### 2.2 整数除法和余数语义 `DIV`/`REM` 使用 RV32M 有符号语义: @@ -94,7 +96,7 @@ DSL 同时接受 `rem(a, b)` 和 `mod(a, b)`,二者在第一版均降低为 `O - 除数为 0 时不由选择器插入陷阱,保留 ISA 定义的结果。 - `INT32_MIN / -1` 的溢出行为按 ISA 定义测试,选择器不插入额外分支。 -### 2.3 类型传播 +#### 2.3 类型传播 IRBuilder 不再为所有结果默认创建 `f32` 值。二元操作按以下规则生成结果: @@ -108,7 +110,7 @@ dest.dtype = lhs.dtype 不做隐式 `i32↔f32`、`i32↔f64` 或 `f32↔f64` 转换。异构操作数在 IR 验证阶段报错,不由指令选择器根据“任意一个操作数的 dtype”猜测转换或指令。 -### 2.4 DSL 类型入口 +#### 2.4 DSL 类型入口 为了在不引入完整类型声明语法的情况下编写 `f32/f64` 用例,新增显式常量操作: @@ -121,7 +123,7 @@ return c `dtype` 只接受 `i32`、`f32`、`i64`、`f64`;缺省值与现有字面量一致,为 `f32`。当前简单 DSL 的未声明名称仍是 `f32`,不在本课题扩展函数参数声明语法。 -### 2.5 IR 验证签名 +#### 2.5 IR 验证签名 `analysis/ir_verifier.py` 的集中签名表增加: @@ -134,7 +136,7 @@ return c 验证器只报告错误,不修改 dtype,不插入 cast,不将混合类型或不支持的类型组合静默改为其他操作。 -### 2.6 `DIV/REM/MOD` 表示决策 +#### 2.6 `DIV/REM/MOD` 表示决策 保留现有单一 `OpCode.DIV`,由已验证的结果 dtype 决定 `i32 -> div`、`f32 -> fdiv.s`、`f64 -> fdiv.d`。不新增与它重叠的 `IDIV` OpCode。 @@ -142,9 +144,9 @@ IR 只新增 `REM`;DSL `mod(a,b)` 是其语法别名,不新增独立 `MOD` O --- -## 三、指令选择设计 +### 三、指令选择设计 -### 3.1 总体结构 +#### 3.1 总体结构 保留 `InstructionSelector` 作为 RV32IM 默认选择器,`ExtendedInstructionSelector` 继承它并覆写类型相关操作: @@ -163,7 +165,7 @@ ExtendedInstructionSelector 不在 `supported_ops` 中声称一个 `OpCode` 中尚不存在、或者无法从前端构造的操作已受支持。 -### 3.2 目标特性与候选模式 +#### 3.2 目标特性与候选模式 同一 IR 操作可能有多个合法降低方案。例如 `sqrt(f64)` 可以选择 `fsqrt.d`、libm `sqrt` 或未来的软件展开。为避免将选择政策散落在多个 `_select_*` 分支中,引入轻量接口: @@ -190,7 +192,7 @@ class SelectionPattern: 每个 `_select_*` 仍是现有 `getattr` dispatch 的稳定入口,但可从注册表中过滤“类型正确且目标特性满足”的 pattern,再按 `(estimated_cost, priority, name)` 确定性选择。初版不做全局 DAG 成本分析,也不让不合法候选参与比较。 -### 3.3 整数指令降低 +#### 3.3 整数指令降低 | IR | RV32IM 输出 | 备注 | |---|---|---| @@ -212,7 +214,7 @@ xor dst, b, diff # flag ? a : b `max` 交换选择项即可。如临时 vreg 由选择器生成,必须使用统一的 fresh-name 机制,不得固定使用 `_min_tmp1` 而与多条操作或用户名称冲突。 -### 3.4 立即数合法化 +#### 3.4 立即数合法化 RISC-V `add/sub/mul/div/rem` 都是寄存器-寄存器指令。选择器需区分: @@ -225,7 +227,7 @@ RISC-V `add/sub/mul/div/rem` 都是寄存器-寄存器指令。选择器需区 div t1, t0, 2 # 非法:第三个操作数不是寄存器 ``` -### 3.5 `f32/f64` 算术和访存 +#### 3.5 `f32/f64` 算术和访存 | IR | RV32F 输出 | RV32D 输出 | 寄存器类别 | |---|---|---|---| @@ -242,7 +244,7 @@ div t1, t0, 2 # 非法:第三个操作数不是寄存器 `STORE` 的 IR 操作数顺序保持现有约定 `[address, value]`;降低到机器指令时显式映射为 `store value, offset(address)`,不得将 IR 的 operand 0 误当作待写入值。F/D 指令使用同一组 32 个 FPR;D 扩展使 FLEN=64,f32 值遵守 NaN-boxing 规则。 -### 3.6 `f32/f64` 比较与分支 +#### 3.6 `f32/f64` 比较与分支 IR 继续使用现有 `BR_IF` 比较形式,按 dtype 选择 `.s/.d` 后缀,再按 `cmp_op` 生成比较: @@ -257,13 +259,13 @@ IR 继续使用现有 `BR_IF` 比较形式,按 dtype 选择 `.s/.d` 后缀, 浮点比较结果写入 GPR,然后使用 `bnez` 或 `beq` 分支。这是一条同时涉及 FPR 源和 GPR 目标的重要测试路径。 -### 3.7 `sqrt` 策略 +#### 3.7 `sqrt` 策略 具备 F/D 扩展时,f32 默认选择 `fsqrt.s`,f64 默认选择 `fsqrt.d`。不在第一版默认调用 `sqrtf/sqrt` 库函数,因为硬浮点 ABI 参数和返回值使用 `fa0`,而当前原型错误地使用 `a0` 和普通 `mv`。 如后续保留软件库回退,必须另设显式开关,并在调用前后遵守与精度匹配的硬浮点 ABI(`ilp32f/ilp32d`)、保存活跃 caller-saved FPR,不得与硬件路径共用 GPR `MV`。 -### 3.8 浮点舍入、精度和特殊值 +#### 3.8 浮点舍入、精度和特殊值 - 算术和平方根默认使用 `RNE`(round to nearest, ties to even),不依赖调用环境中未知的动态 `frm`。 - `MachineInstr` 增加可选 `rounding_mode`,`AsmEmitter` 为需要舍入的 FP 指令输出静态 `rne`。`fmin/fmax`、比较和符号操作不附加 rm。 @@ -271,7 +273,7 @@ IR 继续使用现有 `BR_IF` 比较形式,按 dtype 选择 `.s/.d` 后缀, - 浮点异常默认设置 `fcsr` 标志而不触发 trap。 - `fmin/fmax` 按 RISC-V F/D `minimumNumber/maximumNumber` 语义处理 NaN,并将 `-0.0` 视为小于 `+0.0`;测试不直接以 Python `min/max` 作为这些边界的参考。 -### 3.9 `f32/f64` 常量 +#### 3.9 `f32/f64` 常量 RISC-V 没有标准 `li.s/li.d`。当前 `LI_D` 不能作为最终汇编输出,也不能先将 `1.5` 转成整数 `1`。 @@ -290,7 +292,7 @@ RISC-V 没有标准 `li.s/li.d`。当前 `LI_D` 不能作为最终汇编输出 选择器按 dtype 和 IEEE 754 位模式联合去重,`+0.0/-0.0`、NaN 位模式和 Inf 不得经过十进制文本往返而改变。常量池标签只在当前编译单元内可见。 -### 3.10 常量折叠语义 +#### 3.10 常量折叠语义 `optimizer/constant_folding.py` 需同步识别 `REM` 及类型正确的 `DIV`。整数折叠不得直接使用 Python `//` 和 `%`,因为它们的负数语义与 RISC-V 不同。对非零除数: @@ -303,9 +305,9 @@ r = a - q * b --- -## 四、寄存器类与溢出设计 +### 四、寄存器类与溢出设计 -### 4.1 寄存器类 +#### 4.1 寄存器类 新增机器寄存器类: @@ -328,7 +330,7 @@ FLT_S/FLT_D: dst=GPR, src1=FPR, src2=FPR 同一 vreg 在不同指令中出现矛盾类别时立即报错,不将位模式在 GPR/FPR 间静默移动。 -### 4.2 物理寄存器池 +#### 4.2 物理寄存器池 | 类别 | 初版可分配池 | 保留 | |---|---|---| @@ -337,7 +339,7 @@ FLT_S/FLT_D: dst=GPR, src1=FPR, src2=FPR 初版仅分配 caller-saved `ft*` 可避免错误使用未在 `abi_frame.py` 保存的 `fs*`。在无外部调用的默认硬件 `sqrt` 路径中可安全使用。 -### 4.3 分配器策略 +#### 4.3 分配器策略 `naive` 和 `greedy` 分配器将分别维护 GPR/FPR 映射与空闲池。`linear` 分配器在支持寄存器类之前,遇到 FPR vreg 必须给出明确错误,不能生成错误汇编;本课题交付前再将线性扫描按类别分配补齐。 @@ -348,7 +350,7 @@ FLT_S/FLT_D: dst=GPR, src1=FPR, src2=FPR - 活跃值穿越 `CALL` 时,caller-saved GPR/FPR 都需溢出或保存。 - 目标值和源值同名时依据 machine semantics 处理,不重复加载。 -### 4.4 溢出槽 +#### 4.4 溢出槽 | 值类型 | 槽大小 | 对齐 | 写回/加载 | |---|---:|---:|---| @@ -358,7 +360,7 @@ FLT_S/FLT_D: dst=GPR, src1=FPR, src2=FPR 分配器不再只返回“槽位数”,而是返回总溢出字节数或带 size/alignment 的 frame layout。`abi_frame.py` 根据真实字节数建立 16 字节对齐栈帧。 -### 4.5 ABI 返回值 +#### 4.5 ABI 返回值 - `i32` 返回值使用 `a0`。 - `f32/f64` 返回值都使用 `fa0`,并由类型决定值的宽度。 @@ -367,9 +369,9 @@ FLT_S/FLT_D: dst=GPR, src1=FPR, src2=FPR --- -## 五、汇编发射与目标配置 +### 五、汇编发射与目标配置 -### 5.1 访存格式 +#### 5.1 访存格式 `AsmEmitter` 将 `FLW/FSW/FLD/FSD` 与 `LW/SW` 一样按内存操作数格式化: @@ -382,7 +384,7 @@ fsd ft0, 8(sp) 不允许通用三操作数路径输出 `fld ft0, t0`。`LOAD/STORE` 的地址操作数始终是 GPR,数据操作数由宽度和类型决定。 -### 5.2 目标 ISA +#### 5.2 目标 ISA `CompilerConfig` 增加: @@ -395,7 +397,7 @@ fp_rounding_mode: str = "rne" 原生 `f32` 要求 F 扩展,原生 `f64` 要求 D 扩展;D 扩展依赖 F。推荐的完整配置为 `rv32imfd + ilp32d`,只用 f32 时可以使用 `rv32imf + ilp32f`。目标 ISA/ABI 与 IR 类型不匹配时,在代码生成前返回明确错误,不静默降级为整数指令。 -### 5.3 编译器集成 +#### 5.3 编译器集成 ```text --extended-isel @@ -411,7 +413,7 @@ ExtendedInstructionSelector(program) 当 `backend=llvm` 时,`--extended-isel` 报错或显式忽略二者只能选一。本设计选择报错,避免用户误以为开关改变了 LLVM 后端。`--dag-isel` 和 `--extended-isel` 互斥。CLI 同时提供 `--target-isa`、`--target-abi` 和 `--fp-rounding-mode`,并在构造 `CompilerConfig` 时保留其值。 -### 5.4 机器码编码器 +#### 5.4 机器码编码器 当前 `riscv_encoder.py` 主要编码 RV32IM。本课题的必交付以“生成可被 GNU/LLVM RISC-V 汇编器接受的 `.s`”为边界;直接编码 F/D 扩展二进制属于后续扩展。 @@ -419,7 +421,7 @@ ExtendedInstructionSelector(program) --- -## 六、修改模块 +### 六、修改模块 | 文件 | 修改类型 | 设计内容 | |---|---|---| @@ -447,9 +449,9 @@ ExtendedInstructionSelector(program) --- -## 七、测试设计 +### 七、测试设计 -### 7.1 IR 和 DSL 测试 +#### 7.1 IR 和 DSL 测试 - `rem/mod/sqrt/min/max/abs` 能从 DSL 构造对应 IR。 - `const(1.5, dtype:f32/f64)` 分别产生 `DataType.FLOAT32/FLOAT64`。 @@ -458,7 +460,7 @@ ExtendedInstructionSelector(program) - ONNX `FLOAT/DOUBLE` 输入保持精度并到达与 DSL 相同的指令选择路径。 - 新操作不影响现有 DSL 诊断位置和错误数上限。 -### 7.2 指令选择单元测试 +#### 7.2 指令选择单元测试 | 用例 | 必须断言 | |---|---| @@ -475,7 +477,7 @@ ExtendedInstructionSelector(program) 仅断言 `supported_ops` 包含名称不算功能测试。 -### 7.3 寄存器分配测试 +#### 7.3 寄存器分配测试 - 整数指令只获得 GPR,F/D 算术指令只获得 FPR。 - `flt.s/flt.d` 的目标是 GPR,两个源是 FPR。 @@ -483,7 +485,7 @@ ExtendedInstructionSelector(program) - 同时施加 GPR/FPR 压力,两类池不互相污染。 - `naive/greedy/linear` 对同一 f32/f64 用例都不泄漏 vreg。 -### 7.4 汇编合法性测试 +#### 7.4 汇编合法性测试 使用可用的 RISC-V GNU 汇编器或 Clang 交叉目标检查: @@ -501,7 +503,7 @@ CI 环境没有交叉工具时可标记为条件跳过,但必须同时有不 - 需要舍入的 FP 指令显式带 `rne`,不需要 rm 的指令不多出操作数。 - 输出中没有非标准 `li.s/li.d`。 -### 7.5 语义测试 +#### 7.5 语义测试 整数用例由现有 RV32 仿真器或独立参考函数验证: @@ -511,7 +513,7 @@ CI 环境没有交叉工具时可标记为条件跳过,但必须同时有不 f32/f64 用例覆盖正常值、次正常值、±0、±Inf、NaN 以及中点舍入,并校验 `RNE`、`fmin/fmax` 的 NaN/有符号零语义。用例优先在支持 RV32F/D 的 QEMU 或 Spike 上运行;测试启动时先做能力探测并记录模拟器、版本、ISA 和 ABI。若 CI 不具备,将语义测试明确标为 skipped,但仍须完成汇编和 IEEE 754 常量位模式验证。不把当前仅支持 RV32IM 的内置编码器/仿真器误报为 FP 语义验证已通过。 -### 7.6 CLI 与回归测试 +#### 7.6 CLI 与回归测试 - `args_to_config()` 保存 `--extended-isel`。 - 开关关闭时仍使用基础选择器。 @@ -520,7 +522,7 @@ f32/f64 用例覆盖正常值、次正常值、±0、±Inf、NaN 以及中点舍 - `--dag-isel --extended-isel` 和 `--backend llvm --extended-isel` 给出可操作错误。 - 现有 backend、parser、IR verifier、regalloc 测试全部通过。 -### 7.7 性能与稳定性指标 +#### 7.7 性能与稳定性指标 性能验收使用固定输入的静态指标,不使用易受 CI 噪声影响的编译 wall-clock 时间: @@ -532,7 +534,7 @@ f32/f64 用例覆盖正常值、次正常值、±0、±Inf、NaN 以及中点舍 --- -## 八、验收标准 +### 八、验收标准 课题完成需同时满足: @@ -555,40 +557,37 @@ f32/f64 用例覆盖正常值、次正常值、±0、±Inf、NaN 以及中点舍 --- -## 九、实现计划 +### 九、阶段划分概览 -### 阶段 1:基线与整数闭环 +#### 阶段 1:基线与整数闭环 1. 为当前已知缺口添加失败测试,特别是非法立即数 `div` 和 CLI 开关丢失。 2. 增加扩展 OpCode、IRBuilder、DSL 和 IR 验证规则。 3. 实现 `rem/abs/min/max` 的 RV32IM 选择。 4. 完成整数语义测试。 -### 阶段 2:f32/f64 IR 与选择 +#### 阶段 2:f32/f64 IR 与选择 1. 修正 IRBuilder 类型传播和显式 f32/f64 常量入口。 2. 实现 `TargetFeatures/SelectionPattern`,再注册 F/D 算术、访存、比较、分支和返回模式。 3. 实现 binary32/binary64 常量池和显式 `RNE`。 -4. 输出选择后 MachineInstr 的寄存器类别和舍入测试。 +4. 在 MachineInstr 和 machine semantics 中建立 `RegisterClass`、值宽度和舍入合同,并输出选择后测试。 -### 阶段 3:寄存器分配与 ABI +#### 阶段 3:后端贯通与课题收尾 -1. 增加 `RegisterClass`、FPR 池和 machine semantics 类别约束。 +1. 以阶段 2 的 `RegisterClass` 和 machine semantics 为合同,增加 FPR 物理池与带宽度的分配结果。 2. 扩展 naive/greedy/linear 分配器。 3. 支持 4/8 字节 f32/f64 spill 和混合栈帧。 4. 实现 `fa0` 返回和 FPR 复制。 +5. 贯通 CLI/CompilerConfig/CompilerDriver,补齐浮点汇编、常量池和内置工具能力边界。 +6. 运行交叉汇编、语义、性能基线和完整回归验证。 +7. 更新支持列表、类型说明、使用示例和验收证据,关闭 Topic 28。 -### 阶段 4:集成与交付 - -1. 贯通 CLI/CompilerConfig/CompilerDriver。 -2. 补齐 `flw/fsw/fld/fsd`、常量池和指令格式化。 -3. 补齐常量折叠、性能计数和模拟器能力探测。 -4. 运行交叉汇编检查和完整回归。 -5. 更新支持列表、类型说明和使用示例。 +阶段 3 合并了原先拆分的寄存器/ABI 与集成交付工作,完成后不再设第 4 阶段。详细实施顺序和验收门见下文“阶段 3:后端贯通与课题收尾”。 --- -## 十、风险与缓解 +### 十、风险与缓解 | 风险 | 影响 | 缓解方案 | |---|---|---| @@ -605,7 +604,7 @@ f32/f64 用例覆盖正常值、次正常值、±0、±Inf、NaN 以及中点舍 --- -## 十一、最终交付物 +### 十一、最终交付物 - 扩展后的 IR、DSL 和类型验证实现。 - 可达、类型正确且基于目标特性选择候选模式的 `ExtendedInstructionSelector`。 @@ -618,10 +617,564 @@ f32/f64 用例覆盖正常值、次正常值、±0、±Inf、NaN 以及中点舍 --- -## 十二、参考依据 +### 十二、参考依据 - [ScratchV PR #26: Extended Instruction Selection](https://github.com/ScratchV-Compiler/ScratchV/pull/26) - [RISC-V Unprivileged ISA: M Extension](https://docs.riscv.org/reference/isa/v20240411/unpriv/m-st-ext.html) - [RISC-V Unprivileged ISA: F Extension](https://docs.riscv.org/reference/isa/unpriv/f-st-ext.html) - [RISC-V Unprivileged ISA: D Extension](https://docs.riscv.org/reference/isa/unpriv/d-st-ext.html) - [RISC-V ELF psABI](https://riscv-non-isa.github.io/riscv-elf-psabi-doc/) + +--- + +## 阶段 2:f32/f64 IR 与类型化指令选择 + +> 状态:已实施 +> 前置阶段:阶段 1 的整数闭环、扩展数值类型合同和 CLI 路由 +> 阶段边界:本阶段完成浮点 IR、目标特性、精确常量和类型化 MachineInstr;FPR 分配、浮点 spill 与 ABI 返回属于阶段 3 + +### 1. 目标 + +阶段 2 要让 `f32/f64` 从 DSL、ONNX 和 IRBuilder 进入统一的强类型 IR,并被确定地选择为正确的 RISC-V F/D 机器操作。 + +完成后必须满足: + +1. `f32/f64` 算术、访存、比较、`sqrt/min/max/abs` 不会落入整数选择路径。 +2. 浮点常量保留 IEEE 754 binary32/binary64 位模式,不经过 `int()` 截断。 +3. 每个机器操作数携带 GPR/FPR 类别,比较结果明确写入 GPR。 +4. 需要舍入模式的操作显式使用 `RNE`;`min/max/abs` 和比较不附加舍入参数。 +5. 目标 ISA、ABI 或类型不匹配时,在代码生成前给出明确错误。 +6. 阶段 3 完成前,含 FPR vreg 的程序在寄存器分配入口明确停止,不能输出使用 `t*` 等 GPR 的伪浮点汇编。 + +### 2. 当前基线与缺口 + +阶段 1 已经具备: + +- `DataType.FLOAT32/FLOAT64`; +- `MIN/MAX/ABS` 的 `i32/f32/f64` IR 合同; +- `FMIN/FMAX/FABS` 的 `.s/.d` 机器操作; +- `--extended-isel` 到扩展选择器的路由; +- 整数 `DIV/REM/MIN/MAX/ABS` 闭环。 + +阶段 2 需要解决以下问题: + +| 缺口 | 当前风险 | 阶段 2 结果 | +|---|---|---| +| `SQRT` 尚未进入统一 IR 合同 | DSL 名称可能存在,但 IR 无法可靠表示 | 增加 `OpCode.SQRT`、builder、DSL、verifier 和选择规则 | +| `f32 ADD/SUB/MUL/DIV/NEG` 可能落到基础整数选择器 | 生成类型错误的机器操作 | 显式选择 `.s` 操作,`f64` 选择 `.d` 操作 | +| 浮点常量通过 `int(raw_val)` 或非标准 `LI_D` | 精度和位模式丢失 | 使用按位模式去重的常量池 | +| MachineOperand 没有寄存器类别 | regalloc 会把浮点 vreg 分到 GPR | 增加 `RegisterClass` 和位置类别约束 | +| 目标 ISA/ABI/舍入配置未进入选择器 | 在不支持 F/D 时仍可能选择浮点指令 | 增加 `TargetFeatures` 并在候选选择前校验 | +| 浮点比较与分支没有统一降低 | FPR 输入和 GPR 布尔结果容易混淆 | 按比较谓词生成 `feq/flt/fle` 和整数分支 | +| 阶段 3 尚未实现 | 可能输出表面像汇编、实际寄存器非法的文本 | 在 regalloc 边界 fail closed | + +### 3. 本阶段范围 + +#### 3.1 IR 和前端 + +- 增加 `OpCode.SQRT`,签名为 `(F) -> F`,`F ∈ {f32, f64}`。 +- `MIN/MAX/ABS` 保持现有 `i32/f32/f64` 合同,`REM` 保持仅 `i32`。 +- `ADD/SUB/MUL/DIV/NEG` 对相同类型的 `f32/f64` 正确传播结果类型。 +- `IRBuilder.load()` 增加显式结果 dtype;`STORE` 保持 `[address, value]` 的 IR 顺序。 +- DSL 支持 `sqrt(x)` 和 `const(..., dtype:f32/f64)`。 +- ONNX 只映射本阶段有明确语义和选择规则的操作,不以默认 `f32` 掩盖未知类型。 +- 不增加隐式数值转换;混合 dtype 必须在 builder 或 verifier 报错。 + +#### 3.2 目标特性与候选模式 + +新增 `scratchv/backend/inst_patterns.py`: + +```python +@dataclass(frozen=True) +class TargetFeatures: + isa: str = "rv32im" + abi: str = "ilp32" + rounding_mode: str = "rne" + libm_available: bool = False + optimize_for: str = "balanced" + +@dataclass(frozen=True) +class SelectionPattern: + name: str + opcode: OpCode + dtypes: tuple[DataType, ...] + required_extensions: frozenset[str] + estimated_cost: int + priority: int +``` + +候选选择顺序固定为 `(estimated_cost, priority, name)`。类型或 ISA 不合法的候选先过滤,不能参与成本比较。 + +目标组合至少支持: + +| 数据类型 | ISA | ABI | +|---|---|---| +| 仅整数 | `rv32im` | `ilp32` | +| `f32` | `rv32imf` | `ilp32f` | +| `f64` | `rv32imfd` | `ilp32d` | + +`D` 依赖 `F`。不合法组合在创建选择器前失败。 + +#### 3.3 类型化机器表示 + +- 增加 `RegisterClass.GPR/FPR`。 +- `MachineOperand.reg/vreg()` 记录 `reg_class`;旧整数调用缺省为 GPR。 +- `MachineOpSemantics` 记录每个 defs/uses 位置要求的寄存器类别。 +- `MachineInstr` 增加可选 `rounding_mode`,只允许 RISC-V 定义的舍入模式。 +- 同一 vreg 被要求为两种寄存器类别时立即报错。 + +必须覆盖的类别规则: + +| 操作 | dst | src1 | src2 | +|---|---|---|---| +| `FADD/FSUB/FMUL/FDIV` | FPR | FPR | FPR | +| `FMIN/FMAX` | FPR | FPR | FPR | +| `FABS/FNEG/FSQRT` | FPR | FPR | — | +| `FLW/FLD` | FPR | GPR 地址 | — | +| `FSW/FSD` | FPR 值 | GPR 地址 | — | +| `FEQ/FLT/FLE` | GPR | FPR | FPR | + +#### 3.4 浮点常量池 + +- 使用 `struct.pack()` 或等价的无损方法取得 binary32/binary64 位模式。 +- 常量池键为 `(dtype, bit_pattern)`,不能用 Python 浮点值本身作为唯一键。 +- `+0.0` 与 `-0.0` 分开;NaN 按位模式保留;Inf 不经过十进制往返。 +- `f32` 条目使用 4 字节对齐和 `.word`,通过 `flw` 加载。 +- `f64` 条目使用 8 字节对齐和 `.quad`,通过 `fld` 加载。 +- 删除最终汇编中的 `LI_D` 路径;RISC-V 没有标准 `li.s/li.d`。 + +#### 3.5 指令选择矩阵 + +| IR | `f32` | `f64` | 舍入 | +|---|---|---|---| +| `ADD` | `fadd.s` | `fadd.d` | `rne` | +| `SUB` | `fsub.s` | `fsub.d` | `rne` | +| `MUL` | `fmul.s` | `fmul.d` | `rne` | +| `DIV` | `fdiv.s` | `fdiv.d` | `rne` | +| `NEG` | `fneg.s` | `fneg.d` | 无 | +| `ABS` | `fabs.s` | `fabs.d` | 无 | +| `MIN/MAX` | `fmin.s/fmax.s` | `fmin.d/fmax.d` | 无 | +| `SQRT` | `fsqrt.s` | `fsqrt.d` | `rne` | +| `LOAD` | `flw` | `fld` | 无 | +| `STORE` | `fsw` | `fsd` | 无 | + +`SQRT` 本阶段只实现硬件 F/D 路径。libm 回退不是 Topic 28 验收的必要条件;阶段 3 完成硬浮点 ABI 后,未配置 F/D 的目标仍应明确拒绝而不静默回退。 + +浮点比较沿用 `BR_IF` 的双操作数形式: + +- `==` → `feq.s/d` +- `!=` → `feq.s/d` 后反转条件 +- `<`/`>` → `flt.s/d`,必要时交换操作数 +- `<=`/`>=` → `fle.s/d`,必要时交换操作数 + +比较结果写入 GPR,再使用整数分支操作。 + +#### 3.6 编译管线安全边界 + +阶段 2 不实现 FPR 分配。为避免产生错误汇编: + +1. 选择器单元测试直接检查未分配的类型化 MachineInstr。 +2. CompilerDriver 在 regalloc 前扫描 FPR vreg。 +3. 若检测到 FPR vreg,返回明确错误:浮点选择已完成,但需要阶段 3 的 FPR 分配支持。 +4. 不允许把 FPR vreg 临时映射到 GPR,也不允许静默退回整数指令。 + +该限制在阶段 3 完成后由真实的分类寄存器分配替换。 + +### 4. 实施顺序 + +#### 里程碑 A:先写失败测试和 IR 合同 + +- 固化 `SQRT`、浮点算术、访存和比较签名。 +- 增加混合 dtype、`i32 sqrt`、未知目标特性的负例。 +- 验收:IR/DSL 测试通过,选择器测试仍按预期失败。 + +#### 里程碑 B:目标特性和机器类型 + +- 实现 `TargetFeatures/SelectionPattern`。 +- 增加 RegisterClass、rounding mode 和 machine semantics 类别约束。 +- 验收:候选排序稳定,非法 ISA/ABI 无候选,类别冲突明确失败。 + +#### 里程碑 C:精确常量池 + +- 实现 binary32/binary64 位模式、去重、标签和数据段输出。 +- 删除 `int(raw_val)` 与最终 `LI_D` 路径。 +- 验收:覆盖 ±0、正常值、次正常值、±Inf 和多种 NaN 位模式。 + +#### 里程碑 D:完成选择矩阵 + +- 实现 f32/f64 算术、访存、比较、分支、`sqrt/min/max/abs`。 +- 修正 STORE 操作数顺序。 +- 验收:每条 MachineInstr 的 opcode、寄存器类别和舍入模式完全匹配矩阵。 + +#### 里程碑 E:集成与阶段出口 + +- 贯通 `target_isa/target_abi/fp_rounding_mode` 配置。 +- 增加 regalloc 前的 FPR fail-closed 检查。 +- 运行全部整数回归,确认关闭扩展开关时汇编不变。 +- 输出阶段 2 变更说明和阶段 3 输入清单。 + +### 5. 文件级修改计划 + +| 文件 | 计划 | +|---|---| +| `scratchv/ir/types.py` | 增加 `SQRT`,整理操作类型合同 | +| `scratchv/ir/builder.py` | 增加 `sqrt`、显式 load dtype,完成浮点类型传播 | +| `scratchv/frontend/dsl_validator.py` | 增加 `sqrt` 签名和 dtype 诊断 | +| `scratchv/frontend/dsl_parser.py` | 将 `sqrt` 和浮点常量构造成统一 IR | +| `scratchv/frontend/onnx_parser.py` | 保留 FLOAT/DOUBLE 并映射本阶段操作 | +| `scratchv/analysis/ir_verifier.py` | 增加 `SQRT` 和浮点比较合同 | +| `scratchv/backend/inst_patterns.py` | 新增目标特性与候选模式 | +| `scratchv/backend/machine_types.py` | RegisterClass、rounding、缺失 F/D 操作 | +| `scratchv/backend/machine_semantics.py` | 操作数位置的 GPR/FPR 约束 | +| `scratchv/backend/inst_select_ext.py` | 完成类型化浮点选择矩阵 | +| `scratchv/backend/asm_emit.py` | 常量池、FP 访存格式和静态舍入输出 | +| `scratchv/compiler.py`、`scratchv/main.py` | 目标配置和阶段 2 安全边界 | +| `tests/test_topic28_*` | IR、选择、常量、配置和回归测试 | + +### 6. 测试与验收 + +#### 6.1 必须有的测试 + +- IR/DSL:合法的 `f32/f64` 和所有混合类型负例。 +- 候选选择:ISA、ABI、dtype、成本和稳定排序。 +- 常量位模式:±0、次正常值、边界正常值、±Inf、quiet/signaling NaN。 +- 指令选择:矩阵中每个 `.s/.d` 操作及寄存器类别。 +- 比较:FPR 输入、GPR 输出和六种谓词。 +- 安全边界:阶段 2 不生成使用 GPR 的浮点汇编。 +- 回归:阶段 1 整数测试和完整 `tests/`。 + +#### 6.2 阶段 2 完成标准 + +- [x] `SQRT` 从 DSL/ONNX/Builder 到 IRVerifier 全部可达。 +- [x] `f32/f64` 操作全部选择正确的 F/D MachineOp。 +- [x] MachineOperand 和 machine semantics 能检查 GPR/FPR 类别。 +- [x] 浮点常量位模式完全保真,输出无 `li.s/li.d`。 +- [x] 需要舍入的操作显式携带 `rne`。 +- [x] 不支持的 ISA/ABI/type 组合在选择前失败。 +- [x] 阶段 3 前的 FPR 程序在 regalloc 入口明确失败,不产生非法汇编。 +- [x] 扩展开关关闭时,现有 RV32IM 行为和测试不变。 +- [x] 定向测试和完整回归全部通过。 + +### 7. 明确不属于阶段 2 + +- FPR 物理寄存器分配。 +- f32/f64 的 4/8 字节 spill、reload 和混合栈帧。 +- `fa0` 返回、FPR caller/callee-saved ABI 保存。 +- libm `sqrtf/sqrt` 调用回退。 +- 内置 RV32 编码器和模拟器的完整 F/D 二进制执行。 +- 浮点性能基准和最终用户支持声明。 + +这些内容全部进入阶段 3;阶段 2 只为它们提供明确、可验证的类型化输入。阶段 3 完成后 Topic 28 直接关闭,不再设第 4 阶段。 + +--- + +## 阶段 3:后端贯通与课题收尾 + +> 状态:待实施 +> +> 前置阶段:阶段 2 已产生类型正确、带 GPR/FPR 类别的 MachineInstr +> +> 阶段边界:本阶段合并原设计中的“寄存器分配与 ABI”和“集成与交付”;完成后 Topic 28 关闭,不再留第 4 阶段 + +### 1. 收尾目标 + +阶段 3 要把阶段 2 的“能选出浮点指令”变成“能生成合法、可汇编、可验证的 RV32IMF/RV32IMFD 程序”。本阶段结束时,课题要求中的整数新算子、`f32/f64` 类型、F/D 指令选择、寄存器分配、spill、ABI、CLI、测试和文档全部闭环。 + +完成后必须满足: + +1. `f32/f64` 虚拟寄存器在 naive、greedy 和 linear 分配路径中只能分配到 FPR。 +2. `f32/f64` spill 分别使用 4/8 字节槽位和 `fsw/flw`、`fsd/fld`,混合栈帧保持 16 字节对齐。 +3. 浮点返回值进入 `fa0`,FPR 复制使用浮点 move,活跃值穿越调用时按 ABI 保存。 +4. 常量池、F/D 访存、比较、静态舍入模式和函数收尾能输出 GNU/LLVM 汇编器接受的语法。 +5. CLI 指定的 selector、ISA、ABI、舍入模式和寄存器分配器真正到达后端。 +6. 至少一个 `f32` 和一个 `f64` 程序完成前端到汇编的端到端编译,并通过外部 RV32IMFD 汇编器检查。 +7. 内置编码器或模拟器不支持 F/D 时明确拒绝,不跳过未知指令。 +8. 完整回归、支持矩阵、用户示例、性能基线和最终变更说明全部完成。 + +### 2. 阶段 2 出口检查 + +阶段 3 不在不稳定的 MachineInstr 合同上开工。必须先确认: + +- `MachineOperand` 能区分 GPR/FPR,同一 vreg 的类别不冲突。 +- FPR vreg 还携带足够的宽度信息,能区分 4 字节 `f32` 和 8 字节 `f64` spill。只有 `RegisterClass.FPR` 不足以分配槽位。 +- `MachineOpSemantics` 完整标注 defs、uses、每个位置的寄存器类别和 call/terminator 属性。 +- `FLW/FSW/FLD/FSD`、F/D 算术与比较所需的 MachineOp 已齐全;ABI 专用的 `FMV_S/FMV_D` 在里程碑 A 补齐。 +- 浮点常量已经是“常量池标签 + GPR 地址 + FPR 加载”,不再出现 `LI_D` 类非标准终态伪指令。 +- 选择后验证器可以在进入 regalloc 前拦截 opcode、寄存器类别、宽度或舍入模式冲突。 + +如果上述合同有一项未成立,先修正阶段 2,不在分配器中猜 dtype。 + +### 3. 核心设计 + +#### 3.1 寄存器池 + +```text +GPR: t0-t6, s0-s11 +FPR: ft0-ft11 +保留: zero/sp/ra/gp/tp, a0-a7, fa0-fa7, fs0-fs11 +``` + +- 三种分配路径分别维护 GPR/FPR 映射、空闲池、活跃集和 spill 决策。 +- `ft0-ft11` 是 caller-saved,有 `CALL` 时,所有穿越调用且仍活跃的 FPR 值必须在调用前 spill、调用后按需 reload。 +- 本课题不把 `fs0-fs11` 加入可分配池,从而不需为可选优化扩大 FPR callee-saved 帧协议。 +- 物理寄存器合法性由统一函数检查;不允许以名称前缀散落判断。 + +#### 3.2 分配器合同 + +naive、greedy 和 linear 最终返回同一种结果,至少包含: + +```python +@dataclass(frozen=True) +class SpillSlot: + 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 +``` + +具体类名可根据仓库风格调整,但不再以单一 `spill_slot_count * 4` 代表混合栈帧。槽位分配按 `align_up(cursor, alignment)` 计算,且同一 vreg 始终复用同一槽位。 + +#### 3.3 spill/reload 规则 + +| 值 | 宽度 | 对齐 | spill | reload | +|---|---:|---:|---|---| +| `i32` / GPR | 4 | 4 | `sw` | `lw` | +| `f32` / FPR | 4 | 4 | `fsw` | `flw` | +| `f64` / FPR | 8 | 8 | `fsd` | `fld` | + +- spill 的值操作数使用其本来的寄存器类,地址基寄存器始终是 GPR `sp`。 +- 重写器不从指令拼写猜宽度,宽度来自 vreg 类型元数据。 +- 超过 I-type 12 位偏移范围的栈帧继续明确失败;扩展大栈帧寻址序列不属于本课题。 + +#### 3.4 混合栈帧 + +`abi_frame.py` 接收完整 frame layout,而不是槽位数。栈帧布局顺序固定为: + +1. `ra` 和已使用的 GPR callee-saved 保存区。 +2. 按对齐排列的 GPR/f32/f64 spill 区。 +3. 尾部 padding,使函数入口和调用点的 `sp` 保持 16 字节对齐。 + +栈帧终结器根据结构化槽位重写偏移,不再依赖“每个槽 4 字节”的全局假设。每个 return 出口都恢复保存寄存器和 `sp`。 + +#### 3.5 返回值与 ABI + +| IR 返回类型 | ABI 寄存器 | 复制方式 | +|---|---|---| +| `i32` | `a0` | `mv` | +| `f32` | `fa0` | `fmv.s` | +| `f64` | `fa0` | `fmv.d` | + +- 返回 lowering 必须查看 IR 结果 dtype,不得固定发射 `mv a0, ...`。 +- `fa0` 不进入普通 FPR 分配池,由返回指令显式使用。 +- `f32` 在 FLEN=64 的 D 目标上依照 psABI/ISA 的 NaN-boxing 规则处理,不用 GPR 复制替代。 +- 本课题必须支持单值返回;多参数函数、多返回值和可变参数不在原始需求中。 + +#### 3.6 汇编发射 + +- `flw/fsw/fld/fsd` 使用 `value, offset(base)` 格式,base 必须是 GPR。 +- `fadd/fsub/fmul/fdiv/fsqrt` 输出选定的静态舍入模式;`fmin/fmax/fabs/fneg/feq/flt/fle/fmv` 不带 rm。 +- 常量池在 `.rodata` 中按 4/8 字节对齐,标签是编译单元局部的,正文以 `la + flw/fld` 访问。 +- 函数 `.size` 必须引用对应的函数名,多函数输出不能拿下一个标签当上一个函数名。 +- 发射后扫描不得出现 `%vreg`、非标准 `li.s/li.d`、FPR 位置上的 `t*/s*/a*` 或 GPR 位置上的 `ft*/fa*`。 + +#### 3.7 工具链和内置后端边界 + +- 可交付的 F/D 边界是生成能被 GNU assembler 或 Clang `--target=riscv32` 接受的 `.s`。 +- `riscv_encoder.py` 和内置 RV32 模拟器未实现 F/D 编码/执行时,对每个 F/D 操作给出包含所需扩展名的明确错误。 +- 浮点语义测试优先使用 Spike 或 QEMU;使用前记录工具、版本、ISA 和 ABI,不可用“环境没有”代替结构和汇编合法性测试。 +- libm `sqrtf/sqrt` 回退不是课题验收的必要条件;RV32IMF/D 使用 `fsqrt.s/d` 已满足需求。未实现的软浮点目标必须拒绝,不列为 Topic 28 遗留项。 + +### 4. 实施里程碑 + +#### 里程碑 A:分配结果与槽位模型 + +1. 引入带 size/alignment/class 的 spill slot 和统一分配结果。 +2. 补齐 ABI 返回所需的 `FMV_S/FMV_D` 和操作数语义。 +3. 让现有 GPR-only 用例通过新结果模型,汇编不变。 +4. 对重复槽位、对齐、偏移越界和元数据缺失添加负例。 + +**出口**:不再有 `spill_slot_count * 4` 这类混合类型错误假设。 + +#### 里程碑 B:三种分配器支持 FPR + +1. naive 按操作数类别选择 scratch GPR/FPR 和 reload/spill opcode。 +2. greedy 分开两类寄存器的所有者、剩余使用和驱逐。 +3. linear 为活跃区间携带寄存器类别和宽度,各类独立扫描和 spill。 +4. 重写后执行“无 vreg + 每个物理寄存器位置类别正确”验证。 + +**出口**:同一混合 GPR/FPR 程序在 `naive|greedy|linear` 下都产生合法物理寄存器。 + +#### 里程碑 C:混合 spill、栈帧和 ABI + +1. 以字节布局接口替换 `spill_slot_count`。 +2. 实现 f32/f64 高压下的 `fsw/flw` 和 `fsd/fld`。 +3. 实现 `a0/fa0` 分类返回、`fmv.s/fmv.d` 和 call 边界活跃 FPR 保存。 +4. 验证多出口函数的每条返回路径均恢复栈帧。 + +**出口**:混合压力程序的槽位不重叠,`sp % 16 == 0`,返回值位置符合 ABI。 + +#### 里程碑 D:汇编、常量池和编译管线 + +1. 完成 F/D 语法、rm、访存格式、常量池和函数指令的发射。 +2. 删除阶段 2 的 FPR regalloc fail-closed 临时拦截,改为真实分配。 +3. 贯通 `CompilerConfig` 和 CLI,合法配置生成汇编,非法 ISA/ABI/舍入组合在代码生成前失败。 +4. 让后续 peephole、scheduler、instruction counter 和 beautifier 不损坏 F/D 指令或 `.rodata`。 + +**出口**:DSL/ONNX 的 f32/f64 用例通过主编译管线,输出无 vreg 的 `.s`。 + +#### 里程碑 E:工具链、语义和性能验证 + +1. 使用 `clang --target=riscv32 -march=rv32imf -mabi=ilp32f` 检查 f32 输出。 +2. 使用 `clang --target=riscv32 -march=rv32imfd -mabi=ilp32d` 检查 f64 输出。 +3. 如环境有 Spike/QEMU,运行正常值、±0、±Inf、NaN、次正常值和舍入边界的语义测试;否则记录条件跳过原因。 +4. 固化指令数、FP 指令数、spill load/store、spill bytes 和预估 cycles JSON。 +5. 比较扩展开关关闭的 RV32IM 基线,不允许无关程序的汇编或静态指标退化。 + +**出口**:汇编合法性是必测项,语义执行按环境能力执行或明确跳过,基线数据可重现。 + +#### 里程碑 F:文档和课题关闭 + +1. 新增最终阶段变更说明,按文件解释实际改动和设计原因。 +2. 在 Topic 28 主文档中发布支持矩阵、ISA/ABI 组合、DSL 示例、`mod` 语义、舍入和已知限制。 +3. 将技术设计文档状态改为“已实施”,逐条对照验收标准记录证据。 +4. 运行 Topic 28 定向测试、完整 `tests/` 和项目 L2 harness,记录命令、结果、跳过项与工具版本。 + +**出口**:下方完成清单全部勾选,Topic 28 标记完成。 + +### 5. 文件级修改计划 + +| 文件 | 收尾工作 | +|---|---| +| `scratchv/backend/machine_types.py` | FPR 物理寄存器、`FMV_D`、宽度/类型元数据和分配结果类型 | +| `scratchv/backend/machine_semantics.py` | 补齐所有 F/D defs/uses、类别、调用和返回语义 | +| `scratchv/backend/register_alloc.py` | naive/greedy 分类分配、混合 spill 和 call 边界 | +| `scratchv/backend/regalloc_linear.py` | 带 class/width 的活跃区间、分类线性扫描和 spill | +| `scratchv/backend/regalloc_rewrite.py` | 按槽位元数据选择 load/store,保持操作数类别 | +| `scratchv/backend/abi_frame.py` | 混合宽度 frame layout、16 字节对齐和多出口恢复 | +| `scratchv/backend/asm_emit.py` | FP 访存、rm、复制、常量池和函数尺寸发射 | +| `scratchv/backend/riscv_encoder.py` | 对尚未支持的 F/D 编码给出确定错误 | +| `scratchv/backend/cycle_estimator.py` | F/D 类别、延迟和统计字段 | +| `scratchv/compiler.py` | 统一分配结果、frame layout、返回 lowering 和端到端管线 | +| `scratchv/main.py` | ISA/ABI/rm/regalloc 配置和错误信息 | +| `tests/test_topic28_*` | DSL/ONNX、三种分配器、spill、ABI、CLI 和端到端用例 | +| `tests/test_regalloc_*` | GPR 兼容回归和混合压力测试 | +| `tests/test_abi_frame.py` | f32/f64 槽位、对齐、多 return 和越界负例 | +| `tests/test_riscv_encoder_validation.py` | F/D 能力边界的拒绝测试 | +| Topic 28 文档 | 支持矩阵、示例、验收证据和最终变更说明 | + +`regalloc_linear_v1_5.py` 只在运行时仍有调用者时同步新合同;若已无生产入口,则明确弃用并添加调用点搜索证据,不维护两套平行实现。 + +### 6. 测试矩阵 + +#### 6.1 分配和 spill + +| 用例 | naive | greedy | linear | +|---|---:|---:|---:| +| 纯 GPR 回归 | 必测 | 必测 | 必测 | +| 纯 f32,无 spill | 必测 | 必测 | 必测 | +| 纯 f64,无 spill | 必测 | 必测 | 必测 | +| 混合 GPR/FPR | 必测 | 必测 | 必测 | +| 超过 12 个活跃 f32 | 必测 | 必测 | 必测 | +| 超过 12 个活跃 f64 | 必测 | 必测 | 必测 | +| FPR 值穿越 CALL | 必测 | 必测 | 必测 | + +每个用例同时断言:无 vreg,类别正确,槽位大小/对齐正确,没有两个同时活跃值覆盖同一槽位。 + +#### 6.2 汇编结构 + +- f32/f64 算术、`sqrt/min/max/abs`、load/store 和六种比较谓词。 +- `fa0` 返回和 `a0` 整数返回。 +- 带和不带 rm 的指令各自严格匹配参数数量。 +- `.rodata` 常量的 ±0、次正常值、±Inf 和不同 NaN 位模式。 +- 多函数、多 return、无 spill 和有 spill 栈帧。 +- 输出扫描:无 vreg、无 `li.s/li.d`、无寄存器类别泄漏。 + +#### 6.3 端到端程序 + +| 程序 | 类型 | 覆盖点 | +|---|---|---| +| 有符号除法与余数 | i32 | `div/rem`、负数、返回 `a0` | +| 求和 | f32 | 常量池、`fadd.s`、RNE、`fa0` | +| 点积 | f64 | `fmul.d/fadd.d`、load/store、spill | +| 数值工具组合 | f32/f64 | `sqrt/min/max/abs`、比较和分支 | +| ONNX 小图 | f32/f64 | ONNX dtype 保留到最终汇编 | + +每个程序至少运行一次主 CLI 路径;直接调用 selector 的单元测试不替代端到端验收。 + +#### 6.4 负例 + +- `f32` + `ilp32` 或不含 F 的 ISA。 +- `f64` + `ilp32f` 或不含 D 的 ISA。 +- D 存在但 F 缺失的非法 ISA。 +- 混合 dtype、同一 vreg 的 GPR/FPR 冲突和 FPR 宽度缺失。 +- 非法舍入模式和不需 rm 的指令携带 rm。 +- 栈偏移越界、未分配 vreg 泄漏、内置编码器接收 F/D 指令。 + +### 7. 验证命令 + +实施时在仓库环境中确认具体命令可用性,验收至少包含: + +```bash +pytest -q tests/test_topic28_* tests/test_inst_select_ext.py \ + tests/test_regalloc_*.py tests/test_abi_frame.py \ + tests/test_riscv_encoder_validation.py + +pytest -q tests/ + +python .claude/harness/verify/run.py --level L2 + +clang --target=riscv32 -march=rv32imf -mabi=ilp32f \ + -c topic28_f32.s -o topic28_f32.o + +clang --target=riscv32 -march=rv32imfd -mabi=ilp32d \ + -c topic28_f64.s -o topic28_f64.o +``` + +如 harness 或交叉工具链在当前环境中缺失,最终变更说明要记录精确命令、缺失路径/可执行文件和替代验证结果,不得写成“全部通过”。结构测试可以继续,但“交叉汇编通过”勾选项必须等到有工具的环境产生真实证据后才能勾选。 + +### 8. 完成清单 + +#### 功能 + +- [ ] `i32 div/rem/min/max/abs` 端到端可用。 +- [ ] `f32/f64 add/sub/mul/div/neg/abs/min/max/sqrt` 端到端可用。 +- [ ] `f32/f64 load/store` 和六种比较谓词端到端可用。 +- [ ] DSL 与课题范围内 ONNX 映射保留 `f32/f64` dtype。 + +#### 后端正确性 + +- [ ] naive、greedy、linear 全部支持 GPR/FPR。 +- [ ] f32/f64 spill 的 opcode、大小、对齐和偏移正确。 +- [ ] 混合栈帧 16 字节对齐,所有 return 路径完整恢复。 +- [ ] `a0/fa0`、caller-saved FPR 和 FPR move 符合 ABI。 +- [ ] 常量池保留 IEEE 754 位模式,汇编无非标准 FP 立即数伪指令。 +- [ ] 输出无 vreg、无寄存器类别错误、无静默跳过的未知 MachineOp。 + +#### 集成和交付 + +- [ ] CLI/CompilerConfig/CompilerDriver 的 selector、ISA、ABI、rm 和 regalloc 配置一致。 +- [ ] f32 和 f64 输出均通过 RV32 交叉汇编器。 +- [ ] 浮点语义测试已执行,或按能力探测明确记录跳过。 +- [ ] Topic 28 定向测试、完整回归和 L2 验证已记录。 +- [ ] RV32IM 旧路径的汇编和静态基线无退化。 +- [ ] 支持矩阵、数据类型、ISA/ABI、舍入、`mod` 语义、示例和限制文档已发布。 +- [ ] 技术设计的 16 条验收标准均有实现位置和测试证据。 + +### 9. 明确不延后的项目 + +以下内容都在阶段 3 内完成,不再推到“后续阶段”: + +- FPR 分配和分类活跃分析。 +- f32/f64 spill/reload 和混合栈帧。 +- `fa0` 返回、FPR 复制和 call 边界保存。 +- 浮点汇编格式、常量池和静态舍入。 +- CLI 集成、交叉汇编、完整回归、性能基线和用户文档。 + +课题外的内置 F/D 二进制编码、完整软浮点库、向量化、多返回值和大栈帧寻址可以作为新课题,但它们不影响 Topic 28 按原始要求关闭。 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 a38a075..a344ecf 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" @@ -33,4 +33,6 @@ ## 实现文档 -- [技术设计文档](28-扩展指令选择-设计文档.md) +- [技术设计文档(含阶段 1/2/3 设计与实施规划)](28-扩展指令选择-设计文档.md) +- [阶段 1 变更说明:整数闭环与类型合同](28-扩展指令选择-阶段1变更说明.md) +- [阶段 2 变更说明:f32/f64 IR 与类型化指令选择](28-扩展指令选择-阶段2变更说明.md) From 3a681f40e277ffdef9c142a7a3b89a115b39aeba Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Sun, 11 Oct 2026 22:16:27 +0800 Subject: [PATCH 03/13] docs(topic28): specify API-level instruction selection design --- ...76\350\256\241\346\226\207\346\241\243.md" | 1392 +++++------------ ...07\344\273\244\351\200\211\346\213\251.md" | 4 +- 2 files changed, 383 insertions(+), 1013 deletions(-) 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" index 7a9cff8..44a925b 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-\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" @@ -1,1180 +1,552 @@ -# ScratchV 扩展指令选择技术设计文档 +# 课题 28:扩展指令选择技术设计 -> **文档版本**:v1.2 -> **编写日期**:2026-09-28 -> **最后修订**:2026-09-30(将三个阶段的设计与实施规划合并为单一文档) -> **课题名称**:课题 28——完善后端指令选择 -> **涉及模块**:IR/前端类型、扩展指令选择、机器语义、寄存器分配、ABI/汇编发射、常量折叠、性能估算和 CLI -> **目标 ISA**:整数路径 RV32IM;原生单/双精度浮点路径 RV32IMFD -> **文档状态**:阶段 1、2 已实施;阶段 3 待实施 +扩展指令选择将带类型的 IR 转换为 RISC-V 机器指令。方案以数值类型为主线,连接前端解析、操作语义、机器表示、寄存器分配、内存布局和函数接口,使整数、单精度和双精度运算使用各自正确的数据通路。 ---- +## 设计目标与范围 -## 阶段 1:总体设计与整数闭环 +设计面向 RV32 标量计算,覆盖 i32 的除法与余数,f32/f64 的四则算术与平方根,以及这些类型的最小值、最大值、取负、绝对值、访存、比较和条件分支。 -### 一、功能介绍 +整数运算采用 I/M 扩展,单精度和双精度浮点采用 F/D 扩展。函数接口以单个标量参数和单值返回为基础。软件浮点、向量化、通用张量布局和复杂参数传递属于独立的设计范围。 -#### 1.1 背景 +整体实现沿用“IR → 指令选择 → 寄存器分配 → 汇编表达”的编译流程。整数规则作为基础路径,浮点规则按明确的类型和目标能力接入;类型、值宽度和存储信息在各环节之间显式传递。 -ScratchV 共享后端将 DSL 或 ONNX 前端产生的 IR 翻译成 RISC-V 汇编: +## 总体架构 -```text -DSL / ONNX - ↓ -Program / Function / BasicBlock / Instruction - ↓ 指令选择 -MachineInstr(仍使用虚拟寄存器) - ↓ 寄存器分配 -使用 t0/s0/f0 等物理寄存器的 MachineInstr - ↓ 汇编发射 -RISC-V 汇编 +```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 ``` -现有基础指令选择器支持 `add/sub/mul/div`、访存、分支以及若干神经网络操作,但对整数余数、常用数学操作和原生 f32/f64 浮点缺少完整的端到端支持。仓库已有 `inst_select_ext.py` 原型和部分 `MachineOp`,但前端、IR、寄存器分配、主编译管线和测试尚未贯通。 - -#### 1.2 设计目标 - -- 在共享 IR 中表示 `rem/mod`、`sqrt`、`min/max`、`abs` 等操作。 -- 为 `i32` 实现合法的 RV32IM 指令选择。 -- 让 `f32/f64` 从 IR 类型传播到 F/D 扩展指令、浮点寄存器、4/8 字节访存和 ABI 返回值。 -- 明确浮点精度、舍入模式、NaN/零和异常标志语义。 -- 为“硬件指令 / 伪指令序列 / 库调用”多候选降低预留目标特性和成本选择接口。 -- 确保每条输出指令的操作数类型和寄存器类别合法,不只检查“出现了某个 opcode”。 -- 通过 `--extended-isel` 显式启用新能力,开关默认关闭,保持旧路径兼容。 -- 建立单元、集成、汇编合法性和语义测试。 -- 补充操作和数据类型支持文档。 - -#### 1.3 非目标 +各模块之间交换结构化信息。指令选择产生机器操作和操作数约束,分配器提供物理寄存器映射及溢出位置,栈布局计算函数内各存储位置的最终偏移,汇编层负责表达已确定的指令与地址。 -- 不修改 `standalone/onnx_to_riscv_standalone.py` 的 CNN 直接机器码生成路径。 -- 不在本课题实现完整的 IEEE 754 软浮点库。 -- 不实现浮点向量化或 RISC-V V 扩展。 -- 不对 LLVM 后端重复实现指令选择;LLVM 路径继续由 LLVM 完成目标降低。 -- 不将 `transpose/concat` 未经数据布局证明地降低为“复制第一个指针”的 no-op。这两个算子不属于本课题交付范围。 -- 不实现完整的 DAG 指令选择或全局调度成本模型;只引入轻量候选模式选择接口。 +下面用 Python 类型标注表达接口设计,签名中的省略号代表由相应模块实现的行为。`Sequence` 表示有序输入,`Mapping` 表示只读查询;类型、目标描述和跨阶段结果作为不可变数据传递。接口名称围绕模块职责组织,内部算法可以在保持这些合同的前提下调整。 -#### 1.4 当前实现与目标状态 - -以 2026-09-28 工作区为基线: - -| 能力 | 当前状态 | 目标状态 | +| 模块 | 实现职责 | 向后续环节提供的信息 | |---|---|---| -| `DataType.FLOAT32/FLOAT64` | 已定义,ONNX `FLOAT/DOUBLE` 已映射 | 保留并补齐构造、传播和验证 | -| 基础 `div` | 已选择为 `DIV`,但常量操作数可能直接落到寄存器位置 | `i32` 源始终是 GPR,`f32/f64` 源始终是 FPR | -| 扩展 IR opcode | `OpCode` 中缺失 | 新增 `REM/SQRT/MIN/MAX/ABS` | -| 扩展选择器 | `inst_select_ext.py` 有原型 | 与真实 IR、寄存器类和主管线贯通 | -| F/D 扩展 MachineOp | 部分已列出 | 补齐 f32/f64 指令,删除非标准 `li.d`,增加舍入信息 | -| 浮点寄存器 | 未实现,所有 vreg 均分配到 GPR | `f32/f64` vreg 均分配到 FPR | -| `flw/fsw/fld/fsd` 输出 | FP 路径未完整贯通 | 输出标准 `offset(base)` 格式 | -| `--extended-isel` | CLI 已声明,但 `CompilerConfig` 未保存 | 完整贯通 CLI 到 `CompilerDriver` | -| 测试 | 13 个用例主要检查对象创建和旧操作 | 直接覆盖扩展 IR、汇编和语义 | - -> 课题描述中的 `type_system.py` 在当前仓库中不存在,实际类型系统文件为 `scratchv/ir/types.py`。 - ---- - -### 二、语义与类型设计 - -#### 2.1 新增 IR 操作 - -| IR opcode | 操作数 | 结果 | 允许类型 | 语义 | -|---|---:|---:|---|---| -| `REM` | 2 | 1 | `i32` | RISC-V 有符号余数 | -| `SQRT` | 1 | 1 | `f32` 或 `f64` | IEEE 754 单/双精度平方根 | -| `MIN` | 2 | 1 | 相同的 `i32`、`f32` 或 `f64` | 返回较小值 | -| `MAX` | 2 | 1 | 相同的 `i32`、`f32` 或 `f64` | 返回较大值 | -| `ABS` | 1 | 1 | `i32`、`f32` 或 `f64` | 绝对值 | - -DSL 同时接受 `rem(a, b)` 和 `mod(a, b)`,二者在第一版均降低为 `OpCode.REM`,遵循 RISC-V `rem` 语义,不声称与 Python `%` 在负数上一致。文档和测试必须显式说明这一点。 - -#### 2.2 整数除法和余数语义 +| 前端与类型系统 | 解析类型,归一化算子,构造合法 IR | 类型明确的值、操作和常量编码 | +| 目标能力描述 | 统一表达 ISA、ABI 和可用精度 | 操作可用性、寄存器类别与调用约束 | +| 指令选择 | 根据操作和类型选择指令或等价序列 | 机器指令、虚拟寄存器、常量引用 | +| 机器表示 | 描述指令的定义、使用及操作数要求 | 寄存器类别、值宽度、控制流和副作用 | +| 函数接口 | 接入参数、返回及调用位置约束 | 物理位置搬运、固定约束和调用覆盖集合 | +| 寄存器分配 | 安排物理寄存器,保存与恢复溢出值 | 寄存器映射、存储槽和寄存器保存需求 | +| 栈布局 | 布局局部数据和保存区,处理入口与出口 | 具体偏移、栈调整和寄存器保存/恢复 | +| 汇编表达 | 输出指令、函数边界及常量数据 | 符合目标语法的汇编 | -`DIV`/`REM` 使用 RV32M 有符号语义: +## 类型系统与前端处理 -- 商向 0 截断。 -- 非零余数与被除数同号。 -- 除数为 0 时不由选择器插入陷阱,保留 ISA 定义的结果。 -- `INT32_MIN / -1` 的溢出行为按 ISA 定义测试,选择器不插入额外分支。 +### 类型描述 -#### 2.3 类型传播 +类型描述包含数值类别、位宽、存储大小和自然对齐。位宽用于决定运算精度,存储大小和对齐用于安排常量、局部数据和溢出位置。 -IRBuilder 不再为所有结果默认创建 `f32` 值。二元操作按以下规则生成结果: +| 类型 | 数值表示 | 位宽 | 存储大小/对齐 | 数据寄存器类别 | +|---|---|---|---|---| +| i32 | 有符号整数 | 32 位 | 4/4 字节 | 通用寄存器 GPR | +| f32 | IEEE 754 binary32 | 32 位 | 4/4 字节 | 浮点寄存器 FPR | +| f64 | IEEE 754 binary64 | 64 位 | 8/8 字节 | 浮点寄存器 FPR | -```text -require lhs.dtype == rhs.dtype -require dtype is supported by opcode -dest.dtype = lhs.dtype -``` - -一元操作的结果类型继承输入。`LOAD` 的结果类型由指针所指元素类型决定;在当前 IR 没有独立指针类型的情况下,`builder.load(ptr, dtype=...)` 需允许显式指定。 - -不做隐式 `i32↔f32`、`i32↔f64` 或 `f32↔f64` 转换。异构操作数在 IR 验证阶段报错,不由指令选择器根据“任意一个操作数的 dtype”猜测转换或指令。 +地址采用 RV32 通用寄存器表示。访存操作显式关联元素类型,局部存储声明记录元素类型与数量;地址的机器表示和所指数据的精度分别处理。这样,指向 f64 数据的地址使用 GPR,而读写数据使用 FPR。 -#### 2.4 DSL 类型入口 - -为了在不引入完整类型声明语法的情况下编写 `f32/f64` 用例,新增显式常量操作: - -```text -a = const(1.5, dtype:f64) -b = const(2.0, dtype:f64) -c = add(a, b) -return c -``` - -`dtype` 只接受 `i32`、`f32`、`i64`、`f64`;缺省值与现有字面量一致,为 `f32`。当前简单 DSL 的未声明名称仍是 `f32`,不在本课题扩展函数参数声明语法。 - -#### 2.5 IR 验证签名 - -`analysis/ir_verifier.py` 的集中签名表增加: - -| 指令 | 签名 | -|---|---| -| `REM` | `(i32, i32) -> i32` | -| `SQRT` | `(F) -> F`,`F ∈ {f32, f64}` | -| `MIN/MAX` | `(T, T) -> T`,`T ∈ {i32, f32, f64}` | -| `ABS` | `(T) -> T`,`T ∈ {i32, f32, f64}` | - -验证器只报告错误,不修改 dtype,不插入 cast,不将混合类型或不支持的类型组合静默改为其他操作。 - -#### 2.6 `DIV/REM/MOD` 表示决策 - -保留现有单一 `OpCode.DIV`,由已验证的结果 dtype 决定 `i32 -> div`、`f32 -> fdiv.s`、`f64 -> fdiv.d`。不新增与它重叠的 `IDIV` OpCode。 - -IR 只新增 `REM`;DSL `mod(a,b)` 是其语法别名,不新增独立 `MOD` OpCode。这样保持旧 `DIV` IR 兼容,同时避免两个语义完全相同的操作码进入所有下游 pass。 - ---- - -### 三、指令选择设计 - -#### 3.1 总体结构 - -保留 `InstructionSelector` 作为 RV32IM 默认选择器,`ExtendedInstructionSelector` 继承它并覆写类型相关操作: - -```text -InstructionSelector - ├─ 现有 RV32IM 操作 - └─ 操作数合法化辅助方法 - ↑ -ExtendedInstructionSelector - ├─ i32 rem/min/max/abs - ├─ f32/f64 load/store/arithmetic - ├─ f32/f64 compare/min/max/abs/sqrt - ├─ TargetFeatures + SelectionPattern 候选选择 - └─ 按 dtype 选择 GPR/FPR 操作数 -``` - -不在 `supported_ops` 中声称一个 `OpCode` 中尚不存在、或者无法从前端构造的操作已受支持。 - -#### 3.2 目标特性与候选模式 - -同一 IR 操作可能有多个合法降低方案。例如 `sqrt(f64)` 可以选择 `fsqrt.d`、libm `sqrt` 或未来的软件展开。为避免将选择政策散落在多个 `_select_*` 分支中,引入轻量接口: +类型系统提供统一查询和常量编码接口: ```python @dataclass(frozen=True) -class TargetFeatures: - isa: str = "rv32im" - abi: str = "ilp32" - rounding_mode: str = "rne" - libm_available: bool = False - optimize_for: str = "balanced" # size / speed / balanced +class TypeInfo: + dtype: DataType + bit_width: int + size_bytes: int + alignment_bytes: int @dataclass(frozen=True) -class SelectionPattern: - name: str - opcode: OpCode - dtypes: tuple[DataType, ...] - required_extensions: frozenset[str] - estimated_cost: int - predicate: PatternPredicate - emit: PatternEmitter - priority: int = 0 +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: ... ``` -每个 `_select_*` 仍是现有 `getattr` dispatch 的稳定入口,但可从注册表中过滤“类型正确且目标特性满足”的 pattern,再按 `(estimated_cost, priority, name)` 确定性选择。初版不做全局 DAG 成本分析,也不让不合法候选参与比较。 +`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 构造规则 -#### 3.3 整数指令降低 +IR 构造集中使用操作签名来确定操作数和结果类型,前端仅负责将源表达式转换为相应操作。 -| IR | RV32IM 输出 | 备注 | +| 操作 | 操作数要求 | 结果规则 | |---|---|---| -| `DIV i32` | `div rd, rs1, rs2` | `rs1/rs2` 必须是 GPR | -| `REM i32` | `rem rd, rs1, rs2` | `mod` DSL 别名也到该路径 | -| `ABS i32` | `srai/xor/sub` | 保留 `INT32_MIN` 的二进制补码结果 | -| `MIN i32` | `slt` + 掩码选择 | 不依赖 Zbb `min` | -| `MAX i32` | `slt` + 掩码选择 | 不依赖非 RV32IM 指令 | - -`min/max` 使用正确的全位掩码,不能将 `slt` 产生的 0/1 直接与差值做 `and`。一种无分支的 `min` 方案为: - -```asm -slt flag, a, b # flag = a < b -sub mask, x0, flag # mask = 0x00000000 or 0xffffffff -xor diff, a, b -and diff, diff, mask -xor dst, b, diff # flag ? a : b -``` +| 加、减、乘、除、min、max | 两个同类型数值 | 继承操作数类型 | +| neg、abs | 一个数值 | 继承输入类型 | +| rem | 两个 i32 | i32 | +| sqrt | 一个 f32 或 f64 | 继承输入精度 | +| load | 地址及元素类型 | 元素类型 | +| store | 地址、元素类型及同类型数据 | 无数值结果 | +| 比较 | 两个同类型数值 | 逻辑结果,供条件分支使用 | -`max` 交换选择项即可。如临时 vreg 由选择器生成,必须使用统一的 fresh-name 机制,不得固定使用 `_min_tmp1` 而与多条操作或用户名称冲突。 +操作构造先确认类型签名,再创建结果值。不同类型不做隐式转换;改变精度需要明确的转换语义。函数参数和返回值也使用相同的类型描述。 -#### 3.4 立即数合法化 +浮点常量除类型外,还保留其 IEEE 编码。文本常量按声明精度编码,模型中的浮点常量保留源编码;数值展示和机器数据表达分别使用各自的信息。 -RISC-V `add/sub/mul/div/rem` 都是寄存器-寄存器指令。选择器需区分: +### IR 构造 API -- 允许立即数的机器操作,如 `ADDI`、`SRAI`、`LI`。 -- 只允许寄存器的机器操作,如 `ADD`、`MUL`、`DIV`、`REM`、`FADD_S`、`FADD_D`。 +数值签名检查与指令构造分开:签名函数用于推导结果,构造器负责创建值、追加指令和维护函数/基本块归属。内存和控制流通过专门入口表达。 -对后一类操作,常量必须先由它自己的 `LOAD_CONST` 定义产生虚拟寄存器,或由选择器显式物化到 fresh vreg。不得输出: +```python +def infer_numeric_result( + op: OpCode, inputs: Sequence[DataType] +) -> DataType: ... -```asm -div t1, t0, 2 # 非法:第三个操作数不是寄存器 +@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: ... ``` -#### 3.5 `f32/f64` 算术和访存 - -| IR | RV32F 输出 | RV32D 输出 | 寄存器类别 | -|---|---|---|---| -| `LOAD` | `flw fd, offset(rs1)` | `fld fd, offset(rs1)` | `fd:FPR`, `rs1:GPR` | -| `STORE` | `fsw fs2, offset(rs1)` | `fsd fs2, offset(rs1)` | `fs2:FPR`, `rs1:GPR` | -| `ADD` | `fadd.s fd, fs1, fs2` | `fadd.d fd, fs1, fs2` | FPR/FPR/FPR | -| `SUB` | `fsub.s fd, fs1, fs2` | `fsub.d fd, fs1, fs2` | FPR/FPR/FPR | -| `MUL` | `fmul.s fd, fs1, fs2` | `fmul.d fd, fs1, fs2` | FPR/FPR/FPR | -| `DIV` | `fdiv.s fd, fs1, fs2` | `fdiv.d fd, fs1, fs2` | FPR/FPR/FPR | -| `NEG` | `fneg.s fd, fs1` | `fneg.d fd, fs1` | FPR/FPR | -| `ABS` | `fabs.s fd, fs1` | `fabs.d fd, fs1` | FPR/FPR | -| `MIN/MAX` | `fmin.s/fmax.s` | `fmin.d/fmax.d` | FPR/FPR/FPR | -| `SQRT` | `fsqrt.s fd, fs1` | `fsqrt.d fd, fs1` | FPR/FPR | - -`STORE` 的 IR 操作数顺序保持现有约定 `[address, value]`;降低到机器指令时显式映射为 `store value, offset(address)`,不得将 IR 的 operand 0 误当作待写入值。F/D 指令使用同一组 32 个 FPR;D 扩展使 FLEN=64,f32 值遵守 NaN-boxing 规则。 - -#### 3.6 `f32/f64` 比较与分支 - -IR 继续使用现有 `BR_IF` 比较形式,按 dtype 选择 `.s/.d` 后缀,再按 `cmp_op` 生成比较: - -| `cmp_op` | 机器序列 | -|---|---| -| `==` | `feq.s gpr, fa, fb` / `feq.d gpr, fa, fb` | -| `!=` | `feq.s gpr, fa, fb` / `feq.d gpr, fa, fb`,再反转条件 | -| `<` | `flt.s gpr, fa, fb` / `flt.d gpr, fa, fb` | -| `>` | `flt.s gpr, fb, fa` / `flt.d gpr, fb, fa` | -| `<=` | `fle.s gpr, fa, fb` / `fle.d gpr, fa, fb` | -| `>=` | `fle.s gpr, fb, fa` / `fle.d gpr, fb, fa` | +`DataType` 使用 INT32/FLOAT32/FLOAT64 三个枚举成员,`OpCode` 表达归一化操作,`ComparePredicate` 包含 EQ/NE/LT/LE/GT/GE 六种关系。`Value` 记录名称、数值类型和可选的 `ScalarConstant`;`Instruction` 记录操作、输入、结果及来源位置。`Function` 保存参数、返回类型、基本块和局部存储声明,`Program` 保存函数与全局值。 -浮点比较结果写入 GPR,然后使用 `bnez` 或 `beq` 分支。这是一条同时涉及 FPR 源和 GPR 目标的重要测试路径。 +`binary` 和 `unary` 先调用 `infer_numeric_result`,检查元数、同类型要求和操作允许的类型,再构造结果;add/div/sqrt 等便捷方法委托这两个入口。类型不匹配时不创建结果,也不追加指令。`constant` 将编码与声明类型绑定,不能只保存用于展示的 Python 数值。 -#### 3.7 `sqrt` 策略 +`alloca` 要求元素数量为正整数,在当前函数登记局部对象,生成标记为地址的 i32 基址,并以 `AddressRef` 携带元素类型;不新增通用指针类型。`load` 从元素类型推导结果,`store` 要求数据与元素类型相同。IR 中也保留该元素类型,后续环节无需从地址值的 i32 类型反推数据精度。地址角色仅能由地址构造操作产生,普通 i32 数值不能直接用于访存。 -具备 F/D 扩展时,f32 默认选择 `fsqrt.s`,f64 默认选择 `fsqrt.d`。不在第一版默认调用 `sqrtf/sqrt` 库函数,因为硬浮点 ABI 参数和返回值使用 `fa0`,而当前原型错误地使用 `a0` 和普通 `mv`。 +构造器拥有正在建立的 `Program`,每个值和跳转目标必须属于正确的函数。`new_function` 接收零个或一个尚未绑定函数的参数声明,将其绑定到新函数;返回类型为单个数值类型或无返回值,`ret` 与其声明一致。`new_block` 创建并选中当前函数中的块,`position_at_end` 切换到同一函数中可追加指令的块。基本块终结后不再追加指令,`br` 和 `br_compare` 通过块引用保存目标。`finish` 调用 `validate_ir` 并交出完整程序,之后构造器不再修改该程序;验证还检查定义与使用、控制流及地址元数据。前端来源位置随值和指令保留,供诊断使用。 -如后续保留软件库回退,必须另设显式开关,并在调用前后遵守与精度匹配的硬浮点 ABI(`ilp32f/ilp32d`)、保存活跃 caller-saved FPR,不得与硬件路径共用 GPR `MV`。 +### DSL 与 ONNX 归一化 -#### 3.8 浮点舍入、精度和特殊值 - -- 算术和平方根默认使用 `RNE`(round to nearest, ties to even),不依赖调用环境中未知的动态 `frm`。 -- `MachineInstr` 增加可选 `rounding_mode`,`AsmEmitter` 为需要舍入的 FP 指令输出静态 `rne`。`fmin/fmax`、比较和符号操作不附加 rm。 -- f32/f64 分别使用 IEEE 754 binary32/binary64,不允许先转为 Python `int` 或经过低精度十进制文本往返。 -- 浮点异常默认设置 `fcsr` 标志而不触发 trap。 -- `fmin/fmax` 按 RISC-V F/D `minimumNumber/maximumNumber` 语义处理 NaN,并将 `-0.0` 视为小于 `+0.0`;测试不直接以 Python `min/max` 作为这些边界的参考。 - -#### 3.9 `f32/f64` 常量 - -RISC-V 没有标准 `li.s/li.d`。当前 `LI_D` 不能作为最终汇编输出,也不能先将 `1.5` 转成整数 `1`。 - -目标方案使用按宽度对齐的常量池:f32 使用 4 字节 `.word + flw`,f64 使用 8 字节 `.quad + fld`。例如: - -```asm -.section .rodata -.p2align 3 -.LC_f64_0: - .quad 0x3ff8000000000000 # 1.5 - -.text - la t0, .LC_f64_0 - fld f0, 0(t0) -``` - -选择器按 dtype 和 IEEE 754 位模式联合去重,`+0.0/-0.0`、NaN 位模式和 Inf 不得经过十进制文本往返而改变。常量池标签只在当前编译单元内可见。 - -#### 3.10 常量折叠语义 - -`optimizer/constant_folding.py` 需同步识别 `REM` 及类型正确的 `DIV`。整数折叠不得直接使用 Python `//` 和 `%`,因为它们的负数语义与 RISC-V 不同。对非零除数: +DSL 常量允许显式指定 i32、f32 或 f64,未标注的常量采用 f32。后续运算由操作数类型决定精度。例如: ```text -q = trunc_toward_zero(a / b) # 用整数绝对值运算实现,不经过 float -r = a - q * b +x = const(1.5, dtype:f64) +y = const(2.0, dtype:f64) +z = add(x, y) +r = sqrt(z) +return r ``` -除零折叠在初版保守跳过;`INT32_MIN / -1` 按 RV32 回绕结果处理。浮点常量折叠只有在能证明与目标 binary32/binary64 + RNE 相同时才启用,否则保留到目标指令。 +这里的常量、加法、平方根和返回值均为 f64;精度在构造 IR 时确定,后端沿用该类型信息。 ---- +ONNX 的 INT32、FLOAT、DOUBLE 分别映射到 i32、f32、f64。前端先处理参数、initializer 和声明类型,再将标量 Add/Sub/Mul/Div/Sqrt/Min/Max 等操作转换为统一 IR。多输入 Min/Max 按输入顺序展开为同类型的二元操作;只有一个输入时直接传递该值。 -### 四、寄存器类与溢出设计 +标量形状在前端归一化后仍保留必要的元素类型。需要通用张量布局或广播的输入超出标量范围时,在进入目标选择前给出诊断。DSL 的 mod 归一化为 rem;ONNX 的算子按自身定义单独映射。 -#### 4.1 寄存器类 - -新增机器寄存器类: +前端接口保持一致的 IR 输出: ```python -class RegisterClass(enum.Enum): - GPR = "gpr" - FPR = "fpr" -``` - -`MachineOperand` 对 `reg/vreg` 记录 `reg_class`,立即数和标签不需寄存器类。为保持旧代码兼容,未指定的现有整数操作数默认为 GPR。 +class DSLParser: + def parse(self, text: str, filename: str | None = None) -> Program: ... -`machine_semantics.py` 除 defs/uses 以外记录每个位置的类别约束,例如: - -```text -FADD_S/FADD_D: dst=FPR, src1=FPR, src2=FPR -FLW/FLD: dst=FPR, src1=GPR -FSW/FSD: operand0=FPR(value), operand1=GPR(address) -FLT_S/FLT_D: dst=GPR, src1=FPR, src2=FPR +class ONNXParser: + def parse(self, model_path: str) -> Program: ... ``` -同一 vreg 在不同指令中出现矛盾类别时立即报错,不将位模式在 GPR/FPR 间静默移动。 - -#### 4.2 物理寄存器池 - -| 类别 | 初版可分配池 | 保留 | -|---|---|---| -| GPR | 现有 `t0-t6, s0-s11` | `zero/sp/ra/gp/tp`、ABI 参数寄存器 | -| FPR | `ft0-ft11` | `fa0-fa7` 作为参数/返回,`fs0-fs11` 留待 ABI 保存支持 | - -初版仅分配 caller-saved `ft*` 可避免错误使用未在 `abi_frame.py` 保存的 `fs*`。在无外部调用的默认硬件 `sqrt` 路径中可安全使用。 - -#### 4.3 分配器策略 - -`naive` 和 `greedy` 分配器将分别维护 GPR/FPR 映射与空闲池。`linear` 分配器在支持寄存器类之前,遇到 FPR vreg 必须给出明确错误,不能生成错误汇编;本课题交付前再将线性扫描按类别分配补齐。 - -分配时必须保证: - -- GPR 和 FPR 名称空间分离。 -- 一条混合类指令的源/目标分别从正确池中取寄存器。 -- 活跃值穿越 `CALL` 时,caller-saved GPR/FPR 都需溢出或保存。 -- 目标值和源值同名时依据 machine semantics 处理,不重复加载。 - -#### 4.4 溢出槽 - -| 值类型 | 槽大小 | 对齐 | 写回/加载 | -|---|---:|---:|---| -| GPR/i32 | 4 字节 | 4 | `sw/lw` | -| FPR/f32 | 4 字节 | 4 | `fsw/flw` | -| FPR/f64 | 8 字节 | 8 | `fsd/fld` | +两个前端均通过 IR 构造 API 建立程序,在返回前完成类型和标量范围检查。DSL 文本常量调用 `encode_literal`,ONNX 已编码的浮点数据调用 `constant_from_bits`;ONNX 类型编号通过显式映射转换,未支持的编号给出诊断。多输入 Min/Max 的展开也调用 `binary`,使每一步继承相同的签名规则。 -分配器不再只返回“槽位数”,而是返回总溢出字节数或带 size/alignment 的 frame layout。`abi_frame.py` 根据真实字节数建立 16 字节对齐栈帧。 +## 目标能力与数值语义 -#### 4.5 ABI 返回值 +### 目标描述 -- `i32` 返回值使用 `a0`。 -- `f32/f64` 返回值都使用 `fa0`,并由类型决定值的宽度。 -- FPR 间复制分别使用 `fmv.s/fmv.d`,不使用整数 `mv`。 -- 本课题不实现多返回值。 +编译入口建立一个目标描述,包含 ISA 扩展、ABI、浮点寄存器宽度和可用数值类型,并将其传给后端各模块。本设计采用以下目标组合: ---- - -### 五、汇编发射与目标配置 - -#### 5.1 访存格式 - -`AsmEmitter` 将 `FLW/FSW/FLD/FSD` 与 `LW/SW` 一样按内存操作数格式化: - -```asm -flw ft0, 0(t0) -fsw ft0, 4(sp) -fld ft0, 0(t0) -fsd ft0, 8(sp) -``` - -不允许通用三操作数路径输出 `fld ft0, t0`。`LOAD/STORE` 的地址操作数始终是 GPR,数据操作数由宽度和类型决定。 +| ISA | ABI | 浮点寄存器宽度 | 可用数值类型 | +|---|---|---|---| +| RV32IM | ILP32 | 无浮点寄存器 | i32 | +| RV32IMF | ILP32F | 32 位 | i32、f32 | +| RV32IMFD | ILP32D | 64 位 | i32、f32、f64 | -#### 5.2 目标 ISA +指令规则声明所需扩展。选择 f32 运算前确认 F 可用,选择 f64 运算前确认 D 可用;ABI 同时决定参数和返回值的传递方式。配置与运算不兼容时,在目标代码输出前停止转换。 -`CompilerConfig` 增加: +目标描述由统一入口构造,派生属性不由调用方分别填写: ```python -use_extended_isel: bool = False -target_isa: str = "rv32im" -target_abi: str = "ilp32" -fp_rounding_mode: str = "rne" +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: ... ``` -原生 `f32` 要求 F 扩展,原生 `f64` 要求 D 扩展;D 扩展依赖 F。推荐的完整配置为 `rv32imfd + ilp32d`,只用 f32 时可以使用 `rv32imf + ilp32f`。目标 ISA/ABI 与 IR 类型不匹配时,在代码生成前返回明确错误,不静默降级为整数指令。 - -#### 5.3 编译器集成 - -```text ---extended-isel - ↓ -args.extended_isel - ↓ -CompilerConfig.use_extended_isel - ↓ -CompilerDriver._generate_riscv_linear() - ↓ -ExtendedInstructionSelector(program) -``` - -当 `backend=llvm` 时,`--extended-isel` 报错或显式忽略二者只能选一。本设计选择报错,避免用户误以为开关改变了 LLVM 后端。`--dag-isel` 和 `--extended-isel` 互斥。CLI 同时提供 `--target-isa`、`--target-abi` 和 `--fp-rounding-mode`,并在构造 `CompilerConfig` 时保留其值。 - -#### 5.4 机器码编码器 - -当前 `riscv_encoder.py` 主要编码 RV32IM。本课题的必交付以“生成可被 GNU/LLVM RISC-V 汇编器接受的 `.s`”为边界;直接编码 F/D 扩展二进制属于后续扩展。 - -如用户对含 F/D 指令的汇编调用内置编码器,必须得到“当前编码器不支持 F/D 扩展”的明确错误,不得跳过未知指令产生不完整二进制。 - ---- - -### 六、修改模块 - -| 文件 | 修改类型 | 设计内容 | -|---|---|---| -| `scratchv/ir/types.py` | 修改 | 新增 `REM/SQRT/MIN/MAX/ABS`;保留 `FLOAT32/FLOAT64` 并贯通类型传播 | -| `scratchv/ir/builder.py` | 修改 | 新增 builder API;修正类型传播;支持显式 load dtype | -| `scratchv/frontend/dsl_validator.py` | 修改 | 增加新操作签名和 `const(..., dtype:...)` 校验 | -| `scratchv/frontend/dsl_parser.py` | 修改 | 解析扩展操作和类型常量 | -| `scratchv/frontend/onnx_parser.py` | 修改 | 将课题范围内 ONNX 算子和 `FLOAT/DOUBLE` 映射到同一类型化 IR | -| `scratchv/analysis/ir_verifier.py` | 修改 | 增加 opcode 签名与类型规则 | -| `scratchv/backend/instruction_select.py` | 修改 | 抽取操作数合法化、fresh vreg 和类别辅助逻辑 | -| `scratchv/backend/inst_select_ext.py` | 重构 | 实现真实可达的 F/D 扩展 handler,修正 min/store/constant/sqrt | -| `scratchv/backend/inst_patterns.py` | 新增 | 定义 `TargetFeatures/SelectionPattern` 及确定性候选排序 | -| `scratchv/backend/machine_types.py` | 修改 | 新增 `RegisterClass`、FPR 集合、F/D MachineOp 和舍入字段;移除最终 `LI_D` | -| `scratchv/backend/machine_semantics.py` | 修改 | 记录各操作数位置的 GPR/FPR 约束 | -| `scratchv/backend/register_alloc.py` | 修改 | 分类寄存器池、f32/f64 spill/reload、调用保存 | -| `scratchv/backend/regalloc_linear.py` | 修改 | 按寄存器类分别计算和分配区间 | -| `scratchv/backend/asm_emit.py` | 修改 | 正确格式化 FP 指令、静态舍入、访存和常量池 | -| `scratchv/backend/abi_frame.py` | 修改 | 按字节和对齐建立混合溢出栈帧 | -| `scratchv/optimizer/constant_folding.py` | 修改 | 按 RISC-V 语义折叠 `DIV/REM`,仅在可保证目标一致时折叠 FP | -| `scratchv/backend/cycle_estimator.py` | 修改 | 补齐 F/D 指令分类和延迟,用于稳定性能指标 | -| `scratchv/compiler.py` | 修改 | 增加配置并选择扩展后端 | -| `scratchv/main.py` | 修改 | 贯通扩展、ISA、ABI 和舍入 CLI 参数,校验互斥开关 | -| `tests/test_inst_select_ext.py` | 重写/扩展 | 覆盖真实扩展操作和负例 | -| `tests/test_*` | 扩展 | DSL、IR 验证、寄存器分配、CLI 和端到端测试 | - ---- - -### 七、测试设计 - -#### 7.1 IR 和 DSL 测试 - -- `rem/mod/sqrt/min/max/abs` 能从 DSL 构造对应 IR。 -- `const(1.5, dtype:f32/f64)` 分别产生 `DataType.FLOAT32/FLOAT64`。 -- `f32 + f32 -> f32`、`f64 + f64 -> f64`,`i32 rem i32 -> i32`。 -- `f32 + f64`、`f64 + i32`、`i32 sqrt`、未知 dtype 被验证器拒绝。 -- ONNX `FLOAT/DOUBLE` 输入保持精度并到达与 DSL 相同的指令选择路径。 -- 新操作不影响现有 DSL 诊断位置和错误数上限。 - -#### 7.2 指令选择单元测试 - -| 用例 | 必须断言 | -|---|---| -| `i32 div` | 产生 `DIV`,两个源均为 GPR vreg | -| `i32 rem` | 产生 `REM` | -| `i32 abs` | 序列和临时 vreg 唯一 | -| `i32 min/max` | 序列对正、负、相等值结果正确 | -| `f32/f64 add/sub/mul/div` | 分别产生 F/D 扩展指令,所有数据 vreg 为 FPR | -| `f32/f64 load/store` | 分别产生 `flw/fsw` 或 `fld/fsd`;地址为 GPR,数据为 FPR,store 顺序正确 | -| `f32/f64 compare` | 比较输入为 FPR,布尔结果为 GPR | -| `f32/f64 sqrt` | 默认产生 `fsqrt.s/fsqrt.d`,不产生错误的 `a0` 调用序列 | -| `f32/f64 const` | 保留完整 binary32/binary64 位模式,不出现 `li.s/li.d` | -| 多候选操作 | 只选择 ISA/ABI/类型条件满足的 pattern,同成本时结果确定 | - -仅断言 `supported_ops` 包含名称不算功能测试。 - -#### 7.3 寄存器分配测试 - -- 整数指令只获得 GPR,F/D 算术指令只获得 FPR。 -- `flt.s/flt.d` 的目标是 GPR,两个源是 FPR。 -- 分别构造超过 12 个同时活跃 f32/f64 值,验证 `fsw/flw` 的 4 字节槽位和 `fsd/fld` 的 8 字节槽位。 -- 同时施加 GPR/FPR 压力,两类池不互相污染。 -- `naive/greedy/linear` 对同一 f32/f64 用例都不泄漏 vreg。 - -#### 7.4 汇编合法性测试 - -使用可用的 RISC-V GNU 汇编器或 Clang 交叉目标检查: - -```bash -clang --target=riscv32 -march=rv32imfd -mabi=ilp32d \ - -c output.s -o output.o -``` - -CI 环境没有交叉工具时可标记为条件跳过,但必须同时有不依赖外部工具的文本结构测试,检查: - -- 无虚拟寄存器泄漏。 -- `div/rem` 不包含立即数源操作数。 -- `fadd.s/fadd.d` 不使用 `t*/s*/a*`。 -- `flw/fsw/fld/fsd` 使用 `offset(base)` 格式。 -- 需要舍入的 FP 指令显式带 `rne`,不需要 rm 的指令不多出操作数。 -- 输出中没有非标准 `li.s/li.d`。 - -#### 7.5 语义测试 - -整数用例由现有 RV32 仿真器或独立参考函数验证: +`make_target` 规范化 ISA/ABI 名称,检查扩展依赖和组合,计算 XLEN、FLEN、ABI_FLEN 及栈对齐;无浮点时两个浮点宽度均为 0。`require_operation` 按操作、类型和所需扩展检查可用性。`RegisterClass` 只有 GPR/FPR,`register_class` 将数值类型映射到其中之一,地址固定使用 GPR。 -- 正数、负数、0、`INT32_MIN/MAX`。 -- `div/rem` 除零和溢出边界。 -- `min/max/abs` 的随机对比。 +`RegisterPolicy` 包含各类别的可分配集合、保留寄存器、调用者保存集合和被调用者保存集合,集合元素为带名称和类别的 `PhysicalRegister`。参数及返回寄存器按使用位置施加固定约束;栈指针等保留寄存器不参与普通值分配。同一个 `TargetSpec` 贯穿一次编译,不由各阶段再次推断目标。 -f32/f64 用例覆盖正常值、次正常值、±0、±Inf、NaN 以及中点舍入,并校验 `RNE`、`fmin/fmax` 的 NaN/有符号零语义。用例优先在支持 RV32F/D 的 QEMU 或 Spike 上运行;测试启动时先做能力探测并记录模拟器、版本、ISA 和 ABI。若 CI 不具备,将语义测试明确标为 skipped,但仍须完成汇编和 IEEE 754 常量位模式验证。不把当前仅支持 RV32IM 的内置编码器/仿真器误报为 FP 语义验证已通过。 +### 整数与浮点语义 -#### 7.6 CLI 与回归测试 +i32 采用固定位宽整数语义。除法向零截断,非零余数与被除数同号;除零和最小有符号整数除以 −1 的结果遵循 RV32M 定义,不额外插入陷阱。整数结果保持 32 位表示。 -- `args_to_config()` 保存 `--extended-isel`。 -- 开关关闭时仍使用基础选择器。 -- 开关开启时对 f32/f64 DSL 生成 F/D 扩展汇编。 -- `--target-isa/--target-abi/--fp-rounding-mode` 能到达选择器,不合法组合在生成前失败。 -- `--dag-isel --extended-isel` 和 `--backend llvm --extended-isel` 给出可操作错误。 -- 现有 backend、parser、IR verifier、regalloc 测试全部通过。 +浮点四则算术与平方根显式采用 RNE,即最近值、相同距离取偶数。每次运算按其声明精度舍入,舍入选择作为机器指令的属性传递到汇编层。 -#### 7.7 性能与稳定性指标 +min/max、符号操作和比较采用对应 F/D 指令的特殊值语义。min/max 对符号零、NaN 和 signaling NaN 的处理由目标规则定义;常量与访存保留源编码,计算结果及异常标志由实际浮点运算产生。 -性能验收使用固定输入的静态指标,不使用易受 CI 噪声影响的编译 wall-clock 时间: +## 指令选择方案 -- 记录指令选择后和寄存器分配后的指令数、FP 指令数、spill load/store 数及总 spill 字节。 -- 扩展 `cycle_estimator.py` 对 `fmin/fmax/compare/sqrt` 的分类与延迟表,并在固定管线配置下输出预估 cycles/CPI。 -- 开关关闭时,现有 RV32IM 基准的汇编文本和静态指标必须与基线一致。 -- 开关开启但程序不含扩展操作时,分配后指令数和 spill 数不得退化。 -- f32/f64 求和、点积、比较和 `sqrt/min/max` 微基准将上述数值写入 JSON;初版将其作为可复现基线,不设置未经硬件标定的绝对 cycles 门槛。 +### 选择流程 ---- +每条 IR 操作按以下顺序转换:取得操作与类型,确认目标能力,准备符合要求的操作数,再生成带类型信息的机器指令。规则使用操作本身及其操作数/结果类型,不依赖其他指令留下的类型状态。 -### 八、验收标准 +简单运算直接通过“操作 + 类型”映射选择 opcode;需要展开的操作由对应规则生成等价序列。整数规则与浮点规则共享控制流、地址和函数结构处理,不为两种精度分别建立完整后端。 -课题完成需同时满足: - -1. DSL、ONNX 课题范围映射和 IRBuilder 可构造 `rem/mod/sqrt/min/max/abs` 及 f32/f64 算术用例。 -2. IR 验证器对新操作进行数量、结果和类型检查。 -3. `i32 div/rem` 只产生合法寄存器操作数。 -4. `f32/f64 add/sub/mul/div/load/store/neg/abs/min/max/sqrt` 分别产生对应 F/D 扩展指令。 -5. f32/f64 数据使用 FPR,地址和比较布尔结果使用 GPR。 -6. f32/f64 spill 分别使用 4/8 字节对齐的 `fsw/flw` 和 `fsd/fld`,栈帧继续保持 16 字节对齐。 -7. f32/f64 常量不丢失精度和位模式,输出不包含 `li.s/li.d`。 -8. 默认 FP 算术和平方根显式使用 `RNE`,NaN、±0 和异常标志语义有定向测试。 -9. `SelectionPattern` 仅从类型与目标特性合法的候选中确定性选择。 -10. 整数 `DIV/REM` 常量折叠与 RV32M 在负数和溢出边界上一致。 -11. `--extended-isel`、目标 ISA/ABI 和舍入配置真正到达指令选择路径。 -12. 至少一个整数除法/余数 DSL 程序和 f32/f64 求和或点积程序完成端到端编译。 -13. 生成汇编通过 RV32IMFD 交叉汇编器检查;模拟器测试通过能力探测后执行或明确跳过并记录环境。 -14. 固定微基准产生指令数、spill 和预估 cycles JSON;关闭开关时 RV32IM 基线完全不变。 -15. Topic 28 定向测试和现有回归测试全部通过。 -16. 用户文档列出支持操作、类型、ISA/ABI 要求、舍入、`mod` 语义和当前限制。 - ---- - -### 九、阶段划分概览 +| 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 | -#### 阶段 1:基线与整数闭环 +整数 min/max 用有符号比较确定大小,再通过值选择序列取得结果,比较过程不依赖可能溢出的减法。整数 abs 按符号处理结果,最小有符号整数的结果仍按 32 位表示。 -1. 为当前已知缺口添加失败测试,特别是非法立即数 `div` 和 CLI 开关丢失。 -2. 增加扩展 OpCode、IRBuilder、DSL 和 IR 验证规则。 -3. 实现 `rem/abs/min/max` 的 RV32IM 选择。 -4. 完成整数语义测试。 +整数指令需要寄存器操作数时,常量先转换为 GPR 中的值;浮点数据先进入对应精度的 FPR。地址准备使用统一的寻址处理,再交给加载或存储规则。 -#### 阶段 2:f32/f64 IR 与选择 +指令选择按程序入口和函数内入口分工: -1. 修正 IRBuilder 类型传播和显式 f32/f64 常量入口。 -2. 实现 `TargetFeatures/SelectionPattern`,再注册 F/D 算术、访存、比较、分支和返回模式。 -3. 实现 binary32/binary64 常量池和显式 `RNE`。 -4. 在 MachineInstr 和 machine semantics 中建立 `RegisterClass`、值宽度和舍入合同,并输出选择后测试。 +```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: ... +``` -#### 阶段 3:后端贯通与课题收尾 +`select_program` 接收已验证的 IR,建立本次编译专用的常量池,逐函数调用 `select_function`,最后返回 `SelectedModule`。其中 `functions` 为有序机器函数,`constants` 为冻结后的常量条目。`select_function` 保留函数签名、块间跳转、局部对象和来源位置,不将多函数压成丢失边界的指令列表。 -1. 以阶段 2 的 `RegisterClass` 和 machine semantics 为合同,增加 FPR 物理池与带宽度的分配结果。 -2. 扩展 naive/greedy/linear 分配器。 -3. 支持 4/8 字节 f32/f64 spill 和混合栈帧。 -4. 实现 `fa0` 返回和 FPR 复制。 -5. 贯通 CLI/CompilerConfig/CompilerDriver,补齐浮点汇编、常量池和内置工具能力边界。 -6. 运行交叉汇编、语义、性能基线和完整回归验证。 -7. 更新支持列表、类型说明、使用示例和验收证据,关闭 Topic 28。 +每个 `SelectionContext` 属于一个函数,拥有 IR 值到虚拟寄存器/地址引用的映射、虚拟编号和输出块。`new_vreg` 由类型确定类别与宽度,`new_address_reg` 产生 32 位 GPR。`materialize` 将已有值或常量准备为其类型要求的寄存器,并在当前块追加必要指令;跨块复用必须满足定义支配使用,不能仅凭值名全局缓存加载结果。 -阶段 3 合并了原先拆分的寄存器/ABI 与集成交付工作,完成后不再设第 4 阶段。详细实施顺序和验收门见下文“阶段 3:后端贯通与课题收尾”。 +操作规则先调用 `require_operation`,再按 `(op, dtype)` 分派。规则仅使用当前指令、目标和函数上下文;未支持的组合返回明确诊断,不生成默认指令。整数展开序列与浮点直接映射使用同一个 `emit`,每条产生的机器指令保留原 IR 的来源信息。 ---- +### 比较与条件分支 -### 十、风险与缓解 +浮点比较先产生 GPR 中的 0 或 1,再由普通条件分支决定控制流。两种精度使用相同的谓词规则,仅选择不同的指令后缀: -| 风险 | 影响 | 缓解方案 | +| 谓词 | 浮点比较方式 | 分支条件 | |---|---|---| -| 只扩展 selector,忘记 regalloc | 生成 `fadd.d s0,s1,s2` 等非法汇编 | 将寄存器类别作为 MachineOperand 的显式属性并写混合类测试 | -| 常量丢失精度 | f32/f64 结果错误 | 使用 binary32/binary64 位模式和常量池,禁止 `int(raw_val)` | -| spill 仍固定 4 字节 | f64 覆盖相邻槽位、错误对齐 | frame layout 记录 size/alignment,对 f32/f64 分别做高压力测试 | -| `mod` 语义混淆 | 负数用例与 Python 不同 | 文档明确定义为 RISC-V `rem` 别名并加负数测试 | -| Python 直接折叠除余 | 负数和溢出边界错误 | 用纯整数实现向 0 截断,与 RV32M 参考函数做属性测试 | -| 舍入模式依赖运行环境 | 同一程序在不同 `frm` 下结果不同 | 默认发射静态 `rne`,定向测试中改变 `frm` 验证不受影响 | -| 候选模式顺序不稳定 | 相同输入生成不同汇编 | 使用成本加显式稳定优先级,对注册顺序做置换测试 | -| 模拟器不支持 F/D 扩展 | 无法在内置环境执行 FP | 能力探测后分离结构测试、交叉汇编检查和 QEMU/Spike 语义测试 | -| 扩展开关被静默忽略 | 用户以为功能已开启 | CLI 集成测试直接检查选择器类型和输出 | -| 同时重构三个分配器引起回归 | 旧 RV32IM 管线受影响 | `RegisterClass.GPR` 作为兼容默认,分阶段提交并每阶段运行全量测试 | +| 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 语义;互换操作数构造大于或大于等于时沿用原指令的异常行为。 -### 十一、最终交付物 +### 浮点常量 -- 扩展后的 IR、DSL 和类型验证实现。 -- 可达、类型正确且基于目标特性选择候选模式的 `ExtendedInstructionSelector`。 -- GPR/FPR 分类寄存器分配与 f32/f64 spill 支持。 -- 可被 RV32IMFD 工具链接受的汇编输出。 -- 整数除法/余数和 f32/f64 求和/点积测试程序。 -- Topic 28 单元、集成、语义和回归测试。 -- 包含固定配置、指令数、spill 和预估 cycles 的可复现 JSON 基准。 -- 支持的操作列表、数据类型、ISA/ABI、舍入语义和已知限制文档。 +浮点常量集中放入只读常量区,以“类型与原始编码”作为标识,复用相同的常量。f32 以 4 字节数据表示,f64 以 8 字节数据表示,并分别满足自然对齐。 ---- +使用常量时,先取得 GPR 地址,再用 flw 或 fld 加载到 FPR。常量引用、标签和数据定义由同一常量管理模块生成,指令选择仅保存引用。 -### 十二、参考依据 - -- [ScratchV PR #26: Extended Instruction Selection](https://github.com/ScratchV-Compiler/ScratchV/pull/26) -- [RISC-V Unprivileged ISA: M Extension](https://docs.riscv.org/reference/isa/v20240411/unpriv/m-st-ext.html) -- [RISC-V Unprivileged ISA: F Extension](https://docs.riscv.org/reference/isa/unpriv/f-st-ext.html) -- [RISC-V Unprivileged ISA: D Extension](https://docs.riscv.org/reference/isa/unpriv/d-st-ext.html) -- [RISC-V ELF psABI](https://riscv-non-isa.github.io/riscv-elf-psabi-doc/) - ---- - -## 阶段 2:f32/f64 IR 与类型化指令选择 - -> 状态:已实施 -> 前置阶段:阶段 1 的整数闭环、扩展数值类型合同和 CLI 路由 -> 阶段边界:本阶段完成浮点 IR、目标特性、精确常量和类型化 MachineInstr;FPR 分配、浮点 spill 与 ABI 返回属于阶段 3 +```python +class ConstantPool: + def intern(self, literal: ScalarConstant) -> ConstantRef: ... + def freeze(self) -> tuple[ConstantEntry, ...]: ... +``` -### 1. 目标 +`ConstantRef` 是池内唯一的符号引用;`ConstantEntry` 保存引用、类型、位模式、大小和对齐。`intern` 按 `(dtype, bits)` 去重,返回稳定引用;`freeze` 后不再接受新增条目。地址准备指令引用 `ConstantRef`,汇编层为相同引用生成唯一标签和数据定义。常量池作用域为一个模块,可被该模块的多个函数共享,不跨编译请求保留状态。 -阶段 2 要让 `f32/f64` 从 DSL、ONNX 和 IRBuilder 进入统一的强类型 IR,并被确定地选择为正确的 RISC-V F/D 机器操作。 +## 机器表示与寄存器分配 -完成后必须满足: +### 机器表示需要携带的信息 -1. `f32/f64` 算术、访存、比较、`sqrt/min/max/abs` 不会落入整数选择路径。 -2. 浮点常量保留 IEEE 754 binary32/binary64 位模式,不经过 `int()` 截断。 -3. 每个机器操作数携带 GPR/FPR 类别,比较结果明确写入 GPR。 -4. 需要舍入模式的操作显式使用 `RNE`;`min/max/abs` 和比较不附加舍入参数。 -5. 目标 ISA、ABI 或类型不匹配时,在代码生成前给出明确错误。 -6. 阶段 3 完成前,含 FPR vreg 的程序在寄存器分配入口明确停止,不能输出使用 `t*` 等 GPR 的伪浮点汇编。 +机器操作数区分寄存器、立即数、标签和内存引用。寄存器操作数同时记录类别和数值位宽;内存引用记录地址来源、元素类型或存储位置引用。局部数据和溢出位置在布局确定前使用符号引用。 -### 2. 当前基线与缺口 +每种机器操作声明结果定义、输入使用、操作数类别及是否涉及内存、调用、分支或浮点异常。分配器利用定义与使用计算活跃关系,汇编层利用操作数形式输出语法。 -阶段 1 已经具备: +比较指令可以同时具有 FPR 输入和 GPR 输出,浮点访存也同时具有 GPR 地址和 FPR 数据,因此类别信息按操作数位置描述。 -- `DataType.FLOAT32/FLOAT64`; -- `MIN/MAX/ABS` 的 `i32/f32/f64` IR 合同; -- `FMIN/FMAX/FABS` 的 `.s/.d` 机器操作; -- `--extended-isel` 到扩展选择器的路由; -- 整数 `DIV/REM/MIN/MAX/ABS` 闭环。 +### 机器数据与指令描述 API -阶段 2 需要解决以下问题: +机器表示保留控制流结构,操作数中的引用由所属函数或模块管理: -| 缺口 | 当前风险 | 阶段 2 结果 | +| 数据对象 | 主要字段 | 约束 | |---|---|---| -| `SQRT` 尚未进入统一 IR 合同 | DSL 名称可能存在,但 IR 无法可靠表示 | 增加 `OpCode.SQRT`、builder、DSL、verifier 和选择规则 | -| `f32 ADD/SUB/MUL/DIV/NEG` 可能落到基础整数选择器 | 生成类型错误的机器操作 | 显式选择 `.s` 操作,`f64` 选择 `.d` 操作 | -| 浮点常量通过 `int(raw_val)` 或非标准 `LI_D` | 精度和位模式丢失 | 使用按位模式去重的常量池 | -| MachineOperand 没有寄存器类别 | regalloc 会把浮点 vreg 分到 GPR | 增加 `RegisterClass` 和位置类别约束 | -| 目标 ISA/ABI/舍入配置未进入选择器 | 在不支持 F/D 时仍可能选择浮点指令 | 增加 `TargetFeatures` 并在候选选择前校验 | -| 浮点比较与分支没有统一降低 | FPR 输入和 GPR 布尔结果容易混淆 | 按比较谓词生成 `feq/flt/fle` 和整数分支 | -| 阶段 3 尚未实现 | 可能输出表面像汇编、实际寄存器非法的文本 | 在 regalloc 边界 fail closed | +| `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 | 保留函数签名、控制流及局部存储对象 | -### 3. 本阶段范围 - -#### 3.1 IR 和前端 - -- 增加 `OpCode.SQRT`,签名为 `(F) -> F`,`F ∈ {f32, f64}`。 -- `MIN/MAX/ABS` 保持现有 `i32/f32/f64` 合同,`REM` 保持仅 `i32`。 -- `ADD/SUB/MUL/DIV/NEG` 对相同类型的 `f32/f64` 正确传播结果类型。 -- `IRBuilder.load()` 增加显式结果 dtype;`STORE` 保持 `[address, value]` 的 IR 顺序。 -- DSL 支持 `sqrt(x)` 和 `const(..., dtype:f32/f64)`。 -- ONNX 只映射本阶段有明确语义和选择规则的操作,不以默认 `f32` 掩盖未知类型。 -- 不增加隐式数值转换;混合 dtype 必须在 builder 或 verifier 报错。 - -#### 3.2 目标特性与候选模式 - -新增 `scratchv/backend/inst_patterns.py`: +`MachineOperand` 是寄存器引用、立即数、内存引用、常量引用或控制流符号引用的联合。比较结果用 32 位 GPR 表示 0/1;地址计算结果同样是 32 位 GPR,但访存数据精度由 `MemoryRef.element_type` 决定。局部地址计算和访存均保留 `StackSlotRef`,待布局阶段统一解析。 ```python -@dataclass(frozen=True) -class TargetFeatures: - isa: str = "rv32im" - abi: str = "ilp32" - rounding_mode: str = "rne" - libm_available: bool = False - optimize_for: str = "balanced" - -@dataclass(frozen=True) -class SelectionPattern: - name: str - opcode: OpCode - dtypes: tuple[DataType, ...] - required_extensions: frozenset[str] - estimated_cost: int - priority: int +def instruction_info( + instruction: MachineInstruction, target: TargetSpec +) -> InstructionInfo: ... ``` -候选选择顺序固定为 `(estimated_cost, priority, name)`。类型或 ISA 不合法的候选先过滤,不能参与成本比较。 - -目标组合至少支持: - -| 数据类型 | ISA | ABI | -|---|---|---| -| 仅整数 | `rv32im` | `ilp32` | -| `f32` | `rv32imf` | `ilp32f` | -| `f64` | `rv32imfd` | `ilp32d` | - -`D` 依赖 `F`。不合法组合在创建选择器前失败。 - -#### 3.3 类型化机器表示 +`InstructionInfo` 返回寄存器定义、使用、逐位置操作数约束,以及读/写内存、写浮点异常标志、分支和调用等效果;调用还包含实际物理寄存器覆盖集合。每个 `OperandConstraint` 记录类别、宽度和可选的固定物理寄存器。分配器与机器优化共同使用该接口,指令打印字符串和注释不参与语义判断。`RoundingMode` 在本设计中使用 RNE,非舍入操作的 `rounding` 为 `None`。 -- 增加 `RegisterClass.GPR/FPR`。 -- `MachineOperand.reg/vreg()` 记录 `reg_class`;旧整数调用缺省为 GPR。 -- `MachineOpSemantics` 记录每个 defs/uses 位置要求的寄存器类别。 -- `MachineInstr` 增加可选 `rounding_mode`,只允许 RISC-V 定义的舍入模式。 -- 同一 vreg 被要求为两种寄存器类别时立即报错。 +### 分配与溢出 -必须覆盖的类别规则: +GPR 与 FPR 使用独立的可分配集合。f32 和 f64 共用一个 FPR 集合,不按精度划分互不相干的物理寄存器池;数值位宽用于决定搬运和溢出方式。 -| 操作 | dst | src1 | src2 | -|---|---|---|---| -| `FADD/FSUB/FMUL/FDIV` | FPR | FPR | FPR | -| `FMIN/FMAX` | FPR | FPR | FPR | -| `FABS/FNEG/FSQRT` | FPR | FPR | — | -| `FLW/FLD` | FPR | GPR 地址 | — | -| `FSW/FSD` | FPR 值 | GPR 地址 | — | -| `FEQ/FLT/FLE` | GPR | FPR | FPR | - -#### 3.4 浮点常量池 - -- 使用 `struct.pack()` 或等价的无损方法取得 binary32/binary64 位模式。 -- 常量池键为 `(dtype, bit_pattern)`,不能用 Python 浮点值本身作为唯一键。 -- `+0.0` 与 `-0.0` 分开;NaN 按位模式保留;Inf 不经过十进制往返。 -- `f32` 条目使用 4 字节对齐和 `.word`,通过 `flw` 加载。 -- `f64` 条目使用 8 字节对齐和 `.quad`,通过 `fld` 加载。 -- 删除最终汇编中的 `LI_D` 路径;RISC-V 没有标准 `li.s/li.d`。 - -#### 3.5 指令选择矩阵 +参数、返回、栈指针及其他 ABI 保留寄存器作为固定约束处理。分配器根据活跃关系安排其余物理寄存器,寄存器不足时选择值写入溢出位置,再在使用点恢复。 -| IR | `f32` | `f64` | 舍入 | +| 溢出值类型 | 槽大小/对齐 | 保存 | 恢复 | |---|---|---|---| -| `ADD` | `fadd.s` | `fadd.d` | `rne` | -| `SUB` | `fsub.s` | `fsub.d` | `rne` | -| `MUL` | `fmul.s` | `fmul.d` | `rne` | -| `DIV` | `fdiv.s` | `fdiv.d` | `rne` | -| `NEG` | `fneg.s` | `fneg.d` | 无 | -| `ABS` | `fabs.s` | `fabs.d` | 无 | -| `MIN/MAX` | `fmin.s/fmax.s` | `fmin.d/fmax.d` | 无 | -| `SQRT` | `fsqrt.s` | `fsqrt.d` | `rne` | -| `LOAD` | `flw` | `fld` | 无 | -| `STORE` | `fsw` | `fsd` | 无 | - -`SQRT` 本阶段只实现硬件 F/D 路径。libm 回退不是 Topic 28 验收的必要条件;阶段 3 完成硬浮点 ABI 后,未配置 F/D 的目标仍应明确拒绝而不静默回退。 - -浮点比较沿用 `BR_IF` 的双操作数形式: - -- `==` → `feq.s/d` -- `!=` → `feq.s/d` 后反转条件 -- `<`/`>` → `flt.s/d`,必要时交换操作数 -- `<=`/`>=` → `fle.s/d`,必要时交换操作数 - -比较结果写入 GPR,再使用整数分支操作。 - -#### 3.6 编译管线安全边界 - -阶段 2 不实现 FPR 分配。为避免产生错误汇编: - -1. 选择器单元测试直接检查未分配的类型化 MachineInstr。 -2. CompilerDriver 在 regalloc 前扫描 FPR vreg。 -3. 若检测到 FPR vreg,返回明确错误:浮点选择已完成,但需要阶段 3 的 FPR 分配支持。 -4. 不允许把 FPR vreg 临时映射到 GPR,也不允许静默退回整数指令。 - -该限制在阶段 3 完成后由真实的分类寄存器分配替换。 - -### 4. 实施顺序 +| i32 | 4/4 字节 | sw | lw | +| f32 | 4/4 字节 | fsw | flw | +| f64 | 8/8 字节 | fsd | fld | -#### 里程碑 A:先写失败测试和 IR 合同 +一个值对应稳定的溢出位置。分配策略可以采用不同的保留或驱逐方法,但共用值类别、槽布局和搬运规则,避免每种策略分别判断存储精度。 -- 固化 `SQRT`、浮点算术、访存和比较签名。 -- 增加混合 dtype、`i32 sqrt`、未知目标特性的负例。 -- 验收:IR/DSL 测试通过,选择器测试仍按预期失败。 +分配结果向栈布局提供溢出位置、所使用的被调用者保存寄存器,以及最终机器指令。跨块传递依照活跃关系处理,使每个后继块在使用值前能从正确位置取得数据。 -#### 里程碑 B:目标特性和机器类型 - -- 实现 `TargetFeatures/SelectionPattern`。 -- 增加 RegisterClass、rounding mode 和 machine semantics 类别约束。 -- 验收:候选排序稳定,非法 ISA/ABI 无候选,类别冲突明确失败。 - -#### 里程碑 C:精确常量池 +```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: ... +``` -- 实现 binary32/binary64 位模式、去重、标签和数据段输出。 -- 删除 `int(raw_val)` 与最终 `LI_D` 路径。 -- 验收:覆盖 ±0、正常值、次正常值、±Inf 和多种 NaN 位模式。 +`LivenessInfo` 提供各块的 live-in/live-out 及指令位置的活跃值,依据 `instruction_info` 和控制流计算。`SpillRecipe` 提供槽大小、对齐、保存 opcode 和恢复 opcode,各分配策略共用该查询。`AllocationStrategy` 包含 NAIVE/GREEDY/LINEAR,只选择分配算法,不改变类型规则、调用覆盖规则或栈槽格式。 -#### 里程碑 D:完成选择矩阵 +`AllocationResult` 包含已改写的机器函数、未溢出虚拟值的物理映射、虚拟值到溢出槽的映射、溢出槽列表和使用的被调用者保存寄存器。分配过程保留局部槽引用,在其编号之外生成溢出槽引用,同一个虚拟值重复溢出时复用同一槽。改写结果包含保存/恢复及其临时寄存器分配,不再含待分配虚拟寄存器;局部和溢出地址仍为符号引用。 -- 实现 f32/f64 算术、访存、比较、分支、`sqrt/min/max/abs`。 -- 修正 STORE 操作数顺序。 -- 验收:每条 MachineInstr 的 opcode、寄存器类别和舍入模式完全匹配矩阵。 +分配过程检查固定物理操作数的干涉,并处理调用覆盖集合。跨调用值可以分配到允许的被调用者保存寄存器,或按类型写入槽后恢复;两种选择遵守相同活跃规则。物理映射用于后续查询,机器函数中的改写结果是汇编生成的依据,后续阶段不再次从映射重做分配。 -#### 里程碑 E:集成与阶段出口 +## 栈布局与调用约定 -- 贯通 `target_isa/target_abi/fp_rounding_mode` 配置。 -- 增加 regalloc 前的 FPR fail-closed 检查。 -- 运行全部整数回归,确认关闭扩展开关时汇编不变。 -- 输出阶段 2 变更说明和阶段 3 输入清单。 +### 每函数布局 -### 5. 文件级修改计划 +前端建立局部存储描述,分配器建立溢出描述,函数接口处理提供寄存器保存需求。栈布局统一收集这些信息,为每个函数计算独立的地址安排。 -| 文件 | 计划 | +| 布局区域 | 安排方式 | |---|---| -| `scratchv/ir/types.py` | 增加 `SQRT`,整理操作类型合同 | -| `scratchv/ir/builder.py` | 增加 `sqrt`、显式 load dtype,完成浮点类型传播 | -| `scratchv/frontend/dsl_validator.py` | 增加 `sqrt` 签名和 dtype 诊断 | -| `scratchv/frontend/dsl_parser.py` | 将 `sqrt` 和浮点常量构造成统一 IR | -| `scratchv/frontend/onnx_parser.py` | 保留 FLOAT/DOUBLE 并映射本阶段操作 | -| `scratchv/analysis/ir_verifier.py` | 增加 `SQRT` 和浮点比较合同 | -| `scratchv/backend/inst_patterns.py` | 新增目标特性与候选模式 | -| `scratchv/backend/machine_types.py` | RegisterClass、rounding、缺失 F/D 操作 | -| `scratchv/backend/machine_semantics.py` | 操作数位置的 GPR/FPR 约束 | -| `scratchv/backend/inst_select_ext.py` | 完成类型化浮点选择矩阵 | -| `scratchv/backend/asm_emit.py` | 常量池、FP 访存格式和静态舍入输出 | -| `scratchv/compiler.py`、`scratchv/main.py` | 目标配置和阶段 2 安全边界 | -| `tests/test_topic28_*` | IR、选择、常量、配置和回归测试 | - -### 6. 测试与验收 - -#### 6.1 必须有的测试 - -- IR/DSL:合法的 `f32/f64` 和所有混合类型负例。 -- 候选选择:ISA、ABI、dtype、成本和稳定排序。 -- 常量位模式:±0、次正常值、边界正常值、±Inf、quiet/signaling NaN。 -- 指令选择:矩阵中每个 `.s/.d` 操作及寄存器类别。 -- 比较:FPR 输入、GPR 输出和六种谓词。 -- 安全边界:阶段 2 不生成使用 GPR 的浮点汇编。 -- 回归:阶段 1 整数测试和完整 `tests/`。 - -#### 6.2 阶段 2 完成标准 - -- [x] `SQRT` 从 DSL/ONNX/Builder 到 IRVerifier 全部可达。 -- [x] `f32/f64` 操作全部选择正确的 F/D MachineOp。 -- [x] MachineOperand 和 machine semantics 能检查 GPR/FPR 类别。 -- [x] 浮点常量位模式完全保真,输出无 `li.s/li.d`。 -- [x] 需要舍入的操作显式携带 `rne`。 -- [x] 不支持的 ISA/ABI/type 组合在选择前失败。 -- [x] 阶段 3 前的 FPR 程序在 regalloc 入口明确失败,不产生非法汇编。 -- [x] 扩展开关关闭时,现有 RV32IM 行为和测试不变。 -- [x] 定向测试和完整回归全部通过。 - -### 7. 明确不属于阶段 2 +| 寄存器保存区 | 根据函数实际需要保持的寄存器生成 | +| 局部数据区 | 按元素数量、大小和对齐排列 | +| 溢出区 | 按各值的类型与对齐排列 | +| 填充区 | 使整个栈帧满足 ABI 对齐要求 | -- FPR 物理寄存器分配。 -- f32/f64 的 4/8 字节 spill、reload 和混合栈帧。 -- `fa0` 返回、FPR caller/callee-saved ABI 保存。 -- libm `sqrtf/sqrt` 调用回退。 -- 内置 RV32 编码器和模拟器的完整 F/D 二进制执行。 -- 浮点性能基准和最终用户支持声明。 - -这些内容全部进入阶段 3;阶段 2 只为它们提供明确、可验证的类型化输入。阶段 3 完成后 Topic 28 直接关闭,不再设第 4 阶段。 - ---- - -## 阶段 3:后端贯通与课题收尾 - -> 状态:待实施 -> -> 前置阶段:阶段 2 已产生类型正确、带 GPR/FPR 类别的 MachineInstr -> -> 阶段边界:本阶段合并原设计中的“寄存器分配与 ABI”和“集成与交付”;完成后 Topic 28 关闭,不再留第 4 阶段 - -### 1. 收尾目标 - -阶段 3 要把阶段 2 的“能选出浮点指令”变成“能生成合法、可汇编、可验证的 RV32IMF/RV32IMFD 程序”。本阶段结束时,课题要求中的整数新算子、`f32/f64` 类型、F/D 指令选择、寄存器分配、spill、ABI、CLI、测试和文档全部闭环。 - -完成后必须满足: - -1. `f32/f64` 虚拟寄存器在 naive、greedy 和 linear 分配路径中只能分配到 FPR。 -2. `f32/f64` spill 分别使用 4/8 字节槽位和 `fsw/flw`、`fsd/fld`,混合栈帧保持 16 字节对齐。 -3. 浮点返回值进入 `fa0`,FPR 复制使用浮点 move,活跃值穿越调用时按 ABI 保存。 -4. 常量池、F/D 访存、比较、静态舍入模式和函数收尾能输出 GNU/LLVM 汇编器接受的语法。 -5. CLI 指定的 selector、ISA、ABI、舍入模式和寄存器分配器真正到达后端。 -6. 至少一个 `f32` 和一个 `f64` 程序完成前端到汇编的端到端编译,并通过外部 RV32IMFD 汇编器检查。 -7. 内置编码器或模拟器不支持 F/D 时明确拒绝,不跳过未知指令。 -8. 完整回归、支持矩阵、用户示例、性能基线和最终变更说明全部完成。 - -### 2. 阶段 2 出口检查 - -阶段 3 不在不稳定的 MachineInstr 合同上开工。必须先确认: - -- `MachineOperand` 能区分 GPR/FPR,同一 vreg 的类别不冲突。 -- FPR vreg 还携带足够的宽度信息,能区分 4 字节 `f32` 和 8 字节 `f64` spill。只有 `RegisterClass.FPR` 不足以分配槽位。 -- `MachineOpSemantics` 完整标注 defs、uses、每个位置的寄存器类别和 call/terminator 属性。 -- `FLW/FSW/FLD/FSD`、F/D 算术与比较所需的 MachineOp 已齐全;ABI 专用的 `FMV_S/FMV_D` 在里程碑 A 补齐。 -- 浮点常量已经是“常量池标签 + GPR 地址 + FPR 加载”,不再出现 `LI_D` 类非标准终态伪指令。 -- 选择后验证器可以在进入 regalloc 前拦截 opcode、寄存器类别、宽度或舍入模式冲突。 - -如果上述合同有一项未成立,先修正阶段 2,不在分配器中猜 dtype。 - -### 3. 核心设计 - -#### 3.1 寄存器池 +各存储对象用同一种对齐分配规则安排: ```text -GPR: t0-t6, s0-s11 -FPR: ft0-ft11 -保留: zero/sp/ra/gp/tp, a0-a7, fa0-fa7, fs0-fs11 +对象偏移 = 向上对齐(已使用字节数, 对象对齐) +已使用字节数 = 对象偏移 + 对象大小 +栈帧大小 = 向上对齐(已使用字节数, ABI 栈对齐) ``` -- 三种分配路径分别维护 GPR/FPR 映射、空闲池、活跃集和 spill 决策。 -- `ft0-ft11` 是 caller-saved,有 `CALL` 时,所有穿越调用且仍活跃的 FPR 值必须在调用前 spill、调用后按需 reload。 -- 本课题不把 `fs0-fs11` 加入可分配池,从而不需为可选优化扩大 FPR callee-saved 帧协议。 -- 物理寄存器合法性由统一函数检查;不允许以名称前缀散落判断。 - -#### 3.2 分配器合同 - -naive、greedy 和 linear 最终返回同一种结果,至少包含: +布局完成后,将机器表示中的栈位置引用替换为相对栈指针的偏移。栈调整和访存偏移满足目标指令的立即数范围;超出直接寻址范围的布局在汇编表达前给出诊断。 ```python @dataclass(frozen=True) -class SpillSlot: - offset: int - size: int - alignment: int - reg_class: RegisterClass +class RegisterSave: + register: PhysicalRegister + width_bits: int + slot: StackSlotRef -@dataclass -class AllocationResult: - instructions: list[MachineInstr] - register_map: dict[str, str] - spill_slots: dict[str, SpillSlot] - spill_bytes: int +@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: ... ``` -具体类名可根据仓库风格调整,但不再以单一 `spill_slot_count * 4` 代表混合栈帧。槽位分配按 `align_up(cursor, alignment)` 计算,且同一 vreg 始终复用同一槽位。 - -#### 3.3 spill/reload 规则 +`FrameLayout` 保存所属函数、总大小、`StackSlotRef → 字节偏移` 的映射和寄存器保存条目。`plan_frame` 汇总机器函数的局部槽、分配产生的溢出槽,以及被调用者寄存器和返回地址的保存需求,为保存条目另建不冲突的槽引用;所有槽均按调整后的 sp 为基准。各对象区间不得重叠,槽和整个栈帧分别满足自身与 ABI 的对齐。 -| 值 | 宽度 | 对齐 | spill | reload | -|---|---:|---:|---|---| -| `i32` / GPR | 4 | 4 | `sw` | `lw` | -| `f32` / FPR | 4 | 4 | `fsw` | `flw` | -| `f64` / FPR | 8 | 8 | `fsd` | `fld` | +寄存器保存条目记录物理寄存器、保存宽度和位置。GPR 按 XLEN 保存;硬浮点 ABI 的被调用者保存 FPR 按 ABI_FLEN 保存整个需要保持的寄存器状态,而值溢出仍按该值的 f32/f64 宽度安排。包含调用的函数按返回地址的活跃需求保存 ra。 -- spill 的值操作数使用其本来的寄存器类,地址基寄存器始终是 GPR `sp`。 -- 重写器不从指令拼写猜宽度,宽度来自 vreg 类型元数据。 -- 超过 I-type 12 位偏移范围的栈帧继续明确失败;扩展大栈帧寻址序列不属于本课题。 +`finalize_function` 用同一布局解析局部/溢出地址,加入入口栈调整、保存,以及每个返回出口的恢复和栈释放。它检查实际栈调整与每个最终访存偏移的可编码范围。结果 `FinalMachineFunction` 不含虚拟寄存器或栈槽引用,仅保留物理操作数和合法目标指令或伪指令;函数引用与常量符号留给汇编表达。 -#### 3.4 混合栈帧 +### 参数、返回与调用保护 -`abi_frame.py` 接收完整 frame layout,而不是槽位数。栈帧布局顺序固定为: +入口将参数接入相应类别的虚拟值,返回处理将结果传到 ABI 规定的位置,搬运按数值精度选择: -1. `ra` 和已使用的 GPR callee-saved 保存区。 -2. 按对齐排列的 GPR/f32/f64 spill 区。 -3. 尾部 padding,使函数入口和调用点的 `sp` 保持 16 字节对齐。 - -栈帧终结器根据结构化槽位重写偏移,不再依赖“每个槽 4 字节”的全局假设。每个 return 出口都恢复保存寄存器和 `sp`。 - -#### 3.5 返回值与 ABI - -| IR 返回类型 | ABI 寄存器 | 复制方式 | +| 类型 | 单个参数/返回寄存器 | 同类别搬运形式 | |---|---|---| -| `i32` | `a0` | `mv` | -| `f32` | `fa0` | `fmv.s` | -| `f64` | `fa0` | `fmv.d` | - -- 返回 lowering 必须查看 IR 结果 dtype,不得固定发射 `mv a0, ...`。 -- `fa0` 不进入普通 FPR 分配池,由返回指令显式使用。 -- `f32` 在 FLEN=64 的 D 目标上依照 psABI/ISA 的 NaN-boxing 规则处理,不用 GPR 复制替代。 -- 本课题必须支持单值返回;多参数函数、多返回值和可变参数不在原始需求中。 - -#### 3.6 汇编发射 - -- `flw/fsw/fld/fsd` 使用 `value, offset(base)` 格式,base 必须是 GPR。 -- `fadd/fsub/fmul/fdiv/fsqrt` 输出选定的静态舍入模式;`fmin/fmax/fabs/fneg/feq/flt/fle/fmv` 不带 rm。 -- 常量池在 `.rodata` 中按 4/8 字节对齐,标签是编译单元局部的,正文以 `la + flw/fld` 访问。 -- 函数 `.size` 必须引用对应的函数名,多函数输出不能拿下一个标签当上一个函数名。 -- 发射后扫描不得出现 `%vreg`、非标准 `li.s/li.d`、FPR 位置上的 `t*/s*/a*` 或 GPR 位置上的 `ft*/fa*`。 +| i32 | a0 | mv | +| f32 | fa0 | fmv.s | +| f64 | fa0 | fmv.d | -#### 3.7 工具链和内置后端边界 +D 目标上的 f32 仍使用 4 字节内存表示,进入较宽浮点寄存器时遵循 NaN-boxing。按单精度执行加载和搬运,由目标指令建立正确的寄存器表示。 -- 可交付的 F/D 边界是生成能被 GNU assembler 或 Clang `--target=riscv32` 接受的 `.s`。 -- `riscv_encoder.py` 和内置 RV32 模拟器未实现 F/D 编码/执行时,对每个 F/D 操作给出包含所需扩展名的明确错误。 -- 浮点语义测试优先使用 Spike 或 QEMU;使用前记录工具、版本、ISA 和 ABI,不可用“环境没有”代替结构和汇编合法性测试。 -- libm `sqrtf/sqrt` 回退不是课题验收的必要条件;RV32IMF/D 使用 `fsqrt.s/d` 已满足需求。未实现的软浮点目标必须拒绝,不列为 Topic 28 遗留项。 +调用的机器语义声明可能被覆盖的寄存器。对处于调用者保存寄存器且跨调用仍活跃的值,在调用前保存,后续使用时按其类型恢复。函数自身使用的被调用者保存寄存器,在入口和每个返回出口分别保存与恢复。 -### 4. 实施里程碑 +函数入口统一分配栈帧,所有返回出口使用同一布局释放栈帧。栈指针在函数执行及调用边界保持 16 字节对齐。 -#### 里程碑 A:分配结果与槽位模型 +函数接口约束在分配前显式进入机器表示,栈保存序列在布局后生成: -1. 引入带 size/alignment/class 的 spill slot 和统一分配结果。 -2. 补齐 ABI 返回所需的 `FMV_S/FMV_D` 和操作数语义。 -3. 让现有 GPR-only 用例通过新结果模型,汇编不变。 -4. 对重复槽位、对齐、偏移越界和元数据缺失添加负例。 - -**出口**:不再有 `spill_slot_count * 4` 这类混合类型错误假设。 - -#### 里程碑 B:三种分配器支持 FPR - -1. naive 按操作数类别选择 scratch GPR/FPR 和 reload/spill opcode。 -2. greedy 分开两类寄存器的所有者、剩余使用和驱逐。 -3. linear 为活跃区间携带寄存器类别和宽度,各类独立扫描和 spill。 -4. 重写后执行“无 vreg + 每个物理寄存器位置类别正确”验证。 - -**出口**:同一混合 GPR/FPR 程序在 `naive|greedy|linear` 下都产生合法物理寄存器。 - -#### 里程碑 C:混合 spill、栈帧和 ABI - -1. 以字节布局接口替换 `spill_slot_count`。 -2. 实现 f32/f64 高压下的 `fsw/flw` 和 `fsd/fld`。 -3. 实现 `a0/fa0` 分类返回、`fmv.s/fmv.d` 和 call 边界活跃 FPR 保存。 -4. 验证多出口函数的每条返回路径均恢复栈帧。 - -**出口**:混合压力程序的槽位不重叠,`sp % 16 == 0`,返回值位置符合 ABI。 - -#### 里程碑 D:汇编、常量池和编译管线 - -1. 完成 F/D 语法、rm、访存格式、常量池和函数指令的发射。 -2. 删除阶段 2 的 FPR regalloc fail-closed 临时拦截,改为真实分配。 -3. 贯通 `CompilerConfig` 和 CLI,合法配置生成汇编,非法 ISA/ABI/舍入组合在代码生成前失败。 -4. 让后续 peephole、scheduler、instruction counter 和 beautifier 不损坏 F/D 指令或 `.rodata`。 - -**出口**:DSL/ONNX 的 f32/f64 用例通过主编译管线,输出无 vreg 的 `.s`。 - -#### 里程碑 E:工具链、语义和性能验证 +```python +@dataclass(frozen=True) +class ScalarSignature: + param_type: DataType | None + return_type: DataType | None -1. 使用 `clang --target=riscv32 -march=rv32imf -mabi=ilp32f` 检查 f32 输出。 -2. 使用 `clang --target=riscv32 -march=rv32imfd -mabi=ilp32d` 检查 f64 输出。 -3. 如环境有 Spike/QEMU,运行正常值、±0、±Inf、NaN、次正常值和舍入边界的语义测试;否则记录条件跳过原因。 -4. 固化指令数、FP 指令数、spill load/store、spill bytes 和预估 cycles JSON。 -5. 比较扩展开关关闭的 RV32IM 基线,不允许无关程序的汇编或静态指标退化。 +def scalar_abi(signature: ScalarSignature, target: TargetSpec) -> ABIContract: ... +def lower_abi(function: MachineFunction, target: TargetSpec) -> MachineFunction: ... +``` -**出口**:汇编合法性是必测项,语义执行按环境能力执行或明确跳过,基线数据可重现。 +`ScalarSignature` 保存可选的参数类型和返回类型。`ABIContract` 返回参数/返回物理位置、按类型搬运形式、调用覆盖集合与栈对齐。零参数或无返回值对应空位置;不能将多个值悄悄压入单个位置。 -#### 里程碑 F:文档和课题关闭 +`lower_abi` 保留原控制流,生成入口物理寄存器到虚拟值的搬运、返回值到规定位置的搬运,以及调用前后的参数/结果搬运。调用指令附带被调函数的 `ScalarSignature` 和覆盖集合;间接复杂参数传递不在此接口中扩展。固定位置只在这些使用点约束分配,不要求参数值在整个函数中一直占用 a0/fa0。该函数只处理值传递和调用约束,寄存器保存需求由分配及布局阶段确定。 -1. 新增最终阶段变更说明,按文件解释实际改动和设计原因。 -2. 在 Topic 28 主文档中发布支持矩阵、ISA/ABI 组合、DSL 示例、`mod` 语义、舍入和已知限制。 -3. 将技术设计文档状态改为“已实施”,逐条对照验收标准记录证据。 -4. 运行 Topic 28 定向测试、完整 `tests/` 和项目 L2 harness,记录命令、结果、跳过项与工具版本。 +## 优化与汇编衔接 -**出口**:下方完成清单全部勾选,Topic 28 标记完成。 +整数常量折叠和等价变换使用统一的 32 位计算规则,包含除法与余数的边界行为。浮点算术保持声明精度的逐次运算,不以宿主语言结果直接替换目标运算。 -### 5. 文件级修改计划 +优化器将浮点异常视为可观察行为。删除未使用的浮点计算、移动循环内计算或融合乘加时,需要同时保留执行条件、舍入次数和异常行为。只依据实数代数关系成立的变换不应用到本设计的浮点路径。 -| 文件 | 收尾工作 | -|---|---| -| `scratchv/backend/machine_types.py` | FPR 物理寄存器、`FMV_D`、宽度/类型元数据和分配结果类型 | -| `scratchv/backend/machine_semantics.py` | 补齐所有 F/D defs/uses、类别、调用和返回语义 | -| `scratchv/backend/register_alloc.py` | naive/greedy 分类分配、混合 spill 和 call 边界 | -| `scratchv/backend/regalloc_linear.py` | 带 class/width 的活跃区间、分类线性扫描和 spill | -| `scratchv/backend/regalloc_rewrite.py` | 按槽位元数据选择 load/store,保持操作数类别 | -| `scratchv/backend/abi_frame.py` | 混合宽度 frame layout、16 字节对齐和多出口恢复 | -| `scratchv/backend/asm_emit.py` | FP 访存、rm、复制、常量池和函数尺寸发射 | -| `scratchv/backend/riscv_encoder.py` | 对尚未支持的 F/D 编码给出确定错误 | -| `scratchv/backend/cycle_estimator.py` | F/D 类别、延迟和统计字段 | -| `scratchv/compiler.py` | 统一分配结果、frame layout、返回 lowering 和端到端管线 | -| `scratchv/main.py` | ISA/ABI/rm/regalloc 配置和错误信息 | -| `tests/test_topic28_*` | DSL/ONNX、三种分配器、spill、ABI、CLI 和端到端用例 | -| `tests/test_regalloc_*` | GPR 兼容回归和混合压力测试 | -| `tests/test_abi_frame.py` | f32/f64 槽位、对齐、多 return 和越界负例 | -| `tests/test_riscv_encoder_validation.py` | F/D 能力边界的拒绝测试 | -| Topic 28 文档 | 支持矩阵、示例、验收证据和最终变更说明 | - -`regalloc_linear_v1_5.py` 只在运行时仍有调用者时同步新合同;若已无生产入口,则明确弃用并添加调用点搜索证据,不维护两套平行实现。 - -### 6. 测试矩阵 - -#### 6.1 分配和 spill - -| 用例 | naive | greedy | linear | -|---|---:|---:|---:| -| 纯 GPR 回归 | 必测 | 必测 | 必测 | -| 纯 f32,无 spill | 必测 | 必测 | 必测 | -| 纯 f64,无 spill | 必测 | 必测 | 必测 | -| 混合 GPR/FPR | 必测 | 必测 | 必测 | -| 超过 12 个活跃 f32 | 必测 | 必测 | 必测 | -| 超过 12 个活跃 f64 | 必测 | 必测 | 必测 | -| FPR 值穿越 CALL | 必测 | 必测 | 必测 | - -每个用例同时断言:无 vreg,类别正确,槽位大小/对齐正确,没有两个同时活跃值覆盖同一槽位。 - -#### 6.2 汇编结构 - -- f32/f64 算术、`sqrt/min/max/abs`、load/store 和六种比较谓词。 -- `fa0` 返回和 `a0` 整数返回。 -- 带和不带 rm 的指令各自严格匹配参数数量。 -- `.rodata` 常量的 ±0、次正常值、±Inf 和不同 NaN 位模式。 -- 多函数、多 return、无 spill 和有 spill 栈帧。 -- 输出扫描:无 vreg、无 `li.s/li.d`、无寄存器类别泄漏。 - -#### 6.3 端到端程序 - -| 程序 | 类型 | 覆盖点 | -|---|---|---| -| 有符号除法与余数 | i32 | `div/rem`、负数、返回 `a0` | -| 求和 | f32 | 常量池、`fadd.s`、RNE、`fa0` | -| 点积 | f64 | `fmul.d/fadd.d`、load/store、spill | -| 数值工具组合 | f32/f64 | `sqrt/min/max/abs`、比较和分支 | -| ONNX 小图 | f32/f64 | ONNX dtype 保留到最终汇编 | +汇编层根据机器表示输出操作数类别、访存格式和舍入属性。四则浮点算术与平方根附加 rne;min/max、比较、符号操作和搬运按其各自的指令形式表达。 -每个程序至少运行一次主 CLI 路径;直接调用 selector 的单元测试不替代端到端验收。 +函数和基本块以不同角色管理,标签引用由对应符号生成。正文与只读常量分节表达,函数边界及大小信息从函数结构产生。后续汇编变换保留指令语义、数据区和符号引用。 -#### 6.4 负例 +```python +@dataclass(frozen=True) +class FinalMachineModule: + target: TargetSpec + functions: tuple[FinalMachineFunction, ...] + constants: tuple[ConstantEntry, ...] -- `f32` + `ilp32` 或不含 F 的 ISA。 -- `f64` + `ilp32f` 或不含 D 的 ISA。 -- D 存在但 F 缺失的非法 ISA。 -- 混合 dtype、同一 vreg 的 GPR/FPR 冲突和 FPR 宽度缺失。 -- 非法舍入模式和不需 rm 的指令携带 rm。 -- 栈偏移越界、未分配 vreg 泄漏、内置编码器接收 F/D 指令。 +def emit_assembly(module: FinalMachineModule) -> str: ... +``` -### 7. 验证命令 +`FinalMachineModule` 包含目标描述、有序的 `FinalMachineFunction` 和冻结的常量条目。`emit_assembly` 检查操作数类别、舍入属性和符号引用是否满足目标形式,再输出函数、基本块、指令与只读数据;不补做指令选择、分配或栈布局。模块内每个引用有唯一符号身份,函数大小仅覆盖其代码范围。返回值为完整汇编文本,文件写入由编译入口负责。 -实施时在仓库环境中确认具体命令可用性,验收至少包含: +## 编译入口与调用顺序 -```bash -pytest -q tests/test_topic28_* tests/test_inst_select_ext.py \ - tests/test_regalloc_*.py tests/test_abi_frame.py \ - tests/test_riscv_encoder_validation.py +扩展路径提供一个接收类型明确 IR 的入口: -pytest -q tests/ +```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) +``` -python .claude/harness/verify/run.py --level L2 +IR 优化在该入口前执行,并在优化后的 IR 上重新验证类型与结构。各后端阶段接收上一阶段的结构化结果,返回新的结果,不改写调用方的 IR,也不复用上一函数的分配或栈布局状态。IR 构造器、常量池、函数选择上下文和分配过程在各自作用域内维护可变状态;交出的跨阶段结果不再由生产者修改。 -clang --target=riscv32 -march=rv32imf -mabi=ilp32f \ - -c topic28_f32.s -o topic28_f32.o +例如,用 f64 常量构造除法与平方根,类型经以下接口传到目标代码: -clang --target=riscv32 -march=rv32imfd -mabi=ilp32d \ - -c topic28_f64.s -o topic28_f64.o +```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, +) ``` -如 harness 或交叉工具链在当前环境中缺失,最终变更说明要记录精确命令、缺失路径/可执行文件和替代验证结果,不得写成“全部通过”。结构测试可以继续,但“交叉汇编通过”勾选项必须等到有工具的环境产生真实证据后才能勾选。 +该调用序列中,IR 保留 f64 类型,选择得到双精度运算及常量引用,函数接口约束返回位置为 fa0,分配和布局完成寄存器及存储安排,汇编表达输出已确定的目标形式。 -### 8. 完成清单 +### 错误与诊断接口 -#### 功能 +模块在不能满足输入合同的位置抛出 `CompileError` 的具体子类,每个异常携带 `Diagnostic`。诊断记录阶段、消息,以及可获得的源文件/行列、函数、块和操作;机器指令通过 `origin` 延续 IR 来源。 -- [ ] `i32 div/rem/min/max/abs` 端到端可用。 -- [ ] `f32/f64 add/sub/mul/div/neg/abs/min/max/sqrt` 端到端可用。 -- [ ] `f32/f64 load/store` 和六种比较谓词端到端可用。 -- [ ] DSL 与课题范围内 ONNX 映射保留 `f32/f64` dtype。 - -#### 后端正确性 - -- [ ] naive、greedy、linear 全部支持 GPR/FPR。 -- [ ] f32/f64 spill 的 opcode、大小、对齐和偏移正确。 -- [ ] 混合栈帧 16 字节对齐,所有 return 路径完整恢复。 -- [ ] `a0/fa0`、caller-saved FPR 和 FPR move 符合 ABI。 -- [ ] 常量池保留 IEEE 754 位模式,汇编无非标准 FP 立即数伪指令。 -- [ ] 输出无 vreg、无寄存器类别错误、无静默跳过的未知 MachineOp。 +| 错误类型 | 触发位置 | 诊断内容 | +|---|---|---| +| `IRValidationError` | 类型解析、常量编码、IR 构造与验证 | 未支持类型、错误元数、混合精度、地址/返回类型或标量范围不合法 | +| `TargetConfigError` | `make_target` | ISA、扩展依赖或 ABI 组合不合法 | +| `UnsupportedLoweringError` | 操作可用性检查及目标选择 | 操作与类型组合未支持,或目标缺少必要扩展 | +| `RegisterAllocationError` | 分配与溢出改写 | 固定寄存器约束或临时寄存器需求无法满足 | +| `FrameLayoutError` | 栈布局和最终地址解析 | 布局对象不合法,栈调整或访存偏移超出直接编码范围 | +| `MachineValidationError` | 机器信息查询及汇编表达 | 操作数类别、舍入属性、符号引用或阶段结果不合法 | -#### 集成和交付 +构造 API 在失败前不追加该条指令,后端阶段失败时不交出部分结果。异常由外层编译入口统一转换为用户诊断,内部模块不吞掉异常、默认切换精度或把失败当作空输出。完整汇编文本生成后再执行输出写入,避免类型或目标错误留下半份文件。 -- [ ] CLI/CompilerConfig/CompilerDriver 的 selector、ISA、ABI、rm 和 regalloc 配置一致。 -- [ ] f32 和 f64 输出均通过 RV32 交叉汇编器。 -- [ ] 浮点语义测试已执行,或按能力探测明确记录跳过。 -- [ ] Topic 28 定向测试、完整回归和 L2 验证已记录。 -- [ ] RV32IM 旧路径的汇编和静态基线无退化。 -- [ ] 支持矩阵、数据类型、ISA/ABI、舍入、`mod` 语义、示例和限制文档已发布。 -- [ ] 技术设计的 16 条验收标准均有实现位置和测试证据。 +## 实现依赖与顺序 -### 9. 明确不延后的项目 +实现按数据依赖推进,使每一步建立的表示能被下一步直接使用: -以下内容都在阶段 3 内完成,不再推到“后续阶段”: +1. 建立数值类型描述和操作签名,完成 DSL/ONNX 到类型明确 IR 的归一化;同时确定整数与浮点数值语义。 +2. 接入统一目标描述,整理整数选择规则,补充余数与整数值选择;整数常量处理沿用相同的 32 位语义。 +3. 扩充机器操作数的类别和宽度信息,加入常量引用;据此完成 f32/f64 算术、访存和比较规则。 +4. 将参数、返回及调用约束接入机器表示,再将寄存器类别、活跃关系和按类型搬运接入分配过程,形成统一的溢出位置描述。 +5. 汇总局部数据、溢出及寄存器保存需求,完成每函数栈布局、入口出口和最终地址解析。 +6. 将目标选择、分配、布局及汇编表达连接为一条编译路径,并将类型与浮点语义约束传递给前后各类优化。 -- FPR 分配和分类活跃分析。 -- f32/f64 spill/reload 和混合栈帧。 -- `fa0` 返回、FPR 复制和 call 边界保存。 -- 浮点汇编格式、常量池和静态舍入。 -- CLI 集成、交叉汇编、完整回归、性能基线和用户文档。 +## 规范依据 -课题外的内置 F/D 二进制编码、完整软浮点库、向量化、多返回值和大栈帧寻址可以作为新课题,但它们不影响 Topic 28 按原始要求关闭。 +- [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 a344ecf..590e2a6 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" @@ -33,6 +33,4 @@ ## 实现文档 -- [技术设计文档(含阶段 1/2/3 设计与实施规划)](28-扩展指令选择-设计文档.md) -- [阶段 1 变更说明:整数闭环与类型合同](28-扩展指令选择-阶段1变更说明.md) -- [阶段 2 变更说明:f32/f64 IR 与类型化指令选择](28-扩展指令选择-阶段2变更说明.md) +- [技术设计:数值语义、架构与 API](28-扩展指令选择-设计文档.md) From 2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1 Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Sun, 11 Oct 2026 23:17:16 +0800 Subject: [PATCH 04/13] feat(topic28): add RISC-V operators and float64 support --- .github/workflows/ci.yml | 3 + benchmarks/topic28/f32_sum.dsl | 4 + benchmarks/topic28/f64_dot.dsl | 8 + benchmarks/topic28/f64_sum.dsl | 6 + benchmarks/topic28/i32_div_rem.dsl | 6 + .../28-README.md" | 55 ++ ...30\344\270\216\351\252\214\346\224\266.md" | 50 ++ ...07\344\273\244\351\200\211\346\213\251.md" | 4 +- scratchv/analysis/ir_verifier.py | 31 +- scratchv/backend/abi_frame.py | 114 ++- scratchv/backend/allocation.py | 101 +++ scratchv/backend/asm_beautifier.py | 22 + scratchv/backend/asm_emit.py | 111 ++- scratchv/backend/asm_parser_for_beautifier.py | 11 + scratchv/backend/cycle_estimator.py | 6 +- scratchv/backend/inst_select_ext.py | 832 +++++++++++++----- scratchv/backend/instruction_select.py | 211 +++-- scratchv/backend/machine_semantics.py | 182 +++- scratchv/backend/machine_types.py | 181 +++- scratchv/backend/regalloc_linear.py | 108 ++- scratchv/backend/regalloc_linear_v1_5.py | 5 + scratchv/backend/regalloc_metrics.py | 4 +- scratchv/backend/regalloc_rewrite.py | 135 ++- scratchv/backend/register_alloc.py | 142 +-- scratchv/backend/riscv_encoder.py | 8 + scratchv/backend/symbols.py | 44 + scratchv/backend/target_features.py | 37 + scratchv/compiler.py | 143 ++- scratchv/frontend/dsl_extended.py | 50 +- scratchv/frontend/dsl_lexical.py | 31 + scratchv/frontend/dsl_parser.py | 48 +- scratchv/frontend/dsl_validator.py | 39 +- scratchv/frontend/onnx_bits.py | 120 +++ scratchv/frontend/onnx_parser.py | 128 ++- scratchv/ir/builder.py | 135 ++- scratchv/ir/types.py | 33 +- scratchv/main.py | 17 +- scratchv/optimizer/constant_folding.py | 94 +- scratchv/optimizer/dead_code.py | 42 +- scratchv/optimizer/licm.py | 33 +- scratchv/optimizer/muladd_fusion.py | 84 +- scratchv/optimizer/peephole.py | 58 +- scratchv/simulator/rv32_emulator.py | 130 ++- scratchv/verification/division_reference.py | 34 + scratchv/verification/fp_reference.py | 43 + scratchv/verification/verifier.py | 50 +- tests/test_compare_peephole.py | 21 +- tests/test_inst_select_ext.py | 121 ++- tests/test_ir_verifier.py | 24 +- tests/test_llvm_codegen.py | 2 +- tests/test_optimizer_advanced.py | 49 +- tests/test_regalloc_pseudo.py | 16 +- tests/test_rv32_emulator.py | 33 + tests/test_topic28_acceptance.py | 138 +++ tests/test_topic28_cli_integration.py | 179 ++++ tests/test_topic28_constant_folding.py | 105 +++ tests/test_topic28_fifth_review.py | 281 ++++++ tests/test_topic28_fourth_review.py | 293 ++++++ tests/test_topic28_independent_boundaries.py | 205 +++++ tests/test_topic28_independent_division.py | 60 ++ tests/test_topic28_independent_integer.py | 113 +++ tests/test_topic28_ir_frontend.py | 206 +++++ tests/test_topic28_isel_integer.py | 203 +++++ tests/test_topic28_overall_review.py | 296 +++++++ tests/test_topic28_review_backend.py | 119 +++ tests/test_topic28_review_fixed_uses.py | 60 ++ tests/test_topic28_review_frontend_oracles.py | 258 ++++++ tests/test_topic28_review_onnx.py | 173 ++++ tests/test_topic28_review_optimizations.py | 170 ++++ tests/test_topic28_sixth_review.py | 249 ++++++ tests/test_topic28_stage2.py | 431 +++++++++ tests/test_topic28_stage3.py | 349 ++++++++ tests/test_topic28_third_review.py | 359 ++++++++ tests/test_verification.py | 91 ++ tests/topic28_runtime.py | 201 +++++ tests/topic28_support.py | 35 + 76 files changed, 7758 insertions(+), 815 deletions(-) create mode 100644 benchmarks/topic28/f32_sum.dsl create mode 100644 benchmarks/topic28/f64_dot.dsl create mode 100644 benchmarks/topic28/f64_sum.dsl create mode 100644 benchmarks/topic28/i32_div_rem.dsl create mode 100644 "docs/topics/28-\346\211\251\345\261\225\346\214\207\344\273\244\351\200\211\346\213\251/28-README.md" create mode 100644 "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" create mode 100644 scratchv/backend/allocation.py create mode 100644 scratchv/backend/symbols.py create mode 100644 scratchv/backend/target_features.py create mode 100644 scratchv/frontend/dsl_lexical.py create mode 100644 scratchv/frontend/onnx_bits.py create mode 100644 scratchv/verification/division_reference.py create mode 100644 scratchv/verification/fp_reference.py create mode 100644 tests/test_topic28_acceptance.py create mode 100644 tests/test_topic28_cli_integration.py create mode 100644 tests/test_topic28_constant_folding.py create mode 100644 tests/test_topic28_fifth_review.py create mode 100644 tests/test_topic28_fourth_review.py create mode 100644 tests/test_topic28_independent_boundaries.py create mode 100644 tests/test_topic28_independent_division.py create mode 100644 tests/test_topic28_independent_integer.py create mode 100644 tests/test_topic28_ir_frontend.py create mode 100644 tests/test_topic28_isel_integer.py create mode 100644 tests/test_topic28_overall_review.py create mode 100644 tests/test_topic28_review_backend.py create mode 100644 tests/test_topic28_review_fixed_uses.py create mode 100644 tests/test_topic28_review_frontend_oracles.py create mode 100644 tests/test_topic28_review_onnx.py create mode 100644 tests/test_topic28_review_optimizations.py create mode 100644 tests/test_topic28_sixth_review.py create mode 100644 tests/test_topic28_stage2.py create mode 100644 tests/test_topic28_stage3.py create mode 100644 tests/test_topic28_third_review.py create mode 100644 tests/topic28_runtime.py create mode 100644 tests/topic28_support.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e536dc..ebafc41 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/benchmarks/topic28/f32_sum.dsl b/benchmarks/topic28/f32_sum.dsl new file mode 100644 index 0000000..f2f4275 --- /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 0000000..c8426df --- /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 0000000..2596101 --- /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 0000000..b022039 --- /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 0000000..7fb3f72 --- /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,55 @@ +# Topic 28 使用指南 + +本课题完成整数算子扩展及标量 f32/f64 的 RISC-V F/D 指令选择。交付与完成条件见[交付与验收](28-交付与验收.md),详细合同见[技术设计](28-扩展指令选择-设计文档.md)。 + +## 支持的操作和类型 + +所有新增能力通过 `--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`(最近值、相同距离取偶数)。`min/max` 使用 RISC-V 的 NaN、符号零及异常标志语义,不能用 Python `min/max` 直接替代。 +- 硬浮点交付产物是合法 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 0000000..46423ac --- /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,50 @@ +# Topic 28 交付与验收 + +更新:2026-10-11。交付按[原始任务](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。 + +```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-\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 590e2a6..f208cad 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" @@ -31,6 +31,8 @@ - **W11**:为`sqrt`、`min`/`max`等添加指令选择(可使用库调用或硬件指令)。 - **W12**:更新文档,撰写新算子、新类型的使用指南。 -## 实现文档 +## 相关文档 - [技术设计:数值语义、架构与 API](28-扩展指令选择-设计文档.md) +- [使用指南:支持操作、类型与示例](28-README.md) +- [交付与验收:原始要求与测试入口](28-交付与验收.md) diff --git a/scratchv/analysis/ir_verifier.py b/scratchv/analysis/ir_verifier.py index d640137..f13fc33 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 2f9deca..89f1192 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 0000000..9cecc60 --- /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 1760166..d403cb1 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 ecbfe27..384afa7 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 7e15040..88bf8f3 100755 --- a/scratchv/backend/asm_parser_for_beautifier.py +++ b/scratchv/backend/asm_parser_for_beautifier.py @@ -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( """ diff --git a/scratchv/backend/cycle_estimator.py b/scratchv/backend/cycle_estimator.py index 1f86d79..cb56f33 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 372ca1e..57cc5ab 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,87 +16,267 @@ 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 _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 @@ -106,49 +286,112 @@ 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 == DataType.FLOAT64: + self._require_fp_extension(OpCode.MIN, dtype) + self._emit( + MachineOp.FMIN_D, self._fp_dst(instr), + self._fp_op(instr, 0), self._fp_op(instr, 1), + comment="fmin.d", + ) + elif dtype == DataType.FLOAT32: + self._require_fp_extension(OpCode.MIN, dtype) + self._emit( + MachineOp.FMIN_S, self._fp_dst(instr), + self._fp_op(instr, 0), self._fp_op(instr, 1), + comment="fmin.s", + ) + 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.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, and_tmp, tmp, diff, comment="min: and" + 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 == DataType.FLOAT64: + self._require_fp_extension(OpCode.MAX, dtype) + self._emit( + MachineOp.FMAX_D, self._fp_dst(instr), + self._fp_op(instr, 0), self._fp_op(instr, 1), + comment="fmax.d", + ) + elif dtype == DataType.FLOAT32: + self._require_fp_extension(OpCode.MAX, dtype) + self._emit( + MachineOp.FMAX_S, self._fp_dst(instr), + self._fp_op(instr, 0), self._fp_op(instr, 1), + comment="fmax.s", + ) + 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 +405,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 +435,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 +755,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 4b15f8d..2f78ad3 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -10,6 +10,7 @@ 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 +26,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 +52,84 @@ 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 func, labels in zip(self.program.functions, block_labels): + self._block_labels = labels self._select_function(func) return self._instructions + 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 +140,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 +183,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 +194,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 +233,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 +303,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 +328,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 +361,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 +382,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 +406,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 +443,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 +465,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 +473,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 +524,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 +541,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 +564,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 +577,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 +589,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/machine_semantics.py b/scratchv/backend/machine_semantics.py index ac38523..68deea9 100644 --- a/scratchv/backend/machine_semantics.py +++ b/scratchv/backend/machine_semantics.py @@ -12,7 +12,15 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from scratchv.backend.machine_types import ARG_REGS, TEMP_REGS, MachineOp +from scratchv.backend.machine_types import ( + ARG_REGS, + TEMP_REGS, + FP_TEMP_REGS, + FP_ARG_REGS, + MachineOp, + RegisterClass, + physical_register_class, +) if TYPE_CHECKING: from scratchv.backend.machine_types import MachineInstr @@ -43,24 +51,62 @@ class MachineOpSemantics: n_phys: int = 0 is_pseudo: bool = False is_label: bool = False + operand_classes: tuple[ + RegisterClass | None, + RegisterClass | None, + RegisterClass | None, + ] = (None, None, None) -_DEF_USE_USE = MachineOpSemantics(defs=(0,), uses=(1, 2), n_phys=3) -_DEF_USE = MachineOpSemantics(defs=(0,), uses=(1,), n_phys=2) -_STORE = MachineOpSemantics(uses=(0, 1), n_phys=2) +_GPR = RegisterClass.GPR +_FPR = RegisterClass.FPR +_DEF_USE_USE = MachineOpSemantics( + defs=(0,), uses=(1, 2), n_phys=3, + operand_classes=(_GPR, _GPR, _GPR), +) +_DEF_USE = MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, + operand_classes=(_GPR, _GPR, None), +) +_STORE = MachineOpSemantics( + uses=(0, 1), n_phys=2, + operand_classes=(_GPR, _GPR, None), +) +_FP_DEF_USE_USE = MachineOpSemantics( + defs=(0,), uses=(1, 2), n_phys=3, + operand_classes=(_FPR, _FPR, _FPR), +) +_FP_DEF_USE = MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, + operand_classes=(_FPR, _FPR, None), +) +_FP_COMPARE = MachineOpSemantics( + defs=(0,), uses=(1, 2), n_phys=3, + operand_classes=(_GPR, _FPR, _FPR), +) +_FP_LOAD = MachineOpSemantics( + defs=(0,), uses=(1,), n_phys=2, + operand_classes=(_FPR, _GPR, None), +) +_FP_STORE = MachineOpSemantics( + uses=(0, 1), n_phys=2, + operand_classes=(_FPR, _GPR, None), +) _NO_REGISTERS = MachineOpSemantics() OP_SEM: dict[MachineOp, MachineOpSemantics] = { MachineOp.ADD: _DEF_USE_USE, MachineOp.ADDI: MachineOpSemantics( - defs=(0,), uses=(1,), immediate_positions=(2,), n_phys=2 + defs=(0,), uses=(1,), immediate_positions=(2,), n_phys=2, + operand_classes=(_GPR, _GPR, None), ), MachineOp.SUB: _DEF_USE_USE, MachineOp.MUL: _DEF_USE_USE, MachineOp.DIV: _DEF_USE_USE, MachineOp.SRAI: MachineOpSemantics( - defs=(0,), uses=(1,), immediate_positions=(2,), n_phys=2 + defs=(0,), uses=(1,), immediate_positions=(2,), n_phys=2, + operand_classes=(_GPR, _GPR, None), ), MachineOp.XOR: _DEF_USE_USE, MachineOp.AND: _DEF_USE_USE, @@ -68,14 +114,15 @@ class MachineOpSemantics: MachineOp.REM: _DEF_USE_USE, MachineOp.LW: _DEF_USE, MachineOp.SW: _STORE, - MachineOp.FLD: _DEF_USE, - MachineOp.FSD: _STORE, + MachineOp.FLD: _FP_LOAD, + MachineOp.FSD: _FP_STORE, # mv rd, rs -> 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 b132e8f..5830ca4 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 0e8b58f..810ef86 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 81138f9..e3ee74b 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 a074145..8326a9b 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 98ed9bc..6a7fe40 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: @@ -36,15 +37,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) ) @@ -53,6 +100,36 @@ def rewrite_with_spills(allocator: Any, instructions: list[Any]) -> str: stack_current: set[str] = set() rename: dict[str, str] = {} lines: list[str] = [] + allocator._allocated_instructions = [] + + 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) @@ -74,9 +151,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 +173,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 +221,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 +229,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 +245,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 +260,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 +303,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 +345,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 +363,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 2bfe414..b409448 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 79c14d3..39050a8 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/symbols.py b/scratchv/backend/symbols.py new file mode 100644 index 0000000..e5ab990 --- /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 0000000..7a07493 --- /dev/null +++ b/scratchv/backend/target_features.py @@ -0,0 +1,37 @@ +"""ISA/ABI requirements for scalar RISC-V instruction selection.""" + +from dataclasses import dataclass + +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.removeprefix("rv32")) + + 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 ffaa3a2..9487bb2 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,46 @@ 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.verify_ir: self._check_ir(program, "after-parse") @@ -384,6 +439,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 +490,20 @@ def _run_optimizations(self, program: Program) -> PipelineResult: passes=self.config.passes, disabled_passes=self.config.disabled_passes, ) + if self.config.use_extended_isel: + 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. + # 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 +555,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 +590,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 +615,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 +671,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 +684,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 37996bf..e46f806 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 0000000..f962256 --- /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 3d57e9c..009bafc 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 ca0db58..22cf055 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 0000000..7178ae2 --- /dev/null +++ b/scratchv/frontend/onnx_bits.py @@ -0,0 +1,120 @@ +"""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 + + +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 b6af645..af18519 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,37 @@ 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) + 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 e49808d..b48002d 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,105 @@ 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, + ) -> 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]) 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) -> Value: + return self._binary_arith( + OpCode.MIN, lhs, rhs, _MIN_MAX_ABS_TYPES, + ) + + def max(self, lhs: Value, rhs: Value) -> Value: + return self._binary_arith( + OpCode.MAX, lhs, rhs, _MIN_MAX_ABS_TYPES, + ) 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 +255,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 059f73d..3b5c732 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 d1b831a..8fcca2a 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 197ab78..2ae8844 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, including explicitly selected 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 f2e5485..c84de12 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 4d9b254..37cd1ba 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 ac79cfa..6ea826b 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 8ec7a7f..ba55751 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 98b9bc4..9ff18be 100644 --- a/scratchv/simulator/rv32_emulator.py +++ b/scratchv/simulator/rv32_emulator.py @@ -10,6 +10,32 @@ import struct import numpy as np +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 +275,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 +293,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 +341,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: @@ -374,8 +434,8 @@ 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__( @@ -405,14 +465,19 @@ 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) + dtype = { + "i32": np.int32, + "f32": np.float32, + "f64": np.float64, + }.get(val.dtype.value, np.float32) + return np.array(val.const_value, dtype=dtype) 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 +505,43 @@ 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.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.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 @@ -475,7 +576,12 @@ def _op_sigmoid(self, instr): def _op_load_const(self, instr): val = instr.attrs.get("value", 0) - result = np.array(float(val), dtype=np.float32) + dtype = { + "i32": np.int32, + "f32": np.float32, + "f64": np.float64, + }.get(instr.dest.dtype.value if instr.dest else "f32", np.float32) + result = np.array(val, dtype=dtype) if instr.dest: self._values[instr.dest.name] = result return result @@ -648,10 +754,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 0000000..3bf8373 --- /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 0000000..80dec2e --- /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 03d1508..b214184 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/tests/test_compare_peephole.py b/tests/test_compare_peephole.py index 6b2489f..285a727 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_inst_select_ext.py b/tests/test_inst_select_ext.py index 911f4fb..f19ac9f 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_verifier.py b/tests/test_ir_verifier.py index 8ccfc84..7333ab1 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 2b22210..ace6f8f 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(i, i) endfor return y """ diff --git a/tests/test_optimizer_advanced.py b/tests/test_optimizer_advanced.py index 44c2bc9..184d84c 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 4818359..9322703 100644 --- a/tests/test_regalloc_pseudo.py +++ b/tests/test_regalloc_pseudo.py @@ -28,9 +28,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, } @@ -227,6 +231,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 +240,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_rv32_emulator.py b/tests/test_rv32_emulator.py index b669529..356c229 100644 --- a/tests/test_rv32_emulator.py +++ b/tests/test_rv32_emulator.py @@ -29,3 +29,36 @@ 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 + + +def test_div_and_rem_use_signed_riscv_semantics() -> 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() -> 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_topic28_acceptance.py b/tests/test_topic28_acceptance.py new file mode 100644 index 0000000..fa9da75 --- /dev/null +++ b/tests/test_topic28_acceptance.py @@ -0,0 +1,138 @@ +"""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(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 0000000..053d5e5 --- /dev/null +++ b/tests/test_topic28_cli_integration.py @@ -0,0 +1,179 @@ +"""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 + + +def test_extended_isel_cli_flag_reaches_compiler_config(): + 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(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(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( + 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( + 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( + 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( + 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( + 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 0000000..6ac1d56 --- /dev/null +++ b/tests/test_topic28_constant_folding.py @@ -0,0 +1,105 @@ +"""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 + + +@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(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(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(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(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(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 0000000..26b2c0f --- /dev/null +++ b/tests/test_topic28_fifth_review.py @@ -0,0 +1,281 @@ +"""Fifth-review regressions: source bytes, symbols, ELF metadata and ABI frames.""" + +import re +import struct +import subprocess + +import onnx +import pytest +from onnx import TensorProto as TP, helper + +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() + + +@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(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 + two = 0x40000000 if dtype is T.FLOAT32 else 0x4000000000000000 + expected = (raw, 0) if operation == "return" or pattern == "negative_zero" else ( + two, 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(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(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(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(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(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(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(): + 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(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(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(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(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(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 0000000..0bbb081 --- /dev/null +++ b/tests/test_topic28_ir_frontend.py @@ -0,0 +1,206 @@ +"""Extended instruction-selection IR and DSL coverage.""" + +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 test_builder_propagates_arithmetic_result_types(): + 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(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(): + 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(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(): + 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(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(source): + collector = DSLParser().validate(source) + assert [error.error_code for error in collector.errors] == ["E203"] + with pytest.raises(DSLSyntaxError): + DSLParser().parse(source) + + +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 + + +@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(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( + 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 0000000..095fd3b --- /dev/null +++ b/tests/test_topic28_isel_integer.py @@ -0,0 +1,203 @@ +"""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 + + +@pytest.mark.parametrize( + "selector_type", [InstructionSelector, ExtendedInstructionSelector] +) +def test_div_materializes_both_constant_sources(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() -> 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( + 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( + 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() -> 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() -> 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_overall_review.py b/tests/test_topic28_overall_review.py new file mode 100644 index 0000000..ff41185 --- /dev/null +++ b/tests/test_topic28_overall_review.py @@ -0,0 +1,296 @@ +"""G1-G5: ABI, optimizer and compilation-unit contracts with real execution.""" + +import struct + +import numpy as np +import pytest + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.backend.inst_select_ext import ExtendedInstructionSelector +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as T, OpCode +from scratchv.optimizer.dead_code import DeadCodeEliminator +from tests.topic28_runtime import execute_fp, execute_i32 + + +MODES = ("naive", "greedy", "linear") +LEVELS = ("none", "basic", "all") +FLOATS = ((32, T.FLOAT32), (64, T.FLOAT64)) + + +def config(mode="greedy", level="none", verify=True, **kwargs): + return CompilerConfig(use_extended_isel=True, reg_alloc=mode, + optimize_level=level, verify_ir=verify, + target_isa="rv32imfd", target_abi="ilp32d", **kwargs) + + +def compile_ir(program, tmp_path, configuration): + driver = CompilerDriver(configuration) + 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(" 3", 9)]) +def test_r5_integer_control_flow_constant_stores_are_legal(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 + + +def _fp(name, width): + return P.vreg(name, RC.FPR, width) + + +@pytest.mark.parametrize("alias", ["ft0", "f0"]) +def test_r8_report_literal_stream_allocates_after_physical_clobber(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(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(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(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(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(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) + + +def run_reference(source, implementation): + if implementation == "dsl": + return DSLInterpreter().run(source, {}) + return IRTraceExecutor(DSLParser().parse(source)).run({}) + + +@pytest.mark.parametrize("implementation", ["dsl", "ir"]) +@pytest.mark.parametrize("dtype", ["f32", "f64"]) +def test_r7_sqrt_reference_is_defined_and_preserves_precision(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 + + +FP_MIN_MAX_CASES = [ + ("min", "nan", "4", 4.0), ("min", "4", "nan", 4.0), + ("max", "nan", "4", 4.0), ("max", "4", "nan", 4.0), + ("min", "-0.0", "0.0", -0.0), ("min", "0.0", "-0.0", -0.0), + ("max", "-0.0", "0.0", 0.0), ("max", "0.0", "-0.0", 0.0), + ("min", "nan", "nan", float("nan")), + ("max", "nan", "nan", float("nan")), + ("min", "1.0000000000000002", "1.0000000000000004", 1.0000000000000002), + ("max", "1.0000000000000002", "1.0000000000000004", 1.0000000000000004), +] + + +@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( + 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(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(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(): + 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(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(): + 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"] + + +def compile_source(source, tmp_path, width, mode): + path = tmp_path / "review.dsl" + path.write_text(source) + result = CompilerDriver(CompilerConfig( + use_extended_isel=True, verify_ir=True, reg_alloc=mode, + target_isa="rv32im" if width == 0 else "rv32imfd", + target_abi="ilp32" if width == 0 else "ilp32d", + )).compile(str(path)) + assert result.success, result.errors + return result.output_text + + +@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(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(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(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(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 0000000..24b12c1 --- /dev/null +++ b/tests/test_topic28_review_onnx.py @@ -0,0 +1,173 @@ +"""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) + + +@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(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(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 + two = 0x40000000 if nt == TP.FLOAT else 0x4000000000000000 + assert actual == ((raw, 0) if operation == "return" else (two, 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(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 + assert actual == (0x40000000 if nt == TP.FLOAT else 0x4000000000000000, 0x10) + + +@pytest.mark.parametrize("target", TARGETS) +@pytest.mark.parametrize("storage", ["raw", "external"]) +def test_t12_actual_raw_and_external_storage_remain_bit_exact(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) + + +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 + + +@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(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(): + 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(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(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 0000000..e2c92c6 --- /dev/null +++ b/tests/test_topic28_stage2.py @@ -0,0 +1,431 @@ +"""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 + ) + + +def test_sqrt_reaches_typed_ir_from_builder_and_dsl(): + 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(): + 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( + 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(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(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(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(): + 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(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(): + 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(): + 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(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(): + with pytest.raises(ValueError, match="ISA/ABI combination"): + TargetFeatures("rv32im", "ilp32d") + + +def test_missing_target_extension_fails_before_selection(): + 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(): + 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(): + 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(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(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(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(): + 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(): + 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(): + 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(): + 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 0000000..e72c826 --- /dev/null +++ b/tests/test_topic28_stage3.py @@ -0,0 +1,349 @@ +"""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 + + +@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(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 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")) + + +def test_metadata_missing_width_conflict_and_wrong_physical_class_rejected(): + 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(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(): + with pytest.raises(ValueError, match="Unknown instruction: fence"): + RISCVAEncoder().assemble("fence rw,rw") + + +def test_builtin_emulator_rejects_floating_binary(): + 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(): + 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(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(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(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(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(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(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(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(): + 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(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(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 0000000..a8b2238 --- /dev/null +++ b/tests/test_topic28_third_review.py @@ -0,0 +1,359 @@ +"""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 0000000..96b25de --- /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() From 492bcfbe15bcc61e24b091e7ec9f89c9d7c004b2 Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Mon, 12 Oct 2026 17:51:34 +0800 Subject: [PATCH 05/13] fix(llvm): reject unsupported integer lowering --- ...iew\346\212\245\345\221\212-2026-10-12.md" | 448 ++++++++++++++++++ scratchv/backend/llvm_codegen.py | 35 +- scratchv/compiler.py | 11 + tests/test_llvm_codegen.py | 2 +- tests/test_llvm_input_types.py | 132 ++++++ 5 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 "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" create mode 100644 tests/test_llvm_input_types.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\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 0000000..f9ad703 --- /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,448 @@ +# Topic 28 代码 Review 报告 + +Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-design`,提交 `2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1`。比较基线:`f3af99d`,即本工作区 `main` / `origin/main` 指向的提交。 + +**结论:建议修改后合并。大部分跨模块改动具有正确性上的必要性。审查提交共发现 4 个已复现问题;R1 的 LLVM 假成功已按下文方案修复,R2–R4 仍待处理。** 不能因为全量测试通过,就认为共享后端、每函数命名空间和可复用 API 已覆盖完整。 + +本报告保留对上述提交的原始审查结果,并在 R1 后补记修复方案及验证结果。本次提交包含报告、R1 修复代码和回归测试;复现脚本及原始 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 ELF 执行结果。本地复现脚本记录被审查提交的缺陷行为,**不是应加入 CI 的成功行为测试**;R1 修复后的正确行为已纳入对应回归测试。 + +### 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`;脚本及原始 JSON 不包含在本次提交中,关键输入、输出和结果见本报告第 2 节。 + +以上验证需要项目/测试依赖、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`,所以没有执行该 Harness 的 L2 命令;上述 pytest 和独立执行检查是实际完成的验证。 + +## 2. 已确认的问题 + +优先级含义:P1 为影响常见有效输入、应优先修复的问题;P2 为有明确复现条件、需要正常修复的问题。以下不是推测或仅凭代码风格提出的建议。 + +| 编号 | 优先级 | 问题 | 影响范围 | +|---|---|---|---| +| R1 | P1 | 共享前端接入 i32 后,LLVM 编译仍返回成功,但输出非法 IR | 新 typed DSL;已有 ONNX i32 Add 路径 | +| R2 | P2 | 值名/局部槽按整个程序管理,与函数内合法重名冲突 | 多函数 IR,三种分配器 | +| R3 | P2 | IR 参照执行器忽略权威 `bit_pattern` | 显式编码的 f32/f64 常量 | +| R4 | P2 | 线性分配器复用后,空输入返回旧指令 | 新 `allocate_result` API 的复用 | + +### R1:LLVM 对新接入的 i32 输入给出假成功 + +**变更位置:**[dsl_parser.py](../../../scratchv/frontend/dsl_parser.py#L170) 的 typed `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`。 + +复现输入: + +```text +a = const(7,dtype:i32) +b = const(3,dtype:i32) +r = add(a,b) +return r +``` + +使用 `CompilerConfig(backend="llvm", optimize_level=...)`,不启用 `use_extended_isel`。`none/basic/all` 三种级别均返回 `success=True`、空错误列表,却分别输出如下语句: + +```llvm +; none +%v_1_1 = fadd i32 7, 0.0 +; basic / all +%v_3_1 = fadd i32 10, 0.0 +``` + +`llvmlite.binding.parse_assembly(...).verify()` 直接失败:`floating point constant invalid for type`。`fadd` 只接受浮点或浮点向量类型,不能用来构造 i32 常量或做整数加法。[LLVM fadd 合同](https://llvm.org/docs/LangRef.html#fadd-instruction) + +**为什么属于本次变更的集成问题:**前端已对所有后端开放新 `const` 语法,而编译入口的 extended-op 拒绝集合只有 `rem/min/max/abs/sqrt`,不会阻止 i32 const/add 进入 LLVM。`--extended-isel` 与 LLVM 互斥只约束选择器开关,不能保护已经共享的前端和 IR 变化。 + +还构造了经 `onnx.checker.check_model` 验证的、两个 INT32 标量 initializer 相加的 ONNX 模型。同一模型在基线 `f3af99d` 的 `basic/all` 下可通过 LLVM 解析,在 HEAD 下全部失败。需要准确区分:**基线 `none` 已存在此类非法整数常量发射问题;基线优化后通过解析,也不表示其 ONNX 返回类型语义完全正确。** 本次移除前端宿主折叠后,将这一未适配的后端问题扩展到了原来可输出合法 LLVM 文本的默认优化路径。 + +| 同一合法 ONNX INT32 Add 模型 | 基线 LLVM 解析 | HEAD LLVM 解析 | 两者 CompilerDriver 返回值 | +|---|---|---|---| +| none | 失败 | 失败 | 均 success=True | +| basic | 通过 | 失败 | 均 success=True | +| all | 通过 | 失败 | 均 success=True | + +**影响:**调用者拿到“成功”的 `.ll`,到下游工具才失败。不是新增 RISC-V 指令是否正确的问题,而是共享前端改变后消费者未同步。 + +**建议修复:**按 dtype 发射 LLVM 整数常量和 `add/sub/mul`;整数除法另外处理目标语义。若 LLVM 暂不支持这些类型,应在 codegen 前明确拒绝并返回失败。不能把 IRBuilder 的类型传播改回 f32,也不能恢复不带 RV32 语义的前端折叠来掩盖问题。如果把 RV32 的除零/溢出合同沿用到 LLVM,不能简单用裸 `sdiv`,因为其对应边界是未定义行为。[LLVM sdiv 合同](https://llvm.org/docs/LangRef.html#sdiv-instruction) + +**补测:**typed DSL 与 checker 合法的 ONNX INT32 输入,对 LLVM 输出执行解析/验证;覆盖 `none/basic/all`。只检查文本中有 `define` 或 `fadd` 不足以捕获这个问题。 + +**我们的解决方案(2026-10-12):**按课题28的 RISC-V 交付范围,采用“LLVM 对未支持的操作与类型组合明确拒绝”的修复方案。原始课题没有要求扩展 LLVM 的完整整数支持;本次修复处理共享前端/IR 变更引起的假成功,不把 LLVM 整数后端或 RV32 除法语义移植追加为课题功能。 + +- [compiler.py](../../../scratchv/compiler.py) 在解析完成、运行优化器之前调用 LLVM 类型能力检查。`load_const/add/sub/mul/div/neg` 的现有 LLVM 发射只支持 f32/f64;遇到 i32/i64 时返回 `success=False`,错误包含操作、类型、函数和基本块。检查位于优化前,避免未使用的整数常量或可折叠运算被删除后绕过拒绝边界,`none/basic/all` 使用同一政策。 +- [llvm_codegen.py](../../../scratchv/backend/llvm_codegen.py) 在 `emit()` 开始时复用同一检查,保护绕过 CompilerDriver 的调用,并检查优化后的 IR。`save()` 先生成完整文本,再打开目标文件,拒绝输入时不创建或截断文件。CompilerDriver 的拒绝也发生在输出写入之前。 +- 检查按操作与类型组合执行,不全面禁用整数值;已有的整数循环控制、比较、访存和返回路径不因这一检查被拒绝。保留前端 typed `const`、IRBuilder 类型传播及 IR 优化器的 RV32 整数规则,不恢复默认 f32 或宿主前端折叠,也不发射未经边界处理的整数 `sdiv`。 +- 新增 [test_llvm_input_types.py](../../../tests/test_llvm_input_types.py) 的 26 项回归:typed DSL 和经 ONNX checker 验证的 INT32 标量 Add 在三个优化级别均拒绝且不写出文件;没有常量加载的整数四则运算及 neg 也拒绝;覆盖优化前拒绝、`verify_ir=True`、已有文件保留和直接 `LLVMCodegen.save()`。对应 f32/f64 输入的 12 份成功输出全部执行 LLVM `parse_assembly(...).verify()`,另验证整数循环控制仍生成合法 LLVM。 + +生产类型检查只使用 Python,不新增 llvmlite 运行时依赖;LLVM 解析与验证在回归测试中执行。 + +| 修复后的输入 | none | basic | all | +|---|---|---|---| +| typed DSL i32 const/Add | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | +| checker 合法的 ONNX INT32 initializer Add | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | +| DSL/ONNX f32、f64 const/Add | 成功,LLVM 解析/验证通过 | 成功,LLVM 解析/验证通过 | 成功,LLVM 解析/验证通过 | + +**验证结果:**相关测试 121 项通过。使用前文所列 Python/QEMU 环境执行 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py`,完整回归 **3501 passed,0 failed,0 skipped**,耗时 154.62 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。 + +以上为修复后行为;本节上方的复现和基线/HEAD 对比保留为审查提交 `2dc976c` 的修复前证据。修复后的验收使用上述已提交的回归测试,本地原始复现脚本不纳入提交。 + +### R2:多个函数合法重名,导致槽覆盖或类型冲突 + +**变更位置:**[inst_select_ext.py](../../../scratchv/backend/inst_select_ext.py#L245) 第 252–254 行;[allocation.py](../../../scratchv/backend/allocation.py#L71) 的 `collect_register_types`。前者以纯值名作为整个程序的 local_slots 字典键;后者也以纯值名对平铺机器流做类型归并。 + +最小结构是:分别使用两个 IRBuilder 建立 `f0`、`f1`,每个函数都 `alloca(1)`、store 常量、load、return,然后把两个 Function 放入同一个 Program。两个函数各自生成 `v_1/v_2/v_3`。`verify_ir(program)` 返回 `(True, [])`;函数内 SSA 值名相同是合法的。 + +| 函数类型 | 预期 | 实测: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` | + +**根因:**`local_bytes` 跨函数累计,第一次 alloca 为 `-8`,第二次为 `-16`;两个槽同名 `v_1`,第二个覆盖第一个字典项,发射的第一处 `-8` 地址因此失去布局元数据。混合精度时,类型收集更早把两个函数的 `v_2` 当作同一虚拟寄存器并报冲突。 + +现有多函数测试使用同一个 IRBuilder,计数器持续递增;spill-only 测试显式把函数名加到值名前。这些输入绕开了函数内重名,不能证明实现真的按函数隔离。 + +**影响:**合法多函数 IR 被拒绝;目前 DSL/ONNX 单函数入口通常不触发。报告不把这描述成所有单函数编译均损坏。 + +**建议修复:**在平铺前把虚拟寄存器和局部对象按函数命名空间归一化,或者真正逐函数分配/布局后再拼接。键可用 `(function_id, value_name)`。只对 local_slots 换键仍不够,类型表、spill 表和分配器的值身份也需要保持相同作用域。 + +**补测:**分别建函数后合并,故意复用所有内部值名;既测同精度也测不同精度,并在修复后分别执行两个函数。这与当前已有的“多函数栈帧累计超过 2032 字节”测试属于不同边界。 + +### R3:IR 参照执行器与产品对同一个常量解释不同 + +**变更位置:**[types.py](../../../scratchv/ir/types.py#L118) 新增 `Value.bit_pattern` 并声明其优先于宿主转换;[rv32_emulator.py](../../../scratchv/simulator/rv32_emulator.py#L470) 的 `_resolve` 和第 577 行 `_op_load_const` 仍只使用 const_value/attrs.value。 + +复现: + +```python +b.ret(b.load_const(0.0, T.FLOAT32, bit_pattern=0x3f800000)) +``` + +这一 IR 通过 verifier,按新增合同,`bit_pattern` 是权威编码,实际值应为 1.0。对 f64 使用 `0x3ff0000000000000` 同理。 + +| 精度 | 三种分配器真实 ELF 返回位 | IRTraceExecutor 返回位 | +|---|---|---| +| f32 | `0x3f800000`,即 1.0 | `0x00000000`,即 0.0 | +| f64 | `0x3ff0000000000000`,即 1.0 | `0x0000000000000000`,即 0.0 | + +所有 QEMU 场景 `fflags=0`。因此问题涉及**普通数值**,并不能用“参照不模拟 fflags/舍入模式”来解释。 + +**影响:**使用 IRTraceExecutor 的值对照可能报错或掩盖错误;本次 QEMU 验收入口没有依赖它充当二进制 oracle,因此既有验收仍通过。 + +**建议修复:**两个入口都从权威位模式解码常量,使用 NumPy 对字节做 dtype view,避免再经 Python float 转换。LOAD_CONST attrs 和 Value 的编码优先级应与 selector 一致。对于不承诺精确模拟的运算继续保留现有“值参照”定位,不需要在本课题重写软件浮点单元。 + +**补测:**同时覆盖 `_op_load_const` 和 `_resolve`;显式编码与宿主值不同、符号零、NaN payload 是不同输入类别。有限数值例子可以防止把失败误归因于异常标志。 + +### R4:空分配结果混入上一轮机器指令 + +**变更位置:**[regalloc_linear.py](../../../scratchv/backend/regalloc_linear.py#L275) 新增 `allocate_result`;[regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py#L22) 在第 22 行对空输入提前返回,而 `_allocated_instructions=[]` 在第 103 行才执行。 + +复现: + +```python +a = LinearScanAllocator() +a.allocate_result([MI(O.LI, P.vreg("v"), P.immediate(7))]) +r = a.allocate_result([]) +``` + +实际 `r.instructions == [li %t0 7]`,但 `r.register_map == {}`、`r.spill_bytes == 0`。一个结果对象里的指令和元数据属于不同轮次。 + +**影响:**复用分配器的新结构化 API 返回陈旧产品。CompilerDriver 当前每次新建分配器,所以普通 CLI 编译通常不会遇到这一情况。 + +**建议修复:**在每次分配/改写入口、任何提前返回之前清空结构化输出。明确对象复用的状态重置规则;空输入应得到空 instructions、空 map、零字节布局。 + +**补测:**同一实例按“非空 → 空 → 非空”调用,检查结构化结果和字符串结果属于同一轮。无需乘上所有优化级别。 + +## 3. 哪些改动是必须的:先看端到端依赖 + +基线已经有 `DataType.FLOAT64`、若干 F/D MachineOp、整数 DIV 和一个扩展选择器。问题是它们没有形成可靠的生产路径:结果类型经常回到默认 f32;浮点操作数仍可能被整数分配器处理;f64 的常量、spill、ABI 与发射也没有贯通。**本次必要的工作是把已有名义支持变成真实可编译、可调用、可执行的支持,不是再添加一个 FLOAT64 枚举。** + +```mermaid +flowchart LR + A[DSL/ONNX保留dtype和常量编码] --> B[IR结果保留类型] + B --> C[优化保持整数和浮点合同] + C --> D[按ISA和dtype选择机器指令] + D --> E[保留GPR/FPR和值宽度] + E --> F[分类分配与4/8字节spill] + F --> G[每函数栈帧和ABI] + G --> H[合法汇编与真实执行验证] +``` + +每条依赖都可以构造删除后失败的例子: + +| 如果撤掉的改动 | 直接后果 | +|---|---| +| IRBuilder 结果类型传播 | f64 add 的 dest 回到 f32,下一环节精度/指令不一致 | +| 常量位模式和合法常量池 | `-0.0`/NaN 编码可能变;`li.d` 无标准实现可供正常汇编 | +| FPR 类别及位置约束 | 浮点数据可能分到 t/s 整数寄存器,或比较结果错误分到 FPR | +| 按宽度 spill | f64 被 4 字节 sw/lw 截断,压力下低位结果看似正常却失去高位 | +| ABI 和局部槽合并布局 | spill 与局部内存或保存区重叠;返回时 sp/保存寄存器状态不正确 | +| 浮点优化保护 | 符号零、逐步舍入或 fflags 与执行程序不同 | +| 输入能力校验 | 未支持的浮点/张量进入旧整数近似路径,产生“成功”的错误结果 | + +f32/f64 数据共享 FPR 寄存器文件,用 width 区分即可;不需要另外建立“FPR32 类”和“FPR64 类”。地址仍使用 RV32 GPR。F/D 访存和运算、较窄值在更宽浮点寄存器中的表示属于架构约束。[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) + +本报告对“必要性”使用四种口径: + +| 标记 | 意义 | +|---|---| +| **核心必需** | 直接满足原始算子/类型要求;撤掉会缺功能或产生错误代码 | +| **集成必需** | 原任务未逐个点名,但端到端正确运行或修复已暴露问题需要 | +| **合同必需** | 为本分支主动选择的更严格数值/输入合同服务;缩小合同才可缩减 | +| **可选配套** | 使用体验、观测或维护便利;不决定核心数值结果 | + +“必须具有某种能力”不等于“必须保留当前每一行实现”。下面同时说明合理替代方式和可以精简的部分。 + +## 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. 测试变更是否有必要,是否在掩盖回归 + +测试新增行占主要增量。数量大不自动意味着过度测试;对编译器,应优先检查是否验证真实结果、是否来自独立 oracle,以及矩阵是否对应真实状态组合。 + +### 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 断言仍保留。 | + +总体上,未发现把失败简单改成 skip 或删除关键执行断言来通过测试的情况。测试仍有独立输入和真实执行证据。**主要缺口是没有跨到 LLVM 输出验证、独立 Function 的命名空间、权威常量编码的参照入口、以及可复用结构化 API 的状态重置。** + +## 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. 建议的处理顺序与合并条件 + +1. **先处理 R1。**共享 typed 前端不能对 LLVM 产出假成功;明确支持或拒绝,加入真实 LLVM 输出验证。不要撤回 IR 类型传播。 +2. **处理 R2。**虚拟值和局部对象按函数保持身份,三种分配器统一受益;避免只修字典覆盖而留下类型/分配身份冲突。 +3. **处理 R3、R4。**参照执行器遵守权威位模式;空分配结果清状态。这两项修复范围较小,适合本分支完成。 +4. **补充接口与限制说明。**记录 ExtendedInstructionSelector 构造参数迁移、设计 API 与实际 API 差异,以及标量点积示例的范围。 +5. **修复后按相关缺陷先做针对验证,再执行现有全量回归。**R1 加 LLVM verifier;R2 真正执行各函数;R3 对照有限数值编码;R4 检查同一实例三轮状态。无需再对每个解析边界机械展开全部分配器/优化级别。 + +本次对必要性的最终判断是:**类型传播、真实 F/D 选择、分类分配、8 字节 spill、合法常量发射、ABI 接线和优化安全保护应保留。** 非常规 protobuf 支持、统计/美化、部分保守策略和完整 API 草案属于可以明确拆分或精化的扩展。原始 4 个确定问题中 R1 已修复,R2–R4 仍需处理;全量回归通过不能替代对这些未覆盖合同的针对验证。 diff --git a/scratchv/backend/llvm_codegen.py b/scratchv/backend/llvm_codegen.py index 3df9596..720b01a 100644 --- a/scratchv/backend/llvm_codegen.py +++ b/scratchv/backend/llvm_codegen.py @@ -24,6 +24,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 +76,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 +93,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 diff --git a/scratchv/compiler.py b/scratchv/compiler.py index 9487bb2..9a65926 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -321,6 +321,17 @@ def _compile( ], ) + 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") diff --git a/tests/test_llvm_codegen.py b/tests/test_llvm_codegen.py index ace6f8f..60c4b27 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(i, 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 0000000..32377ab --- /dev/null +++ b/tests/test_llvm_input_types.py @@ -0,0 +1,132 @@ +"""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 + + +@pytest.mark.parametrize("frontend", ["dsl", "onnx"]) +@pytest.mark.parametrize("level", ["none", "basic", "all"]) +def test_llvm_rejects_i32_add_without_output(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() + + +def test_llvm_rejects_unused_i32_before_optimization_and_preserves_output( + 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(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(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(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(): + llvm = pytest.importorskip("llvmlite.binding") + program = DSLParser().parse("for i = 0, 3\nendfor\nreturn 0") + + llvm.parse_assembly(LLVMCodegen(program).emit()).verify() From 814dc9ea11a7195f32fdd3294734ff538d018752 Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Mon, 12 Oct 2026 18:28:01 +0800 Subject: [PATCH 06/13] fix(riscv): preserve function-local value identities Add independent-function regressions and expand LLVM integer input rejection tests, recording before/after validation in the review report. --- ...iew\346\212\245\345\221\212-2026-10-12.md" | 37 ++++- scratchv/backend/inst_select_ext.py | 8 + scratchv/backend/instruction_select.py | 31 +++- tests/test_llvm_input_types.py | 32 ++++ tests/test_topic28_function_scope.py | 139 ++++++++++++++++++ 5 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 tests/test_topic28_function_scope.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\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" index f9ad703..c2d01fc 100644 --- "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" @@ -2,9 +2,9 @@ Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-design`,提交 `2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1`。比较基线:`f3af99d`,即本工作区 `main` / `origin/main` 指向的提交。 -**结论:建议修改后合并。大部分跨模块改动具有正确性上的必要性。审查提交共发现 4 个已复现问题;R1 的 LLVM 假成功已按下文方案修复,R2–R4 仍待处理。** 不能因为全量测试通过,就认为共享后端、每函数命名空间和可复用 API 已覆盖完整。 +**结论:建议修改后合并。大部分跨模块改动具有正确性上的必要性。审查提交共发现 4 个已复现问题;R1 的 LLVM 假成功及 R2 的函数命名空间问题已按下文方案修复,R3–R4 仍待处理。** 不能因为全量测试通过,就认为共享后端、每函数命名空间和可复用 API 已覆盖完整。 -本报告保留对上述提交的原始审查结果,并在 R1 后补记修复方案及验证结果。本次提交包含报告、R1 修复代码和回归测试;复现脚本及原始 JSON 证据仅保留为本地调试资料,不纳入提交。原始审查部分的行号均以被审查提交为准。 +本报告保留对上述提交的原始审查结果,并在 R1/R2 后补记修复方案及验证结果。配套修复包含生产代码和回归测试;复现脚本及原始 JSON 证据仅保留为本地调试资料,不纳入提交。原始审查部分的行号均以被审查提交为准。 ## 1. 范围、方法和验证结果 @@ -121,7 +121,7 @@ return r - [compiler.py](../../../scratchv/compiler.py) 在解析完成、运行优化器之前调用 LLVM 类型能力检查。`load_const/add/sub/mul/div/neg` 的现有 LLVM 发射只支持 f32/f64;遇到 i32/i64 时返回 `success=False`,错误包含操作、类型、函数和基本块。检查位于优化前,避免未使用的整数常量或可折叠运算被删除后绕过拒绝边界,`none/basic/all` 使用同一政策。 - [llvm_codegen.py](../../../scratchv/backend/llvm_codegen.py) 在 `emit()` 开始时复用同一检查,保护绕过 CompilerDriver 的调用,并检查优化后的 IR。`save()` 先生成完整文本,再打开目标文件,拒绝输入时不创建或截断文件。CompilerDriver 的拒绝也发生在输出写入之前。 - 检查按操作与类型组合执行,不全面禁用整数值;已有的整数循环控制、比较、访存和返回路径不因这一检查被拒绝。保留前端 typed `const`、IRBuilder 类型传播及 IR 优化器的 RV32 整数规则,不恢复默认 f32 或宿主前端折叠,也不发射未经边界处理的整数 `sdiv`。 -- 新增 [test_llvm_input_types.py](../../../tests/test_llvm_input_types.py) 的 26 项回归:typed DSL 和经 ONNX checker 验证的 INT32 标量 Add 在三个优化级别均拒绝且不写出文件;没有常量加载的整数四则运算及 neg 也拒绝;覆盖优化前拒绝、`verify_ir=True`、已有文件保留和直接 `LLVMCodegen.save()`。对应 f32/f64 输入的 12 份成功输出全部执行 LLVM `parse_assembly(...).verify()`,另验证整数循环控制仍生成合法 LLVM。 +- 新增 [test_llvm_input_types.py](../../../tests/test_llvm_input_types.py) 的首批 26 项回归,后续补充 12 项非常量 ONNX 算术测试,共 38 项:typed DSL 和经 ONNX checker 验证的 INT32 标量 Add 在三个优化级别均拒绝且不写出文件;没有常量加载的整数四则运算及 neg 也拒绝;覆盖优化前拒绝、`verify_ir=True`、已有文件保留和直接 `LLVMCodegen.save()`。对应 f32/f64 输入的 12 份成功输出全部执行 LLVM `parse_assembly(...).verify()`,另验证整数循环控制仍生成合法 LLVM。 生产类型检查只使用 Python,不新增 llvmlite 运行时依赖;LLVM 解析与验证在回归测试中执行。 @@ -129,11 +129,23 @@ return r |---|---|---|---| | typed DSL i32 const/Add | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | | checker 合法的 ONNX INT32 initializer Add | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | +| checker 合法的 ONNX INT32 参数 Add/Sub/Mul/Div | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | | DSL/ONNX f32、f64 const/Add | 成功,LLVM 解析/验证通过 | 成功,LLVM 解析/验证通过 | 成功,LLVM 解析/验证通过 | **验证结果:**相关测试 121 项通过。使用前文所列 Python/QEMU 环境执行 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py`,完整回归 **3501 passed,0 failed,0 skipped**,耗时 154.62 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。 -以上为修复后行为;本节上方的复现和基线/HEAD 对比保留为审查提交 `2dc976c` 的修复前证据。修复后的验收使用上述已提交的回归测试,本地原始复现脚本不纳入提交。 +**后续补测及修复前后对照(2026-10-12):**新增的 12 项用例使用两个 INT32 运行时参数,没有 initializer,覆盖 Add/Sub/Mul/Div × none/basic/all,并通过 ONNX checker 验证。它们避免常量折叠或常量加载错误掩盖算术发射路径,检查编译入口明确拒绝且不写出 `.ll`。 + +对照时分别从 Git 导出修复前 `2dc976c` 和 R1 修复提交 `492bcfb` 的源码到两个临时目录,将同一份当前测试文件复制到两边,校验测试内容一致并确认 Python 实际加载各自导出的生产模块;两边均运行 `python -m pytest -q tests/test_llvm_input_types.py`。未回退当前工作区,也未混入未提交的 R2 代码。 + +| 相同的 38 项 R1 测试 | 通过 | 失败 | 跳过 | +|---|---|---|---| +| 修复前代码 `2dc976c` | 13 | 25 | 0 | +| 修复后代码 `492bcfb` | 38 | 0 | 0 | + +修复前的 25 项失败均对应整数输入拒绝和失败时保留文件的合同;原有 12 项 f32/f64 输出验证及 1 项整数循环控制测试已通过。另在包含 R2 修复的当前工作区联合运行 R1/R2 回归,**58 passed**。上方 3501 项完整回归保留为首批 26 项测试时的验证记录。 + +以上为修复后行为;本节上方的复现和基线/HEAD 对比保留为审查提交 `2dc976c` 的修复前证据。修复后的验收使用上述仓库内的回归测试,本地原始复现脚本不纳入提交。 ### R2:多个函数合法重名,导致槽覆盖或类型冲突 @@ -156,6 +168,21 @@ return r **补测:**分别建函数后合并,故意复用所有内部值名;既测同精度也测不同精度,并在修复后分别执行两个函数。这与当前已有的“多函数栈帧累计超过 2032 字节”测试属于不同边界。 +**我们的解决方案(2026-10-12):**先新增 [test_topic28_function_scope.py](../../../tests/test_topic28_function_scope.py) 的 20 项单元及执行回归,再修改生产代码。修复前为 **19 failed、1 passed**,失败包含局部槽记录覆盖、浮点位宽冲突和 naive spill 身份混用;重复选择的稳定性测试原本通过,单独保留用于约束修复后的命名行为。 + +- [instruction_select.py](../../../scratchv/backend/instruction_select.py) 在每个函数完成选择后、交给平铺机器管线之前,将该函数所有虚拟寄存器的定义及使用映射为唯一的后端名称。映射同时覆盖源 IR 值、参数/全局常量的函数内物化和选择器临时值,保留寄存器类别、位宽及物理寄存器/符号信息。名称通过已有临时名分配接口生成,避开所有源名称和已生成名称;仅多函数程序执行此步骤,单函数命名保持原有行为。 +- [inst_select_ext.py](../../../scratchv/backend/inst_select_ext.py) 使用同一映射同步更新局部对象的 `local_slots` 键,并在开始处理下一个函数前完成更新。这样,后续函数再次使用 `v_1` 时不会覆盖前一个函数的局部槽。类型表、寄存器映射、spill 表和活跃性分析接收同一套唯一值身份,保留现有类型冲突检查。 +- 保留程序级唯一的临时栈偏移,由既有 ABI finalizer 按函数压缩和重排实际栈帧;无需把所有函数的局部空间强行放进一个运行时栈帧,也不要求调用者把函数内 IR 名称改成全程序唯一。重命名仅作用于机器操作数,原始 IR 不被修改;同一个 selector 重复运行得到稳定的名称和槽记录。 +- 单元测试覆盖 f64/f64、f64/f32 的值及局部槽隔离、FPR 位宽保留、naive spill 身份、与编译器名称相似的源值名、原始 IR 保留、重复选择以及基础整数选择器。执行测试覆盖两种精度组合 × 三种分配器 × 简单局部对象/16 个同时存活浮点值的寄存器压力;压力用例明确检查发生 spill,并独立汇编、链接后通过 QEMU 分别调用两个函数,检查返回位模式、fflags 及调用后的栈/GPR 状态。 + +| 修复后的输入 | naive | greedy | linear | +|---|---|---|---| +| f0:f64、f1:f64,重复 v_1/v_2/v_3 | 编译并分别执行通过 | 编译并分别执行通过 | 编译并分别执行通过 | +| f0:f64、f1:f32,重复 v_1/v_2/v_3 | 编译并分别执行通过 | 编译并分别执行通过 | 编译并分别执行通过 | +| 同名函数值及局部对象,增加 FPR spill 压力 | 两个函数结果均正确 | 两个函数结果均正确 | 两个函数结果均正确 | + +**验证结果:**修复后新增的 20 项测试全部通过;连同既有整体、栈帧和分配回归,**409 passed**。使用前文所列 Python/QEMU 环境执行 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py`,完整回归 **3521 passed,0 failed,0 skipped**,耗时 156.36 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。本节上方的错误与原始对比保留为审查提交 `2dc976c` 的修复前证据。 + ### R3:IR 参照执行器与产品对同一个常量解释不同 **变更位置:**[types.py](../../../scratchv/ir/types.py#L118) 新增 `Value.bit_pattern` 并声明其优先于宿主转换;[rv32_emulator.py](../../../scratchv/simulator/rv32_emulator.py#L470) 的 `_resolve` 和第 577 行 `_op_load_const` 仍只使用 const_value/attrs.value。 @@ -445,4 +472,4 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 4. **补充接口与限制说明。**记录 ExtendedInstructionSelector 构造参数迁移、设计 API 与实际 API 差异,以及标量点积示例的范围。 5. **修复后按相关缺陷先做针对验证,再执行现有全量回归。**R1 加 LLVM verifier;R2 真正执行各函数;R3 对照有限数值编码;R4 检查同一实例三轮状态。无需再对每个解析边界机械展开全部分配器/优化级别。 -本次对必要性的最终判断是:**类型传播、真实 F/D 选择、分类分配、8 字节 spill、合法常量发射、ABI 接线和优化安全保护应保留。** 非常规 protobuf 支持、统计/美化、部分保守策略和完整 API 草案属于可以明确拆分或精化的扩展。原始 4 个确定问题中 R1 已修复,R2–R4 仍需处理;全量回归通过不能替代对这些未覆盖合同的针对验证。 +本次对必要性的最终判断是:**类型传播、真实 F/D 选择、分类分配、8 字节 spill、合法常量发射、ABI 接线和优化安全保护应保留。** 非常规 protobuf 支持、统计/美化、部分保守策略和完整 API 草案属于可以明确拆分或精化的扩展。原始 4 个确定问题中 R1/R2 已修复,R3–R4 仍需处理;全量回归通过不能替代对这些未覆盖合同的针对验证。 diff --git a/scratchv/backend/inst_select_ext.py b/scratchv/backend/inst_select_ext.py index 57cc5ab..054b210 100644 --- a/scratchv/backend/inst_select_ext.py +++ b/scratchv/backend/inst_select_ext.py @@ -242,6 +242,14 @@ def _select_function(self, func) -> None: 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 diff --git a/scratchv/backend/instruction_select.py b/scratchv/backend/instruction_select.py index 2f78ad3..11c7870 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -7,6 +7,7 @@ from __future__ import annotations +from dataclasses import replace from typing import Optional from scratchv.ir.types import Instruction, Function, Program @@ -77,11 +78,39 @@ def run(self) -> list[MachineInstr]: ) for block_index, block in enumerate(func.blocks) }) - for func, labels in zip(self.program.functions, block_labels): + 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 diff --git a/tests/test_llvm_input_types.py b/tests/test_llvm_input_types.py index 32377ab..d821f9a 100644 --- a/tests/test_llvm_input_types.py +++ b/tests/test_llvm_input_types.py @@ -59,6 +59,38 @@ def test_llvm_rejects_i32_add_without_output(tmp_path, frontend, level): 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(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( tmp_path, monkeypatch, ): diff --git a/tests/test_topic28_function_scope.py b/tests/test_topic28_function_scope.py new file mode 100644 index 0000000..512369a --- /dev/null +++ b/tests/test_topic28_function_scope.py @@ -0,0 +1,139 @@ +"""R2: independent functions may reuse every IR value name.""" + +import struct + +import pytest + +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.backend.allocation import collect_register_types +from scratchv.backend.inst_select_ext import ExtendedInstructionSelector +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.machine_types import MachineOp, RegisterClass +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as T, Program +from tests.topic28_runtime import observe +from tests.topic28_support import MODES, allocate, compile_ir, configuration + + +TYPE_PAIRS = ((T.FLOAT64, T.FLOAT64), (T.FLOAT64, T.FLOAT32)) + + +def independent_functions(types, *, pressure=False, reserved_names=False): + program = Program() + for index, dtype in enumerate(types): + builder = IRBuilder() + builder.new_function(f"f{index}") + builder.new_block("entry") + pointer = builder.alloca(1, dtype) + builder.store(pointer, builder.load_const(2 + 3 * index, dtype)) + values = ( + [builder.load_const(value, dtype) for value in range(1, 17)] + if pressure else [] + ) + if pressure: + # More simultaneously live FPR values than the allocator's pool. + builder.br("use") + builder.new_block("use") + result = builder.load(pointer, dtype) + for value in values: + result = builder.add(result, value) + builder.ret(result) + if reserved_names: + instructions = builder.program.functions[0].blocks[0].instructions + for number, instruction in enumerate(instructions, 2): + if instruction.dest is not None: + instruction.dest.name = f"__scratchv_f0_{number}" + program.functions.extend(builder.program.functions) + assert verify_ir(program) == (True, []) + return program + + +def registers_by_function(instructions): + result = {} + current = None + for instruction in instructions: + if instruction.op is MachineOp.LABEL and instruction.label_role == "function": + current = instruction.target + result[current] = set() + for operand in (instruction.dst, instruction.src1, instruction.src2): + if operand is not None and operand.kind == "vreg": + result[current].add(str(operand.value)) + return result + + +@pytest.mark.parametrize("types", TYPE_PAIRS) +@pytest.mark.parametrize("reserved_names", [False, True]) +def test_selection_preserves_local_slots_and_function_value_identity(types, reserved_names): + program = independent_functions(types, reserved_names=reserved_names) + before = program.dump() + source_names = { + instruction.dest.name for function in program.functions + for block in function.blocks for instruction in block.instructions + if instruction.dest is not None + } + selector = ExtendedInstructionSelector(program) + instructions = selector.run() + registers = registers_by_function(instructions) + + assert registers["f0"].isdisjoint(registers["f1"]) + assert len(selector.local_slots) == 2 + assert len({slot.offset for slot in selector.local_slots.values()}) == 2 + value_types = collect_register_types(instructions) + for index, dtype in enumerate(types): + names = registers[f"f{index}"] + assert names.isdisjoint(source_names) + slots = [slot for name, slot in selector.local_slots.items() if name in names] + assert len(slots) == 1 + width = 8 if dtype is T.FLOAT64 else 4 + assert (slots[0].size, slots[0].alignment) == (width, width) + assert {value_types[name].bit_width for name in names + if value_types[name].reg_class is RegisterClass.FPR} == {width * 8} + assert program.dump() == before + assert verify_ir(program) == (True, []) + + +@pytest.mark.parametrize("types", TYPE_PAIRS) +def test_naive_spill_storage_keeps_same_named_function_values_separate(types): + instructions = ExtendedInstructionSelector(independent_functions(types)).run() + registers = registers_by_function(instructions) + allocation = allocate(instructions, "naive") + spills = set(allocation.spill_slots) + + assert spills & registers["f0"] + assert spills & registers["f1"] + assert (spills & registers["f0"]).isdisjoint(spills & registers["f1"]) + + +def test_multifunction_selection_can_be_repeated_without_changing_identity(): + selector = ExtendedInstructionSelector(independent_functions(TYPE_PAIRS[0])) + first = selector.run() + slots = dict(selector.local_slots) + + assert selector.run() == first + assert selector.local_slots == slots + + +def test_base_selector_also_distinguishes_same_named_integer_values(): + instructions = InstructionSelector(independent_functions((T.INT32, T.INT32))).run() + registers = registers_by_function(instructions) + + assert registers["f0"].isdisjoint(registers["f1"]) + + +@pytest.mark.parametrize("types", TYPE_PAIRS) +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("pressure", [False, True]) +def test_same_named_functions_execute_independently(tmp_path, types, mode, pressure): + assembly = compile_ir( + independent_functions(types, pressure=pressure), tmp_path, + configuration(mode, "none"), + ) + if pressure: + assert "[regalloc:spill]" in assembly + for index, dtype in enumerate(types): + path = tmp_path / f"f{index}" + path.mkdir() + value = 2 + 3 * index + (sum(range(1, 17)) if pressure else 0) + encoding = " Date: Mon, 12 Oct 2026 21:28:32 +0800 Subject: [PATCH 07/13] fix(simulator): honor encoded floating-point constants Decode constant storage bits consistently at load and operand boundaries, preserving instruction-only encodings within each function. Add bit-level regression tests and document before/after validation. --- ...iew\346\212\245\345\221\212-2026-10-12.md" | 24 ++- scratchv/simulator/rv32_emulator.py | 51 +++-- tests/test_ir_trace_constants.py | 179 ++++++++++++++++++ 3 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 tests/test_ir_trace_constants.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\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" index c2d01fc..b1c2b1a 100644 --- "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" @@ -2,9 +2,9 @@ Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-design`,提交 `2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1`。比较基线:`f3af99d`,即本工作区 `main` / `origin/main` 指向的提交。 -**结论:建议修改后合并。大部分跨模块改动具有正确性上的必要性。审查提交共发现 4 个已复现问题;R1 的 LLVM 假成功及 R2 的函数命名空间问题已按下文方案修复,R3–R4 仍待处理。** 不能因为全量测试通过,就认为共享后端、每函数命名空间和可复用 API 已覆盖完整。 +**结论:建议修改后合并。大部分跨模块改动具有正确性上的必要性。审查提交共发现 4 个已复现问题;R1 的 LLVM 假成功、R2 的函数命名空间及 R3 的参照常量编码问题已按下文方案修复,R4 仍待处理。** 不能因为全量测试通过,就认为共享后端、每函数命名空间和可复用 API 已覆盖完整。 -本报告保留对上述提交的原始审查结果,并在 R1/R2 后补记修复方案及验证结果。配套修复包含生产代码和回归测试;复现脚本及原始 JSON 证据仅保留为本地调试资料,不纳入提交。原始审查部分的行号均以被审查提交为准。 +本报告保留对上述提交的原始审查结果,并在 R1/R2/R3 后补记修复方案及验证结果。配套修复包含生产代码和回归测试;复现脚本及原始 JSON 证据仅保留为本地调试资料,不纳入提交。原始审查部分的行号均以被审查提交为准。 ## 1. 范围、方法和验证结果 @@ -208,6 +208,24 @@ b.ret(b.load_const(0.0, T.FLOAT32, bit_pattern=0x3f800000)) **补测:**同时覆盖 `_op_load_const` 和 `_resolve`;显式编码与宿主值不同、符号零、NaN payload 是不同输入类别。有限数值例子可以防止把失败误归因于异常标志。 +**我们的解决方案(2026-10-12):**先新增 [test_ir_trace_constants.py](../../../tests/test_ir_trace_constants.py) 的常量表示单元测试,再修复 [rv32_emulator.py](../../../scratchv/simulator/rv32_emulator.py) 的两个读取入口。最终 55 项测试同时覆盖 f32/f64:普通有限数值、编码为零但宿主值非零、负零、quiet/signaling NaN payload,以及指令独有编码、Value 独有编码和两者一致时的返回路径。预期字节直接由整数编码生成,避免测试与生产解码器共用同一实现。 + +- 使用统一的常量解码函数,将编码保存为 NumPy uint32/uint64 数组,再用 `view` 解释为对应 f32/f64。结果为保持原始位模式的零维数组,不经过 Python float;没有显式编码时保留原有数值转换及 i32/f32/f64 行为。解码边界拒绝布尔值、非整数、超出位宽或用于整数 dtype 的浮点编码。 +- `_op_load_const` 沿用 selector 的编码规则:优先使用指令属性,缺省时使用结果 Value 的编码;两处都提供但不一致时,在写入执行器状态前报错。`_resolve` 对常量也使用同一解码函数,避免返回或算术操作数重新读取辅助宿主数值。 +- 对仅存在于指令属性中的编码,执行器按值名记录已执行常量定义的编码;后续解析同一常量时使用该定义。记录在每次 `run()` 及每个函数开始时清空,防止运行复用或函数内合法重名造成串值。不修改原始 IR,不改变数值参照对于 fflags、舍入模式和控制流的既有限制。 +- 单元测试还覆盖编码冲突时不覆盖已有值、两个入口对非法编码的拒绝、无编码常量的兼容行为,以及“加载后返回/参与加法”“重复运行后移除编码定义”“两个函数使用相同常量名”的状态边界。算术结果继续允许既有 NumPy 标量形式,仅检查 dtype 与原始字节,不追加数组接口要求。 + +| 同一份最终 55 项 R3 测试 | 通过 | 失败 | 跳过 | +|---|---|---|---| +| 修复前代码 `814dc9e`,在临时目录导出并确认实际加载 | 3 | 52 | 0 | +| 修复后代码 | 55 | 0 | 0 | + +修复前通过的 3 项是无编码的 i32/f32/f64 数值兼容测试。修复后连同既有参照、除法、验证、CNN 和模拟器回归,**309 passed**。扩展回归首次因原临时 QEMU 路径失效而失败;从 Ubuntu 软件源下载并解压 QEMU 10.2.1 到 `/tmp/scratchv-r3-tools/extracted`、恢复测试 PATH 后,上述执行回归全部通过,未把缺失工具改成 skip。 + +**完整回归:**使用 `/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`,结果为 **3588 passed,0 failed,0 skipped**,耗时 152.77 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。 + +本节上方产品与参照的差异保留为审查提交 `2dc976c` 的修复前证据;本次修复限于常量读取的一致性,不实现完整软件浮点单元。 + ### R4:空分配结果混入上一轮机器指令 **变更位置:**[regalloc_linear.py](../../../scratchv/backend/regalloc_linear.py#L275) 新增 `allocate_result`;[regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py#L22) 在第 22 行对空输入提前返回,而 `_allocated_instructions=[]` 在第 103 行才执行。 @@ -472,4 +490,4 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 4. **补充接口与限制说明。**记录 ExtendedInstructionSelector 构造参数迁移、设计 API 与实际 API 差异,以及标量点积示例的范围。 5. **修复后按相关缺陷先做针对验证,再执行现有全量回归。**R1 加 LLVM verifier;R2 真正执行各函数;R3 对照有限数值编码;R4 检查同一实例三轮状态。无需再对每个解析边界机械展开全部分配器/优化级别。 -本次对必要性的最终判断是:**类型传播、真实 F/D 选择、分类分配、8 字节 spill、合法常量发射、ABI 接线和优化安全保护应保留。** 非常规 protobuf 支持、统计/美化、部分保守策略和完整 API 草案属于可以明确拆分或精化的扩展。原始 4 个确定问题中 R1/R2 已修复,R3–R4 仍需处理;全量回归通过不能替代对这些未覆盖合同的针对验证。 +本次对必要性的最终判断是:**类型传播、真实 F/D 选择、分类分配、8 字节 spill、合法常量发射、ABI 接线和优化安全保护应保留。** 非常规 protobuf 支持、统计/美化、部分保守策略和完整 API 草案属于可以明确拆分或精化的扩展。原始 4 个确定问题中 R1/R2/R3 已修复,R4 仍需处理;全量回归通过不能替代对这些未覆盖合同的针对验证。 diff --git a/scratchv/simulator/rv32_emulator.py b/scratchv/simulator/rv32_emulator.py index 9ff18be..fc9353d 100644 --- a/scratchv/simulator/rv32_emulator.py +++ b/scratchv/simulator/rv32_emulator.py @@ -10,6 +10,7 @@ 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 @@ -428,6 +429,21 @@ 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: @@ -443,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) @@ -468,13 +489,10 @@ def _exec_instr(self, instr): 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: - dtype = { - "i32": np.int32, - "f32": np.float32, - "f64": np.float64, - }.get(val.dtype.value, np.float32) - return np.array(val.const_value, dtype=dtype) + 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] raise ValueError(f"Undefined IR value: {val.name}") @@ -575,15 +593,20 @@ def _op_sigmoid(self, instr): return result def _op_load_const(self, instr): - val = instr.attrs.get("value", 0) - dtype = { - "i32": np.int32, - "f32": np.float32, - "f64": np.float64, - }.get(instr.dest.dtype.value if instr.dest else "f32", np.float32) - result = np.array(val, dtype=dtype) + 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): diff --git a/tests/test_ir_trace_constants.py b/tests/test_ir_trace_constants.py new file mode 100644 index 0000000..e00fe2a --- /dev/null +++ b/tests/test_ir_trace_constants.py @@ -0,0 +1,179 @@ +"""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}, + )) + + +@pytest.mark.parametrize("dtype", PRECISIONS) +@pytest.mark.parametrize("case", ENCODINGS) +@pytest.mark.parametrize("entry", ["load", "operand"]) +def test_explicit_constant_encoding_preserves_every_bit(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) + + +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 + + +@pytest.mark.parametrize("dtype", PRECISIONS) +@pytest.mark.parametrize("source", ["instruction", "value", "both"]) +def test_return_uses_the_encoding_from_the_constant_definition(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(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(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(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(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(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(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(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() From 701cdc5d56a50d23a9549a5498224826f5ad7895 Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Mon, 12 Oct 2026 22:09:00 +0800 Subject: [PATCH 08/13] fix(regalloc): reset output before empty rewrites --- ...iew\346\212\245\345\221\212-2026-10-12.md" | 21 ++- scratchv/backend/regalloc_rewrite.py | 4 +- tests/test_regalloc_result_reuse.py | 138 ++++++++++++++++++ 3 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 tests/test_regalloc_result_reuse.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\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" index b1c2b1a..011efcd 100644 --- "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" @@ -2,9 +2,9 @@ Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-design`,提交 `2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1`。比较基线:`f3af99d`,即本工作区 `main` / `origin/main` 指向的提交。 -**结论:建议修改后合并。大部分跨模块改动具有正确性上的必要性。审查提交共发现 4 个已复现问题;R1 的 LLVM 假成功、R2 的函数命名空间及 R3 的参照常量编码问题已按下文方案修复,R4 仍待处理。** 不能因为全量测试通过,就认为共享后端、每函数命名空间和可复用 API 已覆盖完整。 +**原始审查结论:建议修改后合并。大部分跨模块改动具有正确性上的必要性。审查提交共发现 4 个已复现问题;R1 的 LLVM 假成功、R2 的函数命名空间、R3 的参照常量编码及 R4 的空分配状态残留问题已按下文方案修复。** 不能因为全量测试通过,就认为共享后端、每函数命名空间和可复用 API 已覆盖完整。 -本报告保留对上述提交的原始审查结果,并在 R1/R2/R3 后补记修复方案及验证结果。配套修复包含生产代码和回归测试;复现脚本及原始 JSON 证据仅保留为本地调试资料,不纳入提交。原始审查部分的行号均以被审查提交为准。 +本报告保留对上述提交的原始审查结果,并在 R1/R2/R3/R4 后补记修复方案及验证结果。配套修复包含生产代码和回归测试;复现脚本及原始 JSON 证据仅保留为本地调试资料,不纳入提交。原始审查部分的行号均以被审查提交为准。 ## 1. 范围、方法和验证结果 @@ -246,6 +246,23 @@ r = a.allocate_result([]) **补测:**同一实例按“非空 → 空 → 非空”调用,检查结构化结果和字符串结果属于同一轮。无需乘上所有优化级别。 +**我们的解决方案(2026-10-12):**先新增 [test_regalloc_result_reuse.py](../../../tests/test_regalloc_result_reuse.py) 的 16 项单元测试,在尚未修改生产代码的 `9d08814` 上运行并确认失败,再修复共享的 [regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py) 改写入口。 + +- 在进入 `rewrite_with_spills()` 时立即将 `_allocated_instructions` 绑定到新列表,位置在空输入提前返回及其他检查之前。`allocate_result()` 和 `emit()` 都经过这一入口,因此两种接口的空调用都得到空指令列表。寄存器映射、spill 槽及字节布局继续由本轮分配过程重建,空输入的完整结果为 `AllocationResult([], {}, {}, 0)`。 +- 使用新列表而非对旧列表执行 `clear()`,使已经返回给调用者的结果保留其指令。测试保存前一次结果的独立副本,并在后续空调用、非空调用后检查旧结果未被修改;也检查空结果不会被下一轮追加指令。 +- 测试覆盖 i32/f32/f64 × 两种调用接口的“非空 → 空 → 非空”;发生实际 spill/store/reload 后的空输入及再次分配;新对象与连续空调用;两种接口交替调用,以及相同虚拟寄存器名从 f64 切换为 f32。非空结果与独立的新分配器结果对照,空结果直接检查指令、映射、槽和字节数,并检查栈偏移及 spill/reload 计数归零。 + +| 同一份 16 项 R4 测试 | 通过 | 失败 | 跳过 | +|---|---|---|---| +| 修复前代码 `9d08814`,先添加测试再运行 | 2 | 14 | 0 | +| 修复后代码 | 16 | 0 | 0 | + +修复前通过的 2 项是新对象及连续空输入的兼容测试;其余 14 项均因空调用保留旧指令而失败。修复后 16 项全部通过。运行命令为 `python -m pytest -q tests/test_regalloc_result_reuse.py`,前后使用同一测试文件。 + +**完整回归:**使用 `/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 passed,0 failed,0 skipped**,耗时 154.05 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。 + +本节上方的复现保留为审查提交 `2dc976c` 的修复前证据。CompilerDriver 每次新建分配器的行为保持不变,本次修复保障新增结构化 API 与既有字符串 API 在对象复用时返回当前调用的结果。 + ## 3. 哪些改动是必须的:先看端到端依赖 基线已经有 `DataType.FLOAT64`、若干 F/D MachineOp、整数 DIV 和一个扩展选择器。问题是它们没有形成可靠的生产路径:结果类型经常回到默认 f32;浮点操作数仍可能被整数分配器处理;f64 的常量、spill、ABI 与发射也没有贯通。**本次必要的工作是把已有名义支持变成真实可编译、可调用、可执行的支持,不是再添加一个 FLOAT64 枚举。** diff --git a/scratchv/backend/regalloc_rewrite.py b/scratchv/backend/regalloc_rewrite.py index 6a7fe40..c0db942 100644 --- a/scratchv/backend/regalloc_rewrite.py +++ b/scratchv/backend/regalloc_rewrite.py @@ -19,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: @@ -100,7 +103,6 @@ def collect(positions: tuple[int, ...], virtuals: set[str]) -> set[str]: stack_current: set[str] = set() rename: dict[str, str] = {} lines: list[str] = [] - allocator._allocated_instructions = [] def bank_for(vreg: str) -> list[str]: return allocator.registers_for(vreg) if hasattr(allocator, "registers_for") else allocator.phys_regs diff --git a/tests/test_regalloc_result_reuse.py b/tests/test_regalloc_result_reuse.py new file mode 100644 index 0000000..6228558 --- /dev/null +++ b/tests/test_regalloc_result_reuse.py @@ -0,0 +1,138 @@ +"""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 + + +@pytest.mark.parametrize("entry", ["allocate_result", "emit"]) +@pytest.mark.parametrize("kind", ["i32", "f32", "f64"]) +def test_nonempty_empty_nonempty_results_belong_to_each_call(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(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(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(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) From e061861c6593cd34490b97a4fdc1e807fae65a6f Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Mon, 12 Oct 2026 22:26:22 +0800 Subject: [PATCH 09/13] docs(topic28): conclude R1-R4 review Explain the findings and fixes in plain language, mark all four issues as resolved, and retain reproduction examples and validation results. --- ...iew\346\212\245\345\221\212-2026-10-12.md" | 347 +++++++++++------- 1 file changed, 215 insertions(+), 132 deletions(-) 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" index 011efcd..b9a56c9 100644 --- "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" @@ -2,9 +2,9 @@ Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-design`,提交 `2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1`。比较基线:`f3af99d`,即本工作区 `main` / `origin/main` 指向的提交。 -**原始审查结论:建议修改后合并。大部分跨模块改动具有正确性上的必要性。审查提交共发现 4 个已复现问题;R1 的 LLVM 假成功、R2 的函数命名空间、R3 的参照常量编码及 R4 的空分配状态残留问题已按下文方案修复。** 不能因为全量测试通过,就认为共享后端、每函数命名空间和可复用 API 已覆盖完整。 +**审查时发现了 4 个可以重现的错误,当时建议修复后再合并。目前四项都已修复,最新一次全量回归为 3604 项通过、0 失败、0 跳过。** 四个问题分别是:LLVM 报成功却生成无效文件、两个函数的同名变量被混淆、核对工具读错浮点常量、空输入返回上一轮指令。 -本报告保留对上述提交的原始审查结果,并在 R1/R2/R3/R4 后补记修复方案及验证结果。配套修复包含生产代码和回归测试;复现脚本及原始 JSON 证据仅保留为本地调试资料,不纳入提交。原始审查部分的行号均以被审查提交为准。 +第 2 节先用例子解释每个错误,再说明原因、影响、与课题的关系、修复方法和测试结果。第 4–7 节保留对审查提交的逐文件评估,当前修复状态以第 2 节和第 8 节为准。代码行号指向审查时的版本;各次测试数字注明对应的修复阶段。复现脚本及原始 JSON 证据只作为本地调试资料,不纳入提交。 ## 1. 范围、方法和验证结果 @@ -17,7 +17,7 @@ Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-des | `3a681f4` | API 级设计 | | `2dc976c` | 算子、类型和端到端实现 | -使用 `git diff f3af99d..2dc976c` 检查实际差异,而非只根据提交说明和交付文档判断。 +使用 `git diff f3af99d..2dc976c` 检查这批提交具体改了哪些代码。 | 分类 | 文件数 | 新增行 | 删除行 | |---|---:|---:|---:| @@ -28,7 +28,7 @@ Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-des | CI | 1 | 3 | 0 | | 合计 | **77** | **8314** | **815** | -验证采用三层证据:读取调用链与前后差异;执行既有回归;另外构造与实现无关的输入并检查 LLVM 解析结果或真实 RISC-V ELF 执行结果。本地复现脚本记录被审查提交的缺陷行为,**不是应加入 CI 的成功行为测试**;R1 修复后的正确行为已纳入对应回归测试。 +检查分为三步:先读代码,确认输入经过哪些处理;再运行已有测试;最后补充小例子,检查生成的 LLVM 文件能否被读取,或将 RISC-V 代码汇编、链接成可执行文件并运行。本地复现脚本记录的是旧代码怎样出错;仓库中的回归测试则检查修复后应该怎样工作。 ### 1.1 已执行的回归 @@ -57,30 +57,32 @@ SCRATCHV_REQUIRE_RISCV_EXECUTION=1 \ | qemu-riscv32 | 10.2.1 | | llvmlite | 0.49.0 | -审查时另外执行了本地复现脚本,6 个 LLVM 场景、6 个函数命名空间场景、6 个常量编码执行场景及 1 个空分配场景全部复现。常量编码场景使用分开编译的产品与调用者,经 Clang/LLD 链接后在 QEMU 执行。这些结果对应被审查提交 `2dc976c`;脚本及原始 JSON 不包含在本次提交中,关键输入、输出和结果见本报告第 2 节。 +审查时还运行了本地复现脚本: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 结论的边界 +### 1.2 这些验证能说明什么 -本次验证证明既有用例的行为以及下述缺陷的复现,不证明所有 ONNX、所有 Python 版本或所有 RISC-V 工具链都受支持。没有测量性能,不能据此声称指令数或运行速度优于基线。仓库未提供 `CLAUDE.md` 引用的 `.claude/harness/verify/run.py`,所以没有执行该 Harness 的 L2 命令;上述 pytest 和独立执行检查是实际完成的验证。 +这些结果说明报告中的输入确实能触发错误,以及修复通过了列出的测试。测试范围限于实际使用的 ONNX 模型、Python 版本和 RISC-V 工具链。本次没有测量性能,因此没有得出“更快”或“指令更少”的结论。仓库里没有 `CLAUDE.md` 引用的 `.claude/harness/verify/run.py`,所以未执行其中的 L2 检查;实际完成的是上述 pytest 和独立运行检查。 -## 2. 已确认的问题 +## 2. 四个已确认的问题及修复结果 -优先级含义:P1 为影响常见有效输入、应优先修复的问题;P2 为有明确复现条件、需要正常修复的问题。以下不是推测或仅凭代码风格提出的建议。 +下面的例子记录的是审查提交 `2dc976c` 的错误表现。四个问题现在都已修复,各节分别说明修复方法和测试结果。P1 表示应优先处理,P2 表示需要修复但触发条件较有限。 -| 编号 | 优先级 | 问题 | 影响范围 | +例子中的 `i32` 是 32 位整数,`f32`、`f64` 分别是单精度、双精度浮点数。IR 是编译器在读取输入后、生成目标代码前使用的中间表示。 + +| 编号 | 优先级 | 修复前发生了什么 | 什么情况下会遇到 | |---|---|---|---| -| R1 | P1 | 共享前端接入 i32 后,LLVM 编译仍返回成功,但输出非法 IR | 新 typed DSL;已有 ONNX i32 Add 路径 | -| R2 | P2 | 值名/局部槽按整个程序管理,与函数内合法重名冲突 | 多函数 IR,三种分配器 | -| R3 | P2 | IR 参照执行器忽略权威 `bit_pattern` | 显式编码的 f32/f64 常量 | -| R4 | P2 | 线性分配器复用后,空输入返回旧指令 | 新 `allocate_result` API 的复用 | +| R1 | P1 | LLVM 报告编译成功,生成的文件却无法通过 LLVM 检查 | 使用整数常量或整数算术的 DSL/ONNX 输入 | +| R2 | P2 | 两个函数里的同名变量被当成同一个,导致编译失败 | 分别创建函数后合并,内部变量名重复 | +| R3 | P2 | 用来核对结果的执行器,把应为 1.0 的常量读成 0.0 | 浮点常量明确提供了原始二进制编码 | +| R4 | P2 | 上一次处理过指令后,空输入又返回了旧指令 | 重复使用同一个线性扫描寄存器分配器 | -### R1:LLVM 对新接入的 i32 输入给出假成功 +### R1:LLVM 报告编译成功,生成的文件却不能用 -**变更位置:**[dsl_parser.py](../../../scratchv/frontend/dsl_parser.py#L170) 的 typed `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`。 +#### 发生了什么 -复现输入: +输入下面这个程序,要求计算两个整数的和: ```text a = const(7,dtype:i32) @@ -89,7 +91,9 @@ r = add(a,b) return r ``` -使用 `CompilerConfig(backend="llvm", optimize_level=...)`,不启用 `use_extended_isel`。`none/basic/all` 三种级别均返回 `success=True`、空错误列表,却分别输出如下语句: +编译器选择 LLVM 后端时,合理的结果有两种:支持这段整数计算,生成可用的代码;或者明确说明暂不支持,并返回失败。修复前却返回了 `success=True`,错误列表也是空的,生成的 `.ll` 文件无法被 LLVM 读取。 + +不启用 `use_extended_isel`,分别使用 `none/basic/all` 三个优化级别,可以看到这些错误语句: ```llvm ; none @@ -98,139 +102,197 @@ return r %v_3_1 = fadd i32 10, 0.0 ``` -`llvmlite.binding.parse_assembly(...).verify()` 直接失败:`floating point constant invalid for type`。`fadd` 只接受浮点或浮点向量类型,不能用来构造 i32 常量或做整数加法。[LLVM fadd 合同](https://llvm.org/docs/LangRef.html#fadd-instruction) +这里 `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` 等扩展操作,没有拦住这里的整数常量和整数加法。 -**为什么属于本次变更的集成问题:**前端已对所有后端开放新 `const` 语法,而编译入口的 extended-op 拒绝集合只有 `rem/min/max/abs/sqrt`,不会阻止 i32 const/add 进入 LLVM。`--extended-isel` 与 LLVM 互斥只约束选择器开关,不能保护已经共享的前端和 IR 变化。 +相关位置:[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`。 -还构造了经 `onnx.checker.check_model` 验证的、两个 INT32 标量 initializer 相加的 ONNX 模型。同一模型在基线 `f3af99d` 的 `basic/all` 下可通过 LLVM 解析,在 HEAD 下全部失败。需要准确区分:**基线 `none` 已存在此类非法整数常量发射问题;基线优化后通过解析,也不表示其 ONNX 返回类型语义完全正确。** 本次移除前端宿主折叠后,将这一未适配的后端问题扩展到了原来可输出合法 LLVM 文本的默认优化路径。 +**课题要求扩展 RISC-V,不要求同时完成 LLVM 整数后端。但我们修改了共用的输入处理代码,就需要保证 LLVM 对不支持的输入明确报错。** 否则,调用者会相信“编译成功”,直到把文件交给后续工具才发现不能用。 -| 同一合法 ONNX INT32 Add 模型 | 基线 LLVM 解析 | HEAD LLVM 解析 | 两者 CompilerDriver 返回值 | +#### 和旧代码相比,哪里变差了 + +还测试了一个通过 `onnx.checker.check_model` 检查的 ONNX 模型:两个 INT32 标量常量相加。这排除了“输入模型本身不合法”的解释。 + +| 同一个合法 ONNX INT32 Add 模型 | 旧代码 `f3af99d` 的输出能否被 LLVM 读取 | 审查代码 `2dc976c` 的输出能否被 LLVM 读取 | 两边编译器报告 | |---|---|---|---| -| none | 失败 | 失败 | 均 success=True | -| basic | 通过 | 失败 | 均 success=True | -| all | 通过 | 失败 | 均 success=True | +| none | 不能 | 不能 | 都报告成功 | +| basic | 能 | 不能 | 都报告成功 | +| all | 能 | 不能 | 都报告成功 | + +旧代码在前端先用 Python 算出了常量结果,部分优化路径因此绕开了问题。本次把计算统一交给 IR 优化器后,原来能输出合法 LLVM 文本的 `basic/all` 路径也开始失败。 -**影响:**调用者拿到“成功”的 `.ll`,到下游工具才失败。不是新增 RISC-V 指令是否正确的问题,而是共享前端改变后消费者未同步。 +需要区分两件事:旧代码的 `none` 本来就有这个问题;旧代码的 `basic/all` 能通过 LLVM 解析,也只能说明文件格式有效,不能据此认定 ONNX 返回值的类型完全正确。 -**建议修复:**按 dtype 发射 LLVM 整数常量和 `add/sub/mul`;整数除法另外处理目标语义。若 LLVM 暂不支持这些类型,应在 codegen 前明确拒绝并返回失败。不能把 IRBuilder 的类型传播改回 f32,也不能恢复不带 RV32 语义的前端折叠来掩盖问题。如果把 RV32 的除零/溢出合同沿用到 LLVM,不能简单用裸 `sdiv`,因为其对应边界是未定义行为。[LLVM sdiv 合同](https://llvm.org/docs/LangRef.html#sdiv-instruction) +#### 我们怎么修复 -**补测:**typed DSL 与 checker 合法的 ONNX INT32 输入,对 LLVM 输出执行解析/验证;覆盖 `none/basic/all`。只检查文本中有 `define` 或 `fadd` 不足以捕获这个问题。 +原审查提出两种办法:补上正确的 LLVM 整数代码生成,或者明确拒绝当前不支持的操作。我们按本课题范围采用了第二种办法。 -**我们的解决方案(2026-10-12):**按课题28的 RISC-V 交付范围,采用“LLVM 对未支持的操作与类型组合明确拒绝”的修复方案。原始课题没有要求扩展 LLVM 的完整整数支持;本次修复处理共享前端/IR 变更引起的假成功,不把 LLVM 整数后端或 RV32 除法语义移植追加为课题功能。 +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 的整数计算规则。 -- [compiler.py](../../../scratchv/compiler.py) 在解析完成、运行优化器之前调用 LLVM 类型能力检查。`load_const/add/sub/mul/div/neg` 的现有 LLVM 发射只支持 f32/f64;遇到 i32/i64 时返回 `success=False`,错误包含操作、类型、函数和基本块。检查位于优化前,避免未使用的整数常量或可折叠运算被删除后绕过拒绝边界,`none/basic/all` 使用同一政策。 -- [llvm_codegen.py](../../../scratchv/backend/llvm_codegen.py) 在 `emit()` 开始时复用同一检查,保护绕过 CompilerDriver 的调用,并检查优化后的 IR。`save()` 先生成完整文本,再打开目标文件,拒绝输入时不创建或截断文件。CompilerDriver 的拒绝也发生在输出写入之前。 -- 检查按操作与类型组合执行,不全面禁用整数值;已有的整数循环控制、比较、访存和返回路径不因这一检查被拒绝。保留前端 typed `const`、IRBuilder 类型传播及 IR 优化器的 RV32 整数规则,不恢复默认 f32 或宿主前端折叠,也不发射未经边界处理的整数 `sdiv`。 -- 新增 [test_llvm_input_types.py](../../../tests/test_llvm_input_types.py) 的首批 26 项回归,后续补充 12 项非常量 ONNX 算术测试,共 38 项:typed DSL 和经 ONNX checker 验证的 INT32 标量 Add 在三个优化级别均拒绝且不写出文件;没有常量加载的整数四则运算及 neg 也拒绝;覆盖优化前拒绝、`verify_ir=True`、已有文件保留和直接 `LLVMCodegen.save()`。对应 f32/f64 输入的 12 份成功输出全部执行 LLVM `parse_assembly(...).verify()`,另验证整数循环控制仍生成合法 LLVM。 +如果以后补 LLVM 整数除法,还需要单独处理除零和溢出。直接输出 `sdiv` 不能保证 RV32 在这些情况下的结果,因为 LLVM 将相应情况定义为未定义行为。[LLVM 对 sdiv 的要求](https://llvm.org/docs/LangRef.html#sdiv-instruction) -生产类型检查只使用 Python,不新增 llvmlite 运行时依赖;LLVM 解析与验证在回归测试中执行。 +#### 怎么证明修好了 + +[test_llvm_input_types.py](../../../tests/test_llvm_input_types.py) 共 38 项测试,首批 26 项,后来补充 12 项。测试检查整数输入明确失败且不写文件,已有文件保持原样,直接调用代码生成器也会拒绝不支持的输入。成功的 f32/f64 输出必须通过 LLVM 解析和验证;已有整数循环控制也要继续生成合法文件。 | 修复后的输入 | none | basic | all | |---|---|---|---| -| typed DSL i32 const/Add | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | -| checker 合法的 ONNX INT32 initializer Add | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | -| checker 合法的 ONNX INT32 参数 Add/Sub/Mul/Div | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | 明确失败,不写出 `.ll` | -| DSL/ONNX f32、f64 const/Add | 成功,LLVM 解析/验证通过 | 成功,LLVM 解析/验证通过 | 成功,LLVM 解析/验证通过 | - -**验证结果:**相关测试 121 项通过。使用前文所列 Python/QEMU 环境执行 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py`,完整回归 **3501 passed,0 failed,0 skipped**,耗时 154.62 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。 +| 带 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 检查通过 | -**后续补测及修复前后对照(2026-10-12):**新增的 12 项用例使用两个 INT32 运行时参数,没有 initializer,覆盖 Add/Sub/Mul/Div × none/basic/all,并通过 ONNX checker 验证。它们避免常量折叠或常量加载错误掩盖算术发射路径,检查编译入口明确拒绝且不写出 `.ll`。 +新增的 12 项 ONNX 测试使用两个运行时整数参数,没有预先给定的常量,覆盖四则运算和三个优化级别。这样能单独检查整数算术问题,不会只测到“整数常量读错了”。所有模型都通过 ONNX checker 检查。 -对照时分别从 Git 导出修复前 `2dc976c` 和 R1 修复提交 `492bcfb` 的源码到两个临时目录,将同一份当前测试文件复制到两边,校验测试内容一致并确认 Python 实际加载各自导出的生产模块;两边均运行 `python -m pytest -q tests/test_llvm_input_types.py`。未回退当前工作区,也未混入未提交的 R2 代码。 +将同一份最终测试放到两个独立临时目录,分别加载修复前 `2dc976c` 和修复后 `492bcfb` 的生产代码,执行 `python -m pytest -q tests/test_llvm_input_types.py`,结果如下: | 相同的 38 项 R1 测试 | 通过 | 失败 | 跳过 | |---|---|---|---| | 修复前代码 `2dc976c` | 13 | 25 | 0 | | 修复后代码 `492bcfb` | 38 | 0 | 0 | -修复前的 25 项失败均对应整数输入拒绝和失败时保留文件的合同;原有 12 项 f32/f64 输出验证及 1 项整数循环控制测试已通过。另在包含 R2 修复的当前工作区联合运行 R1/R2 回归,**58 passed**。上方 3501 项完整回归保留为首批 26 项测试时的验证记录。 +修复前通过的是 12 项浮点输出检查和 1 项整数循环控制测试;失败的 25 项涉及整数输入应被拒绝、失败时应保留已有文件。两边测试内容相同,并确认 Python 加载了各自目录的代码,没有回退当前工作区或混入 R2 修改。 + +**历史验证记录:**首批修复时,相关测试 **121 项通过**;全量回归 **3501 通过、0 失败、0 跳过**,耗时 154.62 秒。补测后,R1/R2 联合回归 **58 项通过**。全量测试的数字对应各次运行时已有的测试数量;本报告开头和 R4 节给出最新全量结果。 -以上为修复后行为;本节上方的复现和基线/HEAD 对比保留为审查提交 `2dc976c` 的修复前证据。修复后的验收使用上述仓库内的回归测试,本地原始复现脚本不纳入提交。 +生产代码的类型检查不依赖 llvmlite。llvmlite 只用于测试,确认成功生成的文件确实能被 LLVM 读取和验证。 -### R2:多个函数合法重名,导致槽覆盖或类型冲突 +### R2:两个函数里的同名变量,被编译器当成了同一个 -**变更位置:**[inst_select_ext.py](../../../scratchv/backend/inst_select_ext.py#L245) 第 252–254 行;[allocation.py](../../../scratchv/backend/allocation.py#L71) 的 `collect_register_types`。前者以纯值名作为整个程序的 local_slots 字典键;后者也以纯值名对平铺机器流做类型归并。 +#### 发生了什么 -最小结构是:分别使用两个 IRBuilder 建立 `f0`、`f1`,每个函数都 `alloca(1)`、store 常量、load、return,然后把两个 Function 放入同一个 Program。两个函数各自生成 `v_1/v_2/v_3`。`verify_ir(program)` 返回 `(True, [])`;函数内 SSA 值名相同是合法的。 +`f0` 和 `f1` 是两个不同的函数。它们各自有一个内部变量叫 `v_1`,这是合法的,就像两个函数都可以有自己的 `x`。问题在于,修复前编译器只记住了名字 `v_1`,没有记住它属于哪个函数。 -| 函数类型 | 预期 | 实测:naive / greedy / linear | +最小例子是:用两个独立的 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` | +| `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)的测试,也预先给变量名加了函数前缀,避开了重名情况。 -**根因:**`local_bytes` 跨函数累计,第一次 alloca 为 `-8`,第二次为 `-16`;两个槽同名 `v_1`,第二个覆盖第一个字典项,发射的第一处 `-8` 地址因此失去布局元数据。混合精度时,类型收集更早把两个函数的 `v_2` 当作同一虚拟寄存器并报冲突。 +#### 影响和课题关系 -现有多函数测试使用同一个 IRBuilder,计数器持续递增;spill-only 测试显式把函数名加到值名前。这些输入绕开了函数内重名,不能证明实现真的按函数隔离。 +合法的多函数 IR 会编译失败。当前 DSL/ONNX 入口通常只生成一个函数,因此这个问题通常不会出现在普通单函数编译中。 -**影响:**合法多函数 IR 被拒绝;目前 DSL/ONNX 单函数入口通常不触发。报告不把这描述成所有单函数编译均损坏。 +课题没有要求调用者把所有函数的内部变量改成全程序唯一。我们新增的局部内存和类型记录需要正确区分函数,才能让已有的多函数输入继续工作,这属于本次实现需要处理的错误。 -**建议修复:**在平铺前把虚拟寄存器和局部对象按函数命名空间归一化,或者真正逐函数分配/布局后再拼接。键可用 `(function_id, value_name)`。只对 local_slots 换键仍不够,类型表、spill 表和分配器的值身份也需要保持相同作用域。 +#### 我们怎么修复 -**补测:**分别建函数后合并,故意复用所有内部值名;既测同精度也测不同精度,并在修复后分别执行两个函数。这与当前已有的“多函数栈帧累计超过 2032 字节”测试属于不同边界。 +在把不同函数的机器指令合到一起之前,先给它们的内部变量生成不会重复的后端名字。可以理解为给名字补上“属于哪个函数”的身份。调用者仍可在每个函数里使用 `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 | -**我们的解决方案(2026-10-12):**先新增 [test_topic28_function_scope.py](../../../tests/test_topic28_function_scope.py) 的 20 项单元及执行回归,再修改生产代码。修复前为 **19 failed、1 passed**,失败包含局部槽记录覆盖、浮点位宽冲突和 naive spill 身份混用;重复选择的稳定性测试原本通过,单独保留用于约束修复后的命名行为。 +修复前唯一通过的是重复运行选择器时名字保持稳定的测试。失败项包括局部内存记录被覆盖、浮点类型被混淆,以及 naive 分配方式混用同名变量的 spill 记录。 -- [instruction_select.py](../../../scratchv/backend/instruction_select.py) 在每个函数完成选择后、交给平铺机器管线之前,将该函数所有虚拟寄存器的定义及使用映射为唯一的后端名称。映射同时覆盖源 IR 值、参数/全局常量的函数内物化和选择器临时值,保留寄存器类别、位宽及物理寄存器/符号信息。名称通过已有临时名分配接口生成,避开所有源名称和已生成名称;仅多函数程序执行此步骤,单函数命名保持原有行为。 -- [inst_select_ext.py](../../../scratchv/backend/inst_select_ext.py) 使用同一映射同步更新局部对象的 `local_slots` 键,并在开始处理下一个函数前完成更新。这样,后续函数再次使用 `v_1` 时不会覆盖前一个函数的局部槽。类型表、寄存器映射、spill 表和活跃性分析接收同一套唯一值身份,保留现有类型冲突检查。 -- 保留程序级唯一的临时栈偏移,由既有 ABI finalizer 按函数压缩和重排实际栈帧;无需把所有函数的局部空间强行放进一个运行时栈帧,也不要求调用者把函数内 IR 名称改成全程序唯一。重命名仅作用于机器操作数,原始 IR 不被修改;同一个 selector 重复运行得到稳定的名称和槽记录。 -- 单元测试覆盖 f64/f64、f64/f32 的值及局部槽隔离、FPR 位宽保留、naive spill 身份、与编译器名称相似的源值名、原始 IR 保留、重复选择以及基础整数选择器。执行测试覆盖两种精度组合 × 三种分配器 × 简单局部对象/16 个同时存活浮点值的寄存器压力;压力用例明确检查发生 spill,并独立汇编、链接后通过 QEMU 分别调用两个函数,检查返回位模式、fflags 及调用后的栈/GPR 状态。 +测试还检查原始 IR 没被修改、新名字不会撞上用户已有名字、浮点位宽被保留,以及基础整数选择器也能正确区分函数。 + +执行测试覆盖两种精度组合和三种分配器,分别测试简单局部内存和 16 个同时存活的浮点值。后者会让寄存器不够用,必须将部分值保存到栈上。测试独立汇编、链接后,在 QEMU 中分别调用两个函数,检查返回值的二进制编码、浮点异常标志 `fflags`,以及调用后的栈和整数寄存器状态。 | 修复后的输入 | naive | greedy | linear | |---|---|---|---| -| f0:f64、f1:f64,重复 v_1/v_2/v_3 | 编译并分别执行通过 | 编译并分别执行通过 | 编译并分别执行通过 | -| f0:f64、f1:f32,重复 v_1/v_2/v_3 | 编译并分别执行通过 | 编译并分别执行通过 | 编译并分别执行通过 | -| 同名函数值及局部对象,增加 FPR spill 压力 | 两个函数结果均正确 | 两个函数结果均正确 | 两个函数结果均正确 | +| 两个 f64 函数,内部名字重复 | 编译及分别执行通过 | 编译及分别执行通过 | 编译及分别执行通过 | +| 一个 f64、一个 f32 函数,内部名字重复 | 编译及分别执行通过 | 编译及分别执行通过 | 编译及分别执行通过 | +| 内部名字重复,且寄存器不够用、需要 spill | 两个函数结果均正确 | 两个函数结果均正确 | 两个函数结果均正确 | + +**历史验证记录:**新增 20 项全部通过;连同已有的整体、栈和分配测试,**409 项通过**。当时的全量回归为 **3521 通过、0 失败、0 跳过**,耗时 156.36 秒。这次检查的是“不同函数的同名变量能否分开”,与已有的“大栈空间是否按函数计算”测试解决的是两个问题。 -**验证结果:**修复后新增的 20 项测试全部通过;连同既有整体、栈帧和分配回归,**409 passed**。使用前文所列 Python/QEMU 环境执行 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py`,完整回归 **3521 passed,0 failed,0 skipped**,耗时 156.36 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。本节上方的错误与原始对比保留为审查提交 `2dc976c` 的修复前证据。 +### R3:用来核对结果的执行器,读错了浮点常量 -### R3:IR 参照执行器与产品对同一个常量解释不同 +#### 发生了什么 -**变更位置:**[types.py](../../../scratchv/ir/types.py#L118) 新增 `Value.bit_pattern` 并声明其优先于宿主转换;[rv32_emulator.py](../../../scratchv/simulator/rv32_emulator.py#L470) 的 `_resolve` 和第 577 行 `_op_load_const` 仍只使用 const_value/attrs.value。 +项目中的 `IRTraceExecutor` 可以直接计算 IR 的结果,用来和编译后的程序核对。修复前,同一个常量在编译后的程序里是 1.0,在这个执行器里却成了 0.0。 -复现: +例子如下: ```python b.ret(b.load_const(0.0, T.FLOAT32, bit_pattern=0x3f800000)) ``` -这一 IR 通过 verifier,按新增合同,`bit_pattern` 是权威编码,实际值应为 1.0。对 f64 使用 `0x3ff0000000000000` 同理。 +这条常量同时带了两份信息:数值字段写着 `0.0`,`bit_pattern` 字段写着 `0x3f800000`。后者是浮点数的原始二进制编码,在 f32 中表示 **1.0**。 + +本次新增的规则明确规定:提供了 `bit_pattern` 时,以这个编码为准。因此这个合法 IR 应返回 1.0。把辅助数值字段故意写成 0.0,是为了检查每个读取入口是否遵守同一规则。 -| 精度 | 三种分配器真实 ELF 返回位 | IRTraceExecutor 返回位 | +| 类型 | 编译后的程序在 QEMU 中实际返回 | IRTraceExecutor 返回 | |---|---|---| | f32 | `0x3f800000`,即 1.0 | `0x00000000`,即 0.0 | | f64 | `0x3ff0000000000000`,即 1.0 | `0x0000000000000000`,即 0.0 | -所有 QEMU 场景 `fflags=0`。因此问题涉及**普通数值**,并不能用“参照不模拟 fflags/舍入模式”来解释。 +三种寄存器分配方式的编译结果都返回了正确值。所有 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` 没有同步修改。一个入口负责读取运算中的常量,另一个负责执行常量加载,两处都需要修复。 -**影响:**使用 IRTraceExecutor 的值对照可能报错或掩盖错误;本次 QEMU 验收入口没有依赖它充当二进制 oracle,因此既有验收仍通过。 +#### 影响和课题关系 -**建议修复:**两个入口都从权威位模式解码常量,使用 NumPy 对字节做 dtype view,避免再经 Python float 转换。LOAD_CONST attrs 和 Value 的编码优先级应与 selector 一致。对于不承诺精确模拟的运算继续保留现有“值参照”定位,不需要在本课题重写软件浮点单元。 +核对工具读错输入后,可能把正确的编译结果判为错误,也可能让某些错误没有被发现。本次 QEMU 验收没有用 `IRTraceExecutor` 作为预期结果来源,所以那些执行测试仍然通过。 -**补测:**同时覆盖 `_op_load_const` 和 `_resolve`;显式编码与宿主值不同、符号零、NaN payload 是不同输入类别。有限数值例子可以防止把失败误归因于异常标志。 +本课题增加了浮点常量的表示方式,也更新了这个核对工具。它需要按同一规则读取常量,才能继续可靠地核对结果。这属于本次修改的配套修复;修复范围是读准常量,不需要把它改成完整的浮点硬件模拟器。 -**我们的解决方案(2026-10-12):**先新增 [test_ir_trace_constants.py](../../../tests/test_ir_trace_constants.py) 的常量表示单元测试,再修复 [rv32_emulator.py](../../../scratchv/simulator/rv32_emulator.py) 的两个读取入口。最终 55 项测试同时覆盖 f32/f64:普通有限数值、编码为零但宿主值非零、负零、quiet/signaling NaN payload,以及指令独有编码、Value 独有编码和两者一致时的返回路径。预期字节直接由整数编码生成,避免测试与生产解码器共用同一实现。 +#### 我们怎么修复 -- 使用统一的常量解码函数,将编码保存为 NumPy uint32/uint64 数组,再用 `view` 解释为对应 f32/f64。结果为保持原始位模式的零维数组,不经过 Python float;没有显式编码时保留原有数值转换及 i32/f32/f64 行为。解码边界拒绝布尔值、非整数、超出位宽或用于整数 dtype 的浮点编码。 -- `_op_load_const` 沿用 selector 的编码规则:优先使用指令属性,缺省时使用结果 Value 的编码;两处都提供但不一致时,在写入执行器状态前报错。`_resolve` 对常量也使用同一解码函数,避免返回或算术操作数重新读取辅助宿主数值。 -- 对仅存在于指令属性中的编码,执行器按值名记录已执行常量定义的编码;后续解析同一常量时使用该定义。记录在每次 `run()` 及每个函数开始时清空,防止运行复用或函数内合法重名造成串值。不修改原始 IR,不改变数值参照对于 fflags、舍入模式和控制流的既有限制。 -- 单元测试还覆盖编码冲突时不覆盖已有值、两个入口对非法编码的拒绝、无编码常量的兼容行为,以及“加载后返回/参与加法”“重复运行后移除编码定义”“两个函数使用相同常量名”的状态边界。算术结果继续允许既有 NumPy 标量形式,仅检查 dtype 与原始字节,不追加数组接口要求。 +两个读取入口使用同一个解码函数:先把编码按 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 | +| 修复前代码 `814dc9e`,在独立临时目录运行 | 3 | 52 | 0 | | 修复后代码 | 55 | 0 | 0 | -修复前通过的 3 项是无编码的 i32/f32/f64 数值兼容测试。修复后连同既有参照、除法、验证、CNN 和模拟器回归,**309 passed**。扩展回归首次因原临时 QEMU 路径失效而失败;从 Ubuntu 软件源下载并解压 QEMU 10.2.1 到 `/tmp/scratchv-r3-tools/extracted`、恢复测试 PATH 后,上述执行回归全部通过,未把缺失工具改成 skip。 +修复前通过的是 3 项没有明确编码的普通数值测试。修复后还检查了冲突报错时原有值不被覆盖,以及返回值的类型和原始字节;算术结果继续允许原有的 NumPy 标量形式。 -**完整回归:**使用 `/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`,结果为 **3588 passed,0 failed,0 skipped**,耗时 152.77 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。 +**历史验证记录:**连同已有的核对、除法、CNN 和模拟器测试,**309 项通过**。当时的全量回归为 **3588 通过、0 失败、0 跳过**,耗时 152.77 秒。第一次扩大测试时因临时 QEMU 路径失效而失败;下载并解压 QEMU 10.2.1、恢复 PATH 后,执行测试全部通过,没有把工具缺失改成跳过测试。 -本节上方产品与参照的差异保留为审查提交 `2dc976c` 的修复前证据;本次修复限于常量读取的一致性,不实现完整软件浮点单元。 +### R4:重复使用分配器时,空输入带回了上一轮指令 -### R4:空分配结果混入上一轮机器指令 +#### 发生了什么 -**变更位置:**[regalloc_linear.py](../../../scratchv/backend/regalloc_linear.py#L275) 新增 `allocate_result`;[regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py#L22) 在第 22 行对空输入提前返回,而 `_allocated_instructions=[]` 在第 103 行才执行。 +寄存器分配器负责给指令中的临时变量安排实际寄存器。新增的 `allocate_result()` 接口会返回安排后的指令、变量与寄存器的对应表,以及需要的栈空间。 -复现: +修复前,如果重复使用同一个分配器,第一次传入一条指令,第二次传入空列表,第二次的结果仍包含第一条指令。可以理解为上一轮的结果没有清干净。 ```python a = LinearScanAllocator() @@ -238,72 +300,95 @@ a.allocate_result([MI(O.LI, P.vreg("v"), P.immediate(7))]) r = a.allocate_result([]) ``` -实际 `r.instructions == [li %t0 7]`,但 `r.register_map == {}`、`r.spill_bytes == 0`。一个结果对象里的指令和元数据属于不同轮次。 +这里第一轮的指令是“把数字 7 放入寄存器”。第二轮没有指令,应该返回空结果,实际却如下: + +| 第二轮返回的内容 | 应该是什么 | 修复前实际是什么 | +|---|---|---| +| 指令列表 `r.instructions` | 空列表 | 上一轮的 `[li %t0 7]` | +| 寄存器对应表 `r.register_map` | 空字典 | 空字典 | +| 栈上保存临时值的空间 `r.spill_bytes` | 0 字节 | 0 字节 | -**影响:**复用分配器的新结构化 API 返回陈旧产品。CompilerDriver 当前每次新建分配器,所以普通 CLI 编译通常不会遇到这一情况。 +结果中的指令来自上一轮,配套信息却来自当前这轮。后续代码拿到这个结果,就无法把指令和说明对应起来。 -**建议修复:**在每次分配/改写入口、任何提前返回之前清空结构化输出。明确对象复用的状态重置规则;空输入应得到空 instructions、空 map、零字节布局。 +#### 为什么会出错 -**补测:**同一实例按“非空 → 空 → 非空”调用,检查结构化结果和字符串结果属于同一轮。无需乘上所有优化级别。 +代码遇到空输入时直接返回了,但清空已保存指令列表的语句写在后面,没有执行。寄存器表和栈布局已经在分配阶段重置,只有指令列表留下来了。 -**我们的解决方案(2026-10-12):**先新增 [test_regalloc_result_reuse.py](../../../tests/test_regalloc_result_reuse.py) 的 16 项单元测试,在尚未修改生产代码的 `9d08814` 上运行并确认失败,再修复共享的 [regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py) 改写入口。 +相关位置:[regalloc_linear.py](../../../scratchv/backend/regalloc_linear.py#L275) 新增的 `allocate_result()`;[regalloc_rewrite.py](../../../scratchv/backend/regalloc_rewrite.py#L22) 第 22 行提前返回,而指令列表到第 103 行才重置。 -- 在进入 `rewrite_with_spills()` 时立即将 `_allocated_instructions` 绑定到新列表,位置在空输入提前返回及其他检查之前。`allocate_result()` 和 `emit()` 都经过这一入口,因此两种接口的空调用都得到空指令列表。寄存器映射、spill 槽及字节布局继续由本轮分配过程重建,空输入的完整结果为 `AllocationResult([], {}, {}, 0)`。 -- 使用新列表而非对旧列表执行 `clear()`,使已经返回给调用者的结果保留其指令。测试保存前一次结果的独立副本,并在后续空调用、非空调用后检查旧结果未被修改;也检查空结果不会被下一轮追加指令。 -- 测试覆盖 i32/f32/f64 × 两种调用接口的“非空 → 空 → 非空”;发生实际 spill/store/reload 后的空输入及再次分配;新对象与连续空调用;两种接口交替调用,以及相同虚拟寄存器名从 f64 切换为 f32。非空结果与独立的新分配器结果对照,空结果直接检查指令、映射、槽和字节数,并检查栈偏移及 spill/reload 计数归零。 +#### 影响和课题关系 + +直接调用这个接口、并重复使用同一个分配器时,会收到旧指令。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 | +| 修复前代码 `9d08814` | 2 | 14 | 0 | | 修复后代码 | 16 | 0 | 0 | -修复前通过的 2 项是新对象及连续空输入的兼容测试;其余 14 项均因空调用保留旧指令而失败。修复后 16 项全部通过。运行命令为 `python -m pytest -q tests/test_regalloc_result_reuse.py`,前后使用同一测试文件。 +修复前通过的 2 项是新对象和连续空输入测试;其余 14 项都因空调用返回旧指令而失败。前后使用同一测试文件,运行命令为 `python -m pytest -q tests/test_regalloc_result_reuse.py`。 -**完整回归:**使用 `/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 passed,0 failed,0 skipped**,耗时 154.05 秒;仅有既有 tinyfive `numpy.fix` 弃用警告。`git diff --check` 通过。 +**最新全量验证记录:**使用 `/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 秒。 -本节上方的复现保留为审查提交 `2dc976c` 的修复前证据。CompilerDriver 每次新建分配器的行为保持不变,本次修复保障新增结构化 API 与既有字符串 API 在对象复用时返回当前调用的结果。 +各次完整回归仅有已有的 TinyFive `numpy.fix` 弃用警告,`git diff --check` 均通过。上述数字是各次代码修复时的运行记录;本次文字整理没有重新运行这些测试。原始复现脚本和 JSON 证据继续只保留在本地。 -## 3. 哪些改动是必须的:先看端到端依赖 -基线已经有 `DataType.FLOAT64`、若干 F/D MachineOp、整数 DIV 和一个扩展选择器。问题是它们没有形成可靠的生产路径:结果类型经常回到默认 f32;浮点操作数仍可能被整数分配器处理;f64 的常量、spill、ABI 与发射也没有贯通。**本次必要的工作是把已有名义支持变成真实可编译、可调用、可执行的支持,不是再添加一个 FLOAT64 枚举。** +## 3. 为什么支持 f64 需要同时修改多个模块 + +旧代码已经有 `DataType.FLOAT64` 和部分双精度指令,但一个 f64 输入经过整个编译过程时,仍可能在中途被当成 f32、被安排到整数寄存器,或只保存了 4 字节。**要完成课题,需要让双精度值从输入到运行结果一直被正确处理。** 这就是为什么前端、类型、优化器、寄存器分配和输出代码都需要一起检查。 ```mermaid flowchart LR - A[DSL/ONNX保留dtype和常量编码] --> B[IR结果保留类型] - B --> C[优化保持整数和浮点合同] - C --> D[按ISA和dtype选择机器指令] - D --> E[保留GPR/FPR和值宽度] - E --> F[分类分配与4/8字节spill] - F --> G[每函数栈帧和ABI] - G --> H[合法汇编与真实执行验证] + A[读取输入中的类型和常量] --> B[运算结果保留正确类型] + B --> C[优化保持计算结果和约定的浮点行为] + C --> D[选择对应类型的机器指令] + D --> E[安排正确类别的寄存器并保留位宽] + E --> F[寄存器不够时保存完整的4或8字节] + F --> G[为每个函数安排栈空间和调用规则] + G --> H[生成合法汇编并实际运行检查] ``` -每条依赖都可以构造删除后失败的例子: +每一步漏掉,都可能导致具体错误: | 如果撤掉的改动 | 直接后果 | |---|---| -| IRBuilder 结果类型传播 | f64 add 的 dest 回到 f32,下一环节精度/指令不一致 | -| 常量位模式和合法常量池 | `-0.0`/NaN 编码可能变;`li.d` 无标准实现可供正常汇编 | -| FPR 类别及位置约束 | 浮点数据可能分到 t/s 整数寄存器,或比较结果错误分到 FPR | -| 按宽度 spill | f64 被 4 字节 sw/lw 截断,压力下低位结果看似正常却失去高位 | -| ABI 和局部槽合并布局 | spill 与局部内存或保存区重叠;返回时 sp/保存寄存器状态不正确 | -| 浮点优化保护 | 符号零、逐步舍入或 fflags 与执行程序不同 | -| 输入能力校验 | 未支持的浮点/张量进入旧整数近似路径,产生“成功”的错误结果 | +| 保留运算结果的类型 | 两个 f64 相加,结果却被标成 f32,后续按错误精度处理 | +| 保留常量原始编码,并用合法指令读取 | 负零或 NaN 的编码改变,或生成工具不认识的 `li.d` 指令 | +| 区分整数寄存器和浮点寄存器 | 浮点值被放到整数寄存器,或比较得到的布尔值被放到浮点寄存器 | +| 按类型保存栈上的临时值 | 8 字节的 f64 只保存 4 字节,丢失一半数据 | +| 统一安排函数的栈空间和调用规则 | 两份数据使用重叠空间,或函数返回时没有恢复栈和寄存器 | +| 保持浮点优化的正确性 | 优化前后得到不同的符号零、舍入结果或浮点异常标志 | +| 检查是否支持输入的类型和形状 | 不支持的输入被错误处理,编译器还报告成功 | -f32/f64 数据共享 FPR 寄存器文件,用 width 区分即可;不需要另外建立“FPR32 类”和“FPR64 类”。地址仍使用 RV32 GPR。F/D 访存和运算、较窄值在更宽浮点寄存器中的表示属于架构约束。[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) +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. 生产代码逐文件评估(审查时) ### 4.1 类型系统、IR 和前端 @@ -412,9 +497,9 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 单标量参数、三组目标、非张量形状、硬件 sqrt、固定 RNE,以及不启用 DAG/LLVM 的 extended selector 都是清晰的范围选择。若用户要求这些边界之外的能力,需要继续实现;当前不应据此要求把整个 RISC-V 后端一次扩展为通用框架。 -## 5. 测试变更是否有必要,是否在掩盖回归 +## 5. 测试变更是否有必要,是否在掩盖回归(审查时) -测试新增行占主要增量。数量大不自动意味着过度测试;对编译器,应优先检查是否验证真实结果、是否来自独立 oracle,以及矩阵是否对应真实状态组合。 +新增代码里,测试占了很大一部分。判断它们是否有必要,要看三个问题:是否检查了实际结果,预期结果是否独立于被测代码,以及不同配置是否真的会经过不同的处理路径。 ### 5.1 新增测试和辅助文件 @@ -460,7 +545,7 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 | [test_verification.py](../../../tests/test_verification.py) | 新 dtype、除法及参照操作回归有必要;应再补 R3 权威编码。 | | [test_compare_peephole.py](../../../tests/test_compare_peephole.py) | 用本次测量值验证报告渲染及聚合关系,替代非法旧汇编时代固定指令总数,合理。但同源聚合只证明展示一致,不能独立证明编译器没有性能退化;核心固定 micro/CNN 断言仍保留。 | -总体上,未发现把失败简单改成 skip 或删除关键执行断言来通过测试的情况。测试仍有独立输入和真实执行证据。**主要缺口是没有跨到 LLVM 输出验证、独立 Function 的命名空间、权威常量编码的参照入口、以及可复用结构化 API 的状态重置。** +审查时没有发现通过跳过失败测试、或删除关键结果检查来让测试通过的情况。但原有测试漏掉了四类输入:LLVM 成功输出是否真的有效、分别创建的函数是否会混用同名变量、核对工具是否读取常量的原始编码,以及同一个分配器能否在空调用时清掉旧结果。第 2 节记录了后来补上的测试和修复。 ## 6. 文档、示例与 CI:哪些是必要配套 @@ -499,12 +584,10 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 所谓“不是最低必需”,不意味着应把已经验证的保护删掉。更合理的处理是保持生产正确性,减少重复实现、清晰划分合同和提交边界。 -## 8. 建议的处理顺序与合并条件 +## 8. 当前修复状态和后续建议 + +四个已确认错误都已修复:R1 对 LLVM 暂不支持的整数操作明确报错;R2 区分不同函数的同名变量;R3 按原始编码读取浮点常量;R4 每轮重新建立指令列表,空输入不再返回旧内容。每项都先补充测试,再修复代码,前后结果见第 2 节。最新一次全量回归为 **3604 通过、0 失败、0 跳过**。 -1. **先处理 R1。**共享 typed 前端不能对 LLVM 产出假成功;明确支持或拒绝,加入真实 LLVM 输出验证。不要撤回 IR 类型传播。 -2. **处理 R2。**虚拟值和局部对象按函数保持身份,三种分配器统一受益;避免只修字典覆盖而留下类型/分配身份冲突。 -3. **处理 R3、R4。**参照执行器遵守权威位模式;空分配结果清状态。这两项修复范围较小,适合本分支完成。 -4. **补充接口与限制说明。**记录 ExtendedInstructionSelector 构造参数迁移、设计 API 与实际 API 差异,以及标量点积示例的范围。 -5. **修复后按相关缺陷先做针对验证,再执行现有全量回归。**R1 加 LLVM verifier;R2 真正执行各函数;R3 对照有限数值编码;R4 检查同一实例三轮状态。无需再对每个解析边界机械展开全部分配器/优化级别。 +后续仍建议补充文档中的接口说明:旧的 ExtendedInstructionSelector 构造参数如何迁移,设计草案中的接口与实际代码如何对应,以及当前点积示例只展示标量乘加、没有证明通用张量点积已支持。 -本次对必要性的最终判断是:**类型传播、真实 F/D 选择、分类分配、8 字节 spill、合法常量发射、ABI 接线和优化安全保护应保留。** 非常规 protobuf 支持、统计/美化、部分保守策略和完整 API 草案属于可以明确拆分或精化的扩展。原始 4 个确定问题中 R1/R2/R3 已修复,R4 仍需处理;全量回归通过不能替代对这些未覆盖合同的针对验证。 +本课题需要保留的能力是:**从输入到结果保持正确类型,选择真实的单/双精度指令,使用正确的寄存器,完整保存 8 字节的 f64,生成合法汇编,正确处理函数调用,并保证优化不改变约定的结果。** 对非常规 protobuf 编码的支持、统计和美化工具、部分更严格的浮点保证,可以单独划分维护范围。调整范围时,应同步说明和测试,让读者知道实际支持到哪里。 From 77bef84c3d03e12af3902269218249df5c6aac24 Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Tue, 13 Oct 2026 15:28:48 +0800 Subject: [PATCH 10/13] fix: restore Python 3.8 compatibility Use minimum-version annotations and standard-library APIs, preserve frozen scheduler construction, and align Python support documentation. Add real Python 3.8 coverage to the existing CI test job and keep ONNX test fixtures consistent across supported NumPy and ONNX versions. --- .github/workflows/ci.yml | 27 ++++++++ CLAUDE.md | 3 +- README.md | 2 +- benchmarks/bench_cnn_schedule.py | 6 +- benchmarks/bench_dsl_diagnostics.py | 8 ++- benchmarks/compare_schedule_disabled.py | 6 +- benchmarks/generate_models.py | 2 + benchmarks/test_regalloc/bench_cnn.py | 2 + benchmarks/test_regalloc/bench_dense.py | 2 + .../test_regalloc/bench_regalloc_linear.py | 2 + benchmarks/test_regalloc/bench_simple.py | 2 + benchmarks/test_regalloc/bench_utils.py | 2 + .../28-README.md" | 4 ++ ...30\344\270\216\351\252\214\346\224\266.md" | 4 +- ...iew\346\212\245\345\221\212-2026-10-12.md" | 35 ++++++++-- memory/memory.md | 2 + scratchv/backend/asm_parser_for_beautifier.py | 26 ++++---- scratchv/backend/schedule_semantics.py | 28 +++++++- scratchv/backend/target_features.py | 5 +- scratchv/frontend/onnx_bits.py | 5 +- scripts/run_topic06_benchmarks.py | 2 + tests/test_asm_beautifier_comments.py | 2 + tests/test_dsl_control_flow_benchmark.py | 2 + tests/test_dsl_diagnostics_benchmark.py | 2 + tests/test_inst_scheduler_llvm.py | 2 + tests/test_inst_scheduler_standalone.py | 3 +- tests/test_regalloc_pseudo.py | 4 +- tests/test_standalone_execution.py | 3 +- tests/test_topic28_ir_frontend.py | 4 +- tests/test_topic28_python_compat.py | 65 +++++++++++++++++++ tests/test_topic28_review_onnx.py | 2 +- 31 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 tests/test_topic28_python_compat.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebafc41..1298c0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,32 @@ jobs: qemu-riscv32 --version $PY -c 'from scratchv.backend.llvm_mca import check_version; print(check_version("llvm-mca"))' + - name: Set up minimum supported Python + id: minimum_python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.8" + update-environment: false + + - name: Run Topic 28 on minimum supported Python + env: + MINIMUM_PYTHON: ${{ steps.minimum_python.outputs.python-path }} + PYTHONHASHSEED: "0" + SCRATCHV_REQUIRE_RISCV_EXECUTION: "1" + run: | + set -euo pipefail + "$MINIMUM_PYTHON" -m venv "$RUNNER_TEMP/topic28-py38" + COMPAT_PYTHON="$RUNNER_TEMP/topic28-py38/bin/python" + "$COMPAT_PYTHON" -m pip install -e . "pytest>=7,<9" + mkdir -p benchmark_reports + "$COMPAT_PYTHON" -m pytest \ + tests/test_topic28_python_compat.py \ + tests/test_topic28_ir_frontend.py \ + tests/test_topic28_acceptance.py \ + tests/test_regalloc_result_reuse.py \ + -v --tb=short \ + --junit-xml=benchmark_reports/topic28_python38.xml + # Includes scheduling and standalone CNN execution regressions. - name: Run all topic tests env: @@ -235,6 +261,7 @@ jobs: benchmark_reports/ir_verifier_smoke/ benchmark_reports/topic04_pass_manager.xml benchmark_reports/topic04_pass_manager.txt + benchmark_reports/topic28_python38.xml retention-days: 30 # ═════════════════════════════════════════════════════════════════════════ diff --git a/CLAUDE.md b/CLAUDE.md index 6583cfb..0c37fd8 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` 为准。 +- 涉及版本敏感 API 的修改,必须用 Python 3.8 验证导入及相关测试。CI 主环境使用 3.12,Topic 28 另有 3.8 兼容测试。 - argparse CLI: `--json` (bool, stdout), `--json-output` (path), `--markdown` (path) - 报告工具: HTML/JSON/MD 三种格式,零外部可视化依赖 diff --git a/README.md b/README.md index 5cf2dae..bc0dc26 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 4d53b70..094b15e 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 5894c29..e051773 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 dfedd8e..aaaf688 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 da68f93..b206e69 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 df25ec8..ffd23f9 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 d5eec4f..404bc58 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 a3b6a72..52589da 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 64e4665..c1f305d 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 6d6e494..39e84e5 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/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" index 7fb3f72..b0cb31d 100644 --- "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" @@ -2,6 +2,10 @@ 本课题完成整数算子扩展及标量 f32/f64 的 RISC-V F/D 指令选择。交付与完成条件见[交付与验收](28-交付与验收.md),详细合同见[技术设计](28-扩展指令选择-设计文档.md)。 +## Python 版本 + +最低支持 Python 3.8,与项目安装配置一致。CI 在 Python 3.12 上运行全量回归,并在独立的 Python 3.8 环境中检查目标配置、ONNX 常量读取、IR/前端、原始课题验收和分配器复用。 + ## 支持的操作和类型 所有新增能力通过 `--extended-isel` 启用。 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" index 46423ac..94b8662 100644 --- "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" @@ -1,6 +1,6 @@ # Topic 28 交付与验收 -更新:2026-10-11。交付按[原始任务](28-扩展指令选择.md)的六项要求组织。 +更新:2026-10-13。交付按[原始任务](28-扩展指令选择.md)的六项要求组织。 ## 要求与交付对应 @@ -21,6 +21,8 @@ W1–W4 对应整数映射和边界;W5–W8 对应 F/D 类型、访存和四 在已安装项目和测试依赖的 Python 环境中运行,PATH 需包含支持 RISC-V 的 Clang、LLD、qemu-riscv32;全量回归还需 readelf。 +最低支持 Python 3.8。现有 CI 测试任务另外创建独立的 3.8 环境,运行 `test_topic28_python_compat.py`、`test_topic28_ir_frontend.py`、`test_topic28_acceptance.py` 和 `test_regalloc_result_reuse.py`,结果保存为 `topic28_python38.xml` 并上传到原有 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 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" index b9a56c9..bebb60b 100644 --- "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" @@ -2,7 +2,7 @@ Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-design`,提交 `2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1`。比较基线:`f3af99d`,即本工作区 `main` / `origin/main` 指向的提交。 -**审查时发现了 4 个可以重现的错误,当时建议修复后再合并。目前四项都已修复,最新一次全量回归为 3604 项通过、0 失败、0 跳过。** 四个问题分别是:LLVM 报成功却生成无效文件、两个函数的同名变量被混淆、核对工具读错浮点常量、空输入返回上一轮指令。 +**审查时发现了 4 个可以重现的错误,当时建议修复后再合并。目前四项都已修复,最新一次全量回归为 3610 项通过、0 失败、0 跳过。** 四个问题分别是:LLVM 报成功却生成无效文件、两个函数的同名变量被混淆、核对工具读错浮点常量、空输入返回上一轮指令。后续 Python 3.8 兼容性修复见第 9 节。 第 2 节先用例子解释每个错误,再说明原因、影响、与课题的关系、修复方法和测试结果。第 4–7 节保留对审查提交的逐文件评估,当前修复状态以第 2 节和第 8 节为准。代码行号指向审查时的版本;各次测试数字注明对应的修复阶段。复现脚本及原始 JSON 证据只作为本地调试资料,不纳入提交。 @@ -162,7 +162,7 @@ return r 修复前通过的是 12 项浮点输出检查和 1 项整数循环控制测试;失败的 25 项涉及整数输入应被拒绝、失败时应保留已有文件。两边测试内容相同,并确认 Python 加载了各自目录的代码,没有回退当前工作区或混入 R2 修改。 -**历史验证记录:**首批修复时,相关测试 **121 项通过**;全量回归 **3501 通过、0 失败、0 跳过**,耗时 154.62 秒。补测后,R1/R2 联合回归 **58 项通过**。全量测试的数字对应各次运行时已有的测试数量;本报告开头和 R4 节给出最新全量结果。 +**历史验证记录:**首批修复时,相关测试 **121 项通过**;全量回归 **3501 通过、0 失败、0 跳过**,耗时 154.62 秒。补测后,R1/R2 联合回归 **58 项通过**。全量测试的数字对应各次运行时已有的测试数量;本报告第 9 节给出后续兼容性验证结果。 生产代码的类型检查不依赖 llvmlite。llvmlite 只用于测试,确认成功生成的文件确实能被 LLVM 读取和验证。 @@ -343,7 +343,7 @@ r = a.allocate_result([]) 修复前通过的 2 项是新对象和连续空输入测试;其余 14 项都因空调用返回旧指令而失败。前后使用同一测试文件,运行命令为 `python -m pytest -q tests/test_regalloc_result_reuse.py`。 -**最新全量验证记录:**使用 `/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 秒。 +**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 证据继续只保留在本地。 @@ -586,8 +586,35 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 ## 8. 当前修复状态和后续建议 -四个已确认错误都已修复:R1 对 LLVM 暂不支持的整数操作明确报错;R2 区分不同函数的同名变量;R3 按原始编码读取浮点常量;R4 每轮重新建立指令列表,空输入不再返回旧内容。每项都先补充测试,再修复代码,前后结果见第 2 节。最新一次全量回归为 **3604 通过、0 失败、0 跳过**。 +四个已确认错误都已修复:R1 对 LLVM 暂不支持的整数操作明确报错;R2 区分不同函数的同名变量;R3 按原始编码读取浮点常量;R4 每轮重新建立指令列表,空输入不再返回旧内容。每项都先补充测试,再修复代码,前后结果见第 2 节。后续兼容性修复后的全量回归为 **3610 通过、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+。现有 CI 测试任务增加独立的 3.8 环境,运行目标配置、常量读取、IR/前端、99 项原始验收和分配器复用测试,共 145 项。主 3.12 环境继续运行完整回归,兼容性结果上传到原有 test-reports artifact。 + +**先失败、再修复:**新增 [test_topic28_python_compat.py](../../../tests/test_topic28_python_compat.py) 的 6 项公开接口测试。在临时目录导出修复前 `e061861` 的生产代码,复制同一测试文件,并确认实际加载该目录的模块。Python 3.8.20 下,修复前 **6 项全部失败**;修复后 **6 项全部通过**。 + +修复入口后,第一次扩大到 3.8 全库测试得到 **3543 通过、67 失败**。这些失败继续暴露了上面列出的旧路径 API、注解、枚举比较和测试夹具类型问题。逐项修复后,相关测试 **125 项全部通过**;未把失败改为跳过,也未修改 CNN 生产算子来绕过夹具错误。 + +**两种版本的完整回归:**均执行 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py`。 + +| Python 环境 | 通过 | 失败 | 跳过 | 耗时 | +|---|---|---|---|---| +| 3.8.20,最低支持版本 | 3610 | 0 | 0 | 353.53 秒 | +| 3.12,常用测试环境 | 3610 | 0 | 0 | 159.18 秒 | + +3.12 环境只有既有 TinyFive `numpy.fix` 弃用提示。两个环境均实际执行原始 99 项验收,未跳过 RISC-V 执行测试。新增 CI 的 145 项入口也已在本地 3.8 环境单独通过。`git diff --check` 通过。 + +验证使用真实的 Python 3.8.20 和 3.12 解释器,以及 Clang/LLD/QEMU。旧环境通过项目依赖声明安装可用版本:NumPy 1.24.4、ONNX 1.17.0、protobuf 5.29.6、pytest 8.3.5。GitHub Actions 配置已检查语法和环境隔离,本地验证运行相同的测试入口;远端任务结果需以 Actions 实际运行记录为准。 diff --git a/memory/memory.md b/memory/memory.md index bec2156..9a90916 100644 --- a/memory/memory.md +++ b/memory/memory.md @@ -1,5 +1,7 @@ # ScratchV engineering memory +[2026-10-13] Python 最低版本兼容必须在真实最低版本环境中导入完整包并运行公开入口;AST 能解析、较新版本回归通过,都不能发现旧解释器的注解求值、dataclass 参数和路径 API 问题。NumPy 1.x 与 2.x 的标量提升规则不同,要求 f32 的 ONNX 测试夹具应显式使用 f32 运算数;pybind 枚举应比较值而非实例身份。适用于项目声明 Python 3.8+ 时的兼容验证和 CI。 + [2026-09-18] 课题 01 的解析成功和块数检查不能证明控制流执行正确;验收须核对比较契约、分支合流、循环变量更新与零次路径。当前 DSLInterpreter 的分支处理为空操作,不可作为控制流执行 oracle;未更新条件变量的旧 while 示例仅适合解析测试。 [2026-09-13] DSL benchmark 的详细日志按用例使用默认关闭的 details/summary 折叠,汇总表、失败检查和性能提示保留在外层;GitHub Markdown 与本地 HTML 均需支持,HTML 中的诊断源码必须转义。 diff --git a/scratchv/backend/asm_parser_for_beautifier.py b/scratchv/backend/asm_parser_for_beautifier.py index 88bf8f3..6e0a59f 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 " @@ -182,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 @@ -231,7 +231,7 @@ def _split_operands( text: str, *, allow_top_level_spaces: bool = False, -) -> Optional[list[str]]: +) -> Optional[List[str]]: """按顶层逗号拆分;格式异常时返回 None。 普通指令的单个操作数不允许包含顶层空格,以便识别缺失逗号的 @@ -241,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 @@ -282,7 +282,7 @@ def _split_operands( def _split_label( code: str, -) -> tuple[Optional[str], str, bool]: +) -> Tuple[Optional[str], str, bool]: """提取可选标签并验证标签格式。""" stripped = code.strip() @@ -302,7 +302,7 @@ def _split_label( def _status_for( label: Optional[str], opcode: Optional[str], - operands: list[str], + operands: List[str], ) -> ParseStatus: """根据 opcode 和操作数数量确定解析状态。 @@ -411,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/schedule_semantics.py b/scratchv/backend/schedule_semantics.py index be80dd2..693fb13 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/target_features.py b/scratchv/backend/target_features.py index 7a07493..04cfa5f 100644 --- a/scratchv/backend/target_features.py +++ b/scratchv/backend/target_features.py @@ -1,6 +1,7 @@ """ISA/ABI requirements for scalar RISC-V instruction selection.""" from dataclasses import dataclass +from typing import FrozenSet from scratchv.ir.types import DataType, OpCode @@ -23,8 +24,8 @@ def __post_init__(self) -> None: ) @property - def extensions(self) -> frozenset[str]: - return frozenset(char.upper() for char in self.isa.removeprefix("rv32")) + 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}: diff --git a/scratchv/frontend/onnx_bits.py b/scratchv/frontend/onnx_bits.py index 7178ae2..74c41e8 100644 --- a/scratchv/frontend/onnx_bits.py +++ b/scratchv/frontend/onnx_bits.py @@ -6,9 +6,10 @@ """ from pathlib import Path +from typing import Dict, Tuple -def _varint(data: bytes, position: int) -> tuple[int, int]: +def _varint(data: bytes, position: int) -> Tuple[int, int]: value = 0 for shift in range(0, 70, 7): if position >= len(data): @@ -66,7 +67,7 @@ def _int32(payload: bytes) -> int: return value - (1 << 32) if value & (1 << 31) else value -def typed_initializer_bits(model_path: str, onnx) -> dict[tuple[str, int], int]: +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 diff --git a/scripts/run_topic06_benchmarks.py b/scripts/run_topic06_benchmarks.py index a83452e..7fb6d44 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 c0d3789..2ff4e73 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_dsl_control_flow_benchmark.py b/tests/test_dsl_control_flow_benchmark.py index ff0474c..e39fd55 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 1ff4506..88d8fbd 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 f32b4a8..e0723a8 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 528b88b..d4f7390 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_regalloc_pseudo.py b/tests/test_regalloc_pseudo.py index 9322703..6d87426 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 @@ -55,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, ): diff --git a/tests/test_standalone_execution.py b/tests/test_standalone_execution.py index 1809432..8122e46 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_ir_frontend.py b/tests/test_topic28_ir_frontend.py index 0bbb081..965761d 100644 --- a/tests/test_topic28_ir_frontend.py +++ b/tests/test_topic28_ir_frontend.py @@ -1,5 +1,7 @@ """Extended instruction-selection IR and DSL coverage.""" +from typing import Tuple + import pytest from scratchv.analysis.ir_verifier import verify_ir @@ -153,7 +155,7 @@ def test_dsl_const_rejects_unsupported_or_invalid_values(source): def _extension_program( opcode: OpCode, - operand_types: tuple[DataType, ...], + operand_types: Tuple[DataType, ...], result_type: DataType, ) -> Program: operands = [ diff --git a/tests/test_topic28_python_compat.py b/tests/test_topic28_python_compat.py new file mode 100644 index 0000000..223bdd1 --- /dev/null +++ b/tests/test_topic28_python_compat.py @@ -0,0 +1,65 @@ +"""Public Topic 28 interfaces exercised on the minimum supported Python.""" + +import pytest + + +class TestTargetFeaturesCompatibility: + @pytest.mark.parametrize( + "isa,abi,expected", + [ + ("RV32IM", "ILP32", frozenset({"I", "M"})), + ("RV32IMF", "ILP32F", frozenset({"I", "M", "F"})), + ("RV32IMFD", "ILP32D", frozenset({"I", "M", "F", "D"})), + ], + ) + def test_normalized_targets_expose_their_extensions(self, isa, abi, expected): + from scratchv.backend.target_features import TargetFeatures + + features = TargetFeatures(isa, abi) + assert features.extensions == expected + + +class TestSchedulerCompatibility: + def test_keyword_only_target_preserves_immutable_instruction_behavior(self): + from dataclasses import FrozenInstanceError, replace + + from scratchv.backend.schedule_semantics import SchedInst + + instruction = SchedInst(0, "BNEZ", ["t0", ".next"], target=".next") + assert instruction.opcode == "bnez" + assert instruction.operands == ("t0", ".next") + assert instruction.uses == frozenset({"x5"}) + assert instruction.target == ".next" + assert replace(instruction, id=1).target == ".next" + with pytest.raises(FrozenInstanceError): + instruction.target = ".other" + with pytest.raises(TypeError): + SchedInst(0, ".label", (), frozenset(), frozenset(), "", 0, ".next") + + +class TestTypedInitializerCompatibility: + @pytest.mark.parametrize( + "dtype,value,expected_bits", + [(1, -0.0, 0x80000000), (11, 1.0, 0x3FF0000000000000)], + ) + def test_reads_scalar_typed_encoding(self, dtype, value, expected_bits, tmp_path): + import onnx + from onnx import helper + + from scratchv.frontend.onnx_bits import typed_initializer_bits + + graph = helper.make_graph( + [], + "main", + [], + [helper.make_tensor_value_info("value", dtype, [])], + [helper.make_tensor("value", dtype, [], [value])], + ) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + onnx.checker.check_model(model) + path = tmp_path / "constant.onnx" + onnx.save(model, path) + + assert typed_initializer_bits(str(path), onnx) == { + ("value", dtype): expected_bits, + } diff --git a/tests/test_topic28_review_onnx.py b/tests/test_topic28_review_onnx.py index 24b12c1..08e0a40 100644 --- a/tests/test_topic28_review_onnx.py +++ b/tests/test_topic28_review_onnx.py @@ -143,7 +143,7 @@ def test_r3_all_variadic_inputs_and_single_input_are_preserved( ): schema = onnx.defs.get_schema(operator) assert schema.min_input == 1 - assert schema.inputs[0].option is onnx.defs.OpSchema.FormalParameterOption.Variadic + assert schema.inputs[0].option == onnx.defs.OpSchema.FormalParameterOption.Variadic values = ([5.0, 3.0, 1.0, -2.0, -4.0] if operator == "Min" else [1.0, 3.0, 5.0, 7.0, 9.0])[:count] names = ["x" if parameter and index == 0 else f"c{index}" for index in range(count)] From 37516a74136a661e65298375cbca275e0c99ae86 Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Tue, 13 Oct 2026 17:57:50 +0800 Subject: [PATCH 11/13] fix: preserve NaN propagation in ONNX Min and Max --- .github/workflows/ci.yml | 1 + .../28-README.md" | 3 +- ...iew\346\212\245\345\221\212-2026-10-12.md" | 52 ++++- memory/memory.md | 2 + scratchv/backend/inst_select_ext.py | 65 +++--- scratchv/frontend/onnx_parser.py | 5 +- scratchv/ir/builder.py | 11 +- scratchv/simulator/rv32_emulator.py | 6 + tests/test_topic28_fifth_review.py | 9 +- tests/test_topic28_fourth_review.py | 7 +- tests/test_topic28_onnx_nan.py | 210 ++++++++++++++++++ tests/test_topic28_sixth_review.py | 13 +- 12 files changed, 340 insertions(+), 44 deletions(-) create mode 100644 tests/test_topic28_onnx_nan.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1298c0c..783b01b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,7 @@ jobs: "$COMPAT_PYTHON" -m pytest \ tests/test_topic28_python_compat.py \ tests/test_topic28_ir_frontend.py \ + tests/test_topic28_onnx_nan.py \ tests/test_topic28_acceptance.py \ tests/test_regalloc_result_reuse.py \ -v --tb=short \ 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" index b0cb31d..a9e6cd3 100644 --- "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" @@ -52,7 +52,8 @@ SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests/test_topic28_accept ## 数值语义与边界 - 整数除法向零截断,余数与被除数同号:`div(-17,5)=-3`,`mod(-17,5)=-2`。除零及 `INT32_MIN/-1` 使用 RV32M 定义,不插入陷阱。 -- 浮点算术和平方根固定使用 `rne`(最近值、相同距离取偶数)。`min/max` 使用 RISC-V 的 NaN、符号零及异常标志语义,不能用 Python `min/max` 直接替代。 +- 浮点算术和平方根固定使用 `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 规范允许实现选择是否提供覆盖。 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" index bebb60b..22581d4 100644 --- "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" @@ -2,7 +2,7 @@ Review 日期:2026-10-12(Asia/Shanghai)。审查对象:`docs/topic28-design`,提交 `2dc976c28bc0ad2ba40aca1417b0fda3a31b1ad1`。比较基线:`f3af99d`,即本工作区 `main` / `origin/main` 指向的提交。 -**审查时发现了 4 个可以重现的错误,当时建议修复后再合并。目前四项都已修复,最新一次全量回归为 3610 项通过、0 失败、0 跳过。** 四个问题分别是:LLVM 报成功却生成无效文件、两个函数的同名变量被混淆、核对工具读错浮点常量、空输入返回上一轮指令。后续 Python 3.8 兼容性修复见第 9 节。 +**首次审查发现的 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 证据只作为本地调试资料,不纳入提交。 @@ -65,9 +65,9 @@ SCRATCHV_REQUIRE_RISCV_EXECUTION=1 \ 这些结果说明报告中的输入确实能触发错误,以及修复通过了列出的测试。测试范围限于实际使用的 ONNX 模型、Python 版本和 RISC-V 工具链。本次没有测量性能,因此没有得出“更快”或“指令更少”的结论。仓库里没有 `CLAUDE.md` 引用的 `.claude/harness/verify/run.py`,所以未执行其中的 L2 检查;实际完成的是上述 pytest 和独立运行检查。 -## 2. 四个已确认的问题及修复结果 +## 2. 已确认的问题及修复结果 -下面的例子记录的是审查提交 `2dc976c` 的错误表现。四个问题现在都已修复,各节分别说明修复方法和测试结果。P1 表示应优先处理,P2 表示需要修复但触发条件较有限。 +R1–R4 的例子记录的是审查提交 `2dc976c` 的错误表现,四项均已修复。R5 来自后续复审,修复前版本为 `77bef84`。各节分别说明修复方法和测试结果。P1 表示应优先处理,P2 表示需要修复但触发条件较有限。 例子中的 `i32` 是 32 位整数,`f32`、`f64` 分别是单精度、双精度浮点数。IR 是编译器在读取输入后、生成目标代码前使用的中间表示。 @@ -77,6 +77,7 @@ SCRATCHV_REQUIRE_RISCV_EXECUTION=1 \ | R2 | P2 | 两个函数里的同名变量被当成同一个,导致编译失败 | 分别创建函数后合并,内部变量名重复 | | R3 | P2 | 用来核对结果的执行器,把应为 1.0 的常量读成 0.0 | 浮点常量明确提供了原始二进制编码 | | R4 | P2 | 上一次处理过指令后,空输入又返回了旧指令 | 重复使用同一个线性扫描寄存器分配器 | +| R5 | P2 | ONNX Min/Max 应返回 NaN,编译后却返回另一个正常数字 | 浮点 Min/Max 的输入中含有 NaN | ### R1:LLVM 报告编译成功,生成的文件却不能用 @@ -348,6 +349,49 @@ r = a.allocate_result([]) 各次完整回归仅有已有的 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.8 CI 入口,不新建流水线。主环境的全量 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 秒** | +| Python 3.8.20 新专项及相关回归 | — | **917 通过、0 失败、0 跳过,68.41 秒** | + +3.8 的 917 项包含新专项、三个既有编码测试文件,以及更新后 CI 入口的 361 项(兼容性、IR/前端、NaN 专项、99 项课题验收、分配器复用)。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 字节。**要完成课题,需要让双精度值从输入到运行结果一直被正确处理。** 这就是为什么前端、类型、优化器、寄存器分配和输出代码都需要一起检查。 @@ -586,7 +630,7 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 ## 8. 当前修复状态和后续建议 -四个已确认错误都已修复:R1 对 LLVM 暂不支持的整数操作明确报错;R2 区分不同函数的同名变量;R3 按原始编码读取浮点常量;R4 每轮重新建立指令列表,空输入不再返回旧内容。每项都先补充测试,再修复代码,前后结果见第 2 节。后续兼容性修复后的全量回归为 **3610 通过、0 失败、0 跳过**,见第 9 节。 +五个已确认错误都已修复:R1 对 LLVM 暂不支持的整数操作明确报错;R2 区分不同函数的同名变量;R3 按原始编码读取浮点常量;R4 每轮重新建立指令列表,空输入不再返回旧内容;R5 保留 ONNX 浮点 Min/Max 的 NaN 传播。每项都先补充测试,再修复代码,前后结果见第 2 节。最新全量回归为 **3826 通过、0 失败、0 跳过**;Python 3.8 本次相关回归 917 项通过,之前完整兼容性回归见第 9 节。 后续仍建议补充文档中的接口说明:旧的 ExtendedInstructionSelector 构造参数如何迁移,设计草案中的接口与实际代码如何对应,以及当前点积示例只展示标量乘加、没有证明通用张量点积已支持。 diff --git a/memory/memory.md b/memory/memory.md index 9a90916..1e1e485 100644 --- a/memory/memory.md +++ b/memory/memory.md @@ -1,5 +1,7 @@ # ScratchV engineering memory +[2026-10-13] ONNX 浮点 Min/Max 的 NaN 传播不同于 RISC-V fmin/fmax 选择正常数字的规则,IR 必须保留来源要求,不能只按同名算子映射。用 ONNX 官方参考实现核对合法模型,再实际执行目标程序;同时检查 qNaN/sNaN 标志、普通数/无穷大和既有 DSL 行为。NaN 补偿运算只能在 NaN 路径执行,否则额外加法会引入溢出或 invalid;值参照工具也要遵循同一 IR 标记和目标符号零约定。 + [2026-10-13] Python 最低版本兼容必须在真实最低版本环境中导入完整包并运行公开入口;AST 能解析、较新版本回归通过,都不能发现旧解释器的注解求值、dataclass 参数和路径 API 问题。NumPy 1.x 与 2.x 的标量提升规则不同,要求 f32 的 ONNX 测试夹具应显式使用 f32 运算数;pybind 枚举应比较值而非实例身份。适用于项目声明 Python 3.8+ 时的兼容验证和 CI。 [2026-09-18] 课题 01 的解析成功和块数检查不能证明控制流执行正确;验收须核对比较契约、分支合流、循环变量更新与零次路径。当前 DSLInterpreter 的分支处理为空操作,不可作为控制流执行 oracle;未更新条件变量的旧 while 示例仅适合解析测试。 diff --git a/scratchv/backend/inst_select_ext.py b/scratchv/backend/inst_select_ext.py index 054b210..1de73bd 100644 --- a/scratchv/backend/inst_select_ext.py +++ b/scratchv/backend/inst_select_ext.py @@ -290,6 +290,39 @@ def _select_sqrt(self, instr: Instruction) -> None: # 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). @@ -301,20 +334,8 @@ def _select_min(self, instr: Instruction) -> None: xor dst, b, selected # a if a < b, otherwise b """ dtype = instr.dest.dtype if instr.dest is not None else None - if dtype == DataType.FLOAT64: - self._require_fp_extension(OpCode.MIN, dtype) - self._emit( - MachineOp.FMIN_D, self._fp_dst(instr), - self._fp_op(instr, 0), self._fp_op(instr, 1), - comment="fmin.d", - ) - elif dtype == DataType.FLOAT32: - self._require_fp_extension(OpCode.MIN, dtype) - self._emit( - MachineOp.FMIN_S, self._fp_dst(instr), - self._fp_op(instr, 0), self._fp_op(instr, 1), - comment="fmin.s", - ) + 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) @@ -354,20 +375,8 @@ def _select_max(self, instr: Instruction) -> None: Integer max uses the same full-width-mask selection scheme as min. """ dtype = instr.dest.dtype if instr.dest is not None else None - if dtype == DataType.FLOAT64: - self._require_fp_extension(OpCode.MAX, dtype) - self._emit( - MachineOp.FMAX_D, self._fp_dst(instr), - self._fp_op(instr, 0), self._fp_op(instr, 1), - comment="fmax.d", - ) - elif dtype == DataType.FLOAT32: - self._require_fp_extension(OpCode.MAX, dtype) - self._emit( - MachineOp.FMAX_S, self._fp_dst(instr), - self._fp_op(instr, 0), self._fp_op(instr, 1), - comment="fmax.s", - ) + 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) diff --git a/scratchv/frontend/onnx_parser.py b/scratchv/frontend/onnx_parser.py index af18519..003cee7 100644 --- a/scratchv/frontend/onnx_parser.py +++ b/scratchv/frontend/onnx_parser.py @@ -278,7 +278,10 @@ def _reduce_min_max(self, inputs: list[Value], outputs: list[str], result = inputs[0] reduce_pair = getattr(self.builder, operator) for value in inputs[1:]: - result = reduce_pair(result, value) + 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], diff --git a/scratchv/ir/builder.py b/scratchv/ir/builder.py index b48002d..c0ab656 100644 --- a/scratchv/ir/builder.py +++ b/scratchv/ir/builder.py @@ -105,6 +105,7 @@ def make_const( 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( @@ -116,7 +117,7 @@ def _binary_arith( ) self.reserve_names([lhs.name, rhs.name]) dest = self.make_value(dtype=lhs.dtype) - self._emit(opcode, dest, [lhs, rhs]) + self._emit(opcode, dest, [lhs, rhs], **attrs) return dest def _unary_arith( @@ -149,14 +150,18 @@ def rem(self, lhs: Value, rhs: Value) -> Value: OpCode.REM, lhs, rhs, frozenset({DataType.INT32}), ) - def min(self, lhs: Value, rhs: Value) -> Value: + 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) -> Value: + 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: diff --git a/scratchv/simulator/rv32_emulator.py b/scratchv/simulator/rv32_emulator.py index fc9353d..9230dfe 100644 --- a/scratchv/simulator/rv32_emulator.py +++ b/scratchv/simulator/rv32_emulator.py @@ -538,6 +538,9 @@ def _op_rem(self, instr): 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 @@ -545,6 +548,9 @@ def _op_min(self, instr): 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 diff --git a/tests/test_topic28_fifth_review.py b/tests/test_topic28_fifth_review.py index 26b2c0f..529b799 100644 --- a/tests/test_topic28_fifth_review.py +++ b/tests/test_topic28_fifth_review.py @@ -5,8 +5,10 @@ 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 @@ -82,9 +84,12 @@ def test_t12_typed_wire_bits_and_flags(target, packed, pattern, operation, tmp_p result = CompilerDriver(configuration("greedy", "all", True, isa=isa, abi=abi)).compile( str(path), str(tmp_path / "out.s")) assert result.success, result.errors - two = 0x40000000 if dtype is T.FLOAT32 else 0x4000000000000000 + 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 ( - two, 0x10 if pattern == "snan" else 0) + 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 diff --git a/tests/test_topic28_fourth_review.py b/tests/test_topic28_fourth_review.py index 83dc49f..cda842c 100644 --- a/tests/test_topic28_fourth_review.py +++ b/tests/test_topic28_fourth_review.py @@ -157,13 +157,16 @@ def test_t12_onnx_snan_arithmetic_sets_invalid(dtype, isa, abi, operation, tmp_p array = np.asarray(raw, dtype=np.uint32 if dtype is T.FLOAT32 else np.uint64).view( np.float32 if dtype is T.FLOAT32 else np.float64) nt = TensorProto.FLOAT if dtype is T.FLOAT32 else TensorProto.DOUBLE - path, _ = save_model(helper.make_graph([helper.make_node(operation, ["a", "b"], ["out"])], + path, model = save_model(helper.make_graph([helper.make_node(operation, ["a", "b"], ["out"])], "main", [], [helper.make_tensor_value_info("out", nt, [])], [numpy_helper.from_array(array, name="a"), numpy_helper.from_array( np.asarray(2.0, dtype=array.dtype), name="b")]), tmp_path) + with np.errstate(invalid="ignore"): + assert np.isnan(ReferenceEvaluator(model).run(None, {})[0]).all() result = CompilerDriver(config("greedy", "all", True, 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, isa=isa, abi=abi) == (float_bits(2.0, dtype), 0x10) + canonical_nan = 0x7FC00000 if dtype is T.FLOAT32 else 0x7FF8000000000000 + assert observe(result.output_text, tmp_path, dtype, isa=isa, abi=abi) == (canonical_nan, 0x10) @pytest.mark.parametrize("name", ["_zero", "__scratchv_peephole_zero_1", "__scratchv_peephole_zero_2"]) diff --git a/tests/test_topic28_onnx_nan.py b/tests/test_topic28_onnx_nan.py new file mode 100644 index 0000000..7a1e297 --- /dev/null +++ b/tests/test_topic28_onnx_nan.py @@ -0,0 +1,210 @@ +"""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(" Date: Wed, 14 Oct 2026 13:57:03 +0800 Subject: [PATCH 12/13] fix: emit exact LLVM floating-point constants --- .github/workflows/ci.yml | 28 -- CLAUDE.md | 2 +- .../28-README.md" | 2 +- ...30\344\270\216\351\252\214\346\224\266.md" | 4 +- ...iew\346\212\245\345\221\212-2026-10-12.md" | 24 +- ...241\346\212\245\345\221\212-2026-10-14.md" | 139 ++++++++ memory/memory.md | 4 +- scratchv/backend/llvm_codegen.py | 43 ++- scratchv/compiler.py | 4 +- scratchv/optimizer/constant_folding.py | 2 +- tests/test_llvm_scalar_constants.py | 298 ++++++++++++++++++ tests/test_topic28_python_compat.py | 65 ---- 12 files changed, 493 insertions(+), 122 deletions(-) create mode 100644 "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" create mode 100644 tests/test_llvm_scalar_constants.py delete mode 100644 tests/test_topic28_python_compat.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 783b01b..ebafc41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,33 +133,6 @@ jobs: qemu-riscv32 --version $PY -c 'from scratchv.backend.llvm_mca import check_version; print(check_version("llvm-mca"))' - - name: Set up minimum supported Python - id: minimum_python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.8" - update-environment: false - - - name: Run Topic 28 on minimum supported Python - env: - MINIMUM_PYTHON: ${{ steps.minimum_python.outputs.python-path }} - PYTHONHASHSEED: "0" - SCRATCHV_REQUIRE_RISCV_EXECUTION: "1" - run: | - set -euo pipefail - "$MINIMUM_PYTHON" -m venv "$RUNNER_TEMP/topic28-py38" - COMPAT_PYTHON="$RUNNER_TEMP/topic28-py38/bin/python" - "$COMPAT_PYTHON" -m pip install -e . "pytest>=7,<9" - mkdir -p benchmark_reports - "$COMPAT_PYTHON" -m pytest \ - tests/test_topic28_python_compat.py \ - tests/test_topic28_ir_frontend.py \ - tests/test_topic28_onnx_nan.py \ - tests/test_topic28_acceptance.py \ - tests/test_regalloc_result_reuse.py \ - -v --tb=short \ - --junit-xml=benchmark_reports/topic28_python38.xml - # Includes scheduling and standalone CNN execution regressions. - name: Run all topic tests env: @@ -262,7 +235,6 @@ jobs: benchmark_reports/ir_verifier_smoke/ benchmark_reports/topic04_pass_manager.xml benchmark_reports/topic04_pass_manager.txt - benchmark_reports/topic28_python38.xml retention-days: 30 # ═════════════════════════════════════════════════════════════════════════ diff --git a/CLAUDE.md b/CLAUDE.md index 0c37fd8..43dbfcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,6 @@ docs/ ## 代码风格 - Python 3.8+ compatible, type hints;版本与注解规则以 `CONTRIBUTING.md` 为准。 -- 涉及版本敏感 API 的修改,必须用 Python 3.8 验证导入及相关测试。CI 主环境使用 3.12,Topic 28 另有 3.8 兼容测试。 +- 测试沿用现有 CI 流程;按用户要求移除 Python 3.8 专项测试。项目最低版本仍为 Python 3.8,代码必须兼容该版本。 - argparse CLI: `--json` (bool, stdout), `--json-output` (path), `--markdown` (path) - 报告工具: HTML/JSON/MD 三种格式,零外部可视化依赖 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" index a9e6cd3..0591b8a 100644 --- "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" @@ -4,7 +4,7 @@ ## Python 版本 -最低支持 Python 3.8,与项目安装配置一致。CI 在 Python 3.12 上运行全量回归,并在独立的 Python 3.8 环境中检查目标配置、ONNX 常量读取、IR/前端、原始课题验收和分配器复用。 +项目最低支持 Python 3.8,与安装配置一致,代码必须兼容该版本。现有 CI 使用 Python 3.12 执行功能测试;这是测试环境,不改变项目的最低版本要求。按用户要求移除 Python 3.8 专项测试。 ## 支持的操作和类型 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" index 94b8662..98ebba9 100644 --- "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" @@ -1,6 +1,6 @@ # Topic 28 交付与验收 -更新:2026-10-13。交付按[原始任务](28-扩展指令选择.md)的六项要求组织。 +更新:2026-10-14。交付按[原始任务](28-扩展指令选择.md)的六项要求组织。 ## 要求与交付对应 @@ -21,7 +21,7 @@ W1–W4 对应整数映射和边界;W5–W8 对应 F/D 类型、访存和四 在已安装项目和测试依赖的 Python 环境中运行,PATH 需包含支持 RISC-V 的 Clang、LLD、qemu-riscv32;全量回归还需 readelf。 -最低支持 Python 3.8。现有 CI 测试任务另外创建独立的 3.8 环境,运行 `test_topic28_python_compat.py`、`test_topic28_ir_frontend.py`、`test_topic28_acceptance.py` 和 `test_regalloc_result_reuse.py`,结果保存为 `topic28_python38.xml` 并上传到原有 test-reports artifact。 +代码必须兼容 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 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" index 22581d4..3c122c9 100644 --- "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" @@ -367,7 +367,7 @@ r = a.allocate_result([]) - 后端先执行 `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.8 CI 入口,不新建流水线。主环境的全量 pytest 也会自动运行这份测试。 +- 新测试由现有 Python 3.12 主环境的全量 pytest 自动运行,不新建流水线。 #### 怎么证明修好了 @@ -384,9 +384,8 @@ r = a.allocate_result([]) | 新增专项 216 项 | 156 失败、60 通过 | 216 通过 | | 专项加相关既有编码测试,共 312 项 | 216 失败、96 通过 | 312 通过,包含在全量回归中 | | Python 3.12 全量 `tests benchmarks/test_benchmark.py` | 之前 3610 项通过,但 NaN 预期有误 | **3826 通过、0 失败、0 跳过,166.83 秒** | -| Python 3.8.20 新专项及相关回归 | — | **917 通过、0 失败、0 跳过,68.41 秒** | -3.8 的 917 项包含新专项、三个既有编码测试文件,以及更新后 CI 入口的 361 项(兼容性、IR/前端、NaN 专项、99 项课题验收、分配器复用)。3.12 全量只有既有 TinyFive `numpy.fix` 弃用提示。两种环境均设置 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1`,没有跳过 RISC-V 执行。 +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` 通过。 @@ -630,7 +629,7 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 ## 8. 当前修复状态和后续建议 -五个已确认错误都已修复:R1 对 LLVM 暂不支持的整数操作明确报错;R2 区分不同函数的同名变量;R3 按原始编码读取浮点常量;R4 每轮重新建立指令列表,空输入不再返回旧内容;R5 保留 ONNX 浮点 Min/Max 的 NaN 传播。每项都先补充测试,再修复代码,前后结果见第 2 节。最新全量回归为 **3826 通过、0 失败、0 跳过**;Python 3.8 本次相关回归 917 项通过,之前完整兼容性回归见第 9 节。 +五个已确认错误都已修复:R1 对 LLVM 暂不支持的整数操作明确报错;R2 区分不同函数的同名变量;R3 按原始编码读取浮点常量;R4 每轮重新建立指令列表,空输入不再返回旧内容;R5 保留 ONNX 浮点 Min/Max 的 NaN 传播。每项都先补充测试,再修复代码,前后结果见第 2 节。R5 修复时的全量回归为 **3826 通过、0 失败、0 跳过**;代码兼容性修改见第 9 节。 后续仍建议补充文档中的接口说明:旧的 ExtendedInstructionSelector 构造参数如何迁移,设计草案中的接口与实际代码如何对应,以及当前点积示例只展示标量乘加、没有证明通用张量点积已支持。 @@ -646,19 +645,8 @@ ABI 通过 `[regalloc:*]` 注释及正则重写字节偏移,是沿用旧管线 - 真实后端导入还会经过汇编解析器和调度器。解析器的注解改为 3.8 支持的类型;`SchedInst` 用显式构造函数保留只能通过关键字传入的 `target`,继续保持不可变对象、替换和原有位置匹配行为。 - 一些既有测试和基准脚本也会在导入时计算新式注解,补上兼容注解或延迟求值保护。脚本里的 `Path.is_relative_to()` 换为 `relative_to()` 加异常处理,继续检查模块是否来自指定目录,以及模型路径如何展示。 - 旧 ONNX 的枚举可能返回同值但不同实例,测试改为比较枚举值。旧 NumPy 在部分 CNN 测试中会把除法结果提升成 f64,测试夹具明确使用 f32 除数,保持原本要求的 FLOAT 模型;预期结果及执行检查保留。 -- README 和 CLAUDE 的版本说明统一为 Python 3.8+。现有 CI 测试任务增加独立的 3.8 环境,运行目标配置、常量读取、IR/前端、99 项原始验收和分配器复用测试,共 145 项。主 3.12 环境继续运行完整回归,兼容性结果上传到原有 test-reports artifact。 +- README 和 CLAUDE 的版本说明统一为 Python 3.8+;代码保留兼容写法。测试使用 Python 3.12 主环境,结果上传到现有 test-reports artifact。 -**先失败、再修复:**新增 [test_topic28_python_compat.py](../../../tests/test_topic28_python_compat.py) 的 6 项公开接口测试。在临时目录导出修复前 `e061861` 的生产代码,复制同一测试文件,并确认实际加载该目录的模块。Python 3.8.20 下,修复前 **6 项全部失败**;修复后 **6 项全部通过**。 +**当前测试安排(2026-10-14):**按用户要求,删除专门的最低版本测试文件、CI 环境及对应报告 artifact。项目仍要求兼容 Python 3.8;功能回归沿用现有 CI 流程,其 Python 3.12 解释器只是测试环境。 -修复入口后,第一次扩大到 3.8 全库测试得到 **3543 通过、67 失败**。这些失败继续暴露了上面列出的旧路径 API、注解、枚举比较和测试夹具类型问题。逐项修复后,相关测试 **125 项全部通过**;未把失败改为跳过,也未修改 CNN 生产算子来绕过夹具错误。 - -**两种版本的完整回归:**均执行 `SCRATCHV_REQUIRE_RISCV_EXECUTION=1 python -m pytest -q tests benchmarks/test_benchmark.py`。 - -| Python 环境 | 通过 | 失败 | 跳过 | 耗时 | -|---|---|---|---|---| -| 3.8.20,最低支持版本 | 3610 | 0 | 0 | 353.53 秒 | -| 3.12,常用测试环境 | 3610 | 0 | 0 | 159.18 秒 | - -3.12 环境只有既有 TinyFive `numpy.fix` 弃用提示。两个环境均实际执行原始 99 项验收,未跳过 RISC-V 执行测试。新增 CI 的 145 项入口也已在本地 3.8 环境单独通过。`git diff --check` 通过。 - -验证使用真实的 Python 3.8.20 和 3.12 解释器,以及 Clang/LLD/QEMU。旧环境通过项目依赖声明安装可用版本:NumPy 1.24.4、ONNX 1.17.0、protobuf 5.29.6、pytest 8.3.5。GitHub Actions 配置已检查语法和环境隔离,本地验证运行相同的测试入口;远端任务结果需以 Actions 实际运行记录为准。 +兼容性代码修改当时在 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 0000000..409c7a9 --- /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,139 @@ +# Topic 28 复审报告(2026-10-14) + +**本次确认两个功能问题:R7 是 LLVM 常量输出假成功或改变数值,优先级 P1;R6 是 ONNX 正负零处理与官方实现不一致,优先级 P2。另有两项 P3 规范/维护意见。** R1–R5 的修复仍然有效,但当前变更还不能宣称所有支持输入都正确。 + +使用 Matt 的 `code-review` 工程技能,由两个独立审查分别检查项目规范(Standards)和课题要求(Spec),主审再核对可疑结果。日期按 Asia/Shanghai。 + +**后续本地状态:R7 已修复并通过验证,方案和前后结果见 R7 条目。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#L61)及多个新增 Topic 28 文件使用顶层测试函数。 + +这是明确的文档规则不一致,不影响运算结果。可按行为整理为测试类,或者正式调整规范,使其与项目实际采用的 pytest 写法一致。无需为此改变测试输入或预期。 + +### 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。两条审查轴分别计数,不跨轴合并或重新排序。R7 的本地解决办法与验证结果已写在对应问题后面,R6 仍待处理。 diff --git a/memory/memory.md b/memory/memory.md index 1e1e485..0d8a0b7 100644 --- a/memory/memory.md +++ b/memory/memory.md @@ -1,8 +1,10 @@ # ScratchV engineering memory +[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-13] Python 最低版本兼容必须在真实最低版本环境中导入完整包并运行公开入口;AST 能解析、较新版本回归通过,都不能发现旧解释器的注解求值、dataclass 参数和路径 API 问题。NumPy 1.x 与 2.x 的标量提升规则不同,要求 f32 的 ONNX 测试夹具应显式使用 f32 运算数;pybind 枚举应比较值而非实例身份。适用于项目声明 Python 3.8+ 时的兼容验证和 CI。 +[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 示例仅适合解析测试。 diff --git a/scratchv/backend/llvm_codegen.py b/scratchv/backend/llvm_codegen.py index 720b01a..f7e991f 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, ) @@ -306,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) @@ -618,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_ = " PipelineResult: passes=self.config.passes, disabled_passes=self.config.disabled_passes, ) - if self.config.use_extended_isel: + 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 @@ -510,6 +510,8 @@ def _run_optimizations(self, program: Program) -> PipelineResult: # 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: diff --git a/scratchv/optimizer/constant_folding.py b/scratchv/optimizer/constant_folding.py index 2ae8844..8e46220 100644 --- a/scratchv/optimizer/constant_folding.py +++ b/scratchv/optimizer/constant_folding.py @@ -19,7 +19,7 @@ class ConstantFolder(OptimizationPass): 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, including explicitly selected passes. + for extended instruction selection and LLVM, including explicit passes. """ name = "constant-folding" diff --git a/tests/test_llvm_scalar_constants.py b/tests/test_llvm_scalar_constants.py new file mode 100644 index 0000000..56cd83b --- /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_topic28_python_compat.py b/tests/test_topic28_python_compat.py deleted file mode 100644 index 223bdd1..0000000 --- a/tests/test_topic28_python_compat.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Public Topic 28 interfaces exercised on the minimum supported Python.""" - -import pytest - - -class TestTargetFeaturesCompatibility: - @pytest.mark.parametrize( - "isa,abi,expected", - [ - ("RV32IM", "ILP32", frozenset({"I", "M"})), - ("RV32IMF", "ILP32F", frozenset({"I", "M", "F"})), - ("RV32IMFD", "ILP32D", frozenset({"I", "M", "F", "D"})), - ], - ) - def test_normalized_targets_expose_their_extensions(self, isa, abi, expected): - from scratchv.backend.target_features import TargetFeatures - - features = TargetFeatures(isa, abi) - assert features.extensions == expected - - -class TestSchedulerCompatibility: - def test_keyword_only_target_preserves_immutable_instruction_behavior(self): - from dataclasses import FrozenInstanceError, replace - - from scratchv.backend.schedule_semantics import SchedInst - - instruction = SchedInst(0, "BNEZ", ["t0", ".next"], target=".next") - assert instruction.opcode == "bnez" - assert instruction.operands == ("t0", ".next") - assert instruction.uses == frozenset({"x5"}) - assert instruction.target == ".next" - assert replace(instruction, id=1).target == ".next" - with pytest.raises(FrozenInstanceError): - instruction.target = ".other" - with pytest.raises(TypeError): - SchedInst(0, ".label", (), frozenset(), frozenset(), "", 0, ".next") - - -class TestTypedInitializerCompatibility: - @pytest.mark.parametrize( - "dtype,value,expected_bits", - [(1, -0.0, 0x80000000), (11, 1.0, 0x3FF0000000000000)], - ) - def test_reads_scalar_typed_encoding(self, dtype, value, expected_bits, tmp_path): - import onnx - from onnx import helper - - from scratchv.frontend.onnx_bits import typed_initializer_bits - - graph = helper.make_graph( - [], - "main", - [], - [helper.make_tensor_value_info("value", dtype, [])], - [helper.make_tensor("value", dtype, [], [value])], - ) - model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) - onnx.checker.check_model(model) - path = tmp_path / "constant.onnx" - onnx.save(model, path) - - assert typed_initializer_bits(str(path), onnx) == { - ("value", dtype): expected_bits, - } From 0e2cbc2112e00a8e71df0614ae0f260935e41efa Mon Sep 17 00:00:00 2001 From: ForsakenDelusion Date: Wed, 14 Oct 2026 14:45:56 +0800 Subject: [PATCH 13/13] test: organize Topic 28 regressions into pytest classes --- ...241\346\212\245\345\221\212-2026-10-14.md" | 18 +- memory/memory.md | 2 + tests/test_ir_trace_constants.py | 274 +++--- tests/test_llvm_input_types.py | 241 +++--- tests/test_regalloc_result_reuse.py | 165 ++-- tests/test_rv32_emulator.py | 26 +- tests/test_topic28_acceptance.py | 277 ++++--- tests/test_topic28_cli_integration.py | 291 +++---- tests/test_topic28_constant_folding.py | 135 ++- tests/test_topic28_fifth_review.py | 604 +++++++++----- tests/test_topic28_fourth_review.py | 735 +++++++++++------ tests/test_topic28_function_scope.py | 168 ++-- tests/test_topic28_independent_boundaries.py | 412 ++++++---- tests/test_topic28_independent_division.py | 134 +-- tests/test_topic28_independent_integer.py | 177 ++-- tests/test_topic28_ir_frontend.py | 328 ++++---- tests/test_topic28_isel_integer.py | 227 +++-- tests/test_topic28_onnx_nan.py | 414 ++++++---- tests/test_topic28_overall_review.py | 511 +++++++----- tests/test_topic28_review_backend.py | 257 +++--- tests/test_topic28_review_fixed_uses.py | 115 ++- tests/test_topic28_review_frontend_oracles.py | 485 ++++++----- tests/test_topic28_review_onnx.py | 315 ++++--- tests/test_topic28_review_optimizations.py | 352 +++++--- tests/test_topic28_sixth_review.py | 527 +++++++----- tests/test_topic28_stage2.py | 765 ++++++++--------- tests/test_topic28_stage3.py | 778 +++++++++++------- tests/test_topic28_third_review.py | 749 ++++++++++------- tests/test_verification.py | 72 +- 29 files changed, 5707 insertions(+), 3847 deletions(-) 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" index 409c7a9..873a16f 100644 --- "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" @@ -4,7 +4,7 @@ 使用 Matt 的 `code-review` 工程技能,由两个独立审查分别检查项目规范(Standards)和课题要求(Spec),主审再核对可疑结果。日期按 Asia/Shanghai。 -**后续本地状态:R7 已修复并通过验证,方案和前后结果见 R7 条目。R6 仍待处理。本轮修复仅作本地提交,未 push,也没有更新 PR。** 上面的审查结果针对固定提交 `37516a7`,下面新增的修复记录随本次本地提交保存。 +**后续本地状态:S1、R7 已修复并通过验证,方案和结果见对应条目。S2、R6 仍待处理。后续修复仅作本地提交,未 push,也没有更新 PR。** 上面的审查结果针对固定提交 `37516a7`,下面新增的修复记录随本地提交保存。 ## 范围与方法 @@ -23,12 +23,22 @@ git log f3af99d..37516a7 --oneline ## Standards -### S1,P3:新增测试的组织方式与项目文档不一致 +### S1,P3:新增测试的组织方式与项目文档不一致(已修复) -`docs/reference/CODING_STANDARDS.md` 的 Testing 要求 “Use pytest with class-based test organization”。[新 NaN 测试](../../../tests/test_topic28_onnx_nan.py#L61)及多个新增 Topic 28 文件使用顶层测试函数。 +`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 选择哪个输入。 @@ -136,4 +146,4 @@ SCRATCHV_REQUIRE_RISCV_EXECUTION=1 \ 日志与临时探针保留在本地:`/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。两条审查轴分别计数,不跨轴合并或重新排序。R7 的本地解决办法与验证结果已写在对应问题后面,R6 仍待处理。 +审查时 R6/R7 尚未修复。Standards:2 项,最高 P3;Spec:2 项,最高 P1。两条审查轴分别计数,不跨轴合并或重新排序。后续 S1、R7 已修复,解决办法与验证结果写在对应问题后面;S2、R6 仍待处理。 diff --git a/memory/memory.md b/memory/memory.md index 0d8a0b7..df06286 100644 --- a/memory/memory.md +++ b/memory/memory.md @@ -1,5 +1,7 @@ # 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 标记和目标符号零约定。 diff --git a/tests/test_ir_trace_constants.py b/tests/test_ir_trace_constants.py index e00fe2a..641eca2 100644 --- a/tests/test_ir_trace_constants.py +++ b/tests/test_ir_trace_constants.py @@ -14,11 +14,11 @@ 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), + "one": (0x3F800000, 0x3FF0000000000000), "positive_zero": (0x00000000, 0x0000000000000000), "negative_zero": (0x80000000, 0x8000000000000000), - "quiet_nan": (0x7fc01234, 0x7ff8000000001234), - "signaling_nan": (0x7f801234, 0x7ff0000000001234), + "quiet_nan": (0x7FC01234, 0x7FF8000000001234), + "signaling_nan": (0x7F801234, 0x7FF0000000001234), } TWO = {T.FLOAT32: 0x40000000, T.FLOAT64: 0x4000000000000000} THREE = {T.FLOAT32: 0x40400000, T.FLOAT64: 0x4008000000000000} @@ -40,22 +40,13 @@ def assert_storage(result, dtype, bits): 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}, - )) - - -@pytest.mark.parametrize("dtype", PRECISIONS) -@pytest.mark.parametrize("case", ENCODINGS) -@pytest.mark.parametrize("entry", ["load", "operand"]) -def test_explicit_constant_encoding_preserves_every_bit(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) + 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): @@ -71,109 +62,142 @@ def encoded_program(dtype, source): return builder, value, instruction -@pytest.mark.parametrize("dtype", PRECISIONS) -@pytest.mark.parametrize("source", ["instruction", "value", "both"]) -def test_return_uses_the_encoding_from_the_constant_definition(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(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(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(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(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(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(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(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() +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_llvm_input_types.py b/tests/test_llvm_input_types.py index d821f9a..b252a5b 100644 --- a/tests/test_llvm_input_types.py +++ b/tests/test_llvm_input_types.py @@ -34,12 +34,14 @@ def compile_add(tmp_path, frontend, dtype, level): ] graph = onnx.helper.make_graph( [onnx.helper.make_node("Add", ["a", "b"], ["r"])], - "main", [], + "main", + [], [onnx.helper.make_tensor_value_info("r", onnx_type, [])], initializers, ) model = onnx.helper.make_model( - graph, opset_imports=[onnx.helper.make_opsetid("", 13)], + graph, + opset_imports=[onnx.helper.make_opsetid("", 13)], ) onnx.checker.check_model(model) model_path = tmp_path / "add.onnx" @@ -48,117 +50,134 @@ def compile_add(tmp_path, frontend, dtype, level): return result, output -@pytest.mark.parametrize("frontend", ["dsl", "onnx"]) -@pytest.mark.parametrize("level", ["none", "basic", "all"]) -def test_llvm_rejects_i32_add_without_output(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(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( - 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(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(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" - +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, + ) + ) -@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(tmp_path, frontend, level, dtype): - llvm = pytest.importorskip("llvmlite.binding") - result, output = compile_add(tmp_path, frontend, dtype, level) + result = driver.compile(str(source), str(output)) - 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 + 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") -def test_llvm_integer_loop_control_still_parses_and_verifies(): - llvm = pytest.importorskip("llvmlite.binding") - program = DSLParser().parse("for i = 0, 3\nendfor\nreturn 0") + 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", + ) - llvm.parse_assembly(LLVMCodegen(program).emit()).verify() + 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_regalloc_result_reuse.py b/tests/test_regalloc_result_reuse.py index 6228558..d841949 100644 --- a/tests/test_regalloc_result_reuse.py +++ b/tests/test_regalloc_result_reuse.py @@ -30,16 +30,23 @@ def value(index): 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)) + ( + 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), - )) + instructions.append( + MI( + move, + P.reg("fa0" if is_float else "a0", reg_class, width), + value(0), + ) + ) return instructions @@ -63,76 +70,78 @@ def _assert_empty(allocator, result): assert allocator.reload_load_count == 0 -@pytest.mark.parametrize("entry", ["allocate_result", "emit"]) -@pytest.mark.parametrize("kind", ["i32", "f32", "f64"]) -def test_nonempty_empty_nonempty_results_belong_to_each_call(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(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(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(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) +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 356c229..0363900 100644 --- a/tests/test_rv32_emulator.py +++ b/tests/test_rv32_emulator.py @@ -39,26 +39,30 @@ def _run_integer_assembly(assembly: str) -> RV32Emulator: return emulator -def test_div_and_rem_use_signed_riscv_semantics() -> None: - emulator = _run_integer_assembly("""\ +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) +""" + ) + 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() -> None: - emulator = _run_integer_assembly("""\ + 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 + assert emulator.regs[REG_ID["t1"]] == (-17 & 0xFFFFFFFF) + assert emulator.regs[REG_ID["t2"]] == 0xFFFFFFFF diff --git a/tests/test_topic28_acceptance.py b/tests/test_topic28_acceptance.py index fa9da75..90336d2 100644 --- a/tests/test_topic28_acceptance.py +++ b/tests/test_topic28_acceptance.py @@ -25,114 +25,177 @@ 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(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) + def test_delivered_programs(self, mode, name, dtype, isa, abi, expected, tmp_path): + source = EXAMPLES / f"{name}.dsl" + args = build_arg_parser().parse_args( + [ + str(source), + "--extended-isel", + "--target-isa", + isa, + "--target-abi", + abi, + "--reg-alloc", + mode, + "--verify-ir", + ] + ) + result = CompilerDriver(args_to_config(args)).compile( + str(source), str(tmp_path / "out.s") + ) + assert result.success, result.errors + assert observe_abi(result.output_text, tmp_path, dtype, isa=isa, abi=abi) == ( + bits(expected, dtype), + 0, + ) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize( + "expression,a,b,expected", + [ + ("div(a,b)", -17, 5, -3), + ("rem(a,b)", -17, 5, -2), + ("mod(a,b)", -17, 5, -2), + ("div(a,b)", 17, -5, -3), + ("rem(a,b)", 17, -5, 2), + ("div(a,b)", 7, 0, -1), + ("rem(a,b)", 7, 0, 7), + ("div(a,b)", -(1 << 31), -1, -(1 << 31)), + ("rem(a,b)", -(1 << 31), -1, 0), + ("min(a,b)", -17, 5, -17), + ("max(a,b)", -17, 5, 5), + ], + ) + def test_integer_operators_and_division_contract( + self, mode, expression, a, b, expected, tmp_path + ): + result = CompilerDriver(configuration(mode, isa="rv32im", abi="ilp32")).compile( + "", + str(tmp_path / "out.s"), + dsl_source=f"a = const({a},dtype:i32)\nb = const({b},dtype:i32)\nr = {expression}\nreturn r", + ) + assert result.success, result.errors + assert observe_abi( + result.output_text, tmp_path, T.INT32, isa="rv32im", abi="ilp32" + ) == (expected, 0) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("target", TARGETS) + def test_dsl_float_arithmetic_and_math(self, mode, target, tmp_path): + dtype, _, isa, abi = target + result = CompilerDriver(configuration(mode, isa=isa, abi=abi)).compile( + "", + str(tmp_path / "out.s"), + dsl_source=( + f"a = const(4.0,dtype:{dtype.value})\nb = const(2.0,dtype:{dtype.value})\n" + "c = add(a,b)\nd = sub(c,b)\ne = mul(d,b)\nf = div(e,b)\n" + "g = neg(f)\nh = abs(g)\ni = sqrt(h)\nj = min(i,a)\nr = max(j,b)\nreturn r" + ), + ) + assert result.success, result.errors + suffix = "s" if dtype is T.FLOAT32 else "d" + assert f"fmv.{suffix} fa0," in result.output_text + assert "rne" in result.output_text and "li.d" not in result.output_text + for mnemonic in ("fadd", "fsub", "fmul", "fdiv", "fsqrt", "fmin", "fmax"): + assert f"{mnemonic}.{suffix} " in result.output_text + assert observe_abi(result.output_text, tmp_path, dtype, isa=isa, abi=abi) == ( + bits(2.0, dtype), + 0, + ) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("target", TARGETS) + def test_onnx_scalar_math_and_dtype(self, mode, target, tmp_path): + dtype, nt, isa, abi = target + nodes = [ + helper.make_node(op, inputs, [output]) + for op, inputs, output in [ + ("Add", ["a", "b"], "sum"), + ("Sub", ["sum", "b"], "difference"), + ("Mul", ["difference", "b"], "product"), + ("Div", ["product", "b"], "quotient"), + ("Sqrt", ["quotient"], "root"), + ("Min", ["root", "a"], "minimum"), + ("Max", ["minimum", "b"], "out"), + ] + ] + graph = helper.make_graph( + nodes, + "main", + [], + [helper.make_tensor_value_info("out", nt, [])], + [ + helper.make_tensor("a", nt, [], [4.0]), + helper.make_tensor("b", nt, [], [2.0]), + ], + ) + 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) + result = CompilerDriver(configuration(mode, isa=isa, abi=abi)).compile( + str(path), str(tmp_path / "out.s") + ) + assert result.success, result.errors + assert observe_abi(result.output_text, tmp_path, dtype, isa=isa, abi=abi) == ( + bits(2.0, dtype), + 0, + ) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize( + "operator,a,b,condition", + [ + ("==", 2.0, 2.0, True), + ("==", 2.0, 3.0, False), + ("!=", 2.0, 3.0, True), + ("!=", 2.0, 2.0, False), + ("<", 2.0, 3.0, True), + ("<", 3.0, 2.0, False), + (">", 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 index 053d5e5..c89971a 100644 --- a/tests/test_topic28_cli_integration.py +++ b/tests/test_topic28_cli_integration.py @@ -9,171 +9,176 @@ from scratchv.simulator.tinyfive import verify_assembly -def test_extended_isel_cli_flag_reaches_compiler_config(): - 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(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(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( - tmp_path: Path, reg_alloc: str, -): - source = """ +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, + 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), + ], ) - - 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( - tmp_path: Path, expression: str, expected: int, -): - pytest.importorskip("tinyfive") - source = """ + 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( - tmp_path: Path, -): - pytest.importorskip("tinyfive") - source = """ +""".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( - tmp_path: Path, -): - source = """ + 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( - tmp_path: Path, -): - source = """ + 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, - ) + 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", - ] + 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 index 6ac1d56..aed0a40 100644 --- a/tests/test_topic28_constant_folding.py +++ b/tests/test_topic28_constant_folding.py @@ -34,72 +34,69 @@ def _fold_unary(op: str, value: int): return changes, instruction -@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(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(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(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(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(value, expected): - changes, instruction = _fold_unary("abs", value) - - assert changes == 1 - assert instruction.opcode is OpCode.LOAD_CONST - assert instruction.attrs["value"] == expected +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 index 529b799..14834b4 100644 --- a/tests/test_topic28_fifth_review.py +++ b/tests/test_topic28_fifth_review.py @@ -14,7 +14,10 @@ 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, + FloatConstant, + MachineInstr as MI, + MachineOp as O, + MachineOperand as P, RegisterClass as RC, ) from scratchv.compiler import CompilerDriver @@ -24,6 +27,7 @@ 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. @@ -34,6 +38,7 @@ def varint(value): value >>= 7 result.append(value) return bytes(result) + return varint(number * 8 + 2) + varint(len(payload)) + payload @@ -41,246 +46,409 @@ 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")) + 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() + 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()) + 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 = 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"")) + 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() -@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(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(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(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(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(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(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) - +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("name", [".text", ".data", ".bss", ".rodata"]) -def test_t14_reserved_call_targets_rejected(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() + @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) -def test_t14_custom_section_cannot_be_a_label(): - 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("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("extended", [False, True]) -def test_t14_elf_sizes_match_distinct_functions(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("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() -@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(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("= 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,count", [(T.INT32, 200), (T.FLOAT32, 200), (T.FLOAT64, 100)]) -def test_t15_spill_only_frames_are_per_function(mode, dtype, count, tmp_path): - width = 64 if dtype is T.FLOAT64 else 32 - kind = RC.GPR if dtype is T.INT32 else RC.FPR - pool = []; instructions = [] - for index in range(3): - function = f"spill{index}" - instructions.append(MI(O.LABEL, target=function, label_role="function")) - for value in range(count): - dest = P.vreg(f"{function}_v{value}", kind, width) - if dtype is T.INT32: - instructions.append(MI(O.LI, dest, P.immediate(value + 1 + index))) - else: - label = f".pool_{index}_{value}" - raw = 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(" 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(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(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(passes, tmp_path): - assert compile_memory(tmp_path, "n4", 7, "greedy", True, passes=passes) == 7 +class TestIntegerOptimizationSemantics: + @pytest.mark.parametrize("operator", ["Add", "Mul"]) + def test_n1_parser_leaves_integer_arithmetic_to_typed_folder( + self, operator, tmp_path + ): + path, _ = integer_model(tmp_path, operator, "Div") + program = ONNXParser().parse(str(path)) + assert verify_ir(program) == (True, []) + opcode = OpCode.ADD if operator == "Add" else OpCode.MUL + assert any( + i.opcode is opcode + for f in program.functions + for b in f.blocks + for i in b.instructions + ) + + @pytest.mark.parametrize("level", LEVELS) + @pytest.mark.parametrize( + "operator,final,expected", + [ + ("Add", "Div", -1073741824), + ("Add", "Min", -2147483648), + ("Mul", "Div", -1), + ("Mul", "Min", -2), + ], + ) + def test_n1_overflow_chain_matches_onnx_and_qemu( + self, level, operator, final, expected, tmp_path + ): + from onnx.reference import ReferenceEvaluator + + path, model = integer_model(tmp_path, operator, final) + reference = ReferenceEvaluator(model).run(None, {})[0] + assert reference.dtype == np.int32 and int(reference) == expected + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + reg_alloc="greedy", + optimize_level=level, + verify_ir=True, + ) + ).compile(str(path), str(tmp_path / "out.s")) + assert result.success, result.errors + assert execute_i32(result.output_text, tmp_path) == expected + + @pytest.mark.parametrize("level", LEVELS) + @pytest.mark.parametrize("input_value", [7, -7]) + def test_n3_integer_identity_keeps_result_definition( + self, level, input_value, tmp_path + ): + assert compile_memory(tmp_path, "n3", input_value, "greedy", True, level) == ( + 2 if input_value > 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 index 965761d..cf5f458 100644 --- a/tests/test_topic28_ir_frontend.py +++ b/tests/test_topic28_ir_frontend.py @@ -19,148 +19,13 @@ ) -def test_builder_propagates_arithmetic_result_types(): - 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(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(): - 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(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(): - 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(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(source): - collector = DSLParser().validate(source) - assert [error.error_code for error in collector.errors] == ["E203"] - with pytest.raises(DSLSyntaxError): - DSLParser().parse(source) - - 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) + Value(f"arg{index}", dtype=dtype) for index, dtype in enumerate(operand_types) ] result = Value("result", dtype=result_type) block = BasicBlock("entry") @@ -172,37 +37,166 @@ def _extension_program( return program -@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(opcode, arity, dtypes): - for dtype in dtypes: - program = _extension_program(opcode, (dtype,) * arity, dtype) +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( - ("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( - 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 + @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 index 095fd3b..b0c5ded 100644 --- a/tests/test_topic28_isel_integer.py +++ b/tests/test_topic28_isel_integer.py @@ -72,8 +72,7 @@ def read(operand: MachineOperand | None) -> int: result = _signed32(read(instruction.src1)) >> read(instruction.src2) elif instruction.op == MachineOp.SLT: result = int( - _signed32(read(instruction.src1)) - < _signed32(read(instruction.src2)) + _signed32(read(instruction.src1)) < _signed32(read(instruction.src2)) ) elif instruction.op == MachineOp.SUB: result = read(instruction.src1) - read(instruction.src2) @@ -87,117 +86,117 @@ def read(operand: MachineOperand | None) -> int: return registers -@pytest.mark.parametrize( - "selector_type", [InstructionSelector, ExtendedInstructionSelector] -) -def test_div_materializes_both_constant_sources(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() -> 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( - 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) +class TestIntegerInstructionSelection: + @pytest.mark.parametrize( + "selector_type", [InstructionSelector, ExtendedInstructionSelector] ) - - selector.run() - registers = _execute_integer_sequence( - selector._instructions, {"left": lhs, "right": rhs} + 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), + ], ) - - 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( - 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() -> 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() -> None: - selector = ExtendedInstructionSelector(Program()) - - assert "rem" in selector.supported_ops - assert "idiv" not in selector.supported_ops - assert "mod" not in selector.supported_ops + 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 index 7a1e297..e86ec22 100644 --- a/tests/test_topic28_onnx_nan.py +++ b/tests/test_topic28_onnx_nan.py @@ -22,16 +22,26 @@ 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)] + 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, + [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, + graph, + opset_imports=[onnx.helper.make_opsetid("", 13)], + ir_version=9, ) onnx.checker.check_model(model) path = tmp_path / "nan.onnx" @@ -39,172 +49,240 @@ def _model(tmp_path, operator, width, values, *, parameter=False): 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")) +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(extended, condition, expected, tmp_path): - source = f"""a = const(1,dtype:i32) + @pytest.mark.parametrize("extended", [False, True]) + @pytest.mark.parametrize("condition,expected", [("a < 3", 7), ("a > 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) @@ -41,79 +64,127 @@ def test_r5_integer_control_flow_constant_stores_are_legal(extended, condition, 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 - - -def _fp(name, width): - return P.vreg(name, RC.FPR, width) - - -@pytest.mark.parametrize("alias", ["ft0", "f0"]) -def test_r8_report_literal_stream_allocates_after_physical_clobber(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) - + 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("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(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(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(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(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(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) - - def run_reference(source, implementation): if implementation == "dsl": return DSLInterpreter().run(source, {}) return IRTraceExecutor(DSLParser().parse(source)).run({}) -@pytest.mark.parametrize("implementation", ["dsl", "ir"]) -@pytest.mark.parametrize("dtype", ["f32", "f64"]) -def test_r7_sqrt_reference_is_defined_and_preserves_precision(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 - - FP_MIN_MAX_CASES = [ - ("min", "nan", "4", 4.0), ("min", "4", "nan", 4.0), - ("max", "nan", "4", 4.0), ("max", "4", "nan", 4.0), - ("min", "-0.0", "0.0", -0.0), ("min", "0.0", "-0.0", -0.0), - ("max", "-0.0", "0.0", 0.0), ("max", "0.0", "-0.0", 0.0), + ("min", "nan", "4", 4.0), + ("min", "4", "nan", 4.0), + ("max", "nan", "4", 4.0), + ("max", "4", "nan", 4.0), + ("min", "-0.0", "0.0", -0.0), + ("min", "0.0", "-0.0", -0.0), + ("max", "-0.0", "0.0", 0.0), + ("max", "0.0", "-0.0", 0.0), ("min", "nan", "nan", float("nan")), ("max", "nan", "nan", float("nan")), ("min", "1.0000000000000002", "1.0000000000000004", 1.0000000000000002), @@ -111,148 +45,283 @@ def test_r7_sqrt_reference_is_defined_and_preserves_precision(implementation, dt ] -@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( - 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(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(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(): - 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(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(): - 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"] - - def compile_source(source, tmp_path, width, mode): path = tmp_path / "review.dsl" path.write_text(source) - result = CompilerDriver(CompilerConfig( - use_extended_isel=True, verify_ir=True, reg_alloc=mode, - target_isa="rv32im" if width == 0 else "rv32imfd", - target_abi="ilp32" if width == 0 else "ilp32d", - )).compile(str(path)) + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + verify_ir=True, + reg_alloc=mode, + target_isa="rv32im" if width == 0 else "rv32imfd", + target_abi="ilp32" if width == 0 else "ilp32d", + ) + ).compile(str(path)) assert result.success, result.errors return result.output_text -@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(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(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 +class TestFrontendReferenceSemantics: + @pytest.mark.parametrize("kind", ["if", "while"]) + @pytest.mark.parametrize( + "condition", + [ + "a < 3", + "3 > 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("left_literal", [False, True]) -def test_r6_verified_integer_control_flow_executes_on_qemu(kind, left_literal, tmp_path): - condition = "3 > a" if left_literal else "a < 3" - if kind == "if": - initial = 4 if left_literal else 1 + @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 = ( - 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" + "a = const(1,dtype:f64)\n" + "then_value = const(2,dtype:f64)\nelse_value = const(3,dtype:f64)\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(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 + 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 index 08e0a40..08d3acb 100644 --- a/tests/test_topic28_review_onnx.py +++ b/tests/test_topic28_review_onnx.py @@ -23,11 +23,14 @@ def _model(tmp_path, nodes, constants, tensor_type, *, parameter=False): graph = onnx.helper.make_graph( - nodes, "main", + 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()], + [ + 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) @@ -37,8 +40,12 @@ def _model(tmp_path, nodes, constants, tensor_type, *, parameter=False): def _instructions(program): - return [instruction for function in program.functions - for block in function.blocks for instruction in block.instructions] + return [ + instruction + for function in program.functions + for block in function.blocks + for instruction in block.instructions + ] def _bits(value, width): @@ -46,11 +53,16 @@ def _bits(value, width): def _compile(path, tmp_path, mode, optimization="none"): - result = CompilerDriver(CompilerConfig( - use_extended_isel=True, reg_alloc=mode, verify_ir=True, - target_isa="rv32imfd", target_abi="ilp32d", - optimize_level=optimization, - )).compile(str(path), str(tmp_path / "review.s")) + result = CompilerDriver( + CompilerConfig( + use_extended_isel=True, + reg_alloc=mode, + verify_ir=True, + target_isa="rv32imfd", + target_abi="ilp32d", + optimize_level=optimization, + ) + ).compile(str(path), str(tmp_path / "review.s")) assert result.success, result.errors return result.output_text @@ -60,114 +72,185 @@ def _execute(assembly, tmp_path, width, expected, flags=0, argument=None): from tests.topic28_runtime import execute_fp actual = execute_fp(assembly, tmp_path, width, argument=argument) - assert actual == (_bits(expected, width), flags), ( - f"actual bits/fflags={actual}, expected={(_bits(expected, width), flags)}" - ) + assert actual == ( + _bits(expected, width), + flags, + ), f"actual bits/fflags={actual}, expected={(_bits(expected, width), flags)}" -@pytest.mark.parametrize("type_name,ir_type,width", TYPES) -@pytest.mark.parametrize("operator,opcode", (("Add", OpCode.ADD), ("Mul", OpCode.MUL))) -def test_r2_float_constants_keep_defined_target_arithmetic( - tmp_path, type_name, ir_type, width, operator, opcode, -): - path = _model( - tmp_path, [onnx.helper.make_node(operator, ["a", "b"], ["y"])], - {"a": 2.0, "b": 3.0}, getattr(onnx.TensorProto, type_name), - ) - program = ONNXParser().parse(str(path)) - assert verify_ir(program) == (True, []) - operation, = [instruction for instruction in _instructions(program) - if instruction.opcode is opcode] - returned = _instructions(program)[-1].operands[0] - assert returned is operation.dest - assert returned.dtype is ir_type and not returned.is_constant - - -@pytest.mark.parametrize("type_name,ir_type,width", TYPES) -@pytest.mark.parametrize("operator,expected", (("Add", 5.0), ("Mul", 6.0))) -@pytest.mark.parametrize("continued", (False, True)) -def test_r2_constant_arithmetic_returns_and_remains_usable( - tmp_path, type_name, ir_type, width, operator, expected, continued, -): - nodes = [onnx.helper.make_node(operator, ["a", "b"], ["first" if continued else "y"])] - if continued: - nodes.append(onnx.helper.make_node("Sub", ["first", "b"], ["y"])) - expected -= 3.0 - path = _model(tmp_path, nodes, {"a": 2.0, "b": 3.0}, getattr(onnx.TensorProto, type_name)) - _execute(_compile(path, tmp_path, "greedy"), tmp_path, width, expected) - - -@pytest.mark.parametrize("type_name,ir_type,width", TYPES) -@pytest.mark.parametrize("operator", ("Add", "Mul")) -@pytest.mark.parametrize("optimization", ("none", "basic", "all")) -def test_r2_constant_arithmetic_keeps_hardware_rounding_and_flags( - tmp_path, type_name, ir_type, width, operator, optimization, -): - precision = 24 if width == 32 else 53 - if operator == "Add": - constants = {"a": 1.0, "b": 2.0 ** -precision} - expected = 1.0 # exactly halfway, RNE keeps the even mantissa - else: - constants = {"a": 1.0 + 2.0 ** -(precision - 1), "b": 1.0 + 2.0 ** -(precision - 1)} - expected = 1.0 + 2.0 ** -(precision - 2) - path = _model( - tmp_path, [onnx.helper.make_node(operator, ["a", "b"], ["y"])], - constants, getattr(onnx.TensorProto, type_name), +class TestONNXArithmeticSemantics: + @pytest.mark.parametrize("type_name,ir_type,width", TYPES) + @pytest.mark.parametrize( + "operator,opcode", (("Add", OpCode.ADD), ("Mul", OpCode.MUL)) ) - _execute(_compile(path, tmp_path, "greedy", optimization), tmp_path, width, expected, flags=1) + def test_r2_float_constants_keep_defined_target_arithmetic( + self, + tmp_path, + type_name, + ir_type, + width, + operator, + opcode, + ): + path = _model( + tmp_path, + [onnx.helper.make_node(operator, ["a", "b"], ["y"])], + {"a": 2.0, "b": 3.0}, + getattr(onnx.TensorProto, type_name), + ) + program = ONNXParser().parse(str(path)) + assert verify_ir(program) == (True, []) + (operation,) = [ + instruction + for instruction in _instructions(program) + if instruction.opcode is opcode + ] + returned = _instructions(program)[-1].operands[0] + assert returned is operation.dest + assert returned.dtype is ir_type and not returned.is_constant + @pytest.mark.parametrize("type_name,ir_type,width", TYPES) + @pytest.mark.parametrize("operator,expected", (("Add", 5.0), ("Mul", 6.0))) + @pytest.mark.parametrize("continued", (False, True)) + def test_r2_constant_arithmetic_returns_and_remains_usable( + self, + tmp_path, + type_name, + ir_type, + width, + operator, + expected, + continued, + ): + nodes = [ + onnx.helper.make_node(operator, ["a", "b"], ["first" if continued else "y"]) + ] + if continued: + nodes.append(onnx.helper.make_node("Sub", ["first", "b"], ["y"])) + expected -= 3.0 + path = _model( + tmp_path, nodes, {"a": 2.0, "b": 3.0}, getattr(onnx.TensorProto, type_name) + ) + _execute(_compile(path, tmp_path, "greedy"), tmp_path, width, expected) -@pytest.mark.parametrize("operator,expected", (("Add", 5), ("Mul", 6))) -def test_r2_integer_constant_fold_is_deferred_to_typed_folder(tmp_path, operator, expected): - path = _model( - tmp_path, [onnx.helper.make_node(operator, ["a", "b"], ["y"])], - {"a": 2, "b": 3}, onnx.TensorProto.INT32, - ) - program = ONNXParser().parse(str(path)) - returned = _instructions(program)[-1].operands[0] - assert not returned.is_constant - assert verify_ir(program) == (True, []) - assert ConstantFolder().optimize(program) == 1 - assert verify_ir(program) == (True, []) - assert returned.dtype is DataType.INT32 - assert returned.is_constant and type(returned.const_value) is int - assert returned.const_value == expected - - -@pytest.mark.parametrize("type_name,ir_type,width", TYPES) -@pytest.mark.parametrize("operator", ("Min", "Max")) -@pytest.mark.parametrize("count", (1, 2, 3, 5)) -@pytest.mark.parametrize("parameter", (False, True)) -def test_r3_all_variadic_inputs_and_single_input_are_preserved( - tmp_path, type_name, ir_type, width, operator, count, parameter, -): - schema = onnx.defs.get_schema(operator) - assert schema.min_input == 1 - assert schema.inputs[0].option == onnx.defs.OpSchema.FormalParameterOption.Variadic - values = ([5.0, 3.0, 1.0, -2.0, -4.0] if operator == "Min" - else [1.0, 3.0, 5.0, 7.0, 9.0])[:count] - names = ["x" if parameter and index == 0 else f"c{index}" for index in range(count)] - constants = {name: value for name, value in zip(names, values) if name != "x"} - path = _model( - tmp_path, [onnx.helper.make_node(operator, names, ["y"])], constants, - getattr(onnx.TensorProto, type_name), parameter=parameter, - ) - program = ONNXParser().parse(str(path)) - assert verify_ir(program) == (True, []) - expected = min(values) if operator == "Min" else max(values) - _execute(_compile(path, tmp_path, "greedy"), tmp_path, width, expected, - argument=values[0] if parameter else None) - opcode = OpCode.MIN if operator == "Min" else OpCode.MAX - reductions = [instruction for instruction in _instructions(program) if instruction.opcode is opcode] - assert len(reductions) == count - 1 - assert all(len(instruction.operands) == 2 and instruction.dest.dtype is ir_type - and all(value.dtype is ir_type for value in instruction.operands) - for instruction in reductions) - assert _instructions(program)[-1].operands[0].dtype is ir_type - - -@pytest.mark.parametrize("operator", ("min", "max")) -def test_r3_empty_variadic_input_has_a_clear_diagnostic(operator): - parser = ONNXParser() - with pytest.raises(ONNXParseError, match="requires at least one input"): - getattr(parser, f"_handle_{operator}")(None, [], ["y"]) + @pytest.mark.parametrize("type_name,ir_type,width", TYPES) + @pytest.mark.parametrize("operator", ("Add", "Mul")) + @pytest.mark.parametrize("optimization", ("none", "basic", "all")) + def test_r2_constant_arithmetic_keeps_hardware_rounding_and_flags( + self, + tmp_path, + type_name, + ir_type, + width, + operator, + optimization, + ): + precision = 24 if width == 32 else 53 + if operator == "Add": + constants = {"a": 1.0, "b": 2.0**-precision} + expected = 1.0 # exactly halfway, RNE keeps the even mantissa + else: + constants = { + "a": 1.0 + 2.0 ** -(precision - 1), + "b": 1.0 + 2.0 ** -(precision - 1), + } + expected = 1.0 + 2.0 ** -(precision - 2) + path = _model( + tmp_path, + [onnx.helper.make_node(operator, ["a", "b"], ["y"])], + constants, + getattr(onnx.TensorProto, type_name), + ) + _execute( + _compile(path, tmp_path, "greedy", optimization), + tmp_path, + width, + expected, + flags=1, + ) + + @pytest.mark.parametrize("operator,expected", (("Add", 5), ("Mul", 6))) + def test_r2_integer_constant_fold_is_deferred_to_typed_folder( + self, tmp_path, operator, expected + ): + path = _model( + tmp_path, + [onnx.helper.make_node(operator, ["a", "b"], ["y"])], + {"a": 2, "b": 3}, + onnx.TensorProto.INT32, + ) + program = ONNXParser().parse(str(path)) + returned = _instructions(program)[-1].operands[0] + assert not returned.is_constant + assert verify_ir(program) == (True, []) + assert ConstantFolder().optimize(program) == 1 + assert verify_ir(program) == (True, []) + assert returned.dtype is DataType.INT32 + assert returned.is_constant and type(returned.const_value) is int + assert returned.const_value == expected + + @pytest.mark.parametrize("type_name,ir_type,width", TYPES) + @pytest.mark.parametrize("operator", ("Min", "Max")) + @pytest.mark.parametrize("count", (1, 2, 3, 5)) + @pytest.mark.parametrize("parameter", (False, True)) + def test_r3_all_variadic_inputs_and_single_input_are_preserved( + self, + tmp_path, + type_name, + ir_type, + width, + operator, + count, + parameter, + ): + schema = onnx.defs.get_schema(operator) + assert schema.min_input == 1 + assert ( + schema.inputs[0].option == onnx.defs.OpSchema.FormalParameterOption.Variadic + ) + values = ( + [5.0, 3.0, 1.0, -2.0, -4.0] + if operator == "Min" + else [1.0, 3.0, 5.0, 7.0, 9.0] + )[:count] + names = [ + "x" if parameter and index == 0 else f"c{index}" for index in range(count) + ] + constants = {name: value for name, value in zip(names, values) if name != "x"} + path = _model( + tmp_path, + [onnx.helper.make_node(operator, names, ["y"])], + constants, + getattr(onnx.TensorProto, type_name), + parameter=parameter, + ) + program = ONNXParser().parse(str(path)) + assert verify_ir(program) == (True, []) + expected = min(values) if operator == "Min" else max(values) + _execute( + _compile(path, tmp_path, "greedy"), + tmp_path, + width, + expected, + argument=values[0] if parameter else None, + ) + opcode = OpCode.MIN if operator == "Min" else OpCode.MAX + reductions = [ + instruction + for instruction in _instructions(program) + if instruction.opcode is opcode + ] + assert len(reductions) == count - 1 + assert all( + len(instruction.operands) == 2 + and instruction.dest.dtype is ir_type + and all(value.dtype is ir_type for value in instruction.operands) + for instruction in reductions + ) + assert _instructions(program)[-1].operands[0].dtype is ir_type + + @pytest.mark.parametrize("operator", ("min", "max")) + def test_r3_empty_variadic_input_has_a_clear_diagnostic(self, operator): + parser = ONNXParser() + with pytest.raises(ONNXParseError, match="requires at least one input"): + getattr(parser, f"_handle_{operator}")(None, [], ["y"]) diff --git a/tests/test_topic28_review_optimizations.py b/tests/test_topic28_review_optimizations.py index 885952a..49a7744 100644 --- a/tests/test_topic28_review_optimizations.py +++ b/tests/test_topic28_review_optimizations.py @@ -25,35 +25,6 @@ def fp_bits(value, width): return int.from_bytes(struct.pack("= 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(): - 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(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(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) +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 index e2c92c6..e62c74c 100644 --- a/tests/test_topic28_stage2.py +++ b/tests/test_topic28_stage2.py @@ -39,393 +39,416 @@ def _binary_selection(method: str, dtype: DataType): rhs = builder.load_const(2.0, dtype) result = getattr(builder, method)(lhs, rhs) instructions = ExtendedInstructionSelector( - builder.program, features=_features(dtype), + 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 - ) - - -def test_sqrt_reaches_typed_ir_from_builder_and_dsl(): - 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 + for instruction in instructions + if instruction.dst is not None and instruction.dst.value == result.name ) - 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(): - program = DSLParser().parse( - "a = const(1e-45, dtype:f32)\n" - "b = const(inf, dtype:f32)\n" - "c = const(nan, dtype:f64)\n" - "return a" +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), + ], ) - 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( - 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(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(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 + @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), + ], ) - 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(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(): - 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(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(): - 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"), + @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), + ], ) - 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(): - 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, + @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 ) - 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(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 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), + ], ) - assert sqrt.dest.dtype is ir_dtype - assert verify_ir(program) == (True, []) - - -def test_target_isa_abi_combination_is_validated(): - with pytest.raises(ValueError, match="ISA/ABI combination"): - TargetFeatures("rv32im", "ilp32d") - - -def test_missing_target_extension_fails_before_selection(): - 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( + 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=TargetFeatures("rv32im", "ilp32"), + features=_features(dtype), ).run() - - -def test_unsupported_float_op_cannot_fall_back_to_integer_selection(): - 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( + 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=TargetFeatures("rv32imf", "ilp32f"), + 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 - -def test_machine_register_class_conflict_is_rejected(): - 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(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(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(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(): - 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", + @pytest.mark.parametrize( + ("onnx_dtype", "ir_dtype"), + [("FLOAT", DataType.FLOAT32), ("DOUBLE", DataType.FLOAT64)], ) - - -def test_rounding_mode_is_rejected_on_non_rounding_operation(): - 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(): - 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(): - with pytest.raises(ValueError, match="not a standard RISC-V instruction"): - AsmEmitter([ + 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.LI_D, - MachineOperand.reg("f0", RegisterClass.FPR), - MachineOperand.immediate(1), + 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", ) - ]).emit() + ).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 index e72c826..ac40004 100644 --- a/tests/test_topic28_stage3.py +++ b/tests/test_topic28_stage3.py @@ -15,7 +15,10 @@ 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, + 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 @@ -25,8 +28,16 @@ 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() + 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): @@ -38,48 +49,23 @@ def cross_assemble(assembly, tmp_path, width=64): 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) + 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 -@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(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 execute_allocated(instructions, memory): registers = {"zero": 0, "x0": 0, "sp": 4096} memory = dict(memory) @@ -105,245 +91,473 @@ def address(operand): return registers.get("fa0", registers.get("a0")) -def test_metadata_missing_width_conflict_and_wrong_physical_class_rejected(): - 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(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(): - with pytest.raises(ValueError, match="Unknown instruction: fence"): - RISCVAEncoder().assemble("fence rw,rw") - - -def test_builtin_emulator_rejects_floating_binary(): - 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(): - 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(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(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])], +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"], ) - 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(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(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, + 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"), + ], ) - 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(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(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(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(): - 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(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(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 + 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 index a8b2238..e79cb16 100644 --- a/tests/test_topic28_third_review.py +++ b/tests/test_topic28_third_review.py @@ -23,16 +23,24 @@ 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} +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) + 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): @@ -50,7 +58,10 @@ def assert_return(assembly, tmp_path, dtype, expected, argument=None): assert execute_i32(assembly, tmp_path, argument=argument) == expected else: width = 32 if dtype is T.FLOAT32 else 64 - assert execute_fp(assembly, tmp_path, width, argument=argument) == (bits(expected, width), 0) + assert execute_fp(assembly, tmp_path, width, argument=argument) == ( + bits(expected, width), + 0, + ) def save_model(graph, tmp_path): @@ -61,299 +72,465 @@ def save_model(graph, tmp_path): return path, model -@pytest.mark.parametrize("dtype", TYPES) -@pytest.mark.parametrize("collision", ["parameter", "later_parameter", "initializer"]) -def test_t1_onnx_names_cannot_alias_generated_values(dtype, collision, tmp_path): - parameter = {"parameter": "v_2", "later_parameter": "v_3"}.get(collision, "input") - constant = "v_2" if collision == "initializer" else "b" - nodes = [helper.make_node("Mul", [parameter, constant], ["p"])] - if collision == "later_parameter": - nodes.extend([helper.make_node("Add", ["p", parameter], ["q"]), - helper.make_node("Add", ["q", parameter], ["out"])]) - else: - nodes.append(helper.make_node("Add", ["p", constant if collision == "initializer" else parameter], ["out"])) - graph = helper.make_graph(nodes, "main", - [helper.make_tensor_value_info(parameter, ONNX_TYPES[dtype], [])], - [helper.make_tensor_value_info("out", ONNX_TYPES[dtype], [])], - [numpy_helper.from_array(np.asarray(3, dtype=ARRAY_TYPES[dtype]), name=constant)]) - path, model = save_model(graph, tmp_path) - expected = ReferenceEvaluator(model).run(None, {parameter: np.asarray(2, dtype=ARRAY_TYPES[dtype])})[0].item() - program = ONNXParser().parse(str(path)) - assert verify_ir(program) == (True, []) - result = CompilerDriver(configuration("greedy", "all", True)).compile(str(path), str(tmp_path / "out.s")) - assert result.success, result.errors - assert_return(result.output_text, tmp_path, dtype, expected, 2) - - -def test_t1_builder_reserves_explicit_and_external_operand_names(): - b = IRBuilder() - b.new_function("main", [Value("v_1", T.INT32)]) - b.new_block("entry") - b.make_value("v_2", T.INT32) - one = b.load_const(1, T.INT32) - external = Value("v_4", T.INT32, True, 3) - result = b.add(one, external) - b.ret(result) - assert len({one.name, result.name, "v_1", "v_2", "v_4"}) == 5 - assert verify_ir(b.program) == (True, []) - - -@pytest.mark.parametrize("verify", [False, True]) -@pytest.mark.parametrize("shape", [(2,), (1, 2), (0,)]) -def test_t2_integer_initializer_tensor_is_rejected_before_output(verify, shape, tmp_path): - initializers = [numpy_helper.from_array(np.full(shape, value, dtype=np.int32), name=name) - for name, value in [("a", 1), ("b", 3)]] - path, model = save_model(helper.make_graph([helper.make_node("Add", ["a", "b"], ["out"])], - "main", [], [helper.make_tensor_value_info("out", TensorProto.INT32, shape)], initializers), tmp_path) - np.testing.assert_array_equal(ReferenceEvaluator(model).run(None, {})[0], np.full(shape, 4, dtype=np.int32)) - output = tmp_path / "out.s" - output.write_text("previous output") - result = CompilerDriver(configuration("greedy", "all", verify)).compile(str(path), str(output)) - assert not result.success - assert any("scalar" in error and "shape" in error for error in result.errors), result.errors - assert output.read_text() == "previous output" - with pytest.raises(ValueError, match="scalar.*shape"): - ExtendedInstructionSelector(ONNXParser().parse(str(path))).run() - - -@pytest.mark.parametrize("shape", [(), (1,), (1, 1)]) -def test_t2_integer_scalar_shapes_are_not_rejected(shape, tmp_path): - path, model = save_model(helper.make_graph([helper.make_node("Add", ["a", "b"], ["out"])], - "main", [], [helper.make_tensor_value_info("out", TensorProto.INT32, shape)], - [numpy_helper.from_array(np.full(shape, value, dtype=np.int32), name=name) - for name, value in [("a", 1), ("b", 3)]]), tmp_path) - assert ReferenceEvaluator(model).run(None, {})[0].item() == 4 - result = CompilerDriver(configuration("greedy", "all")).compile(str(path), str(tmp_path / "out.s")) - assert result.success, result.errors - assert execute_i32(result.output_text, tmp_path) == 4 - - def execute_fixed_address(assembly, tmp_path, dtype): """Place known data at an explicit address; test LOAD itself, not unmapped memory.""" width = 64 if dtype is T.FLOAT64 else 32 payload = 37 if dtype is T.INT32 else bits(3.25, width) store = {T.INT32: "sw a0", T.FLOAT32: "fsw fa0", T.FLOAT64: "fsd fa0"}[dtype] source = tmp_path / "execution.s" - source.write_text(assembly + ( - f'\n.section .review_data,"aw",@progbits\n.p2align 3\n' - f'.review_payload:\n{ ".quad" if width == 64 else ".word" } {payload}\n' - '.text\n.globl _start\n_start:\naddi sp,sp,-16\ncsrw fflags,zero\ncall main\n' - f'{store},0(sp)\n' + ('sw zero,4(sp)\n' if width == 32 else '') + - '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')) + source.write_text( + assembly + + ( + f'\n.section .review_data,"aw",@progbits\n.p2align 3\n' + f'.review_payload:\n{ ".quad" if width == 64 else ".word" } {payload}\n' + ".text\n.globl _start\n_start:\naddi sp,sp,-16\ncsrw fflags,zero\ncall main\n" + f"{store},0(sp)\n" + + ("sw zero,4(sp)\n" if width == 32 else "") + + "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" + ) + ) binary = tmp_path / "execution" - linked = subprocess.run([_required_tool("clang"), "--target=riscv32", "-march=rv32imfd", - "-mabi=ilp32d", "-nostdlib", "-static", "-fuse-ld=lld", "-Wl,-e,_start", - "-Wl,--section-start=.review_data=0x20000", str(source), "-o", str(binary)], - capture_output=True, text=True, timeout=20) + linked = subprocess.run( + [ + _required_tool("clang"), + "--target=riscv32", + "-march=rv32imfd", + "-mabi=ilp32d", + "-nostdlib", + "-static", + "-fuse-ld=lld", + "-Wl,-e,_start", + "-Wl,--section-start=.review_data=0x20000", + str(source), + "-o", + str(binary), + ], + capture_output=True, + text=True, + timeout=20, + ) assert linked.returncode == 0, linked.stderr + "\n" + source.read_text() executed = subprocess.run([_qemu(), str(binary)], capture_output=True, timeout=10) assert executed.returncode == 0, (executed.returncode, executed.stderr) assert len(executed.stdout) == 12 - assert int.from_bytes(executed.stdout[:width // 8], "little") == payload + assert int.from_bytes(executed.stdout[: width // 8], "little") == payload assert int.from_bytes(executed.stdout[8:12], "little") == 0 -@pytest.mark.parametrize("mode", MODES) -@pytest.mark.parametrize("dtype,extended", [(T.INT32, False), (T.INT32, True), (T.FLOAT32, True), (T.FLOAT64, True)]) -@pytest.mark.parametrize("source", ["load_const", "inline"]) -def test_t3_constant_address_load_executes(mode, dtype, extended, source, tmp_path): - b = IRBuilder(); b.new_function("main"); b.new_block("entry") - address = (b.load_const(0x20000, T.INT32) if source == "load_const" - else b.make_value("address", T.INT32, True, 0x20000)) - b.ret(b.load(address, dtype)) - assert verify_ir(b.program) == (True, []) - result = compile_ir(b.program, tmp_path, configuration(mode, "all", True, extended=extended)) - assert result.success, result.errors - assert verify_ir(b.program) == (True, []) - execute_fixed_address(result.output_text, tmp_path, dtype) - - -@pytest.mark.parametrize("mode", MODES) -@pytest.mark.parametrize("dtype", [T.FLOAT32, T.FLOAT64]) -@pytest.mark.parametrize("consumer", ["return", "arithmetic", "store", "branch"]) -def test_t4_fp_definition_uses_cfg_not_block_layout(mode, dtype, consumer, tmp_path): - b = IRBuilder(); f = b.new_function("main"); entry = b.new_block("entry") - b.br("define"); define = b.new_block("define") - value = b.sqrt(b.load_const(16.0, dtype)); b.br("use") - use = b.new_block("use") - if consumer == "arithmetic": - b.ret(b.add(value, value)); expected = 8 - elif consumer == "store": - ptr = b.alloca(1, dtype); b.store(ptr, value); b.ret(b.load(ptr, dtype)); expected = 4 - elif consumer == "branch": - b.br_compare(value, "<", b.load_const(5.0, dtype), "yes", "no") - yes = b.new_block("yes"); b.ret(b.load_const(7.0, dtype)) - no = b.new_block("no"); b.ret(b.load_const(11.0, dtype)); expected = 7 - else: - b.ret(value); expected = 4 - f.blocks = [entry, use, *([yes, no] if consumer == "branch" else []), define] - assert verify_ir(b.program) == (True, []) - result = compile_ir(b.program, tmp_path, configuration(mode, "all", True)) - assert result.success, result.errors - assert verify_ir(b.program) == (True, []) - assert_return(result.output_text, tmp_path, dtype, expected) - - -@pytest.mark.parametrize("dtype", [T.FLOAT32, T.FLOAT64]) -@pytest.mark.parametrize("case", ["use_before_definition", "not_dominating", "address_as_data"]) -def test_t4_forward_source_inventory_does_not_accept_invalid_definitions(dtype, case, tmp_path): - b = IRBuilder(); f = b.new_function("main"); b.new_block("entry") - if case == "address_as_data": - b.ret(b.sqrt(b.alloca(1, dtype))) - elif case == "not_dominating": - b.br_if(b.load_const(1, T.INT32), "define", "use") - b.new_block("define"); value = b.sqrt(b.load_const(16.0, dtype)); b.br("use") - b.new_block("use"); b.ret(value) - else: - value = b.sqrt(b.load_const(16.0, dtype)); b.ret(value) - instructions = f.blocks[0].instructions - instructions[0], instructions[1] = instructions[1], instructions[0] - result = compile_ir(b.program, tmp_path, configuration(verify=False)) - assert not result.success - assert any("definition" in error or "dominate" in error for error in result.errors), result.errors - with pytest.raises(ValueError): - ExtendedInstructionSelector(b.program).run() - - -@pytest.mark.parametrize("mode", MODES) -@pytest.mark.parametrize("dtype,extended", [(T.INT32, False), (T.INT32, True), (T.FLOAT32, True), (T.FLOAT64, True)]) -def test_t8_layout_forward_definition_is_not_a_function_live_in(mode, dtype, extended, tmp_path): - b = IRBuilder(); f = b.new_function("main"); entry = b.new_block("entry") - b.br("define"); define = b.new_block("define") - ptr = b.alloca(1, dtype); b.store(ptr, b.load_const(7 if dtype is T.INT32 else 4.0, dtype)) - value = b.load(ptr, dtype); b.br("use") - use = b.new_block("use"); b.ret(value) - f.blocks = [entry, use, define] - assert verify_ir(b.program) == (True, []) - result = compile_ir(b.program, tmp_path, configuration(mode, "all", True, extended=extended)) - assert result.success, result.errors - assert_return(result.output_text, tmp_path, dtype, 7 if dtype is T.INT32 else 4) - - -def test_licm_processes_adjacent_loops_and_keeps_memory_reads_inside(): - b = IRBuilder(); parameter = b.make_value("input", T.INT32) - b.new_function("main", [parameter]); b.new_block("entry") - ptr = b.alloca(1, T.INT32); b.store(ptr, b.load_const(0, T.INT32)) - two = b.load_const(2, T.INT32) - for _ in range(2): - b.for_loop(0, 1) +PIPELINES = [ + ("none", None), + ("basic", None), + ("all", None), + ("none", ("licm",)), + ("none", ("licm", "licm")), + ("all", ("dead-code-elim", "licm", "dead-code-elim", "licm")), +] + + +class TestTopic28ThirdReviewRegressions: + @pytest.mark.parametrize("dtype", TYPES) + @pytest.mark.parametrize( + "collision", ["parameter", "later_parameter", "initializer"] + ) + def test_t1_onnx_names_cannot_alias_generated_values( + self, dtype, collision, tmp_path + ): + parameter = {"parameter": "v_2", "later_parameter": "v_3"}.get( + collision, "input" + ) + constant = "v_2" if collision == "initializer" else "b" + nodes = [helper.make_node("Mul", [parameter, constant], ["p"])] + if collision == "later_parameter": + nodes.extend( + [ + helper.make_node("Add", ["p", parameter], ["q"]), + helper.make_node("Add", ["q", parameter], ["out"]), + ] + ) + else: + nodes.append( + helper.make_node( + "Add", + ["p", constant if collision == "initializer" else parameter], + ["out"], + ) + ) + graph = helper.make_graph( + nodes, + "main", + [helper.make_tensor_value_info(parameter, ONNX_TYPES[dtype], [])], + [helper.make_tensor_value_info("out", ONNX_TYPES[dtype], [])], + [ + numpy_helper.from_array( + np.asarray(3, dtype=ARRAY_TYPES[dtype]), name=constant + ) + ], + ) + path, model = save_model(graph, tmp_path) + expected = ( + ReferenceEvaluator(model) + .run(None, {parameter: np.asarray(2, dtype=ARRAY_TYPES[dtype])})[0] + .item() + ) + program = ONNXParser().parse(str(path)) + assert verify_ir(program) == (True, []) + result = CompilerDriver(configuration("greedy", "all", True)).compile( + str(path), str(tmp_path / "out.s") + ) + assert result.success, result.errors + assert_return(result.output_text, tmp_path, dtype, expected, 2) + + def test_t1_builder_reserves_explicit_and_external_operand_names(self): + b = IRBuilder() + b.new_function("main", [Value("v_1", T.INT32)]) + b.new_block("entry") + b.make_value("v_2", T.INT32) + one = b.load_const(1, T.INT32) + external = Value("v_4", T.INT32, True, 3) + result = b.add(one, external) + b.ret(result) + assert len({one.name, result.name, "v_1", "v_2", "v_4"}) == 5 + assert verify_ir(b.program) == (True, []) + + @pytest.mark.parametrize("verify", [False, True]) + @pytest.mark.parametrize("shape", [(2,), (1, 2), (0,)]) + def test_t2_integer_initializer_tensor_is_rejected_before_output( + self, verify, shape, tmp_path + ): + initializers = [ + numpy_helper.from_array(np.full(shape, value, dtype=np.int32), name=name) + for name, value in [("a", 1), ("b", 3)] + ] + path, model = save_model( + helper.make_graph( + [helper.make_node("Add", ["a", "b"], ["out"])], + "main", + [], + [helper.make_tensor_value_info("out", TensorProto.INT32, shape)], + initializers, + ), + tmp_path, + ) + np.testing.assert_array_equal( + ReferenceEvaluator(model).run(None, {})[0], + np.full(shape, 4, dtype=np.int32), + ) + output = tmp_path / "out.s" + output.write_text("previous output") + result = CompilerDriver(configuration("greedy", "all", verify)).compile( + str(path), str(output) + ) + assert not result.success + assert any( + "scalar" in error and "shape" in error for error in result.errors + ), result.errors + assert output.read_text() == "previous output" + with pytest.raises(ValueError, match="scalar.*shape"): + ExtendedInstructionSelector(ONNXParser().parse(str(path))).run() + + @pytest.mark.parametrize("shape", [(), (1,), (1, 1)]) + def test_t2_integer_scalar_shapes_are_not_rejected(self, shape, tmp_path): + path, model = save_model( + helper.make_graph( + [helper.make_node("Add", ["a", "b"], ["out"])], + "main", + [], + [helper.make_tensor_value_info("out", TensorProto.INT32, shape)], + [ + numpy_helper.from_array( + np.full(shape, value, dtype=np.int32), name=name + ) + for name, value in [("a", 1), ("b", 3)] + ], + ), + tmp_path, + ) + assert ReferenceEvaluator(model).run(None, {})[0].item() == 4 + result = CompilerDriver(configuration("greedy", "all")).compile( + str(path), str(tmp_path / "out.s") + ) + assert result.success, result.errors + assert execute_i32(result.output_text, tmp_path) == 4 + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize( + "dtype,extended", + [(T.INT32, False), (T.INT32, True), (T.FLOAT32, True), (T.FLOAT64, True)], + ) + @pytest.mark.parametrize("source", ["load_const", "inline"]) + def test_t3_constant_address_load_executes( + self, mode, dtype, extended, source, tmp_path + ): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + address = ( + b.load_const(0x20000, T.INT32) + if source == "load_const" + else b.make_value("address", T.INT32, True, 0x20000) + ) + b.ret(b.load(address, dtype)) + assert verify_ir(b.program) == (True, []) + result = compile_ir( + b.program, tmp_path, configuration(mode, "all", True, extended=extended) + ) + assert result.success, result.errors + assert verify_ir(b.program) == (True, []) + execute_fixed_address(result.output_text, tmp_path, dtype) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("dtype", [T.FLOAT32, T.FLOAT64]) + @pytest.mark.parametrize("consumer", ["return", "arithmetic", "store", "branch"]) + def test_t4_fp_definition_uses_cfg_not_block_layout( + self, mode, dtype, consumer, tmp_path + ): + b = IRBuilder() + f = b.new_function("main") + entry = b.new_block("entry") + b.br("define") + define = b.new_block("define") + value = b.sqrt(b.load_const(16.0, dtype)) + b.br("use") + use = b.new_block("use") + if consumer == "arithmetic": + b.ret(b.add(value, value)) + expected = 8 + elif consumer == "store": + ptr = b.alloca(1, dtype) + b.store(ptr, value) + b.ret(b.load(ptr, dtype)) + expected = 4 + elif consumer == "branch": + b.br_compare(value, "<", b.load_const(5.0, dtype), "yes", "no") + yes = b.new_block("yes") + b.ret(b.load_const(7.0, dtype)) + no = b.new_block("no") + b.ret(b.load_const(11.0, dtype)) + expected = 7 + else: + b.ret(value) + expected = 4 + f.blocks = [entry, use, *([yes, no] if consumer == "branch" else []), define] + assert verify_ir(b.program) == (True, []) + result = compile_ir(b.program, tmp_path, configuration(mode, "all", True)) + assert result.success, result.errors + assert verify_ir(b.program) == (True, []) + assert_return(result.output_text, tmp_path, dtype, expected) + + @pytest.mark.parametrize("dtype", [T.FLOAT32, T.FLOAT64]) + @pytest.mark.parametrize( + "case", ["use_before_definition", "not_dominating", "address_as_data"] + ) + def test_t4_forward_source_inventory_does_not_accept_invalid_definitions( + self, dtype, case, tmp_path + ): + b = IRBuilder() + f = b.new_function("main") + b.new_block("entry") + if case == "address_as_data": + b.ret(b.sqrt(b.alloca(1, dtype))) + elif case == "not_dominating": + b.br_if(b.load_const(1, T.INT32), "define", "use") + b.new_block("define") + value = b.sqrt(b.load_const(16.0, dtype)) + b.br("use") + b.new_block("use") + b.ret(value) + else: + value = b.sqrt(b.load_const(16.0, dtype)) + b.ret(value) + instructions = f.blocks[0].instructions + instructions[0], instructions[1] = instructions[1], instructions[0] + result = compile_ir(b.program, tmp_path, configuration(verify=False)) + assert not result.success + assert any( + "definition" in error or "dominate" in error for error in result.errors + ), result.errors + with pytest.raises(ValueError): + ExtendedInstructionSelector(b.program).run() + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize( + "dtype,extended", + [(T.INT32, False), (T.INT32, True), (T.FLOAT32, True), (T.FLOAT64, True)], + ) + def test_t8_layout_forward_definition_is_not_a_function_live_in( + self, mode, dtype, extended, tmp_path + ): + b = IRBuilder() + f = b.new_function("main") + entry = b.new_block("entry") + b.br("define") + define = b.new_block("define") + ptr = b.alloca(1, dtype) + b.store(ptr, b.load_const(7 if dtype is T.INT32 else 4.0, dtype)) + value = b.load(ptr, dtype) + b.br("use") + use = b.new_block("use") + b.ret(value) + f.blocks = [entry, use, define] + assert verify_ir(b.program) == (True, []) + result = compile_ir( + b.program, tmp_path, configuration(mode, "all", True, extended=extended) + ) + assert result.success, result.errors + assert_return(result.output_text, tmp_path, dtype, 7 if dtype is T.INT32 else 4) + + def test_licm_processes_adjacent_loops_and_keeps_memory_reads_inside(self): + b = IRBuilder() + parameter = b.make_value("input", T.INT32) + b.new_function("main", [parameter]) + b.new_block("entry") + ptr = b.alloca(1, T.INT32) + b.store(ptr, b.load_const(0, T.INT32)) + two = b.load_const(2, T.INT32) + for _ in range(2): + b.for_loop(0, 1) + product = b.mul(parameter, two) + b.store(ptr, b.add(b.load(ptr, T.INT32), product)) + b.endfor() + b.ret(b.load(ptr, T.INT32)) + assert verify_ir(b.program) == (True, []) + assert LICM(preserve_float_semantics=True).optimize(b.program) == 2 + assert verify_ir(b.program) == (True, []) + instructions = b.current_block.instructions + depth = 0 + for instr in instructions: + if instr.opcode is OpCode.FOR: + depth += 1 + elif instr.opcode is OpCode.ENDFOR: + depth -= 1 + elif instr.opcode is OpCode.MUL: + assert depth == 0 + elif instr.opcode is OpCode.ADD: + assert depth == 1 + + @pytest.mark.parametrize("level", LEVELS) + def test_t9_licm_multiple_invariants_preserve_loop_and_instruction_order( + self, level, tmp_path + ): + b = IRBuilder() + parameter = b.make_value("input", T.INT32) + b.new_function("main", [parameter]) + b.new_block("entry") + ptr = b.alloca(1, T.INT32) + b.store(ptr, b.load_const(0, T.INT32)) + two = b.load_const(2, T.INT32) + three = b.load_const(3, T.INT32) + b.for_loop(0, 2) product = b.mul(parameter, two) - b.store(ptr, b.add(b.load(ptr, T.INT32), product)) + offset = b.add(parameter, three) + total = b.add(product, offset) + b.store(ptr, b.add(b.load(ptr, T.INT32), total)) b.endfor() - b.ret(b.load(ptr, T.INT32)) - assert verify_ir(b.program) == (True, []) - assert LICM(preserve_float_semantics=True).optimize(b.program) == 2 - assert verify_ir(b.program) == (True, []) - instructions = b.current_block.instructions - depth = 0 - for instr in instructions: - if instr.opcode is OpCode.FOR: - depth += 1 - elif instr.opcode is OpCode.ENDFOR: - depth -= 1 - elif instr.opcode is OpCode.MUL: - assert depth == 0 - elif instr.opcode is OpCode.ADD: - assert depth == 1 - - -@pytest.mark.parametrize("level", LEVELS) -def test_t9_licm_multiple_invariants_preserve_loop_and_instruction_order(level, tmp_path): - b = IRBuilder() - parameter = b.make_value("input", T.INT32) - b.new_function("main", [parameter]) - b.new_block("entry") - ptr = b.alloca(1, T.INT32) - b.store(ptr, b.load_const(0, T.INT32)) - two = b.load_const(2, T.INT32) - three = b.load_const(3, T.INT32) - b.for_loop(0, 2) - product = b.mul(parameter, two) - offset = b.add(parameter, three) - total = b.add(product, offset) - b.store(ptr, b.add(b.load(ptr, T.INT32), total)) - b.endfor() - b.ret(b.load(ptr, T.INT32)) - assert verify_ir(b.program) == (True, []) - result = compile_ir(b.program, tmp_path, configuration("greedy", level, True)) - assert result.success, result.errors - assert verify_ir(b.program) == (True, []) - instructions = b.current_block.instructions - assert sum(instr.opcode is OpCode.FOR for instr in instructions) == 1 - assert sum(instr.opcode is OpCode.ENDFOR for instr in instructions) == 1 - assert_return(result.output_text, tmp_path, T.INT32, 36, argument=5) - - -@pytest.mark.parametrize("mode", MODES) -@pytest.mark.parametrize("dtype", [T.FLOAT32, T.FLOAT64]) -def test_t5_block_and_pool_symbols_and_br_targets_do_not_collide(mode, dtype, tmp_path): - b = IRBuilder(); b.new_function("main"); b.new_block("entry") - target = "LC_" + dtype.value + "_0" - b.br(target); b.new_block(target); b.ret(b.load_const(2.0, dtype)) - assert verify_ir(b.program) == (True, []) - result = compile_ir(b.program, tmp_path, configuration(mode, "all")) - assert result.success, result.errors - labels = [line.strip() for line in result.output_text.splitlines() if line.strip().endswith(":")] - assert len(labels) == len(set(labels)) - assert_return(result.output_text, tmp_path, dtype, 2) - selector = ExtendedInstructionSelector(b.program) - first = selector.run(); first_pool = selector.constant_pool - second = selector.run() - assert [i.target for i in first if i.op is MachineOp.LABEL] == [i.target for i in second if i.op is MachineOp.LABEL] - assert first_pool == selector.constant_pool - - -@pytest.mark.parametrize("mode", MODES) -@pytest.mark.parametrize("extended", [False, True]) -@pytest.mark.parametrize("case", ["zero", "sum", "nested", "label_collision"]) -def test_t6_for_loops_cross_assemble_and_execute(mode, extended, case, tmp_path): - b = IRBuilder(); b.new_function("main"); b.new_block("Lloop_header_1" if case == "label_collision" else "entry") - acc = b.alloca(1, T.INT32); b.store(acc, b.load_const(0, T.INT32)) - iv = b.for_loop(0, 0 if case == "zero" else (2 if case == "nested" else 3)) - if case == "nested": - jv = b.for_loop(0, 2) - term = b.add(iv, jv) - else: - term = iv - b.store(acc, b.add(b.load(acc, T.INT32), term)) - if case == "nested": + b.ret(b.load(ptr, T.INT32)) + assert verify_ir(b.program) == (True, []) + result = compile_ir(b.program, tmp_path, configuration("greedy", level, True)) + assert result.success, result.errors + assert verify_ir(b.program) == (True, []) + instructions = b.current_block.instructions + assert sum(instr.opcode is OpCode.FOR for instr in instructions) == 1 + assert sum(instr.opcode is OpCode.ENDFOR for instr in instructions) == 1 + assert_return(result.output_text, tmp_path, T.INT32, 36, argument=5) + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("dtype", [T.FLOAT32, T.FLOAT64]) + def test_t5_block_and_pool_symbols_and_br_targets_do_not_collide( + self, mode, dtype, tmp_path + ): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + target = "LC_" + dtype.value + "_0" + b.br(target) + b.new_block(target) + b.ret(b.load_const(2.0, dtype)) + assert verify_ir(b.program) == (True, []) + result = compile_ir(b.program, tmp_path, configuration(mode, "all")) + assert result.success, result.errors + labels = [ + line.strip() + for line in result.output_text.splitlines() + if line.strip().endswith(":") + ] + assert len(labels) == len(set(labels)) + assert_return(result.output_text, tmp_path, dtype, 2) + selector = ExtendedInstructionSelector(b.program) + first = selector.run() + first_pool = selector.constant_pool + second = selector.run() + assert [i.target for i in first if i.op is MachineOp.LABEL] == [ + i.target for i in second if i.op is MachineOp.LABEL + ] + assert first_pool == selector.constant_pool + + @pytest.mark.parametrize("mode", MODES) + @pytest.mark.parametrize("extended", [False, True]) + @pytest.mark.parametrize("case", ["zero", "sum", "nested", "label_collision"]) + def test_t6_for_loops_cross_assemble_and_execute( + self, mode, extended, case, tmp_path + ): + b = IRBuilder() + b.new_function("main") + b.new_block("Lloop_header_1" if case == "label_collision" else "entry") + acc = b.alloca(1, T.INT32) + b.store(acc, b.load_const(0, T.INT32)) + iv = b.for_loop(0, 0 if case == "zero" else (2 if case == "nested" else 3)) + if case == "nested": + jv = b.for_loop(0, 2) + term = b.add(iv, jv) + else: + term = iv + b.store(acc, b.add(b.load(acc, T.INT32), term)) + if case == "nested": + b.endfor() b.endfor() - b.endfor(); b.ret(b.load(acc, T.INT32)) - assert verify_ir(b.program) == (True, []) - result = compile_ir(b.program, tmp_path, configuration(mode, "all", True, extended=extended)) - assert result.success, result.errors - assert verify_ir(b.program) == (True, []) - assert execute_i32(result.output_text, tmp_path) == (0 if case == "zero" else 4 if case == "nested" else 3) - - -PIPELINES = [("none", None), ("basic", None), ("all", None), - ("none", ("licm",)), ("none", ("licm", "licm")), - ("all", ("dead-code-elim", "licm", "dead-code-elim", "licm"))] - - -@pytest.mark.parametrize("pipeline", PIPELINES) -@pytest.mark.parametrize("dtype", [T.FLOAT32, T.FLOAT64]) -@pytest.mark.parametrize("count", [0, 2]) -@pytest.mark.parametrize("operation", ["sqrt", "divide", "snan_min"]) -def test_licm_keeps_fp_exceptions_inside_loop(pipeline, dtype, count, operation, tmp_path): - b = IRBuilder(); b.new_function("main"); b.new_block("entry") - a = b.load_const(-1.0 if operation == "sqrt" else 1.0, dtype) - if operation == "snan_min": - b.current_block.instructions[-1].attrs["bit_pattern"] = 0x7f800001 if dtype is T.FLOAT32 else 0x7ff0000000000001 - rhs = b.load_const(0.0 if operation == "divide" else 2.0, dtype) - b.for_loop(0, count) - if operation == "sqrt": - b.sqrt(a) - elif operation == "divide": - b.div(a, rhs) - else: - b.min(a, rhs) - b.endfor(); b.ret(b.load_const(4.0, dtype)) - assert verify_ir(b.program) == (True, []) - level, passes = pipeline - result = compile_ir(b.program, tmp_path, configuration("greedy", level, True, passes=passes)) - assert result.success, result.errors - assert verify_ir(b.program) == (True, []) - width = 32 if dtype is T.FLOAT32 else 64 - assert execute_fp(result.output_text, tmp_path, width) == (bits(4.0, width), 0 if count == 0 else 8 if operation == "divide" else 16) + b.ret(b.load(acc, T.INT32)) + assert verify_ir(b.program) == (True, []) + result = compile_ir( + b.program, tmp_path, configuration(mode, "all", True, extended=extended) + ) + assert result.success, result.errors + assert verify_ir(b.program) == (True, []) + assert execute_i32(result.output_text, tmp_path) == ( + 0 if case == "zero" else 4 if case == "nested" else 3 + ) + + @pytest.mark.parametrize("pipeline", PIPELINES) + @pytest.mark.parametrize("dtype", [T.FLOAT32, T.FLOAT64]) + @pytest.mark.parametrize("count", [0, 2]) + @pytest.mark.parametrize("operation", ["sqrt", "divide", "snan_min"]) + def test_licm_keeps_fp_exceptions_inside_loop( + self, pipeline, dtype, count, operation, tmp_path + ): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(-1.0 if operation == "sqrt" else 1.0, dtype) + if operation == "snan_min": + b.current_block.instructions[-1].attrs["bit_pattern"] = ( + 0x7F800001 if dtype is T.FLOAT32 else 0x7FF0000000000001 + ) + rhs = b.load_const(0.0 if operation == "divide" else 2.0, dtype) + b.for_loop(0, count) + if operation == "sqrt": + b.sqrt(a) + elif operation == "divide": + b.div(a, rhs) + else: + b.min(a, rhs) + b.endfor() + b.ret(b.load_const(4.0, dtype)) + assert verify_ir(b.program) == (True, []) + level, passes = pipeline + result = compile_ir( + b.program, tmp_path, configuration("greedy", level, True, passes=passes) + ) + assert result.success, result.errors + assert verify_ir(b.program) == (True, []) + width = 32 if dtype is T.FLOAT32 else 64 + assert execute_fp(result.output_text, tmp_path, width) == ( + bits(4.0, width), + 0 if count == 0 else 8 if operation == "divide" else 16, + ) diff --git a/tests/test_verification.py b/tests/test_verification.py index 5fe6e0b..8a76a63 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -66,20 +66,26 @@ class TestDSLInterpreter: def test_simple_add(self): dsl = "y = add(a, b)\nreturn y" interpreter = DSLInterpreter() - result = interpreter.run(dsl, { - "a": np.array([1.0, 2.0]), - "b": np.array([3.0, 4.0]), - }) + result = interpreter.run( + dsl, + { + "a": np.array([1.0, 2.0]), + "b": np.array([3.0, 4.0]), + }, + ) np.testing.assert_array_equal(result, np.array([4.0, 6.0])) def test_mul_then_add(self): dsl = "t = mul(a, b)\ny = add(t, c)\nreturn y" interpreter = DSLInterpreter() - result = interpreter.run(dsl, { - "a": np.float64(2.0), - "b": np.float64(3.0), - "c": np.float64(1.0), - }) + result = interpreter.run( + dsl, + { + "a": np.float64(2.0), + "b": np.float64(3.0), + "c": np.float64(1.0), + }, + ) assert abs(result - 7.0) < 1e-6 def test_relu(self): @@ -144,7 +150,9 @@ def test_extended_remainder_divide_by_zero_matches_riscv(self): [("f32", np.float32), ("f64", np.float64)], ) def test_extended_float_min_max_abs_preserve_dtype( - self, dtype, numpy_dtype, + self, + dtype, + numpy_dtype, ): dsl = f""" a = const(-2.5, dtype:{dtype}) @@ -161,8 +169,10 @@ def test_extended_float_min_max_abs_preserve_dtype( assert float(result) == 1.25 -def test_ir_trace_executor_supports_extended_integer_operations(): - program = DSLParser().parse(""" +class TestIRTraceExtendedOperations: + def test_ir_trace_executor_supports_extended_integer_operations(self): + program = DSLParser().parse( + """ a = const(-17, dtype:i32) b = const(5, dtype:i32) r = rem(a, b) @@ -170,31 +180,35 @@ def test_ir_trace_executor_supports_extended_integer_operations(): hi = max(lo, a) y = abs(hi) return y -""") - - result = IRTraceExecutor(program).run({}) +""" + ) - assert result.dtype == np.int32 - assert int(result) == 2 + result = IRTraceExecutor(program).run({}) + assert result.dtype == np.int32 + assert int(result) == 2 -@pytest.mark.parametrize( - ("dtype", "numpy_dtype"), - [("f32", np.float32), ("f64", np.float64)], -) -def test_ir_trace_executor_supports_extended_float_operations( - dtype, numpy_dtype, -): - program = DSLParser().parse(f""" + @pytest.mark.parametrize( + ("dtype", "numpy_dtype"), + [("f32", np.float32), ("f64", np.float64)], + ) + def test_ir_trace_executor_supports_extended_float_operations( + self, + dtype, + numpy_dtype, + ): + program = DSLParser().parse( + f""" a = const(-2.5, dtype:{dtype}) b = const(1.25, dtype:{dtype}) lo = min(a, b) hi = max(lo, b) y = abs(hi) return y -""") +""" + ) - result = IRTraceExecutor(program).run({}) + result = IRTraceExecutor(program).run({}) - assert result.dtype == numpy_dtype - assert float(result) == 1.25 + assert result.dtype == numpy_dtype + assert float(result) == 1.25