#!/usr/bin/env python3 """Run the locked APU-P7 KWS 1000-window corpus through the production RTL path.""" from __future__ import annotations import argparse import hashlib import json import os import re import shutil import struct import subprocess import sys import time from datetime import datetime, timezone from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from apu_kws import ( # noqa: E402 APUM_BYTES, APUM_PAYLOAD_CRC, APUM_SHA256, CORPUS_SHA256, TFLITE_SHA256, image_sha256, parse_apum, ) from apu_kws_convert import TfliteError, import_tflite # noqa: E402 from generate_apu_kws_rtl_constants import build_apuc # noqa: E402 from setup_apu_kws_reference import REQUIRED_ARCHIVE, REQUIRED_SOURCES # noqa: E402 MANIFEST = ROOT / "docs/ip/apu-kws-corpus.tsv" PCM_SOURCES = ROOT / ".cache/retrosoc/sources/apu-kws-pcm" KWS01 = ROOT / ".cache/retrosoc/sources/apu-kws-mfcc/datasets/kws01" Y_LABELS = KWS01 / "y_labels.csv" Y_LABELS_SHA256 = "6cb3709762d53fe64eb00eaf4a75796248bb3c1386f1d1f93ccdb07b071e9698" AGGREGATE_PCM_SHA256 = "abd1ff2c988ac168dd68c29f934d95114d1094d7818d79fe73adb166ac009bee" TFLITE = ( ROOT / ".cache/retrosoc/sources/apu-mlperf-tiny/benchmark/training/keyword_spotting" / "trained_models/kws_ref_model.tflite" ) FIXTURE = ROOT / "tests/rtl/apu_p7_accuracy_tb.sv" ROM = ROOT / "rtl/ip/multimedia/apu_kws_rom.svh" WINDOW_COUNT = 1000 WINDOW_BYTES = 32000 WINDOW_SAMPLES = 16000 MINIMUM_TOP1 = 900 CLASS_COUNT = 12 SETUP_HINT = "install the locked P7 inputs with `make setup-apu-kws-reference`" RESULT_PATTERN = re.compile(r"^KWS_RESULT (\d+) (\d+) (\d+) (\d+)$", re.MULTILINE) COMPLETE_PATTERN = re.compile(r"^APU_P7_ACCURACY_COMPLETE windows=(\d+) first=(\d+)$", re.MULTILINE) VARIANT_PATTERN = re.compile(r"^(?P.+)-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-[0-9a-f]{6,}$") class QualificationError(RuntimeError): """A locked input, tool, or simulation step failed closed.""" def _sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: for block in iter(lambda: source.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def _write_json(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") temporary.replace(path) def _require_file(path: Path, description: str) -> Path: if not path.is_file(): raise QualificationError(f"missing {description}: {path}; {SETUP_HINT}") return path def _git_identity(paths: list[Path] | None = None) -> dict[str, Any]: command = ["git", "-C", str(ROOT)] revision = subprocess.run( [*command, "rev-parse", "HEAD"], text=True, capture_output=True, check=False ) status_command = [*command, "status", "--porcelain"] if paths is not None: status_command.extend(("--", *[str(path) for path in paths])) status = subprocess.run(status_command, text=True, capture_output=True, check=False) return { "git_revision": revision.stdout.strip() if revision.returncode == 0 else None, "git_dirty": (bool(status.stdout.strip()) if status.returncode == 0 else None), } def _tool_version(command: list[str]) -> str | None: result = subprocess.run(command, text=True, capture_output=True, check=False) if result.returncode != 0: return None line = (result.stdout or result.stderr).splitlines() return line[0].strip() if line else None def _load_manifest() -> list[dict[str, Any]]: _require_file(MANIFEST, "locked PCM manifest") raw = MANIFEST.read_bytes() text = raw.replace(b"\r\n", b"\n") if b"\r\n" in raw else raw if _sha256_bytes(text) != CORPUS_SHA256: raise QualificationError( f"stale locked PCM manifest: {MANIFEST} canonical-text SHA-256 mismatch; {SETUP_HINT}" ) lines = text.decode("ascii").split("\n") if lines[-1] != "": raise QualificationError("locked PCM manifest is missing its trailing LF") rows = lines[:-1] if len(rows) != WINDOW_COUNT: raise QualificationError(f"locked PCM manifest has {len(rows)} rows, expected 1000") records: list[dict[str, Any]] = [] for index, row in enumerate(rows): fields = row.split("\t") if len(fields) != 6: raise QualificationError(f"locked PCM manifest row {index} does not have 6 columns") bin_name, relpath, class_text, wav_sha256, pcm_sha256 = fields[1:] if fields[0] != f"{index:06d}": raise QualificationError(f"locked PCM manifest row {index} is out of order") if relpath.startswith("/") or ".." in Path(relpath).parts: raise QualificationError(f"locked PCM manifest row {index} has an unsafe path") class_id = int(class_text) if not 0 <= class_id < CLASS_COUNT: raise QualificationError(f"locked PCM manifest row {index} class out of range") records.append( { "index": index, "bin_name": bin_name, "relpath": relpath, "class_id": class_id, "wav_sha256": wav_sha256, "pcm_sha256": pcm_sha256, } ) return records def _load_labels(records: list[dict[str, Any]]) -> list[int]: _require_file(Y_LABELS, "official y_labels.csv") if _sha256_file(Y_LABELS) != Y_LABELS_SHA256: raise QualificationError(f"stale official y_labels.csv: {Y_LABELS}; {SETUP_HINT}") lines = Y_LABELS.read_text(encoding="ascii").splitlines() if len(lines) != WINDOW_COUNT: raise QualificationError(f"official y_labels.csv has {len(lines)} rows, expected 1000") labels: list[int] = [] for record, line in zip(records, lines): name, total, label = line.split(",") if name != record["bin_name"] or int(total) != CLASS_COUNT: raise QualificationError( f"official y_labels.csv row {record['index']} does not match the PCM manifest" ) if int(label) != record["class_id"]: raise QualificationError( f"official y_labels.csv row {record['index']} label disagrees with the PCM manifest" ) labels.append(int(label)) return labels def _padded_pcm(record: dict[str, Any]) -> bytes: source = _require_file(PCM_SOURCES / record["relpath"], "locked source WAV") wav = source.read_bytes() if _sha256_bytes(wav) != record["wav_sha256"]: raise QualificationError(f"source WAV SHA-256 mismatch: {source}; {SETUP_HINT}") if len(wav) < 12 or wav[0:4] != b"RIFF" or wav[8:12] != b"WAVE": raise QualificationError(f"source WAV is not RIFF/WAVE: {source}") fmt: bytes | None = None data: bytes | None = None offset = 12 while offset + 8 <= len(wav): chunk_size = struct.unpack_from(" WINDOW_BYTES or len(data) % 2 != 0: raise QualificationError(f"source WAV exceeds one 16000-sample window: {source}") padded = data + bytes(WINDOW_BYTES - len(data)) if _sha256_bytes(padded) != record["pcm_sha256"]: raise QualificationError(f"padded PCM SHA-256 mismatch for row {record['index']}: {source}") return padded def _write_hex(path: Path, payload: bytes) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( "".join( f"{int.from_bytes(payload[offset : offset + 4], 'little'):08x}\n" for offset in range(0, len(payload), 4) ), encoding="ascii", ) def _production_sources() -> list[Path]: common = ROOT / "rtl/managed/clusterip/common/rtl" multimedia = ROOT / "rtl/ip/multimedia" return [ common / "interface/axi4_stream_if.sv", multimedia / "apu_kws_sram_client.sv", multimedia / "apu_kws_coeff_store.sv", multimedia / "apu_kws_engine.sv", ROOT / "tests/rtl/apu_kws_coeff_fixture.sv", FIXTURE, ] def _rtl_inputs() -> list[Path]: return [ *_production_sources(), ROOT / "rtl/ip/multimedia/apu_define.svh", ROM, Path(__file__).resolve(), ] def _verification_sha256() -> str: digest = hashlib.sha256() for path in sorted(_rtl_inputs()): digest.update(path.relative_to(ROOT).as_posix().encode("utf-8")) digest.update(b"\0") digest.update(path.read_bytes()) return digest.hexdigest() def _subprocess_text(value: str | bytes | None) -> str: if isinstance(value, bytes): return value.decode("utf-8", errors="replace") return value or "" def _run_command(command: list[str], *, timeout: int | None = None) -> dict[str, Any]: started = time.monotonic() try: process = subprocess.run(command, check=False, capture_output=True, text=True, timeout=timeout) return { "command": command, "duration_seconds": round(time.monotonic() - started, 3), "exit_code": process.returncode, "stdout": process.stdout, "stderr": process.stderr, "timed_out": False, } except subprocess.TimeoutExpired as error: return { "command": command, "duration_seconds": round(time.monotonic() - started, 3), "exit_code": 124, "stdout": _subprocess_text(error.stdout), "stderr": _subprocess_text(error.stderr), "timed_out": True, } def _compile(build_dir: Path, simulator: str) -> dict[str, Any]: tools = {name: shutil.which(name) for name in ("sv2v", "iverilog", "vvp")} missing = [name for name, path in tools.items() if path is None] if missing: raise QualificationError(f"missing required simulators: {', '.join(missing)}") tools["verilator"] = shutil.which("verilator") if simulator == "verilator" and tools["verilator"] is None: raise QualificationError("--simulator verilator requires verilator on PATH") common = ROOT / "rtl/managed/clusterip/common/rtl" multimedia = ROOT / "rtl/ip/multimedia" filelist = build_dir / "apu_p7_accuracy.fl" filelist.write_text( "\n".join( [ "+define+SV_ASSRT_DISABLE", f"+incdir+{common}", f"+incdir+{common / 'interface'}", f"+incdir+{multimedia}", *(str(source) for source in _production_sources()), "", ] ), encoding="utf-8", ) converted = build_dir / "apu_p7_accuracy.v" conversion = _run_command( [ sys.executable, str(ROOT / "scripts/rtl/convt_sv2v.py"), "-f", str(filelist), "--output", str(converted), ] ) if conversion["exit_code"] != 0: raise QualificationError(f"APU-P7 accuracy sv2v failed: {conversion['stderr']}") simv = build_dir / "apu_p7_accuracy_simv" compile_result = _run_command( [tools["iverilog"], "-g2012", "-s", "apu_p7_accuracy_tb", "-o", str(simv), str(converted)] ) if compile_result["exit_code"] != 0: raise QualificationError(f"APU-P7 accuracy Icarus compile failed: {compile_result['stderr']}") verilator_binary: Path | None = None verilator_log: dict[str, Any] | None = None if tools["verilator"] is not None: verilator_dir = build_dir / "verilator" ccache_dir = build_dir / "ccache" ccache_dir.mkdir(exist_ok=True) environment = { **os.environ, "CCACHE_DIR": str(ccache_dir), "CCACHE_TEMPDIR": str(ccache_dir), } started = time.monotonic() process = subprocess.run( [ str(tools["verilator"]), "--binary", "--timing", "-O3", "-Wno-fatal", "--top-module", "apu_p7_accuracy_tb", "--Mdir", str(verilator_dir), str(converted), ], check=False, capture_output=True, text=True, env=environment, ) verilator_log = { "command": list(process.args), "duration_seconds": round(time.monotonic() - started, 3), "exit_code": process.returncode, "stdout": process.stdout, "stderr": process.stderr, } if process.returncode != 0: if simulator == "verilator": raise QualificationError( f"APU-P7 accuracy Verilator compile failed: {process.stderr}" ) else: verilator_binary = verilator_dir / "Vapu_p7_accuracy_tb" if simulator == "verilator" and verilator_binary is None: raise QualificationError("Verilator compile did not produce a simulation binary") return { "tools": tools, "filelist": filelist, "converted": converted, "simv": simv, "verilator": verilator_binary, "sv2v_log": conversion, "iverilog_log": compile_result, "verilator_log": verilator_log, } def _profile_name(build_dir: Path, override: str | None) -> str: if override: return override parts = build_dir.resolve().parts if len(parts) >= 4 and parts[-2:] == ("apu", "kws") and parts[-4] == "build": match = VARIANT_PATTERN.match(parts[-3]) if match: return match.group("profile") return "standalone" def _parse_predictions(stdout: str, window_count: int) -> list[dict[str, int]]: predictions: dict[int, dict[str, int]] = {} for match in RESULT_PATTERN.finditer(stdout): index, class_id, score, hit = (int(value) for value in match.groups()) if index in predictions: raise QualificationError(f"duplicated utterance index {index} in simulator output") if class_id >= CLASS_COUNT: raise QualificationError(f"simulator emitted invalid class {class_id}") predictions[index] = {"index": index, "predicted": class_id, "score": score, "hit": hit} complete = COMPLETE_PATTERN.search(stdout) if complete is None or int(complete.group(1)) != window_count or int(complete.group(2)) != 0: raise QualificationError("simulator did not emit the expected completion marker") expected = set(range(window_count)) observed = set(predictions) if observed != expected: raise QualificationError( f"skipped utterances: missing {sorted(expected - observed)[:8]} " f"unexpected {sorted(observed - expected)[:8]}" ) return [predictions[index] for index in sorted(predictions)] def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--build-dir", type=Path, required=True) parser.add_argument("--max-windows", type=int, default=0, help="run only the first N locked windows (0 = all 1000)") parser.add_argument("--report", type=str, default="accuracy.json") parser.add_argument("--profile", type=str, default=None) parser.add_argument("--timeout-seconds", type=int, default=21600) parser.add_argument("--max-window-cycles", type=int, default=4000000) parser.add_argument( "--simulator", choices=("auto", "iverilog", "verilator"), default="auto", help="run simulator; auto prefers Verilator (Icarus is ~400x slower per window)", ) args = parser.parse_args() if not 0 <= args.max_windows <= WINDOW_COUNT: raise SystemExit("--max-windows must be within 0..1000") window_count = WINDOW_COUNT if args.max_windows == 0 else args.max_windows build_dir = args.build_dir.resolve() started_utc = datetime.now(timezone.utc).isoformat() started = time.monotonic() report_path = build_dir / args.report try: records = _load_manifest() labels = _load_labels(records) image = import_tflite(_require_file(TFLITE, "locked kws_ref_model.tflite")) parse_apum(image) pcm_dir = build_dir / "pcm" aggregate = hashlib.sha256() for record in records[:window_count]: padded = _padded_pcm(record) aggregate.update(padded) _write_hex(pcm_dir / f"pcm_{record['index']:06d}.hex", padded) aggregate_sha256 = aggregate.hexdigest() if window_count == WINDOW_COUNT and aggregate_sha256 != AGGREGATE_PCM_SHA256: raise QualificationError( "aggregate padded PCM SHA-256 does not match the frozen 1000-window value" ) apum_hex = build_dir / "kws_apum.hex" _write_hex(apum_hex, image) apuc_hex = build_dir / "kws_apuc.hex" apuc, _layout = build_apuc(image) _write_hex(apuc_hex, apuc) built = _compile(build_dir, args.simulator) except (OSError, TfliteError, ValueError, QualificationError) as error: raise SystemExit(str(error)) from error selected = ( "verilator" if (args.simulator == "verilator" or (args.simulator == "auto" and built["verilator"])) else "icarus" ) plusargs = [ f"+APUM_HEX={apum_hex}", f"+APUC_HEX={apuc_hex}", f"+PCM_DIR={pcm_dir}", f"+WINDOW_COUNT={window_count}", "+FIRST_INDEX=0", f"+MAX_WINDOW_CYCLES={args.max_window_cycles}", ] command = ( [str(built["verilator"]), *plusargs] if selected == "verilator" else [str(built["tools"]["vvp"]), str(built["simv"]), *plusargs] ) execution = _run_command(command, timeout=args.timeout_seconds) log_path = build_dir / "sim.log" log_path.write_text(execution["stdout"] + execution["stderr"], encoding="utf-8") failure: str | None = None predictions: list[dict[str, int]] = [] if execution["timed_out"]: failure = f"simulator timeout after {args.timeout_seconds} seconds" elif execution["exit_code"] != 0: failure = f"simulator failed with exit code {execution['exit_code']}" else: try: predictions = _parse_predictions(execution["stdout"], window_count) except QualificationError as error: failure = str(error) for prediction in predictions: prediction["expected"] = labels[prediction["index"]] correct = sum(1 for item in predictions if item["predicted"] == item["expected"]) incorrect = [item["index"] for item in predictions if item["predicted"] != item["expected"]] per_class = [ {"class_id": class_id, "total": 0, "correct": 0, "incorrect": 0} for class_id in range(CLASS_COUNT) ] confusion = [[0] * CLASS_COUNT for _ in range(CLASS_COUNT)] for item in predictions: expected, predicted = item["expected"], item["predicted"] confusion[expected][predicted] += 1 per_class[expected]["total"] += 1 if expected == predicted: per_class[expected]["correct"] += 1 else: per_class[expected]["incorrect"] += 1 gate_applies = window_count == WINDOW_COUNT gate_passed = correct >= MINIMUM_TOP1 success = failure is None and (not gate_applies or gate_passed) if failure is None and gate_applies and not gate_passed: failure = f"top-1 accuracy {correct}/1000 is below the required 900/1000" rtl_inputs = _rtl_inputs() report: dict[str, Any] = { "schema_version": 1, "report": "accuracy", "target": "p7", "profile": _profile_name(build_dir, args.profile), "success": success, "failure": failure, "gate": { "window_count": WINDOW_COUNT, "minimum_top1": MINIMUM_TOP1, "applies": gate_applies, "passed": gate_passed if gate_applies else None, }, "seed": 0, "command": [sys.executable, str(Path(__file__).resolve()), *sys.argv[1:]], "started_utc": started_utc, "duration_seconds": round(time.monotonic() - started, 3), "counts": { "windows_requested": window_count, "windows_completed": len(predictions), "top1_correct": correct, "top1_accuracy": round(correct / len(predictions), 6) if predictions else 0.0, "hits": sum(item["hit"] for item in predictions), "incorrect": len(incorrect), }, "per_class": per_class, "confusion_matrix": confusion, "incorrect_indices": incorrect, "predictions": predictions, "inputs": { "manifest": { "path": str(MANIFEST.relative_to(ROOT)), "sha256": CORPUS_SHA256, }, "y_labels": {"path": str(Y_LABELS.relative_to(ROOT)), "sha256": Y_LABELS_SHA256}, "tflite": {"path": str(TFLITE.relative_to(ROOT)), "sha256": TFLITE_SHA256}, "pcm_sources": { "path": str(PCM_SOURCES.relative_to(ROOT)), "windows": window_count, "source_sha256_verified": True, "padded_pcm_sha256_verified": True, "aggregate_padded_pcm_sha256": aggregate_sha256, }, "reference_sources": REQUIRED_SOURCES, "reference_archives": REQUIRED_ARCHIVE, }, "converter": { "path": "scripts/apu_kws_convert.py", "sha256": _sha256_file(ROOT / "scripts/apu_kws_convert.py"), "contract_revision": "apu-kws-convert/1.0.0", **_git_identity(), }, "reference": { "path": "scripts/apu_kws.py", "sha256": _sha256_file(ROOT / "scripts/apu_kws.py"), }, "rtl": { **_git_identity(rtl_inputs), "fixture": str(FIXTURE.relative_to(ROOT)), "verification_sha256": _verification_sha256(), "sources": [ {"path": str(path.relative_to(ROOT)), "sha256": _sha256_file(path)} for path in rtl_inputs ], }, "model": { "apum_sha256": image_sha256(image), "payload_crc": f"0x{APUM_PAYLOAD_CRC:08x}", "bytes": APUM_BYTES, "frozen_sha256": APUM_SHA256, }, "rom": {"path": str(ROM.relative_to(ROOT)), "sha256": _sha256_file(ROM)}, "tools": { "python": sys.version.split()[0], "sv2v": _tool_version([str(built["tools"]["sv2v"]), "--version"]), "iverilog": _tool_version([str(built["tools"]["iverilog"]), "-V"]), "vvp": _tool_version([str(built["tools"]["vvp"]), "-V"]), "verilator": ( _tool_version([str(built["tools"]["verilator"]), "--version"]) if built["tools"]["verilator"] is not None else None ), }, "simulation": { "simulator": selected, "command": command, "exit_code": execution["exit_code"], "timed_out": execution["timed_out"], "duration_seconds": execution["duration_seconds"], "log": str(log_path), }, "artifacts": { "build_dir": str(build_dir), "report": str(report_path), "apum_hex": str(apum_hex), "pcm_dir": str(pcm_dir), "filelist": str(built["filelist"]), "converted": str(built["converted"]), "simv": str(built["simv"]), "verilator": str(built["verilator"]) if built["verilator"] is not None else None, }, } _write_json(report_path, report) if gate_applies: verdict = f"gate 900/1000 {'PASS' if gate_passed else 'FAIL'}" else: verdict = "subset, gate not applied" print(f"APU-P7 KWS accuracy: top-1 {correct}/{len(predictions)} ({verdict})") print(f"report: {report_path}") return 0 if success else 1 if __name__ == "__main__": raise SystemExit(main())