Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions probes/numeric_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,16 @@ def _table_rows(comparisons):
passed = sum(row.get("passed") is True for row in rows)
failed = sum(row.get("passed") is False for row in rows)
unknown = len(rows) - passed - failed
missing = Counter(str(row["missing_reason"]) for row in rows if row.get("missing_reason"))
if not rows:
status = "未测(0 条比较)"
elif missing:
reasons = ";".join(f"{reason} ×{count}" for reason, count in missing.items())
failures = f",数值比较失败 {failed}" if failed else ""
status = (f"未完成(通过 {passed}/{len(rows)}{failures},"
f"未得到结果 {sum(missing.values())}:{reasons})")
if unknown > sum(missing.values()):
status += f";另有 {unknown - sum(missing.values())} 次结果未确认"
elif passed == len(rows):
status = f"通过({passed}/{len(rows)})"
else:
Expand Down
5 changes: 5 additions & 0 deletions probes/w2_backend_ops/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import argparse
from collections import Counter
import copy
import hashlib
from html import escape
Expand Down Expand Up @@ -104,6 +105,10 @@ def run_probe(out, report, *, cc=None, qemu=None, timeout=120):
try:
path = folder / "model.onnx"
expected, parser, program = reference_case(case, path)
row["frontend_parse"] = {
"passed": True,
"operators": dict(sorted(Counter(node.op_type for node in case.model.graph.node).items())),
}
np.savez(folder / "inputs.npz", **case.feed)
np.save(folder / "numpy.npy", case.expected)
np.save(folder / "ort.npy", expected)
Expand Down
4 changes: 4 additions & 0 deletions probes/w2_qwen3_parse/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ def stage(name, operation):
"issues": [str(issue) for issue in issues]}
if not verified:
raise ValueError("ScratchV IR verifier rejected the complete model; see ir_verifier.issues")
report["frontend_parse"] = {
"passed": True,
"operators": dict(sorted(Counter(node.op_type for node in model.graph.node).items())),
}
try:
audit = stage("ir-and-bindings", lambda: audit_parsed_model(model, parser, program, model_dir))
except Exception:
Expand Down
7 changes: 7 additions & 0 deletions probes/w2_qwen3_small/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ def run_probe(out: Path, report: dict, model_seed: int = 0) -> None:
name: tensor.detach().cpu().numpy() for name, tensor in wrapper.model.state_dict().items()})
(out / "config.json").write_text(wrapper.model.config.to_json_string(use_diff=False), encoding="utf-8")
cases = input_cases()
report["planned_cases"] = len(cases)

def reference(feed):
args = [torch.from_numpy(feed[name]) for name in ("input_ids", "attention_mask")]
Expand Down Expand Up @@ -240,12 +241,18 @@ def reference(feed):
providers=["CPUExecutionProvider"])
report["stage"] = "parse"
parsers, programs = {}, {}
parsed_operators, parsed_graphs = Counter(), []
for kind, filename in [("normal", "model.onnx"), ("diagnostic", "diagnostics.onnx")]:
parsers[kind] = ONNXParser()
programs[kind] = parsers[kind].parse(str(out / filename))
valid, errors = verify_ir(programs[kind])
if not valid:
raise ValueError(f"{kind} IR verification failed: {errors}")
graph = ordinary if kind == "normal" else diagnostic
parsed_operators.update(node.op_type for node in graph.graph.node)
parsed_graphs.append(kind)
report["frontend_parse"] = {"passed": True, "operators": dict(sorted(parsed_operators.items())),
"graphs": list(parsed_graphs)}
report["stage"] = "numeric"
report["cases"] = []
cached_logits = {}
Expand Down
6 changes: 5 additions & 1 deletion probes/w2_runtime_model/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import argparse
from collections import Counter
import copy
from datetime import datetime, timezone
import gc
Expand Down Expand Up @@ -171,7 +172,8 @@ def source_evidence():


def run_probe(directory, out, report):
report.update(stage="environment", environment=environment(), tokenizer_environment=require_environment())
report.update(stage="environment", planned_cases=len(CASES), levels=list(LEVELS),
environment=environment(), tokenizer_environment=require_environment())
import onnx
import onnxruntime as ort
import torch
Expand Down Expand Up @@ -258,6 +260,7 @@ def require_valid(program):
check_onnx_contract(exported)
report["onnx"] = {"sha256": sha256(out / "model.onnx"), "bytes": (out / "model.onnx").stat().st_size,
"nodes": len(exported.graph.node), "opset": 18,
"operators": dict(sorted(Counter(node.op_type for node in exported.graph.node).items())),
"exporter": "torch.onnx.export(dynamo=True, optimize=False, fallback=False)",
"inputs": {"input_ids": "INT64[1,256]", "attention_mask": "FP32[1,1,256,256]"},
"output": "FP32[1,256,151936]", "output_bytes": SEQ * VOCAB * 4}
Expand Down Expand Up @@ -292,6 +295,7 @@ def require_valid(program):
parser = ONNXParser()
program = parser.parse(str(out / "model.onnx"))
require_valid(program)
report["frontend_parse"] = {"passed": True, "operators": dict(report["onnx"]["operators"])}
report["parse_seconds"] = time.perf_counter() - started
report["ir_optimization_seconds"] = {}
for level in LEVELS:
Expand Down
119 changes: 94 additions & 25 deletions scripts/summarize_w2_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,28 @@ def _onnx_operators(report, name):
return set(_mapping(inner.get("operators")))


def parsed_operators(record):
"""New reports record successful parsing separately from later execution."""
evidence = _mapping(record.get("frontend_parse"))
if evidence.get("passed") is True:
return {str(op).lower() for op in _mapping(evidence.get("operators"))}
return set()


def exercised_operators(reports):
"""``(frontend_op_types, backend_opcodes)`` actually exercised in this run."""
onnx_seen = set()
for name in (QWEN3_SMALL, QWEN3_PARSE):
if reports.get(name) is not None:
onnx_seen |= _onnx_operators(reports[name], name)
for name in (QWEN3_SMALL, QWEN3_PARSE, RUNTIME_MODEL):
report = _mapping(reports.get(name))
if "frontend_parse" in report:
onnx_seen |= parsed_operators(report)
# Compatibility with reports written before the explicit parse record.
# An exported graph's operator list alone does not prove parsing passed.
elif name == QWEN3_SMALL and report.get("stage") in ("numeric", "complete"):
onnx_seen |= _onnx_operators(report, name)
elif (name == QWEN3_PARSE and isinstance(report.get("ir"), dict)
and _mapping(report.get("ir_verifier")).get("passed") is True):
onnx_seen |= _onnx_operators(report, name)

opcodes = set()
# The two-layer model is the only graph the RISC-V QEMU probe compiles, so
Expand All @@ -171,7 +187,13 @@ def exercised_operators(reports):
opcodes |= {opcode_name(op) for op in _onnx_operators(reports[QWEN3_SMALL], QWEN3_SMALL)}
# The operator probe records only its families; a passing execution proves them.
for case in _cases(reports.get(BACKEND_OPS)):
family = str(case.get("name", "")).split("_", 1)[0]
family = case.get("family") or str(case.get("name", "")).split("_", 1)[0]
if "frontend_parse" in case:
onnx_seen |= parsed_operators(case)
elif isinstance(case.get("ort_vs_numpy"), dict) or case.get("model_sha256"):
# Legacy fixed probe graphs: these fields are written only after
# reference_case returned from parse + IR verification.
onnx_seen |= {op.replace("_", "") for op in FAMILY_OPS.get(family, ())}
if any(row.get("passed") is True for row in _rows(case, "executions")):
opcodes |= set(FAMILY_OPS.get(family, ()))
return {str(op).lower() for op in onnx_seen}, opcodes & set(backend_opcodes())
Expand All @@ -188,7 +210,7 @@ def measured(names, exercised):
if not names:
return "无"
hit = [name for name in names if name.lower() in exercised]
return listing(hit) if hit else "无(本次未执行)"
return listing(hit) if hit else "无(没有通过记录)"

rows = [
("IR 层 (`ONNXParser._handle_*`)", listing(supported_frontend),
Expand All @@ -215,40 +237,85 @@ def precision_legend():
]


def missing_comparison(record=None):
record = _mapping(record)
stage = record.get("stage") or record.get("error_stage")
reason = {"reference": "参考结果未完成", "ir": "IR 计算未完成",
"compile": "编译失败" if record.get("error") else "编译未完成",
"qemu": "QEMU 运行未完成"}.get(stage, "未执行或未记录比较结果")
return {"missing_reason": reason}


def comparison(record, key):
result = record.get(key)
return result if isinstance(result, dict) and result else missing_comparison(record)


def fill_missing(rows, planned):
# Reports are external input. Bound the count as well as the file size.
if type(planned) is not int or not 0 <= planned <= 4096:
raise ValueError("Invalid planned comparison count")
return rows + [missing_comparison() for _ in range(max(0, planned - len(rows)))]


def planned_cases(report, legacy_count):
return fill_missing(_cases(report), report.get("planned_cases", legacy_count))


def planned_executions(report):
executions = []
levels = report.get("levels", ("none", "all"))
for case in _cases(report):
rows = _rows(case, "executions")
executions.extend(rows)
present = {row.get("optimization") for row in rows}
executions.extend({"optimization": level, "stage": case.get("stage"),
"error": case.get("error")}
for level in levels if level not in present)
return fill_missing(executions, report.get("planned_executions", len(executions)))


def precision_comparisons(reports):
comparisons = []

def add(label, records):
comparisons.append((label, [row for row in records if isinstance(row, dict)]))
comparisons.append((label, [row if isinstance(row, dict)
else missing_comparison() for row in records]))

backend = reports.get(BACKEND_OPS)
if backend is not None:
executions = [row for case in _cases(backend) for row in _rows(case, "executions")]
executions = planned_executions(backend)
for key, label in (("ir_vs_ort", "IR vs ORT"), ("ir_vs_numpy", "IR vs NumPy"),
("qemu_vs_ort", "QEMU vs ORT"), ("qemu_vs_numpy", "QEMU vs NumPy")):
add(f"w2-backend-ops / {label}", [row.get(key) for row in executions])
add(f"w2-backend-ops / {label}", [comparison(row, key) for row in executions])

if reports.get(QWEN3_SMALL) is not None:
# The host IR comparison wraps one tensor_diff per checkpoint; report
# the final output, which is what the probe's own gate compares.
records = []
for case in _cases(reports[QWEN3_SMALL]):
checkpoints = _rows(_mapping(case.get("ir_vs_ort")), "checkpoints")
final = [row for row in checkpoints if row.get("name") == "logits"]
if final:
records.extend(final)
elif isinstance(case.get("ordinary_logits"), dict):
records.append(case["ordinary_logits"].get("ir_vs_ort"))
add("qwen3-small host IR / IR vs ORT(最终 logits)", records)
# Do not substitute the diagnostic graph when ordinary execution fails.
records = [comparison(_mapping(case.get("ordinary_logits")), "ir_vs_ort")
for case in planned_cases(reports[QWEN3_SMALL], 7)]
add("两层模型:IR 解释器与 ORT(最终输出)", records)

if reports.get(QWEN3_RISCV) is not None:
add("qwen3-small RV64 QEMU / QEMU vs ORT",
[row for case in _cases(reports[QWEN3_RISCV]) for row in _rows(case, "qemu")])
report = reports[QWEN3_RISCV]
# Keep both normal and diagnostic results, as requested. Only account
# for missing executions; do not change the scope of this comparison.
rows = [row for case in _cases(report) for row in _rows(case, "qemu")]
planned = _mapping(report.get("timing")).get("planned_count", len(rows))
add("qwen3-small RV64 QEMU / QEMU vs ORT", fill_missing(rows, planned))

if reports.get(RUNTIME_MODEL) is not None:
executions = [row for case in _cases(reports[RUNTIME_MODEL]) for row in _rows(case, "executions")]
add("w2-runtime-model / vs torch", [row.get("vs_torch") for row in executions])
add("w2-runtime-model / vs ORT", [row.get("vs_ort") for row in executions])
report = reports[RUNTIME_MODEL]
engines = ["ort", *(f"ir-{level}" for level in report.get("levels", ("none", "all")))]
executions = []
for case in planned_cases(report, 2):
rows = _rows(case, "executions")
executions.extend(rows)
present = {row.get("engine") for row in rows}
executions.extend({"engine": engine} for engine in engines if engine not in present)
add("w2-runtime-model / vs torch", [comparison(row, "vs_torch") for row in executions])
# ORT is the reference here; there is deliberately no ORT-vs-itself test.
add("w2-runtime-model / vs ORT", [comparison(row, "vs_ort") for row in executions
if row.get("engine") != "ort"])

# These two chains publish structural evidence only; say so instead of omitting them.
add("w2-runtime(文本层,无数值比较)", [])
Expand Down Expand Up @@ -318,7 +385,9 @@ def render(directory):
"请检查各 W2 job 的日志与产物。\n")

lines = ["## W2 测试项\n\n", "### 算子支持\n\n", *operators,
"\n### 精度\n\n", *precision_legend(), precision, "\n\n", METRIC_EXPLANATION, "\n\n",
"\n### 精度\n\n", *precision_legend(), precision,
"\n\nIR 最终输出一行使用只输出最终结果的模型,不用额外输出中间值的版本代替。\n\n",
METRIC_EXPLANATION, "\n\n",
"### 耗时\n\n", *timing]
rendered = "".join(lines)
if len(rendered.encode("utf-8")) > MAX_SUMMARY_BYTES:
Expand Down
15 changes: 15 additions & 0 deletions tests/test_numeric_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ def test_failures_are_not_hidden_by_passing_rows():
assert "未记录 1/3" in result


@pytest.mark.parametrize("render", [compact_metric_table, compact_metric_table_html])
def test_missing_comparisons_keep_the_count_and_failure_reason(render):
result = render([("partial", [record(), {"missing_reason": "编译失败"}])])
assert "未完成(通过 1/2,未得到结果 1:编译失败 ×1)" in result
assert "已记录 1/2" in result
assert "未记录 1/2" in result
assert "通过(1/1)" not in result


def test_numerical_failures_remain_distinct_from_missing_comparisons():
failed = dict(record(), passed=False)
result = compact_metric_table([("mixed", [record(), failed, {"missing_reason": "编译失败"}])])
assert "通过 1/3,数值比较失败 1,未得到结果 1" in result


def test_undefined_values_show_reason_and_coverage_not_zero():
undefined = dict(passed=True, max_abs=0.0, relative_l2=None, cosine_similarity=None,
relative_l2_reason="zero reference norm", cosine_similarity_reason="zero norm")
Expand Down
10 changes: 10 additions & 0 deletions tests/test_qwen3_full_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ def bad_files(*args):
])
def test_real_decoder_pipeline_and_failure_context(tmp_path, monkeypatch, fault, evidence_locked):
"""Exercise the stage wiring with real files, parser and audits, cheaply."""
from collections import Counter
import onnx
from scratchv.analysis import ir_verifier
from probes.w2_qwen3_parse import audit, validation
Expand Down Expand Up @@ -420,3 +421,12 @@ def write(path, value):
assert report["audit"]["return"]["shape"] == [1, 3, 9]
for name, evidence in report["evidence"].items():
assert evidence["sha256"] == gate.artifacts.sha256(out / name)
if fault in ("parse", "verifier"):
assert "frontend_parse" not in report
else:
# A later binding/evidence failure does not erase successful parsing.
# Count actual ONNX types, not the deliberately synthetic graph summary.
assert report["frontend_parse"] == {
"passed": True,
"operators": dict(Counter(node.op_type for node in model.graph.node)),
}
Loading