Result: ' + escape(status) + + '
' + summary_html + index_html) + atomic_text(out / "report.md", markdown) + atomic_text(out / "report.html", html) + atomic_text(out / "report.json", payload) + except Exception as exc: + report["passed"] = False + report["status"] = "FAIL" + report.setdefault("report_errors", []).append(f"{type(exc).__name__}: {exc}") + # A human may open a view directly; remove already-written PASS views + # as well as JSON when any required report fails to publish. + for filename in ("report.md", "report.html", "report.json"): + try: + (out / filename).unlink(missing_ok=True) + except OSError as cleanup_error: + report["report_errors"].append(f"cleanup {filename}: {cleanup_error}") + target = out / "report.json" + try: + target.unlink(missing_ok=True) + atomic_text(target, json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n") + except Exception: + pass + raise diff --git a/probes/w3_full_preflight/README.md b/probes/w3_full_preflight/README.md new file mode 100644 index 0000000..02661f8 --- /dev/null +++ b/probes/w3_full_preflight/README.md @@ -0,0 +1,15 @@ +# W3 完整资产与静态尺寸预检 + +复现统一采用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 环境与资产准备](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md),再在同一个 Bash 会话、仓库根目录执行命令;该指南设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC` 和 `SCRATCHV_QEMU`。 + +~~~bash +set -euo pipefail +W3_MODEL=/absolute/path/to/qwen3-0.6b-onnx +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_full_preflight/run.py --model-dir "$W3_MODEL" --output-dir output/w3-full-preflight-new +~~~ + +离线流式校验 W1 固定 manifest 的模型和三个 external-data 文件,复用 ONNX 静态契约/范围检查,统计 initializer、嵌入/外部 tensor、具名 value 与输出的逻辑字节。不加载外部数组进行 IR 运算,也不执行完整 ORT。 + +报告始终标记 full_ir_executed=false、w3_exit_accepted=false。PASS 只代表准备检查通过;sum_named_value_logical_bytes 包含别名、可能重复,不能作为所需物理内存上限或峰值承诺。process_peak_rss_bytes 只属于这个 metadata-only 预检进程。 + +完整数值验收仍需 W1 人工前置确认、中等与真实子图基线、完整 IR/ORT <1e-4、三人复现和 Nightly。参见 [W3 准备说明](../../docs/llm-deploy-v1.0/W3/README.md)。 diff --git a/probes/w3_full_preflight/run.py b/probes/w3_full_preflight/run.py new file mode 100644 index 0000000..8943d32 --- /dev/null +++ b/probes/w3_full_preflight/run.py @@ -0,0 +1,101 @@ +"""Verify fixed full-model assets and size accounting without executing full IR.""" +from __future__ import annotations +import argparse +from collections import Counter +import math +from pathlib import Path +import sys +import time +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +import numpy as np +import onnx +from probes.w1_qwen3_export.run import MANIFEST, verify_files, inspect_model, tensors +from probes.w3_common import new_output_dir, source_evidence, write_reports, process_peak_rss_bytes + +def tensor_size(tensor): + return math.prod(tensor.dims) * np.dtype(onnx.helper.tensor_dtype_to_np_dtype(tensor.data_type)).itemsize + +def value_size(value): + kind = value.type.tensor_type + if not kind.HasField("shape"): + return None + dimensions = [] + for dim in kind.shape.dim: + if not dim.HasField("dim_value"): + return None + dimensions.append(dim.dim_value) + return math.prod(dimensions) * np.dtype(onnx.helper.tensor_dtype_to_np_dtype(kind.elem_type)).itemsize + +def size_accounting(model): + names = {} + unknown = [] + for value in (*model.graph.input, *model.graph.value_info, *model.graph.output): + count = value_size(value) + if count is None: + unknown.append(value.name) + else: + if value.name in names and names[value.name] != count: + raise ValueError(f"Conflicting shape information: {value.name}") + names[value.name] = count + initializer_bytes = sum(tensor_size(t) for t in model.graph.initializer) + all_tensor_bytes = sum(tensor_size(t) for t in tensors(model)) + graph_names = {v.name for v in model.graph.input} | {t.name for t in model.graph.initializer} + graph_names.update(name for node in model.graph.node for name in node.output if name) + known = set(names) | {t.name for t in model.graph.initializer} + return { + "initializer_logical_bytes": initializer_bytes, + "all_embedded_and_external_tensor_logical_bytes": all_tensor_bytes, + "sum_named_value_logical_bytes": sum(names.values()), + "named_values_with_static_size": len(names), "unknown_size_values": unknown, + "graph_value_count": len(graph_names), + "values_without_persisted_static_size": sorted(graph_names - known), + "output_logical_bytes": {v.name:value_size(v) for v in model.graph.output}, + "largest_named_values": sorted(names.items(), key=lambda x:(-x[1],x[0]))[:10], + "scope": "Static ONNX logical sizes, including aliases. Not measured IR live storage or a process RAM requirement.", + } + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args(argv) + out = new_output_dir(args.output_dir) + report = { + "gate":"preflight:w3-full-assets", "status":"FAIL", "passed":False, "stage":"source_evidence", + "full_ir_executed":False, "w3_exit_accepted":False, + "scope":"Asset integrity and static size preparation only; no ORT/IR/full-model numerical acceptance.", + "required_before_full_acceptance":[ + "W1 human acceptance and interface/risk decisions", + "Medium and pretrained subgraph numeric/memory baselines", + "Measured host capacity and bounded full IR/ORT runner", + "Full IR vs ORT <1e-4, three independent reproductions and successful Nightly", + ], + } + start = time.perf_counter() + try: + report.update(source_evidence()) + report["stage"] = "asset_hashes" + files = verify_files(args.model_dir) + report["files"] = files + report["source_revision"] = MANIFEST["revision"] + report["stage"] = "onnx_contract" + report["onnx_contract"] = inspect_model(args.model_dir, files) + report["stage"] = "static_size_accounting" + model = onnx.load(str(args.model_dir / "model.onnx"), load_external_data=False) + report["node_count"] = len(model.graph.node) + report["operator_counts"] = dict(Counter(node.op_type for node in model.graph.node)) + report["sizes"] = size_accounting(model) + report["weights_file_bytes"] = sum(item["bytes"] for item in files if item["name"] != "model.onnx") + report.update(passed=True, status="PASS", stage="complete") + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + report.update(elapsed_seconds=time.perf_counter()-start, + process_peak_rss_bytes=process_peak_rss_bytes(), + peak_scope="This metadata-only preflight process, not full parser/IR execution") + write_reports(out,report) + return 0 if report["passed"] else 1 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_layer_diff/README.md b/probes/w3_layer_diff/README.md new file mode 100644 index 0000000..94c18dd --- /dev/null +++ b/probes/w3_layer_diff/README.md @@ -0,0 +1,65 @@ +# W3 offline checkpoint comparison + +Reproduction targets **Ubuntu 24.04 x86_64, Bash and Python 3.12**. Complete the [Linux environment and asset setup](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md) first. Run commands from the repository root in the same Bash session; the setup exports `SCRATCHV_PYTHON`, `SCRATCHV_CC` and `SCRATCHV_QEMU`. + +This tool compares recorded NPZ tensors without executing a model. It reuses +`probes.w2_qwen3_small.diagnostics.compare_outputs`: exact shape and dtype, +finite numeric values, and strict `max_abs < atol` (default `1e-5`). + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" probes/w3_layer_diff/run.py --actual output/w3/medium/traces/full_seed_0/ir_diagnostic_none.npz --reference output/w3/medium/traces/full_seed_0/reference.npz --schema output/w3/medium/trace_schema.json --out output/w3/layerdiff-full +``` + +`--out` must be new. The shared W3 publisher writes `report.json`, `report.md` +and `report.html` atomically per file, with JSON last. Publication errors fail +the command. No network, pickle loading or model execution is involved. + +The schema is UTF-8 JSON with an explicit ordered checkpoint list: + +```json +{"version":1,"checkpoints":[{"name":"layer_0.hidden","shape":[1,256,128],"dtype":"float32","layer":0,"checkpoint":"hidden","sequence_axis":1}]} +``` + +Only `name`, `shape`, and `dtype` are mandatory in each entry. `layer` is a +nonnegative integer; `checkpoint` is a local selector name; `sequence_axis` is +an optional valid axis or null. `--reference-schema` can provide a second schema +which must match exactly, including checkpoint order and metadata. Every NPZ +must have exactly the schema names, no missing/extra arrays or duplicate ZIP +members. Duplicate JSON keys/checkpoint names, object arrays, shape/type +disagreements and nonfinite values fail before comparison. ZIP expanded size +and schema logical size are limited by `--max-bytes` (default 2 GiB per NPZ); +schema size is limited to 8 MiB. NPY v1/v2 headers and payload sizes are checked +against the schema before NumPy allocates an array. This is validation of local evidence, not a +claim to safely process arbitrary hostile archives. + +Repeat `--layer N` or `--checkpoint NAME` to select checkpoints. A checkpoint +selector matches an exact name or the optional local `checkpoint` field. +Layer and checkpoint filters intersect; unknown, duplicate and empty selections +fail. All evidence names, shapes, dtypes and finite values are still validated. +Every explicitly selected comparison reports **PARTIAL**, even when the +selection happens to cover every checkpoint; it cannot produce a full PASS. + +Exit codes: `0` = all recorded checkpoints compared and passed; `1` = numeric +difference, invalid evidence or report publication failure; `2` = selected +checkpoints passed with partial coverage. Reports include ordered per-checkpoint +errors, the first divergence, the worst absolute-error element, source evidence +and artifact SHA-256 hashes. A full checkpoint PASS establishes no claim about +whole-model correctness, causal/padding tests or backend acceptance. + +## IR memory observations + +`IRInterpreter.run(..., collect_memory_stats=True)` populates +`ExecutionResult.memory_stats`; it is `None` by default. The optional observation +records input/initializer logical bytes, peak retained logical bytes, peak +deduplicated NumPy backing storage, and retained value counts. Inputs retain +their existing copy semantics; initializers count referenced bound globals +(including scalar global constants). Views count in logical bytes but share one +storage owner. ALLOCA buffers are counted. Final return copies are included in +peaks and separately reported, but excluded from retained counts. + +These are instruction-boundary snapshots of values retained by the interpreter, +not RSS or total process allocation. They exclude caller arrays, transient +NumPy kernel buffers, Python object sizes and allocator overhead. The observer +does not release values, change kernels, alter control flow or add tensor copies. +Use the separate W3 process RSS metric for process-level memory evidence. diff --git a/probes/w3_layer_diff/__init__.py b/probes/w3_layer_diff/__init__.py new file mode 100644 index 0000000..be43338 --- /dev/null +++ b/probes/w3_layer_diff/__init__.py @@ -0,0 +1 @@ +"""Offline W3 checkpoint evidence comparison.""" diff --git a/probes/w3_layer_diff/run.py b/probes/w3_layer_diff/run.py new file mode 100644 index 0000000..7be1e81 --- /dev/null +++ b/probes/w3_layer_diff/run.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Compare recorded checkpoint arrays without executing a model or loading pickle.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +from pathlib import Path +import sys +import zipfile + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.w2_qwen3_small.diagnostics import compare_outputs, tensor_diff +from probes.w3_common import source_evidence, write_reports + +DEFAULT_MAX_BYTES = 2 * 1024**3 + + +class EvidenceError(ValueError): + def __init__(self, message, checkpoint=None, details=None): + super().__init__(message) + self.checkpoint = checkpoint + self.details = details + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise EvidenceError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _integer(value): + return isinstance(value, int) and not isinstance(value, bool) + + +def load_schema(path: Path) -> list[dict]: + if path.stat().st_size > 8 * 1024**2: + raise EvidenceError("schema exceeds 8 MiB limit") + document = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique_object, + parse_constant=lambda value: (_ for _ in ()).throw( + EvidenceError(f"nonfinite JSON constant: {value}"))) + if (not isinstance(document, dict) or not _integer(document.get("version")) + or document["version"] != 1): + raise EvidenceError("schema must be an object with version=1") + entries = document.get("checkpoints") + if not isinstance(entries, list) or not entries: + raise EvidenceError("schema checkpoints must be a nonempty ordered list") + seen = set() + for entry in entries: + if not isinstance(entry, dict) or not {"name", "shape", "dtype"} <= set(entry): + raise EvidenceError("each checkpoint requires name, shape and dtype") + name = entry["name"] + if not isinstance(name, str) or not name or name in seen: + raise EvidenceError(f"empty, invalid or duplicate checkpoint name: {name!r}") + seen.add(name) + shape = entry["shape"] + if (not isinstance(shape, list) + or any(not _integer(dim) or dim < 0 for dim in shape)): + raise EvidenceError("shape must contain nonnegative integer dimensions", name) + try: + if not isinstance(entry["dtype"], str): + raise TypeError("dtype must be a string") + dtype = np.dtype(entry["dtype"]) + except (TypeError, ValueError) as exc: + raise EvidenceError(f"invalid dtype: {entry['dtype']!r}", name) from exc + if dtype.kind not in "biuf" or dtype.hasobject: + raise EvidenceError("dtype must be real numeric or boolean, without objects", name) + if "layer" in entry and (not _integer(entry["layer"]) or entry["layer"] < 0): + raise EvidenceError("layer must be a nonnegative integer", name) + if "checkpoint" in entry and (not isinstance(entry["checkpoint"], str) + or not entry["checkpoint"]): + raise EvidenceError("checkpoint must be a nonempty string", name) + axis = entry.get("sequence_axis") + if axis is not None and (not _integer(axis) or not -len(shape) <= axis < len(shape)): + raise EvidenceError("sequence_axis is outside checkpoint rank", name) + return entries + + +def load_arrays(path: Path, entries: list[dict], max_bytes=DEFAULT_MAX_BYTES) -> dict: + """Preflight ZIP membership/size, then load numeric NPY arrays with no pickle.""" + if not _integer(max_bytes) or max_bytes < 1: + raise EvidenceError("max_bytes must be a positive integer") + names = [entry["name"] for entry in entries] + by_name = {entry["name"]: entry for entry in entries} + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + raw_names = [member.filename for member in members] + if len(set(raw_names)) != len(raw_names): + raise EvidenceError("NPZ contains duplicate ZIP members") + if any(not name.endswith(".npy") or name == ".npy" for name in raw_names): + raise EvidenceError("NPZ must contain only named .npy array members") + keys = [name[:-4] for name in raw_names] + # NpzFile accepts both member paths and names without the .npy suffix; + # keys such as x and x.npy would otherwise resolve to the same member. + if set(keys) & set(raw_names): + raise EvidenceError("NPZ contains ambiguous checkpoint/member name aliases") + if len(set(keys)) != len(keys) or set(keys) != set(names): + missing, extra = sorted(set(names) - set(keys)), sorted(set(keys) - set(names)) + first = next((name for name in names if name not in keys), None) + raise EvidenceError(f"NPZ names must exactly match schema; missing={missing}, extra={extra}", first) + if sum(member.file_size for member in members) > max_bytes: + raise EvidenceError("NPZ expanded data exceeds max_bytes") + if sum(math.prod(entry["shape"]) * np.dtype(entry["dtype"]).itemsize + for entry in entries) > max_bytes: + raise EvidenceError("schema logical data exceeds max_bytes") + # Inspect headers before NumPy can allocate arrays. A tiny forged member + # claiming a huge shape must not bypass the expanded ZIP size limit. + for member in members: + name = member.filename[:-4] + entry = by_name[name] + with archive.open(member) as stream: + version = np.lib.format.read_magic(stream) + if version == (1, 0): + shape, _, dtype = np.lib.format.read_array_header_1_0(stream, max_header_size=10000) + elif version == (2, 0): + shape, _, dtype = np.lib.format.read_array_header_2_0(stream, max_header_size=10000) + else: + raise EvidenceError(f"unsupported NPY version: {version}", name) + if list(shape) != entry["shape"] or dtype != np.dtype(entry["dtype"]): + raise EvidenceError(f"{name}: NPY header shape/dtype disagrees with schema", name) + if dtype.hasobject or dtype.kind not in "biuf": + raise EvidenceError(f"{name}: NPY dtype is not safe numeric data", name) + payload_bytes = math.prod(shape) * dtype.itemsize + if stream.tell() + payload_bytes != member.file_size: + raise EvidenceError(f"{name}: NPY payload size disagrees with header", name) + result = {} + with np.load(path, allow_pickle=False, max_header_size=10000) as archive: + for entry in entries: + name = entry["name"] + try: + array = archive[name] + except (ValueError, TypeError, EOFError) as exc: + raise EvidenceError(f"cannot safely load checkpoint {name}: {exc}", name) from exc + if list(array.shape) != entry["shape"]: + raise EvidenceError(f"{name}: array shape {list(array.shape)} disagrees with schema {entry['shape']}", name) + if array.dtype != np.dtype(entry["dtype"]): + raise EvidenceError(f"{name}: array dtype {array.dtype} disagrees with schema {entry['dtype']}", name) + if array.dtype.kind not in "biuf" or not np.isfinite(array).all(): + diagnostic = tensor_diff(array, array) + raise EvidenceError(f"{name}: nonfinite or unsupported array", name, + dict(name=name, evidence_path=str(path.resolve()), + worst_index=diagnostic["worst_index"], + value=diagnostic["actual_value"], + reason=diagnostic["reason"])) + result[name] = array + return result + + +def select_names(entries: list[dict], layers=(), checkpoints=()) -> list[str]: + if len(set(layers)) != len(layers) or len(set(checkpoints)) != len(checkpoints): + raise EvidenceError("duplicate layer/checkpoint selections are not allowed") + known_layers = {entry["layer"] for entry in entries if "layer" in entry} + if any(not _integer(layer) or layer < 0 or layer not in known_layers for layer in layers): + raise EvidenceError(f"layer selection is outside schema layers {sorted(known_layers)}") + known_checkpoints = {entry["name"] for entry in entries} + known_checkpoints.update(entry["checkpoint"] for entry in entries if "checkpoint" in entry) + if set(checkpoints) - known_checkpoints: + raise EvidenceError(f"unknown checkpoint selections: {sorted(set(checkpoints) - known_checkpoints)}") + names = [entry["name"] for entry in entries + if (not layers or entry.get("layer") in layers) + and (not checkpoints or entry["name"] in checkpoints + or entry.get("checkpoint") in checkpoints)] + if not names: + raise EvidenceError("layer/checkpoint selection has an empty intersection") + return names + + +def compare_evidence(actual_path, reference_path, schema_path, *, reference_schema_path=None, + layers=(), checkpoints=(), atol=1e-5, max_bytes=DEFAULT_MAX_BYTES): + # tensor_diff owns the W2 finite positive tolerance contract, even for bad inputs. + tensor_diff(np.array([], dtype="float32"), np.array([], dtype="float32"), atol) + entries = load_schema(Path(schema_path)) + reference_entries = load_schema(Path(reference_schema_path)) if reference_schema_path else entries + if entries != reference_entries: + raise EvidenceError("actual/reference schemas must match exactly in checkpoint order and metadata") + names = select_names(entries, layers, checkpoints) + # Always validate every member, including unselected evidence, before comparison. + actual = load_arrays(Path(actual_path), entries, max_bytes) + expected = load_arrays(Path(reference_path), reference_entries, max_bytes) + comparison = compare_outputs({name: actual[name] for name in names}, + {name: expected[name] for name in names}, names, atol=atol) + partial = bool(layers or checkpoints) + passing = comparison["passed"] + worst = max(comparison["checkpoints"], + key=lambda row: (math.inf if row["reason"] == "absolute error overflow" + else row["max_abs"] if row["max_abs"] is not None else -1), + default=None) + return dict(gate="w3_layer_diff", status="FAIL" if not passing else ("PARTIAL" if partial else "PASS"), + passed=passing and not partial, selected_passed=passing, partial=partial, + coverage=dict(total_checkpoints=len(entries), compared_checkpoints=len(names), + complete=not partial, selected_names=names, + evidence_validated_checkpoints=len(entries)), + atol=float(atol), first_divergence=comparison["first_divergence"], + worst_element=worst, checkpoints=comparison["checkpoints"], + claim="Comparison covers recorded checkpoints only; no whole-model or backend acceptance is implied.") + + +def _file_evidence(path): + path = Path(path) + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024**2), b""): + digest.update(chunk) + return dict(path=str(path.resolve()), size_bytes=path.stat().st_size, sha256=digest.hexdigest()) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--actual", required=True, type=Path) + parser.add_argument("--reference", required=True, type=Path) + parser.add_argument("--schema", required=True, type=Path) + parser.add_argument("--reference-schema", type=Path) + parser.add_argument("--out", required=True, type=Path) + parser.add_argument("--layer", type=int, action="append", default=[]) + parser.add_argument("--checkpoint", action="append", default=[]) + parser.add_argument("--atol", type=float, default=1e-5) + parser.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES) + args = parser.parse_args(argv) + report = dict(gate="w3_layer_diff", status="FAIL", passed=False) + try: + # Existing evidence is never overwritten, including on report errors. + args.out.mkdir(parents=True, exist_ok=False) + except OSError as exc: + print(f"FAIL: cannot create fresh output directory: {exc}", file=sys.stderr) + return 1 + try: + report.update(source_evidence()) + report["artifacts"] = {name: _file_evidence(path) for name, path in + (("actual", args.actual), ("reference", args.reference), + ("schema", args.schema), ("reference_schema", args.reference_schema)) + if path is not None} + report.update(compare_evidence(args.actual, args.reference, args.schema, + reference_schema_path=args.reference_schema, + layers=args.layer, checkpoints=args.checkpoint, + atol=args.atol, max_bytes=args.max_bytes)) + except Exception as exc: + report.update(status="FAIL", passed=False, error=f"{type(exc).__name__}: {exc}", + first_divergence=getattr(exc, "checkpoint", None), + worst_element=getattr(exc, "details", None)) + try: + write_reports(args.out, report) + except Exception as exc: + print(f"FAIL: report publication failed: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + print(f"{report['status']}: {args.out / 'report.json'}") + return 0 if report["passed"] else (2 if report["status"] == "PARTIAL" else 1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_full/EVIDENCE.md b/probes/w3_qwen3_full/EVIDENCE.md new file mode 100644 index 0000000..83e78aa --- /dev/null +++ b/probes/w3_qwen3_full/EVIDENCE.md @@ -0,0 +1,58 @@ +# 完整 W3 结果的离线复核 + +复现统一采用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 环境与资产准备](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md),再在同一个 Bash 会话、仓库根目录执行命令;该指南设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC` 和 `SCRATCHV_QEMU`。 + +`scripts/verify_w3_evidence.py` 对**已有七案例原始产物**重新计算误差,不执行模型、不下载资产。 +它复用完整模型 runner 的 `validate_worker`、`compare_case`、`invariants`,以及安全 NPZ 读取器。 + +在仓库根目录,使用已有 Python 3.12 和项目锁定依赖运行: + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B scripts/verify_w3_evidence.py \ + --evidence-dir output/downloaded-w3-run/raw/w3-full \ + --output-dir output/w3-evidence-audit-01 +``` + +输出目录必须不存在;不会覆盖被审核的原报告。 +输出为 `report.json`、`report.md`、`report.html`,退出码 `0` 表示复核通过,`1` 表示证据无效或不足。 +非法参数、已有输出目录等启动错误同样返回非零。 + +## 所需内容与检查 + +需要 full runner 的完整输出目录:顶层报告,以及每个案例的 `inputs.npz`、两个后端的报告、 +普通/诊断 `logits.npy`、`diagnostic_logits.npy`、`checkpoints.npz` 和 `checkpoint_schema.json`。 +仅下载 reports artifact 不足以复核,必须同时保留 raw artifact 并保持目录结构。CI raw artifact 内含 `w3-full/` 前缀:指定 `gh run download ... -n w3-full-numeric-raw --dir output/downloaded-w3-run/raw` 后,证据根是 `output/downloaded-w3-run/raw/w3-full`。完整下载和报告 SHA256 获取方法见 [Linux CI 产物说明](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md#产物与失败定位)。 + +- 七组案例完整、顺序固定,输入 dtype/shape/有限值以及每个数值均符合确定性输入生成规则。 +- 模型文件描述符与固定 manifest 一致。可选 `--model-dir PATH` 进一步核对本地模型文件哈希,仍不执行模型。 +- worker 报告哈希、产物大小/哈希、checkpoint schema、执行步数、耗时和 ORT 配置符合 runner 的验证规则。 +- 每组普通/诊断 logits 一致,IR/ORT 对全部 256 个位置重新比较,严格 `max_abs < 1e-4`,`rtol=0`。 +- 30 个检查点的 dtype、shape、有限值和误差重新计算;其误差用于诊断定位,不额外替代或改变 logits 验收门槛。 +- 两个后端各自的未来 token 隔离、padding 隔离,共四项 invariant 重新计算。 +- 重新计算结果必须与原报告一致;仅有 `passed: true` 不能通过。 +- 解释器数值模式和策略一致,并受当前验证器支持。当前已提交 reference 契约为 `numpy-fp32-reference-v4`,所有 IR worker 的 `cpu_strategy` 必须一致,且完整 profile 的字段和值须与 `avx2-fma3` 或 `avx512` 的规范契约匹配;不是仅检查一个策略名字。 + +v4 复核读取生产报告保存的 `cpu_strategy`,不从审计机器 CPU 或 `SCRATCHV_FP32_REFERENCE_CPU` 环境变量推断生产策略。完整 profile 校验包括 `matmul_k_block`、`matmul_row_tile`、`matmul_row_tail`、`matmul_column_tail`、`matmul_layout` 和 `matmul_vector_policy`,不能缺字段或仅靠版本名字通过。因此可以在另一种 CPU 上核对已保存的数组,无需控制或执行本机 ORT。这里的跨机器数组核对不证明该机器实际执行模型也能通过。 + +当前 v4 验证器不接受历史 v2/v3 profile,须使用报告对应源码版本中的验证器,或重新生成 v4 完整证据。不能手动改旧报告的 profile、CPU 策略或源码哈希。`SCRATCHV_FP32_REFERENCE_CPU=auto|avx2-fma3|avx512` 是实际 reference 执行的选项,默认 `auto`;它不用于把旧证据转换到新契约,也不控制 ORT dispatch。 + +## 结论边界 + +`audit:w3-full-saved-evidence` 的 PASS 只说明**保存的文件和数值结果自洽**。 +它不证明生产者身份,也不代表第二人独立执行模型、当前源码已运行、Linux 通过、Nightly 通过或团队验收。 +本工具无法通过保存文件独立证明报告中的历史耗时与执行声明确实发生。 + +默认 SHA-256 检查发现文件与报告不一致;若报告和数据同时被替换,它不能充当数字签名。 +可从可信渠道预先独立保留原始顶层报告哈希,然后传入: + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B scripts/verify_w3_evidence.py --evidence-dir output/downloaded-w3-run/raw/w3-full --output-dir output/w3-evidence-audit-02 --expected-report-sha256 ORIGINAL_REPORT_SHA256 +``` + +`source_comparison` 明确列出原报告中生产源码与当前审计源码的更改、新增、缺失。 +源码不一致时,只要保存的数值契约仍受当前验证器支持,就可复核旧数组;但**不能将旧报告升级成当前源码执行的证明**。不受支持的旧 profile 仍会失败。 +报告同时保存 producer 环境、审计器环境和版本化数值策略。生产环境中的 `environment.numeric_runtime.blas_environment.OPENBLAS_CORETYPE` 是环境变量原值;`numpy_openblas` 的 `status/libraries` 及可用库项的 `core_name/config` 是可选运行诊断。审计不得以本机 CPU SIMD 或 BLAS 内核替填生产环境,也不能从该环境变量推断生产库已采用某个内核。诊断不可用或历史字段缺失不豁免任何数组数值门槛。 + +当前已提交 v4 的[第四轮 Linux 执行](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)(`1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`)已在七组完整执行、preparation 与手动汇总通过;完整 raw 数组已下载验真、离线 audit 通过。实际执行 PASS 和保存证据 audit PASS 分别记录,实际 audit 报告及 SHA256、原样保留的源码换行差异说明见 [第四轮实测记录](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md)。本说明本身不替代该原始记录。 diff --git a/probes/w3_qwen3_full/LOCALIZE.md b/probes/w3_qwen3_full/LOCALIZE.md new file mode 100644 index 0000000..0af0093 --- /dev/null +++ b/probes/w3_qwen3_full/LOCALIZE.md @@ -0,0 +1,46 @@ +# 完整模型的局部数值定位 + +复现统一采用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 环境与资产准备](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md),再在同一个 Bash 会话、仓库根目录执行命令;该指南设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC` 和 `SCRATCHV_QEMU`。 + +`localize.py` 将同一个完整 ORT 层输入送入原图切片的 ORT 和 ScratchV IR,帮助区分当前层局部误差与上游累计误差。它保留完整 FP32 运算、真实权重和所有 padding query。不会接受 W3 完整数值 gate,也不会修改固定模型或门槛。 + +先用本目录的 `run.py` 生成完整模型证据,再运行: + +```bash +set -euo pipefail +W3_MODEL=/absolute/path/to/qwen3-0.6b-onnx +export OMP_NUM_THREADS='1' +export OPENBLAS_NUM_THREADS='1' +export MKL_NUM_THREADS='1' +export PYTHONIOENCODING='utf-8' +export SCRATCHV_FP32_REFERENCE_CPU='auto' +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_qwen3_full/localize.py \ + --model-dir "$W3_MODEL" \ + --case-dir output/full-run/short_17 \ + --output-dir output/full-localize-new \ + --layer 0 --layer 21 +``` + +依赖使用完整探测的固定 CPU 环境。输出目录必须不存在;不指定 `--layer` 时仅诊断第一层,层编号从 0 到 27,可重复指定不同层。原始 case 必须包含 `inputs.npz` 和 `ort/`、`ir/` 下完整 worker 的报告、logits、检查点及 schema。工具重新核验固定资产 SHA256、输入/mask、报告记录的数组哈希、普通/诊断 logits 一致性和固定图的 30 检查点结构。历史 IR/ORT 的源码指纹必须彼此一致,但不要求与新增诊断工具后的源码集相同;旧报告和当前诊断的源码指纹分别保留。 + +诊断沿用原 IR worker 记录的 `fp32_mode`,并核对完整 reference profile;无模式字段的旧报告按 `native` 解释。当前已提交 reference 版本为 `numpy-fp32-reference-v4`,`SCRATCHV_FP32_REFERENCE_CPU` 可设为 `auto`(默认)、`avx2-fma3` 或 `avx512`。本机自动解析出的策略必须与原报告 `ir.fp32_profile.cpu_strategy` 一致;若跨 CPU 诊断,先读取原报告,在启动 Python 前显式设置同一策略。否则工具会拒绝不一致的 profile,不会静默换用当前机器策略。 + +复现时另外保留原报告与本机 `environment.numeric_runtime` 中的 `blas_environment.OPENBLAS_CORETYPE`、`numpy_openblas.status/libraries` 及可用库项的 `core_name/config`。CPU SIMD 支持、参考策略和实际 BLAS 内核不同;环境变量原值不证明库已采用该内核。可选诊断不可用时保留原说明,不把它作为跳过数值检查的理由。 + +这个环境变量只改变本次 IR reference 算术策略,不控制本机 ORT dispatch;局部诊断会实际执行本机 ORT/IR,因此不能把“重放保存的 IR 策略”当作两后端必然一致的保证。这与只读取原始数组的 [离线 audit](EVIDENCE.md) 不同,后者无需在当前机器选择或执行生产 CPU 策略。 + +未知、互相矛盾或不受当前实现支持的 profile 会失败,不能自动换成最新策略。历史 v2/v3 的局部诊断应使用对应的代码版本,或重新生成 v4 完整证据;原报告的模式、profile 和源码哈希不得改写。 + +v4 的 profile 还包含 MatMul 分块、尾行/单列处理与布局字段;即使 `cpu_strategy` 相同,也必须与保存证据的完整契约一致。当前 v4 实现不能直接用 v3 证据运行并宣称是在复现原算术策略。 + +所选层按固定图的依赖关系切片;计算节点与权重不重写,以保存的前一层 ORT hidden state 作为共同输入。每层记录 13 个检查点,包含 Q/K RMSNorm、RoPE、Attention probabilities/context、残差及 MLP。第一次归约的 `Pow/ReduceMean/epsilon/rsqrt` 在选择第 0 层时额外记录。 + +选择第 0 层时还自动运行五项相同输入原语实验:input RMSNorm 的 ReduceMean、Q/K 投影 MatMul、Attention QK MatMul、Softmax。FP64 仅用于估计原语的数学误差,不参与 IR/ORT 执行、不替换任何输出或正式参考。Softmax 另记录不可见 key 概率和行和偏差。 + +`report.json`、`report.md`、`report.html` 保存误差、最差索引、有效/padding 分项、耗时、进程峰值 RSS 和源码/资产指纹。`layer-XX-values.npz` 保存原始检查点,`op-*-values.npz` 保存原语输入和双方输出,`.onnx` 是本次使用的实际 FP32 局部图。大文件仅在本地输出目录生成。 + +退出码 0 与 `status=COMPLETE` 仅表示诊断完成;局部误差超过 `1e-4` 会照实记录为比较不通过,不伪装成工具执行失败。资产、输入、schema、执行或有限值检查失败时退出 1。无论诊断完成与否,`numeric_gate_passed=false`、`w3_exit_accepted=false`,原完整模型失败报告保持原样。 + +不能单凭一个大值中间张量超 `1e-4` 判定语义错误。例如 6000 量级 FP32 数值的一个 ULP 已可能超过该阈值。应同时查看同输入对照、原语误差、值域和原始 mask 概率,最终仍以完整 runner 的全部 logits `<1e-4` 验收。 + +当前已提交 v4 的[第四轮 Linux 完整运行](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)(`1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`)已七组完整执行通过;raw 数组 audit 也已通过。该结论来自完整 runner,局部诊断和本说明不替代完整 gate 或团队验收。 diff --git a/probes/w3_qwen3_full/README.md b/probes/w3_qwen3_full/README.md new file mode 100644 index 0000000..5b51e48 --- /dev/null +++ b/probes/w3_qwen3_full/README.md @@ -0,0 +1,87 @@ +# 完整 Qwen3 IR 数值验证 + +复现统一采用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 环境与资产准备](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md),再在同一个 Bash 会话、仓库根目录执行命令;该指南设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC` 和 `SCRATCHV_QEMU`。 + +该入口实际执行固定发布的 28 层 Qwen3-0.6B FP32 ONNX,比较 ScratchV IR 与 ORT 的完整 logits,包括右侧 padding query。它使用优化前 IR(none),不会把资产检查、两层模型或部分输入通过视为完整模型数值验收。 + +## 验证范围 + +默认运行七组输入:两组满长随机输入、单 token、17/255 个有效 token,以及改变未来 token、改变 padding token 的输入。固定形状为 INT64 input_ids `[1,256]` 和 FP32 加性 mask `[1,1,256,256]`,输出为 FP32 `[1,256,151936]`。输入是可复现的原始 embedding ID,用于数值测试,不作为 tokenizer 或语言生成质量证据。 + +每组输入分别启动 ORT 和 IR 独立进程;每个后端运行普通与诊断两次,要求两份完整 logits 逐元素完全一致。跨后端对普通、诊断 logits 分别执行严格的 `max_abs < 1e-4`、`rtol=0`,同时报告有效位置和 padding 位置的误差。因果性和 padding 隔离另有四项跨输入检查。 + +诊断保存 embedding、28 层 residual 输出、final norm 共 30 个检查点,按固定图的实际连接定位。中间张量的 `1e-4` 标记用于定位误差,正式数值门槛作用于完整 logits;某些中间激活很大,单个 FP32 ULP 已可能大于 `1e-4`。报告保留这些标记,不将它们隐藏,也不把中间误差与最终输出误差混为一谈。 + +## 运行 + +在 W3 工作树根目录,使用 [固定依赖](../../requirements/qwen3-small-probe.txt)。模型目录必须包含已发布且匹配 manifest 的完整 ONNX 与三份权重分片;入口不下载、不修改模型。 + +完整入口默认使用 `--fp32-mode reference`,当前已提交策略为 `numpy-fp32-reference-v4`;请在复现命令中显式写出模式。解释器 API 默认仍为 `native`,追加 `--fp32-mode native` 才复现原 NumPy 策略。两种模式、不同版本和不同 CPU 策略的 profile 必须分别记录与验收,不能将一份报告的通过结论用于另一种模式。该参考模式不是跨 CPU、BLAS 或 ORT 版本逐元素一致的保证。 + +环境变量 `SCRATCHV_FP32_REFERENCE_CPU` 只作用于 `reference` IR 的算术策略,允许以下取值: + +| 值 | 行为 | +|---|---| +| `auto`(未设置时的默认值) | 从 NumPy 报告的可用 CPU 能力推断;具有 AVX2/FMA3 时,根据 AVX512F 是否可用选择下列一种策略 | +| `avx2-fma3` | 显式采用对应的参考算术顺序,profile 记录该值 | +| `avx512` | 显式采用对应的参考算术顺序,profile 记录该值 | + +显式设置不会控制 ORT 的 CPU dispatch,也不是启用或禁用真实硬件指令的开关;两条参考路径仍由 NumPy 实现。跨机器重放保存的算术策略时,可以在启动 Python 前显式指定报告中的 `cpu_strategy`,但必须重新比较本机 ORT 输出。未知变量取值会报错;`auto` 模式无法识别所需 CPU 能力,或发现 `NPY_DISABLE_CPU_FEATURES` 遮蔽相关 AVX/FMA 能力时也会明确报错,要求显式选择并记录策略。 + +v4 把矩阵乘的分块与内存布局纳入版本化 profile;数值策略名字与 CPU 策略相同仍不足以替代完整字段核对: + +| profile 字段 | `avx2-fma3` | `avx512` | +|---|---|---| +| `matmul_k_block` | 128 | 128;K≤128 时保留原 NumPy 路径 | +| `matmul_row_tile` | 2 | `null`,保留原宽矩阵路径 | +| `matmul_row_tail` | `zero-pad-to-two-then-trim` | `native` | +| `matmul_column_tail` | `zero-pad-single-column-to-two-then-trim` | `native` | +| `matmul_layout` | `contiguous-matrix-operands-per-k-block` | `native-strides` | +| `matmul_vector_policy` | `native-numpy-vector-promotion` | `native-numpy-vector-promotion` | + +AVX2 矩阵路径按两个输出行计算,尾行和单列临时补零后裁回原输出形状,每个 K 分块的两个矩阵操作数使用 C 连续切片;向量提升继续遵循 NumPy。以上是参考计算契约,不改模型图、权重、输出形状或误差门槛,也不是性能优化验收。本节描述已提交的 v4 实现;第四轮 Linux 已取得同提交七组完整运行 PASS,普通/诊断完整 logits 及四项不变量最大误差均为 0;这是 v4 的新实测证据,历史 v3 或单例结果不能替代它。 + +~~~bash +set -euo pipefail +W3_MODEL=/absolute/path/to/qwen3-0.6b-onnx +export SCRATCHV_FP32_REFERENCE_CPU=auto +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_qwen3_full/run.py \ + --model-dir "$W3_MODEL" --output-dir output/w3-full-new \ + --fp32-mode reference --worker-timeout 1800 --max-worker-memory-gib 10 +~~~ + +需要复现单个失败时追加 `--case short_17`。成功但仅覆盖部分 case 返回 PARTIAL/2,完整七组和不变量全部通过才返回 PASS/0;实际执行失败或数值超标返回 FAIL/1。中断返回 130。输出目录必须是新目录。 + +每个 worker 的超时和采样内存上限覆盖本次拥有的整棵进程树。Linux 使用独立进程组,采样整个进程树的 RSS,并在超时、超限或中断时清理本次拥有的进程。采样间隔为 0.2 秒,共享内存页可能按进程重复计数。它是超限终止措施,不是操作系统的硬内存配额,短暂峰值仍可能超过界限。失败保留阶段进度、日志、已完成产物和报告;中断、超时及超限会清理本次拥有的进程树。 + +## 证据与时长 + +统一 `report.json`、`report.md` 和 `report.html` 包含每组输入、每个 worker、完整比较和四项不变量。`cases[].comparison.logits` 是最终精度验收数据,`checkpoints` 及 `first_divergence` 定位最早超过诊断阈值的检查点。 + +Summary 将普通图与诊断图的最终 logits 分开,明确实际 `native/reference` 模式,并报告最大绝对误差、余弦相似度和相对 L2 误差;逐用例索引默认折叠,完整检查点和机器证据仅保存在 `report.json`,不重复嵌入摘要。后两项由实际输出和参考数组分块计算,使用 FP64 累积,不改变原有通过门槛。不能把参考兼容模式的通过结果归给默认 NumPy 模式。旧报告不补写或重新命名为本轮运行;离线重算保存数组也不代表重新执行了模型。 + +PPL(真实文本预测的困惑度)和 zero-shot(无示例任务评分)本轮未评测。这里的随机 token 输入用于数值对齐,不能作为语言质量语料;加入这类评测前需要固定真实文本、Tokenizer、L=256 的分窗及计分方式。 + +每组目录内有 `inputs.npz`、ORT/IR 日志,以及两个后端各自的完整 logits、诊断 logits、检查点 NPZ、schema、报告和阶段进度。报告包含固定模型、输入、输出及源码哈希;统一入口重新校验原始数组与报告一致性。 + +`cases[].workers[].stages_seconds` 可查看模型解析、普通执行、诊断执行和写盘时间;统一 `elapsed_seconds` 包括所有 worker 及文件核对。`process_peak_rss_bytes` 为该 worker 的全生命周期峰值,`resource_monitor` 是父进程采样观测。两者测量口径不同。 + +每个 IR worker 的 `ir.fp32_profile` 记录实际数值策略,包括 `name=numpy-fp32-reference-v4` 与已解析的 `cpu_strategy`;`auto` 是选择方式,不是保存后的策略名称。`environment.numeric_runtime` 补充 CPU 架构、NumPy 构建/运行诊断和线程环境。`blas_environment.OPENBLAS_CORETYPE` 保存环境变量原值,未设置时为 `null`,不是实际生效内核的证明;`numpy_openblas` 保存可选诊断的总体 `status`、`libraries`,可用库项包含 `path`、`status`、`core_name`、`config`,不可用时保存 `reason` 或 `error`。该诊断只读取已装载的 NumPy 私有 OpenBLAS 库,系统 BLAS/MKL 等可明确为不可用。 + +CPU SIMD 能力、IR 的 `cpu_strategy` 与实际 BLAS 内核须分别看待;上述字段也不证明 ORT 使用了哪一种内部内核。可选诊断不可用或旧证据缺少该字段,不能掩盖数值失败,所有门槛照常执行。独立复现应一并保留这些字段、环境变量原值、依赖版本与源码哈希。 + +离线 audit 按生产报告中保存的 CPU 策略核对整个 profile,不从审计机器 CPU 推断生产策略,也不会重新运行模型。当前 v4 工具严格验证 v4 契约;历史 v2/v3 原始证据应在其对应源码版本下核对,不能把报告字段改为 v4 或直接沿用旧版本的 PASS。每个提交和策略的完整七组以对应运行结果验收。 + +完整执行使用只读映射权重、显式借用 initializer、最后一次使用后释放中间 binding;默认解释器 API 仍复制和保留原有数据。只读映射文件在调用期间必须保持不变。解释器的 `peak_numpy_storage_bytes` 计入当时已绑定的借用 buffer,排除已解除绑定但仍由调用方持有的数组、内核临时缓冲和观察回调的拷贝;请同时参考实际进程 RSS。observer 是受信任的同步回调,只读 view 用于防止误写,不提供隔离沙箱。 + +需要继续定位失败时,使用 [同输入逐层与原语诊断](LOCALIZE.md)。该工具读取已保存的完整执行证据,保存层内检查点和局部 FP64 参考误差;诊断完成不代表完整数值验收通过。 + +## CI 与阶段结论 + +[完整模型手动 CI](../../.github/workflows/w3-full-numeric.yml) 已随 PR #96 发布。2026-10-05,作者在提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 的[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)中通过 preparation、完整七组 full 和汇总;v4/avx2-fma3、OpenBLAS Haswell 环境下普通/诊断 logits、30 检查点与四不变量最大误差均为 0,完整 runner 用时 `1477.314971525 s`(约 24.6 分钟)。reports 已下载并核对,完整 raw 已下载验真、离线 audit 通过。前三轮失败及旧 v2/v3 记录保留在 [Linux 实测记录](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md)。实际完整数值失败仍使 job 失败;没有自动忽略 padding、调大阈值、启用低精度或回退到小模型。 + +该流程同时支持 `workflow_call`,供显式启用的 [Nightly 编排](../../.github/workflows/w3-nightly.yml) 复用;两套自动检查均成功才能通过汇总。Linux 运行、定时启用和资源限制说明见 [Linux CI 与 Nightly](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md)。收到完整保存目录后,可按 [离线证据复核](EVIDENCE.md) 重新检查数组;这不执行模型,也不替代独立复现。 + +CI 将报告、哈希和 schema 放入 `w3-full-numeric-reports`(保留 30 天),完整原始输入、logits 和检查点另放入 `w3-full-numeric-raw`(保留 3 天,也包含报告)。需要独立复核原始数值时,应在过期前下载 raw 产物;其解压目录中保留 `w3-full/` 前缀,所以 `--evidence-dir` 应为 `下载目录/w3-full`。下载命令、报告 SHA256 获取与完整 audit 示例见 [产物与失败定位](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md#产物与失败定位)。仅有摘要和哈希不足以重新比较数组。失败或中断运行的产物可能不完整,须先核对报告状态与覆盖范围。 + +即使本地数值 PASS,`w3_exit_accepted` 仍为 false:W1 人工前置验收、团队确认、E1/E2/E5 独立复现及 Nightly 状态由团队单独核对。[Linux 完整执行记录](../../docs/llm-deploy-v1.0/W3/W3-完整模型执行与数值诊断报告.md) 保留固定提交、模式和数据,不代表之后版本已经重跑。当前结论以本次完整运行生成的 `report.json`、实际 profile 和源码指纹为准。 diff --git a/probes/w3_qwen3_full/__init__.py b/probes/w3_qwen3_full/__init__.py new file mode 100644 index 0000000..67f94da --- /dev/null +++ b/probes/w3_qwen3_full/__init__.py @@ -0,0 +1 @@ +"""Bounded full Qwen3 numerical verification workers and gate.""" diff --git a/probes/w3_qwen3_full/cases.py b/probes/w3_qwen3_full/cases.py new file mode 100644 index 0000000..aa5b542 --- /dev/null +++ b/probes/w3_qwen3_full/cases.py @@ -0,0 +1,29 @@ +"""Fixed numerical input suite for the released FP32 L256 model.""" +from __future__ import annotations + +import numpy as np + +from scratchv.runtime.llm_inputs import prepare_inputs, QWEN3_VOCAB_SIZE + +CASE_NAMES = ("full_seed_0", "full_seed_42", "one_token", "short_17", "short_255", + "changed_future", "changed_padding") +SEQ = 256 + + +def input_cases(): + """Raw embedding IDs; this suite makes no tokenizer or language-quality claim.""" + result = [] + for name, seed, length in ((CASE_NAMES[0], 0, 256), (CASE_NAMES[1], 42, 256), + (CASE_NAMES[2], 7, 1), (CASE_NAMES[3], 7, 17), + (CASE_NAMES[4], 7, 255)): + tokens = np.random.default_rng(seed).integers(1, QWEN3_VOCAB_SIZE, length, dtype=np.int64) + feed = prepare_inputs(tokens, pad_token_id=0).as_feed() + result.append((name, length, feed)) + future = {key: value.copy() for key, value in result[0][2].items()} + future["input_ids"][:, 64:] = future["input_ids"][:, 64:] % (QWEN3_VOCAB_SIZE - 1) + 1 + result.append((CASE_NAMES[5], 256, future)) + padding = {key: value.copy() for key, value in result[3][2].items()} + padding["input_ids"][:, 17:] = np.random.default_rng(99).integers( + 1, QWEN3_VOCAB_SIZE, (1, 239), dtype=np.int64) + result.append((CASE_NAMES[6], 17, padding)) + return result diff --git a/probes/w3_qwen3_full/comparison.py b/probes/w3_qwen3_full/comparison.py new file mode 100644 index 0000000..478b864 --- /dev/null +++ b/probes/w3_qwen3_full/comparison.py @@ -0,0 +1,62 @@ +"""Bounded-memory absolute-error comparisons, including padded query rows.""" +from __future__ import annotations + +import math +import numpy as np + +from scratchv.verification.numeric_metrics import numeric_metrics, undefined_numeric_metrics + +ATOL = 1e-4 + + +def compare_tensor(actual, reference, *, atol=ATOL, chunk_elements=262144): + if not math.isfinite(atol) or atol <= 0: + raise ValueError("atol must be finite and positive") + if type(chunk_elements) is not int or chunk_elements < 1: + raise ValueError("chunk_elements must be a positive integer") + row = {"passed": False, "atol": atol, "rtol": 0, + "shape": list(actual.shape), "reference_shape": list(reference.shape), + "dtype": str(actual.dtype), "reference_dtype": str(reference.dtype), + "elements": int(actual.size), "max_abs": None, "finite": False} + row.update(undefined_numeric_metrics("comparison is invalid")) + if actual.shape != reference.shape or actual.dtype != np.float32 or reference.dtype != np.float32: + row["reason"] = "shape or FP32 dtype mismatch" + row.update(undefined_numeric_metrics(row["reason"])) + return row + if not actual.size: + row["reason"] = "empty comparison is not numerical evidence" + row.update(undefined_numeric_metrics(row["reason"])) + return row + # nditer handles strided slices without a full-size ravel/copy allocation. + chunks = np.nditer([actual, reference], flags=["external_loop", "buffered"], + op_flags=[["readonly"], ["readonly"]], order="C", buffersize=chunk_elements) + maximum, offset, worst = -1.0, 0, None + for a, b in chunks: + finite = np.isfinite(a) & np.isfinite(b) + if not finite.all(): + index = offset + int(np.flatnonzero(~finite)[0]) + row.update(reason="nonfinite value", worst_index=list(np.unravel_index(index, actual.shape))) + row["worst_index"] = [int(x) for x in row["worst_index"]] + row.update(undefined_numeric_metrics(row["reason"])) + return row + difference = np.abs(a.astype(np.float64) - b.astype(np.float64)) + local = int(np.argmax(difference)) + if float(difference[local]) > maximum: + maximum = float(difference[local]) + worst = (offset + local, float(a[local]), float(b[local])) + offset += a.size + row.update(passed=maximum < atol, finite=True, max_abs=maximum, + worst_index=[int(x) for x in np.unravel_index(worst[0], actual.shape)], + actual_value=worst[1], reference_value=worst[2]) + row.update(numeric_metrics(actual, reference, block_elements=chunk_elements)) + return row + + +def compare_positions(actual, reference, valid_length, *, atol=ATOL): + if actual.ndim != 3 or reference.ndim != 3 or not 1 <= valid_length <= actual.shape[1]: + raise ValueError("expected [batch, sequence, width] and a valid query length") + total = compare_tensor(actual, reference, atol=atol) + total["valid_queries"] = compare_tensor(actual[:, :valid_length], reference[:, :valid_length], atol=atol) + total["padding_queries"] = (compare_tensor(actual[:, valid_length:], reference[:, valid_length:], atol=atol) + if valid_length < actual.shape[1] else None) + return total diff --git a/probes/w3_qwen3_full/localize.py b/probes/w3_qwen3_full/localize.py new file mode 100644 index 0000000..c4fd787 --- /dev/null +++ b/probes/w3_qwen3_full/localize.py @@ -0,0 +1,390 @@ +"""Localize full-Qwen3 numerical differences using identical layer inputs. + +A completed diagnosis is not a numerical gate: large intermediate activations +may differ by more than 1e-4 in a few FP32 ULPs. The full-model runner remains +the authority for full logits. This tool never changes execution precision. +""" +from __future__ import annotations + +import argparse +import copy +import gc +import json +from pathlib import Path +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np +import onnx + +from probes.w1_qwen3_export.run import verify_files +from probes.w3_common import (new_output_dir, process_peak_rss_bytes, sha256_file, + source_evidence, write_reports) +from probes.w3_layer_diff.run import load_arrays, load_schema +from probes.w3_qwen3_full.comparison import compare_tensor +from probes.w3_qwen3_full.run import validate_worker +from probes.w3_qwen3_full.worker import checkpoint_schema, load_inputs + + +def require(value, message): + if not value: + raise ValueError(message) + + +def selected_layers(values): + values = [0] if values is None else list(values) + require(bool(values) and all(type(v) is int and 0 <= v < 28 for v in values), + "Layers must be integer indices in [0, 27]") + require(len(values) == len(set(values)), "Duplicate layer selection") + return sorted(values) + + +def compare_queries(actual, reference, valid, axis): + require(actual.ndim > axis and actual.shape[axis] == 256 and 1 <= valid <= 256, + "Expected complete L256 query axis and valid length") + row = compare_tensor(actual, reference) + prefix = [slice(None)] * actual.ndim + suffix = prefix.copy() + prefix[axis], suffix[axis] = slice(0, valid), slice(valid, None) + row["valid_queries"] = compare_tensor(actual[tuple(prefix)], reference[tuple(prefix)]) + row["padding_queries"] = (compare_tensor(actual[tuple(suffix)], reference[tuple(suffix)]) + if valid < 256 else None) + row["query_axis"] = axis + return row + + +def probability_diagnostics(probabilities, mask): + require(probabilities.dtype == np.float32 and probabilities.ndim == 4 + and probabilities.shape[0] == 1 and probabilities.shape[2:] == (256, 256) + and np.isfinite(probabilities).all(), "Invalid attention probabilities") + require(mask.shape == (1, 1, 256, 256) and mask.dtype == np.float32, + "Invalid attention mask") + invisible = np.broadcast_to(mask < 0, probabilities.shape) + return {"masked_probability_max_abs": float(np.abs(probabilities[invisible]).max()), + "row_sum_max_abs_error": float(np.abs(probabilities.sum(-1, dtype=np.float64) - 1).max()), + "minimum_probability": float(probabilities.min())} + + +def fp64_error_oracle(op, feed, ir_value, ort_value): + """Double precision is only an independent local error estimate.""" + require(ir_value.dtype == ort_value.dtype == np.float32, + "Execution outputs must remain FP32") + if op == "MatMul": + truth = np.matmul(feed["a"].astype(np.float64), feed["b"].astype(np.float64)) + elif op == "ReduceMean": + truth = feed["x"].astype(np.float64).mean(-1, keepdims=True) + elif op == "Softmax": + x = feed["x"].astype(np.float64) + exp = np.exp(x - x.max(-1, keepdims=True)) + truth = exp / exp.sum(-1, keepdims=True) + else: + raise ValueError("Unsupported local diagnostic operation") + return {"scope": "diagnostic only; neither execution result nor acceptance output", + "ir_max_abs": float(np.abs(ir_value - truth).max()), + "ort_max_abs": float(np.abs(ort_value - truth).max())} + + +class LayerSlice: + """Extract exact pinned-graph ancestors; never reconstruct model arithmetic.""" + + def __init__(self, model, model_dir): + self.model, self.model_dir = model, Path(model_dir) + self.nodes = list(model.graph.node) + self.producers = {name: node for node in self.nodes for name in node.output} + self.info = {v.name: v for v in [*model.graph.value_info, *model.graph.input, *model.graph.output]} + self.initializers = {v.name: v for v in model.graph.initializer} + require(len(self.producers) == sum(len(n.output) for n in self.nodes), + "Duplicate graph values") + + @staticmethod + def one(nodes, description): + rows = list(nodes) + require(len(rows) == 1, f"Expected unique {description}") + return rows[0] + + def norm(self, weight): + node = self.one((n for n in self.nodes if n.op_type == "Mul" and weight in n.input), weight) + value = next(x for x in node.input if x != weight) + cast = self.producers[value] + require(cast.op_type == "Cast", "Unexpected normalization cast") + mul = self.producers[cast.input[0]] + require(mul.op_type == "Mul", "Unexpected normalization product") + cast = self.producers[mul.input[0]] + require(cast.op_type == "Cast", "Unexpected normalization input") + return cast.input[0], node.output[0] + + def projection(self, weight): + tr = self.one((n for n in self.nodes if n.op_type == "Transpose" and weight in n.input), weight) + mat = self.one((n for n in self.nodes if n.op_type == "MatMul" and tr.output[0] in n.input), weight + " MatMul") + return mat.input[0], mat.output[0] + + def describe(self, index): + p = f"model.model.layers.{index}." + hidden, input_norm = self.norm(p + "input_layernorm.weight") + residual, postnorm = self.norm(p + "post_attention_layernorm.weight") + _, qnorm = self.norm(p + "self_attn.q_norm.weight") + _, knorm = self.norm(p + "self_attn.k_norm.weight") + _, vproj = self.projection(p + "self_attn.v_proj.weight") + context, attn_output = self.projection(p + "self_attn.o_proj.weight") + _, mlp = self.projection(p + "mlp.down_proj.weight") + final = self.one((n for n in self.nodes if n.op_type == "Add" and mlp in n.input), "MLP residual").output[0] + begin, end = self.nodes.index(self.producers[input_norm]), self.nodes.index(self.producers[final]) + block = self.nodes[begin:end + 1] + softmax = self.one((n for n in block if n.op_type == "Softmax"), "layer Softmax").output[0] + probs = self.one((n for n in block if n.op_type == "Cast" and softmax in n.input), "Softmax cast").output[0] + + def rope(norm): + tr = self.one((n for n in block if n.op_type == "Transpose" and norm in n.input), "norm transpose") + mul = self.one((n for n in block if n.op_type == "Mul" and tr.output[0] in n.input), "RoPE cosine product") + return self.one((n for n in block if n.op_type == "Add" and mul.output[0] in n.input), "RoPE sum").output[0] + + return hidden, {"input_norm": input_norm, "q_norm": qnorm, "k_norm": knorm, "v_proj": vproj, + "rope_q": rope(qnorm), "rope_k": rope(knorm), "attn_probs": probs, + "attn_context": context, "attn_output": attn_output, "attn_residual": residual, + "post_attention_norm": postnorm, "mlp": mlp, "residual": final} + + def build(self, hidden, outputs): + stop, needed, queue, chosen = {hidden, "attention_mask"}, set(outputs), list(outputs), set() + while queue: + value = queue.pop() + if value in stop or value not in self.producers: + continue + node = self.producers[value] + if node.name in chosen: + continue + chosen.add(node.name) + for name in node.input: + if name and name not in needed: + needed.add(name) + queue.append(name) + nodes = [copy.deepcopy(n) for n in self.nodes if n.name in chosen] + weights = [onnx.numpy_helper.from_array(onnx.numpy_helper.to_array(t, base_dir=str(self.model_dir)), name) + for name, t in self.initializers.items() if name in needed and name not in stop] + for node in nodes: + for attr in node.attribute: + if attr.type == onnx.AttributeProto.TENSOR and attr.t.external_data: + attr.t.CopyFrom(onnx.numpy_helper.from_array( + onnx.numpy_helper.to_array(attr.t, base_dir=str(self.model_dir)), attr.t.name)) + graph = onnx.helper.make_graph(nodes, "pinned_layer_slice", + [copy.deepcopy(self.info[x]) for x in sorted(stop) if x in needed], + [copy.deepcopy(self.info[x]) for x in outputs], initializer=weights) + result = onnx.helper.make_model(graph, opset_imports=self.model.opset_import) + result.ir_version = self.model.ir_version + onnx.checker.check_model(result) + return result + + +def run_pair(model, feed, outputs, path, *, fp32_mode="native"): + """Actual ORT and production Parser/IR execution, with FP32 feeds unchanged.""" + import onnxruntime as ort + from scratchv.frontend.onnx_parser import ONNXParser + from scratchv.verification.ir_interpreter import IRInterpreter + + require(all(v.dtype == np.float32 and np.isfinite(v).all() for v in feed.values()), + "Local execution feeds must be finite FP32") + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + options.enable_cpu_mem_arena = False + options.log_severity_level = 3 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + diagnostic_path = path.with_name(path.stem + "-diagnostic.onnx") + onnx.save_model(model, diagnostic_path) + session = ort.InferenceSession(model.SerializeToString(), sess_options=options, providers=["CPUExecutionProvider"]) + ort_values = dict(zip(outputs, session.run(list(outputs.values()), feed))) + ordinary = copy.deepcopy(model) + wanted = outputs[next(reversed(outputs))] + last = next(v for v in ordinary.graph.output if v.name == wanted) + last = copy.deepcopy(last) + del ordinary.graph.output[:] + ordinary.graph.output.append(last) + onnx.save_model(ordinary, path) + parser = ONNXParser() + program = parser.parse(str(path)) + for value in parser.initializers.values(): + value.setflags(write=False) + bindings = {parser._value_map[value].name: name for name, value in outputs.items()} + require(len(bindings) == len(outputs), "Aliased/ambiguous diagnostic checkpoints") + ir_values = {} + + def observe(name, value): + if name in bindings: + require(bindings[name] not in ir_values, "Checkpoint executed twice") + ir_values[bindings[name]] = value.copy() + + result = IRInterpreter(program).run(feed, initializers=parser.initializers, + memory_mode="last_use", copy_initializers=False, observer=observe, + fp32_mode=fp32_mode) + require(set(ir_values) == set(outputs), "Missing executed checkpoint") + require(all(v.dtype == np.float32 and np.isfinite(v).all() for v in [*ir_values.values(), *ort_values.values()]), + "Nonfinite/non-FP32 execution output") + return ir_values, ort_values, {"executed_steps": result.executed_steps, + "fp32_mode": fp32_mode, + "model_sha256": sha256_file(path), "model_file": path.name, + "ort_model_file": diagnostic_path.name, + "ort_model_sha256": sha256_file(diagnostic_path), + "checkpoint_onnx_names": dict(outputs)} + + +def primitive(op, feed, shape): + inputs, initializers, attrs = list(feed), [], {} + if op == "ReduceMean": + inputs += ["axes"] + initializers = [onnx.numpy_helper.from_array(np.array([-1], dtype=np.int64), "axes")] + attrs["keepdims"] = 1 + elif op == "Softmax": + attrs["axis"] = -1 + graph = onnx.helper.make_graph([onnx.helper.make_node(op, inputs, ["y"], **attrs)], op, + [onnx.helper.make_tensor_value_info(k, 1, v.shape) for k, v in feed.items()], + [onnx.helper.make_tensor_value_info("y", 1, shape)], initializer=initializers) + model = onnx.helper.make_model(graph, opset_imports=[onnx.helper.make_opsetid("", 18)]) + model.ir_version = 10 + return model + + +def local_primitives(extract, ort_values, mask, valid, out, *, fp32_mode="native"): + rows = [] + + def run(name, op, feed, shape, axis=1): + ir, ort_values, evidence = run_pair(primitive(op, feed, shape), feed, {"result": "y"}, + out / f"op-{name}.onnx", fp32_mode=fp32_mode) + a, b = ir["result"], ort_values["result"] + row = {"name": name, "op": op, "comparison": compare_queries(a, b, valid, axis), + "fp64_error_oracle": fp64_error_oracle(op, feed, a, b), **evidence} + if op == "Softmax": + row["probabilities"] = {"ir": probability_diagnostics(a, mask), "ort": probability_diagnostics(b, mask)} + rows.append(row) + values_path = out / f"op-{name}-values.npz" + np.savez(values_path, ir=a, ort=b, **feed) + row["values"] = {"file": values_path.name, "bytes": values_path.stat().st_size, + "sha256": sha256_file(values_path)} + return b + + run("input_norm_reduce_mean", "ReduceMean", {"x": ort_values["square"]}, [1, 256, 1]) + for key, width in (("q", 2048), ("k", 1024)): + name = f"model.model.layers.0.self_attn.{key}_proj.weight" + weight = onnx.numpy_helper.to_array(extract.initializers[name], base_dir=str(extract.model_dir)).T.copy() + run(f"projection_{key}", "MatMul", {"a": ort_values["input_norm"], "b": weight}, [1, 256, width]) + q, k = ort_values["rope_q"], np.repeat(ort_values["rope_k"], 2, axis=1) + scores = run("attention_scores", "MatMul", {"a": q, "b": k.transpose(0, 1, 3, 2).copy()}, [1, 16, 256, 256], 2) + scores = scores * np.float32(128 ** -0.5) + mask + run("attention_softmax", "Softmax", {"x": scores}, [1, 16, 256, 256], 2) + return rows + + +def validate_reference_metadata(record, schema, expected_schema, files, input_hash): + require(record.get("gate") == "numeric:w3-full-worker" and record.get("backend") == "ort" + and record.get("passed") is True and record.get("status") == "PASS", "Expected executed ORT worker") + require(record.get("files") == files, "Reference model assets differ") + require(record.get("input", {}).get("sha256") == input_hash, "Reference inputs differ") + require(schema == expected_schema and record.get("checkpoints") == schema, + "Reference schema differs from fixed graph dataflow") + + +def validate_producer_pair(ort_record, ir_record): + require(bool(ort_record.get("source_sha256")) + and ort_record["source_sha256"] == ir_record.get("source_sha256"), + "Historical IR/ORT producer source fingerprints differ") + + +def producer_fp32_mode(ir_record): + details = ir_record.get("ir", {}) + mode = details.get("fp32_mode", "native") + require(mode in ("native", "reference"), "Unknown producer FP32 mode") + require(ir_record.get("fp32_mode", mode) == mode, "Producer FP32 modes disagree") + if mode == "reference": + from scratchv.verification.fp32_reference import profile + require(details.get("fp32_profile") == profile(), + "Unsupported historical FP32 reference profile") + else: + require("fp32_profile" not in details or details["fp32_profile"] == {"name": "numpy-native"}, + "Unsupported historical native FP32 profile") + return mode + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-dir", type=Path, required=True) + parser.add_argument("--case-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--layer", type=int, action="append", help="Repeat for selected indices 0..27; default 0") + args = parser.parse_args(argv) + try: + layers = selected_layers(args.layer) + except ValueError as exc: + parser.error(str(exc)) + out = new_output_dir(args.output_dir) + report = {"gate": "diagnostic:w3-full-localize", "status": "FAIL", "passed": False, + "diagnostic_complete": False, "numeric_gate_passed": False, "w3_exit_accepted": False, + "scope": "Selected exact layer slices and equal-input FP32 primitives; cannot accept the full-model gate", + "selected_layers": layers, "layers": [], "primitives": [], + "execution_dtype": "float32", "fp64_scope": "local error oracle only"} + start = time.perf_counter() + try: + report.update(source_evidence()) + files = verify_files(args.model_dir) + feed, valid = load_inputs(args.case_dir / "inputs.npz") + model = onnx.load(args.model_dir / "model.onnx", load_external_data=False) + expected = checkpoint_schema(model) + folder = args.case_dir / "ort" + schema = load_schema(folder / "checkpoint_schema.json") + record = json.loads((folder / "report.json").read_text(encoding="utf-8")) + ir_folder = args.case_dir / "ir" + ir_record = json.loads((ir_folder / "report.json").read_text(encoding="utf-8")) + validate_producer_pair(record, ir_record) + input_hash = sha256_file(args.case_dir / "inputs.npz") + validate_reference_metadata(record, schema, expected, files, input_hash) + # Historical source identity is retained rather than falsely claiming a + # new localizer file existed when that complete reference was generated. + validate_worker(folder, "ort", args.case_dir / "inputs.npz", record["source_sha256"], files) + validate_worker(ir_folder, "ir", args.case_dir / "inputs.npz", record["source_sha256"], files) + fp32_mode = producer_fp32_mode(ir_record) + report["fp32_mode"] = fp32_mode + states = load_arrays(folder / "checkpoints.npz", schema, max_bytes=64 * 1024**2) + report.update(files=files, valid_length=valid, input_sha256=input_hash, + reference_report_sha256=sha256_file(folder / "report.json"), + paired_ir_report_sha256=sha256_file(ir_folder / "report.json"), + reference_source_sha256=record["source_sha256"]) + extract = LayerSlice(model, args.model_dir) + for index in layers: + hidden, names = extract.describe(index) + require(hidden == expected[index]["onnx_name"] and names["residual"] == expected[index + 1]["onnx_name"], + "Extracted layer boundary differs from audited graph") + if index == 0: + names = {"square": "pow_1", "mean": "mean", "epsilon_add": "add", "rsqrt": "rsqrt", **names} + state = states["embedding" if index == 0 else f"layer_{index - 1}.output"] + pair_feed = {hidden: state, "attention_mask": feed["attention_mask"]} + graph = extract.build(hidden, list(names.values())) + ir, ort_values, evidence = run_pair(graph, pair_feed, names, out / f"layer-{index:02d}.onnx", + fp32_mode=fp32_mode) + comparisons = {name: compare_queries(ir[name], ort_values[name], valid, + 2 if name in ("rope_q", "rope_k", "attn_probs") else 1) for name in names} + report["layers"].append({"layer": index, "same_input": comparisons, **evidence, + "probabilities": {"ir": probability_diagnostics(ir["attn_probs"], feed["attention_mask"]), + "ort": probability_diagnostics(ort_values["attn_probs"], feed["attention_mask"])}}) + values_path = out / f"layer-{index:02d}-values.npz" + np.savez(values_path, **{"ir_" + k: v for k, v in ir.items()}, + **{"ort_" + k: v for k, v in ort_values.items()}) + report["layers"][-1]["values"] = {"file": values_path.name, "bytes": values_path.stat().st_size, + "sha256": sha256_file(values_path)} + if index == 0: + report["primitives"] = local_primitives(extract, ort_values, feed["attention_mask"], valid, + out, fp32_mode=fp32_mode) + del graph, ir, ort_values + gc.collect() + require(source_evidence()["source_sha256"] == report["source_sha256"], "Sources changed during diagnosis") + report.update(status="COMPLETE", passed=True, diagnostic_complete=True) + except KeyboardInterrupt: + report.update(interrupted=True, error="KeyboardInterrupt: local diagnosis cancelled") + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + report.update(elapsed_seconds=time.perf_counter() - start, process_peak_rss_bytes=process_peak_rss_bytes()) + write_reports(out, report) + return 130 if report.get("interrupted") else (0 if report["diagnostic_complete"] else 1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_full/resources.py b/probes/w3_qwen3_full/resources.py new file mode 100644 index 0000000..4821ad9 --- /dev/null +++ b/probes/w3_qwen3_full/resources.py @@ -0,0 +1,403 @@ +"""Observe and bound an owned full-model worker; no extra monitor dependency.""" +from __future__ import annotations + +import ctypes +import os +from pathlib import Path +import subprocess +import time + +from scripts.run_w2_acceptance import terminate_process_tree as _legacy_terminate + + +class _ResidentMemoryUnavailable(OSError): + """Linux can drop VmRSS while exit is still in progress, before zombie state.""" + + +_RSS_EXIT_GRACE_SECONDS = 0.2 +_RSS_EXIT_RECHECK_SECONDS = 0.01 + + +class _VerifiedProcessHandle: + """A handle already verified as a member of the owned Windows Job.""" + def __init__(self, handle): + self.handle = handle + + +class _WindowsJob: + """Own descendants before the suspended launcher executes any user code. + + Windows 8+ supports nesting under an existing sandbox Job. No breakaway + flags or UI limits are used. Assignment failure is fatal, not a fallback + to monitoring only the launcher. Handles are non-inheritable. + """ + def __init__(self): + from ctypes import wintypes as w + self.k = k = ctypes.WinDLL("kernel32", use_last_error=True) + prototypes = { + "CreateJobObjectW": ([ctypes.c_void_p, w.LPCWSTR], w.HANDLE), + "SetInformationJobObject": ([w.HANDLE, ctypes.c_int, ctypes.c_void_p, w.DWORD], w.BOOL), + "AssignProcessToJobObject": ([w.HANDLE, w.HANDLE], w.BOOL), + "QueryInformationJobObject": ([w.HANDLE, ctypes.c_int, ctypes.c_void_p, w.DWORD, ctypes.c_void_p], w.BOOL), + "IsProcessInJob": ([w.HANDLE, w.HANDLE, ctypes.POINTER(w.BOOL)], w.BOOL), + "TerminateJobObject": ([w.HANDLE, w.UINT], w.BOOL), + "OpenProcess": ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE), + "GetExitCodeProcess": ([w.HANDLE, ctypes.POINTER(w.DWORD)], w.BOOL), + "CloseHandle": ([w.HANDLE], w.BOOL), + "CreateToolhelp32Snapshot": ([w.DWORD, w.DWORD], w.HANDLE), + "Thread32First": ([w.HANDLE, ctypes.c_void_p], w.BOOL), + "Thread32Next": ([w.HANDLE, ctypes.c_void_p], w.BOOL), + "OpenThread": ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE), + "GetProcessIdOfThread": ([w.HANDLE], w.DWORD), + "ResumeThread": ([w.HANDLE], w.DWORD), + } + for name, (args, result) in prototypes.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + + class Basic(ctypes.Structure): + _fields_ = [("process_time", ctypes.c_longlong), ("job_time", ctypes.c_longlong), + ("flags", w.DWORD), ("min_working", ctypes.c_size_t), + ("max_working", ctypes.c_size_t), ("active", w.DWORD), + ("affinity", ctypes.c_size_t), ("priority", w.DWORD), ("scheduling", w.DWORD)] + class Limits(ctypes.Structure): + _fields_ = [("basic", Basic), ("io", ctypes.c_ulonglong * 6), + ("process_memory", ctypes.c_size_t), ("job_memory", ctypes.c_size_t), + ("peak_process", ctypes.c_size_t), ("peak_job", ctypes.c_size_t)] + self.handle = k.CreateJobObjectW(None, None) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + limits = Limits() + limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not k.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)): + error = ctypes.WinError(ctypes.get_last_error()) + self.close() + raise error + + def assign_and_resume(self, process): + from ctypes import wintypes as w + if not self.k.AssignProcessToJobObject(self.handle, int(process._handle)): + raise ctypes.WinError(ctypes.get_last_error()) + class ThreadEntry(ctypes.Structure): + _fields_ = [("size", w.DWORD), ("usage", w.DWORD), ("tid", w.DWORD), + ("pid", w.DWORD), ("priority", w.LONG), ("delta", w.LONG), ("flags", w.DWORD)] + snapshot = self.k.CreateToolhelp32Snapshot(4, 0) # TH32CS_SNAPTHREAD + if snapshot == ctypes.c_void_p(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + tids = [] + try: + entry = ThreadEntry() + entry.size = ctypes.sizeof(entry) + ok = self.k.Thread32First(snapshot, ctypes.byref(entry)) + while ok: + if entry.pid == process.pid: + tids.append(entry.tid) + ok = self.k.Thread32Next(snapshot, ctypes.byref(entry)) + if ctypes.get_last_error() != 18: # ERROR_NO_MORE_FILES + raise ctypes.WinError(ctypes.get_last_error()) + finally: + self.k.CloseHandle(snapshot) + if len(tids) != 1: + raise OSError("Suspended launcher must have exactly one primary thread") + thread = self.k.OpenThread(0x0002 | 0x0040, False, tids[0]) + if not thread: + raise ctypes.WinError(ctypes.get_last_error()) + try: + if self.k.GetProcessIdOfThread(thread) != process.pid: + raise OSError("Suspended primary thread ownership changed") + if self.k.ResumeThread(thread) != 1: + raise OSError("Suspended launcher did not have exactly one suspension") + finally: + self.k.CloseHandle(thread) + + def pids(self): + # QueryInformationJobObject includes descendant nested Jobs. Retry if + # more processes arrived between sizing and reading the PID list. + for capacity in (16, 64, 256, 1024, 4096, 16384): + buffer = ctypes.create_string_buffer(8 + capacity * ctypes.sizeof(ctypes.c_size_t)) + ok = self.k.QueryInformationJobObject(self.handle, 3, buffer, len(buffer), None) + assigned, count = (ctypes.c_ulong * 2).from_buffer(buffer) + if ok and count == assigned and count <= capacity: + return list((ctypes.c_size_t * count).from_buffer(buffer, 8)) + if not ok and ctypes.get_last_error() != 234: # ERROR_MORE_DATA + raise ctypes.WinError(ctypes.get_last_error()) + raise OSError("Owned worker Job exceeds bounded process enumeration") + + def sample(self): + from ctypes import wintypes as w + total = {"rss_bytes": 0, "private_commit_bytes": 0, "pids": []} + for pid in self.pids(): + handle = self.k.OpenProcess(0x1010, False, pid) + if not handle: + if pid not in self.pids(): + continue # Exited after the Job snapshot. + raise ctypes.WinError(ctypes.get_last_error()) + try: + member = w.BOOL() + if not self.k.IsProcessInJob(handle, self.handle, ctypes.byref(member)): + raise ctypes.WinError(ctypes.get_last_error()) + if not member.value: + # PID reuse cannot substitute an unrelated process: never + # inspect or terminate the new process's memory. + continue + try: + value = process_memory(_VerifiedProcessHandle(handle)) + except OSError: + code = w.DWORD() + if self.k.GetExitCodeProcess(handle, ctypes.byref(code)) and code.value != 259: + continue + raise + total["rss_bytes"] += value["rss_bytes"] + total["private_commit_bytes"] += value["private_commit_bytes"] or 0 + total["pids"].append(pid) + finally: + self.k.CloseHandle(handle) + return total + + def terminate(self): + if not self.k.TerminateJobObject(self.handle, 1): + raise ctypes.WinError(ctypes.get_last_error()) + deadline = time.monotonic() + 5 + while self.pids(): + if time.monotonic() > deadline: + raise OSError("Owned worker Job still has active descendants after termination") + time.sleep(0.01) + + def close(self): + if self.handle: + handle, self.handle = self.handle, None + if not self.k.CloseHandle(handle): + raise ctypes.WinError(ctypes.get_last_error()) + + +def spawn_owned(command, **kwargs): + """Create a worker tree whose ownership precedes all executed child code.""" + if os.name != "nt": + kwargs["start_new_session"] = True + process = subprocess.Popen(command, **kwargs) + process._scratchv_owned_group = True + return process + flags = kwargs.pop("creationflags", 0) + if flags & (0x01000000 | 0x00000004): + raise ValueError("Caller cannot request breakaway or manage suspended owned workers") + job, process = _WindowsJob(), None + try: + process = subprocess.Popen(command, creationflags=flags | 0x00000004, **kwargs) + process._scratchv_job = job + job.assign_and_resume(process) + return process + except BaseException as original: + # Before ResumeThread the launcher cannot have started descendants; + # after assignment closing the Job owns every descendant regardless. + try: + job.close() + except BaseException as cleanup: + original.add_note(f"Closing failed worker Job: {cleanup}") + try: + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + except BaseException as cleanup: + original.add_note(f"Reaping failed launcher: {cleanup}") + raise + + +def terminate_process_tree(process): + job = getattr(process, "_scratchv_job", None) + if job is not None: + try: + job.terminate() + finally: + job.close() + process._scratchv_job = None + process.wait(timeout=5) + else: + _legacy_terminate(process) + + +class _PosixGroup: + """Linux session/group membership, including children outliving launcher.""" + def __init__(self, process): + self.process = process + self.rss_exit_rechecks = 0 + + @staticmethod + def identity(pid): + text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + fields = text[text.rindex(")") + 2:].split() + return fields[0], int(fields[2]), int(fields[3]), int(fields[19]) + + def pids(self): + found = [] + for entry in Path("/proc").iterdir(): + if not entry.name.isdecimal(): + continue + try: + state, group, session, _ = self.identity(int(entry.name)) + except (FileNotFoundError, ProcessLookupError): + continue + if group == session == self.process.pid and state not in {"Z", "X", "x"}: + found.append(int(entry.name)) + return found + + def _member_memory(self, pid, before): + # exit_mm() can remove VmRSS before the process becomes a zombie or + # waitpid observes its exit. Only this specific missing-field case gets + # a short grace period; permission errors and malformed RSS fail closed. + deadline = None + while True: + try: + value = process_memory(pid) + except _ResidentMemoryUnavailable: + after = self.identity(pid) + if before[1:] != after[1:] or after[0] in {"Z", "X", "x"}: + return None + if deadline is None: + deadline = time.perf_counter() + _RSS_EXIT_GRACE_SECONDS + if time.perf_counter() >= deadline: + raise # Still the same live process and still unobservable. + self.rss_exit_rechecks += 1 + time.sleep(_RSS_EXIT_RECHECK_SECONDS) + continue + after = self.identity(pid) + if before[1:] != after[1:] or after[0] in {"Z", "X", "x"}: + return None + return value + + def sample(self): + total = {"rss_bytes": 0, "private_commit_bytes": None, "pids": []} + for pid in self.pids(): + try: + before = self.identity(pid) + if (before[1] != self.process.pid or before[2] != self.process.pid + or before[0] in {"Z", "X", "x"}): + continue + value = self._member_memory(pid, before) + if value is None: + continue # PID was reused/exited during sampling. + except (FileNotFoundError, ProcessLookupError): + continue + except OSError: + try: + after = self.identity(pid) + except (FileNotFoundError, ProcessLookupError): + continue + if after[0] in {"Z", "X", "x"}: + continue + raise + total["rss_bytes"] += value["rss_bytes"] + total["pids"].append(pid) + return total + + +def process_memory(pid): + """Current RSS, and private committed bytes on Windows (not virtual size).""" + if os.name == "nt": + from ctypes import wintypes + class Counters(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("faults", wintypes.DWORD), + ("peak_rss", ctypes.c_size_t), ("rss", ctypes.c_size_t), + ("peak_paged", ctypes.c_size_t), ("paged", ctypes.c_size_t), + ("peak_nonpaged", ctypes.c_size_t), ("nonpaged", ctypes.c_size_t), + ("pagefile", ctypes.c_size_t), ("peak_pagefile", ctypes.c_size_t), + ("private", ctypes.c_size_t)] + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel.OpenProcess.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + psapi = ctypes.WinDLL("psapi", use_last_error=True) + psapi.GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(Counters), wintypes.DWORD] + psapi.GetProcessMemoryInfo.restype = wintypes.BOOL + borrowed = isinstance(pid, _VerifiedProcessHandle) + handle = pid.handle if borrowed else kernel.OpenProcess(0x1010, False, pid) + if not handle: + raise OSError(ctypes.get_last_error(), "Cannot observe worker memory") + try: + values = Counters() + values.cb = ctypes.sizeof(values) + if not psapi.GetProcessMemoryInfo(handle, ctypes.byref(values), values.cb): + raise OSError(ctypes.get_last_error(), "Cannot read worker memory") + return {"rss_bytes": int(values.rss), "private_commit_bytes": int(values.private)} + finally: + if not borrowed: + kernel.CloseHandle(handle) + status = Path(f"/proc/{pid}/status").read_text(encoding="utf-8") + rows = dict(line.split(":", 1) for line in status.splitlines() if ":" in line) + if "VmRSS" not in rows: + raise _ResidentMemoryUnavailable(f"Cannot observe worker {pid} resident memory") + try: + resident = rows["VmRSS"].split() + if len(resident) != 2 or resident[1] != "kB": + raise ValueError("VmRSS must use the Linux kB unit") + rss = int(resident[0]) * 1024 + if rss < 0: + raise ValueError("VmRSS cannot be negative") + except ValueError as exc: + raise OSError(f"Cannot observe worker {pid} resident memory") from exc + return {"rss_bytes": rss, "private_commit_bytes": None} + + +def wait_bounded(process, *, timeout, max_memory_bytes, row, interval=0.2): + """A sampled guard, not an OS hard limit. Stop the owned tree on any failure.""" + started = time.perf_counter() + job = getattr(process, "_scratchv_job", None) + group = bool(getattr(process, "_scratchv_owned_group", False)) + tree = job if job is not None else _PosixGroup(process) if group else None + observation = {"samples": 0, "rss_peak_sampled_bytes": 0, "private_commit_peak_sampled_bytes": None, + "interval_seconds": interval, "limit_bytes": max_memory_bytes, + "ownership": "windows_job" if job else "posix_process_group", + "observed_pids": [], "peak_sampled_processes": 0, + "scope": ("Owned Windows Job and descendants; summed sampled RSS/private commit, shared pages may be counted per process, not a hard OS quota" + if job else "Owned Linux process group/session; summed sampled RSS, shared pages may be counted per process, not a hard OS quota")} + row["resource_monitor"] = observation + try: + if os.name == "nt" and isinstance(process, subprocess.Popen) and job is None: + raise ValueError("Windows workers must be created with spawn_owned before execution") + while process.poll() is None or (tree is not None and tree.pids()): + if time.perf_counter() - started > timeout: + raise TimeoutError(f"Full worker exceeded {timeout} seconds") + try: + values = tree.sample() if tree is not None else process_memory(process.pid) + except OSError: + if process.poll() is not None and (tree is None or not tree.pids()): + break + raise + observation["samples"] += 1 + observation["rss_peak_sampled_bytes"] = max(observation["rss_peak_sampled_bytes"], values["rss_bytes"]) + private = values["private_commit_bytes"] + pids = values.get("pids", [process.pid]) + observation["observed_pids"] = sorted(set(observation["observed_pids"]) | set(pids)) + observation["peak_sampled_processes"] = max(observation["peak_sampled_processes"], len(pids)) + if private is not None: + observation["private_commit_peak_sampled_bytes"] = max( + observation["private_commit_peak_sampled_bytes"] or 0, private) + if max(values["rss_bytes"], private or 0) > max_memory_bytes: + raise MemoryError(f"Full worker exceeded sampled memory limit {max_memory_bytes} bytes") + if process.poll() is not None: + time.sleep(interval) # Job children can outlive their launcher. + else: + try: + process.wait(timeout=interval) + except subprocess.TimeoutExpired: + pass + return process.wait(timeout=5) + except BaseException: + if process.poll() is None or job is not None or group: + try: + terminate_process_tree(process) + except (OSError, subprocess.SubprocessError) as cleanup: + # Preserve timeout/interruption rather than replacing its + # cause with any secondary cleanup diagnostic. + row["cleanup_error"] = f"{type(cleanup).__name__}: {cleanup}" + raise + finally: + if isinstance(tree, _PosixGroup): + observation["rss_exit_rechecks"] = tree.rss_exit_rechecks + if getattr(process, "_scratchv_job", None) is not None: + try: + job.close() + except OSError as cleanup: + row["cleanup_error"] = f"{type(cleanup).__name__}: {cleanup}" + process._scratchv_job = None + row["returncode"] = process.poll() + row["elapsed_seconds"] = time.perf_counter() - started diff --git a/probes/w3_qwen3_full/run.py b/probes/w3_qwen3_full/run.py new file mode 100644 index 0000000..2e1c364 --- /dev/null +++ b/probes/w3_qwen3_full/run.py @@ -0,0 +1,296 @@ +"""Full pinned Qwen3 IR/ORT comparison with isolated, resource-bounded workers.""" +from __future__ import annotations + +import argparse +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np +from probes.w1_qwen3_export.run import verify_files +from probes.w3_common import new_output_dir, sha256_file, source_evidence, write_reports +from probes.w3_layer_diff.run import load_schema, load_arrays +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases +from probes.w3_qwen3_full.comparison import ATOL, compare_tensor, compare_positions +from probes.w3_qwen3_full.resources import spawn_owned, wait_bounded + +CHECKPOINTS = ("embedding", *(f"layer_{i}.output" for i in range(28)), "final_norm") +LOGIT_SHAPE = (1, 256, 151936) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def checked_artifact(folder, descriptor, expected_name): + require(isinstance(descriptor, dict), f"Missing artifact {expected_name}") + require(descriptor.get("path") == expected_name, f"Unexpected artifact path: {expected_name}") + path = folder / expected_name + require(path.is_file() and path.resolve().parent == folder.resolve(), f"Missing/external artifact: {expected_name}") + require(path.stat().st_size == descriptor.get("bytes") and sha256_file(path) == descriptor.get("sha256"), + f"Altered artifact: {expected_name}") + return path + + +def load_logits(path): + value = np.load(path, mmap_mode="r", allow_pickle=False, max_header_size=10000) + require(value.shape == LOGIT_SHAPE and value.dtype == np.float32, "Wrong full logits shape/dtype") + return value + + +def saved_arithmetic_profile(mode, recorded): + """Validate saved arithmetic without guessing it from the auditing host CPU.""" + require(isinstance(recorded, dict), "Missing saved FP32 arithmetic profile") + if mode == "reference": + from scratchv.verification.fp32_reference import profile + strategy = recorded.get("cpu_strategy") + require(strategy in ("avx2-fma3", "avx512"), "Unsupported saved FP32 CPU profile") + canonical = profile(cpu_strategy=strategy) + else: + require(mode == "native", "Unsupported saved FP32 arithmetic profile") + canonical = {"name": "numpy-native"} + require(recorded == canonical and all(type(recorded[key]) is type(value) + for key, value in canonical.items()), "Noncanonical saved FP32 arithmetic profile") + return canonical + + +def validate_worker(folder, backend, input_path, sources, assets, *, expected_fp32_mode=None, + expected_fp32_profile=None): + require(expected_fp32_profile is None or expected_fp32_mode is not None, + "Saved FP32 profile requires an explicit mode") + for name in ("report.json", "report.md", "report.html"): + require((folder/name).is_file(), f"Missing worker {name}") + report = json.loads((folder/"report.json").read_text(encoding="utf-8")) + require(report.get("gate") == "numeric:w3-full-worker" and report.get("schema_version") == 1, + "Wrong worker identity") + require(report.get("backend") == backend and report.get("optimization_level") == "none", "Wrong backend") + require(report.get("passed") is True and report.get("status") == "PASS", "Worker not PASS") + require(report.get("full_model_executed") is True and report.get("full_ir_executed") is (backend == "ir"), + "Full model was not actually executed") + require(report.get("w3_exit_accepted") is False, "A worker cannot accept the team milestone") + require(report.get("source_sha256") == sources, "Worker production sources differ") + require(report.get("files") == assets, "Worker assets differ from fixed model") + require(report.get("input", {}).get("sha256") == sha256_file(input_path), "Worker input differs") + require(type(report.get("process_peak_rss_bytes")) is int and report["process_peak_rss_bytes"] > 0, + "Missing measured peak RSS") + timings = report.get("stages_seconds") + require(isinstance(timings, dict), "Missing worker execution timings") + for mode in ("ordinary", "diagnostic"): + name = f"{backend}_{mode}_execute" + duration = timings.get(name) + require(type(duration) in (int, float) and math.isfinite(duration) and duration > 0, + f"Missing/invalid worker execution timing: {name}") + if backend == "ort": + runtime = report.get("ort") + expected_runtime = {"provider": "CPUExecutionProvider", "graph_optimization": "disabled", + "intra_op_threads": 1, "inter_op_threads": 1, "cpu_mem_arena": False} + require(isinstance(runtime, dict) and all(type(runtime.get(key)) is type(value) + and runtime.get(key) == value for key, value in expected_runtime.items()), + "Worker ORT execution profile differs") + comparison = report.get("ordinary_diagnostic", {}) + require(comparison.get("passed") is True and comparison.get("comparison") == "exact" + and comparison.get("max_abs") == 0.0, "Diagnostic execution changed logits") + names = {"logits": "logits.npy", "diagnostic_logits": "diagnostic_logits.npy", + "checkpoints": "checkpoints.npz", "checkpoint_schema": "checkpoint_schema.json"} + artifacts = report.get("artifacts", {}) + for key, filename in names.items(): + checked_artifact(folder, artifacts.get(key), filename) + schema = load_schema(folder/"checkpoint_schema.json") + require(tuple(x["name"] for x in schema) == CHECKPOINTS, "Incomplete/out-of-order full checkpoints") + require(all(x["shape"] == [1, 256, 1024] and x["dtype"] == "float32" and x.get("sequence_axis") == 1 + for x in schema), "Wrong full checkpoint contract") + require(report.get("checkpoints") == schema, "Worker report/schema disagree") + ordinary, diagnostic = load_logits(folder/"logits.npy"), load_logits(folder/"diagnostic_logits.npy") + exact = compare_tensor(ordinary, diagnostic) + require(exact["passed"] and exact["max_abs"] == 0, "Ordinary/diagnostic arrays actually differ") + del ordinary, diagnostic + if backend == "ir": + ir = report.get("ir") + require(isinstance(ir, dict), "Missing IR execution profile") + instruction_count = ir.get("instruction_count") + require(type(instruction_count) is int and instruction_count > 0, + "Missing/invalid IR instruction count") + if expected_fp32_mode is not None: + from scratchv.verification.fp32_reference import profile + require(report.get("fp32_mode") == expected_fp32_mode, + "Worker FP32 execution mode differs") + # Live runs require the selected local strategy. Saved-array audits + # can explicitly supply a known producer strategy from another CPU; + # the complete canonical profile still has to match. + expected_profile = (saved_arithmetic_profile(expected_fp32_mode, expected_fp32_profile) + if expected_fp32_profile is not None else + profile() if expected_fp32_mode == "reference" else {"name": "numpy-native"}) + require(ir.get("fp32_mode") == expected_fp32_mode + and ir.get("fp32_profile") == expected_profile, + "Worker FP32 arithmetic profile differs") + for mode in ("ordinary", "diagnostic"): + execution = report.get("executions", {}).get(mode, {}) + # The pinned full graph is one straight-line block, including RETURN; + # every declared instruction must execute once in both runs. + require(type(execution.get("executed_steps")) is int + and execution["executed_steps"] == instruction_count, + "IR execution steps do not cover the declared instruction count") + memory = execution.get("memory_stats", {}) + require(type(memory.get("peak_numpy_storage_bytes")) is int and memory["peak_numpy_storage_bytes"] > 0, + "Missing IR storage measurement") + return report + + +def compare_case(folder, valid_length): + rows = [] + for filename in ("logits.npy", "diagnostic_logits.npy"): + actual, reference = load_logits(folder/"ir"/filename), load_logits(folder/"ort"/filename) + rows.append({"name": filename, **compare_positions(actual, reference, valid_length)}) + del actual, reference + schema = load_schema(folder/"ir/checkpoint_schema.json") + require(schema == load_schema(folder/"ort/checkpoint_schema.json"), "IR/ORT checkpoint schema differs") + actual = load_arrays(folder/"ir/checkpoints.npz", schema, max_bytes=64*1024**2) + reference = load_arrays(folder/"ort/checkpoints.npz", schema, max_bytes=64*1024**2) + checkpoints = [{"name": entry["name"], **compare_positions(actual[entry["name"]], reference[entry["name"]], valid_length)} + for entry in schema] + all_rows = checkpoints + rows + worst = max((r for r in all_rows if r["max_abs"] is not None), key=lambda x: x["max_abs"], default=None) + return {"passed": all(r["passed"] for r in rows), "logits": rows, "checkpoints": checkpoints, + "max_logits_abs": max(r["max_abs"] for r in rows), + "checkpoint_threshold_scope": "Diagnostic location only: large intermediate activations can exceed 1e-4 in one FP32 ULP; acceptance applies to complete output logits", + "first_divergence": next((r for r in all_rows if not r["passed"]), None), + "worst": worst, "atol": ATOL, "rtol": 0} + + +def invariants(out, successful): + rows = [] + for name, reference, prefix in (("changed_future", "full_seed_0", 64), ("changed_padding", "short_17", 17)): + if name not in successful or reference not in successful: + continue + for backend in ("ort", "ir"): + a = load_logits(out/name/backend/"logits.npy") + b = load_logits(out/reference/backend/"logits.npy") + rows.append({"case": name, "reference": reference, "prefix": prefix, "backend": backend, + **compare_tensor(a[:, :prefix], b[:, :prefix])}) + del a, b + return rows + + +def run_worker(args, folder, backend, input_path, row=None): + # Keep the selected venv symlink: resolving it runs the base interpreter. + command = [str(Path(args.python).absolute()), "-B", "-X", "utf8", + "probes/w3_qwen3_full/worker.py", "--backend", backend, + "--model-dir", str(args.model_dir.resolve()), "--input-file", str(input_path), + "--output-dir", str(folder/backend), "--optimization-level", "none", + "--fp32-mode", getattr(args, "fp32_mode", "reference")] + row = {} if row is None else row + row.update(backend=backend, passed=False, command=command) + env = dict(os.environ, PYTHONIOENCODING="utf-8", OMP_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1", MKL_NUM_THREADS="1") + options = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True} + with (folder/(backend+".log")).open("w", encoding="utf-8") as stream: + process = spawn_owned(command, cwd=ROOT, env=env, stdout=stream, stderr=subprocess.STDOUT, **options) + try: + row["returncode"] = wait_bounded(process, timeout=args.worker_timeout, + max_memory_bytes=int(args.max_worker_memory_gib*1024**3), row=row) + require(row["returncode"] == 0, f"{backend} worker exited {row['returncode']}") + row["passed"] = True + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + for filename in ("report.json", "progress.json"): + try: + child = json.loads((folder/backend/filename).read_text(encoding="utf-8")) + row["child_stage"] = child.get("stage") + row["child_error"] = child.get("error") + break + except (OSError, ValueError): + pass + return row + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-dir", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--python", default=sys.executable) + parser.add_argument("--worker-timeout", type=float, default=1800) + parser.add_argument("--max-worker-memory-gib", type=float, default=10) + parser.add_argument("--case", action="append", choices=CASE_NAMES) + parser.add_argument("--fp32-mode", choices=("native", "reference"), default="reference", + help="Explicit IR FP32 arithmetic policy; native reproduces the original NumPy baseline") + args = parser.parse_args(argv) + if any(not math.isfinite(v) or v <= 0 for v in (args.worker_timeout, args.max_worker_memory_gib)): + parser.error("Resource limits must be finite and positive") + if args.case and len(args.case) != len(set(args.case)): + parser.error("Duplicate case selection") + out = new_output_dir(args.output_dir) + selected = set(args.case or CASE_NAMES) + start = time.perf_counter() + report = {"gate": "numeric:ir-full-qwen3", "status": "FAIL", "passed": False, + "optimization_level": "none", "atol": ATOL, "rtol": 0, + "fp32_mode": args.fp32_mode, + "full_ir_executed": False, "w3_exit_accepted": False, + "scope": "Pinned 28-layer FP32 L256 IR vs ORT; strict 1e-4 on all ordinary/diagnostic logits including padding; 30 checkpoints localize errors", + "cases": [], "invariants": [], "required_cases": list(CASE_NAMES), + "selected_cases": [x for x in CASE_NAMES if x in selected], "stage": "sources"} + try: + report.update(source_evidence()) + report["stage"] = "assets" + assets = verify_files(args.model_dir) + report["files"] = assets + successful = set() + for name, valid, feed in input_cases(): + if name not in selected: + continue + report["stage"] = name + folder = out/name + folder.mkdir() + input_path = folder/"inputs.npz" + np.savez(input_path, **feed) + row = {"name": name, "valid_length": valid, "passed": False, "workers": [], + "input_sha256": sha256_file(input_path)} + report["cases"].append(row) + for backend in ("ort", "ir"): + worker = {"backend": backend, "passed": False} + row["workers"].append(worker) + run_worker(args, folder, backend, input_path, worker) + if worker["passed"]: + try: + evidence = validate_worker(folder/backend, backend, input_path, + report["source_sha256"], assets, + expected_fp32_mode=args.fp32_mode) + require(evidence["input"]["valid_length"] == valid, "Worker valid length differs") + worker.update(report_sha256=sha256_file(folder/backend/"report.json"), + process_peak_rss_bytes=evidence["process_peak_rss_bytes"], + stages_seconds=evidence["stages_seconds"]) + if backend == "ir": + report["full_ir_executed"] = True + except Exception as exc: + worker.update(passed=False, error=f"{type(exc).__name__}: {exc}") + print(f"[{name}/{backend}] {'EXECUTED' if worker['passed'] else 'FAIL'}", flush=True) + if all(w["passed"] for w in row["workers"]): + row["comparison"] = compare_case(folder, valid) + row["passed"] = row["comparison"]["passed"] + successful.add(name) + print(f"[{name}/compare] {'PASS' if row['passed'] else 'FAIL'}; logits_max_abs={row['comparison']['max_logits_abs']}; diagnostic_max_abs={row['comparison']['worst']['max_abs']}", flush=True) + write_reports(out, report) + report["invariants"] = invariants(out, successful) + require(source_evidence()["source_sha256"] == report["source_sha256"], "Sources changed during full execution") + coverage = set(selected) == set(CASE_NAMES) and len(report["cases"]) == len(CASE_NAMES) and len(report["invariants"]) == 4 + numeric = len(report["cases"]) == len(selected) and all(row["passed"] for row in report["cases"]) and all(x["passed"] for x in report["invariants"]) + report.update(coverage_complete=coverage, selected_numerical_passed=numeric, + passed=coverage and numeric, status="PASS" if coverage and numeric else ("PARTIAL" if numeric else "FAIL"), stage="complete") + except KeyboardInterrupt: + report.update(status="FAIL", passed=False, interrupted=True, error="KeyboardInterrupt: full verification cancelled") + except Exception as exc: + report.update(status="FAIL", passed=False, error=f"{type(exc).__name__}: {exc}") + report["elapsed_seconds"] = time.perf_counter() - start + write_reports(out, report) + return 130 if report.get("interrupted") else (0 if report["passed"] else (2 if report["status"] == "PARTIAL" else 1)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_full/worker.py b/probes/w3_qwen3_full/worker.py new file mode 100644 index 0000000..e86e297 --- /dev/null +++ b/probes/w3_qwen3_full/worker.py @@ -0,0 +1,327 @@ +"""Execute one complete pinned Qwen3 backend in an isolated process. + +A worker PASS proves execution and ordinary/diagnostic consistency only. The +parent compares independent IR and ORT results; no worker accepts W3 itself. +""" +from __future__ import annotations + +import argparse +import gc +import json +import os +from pathlib import Path +import sys +import tempfile +import time +import traceback + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np +import onnx + +from probes.w1_qwen3_export import run as assets +from probes.w2_qwen3_parse.validation import audit_graph_structure +from probes.w3_common import (atomic_text, new_output_dir, process_peak_rss_bytes, + sha256_file, source_evidence, write_reports) +from probes.w3_layer_diff.run import load_arrays + +GATE = "numeric:w3-full-worker" +INPUT_SCHEMA = [ + {"name": "input_ids", "shape": [1, 256], "dtype": "int64"}, + {"name": "attention_mask", "shape": [1, 1, 256, 256], "dtype": "float32"}, +] +LOGITS_SHAPE = (1, 256, 151936) + + +def load_inputs(path): + """Validate the pinned export's finite additive, right-padded causal mask.""" + feed = load_arrays(Path(path), INPUT_SCHEMA, max_bytes=1024 * 1024) + ids, mask = feed["input_ids"], feed["attention_mask"] + if np.any(ids < 0) or np.any(ids >= 151936): + raise ValueError("input_ids must be in the fixed Qwen3 vocabulary") + length = int(np.count_nonzero(mask[0, 0, -1] == 0)) + if not 1 <= length <= 256: + raise ValueError("attention_mask must expose at least one valid token") + query, key = np.arange(256)[:, None], np.arange(256)[None, :] + wanted = np.where((key <= query) & (key < length), np.float32(0), + np.finfo(np.float32).min).reshape(1, 1, 256, 256) + if not np.array_equal(mask, wanted): + raise ValueError("attention_mask is not the pinned causal/right-padding additive mask") + return feed, length + + +def checkpoint_schema(model, audit=None): + """Locate checkpoints from audited dataflow, never layer-name substrings.""" + audit = audit_graph_structure(model) if audit is None else audit + layers = audit.get("layers", []) + if (audit.get("passed") is not True or audit.get("layer_count") != 28 + or [row.get("layer") for row in layers] != list(range(28))): + raise ValueError("Checkpoint coverage requires the complete ordered 28-layer audit") + by_name = {} + for node in model.graph.node: + by_name.setdefault(node.name, []).append(node) + + def output(name): + nodes = by_name.get(name, []) + if len(nodes) != 1 or len(nodes[0].output) != 1: + raise ValueError(f"Checkpoint node must be unique with one output: {name}") + return nodes[0].output[0] + + rows = [{"name": "embedding", "onnx_name": output(audit["embedding"]), + "checkpoint": "embedding"}] + rows += [{"name": f"layer_{row['layer']}.output", "onnx_name": row["output"], + "layer": row["layer"], "checkpoint": "output"} for row in layers] + rows += [{"name": "final_norm", "onnx_name": output(audit["final_norm"]), + "checkpoint": "final_norm"}] + definitions = {name for node in model.graph.node for name in node.output} + names = [row["onnx_name"] for row in rows] + if len(names) != len(set(names)) or not set(names) <= definitions: + raise ValueError("Checkpoint tensors must be unique graph-produced values") + for row in rows: + row.update(shape=[1, 256, 1024], dtype="float32", sequence_axis=1) + return rows + + +def checked_tensor(array, shape, name): + if not isinstance(array, np.ndarray) or array.shape != tuple(shape) or array.dtype != np.float32: + raise ValueError(f"{name}: expected FP32 {tuple(shape)}, got " + f"{getattr(array, 'shape', None)}/{getattr(array, 'dtype', None)}") + for start in range(0, shape[1], 8): + if not np.isfinite(array[:, start:start + 8]).all(): + raise ValueError(f"{name}: nonfinite output") + return array + + +def save_array(path, value, *, archive=False): + """Atomically persist numeric arrays; leave existing evidence untouched.""" + path = Path(path) + if path.exists(): + raise FileExistsError(path) + fd, temporary = tempfile.mkstemp(prefix="." + path.name, suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + if archive: + np.savez(stream, **value) + else: + np.save(stream, value, allow_pickle=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def evidence(path, *, array=None): + path = Path(path) + record = {"path": path.name, "sha256": sha256_file(path), "bytes": path.stat().st_size} + if array is not None: + record.update(shape=list(array.shape), dtype=str(array.dtype), all_finite=True) + return record + + +def save_logits(out, name, array, report): + checked_tensor(array, LOGITS_SHAPE, name) + path = out / f"{name}.npy" + save_array(path, array) + report["artifacts"][name] = evidence(path, array=array) + + +def save_checkpoints(out, arrays, schema, report): + if set(arrays) != {row["name"] for row in schema} or len(arrays) != 30: + raise ValueError("Diagnostic output omitted/duplicated a required checkpoint") + for row in schema: + checked_tensor(arrays[row["name"]], row["shape"], row["name"]) + path = out / "checkpoints.npz" + save_array(path, arrays, archive=True) + report["artifacts"]["checkpoints"] = evidence(path) + + +def exact_ordinary_diagnostic(out): + ordinary = np.load(out / "logits.npy", mmap_mode="r", allow_pickle=False) + diagnostic = np.load(out / "diagnostic_logits.npy", mmap_mode="r", allow_pickle=False) + checked_tensor(ordinary, LOGITS_SHAPE, "ordinary logits") + checked_tensor(diagnostic, LOGITS_SHAPE, "diagnostic logits") + for start in range(0, LOGITS_SHAPE[1], 8): + if not np.array_equal(ordinary[:, start:start + 8], diagnostic[:, start:start + 8]): + raise ValueError(f"Ordinary and diagnostic logits differ at sequence chunk {start}") + return {"passed": True, "comparison": "exact", "max_abs": 0.0, + "positions_compared": 256, "elements_compared": int(np.prod(LOGITS_SHAPE))} + + +def diagnostic_graph(model, schema, model_dir, out): + """Add graph outputs without re-exporting or copying the external weights.""" + model = onnx.ModelProto.FromString(model.SerializeToString()) + for row in schema: + model.graph.output.append(onnx.helper.make_tensor_value_info( + row["onnx_name"], onnx.TensorProto.FLOAT, row["shape"])) + # External references are now relative to the derived metadata's directory. + # Source manifest verification already bounded and validated these files. + for tensor in assets.tensors(model): + if tensor.data_location == onnx.TensorProto.EXTERNAL: + for entry in tensor.external_data: + if entry.key == "location": + original = (model_dir / entry.value).resolve() + if not original.is_relative_to(model_dir.resolve()) or not original.is_file(): + raise ValueError("Invalid external tensor location in diagnostic model") + entry.value = os.path.relpath(original, out).replace("\\", "/") + target = out / "diagnostic.onnx" + target.write_bytes(model.SerializeToString()) + return target + + +def execute_ort(model_dir, out, feed, schema, model, report, stage): + import onnxruntime as ort + + def session(path): + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + options.enable_cpu_mem_arena = False + return ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"]) + + runtime = stage("ort_ordinary_session", lambda: session(model_dir / "model.onnx")) + logits = stage("ort_ordinary_execute", lambda: runtime.run(["logits"], feed)[0]) + stage("ordinary_save", lambda: save_logits(out, "logits", logits, report)) + del logits, runtime + gc.collect() + diagnostic = stage("diagnostic_graph", lambda: diagnostic_graph(model, schema, model_dir, out)) + report["artifacts"]["diagnostic_model"] = evidence(diagnostic) + runtime = stage("ort_diagnostic_session", lambda: session(diagnostic)) + names = ["logits", *[row["onnx_name"] for row in schema]] + results = stage("ort_diagnostic_execute", lambda: runtime.run(names, feed)) + if len(results) != 31: + raise ValueError("ORT diagnostic did not return all 31 outputs") + stage("diagnostic_save", lambda: save_logits(out, "diagnostic_logits", results[0], report)) + arrays = {row["name"]: value for row, value in zip(schema, results[1:])} + stage("checkpoints_save", lambda: save_checkpoints(out, arrays, schema, report)) + report["ort"] = {"provider": "CPUExecutionProvider", "graph_optimization": "disabled", + "intra_op_threads": 1, "inter_op_threads": 1, "cpu_mem_arena": False} + + +def execute_ir(model_dir, out, feed, schema, model, report, stage): + from scratchv.frontend.onnx_parser import ONNXParser + from scratchv.verification.ir_interpreter import IRInterpreter + from scratchv.verification.fp32_reference import profile + + fp32_mode = report.get("fp32_mode", "reference") + + parser = ONNXParser() + program = stage("scratchv_parse", lambda: parser.parse(str(model_dir / "model.onnx"), + mmap_external_data=True)) + for value in parser.initializers.values(): + value.setflags(write=False) + mapping = {} + for row in schema: + value = parser._value_map.get(row["onnx_name"]) + if value is None or value.name in mapping: + raise ValueError(f"Missing/non-unique IR checkpoint mapping: {row['name']}") + mapping[value.name] = row + report["ir_checkpoint_bindings"] = {name: row["name"] for name, row in mapping.items()} + report["executions"] = {} + for kind in ("ordinary", "diagnostic"): + arrays = {} + + def observe(name, value): + if name in mapping: + row = mapping[name] + if row["name"] in arrays: + raise ValueError(f"Checkpoint executed multiple times: {row['name']}") + arrays[row["name"]] = checked_tensor(value, row["shape"], row["name"]).copy() + + result = stage(f"ir_{kind}_execute", lambda: IRInterpreter(program).run( + feed, initializers=parser.initializers, collect_memory_stats=True, + memory_mode="last_use", copy_initializers=False, + fp32_mode=fp32_mode, + observer=observe if kind == "diagnostic" else None)) + report["executions"][kind] = {"executed_steps": result.executed_steps, + "memory_stats": result.memory_stats} + label = "logits" if kind == "ordinary" else "diagnostic_logits" + stage(f"{kind}_save", lambda: save_logits(out, label, result.return_value, report)) + if kind == "diagnostic": + stage("checkpoints_save", lambda: save_checkpoints(out, arrays, schema, report)) + del result, arrays + gc.collect() + report["ir"] = {"optimization_level": "none", "memory_mode": "last_use", + "fp32_mode": fp32_mode, + "fp32_profile": profile() if fp32_mode == "reference" else {"name": "numpy-native"}, + "copy_initializers": False, "mmap_external_data": True, + "instruction_count": sum(len(b.instructions) for f in program.functions for b in f.blocks)} + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--backend", choices=("ort", "ir"), required=True) + cli.add_argument("--model-dir", type=Path, required=True) + cli.add_argument("--input-file", type=Path, required=True) + cli.add_argument("--output-dir", type=Path, required=True) + cli.add_argument("--optimization-level", choices=("none",), default="none") + cli.add_argument("--fp32-mode", choices=("native", "reference"), default="reference") + args = cli.parse_args(argv) + out = new_output_dir(args.output_dir) + started = time.perf_counter() + report = {"schema_version": 1, "gate": GATE, "backend": args.backend, + "optimization_level": args.optimization_level, "status": "FAIL", "passed": False, + "fp32_mode": args.fp32_mode if args.backend == "ir" else None, + "stage": "initialization", "stages_seconds": {}, "artifacts": {}, + "full_ir_executed": False, "full_model_executed": False, "w3_exit_accepted": False, + "scope": "Complete single-backend execution and exact ordinary/diagnostic consistency. " + "Independent IR/ORT comparison belongs to the parent gate; not W3 team acceptance.", + "model_id": assets.MANIFEST["model_id"], "revision": assets.MANIFEST["revision"]} + + def stage(name, operation): + report["stage"] = name + atomic_text(out / "progress.json", json.dumps({"stage": name, + "elapsed_seconds": time.perf_counter() - started, + "process_peak_rss_bytes": process_peak_rss_bytes()}) + "\n") + print(f"[{args.backend}] {name}", flush=True) + begin = time.perf_counter() + try: + return operation() + finally: + report["stages_seconds"][name] = time.perf_counter() - begin + + try: + report.update(stage("source_evidence", source_evidence)) + model_dir = args.model_dir.resolve() + report["model_dir"] = str(model_dir) + report["files"] = stage("asset_hashes", lambda: assets.verify_files(model_dir)) + report["onnx_contract"] = stage("onnx_contract", lambda: assets.inspect_model(model_dir, report["files"])) + feed, length = stage("input_validation", lambda: load_inputs(args.input_file)) + report["input"] = evidence(args.input_file) + report["input"].update(path=str(args.input_file.resolve()), valid_length=length) + model = stage("metadata_load", lambda: onnx.load(str(model_dir / "model.onnx"), load_external_data=False)) + report["graph_audit"] = stage("graph_audit", lambda: audit_graph_structure(model)) + schema = stage("checkpoint_selection", lambda: checkpoint_schema(model, report["graph_audit"])) + report["checkpoints"] = schema + atomic_text(out / "checkpoint_schema.json", json.dumps({"version": 1, "checkpoints": schema}, indent=2) + "\n") + report["artifacts"]["checkpoint_schema"] = evidence(out / "checkpoint_schema.json") + executor = execute_ort if args.backend == "ort" else execute_ir + executor(model_dir, out, feed, schema, model, report, stage) + report["full_model_executed"] = True + report["full_ir_executed"] = args.backend == "ir" + report["ordinary_diagnostic"] = stage("ordinary_diagnostic_consistency", lambda: exact_ordinary_diagnostic(out)) + final_sources = stage("source_recheck", source_evidence)["source_sha256"] + if final_sources != report["source_sha256"]: + raise ValueError("Production sources changed during numerical execution") + report.update(passed=True, status="PASS", stage="complete") + except BaseException as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + traceback.print_exc() + if isinstance(exc, KeyboardInterrupt): + report["interrupted"] = True + report.update(elapsed_seconds=time.perf_counter() - started, + process_peak_rss_bytes=process_peak_rss_bytes(), + peak_scope="Lifetime resident high-water mark of this single backend worker, " + "including both ordinary and diagnostic runs; not isolated kernel memory") + write_reports(out, report) + print(f"[{GATE}] {report['status']}; {out / 'report.json'}", flush=True) + return 130 if report.get("interrupted") else 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_medium/README.md b/probes/w3_qwen3_medium/README.md new file mode 100644 index 0000000..c06072d --- /dev/null +++ b/probes/w3_qwen3_medium/README.md @@ -0,0 +1,110 @@ +# W3 medium Qwen3 host numerical probe + +Reproduction targets **Ubuntu 24.04 x86_64, Bash and Python 3.12**. Complete the [Linux environment and asset setup](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md) first. Run commands from the repository root in the same Bash session; the setup exports `SCRATCHV_PYTHON`, `SCRATCHV_CC` and `SCRATCHV_QEMU`. + +This is a fixed candidate for the W3 six-layer experiment. The plan fixes six +layers; the dimensions below are the candidate chosen for this experiment, +not a claim that the plan mandated a specific hidden or feed-forward width. + +| Property | W2 small default | W3 medium candidate | +|---|---:|---:| +| Decoder layers | 2 | 6 | +| Hidden width | 32 | 64 | +| FFN intermediate width | 96 | 192 | +| Query heads / KV heads | 4 / 2 | 4 / 2 | +| Head dimension | 16 | 16 | +| Vocabulary / sequence length | 128 / 256 | 128 / 256 | +| Batch / precision | 1 / FP32 | 1 / FP32 | +| Named checkpoints | 29 | 81 | + +Weights are fixed-seed random initialization of the official +`transformers==4.51.3` `Qwen3ForCausalLM`. No weights or tokenizer are downloaded. +This gate measures architecture and numerical semantics. It does not establish +pretrained language quality, full Qwen3-0.6B execution, or RISC-V/QEMU correctness. +Attention is eager, positions are `0..255`, embeddings and LM head are tied, and +there is no KV cache. All numerical backends use one CPU thread. + +## Run + +Use Python 3.12 and the existing pinned +`requirements/qwen3-small-probe.txt` environment. From the W3 worktree: + +```bash +set -euo pipefail +export OMP_NUM_THREADS='1' +export OPENBLAS_NUM_THREADS='1' +export MKL_NUM_THREADS='1' +"$SCRATCHV_PYTHON" -m probes.w3_qwen3_medium.run --output-dir output/w3-medium --model-seed 0 +``` + +The output directory must not already exist, even if it is empty. Preserve a +failed directory and choose a new path for a retry. The runner exits 0 only if the entire gate passes; numeric, +parsing, optimization, execution, and missing-reference failures exit 1. + +## Acceptance and evidence + +The gate uses the existing seven W2 inputs: two full random sequences, lengths +1, 17, and 255 with right padding, an altered future suffix, and altered masked +padding tokens. It exports the official model with named observations, then +produces ordinary logits-only and diagnostic packed-output ONNX graphs. + +Both graphs run in ORT with graph optimization disabled and in ScratchV IR at +`none`, `basic`, and `all`. Every IR path compares against both PyTorch and its +own ORT graph. Ordinary graph, diagnostic graph, and every optimization level +record independent results. One failure cannot be masked by another passing +path. Independent NumPy GQA/probability/context and blocked-attention checks +also run against PyTorch. + +The report uses uppercase `PASS` / `FAIL`. Its `coverage_complete` field requires +the exact seven named cases and lengths, both ORT graphs, all six IR variants +per case, and all 18 named/backend/graph isolation checks. Missing or duplicated +evidence cannot pass by reducing the set of checks being summarized. + +All comparisons require matching shape and dtype, finite values, and strict +`max_abs < 1e-5`, with `rtol=0`. The threshold is fixed and is not a CLI option. +All positions are included, with valid-token and padding-query errors reported +separately. Causal and padding isolation compare the valid prefix at **every +checkpoint**, for PyTorch, both ORT graphs, and all six IR variants. + +Checkpoint count is `3 + 13 * num_hidden_layers`: embedding, 13 observations per +layer, final norm, and logits. Shapes are derived from the actual model config, +including query/KV head count, head dimension, hidden width, vocabulary, and +sequence length. `probes/w2_qwen3_small/model.py` retains its two-layer defaults +and accepts explicit `model_config` overrides plus `sequence_length`. + +The output contains: + +- `model.onnx`, `checkpoints.onnx`, and `diagnostics.onnx`. +- `config.json`, `checkpoint_metadata.json`, packed `checkpoints.json`, and + versioned named-NPZ `trace_schema.json` / `logits_schema.json`. +- `inputs_' + + escape(coverage) + '
' + escape(explanation) + + '
" + escape(description) + "
" + if comparisons: + md += compact_metric_table(comparisons) + "\n\n" + page += compact_metric_table_html(comparisons) + md += "\n\n".join(escape(note) for note in notes) + "\n\n" + page += "".join("" + escape(note) + "
" for note in notes) + return md, page diff --git a/probes/w4_qwen3_full/__init__.py b/probes/w4_qwen3_full/__init__.py new file mode 100644 index 0000000..2ae1a0d --- /dev/null +++ b/probes/w4_qwen3_full/__init__.py @@ -0,0 +1 @@ +"""W4 complete model external-weight RV64 probe.""" diff --git a/probes/w4_qwen3_full/audit.py b/probes/w4_qwen3_full/audit.py new file mode 100644 index 0000000..7dfb9e6 --- /dev/null +++ b/probes/w4_qwen3_full/audit.py @@ -0,0 +1,107 @@ +"""Recompute W4 numeric evidence from retained raw arrays, without QEMU or ORT. + +This proves consistency of supplied evidence only. It does not authenticate a +self-authored report or replace independent compiler/runtime reproduction. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.w3_common import sha256_file +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases +from probes.w3_qwen3_full.worker import load_inputs +from probes.w4_qwen3_full.run import compare_logits, write_reports +from scratchv.backend.tensor_c_codegen import TensorSpec +from scratchv.ir.types import DataType +from scratchv.runtime.riscv_external import decode_completion +from scratchv.runtime.riscv_tensor import pack_inputs + + +SHAPE = (1, 256, 151936) +NBYTES = int(np.prod(SHAPE)) * 4 +SPECS = (TensorSpec("input_ids", DataType.INT64, (1, 256)), + TensorSpec("attention_mask", DataType.FLOAT32, (1, 1, 256, 256))) + + +def audit(directory): + directory = Path(directory).resolve() + report = json.loads((directory / "report.json").read_text(encoding="utf-8")) + required = {"build:riscv-full", "smoke:qemu-full", "numeric:qemu-full-qwen3"} + if (report.get("passed") is not True or report.get("status") != "PASS" + or report.get("full_qemu_forward_executed") is not True + or any(report.get("gates", {}).get(name) is not True for name in required)): + raise ValueError("Source report does not claim all full-model gates passed") + coverage = report.get("coverage") + wanted = list(CASE_NAMES) if coverage == "all" else [coverage] + if not wanted or any(name not in CASE_NAMES for name in wanted): + raise ValueError("Invalid source coverage") + if [case.get("name") for case in report.get("cases", [])] != wanted: + raise ValueError("Case records do not match declared coverage") + fixed_inputs = {name: (length, feed) for name, length, feed in input_cases()} + results = [] + for case in report["cases"]: + out = directory / case["name"] + for filename, expected in (("inputs.npz", case["input_sha256"]), + ("ort.npy", case["ort_sha256"]), + ("qemu/output.bin", case["qemu"]["output_sha256"]), + ("qemu/inputs.bin", case["qemu"]["input_sha256"])): + if sha256_file(out / filename) != expected: + raise ValueError(f"Retained artifact hash mismatch: {case['name']}/{filename}") + feed, length = load_inputs(out / "inputs.npz") + fixed_length, fixed_feed = fixed_inputs[case["name"]] + if (type(case["valid_length"]) is not int or length != fixed_length + or case["valid_length"] != fixed_length + or set(feed) != set(fixed_feed) + or any(not np.array_equal(feed[name], value) for name, value in fixed_feed.items())): + raise ValueError("Retained input does not match the fixed input case") + if pack_inputs(SPECS, feed) != (out / "qemu/inputs.bin").read_bytes(): + raise ValueError("Retained raw loader input differs from reference feed") + qemu = json.loads((out / "qemu/run.json").read_text(encoding="utf-8")) + if (json.dumps(qemu, sort_keys=True) != json.dumps(case["qemu"], sort_keys=True) + or qemu.get("guest_completed") is not True or type(qemu.get("exit_code")) is not int + or qemu["exit_code"] != 0 or qemu.get("passed") is not True + or qemu.get("status") != "PASS" or qemu.get("cleanup_errors") or qemu.get("error")): + raise ValueError("Retained run record differs or has no guest completion") + decode_completion((out / "qemu/uart.bin").read_bytes(), NBYTES) + if (out / "qemu/output.bin").stat().st_size != NBYTES: + raise ValueError("QEMU output length differs from full logits contract") + actual = np.memmap(out / "qemu/output.bin", dtype="len(data): + raise ValueError("Invalid ELF section headers") + result = [] + for i in range(count): + header = section_offset + i*entry_size + flags, address, offset, size = struct.unpack_from("len(data): + raise ValueError("Truncated executable ELF section") + position = 0 + while position+2 <= size: + half = struct.unpack_from(" size: + raise ValueError("Unsupported/truncated RV64 instruction") + word = struct.unpack_from("> 25) & 3 == 0: + result.append({"address": hex(address+position), "word": f"0x{word:08x}", + "rm": (word >> 12) & 7}) + position += length + if not {0, 7} <= {row["rm"] for row in result}: + raise ValueError("ELF must contain both static RNE and dynamic FMADD.S") + return result + + +def run_probe(out, *, cc=None, qemu=None, timeout=30.0): + out = new_output_dir(out) + started = time.perf_counter() + report = {"schema_version": 1, "passed": False, "status": "FAIL", "stage": "oracle", + "scope": "RV64 FMADD.S conformance, not model accuracy or performance", + "cases": [{**case, "input_bits": [f"0x{x:08x}" for x in case["input_bits"]], + "expected_bits": f"0x{case['expected_bits']:08x}"} for case in CASES]} + try: + verify_oracle() + report["stage"] = "toolchain" + toolchain = discover_toolchain(cc=cc, qemu=qemu) + report["tool_binary_sha256"] = {"cc": sha256_file(toolchain.cc[0]), + "qemu": sha256_file(toolchain.qemu)} + report["probe_sha256"] = sha256_file(__file__) + model = artifact() + write_weight_bundle(model.external_weights, model.external_initializers, out / "weights") + report["stage"] = "build" + executable = build_external(model, out / "weights", out / "build", toolchain, timeout=timeout) + report["build"] = executable.evidence + report["fmadd_encodings"] = fmadd_encodings(executable.elf_path) + inputs = np.array([case["input_bits"] for case in CASES], dtype=np.int64) + np.save(out / "input.npy", inputs, allow_pickle=False) + report["stage"] = "qemu" + observations, run_report = run_external(executable, {"bits": inputs}, out / "run", timeout=timeout) + np.save(out / "observations.npy", observations, allow_pickle=False) + report["run"] = run_report + report["stage"] = "conformance" + report["comparison"] = compare_observations(observations) + report["passed"] = bool(report["comparison"]["passed"] and run_report["passed"]) + report["status"] = "PASS" if report["passed"] else "FAIL" + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + atomic_text(out / "traceback.txt", traceback.format_exc()) + report["elapsed_s"] = time.perf_counter() - started + atomic_text(out / "report.json", json.dumps(report, indent=2, allow_nan=False) + "\n") + return report + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", required=True) + parser.add_argument("--cc") + parser.add_argument("--qemu") + parser.add_argument("--timeout", type=float, default=30.0) + args = parser.parse_args(argv) + report = run_probe(args.out, cc=args.cc, qemu=args.qemu, timeout=args.timeout) + print(json.dumps({"status": report["status"], "stage": report["stage"], + "report": str(Path(args.out).resolve() / "report.json")})) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w4_qwen3_full/host_diagnostic.py b/probes/w4_qwen3_full/host_diagnostic.py new file mode 100644 index 0000000..214a2f7 --- /dev/null +++ b/probes/w4_qwen3_full/host_diagnostic.py @@ -0,0 +1,370 @@ +"""Optional full-graph Host C diagnostic; never an RV64/QEMU acceptance gate.""" + +from __future__ import annotations + +import argparse +import ctypes +from dataclasses import dataclass +import gc +from html import escape +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +import traceback + +import numpy as np + +from probes.w1_qwen3_export import run as assets +from probes.w2_qwen3_parse.validation import audit_graph_structure +from probes.w3_common import atomic_text, process_peak_rss_bytes, sha256_file, source_evidence +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases +from probes.w4_qwen3_full.run import compare_logits, ort_reference +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.runtime.riscv_tensor import _run_process, _save_process_logs + + +ROOT = Path(__file__).resolve().parents[2] +POINTERS = ctypes.POINTER(ctypes.c_void_p) + + +def new_report(case, policy): + return {"schema_version": 1, "diagnostic_kind": "host_c_full_graph", "host_only": True, + "status": "RUNNING", "passed": False, "host_diagnostic_passed": False, + "full_host_forward_executed": False, "full_qemu_forward_executed": False, + "w4_team_accepted": False, + "gates": {"build:riscv-full": False, "smoke:qemu-full": False, + "numeric:qemu-full-qwen3": False}, + "scope": "Native host shared-library diagnostic only; no RV64 code or QEMU forward", + "case": case, "matmul_policy": policy, "stage": "initialization", + "stages_seconds": {}, "host_process_peak_rss_bytes": {}} + + +def publish(out, report): + """Publish explicit Host C views, clearing stale success on any failure.""" + try: + payload = json.dumps(report, indent=2, ensure_ascii=False, allow_nan=False) + text = ("# Host C full-graph diagnostic\n\n**This is not an RV64/QEMU gate.**\n\n" + f"Status: **{report['status']}**; stage: `{report['stage']}`.\n\n" + "```json\n" + payload + "\n```\n") + atomic_text(out / "report.md", text) + atomic_text(out / "report.html", '' + ' Host C diagnostic, not RV64 acceptance ' + 'Host C diagnostic — no RV64/QEMU claim
' + escape(payload) + '') + atomic_text(out / "report.json", payload + "\n") + except Exception as exc: + report.update(status="HOST_FAIL", passed=False, host_diagnostic_passed=False) + for name in ("report.json", "report.md", "report.html"): + try: + (out / name).unlink(missing_ok=True) + except OSError: + pass + try: + fallback = new_report(report.get("case"), report.get("matmul_policy")) + fallback.update(status="HOST_FAIL", report_error=f"{type(exc).__name__}: {exc}") + atomic_text(out / "report.json", json.dumps(fallback) + "\n") + except Exception: + pass + raise + + +def compiler_command(cc=None): + candidates = [cc] if cc else [os.environ.get("SCRATCHV_ZIG"), os.environ.get("SCRATCHV_CC"), + "zig", "clang", "gcc", "cc"] + for candidate in candidates: + if not candidate: + continue + found = str(Path(candidate).resolve()) if Path(candidate).is_file() else shutil.which(candidate) + if found: + return [found, "cc"] if Path(found).stem.lower() == "zig" else [found] + raise FileNotFoundError("Host C diagnostic requires an explicit Zig/clang/gcc compiler") + + +def read_worker_report(path): + """Read local worker evidence without accepting ambiguous JSON fields.""" + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate Host C report key: {key}") + result[key] = value + return result + + def reject_constant(value): + raise ValueError(f"Nonfinite Host C report value: {value}") + + if path.stat().st_size > 16 * 1024**2: + raise ValueError("Host C worker report exceeds 16 MiB") + report = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object, + parse_constant=reject_constant) + if not isinstance(report, dict): + raise ValueError("Host C worker report must be an object") + return report + + +def build_library(artifact, directory, compiler, timeout=900.0): + if artifact.constant_storage != "external": + raise ValueError("Host diagnostic requires the external tensor ABI") + directory = Path(directory).resolve() + directory.mkdir(parents=True, exist_ok=False) + source = directory / "model.c" + source.write_text(artifact.source, encoding="utf-8") + library = directory / ("model.dll" if os.name == "nt" else "model.so") + flags = list(dict.fromkeys(["-fno-fast-math", "-ffp-contract=off", "-fno-strict-aliasing", + *artifact.compile_flags])) + command = [*compiler, "-shared", "-O2", "-std=c11", *flags] + if os.name != "nt": + command.append("-fPIC") + command += [str(source), "-o", str(library), "-lm"] + environment = dict(os.environ) + environment["ZIG_GLOBAL_CACHE_DIR"] = str(ROOT / "output/zig-global-cache") + environment["ZIG_LOCAL_CACHE_DIR"] = str(directory / "zig-cache") + try: + result = _run_process(command, cwd=directory, timeout=timeout, env=environment) + except subprocess.TimeoutExpired as exc: + _save_process_logs(directory, "compiler", exc.stdout, exc.stderr) + raise TimeoutError(f"Host C compilation exceeded {timeout}s") from exc + _save_process_logs(directory, "compiler", result.stdout, result.stderr) + if result.returncode != 0: + raise RuntimeError(f"Host C compiler failed ({result.returncode}): " + + result.stderr.decode(errors="replace")[-8000:]) + version_command = [compiler[0], "version"] if len(compiler) == 2 else [compiler[0], "--version"] + version = _run_process(version_command, cwd=directory, timeout=30, env=environment) + if version.returncode: + raise RuntimeError("Could not record the host compiler version") + return library, {"command": command, "compiler_version": version.stdout.decode(errors="replace").strip(), + "library_sha256": sha256_file(library), "library_bytes": library.stat().st_size, + "source_sha256": sha256_file(source), "compile_flags": flags, + "implicit_fma_contraction": False, "kernel_calls": artifact.kernel_calls, + "matmul_policy": artifact.matmul_policy} + + +def _aligned_bytes(nbytes): + if type(nbytes) is not int or nbytes < 0: + raise ValueError("Storage size must be a nonnegative integer") + owner = np.empty(max(1, nbytes) + 63, dtype=np.uint8) + offset = (-owner.ctypes.data) % 64 + return owner, owner[offset:offset + nbytes] + + +def _array_for_spec(array, spec): + if (not isinstance(array, np.ndarray) or array.shape != tuple(spec.shape) + or array.dtype != np.dtype(spec.numpy_dtype)): + raise ValueError(f"Host tensor {spec.name!r} shape/dtype mismatch") + result = array if array.flags.c_contiguous else np.ascontiguousarray(array) + if not result.flags.aligned: + raise ValueError(f"Host tensor {spec.name!r} must have aligned element storage") + return result + + +@dataclass +class NativeBuffers: + """Own every Python object whose address the synchronous C call may use.""" + inputs: tuple + weights: tuple + workspace_owner: np.ndarray + workspace: np.ndarray + output_owner: np.ndarray + output: np.ndarray + input_pointers: object + weight_pointers: object + copied_weight_bytes: int + + def arguments(self, workspace_bytes): + return (self.input_pointers, self.weight_pointers, self.workspace.ctypes.data, + workspace_bytes, self.output.ctypes.data) + + +def prepare_buffers(artifact, feed): + if set(feed) != {spec.name for spec in artifact.inputs}: + raise ValueError("Host input names must exactly match the generated ABI") + if len(artifact.external_weights) != len(artifact.external_initializers): + raise ValueError("External tensor specs and arrays differ in length") + inputs = tuple(_array_for_spec(feed[spec.name], spec) for spec in artifact.inputs) + weights = tuple(_array_for_spec(array, spec) + for spec, array in zip(artifact.external_weights, artifact.external_initializers)) + copied = sum(array.nbytes for array, original in zip(weights, artifact.external_initializers) + if array is not original) + workspace_owner, workspace = _aligned_bytes(artifact.workspace_bytes) + output_owner, output_bytes = _aligned_bytes(artifact.output.nbytes) + output = output_bytes.view(artifact.output.numpy_dtype).reshape(artifact.output.shape) + if output.dtype.kind == "f": + output.fill(np.nan) # An unwritten output element must not look valid. + input_pointers = (ctypes.c_void_p * len(inputs))(*(array.ctypes.data for array in inputs)) + weight_pointers = (ctypes.c_void_p * len(weights))(*(array.ctypes.data for array in weights)) + return NativeBuffers(inputs, weights, workspace_owner, workspace, output_owner, output, + input_pointers, weight_pointers, copied) + + +def invoke_library(artifact, buffers, library): + loaded = ctypes.CDLL(str(library)) + function = None + try: + function = getattr(loaded, artifact.function_name) + function.restype = ctypes.c_int + function.argtypes = [POINTERS, POINTERS, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p] + status = function(*buffers.arguments(artifact.workspace_bytes)) + if status != 0: + raise RuntimeError(f"Host C returned failure status {status}") + return buffers.output + finally: + del function + import _ctypes + if os.name == "nt": + _ctypes.FreeLibrary(loaded._handle) + else: + _ctypes.dlclose(loaded._handle) + loaded._handle = 0 + + +def worker(args): + out = args.output_dir.resolve() + out.mkdir(parents=True, exist_ok=False) + report = new_report(args.case, args.matmul_policy) + started = time.perf_counter() + publish(out, report) + + def stage(name, operation): + report["stage"] = name + atomic_text(out / "progress.json", json.dumps({"stage": name, + "elapsed_s": time.perf_counter() - started}) + "\n") + print(f"[Host C only] {name}", flush=True) + begin = time.perf_counter() + try: + return operation() + finally: + report["stages_seconds"][name] = time.perf_counter() - begin + report["host_process_peak_rss_bytes"][name] = process_peak_rss_bytes() + publish(out, report) + + try: + report.update(stage("source_evidence", source_evidence)) + model_dir = args.model_dir.resolve() + report["model_dir"] = str(model_dir) + report["model_files"] = stage("asset_hashes", lambda: assets.verify_files(model_dir)) + import onnx + metadata = stage("metadata_load", lambda: onnx.load(str(model_dir / "model.onnx"), load_external_data=False)) + report["graph_audit"] = stage("28_layer_audit", lambda: audit_graph_structure(metadata)) + if report["graph_audit"].get("passed") is not True or report["graph_audit"].get("layer_count") != 28: + raise ValueError("Host diagnostic requires the fixed complete 28-layer model") + selected = [case for case in input_cases() if case[0] == args.case] + if len(selected) != 1: + raise ValueError("Host diagnostic requires exactly one selected full input") + _, valid_length, feed = selected[0] + report["valid_length"] = valid_length + np.savez(out / "inputs.npz", **feed) + report["input_sha256"] = sha256_file(out / "inputs.npz") + del metadata + # Keep ORT's session/weights out of the native C allocation phase. + stage("ort_reference", lambda: ort_reference(model_dir, feed, out / "ort.npy")) + gc.collect() + report["ort_sha256"] = sha256_file(out / "ort.npy") + parser = ONNXParser() + program = stage("parse", lambda: parser.parse(str(model_dir / "model.onnx"), mmap_external_data=True)) + artifact = stage("external_codegen", lambda: TensorCCodegen(program, parser.initializers, + constant_storage="external", kernel_calls=True, matmul_policy=args.matmul_policy, + max_constant_bytes=3 * 1024**3, max_workspace_bytes=1024**3).generate()) + report["codegen"] = {"workspace_bytes": artifact.workspace_bytes, + "weight_bytes": artifact.constant_bytes, + "weights": len(artifact.external_weights), + "source_bytes": len(artifact.source.encode()), + "output_shape": list(artifact.output.shape), "kernel_calls": True} + compiler = compiler_command(args.cc) + library, report["build"] = stage("host_shared_library", lambda: + build_library(artifact, out / "build", compiler, args.compile_timeout)) + buffers = stage("allocate_native_buffers", lambda: prepare_buffers(artifact, feed)) + report["buffer_policy"] = {"workspace_alignment": 64, "copied_weight_bytes": buffers.copied_weight_bytes, + "borrowed_weight_count": sum(array is original for array, original in + zip(buffers.weights, artifact.external_initializers)), + "weight_lifetime": "Strong Python references retained through the native call"} + del program, parser + gc.collect() + actual = stage("host_c_forward", lambda: invoke_library(artifact, buffers, library)) + report["full_host_forward_executed"] = True + np.save(out / "host.npy", actual, allow_pickle=False) + report["host_output_sha256"] = sha256_file(out / "host.npy") + expected = np.load(out / "ort.npy", mmap_mode="r", allow_pickle=False) + report["numeric"] = stage("full_logits_comparison", lambda: compare_logits(actual, expected)) + report.update(passed=report["numeric"]["passed"], host_diagnostic_passed=report["numeric"]["passed"], + status="HOST_PASS" if report["numeric"]["passed"] else "HOST_FAIL", stage="complete") + except BaseException as exc: + report.update(passed=False, host_diagnostic_passed=False, status="HOST_FAIL", + error=f"{type(exc).__name__}: {exc}") + traceback.print_exc() + report["elapsed_seconds"] = time.perf_counter() - started + publish(out, report) + return 0 if report["host_diagnostic_passed"] else 1 + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--model-dir", type=Path, required=True) + cli.add_argument("--output-dir", type=Path, required=True) + cli.add_argument("--case", choices=CASE_NAMES, default="full_seed_0") + cli.add_argument("--cc") + cli.add_argument("--matmul-policy", choices=("sequential", "blocked_fma"), default="sequential") + cli.add_argument("--timeout", type=float, default=3600.0) + cli.add_argument("--compile-timeout", type=float, default=900.0) + cli.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) + args = cli.parse_args(argv) + if any(not np.isfinite(value) or value <= 0 for value in (args.timeout, args.compile_timeout)): + raise ValueError("Diagnostic timeouts must be positive and finite") + if args.worker: + return worker(args) + out = args.output_dir.resolve() + out.mkdir(parents=True, exist_ok=False) + report = new_report(args.case, args.matmul_policy) + report["stage"] = "isolated_worker" + publish(out, report) + command = [sys.executable, "-B", "-X", "utf8", "-m", "probes.w4_qwen3_full.host_diagnostic", + "--worker", "--model-dir", str(args.model_dir.resolve()), "--output-dir", str(out / "worker"), + "--case", args.case, "--matmul-policy", args.matmul_policy, + "--compile-timeout", str(args.compile_timeout)] + if args.cc: + command += ["--cc", args.cc] + environment = dict(os.environ, OMP_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1", MKL_NUM_THREADS="1") + start = time.perf_counter() + try: + result = _run_process(command, cwd=ROOT, timeout=args.timeout, env=environment) + _save_process_logs(out, "worker", result.stdout, result.stderr) + child = read_worker_report(out / "worker/report.json") + if (type(child.get("schema_version")) is not int or child["schema_version"] != 1 + or child.get("diagnostic_kind") != "host_c_full_graph" + or child.get("w4_team_accepted") is not False + or child.get("host_only") is not True or child.get("full_qemu_forward_executed") is not False + or child.get("case") != args.case or child.get("matmul_policy") != args.matmul_policy + or set(child.get("gates", {})) != set(report["gates"]) + or any(value is not False for value in child.get("gates", {}).values())): + raise ValueError("Worker report crossed the Host C diagnostic boundary") + report = child + if result.returncode or report.get("status") != "HOST_PASS" or report.get("host_diagnostic_passed") is not True: + report.update(passed=False, host_diagnostic_passed=False, status="HOST_FAIL") + else: + numeric = report.get("numeric", {}) + error = numeric.get("max_abs") + if (report.get("full_host_forward_executed") is not True or report.get("passed") is not True + or numeric.get("passed") is not True or numeric.get("atol") != 1e-3 + or numeric.get("rtol") != 0.0 or numeric.get("elements_compared") != 256 * 151936 + or isinstance(error, bool) or not isinstance(error, (int, float)) + or not np.isfinite(error) or not 0 <= error < 1e-3): + raise ValueError("Incomplete or invalid Host C full-logits numerical evidence") + report["worker_exit_code"] = result.returncode + except BaseException as exc: + if isinstance(exc, subprocess.TimeoutExpired): + _save_process_logs(out, "worker", exc.stdout, exc.stderr) + report.update(passed=False, host_diagnostic_passed=False, status="HOST_FAIL", + error=f"{type(exc).__name__}: {exc}") + report["worker_command"] = command + report["parent_elapsed_seconds"] = time.perf_counter() - start + report["worker_timeout_seconds"] = args.timeout + publish(out, report) + print(f"[Host C only] {report['status']}: {out / 'report.json'}", flush=True) + return 0 if report["host_diagnostic_passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w4_qwen3_full/run.py b/probes/w4_qwen3_full/run.py new file mode 100644 index 0000000..9dd9852 --- /dev/null +++ b/probes/w4_qwen3_full/run.py @@ -0,0 +1,324 @@ +"""Build and execute the pinned complete Qwen3 model on bare-metal RV64 QEMU.""" +from __future__ import annotations + +import argparse +from dataclasses import replace +import gc +import hashlib +from html import escape +import json +from pathlib import Path +import sys +import time +import traceback +import zipfile + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.w1_qwen3_export import run as assets +from probes.w2_qwen3_parse.validation import audit_graph_structure +from probes.w3_common import atomic_text, recheck_sources, source_evidence, sha256_file +from probes.w3_qwen3_full.cases import input_cases, CASE_NAMES +from probes.w4_qwen3_full.fma_conformance import run_probe as run_fma_probe +from scratchv.backend.tensor_c_codegen import TensorCCodegen, TensorSpec, _shape +from scratchv.ir.types import DataType +from scratchv.runtime.riscv_external import build_external, run_external, ExternalExecutable, validate_external_elf +from scratchv.runtime.riscv_tensor import discover_toolchain, RiscVToolchain, input_layout, toolchain_versions +from scratchv.runtime.weight_bundle import write_weight_bundle, validate_weight_bundle, plan_guest_memory + +ATOL = 1e-3 + + +def save_source_snapshot(out, hashes): + """Retain executed source contents as well as hashes; never a clean git claim.""" + target = out / "source-snapshot.zip" + with zipfile.ZipFile(target, "x", compression=zipfile.ZIP_DEFLATED) as archive: + for name, digest in sorted(hashes.items()): + path = ROOT / name + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != digest: + raise ValueError(f"Source changed while taking snapshot: {name}") + archive.writestr(name, data) + return {"file": target.name, "sha256": sha256_file(target), + "scope": "Hashed production/probe/script sources and fixed requirements/manifests; " + "restore over recorded Git base, not a complete standalone repository"} + + +def write_reports(out, report): + try: + payload = json.dumps(report, indent=2, ensure_ascii=False, allow_nan=False) + lines = ["# W4 complete Qwen3 RV64 probe", "", f"Status: **{report['status']}**", + f"Stage: `{report['stage']}`", "", "| Gate | Passed |", "|---|---|"] + lines += [f"| `{name}` | {value} |" for name, value in report["gates"].items()] + lines += ["", "External team reproduction and remote nightly remain pending.", "", + "```json", payload, "```", ""] + atomic_text(out / "report.md", "\n".join(lines)) + atomic_text(out / "report.html", 'W4 evidence ' + 'W4 complete Qwen3 RV64 probe
' + escape(payload) + '') + atomic_text(out / "report.json", payload + "\n") + except Exception as exc: + report.update(passed=False, status="FAIL", report_error=f"{type(exc).__name__}: {exc}") + for name in ("report.json", "report.md", "report.html"): + try: + (out / name).unlink(missing_ok=True) + except OSError: + pass + try: + fallback = {"passed": False, "status": "FAIL", "stage": str(report.get("stage")), + "report_error": report["report_error"]} + atomic_text(out / "report.json", json.dumps(fallback, indent=2, allow_nan=False) + "\n") + except Exception: + pass + raise + + +def compare_logits(actual, expected): + shape = (1, 256, 151936) + if actual.shape != shape or expected.shape != shape or actual.dtype != np.float32 or expected.dtype != np.float32: + raise ValueError("Full W4 comparison requires FP32 [1,256,151936] logits") + maximum, worst, bad, square_sum = 0.0, None, 0, 0.0 + for pos in range(256): + a, b = actual[0, pos], expected[0, pos] + if not np.isfinite(a).all() or not np.isfinite(b).all(): + raise ValueError("Nonfinite logits cannot pass numeric acceptance") + diff = np.abs(a.astype(np.float64) - b.astype(np.float64)) + index = int(diff.argmax()) + if diff[index] > maximum: + maximum, worst = float(diff[index]), [0, pos, index] + bad += int(np.count_nonzero(diff >= ATOL)) + square_sum += float(np.dot(diff, diff)) + return {"passed": maximum < ATOL, "atol": ATOL, "rtol": 0.0, + "criterion": "strict max_abs < atol across every output element", + "max_abs": maximum, "worst_index": worst, "elements_at_or_above_atol": bad, + "rmse": float(np.sqrt(square_sum / actual.size)), "elements_compared": int(actual.size)} + + +def spec_json(spec): + return {"name": spec.name, "dtype": spec.dtype.value, "shape": list(spec.shape)} + + +def save_executable(exe, out): + record = {"elf": str(exe.elf_path), "bundle_dir": str(exe.bundle_dir), "manifest": exe.manifest, + "layout": exe.layout, "inputs": [spec_json(s) for s in exe.inputs], + "weights": [spec_json(s) for s in exe.weights], "output": spec_json(exe.output), + "cc": list(exe.toolchain.cc), "qemu": exe.toolchain.qemu, "evidence": exe.evidence} + atomic_text(out / "executable.json", json.dumps(record, indent=2) + "\n") + return sha256_file(out / "executable.json") + + +def load_executable(path): + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate executable metadata key: {key}") + result[key] = value + return result + def reject_constant(value): + raise ValueError(f"Nonfinite executable metadata: {value}") + path = Path(path) + if path.stat().st_size > 16 * 1024**2: + raise ValueError("Executable metadata exceeds 16 MiB") + record = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object, + parse_constant=reject_constant) + def spec(value): + if (set(value) != {"name", "dtype", "shape"} or not isinstance(value["name"], str) + or not value["name"] or "\0" in value["name"]): + raise ValueError("Invalid executable tensor spec") + dtype = DataType(value["dtype"]) + if dtype not in (DataType.FLOAT32, DataType.INT32, DataType.INT64): + raise ValueError("Unsupported external tensor dtype") + return TensorSpec(value["name"], dtype, _shape(value["shape"])) + exe = ExternalExecutable(Path(record["elf"]), Path(record["bundle_dir"]), record["manifest"], + record["layout"], tuple(map(spec, record["inputs"])), spec(record["output"]), + tuple(map(spec, record["weights"])), RiscVToolchain(tuple(record["cc"]), record["qemu"]), + record["evidence"]) + manifest = validate_weight_bundle(exe.bundle_dir, exe.weights) + if manifest != exe.manifest: + raise ValueError("Reused weights do not match build manifest") + _, input_bytes = input_layout(exe.inputs) + layout = plan_guest_memory(workspace_bytes=exe.layout["regions"]["workspace"]["nbytes"], + weight_bytes=manifest["total_bytes"], input_bytes=input_bytes, output_bytes=exe.output.nbytes) + if layout != exe.layout: + raise ValueError("Reused memory layout is not the canonical non-overlapping plan") + evidence = validate_external_elf(exe.elf_path, layout) + if any(evidence[key] != exe.evidence.get(key) for key in evidence): + raise ValueError("Reused ELF changed after build") + if set(exe.evidence["sources"]) != {"model.c", "guest.c", "start.S", "link.ld"}: + raise ValueError("Reused generated source manifest must contain all four source files") + for name, digest in exe.evidence["sources"].items(): + if Path(name).name != name or sha256_file(exe.elf_path.parent / name) != digest: + raise ValueError("Reused generated source changed after build") + return exe + + +def ort_reference(model_dir, feed, output): + import onnxruntime as ort + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + options.enable_cpu_mem_arena = False + session = ort.InferenceSession(str(model_dir / "model.onnx"), options, + providers=["CPUExecutionProvider"]) + logits = session.run(["logits"], feed)[0] + if logits.shape != (1, 256, 151936) or logits.dtype != np.float32 or not np.isfinite(logits).all(): + raise ValueError("Invalid complete ORT reference output") + np.save(output, logits, allow_pickle=False) + del logits, session + gc.collect() + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--model-dir", type=Path, required=True) + cli.add_argument("--output-dir", type=Path, required=True) + cli.add_argument("--case", choices=(*CASE_NAMES, "all"), default="full_seed_0") + cli.add_argument("--cc") + cli.add_argument("--qemu") + cli.add_argument("--timeout", type=float, default=10800) + cli.add_argument("--build-timeout", type=float, default=900) + cli.add_argument("--build-only", action="store_true") + cli.add_argument("--matmul-policy", choices=("sequential", "blocked_fma"), default="sequential") + cli.add_argument("--reuse-build", type=Path, help="Local executable.json; verified before every run") + args = cli.parse_args(argv) + out = args.output_dir.resolve() + out.mkdir(parents=True, exist_ok=False) + start = time.perf_counter() + report = {"schema_version": 1, "gate": "numeric:qemu-full-qwen3", "passed": False, + "status": "FAIL", "stage": "initialization", "stages_seconds": {}, "cases": [], + "gates": {"build:riscv-full": False, "smoke:qemu-full": False, + "numeric:qemu-full-qwen3": False}, + "full_qemu_forward_executed": False, "team_reproduction": "PENDING", + "remote_nightly": "NOT_RUN", "w4_team_accepted": False, + "scope": "Local bare-metal RV64 full single-forward evidence; no Linux guest/mmap or generation loop"} + + def stage(name, operation): + report["stage"] = name + atomic_text(out / "progress.json", json.dumps({"stage": name, + "elapsed_s": time.perf_counter() - start}) + "\n") + print(f"[W4] {name}", flush=True) + begin = time.perf_counter() + try: + return operation() + finally: + report["stages_seconds"][name] = time.perf_counter() - begin + write_reports(out, report) + + try: + if any(not np.isfinite(value) or value <= 0 for value in (args.timeout, args.build_timeout)): + raise ValueError("Timeouts must be positive and finite") + report.update(stage("source_evidence", source_evidence)) + report["source_snapshot"] = stage("source_snapshot", lambda: save_source_snapshot(out, report["source_sha256"])) + model_dir = args.model_dir.resolve() + report["model_dir"] = str(model_dir) + report["matmul_policy"] = args.matmul_policy + report["model_files"] = stage("asset_hashes", lambda: assets.verify_files(model_dir)) + import onnx + model = stage("metadata_load", lambda: onnx.load(str(model_dir / "model.onnx"), load_external_data=False)) + report["graph_audit"] = stage("28_layer_audit", lambda: audit_graph_structure(model)) + if not report["graph_audit"]["passed"] or report["graph_audit"]["layer_count"] != 28: + raise ValueError("Complete 28-layer Qwen3 structural audit failed") + if args.reuse_build: + previous = json.loads(args.reuse_build.with_name("report.json").read_text(encoding="utf-8")) + if sha256_file(args.reuse_build) != previous.get("executable_sha256"): + raise ValueError("Reused executable metadata hash is missing or changed") + exe = stage("load_existing_build", lambda: load_executable(args.reuse_build)) + if args.cc: + raise ValueError("Do not change --cc while reusing an ELF; build a new executable instead") + if args.qemu: + replacement = discover_toolchain(exe.toolchain.cc, args.qemu) + report["qemu_override"] = {"built_with": exe.toolchain.qemu, "execute_with": replacement.qemu} + exe = replace(exe, toolchain=replacement) + if previous["model_files"] != report["model_files"]: + raise ValueError("Reused build model identity mismatch") + if previous.get("matmul_policy") != args.matmul_policy: + raise ValueError("Reused build arithmetic policy differs from requested policy") + # Relevant production files must still have exactly the build's contents. + production = lambda hashes: {key: value for key, value in hashes.items() if key.startswith("scratchv/")} + if production(previous["source_sha256"]) != production(report["source_sha256"]): + raise ValueError("Reused build production source inventory or contents changed") + else: + from scratchv.frontend.onnx_parser import ONNXParser + parser = ONNXParser() + program = stage("parse", lambda: parser.parse(str(model_dir / "model.onnx"), mmap_external_data=True)) + artifact = stage("external_codegen", lambda: TensorCCodegen(program, + initializers=parser.initializers, constant_storage="external", + max_constant_bytes=3 * 1024**3, max_workspace_bytes=1024**3, + kernel_calls=True, matmul_policy=args.matmul_policy).generate()) + report["codegen"] = {"source_bytes": len(artifact.source.encode()), + "constant_bytes": artifact.constant_bytes, + "workspace_bytes": artifact.workspace_bytes, + "weights": len(artifact.external_weights), + "kernel_calls": artifact.kernel_calls, + "output": spec_json(artifact.output)} + stage("write_weight_bundle", lambda: write_weight_bundle(artifact.external_weights, + artifact.external_initializers, out / "weights")) + toolchain = discover_toolchain(args.cc, args.qemu) + exe = stage("build_riscv_full", lambda: build_external(artifact, out / "weights", + out / "build", toolchain, timeout=args.build_timeout)) + del artifact, program, parser + gc.collect() + report["executable_sha256"] = save_executable(exe, out) + report.update(build=exe.evidence, memory_layout=exe.layout, weight_sha256=exe.manifest["sha256"]) + report["gates"]["build:riscv-full"] = True + report["execution_tools"] = stage("current_tool_versions", lambda: toolchain_versions(exe.toolchain)) + report["execution_tool_binary_sha256"] = { + "cc": sha256_file(exe.toolchain.cc[0]), "qemu": sha256_file(exe.toolchain.qemu)} + if args.build_only: + stage("source_postcheck", lambda: recheck_sources(report)) + report.update(status="BUILD_ONLY", stage="build_complete") + else: + report["fma_conformance"] = stage("fma_conformance", lambda: run_fma_probe( + out / "fma-conformance", cc=exe.toolchain.cc, qemu=exe.toolchain.qemu)) + conformance = report["fma_conformance"] + if (conformance.get("passed") is not True or conformance.get("status") != "PASS" + or conformance.get("tool_binary_sha256") != report["execution_tool_binary_sha256"]): + raise ValueError("Actual execution toolchain failed strict RV64 FMA conformance") + cases = input_cases() + if args.case != "all": + cases = [case for case in cases if case[0] == args.case] + requested = list(CASE_NAMES) if args.case == "all" else [args.case] + if [case[0] for case in cases] != requested: + raise ValueError("Selected cases must exactly match requested nonempty coverage") + for name, valid_length, feed in cases: + case_dir = out / name + case_dir.mkdir() + np.savez(case_dir / "inputs.npz", **feed) + row = {"name": name, "valid_length": valid_length, "passed": False, + "input_sha256": sha256_file(case_dir / "inputs.npz")} + report["cases"].append(row) + stage(f"{name}_ort", lambda: ort_reference(model_dir, feed, case_dir / "ort.npy")) + row["ort_sha256"] = sha256_file(case_dir / "ort.npy") + actual, run = stage(f"{name}_qemu", lambda: run_external(exe, feed, case_dir / "qemu", timeout=args.timeout)) + if sha256_file(exe.toolchain.qemu) != report["execution_tool_binary_sha256"]["qemu"]: + raise ValueError("QEMU executable changed after FMA preflight") + row["qemu"] = run + report["full_qemu_forward_executed"] = True + report["gates"]["smoke:qemu-full"] = True + expected = np.load(case_dir / "ort.npy", mmap_mode="r", allow_pickle=False) + row["numeric"] = stage(f"{name}_comparison", lambda: compare_logits(actual, expected)) + row["passed"] = row["numeric"]["passed"] + del actual, expected + if not row["passed"]: + raise ValueError(f"{name}: full QEMU error {row['numeric']['max_abs']} exceeds strict {ATOL}") + stage("source_postcheck", lambda: recheck_sources(report)) + report["gates"]["numeric:qemu-full-qwen3"] = all(row["passed"] for row in report["cases"]) + report.update(passed=True, status="PASS", stage="complete", coverage=args.case, + cases_passed=len(report["cases"]), seven_case_coverage=args.case == "all") + except BaseException as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + traceback.print_exc() + if isinstance(exc, KeyboardInterrupt): + report["interrupted"] = True + report["elapsed_seconds"] = time.perf_counter() - start + write_reports(out, report) + print(f"[W4] {report['status']}: {out / 'report.json'}", flush=True) + return 0 if report["passed"] or (args.build_only and report["status"] == "BUILD_ONLY") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 670aa63..b4320d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "scratchv" version = "0.3.0" description = "A compiler from ONNX models to RISC-V assembly and LLVM IR" readme = "README.md" -license = {text = "MIT"} +license = {text = "MIT AND MPL-2.0"} keywords = ["compiler", "risc-v", "onnx", "llvm", "machine-learning"] requires-python = ">=3.8" dependencies = [ @@ -20,6 +20,7 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: Education", "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -46,6 +47,9 @@ Documentation = "https://github.com/kinsomwang/ScratchV/tree/main/docs/INDEX.md" [project.scripts] scratchv = "scratchv.main:main" +[tool.setuptools] +license-files = ["LICENSE", "LICENSES/*.txt", "THIRD_PARTY_NOTICES.txt"] + [tool.setuptools.packages.find] include = ["scratchv", "scratchv.*", "scratchv_dag", "scratchv_dag.*", "benchmarks", "benchmarks.*"] diff --git a/scratchv/backend/instruction_select.py b/scratchv/backend/instruction_select.py index 4b15f8d..8569e80 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -28,6 +28,8 @@ def __init__(self, program: Program): self._max_temp_counter = 0 self._loop_stack: list[dict] = [] self._reserved_vreg_names = self._collect_ir_value_names() + self._bound_value_names: set[str] = set() + self._globals = {value.name: value for value in program.global_values} def _collect_ir_value_names(self) -> set[str]: """Reserve every user-visible IR name before creating temporaries.""" @@ -58,6 +60,18 @@ def _fresh_label(self, prefix: str = "L") -> str: return f".L{prefix}_{self._label_counter}" def _select_function(self, func: Function) -> None: + # Named definitions take precedence over constant metadata on a + # reference, matching the interpreter and LLVM backend. In particular, + # a parameter's constant flag is only a hint, not its runtime value. + # LOAD_CONST also defines a register: using that definition avoids + # emitting illegal immediates in ADD/STORE register operand positions. + self._bound_value_names = {value.name for value in func.params} + self._bound_value_names.update( + instr.dest.name + for block in func.blocks + for instr in (*block.phi_nodes, *block.instructions) + if instr.dest is not None + ) # Function prologue label self._emit_label(func.name) self._stack_offset = 0 @@ -138,6 +152,11 @@ def _emit_label(self, name: str) -> None: def _op(self, instr: Instruction, idx: int): """Get an operand from an IR instruction as a machine operand.""" op = instr.operands[idx] + if op.name in self._bound_value_names: + return MachineOperand.vreg(op.name) + # Global scalar literals are defined by Program.global_values, not by + # potentially incomplete or stale metadata on a separate reference. + op = self._globals.get(op.name, op) if op.is_constant and op.const_value is not None: return MachineOperand.immediate(int(op.const_value)) return MachineOperand.vreg(op.name) @@ -463,6 +482,8 @@ def _select_conv(self, instr: Instruction) -> None: def _select_gemm(self, instr: Instruction) -> None: """GEMM inline: real RISC-V MUL+ADD MAC.""" + if any(instr.attrs.get(name, 1) != 1 for name in ("alpha", "beta")): + raise ValueError("Legacy RISC-V GEMM does not support nondefault alpha/beta scaling") dst = self._dst(instr) a_reg = self._op(instr, 0) w_reg = self._op(instr, 1) diff --git a/scratchv/backend/llvm_codegen.py b/scratchv/backend/llvm_codegen.py index 1138f90..7bce9af 100644 --- a/scratchv/backend/llvm_codegen.py +++ b/scratchv/backend/llvm_codegen.py @@ -533,6 +533,8 @@ def _emit_conv(self, instr: Instruction) -> None: self._p(f" {dst} = fadd {ty} 0.0, 0.0 ; conv placeholder") def _emit_gemm(self, instr: Instruction) -> None: + if any(instr.attrs.get(name, 1) != 1 for name in ("alpha", "beta")): + raise ValueError("Legacy LLVM GEMM does not support nondefault alpha/beta scaling") dst = self._dest(instr) a = self._op(instr, 0) b = self._op(instr, 1) diff --git a/scratchv/backend/tensor_c_codegen.py b/scratchv/backend/tensor_c_codegen.py index 5b45db4..2d9cc9a 100644 --- a/scratchv/backend/tensor_c_codegen.py +++ b/scratchv/backend/tensor_c_codegen.py @@ -8,6 +8,14 @@ ``-fno-fast-math -ffp-contract=off -fno-strict-aliasing`` and link a conforming libm plus memcpy/memset. No tensor storage is placed on the stack. +``constant_storage="external"`` instead emits ``scratchv_run_external(inputs, +weights, workspace, workspace_bytes, output)``. Its caller supplies read-only +weight buffers in artifact order and an 8-byte-aligned workspace. There is no +mutable static storage. Input/output/weight buffer sizes are a caller contract: +the C ABI has no descriptors to discover allocation lengths. The loader must +validate them against TensorSpec before calling the function. Pointers, numeric +values, workspace capacity and writable-buffer overlap are checked in C. + Supported element types are FP32, INT32 and INT64. Control flow, dynamic shapes, empty reductions and nonfinite arithmetic are rejected explicitly. This backend does not silently lower tensor operations to scalar integer instructions. @@ -15,7 +23,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import math from typing import Mapping @@ -32,6 +40,7 @@ class TensorCCodegenError(ValueError): _TYPES = {DataType.FLOAT32: ("float", np.dtype("float32")), DataType.INT32: ("int32_t", np.dtype("int32")), DataType.INT64: ("int64_t", np.dtype("int64"))} +_UINT64_MAX = (1 << 64) - 1 @dataclass(frozen=True) @@ -62,6 +71,11 @@ class TensorCArtifact: constant_bytes: int function_name: str = "scratchv_run" compile_flags: tuple[str, ...] = ("-fno-fast-math", "-ffp-contract=off", "-fno-strict-aliasing") + constant_storage: str = "inline" + external_weights: tuple[TensorSpec, ...] = () + external_initializers: tuple[np.ndarray, ...] = field(default=(), repr=False, compare=False) + kernel_calls: bool = False + matmul_policy: str = "sequential" def _shape(shape): @@ -152,19 +166,50 @@ def _literal(value, dtype): } ''' +_EXTERNAL_PRELUDE = r''' +/* External storage targets 64-bit hosts/guests; lengths are caller-validated. */ +_Static_assert(sizeof(size_t) == 8 && sizeof(uintptr_t) == 8, + "external tensor ABI requires a 64-bit target"); +static int sv_buffer(const void *pointer, uint64_t bytes, size_t alignment) { + uintptr_t address = (uintptr_t)pointer; + return pointer && address % alignment == 0 && bytes <= UINTPTR_MAX - address; +} +static int sv_overlap(const void *a, uint64_t na, const void *b, uint64_t nb) { + uintptr_t pa = (uintptr_t)a, pb = (uintptr_t)b; + return na && nb && (pa <= pb ? pb - pa < na : pa - pb < nb); +} +static uint32_t sv_float_bits(float x) { + union { float f; uint32_t u; } value; value.f = x; return value.u; +} +''' + class TensorCCodegen: """Generate one straight-line function with strict tensor/dtype validation.""" def __init__(self, program: Program, initializers: Mapping[str, np.ndarray] | None = None, *, max_workspace_bytes=256 * 1024 * 1024, - max_constant_bytes=256 * 1024 * 1024): + max_constant_bytes=256 * 1024 * 1024, constant_storage="inline", + kernel_calls=False, matmul_policy="sequential"): self.program = program self.initializers = {} if initializers is None else dict(initializers) self.max_workspace_bytes = max_workspace_bytes self.max_constant_bytes = max_constant_bytes + self.constant_storage = constant_storage + self.kernel_calls = kernel_calls + self.matmul_policy = matmul_policy def generate(self) -> TensorCArtifact: + if self.constant_storage not in ("inline", "external"): + raise TensorCCodegenError("constant_storage must be 'inline' or 'external'") + if not isinstance(self.kernel_calls, bool): + raise TensorCCodegenError("kernel_calls must be boolean") + if self.kernel_calls and self.constant_storage != "external": + raise TensorCCodegenError("kernel_calls requires external constant storage") + if self.matmul_policy not in ("sequential", "blocked_fma"): + raise TensorCCodegenError("matmul_policy must be 'sequential' or 'blocked_fma'") + if self.matmul_policy != "sequential" and self.constant_storage != "external": + raise TensorCCodegenError("Experimental MatMul policies require external constant storage") if len(self.program.functions) != 1: raise TensorCCodegenError("Exactly one function is required") func = self.program.functions[0] @@ -180,9 +225,13 @@ def generate(self) -> TensorCArtifact: if any(value.is_constant for value in func.params): raise TensorCCodegenError("Function parameters cannot be constants") for bound in (self.max_workspace_bytes, self.max_constant_bytes): - if isinstance(bound, bool) or not isinstance(bound, int) or bound < 0: - raise TensorCCodegenError("Memory limits must be nonnegative integers") + if (isinstance(bound, bool) or not isinstance(bound, int) + or not 0 <= bound <= _UINT64_MAX): + raise TensorCCodegenError("Memory limits must be nonnegative 64-bit integers") self.specs, self.names, self.constants, self.body = {}, {}, [], [] + self.external_weights, self.external_initializers = [], [] + self.external_scalar_checks = [] + self.kernels = [] self.constant_bytes = 0 self._counter = 0 defined = set() @@ -252,14 +301,18 @@ def generate(self) -> TensorCArtifact: offset, block_size = free.pop(choice) if block_size > needed: free.append((offset + needed, block_size - needed)) - if high > self.max_workspace_bytes: + if high > self.max_workspace_bytes or high > _UINT64_MAX: raise TensorCCodegenError(f"Static workspace exceeds {self.max_workspace_bytes} bytes") allocations[value.name] = (offset, needed) self.body.append(f"/* {index}: {instruction.opcode.value} */") + arena = "sv_workspace" if self.constant_storage == "external" else "sv_arena.bytes" self.body.append(f"{_TYPES[spec.dtype][0]} *{self.names[value.name]} = " - f"({_TYPES[spec.dtype][0]} *)(sv_arena.bytes + {offset}ULL);") - self._emit(instruction, spec, details) - self._finite_check(spec) + f"({_TYPES[spec.dtype][0]} *)({arena} + {offset}ULL);") + if self.kernel_calls: + self._emit_kernel(instruction, spec, details, index) + else: + self._emit(instruction, spec, details) + self._finite_check(spec) for name in list(allocations): if last.get(name, index) <= index: free.append(allocations.pop(name)) @@ -279,10 +332,42 @@ def generate(self) -> TensorCArtifact: self._constant(returned, np.asarray(returned.const_value, dtype=self.specs[returned.name].numpy_dtype)) output = self.specs[returned.name] + if returned.dtype != output.dtype: + raise TensorCCodegenError(f"RETURN {returned.name}: dtype disagrees with defined value") + # Empty shape on a builder-created intermediate still means "infer". + # Parameters/globals and an explicit function signature are boundaries: + # their empty shape is a scalar, never a wildcard for a tensor buffer. + bound_names = {value.name for value in [*self.program.global_values, *func.params]} + inferred_return = isinstance(returned.shape, (tuple, list)) and len(returned.shape) == 0 + if ((not inferred_return or returned.name in bound_names) + and _shape(returned.shape) != output.shape): + raise TensorCCodegenError(f"RETURN {returned.name}: shape disagrees with defined value") + if func.returns: + if len(func.returns) != 1: + raise TensorCCodegenError("Function return signature must declare one tensor") + declared = func.returns[0] + if declared.dtype != output.dtype or _shape(declared.shape) != output.shape: + raise TensorCCodegenError("Function return signature dtype/shape disagrees with RETURN") ctype = _TYPES[output.dtype][0] self.body += ["if (!output) return 1;", f"for (size_t i=0; i<{output.size}ULL; ++i) (({ctype} *)output)[i] = {self.names[output.name]}[i];", "return 0;"] + if self.constant_storage == "external": + source = (_PRELUDE + _EXTERNAL_PRELUDE + "".join(self.kernels) + + "#if defined(_WIN32)\n__declspec(dllexport)\n#endif\n" + + "int scratchv_run_external(const void *const inputs[], " + "const void *const weights[], void *workspace, size_t workspace_bytes, " + "void *output) {\n " + + "\n ".join(self._external_prologue(inputs, output, high) + self.body) + + "\n}\n") + return TensorCArtifact( + source, inputs, output, high, self.constant_bytes, + function_name="scratchv_run_external", constant_storage="external", + external_weights=tuple(self.external_weights), + external_initializers=tuple(self.external_initializers), + kernel_calls=self.kernel_calls, + matmul_policy=self.matmul_policy, + ) source = (_PRELUDE + "\n" + "\n".join(self.constants) + f"\nstatic union {{ uint64_t align; unsigned char bytes[{max(high, 8)}]; }} sv_arena;\n" + "#if defined(_WIN32)\n__declspec(dllexport)\n#endif\n" @@ -295,6 +380,8 @@ def _register(self, value, shape=None): raise TensorCCodegenError(f"Unsupported dtype {value.dtype}; use FP32/INT32/INT64") shape = _shape(value.shape if shape is None else shape) spec = TensorSpec(value.name, value.dtype, shape) + if spec.nbytes > _UINT64_MAX: + raise TensorCCodegenError("Tensor byte count exceeds 64-bit address space") self.specs[value.name] = spec self.names[value.name] = f"sv_v{self._counter}" self._counter += 1 @@ -313,19 +400,98 @@ def _constant(self, value, data): raise TensorCCodegenError(f"Invalid scalar constant {value.name}") from exc if data.shape != () or expected.shape != () or not np.array_equal(data, expected): raise TensorCCodegenError(f"Initializer {value.name} disagrees with scalar constant") + if (self.constant_storage == "external" and spec.dtype == DataType.FLOAT32 + and data.view(np.uint32).item() != expected.view(np.uint32).item()): + raise TensorCCodegenError(f"Initializer {value.name} disagrees with scalar constant bits") self.constant_bytes += spec.nbytes - if self.constant_bytes > self.max_constant_bytes: + if self.constant_bytes > self.max_constant_bytes or self.constant_bytes > _UINT64_MAX: raise TensorCCodegenError("Constant tensor storage exceeds configured limit") + if self.constant_storage == "external": + # nditer bounds temporary memory even for a noncontiguous mmap/view. + if spec.dtype == DataType.FLOAT32: + for chunk in np.nditer(data, flags=["external_loop", "buffered", "zerosize_ok"], + op_flags=["readonly"], order="C", buffersize=262144): + if not np.isfinite(chunk).all(): + raise TensorCCodegenError("Nonfinite initializer/constant") + self.external_weights.append(spec) + self.external_initializers.append(data) + if value.is_constant: + name = self.names[value.name] + if spec.dtype == DataType.FLOAT32: + expected_bits = int(data.view(np.uint32).item()) + check = f"sv_float_bits({name}[0]) != {expected_bits}U" + else: + check = f"{name}[0] != {_literal(value.const_value, spec.dtype)}" + self.external_scalar_checks.append(f"if ({check}) return 2;") + return values = [_literal(v.item(), spec.dtype) for v in data.ravel()] lines = [", ".join(values[i:i + 8]) for i in range(0, len(values), 8)] self.constants.append(f"static const {_TYPES[spec.dtype][0]} {self.names[value.name]}" f"[{max(spec.size, 1)}] = {{\n" + ",\n".join(lines or ["0"]) + "\n};") + def _external_prologue(self, inputs, output, workspace_bytes): + """Validate all external addresses before dereferencing tensor data.""" + lines = [ + f"if (workspace_bytes < {workspace_bytes}ULL) return 1;", + f"if ({workspace_bytes}ULL && !sv_buffer(workspace, {workspace_bytes}ULL, 8)) return 1;", + f"if (!sv_buffer(output, {output.nbytes}ULL, {output.numpy_dtype.itemsize})) return 1;", + f"if (sv_overlap(workspace, {workspace_bytes}ULL, output, {output.nbytes}ULL)) return 1;", + "unsigned char *sv_workspace = (unsigned char *)workspace;", + ] + for table, specs in (("inputs", inputs), ("weights", self.external_weights)): + if specs: + lines.append(f"if (!sv_buffer({table}, {len(specs)}ULL * sizeof(void *), " + "_Alignof(void *))) return 1;") + lines.append(f"if (sv_overlap(workspace, {workspace_bytes}ULL, {table}, " + f"{len(specs)}ULL * sizeof(void *)) || sv_overlap(output, " + f"{output.nbytes}ULL, {table}, {len(specs)}ULL * sizeof(void *))) return 1;") + for index, spec in enumerate(specs): + pointer = f"{table}[{index}]" + lines.append(f"if (!sv_buffer({pointer}, {spec.nbytes}ULL, " + f"{spec.numpy_dtype.itemsize})) return 1;") + lines.append(f"if (sv_overlap(workspace, {workspace_bytes}ULL, {pointer}, " + f"{spec.nbytes}ULL) || sv_overlap(output, {output.nbytes}ULL, " + f"{pointer}, {spec.nbytes}ULL)) return 1;") + if table == "weights": + ctype, name = _TYPES[spec.dtype][0], self.names[spec.name] + lines.append(f"const {ctype} *{name} = (const {ctype} *){pointer};") + if spec.dtype == DataType.FLOAT32: + lines.append(f"for (size_t i=0; i<{spec.size}ULL; ++i) " + f"if (!sv_finite({name}[i])) return 2;") + return lines + self.external_scalar_checks + def _finite_check(self, spec): if spec.dtype == DataType.FLOAT32: self.body.append(f"for (size_t i=0; i<{spec.size}ULL; ++i) " f"if (!sv_finite({self.names[spec.name]}[i])) return 2;") + def _emit_kernel(self, instruction, output, details, index): + """Bound each C optimizer unit without changing the arithmetic body. + + Move the original operation and its immediate finite check verbatim. + Shape-specialized loops retain their reduction order and FP flags. + Explicit noinline prevents LLVM from rebuilding the giant graph body. + """ + start = len(self.body) + self._emit(instruction, output, details) + self._finite_check(output) + statements = self.body[start:] + del self.body[start:] + names = [self.names[output.name]] + parameters = [f"{_TYPES[output.dtype][0]} *{names[0]}"] + unique = {} + for value in instruction.operands: + unique.setdefault(value.name, value) + for value in unique.values(): + names.append(self.names[value.name]) + parameters.append(f"const {_TYPES[value.dtype][0]} *{names[-1]}") + self.kernels.append( + f"\nstatic __attribute__((noinline)) int sv_kernel_{index}({', '.join(parameters)}) {{\n " + + "\n ".join(statements) + "\n return 0;\n}\n" + ) + self.body.append(f"int sv_status_{index} = sv_kernel_{index}({', '.join(names)});") + self.body.append(f"if (sv_status_{index}) return sv_status_{index};") + def _infer(self, instruction): op, attrs = instruction.opcode, instruction.attrs xs = [self.specs[x.name] for x in instruction.operands] @@ -551,6 +717,37 @@ def _emit(self, instruction, output, details): m, k, n = a[-2], a[-1], b[-1] left = _broadcast_offset(batch, a[:-2], "batch") right = _broadcast_offset(batch, b[:-2], "batch") + if self.matmul_policy == "blocked_fma": + # Explicit experiment: four adjacent output columns reuse each + # left value. K-block products use fused FP32 multiply-add in K + # order, then each block is added to an FP32 running sum. This + # deliberately differs from sequential separate mul/add rounding. + # __builtin_fmaf keeps the explicit operation even with + # -fno-builtin and -ffp-contract=off used by the RV64 runtime. + lines = [ + f"for (size_t batch=0; batch<{math.prod(batch)}ULL; ++batch) " + f"for (size_t m=0; m<{m}ULL; ++m) " + f"for (size_t column=0; column<{n}ULL; column+=4) {{", + "float sum0=0.0f, sum1=0.0f, sum2=0.0f, sum3=0.0f;", + f"for (size_t block=0; block<{k}ULL; block+=128) {{", + "float acc0=0.0f, acc1=0.0f, acc2=0.0f, acc3=0.0f;", + f"size_t end=block+128 < {k}ULL ? block+128 : {k}ULL;", + "for (size_t k=block; kProgram: - """Parse an ONNX model file and return an IR Program.""" + self._mmap_external_data = False + self._opsets: dict[str, int] = {} + + def parse(self, model_path: str, *, mmap_external_data: bool = False) -> Program: + """Parse ONNX, optionally mapping external weights as read-only arrays. + + The default loader is unchanged. The explicit mmap mode avoids + materializing external weights in TensorProto.raw_data. External files + must be within the model directory, with valid byte ranges matching + the tensor shape and dtype. The caller must keep those files unchanged + while this parser's initializer arrays are in use. Mapping is not an + integrity check; model/weight hashes must be validated separately. + Runtime inputs require explicit static shapes after ONNX inference; + symbolic dimensions or unknown rank are rejected, not treated as zero. + Inputs also listed as initializers use the initializer's fixed shape. + """ + if not isinstance(mmap_external_data, bool): + raise ONNXParseError("mmap_external_data must be boolean") try: import onnx except ImportError: @@ -52,9 +67,11 @@ def parse(self, model_path: str) -> Program: self._producers = {} self._constant_cache = {} self._base_dir = str(Path(model_path).resolve().parent) + self._mmap_external_data = mmap_external_data # Inspect/infer the graph before materializing external weights. This # avoids serializing a >2 GiB protobuf just to infer tensor shapes. model = onnx.load(model_path, load_external_data=False) + self._opsets = {item.domain: item.version for item in model.opset_import} domains = sorted({node.domain for node in model.graph.node if node.domain not in ("", "ai.onnx")}) if domains: @@ -70,6 +87,28 @@ def parse(self, model_path: str) -> Program: except onnx.shape_inference.InferenceError as exc: raise ONNXParseError(f"ONNX shape inference failed: {exc}") from exc graph = model.graph + # Protobuf returns dim_value=0 when a dimension is symbolic or absent. + # Check presence before reading it: an explicit zero-sized tensor is + # static, while a missing shape field is unknown rank, not a scalar. + input_shapes = {} + initializer_names = {init.name for init in graph.initializer} + for inp in graph.input: + if inp.name in initializer_names: + # Legacy exports list fixed weights as graph inputs as well. + # This parser binds those as initializers, not parameters; + # their tensor data defines the shape even if input metadata + # remains symbolic after ONNX shape inference. + continue + tensor = inp.type.tensor_type + if not tensor.HasField("shape") or any( + not dim.HasField("dim_value") or dim.dim_value < 0 + for dim in tensor.shape.dim + ): + raise ONNXParseError( + f"Input {inp.name!r} requires a static nonnegative shape; " + "dynamic dimensions and unknown rank are unsupported" + ) + input_shapes[inp.name] = tuple(dim.dim_value for dim in tensor.shape.dim) self._tensor_info = { value.name: value.type.tensor_type for value in [*graph.input, *graph.value_info, *graph.output] @@ -93,7 +132,10 @@ def parse(self, model_path: str) -> Program: # Map ONNX initializers (constants) to IR values for init in graph.initializer: - arr = onnx.numpy_helper.to_array(init, base_dir=self._base_dir) + if mmap_external_data and onnx.external_data_helper.uses_external_data(init): + arr = self._map_external_initializer(init) + else: + arr = onnx.numpy_helper.to_array(init, base_dir=self._base_dir) self._bind_constant(init.name, arr) # Map graph inputs to function params @@ -104,9 +146,7 @@ def parse(self, model_path: str) -> Program: if inp.type.tensor_type.elem_type: dtype = self._dtype(inp.type.tensor_type.elem_type) val = self.builder.make_value(name=inp.name, dtype=dtype) - # Infer shape from ONNX type - shape_dims = list(inp.type.tensor_type.shape.dim) - val.shape = tuple(d.dim_value for d in shape_dims) + val.shape = input_shapes[inp.name] func.params.append(val) self._value_map[inp.name] = val @@ -127,6 +167,66 @@ def parse(self, model_path: str) -> Program: return self.builder.program + def _map_external_initializer(self, tensor): + """Validate before mapping; never populate the TensorProto raw_data.""" + import numpy as np + from onnx import helper + + self._dtype(tensor.data_type) # Keep the frontend's supported dtype set. + fields = {} + for entry in tensor.external_data: + if entry.key in fields: + raise ONNXParseError(f"Duplicate external field for {tensor.name}: {entry.key}") + fields[entry.key] = entry.value + unknown = set(fields) - {"location", "offset", "length", "checksum"} + if unknown: + raise ONNXParseError(f"Unsupported external fields for {tensor.name}: {sorted(unknown)}") + location = fields.get("location", "") + windows = PureWindowsPath(location) + path = Path(location) + if (not location or path.is_absolute() or windows.is_absolute() + or windows.drive or ".." in path.parts or ".." in windows.parts): + raise ONNXParseError(f"External path must stay within the model directory: {tensor.name}") + # Recognize either separator on every host; resolve symlinks as well. + base = Path(self._base_dir).resolve() + candidate = (base / Path(*windows.parts)).resolve() + try: + candidate.relative_to(base) + except ValueError as exc: + raise ONNXParseError(f"External path escapes the model directory: {tensor.name}") from exc + if candidate == base: + raise ONNXParseError(f"External path escapes the model directory: {tensor.name}") + if tensor.HasField("raw_data") or tensor.HasField("segment"): + raise ONNXParseError(f"Ambiguous raw or segmented external tensor: {tensor.name}") + shape = tuple(tensor.dims) + if any(dimension < 0 for dimension in shape): + raise ONNXParseError(f"Negative external tensor dimension: {tensor.name}") + dtype = np.dtype(helper.tensor_dtype_to_np_dtype(tensor.data_type)).newbyteorder("<") + expected = math.prod(shape) * dtype.itemsize + + def size_field(name, default): + value = fields.get(name) + if value is None: + return default + if not value or not value.isascii() or not value.isdecimal(): + raise ONNXParseError(f"External {name} must be a nonnegative integer: {tensor.name}") + return int(value) + + offset = size_field("offset", 0) + length = size_field("length", expected) + if length != expected: + raise ONNXParseError(f"External length disagrees with tensor shape/dtype: {tensor.name}") + try: + if not candidate.is_file() or offset + expected > candidate.stat().st_size: + raise ONNXParseError(f"External tensor byte range exceeds file: {tensor.name}") + if expected == 0: + array = np.empty(shape, dtype=dtype) + array.setflags(write=False) + return array + return np.memmap(candidate, mode="r", dtype=dtype, offset=offset, shape=shape, order="C") + except (OSError, ValueError, OverflowError) as exc: + raise ONNXParseError(f"Cannot map external tensor {tensor.name}: {exc}") from exc + def _translate_node(self, node, output_names: set[str]) -> None: """Translate a single ONNX node to IR instructions.""" op_type = node.op_type @@ -244,13 +344,16 @@ def _constant_ints(self, value: Value) -> tuple[int, ...]: def _handle_constant(self, node, inputs, outputs): import numpy as np - from onnx import numpy_helper + from onnx import external_data_helper, numpy_helper attrs = self._attributes(node) if len(attrs) != 1 or len(outputs) != 1 or inputs: raise ONNXParseError("Constant requires one value attribute and one output") key, value = next(iter(attrs.items())) if key == "value": - data = numpy_helper.to_array(value, base_dir=self._base_dir) + if self._mmap_external_data and external_data_helper.uses_external_data(value): + data = self._map_external_initializer(value) + else: + data = numpy_helper.to_array(value, base_dir=self._base_dir) elif key in ("value_int", "value_ints"): data = np.asarray(value, dtype=np.int64) elif key in ("value_float", "value_floats"): @@ -323,11 +426,22 @@ def _handle_matmul(self, node, inputs: list[Value], def _handle_gelu(self, node, inputs: list[Value], outputs: list[str]) -> None: + # ONNX defaults to the exact erf formulation, while this IR's GELU + # deliberately implements the tanh approximation. Never substitute it + # for the default just because both operations share a name. + attrs = self._attributes(node) + if set(attrs) - {"approximate"} or attrs.get("approximate", b"none") != b"tanh": + raise ONNXParseError("Gelu supports only approximate='tanh'; exact Gelu is unsupported") result = self.builder.gelu(inputs[0]) self._define_outputs(outputs, result) def _handle_softmax(self, node, inputs: list[Value], outputs: list[str]) -> None: + # Before opset 13 Softmax flattens dimensions from axis onward and + # defaults to axis=1; the IR normalizes a single axis. Supporting the + # modern default for a legacy graph silently produces wrong values. + if self._opsets.get(node.domain, 0) < 13: + raise ONNXParseError("Softmax requires ONNX opset >= 13; legacy flatten semantics are unsupported") axis = -1 for attr in node.attribute: if attr.name == "axis": @@ -377,15 +491,17 @@ def _handle_conv(self, node, inputs: list[Value], def _handle_gemm(self, node, inputs: list[Value], outputs: list[str]) -> None: - a, w, b = inputs[0], inputs[1], inputs[2] - trans_a = False - trans_b = False - for attr in node.attribute: - if attr.name == "transA": - trans_a = attr.i != 0 - elif attr.name == "transB": - trans_b = attr.i != 0 - result = self.builder.gemm(a, w, b, trans_a, trans_b) + attrs = self._attributes(node) + if set(attrs) - {"alpha", "beta", "transA", "transB"}: + raise ONNXParseError("Unsupported Gemm attributes") + if len(inputs) not in (2, 3): + raise ONNXParseError("Gemm requires two matrices and an optional bias") + a, w = inputs[:2] + b = inputs[2] if len(inputs) == 3 else self.builder.make_const(0, a.dtype) + result = self.builder.gemm( + a, w, b, bool(attrs.get("transA", 0)), bool(attrs.get("transB", 0)), + alpha=attrs.get("alpha", 1.0), beta=attrs.get("beta", 1.0), + ) self._define_outputs(outputs, result) def _handle_sigmoid(self, node, inputs: list[Value], diff --git a/scratchv/ir/builder.py b/scratchv/ir/builder.py index 36e526c..4820297 100644 --- a/scratchv/ir/builder.py +++ b/scratchv/ir/builder.py @@ -256,10 +256,16 @@ def conv(self, x: Value, w: Value, b: Value, return dest def gemm(self, a: Value, w: Value, b: Value, - trans_a: bool = False, trans_b: bool = False) -> Value: + trans_a: bool = False, trans_b: bool = False, *, + alpha: float = 1.0, beta: float = 1.0) -> Value: dest = self.make_value(dtype=a.dtype) + scales = {} + if alpha != 1: + scales["alpha"] = alpha + if beta != 1: + scales["beta"] = beta self._emit(OpCode.GEMM, dest, [a, w, b], - trans_a=trans_a, trans_b=trans_b) + trans_a=trans_a, trans_b=trans_b, **scales) return dest def sigmoid(self, val: Value) -> Value: diff --git a/scratchv/runtime/riscv_external.py b/scratchv/runtime/riscv_external.py new file mode 100644 index 0000000..112ae1f --- /dev/null +++ b/scratchv/runtime/riscv_external.py @@ -0,0 +1,569 @@ +"""Bare-metal RV64 external-weight execution; QMP transports completed output. + +QMP only reads guest RAM after the guest's success frame. All model arithmetic +runs in RV64 instructions, without semihosting or host numerical substitution. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import ctypes +import hashlib +import json +import os +from pathlib import Path +import signal +import socket +import stat +import struct +import subprocess +import threading +import time + +import numpy as np + +from .riscv_tensor import ( + STARTUP_ASSEMBLY, LINKER_SCRIPT, RiscVToolchain, _creation_flags, + _run_process, _save_process_logs, _spec_layout, _stop_process_tree, + _terminate_invocation, _windows_job, input_layout, pack_inputs, toolchain_versions, +) +from .weight_bundle import plan_guest_memory, validate_weight_bundle +from .weight_transport import ( + stage_transport, validate_transport, remove_transport, save_transport_report, + refresh_transport_residuals, +) + + +FRAME = struct.Struct("<8sIQ") +MAGIC = b"SVEXT001" +MAX_ELF_BYTES = 100_000_000 + + +def sha256_file(path): + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def validate_external_elf(path, layout): + """Validate every load segment, including BSS, against code/stack bounds.""" + path = Path(path) + before = path.stat() + if not stat.S_ISREG(before.st_mode) or before.st_size >= MAX_ELF_BYTES: + raise ValueError("Code-only ELF must be smaller than 100,000,000 bytes") + def identity(info): + # Windows stat/fstat may expose different ctime meanings. Device, + # inode, size and mtime remain comparable across both APIs. + fields = (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns) + return fields + ((info.st_ctime_ns,) if os.name != "nt" else ()) + with path.open("rb") as stream: + if identity(os.fstat(stream.fileno())) != identity(before): + raise ValueError("ELF changed while opening for validation") + data = stream.read(MAX_ELF_BYTES) + if (len(data) != before.st_size + or identity(os.fstat(stream.fileno())) != identity(before)): + raise ValueError("ELF changed while reading for validation") + if len(data) < 64 or data[:7] != b"\x7fELF\x02\x01\x01": + raise ValueError("Expected little-endian ELF64") + kind, machine = struct.unpack_from(" len(data): + raise ValueError("Invalid ELF program header table") + segments = [] + for index in range(phcount): + typ, attrs, offset, vaddr, paddr, filesz, memsz, align = struct.unpack_from( + " memsz or offset + filesz > len(data) or vaddr != paddr + or paddr < layout["code_base"] or paddr + memsz > layout["code_limit"]): + raise ValueError("ELF load segment exceeds code region or has invalid sizes") + if align not in (0, 1) and (align & (align - 1) or (vaddr - offset) % align): + raise ValueError("Invalid ELF segment alignment") + segments.append({"address": paddr, "filesz": filesz, "memsz": memsz, "flags": attrs}) + ordered = sorted(segments, key=lambda item: item["address"]) + for left, right in zip(ordered, ordered[1:]): + if left["address"] + left["memsz"] > right["address"]: + raise ValueError("ELF load segments overlap") + if not any(row["flags"] & 1 and row["address"] <= entry < row["address"] + row["filesz"] + for row in segments): + raise ValueError("ELF entry is not in an executable file-backed segment") + if identity(path.stat()) != identity(before): + raise ValueError("ELF changed during validation") + # Hash precisely the bytes whose ELF structure was checked, not a second + # open of the pathname which could describe a different file by now. + return {"bytes": len(data), "entry": entry, "segments": segments, + "sha256": hashlib.sha256(data).hexdigest()} + + +@dataclass(frozen=True) +class ExternalExecutable: + elf_path: Path + bundle_dir: Path + manifest: dict + layout: dict + inputs: tuple + output: object + weights: tuple + toolchain: RiscVToolchain + evidence: dict + + +def guest_harness(artifact, manifest, layout): + offsets, _ = input_layout(artifact.inputs) + function = artifact.function_name + if not function.isascii() or not function.isidentifier(): + raise ValueError("Invalid C entry point") + inputs = ",".join(f"(const void *)0x{layout['input_base'] + n:x}UL" for n in offsets) or "0" + weights = ",".join(f"(const void *)0x{layout['weights_base'] + t['offset']:x}UL" + for t in manifest["tensors"]) or "0" + return r''' +typedef unsigned long usize; +typedef unsigned char u8; +typedef unsigned int u32; +typedef unsigned long long u64; +void *memcpy(void *dst, const void *src, usize n) { + u8 *d=dst; const u8 *s=src; for (usize i=0;i >(8*i))); + for (int i=0;i<8;++i) uart_byte((u8)(size>>(8*i))); + __asm__ volatile("fence rw,rw" ::: "memory"); + for (;;) __asm__ volatile("wfi"); +} +void sv_trap_report(u64 cause) { frame(0x80000000U|(u32)cause, 0); } +''' + f''' +extern int {function}(const void *const *, const void *const *, void *, usize, void *); +void sv_main(void) {{ + const void *inputs[]={{ {inputs} }}; + const void *weights[]={{ {weights} }}; + int status={function}(inputs,weights,(void *)0x{layout['workspace_base']:x}UL, + {artifact.workspace_bytes}UL,(void *)0x{layout['output_base']:x}UL); + frame((u32)status,{artifact.output.nbytes}ULL); +}} +''' + + +def build_external(artifact, bundle_dir, build_dir, toolchain, *, timeout=600.0): + if artifact.constant_storage != "external": + raise ValueError("External runtime requires external code generation") + manifest = validate_weight_bundle(bundle_dir, artifact.external_weights) + _, input_bytes = input_layout(artifact.inputs) + layout = plan_guest_memory(workspace_bytes=artifact.workspace_bytes, + weight_bytes=manifest["total_bytes"], input_bytes=input_bytes, + output_bytes=artifact.output.nbytes) + build_dir = Path(build_dir).resolve() + build_dir.mkdir(parents=True, exist_ok=False) + linker = LINKER_SCRIPT.replace("0x1c000000", hex(layout["code_capacity"])) + sources = {"model.c": artifact.source, "guest.c": guest_harness(artifact, manifest, layout), + "start.S": STARTUP_ASSEMBLY, "link.ld": linker} + for name, source in sources.items(): + (build_dir / name).write_text(source, encoding="utf-8") + target = (["-target", "riscv64-linux-musl", "-static"] if toolchain.is_zig else + ["--target=riscv64-unknown-elf", "-fuse-ld=lld", "-nostdlib"]) + arch = (["-mcpu=generic_rv64+m+a+f+d+c+zicsr+zifencei"] if toolchain.is_zig else ["-march=rv64gc"]) + command = [*toolchain.cc, *target, "-O2", "-std=c11", *arch, "-mabi=lp64d", "-mcmodel=medany", + "-msmall-data-limit=0", "-mno-relax", "-ffreestanding", "-fno-builtin", + "-fno-stack-protector", "-fno-pie", "-fPIC" if toolchain.is_zig else "-fno-pic", + "-fno-fast-math", "-ffp-contract=off", "-fno-strict-aliasing", + "-ffunction-sections", "-fdata-sections", *artifact.compile_flags, + "model.c", "guest.c", "start.S", "-Wl,-T,link.ld", "-Wl,-e,sv_boot", + "-Wl,--gc-sections", "-Wl,--build-id=none", *(["-lm"] if toolchain.is_zig else []), + "-o", "model.elf"] + env = dict(os.environ) + temporary = build_dir / "tmp" + temporary.mkdir() + env.update(TMP=str(temporary), TEMP=str(temporary), TMPDIR=str(temporary)) + if toolchain.is_zig: + shared_cache = Path(__file__).resolve().parents[2] / "output" / ".riscv-zig-cache" + env["ZIG_GLOBAL_CACHE_DIR"] = str( + Path(os.environ.get("SCRATCHV_ZIG_CACHE", os.environ.get("ZIG_GLOBAL_CACHE_DIR", shared_cache))).resolve() + ) + env["ZIG_LOCAL_CACHE_DIR"] = str(build_dir / ".zig-cache") + start = time.perf_counter() + try: + result = _run_process(command, cwd=build_dir, timeout=timeout, env=env) + except subprocess.TimeoutExpired as exc: + _save_process_logs(build_dir, "compiler", exc.stdout, exc.stderr) + raise TimeoutError(f"External RV64 build exceeded {timeout}s") from exc + _save_process_logs(build_dir, "compiler", result.stdout, result.stderr) + if result.returncode: + raise RuntimeError(result.stderr.decode(errors="replace")[-12000:]) + evidence = validate_external_elf(build_dir / "model.elf", layout) + evidence.update(compile_seconds=time.perf_counter() - start, command=command, + versions=toolchain_versions(toolchain), + sources={name: sha256_file(build_dir / name) for name in sources}) + (build_dir / "layout.json").write_text(json.dumps(layout, indent=2) + "\n", encoding="utf-8") + (build_dir / "build.json").write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + return ExternalExecutable(build_dir / "model.elf", Path(bundle_dir).resolve(), manifest, layout, + tuple(artifact.inputs), artifact.output, tuple(artifact.external_weights), + toolchain, evidence) + + +class QMP: + """A bounded localhost QMP client that ignores asynchronous events.""" + def __init__(self, port, deadline): + self.deadline = deadline + self.serial = 0 + self.socket = socket.create_connection(("127.0.0.1", port), timeout=self.remaining()) + self.stream = None + try: + self.stream = self.socket.makefile("rwb", buffering=0) + if "QMP" not in self.read(): + raise RuntimeError("Invalid QMP greeting") + self.command("qmp_capabilities") + except BaseException: + try: + self.close() + except BaseException: + pass + raise + + def remaining(self): + value = self.deadline - time.monotonic() + if value <= 0: + raise TimeoutError("QMP deadline exceeded") + return min(value, 30.0) + + def read(self): + self.socket.settimeout(self.remaining()) + line = self.stream.readline(1024 * 1024) + if not line: + raise EOFError("QMP connection closed before response") + if len(line) >= 1024 * 1024 or not line.endswith(b"\n"): + raise RuntimeError("Truncated/oversized QMP response") + return json.loads(line) + + def command(self, name, arguments=None): + self.serial += 1 + request = {"execute": name, "id": self.serial} + if arguments is not None: + request["arguments"] = arguments + payload = json.dumps(request).encode() + b"\n" + self.socket.settimeout(self.remaining()) + if self.stream.write(payload) != len(payload): + raise RuntimeError("Incomplete QMP command write") + while True: + try: + result = self.read() + except (EOFError, ConnectionResetError): + # QMP quit may terminate QEMU before its reply is delivered: + # https://www.qemu.org/docs/master/interop/qemu-qmp-ref.html#command-quit + # Only a completely written, argument-free quit gets this + # exception. The caller must still require a normal process + # exit and validate the completed output and resource cleanup. + if name == "quit" and arguments is None: + return None + raise + if result.get("id") == self.serial: + if "error" in result: + raise RuntimeError(f"QMP {name}: {result['error']}") + if "return" not in result: + raise RuntimeError("Invalid QMP reply") + return result["return"] + + def close(self): + first_error = None + for resource in (self.stream, self.socket): + if resource is not None: + try: + resource.close() + except BaseException as exc: + if first_error is None: + first_error = exc + if first_error is not None: + raise first_error + + +def decode_completion(data, output_bytes): + if len(data) != FRAME.size: + raise ValueError("Missing, truncated or trailing completion frame") + magic, status, size = FRAME.unpack(data) + if magic != MAGIC: + raise ValueError("Invalid completion magic") + if status: + raise RuntimeError(f"RV64 guest failed with status 0x{status:08x}") + if size != output_bytes: + raise ValueError("Guest output length differs from generated ABI") + + +def _linux_rss(status): + rows = dict(line.split(":", 1) for line in status.splitlines() if ":" in line) + try: + amount, unit = rows["VmRSS"].split() + amount = int(amount) + if amount < 0 or unit != "kB": + raise ValueError("Invalid VmRSS unit/value") + return amount * 1024 + except (KeyError, ValueError) as exc: + raise OSError("QEMU resident memory is unavailable") from exc + + +def _owned_qemu_rss(process): + """Read only an owned Popen child, avoiding PID reuse after it is reaped.""" + if os.name == "nt": + from ctypes import wintypes + + class Counters(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("faults", wintypes.DWORD), + ("peak_rss", ctypes.c_size_t), ("rss", ctypes.c_size_t), + ("peak_paged", ctypes.c_size_t), ("paged", ctypes.c_size_t), + ("peak_nonpaged", ctypes.c_size_t), ("nonpaged", ctypes.c_size_t), + ("pagefile", ctypes.c_size_t), ("peak_pagefile", ctypes.c_size_t)] + get_memory = ctypes.WinDLL("psapi", use_last_error=True).GetProcessMemoryInfo + get_memory.argtypes = [wintypes.HANDLE, ctypes.POINTER(Counters), wintypes.DWORD] + get_memory.restype = wintypes.BOOL + values = Counters() + values.cb = ctypes.sizeof(values) + if not get_memory(int(process._handle), ctypes.byref(values), values.cb): + raise OSError(ctypes.get_last_error(), "Cannot read owned QEMU resident memory") + return int(values.rss) + # Popen.poll/wait use this lock too. While held, a live child cannot be + # reaped and replaced by an unrelated process with the same PID. + with process._waitpid_lock: + if process.returncode is not None: + return None + return _linux_rss(Path(f"/proc/{process.pid}/status").read_text(encoding="utf-8")) + + +class _QemuMemoryMonitor: + """Sample process RSS; this is observability, not an enforced RAM limit.""" + def __init__(self, process, interval=0.2): + self.process, self.interval = process, interval + self.samples, self.errors, self.peak, self.last_error = 0, 0, None, None + self.stopped = threading.Event() + self.thread = threading.Thread(target=self._run, name="scratchv-qemu-rss", daemon=True) + + def _run(self): + while not self.stopped.is_set(): + try: + value = _owned_qemu_rss(self.process) + if value is not None: + self.samples += 1 + self.peak = value if self.peak is None else max(self.peak, value) + except (OSError, AttributeError, ValueError) as exc: + self.errors += 1 + self.last_error = f"{type(exc).__name__}: {exc}" + self.stopped.wait(self.interval) + + def start(self): + self.thread.start() + + def close(self): + self.stopped.set() + self.thread.join(timeout=1) + if self.thread.is_alive(): + raise RuntimeError("QEMU RSS monitor did not stop") + + def report(self): + return { + "sampled_peak_qemu_rss_bytes": self.peak, + "qemu_rss_observed_samples": self.samples, + "qemu_rss_availability": ("unavailable" if not self.samples else + "partial" if self.errors else "available"), + "qemu_rss_sample_errors": self.errors, + "qemu_rss_last_error": self.last_error, + "qemu_rss_sample_interval_s": self.interval, + "qemu_rss_scope": "QEMU process RSS; distinct from guest layout and not a hard limit", + } + + +def run_external(executable, inputs, run_dir, *, timeout=3600.0): + """Run a fresh guest, require completion, stop, dump exact output, then quit.""" + if not np.isfinite(timeout) or timeout <= 0: + raise ValueError("timeout must be positive and finite") + if sha256_file(executable.elf_path) != executable.evidence["sha256"]: + raise ValueError("ELF changed after build") + manifest = validate_weight_bundle(executable.bundle_dir, executable.weights) + if manifest != executable.manifest: + raise ValueError("Weight manifest changed after build") + payload = pack_inputs(executable.inputs, inputs) + run_dir = Path(run_dir).resolve() + run_dir.mkdir(parents=True, exist_ok=False) + (run_dir / "inputs.bin").write_bytes(payload or b"\0") + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + layout = executable.layout + command = [executable.toolchain.qemu, "-machine", "virt", "-cpu", "rv64", "-accel", "tcg", + "-smp", "1", "-m", str(layout["memory_mib"]), "-bios", "none", "-display", "none", + "-monitor", "none", "-serial", "file:uart.bin", "-no-reboot", + "-qmp", f"tcp:127.0.0.1:{port},server=on,wait=off", + "-kernel", str(executable.elf_path), "-device", + f"loader,file=inputs.bin,addr=0x{layout['input_base']:x},force-raw=on"] + options = {"creationflags": _creation_flags()} + if os.name != "nt": + options["start_new_session"] = True + started = time.perf_counter() + report = {"passed": False, "status": "FAIL", "command": command, "timeout_s": timeout, + "transport": "QMP physical memory dump after guest completion", "guest_completed": False} + transport_dir = run_dir / "weight-transport" + report["weight_transport"] = {} + qmp, process, job, primary_error, monitor = None, None, None, None, None + qemu_started, qemu_exited = None, None + previous_term = None + try: + # SIGTERM must unwind through cleanup: QEMU owns a separate POSIX + # session and would otherwise survive a supervisor cancelling Python. + if os.name != "nt" and threading.current_thread() is threading.main_thread(): + previous_term = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, _terminate_invocation) + report["stage"] = "weight_transport" + stage_transport(executable.bundle_dir / "weights.bin", manifest, transport_dir, + report["weight_transport"]) + for row in report["weight_transport"]["files"]: + path = str(transport_dir / row["file"]).replace(",", ",,") + command += ["-device", f"loader,file={path},addr=0x{layout['weights_base'] + row['offset']:x},force-raw=on"] + # Empty bundles intentionally emit no raw loader: QEMU rejects 0-byte files. + report["stage"] = "qemu" + deadline = time.monotonic() + timeout + with (run_dir / "qemu.stdout").open("wb") as stdout, (run_dir / "qemu.stderr").open("wb") as stderr: + qemu_started = time.perf_counter() + process = subprocess.Popen(command, cwd=run_dir, stdout=stdout, stderr=stderr, **options) + job = _windows_job(process) + monitor = _QemuMemoryMonitor(process) + monitor.start() + uart = run_dir / "uart.bin" + while True: + if time.monotonic() >= deadline: + raise TimeoutError(f"RV64 forward exceeded {timeout}s") + if uart.exists() and uart.stat().st_size >= FRAME.size: + decode_completion(uart.read_bytes(), executable.output.nbytes) + break + if process.poll() is not None: + raise RuntimeError(f"QEMU exited before guest completion ({process.returncode})") + time.sleep(0.05) + report.update(guest_completed=True, forward_wall_s=time.perf_counter() - qemu_started) + qmp = QMP(port, deadline) + qmp.command("stop") + dump_started = time.perf_counter() + output_path = run_dir / "output.bin" + if executable.output.nbytes: + reply = qmp.command("human-monitor-command", {"command-line": + f"pmemsave 0x{layout['output_base']:x} {executable.output.nbytes} output.bin"}) + if reply.strip(): + raise RuntimeError(f"QMP memory dump failed: {reply}") + else: + output_path.write_bytes(b"") + if output_path.stat().st_size != executable.output.nbytes: + raise ValueError("Output dump size mismatch") + report["output_dump_s"] = time.perf_counter() - dump_started + report["qmp_quit_reply_received"] = qmp.command("quit") is not None + process.wait(timeout=min(10, max(0.1, deadline - time.monotonic()))) + qemu_exited = time.perf_counter() + if process.returncode: + raise RuntimeError(f"QEMU shutdown failed ({process.returncode})") + shape, dtype, _ = _spec_layout(executable.output) + output = (np.memmap(output_path, dtype=dtype.newbyteorder("<"), mode="r", shape=shape) + if executable.output.nbytes else np.empty(shape, dtype=dtype)) + for offset in range(0, output.size, 262144): + if not np.isfinite(output.reshape(-1)[offset:offset + 262144]).all(): + raise ValueError("Nonfinite RV64 output") + if validate_weight_bundle(executable.bundle_dir, executable.weights) != manifest: + raise ValueError("Weight bundle changed during execution") + report["stage"] = "weight_transport_postcheck" + report["weight_transport"]["post_validation_elapsed_s"] = validate_transport( + transport_dir, report["weight_transport"]) + report.update(passed=True, status="PASS", exit_code=process.returncode, + output_sha256=sha256_file(output_path), elf_sha256=executable.evidence["sha256"], + weight_sha256=manifest["sha256"], input_sha256=sha256_file(run_dir / "inputs.bin")) + return output, report + except BaseException as exc: + primary_error = exc + report.update(passed=False, status="FAIL", error=f"{type(exc).__name__}: {exc}") + if report.get("stage") == "weight_transport_postcheck": + report["weight_transport"].update(status="FAIL", stage="postcheck", + error=report["error"]) + raise + finally: + cleanup_errors = [] + + def cleanup(label, action): + try: + action() + except BaseException as exc: + cleanup_errors.append(f"{label}: {type(exc).__name__}: {exc}") + + if qmp is not None: + cleanup("qmp.close", qmp.close) + report.update(sampled_peak_qemu_rss_bytes=None, qemu_rss_observed_samples=0, + qemu_rss_availability="unavailable", qemu_rss_sample_interval_s=0.2) + if monitor is not None: + cleanup("QEMU RSS monitor close", monitor.close) + cleanup("QEMU RSS monitor report", lambda: report.update(monitor.report())) + if process is not None: + # Each cleanup step is attempted even when a preceding one fails. + # Bounded waits prevent a failed kill from hanging report creation. + try: + alive = process.poll() is None + except BaseException as exc: + cleanup_errors.append(f"process.poll: {type(exc).__name__}: {exc}") + alive = True + if alive: + cleanup("process-tree termination", lambda: _stop_process_tree(process, job)) + try: + process.wait(timeout=10) + except BaseException as exc: + cleanup_errors.append(f"process.wait: {type(exc).__name__}: {exc}") + cleanup("process.kill fallback", process.kill) + cleanup("process.wait fallback", lambda: process.wait(timeout=5)) + if process.returncode is not None and qemu_exited is None: + qemu_exited = time.perf_counter() + report["qemu_wall_s"] = (qemu_exited - qemu_started + if qemu_exited is not None and qemu_started is not None else None) + if job is not None: + def close_job(): + if job[0].CloseHandle(job[1]) == 0: + raise OSError("CloseHandle failed") + cleanup("Windows job close", close_job) + if previous_term is not None: + cleanup("restore SIGTERM", lambda: signal.signal(signal.SIGTERM, previous_term)) + # Only a fully successful, stopped/reaped guest permits copy removal. + # Failed/still-running invocations retain transport data for inspection. + if (report["passed"] and primary_error is None and not cleanup_errors + and process is not None and process.returncode == 0): + cleanup("weight transport cleanup", lambda: remove_transport( + transport_dir, report["weight_transport"])) + # Staging evidence can become stale during the guest or its postcheck. + # Observe owned copies again even on failure, without deleting them or + # allowing a secondary stat error to replace the original exception. + if "files" in report["weight_transport"]: + cleanup("weight transport residual observation", lambda: refresh_transport_residuals( + transport_dir, report["weight_transport"])) + def preserve_transport_report(): + if (transport_dir / "transport.json").is_file(): + save_transport_report(transport_dir, report["weight_transport"]) + cleanup("weight transport report", preserve_transport_report) + report["elapsed_s"] = time.perf_counter() - started + if cleanup_errors: + report.update(passed=False, status="FAIL", cleanup_errors=cleanup_errors) + if primary_error is None: + report["error"] = "RuntimeError: runtime cleanup failed" + try: + (run_dir / "run.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + except BaseException as exc: + if primary_error is None: + raise + if hasattr(primary_error, "add_note"): + primary_error.add_note(f"Could not preserve run.json: {type(exc).__name__}: {exc}") + if cleanup_errors and primary_error is None: + raise RuntimeError("Runtime cleanup failed: " + "; ".join(cleanup_errors)) diff --git a/scratchv/runtime/riscv_tensor.py b/scratchv/runtime/riscv_tensor.py index d6a401f..1860821 100644 --- a/scratchv/runtime/riscv_tensor.py +++ b/scratchv/runtime/riscv_tensor.py @@ -158,56 +158,102 @@ def _stop_process_tree(process: subprocess.Popen, job=None) -> None: def _run_process(command, *, cwd, timeout, env=None) -> subprocess.CompletedProcess: + """Run one owned tree with bounded process waits, including setup failures.""" options = {"creationflags": _creation_flags()} if os.name == "nt": options["creationflags"] |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) else: options["start_new_session"] = True - with subprocess.Popen(command, cwd=cwd, env=env, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, **options) as process: - job = _windows_job(process) + process, job, primary_error, previous_term = None, None, None, None + try: # The W2 supervisor first sends SIGTERM to the probe. This invocation # owns a different session, so killing the probe group alone would - # orphan its compiler/QEMU. Unwind through cleanup before exiting. - previous_term = None + # orphan its compiler/QEMU. Install before spawning, so setup failures + # and interruptions also unwind through the same cleanup boundary. if os.name != "nt" and threading.current_thread() is threading.main_thread(): previous_term = signal.getsignal(signal.SIGTERM) signal.signal(signal.SIGTERM, _terminate_invocation) - try: - stdout, stderr = process.communicate(timeout=timeout) - except subprocess.TimeoutExpired as exc: - _stop_process_tree(process, job) - try: - stdout, stderr = process.communicate(timeout=10) - except subprocess.TimeoutExpired as drain: - # A failed OS-level tree termination must not turn a bounded - # compiler timeout into an unbounded pipe read. - process.kill() - if process.stdout: - process.stdout.close() - if process.stderr: - process.stderr.close() - process.wait(timeout=5) - stdout, stderr = drain.output or b"", drain.stderr or b"" - raise subprocess.TimeoutExpired(command, timeout, output=stdout, stderr=stderr) from exc - except BaseException: - # Cancellation and Ctrl-C must also close the owned invocation. - # Do not replace the primary interruption with a cleanup failure. - try: - _stop_process_tree(process, job) - process.communicate(timeout=5) - except (OSError, subprocess.SubprocessError): - process.kill() - for stream in (process.stdout, process.stderr): - if stream: - stream.close() - raise - finally: - if previous_term is not None: - signal.signal(signal.SIGTERM, previous_term) - if job is not None: - job[0].CloseHandle(job[1]) + # Do not use Popen's context manager: its implicit __exit__.wait() has + # no deadline, even if Job setup failed before communicate() began. + process = subprocess.Popen(command, cwd=cwd, env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, **options) + job = _windows_job(process) + stdout, stderr = process.communicate(timeout=timeout) return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) + except BaseException as exc: + primary_error = exc + raise + finally: + cleanup_errors = [] + drain_failed = False + + def cleanup(label, action): + try: + action() + except BaseException as exc: + cleanup_errors.append(f"{label}: {type(exc).__name__}: {exc}") + + def close_pipe(stream): + if not drain_failed or os.name != "nt": + stream.close() + return + # Windows communicate() owns reader threads. If a descendant kept + # a pipe open after a failed tree kill, BufferedReader.close() can + # block acquiring its reader's lock. Do not turn bounded recovery + # into an unbounded wait on that lock. The daemon keeps the stream + # alive and eventually closes it when the reader releases the lock. + failures = [] + def close(): + try: + stream.close() + except BaseException as exc: + failures.append(exc) + closer = threading.Thread(target=close, daemon=True) + closer.start() + closer.join(timeout=0.25) + if closer.is_alive(): + raise TimeoutError("Pipe close is pending on a Windows reader thread") + if failures: + raise failures[0] + + if process is not None: + if primary_error is not None: + cleanup("process-tree termination", lambda: _stop_process_tree(process, job)) + try: + stdout, stderr = process.communicate(timeout=10) + if isinstance(primary_error, subprocess.TimeoutExpired): + primary_error.output, primary_error.stderr = stdout, stderr + except BaseException as exc: + drain_failed = True + cleanup_errors.append(f"pipe drain: {type(exc).__name__}: {exc}") + if (isinstance(primary_error, subprocess.TimeoutExpired) + and isinstance(exc, subprocess.TimeoutExpired)): + if exc.output is not None: + primary_error.output = exc.output + if exc.stderr is not None: + primary_error.stderr = exc.stderr + cleanup("process.kill fallback", process.kill) + cleanup("process.wait fallback", lambda: process.wait(timeout=5)) + # Kill-on-close is a second chance to release inherited pipe handles + # before attempting their close, even if explicit tree termination failed. + if job is not None: + def close_job(): + if job[0].CloseHandle(job[1]) == 0: + raise OSError("CloseHandle failed") + cleanup("close Windows Job", close_job) + if process is not None: + for name in ("stdout", "stderr"): + stream = getattr(process, name, None) + if stream is not None: + cleanup(f"close {name}", lambda stream=stream: close_pipe(stream)) + if previous_term is not None: + cleanup("restore SIGTERM", lambda: signal.signal(signal.SIGTERM, previous_term)) + if cleanup_errors: + detail = "; ".join(cleanup_errors) + if primary_error is None: + raise RuntimeError("Process cleanup failed: " + detail) + if hasattr(primary_error, "add_note"): + primary_error.add_note("Process cleanup: " + detail) def _terminate_invocation(signum, frame): diff --git a/scratchv/runtime/weight_bundle.py b/scratchv/runtime/weight_bundle.py new file mode 100644 index 0000000..9346350 --- /dev/null +++ b/scratchv/runtime/weight_bundle.py @@ -0,0 +1,373 @@ +"""Versioned, streaming external tensor weights and an explicit RV64 RAM plan. + +A bundle has fixed public files ``manifest.json`` and ``weights.bin``. Hashes detect damage, +not provenance: callers must bind ``expected_specs`` and the model identity to a +trusted source. Validation is a snapshot, not a lock for a later QEMU loader. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import hashlib +import json +import math +import os +from pathlib import Path +import re +import stat + +import numpy as np + + +SCHEMA = "scratchv.weights" +VERSION = 1 +ALIGNMENT = 64 +CHUNK_BYTES = 1024 * 1024 +MAX_MANIFEST_BYTES = 16 * 1024 * 1024 +UINT64_MAX = (1 << 64) - 1 +MIB = 1024 * 1024 +_DTYPES = {"f32": np.dtype(" 16: + raise WeightBundleError(f"{label} must have at most 16 static dimensions") + result = [_integer(d, label, maximum=(1 << 31) - 1, numpy_ok=numpy_ok) for d in value] + if math.prod(result) > (1 << 31) - 1: + raise WeightBundleError(f"{label} exceeds the static tensor element limit") + return result + + +def _name(value): + if not isinstance(value, str) or not value or len(value) > 65536 or "\0" in value: + raise WeightBundleError("Tensor name must be a nonempty string without NUL") + return value + + +def _spec_metadata(specs): + result, names = [], set() + for index, spec in enumerate(specs): + try: + name = _name(spec.name) + dtype = getattr(spec.dtype, "value", spec.dtype) + if not isinstance(dtype, str) or dtype not in _DTYPES: + raise WeightBundleError(f"Unsupported dtype for {name!r}: {dtype!r}") + shape = _shape(spec.shape, f"Shape of {name!r}", numpy_ok=True) + numpy_dtype = np.dtype(spec.numpy_dtype) + if numpy_dtype.newbyteorder("<") != _DTYPES[dtype]: + raise WeightBundleError(f"Spec dtype/numpy_dtype disagree for {name!r}") + nbytes = math.prod(shape) * _DTYPES[dtype].itemsize + if _integer(spec.nbytes, f"nbytes of {name!r}", numpy_ok=True) != nbytes: + raise WeightBundleError(f"Spec nbytes/shape disagree for {name!r}") + except (AttributeError, TypeError) as exc: + raise WeightBundleError(f"Invalid tensor spec at index {index}") from exc + if name in names: + raise WeightBundleError(f"Tensor names must be unique: {name!r}") + names.add(name) + result.append({"index": index, "name": name, "dtype": dtype, + "shape": shape, "nbytes": nbytes}) + return result + + +def _iter_array_chunks(array, dtype): + """C-order, little-endian blocks, bounded even for strided/mapped arrays.""" + iterator = np.nditer(array, flags=["external_loop", "buffered", "zerosize_ok"], + op_flags=["readonly"], op_dtypes=[dtype], casting="equiv", + order="C", buffersize=CHUNK_BYTES // dtype.itemsize) + with iterator: + for block in iterator: + if dtype.kind == "f" and not np.isfinite(block).all(): + raise WeightBundleError("Nonfinite floating-point weight") + yield block.tobytes(order="C") + + +def _reject_links(path): + """Reject symlinks/junctions in existing components, including the root.""" + path = Path(os.path.abspath(os.fspath(path))) + for component in (*reversed(path.parents), path): + if component.is_symlink() or (hasattr(component, "is_junction") and component.is_junction()): + raise WeightBundleError(f"Bundle path must not traverse a link: {component}") + return path + + +def write_weight_bundle(specs, arrays, directory): + """Write a new bundle directory without copying the complete model. + + ``arrays`` is a name->ndarray mapping or an iterable in ``specs`` order. + Parent directories must exist; even an empty existing output is rejected. + On failure a new directory may contain partial weights, but no valid manifest + is published. The caller owns and must keep source arrays stable while writing. + """ + metadata = _spec_metadata(specs) + names = [entry["name"] for entry in metadata] + if isinstance(arrays, Mapping): + if set(arrays) != set(names): + raise WeightBundleError("Array names must exactly match tensor specs") + array_iter = (arrays[name] for name in names) + else: + array_iter = iter(arrays) + directory = _reject_links(directory) + directory.mkdir(exist_ok=False) + total_hash = hashlib.sha256() + tensors, cursor = [], 0 + with (directory / "weights.bin").open("xb") as stream: + for entry in metadata: + try: + array = next(array_iter) + except StopIteration as exc: + raise WeightBundleError("Fewer arrays than tensor specs") from exc + dtype = _DTYPES[entry["dtype"]] + if not isinstance(array, np.ndarray): + raise WeightBundleError(f"Weight {entry['name']!r} must be an ndarray") + if list(array.shape) != entry["shape"] or array.dtype.newbyteorder("<") != dtype: + raise WeightBundleError(f"Weight {entry['name']!r} shape/dtype mismatch") + offset = _align(cursor) + padding = b"\0" * (offset - cursor) + stream.write(padding) + total_hash.update(padding) + tensor_hash, written = hashlib.sha256(), 0 + for chunk in _iter_array_chunks(array, dtype): + stream.write(chunk) + tensor_hash.update(chunk) + total_hash.update(chunk) + written += len(chunk) + if written != entry["nbytes"]: + raise WeightBundleError("Weight changed size while being written") + tensors.append({**entry, "offset": offset, "sha256": tensor_hash.hexdigest()}) + cursor = offset + written + sentinel = object() + if next(array_iter, sentinel) is not sentinel: + raise WeightBundleError("More arrays than tensor specs") + stream.flush() + os.fsync(stream.fileno()) + manifest = {"schema": SCHEMA, "version": VERSION, "endian": "little", + "alignment": ALIGNMENT, "total_bytes": cursor, + "sha256": total_hash.hexdigest(), "tensors": tensors} + payload = (json.dumps(manifest, indent=2, ensure_ascii=True) + "\n").encode("utf-8") + if len(payload) > MAX_MANIFEST_BYTES: + raise WeightBundleError("Manifest exceeds size limit") + # Publish only after durable tensor and manifest writes. A hard link is an + # atomic, no-replace publication within the same directory on Linux/Windows. + # Any pre-publication failure leaves no manifest.json for a reader to accept. + pending = directory / "manifest.pending.json" + with pending.open("xb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.link(pending, directory / "manifest.json") + try: + pending.unlink() + except OSError: + # The published bundle is complete. Cleanup failure is not data failure; + # validators only consume the two fixed public filenames. + pass + return manifest + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise WeightBundleError(f"Duplicate JSON key: {key!r}") + result[key] = value + return result + + +def _reject_constant(value): + raise WeightBundleError(f"Invalid JSON numeric constant: {value}") + + +def _fingerprint(info): + # CPython/Windows lstat and fstat can report creation/change time differently. + # POSIX ctime is comparable and adds a useful mutation check there. + change_time = info.st_ctime_ns if os.name != "nt" else None + return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, change_time + + +def _open_regular(path): + before = path.lstat() + if not stat.S_ISREG(before.st_mode): + raise WeightBundleError(f"Expected regular file: {path.name}") + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags) + stream = os.fdopen(fd, "rb") + if _fingerprint(before) != _fingerprint(os.fstat(stream.fileno())): + stream.close() + raise WeightBundleError(f"File changed while opening: {path.name}") + return stream, before + + +def _check_unchanged(stream, path, before): + if (_fingerprint(os.fstat(stream.fileno())) != _fingerprint(before) + or _fingerprint(path.lstat()) != _fingerprint(before)): + raise WeightBundleError(f"File changed during validation: {path.name}") + + +def _sha(value, label): + if not isinstance(value, str) or not _HEX_SHA256.fullmatch(value): + raise WeightBundleError(f"Invalid SHA-256 for {label}") + + +def _validate_manifest(manifest, expected_specs): + if not isinstance(manifest, dict) or set(manifest) != _TOP_KEYS: + raise WeightBundleError("Manifest must contain exactly the supported schema fields") + if manifest["schema"] != SCHEMA or _integer(manifest["version"], "version") != VERSION: + raise WeightBundleError("Unsupported weight bundle schema/version") + if manifest["endian"] != "little" or _integer(manifest["alignment"], "alignment") != ALIGNMENT: + raise WeightBundleError("Unsupported endian/alignment") + total_bytes = _integer(manifest["total_bytes"], "total_bytes") + _sha(manifest["sha256"], "weights.bin") + tensors = manifest["tensors"] + if not isinstance(tensors, list): + raise WeightBundleError("tensors must be an ordered list") + names, cursor = set(), 0 + for index, tensor in enumerate(tensors): + if not isinstance(tensor, dict) or set(tensor) != _TENSOR_KEYS: + raise WeightBundleError("Tensor entry contains unsupported schema fields") + if _integer(tensor["index"], "tensor index") != index: + raise WeightBundleError("Tensor indices must be unique, ordered and contiguous") + name = _name(tensor["name"]) + if name in names: + raise WeightBundleError("Tensor names must be unique") + names.add(name) + dtype = tensor["dtype"] + if not isinstance(dtype, str) or dtype not in _DTYPES: + raise WeightBundleError(f"Unsupported tensor dtype: {dtype!r}") + if not isinstance(tensor["shape"], list): + raise WeightBundleError("Manifest tensor shape must be a list") + shape = _shape(tensor["shape"], f"Shape of {name!r}") + nbytes = _integer(tensor["nbytes"], "tensor nbytes") + if nbytes != math.prod(shape) * _DTYPES[dtype].itemsize: + raise WeightBundleError("Tensor shape/dtype/nbytes mismatch") + offset = _integer(tensor["offset"], "tensor offset") + if offset != _align(cursor): + raise WeightBundleError("Tensor offsets must be aligned, ordered and non-overlapping") + cursor = offset + nbytes + if cursor > total_bytes: + raise WeightBundleError("Tensor exceeds weights.bin bounds") + _sha(tensor["sha256"], name) + if cursor != total_bytes: + raise WeightBundleError("total_bytes does not match final tensor boundary") + if expected_specs is not None: + expected = _spec_metadata(expected_specs) + actual = [{key: tensor[key] for key in ("index", "name", "dtype", "shape", "nbytes")} + for tensor in tensors] + if actual != expected: + raise WeightBundleError("Manifest tensors differ from trusted expected specs") + + +def validate_weight_bundle(directory, expected_specs=None): + """Validate fixed bundle files, all byte hashes, padding and finite fp32. + + Checks are bounded-memory and strict, but cannot authenticate a self-authored + manifest or guarantee that a file stays unchanged after this call returns. + """ + directory = _reject_links(directory) + if not directory.is_dir(): + raise WeightBundleError("Bundle directory does not exist") + try: + manifest_path = directory / "manifest.json" + manifest_stream, manifest_before = _open_regular(manifest_path) + with manifest_stream: + if manifest_before.st_size > MAX_MANIFEST_BYTES: + raise WeightBundleError("Manifest exceeds size limit") + payload = manifest_stream.read(MAX_MANIFEST_BYTES + 1) + if len(payload) > MAX_MANIFEST_BYTES: + raise WeightBundleError("Manifest exceeds size limit") + manifest = json.loads(payload.decode("utf-8"), object_pairs_hook=_unique_object, + parse_constant=_reject_constant) + _validate_manifest(manifest, expected_specs) + weights_path = directory / "weights.bin" + weights_stream, weights_before = _open_regular(weights_path) + with weights_stream: + if weights_before.st_size != manifest["total_bytes"]: + raise WeightBundleError("weights.bin length mismatch (truncated or extra bytes)") + total_hash, cursor = hashlib.sha256(), 0 + for tensor in manifest["tensors"]: + padding = weights_stream.read(tensor["offset"] - cursor) + if len(padding) != tensor["offset"] - cursor or any(padding): + raise WeightBundleError("Invalid or truncated alignment padding") + total_hash.update(padding) + remaining, tensor_hash = tensor["nbytes"], hashlib.sha256() + while remaining: + chunk = weights_stream.read(min(remaining, CHUNK_BYTES)) + if len(chunk) != min(remaining, CHUNK_BYTES): + raise WeightBundleError("Truncated weight tensor") + if tensor["dtype"] == "f32" and not np.isfinite(np.frombuffer(chunk, dtype=" UINT64_MAX: + raise WeightBundleError("Guest address range overflows uint64") + regions[name] = {"base": base, "end": end, "nbytes": nbytes} + cursor = end + used_bytes = cursor - ram_base + margin_bytes = max(16 * MIB, (used_bytes + 19) // 20) + ram_bytes = _align(used_bytes + margin_bytes, MIB) + if ram_base + ram_bytes > UINT64_MAX: + raise WeightBundleError("Guest RAM including margin overflows uint64") + return {"ram_base": ram_base, "ram_end": ram_base + ram_bytes, + "ram_bytes": ram_bytes, "memory_mib": ram_bytes // MIB, + "alignment": ALIGNMENT, "used_bytes": used_bytes, + "margin_bytes": ram_bytes - used_bytes, "regions": regions, + "code_base": ram_base, "code_capacity": code_capacity, + "code_limit": regions["code"]["end"], + "stack_bottom": regions["stack"]["base"], "stack_top": regions["stack"]["end"], + "input_base": regions["inputs"]["base"], "output_base": regions["outputs"]["base"], + "workspace_base": regions["workspace"]["base"], "weights_base": regions["weights"]["base"]} diff --git a/scratchv/runtime/weight_transport.py b/scratchv/runtime/weight_transport.py new file mode 100644 index 0000000..3515bbb --- /dev/null +++ b/scratchv/runtime/weight_transport.py @@ -0,0 +1,260 @@ +"""Bounded QEMU raw-loader copies of an unchanged standard weight bundle. + +Some QEMU releases issue a single host read per raw file. Transport files stay +well below Linux's single-read limit; the public bundle remains weights.bin. +""" +from __future__ import annotations + +import hashlib +import json +import os +import stat +import time + +from .weight_bundle import _open_regular, _check_unchanged, _reject_links + +CHUNK_BYTES = 512 * 1024 * 1024 +COPY_BUFFER_BYTES = 1024 * 1024 + + +def save_transport_report(directory, report): + directory = _reject_links(directory) + pending = _reject_links(directory / "transport.pending.json") + pending.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n", encoding="utf-8") + os.replace(pending, directory / "transport.json") + + +def _files(directory, report): + directory = _reject_links(directory) + for index, row in enumerate(report["files"]): + name = f"weights-{index:05d}.bin" + if row["file"] != name: + raise ValueError("Invalid weight transport filename/order") + path = directory / name + # Exact files created by this stage only; no glob or recursive cleanup. + if path.parent.resolve() != directory.resolve() or path.is_symlink(): + raise ValueError("Weight transport path escaped its directory") + yield row, path + + +def _owned_stat(path, row): + """Inspect an owned pathname without following or accepting a replacement.""" + if row.get("created") is not True: + raise ValueError("Weight transport file was not created by this stage") + info = path.lstat() + if (not stat.S_ISREG(info.st_mode) + or getattr(info, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)): + raise ValueError("Owned weight transport path is a link or nonregular file") + expected = row.get("file_identity") + actual = {"device": info.st_dev, "inode": info.st_ino} + if expected != actual: + raise ValueError("Owned weight transport file identity changed") + return info + + +def _observe_retained(directory, report): + """Measure owned file lengths; unknown observations are never reported as 0.""" + report.update(retained=None, retained_bytes=None, retained_files=[], + retained_observed_bytes=0, retained_observation="unavailable", + retained_observation_errors=[]) + remaining, errors, observed = [], [], 0 + owned = [(index, row) for index, row in enumerate(report["files"]) if row.get("created") is True] + try: + directory = _reject_links(directory) + except (OSError, ValueError) as exc: + errors.append(f"directory: {type(exc).__name__}: {exc}") + directory = None + for index, row in owned: + name = f"weights-{index:05d}.bin" + try: + if row["file"] != name: + raise ValueError("Invalid owned weight transport filename/order") + if directory is None: + raise ValueError("Owned transport directory is not safely observable") + info = _owned_stat(directory / name, row) + except FileNotFoundError: + observed += 1 + except (OSError, ValueError) as exc: + detail = f"{name}: {type(exc).__name__}: {exc}" + errors.append(detail) + remaining.append({"file": name, "nbytes": None, "error": detail}) + else: + observed += 1 + remaining.append({"file": name, "nbytes": info.st_size}) + known = [row for row in remaining if row["nbytes"] is not None] + known_bytes = sum(row["nbytes"] for row in known) + report.update(retained=True if known else None if errors else False, + retained_files=remaining, retained_bytes=None if errors else known_bytes, + retained_observed_bytes=known_bytes, + retained_observation="partial" if errors and observed else "unavailable" if errors else "complete", + retained_observation_errors=errors) + if errors: + raise OSError("Weight transport residual observation failed: " + "; ".join(errors)) + + +def refresh_transport_residuals(directory, report): + """Refresh owned-file evidence without modifying or deleting any file. + + Observation failures leave unknown lengths explicit, mark the transport as + failed and propagate to the caller, which may already have a primary error. + """ + try: + _observe_retained(directory, report) + except BaseException: + report["status"] = "FAIL" + raise + + +def _finish_report(directory, report, primary): + """Publish residual evidence without replacing an existing operation error.""" + deferred = None + try: + refresh_transport_residuals(directory, report) + except BaseException as exc: + report["status"] = "FAIL" + if not report["retained_observation_errors"]: + report["retained_observation_errors"] = [f"{type(exc).__name__}: {exc}"] + if primary is None: + primary = deferred = exc + report["error"] = f"{type(exc).__name__}: {exc}" + elif hasattr(primary, "add_note"): + primary.add_note(f"Could not fully observe transport residuals: {exc}") + try: + save_transport_report(directory, report) + except BaseException as exc: + report.update(status="FAIL", report_error=f"{type(exc).__name__}: {exc}") + if primary is None: + primary = deferred = exc + elif hasattr(primary, "add_note"): + primary.add_note(f"Could not preserve transport.json: {exc}") + if deferred is not None: + raise deferred + + +def validate_transport(directory, report): + """Re-read actual copies, checking each segment and concatenated byte hash.""" + started = time.perf_counter() + whole, cursor = hashlib.sha256(), 0 + for row, path in _files(directory, report): + if row["offset"] != cursor or not 0 < row["nbytes"] <= report["chunk_bytes"]: + raise ValueError("Invalid weight transport extent") + stream, before = _open_regular(path) + digest, count = hashlib.sha256(), 0 + with stream: + if before.st_size != row["nbytes"]: + raise ValueError("Weight transport size mismatch") + while count < row["nbytes"]: + block = stream.read(min(COPY_BUFFER_BYTES, row["nbytes"] - count)) + if not block: + break + count += len(block) + digest.update(block) + whole.update(block) + if stream.read(1): + raise ValueError("Weight transport grew during validation") + _check_unchanged(stream, path, before) + if count != row["nbytes"] or digest.hexdigest() != row["sha256"]: + raise ValueError("Weight transport segment hash mismatch") + cursor += count + if cursor != report["total_bytes"] or whole.hexdigest() != report["sha256"]: + raise ValueError("Weight transport total length/hash mismatch") + return time.perf_counter() - started + + +def stage_transport(source, manifest, directory, report, *, chunk_bytes=None): + """Create and verify new copies; failures retain owned files for inspection. + + ``report`` is also held by the caller, so partial failure evidence survives + even if writing transport.json itself fails (e.g. a full output volume). + """ + limit = CHUNK_BYTES if chunk_bytes is None else chunk_bytes + if type(limit) is not int or not 0 < limit <= 512 * 1024 * 1024: + raise ValueError("Weight transport chunks must be between 1 byte and 512 MiB") + report.update(schema="scratchv.weight-transport", version=1, status="FAIL", stage="prepare", + chunk_bytes=limit, total_bytes=manifest["total_bytes"], sha256=manifest["sha256"], + copied_bytes=0, files=[], retained=False, retained_bytes=0, retained_files=[]) + source, directory = _reject_links(source), _reject_links(directory) + owned, primary = False, None + started = time.perf_counter() + try: + directory.mkdir(exist_ok=False) + owned = True + report["stage"] = "copy" + stream, before = _open_regular(source) + whole, cursor = hashlib.sha256(), 0 + with stream: + if before.st_size != report["total_bytes"]: + raise ValueError("Source weight bundle length changed before transport") + while cursor < report["total_bytes"]: + size = min(limit, report["total_bytes"] - cursor) + row = {"file": f"weights-{len(report['files']):05d}.bin", "offset": cursor, + "nbytes": size, "written_bytes": 0, "sha256": None, "created": False} + report["files"].append(row) + digest = hashlib.sha256() + output = (directory / row["file"]).open("xb") + row["created"] = True + with output: + info = os.fstat(output.fileno()) + row["file_identity"] = {"device": info.st_dev, "inode": info.st_ino} + while row["written_bytes"] < size: + block = stream.read(min(COPY_BUFFER_BYTES, size - row["written_bytes"])) + if not block: + raise ValueError("Source weight bundle truncated during transport") + if output.write(block) != len(block): + raise OSError("Short weight transport write") + digest.update(block) + whole.update(block) + row["written_bytes"] += len(block) + report["copied_bytes"] += len(block) + output.flush() + os.fsync(output.fileno()) + row["sha256"] = digest.hexdigest() + cursor += size + if stream.read(1): + raise ValueError("Source weight bundle grew during transport") + _check_unchanged(stream, source, before) + if whole.hexdigest() != report["sha256"]: + raise ValueError("Source weight bundle hash changed before/during transport") + report["copy_elapsed_s"] = time.perf_counter() - started + report["stage"] = "validate" + report["validation_elapsed_s"] = validate_transport(directory, report) + report.update(status="PASS", stage="ready") + return report + except BaseException as exc: + primary = exc + report.update(status="FAIL", error=f"{type(exc).__name__}: {exc}") + raise + finally: + report["prepare_elapsed_s"] = time.perf_counter() - started + if owned: + _finish_report(directory, report, primary) + + +def remove_transport(directory, report): + """After successful execution only, unlink exactly this run's owned copies.""" + errors, primary = [], None + try: + directory = _reject_links(directory) + # Preflight every path before deleting any file. Planned but never + # created files, replacements and links are not this invocation's data. + paths = list(_files(directory, report)) + for row, path in paths: + _owned_stat(path, row) + for row, path in paths: + try: + _owned_stat(path, row) + path.unlink() + except (OSError, ValueError) as exc: + errors.append(f"{row['file']}: {type(exc).__name__}: {exc}") + if primary is None: + primary = exc + if primary is not None: + raise primary + report["stage"] = "copies_removed" + except BaseException as exc: + primary = exc + report.update(status="FAIL", stage="cleanup", + cleanup_errors=errors or [f"{type(exc).__name__}: {exc}"]) + raise + finally: + _finish_report(directory, report, primary) diff --git a/scratchv/verification/fp32_eigen.py b/scratchv/verification/fp32_eigen.py new file mode 100644 index 0000000..c53960d --- /dev/null +++ b/scratchv/verification/fp32_eigen.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: MPL-2.0 +# Adapted from Eigen (https://gitlab.com/libeigen/eigen), commit +# 1d8b82b0740839c0de7f1242a3585e3390ff5f33: +# Eigen/src/Core/Redux.h +# Eigen/src/Core/arch/SSE/PacketMath.h +# Eigen/src/Core/arch/Default/GenericPacketMathFunctions.h +# Copyright (C) 2006-2008 Benoit Jacob +# Copyright (C) 2007 Julien Pommier +# Copyright (C) 2008-2009 Gael Guennebaud +# Copyright (C) 2009-2019 Gael Guennebaud +# Copyright (C) 2014 Pedro Gonnet (pedro.gonnet@gmail.com) +# +# This Source Code Form is subject to the terms of the Mozilla +# Public License, v. 2.0. If a copy of the MPL was not distributed +# with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +# The full license is also distributed in LICENSES/Eigen-MPL-2.0.txt. +"""Deterministic FP32 reductions and trigonometric approximations. + +This NumPy adaptation preserves Eigen's SSE reduction order and its non-FMA +``psincos_float`` polynomial. All floating-point computation remains FP32. +It makes no promise of bitwise agreement with every platform's math library. +The reduction uses canonical contiguous row alignment, independent of the +input's memory address or strides. Trigonometry uses NumPy outside a shared +conservative interval for the non-FMA approximation. +""" + +import numpy as np + + +def _fp32_array(values): + array = np.asarray(values) + if array.dtype != np.dtype(np.float32): + raise TypeError("FP32 math requires a float32 array") + return array + + +def _sum_last(array, aligned_start=0): + """Eigen's two Packet4f accumulators, scalar prefix/tail, and predux.""" + width = array.shape[-1] + if width - aligned_start < 4: + result = array[..., 0].copy() + for index in range(1, width): + result = result + array[..., index] + return result + + packet_end = aligned_start + ((width - aligned_start) // 4) * 4 + pair_end = aligned_start + ((width - aligned_start) // 8) * 8 + first = array[..., aligned_start : aligned_start + 4].copy() + if packet_end - aligned_start > 4: + second = array[..., aligned_start + 4 : aligned_start + 8].copy() + for index in range(aligned_start + 8, pair_end, 8): + first = first + array[..., index : index + 4] + second = second + array[..., index + 4 : index + 8] + first = first + second + if packet_end > pair_end: + first = first + array[..., pair_end : pair_end + 4] + + result = (first[..., 0] + first[..., 2]) + (first[..., 1] + first[..., 3]) + for index in range(aligned_start): + result = result + array[..., index] + for index in range(packet_end, width): + result = result + array[..., index] + return result + + +def mean_last(values, keepdims=True): + """Reduce a nonempty last dimension using an explicit FP32 sum order. + + Leading dimensions may be empty. Scalar inputs and an empty reduction + dimension are rejected rather than inventing a mean for an empty set. + Inputs, including read-only and strided arrays, are never modified. + """ + array = _fp32_array(values) + if array.ndim == 0 or array.shape[-1] == 0: + raise ValueError("mean_last requires a nonempty last dimension") + if not isinstance(keepdims, (bool, np.bool_)): + raise TypeError("keepdims must be boolean") + + width = array.shape[-1] + if width % 4 == 0 or width < 4: + sums = _sum_last(array) + else: + # Model a 16-byte-aligned first row and contiguous successive rows. + # Shape alone determines the prefix; array pointer alignment does not. + rows = array.reshape(-1, width) + row_starts = (-np.arange(rows.shape[0]) * width) % 4 + sums = np.empty(rows.shape[0], dtype=np.float32) + for start in range(4): + selected = np.flatnonzero(row_starts == start) + if selected.size: + sums[selected] = _sum_last(rows[selected], start) + + result = np.asarray(sums / np.float32(width)).reshape(array.shape[:-1] + (1,)) + return result if keepdims else result.squeeze(axis=-1) + + +def trig(values, sine: bool): + """FP32 sine or cosine with Eigen's non-FMA range reduction polynomial. + + The shared interval ``abs(x) < 18000`` is below both upstream non-FMA + cutoffs. Larger arguments and nonfinite values retain NumPy semantics. + This approximation can differ from NumPy by a few ULP; it is not a claim + of universally improved accuracy. Scalar and empty arrays are supported. + """ + array = _fp32_array(values) + if not isinstance(sine, (bool, np.bool_)): + raise TypeError("sine must be boolean") + selected = np.isfinite(array) & (np.abs(array) < np.float32(18000)) + result = np.array(np.sin(array) if sine else np.cos(array), dtype=np.float32, copy=True) + reduced = np.abs(array[selected]) + rounded = reduced * np.float32(0.636619746685028076171875) + rounded = rounded + np.float32(12582912) + quadrant = rounded.view(np.uint32) + multiple = rounded - np.float32(12582912) + for coefficient in ( + -1.5703125, + -0.000483989715576171875, + 1.62865035235881805419921875e-7, + 5.5644315544167710640977020375430583953857421875e-11, + ): + reduced = multiple * np.float32(coefficient) + reduced + + squared = reduced * reduced + cosine = np.full_like(reduced, np.float32(2.4372266125283204019069671630859375e-5)) + for coefficient in ( + -0.00138865201734006404876708984375, + 0.041666619479656219482421875, + -0.5, + 1.0, + ): + cosine = cosine * squared + np.float32(coefficient) + + sinpoly = np.full_like( + reduced, + np.float32(-0.0001959234114083702898469196984621021329076029360294342041015625), + ) + sinpoly = sinpoly * squared + np.float32( + 0.00833268736556168516937947998712843400426208972930908203125 + ) + sinpoly = sinpoly * squared + np.float32( + -0.166666620398229825550373561782180331647396087646484375 + ) + sinpoly = sinpoly * squared + sinpoly = sinpoly * reduced + reduced + + even = (quadrant & np.uint32(1)) == 0 + if sine: + output = np.where(even, sinpoly, cosine) + sign = (array[selected].view(np.uint32) ^ (quadrant << np.uint32(30))) & np.uint32( + 0x80000000 + ) + else: + output = np.where(even, cosine, sinpoly) + sign = ((quadrant + np.uint32(1)) << np.uint32(30)) & np.uint32(0x80000000) + result[selected] = (output.view(np.uint32) ^ sign).view(np.float32) + return result diff --git a/scratchv/verification/fp32_fma.py b/scratchv/verification/fp32_fma.py new file mode 100644 index 0000000..d035212 --- /dev/null +++ b/scratchv/verification/fp32_fma.py @@ -0,0 +1,214 @@ +"""Correctly rounded finite FP32 fused multiply-add using NumPy only. + +No floating-point precision promotion is used. The integer fallback aligns +exact significands in uint64 (or nine uint64 limbs), then performs one binary32 +round-to-nearest-even operation. It supports broadcasting, normals, +subnormals and signed zero; NaN/Inf inputs and finite-result overflow raise. + +The FP32 error-free-transform fast path requires all three absolute inputs +in [2**-40, 2**40], hence a/b low-part products cannot be smaller than 2**-126 +and no intermediate can overflow. Dekker TwoProduct and Knuth TwoSum give +exact product_error and sum_error. Let s=fl(fl(a*b)+c), E=product_error+sum_error +(exact), and r=fl(E). Only cases abs(fl(a*b))<=abs(s), with s not a power of two, +are accepted. Then s has equal adjacent spacing u, abs(E)<=u, and abs(r)<=u. +The only rounding boundaries reachable from s are s +/- u/2. If abs(r)==u/2, +use the integer fallback. Otherwise r's distance from either boundary is at +least one ulp(r), larger than abs(E-r)<=0.5 ulp(r); fl(s+r) is therefore the +correct single-rounded result. Excluding powers of two avoids asymmetric +binade spacing. Cancellation, zero, range boundaries not satisfying these +conditions, midpoint ambiguity and all other cases use exact integers. + +The implementation assumes normal NumPy IEEE binary32 round-to-nearest-even +operations. It does not change the host rounding mode or enable FTZ/DAZ. +""" +import numpy as np + +_U = np.uint64 +_I = np.int64 + + +def _topbit(x): + """floor(log2(x)), choosing zero for x=0; only integer shifts.""" + x = x.copy() + result = np.zeros(x.shape, dtype=np.int64) + for shift in (32, 16, 8, 4, 2, 1): + high = x >> _U(shift) + selected = high != 0 + result += np.where(selected, shift, 0) + x = np.where(selected, high, x) + return result + + +def _unpack(array): + bits = array.view(np.uint32).reshape(-1) + exponent = (bits >> np.uint32(23)) & np.uint32(255) + if np.any(exponent == 255): + raise ValueError("finite float32 inputs required") + mantissa = (bits & np.uint32(0x7fffff)).astype(np.uint64) + mantissa |= np.where(exponent != 0, _U(1 << 23), _U(0)) + scale = np.where(exponent != 0, exponent.astype(np.int64) - _I(150), _I(-149)) + return bits >> np.uint32(31), mantissa, scale + + +def _pack(sign, q, quantum): + carry = q == _U(1 << 24) + q = np.where(carry, q >> _U(1), q) + quantum = quantum + carry.astype(np.int64) + exponent = np.where(q >= _U(1 << 23), quantum + _I(150), _I(0)) + if np.any(exponent >= 255): + raise FloatingPointError("fused result overflows finite float32") + bits = ((sign.astype(np.uint32) << np.uint32(31)) + | (exponent.astype(np.uint32) << np.uint32(23)) + | (q.astype(np.uint32) & np.uint32(0x7fffff))) + return bits.view(np.float32) + + +def _round64(sign, magnitude, scale): + quantum = np.maximum(_topbit(magnitude) + scale - _I(23), _I(-149)) + shift = quantum - scale + right = np.maximum(shift, _I(0)).astype(np.uint64) + left = np.maximum(-shift, _I(0)).astype(np.uint64) + q = (magnitude >> right) << left + guard_shift = np.maximum(shift - _I(1), _I(0)).astype(np.uint64) + guard = (shift > 0) & (((magnitude >> guard_shift) & _U(1)) != 0) + # For shift >=65 all magnitude bits are sticky, but the guard is zero. + mask = np.where(guard_shift >= _U(64), _U(0xffffffffffffffff), + (_U(1) << np.minimum(guard_shift, _U(63))) - _U(1)) + sticky = (magnitude & mask) != 0 + q += (guard & (sticky | ((q & _U(1)) != 0))).astype(np.uint64) + return _pack(sign, q, quantum) + + +def _limbs(magnitude, scale): + # Smallest exact product quantum is 2^-298. Highest possible product bit + # index is 553; 9 _limbs (576 bits) also have room for the final addition. + result = np.zeros((9, magnitude.size), dtype=np.uint64) + shifts = scale + _I(298) + indices = np.arange(magnitude.size) + low = shifts // _I(64) + rem = (shifts % _I(64)).astype(np.uint64) + result[low, indices] = magnitude << rem + high = magnitude >> (_U(64) - rem) + selected = (low + 1 < 9) & (high != 0) + result[low[selected] + 1, indices[selected]] = high[selected] + return result + + +def _slow(sign_p, product, ep, sign_c, cm, ec): + p, c = _limbs(product, ep), _limbs(cm, ec) + greater, less = np.zeros(product.size, dtype=bool), np.zeros(product.size, dtype=bool) + for index in range(8, -1, -1): + undecided = ~(greater | less) + greater |= undecided & (p[index] > c[index]) + less |= undecided & (p[index] < c[index]) + p_ge = ~less + same = sign_p == sign_c + sign = np.where(same | p_ge, sign_p, sign_c) + mag = np.zeros_like(p) + carry, borrow = np.zeros(product.size, dtype=np.uint64), np.zeros(product.size, dtype=np.uint64) + for index in range(9): + total = p[index] + c[index] + addition = total + carry + carry = ((total < p[index]) | (addition < total)).astype(np.uint64) + hi, lo = np.where(p_ge, p[index], c[index]), np.where(p_ge, c[index], p[index]) + diff = hi - lo + subtraction = diff - borrow + borrow = ((hi < lo) | (subtraction > diff)).astype(np.uint64) + mag[index] = np.where(same, addition, subtraction) + exact_zero = ~np.any(mag != 0, axis=0) + sign = np.where(exact_zero & ~same, np.uint32(0), sign) + high_index = np.zeros(product.size, dtype=np.int64) + for index in range(9): + high_index = np.where(mag[index] != 0, index, high_index) + columns = np.arange(product.size) + highest = high_index * _I(64) + _topbit(mag[high_index, columns]) + shift = np.maximum(highest - _I(23), _I(149)) + index = shift // _I(64) + rem = (shift % _I(64)).astype(np.uint64) + padded = np.vstack((mag, np.zeros((1, product.size), dtype=np.uint64))) + q = (padded[index, columns] >> rem) | (padded[index + 1, columns] << (_U(64) - rem)) + guard_index = (shift - _I(1)) // _I(64) + guard_rem = ((shift - _I(1)) % _I(64)).astype(np.uint64) + guard = ((mag[guard_index, columns] >> guard_rem) & _U(1)) != 0 + mask = (_U(1) << guard_rem) - _U(1) + sticky = np.zeros(product.size, dtype=bool) + for index in range(9): + sticky |= ((index < guard_index) & (mag[index] != 0)) + sticky |= ((index == guard_index) & ((mag[index] & mask) != 0)) + q += (guard & (sticky | ((q & _U(1)) != 0))).astype(np.uint64) + return _pack(sign, q, shift - _I(298)) + + +def _integer_fma(a, b, c): + arrays = [np.asarray(x) for x in (a, b, c)] + if any(x.dtype != np.dtype("float32") for x in arrays): + raise TypeError("FMA requires native float32 inputs") + a, b, c = np.broadcast_arrays(*arrays) + sa, am, ae = _unpack(a) + sb, bm, be = _unpack(b) + sc, cm, ce = _unpack(c) + product, pe, sp = am * bm, ae + be, sa ^ sb + scale = np.where(product == 0, ce, np.where(cm == 0, pe, np.minimum(pe, ce))) + ps, cs = pe - scale, ce - scale + # Each aligned operand has <=63 significant bits. Their sum fits uint64 + # (max 2^64-2), and opposite-sign subtraction is exact magnitude arithmetic. + fast = (((product == 0) | (_topbit(product) + ps < 63)) + & ((cm == 0) | (_topbit(cm) + cs < 63))) + result = np.empty(product.shape, dtype=np.float32) + if np.any(fast): + p = product[fast] << np.maximum(ps[fast], _I(0)).astype(np.uint64) + cv = cm[fast] << np.maximum(cs[fast], _I(0)).astype(np.uint64) + same = sp[fast] == sc[fast] + p_ge = p >= cv + magnitude = np.where(same, p + cv, np.where(p_ge, p - cv, cv - p)) + sign = np.where(same | p_ge, sp[fast], sc[fast]) + sign = np.where((magnitude == 0) & ~same, np.uint32(0), sign) + result[fast] = _round64(sign, magnitude, scale[fast]) + if np.any(~fast): + result[~fast] = _slow(sp[~fast], product[~fast], pe[~fast], sc[~fast], cm[~fast], ce[~fast]) + return result.reshape(a.shape) + + +def fma(a, b, c): + """Return round32(a*b+c), rejecting non-f32/nonfinite inputs and overflow.""" + values = [np.asarray(value) for value in (a, b, c)] + if any(value.dtype != np.dtype("float32") for value in values): + raise TypeError("FMA requires native float32 inputs") + arrays = np.broadcast_arrays(*values) + shape = arrays[0].shape + a, b, c = [value.reshape(-1) for value in arrays] + lower = np.array(87 << 23, dtype=np.uint32).view(np.float32) + upper = np.array(167 << 23, dtype=np.uint32).view(np.float32) + bounded = np.ones(a.shape, dtype=bool) + for value in (a, b, c): + bounded &= (np.abs(value) >= lower) & (np.abs(value) <= upper) + indices = np.flatnonzero(bounded) + result = np.empty(a.shape, dtype=np.float32) + accepted = np.zeros(a.shape, dtype=bool) + if indices.size: + av, bv, cv = a[indices], b[indices], c[indices] + product = av * bv + split_a = np.float32(4097) * av + high_a = split_a - (split_a - av) + low_a = av - high_a + split_b = np.float32(4097) * bv + high_b = split_b - (split_b - bv) + low_b = bv - high_b + product_error = low_a * low_b - ( + ((product - high_a * high_b) - low_a * high_b) - high_a * low_b) + total = product + cv + virtual_c = total - product + sum_error = (product - (total - virtual_c)) + (cv - virtual_c) + residual = product_error + sum_error + total_bits = total.view(np.uint32) + exponent = (total_bits >> np.uint32(23)) & np.uint32(255) + half_ulp = ((np.maximum(exponent, np.uint32(24)) - np.uint32(24)) + << np.uint32(23)).view(np.float32) + safe = ((np.abs(product) <= np.abs(total)) + & ((total_bits & np.uint32(0x7fffff)) != 0) + & (np.abs(residual) != half_ulp)) + accepted[indices[safe]] = True + result[indices[safe]] = (total + residual)[safe] + if np.any(~accepted): + result[~accepted] = _integer_fma(a[~accepted], b[~accepted], c[~accepted]) + return result.reshape(shape) diff --git a/scratchv/verification/fp32_reference.py b/scratchv/verification/fp32_reference.py new file mode 100644 index 0000000..24fbbc0 --- /dev/null +++ b/scratchv/verification/fp32_reference.py @@ -0,0 +1,269 @@ +"""Opt-in, fixed-order NumPy FP32 reference evaluation. + +This profile specifies evaluation order and polynomial approximations, not +universally greater mathematical accuracy or bitwise equality to every ORT +build/CPU. No model names, positions, weights, or expected outputs are used. +Default interpreter execution continues to use the native NumPy kernels. + +MLAS polynomial constants/evaluation are adapted from ONNX Runtime v1.22.1: +core/mlas/lib/logistic.cpp, compute.cpp, x86_64/TransKernelFma3.S and +x86_64/TransKernelAvx512F.S (including their amd64 Windows equivalents). +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from __future__ import annotations + +import os +import re + +import numpy as np + +from scratchv.ir.types import OpCode +from scratchv.verification.fp32_eigen import mean_last, trig +from scratchv.verification.fp32_fma import fma + + +_CPU_ENV = "SCRATCHV_FP32_REFERENCE_CPU" +_CPU_STRATEGIES = ("avx2-fma3", "avx512") + + +def _cpu_features(): + # NumPy checks both hardware capabilities and usable OS vector state. + # This private diagnostic is optional: an explicit strategy works without it. + try: + from numpy._core._multiarray_umath import __cpu_features__ + except ImportError: + try: + from numpy.core._multiarray_umath import __cpu_features__ + except ImportError: + return {} + return __cpu_features__ + + +def _cpu_strategy(requested=None): + """Resolve a reference arithmetic policy; never change ORT dispatch. + + Explicit policies also allow reproducing saved arithmetic on another CPU. + NumPy's disable list does not constrain MLAS, so automatic inference must + not treat deliberately hidden NumPy capabilities as hardware evidence. + """ + requested = os.environ.get(_CPU_ENV, "auto") if requested is None else requested + if requested in _CPU_STRATEGIES: + return requested + if requested != "auto": + raise ValueError(f"{_CPU_ENV} must be auto, avx2-fma3, or avx512") + disabled = re.split(r"[\s,]+", os.environ.get("NPY_DISABLE_CPU_FEATURES", "").upper()) + if any(feature in ("AVX", "AVX2", "FMA3") or feature.startswith("AVX512") + for feature in disabled): + raise ValueError(f"NPY_DISABLE_CPU_FEATURES obscures MLAS capabilities; set {_CPU_ENV} explicitly") + features = _cpu_features() + if features.get("AVX2") and features.get("FMA3"): + return "avx512" if features.get("AVX512F") else "avx2-fma3" + raise ValueError(f"Cannot infer a supported FP32 CPU strategy; set {_CPU_ENV} explicitly") + + +def profile(cpu_strategy=None): + """Return a canonical arithmetic contract, independent of selection method. + + Passing a supported explicit cpu_strategy validates a saved profile on a + different host without claiming that the local ORT uses that strategy. + """ + strategy = _cpu_strategy(cpu_strategy) + lanes = 8 if strategy == "avx2-fma3" else 16 + return { + "name": "numpy-fp32-reference-v4", + "dtype": "float32", + "cpu_strategy": strategy, + "matmul_k_block": 128, + "matmul_row_tile": 2 if lanes == 8 else None, + "matmul_row_tail": "zero-pad-to-two-then-trim" if lanes == 8 else "native", + "matmul_column_tail": "zero-pad-single-column-to-two-then-trim" if lanes == 8 else "native", + "matmul_layout": "contiguous-matrix-operands-per-k-block" if lanes == 8 else "native-strides", + "matmul_vector_policy": "native-numpy-vector-promotion", + "mean": "two-four-lane-last-axis", + "trig": "non-fused-range-reduction-polynomial", + "softmax": f"exp-polynomial-{lanes}-lane-sum-reciprocal-multiply", + "softmax_lanes": lanes, + "softmax_exp_lower_bound": float(np.float32( + -88.3762626647949 if lanes == 8 else -103.9720840454)), + "softmax_exp_scaling": "binary32-exponent-bits" if lanes == 8 else "ldexp", + "sigmoid": "bounded-rational-polynomial-clamped-to-[0,1]", + "multiply_add": "binary32-round-to-nearest-even", + "reference_basis": "ORT 1.22.1 CPU arithmetic; no ORT runtime dependency", + "scope": "Explicit FP32 reference evaluation; other dtypes unchanged. " + "CPU strategy specifies reference arithmetic, not ORT dispatch. " + "Not a promise of bitwise matching every CPU or ORT version.", + } + + +def matmul(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError + a, b = xs + if "m" in attrs: + m, n, k = (attrs[key] for key in ("m", "n", "k")) + if a.ndim == b.ndim == 1: + a, b = a.reshape(m, k), b.reshape(k, n) + elif a.shape != (m, k) or b.shape != (k, n): + raise OpError("ShapeError", "MATMUL m/n/k disagree with tensor shapes") + # Preserve NumPy's existing vector-promotion arithmetic and shape errors. + if a.ndim < 2 or b.ndim < 2: + return np.matmul(a, b) + if a.shape[-1] != b.shape[-2]: + raise OpError("ShapeError", "MATMUL reduction dimensions differ") + strategy = _cpu_strategy() + if strategy == "avx512" and a.shape[-1] <= 128: + return np.matmul(a, b) + shape = np.broadcast_shapes(a.shape[:-2], b.shape[:-2]) + (a.shape[-2], b.shape[-1]) + result = np.zeros(shape, dtype=np.float32) + if strategy == "avx512": + # Retain the existing wide-matrix policy. SkylakeX small-matrix kernels + # have a different reduction order from its ordinary wide GEMM path. + for start in range(0, a.shape[-1], 128): + result += np.matmul(a[..., start:start + 128], b[..., start:start + 128, :]) + return result + # Haswell OpenBLAS 0.3.29's wider SGEMM tiles use two interleaved K + # accumulators on selected boundaries (sgemm_kernel_8x4_haswell_2.c). + # A fixed two-row tile avoids those boundaries; padding a single-row + # tail also avoids switching to a different GEMV reduction. This applies + # to K<=128 too: attention score products use exactly K=128. + if not a.shape[-2] or not b.shape[-1]: + return result + for start in range(0, a.shape[-1], 128): + # Packing once per K block also handles stepped/transposed inputs: + # non-BLAS NumPy strided loops have a different arithmetic order. + left = np.ascontiguousarray(a[..., start:start + 128]) + right = np.ascontiguousarray(b[..., start:start + 128, :]) + if left.shape[-2] % 2: + padded = np.zeros(left.shape[:-2] + (left.shape[-2] + 1, left.shape[-1]), dtype=np.float32) + padded[..., :-1, :] = left + left = padded + if right.shape[-1] == 1: + padded = np.zeros(right.shape[:-1] + (2,), dtype=np.float32) + padded[..., :1] = right + right = padded + for row in range(0, a.shape[-2], 2): + rows = min(2, a.shape[-2] - row) + product = np.matmul(left[..., row:row + 2, :], right) + result[..., row:row + rows, :] += product[..., :rows, :b.shape[-1]] + return result + + +def reduce_mean(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError, axes, flag, reduce_mean as native + x = xs[0] + requested = attrs.get("axes") + selected = tuple(range(x.ndim)) if not requested else axes(requested, x.ndim) + if any(x.shape[a] == 0 for a in selected): + raise OpError("NumericError", "cannot reduce an empty dimension") + if x.ndim and selected == (x.ndim - 1,): + return mean_last(x, keepdims=flag(attrs.get("keepdims", True), "keepdims")) + return native(xs, attrs, dtype) + + +_SIGMOID_P = tuple(np.float32(v) for v in ( + 4.37031012579801e-11, 1.15627324459942e-7, 6.08574864600143e-5, + 8.51377133304701e-3, 2.48287947061529e-1)) +_SIGMOID_Q = tuple(np.float32(v) for v in ( + 6.10247389755681e-13, 5.76102136993427e-9, 6.29106785017040e-6, + 1.70198817374094e-3, 1.16817656904453e-1, 9.93151921023180e-1)) +_EXP = tuple(np.float32(float.fromhex(v)) for v in ( + "0x1.694000p-10", "0x1.125edcp-7", "0x1.555b5ap-5", "0x1.555450p-3", + "0x1.fffff6p-2", "0x1.000000p+0", "0x1.000000p+0")) + + +def sigmoid(xs, attrs, dtype): + x = np.clip(xs[0], np.float32(-18), np.float32(18)) + squared = x * x + numerator = np.full_like(x, _SIGMOID_P[0]) + denominator = np.full_like(x, _SIGMOID_Q[0]) + for coefficient in _SIGMOID_P[1:]: + numerator = fma(numerator, squared, coefficient) + for coefficient in _SIGMOID_Q[1:]: + denominator = fma(denominator, squared, coefficient) + # Rational-approximation roundoff can cross either saturation endpoint. + return np.clip((numerator * x) / denominator + np.float32(0.5), + np.float32(0), np.float32(1)) + + +def _exp_nonpositive(x, *, cpu_strategy="avx512"): + # The bounded polynomial's smallest result rounds to zero; masked -inf + # from stable subtraction is consequently normalized to probability zero. + x = np.maximum(x, np.float32( + -88.3762626647949 if cpu_strategy == "avx2-fma3" else -103.9720840454)) + bias = np.float32(12582912) + rounded = fma(x, np.float32(1.44269504088896341), bias) + exponent = rounded - bias + x = fma(exponent, np.float32(-6.93145752e-1), x) + x = fma(exponent, np.float32(-1.42860677e-6), x) + polynomial = np.full_like(x, _EXP[0]) + for coefficient in _EXP[1:]: + polynomial = fma(polynomial, x, coefficient) + if cpu_strategy == "avx2-fma3": + # FMA3 reconstructs 2**m directly in the binary32 exponent field. + # In particular m=-127 becomes zero, unlike AVX512's VSCALEFPS. + bits = (rounded.view(np.uint32) << np.uint32(23)) + np.uint32(0x3f800000) + return polynomial * bits.view(np.float32) + return np.ldexp(polynomial, exponent.astype(np.int32)) + + +def _sum_lanes(values, lanes): + sums = np.zeros(values.shape[:-1] + (lanes,), dtype=np.float32) + for start in range(0, values.shape[-1], lanes): + block = values[..., start:start + lanes] + sums[..., :block.shape[-1]] += block + if lanes == 16: + sums = sums[..., :8] + sums[..., 8:] + first = (sums[..., 0] + sums[..., 1]) + (sums[..., 2] + sums[..., 3]) + last = (sums[..., 4] + sums[..., 5]) + (sums[..., 6] + sums[..., 7]) + return (first + last)[..., None] + + +def _sum_sixteen(values): + # Retain the named primitive for existing standalone arithmetic diagnostics. + return _sum_lanes(values, 16) + + +def softmax(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError, axis + x = xs[0] + selected = axis(attrs.get("axis", -1), x.ndim) + if x.shape[selected] == 0: + raise OpError("NumericError", "SOFTMAX cannot normalize an empty axis") + x = np.moveaxis(x, selected, -1) + maximum = np.max(x, axis=-1, keepdims=True) + if not np.isfinite(maximum).all(): + raise OpError("NumericError", "SOFTMAX row is entirely masked or nonfinite") + with np.errstate(over="ignore"): + reduced = x - maximum + strategy = _cpu_strategy() + weights = _exp_nonpositive(reduced, cpu_strategy=strategy) + result = weights * (np.float32(1) / _sum_lanes( + weights, 8 if strategy == "avx2-fma3" else 16)) + return np.moveaxis(result, -1, selected) + + +HANDLERS = { + OpCode.MATMUL: matmul, + OpCode.REDUCE_MEAN: reduce_mean, + OpCode.SOFTMAX: softmax, + OpCode.SIGMOID: sigmoid, + OpCode.COS: lambda xs, attrs, dtype: trig(xs[0], sine=False), + OpCode.SIN: lambda xs, attrs, dtype: trig(xs[0], sine=True), +} diff --git a/scratchv/verification/ir_interpreter.py b/scratchv/verification/ir_interpreter.py index 1891229..bf91684 100644 --- a/scratchv/verification/ir_interpreter.py +++ b/scratchv/verification/ir_interpreter.py @@ -3,13 +3,15 @@ Integer ADD/SUB/MUL/NEG wrap at the declared width; DIV truncates toward zero and rejects zero and min/-1. ALLOCA sizes are bytes; LOAD/STORE access the first scalar slot only and reject reads before STORE. No host pointers exist. -Every run owns fresh state and copies external arrays and returned results. +Every run owns fresh state and, by default, copies external arrays and results. +Explicit straight-line execution options can borrow immutable weights and +release values after their final use. Returned results are always copied. """ from __future__ import annotations from dataclasses import dataclass -from typing import Mapping +from typing import Callable, Mapping import numpy as np @@ -66,6 +68,7 @@ class ExecutionResult: return_value: np.ndarray | None executed_steps: int diagnostics: tuple[VerificationError, ...] = () + memory_stats: dict | None = None @dataclass @@ -74,6 +77,80 @@ class _MemorySlot: initialized: bool = False +def _backing_storage(array): + """Identify the owner of a view without keeping new array references alive.""" + owner = array + seen = set() + while id(owner) not in seen: + seen.add(id(owner)) + base = getattr(owner, "base", None) + if base is None and isinstance(owner, memoryview): + base = owner.obj + if base is None: + break + owner = base + try: + size = memoryview(owner).nbytes + except TypeError: + # Normal NumPy views (including broadcast/strided views) terminate in + # a buffer owner. Fall back to the root array for unusual owners. + size = getattr(owner, "nbytes", array.nbytes) + return id(owner), int(size) + + +class _MemoryObservation: + """Optional instruction-boundary accounting; never releases/copies values.""" + + def __init__(self, values, params, globals_): + self.input_bytes = sum(values[name].nbytes for name in params) + self.initializer_bytes = sum( + values[name].nbytes for name in globals_ if name in values + ) + self.peak_logical = 0 + self.peak_storage = 0 + self.observe(values) + + @staticmethod + def snapshot(values, returned=None): + arrays = [value.storage if isinstance(value, _MemorySlot) else value + for value in values.values()] + if returned is not None: + arrays.append(returned) + storage = {} + for array in arrays: + key, size = _backing_storage(array) + storage[key] = max(size, storage.get(key, 0)) + return sum(int(array.nbytes) for array in arrays), sum(storage.values()) + + def observe(self, values, returned=None): + logical, storage = self.snapshot(values, returned) + self.peak_logical = max(self.peak_logical, logical) + self.peak_storage = max(self.peak_storage, storage) + + def finish(self, values, returned): + self.observe(values, returned) + logical, storage = self.snapshot(values) + return { + "input_logical_bytes": int(self.input_bytes), + "initializer_logical_bytes": int(self.initializer_bytes), + "peak_live_logical_bytes": self.peak_logical, + "peak_numpy_storage_bytes": self.peak_storage, + "retained_values": len(values), + "retained_logical_bytes": logical, + "retained_numpy_storage_bytes": storage, + "return_logical_bytes": 0 if returned is None else int(returned.nbytes), + "scope": "interpreter retained values at instruction boundaries; final return copy included in peaks", + "notes": [ + "Logical bytes count each retained binding, including views and ALLOCA storage.", + "Storage bytes deduplicate shared backing buffer owners, including NumPy views.", + "Initializer bytes count bound referenced globals, including scalar global constants.", + "Retained values exclude the independent return copy; peaks include it.", + "Counts currently bound buffers, including borrowed initializers; excludes other caller-held arrays, temporary kernel buffers, Python objects and allocator overhead; not process RSS.", + "Observation does not change existing copying, value retention, control flow or numeric semantics.", + ], + } + + _CONTROL_ATTRS = { OpCode.FOR: {"start", "end", "step"}, OpCode.ENDFOR: set(), @@ -85,6 +162,19 @@ class _MemorySlot: OpCode.STORE: set(), } +# An explicit allow-list makes future effectful instructions fail closed in +# the opt-in borrowing/liveness modes. Every listed kernel treats inputs as +# immutable and returns an array or a view of an input. +_PURE_OPS = { + OpCode.ADD, OpCode.SUB, OpCode.MUL, OpCode.DIV, OpCode.POW, + OpCode.NEG, OpCode.ABS, OpCode.COS, OpCode.SIN, OpCode.RECIPROCAL, + OpCode.EXP, OpCode.SQRT, OpCode.REDUCE_MEAN, OpCode.LOAD_CONST, + OpCode.CAST, OpCode.MATMUL, OpCode.RELU, OpCode.MAXPOOL, OpCode.SOFTMAX, + OpCode.GELU, OpCode.DOT, OpCode.CONV, OpCode.GEMM, OpCode.SIGMOID, + OpCode.TRANSPOSE, OpCode.RESHAPE, OpCode.CONCAT, OpCode.GATHER, + OpCode.SLICE, OpCode.UNSQUEEZE, OpCode.EXPAND, +} + class IRInterpreter: def __init__(self, program: Program): @@ -97,7 +187,38 @@ def run( initializers: Mapping[str, np.ndarray] | None = None, function_name: str | None = None, max_steps: int = 1_000_000, + collect_memory_stats: bool = False, + copy_initializers: bool = True, + memory_mode: str = "retain_all", + observer: Callable[[str, np.ndarray], None] | None = None, + fp32_mode: str = "native", ) -> ExecutionResult: + """Run verified IR with strict bindings. + + Parameters and bound initializers require their exact declared static + shape, including ``()`` for scalars. Builder intermediate values may + retain the default ``()`` until the operation infers its result shape. + An optional ``Function.returns`` signature is also exact; omitting it + preserves the actual returned tensor's inferred shape and dtype. + + ``copy_initializers=False`` borrows only read-only NumPy arrays. The + caller must not mutate their backing storage for the duration of this + synchronous call. ``memory_mode='last_use'`` releases bindings after + their final instruction use; it does not skip dead instructions or + their errors. Both options require a pure, single-block SSA function + ending in one RETURN; unsupported control flow fails explicitly. + + ``observer`` receives each numeric destination and a temporary + read-only view after validation, before reclamation. It must not mutate + backing storage or retain arrays if bounded memory is desired. Save + selected checkpoints synchronously; observer allocations are excluded + from interpreter accounting. Inputs and results are always copied. + + ``fp32_mode='reference'`` selects a versioned NumPy FP32 evaluation + order and polynomial profile for numerical comparisons. It does not + call ORT, change graph optimization, or change non-FP32 kernels. The + default ``native`` retains the original NumPy evaluation policy. + """ if not isinstance(inputs, Mapping) or ( initializers is not None and not isinstance(initializers, Mapping) ): @@ -112,6 +233,16 @@ def run( raise IRExecutionError( "InvalidOptions", "max_steps must be positive integer" ) + if not isinstance(collect_memory_stats, bool): + raise IRExecutionError("InvalidOptions", "collect_memory_stats must be boolean") + if not isinstance(copy_initializers, bool): + raise IRExecutionError("InvalidOptions", "copy_initializers must be boolean") + if not isinstance(memory_mode, str) or memory_mode not in ("retain_all", "last_use"): + raise IRExecutionError("InvalidOptions", "memory_mode must be retain_all or last_use") + if observer is not None and not callable(observer): + raise IRExecutionError("InvalidOptions", "observer must be callable or None") + if not isinstance(fp32_mode, str) or fp32_mode not in ("native", "reference"): + raise IRExecutionError("InvalidOptions", "fp32_mode must be native or reference") passed, issues = verify_ir(self.program, stage="before-execution") if not passed: issue = next(i for i in issues if i.level.value == "error") @@ -159,6 +290,20 @@ def error(code, message, position=None, instr=None, value_name=None): value_name=value_name, ) + last_uses = None + if memory_mode == "last_use" or not copy_initializers: + if len(function.blocks) != 1 or function.blocks[0].phi_nodes: + raise error("InvalidOptions", "memory optimization requires a pure single-block function") + instructions = function.blocks[0].instructions + if (not instructions or instructions[-1].opcode != OpCode.RETURN + or any(inst.opcode not in _PURE_OPS for inst in instructions[:-1])): + raise error("InvalidOptions", "memory optimization requires pure instructions and one final RETURN") + if memory_mode == "last_use": + last_uses = {} + for index, inst in enumerate(instructions): + for operand in inst.operands: + last_uses[operand.name] = index + for block in function.blocks: for index, instr in enumerate(block.instructions): position = IRSourcePosition(block.name, index) @@ -199,6 +344,29 @@ def error(code, message, position=None, instr=None, value_name=None): f"invalid initializer bindings: {sorted(extra | (set(initializers) & set(params)))}", ) + def checked_shape(value, actual_shape, position=None, instr=None): + # Boundary declarations describe actual tensors: () is a scalar, + # not an unspecified shape. Builder intermediates are inferred + # separately and do not enter this check unless explicitly shaped. + if not isinstance(value.shape, (tuple, list)) or any( + isinstance(d, (bool, np.bool_)) + or not isinstance(d, (int, np.integer)) or d < 0 + for d in value.shape + ): + raise error( + "ShapeError", "tensor boundary requires a static nonnegative shape", + position, instr, + value_name=value.name, + ) + shape = tuple(int(d) for d in value.shape) + if actual_shape != shape: + raise error( + "ShapeError", + f"expected {shape}, got {actual_shape}", + position, instr, + value_name=value.name, + ) + def checked_array(value, data, *, copy=False): if not isinstance(data, np.ndarray): raise error( @@ -210,16 +378,7 @@ def checked_array(value, data, *, copy=False): f"expected {DTYPES[value.dtype]}, got {data.dtype}", value_name=value.name, ) - if ( - value.shape - and all(isinstance(d, int) and d >= 0 for d in value.shape) - and data.shape != value.shape - ): - raise error( - "ShapeError", - f"expected {value.shape}, got {data.shape}", - value_name=value.name, - ) + checked_shape(value, data.shape) if np.issubdtype(data.dtype, np.floating) and ( np.isnan(data).any() or np.isposinf(data).any() ): @@ -241,7 +400,12 @@ def checked_array(value, data, *, copy=False): for name in referenced & set(globals_): value = globals_[name] if name in initializers: - data = checked_array(value, initializers[name], copy=True) + data = checked_array(value, initializers[name], copy=copy_initializers) + if not copy_initializers: + if data.flags.writeable: + raise error("BindingError", "borrowed initializer must be read-only", value_name=name) + data = data.view() + data.setflags(write=False) if value.is_constant: literal = np.asarray(value.const_value, dtype=DTYPES[value.dtype]) if data.shape != () or not np.array_equal(data, literal): @@ -258,6 +422,31 @@ def checked_array(value, data, *, copy=False): "BindingError", "global tensor data missing", value_name=name ) + memory = _MemoryObservation(values, params, globals_) if collect_memory_stats else None + reclaimed_bindings = 0 + + def reclaim(names): + nonlocal reclaimed_bindings + for name in names: + if name in values: + del values[name] + reclaimed_bindings += 1 + + if last_uses is not None: + reclaim([name for name in values if name not in last_uses]) + + def memory_result(returned): + if memory is None: + return None + stats = memory.finish(values, returned) + if memory_mode != "retain_all" or not copy_initializers or observer is not None: + stats.update(memory_mode=memory_mode, copy_initializers=copy_initializers, + reclaimed_bindings=reclaimed_bindings, observer_enabled=observer is not None) + stats["notes"].append( + "Opt-in policy applies: borrowed buffers remain resident if retained by the caller; " + "reclaimed bindings and observer allocations are not process RSS reductions.") + return stats + adapter = IRCFGAdapter(function) cfg = build_cfg(adapter) plan = adapter.execution_plan @@ -326,22 +515,34 @@ def resolve(value): operands = [resolve(v) for v in instr.operands] if op == OpCode.RETURN: if not operands: - return ExecutionResult(None, steps, tuple(issues)) + return ExecutionResult(None, steps, tuple(issues), + memory_result(None)) value = operands[0] if not isinstance(value, np.ndarray): raise OpError("MemoryError", "cannot return memory reference") + reference = instr.operands[0] + inferred_return = isinstance(reference.shape, (tuple, list)) and len(reference.shape) == 0 + if not inferred_return or reference.name in params or reference.name in globals_: + checked_shape(reference, value.shape, position, instr) + if function.returns: + # verify_ir already checks declaration count and dtype. + checked_shape(function.returns[0], value.shape, position, instr) if ( np.issubdtype(value.dtype, np.floating) and not np.isfinite(value).all() ): raise OpError("NumericError", "return value must be finite") - return ExecutionResult(value.copy(), steps, tuple(issues)) + returned = value.copy() + return ExecutionResult(returned, steps, tuple(issues), + memory_result(returned)) if op == OpCode.ALLOCA: size = instr.attrs.get("size", 4) dtype = DTYPES[instr.dest.dtype] values[instr.dest.name] = _MemorySlot( np.empty(size // dtype.itemsize, dtype=dtype) ) + if memory: + memory.observe(values) continue if op in (OpCode.LOAD, OpCode.STORE): slot = operands[0] @@ -377,7 +578,8 @@ def resolve(value): bounds = np.iinfo(np.int32) if total < bounds.min or total > bounds.max: raise OpError("NumericError", "loop counter overflows i32") - result = compute(instr, operands) + result = (compute(instr, operands) if fp32_mode == "native" + else compute(instr, operands, fp32_mode=fp32_mode)) if result.dtype != DTYPES[instr.dest.dtype]: raise OpError( "DTypeError", @@ -393,6 +595,25 @@ def resolve(value): f"result expected {instr.dest.shape}, got {result.shape}", ) values[instr.dest.name] = result + if observer is not None: + observed = result.view() + observed.setflags(write=False) + try: + observer(instr.dest.name, observed) + except Exception as exc: + raise error("ObserverError", str(exc), position, instr) from exc + finally: + del observed + if memory: + memory.observe(values) + if last_uses is not None: + index = position.instruction_index + reclaim({value.name for value in instr.operands + if last_uses.get(value.name) == index} + | ({instr.dest.name} if instr.dest.name not in last_uses else set())) + # Do not accidentally pin reclaimed arrays in loop locals. + operands = [] + result = None except OpError as exc: raise error(exc.code, str(exc), position, instr) from exc except FloatingPointError as exc: diff --git a/scratchv/verification/ir_numpy_ops.py b/scratchv/verification/ir_numpy_ops.py index 92baeab..432ff02 100644 --- a/scratchv/verification/ir_numpy_ops.py +++ b/scratchv/verification/ir_numpy_ops.py @@ -183,12 +183,15 @@ def check_instruction(instr: Instruction): raise OpError("AttributeError", f"{key} must be finite numeric") -def compute(instr, operands): +def compute(instr, operands, *, fp32_mode="native"): + handler = KERNELS[instr.opcode].handler + dtype = DTYPES[instr.dest.dtype] + if fp32_mode == "reference" and dtype == np.dtype("float32"): + from scratchv.verification.fp32_reference import HANDLERS + handler = HANDLERS.get(instr.opcode, handler) with np.errstate(over="raise", divide="raise", invalid="raise", under="ignore"): result = np.asarray( - KERNELS[instr.opcode].handler( - operands, instr.attrs, DTYPES[instr.dest.dtype] - ) + handler(operands, instr.attrs, dtype) ) if np.issubdtype(result.dtype, np.floating) and not np.isfinite(result).all(): raise OpError("NumericError", "operation produced nonfinite output") diff --git a/scripts/ci_scopes.json b/scripts/ci_scopes.json index f83c8f5..000e206 100644 --- a/scripts/ci_scopes.json +++ b/scripts/ci_scopes.json @@ -28,7 +28,39 @@ "tests/test_w2_*.py", "tests/test_numeric_metrics.py", "tests/test_numeric_summary.py", - ".github/workflows/llm-deploy.yml" + ".github/workflows/llm-deploy.yml", + "scripts/summarize_w3_ci.py", + "tests/test_w3_actions_summary.py", + "tests/test_w3_workflows.py", + ".github/workflows/w3-*.yml" ], - "w4": [] + "w4": [ + ".github/workflows/w4-full-numeric.yml", + ".github/workflows/w4-nightly.yml", + "scratchv/**", + "export_qwen3_onnx.py", + "probes/w1_qwen3_export/**", + "probes/w2_runtime/**", + "probes/w3_common.py", + "probes/w3_qwen3_full/**", + "probes/w3_layer_diff/**", + "probes/w4_qwen3_full/**", + "scripts/summarize_w4_ci.py", + "tests/test_w1_qwen3_export.py", + "tests/test_llm_inputs.py", + "tests/test_w2_runtime_metadata.py", + "tests/test_licm_safety.py", + "tests/test_optimizer_numeric_semantics.py", + "tests/test_w3_full_gate.py", + "tests/test_w3_atomic_text.py", + "tests/test_weight_bundle.py", + "tests/test_tensor_c_external.py", + "tests/test_tensor_boundary_contracts.py", + "tests/test_tensor_return_contract.py", + "tests/test_w4*.py", + "tests/test_riscv_external*.py", + "tests/test_riscv_process_cleanup.py", + "tests/test_riscv_weight_transport.py", + "requirements/qwen3-small-probe.txt" + ] } diff --git a/scripts/collect_w3_ci_evidence.py b/scripts/collect_w3_ci_evidence.py new file mode 100644 index 0000000..8a65315 --- /dev/null +++ b/scripts/collect_w3_ci_evidence.py @@ -0,0 +1,172 @@ +"""Retain bounded diagnostic evidence, with an explicit inventory of omissions.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat + + +MIB = 1024**2 +MAX_FILE_BYTES = 256 * MIB +MAX_TOTAL_BYTES = 512 * MIB +MAX_REPORT_BYTES = 32 * MIB +FAILURE_SUFFIXES = {".npz", ".npy", ".onnx", ".log", ".stdout", ".stderr"} + + +def collect(source, destination, *, max_file_bytes=MAX_FILE_BYTES, max_total_bytes=MAX_TOTAL_BYTES): + """Copy selected evidence; omissions remain visible even after raw artifacts expire. + + This inventories copied bytes, but does not rerun any numerical gate. The CLI + exits unsuccessfully when selection is incomplete; other safe files survive. + """ + for limit in (max_file_bytes, max_total_bytes): + if type(limit) is not int or limit <= 0: + raise ValueError("Evidence byte limits must be positive integers") + source, destination = Path(source).resolve(), Path(destination).resolve() + if destination.is_relative_to(source): + raise ValueError("Evidence destination must be outside its source directory") + destination.mkdir(parents=True, exist_ok=False) + selected, omitted = {}, [] + + def omit(path, reason, **details): + omitted.append({"path": str(path.relative_to(source)), "reason": reason, **details}) + + def safe(path): + if not path.resolve().is_relative_to(source): + raise ValueError("Evidence path escapes source directory") + + def scan(folder, suffixes, names=(), *, required=False): + try: + safe(folder) + if not folder.exists(): + if required: + omit(folder, "Missing case evidence directory") + return + # Never follow linked directories while searching for failure evidence. + def walk_error(error): + omit(folder, f"Cannot scan evidence: {error}") + + for root, dirs, files in os.walk(folder, followlinks=False, onerror=walk_error): + root = Path(root) + for name in list(dirs): + child = root / name + if child.is_symlink() or not child.resolve().is_relative_to(source): + dirs.remove(name) + omit(child, "Linked or external evidence directory") + dirs.sort() + for name in sorted(files): + path = root / name + if path.suffix in suffixes or name in names: + selected[path] = None + except (OSError, ValueError) as exc: + omit(folder, str(exc)) + + # First priority: the exact trace selected by the independent layer-diff gate. + for name in ("trace_schema.json", "traces/short_17/ir_diagnostic_all.npz", + "traces/short_17/ort_diagnostic.npz"): + selected[source / "medium" / name] = None + # Original target logs permit inspection of UART/protocol failures. + scan(source / "attention", {".log", ".stdout", ".stderr"}, {"uart.bin"}) + for gate in ("medium", "subgraphs", "attention"): + folder = source / gate + report_path = folder / "report.json" + if not report_path.exists(): + # Interrupted runs may have raw evidence but no final report. + omit(report_path, "Missing gate report; collected available diagnostics") + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + continue + try: + safe(report_path) + info = report_path.stat() + if not stat.S_ISREG(info.st_mode): + raise ValueError("Report is not a regular file") + if info.st_size > MAX_REPORT_BYTES: + raise ValueError("Report exceeds size limit") + report = json.loads(report_path.read_text(encoding="utf-8")) + if not isinstance(report, dict): + raise ValueError("Report must be an object") + if report.get("passed") is True: + continue + rows = report.get("cases", []) + if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): + raise ValueError("Report cases must be a list of objects") + failed = [row for row in rows if row.get("passed") is not True] + # An invariant can fail after all individual comparisons have passed. + for row in failed or rows: + name = row.get("name") + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name): + raise ValueError("Invalid case evidence directory name") + case_folder = folder / ("traces" if gate == "medium" else "") / name + if not case_folder.resolve().is_relative_to(folder.resolve()): + raise ValueError("Case evidence escapes its gate directory") + scan(case_folder, FAILURE_SUFFIXES, {"uart.bin"}, required=True) + if not rows: + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + except (OSError, ValueError) as exc: + omit(report_path, f"Cannot select cases from report: {exc}") + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + + records, total = [], 0 + for path in selected: + relative = path.relative_to(source) + target = destination / relative + try: + safe(path) + info = path.stat() + if not stat.S_ISREG(info.st_mode): + raise ValueError("Evidence is not a regular file") + if info.st_size > max_file_bytes: + raise ValueError("Evidence exceeds per-file byte limit") + if info.st_size > max_total_bytes - total: + raise ValueError("Evidence exceeds remaining total byte limit") + target.parent.mkdir(parents=True, exist_ok=True) + digest, copied = hashlib.sha256(), 0 + with path.open("rb") as src, target.open("xb") as dst: + while chunk := src.read(MIB): + copied += len(chunk) + if copied > min(max_file_bytes, max_total_bytes - total): + raise ValueError("Evidence grew beyond byte limit during collection") + dst.write(chunk) + digest.update(chunk) + total += copied + records.append({"path": str(relative), "bytes": copied, "sha256": digest.hexdigest()}) + except (OSError, ValueError) as exc: + target.unlink(missing_ok=True) + omit(path, str(exc)) + + files = [record["path"] for record in records] + manifest = { + "schema_version": 1, + "scope": "Selected preparation trace and failure diagnostics only; not complete W3 raw evidence.", + "independent_full_revalidation": False, + "validation": "Copy inventory and SHA256 only; this collector does not perform numerical validation.", + "selection_complete": not omitted, + "limits": {"max_file_bytes": max_file_bytes, "max_total_bytes": max_total_bytes}, + "retained_bytes": total, + "files": records, + "omitted": omitted, + } + (destination / "retained-files.json").write_text(json.dumps(files, indent=2) + "\n", encoding="utf-8") + (destination / "retained-evidence.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return files + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--destination", type=Path, required=True) + args = parser.parse_args(argv) + collect(args.source, args.destination) + manifest = json.loads((args.destination / "retained-evidence.json").read_text(encoding="utf-8")) + if not manifest["selection_complete"]: + print(f"Evidence selection incomplete: {len(manifest['omitted'])} omissions; see retained-evidence.json") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/package_w3_repro.py b/scripts/package_w3_repro.py new file mode 100644 index 0000000..3573c65 --- /dev/null +++ b/scripts/package_w3_repro.py @@ -0,0 +1,216 @@ +"""Create/verify a bounded, content-addressed W3 source handoff without committing. + +This preserves source bytes, not Git history, model assets or execution results. +The manifest is an integrity inventory, not a signature or numerical acceptance. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import subprocess +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = "W3_SOURCE_MANIFEST.json" +SCHEMA_VERSION = 2 +ROOT_FILES = {"pyproject.toml", "README.md", "LICENSE", "THIRD_PARTY_NOTICES.txt", + ".gitignore", ".gitattributes"} +TREES = {"scratchv", "scratchv_dag", "benchmarks", "examples", "probes", "scripts", "requirements", + "tests", "LICENSES"} +EXCLUDED_PARTS = {".git", "__pycache__", "output", "out", "build", "dist", ".venv", + "venv", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +EXCLUDED_SUFFIXES = {".onnx", ".data", ".safetensors", ".npy", ".npz", ".pyc", + ".elf", ".bin", ".zip", ".pt", ".pth", ".exe", ".dll"} +MAX_FILE_BYTES = 8 * 1024**2 +MAX_TOTAL_BYTES = 64 * 1024**2 +REQUIRED = {"pyproject.toml", "LICENSE", "scripts/package_w3_repro.py", + "probes/w3_qwen3_full/run.py", "requirements/qwen3-small-probe.txt", + "examples/run_ir_interpreter.py", + "docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md", + "docs/llm-deploy-v1.0/W3/README.md"} +REPRODUCTION = {"platform": "linux", "shell": "bash", + "guide": "docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md", + "stage_guide": "docs/llm-deploy-v1.0/W3/README.md"} + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def safe_name(name): + if not isinstance(name, str): + raise ValueError("Unsafe manifest path") + path = PurePosixPath(name) + if (not name or "\\" in name or ":" in name + or path.is_absolute() or any(x in ("", ".", "..") for x in name.split("/"))): + raise ValueError("Unsafe manifest path") + return path + + +def included(name): + path = safe_name(name) + if any(x in EXCLUDED_PARTS or x.startswith(".env") for x in path.parts): + return False + if path.suffix.lower() in EXCLUDED_SUFFIXES: + return False + return (name in ROOT_FILES or path.parts[0] in TREES + or name.startswith("docs/llm-deploy-v1.0/") + or (name.startswith(".github/workflows/w3-") and path.suffix in (".yml", ".yaml"))) + + +def git(root, *args): + result = subprocess.run(["git", "-C", str(root), *args], check=True, + capture_output=True, timeout=30) + return result.stdout + + +def candidates(root): + raw = git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard") + return sorted({name for value in raw.split(b"\0") if value + if included(name := value.decode("utf-8")) + and ((root/name).exists() or (root/name).is_symlink())}) + + +def read_source(root, name): + path = root / name + # Reject links including links in parents, even when pointing inside the tree. + for part in (path, *path.parents): + if part == root: + break + if part.is_symlink(): + raise ValueError(f"Linked source is not supported: {name}") + if not path.resolve().is_relative_to(root) or not path.is_file(): + raise ValueError(f"Missing or external source: {name}") + if path.stat().st_size > MAX_FILE_BYTES: + raise ValueError(f"Source exceeds file limit: {name}") + with path.open("rb") as stream: + value = stream.read(MAX_FILE_BYTES + 1) + if len(value) > MAX_FILE_BYTES: + raise ValueError(f"Source grew beyond file limit: {name}") + return value + + +def snapshot_id(files): + return digest(json.dumps(files, ensure_ascii=False, sort_keys=True, + separators=(",", ":")).encode("utf-8")) + + +def create(root, output): + root, output = Path(root).resolve(), Path(output).resolve() + checksum = output.with_suffix(output.suffix + ".sha256") + if output.exists() or checksum.exists(): + raise ValueError("Archive and checksum must use fresh paths") + payload, total = {}, 0 + for name in candidates(root): + value = read_source(root, name) + total += len(value) + if total > MAX_TOTAL_BYTES: + raise ValueError("Source snapshot exceeds total size limit") + payload[name] = value + for required in REQUIRED: + if required not in payload: + raise ValueError(f"Missing required source: {required}") + files = [{"path": name, "bytes": len(value), "sha256": digest(value)} + for name, value in payload.items()] + manifest = {"schema_version": SCHEMA_VERSION, "snapshot_id": snapshot_id(files), + "base_commit": git(root, "rev-parse", "HEAD").decode().strip(), + "scope": "Selected current working-tree source bytes; includes uncommitted W3 files.", + "excludes": "Git history, model weights/binaries, environments and run evidence.", + "reproduction": dict(REPRODUCTION), + "is_commit": False, "numerical_acceptance": False, "files": files} + if candidates(root) != list(payload): + raise ValueError("Source inventory changed during packaging; retry after edits finish") + for name, value in payload.items(): + if digest(read_source(root, name)) != digest(value): + raise ValueError(f"Source changed during packaging: {name}") + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_DEFLATED) as archive: + for name, value in [*payload.items(), (MANIFEST, json.dumps( + manifest, ensure_ascii=False, indent=2).encode("utf-8") + b"\n")]: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, value) + archive_hash = digest(output.read_bytes()) + with checksum.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(f"{archive_hash} {output.name}\n") + return {"snapshot_id": manifest["snapshot_id"], "files": len(files), + "source_bytes": total, "archive": str(output), "archive_sha256": archive_hash, + "base_commit": manifest["base_commit"], "is_commit": False} + + +def verify(root, expected_snapshot_id=None): + root = Path(root).resolve() + manifest = json.loads(read_source(root, MANIFEST)) + if (not isinstance(manifest, dict) or manifest.get("schema_version") != SCHEMA_VERSION + or not isinstance(manifest.get("files"), list)): + raise ValueError("Unsupported source manifest") + if manifest.get("reproduction") != REPRODUCTION: + raise ValueError("Source manifest must declare the Linux reproduction contract") + files = manifest["files"] + if not files: + raise ValueError("Empty source manifest") + actual_id = snapshot_id(files) + if actual_id != manifest.get("snapshot_id") or (expected_snapshot_id is not None + and actual_id != expected_snapshot_id): + raise ValueError("Snapshot identity mismatch") + seen, total = set(), 0 + for entry in files: + if not isinstance(entry, dict) or set(entry) != {"path", "bytes", "sha256"}: + raise ValueError("Invalid source file descriptor") + name = entry["path"] + if not isinstance(name, str) or not included(name) or name in seen: + raise ValueError("Unexpected or duplicate source path") + seen.add(name) + value = read_source(root, name) + total += len(value) + if total > MAX_TOTAL_BYTES or type(entry["bytes"]) is not int: + raise ValueError("Invalid source byte inventory") + if len(value) != entry["bytes"] or digest(value) != entry["sha256"]: + raise ValueError(f"Source bytes differ: {name}") + if not REQUIRED.issubset(seen): + raise ValueError("Manifest is missing required source files") + # Output directories and external models are allowed; added source is not. + for folder in [*(root/x for x in TREES), root/"docs/llm-deploy-v1.0", root/".github/workflows"]: + if folder.exists(): + for directory, dirs, names in os.walk(folder, followlinks=False): + dirs[:] = [name for name in dirs if name not in EXCLUDED_PARTS] + if any((Path(directory)/name).is_symlink() for name in dirs): + raise ValueError("Linked source directory is not supported") + for filename in names: + path = Path(directory) / filename + name = path.relative_to(root).as_posix() + if included(name) and name not in seen: + raise ValueError(f"Unlisted source file: {name}") + for name in ROOT_FILES: + if (root/name).exists() and name not in seen: + raise ValueError(f"Unlisted root source: {name}") + return {"passed": True, "snapshot_id": actual_id, "files": len(seen), + "source_bytes": total, "claim": "Source integrity only; no numerical or team acceptance."} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + make = sub.add_parser("create") + make.add_argument("--source-root", type=Path, default=ROOT) + make.add_argument("--output", type=Path, required=True) + check = sub.add_parser("verify") + check.add_argument("--root", type=Path, default=ROOT) + check.add_argument("--expected-snapshot-id") + args = parser.parse_args(argv) + try: + result = create(args.source_root, args.output) if args.command == "create" else verify( + args.root, args.expected_snapshot_id) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + parser.exit(1, f"W3 source snapshot failed: {exc}\n") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_w3_preparation.py b/scripts/run_w3_preparation.py new file mode 100644 index 0000000..9e979da --- /dev/null +++ b/scripts/run_w3_preparation.py @@ -0,0 +1,369 @@ +"""Run the five W3 preparation checks; this is not W3 full-model acceptance.""" +from __future__ import annotations +import argparse +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import time +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0,str(ROOT)) +from probes.w3_common import new_output_dir, source_evidence, sha256_file, write_reports +from probes.w3_qwen3_full.resources import spawn_owned, terminate_process_tree, wait_bounded + +GATES = ("medium","subgraphs","attention","full-preflight","layer-diff") +IDENTITIES = {"medium":"w3-medium-host","subgraphs":"w3_qwen3_subgraphs", + "attention":"unit:attention-backend","full-preflight":"preflight:w3-full-assets", + "layer-diff":"w3_layer_diff"} +LEVELS = ("none", "basic", "all") +GRAPH_KINDS = ("normal", "diagnostic") +MEDIUM_CASES = (("full_seed_0",256),("full_seed_42",256),("one_token",1), + ("short_17",17),("short_255",255),("changed_future",256),("changed_padding",17)) +MEDIUM_CHECKPOINTS = ("token_embedding",) + tuple( + f"layer_{layer}.{name}" for layer in range(6) for name in + ("input_norm","q_norm","k_norm","v_proj","rope_q","rope_k","attn_probs","attn_context", + "attn_output","attn_residual","post_attention_norm","mlp","residual")) + ("final_norm","logits") +SUBGRAPH_CASES = ("projection_q","projection_k","projection_v","projection_o","rmsnorm_hidden", + "rmsnorm_q","rope_q","rmsnorm_k","rope_k","gqa_full","gqa_padding", + "gqa_changed_padding","gqa_changed_future","swiglu") +ATTENTION_CASES = (("full17",17),("padding17",5),("future17",17), + ("padding_changed17",5),("single17",1),("boundary256",255)) +MEMORY_FIELDS = ("input_logical_bytes","initializer_logical_bytes","peak_live_logical_bytes", + "peak_numpy_storage_bytes","retained_values","retained_logical_bytes", + "retained_numpy_storage_bytes","return_logical_bytes") + +def require(condition, message): + if not condition: + raise ValueError(message) + +def check_numeric(value, atol): + if isinstance(value,dict): + if "passed" in value: + require(value["passed"] is True, "A nested required comparison failed") + for key,item in value.items(): + if key in ("max_abs","max_abs_error") and item is not None: + require(type(item) in (int,float) and math.isfinite(item) and 0 <= item < atol, + f"Numeric error exceeds {atol}") + check_numeric(item,atol) + elif isinstance(value,list): + for item in value: + check_numeric(item,atol) + elif isinstance(value,float): + require(math.isfinite(value),"Nonfinite number in evidence") + + +def checked_keys(value, keys, label): + require(isinstance(value,dict) and set(value)==set(keys),f"Missing/extra {label}") + return value + + +def checked_rows(rows, identities, fields, label): + require(isinstance(rows,list) and all(isinstance(row,dict) for row in rows),f"Missing {label}") + actual = [tuple(row.get(field) for field in fields) for row in rows] + require(len(actual)==len(identities) and set(actual)==set(identities),f"Missing/duplicate {label}") + return rows + + +def checked_tensor(row, atol): + require(isinstance(row,dict) and row.get("passed") is True,"Missing passing tensor comparison") + require(row.get("finite") is True and row.get("shape_matches") is True,"Missing finite/shape proof") + actual_shape = row.get("actual_shape",row.get("shape")) + require(isinstance(actual_shape,list) and actual_shape==row.get("expected_shape"),"Tensor shape mismatch") + require(row.get("actual_dtype",row.get("dtype"))==row.get("expected_dtype")=="float32","Missing FP32 proof") + maximum = row.get("max_abs",row.get("max_abs_error")) + require(type(maximum) in (int,float) and math.isfinite(maximum) and 0 <= maximum < atol,"Missing strict numeric proof") + require(row.get("atol")==atol and row.get("rtol",0)==0,"Wrong comparison tolerance") + require(row.get("empty",False) is False,"Empty tensor comparison") + + +def checked_comparison(comparison, names, atol, *, positions=False, padding=False): + require(isinstance(comparison,dict) and comparison.get("passed") is True,"Missing passing comparison") + rows = comparison.get("checkpoints") + require(isinstance(rows,list) and tuple(row.get("name") for row in rows)==tuple(names), + "Missing/duplicate/reordered comparison checkpoints") + for row in rows: + checked_tensor(row,atol) + if positions: + checked_tensor(row.get("valid_tokens"),atol) + if padding: + checked_tensor(row.get("padding_queries"),atol) + + +def checked_memory(execution): + require(isinstance(execution,dict) and type(execution.get("executed_steps")) is int + and execution["executed_steps"]>0,"Missing IR execution") + memory = execution.get("memory_stats") + require(isinstance(memory,dict),"Missing memory statistics") + require(all(type(memory.get(key)) is int and memory[key]>=0 for key in MEMORY_FIELDS), + "Missing/invalid memory statistic") + require(isinstance(memory.get("scope"),str) and bool(memory["scope"]),"Missing memory scope") + + +def checked_file(folder, relative, digest=None): + require(isinstance(relative,str) and bool(relative),"Missing evidence path") + path = (folder/relative).resolve() + require(path.is_relative_to(folder.resolve()),"Evidence path escapes output") + require(path.is_file(),f"Missing evidence: {relative}") + if digest is not None: + require(isinstance(digest,str) and len(digest)==64 and sha256_file(path)==digest, + f"Evidence hash mismatch: {relative}") + return path + + +def checked_artifact(folder, artifact): + require(isinstance(artifact,dict) and isinstance(artifact.get("sha256"),str),"Missing artifact hash") + return checked_file(folder,artifact.get("path"),artifact["sha256"]) + + +def validate_medium(report,folder): + require(report.get("checkpoint_count")==81 and report.get("coverage_complete") is True,"Incomplete medium coverage") + require(tuple(report.get("checkpoint_metadata",{}))==MEDIUM_CHECKPOINTS,"Wrong medium checkpoints") + cases = checked_rows(report.get("cases"),MEDIUM_CASES,("name","valid_length"),"medium cases") + kinds = (("pytorch","diagnostic"),("ort","normal"),("ort","diagnostic"), + *((f"ir_{level}",kind) for level in LEVELS for kind in GRAPH_KINDS)) + identities = {(name,backend,kind) for name in ("causality","padding_isolation") for backend,kind in kinds} + invariants = checked_rows(report.get("invariants"),identities,("name","backend","graph"),"medium invariants") + trace_paths = {} + for name,_ in MEDIUM_CASES: + trace_paths[f"traces/{name}/reference.npz"] = (name,"pytorch","diagnostic",None) + for kind in GRAPH_KINDS: + trace_paths[f"traces/{name}/ort_{kind}.npz"] = (name,"ort",kind,None) + for level in LEVELS: + trace_paths[f"traces/{name}/ir_{kind}_{level}.npz"] = (name,"ir",kind,level) + traces = checked_rows(report.get("trace_artifacts"),{(name,) for name in trace_paths},("path",),"medium traces") + by_path = {row["path"]:row for row in traces} + for trace in traces: + require(tuple(trace.get(key) for key in ("case","backend","graph","optimization"))==trace_paths[trace["path"]], + "Trace identity does not match its path") + checked_artifact(folder,trace) + for artifact in checked_keys(report.get("onnx"),GRAPH_KINDS,"medium ONNX graphs").values(): + checked_artifact(folder,artifact) + checked_file(folder,"trace_schema.json") + checked_file(folder,"logits_schema.json") + for case in cases: + require(case.get("passed") is True,"Failed medium case") + checked_tensor(case.get("capture_preserves_logits"),1e-5) + attention_names = {(f"layer_{layer}.{suffix}",) for layer in range(6) + for suffix in ("gqa_probabilities","gqa_context","blocked_attention")} + for check in checked_rows(case.get("attention_checks"),attention_names,("name",),"medium attention checks"): + checked_tensor(check,1e-5) + reference = case.get("reference_trace",{}) + require(reference==by_path[f"traces/{case['name']}/reference.npz"],"Missing reference trace binding") + ort = checked_keys(case.get("ort"),GRAPH_KINDS,"medium ORT graphs") + ir = checked_keys(case.get("ir"),LEVELS,"medium IR levels") + for kind in GRAPH_KINDS: + names = MEDIUM_CHECKPOINTS if kind=="diagnostic" else ("logits",) + require(ort[kind].get("passed") is True,"Missing successful medium ORT execution") + checked_comparison(ort[kind].get("pytorch_comparison"),names,1e-5,positions=True,padding=case["valid_length"]<256) + require(ort[kind].get("trace")==by_path[f"traces/{case['name']}/ort_{kind}.npz"],"Missing ORT trace binding") + if kind=="diagnostic": + checked_comparison(ort[kind].get("pack_layout"),names,1e-5) + for level in LEVELS: + execution = checked_keys(ir[level],GRAPH_KINDS,"medium IR graphs")[kind] + require(execution.get("status")=="success" and execution.get("passed") is True,"Missing successful medium execution") + checked_memory(execution) + for comparison in ("pytorch_comparison","ort_comparison"): + checked_comparison(execution.get(comparison),names,1e-5,positions=True,padding=case["valid_length"]<256) + require(execution.get("trace")==by_path[f"traces/{case['name']}/ir_{kind}_{level}.npz"],"Missing IR trace binding") + for invariant in invariants: + checked_comparison(invariant.get("comparison"),MEDIUM_CHECKPOINTS if invariant["graph"]=="diagnostic" else ("logits",),1e-5) + + +def validate_subgraphs(report,folder): + require(report.get("sequence_length")==256 and report.get("coverage_complete") is True,"Not full L256 subgraph set") + require(report.get("levels")==list(LEVELS),"Missing/duplicate subgraph levels") + rows = checked_rows(report.get("cases"),{(name,) for name in SUBGRAPH_CASES},("name",),"subgraph cases") + base = {"ort_pack_layout","torch_vs_ort","ordinary_ort_vs_torch","ordinary_ort_vs_diagnostic"} + keys = base | {f"{prefix}_{level}_vs_{reference}" for level in LEVELS + for prefix,references in (("ir",("ort","torch")),("ordinary_ir",("ort","torch","diagnostic"))) + for reference in references} + for row in rows: + name = row["name"] + require(row.get("passed") is True,"Failed subgraph case") + names = ("probabilities","y") if name.startswith("gqa_") else (("gate","up","hidden","y") if name=="swiglu" else ("y",)) + for key,comparison in checked_keys(row.get("comparisons"),keys,"subgraph comparisons").items(): + if key.startswith("ordinary_"): + checked_tensor(comparison,1e-4) + else: + checked_comparison(comparison,names,1e-4) + for executions in checked_keys(row.get("executions"),LEVELS,"subgraph IR levels").values(): + for execution in checked_keys(executions,("ordinary","diagnostic"),"subgraph IR graphs").values(): + checked_memory(execution) + artifacts = row.get("artifacts",{}) + require(artifacts.get("directory")==name,"Wrong subgraph artifact directory") + for filename,key in (("model.onnx","model_sha256"),("diagnostics.onnx","diagnostic_sha256")): + checked_artifact(folder,{"path":f"{name}/{filename}","sha256":artifacts.get(key)}) + for filename in ("inputs.npz","torch.npz","ort.npz","ordinary_ort.npy","checkpoints.json", + *(f"ir_{level}.npz" for level in LEVELS),*(f"ordinary_ir_{level}.npy" for level in LEVELS)): + checked_file(folder,f"{name}/{filename}") + if name.startswith("gqa_"): + for checks in checked_keys(row.get("semantic_checks"),("torch","ort","ir_none","ir_basic","ir_all"),"GQA backends").values(): + for comparison in checked_keys(checks,("blocked_probability","probability_row_sum"),"GQA semantics").values(): + checked_tensor(comparison,1e-4) + identities = {(name,backend) for name in ("padding_isolation","causality") + for backend in ("torch","ort","ir_none","ir_basic","ir_all")} + for invariant in checked_rows(report.get("invariants"),identities,("name","backend"),"subgraph invariants"): + require(invariant.get("query_prefix")== (256 if invariant["name"]=="padding_isolation" else 64),"Wrong invariant prefix") + checked_comparison(invariant,("probabilities","y"),1e-4) + + +def validate_attention(report,folder): + require(report.get("planned_executions")==report.get("passed_executions")==12,"Missing QEMU runs") + rows = checked_rows(report.get("cases"),ATTENTION_CASES,("name","valid_length"),"Attention cases") + for row in rows: + name = row["name"] + require(row.get("passed") is True,"Failed Attention case") + checked_tensor(row.get("ort_vs_numpy"),1e-4) + for filename,key in (("model.onnx","model_sha256"),("inputs.npz","input_sha256")): + checked_artifact(folder,{"path":f"{name}/{filename}","sha256":row.get(key)}) + for execution in checked_rows(row.get("executions"),{("none",),("all",)},("optimization",),"Attention executions"): + require(execution.get("status")=="success" and execution.get("passed") is True,"Missing successful target execution") + for key in ("ir_vs_ort","ir_vs_numpy","qemu_vs_ort","qemu_vs_numpy"): + checked_tensor(execution.get(key),1e-4) + elapsed = execution.get("qemu_process_wall_seconds") + require(type(elapsed) in (int,float) and math.isfinite(elapsed) and elapsed>=0,"Missing QEMU wall time") + for key in ("command","compile_command"): + command = execution.get(key) + require(isinstance(command,list) and len(command)>1 and all(isinstance(x,str) and x for x in command),"Missing target command") + level = execution["optimization"] + checked_artifact(folder,{"path":f"{name}/{level}/build/model.elf","sha256":execution.get("elf_sha256")}) + for filename in ("qemu.npy","ir.npy"): + checked_file(folder,f"{name}/{level}/{filename}") + identities = {(name,reference,prefix,backend) for name,reference,prefix in + (("future17","full17",5),("padding_changed17","padding17",17)) + for backend in ("ort","ir_none","ir_all","qemu_none","qemu_all")} + for invariant in checked_rows(report.get("invariants"),identities,("case","reference","prefix","backend"),"Attention invariants"): + checked_tensor(invariant.get("comparison"),1e-4) + +def validate_report(name, folder, sources): + for filename in ("report.json","report.md","report.html"): + require((folder/filename).is_file(),f"Missing {filename}") + report = json.loads((folder/"report.json").read_text(encoding="utf-8")) + require(report.get("gate")==IDENTITIES[name],"Wrong gate identity") + require(report.get("status")=="PASS" and report.get("passed") is True,"Gate not PASS") + fingerprints = report.get("source_sha256") or report.get("provenance",{}).get("source_sha256") + require(fingerprints == sources,"Source fingerprints differ from this invocation") + check_numeric(report,1e-5 if name in ("medium","layer-diff") else 1e-4) + if name == "medium": + validate_medium(report,folder) + elif name == "subgraphs": + validate_subgraphs(report,folder) + elif name == "attention": + validate_attention(report,folder) + elif name == "full-preflight": + require(report.get("full_ir_executed") is False and report.get("w3_exit_accepted") is False, + "Preflight must not claim complete-model numerical acceptance") + require(report.get("node_count")==7847 and report.get("weights_file_bytes")==2384201728,"Unexpected fixed full assets") + else: + coverage = report.get("coverage",{}) + require(coverage.get("complete") is True and coverage.get("compared_checkpoints")==81,"Incomplete layer comparison") + return {"report_sha256":sha256_file(folder/"report.json"),"gate":report["gate"], + "child_seconds":report.get("seconds",report.get("elapsed_seconds")), + "process_peak_rss_bytes":report.get("process_peak_rss_bytes")} + + +def wait_child(process,timeout,row): + """Bound the child and also reclaim its owned process tree on cancellation.""" + if getattr(process,"_scratchv_job",None) is not None or getattr(process,"_scratchv_owned_group",False): + try: + # Share the full runner's ownership protocol, including children + # surviving their launcher. Preparation has no memory threshold; + # the added samples only describe the owned subprocess tree. + return wait_bounded(process,timeout=timeout,max_memory_bytes=sys.maxsize,row=row) + except TimeoutError: + raise subprocess.TimeoutExpired(process.args,timeout) from None + try: + return process.wait(timeout=timeout) + except BaseException: + if process.poll() is None: + try: + terminate_process_tree(process) + except (OSError,subprocess.SubprocessError) as cleanup: + row["cleanup_error"] = f"{type(cleanup).__name__}: {cleanup}" + row["returncode"] = process.returncode + raise + +def build_commands(args, out): + # Keep the selected venv symlink: resolving it runs the base interpreter. + python = [str(Path(args.python).absolute()),"-B","-X","utf8"] + tools = [] + if args.cc: + tools += ["--cc",args.cc] + if args.qemu: + tools += ["--qemu",args.qemu] + medium = out/"medium" + return { + "medium":python+["probes/w3_qwen3_medium/run.py","--output-dir",str(medium)], + "subgraphs":python+["probes/w3_qwen3_subgraphs/run.py","--source-dir",str(args.source_dir.resolve()), + "--output-dir",str(out/"subgraphs")], + "attention":python+["probes/w3_attention/run.py","--output-dir",str(out/"attention"), + "--timeout",str(args.qemu_timeout),*tools], + "full-preflight":python+["probes/w3_full_preflight/run.py","--model-dir",str(args.model_dir.resolve()), + "--output-dir",str(out/"full-preflight")], + "layer-diff":python+["probes/w3_layer_diff/run.py", + "--actual",str(medium/"traces/short_17/ir_diagnostic_all.npz"), + "--reference",str(medium/"traces/short_17/ort_diagnostic.npz"), + "--schema",str(medium/"trace_schema.json"),"--out",str(out/"layer-diff")], + } + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--python",default=sys.executable) + parser.add_argument("--source-dir",type=Path,required=True) + parser.add_argument("--model-dir",type=Path,required=True) + parser.add_argument("--output-dir",type=Path,required=True) + parser.add_argument("--cc") + parser.add_argument("--qemu") + parser.add_argument("--gate-timeout",type=float,default=1800) + parser.add_argument("--qemu-timeout",type=float,default=180) + args = parser.parse_args(argv) + if any(not math.isfinite(x) or x<=0 for x in (args.gate_timeout,args.qemu_timeout)): + parser.error("Timeouts must be finite and positive") + out = new_output_dir(args.output_dir) + (out/"logs").mkdir() + started = time.perf_counter() + report = {"gate":"preparation:w3","passed":False,"status":"FAIL","gates":[], + "w3_exit_accepted":False,"full_ir_executed":False, + "scope":"Five local preparation checks only; full IR, team sign-off and Nightly remain separate"} + try: + report.update(source_evidence()) + commands = build_commands(args,out) + env = dict(os.environ,PYTHONIOENCODING="utf-8",OMP_NUM_THREADS="1",OPENBLAS_NUM_THREADS="1",MKL_NUM_THREADS="1") + for name in GATES: + row = {"name":name,"passed":False,"status":"FAIL","command":commands[name]} + report["gates"].append(row) + tick = time.perf_counter() + try: + if name=="layer-diff": + require(report["gates"][0]["passed"],"Layer-diff blocked by failed medium evidence") + with (out/"logs"/(name+".log")).open("w",encoding="utf-8") as stream: + options = {"creationflags":subprocess.CREATE_NEW_PROCESS_GROUP} if os.name=="nt" else {"start_new_session":True} + process = spawn_owned(commands[name],cwd=ROOT,stdout=stream,stderr=subprocess.STDOUT,env=env,**options) + try: + row["returncode"] = wait_child(process,args.gate_timeout,row) + except subprocess.TimeoutExpired: + raise TimeoutError(f"{name} exceeded {args.gate_timeout} seconds") from None + require(row["returncode"]==0,f"{name} exited {row['returncode']}; see logs/{name}.log") + row["validation"] = validate_report(name,out/name,report["source_sha256"]) + row.update(passed=True,status="PASS") + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + row["elapsed_seconds"] = time.perf_counter()-tick + print(f"[{name}] {row['status']}: {row.get('error','verified')}",flush=True) + require(source_evidence()["source_sha256"]==report["source_sha256"],"Source changed during execution") + report["passed"] = len(report["gates"])==len(GATES) and all(r["passed"] for r in report["gates"]) + except KeyboardInterrupt: + report.update(passed=False,interrupted=True,error="KeyboardInterrupt: preparation cancelled") + if report["gates"]: + report["gates"][-1].update(passed=False,status="FAIL",interrupted=True) + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + report.update(status="PASS" if report["passed"] else "FAIL",elapsed_seconds=time.perf_counter()-started) + write_reports(out,report) + return 130 if report.get("interrupted") else (0 if report["passed"] else 1) + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/scripts/summarize_w3_ci.py b/scripts/summarize_w3_ci.py new file mode 100644 index 0000000..1271a89 --- /dev/null +++ b/scripts/summarize_w3_ci.py @@ -0,0 +1,199 @@ +"""Publish bounded W3 numerical reports to GitHub Actions without changing gates.""" +from __future__ import annotations + +import argparse +from html import escape +import json +import math +import os +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.numeric_summary import compact_metric_table +from probes.w3_summary import evidence_index_views, numerical_summary + +# The medium report currently exceeds 12 MiB because it retains checkpoints. +# Bound input allocation separately from the much smaller Actions page budget. +MAX_REPORT_BYTES = 64 * 1024 * 1024 +MAX_SUMMARY_BYTES = 64 * 1024 + +GATES = ( + ("medium", "w3-medium-host", "6-layer random Qwen3 / host IR"), + ("subgraphs", "w3_qwen3_subgraphs", "Pretrained layer-0 subgraphs / host IR"), + ("attention", "unit:attention-backend", "Small Attention / RISC-V QEMU"), + ("full-preflight", "preflight:w3-full-assets", "Full assets / static checks only"), + ("layer-diff", "w3_layer_diff", "Saved medium-model checkpoints only"), +) + + +def cell(value, limit=180): + text = " ".join(str(value).split()) + if len(text) > limit: + text = text[:limit] + "…" + return escape(text).replace("|", "|") + + +def load_report(path, gate): + try: + with path.open("rb") as stream: + payload = stream.read(MAX_REPORT_BYTES + 1) + if len(payload) > MAX_REPORT_BYTES: + return None, "INVALID REPORT (size limit)" + report = json.loads(payload.decode("utf-8")) + except FileNotFoundError: + return None, "MISSING REPORT" + except (OSError, ValueError, RecursionError): + return None, "INVALID REPORT" + if not isinstance(report, dict) or report.get("gate") != gate: + return None, "INVALID REPORT (gate identity)" + # Fail closed on malformed structures before calling the shared renderer. + for key in ("cases", "gates", "invariants"): + rows = report.get(key, []) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + return None, "INVALID REPORT (rows)" + return report, "" + + +def status(report): + if report.get("status") == "PARTIAL": + return "PARTIAL (not acceptance)" + if report.get("status") == "PASS" and report.get("passed") is True: + if report.get("gate") == "numeric:ir-full-qwen3" and report.get("coverage_complete") is not True: + return "INCOMPLETE (not acceptance)" + return "PASS" + return "FAIL" if report.get("passed") is False else "UNCONFIRMED" + + +def seconds(report): + value = report.get("elapsed_seconds", report.get("seconds")) + if type(value) in (int, float) and math.isfinite(value) and value >= 0: + return f"{value:.3f} s" + return "not recorded" + + +def coverage(report): + gate = report.get("gate") + cases = report.get("cases", []) + if gate == "w3_layer_diff": + values = report.get("coverage", {}) + return f"{values.get('compared_checkpoints', '?')}/{values.get('total_checkpoints', '?')} checkpoints" + if gate == "preflight:w3-full-assets": + return "no model execution" + if gate == "unit:attention-backend": + count = sum(len(row.get("executions", [])) for row in cases) + return f"{len(cases)} cases / {count} target executions recorded" + required = report.get("required_cases") + expected = len(required) if isinstance(required, list) else {"w3-medium-host": 7, "w3_qwen3_subgraphs": 14}.get(gate, "?") + return f"{len(cases)}/{expected} cases recorded" + + +def details(report, title): + """Reuse bounded semantic selectors, never dump arbitrary report dictionaries.""" + description, comparisons, notes = numerical_summary(report) + index, _ = evidence_index_views(report) + text = f" \n\n" + return text + + +def render(kind, directory, outcome): + title = "W3 preparation" if kind == "preparation" else "W3 complete IR numeric" + artifact = "w3-preparation-reports" if kind == "preparation" else "w3-full-numeric-reports" + evidence = (f"Artifacts: **{artifact}** (`report.json`, `report.md`, `report.html`). " + "Raw arrays are separate where retained. This summary does not replace independent reproduction or team acceptance.\n") + lines = [f"## {title}\n\n"] + if outcome in ("skipped", "", "cancelled"): + label = "CANCELLED" if outcome == "cancelled" else "NOT RUN" + lines.append(f"**{label}** — probe step did not complete; no numerical acceptance claimed.\n\n") + lines.append("Existing files, if any, are not used as results for this step.\n") + return "".join(lines) + evidence + identity = "preparation:w3" if kind == "preparation" else "numeric:ir-full-qwen3" + report, problem = load_report(directory / "report.json", identity) + if report is None: + lines.append(f"**{problem}** — step outcome: **{cell(outcome)}**. No acceptance claimed; inspect step logs.\n") + return "".join(lines) + evidence + conclusion = status(report) + if outcome != "success" and conclusion == "PASS": + conclusion = "FAIL (step did not succeed)" + lines.append(f"**{conclusion}** · step: {cell(outcome)} · pipeline: {seconds(report)}\n\n") + comparisons, folded = [], [] + try: + if kind == "preparation": + lines.append("Preparation only; full 28-layer IR numeric and full-model QEMU are **NOT RUN by this job**.\n\n") + lines.append("| Check / scope | Result | Recorded coverage | Pipeline time |\n| --- | --- | --- | --- |\n") + for name, identity, scope in GATES: + rows = [row for row in report.get("gates", []) if row.get("name") == name] + row = rows[0] if len(rows) == 1 else None + child, problem = load_report(directory / name / "report.json", identity) if row else (None, "NOT RUN" if not rows else "INVALID REPORT (duplicate gate)") + child_status = problem or status(child) + if row and status(row) != "PASS" and child_status == "PASS": + child_status = "FAIL (parent validation)" + if child_status != "PASS" and conclusion == "PASS": + lines[1] = f"**INCOMPLETE REPORTS (not acceptance)** · step: {cell(outcome)} · pipeline: {seconds(report)}\n\n" + lines.append("| " + " | ".join(cell(x) for x in (scope, child_status, coverage(child) if child else "not recorded", seconds(row) if row else "not recorded")) + " |\n") + if child: + _, numeric, _ = numerical_summary(child) + selected = numeric[1:] if name in ("medium", "subgraphs") else numeric[1:2] if name == "attention" else [] + records = [item for _, items in selected for item in items] + if selected: + comparisons.append((scope + " vs ORT", records)) + folded.append(details(child, scope + " — details")) + folded.insert(0, details(report, "Preparation execution / failures")) + else: + lines.append("28-layer pretrained Qwen3, FP32 / L=256 / no KV cache; IR vs ORT. **No full-model QEMU result in this job.**\n\n") + lines.append(f"Coverage: **{cell(coverage(report))}** · FP32 mode: **{cell(report.get('fp32_mode', 'not recorded'))}** · maximum absolute error threshold: **{cell(report.get('atol', 'not recorded'))}** (strict <, rtol=0).\n\n") + display = dict(report) + recorded = {row.get("name") for row in report.get("cases", [])} + display["cases"] = list(report.get("cases", [])) + [ + {"name": name, "status": "NOT RUN"} for name in report.get("required_cases", []) + if name not in recorded] + _, comparisons, _ = numerical_summary(display) + folded.append(details(display, "Cases, diagnostics, invariants and failures")) + if comparisons: + lines.append("\n" + compact_metric_table(comparisons) + "\n\n") + lines.append("Metrics summarize final outputs only (worst MaxAbs / relative L2, lowest cosine across recorded comparisons); checkpoints still affect the original gate. Missing metrics remain explicit.\n\n") + if report.get("error"): + lines.append("Issue: " + cell(report["error"], 300) + "\n\n") + lines.extend(folded) + except (TypeError, KeyError, ValueError, AttributeError, RecursionError): + # Unexpected report schemas must not make an apparently passing page. + return f"## {title}\n\n**INVALID REPORT** — could not render numerical evidence; step: {cell(outcome)}. No acceptance claimed.\n\n" + evidence + lines.append(evidence) + rendered = "".join(lines) + if len(rendered.encode("utf-8")) > MAX_SUMMARY_BYTES: + # Do not slice Markdown or HTML: a metric's free-form reason can be + # enormous even when report structure and all numeric values are valid. + # Retain the authoritative, already bounded status (including parent + # validation failures) and replace the entire detail body atomically. + return ("".join(lines[:2]) + + "Numerical details exceed the 64 KiB Actions summary budget and are omitted here. " + "The recorded gate result above is unchanged; inspect the complete artifact for metrics and scope.\n\n" + evidence) + return rendered + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--kind", choices=("preparation", "full"), required=True) + parser.add_argument("--report-dir", type=Path, required=True) + parser.add_argument("--outcome", default=os.environ.get("PROBE_OUTCOME", "")) + parser.add_argument("--summary", type=Path, default=os.environ.get("GITHUB_STEP_SUMMARY")) + args = parser.parse_args(argv) + text = render(args.kind, args.report_dir, args.outcome) + if args.summary: + with args.summary.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(text) + else: + print(text, end="") + return 0 # Reporting never substitutes for or changes the probe's exit code. + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/summarize_w4_ci.py b/scripts/summarize_w4_ci.py new file mode 100644 index 0000000..234a683 --- /dev/null +++ b/scripts/summarize_w4_ci.py @@ -0,0 +1,185 @@ +"""Render bounded W4 Actions summaries from this run's evidence (stdlib only).""" +from __future__ import annotations + +import argparse +from html import escape +import json +import math +import os +from pathlib import Path +import xml.etree.ElementTree as ET + + +NOT_RUN = "NOT RUN(未运行)" +MISSING = "MISSING(报告缺失或无效)" + + +def cell(value): + text = " ".join(str(value).split())[:240] + return escape(text).replace("|", "|").replace("`", "`") + + +def table(headers, rows): + return "\n".join("| " + " | ".join(cell(value) for value in row) + " |" + for row in [headers, ["---"] * len(headers), *rows]) + + +def number(value, *, integer=False): + if type(value) not in (int, float): + return "未记录" + try: + if not math.isfinite(value) or value < 0 or (integer and int(value) != value): + return "无效" + except OverflowError: + return "无效" + return f"{int(value):,}" if integer else f"{value:.9g}" + + +def mapping(value): + return value if isinstance(value, dict) else {} + + +def records(value): + return [row for row in value if isinstance(row, dict)] if isinstance(value, list) else [] + + +def exit_code(value): + # Negative POSIX return codes identify a terminating signal. + return str(value) if type(value) is int else "未记录" + + +def read_report(path): + try: + if path.stat().st_size > 16 * 1024 * 1024: + return {} + return mapping(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError, RecursionError): + return {} + + +def outcome_status(outcome, report=None): + if outcome == "skipped": + return NOT_RUN + if outcome in ("failure", "cancelled"): + return "FAIL" if outcome == "failure" else "CANCELLED(已取消)" + if outcome != "success": + return "UNKNOWN(执行状态缺失)" + if not report: + return MISSING + return "PASS" if report.get("passed") is True and report.get("status") == "PASS" else "FAIL / 未完成" + + +def unit_summary(path, outcome): + """Count actual JUnit cases, including skipped cases; never infer zero passes.""" + counts = dict(passed=0, failed=0, errors=0, skipped=0) + status = outcome_status(outcome) + available = False + if outcome != "skipped": + try: + root = ET.parse(path).getroot() + cases = list(root.iter("testcase")) + available = True + for case in cases: + key = ("errors" if case.find("error") is not None else + "failed" if case.find("failure") is not None else + "skipped" if case.find("skipped") is not None else "passed") + counts[key] += 1 + valid = bool(cases) and not counts["failed"] and not counts["errors"] and counts["passed"] > 0 + status = outcome_status(outcome, {"passed": valid, "status": "PASS" if valid else "FAIL"}) + except (OSError, ET.ParseError): + pass + values = [counts[key] for key in ("passed", "failed", "errors", "skipped")] + if not available or status in (NOT_RUN, MISSING, "UNKNOWN(执行状态缺失)"): + values = ["—"] * 4 + return (f"## W4 单测与真实小图 QEMU:{status}\n\n" + + table(["通过", "失败", "错误", "跳过"], [values]) + + ("\n\nJUnit 报告缺失或无效,未取得测试计数。" if not available and outcome != "skipped" else "") + + "\n\n范围:外置权重 ABI、装载和小图回归;不代表完整 28 层前向通过。" + "详细用例见本次运行的 `w4-unit-*` artifact / `tests.xml`。\n") + + +def full_summary(report, audit, *, probe_outcome, gate_outcome, audit_outcome, commit, case): + # A skipped probe must not display metrics from an old or unrelated report. + if probe_outcome == "skipped": + report, audit = {}, {} + status = outcome_status(probe_outcome, report) + identity = mapping(report.get("git")) + identity_ok = bool(commit) and identity.get("head") == commit and identity.get("dirty") is False + if status == "PASS" and not identity_ok: + status = "INVALID(报告来源与本次提交不符)" + if status == "PASS" and gate_outcome != "success": + status = "未验收(完整门禁未通过)" + audit_status = outcome_status(audit_outcome, audit) + if status == "PASS" and audit_status != "PASS": + status = "未验收(数组审计未通过)" + gates = mapping(report.get("gates")) + gate_rows = [] + for key in ("build:riscv-full", "smoke:qemu-full", "numeric:qemu-full-qwen3"): + flag = gates.get(key) + value = "PASS" if flag is True else "未完成" if flag is False else NOT_RUN + gate_rows.append([key, value]) + fma = mapping(report.get("fma_conformance")) + gate_rows += [["FMA 位模式 / FCSR", outcome_status("success", fma) if fma else NOT_RUN], + ["完整来源与数值门禁", "PASS" if gate_outcome == "success" else outcome_status(gate_outcome)], + ["保留数组审计", audit_status]] + cases = records(report.get("cases")) + # Prioritize failures and cap presentation only; machine evidence stays intact. + selected = sorted(cases, key=lambda row: row.get("passed") is True)[:7] + numeric_rows, run_rows = [], [] + for row in selected: + numeric, run = mapping(row.get("numeric")), mapping(row.get("qemu")) + numeric_rows.append([row.get("name", "未命名"), number(numeric.get("max_abs")), + number(numeric.get("rmse")), number(numeric.get("elements_compared"), integer=True), + number(numeric.get("elements_at_or_above_atol"), integer=True), + "PASS" if numeric.get("passed") is True else "未通过 / 未运行"]) + run_rows.append([row.get("name", "未命名"), number(run.get("qemu_wall_s")), + number(run.get("forward_wall_s")), exit_code(run.get("exit_code"))]) + if not numeric_rows: + numeric_rows = [[case, "未测", "未测", "未测", "未测", NOT_RUN]] + build = mapping(report.get("build")) + lines = [f"## W4 完整 Qwen3 RV64 前向:{status}", "", + f"本次提交:`{cell(commit)}`;请求用例:`{cell(case)}`。", "", + "完整输出门槛:所有 38,895,616 个 FP32 logits 的 MaxAbs < 1e-3,rtol=0。", "", + table(["用例", "MaxAbs", "RMSE", "比较元素", "超限元素", "数值结果"], numeric_rows), "", + f"不含权重 ELF:{number(build.get('bytes'), integer=True)} bytes(要求 < 100,000,000);" + f"流程墙钟:{number(report.get('elapsed_seconds'))} s。", "", + "QEMU 进程墙钟包含启动、执行和输出转储,不是芯片性能指标。", "", + table(["用例", "QEMU 墙钟 / s", "到 guest 完成 / s", "退出码"], run_rows or [[case, "未测", "未测", "未测"]]), "", + "{cell(title)}
\n\n{cell(description, 700)}\n\n" + if comparisons: + text += compact_metric_table(comparisons) + "\n\n" + if report.get("error"): + text += "Issue: " + cell(report["error"], 400) + "\n\n" + text += "\n\n".join(cell(note, 500) for note in notes) + "\n\n" + index + "", "", "范围:请求的单次前向;不代表七组覆盖、团队验收或第二人独立复现。", ""] + return "\n".join(lines) + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--kind", choices=("unit", "full"), required=True) + cli.add_argument("--report-dir", type=Path, required=True) + cli.add_argument("--output", type=Path, required=True) + args = cli.parse_args(argv) + if args.kind == "unit": + text = unit_summary(args.report_dir / "tests.xml", os.environ.get("UNIT_OUTCOME", "")) + else: + text = full_summary(read_report(args.report_dir / "report.json"), + read_report(args.report_dir.with_name(args.report_dir.name + "-audit") / "report.json"), + probe_outcome=os.environ.get("PROBE_OUTCOME", ""), gate_outcome=os.environ.get("GATE_OUTCOME", ""), + audit_outcome=os.environ.get("AUDIT_OUTCOME", ""), commit=os.environ.get("GITHUB_SHA", ""), + case=os.environ.get("SELECTED_CASE", "未记录")) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + if os.environ.get("GITHUB_STEP_SUMMARY"): + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as stream: + stream.write(text + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_w3_evidence.py b/scripts/verify_w3_evidence.py new file mode 100644 index 0000000..e4bb4fd --- /dev/null +++ b/scripts/verify_w3_evidence.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Recheck saved W3 full-model evidence without executing either model backend.""" +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import re +import sys +import time + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np + +from probes.w1_qwen3_export.run import MANIFEST, verify_files +from probes.w3_common import new_output_dir, sha256_file, source_evidence, write_reports +from probes.w3_layer_diff.run import load_arrays +from probes.w3_qwen3_full import run as full +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases + +MAX_JSON_BYTES = 8 * 1024**2 +HASH = re.compile(r"[0-9a-f]{64}\Z") +OBSERVATIONAL_FIELDS = frozenset(("relative_l2", "cosine_similarity", + "relative_l2_reason", "cosine_similarity_reason")) +SCOPE = ("Offline consistency and numerical recheck of saved seven-case arrays. " + "No model execution, independent-person reproduction, current-source execution, " + "Linux execution, Nightly run, or team acceptance is established. " + "Hashes establish consistency, not producer authenticity; retain a trusted report hash.") + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def _unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, f"Duplicate JSON key: {key}") + result[key] = value + return result + + +def read_json(path): + require(path.is_file() and path.stat().st_size <= MAX_JSON_BYTES, + f"Missing or oversized JSON: {path.name}") + def reject(value): + raise ValueError(f"Nonfinite JSON constant: {value}") + def finite_float(value): + number = float(value) + require(math.isfinite(number), f"Nonfinite JSON number: {value}") + return number + value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique, + parse_constant=reject, parse_float=finite_float) + require(isinstance(value, dict), f"JSON object required: {path.name}") + return value + + +def safe_file(root, relative): + path = root / relative + require(path.is_file() and path.resolve().is_relative_to(root.resolve()), + f"Missing/external evidence file: {relative}") + return path + + +def source_difference(recorded, current): + require(isinstance(recorded, dict) and bool(recorded), "Missing producer source hashes") + for name, digest in recorded.items(): + require(isinstance(name, str) and name and "\\" not in name and ":" not in name + and not name.startswith("/") and all(p not in ("", ".", "..") for p in name.split("/")) + and isinstance(digest, str) and HASH.fullmatch(digest), "Invalid producer source hash") + return {"matches_current": recorded == current, + "changed": [{"path": name, "recorded_sha256": recorded[name], "current_sha256": current[name]} + for name in sorted(recorded.keys() & current.keys()) if recorded[name] != current[name]], + "added_since_recording": sorted(current.keys() - recorded.keys()), + "missing_from_current": sorted(recorded.keys() - current.keys()), + "scope": "Source mismatch is reported, not silently treated as current-source execution. " + "This audit recomputes stored-array comparisons only."} + + +def comparison_matches_saved(recomputed, saved): + """Permit only the absent four-field observation group in legacy rows. + + Old reports predate these descriptive metrics. Existing fields and any + recorded metrics must still match the arrays; a partial group is invalid. + This does not relax profiles, source evidence, thresholds or PASS checks. + """ + if isinstance(recomputed, dict): + if not isinstance(saved, dict): + return False + wanted = set(recomputed) + observed = OBSERVATIONAL_FIELDS & saved.keys() + if OBSERVATIONAL_FIELDS <= wanted: + if not observed: + wanted -= OBSERVATIONAL_FIELDS + elif observed != OBSERVATIONAL_FIELDS: + return False + else: + # JSON booleans must not impersonate numeric 0/1 observations. + if any(type(saved[key]) is not type(recomputed[key]) + for key in OBSERVATIONAL_FIELDS): + return False + if set(saved) != wanted: + return False + return all(comparison_matches_saved(recomputed[key], saved[key]) for key in wanted) + if isinstance(recomputed, list): + return (isinstance(saved, list) and len(recomputed) == len(saved) + and all(comparison_matches_saved(a, b) for a, b in zip(recomputed, saved))) + return recomputed == saved + + +def verify(directory, *, model_dir=None, expected_report_sha256=None, progress=None): + """Reuse the runner's numerical validators; never call its worker launcher.""" + directory = Path(directory).resolve() + top_path = safe_file(directory, "report.json") + digest = sha256_file(top_path) + if expected_report_sha256 is not None: + require(isinstance(expected_report_sha256, str) and HASH.fullmatch(expected_report_sha256), + "Expected report SHA-256 must be 64 lowercase hexadecimal characters") + require(digest == expected_report_sha256, "Trusted report SHA-256 differs") + top = read_json(top_path) + require(top.get("gate") == "numeric:ir-full-qwen3" and top.get("stage") == "complete", + "Wrong/incomplete full-model report") + require(top.get("passed") is True and top.get("status") == "PASS" + and top.get("coverage_complete") is True and top.get("selected_numerical_passed") is True + and top.get("full_ir_executed") is True and top.get("w3_exit_accepted") is False, + "Producer report is not a complete passing numerical run") + require(top.get("optimization_level") == "none" and top.get("atol") == full.ATOL + and top.get("rtol") == 0 and top.get("fp32_mode") in ("native", "reference"), + "Wrong numerical contract") + require(top.get("required_cases") == list(CASE_NAMES) and top.get("selected_cases") == list(CASE_NAMES), + "Seven-case coverage required") + rows = top.get("cases") + require(isinstance(rows, list) and all(isinstance(row, dict) for row in rows) + and [row.get("name") for row in rows] == list(CASE_NAMES), "Missing/duplicate/reordered cases") + require(top.get("files") == MANIFEST["files"], "Producer assets differ from pinned manifest") + current = source_evidence() + differences = source_difference(top.get("source_sha256"), current["source_sha256"]) + if model_dir is not None: + require(verify_files(model_dir) == top["files"], "Local model assets differ") + + verified, profiles = [], [] + for row, (name, valid, wanted) in zip(rows, input_cases()): + require(row.get("name") == name and type(row.get("valid_length")) is int + and row["valid_length"] == valid and row.get("passed") is True, "Wrong case contract") + folder = directory / name + input_path = safe_file(directory, f"{name}/inputs.npz") + require(row.get("input_sha256") == sha256_file(input_path), f"Altered input: {name}") + schema = [{"name": key, "shape": list(value.shape), "dtype": str(value.dtype)} + for key, value in wanted.items()] + feed = load_arrays(input_path, schema, max_bytes=1024**2) + require(all(np.array_equal(feed[key], value) for key, value in wanted.items()), + f"Saved inputs differ from deterministic case: {name}") + workers = row.get("workers") + require(isinstance(workers, list) and all(isinstance(w, dict) for w in workers) + and [w.get("backend") for w in workers] == ["ort", "ir"], "Missing/duplicate case workers") + for saved in workers: + backend = saved["backend"] + report_path = safe_file(directory, f"{name}/{backend}/report.json") + require(saved.get("passed") is True and type(saved.get("returncode")) is int + and saved["returncode"] == 0 and saved.get("report_sha256") == sha256_file(report_path), + f"Invalid/altered worker report: {name}/{backend}") + report = read_json(report_path) + # Preflight strict JSON and containment before invoking the shared reader. + for filename in ("report.md", "report.html", "logits.npy", "diagnostic_logits.npy", + "checkpoints.npz", "checkpoint_schema.json"): + safe_file(directory, f"{name}/{backend}/{filename}") + read_json(folder / backend / "checkpoint_schema.json") + saved_profile = (full.saved_arithmetic_profile(top["fp32_mode"], + report.get("ir", {}).get("fp32_profile")) if backend == "ir" else None) + evidence = full.validate_worker(folder / backend, backend, input_path, + top["source_sha256"], top["files"], + expected_fp32_mode=top["fp32_mode"], + expected_fp32_profile=saved_profile) + require(evidence == report, "Worker report changed during audit") + require(evidence["input"].get("valid_length") == valid, "Wrong worker valid length") + if backend == "ir": + profiles.append(evidence["ir"]["fp32_profile"]) + comparison = full.compare_case(folder, valid) + require(comparison["passed"], f"Recomputed logits fail threshold: {name}") + require(comparison_matches_saved(comparison, row.get("comparison")), + f"Stored comparison differs from arrays: {name}") + verified.append({"name": name, "passed": True, "valid_length": valid, + "input_sha256": row["input_sha256"], "comparison": comparison}) + if progress: + progress(name) + require(len(profiles) == len(CASE_NAMES) and all(p == profiles[0] for p in profiles), + "Inconsistent numerical profiles") + invariants = full.invariants(directory, set(CASE_NAMES)) + require(len(invariants) == 4 and all(row["passed"] for row in invariants), "Recomputed invariants fail") + require(comparison_matches_saved(invariants, top.get("invariants")), "Stored invariants differ from arrays") + require(sha256_file(top_path) == digest, "Producer report changed during audit") + return {"gate": "audit:w3-full-saved-evidence", "schema_version": 1, "status": "PASS", "passed": True, + "scope": SCOPE, "model_executed": False, "independent_reproduction": False, + "w3_exit_accepted": False, "producer_report_sha256": digest, + "trusted_report_hash_checked": expected_report_sha256 is not None, + "pinned_model_files_checked": model_dir is not None, "producer_environment": top.get("environment"), + "producer_git": top.get("git"), "auditor": current, "source_comparison": differences, + "fp32_mode": top["fp32_mode"], "fp32_profile": profiles[0], "cases": verified, + "invariants": invariants, "coverage_complete": True, + "comparison_compatibility": { + "allowed_legacy_missing_fields": sorted(OBSERVATIONAL_FIELDS), + "scope": "Only a wholly absent observation group is accepted for legacy tensor rows. " + "All observations in this audit are recomputed from saved arrays, not a new model run."}, + "checkpoint_threshold_scope": "Checkpoints are finite FP32 diagnostics; the strict numerical gate " + "applies to every ordinary/diagnostic output logit, including padding."} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--model-dir", type=Path, help="Optionally verify local pinned model file hashes; never execute it") + parser.add_argument("--expected-report-sha256", help="Optional independently retained producer report digest") + args = parser.parse_args(argv) + out = new_output_dir(args.output_dir) + start = time.perf_counter() + result = {"gate": "audit:w3-full-saved-evidence", "schema_version": 1, "passed": False, + "status": "FAIL", "scope": SCOPE, "model_executed": False, + "independent_reproduction": False, "w3_exit_accepted": False} + try: + result = verify(args.evidence_dir, model_dir=args.model_dir, + expected_report_sha256=args.expected_report_sha256, + progress=lambda name: print(f"[{name}] saved arrays verified", flush=True)) + except Exception as exc: + result["error"] = f"{type(exc).__name__}: {exc}" + result["elapsed_seconds"] = time.perf_counter() - start + write_reports(out, result) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py index 4c38853..a37caf2 100644 --- a/tests/test_ci_workflows.py +++ b/tests/test_ci_workflows.py @@ -74,7 +74,7 @@ def test_the_w1_w2_job_writes_no_step_summary(): data = workflow('llm-deploy.yml') writers = {name for name, job in data['jobs'].items() for step in job.get('steps', []) if 'GITHUB_STEP_SUMMARY' in step.get('run', '')} - assert writers == {'full-qwen3-onnx'} + assert writers == {'full-qwen3-onnx', 'w3-execution-summary', 'w4-execution-summary'} def test_linux_reproduction_check_runs_without_a_path_filter(): diff --git a/tests/test_ir_interpreter.py b/tests/test_ir_interpreter.py index c61a028..24c5699 100644 --- a/tests/test_ir_interpreter.py +++ b/tests/test_ir_interpreter.py @@ -111,7 +111,7 @@ def test_empty_invalid_and_void_returns(): def test_error_position_and_cause(): - x = Value("x") + x = Value("x", shape=(2,)) b = builder(x) b.ret(b.reshape(x, (3,))) with pytest.raises(IRExecutionError) as found: @@ -131,7 +131,7 @@ def test_missing_value_not_zero_and_shape_on_result(): b.ret(Value("missing")) with pytest.raises(IRExecutionError, match="InvalidProgram"): IRInterpreter(b.program).run({}) - x = Value("x") + x = Value("x", shape=(2,)) b = builder(x) result = b.neg(x) result.shape = (3,) diff --git a/tests/test_ir_interpreter_control_flow.py b/tests/test_ir_interpreter_control_flow.py index b8b533f..ecceafd 100644 --- a/tests/test_ir_interpreter_control_flow.py +++ b/tests/test_ir_interpreter_control_flow.py @@ -188,7 +188,7 @@ def test_memory_errors(mode): if mode == "numeric_address": b.ret(b.load(b.make_const(42, D.INT32))) if mode == "vector_store": - value = Value("value", D.INT32) + value = Value("value", D.INT32, shape=(2,)) b.current_func.params = [value] b.store(slot, value) b.ret() diff --git a/tests/test_ir_interpreter_fp32_eigen.py b/tests/test_ir_interpreter_fp32_eigen.py new file mode 100644 index 0000000..ab671f1 --- /dev/null +++ b/tests/test_ir_interpreter_fp32_eigen.py @@ -0,0 +1,134 @@ +"""Independent numerical and shape checks for deterministic FP32 kernels.""" + +import numpy as np +import pytest + +from scratchv.verification.fp32_eigen import mean_last, trig + + +@pytest.mark.parametrize("width", [1, 2, 3, 4, 5, 7, 8, 9, 12, 15, 16, 17, 31, 32, 127, 128, 255, 1024, 3072]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_accuracy_including_packet_prefixes_and_tails(width, keepdims): + rng = np.random.default_rng(2201 + width) + values = rng.uniform(-3, 5, (2, 7, width)).astype(np.float32) + actual = mean_last(values, keepdims=keepdims) + reference = np.mean(values.astype(np.float64), axis=-1, keepdims=keepdims) + assert actual.dtype == np.float32 + assert actual.shape == reference.shape + np.testing.assert_allclose(actual, reference, atol=6e-7, rtol=6e-7) + + +@pytest.mark.parametrize("width", [1, 3, 5, 8, 17]) +def test_mean_scalar_result_and_empty_leading_dimensions(width): + values = np.arange(width, dtype=np.float32) + actual = mean_last(values, keepdims=False) + assert isinstance(actual, np.ndarray) + assert actual.shape == () + assert actual == np.float32((width - 1) / 2) + assert mean_last(values).shape == (1,) + for leading in [(0,), (2, 0)]: + empty = np.empty(leading + (width,), dtype=np.float32) + assert mean_last(empty).shape == leading + (1,) + assert mean_last(empty, keepdims=False).shape == leading + + +@pytest.mark.parametrize("shape", [(), (0,), (2, 0)]) +def test_mean_rejects_missing_or_empty_reduction_dimension(shape): + with pytest.raises(ValueError, match="nonempty last dimension"): + mean_last(np.empty(shape, dtype=np.float32)) + + +@pytest.mark.parametrize("width", [3, 5, 8, 17]) +def test_mean_does_not_depend_on_input_alignment_or_strides(width): + backing = np.arange(1 + 6 * width * 2, dtype=np.float32) + values = backing[1:].reshape(6, width * 2)[:, ::-2] + assert not values.flags.c_contiguous + values.setflags(write=False) + original = values.copy() + actual = mean_last(values) + np.testing.assert_array_equal(actual, mean_last(original)) + np.testing.assert_allclose(actual, np.mean(original.astype(np.float64), axis=-1, keepdims=True)) + np.testing.assert_array_equal(values, original) + assert not np.shares_memory(actual, values) + + +@pytest.mark.parametrize("width", [1, 3, 4, 5, 17]) +def test_mean_preserves_negative_zero(width): + actual = mean_last(np.full((4, width), -0.0, dtype=np.float32)) + assert np.all(actual == 0) + assert np.all(np.signbit(actual)) + + +@pytest.mark.parametrize("sine", [True, False]) +@pytest.mark.parametrize("bound", [1e-5, 255, 17999]) +def test_trig_accuracy_against_independent_double_reference(sine, bound): + values = np.random.default_rng(4101).uniform(-bound, bound, 32768).astype(np.float32) + function = np.sin if sine else np.cos + reference = function(values.astype(np.float64)) + actual = trig(values, sine=sine) + assert actual.dtype == np.float32 + np.testing.assert_allclose(actual, reference, rtol=0, atol=1.25e-7) + assert np.all(np.abs(actual) <= np.float32(1)) + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_scalar_empty_and_signed_zero(sine): + for scalar in [np.float32(0), np.float32(-0.0), np.float32(1)]: + actual = trig(scalar, sine=sine) + assert isinstance(actual, np.ndarray) + assert actual.shape == () + assert actual.dtype == np.float32 + zeros = trig(np.array([0.0, -0.0], dtype=np.float32), sine=sine) + np.testing.assert_array_equal(zeros, [0.0, 0.0] if sine else [1.0, 1.0]) + np.testing.assert_array_equal(np.signbit(zeros), [False, True] if sine else [False, False]) + for shape in [(0,), (2, 0), (0, 3)]: + actual = trig(np.empty(shape, dtype=np.float32), sine=sine) + assert actual.shape == shape + assert actual.dtype == np.float32 + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_readonly_strided_input_is_not_mutated(sine): + values = np.linspace(-100, 100, 187, dtype=np.float32).reshape(11, 17).T[:, ::-2] + assert not values.flags.c_contiguous + values.setflags(write=False) + original = values.copy() + actual = trig(values, sine=sine) + np.testing.assert_array_equal(actual, trig(original, sine=sine)) + np.testing.assert_array_equal(values, original) + assert not np.shares_memory(actual, values) + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_interval_boundary_and_numpy_fallback(sine): + edge = np.float32(18000) + interior = np.nextafter(edge, np.float32(0)) + values = np.array( + [-np.finfo(np.float32).max, -1e20, -edge, -interior, interior, edge, 1e20, + np.finfo(np.float32).max, np.inf, -np.inf, np.nan], dtype=np.float32 + ) + function = np.sin if sine else np.cos + with np.errstate(invalid="ignore"): + actual = trig(values, sine=sine) + fallback_reference = function(values) + exact_reference = function(values.astype(np.float64)) + fallback = ~np.isfinite(values) | (np.abs(values) >= edge) + np.testing.assert_array_equal(actual[fallback], fallback_reference[fallback]) + np.testing.assert_allclose(actual[~fallback], exact_reference[~fallback], rtol=0, atol=1.25e-7) + + +@pytest.mark.parametrize("dtype", [np.float64, np.float16, np.int32, np.int64, np.bool_]) +def test_fp32_functions_reject_implicit_precision_conversion(dtype): + values = np.ones(4, dtype=dtype) + with pytest.raises(TypeError, match="float32"): + mean_last(values) + with pytest.raises(TypeError, match="float32"): + trig(values, sine=True) + + +def test_boolean_parameters_reject_ambiguous_values(): + values = np.ones(4, dtype=np.float32) + with pytest.raises(TypeError, match="keepdims"): + mean_last(values, keepdims=1) + with pytest.raises(TypeError, match="sine"): + trig(values, sine="sin") diff --git a/tests/test_ir_interpreter_fp32_fma.py b/tests/test_ir_interpreter_fp32_fma.py new file mode 100644 index 0000000..86048ce --- /dev/null +++ b/tests/test_ir_interpreter_fp32_fma.py @@ -0,0 +1,154 @@ +"""Portable bit-exact FMA checks against a Python arbitrary-integer oracle.""" +import itertools + +import numpy as np +import pytest + +from scratchv.verification.fp32_fma import fma + + +def exact_fma_bits(a_bits, b_bits, c_bits): + """Independent exact dyadic arithmetic, no host floating-point reference.""" + def decode(bits): + sign = bits >> 31 + exponent = (bits >> 23) & 255 + fraction = bits & 0x7fffff + assert exponent != 255 + return sign, fraction | ((1 << 23) if exponent else 0), exponent - 150 if exponent else -149 + + sa, a, ea = decode(int(a_bits)) + sb, b, eb = decode(int(b_bits)) + sc, c, ec = decode(int(c_bits)) + ep, sp = ea + eb, sa ^ sb + base = min(ep, ec) + exact = ((-1 if sp else 1) * ((a * b) << (ep - base)) + + (-1 if sc else 1) * (c << (ec - base))) + if exact == 0: + return (sp & sc) << 31 + sign, magnitude = int(exact < 0), abs(exact) + quantum = max(magnitude.bit_length() - 1 + base - 23, -149) + shift = quantum - base + if shift < 0: + quotient = magnitude << -shift + else: + quotient, remainder = divmod(magnitude, 1 << shift) + midpoint_twice = 1 << shift + if 2 * remainder > midpoint_twice or (2 * remainder == midpoint_twice and quotient & 1): + quotient += 1 + if quotient == 1 << 24: + quotient >>= 1 + quantum += 1 + exponent = quantum + 150 if quotient >= 1 << 23 else 0 + if exponent >= 255: + raise OverflowError("exact result rounds to infinity") + return sign << 31 | exponent << 23 | (quotient & 0x7fffff) + + +def check_bits(rows): + finite, expected, overflowing = [], [], [] + for row in rows: + try: + answer = exact_fma_bits(*row) + except OverflowError: + overflowing.append(row) + else: + finite.append(row) + expected.append(answer) + if finite: + data = np.asarray(finite, dtype=np.uint32).view(np.float32) + actual = fma(*data.T) + np.testing.assert_array_equal(actual.view(np.uint32), np.asarray(expected, dtype=np.uint32)) + if overflowing: + data = np.asarray(overflowing, dtype=np.uint32).view(np.float32) + with pytest.raises(FloatingPointError, match="overflows"): + fma(*data.T) + + +def test_float32_fma_boundaries_signed_zero_subnormal_cancellation_and_overflow(): + bits = [0, 0x80000000, 1, 0x80000001, 0x007fffff, 0x807fffff, + 0x00800000, 0x80800000, 0x3f000000, 0xbf000000, 0x3f800000, + 0xbf800000, 0x3f800001, 0x3f7fffff, 0x7f7fffff, 0xff7fffff] + check_bits(itertools.product(bits, repeat=3)) + + +def test_compensated_double_rounding_midpoint_uses_exact_result(): + values = np.array([0x33800001, 0x3f7fffff, 0x3f800000], dtype=np.uint32).view(np.float32) + result = fma(*values) + assert result.view(np.uint32) == 0x3f800001 + assert exact_fma_bits(*values.view(np.uint32)) == 0x3f800001 + + +@pytest.mark.parametrize("bounded", [False, True]) +def test_random_finite_inputs_against_exact_integer_reference(bounded): + rng = np.random.default_rng(20261005) + rows = rng.integers(0, 2**32, size=(10_000, 3), dtype=np.uint32) + if bounded: + rows &= np.uint32(0x807fffff) + rows |= rng.integers(86, 169, size=rows.shape, dtype=np.uint32) << np.uint32(23) + rows = rows[np.all(((rows >> 23) & 255) != 255, axis=1)] + check_bits(rows) + + +def test_range_endpoints_and_adjacent_values_against_exact_reference(): + values = [np.float32(2**-40), np.float32(2**40)] + choices = [item for value in values for item in ( + np.nextafter(value, np.float32(0)), value, np.nextafter(value, np.float32(np.inf)))] + choices += [-value for value in choices] + bits = np.asarray(choices, dtype=np.float32).view(np.uint32) + check_bits(itertools.product(bits, repeat=3)) + + +def test_product_cancellation_and_adjacent_addends_against_exact_reference(): + # Uniform random triples rarely almost cancel. Place c at the negated + # rounded product and its immediate neighbors to exercise that boundary. + rng = np.random.default_rng(6301) + operands = rng.integers(0, 2**32, size=(4096, 2), dtype=np.uint32) + operands &= np.uint32(0x807fffff) + operands |= rng.integers(100, 151, size=operands.shape, + dtype=np.uint32) << np.uint32(23) + floats = operands.view(np.float32) + canceled = (-(floats[:, 0] * floats[:, 1])).view(np.uint32) + rows = [np.column_stack((operands, canceled + offset)) + for offset in (np.uint32(0), np.uint32(1))] + rows.append(np.column_stack((operands, canceled - np.uint32(1)))) + check_bits(np.concatenate(rows)) + + +def test_mixed_fast_and_integer_fallback_and_input_immutability(monkeypatch): + import scratchv.verification.fp32_fma as module + + original, observed = module._integer_fma, [] + def capture(a, b, c): + observed.append(len(a)) + return original(a, b, c) + monkeypatch.setattr(module, "_integer_fma", capture) + values = np.array([[0.75, 0.25, 0.125], [0, 1, 1], [2, 0.5, 1], + [0.5, 0.5, -0.25]], dtype=np.float32) + before = values.copy() + values.setflags(write=False) + result = fma(*values.T) + expected = [exact_fma_bits(*row) for row in values.view(np.uint32)] + np.testing.assert_array_equal(result.view(np.uint32), expected) + np.testing.assert_array_equal(values, before) + assert observed == [3] + + +def test_broadcast_scalar_and_empty_shapes(): + result = fma(np.array([[1], [-1]], dtype=np.float32), + np.array([[2, 3, 4]], dtype=np.float32), np.float32(1)) + np.testing.assert_array_equal(result, [[3, 4, 5], [-1, -2, -3]]) + assert result.dtype == np.float32 + assert fma(np.float32(1), np.float32(2), np.float32(3)).shape == () + assert fma(np.empty((0, 3), dtype=np.float32), np.float32(1), np.float32(2)).shape == (0, 3) + + +@pytest.mark.parametrize("bad", [np.float32(np.nan), np.float32(np.inf), np.float32(-np.inf)]) +def test_nonfinite_inputs_are_rejected(bad): + with pytest.raises(ValueError, match="finite float32"): + fma(np.float32(1), bad, np.float32(1)) + + +@pytest.mark.parametrize("bad", [1.0, 1, np.float64(1), np.array([1], dtype=">f4")]) +def test_precision_promotion_is_rejected(bad): + with pytest.raises(TypeError, match="float32"): + fma(bad, np.float32(1), np.float32(1)) diff --git a/tests/test_ir_interpreter_fp32_reference.py b/tests/test_ir_interpreter_fp32_reference.py new file mode 100644 index 0000000..f499da7 --- /dev/null +++ b/tests/test_ir_interpreter_fp32_reference.py @@ -0,0 +1,538 @@ +"""The opt-in FP32 profile preserves IR semantics and per-run isolation.""" + +import subprocess +import sys +import os +from pathlib import Path + +import numpy as np +import pytest + +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as D, Instruction, OpCode as O, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +DTYPES = {np.dtype("float32"): D.FLOAT32, np.dtype("float64"): D.FLOAT64, + np.dtype("int32"): D.INT32, np.dtype("int64"): D.INT64} + + +def operation(op, arrays, attrs=None): + params = [Value(f"x{i}", DTYPES[x.dtype], shape=x.shape) + for i, x in enumerate(arrays)] + builder = IRBuilder() + builder.new_function("main", params) + builder.new_block() + dest = Value("result", params[0].dtype) + builder.current_block.add(Instruction(op, dest, params, attrs or {})) + builder.ret(dest) + return builder.program, dict(zip((p.name for p in params), arrays)) + + +def execute(op, arrays, attrs=None, **options): + program, inputs = operation(op, arrays, attrs) + return IRInterpreter(program).run(inputs, **options).return_value + + +@pytest.mark.parametrize("mode", [None, [], True, "", "Reference", "automatic"]) +def test_unknown_profile_is_an_explicit_option_error(mode): + program, inputs = operation(O.SIGMOID, [np.array([0], "float32")]) + with pytest.raises(IRExecutionError, match="fp32_mode") as caught: + IRInterpreter(program).run(inputs, fp32_mode=mode) + assert caught.value.code == "InvalidOptions" + + +@pytest.mark.parametrize("axis", [0, 1, 2, -1, -2, -3]) +def test_softmax_normalizes_the_requested_axis(axis): + x = np.linspace(-4, 3, 30, dtype="float32").reshape(2, 3, 5) + actual = execute(O.SOFTMAX, [x], {"axis": axis}, fp32_mode="reference") + wide = x.astype(np.float64) + weights = np.exp(wide - np.max(wide, axis=axis, keepdims=True)) + expected = weights / np.sum(weights, axis=axis, keepdims=True) + assert actual.shape == x.shape and actual.dtype == x.dtype + np.testing.assert_allclose(actual, expected, rtol=3e-7, atol=3e-8) + np.testing.assert_allclose(actual.sum(axis=axis), 1, rtol=0, atol=2e-7) + + +def test_softmax_finite_extremes_allow_only_negative_overflow_in_shift(): + maximum = np.finfo(np.float32).max + x = np.array([[-maximum, maximum], [maximum, maximum], + [-maximum, -maximum]], "float32") + actual = execute(O.SOFTMAX, [x], {"axis": 1}, fp32_mode="reference") + np.testing.assert_array_equal(actual, [[0, 1], [0.5, 0.5], [0.5, 0.5]]) + + +def test_softmax_partial_negative_infinity_mask_is_preserved(): + x = np.array([[0, -np.inf, 0], [-np.inf, 0, -np.inf]], "float32") + actual = execute(O.SOFTMAX, [x], fp32_mode="reference") + np.testing.assert_array_equal(actual, [[0.5, 0, 0.5], [0, 1, 0]]) + + +@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf]) +def test_softmax_invalid_or_entirely_masked_rows_still_fail(bad): + x = np.full((2, 3), bad, "float32") + with pytest.raises(IRExecutionError): + execute(O.SOFTMAX, [x], fp32_mode="reference") + + +@pytest.mark.parametrize("axis", [3, -4]) +def test_softmax_invalid_axis_is_not_silently_reinterpreted(axis): + with pytest.raises(IRExecutionError, match="ShapeError"): + execute(O.SOFTMAX, [np.ones((2, 3, 4), "float32")], + {"axis": axis}, fp32_mode="reference") + + +@pytest.mark.parametrize("shape,axis", [ + ((0, 7), 1), ((5, 0), 0), ((3, 0, 129), 2), ((3, 129, 0), 1), +]) +def test_softmax_preserves_empty_dimensions_outside_normalization_axis(shape, axis): + actual = execute(O.SOFTMAX, [np.empty(shape, dtype="float32")], + {"axis": axis}, fp32_mode="reference") + assert actual.shape == shape and actual.dtype == np.float32 + + +@pytest.mark.parametrize("left,right", [ + ((3,), (3,)), ((3,), (3, 4)), ((2, 3), (3,)), + ((2, 1, 3, 4), (1, 5, 4, 2)), + ((3, 257), (257, 7)), ((2, 1025), (1025, 3)), + ((257,), (257,)), ((257,), (2, 257, 7)), ((2, 3, 257), (257,)), + ((2, 1, 3, 257), (1, 5, 257, 2)), +]) +def test_matmul_vector_batch_and_reduction_tail_semantics(left, right): + # Integer-valued inputs give an exact, independent integer arithmetic oracle. + rng = np.random.default_rng(1221) + a = rng.integers(-3, 4, left, dtype=np.int64) + b = rng.integers(-3, 4, right, dtype=np.int64) + expected = a @ b + actual = execute(O.MATMUL, [a.astype("float32"), b.astype("float32")], + fp32_mode="reference") + np.testing.assert_array_equal(actual, expected) + assert actual.shape == expected.shape and actual.dtype == np.float32 + + +@pytest.mark.parametrize("left,right,shape", [ + ((2, 0), (0, 3), (2, 3)), ((0,), (0,), ()), + ((2, 0), (0,), (2,)), ((0,), (0, 3), (3,)), + ((0, 4), (4, 3), (0, 3)), ((2, 4), (4, 0), (2, 0)), + ((0, 129), (129, 3), (0, 3)), ((2, 129), (129, 0), (2, 0)), + ((0, 2, 129), (1, 129, 3), (0, 2, 3)), +]) +def test_matmul_empty_dimensions_keep_numpy_shapes(left, right, shape): + actual = execute(O.MATMUL, [np.zeros(left, "float32"), + np.zeros(right, "float32")], + fp32_mode="reference") + np.testing.assert_array_equal(actual, np.zeros(shape, "float32")) + + +def test_matmul_legacy_flat_shapes_are_still_honored(): + a = np.arange(6, dtype="float32") + b = np.arange(12, dtype="float32") + result = execute(O.MATMUL, [a, b], {"m": 2, "n": 4, "k": 3}, + fp32_mode="reference") + np.testing.assert_array_equal(result, [[20, 23, 26, 29], [56, 68, 80, 92]]) + + +def test_matmul_consumes_an_ir_transpose_view_without_mutation(): + a = np.arange(18, dtype="float32").reshape(6, 3) + b = np.arange(24, dtype="float32").reshape(6, 4) + x, w = Value("x", shape=a.shape), Value("w", shape=b.shape) + builder = IRBuilder() + builder.new_function("main", [x, w]) + builder.new_block() + transposed = builder.transpose(x, (1, 0)) + builder.ret(builder.matmul(transposed, w)) + observed = [] + + def observe(name, value): + if name == transposed.name: + observed.append(value.flags.c_contiguous) + + actual = IRInterpreter(builder.program).run( + {"x": a, "w": b}, fp32_mode="reference", observer=observe).return_value + assert observed == [False] + np.testing.assert_array_equal(actual, a.astype(np.int64).T @ b.astype(np.int64)) + np.testing.assert_array_equal(a, np.arange(18).reshape(6, 3)) + np.testing.assert_array_equal(b, np.arange(24).reshape(6, 4)) + + +def test_matmul_random_fp32_remains_accurate_beyond_one_reduction_block(): + rng = np.random.default_rng(128) + a = rng.normal(size=(2, 3, 257)).astype("float32") + b = rng.normal(size=(257, 129)).astype("float32") + actual = execute(O.MATMUL, [a, b], fp32_mode="reference") + expected = a.astype(np.float64) @ b.astype(np.float64) + np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5) + + +@pytest.mark.parametrize("length", [1, 3, 7, 8, 9, 15, 17, 127, 129, 1025]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_last_axis_tails_and_keepdims(length, keepdims): + x = np.arange(2 * length, dtype="float32").reshape(2, length) / np.float32(8) + actual = execute(O.REDUCE_MEAN, [x], + {"axes": (-1,), "keepdims": keepdims}, fp32_mode="reference") + expected = np.mean(x.astype(np.float64), axis=-1, keepdims=keepdims) + assert actual.shape == expected.shape and actual.dtype == x.dtype + np.testing.assert_allclose(actual, expected, rtol=2e-7, atol=1e-7) + + +@pytest.mark.parametrize("axes", [None, (), (0,), (1,), (0, 2), (-1, 0)]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_multiple_axes_and_empty_axes_contract(axes, keepdims): + x = np.arange(30, dtype="float32").reshape(2, 3, 5) + attrs = {"keepdims": keepdims} + if axes is not None: + attrs["axes"] = axes + expected = np.mean(x.astype(np.float64), axis=axes or None, keepdims=keepdims) + actual = execute(O.REDUCE_MEAN, [x], attrs, fp32_mode="reference") + np.testing.assert_allclose(actual, expected, rtol=2e-7, atol=1e-7) + assert actual.shape == expected.shape and actual.dtype == x.dtype + + +def test_mean_scalar_and_empty_dimension_semantics(): + scalar = np.array(7.25, "float32") + actual = execute(O.REDUCE_MEAN, [scalar], fp32_mode="reference") + assert actual.shape == () and actual.dtype == scalar.dtype and actual == scalar + actual = execute(O.REDUCE_MEAN, [np.empty((0, 3), "float32")], + {"axes": (-1,)}, fp32_mode="reference") + assert actual.shape == (0, 1) + with pytest.raises(IRExecutionError, match="NumericError"): + execute(O.REDUCE_MEAN, [np.empty((2, 0), "float32")], + {"axes": (-1,)}, fp32_mode="reference") + + +def test_sigmoid_remains_bounded_monotone_and_close_to_mathematics(): + maximum = np.finfo(np.float32).max + x = np.concatenate([np.array([-maximum], "float32"), + np.linspace(-30, 30, 1001, dtype="float32"), + np.array([maximum], "float32")]) + actual = execute(O.SIGMOID, [x], fp32_mode="reference") + exp = np.exp(-np.abs(x.astype(np.float64))) + expected = np.where(x >= 0, 1 / (1 + exp), exp / (1 + exp)) + assert np.isfinite(actual).all() and np.all((actual >= 0) & (actual <= 1)) + # Approximation roundoff can jitter saturated tails by a few ULP. + assert np.all(np.diff(actual) >= -np.float32(2e-7)) + np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-7) + assert execute(O.SIGMOID, [np.array(0, "float32")], fp32_mode="reference") == 0.5 + + +def test_sigmoid_saturated_roundoff_never_leaves_probability_range(): + # This neighborhood includes a rational-approximation result above one. + # Probe both signs and adjacent binary32 values against sigmoid itself. + center = np.float32(17.868923).view(np.uint32) + positive = np.arange(center - 16, center + 17, dtype=np.uint32).view(np.float32) + x = np.concatenate((-positive[::-1], positive)) + actual = execute(O.SIGMOID, [x], fp32_mode="reference") + expected = 1 / (1 + np.exp(-x.astype(np.float64))) + assert np.all((actual >= 0) & (actual <= 1)) + np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-7) + + +@pytest.mark.parametrize("opcode", [O.MATMUL, O.REDUCE_MEAN, O.SOFTMAX, + O.SIGMOID, O.SIN, O.COS]) +def test_float64_results_are_exactly_the_existing_native_results(opcode): + x = np.linspace(-2, 2, 15, dtype="float64").reshape(3, 5) + arrays = [x, x.T.copy()] if opcode is O.MATMUL else [x] + expected = execute(opcode, arrays) + result = execute(opcode, arrays, fp32_mode="reference") + np.testing.assert_array_equal(result, expected) + assert result.dtype == np.float64 + + +@pytest.mark.parametrize("dtype", ["int32", "int64"]) +def test_integer_matmul_preserves_native_wrap_and_dtype(dtype): + x = np.array([[np.iinfo(dtype).max, 2], [3, -4]], dtype) + y = np.array([[2, -1], [4, 7]], dtype) + expected = execute(O.MATMUL, [x, y]) + actual = execute(O.MATMUL, [x, y], fp32_mode="reference") + np.testing.assert_array_equal(actual, expected) + assert actual.dtype == x.dtype + + +@pytest.mark.parametrize("opcode", [O.SIGMOID, O.REDUCE_MEAN, O.SOFTMAX, O.SIN, O.COS]) +def test_profile_does_not_enable_unsupported_integer_float_operations(opcode): + with pytest.raises(IRExecutionError) as native: + execute(opcode, [np.array([1, 2], "int64")]) + with pytest.raises(IRExecutionError) as reference: + execute(opcode, [np.array([1, 2], "int64")], fp32_mode="reference") + assert reference.value.code == native.value.code + + +@pytest.mark.parametrize("opcode", [O.MATMUL, O.REDUCE_MEAN, O.SOFTMAX, + O.SIGMOID, O.SIN, O.COS]) +def test_reference_profile_never_mutates_registry_or_another_run(opcode): + from scratchv.verification import ir_numpy_ops + + x = np.random.default_rng(48).normal(size=(2, 257)).astype("float32") + arrays = [x, x.T.copy()] if opcode is O.MATMUL else [x] + program, inputs = operation(opcode, arrays) + interpreter = IRInterpreter(program) + kernels = dict(ir_numpy_ops.KERNELS) + original_matmul = np.matmul + baseline = interpreter.run(inputs).return_value + explicit = interpreter.run(inputs, fp32_mode="native").return_value + np.testing.assert_array_equal(explicit, baseline) + observations = [] + + def observe(_name, _value): + assert ir_numpy_ops.KERNELS == kernels + assert np.matmul is original_matmul + nested = interpreter.run(inputs).return_value + np.testing.assert_array_equal(nested, baseline) + observations.append(True) + + interpreter.run(inputs, fp32_mode="reference", observer=observe) + assert observations == [True] + np.testing.assert_array_equal(interpreter.run(inputs).return_value, baseline) + assert ir_numpy_ops.KERNELS == kernels + + +def test_reference_profile_imports_and_executes_without_onnxruntime(): + # A clean interpreter catches eager imports as well as hidden runtime calls. + script = ''' +import sys +class BlockORT: + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] == "onnxruntime": + raise AssertionError("Reference IR must not import ONNX Runtime") +sys.meta_path.insert(0, BlockORT()) +import numpy as np +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.verification.ir_interpreter import IRInterpreter +x, w = Value("x", shape=(2,3)), Value("w", shape=(3,3)) +b = IRBuilder(); b.new_function("main", [x,w]); b.new_block() +v = b.matmul(x,w); v = b.sin(v); v = b.cos(v); v = b.sigmoid(v) +v = b.softmax(v, axis=1); v = b.reduce_mean(v, axes=(1,), keepdims=False); b.ret(v) +out = IRInterpreter(b.program).run( + {"x":np.arange(6,dtype="float32").reshape(2,3),"w":np.eye(3,dtype="float32")}, + fp32_mode="reference").return_value +np.testing.assert_allclose(out, [1/3,1/3], rtol=3e-7) +assert out.dtype == np.float32 +assert not any(name.split(".")[0] == "onnxruntime" for name in sys.modules) +''' + completed = subprocess.run([sys.executable, "-B", "-c", script], + cwd=Path(__file__).resolve().parents[1], + capture_output=True, text=True, timeout=30) + assert completed.returncode == 0, completed.stdout + completed.stderr + + +@pytest.mark.parametrize("avx512,strategy,lanes", [ + (False, "avx2-fma3", 8), (True, "avx512", 16), +]) +def test_auto_profile_records_the_resolved_cpu_arithmetic(monkeypatch, avx512, strategy, lanes): + from scratchv.verification import fp32_reference as reference + + monkeypatch.delenv("SCRATCHV_FP32_REFERENCE_CPU", raising=False) + monkeypatch.delenv("NPY_DISABLE_CPU_FEATURES", raising=False) + monkeypatch.setattr(reference, "_cpu_features", lambda: { + "AVX2": True, "FMA3": True, "AVX512F": avx512}) + actual = reference.profile() + assert actual["name"] == "numpy-fp32-reference-v4" + assert actual["matmul_row_tile"] == (2 if lanes == 8 else None) + assert actual["matmul_row_tail"] == ("zero-pad-to-two-then-trim" if lanes == 8 else "native") + assert actual["cpu_strategy"] == strategy and actual["softmax_lanes"] == lanes + assert actual == reference.profile(cpu_strategy=strategy) + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + assert reference.profile() == actual + + +@pytest.mark.parametrize("disabled", ["AVX2", "FMA3", "AVX512F", "AVX512_SKX", "AVX2, FMA3"]) +def test_auto_policy_rejects_cpu_features_hidden_only_from_numpy(monkeypatch, disabled): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + monkeypatch.setenv("NPY_DISABLE_CPU_FEATURES", disabled) + monkeypatch.setattr(reference, "_cpu_features", lambda: {"AVX2": True, "FMA3": True}) + with pytest.raises(ValueError, match="obscures MLAS"): + reference.profile() + # The explicit contract is reproducible on other CPUs and independent of + # NumPy's feature controls; it makes no claim to reconfigure ORT. + assert reference.profile(cpu_strategy="avx512")["softmax_lanes"] == 16 + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx2-fma3") + assert reference.profile()["softmax_lanes"] == 8 + + +@pytest.mark.parametrize("features", [{}, {"AVX2": True}, {"FMA3": True}, {"AVX512F": True}]) +def test_unknown_hardware_requires_an_explicit_policy(monkeypatch, features): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + monkeypatch.delenv("NPY_DISABLE_CPU_FEATURES", raising=False) + monkeypatch.setattr(reference, "_cpu_features", lambda: features) + with pytest.raises(ValueError, match="Cannot infer"): + reference.profile() + assert reference.profile(cpu_strategy="avx2-fma3")["softmax_lanes"] == 8 + + +@pytest.mark.parametrize("bad", ["", "AVX512", "sse", "automatic"]) +def test_invalid_cpu_policy_cannot_silently_select_another_kernel(monkeypatch, bad): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", bad) + with pytest.raises(ValueError, match="SCRATCHV_FP32_REFERENCE_CPU"): + reference.profile() + with pytest.raises(IRExecutionError, match="SCRATCHV_FP32_REFERENCE_CPU"): + execute(O.SOFTMAX, [np.array([1, 2], "float32")], fp32_mode="reference") + # The pre-existing native interpreter never consults reference policy. + np.testing.assert_allclose(execute(O.SOFTMAX, [np.array([1, 2], "float32")]), + [0.26894142, 0.73105858], rtol=2e-7) + + +@pytest.mark.parametrize("strategy", ["avx2-fma3", "avx512"]) +@pytest.mark.parametrize("width", [1, 7, 8, 9, 15, 16, 17, 23, 24, 47, 48, 255, 256]) +def test_both_cpu_softmax_policies_preserve_shapes_masks_and_accuracy(monkeypatch, strategy, width): + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + rng = np.random.default_rng(7600 + width) + x = rng.normal(size=(2, width, 3)).astype(np.float32) + if width > 1: + x[:, -1, :] = -np.inf + original = x.copy() + x.setflags(write=False) + actual = execute(O.SOFTMAX, [x], {"axis": 1}, fp32_mode="reference") + wide = x.astype(np.float64) + weights = np.exp(wide - np.max(wide, axis=1, keepdims=True)) + expected = weights / np.sum(weights, axis=1, keepdims=True) + np.testing.assert_allclose(actual, expected, rtol=4e-7, atol=3e-8) + np.testing.assert_array_equal(x, original) + assert actual.dtype == np.float32 and actual.shape == x.shape + if width > 1: + np.testing.assert_array_equal(actual[:, -1, :], 0) + + +def test_fma3_exponent_scaling_preserves_its_distinct_underflow_contract(monkeypatch): + # AVX2 MLAS packs m into binary32 exponent bits: m=-127 is zero. + # AVX512 VSCALEFPS retains representable subnormal results instead. + # This catches replacing the FMA3 scaling step with the AVX512 ldexp. + x = np.array([[0, -88, -90, -100, -104, -np.inf]], np.float32) + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx2-fma3") + avx2 = execute(O.SOFTMAX, [x], fp32_mode="reference") + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx512") + avx512 = execute(O.SOFTMAX, [x], fp32_mode="reference") + np.testing.assert_array_equal(avx2, [[1, 0, 0, 0, 0, 0]]) + assert np.all(avx512[0, 1:4] > 0) + assert np.all(avx512[0, 1:4] < np.finfo(np.float32).tiny) + np.testing.assert_array_equal(avx512[0, 4:], 0) + + +def test_host_softmax_reference_matches_ort1221_random_tail_and_mask_kernels(monkeypatch): + # The CPU matrix matters: CI's EPYC exercises FMA3, the Windows AVX512 host + # exercises VSCALEFPS. ORT is only the independent test oracle. + ort = pytest.importorskip("onnxruntime") + if ort.__version__ != "1.22.1": + pytest.skip("The explicit arithmetic contract targets ORT 1.22.1") + from onnx import helper, TensorProto + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + # Deliberately retain NPY_DISABLE_CPU_FEATURES: if present, automatic + # inference must fail rather than claim an unverified ORT dispatch. + reference.profile() + model = helper.make_model(helper.make_graph( + [helper.make_node("Softmax", ["x"], ["y"], axis=-1)], "softmax_cpu_arithmetic", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, None)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, None)]), + opset_imports=[helper.make_opsetid("", 17)]) + model.ir_version = 10 + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + session = ort.InferenceSession(model.SerializeToString(), sess_options=options, + providers=["CPUExecutionProvider"]) + rng = np.random.default_rng(20261005) + for width in (1, 3, 7, 8, 9, 15, 16, 17, 23, 24, 31, 32, 47, 48, 127, 128, 255, 256): + x = (rng.normal(size=(32, width)) * 8).astype(np.float32) + x[1::2, width // 2 + 1:] = np.finfo(np.float32).min + actual = execute(O.SOFTMAX, [x], fp32_mode="reference") + expected = session.run(None, {"x": x})[0] + np.testing.assert_array_equal(actual, expected, err_msg=f"host kernel width={width}") + + +@pytest.mark.parametrize("strategy", ["avx2-fma3", "avx512"]) +@pytest.mark.parametrize("rows,cols,reduction", [ + (1, 1, 1), (1, 7, 127), (3, 9, 128), (5, 17, 129), (17, 31, 257), +]) +def test_fixed_row_matmul_preserves_broadcast_strides_and_odd_tail(monkeypatch, strategy, rows, cols, reduction): + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + rng = np.random.default_rng(7000 + rows) + a = rng.uniform(-1, 1, (2, 1, reduction, rows)).astype(np.float32).swapaxes(-1, -2) + b = rng.uniform(-1, 1, (1, 3, cols, reduction)).astype(np.float32).swapaxes(-1, -2) + a, b = a[..., ::-1], b[..., ::-1, :] + left, right = a.copy(), b.copy() + a.setflags(write=False) + b.setflags(write=False) + actual = execute(O.MATMUL, [a, b], fp32_mode="reference") + expected = left.astype(np.float64) @ right.astype(np.float64) + np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5) + np.testing.assert_array_equal(a, left) + np.testing.assert_array_equal(b, right) + assert actual.shape == (2, 3, rows, cols) and actual.dtype == np.float32 + + +def test_haswell_matmul_regression_executes_the_real_blas_kernel_in_a_child(): + # Force only this child's NumPy OpenBLAS to Haswell, even on AVX512 CI. + # This catches a CPU-specific accumulation-order regression that an + # AVX512-only ORT/NumPy comparison cannot see. No real gate is reconfigured. + from scratchv.verification import fp32_reference as reference + if np.__version__ != "2.2.6": + pytest.skip("The Haswell dispatch regression targets the pinned NumPy 2.2.6 wheel") + features = reference._cpu_features() + if not (features.get("AVX2") and features.get("FMA3")): + pytest.skip("Haswell BLAS regression requires usable AVX2/FMA3") + ort = pytest.importorskip("onnxruntime") + if ort.__version__ != "1.22.1": + pytest.skip("Exact arithmetic oracle is pinned to ORT 1.22.1") + libraries = list((Path(np.__file__).parent.parent / "numpy.libs").glob("*openblas*")) + if not libraries: + pytest.skip("Haswell regression requires the NumPy OpenBLAS wheel") + script = r''' +import ctypes, json +from pathlib import Path +import numpy as np +import onnxruntime as ort +from onnx import helper, TensorProto +from scratchv.verification.fp32_reference import matmul +libraries = list((Path(np.__file__).parent.parent / "numpy.libs").glob("*openblas*")) +assert len(libraries) == 1, libraries +library = ctypes.CDLL(str(libraries[0])) +get_core = library.scipy_openblas_get_corename64_ +get_core.restype = ctypes.c_char_p +assert get_core().decode().lower() == "haswell", get_core() +model = helper.make_model(helper.make_graph( + [helper.make_node("MatMul", ["a", "b"], ["y"])], "haswell_matmul_regression", + [helper.make_tensor_value_info(name, TensorProto.FLOAT, None) for name in ("a", "b")], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, None)]), + opset_imports=[helper.make_opsetid("", 17)]) +model.ir_version = 10 +options = ort.SessionOptions() +options.intra_op_num_threads = options.inter_op_num_threads = 1 +options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL +session = ort.InferenceSession(model.SerializeToString(), sess_options=options, + providers=["CPUExecutionProvider"]) +rng = np.random.default_rng(373) +old_mismatch = 0 +for shape in ((256, 128, 256), (17, 128, 256), (1, 128, 256), (17, 1024, 256)): + m, k, n = shape + a = rng.normal(size=(m, k)).astype(np.float32) + b = rng.normal(size=(k, n)).astype(np.float32) + expected = session.run(None, {"a": a, "b": b})[0] + actual = matmul([a, b], {}, np.dtype("float32")) + if m == 1: + # ORT itself selects GEMV for an entire one-row operation. Padding + # retains our matrix contract; its mathematical accuracy still holds. + truth = a.astype(np.float64) @ b.astype(np.float64) + np.testing.assert_allclose(actual, truth, rtol=2e-5, atol=2e-5) + else: + np.testing.assert_array_equal(actual, expected, err_msg=str(shape)) + if shape == (256, 128, 256): + old_mismatch = int(np.count_nonzero(np.matmul(a, b) != expected)) +assert old_mismatch > 0, "Fixture must expose the original Haswell accumulation bug" +print(json.dumps({"core": get_core().decode(), "old_mismatch": old_mismatch, "cases": 4})) +''' + environment = dict(os.environ, SCRATCHV_FP32_REFERENCE_CPU="avx2-fma3", + OPENBLAS_CORETYPE="Haswell", OPENBLAS_NUM_THREADS="1", + OMP_NUM_THREADS="1", MKL_NUM_THREADS="1") + completed = subprocess.run([sys.executable, "-B", "-c", script], + cwd=Path(__file__).resolve().parents[1], env=environment, + capture_output=True, text=True, timeout=60) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert '"core": "Haswell"' in completed.stdout diff --git a/tests/test_ir_interpreter_memory.py b/tests/test_ir_interpreter_memory.py new file mode 100644 index 0000000..7739656 --- /dev/null +++ b/tests/test_ir_interpreter_memory.py @@ -0,0 +1,132 @@ +"""Optional memory observations preserve execution and count view owners once.""" + +import numpy as np +import pytest + +from benchmarks.ir_interpreter_cases import CASE_FACTORIES, builder, make_case +from scratchv.ir.types import DataType as D, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def test_default_has_no_stats_and_does_not_observe(monkeypatch): + import scratchv.verification.ir_interpreter as module + + def unexpected(*args, **kwargs): + raise AssertionError("default execution must not collect memory") + + monkeypatch.setattr(module, "_MemoryObservation", unexpected) + b = builder() + b.ret(b.make_const(2.0)) + assert IRInterpreter(b.program).run({}).memory_stats is None + + +def test_views_count_logical_bindings_but_not_repeated_storage(): + x = Value("x", shape=(2, 3)) + b = builder(x) + reshaped = b.reshape(x, (3, 2)) + transposed = b.transpose(reshaped, (1, 0)) + b.ret(transposed) + source = np.arange(6, dtype="float32").reshape(2, 3) + interpreter = IRInterpreter(b.program) + result = interpreter.run({"x": source}, collect_memory_stats=True) + stats = result.memory_stats + assert stats["input_logical_bytes"] == 24 + assert stats["initializer_logical_bytes"] == 0 + assert stats["retained_values"] == 3 + assert stats["retained_logical_bytes"] == 72 + assert stats["retained_numpy_storage_bytes"] == 24 + assert stats["return_logical_bytes"] == 24 + assert stats["peak_live_logical_bytes"] == 96 + assert stats["peak_numpy_storage_bytes"] == 48 + assert not np.shares_memory(result.return_value, source) + result.return_value[:] = 99 + np.testing.assert_array_equal(source, np.arange(6).reshape(2, 3)) + second = interpreter.run({"x": source}, collect_memory_stats=True) + np.testing.assert_array_equal(second.return_value, source.reshape(3, 2).T) + assert second.memory_stats == stats + + +def test_initializer_bytes_and_independent_initializer_copy(): + x, weight = Value("x", shape=(2,)), Value("weight", shape=(2,)) + b = builder(x) + b.program.global_values.append(weight) + b.ret(weight) + # Even aliases supplied by the caller keep the pre-existing separate copies. + source = np.array([1, 2], dtype="float32") + result = IRInterpreter(b.program).run({"x": source}, initializers={"weight": source}, + collect_memory_stats=True) + assert result.memory_stats["input_logical_bytes"] == 8 + assert result.memory_stats["initializer_logical_bytes"] == 8 + assert result.memory_stats["retained_numpy_storage_bytes"] == 16 + assert result.memory_stats["peak_numpy_storage_bytes"] == 24 + result.return_value[0] = 123 + assert source[0] == 1 + + +@pytest.mark.parametrize("name", list(CASE_FACTORIES)) +def test_observer_preserves_all_direct_cases(name): + case = make_case(name) + interpreter = IRInterpreter(case.program) + baseline = interpreter.run(case.inputs, initializers=case.initializers) + observed = interpreter.run(case.inputs, initializers=case.initializers, collect_memory_stats=True) + np.testing.assert_array_equal(observed.return_value, baseline.return_value) + assert observed.executed_steps == baseline.executed_steps + assert observed.diagnostics == baseline.diagnostics + assert observed.memory_stats["retained_values"] >= len(case.inputs) + assert observed.memory_stats["peak_numpy_storage_bytes"] >= observed.memory_stats["retained_numpy_storage_bytes"] + + +@pytest.mark.parametrize("flag", [0, 1]) +def test_only_executed_branch_contributes_memory(flag): + condition = Value("condition", D.INT32) + x = Value("x", shape=(2,)) + b = builder(condition, x) + b.br_if(condition, "left", "right") + b.new_block("left") + b.ret(b.neg(x)) + b.new_block("right") + b.ret(x) + result = IRInterpreter(b.program).run({"condition": np.array(flag, dtype="int32"), + "x": np.array([2, 3], dtype="float32")}, + collect_memory_stats=True) + assert result.memory_stats["retained_values"] == 2 + flag + assert result.memory_stats["retained_logical_bytes"] == 12 + 8 * flag + + +def test_loop_counts_retained_slots_and_overwrites_not_cumulative_allocations(): + def run(end): + b = builder() + slot = b.alloca(16, D.INT32) + b.store(slot, b.make_const(0, D.INT32)) + i = b.for_loop(0, end) + b.store(slot, b.add(b.load(slot), i)) + b.endfor() + b.ret(b.load(slot)) + return IRInterpreter(b.program).run({}, collect_memory_stats=True) + + short, long = run(3), run(30) + assert short.return_value == 3 + assert long.return_value == 435 + assert short.memory_stats == long.memory_stats + assert short.memory_stats["retained_logical_bytes"] >= 16 + assert long.executed_steps > short.executed_steps + + +def test_void_and_empty_arrays_are_counted(): + x = Value("x", shape=(0,)) + b = builder(x) + b.ret() + result = IRInterpreter(b.program).run({"x": np.empty(0, dtype="float32")}, + collect_memory_stats=True) + assert result.return_value is None + assert result.memory_stats["retained_values"] == 1 + assert result.memory_stats["peak_live_logical_bytes"] == 0 + assert result.memory_stats["peak_numpy_storage_bytes"] == 0 + + +@pytest.mark.parametrize("bad", [0, 1, None, "yes"]) +def test_invalid_observation_options(bad): + b = builder() + b.ret() + with pytest.raises(IRExecutionError, match="collect_memory_stats must be boolean"): + IRInterpreter(b.program).run({}, collect_memory_stats=bad) diff --git a/tests/test_ir_interpreter_memory_scaling.py b/tests/test_ir_interpreter_memory_scaling.py new file mode 100644 index 0000000..c026fe5 --- /dev/null +++ b/tests/test_ir_interpreter_memory_scaling.py @@ -0,0 +1,423 @@ +"""Opt-in weight borrowing / last-use execution never weaken default or D8.""" + +import weakref + +import numpy as np +import pytest + +from benchmarks.ir_interpreter_cases import builder +from scratchv.frontend.onnx_parser import ONNXParseError, ONNXParser +from scratchv.ir.types import DataType as D, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def test_last_use_releases_chain_without_changing_result_or_steps(): + x = Value("x", shape=(1024,)) + b = builder(x) + current = x + for _ in range(20): + current = b.neg(current) + b.ret(current) + inputs = {"x": np.arange(1024, dtype=np.float32)} + interp = IRInterpreter(b.program) + old = interp.run(inputs, collect_memory_stats=True) + new = interp.run(inputs, memory_mode="last_use", collect_memory_stats=True) + np.testing.assert_array_equal(old.return_value, new.return_value) + assert old.executed_steps == new.executed_steps + assert old.diagnostics == new.diagnostics + assert old.memory_stats["retained_values"] == 21 + assert new.memory_stats["retained_values"] == 1 + assert new.memory_stats["reclaimed_bindings"] == 20 + assert new.memory_stats["peak_numpy_storage_bytes"] == 2 * inputs["x"].nbytes + assert old.memory_stats["peak_numpy_storage_bytes"] == 22 * inputs["x"].nbytes + assert not np.shares_memory(new.return_value, inputs["x"]) + + +def test_repeated_operand_residual_and_alias_views_survive_last_use(): + x = Value("x", shape=(2, 3)) + b = builder(x) + doubled = b.add(x, x) + view = b.transpose(b.reshape(doubled, (3, 2)), (1, 0)) + result = b.add(view, x) + b.ret(result) + source = np.arange(6, dtype="float32").reshape(2, 3) + interp = IRInterpreter(b.program) + old = interp.run({"x": source}) + new = interp.run({"x": source}, memory_mode="last_use", collect_memory_stats=True) + np.testing.assert_array_equal(old.return_value, new.return_value) + assert new.memory_stats["retained_values"] == 1 + assert not np.shares_memory(new.return_value, source) + + +def test_borrowed_weights_are_shared_readonly_and_returns_are_copied(monkeypatch): + import scratchv.verification.ir_interpreter as module + + weight = Value("w", shape=(2, 2)) + b = builder() + b.program.global_values.append(weight) + b.ret(b.transpose(weight, (1, 0))) + source = np.arange(4, dtype="float32").reshape(2, 2) + source.setflags(write=False) + original_compute = module.compute + seen = [] + + def check(instr, operands): + seen.append((np.shares_memory(operands[0], source), operands[0].flags.writeable)) + return original_compute(instr, operands) + + monkeypatch.setattr(module, "compute", check) + result = IRInterpreter(b.program).run({}, initializers={"w": source}, + memory_mode="last_use", copy_initializers=False, collect_memory_stats=True) + assert seen == [(True, False)] + assert not np.shares_memory(result.return_value, source) + result.return_value[0, 0] = 99 + assert source[0, 0] == 0 and not source.flags.writeable + assert result.memory_stats["copy_initializers"] is False + # Default still owns a separate initializer copy. + IRInterpreter(b.program).run({}, initializers={"w": source}) + assert seen[-1] == (False, True) + + +def test_borrowing_rejects_writable_arrays_without_changing_flags(): + weight = Value("w", shape=(2,)) + b = builder() + b.program.global_values.append(weight) + b.ret(weight) + source = np.ones(2, dtype=np.float32) + with pytest.raises(IRExecutionError, match="borrowed initializer must be read-only"): + IRInterpreter(b.program).run({}, initializers={"w": source}, copy_initializers=False) + assert source.flags.writeable + + +@pytest.mark.parametrize("options", [{"memory_mode": "last_use"}, {"copy_initializers": False}]) +@pytest.mark.parametrize("kind", ["branch", "loop", "memory"]) +def test_optimized_modes_fail_closed_for_control_flow_and_memory(kind, options): + b = builder() + if kind == "branch": + b.br("next") + b.new_block("next") + elif kind == "loop": + b.for_loop(0, 2) + b.endfor() + else: + b.alloca(4, D.INT32) + b.ret() + assert IRInterpreter(b.program).run({}).return_value is None + with pytest.raises(IRExecutionError, match="memory optimization requires"): + IRInterpreter(b.program).run({}, **options) + + +def test_observer_runs_before_release_is_readonly_and_does_not_pin_values(): + x = Value("x", shape=(32,)) + b = builder(x) + dead = b.neg(x) + y = b.add(x, x) + z = b.neg(y) + b.ret(z) + seen, refs = [], [] + + def observe(name, array): + assert not array.flags.writeable + assert array.base is not None + seen.append(name) + refs.append(weakref.ref(array.base)) + if name == y.name: + assert refs[0]() is None # Unused dead result was really released. + + result = IRInterpreter(b.program).run({"x": np.ones(32, dtype="float32")}, + memory_mode="last_use", observer=observe) + assert seen == [dead.name, y.name, z.name] + assert all(reference() is None for reference in refs) + np.testing.assert_array_equal(result.return_value, np.full(32, -2, dtype="float32")) + + +def test_observer_failure_has_ir_location(): + b = builder() + output = b.neg(b.make_const(1.0)) + b.ret(output) + + def broken(name, array): + raise OSError("checkpoint disk full") + + with pytest.raises(IRExecutionError, match="ObserverError.*checkpoint disk full") as caught: + IRInterpreter(b.program).run({}, memory_mode="last_use", observer=broken) + assert caught.value.value_name == output.name + assert caught.value.instruction_index == 0 + + +def test_unused_operation_still_raises_numeric_error(): + b = builder() + b.div(b.make_const(1.0), b.make_const(0.0)) + b.ret(b.make_const(7.0)) + for mode in ("retain_all", "last_use"): + with pytest.raises(IRExecutionError, match="NumericError.*division by zero") as caught: + IRInterpreter(b.program).run({}, memory_mode=mode) + assert caught.value.instruction_index == 0 + + +@pytest.mark.parametrize("options", [ + {"copy_initializers": 1}, {"memory_mode": None}, {"memory_mode": []}, + {"memory_mode": "automatic"}, {"observer": 1}, +]) +def test_invalid_options_are_rejected(options): + b = builder() + b.ret() + with pytest.raises(IRExecutionError, match="InvalidOptions"): + IRInterpreter(b.program).run({}, **options) + + +def external_model(tmp_path, *, fields=None, shape=(2, 3), trailing=0): + onnx = pytest.importorskip("onnx") + from onnx import TensorProto, helper + + data = np.arange(6, dtype="float32") + (tmp_path / "weights.bin").write_bytes(b"HEAD" + data.tobytes() + b"T" * trailing) + tensor = TensorProto(name="weight", data_type=TensorProto.FLOAT, dims=shape, + data_location=TensorProto.EXTERNAL) + for key, value in fields if fields is not None else [ + ("location", "weights.bin"), ("offset", "4"), ("length", "24") + ]: + entry = tensor.external_data.add() + entry.key, entry.value = key, value + graph = helper.make_graph([helper.make_node("Neg", ["weight"], ["output"])], + "external", [], [helper.make_tensor_value_info("output", TensorProto.FLOAT, shape)], + initializer=[tensor]) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + model.ir_version = 10 + path = tmp_path / "model.onnx" + path.write_bytes(model.SerializeToString()) + return path, data.reshape(shape) + + +def test_external_mapping_matches_default_and_borrowed_execution(tmp_path): + path, data = external_model(tmp_path) + normal, mapped = ONNXParser(), ONNXParser() + normal.parse(str(path)) + program = mapped.parse(str(path), mmap_external_data=True) + weight = mapped.initializers["weight"] + assert isinstance(weight, np.memmap) and weight.mode == "r" + assert not weight.flags.writeable + np.testing.assert_array_equal(weight, normal.initializers["weight"]) + result = IRInterpreter(program).run({}, initializers=mapped.initializers, + copy_initializers=False, memory_mode="last_use") + np.testing.assert_array_equal(result.return_value, -data) + assert not np.shares_memory(result.return_value, weight) + with pytest.raises(ValueError): + weight[0, 0] = 999 + + +def test_external_mapping_does_not_require_python39_path_api(tmp_path, monkeypatch): + from pathlib import Path + + import scratchv.frontend.onnx_parser as parser_module + + class Python38Path(type(Path())): + @property + def is_relative_to(self): + raise AttributeError("is_relative_to is unavailable on Python 3.8") + + def relative_to(self, *other): + # Newer pathlib implementations internally call is_relative_to; + # delegate through an ordinary Path while hiding that newer API + # from the parser under test, rather than patching global pathlib. + return Path(str(self)).relative_to(*other) + + path, expected = external_model(tmp_path) + parser = ONNXParser() + monkeypatch.setattr(parser_module, "Path", Python38Path) + assert not hasattr(Python38Path(), "is_relative_to") + parser.parse(str(path), mmap_external_data=True) + weight = parser.initializers["weight"] + assert isinstance(weight, np.memmap) and not weight.flags.writeable + np.testing.assert_array_equal(weight, expected) + + +@pytest.mark.parametrize("fields, message", [ + ([("location", "../weights.bin")], "model directory"), + ([("location", "C:\\weights.bin")], "model directory"), + ([("location", "weights.bin"), ("location", "weights.bin")], "Duplicate external"), + ([("location", "weights.bin"), ("offset", "-1")], "nonnegative integer"), + ([("location", "weights.bin"), ("length", "20")], "disagrees"), + ([("location", "weights.bin"), ("offset", "5")], "exceeds file"), + ([("location", "missing.bin")], "exceeds file"), + ([("location", "weights.bin"), ("length", "24"), ("unknown", "x")], "Unsupported external"), +]) +def test_external_mapping_rejects_unsafe_or_mismatched_metadata(tmp_path, fields, message): + path, _ = external_model(tmp_path, fields=fields) + with pytest.raises(ONNXParseError, match=message): + ONNXParser().parse(str(path), mmap_external_data=True) + + +def test_default_external_loader_option_is_strict(tmp_path): + path, _ = external_model(tmp_path) + with pytest.raises(ONNXParseError, match="mmap_external_data must be boolean"): + ONNXParser().parse(str(path), mmap_external_data=1) + + +def test_external_constant_node_uses_same_readonly_mapping(tmp_path): + import onnx + from onnx import helper + + path, expected = external_model(tmp_path) + model = onnx.load(str(path), load_external_data=False) + tensor = model.graph.initializer[0] + node = helper.make_node("Constant", [], ["weight"], value=tensor) + model.graph.node.insert(0, node) + del model.graph.initializer[:] + path.write_bytes(model.SerializeToString()) + parser = ONNXParser() + parser.parse(str(path), mmap_external_data=True) + assert isinstance(parser.initializers["weight"], np.memmap) + assert not parser.initializers["weight"].flags.writeable + np.testing.assert_array_equal(parser.initializers["weight"], expected) + + +def test_mapper_does_not_materialize_proto_or_read_outside_declared_range(tmp_path, monkeypatch): + import onnx + + path, expected = external_model(tmp_path, trailing=64) + tensor = onnx.load(str(path), load_external_data=False).graph.initializer[0] + parser = ONNXParser() + parser._base_dir = str(tmp_path) + + def forbidden(*args, **kwargs): + raise AssertionError("mmap loader must not materialize external protobuf data") + + monkeypatch.setattr(onnx.external_data_helper, "load_external_data_for_tensor", forbidden) + array = parser._map_external_initializer(tensor) + assert not tensor.HasField("raw_data") + assert array.nbytes == 24 and array.offset == 4 + np.testing.assert_array_equal(array, expected) + + +def test_parser_mapping_option_resets_between_calls(tmp_path): + path, _ = external_model(tmp_path) + parser = ONNXParser() + parser.parse(str(path), mmap_external_data=True) + assert isinstance(parser.initializers["weight"], np.memmap) + parser.parse(str(path)) + assert not isinstance(parser.initializers["weight"], np.memmap) + + +@pytest.mark.parametrize("dtype", ["float32", "float64", "int32", "int64"]) +@pytest.mark.parametrize("shape", [(), (0,), (2, 3)]) +def test_mapping_all_supported_numeric_types_scalar_and_empty_shapes(tmp_path, dtype, shape): + from onnx import TensorProto, helper + + values = np.arange(np.prod(shape, dtype=int), dtype=dtype).reshape(shape) + (tmp_path / "weights.bin").write_bytes(b"HEAD" + values.tobytes()) + tensor_dtype = helper.np_dtype_to_tensor_dtype(values.dtype) + tensor = TensorProto(name="weight", data_type=tensor_dtype, dims=shape, + data_location=TensorProto.EXTERNAL) + for key, value in [("location", "weights.bin"), ("offset", "4"), + ("length", str(values.nbytes))]: + entry = tensor.external_data.add() + entry.key, entry.value = key, value + graph = helper.make_graph([helper.make_node("Neg", ["weight"], ["output"])], + "external_types", [], [helper.make_tensor_value_info("output", tensor_dtype, shape)], + initializer=[tensor]) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + model.ir_version = 10 + path = tmp_path / "model.onnx" + path.write_bytes(model.SerializeToString()) + parser = ONNXParser() + program = parser.parse(str(path), mmap_external_data=True) + assert not parser.initializers["weight"].flags.writeable + result = IRInterpreter(program).run({}, initializers=parser.initializers, + memory_mode="last_use", copy_initializers=False) + assert result.return_value.dtype == values.dtype + assert result.return_value.shape == shape + np.testing.assert_array_equal(result.return_value, -values) + + +def test_memmap_backing_accounting_includes_mapping_alignment_and_deduplicates_views(tmp_path): + import mmap + + from scratchv.verification.ir_interpreter import _MemoryObservation, _backing_storage + + offset = mmap.ALLOCATIONGRANULARITY + 4 + path = tmp_path / "aligned.bin" + path.write_bytes(b"x" * offset + np.arange(6, dtype="float32").tobytes()) + mapped = np.memmap(path, mode="r", dtype="float32", offset=offset, shape=(2, 3)) + alias = mapped.T + owner, mapped_bytes = _backing_storage(mapped) + assert owner == _backing_storage(alias)[0] + assert mapped_bytes == mapped.nbytes + offset % mmap.ALLOCATIONGRANULARITY + logical, storage = _MemoryObservation.snapshot({"mapped": mapped, "alias": alias}) + assert logical == 2 * mapped.nbytes + assert storage == mapped_bytes # Mapping span, deliberately not physical RSS. + second = np.memmap(path, mode="r", dtype="float32", offset=offset, shape=(2, 3)) + assert _backing_storage(second)[0] != owner + assert _MemoryObservation.snapshot({"first": mapped, "second": second})[1] == 2 * mapped_bytes + + +def test_executed_dead_nonfinite_constant_is_not_skipped(): + b = builder() + b.load_const(float("nan")) + b.ret(b.make_const(7.0)) + for mode in ("retain_all", "last_use"): + with pytest.raises(IRExecutionError, match="NumericError.*nonfinite"): + IRInterpreter(b.program).run({}, memory_mode=mode) + + +def test_unreferenced_nan_initializer_keeps_original_binding_scope(): + unused = Value("unused", shape=(2,)) + b = builder() + b.program.global_values.append(unused) + b.ret(b.make_const(7.0)) + values = np.array([np.nan, np.nan], dtype="float32") + values.setflags(write=False) + # Neither mode validates/evaluates global tensors never referenced by this + # function; dead *instructions* remain evaluated, as tested separately. + for mode in ("retain_all", "last_use"): + result = IRInterpreter(b.program).run({}, initializers={"unused": values}, + memory_mode=mode, copy_initializers=False) + assert result.return_value == 7 + + +def test_mapper_rejects_symlink_resolving_outside_model_directory(tmp_path, monkeypatch): + from pathlib import Path + + from onnx import TensorProto + + base = tmp_path / "model" + base.mkdir() + outside = tmp_path / "outside.bin" + outside.write_bytes(np.ones(1, dtype="float32").tobytes()) + link_path = base / "weights.bin" + original = Path.resolve + + def emulate_symlink(path, *args, **kwargs): + # Cross-platform exercise of the resolved-path boundary; creating real + # Windows symlinks can require a privilege unrelated to this contract. + if path == link_path: + return outside + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", emulate_symlink) + tensor = TensorProto(name="weight", data_type=TensorProto.FLOAT, dims=(1,), + data_location=TensorProto.EXTERNAL) + entry = tensor.external_data.add() + entry.key, entry.value = "location", "weights.bin" + parser = ONNXParser() + parser._base_dir = str(base) + with pytest.raises(ONNXParseError, match="escapes the model directory"): + parser._map_external_initializer(tensor) + + +def test_observer_accidental_write_fails_and_preserves_borrowed_weight(): + weight = Value("w", shape=(2, 2)) + b = builder() + b.program.global_values.append(weight) + b.ret(b.transpose(weight, (1, 0))) + source = np.arange(4, dtype="float32").reshape(2, 2) + expected = source.copy() + source.setflags(write=False) + + def observe(name, array): + array[0, 0] = 999 + + with pytest.raises(IRExecutionError, match="ObserverError.*read-only"): + IRInterpreter(b.program).run({}, initializers={"w": source}, + memory_mode="last_use", copy_initializers=False, observer=observe) + np.testing.assert_array_equal(source, expected) diff --git a/tests/test_ir_interpreter_ops.py b/tests/test_ir_interpreter_ops.py index cf8b764..999a605 100644 --- a/tests/test_ir_interpreter_ops.py +++ b/tests/test_ir_interpreter_ops.py @@ -20,7 +20,8 @@ def operation(op, arrays, attrs=None, result_dtype=None): "int32": D.INT32, "int64": D.INT64, } - params = [Value(f"x{i}", dtype_map[str(x.dtype)]) for i, x in enumerate(arrays)] + params = [Value(f"x{i}", dtype_map[str(x.dtype)], shape=x.shape) + for i, x in enumerate(arrays)] b = builder(*params) result = Value("result", result_dtype or params[0].dtype) b.current_block.add(Instruction(op, result, params, attrs or {})) @@ -289,7 +290,7 @@ def test_gather_static_types_and_builder_precision(): result_dtype=D.FLOAT64, ) assert not verify_ir(p)[0] - x = Value("x", D.FLOAT64) + x = Value("x", D.FLOAT64, shape=(2,)) b = builder(x) result = b.sqrt(b.reduce_mean(b.mul(x, x), (0,), False)) b.ret(result) diff --git a/tests/test_legacy_gemm_scales.py b/tests/test_legacy_gemm_scales.py new file mode 100644 index 0000000..1142cc5 --- /dev/null +++ b/tests/test_legacy_gemm_scales.py @@ -0,0 +1,27 @@ +"""Legacy scalar entry points must not discard preserved GEMM coefficients.""" + +import pytest + +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.llvm_codegen import LLVMCodegen +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value + + +@pytest.mark.parametrize("backend", ["riscv", "llvm"]) +@pytest.mark.parametrize("scales", [{"alpha": 2.0}, {"beta": 0.0}, {"alpha": -0.5, "beta": 3.0}]) +def test_direct_legacy_codegen_rejects_nondefault_gemm_scaling(backend, scales): + # The normal CompilerDriver already refuses tensor buffers for these + # backends. Exercise the public direct APIs too, without that protection. + a = Value("a", shape=(2, 3)) + b = Value("b", shape=(3, 2)) + bias = Value("bias", shape=(2,)) + builder = IRBuilder() + builder.new_function("main", [a, b, bias]) + builder.new_block("entry") + builder.ret(builder.gemm(a, b, bias, **scales)) + with pytest.raises(ValueError, match="GEMM.*alpha/beta"): + if backend == "riscv": + InstructionSelector(builder.program).run() + else: + LLVMCodegen(builder.program).emit() diff --git a/tests/test_licm_safety.py b/tests/test_licm_safety.py index 68fe4fb..0d04c82 100644 --- a/tests/test_licm_safety.py +++ b/tests/test_licm_safety.py @@ -199,9 +199,12 @@ def test_mean_checks_empty_dimensions_and_intermediate_sum_overflow(): assert not before_for(b, unbounded) and not before_for(b, empty_mean) -def test_unknown_shapes_and_runtime_indices_do_not_establish_proofs(): - x = Value("x", D.INT32) - y = Value("y", D.INT32) +@pytest.mark.parametrize("xshape,yshape", [((), ()), ((2,), (3,))]) +def test_unproven_shapes_and_runtime_indices_do_not_establish_proofs(xshape, yshape): + # HoistSafety conservatively treats () as unproven. Execution still binds + # a real scalar; the second case preserves the incompatible-vector hazard. + x = Value("x", D.INT32, shape=xshape) + y = Value("y", D.INT32, shape=yshape) data = Value("data", D.INT32, shape=(2,)) index = Value("index", D.INT64) b = builder(x, y, data, index) @@ -213,8 +216,8 @@ def test_unknown_shapes_and_runtime_indices_do_not_establish_proofs(): compare( b, { - "x": np.ones(2, dtype="int32"), - "y": np.ones(3, dtype="int32"), + "x": np.ones(xshape, dtype="int32"), + "y": np.ones(yshape, dtype="int32"), "data": np.ones(2, dtype="int32"), "index": np.array(5, dtype="int64"), }, diff --git a/tests/test_native_value_bindings.py b/tests/test_native_value_bindings.py new file mode 100644 index 0000000..9028d3e --- /dev/null +++ b/tests/test_native_value_bindings.py @@ -0,0 +1,96 @@ +"""Native scalar code must resolve named IR bindings before literal hints.""" + +import numpy as np +import pytest + +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.register_alloc import RegisterAllocator +from scratchv.backend.riscv_encoder import RISCVAEncoder +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Value +from scratchv.simulator.rv32_emulator import REG_ID, RV32Emulator +from scratchv.verification.ir_interpreter import IRInterpreter + + +def _execute_native(program, inputs=None): + """Encode actual RV32 instructions and supply scalar entry bindings.""" + allocator = RegisterAllocator(InstructionSelector(program).run(), mode="greedy") + assembly = AsmEmitter(allocator.run()).emit() + machine = RV32Emulator(mem_size=4 * 1024 * 1024) + machine.load_code(RISCVAEncoder().assemble(assembly)) + for name, value in (inputs or {}).items(): + # The scalar selector exposes virtual inputs; bind their allocated + # entry registers as the caller would, independently of literal hints. + if name in allocator.register_map: + machine.regs[REG_ID[allocator.register_map[name]]] = int(value) + assert machine.run(max_instr=128) < 128 + return machine.regs[REG_ID["a0"]] + + +@pytest.mark.parametrize("binding", ["parameter", "hinted_parameter", "load", "load_const", "arithmetic"]) +def test_named_binding_wins_over_conflicting_literal_hint(binding): + builder = IRBuilder() + inputs = {} + parameter = Value("x", DataType.INT32) + if binding == "hinted_parameter": + parameter.is_constant = True + parameter.const_value = 99 + builder.new_function("main", [parameter] if "parameter" in binding else []) + builder.new_block("entry") + if "parameter" in binding: + result = parameter + inputs["x"] = np.array(7, dtype=np.int32) + elif binding == "load": + pointer = builder.alloca(4, DataType.INT32) + builder.store(pointer, builder.load_const(7, DataType.INT32)) + result = builder.load(pointer) + elif binding == "load_const": + result = builder.load_const(7, DataType.INT32) + else: + result = builder.add(builder.load_const(3, DataType.INT32), + builder.load_const(4, DataType.INT32)) + # A reference can be a separate Value object; the name determines its + # binding. Existing IR contracts permit this stale constant hint. + builder.ret(Value(result.name, DataType.INT32, is_constant=True, const_value=99)) + reference = IRInterpreter(builder.program).run(inputs).return_value + assert reference.item() == 7 + assert _execute_native(builder.program, inputs) == reference.item() + + +def test_unbound_literal_still_executes_as_an_immediate(): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + builder.ret(Value("literal", DataType.INT32, is_constant=True, const_value=23)) + assert _execute_native(builder.program) == 23 + + +@pytest.mark.parametrize("hint", [None, 99]) +def test_scalar_global_definition_wins_over_reference_metadata(hint): + builder = IRBuilder() + builder.program.global_values.append(Value( + "weight", DataType.INT32, is_constant=True, const_value=7, + )) + builder.new_function("main") + builder.new_block("entry") + builder.ret(Value("weight", DataType.INT32, is_constant=hint is not None, const_value=hint)) + reference = IRInterpreter(builder.program).run({}).return_value + assert reference.item() == 7 + assert _execute_native(builder.program) == reference.item() + + +def test_binding_names_are_scoped_to_the_current_function(): + builder = IRBuilder() + parameter = Value("shared", DataType.INT32) + builder.new_function("first", [parameter]) + builder.new_block("entry") + builder.ret(parameter) + builder.new_function("second") + builder.new_block("entry") + builder.ret(Value("shared", DataType.INT32, is_constant=True, const_value=23)) + selected = InstructionSelector(builder.program).run() + returns = [instruction for instruction in selected if instruction.comment == "return value"] + assert [(instruction.src1.kind, instruction.src1.value) for instruction in returns] == [ + ("vreg", "shared"), ("imm", 23), + ] diff --git a/tests/test_numeric_metrics.py b/tests/test_numeric_metrics.py index 488f2d6..6f4aee8 100644 --- a/tests/test_numeric_metrics.py +++ b/tests/test_numeric_metrics.py @@ -123,9 +123,11 @@ def observed(*args): assert len(lengths) > 2 and max(lengths) <= 127 -def test_new_metrics_do_not_weaken_absolute_gate(): +@pytest.mark.parametrize("gate", ["w2", "w3"]) +def test_new_metrics_do_not_weaken_absolute_gate(gate): from probes.w2_qwen3_small.diagnostics import tensor_diff - compare = tensor_diff + from probes.w3_qwen3_full.comparison import compare_tensor + compare = tensor_diff if gate == "w2" else compare_tensor actual = np.array([2, 4], dtype=np.float32) expected = np.array([1, 2], dtype=np.float32) result = compare(actual, expected) @@ -143,8 +145,10 @@ def test_new_metrics_do_not_weaken_absolute_gate(): def test_empty_gate_policies_are_preserved(): from probes.w2_qwen3_small.diagnostics import tensor_diff + from probes.w3_qwen3_full.comparison import compare_tensor array = np.empty(0, dtype=np.float32) - row = tensor_diff(array, array) - assert row["passed"] - assert row["relative_l2"] is None and row["cosine_similarity"] is None - assert row["relative_l2_reason"] and row["cosine_similarity_reason"] + w2, w3 = tensor_diff(array, array), compare_tensor(array, array) + assert w2["passed"] and not w3["passed"] + for row in (w2, w3): + assert row["relative_l2"] is None and row["cosine_similarity"] is None + assert row["relative_l2_reason"] and row["cosine_similarity_reason"] diff --git a/tests/test_onnx_operator_benchmark.py b/tests/test_onnx_operator_benchmark.py index f0067b6..e50e398 100644 --- a/tests/test_onnx_operator_benchmark.py +++ b/tests/test_onnx_operator_benchmark.py @@ -146,6 +146,8 @@ def test_failed_round_cannot_retain_success_median(): assert merged["status"] == "NUMERIC_ERROR" and merged["run_median_s"] is None +@pytest.mark.skipif(not (bench.ROOT / ".git").exists(), + reason="Historical baseline integration requires this source root's Git checkout; archives omit history") def test_actual_git_baseline_and_dirty_snapshot_are_isolated(tmp_path): original_hash = worker.source_hash(bench.ROOT) selected = ["add_float32", "gather", "scalar_fp32_add_chain", "singleton_initializer", "constant"] @@ -171,21 +173,58 @@ def test_actual_git_baseline_and_dirty_snapshot_are_isolated(tmp_path): def test_worker_timeout_is_visible_in_new_report(tmp_path, monkeypatch): actual_run = subprocess.run + attempts = [] + + def fake_snapshots(work, baseline_ref): + # Timeout handling is independent of Git history. Real tiny source + # directories still satisfy the runner's post-execution hash check. + versions = [] + for side in ("before", "after"): + source = work / side + (source / "scratchv").mkdir(parents=True) + (source / "scratchv" / "__init__.py").write_text("# timeout fixture\n") + versions.append({"source_path": str(source), "source_sha256": worker.source_hash(source)}) + versions[0]["commit"] = baseline_ref + versions[1].update(head="fixture-current", dirty=False) + return versions def timeout(command, **kwargs): if command[0] == sys.executable: + attempts.append(command) raise subprocess.TimeoutExpired(command, 60) return actual_run(command, **kwargs) + monkeypatch.setattr(bench, "snapshots", fake_snapshots) monkeypatch.setattr(subprocess, "run", timeout) assert bench.main(["--output-dir", str(tmp_path), "--case", "add_float32", "--warmup", "0", "--repeats", "1"]) == 1 report = json.loads((tmp_path / "report.json").read_text(encoding="utf-8")) assert not report["passed"] + assert len(attempts) == 4 + assert report["results"][0]["before"]["status"] == "WORKER_ERROR" assert report["results"][0]["after"]["status"] == "WORKER_ERROR" assert "timed out" in report["results"][0]["after"]["error"] +def test_archive_cannot_discover_parent_git_and_reports_clear_failure(tmp_path, monkeypatch): + (tmp_path / ".git").mkdir() + archive = tmp_path / "extracted-source" + archive.mkdir() + monkeypatch.setattr(bench, "ROOT", archive) + + def no_parent_git(*args, **kwargs): + pytest.fail("Source archive must reject history comparison before invoking Git") + + monkeypatch.setattr(subprocess, "run", no_parent_git) + with pytest.raises(RuntimeError, match="requires a Git checkout"): + bench.git("rev-parse", "HEAD") + output = tmp_path / "report" + assert bench.main(["--output-dir", str(output), "--case", "add_float32"]) == 1 + report = json.loads((output / "report.json").read_text(encoding="utf-8")) + assert not report["passed"] and not report["results"] + assert "extracted source archive has no Git history" in report["runner_error"] + + def test_preparation_failure_replaces_stale_pass_report(tmp_path, monkeypatch): (tmp_path / "report.html").write_text("stale PASS", encoding="utf-8") diff --git a/tests/test_onnx_qwen_numeric_ops.py b/tests/test_onnx_qwen_numeric_ops.py index b5831f1..3455b9d 100644 --- a/tests/test_onnx_qwen_numeric_ops.py +++ b/tests/test_onnx_qwen_numeric_ops.py @@ -33,8 +33,11 @@ def compare(path, feed, *, exact=False, reference_evaluator=False): program = parser.parse(str(path)) assert verify_ir(program) == (True, []) actual = IRInterpreter(program).run(feed, initializers=parser.initializers).return_value + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 session = (ReferenceEvaluator(str(path)) if reference_evaluator else - ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])) + ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"])) reference = session.run(None, feed)[0] assert actual.shape == reference.shape assert actual.dtype == reference.dtype diff --git a/tests/test_onnx_semantic_boundaries.py b/tests/test_onnx_semantic_boundaries.py new file mode 100644 index 0000000..b77ebb5 --- /dev/null +++ b/tests/test_onnx_semantic_boundaries.py @@ -0,0 +1,84 @@ +"""Operator versions and optional attributes must not silently change ONNX.""" + +import numpy as np +import onnx +import pytest +from onnx import TensorProto, helper, numpy_helper + +from scratchv.frontend.onnx_parser import ONNXParseError, ONNXParser +from scratchv.verification.ir_interpreter import IRInterpreter + +ort = pytest.importorskip("onnxruntime") + + +def _model(tmp_path, node, data, output_shape, *, arrays=None, opset=18): + graph = helper.make_graph( + [node], "semantic_boundary", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, data.shape)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, output_shape)], + [numpy_helper.from_array(value, name) for name, value in (arrays or {}).items()], + ) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", opset)], ir_version=10) + onnx.checker.check_model(model) + path = tmp_path / "model.onnx" + onnx.save(model, path) + expected = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"]).run(None, {"x": data})[0] + return path, expected + + +def _compare(path, data, expected): + parser = ONNXParser() + program = parser.parse(str(path)) + actual = IRInterpreter(program).run({"x": data}, initializers=parser.initializers).return_value + assert actual.shape == expected.shape and actual.dtype == expected.dtype + np.testing.assert_allclose(actual, expected, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("alpha,beta", [(2.0, 3.0), (-0.5, 0.25), (0.0, 2.0), (1.0, 1.0)]) +@pytest.mark.parametrize("trans_a,trans_b", [(0, 0), (1, 1)]) +@pytest.mark.parametrize("bias", [True, False]) +def test_gemm_preserves_coefficients_transpose_and_optional_bias(tmp_path, alpha, beta, trans_a, trans_b, bias): + data = np.array([[1, 2, 3], [4, 5, 6]], np.float32) + weights = np.array([[2, -1], [3, 2], [-2, 4]], np.float32) + data = data.T.copy() if trans_a else data + weights = weights.T.copy() if trans_b else weights + arrays = {"w": weights} + names = ["x", "w"] + if bias: + arrays["b"] = np.array([5, -3], np.float32) + names.append("b") + node = helper.make_node("Gemm", names, ["y"], alpha=alpha, beta=beta, transA=trans_a, transB=trans_b) + path, expected = _model(tmp_path, node, data, [2, 2], arrays=arrays) + _compare(path, data, expected) + + +@pytest.mark.parametrize("opset", [11, 12]) +def test_legacy_softmax_fails_explicitly_instead_of_normalizing_only_last_axis(tmp_path, opset): + data = np.arange(24, dtype=np.float32).reshape(2, 3, 4) + path, expected = _model(tmp_path, helper.make_node("Softmax", ["x"], ["y"]), data, data.shape, opset=opset) + # Legacy default axis=1 flattens dimensions 1..rank-1 into each row. + np.testing.assert_allclose(expected.reshape(2, -1).sum(axis=1), np.ones(2), atol=1e-6) + with pytest.raises(ONNXParseError, match="Softmax.*opset.*13"): + ONNXParser().parse(str(path)) + + +@pytest.mark.parametrize("opset", [13, 18]) +def test_modern_softmax_default_last_axis_matches_ort(tmp_path, opset): + data = np.arange(24, dtype=np.float32).reshape(2, 3, 4) + path, expected = _model(tmp_path, helper.make_node("Softmax", ["x"], ["y"]), data, data.shape, opset=opset) + _compare(path, data, expected) + + +@pytest.mark.parametrize("approximate", [None, "none"]) +def test_exact_gelu_fails_explicitly_instead_of_silently_using_tanh(tmp_path, approximate): + data = np.array([-3, -2, -1, 0, 1, 2, 3], np.float32) + attrs = {} if approximate is None else {"approximate": approximate} + path, _ = _model(tmp_path, helper.make_node("Gelu", ["x"], ["y"], **attrs), data, data.shape, opset=20) + with pytest.raises(ONNXParseError, match="Gelu.*tanh"): + ONNXParser().parse(str(path)) + + +def test_gelu_explicit_tanh_matches_ort(tmp_path): + data = np.array([-3, -2, -1, 0, 1, 2, 3], np.float32) + path, expected = _model(tmp_path, helper.make_node("Gelu", ["x"], ["y"], approximate="tanh"), data, data.shape, opset=20) + _compare(path, data, expected) diff --git a/tests/test_qwen3_medium_model.py b/tests/test_qwen3_medium_model.py new file mode 100644 index 0000000..43a1b67 --- /dev/null +++ b/tests/test_qwen3_medium_model.py @@ -0,0 +1,104 @@ +"""Pinned-stack regressions for config-driven observers and the medium preset.""" + +import numpy as np +import pytest + +torch = pytest.importorskip("torch") +transformers = pytest.importorskip("transformers") +if transformers.__version__ != "4.51.3": + pytest.skip("Qwen observers require transformers==4.51.3", allow_module_level=True) + +from probes.w2_qwen3_small.model import MODEL_CONFIG, build_model, export_onnx +from probes.w2_qwen3_small.diagnostics import tensor_diff +from probes.w3_qwen3_medium.preset import MEDIUM_CONFIG, build_medium_model +from probes.w3_qwen3_medium.run import trace_schema + + +@pytest.fixture(autouse=True) +def one_thread(): + previous = torch.get_num_threads() + torch.set_num_threads(1) + yield + torch.set_num_threads(previous) + + +def execute(wrapper): + length = wrapper.sequence_length + ids = (torch.arange(length).reshape(1, length) % (wrapper.model.config.vocab_size - 1) + 1).long() + mask = torch.zeros((1, 1, length, length), dtype=torch.float32) + mask.masked_fill_(torch.triu(torch.ones((length, length), dtype=torch.bool), 1), + torch.finfo(torch.float32).min) + with torch.inference_mode(), wrapper.capture(): + tensors = wrapper(ids, mask) + return (ids, mask), dict(zip(wrapper.output_names, tensors)) + + +def test_medium_preset_and_all_observed_shapes(): + wrapper = build_medium_model(seed=31) + assert (wrapper.model.config.num_hidden_layers, wrapper.model.config.hidden_size, + wrapper.model.config.intermediate_size) == (6, 64, 192) + assert len(wrapper.output_names) == 3 + 13 * 6 == 81 + assert wrapper.config_metadata["weights"].startswith("fixed-seed random") + assert wrapper.config_metadata["dtype"] == "float32" + assert wrapper.config_metadata["use_cache"] is False + assert wrapper.model.lm_head.weight is wrapper.model.model.embed_tokens.weight + _, values = execute(wrapper) + for name, value in values.items(): + assert list(value.shape) == wrapper.checkpoint_metadata[name]["shape"], name + assert value.dtype == torch.float32 and bool(torch.isfinite(value).all()) + schema = trace_schema(wrapper.checkpoint_metadata) + assert len(schema["checkpoints"]) == 81 + assert [row["layer"] for row in schema["checkpoints"] if row["checkpoint"] == "residual"] == list(range(6)) + assert wrapper.config_metadata["hidden_size"] == 64 + assert MODEL_CONFIG["hidden_size"] == 32 + + +def test_config_changes_every_schema_dimension_and_export_contract(tmp_path): + onnx = pytest.importorskip("onnx") + ort = pytest.importorskip("onnxruntime") + settings = {"hidden_size": 48, "intermediate_size": 80, "num_hidden_layers": 1, + "num_attention_heads": 6, "num_key_value_heads": 3, "head_dim": 8, + "vocab_size": 29, "max_position_embeddings": 11} + original = dict(settings) + wrapper = build_model(seed=41, model_config=settings, sequence_length=7) + assert settings == original + assert len(wrapper.output_names) == 16 + assert wrapper.config_metadata["position_ids"] == "0..6, including right-padding positions" + inputs, values = execute(wrapper) + assert list(values["layer_0.q_norm"].shape) == [1, 7, 6, 8] + assert list(values["layer_0.k_norm"].shape) == [1, 7, 3, 8] + assert list(values["layer_0.v_proj"].shape) == [1, 7, 24] + assert list(values["layer_0.attn_context"].shape) == [1, 7, 48] + assert list(values["logits"].shape) == [1, 7, 29] + for name, value in values.items(): + assert list(value.shape) == wrapper.checkpoint_metadata[name]["shape"] + path = tmp_path / "custom.onnx" + export_onnx(wrapper, path, *inputs) + model = onnx.load(path) + assert [dim.dim_value for dim in model.graph.input[0].type.tensor_type.shape.dim] == [1, 7] + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + session = ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"]) + actual = session.run(None, {"input_ids": inputs[0].numpy(), "attention_mask": inputs[1].numpy()}) + assert all(tensor_diff(got, values[name].numpy(), atol=1e-5)["passed"] + for name, got in zip(wrapper.output_names, actual)) + + +@pytest.mark.parametrize("override,match", [ + ({"num_hidden_layers": 0}, "positive integer"), + ({"num_attention_heads": 5}, "divisible"), + ({"head_dim": 15}, "even"), + ({"hidden_size": True}, "positive integer"), + ({"use_cache": True}, "use_cache=False"), + ({"mystery": 5}, "Unsupported"), +]) +def test_invalid_probe_configs_fail_before_initialization(override, match): + with pytest.raises(ValueError, match=match): + build_model(model_config=override) + + +def test_sequence_length_cannot_exceed_model_positions(): + with pytest.raises(ValueError, match="exceeds"): + build_model(sequence_length=257) diff --git a/tests/test_qwen3_medium_probe.py b/tests/test_qwen3_medium_probe.py new file mode 100644 index 0000000..b99c63a --- /dev/null +++ b/tests/test_qwen3_medium_probe.py @@ -0,0 +1,180 @@ +"""Fail-closed numerical evidence and independent ordinary/diagnostic paths.""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +pytest.importorskip("onnx") + +from probes.w3_qwen3_medium import run as probe +from scratchv.verification.ir_interpreter import IRInterpreter + + +@pytest.mark.parametrize("kind", probe.GRAPH_KINDS) +@pytest.mark.parametrize("level", probe.LEVELS) +def test_one_failed_graph_or_optimization_makes_case_fail(kind, level): + case = {"capture_preserves_logits": {"passed": True}, "attention_checks": [{"passed": True}], + "ort": {key: {"passed": True} for key in probe.GRAPH_KINDS}, + "ir": {opt: {key: {"passed": True} for key in probe.GRAPH_KINDS} for opt in probe.LEVELS}} + assert probe.case_passed(case) + case["ir"][level][kind]["passed"] = False + assert not probe.case_passed(case) + + +@pytest.mark.parametrize("kind", probe.GRAPH_KINDS) +@pytest.mark.parametrize("fault", ["value", "nonfinite", "shape", "exception"]) +def test_ir_fault_is_failed_and_actual_trace_is_preserved(tmp_path, monkeypatch, kind, fault): + expected = np.ones((1, 2, 3), np.float32) + actual = expected.copy() + if fault == "value": + actual.flat[1] += 0.25 + elif fault == "nonfinite": + actual.flat[1] = np.nan + elif fault == "shape": + actual = np.ones((1, 2, 4), np.float32) + + def execute(self, *args, **kwargs): + assert kwargs["collect_memory_stats"] is True + if fault == "exception": + raise RuntimeError("injected execution failure") + return SimpleNamespace(return_value=actual.ravel() if kind == "diagnostic" else actual, + executed_steps=5, memory_stats={"retained_values": 2}) + + monkeypatch.setattr(IRInterpreter, "run", execute) + report = {"trace_artifacts": []} + result = probe.run_ir_variant(None, {}, {}, kind=kind, level="all", + schema=[{"name": "logits", "shape": [1, 2, 3], "offset": 0, "size": 6}], + names=["logits"], metadata={"logits": {"sequence_axis": 1}}, valid_length=1, + reference={"logits": expected}, ort_reference={"logits": expected}, + out=tmp_path, case_name="fault", report=report) + assert result["passed"] is False + assert result["status"] in ("runtime_error", "numeric_failed") + if fault in ("value", "nonfinite") or (fault == "shape" and kind == "normal"): + assert len(report["trace_artifacts"]) == 1 + with np.load(tmp_path / result["trace"]["path"]) as data: + np.testing.assert_array_equal(data["logits"], actual) + assert not result["pytorch_comparison"]["passed"] + if fault == "exception": + assert "injected execution failure" in result["error"] + + +def test_missing_ort_reference_fails_even_when_ir_matches_pytorch(tmp_path, monkeypatch): + expected = np.ones((1, 2, 3), np.float32) + monkeypatch.setattr(IRInterpreter, "run", lambda *args, **kwargs: SimpleNamespace( + return_value=expected.copy(), executed_steps=1, memory_stats={})) + result = probe.run_ir_variant(None, {}, {}, kind="normal", level="none", schema=[], + names=["logits"], metadata={"logits": {"sequence_axis": 1}}, valid_length=2, + reference={"logits": expected}, ort_reference=None, out=tmp_path, + case_name="missing", report={"trace_artifacts": []}) + assert result["pytorch_comparison"]["passed"] + assert not result["passed"] + assert result["ort_comparison"]["error"] == "ORT reference unavailable" + + +@pytest.mark.parametrize("query_index,passed", [(12, False), (100, True)]) +def test_causal_isolation_observes_nonlogit_checkpoint_on_its_sequence_axis(tmp_path, query_index, passed): + reference = {"layer_0.rope_q": np.ones((1, 4, 256, 16), np.float32), + "logits": np.ones((1, 256, 128), np.float32)} + changed = {name: array.copy() for name, array in reference.items()} + changed["layer_0.rope_q"][0, 0, query_index, 0] += 0.5 + for case_name, data in (("full_seed_0", reference), ("changed_future", changed)): + folder = tmp_path / "traces" / case_name + folder.mkdir(parents=True) + np.savez(folder / "reference.npz", **data) + rows = probe.isolation_checks(tmp_path, {"layer_0.rope_q": {"sequence_axis": 2}, + "logits": {"sequence_axis": 1}}) + row = next(row for row in rows if row["name"] == "causality" and row["backend"] == "pytorch") + assert row["passed"] is passed + assert row["comparison"]["first_divergence"] == (None if passed else "layer_0.rope_q") + + +def test_cli_exception_writes_failure_reports_and_refuses_overwrite(tmp_path, monkeypatch): + import json + + def fail(out, report, seed): + report["stage"] = "injected" + raise RuntimeError("injected gate failure") + + monkeypatch.setattr(probe, "run_probe", fail) + out = tmp_path / "evidence" + assert probe.main(["--output-dir", str(out)]) == 1 + report = json.loads((out / "report.json").read_text(encoding="utf-8")) + assert report["passed"] is False + assert report["status"] == "FAIL" + assert report["error"] == "RuntimeError: injected gate failure" + assert report["stage"] == "injected" + assert (out / "report.md").is_file() and (out / "report.html").is_file() + with pytest.raises(SystemExit) as caught: + probe.main(["--output-dir", str(out)]) + assert caught.value.code == 2 + + +def complete_numeric_report(): + return { + "cases": [ + {"name": name, "valid_length": length, "passed": True, + "ort": {graph: {"passed": True} for graph in probe.GRAPH_KINDS}, + "ir": {level: {graph: {"passed": True} for graph in probe.GRAPH_KINDS} + for level in probe.LEVELS}} + for name, length in probe.CASE_IDENTITIES + ], + "invariants": [{"name": name, "backend": backend, "graph": graph, "passed": True} + for name, backend, graph in sorted(probe.INVARIANT_IDENTITIES)], + } + + +@pytest.mark.parametrize("omission", ["case", "all_cases", "invariant", "all_invariants", + "duplicate_case", "duplicate_invariant", "case_length", "ir_level", "ort_graph"]) +def test_missing_or_duplicated_coverage_cannot_pass(omission): + report = complete_numeric_report() + probe.finalize_numeric_report(report) + assert report["coverage_complete"] and report["passed"] + if omission == "case": + report["cases"].pop() + elif omission == "all_cases": + report["cases"] = [] + elif omission == "invariant": + report["invariants"].pop() + elif omission == "all_invariants": + report["invariants"] = [] + elif omission == "duplicate_case": + report["cases"][-1] = report["cases"][0] + elif omission == "duplicate_invariant": + report["invariants"][-1] = report["invariants"][0] + elif omission == "case_length": + report["cases"][0]["valid_length"] = 255 + elif omission == "ir_level": + del report["cases"][0]["ir"]["all"] + else: + del report["cases"][0]["ort"]["normal"] + probe.finalize_numeric_report(report) + assert report["coverage_complete"] is False + assert report["passed"] is False + + +def test_success_status_and_existing_empty_directory_contract(tmp_path, monkeypatch): + import json + from probes import w3_common + + sources = {"source_sha256": {"scratchv/example.py": "a" * 64}} + monkeypatch.setattr(w3_common, "source_evidence", lambda: sources) + + def succeed(out, report, seed): + report.update(complete_numeric_report()) + report["provenance"] = sources + probe.finalize_numeric_report(report) + + monkeypatch.setattr(probe, "run_probe", succeed) + existing = tmp_path / "already-created" + existing.mkdir() + with pytest.raises(SystemExit) as caught: + probe.main(["--output-dir", str(existing)]) + assert caught.value.code == 2 + assert not list(existing.iterdir()) + out = tmp_path / "new-evidence" + assert probe.main(["--output-dir", str(out)]) == 0 + report = json.loads((out / "report.json").read_text(encoding="utf-8")) + assert report["status"] == "PASS" and report["passed"] is True + assert report["coverage_complete"] is True + assert report["source_recheck"] == {"passed": True, "changed": {}} diff --git a/tests/test_qwen3_small_gate.py b/tests/test_qwen3_small_gate.py index dff815f..de5fcf9 100644 --- a/tests/test_qwen3_small_gate.py +++ b/tests/test_qwen3_small_gate.py @@ -1,7 +1,7 @@ """Exercise the real export and prove corrupt IR cannot produce a green gate. -The LLM workflow runs this file in the pinned CPU environment. The generic -compiler job has no torch/transformers dependency and skips this module. +The LLM workflow and full compiler CI run this file in the pinned CPU stack. +Minimal local compiler installations may omit torch/transformers and skip it. """ import json diff --git a/tests/test_qwen3_small_model.py b/tests/test_qwen3_small_model.py index b4d4daf..46f5c29 100644 --- a/tests/test_qwen3_small_model.py +++ b/tests/test_qwen3_small_model.py @@ -1,7 +1,7 @@ """Optional pinned-stack checks for the official Qwen3 diagnostic model. -The ordinary compiler test environment does not need torch/transformers. The -dedicated Qwen probe environment runs these tests with transformers 4.51.3. +Minimal compiler installations do not need torch/transformers. The full CI +and dedicated Qwen probe environments run these tests with transformers 4.51.3. """ import numpy as np diff --git a/tests/test_riscv_external.py b/tests/test_riscv_external.py new file mode 100644 index 0000000..3afea98 --- /dev/null +++ b/tests/test_riscv_external.py @@ -0,0 +1,427 @@ +"""Real QEMU execution of the external tensor ABI (explicit tool opt-in).""" +import json +import os +from pathlib import Path +import signal +import struct +import subprocess +import sys +import time +from types import SimpleNamespace + +import numpy as np +import pytest + +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.runtime.riscv_external import build_external, run_external +from scratchv.runtime.riscv_tensor import RiscVToolchain, discover_toolchain +from scratchv.runtime.weight_bundle import write_weight_bundle +from scratchv.runtime import riscv_external as runtime + + +def artifact(): + builder = IRBuilder() + x, w = Value("x", shape=(4, 4)), Value("w", shape=(4, 4)) + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(w) + builder.ret(builder.matmul(x, w)) + weights = np.arange(16, dtype=np.float32).reshape(4, 4) / 16 + return TensorCCodegen(builder.program, initializers={"w": weights}, + constant_storage="external").generate(), weights + + +@pytest.mark.parametrize("project_cache,standard_cache,selected", [ + (None, None, None), + (None, "standard cache", "standard cache"), + ("project cache", None, "project cache"), + ("project cache", "standard cache", "project cache"), +], ids=["default", "standard", "project", "project-overrides-standard"]) +def test_external_build_respects_zig_cache_environment( + tmp_path, monkeypatch, project_cache, standard_cache, selected): + monkeypatch.chdir(tmp_path) + for key, value in (("SCRATCHV_ZIG_CACHE", project_cache), + ("ZIG_GLOBAL_CACHE_DIR", standard_cache)): + if value is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, value) + model, _ = artifact() + write_weight_bundle(model.external_weights, model.external_initializers, tmp_path / "weights") + received = [] + + def compiler(command, *, cwd, timeout, env): + received.append(env) + # Inspect the actual subprocess environment without compiling a model. + return subprocess.CompletedProcess(command, 1, b"", b"controlled compile stop") + + monkeypatch.setattr(runtime, "_run_process", compiler) + with pytest.raises(RuntimeError, match="controlled compile stop"): + build_external(model, tmp_path / "weights", tmp_path / "build", + RiscVToolchain(("zig", "cc"), "unused-qemu")) + expected = (tmp_path / selected if selected else + Path(runtime.__file__).resolve().parents[2] / "output" / ".riscv-zig-cache") + assert len(received) == 1 + assert received[0]["ZIG_GLOBAL_CACHE_DIR"] == str(expected.resolve()) + assert received[0]["ZIG_LOCAL_CACHE_DIR"] == str(tmp_path / "build" / ".zig-cache") + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +def test_external_rv64_matmul_and_reuse(tmp_path): + model, weights = artifact() + manifest = write_weight_bundle(model.external_weights, model.external_initializers, tmp_path / "weights") + exe = build_external(model, tmp_path / "weights", tmp_path / "build", discover_toolchain()) + assert exe.evidence["bytes"] < 100_000_000 + for number in range(2): + x = np.arange(16, dtype=np.float32).reshape(4, 4) / (number + 1) + output, report = run_external(exe, {"x": x}, tmp_path / f"run{number}", timeout=30) + np.testing.assert_array_equal(output, x @ weights) + assert report["passed"] and report["guest_completed"] and report["exit_code"] == 0 + assert report["weight_sha256"] == manifest["sha256"] + assert report["elapsed_s"] >= report["output_dump_s"] > 0 + assert report["elapsed_s"] >= report["qemu_wall_s"] > 0 + assert report["qemu_rss_observed_samples"] > 0 + assert report["sampled_peak_qemu_rss_bytes"] > 0 + assert json.loads((tmp_path / f"run{number}/run.json").read_text())["passed"] + with pytest.raises(FileExistsError): + run_external(exe, {"x": x}, tmp_path / "run0", timeout=30) + (tmp_path / "weights/weights.bin").write_bytes(b"broken") + with pytest.raises(ValueError, match="length"): + run_external(exe, {"x": x}, tmp_path / "corrupt", timeout=30) + assert not (tmp_path / "corrupt").exists() + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +@pytest.mark.parametrize("empty", [False, True]) +def test_external_rv64_without_weights(tmp_path, empty): + builder = IRBuilder() + x = Value("x", shape=(0,) if empty else (3,)) + builder.new_function("main", [x]) + builder.new_block("entry") + builder.ret(builder.neg(x)) + model = TensorCCodegen(builder.program, constant_storage="external").generate() + manifest = write_weight_bundle(model.external_weights, model.external_initializers, tmp_path / "weights") + assert manifest["total_bytes"] == 0 + exe = build_external(model, tmp_path / "weights", tmp_path / "build", discover_toolchain()) + x_data = np.array([] if empty else [1.25, -2.5, 0.0], np.float32) + output, report = run_external(exe, {"x": x_data}, tmp_path / "run", timeout=30) + np.testing.assert_array_equal(output, -x_data) + assert report["passed"] and report["guest_completed"] + assert not any("loader,file=" in token and "weights.bin" in token for token in report["command"]) + + +def _stub_executable(tmp_path): + """A valid ELF/container for process-lifecycle tests; no numerical execution.""" + from scratchv.runtime.weight_bundle import plan_guest_memory + from scratchv.runtime.riscv_tensor import RiscVToolchain + builder = IRBuilder() + x = Value("x", shape=(1,)) + builder.new_function("main", [x]) + builder.new_block("entry") + builder.ret(x) + model = TensorCCodegen(builder.program, constant_storage="external").generate() + bundle = tmp_path / "weights" + manifest = write_weight_bundle(model.external_weights, model.external_initializers, bundle) + layout = plan_guest_memory(input_bytes=4, output_bytes=4) + elf = tmp_path / "model.elf" + ident = b"\x7fELF\x02\x01\x01" + b"\0" * 9 + header = struct.pack("<16sHHIQQQIHHHHHH", ident, 2, 243, 1, layout["code_base"], + 64, 0, 4, 64, 56, 1, 0, 0, 0) + segment = struct.pack("门禁、阶段耗时与证据入口
", "", table(["门禁", "结果"], gate_rows), "", + f"报告阶段:{cell(report.get('stage', '未记录'))};报告提交:{cell(identity.get('head', '未记录'))}。"] + stages = mapping(report.get("stages_seconds")) + if stages: + lines += ["", table(["阶段", "墙钟 / s"], [[name, number(value)] for name, value in list(stages.items())[:25]])] + for label, item in (("探测", report), ("审计", audit)): + if item.get("error") or item.get("report_error"): + lines += ["", f"{label}错误:{cell(item.get('error') or item.get('report_error'))}"] + lines += ["", "完整报告见本次运行的 `w4-full-reports-*` artifact;原始数组在 `w4-full-raw-*`。", + "Summary 只展示已有报告,不重新计算误差;失败时已显示的数值是部分证据。", + "", "= 2 + assert report["sampled_peak_qemu_rss_bytes"] > 0 + assert report["qemu_rss_availability"] == "available" + finally: + monitor.close() + process.terminate() + process.wait(timeout=5) + + +@pytest.mark.skipif(os.name == "nt", reason="real POSIX SIGTERM/process-group test") +def test_posix_sigterm_reaps_owned_process_session(tmp_path): + executable = _stub_executable(tmp_path) + fake_qemu = tmp_path / "qemu-stub" + fake_qemu.write_text(f"#!{sys.executable}\nimport os,time\nfrom pathlib import Path\n" + "Path('guest.pid').write_text(str(os.getpid()))\ntime.sleep(300)\n") + fake_qemu.chmod(0o700) + worker = tmp_path / "worker.py" + worker.write_text( + "from pathlib import Path\nimport numpy as np\n" + "from scratchv.runtime.riscv_external import ExternalExecutable, run_external\n" + "from scratchv.runtime.riscv_tensor import RiscVToolchain\n" + "from scratchv.backend.tensor_c_codegen import TensorSpec\n" + "from scratchv.ir.types import DataType\n" + "spec=TensorSpec('x',DataType.FLOAT32,(1,))\n" + f"exe=ExternalExecutable(Path({str(executable.elf_path)!r})," + f"Path({str(executable.bundle_dir)!r}),{executable.manifest!r},{executable.layout!r}," + f"(spec,),spec,(),RiscVToolchain(('unused',),{str(fake_qemu)!r}),{executable.evidence!r})\n" + f"run_external(exe,{{'x':np.ones(1,np.float32)}},Path({str(tmp_path / 'run')!r}))\n" + ) + environment = dict(os.environ) + environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[1]) + process = subprocess.Popen([sys.executable, str(worker)], env=environment, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + child_pid = None + try: + deadline = time.monotonic() + 15 + pid_path = tmp_path / "run/guest.pid" + while not pid_path.exists(): + assert process.poll() is None + if time.monotonic() >= deadline: + pytest.fail("fake QEMU session did not start") + time.sleep(0.05) + child_pid = int(pid_path.read_text()) + process.send_signal(signal.SIGTERM) + process.communicate(timeout=20) + assert process.returncode == 143 + with pytest.raises(ProcessLookupError): + os.kill(child_pid, 0) + report = json.loads((tmp_path / "run/run.json").read_text()) + assert report["error"] == "SystemExit: 143" and report["status"] == "FAIL" + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + if child_pid is not None: + try: + os.killpg(child_pid, signal.SIGKILL) + except ProcessLookupError: + pass diff --git a/tests/test_riscv_external_contract.py b/tests/test_riscv_external_contract.py new file mode 100644 index 0000000..81312b9 --- /dev/null +++ b/tests/test_riscv_external_contract.py @@ -0,0 +1,580 @@ +"""Independent ELF, completion protocol and local QMP contract checks. + +No mocked numerical execution or complete-model claim is made by these tests. +""" + +from contextlib import contextmanager +import hashlib +import json +from pathlib import Path +import socket +import struct +import threading +import time +from types import SimpleNamespace + +import pytest + +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.runtime import riscv_external as runtime +from scratchv.runtime import weight_transport as transport +from scratchv.runtime.riscv_tensor import RiscVToolchain +from scratchv.runtime.weight_bundle import plan_guest_memory, write_weight_bundle + + +def _elf(path, layout, *, entry=None, machine=243, flags=5, segments=None): + base = layout["code_base"] + if segments is None: + segments = [(1, 5, 256, base, base, 8, 16, 1)] + data = bytearray(512) + ident = b"\x7fELF\x02\x01\x01" + bytes(9) + struct.pack_into("<16sHHIQQQIHHHHHH", data, 0, ident, 2, machine, 1, + base if entry is None else entry, 64, 0, flags, 64, 56, + len(segments), 0, 0, 0) + for index, segment in enumerate(segments): + struct.pack_into(" = 0 + + +@pytest.mark.parametrize("stat_failure", [False, True], ids=["truncated", "unobservable"]) +def test_postcheck_failure_refreshes_residual_evidence_without_replacing_error( + tmp_path, monkeypatch, stat_failure): + """Exercise report/lifecycle handling with a stub guest, not numerical execution.""" + import numpy as np + + x, w = Value("x", shape=(2,)), Value("w", shape=(2,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(w) + builder.ret(builder.add(x, w)) + artifact = TensorCCodegen(builder.program, {"w": np.ones(2, np.float32)}, + constant_storage="external").generate() + bundle = tmp_path / "weights" + manifest = write_weight_bundle(artifact.external_weights, artifact.external_initializers, bundle) + original_weights = (bundle / "weights.bin").read_bytes() + assert manifest["total_bytes"] == 8 + layout = plan_guest_memory(input_bytes=8, output_bytes=8, weight_bytes=8, + workspace_bytes=artifact.workspace_bytes) + elf = tmp_path / "model.elf" + _elf(elf, layout) + executable = runtime.ExternalExecutable( + elf, bundle, manifest, layout, artifact.inputs, artifact.output, + artifact.external_weights, RiscVToolchain(("unused",), "stub-qemu"), + runtime.validate_external_elf(elf, layout)) + out = tmp_path / "run" + + class Process: + returncode = None + pid = 1234 + + def poll(self): + return self.returncode + + def wait(self, timeout): + self.returncode = 0 + return 0 + + class QMP: + def __init__(self, *args): + pass + + def command(self, command, *args): + if command == "human-monitor-command": + (out / "output.bin").write_bytes(bytes(8)) + return "" + return {} + + def close(self): + pass + + class Monitor: + def __init__(self, *args): + pass + + def start(self): + pass + + def close(self): + pass + + def report(self): + return {} + + def launch(*args, cwd, **kwargs): + (Path(cwd) / "uart.bin").write_bytes(runtime.FRAME.pack(runtime.MAGIC, 0, 8)) + return Process() + + verify, lstat = runtime.validate_transport, Path.lstat + original_errors = [] + + def inaccessible(path, *args, **kwargs): + if path.parent == out / "weight-transport" and path.suffix == ".bin": + raise PermissionError("injected residual stat failure") + return lstat(path, *args, **kwargs) + + def truncate_then_verify(directory, report): + path = directory / report["files"][0]["file"] + path.write_bytes(path.read_bytes()[:-1]) + try: + return verify(directory, report) + except ValueError as exc: + original_errors.append(exc) + if stat_failure: + monkeypatch.setattr(Path, "lstat", inaccessible) + raise + + monkeypatch.setattr(transport, "CHUNK_BYTES", 4) + monkeypatch.setattr(runtime.subprocess, "Popen", launch) + monkeypatch.setattr(runtime, "QMP", QMP) + monkeypatch.setattr(runtime, "_QemuMemoryMonitor", Monitor) + monkeypatch.setattr(runtime, "_windows_job", lambda process: None) + monkeypatch.setattr(runtime, "validate_transport", truncate_then_verify) + monkeypatch.setattr(transport, "remove_transport", lambda *args: pytest.fail("must retain copies")) + monkeypatch.setattr(runtime, "remove_transport", lambda *args: pytest.fail("must retain copies")) + with pytest.raises(ValueError, match="size mismatch") as caught: + runtime.run_external(executable, {"x": np.ones(2, np.float32)}, out) + assert original_errors == [caught.value] + report = json.loads((out / "run.json").read_text(encoding="utf-8")) + assert not report["passed"] and report["status"] == "FAIL" and report["guest_completed"] + assert report["error"] == "ValueError: Weight transport size mismatch" + residual = report["weight_transport"] + assert residual["status"] == "FAIL" and residual["stage"] == "postcheck" + assert residual["retained_bytes"] == (None if stat_failure else 7) + assert residual["retained_observation"] == ("unavailable" if stat_failure else "complete") + assert residual["retained"] is (None if stat_failure else True) + assert residual["retained_observed_bytes"] == (0 if stat_failure else 7) + if stat_failure: + assert len(residual["retained_observation_errors"]) == 2 + assert any("residual stat failure" in error for error in report["cleanup_errors"]) + else: + assert not residual["retained_observation_errors"] + assert json.loads((out / "weight-transport/transport.json").read_text(encoding="utf-8")) == residual + assert (out / "weight-transport/weights-00000.bin").read_bytes() == original_weights[:3] + assert (out / "weight-transport/weights-00001.bin").read_bytes() == original_weights[4:] + assert (bundle / "weights.bin").read_bytes() == original_weights + + +@pytest.mark.parametrize("failure", ["report_stat", "rss_report"]) +@pytest.mark.parametrize("has_primary", [False, True], ids=["completed", "primary_error"]) +def test_cleanup_observation_failure_preserves_error_and_finishes_resource_cleanup( + tmp_path, monkeypatch, failure, has_primary): + """Fault injection for cleanup ownership/reporting; no guest arithmetic.""" + import numpy as np + + executable = _unlaunchable_executable(tmp_path) + out = tmp_path / "run" + events = [] + cause = ValueError("primary QMP stop failure") + + class Process: + returncode = None + pid = 1234 + + def poll(self): + return self.returncode + + def wait(self, timeout): + events.append("process-wait") + self.returncode = 0 + return 0 + + class QMP: + def __init__(self, *args): + pass + + def command(self, command, *args): + if command == "stop" and has_primary: + raise cause + if command == "human-monitor-command": + (out / "output.bin").write_bytes(bytes(4)) + return "" + return {} + + def close(self): + events.append("qmp-close") + + class Monitor: + def __init__(self, *args): + pass + + def start(self): + pass + + def close(self): + events.append("monitor-close") + + def report(self): + events.append("monitor-report") + if failure == "rss_report": + raise OSError("injected RSS report failure") + return {} + + def launch(*args, cwd, **kwargs): + (Path(cwd) / "uart.bin").write_bytes(runtime.FRAME.pack(runtime.MAGIC, 0, 4)) + return Process() + + stat = Path.stat + + def inaccessible_report(path, *args, **kwargs): + if path == out / "weight-transport/transport.json": + raise PermissionError("injected report path stat failure") + return stat(path, *args, **kwargs) + + monkeypatch.setattr(runtime.subprocess, "Popen", launch) + monkeypatch.setattr(runtime, "QMP", QMP) + monkeypatch.setattr(runtime, "_QemuMemoryMonitor", Monitor) + monkeypatch.setattr(runtime, "_windows_job", lambda process: ( + SimpleNamespace(CloseHandle=lambda handle: events.append("job-close") or 1), 99)) + monkeypatch.setattr(runtime, "_stop_process_tree", lambda process, job: events.append("tree-kill")) + if failure == "report_stat": + monkeypatch.setattr(Path, "stat", inaccessible_report) + with pytest.raises(ValueError if has_primary else RuntimeError) as caught: + runtime.run_external(executable, {"x": np.ones(1, np.float32)}, out) + if has_primary: + assert caught.value is cause + assert "tree-kill" in events + else: + assert "Runtime cleanup failed" in str(caught.value) + assert all(event in events for event in ["qmp-close", "monitor-close", "monitor-report", + "process-wait", "job-close"]) + report = json.loads((out / "run.json").read_text(encoding="utf-8")) + assert not report["passed"] and report["status"] == "FAIL" + expected = "report path stat failure" if failure == "report_stat" else "RSS report failure" + assert any(expected in error for error in report["cleanup_errors"]) + if has_primary: + assert report["error"] == "ValueError: primary QMP stop failure" + # Transport observation/reporting is attempted even after monitor failure. + assert report["weight_transport"]["retained_observation"] == "complete" + assert report["weight_transport"]["retained_bytes"] == 0 diff --git a/tests/test_riscv_process_cleanup.py b/tests/test_riscv_process_cleanup.py new file mode 100644 index 0000000..929f9d9 --- /dev/null +++ b/tests/test_riscv_process_cleanup.py @@ -0,0 +1,163 @@ +"""Compiler process setup failures must remain bounded and preserve their cause.""" +import io +import subprocess +import sys +import threading +import time +from types import SimpleNamespace + +import pytest + +from scratchv.runtime import riscv_tensor as runtime + + +class Process: + def __init__(self): + self.returncode = None + self.stdout, self.stderr = io.BytesIO(), io.BytesIO() + self.events = [] + + def __enter__(self): + return self + + def __exit__(self, *args): + # Real Popen.__exit__ waits without a deadline. Record instead of hanging + # this test when exercising the pre-fix job-setup exception path. + self.events.append(("context_exit", self.returncode)) + + def poll(self): + return self.returncode + + def communicate(self, timeout=None): + assert timeout is not None + self.events.append(("communicate", timeout)) + self.returncode = 0 + return b"out", b"err" + + def kill(self): + self.events.append(("kill",)) + self.returncode = -9 + + def wait(self, timeout=None): + assert timeout is not None + self.events.append(("wait", timeout)) + return self.returncode + + +def test_job_setup_exception_terminates_child_without_context_wait(monkeypatch): + process = Process() + failure = OSError("Job setup failed") + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: process) + def no_job(child): + raise failure + monkeypatch.setattr(runtime, "_windows_job", no_job) + monkeypatch.setattr(runtime, "_stop_process_tree", lambda child, job: child.kill()) + with pytest.raises(OSError) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=0.1) + assert caught.value is failure + assert ("kill",) in process.events + assert not any(event[0] == "context_exit" for event in process.events) + assert process.stdout.closed and process.stderr.closed + + +def test_cleanup_failures_preserve_primary_and_try_remaining_resources(monkeypatch): + process = Process() + class OrderedPipe(io.BytesIO): + def close(self): + process.events.append(("close-pipe",)) + super().close() + process.stdout, process.stderr = OrderedPipe(), OrderedPipe() + failure = KeyboardInterrupt("original cancellation") + def broken_communicate(timeout=None): + process.events.append(("communicate", timeout)) + raise failure + process.communicate = broken_communicate + def broken_tree(child, job): + process.events.append(("stop-tree",)) + raise OSError("tree kill failed") + kernel = SimpleNamespace(CloseHandle=lambda handle: process.events.append(("close-job",)) or 1) + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(runtime, "_windows_job", lambda child: (kernel, 42)) + monkeypatch.setattr(runtime, "_stop_process_tree", broken_tree) + with pytest.raises(KeyboardInterrupt) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=0.1) + assert caught.value is failure + assert ("kill",) in process.events and ("close-job",) in process.events + assert process.stdout.closed and process.stderr.closed + assert process.events.index(("close-job",)) < process.events.index(("close-pipe",)) + assert any("tree kill failed" in note for note in failure.__notes__) + + +def test_timeout_keeps_complete_drained_logs(monkeypatch): + process = Process() + calls = [] + def communicate(timeout=None): + calls.append(timeout) + if len(calls) == 1: + raise subprocess.TimeoutExpired(["compiler"], timeout, output=b"partial") + process.returncode = -9 + return b"complete stdout", b"complete stderr" + process.communicate = communicate + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(runtime, "_windows_job", lambda child: None) + monkeypatch.setattr(runtime, "_stop_process_tree", lambda child, job: child.kill()) + with pytest.raises(subprocess.TimeoutExpired) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=0.1) + assert caught.value.output == b"complete stdout" + assert caught.value.stderr == b"complete stderr" + assert all(timeout is not None for timeout in calls) + + +def test_real_child_is_reaped_when_job_setup_raises(monkeypatch, tmp_path): + processes = [] + def no_job(process): + processes.append(process) + raise OSError("injected Job setup failure") + monkeypatch.setattr(runtime, "_windows_job", no_job) + with pytest.raises(OSError, match="injected Job"): + runtime._run_process([sys.executable, "-B", "-c", "import time;time.sleep(60)"], + cwd=tmp_path, timeout=0.1) + assert len(processes) == 1 and processes[0].poll() is not None + + +def test_windows_blocked_reader_close_cannot_stall_cleanup(monkeypatch): + process = Process() + release = threading.Event() + class BlockedPipe(io.BytesIO): + def close(self): + release.wait(timeout=3) + super().close() + process.stdout, process.stderr = BlockedPipe(), BlockedPipe() + def timeout(timeout=None): + raise subprocess.TimeoutExpired(["compiler"], timeout, output=b"partial") + process.communicate = timeout + monkeypatch.setattr(runtime, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(runtime, "_windows_job", lambda child: None) + monkeypatch.setattr(runtime, "_stop_process_tree", lambda child, job: child.kill()) + started = time.monotonic() + try: + with pytest.raises(subprocess.TimeoutExpired) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=0.1) + assert time.monotonic() - started < 2 + assert any("Pipe close is pending" in note for note in caught.value.__notes__) + finally: + release.set() + + +def test_popen_failure_restores_posix_handler_without_touching_a_child(monkeypatch): + events = [] + failure = OSError("compiler launch failed") + fake_signal = SimpleNamespace(SIGTERM=15, SIGKILL=9, getsignal=lambda sig: "original", + signal=lambda sig, handler: events.append(("signal", handler))) + monkeypatch.setattr(runtime, "os", SimpleNamespace(name="posix")) + monkeypatch.setattr(runtime, "signal", fake_signal) + def launch(*args, **kwargs): + events.append(("launch",)) + raise failure + monkeypatch.setattr(runtime.subprocess, "Popen", launch) + monkeypatch.setattr(runtime, "_windows_job", lambda child: pytest.fail("no child exists")) + with pytest.raises(OSError) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=1) + assert caught.value is failure + assert events == [("signal", runtime._terminate_invocation), ("launch",), ("signal", "original")] diff --git a/tests/test_riscv_weight_transport.py b/tests/test_riscv_weight_transport.py new file mode 100644 index 0000000..ec477a3 --- /dev/null +++ b/tests/test_riscv_weight_transport.py @@ -0,0 +1,427 @@ +"""Small-threshold tests exercise the same bounded loader route as full weights.""" +import hashlib +import json +import os +from pathlib import Path +import stat + +import numpy as np +import pytest + +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.runtime import riscv_external as runtime +from scratchv.runtime import weight_transport as transport +from scratchv.runtime.riscv_tensor import discover_toolchain +from scratchv.runtime.weight_bundle import write_weight_bundle + + +def source_bundle(tmp_path, data): + source = tmp_path / "weights.bin" + source.write_bytes(data) + return source, {"total_bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + + +@pytest.mark.parametrize("size", [0, 1, 31, 32, 33, 64, 65]) +def test_chunks_cover_exact_contiguous_bytes_and_hashes(tmp_path, size): + data = bytes(range(size)) + source, manifest = source_bundle(tmp_path, data) + report, out = {}, tmp_path / "transport" + transport.stage_transport(source, manifest, out, report, chunk_bytes=32) + assert report["status"] == "PASS" and report["total_bytes"] == size + assert report["copied_bytes"] == report["retained_bytes"] == size + assert len(report["files"]) == (size + 31) // 32 + assert b"".join((out / row["file"]).read_bytes() for row in report["files"]) == data + assert [row["offset"] for row in report["files"]] == list(range(0, size, 32)) + assert all(row["nbytes"] <= 32 for row in report["files"]) + transport.validate_transport(out, report) + transport.remove_transport(out, report) + assert not list(out.glob("*.bin")) + saved = json.loads((out / "transport.json").read_text()) + assert not saved["retained"] and saved["retained_bytes"] == 0 + assert saved["sha256"] == manifest["sha256"] + assert source.read_bytes() == data + + +@pytest.mark.parametrize("limit", [0, -1, True, 512*1024*1024+1]) +def test_chunk_bound_cannot_exceed_safe_transport_limit(tmp_path, limit): + source, manifest = source_bundle(tmp_path, b"data") + with pytest.raises(ValueError, match="512 MiB"): + transport.stage_transport(source, manifest, tmp_path / "out", {}, chunk_bytes=limit) + + +def test_source_hash_mismatch_keeps_copy_and_fail_manifest(tmp_path): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + manifest["sha256"] = "0" * 64 + report, out = {}, tmp_path / "out" + with pytest.raises(ValueError, match="Source weight bundle hash"): + transport.stage_transport(source, manifest, out, report, chunk_bytes=4) + saved = json.loads((out / "transport.json").read_text()) + assert saved["status"] == "FAIL" and saved["retained_bytes"] == 8 + assert len(list(out.glob("*.bin"))) == 2 + + +@pytest.mark.parametrize("damage", ["same-size", "truncate", "extra", "reorder", "whole-hash"]) +def test_transport_damage_is_rejected(tmp_path, damage): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=4) + first = out / report["files"][0]["file"] + if damage == "same-size": + first.write_bytes(b"wxyz") + elif damage == "truncate": + first.write_bytes(b"abc") + elif damage == "extra": + first.write_bytes(b"abcde") + elif damage == "reorder": + report["files"].reverse() + else: + report["sha256"] = "0" * 64 + with pytest.raises(ValueError): + transport.validate_transport(out, report) + + +def test_partial_copy_failure_retains_bounded_progress(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + original_open = Path.open + monkeypatch.setattr(transport, "COPY_BUFFER_BYTES", 4) + class FailingWriter: + def __init__(self, stream): + self.stream, self.writes = stream, 0 + def __enter__(self): + return self + def __exit__(self, *args): + self.stream.close() + def fileno(self): + return self.stream.fileno() + def write(self, value): + self.writes += 1 + if self.writes == 2: + raise OSError("injected disk full") + return self.stream.write(value) + def opened(path, *args, **kwargs): + stream = original_open(path, *args, **kwargs) + return FailingWriter(stream) if path.name == "weights-00000.bin" else stream + monkeypatch.setattr(Path, "open", opened) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError, match="disk full"): + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + saved = json.loads((out / "transport.json").read_text()) + assert saved["status"] == "FAIL" and saved["copied_bytes"] == 4 + assert saved["files"][0]["written_bytes"] == 4 + assert (out / "weights-00000.bin").stat().st_size == 4 + + +def partial_writer(monkeypatch, *, raise_after_write, fail_observation=False): + """Actually persist a prefix, then return a short count or raise the cause.""" + original_open, original_lstat = Path.open, Path.lstat + cause = OSError("injected error after partial write") + class PartialWriter: + def __init__(self, stream): + self.stream = stream + def __enter__(self): + return self + def __exit__(self, *args): + self.stream.close() + def fileno(self): + return self.stream.fileno() + def write(self, value): + count = self.stream.write(value[:3]) + if raise_after_write: + raise cause + return count + def opened(path, *args, **kwargs): + stream = original_open(path, *args, **kwargs) + if path.name == "weights-00000.bin" and args and args[0] == "xb": + return PartialWriter(stream) + return stream + monkeypatch.setattr(Path, "open", opened) + if fail_observation: + def lstat(path, *args, **kwargs): + if path.name == "weights-00000.bin": + raise PermissionError("injected residual stat failure") + return original_lstat(path, *args, **kwargs) + monkeypatch.setattr(Path, "lstat", lstat) + return cause + + +@pytest.mark.parametrize("raise_after_write", [False, True], ids=["short-count", "write-raises"]) +def test_actual_partial_write_residual_is_measured(tmp_path, monkeypatch, raise_after_write): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + cause = partial_writer(monkeypatch, raise_after_write=raise_after_write) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError) as caught: + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + if raise_after_write: + assert caught.value is cause + else: + assert "Short weight transport write" in str(caught.value) + assert (out / "weights-00000.bin").read_bytes() == b"abc" + saved = json.loads((out / "transport.json").read_text()) + assert saved["status"] == "FAIL" and saved["copied_bytes"] == 0 + assert saved["retained_bytes"] == 3 and saved["retained"] is True + assert saved["retained_observation"] == "complete" + assert saved["retained_files"] == [{"file": "weights-00000.bin", "nbytes": 3}] + assert source.read_bytes() == b"abcdefgh" + + +def test_residual_stat_failure_is_unknown_and_preserves_write_error(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + cause = partial_writer(monkeypatch, raise_after_write=True, fail_observation=True) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError) as caught: + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + assert caught.value is cause + assert report["status"] == "FAIL" and report["retained_bytes"] is None + assert report["retained"] is None and report["retained_observed_bytes"] == 0 + assert report["retained_observation"] == "unavailable" + assert any("residual stat failure" in error for error in report["retained_observation_errors"]) + assert any("residual stat failure" in note for note in cause.__notes__) + assert json.loads((out / "transport.json").read_text())["retained_bytes"] is None + assert (out / "weights-00000.bin").read_bytes() == b"abc" + + +def test_residual_observation_failure_after_copy_is_not_pass(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + validate, lstat = transport.validate_transport, Path.lstat + ready = False + def validated(directory, report): + nonlocal ready + elapsed = validate(directory, report) + ready = True + return elapsed + def observed(path, *args, **kwargs): + if ready and path.name == "weights-00000.bin": + raise PermissionError("injected final observation failure") + return lstat(path, *args, **kwargs) + monkeypatch.setattr(transport, "validate_transport", validated) + monkeypatch.setattr(Path, "lstat", observed) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError, match="observation"): + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + saved = json.loads((out / "transport.json").read_text()) + assert saved["status"] == "FAIL" and saved["retained_bytes"] is None + + +def test_uncreated_foreign_file_is_neither_counted_nor_removed(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + opened = Path.open + def collision(path, *args, **kwargs): + if path.name == "weights-00000.bin" and args and args[0] == "xb": + with opened(path, "xb") as stream: + stream.write(b"foreign") + return opened(path, *args, **kwargs) + monkeypatch.setattr(Path, "open", collision) + report, out = {}, tmp_path / "out" + with pytest.raises(FileExistsError): + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + assert report["retained_bytes"] == 0 and report["retained"] is False + with pytest.raises(ValueError, match="created|owned"): + transport.remove_transport(out, report) + assert (out / "weights-00000.bin").read_bytes() == b"foreign" + + +def test_replaced_file_is_not_removed_as_owned_copy(tmp_path): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=4) + replaced = out / "weights-00001.bin" + replaced.rename(out / "original-owned.bin") + replaced.write_bytes(b"foreign") + with pytest.raises(ValueError, match="identity|owned"): + transport.remove_transport(out, report) + assert replaced.read_bytes() == b"foreign" + assert (out / "weights-00000.bin").read_bytes() == b"abcd" + + +def test_partial_residual_observation_reports_known_bytes_separately(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=4) + original = Path.lstat + def lstat(path, *args, **kwargs): + if path.name == "weights-00000.bin": + raise PermissionError("one file unavailable") + return original(path, *args, **kwargs) + monkeypatch.setattr(Path, "lstat", lstat) + with pytest.raises(OSError, match="observation"): + transport._observe_retained(out, report) + assert report["retained_bytes"] is None and report["retained"] is True + assert report["retained_observed_bytes"] == 4 + assert report["retained_observation"] == "partial" + assert report["retained_files"][0]["nbytes"] is None + assert report["retained_files"][1] == {"file": "weights-00001.bin", "nbytes": 4} + + +def test_report_write_failure_does_not_replace_partial_write_error(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + cause = partial_writer(monkeypatch, raise_after_write=True) + def failed_report(*args, **kwargs): + raise OSError("injected evidence write failure") + monkeypatch.setattr(transport, "save_transport_report", failed_report) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError) as caught: + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + assert caught.value is cause + assert report["retained_bytes"] == 3 + assert any("evidence write failure" in note for note in cause.__notes__) + assert "evidence write failure" in report["report_error"] + + +def test_link_replacement_is_not_followed_or_removed(tmp_path): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + path = out / "weights-00000.bin" + path.rename(out / "original-owned.bin") + target = tmp_path / "foreign.bin" + target.write_bytes(b"foreign") + try: + path.symlink_to(target) + except OSError as exc: + pytest.skip(f"Host cannot create test symlink: {exc}") + with pytest.raises(ValueError, match="escaped|link"): + transport.remove_transport(out, report) + assert path.is_symlink() and target.read_bytes() == b"foreign" + assert report["retained_bytes"] is None + assert report["retained_observation"] == "unavailable" + + +def test_link_observation_refuses_cleanup_without_symlink_privilege(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + lstat, unlink = Path.lstat, Path.unlink + def observed(path, *args, **kwargs): + info = lstat(path, *args, **kwargs) + if path.name == "weights-00000.bin": + fields = list(info) + fields[0] = stat.S_IFLNK | 0o777 + return os.stat_result(fields) + return info + def deleted(path, *args, **kwargs): + if path.name == "weights-00000.bin": + pytest.fail("A link observation must not reach unlink") + return unlink(path, *args, **kwargs) + monkeypatch.setattr(Path, "lstat", observed) + monkeypatch.setattr(Path, "unlink", deleted) + with pytest.raises(ValueError, match="escaped|link"): + transport.remove_transport(out, report) + assert report["retained_bytes"] is None + assert report["retained_observation"] == "unavailable" + assert (out / "weights-00000.bin").read_bytes() == b"abcdefgh" + + +def test_cleanup_directory_observation_failure_marks_residual_unknown(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + reject = transport._reject_links + cause = PermissionError("injected directory observation failure") + def inaccessible(path): + if Path(path) == out: + raise cause + return reject(path) + monkeypatch.setattr(transport, "_reject_links", inaccessible) + with pytest.raises(PermissionError) as caught: + transport.remove_transport(out, report) + assert caught.value is cause + assert report["status"] == "FAIL" and report["retained_bytes"] is None + assert report["retained_observation"] == "unavailable" + assert (out / "weights-00000.bin").read_bytes() == b"abcdefgh" + + +def test_staging_error_is_inside_runtime_failure_report(tmp_path, monkeypatch): + from tests.test_riscv_external import _stub_executable + executable = _stub_executable(tmp_path) + def failure(source, manifest, directory, report): + report.update(status="FAIL", stage="copy", copied_bytes=0) + raise OSError("injected staging error") + monkeypatch.setattr(runtime, "stage_transport", failure) + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: pytest.fail("must not launch")) + out = tmp_path / "run" + with pytest.raises(OSError, match="staging error"): + runtime.run_external(executable, {"x": np.zeros(1, np.float32)}, out) + report = json.loads((out / "run.json").read_text()) + assert not report["passed"] and not report["guest_completed"] + assert report["stage"] == "weight_transport" and report["qemu_wall_s"] is None + assert report["weight_transport"]["copied_bytes"] == 0 + + +def executable(tmp_path, *, columns): + builder = IRBuilder() + x, w = Value("x", shape=(2, 4)), Value("w", shape=(4, columns)) + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(w) + builder.ret(builder.matmul(x, w)) + data = np.arange(4 * columns, dtype=np.float32).reshape(4, columns) / 16 + artifact = TensorCCodegen(builder.program, {"w": data}, constant_storage="external").generate() + write_weight_bundle(artifact.external_weights, artifact.external_initializers, tmp_path / "weights") + return runtime.build_external(artifact, tmp_path / "weights", tmp_path / "build", discover_toolchain()), data + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +@pytest.mark.parametrize("columns", [0, 2, 3, 4]) +@pytest.mark.parametrize("chunk_bytes", [31, 32], ids=["chunk31", "chunk32"]) +def test_real_qemu_contiguous_loader_segments_and_success_cleanup(tmp_path, monkeypatch, columns, chunk_bytes): + exe, weights = executable(tmp_path, columns=columns) + monkeypatch.setattr(transport, "CHUNK_BYTES", chunk_bytes) + x = np.arange(8, dtype=np.float32).reshape(2, 4) + out = tmp_path / "run" + output, report = runtime.run_external(exe, {"x": x}, out, timeout=30) + np.testing.assert_array_equal(output, x @ weights) + assert report["passed"] + evidence = report["weight_transport"] + assert len(evidence["files"]) == (weights.nbytes + chunk_bytes - 1) // chunk_bytes + assert not evidence["retained"] and evidence["retained_bytes"] == 0 + assert evidence["copy_elapsed_s"] >= 0 and evidence["post_validation_elapsed_s"] >= 0 + commands = [part for part in report["command"] if "weight-transport" in part] + assert len(commands) == len(evidence["files"]) + for row, command in zip(evidence["files"], commands): + assert f"addr=0x{exe.layout['weights_base'] + row['offset']:x}" in command + assert not list((out / "weight-transport").glob("*.bin")) + assert (exe.bundle_dir / "weights.bin").exists() + assert json.loads((out / "weight-transport/transport.json").read_text())["stage"] == "copies_removed" + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +def test_post_execution_transport_corruption_fails_and_retains_copies(tmp_path, monkeypatch): + exe, _ = executable(tmp_path, columns=3) + monkeypatch.setattr(transport, "CHUNK_BYTES", 32) + verify = runtime.validate_transport + def corrupt(directory, report): + path = directory / report["files"][0]["file"] + value = bytearray(path.read_bytes()) + value[0] ^= 1 + path.write_bytes(value) + return verify(directory, report) + monkeypatch.setattr(runtime, "validate_transport", corrupt) + out = tmp_path / "run" + with pytest.raises(ValueError, match="segment hash"): + runtime.run_external(exe, {"x": np.ones((2, 4), np.float32)}, out, timeout=30) + report = json.loads((out / "run.json").read_text()) + assert not report["passed"] and report["guest_completed"] + assert report["weight_transport"]["status"] == "FAIL" + assert len(list((out / "weight-transport").glob("*.bin"))) == 2 + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +def test_success_copy_cleanup_failure_is_not_a_pass(tmp_path, monkeypatch): + exe, _ = executable(tmp_path, columns=3) + monkeypatch.setattr(transport, "CHUNK_BYTES", 32) + unlink = Path.unlink + def failure(path, *args, **kwargs): + if path.name == "weights-00000.bin": + raise OSError("injected locked transport copy") + return unlink(path, *args, **kwargs) + monkeypatch.setattr(Path, "unlink", failure) + out = tmp_path / "run" + with pytest.raises(RuntimeError, match="cleanup failed"): + runtime.run_external(exe, {"x": np.ones((2, 4), np.float32)}, out, timeout=30) + report = json.loads((out / "run.json").read_text()) + assert not report["passed"] and report["guest_completed"] + assert any("locked transport" in error for error in report["cleanup_errors"]) + assert report["weight_transport"]["retained_bytes"] == 32 + assert len(list((out / "weight-transport").glob("*.bin"))) == 1 diff --git a/tests/test_tensor_boundary_contracts.py b/tests/test_tensor_boundary_contracts.py new file mode 100644 index 0000000..99fac81 --- /dev/null +++ b/tests/test_tensor_boundary_contracts.py @@ -0,0 +1,198 @@ +"""Static tensor boundaries shared by the ONNX frontend, IR and C ABI.""" + +import numpy as np +import onnx +import pytest +from onnx import TensorProto, helper, numpy_helper + +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.frontend.onnx_parser import ONNXParseError, ONNXParser +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Value +from scratchv.runtime.riscv_tensor import pack_inputs +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def _builder(*parameters): + builder = IRBuilder() + builder.new_function("main", list(parameters)) + builder.new_block("entry") + return builder + + +def _neg_model(tmp_path, shape): + graph = helper.make_graph( + [helper.make_node("Neg", ["x"], ["y"])], "neg", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, shape)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, shape)], + ) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 18)], ir_version=10) + path = tmp_path / "neg.onnx" + onnx.save(model, path) + return path + + +@pytest.mark.parametrize("shape", [["N"], [None], [1, "sequence"], None], + ids=["symbolic", "unknown-dimension", "mixed", "unknown-rank"]) +def test_parser_rejects_unresolved_input_shape_before_static_codegen(tmp_path, shape): + # An absent shape field means unknown rank, not a rank-zero scalar. + with pytest.raises(ONNXParseError, match="static|dynamic|unknown"): + ONNXParser().parse(str(_neg_model(tmp_path, shape))) + + +@pytest.mark.parametrize("weight_shape", [["N"], [None]]) +def test_initializer_backed_graph_input_uses_fixed_initializer_shape(tmp_path, weight_shape): + # Older exporters also list initializer weights in graph.input. ONNX + # inference does not refine that input's symbolic/unknown dimension, but + # this frontend binds it as a fixed initializer rather than a parameter. + weights = np.array([1.0, 2.0], dtype=np.float32) + graph = helper.make_graph( + [helper.make_node("Add", ["x", "weights"], ["y"])], "legacy-weights", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, [2]), + helper.make_tensor_value_info("weights", TensorProto.FLOAT, weight_shape)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, [2])], + [numpy_helper.from_array(weights, name="weights")], + ) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 18)], ir_version=10) + onnx.checker.check_model(model, full_check=True) + path = tmp_path / "legacy-weights.onnx" + onnx.save(model, path) + parser = ONNXParser() + program = parser.parse(str(path)) + assert [value.name for value in program.functions[0].params] == ["x"] + assert program.global_values[0].shape == (2,) + data = np.array([3.0, 4.0], dtype=np.float32) + returned = IRInterpreter(program).run({"x": data}, initializers=parser.initializers).return_value + np.testing.assert_array_equal(returned, [4.0, 6.0]) + artifact = TensorCCodegen(program, initializers=parser.initializers, + constant_storage="external").generate() + assert artifact.output.shape == (2,) + assert [spec.name for spec in artifact.inputs] == ["x"] + assert artifact.external_weights[0].shape == (2,) + + +@pytest.mark.parametrize("shape", [(), (0,), (2, 0, 3), (1, 256)]) +def test_explicit_static_scalar_zero_and_l256_shapes_survive_all_boundaries(tmp_path, shape): + parser = ONNXParser() + program = parser.parse(str(_neg_model(tmp_path, shape))) + assert program.functions[0].params[0].shape == shape + values = np.arange(np.prod(shape, dtype=int), dtype=np.float32).reshape(shape) + result = IRInterpreter(program).run({"x": values}, initializers=parser.initializers).return_value + assert result.shape == shape + np.testing.assert_array_equal(result, -values) + artifact = TensorCCodegen(program, initializers=parser.initializers, + constant_storage="external").generate() + assert artifact.inputs[0].shape == artifact.output.shape == shape + assert pack_inputs(artifact.inputs, {"x": values}) == values.tobytes() + + +@pytest.mark.parametrize("dtype,numpy_dtype", [(DataType.FLOAT32, np.float32), + (DataType.INT64, np.int64)]) +@pytest.mark.parametrize("binding", ["input", "initializer"]) +@pytest.mark.parametrize("array_shape", [(1,), (2,), (1, 1)]) +def test_scalar_bindings_reject_non_scalar_arrays(dtype, numpy_dtype, binding, array_shape): + value = Value("x", dtype=dtype, shape=()) + builder = _builder(value) if binding == "input" else _builder() + if binding == "initializer": + builder.program.global_values.append(value) + builder.ret(value) + data = np.ones(array_shape, dtype=numpy_dtype) + with pytest.raises(IRExecutionError, match="ShapeError.*expected \\(\\)"): + IRInterpreter(builder.program).run( + {"x": data} if binding == "input" else {}, + initializers={"x": data} if binding == "initializer" else {}, + ) + + +@pytest.mark.parametrize("binding", ["input", "initializer"]) +def test_scalar_binding_and_inferred_tensor_intermediate_remain_supported(binding): + scalar, tensor = Value("scale", shape=()), Value("x", shape=(2, 3)) + builder = _builder(scalar, tensor) if binding == "input" else _builder(tensor) + if binding == "initializer": + builder.program.global_values.append(scalar) + product = builder.mul(tensor, scalar) + assert product.shape == () # Builder has not inferred this intermediate yet. + builder.ret(product) + value = np.array(2.0, dtype=np.float32) + value.setflags(write=False) + data = np.arange(6, dtype=np.float32).reshape(2, 3) + feed = {"x": data} + initializers = {} + if binding == "input": + feed["scale"] = value + else: + initializers["scale"] = value + result = IRInterpreter(builder.program).run(feed, initializers=initializers, + copy_initializers=False, + memory_mode="last_use").return_value + np.testing.assert_array_equal(result, data * value) + assert result.shape == (2, 3) + artifact = TensorCCodegen(builder.program, initializers=initializers, + constant_storage="external").generate() + assert artifact.output.shape == (2, 3) + assert product.shape == () # Neither consumer mutates the shared IR shape. + + +@pytest.mark.parametrize("shape", [(-1,), (True,), (1.5,), ("N",), None]) +@pytest.mark.parametrize("binding", ["input", "initializer"]) +def test_static_bindings_reject_invalid_shape_metadata(shape, binding): + value = Value("x", shape=shape) + builder = _builder(value) if binding == "input" else _builder() + if binding == "initializer": + builder.program.global_values.append(value) + builder.ret(value) + data = np.ones(1, dtype=np.float32) + with pytest.raises(IRExecutionError, match="ShapeError"): + IRInterpreter(builder.program).run( + {"x": data} if binding == "input" else {}, + initializers={"x": data} if binding == "initializer" else {}, + ) + + +@pytest.mark.parametrize("shape", [(), (3,), (-1,), (True,), None]) +def test_explicit_function_return_shape_is_a_static_contract(shape): + x = Value("x", shape=(2,)) + builder = _builder(x) + builder.current_func.returns = [Value("result", shape=shape)] + builder.ret(builder.neg(x)) + with pytest.raises(IRExecutionError, match="ShapeError"): + IRInterpreter(builder.program).run({"x": np.ones(2, dtype=np.float32)}) + + +@pytest.mark.parametrize("shape", [(), (2, 3)]) +@pytest.mark.parametrize("declared", [False, True]) +def test_matching_or_omitted_return_signature_preserves_inferred_intermediates(shape, declared): + x = Value("x", shape=shape) + builder = _builder(x) + result = builder.neg(x) + assert result.shape == () + builder.ret(result) + if declared: + builder.current_func.returns = [Value("result", shape=shape)] + data = np.ones(shape, dtype=np.float32) + returned = IRInterpreter(builder.program).run({"x": data}).return_value + np.testing.assert_array_equal(returned, -data) + assert returned.shape == shape + assert result.shape == () + + +@pytest.mark.parametrize("mode", ["wrong-dtype", "multiple-returns", "missing-value"]) +def test_explicit_return_dtype_and_count_are_rejected(mode): + x = Value("x", shape=(2,)) + builder = _builder(x) + dtype = DataType.INT64 if mode == "wrong-dtype" else DataType.FLOAT32 + builder.current_func.returns = [Value("declared", dtype=dtype, shape=(2,))] + if mode == "multiple-returns": + builder.current_func.returns.append(Value("second", shape=(2,))) + builder.ret(None if mode == "missing-value" else x) + with pytest.raises(IRExecutionError, match="InvalidProgram"): + IRInterpreter(builder.program).run({"x": np.ones(2, dtype=np.float32)}) + + +@pytest.mark.parametrize("reference_shape", [(), (3,)]) +def test_return_reference_shape_cannot_override_parameter_shape(reference_shape): + x = Value("x", shape=(2,)) + builder = _builder(x) + builder.ret(Value("x", shape=reference_shape)) + with pytest.raises(IRExecutionError, match="ShapeError"): + IRInterpreter(builder.program).run({"x": np.ones(2, dtype=np.float32)}) diff --git a/tests/test_tensor_c_external.py b/tests/test_tensor_c_external.py new file mode 100644 index 0000000..50cdb94 --- /dev/null +++ b/tests/test_tensor_c_external.py @@ -0,0 +1,466 @@ +"""External weights and caller workspace: execute the generated host C ABI. + +Set SCRATCHV_ZIG to a Zig executable, or install a host clang/gcc. Executable +checks skip explicitly if no compiler is available; graph checks always run. +""" + +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import ctypes +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import numpy as np +import pytest + +from scratchv.backend.tensor_c_codegen import TensorCCodegen, TensorCCodegenError +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Value + + +ROOT = Path(__file__).resolve().parents[1] +POINTERS = ctypes.POINTER(ctypes.c_void_p) + + +def graph(shape=(2, 3)): + x = Value("x", shape=shape) + w, bias = Value("weight", shape=(3, 4)), Value("bias", shape=(4,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.extend([w, bias]) + literal = builder.make_const(0.5) + builder.ret(builder.mul(builder.add(builder.matmul(x, w), bias), literal)) + bindings = { + "weight": np.arange(12, dtype=np.float32).reshape(3, 4) / 8, + "bias": np.array([0.25, -0.5, 0.75, -1.0], dtype=np.float32), + } + return builder.program, bindings, literal + + +@pytest.fixture(scope="module") +def host_cc(): + zig = os.environ.get("SCRATCHV_ZIG") + if zig: + return [zig, "cc"] + for name in ("clang", "gcc", "cc"): + found = shutil.which(name) + if found: + return [found] + pytest.skip("Host C execution requires clang/gcc or SCRATCHV_ZIG") + + +@contextmanager +def library(tmp_path, artifact, compiler): + tmp_path.mkdir(parents=True, exist_ok=True) + source = tmp_path / "model.c" + source.write_text(artifact.source, encoding="utf-8") + binary = tmp_path / ("model.dll" if sys.platform == "win32" else "model.so") + environment = dict(os.environ) + environment.setdefault("ZIG_GLOBAL_CACHE_DIR", str(ROOT / "output/zig-global-cache")) + environment.setdefault("ZIG_LOCAL_CACHE_DIR", str(tmp_path / "zig-cache")) + command = [*compiler, "-shared", "-O2", "-std=c11", *artifact.compile_flags] + if sys.platform != "win32": + command.append("-fPIC") + command += [str(source), "-o", str(binary), "-lm"] + result = subprocess.run(command, capture_output=True, text=True, + timeout=180, env=environment) + assert result.returncode == 0, result.stdout + result.stderr + loaded = ctypes.CDLL(str(binary)) + function = getattr(loaded, artifact.function_name) + function.restype = ctypes.c_int + function.argtypes = ([POINTERS, POINTERS, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p] + if artifact.constant_storage == "external" + else [POINTERS, ctypes.c_void_p]) + try: + yield function + finally: + if sys.platform == "win32": + import _ctypes + _ctypes.FreeLibrary(loaded._handle) + + +def pointers(arrays): + return (ctypes.c_void_p * len(arrays))(*(array.ctypes.data for array in arrays)) + + +def arguments(artifact, x): + # Model packaging may receive strided initializers: materialize C-order only + # here at the ABI boundary, never silently reinterpret their underlying bytes. + weights = [np.array(array, copy=True, order="C") for array in artifact.external_initializers] + workspace = np.empty(max(1, (artifact.workspace_bytes + 7) // 8), dtype=np.uint64) + output = np.full(artifact.output.shape, -999, dtype=artifact.output.numpy_dtype) + return weights, workspace, output, [pointers([x]), pointers(weights), + workspace.ctypes.data, artifact.workspace_bytes, + output.ctypes.data] + + +@pytest.mark.parametrize("shape", [(2, 3), (2, 1, 3)]) +@pytest.mark.parametrize("kernel_calls", [False, True]) +def test_external_matches_inline_and_numpy(tmp_path, host_cc, shape, kernel_calls): + program, bindings, _ = graph(shape) + inline = TensorCCodegen(program, bindings).generate() + external = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=kernel_calls).generate() + x = np.arange(np.prod(shape), dtype=np.float32).reshape(shape) / 4 + weights, workspace, output, args = arguments(external, x) + expected = (x @ bindings["weight"] + bindings["bias"]) * np.float32(0.5) + inline_output = np.empty_like(output) + with library(tmp_path / "inline", inline, host_cc) as run: + assert run(pointers([x]), inline_output.ctypes.data) == 0 + with library(tmp_path / "external", external, host_cc) as run: + assert run(*args) == 0 + np.testing.assert_array_equal(output, inline_output) + np.testing.assert_allclose(output, expected, rtol=0, atol=1e-6) + # Runtime data is not baked into the generated function or cached. + weights[0] *= np.float32(2) + assert run(*args) == 0 + np.testing.assert_allclose(output, (x @ weights[0] + weights[1]) * weights[2], + rtol=0, atol=1e-6) + assert external.workspace_bytes == inline.workspace_bytes + + +@pytest.mark.parametrize("kernel_calls", [False, True]) +def test_external_abi_rejects_malformed_buffers_before_writing(tmp_path, host_cc, kernel_calls): + program, bindings, _ = graph() + artifact = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=kernel_calls).generate() + x = np.arange(6, dtype=np.float32).reshape(2, 3) + weights, workspace, output, valid = arguments(artifact, x) + bad_weight = (ctypes.c_void_p * 3)(0, weights[1].ctypes.data, weights[2].ctypes.data) + unaligned_weight = (ctypes.c_void_p * 3)(weights[0].ctypes.data + 1, + weights[1].ctypes.data, weights[2].ctypes.data) + bad_input = (ctypes.c_void_p * 1)(x.ctypes.data + 1) + overflowing_input = (ctypes.c_void_p * 1)((1 << 64) - 4) + cases = [ + (0, None), (1, None), (2, None), (3, artifact.workspace_bytes - 1), + (2, workspace.ctypes.data + 1), (4, None), (4, output.ctypes.data + 1), + (0, bad_input), (0, overflowing_input), (1, bad_weight), (1, unaligned_weight), + (4, x.ctypes.data), (4, weights[0].ctypes.data), (4, workspace.ctypes.data), + (2, weights[0].ctypes.data), (2, x.ctypes.data), + (4, ctypes.addressof(valid[1])), + ] + original_input = x.copy() + with library(tmp_path, artifact, host_cc) as run: + for argument_index, replacement in cases: + args = list(valid) + args[argument_index] = replacement + assert run(*args) == 1, (argument_index, replacement) + assert np.all(output == -999) + np.testing.assert_array_equal(x, original_input) + assert run(*valid) == 0 + + +@pytest.mark.parametrize("kernel_calls", [False, True]) +def test_external_runtime_checks_finite_weights_and_scalar_literals(tmp_path, host_cc, kernel_calls): + program, bindings, _ = graph() + artifact = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=kernel_calls).generate() + x = np.ones((2, 3), np.float32) + weights, workspace, output, args = arguments(artifact, x) + with library(tmp_path, artifact, host_cc) as run: + weights[0][0, 0] = np.nan + assert run(*args) == 2 + weights[0][0, 0] = bindings["weight"][0, 0] + weights[2][()] = 0.75 + assert run(*args) == 2 + weights[2][()] = 0.5 + assert run(*args) == 0 + + +def test_external_runs_with_independent_workspaces(tmp_path, host_cc): + program, bindings, _ = graph() + artifact = TensorCCodegen(program, bindings, constant_storage="external").generate() + with library(tmp_path, artifact, host_cc) as run: + def execute(index): + x = np.full((2, 3), index / 8, np.float32) + weights, workspace, output, args = arguments(artifact, x) + assert run(*args) == 0 + np.testing.assert_array_equal(output, (x @ bindings["weight"] + bindings["bias"]) * 0.5) + with ThreadPoolExecutor(max_workers=4) as executor: + list(executor.map(execute, range(32))) + + +def test_external_return_input_needs_no_weights_or_workspace(tmp_path, host_cc): + x = Value("x", shape=(3,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.ret(x) + artifact = TensorCCodegen(builder.program, constant_storage="external").generate() + assert not artifact.external_weights and artifact.workspace_bytes == 0 + data, output = np.array([1, 2, 3], np.float32), np.zeros(3, np.float32) + with library(tmp_path, artifact, host_cc) as run: + assert run(pointers([data]), None, None, 0, output.ctypes.data) == 0 + np.testing.assert_array_equal(output, data) + + +@pytest.mark.parametrize("dtype,numpy_dtype", [(DataType.FLOAT32, np.float32), + (DataType.INT32, np.int32), + (DataType.INT64, np.int64)]) +def test_return_only_external_literal_and_zero_workspace(tmp_path, host_cc, dtype, numpy_dtype): + builder = IRBuilder() + builder.new_function("main", []) + builder.new_block("entry") + builder.ret(builder.make_const(3, dtype)) + artifact = TensorCCodegen(builder.program, constant_storage="external").generate() + assert artifact.workspace_bytes == 0 + weight = np.array(3, dtype=numpy_dtype) + output = np.array(0, dtype=numpy_dtype) + with library(tmp_path, artifact, host_cc) as run: + assert run(None, pointers([weight]), None, 0, output.ctypes.data) == 0 + assert output.item() == 3 + weight[()] = 4 + assert run(None, pointers([weight]), None, 0, output.ctypes.data) == 2 + + +def test_external_preserves_initializer_order_and_storage_identity(): + program, bindings, literal = graph() + bindings["weight"] = np.arange(24, dtype=np.float32).reshape(3, 8)[:, ::2] + before = program.dump() + codegen = TensorCCodegen(program, bindings, constant_storage="external") + first, second = codegen.generate(), codegen.generate() + assert first == second + assert program.dump() == before + assert [spec.name for spec in first.external_weights] == ["weight", "bias", literal.name] + assert first.external_initializers[0] is bindings["weight"] + assert first.external_initializers[1] is bindings["bias"] + assert first.external_initializers[2].shape == () + assert "array(" not in repr(first) + assert "sv_arena" not in first.source + assert "static const float" not in first.source + assert "scratchv_run_external" in first.source + + +def test_inline_default_is_unchanged_by_opt_in(): + program, bindings, _ = graph() + default = TensorCCodegen(program, bindings).generate() + explicit = TensorCCodegen(program, bindings, constant_storage="inline").generate() + assert default == explicit + assert default.function_name == "scratchv_run" + assert default.constant_storage == "inline" + assert not default.external_weights and not default.external_initializers + assert "static const float" in default.source + assert "sv_arena" in default.source + assert "scratchv_run_external" not in default.source + assert default.kernel_calls is False + + +def test_kernel_calls_deduplicate_operands_and_keep_error_status(tmp_path, host_cc): + x = Value("x", shape=(2,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + squared = builder.mul(x, x) + builder.ret(builder.sqrt(builder.sub(squared, builder.make_const(1.0)))) + model = TensorCCodegen(builder.program, constant_storage="external", kernel_calls=True).generate() + assert model.source.count("static __attribute__((noinline)) int sv_kernel_") == 3 + input_data = np.array([2, 3], np.float32) + weights, workspace, output, args = arguments(model, input_data) + with library(tmp_path, model, host_cc) as run: + assert run(*args) == 0 + np.testing.assert_array_equal(output, np.sqrt(input_data * input_data - np.float32(1))) + input_data[0] = 0 + assert run(*args) == 2 + + +def test_kernel_calls_propagate_gather_bounds(tmp_path, host_cc): + x, indexes = Value("x", shape=(2,)), Value("indexes", DataType.INT64, shape=(1,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(indexes) + builder.ret(builder.gather(x, indexes)) + model = TensorCCodegen(builder.program, {"indexes": np.array([0], np.int64)}, + constant_storage="external", kernel_calls=True).generate() + input_data = np.array([2, 3], np.float32) + weights, workspace, output, args = arguments(model, input_data) + with library(tmp_path, model, host_cc) as run: + assert run(*args) == 0 and output[0] == 2 + weights[0][0] = 2 + assert run(*args) == 3 + + +def test_kernel_calls_are_explicit_external_opt_in(): + program, bindings, _ = graph() + with pytest.raises(TensorCCodegenError, match="requires external"): + TensorCCodegen(program, bindings, kernel_calls=True).generate() + with pytest.raises(TensorCCodegenError, match="boolean"): + TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=1).generate() + default = TensorCCodegen(program, bindings, constant_storage="external").generate() + explicit = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=False).generate() + assert default.source == explicit.source and not default.kernel_calls + assert default.matmul_policy == "sequential" + + +def matmul_experiment(left, right): + """Use identical seeded FP32 operands for both C policies and ORT.""" + x, weight = Value("x", shape=left), Value("weight", shape=right) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(weight) + builder.ret(builder.matmul(x, weight)) + generator = np.random.default_rng(4108) + data = generator.standard_normal(left).astype(np.float32) + weights = generator.standard_normal(right).astype(np.float32) + return builder.program, {"weight": weights}, data + + +def ort_matmul(data, weights): + onnx = pytest.importorskip("onnx") + ort = pytest.importorskip("onnxruntime") + output_shape = np.matmul(data, weights).shape + graph = onnx.helper.make_graph( + [onnx.helper.make_node("MatMul", ["x", "weight"], ["output"])], "matmul-policy", + [onnx.helper.make_tensor_value_info("x", onnx.TensorProto.FLOAT, data.shape)], + [onnx.helper.make_tensor_value_info("output", onnx.TensorProto.FLOAT, output_shape)], + [onnx.numpy_helper.from_array(weights, "weight")], + ) + model = onnx.helper.make_model(graph, opset_imports=[onnx.helper.make_opsetid("", 14)], ir_version=10) + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + session = ort.InferenceSession(model.SerializeToString(), options, providers=["CPUExecutionProvider"]) + return session.run(None, {"x": data})[0] + + +MATMUL_POLICY_CASES = [ + ((3, 127), (127, 5)), ((3, 128), (128, 7)), ((3, 129), (129, 5)), + ((3, 1024), (1024, 9)), ((2, 1, 3, 129), (1, 2, 129, 5)), + ((129,), (129,)), ((129,), (129, 5)), ((3, 129), (129,)), ((2, 0), (0, 3)), +] + + +@pytest.mark.parametrize("left,right", MATMUL_POLICY_CASES) +def test_blocked_fma_host_against_ort(tmp_path, host_cc, left, right): + program, bindings, data = matmul_experiment(left, right) + expected = ort_matmul(data, bindings["weight"]) + for policy in ("sequential", "blocked_fma"): + model = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=True, + matmul_policy=policy).generate() + weights, workspace, output, args = arguments(model, data) + with library(tmp_path / policy, model, host_cc) as run: + assert run(*args) == 0 + assert model.matmul_policy == policy + assert output.shape == expected.shape and output.dtype == expected.dtype + assert np.isfinite(output).all() + assert np.max(np.abs(output.astype(np.float64) - expected.astype(np.float64)), initial=0) < 1e-3 + + +def test_matmul_policy_is_explicit_external_experiment(): + program, bindings, _ = graph() + with pytest.raises(TensorCCodegenError, match="matmul_policy"): + TensorCCodegen(program, bindings, constant_storage="external", matmul_policy="unknown").generate() + with pytest.raises(TensorCCodegenError, match="require external"): + TensorCCodegen(program, bindings, matmul_policy="blocked_fma").generate() + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +@pytest.mark.parametrize("left,right", [((2, 127), (127, 5)), ((2, 128), (128, 7)), + ((2, 129), (129, 5)), ((2, 1024), (1024, 9))]) +def test_blocked_fma_rv64_against_ort(tmp_path, left, right): + from scratchv.runtime.riscv_external import build_external, run_external + from scratchv.runtime.riscv_tensor import discover_toolchain + from scratchv.runtime.weight_bundle import write_weight_bundle + program, bindings, data = matmul_experiment(left, right) + expected = ort_matmul(data, bindings["weight"]) + for policy in ("sequential", "blocked_fma"): + model = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=True, + matmul_policy=policy).generate() + root = tmp_path / policy + root.mkdir() + write_weight_bundle(model.external_weights, model.external_initializers, root / "weights") + executable = build_external(model, root / "weights", root / "build", discover_toolchain()) + output, report = run_external(executable, {"x": data}, root / "run", timeout=30) + assert report["passed"] + assert np.max(np.abs(output.astype(np.float64) - expected.astype(np.float64)), initial=0) < 1e-3 + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +def test_kernel_calls_rv64_matches_monolithic(tmp_path): + from scratchv.runtime.riscv_external import build_external, run_external + from scratchv.runtime.riscv_tensor import discover_toolchain + from scratchv.runtime.weight_bundle import write_weight_bundle + program, bindings, _ = graph() + x = np.arange(6, dtype=np.float32).reshape(2, 3) / 4 + actuals = [] + for split in (False, True): + artifact = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=split).generate() + root = tmp_path / str(split) + root.mkdir() + write_weight_bundle(artifact.external_weights, artifact.external_initializers, root / "weights") + executable = build_external(artifact, root / "weights", root / "build", discover_toolchain()) + output, report = run_external(executable, {"x": x}, root / "run", timeout=30) + assert report["passed"] + actuals.append(np.array(output)) + np.testing.assert_array_equal(actuals[0].view(np.uint32), actuals[1].view(np.uint32)) + np.testing.assert_array_equal(actuals[0], (x @ bindings["weight"] + bindings["bias"]) * 0.5) + + +@pytest.mark.parametrize("failure", ["missing", "shape", "dtype", "nan", "infinity", "limit"]) +def test_external_preserves_initializer_validation(failure): + program, bindings, _ = graph() + kwargs = {} + if failure == "missing": + del bindings["weight"] + elif failure == "shape": + bindings["weight"] = bindings["weight"].reshape(4, 3) + elif failure == "dtype": + bindings["weight"] = bindings["weight"].astype(np.float64) + elif failure in ("nan", "infinity"): + bindings["weight"][2, 3] = np.nan if failure == "nan" else np.inf + else: + kwargs["max_constant_bytes"] = 1 + with pytest.raises(TensorCCodegenError): + TensorCCodegen(program, bindings, constant_storage="external", **kwargs).generate() + + +def test_external_large_strided_weight_is_scanned_including_final_chunk(): + count = 262144 + 17 + weight = Value("weight", shape=(count,)) + builder = IRBuilder() + builder.new_function("main", []) + builder.new_block("entry") + builder.program.global_values.append(weight) + builder.ret(weight) + data = np.ones(count * 2, np.float32)[::2] + codegen = TensorCCodegen(builder.program, {"weight": data}, constant_storage="external") + artifact = codegen.generate() + assert artifact.external_initializers[0] is data + assert len(artifact.source) < 10000 + data[-1] = np.nan + with pytest.raises(TensorCCodegenError, match="Nonfinite"): + codegen.generate() + + +def test_external_scalar_binding_preserves_signed_zero(tmp_path, host_cc): + value = Value("zero", is_constant=True, const_value=0.0) + builder = IRBuilder() + builder.new_function("main", []) + builder.new_block("entry") + builder.program.global_values.append(value) + builder.ret(value) + with pytest.raises(TensorCCodegenError, match="constant bits"): + TensorCCodegen(builder.program, {"zero": np.array(-0.0, np.float32)}, + constant_storage="external").generate() + artifact = TensorCCodegen(builder.program, constant_storage="external").generate() + negative_zero = np.array(-0.0, np.float32) + output = np.array(1.0, np.float32) + with library(tmp_path, artifact, host_cc) as run: + assert run(None, pointers([negative_zero]), None, 0, output.ctypes.data) == 2 + assert output == 1 + + +@pytest.mark.parametrize("storage", ["unknown", None, 1]) +def test_invalid_storage_mode(storage): + program, bindings, _ = graph() + with pytest.raises(TensorCCodegenError, match="constant_storage"): + TensorCCodegen(program, bindings, constant_storage=storage).generate() + + +@pytest.mark.parametrize("field", ["max_workspace_bytes", "max_constant_bytes"]) +@pytest.mark.parametrize("bound", [-1, True, 1 << 64]) +def test_memory_limits_fit_u64(field, bound): + program, bindings, _ = graph() + with pytest.raises(TensorCCodegenError, match="64-bit"): + TensorCCodegen(program, bindings, constant_storage="external", **{field: bound}).generate() diff --git a/tests/test_tensor_return_contract.py b/tests/test_tensor_return_contract.py new file mode 100644 index 0000000..9c6fea7 --- /dev/null +++ b/tests/test_tensor_return_contract.py @@ -0,0 +1,99 @@ +"""Return metadata is an ABI contract, not an alternative buffer interpretation.""" +import pytest +import numpy as np + +from scratchv.backend.tensor_c_codegen import TensorCCodegen, TensorCCodegenError +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def program_for(value, returned=None, declarations=()): + builder = IRBuilder() + function = builder.new_function("main", [value]) + builder.new_block("entry") + function.returns = list(declarations) + builder.ret(value if returned is None else returned) + return builder.program + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +@pytest.mark.parametrize("dtype", [DataType.INT32, DataType.INT64]) +def test_return_reference_cannot_reinterpret_defined_dtype(storage, dtype): + value = Value("x", DataType.FLOAT32, shape=(2,)) + returned = Value("x", dtype, shape=(2,)) + with pytest.raises(TensorCCodegenError, match="RETURN.*dtype"): + TensorCCodegen(program_for(value, returned), constant_storage=storage).generate() + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +@pytest.mark.parametrize("shape", [(3,), (1, 2), (), (True,), ("N",)]) +def test_return_reference_checks_known_input_shape(storage, shape): + value = Value("x", shape=(2,)) + returned = Value("x", shape=shape) + with pytest.raises(TensorCCodegenError, match="RETURN.*shape|static"): + TensorCCodegen(program_for(value, returned), constant_storage=storage).generate() + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +@pytest.mark.parametrize("declarations", [ + [Value("result", DataType.INT64, shape=(2,))], + [Value("result", shape=())], + [Value("result", shape=(1, 2))], + [Value("result", shape=(True,))], + [Value("result", shape=(2,)), Value("other", shape=(2,))], +]) +def test_function_return_signature_cannot_disagree_with_actual_output(storage, declarations): + value = Value("x", shape=(2,)) + with pytest.raises(TensorCCodegenError, match="return|RETURN|static"): + TensorCCodegen(program_for(value, declarations=declarations), constant_storage=storage).generate() + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +@pytest.mark.parametrize("shape", [(), (0,), (2, 3)]) +def test_explicit_return_signature_accepts_scalar_empty_and_tensor(storage, shape): + value = Value("x", shape=shape) + declaration = Value("public_result", shape=shape) + artifact = TensorCCodegen(program_for(value, declarations=[declaration]), constant_storage=storage).generate() + assert artifact.output.name == "x" + assert artifact.output.shape == shape + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +def test_builder_intermediate_return_keeps_inferred_shape(storage): + builder = IRBuilder() + builder.new_function("main", [Value("x", shape=(2, 3))]) + builder.new_block("entry") + result = builder.neg(builder.program.functions[0].params[0]) + assert result.shape == () # Builder default, not an explicit signature. + builder.ret(result) + artifact = TensorCCodegen(builder.program, constant_storage=storage).generate() + assert artifact.output.shape == (2, 3) + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +def test_inferred_intermediate_return_rejects_explicit_wrong_shape(storage): + builder = IRBuilder() + builder.new_function("main", [Value("x", shape=(2, 3))]) + builder.new_block("entry") + result = builder.neg(builder.program.functions[0].params[0]) + builder.ret(Value(result.name, shape=(3, 2))) + with pytest.raises(TensorCCodegenError, match="RETURN.*shape"): + TensorCCodegen(builder.program, constant_storage=storage).generate() + + +@pytest.mark.parametrize("shape", [None, 0, False, ""]) +@pytest.mark.parametrize("consumer", ["inline", "external", "interpreter"]) +def test_only_empty_sequence_is_an_inferred_intermediate_shape(shape, consumer): + builder = IRBuilder() + x = Value("x", shape=(2,)) + builder.new_function("main", [x]) + builder.new_block("entry") + result = builder.neg(x) + builder.ret(Value(result.name, shape=shape)) + if consumer == "interpreter": + with pytest.raises(IRExecutionError, match="ShapeError"): + IRInterpreter(builder.program).run({"x": np.ones(2, dtype=np.float32)}) + else: + with pytest.raises(TensorCCodegenError, match="static"): + TensorCCodegen(builder.program, constant_storage=consumer).generate() diff --git a/tests/test_w1_qwen3_export.py b/tests/test_w1_qwen3_export.py index ef1faf3..9fa0db2 100644 --- a/tests/test_w1_qwen3_export.py +++ b/tests/test_w1_qwen3_export.py @@ -5,6 +5,7 @@ import stat import zipfile from argparse import Namespace +from pathlib import Path import numpy as np import onnx @@ -218,6 +219,117 @@ def test_dependency_mismatch_fails_before_model_load(monkeypatch): probe.environment() +@pytest.mark.parametrize("name", ["report.md", "report.json"]) +@pytest.mark.parametrize("failure", ["write", "close", "publish"]) +@pytest.mark.parametrize("numeric_passed", [False, True]) +def test_report_io_failure_cannot_leave_pass(artifact, tmp_path, monkeypatch, + name, failure, numeric_passed): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + if not numeric_passed: + def fail_ort(*args, **kwargs): + raise RuntimeError("original ORT failure") + monkeypatch.setattr(probe, "execute_ort", fail_ort) + output = tmp_path / "report-fault" + output.mkdir() + # Reusing this CLI's output directory must not retain an older success. + (output / "report.json").write_text('{"passed": true}', encoding="utf-8") + original_write, original_replace = Path.write_text, Path.replace + + def broken_write(path, content, *args, **kwargs): + if path == output / f".{name}.tmp" and failure in ("write", "close"): + if failure == "close": + original_write(path, content, *args, **kwargs) + raise OSError(f"injected {failure} failure") + return original_write(path, content, *args, **kwargs) + + def broken_replace(path, target): + if Path(target) == output / name and failure == "publish": + raise OSError("injected publish failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "write_text", broken_write) + monkeypatch.setattr(Path, "replace", broken_replace) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 1 + if name == "report.json": + assert not (output / "report.json").exists() + text = (output / "report.md").read_text(encoding="utf-8") + assert "Result: FAIL" in text + if not numeric_passed: + assert "original ORT failure" in text + else: + report = json.loads((output / "report.json").read_text(encoding="utf-8")) + assert report["passed"] is False and report["report_write_errors"] + if numeric_passed: + assert report["failed_stage"] == "report-write" + else: + assert report["failed_stage"] == "ort" + assert report["error"] == "RuntimeError: original ORT failure" + assert not list(output.glob(".report.*.tmp")) + + +def test_report_json_is_published_after_required_view(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-order" + original_replace = Path.replace + names = [] + + def record(path, target): + names.append(Path(target).name) + if Path(target).name == "report.json": + assert (output / "report.md").is_file() + assert not (output / "report.json").exists() + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", record) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 0 + assert names == ["report.md", "report.json"] + + +def test_transient_json_publish_error_retries_only_as_fail(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-retry" + original_replace = Path.replace + attempts = [] + + def fail_once(path, target): + if Path(target).name == "report.json": + attempts.append(json.loads(path.read_text(encoding="utf-8"))["passed"]) + if len(attempts) == 1: + raise OSError("transient publish failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", fail_once) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 1 + assert attempts == [True, False] + assert json.loads((output / "report.json").read_text(encoding="utf-8"))["passed"] is False + assert "Result: FAIL" in (output / "report.md").read_text(encoding="utf-8") + + +def test_interruption_does_not_leave_previous_success(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-interrupted" + output.mkdir() + (output / "report.json").write_text('{"passed": true}', encoding="utf-8") + (output / "report.md").write_text("Result: PASS", encoding="utf-8") + + def interrupted(*args, **kwargs): + raise KeyboardInterrupt() + + monkeypatch.setattr(probe, "execute_ort", interrupted) + with pytest.raises(KeyboardInterrupt): + probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) + assert not (output / "report.json").exists() + assert not (output / "report.md").exists() + + def test_source_fingerprints_report_missing_sources_honestly(tmp_path, monkeypatch): monkeypatch.setattr(probe, "ROOT", tmp_path) result = probe.source_fingerprints() diff --git a/tests/test_w3_actions_summary.py b/tests/test_w3_actions_summary.py new file mode 100644 index 0000000..c00aa87 --- /dev/null +++ b/tests/test_w3_actions_summary.py @@ -0,0 +1,188 @@ +"""W3 Actions pages must distinguish measured results, partial coverage and skips.""" +from copy import deepcopy +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from scripts.summarize_w3_ci import GATES, main, render +from scripts import summarize_w3_ci + +ROOT = Path(__file__).resolve().parents[1] + + +def save(folder, report): + folder.mkdir(parents=True, exist_ok=True) + (folder / "report.json").write_text(json.dumps(report), encoding="utf-8") + + +def metric(value, name): + return {"name": name, "passed": True, "max_abs": value, + "relative_l2": 2e-7, "cosine_similarity": 0.99999} + + +def full_report(**extra): + return {"gate": "numeric:ir-full-qwen3", "status": "PASS", "passed": True, + "coverage_complete": True, "fp32_mode": "reference", "atol": 1e-4, + "elapsed_seconds": 75.125, "required_cases": ["one", "two"], "selected_cases": ["one", "two"], + "cases": [{"name": name, "passed": True, "comparison": { + "logits": [metric(value, "logits.npy"), metric(value * 2, "diagnostic_logits.npy")], + "checkpoints": [metric(0.7, "layer_3.output")]}} + for name, value in [("one", 1e-6), ("two", 3e-6)]], **extra} + + +def test_full_key_metrics_are_visible_and_checkpoint_details_are_folded(tmp_path): + report = full_report() + before = deepcopy(report) + save(tmp_path, report) + text = render("full", tmp_path, "success") + visible = text.split(" ", 1)[0] + assert "**PASS**" in visible and "75.125 s" in visible + assert "2/2 cases" in visible and "reference" in visible + assert format(3e-6, ".16e") in visible and format(6e-6, ".16e") in visible + assert format(0.7, ".16e") not in visible + assert "No full-model QEMU result" in visible + assert "w3-full-numeric-reports" in text and "" in text + assert json.loads((tmp_path / "report.json").read_text()) == before + + +@pytest.mark.parametrize("outcome,expected", [("skipped", "NOT RUN"), ("", "NOT RUN"), ("cancelled", "CANCELLED")]) +def test_skipped_step_does_not_reuse_a_passing_report(tmp_path, outcome, expected): + save(tmp_path, full_report()) + text = render("full", tmp_path, outcome) + assert f"**{expected}**" in text and "PASS" not in text + assert "最大绝对误差" not in text + + +@pytest.mark.parametrize("outcome", ["success", "failure"]) +def test_missing_report_does_not_claim_pass(tmp_path, outcome): + text = render("full", tmp_path, outcome) + assert "MISSING REPORT" in text and "PASS" not in text and outcome in text + + +@pytest.mark.parametrize("invalid", ["{", "[]", '{"gate":"wrong"}', '{"gate":"numeric:ir-full-qwen3","cases":null}']) +def test_invalid_reports_do_not_crash_summary(tmp_path, invalid): + (tmp_path / "report.json").write_text(invalid) + text = render("full", tmp_path, "failure") + assert "INVALID REPORT" in text and "PASS" not in text + + +def test_failed_step_overrides_passing_report(tmp_path): + save(tmp_path, full_report()) + text = render("full", tmp_path, "failure") + assert "**FAIL (step did not succeed)**" in text + assert "**PASS**" not in text + + +def test_partial_coverage_shows_unexecuted_case_and_missing_metrics(tmp_path): + report = full_report(status="PARTIAL", passed=False, coverage_complete=False, selected_cases=["one"]) + report["cases"] = report["cases"][:1] + save(tmp_path, report) + text = render("full", tmp_path, "failure") + assert "**PARTIAL (not acceptance)**" in text + assert "1/2 cases recorded" in text and "NOT RUN" in text and "two" in text + assert "未记录 1/2" in text and "**PASS**" not in text + + +def test_claimed_pass_with_incomplete_coverage_is_not_pass(tmp_path): + save(tmp_path, full_report(coverage_complete=False)) + assert "**INCOMPLETE (not acceptance)**" in render("full", tmp_path, "success") + + +def test_preparation_lists_all_checks_and_never_claims_full_model_execution(tmp_path): + report = {"gate": "preparation:w3", "status": "FAIL", "passed": False, "elapsed_seconds": 32, + "gates": [{"name": "attention", "passed": False, "status": "FAIL", "elapsed_seconds": 1.5}]} + save(tmp_path, report) + save(tmp_path / "attention", {"gate": "unit:attention-backend", "status": "PASS", "passed": True, + "cases": [{"name": "one", "executions": [{"optimization": level, "qemu_vs_ort": metric(3e-6, "logits")} + for level in ("none", "all")]}]}) + # A stale medium child without a corresponding parent invocation is ignored. + save(tmp_path / "medium", {"gate": "w3-medium-host", "status": "PASS", "passed": True}) + text = render("preparation", tmp_path, "failure") + visible = text.split("", 1)[0] + assert "NOT RUN by this job" in visible + assert "FAIL (parent validation)" in visible and "1 cases / 2 target executions recorded" in visible + for _, _, scope in GATES: + assert scope in visible + assert visible.count("NOT RUN") == 5 + assert "w3-preparation-reports" in text + + +def test_missing_child_report_cannot_leave_overall_pass(tmp_path): + save(tmp_path, {"gate": "preparation:w3", "status": "PASS", "passed": True, + "gates": [{"name": "medium", "status": "PASS", "passed": True}]}) + text = render("preparation", tmp_path, "success") + assert "**INCOMPLETE REPORTS (not acceptance)**" in text and "MISSING REPORT" in text + assert "**PASS**" not in text + + +def test_errors_are_escaped_and_arbitrary_machine_evidence_is_not_dumped(tmp_path): + report = full_report(status="FAIL", passed=False, error=""} + before = deepcopy(report) + write_reports(tmp_path, report) + assert json.loads((tmp_path / "report.json").read_text(encoding="utf-8")) == before + markdown = (tmp_path / "report.md").read_text(encoding="utf-8") + page = (tmp_path / "report.html").read_text(encoding="utf-8") + assert "Result: **FAIL**" in markdown + assert "通过(1/1)" in markdown and "总体结论" in markdown + assert "隔离检查未通过 1 项" in markdown + assert "" in markdown and "" in page + assert markdown.index("最大绝对误差") < markdown.index("") + assert '"checkpoints"' not in markdown + assert "[完整机器报告](report.json)" in markdown + assert 'href="report.json"' in page + assert "