From 2b5052e8b8e7c2e92e0be74e04d065f4952b8776 Mon Sep 17 00:00:00 2001
From: Seven Gao <799889633@qq.com>
Date: Mon, 14 Sep 2026 01:52:04 +0800
Subject: [PATCH 1/5] feat(topic27): full RV32 benchmark with honest provenance
and budget controls
---
scratchv/standalone/bench_report.py | 423 +++++++
scratchv/standalone/rv32_bench.py | 1579 +++++++++++++++++++++------
tests/test_rv32_bench.py | 458 ++++++++
3 files changed, 2127 insertions(+), 333 deletions(-)
create mode 100644 tests/test_rv32_bench.py
diff --git a/scratchv/standalone/bench_report.py b/scratchv/standalone/bench_report.py
index 9dfb533..1dbc6ad 100644
--- a/scratchv/standalone/bench_report.py
+++ b/scratchv/standalone/bench_report.py
@@ -17,6 +17,7 @@
import json
import os
+import re
import sys
import time
from typing import Any
@@ -438,6 +439,428 @@ def generate_github_summary(
return "\n".join(lines)
+# ═══════════════════════════════════════════════════════════════════════════
+# Schema v2 renderers (rv32_bench.py reports)
+# ═══════════════════════════════════════════════════════════════════════════
+
+_MISSING = object()
+
+
+def _dig(data: Any, path: str) -> Any:
+ node: Any = data
+ for part in path.split("."):
+ if not isinstance(node, dict) or part not in node:
+ return _MISSING
+ node = node[part]
+ return node
+
+
+def _fmt(value: Any) -> str:
+ if value is None:
+ return "—"
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, float):
+ return f"{value:g}"
+ return str(value)
+
+
+def _shape(shape: Any) -> str:
+ if not shape:
+ return "?"
+ return "×".join(str(d) for d in shape)
+
+
+def render_markdown(report: dict) -> str:
+ """Render a schema v2 report as Markdown with provenance tags."""
+ model = report.get("model") or {}
+ env = report.get("environment") or {}
+ targets = report.get("targets") or {}
+ sv = report.get("scratchv") or {}
+ llvm = report.get("llvm") or {}
+ cmp_ = report.get("comparison") or {}
+
+ sv_compile = sv.get("compile") or {}
+ sv_dyn = sv.get("dynamic") or {}
+ sv_mix = sv.get("static_instruction_mix") or {}
+ sv_out = sv.get("output") or {}
+ ll_compile = llvm.get("compile") or {}
+ ll_dyn = llvm.get("dynamic") or {}
+ tgt_sv = targets.get("scratchv") or {}
+ tgt_ll = targets.get("llvm") or {}
+
+ sha = str(model.get("sha256") or "")
+ completion = sv_dyn.get("completion")
+ dyn_rows = [
+ ("source", _fmt(sv_dyn.get("source")), _fmt(ll_dyn.get("source"))),
+ ("completion", _fmt(completion), _fmt(ll_dyn.get("completion"))),
+ ("executed", _fmt(sv_dyn.get("executed")), _fmt(ll_dyn.get("executed"))),
+ ]
+ sv_ops = sv_dyn.get("ops") or {}
+ for key in ("total", "load", "store", "mul", "add", "madd", "branch"):
+ dyn_rows.append(
+ (f"ops.{key}", _fmt(sv_ops.get(key)), _fmt(None)),
+ )
+
+ lines = []
+ lines.append(f"# RV32 Benchmark Report — {_fmt(model.get('path'))}")
+ lines.append("")
+ lines.append(f"- Generated: {_fmt(report.get('generated_at'))} | "
+ f"schema: {_fmt(report.get('schema_version'))}")
+ lines.append(
+ f"- Model: {_fmt(model.get('input_name'))}{_shape(model.get('input_shape'))}"
+ f" → {_fmt(model.get('output_name'))}{_shape(model.get('output_shape'))}"
+ f" | sha256={sha[:12]} | bytes={_fmt(model.get('bytes'))}"
+ )
+ lines.append(
+ f"- Targets: ScratchV {_fmt(tgt_sv.get('isa'))}/{_fmt(tgt_sv.get('numeric_format'))}"
+ f" | LLVM {_fmt(tgt_ll.get('isa'))}/{_fmt(tgt_ll.get('numeric_format'))}"
+ f" (opt={_fmt(tgt_ll.get('opt_level'))})"
+ )
+ lines.append(
+ f"- Environment: python={_fmt(env.get('python'))} "
+ f"numpy={_fmt(env.get('numpy'))} tinyfive={_fmt(env.get('tinyfive'))} "
+ f"llvmlite={_fmt(env.get('llvmlite'))}"
+ )
+ lines.append("")
+
+ lines.append("## 1. Compilation [static]")
+ lines.append("")
+ lines.append("| Metric | ScratchV | LLVM |")
+ lines.append("|--------|----------|------|")
+ lines.append(f"| status | {_fmt(sv_compile.get('status'))} | {_fmt(ll_compile.get('status'))} |")
+ lines.append(f"| code bytes | {_fmt(sv_compile.get('code_bytes'))} | — |")
+ lines.append(f"| data offset | {_fmt(sv_compile.get('data_offset'))} | — |")
+ lines.append(f"| data bytes | {_fmt(sv_compile.get('data_bytes'))} | — |")
+ lines.append(
+ f"| static insns [asm_scan] | {_fmt(sv_compile.get('static_insns'))} "
+ f"| {_fmt(ll_compile.get('static_insns'))} |"
+ )
+ lines.append("")
+ lines.append("### Static instruction mix [static]")
+ lines.append("")
+ lines.append("| class | count |")
+ lines.append("|-------|-------|")
+ for key in ("load", "store", "mul", "add", "madd", "branch", "other"):
+ lines.append(f"| {key} | {_fmt(sv_mix.get(key))} |")
+ lines.append("")
+
+ lines.append("## 2. Dynamic Execution [measured]")
+ lines.append("")
+ lines.append("| Metric | ScratchV | LLVM |")
+ lines.append("|--------|----------|------|")
+ for label, sv_cell, ll_cell in dyn_rows:
+ lines.append(f"| {label} | {sv_cell} | {ll_cell} |")
+ lines.append("")
+ if completion == "budget_exhausted":
+ lines.append(
+ f"> [measured/budget] budget exhausted at {sv_dyn.get('limit')} "
+ "instructions; dynamic counts are partial and excluded from "
+ "comparison."
+ )
+ lines.append("")
+ elif completion == "timeout":
+ lines.append(
+ f"> [measured/timeout] wall-clock timeout after "
+ f"{_fmt(sv_dyn.get('elapsed_s'))}s; dynamic counts are partial."
+ )
+ lines.append("")
+ elif sv_dyn.get("source") == "unavailable":
+ lines.append(
+ f"> [unavailable] ScratchV dynamic section omitted: "
+ f"{_fmt(sv_dyn.get('reason'))}"
+ )
+ lines.append("")
+ if ll_dyn.get("source") == "unavailable":
+ lines.append(
+ f"> [unavailable] LLVM dynamic section omitted: "
+ f"{_fmt(ll_dyn.get('reason'))}"
+ )
+ lines.append("")
+ if sv_dyn.get("source") == "simulated":
+ lines.append(
+ f"> Simulated by {_fmt(sv_dyn.get('simulator'))} "
+ f"{_fmt(sv_dyn.get('simulator_version'))} | "
+ f"completion={_fmt(completion)} | executed={_fmt(sv_dyn.get('executed'))} "
+ f"| limit={_fmt(sv_dyn.get('limit'))} | "
+ f"memory={_fmt(sv_dyn.get('memory_size_bytes'))} "
+ f"| seed={_fmt(sv_dyn.get('input_seed'))} "
+ f"| halt=0x{int(sv_dyn.get('halt_addr') or 0):x}"
+ )
+ lines.append("")
+
+ lines.append("## 3. Comparison [measured]")
+ lines.append("")
+ ratio = cmp_.get("dynamic_instruction_ratio")
+ if ratio is None:
+ lines.append(
+ f"- comparison ratio: **null** — "
+ f"{_fmt(cmp_.get('incomparable_reason'))}"
+ )
+ else:
+ lines.append(
+ f"- dynamic_instruction_ratio: **{ratio:g}** "
+ "(ScratchV / LLVM, both sides halted)"
+ )
+ lines.append("")
+
+ lines.append("## 4. Analytical Warnings [estimated]")
+ lines.append("")
+ warnings = report.get("warnings") or []
+ if warnings:
+ lines.extend(f"- {w}" for w in warnings)
+ else:
+ lines.append("- none")
+ lines.append("")
+ errors = report.get("errors") or []
+ if errors:
+ lines.append("## 5. Errors")
+ lines.append("")
+ lines.extend(f"- {e}" for e in errors)
+ lines.append("")
+
+ lines.append("## Provenance")
+ lines.append("")
+ lines.append(f"- model sha256={sha}")
+ lines.append(
+ f"- binary sha256={_fmt(sv_compile.get('binary_sha256'))} "
+ f"| data_offset={_fmt(sv_compile.get('data_offset'))} "
+ f"({_fmt(sv_compile.get('data_offset_source'))})"
+ )
+ lines.append(
+ f"- static_source={_fmt(sv_compile.get('static_source'))} "
+ f"| llvm static_source={_fmt(ll_compile.get('static_source'))}"
+ )
+ lines.append(
+ f"- output: {_fmt(sv_out.get('raw_hex'))} "
+ f"(addr=0x{int(sv_out.get('addr') or 0):x}, "
+ f"elements={_fmt(sv_out.get('elements'))})"
+ )
+ return "\n".join(lines)
+
+
+def _md_to_html(md: str) -> str:
+ from html import escape
+ lines = md.splitlines()
+ out: list[str] = []
+ in_table = False
+ in_list = False
+
+ def close_blocks():
+ nonlocal in_table, in_list
+ if in_table:
+ out.append("")
+ in_table = False
+ if in_list:
+ out.append("")
+ in_list = False
+
+ def inline(text: str) -> str:
+ text = escape(text)
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
+ text = re.sub(r"`(.+?)`", r"\1", text)
+ return text
+
+ for line in lines:
+ stripped = line.strip()
+ if stripped.startswith("|"):
+ cells = [c.strip() for c in stripped.strip("|").split("|")]
+ if all(set(c) <= {"-", " "} and c for c in cells):
+ continue
+ if not in_table:
+ close_blocks()
+ out.append("
")
+ in_table = True
+ tag = "th" if not any(mark.startswith("") for mark in out[-2:]) else "td"
+ out.append("
" + "".join(
+ f"<{tag}>{inline(c)}{tag}>" for c in cells) + "
")
+ continue
+ if stripped.startswith("- "):
+ if not in_list:
+ close_blocks()
+ out.append("")
+ in_list = True
+ out.append(f"- {inline(stripped[2:])}
")
+ continue
+ close_blocks()
+ if stripped.startswith("### "):
+ out.append(f"{inline(stripped[4:])}
")
+ elif stripped.startswith("## "):
+ out.append(f"{inline(stripped[3:])}
")
+ elif stripped.startswith("# "):
+ out.append(f"{inline(stripped[2:])}
")
+ elif stripped.startswith("> "):
+ out.append(f"{inline(stripped[2:])}
")
+ elif stripped:
+ out.append(f"{inline(stripped)}
")
+ close_blocks()
+ body = "\n".join(out)
+ return (
+ ""
+ ""
+ "RV32 Benchmark Report"
+ f"{HTML_CSS}\n{body}\n"
+ )
+
+
+def render_html(report: dict) -> str:
+ """Wrap the schema v2 Markdown rendering in a self-contained HTML shell."""
+ return _md_to_html(render_markdown(report))
+
+
+def render_bench_json(report: dict) -> str:
+ """Serialize the schema v2 report as JSON."""
+ return json.dumps(report, sort_keys=False, indent=2, default=str)
+
+
+def render_github_summary(report: dict) -> str:
+ """Render a compact GitHub Actions summary using measured/static data only."""
+ model = report.get("model") or {}
+ env = report.get("environment") or {}
+ sv = report.get("scratchv") or {}
+ sv_compile = sv.get("compile") or {}
+ sv_dyn = sv.get("dynamic") or {}
+ sv_ops = sv_dyn.get("ops") or {}
+ cmp_ = report.get("comparison") or {}
+ sha = str(model.get("sha256") or "")
+
+ lines = []
+ lines.append("# RV32 Benchmark Summary")
+ lines.append("")
+ lines.append(f"- Model: `{_fmt(model.get('path'))}` (sha256={sha[:12]})")
+ lines.append(f"- schema: {_fmt(report.get('schema_version'))}")
+ lines.append("")
+ lines.append("| Metric | Value | Source |")
+ lines.append("|--------|-------|--------|")
+ lines.append(
+ f"| completion | {_fmt(sv_dyn.get('completion'))} | "
+ f"[{_fmt(sv_dyn.get('source'))}] |"
+ )
+ lines.append(f"| executed | {_fmt(sv_dyn.get('executed'))} | [measured] |")
+ lines.append(f"| ops.total | {_fmt(sv_ops.get('total'))} | [measured] |")
+ lines.append(
+ f"| static insns | {_fmt(sv_compile.get('static_insns'))} | [static] |"
+ )
+ lines.append(
+ f"| model bytes | {_fmt(model.get('bytes'))} | [static] |"
+ )
+ lines.append("")
+ ratio = cmp_.get("dynamic_instruction_ratio")
+ if ratio is None:
+ lines.append(
+ f"> ratio: null — {_fmt(cmp_.get('incomparable_reason'))}"
+ )
+ else:
+ lines.append(f"> dynamic_instruction_ratio: {ratio:g}")
+ lines.append("")
+ lines.append(
+ f"> tinyfive={_fmt(env.get('tinyfive'))} "
+ f"| memory={_fmt(sv_dyn.get('memory_size_bytes'))} "
+ f"| seed={_fmt(sv_dyn.get('input_seed'))}"
+ )
+ warnings = report.get("warnings") or []
+ if warnings:
+ lines.append("")
+ lines.append("## Warnings [estimated]")
+ lines.extend(f"- {w}" for w in warnings)
+ return "\n".join(lines)
+
+
+def validate_report_schema(report: dict) -> list[str]:
+ """Structural validation of a schema v2 report; empty list means valid."""
+ errors: list[str] = []
+ if not isinstance(report, dict):
+ return ["report is not a dict"]
+
+ def require(path: str, predicate=None, description: str = ""):
+ value = _dig(report, path)
+ if value is _MISSING:
+ errors.append(f"missing required field: {path}")
+ elif predicate is not None and not predicate(value):
+ errors.append(
+ f"invalid {description or path}: {value!r}"
+ )
+ return value
+
+ def is_int(value) -> bool:
+ return isinstance(value, int) and not isinstance(value, bool)
+
+ require("schema_version", lambda v: v == "rv32-bench/2")
+ require("generated_at", lambda v: isinstance(v, str) and bool(v))
+ require("generator.script", lambda v: isinstance(v, str) and bool(v))
+ require("model.path", lambda v: isinstance(v, str) and bool(v))
+ require(
+ "model.sha256",
+ lambda v: isinstance(v, str) and bool(re.fullmatch(r"[0-9a-f]{64}", v)),
+ )
+ require("model.bytes", is_int)
+ require("environment", lambda v: isinstance(v, dict))
+ require("targets.scratchv.isa", lambda v: isinstance(v, str) and bool(v))
+ require("targets.llvm.isa", lambda v: isinstance(v, str) and bool(v))
+
+ for side in ("scratchv", "llvm"):
+ require(f"{side}.compile.status", lambda v: isinstance(v, str) and bool(v))
+ require(f"{side}.compile.static_source", lambda v: v == "asm_scan")
+ dyn = _dig(report, f"{side}.dynamic")
+ if dyn is _MISSING or not isinstance(dyn, dict):
+ errors.append(f"missing required field: {side}.dynamic")
+ continue
+ source = dyn.get("source")
+ if source not in ("simulated", "unavailable"):
+ errors.append(f"invalid {side}.dynamic.source: {source!r}")
+ continue
+ if source == "simulated":
+ for key in ("simulator", "simulator_version", "completion",
+ "executed", "memory_size_bytes", "input_seed"):
+ if dyn.get(key) is None:
+ errors.append(
+ f"missing required field: {side}.dynamic.{key}"
+ )
+ if dyn.get("completion") not in (
+ "halted", "budget_exhausted", "timeout"
+ ):
+ errors.append(
+ f"invalid {side}.dynamic.completion: "
+ f"{dyn.get('completion')!r}"
+ )
+ if not is_int(dyn.get("executed")):
+ errors.append(
+ f"invalid {side}.dynamic.executed: {dyn.get('executed')!r}"
+ )
+ ops = dyn.get("ops")
+ if not isinstance(ops, dict):
+ errors.append(f"missing required field: {side}.dynamic.ops")
+ else:
+ for key in ("total", "load", "store", "mul", "add",
+ "madd", "branch"):
+ if not is_int(ops.get(key)):
+ errors.append(
+ f"invalid {side}.dynamic.ops.{key}: "
+ f"{ops.get(key)!r}"
+ )
+ else:
+ require(f"{side}.dynamic.reason",
+ lambda v: isinstance(v, str) and bool(v))
+ if dyn.get("ops") is not None:
+ errors.append(
+ f"{side}.dynamic.ops must be null when unavailable"
+ )
+
+ comparison = _dig(report, "comparison")
+ if comparison is _MISSING or not isinstance(comparison, dict):
+ errors.append("missing required field: comparison")
+ else:
+ if "dynamic_instruction_ratio" not in comparison:
+ errors.append(
+ "missing required field: comparison.dynamic_instruction_ratio"
+ )
+ require("comparison.incomparable_reason",
+ lambda v: v is None or (isinstance(v, str) and bool(v)))
+ require("warnings", lambda v: isinstance(v, list))
+ require("errors", lambda v: isinstance(v, list))
+ return errors
+
+
# ═══════════════════════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════════════════════
diff --git a/scratchv/standalone/rv32_bench.py b/scratchv/standalone/rv32_bench.py
index e477a21..bab33d6 100644
--- a/scratchv/standalone/rv32_bench.py
+++ b/scratchv/standalone/rv32_bench.py
@@ -1,428 +1,1341 @@
#!/usr/bin/env python3
-"""Professional RISC-V benchmark: ScratchV vs LLVM, same RV32 target, TinyFive data.
+"""RV32 full benchmark: ScratchV vs LLVM on a unified RV32 target.
-Full pipeline:
- 1. ONNX → ScratchV RV32IM binary (Q16.16 fixed-point)
- 2. ONNX → LLVM IR → RV32IMF assembly (float32, via llvmlite)
- 3. Both run through TinyFive ProfiledMachine
- 4. All metrics from TinyFive ops counters — zero analytical estimates
+Pipeline:
+ 1. ONNX -> ScratchV RV32IM binary (Q16.16 fixed point)
+ 2. ONNX -> LLVM IR -> RV32IMF assembly (float32, via optional llvmlite)
+ 3. ScratchV binary loaded into TinyFive ProfiledMachine (code + weights +
+ deterministic input) and simulated in explicit, budgeted chunks
+ 4. Honest report (schema ``rv32-bench/2``): every number carries its
+ provenance; static counts never masquerade as dynamic ones.
+
+Only measured TinyFive execution fills ``scratchv.dynamic``. When the
+simulator is missing, or a run is truncated by a budget/timeout, the report
+says so instead of inventing numbers.
Usage:
python scratchv/standalone/rv32_bench.py models/graph/cnn.onnx \\
- --output-dir benchmark_reports/ --html report.html --json report.json
+ --full --output-dir benchmark_reports
"""
from __future__ import annotations
-import json, os, re, struct, subprocess, sys, tempfile, time
-from collections import Counter
+
+import contextlib
+import hashlib
+import os
+import random
+import re
+import signal
+import struct
+import subprocess
+import sys
+import time
from pathlib import Path
-from dataclasses import dataclass, field, asdict
+from types import MethodType
+
+import numpy as np
PROJ = Path(__file__).resolve().parent.parent.parent
+if str(PROJ) not in sys.path:
+ sys.path.insert(0, str(PROJ))
+
+from scratchv.simulator.tinyfive import ProfiledMachine # noqa: E402
+from scratchv.standalone.onnx_to_riscv_standalone import ( # noqa: E402
+ ONNXModel,
+ _disasm_one,
+)
+from scratchv.standalone import bench_report # noqa: E402
+
# ═══════════════════════════════════════════════════════════════════════════
-# Step 1: Compile ONNX → ScratchV RV32IM binary
+# Constants and exit codes
# ═══════════════════════════════════════════════════════════════════════════
-def compile_scratchv(onnx_path: str, output_bin: str, output_asm: str) -> dict:
- """Compile ONNX to ScratchV RV32IM binary. Returns {code_size, static_insns, ...}"""
- t0 = time.perf_counter()
- rc, stdout, stderr = _run_py([
- "scratchv/standalone/onnx_to_riscv_standalone.py",
- onnx_path, "-o", output_bin, "--asm", output_asm,
- ], timeout=120)
- elapsed = time.perf_counter() - t0
- if rc != 0:
- return {"status": "failed", "error": stderr[:300], "elapsed_s": elapsed}
+SCHEMA_VERSION = "rv32-bench/2"
+EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_NO_SIMULATOR, EXIT_LAYOUT, EXIT_INCOMPLETE = (
+ 0, 1, 2, 3, 4, 7,
+)
- code_size = len(Path(output_bin).read_bytes()) if Path(output_bin).exists() else 0
- # Count static instructions from assembly
- static_insns = 0
- if Path(output_asm).exists():
- for line in Path(output_asm).read_text().splitlines():
- line = line.strip()
- if line and not line.startswith('.') and not line.endswith(':') and not line.startswith('#'):
- # Count real instructions (exclude comments/labels/directives)
- parts = line.split('#')[0].strip().split()
- if parts and parts[0] not in ('', '.text', '.align', '.globl', '.type'):
- static_insns += 1
+SP_ADDR = 128 * 1024 * 1024
+INPUT_ADDR = 160 * 1024 * 1024
+OUTPUT_ADDR = 192 * 1024 * 1024
+GUARD_BYTES = 1024 * 1024
+OUTPUT_GUARD_BYTES = 4096
- return {"status": "success", "code_size": code_size, "static_insns": static_insns,
- "elapsed_s": elapsed, "binary": output_bin, "assembly": output_asm}
+DEFAULT_MEM_SIZE = 268435456
+DEFAULT_TIMEOUT_S = 900.0
+DEFAULT_CHUNK_INSTRUCTIONS = 10_000_000
+DEFAULT_INPUT_SEED = 42
+DATA_OFFSET_RE = re.compile(r"Data offset:\s*0x([0-9A-Fa-f]+)")
+WORKSPACE_RE = re.compile(r"Workspace:\s*([\d,]+)\s+bytes")
+CODE_SIZE_RE = re.compile(r"Code size:\s*([\d,]+)\s+bytes")
-# ═══════════════════════════════════════════════════════════════════════════
-# Step 2: Compile ONNX → LLVM IR → RV32IMF assembly
-# ═══════════════════════════════════════════════════════════════════════════
+TARGETS = {
+ "scratchv": {"isa": "rv32im", "abi": "ilp32", "numeric_format": "q16.16"},
+ "llvm": {
+ "isa": "rv32imf", "abi": "ilp32", "numeric_format": "float32",
+ "triple": "riscv32-unknown-elf", "opt_level": 2,
+ },
+}
-def compile_llvm_rv32(onnx_path: str, output_asm: str) -> dict:
- """Compile ONNX → LLVM IR → RV32IMF via llvmlite."""
- t0 = time.perf_counter()
+MODEL_FIELDS = (
+ "path", "sha256", "bytes", "input_name", "input_shape", "output_name",
+ "output_shape", "initializer_count", "weight_bytes",
+)
- # 2a. Generate LLVM IR (script writes to file, not stdout)
- ir_file = "/tmp/_cnn.ll"
- rc, _, stderr = _run_py([
- "scratchv/standalone/onnx_to_llvm_standalone.py",
- onnx_path, "-o", ir_file, "--opt-level", "2",
- ], timeout=120)
- if not Path(ir_file).exists():
- return {"status": "failed", "error": f"LLVM IR file not generated: {stderr[:200]}",
- "elapsed_s": time.perf_counter() - t0}
-
- # 2b. Parse and compile to RV32IMF
- try:
- from llvmlite import binding
- binding.initialize_all_targets()
- binding.initialize_all_asmprinters()
+OPS_KEYS = ("total", "load", "store", "mul", "add", "madd", "branch")
- ir_text = Path(ir_file).read_text()
- llmod = binding.parse_assembly(ir_text)
- llmod.verify()
+RV64_ONLY_MNEMONICS = frozenset({
+ "ld", "sd", "lwu", "addw", "subw", "addiw", "sllw", "srlw", "sraw",
+ "slliw", "srliw", "sraiw", "mulw", "divw", "divuw", "remw", "remuw",
+ "fld", "fsd", "fcvt.l.s", "fcvt.s.l",
+})
- target = binding.Target.from_triple("riscv32")
- tm = target.create_target_machine(
- cpu="generic-rv32", features="+m,+f", codemodel="small",
- )
- asm = tm.emit_assembly(llmod)
+_NON_INSTRUCTION_PSEUDO = frozenset({"li", "mv", "nop", "ret", "j", "jr"})
- # Write assembly
- Path(output_asm).write_text(asm)
- static_insns = sum(1 for l in asm.splitlines()
- if l.strip() and not l.strip().startswith('.')
- and not l.strip().endswith(':'))
+_LOAD_OPS = frozenset({"lw", "lh", "lb", "lbu", "lhu", "flw", "flw.s"})
+_STORE_OPS = frozenset({"sw", "sh", "sb", "fsw", "fsw.s"})
+_MUL_OPS = frozenset({"mul", "mulh", "mulhsu", "mulhu"})
+_MADD_OPS = frozenset({
+ "fmadd.s", "fmsub.s", "fnmadd.s", "fnmsub.s", "fmul.s", "fadd.s",
+ "fsub.s", "fdiv.s",
+})
+_ARITH_OPS = frozenset({
+ "add", "addi", "sub", "slt", "sltu", "slti", "sltiu", "slli", "srli",
+ "srai", "and", "andi", "or", "ori", "xor", "xori", "lui", "auipc",
+ "div", "divu", "rem", "remu", "sll", "srl", "sra",
+})
+_BRANCH_OPS = frozenset({
+ "beq", "bne", "blt", "bge", "bltu", "bgeu", "jal", "jalr",
+})
- elapsed = time.perf_counter() - t0
- return {"status": "success", "static_insns": static_insns,
- "elapsed_s": elapsed, "assembly": output_asm,
- "isa": "rv32imf"}
- except ImportError:
- return {"status": "skipped", "reason": "llvmlite not available",
- "elapsed_s": time.perf_counter() - t0}
- except Exception as e:
- return {"status": "failed", "error": str(e)[:300],
- "elapsed_s": time.perf_counter() - t0}
+class LayoutError(RuntimeError):
+ """Image or memory layout cannot satisfy the bare-metal ABI."""
+
+
+class LabelParseError(RuntimeError):
+ """The assembly listing and the binary code size disagree."""
+
+
+class SimulationTimeout(RuntimeError):
+ """Wall-clock timeout fired during simulation."""
+
+
+_timed_out = False
+
+
+def _alarm_handler(signum, frame): # pragma: no cover - signal glue
+ global _timed_out
+ _timed_out = True
+ raise SimulationTimeout("simulation wall-clock timeout")
# ═══════════════════════════════════════════════════════════════════════════
-# Step 3: TinyFive simulation
+# Small helpers
# ═══════════════════════════════════════════════════════════════════════════
-def _run_py(args, timeout=60):
+def sha256_file(path: str | Path) -> str:
+ h = hashlib.sha256()
+ with open(path, "rb") as f:
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+def _align_up(value: int, align: int) -> int:
+ return (value + align - 1) // align * align
+
+
+def _run_py(args: list[str], timeout: float = 120.0):
+ env = dict(os.environ)
+ existing = env.get("PYTHONPATH", "")
+ env["PYTHONPATH"] = str(PROJ) + (os.pathsep + existing if existing else "")
proc = subprocess.run(
[sys.executable] + args, capture_output=True, text=True,
- cwd=str(PROJ), timeout=timeout,
+ cwd=str(PROJ), timeout=timeout, env=env,
)
return proc.returncode, proc.stdout, proc.stderr
-def run_tinyfive(asm_path: str, n_instructions: int = 10000) -> dict:
- """Run RV32 assembly through TinyFive ProfiledMachine. Returns ops counters."""
- t0 = time.perf_counter()
+
+def _to_int(text: str | None) -> int | None:
+ if text is None:
+ return None
try:
- from scratchv.simulator.tinyfive import ProfiledMachine
+ return int(text.replace(",", "").strip())
+ except ValueError:
+ return None
- m = ProfiledMachine(mem_size=65536)
- if not m.available:
- return _tinyfive_static_fallback(asm_path, n_instructions)
- # Parse assembly and load
- asm_text = Path(asm_path).read_text() if Path(asm_path).exists() else ""
- lines = _prepare_asm_for_tinyfive(asm_text)
- m.load_asm(lines, origin=0x200)
+def parse_data_offset(stdout: str) -> int | None:
+ """Parse ``Data offset: 0x...`` from the compiler stdout."""
+ m = DATA_OFFSET_RE.search(stdout)
+ return int(m.group(1), 16) if m else None
- # Set up minimal test data
- m.write_mem_i32(0x1000, 0x00010000) # Q16.16 = 1.0
- # Execute
- try:
- m.run(n=n_instructions)
- except Exception:
- pass # May stop early on ecall or infinite loop
+def parse_workspace_bytes(stdout: str) -> int | None:
+ """Parse ``Workspace: N bytes`` from the compiler stdout."""
+ m = WORKSPACE_RE.search(stdout)
+ return _to_int(m.group(1)) if m else None
- elapsed = time.perf_counter() - t0
- ops = {}
- if hasattr(m, '_machine') and m._machine and hasattr(m._machine, 'ops'):
- ops = dict(m._machine.ops)
- x_used = 0
- if hasattr(m, '_machine') and m._machine and hasattr(m._machine, 'x_usage'):
- x_used = int(sum(m._machine.x_usage))
+def parse_code_bytes(stdout: str) -> int | None:
+ """Parse ``Code size: N bytes`` from the compiler stdout."""
+ m = CODE_SIZE_RE.search(stdout)
+ return _to_int(m.group(1)) if m else None
- return {"status": "success", "instr_count": m.instr_count,
- "ops": ops, "x_regs_used": x_used, "elapsed_s": elapsed}
- except ImportError:
- return _tinyfive_static_fallback(asm_path, n_instructions)
- except Exception as e:
- return {"status": "error", "error": str(e)[:200]}
+def _iter_asm_lines(asm_text: str, stop_at_data: bool = False):
+ """Yield ``(pc_index, mnemonic, operands)`` for instruction lines."""
+ pc = 0
+ for raw in asm_text.splitlines():
+ line = raw.split("#", 1)[0]
+ line = line.split(";", 1)[0]
+ line = line.strip()
+ if not line:
+ continue
+ if line.startswith("."):
+ if stop_at_data and re.match(r"\.(section|data|rodata|bss)\b", line):
+ break
+ continue
+ if ":" in line:
+ head, _, tail = line.partition(":")
+ if " " not in head and tail.strip() == "":
+ continue
+ if " " not in head and tail.strip():
+ line = tail.strip()
+ tokens = line.replace(",", " ").split()
+ if not tokens:
+ continue
+ yield pc, tokens[0].lower(), tokens[1:]
+ pc += 4
-def _prepare_asm_for_tinyfive(asm_text: str) -> list[str]:
- """Clean assembly for TinyFive: remove directives, simplify labels, handle pseudo-ops."""
- lines_out = []
- label_map = {}
+def _scan_asm(asm_text: str, stop_at_data: bool = False) -> tuple[int, dict]:
+ count = 0
+ for _pc, _op, _ops in _iter_asm_lines(asm_text, stop_at_data=stop_at_data):
+ count += 1
+ return count, static_instruction_mix(asm_text, stop_at_data=stop_at_data)
+
+
+def static_instruction_mix(asm_text: str, stop_at_data: bool = False) -> dict:
+ """Static mnemonic mix; never fed into ``dynamic.ops``."""
+ mix = {k: 0 for k in ("load", "store", "mul", "add", "madd", "branch", "other")}
+ for _pc, op, _operands in _iter_asm_lines(asm_text, stop_at_data=stop_at_data):
+ if op in _LOAD_OPS:
+ mix["load"] += 1
+ elif op in _STORE_OPS:
+ mix["store"] += 1
+ elif op in _MUL_OPS:
+ mix["mul"] += 1
+ elif op in _MADD_OPS:
+ mix["madd"] += 1
+ elif op in _BRANCH_OPS or op in ("j", "jr", "ret"):
+ mix["branch"] += 1
+ elif op in _ARITH_OPS or op in ("li", "mv", "nop"):
+ mix["add"] += 1
+ else:
+ mix["other"] += 1
+ return {"source": "asm_scan", **mix}
+
+
+def parse_labels(asm_text: str, expected_code_bytes: int) -> dict[int, str]:
+ """Map PC -> label from a compiled ``.s`` listing.
+
+ Every branch/jump target must resolve to a label; a missing target raises
+ ``LabelParseError`` instead of silently degrading to a self-jump.
+ """
pc = 0
+ labels: dict[int, str] = {}
+ instructions: list[tuple[int, str, list[str]]] = []
+ for raw in asm_text.splitlines():
+ line = raw.split("#", 1)[0].rstrip()
+ if not line.strip():
+ continue
+ stripped = line.strip()
+ if stripped.startswith("."):
+ continue
+ if stripped.endswith(":") and "(" not in stripped:
+ labels[pc] = stripped[:-1]
+ continue
+ tokens = stripped.replace(",", " ").split()
+ instructions.append((pc, tokens[0].lower(), tokens[1:]))
+ pc += 4
- for line in asm_text.splitlines():
- line = line.strip()
- if not line: continue
- if line.startswith('.'): continue
- if '#' in line: line = line.split('#')[0].strip()
- if not line: continue
-
- # Label
- if line.endswith(':') and '(' not in line:
- label_map[line[:-1]] = pc
+ if pc != expected_code_bytes:
+ raise LabelParseError(
+ f"asm/code-size mismatch: {pc} != {expected_code_bytes}"
+ )
+ if 0 not in labels:
+ raise LabelParseError("missing _start label at pc=0")
+
+ for ipc, op, operands in instructions:
+ if op in ("beq", "bne", "blt", "bge", "bltu", "bgeu", "jal", "j"):
+ if not operands:
+ continue
+ target = operands[-1]
+ if re.match(r"^[+-]?\d+$", target):
+ target_pc = ipc + int(target)
+ elif target in labels:
+ target_pc = labels[target]
+ else:
+ raise LabelParseError(
+ f"unresolved branch target {target!r} at pc={ipc}"
+ )
+ if target_pc not in labels:
+ raise LabelParseError(
+ f"branch at pc={ipc} targets pc={target_pc} without a label"
+ )
+ return labels
+
+
+def detect_isa_mismatch(asm_text: str) -> list[str]:
+ """Return RV64-only mnemonics found in an assembly listing."""
+ found = set()
+ for _pc, op, _operands in _iter_asm_lines(asm_text, stop_at_data=True):
+ if op in RV64_ONLY_MNEMONICS:
+ found.add(op)
+ return sorted(found)
+
+
+def _tinyfive_supported_mnemonics() -> frozenset[str]:
+ try:
+ from tinyfive.machine import dec_dict # type: ignore
+ except Exception:
+ return frozenset()
+ return frozenset(entry[1] for entry in dec_dict.values())
+
+
+def check_mnemonics(code_words: list[int]) -> list[str]:
+ """Return code words whose mnemonic TinyFive cannot execute."""
+ supported = _tinyfive_supported_mnemonics()
+ if not supported:
+ return []
+ unsupported = set()
+ for word in code_words:
+ text = _disasm_one(word)
+ op = text.split(" ", 1)[0].strip().lower()
+ if op.startswith(".word"):
+ unsupported.add(f".word (0x{word:08x})")
+ continue
+ if op in supported or op in _NON_INSTRUCTION_PSEUDO:
continue
+ unsupported.add(f"{op} (0x{word:08x})")
+ return sorted(unsupported)
- # Instruction
- lines_out.append(line)
- pc += 4
- # Replace label refs with offsets (TinyFive doesn't support labels natively in load_asm)
- # Skip for now — TinyFive load_asm handles basic labels
- return lines_out
+def build_input_q16(elements: int, seed: int) -> bytes:
+ """Deterministic Q16.16 input identical to the compiler self-check."""
+ rng = random.Random(seed)
+ values = [int((rng.random() - 0.5) * 0.2 * 65536) for _ in range(elements)]
+ return struct.pack(f"<{elements}i", *values)
-def _tinyfive_static_fallback(asm_path: str, n_instr: int) -> dict:
- """Fallback: count ops statically from assembly text."""
- ops = Counter()
- x_regs = set()
+# ═══════════════════════════════════════════════════════════════════════════
+# Model / environment metadata
+# ═══════════════════════════════════════════════════════════════════════════
- try:
- text = Path(asm_path).read_text()
- except: text = ""
+def get_model_info(model_path: str) -> dict:
+ """Model identity, shapes and element counts (no hardcoded descriptions)."""
+ model = ONNXModel.from_file(model_path)
+ input_t = model.inputs[0] if model.inputs else None
+ output_t = model.outputs[0] if model.outputs else None
+
+ def _numel(t):
+ if t is None:
+ return 0
+ n = 1
+ for d in t.shape:
+ n *= int(d)
+ return n
+
+ return {
+ "path": str(model_path),
+ "sha256": sha256_file(model_path),
+ "bytes": os.path.getsize(model_path),
+ "input_name": input_t.name if input_t else None,
+ "input_shape": list(input_t.shape) if input_t else [],
+ "output_name": output_t.name if output_t else None,
+ "output_shape": list(output_t.shape) if output_t else [],
+ "initializer_count": len(model.initializers),
+ "weight_bytes": sum(t.size_bytes for t in model.initializers.values()),
+ "input_elements": _numel(input_t),
+ "output_elements": _numel(output_t),
+ }
+
+
+def get_environment() -> dict:
+ def _version(name: str):
+ try:
+ import importlib.metadata as md
+ return md.version(name)
+ except Exception:
+ pass
+ try:
+ mod = __import__(name)
+ return getattr(mod, "__version__", None)
+ except Exception:
+ return None
- for line in text.splitlines():
- line = line.strip()
- if not line or line.startswith('.') or line.endswith(':'):
- continue
- if '#' in line: line = line.split('#')[0].strip()
- if not line: continue
-
- tokens = line.replace(',', ' ').split()
- op = tokens[0].lower()
-
- if op in ('lw', 'lh', 'lb', 'lbu', 'lhu', 'flw'):
- ops['load'] += 1
- elif op in ('sw', 'sh', 'sb', 'fsw'):
- ops['store'] += 1
- elif op in ('mul', 'mulh'):
- ops['mul'] += 1
- elif op in ('fmul.s', 'fadd.s', 'fsub.s', 'fdiv.s', 'fmadd.s', 'fmsub.s'):
- ops['madd'] += 1
- elif op in ('add', 'addi', 'sub', 'slt', 'slli', 'srli', 'srai',
- 'and', 'andi', 'or', 'ori', 'xor', 'lui', 'auipc'):
- ops['add'] += 1
- elif op in ('beq', 'bne', 'blt', 'bge', 'bltu', 'bgeu', 'jal', 'jalr', 'j', 'ret'):
- ops['branch'] += 1
- elif op in ('ecall', 'nop'):
- ops['add'] += 1
-
- # Track registers
- for t in tokens[1:]:
- t = t.strip(',')
- if re.match(r'^[xstaf]\d+$|^zero$|^ra$|^sp$|^gp$', t):
- x_regs.add(t)
-
- ops['total'] = sum(ops.values())
- for k in ('mul', 'madd'):
- if k not in ops: ops[k] = 0
-
- return {"status": "static_fallback", "ops": dict(ops),
- "x_regs_used": len(x_regs), "instr_count": n_instr,
- "elapsed_s": 0.0, "_note": "TinyFive not installed; static counts only"}
+ return {
+ "python": "%d.%d.%d" % sys.version_info[:3],
+ "numpy": _version("numpy"),
+ "tinyfive": _version("tinyfive"),
+ "llvmlite": _version("llvmlite"),
+ }
# ═══════════════════════════════════════════════════════════════════════════
-# Step 4: Professional report
+# Layout / image loading
# ═══════════════════════════════════════════════════════════════════════════
-@dataclass
-class BenchResult:
- name: str = ""
- isa: str = ""
- compile: dict = field(default_factory=dict)
- tinyfive: dict = field(default_factory=dict)
+def compute_layout(*, data_offset: int, data_size: int, workspace_bytes: int,
+ input_elements: int, output_elements: int,
+ mem_size: int) -> dict:
+ """Validate the bare-metal layout and return the addresses."""
+ if data_offset <= 0 or data_offset % 4 != 0:
+ raise LayoutError(f"data_offset is not a positive 4-byte multiple: {data_offset}")
+ halt_addr = _align_up(data_offset + data_size, 16)
+ in_bytes = input_elements * 4
+ out_bytes = output_elements * 4
-def generate_report(sv: BenchResult, ll: BenchResult) -> str:
- """Generate professional markdown + HTML comparison report."""
- svo = sv.tinyfive.get("ops", {})
- llo = ll.tinyfive.get("ops", {})
+ if halt_addr + 4 > mem_size:
+ raise LayoutError(
+ f"image does not fit: halt_addr=0x{halt_addr:x} + 4 > mem_size={mem_size}"
+ )
+ min_out = OUTPUT_ADDR + out_bytes + OUTPUT_GUARD_BYTES
+ if min_out > mem_size:
+ raise LayoutError(
+ f"memory_layout_invalid: need mem_size >= {min_out} bytes "
+ f"(192MiB output base + {out_bytes} bytes output + "
+ f"{OUTPUT_GUARD_BYTES} bytes guard); got {mem_size}"
+ )
+ if SP_ADDR + workspace_bytes + GUARD_BYTES > INPUT_ADDR:
+ raise LayoutError(
+ f"workspace collides with the input region: sp=0x{SP_ADDR:x} + "
+ f"{workspace_bytes} + {GUARD_BYTES} guard > input=0x{INPUT_ADDR:x}"
+ )
+ if INPUT_ADDR + in_bytes > OUTPUT_ADDR:
+ raise LayoutError(
+ f"input region overruns output base: input=0x{INPUT_ADDR:x} + "
+ f"{in_bytes} > output=0x{OUTPUT_ADDR:x}"
+ )
- sv_total = svo.get("total", 0)
- ll_total = llo.get("total", 0)
- sv_regs = sv.tinyfive.get("x_regs_used", 0)
- ll_regs = ll.tinyfive.get("x_regs_used", 0)
- sv_code = sv.compile.get("static_insns", 0)
- ll_code = ll.compile.get("static_insns", 0)
+ return {
+ "sp": SP_ADDR,
+ "input_addr": INPUT_ADDR,
+ "output_addr": OUTPUT_ADDR,
+ "halt_addr": halt_addr,
+ "mem_size": mem_size,
+ }
+
+
+def load_scratchv_image(binary_path: str, data_offset: int) -> tuple[list[int], bytes]:
+ """Split the flat ``.bin`` into code words and weight bytes."""
+ binary = Path(binary_path).read_bytes()
+ if data_offset is None or data_offset <= 0 or data_offset % 4 != 0:
+ raise LayoutError(f"invalid data_offset for image: {data_offset}")
+ if data_offset >= len(binary):
+ raise LayoutError(
+ f"image has no weight section: data_offset={data_offset}, "
+ f"binary={len(binary)}"
+ )
+ code_words = [
+ int.from_bytes(binary[i:i + 4], "little")
+ for i in range(0, data_offset, 4)
+ ]
+ return code_words, binary[data_offset:]
- def vs_llvm(sv_val, ll_val, suffix=""):
- if not ll_val: return "—"
- v = sv_val / ll_val
- return f"{v:.2f}×" if v < 10 else f"{v:.1f}×"
- md = f"""# RISC-V RV32 Benchmark: ScratchV vs LLVM
+# ═══════════════════════════════════════════════════════════════════════════
+# Compilation
+# ═══════════════════════════════════════════════════════════════════════════
-**Model**: cnn.onnx (3×Conv + 3×MaxPool + 2×FC)
-**Target ISA**: Both compile to RV32 — ScratchV RV32IM (Q16.16) · LLVM RV32IMF (float32)
-**Simulator**: TinyFive ProfiledMachine — all data from actual simulation
+def compile_scratchv(onnx_path: str, output_bin: str, output_asm: str,
+ timeout_s: float = 120.0) -> dict:
+ """Compile ONNX -> ScratchV RV32IM binary and parse its layout."""
+ t0 = time.perf_counter()
+ try:
+ rc, stdout, stderr = _run_py([
+ "scratchv/standalone/onnx_to_riscv_standalone.py",
+ onnx_path, "-o", output_bin, "--asm", output_asm,
+ ], timeout=timeout_s)
+ except Exception as exc: # subprocess.TimeoutExpired, OSError
+ return {
+ "status": "failed", "error": f"{type(exc).__name__}: {exc}"[:300],
+ "reason": "compiler_invocation_failed", "static_insns": 0,
+ "static_source": "asm_scan", "data_offset": None,
+ "elapsed_s": time.perf_counter() - t0,
+ }
----
+ elapsed = time.perf_counter() - t0
+ base = {
+ "status": "failed", "static_insns": 0, "static_source": "asm_scan",
+ "data_offset": None, "elapsed_s": elapsed,
+ }
+ if rc != 0:
+ base.update({
+ "error": (stderr or stdout)[-300:],
+ "reason": "compiler_returned_nonzero",
+ })
+ return base
+
+ data_offset = parse_data_offset(stdout)
+ workspace_bytes = parse_workspace_bytes(stdout)
+ code_bytes = parse_code_bytes(stdout)
+ if data_offset is None or workspace_bytes is None:
+ base.update({
+ "error": "binary_layout_unparsed",
+ "reason": "binary_layout_unparsed",
+ })
+ return base
-## 1. Compilation Summary
+ try:
+ binary = Path(output_bin).read_bytes()
+ except OSError as exc:
+ base.update({"error": f"binary_unreadable: {exc}", "reason": "binary_unreadable"})
+ return base
-| Metric | ScratchV RV32IM | LLVM RV32IMF |
-|--------|-----------------|--------------|
-| Status | {sv.compile.get('status','?')} | {ll.compile.get('status','?')} |
-| Static instructions | {sv_code} | {ll_code} |
-| Compilation time | {sv.compile.get('elapsed_s',0):.1f}s | {ll.compile.get('elapsed_s',0):.1f}s |
+ asm_text = Path(output_asm).read_text() if Path(output_asm).exists() else ""
+ try:
+ parse_labels(asm_text, data_offset)
+ except LabelParseError as exc:
+ base.update({"error": str(exc), "reason": "label_parse_failed"})
+ return base
+
+ static_insns, _mix = _scan_asm(asm_text)
+ if code_bytes is not None and code_bytes != data_offset:
+ base.update({
+ "error": f"code size mismatch: stdout={code_bytes} data_offset={data_offset}",
+ "reason": "binary_layout_unparsed",
+ })
+ return base
+ if static_insns != data_offset // 4:
+ base.update({
+ "error": f"asm/code-size mismatch: {static_insns} != {data_offset // 4}",
+ "reason": "label_parse_failed",
+ })
+ return base
+
+ return {
+ "status": "success",
+ "binary": os.path.basename(output_bin),
+ "binary_bytes": len(binary),
+ "binary_sha256": hashlib.sha256(binary).hexdigest(),
+ "code_bytes": data_offset,
+ "data_offset": data_offset,
+ "data_offset_source": "compiler_stdout",
+ "data_bytes": len(binary) - data_offset,
+ "workspace_bytes": workspace_bytes,
+ "static_insns": static_insns,
+ "static_source": "asm_scan",
+ "elapsed_s": elapsed,
+ }
+
+
+def compile_llvm_rv32(onnx_path: str, output_asm: str, *,
+ triple: str = "riscv32-unknown-elf",
+ opt_level: int = 2) -> dict:
+ """Compile ONNX -> LLVM IR -> assembly with an explicit RV32 triple."""
+ t0 = time.perf_counter()
-## 2. TinyFive Simulation — Ops Counters
+ def _skipped(reason: str) -> dict:
+ return {
+ "status": "skipped", "reason": reason, "isa_detected": None,
+ "isa_mismatch": False, "static_insns": 0,
+ "static_source": "asm_scan",
+ "elapsed_s": time.perf_counter() - t0,
+ }
-| Op Counter | ScratchV RV32IM | LLVM RV32IMF | ScratchV / LLVM |
-|------------|-----------------|--------------|-----------------|
-| `total` | {svo.get('total','—')} | {llo.get('total','—')} | {vs_llvm(sv_total, ll_total)} |
-| `load` | {svo.get('load','—')} | {llo.get('load','—')} | {vs_llvm(svo.get('load',0), llo.get('load',0))} |
-| `store` | {svo.get('store','—')} | {llo.get('store','—')} | {vs_llvm(svo.get('store',0), llo.get('store',0))} |
-| `mul` | {svo.get('mul','—')} | {llo.get('mul','—')} | {vs_llvm(svo.get('mul',0), llo.get('mul',0))} |
-| `add` | {svo.get('add','—')} | {llo.get('add','—')} | {vs_llvm(svo.get('add',0), llo.get('add',0))} |
-| `madd` | {svo.get('madd','—')} | {llo.get('madd','—')} | {vs_llvm(svo.get('madd',0), llo.get('madd',0))} |
-| `branch` | {svo.get('branch','—')} | {llo.get('branch','—')} | {vs_llvm(svo.get('branch',0), llo.get('branch',0))} |
+ try:
+ from llvmlite import binding # type: ignore
+ except ImportError:
+ return _skipped("llvmlite not available")
-## 3. Register Usage
+ ir_path = str(Path(output_asm).with_suffix(".ll"))
+ try:
+ rc, _stdout, stderr = _run_py([
+ "scratchv/standalone/onnx_to_llvm_standalone.py",
+ onnx_path, "-o", ir_path, "--opt-level", str(opt_level),
+ ], timeout=120.0)
+ except Exception as exc:
+ return {
+ "status": "failed", "reason": f"{type(exc).__name__}: {exc}"[:300],
+ "isa_detected": None, "isa_mismatch": False, "static_insns": 0,
+ "static_source": "asm_scan", "elapsed_s": time.perf_counter() - t0,
+ }
+ if rc != 0 or not Path(ir_path).exists():
+ return {
+ "status": "failed",
+ "reason": f"LLVM IR generation failed: {stderr[-200:]}",
+ "isa_detected": None, "isa_mismatch": False, "static_insns": 0,
+ "static_source": "asm_scan", "elapsed_s": time.perf_counter() - t0,
+ }
-| | ScratchV | LLVM |
-|---|---|---|
-| x registers used | {sv_regs} | {ll_regs} |
-| f registers used | 0 | 0 |
+ try:
+ binding.initialize()
+ binding.initialize_all_targets()
+ binding.initialize_all_asmprinters()
+ llmod = binding.parse_assembly(Path(ir_path).read_text())
+ llmod.triple = triple
+ target = binding.Target.from_triple(triple)
+ tm = target.create_target_machine(
+ cpu="generic-rv32", features="+m,+f", codemodel="small",
+ opt=opt_level,
+ )
+ llmod.data_layout = str(tm.target_data)
+ llmod.verify()
+ asm = tm.emit_assembly(llmod)
+ Path(output_asm).write_text(asm)
+ except Exception as exc:
+ return {
+ "status": "failed", "reason": f"{type(exc).__name__}: {exc}"[:300],
+ "isa_detected": None, "isa_mismatch": False, "static_insns": 0,
+ "static_source": "asm_scan", "elapsed_s": time.perf_counter() - t0,
+ }
+
+ mismatches = detect_isa_mismatch(asm)
+ static_insns, _mix = _scan_asm(asm, stop_at_data=True)
+ return {
+ "status": "success",
+ "reason": (
+ f"rv64 mnemonics detected: {', '.join(mismatches)}"
+ if mismatches else None
+ ),
+ "isa_detected": "riscv64" if mismatches else "rv32",
+ "isa_mismatch": bool(mismatches),
+ "static_insns": static_insns,
+ "static_source": "asm_scan",
+ "elapsed_s": time.perf_counter() - t0,
+ }
-## 4. Analysis
-- **Dynamic instruction ratio**: ScratchV executes **{vs_llvm(sv_total, ll_total)}** the instructions of LLVM
-- **Load ratio**: ScratchV makes **{vs_llvm(svo.get('load',0), llo.get('load',0))}** the loads
-- **Store ratio**: ScratchV makes **{vs_llvm(svo.get('store',0), llo.get('store',0))}** the stores
-- **FP advantage**: LLVM uses hardware `fmul.s`/`fadd.s` (1 instruction each), while ScratchV Q16.16 uses `mul` + `srai` + `add` (3+ instructions per MAC)
+# ═══════════════════════════════════════════════════════════════════════════
+# Simulation
+# ═══════════════════════════════════════════════════════════════════════════
----
+# TinyFive 1.0.0 compatibility shims, applied to machine *instances* only.
+#
+# 1. ``machine.LW``/``LH`` build words by shifting ``numpy.uint8`` scalars,
+# which overflows at eight bits under NumPy 2.x and silently drops bytes
+# 1..3 (the adapter already patches ``read_i32`` for instruction fetch but
+# not these data loads).
+# 2. ``machine.exe(start, end, instructions)`` ignores ``instructions``
+# whenever ``end`` is given and has no way to stop at both. Without an
+# instruction budget tinyfive would keep decoding zeroed memory past the
+# halt address; every decode increments ``ops['total']``, which silently
+# inflates the measured dynamic count. The shim below stops at whichever
+# comes first: the halt address or the budget.
+#
+# Neither patch modifies ``scratchv/simulator/tinyfive.py``.
+
+def _tinyfive_lw_compat(s, rd, imm, rs1):
+ addr = int(s.x[rs1]) + int(imm)
+ if addr < 0 or addr + 4 > len(s.mem):
+ raise IndexError(f"tinyfive lw out of bounds: {addr}")
+ s.x[rd] = int.from_bytes(bytes(s.mem[addr:addr + 4]), "little", signed=True)
+ s.ipc()
+
+
+def _tinyfive_lh_compat(s, rd, imm, rs1):
+ addr = int(s.x[rs1]) + int(imm)
+ if addr < 0 or addr + 2 > len(s.mem):
+ raise IndexError(f"tinyfive lh out of bounds: {addr}")
+ s.x[rd] = int.from_bytes(bytes(s.mem[addr:addr + 2]), "little", signed=True)
+ s.ipc()
+
+
+def _install_tinyfive_compat(machine, halt_addr: int) -> None:
+ """Bind NumPy-safe loads and a halt-aware bounded ``exe`` to *machine*."""
+ raw = getattr(machine, "_machine", None)
+ if raw is None:
+ return
+ raw.LW = MethodType(_tinyfive_lw_compat, raw)
+ raw.LH = MethodType(_tinyfive_lh_compat, raw)
+
+ def exe_with_halt(s, start, end=None, instructions=0):
+ target = halt_addr if end is None else end
+ s.pc = s.look_up_label(start)
+ end_pc = s.look_up_label(target)
+ executed = 0
+ while s.pc != end_pc:
+ if instructions and executed >= instructions:
+ break
+ s.dec(np.binary_repr(s.u(s.read_i32(s.pc)), 32))
+ executed += 1
+
+ raw.exe = MethodType(exe_with_halt, raw)
+
+
+def _read_pc(machine) -> int:
+ """Read PC without tripping over TinyFive's scalar-PC after JALR."""
+ try:
+ return int(machine.pc)
+ except Exception:
+ pass
+ raw = getattr(getattr(machine, "_machine", None), "pc", None)
+ if raw is None:
+ return 0
+ if getattr(raw, "ndim", 0):
+ return int(raw[0])
+ return int(raw)
+
+
+def _read_register_usage(machine) -> tuple[int, int, int]:
+ raw = getattr(machine, "_machine", None)
+ if raw is None:
+ return 0, 0, 0
+ x_usage = getattr(raw, "x_usage", None)
+ f_usage = getattr(raw, "f_usage", None)
+ x_used = int((x_usage > 0).sum()) if x_usage is not None else 0
+ x_total = int(x_usage.sum()) if x_usage is not None else 0
+ f_used = int((f_usage > 0).sum()) if f_usage is not None else 0
+ return x_used, x_total, f_used
+
+
+def _unavailable_dynamic(*, reason: str, mem_size: int, timeout_s: float,
+ input_seed: int, input_elements: int,
+ halt_addr: int) -> dict:
+ return {
+ "source": "unavailable",
+ "simulator": "tinyfive",
+ "simulator_version": get_environment().get("tinyfive"),
+ "completion": "not_run",
+ "reason": reason,
+ "limit": None,
+ "executed": None,
+ "timeout_s": float(timeout_s),
+ "elapsed_s": 0.0,
+ "memory_size_bytes": mem_size,
+ "input_seed": input_seed,
+ "input_elements": input_elements,
+ "halt_addr": halt_addr,
+ "ops": None,
+ "x_registers_used": None,
+ "x_usage_total": None,
+ "f_registers_used": None,
+ "per_label": None,
+ "per_label_note": "tinyfive exe() exposes no per-PC trace",
+ "last_error": None,
+ }
+
+
+def _read_output(machine, addr: int, elements: int) -> dict:
+ values = []
+ if machine.available and elements > 0:
+ for i in range(elements):
+ values.append(int(machine.read_mem_i32(addr + i * 4)) & 0xFFFFFFFF)
+ raw_hex = "0x" + "".join(f"{v:08x}" for v in values) if values else ""
+ q16 = [
+ (v if v < 0x80000000 else v - 0x100000000) / 65536.0 for v in values
+ ]
+ return {
+ "addr": addr,
+ "elements": elements,
+ "raw_hex": raw_hex,
+ "q16_16": q16[0] if len(q16) == 1 else q16,
+ }
+
+
+def run_simulation(*, asm_path: str, binary_path: str, data_offset: int,
+ workspace_bytes: int, input_elements: int,
+ output_elements: int, max_instructions: int = 0,
+ mem_size: int = DEFAULT_MEM_SIZE,
+ timeout_s: float = DEFAULT_TIMEOUT_S,
+ chunk_instructions: int = DEFAULT_CHUNK_INSTRUCTIONS,
+ input_seed: int = DEFAULT_INPUT_SEED) -> dict:
+ """Execute the compiled binary with TinyFive and report honest counters."""
+ global _timed_out
-*All metrics sourced from TinyFive ProfiledMachine simulation. No analytical estimates.*
-*ScratchV: Q16.16 fixed-point on RV32IM · LLVM: float32 on RV32IMF (single-precision FPU)*
-"""
- return md
-
-
-def generate_html_report(sv: BenchResult, ll: BenchResult, md: str) -> str:
- """Wrap markdown in clean HTML."""
- from html import escape
- css = """*{box-sizing:border-box;margin:0;padding:0}
-body{font-family:system-ui,sans-serif;background:#f8fafc;color:#1e293b;line-height:1.6;max-width:900px;margin:0 auto;padding:20px}
-h1{font-size:1.3rem;margin-bottom:8px}
-h2{font-size:1rem;margin:20px 0 10px;padding-bottom:6px;border-bottom:2px solid #e2e8f0}
-table{width:100%;border-collapse:collapse;font-size:.85rem;margin:10px 0}
-th{background:#f1f5f9;padding:8px 12px;text-align:left;font-weight:600}
-td{padding:7px 12px;border-bottom:1px solid #f1f5f9}
-tr:hover td{background:#f8fafc}
-.n{text-align:right;font-variant-numeric:tabular-nums}
-.badge{display:inline-block;padding:2px 8px;border-radius:6px;font-size:.7rem;font-weight:700}
-.badge.sv{background:#dbeafe;color:#1e40af}
-.badge.ll{background:#dcfce7;color:#166534}
-pre{background:#f1f5f9;padding:12px;border-radius:6px;overflow-x:auto;font-size:.8rem}
-@media(prefers-color-scheme:dark){
-body{background:#0f172a;color:#e2e8f0}
-th{background:#334155}
-td{border-color:#334155}
-tr:hover td{background:#1e293b}
-pre{background:#1e293b}
-}"""
- # Simple markdown→HTML conversion
- html = md
- html = html.replace("## ", "").replace("\n## ", "\n")
- hl = html.splitlines()
- out = []
- in_table = False; in_code = False
- for l in hl:
- if l.startswith(""):
- if in_table: out.append("
"); in_table = False
- l = l.replace("", "").rstrip()
- out.append(f"{l}
")
- elif l.startswith("# "):
- l = l.replace("# ", "").rstrip()
- out.append(f"{l}
")
- elif l.startswith("|"):
- if not in_table: out.append("
"); in_table = True
- cells = l.split("|")[1:-1]
- if all(c.strip().startswith("--") for c in cells):
- continue # skip separator
- tag = "th" if out and out[-1] == "" else "td"
- cls = ' class="n"' if tag == "td" else ""
- row = "" + "".join(f"<{tag}{cls}>{c.strip()}{tag}>" for c in cells) + "
"
- out.append(row)
- elif l.startswith("**") and l.endswith("**"):
- out.append(f"{l[2:-2]}
")
- elif l.startswith("*All metrics"):
- out.append(f"{l[1:]}
")
- elif l.startswith("*ScratchV"):
- out.append(f"{l[1:]}
")
- elif l.strip():
- # Convert inline **bold** and `code`
- l = re.sub(r'\*\*(.+?)\*\*', r'\1', l)
- l = re.sub(r'`(.+?)`', r'\1', l)
- out.append(f"{l}
")
- if in_table: out.append("
")
-
- body = "\n".join(out)
- return f"RV32 BenchmarkScratchV RV32IM LLVM RV32IMF\n{body}\n"
+ t0 = time.perf_counter()
+ limit = None if max_instructions in (0, None) else int(max_instructions)
+ if limit is not None and limit < 0:
+ raise ValueError(f"max_instructions must be >= 0, got {max_instructions}")
+ if chunk_instructions <= 0:
+ raise ValueError(f"chunk_instructions must be > 0, got {chunk_instructions}")
+
+ binary = Path(binary_path).read_bytes()
+ data_size = len(binary) - data_offset
+ layout = compute_layout(
+ data_offset=data_offset, data_size=data_size,
+ workspace_bytes=workspace_bytes, input_elements=input_elements,
+ output_elements=output_elements, mem_size=mem_size,
+ )
+ halt_addr = layout["halt_addr"]
+
+ m = ProfiledMachine(mem_size=mem_size)
+ if not m.available:
+ return {
+ "dynamic": _unavailable_dynamic(
+ reason="tinyfive not installed (ProfiledMachine.available=False)",
+ mem_size=mem_size, timeout_s=timeout_s, input_seed=input_seed,
+ input_elements=input_elements, halt_addr=halt_addr,
+ ),
+ "output": {
+ "addr": OUTPUT_ADDR, "elements": output_elements,
+ "raw_hex": None, "q16_16": None,
+ },
+ }
+ _install_tinyfive_compat(m, halt_addr)
+
+ try:
+ code_words, weights = load_scratchv_image(binary_path, data_offset)
+ except LayoutError as exc:
+ return {
+ "dynamic": _unavailable_dynamic(
+ reason=f"image_load_failed: {exc}", mem_size=mem_size,
+ timeout_s=timeout_s, input_seed=input_seed,
+ input_elements=input_elements, halt_addr=halt_addr,
+ ),
+ "output": {
+ "addr": OUTPUT_ADDR, "elements": output_elements,
+ "raw_hex": None, "q16_16": None,
+ },
+ }
+
+ unsupported = check_mnemonics(code_words)
+ if unsupported:
+ dyn = _unavailable_dynamic(
+ reason="unsupported_mnemonics: " + ", ".join(unsupported[:8]),
+ mem_size=mem_size, timeout_s=timeout_s, input_seed=input_seed,
+ input_elements=input_elements, halt_addr=halt_addr,
+ )
+ return {
+ "dynamic": dyn,
+ "output": {
+ "addr": OUTPUT_ADDR, "elements": output_elements,
+ "raw_hex": None, "q16_16": None,
+ },
+ }
+
+ m.load_binary(code_words, origin=0)
+ m.load_data(weights, data_offset)
+ input_blob = build_input_q16(input_elements, input_seed)
+ if input_blob:
+ m.load_data(input_blob, INPUT_ADDR)
+ m.set_reg(2, layout["sp"])
+ m.set_reg(10, layout["input_addr"])
+ m.set_reg(11, layout["output_addr"])
+ m.set_reg(1, halt_addr)
+
+ _timed_out = False
+ old_handler = None
+ use_alarm = hasattr(signal, "SIGALRM") and timeout_s is not None and timeout_s > 0
+ if use_alarm:
+ try:
+ old_handler = signal.signal(signal.SIGALRM, _alarm_handler)
+ except (ValueError, OSError): # not in main thread
+ use_alarm = False
+ deadline = (
+ time.monotonic() + float(timeout_s)
+ if timeout_s and timeout_s > 0 else float("inf")
+ )
+
+ executed = 0
+ completion = "error"
+ error_msg: str | None = None
+ devnull = open(os.devnull, "w")
+ try:
+ while True:
+ pc = _read_pc(m)
+ if limit is not None and executed >= limit:
+ completion = "budget_exhausted"
+ break
+ if pc == halt_addr:
+ completion = "halted"
+ break
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ completion = "timeout"
+ break
+ chunk = chunk_instructions
+ if limit is not None:
+ chunk = min(chunk, limit - executed)
+ if chunk <= 0:
+ completion = "budget_exhausted"
+ break
+
+ if use_alarm:
+ signal.setitimer(signal.ITIMER_REAL, max(remaining, 1e-6))
+ try:
+ with contextlib.redirect_stdout(devnull):
+ m.run(instructions=chunk, start=pc, strict=True)
+ except RuntimeError as exc:
+ if _timed_out:
+ completion = "timeout"
+ else:
+ completion = "error"
+ error_msg = m.last_error or str(exc)[:300]
+ break
+ finally:
+ if use_alarm:
+ signal.setitimer(signal.ITIMER_REAL, 0.0)
+
+ after = _perf_total(m)
+ if after == executed and _read_pc(m) != halt_addr:
+ completion = "error"
+ error_msg = (
+ f"simulation stalled at pc=0x{pc:x} "
+ "(unsupported instruction or wedged decoder)"
+ )
+ break
+ executed = after
+ finally:
+ if use_alarm:
+ signal.setitimer(signal.ITIMER_REAL, 0.0)
+ if old_handler is not None:
+ signal.signal(signal.SIGALRM, old_handler)
+ devnull.close()
+
+ elapsed = time.perf_counter() - t0
+ perf = m.get_perf()
+ executed = _perf_total(m)
+ x_used, x_total, f_used = _read_register_usage(m)
+
+ if completion == "error":
+ dyn = _unavailable_dynamic(
+ reason=error_msg or m.last_error or "simulation error",
+ mem_size=mem_size, timeout_s=timeout_s, input_seed=input_seed,
+ input_elements=input_elements, halt_addr=halt_addr,
+ )
+ dyn["completion"] = "error"
+ dyn["executed"] = executed
+ dyn["elapsed_s"] = elapsed
+ dyn["last_error"] = m.last_error
+ return {
+ "dynamic": dyn,
+ "output": {
+ "addr": OUTPUT_ADDR, "elements": output_elements,
+ "raw_hex": None, "q16_16": None,
+ },
+ }
+
+ ops = {key: int(perf.get(key, 0)) for key in OPS_KEYS}
+ return {
+ "dynamic": {
+ "source": "simulated",
+ "simulator": "tinyfive",
+ "simulator_version": get_environment().get("tinyfive"),
+ "completion": completion,
+ "limit": limit,
+ "executed": executed,
+ "timeout_s": float(timeout_s),
+ "elapsed_s": elapsed,
+ "memory_size_bytes": mem_size,
+ "input_seed": input_seed,
+ "input_elements": input_elements,
+ "halt_addr": halt_addr,
+ "ops": ops,
+ "x_registers_used": x_used,
+ "x_usage_total": x_total,
+ "f_registers_used": f_used,
+ "per_label": None,
+ "per_label_note": "tinyfive exe() exposes no per-PC trace",
+ "last_error": m.last_error,
+ },
+ "output": _read_output(m, layout["output_addr"], output_elements),
+ }
+
+
+def _perf_total(machine) -> int:
+ try:
+ return int(machine.get_perf().get("total", 0))
+ except Exception:
+ return int(getattr(machine, "instr_count", 0))
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Report assembly and audit
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _simulated_halted(side: dict, name: str):
+ dyn = side.get("dynamic") or {}
+ if dyn.get("source") != "simulated":
+ return f"{name}.dynamic.source!='simulated'"
+ if dyn.get("completion") != "halted":
+ return f"{name}.completion=='{dyn.get('completion')}'"
+ ops = dyn.get("ops")
+ if not isinstance(ops, dict) or ops.get("total") is None:
+ return f"{name}.dynamic.ops missing"
+ return True
+
+
+def _comparison(scratchv: dict, llvm: dict) -> dict:
+ reasons = []
+ sv_state = _simulated_halted(scratchv, "scratchv")
+ ll_state = _simulated_halted(llvm, "llvm")
+ if sv_state is not True:
+ reasons.append(sv_state)
+ if ll_state is not True:
+ reasons.append(ll_state)
+ if reasons:
+ return {
+ "dynamic_instruction_ratio": None,
+ "incomparable_reason": "; ".join(reasons),
+ }
+ sv_total = scratchv["dynamic"]["ops"]["total"]
+ ll_total = llvm["dynamic"]["ops"]["total"]
+ if ll_total <= 0:
+ return {
+ "dynamic_instruction_ratio": None,
+ "incomparable_reason": "llvm.dynamic.ops.total==0",
+ }
+ return {
+ "dynamic_instruction_ratio": round(sv_total / ll_total, 6),
+ "incomparable_reason": None,
+ }
+
+
+def build_report(model: dict, environment: dict, scratchv: dict, llvm: dict) -> dict:
+ """Assemble the schema v2 report dictionary."""
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "generator": {"script": "rv32_bench.py"},
+ "model": {key: model.get(key) for key in MODEL_FIELDS},
+ "environment": environment,
+ "targets": TARGETS,
+ "scratchv": scratchv,
+ "llvm": llvm,
+ "comparison": _comparison(scratchv, llvm),
+ "warnings": [],
+ "errors": [],
+ }
+
+
+def audit_provenance(report: dict) -> list[str]:
+ """Return the list of honesty violations; empty means the report is clean."""
+ violations: list[str] = []
+
+ if report.get("schema_version") != SCHEMA_VERSION:
+ violations.append(
+ f"schema_version must be {SCHEMA_VERSION!r}, got "
+ f"{report.get('schema_version')!r}"
+ )
+
+ model = report.get("model") or {}
+ sha = str(model.get("sha256") or "")
+ if not re.fullmatch(r"[0-9a-f]{64}", sha):
+ violations.append("model.sha256 missing or not a 64-char hex digest")
+ if not model.get("path"):
+ violations.append("model.path missing")
+
+ env = report.get("environment") or {}
+ for key in ("python", "numpy", "tinyfive", "llvmlite"):
+ if key not in env:
+ violations.append(f"environment.{key} missing")
+
+ targets = report.get("targets") or {}
+ for side in ("scratchv", "llvm"):
+ target = targets.get(side) or {}
+ if not target.get("isa"):
+ violations.append(f"targets.{side}.isa missing")
+
+ for side_name in ("scratchv", "llvm"):
+ side = report.get(side_name) or {}
+ compile_info = side.get("compile") or {}
+ if compile_info.get("status") == "success" and \
+ compile_info.get("static_source") != "asm_scan":
+ violations.append(
+ f"{side_name}.compile.static_source must be 'asm_scan'"
+ )
+ dyn = side.get("dynamic")
+ if dyn is None:
+ violations.append(f"{side_name}.dynamic missing")
+ continue
+ source = dyn.get("source")
+ if source == "simulated":
+ for key in ("simulator", "simulator_version", "executed",
+ "memory_size_bytes", "input_seed"):
+ if dyn.get(key) is None:
+ violations.append(
+ f"{side_name}.dynamic.{key} missing for simulated data"
+ )
+ if dyn.get("completion") not in ("halted", "budget_exhausted", "timeout"):
+ violations.append(
+ f"{side_name}.dynamic.completion invalid: "
+ f"{dyn.get('completion')!r}"
+ )
+ ops = dyn.get("ops")
+ if not isinstance(ops, dict):
+ violations.append(f"{side_name}.dynamic.ops missing")
+ else:
+ for key in OPS_KEYS:
+ if not isinstance(ops.get(key), int):
+ violations.append(
+ f"{side_name}.dynamic.ops.{key} must be an int"
+ )
+ if dyn.get("completion") == "halted" and \
+ dyn.get("limit") is not None and \
+ dyn.get("executed") == dyn.get("limit"):
+ violations.append(
+ f"{side_name}.dynamic: halted but executed==limit=="
+ f"{dyn.get('limit')} (unverified halt)"
+ )
+ elif source == "unavailable":
+ if not dyn.get("reason"):
+ violations.append(f"{side_name}.dynamic.reason missing")
+ if dyn.get("ops") is not None:
+ violations.append(
+ f"{side_name}.dynamic.ops must be null when unavailable"
+ )
+ else:
+ violations.append(
+ f"{side_name}.dynamic.source invalid: {source!r}"
+ )
+
+ comparison = report.get("comparison") or {}
+ ratio = comparison.get("dynamic_instruction_ratio")
+ if ratio is not None:
+ if not isinstance(ratio, (int, float)):
+ violations.append("comparison.dynamic_instruction_ratio not numeric")
+ for side_name in ("scratchv", "llvm"):
+ dyn = (report.get(side_name) or {}).get("dynamic") or {}
+ if dyn.get("source") != "simulated" or \
+ dyn.get("completion") != "halted":
+ violations.append(
+ "comparison.dynamic_instruction_ratio computed from an "
+ f"incomplete/non-simulated side ({side_name})"
+ )
+ break
+ elif not comparison.get("incomparable_reason"):
+ violations.append("comparison.incomparable_reason missing when ratio is null")
+
+ for key in ("warnings", "errors"):
+ if not isinstance(report.get(key), list):
+ violations.append(f"{key} must be a list")
+
+ return violations
# ═══════════════════════════════════════════════════════════════════════════
-# Main
+# CLI
# ═══════════════════════════════════════════════════════════════════════════
-def main():
+def build_parser():
import argparse
- p = argparse.ArgumentParser(description="RV32 Benchmark: ScratchV vs LLVM on TinyFive")
+ p = argparse.ArgumentParser(
+ description="RV32 full benchmark: ScratchV vs LLVM on TinyFive "
+ "(honest provenance, explicit budgets)"
+ )
p.add_argument("model", help="Path to ONNX model")
p.add_argument("--output-dir", default="benchmark_reports")
p.add_argument("--html", default="rv32_bench.html")
p.add_argument("--json", default="rv32_bench.json")
p.add_argument("--md", default="rv32_bench.md")
- a = p.parse_args()
-
- out = Path(a.output_dir); out.mkdir(parents=True, exist_ok=True)
- model = a.model
-
- print(f"RV32 Benchmark: {model}", file=sys.stderr)
- print(f"{'='*60}", file=sys.stderr)
-
- # 1. Compile ScratchV
- print("\n[1/4] ScratchV compilation (RV32IM, Q16.16)...", file=sys.stderr)
- sv = BenchResult(name="ScratchV", isa="RV32IM (Q16.16)")
- sv.compile = compile_scratchv(model, str(out / "_sv.bin"), str(out / "_sv.s"))
-
- # 2. Compile LLVM → RV32IMF
- print("[2/4] LLVM compilation (RV32IMF, float32)...", file=sys.stderr)
- ll = BenchResult(name="LLVM", isa="RV32IMF (float32)")
- ll.compile = compile_llvm_rv32(model, str(out / "_ll_rv32.s"))
-
- # 3. TinyFive simulation
- print("[3/4] TinyFive simulation...", file=sys.stderr)
- sv.tinyfive = run_tinyfive(str(out / "_sv.s"), n_instructions=5000)
- ll.tinyfive = run_tinyfive(str(out / "_ll_rv32.s"), n_instructions=5000) if ll.compile["status"] == "success" else {}
+ p.add_argument("--max-instructions", type=int, default=0,
+ help="Instruction budget; 0 means full simulation (default: 0)")
+ p.add_argument("--full", action="store_true",
+ help="Explicitly request a full (untruncated) simulation")
+ p.add_argument("--mem-size", type=int, default=DEFAULT_MEM_SIZE,
+ help="TinyFive memory size in bytes (default: 268435456)")
+ p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_S,
+ help="Wall-clock timeout in seconds (default: 900)")
+ p.add_argument("--chunk-instructions", type=int,
+ default=DEFAULT_CHUNK_INSTRUCTIONS,
+ help="Instructions per simulation chunk (default: 10000000)")
+ p.add_argument("--input-seed", type=int, default=DEFAULT_INPUT_SEED)
+ p.add_argument("--skip-llvm", action="store_true")
+ p.add_argument("--allow-missing-simulator", action="store_true")
+ p.add_argument("--fail-on-incomplete", action="store_true")
+ p.add_argument("--quiet", action="store_true")
+ return p
+
+
+def _unavailable_llvm(reason: str) -> dict:
+ return {
+ "compile": {
+ "status": "skipped" if reason == "--skip-llvm" else "unavailable",
+ "reason": reason,
+ "isa_detected": None,
+ "isa_mismatch": False,
+ "static_insns": 0,
+ "static_source": "asm_scan",
+ "elapsed_s": 0.0,
+ },
+ "dynamic": {
+ "source": "unavailable",
+ "simulator": "tinyfive",
+ "completion": "not_run",
+ "reason": reason,
+ "ops": None,
+ },
+ }
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ if args.full and args.max_instructions > 0:
+ parser.error(
+ "--full conflicts with --max-instructions N>0: choose one"
+ )
+ if args.max_instructions < 0:
+ parser.error("--max-instructions must be >= 0")
+ if args.chunk_instructions <= 0:
+ parser.error("--chunk-instructions must be > 0")
+
+ out = Path(args.output_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ effective_limit = 0 if args.full else args.max_instructions
+ warnings: list[str] = []
+ errors: list[str] = []
+
+ if not hasattr(signal, "SIGALRM") and effective_limit == 0 and \
+ not args.allow_missing_simulator:
+ parser.error(
+ "this platform has no SIGALRM timeout; "
+ "provide --max-instructions N or --allow-missing-simulator"
+ )
- # 4. Report
- print("[4/4] Generating report...", file=sys.stderr)
- md = generate_report(sv, ll)
- html = generate_html_report(sv, ll, md)
+ try:
+ model_info = get_model_info(args.model)
+ except Exception as exc:
+ print(f"error: model_parse_failed: {exc}", file=sys.stderr)
+ return EXIT_LAYOUT
+
+ out_bytes = model_info["output_elements"] * 4
+ min_mem = OUTPUT_ADDR + out_bytes + OUTPUT_GUARD_BYTES
+ if args.mem_size < min_mem:
+ print(
+ f"error: memory_layout_invalid: need mem_size >= {min_mem} bytes "
+ f"(192MiB output base + {out_bytes} bytes output + "
+ f"{OUTPUT_GUARD_BYTES} bytes guard); got {args.mem_size}",
+ file=sys.stderr,
+ )
+ return EXIT_LAYOUT
- with open(out / a.md, "w") as f: f.write(md)
- with open(out / a.html, "w") as f: f.write(html)
- with open(out / a.json, "w") as f:
- json.dump({"scratchv": asdict(sv), "llvm": asdict(ll)}, f, indent=2, default=str)
+ print(f"RV32 benchmark: {args.model}", file=sys.stderr)
+ print(" [1/4] ScratchV compilation (RV32IM, Q16.16)", file=sys.stderr)
+ sv_compile = compile_scratchv(
+ args.model, str(out / "_sv.bin"), str(out / "_sv.s"),
+ )
+ if sv_compile["status"] != "success":
+ print(
+ f"error: scratchv_compile_failed: "
+ f"{sv_compile.get('reason') or sv_compile.get('error')}",
+ file=sys.stderr,
+ )
+ return EXIT_LAYOUT
+
+ asm_text = (out / "_sv.s").read_text()
+ static_mix = static_instruction_mix(asm_text)
+
+ if args.skip_llvm:
+ llvm = _unavailable_llvm("--skip-llvm")
+ else:
+ print(" [2/4] LLVM compilation (RV32IMF, float32)", file=sys.stderr)
+ llvm_compile = compile_llvm_rv32(args.model, str(out / "_ll_rv32.s"))
+ llvm = {
+ "compile": llvm_compile,
+ "dynamic": {
+ "source": "unavailable",
+ "simulator": "tinyfive",
+ "completion": "not_run",
+ "reason": (
+ "llvm executable image pipeline not implemented "
+ "(topic 25 boundary)"
+ ),
+ "ops": None,
+ },
+ }
+ if llvm_compile["status"] == "skipped":
+ warnings.append(
+ f"LLVM side skipped: {llvm_compile.get('reason')}"
+ )
+ elif llvm_compile.get("isa_mismatch"):
+ warnings.append(
+ "LLVM assembly contains RV64-only mnemonics; dynamic "
+ "comparison disabled"
+ )
+
+ print(" [3/4] TinyFive simulation", file=sys.stderr)
+ try:
+ sim = run_simulation(
+ asm_path=str(out / "_sv.s"),
+ binary_path=str(out / "_sv.bin"),
+ data_offset=sv_compile["data_offset"],
+ workspace_bytes=sv_compile["workspace_bytes"],
+ input_elements=model_info["input_elements"],
+ output_elements=model_info["output_elements"],
+ max_instructions=effective_limit,
+ mem_size=args.mem_size,
+ timeout_s=args.timeout,
+ chunk_instructions=args.chunk_instructions,
+ input_seed=args.input_seed,
+ )
+ except LayoutError as exc:
+ print(f"error: memory_layout_invalid: {exc}", file=sys.stderr)
+ return EXIT_LAYOUT
+
+ dynamic = sim["dynamic"]
+ if dynamic["source"] == "unavailable" and \
+ dynamic["completion"] == "not_run":
+ if dynamic.get("reason", "").startswith("unsupported_mnemonics") or \
+ dynamic.get("reason", "").startswith("image_load_failed"):
+ print(f"error: {dynamic['reason']}", file=sys.stderr)
+ return EXIT_LAYOUT
+ if not args.allow_missing_simulator:
+ print(
+ f"error: simulator_unavailable: {dynamic['reason']} "
+ "(use --allow-missing-simulator for a static-only report)",
+ file=sys.stderr,
+ )
+ return EXIT_NO_SIMULATOR
+ warnings.append(
+ "simulator unavailable; dynamic section omitted "
+ f"({dynamic['reason']})"
+ )
+ elif dynamic.get("completion") == "budget_exhausted":
+ warnings.append(
+ f"budget exhausted at {dynamic['limit']} instructions; "
+ "dynamic counts are partial"
+ )
+ elif dynamic.get("completion") == "timeout":
+ warnings.append(
+ f"wall-clock timeout after {dynamic['elapsed_s']:.1f}s; "
+ "dynamic counts are partial"
+ )
+ elif dynamic.get("completion") == "error":
+ errors.append(dynamic.get("reason") or "simulation error")
+
+ scratchv = {
+ "compile": sv_compile,
+ "static_instruction_mix": static_mix,
+ "dynamic": dynamic,
+ "output": sim["output"],
+ }
+
+ print(" [4/4] Report", file=sys.stderr)
+ report = build_report(model_info, get_environment(), scratchv, llvm)
+ report["warnings"] = warnings
+ report["errors"] = errors
+
+ violations = audit_provenance(report)
+ if violations:
+ for violation in violations:
+ print(f"provenance_violation: {violation}", file=sys.stderr)
+ return EXIT_ERROR
+
+ markdown = bench_report.render_markdown(report)
+ (out / args.md).write_text(markdown, encoding="utf-8")
+ (out / args.html).write_text(bench_report.render_html(report), encoding="utf-8")
+ (out / args.json).write_text(bench_report.render_bench_json(report), encoding="utf-8")
+ if not args.quiet:
+ print(markdown)
+ print(
+ f"Reports: {out / args.html} | {out / args.md} | {out / args.json}",
+ file=sys.stderr,
+ )
- print(f"\nReports: {out/a.html} | {out/a.md} | {out/a.json}", file=sys.stderr)
- print(md)
+ if args.fail_on_incomplete and dynamic.get("completion") != "halted":
+ return EXIT_INCOMPLETE
+ if dynamic.get("completion") == "error":
+ return EXIT_ERROR
+ return EXIT_OK
if __name__ == "__main__":
diff --git a/tests/test_rv32_bench.py b/tests/test_rv32_bench.py
new file mode 100644
index 0000000..4614f08
--- /dev/null
+++ b/tests/test_rv32_bench.py
@@ -0,0 +1,458 @@
+"""Tests for the RV32 full benchmark driver (topic 27).
+
+Dynamic tests run only tiny synthetic models with explicit budgets; the big
+``cnn.onnx`` full simulation is deliberately never executed here.
+"""
+
+import json
+from copy import deepcopy
+
+import pytest
+
+from scratchv.standalone import bench_report, rv32_bench
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# Fixtures / helpers
+# ───────────────────────────────────────────────────────────────────────────
+
+def _build_mini_onnx(path):
+ onnx = pytest.importorskip("onnx")
+ import numpy as np
+ from onnx import TensorProto, helper, numpy_helper
+
+ rng = np.random.RandomState(0)
+ inp = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 1, 8, 8])
+ out = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 1, 6, 6])
+ weight = numpy_helper.from_array(
+ (rng.randn(1, 1, 3, 3).astype(np.float32) * 0.1), "W",
+ )
+ bias = numpy_helper.from_array(np.zeros(1, np.float32), "B")
+ node = helper.make_node(
+ "Conv", ["input", "W", "B"], ["output"],
+ kernel_shape=[3, 3], pads=[0, 0, 0, 0], strides=[1, 1],
+ )
+ graph = helper.make_graph([node], "mini", [inp], [out], [weight, bias])
+ model = helper.make_model(
+ graph, opset_imports=[helper.make_opsetid("", 13)],
+ )
+ onnx.save(model, str(path))
+
+
+@pytest.fixture(scope="module")
+def mini_model(tmp_path_factory):
+ path = tmp_path_factory.mktemp("mini_rv32") / "mini.onnx"
+ _build_mini_onnx(path)
+ return path
+
+
+@pytest.fixture(scope="module")
+def mini_compiled(mini_model, tmp_path_factory):
+ out = tmp_path_factory.mktemp("mini_build")
+ info = rv32_bench.compile_scratchv(
+ str(mini_model), str(out / "_sv.bin"), str(out / "_sv.s"),
+ )
+ assert info["status"] == "success", info
+ return {"onnx": mini_model, "out": out, "info": info}
+
+
+def _fake_model():
+ return {
+ "path": "mini.onnx", "sha256": "0" * 64, "bytes": 241,
+ "input_name": "input", "input_shape": [1, 1, 8, 8],
+ "output_name": "output", "output_shape": [1, 1, 6, 6],
+ "initializer_count": 2, "weight_bytes": 40,
+ }
+
+
+def _fake_env():
+ return {
+ "python": "3.11.0", "numpy": "2.0.0",
+ "tinyfive": "1.0.0", "llvmlite": None,
+ }
+
+
+def _fake_scratchv(*, completion="halted", limit=None, executed=100,
+ ops=None):
+ if ops is None:
+ ops = {
+ "total": executed, "load": 7, "store": 3, "mul": 5,
+ "add": 10, "madd": 0, "branch": 4,
+ }
+ return {
+ "compile": {
+ "status": "success", "binary": "_sv.bin", "binary_bytes": 680,
+ "binary_sha256": "1" * 64, "code_bytes": 640, "data_offset": 640,
+ "data_offset_source": "compiler_stdout", "data_bytes": 40,
+ "workspace_bytes": 400, "static_insns": 160,
+ "static_source": "asm_scan", "elapsed_s": 1.0,
+ },
+ "static_instruction_mix": {
+ "source": "asm_scan", "load": 7, "store": 3, "mul": 5,
+ "add": 10, "madd": 0, "branch": 4, "other": 0,
+ },
+ "dynamic": {
+ "source": "simulated", "simulator": "tinyfive",
+ "simulator_version": "1.0.0", "completion": completion,
+ "limit": limit, "executed": executed, "timeout_s": 900.0,
+ "elapsed_s": 1.0, "memory_size_bytes": 268435456,
+ "input_seed": 42, "input_elements": 64, "halt_addr": 688,
+ "ops": ops, "x_registers_used": 12, "x_usage_total": 30,
+ "f_registers_used": 0, "per_label": None,
+ "per_label_note": "tinyfive exe() exposes no per-PC trace",
+ "last_error": None,
+ },
+ "output": {
+ "addr": 201326592, "elements": 36,
+ "raw_hex": "0x" + "00" * 144, "q16_16": [0.0] * 36,
+ },
+ }
+
+
+def _unavailable_llvm(reason="llvm executable image pipeline not "
+ "implemented (topic 25 boundary)"):
+ return {
+ "compile": {
+ "status": "skipped", "reason": "llvmlite not available",
+ "isa_detected": None, "isa_mismatch": False,
+ "static_insns": 0, "static_source": "asm_scan", "elapsed_s": 0.0,
+ },
+ "dynamic": {
+ "source": "unavailable", "simulator": "tinyfive",
+ "completion": "not_run", "reason": reason, "ops": None,
+ },
+ }
+
+
+def _valid_fake_report():
+ return rv32_bench.build_report(
+ _fake_model(), _fake_env(), _fake_scratchv(), _unavailable_llvm(),
+ )
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T0: defaults / budget semantics
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_cli_defaults_to_full_simulation_no_hidden_truncation():
+ args = rv32_bench.build_parser().parse_args(["model.onnx"])
+ assert args.max_instructions == 0
+ assert args.full is False
+ assert args.mem_size == 268435456
+ assert args.chunk_instructions == 10_000_000
+
+ with pytest.raises(SystemExit) as excinfo:
+ rv32_bench.main(["model.onnx", "--full", "--max-instructions", "5"])
+ assert excinfo.value.code == rv32_bench.EXIT_USAGE
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T1: full run of a tiny model matches an independent emulator
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_full_run_small_model_matches_reference(tmp_path, mini_model,
+ mini_compiled):
+ pytest.importorskip("tinyfive")
+ out = tmp_path / "full"
+ rc = rv32_bench.main([
+ str(mini_model), "--full", "--quiet", "--output-dir", str(out),
+ "--timeout", "120", "--chunk-instructions", "4096",
+ ])
+ assert rc == rv32_bench.EXIT_OK
+
+ report = json.loads((out / "rv32_bench.json").read_text())
+ dyn = report["scratchv"]["dynamic"]
+ assert dyn["completion"] == "halted"
+ assert dyn["executed"] > 0
+ assert dyn["ops"]["total"] == dyn["executed"]
+ assert rv32_bench.audit_provenance(report) == []
+ assert bench_report.validate_report_schema(report) == []
+
+ from scratchv.standalone.benchmark import RV32EmulatorFast
+
+ model_info = rv32_bench.get_model_info(str(mini_model))
+ binary = (out / "_sv.bin").read_bytes()
+ data_offset = report["scratchv"]["compile"]["data_offset"]
+ emu = RV32EmulatorFast(mem_size_mb=256)
+ emu.load_unified_binary(binary, data_offset, load_addr=0)
+ emu.regs[2] = rv32_bench.SP_ADDR
+ emu.regs[10] = rv32_bench.INPUT_ADDR
+ emu.regs[11] = rv32_bench.OUTPUT_ADDR
+ blob = rv32_bench.build_input_q16(model_info["input_elements"], 42)
+ emu.mem[rv32_bench.INPUT_ADDR:rv32_bench.INPUT_ADDR + len(blob)] = blob
+ perf = emu.run(max_instr=2_000_000_000)
+
+ assert perf.total == dyn["executed"]
+ assert perf.load_count == dyn["ops"]["load"]
+ assert perf.store_count == dyn["ops"]["store"]
+ assert perf.branch_total == dyn["ops"]["branch"]
+
+ raw_reference = "".join(
+ f"{emu.read_mem_i32(rv32_bench.OUTPUT_ADDR + 4 * i) & 0xFFFFFFFF:08x}"
+ for i in range(model_info["output_elements"])
+ )
+ assert report["scratchv"]["output"]["raw_hex"] == "0x" + raw_reference
+
+ rerun = rv32_bench.run_simulation(
+ asm_path=str(out / "_sv.s"),
+ binary_path=str(out / "_sv.bin"),
+ data_offset=data_offset,
+ workspace_bytes=report["scratchv"]["compile"]["workspace_bytes"],
+ input_elements=model_info["input_elements"],
+ output_elements=model_info["output_elements"],
+ max_instructions=0, mem_size=268435456, timeout_s=60.0,
+ chunk_instructions=4096, input_seed=42,
+ )
+ assert rerun["dynamic"]["completion"] == "halted"
+ assert rerun["dynamic"]["executed"] == dyn["executed"]
+ assert rerun["dynamic"]["ops"] == dyn["ops"]
+ assert rerun["output"]["raw_hex"] == report["scratchv"]["output"]["raw_hex"]
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T2: budget truncation is labeled, never presented as full
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_budget_exhausted_is_labeled(tmp_path, mini_model):
+ pytest.importorskip("tinyfive")
+ out = tmp_path / "budget"
+ rc = rv32_bench.main([
+ str(mini_model), "--max-instructions", "1000", "--quiet",
+ "--output-dir", str(out), "--timeout", "120",
+ "--chunk-instructions", "4096",
+ ])
+ assert rc == rv32_bench.EXIT_OK
+
+ report = json.loads((out / "rv32_bench.json").read_text())
+ dyn = report["scratchv"]["dynamic"]
+ assert dyn["completion"] == "budget_exhausted"
+ assert dyn["limit"] == 1000
+ assert dyn["executed"] == 1000
+ assert dyn["ops"]["total"] == 1000
+ assert report["comparison"]["dynamic_instruction_ratio"] is None
+ assert "budget_exhausted" in report["comparison"]["incomparable_reason"]
+ assert rv32_bench.audit_provenance(report) == []
+ assert bench_report.validate_report_schema(report) == []
+
+ markdown = (out / "rv32_bench.md").read_text()
+ assert "[measured/budget]" in markdown
+ assert "dynamic instruction ratio" not in markdown
+
+ rc_strict = rv32_bench.main([
+ str(mini_model), "--max-instructions", "1000", "--quiet",
+ "--fail-on-incomplete", "--output-dir", str(tmp_path / "budget2"),
+ "--timeout", "120", "--chunk-instructions", "4096",
+ ])
+ assert rc_strict == rv32_bench.EXIT_INCOMPLETE
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T3: missing simulator never fabricates dynamic data
+# ───────────────────────────────────────────────────────────────────────────
+
+class _UnavailableProfiledMachine:
+ available = False
+
+ def __init__(self, mem_size=0):
+ self.mem_size = mem_size
+
+
+def test_report_requires_provenance(tmp_path, mini_model, monkeypatch):
+ monkeypatch.setattr(
+ rv32_bench, "ProfiledMachine", _UnavailableProfiledMachine,
+ )
+ out = tmp_path / "static"
+ rc = rv32_bench.main([
+ str(mini_model), "--allow-missing-simulator", "--quiet",
+ "--output-dir", str(out),
+ ])
+ assert rc == rv32_bench.EXIT_OK
+
+ report = json.loads((out / "rv32_bench.json").read_text())
+ dyn = report["scratchv"]["dynamic"]
+ assert dyn["source"] == "unavailable"
+ assert dyn["ops"] is None
+ assert dyn["completion"] == "not_run"
+ assert dyn["reason"]
+ assert report["scratchv"]["compile"]["static_insns"] > 0
+ assert report["scratchv"]["compile"]["static_source"] == "asm_scan"
+ assert report["comparison"]["dynamic_instruction_ratio"] is None
+ assert rv32_bench.audit_provenance(report) == []
+ assert bench_report.validate_report_schema(report) == []
+
+ markdown = (out / "rv32_bench.md").read_text()
+ assert "[static]" in markdown
+ assert "[unavailable]" in markdown
+
+ rc_no_flag = rv32_bench.main([
+ str(mini_model), "--quiet", "--output-dir", str(tmp_path / "static2"),
+ ])
+ assert rc_no_flag == rv32_bench.EXIT_NO_SIMULATOR
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T4: memory layout validation refuses impossible budgets
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_memory_layout_validation(tmp_path, mini_model, capsys):
+ out = tmp_path / "memfail"
+ rc = rv32_bench.main([
+ str(mini_model), "--mem-size", "1048576", "--output-dir", str(out),
+ ])
+ assert rc == rv32_bench.EXIT_LAYOUT
+ stderr = capsys.readouterr().err
+ assert "memory_layout_invalid" in stderr
+ assert "need mem_size >=" in stderr
+ assert not (out / "rv32_bench.json").exists()
+ assert not (out / "rv32_bench.md").exists()
+ assert not (out / "rv32_bench.html").exists()
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T5: labels cover all branch targets; size cross-check is strict
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_parsed_labels_cover_all_branches(mini_compiled):
+ asm = (mini_compiled["out"] / "_sv.s").read_text()
+ data_offset = mini_compiled["info"]["data_offset"]
+ labels = rv32_bench.parse_labels(asm, data_offset)
+
+ assert labels.get(0) == "_start"
+ assert "_done" in labels.values()
+ assert mini_compiled["info"]["static_insns"] == data_offset // 4
+
+ for _pc, op, operands in rv32_bench._iter_asm_lines(asm):
+ if op in ("beq", "bne", "blt", "bge", "bltu", "bgeu", "j", "jal"):
+ assert operands[-1] in labels or operands[-1].lstrip("+-").isdigit()
+
+ with pytest.raises(rv32_bench.LabelParseError):
+ rv32_bench.parse_labels(
+ "_start:\n bne t0, zero, +8\n addi t0, t0, 1\n", 8,
+ )
+ with pytest.raises(rv32_bench.LabelParseError):
+ rv32_bench.parse_labels("_start:\n addi t0, t0, 1\n", 8)
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T6: LLVM RV64 output is flagged as incomparable
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_llvm_riscv64_flagged_isa_mismatch():
+ asm = (
+ " ld a0, 0(a1)\n"
+ " sd a0, 8(a1)\n"
+ " addiw a0, a0, 1\n"
+ " ret\n"
+ )
+ assert set(rv32_bench.detect_isa_mismatch(asm)) == {"ld", "sd", "addiw"}
+
+ llvm = {
+ "compile": {
+ "status": "success",
+ "reason": "rv64 mnemonics detected: addiw, ld, sd",
+ "isa_detected": "riscv64", "isa_mismatch": True,
+ "static_insns": 3, "static_source": "asm_scan", "elapsed_s": 0.0,
+ },
+ "dynamic": {
+ "source": "unavailable", "simulator": "tinyfive",
+ "completion": "not_run",
+ "reason": "isa mismatch: rv64 mnemonics detected",
+ "ops": None,
+ },
+ }
+ report = rv32_bench.build_report(
+ _fake_model(), _fake_env(), _fake_scratchv(), llvm,
+ )
+ assert report["llvm"]["compile"]["isa_detected"] == "riscv64"
+ assert report["llvm"]["compile"]["isa_mismatch"] is True
+ assert report["llvm"]["dynamic"]["source"] == "unavailable"
+ assert report["comparison"]["dynamic_instruction_ratio"] is None
+ assert "llvm" in report["comparison"]["incomparable_reason"]
+ markdown = bench_report.render_markdown(report)
+ assert "RV32IMF" not in markdown
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T7: the provenance audit rejects static counts masquerading as dynamic
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_audit_provenance_rejects_static_fallback():
+ clean = _valid_fake_report()
+ assert rv32_bench.audit_provenance(clean) == []
+
+ static_masquerade = deepcopy(clean)
+ static_masquerade["scratchv"]["dynamic"] = {
+ "source": "simulated",
+ "completion": "halted",
+ "ops": {"total": 3841},
+ }
+ violations = rv32_bench.audit_provenance(static_masquerade)
+ assert violations
+ assert any("simulator" in v for v in violations)
+ assert any("executed" in v for v in violations)
+
+ ratio_from_truncated = deepcopy(clean)
+ for side in ("scratchv", "llvm"):
+ ratio_from_truncated[side]["dynamic"] = {
+ "source": "simulated", "simulator": "tinyfive",
+ "simulator_version": "1.0.0", "completion": "budget_exhausted",
+ "limit": 1000, "executed": 1000, "memory_size_bytes": 268435456,
+ "input_seed": 42,
+ "ops": {"total": 1000, "load": 0, "store": 0, "mul": 0,
+ "add": 0, "madd": 0, "branch": 0},
+ }
+ ratio_from_truncated["comparison"] = {
+ "dynamic_instruction_ratio": 0.31, "incomparable_reason": None,
+ }
+ assert rv32_bench.audit_provenance(ratio_from_truncated)
+
+ forged_halt = deepcopy(clean)
+ forged_halt["scratchv"]["dynamic"].update(
+ {"completion": "halted", "limit": 5000, "executed": 5000, "ops": {
+ "total": 5000, "load": 0, "store": 0, "mul": 0,
+ "add": 0, "madd": 0, "branch": 0,
+ }},
+ )
+ assert any(
+ "unverified halt" in v
+ for v in rv32_bench.audit_provenance(forged_halt)
+ )
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T8: schema validation covers every required provenance key
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_bench_report_schema_required_keys():
+ report = _valid_fake_report()
+ assert bench_report.validate_report_schema(report) == []
+
+ empty_errors = bench_report.validate_report_schema({})
+ joined = "\n".join(empty_errors)
+ for key in (
+ "schema_version", "model.sha256", "environment", "targets",
+ "scratchv.compile", "scratchv.dynamic", "llvm.compile",
+ "llvm.dynamic", "comparison",
+ ):
+ assert key in joined, f"{key} missing from {empty_errors}"
+
+ no_static_source = deepcopy(report)
+ del no_static_source["scratchv"]["compile"]["static_source"]
+ assert any(
+ "scratchv.compile.static_source" in e
+ for e in bench_report.validate_report_schema(no_static_source)
+ )
+
+ no_sha = deepcopy(report)
+ del no_sha["model"]["sha256"]
+ assert any(
+ "model.sha256" in e
+ for e in bench_report.validate_report_schema(no_sha)
+ )
+
+ bad_ratio = deepcopy(report)
+ bad_ratio["comparison"] = {"dynamic_instruction_ratio": 1.23}
+ assert any(
+ "comparison.incomparable_reason" in e
+ for e in bench_report.validate_report_schema(bad_ratio)
+ )
From ef1ee278ab230fc951d8ef44fec95c4846deea32 Mon Sep 17 00:00:00 2001
From: Seven Gao <799889633@qq.com>
Date: Mon, 14 Sep 2026 21:21:51 +0800
Subject: [PATCH 2/5] docs(topic27): add design and development documents
---
...00\345\217\221\346\226\207\346\241\243.md" | 447 +++++++++++++++
...76\350\256\241\346\226\207\346\241\243.md" | 514 ++++++++++++++++++
2 files changed, 961 insertions(+)
create mode 100644 "docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\345\274\200\345\217\221\346\226\207\346\241\243.md"
create mode 100644 "docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\350\256\276\350\256\241\346\226\207\346\241\243.md"
diff --git "a/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\345\274\200\345\217\221\346\226\207\346\241\243.md"
new file mode 100644
index 0000000..35f2557
--- /dev/null
+++ "b/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\345\274\200\345\217\221\346\226\207\346\241\243.md"
@@ -0,0 +1,447 @@
+# 课题 27:RV32 全量 Benchmark 开发文档
+
+> 文档版本:v1.0
+> 编写日期:2026-09-14
+> 配套设计:同目录《设计文档.md》(术语、全量定义、诚实报告规范以设计文档为准)
+> 涉及文件:`scratchv/standalone/rv32_bench.py`、`scratchv/standalone/bench_report.py`、`tests/test_rv32_bench.py`(新增)
+> 只读依赖:`scratchv/simulator/tinyfive.py`、`scratchv/standalone/benchmark.py`、`scratchv/standalone/onnx_to_riscv_standalone.py`、`scratchv/backend/riscv_encoder.py`
+
+---
+
+## 一、目标与范围
+
+本课题把 `rv32_bench.py` 从“5000 条指令 + 64KB 内存 + 静态计数兜底”改造成“全量或明确预算 + 真实装载 + 诚实报告”的 RV32 统一基准驱动,并让 `bench_report.py` 按 schema v2 渲染带 provenance 的报告。
+
+范围边界(与设计文档 4.9 一致):
+
+- **不改** `onnx_to_riscv_standalone.py` 代码生成/ABI/内存布局(只读 stdout 与 `.bin/.s`)。
+- **不改** TinyFive 适配器公共接口(只使用既有 `load_binary/load_data/run(strict=True)/pc/get_perf`)。
+- **不改** Spike(课题 24)、基准套件(课题 06)、`cache_model.py`(课题 23)、`llvm_cache_compare.py`(课题 25)。
+- **LLVM 口径**:本课题只做“ISA 标注 + 统一 RV32 请求 + 跨 ISA 检测拒绝”;LLVM 侧可执行镜像(汇编→链接→ABI→数据段)与实测对比属课题 25/27 交界,本次以 `unavailable` 如实标注,不用解析估算补位。
+
+---
+
+## 二、`rv32_bench.py` 改动清单
+
+### 2.1 改动总表
+
+| # | 现行位置 | 改动 | 目的 |
+|---|----------|------|------|
+| C1 | `:118` `run_tinyfive(..., n_instructions: int = 10000)` | 替换为 `run_simulation(...)`,`max_instructions` 默认 `0`(全量语义) | 去除默认截断 |
+| C2 | `:410-412` 调用点 `n_instructions=5000` | 删除该实参,改为 CLI `--max-instructions/--full` 驱动 | 同上 |
+| C3 | `:124` `ProfiledMachine(mem_size=65536)` | 改为 `ProfiledMachine(mem_size=args.mem_size)`(默认 256MiB)+ `compute_layout()` 校验 | 内存容量正确 |
+| C4 | 新增 | `parse_data_offset()` / `parse_workspace_bytes()` 解析编译器 stdout(`:2738,:2794`) | 拿到 `data_offset/workspace_size` |
+| C5 | 新增 | `load_scratchv_image()`:`load_binary(code_words, 0)` + `load_data(weights, data_offset)` + `load_data(input_blob, 160MiB)` | 权重与输入真实装载 |
+| C6 | `:160-184` `_prepare_asm_for_tinyfive` | 删除;新增 `parse_labels()`,标签不再被过滤,`.s` 只用于标签映射与静态计数,不用于装载代码 | 修复分支自跳转 |
+| C7 | `:187-234` `_tinyfive_static_fallback` | 删除;静态计数改由 `static_instruction_mix()` 产出,字段独立为 `static_instruction_mix` | 静态数不冒充动态数 |
+| C8 | `:118-157` `run_tinyfive` | 替换为分块执行器:`chunk` 循环 + `m.pc == halt_addr` 停机检测 + `SIGALRM` 超时 + 完整 provenance 输出 | 停机/预算/超时可控 |
+| C9 | `:58-104` `compile_llvm_rv32` | llvmlite 可用时 `llmod.triple="riscv32-unknown-elf"`;新增 `detect_isa_mismatch()`;静态计数只扫 `.text`;失败原因入档 | 统一 RV32 口径与诚实降级 |
+| C10 | `:241-312` `BenchResult` / `generate_report` | 改为 `build_report()`(schema v2 dict)+ `bench_report.render_*(report)`;删除硬编码模型描述、无依据 ratio 与 “No analytical estimates” 文案 | 诚实报告 |
+| C11 | `:383-425` `main` | 新增 CLI 参数、退出码、`audit_provenance` 写盘前检查、`--quiet` | 接口规格化 |
+| C12 | `bench_report.py` 全文 | 新增 `render_markdown/render_html/render_github_summary/render_bench_json/validate_report_schema`;旧 `generate_*` 保留兼容 | 报告字段与模板 |
+
+### 2.2 去默认截断 / 改可配(C1、C2、C11)
+
+现行代码:
+
+```python
+def run_tinyfive(asm_path: str, n_instructions: int = 10000) -> dict: # :118
+...
+sv.tinyfive = run_tinyfive(str(out / "_sv.s"), n_instructions=5000) # :411
+```
+
+改为:
+
+```python
+def run_simulation(
+ *,
+ asm_path: str,
+ binary_path: str,
+ max_instructions: int = 0, # 0 = 全量
+ mem_size: int = 268435456,
+ timeout_s: float = 900.0,
+ chunk_instructions: int = 10_000_000,
+ input_seed: int = 42,
+ input_elements: int,
+ output_elements: int,
+ halt_addr: int,
+) -> dict: ...
+```
+
+调用点:
+
+```python
+sv.dynamic = run_simulation(..., max_instructions=args.max_instructions, ...)
+```
+
+CLI 语义:`--max-instructions 0`(默认)与 `--full` 等价;两者与 `--max-instructions N>0` 冲突时 `parser.error(...)` → exit 2。`--full` 存在的意义是抵抗“上游/CI 默认值”污染,显式声明无截断。
+
+### 2.3 内存与数据装载(C3、C4、C5)
+
+1. **stdout 解析**(在 `compile_scratchv` 内,`rc==0` 时):
+ ```python
+ DATA_OFFSET_RE = re.compile(r"Data offset:\s*0x([0-9A-Fa-f]+)")
+ WORKSPACE_RE = re.compile(r"Workspace:\s*([\d,]+)\s+bytes")
+ CODE_SIZE_RE = re.compile(r"Code size:\s*([\d,]+)\s+bytes")
+ ```
+ 三个正则分别对应 `onnx_to_riscv_standalone.py:2794`、`:2738`、`:2729`。`compile.scratchv` 返回:
+ `{status, binary, binary_bytes, binary_sha256, code_bytes, data_offset, data_offset_source:"compiler_stdout", data_bytes, workspace_bytes, static_insns, static_source:"asm_scan", elapsed_s}`。
+ 任一正则失配 → `status="failed"`, `error="binary_layout_unparsed"` → main exit 4(未显式给预算时);禁止“猜 `data_offset = len(code_bytes)`”。
+2. **镜像装载**:
+ ```python
+ binary = Path(binary_path).read_bytes()
+ assert data_offset % 4 == 0 and 0 < data_offset < len(binary)
+ code_words = [int.from_bytes(binary[i:i+4], "little")
+ for i in range(0, data_offset, 4)]
+ weights = binary[data_offset:]
+ m = ProfiledMachine(mem_size=mem_size)
+ m.load_binary(code_words, origin=0)
+ m.load_data(weights, data_offset)
+ ```
+3. **输入构造**(一次性):`random.Random(input_seed)`,每个元素 `int((r.random() - 0.5) * 0.2 * 65536)`,`struct.pack(f"<{n}i", *vals)`,`m.load_data(blob, 160*1024*1024)`。禁止逐元素 `write_mem_i32`(慢且易错)。
+4. **寄存器初始化**:`m.set_reg(2, 128*1024*1024)`、`m.set_reg(10, 160*1024*1024)`、`m.set_reg(11, 192*1024*1024)`、`m.set_reg(1, halt_addr)`;`gp` 由代码自身 `_start` 的 AUIPC 补丁设置,harness 不写。
+5. **布局校验**(`compute_layout()`,设计文档 2.1.2 公式;失败抛 `LayoutError` → exit 4,消息含所需最小字节数)。
+6. `halt_addr = align_up(binary_bytes, 16)`;要求 `halt_addr + 4 <= mem_size`。
+
+### 2.4 标签解析(C6)
+
+`.s` 格式:标签独占一行(列 0,`name:`),指令行缩进两格并可能带 `# 注释`(`RISCVEmitter.disassemble()`,`onnx_to_riscv_standalone.py:1291-1305`)。
+
+```python
+def parse_labels(asm_text: str) -> dict[int, str]:
+ pc, labels, seen_tokens = 0, {}, []
+ for raw in asm_text.splitlines():
+ line = raw.split("#", 1)[0].rstrip()
+ if not line.strip():
+ continue
+ if line.endswith(":") and "(" not in line:
+ labels[pc] = line.strip()[:-1]
+ continue
+ if line.strip().startswith("."): # 理论不存在;防御性跳过
+ continue
+ pc += 4
+ seen_tokens.append(line.strip())
+ if pc != expected_code_bytes: # 与 data_offset 交叉校验
+ raise LabelParseError(f"asm/code-size mismatch: {pc} != {expected_code_bytes}")
+ return labels
+```
+
+关键点:
+
+- **保留标签**(现行 `:174-176` 丢弃标签,`label_map` 未用,导致 `riscv_encoder.py:502` 的 `labels.get(label, current_idx)` 把分支解析为自跳转)。
+- 代码装载走 `load_binary`(编译器自己的二进制),不再把 `.s` 喂给 `assemble_to_binary`;`.s` 仅作标签映射与静态 scan。
+- `_start` 必须映射 `pc=0`;`_done` 必须存在;`static_insns == data_offset // 4` 作为不变量断言。
+
+### 2.5 失败路径(C7、C11)
+
+| 失败 | 检测点 | 行为 |
+|------|--------|------|
+| TinyFive 未安装 | `m.available is False` | 无 `--allow-missing-simulator` → exit 3;有 → `dynamic.source="unavailable"`、`ops=null`、`completion="not_run"`,报告只含 static |
+| 内存布局不满足 | `compute_layout()` | exit 4,`errors=["memory_layout_invalid: need >= N bytes"]`,不落盘动态报告 |
+| `data_offset` 解析失败 | `compile_scratchv` stdout 正则 | exit 4,`reason="binary_layout_unparsed"` |
+| 标签/代码尺寸不一致 | `parse_labels` | 抛 `LabelParseError` → exit 4 |
+| 助记符预检失败 | 子集扫描 `.s` vs TinyFive 支持表 | `completion="not_run"`,exit 4,列出不支持助记符 |
+| 预算耗尽 | 循环额度判断 | `completion="budget_exhausted"`,`executed==limit`,ratio `null`,exit 0(显式预算属正常) |
+| 超时 | `SIGALRM` + `_timed_out` 标志 | `completion="timeout"`,保留部分 ops,exit 0(`--fail-on-incomplete` 时 exit 7) |
+| `m.last_error` 非空且非超时 | 适配器 `strict=True` 抛错 | `completion="error"`,`dynamic=null`,exit 1 |
+| 未完成且要求严格 | `--fail-on-incomplete` | exit 7 |
+
+静态兜底已删除:`static_instruction_mix` 只写入 `scratchv.static_instruction_mix`,字段名带 `static_`,永不出现在 `dynamic.ops`。
+
+### 2.6 LLVM 侧改动(C9)
+
+1. `binding.Target.from_triple("riscv32-unknown-elf")`;`ImportError`/`RuntimeError` → `status="skipped"`,`reason` 记录原始异常。llvmlite 当前环境未安装,该分支即 `skipped`。
+2. 成功路径。解析 IR 文本后,用 llvmlite API 覆盖模块头:
+ ```python
+ llmod = binding.parse_assembly(ir_text)
+ llmod.triple = "riscv32-unknown-elf" # 覆盖 onnx_to_llvm_standalone.py:446 的 riscv64 头
+ llmod.data_layout = str(tm.target_data) # 与目标机一致,避免指针宽度假设漂移
+ llmod.verify()
+ asm = tm.emit_assembly(llmod)
+ ```
+3. `detect_isa_mismatch(asm_text) -> list[str]`:扫描助记符集合 `{ld, sd, lwu, addw, subw, addiw, sllw, srlw, sraw, slliw, srliw, sraiw, mulw, divw, divuw, remw, remuw, fld, fsd, fcvt.l.s, fcvt.s.l}`;命中 → `isa_detected="riscv64"`、`isa_mismatch=true`,`llvm.dynamic` 不运行。
+4. 静态计数:遇到 `.section .rodata`(权重数组,`onnx_to_llvm_standalone.py:132-157` 的 `private constant [N x float]`)停止计数;`.word/.long/.byte/.float` 不算指令。`static_source="asm_scan"`。
+5. 不构造 LLVM 侧可执行镜像;`llvm.dynamic={source:"unavailable", completion:"not_run", reason:"llvm executable image pipeline not implemented (topic 25 boundary)", ops:null}`。
+
+---
+
+## 三、`bench_report.py` 报告字段与模板
+
+### 3.1 新增函数
+
+| 函数 | 签名 | 说明 |
+|------|------|------|
+| `render_markdown` | `(report: dict) -> str` | schema v2 → Markdown |
+| `render_html` | `(report: dict) -> str` | Markdown 内容包进既有 CSS 壳 |
+| `render_bench_json` | `(report: dict) -> str` | 规范化 JSON(`sort_keys=False, indent=2`) |
+| `render_github_summary` | `(report: dict) -> str` | CI 摘要,只引用 measured/static 分区 |
+| `validate_report_schema` | `(report: dict) -> list[str]` | 返回缺失/类型错误列表;空列表为通过 |
+
+旧 `generate_html_report/generate_json_report/generate_github_summary` 保留为兼容壳(内部转调 `render_*` 或维持原行为),避免影响 `onnx_to_riscv_standalone.py --report` 与 CI(课题 06/30)。
+
+### 3.2 Markdown 模板(骨架)
+
+```markdown
+# RV32 Benchmark Report — {model.path}
+- Generated: {generated_at} | schema: rv32-bench/2
+- Model: sha256={model.sha256[:12]} | bytes={model.bytes} | input={model.input_shape}
+- Targets: ScratchV {targets.scratchv.isa}/{targets.scratchv.numeric_format}
+ LLVM {targets.llvm.isa}/{targets.llvm.numeric_format} (opt={targets.llvm.opt_level})
+- Environment: python={environment.python} numpy={environment.numpy}
+ tinyfive={environment.tinyfive} llvmlite={environment.llvmlite}
+
+## 1. Compilation [static]
+| Metric | ScratchV | LLVM |
+| status | … | … |
+| code bytes | … | … |
+| static insns [asm_scan] | … | … |
+
+## 2. Dynamic Execution [measured]
+| Metric | ScratchV | LLVM |
+| completion | halted | not_run |
+| executed | … | — |
+| ops.total | … | — |
+| load/store/mul/add/madd/branch | … | — |
+> LLVM dynamic unavailable: {llvm.dynamic.reason}
+
+## 3. Comparison [measured]
+- dynamic_instruction_ratio: **null** — {comparison.incomparable_reason}
+(仅当两侧 source=="simulated" 且 completion=="halted" 时打印比值行,否则打印 null + 原因)
+
+## 4. Analytical Warnings [estimated]
+- {warnings[*]}
+
+## Provenance
+Simulated by tinyfive {environment.tinyfive} | completion={completion} | executed={executed}
+| limit={limit} | memory={memory_size_bytes} | seed={input_seed} | halt=0x{halt_addr:x}
+| model sha256={model.sha256} | binary sha256={scratchv.compile.binary_sha256}
+```
+
+渲染规则:
+
+- 每个表标题必带 `[measured]/[static]/[estimated]/[unavailable]` 之一;同一表格中不得混用来源类别。
+- 页脚逐字取 provenance 字段,禁止出现“all metrics from simulation / no analytical estimates”之类无法由字段证明的断言。
+- 模型名与层结构只能来自 `model.*`;删除现行 `rv32_bench.py:268` 的硬编码描述。
+- `ops` 任一计数为 0 时照实显示 `0`,不得显示 `—` 掩盖“未跑到该类别”。
+
+### 3.3 报告字段总表(dotted path)
+
+| 字段 | 类型 | 来源分类 | 说明 |
+|------|------|----------|------|
+| `schema_version` | str | — | 固定 `"rv32-bench/2"` |
+| `generated_at` | str | — | UTC ISO8601 |
+| `generator.script` | str | — | `"rv32_bench.py"` |
+| `model.path/sha256/bytes/input_name/input_shape/output_name/output_shape/initializer_count/weight_bytes` | — | static | 模型身份 |
+| `environment.python/numpy/tinyfive/llvmlite` | str\|null | — | 依赖版本 |
+| `targets.scratchv.{isa,abi,numeric_format}` | str | — | `rv32im/ilp32/q16.16` |
+| `targets.llvm.{isa,abi,numeric_format,triple,opt_level}` | — | — | `rv32imf/ilp32/float32` |
+| `scratchv.compile.status/binary/binary_bytes/binary_sha256/code_bytes/data_offset/data_offset_source/data_bytes/workspace_bytes/static_insns/static_source/elapsed_s` | — | static | 编译与镜像 |
+| `scratchv.static_instruction_mix.{source,load,store,mul,add,madd,branch,other}` | int | static | 静态助记符分布 |
+| `scratchv.dynamic.{source,simulator,simulator_version,completion,limit,executed,timeout_s,elapsed_s,memory_size_bytes,input_seed,input_elements,halt_addr,ops{total,load,store,mul,add,madd,branch},x_registers_used,x_usage_total,f_registers_used,per_label,per_label_note,last_error}` | — | measured | 动态执行 |
+| `scratchv.output.{addr,elements,raw_hex,q16_16}` | — | measured | 输出读出 |
+| `llvm.compile.{status,reason,isa_detected,isa_mismatch,static_insns,static_source,elapsed_s}` | — | static/unavailable | LLVM 侧 |
+| `llvm.dynamic.{source,simulator,completion,reason,ops}` | — | unavailable | 失败必须 `ops=null` |
+| `comparison.{dynamic_instruction_ratio,incomparable_reason}` | float\|null | measured 派生 | 比值规则见设计文档 2.1.1 |
+| `warnings[]` / `errors[]` | list[str] | — | 预估告警与错误 |
+
+---
+
+## 四、与 `benchmark.py` / `ProfiledMachine` 的接口
+
+### 4.1 `ProfiledMachine`(`scratchv/simulator/tinyfive.py`,只读使用)
+
+| 用法 | 契约 |
+|------|------|
+| `ProfiledMachine(mem_size)` | `mem_size` 为字节数;构造即 `np.zeros(mem_size, uint8)`;256MiB 默认值可接受(本机 numpy 1.24.4) |
+| `.available` | `False` 时只能走 `unavailable` 路径,不得继续 `load_*` |
+| `load_binary(words, origin=0)` | `words: list[int]` 32 位词;越界抛 `ValueError` |
+| `load_data(data: bytes, addr)` | `np.frombuffer` 切片赋值;用于权重(26MB 一次性)与输入 |
+| `run(instructions=N, strict=True)` | 适配器内部 `finally` 更新 `instr_count`;异常包装为 `RuntimeError(last_error)` 继续抛出,ops 保留 |
+| `.pc` / `.get_perf()` | `get_perf()` 返回 `{total,load,store,mul,add,madd,branch}` 累计值;分块循环用 `pc` 判停机 |
+| `.set_reg(idx, value)` | `0 < idx < 32`;本课题写 x1/x2/x10/x11 |
+| `.read_mem_i32(addr)` | 输出读取;不越界返回 0 |
+
+不使用:`load_asm`(标签语义不可靠)、`print_perf`、`StubProfiledMachine`。
+
+### 4.2 `benchmark.py`(只读使用)
+
+- `estimate_cnn_model(model_spec=None) -> dict`:仅用于 `warnings` 与“预计墙钟”提示;字段进入 `estimated` 分类,前缀 `estimated_`。不得写入 `dynamic`。
+- `RV32EmulatorFast` / `run_benchmark`:仅测试用例 1 作为独立功能对照(`load_unified_binary(binary, code_size_base=..., load_addr=0)` + `run(max_instr=...)`);其计数分类(`Cat_*`)与 TinyFive 不同,只对照 `total/load_count/store_count/branch_total`。若未来作为正式数据源,须以 `simulator="rv32_emulator_fast"` 独立字段呈现,不与 TinyFive 混算。
+- `estimate_cnn_instructions` 的 per-MAC 常量(`CONV_INSNS_PER_MAC=8` 等)属解析模型,禁止用于 `measured`。
+
+### 4.3 `cache_model.py`(不在运行时链路)
+
+`CacheSim`/`create_cache_pair` 为分析模型;本课题报告不包含 cache 指标。若后续引入,字段必须放 `estimated` 分类并标注 `model="cache_model"`。
+
+---
+
+## 五、接口契约
+
+### 5.1 CLI(精确名称)
+
+```
+python scratchv/standalone/rv32_bench.py MODEL
+ [--output-dir DIR] # 默认 benchmark_reports
+ [--html FILE] [--json FILE] [--md FILE] # 默认 rv32_bench.{html,json,md}
+ [--max-instructions N] # int,默认 0;0=全量,>0=预算
+ [--full] # 无截断;与 N>0 互斥
+ [--mem-size BYTES] # int,默认 268435456
+ [--timeout SECONDS] # float,默认 900.0
+ [--chunk-instructions N] # int,默认 10000000
+ [--input-seed N] # int,默认 42
+ [--skip-llvm] # flag
+ [--allow-missing-simulator] # flag
+ [--fail-on-incomplete] # flag
+ [--quiet] # flag
+```
+
+退出码:`0` 成功;`1` 未预期错误/审计失败;`2` 参数冲突;`3` 仿真器不可用;`4` 布局/镜像/预检失败;`7` 未完成且 `--fail-on-incomplete`。
+
+### 5.2 函数(精确签名)
+
+```python
+# rv32_bench.py
+SCHEMA_VERSION = "rv32-bench/2"
+EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_NO_SIMULATOR, EXIT_LAYOUT, EXIT_INCOMPLETE = 0, 1, 2, 3, 4, 7
+
+class LayoutError(RuntimeError): ...
+class LabelParseError(RuntimeError): ...
+class SimulationTimeout(RuntimeError): ...
+
+def sha256_file(path: str | Path) -> str
+def parse_data_offset(stdout: str) -> int | None
+def parse_workspace_bytes(stdout: str) -> int | None
+def parse_labels(asm_text: str, expected_code_bytes: int) -> dict[int, str]
+def static_instruction_mix(asm_text: str) -> dict # 键: source,load,store,mul,add,madd,branch,other
+def compute_layout(*, data_offset: int, data_size: int, workspace_bytes: int,
+ input_elements: int, output_elements: int,
+ mem_size: int) -> dict # 键: sp,input_addr,output_addr,halt_addr,mem_size
+def load_scratchv_image(binary_path: str, data_offset: int) -> tuple[list[int], bytes]
+def build_input_q16(elements: int, seed: int) -> bytes
+def run_simulation(*, asm_path: str, binary_path: str, data_offset: int,
+ workspace_bytes: int, input_elements: int, output_elements: int,
+ max_instructions: int = 0, mem_size: int = 268435456,
+ timeout_s: float = 900.0, chunk_instructions: int = 10_000_000,
+ input_seed: int = 42) -> dict # -> scratchv.dynamic + output
+def compile_scratchv(onnx_path: str, output_bin: str, output_asm: str,
+ timeout_s: float = 120.0) -> dict
+def compile_llvm_rv32(onnx_path: str, output_asm: str, *,
+ triple: str = "riscv32-unknown-elf", opt_level: int = 2) -> dict
+def detect_isa_mismatch(asm_text: str) -> list[str]
+def build_report(model: dict, environment: dict, scratchv: dict, llvm: dict) -> dict
+def audit_provenance(report: dict) -> list[str]
+def main(argv: list[str] | None = None) -> int
+
+# bench_report.py
+def render_markdown(report: dict) -> str
+def render_html(report: dict) -> str
+def render_bench_json(report: dict) -> str
+def render_github_summary(report: dict) -> str
+def validate_report_schema(report: dict) -> list[str]
+```
+
+### 5.3 报告字段(精确名称)
+
+一级键:`schema_version, generated_at, generator, model, environment, targets, scratchv, llvm, comparison, warnings, errors`。
+
+二级/三级键以设计文档 5.1 与本文 3.3 为准,关键枚举:
+
+- `scratchv.dynamic.source ∈ {"simulated","unavailable"}`
+- `scratchv.dynamic.completion ∈ {"halted","budget_exhausted","timeout","error","not_run"}`
+- `llvm.dynamic.source ∈ {"simulated","unavailable"}`
+- `comparison.dynamic_instruction_ratio: float | null`(仅两侧 `simulated`+`halted`)
+- `*.static_source == "asm_scan"`
+
+---
+
+## 六、测试文件与用例
+
+测试文件:`tests/test_rv32_bench.py`(新增)。
+
+| 用例 | 名称 | 要点 |
+|------|------|------|
+| T1 | `test_full_run_small_model_matches_reference` | `onnx.helper` 造迷你 CNN → 全量 `halted`;与 `RV32EmulatorFast` 对照 `total/load/store/branch` 精确相等;重跑确定性 |
+| T2 | `test_budget_exhausted_is_labeled` | `--max-instructions 1000` → `budget_exhausted`、`executed==limit==1000`、ratio `null`、`--fail-on-incomplete` 退出 7 |
+| T3 | `test_report_requires_provenance` | monkeypatch TinyFive 不可用 + `--allow-missing-simulator` → `dynamic.source=="unavailable"`、`ops is null`、static 分区存在;无 flag 时退出 3 |
+| T4 | `test_memory_layout_validation` | `--mem-size 1048576` → exit 4,stderr 含 `memory_layout_invalid`,不落动态报告 |
+| T5 | `test_parsed_labels_cover_all_branches` | 分支/跳转目标全部有标签;`static_insns == data_offset//4`;缺标签抛 `LabelParseError` |
+| T6 | `test_llvm_riscv64_flagged_isa_mismatch` | 含 `ld/sd/addiw` 的 fixture → `isa_mismatch=true`、ratio `null` |
+| T7 | `test_audit_provenance_rejects_static_fallback` | 构造非法报告(设计文档 5.3 两例)→ `audit_provenance()` 非空 |
+| T8 | `test_bench_report_schema_required_keys` | `validate_report_schema()` 对必填键逐一断言(无需 TinyFive,不得 skip) |
+
+运行:
+
+```bash
+python -m pytest tests/test_rv32_bench.py -q
+make test
+```
+
+无 TinyFive 环境下 T1/T2 走 `pytest.importorskip("tinyfive")`;T3–T8 必须运行。
+
+---
+
+## 七、验收标准
+
+1. 全量路径:`python scratchv/standalone/rv32_bench.py models/graph/cnn.onnx --full --mem-size 268435456 --timeout 1800 --output-dir /tmp/rv32_full`
+ - 成功产出 3 格式报告;`scratchv.dynamic.completion ∈ {"halted","timeout"}`;
+ - 若 `halted`,`executed` 为全量真实数(不受 5000/10000 限制);若 `timeout`,报告明示部分轨迹且 `comparison=null`;
+ - `audit_provenance(report) == []`;`validate_report_schema(report) == []`。
+2. 预算路径:`--max-instructions 1000000` → `completion=="budget_exhausted"`、`limit==executed==1000000`、ratio `null`;报告含 `[measured/budget]` 标识。
+3. 任何输出(Markdown/HTML/JSON)包含:`model.sha256`、`environment.tinyfive`、`input_seed`、`memory_size_bytes`、`completion`、`static_source`;不再出现 “All metrics sourced from TinyFive … No analytical estimates” 这类无字段支撑的断言。
+4. LLVM 侧:无 llvmlite → `status=="skipped"`、`dynamic.source=="unavailable"`、报告不打印比值数字;有 llvmlite → 模块 triple 为 `riscv32-unknown-elf`,`.s` 若含 RV64 助记符则 `isa_mismatch==true`。
+5. 布局失败:`--mem-size` 不足时 exit 4 且不产生虚假动态数据。
+6. 回归:`make test` 全绿;`python .claude/harness/verify/run.py --level L2` 通过;未改动编译器/Spike/基准套件代码。
+7. 文档一致性:报告字段与本文 3.3/5.3 完全同名;CLI 与 5.1 完全同名。
+
+---
+
+## 八、风险与回退
+
+| # | 风险 | 影响 | 缓解/回退 |
+|---|------|------|-----------|
+| R1 | TinyFive 吞吐低(Python 解释执行),全量 18.5 亿指令可能需数小时 | 全量不可行 | `--max-instructions` 预算并如实标注;先跑 1M 探测得到 MIPS 预估墙钟,写入 `warnings` |
+| R2 | 256MiB numpy 内存 + 26MB 权重 | 内存压力 | 布局不可压缩(ABI 地址固定);内存不足只能拒绝并提示,禁止缩容假装成功 |
+| R3 | `ra` 被生成代码内部 `jal ra, …` 覆盖,`halt_addr` 永不命中 | 无法 `halted` | `completion` 如实标 `budget_exhausted/timeout`;`--fail-on-incomplete` 供 CI |
+| R4 | 平台无 `SIGALRM` | 超时保护缺失 | 退化为要求 `--max-instructions`;否则拒绝启动 |
+| R5 | TinyFive 不支持某助记符导致 PC 不前进(卡死) | 挂起 | 启动前助记符白名单预检,`not_run` + exit 4 |
+| R6 | `.s` 标签格式未来漂移 | 标签解析失败 | `parse_labels` 与 `data_offset//4` 交叉校验,失败即抛错;T5 固定回归 |
+| R7 | llvmlite 未安装/目标不支持 | LLVM 侧不可测 | `skipped`+`unavailable` 如实标注;不引入解析估算 |
+| R8 | LLVM IR 为 riscv64 假设(指针宽度/全局) | 强行 RV32 代码错误 | `llmod.triple/data_layout` 覆盖 + `verify()` + RV64 助记符检测;失败标 `isa_mismatch` |
+| R9 | 工作区 `[sp, sp+workspace)` 与 input@160MiB 冲突 | 静默数据损坏 | `compute_layout()` 的 GUARD 校验,不满足 exit 4 |
+| R10 | 静态兜底被误用回动态展示 | 报告失真 | 字段拆分为 `static_instruction_mix`;`audit_provenance()` 阻断;T7 回归 |
+
+回退策略:若不满足验收(如全量在可接受时间内无法完成),保留本设计的所有“诚实标注”改动,仅把默认改为 `--max-instructions` 显式预算模式,并在报告与 CI 摘要中显著标注 `budget_exhausted`;任何情况下不得恢复“静态计数 + 无 provenance”的报告形态。
+
+---
+
+## 实现结果(2026-09-14 集成)
+
+> **集成 commit**:`12685db`(`feat(topic27): full RV32 benchmark with honest provenance and budget controls`)
+> **集成位置**:`Seven_big_summary` 上第 7 个 topic commit(顺序 … → 24 → **27** → 10 → …)
+> **集成后全量**:`PYTHONPATH=. python3.11 -m pytest tests/ -q` → **1011 passed / 13 xfailed / 20 xpassed / 0 failed**
+
+### 实现文件与要点
+
+| 文件 | 要点 |
+|------|------|
+| `scratchv/standalone/rv32_bench.py` | 重写:默认全量 + `--max-instructions` 预算、`audit_provenance`、`compute_layout` 的 GUARD 校验 |
+| `scratchv/standalone/bench_report.py` | 报告 schema v2(`model.sha256` / `environment.tinyfive` / `completion` / `static_source` 等) |
+| `tests/test_rv32_bench.py` | 9 个新用例 |
+
+### 测试数字
+
+| 口径 | 结果 |
+|------|------|
+| 定向(`tests/test_rv32_bench.py`) | 9 passed |
+| 分支全量(cherry-pick 前) | 574 passed |
+| 集成后全量 | 1011 passed / 13 xfailed / 20 xpassed / 0 failed |
+
+### 与本文档的偏差 / 未完成项
+
+- cnn 全量实测**未跑**(TinyFive 约 7 万 instr/s,预计约 7 小时),只做预算中断干跑(`budget_exhausted`)。
+
+### 已知限制
+
+- 对 TinyFive 机器实例的 NumPy 2.x `LW/LH` 兼容 shim 属课题 26 追修(`tinyfive.py` 未改)。
+- 错误路径统一用 `source=unavailable` 表达(而非 `dynamic=null`)。
+- LLVM 侧无 llvmlite 时 `status=skipped`、不打印比值数字。
diff --git "a/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\350\256\276\350\256\241\346\226\207\346\241\243.md"
new file mode 100644
index 0000000..234bde1
--- /dev/null
+++ "b/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\350\256\276\350\256\241\346\226\207\346\241\243.md"
@@ -0,0 +1,514 @@
+# 课题 27:RV32 全量 Benchmark 设计文档
+
+> 文档版本:v1.0
+> 编写日期:2026-09-14
+> 涉及模块:`scratchv/standalone/rv32_bench.py`(RV32 统一对比驱动)、`scratchv/standalone/bench_report.py`(报告渲染)、`scratchv/simulator/tinyfive.py`(`ProfiledMachine` 适配器,只读接口)、`scratchv/standalone/benchmark.py`(解析估算,仅作告警)
+> 功能范围:RV32IMF 统一目标下的 ScratchV/LLVM 对比基准;全量指令仿真与预算控制;诚实报告规范(provenance 字段、ISA 标注、模型身份);CLI 参数规格;报告 JSON schema v2
+
+---
+
+## 一、功能介绍
+
+### 1.1 功能概述
+
+课题 27 的目标是让 `rv32_bench.py` 真正执行“全量或明确预算并如实标注”的 RV32 模拟基准,而不是当前的“5000 条指令 + 64KB 内存 + 无权重”的伪全量对比。现状问题如下(均已核对源码与运行记录):
+
+1. **默认截断**:`rv32_bench.py:118` 默认 `n_instructions: int = 10000`,调用点 `rv32_bench.py:410-412` 实际传 `n_instructions=5000`。任何一次运行中,`ops.total` 恒 ≤ 5000,仅覆盖 `_start` 与输入拷贝循环的开头,与“动态指令数”语义无关。
+2. **内存容量错误**:`rv32_bench.py:124` 使用 `ProfiledMachine(mem_size=65536)`(64KB)。而 ScratchV 编译器约定的裸机 ABI 是 `sp=128MiB`、`a0=160MiB`(input)、`a1=192MiB`(output)、权重紧跟在代码之后(`data_offset`,`onnx_to_riscv_standalone.py:2548-2557`)。`models/graph/cnn.onnx` 为 27,677,152 字节(约 26.4MB 权重),64KB 内存连权重区都容纳不下。
+3. **权重数据未装载**:编译器只把权重写进 `.bin`(`binary = code_bytes + weight_data`,`onnx_to_riscv_standalone.py:2791`);`.s` 仅是代码反汇编(`onnx_to_riscv_standalone.py:2802-2807`),不含 26MB 权重。`run_tinyfive` 只把 `.s` 文本喂给 `load_asm`,权重恒为 0。
+4. **标签解析失效**:`_prepare_asm_for_tinyfive`(`rv32_bench.py:160-184`)构造了 `label_map` 却从不使用,标签行被丢弃;随后 `load_asm` 经 `RISCVAEncoder` 组装,而未解析标签走 `self.labels.get(label, current_idx)`(`riscv_encoder.py:502`),分支全部退化成“跳到自己”。同时 `.word` 数据行被当指令跳过。
+5. **静态计数冒充动态计数**:TinyFive 缺失时 `_tinyfive_static_fallback`(`rv32_bench.py:187-234`)返回 `ops` 字典,其 `total = sum(静态计数)`,并写入 `instr_count=n_instr`;报告把该结果与真实仿真结果同表渲染。
+6. **报告声明失真**:`generate_report` 硬编码模型描述(`rv32_bench.py:268` 写死 `cnn.onnx (3×Conv + …)`),输出 `Dynamic instruction ratio`(`rv32_bench.py:303`),页脚宣称 “All metrics sourced from TinyFive ProfiledMachine simulation. No analytical estimates.”(`rv32_bench.py:310`)。
+7. **LLVM 侧跨 ISA 与估算**:LLVM IR 生成器固定输出 `riscv64-unknown-elf` 模块头(`onnx_to_llvm_standalone.py:446`),而 `rv32_bench.py:82` 却向 llvmlite 请求 `riscv32` 目标机;两者不一致。llvmlite 未安装时 `status="skipped"`,但报告仍渲染对照表并保留 ratio 行。周边工具 `llvm_cache_compare.py` 的“动态指令数”是解析估算(该文件第 9 行自述),不属于本课题的实测口径。
+
+本课题的功能定义:
+
+- **全量仿真(full simulation)**:从入口 PC=0(`_start`)执行到 `_done: ret` 的停机地址,不设指令数截断;内存按裸机 ABI 尺寸分配;`.bin` 的代码段与权重段分别装载;输入按确定性种子生成。
+- **明确预算(budgeted simulation)**:允许 `--max-instructions N` 主动截断;截断必须写入 `completion="budget_exhausted"`,且所有依赖完整轨迹的派生指标(比值、per-MAC)置空。
+- **诚实报告(honest reporting)**:每个数字携带来源分类(measured/static/estimated/unavailable)与 provenance;动态指标只能来自真实仿真;ISA 与数值格式显式标注;模型身份由内容哈希与维度定义;不可复现的旧文案全部删除。
+
+### 1.2 设计目标
+
+- **真实性**:报告中的每一列都有 `source` 与 provenance;`audit_provenance()` 空违规列表是产出报告的前置条件。
+- **可复现**:相同的模型、种子、参数、依赖版本产出相同的计数(时间类字段除外);模型 `sha256`、输入种子、内存布局全部入档。
+- **全量优先、预算可选**:默认全量;截断是显式选择且显式标注;禁止把截断结果当作全量结果展示。
+- **可比性**:两侧统一标注 `rv32/imelf` 与数值格式(Q16.16 vs float32);不可比时 ratio 为 `null` 并给出 `incomparable_reason`。
+- **失败可见**:TinyFive 缺失、内存不足、标签解析失败、ISA 不匹配都必须以非零退出码或显式状态呈现,绝不静默降级。
+- **零新依赖**:仅使用 `tinyfive`、`numpy`、标准库;llvmlite 保持可选。
+- **兼容性**:保留 `--output-dir/--html/--json/--md` 既有 CLI 语义,既有测试不回归。
+
+---
+
+## 二、设计规范
+
+### 2.1 全量仿真定义
+
+#### 2.1.1 指令上限策略
+
+用运行模式与完成状态替代“隐藏的 10000/5000 常量”:
+
+```
+run_mode ::= full | budgeted(N) | (缺省 = full)
+full ::= "--full" | "--max-instructions 0"
+budgeted(N) ::= "--max-instructions" N ; N > 0
+completion ::= "halted" | "budget_exhausted" | "timeout" | "error" | "not_run"
+```
+
+| 运行模式 | TinyFive 调用 | 停止条件 | `completion` |
+|----------|---------------|----------|--------------|
+| full(默认) | `m.run(instructions=chunk)` 分块循环,累计直到 `pc == halt_addr` | PC 命中停机地址;或墙钟超时 | `halted` / `timeout` |
+| budgeted(N) | 同上,剩余额度 = N − executed | 额度耗尽;或 PC 命中停机地址 | `budget_exhausted` / `halted` |
+| 参数冲突 | `--full` 与 `--max-instructions N>0` 同时给出 | 启动前拒绝 | 退出码 2(usage) |
+
+规则:
+
+- `--max-instructions 0` 与 `--full` 等价,均为无截断语义;`limit=null`、`executed` 为真实执行数。
+- 分块循环使用 `--chunk-instructions`(默认 10,000,000)作为每块大小;块间检查停机与超时。这是在不修改 `ProfiledMachine` 公共接口的前提下获得“停机检测 + 进度 + 超时”的唯一手段。
+- `budget_exhausted` 时 `executed == limit` 必须为等式不变量;`halted` 时 `limit` 可为 `null`(full)或任意(budgeted 提前停机)。
+- 超时只在 `--full` 模式下产生 `timeout`;`budgeted` 模式下先到限额即结束,超时属于异常保护(同样标 `timeout`)。
+- 派生指标约束:`comparison.dynamic_instruction_ratio` 仅当两侧 `source=="simulated"` 且 `completion=="halted"` 时计算;任一 `budget_exhausted/timeout` 一律 `null` 并写 `incomparable_reason`。
+
+#### 2.1.2 内存布局与容量
+
+内存布局沿用编译器自检脚本的裸机约定(`onnx_to_riscv_standalone.py:2548-2557`),并补充停机字:
+
+| 区域 | 起址 | 大小 | 来源 |
+|------|------|------|------|
+| code | `0x00000000` | `data_offset` | `.bin[0:data_offset]` |
+| weights | `data_offset` | `binary_bytes − data_offset` | `.bin[data_offset:]` |
+| HALT(保留 4B) | `halt_addr = align_up(binary_bytes, 16)` | 4 | 本课题注入,不作为指令执行 |
+| workspace | `sp` 向上 | `workspace_size` | 编译器 stdout `Workspace: N bytes` |
+| stack | `sp` 向下 | 预留 | 由 `sp` 指向栈顶 |
+| sp | `128 MiB` | — | ABI 约定 |
+| input | `160 MiB` | `input_elements × 4` | Q16.16,确定性种子 |
+| output | `192 MiB` | `output_elements × 4` | 仿真结束后读取 |
+
+容量约束(启动前校验,不满足即 `exit 4`):
+
+```
+data_offset % 4 == 0
+halt_addr + 4 <= mem_size
+192 MiB + out_bytes + 4096 <= mem_size # output 区必须可用
+sp + workspace_size + GUARD <= 160 MiB # 工作区不得撞 input 区,GUARD = 1 MiB
+```
+
+默认 `--mem-size 268435456`(256MiB)。若模型或工作区要求超过该值,必须显式调大而不能静默截断;若小于 `192MiB + out_bytes`,直接拒绝运行(因为 ABI 地址不可压缩)。
+
+#### 2.1.3 权重数据装载
+
+```
+image ::= code_bytes (已 4 字节对齐) ‖ weight_bytes
+data_offset ::= len(code_bytes)
+data_size ::= len(weight_bytes) = len(.bin) − data_offset
+```
+
+- `data_offset` 从编译器 stdout 行 ` Data offset: 0x{hex} ({n} bytes)`(`onnx_to_riscv_standalone.py:2794`)解析,正则 `r"Data offset:\s*0x([0-9A-Fa-f]+)"`;`workspace_size` 从 ` Workspace: {n} bytes`(`:2738`)解析。解析失败 → `exit 4`,`reason="binary_layout_unparsed"`。
+- 装载顺序:`ProfiledMachine(mem_size)` → `m.load_binary(code_words, origin=0)` → `m.load_data(weight_bytes, data_offset)` → `m.set_reg(2, 128MiB)` / `set_reg(10, 160MiB)` / `set_reg(11, 192MiB)` / `set_reg(1, halt_addr)`。
+- `gp` 由代码自身在 `_start` 通过打补丁的 `auipc/addi` 设置(`onnx_to_riscv_standalone.py:2757-2789`),harness **不得**另写 `gp`。
+- 输入一次性构造后 `m.load_data(input_blob, 160MiB)`,不得逐元素 `write_mem_i32`(性能与确定性双重原因)。Q16.16 生成算法与编译器自检一致:`random.seed(seed)`、`val = int((random.random() − 0.5) × 0.2 × 65536)`(`onnx_to_riscv_standalone.py:2564-2567`),默认 `seed=42`。
+
+#### 2.1.4 停机条件
+
+- 生成代码结尾为 `_done: ret`(`jalr x0, x1, 0`,`onnx_to_riscv_standalone.py:1553-1556`)。harness 在运行前设 `x1 = halt_addr`,当 PC 到达 `halt_addr` 即判定 `halted`。TinyFive 的 `exe(start, end)` 原生支持按 end 地址停止;`ProfiledMachine` 未暴露该参数,故采用 2.1.1 的分块 `instructions=` 循环 + 每块后检查 `m.pc`。
+- 若生成代码在返回前用 `jal ra, …` 覆盖了 `ra`,或跳转路径异常,PC 永远不会命中 `halt_addr`:`budgeted` 记 `budget_exhausted`,`full` 记 `timeout`。不得谎报 `halted`。
+- TinyFive 遇到不支持的指令时打印错误且 PC 不前进(`dec()` 无匹配分支时不调用 `ipc()`),会表现为“卡死”。启动前必须做**助记符白名单预检**:用 ScratchV 的 `_disasm_one` 解析每个 code word,若出现 TinyFive 不支持/无法识别的助记符 → `completion="not_run"`,`exit 4`,禁止开跑。
+- `m.last_error` 非空(适配器捕获到异常)→ `completion="error"`,`dynamic` 整体置 `null`。
+
+#### 2.1.5 超时与预算保护
+
+- `--timeout SECONDS`(默认 900)为单侧仿真墙钟上限。实现:主线程 `signal.setitimer(ITIMER_REAL, remaining)` + `SimulationTimeout` 处理器;`m.run(..., strict=True)` 使适配器 `finally` 仍在异常路径更新 `instr_count`,ops 计数器保留部分值。
+- 超时判定不依赖异常类型(适配器会把异常包装成 `RuntimeError`):置模块级 `_timed_out` 标志,捕获后据此写 `completion="timeout"`,并把 `elapsed_s`、`executed` 落盘。
+- 平台不支持 `SIGALRM`(如 Windows)时:`--timeout` 退化为告警,要求用户必须给 `--max-instructions`;否则拒绝启动并提示替代方案。
+- **预算预估仅作告警**:`benchmark.estimate_cnn_model()` 的输出只能进入 `warnings` 与 “预计墙钟” 提示,字段名带 `estimated_` 前缀,绝不填入 `dynamic`。
+
+### 2.2 诚实报告规范
+
+#### 2.2.1 字段来源分类
+
+报告所有数值字段必须可归入以下四类之一,渲染时以标签区分:
+
+| 分类 | 允许的字段 | 硬性要求 |
+|------|-----------|----------|
+| `measured` | `*.dynamic.*`(ops、executed、输出值) | 必须来自 TinyFive 真实执行;`source="simulated"`,且 `completion ∈ {halted, budget_exhausted, timeout}` |
+| `static` | `*.compile.static_insns`、`*.static_instruction_mix.*` | `source="asm_scan"`;只统计 `.text` 助记符行;数据指令(`.word/.long/.byte/.float`)不计 |
+| `estimated` | 预估值、`est_hw_time_*`、cache 模型输出(如引用) | 字段名含 `estimated`/`model`;不得进入 `dynamic` |
+| `unavailable` | 失败/跳过侧 | `source="unavailable"`,`ops=null`,必须有 `reason` |
+
+#### 2.2.2 ISA 标注与统一 RV32
+
+- `targets.scratchv = {isa:"rv32im", abi:"ilp32", numeric_format:"q16.16"}`。
+- `targets.llvm = {isa:"rv32imf", abi:"ilp32", numeric_format:"float32", triple:"riscv32-unknown-elf", opt_level:2}`。
+- 统一口径动作:llvmlite 可用时,解析 IR 后显式设置 `llmod.triple = "riscv32-unknown-elf"` 再 `emit_assembly`;不可用则 `status="skipped"`。
+- **跨 ISA 检测**:对 LLVM 侧 `.s` 做助记符扫描,命中 RV64-only 集合(`ld, sd, lwu, addw, subw, addiw, sllw, srlw, sraw, slliw, srliw, sraiw, mulw, divw, divuw, remw, remuw, fld, fsd, fcvt.l.s, fcvt.s.l` 等)时置 `isa_mismatch=true`、`isa_detected="riscv64"`,禁用该侧动态对照。
+- 两侧 ISA 或数值格式不同的比值一律 `null` + `incomparable_reason`;ISA 相同也不得用估算数替代。
+- **边界**:LLVM 侧真实可执行镜像(汇编→链接→ABI→输入输出)属于课题 25/27 交界;本课题只负责“标注与统一 RV32 口径”,不实现 LLVM 侧符号重定位与运行时。
+
+#### 2.2.3 模型身份与维度
+
+报告必须包含且只依据以下事实描述模型:
+
+```
+model.path, model.sha256, model.bytes,
+model.input_name, model.input_shape, model.output_name, model.output_shape,
+model.initializer_count, model.weight_bytes
+scratchv.compile.binary_sha256, scratchv.compile.data_offset, scratchv.compile.data_bytes
+environment.python, environment.numpy, environment.tinyfive, environment.llvmlite
+```
+
+禁止在报告模板中硬编码任何具体模型名或层结构描述(现行 `rv32_bench.py:268` 的 “cnn.onnx (3×Conv + …)” 必须删除)。
+
+#### 2.2.4 不可复现数据的禁用
+
+以下做法在 schema v2 中一律违规,由 `audit_provenance()` 检出并阻断:
+
+1. 静态计数标为 `simulated`,或任何 `dynamic` 字段缺少 `simulator/simulator_version/executed/memory_size_bytes/input_seed`。
+2. `instr_count = limit` 式伪造:`completion=="halted"` 但 `executed == limit` 且 `limit != null`(未验证停机)。
+3. 截断/超时结果参与比值计算。
+4. 报告声明与 provenance 不符(例如页脚宣称 “all metrics from simulation” 而存在 `estimated` 列)。
+5. 把 `llvm_cache_compare.py` 的解析估算、`benchmark.estimate_cnn_model()` 的结果、`cache_model.py` 的命中率填入 `measured` 分类。
+6. 硬编码模型描述与 `model.path/sha256` 不一致。
+7. 只记录时间戳不记录哈希/种子/版本。
+
+页脚文案由 provenance 动态生成,例如:
+`Simulated by tinyfive {version} | completion={completion} | executed={executed} | limit={limit} | model sha256={sha256[:12]} | seed={seed}`。
+
+### 2.3 CLI 参数规格
+
+| 参数 | 类型/默认 | 语义 | 约束 |
+|------|-----------|------|------|
+| `model`(位置参数) | path 必填 | ONNX 模型 | 存在且可读 |
+| `--output-dir` | path,`benchmark_reports` | 产出目录 | 自动创建 |
+| `--html` / `--json` / `--md` | filename,`rv32_bench.{html,json,md}` | 三种格式文件名 | 相对 `--output-dir` |
+| `--max-instructions` | int,`0` | `>0` 为预算;`0` 为全量 | 与 `--full` 互斥(N>0 时) |
+| `--full` | flag,默认关 | 等价 `--max-instructions 0` | 与 N>0 同时出现 → exit 2 |
+| `--mem-size` | int 字节,`268435456` | TinyFive 内存容量 | 必须满足 2.1.2 约束,否则 exit 4 |
+| `--timeout` | float 秒,`900` | 单侧仿真墙钟上限 | POSIX `SIGALRM`;否则需预算模式 |
+| `--chunk-instructions` | int,`10000000` | 分块粒度 | `>0` |
+| `--input-seed` | int,`42` | 输入生成种子 | 与编译器自检同算法 |
+| `--skip-llvm` | flag | 跳过 LLVM 编译 | LLVM 侧整体 `not_run` |
+| `--allow-missing-simulator` | flag | TinyFive 缺失时输出纯静态报告 | 报告 `dynamic.source="unavailable"`,exit 0 |
+| `--fail-on-incomplete` | flag | `completion != "halted"` 时 exit 7 | 供 CI 使用 |
+| `--quiet` | flag | 不向 stdout 打印 Markdown | 报告仍落盘 |
+
+退出码:`0` 成功;`1` 未预期错误;`2` 用法错误;`3` 仿真器不可用(未加 `--allow-missing-simulator`);`4` 模型/镜像/内存布局校验失败;`7` 未完成且指定 `--fail-on-incomplete`。
+
+### 2.4 合法/非法报告示例
+
+#### 2.4.1 合法示例 A:全量完成
+
+```json
+{
+ "schema_version": "rv32-bench/2",
+ "model": {"path": "models/graph/cnn.onnx", "sha256": "0123…cdef", "bytes": 27677152},
+ "scratchv": {
+ "compile": {"status": "success", "data_offset": 24336, "data_bytes": 26700000,
+ "static_insns": 6084, "static_source": "asm_scan"},
+ "dynamic": {"source": "simulated", "simulator": "tinyfive", "simulator_version": "1.0.0",
+ "completion": "halted", "limit": null, "executed": 1844674407,
+ "memory_size_bytes": 268435456, "input_seed": 42,
+ "ops": {"total": 1844674407, "load": 1, "store": 1, "mul": 1,
+ "add": 1, "madd": 0, "branch": 1}}
+ },
+ "comparison": {"dynamic_instruction_ratio": null,
+ "incomparable_reason": "llvm.dynamic.source!='simulated'"}
+}
+```
+(数值为占位示例;关键点是全字段 provenance 齐备。)
+
+#### 2.4.2 合法示例 B:预算截断(如实标注)
+
+```json
+{
+ "scratchv": {
+ "dynamic": {"source": "simulated", "simulator": "tinyfive", "simulator_version": "1.0.0",
+ "completion": "budget_exhausted", "limit": 1000000, "executed": 1000000,
+ "memory_size_bytes": 268435456, "input_seed": 42,
+ "ops": {"total": 1000000, "load": 0, "store": 0, "mul": 0,
+ "add": 0, "madd": 0, "branch": 0}}
+ },
+ "comparison": {"dynamic_instruction_ratio": null,
+ "incomparable_reason": "scratchv.completion=='budget_exhausted'"}
+}
+```
+
+#### 2.4.3 非法示例(应被 `audit_provenance()` 拒绝)
+
+| # | 片段 | 违反条款 |
+|---|------|----------|
+| 1 | `"dynamic":{"source":"simulated","ops":{"total":3841}}`,无 simulator/version/executed | 2.2.4-1 |
+| 2 | `"completion":"halted","limit":5000,"executed":5000` | 2.2.4-2(5000 是截断值) |
+| 3 | `"comparison":{"dynamic_instruction_ratio":0.74}` 两侧均 `budget_exhausted` | 2.2.4-3 |
+| 4 | 页脚 “All metrics sourced from TinyFive … No analytical estimates” 而 `estimated_hw_time` 列存在 | 2.2.4-4 |
+| 5 | `"model":{"path":"resnet18.onnx"}` 而正文描述为 “cnn.onnx (3×Conv + …)” | 2.2.4-6 |
+
+---
+
+## 三、测试设计
+
+### 测试用例 1:小模型全量仿真计数正确性
+
+- **文件**:`tests/test_rv32_bench.py::test_full_run_small_model_matches_reference`
+- **输入**:测试内用 `onnx.helper` 构造的迷你模型(如输入 `1×1×8×8`、单层 `Conv 3×3 → 1×1×6×6`,无 FC),写成临时 `.onnx`;随后调用 `compile_scratchv()` + `run_simulation(max_instructions=0, mem_size=268435456, timeout_s=60)`(内存仍按 192MiB 输出区约定,不可缩小)。
+- **预期输出**:`scratchv.dynamic.completion == "halted"`;`executed > 0`;`ops.total == executed`;随后用独立功能仿真器 `benchmark.RV32EmulatorFast`(`load_unified_binary` + `run(max_instr=2_000_000_000)`)跑同一 `.bin` 与同一输入/指针布局。
+- **验证点**:`tinyfive.total == emulator.total`、`load` 与 `load_count`、`store` 与 `store_count`、`branch` 与 `branch_total` 逐一相等;输出 `Q16.16` 值一致;对同一命令重跑一次,计数完全一致(确定性)。任何不等即失败,禁止容差放过。
+
+### 测试用例 2:预算超限行为
+
+- **文件**:`tests/test_rv32_bench.py::test_budget_exhausted_is_labeled`
+- **输入**:对同一迷你模型(或 `cnn.onnx` 若存在)执行 `run_simulation(max_instructions=1000, ...)`。
+- **预期输出**:`completion=="budget_exhausted"`、`limit==1000`、`executed==1000`、`ops.total==1000`;`comparison.dynamic_instruction_ratio is null` 且 `incomparable_reason` 含 `budget_exhausted`;Markdown 中出现 `[measured/budget]` 标签而非 “dynamic instruction ratio” 正文。
+- **验证点**:`audit_provenance(report) == []`;把该 JSON 传给 `bench_report.validate_report_schema()` 通过;`--fail-on-incomplete` 下退出码为 7。
+
+### 测试用例 3:报告字段与 provenance 校验
+
+- **文件**:`tests/test_rv32_bench.py::test_report_requires_provenance`
+- **输入**:monkeypatch `ProfiledMachine` 为不可用(模拟未安装 TinyFive),命令行加 `--allow-missing-simulator`。
+- **预期输出**:`scratchv.dynamic.source=="unavailable"`、`ops is null`、`completion=="not_run"`、`reason` 非空;`scratchv.compile.static_insns > 0` 且 `static_source=="asm_scan"`;无 `dynamic_instruction_ratio`;页面含 “static” 标签与显式不可用提示。
+- **验证点**:`validate_report_schema()` 对必填 provenance 键(`schema_version/model.sha256/environment/targets/*.compile.static_source/comparison.incomparable_reason`)逐项断言;未加 `--allow-missing-simulator` 时退出码为 3。
+
+### 测试用例 4:内存布局校验
+
+- **文件**:`tests/test_rv32_bench.py::test_memory_layout_validation`
+- **输入**:`--mem-size 1048576`(1MiB,小于 output 区 192MiB)。
+- **预期输出**:退出码 4,stderr 含 `memory_layout_invalid` 与所需最小字节数;不产生任何带 `dynamic.ops` 的报告文件。
+- **验证点**:`write_report` 未被调用(临时目录为空);错误信息给出可操作建议(明确 “需要 ≥ 192MiB + 输出区 + 4096 字节” 的具体数值)。
+
+### 测试用例 5:标签解析不产生自跳转
+
+- **文件**:`tests/test_rv32_bench.py::test_parsed_labels_cover_all_branches`
+- **输入**:迷你模型产出的 `.s`;`parse_labels(asm_text)` 的结果与 `data_offset/4` 的关系校验。
+- **预期输出**:`len(labels) > 0`;`_start` 映射 0;`_done` 在文件末尾附近;每个分支/跳转目标标签都能在 labels 中找到;`static_insns == data_offset // 4`。
+- **验证点**:若任一分支目标缺失,函数抛 `LabelParseError`(不允许 `labels.get(target, pc)` 回退为自跳转);该断言直接回归现行 `riscv_encoder.py:502` 的静默语义。
+
+### 测试用例 6:LLVM 跨 ISA 标注
+
+- **文件**:`tests/test_rv32_bench.py::test_llvm_riscv64_flagged_isa_mismatch`
+- **输入**:含 `ld/sd/addiw` 的 RV64 汇编 fixture(或跳过 llvmlite 时的真实 `_ll_rv32.s`)。
+- **预期输出**:`llvm.compile.isa_detected=="riscv64"`、`isa_mismatch==true`、`llvm.dynamic.source=="unavailable"`、`comparison.dynamic_instruction_ratio is null`。
+- **验证点**:报告不出现 “RV32IMF” 对照列;`incomparable_reason` 指名 ISA 不匹配。
+
+---
+
+## 四、修改模块与实现步骤
+
+### 4.1 涉及文件
+
+| 文件 | 角色 | 改动性质 |
+|------|------|----------|
+| `scratchv/standalone/rv32_bench.py` | 主驱动:编译、装载、仿真、组装报告 | 大幅重构(详见开发文档改动清单) |
+| `scratchv/standalone/bench_report.py` | Markdown/HTML/JSON 渲染 | 新增 `render_*(report)` 与 schema 校验;旧函数保留兼容壳 |
+| `tests/test_rv32_bench.py` | 新增测试 | 新建 |
+| `tests/fixtures/`(或测试内生成) | 迷你 ONNX 与 RV64 汇编 fixture | 新建(测试内用 `onnx.helper` 生成优先) |
+| `scratchv/standalone/onnx_to_riscv_standalone.py` | 编译器 | **不改**(只读取 stdout 与 `.bin/.s`) |
+| `scratchv/simulator/tinyfive.py` | 仿真适配器 | **不改公共接口**(只使用 `load_binary/load_data/run(strict=True)/pc/get_perf`) |
+| `scratchv/standalone/llvm_cache_compare.py` / `cache_model.py` / Spike / `benchmarks/` | 其他课题资产 | **不改**(边界见 4.9) |
+
+(注:上表路径为本仓库真实路径;若后续目录重构,以 `scratchv/standalone/` 下同名文件为准。)
+
+### 4.2 镜像装载与布局解析
+
+1. `compile_scratchv()` 保留子进程调用(`--asm` 输出同目录 `.s`),新增解析 stdout:
+ - `Data offset: 0x…` → `data_offset`;`Workspace: N bytes` → `workspace_size`;`Code size: N bytes` → 校验值。
+2. `load_scratchv_image(binary_path)` 返回 `(code_words, weight_bytes, data_offset)`:
+ - 校验 `data_offset % 4 == 0`、`data_offset ≤ len(binary)`、`len(binary) − data_offset > 0`;
+ - `code_size = data_offset`,`binary_bytes = len(binary)`,`static_insns` 由 `.s` 扫描并与 `data_offset // 4` 交叉校验。
+3. `halt_addr = align_up(binary_bytes, 16)`;在 TinyFive `mem` 中 `halt_addr` 处无需写指令(停止发生在取指前),但要求 `halt_addr + 4 ≤ mem_size`。
+
+### 4.3 内存与输入初始化
+
+1. `compute_layout()` 按 2.1.2 公式校验并返回全部地址;失败抛 `LayoutError`,`main` 转 exit 4。
+2. `ProfiledMachine(mem_size=args.mem_size)`;`available` 为假时按 `--allow-missing-simulator` 决策(exit 3 或静态报告)。
+3. `load_binary(code_words, 0)` → `load_data(weight_bytes, data_offset)` → `load_data(input_blob, INPUT_ADDR)`。
+4. `set_reg(2, SP_ADDR=128MiB)`、`set_reg(10, 160MiB)`、`set_reg(11, 192MiB)`、`set_reg(1, halt_addr)`;`gp` 留空由代码设置。
+5. 输出读取:`read_mem_i32(192MiB)`(单元素)或按 `output_elements × 4` 读字节;记录 `output.raw_hex` 与 `output.q16_16`。
+
+### 4.4 仿真执行器
+
+1. `run_simulation(...)` 分块循环:
+
+```
+executed = 0; timed_out = False
+while True:
+ 若 budgeted 且 executed == limit: completion = budget_exhausted; break
+ 若 m.pc == halt_addr: completion = halted; break
+ 若 墙钟超时: completion = timeout; break
+ chunk = min(chunk_instructions, limit - executed) 若 budgeted 否则 chunk_instructions
+ 设置 SIGALRM(剩余墙钟);m.run(instructions=chunk, strict=True);清除 SIGALRM
+ executed = get_perf()["total"]
+```
+
+2. 停机/超时后从 `m.get_perf()` 取 ops、从 `(m._machine.x_usage > 0).sum()` 取 `x_registers_used`、`.sum()` 取 `x_usage_total`;`m.last_error` 非空且非超时 → `completion="error"`。
+3. 启动前执行助记符白名单预检(2.1.4);不通过 → `not_run` + exit 4。
+4. 整个执行器不写 `scratchv/standalone/` 之外的任何文件;临时产物仅 `--output-dir`。
+
+### 4.5 标签解析与静态统计
+
+1. `parse_labels(asm_text)` 两遍扫描:`.s` 中标签独占一行(`RISCVEmitter.disassemble()`,`:1291-1305`),每遇一行缩进的指令行 word index +1;返回 `{pc: label}`。
+2. `static_instruction_mix(asm_text)` 按助记符表分类(复用 `rv32_bench.py:206-220` 的 opcode→类别映射),但输出字段名为 `static_instruction_mix`(`source="asm_scan"`),不与 `dynamic.ops` 同名同表。
+3. 若解析出的指令数 ≠ `data_offset // 4`,抛 `LabelParseError`,禁止“尽力而为”。
+
+### 4.6 报告组装与渲染
+
+1. `build_report()` 产出 schema v2 字典(详细字段见开发文档《接口契约》)。
+2. `audit_provenance(report)` 返回违规列表;`main` 在写盘前调用,非空则打印并 exit 1。
+3. `bench_report.render_markdown/html/github_summary(report)` 负责三格式渲染;所有表格列头或行内带 `[measured]/[static]/[estimated]/[unavailable]` 标签;页脚由 provenance 动态生成。
+4. `bench_report.validate_report_schema(report)` 供测试与 CI 做 JSON 结构断言。
+
+### 4.7 LLVM 侧:标注与统一 RV32
+
+1. `compile_llvm_rv32()`:`binding.Target.from_triple("riscv32-unknown-elf")`;llvmlite 缺失/目标不可用 → `status="skipped"` + `reason`,`dynamic.source="unavailable"`。
+2. 成功的路径上:解析 IR → 显式 `llmod.triple = "riscv32-unknown-elf"`(覆盖 `onnx_to_llvm_standalone.py:446` 的 riscv64 模块头)→ `verify()` → `emit_assembly()`。
+3. 对输出的 `.s` 做 `detect_isa_mismatch()`;命中 RV64-only 助记符 → `isa_mismatch=true`,动态对照关闭。
+4. 静态计数只扫 `.text` 段:遇到 `.section .rodata`(权重 `.word/.long`)即暂停计数;`static_source="asm_scan"`。
+5. 不实现 LLVM 侧可执行镜像(符号重定位、`a0/a1` 约定、数据段加载)——属课题 25 边界;未实现即 `unavailable`,不得用 `llvm_cache_compare.py` 的估算补位。
+
+### 4.8 集成与回归测试
+
+- 新增 `tests/test_rv32_bench.py`(第三部分 6 个用例),全部可在 `make test` 下运行;无 TinyFive 的环境自动 skip 动态类用例(用 `pytest.importorskip("tinyfive")` 模式,与 `tests/test_simulator.py` 一致),但不允许 skip 掉 provenance 校验类用例。
+- 回归:`python .claude/harness/verify/run.py --level L2`;确认 `tests/test_simulator.py`、`tests/test_bench_runner.py`、`tests/test_cnn_pipeline.py` 不回归。
+- 手工验收命令(详见开发文档 7 节):全量(或超时标注)、预算截断、内存拒绝、TinyFive 缺失四条路径各跑一次并人工检查报告。
+
+### 4.9 范围边界
+
+- **不改** `scratchv/standalone/onnx_to_riscv_standalone.py` 的代码生成、ABI 与内存布局;只读取其 stdout 与输出文件。
+- **不改** Spike 相关(课题 24:`spike_sim.py`、`run_spike_bench.py`)与基准套件(课题 06:`benchmarks/`、`bench_runner.py`)。
+- **不改** `tinyfive.py` 公共接口;若未来需要 `end=` 精确停机,作为独立后续课题,附 ABI 兼容性说明。
+- **LLVM 实测对比**:`llvmlite` 目标机切换、RV64→RV32 差异修复、LLVM 侧可执行镜像的构建属于课题 25/27 交界;本课题只做 ISA 标注与 RV32 统一请求,`llvm_cache_compare.py` 的解析估算继续留在课题 25,不得进入本报告 `measured` 分类。
+- **cache 指标**:`cache_model.py` 为分析模型(课题 23);本课题不引入 cache 命中率类指标,若后续引入必须标 `estimated`。
+
+---
+
+## 五、附录
+
+### 5.1 报告 JSON 示例(全量完成,schema v2)
+
+> 以下数值为结构示例(占位),字段名与嵌套关系为规范值。
+
+```json
+{
+ "schema_version": "rv32-bench/2",
+ "generated_at": "2026-09-14T00:00:00Z",
+ "generator": {"script": "rv32_bench.py"},
+ "model": {
+ "path": "models/graph/cnn.onnx",
+ "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
+ "bytes": 27677152,
+ "input_name": "input.1",
+ "input_shape": [1, 3, 250, 250],
+ "output_name": "output",
+ "output_shape": [1, 1],
+ "initializer_count": 16,
+ "weight_bytes": 26700000
+ },
+ "environment": {
+ "python": "3.8.10", "numpy": "1.24.4",
+ "tinyfive": "1.0.0", "llvmlite": null
+ },
+ "targets": {
+ "scratchv": {"isa": "rv32im", "abi": "ilp32", "numeric_format": "q16.16"},
+ "llvm": {"isa": "rv32imf", "abi": "ilp32", "numeric_format": "float32",
+ "triple": "riscv32-unknown-elf", "opt_level": 2}
+ },
+ "scratchv": {
+ "compile": {
+ "status": "success", "binary": "_sv.bin", "binary_bytes": 26724336,
+ "binary_sha256": "1111111111111111111111111111111111111111111111111111111111111111",
+ "code_bytes": 24336, "data_offset": 24336, "data_offset_source": "compiler_stdout",
+ "data_bytes": 26700000, "static_insns": 6084, "static_source": "asm_scan",
+ "elapsed_s": 12.3
+ },
+ "static_instruction_mix": {
+ "source": "asm_scan",
+ "load": 0, "store": 0, "mul": 0, "add": 0, "madd": 0, "branch": 0, "other": 0
+ },
+ "dynamic": {
+ "source": "simulated",
+ "simulator": "tinyfive", "simulator_version": "1.0.0",
+ "completion": "halted", "limit": null, "executed": 1844674407,
+ "timeout_s": 900.0, "elapsed_s": 3210.5,
+ "memory_size_bytes": 268435456,
+ "input_seed": 42, "input_elements": 187500,
+ "halt_addr": 26724336,
+ "ops": {"total": 1844674407, "load": 0, "store": 0, "mul": 0,
+ "add": 0, "madd": 0, "branch": 0},
+ "x_registers_used": 31, "x_usage_total": 123456789,
+ "f_registers_used": 0,
+ "per_label": null,
+ "per_label_note": "tinyfive exe() exposes no per-PC trace",
+ "last_error": null
+ },
+ "output": {"addr": 201326592, "elements": 1, "raw_hex": "0x00018000", "q16_16": 1.5}
+ },
+ "llvm": {
+ "compile": {"status": "skipped", "reason": "llvmlite not available",
+ "isa_detected": null, "isa_mismatch": false,
+ "static_insns": 0, "static_source": "asm_scan", "elapsed_s": 0.0},
+ "dynamic": {"source": "unavailable", "simulator": "tinyfive", "completion": "not_run",
+ "reason": "llvm executable image pipeline not implemented (topic 25 boundary)",
+ "ops": null}
+ },
+ "comparison": {
+ "dynamic_instruction_ratio": null,
+ "incomparable_reason": "llvm.dynamic.source=='unavailable'"
+ },
+ "warnings": [
+ "estimated dynamic instructions (analytical): 1.85e9 — used for wall-clock warning only"
+ ],
+ "errors": []
+}
+```
+
+### 5.2 报告 JSON 示例(预算截断,schema v2)
+
+```json
+{
+ "schema_version": "rv32-bench/2",
+ "model": {"path": "models/graph/cnn.onnx", "sha256": "0123…cdef"},
+ "scratchv": {
+ "compile": {"status": "success", "data_offset": 24336, "data_bytes": 26700000,
+ "static_insns": 6084, "static_source": "asm_scan"},
+ "dynamic": {
+ "source": "simulated", "simulator": "tinyfive", "simulator_version": "1.0.0",
+ "completion": "budget_exhausted", "limit": 1000000, "executed": 1000000,
+ "timeout_s": 900.0, "elapsed_s": 42.0, "memory_size_bytes": 268435456,
+ "input_seed": 42, "halt_addr": 26724336,
+ "ops": {"total": 1000000, "load": 0, "store": 0, "mul": 0,
+ "add": 0, "madd": 0, "branch": 0},
+ "x_registers_used": 12, "x_usage_total": 3456, "f_registers_used": 0,
+ "per_label": null, "per_label_note": "tinyfive exe() exposes no per-PC trace"
+ }
+ },
+ "comparison": {
+ "dynamic_instruction_ratio": null,
+ "incomparable_reason": "scratchv.completion=='budget_exhausted'"
+ },
+ "warnings": ["budget exhausted at 1000000 instructions; dynamic counts are partial"],
+ "errors": []
+}
+```
+
+### 5.3 非法报告片段与拒绝理由(对照 2.2.4)
+
+```json
+{"scratchv": {"dynamic": {"source": "simulated", "completion": "halted",
+ "ops": {"total": 3841}},
+ "compile": {"_note": "fallback: static counts only"}}}
+```
+→ 违规:缺 `simulator/simulator_version/executed/memory_size_bytes/input_seed`;`3841` 是静态计数。
+
+```json
+{"comparison": {"dynamic_instruction_ratio": 0.31},
+ "scratchv": {"dynamic": {"completion": "budget_exhausted"}},
+ "llvm": {"dynamic": {"completion": "budget_exhausted"}}}
+```
+→ 违规:截断轨迹不可比。
+
+### 5.4 参考资料
+
+- 设计文档模板:`/root/Lab/ScratchV/设计文档模板.md`
+- 课题 27 背景:`docs/topics/27-RV32全量Benchmark.md`
+- TinyFive 适配器:`scratchv/simulator/tinyfive.py`(`run(instructions=…, strict=True)`、`get_perf`、`pc`)
+- 编译器 ABI/布局:`scratchv/standalone/onnx_to_riscv_standalone.py:2548-2557, 2729-2794, 2802-2807`
+- LLVM 模块头:`scratchv/standalone/onnx_to_llvm_standalone.py:446`
+- 编码器标签回退语义:`scratchv/backend/riscv_encoder.py:502`
+- 相邻课题:课题 06(基准套件)、课题 23(Cache 模型)、课题 24(Spike)、课题 25(LLVM 对比工具)、课题 26(TinyFive 对比)
From 133437b7ebf3349f7e28f0071cc012ad4a412bb6 Mon Sep 17 00:00:00 2001
From: Seven Gao <799889633@qq.com>
Date: Mon, 14 Sep 2026 22:46:41 +0800
Subject: [PATCH 3/5] fix(topic27): tag partial outputs, surface LLVM failures,
tighten audit
- mark scratchv.output as partial (plus completion) whenever the run does
not halt, force q16_16 to always be a list, and render
[measured/partial] in the Markdown/HTML provenance section
- validate the output block in audit_provenance and
validate_report_schema; add executed==ops.total and executed<=limit
invariants to the provenance audit
- surface llvm.compile.status=='failed' as a warning carrying the real
reason, propagate it into llvm.dynamic.reason, render LLVM static
insns as em-dash for non-success compiles, and emit an explicit
no-auto-estimate warning for full runs
- tests: wall-clock timeout path, partial output, LLVM failure, audit
counter forgeries and q16_16 type stability
---
scratchv/standalone/bench_report.py | 57 ++++++++-
scratchv/standalone/rv32_bench.py | 138 +++++++++++++++++-----
tests/test_rv32_bench.py | 173 +++++++++++++++++++++++++++-
3 files changed, 334 insertions(+), 34 deletions(-)
diff --git a/scratchv/standalone/bench_report.py b/scratchv/standalone/bench_report.py
index 1dbc6ad..05533f6 100644
--- a/scratchv/standalone/bench_report.py
+++ b/scratchv/standalone/bench_report.py
@@ -532,9 +532,13 @@ def render_markdown(report: dict) -> str:
lines.append(f"| code bytes | {_fmt(sv_compile.get('code_bytes'))} | — |")
lines.append(f"| data offset | {_fmt(sv_compile.get('data_offset'))} | — |")
lines.append(f"| data bytes | {_fmt(sv_compile.get('data_bytes'))} | — |")
+ ll_static = (
+ _fmt(ll_compile.get("static_insns"))
+ if ll_compile.get("status") == "success" else "—"
+ )
lines.append(
f"| static insns [asm_scan] | {_fmt(sv_compile.get('static_insns'))} "
- f"| {_fmt(ll_compile.get('static_insns'))} |"
+ f"| {ll_static} |"
)
lines.append("")
lines.append("### Static instruction mix [static]")
@@ -631,10 +635,17 @@ def render_markdown(report: dict) -> str:
f"- static_source={_fmt(sv_compile.get('static_source'))} "
f"| llvm static_source={_fmt(ll_compile.get('static_source'))}"
)
+ if sv_dyn.get("source") != "simulated":
+ out_tag = "[unavailable]"
+ elif sv_out.get("partial"):
+ out_tag = "[measured/partial]"
+ else:
+ out_tag = "[measured]"
lines.append(
- f"- output: {_fmt(sv_out.get('raw_hex'))} "
+ f"- output {out_tag}: {_fmt(sv_out.get('raw_hex'))} "
f"(addr=0x{int(sv_out.get('addr') or 0):x}, "
- f"elements={_fmt(sv_out.get('elements'))})"
+ f"elements={_fmt(sv_out.get('elements'))}, "
+ f"completion={_fmt(sv_out.get('completion'))})"
)
return "\n".join(lines)
@@ -846,6 +857,46 @@ def is_int(value) -> bool:
f"{side}.dynamic.ops must be null when unavailable"
)
+ out = _dig(report, "scratchv.output")
+ if out is _MISSING or not isinstance(out, dict):
+ errors.append("missing required field: scratchv.output")
+ else:
+ if not isinstance(out.get("partial"), bool):
+ errors.append(
+ f"invalid scratchv.output.partial: {out.get('partial')!r}"
+ )
+ if not isinstance(out.get("completion"), str) or \
+ not out.get("completion"):
+ errors.append(
+ "invalid scratchv.output.completion: "
+ f"{out.get('completion')!r}"
+ )
+ if not is_int(out.get("elements")):
+ errors.append(
+ f"invalid scratchv.output.elements: "
+ f"{out.get('elements')!r}"
+ )
+ q16 = out.get("q16_16")
+ if out.get("partial") is False:
+ if out.get("raw_hex") is None:
+ errors.append(
+ "missing required field: scratchv.output.raw_hex"
+ )
+ if not isinstance(q16, list):
+ errors.append(
+ f"invalid scratchv.output.q16_16: {q16!r}"
+ )
+ elif q16 is not None and not isinstance(q16, list):
+ errors.append(
+ f"invalid scratchv.output.q16_16: {q16!r}"
+ )
+ if isinstance(q16, list) and is_int(out.get("elements")) and \
+ len(q16) != out["elements"]:
+ errors.append(
+ f"invalid scratchv.output.q16_16 length: {len(q16)} "
+ f"!= elements {out['elements']}"
+ )
+
comparison = _dig(report, "comparison")
if comparison is _MISSING or not isinstance(comparison, dict):
errors.append("missing required field: comparison")
diff --git a/scratchv/standalone/rv32_bench.py b/scratchv/standalone/rv32_bench.py
index bab33d6..bd139d7 100644
--- a/scratchv/standalone/rv32_bench.py
+++ b/scratchv/standalone/rv32_bench.py
@@ -722,6 +722,18 @@ def _unavailable_dynamic(*, reason: str, mem_size: int, timeout_s: float,
}
+def _unavailable_output(elements: int, completion: str) -> dict:
+ """Output block for runs that produced no (or no complete) values."""
+ return {
+ "addr": OUTPUT_ADDR,
+ "elements": elements,
+ "raw_hex": None,
+ "q16_16": None,
+ "completion": completion,
+ "partial": completion != "halted",
+ }
+
+
def _read_output(machine, addr: int, elements: int) -> dict:
values = []
if machine.available and elements > 0:
@@ -735,7 +747,7 @@ def _read_output(machine, addr: int, elements: int) -> dict:
"addr": addr,
"elements": elements,
"raw_hex": raw_hex,
- "q16_16": q16[0] if len(q16) == 1 else q16,
+ "q16_16": q16,
}
@@ -773,10 +785,7 @@ def run_simulation(*, asm_path: str, binary_path: str, data_offset: int,
mem_size=mem_size, timeout_s=timeout_s, input_seed=input_seed,
input_elements=input_elements, halt_addr=halt_addr,
),
- "output": {
- "addr": OUTPUT_ADDR, "elements": output_elements,
- "raw_hex": None, "q16_16": None,
- },
+ "output": _unavailable_output(output_elements, "not_run"),
}
_install_tinyfive_compat(m, halt_addr)
@@ -789,10 +798,7 @@ def run_simulation(*, asm_path: str, binary_path: str, data_offset: int,
timeout_s=timeout_s, input_seed=input_seed,
input_elements=input_elements, halt_addr=halt_addr,
),
- "output": {
- "addr": OUTPUT_ADDR, "elements": output_elements,
- "raw_hex": None, "q16_16": None,
- },
+ "output": _unavailable_output(output_elements, "not_run"),
}
unsupported = check_mnemonics(code_words)
@@ -804,10 +810,7 @@ def run_simulation(*, asm_path: str, binary_path: str, data_offset: int,
)
return {
"dynamic": dyn,
- "output": {
- "addr": OUTPUT_ADDR, "elements": output_elements,
- "raw_hex": None, "q16_16": None,
- },
+ "output": _unavailable_output(output_elements, "not_run"),
}
m.load_binary(code_words, origin=0)
@@ -906,13 +909,13 @@ def run_simulation(*, asm_path: str, binary_path: str, data_offset: int,
dyn["last_error"] = m.last_error
return {
"dynamic": dyn,
- "output": {
- "addr": OUTPUT_ADDR, "elements": output_elements,
- "raw_hex": None, "q16_16": None,
- },
+ "output": _unavailable_output(output_elements, "error"),
}
ops = {key: int(perf.get(key, 0)) for key in OPS_KEYS}
+ output = _read_output(m, layout["output_addr"], output_elements)
+ output["completion"] = completion
+ output["partial"] = completion != "halted"
return {
"dynamic": {
"source": "simulated",
@@ -935,7 +938,7 @@ def run_simulation(*, asm_path: str, binary_path: str, data_offset: int,
"per_label_note": "tinyfive exe() exposes no per-PC trace",
"last_error": m.last_error,
},
- "output": _read_output(m, layout["output_addr"], output_elements),
+ "output": output,
}
@@ -1046,6 +1049,7 @@ def audit_provenance(report: dict) -> list[str]:
violations.append(f"{side_name}.dynamic missing")
continue
source = dyn.get("source")
+ completion = dyn.get("completion")
if source == "simulated":
for key in ("simulator", "simulator_version", "executed",
"memory_size_bytes", "input_seed"):
@@ -1053,7 +1057,7 @@ def audit_provenance(report: dict) -> list[str]:
violations.append(
f"{side_name}.dynamic.{key} missing for simulated data"
)
- if dyn.get("completion") not in ("halted", "budget_exhausted", "timeout"):
+ if completion not in ("halted", "budget_exhausted", "timeout"):
violations.append(
f"{side_name}.dynamic.completion invalid: "
f"{dyn.get('completion')!r}"
@@ -1067,12 +1071,26 @@ def audit_provenance(report: dict) -> list[str]:
violations.append(
f"{side_name}.dynamic.ops.{key} must be an int"
)
- if dyn.get("completion") == "halted" and \
- dyn.get("limit") is not None and \
- dyn.get("executed") == dyn.get("limit"):
+ executed = dyn.get("executed")
+ if isinstance(ops.get("total"), int) and \
+ isinstance(executed, int) and ops["total"] != executed:
+ violations.append(
+ f"{side_name}.dynamic: executed={executed} != "
+ f"ops.total={ops['total']} (inconsistent counters)"
+ )
+ limit = dyn.get("limit")
+ executed = dyn.get("executed")
+ if isinstance(limit, int) and isinstance(executed, int) and \
+ executed > limit:
+ violations.append(
+ f"{side_name}.dynamic: executed={executed} > "
+ f"limit={limit} (budget overrun)"
+ )
+ if completion == "halted" and limit is not None and \
+ executed == limit:
violations.append(
f"{side_name}.dynamic: halted but executed==limit=="
- f"{dyn.get('limit')} (unverified halt)"
+ f"{limit} (unverified halt)"
)
elif source == "unavailable":
if not dyn.get("reason"):
@@ -1086,6 +1104,41 @@ def audit_provenance(report: dict) -> list[str]:
f"{side_name}.dynamic.source invalid: {source!r}"
)
+ out = side.get("output")
+ if out is None:
+ if side_name == "scratchv":
+ violations.append("scratchv.output missing")
+ elif not isinstance(out, dict):
+ violations.append(f"{side_name}.output must be a dict")
+ else:
+ partial = out.get("partial")
+ if not isinstance(partial, bool):
+ violations.append(f"{side_name}.output.partial must be a bool")
+ elif isinstance(completion, str) and \
+ partial != (completion != "halted"):
+ violations.append(
+ f"{side_name}.output.partial={partial} inconsistent with "
+ f"dynamic.completion={completion!r}"
+ )
+ out_completion = out.get("completion")
+ if not isinstance(out_completion, str) or not out_completion:
+ violations.append(f"{side_name}.output.completion missing")
+ if partial is False and out.get("raw_hex") is None:
+ violations.append(
+ f"{side_name}.output.raw_hex missing for a complete result"
+ )
+ q16 = out.get("q16_16")
+ if q16 is not None and not isinstance(q16, list):
+ violations.append(
+ f"{side_name}.output.q16_16 must be a list or null"
+ )
+ elif isinstance(q16, list) and isinstance(out.get("elements"), int) \
+ and len(q16) != out["elements"]:
+ violations.append(
+ f"{side_name}.output.q16_16 has {len(q16)} elements, "
+ f"expected {out['elements']}"
+ )
+
comparison = report.get("comparison") or {}
ratio = comparison.get("dynamic_instruction_ratio")
if ratio is not None:
@@ -1229,20 +1282,41 @@ def main(argv: list[str] | None = None) -> int:
else:
print(" [2/4] LLVM compilation (RV32IMF, float32)", file=sys.stderr)
llvm_compile = compile_llvm_rv32(args.model, str(out / "_ll_rv32.s"))
+ if llvm_compile["status"] == "failed":
+ llvm_reason = (
+ "llvm compile failed: "
+ f"{llvm_compile.get('reason') or llvm_compile.get('error')}"
+ )
+ elif llvm_compile["status"] == "skipped":
+ llvm_reason = (
+ f"llvm compile skipped: {llvm_compile.get('reason')}"
+ )
+ elif llvm_compile.get("isa_mismatch"):
+ llvm_reason = (
+ f"llvm isa mismatch: {llvm_compile.get('reason')} "
+ "(dynamic comparison disabled)"
+ )
+ else:
+ llvm_reason = (
+ "llvm executable image pipeline not implemented "
+ "(topic 25 boundary)"
+ )
llvm = {
"compile": llvm_compile,
"dynamic": {
"source": "unavailable",
"simulator": "tinyfive",
"completion": "not_run",
- "reason": (
- "llvm executable image pipeline not implemented "
- "(topic 25 boundary)"
- ),
+ "reason": llvm_reason,
"ops": None,
},
}
- if llvm_compile["status"] == "skipped":
+ if llvm_compile["status"] == "failed":
+ warnings.append(
+ "LLVM compilation failed: "
+ f"{llvm_compile.get('reason') or llvm_compile.get('error')}"
+ )
+ elif llvm_compile["status"] == "skipped":
warnings.append(
f"LLVM side skipped: {llvm_compile.get('reason')}"
)
@@ -1252,6 +1326,14 @@ def main(argv: list[str] | None = None) -> int:
"comparison disabled"
)
+ if effective_limit == 0:
+ warnings.append(
+ "full simulation requested; no automatic instruction-count or "
+ "wall-clock estimate is available (use --max-instructions N to "
+ "calibrate); results are marked partial if the wall-clock "
+ "timeout fires"
+ )
+
print(" [3/4] TinyFive simulation", file=sys.stderr)
try:
sim = run_simulation(
diff --git a/tests/test_rv32_bench.py b/tests/test_rv32_bench.py
index 4614f08..7a40729 100644
--- a/tests/test_rv32_bench.py
+++ b/tests/test_rv32_bench.py
@@ -105,6 +105,7 @@ def _fake_scratchv(*, completion="halted", limit=None, executed=100,
"output": {
"addr": 201326592, "elements": 36,
"raw_hex": "0x" + "00" * 144, "q16_16": [0.0] * 36,
+ "completion": completion, "partial": completion != "halted",
},
}
@@ -165,6 +166,13 @@ def test_full_run_small_model_matches_reference(tmp_path, mini_model,
assert dyn["completion"] == "halted"
assert dyn["executed"] > 0
assert dyn["ops"]["total"] == dyn["executed"]
+ assert any(
+ "no automatic" in w for w in report["warnings"]
+ ), report["warnings"]
+ out_block = report["scratchv"]["output"]
+ assert out_block["completion"] == "halted"
+ assert out_block["partial"] is False
+ assert isinstance(out_block["q16_16"], list)
assert rv32_bench.audit_provenance(report) == []
assert bench_report.validate_report_schema(report) == []
@@ -231,11 +239,16 @@ def test_budget_exhausted_is_labeled(tmp_path, mini_model):
assert dyn["ops"]["total"] == 1000
assert report["comparison"]["dynamic_instruction_ratio"] is None
assert "budget_exhausted" in report["comparison"]["incomparable_reason"]
+ out_block = report["scratchv"]["output"]
+ assert out_block["completion"] == "budget_exhausted"
+ assert out_block["partial"] is True
+ assert isinstance(out_block["q16_16"], list)
assert rv32_bench.audit_provenance(report) == []
assert bench_report.validate_report_schema(report) == []
markdown = (out / "rv32_bench.md").read_text()
assert "[measured/budget]" in markdown
+ assert "[measured/partial]" in markdown
assert "dynamic instruction ratio" not in markdown
rc_strict = rv32_bench.main([
@@ -246,6 +259,49 @@ def test_budget_exhausted_is_labeled(tmp_path, mini_model):
assert rc_strict == rv32_bench.EXIT_INCOMPLETE
+# ───────────────────────────────────────────────────────────────────────────
+# T2b: wall-clock timeout keeps partial counters and partial output
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_wall_clock_timeout_is_labeled_partial(tmp_path, mini_model):
+ pytest.importorskip("tinyfive")
+ out = tmp_path / "timeout"
+ rc = rv32_bench.main([
+ str(mini_model), "--quiet", "--output-dir", str(out),
+ "--timeout", "0.05", "--chunk-instructions", "4096",
+ ])
+ assert rc == rv32_bench.EXIT_OK
+
+ report = json.loads((out / "rv32_bench.json").read_text())
+ dyn = report["scratchv"]["dynamic"]
+ assert dyn["source"] == "simulated"
+ assert dyn["completion"] == "timeout"
+ assert dyn["limit"] is None
+ assert dyn["executed"] > 0
+ assert dyn["ops"]["total"] == dyn["executed"]
+ assert report["comparison"]["dynamic_instruction_ratio"] is None
+ assert "timeout" in report["comparison"]["incomparable_reason"]
+ assert any("timeout" in w for w in report["warnings"])
+
+ out_block = report["scratchv"]["output"]
+ assert out_block["completion"] == "timeout"
+ assert out_block["partial"] is True
+ assert isinstance(out_block["q16_16"], list)
+ assert rv32_bench.audit_provenance(report) == []
+ assert bench_report.validate_report_schema(report) == []
+
+ markdown = (out / "rv32_bench.md").read_text()
+ assert "[measured/timeout]" in markdown
+ assert "[measured/partial]" in markdown
+
+ rc_strict = rv32_bench.main([
+ str(mini_model), "--quiet", "--fail-on-incomplete",
+ "--output-dir", str(tmp_path / "timeout_strict"),
+ "--timeout", "0.05", "--chunk-instructions", "4096",
+ ])
+ assert rc_strict == rv32_bench.EXIT_INCOMPLETE
+
+
# ───────────────────────────────────────────────────────────────────────────
# T3: missing simulator never fabricates dynamic data
# ───────────────────────────────────────────────────────────────────────────
@@ -277,6 +333,11 @@ def test_report_requires_provenance(tmp_path, mini_model, monkeypatch):
assert report["scratchv"]["compile"]["static_insns"] > 0
assert report["scratchv"]["compile"]["static_source"] == "asm_scan"
assert report["comparison"]["dynamic_instruction_ratio"] is None
+ out_block = report["scratchv"]["output"]
+ assert out_block["completion"] == "not_run"
+ assert out_block["partial"] is True
+ assert out_block["raw_hex"] is None
+ assert out_block["q16_16"] is None
assert rv32_bench.audit_provenance(report) == []
assert bench_report.validate_report_schema(report) == []
@@ -372,6 +433,47 @@ def test_llvm_riscv64_flagged_isa_mismatch():
assert "RV32IMF" not in markdown
+# ───────────────────────────────────────────────────────────────────────────
+# T6b: a failed LLVM compile is surfaced, not silently swallowed
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_llvm_compile_failure_is_surfaced(tmp_path, mini_model, monkeypatch):
+ failure_reason = "RuntimeError: no rv32 target available"
+
+ def _failed(*_args, **_kwargs):
+ return {
+ "status": "failed", "reason": failure_reason,
+ "isa_detected": None, "isa_mismatch": False,
+ "static_insns": 0, "static_source": "asm_scan", "elapsed_s": 0.0,
+ }
+
+ monkeypatch.setattr(rv32_bench, "compile_llvm_rv32", _failed)
+ out = tmp_path / "llvmfail"
+ rc = rv32_bench.main([
+ str(mini_model), "--quiet", "--output-dir", str(out),
+ "--max-instructions", "500", "--timeout", "60",
+ "--chunk-instructions", "4096",
+ ])
+ assert rc == rv32_bench.EXIT_OK
+
+ report = json.loads((out / "rv32_bench.json").read_text())
+ assert report["llvm"]["compile"]["status"] == "failed"
+ assert any(
+ "LLVM compilation failed" in w and failure_reason in w
+ for w in report["warnings"]
+ ), report["warnings"]
+ assert failure_reason in report["llvm"]["dynamic"]["reason"]
+ assert report["errors"] == []
+
+ markdown = (out / "rv32_bench.md").read_text()
+ assert failure_reason in markdown
+ row = next(
+ line for line in markdown.splitlines()
+ if line.startswith("| static insns [asm_scan] |")
+ )
+ assert row.rstrip().endswith("| — |")
+
+
# ───────────────────────────────────────────────────────────────────────────
# T7: the provenance audit rejects static counts masquerading as dynamic
# ───────────────────────────────────────────────────────────────────────────
@@ -401,10 +503,15 @@ def test_audit_provenance_rejects_static_fallback():
"ops": {"total": 1000, "load": 0, "store": 0, "mul": 0,
"add": 0, "madd": 0, "branch": 0},
}
+ ratio_from_truncated["scratchv"]["output"] = {
+ "addr": 201326592, "elements": 36, "raw_hex": None,
+ "q16_16": None, "completion": "budget_exhausted", "partial": True,
+ }
ratio_from_truncated["comparison"] = {
"dynamic_instruction_ratio": 0.31, "incomparable_reason": None,
}
- assert rv32_bench.audit_provenance(ratio_from_truncated)
+ ratio_violations = rv32_bench.audit_provenance(ratio_from_truncated)
+ assert any("incomplete/non-simulated" in v for v in ratio_violations)
forged_halt = deepcopy(clean)
forged_halt["scratchv"]["dynamic"].update(
@@ -418,6 +525,59 @@ def test_audit_provenance_rejects_static_fallback():
for v in rv32_bench.audit_provenance(forged_halt)
)
+ counters_disagree = deepcopy(clean)
+ counters_disagree["scratchv"]["dynamic"].update(
+ {"executed": 100, "ops": {
+ "total": 9999, "load": 0, "store": 0, "mul": 0,
+ "add": 0, "madd": 0, "branch": 0,
+ }},
+ )
+ assert any(
+ "ops.total" in v
+ for v in rv32_bench.audit_provenance(counters_disagree)
+ )
+
+ budget_overrun = deepcopy(clean)
+ budget_overrun["scratchv"]["dynamic"].update(
+ {"completion": "halted", "limit": 50, "executed": 100, "ops": {
+ "total": 100, "load": 0, "store": 0, "mul": 0,
+ "add": 0, "madd": 0, "branch": 0,
+ }},
+ )
+ assert any(
+ "budget overrun" in v
+ for v in rv32_bench.audit_provenance(budget_overrun)
+ )
+
+ unmasked_output = deepcopy(clean)
+ unmasked_output["scratchv"]["dynamic"]["completion"] = "timeout"
+ assert any(
+ "output.partial" in v
+ for v in rv32_bench.audit_provenance(unmasked_output)
+ )
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# T7b: output.q16_16 has one stable type (list) regardless of element count
+# ───────────────────────────────────────────────────────────────────────────
+
+def test_output_q16_16_is_always_a_list():
+ class _StubMachine:
+ available = True
+
+ def __init__(self, words):
+ self.words = words
+
+ def read_mem_i32(self, addr):
+ return self.words[addr // 4]
+
+ single = rv32_bench._read_output(_StubMachine([-65536]), 0, 1)
+ assert isinstance(single["q16_16"], list)
+ assert single["q16_16"] == [-1.0]
+
+ pair = rv32_bench._read_output(_StubMachine([65536, -131072]), 0, 2)
+ assert pair["q16_16"] == [1.0, -2.0]
+
# ───────────────────────────────────────────────────────────────────────────
# T8: schema validation covers every required provenance key
@@ -431,8 +591,8 @@ def test_bench_report_schema_required_keys():
joined = "\n".join(empty_errors)
for key in (
"schema_version", "model.sha256", "environment", "targets",
- "scratchv.compile", "scratchv.dynamic", "llvm.compile",
- "llvm.dynamic", "comparison",
+ "scratchv.compile", "scratchv.dynamic", "scratchv.output",
+ "llvm.compile", "llvm.dynamic", "comparison",
):
assert key in joined, f"{key} missing from {empty_errors}"
@@ -456,3 +616,10 @@ def test_bench_report_schema_required_keys():
"comparison.incomparable_reason" in e
for e in bench_report.validate_report_schema(bad_ratio)
)
+
+ no_output_partial = deepcopy(report)
+ del no_output_partial["scratchv"]["output"]["partial"]
+ assert any(
+ "scratchv.output.partial" in e
+ for e in bench_report.validate_report_schema(no_output_partial)
+ )
From 7887bdd20dc996e5b1160b54e54cd817d793d17e Mon Sep 17 00:00:00 2001
From: Seven Gao <799889633@qq.com>
Date: Mon, 14 Sep 2026 22:46:47 +0800
Subject: [PATCH 4/5] docs(topic27): sync design and development docs with
implementation
- document the instance-level exe shim that stops at halt_addr or the
instruction budget, and drop the claim that chunking is the only
mechanism available without touching ProfiledMachine
- replace the unimplemented estimated_* wall-clock promise with the
actual full-mode warning and the documented absence of auto-estimation
- fix the run_simulation/parse_labels signatures, the error-path field
shape, llvmlite-unavailable-vs-failed semantics, and the output
provenance fields (q16_16 list, completion, partial)
---
...00\345\217\221\346\226\207\346\241\243.md" | 32 +++++++++++--------
...76\350\256\241\346\226\207\346\241\243.md" | 15 +++++----
2 files changed, 27 insertions(+), 20 deletions(-)
diff --git "a/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\345\274\200\345\217\221\346\226\207\346\241\243.md"
index 35f2557..7ceda61 100644
--- "a/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\345\274\200\345\217\221\346\226\207\346\241\243.md"
+++ "b/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\345\274\200\345\217\221\346\226\207\346\241\243.md"
@@ -34,7 +34,7 @@
| C5 | 新增 | `load_scratchv_image()`:`load_binary(code_words, 0)` + `load_data(weights, data_offset)` + `load_data(input_blob, 160MiB)` | 权重与输入真实装载 |
| C6 | `:160-184` `_prepare_asm_for_tinyfive` | 删除;新增 `parse_labels()`,标签不再被过滤,`.s` 只用于标签映射与静态计数,不用于装载代码 | 修复分支自跳转 |
| C7 | `:187-234` `_tinyfive_static_fallback` | 删除;静态计数改由 `static_instruction_mix()` 产出,字段独立为 `static_instruction_mix` | 静态数不冒充动态数 |
-| C8 | `:118-157` `run_tinyfive` | 替换为分块执行器:`chunk` 循环 + `m.pc == halt_addr` 停机检测 + `SIGALRM` 超时 + 完整 provenance 输出 | 停机/预算/超时可控 |
+| C8 | `:118-157` `run_tinyfive` | 替换为分块执行器:`chunk` 循环 + 实例级 `exe` 停机 shim(`halt_addr`/预算先到者停)+ `SIGALRM` 超时 + 完整 provenance 输出 | 停机/预算/超时可控 |
| C9 | `:58-104` `compile_llvm_rv32` | llvmlite 可用时 `llmod.triple="riscv32-unknown-elf"`;新增 `detect_isa_mismatch()`;静态计数只扫 `.text`;失败原因入档 | 统一 RV32 口径与诚实降级 |
| C10 | `:241-312` `BenchResult` / `generate_report` | 改为 `build_report()`(schema v2 dict)+ `bench_report.render_*(report)`;删除硬编码模型描述、无依据 ratio 与 “No analytical estimates” 文案 | 诚实报告 |
| C11 | `:383-425` `main` | 新增 CLI 参数、退出码、`audit_provenance` 写盘前检查、`--quiet` | 接口规格化 |
@@ -55,17 +55,18 @@ sv.tinyfive = run_tinyfive(str(out / "_sv.s"), n_instructions=5000) # :411
```python
def run_simulation(
*,
- asm_path: str,
+ asm_path: str, # 登记用;代码装载走 binary_path
binary_path: str,
+ data_offset: int,
+ workspace_bytes: int,
+ input_elements: int,
+ output_elements: int,
max_instructions: int = 0, # 0 = 全量
mem_size: int = 268435456,
timeout_s: float = 900.0,
chunk_instructions: int = 10_000_000,
input_seed: int = 42,
- input_elements: int,
- output_elements: int,
- halt_addr: int,
-) -> dict: ...
+) -> dict: ... # -> {"dynamic": …, "output": …}; halt_addr 由 compute_layout 内部计算
```
调用点:
@@ -108,7 +109,7 @@ CLI 语义:`--max-instructions 0`(默认)与 `--full` 等价;两者与 `
`.s` 格式:标签独占一行(列 0,`name:`),指令行缩进两格并可能带 `# 注释`(`RISCVEmitter.disassemble()`,`onnx_to_riscv_standalone.py:1291-1305`)。
```python
-def parse_labels(asm_text: str) -> dict[int, str]:
+def parse_labels(asm_text: str, expected_code_bytes: int) -> dict[int, str]:
pc, labels, seen_tokens = 0, {}, []
for raw in asm_text.splitlines():
line = raw.split("#", 1)[0].rstrip()
@@ -143,14 +144,14 @@ def parse_labels(asm_text: str) -> dict[int, str]:
| 助记符预检失败 | 子集扫描 `.s` vs TinyFive 支持表 | `completion="not_run"`,exit 4,列出不支持助记符 |
| 预算耗尽 | 循环额度判断 | `completion="budget_exhausted"`,`executed==limit`,ratio `null`,exit 0(显式预算属正常) |
| 超时 | `SIGALRM` + `_timed_out` 标志 | `completion="timeout"`,保留部分 ops,exit 0(`--fail-on-incomplete` 时 exit 7) |
-| `m.last_error` 非空且非超时 | 适配器 `strict=True` 抛错 | `completion="error"`,`dynamic=null`,exit 1 |
+| `m.last_error` 非空且非超时 | 适配器 `strict=True` 抛错 | `source="unavailable"` + `completion="error"`,`ops=null`,`output.partial=true`,exit 1 |
| 未完成且要求严格 | `--fail-on-incomplete` | exit 7 |
静态兜底已删除:`static_instruction_mix` 只写入 `scratchv.static_instruction_mix`,字段名带 `static_`,永不出现在 `dynamic.ops`。
### 2.6 LLVM 侧改动(C9)
-1. `binding.Target.from_triple("riscv32-unknown-elf")`;`ImportError`/`RuntimeError` → `status="skipped"`,`reason` 记录原始异常。llvmlite 当前环境未安装,该分支即 `skipped`。
+1. `binding.Target.from_triple("riscv32-unknown-elf")`;`ImportError`(llvmlite 未安装)→ `status="skipped"`,`reason` 记录原始异常;成功导入后的目标机/IR/发射阶段异常(如 `RuntimeError`)→ `status="failed"`,`reason` 记录真实原因,`main` 追加 warning 并把它写入 `llvm.dynamic.reason`(不再统一硬编码为 “pipeline not implemented”)。llvmlite 当前环境未安装,该分支即 `skipped`。
2. 成功路径。解析 IR 文本后,用 llvmlite API 覆盖模块头:
```python
llmod = binding.parse_assembly(ir_text)
@@ -215,6 +216,8 @@ def parse_labels(asm_text: str) -> dict[int, str]:
Simulated by tinyfive {environment.tinyfive} | completion={completion} | executed={executed}
| limit={limit} | memory={memory_size_bytes} | seed={input_seed} | halt=0x{halt_addr:x}
| model sha256={model.sha256} | binary sha256={scratchv.compile.binary_sha256}
+output [measured | measured/partial | unavailable]: {output.raw_hex}
+| elements={output.elements} | completion={output.completion}
```
渲染规则:
@@ -238,7 +241,7 @@ Simulated by tinyfive {environment.tinyfive} | completion={completion} | execute
| `scratchv.compile.status/binary/binary_bytes/binary_sha256/code_bytes/data_offset/data_offset_source/data_bytes/workspace_bytes/static_insns/static_source/elapsed_s` | — | static | 编译与镜像 |
| `scratchv.static_instruction_mix.{source,load,store,mul,add,madd,branch,other}` | int | static | 静态助记符分布 |
| `scratchv.dynamic.{source,simulator,simulator_version,completion,limit,executed,timeout_s,elapsed_s,memory_size_bytes,input_seed,input_elements,halt_addr,ops{total,load,store,mul,add,madd,branch},x_registers_used,x_usage_total,f_registers_used,per_label,per_label_note,last_error}` | — | measured | 动态执行 |
-| `scratchv.output.{addr,elements,raw_hex,q16_16}` | — | measured | 输出读出 |
+| `scratchv.output.{addr,elements,raw_hex,q16_16,completion,partial}` | — | measured | 输出读出;`q16_16` 恒为 list(不可用时为 null);`completion != "halted"` 时 `partial=true`,Markdown/HTML 的 Provenance 段落标 `[measured/partial]` |
| `llvm.compile.{status,reason,isa_detected,isa_mismatch,static_insns,static_source,elapsed_s}` | — | static/unavailable | LLVM 侧 |
| `llvm.dynamic.{source,simulator,completion,reason,ops}` | — | unavailable | 失败必须 `ops=null` |
| `comparison.{dynamic_instruction_ratio,incomparable_reason}` | float\|null | measured 派生 | 比值规则见设计文档 2.1.1 |
@@ -265,7 +268,7 @@ Simulated by tinyfive {environment.tinyfive} | completion={completion} | execute
### 4.2 `benchmark.py`(只读使用)
-- `estimate_cnn_model(model_spec=None) -> dict`:仅用于 `warnings` 与“预计墙钟”提示;字段进入 `estimated` 分类,前缀 `estimated_`。不得写入 `dynamic`。
+- `estimate_cnn_model(model_spec=None) -> dict`:**未接入 `rv32_bench.py`**(本课题不做自动预估);仅 `benchmark.py` 自身 CLI 使用。若未来引入,其字段必须进入 `estimated` 分类,前缀 `estimated_`,不得写入 `dynamic`。
- `RV32EmulatorFast` / `run_benchmark`:仅测试用例 1 作为独立功能对照(`load_unified_binary(binary, code_size_base=..., load_addr=0)` + `run(max_instr=...)`);其计数分类(`Cat_*`)与 TinyFive 不同,只对照 `total/load_count/store_count/branch_total`。若未来作为正式数据源,须以 `simulator="rv32_emulator_fast"` 独立字段呈现,不与 TinyFive 混算。
- `estimate_cnn_instructions` 的 per-MAC 常量(`CONV_INSNS_PER_MAC=8` 等)属解析模型,禁止用于 `measured`。
@@ -399,7 +402,7 @@ make test
| # | 风险 | 影响 | 缓解/回退 |
|---|------|------|-----------|
-| R1 | TinyFive 吞吐低(Python 解释执行),全量 18.5 亿指令可能需数小时 | 全量不可行 | `--max-instructions` 预算并如实标注;先跑 1M 探测得到 MIPS 预估墙钟,写入 `warnings` |
+| R1 | TinyFive 吞吐低(Python 解释执行),全量 18.5 亿指令可能需数小时 | 全量不可行 | `--max-instructions` 预算并如实标注;**未实现自动预估**(无 1M 探测/`estimate_cnn_model` 接入),full 模式启动前只写一条“无自动预估、可用预算校准”的 warning;超时/预算中断的结果标 `partial` |
| R2 | 256MiB numpy 内存 + 26MB 权重 | 内存压力 | 布局不可压缩(ABI 地址固定);内存不足只能拒绝并提示,禁止缩容假装成功 |
| R3 | `ra` 被生成代码内部 `jal ra, …` 覆盖,`halt_addr` 永不命中 | 无法 `halted` | `completion` 如实标 `budget_exhausted/timeout`;`--fail-on-incomplete` 供 CI |
| R4 | 平台无 `SIGALRM` | 超时保护缺失 | 退化为要求 `--max-instructions`;否则拒绝启动 |
@@ -443,5 +446,6 @@ make test
### 已知限制
- 对 TinyFive 机器实例的 NumPy 2.x `LW/LH` 兼容 shim 属课题 26 追修(`tinyfive.py` 未改)。
-- 错误路径统一用 `source=unavailable` 表达(而非 `dynamic=null`)。
-- LLVM 侧无 llvmlite 时 `status=skipped`、不打印比值数字。
+- 停机采用实例级 `exe` 重绑 shim(`halt_addr` 与指令预算先到者停),同样未改 `tinyfive.py`;设计文档 2.1.4 已登记该机制。
+- 错误路径统一用 `source=unavailable` 表达(而非 `dynamic=null`),`output.partial=true`。
+- LLVM 侧无 llvmlite 时 `status=skipped`;导入后目标机/IR 失败为 `status=failed` 并带真实 `reason`(main 写 warning)。两种情况都不打印比值数字。
diff --git "a/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\350\256\276\350\256\241\346\226\207\346\241\243.md"
index 234bde1..911bf48 100644
--- "a/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\350\256\276\350\256\241\346\226\207\346\241\243.md"
+++ "b/docs/topics/27-RV32\345\205\250\351\207\217Benchmark-\350\256\276\350\256\241\346\226\207\346\241\243.md"
@@ -63,7 +63,7 @@ completion ::= "halted" | "budget_exhausted" | "timeout" | "error" | "not_run"
规则:
- `--max-instructions 0` 与 `--full` 等价,均为无截断语义;`limit=null`、`executed` 为真实执行数。
-- 分块循环使用 `--chunk-instructions`(默认 10,000,000)作为每块大小;块间检查停机与超时。这是在不修改 `ProfiledMachine` 公共接口的前提下获得“停机检测 + 进度 + 超时”的唯一手段。
+- 分块循环使用 `--chunk-instructions`(默认 10,000,000)作为每块大小;块间检查停机、墙钟与预算。块内精确停机由实例级 `exe` shim(2.1.4)保证,两者都不修改 `ProfiledMachine` 公共接口。
- `budget_exhausted` 时 `executed == limit` 必须为等式不变量;`halted` 时 `limit` 可为 `null`(full)或任意(budgeted 提前停机)。
- 超时只在 `--full` 模式下产生 `timeout`;`budgeted` 模式下先到限额即结束,超时属于异常保护(同样标 `timeout`)。
- 派生指标约束:`comparison.dynamic_instruction_ratio` 仅当两侧 `source=="simulated"` 且 `completion=="halted"` 时计算;任一 `budget_exhausted/timeout` 一律 `null` 并写 `incomparable_reason`。
@@ -109,17 +109,19 @@ data_size ::= len(weight_bytes) = len(.bin) − data_offset
#### 2.1.4 停机条件
-- 生成代码结尾为 `_done: ret`(`jalr x0, x1, 0`,`onnx_to_riscv_standalone.py:1553-1556`)。harness 在运行前设 `x1 = halt_addr`,当 PC 到达 `halt_addr` 即判定 `halted`。TinyFive 的 `exe(start, end)` 原生支持按 end 地址停止;`ProfiledMachine` 未暴露该参数,故采用 2.1.1 的分块 `instructions=` 循环 + 每块后检查 `m.pc`。
+- 生成代码结尾为 `_done: ret`(`jalr x0, x1, 0`,`onnx_to_riscv_standalone.py:1553-1556`)。harness 在运行前设 `x1 = halt_addr`,当 PC 到达 `halt_addr` 即判定 `halted`。
+- TinyFive 的 `exe(start, end)` 原生支持按 end 地址停止,但 `ProfiledMachine` 未暴露该参数,且其 `exe` 在给定 `end` 时会忽略 `instructions` 预算。harness 因此只对**机器实例**重绑 `exe`:每步执行前同时检查 `pc == halt_addr` 与指令额度,二者先到者停(`_install_tinyfive_compat`,`rv32_bench.py`);`scratchv/simulator/tinyfive.py` 与 `ProfiledMachine` 公共接口保持不变。
+- 外层仍以 2.1.1 的分块 `m.run(instructions=chunk, start=pc, strict=True)` 循环驱动,块间做墙钟检查、预算记账与 `pc` 复核;实例级 shim 保证单块内不会越过停机地址空转解码(否则每次解码都会计入 `ops.total`,虚增动态计数)。
- 若生成代码在返回前用 `jal ra, …` 覆盖了 `ra`,或跳转路径异常,PC 永远不会命中 `halt_addr`:`budgeted` 记 `budget_exhausted`,`full` 记 `timeout`。不得谎报 `halted`。
- TinyFive 遇到不支持的指令时打印错误且 PC 不前进(`dec()` 无匹配分支时不调用 `ipc()`),会表现为“卡死”。启动前必须做**助记符白名单预检**:用 ScratchV 的 `_disasm_one` 解析每个 code word,若出现 TinyFive 不支持/无法识别的助记符 → `completion="not_run"`,`exit 4`,禁止开跑。
-- `m.last_error` 非空(适配器捕获到异常)→ `completion="error"`,`dynamic` 整体置 `null`。
+- `m.last_error` 非空(适配器捕获到异常)→ `completion="error"`,该侧 `dynamic.source="unavailable"`、`ops=null`。
#### 2.1.5 超时与预算保护
- `--timeout SECONDS`(默认 900)为单侧仿真墙钟上限。实现:主线程 `signal.setitimer(ITIMER_REAL, remaining)` + `SimulationTimeout` 处理器;`m.run(..., strict=True)` 使适配器 `finally` 仍在异常路径更新 `instr_count`,ops 计数器保留部分值。
- 超时判定不依赖异常类型(适配器会把异常包装成 `RuntimeError`):置模块级 `_timed_out` 标志,捕获后据此写 `completion="timeout"`,并把 `elapsed_s`、`executed` 落盘。
- 平台不支持 `SIGALRM`(如 Windows)时:`--timeout` 退化为告警,要求用户必须给 `--max-instructions`;否则拒绝启动并提示替代方案。
-- **预算预估仅作告警**:`benchmark.estimate_cnn_model()` 的输出只能进入 `warnings` 与 “预计墙钟” 提示,字段名带 `estimated_` 前缀,绝不填入 `dynamic`。
+- **不做自动预估**:本驱动不运行 1M 探测,也不调用 `benchmark.estimate_cnn_model()`(该解析模型只属于 `benchmark.py` 自身 CLI),因此报告中不存在 `estimated_*` 墙钟数字。full 模式启动前只在 `warnings` 写一条显式提示:无自动预估、可用 `--max-instructions N` 校准、墙钟超时触发时结果如实标 partial。若未来引入任何预估值,字段名必须带 `estimated_` 前缀,且绝不填入 `dynamic`。
### 2.2 诚实报告规范
@@ -437,7 +439,8 @@ while True:
"per_label_note": "tinyfive exe() exposes no per-PC trace",
"last_error": null
},
- "output": {"addr": 201326592, "elements": 1, "raw_hex": "0x00018000", "q16_16": 1.5}
+ "output": {"addr": 201326592, "elements": 1, "raw_hex": "0x00018000",
+ "q16_16": [1.5], "completion": "halted", "partial": false}
},
"llvm": {
"compile": {"status": "skipped", "reason": "llvmlite not available",
@@ -452,7 +455,7 @@ while True:
"incomparable_reason": "llvm.dynamic.source=='unavailable'"
},
"warnings": [
- "estimated dynamic instructions (analytical): 1.85e9 — used for wall-clock warning only"
+ "full simulation requested; no automatic instruction-count or wall-clock estimate is available (use --max-instructions N to calibrate); results are marked partial if the wall-clock timeout fires"
],
"errors": []
}
From e0a70529b25d4125306e89ee60749be115806f42 Mon Sep 17 00:00:00 2001
From: opencode
Date: Tue, 15 Sep 2026 00:59:17 +0800
Subject: [PATCH 5/5] feat(topic27): add RV32-bench feature case report and CI
regressions
---
.github/workflows/ci.yml | 18 +
benchmarks/run_topic27_rv32_bench_case.py | 682 +++++++++++++++++++
tests/test_topic27_rv32_bench_case_report.py | 204 ++++++
3 files changed, 904 insertions(+)
create mode 100644 benchmarks/run_topic27_rv32_bench_case.py
create mode 100644 tests/test_topic27_rv32_bench_case_report.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 15aae4b..8a8daca 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,6 +107,13 @@ jobs:
run: |
python3.12 -m pytest tests/test_pr37_regression.py -v --tb=short
+ - name: Run topic27 RV32-bench regressions
+ run: |
+ python3.12 -m pytest \
+ tests/test_rv32_bench.py \
+ tests/test_topic27_rv32_bench_case_report.py \
+ -v --tb=short
+
- name: Generate test visualization page
if: github.ref == 'refs/heads/main'
run: |
@@ -218,6 +225,14 @@ jobs:
--json benchmark_reports/const_merge_report.json \
--markdown benchmark_reports/const_merge_report.md
+ # ── 3.1.3 课题27:RV32 bench case 报告(诚实 provenance + 降级) ──
+ - name: Topic 27 RV32 bench case report
+ run: |
+ mkdir -p benchmark_reports
+ python3.12 benchmarks/run_topic27_rv32_bench_case.py \
+ --json benchmark_reports/rv32_bench_case_report.json \
+ --markdown benchmark_reports/rv32_bench_case_report.md
+
# ── 3.2 DSL 用例编译 + 模拟基准 ────────────────────────────────────
- name: DSL case compilation benchmarks
run: |
@@ -363,6 +378,9 @@ jobs:
if [ -f benchmark_reports/const_merge_report.md ]; then
cat benchmark_reports/const_merge_report.md >> $GITHUB_STEP_SUMMARY
fi
+ if [ -f benchmark_reports/rv32_bench_case_report.md ]; then
+ cat benchmark_reports/rv32_bench_case_report.md >> $GITHUB_STEP_SUMMARY
+ fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f benchmark_reports/github_summary.md ]; then
cat benchmark_reports/github_summary.md >> $GITHUB_STEP_SUMMARY
diff --git a/benchmarks/run_topic27_rv32_bench_case.py b/benchmarks/run_topic27_rv32_bench_case.py
new file mode 100644
index 0000000..c19c5ca
--- /dev/null
+++ b/benchmarks/run_topic27_rv32_bench_case.py
@@ -0,0 +1,682 @@
+#!/usr/bin/env python3
+"""Topic 27 RV32-bench feature case: drive ``rv32_bench`` and audit its report.
+
+The case runs the real ``rv32_bench.main`` driver on one tiny deterministic
+ONNX model (3x3 Conv, 8x8 -> 6x6, fixed seed) and proves the honest-report
+contract end to end:
+
+1. ``rv32_bench.{json,md,html}`` artifacts are written and the JSON passes
+ ``bench_report.validate_report_schema``;
+2. ``completion`` stays inside the documented enumeration and a null
+ comparison ratio always carries an ``incomparable_reason``;
+3. ``audit_provenance`` rejects forged reports, so the honesty gate is not
+ vacuous;
+4. budget truncation (``--max-instructions``) and wall-clock timeout are
+ labeled honestly, or explicitly reported as ``not_run`` when the
+ simulator is missing.
+
+CI has no LLVM toolchain (and may lack TinyFive), so every run uses
+``--allow-missing-simulator --skip-llvm`` and degrades to a static-only
+report instead of inventing dynamic numbers. This is a deterministic
+feature/integration case, not a performance claim: the embedded dynamic
+counts are TinyFive emulator instruction counts on a toy model.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import sys
+import tempfile
+import time
+from copy import deepcopy
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from scratchv.standalone import bench_report, rv32_bench
+
+SCHEMA_VERSION = "topic27-rv32-bench-case/1"
+RV32_SCHEMA_VERSION = "rv32-bench/2"
+DEFAULT_JSON = Path("benchmark_reports/rv32_bench_case_report.json")
+DEFAULT_MARKDOWN = Path("benchmark_reports/rv32_bench_case_report.md")
+
+ALLOWED_COMPLETIONS = frozenset({
+ "halted", "budget_exhausted", "timeout", "error", "not_run",
+})
+
+CASE_BUILDER = "tiny_conv_8x8_k3"
+BUDGET_LIMIT = 1000
+TIMEOUT_PROBE_S = 0.01
+TIMEOUT_PROBE_CHUNK = 1
+RUN_CHUNK = 4096
+RUN_TIMEOUT_S = 120.0
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Case model
+# ═══════════════════════════════════════════════════════════════════════════
+
+def build_case_model(path: str | Path) -> Path:
+ """Write the deterministic tiny Conv ONNX model used by the case."""
+ import numpy as np
+ import onnx
+ from onnx import TensorProto, helper, numpy_helper
+
+ path = Path(path)
+ rng = np.random.RandomState(0)
+ inp = helper.make_tensor_value_info(
+ "input", TensorProto.FLOAT, [1, 1, 8, 8])
+ out = helper.make_tensor_value_info(
+ "output", TensorProto.FLOAT, [1, 1, 6, 6])
+ weight = numpy_helper.from_array(
+ (rng.randn(1, 1, 3, 3).astype(np.float32) * 0.1), "W")
+ bias = numpy_helper.from_array(np.zeros(1, np.float32), "B")
+ node = helper.make_node(
+ "Conv", ["input", "W", "B"], ["output"],
+ kernel_shape=[3, 3], pads=[0, 0, 0, 0], strides=[1, 1],
+ )
+ graph = helper.make_graph(
+ [node], "topic27_rv32_bench_case", [inp], [out], [weight, bias])
+ model = helper.make_model(
+ graph, opset_imports=[helper.make_opsetid("", 13)])
+ onnx.save(model, str(path))
+ return path
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Driver invocation
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _run_rv32(model_path: Path, out_dir: Path, extra_args: list[str], *,
+ timeout_s: float = RUN_TIMEOUT_S,
+ chunk: int = RUN_CHUNK) -> dict[str, Any]:
+ """Invoke the real ``rv32_bench.main`` in-process and collect artifacts."""
+ out_dir.mkdir(parents=True, exist_ok=True)
+ argv = [
+ str(model_path),
+ "--quiet",
+ "--output-dir", str(out_dir),
+ "--allow-missing-simulator",
+ "--skip-llvm",
+ "--timeout", str(timeout_s),
+ "--chunk-instructions", str(chunk),
+ ] + [str(arg) for arg in extra_args]
+ started = time.perf_counter()
+ status = "ok"
+ error: str | None = None
+ exit_code: int | None = None
+ try:
+ exit_code = rv32_bench.main(argv)
+ except Exception as exc:
+ status = "exception"
+ error = f"{type(exc).__name__}: {exc}"[:300]
+ elapsed = time.perf_counter() - started
+
+ json_path = out_dir / "rv32_bench.json"
+ md_path = out_dir / "rv32_bench.md"
+ html_path = out_dir / "rv32_bench.html"
+ rv_report: dict | None = None
+ if json_path.is_file():
+ try:
+ rv_report = json.loads(json_path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ status = "invalid_json"
+ error = str(exc)[:300]
+
+ return {
+ "argv": argv,
+ "exit_code": exit_code,
+ "status": status,
+ "error": error,
+ "elapsed_s": round(elapsed, 4),
+ "artifacts": {
+ "json": str(json_path),
+ "markdown": str(md_path),
+ "html": str(html_path),
+ "json_written": json_path.is_file(),
+ "markdown_written": md_path.is_file(),
+ "html_written": html_path.is_file(),
+ "json_bytes": (
+ json_path.stat().st_size if json_path.is_file() else None),
+ "json_sha256": (
+ _sha256(json_path) if json_path.is_file() else None),
+ },
+ "report": rv_report,
+ }
+
+
+def _probe(model_path: Path, out_dir: Path, extra_args: list[str],
+ expected: str, *, timeout_s: float = RUN_TIMEOUT_S,
+ chunk: int = RUN_CHUNK) -> dict[str, Any]:
+ """Run one truncation probe and summarize its honesty evidence."""
+ run = _run_rv32(
+ model_path, out_dir, extra_args, timeout_s=timeout_s, chunk=chunk)
+ rv = run.pop("report")
+ probe: dict[str, Any] = {"expected": expected, **run}
+ if rv is None:
+ probe.update({
+ "status": "no_report",
+ "note": run.get("error") or "rv32 report not written",
+ "completion": None, "source": None, "limit": None,
+ "executed": None, "ops_total": None, "partial": None,
+ "ratio": None, "incomparable_reason": None,
+ "schema_errors": ["rv32 report not written"],
+ "audit_violations": ["rv32 report not written"],
+ })
+ return probe
+
+ dyn = (rv.get("scratchv") or {}).get("dynamic") or {}
+ cmp_ = rv.get("comparison") or {}
+ output = (rv.get("scratchv") or {}).get("output") or {}
+ probe.update({
+ "completion": dyn.get("completion"),
+ "source": dyn.get("source"),
+ "reason": dyn.get("reason"),
+ "limit": dyn.get("limit"),
+ "executed": dyn.get("executed"),
+ "ops_total": (dyn.get("ops") or {}).get("total"),
+ "partial": output.get("partial"),
+ "ratio": cmp_.get("dynamic_instruction_ratio"),
+ "incomparable_reason": cmp_.get("incomparable_reason"),
+ "schema_errors": bench_report.validate_report_schema(rv),
+ "audit_violations": rv32_bench.audit_provenance(rv),
+ })
+ if dyn.get("source") == "unavailable" and \
+ dyn.get("completion") == "not_run":
+ probe["status"] = "not_run"
+ probe["note"] = dyn.get("reason") or "simulator unavailable"
+ elif dyn.get("completion") == expected:
+ probe["status"] = "confirmed"
+ probe["note"] = None
+ else:
+ probe["status"] = "unexpected"
+ probe["note"] = (
+ f"expected completion={expected!r}, got {dyn.get('completion')!r}")
+ return probe
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Audit probe
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _tamper(field: str, violations: list[str]) -> dict[str, Any]:
+ return {
+ "field": field,
+ "violations": violations,
+ "rejected": bool(violations),
+ }
+
+
+def _audit_probe(rv_report: dict | None) -> dict[str, Any]:
+ """Forge a copied report and prove ``audit_provenance`` rejects it."""
+ if not rv_report:
+ return {
+ "tampers": [],
+ "rejected": False,
+ "note": "rv32 report not written; audit gate cannot be probed",
+ }
+ tampers = []
+
+ ratio_forgery = deepcopy(rv_report)
+ ratio_forgery["comparison"] = {
+ "dynamic_instruction_ratio": 0.31, "incomparable_reason": None,
+ }
+ tampers.append(_tamper(
+ "comparison.dynamic_instruction_ratio=0.31",
+ rv32_bench.audit_provenance(ratio_forgery),
+ ))
+
+ schema_forgery = deepcopy(rv_report)
+ schema_forgery["schema_version"] = "rv32-bench/1"
+ tampers.append(_tamper(
+ "schema_version=rv32-bench/1",
+ rv32_bench.audit_provenance(schema_forgery),
+ ))
+
+ dyn = (rv_report.get("scratchv") or {}).get("dynamic") or {}
+ if dyn.get("source") == "simulated" and \
+ isinstance(dyn.get("executed"), int):
+ counters_forgery = deepcopy(rv_report)
+ counters_forgery["scratchv"]["dynamic"]["executed"] = (
+ dyn["executed"] + 999)
+ tampers.append(_tamper(
+ "scratchv.dynamic.executed+=999",
+ rv32_bench.audit_provenance(counters_forgery),
+ ))
+
+ return {
+ "tampers": tampers,
+ "rejected": all(tamper["rejected"] for tamper in tampers),
+ "note": None,
+ }
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Report assembly and hard checks
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _simulator_state(rv_report: dict | None):
+ if not rv_report:
+ return None
+ dyn = (rv_report.get("scratchv") or {}).get("dynamic") or {}
+ source = dyn.get("source")
+ if source == "simulated":
+ return True
+ if source == "unavailable":
+ return False
+ return None
+
+
+def _ratio_is_honest(rv_report: dict | None) -> bool:
+ if not rv_report:
+ return False
+ comparison = rv_report.get("comparison") or {}
+ ratio = comparison.get("dynamic_instruction_ratio")
+ if ratio is None:
+ return bool(comparison.get("incomparable_reason"))
+ if isinstance(ratio, bool) or not isinstance(ratio, (int, float)):
+ return False
+ for side in ("scratchv", "llvm"):
+ dyn = (rv_report.get(side) or {}).get("dynamic") or {}
+ if dyn.get("source") != "simulated" or \
+ dyn.get("completion") != "halted":
+ return False
+ return True
+
+
+def _probe_honest(probe: dict | None, expected: str) -> bool:
+ if not probe:
+ return False
+ if probe.get("status") == "confirmed":
+ consistent = (
+ probe.get("completion") == expected
+ and probe.get("executed") == probe.get("ops_total")
+ and probe.get("ratio") is None
+ and bool(probe.get("incomparable_reason"))
+ and probe.get("schema_errors") == []
+ and probe.get("audit_violations") == []
+ )
+ if expected == "budget_exhausted":
+ consistent = consistent and (
+ probe.get("limit") == probe.get("executed"))
+ elif expected == "timeout":
+ consistent = consistent and probe.get("limit") is None
+ return consistent
+ if probe.get("status") == "not_run":
+ return bool(probe.get("reason"))
+ return False
+
+
+def _hard_checks(report: dict[str, Any]) -> dict[str, bool]:
+ rv_report = report.get("rv32_report")
+ artifacts = (report.get("run") or {}).get("artifacts") or {}
+ return {
+ "rv32_json_artifact_written": bool(artifacts.get("json_written")),
+ "rv32_markdown_artifact_written": bool(
+ artifacts.get("markdown_written")),
+ "rv32_schema_version_v2": (
+ bool(rv_report)
+ and rv_report.get("schema_version") == RV32_SCHEMA_VERSION
+ ),
+ "rv32_schema_valid": report.get("schema_errors") == [],
+ "provenance_audit_clean": report.get("audit_violations") == [],
+ "completion_is_legal": (
+ report.get("completion") in ALLOWED_COMPLETIONS),
+ "ratio_is_honest": _ratio_is_honest(rv_report),
+ "github_summary_rendered": (
+ "# RV32 Benchmark Summary" in (
+ report.get("github_summary") or "")),
+ "report_markdown_rendered": (
+ "## " in (report.get("report_markdown") or "")),
+ "audit_gate_rejects_forgery": bool(
+ (report.get("audit_probe") or {}).get("rejected")),
+ "budget_probe_honest": _probe_honest(
+ report.get("budget_probe"), "budget_exhausted"),
+ "timeout_probe_honest": _probe_honest(
+ report.get("timeout_probe"), "timeout"),
+ }
+
+
+def _honesty(rv_report: dict | None, simulator_available) -> str:
+ if not rv_report:
+ return (
+ "The rv32_bench driver did not produce a report; no dynamic or "
+ "static number is claimed."
+ )
+ dyn = (rv_report.get("scratchv") or {}).get("dynamic") or {}
+ parts = [
+ "Deterministic feature case executed through the real rv32_bench CLI "
+ "on a generated tiny Conv model; any dynamic counts are TinyFive "
+ "emulator instruction counts, not hardware cycles, and this case "
+ "makes no speedup or performance claim.",
+ "LLVM compilation is always skipped (--skip-llvm) because CI has no "
+ "LLVM/clang toolchain; the LLVM side is unavailable by design, so "
+ "dynamic_instruction_ratio is null with an explicit "
+ "incomparable_reason.",
+ ]
+ if simulator_available is True:
+ parts.append(
+ f"TinyFive executed the model "
+ f"(completion={dyn.get('completion')}); the budget and wall-clock "
+ "probes confirm that truncated runs are labeled "
+ "budget_exhausted/timeout and excluded from comparison."
+ )
+ elif simulator_available is False:
+ parts.append(
+ "TinyFive is unavailable in this environment, so the dynamic "
+ "section is source=unavailable with ops=null and the "
+ "budget/timeout probes are reported as not_run instead of "
+ "being fabricated."
+ )
+ else:
+ parts.append(
+ "Simulator availability could not be determined because the "
+ "driver failed before simulation; dynamic data is absent and "
+ "unclaimed."
+ )
+ return " ".join(parts)
+
+
+def evaluate(model_path: str | Path, work_root: str | Path, *,
+ case_builder: str | None = None) -> dict[str, Any]:
+ """Run the case (full + probes) and build the auditable report payload."""
+ model_path = Path(model_path).resolve()
+ work_root = Path(work_root)
+ work_root.mkdir(parents=True, exist_ok=True)
+
+ with tempfile.TemporaryDirectory(
+ prefix="t27_case_", dir=str(work_root)) as tmp:
+ root = Path(tmp)
+ run = _run_rv32(model_path, root / "primary", ["--full"])
+ rv_report = run.pop("report")
+
+ completion = None
+ comparison: dict[str, Any] = {
+ "dynamic_instruction_ratio": None, "incomparable_reason": None,
+ }
+ schema_errors = ["rv32 report not written"]
+ audit_violations = ["rv32 report not written"]
+ github_summary = ""
+ report_markdown = ""
+ if rv_report is not None:
+ completion = (
+ (rv_report.get("scratchv") or {}).get("dynamic") or {}
+ ).get("completion")
+ comparison = dict(rv_report.get("comparison") or {})
+ schema_errors = bench_report.validate_report_schema(rv_report)
+ audit_violations = rv32_bench.audit_provenance(rv_report)
+ github_summary = bench_report.render_github_summary(rv_report)
+ report_markdown = bench_report.render_markdown(rv_report)
+
+ simulator_available = _simulator_state(rv_report)
+
+ budget_probe = _probe(
+ model_path, root / "budget_probe",
+ ["--max-instructions", str(BUDGET_LIMIT)], "budget_exhausted",
+ )
+ timeout_probe = _probe(
+ model_path, root / "timeout_probe", [], "timeout",
+ timeout_s=TIMEOUT_PROBE_S, chunk=TIMEOUT_PROBE_CHUNK,
+ )
+
+ report: dict[str, Any] = {
+ "schema_version": SCHEMA_VERSION,
+ "topic": "topic27-rv32-bench",
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "case": {
+ "model": str(model_path),
+ "sha256": _sha256(model_path),
+ "bytes": model_path.stat().st_size,
+ "builder": case_builder,
+ "description": (
+ "deterministic 3x3 Conv, input [1,1,8,8] -> output [1,1,6,6], "
+ "seed 0 weights"
+ ),
+ },
+ "config": {
+ "flags": ["--allow-missing-simulator", "--skip-llvm"],
+ "rv32_schema_version": RV32_SCHEMA_VERSION,
+ "budget_limit": BUDGET_LIMIT,
+ "timeout_probe_s": TIMEOUT_PROBE_S,
+ "timeout_probe_chunk": TIMEOUT_PROBE_CHUNK,
+ "simulator_available": simulator_available,
+ "llvm_skipped": True,
+ },
+ "completion": completion,
+ "comparison": comparison,
+ "schema_errors": schema_errors,
+ "audit_violations": audit_violations,
+ "run": run,
+ "github_summary": github_summary,
+ "report_markdown": report_markdown,
+ "budget_probe": budget_probe,
+ "timeout_probe": timeout_probe,
+ "audit_probe": _audit_probe(rv_report),
+ "rv32_report": rv_report,
+ "honesty": _honesty(rv_report, simulator_available),
+ }
+ checks = _hard_checks(report)
+ report["hard_checks"] = checks
+ report["hard_failures"] = sorted(
+ name for name, ok in checks.items() if not ok)
+ return report
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Markdown rendering
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _cell(value: Any) -> str:
+ if value is None:
+ return "—"
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ return str(value)
+
+
+def render_markdown(report: dict[str, Any]) -> str:
+ """Render the case report as Markdown for stdout and job summaries."""
+ rv_report = report.get("rv32_report") or {}
+ model = rv_report.get("model") or {}
+ env = rv_report.get("environment") or {}
+ targets = rv_report.get("targets") or {}
+ scratchv = rv_report.get("scratchv") or {}
+ sv_compile = scratchv.get("compile") or {}
+ sv_dyn = scratchv.get("dynamic") or {}
+ llvm = rv_report.get("llvm") or {}
+ ll_compile = llvm.get("compile") or {}
+ ll_dyn = llvm.get("dynamic") or {}
+ comparison = rv_report.get("comparison") or {}
+ case = report.get("case") or {}
+ run = report.get("run") or {}
+ ratio = comparison.get("dynamic_instruction_ratio")
+ tag = "PASS" if not report["hard_failures"] else "FAIL"
+
+ lines = [
+ "# Topic 27 RV32-Bench Feature Case",
+ "",
+ f"- Schema: `{report.get('schema_version')}` "
+ f"(embedded report: `{_cell(rv_report.get('schema_version'))}`)",
+ f"- Case: `{_cell(case.get('model'))}` "
+ f"(sha256={str(case.get('sha256') or '')[:12]}, "
+ f"{_cell(case.get('bytes'))} bytes, "
+ f"builder={_cell(case.get('builder'))})",
+ f"- Generated: {report.get('generated_at')}",
+ f"- Hard checks: {tag} "
+ f"({len(report['hard_checks']) - len(report['hard_failures'])}"
+ f"/{len(report['hard_checks'])})",
+ f"- Driver: rv32_bench.main exit={_cell(run.get('exit_code'))}, "
+ f"status={_cell(run.get('status'))}, "
+ f"elapsed={_cell(run.get('elapsed_s'))}s",
+ f"- render_markdown bytes: {len(report.get('report_markdown') or '')}",
+ "",
+ "## RV32 report schema",
+ "",
+ "| Field | Value |",
+ "|-------|-------|",
+ f"| schema_version | {_cell(rv_report.get('schema_version'))} |",
+ f"| generated_at | {_cell(rv_report.get('generated_at'))} |",
+ f"| model.path | {_cell(model.get('path'))} |",
+ f"| model.sha256 | {_cell(model.get('sha256'))} |",
+ f"| environment.python | {_cell(env.get('python'))} |",
+ f"| environment.tinyfive | {_cell(env.get('tinyfive'))} |",
+ f"| environment.llvmlite | {_cell(env.get('llvmlite'))} |",
+ f"| targets.scratchv.isa | "
+ f"{_cell((targets.get('scratchv') or {}).get('isa'))} |",
+ f"| targets.llvm.isa | "
+ f"{_cell((targets.get('llvm') or {}).get('isa'))} |",
+ f"| scratchv.compile.status | {_cell(sv_compile.get('status'))} |",
+ f"| scratchv.compile.static_insns | "
+ f"{_cell(sv_compile.get('static_insns'))} |",
+ f"| llvm.compile.status | {_cell(ll_compile.get('status'))} |",
+ f"| scratchv.dynamic.source | {_cell(sv_dyn.get('source'))} |",
+ f"| comparison.dynamic_instruction_ratio | {_cell(ratio)} |",
+ "",
+ "## Completion / ratio status",
+ "",
+ "| Side | source | completion | executed | limit |",
+ "|------|--------|------------|---------:|------:|",
+ f"| ScratchV | {_cell(sv_dyn.get('source'))} | "
+ f"{_cell(sv_dyn.get('completion'))} | "
+ f"{_cell(sv_dyn.get('executed'))} | {_cell(sv_dyn.get('limit'))} |",
+ f"| LLVM | {_cell(ll_dyn.get('source'))} | "
+ f"{_cell(ll_dyn.get('completion'))} | "
+ f"{_cell(ll_dyn.get('executed'))} | {_cell(ll_dyn.get('limit'))} |",
+ "",
+ ]
+ if ratio is None:
+ lines.append(
+ f"- ratio: **null** — "
+ f"{_cell(comparison.get('incomparable_reason'))}")
+ else:
+ lines.append(
+ f"- ratio: **{ratio:g}** (both sides simulated and halted)")
+ lines += [
+ "",
+ "## Rendered report summary",
+ "",
+ report.get("github_summary") or "_rv32 report not written_",
+ "",
+ "## Probes",
+ "",
+ "| Probe | Expected | Status | completion | executed | limit | Note |",
+ "|-------|----------|--------|------------|---------:|------:|------|",
+ ]
+ for label, probe in (
+ ("budget truncation", report.get("budget_probe")),
+ ("wall-clock timeout", report.get("timeout_probe")),
+ ):
+ probe = probe or {}
+ lines.append(
+ f"| {label} | {_cell(probe.get('expected'))} | "
+ f"{_cell(probe.get('status'))} | "
+ f"{_cell(probe.get('completion'))} | "
+ f"{_cell(probe.get('executed'))} | {_cell(probe.get('limit'))} | "
+ f"{_cell(probe.get('note'))} |"
+ )
+ audit_probe = report.get("audit_probe") or {}
+ forged = ", ".join(
+ tamper.get("field", "?")
+ for tamper in audit_probe.get("tampers") or []
+ )
+ lines.append(
+ f"| provenance forgery | rejected | "
+ f"{'rejected' if audit_probe.get('rejected') else 'NOT rejected'} | "
+ f"— | — | — | forged: {_cell(forged)} |"
+ )
+ lines += [
+ "",
+ "## Hard checks",
+ "",
+ ]
+ for name, ok in report["hard_checks"].items():
+ lines.append(f"- [{'x' if ok else ' '}] {name}")
+ lines += [
+ "",
+ "## Honesty",
+ "",
+ report.get("honesty") or "",
+ "",
+ "> Exit codes: 0 = all hard checks pass, 1 = hard-check failure, "
+ "2 = usage error (missing case model).",
+ "",
+ ]
+ return "\n".join(lines)
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# CLI
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _execute(args: argparse.Namespace, work_root: Path) -> int:
+ work_root.mkdir(parents=True, exist_ok=True)
+ builder: str | None = None
+ if args.model is not None:
+ model_path = args.model.resolve()
+ else:
+ args.json.parent.mkdir(parents=True, exist_ok=True)
+ model_path = args.json.parent / "topic27_rv32_bench_feature.onnx"
+ try:
+ build_case_model(model_path)
+ except Exception as exc:
+ print(
+ f"error: case_model_generation_failed: "
+ f"{type(exc).__name__}: {exc}",
+ file=sys.stderr,
+ )
+ return 2
+ builder = CASE_BUILDER
+
+ report = evaluate(model_path, work_root, case_builder=builder)
+ markdown = render_markdown(report)
+ args.json.parent.mkdir(parents=True, exist_ok=True)
+ args.json.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
+ args.markdown.parent.mkdir(parents=True, exist_ok=True)
+ args.markdown.write_text(markdown + "\n", encoding="utf-8")
+ print(markdown)
+ if report["hard_failures"]:
+ print(
+ "HARD FAILURES: " + ", ".join(report["hard_failures"]),
+ file=sys.stderr,
+ )
+ return 1
+ print(f"reports written: {args.json}, {args.markdown}", file=sys.stderr)
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--model", type=Path, default=None,
+ help="Existing ONNX model; default generates the tiny Conv case model",
+ )
+ parser.add_argument("--json", type=Path, default=DEFAULT_JSON)
+ parser.add_argument("--markdown", type=Path, default=DEFAULT_MARKDOWN)
+ parser.add_argument(
+ "--work-dir", type=Path, default=None,
+ help="Directory for intermediate artifacts (default: temporary dir)",
+ )
+ args = parser.parse_args(argv)
+
+ if args.model is not None and not args.model.is_file():
+ print(f"error: case model not found: {args.model}", file=sys.stderr)
+ return 2
+
+ if args.work_dir is not None:
+ return _execute(args, Path(args.work_dir))
+ with tempfile.TemporaryDirectory(prefix="topic27_rv32_case_") as tmp:
+ return _execute(args, Path(tmp))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_topic27_rv32_bench_case_report.py b/tests/test_topic27_rv32_bench_case_report.py
new file mode 100644
index 0000000..db669a5
--- /dev/null
+++ b/tests/test_topic27_rv32_bench_case_report.py
@@ -0,0 +1,204 @@
+"""Tests for the Topic 27 RV32-bench feature case report.
+
+The report drives the real ``rv32_bench.main`` on a tiny deterministic model
+and is the CI artifact that proves the honest-report contract: schema v2,
+labeled truncation, null ratio with a reason, and a non-vacuous provenance
+audit. Dynamic tests only run the toy case model with explicit budgets.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from benchmarks import run_topic27_rv32_bench_case as case
+from scratchv.standalone import bench_report, rv32_bench
+
+
+class _UnavailableProfiledMachine:
+ available = False
+
+ def __init__(self, mem_size=0):
+ self.mem_size = mem_size
+
+
+@pytest.fixture(scope="module")
+def case_model(tmp_path_factory):
+ pytest.importorskip("onnx")
+ path = (
+ tmp_path_factory.mktemp("topic27_case")
+ / "topic27_rv32_bench_feature.onnx"
+ )
+ case.build_case_model(path)
+ return path
+
+
+@pytest.fixture(scope="module")
+def base_report(case_model, tmp_path_factory):
+ work = tmp_path_factory.mktemp("topic27_case_run")
+ return case.evaluate(case_model, work, case_builder=case.CASE_BUILDER)
+
+
+def test_report_passes_all_hard_checks(base_report):
+ assert base_report["schema_version"] == case.SCHEMA_VERSION
+ assert base_report["rv32_report"]["schema_version"] == (
+ case.RV32_SCHEMA_VERSION)
+ assert base_report["schema_errors"] == []
+ assert base_report["audit_violations"] == []
+ assert base_report["hard_failures"] == []
+ assert all(base_report["hard_checks"].values())
+ assert base_report["honesty"]
+
+
+def test_completion_and_ratio_are_honest(base_report):
+ assert base_report["completion"] in case.ALLOWED_COMPLETIONS
+ comparison = base_report["comparison"]
+ if comparison["dynamic_instruction_ratio"] is None:
+ assert comparison["incomparable_reason"]
+ else:
+ for side in ("scratchv", "llvm"):
+ dyn = base_report["rv32_report"][side]["dynamic"]
+ assert dyn["source"] == "simulated"
+ assert dyn["completion"] == "halted"
+
+ rv_report = base_report["rv32_report"]
+ assert bench_report.validate_report_schema(rv_report) == []
+ dyn = rv_report["scratchv"]["dynamic"]
+ if base_report["config"]["simulator_available"]:
+ assert dyn["source"] == "simulated"
+ assert dyn["ops"]["total"] == dyn["executed"]
+ else:
+ assert dyn["source"] == "unavailable"
+ assert dyn["completion"] == "not_run"
+ assert dyn["ops"] is None
+ assert dyn["reason"]
+
+
+def test_main_writes_json_and_markdown(tmp_path, capsys):
+ json_path = tmp_path / "rv32_bench_case_report.json"
+ md_path = tmp_path / "rv32_bench_case_report.md"
+ exit_code = case.main([
+ "--json", str(json_path),
+ "--markdown", str(md_path),
+ ])
+ assert exit_code == 0
+
+ data = json.loads(json_path.read_text())
+ assert data["hard_failures"] == []
+ assert data["schema_version"] == case.SCHEMA_VERSION
+ assert data["rv32_report"]["schema_version"] == case.RV32_SCHEMA_VERSION
+ assert data["github_summary"].startswith("# RV32 Benchmark Summary")
+ assert "## " in data["report_markdown"]
+ assert data["config"]["llvm_skipped"] is True
+
+ markdown = md_path.read_text()
+ for section in (
+ "## RV32 report schema",
+ "## Completion / ratio status",
+ "## Rendered report summary",
+ "## Probes",
+ "## Hard checks",
+ "## Honesty",
+ ):
+ assert section in markdown, section
+ assert "Topic 27 RV32-Bench Feature Case" in capsys.readouterr().out
+
+
+def test_main_rejects_missing_model(tmp_path, capsys):
+ json_path = tmp_path / "missing.json"
+ exit_code = case.main([
+ "--model", str(tmp_path / "nope.onnx"),
+ "--json", str(json_path),
+ "--markdown", str(tmp_path / "missing.md"),
+ ])
+ assert exit_code == 2
+ assert not json_path.exists()
+ assert "case model not found" in capsys.readouterr().err
+
+
+def test_audit_probe_rejects_forgery(base_report):
+ probe = base_report["audit_probe"]
+ assert probe["rejected"] is True
+ assert len(probe["tampers"]) >= 2
+ assert all(t["rejected"] and t["violations"] for t in probe["tampers"])
+ fields = {t["field"] for t in probe["tampers"]}
+ assert "comparison.dynamic_instruction_ratio=0.31" in fields
+ assert "schema_version=rv32-bench/1" in fields
+
+ clean = base_report["rv32_report"]
+ assert rv32_bench.audit_provenance(clean) == []
+ forged = json.loads(json.dumps(clean))
+ forged["comparison"] = {
+ "dynamic_instruction_ratio": 0.31, "incomparable_reason": None,
+ }
+ violations = rv32_bench.audit_provenance(forged)
+ assert any("incomplete/non-simulated" in v for v in violations)
+
+
+def test_degraded_mode_is_honest_without_simulator(
+ case_model, tmp_path, monkeypatch):
+ monkeypatch.setattr(
+ rv32_bench, "ProfiledMachine", _UnavailableProfiledMachine)
+ report = case.evaluate(case_model, tmp_path / "degraded")
+
+ assert report["config"]["simulator_available"] is False
+ assert report["completion"] == "not_run"
+ assert report["hard_failures"] == []
+ assert report["comparison"]["dynamic_instruction_ratio"] is None
+ assert "scratchv" in report["comparison"]["incomparable_reason"]
+
+ dyn = report["rv32_report"]["scratchv"]["dynamic"]
+ assert dyn["source"] == "unavailable"
+ assert dyn["ops"] is None
+ assert report["budget_probe"]["status"] == "not_run"
+ assert report["timeout_probe"]["status"] == "not_run"
+ assert "TinyFive is unavailable" in report["honesty"]
+
+ markdown = case.render_markdown(report)
+ assert "not_run" in markdown
+ assert "[unavailable]" in report["report_markdown"]
+
+
+def test_probes_match_environment(base_report):
+ budget = base_report["budget_probe"]
+ timeout = base_report["timeout_probe"]
+ if base_report["config"]["simulator_available"]:
+ assert budget["status"] == "confirmed"
+ assert budget["completion"] == "budget_exhausted"
+ assert budget["limit"] == budget["executed"] == case.BUDGET_LIMIT
+ assert budget["ops_total"] == budget["executed"]
+ assert budget["ratio"] is None and budget["incomparable_reason"]
+ assert budget["schema_errors"] == []
+ assert budget["audit_violations"] == []
+
+ assert timeout["status"] == "confirmed"
+ assert timeout["completion"] == "timeout"
+ assert timeout["partial"] is True
+ assert timeout["ratio"] is None and timeout["incomparable_reason"]
+ assert timeout["schema_errors"] == []
+ else:
+ assert budget["status"] == "not_run" and budget["reason"]
+ assert timeout["status"] == "not_run" and timeout["reason"]
+
+
+def test_hard_check_gate_is_not_vacuous(case_model, tmp_path, monkeypatch):
+ def silent_driver(_argv):
+ return rv32_bench.EXIT_OK
+
+ monkeypatch.setattr(rv32_bench, "main", silent_driver)
+ report = case.evaluate(case_model, tmp_path / "silent")
+
+ assert report["run"]["exit_code"] == 0
+ assert report["rv32_report"] is None
+ for name in (
+ "rv32_json_artifact_written",
+ "rv32_schema_valid",
+ "provenance_audit_clean",
+ "completion_is_legal",
+ "ratio_is_honest",
+ "audit_gate_rejects_forgery",
+ "budget_probe_honest",
+ "timeout_probe_honest",
+ ):
+ assert name in report["hard_failures"], report["hard_failures"]