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/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..7ceda61
--- /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,451 @@
+# 课题 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` 循环 + 实例级 `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` | 接口规格化 |
+| 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
+ 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,
+) -> dict: ... # -> {"dynamic": …, "output": …}; halt_addr 由 compute_layout 内部计算
+```
+
+调用点:
+
+```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, expected_code_bytes: int) -> 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` 抛错 | `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`(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)
+ 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}
+output [measured | measured/partial | unavailable]: {output.raw_hex}
+| elements={output.elements} | completion={output.completion}
+```
+
+渲染规则:
+
+- 每个表标题必带 `[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,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 |
+| `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`:**未接入 `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`。
+
+### 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 探测/`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`;否则拒绝启动 |
+| 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` 未改)。
+- 停机采用实例级 `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"
new file mode 100644
index 0000000..911bf48
--- /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,517 @@
+# 课题 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)作为每块大小;块间检查停机、墙钟与预算。块内精确停机由实例级 `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`。
+
+#### 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` 未暴露该参数,且其 `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.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`;否则拒绝启动并提示替代方案。
+- **不做自动预估**:本驱动不运行 1M 探测,也不调用 `benchmark.estimate_cnn_model()`(该解析模型只属于 `benchmark.py` 自身 CLI),因此报告中不存在 `estimated_*` 墙钟数字。full 模式启动前只在 `warnings` 写一条显式提示:无自动预估、可用 `--max-instructions N` 校准、墙钟超时触发时结果如实标 partial。若未来引入任何预估值,字段名必须带 `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], "completion": "halted", "partial": false}
+ },
+ "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": [
+ "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": []
+}
+```
+
+### 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 对比)
diff --git a/scratchv/standalone/bench_report.py b/scratchv/standalone/bench_report.py
index 9dfb533..05533f6 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,479 @@ 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'))} | — |")
+ 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"| {ll_static} |"
+ )
+ 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'))}"
+ )
+ 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 {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"completion={_fmt(sv_out.get('completion'))})"
+ )
+ 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("