diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f294d89..91d3e814 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,16 @@ jobs: $PY -m pip install -e ".[all]" $PY -m pip install "pytest>=7,<10" + - name: Install pinned CPU dependencies for Qwen and W3 tests + run: | + set -euo pipefail + # The full test suite collects W3 subgraph and workflow tests. + # Install their actual dependencies instead of skipping those tests. + $PY -m pip install "torch==2.7.1" --index-url https://download.pytorch.org/whl/cpu + $PY -m pip install -r requirements/qwen3-small-probe.txt "PyYAML==6.0.3" + $PY -m pip check + $PY -c "import torch, yaml, safetensors; assert torch.__version__ == '2.7.1+cpu' and torch.version.cuda is None, torch.__version__" + - name: Run Topic 04 PassManager regressions id: topic04_pass_manager env: @@ -117,11 +127,11 @@ jobs: run: | { echo "## Topic 04 PassManager" - echo "Result: $TOPIC04_OUTCOME" + echo "结果: $TOPIC04_OUTCOME" echo "" - echo "Coverage: pass registration, enable/disable switches, constant folding, functional pipelines, and optimizer regressions." + echo "覆盖范围: pass 注册、启用/停用开关、常量折叠、功能流水线与优化器回归。" echo "" - echo "JUnit XML and detailed test output are included in the test-reports artifact when tests run." + echo "测试运行时,JUnit XML 与详细测试输出包含在 test-reports artifact 中。" } >> "$GITHUB_STEP_SUMMARY" - name: Run IR interpreter and LICM regressions @@ -171,6 +181,9 @@ jobs: env: PYTHONHASHSEED: "0" SCRATCHV_REQUIRE_RISCV_EXECUTION: "1" + OMP_NUM_THREADS: "1" + OPENBLAS_NUM_THREADS: "1" + MKL_NUM_THREADS: "1" run: | mkdir -p benchmark_reports $PY -m pytest tests/ -v --tb=short \ @@ -671,12 +684,12 @@ jobs: cat benchmark_reports/asm_beautifier_summary.md >> "$GITHUB_STEP_SUMMARY" fi echo "" >> "$GITHUB_STEP_SUMMARY" - echo "### LLVM vs ScratchV" >> $GITHUB_STEP_SUMMARY + echo "### LLVM 与 ScratchV 对比" >> $GITHUB_STEP_SUMMARY if [ -f benchmark_reports/llvm_vs_scratchv.md ]; then tail -20 benchmark_reports/llvm_vs_scratchv.md >> $GITHUB_STEP_SUMMARY fi echo "" >> $GITHUB_STEP_SUMMARY - echo "### Topic 17 Register Allocation" >> $GITHUB_STEP_SUMMARY + echo "### Topic 17 寄存器分配" >> $GITHUB_STEP_SUMMARY if [ -f benchmark_reports/regalloc_bench.md ]; then cat benchmark_reports/regalloc_bench.md >> $GITHUB_STEP_SUMMARY fi diff --git a/.github/workflows/llm-deploy.yml b/.github/workflows/llm-deploy.yml index 70526555..522cf7bb 100644 --- a/.github/workflows/llm-deploy.yml +++ b/.github/workflows/llm-deploy.yml @@ -2,20 +2,30 @@ name: LLM Deploy v1.0 # CI for the Qwen3-0.6B-on-RISC-V project (docs/llm-deploy-v1.0/). # +# One pipeline for the whole LLM line: W1/W2 PR gates, the opt-in W3 +# preparation and full 28-layer IR numeric gates, and the nightly schedule. +# Merged from llm-deploy.yml + w3-preparation.yml + w3-full-numeric.yml + +# w3-nightly.yml so that one file, one schedule and one manual entry point +# describe everything. The previous split scheduled the same 23:23 nightly +# from two files at once. +# # Separate from ci.yml on purpose: that one tests the ScratchV compiler, this # one tests the deployment project. A failure here should not read as "the # compiler is broken". # -# The PR job executes small numeric gates. A separate manual/nightly job -# downloads and validates the pinned full-model release with ORT. +# PRs execute small numeric gates and a separate complete frontend audit. +# Both are mandatory for the W2 acceptance result. Full-model ORT and the W3 +# gates remain manual/nightly; frontend parsing never claims full IR execution. # -# docs:interfaces docs:risks +# docs:interfaces docs:risks docs:linux-reproduction # probe:qemu-matmul probe:small-transformer probe:qwen3-onnx -# numeric:ir-qwen3-small +# numeric:ir-qwen3-small frontend:parse-qwen3 +# unit:frontend-ops unit:backend-ops unit:runtime integration:runtime-model # -# Later weeks' gates (numeric:ir-full-qwen3 at W3, numeric:qemu-full-qwen3 at -# W4, e2e:qemu-paris at W5, release:gate at W8) are NOT stubbed here. They get -# added when their week arrives, from the plan's §4.3 table. +# The W3 jobs are opt-in. They run on a manual dispatch with +# run_w3_validation=true, or on the schedule when the W3_NIGHTLY_ENABLED +# repository variable is set. The default W1/W2 checks do not imply +# full-model IR acceptance. on: pull_request: @@ -23,11 +33,19 @@ on: - 'docs/llm-deploy-v1.0/**' - 'probes/**' - 'scripts/check_llm_deploy_docs.py' + - 'scripts/check_linux_repro_docs.py' + - 'tests/test_linux_repro_docs.py' - 'scripts/check_w1_repro_env.py' + - 'scripts/run_w2_acceptance.py' + - 'scripts/check_w2_ci_results.py' + - 'scripts/summarize_w2_ci.py' + - 'scripts/summarize_w3_ci.py' - 'export_qwen3_onnx.py' - 'scratchv/**' - 'requirements/**' + - 'pyproject.toml' - 'tests/test_qwen3_small*.py' + - 'tests/test_qwen3_full*.py' - 'tests/test_tensor*.py' - 'tests/test_riscv_tensor*.py' - 'tests/test_optimizer*.py' @@ -35,11 +53,33 @@ on: - 'tests/test_qwen_artifact_selection.py' - 'tests/test_w1_qwen3*.py' - 'tests/test_w1_repro_env.py' + - 'tests/test_qwen3_frontend_patterns.py' + - 'tests/test_qwen3_tokenizer.py' + - 'tests/test_llm_inputs.py' + - 'tests/test_w2_*.py' + - 'tests/test_numeric_metrics.py' + - 'tests/test_numeric_summary.py' + - 'tests/test_w3_actions_summary.py' + - 'tests/test_w3_workflows.py' - '.github/workflows/llm-deploy.yml' schedule: # Nightly, off the hour: 19:23 UTC = 03:23 北京时间. - cron: '23 19 * * *' workflow_dispatch: + inputs: + run_w3_validation: + description: Also run complete W3 preparation and seven-case IR validation + type: boolean + required: false + default: false + w3_case: + description: W3 coverage; short_17 is a diagnostic and never passes acceptance + type: choice + required: false + default: all + options: + - all + - short_17 permissions: contents: read @@ -50,12 +90,13 @@ env: PROBE_TRANSFORMER: probes/w1_tiny_transformer/run.py PROBE_QWEN3_EXPORT: probes/w1_qwen3_export/run.py PROBE_QWEN3_SMALL: probes/w2_qwen3_small/run.py + PROBE_QWEN3_PARSE: probes/w2_qwen3_parse/run.py QWEN3_PY: output/qwen3-probe-venv/bin/python jobs: llm-deploy: - name: "LLM Deploy v1.0 / W1" - runs-on: ubuntu-latest + name: "LLM Deploy v1.0 / W1 + W2" + runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - uses: actions/checkout@v4 @@ -99,6 +140,9 @@ jobs: id: docs run: python3 scripts/check_llm_deploy_docs.py --week W1 + - name: "docs:linux-reproduction" + run: python3 scripts/check_linux_repro_docs.py --check-bash + # ── probe:qemu-matmul ───────────────────────────────────────────── - name: "probe:qemu-matmul" id: matmul @@ -108,7 +152,16 @@ jobs: - name: Tensor compiler and runtime regressions run: | - python3 -m pytest tests/test_tensor_c_codegen.py tests/test_tensor_compiler.py tests/test_riscv_tensor_runtime.py tests/test_optimizer_numeric_semantics.py tests/test_qwen_artifact_selection.py tests/test_w1_qwen3_export.py tests/test_qwen3_small_riscv_gate.py tests/test_w1_repro_env.py -q + python3 -m pytest \ + tests/test_tensor_c_codegen.py tests/test_tensor_compiler.py \ + tests/test_riscv_tensor_runtime.py tests/test_optimizer_numeric_semantics.py \ + tests/test_qwen_artifact_selection.py tests/test_w1_qwen3_export.py tests/test_qwen3_small_riscv_gate.py \ + tests/test_w1_repro_env.py tests/test_qwen3_full_structure.py \ + tests/test_qwen3_full_audit.py tests/test_qwen3_full_parse.py \ + tests/test_llm_inputs.py tests/test_w2_backend_ops_gate.py \ + tests/test_w2_runtime_gate.py tests/test_w2_runtime_metadata.py \ + tests/test_w2_runtime_reporting.py tests/test_w2_acceptance.py tests/test_w2_ci_results.py \ + tests/test_numeric_metrics.py tests/test_numeric_summary.py tests/test_w2_actions_summary.py -q - name: Repeated Linux timeout cleanup regression timeout-minutes: 5 @@ -123,6 +176,18 @@ jobs: --junit-xml="output/process-cleanup-repeats/attempt-$attempt.xml" done + - name: "unit:frontend-ops" + id: frontend_ops + run: | + python3 -m pytest tests/test_qwen3_frontend_patterns.py -q \ + --junit-xml=output/w2-frontend-ops.xml + + - name: "unit:backend-ops" + id: backend_ops + timeout-minutes: 15 + run: | + python3 probes/w2_backend_ops/run.py --output-dir output/w2-backend-ops + # ── probe:small-transformer ─────────────────────────────────────── - name: "probe:small-transformer" id: transformer @@ -151,6 +216,34 @@ jobs: "$QWEN3_PY" -X utf8 scripts/check_w1_repro_env.py \ --require-clean --output-dir output/w1-repro-env + - name: Runtime unit regressions in pinned environment + run: | + "$QWEN3_PY" -m pytest \ + tests/test_llm_inputs.py tests/test_qwen3_tokenizer.py \ + tests/test_w2_runtime_gate.py tests/test_w2_runtime_metadata.py \ + tests/test_w2_runtime_reporting.py tests/test_w2_runtime_model.py \ + -q --junit-xml=output/w2-runtime-tests.xml + + - name: "unit:runtime" + id: runtime + timeout-minutes: 10 + run: | + "$QWEN3_PY" probes/w2_runtime/run.py --mode download \ + --tokenizer-dir output/qwen3-tokenizer --output-dir output/w2-runtime + + - name: "integration:runtime-model" + id: runtime_model + timeout-minutes: 15 + env: + OMP_NUM_THREADS: "1" + OPENBLAS_NUM_THREADS: "1" + MKL_NUM_THREADS: "1" + HF_HUB_OFFLINE: "1" + TRANSFORMERS_OFFLINE: "1" + run: | + "$QWEN3_PY" probes/w2_runtime_model/run.py \ + --tokenizer-dir output/qwen3-tokenizer --output-dir output/w2-runtime-model + - name: "numeric:ir-qwen3-small" id: qwen3_small timeout-minutes: 15 @@ -196,7 +289,7 @@ jobs: "$QWEN3_PY" -m pytest \ tests/test_qwen3_small_probe.py \ tests/test_qwen3_small_model.py \ - tests/test_qwen3_small_gate.py -q \ + tests/test_qwen3_small_gate.py tests/test_qwen3_small_reports.py -q \ --junit-xml=output/qwen3-small/tests.xml - name: "numeric:qemu-qwen3-small" @@ -210,6 +303,49 @@ jobs: "$QWEN3_PY" probes/w2_qwen3_small/riscv.py \ --model-dir output/qwen3-small --output-dir output/qwen3-riscv + - name: Summarize W2 unit gates + if: always() + env: + FRONTEND_OUTCOME: ${{ steps.frontend_ops.outcome }} + BACKEND_OUTCOME: ${{ steps.backend_ops.outcome }} + RUNTIME_OUTCOME: ${{ steps.runtime.outcome }} + RUNTIME_MODEL_OUTCOME: ${{ steps.runtime_model.outcome }} + run: | + { + echo "## W2 测试项" + echo "unit:frontend-ops: $FRONTEND_OUTCOME" + echo "unit:backend-ops: $BACKEND_OUTCOME" + echo "unit:runtime: $RUNTIME_OUTCOME" + echo "integration:runtime-model: $RUNTIME_MODEL_OUTCOME" + emit_report() { + report="$1"; outcome="$2" + if [ "$outcome" = "skipped" ] || [ -z "$outcome" ]; then + echo "未运行: $report。无数值验收结论。" + elif [ -f "$report" ]; then + cat "$report" + else + echo "未生成报告: $report (步骤: $outcome)。无数值验收结论,请查看日志。" + fi + } + emit_report output/w2-backend-ops/report.md "$BACKEND_OUTCOME" + emit_report output/w2-runtime/report.md "$RUNTIME_OUTCOME" + emit_report output/w2-runtime-model/report.md "$RUNTIME_MODEL_OUTCOME" + echo "这些门槛不执行完整模型 IR/QEMU,也不执行文本生成。" + } + + - name: Upload W2 unit evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: w2-unit-gates-output + path: | + output/w2-backend-ops/ + output/w2-runtime/ + output/w2-runtime-model/report.* + output/w2-frontend-ops.xml + output/w2-runtime-tests.xml + if-no-files-found: ignore + - name: Upload RISC-V tensor evidence if: always() uses: actions/upload-artifact@v4 @@ -228,11 +364,15 @@ jobs: run: | { echo "## numeric:qemu-qwen3-small" - echo "Outcome: $PROBE_OUTCOME" - if [ -f output/qwen3-riscv/report.md ]; then + echo "步骤结果: $PROBE_OUTCOME" + if [ "$PROBE_OUTCOME" = "skipped" ] || [ -z "$PROBE_OUTCOME" ]; then + echo "未运行。无 QEMU 数值验收结论。" + elif [ -f output/qwen3-riscv/report.md ]; then cat output/qwen3-riscv/report.md + else + echo "未生成报告。无 QEMU 数值验收结论,请查看日志。" fi - } >> "$GITHUB_STEP_SUMMARY" + } - name: Summarize Qwen3 small probe if: always() @@ -243,15 +383,17 @@ jobs: run: | { echo "## numeric:ir-qwen3-small" - echo "Probe: $PROBE_OUTCOME; regression tests: $TEST_OUTCOME" - echo "Explicit artifact integration (requires 3 passed / 0 skipped): $ARTIFACT_TEST_OUTCOME" + echo "Probe: $PROBE_OUTCOME;回归测试: $TEST_OUTCOME" + echo "显式 artifact 集成(要求 3 passed / 0 skipped): $ARTIFACT_TEST_OUTCOME" echo "" - if [ -f output/qwen3-small/report.md ]; then + if [ "$PROBE_OUTCOME" = "skipped" ] || [ -z "$PROBE_OUTCOME" ]; then + echo "未运行。无 IR 数值验收结论。" + elif [ -f output/qwen3-small/report.md ]; then cat output/qwen3-small/report.md else - echo "No report was produced; inspect the dependency/probe step logs." + echo "未生成报告。无 IR 数值验收结论,请查看日志。" fi - } >> "$GITHUB_STEP_SUMMARY" + } - name: Upload Qwen3 small probe artifacts if: always() @@ -288,18 +430,18 @@ jobs: QWEN_RISCV_OUTCOME: ${{ steps.qwen3_riscv.outcome }} run: | { - echo "## W1 / small Qwen3 execution status" + echo "## W1 测试项" echo "" - echo "| Gate | Actual step outcome |" + echo "| 门槛 | 实际步骤结果 |" echo "|---|---|" - echo "| Document structure | $DOCS_OUTCOME |" + echo "| 文档结构 | $DOCS_OUTCOME |" echo "| QEMU MatMul | $MATMUL_OUTCOME |" - echo "| Synthetic Transformer IR | $TRANSFORMER_OUTCOME |" - echo "| Real Qwen3 structure IR | $QWEN_IR_OUTCOME |" - echo "| Real Qwen3 structure QEMU | $QWEN_RISCV_OUTCOME |" + echo "| 合成 Transformer IR | $TRANSFORMER_OUTCOME |" + echo "| 真实 Qwen3 结构 IR | $QWEN_IR_OUTCOME |" + echo "| 真实 Qwen3 结构 QEMU | $QWEN_RISCV_OUTCOME |" echo "" - echo "Full-model ORT validation runs in the separate manual/nightly job." - echo "Document checks do not certify team interface approval or second-person reproduction." + echo "完整模型 ORT 验证在独立的手动/夜间 job 中运行。" + echo "文档检查不构成团队接口批准或他人复现的证明。" } >> "$GITHUB_STEP_SUMMARY" full-qwen3-onnx: @@ -307,7 +449,7 @@ jobs: # This downloads 1.24 GB and executes the 2.38 GB FP32 model. It is not # counted as a passing gate on PRs where it did not execute. if: github.event_name != 'pull_request' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - uses: actions/checkout@v4 @@ -336,14 +478,14 @@ jobs: PROBE_OUTCOME: ${{ steps.full_model.outcome }} run: | { - echo "## probe:qwen3-onnx (complete FP32 release, ORT CPU)" - echo "Outcome: $PROBE_OUTCOME" + echo "## W1 测试项" + echo "步骤结果: $PROBE_OUTCOME" if [ -f output/qwen3-full-probe/report.md ]; then cat output/qwen3-full-probe/report.md else - echo "No report produced; inspect the failed step." + echo "未生成报告;请检查失败的步骤。" fi - echo "This validates the complete ONNX artifact, not ScratchV full-model execution." + echo "这验证完整 ONNX 产物,不代表 ScratchV 的完整模型执行。" } >> "$GITHUB_STEP_SUMMARY" - name: Upload full-model validation evidence if: always() @@ -352,3 +494,397 @@ jobs: name: qwen3-full-onnx-probe-output path: output/qwen3-full-probe/ if-no-files-found: ignore + + + full-qwen3-frontend: + name: "W2 / complete Qwen3 frontend" + # Required on PRs as well as manual/nightly runs. No full-model ORT or IR inference. + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Install pinned frontend audit dependencies + run: | + python3 -m pip install "numpy==2.2.6" "onnx==1.18.0" "onnxruntime==1.22.1" "protobuf==5.29.5" + python3 -m pip check + - name: "frontend:parse-qwen3" + id: full_frontend + env: + OMP_NUM_THREADS: "1" + OPENBLAS_NUM_THREADS: "1" + run: | + set -euo pipefail + python3 "$PROBE_QWEN3_PARSE" --mode download \ + --model-dir output/qwen3-full-model \ + --output-dir output/qwen3-parse --timeout 1200 + - name: Summarize complete Qwen3 frontend gate + if: always() + env: + PROBE_OUTCOME: ${{ steps.full_frontend.outcome }} + run: | + { + echo "## W2 测试项" + echo "步骤结果: $PROBE_OUTCOME" + if [ -f output/qwen3-parse/report.md ]; then + cat output/qwen3-parse/report.md + fi + echo "仅完整图与绑定审计;不声称完整 IR 或 QEMU 数值结果。" + } + - name: Upload complete Qwen3 frontend evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: qwen3-full-frontend-parse-output + path: output/qwen3-parse/ + if-no-files-found: ignore + + w2-acceptance: + name: "W2 / required acceptance" + needs: [llm-deploy, full-qwen3-frontend] + if: always() + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + # 每个 artifact 的存储根目录不同:多路径上传的以 output/ 为根,单目录 + # 上传的以该目录自身为根,所以只能逐个下载到各自的 --report-dir 位置。 + - name: Download W2 unit gate reports + if: always() + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: w2-unit-gates-output + path: output + - name: Download RISC-V tensor reports + if: always() + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: riscv-tensor-probe-output + path: output + - name: Download Qwen3 small reports + if: always() + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: qwen3-small-probe-output + path: output/qwen3-small + - name: Download complete Qwen3 frontend report + if: always() + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: qwen3-full-frontend-parse-output + path: output/qwen3-parse + - name: List downloaded W2 reports + if: always() + run: find output -name report.json -print 2>/dev/null | sort || true + - name: Summarize W2 operators, precision and timing + if: always() + run: python3 scripts/summarize_w2_ci.py --report-dir output + - name: Require actual success from every W2 job + env: + W2_JOB_RESULTS: ${{ toJSON(needs) }} + run: python3 scripts/check_w2_ci_results.py + + # ── W3:opt-in 的完整数值门槛(原 w3-preparation / w3-full-numeric / w3-nightly) ── + # + # 三个 job 共用同一个守卫:手动 dispatch 且 run_w3_validation=true,或定时 + # 且仓库变量 W3_NIGHTLY_ENABLED=='true'。等价于合并前 llm-deploy.yml 的入口 + # 守卫与 w3-nightly.yml 的 opt-in 守卫的合取。 + w3-preparation: + name: "W3 / preparation" + if: ${{ (github.event_name == 'workflow_dispatch' && inputs.run_w3_validation) || (github.event_name == 'schedule' && vars.W3_NIGHTLY_ENABLED == 'true') }} + concurrency: + # 与 w3-full-numeric / llm-deploy 用不同 group,避免同一 ref 上互相阻塞。 + group: w3-preparation-${{ github.ref }} + cancel-in-progress: false + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + OMP_NUM_THREADS: "1" + OPENBLAS_NUM_THREADS: "1" + MKL_NUM_THREADS: "1" + PYTHONIOENCODING: utf-8 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + # The ONNX benchmark regression archives a pinned historical baseline. + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - name: Record Linux runner resources + run: | + set -euo pipefail + mkdir -p output + { + printf 'commit=%s\nrun_id=%s\nattempt=%s\n' "$GITHUB_SHA" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" + cat /etc/os-release + uname -a + lscpu + free -b + df -B1 . + } | tee output/w3-runner-resources.txt + - name: Install fixed CPU environment and QEMU + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --no-install-recommends -y qemu-system-misc + python -m pip install torch==2.7.1 --index-url https://download.pytorch.org/whl/cpu + python -m pip install -r requirements/qwen3-small-probe.txt PyYAML==6.0.3 + python -m pip install -e . ziglang==0.14.1 + python -m pip check + python -m pip freeze > output/w3-dependencies.txt + python --version >> output/w3-runner-resources.txt + qemu-system-riscv64 --version >> output/w3-runner-resources.txt + python -m ziglang version >> output/w3-runner-resources.txt + echo "SCRATCHV_CC=$(python -c 'from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent / "zig")')" >> "$GITHUB_ENV" + - name: W3 and affected W2/IR regressions + run: | + set -euo pipefail + mkdir -p output + python -B -X utf8 -m pytest tests/test_w3*.py tests/test_qwen3_medium*.py tests/test_ir_interpreter*.py tests/test_onnx*.py tests/test_qwen3_small_model.py tests/test_qwen3_small_probe.py tests/test_qwen3_small_gate.py tests/test_qwen3_small_riscv_gate.py tests/test_w2_runtime_model.py -q -p no:cacheprovider --basetemp output/pytest-w3 --junitxml output/w3-tests.xml + - name: Download and verify fixed assets + run: | + python - <<'PY' + from pathlib import Path + from huggingface_hub import snapshot_download + from probes.w1_qwen3_export.run import MANIFEST, acquire_model + from probes.w3_qwen3_subgraphs.assets import verify_source + source = Path("output/assets/source") + snapshot_download(repo_id=MANIFEST["model_id"], revision=MANIFEST["revision"], + local_dir=source, allow_patterns=["model.safetensors"] + [x["name"] for x in MANIFEST["source_files"]]) + verify_source(source) + acquire_model(Path("output/assets/onnx")) + PY + - name: Five preparation checks (not full-model acceptance) + id: probe + run: | + python -B -X utf8 scripts/run_w3_preparation.py --source-dir output/assets/source --model-dir output/assets/onnx --output-dir output/w3-preparation --cc "$SCRATCHV_CC" --qemu qemu-system-riscv64 + - name: Summarize W3 preparation evidence + if: always() + env: + PROBE_OUTCOME: ${{ steps.probe.outcome }} + run: python -B -X utf8 scripts/summarize_w3_ci.py --kind preparation --report-dir output/w3-preparation + - name: Retain bounded selected trace and failure evidence + if: always() + run: | + python -B -X utf8 scripts/collect_w3_ci_evidence.py --source output/w3-preparation --destination output/w3-diagnostics + - name: Upload preparation reports (30 days) + if: always() + uses: actions/upload-artifact@v4 + with: + name: w3-preparation-reports + if-no-files-found: warn + retention-days: 30 + path: | + output/w3-runner-resources.txt + output/w3-dependencies.txt + output/w3-tests.xml + output/w3-preparation/**/report.json + output/w3-preparation/**/report.md + output/w3-preparation/**/report.html + output/w3-preparation/**/trace_schema.json + output/w3-preparation/logs/*.log + output/w3-diagnostics/retained-files.json + output/w3-diagnostics/retained-evidence.json + - name: Upload selected diagnostic evidence (7 days) + if: always() + uses: actions/upload-artifact@v4 + with: + name: w3-preparation-diagnostics + if-no-files-found: warn + retention-days: 7 + compression-level: 1 + path: | + output/w3-diagnostics/ + + w3-full-numeric: + name: "W3 / complete IR numeric" + if: ${{ (github.event_name == 'workflow_dispatch' && inputs.run_w3_validation) || (github.event_name == 'schedule' && vars.W3_NIGHTLY_ENABLED == 'true') }} + concurrency: + group: w3-full-numeric-${{ github.ref }} + cancel-in-progress: false + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + OMP_NUM_THREADS: "1" + OPENBLAS_NUM_THREADS: "1" + MKL_NUM_THREADS: "1" + PYTHONIOENCODING: utf-8 + SELECTED_CASE: ${{ inputs.w3_case || 'all' }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - name: Validate requested coverage + run: | + set -euo pipefail + case "${SELECTED_CASE:-all}" in + all|short_17) ;; + *) printf 'Unsupported case: %s\n' "$SELECTED_CASE" >&2; exit 1 ;; + esac + - name: Record Linux runner resources + run: | + set -euo pipefail + mkdir -p output + { + printf 'commit=%s\nrun_id=%s\nattempt=%s\n' "$GITHUB_SHA" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" + cat /etc/os-release + uname -a + lscpu + free -b + df -B1 . + } | tee output/w3-runner-resources.txt + - name: Install fixed CPU environment + run: | + set -euo pipefail + python -m pip install torch==2.7.1 --index-url https://download.pytorch.org/whl/cpu + python -m pip install -r requirements/qwen3-small-probe.txt PyYAML==6.0.3 + python -m pip install -e . + python -m pip check + python -m pip freeze > output/w3-dependencies.txt + python --version >> output/w3-runner-resources.txt + - name: Full runner and memory policy regressions + run: | + set -euo pipefail + mkdir -p output + python -B -X utf8 -m pytest tests/test_w3_full*.py tests/test_w3_actions_summary.py tests/test_w3_workflows.py tests/test_ir_interpreter*.py tests/test_onnx_initializers.py -q -p no:cacheprovider --basetemp output/pytest-w3-full --junitxml output/w3-full-tests.xml + - name: Download fixed ONNX assets + run: | + python - <<'PY' + from pathlib import Path + from probes.w1_qwen3_export.run import acquire_model + acquire_model(Path("output/assets/onnx")) + PY + - name: Compare complete IR and ORT outputs + id: probe + run: | + set -euo pipefail + args=() + SELECTED_CASE="${SELECTED_CASE:-all}" + if [[ "$SELECTED_CASE" != "all" ]]; then args=(--case "$SELECTED_CASE"); fi + python -B -X utf8 probes/w3_qwen3_full/run.py --model-dir output/assets/onnx --output-dir output/w3-full --worker-timeout 1800 --max-worker-memory-gib 8 --fp32-mode reference "${args[@]}" + - name: Summarize W3 full numerical evidence + if: always() + env: + PROBE_OUTCOME: ${{ steps.probe.outcome }} + run: python -B -X utf8 scripts/summarize_w3_ci.py --kind full --report-dir output/w3-full + - name: Record evidence retention scope + if: always() + run: | + mkdir -p output + cat > output/w3-full-evidence-retention.json <<'JSON' + { + "reports_retention_days": 30, + "raw_evidence_retention_days": 3, + "reports_scope": "Reports, hashes and schemas record the original run's checks; reports alone cannot independently revalidate the numerical results.", + "independent_revalidation": "Download w3-full-numeric-raw before expiry to retain the complete emitted inputs, logits, diagnostic logits and checkpoints together with reports. Failed or interrupted runs may contain incomplete evidence." + } + JSON + - name: Upload numerical reports (30 days) + if: always() + uses: actions/upload-artifact@v4 + with: + name: w3-full-numeric-reports + if-no-files-found: warn + retention-days: 30 + path: | + output/w3-runner-resources.txt + output/w3-dependencies.txt + output/w3-full-tests.xml + output/w3-full-evidence-retention.json + output/w3-full/**/report.json + output/w3-full/**/report.md + output/w3-full/**/report.html + output/w3-full/**/progress.json + output/w3-full/**/*.log + output/w3-full/**/checkpoint_schema.json + - name: Upload full raw numerical evidence (3 days) + if: always() + uses: actions/upload-artifact@v4 + with: + name: w3-full-numeric-raw + if-no-files-found: warn + retention-days: 3 + compression-level: 1 + path: | + output/w3-runner-resources.txt + output/w3-dependencies.txt + output/w3-full-tests.xml + output/w3-full-evidence-retention.json + output/w3-full/**/report.json + output/w3-full/**/report.md + output/w3-full/**/report.html + output/w3-full/**/progress.json + output/w3-full/**/*.log + output/w3-full/**/inputs.npz + output/w3-full/**/checkpoint_schema.json + output/w3-full/**/checkpoints.npz + output/w3-full/**/logits.npy + output/w3-full/**/diagnostic_logits.npy + + w3-acceptance: + name: "W3 / required acceptance" + if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.run_w3_validation) || (github.event_name == 'schedule' && vars.W3_NIGHTLY_ENABLED == 'true')) }} + needs: [w3-preparation, w3-full-numeric] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + PREPARATION_RESULT: ${{ needs.w3-preparation.result }} + FULL_NUMERIC_RESULT: ${{ needs.w3-full-numeric.result }} + steps: + - name: Require both complete gates + run: | + set -euo pipefail + { + printf '## W3 测试项\n\n' + printf '提交: `%s`\n\n' "$GITHUB_SHA" + printf '| 门槛 | 结果 |\n| --- | --- |\n' + printf '| 准备 (preparation) | %s |\n' "$PREPARATION_RESULT" + printf '| Complete seven-case Qwen3 IR numeric | %s |\n\n' "$FULL_NUMERIC_RESULT" + printf '只有两道门槛都为 success 本次运行才通过。这不批准 W3 团队里程碑,也不替代独立复现。\n' + } >> "$GITHUB_STEP_SUMMARY" + test "$PREPARATION_RESULT" = success + test "$FULL_NUMERIC_RESULT" = success + + w3-execution-summary: + name: "W3 / execution scope" + needs: [w3-acceptance] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: State whether W3 actually ran + env: + W3_RESULT: ${{ needs.w3-acceptance.result }} + run: | + python3 - <<'PY' + import os + result = os.environ.get("W3_RESULT", "") + labels = {"success": "PASS", "failure": "FAIL", "cancelled": "CANCELLED", "skipped": "NOT RUN"} + label = labels.get(result, "UNCONFIRMED") + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as stream: + stream.write("## W3 测试项\n\n") + stream.write(f"**{label}** — W3 准备与完整 28 层 IR 数值。\n\n") + if result == "skipped": + stream.write("W3 是可选门槛,PR 上默认不执行。W1/W2 通过不等于 W3 验收。\n\n") + elif result == "success": + stream.write("数值数据见本次运行中 w3-preparation 与 w3-full-numeric 两个 job 的概要。\n\n") + else: + stream.write("未声称 W3 验收;请检查被调用的 job 以及可能的残缺证据。\n\n") + stream.write("
范围与证据\n\n手动运行 LLM Deploy 可开启 `run_w3_validation`。" + "数值报告在生成后保留于 `w3-preparation-reports` 与 `w3-full-numeric-reports`。" + "完整模型 QEMU 是独立的 W4 门槛;独立复现与团队签署另行进行。\n\n
\n") + PY diff --git a/.github/workflows/topic06-benchmark.yml b/.github/workflows/topic06-benchmark.yml index 5f3ab84e..02405e73 100644 --- a/.github/workflows/topic06-benchmark.yml +++ b/.github/workflows/topic06-benchmark.yml @@ -45,7 +45,7 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" echo "> 完整 HTML、JSON、PNG 和单用例报告请下载本次运行的 \`topic06-benchmark-reports\` Artifact。" >> "$GITHUB_STEP_SUMMARY" else - echo "Topic 06 report was not generated." >> "$GITHUB_STEP_SUMMARY" + echo "Topic 06 报告未生成。" >> "$GITHUB_STEP_SUMMARY" fi - name: Upload Topic 06 reports diff --git a/.gitignore b/.gitignore index c12c3bca..d68b71f0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ build/ models/ venv/ .venv/ +.venv-linux/ +.venv-*/ .claude/ .omo/ scratchv.egg-info/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 911b66bf..faa8c1f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,11 @@ pytest tests/ -v # run all tests - User-facing docs go in `docs/`. - Inline code comments are for *why* not *what*. - The README is the single source of truth for project-wide docs. +- Qwen3 deployment (W1 and later) is delivered and independently reproduced on + Linux. Use Ubuntu 24.04, Bash, Python 3.12 and Linux tool paths in public guides, + PR instructions and CI. See [the Linux reproduction contract](docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md). + Personal development environments are not acceptance evidence; preserve the + actual platform and commit identity of historical measurements. ## Code of Conduct diff --git a/LICENSES/Eigen-MPL-2.0.txt b/LICENSES/Eigen-MPL-2.0.txt new file mode 100644 index 00000000..d0a1fa14 --- /dev/null +++ b/LICENSES/Eigen-MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt new file mode 100644 index 00000000..6eaafd6c --- /dev/null +++ b/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,14 @@ +ScratchV is distributed under the MIT license in LICENSE, with the following +file-specific third-party material. Package metadata lists both applicable +licenses; it does not replace these file-specific notices. + +scratchv/verification/fp32_eigen.py + Derived from Eigen at commit 1d8b82b0740839c0de7f1242a3585e3390ff5f33. + This file is licensed under MPL-2.0. Its source contains the original + copyright notices and source file references. The full license text is + distributed as LICENSES/Eigen-MPL-2.0.txt. + +scratchv/verification/fp32_reference.py + Contains polynomial constants and evaluation adapted from Microsoft ONNX + Runtime v1.22.1 (MLAS), under MIT. The source file includes the Microsoft + copyright notice and full license text. diff --git a/benchmarks/bench_onnx_operators.py b/benchmarks/bench_onnx_operators.py index de813f95..dea51f74 100644 --- a/benchmarks/bench_onnx_operators.py +++ b/benchmarks/bench_onnx_operators.py @@ -43,6 +43,11 @@ def git(*arguments, binary=False): + # A source handoff may sit inside another checkout. Never let Git discover + # that unrelated parent and manufacture a baseline for the extracted copy. + if not (ROOT / ".git").exists(): + raise RuntimeError("Historical baseline comparison requires a Git checkout at " + f"{ROOT}; an extracted source archive has no Git history") result = subprocess.run(["git", *arguments], cwd=ROOT, capture_output=True, env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, check=True) return result.stdout if binary else result.stdout.decode("utf-8").strip() diff --git a/benchmarks/test_regalloc/regalloc.md b/benchmarks/test_regalloc/regalloc.md index 21e1b129..0b35abe6 100644 --- a/benchmarks/test_regalloc/regalloc.md +++ b/benchmarks/test_regalloc/regalloc.md @@ -284,20 +284,32 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) ## 7. 使用方式 +以下复现命令面向 Linux / Bash,在仓库根目录执行。先准备 Python 3.12 虚拟环境和依赖: + +```bash +python3.12 -m venv .venv-regalloc +source .venv-regalloc/bin/activate +python -m pip install -e ".[all]" +``` + +CNN 用例还需要 `models/graph/cnn.onnx`;请先确认模型文件存在。LLVM 对比依赖 +`llvmlite`,报告中的 `llvm_available=false` 表示该项未执行,不能作为 LLVM 对比通过。 + ```bash -# 运行全部 4 项 benchmark,生成三格式报告(Windows PowerShell) -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_regalloc_linear ` - --repeats 30 ` - --output-json report.json ` - --output-html report.html ` - --output-md report.md +# 运行全部 4 项 benchmark,生成三格式报告 +mkdir -p benchmark_reports +python -m benchmarks.test_regalloc.bench_regalloc_linear \ + --repeats 30 \ + --output-json benchmark_reports/regalloc_bench.json \ + --output-html benchmark_reports/regalloc_bench.html \ + --output-md benchmark_reports/regalloc_bench.md # 单独运行某项 -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_simple --repeats 100 -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_dense --repeats 50 -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_cnn ` +python -m benchmarks.test_regalloc.bench_simple --repeats 100 +python -m benchmarks.test_regalloc.bench_dense --repeats 50 +python -m benchmarks.test_regalloc.bench_cnn \ --cnn-path models/graph/cnn.onnx --repeats 30 -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_pseudo --repeats 30 +python -m benchmarks.test_regalloc.bench_pseudo --repeats 30 ``` --- diff --git "a/docs/guide/00-\347\216\257\345\242\203\346\220\255\345\273\272\346\214\207\345\215\227.md" "b/docs/guide/00-\347\216\257\345\242\203\346\220\255\345\273\272\346\214\207\345\215\227.md" index 952e2bd0..868d42c5 100644 --- "a/docs/guide/00-\347\216\257\345\242\203\346\220\255\345\273\272\346\214\207\345\215\227.md" +++ "b/docs/guide/00-\347\216\257\345\242\203\346\220\255\345\273\272\346\214\207\345\215\227.md" @@ -4,6 +4,10 @@ > **预计时间**: 15-30 分钟 > **前置要求**: 会用终端(命令行),电脑能上网 +本指南面向 Linux/Bash,推荐 Ubuntu 24.04。Qwen3 部署和 W1 及后续阶段的独立复现 +须使用 [Linux 固定环境指南](../llm-deploy-v1.0/LINUX_REPRODUCTION.md),不要用本页的 +通用可选依赖替代该项目固定版本。个人开发环境不作为 Linux 验收依据。 + --- ## 目录 @@ -29,23 +33,14 @@ ### 如果你还没有安装 -**Ubuntu / Debian / WSL**: +**Ubuntu / Debian**: ```bash sudo apt update sudo apt install python3 python3-pip python3-venv python3-dev git build-essential ``` -**macOS**: -```bash -# 先安装 Homebrew(如果还没有): https://brew.sh -brew install python@3.12 git -``` - -**Windows**: -- 推荐安装 [WSL2](https://learn.microsoft.com/zh-cn/windows/wsl/install)(Windows Subsystem for Linux),然后在 WSL 里按 Ubuntu 的方式操作 -- 或者从 [python.org](https://www.python.org/downloads/) 下载 Python 安装包 - -> 💡 **为什么推荐 WSL?** ScratchV 的 RISC-V 仿真工具(TinyFive、Spike)主要在 Linux 上测试过。WSL 让你在 Windows 上拥有一个完整的 Linux 环境,体验最好。 +在 Linux 主机上安装和验证编译器及仿真工具。复现记录须保存发行版、CPU、 +Python 和工具版本;其他个人环境的运行结果不能直接记作 Linux 通过。 --- @@ -87,7 +82,7 @@ remote: Counting objects: 100% (1234/1234), done. python3 -m venv .venv # 激活虚拟环境 -source .venv/bin/activate # Linux / macOS / WSL +source .venv/bin/activate ``` 激活成功后,你的终端提示符前面会出现 `(.venv)`: diff --git "a/docs/guide/04-\346\225\205\351\232\234\346\216\222\351\231\244FAQ.md" "b/docs/guide/04-\346\225\205\351\232\234\346\216\222\351\231\244FAQ.md" index 50061966..8644f112 100644 --- "a/docs/guide/04-\346\225\205\351\232\234\346\216\222\351\231\244FAQ.md" +++ "b/docs/guide/04-\346\225\205\351\232\234\346\216\222\351\231\244FAQ.md" @@ -282,7 +282,7 @@ git status # 手动编辑冲突文件,删除 <<<<<<<, =======, >>>>>>> 标记 # 然后 -git add <冲突文件> +git add path/to/resolved-file git commit ``` diff --git a/docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md b/docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md new file mode 100644 index 00000000..f41f9de9 --- /dev/null +++ b/docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md @@ -0,0 +1,104 @@ +# Linux 交付与独立复现约定 + +从 W1/W2/W3 到后续阶段,正式交付、第二人复现和 CI 验收统一使用 **Linux**。 +推荐 Ubuntu 24.04 x86_64、Bash、Python 3.12、CPU PyTorch 和 Zig 0.14.1。 +作者可使用其他个人开发环境,但对外操作指南只提供 Linux 命令;个人路径、 +工具安装位置和本机通过记录不能作为 Linux 已通过的证明。旧记录保留实际环境、 +源码身份和历史链接,不能将旧平台名称改为 Linux。目标程序仍是 RISC-V 裸机程序: +这里的 Linux 是运行编译器、ORT、IR 解释器和 QEMU 的 **Host 环境**。 + +## 1. 获取要复现的确切版本 + +在独立目录克隆项目,获取待复现 PR 的准确版本。以下以 PR #94 为例; +复现 W3 时使用 96。将期望的完整提交 SHA 与 PR 页面核对,保留 Git 历史以运行旧版本 benchmark。 + +~~~bash +set -euo pipefail +sudo apt-get update +sudo apt-get install --no-install-recommends -y \ + git git-lfs python3.12 python3.12-venv clang lld qemu-user qemu-system-misc +git lfs install +git clone https://github.com/ScratchV-Compiler/ScratchV.git ScratchV-linux +cd ScratchV-linux +PR_NUMBER=94 +git fetch origin "pull/$PR_NUMBER/head" +git switch --detach FETCH_HEAD +git lfs pull +git rev-parse HEAD +git status --short +~~~ + +PR 分支会更新,报告必须记录实际完整 SHA。已解压的源码快照用随包校验入口验证; +它没有 Git 历史,不能宣称执行了依赖历史的 benchmark。 + +## 2. 安装固定环境 + +以下命令均在仓库根目录执行。虚拟环境和下载资产不提交 Git。 +首次安装需要网络;先安装 CPU Torch,避免解析到带 CUDA 的依赖。 + +~~~bash +set -euo pipefail +python3.12 -m venv .venv-linux +export SCRATCHV_PYTHON="$PWD/.venv-linux/bin/python" +"$SCRATCHV_PYTHON" -m pip install --upgrade pip +"$SCRATCHV_PYTHON" -m pip install torch==2.7.1 --index-url https://download.pytorch.org/whl/cpu +"$SCRATCHV_PYTHON" -m pip install -r requirements/qwen3-small-probe.txt +"$SCRATCHV_PYTHON" -m pip install -e . ziglang==0.14.1 PyYAML==6.0.3 +"$SCRATCHV_PYTHON" -m pip check +"$SCRATCHV_PYTHON" -c 'import torch; assert torch.__version__ == "2.7.1+cpu" and torch.version.cuda is None' +export SCRATCHV_CC="$("$SCRATCHV_PYTHON" -c 'from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent / "zig")')" +export SCRATCHV_QEMU="$(command -v qemu-system-riscv64)" +export SCRATCHV_ZIG="$SCRATCHV_CC" +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +export PYTHONIOENCODING=utf-8 +"$SCRATCHV_CC" version +"$SCRATCHV_QEMU" --version +if [[ -e .git ]]; then + "$SCRATCHV_PYTHON" -B -X utf8 scripts/check_w1_repro_env.py \ + --require-clean --output-dir output/linux-repro-env-01 +else + printf '%s\n' 'Source archive: verify its snapshot manifest; Git/LFS preflight does not apply.' +fi +~~~ + +Python 命令显式使用该解释器。重新打开终端后重新设置上述环境变量,不依赖全局 Python。 +环境预检输出目录必须是新目录;预检通过只证明前置条件,不代表数值验收。 + +## 3. 准备固定资产并选择阶段入口 + +W1 两层随机小模型和 W2 小算子不需要完整预训练权重。完整前端审计使用已发布 ONNX; +运行时使用固定官方 Tokenizer。已有资产默认离线校验;需要下载时显式选择 download: + +~~~bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B -X utf8 probes/w1_qwen3_export/run.py \ + --mode download --model-dir output/assets/onnx --output-dir output/linux-onnx-verify-01 +"$SCRATCHV_PYTHON" -B -X utf8 probes/w2_runtime/run.py \ + --mode download --tokenizer-dir output/assets/tokenizer --output-dir output/linux-tokenizer-01 +~~~ + +完整 ONNX 下载约 1.24 GB,并会解压权重、实际执行 ORT 校验。模型身份由仓库 manifest +固定,不能换成任意同名 checkpoint。W3 真实权重子图另需固定源 checkpoint; +按 [W3 PR #96 中的独立复现清单](https://github.com/ScratchV-Compiler/ScratchV/pull/96)准备,仅含 W2 的分支还没有 W3 入口。 + +| 阶段 | Linux 复现入口 | 判据 | +|---|---|---| +| W1 | [W1 README](W1/README.md) | 环境、MatMul、小模型、固定 ONNX 和文档分别验证 | +| W2 | [W2 README](W2/README.md) | 统一入口七项全部 PASS;部分执行不算通过 | +| W3(含 W3 的版本) | [W3 PR #96](https://github.com/ScratchV-Compiler/ScratchV/pull/96) | 准备五项与完整七组在同一版本全部通过 | + +每次执行使用新的输出目录,记录源码 SHA、资产哈希、依赖和系统资源。 +完整模型需要足够内存及原始数组磁盘空间;不要将小模型通过外推为完整模型容量可行。 +W3 已有 Linux 实测的资源、CPU 策略和耗时见其 Linux CI 文档。 + +## 4. 后续提交必须遵守 + +- 对外复现命令使用 Bash、POSIX 路径、Linux 工具及虚拟环境 bin/python。 +- 修改可执行代码后,以该提交的实际 Linux 测试为证据;本机验证与 Linux CI 分开记录。 +- 不改写历史数据的平台,不把旧提交的 PASS 冒充新提交复跑,也不把离线数组审计当成重新执行模型。 +- 文档检查命令如下;CI 必须执行 Bash 语法检查,缺少 Bash 不能算检查通过。 + +~~~bash +set -euo pipefail +"$SCRATCHV_PYTHON" scripts/check_linux_repro_docs.py --check-bash +~~~ diff --git "a/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\344\275\277\347\224\250\350\257\264\346\230\216.md" index dfb892fa..8b053a29 100644 --- "a/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -1,5 +1,7 @@ # ScratchV IR 解释器使用文档 +正式交付、CI 和他人复现使用 Ubuntu 24.04 x86_64 / Bash / Python 3.12,环境与工具准备见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。命令中的 `python` 指已激活的 `.venv-linux/bin/python`。 + > 日期:2026-09-30 > 输入:直接构造的 Program、输入数组及初始化数组。 diff --git "a/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" index e9fa545a..0a1777db 100644 --- "a/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" +++ "b/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -1,5 +1,7 @@ # ScratchV IR 解释器开发文档 +正式交付、CI 和他人复现使用 Ubuntu 24.04 x86_64 / Bash / Python 3.12,环境与工具准备见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。命令中的 `python` 指已激活的 `.venv-linux/bin/python`。 + > 版本:v0.1,开发说明;本轮实现已落地 > 日期:2026-09-30 > 依据:[IR 解释器设计文档(第二版·精简版)](IR解释器-设计文档.md) diff --git "a/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" index d2c8f1b7..ae4f2e32 100644 --- "a/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" +++ "b/docs/llm-deploy-v1.0/W1/IR\350\247\243\351\207\212\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -1,5 +1,7 @@ # ScratchV IR 解释器设计文档(第二版·精简版) +正式交付、CI 和他人复现使用 Ubuntu 24.04 x86_64 / Bash / Python 3.12,环境与工具准备见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。命令中的 `python` 指已激活的 `.venv-linux/bin/python`。 + > 版本:v0.2,本轮实现已落地;实际支持规则见使用说明 > 日期:2026-09-30 diff --git a/docs/llm-deploy-v1.0/W1/README.md b/docs/llm-deploy-v1.0/W1/README.md index ecc17387..d38e667d 100644 --- a/docs/llm-deploy-v1.0/W1/README.md +++ b/docs/llm-deploy-v1.0/W1/README.md @@ -1,30 +1,33 @@ -# W1 独立复现与验收记录 +# W1 Linux 独立复现与验收记录 本指南用于另一名成员从目标提交重新生成参考结果、编译产物并运行探测。当前已知本地结果见 [执行计划](W1-执行计划.md),接口候选见 [interfaces.md](interfaces.md),风险见 [risks.md](risks.md)。**Mastttttter 已于 2026-10-03 完成 `df18b02f` 的两层数值链路独立复现并本人确认;团队接口确认和 W1 出口评审仍待完成。** 原始证据见 [独立复现报告](https://github.com/ScratchV-Compiler/ScratchV/pull/91#issuecomment-5958157573)。该确认归属所列提交,不自动覆盖后续修复。 -本次 PR #91 集成此前本地收尾修复、复现预检和 QEMU 时长记录;历史本地结果与待决策问题见 [收尾与审查报告](W1-本地收尾与审查报告.md)。本次提交的实际测试、CI 与精确 SHA 以 PR 说明和对应运行报告为准,不用历史结果替代。 +PR #91 已合并,最终 head 为 `5903c1381d57f24062de7395f24385a59e279325`,合并提交为 `3bb88e81498d6d9293aed70e25a4e5a2104465d0`。报告落盘失败和源码身份归属修复单独见 [PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93);发布前整合工作树记录见 [本轮修复与验收报告](../W2/W1修复与W2本地验收报告.md)。人工确认和完整模型第二人复现继续由 [Issue #92](https://github.com/ScratchV-Compiler/ScratchV/issues/92) 跟踪。下方原 PR 修复数据均为历史记录。 + +正式交付、CI 和独立复现统一使用 **Ubuntu 24.04 x86_64 / Bash / Python 3.12**,环境总入口见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。Linux 是运行 ScratchV 和 QEMU 的宿主环境;当前 RISC-V guest 仍为裸机。 所有命令在仓库根目录执行。先记录 `git rev-parse HEAD` 和 `git status --short`;干净checkout便于关联提交,存在本地改动则保留diff并明确标记。不要使用第一人的预生成参考数组代替自己的运行。下述输出目录用完后保留,下一轮使用不同名称,不删除旧证据。 本次依据第二人报告修复两个复现问题:Linux 超时测试遗漏 `X` 死亡状态、错误解析含空格的进程名;三项优化集成测试依赖固定目录,导致自选产物目录和 CI 中被跳过。退出测试现在区分原进程身份,允许短暂退出过程,验证无关进程仍存活;CI 另连续运行 20 次真实进程用例,每次保留 JUnit,任一次失败即停止。没有修改编译器数值算法、模型配置或精度阈值,W2 扩展继续独立交付。 -修复工作树的本地验证:452 项专项回归全部通过、0 跳过(`output/pr91-final-regressions-zig.xml`);显式产物集成单独实跑 3/3(`output/pr91-artifact-integration.xml`,与前者重叠)。本轮新导出及两层 QEMU 报告在 `output/pr91-repro-host/`、`output/pr91-repro-qemu/`,28/28 通过,最大目标误差 `1.6689300537109375e-6`,QEMU 进程累计 130.701 秒。以上为 Windows 上提交前的实际工作树证据,不冒充 Linux 退出竞态复验;新提交的 Linux 20 次执行结果以 PR CI 对应 SHA 为准。项目指定的 `.claude/harness/verify/run.py` 本地缺失,未宣称执行 L2。 +提交前个人环境的测量保留于 [固定提交中的历史报告](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/README.md),不作为 Linux 验收数据。Linux 既有执行记录见第 5 节;新代码必须关联新的 run 和源码身份。 ## 0. 确定复现版本与领取任务 -`5ea22ecc7fd314025f6453c556dbb3e3f16a3175` 是已有 Linux CI 证据的**历史基线**,不包含本次新增的环境预检和时长汇总。复现本次更新时,应先从 [PR #91](https://github.com/ScratchV-Compiler/ScratchV/pull/91) 确认维护者/作者约定的完整 head SHA,再获取 PR head 并核对;PR 分支可能继续更新,不能仅记录分支名。 +`3bb88e81498d6d9293aed70e25a4e5a2104465d0` 是 W1 的历史合并基线,`5ea22ec` 是更早的 Linux CI 记录。复现新交付时必须取得待验收 PR 的精确版本;下面以含 W1 基础能力的 PR #94 为例,W3 集成版本改用 96。历史提交只能按其自带的功能和证据验收。 -在新目录获取本次版本的示例(需已安装 Git 和 Git LFS,PowerShell/bash 均可逐条执行): +在新目录获取本次版本的示例(需已安装 Git 和 Git LFS,以下为 Bash 命令): ```bash git clone --no-checkout https://github.com/ScratchV-Compiler/ScratchV.git ScratchV-w1-repro cd ScratchV-w1-repro git lfs install --local -git fetch origin pull/91/head +REPRO_PR=94 +git fetch origin "pull/$REPRO_PR/head" git rev-parse FETCH_HEAD ``` -先确认输出与约定的 PR head SHA 完全一致,再执行以下命令;不一致时先确认目标版本,不要继续运行后误写为原约定提交的结果。 +先确认输出与本次约定的完整 PR head SHA 完全一致,再执行以下命令;不一致时先确认目标版本,不要继续运行后误写为原约定提交的结果。 ```bash git checkout --detach FETCH_HEAD @@ -42,41 +45,30 @@ E4 建议领取 MatMul/两层 QEMU,E5 建议领取合成图/真实结构 IR/ 要求Python3.12,固定依赖在 `requirements/qwen3-small-probe.txt`。先安装CPU PyTorch,再装其余依赖,避免默认索引解析成CUDA包。 -Windows PowerShell: - -```powershell -python -m venv output/w1-repro-venv -$probePython = ".\output\w1-repro-venv\Scripts\python.exe" -& $probePython -m pip install --upgrade pip -& $probePython -m pip install "torch==2.7.1" --index-url https://download.pytorch.org/whl/cpu -& $probePython -m pip install -r requirements/qwen3-small-probe.txt -& $probePython -m pip install --no-deps -e . -& $probePython -m pip install "ziglang==0.14.1" -& $probePython -m pip check -$env:SCRATCHV_CC = (& $probePython -c "from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent / 'zig.exe')") -``` - -准备 `qemu-system-riscv64.exe` 便携工具,可放 `output/tools/` 由运行器自动查找;否则将 `SCRATCHV_QEMU` 设为已安装可执行文件的实际路径。QEMU Windows分发入口见 [QEMU下载页](https://www.qemu.org/download/#windows)。不需要WSL或Linux镜像。 - -Linux shell: +Ubuntu 24.04 / Bash: ```bash -python3.12 -m venv output/w1-repro-venv -. output/w1-repro-venv/bin/activate +set -euo pipefail +sudo apt-get update +sudo apt-get install --no-install-recommends python3.12-venv git-lfs qemu-system-misc +python3.12 -m venv .venv-linux +. .venv-linux/bin/activate python -m pip install --upgrade pip python -m pip install "torch==2.7.1" --index-url https://download.pytorch.org/whl/cpu python -m pip install -r requirements/qwen3-small-probe.txt python -m pip install --no-deps -e . python -m pip install "ziglang==0.14.1" python -m pip check -sudo apt-get install --no-install-recommends qemu-system-misc export SCRATCHV_CC="$(python -c 'from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent / "zig")')" -qemu-system-riscv64 --version +export SCRATCHV_PYTHON="$(pwd)/.venv-linux/bin/python" +export SCRATCHV_QEMU="$(command -v qemu-system-riscv64)" +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +"$SCRATCHV_QEMU" --version ``` -后续代码块中的 `python` 均指上述环境。PowerShell请将行首 `python` 换成 `& $probePython`。CPU小模型无需官方权重下载;首次编译Zig会建立缓存,应允许足够编译时间,不要将首次构建耗时解释为推理性能。 +后续代码块中的 `python` 均指已激活的 `.venv-linux/bin/python`。在新终端先执行 `source .venv-linux/bin/activate` 并恢复工具环境变量。CPU小模型无需官方权重下载;首次编译Zig会建立缓存,应允许足够编译时间,不要将首次构建耗时解释为推理性能。 -本次更新提供环境预检入口(历史 `5ea22ec` 没有此脚本): +当前交付提供环境预检入口(历史 `5ea22ec` 没有此脚本): ```bash python -X utf8 -B scripts/check_w1_repro_env.py --require-clean --output-dir output/w1-repro-env @@ -86,7 +78,7 @@ python -X utf8 -B scripts/check_w1_repro_env.py --require-clean --output-dir out 完整 ONNX 的固定 ZIP 约 1.24 GB,解包模型约 2.41 GB;已有 ORT 验证进程峰值约 5.97 GiB。按实际机器预留下载、解包和运行余量;该内存记录不是导出峰值或最低内存承诺。完整模型可以单独安排,不能将未执行记为成功。 -运行脚本后立即检查退出码:PowerShell 用 `$LASTEXITCODE`,bash 用 `$?`。遇到非零退出先保存报告和日志,不要继续执行后用最后一条命令的成功覆盖前面的失败。 +Bash 使用 `set -euo pipefail`,任何命令失败即停止;记录失败码时使用 `if command; then ...; else status=$?; ...; fi`,不要用后续成功命令覆盖失败。数值失败仍须保存报告和日志。 ## 2. 文档、回归与原两层合成图 @@ -141,16 +133,6 @@ SCRATCHV_QWEN_ARTIFACT_DIR=output/w1-repro-qwen3-small python -X utf8 -B -m pyte -q -rs -o junit_family=xunit1 --junit-xml=output/w1-repro-qwen3-small/optimization-tests.xml ``` -PowerShell 使用同一固定解释器: - -```powershell -$env:SCRATCHV_QWEN_ARTIFACT_DIR = 'output/w1-repro-qwen3-small' -& $probePython -X utf8 -B -m pytest tests/test_optimizer_numeric_semantics.py::test_real_qwen_artifact_optimization_matches_ort -q -rs -o junit_family=xunit1 --junit-xml=output/w1-repro-qwen3-small/optimization-tests.xml -$artifactTestExit = $LASTEXITCODE -Remove-Item Env:SCRATCHV_QWEN_ARTIFACT_DIR -if ($artifactTestExit -ne 0) { throw "Qwen artifact integration failed: $artifactTestExit" } -``` - 必须 **3 passed、0 skipped**,分别对应 none/basic/all,每项执行全部 7 组输入。显式目录为空、缺文件、导出失败、模型/输入/源码指纹不符均失败;没有配置目录才允许普通单测跳过,不再查找历史 `output/qwen3-small-pr91`。 测试复用现有导出报告及模型校验,并验证实际 NPZ 文件。ORT 参考和未优化 IR 基线当次计算,不读取旧 `logits_*.npy` 作为真值;每种优化要求对 ORT 严格误差 `<1e-5`,同时与当次未优化 IR 数组逐元素相等。JUnit 的 testcase properties 记录模型、导出报告与源码哈希,以及导出/测试提交和实际 case 数。哈希一致只能证明内容配套;仍须在本轮新目录导出,保留执行命令及报告,不能把旧产物改名后宣称新导出。 @@ -218,7 +200,7 @@ peak_memory是验证器进程峰值RSS,**不包含导出子进程**,不能 - 完整模型job只需要固定NumPy2.2.6、ONNX1.18.0、ORT1.22.1、protobuf5.29.5,不需要PyTorch;这也意味着它不重新导出或比较PyTorch。 - PR中完整模型job按条件未执行,应明确记录“ORT重型门禁未执行”,不能用轻量测试通过代替。 -已确认本地28次两层QEMU执行,以及完整模型新入口verify的hash/checker/ORT两case通过(验证器峰值RSS约6.40 GB,报告 `output/qwen3-full-local/report.json`)。提交 `545e696` 已有两项Linux成功证据: +提交 `545e696` 已有两项 Linux 成功证据;历史个人测量不充当 Linux 报告: - [完整ONNX重型任务](https://github.com/yuki-328/ScratchV/actions/runs/36983119833/job/110761955767):真实固定release下载、校验、ORT两case及artifact上传。 - [小模型部署功能任务](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36983000988/job/110761571841):所有部署步骤通过,含MatMul、合成图IR、官方Qwen3小模型IR和28次真实QEMU执行,artifacts上传成功。 diff --git "a/docs/llm-deploy-v1.0/W1/W1-\346\211\247\350\241\214\350\256\241\345\210\222.md" "b/docs/llm-deploy-v1.0/W1/W1-\346\211\247\350\241\214\350\256\241\345\210\222.md" index ac6abc87..eb6dacf4 100644 --- "a/docs/llm-deploy-v1.0/W1/W1-\346\211\247\350\241\214\350\256\241\345\210\222.md" +++ "b/docs/llm-deploy-v1.0/W1/W1-\346\211\247\350\241\214\350\256\241\345\210\222.md" @@ -3,7 +3,9 @@ > 上游:[开发计划.md](../开发计划.md) §4.3 W1。负责人:E2 主导,全员参与。 > 状态(2026-10-03):**Mastttttter 已完成 `df18b02f` 的合成图、MatMul、真实两层 IR/QEMU 独立复现并本人确认。完整模型第二人复现、团队确认与 W1 出口评审仍待完成。** [原始报告](https://github.com/ScratchV-Compiler/ScratchV/pull/91#issuecomment-5958157573)保留首轮 1 项失败、3 项跳过及后续复跑;本次修复退出状态误判与产物路径缺口,新提交验证单独记录。下文 `5ea22ec`、`545e696` CI 均为历史证据。 > 本文记录实现事实和待办,不代表已召开冻结会议。命令与签字模板见 [独立复现指南](README.md)。 -> 本次将暂留的本地收尾与仿真计时改动集成到 PR #91;上述远端结果只对应所列提交,新增改动须另行验证,不继承远端 PASS。 +> PR #91 已合并为 `3bb88e81498d6d9293aed70e25a4e5a2104465d0`。本地继续补齐报告发布与源码身份修复,结果见 [本轮报告](../W2/W1修复与W2本地验收报告.md)。W1 报告修复单独发布于 [PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93),W2 独立交付;人工确认和完整模型第二人复现由 [Issue #92](https://github.com/ScratchV-Compiler/ScratchV/issues/92) 继续跟踪,旧 CI 不覆盖本地增量。 + +正式交付与独立复现环境统一为 **Ubuntu 24.04 x86_64 / Bash / Python 3.12**。环境入口见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。本页 Linux 历史 run 保留原提交身份;个人环境测量归档后不计为 Linux 结果。 ## 1. 目标和验收边界 @@ -28,16 +30,14 @@ ONNX → ONNXParser → shared Program IR → CompilerDriver / 优化 / verifier | IR 解释器 | 有直线图、循环/分支测试,并可执行两层图 | `scratchv/verification/ir_interpreter.py`、`tests/test_ir_interpreter_control_flow.py`;与旧 DSL 文本解释器不同 | | PR #89 两层合成图 | ONNX → ORT / IR 的数值验收已通过 | 保留 `probe:small-transformer`;该图没有 Q/K RMSNorm,使用 partial RoPE,不等于真实 Qwen3 | | 官方 Qwen3 类的两层小配置 | PyTorch → ONNX → ORT / IR,7 组输入、29 个检查点通过 | 固定随机权重、hidden=32、4 Q 头/2 KV 头、head_dim=16、L=256;不是预训练权重切片 | -| FP32 MatMul QEMU | 7 个矩阵/广播/向量用例通过;4×4 小数误差 `1.4901161193847656e-8` | 新 tensor-c 路线,`max_abs < 1e-5`、`rtol=0` | -| 两层 Qwen3 QEMU | 普通/诊断图 × none/all 优化 × 7 组输入,共 **28 次真实 QEMU 执行通过** | 29 个检查点,最大全张量绝对误差 `1.6689300537109375e-6`;另验 none/basic/all 优化后 IR | -| 新编译/运行时回归 | 此前本地 **237 项专项测试通过** | 历史专项集合计数,不代表本轮全仓测试或新增 Linux CI 已通过;本轮合并回归另列实际结果 | -| 完整 Qwen3-0.6B 导出产物 | 本轮 verify 模式重新通过四文件哈希、完整 ONNX checker、两组 ORT 执行,输出均为 `[1,256,151936]` | 新验证器峰值 RSS 6,401,679,360 bytes(约6.40 GB);验证已有导出,未重新导出或比较PyTorch,不是完整 IR/QEMU 推理验收 | +| FP32 MatMul QEMU | `5ea22ec` Linux 部署任务的 7 个矩阵/广播/向量用例通过 | 新 tensor-c 路线,`max_abs < 1e-5`、`rtol=0` | +| 两层 Qwen3 QEMU | 普通/诊断图 × none/all 优化 × 7 组输入,共 **28 次真实 QEMU 执行通过** | `5ea22ec` Linux 任务:29 个检查点,最大全张量绝对误差 `1.7285346984863281e-6`;另验 none/basic/all 优化后 IR | | 完整ONNX Linux重型CI | 提交 `545e696` 的固定release下载、哈希/checker、ORT两case及artifact上传通过;验证器峰值RSS 6,409,670,656 bytes(约6.41 GB) | [实际job](https://github.com/yuki-328/ScratchV/actions/runs/36983119833/job/110761955767);只证明该任务/提交,不代表整个CI全绿 | -| 当前基线小模型 Linux CI | 提交 `5ea22ec` 的 MatMul、合成图 IR、官方小模型 IR 和 28 次 QEMU 均通过;QEMU 最大绝对误差 `1.7285346984863281e-6` | [部署工作流](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369623);与 Windows 本地误差分开记录 | +| 当前基线小模型 Linux CI | 提交 `5ea22ec` 的 MatMul、合成图 IR、官方小模型 IR 和 28 次 QEMU 均通过;QEMU 最大绝对误差 `1.7285346984863281e-6` | [部署工作流](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369623);仅归属该 Linux run | | 当前基线主 CI / benchmark | 提交 `5ea22ec` 的 Linux 全量测试 **2371 passed、6 skipped**,主 CI 后续步骤和 benchmark 通过 | [主 CI](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369611);逐项 skip 不替代强制 QEMU 门禁 | | 当前基线其他检查 | Topic 06 和 AI review 通过 | [Topic 06](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369652)、[PR #91 Checks](https://github.com/ScratchV-Compiler/ScratchV/pull/91/checks);自动 review 不代替维护者评审或第二人复现 | -原始本地 QEMU 证据位于 `output/qemu-matmul-final/report.json`、`output/qwen3-riscv-final/report.json`,本轮完整模型验证为 `output/qwen3-full-local/report.json`;这些忽略目录不会自动随 PR 发布。复现时生成新报告,比较其中的源码、模型、输入和 ELF 哈希。完整导出历史证据及 padding 数值边界见 [探测方案](W1-探测方案.md)。 +个人工作树测量见 [固定提交历史归档](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/W1-执行计划.md),原始数值与平台身份保持不变。新的 Linux 复现须生成自己的报告,比较源码、模型、输入和 ELF 哈希。完整导出历史边界见 [探测方案](W1-探测方案.md)。 ## 3. 工具链与交付物 @@ -46,8 +46,7 @@ ONNX → ONNXParser → shared Program IR → CompilerDriver / 优化 / verifier | Python / 导出依赖 | Python 3.12;CPU PyTorch 2.7.1;其余固定版本见 `requirements/qwen3-small-probe.txt` | | C 交叉编译器 | Zig 0.14.1,使用 `zig cc`;`SCRATCHV_CC` 指向可执行文件 | | QEMU | `qemu-system-riscv64`、virt、TCG、RV64GC/LP64D;`SCRATCHV_QEMU` 可指定路径 | -| 本机已测工具 | Zig 0.14.1;QEMU 11.1.0;Windows 便携工具,不需要 WSL | -| Linux 复现 | `qemu-system-misc` 提供执行器;版本写入新报告,不能把 Windows 通过直接当成 Linux 通过 | +| Linux 复现 | Ubuntu 24.04 的 `qemu-system-misc` 提供执行器;实际版本写入本次 Linux 报告 | | 数学与内存 | Zig 随附 musl 数学库;禁用 fast-math/FMA;静态 arena 默认上限 256 MiB,当前 QEMU 512 MiB | | 交付物 | 仓库路径 | @@ -77,9 +76,9 @@ ONNX → ONNXParser → shared Program IR → CompilerDriver / 优化 / verifier ## 5. 出口验收清单 -- [x] 本地 IR 解释器和两层数值探测可用;没有把解析成功当成执行成功。 -- [x] 新 tensor-c 路线本地 MatMul 与两层 Qwen3 FP32 QEMU 数值探测通过。 -- [x] 完整 Qwen3 导出已有历史证据;本轮对固定产物完成哈希、结构和两组ORT的本地重验,保留两次验证的不同范围。 +- [x] 已有 Linux CI 中 IR 解释器和两层数值探测可用;没有把解析成功当成执行成功。 +- [x] `5ea22ec` Linux 部署任务中,新 tensor-c 路线的 MatMul 与两层 Qwen3 FP32 QEMU 数值探测通过。 +- [x] 完整 Qwen3 导出已有历史证据;固定产物在 `545e696` Linux 任务完成哈希、结构和两组 ORT 验证,与重新导出分别记录。 - [x] 提交 `5ea22ec` 的部署小模型 Linux 功能门禁实际通过:[run](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369623),含 MatMul、IR 和 28 次真实 QEMU 执行;不是仅 IR 的 CI 证据。 - [x] 提交 `545e696` 的完整Qwen3重型门禁已在Linux实际通过:[run/job](https://github.com/yuki-328/ScratchV/actions/runs/36983119833/job/110761955767),含下载固定release、ORT两case、报告artifact。后续提交以对应Checks为准;PR仅跑轻量回归时不得算作新的重型通过。 - [x] 修复历史 Linux 测试竞争和旧 CNN smoke 命令;`5ea22ec` 的 [主 CI / benchmark](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369611) 已通过,Linux 全量测试 2371 passed、6 skipped。 @@ -113,43 +112,13 @@ ONNX → ONNXParser → shared Program IR → CompilerDriver / 优化 / verifier 工具链/网络阻塞时允许“本地先跑、CI 后补”,但 W1 状态保持未关闭。保留失败报告,按 [risks.md](risks.md) 触发方案;不得静默缩短序列、改精度、换模型或降级为 parse-only 后仍标原 gate 通过。 -## 7. 2026-10-02 回归收尾记录 - -本轮在 Windows / Python 3.12 上补齐真实 LLVM 18.1.8 工具后,运行 `pytest tests/`: -**2353 passed,0 failed,40 skipped**。跳过中 38 项需要本机没有的 Linux 用户态 -`qemu-riscv32`,另外两个是通用环境未安装 PyTorch 的模块;后者在固定 CPU 环境中 -与完整导出入口单测一起执行,**111 passed**。Linux CI 安装用户态与系统态 QEMU, -关键 MatMul 和真实 Qwen3 RISC-V 探测缺工具时必须失败。 - -独立代码审查随后发现并修复 Softmax 中间减法溢出没有立即报错的问题;追加 -Softmax、ReduceMean、MatMul、Mul 溢出和失败后恢复用例,后端/运行时专项 -**86 passed、0 skipped**。真实 RV64 ELF 对极端输入返回错误码 2,随后合法输入 -得到 `[0.5, 0.5]`。全量计数与之后的专项计数有重叠,不相加宣称总用例数。 - -对旧提交 `974ee37` 的同环境定点测试实际复现了 Windows 文件占用、路径、换行和 -`resource` 导入问题。完整源码归档基线也复现了这些问题;其两个依赖 Git 元数据的 -benchmark 在归档目录中不可比较,因此不把这两项失败归因于旧版本。当前工作树的 -这两项已通过。缺 `llvm-mca` 的问题通过准备真实工具解决,没有加入整体跳过策略。 - -CNN 调度 benchmark 的主要验收仍是 standalone 完整数值/安全路径;旧 CompilerDriver -的简化张量汇编不再计为成功,用 schema 5 的 `unsupported_cases` 明确列出。 -任何其他编译错误仍失败。IR verifier benchmark 改为对照优化后的 IR,计时仅代表 -验证开关的编译开销,不能与旧版占位汇编计时直接比较。 - -本机日志保存在忽略的 `output/closeout-final.*`、`output/pinned-closeout.*`、 -`output/tensor-overflow-review.*`、`output/windows-baseline.*`。这些是本地验证记录, -不是第二人复现记录。目标提交的 Linux 结果应查看 PR #91 Checks 和下载的 artifact; -仓库本地 `.claude/harness/verify/run.py` 不存在,未宣称执行该 L2 harness。 - -提交 `545e696` 的完整ONNX Linux重型任务已通过并上传 `qwen3-full-onnx-probe-output` -artifact,链接见§2。同一提交的小模型Linux功能job也已通过,包括28次QEMU执行与 -全部部署任务步骤。545e696首次通用主CI发生过测试竞争失败: -`tests/test_riscv_tensor_runtime.py` 对 `/proc` 的“存在检查后读取”遇到进程退出。 -随后仅修正测试的该处竞争处理并增加4个确定性用例,本地 **26 passed、0 skipped**; -运行时源码未因该修复改变。保留这次历史失败,**不把545e696写成整个CI全绿**; -后续 `bed2d32` 的 Linux 全量测试已通过,但旧 CNN verifier smoke 仍调用不支持张量的旧汇编后端;`5ea22ec` 将该 smoke 明确切换为 `--backend ir` 和 `.ir` 产物,仅验证 IR。最终 `5ea22ec` 主 CI、benchmark、部署任务和 Topic 06 均通过,链接见 §2。没有为了通过 CI 放松旧后端张量拒绝检查。 - -暂留阶段的修复曾通过2415项全仓测试、7次MatMul和28次两层QEMU执行,并重验完整ONNX ORT。本次将这些修复与新增仿真耗时统计一并集成到 PR #91;暂留阶段的历史结果、40项skip原因、数值与待决策事项见[本地审查报告](W1-本地收尾与审查报告.md),新增改动的验证另行记录,不能沿用旧提交的 Linux CI 结论。 +## 7. Linux 回归与历史记录 + +提交 `545e696` 的完整 ONNX Linux 重型任务通过并保留 artifact;同提交小模型功能任务含 28 次 QEMU 通过,但通用主 CI 曾因 `/proc` 读取与进程退出竞争而失败。后续修复读取竞争,`5ea22ec` 将旧 CNN verifier smoke 明确切换为 IR 产物,通用 CI、benchmark、部署及 Topic 06 通过,链接见第 2 节。保留失败身份,不把 `545e696` 写成整套 CI 全绿。 + +已有语义修复包括 Softmax 等算子的非有限值检查、跨块 DCE、空输出 Gather 越界检查,以及上下游产物绑定和仿真时长。旧标量后端的张量缺口仍明确拒绝,不用占位汇编冒充成功。具体修复与未决数值错误契约见 [收尾与审查记录](W1-本地收尾与审查报告.md)。 + +个人环境的原始计数、计时及平台专属调查保留于 [固定提交历史归档](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/W1-执行计划.md),不作为新的 Linux 测量。每次目标提交的正式验证以该 SHA 的 Linux Checks、artifact 及独立执行记录为准。 ## 8. 变更记录 diff --git "a/docs/llm-deploy-v1.0/W1/W1-\346\212\245\345\221\212\345\217\257\351\235\240\346\200\247\344\277\256\345\244\215.md" "b/docs/llm-deploy-v1.0/W1/W1-\346\212\245\345\221\212\345\217\257\351\235\240\346\200\247\344\277\256\345\244\215.md" index 7a5b29ed..f393af2c 100644 --- "a/docs/llm-deploy-v1.0/W1/W1-\346\212\245\345\221\212\345\217\257\351\235\240\346\200\247\344\277\256\345\244\215.md" +++ "b/docs/llm-deploy-v1.0/W1/W1-\346\212\245\345\221\212\345\217\257\351\235\240\346\200\247\344\277\256\345\244\215.md" @@ -11,11 +11,15 @@ ## 验收与边界 -发布前在本分支 Windows 固定 CPU 环境重新运行:**262 passed / 0 skipped**;两层 IR 的 7 组输入、29 个检查点及不变性通过;普通/诊断图 × none/all × 7 输入共 **28/28 次实际 QEMU 通过**,目标最大绝对误差 `1.6689300537109375e-6`。QEMU 进程墙钟累计 154.702 秒、探测流程 189.065 秒;并行验收时的单次观察,不作性能结论。证据在 `output/w1-publish-tests.xml`、`output/w1-publish-ir/`、`output/w1-publish-qemu/`;执行源码指纹与发布内容核对一致。远端 CI 以 PR 对应提交的实际 Checks 为准。项目指定的本地 L2 harness 文件缺失,未宣称运行。源码没有修改计算算法、模型配置或 `max_abs < 1e-5` 门槛;不自动代表第二人复现或团队接口冻结通过。人工待办继续由 [Issue #92](https://github.com/ScratchV-Compiler/ScratchV/issues/92) 跟踪。 +正式复现使用 [Linux 统一环境](../LINUX_REPRODUCTION.md)。本修复已随 PR #93 合并为 `faab6a81562710e105d87265dafd2dc769a67c72`。当前 W3 集成分支已有 Linux 验证:提交 `77c3aa86e9794e5bfe3c81fd873a1506d14bc753` 的 [LLM 工作流](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37303495582)完成两层 QEMU 28/28、显式产物 3/3;两层最大误差 `1.7881393432617188e-6 < 1e-5`。这些属于该次实际运行,不作为本次文档更新的新执行结果。 + +发布前个人环境的原始记录保留在 [固定历史原文](https://github.com/ScratchV-Compiler/ScratchV/blob/77c3aa86e9794e5bfe3c81fd873a1506d14bc753/docs/llm-deploy-v1.0/W1/W1-报告可靠性修复.md),不改写成 Linux 数据。后续提交应使用对应 SHA 的实际 Linux Checks。修复不修改计算算法、模型配置或 `max_abs < 1e-5` 门槛;人工接口冻结与独立复现继续按 [Issue #92](https://github.com/ScratchV-Compiler/ScratchV/issues/92)验收。 输出保存在本机新目录,复现者应自行重新生成: ```bash +set -euo pipefail +source .venv-linux/bin/activate python -B -X utf8 -m pytest tests/test_qwen3_small_gate.py tests/test_qwen3_small_riscv_gate.py tests/test_onnx_qwen_numeric_ops.py tests/test_onnx_shape_ops.py -q python -B -X utf8 probes/w2_qwen3_small/run.py --output-dir output/w1-ir-new python -B -X utf8 probes/w2_qwen3_small/riscv.py --model-dir output/w1-ir-new --output-dir output/w1-qemu-new diff --git "a/docs/llm-deploy-v1.0/W1/W1-\346\216\242\346\265\213\346\226\271\346\241\210.md" "b/docs/llm-deploy-v1.0/W1/W1-\346\216\242\346\265\213\346\226\271\346\241\210.md" index 8112c1f0..15f46c4f 100644 --- "a/docs/llm-deploy-v1.0/W1/W1-\346\216\242\346\265\213\346\226\271\346\241\210.md" +++ "b/docs/llm-deploy-v1.0/W1/W1-\346\216\242\346\265\213\346\226\271\346\241\210.md" @@ -1,20 +1,22 @@ # W1 探测方案与结果记录 > 上游:[开发计划.md](../开发计划.md) §4.3 W1、[执行计划](W1-执行计划.md)。 -> 更新:2026-10-02。**`5ea22ec` 主 CI 和小模型 Linux 部署任务已通过;完整 ONNX 重型证据来自验证源码未变的 `545e696`。独立复现和团队确认待完成**。 +> 更新:2026-10-03。**PR #91 已合并;Mastttttter 已确认 `df18b02f` 的两层独立复现。完整模型第二人复现、团队确认和出口评审仍待完成**。下文 `5ea22ec`、`545e696` 等远端结果均是历史证据。 > 完整命令和验收模板见 [README](README.md)。保留各探测的范围,不用替代模型或解析成功冒充原数值门槛。 -> 本次将暂留的本地收尾与仿真计时改动集成到 PR #91;新增结果与上述远端基线分开记录,不能把旧 CI 结果当作新增修改的验证。 +> 合并后的本地修复与 W2 增量见 [本轮验收报告](../W2/W1修复与W2本地验收报告.md);其中 W1 报告修复见 [PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93),W2 独立交付,远端 CI 以对应提交实际执行为准。人工待办继续由 [Issue #92](https://github.com/ScratchV-Compiler/ScratchV/issues/92) 跟踪。 + +正式交付与独立复现环境为 **Ubuntu 24.04 x86_64 / Bash / Python 3.12**,见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。当前页面保留既有 Linux run 的真实身份;个人环境数值只在固定提交历史归档中保存。 ## 0. 总览 | 探测 | 核心判据 | 当前证据 | |---|---|---| -| probe:qemu-matmul | 真正执行 RISC-V 的 FP32 4×4 MatMul,绝对误差小于1e-5 | 新 tensor-c 路线本地通过,扩展7用例 | +| probe:qemu-matmul | 真正执行 RISC-V 的 FP32 4×4 MatMul,绝对误差小于1e-5 | `5ea22ec` Linux 部署任务通过,扩展 7 用例 | | probe:small-transformer | 两层 ONNX → IR解释器 vs ORT,绝对误差小于1e-5 | PR89合成图已通过;保留原入口 | -| numeric:ir-qwen3-small | 官方Qwen3类小配置,PyTorch/ORT/IR,7输入29检查点 | 本地已通过;不是预训练切片 | -| numeric:qemu-qwen3-small | 同一普通/诊断图经编译优化后生成RV64 ELF并执行 | 本地及 `5ea22ec` 的 Linux 部署任务 28 次 QEMU 通过,CI 链接见 §3.3 | -| probe:qwen3-onnx | 完整0.6B导出、固定输入/完整输出形状、ORT可执行 | 历史导出、本轮本地verify,以及545e696的Linux download+hash/checker/ORT两case均通过;CI链接见§4.4 | -| 环境与文档 | 工具可用、门禁实际执行、接口/风险确认 | 本地工具已验证;结构检查不能替代团队确认 | +| numeric:ir-qwen3-small | 官方Qwen3类小配置,PyTorch/ORT/IR,7输入29检查点 | `5ea22ec` Linux 部署任务通过;不是预训练切片 | +| numeric:qemu-qwen3-small | 同一普通/诊断图经编译优化后生成RV64 ELF并执行 | `5ea22ec` 的 Linux 部署任务 28 次 QEMU 通过,CI 链接见 §3.3 | +| probe:qwen3-onnx | 完整0.6B导出、固定输入/完整输出形状、ORT可执行 | 历史导出,以及545e696的Linux download+hash/checker/ORT两case均通过;CI链接见§4.4 | +| 环境与文档 | 工具可用、门禁实际执行、接口/风险确认 | Linux 工具与实际执行由对应 run 验证;结构检查不能替代团队确认 | ## 1. 当前执行配方与证据格式 @@ -42,13 +44,10 @@ | 项 | 已记录结果 | |---|---| -| 新路径结论 | 本地PASS,7/7 | -| 4×4小数最大绝对误差 | `1.4901161193847656e-8` | -| 工具 | Zig0.14.1、QEMU11.1.0;具体build字符串在报告 | -| 证据 | `output/qemu-matmul-final/report.json`;原始忽略产物不默认随PR上传 | +| 新路径结论 | `5ea22ec` Linux 部署任务 PASS,7/7 | | 旧路径结论 | H1/H2所述限制保留,不标成修复 | | Linux CI | `5ea22ec` 的[部署任务](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369623)已通过 MatMul 步骤;历史 `545e696` 同项也通过 | -| 第二人独立复现 | 待另一名成员执行和记录,不用CI自动运行替代 | +| 第二人独立复现 | Mastttttter 已确认历史 `df18b02f` 两层链路;新提交须按 README 留下新记录 | ## 3. 两层 Transformer 与真实 Qwen3 小配置 @@ -75,13 +74,10 @@ | 基础图/解释器 | 官方结构小配置已通过,不是parse-only | | 编译构建 / QEMU次数 | 普通/诊断 × none/all = 4构建;每个7输入,共28执行通过 | | 观察点 | 29检查点,含每层norm、RoPE、attention、MLP/residual以及最终logits | -| Windows本地QEMU最大绝对误差 | `1.6689300537109375e-6`,保持原1e-5阈值 | -| `5ea22ec` Linux QEMU最大绝对误差 | `1.7285346984863281e-6`(short_255),28次均通过;与历史 `545e696` 记录一致,仍单独对应本次 run,不覆盖本地值或改变1e-5阈值 | +| `5ea22ec` Linux QEMU最大绝对误差 | `1.7285346984863281e-6`(short_255),28次均通过;与历史 `545e696` 记录一致,仍单独对应本次 run,不改变1e-5阈值 | | 额外检查 | 普通/诊断logits一致;因果性与padding隔离通过 | -| 专项回归 | 此前本地237项通过,属于历史专项集合,不是本轮整个仓库测试计数 | -| 原始证据 | `output/qwen3-riscv-final/report.json`,含源码/模型/ELF/输入指纹、工具与优化信息 | | Linux CI | `5ea22ec` 的[部署任务](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369623)全部步骤通过,含官方小模型 IR、28 次真实 QEMU 执行;不是 IR-only 的 CI | -| 第二人独立复现 | 待另一名成员执行和记录;CI通过不等于人工签字 | +| 第二人独立复现 | 已有历史 `df18b02f` 确认;CI 通过不等于新提交人工签字 | 这不是两层预训练权重切片,也不具有语言生成能力;不是完整0.6B、完整28层误差/内存验证,更不是目标硬件性能证明。 @@ -93,66 +89,40 @@ 这是完整模型的ONNX/ORT门槛,不要求本项同时完成完整IR/QEMU推理。只检查protobuf或只运行两层小图不能完成此门槛。 -### 4.2 历史本机证据 - -历史 `models/qwen3-0.6b-onnx/verification.json` 记录如下(记录生成于2026-09-29,文件在原本机模型目录;新checkout须另准备,不能假定这些文件存在): - -| 项 | 已记录结果 | -|---|---| -| 模型revision | `c1899de289a04d12100db370d81485cdf75e47ca` | -| 原始checkpoint SHA256 | `f47f71177f32bcd101b7573ec9171e6a57f4f4d31148d38e382306f42996874b` | -| 导出配置 | dynamo、opset18、FP32、eager、无Cache,IR version10 | -| ORT | CPUExecutionProvider,threads=4 | -| 形状 / 有限值 | 有效长度5及256两输入均为 `[1,256,151936]`,有限 | -| 数值比较范围 | 既有导出验证的有效token allclose通过,atol=rtol=1e-4;此容差不是小模型IR/QEMU的1e-5门槛 | -| 短输入5有效token | valid max_abs=`2.956390380859375e-5`;全张量max_abs=`0.0021619796752929688`、full_allclose=false | -| 全长256输入 | max_abs=`5.53131103515625e-5`;full_allclose=true | -| ORT单次前向 | 历史两case约1.365s / 1.785s,非QEMU时间或标准性能benchmark | -| 尚无记录 | 峰值内存、贪心文本输出;不能声称已经完成这些观察 | -| 第二人独立复现 | 待记录 | +### 4.2 导出来源与尚存边界 -保留padding位置全张量比较失败的事实,不能只展示valid通过而写“全部位置一致”。原计划要求记录峰值内存/生成文本作为观察;核心W1门槛仍是导出、形状与ORT可执行。新门禁若只核验现有导出产物,应报告“验证已有导出”,不冒称本轮重新导出或重新做过PyTorch对照。 +固定 revision 为 `c1899de289a04d12100db370d81485cdf75e47ca`,原始 checkpoint SHA256 为 `f47f71177f32bcd101b7573ec9171e6a57f4f4d31148d38e382306f42996874b`。发布产物来自 dynamo、opset 18、FP32、eager、无 KV cache 的导出,模型身份由 manifest 固定。 -### 4.3 本轮完整产物本地重验(2026-10-02) +原导出的有效 token PyTorch/ORT 对照通过,但短输入 padding 全张量对照曾失败。该历史差异仍需保留,不能因 Linux download/verify 模式通过就写成 PyTorch 与 ORT 全位置一致。这两种模式只验证既有产物,不重新导出或生成新的 PyTorch 参考。原始数值、依赖与当时环境见 [固定提交历史归档](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/W1-探测方案.md);它们不属于新的 Linux 测量。 -`output/qwen3-full-local/report.json` 的 `mode=verify`、`passed=true`,记录时间为2026-10-02 16:08:45 +08:00。本轮重新读取真实完整文件并执行ORT,没有依赖历史JSON的PASS标志。 +### 4.3 Linux 独立执行要求 -| 项 | 本轮结果 | -|---|---| -| 来源 | 同一固定revision发布产物,`model.onnx`及三个external-data分片逐文件大小/SHA256通过 | -| 结构 | 完整ONNX checker通过,opset18/IR10;311个外部tensor、三个真实分片,静态输入输出符合约定 | -| 环境 | Windows、Python3.12.13、NumPy2.2.6、ONNX1.18.0、ORT1.22.1、protobuf5.29.5 | -| ORT配置 | CPUExecutionProvider、intra-op threads=2、inter-op threads=1、图优化关闭 | -| 输入 | 新生成有效长度5/256,seed分别20260929/20260930;两输出均FP32 `[1,256,151936]`、全部有限 | -| 时间 | 两次前向约2.6663s / 2.8343s;session初始化约0.7139s;整个验证约8.7384s,非稳定性能benchmark | -| 内存 | 验证器进程 `PeakWorkingSetSize=6,401,679,360 bytes`(约6.40 GB / 5.96 GiB);不含导出子进程,不代表QEMU模型内存 | -| 边界 | 没有重新导出,没有重跑PyTorch对照,不验证语言质量或ScratchV完整模型执行;也没有刷新历史padding差异结论 | +按 [W1 README](README.md) 在新目录运行 `download` 或 `verify`;需要重验导出数值时执行 `export` 并保留两套结果的区别。源 snapshot、模型分片及输入身份必须核对,不能读取历史 JSON 的 PASS 代替真实执行。记录 Linux 系统、工具和依赖版本,峰值内存仅按实际测量范围报告。 ### 4.4 仓库门禁 `probes/w1_qwen3_export/run.py` 是统一门禁入口,具体命令见独立复现指南。PR运行该入口的轻量回归测试;手动/定时工作流的独立 `full-qwen3-onnx` job 用download模式验证固定发布产物并真实执行ORT两case。PR的轻量测试不替代完整ORT执行;PR中重型job未执行应如实显示。入口各模式均检查哈希、结构并运行ORT,缺权重/分片、哈希不符或执行失败时非零退出,不能以“尚未交付”成功退出。 -固定revision、模型/分片哈希、输入seed/有效长度、输出shape/dtype/有限值、provider/线程数和实际执行状态写入报告。新入口还以 `source_fingerprints` 记录wrapper、导出脚本和manifest的SHA256,成功/失败报告均保留,源码不可读取时为null并附错误。§4.3的既有本地报告生成较早,尚无该字段;必须按实际报告内容核对,不能事后补造源码指纹。报告不保存完整logits数组,checkout commit仍应与CI run关联。历史 `verification.json` 只作来源说明,不能作为新运行的成功标志。 +固定revision、模型/分片哈希、输入seed/有效长度、输出shape/dtype/有限值、provider/线程数和实际执行状态写入报告。新入口还以 `source_fingerprints` 记录wrapper、导出脚本和manifest的SHA256,成功/失败报告均保留,源码不可读取时为null并附错误。历史报告必须按实际字段核对,不能事后补造源码指纹。报告不保存完整logits数组,checkout commit仍应与CI run关联。历史 `verification.json` 只作来源说明,不能作为新运行的成功标志。 2026-10-02,提交 `545e696` 的 [full-qwen3-onnx Linux任务](https://github.com/yuki-328/ScratchV/actions/runs/36983119833/job/110761955767) **已通过**:真实下载固定release ZIP,校验完整产物、执行ORT两case,并成功上传 `qwen3-full-onnx-probe-output` artifact。这是该提交的完整 ONNX 交付物验证,不是重新导出或 ScratchV 完整模型执行。已核对 `probes/w1_qwen3_export/run.py`、`manifest.json`、`export_qwen3_onnx.py` 在 `545e696..5ea22ec` 无变化,因此该结果仍可作为当前已提交验证入口的既有证据;不写成 `5ea22ec` 又执行了一次重型任务。后续若修改这些源码、manifest 或依赖,应复跑受影响门禁。 -已取回的Linux artifact记录 `mode=download`、`download.reused=false`;下载阶段约32.216s,总验证40.193s,ORT两次前向约2.6874s/2.7554s。验证器进程峰值RSS为 **6,409,670,656 bytes(约6.41 GB)**,来自Linux `getrusage(RUSAGE_SELF).ru_maxrss`,与§4.3本地Windows约6.40 GB分开记录,均不包含导出子进程。`source_fingerprints` 三项已与提交545e696的对应源码blob核对一致。 +已取回的Linux artifact记录 `mode=download`、`download.reused=false`;下载阶段约32.216s,总验证40.193s,ORT两次前向约2.6874s/2.7554s。验证器进程峰值RSS为 **6,409,670,656 bytes(约6.41 GB)**,来自Linux `getrusage(RUSAGE_SELF).ru_maxrss`,不包含导出子进程。`source_fingerprints` 三项已与提交545e696的对应源码blob核对一致。 ## 5. 环境、CI与团队验收 | 检查 | 当前状态 / 下一步 | |---|---| -| Windows工具与真实执行 | 已通过;便携Zig/QEMU,不需要WSL | | 当前基线 Linux 部署工具链 | `5ea22ec` [部署工作流](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369623)通过 MatMul、官方小模型 IR 与 28 次 QEMU;固定 release 完整 ORT 有独立历史通过记录 | | 当前基线 Linux 主 CI / benchmark | `5ea22ec` [主工作流](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369611)通过;全量测试 2371 passed、6 skipped。AI review 通过不替代人工验收 | | IR-only既有CI | 只证明相应提交和IR路径,不能外推新增QEMU路径 | -| 完整ONNX重型门禁 | 本地verify及545e696的Linux download重型任务通过,见§4.4;PR轻量回归不替代新的重型运行 | +| 完整ONNX重型门禁 | 545e696 的 Linux download 重型任务通过,见§4.4;PR轻量回归不替代新的重型运行 | | docs检查 | 只验证结构,不证明接口决定获批 | | 第二人复现 | 按README模板记录,不能用第一人重复跑/代理并行测试替代 | 原runner的sudo/HTTPS故障属于历史环境证据,不能假定仍然存在或已解决;以当前run为准。推进时先跑MatMul定位工具/数值链路,再运行真实结构小图,最后记录完整ONNX gate与人工签字。任一错误都保留报告,明确阶段和首次偏差。 -暂留阶段的修复曾通过2415项全仓测试、7次MatMul和28次两层QEMU执行,并重验完整ONNX ORT。本次将这些修复与新增仿真耗时统计一并集成到 PR #91;暂留阶段的历史结果、40项skip原因、数值与待决策事项见[本地审查报告](W1-本地收尾与审查报告.md),新增改动的验证另行记录,不能沿用旧提交的 Linux CI 结论。 +个人工作树收尾测量见 [固定提交历史归档](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/W1-探测方案.md);修改后的 Linux 验证需要对应 SHA 的新记录,不沿用旧 CI 结论。 ## 6. 当前结论与变更记录 diff --git "a/docs/llm-deploy-v1.0/W1/W1-\346\234\254\345\234\260\346\224\266\345\260\276\344\270\216\345\256\241\346\237\245\346\212\245\345\221\212.md" "b/docs/llm-deploy-v1.0/W1/W1-\346\234\254\345\234\260\346\224\266\345\260\276\344\270\216\345\256\241\346\237\245\346\212\245\345\221\212.md" index c062a795..eb0935bc 100644 --- "a/docs/llm-deploy-v1.0/W1/W1-\346\234\254\345\234\260\346\224\266\345\260\276\344\270\216\345\256\241\346\237\245\346\212\245\345\221\212.md" +++ "b/docs/llm-deploy-v1.0/W1/W1-\346\234\254\345\234\260\346\224\266\345\260\276\344\270\216\345\256\241\346\237\245\346\212\245\345\221\212.md" @@ -3,6 +3,8 @@ 日期:2026-10-02。基线:`5ea22ecc7fd314025f6453c556dbb3e3f16a3175`。 本地暂留阶段分支:`codex/w1-local-closeout`,不是最终 PR 分支。此前按要求完成本地收尾并保留未提交修改;此次经授权将这些修复、复现准备和新增时长记录整合更新 PR #91,其源分支为 `w2/qwen3-real-2layer-probe`。`5ea22ec` 仅为历史起点,本次发布的精确 SHA、测试和 CI 见 PR 说明及对应运行报告,不能沿用历史 PASS。 +正式交付和独立复现统一使用 **Ubuntu 24.04 x86_64 / Bash / Python 3.12**,见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。文件名保留以兼容已有引用。 + ## 1. 结论与完成范围 此前本地阶段完成文档对齐、复现准备和明确缺陷修复,并重新执行本地数值探测。本次集成进一步补入成功/失败/超时可见的 QEMU 时长记录;最终结果须按本次提交的报告验收。 @@ -31,30 +33,11 @@ W1 仍不能标记为正式关闭:第二名成员独立复现、团队接口/ 全仓初次回归暴露一项旧测试依赖过时的检查顺序:缺工具测试使用空模型目录,现在会先在产物校验阶段失败。该测试已隔离前置产物检查,继续验证工具缺失的退出码、失败阶段和报告保留;产物错误由新的独立用例覆盖。 -## 3. 历史本地阶段验证 - -下表记录时长功能加入前的本地收尾验证,是本次集成的已有证据,不是新增计时代码或当前提交的最终验证。本次实测见 PR 说明和当前报告;受影响回归与真实 QEMU 探测需要重新执行。 - -| 验证 | 实际结果 | 本地证据(仓库根目录下) | -|---|---|---| -| 全量 `tests/` | **2415 passed、0 failed、40 skipped** | `output/w1-local-all-tests-final.log`、同名 `.xml` | -| 固定CPU环境的Qwen/导出相关回归 | **133 passed、0 skipped** | `output/w1-probe-closeout-20261002-cpu.xml` | -| 环境预检回归 | **26 passed**;真实环境ready=true | `output/w1-local-preflight-review-tests.xml`、`output/w1-local-preflight-reviewed/report.json` | -| 原PR89合成图 | ORT/NumPy max_abs约1.097e-6;IR/ORT约7.153e-7,均通过 | `output/w1-local-synthetic/report.json` | -| FP32 MatMul | **7次QEMU均通过**,最大绝对误差2.384185791015625e-7 | `output/w1-local-matmul/report.json` | -| 真实Qwen3两层小配置 | 新导出;7输入、29检查点、PyTorch/ORT/IR全部通过 | `output/w1-local-qwen3-small/report.json/md/html` | -| 新增后端与优化集成 | **4构建、28次QEMU均通过**;QEMU最大绝对误差1.6689300537109375e-6,优化后IR最大绝对误差1.9073486328125e-6 | `output/w1-local-qwen3-riscv/report.json/md/html` | -| 空输出Gather真实RV64专项 | 4次执行通过:正/负越界、正/负合法索引恢复 | `output/tensor-review-empty-gather-rv64/report.json` | -| 完整0.6B固定ONNX产物 | verify模式:4文件哈希、311个外部tensor、checker、两组ORT执行通过 | `output/w1-local-full-onnx/report.json/md` | -| 文档及workflow | 四接口/风险检查、链接检查、YAML与17段shell语法检查通过 | `scripts/check_llm_deploy_docs.py`、`scripts/check_docs_links.py`、`output/w1-local-workflow-check/` | - -全量40个skip中,38个需要本机没有的Linux用户态`qemu-riscv32`,另2个为通用环境缺Torch的模块,后者在固定CPU环境的133项回归中执行。回归集合有重叠,不相加作为总用例数。当前主机实际运行了系统态`qemu-system-riscv64`,两者不可混淆。 +## 3. Linux 验证与历史归档 -所有小模型数值门槛仍为`max_abs < 1e-5`、`rtol=0`。上述历史本地阶段的完整 ONNX verify 进程峰值 RSS 为 6,401,183,744 bytes,约 5.96 GiB;不含重新导出子进程。 -该阶段没有重新导出完整模型、没有重新生成全模型 PyTorch 参考,也没有执行完整 ScratchV IR/QEMU。 -导出验证器修复通过真实小型ONNX/ORT测试检查错误参考输入;它不能被描述为完整0.6B重新导出成功。 +个人工作树的原始回归计数、内存和耗时保留于 [固定提交历史归档](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/W1-本地收尾与审查报告.md),不转换成 Linux 测量,也不视为新提交执行。已有 Linux 的 MatMul、两层 QEMU 和完整 ONNX ORT run 见 [W1 README](README.md) 第 5 节。后续复现按 Linux 指南在指定 SHA 重新运行,保存自己的报告与数组。 -本地 L2 harness 文件 `.claude/harness/verify/run.py` 不存在,未宣称执行。上表所处的历史阶段,新增 CI 步骤只完成本地语法与对应命令检查,尚未在远端执行;本次集成后的 Linux 结论以对应提交的 Checks 为准。`--require-clean` 在当时 dirty 工作树按设计应失败,本地数值测试保留修改状态。 +所有小模型数值门槛为 `max_abs < 1e-5`、`rtol=0`;完整 ONNX download/verify 不等于重新导出或完整模型 PyTorch 对比。Linux CI 和作者执行不能替代第二人确认。 ### 3.1 本次新增时长记录的读取与边界 @@ -62,23 +45,9 @@ W1 仍不能标记为正式关闭:第二名成员独立复现、团队接口/ 数值成功/失败、运行失败、超时、已尝试但未启动、尚未尝试等状态应与时长一起查看。QEMU 进程墙钟包含启动、guest 计算、输出打包/UART 和退出;超时还包含子进程清理,不含 host 输入准备/输出解码,不是纯 forward 计时;流水线时间不含最终报告渲染。失败保留已测耗时,无测量为 null;未启动不能记成零秒成功,汇总不能把未完成的尝试写成 28 次通过。CI Summary 展示相同 Markdown 数据,原始 JSON/HTML 随 artifact 保留。当前不据此给出加速比或性能通过结论。 -### 3.2 本次集成修改的本地复验 - -2026-10-02,在包含本次计时实现的工作树执行(提交前验证;源码和产物哈希由报告保留): - -| 验证 | 结果 | 证据 | -|---|---|---| -| 全仓回归 | **2441 passed、0 failed、40 skipped**;skip 分类与上文相同 | `output/pr91-timing-all.log/xml` | -| 固定 CPU 环境回归 | **171 passed、0 skipped**,含真实模型导出与上游产物绑定 | `output/pr91-timing-cpu.log/xml` | -| 两层 QEMU | **4 构建、28 次通过**;最大绝对误差 **1.6689300537109375e-6** | `output/pr91-timing-qwen3-riscv/report.json/md/html` | -| MatMul | **7 次通过** | `output/pr91-timing-matmul/report.json` | -| 超时端到端负例 | 刻意设定 0.01 秒;进程非零退出,报告保留实际耗时、1 次 timeout、27 次未尝试、部分结果 | `output/pr91-timing-timeout/report.json/md/html` | -| 环境预检 | ready=true;提交前 dirty 状态如实记录 | `output/pr91-timing-env/report.json` | -| 完整已有 ONNX | verify 模式通过;仍不等于重新导出或完整模型 PyTorch 对比 | `output/pr91-timing-full-onnx/report.json/md` | - -本轮两层探测流程 **150.798 秒**,QEMU 进程合计 **139.350 秒**,成功交叉编译合计 **5.247 秒**。normal 的 none/all 平均分别为 **0.996/0.989 秒**,diagnostic 为 **9.130/8.792 秒**。这是一次运行的观察数据,期间本机也运行其他验证任务,不用于稳定性能或优化加速结论;诊断图的额外计算及输出开销包含在内。不同测试集合有重叠,不相加。 +### 3.2 Linux 复验记录 -受影响源码已经交叉审查,文档检查、workflow YAML/17 段 Bash 语法及 diff 空白检查通过。远端 CI 必须以更新后 PR head 的 Checks 为准;后续第二人验收仍须独立检出该 SHA 复现。 +运行前记录 SHA、dirty/diff 和 Linux 环境;运行后保留原始报告、退出码及 source/model/input/ELF 身份。完整七项 W2 的 Linux 入口与已记录 Linux CI 见 [W2 README](../W2/README.md)。不同批次、重叠集合或历史提交的计数不可相加,也不能迁移成当前提交结论。 ## 4. 留待决定或后续实施 diff --git a/docs/llm-deploy-v1.0/W1/interfaces.md b/docs/llm-deploy-v1.0/W1/interfaces.md index dedf913c..d69e7d5e 100644 --- a/docs/llm-deploy-v1.0/W1/interfaces.md +++ b/docs/llm-deploy-v1.0/W1/interfaces.md @@ -1,7 +1,9 @@ # 接口基线 v1.1:待团队冻结确认 +正式交付、CI 和他人复现使用 Ubuntu 24.04 x86_64 / Bash / Python 3.12,环境与工具准备见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。命令中的 `python` 指已激活的 `.venv-linux/bin/python`。 + > 上游:[开发计划.md](../开发计划.md) §2.2、§4.3 W1。 -> 状态(2026-10-02):**按当前实现核对的候选基线,团队确认未完成**。 +> 状态(2026-10-03):**按当前实现核对的候选基线,团队确认未完成**。PR #91 已合并;W2 运行时扩展与 W1 报告修复分开交付,D1–D8 不因此自动通过。 > `docs:interfaces` 只检查文档结构;“有版本号”不等于冻结会议已通过。正式决议见 §7。 ## 0. 四类接口与路线 @@ -13,7 +15,7 @@ | 三 | 后端 | `CompilerDriver(backend="tensor-c")` → 显式张量循环 C → Zig/LLVM → RV64GC ELF | | 四 | 运行时 | 指针数组模型 ABI;QEMU virt 裸机原始输入装载与 UART 输出协议 | -上述新路径已经本地及 `5ea22ec` 的 Linux 数值验证;本次收尾与仿真计时改动一起集成到 PR #91;新增改动须以对应新提交的检查为准,不能继承该提交的 CI PASS。原 `riscv` 标量选择器及旧 `llvm` 路径不由本探测证明;旧选择器的 MatMul 占位/张量与 FP32 覆盖缺口仍保留。不能将“仓库有 LLVM 后端”或 CNN standalone 可运行等同于 Qwen FP32 已支持。 +上述新路径已有本地及历史提交的 Linux 数值验证,收尾与仿真计时已随 PR #91 合并。合并后的本地修复与 W2 扩展须以本轮报告及未来对应提交的检查为准,不能继承旧 CI PASS。原 `riscv` 标量选择器及旧 `llvm` 路径不由本探测证明;旧选择器的 MatMul 占位/张量与 FP32 覆盖缺口仍保留。不能将“仓库有 LLVM 后端”或 CNN standalone 可运行等同于 Qwen FP32 已支持。 ## 1. 冻结与变更规则 @@ -107,11 +109,19 @@ int scratchv_run(const void *const inputs[], void *output); 当前 QEMU 使用 virt/TCG、512 MiB RAM、无 BIOS/OS,自带启动汇编和链接脚本。输入通过 raw loader 写入保留区域(地址 `0x9c000000`,容量 64 MiB),输出通过 UART 二进制帧返回。帧包含标识、状态、长度、校验和和结束标记;解析拒绝截断/损坏/异常状态,超时失败并清理本次子进程。该传输是现有探测协议,不应误写为通用 Linux FFI 或 mmap。 -### 5.3 后续运行时边界 +### 5.3 W2 Host 运行时辅助接口 + +`scratchv/runtime/qwen3_tokenizer.py` 的 `Qwen3Tokenizer.from_directory` 读取已校验的官方本地配置,提供编码/解码和特殊 token 信息,不下载权重、不依赖 Torch/Transformers、不渲染聊天模板。基础词表、含 added tokens 的实际 ID 集合与模型 logits 宽度是不同契约;Qwen3-0.6B 分别为 151643、151669 和 151936。未知或空余模型 ID 解码时显式报错。普通文本编码不自动加入 BOS/EOS;NFC 规范化遵循官方 tokenizer JSON。 + +`scratchv/runtime/llm_inputs.py` 提供 `prepare_inputs`、`build_attention_mask`、`last_valid_logits`、`greedy_next_token` 和 `generation_stop_reason`。输入维持 batch=1、L=256、INT64 IDs、FP32 加性 mask;空/超长输入失败,prompt 中出现 pad ID 不影响有效长度。greedy 选择最后有效位置、平局取最小 ID,拒绝错误形状/dtype和非有限输出。停止判断区分生成 EOS、上下文容量和生成预算,prompt 中的 EOS 不自行停止。 + +这些接口是 W2 辅助能力,未改变模型 ABI 或交付生成循环。验收以 [运行时探测](../../../probes/w2_runtime/README.md) 的固定语料与官方 fast/slow 对照为准;不替代 D1–D8 团队确认。 + +### 5.4 后续运行时边界 -Tokenizer、采样、生成循环和完整模型权重装载仍待实现/验收。当前小模型权重静态嵌入;完整 0.6B 外部分片合计 2,384,201,728 bytes(约 2.22 GiB),不应直接套用 512 MiB guest 配置。W4 需设计代码与权重分离,明确名称/偏移/dtype/shape 描述、装载策略、容量和地址重叠检查、生命周期及分块输出;选择 Linux mmap 时须另建 Linux guest/user-mode 路线,裸机不具备该系统调用。完整 logits 对照仍是原数值门槛,后续生成专用“最后有效位置 logits”接口需独立验收,不能悄悄替换原输出。 +生成循环和完整模型权重装载仍待实现/验收。当前小模型权重静态嵌入;完整 0.6B 外部分片合计 2,384,201,728 bytes(约 2.22 GiB),不应直接套用 512 MiB guest 配置。W4 需设计代码与权重分离,明确名称/偏移/dtype/shape 描述、装载策略、容量和地址重叠检查、生命周期及分块输出;选择 Linux mmap 时须另建 Linux guest/user-mode 路线,裸机不具备该系统调用。完整 logits 对照仍是原数值门槛,后续生成专用“最后有效位置 logits”接口需独立验收,不能悄悄替换原输出。 -固定长度的 host 生成循环须选择最后一个有效 token 的 logits,不能选择右侧 padding 的最后物理位置。ID 范围、mask、空输入、EOS 和有效长度达到 256 的行为需要显式测试;超长输入不静默截断。上述生成策略是后续实现要求,不代表当前探测已经提供生成 API。 +固定长度的 host 生成循环须复用已验收的最后有效位置、mask、ID 范围、空输入、EOS 和长度边界辅助函数,不能选择右侧 padding 的最后物理位置或静默截断。后续仍需将这些能力与完整模型前向组合验证;当前探测未提供端到端生成 API。 ## 6. 能力与剩余缺口 @@ -122,7 +132,8 @@ Tokenizer、采样、生成循环和完整模型权重装载仍待实现/验收 | tensor-c / RV64 运行器 | 本地及 `5ea22ec` 的[Linux 部署任务](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/36984369623)通过,含 28 次两层 QEMU 执行;该提交主 CI 也通过 | E3/E4/E5:第二人独立复现、后续修改的受影响门禁;不外推完整模型 | | 完整模型 IR 数值与容量 | 未由小模型验收 | E2/E4/E5:逐步扩大配置及预训练权重验证 | | 完整 ONNX 门禁 | 历史导出、本地 verify,以及 `545e696` 的[Linux download/ORT 重型任务](https://github.com/yuki-328/ScratchV/actions/runs/36983119833/job/110761955767)均通过;三项验证源码至 `5ea22ec` 未变 | E1/E5:第二人复现及受影响门禁;不冒称重新导出或 `5ea22ec` 重跑重型任务 | -| Tokenizer / 生成 / 大权重加载 | 当前探测不覆盖 | E4:W2 基础运行时、W4 权重装载、W5 生成集成及专项验收 | +| Tokenizer / mask / greedy | W2 基础模块与独立门禁已交付,结果按当前提交检查 | E4/E5:第二人复现,后续集成验收 | +| 生成 / 大权重加载 | 当前探测不覆盖 | E4:W4 权重装载、W5 生成集成及专项验收 | ## 7. 团队待决议与确认 diff --git a/docs/llm-deploy-v1.0/W1/risks.md b/docs/llm-deploy-v1.0/W1/risks.md index 26def2c1..c7ba7ffe 100644 --- a/docs/llm-deploy-v1.0/W1/risks.md +++ b/docs/llm-deploy-v1.0/W1/risks.md @@ -1,7 +1,7 @@ # 风险清单 v1.1 候选 > 上游:[开发计划.md](../开发计划.md) §5。 -> 状态(2026-10-02):**已按本地实测和 `5ea22ec` Linux CI 回填;责任人及处置路线待团队确认。本次收尾与仿真计时改动集成到 PR #91,验证须对应新提交,不能继承旧 CI PASS**。 +> 状态(2026-10-03):**PR #91 已合并,责任人及处置路线仍待团队确认**。下文旧 SHA 的数值结果是历史证据;合并后的本地修复与 W2 新结果见 [本轮报告](../W2/W1修复与W2本地验收报告.md)。人工待办由 [Issue #92](https://github.com/ScratchV-Compiler/ScratchV/issues/92) 跟踪,不能继承旧 CI PASS。 > 对应 `docs:risks` 的结构检查:21 条风险,每条有触发信号和 Plan B。自动检查不替代人工评审。 ## 0. 阅读约定 @@ -25,7 +25,7 @@ | # | 风险 | 概率 | 影响 | 当前状态 / 证据 | 触发信号 | Plan B | 责任 | |---|---|---|---|---|---|---|---| -| R9 | 完整 Qwen3 ONNX 导出/载入失败 | 本地及指定CI已验证 | 高 | 历史导出、本地verify和545e696 Linux download重型任务通过;验证器峰值RSS分别为Windows约6.40 GB、Linux约6.41 GB,均非导出总峰值 | 缺/损坏分片、版本漂移、shape 不符或 ORT 失败 | 固定 revision/依赖与哈希,从已批准来源重新准备产物;必要时重导出。子图只能作定位工具,不代替完整导出 gate | E1/E5 | +| R9 | 完整 Qwen3 ONNX 导出/载入失败 | 本地及指定CI已验证 | 高 | 历史导出及 545e696 Linux download 重型任务通过;Linux 验证器峰值 RSS 约 6.41 GB,非导出总峰值 | 缺/损坏分片、版本漂移、shape 不符或 ORT 失败 | 固定 revision/依赖与哈希,从已批准来源重新准备产物;必要时重导出。子图只能作定位工具,不代替完整导出 gate | E1/E5 | | R10 | 完整权重/激活超出内存布局 | 当前配置容量不足已确认 | 高 | guest 512 MiB,constant/workspace 默认各上限 256 MiB;完整 logits 约 148.4 MiB,权重分片合计 2,384,201,728 bytes(约 2.22 GiB)。权重当前展开为 C 字面量;完整装载和 host IR 峰值均未验收 | C 源码/编译资源膨胀、arena/ELF 与输入区冲突、容量超限或 host/guest OOM | 先测各阶段峰值和完整预算,设计代码权重分离、地址/绑定校验及生命周期;评估 IR 权重复制和中间值驻留,检查点按需保存。mmap 须另有 Linux 路线;不能只放宽上限或增加 -m 就声称解决 | E2/E3/E4,W3–W4 | | R11 | L=256 无 KV Cache 前向耗时过长 | 待测完整规模 | 中 | 小模型 QEMU 时间只用于功能回归,不代表硬件性能 | 完整前向超时或生成循环不可用 | 记录真实耗时,按算子分块/优化;短 L 仅作诊断且另标配置,不降低固定 L=256 验收 | E3/E5 | | R12 | 151936×1024 LM Head 计算/输出开销 | 配置已确认 | 高 | 小词表128不覆盖完整词表开销 | 完整 LM Head 超时或输出传输过大 | 保留完整 logits oracle;生产生成路径可另设计最后位置/分块接口,并与完整输出对照,不能替换 W1 完整形状 gate | E3/E4 | diff --git "a/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.html" "b/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.html" index 0077c1c1..11fe9ddf 100644 --- "a/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.html" +++ "b/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.html" @@ -1,674 +1,8 @@ - - -ONNX 算子支持与正确性对照 -
-

ONNX 算子支持与正确性:修改前后对照

-

归档说明(2026-10-01):本文件保留原始测量的全部结果、计时样本、误差、源码及数据指纹。current.head 与 current.dirty 记录测量时的工作区状态;dirty=true 不描述归档或 PR 提交后的状态。已移除本机源码快照、manifest、fixture、解释器及依赖构建目录的绝对路径;fixture_files 仅记录文件名,相关临时文件未随报告提交。复跑请在仓库根目录执行 python -X utf8 -B -m benchmarks.bench_onnx_operators --baseline-ref 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 --output-dir benchmark_reports/onnx_operators --warmup 1 --repeats 5,由 CLI 重新生成 fixtures;依赖、历史基线和 LFS 准备见同目录《算子补齐与单测报告.txt》。源码 SHA-256 是测量快照的原始字节指纹,跨平台换行符可能影响该值。

-

总验收:通过。修改后 24/24 个用例通过;原先不支持、现通过的用例 20 个,原先错误、现通过的用例 2 个,出现回归的用例 0 个。用例数不等于新增算子种类数。

-
-
24 / 24修改后通过
-
20原先不支持、现通过的用例数
-
2原先错误、现通过的用例数
-
0出现回归的用例数
-

本次改动扩展算子支持并修正计算语义;计时用于本地观测,不代表性能优化结论。只有修改前后均 PASS 的用例才计算执行耗时比。不支持或缺少有效测量时显示“—”,不能解释为零耗时或无限加速。

-

基线兼容适配与输入绑定

-

输入绑定兼容适配不属于基线编译器实现;以下为 runner 的实际策略,需与数值结果一起审阅。

-

Both versions receive original ONNX initializer arrays without reshaping. The current parser's arrays must match dtype/shape/bytes, and its extra Constant globals are added. The baseline parser has no initializers property; supplying this existing interpreter argument is a harness adapter, not a baseline implementation change.

-

按组通过情况

-
分组用例总数修改前 PASS修改后 PASS
独立算子160/1616/16
已有支持对照22/22/2
错误回归20/22/2
组合链路40/44/4
-

版本与测量条件

-
项目值
报告 schema1
基线提交20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6
基线源码 SHA-256a189ff5bd14d9f0c534009fbfa90416a554facb4ee047fe6288c2567723a067c
当前 HEAD20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6
当前包含未提交改动是
当前源码 SHA-25657184d30623c30ddcc0c59db74e0a37a74171ca4a85e45adf1dae1dd578ffeaf
执行顺序ABBA:修改前 → 修改后 → 修改后 → 修改前
每版本轮数2
每轮预热次数1
每轮正式计时次数5
每版本正式样本总数10
计时统计合并各轮正式样本后计算中位数(ms),预热不计入;原始秒数见用例详情
runner 计时说明parse: fresh ONNXParser.parse per sample; run: IRInterpreter.run including verification, planning, binding copies, NumPy computation and return copy. ORT reference generation, common initializer loading, external verification and comparisons are excluded. Each round starts with one untimed correctness qualification, then the configured warmups and timed repetitions. Sequential ABBA order, two rounds per version; warmup is per round. Host CPU, not QEMU.
-

执行计时覆盖完整 IRInterpreter.run,包括 IR 校验与输入/权重复制;不含 ONNX 解析和 ONNX Runtime 参考计算。ONNX → IR 解析单独计时。

-
运行环境 -
项目值
blas{"Build Dependencies": {"blas": {"detection method": "pkgconfig", "found": true, "name": "scipy-openblas", "openblas configuration": "OpenBLAS 0.3.34.106.0 USE64BITINT DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=24", "version": "0.3.34.106.0"}, "lapack": {"detection method": "pkgconfig", "found": true, "name": "scipy-openblas", "openblas configuration": "OpenBLAS 0.3.34.106.0 USE64BITINT DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=24", "version": "0.3.34.106.0"}}, "Compilers": {"c": {"commands": "cl", "linker": "link", "name": "msvc", "version": "19.44.35228"}, "c++": {"commands": "cl", "linker": "link", "name": "msvc", "version": "19.44.35228"}, "cython": {"commands": "cython", "linker": "cython", "name": "cython", "version": "3.3.0"}}, "Machine Information": {"build": {"cpu": "x86_64", "endian": "little", "family": "x86_64", "system": "windows"}, "host": {"cpu": "x86_64", "endian": "little", "family": "x86_64", "system": "windows"}}, "Python Information": {"version": "3.12"}, "SIMD Extensions": {"baseline": ["X86_V2"], "found": ["X86_V3"]}}
numpy2.5.3
onnx1.23.0
onnxruntime1.29.0
ortCPUExecutionProvider, sequential, one thread, optimizations disabled
platformWindows-11-10.0.26200-SP0
python3.12.13
threads{"MKL_NUM_THREADS": "1", "NUMEXPR_NUM_THREADS": "1", "OMP_NUM_THREADS": "1", "OPENBLAS_NUM_THREADS": "1", "VECLIB_MAXIMUM_THREADS": "1"}
-
-

全部用例

耗时比 = 修改前 / 修改后;大于 1 表示该次观测中修改后较快,小于 1 表示较慢。

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
用例分组修改前修改后前最大绝对误差后最大绝对误差前解析 ms后解析 ms前执行 ms后执行 ms执行耗时比 前/后
add_float32前后均通过已有支持对照PASSPASS0.000e+000.000e+000.12710.37710.09700.10250.946×
matmul_32前后均通过已有支持对照PASSPASS0.000e+000.000e+000.12690.36940.09960.10740.927×
gather原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3609—0.1014—
sqrt原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3480—0.0959—
reduce_mean原先不支持 → 现通过独立算子UNSUPPORTEDPASS—2.980e-08—0.3785—0.1077—
transpose原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3687—0.1007—
concat原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3693—0.0978—
slice原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3966—0.1037—
unsqueeze原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3842—0.0848—
expand原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3765—0.1045—
abs原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3495—0.0859—
cast原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3603—0.0802—
constant原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3612—0.0613—
cos原先不支持 → 现通过独立算子UNSUPPORTEDPASS—5.960e-08—0.3408—0.0865—
identity原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3380—0.0520—
pow原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.4307—0.1330—
reciprocal原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3487—0.0909—
sin原先不支持 → 现通过独立算子UNSUPPORTEDPASS—0.000e+00—0.3411—0.0864—
scalar_fp32_add_chain原先错误 → 现正确错误回归NUMERIC_ERRORPASS2.000e+000.000e+00—0.4024—0.1478—
singleton_initializer原先错误 → 现正确错误回归EXECUTION_ERRORPASS—0.000e+00—0.3394—0.0623—
constant_cast_abs_expand原先不支持 → 现通过组合链路UNSUPPORTEDPASS—0.000e+00—0.4442—0.1341—
qwen_rmsnorm原先不支持 → 现通过组合链路UNSUPPORTEDPASS—0.000e+00—0.5022—0.2821—
rope_numeric原先不支持 → 现通过组合链路UNSUPPORTEDPASS—2.980e-08—0.5084—0.2474—
pr89_transformer_2l原先不支持 → 现通过组合链路UNSUPPORTEDPASS—7.153e-07—4.5905—10.1120—

用例与错误详情

-
add_float32 · 前后均通过 -

FP32 x[2,4] + bias[4] -> [2,4]; both tensors are runtime inputs.

-
项目值
算子["Add"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-2561a1905eb33b4789ee579dbd7d5115bb6b0a8a873debffe328373747fdabf31ba
输入 SHA-2568d89e6f89fd7071d90cac47c89d8ded538f4b4aae762624573a6a39e6955a036
参考输出 SHA-256 (expected_sha256)9e9c9737447763b3066c69b737e0d08b98cfb8790a5b17b01d46887bc9dc84da
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 4] / float32
参考 shape / dtype[2, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.00012459990102797747, 0.00012260000221431255, 0.00012610002886503935, 0.00012240000069141388, 0.00011799996718764305, 0.00017060001846402884, 0.00017589994240552187, 0.0001556000206619501, 0.00013669999316334724, 0.0001281999284401536]
执行原始样本 s[9.820004925131798e-05, 9.109999518841505e-05, 9.620003402233124e-05, 9.129999671131372e-05, 8.999998681247234e-05, 0.0001316999550908804, 0.0001227000029757619, 0.00012240000069141388, 9.77999297901988e-05, 9.400001727044582e-05]
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 4] / float32
参考 shape / dtype[2, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.0004192000487819314, 0.00039460009429603815, 0.00038620003033429384, 0.0003771999618038535, 0.00038330000825226307, 0.00037699996028095484, 0.00035340001340955496, 0.00035879993811249733, 0.00034790008794516325, 0.0003451999509707093]
执行原始样本 s[0.00011930009350180626, 0.00010579999070614576, 0.00010310008656233549, 0.00010159995872527361, 0.0001004999503493309, 0.00010600010864436626, 0.00010289996862411499, 0.0001020999625325203, 0.00010160007514059544, 0.00010059995111078024]
-
-
-
matmul_32 · 前后均通过 -

FP32 x[32,32] @ w[32,32] -> [32,32]; both matrices are runtime inputs.

-
项目值
算子["MatMul"]
绝对容差 atol1e-05
相对容差 rtol1e-05
模型 SHA-256c973f94731864d1635c9bddb470d5c4cd94f0419f1f94128cb4898969e28b203
输入 SHA-2563c92a5b12a96c82867e7404232374651a9dd92c1d9d7797c1139f311f082f0a0
参考输出 SHA-256 (expected_sha256)d5c0096f9040d6b2358be41e18aff6195c7fda4791f54e38a0f7267c4461285a
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[32, 32] / float32
参考 shape / dtype[32, 32] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.00012470001820474863, 0.00012370001059025526, 0.0001233000075444579, 0.0001382000045850873, 0.00012390001211315393, 0.00015219999477267265, 0.00013090006541460752, 0.0001327999634668231, 0.00012919993605464697, 0.00012370001059025526]
执行原始样本 s[0.00010169995948672295, 9.799993131309748e-05, 9.8800053820014e-05, 9.50000248849392e-05, 9.69000393524766e-05, 0.00011190003715455532, 0.00010029994882643223, 0.00010699999984353781, 0.00010060006752610207, 9.679992217570543e-05]
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[32, 32] / float32
参考 shape / dtype[32, 32] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.000394199974834919, 0.00036610011011362076, 0.00036529998760670424, 0.0003619999624788761, 0.00037210003938525915, 0.00037340004928410053, 0.00036669999826699495, 0.0003709999145939946, 0.00040339992847293615, 0.000367900007404387]
执行原始样本 s[0.00011040002573281527, 0.00010629999451339245, 0.00010939990170300007, 0.00010389997623860836, 0.00011880008969455957, 0.00010979990474879742, 0.00010659999679774046, 0.00010810000821948051, 0.00010469998233020306, 0.0001050999853760004]
-
-
-
gather · 原先不支持 → 现通过 -

FP32 table[8,4], INT64 indices[2,3] including -1; axis=0 -> [2,3,4].

-
项目值
算子["Gather"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256698871efdf7efb0830f4743bcad6bdf661ea15cb920effbb6625d9b314442f6e
输入 SHA-256dc4e4b0e613fdd64091fc2d9960083e55125cd961bab6c40151bca09801a71cf
参考输出 SHA-256 (expected_sha256)4bd588e72cbd3de896e1fe60ba48ef5969f272be01c201c4ecfe0e8c7306c974
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Gather
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3, 4] / float32
参考 shape / dtype[2, 3, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.000382099999114871, 0.0003637999761849642, 0.00036070006899535656, 0.00037329993210732937, 0.0003640999784693122, 0.0003535000141710043, 0.0003445000620558858, 0.00034570007119327784, 0.00036100007127970457, 0.000354600022546947]
执行原始样本 s[0.00010419997852295637, 0.00010269996710121632, 0.00010080006904900074, 0.0001020999625325203, 0.00010219996329396963, 0.00010059995111078024, 0.00010079995263367891, 0.00010109995491802692, 0.00010179996024817228, 0.00010019994806498289]
-
-
-
sqrt · 原先不支持 → 现通过 -

Positive FP32 x[2,4] -> Sqrt [2,4].

-
项目值
算子["Sqrt"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256cb10a2140fd47284e844b3d6a335b751c80b4647c12543a7e84133363855ce97
输入 SHA-256dd7b2461264e89c3ed6592836c2c66c4131208d2ad1f2af1a1d04fc368d2e8a6
参考输出 SHA-256 (expected_sha256)2940435af5f548faedd548a4c8752a60c9dcb25a05f00abbb1387ab26024f0da
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Sqrt
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 4] / float32
参考 shape / dtype[2, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.0003466999623924494, 0.0003605000674724579, 0.0003635999746620655, 0.00034799997229129076, 0.00034380005672574043, 0.00040040002204477787, 0.0003725000424310565, 0.0003456000704318285, 0.0003480000887066126, 0.0003408000338822603]
执行原始样本 s[0.00010339997243136168, 9.490002412348986e-05, 9.290000889450312e-05, 9.370001498609781e-05, 9.099999442696571e-05, 9.990006219595671e-05, 9.940005838871002e-05, 9.69000393524766e-05, 9.69000393524766e-05, 9.26000066101551e-05]
-
-
-
reduce_mean · 原先不支持 → 现通过 -

FP32 x[2,3,4], constant INT64 axes[1]=[-1], keepdims=1 -> [2,3,1].

-
项目值
算子["ReduceMean"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-25620a01c6a789029d8138071b2ac1e692a4402d74d6ae928319351aca39c708132
输入 SHA-256037f24d9d4527b017605afa283f05dddb03773049338c106ffa47fa642d45b17
参考输出 SHA-256 (expected_sha256)43dcac99f0a069df3610b20c7d4c8e59f9d5c1248ac3578e2ec0e5c5124216d7
初始化器 SHA-256 (initializers_sha256)31bd8476b5e08b62c88590c3a19c07e7c89306b3027fee871f28a188701f0793
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3, 1] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: ReduceMean
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3, 1] / float32
参考 shape / dtype[2, 3, 1] / float32
最大绝对误差2.980e-08
最大相对误差9.933e-08
执行 IR 步数2
解析原始样本 s[0.0003934999695047736, 0.00037949997931718826, 0.00037489994429051876, 0.000372299924492836, 0.00037619995418936014, 0.0003952999832108617, 0.00040259992238134146, 0.0003915999550372362, 0.0003774999640882015, 0.0003698000218719244]
执行原始样本 s[0.00011030002497136593, 0.00010720000136643648, 0.0001078000059351325, 0.00010830000974237919, 0.00010569998994469643, 0.00010750000365078449, 0.00010809989180415869, 0.00010909989941865206, 0.00010730000212788582, 0.00010609999299049377]
-
-
-
transpose · 原先不支持 → 现通过 -

FP32 x[1,2,3,4], perm=[0,2,1,3] -> [1,3,2,4].

-
项目值
算子["Transpose"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256e6c4bcca556bf025f9035ef090e35bba44c4dbcbc9175d3bf087590a3f7318e6
输入 SHA-256c2c2c890613dd624400f084697ecf07d2251cd6956cf57234da0551d2b56508a
参考输出 SHA-256 (expected_sha256)b610b4d2f5c3976762d1a7c04a0d9690f4ec3d39ab51fa9cc7fc9d5f628f6b75
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[1, 3, 2, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Transpose
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[1, 3, 2, 4] / float32
参考 shape / dtype[1, 3, 2, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.0003711999161168933, 0.00037619995418936014, 0.0003662999952211976, 0.00041259999852627516, 0.0003730000462383032, 0.00035260000731796026, 0.0003722000401467085, 0.0003524000057950616, 0.0003497001016512513, 0.0003529998939484358]
执行原始样本 s[0.00010429997928440571, 0.00010159995872527361, 9.989994578063488e-05, 0.00010140007361769676, 0.00010170007590204477, 0.00010349997319281101, 9.93999419733882e-05, 9.840005077421665e-05, 9.790004696696997e-05, 9.710004087537527e-05]
-
-
-
concat · 原先不支持 → 现通过 -

FP32 left[2,1,4] and right[2,2,4], axis=-2 -> [2,3,4].

-
项目值
算子["Concat"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256c96a7b79f82cd23c6c943f61a9d675161ee47102c1251259b7d5d1bfbbc8c977
输入 SHA-25609f2f52e4a0c7c9c0cbb4a3962eff5a53214d57e4445b1ce4fce07bc6acae6d9
参考输出 SHA-256 (expected_sha256)c94ae1852d9ca136be5084962a6333495a5a4df0f7f7db13d5ea0d3d41ce80cf
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Concat
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3, 4] / float32
参考 shape / dtype[2, 3, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.0003693000180646777, 0.00038620003033429384, 0.0003693000180646777, 0.0003645999822765589, 0.00036700000055134296, 0.00041009997949004173, 0.0003698000218719244, 0.00037619995418936014, 0.00035749992821365595, 0.00035550002939999104]
执行原始样本 s[9.770004544407129e-05, 0.00010369997471570969, 9.69000393524766e-05, 9.790004696696997e-05, 9.599991608411074e-05, 0.00010299996938556433, 9.889993816614151e-05, 9.699992369860411e-05, 9.910005610436201e-05, 9.550002869218588e-05]
-
-
-
slice · 原先不支持 → 现通过 -

FP32 x[2,8], INT64 vectors: start=-1,end=INT64_MIN,axis=-1,step=-2 -> [2,4].

-
项目值
算子["Slice"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256b9d3881f01090a4f8ec8ab828d2ef4d9f5460d37c9dce469e03577525ebb3b30
输入 SHA-25604457bea47baae4c174cbb9964c15e2173ed21476eea164fdfa83f0c66b3f3aa
参考输出 SHA-256 (expected_sha256)15916501385783e26c51e19e641bcd04e59f27d8f241438d4c392f02a2da72df
初始化器 SHA-256 (initializers_sha256)6fb5ce1e94fa98118310be1dbbec731166552b05cf34b063477f2d54ea3bda7a
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Slice
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 4] / float32
参考 shape / dtype[2, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.00040770007763057947, 0.0004149000160396099, 0.00039679999463260174, 0.000395399983972311, 0.000394199974834919, 0.0004005999071523547, 0.0003872000379487872, 0.0003947000950574875, 0.00039820000529289246, 0.0003963999915868044]
执行原始样本 s[0.00010409997776150703, 0.00010469998233020306, 0.00010289996862411499, 0.00010169995948672295, 0.00010259996633976698, 0.00010469998233020306, 0.00010290008503943682, 0.0001039999770000577, 0.00010349997319281101, 0.00010409997776150703]
-
-
-
unsqueeze · 原先不支持 → 现通过 -

INT64 x[2,3], constant INT64 axes[2]=[0,-1] -> [1,2,3,1].

-
项目值
算子["Unsqueeze"]
绝对容差 atol0.0
相对容差 rtol0.0
模型 SHA-2563af8a32c4178606972d98c12b3e1bf28d1fd8301587a5efa3b1dd3e10d4545cd
输入 SHA-2564a7719bc73234f93e26685e50c1a40a771fe9465af4fa82ee75df54ceba5ea50
参考输出 SHA-256 (expected_sha256)d96e4f88f5e537f71a56023d5684c075b9d81e960a60fdb1ceef46d3434e7193
初始化器 SHA-256 (initializers_sha256)a8ff6dd261ccbd0a49d97047d600420769bfd677d4891898103e1d823513dcdf
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[1, 2, 3, 1] / int64
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Unsqueeze
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[1, 2, 3, 1] / int64
参考 shape / dtype[1, 2, 3, 1] / int64
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.0004278999986127019, 0.00037889997474849224, 0.0003894000547006726, 0.0004752000095322728, 0.00046479993034154177, 0.00037020002491772175, 0.00035470002330839634, 0.0003568999236449599, 0.0003622000804170966, 0.0003958999877795577]
执行原始样本 s[8.939998224377632e-05, 8.649996016174555e-05, 8.329993579536676e-05, 8.98001017048955e-05, 8.560006972402334e-05, 8.510006591677666e-05, 8.279993198812008e-05, 8.359993807971478e-05, 8.44000605866313e-05, 8.329993579536676e-05]
-
-
-
expand · 原先不支持 → 现通过 -

FP32 x[2,1,4], constant INT64 shape[3]=[1,3,4], bidirectional broadcast -> [2,3,4].

-
项目值
算子["Expand"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-25665a0c9a5fe1045ad4511d3099009989574b5b294a542a2716eb014aa0526a6ab
输入 SHA-2564fdf47ab1aae83654c3b631bf596434cdad68ae0ea325af90a5feb832173d3d6
参考输出 SHA-256 (expected_sha256)eec80e44c7acb11e603e87f01a7570d0e15a45d9b2db4c7d148a5c5e379ddfd6
初始化器 SHA-256 (initializers_sha256)7eccb03cb24eb87dec4c8282d83c3bf88de5ed2ad14006e861cb239668ff0f3a
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Expand
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3, 4] / float32
参考 shape / dtype[2, 3, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.0004062999505549669, 0.0003779999678954482, 0.0004357000580057502, 0.00038139999378472567, 0.0004078999627381563, 0.0003739000530913472, 0.0003749999450519681, 0.0003727000439539552, 0.00036589999217540026, 0.000359599944204092]
执行原始样本 s[0.00011890009045600891, 0.00010760000441223383, 0.00010830000974237919, 0.00010639999527484179, 0.0001023000804707408, 0.00010429997928440571, 0.00010469998233020306, 0.00010419997852295637, 0.00010289996862411499, 0.00010410009417682886]
-
-
-
abs · 原先不支持 → 现通过 -

Signed INT64 x[2,3] -> Abs [2,3], preserving integer dtype.

-
项目值
算子["Abs"]
绝对容差 atol0.0
相对容差 rtol0.0
模型 SHA-2562446ec6d9d36c8ab37f7a443113228b6773c8a6e05822c33e638e10338a58cdf
输入 SHA-256898fdb420a5c2a481f896b1f932dbbd6a45ef50a482d7c2d3ef7263b830e043a
参考输出 SHA-256 (expected_sha256)7443fff444e2f78e27d69236ee9c0023e49acfc2d0158234069c0ca7d02bb640
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3] / int64
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Abs
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3] / int64
参考 shape / dtype[2, 3] / int64
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.00036099995486438274, 0.0003568999236449599, 0.0003541000187397003, 0.0003519000019878149, 0.0003469999646767974, 0.00033569999504834414, 0.00035450002178549767, 0.00034719996619969606, 0.0003350999904796481, 0.000327100045979023]
执行原始样本 s[8.889997843652964e-05, 8.719996549189091e-05, 8.620007429271936e-05, 8.420005906373262e-05, 8.320005144923925e-05, 8.759996853768826e-05, 8.549995254725218e-05, 8.499994874000549e-05, 8.68999632075429e-05, 8.470006287097931e-05]
-
-
-
cast · 原先不支持 → 现通过 -

INT64 position IDs[1,8] -> FP32 [1,8].

-
项目值
算子["Cast"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256e5bf2bf24fd5667082da808f92a835bf3b6412a8a494bee301ee042c31167dd5
输入 SHA-25693f57a04397de81ea2aabce45687351a7238a5b72a8b4a625de522bfd69df92f
参考输出 SHA-256 (expected_sha256)5296d1fd5eb039a4b0ccb36f0498e194eb76696167b82a590cb30defc016141f
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[1, 8] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Cast
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[1, 8] / float32
参考 shape / dtype[1, 8] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.0003632999723777175, 0.00036179996095597744, 0.0004943999228999019, 0.0005131000652909279, 0.00037509994581341743, 0.00034440006129443645, 0.00033980002626776695, 0.00034899997990578413, 0.00033760000951588154, 0.00035889993887394667]
执行原始样本 s[7.98999099060893e-05, 0.00017800007481127977, 0.00011080002877861261, 8.82000895217061e-05, 8.230004459619522e-05, 7.829989772289991e-05, 7.860001642256975e-05, 7.840001489967108e-05, 8.060003165155649e-05, 7.970002479851246e-05]
-
-
-
constant · 原先不支持 → 现通过 -

Constant tensor attribute FP32 [2,3]; no runtime inputs.

-
项目值
算子["Constant"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256c9d931c03dd507060befe5387ac7a6e07c4de2f8970c6fe4009ad2799a7e7227
输入 SHA-2568739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
参考输出 SHA-256 (expected_sha256)5a1a9e658f38c6a788419b7f7321353352ffeb929be456073dca9d36511f2a67
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Constant
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3] / float32
参考 shape / dtype[2, 3] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数1
解析原始样本 s[0.0003632999723777175, 0.000381799996830523, 0.00039549998473376036, 0.000381799996830523, 0.0003598000621423125, 0.00036259996704757214, 0.0003591999411582947, 0.0003554000286385417, 0.0003502999898046255, 0.0003415999235585332]
执行原始样本 s[6.250001024454832e-05, 6.170000415295362e-05, 6.10999995842576e-05, 6.200000643730164e-05, 6.0399994254112244e-05, 6.150000263005495e-05, 6.219989154487848e-05, 6.0399994254112244e-05, 6.049999501556158e-05, 5.909998435527086e-05]
-
-
-
cos · 原先不支持 → 现通过 -

FP32 angles[2,4] spanning positive/negative radians -> Cos [2,4].

-
项目值
算子["Cos"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-2563af95b0a4f4cd19cd254fb139b753cfa99171c187c492d6a322a8e31b8866298
输入 SHA-2568c196fdf42e2e25a11894ee88e8d73e31e59a061f7e76bead86793c428ddc998
参考输出 SHA-256 (expected_sha256)7a6831ace7c14d1ee2ec5d6d7ad497adc95914e56eecf8f384c018012e36e0ed
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Cos
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 4] / float32
参考 shape / dtype[2, 4] / float32
最大绝对误差5.960e-08
最大相对误差8.024e-08
执行 IR 步数2
解析原始样本 s[0.0003530000103637576, 0.00034549995325505733, 0.00033980002626776695, 0.000340400030836463, 0.0003451999509707093, 0.00034739996772259474, 0.000332699972204864, 0.0003412999212741852, 0.00033599999733269215, 0.00033199996687471867]
执行原始样本 s[8.690007962286472e-05, 8.670007809996605e-05, 8.889997843652964e-05, 8.739996701478958e-05, 8.559995330870152e-05, 8.60999571159482e-05, 8.629995863884687e-05, 8.690007962286472e-05, 8.579995483160019e-05, 8.400005754083395e-05]
-
-
-
identity · 原先不支持 → 现通过 -

INT64 indices[2,3] -> Identity [2,3]; frontend alias.

-
项目值
算子["Identity"]
绝对容差 atol0.0
相对容差 rtol0.0
模型 SHA-256e16a4cbfc11442ded30de5b7503512fe1d179b3c64dfcc65140d17f81cab3a4b
输入 SHA-25619f74d0f712a161d1f093b37f6d7d1464fb4a730dd83d1069f5cbf5e38e13413
参考输出 SHA-256 (expected_sha256)a700997366bef11ca0353f21bc678822148eb06156a05ed32d1ce76b73ad1cc8
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3] / int64
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Identity
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3] / int64
参考 shape / dtype[2, 3] / int64
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数1
解析原始样本 s[0.000341000035405159, 0.00034380005672574043, 0.0003355999942868948, 0.0003361001145094633, 0.0003378000110387802, 0.00033820001408457756, 0.0003301999531686306, 0.00032760004978626966, 0.0003744999412447214, 0.0003630999708548188]
执行原始样本 s[5.170004442334175e-05, 5.280005279928446e-05, 5.219993181526661e-05, 5.2200048230588436e-05, 5.080003757029772e-05, 5.179992876946926e-05, 5.079992115497589e-05, 5.139992572367191e-05, 6.809993647038937e-05, 5.4600066505372524e-05]
-
-
-
pow · 原先不支持 → 现通过 -

Signed FP32 x[2,3] ** INT64 scalar exponent 2 -> FP32 [2,3].

-
项目值
算子["Pow"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-2569184fbaccd896e171a8ca025b6745d0674bf1493913f34b77f8bc0f218751771
输入 SHA-256b33fcf5b3bd13b2fa3bacb0388119b95ed138163db9b168dd3993654533b4cd1
参考输出 SHA-256 (expected_sha256)dcada1e53c419cefb8b09810f4494beb138bcecdce47c5c0f48466ee49d5d51e
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Pow
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3] / float32
参考 shape / dtype[2, 3] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.0004491000436246395, 0.00043999997433274984, 0.00040019990410655737, 0.00037569995038211346, 0.00038789992686361074, 0.0003453000681474805, 0.00048369995784014463, 0.00044920004438608885, 0.00047619990073144436, 0.0004214000655338168]
执行原始样本 s[0.00014999997802078724, 0.00014180003199726343, 0.00011959997937083244, 0.00011729996185749769, 0.00011809996794909239, 0.00011589995119720697, 0.00015820004045963287, 0.00015029998030513525, 0.00015840004198253155, 0.0001241000136360526]
-
-
-
reciprocal · 原先不支持 → 现通过 -

Nonzero signed FP32 x[2,4] -> Reciprocal [2,4].

-
项目值
算子["Reciprocal"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-2563d0e2326e2db7ff3ad97b6a1e7f2e59dc713fd2e70ca1a4d067c04ed699a04e3
输入 SHA-256fb2145f899f1258c7169e1f7a540b9b94625c689d770793b142cda8ae4bf722f
参考输出 SHA-256 (expected_sha256)c31992656b1ac1d73d6fc90936ecaf8d816946286b2c6170097a6cf9823d818d
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Reciprocal
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 4] / float32
参考 shape / dtype[2, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.00035170000046491623, 0.0003496999852359295, 0.00034989998675882816, 0.00034769997000694275, 0.00033870001789182425, 0.00037759996484965086, 0.00035119999665766954, 0.0003469000803306699, 0.00033660000190138817, 0.00033329997677356005]
执行原始样本 s[9.190000128000975e-05, 9.019998833537102e-05, 9.029998909682035e-05, 9.14999982342124e-05, 8.899997919797897e-05, 9.609991684556007e-05, 9.510002564638853e-05, 9.06999921426177e-05, 9.109999518841505e-05, 9.059999138116837e-05]
-
-
-
sin · 原先不支持 → 现通过 -

FP32 angles[2,4] spanning positive/negative radians -> Sin [2,4].

-
项目值
算子["Sin"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256cd93a789d0f603c48706203da065f2451a65cf345581f9772d34fff8bca88a2a
输入 SHA-256efb400ff25087f013055f64abc4fcf498072fb9d417b825f6e9e292742b3ef9e
参考输出 SHA-256 (expected_sha256)e2a29d3f9d4bc5d9003ed673b7e4cb70997b5048a77aff3b181721c9461d13ee
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 4] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Sin
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 4] / float32
参考 shape / dtype[2, 4] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数2
解析原始样本 s[0.00034879997838288546, 0.0003532000118866563, 0.00035470002330839634, 0.0003448999486863613, 0.00033730000723153353, 0.00033710000570863485, 0.0003510999958962202, 0.0003322999691590667, 0.00032819993793964386, 0.000327700050547719]
执行原始样本 s[8.570007048547268e-05, 8.719996549189091e-05, 8.620007429271936e-05, 8.480006363242865e-05, 8.730008266866207e-05, 8.71000811457634e-05, 8.659996092319489e-05, 8.569995407015085e-05, 8.390005677938461e-05, 8.750008419156075e-05]
-
-
-
scalar_fp32_add_chain · 原先错误 → 现正确 -

FP32 scalar initializers: (16777216 + 1) + 1 must round after each Add; exact scalar output.

-
项目值
算子["Add"]
绝对容差 atol0.0
相对容差 rtol0.0
模型 SHA-2564ff5c4c55a16938c3a8c30f16a2b8e6662e6f7cc38413473bc00bd50a36abce7
输入 SHA-2568739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
参考输出 SHA-256 (expected_sha256)87da7d0335886bc568cddf1844505676699d5476a6bd72d0d6292c3ee42c253a
初始化器 SHA-256 (initializers_sha256)32823c7b66e5511946296130ba5b03f3e897d3939bd738d14f84d41f7e80923a
-
-

修改前 NUMERIC_ERROR

-
项目值
状态 / 阶段NUMERIC_ERROR / compare
实际 shape / dtype[] / float32
参考 shape / dtype[] / float32
最大绝对误差2.000e+00
最大相对误差1.192e-07
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

NumericMismatch: output differs from ORT: max_abs_error=2
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[] / float32
参考 shape / dtype[] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数5
解析原始样本 s[0.0005631999811157584, 0.0004637000383809209, 0.0004089000867679715, 0.00039579998701810837, 0.0003945999778807163, 0.000381799996830523, 0.00041089998558163643, 0.00040959997568279505, 0.0003945999778807163, 0.0003902000607922673]
执行原始样本 s[0.00019599997904151678, 0.00015169999096542597, 0.00014780007768422365, 0.0001476999605074525, 0.0001452999422326684, 0.00015129998791962862, 0.00014889996964484453, 0.0001441999338567257, 0.00014589994680136442, 0.0001444999361410737]
-
-
-
singleton_initializer · 原先错误 → 现正确 -

FP32 initializer weight[1]=[7] returned directly: retain tensor data and shape [1], not scalar.

-
项目值
算子[]
绝对容差 atol0.0
相对容差 rtol0.0
模型 SHA-25652473f6b8b73062f1bc8eb98f6a61a2dffb467a6c9966cb985c8b53037c2f722
输入 SHA-2568739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
参考输出 SHA-256 (expected_sha256)3948d58d392cf3b813d8cc0773e2ed0af42344e5e96ff9eff386c223ce2ac1f8
初始化器 SHA-256 (initializers_sha256)7da88694441abcf15dddeff15e70aaa41eb8e0ee336a5b08e02b61215b3bb6df
-
-

修改前 EXECUTION_ERROR

-
项目值
状态 / 阶段EXECUTION_ERROR / execute
实际 shape / dtype— / —
参考 shape / dtype[1] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

IRExecutionError: BindingError function=singleton_initializer value=weight: initializer disagrees with scalar constant
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[1] / float32
参考 shape / dtype[1] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数1
解析原始样本 s[0.0003521000035107136, 0.00034039991442114115, 0.00033650000113993883, 0.0003445999464020133, 0.0003336999798193574, 0.0003494999837130308, 0.0003339999821037054, 0.00035889993887394667, 0.00032849994022399187, 0.00033849989995360374]
执行原始样本 s[6.290001329034567e-05, 6.280001252889633e-05, 6.140000186860561e-05, 6.009999196976423e-05, 6.079999729990959e-05, 6.370001938194036e-05, 6.200000643730164e-05, 6.330001633614302e-05, 6.260001100599766e-05, 6.0399994254112244e-05]
-
-
-
constant_cast_abs_expand · 原先不支持 → 现通过 -

Constant INT32 [-2,3] -> Cast INT64 -> Abs target[2]=[2,3]; Expand FP32 x[1,3] -> [2,3].

-
项目值
算子["Constant", "Cast", "Abs", "Expand"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256bd8568ed91a153a8c061bf51d0ff3512314db206860531456cb11aa0735938b3
输入 SHA-2569b2c7512831a4cace198062de741146fbeedb181b006cf32431c4fce833a9822
参考输出 SHA-256 (expected_sha256)eed5f694fcfe47f46f2a01790cff931b624565234b061b5c64a278c2ed1eac53
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[2, 3] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Constant
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[2, 3] / float32
参考 shape / dtype[2, 3] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数4
解析原始样本 s[0.00043970008846372366, 0.00044329999946057796, 0.0004450000124052167, 0.0004384000785648823, 0.0006199999479576945, 0.00047199998516589403, 0.00045169994700700045, 0.00044700002763420343, 0.00044139998499304056, 0.00043539993930608034]
执行原始样本 s[0.00013560010120272636, 0.0001338999718427658, 0.00013189995661377907, 0.0002367999404668808, 0.00017560005653649569, 0.00013229995965957642, 0.0001343999756500125, 0.000130399945192039, 0.00013109995052218437, 0.00013479997869580984]
-
-
-
qwen_rmsnorm · 原先不支持 → 现通过 -

FP32 x[1,4,8], gamma[8]; Pow(INT64 scalar 2)/ReduceMean(-1)/Add(eps=1e-6)/Sqrt/Reciprocal/Mul -> [1,4,8].

-
项目值
算子["Pow", "ReduceMean", "Add", "Sqrt", "Reciprocal", "Mul"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-256cf11b7c966672ab3f03eb3d92a8e45fc87968d70e33a4a500964ce52d02a7237
输入 SHA-256f9c2bb681cd4b72783d29572f427b6e237b6c7bf40252ab5b88a2c6174751a48
参考输出 SHA-256 (expected_sha256)5fe5157ae0bc87a41a4a9db76a037f52f8b7e5aa4f2c64e8674e0bdbcb8dfaec
初始化器 SHA-256 (initializers_sha256)6bf5dd34dd2bb7907f3d53ccd995420cfd6a610b463eb41cf53d0698308ab92c
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[1, 4, 8] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Pow
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[1, 4, 8] / float32
参考 shape / dtype[1, 4, 8] / float32
最大绝对误差0.000e+00
最大相对误差0.000e+00
执行 IR 步数10
解析原始样本 s[0.000506900018081069, 0.0005015999777242541, 0.0005096999229863286, 0.0005014999769628048, 0.0004960000514984131, 0.0005388999124988914, 0.0005028999876230955, 0.000512199942022562, 0.000493900035507977, 0.0004851999692618847]
执行原始样本 s[0.0002813000464811921, 0.0002788000274449587, 0.0002808000426739454, 0.00028170004952698946, 0.00027820002287626266, 0.0002930000191554427, 0.00028490007389336824, 0.00028249993920326233, 0.0002837000647559762, 0.0002848999574780464]
-
-
-
rope_numeric · 原先不支持 → 现通过 -

INT64 positions[1,4,1] -> Cast FP32; frequencies[1,1,4], Sin/Cos rotate FP32 left/right[1,4,4] -> [1,4,8].

-
项目值
算子["Cast", "Mul", "Cos", "Sin", "Sub", "Add", "Concat"]
绝对容差 atol1e-06
相对容差 rtol1e-06
模型 SHA-2567aed8255b36ac1866b0f27cd399e045010f0b82b1308036bbdbb849e02476cfa
输入 SHA-256823f1be624262313583ea77e2dc14efcb3ea0696d059c2cc89643266d78f944a
参考输出 SHA-256 (expected_sha256)eb0681ce4f70f4032e2806727ea5eb56e3d9f4036729cba48950643b393a39cd
初始化器 SHA-256 (initializers_sha256)8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[1, 4, 8] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Cast
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[1, 4, 8] / float32
参考 shape / dtype[1, 4, 8] / float32
最大绝对误差2.980e-08
最大相对误差1.699e-07
执行 IR 步数12
解析原始样本 s[0.0005266999360173941, 0.000520200002938509, 0.0005088000325486064, 0.0006041000597178936, 0.0006553001003339887, 0.0005080000264570117, 0.0005031999899074435, 0.0005010999739170074, 0.0005029999883845448, 0.00049919995944947]
执行原始样本 s[0.0002498000394552946, 0.00024560000747442245, 0.0002465000143274665, 0.0003208999987691641, 0.00031370006036013365, 0.00025370006915181875, 0.0002449000021442771, 0.00024610001128166914, 0.0002482000272721052, 0.0002460000105202198]
-
-
-
pr89_transformer_2l · 原先不支持 → 现通过 -

PR #89 原始两层合成 Transformer,FP32,输出 [1,256,128];非完整 Qwen3

-
项目值
算子["Add", "Concat", "Div", "Expand", "Gather", "MatMul", "Mul", "Neg", "ReduceMean", "Reshape", "Sigmoid", "Slice", "Softmax", "Sqrt", "Transpose", "Unsqueeze"]
绝对容差 atol1e-05
相对容差 rtol0
模型 SHA-25617a8f191ee65a9d97143131016058e8a3c10a7d5c06e8152bf16dd8941cd19e1
输入 SHA-256bd3f848d2679e66691a7701a84e39f35a02db58c0874e65b023b561f4f507457
参考输出 SHA-256 (expected_sha256)413dd44ea90d93a25cbe0f89bc3703ffeb8ee7b0774c8fa955bcd7521b24ef5c
初始化器 SHA-256 (initializers_sha256)955cb24a985e3aa13cb93e62af4e51e92ff27d14bdfdeac1d5a6107f65a8e3de
-
-

修改前 UNSUPPORTED

-
项目值
状态 / 阶段UNSUPPORTED / parse
实际 shape / dtype— / —
参考 shape / dtype[1, 256, 128] / float32
最大绝对误差—
最大相对误差—
执行 IR 步数—
解析原始样本 s[]
执行原始样本 s[]
-

错误详情

ONNXParseError: Unsupported ONNX op type: Gather
-
-

修改后 PASS

-
项目值
状态 / 阶段PASS / complete
实际 shape / dtype[1, 256, 128] / float32
参考 shape / dtype[1, 256, 128] / float32
最大绝对误差7.153e-07
最大相对误差5.722e-04
执行 IR 步数159
解析原始样本 s[0.004760700045153499, 0.004594899946823716, 0.004717700066976249, 0.004990800051018596, 0.004586199996992946, 0.004221900017000735, 0.004203699994832277, 0.004572000005282462, 0.004674999974668026, 0.004224899923428893]
执行原始样本 s[0.010629900032654405, 0.011460799956694245, 0.009812200092710555, 0.009985200013034046, 0.010765800019726157, 0.009604100021533668, 0.010238799965009093, 0.009660700103268027, 0.010715999989770353, 0.009805700043216348]
-
-
-
- + +ONNX 算子 benchmark Linux 复跑入口 +

ONNX 算子 benchmark:Linux 复跑入口

+

正式环境:Ubuntu 24.04 x86_64 / Bash / Python 3.12。本页是入口,不是新测量报告。

+

请按环境与单测指南准备依赖、固定 Git 基线和 LFS 模型,再按Linux benchmark 命令生成自己的 JSON、Markdown 和 HTML。

+

当前同名 JSON 为 historical_benchmark_index,不能作为 benchmark 结果。历史个人测量不改名为 Linux 数据,也不能与不同环境的耗时直接计算加速比。

+

查看固定提交中的原始 HTML 测量

+ diff --git "a/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.json" "b/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.json" index 9b686473..1ce68a34 100644 --- "a/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.json" +++ "b/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.json" @@ -1,2388 +1,14 @@ { + "document_kind": "historical_benchmark_index", "schema_version": 1, - "baseline": { - "commit": "20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6", - "source_sha256": "a189ff5bd14d9f0c534009fbfa90416a554facb4ee047fe6288c2567723a067c" + "measurement_available": false, + "reproduction_target": "Ubuntu 24.04 x86_64 / Bash / Python 3.12", + "guide": "算子补齐与单测-benchmark.md", + "original_commit": "2d07ccc42936dcfde19d552bca02a324cd33821c", + "historical_reports": { + "md": "https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/算子补齐与单测-benchmark.md", + "json": "https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/算子补齐与单测-benchmark.json", + "html": "https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/算子补齐与单测-benchmark.html" }, - "current": { - "head": "20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6", - "dirty": true, - "source_sha256": "57184d30623c30ddcc0c59db74e0a37a74171ca4a85e45adf1dae1dd578ffeaf" - }, - "environment": { - "python": "3.12.13", - "platform": "Windows-11-10.0.26200-SP0", - "numpy": "2.5.3", - "onnx": "1.23.0", - "onnxruntime": "1.29.0", - "threads": { - "OMP_NUM_THREADS": "1", - "OPENBLAS_NUM_THREADS": "1", - "MKL_NUM_THREADS": "1", - "NUMEXPR_NUM_THREADS": "1", - "VECLIB_MAXIMUM_THREADS": "1" - }, - "blas": { - "Compilers": { - "c": { - "name": "msvc", - "linker": "link", - "version": "19.44.35228", - "commands": "cl" - }, - "cython": { - "name": "cython", - "linker": "cython", - "version": "3.3.0", - "commands": "cython" - }, - "c++": { - "name": "msvc", - "linker": "link", - "version": "19.44.35228", - "commands": "cl" - } - }, - "Machine Information": { - "host": { - "cpu": "x86_64", - "family": "x86_64", - "endian": "little", - "system": "windows" - }, - "build": { - "cpu": "x86_64", - "family": "x86_64", - "endian": "little", - "system": "windows" - } - }, - "Build Dependencies": { - "blas": { - "name": "scipy-openblas", - "found": true, - "version": "0.3.34.106.0", - "detection method": "pkgconfig", - "openblas configuration": "OpenBLAS 0.3.34.106.0 USE64BITINT DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=24" - }, - "lapack": { - "name": "scipy-openblas", - "found": true, - "version": "0.3.34.106.0", - "detection method": "pkgconfig", - "openblas configuration": "OpenBLAS 0.3.34.106.0 USE64BITINT DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=24" - } - }, - "Python Information": { - "version": "3.12" - }, - "SIMD Extensions": { - "baseline": [ - "X86_V2" - ], - "found": [ - "X86_V3" - ] - } - }, - "ort": "CPUExecutionProvider, sequential, one thread, optimizations disabled" - }, - "warmup": 1, - "repeats": 10, - "repeats_per_round": 5, - "rounds_per_variant": 2, - "execution_order": [ - "before", - "after", - "after", - "before" - ], - "timing_scope": "parse: fresh ONNXParser.parse per sample; run: IRInterpreter.run including verification, planning, binding copies, NumPy computation and return copy. ORT reference generation, common initializer loading, external verification and comparisons are excluded. Each round starts with one untimed correctness qualification, then the configured warmups and timed repetitions. Sequential ABBA order, two rounds per version; warmup is per round. Host CPU, not QEMU.", - "binding_policy": "Both versions receive original ONNX initializer arrays without reshaping. The current parser's arrays must match dtype/shape/bytes, and its extra Constant globals are added. The baseline parser has no initializers property; supplying this existing interpreter argument is a harness adapter, not a baseline implementation change.", - "results": [ - { - "name": "add_float32", - "group": "control", - "description": "FP32 x[2,4] + bias[4] -> [2,4]; both tensors are runtime inputs.", - "operators": [ - "Add" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "1a1905eb33b4789ee579dbd7d5115bb6b0a8a873debffe328373747fdabf31ba", - "inputs_sha256": "8d89e6f89fd7071d90cac47c89d8ded538f4b4aae762624573a6a39e6955a036", - "expected_sha256": "9e9c9737447763b3066c69b737e0d08b98cfb8790a5b17b01d46887bc9dc84da", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.00012459990102797747, - 0.00012260000221431255, - 0.00012610002886503935, - 0.00012240000069141388, - 0.00011799996718764305, - 0.00017060001846402884, - 0.00017589994240552187, - 0.0001556000206619501, - 0.00013669999316334724, - 0.0001281999284401536 - ], - "run_samples_s": [ - 9.820004925131798e-05, - 9.109999518841505e-05, - 9.620003402233124e-05, - 9.129999671131372e-05, - 8.999998681247234e-05, - 0.0001316999550908804, - 0.0001227000029757619, - 0.00012240000069141388, - 9.77999297901988e-05, - 9.400001727044582e-05 - ], - "parse_median_s": 0.00012714997865259647, - "run_median_s": 9.699998190626502e-05, - "executed_steps": 2, - "actual_shape": [ - 2, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.00011799996718764305, - "parse_max_s": 0.00017589994240552187, - "run_min_s": 8.999998681247234e-05, - "run_max_s": 0.0001316999550908804 - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0004192000487819314, - 0.00039460009429603815, - 0.00038620003033429384, - 0.0003771999618038535, - 0.00038330000825226307, - 0.00037699996028095484, - 0.00035340001340955496, - 0.00035879993811249733, - 0.00034790008794516325, - 0.0003451999509707093 - ], - "run_samples_s": [ - 0.00011930009350180626, - 0.00010579999070614576, - 0.00010310008656233549, - 0.00010159995872527361, - 0.0001004999503493309, - 0.00010600010864436626, - 0.00010289996862411499, - 0.0001020999625325203, - 0.00010160007514059544, - 0.00010059995111078024 - ], - "parse_median_s": 0.0003770999610424042, - "run_median_s": 0.00010249996557831764, - "executed_steps": 2, - "actual_shape": [ - 2, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003451999509707093, - "parse_max_s": 0.0004192000487819314, - "run_min_s": 0.0001004999503493309, - "run_max_s": 0.00011930009350180626 - }, - "fixture_files": { - "model": "add_float32.onnx", - "inputs": "add_float32.inputs.npz", - "expected": "add_float32.expected.npy", - "initializers": "add_float32.initializers.npz" - } - }, - { - "name": "matmul_32", - "group": "control", - "description": "FP32 x[32,32] @ w[32,32] -> [32,32]; both matrices are runtime inputs.", - "operators": [ - "MatMul" - ], - "atol": 1e-05, - "rtol": 1e-05, - "model_sha256": "c973f94731864d1635c9bddb470d5c4cd94f0419f1f94128cb4898969e28b203", - "inputs_sha256": "3c92a5b12a96c82867e7404232374651a9dd92c1d9d7797c1139f311f082f0a0", - "expected_sha256": "d5c0096f9040d6b2358be41e18aff6195c7fda4791f54e38a0f7267c4461285a", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.00012470001820474863, - 0.00012370001059025526, - 0.0001233000075444579, - 0.0001382000045850873, - 0.00012390001211315393, - 0.00015219999477267265, - 0.00013090006541460752, - 0.0001327999634668231, - 0.00012919993605464697, - 0.00012370001059025526 - ], - "run_samples_s": [ - 0.00010169995948672295, - 9.799993131309748e-05, - 9.8800053820014e-05, - 9.50000248849392e-05, - 9.69000393524766e-05, - 0.00011190003715455532, - 0.00010029994882643223, - 0.00010699999984353781, - 0.00010060006752610207, - 9.679992217570543e-05 - ], - "parse_median_s": 0.0001269499771296978, - "run_median_s": 9.955000132322311e-05, - "executed_steps": 2, - "actual_shape": [ - 32, - 32 - ], - "actual_dtype": "float32", - "expected_shape": [ - 32, - 32 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0001233000075444579, - "parse_max_s": 0.00015219999477267265, - "run_min_s": 9.50000248849392e-05, - "run_max_s": 0.00011190003715455532 - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.000394199974834919, - 0.00036610011011362076, - 0.00036529998760670424, - 0.0003619999624788761, - 0.00037210003938525915, - 0.00037340004928410053, - 0.00036669999826699495, - 0.0003709999145939946, - 0.00040339992847293615, - 0.000367900007404387 - ], - "run_samples_s": [ - 0.00011040002573281527, - 0.00010629999451339245, - 0.00010939990170300007, - 0.00010389997623860836, - 0.00011880008969455957, - 0.00010979990474879742, - 0.00010659999679774046, - 0.00010810000821948051, - 0.00010469998233020306, - 0.0001050999853760004 - ], - "parse_median_s": 0.0003694499609991908, - "run_median_s": 0.00010735000250861049, - "executed_steps": 2, - "actual_shape": [ - 32, - 32 - ], - "actual_dtype": "float32", - "expected_shape": [ - 32, - 32 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003619999624788761, - "parse_max_s": 0.00040339992847293615, - "run_min_s": 0.00010389997623860836, - "run_max_s": 0.00011880008969455957 - }, - "fixture_files": { - "model": "matmul_32.onnx", - "inputs": "matmul_32.inputs.npz", - "expected": "matmul_32.expected.npy", - "initializers": "matmul_32.initializers.npz" - } - }, - { - "name": "gather", - "group": "operators", - "description": "FP32 table[8,4], INT64 indices[2,3] including -1; axis=0 -> [2,3,4].", - "operators": [ - "Gather" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "698871efdf7efb0830f4743bcad6bdf661ea15cb920effbb6625d9b314442f6e", - "inputs_sha256": "dc4e4b0e613fdd64091fc2d9960083e55125cd961bab6c40151bca09801a71cf", - "expected_sha256": "4bd588e72cbd3de896e1fe60ba48ef5969f272be01c201c4ecfe0e8c7306c974", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Gather", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.000382099999114871, - 0.0003637999761849642, - 0.00036070006899535656, - 0.00037329993210732937, - 0.0003640999784693122, - 0.0003535000141710043, - 0.0003445000620558858, - 0.00034570007119327784, - 0.00036100007127970457, - 0.000354600022546947 - ], - "run_samples_s": [ - 0.00010419997852295637, - 0.00010269996710121632, - 0.00010080006904900074, - 0.0001020999625325203, - 0.00010219996329396963, - 0.00010059995111078024, - 0.00010079995263367891, - 0.00010109995491802692, - 0.00010179996024817228, - 0.00010019994806498289 - ], - "parse_median_s": 0.00036085007013753057, - "run_median_s": 0.0001014499575830996, - "executed_steps": 2, - "actual_shape": [ - 2, - 3, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 3, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003445000620558858, - "parse_max_s": 0.000382099999114871, - "run_min_s": 0.00010019994806498289, - "run_max_s": 0.00010419997852295637 - }, - "fixture_files": { - "model": "gather.onnx", - "inputs": "gather.inputs.npz", - "expected": "gather.expected.npy", - "initializers": "gather.initializers.npz" - } - }, - { - "name": "sqrt", - "group": "operators", - "description": "Positive FP32 x[2,4] -> Sqrt [2,4].", - "operators": [ - "Sqrt" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "cb10a2140fd47284e844b3d6a335b751c80b4647c12543a7e84133363855ce97", - "inputs_sha256": "dd7b2461264e89c3ed6592836c2c66c4131208d2ad1f2af1a1d04fc368d2e8a6", - "expected_sha256": "2940435af5f548faedd548a4c8752a60c9dcb25a05f00abbb1387ab26024f0da", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Sqrt", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0003466999623924494, - 0.0003605000674724579, - 0.0003635999746620655, - 0.00034799997229129076, - 0.00034380005672574043, - 0.00040040002204477787, - 0.0003725000424310565, - 0.0003456000704318285, - 0.0003480000887066126, - 0.0003408000338822603 - ], - "run_samples_s": [ - 0.00010339997243136168, - 9.490002412348986e-05, - 9.290000889450312e-05, - 9.370001498609781e-05, - 9.099999442696571e-05, - 9.990006219595671e-05, - 9.940005838871002e-05, - 9.69000393524766e-05, - 9.69000393524766e-05, - 9.26000066101551e-05 - ], - "parse_median_s": 0.0003480000304989517, - "run_median_s": 9.590003173798323e-05, - "executed_steps": 2, - "actual_shape": [ - 2, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003408000338822603, - "parse_max_s": 0.00040040002204477787, - "run_min_s": 9.099999442696571e-05, - "run_max_s": 0.00010339997243136168 - }, - "fixture_files": { - "model": "sqrt.onnx", - "inputs": "sqrt.inputs.npz", - "expected": "sqrt.expected.npy", - "initializers": "sqrt.initializers.npz" - } - }, - { - "name": "reduce_mean", - "group": "operators", - "description": "FP32 x[2,3,4], constant INT64 axes[1]=[-1], keepdims=1 -> [2,3,1].", - "operators": [ - "ReduceMean" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "20a01c6a789029d8138071b2ac1e692a4402d74d6ae928319351aca39c708132", - "inputs_sha256": "037f24d9d4527b017605afa283f05dddb03773049338c106ffa47fa642d45b17", - "expected_sha256": "43dcac99f0a069df3610b20c7d4c8e59f9d5c1248ac3578e2ec0e5c5124216d7", - "initializers_sha256": "31bd8476b5e08b62c88590c3a19c07e7c89306b3027fee871f28a188701f0793", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: ReduceMean", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3, - 1 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 2.9802322387695312e-08, - "max_rel_error": 9.933077874436559e-08, - "parse_samples_s": [ - 0.0003934999695047736, - 0.00037949997931718826, - 0.00037489994429051876, - 0.000372299924492836, - 0.00037619995418936014, - 0.0003952999832108617, - 0.00040259992238134146, - 0.0003915999550372362, - 0.0003774999640882015, - 0.0003698000218719244 - ], - "run_samples_s": [ - 0.00011030002497136593, - 0.00010720000136643648, - 0.0001078000059351325, - 0.00010830000974237919, - 0.00010569998994469643, - 0.00010750000365078449, - 0.00010809989180415869, - 0.00010909989941865206, - 0.00010730000212788582, - 0.00010609999299049377 - ], - "parse_median_s": 0.0003784999717026949, - "run_median_s": 0.0001076500047929585, - "executed_steps": 2, - "actual_shape": [ - 2, - 3, - 1 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 3, - 1 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003698000218719244, - "parse_max_s": 0.00040259992238134146, - "run_min_s": 0.00010569998994469643, - "run_max_s": 0.00011030002497136593 - }, - "fixture_files": { - "model": "reduce_mean.onnx", - "inputs": "reduce_mean.inputs.npz", - "expected": "reduce_mean.expected.npy", - "initializers": "reduce_mean.initializers.npz" - } - }, - { - "name": "transpose", - "group": "operators", - "description": "FP32 x[1,2,3,4], perm=[0,2,1,3] -> [1,3,2,4].", - "operators": [ - "Transpose" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "e6c4bcca556bf025f9035ef090e35bba44c4dbcbc9175d3bf087590a3f7318e6", - "inputs_sha256": "c2c2c890613dd624400f084697ecf07d2251cd6956cf57234da0551d2b56508a", - "expected_sha256": "b610b4d2f5c3976762d1a7c04a0d9690f4ec3d39ab51fa9cc7fc9d5f628f6b75", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Transpose", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 1, - 3, - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0003711999161168933, - 0.00037619995418936014, - 0.0003662999952211976, - 0.00041259999852627516, - 0.0003730000462383032, - 0.00035260000731796026, - 0.0003722000401467085, - 0.0003524000057950616, - 0.0003497001016512513, - 0.0003529998939484358 - ], - "run_samples_s": [ - 0.00010429997928440571, - 0.00010159995872527361, - 9.989994578063488e-05, - 0.00010140007361769676, - 0.00010170007590204477, - 0.00010349997319281101, - 9.93999419733882e-05, - 9.840005077421665e-05, - 9.790004696696997e-05, - 9.710004087537527e-05 - ], - "parse_median_s": 0.00036874995566904545, - "run_median_s": 0.00010065000969916582, - "executed_steps": 2, - "actual_shape": [ - 1, - 3, - 2, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 1, - 3, - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003497001016512513, - "parse_max_s": 0.00041259999852627516, - "run_min_s": 9.710004087537527e-05, - "run_max_s": 0.00010429997928440571 - }, - "fixture_files": { - "model": "transpose.onnx", - "inputs": "transpose.inputs.npz", - "expected": "transpose.expected.npy", - "initializers": "transpose.initializers.npz" - } - }, - { - "name": "concat", - "group": "operators", - "description": "FP32 left[2,1,4] and right[2,2,4], axis=-2 -> [2,3,4].", - "operators": [ - "Concat" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "c96a7b79f82cd23c6c943f61a9d675161ee47102c1251259b7d5d1bfbbc8c977", - "inputs_sha256": "09f2f52e4a0c7c9c0cbb4a3962eff5a53214d57e4445b1ce4fce07bc6acae6d9", - "expected_sha256": "c94ae1852d9ca136be5084962a6333495a5a4df0f7f7db13d5ea0d3d41ce80cf", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Concat", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0003693000180646777, - 0.00038620003033429384, - 0.0003693000180646777, - 0.0003645999822765589, - 0.00036700000055134296, - 0.00041009997949004173, - 0.0003698000218719244, - 0.00037619995418936014, - 0.00035749992821365595, - 0.00035550002939999104 - ], - "run_samples_s": [ - 9.770004544407129e-05, - 0.00010369997471570969, - 9.69000393524766e-05, - 9.790004696696997e-05, - 9.599991608411074e-05, - 0.00010299996938556433, - 9.889993816614151e-05, - 9.699992369860411e-05, - 9.910005610436201e-05, - 9.550002869218588e-05 - ], - "parse_median_s": 0.0003693000180646777, - "run_median_s": 9.780004620552063e-05, - "executed_steps": 2, - "actual_shape": [ - 2, - 3, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 3, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.00035550002939999104, - "parse_max_s": 0.00041009997949004173, - "run_min_s": 9.550002869218588e-05, - "run_max_s": 0.00010369997471570969 - }, - "fixture_files": { - "model": "concat.onnx", - "inputs": "concat.inputs.npz", - "expected": "concat.expected.npy", - "initializers": "concat.initializers.npz" - } - }, - { - "name": "slice", - "group": "operators", - "description": "FP32 x[2,8], INT64 vectors: start=-1,end=INT64_MIN,axis=-1,step=-2 -> [2,4].", - "operators": [ - "Slice" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "b9d3881f01090a4f8ec8ab828d2ef4d9f5460d37c9dce469e03577525ebb3b30", - "inputs_sha256": "04457bea47baae4c174cbb9964c15e2173ed21476eea164fdfa83f0c66b3f3aa", - "expected_sha256": "15916501385783e26c51e19e641bcd04e59f27d8f241438d4c392f02a2da72df", - "initializers_sha256": "6fb5ce1e94fa98118310be1dbbec731166552b05cf34b063477f2d54ea3bda7a", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Slice", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.00040770007763057947, - 0.0004149000160396099, - 0.00039679999463260174, - 0.000395399983972311, - 0.000394199974834919, - 0.0004005999071523547, - 0.0003872000379487872, - 0.0003947000950574875, - 0.00039820000529289246, - 0.0003963999915868044 - ], - "run_samples_s": [ - 0.00010409997776150703, - 0.00010469998233020306, - 0.00010289996862411499, - 0.00010169995948672295, - 0.00010259996633976698, - 0.00010469998233020306, - 0.00010290008503943682, - 0.0001039999770000577, - 0.00010349997319281101, - 0.00010409997776150703 - ], - "parse_median_s": 0.00039659999310970306, - "run_median_s": 0.00010374997509643435, - "executed_steps": 2, - "actual_shape": [ - 2, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003872000379487872, - "parse_max_s": 0.0004149000160396099, - "run_min_s": 0.00010169995948672295, - "run_max_s": 0.00010469998233020306 - }, - "fixture_files": { - "model": "slice.onnx", - "inputs": "slice.inputs.npz", - "expected": "slice.expected.npy", - "initializers": "slice.initializers.npz" - } - }, - { - "name": "unsqueeze", - "group": "operators", - "description": "INT64 x[2,3], constant INT64 axes[2]=[0,-1] -> [1,2,3,1].", - "operators": [ - "Unsqueeze" - ], - "atol": 0.0, - "rtol": 0.0, - "model_sha256": "3af8a32c4178606972d98c12b3e1bf28d1fd8301587a5efa3b1dd3e10d4545cd", - "inputs_sha256": "4a7719bc73234f93e26685e50c1a40a771fe9465af4fa82ee75df54ceba5ea50", - "expected_sha256": "d96e4f88f5e537f71a56023d5684c075b9d81e960a60fdb1ceef46d3434e7193", - "initializers_sha256": "a8ff6dd261ccbd0a49d97047d600420769bfd677d4891898103e1d823513dcdf", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Unsqueeze", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 1, - 2, - 3, - 1 - ], - "expected_dtype": "int64", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0004278999986127019, - 0.00037889997474849224, - 0.0003894000547006726, - 0.0004752000095322728, - 0.00046479993034154177, - 0.00037020002491772175, - 0.00035470002330839634, - 0.0003568999236449599, - 0.0003622000804170966, - 0.0003958999877795577 - ], - "run_samples_s": [ - 8.939998224377632e-05, - 8.649996016174555e-05, - 8.329993579536676e-05, - 8.98001017048955e-05, - 8.560006972402334e-05, - 8.510006591677666e-05, - 8.279993198812008e-05, - 8.359993807971478e-05, - 8.44000605866313e-05, - 8.329993579536676e-05 - ], - "parse_median_s": 0.00038415001472458243, - "run_median_s": 8.475006325170398e-05, - "executed_steps": 2, - "actual_shape": [ - 1, - 2, - 3, - 1 - ], - "actual_dtype": "int64", - "expected_shape": [ - 1, - 2, - 3, - 1 - ], - "expected_dtype": "int64", - "parse_min_s": 0.00035470002330839634, - "parse_max_s": 0.0004752000095322728, - "run_min_s": 8.279993198812008e-05, - "run_max_s": 8.98001017048955e-05 - }, - "fixture_files": { - "model": "unsqueeze.onnx", - "inputs": "unsqueeze.inputs.npz", - "expected": "unsqueeze.expected.npy", - "initializers": "unsqueeze.initializers.npz" - } - }, - { - "name": "expand", - "group": "operators", - "description": "FP32 x[2,1,4], constant INT64 shape[3]=[1,3,4], bidirectional broadcast -> [2,3,4].", - "operators": [ - "Expand" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "65a0c9a5fe1045ad4511d3099009989574b5b294a542a2716eb014aa0526a6ab", - "inputs_sha256": "4fdf47ab1aae83654c3b631bf596434cdad68ae0ea325af90a5feb832173d3d6", - "expected_sha256": "eec80e44c7acb11e603e87f01a7570d0e15a45d9b2db4c7d148a5c5e379ddfd6", - "initializers_sha256": "7eccb03cb24eb87dec4c8282d83c3bf88de5ed2ad14006e861cb239668ff0f3a", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Expand", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0004062999505549669, - 0.0003779999678954482, - 0.0004357000580057502, - 0.00038139999378472567, - 0.0004078999627381563, - 0.0003739000530913472, - 0.0003749999450519681, - 0.0003727000439539552, - 0.00036589999217540026, - 0.000359599944204092 - ], - "run_samples_s": [ - 0.00011890009045600891, - 0.00010760000441223383, - 0.00010830000974237919, - 0.00010639999527484179, - 0.0001023000804707408, - 0.00010429997928440571, - 0.00010469998233020306, - 0.00010419997852295637, - 0.00010289996862411499, - 0.00010410009417682886 - ], - "parse_median_s": 0.00037649995647370815, - "run_median_s": 0.00010449998080730438, - "executed_steps": 2, - "actual_shape": [ - 2, - 3, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 3, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.000359599944204092, - "parse_max_s": 0.0004357000580057502, - "run_min_s": 0.0001023000804707408, - "run_max_s": 0.00011890009045600891 - }, - "fixture_files": { - "model": "expand.onnx", - "inputs": "expand.inputs.npz", - "expected": "expand.expected.npy", - "initializers": "expand.initializers.npz" - } - }, - { - "name": "abs", - "group": "operators", - "description": "Signed INT64 x[2,3] -> Abs [2,3], preserving integer dtype.", - "operators": [ - "Abs" - ], - "atol": 0.0, - "rtol": 0.0, - "model_sha256": "2446ec6d9d36c8ab37f7a443113228b6773c8a6e05822c33e638e10338a58cdf", - "inputs_sha256": "898fdb420a5c2a481f896b1f932dbbd6a45ef50a482d7c2d3ef7263b830e043a", - "expected_sha256": "7443fff444e2f78e27d69236ee9c0023e49acfc2d0158234069c0ca7d02bb640", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Abs", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "int64", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.00036099995486438274, - 0.0003568999236449599, - 0.0003541000187397003, - 0.0003519000019878149, - 0.0003469999646767974, - 0.00033569999504834414, - 0.00035450002178549767, - 0.00034719996619969606, - 0.0003350999904796481, - 0.000327100045979023 - ], - "run_samples_s": [ - 8.889997843652964e-05, - 8.719996549189091e-05, - 8.620007429271936e-05, - 8.420005906373262e-05, - 8.320005144923925e-05, - 8.759996853768826e-05, - 8.549995254725218e-05, - 8.499994874000549e-05, - 8.68999632075429e-05, - 8.470006287097931e-05 - ], - "parse_median_s": 0.0003495499840937555, - "run_median_s": 8.585001341998577e-05, - "executed_steps": 2, - "actual_shape": [ - 2, - 3 - ], - "actual_dtype": "int64", - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "int64", - "parse_min_s": 0.000327100045979023, - "parse_max_s": 0.00036099995486438274, - "run_min_s": 8.320005144923925e-05, - "run_max_s": 8.889997843652964e-05 - }, - "fixture_files": { - "model": "abs.onnx", - "inputs": "abs.inputs.npz", - "expected": "abs.expected.npy", - "initializers": "abs.initializers.npz" - } - }, - { - "name": "cast", - "group": "operators", - "description": "INT64 position IDs[1,8] -> FP32 [1,8].", - "operators": [ - "Cast" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "e5bf2bf24fd5667082da808f92a835bf3b6412a8a494bee301ee042c31167dd5", - "inputs_sha256": "93f57a04397de81ea2aabce45687351a7238a5b72a8b4a625de522bfd69df92f", - "expected_sha256": "5296d1fd5eb039a4b0ccb36f0498e194eb76696167b82a590cb30defc016141f", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Cast", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 1, - 8 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0003632999723777175, - 0.00036179996095597744, - 0.0004943999228999019, - 0.0005131000652909279, - 0.00037509994581341743, - 0.00034440006129443645, - 0.00033980002626776695, - 0.00034899997990578413, - 0.00033760000951588154, - 0.00035889993887394667 - ], - "run_samples_s": [ - 7.98999099060893e-05, - 0.00017800007481127977, - 0.00011080002877861261, - 8.82000895217061e-05, - 8.230004459619522e-05, - 7.829989772289991e-05, - 7.860001642256975e-05, - 7.840001489967108e-05, - 8.060003165155649e-05, - 7.970002479851246e-05 - ], - "parse_median_s": 0.00036034994991496205, - "run_median_s": 8.02499707788229e-05, - "executed_steps": 2, - "actual_shape": [ - 1, - 8 - ], - "actual_dtype": "float32", - "expected_shape": [ - 1, - 8 - ], - "expected_dtype": "float32", - "parse_min_s": 0.00033760000951588154, - "parse_max_s": 0.0005131000652909279, - "run_min_s": 7.829989772289991e-05, - "run_max_s": 0.00017800007481127977 - }, - "fixture_files": { - "model": "cast.onnx", - "inputs": "cast.inputs.npz", - "expected": "cast.expected.npy", - "initializers": "cast.initializers.npz" - } - }, - { - "name": "constant", - "group": "operators", - "description": "Constant tensor attribute FP32 [2,3]; no runtime inputs.", - "operators": [ - "Constant" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "c9d931c03dd507060befe5387ac7a6e07c4de2f8970c6fe4009ad2799a7e7227", - "inputs_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "expected_sha256": "5a1a9e658f38c6a788419b7f7321353352ffeb929be456073dca9d36511f2a67", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Constant", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0003632999723777175, - 0.000381799996830523, - 0.00039549998473376036, - 0.000381799996830523, - 0.0003598000621423125, - 0.00036259996704757214, - 0.0003591999411582947, - 0.0003554000286385417, - 0.0003502999898046255, - 0.0003415999235585332 - ], - "run_samples_s": [ - 6.250001024454832e-05, - 6.170000415295362e-05, - 6.10999995842576e-05, - 6.200000643730164e-05, - 6.0399994254112244e-05, - 6.150000263005495e-05, - 6.219989154487848e-05, - 6.0399994254112244e-05, - 6.049999501556158e-05, - 5.909998435527086e-05 - ], - "parse_median_s": 0.00036120001459494233, - "run_median_s": 6.130000110715628e-05, - "executed_steps": 1, - "actual_shape": [ - 2, - 3 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003415999235585332, - "parse_max_s": 0.00039549998473376036, - "run_min_s": 5.909998435527086e-05, - "run_max_s": 6.250001024454832e-05 - }, - "fixture_files": { - "model": "constant.onnx", - "inputs": "constant.inputs.npz", - "expected": "constant.expected.npy", - "initializers": "constant.initializers.npz" - } - }, - { - "name": "cos", - "group": "operators", - "description": "FP32 angles[2,4] spanning positive/negative radians -> Cos [2,4].", - "operators": [ - "Cos" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "3af95b0a4f4cd19cd254fb139b753cfa99171c187c492d6a322a8e31b8866298", - "inputs_sha256": "8c196fdf42e2e25a11894ee88e8d73e31e59a061f7e76bead86793c428ddc998", - "expected_sha256": "7a6831ace7c14d1ee2ec5d6d7ad497adc95914e56eecf8f384c018012e36e0ed", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Cos", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 5.960464477539063e-08, - "max_rel_error": 8.023689783612319e-08, - "parse_samples_s": [ - 0.0003530000103637576, - 0.00034549995325505733, - 0.00033980002626776695, - 0.000340400030836463, - 0.0003451999509707093, - 0.00034739996772259474, - 0.000332699972204864, - 0.0003412999212741852, - 0.00033599999733269215, - 0.00033199996687471867 - ], - "run_samples_s": [ - 8.690007962286472e-05, - 8.670007809996605e-05, - 8.889997843652964e-05, - 8.739996701478958e-05, - 8.559995330870152e-05, - 8.60999571159482e-05, - 8.629995863884687e-05, - 8.690007962286472e-05, - 8.579995483160019e-05, - 8.400005754083395e-05 - ], - "parse_median_s": 0.0003408499760553241, - "run_median_s": 8.650001836940646e-05, - "executed_steps": 2, - "actual_shape": [ - 2, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.00033199996687471867, - "parse_max_s": 0.0003530000103637576, - "run_min_s": 8.400005754083395e-05, - "run_max_s": 8.889997843652964e-05 - }, - "fixture_files": { - "model": "cos.onnx", - "inputs": "cos.inputs.npz", - "expected": "cos.expected.npy", - "initializers": "cos.initializers.npz" - } - }, - { - "name": "identity", - "group": "operators", - "description": "INT64 indices[2,3] -> Identity [2,3]; frontend alias.", - "operators": [ - "Identity" - ], - "atol": 0.0, - "rtol": 0.0, - "model_sha256": "e16a4cbfc11442ded30de5b7503512fe1d179b3c64dfcc65140d17f81cab3a4b", - "inputs_sha256": "19f74d0f712a161d1f093b37f6d7d1464fb4a730dd83d1069f5cbf5e38e13413", - "expected_sha256": "a700997366bef11ca0353f21bc678822148eb06156a05ed32d1ce76b73ad1cc8", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Identity", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "int64", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.000341000035405159, - 0.00034380005672574043, - 0.0003355999942868948, - 0.0003361001145094633, - 0.0003378000110387802, - 0.00033820001408457756, - 0.0003301999531686306, - 0.00032760004978626966, - 0.0003744999412447214, - 0.0003630999708548188 - ], - "run_samples_s": [ - 5.170004442334175e-05, - 5.280005279928446e-05, - 5.219993181526661e-05, - 5.2200048230588436e-05, - 5.080003757029772e-05, - 5.179992876946926e-05, - 5.079992115497589e-05, - 5.139992572367191e-05, - 6.809993647038937e-05, - 5.4600066505372524e-05 - ], - "parse_median_s": 0.0003380000125616789, - "run_median_s": 5.1999930292367935e-05, - "executed_steps": 1, - "actual_shape": [ - 2, - 3 - ], - "actual_dtype": "int64", - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "int64", - "parse_min_s": 0.00032760004978626966, - "parse_max_s": 0.0003744999412447214, - "run_min_s": 5.079992115497589e-05, - "run_max_s": 6.809993647038937e-05 - }, - "fixture_files": { - "model": "identity.onnx", - "inputs": "identity.inputs.npz", - "expected": "identity.expected.npy", - "initializers": "identity.initializers.npz" - } - }, - { - "name": "pow", - "group": "operators", - "description": "Signed FP32 x[2,3] ** INT64 scalar exponent 2 -> FP32 [2,3].", - "operators": [ - "Pow" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "9184fbaccd896e171a8ca025b6745d0674bf1493913f34b77f8bc0f218751771", - "inputs_sha256": "b33fcf5b3bd13b2fa3bacb0388119b95ed138163db9b168dd3993654533b4cd1", - "expected_sha256": "dcada1e53c419cefb8b09810f4494beb138bcecdce47c5c0f48466ee49d5d51e", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Pow", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0004491000436246395, - 0.00043999997433274984, - 0.00040019990410655737, - 0.00037569995038211346, - 0.00038789992686361074, - 0.0003453000681474805, - 0.00048369995784014463, - 0.00044920004438608885, - 0.00047619990073144436, - 0.0004214000655338168 - ], - "run_samples_s": [ - 0.00014999997802078724, - 0.00014180003199726343, - 0.00011959997937083244, - 0.00011729996185749769, - 0.00011809996794909239, - 0.00011589995119720697, - 0.00015820004045963287, - 0.00015029998030513525, - 0.00015840004198253155, - 0.0001241000136360526 - ], - "parse_median_s": 0.00043070001993328333, - "run_median_s": 0.00013295002281665802, - "executed_steps": 2, - "actual_shape": [ - 2, - 3 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0003453000681474805, - "parse_max_s": 0.00048369995784014463, - "run_min_s": 0.00011589995119720697, - "run_max_s": 0.00015840004198253155 - }, - "fixture_files": { - "model": "pow.onnx", - "inputs": "pow.inputs.npz", - "expected": "pow.expected.npy", - "initializers": "pow.initializers.npz" - } - }, - { - "name": "reciprocal", - "group": "operators", - "description": "Nonzero signed FP32 x[2,4] -> Reciprocal [2,4].", - "operators": [ - "Reciprocal" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "3d0e2326e2db7ff3ad97b6a1e7f2e59dc713fd2e70ca1a4d067c04ed699a04e3", - "inputs_sha256": "fb2145f899f1258c7169e1f7a540b9b94625c689d770793b142cda8ae4bf722f", - "expected_sha256": "c31992656b1ac1d73d6fc90936ecaf8d816946286b2c6170097a6cf9823d818d", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Reciprocal", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.00035170000046491623, - 0.0003496999852359295, - 0.00034989998675882816, - 0.00034769997000694275, - 0.00033870001789182425, - 0.00037759996484965086, - 0.00035119999665766954, - 0.0003469000803306699, - 0.00033660000190138817, - 0.00033329997677356005 - ], - "run_samples_s": [ - 9.190000128000975e-05, - 9.019998833537102e-05, - 9.029998909682035e-05, - 9.14999982342124e-05, - 8.899997919797897e-05, - 9.609991684556007e-05, - 9.510002564638853e-05, - 9.06999921426177e-05, - 9.109999518841505e-05, - 9.059999138116837e-05 - ], - "parse_median_s": 0.0003486999776214361, - "run_median_s": 9.089999366551638e-05, - "executed_steps": 2, - "actual_shape": [ - 2, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.00033329997677356005, - "parse_max_s": 0.00037759996484965086, - "run_min_s": 8.899997919797897e-05, - "run_max_s": 9.609991684556007e-05 - }, - "fixture_files": { - "model": "reciprocal.onnx", - "inputs": "reciprocal.inputs.npz", - "expected": "reciprocal.expected.npy", - "initializers": "reciprocal.initializers.npz" - } - }, - { - "name": "sin", - "group": "operators", - "description": "FP32 angles[2,4] spanning positive/negative radians -> Sin [2,4].", - "operators": [ - "Sin" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "cd93a789d0f603c48706203da065f2451a65cf345581f9772d34fff8bca88a2a", - "inputs_sha256": "efb400ff25087f013055f64abc4fcf498072fb9d417b825f6e9e292742b3ef9e", - "expected_sha256": "e2a29d3f9d4bc5d9003ed673b7e4cb70997b5048a77aff3b181721c9461d13ee", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Sin", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.00034879997838288546, - 0.0003532000118866563, - 0.00035470002330839634, - 0.0003448999486863613, - 0.00033730000723153353, - 0.00033710000570863485, - 0.0003510999958962202, - 0.0003322999691590667, - 0.00032819993793964386, - 0.000327700050547719 - ], - "run_samples_s": [ - 8.570007048547268e-05, - 8.719996549189091e-05, - 8.620007429271936e-05, - 8.480006363242865e-05, - 8.730008266866207e-05, - 8.71000811457634e-05, - 8.659996092319489e-05, - 8.569995407015085e-05, - 8.390005677938461e-05, - 8.750008419156075e-05 - ], - "parse_median_s": 0.0003410999779589474, - "run_median_s": 8.640001760795712e-05, - "executed_steps": 2, - "actual_shape": [ - 2, - 4 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 4 - ], - "expected_dtype": "float32", - "parse_min_s": 0.000327700050547719, - "parse_max_s": 0.00035470002330839634, - "run_min_s": 8.390005677938461e-05, - "run_max_s": 8.750008419156075e-05 - }, - "fixture_files": { - "model": "sin.onnx", - "inputs": "sin.inputs.npz", - "expected": "sin.expected.npy", - "initializers": "sin.initializers.npz" - } - }, - { - "name": "scalar_fp32_add_chain", - "group": "regressions", - "description": "FP32 scalar initializers: (16777216 + 1) + 1 must round after each Add; exact scalar output.", - "operators": [ - "Add" - ], - "atol": 0.0, - "rtol": 0.0, - "model_sha256": "4ff5c4c55a16938c3a8c30f16a2b8e6662e6f7cc38413473bc00bd50a36abce7", - "inputs_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "expected_sha256": "87da7d0335886bc568cddf1844505676699d5476a6bd72d0d6292c3ee42c253a", - "initializers_sha256": "32823c7b66e5511946296130ba5b03f3e897d3939bd738d14f84d41f7e80923a", - "before": { - "status": "NUMERIC_ERROR", - "phase": "compare", - "error": "NumericMismatch: output differs from ORT: max_abs_error=2", - "max_abs_error": 2.0, - "max_rel_error": 1.1920928955078125e-07, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": [], - "actual_dtype": "float32", - "expected_shape": [], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0005631999811157584, - 0.0004637000383809209, - 0.0004089000867679715, - 0.00039579998701810837, - 0.0003945999778807163, - 0.000381799996830523, - 0.00041089998558163643, - 0.00040959997568279505, - 0.0003945999778807163, - 0.0003902000607922673 - ], - "run_samples_s": [ - 0.00019599997904151678, - 0.00015169999096542597, - 0.00014780007768422365, - 0.0001476999605074525, - 0.0001452999422326684, - 0.00015129998791962862, - 0.00014889996964484453, - 0.0001441999338567257, - 0.00014589994680136442, - 0.0001444999361410737 - ], - "parse_median_s": 0.00040235003689303994, - "run_median_s": 0.00014775001909583807, - "executed_steps": 5, - "actual_shape": [], - "actual_dtype": "float32", - "expected_shape": [], - "expected_dtype": "float32", - "parse_min_s": 0.000381799996830523, - "parse_max_s": 0.0005631999811157584, - "run_min_s": 0.0001441999338567257, - "run_max_s": 0.00019599997904151678 - }, - "fixture_files": { - "model": "scalar_fp32_add_chain.onnx", - "inputs": "scalar_fp32_add_chain.inputs.npz", - "expected": "scalar_fp32_add_chain.expected.npy", - "initializers": "scalar_fp32_add_chain.initializers.npz" - } - }, - { - "name": "singleton_initializer", - "group": "regressions", - "description": "FP32 initializer weight[1]=[7] returned directly: retain tensor data and shape [1], not scalar.", - "operators": [], - "atol": 0.0, - "rtol": 0.0, - "model_sha256": "52473f6b8b73062f1bc8eb98f6a61a2dffb467a6c9966cb985c8b53037c2f722", - "inputs_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "expected_sha256": "3948d58d392cf3b813d8cc0773e2ed0af42344e5e96ff9eff386c223ce2ac1f8", - "initializers_sha256": "7da88694441abcf15dddeff15e70aaa41eb8e0ee336a5b08e02b61215b3bb6df", - "before": { - "status": "EXECUTION_ERROR", - "phase": "execute", - "error": "IRExecutionError: BindingError function=singleton_initializer value=weight: initializer disagrees with scalar constant", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 1 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.0003521000035107136, - 0.00034039991442114115, - 0.00033650000113993883, - 0.0003445999464020133, - 0.0003336999798193574, - 0.0003494999837130308, - 0.0003339999821037054, - 0.00035889993887394667, - 0.00032849994022399187, - 0.00033849989995360374 - ], - "run_samples_s": [ - 6.290001329034567e-05, - 6.280001252889633e-05, - 6.140000186860561e-05, - 6.009999196976423e-05, - 6.079999729990959e-05, - 6.370001938194036e-05, - 6.200000643730164e-05, - 6.330001633614302e-05, - 6.260001100599766e-05, - 6.0399994254112244e-05 - ], - "parse_median_s": 0.00033944990718737245, - "run_median_s": 6.230000872164965e-05, - "executed_steps": 1, - "actual_shape": [ - 1 - ], - "actual_dtype": "float32", - "expected_shape": [ - 1 - ], - "expected_dtype": "float32", - "parse_min_s": 0.00032849994022399187, - "parse_max_s": 0.00035889993887394667, - "run_min_s": 6.009999196976423e-05, - "run_max_s": 6.370001938194036e-05 - }, - "fixture_files": { - "model": "singleton_initializer.onnx", - "inputs": "singleton_initializer.inputs.npz", - "expected": "singleton_initializer.expected.npy", - "initializers": "singleton_initializer.initializers.npz" - } - }, - { - "name": "constant_cast_abs_expand", - "group": "composite", - "description": "Constant INT32 [-2,3] -> Cast INT64 -> Abs target[2]=[2,3]; Expand FP32 x[1,3] -> [2,3].", - "operators": [ - "Constant", - "Cast", - "Abs", - "Expand" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "bd8568ed91a153a8c061bf51d0ff3512314db206860531456cb11aa0735938b3", - "inputs_sha256": "9b2c7512831a4cace198062de741146fbeedb181b006cf32431c4fce833a9822", - "expected_sha256": "eed5f694fcfe47f46f2a01790cff931b624565234b061b5c64a278c2ed1eac53", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Constant", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.00043970008846372366, - 0.00044329999946057796, - 0.0004450000124052167, - 0.0004384000785648823, - 0.0006199999479576945, - 0.00047199998516589403, - 0.00045169994700700045, - 0.00044700002763420343, - 0.00044139998499304056, - 0.00043539993930608034 - ], - "run_samples_s": [ - 0.00013560010120272636, - 0.0001338999718427658, - 0.00013189995661377907, - 0.0002367999404668808, - 0.00017560005653649569, - 0.00013229995965957642, - 0.0001343999756500125, - 0.000130399945192039, - 0.00013109995052218437, - 0.00013479997869580984 - ], - "parse_median_s": 0.00044415000593289733, - "run_median_s": 0.00013414997374638915, - "executed_steps": 4, - "actual_shape": [ - 2, - 3 - ], - "actual_dtype": "float32", - "expected_shape": [ - 2, - 3 - ], - "expected_dtype": "float32", - "parse_min_s": 0.00043539993930608034, - "parse_max_s": 0.0006199999479576945, - "run_min_s": 0.000130399945192039, - "run_max_s": 0.0002367999404668808 - }, - "fixture_files": { - "model": "constant_cast_abs_expand.onnx", - "inputs": "constant_cast_abs_expand.inputs.npz", - "expected": "constant_cast_abs_expand.expected.npy", - "initializers": "constant_cast_abs_expand.initializers.npz" - } - }, - { - "name": "qwen_rmsnorm", - "group": "composite", - "description": "FP32 x[1,4,8], gamma[8]; Pow(INT64 scalar 2)/ReduceMean(-1)/Add(eps=1e-6)/Sqrt/Reciprocal/Mul -> [1,4,8].", - "operators": [ - "Pow", - "ReduceMean", - "Add", - "Sqrt", - "Reciprocal", - "Mul" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "cf11b7c966672ab3f03eb3d92a8e45fc87968d70e33a4a500964ce52d02a7237", - "inputs_sha256": "f9c2bb681cd4b72783d29572f427b6e237b6c7bf40252ab5b88a2c6174751a48", - "expected_sha256": "5fe5157ae0bc87a41a4a9db76a037f52f8b7e5aa4f2c64e8674e0bdbcb8dfaec", - "initializers_sha256": "6bf5dd34dd2bb7907f3d53ccd995420cfd6a610b463eb41cf53d0698308ab92c", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Pow", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 1, - 4, - 8 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 0.0, - "max_rel_error": 0.0, - "parse_samples_s": [ - 0.000506900018081069, - 0.0005015999777242541, - 0.0005096999229863286, - 0.0005014999769628048, - 0.0004960000514984131, - 0.0005388999124988914, - 0.0005028999876230955, - 0.000512199942022562, - 0.000493900035507977, - 0.0004851999692618847 - ], - "run_samples_s": [ - 0.0002813000464811921, - 0.0002788000274449587, - 0.0002808000426739454, - 0.00028170004952698946, - 0.00027820002287626266, - 0.0002930000191554427, - 0.00028490007389336824, - 0.00028249993920326233, - 0.0002837000647559762, - 0.0002848999574780464 - ], - "parse_median_s": 0.0005022499826736748, - "run_median_s": 0.0002820999943651259, - "executed_steps": 10, - "actual_shape": [ - 1, - 4, - 8 - ], - "actual_dtype": "float32", - "expected_shape": [ - 1, - 4, - 8 - ], - "expected_dtype": "float32", - "parse_min_s": 0.0004851999692618847, - "parse_max_s": 0.0005388999124988914, - "run_min_s": 0.00027820002287626266, - "run_max_s": 0.0002930000191554427 - }, - "fixture_files": { - "model": "qwen_rmsnorm.onnx", - "inputs": "qwen_rmsnorm.inputs.npz", - "expected": "qwen_rmsnorm.expected.npy", - "initializers": "qwen_rmsnorm.initializers.npz" - } - }, - { - "name": "rope_numeric", - "group": "composite", - "description": "INT64 positions[1,4,1] -> Cast FP32; frequencies[1,1,4], Sin/Cos rotate FP32 left/right[1,4,4] -> [1,4,8].", - "operators": [ - "Cast", - "Mul", - "Cos", - "Sin", - "Sub", - "Add", - "Concat" - ], - "atol": 1e-06, - "rtol": 1e-06, - "model_sha256": "7aed8255b36ac1866b0f27cd399e045010f0b82b1308036bbdbb849e02476cfa", - "inputs_sha256": "823f1be624262313583ea77e2dc14efcb3ea0696d059c2cc89643266d78f944a", - "expected_sha256": "eb0681ce4f70f4032e2806727ea5eb56e3d9f4036729cba48950643b393a39cd", - "initializers_sha256": "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Cast", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 1, - 4, - 8 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 2.9802322387695312e-08, - "max_rel_error": 1.698927025647682e-07, - "parse_samples_s": [ - 0.0005266999360173941, - 0.000520200002938509, - 0.0005088000325486064, - 0.0006041000597178936, - 0.0006553001003339887, - 0.0005080000264570117, - 0.0005031999899074435, - 0.0005010999739170074, - 0.0005029999883845448, - 0.00049919995944947 - ], - "run_samples_s": [ - 0.0002498000394552946, - 0.00024560000747442245, - 0.0002465000143274665, - 0.0003208999987691641, - 0.00031370006036013365, - 0.00025370006915181875, - 0.0002449000021442771, - 0.00024610001128166914, - 0.0002482000272721052, - 0.0002460000105202198 - ], - "parse_median_s": 0.000508400029502809, - "run_median_s": 0.00024735002079978585, - "executed_steps": 12, - "actual_shape": [ - 1, - 4, - 8 - ], - "actual_dtype": "float32", - "expected_shape": [ - 1, - 4, - 8 - ], - "expected_dtype": "float32", - "parse_min_s": 0.00049919995944947, - "parse_max_s": 0.0006553001003339887, - "run_min_s": 0.0002449000021442771, - "run_max_s": 0.0003208999987691641 - }, - "fixture_files": { - "model": "rope_numeric.onnx", - "inputs": "rope_numeric.inputs.npz", - "expected": "rope_numeric.expected.npy", - "initializers": "rope_numeric.initializers.npz" - } - }, - { - "name": "pr89_transformer_2l", - "group": "composite", - "operators": [ - "Add", - "Concat", - "Div", - "Expand", - "Gather", - "MatMul", - "Mul", - "Neg", - "ReduceMean", - "Reshape", - "Sigmoid", - "Slice", - "Softmax", - "Sqrt", - "Transpose", - "Unsqueeze" - ], - "description": "PR #89 原始两层合成 Transformer,FP32,输出 [1,256,128];非完整 Qwen3", - "atol": 1e-05, - "rtol": 0, - "model_sha256": "17a8f191ee65a9d97143131016058e8a3c10a7d5c06e8152bf16dd8941cd19e1", - "inputs_sha256": "bd3f848d2679e66691a7701a84e39f35a02db58c0874e65b023b561f4f507457", - "expected_sha256": "413dd44ea90d93a25cbe0f89bc3703ffeb8ee7b0774c8fa955bcd7521b24ef5c", - "initializers_sha256": "955cb24a985e3aa13cb93e62af4e51e92ff27d14bdfdeac1d5a6107f65a8e3de", - "before": { - "status": "UNSUPPORTED", - "phase": "parse", - "error": "ONNXParseError: Unsupported ONNX op type: Gather", - "max_abs_error": null, - "max_rel_error": null, - "parse_samples_s": [], - "run_samples_s": [], - "parse_median_s": null, - "run_median_s": null, - "executed_steps": null, - "actual_shape": null, - "actual_dtype": null, - "expected_shape": [ - 1, - 256, - 128 - ], - "expected_dtype": "float32", - "parse_min_s": null, - "parse_max_s": null, - "run_min_s": null, - "run_max_s": null - }, - "after": { - "status": "PASS", - "phase": "complete", - "error": null, - "max_abs_error": 7.152557373046875e-07, - "max_rel_error": 0.0005722416551695097, - "parse_samples_s": [ - 0.004760700045153499, - 0.004594899946823716, - 0.004717700066976249, - 0.004990800051018596, - 0.004586199996992946, - 0.004221900017000735, - 0.004203699994832277, - 0.004572000005282462, - 0.004674999974668026, - 0.004224899923428893 - ], - "run_samples_s": [ - 0.010629900032654405, - 0.011460799956694245, - 0.009812200092710555, - 0.009985200013034046, - 0.010765800019726157, - 0.009604100021533668, - 0.010238799965009093, - 0.009660700103268027, - 0.010715999989770353, - 0.009805700043216348 - ], - "parse_median_s": 0.004590549971908331, - "run_median_s": 0.01011199998902157, - "executed_steps": 159, - "actual_shape": [ - 1, - 256, - 128 - ], - "actual_dtype": "float32", - "expected_shape": [ - 1, - 256, - 128 - ], - "expected_dtype": "float32", - "parse_min_s": 0.004203699994832277, - "parse_max_s": 0.004990800051018596, - "run_min_s": 0.009604100021533668, - "run_max_s": 0.011460799956694245 - }, - "fixture_files": { - "model": "pr89_transformer_2l.onnx", - "inputs": "pr89_transformer_2l.inputs.npz", - "expected": "pr89_transformer_2l.expected.npy", - "initializers": "pr89_transformer_2l.initializers.npz" - } - } - ], - "passed": true, - "archive_note": "归档说明(2026-10-01):本文件保留原始测量的全部结果、计时样本、误差、源码及数据指纹。current.head 与 current.dirty 记录测量时的工作区状态;dirty=true 不描述归档或 PR 提交后的状态。已移除本机源码快照、manifest、fixture、解释器及依赖构建目录的绝对路径;fixture_files 仅记录文件名,相关临时文件未随报告提交。复跑请在仓库根目录执行 python -X utf8 -B -m benchmarks.bench_onnx_operators --baseline-ref 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 --output-dir benchmark_reports/onnx_operators --warmup 1 --repeats 5,由 CLI 重新生成 fixtures;依赖、历史基线和 LFS 准备见同目录《算子补齐与单测报告.txt》。源码 SHA-256 是测量快照的原始字节指纹,跨平台换行符可能影响该值。" + "note": "Index only; not a benchmark measurement or a Linux execution result. Generate a new report with benchmarks.bench_onnx_operators." } diff --git "a/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.md" "b/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.md" index 87d5a068..2428b17b 100644 --- "a/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.md" +++ "b/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213-benchmark.md" @@ -1,1228 +1,25 @@ -# ONNX 算子支持与正确性:修改前后对照 +# ONNX 算子补齐 benchmark:Linux 复跑入口 -> 归档说明(2026-10-01):本文件保留原始测量的全部结果、计时样本、误差、源码及数据指纹。current.head 与 current.dirty 记录测量时的工作区状态;dirty=true 不描述归档或 PR 提交后的状态。已移除本机源码快照、manifest、fixture、解释器及依赖构建目录的绝对路径;fixture_files 仅记录文件名,相关临时文件未随报告提交。复跑请在仓库根目录执行 python -X utf8 -B -m benchmarks.bench_onnx_operators --baseline-ref 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 --output-dir benchmark_reports/onnx_operators --warmup 1 --repeats 5,由 CLI 重新生成 fixtures;依赖、历史基线和 LFS 准备见同目录《算子补齐与单测报告.txt》。源码 SHA-256 是测量快照的原始字节指纹,跨平台换行符可能影响该值。 +正式交付与复现使用 **Ubuntu 24.04 x86_64 / Bash / Python 3.12**。本页是入口,**不是新测量报告**。环境、Git 历史和 LFS 准备见 [算子补齐与单测报告](算子补齐与单测报告.txt),通用交付约定见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。 -总验收:通过。修改后 24/24 个用例通过;原先不支持、现通过的用例 20 个,原先错误、现通过的用例 2 个,出现回归的用例 0 个。用例数不等于新增算子种类数。 +在仓库根目录激活 `.venv-operators` 后执行: -本次改动扩展算子支持并修正计算语义;计时用于本地观测,不代表性能优化结论。只有修改前后均 PASS 的用例才计算执行耗时比。不支持或缺少有效测量时显示“—”,不能解释为零耗时或无限加速。 - -## 基线兼容适配与输入绑定 - -输入绑定兼容适配不属于基线编译器实现;以下为 runner 的实际策略,需与数值结果一起审阅。 - -> Both versions receive original ONNX initializer arrays without reshaping. The current parser's arrays must match dtype/shape/bytes, and its extra Constant globals are added. The baseline parser has no initializers property; supplying this existing interpreter argument is a harness adapter, not a baseline implementation change. - -## 按组通过情况 - -| 分组 | 用例总数 | 修改前 PASS | 修改后 PASS | -| --- | --- | --- | --- | -| 独立算子 | 16 | 0/16 | 16/16 | -| 已有支持对照 | 2 | 2/2 | 2/2 | -| 错误回归 | 2 | 0/2 | 2/2 | -| 组合链路 | 4 | 0/4 | 4/4 | - -## 版本与测量条件 - -| 项目 | 值 | -| --- | --- | -| 报告 schema | 1 | -| 基线提交 | 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 | -| 基线源码 SHA-256 | a189ff5bd14d9f0c534009fbfa90416a554facb4ee047fe6288c2567723a067c | -| 当前 HEAD | 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 | -| 当前包含未提交改动 | 是 | -| 当前源码 SHA-256 | 57184d30623c30ddcc0c59db74e0a37a74171ca4a85e45adf1dae1dd578ffeaf | -| 执行顺序 | ABBA:修改前 → 修改后 → 修改后 → 修改前 | -| 每版本轮数 | 2 | -| 每轮预热次数 | 1 | -| 每轮正式计时次数 | 5 | -| 每版本正式样本总数 | 10 | -| 计时统计 | 合并各轮正式样本后计算中位数(ms),预热不计入;原始秒数见用例详情 | -| runner 计时说明 | parse: fresh ONNXParser.parse per sample; run: IRInterpreter.run including verification, planning, binding copies, NumPy computation and return copy. ORT reference generation, common initializer loading, external verification and comparisons are excluded. Each round starts with one untimed correctness qualification, then the configured warmups and timed repetitions. Sequential ABBA order, two rounds per version; warmup is per round. Host CPU, not QEMU. | - -执行计时覆盖完整 IRInterpreter.run,包括 IR 校验与输入/权重复制;不含 ONNX 解析和 ONNX Runtime 参考计算。ONNX → IR 解析单独计时。 - -环境: - -| 项目 | 值 | -| --- | --- | -| blas | {"Build Dependencies": {"blas": {"detection method": "pkgconfig", "found": true, "name": "scipy-openblas", "openblas configuration": "OpenBLAS 0.3.34.106.0 USE64BITINT DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=24", "version": "0.3.34.106.0"}, "lapack": {"detection method": "pkgconfig", "found": true, "name": "scipy-openblas", "openblas configuration": "OpenBLAS 0.3.34.106.0 USE64BITINT DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=24", "version": "0.3.34.106.0"}}, "Compilers": {"c": {"commands": "cl", "linker": "link", "name": "msvc", "version": "19.44.35228"}, "c++": {"commands": "cl", "linker": "link", "name": "msvc", "version": "19.44.35228"}, "cython": {"commands": "cython", "linker": "cython", "name": "cython", "version": "3.3.0"}}, "Machine Information": {"build": {"cpu": "x86_64", "endian": "little", "family": "x86_64", "system": "windows"}, "host": {"cpu": "x86_64", "endian": "little", "family": "x86_64", "system": "windows"}}, "Python Information": {"version": "3.12"}, "SIMD Extensions": {"baseline": ["X86_V2"], "found": ["X86_V3"]}} | -| numpy | 2.5.3 | -| onnx | 1.23.0 | -| onnxruntime | 1.29.0 | -| ort | CPUExecutionProvider, sequential, one thread, optimizations disabled | -| platform | Windows-11-10.0.26200-SP0 | -| python | 3.12.13 | -| threads | {"MKL_NUM_THREADS": "1", "NUMEXPR_NUM_THREADS": "1", "OMP_NUM_THREADS": "1", "OPENBLAS_NUM_THREADS": "1", "VECLIB_MAXIMUM_THREADS": "1"} | - -## 全部用例 - -耗时比 = 修改前 / 修改后;大于 1 表示该次观测中修改后较快,小于 1 表示较慢。 - -| 用例 | 分组 | 修改前 | 修改后 | 前最大绝对误差 | 后最大绝对误差 | 前解析 ms | 后解析 ms | 前执行 ms | 后执行 ms | 执行耗时比 前/后 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| add_float32 | 已有支持对照 | PASS | PASS | 0.000e+00 | 0.000e+00 | 0.1271 | 0.3771 | 0.0970 | 0.1025 | 0.946× | -| matmul_32 | 已有支持对照 | PASS | PASS | 0.000e+00 | 0.000e+00 | 0.1269 | 0.3694 | 0.0996 | 0.1074 | 0.927× | -| gather | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3609 | — | 0.1014 | — | -| sqrt | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3480 | — | 0.0959 | — | -| reduce_mean | 独立算子 | UNSUPPORTED | PASS | — | 2.980e-08 | — | 0.3785 | — | 0.1077 | — | -| transpose | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3687 | — | 0.1007 | — | -| concat | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3693 | — | 0.0978 | — | -| slice | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3966 | — | 0.1037 | — | -| unsqueeze | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3842 | — | 0.0848 | — | -| expand | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3765 | — | 0.1045 | — | -| abs | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3495 | — | 0.0859 | — | -| cast | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3603 | — | 0.0802 | — | -| constant | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3612 | — | 0.0613 | — | -| cos | 独立算子 | UNSUPPORTED | PASS | — | 5.960e-08 | — | 0.3408 | — | 0.0865 | — | -| identity | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3380 | — | 0.0520 | — | -| pow | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.4307 | — | 0.1330 | — | -| reciprocal | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3487 | — | 0.0909 | — | -| sin | 独立算子 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.3411 | — | 0.0864 | — | -| scalar_fp32_add_chain | 错误回归 | NUMERIC_ERROR | PASS | 2.000e+00 | 0.000e+00 | — | 0.4024 | — | 0.1478 | — | -| singleton_initializer | 错误回归 | EXECUTION_ERROR | PASS | — | 0.000e+00 | — | 0.3394 | — | 0.0623 | — | -| constant_cast_abs_expand | 组合链路 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.4442 | — | 0.1341 | — | -| qwen_rmsnorm | 组合链路 | UNSUPPORTED | PASS | — | 0.000e+00 | — | 0.5022 | — | 0.2821 | — | -| rope_numeric | 组合链路 | UNSUPPORTED | PASS | — | 2.980e-08 | — | 0.5084 | — | 0.2474 | — | -| pr89_transformer_2l | 组合链路 | UNSUPPORTED | PASS | — | 7.153e-07 | — | 4.5905 | — | 10.1120 | — | - -## 用例与错误详情 - -
add_float32 · 前后均通过 - -FP32 x[2,4] + bias[4] -> [2,4]; both tensors are runtime inputs. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Add"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | 1a1905eb33b4789ee579dbd7d5115bb6b0a8a873debffe328373747fdabf31ba | -| 输入 SHA-256 | 8d89e6f89fd7071d90cac47c89d8ded538f4b4aae762624573a6a39e6955a036 | -| 参考输出 SHA-256 (expected_sha256) | 9e9c9737447763b3066c69b737e0d08b98cfb8790a5b17b01d46887bc9dc84da | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 4] / float32 | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.00012459990102797747, 0.00012260000221431255, 0.00012610002886503935, 0.00012240000069141388, 0.00011799996718764305, 0.00017060001846402884, 0.00017589994240552187, 0.0001556000206619501, 0.00013669999316334724, 0.0001281999284401536] | -| 执行原始样本 s | [9.820004925131798e-05, 9.109999518841505e-05, 9.620003402233124e-05, 9.129999671131372e-05, 8.999998681247234e-05, 0.0001316999550908804, 0.0001227000029757619, 0.00012240000069141388, 9.77999297901988e-05, 9.400001727044582e-05] | - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 4] / float32 | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0004192000487819314, 0.00039460009429603815, 0.00038620003033429384, 0.0003771999618038535, 0.00038330000825226307, 0.00037699996028095484, 0.00035340001340955496, 0.00035879993811249733, 0.00034790008794516325, 0.0003451999509707093] | -| 执行原始样本 s | [0.00011930009350180626, 0.00010579999070614576, 0.00010310008656233549, 0.00010159995872527361, 0.0001004999503493309, 0.00010600010864436626, 0.00010289996862411499, 0.0001020999625325203, 0.00010160007514059544, 0.00010059995111078024] | - -
- -
matmul_32 · 前后均通过 - -FP32 x[32,32] @ w[32,32] -> [32,32]; both matrices are runtime inputs. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["MatMul"] | -| 绝对容差 atol | 1e-05 | -| 相对容差 rtol | 1e-05 | -| 模型 SHA-256 | c973f94731864d1635c9bddb470d5c4cd94f0419f1f94128cb4898969e28b203 | -| 输入 SHA-256 | 3c92a5b12a96c82867e7404232374651a9dd92c1d9d7797c1139f311f082f0a0 | -| 参考输出 SHA-256 (expected_sha256) | d5c0096f9040d6b2358be41e18aff6195c7fda4791f54e38a0f7267c4461285a | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [32, 32] / float32 | -| 参考 shape / dtype | [32, 32] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.00012470001820474863, 0.00012370001059025526, 0.0001233000075444579, 0.0001382000045850873, 0.00012390001211315393, 0.00015219999477267265, 0.00013090006541460752, 0.0001327999634668231, 0.00012919993605464697, 0.00012370001059025526] | -| 执行原始样本 s | [0.00010169995948672295, 9.799993131309748e-05, 9.8800053820014e-05, 9.50000248849392e-05, 9.69000393524766e-05, 0.00011190003715455532, 0.00010029994882643223, 0.00010699999984353781, 0.00010060006752610207, 9.679992217570543e-05] | - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [32, 32] / float32 | -| 参考 shape / dtype | [32, 32] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.000394199974834919, 0.00036610011011362076, 0.00036529998760670424, 0.0003619999624788761, 0.00037210003938525915, 0.00037340004928410053, 0.00036669999826699495, 0.0003709999145939946, 0.00040339992847293615, 0.000367900007404387] | -| 执行原始样本 s | [0.00011040002573281527, 0.00010629999451339245, 0.00010939990170300007, 0.00010389997623860836, 0.00011880008969455957, 0.00010979990474879742, 0.00010659999679774046, 0.00010810000821948051, 0.00010469998233020306, 0.0001050999853760004] | - -
- -
gather · 原先不支持 → 现通过 - -FP32 table[8,4], INT64 indices[2,3] including -1; axis=0 -> [2,3,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Gather"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | 698871efdf7efb0830f4743bcad6bdf661ea15cb920effbb6625d9b314442f6e | -| 输入 SHA-256 | dc4e4b0e613fdd64091fc2d9960083e55125cd961bab6c40151bca09801a71cf | -| 参考输出 SHA-256 (expected_sha256) | 4bd588e72cbd3de896e1fe60ba48ef5969f272be01c201c4ecfe0e8c7306c974 | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Gather -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3, 4] / float32 | -| 参考 shape / dtype | [2, 3, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.000382099999114871, 0.0003637999761849642, 0.00036070006899535656, 0.00037329993210732937, 0.0003640999784693122, 0.0003535000141710043, 0.0003445000620558858, 0.00034570007119327784, 0.00036100007127970457, 0.000354600022546947] | -| 执行原始样本 s | [0.00010419997852295637, 0.00010269996710121632, 0.00010080006904900074, 0.0001020999625325203, 0.00010219996329396963, 0.00010059995111078024, 0.00010079995263367891, 0.00010109995491802692, 0.00010179996024817228, 0.00010019994806498289] | - -
- -
sqrt · 原先不支持 → 现通过 - -Positive FP32 x[2,4] -> Sqrt [2,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Sqrt"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | cb10a2140fd47284e844b3d6a335b751c80b4647c12543a7e84133363855ce97 | -| 输入 SHA-256 | dd7b2461264e89c3ed6592836c2c66c4131208d2ad1f2af1a1d04fc368d2e8a6 | -| 参考输出 SHA-256 (expected_sha256) | 2940435af5f548faedd548a4c8752a60c9dcb25a05f00abbb1387ab26024f0da | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Sqrt -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 4] / float32 | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0003466999623924494, 0.0003605000674724579, 0.0003635999746620655, 0.00034799997229129076, 0.00034380005672574043, 0.00040040002204477787, 0.0003725000424310565, 0.0003456000704318285, 0.0003480000887066126, 0.0003408000338822603] | -| 执行原始样本 s | [0.00010339997243136168, 9.490002412348986e-05, 9.290000889450312e-05, 9.370001498609781e-05, 9.099999442696571e-05, 9.990006219595671e-05, 9.940005838871002e-05, 9.69000393524766e-05, 9.69000393524766e-05, 9.26000066101551e-05] | - -
- -
reduce_mean · 原先不支持 → 现通过 - -FP32 x[2,3,4], constant INT64 axes[1]=[-1], keepdims=1 -> [2,3,1]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["ReduceMean"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | 20a01c6a789029d8138071b2ac1e692a4402d74d6ae928319351aca39c708132 | -| 输入 SHA-256 | 037f24d9d4527b017605afa283f05dddb03773049338c106ffa47fa642d45b17 | -| 参考输出 SHA-256 (expected_sha256) | 43dcac99f0a069df3610b20c7d4c8e59f9d5c1248ac3578e2ec0e5c5124216d7 | -| 初始化器 SHA-256 (initializers_sha256) | 31bd8476b5e08b62c88590c3a19c07e7c89306b3027fee871f28a188701f0793 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3, 1] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: ReduceMean -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3, 1] / float32 | -| 参考 shape / dtype | [2, 3, 1] / float32 | -| 最大绝对误差 | 2.980e-08 | -| 最大相对误差 | 9.933e-08 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0003934999695047736, 0.00037949997931718826, 0.00037489994429051876, 0.000372299924492836, 0.00037619995418936014, 0.0003952999832108617, 0.00040259992238134146, 0.0003915999550372362, 0.0003774999640882015, 0.0003698000218719244] | -| 执行原始样本 s | [0.00011030002497136593, 0.00010720000136643648, 0.0001078000059351325, 0.00010830000974237919, 0.00010569998994469643, 0.00010750000365078449, 0.00010809989180415869, 0.00010909989941865206, 0.00010730000212788582, 0.00010609999299049377] | - -
- -
transpose · 原先不支持 → 现通过 - -FP32 x[1,2,3,4], perm=[0,2,1,3] -> [1,3,2,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Transpose"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | e6c4bcca556bf025f9035ef090e35bba44c4dbcbc9175d3bf087590a3f7318e6 | -| 输入 SHA-256 | c2c2c890613dd624400f084697ecf07d2251cd6956cf57234da0551d2b56508a | -| 参考输出 SHA-256 (expected_sha256) | b610b4d2f5c3976762d1a7c04a0d9690f4ec3d39ab51fa9cc7fc9d5f628f6b75 | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [1, 3, 2, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Transpose -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [1, 3, 2, 4] / float32 | -| 参考 shape / dtype | [1, 3, 2, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0003711999161168933, 0.00037619995418936014, 0.0003662999952211976, 0.00041259999852627516, 0.0003730000462383032, 0.00035260000731796026, 0.0003722000401467085, 0.0003524000057950616, 0.0003497001016512513, 0.0003529998939484358] | -| 执行原始样本 s | [0.00010429997928440571, 0.00010159995872527361, 9.989994578063488e-05, 0.00010140007361769676, 0.00010170007590204477, 0.00010349997319281101, 9.93999419733882e-05, 9.840005077421665e-05, 9.790004696696997e-05, 9.710004087537527e-05] | - -
- -
concat · 原先不支持 → 现通过 - -FP32 left[2,1,4] and right[2,2,4], axis=-2 -> [2,3,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Concat"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | c96a7b79f82cd23c6c943f61a9d675161ee47102c1251259b7d5d1bfbbc8c977 | -| 输入 SHA-256 | 09f2f52e4a0c7c9c0cbb4a3962eff5a53214d57e4445b1ce4fce07bc6acae6d9 | -| 参考输出 SHA-256 (expected_sha256) | c94ae1852d9ca136be5084962a6333495a5a4df0f7f7db13d5ea0d3d41ce80cf | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Concat -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3, 4] / float32 | -| 参考 shape / dtype | [2, 3, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0003693000180646777, 0.00038620003033429384, 0.0003693000180646777, 0.0003645999822765589, 0.00036700000055134296, 0.00041009997949004173, 0.0003698000218719244, 0.00037619995418936014, 0.00035749992821365595, 0.00035550002939999104] | -| 执行原始样本 s | [9.770004544407129e-05, 0.00010369997471570969, 9.69000393524766e-05, 9.790004696696997e-05, 9.599991608411074e-05, 0.00010299996938556433, 9.889993816614151e-05, 9.699992369860411e-05, 9.910005610436201e-05, 9.550002869218588e-05] | - -
- -
slice · 原先不支持 → 现通过 - -FP32 x[2,8], INT64 vectors: start=-1,end=INT64_MIN,axis=-1,step=-2 -> [2,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Slice"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | b9d3881f01090a4f8ec8ab828d2ef4d9f5460d37c9dce469e03577525ebb3b30 | -| 输入 SHA-256 | 04457bea47baae4c174cbb9964c15e2173ed21476eea164fdfa83f0c66b3f3aa | -| 参考输出 SHA-256 (expected_sha256) | 15916501385783e26c51e19e641bcd04e59f27d8f241438d4c392f02a2da72df | -| 初始化器 SHA-256 (initializers_sha256) | 6fb5ce1e94fa98118310be1dbbec731166552b05cf34b063477f2d54ea3bda7a | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Slice +```bash +set -euo pipefail +source .venv-operators/bin/activate +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 NUMEXPR_NUM_THREADS=1 +python -X utf8 -B -m benchmarks.bench_onnx_operators \ + --baseline-ref 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 \ + --output-dir benchmark_reports/onnx_operators-linux-01 \ + --warmup 1 --repeats 5 ``` -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 4] / float32 | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.00040770007763057947, 0.0004149000160396099, 0.00039679999463260174, 0.000395399983972311, 0.000394199974834919, 0.0004005999071523547, 0.0003872000379487872, 0.0003947000950574875, 0.00039820000529289246, 0.0003963999915868044] | -| 执行原始样本 s | [0.00010409997776150703, 0.00010469998233020306, 0.00010289996862411499, 0.00010169995948672295, 0.00010259996633976698, 0.00010469998233020306, 0.00010290008503943682, 0.0001039999770000577, 0.00010349997319281101, 0.00010409997776150703] | - -
- -
unsqueeze · 原先不支持 → 现通过 - -INT64 x[2,3], constant INT64 axes[2]=[0,-1] -> [1,2,3,1]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Unsqueeze"] | -| 绝对容差 atol | 0.0 | -| 相对容差 rtol | 0.0 | -| 模型 SHA-256 | 3af8a32c4178606972d98c12b3e1bf28d1fd8301587a5efa3b1dd3e10d4545cd | -| 输入 SHA-256 | 4a7719bc73234f93e26685e50c1a40a771fe9465af4fa82ee75df54ceba5ea50 | -| 参考输出 SHA-256 (expected_sha256) | d96e4f88f5e537f71a56023d5684c075b9d81e960a60fdb1ceef46d3434e7193 | -| 初始化器 SHA-256 (initializers_sha256) | a8ff6dd261ccbd0a49d97047d600420769bfd677d4891898103e1d823513dcdf | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [1, 2, 3, 1] / int64 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Unsqueeze -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [1, 2, 3, 1] / int64 | -| 参考 shape / dtype | [1, 2, 3, 1] / int64 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0004278999986127019, 0.00037889997474849224, 0.0003894000547006726, 0.0004752000095322728, 0.00046479993034154177, 0.00037020002491772175, 0.00035470002330839634, 0.0003568999236449599, 0.0003622000804170966, 0.0003958999877795577] | -| 执行原始样本 s | [8.939998224377632e-05, 8.649996016174555e-05, 8.329993579536676e-05, 8.98001017048955e-05, 8.560006972402334e-05, 8.510006591677666e-05, 8.279993198812008e-05, 8.359993807971478e-05, 8.44000605866313e-05, 8.329993579536676e-05] | - -
- -
expand · 原先不支持 → 现通过 - -FP32 x[2,1,4], constant INT64 shape[3]=[1,3,4], bidirectional broadcast -> [2,3,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Expand"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | 65a0c9a5fe1045ad4511d3099009989574b5b294a542a2716eb014aa0526a6ab | -| 输入 SHA-256 | 4fdf47ab1aae83654c3b631bf596434cdad68ae0ea325af90a5feb832173d3d6 | -| 参考输出 SHA-256 (expected_sha256) | eec80e44c7acb11e603e87f01a7570d0e15a45d9b2db4c7d148a5c5e379ddfd6 | -| 初始化器 SHA-256 (initializers_sha256) | 7eccb03cb24eb87dec4c8282d83c3bf88de5ed2ad14006e861cb239668ff0f3a | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Expand -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3, 4] / float32 | -| 参考 shape / dtype | [2, 3, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0004062999505549669, 0.0003779999678954482, 0.0004357000580057502, 0.00038139999378472567, 0.0004078999627381563, 0.0003739000530913472, 0.0003749999450519681, 0.0003727000439539552, 0.00036589999217540026, 0.000359599944204092] | -| 执行原始样本 s | [0.00011890009045600891, 0.00010760000441223383, 0.00010830000974237919, 0.00010639999527484179, 0.0001023000804707408, 0.00010429997928440571, 0.00010469998233020306, 0.00010419997852295637, 0.00010289996862411499, 0.00010410009417682886] | - -
- -
abs · 原先不支持 → 现通过 - -Signed INT64 x[2,3] -> Abs [2,3], preserving integer dtype. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Abs"] | -| 绝对容差 atol | 0.0 | -| 相对容差 rtol | 0.0 | -| 模型 SHA-256 | 2446ec6d9d36c8ab37f7a443113228b6773c8a6e05822c33e638e10338a58cdf | -| 输入 SHA-256 | 898fdb420a5c2a481f896b1f932dbbd6a45ef50a482d7c2d3ef7263b830e043a | -| 参考输出 SHA-256 (expected_sha256) | 7443fff444e2f78e27d69236ee9c0023e49acfc2d0158234069c0ca7d02bb640 | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3] / int64 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Abs -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3] / int64 | -| 参考 shape / dtype | [2, 3] / int64 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.00036099995486438274, 0.0003568999236449599, 0.0003541000187397003, 0.0003519000019878149, 0.0003469999646767974, 0.00033569999504834414, 0.00035450002178549767, 0.00034719996619969606, 0.0003350999904796481, 0.000327100045979023] | -| 执行原始样本 s | [8.889997843652964e-05, 8.719996549189091e-05, 8.620007429271936e-05, 8.420005906373262e-05, 8.320005144923925e-05, 8.759996853768826e-05, 8.549995254725218e-05, 8.499994874000549e-05, 8.68999632075429e-05, 8.470006287097931e-05] | - -
- -
cast · 原先不支持 → 现通过 - -INT64 position IDs[1,8] -> FP32 [1,8]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Cast"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | e5bf2bf24fd5667082da808f92a835bf3b6412a8a494bee301ee042c31167dd5 | -| 输入 SHA-256 | 93f57a04397de81ea2aabce45687351a7238a5b72a8b4a625de522bfd69df92f | -| 参考输出 SHA-256 (expected_sha256) | 5296d1fd5eb039a4b0ccb36f0498e194eb76696167b82a590cb30defc016141f | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [1, 8] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Cast -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [1, 8] / float32 | -| 参考 shape / dtype | [1, 8] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0003632999723777175, 0.00036179996095597744, 0.0004943999228999019, 0.0005131000652909279, 0.00037509994581341743, 0.00034440006129443645, 0.00033980002626776695, 0.00034899997990578413, 0.00033760000951588154, 0.00035889993887394667] | -| 执行原始样本 s | [7.98999099060893e-05, 0.00017800007481127977, 0.00011080002877861261, 8.82000895217061e-05, 8.230004459619522e-05, 7.829989772289991e-05, 7.860001642256975e-05, 7.840001489967108e-05, 8.060003165155649e-05, 7.970002479851246e-05] | - -
- -
constant · 原先不支持 → 现通过 - -Constant tensor attribute FP32 [2,3]; no runtime inputs. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Constant"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | c9d931c03dd507060befe5387ac7a6e07c4de2f8970c6fe4009ad2799a7e7227 | -| 输入 SHA-256 | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | -| 参考输出 SHA-256 (expected_sha256) | 5a1a9e658f38c6a788419b7f7321353352ffeb929be456073dca9d36511f2a67 | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Constant -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3] / float32 | -| 参考 shape / dtype | [2, 3] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 1 | -| 解析原始样本 s | [0.0003632999723777175, 0.000381799996830523, 0.00039549998473376036, 0.000381799996830523, 0.0003598000621423125, 0.00036259996704757214, 0.0003591999411582947, 0.0003554000286385417, 0.0003502999898046255, 0.0003415999235585332] | -| 执行原始样本 s | [6.250001024454832e-05, 6.170000415295362e-05, 6.10999995842576e-05, 6.200000643730164e-05, 6.0399994254112244e-05, 6.150000263005495e-05, 6.219989154487848e-05, 6.0399994254112244e-05, 6.049999501556158e-05, 5.909998435527086e-05] | - -
- -
cos · 原先不支持 → 现通过 - -FP32 angles[2,4] spanning positive/negative radians -> Cos [2,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Cos"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | 3af95b0a4f4cd19cd254fb139b753cfa99171c187c492d6a322a8e31b8866298 | -| 输入 SHA-256 | 8c196fdf42e2e25a11894ee88e8d73e31e59a061f7e76bead86793c428ddc998 | -| 参考输出 SHA-256 (expected_sha256) | 7a6831ace7c14d1ee2ec5d6d7ad497adc95914e56eecf8f384c018012e36e0ed | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Cos -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 4] / float32 | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | 5.960e-08 | -| 最大相对误差 | 8.024e-08 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0003530000103637576, 0.00034549995325505733, 0.00033980002626776695, 0.000340400030836463, 0.0003451999509707093, 0.00034739996772259474, 0.000332699972204864, 0.0003412999212741852, 0.00033599999733269215, 0.00033199996687471867] | -| 执行原始样本 s | [8.690007962286472e-05, 8.670007809996605e-05, 8.889997843652964e-05, 8.739996701478958e-05, 8.559995330870152e-05, 8.60999571159482e-05, 8.629995863884687e-05, 8.690007962286472e-05, 8.579995483160019e-05, 8.400005754083395e-05] | - -
- -
identity · 原先不支持 → 现通过 - -INT64 indices[2,3] -> Identity [2,3]; frontend alias. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Identity"] | -| 绝对容差 atol | 0.0 | -| 相对容差 rtol | 0.0 | -| 模型 SHA-256 | e16a4cbfc11442ded30de5b7503512fe1d179b3c64dfcc65140d17f81cab3a4b | -| 输入 SHA-256 | 19f74d0f712a161d1f093b37f6d7d1464fb4a730dd83d1069f5cbf5e38e13413 | -| 参考输出 SHA-256 (expected_sha256) | a700997366bef11ca0353f21bc678822148eb06156a05ed32d1ce76b73ad1cc8 | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3] / int64 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Identity -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3] / int64 | -| 参考 shape / dtype | [2, 3] / int64 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 1 | -| 解析原始样本 s | [0.000341000035405159, 0.00034380005672574043, 0.0003355999942868948, 0.0003361001145094633, 0.0003378000110387802, 0.00033820001408457756, 0.0003301999531686306, 0.00032760004978626966, 0.0003744999412447214, 0.0003630999708548188] | -| 执行原始样本 s | [5.170004442334175e-05, 5.280005279928446e-05, 5.219993181526661e-05, 5.2200048230588436e-05, 5.080003757029772e-05, 5.179992876946926e-05, 5.079992115497589e-05, 5.139992572367191e-05, 6.809993647038937e-05, 5.4600066505372524e-05] | - -
- -
pow · 原先不支持 → 现通过 - -Signed FP32 x[2,3] ** INT64 scalar exponent 2 -> FP32 [2,3]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Pow"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | 9184fbaccd896e171a8ca025b6745d0674bf1493913f34b77f8bc0f218751771 | -| 输入 SHA-256 | b33fcf5b3bd13b2fa3bacb0388119b95ed138163db9b168dd3993654533b4cd1 | -| 参考输出 SHA-256 (expected_sha256) | dcada1e53c419cefb8b09810f4494beb138bcecdce47c5c0f48466ee49d5d51e | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Pow -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3] / float32 | -| 参考 shape / dtype | [2, 3] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.0004491000436246395, 0.00043999997433274984, 0.00040019990410655737, 0.00037569995038211346, 0.00038789992686361074, 0.0003453000681474805, 0.00048369995784014463, 0.00044920004438608885, 0.00047619990073144436, 0.0004214000655338168] | -| 执行原始样本 s | [0.00014999997802078724, 0.00014180003199726343, 0.00011959997937083244, 0.00011729996185749769, 0.00011809996794909239, 0.00011589995119720697, 0.00015820004045963287, 0.00015029998030513525, 0.00015840004198253155, 0.0001241000136360526] | - -
- -
reciprocal · 原先不支持 → 现通过 - -Nonzero signed FP32 x[2,4] -> Reciprocal [2,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Reciprocal"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | 3d0e2326e2db7ff3ad97b6a1e7f2e59dc713fd2e70ca1a4d067c04ed699a04e3 | -| 输入 SHA-256 | fb2145f899f1258c7169e1f7a540b9b94625c689d770793b142cda8ae4bf722f | -| 参考输出 SHA-256 (expected_sha256) | c31992656b1ac1d73d6fc90936ecaf8d816946286b2c6170097a6cf9823d818d | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Reciprocal -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 4] / float32 | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.00035170000046491623, 0.0003496999852359295, 0.00034989998675882816, 0.00034769997000694275, 0.00033870001789182425, 0.00037759996484965086, 0.00035119999665766954, 0.0003469000803306699, 0.00033660000190138817, 0.00033329997677356005] | -| 执行原始样本 s | [9.190000128000975e-05, 9.019998833537102e-05, 9.029998909682035e-05, 9.14999982342124e-05, 8.899997919797897e-05, 9.609991684556007e-05, 9.510002564638853e-05, 9.06999921426177e-05, 9.109999518841505e-05, 9.059999138116837e-05] | - -
- -
sin · 原先不支持 → 现通过 - -FP32 angles[2,4] spanning positive/negative radians -> Sin [2,4]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Sin"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | cd93a789d0f603c48706203da065f2451a65cf345581f9772d34fff8bca88a2a | -| 输入 SHA-256 | efb400ff25087f013055f64abc4fcf498072fb9d417b825f6e9e292742b3ef9e | -| 参考输出 SHA-256 (expected_sha256) | e2a29d3f9d4bc5d9003ed673b7e4cb70997b5048a77aff3b181721c9461d13ee | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Sin -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 4] / float32 | -| 参考 shape / dtype | [2, 4] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 2 | -| 解析原始样本 s | [0.00034879997838288546, 0.0003532000118866563, 0.00035470002330839634, 0.0003448999486863613, 0.00033730000723153353, 0.00033710000570863485, 0.0003510999958962202, 0.0003322999691590667, 0.00032819993793964386, 0.000327700050547719] | -| 执行原始样本 s | [8.570007048547268e-05, 8.719996549189091e-05, 8.620007429271936e-05, 8.480006363242865e-05, 8.730008266866207e-05, 8.71000811457634e-05, 8.659996092319489e-05, 8.569995407015085e-05, 8.390005677938461e-05, 8.750008419156075e-05] | - -
- -
scalar_fp32_add_chain · 原先错误 → 现正确 - -FP32 scalar initializers: (16777216 + 1) + 1 must round after each Add; exact scalar output. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Add"] | -| 绝对容差 atol | 0.0 | -| 相对容差 rtol | 0.0 | -| 模型 SHA-256 | 4ff5c4c55a16938c3a8c30f16a2b8e6662e6f7cc38413473bc00bd50a36abce7 | -| 输入 SHA-256 | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | -| 参考输出 SHA-256 (expected_sha256) | 87da7d0335886bc568cddf1844505676699d5476a6bd72d0d6292c3ee42c253a | -| 初始化器 SHA-256 (initializers_sha256) | 32823c7b66e5511946296130ba5b03f3e897d3939bd738d14f84d41f7e80923a | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | NUMERIC_ERROR / compare | -| 实际 shape / dtype | [] / float32 | -| 参考 shape / dtype | [] / float32 | -| 最大绝对误差 | 2.000e+00 | -| 最大相对误差 | 1.192e-07 | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -NumericMismatch: output differs from ORT: max_abs_error=2 -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [] / float32 | -| 参考 shape / dtype | [] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 5 | -| 解析原始样本 s | [0.0005631999811157584, 0.0004637000383809209, 0.0004089000867679715, 0.00039579998701810837, 0.0003945999778807163, 0.000381799996830523, 0.00041089998558163643, 0.00040959997568279505, 0.0003945999778807163, 0.0003902000607922673] | -| 执行原始样本 s | [0.00019599997904151678, 0.00015169999096542597, 0.00014780007768422365, 0.0001476999605074525, 0.0001452999422326684, 0.00015129998791962862, 0.00014889996964484453, 0.0001441999338567257, 0.00014589994680136442, 0.0001444999361410737] | - -
- -
singleton_initializer · 原先错误 → 现正确 - -FP32 initializer weight[1]=[7] returned directly: retain tensor data and shape [1], not scalar. - -| 项目 | 值 | -| --- | --- | -| 算子 | [] | -| 绝对容差 atol | 0.0 | -| 相对容差 rtol | 0.0 | -| 模型 SHA-256 | 52473f6b8b73062f1bc8eb98f6a61a2dffb467a6c9966cb985c8b53037c2f722 | -| 输入 SHA-256 | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | -| 参考输出 SHA-256 (expected_sha256) | 3948d58d392cf3b813d8cc0773e2ed0af42344e5e96ff9eff386c223ce2ac1f8 | -| 初始化器 SHA-256 (initializers_sha256) | 7da88694441abcf15dddeff15e70aaa41eb8e0ee336a5b08e02b61215b3bb6df | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | EXECUTION_ERROR / execute | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [1] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -IRExecutionError: BindingError function=singleton_initializer value=weight: initializer disagrees with scalar constant -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [1] / float32 | -| 参考 shape / dtype | [1] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 1 | -| 解析原始样本 s | [0.0003521000035107136, 0.00034039991442114115, 0.00033650000113993883, 0.0003445999464020133, 0.0003336999798193574, 0.0003494999837130308, 0.0003339999821037054, 0.00035889993887394667, 0.00032849994022399187, 0.00033849989995360374] | -| 执行原始样本 s | [6.290001329034567e-05, 6.280001252889633e-05, 6.140000186860561e-05, 6.009999196976423e-05, 6.079999729990959e-05, 6.370001938194036e-05, 6.200000643730164e-05, 6.330001633614302e-05, 6.260001100599766e-05, 6.0399994254112244e-05] | - -
- -
constant_cast_abs_expand · 原先不支持 → 现通过 - -Constant INT32 [-2,3] -> Cast INT64 -> Abs target[2]=[2,3]; Expand FP32 x[1,3] -> [2,3]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Constant", "Cast", "Abs", "Expand"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | bd8568ed91a153a8c061bf51d0ff3512314db206860531456cb11aa0735938b3 | -| 输入 SHA-256 | 9b2c7512831a4cace198062de741146fbeedb181b006cf32431c4fce833a9822 | -| 参考输出 SHA-256 (expected_sha256) | eed5f694fcfe47f46f2a01790cff931b624565234b061b5c64a278c2ed1eac53 | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [2, 3] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Constant -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [2, 3] / float32 | -| 参考 shape / dtype | [2, 3] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 4 | -| 解析原始样本 s | [0.00043970008846372366, 0.00044329999946057796, 0.0004450000124052167, 0.0004384000785648823, 0.0006199999479576945, 0.00047199998516589403, 0.00045169994700700045, 0.00044700002763420343, 0.00044139998499304056, 0.00043539993930608034] | -| 执行原始样本 s | [0.00013560010120272636, 0.0001338999718427658, 0.00013189995661377907, 0.0002367999404668808, 0.00017560005653649569, 0.00013229995965957642, 0.0001343999756500125, 0.000130399945192039, 0.00013109995052218437, 0.00013479997869580984] | - -
- -
qwen_rmsnorm · 原先不支持 → 现通过 - -FP32 x[1,4,8], gamma[8]; Pow(INT64 scalar 2)/ReduceMean(-1)/Add(eps=1e-6)/Sqrt/Reciprocal/Mul -> [1,4,8]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Pow", "ReduceMean", "Add", "Sqrt", "Reciprocal", "Mul"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | cf11b7c966672ab3f03eb3d92a8e45fc87968d70e33a4a500964ce52d02a7237 | -| 输入 SHA-256 | f9c2bb681cd4b72783d29572f427b6e237b6c7bf40252ab5b88a2c6174751a48 | -| 参考输出 SHA-256 (expected_sha256) | 5fe5157ae0bc87a41a4a9db76a037f52f8b7e5aa4f2c64e8674e0bdbcb8dfaec | -| 初始化器 SHA-256 (initializers_sha256) | 6bf5dd34dd2bb7907f3d53ccd995420cfd6a610b463eb41cf53d0698308ab92c | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [1, 4, 8] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Pow -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [1, 4, 8] / float32 | -| 参考 shape / dtype | [1, 4, 8] / float32 | -| 最大绝对误差 | 0.000e+00 | -| 最大相对误差 | 0.000e+00 | -| 执行 IR 步数 | 10 | -| 解析原始样本 s | [0.000506900018081069, 0.0005015999777242541, 0.0005096999229863286, 0.0005014999769628048, 0.0004960000514984131, 0.0005388999124988914, 0.0005028999876230955, 0.000512199942022562, 0.000493900035507977, 0.0004851999692618847] | -| 执行原始样本 s | [0.0002813000464811921, 0.0002788000274449587, 0.0002808000426739454, 0.00028170004952698946, 0.00027820002287626266, 0.0002930000191554427, 0.00028490007389336824, 0.00028249993920326233, 0.0002837000647559762, 0.0002848999574780464] | - -
- -
rope_numeric · 原先不支持 → 现通过 - -INT64 positions[1,4,1] -> Cast FP32; frequencies[1,1,4], Sin/Cos rotate FP32 left/right[1,4,4] -> [1,4,8]. - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Cast", "Mul", "Cos", "Sin", "Sub", "Add", "Concat"] | -| 绝对容差 atol | 1e-06 | -| 相对容差 rtol | 1e-06 | -| 模型 SHA-256 | 7aed8255b36ac1866b0f27cd399e045010f0b82b1308036bbdbb849e02476cfa | -| 输入 SHA-256 | 823f1be624262313583ea77e2dc14efcb3ea0696d059c2cc89643266d78f944a | -| 参考输出 SHA-256 (expected_sha256) | eb0681ce4f70f4032e2806727ea5eb56e3d9f4036729cba48950643b393a39cd | -| 初始化器 SHA-256 (initializers_sha256) | 8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85 | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [1, 4, 8] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Cast -``` - -**修改后** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [1, 4, 8] / float32 | -| 参考 shape / dtype | [1, 4, 8] / float32 | -| 最大绝对误差 | 2.980e-08 | -| 最大相对误差 | 1.699e-07 | -| 执行 IR 步数 | 12 | -| 解析原始样本 s | [0.0005266999360173941, 0.000520200002938509, 0.0005088000325486064, 0.0006041000597178936, 0.0006553001003339887, 0.0005080000264570117, 0.0005031999899074435, 0.0005010999739170074, 0.0005029999883845448, 0.00049919995944947] | -| 执行原始样本 s | [0.0002498000394552946, 0.00024560000747442245, 0.0002465000143274665, 0.0003208999987691641, 0.00031370006036013365, 0.00025370006915181875, 0.0002449000021442771, 0.00024610001128166914, 0.0002482000272721052, 0.0002460000105202198] | - -
- -
pr89_transformer_2l · 原先不支持 → 现通过 - -PR #89 原始两层合成 Transformer,FP32,输出 [1,256,128];非完整 Qwen3 - -| 项目 | 值 | -| --- | --- | -| 算子 | ["Add", "Concat", "Div", "Expand", "Gather", "MatMul", "Mul", "Neg", "ReduceMean", "Reshape", "Sigmoid", "Slice", "Softmax", "Sqrt", "Transpose", "Unsqueeze"] | -| 绝对容差 atol | 1e-05 | -| 相对容差 rtol | 0 | -| 模型 SHA-256 | 17a8f191ee65a9d97143131016058e8a3c10a7d5c06e8152bf16dd8941cd19e1 | -| 输入 SHA-256 | bd3f848d2679e66691a7701a84e39f35a02db58c0874e65b023b561f4f507457 | -| 参考输出 SHA-256 (expected_sha256) | 413dd44ea90d93a25cbe0f89bc3703ffeb8ee7b0774c8fa955bcd7521b24ef5c | -| 初始化器 SHA-256 (initializers_sha256) | 955cb24a985e3aa13cb93e62af4e51e92ff27d14bdfdeac1d5a6107f65a8e3de | - -**修改前** - -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | UNSUPPORTED / parse | -| 实际 shape / dtype | — / — | -| 参考 shape / dtype | [1, 256, 128] / float32 | -| 最大绝对误差 | — | -| 最大相对误差 | — | -| 执行 IR 步数 | — | -| 解析原始样本 s | [] | -| 执行原始样本 s | [] | - -错误详情: - -```text -ONNXParseError: Unsupported ONNX op type: Gather -``` +输出目录每次换新名字,生成本次 JSON、Markdown、HTML 及原始样本。仅前后均 PASS 的用例比较耗时;缺失、不支持或失败不等于零耗时。结果用于支持/正确性对比,IR 解释器计时不等于 QEMU 或目标硬件性能。 -**修改后** +原始个人测量保持原平台身份和数据,分别保存于固定提交: -| 项目 | 值 | -| --- | --- | -| 状态 / 阶段 | PASS / complete | -| 实际 shape / dtype | [1, 256, 128] / float32 | -| 参考 shape / dtype | [1, 256, 128] / float32 | -| 最大绝对误差 | 7.153e-07 | -| 最大相对误差 | 5.722e-04 | -| 执行 IR 步数 | 159 | -| 解析原始样本 s | [0.004760700045153499, 0.004594899946823716, 0.004717700066976249, 0.004990800051018596, 0.004586199996992946, 0.004221900017000735, 0.004203699994832277, 0.004572000005282462, 0.004674999974668026, 0.004224899923428893] | -| 执行原始样本 s | [0.010629900032654405, 0.011460799956694245, 0.009812200092710555, 0.009985200013034046, 0.010765800019726157, 0.009604100021533668, 0.010238799965009093, 0.009660700103268027, 0.010715999989770353, 0.009805700043216348] | +- [原始 MD](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/算子补齐与单测-benchmark.md) +- [原始 JSON](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/算子补齐与单测-benchmark.json) +- [原始 HTML](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/算子补齐与单测-benchmark.html) -
+当前同名 JSON 的 `document_kind=historical_benchmark_index`,只保存归档链接,不含 PASS、环境测量或计时字段,不能作为 benchmark 结果输入。新 Linux 测量必须从 CLI 输出目录读取。 diff --git "a/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213\346\212\245\345\221\212.txt" "b/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213\346\212\245\345\221\212.txt" index 13b7df25..4d516da1 100644 --- "a/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213\346\212\245\345\221\212.txt" +++ "b/docs/llm-deploy-v1.0/W1/\347\256\227\345\255\220\350\241\245\351\275\220\344\270\216\345\215\225\346\265\213\346\212\245\345\221\212.txt" @@ -1,148 +1,92 @@ -Qwen3 ONNX 算子补齐与单测报告 -日期:2026-10-01 - -工作目录:仓库根目录;本文使用仓库相对路径。 -主干基线:20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 -历史测量状态:原始算子探测和 benchmark 在实现尚未提交时执行;归档报告保留测量时的 HEAD、dirty 和源码指纹。 -本文用于随算子实现及测试 PR 归档,不以历史 dirty 标记描述当前提交或远程仓库状态。 -本报告更新上一阶段“真实 Qwen3 尚缺八类前端算子”的临时结论。 - -一、完成范围 - -完成 ONNX 前端 → 共享 IR → NumPy 解释器路径所需的两组算子: -1. PR #89 探测图八类:Gather、Sqrt、ReduceMean、Transpose、Concat、Slice、Unsqueeze、Expand。 -2. 真实 Qwen3 导出图额外八类:Abs、Cast、Constant、Cos、Identity、Pow、Reciprocal、Sin。 -第一组复用已有 IR 算子与内核,补齐前端和边界测试;第二组新增六个 IR opcode,Constant 绑定为全局常量,Identity 复用输入值。 - -二、本轮代码改动 - -scratchv/frontend/onnx_parser.py -- 补齐第二组前端映射;支持 Constant 的 tensor、value_int/value_ints、value_float/value_floats。 -- 保持零维标量、单元素向量与 INT64 值的区别,支持外部权重及 Constant 外部数据。 -- 求值 Constant → Cast/Abs/Reshape 等静态 shape/axis 链,适配真实 Qwen3 导出形式。 -- 不将中间结果冒充 initializer;限制静态参数求值规模,避免超大 Expand 与尺寸乘积溢出。 -- Add/Mul 统一发出 IR,修复 Python 标量折叠造成的 FP32 舍入与整数宽度错误。 -- 预留全图 v_N 名字,修复 ONNX 原始名字和临时结果名碰撞。 -- 明确拒绝当前 IR 不支持的数据类型。 - -scratchv/ir/types.py、scratchv/ir/builder.py -- 新增 ABS、CAST、COS、SIN、POW、RECIPROCAL 及 builder 方法。 -- Cast 的结果 dtype 表示目标类型;Pow 允许底数与指数类型不同,结果保持底数类型。 - -scratchv/analysis/ir_verifier.py -- 增加新算子签名、浮点算子类型约束和 Pow 输出类型校验。 - -scratchv/verification/ir_numpy_ops.py -- 增加新算子的实际 NumPy 求值和数值错误诊断。 -- Cast 整数缩窄保留低位;浮点转整数按截断后范围检查,避免 INT64 边界错误。 -- Pow 支持广播、混合类型以及有界的精确整数计算。 -- Slice 使用 ONNX 的正反向钳制规则,修复过小负 start 的反向切片,并处理空维和 INT64 哨兵。 - -三、测试 - -本轮新增四个测试文件,共 282 个用例: -- tests/test_onnx_shape_ops.py:116 个,第一组八类算子与 ORT 对照、边界和非法参数。 -- tests/test_onnx_qwen_numeric_ops.py:78 个,第二组算子、外部常量、实际静态参数链及 RMSNorm 组合。 -- tests/test_ir_qwen_numeric_ops.py:74 个,IR builder、类型签名、广播、整数精度、数值边界与错误定位。 -- tests/test_onnx_constant_regressions.py:14 个,FP32 舍入、整数宽度、名字碰撞和静态求值规模限制。 -更新 tests/test_ir_verifier.py 的穷举签名表,覆盖六个新增 opcode。 -延续上一阶段的 tests/test_onnx_transformer_execution.py 和 initializer 测试修复。 - -算子及原有相关回归共 715 项,加上 22 项 benchmark 自检;提交前合并重跑结果为 737 passed,无失败。 -该次合并运行有 7 条 NumPy 配置提示 warnings,未影响测试通过。 -其中 715 项算子及原有相关回归的分组记录如下: -- IR、解释器、校验器、优化器、PassManager 相关:439 passed。 -- 前端、ONNX 算子、Transformer 组合、诊断和相关 benchmark:262 passed。 -- Constant 专项回归:14 passed(另与已计入的 85 项用例联合运行,结果 99 passed)。 -这是相关回归集合,未声称运行整个项目测试全集。 -单测测量环境:Python 3.12.13,NumPy 2.5.3、ONNX 1.23.0、ORT 1.29.0,Python -X utf8 -B。 - -四、模型验证 - -1. PR #89 原始 LFS 模型未修改: - probes/w1_tiny_transformer/out/tiny_transformer_2l.onnx - SHA-256:17a8f191ee65a9d97143131016058e8a3c10a7d5c06e8152bf16dd8941cd19e1 - 本轮重新运行 seed=1:ORT vs 独立 NumPy 参考最大绝对误差 1.09718e-6;IR vs ORT 为 7.1525574e-7。 - 均通过最大绝对误差 <1e-5;IR 执行 159 步。 - 另有 seed=0/1/42 的组合回归通过。 - 原始探测 JSON 保留在本机,未随本 PR 归档;此处保留模型指纹和数值结论。 - -2. 本地真实 Qwen3-0.6B 导出模型: - models/qwen3-0.6b-onnx/model.onnx(额外交付模型,不包含在本次算子 PR 中) - 7847 个 ONNX 节点、23 类算子;未识别算子为 0。 - 完整载入外部权重,得到 2148 个全局定义、5910 条 IR 指令。 - verify_ir 通过,问题数为 0。 - 原始结构审计 JSON 保留在本机,未随本 PR 归档。 - 此项是完整图解析及结构/类型校验,不是全模型 IR 推理的数值对照。 -模型探测使用独立导出依赖环境(NumPy 2.2.6、ONNX 1.18.0、ORT 1.22.1)。 - -五、支持边界与下一步 - -本次范围是部署方案所需的静态图及 FP32 路径,同时覆盖 IR 已有的 FLOAT64、INT32、INT64 类型。 -没有实现 ONNX 的全部 dtype/opset 扩展;动态 shape/axis 参数及 Reshape allowzero=1 仍明确拒绝。 -解释器沿用严格数值策略:非法定义域、非有限结果以及新增 Abs/整数 Pow 的溢出会报错。 -整数 Pow 对大整数采用精确计算,ORT CPU 在超 2**53 的整数幂上可能存在浮点精度差异;实际 Qwen 使用 FP32 底数和 INT64 指数 2。 - -下一步应运行真实 Qwen3 结构的小模型,包含 Q/K RMSNorm、完整 RoPE、GQA、SwiGLU,逐层对照 PyTorch/ORT/IR。 -之后再验证优化流水线、完整 0.6B 推理的数值/内存,以及 RISC-V 后端和 QEMU。 -本轮没有补 RISC-V 降低和执行支持,不能据此前端/IR 结果判断 QEMU 已可运行。 - -六、修改前后 benchmark 对比 - -归档文件(同目录): -- 算子补齐与单测-benchmark.json:移除本机路径后的测量数据,保留全部样本、误差及指纹。 -- 算子补齐与单测-benchmark.md:GitHub 可直接阅读的结果报告。 -- 算子补齐与单测-benchmark.html:独立 HTML 报告,下载后用浏览器打开,可筛选并展开错误详情。 - -共 24 个固定用例,修改前 2/24 PASS,修改后 24/24 PASS: -- 16 个独立算子用例:0/16 → 16/16。 -- 2 个已有支持的对照用例:2/2 → 2/2。 -- 2 个错误回归用例:0/2 → 2/2。 -- 4 个组合链路用例:0/4 → 4/4,包含 PR #89 的原始两层 Transformer。 -因此,“原先不支持、现通过”是 20 个用例,不是新增 20 种算子。 - -使用同一批模型、输入、ORT 参考及初始化器,并校验指纹;基线通过解释器已有 initializers 参数接受原始 ONNX 数组,属于基准适配,不改基线实现,也不重塑权重以掩盖旧错误。 -ABBA 顺序为修改前、修改后、修改后、修改前;每版本 2 轮,每轮预热 1 次、正式计时 5 次,合计每版本 10 个正式样本。 -IR.run 完整计时包括校验、执行规划、绑定复制、NumPy 计算及返回复制;ONNX 解析单独计时,ORT 参考计算不计入。 -只在前后均 PASS 时显示执行耗时比;本报告证明支持和正确性改善,不宣称性能优化或 QEMU 性能。 -新增 tests/test_onnx_operator_benchmark.py 的 22 项自检与原 715 项回归已合并运行,共 737 passed。 -CI 的测试 checkout 获取完整历史,以便历史基线集成测试实际运行。 - -七、复跑准备与命令(在仓库根目录 PowerShell 执行) - -建议 Python 3.12,并使用独立虚拟环境。依赖安装: -python -m pip install -e ".[all]" pytest -其中项目 all 依赖包含 onnxruntime;基准和测试直接依赖 NumPy、ONNX、onnxruntime、pytest。 -本次单测/benchmark 版本为 NumPy 2.5.3、ONNX 1.23.0、onnxruntime 1.29.0;解释器路径不是复跑前提。 - -确保 Git 历史中存在固定基线提交,浅克隆需补充该历史;origin 应指向包含该提交的项目仓库或 fork: +Qwen3 ONNX 算子补齐与单测:Linux 复现说明 +更新:2026-10-06 + +正式交付与他人复现环境:Ubuntu 24.04 x86_64 / Bash / Python 3.12。 +所有命令在仓库根目录执行。通用环境约定见 docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md。 +个人本机测量的原始版本保留在固定提交: +https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W1/算子补齐与单测报告.txt +原始平台、计数、计时和数值不改名为 Linux 数据;新结果必须由下面的 Linux 命令重新生成。 + +一、实现范围 + +ONNX 前端 → 共享 IR → NumPy 解释器覆盖两组算子: +1. PR #89 探测图:Gather、Sqrt、ReduceMean、Transpose、Concat、Slice、Unsqueeze、Expand。 +2. 真实 Qwen3 导出额外需要:Abs、Cast、Constant、Cos、Identity、Pow、Reciprocal、Sin。 +第一组复用已有 IR 与内核,补前端/边界;第二组新增 ABS、CAST、COS、SIN、POW、RECIPROCAL,Constant 使用绑定,Identity 复用输入值。 + +语义修复包括零维标量与向量区别、INT64 精度、静态 shape/axis 参数链、外部权重和 Constant、FP32 常量舍入、临时值命名冲突、静态求值规模限制、Cast 截断/溢出、Pow 广播与整数精度,以及 Slice 的反向边界和 INT64 哨兵。前端不将中间值伪装为 initializer。 + +二、验收用例 + +tests/test_onnx_shape_ops.py:八类形状/索引算子,ORT 对照、边界和非法参数。 +tests/test_onnx_qwen_numeric_ops.py:数值算子、常量、静态参数链及 RMSNorm 组合。 +tests/test_ir_qwen_numeric_ops.py:IR 类型、广播、整数精度与错误定位。 +tests/test_onnx_constant_regressions.py:FP32 舍入、整数宽度、名字碰撞、求值规模。 +tests/test_onnx_operator_benchmark.py:基准自身的身份、正确性及失败传播。 +实际 passed/skipped/failed 以本次 Linux 输出为准,不沿用历史计数。 + +三、Linux 环境与资产准备 + +以下为 Bash,已存在环境时可复用,但必须核对依赖版本。benchmark 使用单独环境,以保持历史前后对照依赖;它不替代完整模型门禁的固定导出环境。 + +set -euo pipefail +sudo apt-get update +sudo apt-get install --no-install-recommends python3.12-venv git-lfs +python3.12 -m venv .venv-operators +source .venv-operators/bin/activate +python -m pip install "numpy==2.5.3" "onnx==1.23.0" "onnxruntime==1.29.0" "pytest==9.1.1" +python -m pip install --no-deps -e . +python -m pip check +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +export NUMEXPR_NUM_THREADS=1 + +git rev-parse HEAD +git status --short git fetch origin 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 - -安装 Git LFS 后,下载已由 PR #89 提交的模型实体,不能把 LFS 指针作为 ONNX 运行: -git lfs install +git lfs install --local git lfs pull --include="probes/w1_tiny_transformer/out/tiny_transformer_2l.onnx" -以下 24 用例基准不需要完整 Qwen3-0.6B 权重;完整模型结构审计需要另外准备本文第四节所述模型。 -生成新的基准报告及临时 fixtures,不覆盖 docs 中的历史归档: -python -X utf8 -B -m benchmarks.bench_onnx_operators --baseline-ref 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 --output-dir benchmark_reports/onnx_operators --warmup 1 --repeats 5 - -复跑相关测试: - -$operatorTests = @( - 'tests/test_ir.py', 'tests/test_ir_qwen_numeric_ops.py', - 'tests/test_ir_interpreter.py', 'tests/test_ir_interpreter_ops.py', - 'tests/test_ir_interpreter_control_flow.py', 'tests/test_ir_verifier.py', - 'tests/test_ir_verifier_integration.py', 'tests/test_optimizer.py', - 'tests/test_optimizer_advanced.py', 'tests/test_pass_manager.py', - 'tests/test_pass_registry.py', 'tests/test_parser.py', - 'tests/test_onnx_initializers.py', 'tests/test_onnx_shape_ops.py', - 'tests/test_onnx_qwen_numeric_ops.py', 'tests/test_onnx_transformer_execution.py', - 'tests/test_ir_interpreter_benchmark.py', 'tests/test_ir_verifier_benchmark.py', - 'tests/test_ir_diagnostics.py', 'tests/test_onnx_constant_regressions.py', - 'tests/test_onnx_operator_benchmark.py' +基线为固定提交 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6,不能用不同分支 HEAD 代替。浅克隆必须补齐历史。原模型 SHA256 应为 17a8f191ee65a9d97143131016058e8a3c10a7d5c06e8152bf16dd8941cd19e1,不可把 LFS pointer 当 ONNX。 + +四、生成修改前后 benchmark + +python -X utf8 -B -m benchmarks.bench_onnx_operators \ + --baseline-ref 20b105e80ba3dbe13cb01a3d4ca18c32b6c31ed6 \ + --output-dir benchmark_reports/onnx_operators-linux-01 \ + --warmup 1 --repeats 5 + +每轮选择新的输出目录。CLI 生成自己的 fixtures、参考和 JSON/Markdown/HTML 报告,不读取 docs 目录中的历史索引作为测量。源码、模型、输入、环境身份与错误详情由新报告保存。 + +24 个固定用例包含 16 独立算子、2 已支持的对照、2 错误回归和 4 组合链路,含原 PR #89 两层 Transformer。ABBA 顺序比较修改前/后,每版本两轮;前后都通过的用例才计算耗时比。不支持、失败或缺测不能解释为零耗时或无限加速。 + +IR.run 计时包含验证、执行规划、绑定复制、NumPy 计算与返回复制;ONNX 解析单独计时,ORT 参考不计入。此基准验证支持与正确性变化,不证明 QEMU 性能改善。重新生成的 Linux 样本不可与不同平台的历史耗时直接算加速比。 + +五、运行相关回归与合成图 + +operator_tests=( + tests/test_ir.py tests/test_ir_qwen_numeric_ops.py + tests/test_ir_interpreter.py tests/test_ir_interpreter_ops.py + tests/test_ir_interpreter_control_flow.py tests/test_ir_verifier.py + tests/test_ir_verifier_integration.py tests/test_optimizer.py + tests/test_optimizer_advanced.py tests/test_pass_manager.py + tests/test_pass_registry.py tests/test_parser.py + tests/test_onnx_initializers.py tests/test_onnx_shape_ops.py + tests/test_onnx_qwen_numeric_ops.py tests/test_onnx_transformer_execution.py + tests/test_ir_interpreter_benchmark.py tests/test_ir_verifier_benchmark.py + tests/test_ir_diagnostics.py tests/test_onnx_constant_regressions.py + tests/test_onnx_operator_benchmark.py ) -python -X utf8 -B -m pytest @operatorTests -q --basetemp=output/pytest-operator-completion +mkdir -p output +python -X utf8 -B -m pytest "${operator_tests[@]}" -q \ + --junit-xml=output/operator-tests-linux-01.xml +python -X utf8 -B probes/w1_tiny_transformer/run.py \ + --model probes/w1_tiny_transformer/out/tiny_transformer_2l.onnx \ + --output-dir output/operator-completion-linux-01 --seed 1 + +六、范围与后续门禁 + +这是静态图和 FP32 所需前端/IR 能力,同时覆盖现有 FLOAT64、INT32、INT64;不宣称全部 ONNX dtype/opset 或动态 shape/axis。Reshape allowzero=1 等不支持语义明确拒绝;非法定义域、非有限值及部分整数溢出明确报错。当前固定 Qwen 图使用 FP32 底数与整数指数 2;通用 Pow 范围需要分别验证。 -python -X utf8 -B probes/w1_tiny_transformer/run.py --model probes/w1_tiny_transformer/out/tiny_transformer_2l.onnx --output-dir output/operator-completion-probe --seed 1 +完整 28 层解析、真实两层 Qwen3 数值与 RV64/QEMU 分别从 W1/README.md、W2/README.md 和 probes/w2_qwen3_small/README.md 进入;基础算子 benchmark 通过不能代替这些门禁。 -上述探测命令默认使用已下载的 ONNX,不要求重新导出;切换导出依赖环境时请遵循模型探测的已记录版本。 +同目录 benchmark.md 为 Linux 复跑入口,benchmark.html 为可打开的指引,benchmark.json 为明确标识的历史索引,不包含新测量。原始完整测量保留在固定提交历史中。 diff --git a/docs/llm-deploy-v1.0/W2/README.md b/docs/llm-deploy-v1.0/W2/README.md new file mode 100644 index 00000000..76055ee6 --- /dev/null +++ b/docs/llm-deploy-v1.0/W2/README.md @@ -0,0 +1,93 @@ +# W2 Linux 独立复现与验收 + +正式交付、CI 和第二人复现统一使用 **Ubuntu 24.04 x86_64 / Bash / Python 3.12**。完整环境准备见 [Linux 复现约定](../LINUX_REPRODUCTION.md)。Linux 为宿主环境;QEMU 内的 RV64GC/LP64D 程序仍使用当前裸机 ABI。 + +W2 的范围是完整前端解析、小模型 IR 数值、基础后端和 Host 单步接口。完整 28 层模型的 IR 数值前向属于 W3;完整模型 QEMU 与生成循环属于后续阶段。W1 的人工接口决议继续见 [W1 记录](../W1/README.md) 和 [Issue #92](https://github.com/ScratchV-Compiler/ScratchV/issues/92)。 + +## 独立复现版本 + +[PR #94](https://github.com/ScratchV-Compiler/ScratchV/pull/94) 的既有 Linux 记录对应 `2d07ccc42936dcfde19d552bca02a324cd33821c`。后续文档或源码更新不自动继承该提交的执行结果。开始前记录约定 SHA、`git rev-parse HEAD` 和 `git status --short`;有未提交增量则保留完整 diff 和新增文件,不能只报告 HEAD。 + +发布前个人工作树的详细测量按原始身份保留于 [固定提交归档](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W2/README.md)。当前页面只提供 Linux 交付入口与可关联到具体提交的 Linux CI 记录,不把历史个人测量改名为 Linux 结果。 + +## 七项统一验收 + +先完成环境准备,激活 `.venv-linux`,并设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC`、`SCRATCHV_QEMU`。模型及官方 Tokenizer 目录必须包含固定 manifest 对应的真实资产。在仓库根目录执行: + +```bash +set -euo pipefail +source .venv-linux/bin/activate +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +export PYTHONIOENCODING=utf-8 +export SCRATCHV_PYTHON="$(pwd)/.venv-linux/bin/python" +export SCRATCHV_CC="$(python -c 'from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent / "zig")')" +export SCRATCHV_QEMU="$(command -v qemu-system-riscv64)" +W2_TOKENIZER=/absolute/path/to/fixed-qwen3-tokenizer +W2_MODEL=/absolute/path/to/fixed-qwen3-onnx + +git rev-parse HEAD +git status --short +"$SCRATCHV_PYTHON" -B -X utf8 scripts/run_w2_acceptance.py \ + --python "$SCRATCHV_PYTHON" \ + --output-dir output/w2-acceptance-linux-01 \ + --tokenizer-dir "$W2_TOKENIZER" \ + --model-dir "$W2_MODEL" \ + --cc "$SCRATCHV_CC" \ + --qemu "$SCRATCHV_QEMU" +``` + +把两项资产路径改成 Linux 实际目录。默认使用 `verify` 离线校验;无资产时可显式增加 `--tokenizer-mode download` 与 `--model-mode download`。后者下载约 1.24 GB 的固定完整发布包,解包及解析需要额外磁盘与数 GiB 空闲内存。入口不安装依赖。`--output-dir` 必须是本次全新的路径,连已存在的空目录也会被拒绝。 + +| 门禁 | 实际执行与判断 | +|---|---| +| `frontend` | RMSNorm、RoPE、SwiGLU、GQA 四种基础算子分解图;独立公式与反例 | +| `backend` | 七类基础算子的 16 个小图 × none/all,共 32 次 QEMU;误差 `<1e-5` | +| `runtime` | 官方 Tokenizer 的 20 组语料、480 次对照,以及输入、掩码、greedy、停止边界 | +| `runtime-model` | 完整词表两层随机小配置;2 文本、8 次 Torch/ORT/IR 调用及单步 greedy/解码 | +| `small-ir` | 官方 Qwen3 类两层小配置,7 输入、29 检查点、none/basic/all,误差 `<1e-5` | +| `small-qemu` | 同一本轮普通/诊断图 × none/all × 7 输入,共 28 次真实 QEMU,误差 `<1e-5` | +| `full-parse` | 固定完整 28 层 ONNX 的结构、逐节点转换、真实权重绑定与 IR verifier 审计 | + +所有数值门禁同时检查 shape、dtype、有限值;上述阈值使用最大绝对误差、`rtol=0`,包括 padding 输出。Attention 使用基础算子组合,不要求新增融合 opcode。Tokenizer 的“往返 100%”指与官方 NFC/特殊 Token 规则一致,不是任意字节可逆。 + +仅七项全部执行并通过才会退出 **0 / PASS**;`--gates` 选择部分门禁即使成功,也只会退出 **2 / PARTIAL**。失败退出 1,中断按实际退出状态记录。`runtime-model` 自动带上 `runtime`,`small-qemu` 自动带上 `small-ir`。缺失、跳过、非有限误差、复用旧输出或执行中源码变化均不能冒充完整通过。 + +## 报告与独立复现记录 + +总报告位于本次 `--output-dir` 下的 `report.json`、`report.md`、`report.html`;子目录保存各门禁原始报告、命令、日志、数组和模型/ELF 身份。源码指纹核对的是实际执行文件,不能把别处安装的模块误当当前 checkout。 + +数值报告首页按链路展示普通图的最终 logits(模型输出):ORT 对 PyTorch、原生 NumPy IR 对 ORT,以及生成 C 后经 RISC-V/QEMU 执行的输出对 ORT。最后一项同时验证 C 生成和目标端执行,并不是独立的宿主 C Transformer 测试,也不验证旧标量汇编后端。中间检查点用于定位误差,单独折叠,不混入最终输出的精度。 + +每次实际数组比较新增余弦相似度与相对 L2 误差:前者为两输出向量夹角的余弦,越接近 1 越一致;后者为 `||实际输出−参考输出||₂ / ||参考输出||₂`,越接近 0 越一致。汇总取各次比较中最小余弦、最大相对 L2 和最大绝对误差,它们可能来自不同用例;原有最大绝对误差门槛保持不变。零范数、非有限值等无法定义的指标明确标注原因,不填成 0;旧报告缺少新增指标时显示未记录。 + +逐用例、检查点、计时和完整 JSON 默认折叠。对应表下解释普通图/诊断图及 IR 优化等级;QEMU 秒数仅为进程墙钟时长。报告精简不减少实际测试:七组输入包含两组满长随机输入与五组边界/扰动用例,不是七个随机种子。 + +**PPL / zero-shot 暂不纳入当前验收。** PPL(困惑度)衡量模型预测真实文本下一个 token 的能力;zero-shot 衡量不提供示例时完成指定任务的能力。W2 使用随机权重小模型,这两项不能说明其语言质量。后续应在真实预训练权重上固定语料、Tokenizer、上下文窗口和任务评分方式,再比较编译前后质量;本轮明确标记未评测。 + +- 保存 checkout SHA、dirty/diff、Linux 发行版、CPU、Python/NumPy/ORT/Torch、Zig/QEMU 版本。 +- 保存每项实际退出码、PASS/FAIL/PARTIAL/未执行、原始误差、首次失败阶段及报告位置。 +- QEMU 时长是进程墙钟,包含启动、guest 执行、UART 和退出;不是目标硬件性能或纯 forward 时间。 +- 重新生成自己的模型、参考与输出,不能下载第一人的数组后称为独立执行。 +- 独立复现和团队接口/出口确认分开记录,作者再次执行或 CI 自动运行不替代第二人。 + +## 已有 Linux CI 证据 + +以下全部归属历史 SHA `2d07ccc42936dcfde19d552bca02a324cd33821c`,不作为这次文档更新的新执行结果: + +| 工作流 | 已记录结果 | +|---|---| +| [通用 CI](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37277915411) | 成功;2927 passed / 7 skipped。Linux `test_w2_acceptance.py` 55/55、无跳过 | +| [LLM Deploy](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37277915420) | 成功;32/32 基础 QEMU、28/28 两层 QEMU、3/3 显式产物、20/20 超时清理;两层诊断最大误差 `1.7285346984863281e-6` | +| [Topic 06](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37277915428) | 成功;范围以该 run 的实际步骤与 artifact 为准 | + +PR 中按条件未运行的完整 ONNX ORT 重型任务仍是未执行,不因上述绿色记录而算作完整 ORT 重验。新的提交需要新的 Checks;已有 Linux CI 不替代本次七项统一入口的第二人独立复现。 + +## 分项入口与后续边界 + +- [完整前端解析](../../../probes/w2_qwen3_parse/README.md):无须 Torch 或 QEMU,可单独准备固定解析环境。报告含 `nodes.json`、`bindings.json`、`ir.txt`。 +- [前端模式与基础后端](../../../probes/w2_backend_ops/README.md):NumPy 独立公式、ORT、IR 和 QEMU 四路检查。 +- [Tokenizer 与输入](../../../probes/w2_runtime/README.md) 和 [单步模型接口](../../../probes/w2_runtime_model/README.md):模型词表 151936 与实际可解码 ID 集合分开处理,不取模重映射。 +- [两层数值探测](../../../probes/w2_qwen3_small/README.md):普通图、诊断图、产物绑定、仿真计时与精度边界。 +- [发布范围与修复](W2-分支验收与发布说明.md):共享后端、日志和 Linux 取消清理的交付归属。 + +完整解析通过不能替代完整 IR 数值、完整权重独立装载、完整模型 QEMU 前向或真实文本生成。阶段安排见 [开发计划](../开发计划.md)。 diff --git "a/docs/llm-deploy-v1.0/W2/W1\344\277\256\345\244\215\344\270\216W2\346\234\254\345\234\260\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/llm-deploy-v1.0/W2/W1\344\277\256\345\244\215\344\270\216W2\346\234\254\345\234\260\351\252\214\346\224\266\346\212\245\345\221\212.md" new file mode 100644 index 00000000..1cde2ae3 --- /dev/null +++ "b/docs/llm-deploy-v1.0/W2/W1\344\277\256\345\244\215\344\270\216W2\346\234\254\345\234\260\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -0,0 +1,9 @@ +# W1 修复与 W2 历史验收记录索引 + +本文件名保留以兼容已有链接。当前正式交付、CI 和独立复现统一使用 **Ubuntu 24.04 x86_64 / Bash / Python 3.12**,从 [Linux 复现约定](../LINUX_REPRODUCTION.md) 和 [W2 七项统一入口](README.md) 开始。 + +原报告记录 2026-10-03 发布前的个人混合工作树,包含 W1 修复与 W2 增量,既不是独立 W2 分支的精确源码,也不是 Linux 实测。原始测量和当时状态完整保留于 [固定提交归档](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W2/W1修复与W2本地验收报告.md)。没有修改原始平台标签、计数、误差或耗时来生成新的通过结论。 + +已交付的修复包括报告写入失败与源码归属、标量常量绑定一致性、Linux 嵌套会话退出清理、七项统一验收及 CI 缺失/跳过检测。具体代码归属与 Linux run 见 [W2 分支验收与发布说明](W2-分支验收与发布说明.md);W1 报告修复单独见 [PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93)。 + +新复现必须在指定 SHA 上自行生成报告,记录 Linux 环境、实际退出码、源码与模型身份以及误差。旧原始数据可用于调查历史问题,不能代替新提交执行或第二人确认。 diff --git "a/docs/llm-deploy-v1.0/W2/W2-\345\210\206\346\224\257\351\252\214\346\224\266\344\270\216\345\217\221\345\270\203\350\257\264\346\230\216.md" "b/docs/llm-deploy-v1.0/W2/W2-\345\210\206\346\224\257\351\252\214\346\224\266\344\270\216\345\217\221\345\270\203\350\257\264\346\230\216.md" new file mode 100644 index 00000000..fc5b57d7 --- /dev/null +++ "b/docs/llm-deploy-v1.0/W2/W2-\345\210\206\346\224\257\351\252\214\346\224\266\344\270\216\345\217\221\345\270\203\350\257\264\346\230\216.md" @@ -0,0 +1,27 @@ +# W2 Linux 分支验收与发布说明 + +正式复现环境为 Ubuntu 24.04 x86_64 / Bash / Python 3.12。环境与工具准备见 [Linux 复现约定](../LINUX_REPRODUCTION.md),七项统一入口和实际 CI 链接见 [W2 README](README.md)。 + +W1 两层报告与参考测试修复见 [PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93)。W2 初次独立交付基于 PR #91 合并提交 `3bb88e81498d6d9293aed70e25a4e5a2104465d0`;[PR #94](https://github.com/ScratchV-Compiler/ScratchV/pull/94) 既有 Linux 执行对应 `2d07ccc42936dcfde19d552bca02a324cd33821c`。新修改按新 SHA 验收,不能继承旧 CI PASS。 + +## 交付范围 + +- 固定完整 28 层 ONNX 的结构、前端解析、IR verifier、逐节点和真实权重绑定审计。 +- RMSNorm/RoPE/SwiGLU/GQA 前端模式;七类基础后端的 16 小图、32 次 QEMU。 +- 官方 Tokenizer、输入/mask、最后有效位置 greedy/停止条件,以及完整词表两层随机小配置的单步模型集成。 +- 七项统一验收、完整前端 PR 必跑任务,以及拒绝失败、取消、跳过和缺失的 CI 汇总。 +- 标量 IR 常量外部绑定一致性;保存日志失败时保留主错误;Linux 取消时清理独立编译器/QEMU 会话。 + +## Linux 验证与源码归属 + +历史 SHA `2d07ccc42936dcfde19d552bca02a324cd33821c` 的通用 CI、LLM Deploy、Topic 06 均成功。通用 CI 为 2927 passed / 7 skipped;Linux 统一验收单测 55/55、无跳过;LLM 记录基础 QEMU 32/32、两层 QEMU 28/28、显式优化产物 3/3、退出清理 20/20。两层 QEMU 诊断最大绝对误差 `1.7285346984863281e-6`,小于 `1e-5`。原始 run 链接在 [W2 README](README.md)。 + +跳过项不计为通过;按 PR 条件未运行的完整 ONNX ORT 重型任务不计为完整 ORT 重验。上述工作流集合与作者运行统一七项 CLI、第二人完整独立执行,是不同证据,分别记录。 + +发布前个人工作树的计数、数值和耗时保留于 [固定提交中的原报告](https://github.com/ScratchV-Compiler/ScratchV/blob/2d07ccc42936dcfde19d552bca02a324cd33821c/docs/llm-deploy-v1.0/W2/W2-分支验收与发布说明.md),不迁移成 Linux 测量值。早期 W1/W2 混合工作树记录见 [历史归档说明](W1修复与W2本地验收报告.md),不代表独立 W2 分支的精确源码。 + +## 复现与限制 + +运行统一入口时每次使用不存在的新输出目录。保留 Linux 版本、CPU、依赖和工具版本、源文件哈希、模型/ELF 身份、原始命令、误差及仿真墙钟;七项完整通过才能记为 PASS。新的提交需要新的实际运行证据。 + +完整图解析不等于完整 0.6B 的 IR/QEMU 数值前向。当前 Attention 使用已定义语义的基础算子组合;完整词表单步模型使用随机小配置,不具备预训练模型语言能力。第二人独立复现和团队接口确认仍由参与者完成;完整权重装载、生成循环等后续阶段不能由本分支的小模型结果代替。 diff --git "a/docs/llm-deploy-v1.0/W3/Linux-CI\344\270\216Nightly.md" "b/docs/llm-deploy-v1.0/W3/Linux-CI\344\270\216Nightly.md" new file mode 100644 index 00000000..8cda98c9 --- /dev/null +++ "b/docs/llm-deploy-v1.0/W3/Linux-CI\344\270\216Nightly.md" @@ -0,0 +1,236 @@ +# W3 Linux CI 与 Nightly + +对外复现统一使用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 统一环境与资产准备](../LINUX_REPRODUCTION.md),并在同一个 Bash 会话、仓库根目录执行以下命令;`SCRATCHV_PYTHON`、`SCRATCHV_CC`、`SCRATCHV_QEMU` 由该指南设置。 + +配置已随 [PR #96](https://github.com/ScratchV-Compiler/ScratchV/pull/96) 提交。2026-10-05,作者在提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 的[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)中完成同提交 preparation、完整七组 full-numeric 和汇总,均为 success。preparation 下载已复核;完整 reports/raw 均已下载验真,七组原始数组离线 audit 通过。前三轮失败与历史数值 profile 原样保留。下面区分实际运行记录与通用命令模板;没有设置 Nightly 远端开关,手动调用不等于 schedule,也不替代他人独立复现。团队确认内容见 [独立复现与验收清单](独立复现与验收清单.md)。 + +## 2026-10-05 Linux 实测记录 + +### 第一轮:preparation 通过,完整模型失败 + +运行:[37290898463 / attempt 1](https://github.com/yuki-328/ScratchV/actions/runs/37290898463),仓库 `yuki-328/ScratchV`,提交 `2e5f9f4817812a43bd9587c8f79fab43e068b1d9`。触发方式为作者 `workflow_dispatch` → `run_w3_validation=true` → W3 reusable 编排;没有更改默认分支或开启定时任务。该记录证明作者代码在 GitHub 托管 Linux runner 上实际执行,不能登记为 E1/E2/E5 三人的独立复现。 + +| 检查 | 实际结果 | 用时 | +|---|---|---:| +| preparation 回归 | 1203 passed、1 skipped;跳过项仅适用于非 Linux 平台进程所有权兼容测试 | 46.80 s | +| 六层 medium | 7/7 输入、81 检查点、18 不变量;各项对比最大绝对误差 `2.771615982055664e-6 < 1e-5` | 97.052790 s | +| 真实权重子图 | 14/14 输入;各项对比最大绝对误差 `3.0517578125e-5 < 1e-4` | 5.894554 s | +| 小 Attention 后端 | 12/12 执行;QEMU vs ORT 最大绝对误差 `3.5762786865234375e-7 < 1e-4` | 22.863963 s | +| 完整资产预检 | 固定资产与 7847 节点图通过;不执行完整模型数值 | 2.158714 s | +| layer-diff | 81/81 检查点;最大绝对误差 `2.205371856689453e-6 < 1e-5`,无首个分歧 | 包装进程 0.385691 s | +| preparation 汇总 | 五项 PASS;12 次 QEMU 进程时间合计 `0.952405885 s` | 总计 132.844285 s | +| 完整七组 full-numeric 首跑 | FAIL;`short_17` 输出误差超标,`changed_padding` 的父进程资源观察失败;其余五组通过 | 693.543067 s | +| 完整七组原始数组离线复核 | 首跑失败产物待下载复核;不能登记为完整数值 PASS | 待完成 | + +完整模型首跑报告保留 `FAIL`:`short_17` 的普通/诊断 logits 最大绝对误差均为 `0.00013637542724609375`,超过严格 `<1e-4`;`changed_padding` 的 ORT 子进程返回 0,但父进程记录 `Cannot observe worker ... resident memory`,该 case 未被接受。其余五组数值通过不构成七组全覆盖通过。原始失败报告与源码指纹保持原样,后续修复必须以新提交、新运行记录重新验证;此处仅登记验收事实,不记录数值调查过程。 + +medium 与子图表中的最大误差覆盖各自报告内所有比较,不能当作单一 IR/ORT 指标。子入口用时不包含统一包装器启动与核验开销,QEMU 进程时间包含启动、UART 与退出。preparation 主报告依然明确 `full_ir_executed=false`、`w3_exit_accepted=false`。 + +preparation runner 为 Ubuntu 24.04.5、Python 3.12.14、4 vCPU AMD EPYC 7763;RAM `16,766,414,848` bytes、开始时可用 `15,263,739,904` bytes,磁盘可用 `92,331,950,080` bytes。实际工具为 QEMU 8.2.2、Zig 0.14.1。medium/subgraphs/attention/preflight 子进程 RSS 峰值分别为 `649,732,096` / `798,867,456` / `105,816,064` / `147,361,792` bytes;这些不是整个 runner 或编译器/QEMU 子孙进程的合计峰值。环境、线程、依赖原始记录随 artifact 保存。 + +preparation 产物上传成功且已下载核对: + +| 产物 | ZIP 大小 | ZIP SHA256 | +|---|---:|---| +| [w3-preparation-reports](https://github.com/yuki-328/ScratchV/actions/runs/37290898463/artifacts/11336224886) | 1,903,761 bytes,29 文件 | `a83ab4c192e0b3c644b0aa6334dc9d731018f789fe1be131fdc2a4fcb4a19594` | +| [w3-preparation-diagnostics](https://github.com/yuki-328/ScratchV/actions/runs/37290898463/artifacts/11337260339) | 7,378,424 bytes,65 文件 | `10c1951a3f44b770076aaf753f000e86061a768941b7b59b59251778e8631056` | + +诊断清单中的 63 个证据文件逐文件哈希匹配、无遗漏;另外两份文件为收集清单本身。下载后的 medium 原始数组已在作者本机离线重算,layer-diff 再次 81/81 PASS,最大误差 `2.205371856689453e-6`。这验证了迁移后选定证据可读且自洽,没有再次执行模型,也没有验证全部 preparation 原始数组。 + +同提交[通用 CI](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37290861920)为 3727 passed、5 skipped,[W1/W2](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37290862687)与 [Topic06](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37290861847)均通过。同次 fork 运行的 W1 完整 ONNX 校验也通过:有效长度 5/256 两组输出均为有限 FP32 `[1,256,151936]`,总用时 26.701271 s。上述结果分别证明对应范围,不替代完整 W3 七组验收。 + +### 第二轮:完整模型通过,preparation 失败 + +运行:[37294081510](https://github.com/yuki-328/ScratchV/actions/runs/37294081510),提交 `ca110be3654b99cb4caebdab5eff5dc2e6994a7f`,checkout clean。完整模型主报告及 worker 报告已下载并核对;本轮 `numeric:ir-full-qwen3` 为 PASS,七组输入的普通/诊断完整 logits(含 padding)最大绝对误差均为 0,四项不变量也均为 0,总用时 `456.723772664 s`。实际 profile 为 `numpy-fp32-reference-v3`、`cpu_strategy=avx512`。 + +该轮 preparation 为 FAIL:子图仅 12/14 通过,其余四项准备检查通过。因此整套 W3 编排不通过;首轮 preparation PASS 不能代替该提交上的失败结果。完整模型主报告 SHA256 为 `e34c20c72407ae926949a04b65eb5147534eac2ed170bd63ab6758e740a9f0c8`;报告下载核对不等于原始七组数组的离线复算。后续完整 raw 数组下载与 audit 按实际新提交和运行单独记录,不能提前沿用本轮 PASS。 + +前两轮原始报告、失败状态、源码和数值 profile 均保持原样。 + +### 第三轮:preparation 通过,完整模型失败 + +运行:[37295229214](https://github.com/yuki-328/ScratchV/actions/runs/37295229214),提交 `8132f3ce5f4e4892663a6bc2ee15ad59dc891f28`,checkout clean。[preparation job](https://github.com/yuki-328/ScratchV/actions/runs/37295229214/job/111714763982) 已通过;完整模型已确认 FAIL,整套编排未通过;后续修复须另存新提交和运行记录。 + +| 检查 | 第三轮实际结果 | 用时 | +|---|---|---:| +| preparation 回归 | 1270 passed、1 skipped;skip 仅为非 Linux 平台进程所有权兼容测试 | 49.64 s | +| 六层 medium | 7/7 输入、81 检查点、18 不变量;全部比较最大绝对误差 `2.771615982055664e-6 < 1e-5` | 99.530785 s | +| 真实权重子图 | 14/14 输入、10 不变量;全部比较最大绝对误差 `3.0517578125e-5 < 1e-4` | 5.807397 s | +| 小 Attention 后端 | 12/12 执行;QEMU vs ORT 最大绝对误差 `3.5762786865234375e-7 < 1e-4` | 23.216017 s | +| 完整资产预检 | 固定资产及图契约通过;仍不是完整模型数值执行 | 2.189634 s | +| layer-diff | 81/81 检查点;最大绝对误差 `2.205371856689453e-6 < 1e-5` | 包装进程 0.387957 s | +| preparation 汇总 | 五项 PASS;12 次 QEMU 进程时间合计 `1.018794338 s` | 总计 135.615884 s | +| 完整模型和整套编排 | FAIL;preparation 通过不能替代完整模型门槛 | 后续修复与复跑待完成 | + +preparation 主报告 SHA256 为 `2f12bdf97f18356829c6222bc86e2da7175983619e17c33cacdac12dd63b3ab1`。runner 为 Ubuntu 24.04.5、Python 3.12.14、4 vCPU AMD EPYC 7763、RAM `16,766,414,848` bytes;开始时可用内存 `15,431,999,488` bytes,磁盘可用 `92,331,909,120` bytes。工具仍为 QEMU 8.2.2、Zig 0.14.1。medium/subgraphs/attention/preflight 子进程 RSS 峰值分别为 `652,468,224` / `807,346,176` / `106,446,848` / `147,103,744` bytes,口径与第一轮相同,不包含整个 runner 或全部子孙进程。 + +| 第三轮下载产物 | ZIP 大小 | 已核对 ZIP SHA256 | +|---|---:|---| +| [w3-preparation-reports](https://github.com/yuki-328/ScratchV/actions/runs/37295229214/artifacts/11338866162) | 1,905,927 bytes,29 文件 | `1ebdc88ed7f3bb4ae7e076db90b6f990796e7ab6316282d1d4df70124f370089` | +| [w3-preparation-diagnostics](https://github.com/yuki-328/ScratchV/actions/runs/37295229214/artifacts/11338910911) | 7,378,475 bytes,65 文件 | `f1cbb7289e29a383b425871cd18c032f6fe4ddf50e5c7b587303405b94479d59` | + +下载后的诊断清单 63 个证据文件共 `21,516,508` bytes,大小及 SHA256 全部匹配,`selection_complete=true`、无 omissions。另两份文件是清单本身。作者本机重新比较下载的 medium `short_17` 原始数组,81/81 PASS、最大误差 `2.205371856689453e-6`;本地复核输出为 `output/w3-linux-37295229214/layer-diff-download-audit-01/report.json`。这仍仅是选定数组迁移后的离线复算,没有再次执行模型,也不替代完整七组 audit 或他人独立复现。 + +### 第四轮:preparation、完整模型与汇总均通过 + +运行:[37299235103 / attempt 1](https://github.com/yuki-328/ScratchV/actions/runs/37299235103),提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`,远端 checkout clean。本轮使用已提交的 `numpy-fp32-reference-v4`,通过既有手动入口调用同提交 preparation、全部七组 full-numeric 及汇总。preparation、full-numeric 和整套汇总均为 success;这是同一提交上的完整作者手动 Linux 验证。preparation 下载已复核,完整 reports/raw 产物均已下载并核对 GitHub digest,七组原始数组离线 audit 也已通过。前三轮原始失败结果与原数值 profile 继续保留,不用本轮结果覆盖。 + +| 检查 | 第四轮实际结果 | 用时 | +|---|---|---:| +| preparation 回归 | 1297 passed、1 skipped;skip 为非 Linux 平台进程所有权兼容测试;真实 Haswell BLAS 回归通过 | JUnit 50.074 s | +| 六层 medium | 7/7 输入、81 检查点、18 不变量;全部比较最大绝对误差 `2.771615982055664e-6 < 1e-5` | 97.368098 s | +| 真实权重子图 | 14/14 输入、10 不变量;全部比较最大绝对误差 `3.0517578125e-5 < 1e-4` | 5.775279 s | +| 小 Attention 后端 | 12/12 QEMU 执行;QEMU vs ORT 最大绝对误差 `3.5762786865234375e-7 < 1e-4` | 22.837814 s | +| 完整资产预检 | 固定资产及 7847 节点图契约通过;不执行完整模型数值 | 2.164485 s | +| layer-diff | 81/81 检查点;最大绝对误差 `2.205371856689453e-6 < 1e-5` | 包含于总计 | +| preparation 汇总 | 五项 PASS;12 次 QEMU 进程时间合计 `0.913058207 s` | 总计 132.923089 s | +| full worker 专项回归 | 581 passed、0 skipped | 15.90 s | +| 完整七组 full-numeric | v4/avx2-fma3 下普通及诊断完整 logits 最大误差 0;每组 30 检查点、4 项不变量的最大绝对误差均为 0 | 总计 1477.314972 s(约 24.6 分钟) | +| 整套手动编排 | preparation、full 与汇总均 success;不是团队正式验收 | 以 Actions 日志为准 | + +preparation 主报告 SHA256 为 `ceeeb239ac7b88c22372d0e76f5506841e1b634a81b5b1567af35ca84d443046`。实际环境为 Ubuntu 24.04.5、Python 3.12.14、4 vCPU AMD EPYC 7763;NumPy 2.2.6、ONNX 1.18.0、ORT 1.22.1、Torch 2.7.1+cpu。NumPy 运行诊断记录 AVX2/FMA3,可选库诊断实际读取到 OpenBLAS 0.3.29 的 `core_name=Haswell`;`OPENBLAS_CORETYPE` 未设置(`null`)。这些是分别记录的 CPU 和库事实,不推断 ORT 内部内核。同提交[通用 CI](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37299215953)为 3821 passed、5 skipped,真实 Haswell 回归及 17 项 BLAS 环境证据测试在 Linux 实际通过;[W1/W2 工作流](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37299217144)和 [Topic06](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37299216093)通过。两层小模型 QEMU 为 28/28 通过、最大误差 `1.78813934326e-6`、QEMU 进程用时合计约 146.393 秒;显式产物校验为 3 passed、0 skipped。上述各项分别记录自己的验证范围。 + +| 第四轮下载产物 | ZIP 大小 | 已核对 ZIP SHA256 | +|---|---:|---| +| [w3-preparation-reports](https://github.com/yuki-328/ScratchV/actions/runs/37299235103/artifacts/11341510503) | 1,908,329 bytes,29 文件 | `0437e1cc3bc9c063ee3b361ed027438bb12939a037dc83a3d237b4b6046df3ec` | +| [w3-preparation-diagnostics](https://github.com/yuki-328/ScratchV/actions/runs/37299235103/artifacts/11341395639) | 7,378,481 bytes,65 文件 | `7516a668991bc97ffb444265aa03d899d3a703dbbd3d72e7081e217cc37ffd02` | + +诊断清单 63 个证据文件共 `21,516,508` bytes,逐文件大小及 SHA256 全部匹配,无 omissions 或额外文件;另外两份为清单本身。核对记录位于 `output/w3-linux-37299235103/preparation-independent-audit.json`。下载后的 medium `short_17` 数组另行重算 81/81 PASS,最大误差 `2.205371856689453e-6`,报告位于 `output/w3-linux-37299235103/layer-diff-download-audit-01/report.json`。这是作者对迁移后选定证据的复核,不是第二人重新执行模型,也不等于完整七组 audit。preparation 报告仍为 `full_ir_executed=false`、`w3_exit_accepted=false`。 + +完整 full 主报告 SHA256 为 `0ddb97d13b6bab44442fb0b453d1e0132ef7a02c96be9c8caf70ae2862e31746`,`git.head=1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`、`dirty=false`。七组全部普通/诊断 logits(包含所有 padding query)严格 `<1e-4`,本次最大误差为 0;每组 30 检查点与四项不变量的最大绝对误差也均为 0。实际 reference profile 为 `numpy-fp32-reference-v4/avx2-fma3`,NumPy 已装载 OpenBLAS 0.3.29、`core_name=Haswell`,`OPENBLAS_CORETYPE=null`。这是该提交、资产与环境的实测结果,不承诺任意平台逐位一致。 + +完整 runner 耗时 `1477.314971525 s`,包括加载、普通/诊断前向、比较及证据写盘,并非单次前向性能 benchmark。七组 ORT worker 每组总用时约 15.20–15.53 秒,IR worker 每组约 193.45–195.88 秒。worker 自报全生命周期 RSS 峰值范围:ORT `4,443,017,216–4,443,987,968` bytes,IR `3,102,253,056–3,102,715,904` bytes。父进程采样的进程树 RSS 范围分别为 `4,443,328,512–4,444,389,376` 与 `2,943,205,376–3,057,094,656` bytes;采样可能错过瞬时峰值,不与自报值混用,也不是整个 runner 的总峰值。full runner 为 AMD EPYC 7763、4 vCPU,RAM `16,766,414,848` bytes,开始时可用内存 `15,577,612,288` bytes、可用磁盘 `92,333,924,352` bytes。 + +| 第四轮完整模型产物 | 大小 / SHA256 | 当前核对状态 | +|---|---|---| +| [w3-full-numeric-reports](https://github.com/yuki-328/ScratchV/actions/runs/37299235103/artifacts/11341033619) | 819,824 bytes;`46047d41334f3336eaf2e39385bffca37551a726891ad6c671c23df1e60f88c5` | 已下载,ZIP digest 与 GitHub 记录一致;主报告及 worker 报告已核对 | +| [w3-full-numeric-raw](https://github.com/yuki-328/ScratchV/actions/runs/37299235103/artifacts/11341924764) | 4,350,864,947 bytes,140 文件;`b99c06f3e39c7c465d31763eba98350682961de1541cab719ce3da1df2ee4171` | 已下载,ZIP digest 与 GitHub 记录一致;完整七组离线 audit 通过 | + +下载后的完整 raw 已通过 `audit:w3-full-saved-evidence`,7 组 case、4 项不变量均重新核对,`coverage_complete=true`,`trusted_report_hash_checked=true`、`pinned_model_files_checked=true`,用时 `21.234900 s`。audit 报告位于 `output/w3-linux-37299235103/full-evidence-download-audit-01/report.json`,SHA256 为 `804cc0db397af3dd7fe1696635357999635f0a45cb409410d6bcd6f9b5db2bae`。该核对没有重新执行模型:`model_executed=false`、`independent_reproduction=false`。 + +原 audit 记录 `source_comparison.matches_current=false`、129 个字节哈希差异。随后只读核对 `1c49e8a` 的 Git blob:159 个生产源码哈希全部与该提交 blob 吻合,129 个本地差异仅为 CRLF/LF 换行,其余 30 个文件逐字节相同;记录位于 `output/w3-linux-37299235103/source-checkout-line-ending-audit.json`(`all_producer_match_committed_blobs=true`、`all_working_copies_identical_or_crlf_only=true`)。原 audit 和生产报告均未改写;这项核对解释源码身份,不将数组 audit 当成重新执行。 + +当前作者在同提交上的两套 Linux gate、汇总、产物下载及完整数组 audit 均已完成。作者手动远端实跑、作者下载数组 audit、E1/E2/E5 独立执行、定时 schedule 和团队签认仍是不同事项;本轮保留 `w3_exit_accepted=false`,没有启用定时 Nightly。 + +## 入口与验收范围 + +三套 W3 门禁已是 [.github/workflows/llm-deploy.yml](../../../.github/workflows/llm-deploy.yml) 中的三个 job,与 W1/W2 同属一个工作流文件。 + +| Job(均在 `llm-deploy.yml`) | 手动入口 | 参数 | 通过条件 | +|---|---|---|---| +| `w3-preparation` | 手动 `run_w3_validation=true`,或定时且 `W3_NIGHTLY_ENABLED=='true'` | 无 | 相关回归和五项 preparation 全部成功,证据收集完整 | +| `w3-full-numeric` | 同上 | `w3_case`,默认 `all`;`short_17` 仅诊断 | 完整七组真实 28 层 IR/ORT 数值门槛及不变量全部通过 | +| `w3-acceptance` | 同上 | 无 | 上游两个 job 都为 `success`;失败、取消或跳过均不能通过汇总 | + +三个 job 同属一个文件并以 `needs` 显式串联,因此必然来自同一提交;没有从其他分支临时取最新版脚本。 + +完整比较固定 `--fp32-mode reference`,当前已提交 profile 是 `numpy-fp32-reference-v4`;默认运行全部七组输入。`SCRATCHV_FP32_REFERENCE_CPU` 允许 `auto`(默认)、`avx2-fma3`、`avx512`,决定 IR 参考算术策略,不控制 ORT dispatch。报告保存解析后的 `cpu_strategy`;当前工作流未显式覆盖该变量,因此按 runner 能力自动选择。数值门槛仍为全部普通/诊断 logits 的严格 `max_abs < 1e-4`,含 padding query,`rtol=0`。选择 `short_17` 即使数值正确也返回 `PARTIAL/2`,工作流保持失败状态,不能用单例绿色代替完整验收。`native` 模式和 W4 完整 RISC-V/QEMU 前向不在这套完整 IR 门槛的通过范围内。首跑记录仍属于当时的 v2 契约,不能用当前说明覆盖其原 profile;第二轮 v3/avx512 完整数值通过仅属于该轮提交与环境。 + +## 先做 Linux 手动验证 + +若 W3 新入口尚未进入默认分支,但仓库已注册并运行过 `LLM Deploy v1.0`,可从该既有手动入口选择包含 W3 代码的分支,显式设置 `run_w3_validation=true`。它从同一提交复用 W3 完整编排,执行 preparation、七组 full-numeric 和汇总;原 W1/W2 任务也照常执行。默认值为 false,普通 PR 和原 schedule 不会因此自动增加 W3 重型任务。首次远端通过前仍须按实际日志核对,手动调用不算定时 Nightly 记录。 + +```bash +set -euo pipefail +gh workflow run llm-deploy.yml --repo YOUR_ACCOUNT/ScratchV --ref YOUR_W3_BRANCH -f run_w3_validation=true +``` + +这个入口不要求更改默认分支,也不设置 Nightly 开关。若既有入口也未注册,则仍需按下列默认分支条件准备入口。[GitHub 手动触发与目标 ref](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_dispatch) + +1. 选定个人 fork 或有写权限的运行仓库及包含完整 W3 改动的提交;代码现已在 PR 中,不需要重新创建一笔 W3 PR。复核目标分支的实际 SHA,先保持 `W3_NIGHTLY_ENABLED` 未设置;这一步不会启动定时重计算。 +2. 确认仓库已启用 Actions,并允许本工作流使用的 actions/reusable workflows。手动入口文件必须已经存在于默认分支,才能正常通过 `workflow_dispatch` 发起;之后可以选择含 W3 修改的目标分支。仅上传到一个没有默认分支入口的功能分支,不应预期必然出现 Run workflow 按钮。[GitHub 手动运行说明](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow) +3. 在 Actions 页面运行 `LLM Deploy v1.0` 的手动入口并勾选 `run_w3_validation`,一次即可执行两套 W3 门禁;只需单例诊断时把 `w3_case` 选为 `short_17`(该值即使数值正确也不会通过验收)。记录 run URL、commit SHA 和 attempt,确认两套结果来自同一代码版本。 +4. 检查资源日志、依赖安装、资产获取与哈希、回归、QEMU、数值比较和产物上传。需要整个流程完成,不能只看某一步为绿。 +5. 在原始产物过期前下载留存,按下文核对。出现 Linux 数值差异时保留失败数组和环境信息,用已有 layer-diff/localize 入口定位;不得通过缩减 case、忽略 padding 或放宽阈值取得通过。 + +需要命令行时,可在确定运行仓库和分支后使用以下模板;替换占位符。实际已执行记录见本文首节,命令模板本身不是运行证据: + +```bash +set -euo pipefail +gh workflow run llm-deploy.yml --repo YOUR_ACCOUNT/ScratchV --ref YOUR_W3_BRANCH -f run_w3_validation=true +gh run list --repo YOUR_ACCOUNT/ScratchV --workflow llm-deploy.yml +gh run view RUN_ID --repo YOUR_ACCOUNT/ScratchV +gh run download RUN_ID --repo YOUR_ACCOUNT/ScratchV --dir downloaded-w3-run +``` + +手动运行需要仓库写权限;工作流自身仅请求 `contents: read`,checkout 不持久保存凭据,不使用发布或仓库写入权限。Actions 产物由标准 `upload-artifact` 步骤保存,不是 Git/LFS 提交。 + +## 环境与资源记录 + +两套执行工作流固定为 `ubuntu-24.04`、Python 3.12、CPU Torch 2.7.1 和仓库的固定依赖清单;CI 配置测试额外固定 PyYAML 6.0.3。准备项安装 Zig 0.14.1 和 Ubuntu 的 `qemu-system-misc`,记录实际版本。Ubuntu 镜像补丁、apt 包和传递依赖仍可能更新,因此保留 `pip freeze` 及实际系统信息,不能将一个版本标签当成完整镜像指纹。 + +preparation 的 checkout 显式设置 `fetch-depth: 0`:其 `tests/test_onnx*.py` 回归包含真实历史版本与当前版本的算子 benchmark,需要用 `git archive` 读取固定历史基线。只拉取最新一次提交会缺少该对象,不能正常执行这项回归。完整模型 workflow 的测试不依赖该历史,因此保留默认浅克隆。源码压缩包本身不含 Git 历史,不能把源码包里的历史 benchmark 检查等同于 Git checkout 下的历史比较。 + +- `w3-runner-resources.txt`:commit、run ID、attempt、发行版、内核、CPU、内存、磁盘容量和工具版本。 +- `w3-dependencies.txt`:实际安装依赖版本。 +- 数值报告的 `environment.numeric_runtime`:线程环境、CPU/NumPy 构建与运行诊断;进程资源与各阶段耗时继续按原报告记录。 +- `environment.numeric_runtime.blas_environment.OPENBLAS_CORETYPE`:启动时环境变量原值,未设置时为 `null`;设置值本身不证明 OpenBLAS 已采用该内核。 +- `environment.numeric_runtime.numpy_openblas`:可选的已装载 NumPy 私有 OpenBLAS 库诊断,记录总体 `status` 和 `libraries`;可用的库项含 `path`、`status`、`core_name`、`config`,不可用时保留 `reason` 或 `error`。它不通过额外装载系统库来补造信息。 + +CPU 的 SIMD 支持、IR 选择的 `cpu_strategy` 和 NumPy 实际 BLAS 内核是不同信息,必须分别保留。系统 BLAS/MKL 等未被此可选诊断覆盖时,`unavailable` 或字段缺失只表示无法取得该项诊断,不能推断内核,也不能使任何数值失败变成通过;完整七组门槛不变。上述 NumPy 诊断不证明 ORT 内部采用哪个内核。 + +完整流程每个 worker 超时 1800 秒、进程树 RSS 上限 8 GiB,job 超时 60 分钟。这些是失败保护上限,不是资源已足够或预计用时的证明。准备和完整模型在两个不同 runner 上执行;父进程、系统和依赖也占内存。公开仓库标准 x64 Ubuntu runner 的官方配置当前为 16 GB RAM;私有仓库标准配置较小,不能默认满足这套容量要求。选择 runner 前应核对实际资源和账户设置。[GitHub runner 规格](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) + +远端首跑还要核对资产下载、安装缓存及全部原始数组的磁盘占用,避免只按模型文件大小估算。OOM、磁盘不足、网络下载失败或 job 超时都属于本次 CI 未通过;应保留日志后调整资源/下载策略,不能将未完成执行计为数值通过。 + +## 产物与失败定位 + +| Artifact | 保存期限 | 用途 | +|---|---|---| +| `w3-preparation-reports` | 30 天 | 报告、测试 XML、日志、环境与收集清单 | +| `w3-preparation-diagnostics` | 7 天 | 选定 layer-diff 轨迹及失败 case 数组,受收集器上限约束 | +| `w3-full-numeric-reports` | 30 天 | 完整门槛报告、进度、哈希/schema、环境与测试 XML | +| `w3-full-numeric-raw` | 3 天 | 报告及全部已生成 inputs/logits/diagnostic logits/checkpoints,供离线核对 | + +下载后先检查完整模型 `status=PASS`、七组覆盖齐全、数值模式/profile 符合约定、四项不变量通过、源码/模型指纹匹配;准备项则检查五项全部通过与收集清单没有遗漏。报告 JSON 的原有路径可能指向 runner 的绝对目录;迁移后的离线核对应显式指定下载目录,不能默认旧绝对路径仍存在。 + +完整 raw artifact 内的相对根路径为 `w3-full/`。用 `gh run download` 指定单个 `-n` 时,它直接解压到 `--dir`;所以传给离线工具的是 `--dir` 下的 `w3-full`,不能把下载根目录直接当作模型证据根。下面使用新目录,先分别下载 reports 与 raw,再要求两份主报告字节哈希一致: + +```bash +set -euo pipefail +W3_REPO=YOUR_ACCOUNT/ScratchV +W3_RUN_ID=RUN_ID +W3_DOWNLOAD=output/downloaded-w3-RUN_ID +gh run download "$W3_RUN_ID" --repo "$W3_REPO" \ + -n w3-full-numeric-reports --dir "$W3_DOWNLOAD/reports" +gh run download "$W3_RUN_ID" --repo "$W3_REPO" \ + -n w3-full-numeric-raw --dir "$W3_DOWNLOAD/raw" +W3_REPORT_SHA=$("$SCRATCHV_PYTHON" -c 'import hashlib,pathlib,sys; print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())' "$W3_DOWNLOAD/reports/w3-full/report.json") +printf '%s\n' "$W3_REPORT_SHA" +"$SCRATCHV_PYTHON" -B -X utf8 scripts/verify_w3_evidence.py \ + --evidence-dir "$W3_DOWNLOAD/raw/w3-full" \ + --output-dir output/w3-ci-evidence-audit-RUN_ID \ + --expected-report-sha256 "$W3_REPORT_SHA" +``` + +先核对 Actions run 的完整 SHA、attempt、结果和 artifact 身份,再单独保存 `W3_REPORT_SHA`。这里从 reports 产物取哈希,用它校验 raw 中的报告,能发现两包混用;两包本身来自同一次运行,不构成额外的发布者身份认证。若发件人已通过另一可信渠道给出报告 SHA,应直接用该值作为 `--expected-report-sha256`。Linux 也可用 `sha256sum` 读取报告哈希。每次 audit 使用新输出目录,检查返回码以及生成的 `report.json`。 + +完整原始数组可以用 [复现清单](独立复现与验收清单.md) 中的离线复核入口重新核对。仅有报告和哈希不能重新计算数组误差;选定 preparation 轨迹也不能替代完整七组数组。v4 auditor 依据报告保存的 CPU 策略核对完整规范 profile(包括 MatMul 布局字段),不根据下载者 CPU 推断生产策略,不需要为数组核对更改本机 CPU 策略;旧 v2/v3 证据必须使用对应历史源码的验证器,不能手改报告升级。`audit:w3-full-saved-evidence` PASS 表示保存证据满足其契约,`source_comparison` 明列生成时与当前审计源码的差异;它不执行 ORT/IR,也不能替代第二人在其环境重新执行模型。 + +上传和证据保留步骤使用 `always()`,以尽可能保存失败现场;它们不清除前面的失败状态。runner 强制终止、job 超时或上传服务失败仍可能导致产物不全,需要检查实际上传结果。仓库设定的保留期限上限和存储额度也需由维护者核对。 + +## 何时启用 Nightly + +先取得同一提交在 Linux 手动完整运行通过的记录,完成资源和产物检查,再与维护者确认运行频率及验收口径。维护者在仓库 Settings → Secrets and variables → Actions → Variables 设置: + +```text +W3_NIGHTLY_ENABLED=true +``` + +只使用非敏感仓库变量,不需要创建访问令牌。变量未设置、为空或为 `false` 时,定时触发的数值任务与汇总任务全部跳过;GitHub 可能仍显示一次被跳过的 schedule 记录,这不代表 Nightly 已通过。需要停用时移除变量或设为 `false`。手动 dispatch 是一次明确运行,允许在定时开关关闭时进行验证。[GitHub 变量与条件说明](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-variables) + +配置为每天 `19:23 UTC`,即北京时间次日 `03:23`;未采用整点以减少排队拥堵。schedule 只运行默认分支最新提交,工作流也必须存在于默认分支。GitHub 高负载时可能延迟或漏掉排队任务,公开仓库长时间无活动也可能停用定时工作流;不能把“没有失败消息”当作每天实际运行成功。[GitHub schedule 规则](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) + +启用后至少检查实际 schedule run 的 SHA、两套 gate、汇总与产物;连续运行次数及观察周期由团队确认。本地静态验证、手动成功和 Nightly 自动成功分别记录,不能相互替代。 + +## 本地配置验证 + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B -X utf8 -m pytest tests/test_w3_workflows.py -q -p no:cacheprovider +actionlint -shellcheck= -pyflakes= .github/workflows/llm-deploy.yml +``` + +测试读取实际 YAML,校验默认全覆盖、手动/复用入口、opt-in 条件、独立并发组、失败汇总和产物范围;通过 Bash 执行实际参数组装及汇总片段,确保 `FAIL/1`、`PARTIAL/2` 不被吞掉,并对所有 shell 块做语法检查。Linux 复现环境必须安装 Bash 并实际执行这些检查;跳过 shell 检查不能作为该部分通过。 + +本轮使用 actionlint 1.7.12 对上述文件静态检查通过;工具放在本地忽略的 output 下,未安装系统服务。actionlint 不执行下载、Python 环境安装、QEMU 或数值模型;实际 Linux 执行与产物核对另见本文首节。 diff --git a/docs/llm-deploy-v1.0/W3/README.md b/docs/llm-deploy-v1.0/W3/README.md new file mode 100644 index 00000000..70405656 --- /dev/null +++ b/docs/llm-deploy-v1.0/W3/README.md @@ -0,0 +1,85 @@ +# W3 完整 Qwen3 IR 验证与交付 + +对外复现统一使用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 统一环境与资产准备](../LINUX_REPRODUCTION.md),并在同一个 Bash 会话、仓库根目录执行以下命令;`SCRATCHV_PYTHON`、`SCRATCHV_CC`、`SCRATCHV_QEMU` 由该指南设置。 + +本目录汇总完整 Qwen3 IR 数值验证、准备探测、诊断与复现交付。W3 已提交至 [PR #96](https://github.com/ScratchV-Compiler/ScratchV/pull/96),本地开发工作树为 `codex/w3-preparation`,原 W2 基线为 `65616a8cde6a581661ac3734378da78a9fcbe5ed`。当前同提交 Linux 全部硬门槛和下载数组 audit 已通过,下一步由团队确认口径、独立复现并验收 Nightly。PR 可能继续更新,复现时按清单获取 head 并记录实际完整 SHA,不能只记录分支名或旧基线。 + +提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 的[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)已完成 preparation 五项、完整七组 full 及汇总,全部通过;v4/avx2-fma3 下普通/诊断完整 logits 最大误差为 0,完整 runner 约 24.6 分钟。通用 CI 为 3821 passed、5 skipped,W1/W2 和 Topic06 通过。preparation 下载诊断已复核;完整 raw 已下载验真、七组数组离线 audit 通过。前三轮失败记录与历史 v2/v3 证据均保留,不能跨提交拼接。定时 Nightly 未启用,E1/E2/E5 团队独立复现尚未登记。各轮结果和下载方法见 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。 + +Linux 预备与完整执行的数据分别见 [预备验收记录](W3-预备工作本地验收报告.md) 和 [完整模型执行记录](W3-完整模型执行与数值诊断报告.md)。早期个人开发机记录只保留为固定提交历史索引,不用于 Linux 验收;本次说明调整没有重新执行模型。 + +Linux 交付入口: + +- [W3 工作总结与验收进度](W3-工作总结与验收进度.md):开发计划对照、改动分组、实测证据及待团队推进的事项。 +- [独立复现与验收清单](独立复现与验收清单.md):候选验收口径、源码快照、实际执行命令,以及 E1/E2/E5 各自填写的记录。 +- [Linux CI 与 Nightly](Linux-CI与Nightly.md):手动 Linux 实测、原始产物下载与离线核对,以及尚未启用的定时编排。 +- [保存证据的离线复核](../../../probes/w3_qwen3_full/EVIDENCE.md):重新读取原始数组核对,不能替代另一位开发者执行模型。 + +优先使用 PR 的固定提交复现;需要交付未提交修改或离线源码时,可通过 `scripts/package_w3_repro.py` 生成带文件哈希和 snapshot ID 的本地源码包。源码包不是新的 Git 提交,不包含模型、运行产物或环境。解压后先校验源码,再按清单准备已有资产和依赖。报告中的历史源码指纹保持原样,旧报告不会自动成为新提交重新执行的证据。 + +完整 28 层的实际 ORT/IR 执行、内存受控模式和同输入局部诊断见 [完整模型入口](../../../probes/w3_qwen3_full/README.md)。完整入口默认采用显式版本化的 `reference` FP32 模式,当前已提交接口为 `numpy-fp32-reference-v4`;`SCRATCHV_FP32_REFERENCE_CPU=auto|avx2-fma3|avx512` 选择 IR 参考算术策略,默认 `auto`,不控制 ORT dispatch。解释器 API 仍默认 `native`;复现时记录并核对模式、完整 profile 和解析后的 `cpu_strategy`。历史 v2/v3 证据保留原契约,应使用对应历史源码核对;这些通过记录不能作为 v4 的验收结果,v4 完整七组已在第四轮实际通过,原始数组已下载验真、离线 audit 通过。统一 preparation 的五项 PASS 只代表准备范围通过,完整模型 gate 另行执行。 + +本轮完成可独立执行的数值与资源准备,不代表 W3 阶段出口通过。开发计划要求的完整 28 层 IR/ORT 数值 <1e-4、W1 人工验收前置、E1/E2/E5 三人复现及 Nightly 仍单独验收。 + +## 交付与范围 + +| 项目 | 本地入口 | 通过含义 | +|---|---|---| +| 完整 Qwen3 IR | [full](../../../probes/w3_qwen3_full/README.md) | 固定真实 28 层、reference-v4 及明确 CPU/MatMul 策略、七输入,普通/诊断完整 logits 含 padding,四项不变量;严格 <1e-4 | +| 六层 medium | [medium](../../../probes/w3_qwen3_medium/README.md) | 官方 Qwen3 类的六层随机小配置,7 输入、81 检查点、普通/诊断双图、none/basic/all;全部 <1e-5 | +| 真实维度子图 | [subgraphs](../../../probes/w3_qwen3_subgraphs/README.md) | 固定 checkpoint 的第一层预训练权重,14 个 L256 子图及 10 项不变量;Torch/ORT/IR <1e-4 | +| 组合 Attention 后端 | [attention](../../../probes/w3_attention/README.md) | Q/K RMSNorm、RoPE、GQA、因果/key-padding mask、Softmax 和 context 组合,6 图 × 2 优化等级,共 12 次实际 QEMU,<1e-4 | +| 逐层误差工具 | [layer-diff](../../../probes/w3_layer_diff/README.md) | 独立验证已有 NPZ/schema 的全部检查点或选定子集;部分比较始终 PARTIAL | +| 完整资产/尺寸预检 | [preflight](../../../probes/w3_full_preflight/README.md) | 固定完整产物哈希、ONNX 契约、静态逻辑字节数通过;不执行完整 ORT/IR | + +medium 候选配置为 6 层、hidden64、FFN192、Q4/KV2、head_dim16、vocab128、L256,304128 个随机参数。开发计划尚未规定 medium 的通道尺寸,本 preset 是可复现的候选,需团队确认,不能当作完整尺寸或预训练六层模型。 + +真实子图采用 hidden1024、FFN3072、16Q/8KV、head_dim128;Q 投影宽度是 2048。其激活为固定种子的合成输入,部分 Q/K/V 由真实投影、归一化和 RoPE 生成,未执行完整预训练 decoder 层。BF16 checkpoint 权重提升到 FP32 计算,来源与哈希记录在报告中。W3 的 1e-4 不改变已有 W2 的 1e-5 门槛。 + +## 环境与统一复现 + +使用 Python 3.12、[固定 CPU 依赖](../../../requirements/qwen3-small-probe.txt)、Zig 0.14.1 和 qemu-system-riscv64。环境安装方法见 [Linux 统一环境与资产准备](../LINUX_REPRODUCTION.md)。固定 HF snapshot 及 ONNX 发布目录均需真实完整资产,不能是 LFS 指针。统一入口离线校验已有资产,不会安装依赖或下载模型。 + +在这个 W3 checkout 的根目录执行;输出目录必须不存在: + +~~~bash +set -euo pipefail +W3_SOURCE=/absolute/path/to/qwen3-source/c1899de289a04d12100db370d81485cdf75e47ca +W3_MODEL=/absolute/path/to/qwen3-0.6b-onnx +"$SCRATCHV_PYTHON" -B -X utf8 scripts/run_w3_preparation.py \ + --source-dir "$W3_SOURCE" --model-dir "$W3_MODEL" \ + --output-dir output/w3-team-preparation-new \ + --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" +~~~ + +五项都实际执行并通过、源码指纹一致、必需报告/产物齐全时,才返回 preparation:w3 的 PASS/0。它不是 numeric:ir-full-qwen3 的通过。失败保留子日志和 FAIL 报告,后续复跑必须换新目录。 + +统一报告位于 output/w3-team-preparation-new/report.json、report.md、report.html;子目录中保存原始张量、模型、ELF、命令和各项报告,logs 目录保存原始执行日志。medium 的 traces// 下保存原始 PyTorch、ORT、IR 张量,trace_schema.json 定义检查点;layer-diff 会独立读取本轮 short_17 的全部 81 个检查点进行核对。CI 配置上传报告、schema、日志、单测 XML、用于 layer-diff 的 actual/reference 原始数组和失败 case 证据;固定完整模型权重及其余成功大数组不上传。 + +准备阶段报告产物保留 30 天,选定诊断产物保留 7 天。诊断收集器默认单文件上限 256 MiB、累计上限 512 MiB;`retained-evidence.json` 记录实际复制的大小、SHA256 和省略原因,收集不完整时返回失败。它仅保留选定轨迹与失败证据,不代替完整 W3 数值验收。 + +子入口具有各自完整报告和退出码;layer-diff 选层、子图缩短序列的成功只产生 PARTIAL/2,不能用作上述完整准备验收。 + +## 内存和耗时口径 + +IRInterpreter.run 新增可选 collect_memory_stats=True,返回结果增加 memory_stats;默认关闭。测量输入/initializer 逻辑字节、每条指令边界的保留张量逻辑峰值、去重 NumPy 底层存储峰值,以及返回值拷贝。它不改变输入复制、张量生命周期、控制流、数值或 D8 异常语义。 + +- peak_live_logical_bytes 包含别名视图按各自形状计算的逻辑大小,可能重复。 +- peak_numpy_storage_bytes 对底层 buffer 去重,包含返回结果的独立拷贝。 +- process_peak_rss_bytes 是所在进程的全生命周期峰值,包括 Torch/ORT/IR 等;不是某个阶段的增量峰值,也不是 IR 工作区。 +- NumPy kernel 临时 buffer、Python 对象和分配器缓存不在解释器的保留值计数中;其进程影响可能反映在 RSS。 +- QEMU 时间包含启动、guest 计算、UART 传输和退出;成功与失败尝试都计入,不能当作目标硬件纯推理性能。 + +上述是预备阶段的默认内存统计。后续完整入口已显式启用只读权重映射、initializer 借用和 last-use 回收,旧调用默认行为保持不变;该容量准备不等于 W6 性能门禁通过,详见完整模型报告。 + +## CI 与后续阶段 + +W3 门禁现已是 [.github/workflows/llm-deploy.yml](../../../.github/workflows/llm-deploy.yml) 的 `w3-preparation`、`w3-full-numeric` 和 `w3-acceptance` 三个 job,不再单独成文。`w3-preparation` 安装固定依赖,下载并校验固定 checkpoint 和 ONNX 资产,运行回归与五项入口。本仓库通过该文件的 `run_w3_validation=true` 显式开启,从同一提交运行 W3 编排;preparation 已实际通过,完整模型状态另行记录。Nightly 定时任务需要仓库变量 `W3_NIGHTLY_ENABLED` 显式启用,手动调用不构成 schedule 运行记录。PR 的通用 CI 通过也不能替代 W3 专项门槛。 + +完整模型 preflight 仅验证资产与静态大小,峰值是 metadata-only 进程,不能替代完整 IR 的容量实测。完整 logits 必须包括 padding 位置;PyTorch/ORT 的导出诊断与正式 IR/ORT 门槛分别记录,不能用局部子图或有效位置通过覆盖。 + +后续顺序: +1. 团队完成 W1 人工验收、确认 medium 配置与 W3 输入/比较契约。 +2. 使用完整 ORT/IR 分进程入口,在约定的数值模式下重新生成七组全部 logits 的 <1e-4 验收证据;保留依赖、CPU/BLAS 诊断和内存测量。 +3. 并行安排 E1/E2/E5 独立复现和远端手动 CI;出现差异时使用局部诊断。Nightly 的配置和真正运行通过须单独确认。 +4. W3 完整数值硬门槛通过后,推进 W4 权重独立装载和完整 QEMU 前向;生成循环属于 W5。 diff --git "a/docs/llm-deploy-v1.0/W3/W3-\345\256\214\346\225\264\346\250\241\345\236\213\346\211\247\350\241\214\344\270\216\346\225\260\345\200\274\350\257\212\346\226\255\346\212\245\345\221\212.md" "b/docs/llm-deploy-v1.0/W3/W3-\345\256\214\346\225\264\346\250\241\345\236\213\346\211\247\350\241\214\344\270\216\346\225\260\345\200\274\350\257\212\346\226\255\346\212\245\345\221\212.md" new file mode 100644 index 00000000..f20ddbcf --- /dev/null +++ "b/docs/llm-deploy-v1.0/W3/W3-\345\256\214\346\225\264\346\250\241\345\236\213\346\211\247\350\241\214\344\270\216\346\225\260\345\200\274\350\257\212\346\226\255\346\212\245\345\221\212.md" @@ -0,0 +1,48 @@ +# W3 完整模型 Linux 执行与验收记录 + +复现采用 Ubuntu 24.04 x86_64、Bash、Python 3.12;环境、模型资产与 Linux 工具链按 [统一指南](../LINUX_REPRODUCTION.md) 准备,实际命令见 [完整模型入口](../../../probes/w3_qwen3_full/README.md)。 + +本文整理 **2026-10-05 已执行的 Linux 证据**。固定代码 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 在作者 [run 37299235103 / attempt 1](https://github.com/yuki-328/ScratchV/actions/runs/37299235103) 中同时通过 preparation、完整七组和汇总。本次文档迁移没有重新执行模型,也不将旧证据认定为新代码或第二人执行。 + +## 执行与数值范围 + +固定真实 Qwen3-0.6B ONNX 为 28 层、FP32、L256、无 KV cache。每组输入分别启动 ORT 和 IR worker,两个后端各运行普通图及诊断图。ORT 使用 CPUExecutionProvider、关闭图优化与 CPU memory arena、intra/inter threads 均为 1。IR 使用优化等级 `none`、`numpy-fp32-reference-v4/avx2-fma3`。 + +| 检查 | Linux 实测结果 | 验收含义 | +|---|---|---| +| 七组输入 | 7/7 PASS:两组满长、one_token、short_17、short_255、changed_future、changed_padding | 完整覆盖,没有缩减失败 case | +| 普通/诊断完整 logits | 最大绝对误差 0,全部有限,形状 `[1,256,151936]` | 正式比较包含全部 padding query,严格 `<1e-4`、rtol=0 | +| 每组 30 检查点 | 本次全部最大绝对误差 0 | 保存中间定位证据;不改变最终 logits 门槛 | +| 因果和 padding 不变量 | 两后端共四项,最大绝对误差均为 0 | 修改未来或填充 token 后,受保护的有效前缀保持一致 | +| full worker 回归 | 581 passed、0 skipped | 该工作流专项测试,不等于全部仓库测试 | +| 同提交 preparation | 五项 PASS | 见 [Linux 预备记录](W3-预备工作本地验收报告.md) | + +零误差是该模型、代码与环境的实测值,不是跨机器承诺。`reference-v4` 约定 FP32 计算顺序及数学近似;IR 独立执行,不在算子中调用 ORT。解释器默认 API 的 `native` 模式仍需单独验证,reference 通过不等于 native 或目标后端通过。 + +## Linux 环境、时长与内存 + +实际 runner 为 Ubuntu 24.04.5、Python 3.12.14、4 vCPU AMD EPYC 7763、约 16.77 GB RAM。NumPy 2.2.6、ONNX 1.18.0、ORT 1.22.1、Torch 2.7.1+cpu;NumPy 诊断为 AVX2/FMA3、OpenBLAS 0.3.29 / Haswell、`OPENBLAS_CORETYPE=null`。这些字段不能推断 ORT 内部 dispatch。 + +- 完整 runner 墙钟时长 `1477.314971525 s`,约 24.6 分钟,包含加载、普通/诊断执行、比较和证据写盘。 +- 每组 ORT worker 总时长约 `15.20–15.53 s`;每组 IR worker 总时长约 `193.45–195.88 s`。每个 worker 都有两次前向,不是单次推理 benchmark。 +- worker 自报全生命周期 RSS 峰值:ORT `4,443,017,216–4,443,987,968` bytes,IR `3,102,253,056–3,102,715,904` bytes。 +- 父监控的进程树 RSS 采样峰值:ORT `4,443,328,512–4,444,389,376` bytes,IR `2,943,205,376–3,057,094,656` bytes。采样可能错过瞬时峰值,不与 worker 自报值混用。 + +完整入口采用只读权重映射、initializer 借用和中间 binding 最后使用后回收。Linux 通过独立进程组管理本次拥有的进程树,超时/采样内存超限/中断时清理。采样终止阈值不是操作系统硬配额,也不是整台机器总内存预算。默认解释器调用行为保持兼容;本报告不将容量准备当作 W6 性能优化收益。 + +## 原始产物与核对 + +主报告 SHA256:`0ddb97d13b6bab44442fb0b453d1e0132ef7a02c96be9c8caf70ae2862e31746`。 + +| Artifact | ID | 已核对 ZIP SHA256 | +|---|---|---| +| `w3-full-numeric-reports` | 11341033619 | `46047d41334f3336eaf2e39385bffca37551a726891ad6c671c23df1e60f88c5` | +| `w3-full-numeric-raw` | 11341924764 | `b99c06f3e39c7c465d31763eba98350682961de1541cab719ce3da1df2ee4171` | + +完整 raw ZIP 为 `4,350,864,947` bytes,包含 140 个文件。作者下载后完成七组、四不变量的数组离线复核,固定报告 hash 与模型文件也通过核对,用时 `21.234900 s`;`model_executed=false`、`independent_reproduction=false`。历史源码换行差异的只读核对说明以及原始 audit 字段完整保留在 [Linux 实测记录](Linux-CI与Nightly.md),不改写报告中的源码哈希。产物保留期限有限,需另行留存或重新运行。 + +## 尚待验收的边界 + +团队仍需确认 medium 配置、reference 模式与比较契约;E1/E2/E5 各自在 Linux 上执行;定时 Nightly 尚未启用。`w3_exit_accepted=false`。既有 PyTorch/ORT 导出差异单独记录,IR/ORT 通过不表示它自动消失。W3 的小 Attention QEMU 与完整 host IR 分别验收,完整模型的 RISC-V 权重加载和 QEMU 前向属于 W4。 + +早期个人开发机的失败定位、内存实验和进程兼容记录保留在 [固定提交历史原文](https://github.com/ScratchV-Compiler/ScratchV/blob/77c3aa86e9794e5bfe3c81fd873a1506d14bc753/docs/llm-deploy-v1.0/W3/W3-完整模型执行与数值诊断报告.md),不计入 Linux 验收;没有将其历史结果换成 Linux 标签。Linux 前三轮失败与第四轮同提交通过记录见 [Linux CI 与 Nightly](Linux-CI与Nightly.md),不能跨提交拼接通过项。 diff --git "a/docs/llm-deploy-v1.0/W3/W3-\345\267\245\344\275\234\346\200\273\347\273\223\344\270\216\351\252\214\346\224\266\350\277\233\345\272\246.md" "b/docs/llm-deploy-v1.0/W3/W3-\345\267\245\344\275\234\346\200\273\347\273\223\344\270\216\351\252\214\346\224\266\350\277\233\345\272\246.md" new file mode 100644 index 00000000..5bc8007d --- /dev/null +++ "b/docs/llm-deploy-v1.0/W3/W3-\345\267\245\344\275\234\346\200\273\347\273\223\344\270\216\351\252\214\346\224\266\350\277\233\345\272\246.md" @@ -0,0 +1,92 @@ +# W3 工作总结与验收进度 + +对外复现统一使用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 统一环境与资产准备](../LINUX_REPRODUCTION.md),并在同一个 Bash 会话、仓库根目录执行以下命令;`SCRATCHV_PYTHON`、`SCRATCHV_CC`、`SCRATCHV_QEMU` 由该指南设置。 + +更新日期:2026-10-06;仅调整 Linux 交付说明,未重新执行模型。W3 已在第四轮同提交 Linux 实跑中通过 preparation、完整七组及汇总,下载原始数组 audit 也通过;[PR #96](https://github.com/ScratchV-Compiler/ScratchV/pull/96) 可交给团队复现。剩余为团队契约确认、E1/E2/E5 独立执行及实际定时 Nightly 验收,正式阶段出口仍为 false。 + +本地代码位于 `codex/w3-preparation` 工作树,原基线为 `65616a8cde6a581661ac3734378da78a9fcbe5ed`。当前实测代码为 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`,[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)的 preparation、完整七组 full 和汇总均通过;通用 CI 为 3821 passed、5 skipped,W1/W2 与 Topic06 通过。preparation 产物迁移已复核,完整 raw 已下载验真、离线 audit 通过;实际 schedule 未启用。复现应获取 PR head 并记录实际完整 SHA,不能只记录分支或旧基线。前三轮失败记录保留,各轮环境、耗时和 artifact 身份见 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。 + +## 1. 按开发计划核对 + +| 计划项 | 已完成内容 | 实测证据 | 当前状态 | +|---|---|---|---| +| 基础 Attention、RoPE、因果/key-padding 语义 | Q/K RMSNorm、RoPE、GQA、掩码、Softmax、context 组合探测 | 第四轮 Linux 6 个小图 × `none/all`,12 次实际 QEMU;全输出 QEMU vs ORT 最大绝对误差 `3.5762786865234375e-7 < 1e-4` | 作者第四轮 Linux 通过;范围是小 Attention | +| `numeric:ir-medium` | 官方 Qwen3 类的六层随机配置;7 输入、普通/诊断图、IR `none/basic/all` | 第四轮 Linux 7/7 输入、81 检查点、18 不变量全部通过;报告全部比较最大绝对误差 `2.771615982055664e-6 < 1e-5` | 作者第四轮 Linux 通过;medium 配置待团队确认 | +| 真实维度与预训练权重子图 | 固定 checkpoint 第一层的 14 个 L256 子图,三个 IR 优化等级 | 第四轮 Linux 14/14 输入、10 不变量通过;全部比较最大绝对误差 `3.0517578125e-5 < 1e-4` | 第四轮通过;不是完整预训练 decoder 层执行 | +| `numeric:ir-full-qwen3` | 固定真实 28 层、FP32、L256、无 KV cache;七组输入全覆盖 | 第四轮 Linux v4/avx2-fma3、IR `none` 下普通/诊断完整 logits 最大误差 0;每组 30 检查点与四项不变量的最大绝对误差也均为 0;约 24.6 分钟 | 作者同提交 Linux 七组通过;raw audit 通过;团队独立复现仍待完成 | +| `tool:layer-diff` | schema/形状/dtype/有限值校验、逐点误差与首个异常定位;另有完整模型同输入局部诊断 | 第四轮 Linux medium `short_17` 的 IR `all` 与 ORT 诊断数组比较 81/81 点,最大 `2.205371856689453e-6`;下载后离线重算一致 | 作者第四轮 Linux 及选定数组迁移复核通过 | +| 峰值内存与时长 | 解释器 binding/NumPy storage 统计、进程 RSS、进程树采样、超时及各阶段耗时 | preparation 与 full 原始报告均保存实际观测和字段口径 | 已实现并验证;不等同目标硬件性能指标 | +| CI 与 Nightly | 两套手动/可复用工作流,以及显式启用的定时编排;既有入口可显式调用 W3 | 第四轮 preparation、完整 full 和汇总均通过;完整 raw audit 已通过 | 同提交两套 gate 和汇总通过;完整原始数组 audit 已通过,定时 schedule 未启用 | +| 阶段出口 | 验收口径提案、复现步骤、每人记录模板 | [独立复现与验收清单](独立复现与验收清单.md) | 团队确认及 E1/E2/E5 记录待填写 | + +完整模型的正式数值门槛是全部普通/诊断 logits 的严格 `max_abs < 1e-4`、`rtol=0`,包括全部 256 个位置及 padding query。每组另外保存 30 个检查点用于定位;第四轮 Linux 记录的检查点实际误差也均为 0,但不把它们改成额外的全中间激活硬门槛,也不据此预判新代码或其他 Linux 环境的复跑结果。 + +## 2. 改动如何组成完整工作 + +### 编译器与解释器支撑 + +- ONNX 前端增加可选的外置权重只读映射,检查名称、路径、偏移、长度、dtype 和 shape。 +- IR 解释器增加只读 initializer 借用、最后使用后释放 binding、同步观察回调及可选内存统计,支撑完整模型执行和选择性检查点保存。 +- 当前已提交实现更新为版本化 `numpy-fp32-reference-v4` 模式及对应单测,`SCRATCHV_FP32_REFERENCE_CPU=auto|avx2-fma3|avx512` 选择 IR 参考算术策略,默认 `auto`。该模式使用 NumPy 计算,不在 IR 算子内调用 ORT,也不控制 ORT dispatch;解释器 API 默认仍为 `native`。旧 v2/v3 证据保留原版本,不能用作 v4 的完整验收。 +- 默认 initializer 复制、保留中间 binding 等调用行为保持;借用及回收模式限定纯算子、单基本块、SSA、单一末尾 RETURN,不支持时明确报错。 +- 第三方 Eigen MPL-2.0 与 MLAS MIT 材料的声明和许可证随源码/安装包保存。项目主体 MIT 与文件级许可分别说明。 + +### 探测与验收入口 + +| 入口 | 作用 | +|---|---| +| `scripts/run_w3_preparation.py` | 顺序执行 medium、真实权重子图、小 Attention QEMU、完整资产预检、layer-diff 五项检查 | +| `probes/w3_qwen3_full/run.py` | 启动 ORT/IR 独立 worker,实际执行完整模型;核对七组输入、完整输出和四项不变量 | +| `probes/w3_qwen3_full/localize.py` | 基于同一输入继续定位层内与原语差异,诊断完成不等于数值验收通过 | +| `scripts/verify_w3_evidence.py` | 重新读取已保存原始数组,重算误差及不变量,核对报告、哈希、schema 和数值策略 | +| `scripts/package_w3_repro.py` | 为未提交代码生成可校验源码快照,包含复现所需示例、测试和文档;不包含 Git 历史或模型 | +| `scripts/collect_w3_ci_evidence.py` | 收集受大小限制的准备阶段诊断证据,记录实际文件哈希及省略原因 | + +完整执行区分 `PASS/0`、`FAIL/1`、`PARTIAL/2`,中断为 130。单例正确不会变成全覆盖 PASS。进程树超时/内存保护、失败报告、原始数组保留和读取边界均有回归覆盖。 + +### CI 和交付 + +两套验收工作流固定使用 `ubuntu-24.04` 与 Python 3.12,记录系统资源、实际依赖和工具版本;保持手动入口并支持同提交复用。准备工作流获取完整 Git 历史,使固定旧版本 benchmark 真正执行。 + +Nightly 默认不执行定时数值任务,需要 `W3_NIGHTLY_ENABLED=true`;手动触发可明确运行完整编排。只有准备项与完整七组均成功,汇总才能通过。报告与原始数组分开管理保留期,不能只凭报告文件存在判断产物完整。具体见 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。 + +源码包内历史 benchmark 会明确说明缺少 Git 历史;它不会向上借用其他仓库。超时处理测试独立于真实历史,Git 集成则在实际 checkout 中执行。 + +## 3. 验证记录及证据边界 + +对外验收使用实际 Linux 记录。下表同属于提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 的 [第四轮 run 37299235103](https://github.com/yuki-328/ScratchV/actions/runs/37299235103),不把早期个人开发机测试改名为 Linux 证据。本次说明调整未重新执行模型。 + +| 记录 | 已保存的结果 | 证据边界 | +|---|---|---| +| Linux preparation | 五项 PASS,132.923089 秒;回归 1297 passed、1 项非 Linux 平台兼容性测试 skipped | 准备范围;不包含完整 IR gate | +| Linux 完整模型 | 七组、四不变量 PASS,1477.314971525 秒;v4/avx2-fma3、IR none;普通/诊断完整 logits 最大误差 0 | 固定完整模型、代码及环境;不是 native 或任意 CPU 的通过保证 | +| 完整 worker 回归 | 581 passed、0 skipped | 专项测试范围 | +| 同提交通用 CI | 3821 passed、5 skipped | [CI run 37299215953](https://github.com/ScratchV-Compiler/ScratchV/actions/runs/37299215953),与专项范围分别记录 | +| preparation 下载复核 | 63 文件大小/哈希匹配、无遗漏;选定数组 81/81 点重算通过 | 作者离线复核,没有执行模型 | +| 完整 raw 下载复核 | 140 文件 ZIP digest 匹配;7 组、4 不变量及固定报告 hash、模型文件核对通过;21.234900 秒 | `model_executed=false`、`independent_reproduction=false` | + +运行环境、报告 SHA256、artifact 身份、历史失败轮次和保存产物的方法集中在 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。[预备记录](W3-预备工作本地验收报告.md) 和 [完整记录](W3-完整模型执行与数值诊断报告.md) 给出各自的数据与范围;早期个人开发机资料通过其中的固定提交索引留存,不计入 Linux 验收。 + +每次重新执行保存新的目录、实际 SHA、环境及模型指纹。旧报告不因文档或代码更新而自动成为新版本通过证据;完整执行、单例执行和离线数组重验是三种不同证据。耗时包含加载、编译、诊断或写盘,不能当作目标硬件性能或优化前后 benchmark。 + +## 4. 评审顺序与兼容边界 + +建议先确认完整 logits 的范围和误差门槛,再评审前端/解释器可选接口、FP32 模式、完整 runner 的验收拒绝逻辑、其他探测与证据工具,最后核对 CI、文档及第三方许可。 + +- 第二轮 Linux v3/avx512 与第四轮 Linux v4/avx2-fma3 的零误差分别属于其原提交和环境;不是默认 `native`、其他 CPU/BLAS 策略或未来提交的通过保证。v4 应记录解析后的 `cpu_strategy`、MatMul 布局字段及 `environment.numeric_runtime` 中的 `OPENBLAS_CORETYPE` 原值和 `numpy_openblas` 实际库诊断;CPU SIMD 不等于实际 BLAS 内核,可选诊断缺失不豁免数值门槛;离线 audit 按保存的策略核对,旧 v2/v3 数组则用对应历史源码核对。 +- medium 为六层随机小配置候选,需要团队认可通道尺寸;真实子图仅使用第一层预训练权重和受控激活。 +- 映射的权重文件在调用期间必须保持不变;映射接口本身不替代固定资产哈希验证。 +- 解释器统计不包含全部临时缓冲、分配器及调用方持有的数组;进程 RSS 和采样内存另行报告。 +- 完整 IR 门槛以固定 ONNX 的 ORT 执行为参考;既有导出阶段 PyTorch/ORT 差异保留独立口径,不因 IR/ORT 通过自动消失。 +- W3 的小 Attention QEMU 与完整 host IR 分别验收。完整大模型的 RISC-V 代码/权重分离、装载及 QEMU 前向属于 W4;生成循环属于 W5。 + +## 5. 剩余收尾及责任 + +| 待办 | 谁来推进 | 通过依据 | +|---|---|---| +| 确认 medium 配置、reference 模式和比较契约 | 作者提出,团队确认 | 讨论结论及复现清单记录 | +| E1/E2/E5 独立复现 | 三位复现者各自执行 | 每人环境、命令、源码/模型身份、完整报告和原始数组 | +| 启用并观察 Nightly | 有仓库权限者与维护者协作 | 实际 schedule run 成功,不能用静态检查或手动成功替代 | +| W3 正式验收 | 团队讨论 | 完整硬门槛、独立复现、Nightly 和前置确认齐全 | + +当前可以发起代码评审和复现讨论;`w3_exit_accepted` 保持 false。提交 PR 不等于自动完成阶段验收。 diff --git "a/docs/llm-deploy-v1.0/W3/W3-\351\242\204\345\244\207\345\267\245\344\275\234\346\234\254\345\234\260\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/llm-deploy-v1.0/W3/W3-\351\242\204\345\244\207\345\267\245\344\275\234\346\234\254\345\234\260\351\252\214\346\224\266\346\212\245\345\221\212.md" new file mode 100644 index 00000000..b6a7adf0 --- /dev/null +++ "b/docs/llm-deploy-v1.0/W3/W3-\351\242\204\345\244\207\345\267\245\344\275\234\346\234\254\345\234\260\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -0,0 +1,38 @@ +# W3 Linux 预备工作验收记录 + +对外复现统一采用 Ubuntu 24.04 x86_64、Bash、Python 3.12,环境安装见 [Linux 统一指南](../LINUX_REPRODUCTION.md),执行步骤见 [W3 入口](README.md) 与 [独立复现清单](独立复现与验收清单.md)。保留当前文件名以兼容已有链接。 + +本文整理 **2026-10-05 已执行的 Linux 记录**,不是本次文档调整后的重新运行,也不是团队独立复现。固定代码为 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`;来源为作者 [run 37299235103 / attempt 1](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)。该提交的 preparation、完整七组及汇总全部通过。 + +## 环境与身份 + +实际 runner 为 Ubuntu 24.04.5、Python 3.12.14、4 vCPU AMD EPYC 7763;NumPy 2.2.6、ONNX 1.18.0、ORT 1.22.1、Torch 2.7.1+cpu。NumPy 诊断记录 AVX2/FMA3、OpenBLAS 0.3.29 / Haswell,`OPENBLAS_CORETYPE=null`。CPU 能力、IR 参考策略与实际 BLAS 内核分别记录。 + +preparation 主报告 SHA256 为 `ceeeb239ac7b88c22372d0e76f5506841e1b634a81b5b1567af35ca84d443046`。完整产物身份、历史失败轮次和下载方法见 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。 + +## 五项验收 + +| 项目 | 实际覆盖 | 严格门槛 | Linux 实测结果 | +|---|---|---|---| +| 六层 medium | 7 输入、81 检查点、18 不变量;普通/诊断图;IR none/basic/all | 全部位置含 padding,`max_abs < 1e-5`、rtol=0 | PASS;全部比较最大 `2.771615982055664e-6` | +| 真实维度子图 | 第一层真实权重的 14 个 L256 子图、10 不变量;Torch/ORT/IR | `max_abs < 1e-4`、rtol=0 | PASS;全部比较最大 `3.0517578125e-5` | +| 组合 Attention 后端 | 6 图 × none/all;12 次真实 QEMU | `max_abs < 1e-4`、rtol=0 | PASS;QEMU/ORT 最大 `3.5762786865234375e-7` | +| 完整资产预检 | 固定哈希与 7847 节点 ONNX 契约 | manifest 与图契约一致 | PASS;不执行完整模型数值 | +| layer-diff | medium short_17,IR all 与 ORT 诊断数组,81/81 点 | `max_abs < 1e-5`、rtol=0 | PASS;最大 `2.205371856689453e-6` | + +medium 是六层随机小配置:hidden64、FFN192、Q4/KV2、head_dim16、vocab128、L256,共 304128 个随机参数;其宽度是待团队确认的候选。真实子图采用固定 checkpoint 第一层的预训练权重、受控合成激活,验证真实尺寸的局部计算,不等于完整预训练 decoder 层执行。W3 的阈值不修改 W2 的 `<1e-5` 门槛。 + +## 耗时、回归与证据 + +- preparation 总墙钟时长 `132.923089 s`;medium `97.368098 s`、真实子图 `5.775279 s`、Attention 子入口 `22.837814 s`、预检 `2.164485 s`。 +- 12 次 QEMU 进程墙钟合计 `0.913058207 s`,包含启动、guest 计算、UART 和退出,不能当作目标硬件纯推理性能。 +- 同次 preparation 回归:1297 passed、1 项非 Linux 平台进程所有权兼容测试 skipped;不得表述为零跳过。 +- 诊断收集清单中的 63 个证据文件共 `21,516,508` bytes,下载后大小与 SHA256 全部匹配、无遗漏;选定 81 点数组离线重算通过。离线重算没有执行模型,不是第二人复现。 + +原始报告分别保存进程全生命周期 RSS、解释器保留张量逻辑字节、去重底层存储字节和采样值;不同口径不可混用。只读权重映射和最后使用后释放服务于完整模型容量,不构成后续性能优化验收。 + +## 结论与历史记录 + +本次 preparation 报告是 `full_ir_executed=false`、`w3_exit_accepted=false`。完整七组是同提交的另一项执行,见 [完整模型 Linux 记录](W3-完整模型执行与数值诊断报告.md)。团队契约确认、E1/E2/E5 各自 Linux 执行和实际定时 Nightly 仍待完成。 + +早期个人开发机报告保留在 [固定提交历史原文](https://github.com/ScratchV-Compiler/ScratchV/blob/77c3aa86e9794e5bfe3c81fd873a1506d14bc753/docs/llm-deploy-v1.0/W3/W3-预备工作本地验收报告.md)。其中路径、环境、失败与耗时保持历史含义,不用于 Linux 验收,也没有把原报告改写成 Linux 执行结果。 diff --git "a/docs/llm-deploy-v1.0/W3/\347\213\254\347\253\213\345\244\215\347\216\260\344\270\216\351\252\214\346\224\266\346\270\205\345\215\225.md" "b/docs/llm-deploy-v1.0/W3/\347\213\254\347\253\213\345\244\215\347\216\260\344\270\216\351\252\214\346\224\266\346\270\205\345\215\225.md" new file mode 100644 index 00000000..63c75d02 --- /dev/null +++ "b/docs/llm-deploy-v1.0/W3/\347\213\254\347\253\213\345\244\215\347\216\260\344\270\216\351\252\214\346\224\266\346\270\205\345\215\225.md" @@ -0,0 +1,261 @@ +# W3 独立复现与验收清单 + +对外复现统一使用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先按第 2.1 节固定源码,再完成 [Linux 统一环境与资产准备](../LINUX_REPRODUCTION.md),并在同一个 Bash 会话、仓库根目录执行探测命令;`SCRATCHV_PYTHON`、`SCRATCHV_CC`、`SCRATCHV_QEMU` 由该指南设置。 + +本文件把 W3 的本地交付、待确认口径和团队验收记录放在一起。它是复现说明和待填写模板,不是验收签字。开发计划要求完整 Qwen3 IR 数值硬门槛通过、E2/E5/E1 三人独立复现通过、Nightly 绿;本地通过、读取作者的报告或校验作者的数组,都不能替代三人的独立执行。 + +W3 已提交至 [PR #96](https://github.com/ScratchV-Compiler/ScratchV/pull/96),本地工作树为 `codex/w3-preparation`,原基线为 `65616a8cde6a581661ac3734378da78a9fcbe5ed`。提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 的[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)已在同提交通过 preparation、完整七组 full 和汇总;preparation 下载已复核,完整 raw 已下载验真、audit 通过。历史 v2/v3 和前三轮失败报告原样保留。定时 schedule 未启用,E1/E2/E5 各自执行仍待登记。详见 [Linux 实测及产物核对](Linux-CI与Nightly.md)。PR 继续更新时,每位复现者必须固定并记录自己的实际 SHA;旧基线不能单独标识当前 W3 代码。 + +## 1. 待团队确认的验收口径 + +以下是可以直接执行的候选口径。确认人和日期留空,不能将作者选择当作团队共识。 + +| 项目 | 本地采用的定义 | 团队确认记录 | +|---|---|---| +| medium | 官方 `Qwen3ForCausalLM`,模型种子 0,6 层、hidden64、FFN192、Q4/KV2、head_dim16、vocab128、L256、batch1;304128 个随机参数,preset `qwen3-medium-6l-h64-ffn192-q4-kv2-d16-random` | 待确认:人 / 日期 / 讨论记录 | +| 完整模型 | 固定 Qwen3-0.6B 发布 ONNX,28 层、FP32、L256、无 KV cache;保持原图和权重 | 待确认:人 / 日期 / 讨论记录 | +| 完整数值模式 | 显式 `--fp32-mode reference`,候选 profile `numpy-fp32-reference-v4`,IR 优化等级 `none`;记录 `SCRATCHV_FP32_REFERENCE_CPU` 原值和解析后的 `cpu_strategy` | 待确认:人 / 日期 / 讨论记录 | +| 正式输出门槛 | 全部普通和诊断 logits,形状和 dtype 一致、值有限,严格 `max_abs < 1e-4`、`rtol=0`,包含所有 padding query | 待确认:人 / 日期 / 讨论记录 | +| 验证覆盖 | 下列七组输入全部执行、普通/诊断同后端 logits 完全一致、四项因果/key-padding 不变量通过 | 待确认:人 / 日期 / 讨论记录 | +| Nightly | 以完整七组硬门槛为数值条件,记录实际 workflow run、SHA、结果及原始产物;具体运行频率和资源由维护者确认 | 待确认:人 / 日期 / 讨论记录 | + +medium 的通道尺寸是候选配置,开发计划只指定了六层。它验证 Qwen3 结构和计算语义,不是完整尺寸预训练六层模型。完整模型使用真实预训练权重;两种证据分别验收。 + +`reference-v4` 规定若干 FP32 运算的计算顺序和近似算法,不修改模型输出、不把 ORT 调用作为 IR 算子实现,也不保证任意 CPU、BLAS 或 ORT 版本逐位一致。解释器 API 默认仍是 `native`;`reference-v4` 通过不等于 `native` 通过,也不能沿用历史 v1/v2/v3 的验收结果。复现达到固定误差门槛即可,不要求重复作者机器上的零误差。 + +`SCRATCHV_FP32_REFERENCE_CPU` 可设为 `auto`、`avx2-fma3` 或 `avx512`,未设置时默认 `auto`。`auto` 根据 NumPy 报告的可用 AVX2/FMA3/AVX512F 能力选择参考策略;能力不足、无法推断或 `NPY_DISABLE_CPU_FEATURES` 遮蔽相关能力时明确失败。显式取值只固定 IR 的参考算术顺序,不控制 ORT 选择哪种 CPU 内核,也不保证本机 IR/ORT 一致。报告保存实际解析后的 `cpu_strategy`,每位复现者应同时保存启动时的环境变量原值。完整使用契约见 [完整模型入口](../../../probes/w3_qwen3_full/README.md)。 + +v4 的完整 profile 还记录 MatMul 的 K 分块、行分块、尾行/单列补齐、操作数布局和向量策略。AVX2 采用固定两行和 C 连续分块,AVX512 保留原路径;复现与 audit 必须核对这些字段,不能只核对 `name`、`cpu_strategy` 或最终形状。当前 v4 已提交于 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`,[第四轮](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)的 preparation、完整 full 和汇总均已通过;第三轮 FAIL 保留为历史记录,完整 raw 数组离线 audit 也已通过。 + +完整模型的 ORT 基准固定为 CPUExecutionProvider、关闭图优化、intra/inter threads 均为 1、关闭 CPU memory arena。medium 分别覆盖 IR `none/basic/all` 和普通/诊断两种图,使用原有 NumPy 执行路径,不能将完整模型的单一优化等级覆盖描述成所有优化都已验证。 + +## 2. 固定代码、依赖和模型 + +### 2.1 固定 PR 提交;源码快照作为补充 + +先在 Linux 主机准备 Git 和 Git LFS(Ubuntu 可执行 `sudo apt-get update` 和 `sudo apt-get install -y git git-lfs`)。接收者在新目录获取 PR #96 的 head,避免将其他分支或既有工作区修改混入复现;签出后显式拉取 LFS 实体文件: + +```bash +set -euo pipefail +git clone --no-checkout https://github.com/ScratchV-Compiler/ScratchV.git ScratchV-w3-repro +git -C ScratchV-w3-repro fetch origin refs/pull/96/head +git -C ScratchV-w3-repro switch --detach FETCH_HEAD +git -C ScratchV-w3-repro lfs install --local +git -C ScratchV-w3-repro lfs pull +git -C ScratchV-w3-repro rev-parse HEAD +git -C ScratchV-w3-repro status --short +cd ScratchV-w3-repro +``` + +上述命令仅用于新建的 `ScratchV-w3-repro` 目录;不要对包含自己未提交工作的目录执行切换。记录 `rev-parse HEAD` 返回的完整 SHA,之后进入该目录运行测试。PR 后续更新不改变这份已固定的 checkout;若要复现更新,应另存新记录,不能继续引用旧 SHA 的通过结论。本文登记的 `1c49e8a` 是一次具体运行的提交,不是所有后续修改的验收标签。 + +如需交付未提交修改或离线源码,可从所需 W3 checkout 根目录生成本地源码包: + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B -X utf8 scripts/package_w3_repro.py create --output output/w3-local-handoff/w3-source.zip +``` + +包内清单记录 `snapshot_id` 及文件 SHA256,旁边的 `.sha256` 校验压缩包本身。包包含本轮源码、测试、工作流、依赖清单、文档和许可证,不包含 Git 历史、模型权重或运行产物。最终身份以实际生成的清单为准,不能只记录分支名或基线提交号。 + +当前源码包清单为 schema 2,声明 Linux/Bash 复现契约,并要求包含 Linux 环境指南和 W3 入口。 +该声明是交付目标,不是模型已执行的证明。历史 schema 1 包应使用随其原版本提供的验证器; +不能修改旧清单字段来冒充新包。打包保留源码原始字节,接收者安装自己的 Linux 环境。 + +接收者解压到新目录后,使用发件人提供的清单 ID 核对: + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B -X utf8 scripts/package_w3_repro.py verify --root /absolute/path/to/extracted-w3 --expected-snapshot-id '填写实际snapshot_id' +``` + +请把示例中的路径和 ID 换成实际值。执行工具前先核对收到的压缩包 SHA256;该校验确定收到的文件一致性,不提供发送者身份认证。交付文件变化后需要新快照;数值执行代码变化后需要重跑受影响的验收并生成新记录。历史报告保存的是其生成时的 `source_sha256`,不应人为改成当前快照的哈希;工具或文档的后续变化需要单独说明。 + +源码包可执行 W3 模型探测,但不带旧提交历史。依赖真实 Git 历史的 benchmark 集成测试在无 `.git` 的包内会明确跳过,不能据此声称历史性能比较通过;该测试应在含完整历史的 checkout/CI 中实际执行。一般回归使用 `--basetemp output/某个新目录` 时,应先创建其父目录 `output`,这不是需要打包的源码目录。 + +### 2.2 固定依赖 + +使用 Python 3.12 和 [requirements/qwen3-small-probe.txt](../../../requirements/qwen3-small-probe.txt)。关键版本为 NumPy 2.2.6、ONNX 1.18.0、ORT 1.22.1、Torch 2.7.1 CPU、Transformers 4.51.3、safetensors 0.5.3。Attention QEMU 需要 Zig 0.14.1 和 `qemu-system-riscv64`,记录实际 QEMU 版本及可执行文件路径。 + +已有合适环境可直接使用;新环境按依赖清单首部说明先安装 CPU Torch,再安装该清单。固定清单是顶层依赖约束,不保证传递依赖、wheel 或系统动态库逐字节相同;每位复现者还应保存本机的 `python --version`、`python -m pip freeze`、编译器/QEMU 版本,以及报告中的 `environment` 原始字段。 + +报告的 `environment.numeric_runtime` 记录 CPU 架构、处理器、线程环境及 NumPy 构建/运行诊断。还应保留 `blas_environment.OPENBLAS_CORETYPE` 原值(未设置为 `null`)以及 `numpy_openblas.status`、`libraries`;可用库项中的 `path/status/core_name/config` 和不可用时的 `reason/error` 都按原样保存。环境变量设置不证明实际生效,CPU SIMD 能力也不等于 BLAS 正在使用的内核。此诊断只覆盖已装载的 NumPy 私有 OpenBLAS 库,不证明 ORT 内核。 + +可选诊断不可用或旧证据缺少该字段时保留缺失说明,不能推断未记录的内核,更不能掩盖任何数值失败。不同 Linux CPU、计算库或代码版本仍需实际运行验证。 + +### 2.3 固定资产 + +资产唯一清单为 [probes/w1_qwen3_export/manifest.json](../../../probes/w1_qwen3_export/manifest.json),入口会校验其 SHA256。模型目录必须放真实二进制,不可用 Git LFS 指针代替。 + +| 资产 | 需要保留的身份信息 | +|---|---| +| HF 原始 checkpoint | `Qwen/Qwen3-0.6B`,revision `c1899de289a04d12100db370d81485cdf75e47ca`;`model.safetensors` SHA256 `f47f71177f32bcd101b7573ec9171e6a57f4f4d31148d38e382306f42996874b` | +| 完整 ONNX | `model.onnx`,25053577 bytes;SHA256 `0b2e02436055e02612764851384e158467b3f8f544a1c51f52276afc0d229eea` | +| 外置权重 | `weights-00001.data`、`weights-00002.data`、`weights-00003.data`,合计 2384201728 bytes;逐文件尺寸和哈希以 manifest 为准 | +| 配置及 tokenizer 来源 | 使用 manifest 中固定 `source_files`,保存入口实际核验结果 | + +先按 [Linux 统一指南](../LINUX_REPRODUCTION.md) 完成 Python 依赖、工具链、完整 ONNX 和 tokenizer 准备。W3 的真实权重子图还需要原始 HF checkpoint;它不是 ONNX 外置权重文件,必须另行准备。 + +以下命令**仅在含 W3 实现的固定 checkout 中**执行,因为它调用 `probes.w3_qwen3_subgraphs.assets.verify_source`。下载步骤与 [.github/workflows/llm-deploy.yml](../../../.github/workflows/llm-deploy.yml) 的 `w3-preparation` job 相同:从仓库 manifest 读取模型 ID、固定 revision 和允许文件列表,下载到 `output/assets/source`,随即流式核验 checkpoint、全部 source 文件 SHA256 及配置。此准备步骤需要联网,后续探测入口只读、离线校验已有资产。 + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B -X utf8 - <<'PY' +from pathlib import Path +from huggingface_hub import snapshot_download +from probes.w1_qwen3_export.run import MANIFEST +from probes.w3_qwen3_subgraphs.assets import verify_source + +source = Path("output/assets/source") +snapshot_download( + repo_id=MANIFEST["model_id"], + revision=MANIFEST["revision"], + local_dir=source, + allow_patterns=["model.safetensors"] + [item["name"] for item in MANIFEST["source_files"]], +) +verified = verify_source(source) +print(f"Verified {verified['model_id']} @ {verified['revision']}: {source.resolve()}") +PY +W3_SOURCE="$PWD/output/assets/source" +``` + +下载或校验失败时先保留错误并解决资产问题,不得绕过 SHA256 检查。已有另一份固定 checkpoint 可跳过下载,将 `W3_SOURCE` 设为其 Linux 绝对路径;preparation 仍会执行相同的离线验证。`W3_MODEL` 指向统一指南准备的完整 ONNX 目录,两个目录用途不同。 + +## 3. 独立执行步骤 + +在已核对的快照或 checkout 根目录运行。每次重试使用新的输出目录;保留失败目录,不能将以前的成功产物覆盖到新结果中。 + +```bash +set -euo pipefail +W3_SOURCE="${W3_SOURCE:-$PWD/output/assets/source}" +W3_MODEL=/absolute/path/to/qwen3-0.6b-onnx +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +export PYTHONIOENCODING=utf-8 SCRATCHV_FP32_REFERENCE_CPU=auto + +if [[ -e .git ]]; then + git rev-parse HEAD + git status --short +else + "$SCRATCHV_PYTHON" -B -X utf8 scripts/package_w3_repro.py verify --root "$PWD" +fi +"$SCRATCHV_PYTHON" -B -X utf8 scripts/run_w3_preparation.py \ + --source-dir "$W3_SOURCE" --model-dir "$W3_MODEL" \ + --output-dir output/w3-repro-preparation-01 \ + --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_qwen3_full/run.py \ + --model-dir "$W3_MODEL" --output-dir output/w3-repro-full-01 \ + --fp32-mode reference --worker-timeout 1800 --max-worker-memory-gib 10 +``` + +上面的 Bash 命令使用 `set -euo pipefail`,任何入口返回非零都会终止后续步骤,失败目录须保留。远端执行参照 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。两套工作流支持手动运行,Nightly 编排准备项与完整七组。作者[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)已在同提交通过两套 gate 和汇总,完整 raw audit 已通过;应按每次实际 SHA、profile 和各套结果分别登记,不能拼接不同提交的通过项。配置存在、手动调用成功和定时 schedule 成功是三种不同状态;作者运行不能替代复现者在自己环境执行。 + +`--max-worker-memory-gib 10` 是 worker 进程树的采样终止阈值,不是总机器内存预算或操作系统硬配额。超时和内存不足属于运行失败;可以在记录机器资源后调整资源预算,但不能用改模型、缩短覆盖、放宽数值误差来取得全量 PASS。解释器张量存储统计、进程全生命周期 RSS 和父进程采样值口径不同,原始字段应分别保留。 + +### 3.1 必须覆盖的七组输入 + +完整图契约:INT64 `input_ids [1,256]`、FP32 加性 mask `[1,1,256,256]`,FP32 logits `[1,256,151936]`。这些是可复现的 embedding ID 数值测试,不是语言生成质量测试。 + +| case | 有效长度 | 必须检查的内容 | +|---|---:|---| +| `full_seed_0` | 256 | 种子 0 的满长输入,全部 logits | +| `full_seed_42` | 256 | 种子 42 的满长输入,全部 logits | +| `one_token` | 1 | 1 个有效 token 和 255 个 padding query 的输出 | +| `short_17` | 17 | 17 个有效 token 和 239 个 padding query 的输出 | +| `short_255` | 255 | 255 个有效 token 和最后一个 padding query 的输出 | +| `changed_future` | 256 | 修改 `full_seed_0` 第 64 个索引起的 token,前 64 个位置应保持因果隔离 | +| `changed_padding` | 17 | 保持 `short_17` 的 mask,改变 padding token ID,前 17 个有效位置应保持隔离 | + +每组 ORT 和 IR 各执行普通图、诊断图,先检查各自两份完整 logits 逐元素完全一致,再分别跨后端比较。四项不变量是 ORT/IR 各自的未来 token 隔离和 padding 隔离,均严格 `<1e-4`、`rtol=0`。它们比较受保护的有效前缀;正式跨后端精度仍比较全部 256 个位置,不能只比较前缀。 + +完整诊断有 30 个检查点:embedding、28 层 residual 输出、final norm。检查点误差用于定位;正式硬门槛作用于完整 logits。medium 的 81 个检查点及 18 项隔离检查由 preparation 单独验收。 + +### 3.2 退出判定与失败处理 + +| 入口 | 可以接受的结果 | 不足以验收的结果 | +|---|---|---| +| preparation | 五项均 PASS、返回 0、报告和原始产物齐全 | 仅 preflight 或部分子入口通过 | +| full runner | 七组完整覆盖、四项不变量通过、`status=PASS`、返回 0 | `--case` 子集返回 PARTIAL/2;FAIL/1;中断 130 | +| layer-diff | 完整 schema 覆盖并通过 | 选定部分层的 PARTIAL 不替代完整覆盖 | +| localize | 用于记录同输入的层内误差、原语误差和定位结果 | `COMPLETE`/0 仅表示诊断完成,不是 W3 数值 PASS | + +失败先保存日志、输入、两后端报告、完整 logits、检查点和 schema。检查 `cases[].comparison.logits` 与 `first_divergence`,再按 [LOCALIZE.md](../../../probes/w3_qwen3_full/LOCALIZE.md) 定位。不得删除不通过的 padding 位置、换用不同参考、将局部成功写成全量成功。 + +## 4. 固定 Linux 证据与接收时的核对 + +作者已在 Ubuntu 24.04.5、Python 3.12.14、AMD EPYC 7763 / OpenBLAS Haswell 环境运行以下同提交证据,代码 SHA 为 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`,来源为 [run 37299235103](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)。这些是已有运行记录,本次文档迁移没有重新执行,也不能代替接收者执行。 + +| 记录 | 已保存的结果 | +|---|---| +| 五项 preparation | 5/5 PASS;132.923089 秒;相关回归 1297 passed、1 项非 Linux 平台兼容性测试 skipped | +| 完整模型 | 7/7 输入、30 检查点/组、4 不变量;v4/avx2-fma3、IR none;普通/诊断完整 logits 最大绝对误差 0;1477.314971525 秒 | +| 完整原始数组离线复核 | 7 组、4 不变量通过;未重新执行模型,`independent_reproduction=false` | + +完整报告 SHA256 为 `0ddb97d13b6bab44442fb0b453d1e0132ef7a02c96be9c8caf70ae2862e31746`。raw artifact ID 为 `11341924764`;其保存期限有限,应使用自己已留存的对应产物或重新运行。详细 artifact 身份与来源见 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。不同环境只要求满足固定误差门槛,不要求重复零误差。 + +早期个人开发机证据只在 [固定提交历史原文](https://github.com/ScratchV-Compiler/ScratchV/blob/77c3aa86e9794e5bfe3c81fd873a1506d14bc753/docs/llm-deploy-v1.0/W3/独立复现与验收清单.md) 留存,不计入 Linux 验收。 + +接收证据时需同时保留并核对: + +1. 实际提交完整 SHA 和工作树状态;若使用源码快照,则记录 snapshot ID、清单和压缩包 SHA256。 +2. 模型 manifest、资产实际哈希、源 revision;每人自己的模型与执行目录。 +3. `environment`、`source_sha256`、`ir.fp32_profile`(包括 `cpu_strategy`)、启动时 `SCRATCHV_FP32_REFERENCE_CPU` 原值、输入/输出哈希、普通/诊断执行步数与阶段耗时。 +4. 完整 `report.json`、各 worker 报告和日志、`inputs.npz`、普通/诊断 logits、检查点 NPZ 及 schema。 +5. preparation 子报告、layer-diff 使用的实际/reference/schema、Attention 工具链与 QEMU 时长。 + +摘要、截图和 SHA256 能辅助交接,但不能代替原始数组;离线复核原始数组也不等于在另一台机器上执行模型。CI 报告和 raw 产物保留期分别管理,接收者应在过期前保留完整证据。CI raw 解压后模型证据位于 `下载目录/w3-full`;完整 `gh run download`、报告 SHA256 获取和 audit 参数模板见 [Linux CI 的产物与失败定位](Linux-CI与Nightly.md#产物与失败定位)。精度问题的排查过程不在本清单中记录。 + +拿到完整原始目录后,可使用与报告契约兼容的离线工具重新读取数组和重算比较。以下命令对应上述 Linux v4 报告;证据目录必须包含已解压的 `w3-full/` 内容,不能只有摘要报告: + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B -X utf8 scripts/verify_w3_evidence.py \ + --evidence-dir output/downloaded-w3-37299235103/raw/w3-full \ + --output-dir output/w3-saved-evidence-audit-01 \ + --expected-report-sha256 0ddb97d13b6bab44442fb0b453d1e0132ef7a02c96be9c8caf70ae2862e31746 +``` + +上述 hash 只适用于指定的作者 Linux 报告。复核本人新报告时,换成本人另行保存的报告 SHA256。v4 离线工具依据保存的 `cpu_strategy` 校验整个规范 profile(包括 MatMul 布局字段),不根据审计机器 CPU 或当前环境变量猜测生产策略;跨机器审计不执行 ORT/IR。`audit:w3-full-saved-evidence` PASS 只表示保存证据完整、自洽并满足记录的数值契约;源码差异在 `source_comparison` 中单独列出,不能据此声称当前源码刚刚通过全量运行。详见 [离线证据复核说明](../../../probes/w3_qwen3_full/EVIDENCE.md)。 + +## 5. E1 / E2 / E5 独立复现记录模板 + +每位复现者各复制一份下表,在自己的 Linux 环境实际执行后填写。三人均应执行完整硬门槛;角色分工只表示额外重点,不允许只复核他人报告代替运行。 + +| 字段 | 复现者填写 | +|---|---| +| 姓名 / 角色 / 日期 | 待填写 | +| 代码 snapshot ID / 压缩包 SHA256 / 实际提交 SHA(如有) | 待填写;未提交快照不可仅填基线提交号 | +| 源码校验结果 / 本机解压或 checkout 绝对路径 | 待填写 | +| Linux 发行版 / 内核 / CPU / RAM / 可用磁盘 / Python 绝对路径 | 待填写;Ubuntu 24.04 x86_64、Python 3.12 | +| Python / pip freeze / NumPy-CPU-BLAS 诊断原始文件路径 | 待填写;包括 `OPENBLAS_CORETYPE` 原值、`numpy_openblas.status/libraries` 及每库 `core_name/config` 或不可用原因 | +| checkpoint 绝对路径 / revision / 实测 SHA256 | 待填写 | +| ONNX 绝对路径 / manifest 校验结果 | 待填写 | +| Zig / QEMU 版本和绝对路径 | 待填写 | +| 原始运行命令 / 线程环境 / 时间和内存预算 | 待填写 | +| preparation 原始报告绝对路径 / 返回码 / 各项结果 | 待填写 | +| full 原始报告绝对路径 / 返回码 / 七组覆盖 / 四不变量 | 待填写 | +| 每组普通及诊断 logits 最大绝对误差 | 待填写;不得仅填平均误差 | +| 实际 fp32 mode / 完整 profile / CPU 策略 / 优化等级 | 待填写;同时记录 `SCRATCHV_FP32_REFERENCE_CPU` 原值与解析后的 `cpu_strategy` | +| 最早异常检查点 / 失败 case / 原始日志路径(如有) | 待填写 | +| 完整耗时 / 每 worker RSS与采样内存 / QEMU 时长 | 待填写;注明字段口径 | +| 本人生成的原始证据目录 / 哈希清单 / 保存位置 | 待填写 | +| 本人结论 / 阻塞问题 / 讨论链接 | 待填写;PASS、FAIL、未完成应明确区分 | + +| 角色 | 额外核对重点 | 独立运行状态 / 记录位置 | +|---|---|---| +| E1 | ONNX 契约、28 层覆盖、shape/dtype、权重和源码身份 | 待执行 / 待填写 | +| E2 | 完整数值、七输入、因果与 padding 语义、profile、误差定位 | 待执行 / 待填写 | +| E5 | 干净环境、CI 参数与资源、原始产物可用性、Nightly 实际运行 | 待执行 / 待填写 | + +## 6. W3 最终退出记录 + +| 条件 | 当前可作出的结论 | 最终证据 / 确认人 | +|---|---|---| +| 完整 Qwen3 IR 硬门槛 | 作者第四轮 Linux v4/avx2-fma3 七组及整套手动编排通过;完整 logits、30 检查点与四不变量的最大绝对误差均为 0;raw audit 通过;团队验收仍待完成 | 待团队核对对应报告版本 | +| medium、reference 模式与比较范围 | 候选契约已给出 | 待团队确认 | +| W1 人工前置和遗留问题处理 | 本文件不代替既有阶段确认 | 填入团队已有记录或未完成项 | +| E1 / E2 / E5 独立复现 | 本文件未记录三人通过 | 各自填写第 5 节 | +| W3 Linux 手动 CI | 作者第四轮同提交两套 gate 及汇总通过;完整 raw 已下载验真、audit 通过 | [run 37299235103](https://github.com/yuki-328/ScratchV/actions/runs/37299235103),提交 `1c49e8a` | +| Nightly 绿 | 定时 schedule 未启用;作者手动复用编排不算 schedule 通过 | 待实际 schedule run URL / SHA / artifact | +| W3 正式收尾 | `w3_exit_accepted=false` | 待团队讨论确认 | + +完整模型编译到 RISC-V、完整权重装载和 QEMU 单次前向属于 W4。当前小 Attention 的 QEMU 通过和完整模型的 host IR 通过,分别证明各自范围,不能合并表述为完整大模型已在 QEMU 运行。 diff --git "a/docs/llm-deploy-v1.0/\345\274\200\345\217\221\350\256\241\345\210\222.md" "b/docs/llm-deploy-v1.0/\345\274\200\345\217\221\350\256\241\345\210\222.md" index 3f84dec4..86872ebc 100644 --- "a/docs/llm-deploy-v1.0/\345\274\200\345\217\221\350\256\241\345\210\222.md" +++ "b/docs/llm-deploy-v1.0/\345\274\200\345\217\221\350\256\241\345\210\222.md" @@ -7,15 +7,17 @@ **周期**:8 周 **验收方式**:CI 门禁驱动 -**实施基线更新(2026-10-02)**:本文保留 v1 的范围、周次目标和数值门槛,按提交 `5ea22ec` 已验证的实现校正路线。当前小模型采用 `tensor-c → Zig/LLVM → RV64GC/LP64D → QEMU virt 裸机`,接口仍为候选基线,未代替团队冻结决议。W1 技术探测已通过,第二人独立复现、接口/风险确认和出口评审仍待完成,详见 [W1 执行计划](W1/W1-执行计划.md) 和 [独立复现指南](W1/README.md)。后文 W2–W8 是待验收计划,不因小模型成功自动完成。 +**实施基线更新(2026-10-03)**:本文保留 v1 的范围、周次目标和数值门槛。PR #91 已合并为 `3bb88e81498d6d9293aed70e25a4e5a2104465d0`,小模型采用 `tensor-c → Zig/LLVM → RV64GC/LP64D → QEMU virt 裸机`。W1 已有 `df18b02f` 两层链路的第二人独立复现;完整模型第二人复现、接口/风险确认和出口评审仍待完成,详见 [W1 执行计划](W1/W1-执行计划.md) 和 [独立复现指南](W1/README.md)。合并后的 W1 报告修复见 [PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93),W2 单独交付;发布前整合工作树实测与限制见 [本轮验收报告](W2/W1修复与W2本地验收报告.md)。W2–W8 按各自出口验收,不因小模型成功自动完成。 --- +**交付环境约定(2026-10-06)**:W1 及后续阶段的公开指南、第二人复现和 CI 统一使用 Linux(Ubuntu 24.04 / Bash / Python 3.12)。作者个人环境不构成 Linux 验收;保留历史记录的真实平台和提交身份。环境安装、资产和后续提交规则见 [Linux 复现约定](LINUX_REPRODUCTION.md)。这只规定 Host 环境,目标仍为 RISC-V 裸机程序。 + ## 1. 项目概述 ### 1.1 背景 -ScratchV 是一个面向计算机体系结构与编译原理学习的 AI 编译器,核心管线为 ONNX 解析 → IR 构建 → 优化 Pass → RISC-V 代码生成。项目最初主要验证 CNN 类静态计算图;当前已沿用共享 IR 和 CompilerDriver,补入 NumPy IR 解释器、张量 C 后端和 RV64 裸机运行器,跑通真实 Qwen3 结构的两层缩小模型。完整 Qwen3-0.6B 仍需完成 IR 数值、大权重装载、容量和完整 QEMU 前向验收。 +ScratchV 是一个面向计算机体系结构与编译原理学习的 AI 编译器,核心管线为 ONNX 解析 → IR 构建 → 优化 Pass → RISC-V 代码生成。项目最初主要验证 CNN 类静态计算图;当前已沿用共享 IR 和 CompilerDriver,补入 NumPy IR 解释器、张量 C 后端和 RV64 裸机运行器,跑通真实 Qwen3 结构的两层缩小模型。完整 Qwen3-0.6B 已在作者[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)的提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 下通过全部七组 IR/ORT 数值,preparation 和同提交汇总也通过;完整 raw 数组下载验真及 audit 通过,团队独立复现及正式验收尚待完成。早期 v2 记录保留在固定历史提交,Linux v3 证据按原身份保留,详情见 [W3 工作总结](W3/W3-工作总结与验收进度.md)。大权重装载、目标容量和完整 QEMU 前向按 W4 推进。 ### 1.2 目标 @@ -47,7 +49,7 @@ ScratchV 是一个面向计算机体系结构与编译原理学习的 AI 编译 ### 2.1 整体数据流 -下图是完整模型的目标数据流。当前已实现的是 Python host 装载输入、启动裸机 QEMU 和读取 UART 结果;Tokenizer、采样、token 循环与完整模型仍待交付。图中的 FFI 表示模型调用边界:host 通过 QEMU 装载/传输访问 guest,不能在宿主机直接调用 RISC-V 函数。实际模型 ABI 为 `int scratchv_run(const void *const inputs[], void *output)`,详见 [候选接口](W1/interfaces.md)。 +下图是完整模型的目标数据流。当前已实现 Python host 装载输入、启动裸机 QEMU 和读取 UART 结果;W2 另提供固定官方 Tokenizer、输入/mask、最后有效位置 greedy 及停止边界辅助接口,见 [W2 交付记录](W2/README.md)。完整 token 循环与完整模型仍待交付。图中的 FFI 表示模型调用边界:host 通过 QEMU 装载/传输访问 guest,不能在宿主机直接调用 RISC-V 函数。实际模型 ABI 为 `int scratchv_run(const void *const inputs[], void *output)`,详见 [候选接口](W1/interfaces.md)。 ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -77,11 +79,11 @@ ScratchV 是一个面向计算机体系结构与编译原理学习的 AI 编译 | 阶段 | 已验证能力(两层小模型范围) | 后续需验收 / 扩展 | 负责模块 | |---|---|---|---| -| 前端 | Qwen3 基础算子解析、属性/shape/dtype 和权重绑定 | 完整 28 层导出图逐节点与权重验收;模式识别按优化需要添加 | `scratchv/frontend/` | +| 前端 | Qwen3 基础算子解析;固定完整 28 层图的属性/shape/dtype、逐节点转换和权重绑定已验收 | 预训练完整图数值;模式融合按优化需要添加 | `scratchv/frontend/` | | IR | 共享 SSA、verifier、NumPy 解释器可表达基础 Attention/RoPE/SwiGLU 图 | 完整 IR 数值与峰值内存;融合节点不是正确性前置 | `scratchv/ir/`、`scratchv/verification/` | | 优化 | none/basic/all 后 IR 对照;修复 FP32 舍入和 SSA 问题 | 更大配置回归;融合 Attention 等作为可选优化逐项验收 | `scratchv/optimizer/` | | 后端 | tensor-c 显式 FP32 张量循环,经 Zig/LLVM 生成 RV64GC 机器码 | 大权重与代码分离、完整容量、MatMul 分块/布局;旧标量选择器缺口另行决定是否补齐 | `scratchv/backend/` | -| 运行时 | 固定输入装载、裸机 QEMU、UART 张量输出与错误处理 | Tokenizer、采样、生成循环、完整权重装载与分块输出 | `scratchv/runtime/` | +| 运行时 | 固定输入装载、裸机 QEMU、UART 张量输出与错误处理;Tokenizer/mask/greedy/停止边界辅助接口 | 生成循环、完整权重装载与分块输出、端到端集成 | `scratchv/runtime/` | ### 2.3 Qwen3-0.6B 关键配置 @@ -153,7 +155,7 @@ Host 端循环: | Nightly 完整门禁 | 每日夜间 | 完整 ONNX、完整 IR 数值、QEMU 单次前向、端到端 | 2–4 h | 阻塞里程碑 | | Weekly/Release 门禁 | 每周五 / tag | 多轮、文档复现、性能回归、交付物 | 4–8 h | 阻塞发布 | -上表是分层目标,不代表所有未来 job 已交付。当前 `.github/workflows/llm-deploy.yml` 使用 GitHub 托管 Ubuntu runner:PR 小模型任务和手动/定时完整 ONNX 任务各有 60 分钟 job 上限,QEMU 小模型步骤另有超时;完整 IR/QEMU 和生成门禁尚未实现。实际工具为 Zig 0.14.1、`qemu-system-riscv64`(裸机)和固定 Python 依赖;旧独立测试仍使用 `qemu-riscv32`,不能互换。更大内存的自托管 runner、容器封装与缓存策略是完整模型阶段的资源计划,不宣称已经部署。 +上表是分层目标,不代表所有未来 job 已交付。当前 `.github/workflows/llm-deploy.yml` 使用 GitHub 托管 Ubuntu runner:PR 小模型任务和手动/定时完整 ONNX 任务各有 60 分钟 job 上限,QEMU 小模型步骤另有超时。W3 门禁已并入 `llm-deploy.yml`(`w3-preparation`、`w3-full-numeric`、`w3-acceptance` 三个 job);可通过该文件手动设置 `run_w3_validation=true` 从同一提交运行 W3 编排,或设置仓库变量 `W3_NIGHTLY_ENABLED` 交给定时任务。作者[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)已在 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 通过同提交两套 W3 gate 及汇总,完整 raw 已下载验真、audit 通过,定时 schedule 未启用。通用 PR CI 全绿不能替代这些专项记录。完整 QEMU 和生成门禁尚未实现。实际工具为 Zig 0.14.1、`qemu-system-riscv64`(裸机)和固定 Python 依赖;旧独立测试仍使用 `qemu-riscv32`,不能互换。更大内存的自托管 runner、容器封装与缓存策略是完整模型阶段的资源计划,不宣称已经部署。 ### 4.2 硬门槛 CI Job @@ -188,6 +190,8 @@ Host 端循环: | **CI 验收** | `unit:frontend-ops`:四类模型模式所用基础算子及边界用例通过
`unit:backend-ops`:基础算子 QEMU 单测,误差 < 1e-5
`numeric:ir-small`:2 层小模型 IR vs ORT,误差 < 1e-5
`unit:runtime`:固定语料 tokenizer 编码/解码与官方配置一致,greedy、最后有效位置和掩码正确
`frontend:parse-qwen3`:完整 Qwen3 解析无错误,28 层和权重绑定完整 | | **出口标准** | 小模型 IR 误差 < 1e-5;完整 Qwen3 解析无缺失算子;CI 全绿 | +**W2 实施补记**:已新增四模式前端独立回归、七类基础后端的 16 个小图/32 次 QEMU 门禁,以及官方 tokenizer fast/slow/golden 对照和 host 输入/采样门禁。具体命令、实测与 CI 状态见 [W2 交付记录](W2/README.md)。Tokenizer 会遵循官方 NFC 规范化,“往返正确”按官方输出一致验收;模型 151936 个 logits 行不等于 151669 个可解码 token。融合 Attention 不是本轮正确性前置,完整 IR 数值仍按 W3 单独验收。 + #### W3:完整 Qwen3 IR 数值验证(硬门槛) | 项目 | 内容 | @@ -200,6 +204,10 @@ Host 端循环: > ⚠️ 若 W3 硬门槛不通过,W4 暂停后端集成,E2+E5 主导修复,E1/E3/E4 支援。 +**W3 实施补记(2026-10-05)**:提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 的[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)中,preparation 五项、v4/avx2-fma3 完整七组和汇总均通过。普通/诊断完整 logits、每组 30 检查点及四项不变量最大误差均为 0,完整 runner 用时约 24.6 分钟;同提交通用 CI 为 3821 passed、5 skipped,W1/W2 和 Topic06 通过。preparation 下载已复核,完整 raw 已下载验真、audit 通过。早期个人环境记录和 Linux 前三轮失败记录按原身份保留,不能跨提交替代本轮证据。定时 schedule 未启用,团队口径确认及 E1/E2/E5 独立复现仍待完成。作者远端执行、数组离线复核与团队正式验收分别记录,详见 [W3 工作总结与验收进度](W3/W3-工作总结与验收进度.md)。 + +当前已提交的完整模型接口使用 `numpy-fp32-reference-v4`,通过 `SCRATCHV_FP32_REFERENCE_CPU=auto|avx2-fma3|avx512` 选择 IR 参考算术策略,默认 `auto`;该设置不控制 ORT 的 CPU dispatch。报告必须记录实际 `cpu_strategy`,以及 `environment.numeric_runtime` 中 `blas_environment.OPENBLAS_CORETYPE` 和 `numpy_openblas` 实际库诊断;CPU SIMD 与 BLAS 内核分别记录,可选诊断不可用不豁免数值门槛。完整七组按原门槛重新验收。历史 v2/v3 报告与其源码版本保持对应,不自动升级为 v4 的通过证据。 + #### W4:完整代码生成与 QEMU 单次前向(硬门槛) | 项目 | 内容 | @@ -294,7 +302,7 @@ Host 端循环: |---|---|---|---| | 1 | 修改后的 ScratchV 源码 | 前端、IR、后端、运行时的所有修改 | E1–E4 | | 2 | `export_qwen3_onnx.py` | Qwen3 FP32 ONNX 导出脚本 | E1 | -| 3 | IR 数值验证入口(原设想 `verify_ir_numerics.py`) | 小模型已有 `probes/w2_qwen3_small/run.py`;完整模型入口待交付,含逐层对比 | E5 | +| 3 | IR 数值验证入口(原设想 `verify_ir_numerics.py`) | 小模型 `probes/w2_qwen3_small/run.py`;完整模型 `probes/w3_qwen3_full/run.py` 已实现并通过作者 Linux 完整七组及下载数组 audit,含完整 logits、逐层检查点及局部诊断;团队独立复现与定时 Nightly 验收待完成 | E5 | | 4 | 编译执行入口(原设想 `compile_to_riscv.sh`) | 小模型已有 `scratchv/runtime/riscv_tensor.py` 与探测脚本;完整模型部署入口待交付 | E3 | | 5 | `run_inference.py`(待交付) | Host 运行时:tokenizer、采样、token 循环 | E4 | | 6 | `DEPLOYMENT.md` | 完整部署文档,含环境准备、导出、编译、运行 | E1+E5 | @@ -329,4 +337,4 @@ v1 完成后,按优先级推进: 5. **周粒度管理**:降低管理开销,每周集成日,W8 纯缓冲吸收延期。 6. **清晰演进路径**:v1 验证正确性,v2 加 KV Cache,v3 加量化与向量化。 -下一项数值硬门槛是 **W3 的 `numeric:ir-full-qwen3`(误差 < 1e-4)**。在推进完整模型前,先完成 W1 的人工验收并建立中等配置、真实维度子图和内存基线;进度与可行性以这些实际结果判断,不使用没有测量依据的成功率估计。 +**W3 的 `numeric:ir-full-qwen3`(误差 < 1e-4)已获得作者第四轮 Linux v4 七组完整执行证据,同提交 preparation 及整套手动汇总也已通过**,中等配置、真实维度子图和资源观测也已建立。历史失败记录保留;第四轮原始数组下载验真和 audit 也已通过,剩余为 W1 人工前置记录、W3 团队口径确认、E1/E2/E5 独立复现及定时 Nightly 验收,之后按阶段条件推进 W4 完整 RISC-V/QEMU 前向。当前实施状态与证据范围见 [W3 工作总结](W3/W3-工作总结与验收进度.md)。 diff --git a/docs/reference/ARCHITECTURE_DESIGN.md b/docs/reference/ARCHITECTURE_DESIGN.md index 1e1711fe..506efeb4 100644 --- a/docs/reference/ARCHITECTURE_DESIGN.md +++ b/docs/reference/ARCHITECTURE_DESIGN.md @@ -542,8 +542,8 @@ grep -rn "riscv64\|riscv32\|+mattr" scratchv/ --include=*.py # 内核层与主线是否真的没有调用关系(§6 内核调用钩子) grep -rn "backend.kernels" scratchv/ tests/ tools/ --include=*.py -# LLVM 成本模型可切集合(附录 B.2) -llc --cost-kind= +# LLVM 成本模型(附录 B.2);也可选 latency、code-size、size-latency +llc --cost-kind=throughput # 附录 B.3 的两条实测 ./scratchv_env/bin/python -m scratchv my_examples/hello.dsl --backend llvm -o /tmp/o.ll @@ -558,8 +558,9 @@ llc -march=riscv32 -mattr=+m /tmp/o.ll -o /tmp/o.s grep -n "backend == " scratchv/compiler.py # 平台侧实测口径(附录 B.4) +# 运行前把下方带引号的 和 替换为实际参数。 cd /root/riscv-ai-compiler-platform set -a && . /root/.riscv_platform_env && set +a PLATFORM_ENABLE_SANDBOX=0 ./.venv/bin/python \ - /root/workspace/riscv_matmul/measure.py + /root/workspace/riscv_matmul/measure.py '' '' ``` diff --git "a/docs/topics/06-\346\200\247\350\203\275\345\237\272\345\207\206\345\245\227\344\273\266/06-\346\200\247\350\203\275\346\265\213\350\257\225\345\245\227\344\273\266\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/topics/06-\346\200\247\350\203\275\345\237\272\345\207\206\345\245\227\344\273\266/06-\346\200\247\350\203\275\346\265\213\350\257\225\345\245\227\344\273\266\344\275\277\347\224\250\350\257\264\346\230\216.md" index b39dab20..b1395f81 100644 --- "a/docs/topics/06-\346\200\247\350\203\275\345\237\272\345\207\206\345\245\227\344\273\266/06-\346\200\247\350\203\275\346\265\213\350\257\225\345\245\227\344\273\266\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/docs/topics/06-\346\200\247\350\203\275\345\237\272\345\207\206\345\245\227\344\273\266/06-\346\200\247\350\203\275\346\265\213\350\257\225\345\245\227\344\273\266\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -17,14 +17,22 @@ DSL -> DSLInterpreter -> 解释器结果 -> 期望值对比 ## 运行依赖 -测试命令需要在 ScratchV 仓库根目录运行。`scripts/run_topic06_benchmarks.py` 会导入 `scratchv` 包,并通过 ScratchV 编译器把每个 DSL 用例编译为 RISC-V 汇编。 +复现环境统一为 Linux / Bash,使用 Python 3.12。测试命令需要在 ScratchV 仓库根目录运行。 +首次使用时创建并激活虚拟环境: + +```bash +python3.12 -m venv .venv-topic06 +source .venv-topic06/bin/activate +``` + +`scripts/run_topic06_benchmarks.py` 会导入 `scratchv` 包,并通过 ScratchV 编译器把每个 DSL 用例编译为 RISC-V 汇编。 调用真实 TinyFive 所需的配套接口位于 `scratchv/simulator/tinyfive.py`、 `scratchv/backend/register_alloc.py`、`scratchv/compiler.py` 和 `scratchv/main.py`。 安装基础测试依赖: -```powershell -pip install -e ".[topic06]" +```bash +python -m pip install -e ".[topic06]" ``` 基础模式需要以下依赖: @@ -34,8 +42,8 @@ pip install -e ".[topic06]" 如需生成 HTML 和 PNG 完整报告,再安装可选依赖: -```powershell -pip install -e ".[topic06-report]" +```bash +python -m pip install -e ".[topic06-report]" ``` 其中 `jinja2` 用于生成 HTML,`matplotlib` 用于生成性能图表。 @@ -67,13 +75,13 @@ build/topic06/ # 运行时生成,不提交 在仓库根目录执行: -```powershell +```bash python scripts/run_topic06_benchmarks.py ``` 默认同时执行解释器与 TinyFive,并分别记录状态。也可以显式选择: -```powershell +```bash python scripts/run_topic06_benchmarks.py --verification-backend both python scripts/run_topic06_benchmarks.py --verification-backend interpreter python scripts/run_topic06_benchmarks.py --verification-backend tinyfive @@ -102,13 +110,13 @@ python scripts/run_topic06_benchmarks.py --verification-backend tinyfive 更新性能基线: -```powershell +```bash python scripts/run_topic06_benchmarks.py --update-baseline ``` 性能退化阈值默认是 5%,可以按场景调整: -```powershell +```bash # 超过 2% 即判定为退化 python scripts/run_topic06_benchmarks.py --regression-threshold 2 @@ -122,19 +130,19 @@ python scripts/run_topic06_benchmarks.py --regression-threshold 10 按类别运行: -```powershell +```bash python scripts/run_topic06_benchmarks.py --category activation ``` 按用例名称进行大小写不敏感的包含匹配: -```powershell +```bash python scripts/run_topic06_benchmarks.py --filter matmul ``` 组合筛选: -```powershell +```bash python scripts/run_topic06_benchmarks.py --category tensor --filter relu ``` @@ -160,7 +168,7 @@ benchmarks/topic06/baseline.json 测试和报告生成采用两阶段流程。先运行测试并生成与展示格式无关的元数据: -```powershell +```bash python scripts/run_topic06_benchmarks.py ``` @@ -172,7 +180,7 @@ benchmark_reports/topic06/metadata.json 随后由独立报告工具读取元数据并生成轻量报告: -```powershell +```bash python scripts/generate_topic06_report.py ``` @@ -187,7 +195,7 @@ benchmark_reports/topic06/cases/*.json 需要 HTML 和 PNG 时运行: -```powershell +```bash python scripts/generate_topic06_report.py --full-report ``` diff --git "a/docs/topics/06-\346\200\247\350\203\275\345\237\272\345\207\206\345\245\227\344\273\266/06-\346\200\247\350\203\275\346\265\213\350\257\225\345\245\227\344\273\266\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/06-\346\200\247\350\203\275\345\237\272\345\207\206\345\245\227\344\273\266/06-\346\200\247\350\203\275\346\265\213\350\257\225\345\245\227\344\273\266\350\256\276\350\256\241\346\226\207\346\241\243.md" index b5a687c9..a95e6b06 100644 --- "a/docs/topics/06-\346\200\247\350\203\275\345\237\272\345\207\206\345\245\227\344\273\266/06-\346\200\247\350\203\275\346\265\213\350\257\225\345\245\227\344\273\266\350\256\276\350\256\241\346\226\207\346\241\243.md" +++ "b/docs/topics/06-\346\200\247\350\203\275\345\237\272\345\207\206\345\245\227\344\273\266/06-\346\200\247\350\203\275\346\265\213\350\257\225\345\245\227\344\273\266\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -366,7 +366,7 @@ Markdown 和 HTML 报告固定展示相同核心指标。CI 推荐读取 `benchm 运行: -```powershell +```bash python scripts/run_topic06_benchmarks.py ``` @@ -374,7 +374,7 @@ python scripts/run_topic06_benchmarks.py 运行: -```powershell +```bash python scripts/run_topic06_benchmarks.py --update-baseline ``` @@ -386,7 +386,7 @@ benchmarks/topic06/baseline.json 后续再次运行: -```powershell +```bash python scripts/run_topic06_benchmarks.py ``` @@ -398,7 +398,7 @@ python scripts/run_topic06_benchmarks.py 如果变化率超过配置阈值,则认为发生性能退化。默认阈值仍为 5%,可以使用命令行参数覆盖: -```powershell +```bash python scripts/run_topic06_benchmarks.py --regression-threshold 2 ``` @@ -478,48 +478,57 @@ benchmark_reports/topic06/course_report_instructions.png 测试运行时仍使用绝对路径调用编译器、读取用例和写入文件,避免工作目录变化影响执行。写入报告前,`_report_path()` 会将仓库内路径转换为相对于 ScratchV 仓库根目录的 POSIX 格式。 -例如,原报告中的: +例如,Linux 工作区中的绝对路径: ```text -D:\PycharmProjects\ScratchV\ScratchV\tests\topic06\cases\activation\relu_only.dsl +/home/reviewer/ScratchV/tests/topic06/cases/activation/relu_only.dsl ``` -现在写为: +在报告中写为: ```text tests/topic06/cases/activation/relu_only.dsl ``` -该规则应用于测试用例路径、汇编文件、编译失败日志,以及 Markdown 概览中的测试目录、构建目录和基线文件。生成的 Markdown、JSON 和 HTML 报告不再依赖本机盘符与工作区位置,可以在 Windows、Linux、macOS 和 CI artifact 中查看。 +该规则应用于测试用例路径、汇编文件、编译失败日志,以及 Markdown 概览中的测试目录、构建目录和基线文件。生成的 Markdown、JSON 和 HTML 报告不再依赖作者机器的工作区位置,可在其他 Linux 工作区和 CI artifact 中查看。 --- ## 六、运行方式 +以下复现命令面向 Linux / Bash,在仓库根目录执行,使用 Python 3.12: + +```bash +python3.12 -m venv .venv-topic06 +source .venv-topic06/bin/activate +``` + +各节保留的历史测试结果不因命令格式调整而变成新的 Linux 执行证据;新结果以本次生成的报告为准。 + ### 6.1 安装依赖 基础测试: -```powershell -pip install -e ".[topic06]" +```bash +python -m pip install -e ".[topic06]" ``` 完整报告: -```powershell -pip install -e ".[topic06-report]" +```bash +python -m pip install -e ".[topic06-report]" ``` ### 6.2 普通测试 -```powershell +```bash python scripts/run_topic06_benchmarks.py python scripts/generate_topic06_report.py ``` 第一条命令生成 `metadata.json`,第二条命令读取元数据并生成 `report.md`、`report.json` 和单用例报告。生成 HTML 和 PNG 完整报告: -```powershell +```bash python scripts/generate_topic06_report.py --full-report ``` @@ -527,19 +536,19 @@ python scripts/generate_topic06_report.py --full-report 按测试目录类别筛选: -```powershell +```bash python scripts/run_topic06_benchmarks.py --category activation ``` 按 DSL 文件名进行大小写不敏感的包含匹配: -```powershell +```bash python scripts/run_topic06_benchmarks.py --filter matmul ``` 两个条件可以组合,组合时必须同时满足: -```powershell +```bash python scripts/run_topic06_benchmarks.py --category tensor --filter relu ``` @@ -549,7 +558,7 @@ python scripts/run_topic06_benchmarks.py --category tensor --filter relu ### 6.4 性能回归测试 -```powershell +```bash python scripts/run_topic06_benchmarks.py ``` @@ -557,7 +566,7 @@ python scripts/run_topic06_benchmarks.py ### 6.5 更新性能基线 -```powershell +```bash python scripts/run_topic06_benchmarks.py --update-baseline ``` diff --git "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-CI\344\270\216Benchmark.md" "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-CI\344\270\216Benchmark.md" index 9cd0e8c3..bf93541e 100644 --- "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-CI\344\270\216Benchmark.md" +++ "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-CI\344\270\216Benchmark.md" @@ -8,7 +8,11 @@ push 使用 self-hosted)、Python 3.12 ## 专项测试 -```powershell +以下复现命令统一使用 Linux / Bash,在仓库根目录执行。先准备 Python 3.12 虚拟环境: + +```bash +python3.12 -m venv .venv-dsl-diagnostics +source .venv-dsl-diagnostics/bin/activate python -m pip install -e . "pytest>=7,<10" python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_dsl_diagnostics_cli.py tests/test_parser.py tests/test_dsl_extended.py tests/test_dsl_diagnostics_benchmark.py -v --tb=short ``` @@ -23,7 +27,7 @@ python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test 先为基线建立独立 checkout。以下 `997d2aa` 是本 PR 加入诊断实现之前的历史版本; rebase 后与最新上游比较时,应改用对应的 PR base SHA。 -```powershell +```bash git worktree add --detach ../ScratchV-dsl-baseline 997d2aa python -m benchmarks.bench_dsl_diagnostics --baseline-root ../ScratchV-dsl-baseline --json-output benchmark_reports/dsl_diagnostics.json --markdown benchmark_reports/dsl_diagnostics.md --html benchmark_reports/dsl_diagnostics.html ``` diff --git "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" index dd5e243e..2346026d 100644 --- "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" +++ "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -507,6 +507,9 @@ scratchv bad.dsl -o output.s 2> error.txt ## 16. 测试计划 +本节复现命令统一使用 Linux / Bash,在仓库根目录和 Python 3.12 虚拟环境中执行; +依赖安装见 [CI 与 Benchmark](09-DSL错误提示美化器-CI与Benchmark.md#专项测试)。 + ### 16.1 快速测试 开发过程中每个小步骤运行: @@ -526,12 +529,6 @@ python -m pytest \ tests/test_dsl_diagnostics_cli.py -q ``` -Windows PowerShell 可把路径放在同一行执行。 - -```powershell -python -m pytest tests/test_parser.py tests/test_dsl_extended.py tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_dsl_diagnostics_cli.py -q -``` - ### 16.3 项目验证 ```bash diff --git "a/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-Benchmark\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-Benchmark\350\256\276\350\256\241\346\226\207\346\241\243.md" index 9fed5ea3..bd89c204 100644 --- "a/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-Benchmark\350\256\276\350\256\241\346\226\207\346\241\243.md" +++ "b/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-Benchmark\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -286,20 +286,32 @@ convert_onnx_to_llvm(model) → LLVM IR (866K lines, 183MB) ## 7. 使用方式 +以下复现命令面向 Linux / Bash,在仓库根目录执行。先准备 Python 3.12 虚拟环境和依赖: + +```bash +python3.12 -m venv .venv-regalloc +source .venv-regalloc/bin/activate +python -m pip install -e ".[all]" +``` + +CNN 用例还需要 `models/graph/cnn.onnx`;请先确认模型文件存在。LLVM 对比依赖 +`llvmlite`,报告中的 `llvm_available=false` 表示该项未执行,不能作为 LLVM 对比通过。 + ```bash -# 运行全部 4 项 benchmark,生成三格式报告(Windows PowerShell) -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_regalloc_linear ` - --repeats 30 ` - --output-json report.json ` - --output-html report.html ` - --output-md report.md +# 运行全部 4 项 benchmark,生成三格式报告 +mkdir -p benchmark_reports +python -m benchmarks.test_regalloc.bench_regalloc_linear \ + --repeats 30 \ + --output-json benchmark_reports/regalloc_bench.json \ + --output-html benchmark_reports/regalloc_bench.html \ + --output-md benchmark_reports/regalloc_bench.md # 单独运行某项 -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_simple --repeats 100 -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_dense --repeats 50 -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_cnn ` +python -m benchmarks.test_regalloc.bench_simple --repeats 100 +python -m benchmarks.test_regalloc.bench_dense --repeats 50 +python -m benchmarks.test_regalloc.bench_cnn \ --cnn-path models/graph/cnn.onnx --repeats 30 -& .\.venv\Scripts\python.exe -m benchmarks.test_regalloc.bench_pseudo --repeats 30 +python -m benchmarks.test_regalloc.bench_pseudo --repeats 30 ``` --- diff --git "a/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\344\277\256\346\224\271\346\265\213\350\257\225\350\207\252\346\237\245\346\212\245\345\221\212.md" "b/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\344\277\256\346\224\271\346\265\213\350\257\225\350\207\252\346\237\245\346\212\245\345\221\212.md" index 8f5e0f3f..a4c2dda8 100644 --- "a/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\344\277\256\346\224\271\346\265\213\350\257\225\350\207\252\346\237\245\346\212\245\345\221\212.md" +++ "b/docs/topics/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215/17-\345\257\204\345\255\230\345\231\250\345\210\206\351\205\215-\344\277\256\346\224\271\346\265\213\350\257\225\350\207\252\346\237\245\346\212\245\345\221\212.md" @@ -137,13 +137,16 @@ ### 5.2 30 次运行结果 -命令: - -```powershell -python -m benchmarks.test_regalloc.bench_regalloc_linear ` - --repeats 30 ` - --output-json benchmark_reports/regalloc_bench.json ` - --output-html benchmark_reports/regalloc_bench.html ` +以下是原验证命令的 Linux / Bash 等价写法,在仓库根目录和已安装依赖的 Python 3.12 +虚拟环境中执行。环境准备见 [Benchmark 设计文档](17-寄存器分配-Benchmark设计文档.md#7-使用方式)。 +下表保留原历史测量,不代表使用这份 Linux 命令重新运行过。 + +```bash +mkdir -p benchmark_reports +python -m benchmarks.test_regalloc.bench_regalloc_linear \ + --repeats 30 \ + --output-json benchmark_reports/regalloc_bench.json \ + --output-html benchmark_reports/regalloc_bench.html \ --output-md benchmark_reports/regalloc_bench.md ``` diff --git a/probes/numeric_summary.py b/probes/numeric_summary.py new file mode 100644 index 00000000..3410d835 --- /dev/null +++ b/probes/numeric_summary.py @@ -0,0 +1,119 @@ +"""Compact numerical evidence tables; never invent metrics for older reports. + +The ``passed`` flag remains the existing numerical gate's result. The two +additional metrics are observations, not new pass/fail thresholds. A table row +is an aggregation of tensor comparisons, not a comparison of concatenated data. +""" +from __future__ import annotations + +from collections import Counter +from html import escape +import math +from numbers import Real + + +METRIC_EXPLANATION = ( + "最大绝对误差是单个元素最坏的偏差;相对 L2 = ||实际−参考||₂ / ||参考||₂,越小越好;" + "余弦相似度衡量两个输出向量方向的一致性,越接近 1 越好。" + "先逐次比较,再取最大绝对误差、最大相对 L2、最小余弦(可能来自不同用例,不拼接计算)。" + "原有通过门槛不变,其他检查仍生效;新增两项仅供观察,缺失或未定义明确标注。" +) + +_METRICS = ("max_abs", "relative_l2", "cosine_similarity") +_HEADERS = ("比较", "最大绝对误差", "相对 L2 误差", "余弦相似度", "结果") + + +def _metric_summary(rows, key): + values, missing, reasons = [], 0, Counter() + for row in rows: + if key not in row: + missing += 1 + continue + value = row[key] + reason = row.get(key + "_reason") or row.get("reason") + valid = isinstance(value, Real) and not isinstance(value, bool) + try: + number = float(value) if valid else None + valid = valid and math.isfinite(number) + except (OverflowError, ValueError): + valid = False + if valid and (number < 0 if key != "cosine_similarity" else not -1 <= number <= 1): + valid = False + if valid: + values.append(number) + else: + reasons[str(reason or ("未提供原因" if value is None else "数值无效"))] += 1 + worst = (min(values) if key == "cosine_similarity" else max(values)) if values else None + return dict(value=worst, valid_count=len(values), total=len(rows), + missing=missing, undefined=sum(reasons.values()), reasons=reasons) + + +def _metric_text(summary, key): + parts = [] + if summary["value"] is not None: + # 17 significant digits distinguish adjacent binary64 cosine values. + # Scientific notation preserves even the smallest nonzero errors. + number = summary["value"] + parts.append(format(number, ".17g") if key == "cosine_similarity" else format(number, ".16e")) + if summary["missing"]: + parts.append(f"未记录 {summary['missing']}/{summary['total']}") + if summary["undefined"]: + reasons = ";".join(f"{reason} ×{count}" for reason, count in summary["reasons"].items()) + parts.append(f"未定义 {summary['undefined']}/{summary['total']}({reasons})") + if summary["value"] is not None and (summary["missing"] or summary["undefined"]): + parts[0] += f"(已记录 {summary['valid_count']}/{summary['total']})" + return ";".join(parts) or "未记录(无比较)" + + +def _table_rows(comparisons): + """Preserve caller ordering and distinguish gate results from coverage.""" + result = [] + for label, records in comparisons: + rows = list(records) + if not all(isinstance(row, dict) for row in rows): + raise TypeError("comparison rows must be dictionaries") + metrics = [_metric_summary(rows, key) for key in _METRICS] + 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 + if not rows: + status = "未测(0 条比较)" + elif passed == len(rows): + status = f"门槛通过({passed}/{len(rows)})" + else: + status = f"门槛未通过(通过 {passed}/{len(rows)},失败 {failed},未确认 {unknown})" + if rows and any(item["missing"] or item["undefined"] for item in metrics): + status += ";指标不完整" + result.append([str(label), *(_metric_text(item, key) for item, key in zip(metrics, _METRICS)), status]) + return result + + +def _markdown_cell(value): + # Escape HTML as well: GitHub renders HTML inside Markdown table cells. + return escape(value).replace("|", "|").replace("\r", " ").replace("\n", " ") + + +def compact_metric_table(comparisons): + """Render ``[(label, [tensor_comparison, ...]), ...]`` as Markdown. + + Only individual comparison flags determine this table's gate status. The + caller must report its overall probe status (including execution failures) + separately; this table is not a replacement for that status. + """ + rows = _table_rows(comparisons) + if not rows: + return "暂无数值比较记录。" + def render(row): + return "| " + " | ".join(_markdown_cell(value) for value in row) + " |" + + return "\n".join([render(_HEADERS), render(["---"] * len(_HEADERS)), *(render(row) for row in rows)]) + + +def compact_metric_table_html(comparisons): + """HTML equivalent of :func:`compact_metric_table`, using identical rules.""" + rows = _table_rows(comparisons) + if not rows: + return "

暂无数值比较记录。

" + header = "".join(f"{escape(value)}" for value in _HEADERS) + body = "".join("" + "".join(f"{escape(value)}" for value in row) + "" for row in rows) + return f"{header}{body}
" diff --git a/probes/w1_qwen3_export/README.md b/probes/w1_qwen3_export/README.md index 02d5da2b..f72c0192 100644 --- a/probes/w1_qwen3_export/README.md +++ b/probes/w1_qwen3_export/README.md @@ -15,24 +15,19 @@ for blocked positions. Short-input predictions use the last valid token. ## Environment -Use Python 3.12. The existing `requirements/qwen3-small-probe.txt` CPU +The published reproduction target is Ubuntu 24.04 x86_64 with Bash and Python 3.12. See the [Linux reproduction contract](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md). The existing `requirements/qwen3-small-probe.txt` CPU environment also satisfies this gate. For a separate reproducible export environment: -```sh -python -m venv output/qwen3-export-venv -source output/qwen3-export-venv/bin/activate +```bash +set -euo pipefail +python3.12 -m venv .venv-linux +source .venv-linux/bin/activate python -m pip install torch==2.7.1 --index-url https://download.pytorch.org/whl/cpu python -m pip install -r requirements/qwen3-export.txt ``` -Keep this environment activated for all `python` commands below. On Windows -PowerShell, create it with `py -3.12 -m venv output/qwen3-export-venv`, then -activate it with `& .\output\qwen3-export-venv\Scripts\Activate.ps1` and run -the two installation commands above. If activation is unavailable, replace -every later `python` with `& .\output\qwen3-export-venv\Scripts\python.exe` -(or `output/qwen3-export-venv/bin/python` on Linux); put multiline commands on -one line in PowerShell rather than using shell backslash continuations. +Keep this environment activated for all `python` commands below; the interpreter is `.venv-linux/bin/python`. Record the checkout SHA and actual dependency versions with each new run. Verify/download mode enforces NumPy 2.2.6, ONNX 1.18.0, ONNX Runtime 1.22.1 and protobuf 5.29.5; @@ -41,7 +36,7 @@ export mode additionally enforces the complete fixed export stack, including ## Verify the fixed release -```sh +```bash python probes/w1_qwen3_export/run.py --mode download \ --model-dir output/qwen3-full-model \ --output-dir output/qwen3-full-probe --threads 2 @@ -59,7 +54,7 @@ uses a previously downloaded ZIP with the same size/hash checks. For an already available complete model directory, skip network access: -```sh +```bash python probes/w1_qwen3_export/run.py --mode verify \ --model-dir models/qwen3-0.6b-onnx \ --output-dir output/qwen3-full-probe --threads 2 @@ -78,7 +73,7 @@ ScratchV's full-model execution or language quality. Obtain the official `Qwen/Qwen3-0.6B` Hugging Face snapshot at revision `c1899de289a04d12100db370d81485cdf75e47ca`. For example, in the pinned environment: -```sh +```bash python -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-0.6B', revision='c1899de289a04d12100db370d81485cdf75e47ca', local_dir='output/qwen3-source')" python probes/w1_qwen3_export/run.py --mode export \ --source-dir output/qwen3-source \ @@ -110,7 +105,7 @@ measurement. Export mode also saves `export.log` and its reference artifacts. Source fingerprints are included in failed reports as well as successful ones. Upload the report directory as CI evidence, not the multi-gigabyte model. -```sh +```bash python -m pytest tests/test_w1_qwen3_export.py -q ``` diff --git a/probes/w1_qwen3_export/run.py b/probes/w1_qwen3_export/run.py index ff35916d..8c9e2da8 100644 --- a/probes/w1_qwen3_export/run.py +++ b/probes/w1_qwen3_export/run.py @@ -362,6 +362,62 @@ def export_model(args): return manifest +def _report_markdown(report): + summary = ["# probe:qwen3-onnx", "", f"Result: {'PASS' if report['passed'] else 'FAIL'}", + f"Mode: {report['mode']}; revision: {report['revision']}", "", report["scope"], "", + f"Duration: {report['seconds']:.3f} s; verifier peak RSS: {report['peak_memory']['bytes']} bytes."] + if "error" in report: + summary += ["", f"Failed stage: {report['failed_stage']}", report["error"]] + if report.get("report_write_errors"): + summary += ["", "Report write errors:", *[f"- {item}" for item in report["report_write_errors"]]] + summary += ["", "| Probe source | SHA-256 |", "|---|---|"] + for name, fingerprint in report["source_fingerprints"].items(): + summary.append(f"| `{name}` | `{fingerprint['sha256'] or 'unavailable'}` |") + return "\n".join(summary) + "\n" + + +def write_reports(out, report): + """Publish complete JSON last and keep evidence failures non-successful.""" + failures = [] + + def write(name, content): + temporary = out / f".{name}.tmp" + try: + temporary.write_text(content, encoding="utf-8") + temporary.replace(out / name) + return True + except OSError as exc: + failures.append(f"{name}: {type(exc).__name__}: {exc}") + try: + temporary.unlink(missing_ok=True) + except OSError as cleanup_error: + failures.append(f"{temporary.name}: cleanup failed: {cleanup_error}") + return False + + def fail(): + if report["passed"]: + report.update(passed=False, failed_stage="report-write", + error="Incomplete full ONNX gate evidence") + # Keep the original environment/model/ORT error when the probe failed. + report["report_write_errors"] = list(failures) + + def recover_view(): + write("report.md", _report_markdown(report)) + report["report_write_errors"] = list(failures) + + if not write("report.md", _report_markdown(report)): + fail() + recover_view() + if not write("report.json", json.dumps(report, indent=2, allow_nan=False) + "\n"): + fail() + recover_view() + # A retry may publish only FAIL, even if a failed close wrote all bytes. + write("report.json", json.dumps(report, indent=2, allow_nan=False) + "\n") + report["report_write_errors"] = list(failures) + if failures: + print("[gate] FAIL; report write errors: " + "; ".join(failures), file=sys.stderr) + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--mode", choices=("verify", "download", "export"), default="verify") @@ -378,6 +434,11 @@ def main(argv=None): if args.output_dir == args.model_dir or args.output_dir.is_relative_to(args.model_dir): parser.error("Report output must be outside the model directory") args.output_dir.mkdir(parents=True, exist_ok=True) + # This entry point permits output-directory reuse. Invalidate the previous + # result before any work, so interruption or a failed atomic publish cannot + # leave an old successful JSON attributed to the current attempt. + for name in ("report.json", "report.md"): + (args.output_dir / name).unlink(missing_ok=True) started = time.perf_counter() report = {"schema_version": 1, "gate": "probe:qwen3-onnx", "passed": False, "created_at": datetime.now(timezone.utc).isoformat(), "mode": args.mode, @@ -413,16 +474,7 @@ def main(argv=None): report["error"] = f"{type(exc).__name__}: {exc}" report["seconds"] = time.perf_counter() - started report["peak_memory"] = peak_memory() - (args.output_dir / "report.json").write_text(json.dumps(report, indent=2, allow_nan=False) + "\n", encoding="utf-8") - summary = ["# probe:qwen3-onnx", "", f"Result: {'PASS' if report['passed'] else 'FAIL'}", - f"Mode: {args.mode}; revision: {MANIFEST['revision']}", "", report["scope"], "", - f"Duration: {report['seconds']:.3f} s; verifier peak RSS: {report['peak_memory']['bytes']} bytes."] - if "error" in report: - summary += ["", f"Failed stage: {stage}", report["error"]] - summary += ["", "| Probe source | SHA-256 |", "|---|---|"] - for name, fingerprint in report["source_fingerprints"].items(): - summary.append(f"| `{name}` | `{fingerprint['sha256'] or 'unavailable'}` |") - (args.output_dir / "report.md").write_text("\n".join(summary) + "\n", encoding="utf-8") + write_reports(args.output_dir, report) print(json.dumps({key: report[key] for key in ("gate", "passed", "mode", "seconds")}), flush=True) if not report["passed"]: print(report["error"], file=sys.stderr) diff --git a/probes/w2_backend_ops/README.md b/probes/w2_backend_ops/README.md new file mode 100644 index 00000000..dd261844 --- /dev/null +++ b/probes/w2_backend_ops/README.md @@ -0,0 +1,47 @@ +# W2 前端模式与基础后端算子验收 + +正式复现使用 Ubuntu 24.04 x86_64 / Bash / Python 3.12,先按 [Linux 复现约定](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md) 准备并激活环境。下文 `python` 为 `.venv-linux/bin/python`。 + +`unit:frontend-ops` 明确检查 RMSNorm、RoPE、SwiGLU、GQA 四种分解图模式。 +这些模式由现有 ONNX 基础算子表达,不声明已有独立融合 Attention/RMSNorm IR。 + +```bash +python -m pytest tests/test_qwen3_frontend_patterns.py -q +``` + +`unit:backend-ops` 从真实 ONNX 图走 production parser、优化器、tensor-c 后端, +交叉编译为 RV64GC ELF,然后在 QEMU `virt` 裸机执行。16 个用例分别运行 +`none`、`all` 两个优化级别,共 32 次目标执行;不下载模型权重。 + +```bash +python probes/w2_backend_ops/run.py --output-dir output/w2-backend-ops --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" +python -m pytest tests/test_w2_backend_ops_gate.py -q +``` + +也可省略工具参数,使用 PATH 或 `SCRATCHV_CC`、`SCRATCHV_QEMU`。 +使用现有 `requirements/qwen3-small-probe.txt` 中的 NumPy/ONNX/ORT 环境即可, +本探测不需要 PyTorch。输出目录须不存在或为空;依赖、工具、编译、执行或 +比较失败均非零退出,不将跳过当通过。 + +| 类别 | 用例与针对的问题 | +|---|---| +| MatMul | 4×4、四维 batch 广播、向量 dot 标量返回 | +| 逐元素 | Add/Sub/Mul/Div 串联,标量与尾维广播 | +| Softmax | axis=-1/0/1,大 logits 稳定性及非末轴 | +| RMSNorm | hidden/Q/K 的不同 head 数,全零/极小输入、权重、1e-6/1e-3 epsilon | +| RoPE | 全 head_dim rotate_half、Q/K head 广播、position=0/1/255 及非零起点 | +| SwiGLU | 两组不同 gate/up 权重,SiLU 仅作用于 gate,再经 down 投影 | +| GQA | KV 连续重复顺序、FP32 最小有限值因果 mask、attention context,重复比 2 与 MHA 比 1 | + +NumPy 参考使用独立数学公式及双精度中间值,最后转 FP32;ORT 单独执行 ONNX。 +IR 与 QEMU 都同时对照这两种参考,要求 shape、dtype 一致、全部有限,且 +`max_abs < 1e-5`、`rtol=0`。前端单测还注入错误 epsilon、旋转符号、门控分支、 +KV head 顺序,确认用例可以发现这些语义错误。 + +`report.json`、`report.md`、`report.html` 含逐例误差、失败阶段、环境及源码哈希, +还有编译命令、ELF 哈希和每次 QEMU 进程实测墙钟时长。 +`qemu_process_wall_seconds` 包括启动、guest 计算、UART 输出与退出,不是 guest +纯计算时间或周期数。每例同时保存模型、输入、NumPy/ORT/IR/QEMU 张量和工具日志, +可定位第一次不一致发生于参考、IR 还是后端。 + +门禁验证静态 FP32 小图,不代表完整 0.6B 的内存容量、性能或完整模型 QEMU 前向已通过。 diff --git a/probes/w2_backend_ops/cases.py b/probes/w2_backend_ops/cases.py new file mode 100644 index 00000000..49d182b9 --- /dev/null +++ b/probes/w2_backend_ops/cases.py @@ -0,0 +1,174 @@ +"""Small, deterministic ONNX graphs with independent NumPy formula references. + +These are semantic pattern tests, not fused RMSNorm/RoPE/SwiGLU/GQA operators. +The graph uses the same decomposed primitives accepted by the Qwen3 frontend. +""" +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import onnx +from onnx import TensorProto as T, helper, numpy_helper + + +@dataclass(frozen=True) +class Case: + name: str + family: str + model: onnx.ModelProto + feed: dict[str, np.ndarray] + expected: np.ndarray + purpose: str + + +def graph(name, family, nodes, feed, expected, purpose, constants=None): + expected = np.asarray(expected, dtype=np.float32) + model = helper.make_model(helper.make_graph( + nodes, name, + [helper.make_tensor_value_info(key, T.FLOAT, value.shape) for key, value in feed.items()], + [helper.make_tensor_value_info("y", T.FLOAT, expected.shape)], + [numpy_helper.from_array(np.asarray(value), key) for key, value in (constants or {}).items()], + ), opset_imports=[helper.make_opsetid("", 18)], ir_version=10) + onnx.checker.check_model(model) + return Case(name, family, model, feed, expected, purpose) + + +def rmsnorm_case(kind="hidden", epsilon=1e-6): + shape = (1, 3, 16) if kind == "hidden" else ((1, 4, 3, 8) if kind == "q" else (1, 2, 3, 8)) + x = np.linspace(-0.003, 0.004, np.prod(shape), dtype=np.float32).reshape(shape) + x[..., 0] = 0 + x[..., 0, :] = 0 + weight = np.linspace(0.4, 1.6, shape[-1], dtype=np.float32) + # Accumulate independently in double precision, then round only the result. + expected = (x.astype(np.float64) / np.sqrt(np.mean(x.astype(np.float64) ** 2, + axis=-1, keepdims=True) + np.float32(epsilon))) * weight + nodes = [helper.make_node("Pow", ["x", "two"], ["square"]), + helper.make_node("ReduceMean", ["square", "last"], ["variance"], keepdims=1), + helper.make_node("Add", ["variance", "epsilon"], ["stabilized"]), + helper.make_node("Sqrt", ["stabilized"], ["denominator"]), + helper.make_node("Div", ["x", "denominator"], ["normalized"]), + helper.make_node("Mul", ["normalized", "weight"], ["y"])] + return graph(f"rmsnorm_{kind}_eps_{epsilon:g}", "rmsnorm", nodes, {"x": x}, expected, + "Normalize the last dimension with learned weights; small inputs expose epsilon changes", + {"two": np.array(2, np.int64), "last": np.array([-1], np.int64), + "epsilon": np.array(epsilon, np.float32), "weight": weight}) + + +def rope_case(positions=(0, 1, 255), heads=4): + dim = 8 + x = np.linspace(-1.1, 1.2, heads * len(positions) * dim, dtype=np.float32).reshape(1, heads, -1, dim) + position = np.array(positions, np.float32).reshape(1, -1, 1) + inv = (1.0 / 1e6 ** (np.arange(0, dim, 2, dtype=np.float64) / dim)).astype(np.float32) + # Direct complex rotation of each pair of halves, not graph Slice/Concat. + angle = np.asarray(positions, np.float64)[:, None] * inv.astype(np.float64)[None, :] + first, second = x[..., :dim // 2].astype(np.float64), x[..., dim // 2:].astype(np.float64) + expected = np.empty_like(x) + expected[..., :dim // 2] = first * np.cos(angle) - second * np.sin(angle) + expected[..., dim // 2:] = second * np.cos(angle) + first * np.sin(angle) + nodes = [helper.make_node("Mul", ["position", "inv_freq"], ["angle"]), + helper.make_node("Concat", ["angle", "angle"], ["full_angle"], axis=-1), + helper.make_node("Cos", ["full_angle"], ["cos_3d"]), + helper.make_node("Sin", ["full_angle"], ["sin_3d"]), + helper.make_node("Unsqueeze", ["cos_3d", "head_axis"], ["cos"]), + helper.make_node("Unsqueeze", ["sin_3d", "head_axis"], ["sin"]), + helper.make_node("Slice", ["x", "zero", "half", "last"], ["first"]), + helper.make_node("Slice", ["x", "half", "end", "last"], ["second"]), + helper.make_node("Neg", ["second"], ["negative_second"]), + helper.make_node("Concat", ["negative_second", "first"], ["rotated"], axis=-1), + helper.make_node("Mul", ["x", "cos"], ["real"]), + helper.make_node("Mul", ["rotated", "sin"], ["imaginary"]), + helper.make_node("Add", ["real", "imaginary"], ["y"])] + return graph(f"rope_h{heads}_p{'_'.join(map(str, positions))}", "rope", nodes, + {"x": x, "position": position}, expected, + "Full head dimension rotate_half with broadcast positions, including nonzero offsets", + {"inv_freq": inv.reshape(1, 1, -1), "head_axis": np.array([1], np.int64), + "zero": np.array([0], np.int64), "half": np.array([4], np.int64), + "end": np.array([8], np.int64), "last": np.array([-1], np.int64)}) + + +def swiglu_case(seed=11): + rng = np.random.default_rng(seed) + x = rng.normal(size=(1, 3, 4)).astype(np.float32) + weights = {name: (rng.normal(size=shape) / 4).astype(np.float32) for name, shape in + (("gate_weight", (4, 7)), ("up_weight", (4, 7)), ("down_weight", (7, 4)))} + gate = x.astype(np.float64) @ weights["gate_weight"].astype(np.float64) + up = x.astype(np.float64) @ weights["up_weight"].astype(np.float64) + expected = ((gate / (1 + np.exp(-gate))) * up) @ weights["down_weight"].astype(np.float64) + nodes = [helper.make_node("MatMul", ["x", "gate_weight"], ["gate"]), + helper.make_node("MatMul", ["x", "up_weight"], ["up"]), + helper.make_node("Sigmoid", ["gate"], ["activation"]), + helper.make_node("Mul", ["gate", "activation"], ["silu"]), + helper.make_node("Mul", ["silu", "up"], ["hidden"]), + helper.make_node("MatMul", ["hidden", "down_weight"], ["y"])] + return graph(f"swiglu_seed_{seed}", "swiglu", nodes, {"x": x}, expected, + "Different gate/up projections, SiLU on gate only, and down projection", weights) + + +def gqa_case(repeats=2): + rng = np.random.default_rng(131) + kv_heads, length, dim = 2, 3, 4 + q = (rng.normal(size=(1, kv_heads * repeats, length, dim)) / 2).astype(np.float32) + k = (rng.normal(size=(1, kv_heads, length, dim)) / 2).astype(np.float32) + v = (np.arange(kv_heads * length * dim).reshape(1, kv_heads, length, dim) / 13).astype(np.float32) + mask = np.where(np.tril(np.ones((length, length), dtype=bool)), + 0, np.finfo(np.float32).min).astype(np.float32) + expected = np.empty_like(q) + # Explicit head mapping detects tile-vs-repeat mistakes; masked softmax is + # calculated independently in double precision for every query head. + for head in range(q.shape[1]): + kv = head // repeats + scores = q[0, head].astype(np.float64) @ k[0, kv].astype(np.float64).T / np.sqrt(dim) + mask + probabilities = np.exp(scores - scores.max(axis=-1, keepdims=True)) + probabilities /= probabilities.sum(axis=-1, keepdims=True) + expected[0, head] = probabilities @ v[0, kv].astype(np.float64) + nodes = [] + for name in ("k", "v"): + nodes.extend([helper.make_node("Unsqueeze", [name, "repeat_axis"], [f"{name}_5d"]), + helper.make_node("Expand", [f"{name}_5d", "expanded_shape"], [f"{name}_expanded"]), + helper.make_node("Reshape", [f"{name}_expanded", "repeated_shape"], [f"{name}_repeated"])]) + nodes.extend([helper.make_node("Transpose", ["k_repeated"], ["kt"], perm=[0, 1, 3, 2]), + helper.make_node("MatMul", ["q", "kt"], ["unscaled"]), + helper.make_node("Mul", ["unscaled", "scale"], ["scores"]), + helper.make_node("Add", ["scores", "mask"], ["masked"]), + helper.make_node("Softmax", ["masked"], ["probabilities"], axis=-1), + helper.make_node("MatMul", ["probabilities", "v_repeated"], ["y"])]) + return graph(f"gqa_repeat_{repeats}", "gqa", nodes, {"q": q, "k": k, "v": v}, expected, + "Contiguous KV head repetition, causal mask and attention context, including MHA ratio 1", + {"repeat_axis": np.array([2], np.int64), "scale": np.array(1 / np.sqrt(dim), np.float32), + "mask": mask, "expanded_shape": np.array([1, kv_heads, repeats, length, dim], np.int64), + "repeated_shape": np.array([1, kv_heads * repeats, length, dim], np.int64)}) + + +def build_cases(): + rng = np.random.default_rng(314159) + cases = [] + for name, left, right in (("matrix", (4, 4), (4, 4)), + ("batch_broadcast", (2, 1, 3, 5), (1, 4, 5, 2)), + ("vector_dot", (5,), (5,))): + a, b = [(rng.normal(size=shape) / 3).astype(np.float32) for shape in (left, right)] + cases.append(graph(f"matmul_{name}", "matmul", [helper.make_node("MatMul", ["a", "b"], ["y"])], + {"a": a, "b": b}, a.astype(np.float64) @ b.astype(np.float64), + "Matrix, batched broadcasting and scalar dot-product outputs")) + x = np.linspace(-2, 2, 24, dtype=np.float32).reshape(2, 3, 4) + bias = np.array([0.1, -0.3, 0.7, 1.1], np.float32) + denominator = np.array(0.75, np.float32) + cases.append(graph("elementwise_broadcast", "elementwise", [ + helper.make_node("Add", ["x", "bias"], ["sum"]), + helper.make_node("Sub", ["sum", "denominator"], ["difference"]), + helper.make_node("Mul", ["difference", "bias"], ["product"]), + helper.make_node("Div", ["product", "denominator"], ["y"])], + {"x": x, "bias": bias, "denominator": denominator}, + (x.astype(np.float64) + bias - denominator) * bias / denominator, + "Scalar and trailing-dimension broadcast through Add/Sub/Mul/Div")) + for axis in (-1, 0, 1): + data = np.arange(24, dtype=np.float32).reshape(2, 3, 4) + 1000 + expected = np.exp(data.astype(np.float64) - data.max(axis=axis, keepdims=True)) + expected /= expected.sum(axis=axis, keepdims=True) + cases.append(graph(f"softmax_axis_{axis}", "softmax", [ + helper.make_node("Softmax", ["x"], ["y"], axis=axis)], {"x": data}, expected, + "Numerically stable softmax with large logits and non-last dimensions")) + cases.extend([rmsnorm_case("hidden"), rmsnorm_case("q"), rmsnorm_case("k", 1e-3), + rope_case(), rope_case((7, 19, 255), heads=2), + swiglu_case(), swiglu_case(23), gqa_case(), gqa_case(1)]) + return cases diff --git a/probes/w2_backend_ops/run.py b/probes/w2_backend_ops/run.py new file mode 100644 index 00000000..53fc22f2 --- /dev/null +++ b/probes/w2_backend_ops/run.py @@ -0,0 +1,272 @@ +"""W2 operator gate: independent formulas -> ORT -> IR -> tensor C -> RV64 QEMU. + +All seven families and both optimization levels are mandatory. Missing tools, +nonfinite tensors, shape/type changes or max_abs >= 1e-5 fail the gate. +""" +from __future__ import annotations + +import argparse +import copy +import hashlib +from html import escape +import json +import math +from pathlib import Path +import platform +import sys +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 +import onnxruntime as ort + +from probes.w2_backend_ops.cases import build_cases +from probes.w2_qwen3_small.diagnostics import tensor_diff +from probes.numeric_summary import compact_metric_table, compact_metric_table_html, METRIC_EXPLANATION +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.pass_manager import create_optimization_pass_manager +from scratchv.runtime.riscv_tensor import build_riscv_tensor, discover_toolchain, run_riscv_tensor +from scratchv.verification.ir_interpreter import IRInterpreter + +ATOL = 1e-5 +LEVELS = ("none", "all") +FAMILIES = ("matmul", "elementwise", "softmax", "rmsnorm", "rope", "swiglu", "gqa") +FRONTEND_FAMILIES = ("rmsnorm", "rope", "swiglu", "gqa") + + +def require_valid(program): + passed, issues = verify_ir(program) + if not passed: + raise ValueError("; ".join(str(issue) for issue in issues)) + + +def reference_case(case, path): + onnx.save(case.model, path) + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + expected = ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"]).run(None, case.feed)[0] + parser = ONNXParser() + program = parser.parse(str(path)) + require_valid(program) + return expected, parser, program + + +def interpret(program, bindings, feed, level): + manager = create_optimization_pass_manager(level) + manager.before_pass = lambda p, value: require_valid(value) + manager.after_pass = lambda p, value: require_valid(value) + optimized = manager.run_pipeline(copy.deepcopy(program)).data + require_valid(optimized) + return IRInterpreter(optimized).run(feed, initializers=bindings).return_value + + +def evidence(): + sources = [Path(__file__), ROOT / "probes/w2_backend_ops/cases.py", + ROOT / "probes/w2_qwen3_small/diagnostics.py", ROOT / "probes/numeric_summary.py", + ROOT / "scratchv/verification/numeric_metrics.py", ROOT / "scratchv/compiler.py", + ROOT / "scratchv/frontend/onnx_parser.py", ROOT / "scratchv/ir/types.py", + ROOT / "scratchv/ir/builder.py", ROOT / "scratchv/pass_manager.py", + ROOT / "scratchv/pass_interface.py", ROOT / "scratchv/verification/ir_interpreter.py", + ROOT / "scratchv/verification/ir_numpy_ops.py", + ROOT / "scratchv/backend/tensor_c_codegen.py", ROOT / "scratchv/runtime/riscv_tensor.py", + *sorted((ROOT / "scratchv/analysis").glob("*.py")), + *sorted((ROOT / "scratchv/optimizer").glob("*.py"))] + return {"environment": {"python": platform.python_version(), "executable": sys.executable, + "platform": platform.platform(), "numpy": np.__version__, + "onnx": onnx.__version__, "onnxruntime": ort.__version__}, + "source_sha256": {path.relative_to(ROOT).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sources}} + + +def run_probe(out, report, *, cc=None, qemu=None, timeout=120): + cases = build_cases() + if (not cases or len({case.name for case in cases}) != len(cases) + or set(case.family for case in cases) != set(FAMILIES)): + raise ValueError("Cases must have unique names and cover every required operator family") + report.update(evidence(), stage="toolchain", families=list(FAMILIES), levels=list(LEVELS), + planned_executions=len(cases) * len(LEVELS), cases=[]) + tools = discover_toolchain(cc=cc, qemu=qemu) + report["toolchain"] = {"cc": list(tools.cc), "qemu": tools.qemu} + report["stage"] = "execution" + for case in cases: + folder = out / case.name + folder.mkdir() + row = {"name": case.name, "family": case.family, "purpose": case.purpose, + "passed": False, "stage": "reference", "executions": []} + report["cases"].append(row) + try: + path = folder / "model.onnx" + expected, parser, program = reference_case(case, path) + np.savez(folder / "inputs.npz", **case.feed) + np.save(folder / "numpy.npy", case.expected) + np.save(folder / "ort.npy", expected) + row["model_sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + row["input_sha256"] = {name: hashlib.sha256(array.tobytes()).hexdigest() + for name, array in case.feed.items()} + row["ort_vs_numpy"] = tensor_diff(expected, case.expected, ATOL) + if not row["ort_vs_numpy"]["passed"]: + raise ValueError("ORT disagrees with independent NumPy formula") + for level in LEVELS: + target = folder / level + target.mkdir() + execution = {"optimization": level, "passed": False, "stage": "ir", + "qemu_process_wall_seconds": None} + row["executions"].append(execution) + try: + ir_output = interpret(program, parser.initializers, case.feed, level) + np.save(target / "ir.npy", ir_output) + execution["ir_vs_ort"] = tensor_diff(ir_output, expected, ATOL) + execution["ir_vs_numpy"] = tensor_diff(ir_output, case.expected, ATOL) + if not all(execution[key]["passed"] for key in ("ir_vs_ort", "ir_vs_numpy")): + raise ValueError("IR disagrees with ORT or independent NumPy formula") + execution["stage"] = "compile" + driver = CompilerDriver(CompilerConfig(backend="tensor-c", optimize_level=level, verify_ir=True)) + result = driver.compile(str(path), str(target / "model.c")) + if not result.success: + raise RuntimeError("; ".join(result.errors)) + executable = build_riscv_tensor(driver.tensor_artifact, target / "build", tools) + execution.update(elf_sha256=executable.elf_sha256, compile_command=executable.compile_command, + compile_seconds=executable.compile_seconds, + workspace_bytes=executable.workspace_bytes, tool_versions=executable.tool_versions, + stage="qemu") + actual = run_riscv_tensor(executable, case.feed, target / "run", timeout=timeout) + execution.update(qemu_process_wall_seconds=actual.elapsed_s, command=actual.command, stage="numeric") + np.save(target / "qemu.npy", actual.output) + execution["qemu_vs_ort"] = tensor_diff(actual.output, expected, ATOL) + execution["qemu_vs_numpy"] = tensor_diff(actual.output, case.expected, ATOL) + execution["passed"] = all(execution[key]["passed"] for key in ("qemu_vs_ort", "qemu_vs_numpy")) + execution["stage"] = "complete" + except Exception as exc: + execution["error"] = f"{type(exc).__name__}: {exc}" + if getattr(exc, "elapsed_s", None) is not None: + execution.update(qemu_process_wall_seconds=exc.elapsed_s, + command=getattr(exc, "command", None), + status=getattr(exc, "status", "runtime_error")) + print(f"[{case.name}/{level}] {'PASS' if execution['passed'] else 'FAIL'} " + f"max_abs={execution.get('qemu_vs_ort', {}).get('max_abs')} " + f"stage={execution['stage']}", flush=True) + row["passed"] = len(row["executions"]) == len(LEVELS) and all(item["passed"] for item in row["executions"]) + row["stage"] = "complete" + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + executions = [execution for case in report["cases"] for execution in case["executions"]] + report["completed_executions"] = sum(item["stage"] == "complete" for item in executions) + report["passed_executions"] = sum(item["passed"] for item in executions) + measured = [item["qemu_process_wall_seconds"] for item in executions if item["qemu_process_wall_seconds"] is not None] + report["qemu_process_wall_seconds"] = {"measured_count": len(measured), + "total": sum(measured) if measured else None} + report["passed"] = (len(executions) == report["planned_executions"] + and all(case["passed"] for case in report["cases"])) + report["stage"] = "complete" + + +def _report_markdown(report): + payload = json.dumps(report, indent=2, ensure_ascii=False, allow_nan=False) + "\n" + lines = ["# W2 backend operator gate", "", f"Result: {'PASS' if report['passed'] else 'FAIL'}", + "", "Strict max_abs < 1e-5; rtol=0; exact shape/dtype; finite values.", + "", "| Case | Level | Result | QEMU vs ORT max_abs | QEMU process seconds |", + "|---|---|---|---|---|"] + for case in report.get("cases", []): + if not case["executions"]: + lines.append(f"| {case['name']} | not started | FAIL | - | - |") + for item in case["executions"]: + lines.append(f"| {case['name']} | {item['optimization']} | {'PASS' if item['passed'] else 'FAIL'} | " + f"{item.get('qemu_vs_ort', {}).get('max_abs')} | {item['qemu_process_wall_seconds']} |") + lines.extend(["", "Level 的 none/all 表示关闭/开启全部 ScratchV IR 优化;C 交叉编译均使用 -O2。" + "QEMU process seconds 为进程墙钟秒数,包含启动、目标程序计算、输出传输和退出,不是纯计算耗时。", + "", "Full diagnostics, commands, environment and source hashes:", "", "```json", payload.rstrip(), "```", ""]) + return "\n".join(lines) + + +def write_reports(out, report): + """Keep primary errors and publish JSON only after required evidence succeeds.""" + failures = [] + + def write(name, content, *, atomic=False): + path = out / name + pending = out / (name + ".tmp") if atomic else path + try: + pending.write_text(content, encoding="utf-8") + if atomic: + pending.replace(path) + except OSError as exc: + failures.append(f"{name}: {type(exc).__name__}: {exc}") + finally: + if atomic: + try: + pending.unlink(missing_ok=True) + except OSError: + pass + + def record_failure(): + report["report_write_errors"] = failures + if report["passed"]: + report.update(passed=False, stage="report-write", error="Incomplete backend gate evidence") + + def views(): + markdown = _report_markdown(report) + summary = f"W2 基础后端算子检查:{'PASS' if report['passed'] else 'FAIL'}(展开详情)" + comparisons = [("基础算子 C→RISC-V/QEMU 对 ORT", [ + next((execution.get("qemu_vs_ort", {}) for execution in case.get("executions", []) + if execution.get("optimization") == level), {}) + for case in report.get("cases", []) for level in LEVELS])] + description = ("基础后端检查 MatMul、逐元素、Softmax、RMSNorm、RoPE、SwiGLU、GQA 七类算子或组合;" + "16 个用例分别关闭/开启 IR 优化,计划 32 次 QEMU 执行。不是完整 Transformer 模型。") + write("report.md", f"
\n{summary}\n\n{description}\n\n" + + compact_metric_table(comparisons) + "\n\n" + METRIC_EXPLANATION + + f"\n\n{markdown}\n
\n\n") + write("report.html", "W2 backend operators" + "
" + escape(summary) + "

" + escape(description) + "

" + + compact_metric_table_html(comparisons) + "

" + escape(METRIC_EXPLANATION) + "

" + escape(markdown)
+              + "
") + + views() + if failures: + record_failure() + views() + # Write and close a sibling temporary file before publishing JSON. A late + # write/close failure must not leave a valid PASS artifact for CI consumers. + before_json = len(failures) + write("report.json", json.dumps(report, indent=2, ensure_ascii=False, allow_nan=False) + "\n", atomic=True) + if len(failures) != before_json: + record_failure() + views() + if failures: + print("[unit:backend-ops] FAIL; report write errors: " + "; ".join(failures), file=sys.stderr) + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--output-dir", type=Path, default=ROOT / "output/w2-backend-ops") + cli.add_argument("--cc") + cli.add_argument("--qemu") + cli.add_argument("--timeout", type=float, default=120) + args = cli.parse_args(argv) + if not math.isfinite(args.timeout) or args.timeout <= 0: + cli.error("--timeout must be finite and positive") + out = args.output_dir.resolve() + if out.exists() and (not out.is_dir() or any(out.iterdir())): + cli.error("--output-dir must be empty") + out.mkdir(parents=True, exist_ok=True) + report = {"passed": False, "stage": "setup", "backend": "ScratchV tensor-c -> RV64GC -> QEMU", + "atol": ATOL, "rtol": 0, "timeout_seconds": args.timeout} + start = time.perf_counter() + try: + run_probe(out, report, cc=args.cc, qemu=args.qemu, timeout=args.timeout) + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + traceback.print_exc() + report["seconds"] = time.perf_counter() - start + write_reports(out, report) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w2_qwen3_parse/README.md b/probes/w2_qwen3_parse/README.md new file mode 100644 index 00000000..d303efe6 --- /dev/null +++ b/probes/w2_qwen3_parse/README.md @@ -0,0 +1,76 @@ +# W2:完整 Qwen3 ONNX 前端解析探测 + +入口 `probes/w2_qwen3_parse/run.py` 面向固定发布的完整 28 层 Qwen3-0.6B 图,验证 ScratchV 前端解析与结构、节点及权重绑定审计。使用 W1 已固定的 FP32、batch=1、L=256、无 KV Cache 的 ONNX 产物和 [manifest](../w1_qwen3_export/manifest.json)。 + +本入口不执行完整模型 IR,不生成完整模型 RISC-V,不运行 QEMU,也不重新导出模型或比较 PyTorch/ORT 数值。完整模型的 ORT 执行继续由 [W1 导出门禁](../w1_qwen3_export/README.md) 负责;两项门禁分别报告结果。 + +## 准备环境 + +正式复现使用 Ubuntu 24.04 x86_64 / Bash / Python 3.12,固定 NumPy 2.2.6、ONNX 1.18.0、ONNX Runtime 1.22.1 和 protobuf 5.29.5。可直接复用 W1 的固定环境;单独准备时无需安装 torch、Zig 或 QEMU。 + +Linux Bash: + +```bash +set -euo pipefail +python3.12 -m venv output/qwen3-parse-venv +. output/qwen3-parse-venv/bin/activate +python -m pip install "numpy==2.2.6" "onnx==1.18.0" "onnxruntime==1.22.1" "protobuf==5.29.5" +python -m pip check +python -X utf8 -B probes/w2_qwen3_parse/run.py --mode verify \ + --model-dir output/qwen3-full-model --output-dir output/qwen3-parse --timeout 300 +``` + +所有命令在仓库根目录执行。`--model-dir` 必填,应指向同目录保存 `model.onnx` 和三个真实 external-data 分片的完整产物,不能只有 ONNX 文件或 LFS pointer。`verify` 是默认模式,不访问网络;不存在完整模型时,将命令中的 `--mode verify` 改成 `--mode download`,由入口获取并校验 W1 固定发布产物。不要用新导出或其他 revision 的文件替换固定产物。 + +`--output-dir` 默认是 `output/qwen3-parse`,必须不存在或为空,每次使用新的目录以保留失败日志与首次报告。`--timeout` 默认 300 秒,限制整个验证子进程,包含文件检查、解析、审计,以及 download 模式的下载;慢网络应据实调整此上限。它是超时设置,不是执行耗时或纯解析耗时。 + +固定 ZIP 约 1.24 GB,解包后需要数 GiB 磁盘空间,完整权重解析也需要数 GiB 内存。解析通过不表示当前 tensor-c 的常量上限或 QEMU 内存布局能够装载完整模型;不要从小型 fixture 的内存消耗推断完整图容量。 + +## 验收范围与证据 + +检查顺序及具体失败阶段以 `report.json` 为准。完整门禁覆盖固定产物身份、完整图结构与 28 层覆盖、实际 ONNX→共享 IR 解析、节点转换与 initializer 绑定审计,以及共享 IR 验证器检查。节点数量、算子统计、绑定数量和实际耗时应读取本次报告,不使用旧报告代替执行。 + +结构检查沿实际输入依赖追踪模型,而不是只统计节点名中的层号: + +- 核对全部 initializer 的名字、shape、dtype,以及两个静态输入和 logits 输出。 +- 核对每层投影和 RMSNorm、Q/K 归一化、attention 的缩放/掩码/Softmax/V/O 路径、RoPE 位置依赖和 GQA 扩展。 +- 核对 SwiGLU、两个残差连接和完整层顺序,最后归一化连接共享 LM Head。 +- 要求全部节点和 initializer 都参与返回 logits 的依赖,避免断开的装饰节点被当作完整模型。 + +这些是固定图的结构规则,不是对算子数值实现或所有可能 ONNX 导出形式的证明。 + +转换审计记录实际 parser 的每个节点,核对 IR 指令区间、opcode、输入 Value、输出映射和静态属性,允许合法的 Constant 绑定与 Identity 别名而不把它们误报为漏转换。形状、切片和轴等控制参数从原 ONNX 小型控制子图独立求值,不复用被检查解析器的常量缓存。所有 ONNX value 的静态 shape/dtype、全局张量集合与唯一 RETURN 也会核对。权重和 Constant 绑定逐张量比较 shape、dtype 与内容 SHA256;外部分片按块读取校验,不为审计再保存一套权重。该审计采用明确支持的基础算子转换规则,未知转换方式应报错,由新增规则和测试后再接受。 + +| 文件 | 用途 | +|---|---| +| `report.json` | 机器可读的本次结果、阶段、来源与审计摘要 | +| `report.md`、`report.html` | 便于 review 的摘要和可浏览报告 | +| `nodes.json` | ONNX 节点与转换审计证据 | +| `bindings.json` | initializer / IR 绑定审计证据 | +| `ir.txt` | 本次解析得到的共享 IR 文本 | +| `worker.stdout`、`worker.stderr`、`worker-progress.json` | 子进程日志和最后进度,用于定位超时或异常退出 | + +报告目录不包含权重分片。CI 上传该目录,不上传模型目录。失败可能发生在报告明细生成之前,应结合报告与命令日志判断缺失文件,不能把缺失当作通过。 + +`attempt` 保存 supervisor 启动前的命令、输入路径、解释器与源码身份;它用于定位早期失败,不代表 worker 已初始化或验证通过。`log_errors` 和 `evidence_errors` 分别保存附加日志、失败明细的写入问题,不覆盖原始解析/审计错误。成功路径的必需证据或日志无法保存时仍失败。 + +`seconds` 记录 supervisor 溯源开始至验证与日志保存结束的耗时,不含随后读取最后进度与生成最终三种报告的时间;各阶段和纯解析耗时另行记录。峰值 RSS 覆盖验证子进程,不包含 supervisor 父进程。平台无法提供的测量值保持未测得。超时后只报告已经保存的进度,不能将 300 秒上限当作实际解析耗时或补全缺失的峰值内存。 + +成功需要命令退出 0 且本次 `report.json` 的 `passed` 为 true。失败时保留退出码、失败阶段、错误、输入模型身份和已经产生的明细。记录实际 checkout SHA;有未提交修改时还应保存完整 diff 和新增文件,单独记录 HEAD 不能确定所执行代码。 + +`passed=true` 只证明这份固定产物完成本门禁的前端解析与审计。它不证明完整 IR 数值正确、优化后语义等价、完整权重部署、文本生成或目标硬件性能。下一阶段仍需逐步开展完整模型 IR/ORT 数值对照和容量验证。 + +## 轻量测试与 CI + +```bash +python -m pip install "pytest==9.1.1" +python -m pytest tests/test_qwen3_full_structure.py tests/test_qwen3_full_audit.py tests/test_qwen3_full_parse.py -q +``` + +命令中的 `python` 指已激活的 `output/qwen3-parse-venv/bin/python`。这些测试使用小型 fixture 和故障注入,不下载或加载完整权重,不能代替完整产物实跑。 + +[LLM 工作流](../../.github/workflows/llm-deploy.yml) 的 PR 路径接入上述三组测试,并在独立 `full-qwen3-frontend` job 以 `--mode download` 实跑完整固定图。手动/定时同样运行该解析任务。解析有独立 Summary,并以 `always()` 上传 `output/qwen3-parse/`,artifact 名为 `qwen3-full-frontend-parse-output`。下载约 1.24 GB,解析 worker 峰值 RSS 约 5 GB;runner 的实际容量与耗时仍须远端验证。 + +`w2-acceptance` 汇总任务要求数值任务和完整解析任务均实际成功,失败、取消、跳过或缺失都不能通过。手动/定时的 `full-qwen3-onnx` job 独立保留 W1 完整 ONNX ORT 验证;解析不依赖它成功。并行的两个完整模型任务会各自下载资产。 + +PR #91 已合并为 W1 基线;这些 W2 代码、测试、文档与 CI 配置独立发布,W1 两层报告修复见 [PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93)。[W2 交付记录](../../docs/llm-deploy-v1.0/W2/README.md) 保存 Linux 复现入口与已记录的 Linux CI 证据,CI 配置存在不等于远端已通过。W1 已有旧提交的两层第二人复现;完整模型第二人复现和团队接口确认仍待完成,见 [W1 记录](../../docs/llm-deploy-v1.0/W1/README.md)。 diff --git a/probes/w2_qwen3_parse/audit.py b/probes/w2_qwen3_parse/audit.py new file mode 100644 index 00000000..cc8a8914 --- /dev/null +++ b/probes/w2_qwen3_parse/audit.py @@ -0,0 +1,417 @@ +"""Read-only evidence for complete ONNX -> IR translation and weight binding. + +This deliberately audits the fixed, single-output Qwen graph. It does not run +the model or claim numerical equivalence. External tensors are hashed in chunks +without loading another copy of the complete weights. +""" + +from __future__ import annotations + +import hashlib +import math +from pathlib import Path, PureWindowsPath + +import numpy as np +import onnx + +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.ir.types import DataType, OpCode +from probes.w2_qwen3_parse.validation import make_control_constant_reader + + +class AuditError(ValueError): + """Translation or binding evidence is incomplete or inconsistent.""" + + +_TYPES = {1: DataType.FLOAT32, 6: DataType.INT32, + 7: DataType.INT64, 11: DataType.FLOAT64} +_CHUNK_BYTES = 1024 * 1024 + + +def _require(condition, message): + if not condition: + raise AuditError(message) + + +def _value_record(value): + return {"ir_name": value.name, "shape": list(value.shape), + "dtype": value.dtype.value} + + +class AuditedONNXParser(ONNXParser): + """The production parser, with per-node observations rather than new lowering.""" + + def __init__(self, progress=None): + super().__init__() + self.progress = progress + self.node_records = [] + self.current_context = {"stage": "not_started"} + self._node_instructions = [] + + def parse(self, model_path): + self.node_records = [] + self._node_instructions = [] + self.current_context = {"stage": "parse", "model_path": str(model_path)} + if self.progress: + self.progress(dict(self.current_context)) + program = super().parse(model_path) + self.current_context = {"stage": "parsed", "node_count": len(self.node_records)} + if self.progress: + self.progress(dict(self.current_context)) + return program + + def _translate_node(self, node, output_names): + index = len(self.node_records) + block = self.builder.current_block + start = len(block.instructions) + self.current_context = {"stage": "translate_node", "node_index": index, + "node_name": node.name, "op_type": node.op_type, + "inputs": list(node.input), "outputs": list(node.output)} + if self.progress: + self.progress(dict(self.current_context)) + record = {**self.current_context, "ir_start": start, "status": "running", + "input_values": {name: _value_record(self._value_map[name]) + for name in node.input if name in self._value_map}} + try: + super()._translate_node(node, output_names) + except Exception as exc: + record.update(status="failed", error=f"{type(exc).__name__}: {exc}") + self.node_records.append(record) + raise + instructions = block.instructions[start:] + record.update(status="translated", ir_end=len(block.instructions), + output_values={name: _value_record(self._value_map[name]) + for name in node.output if name in self._value_map}, + instructions=[{"opcode": inst.opcode.value, + "dest": inst.dest.name if inst.dest else None, + "operands": [value.name for value in inst.operands], + "attrs": dict(inst.attrs)} for inst in instructions]) + self.node_records.append(record) + self._node_instructions.append(instructions) + + +def _array_hash(data): + """Hash logical C-order little-endian bytes with bounded temporary buffers.""" + digest = hashlib.sha256() + array = np.asarray(data) + little_dtype = array.dtype.newbyteorder("<") + if array.flags.c_contiguous and array.dtype == little_dtype: + view = memoryview(array.reshape(-1)).cast("B") + for offset in range(0, len(view), _CHUNK_BYTES): + digest.update(view[offset:offset + _CHUNK_BYTES]) + else: + iterator = np.nditer(array, flags=["external_loop", "buffered", "zerosize_ok"], + op_flags=["readonly"], op_dtypes=[little_dtype], + order="C", buffersize=max(1, _CHUNK_BYTES // array.itemsize)) + for chunk in iterator: + digest.update(chunk.tobytes(order="C")) + return digest.hexdigest() + + +def _external_hash(tensor, model_dir, expected_bytes): + entries = list(tensor.external_data) + fields = {item.key: item.value for item in entries} + _require(len(fields) == len(entries), f"{tensor.name}: duplicate external-data keys") + location = fields.get("location", "") + windows = PureWindowsPath(location) + _require(bool(location) and "\x00" not in location and not windows.drive + and not windows.is_absolute() and not Path(location).is_absolute(), + f"{tensor.name}: invalid external-data location") + root = Path(model_dir).resolve() + path = (root / location.replace("\\", "/")).resolve() + _require(path.is_relative_to(root) and path.is_file(), + f"{tensor.name}: external data missing or outside model directory: {location}") + for key in ("offset", "length"): + if key in fields: + _require(fields[key].isascii() and fields[key].isdecimal(), + f"{tensor.name}: invalid external-data {key}") + offset = int(fields.get("offset", 0)) + size = path.stat().st_size + length = int(fields.get("length", size - offset)) + _require(offset <= size and length == expected_bytes and offset + length <= size, + f"{tensor.name}: external-data range/length mismatch") + digest = hashlib.sha256() + remaining = length + with path.open("rb") as stream: + stream.seek(offset) + while remaining: + chunk = stream.read(min(_CHUNK_BYTES, remaining)) + _require(bool(chunk), f"{tensor.name}: truncated external data") + digest.update(chunk) + remaining -= len(chunk) + return digest.hexdigest(), {"location": location, "offset": offset, "length": length} + + +def _tensor_spec(tensor, model_dir): + _require(tensor.data_type in _TYPES, f"{tensor.name}: unsupported tensor dtype") + dtype = np.dtype(onnx.helper.tensor_dtype_to_np_dtype(tensor.data_type)) + shape = tuple(tensor.dims) + _require(all(dim >= 0 for dim in shape), f"{tensor.name}: negative tensor dimension") + size = math.prod(shape) * dtype.itemsize + if tensor.data_location == onnx.TensorProto.EXTERNAL: + sha, external = _external_hash(tensor, model_dir, size) + else: + sha = _array_hash(onnx.numpy_helper.to_array(tensor)) + external = None + return dtype, shape, size, sha, external + + +def _onnx_attributes(node): + """Read source protobuf directly, independently of production lowering.""" + attributes = {attribute.name: onnx.helper.get_attribute_value(attribute) + for attribute in node.attribute} + _require(len(attributes) == len(node.attribute), f"{node.name}: duplicate ONNX attributes") + return attributes + + +def _constant_spec(node, model_dir): + attrs = _onnx_attributes(node) + _require(len(attrs) == 1, f"{node.name}: Constant requires exactly one attribute") + key, data = next(iter(attrs.items())) + if key == "value": + return _tensor_spec(data, model_dir) + if key in ("value_int", "value_ints"): + array = np.asarray(data, dtype=np.int64) + elif key in ("value_float", "value_floats"): + array = np.asarray(data, dtype=np.float32) + else: + raise AuditError(f"{node.name}: unsupported Constant attribute {key}") + return array.dtype, array.shape, array.nbytes, _array_hash(array), None + + +def _same_literal(left, right): + if left == right: + return left != 0 or math.copysign(1, left) == math.copysign(1, right) + return (isinstance(left, float) and isinstance(right, float) + and math.isnan(left) and math.isnan(right)) + + +def _audit_scalar_load(instruction, data, dtype, context): + """Cover auxiliary LOAD_CONST results which have no ONNX value-map entry.""" + destination = instruction.dest + _require(instruction.opcode == OpCode.LOAD_CONST and destination is not None + and not instruction.operands and not instruction.target + and destination.dtype == dtype and destination.shape == () + and destination.is_constant is True + and _same_literal(destination.const_value, data.item()) + and set(instruction.attrs) == {"value"} + and _same_literal(instruction.attrs.get("value"), data.item()), + f"{context}: scalar instruction metadata mismatch") + + +def _expected_lowering(node, parser, read_control): + """Independent structural contract for the operations in the fixed graph.""" + attrs = _onnx_attributes(node) + names = [name for name in node.input if name] + args = [parser._value_map[name] for name in names] + op = node.op_type + ir_attrs = {} + operands = args + if op in {"Abs", "Add", "Cast", "Cos", "Div", "Exp", "Gather", "MatMul", + "Mul", "Neg", "Pow", "Reciprocal", "Relu", "Sigmoid", "Sin", + "Softmax", "Sqrt", "Sub", "Transpose", "Concat", "Reshape", + "Expand", "Unsqueeze", "Slice", "ReduceMean"}: + opcode = "reduce_mean" if op == "ReduceMean" else op.lower() + else: + raise AuditError(f"{node.name}: no independent lowering contract for {op}") + + def integers(index): + # Never ask the parser/IR/cache to certify its own shape evaluation. + # This reader resolves the original ONNX graph with bounded NumPy ops. + name = node.input[index] + array = read_control(name) + _require(array.ndim == 1 and array.dtype.kind in "iu", + f"{node.name}: {name} is not a constant integer vector") + return tuple(int(value) for value in array) + + if op == "Cast": + _require(set(attrs) == {"to"} and attrs["to"] in _TYPES, + f"{node.name}: unsupported Cast attributes") + # CAST encodes its target in dest.dtype, not an instruction attribute. + ir_attrs = {} + elif op in ("Gather", "Softmax"): + ir_attrs = {"axis": attrs.get("axis", 0 if op == "Gather" else -1)} + elif op == "MatMul": + a, b = args + if len(a.shape) == len(b.shape) == 2 and all(d > 0 for d in a.shape + b.shape): + ir_attrs = {"m": a.shape[0], "n": b.shape[1], "k": a.shape[1]} + elif op in ("Reshape", "Expand"): + _require(not attrs.get("allowzero", 0), f"{node.name}: unsupported allowzero") + ir_attrs = {"shape": integers(1)} + operands = args[:1] + elif op == "Unsqueeze": + ir_attrs = {"axes": integers(1) if len(args) > 1 else tuple(attrs["axes"])} + operands = args[:1] + elif op == "Slice": + for index, key in enumerate(("starts", "ends", "axes", "steps"), 1): + if len(node.input) > index and node.input[index]: + ir_attrs[key] = integers(index) + elif key in attrs: + ir_attrs[key] = tuple(attrs[key]) + operands = args[:1] + elif op == "ReduceMean": + axes = integers(1) if len(args) > 1 else attrs.get("axes") + if not axes and attrs.get("noop_with_empty_axes", 0): + return None, args[:1], {} + ir_attrs = {"keepdims": bool(attrs.get("keepdims", 1))} + if axes is not None: + ir_attrs["axes"] = tuple(axes) + operands = args[:1] + elif op == "Transpose" and "perm" in attrs: + ir_attrs = {"perm": tuple(attrs["perm"])} + elif op == "Concat": + ir_attrs = {"axis": attrs["axis"]} + return opcode, operands, ir_attrs + + +def audit_parsed_model(model, parser, program, model_dir): + """Raise AuditError on any gap; otherwise return serializable evidence.""" + graph = model.graph + _require(len(graph.output) == 1, "fixed-graph audit requires exactly one graph output") + _require(program is parser.builder.program, "program is not the observed parser result") + _require(len(program.functions) == 1 and len(program.functions[0].blocks) == 1, + "fixed-graph audit requires one function and one basic block") + function = program.functions[0] + instructions = function.blocks[0].instructions + _require(len(parser.node_records) == len(graph.node) + and len(parser._node_instructions) == len(graph.node), + "translated node count does not cover the ONNX graph") + read_control = make_control_constant_reader(model) + inferred = onnx.shape_inference.infer_shapes(model, strict_mode=True) + metadata = {value.name: value.type.tensor_type for value in + [*inferred.graph.input, *inferred.graph.value_info, *inferred.graph.output]} + expected_names = {value.name for value in [*graph.input, *graph.initializer]} + initializer_names = {value.name for value in graph.initializer} + _require(len(initializer_names) == len(graph.initializer), "duplicate initializer names") + def progress(stage, **context): + parser.current_context = {"stage": stage, **context} + if parser.progress: + parser.progress(dict(parser.current_context)) + + bindings = [] + for value in graph.initializer: + progress("audit_source_tensor", tensor_name=value.name, source="initializer") + bindings.append((value.name, "initializer", _tensor_spec(value, model_dir))) + for node in graph.node: + _require(len(node.output) == 1 and bool(node.output[0]), + f"{node.name}: audit requires one nonempty node output") + _require(node.output[0] not in expected_names, f"{node.name}: duplicate ONNX definition") + expected_names.add(node.output[0]) + if node.op_type == "Constant": + progress("audit_source_tensor", tensor_name=node.output[0], source="Constant") + bindings.append((node.output[0], "Constant", _constant_spec(node, model_dir))) + _require(set(parser._value_map) == expected_names, + "ONNX -> IR value mapping has missing or unexpected names") + _require(graph.output[0].name in expected_names, + "graph output does not have an ONNX definition") + for name in expected_names - initializer_names: + info = metadata.get(name) + _require(info is not None and info.HasField("shape") + and all(dim.HasField("dim_value") for dim in info.shape.dim), + f"{name}: missing static ONNX shape") + _require(info.elem_type in _TYPES, f"{name}: unsupported ONNX dtype") + value = parser._value_map[name] + _require(value.shape == tuple(dim.dim_value for dim in info.shape.dim) + and value.dtype == _TYPES[info.elem_type], f"{name}: IR shape/dtype mismatch") + + globals_by_name = {value.name: value for value in program.global_values} + binding_names = {name for name, _, _ in bindings} + _require(len(globals_by_name) == len(program.global_values) + and set(globals_by_name) == binding_names, + "IR global definitions are missing, duplicated, or unexpected") + _require(set(parser.initializers) == binding_names, + "initializer bindings are missing or unexpected") + tensor_rows = [] + for name, source, (dtype, shape, size, expected_sha, external) in bindings: + progress("audit_bound_tensor", tensor_name=name, source=source) + array = parser.initializers[name] + _require(isinstance(array, np.ndarray), f"{name}: binding must be a NumPy array") + value = globals_by_name[name] + _require(value is parser._value_map[name], f"{name}: global/value mapping identity mismatch") + _require(array.dtype == dtype and array.shape == shape and value.shape == shape + and value.dtype == _TYPES[onnx.helper.np_dtype_to_tensor_dtype(dtype)], + f"{name}: tensor binding shape/dtype mismatch") + _require(value.is_constant == (len(shape) == 0), f"{name}: scalar metadata mismatch") + if len(shape) == 0: + _require(_same_literal(value.const_value, array.item()), f"{name}: scalar value mismatch") + actual_sha = _array_hash(array) + _require(actual_sha == expected_sha, f"{name}: bound tensor content SHA256 mismatch") + tensor_rows.append({"name": name, "source": source, "shape": list(shape), + "dtype": str(dtype), "bytes": size, "sha256": actual_sha, + "external": external}) + expected_params = [parser._value_map[value.name] for value in graph.input + if value.name not in initializer_names] + _require(len(function.params) == len(expected_params) + and all(a is b for a, b in zip(function.params, expected_params)), + "function parameters do not match ONNX inputs") + expected_param_names = [value.name for value in graph.input if value.name not in initializer_names] + _require([value.name for value in function.params] == expected_param_names, + "function parameter names do not match ONNX input bindings") + _require(not any(value.is_constant for value in function.params), + "function parameters must not be constants") + + scalar_initializers = [value for value in graph.initializer if len(value.dims) == 0] + cursor = len(scalar_initializers) + for inst, tensor in zip(instructions[:cursor], scalar_initializers): + data = parser.initializers[tensor.name] + _audit_scalar_load(inst, data, parser._value_map[tensor.name].dtype, + f"{tensor.name}: scalar initializer") + alias_rows = [] + for index, (node, record, observed) in enumerate(zip( + graph.node, parser.node_records, parser._node_instructions)): + progress("audit_node", node_index=index, node_name=node.name, op_type=node.op_type) + context = f"node #{index} {node.name} ({node.op_type})" + _require(record.get("status") == "translated" and record.get("node_index") == index + and record.get("node_name") == node.name and record.get("op_type") == node.op_type + and record.get("inputs") == list(node.input) and record.get("outputs") == list(node.output), + f"{context}: node observation mismatch") + end = record.get("ir_end") + _require(record.get("ir_start") == cursor and isinstance(end, int) + and end == cursor + len(observed) + and all(a is b for a, b in zip(instructions[cursor:end], observed)) + and len(instructions[cursor:end]) == len(observed), + f"{context}: emitted IR range changed or has a gap") + cursor = end + output = parser._value_map[node.output[0]] + if node.op_type == "Constant": + _require(not node.input, f"{context}: Constant has inputs") + array = parser.initializers[node.output[0]] + _require(len(observed) == (1 if array.ndim == 0 else 0), + f"{context}: Constant emitted unexpected instructions") + if observed: + _audit_scalar_load(observed[0], array, output.dtype, f"{context}: Constant") + continue + if node.op_type == "Identity": + _require(len(node.input) == 1, f"{context}: invalid Identity inputs") + opcode, operands, ir_attrs = None, [parser._value_map[node.input[0]]], {} + else: + opcode, operands, ir_attrs = _expected_lowering(node, parser, read_control) + if opcode is None: + _require(not observed and output is operands[0], f"{context}: incorrect alias binding") + alias_rows.append({"onnx_name": node.output[0], "input": node.input[0], + "ir_name": output.name}) + else: + _require(len(observed) == 1, f"{context}: node did not emit exactly one instruction") + inst = observed[0] + _require(inst.opcode.value == opcode and inst.dest is output, + f"{context}: wrong IR opcode or output binding") + _require(len(inst.operands) == len(operands) + and all(a is b for a, b in zip(inst.operands, operands)), + f"{context}: wrong IR dependency bindings") + _require(inst.attrs == ir_attrs, f"{context}: IR attributes mismatch") + _require(len(instructions) == cursor + 1, "unexpected instructions outside node ranges and RETURN") + returns = [inst for inst in instructions if inst.opcode == OpCode.RETURN] + output_name = graph.output[0].name + _require(len(returns) == 1 and instructions[-1] is returns[0] + and len(returns[0].operands) == 1 + and returns[0].operands[0] is parser._value_map[output_name], + "actual RETURN does not bind the unique ONNX graph output") + progress("audit_complete", node_count=len(graph.node), binding_count=len(bindings)) + return {"passed": True, "node_count": len(graph.node), "value_count": len(expected_names), + "ir_instruction_count": len(instructions), "global_count": len(globals_by_name), + "initializer_count": len(graph.initializer), + "constant_count": len(bindings) - len(graph.initializer), + "binding_count": len(bindings), "binding_bytes": sum(row["bytes"] for row in tensor_rows), + "tensor_bindings": tensor_rows, "alias_count": len(alias_rows), "aliases": alias_rows, + "return": {"onnx_name": output_name, **_value_record(parser._value_map[output_name])}, + "node_records": parser.node_records} diff --git a/probes/w2_qwen3_parse/run.py b/probes/w2_qwen3_parse/run.py new file mode 100644 index 00000000..1cf2366b --- /dev/null +++ b/probes/w2_qwen3_parse/run.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Audit complete, pinned Qwen3 ONNX -> ScratchV IR parsing and weight binding. + +This gate does not execute the full IR, generate RISC-V code, or run QEMU. +Large-model work runs in a bounded child process; failure preserves reports. +""" +from __future__ import annotations + +import argparse +from collections import Counter +from datetime import datetime, timezone +from html import escape +import json +import math +from pathlib import Path +import subprocess +import sys +import time +import traceback + +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 artifacts + +GATE = "frontend:parse-qwen3" +SCOPE = ("Complete pinned 28-layer Qwen3 ONNX parsing, graph connectivity, IR verification " + "and actual weight/constant bindings. No full-model IR numerical comparison, " + "code generation or QEMU execution.") + + +def write_json(path, value): + path = Path(path) + # Readers must not observe a half-written progress or result file. + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n", + encoding="utf-8") + temporary.replace(path) + + +def source_fingerprints(): + files = [*sorted(Path(__file__).parent.glob("*.py")), + ROOT / "probes/w1_qwen3_export/run.py", ROOT / "probes/w1_qwen3_export/manifest.json", + ROOT / "scratchv/frontend/onnx_parser.py", ROOT / "scratchv/ir/types.py", + ROOT / "scratchv/ir/builder.py", ROOT / "scratchv/verification/ir_numpy_ops.py", + ROOT / "scratchv/runtime/riscv_tensor.py", + *sorted((ROOT / "scratchv/analysis").glob("*.py"))] + return {p.relative_to(ROOT).as_posix(): artifacts.sha256(p) for p in files} + + +def checkout_evidence(): + result = {"head": None, "status": "unknown", "clean": None} + if not (ROOT / ".git").exists(): + # Worktrees have a .git file; source archives have neither a file nor + # a directory here and must not inherit another checkout's identity. + result["reason"] = "Source directory has no .git metadata" + return result + try: + for key, arguments in (("head", ["rev-parse", "HEAD"]), + ("status", ["status", "--porcelain=v1", "--untracked-files=all"])): + process = subprocess.run(["git", *arguments], cwd=ROOT, capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=10, + check=True) + result[key] = process.stdout.rstrip("\r\n") + result["clean"] = not bool(result["status"]) + except (OSError, subprocess.SubprocessError) as exc: + result.update(head=None, status="unknown", clean=None, + error=f"{type(exc).__name__}: {exc}", + reason="Checkout metadata unavailable") + return result + + +def initial_report(mode): + return {"schema_version": 1, "gate": GATE, "passed": False, "stage": "initialization", + "created_at": datetime.now(timezone.utc).isoformat(), "mode": mode, "scope": SCOPE, + "model_id": artifacts.MANIFEST["model_id"], "revision": artifacts.MANIFEST["revision"], + "stages_seconds": {}} + + +def save_failure_evidence(path, value, report): + """A secondary evidence failure must not replace the active validation error.""" + try: + write_json(path, value) + except (OSError, TypeError, ValueError) as exc: + report.setdefault("evidence_errors", []).append( + f"{path.name}: {type(exc).__name__}: {exc}") + + +def run_validation(model_dir, out, report, *, mode="verify", manifest=None, progress=None): + """Run real parsing. Small test fixtures may explicitly supply a manifest. + + The CLI always uses the committed full-model manifest and architecture. + This function never replaces parsing with a handler inventory or ORT run. + """ + import onnx + from scratchv.analysis.ir_verifier import verify_ir + from probes.w2_qwen3_parse.audit import AuditedONNXParser, audit_parsed_model + from probes.w2_qwen3_parse.validation import audit_graph_structure + + manifest = artifacts.MANIFEST if manifest is None else manifest + model_dir, out = Path(model_dir).resolve(), Path(out).resolve() + report["model_dir"] = str(model_dir) + + def stage(name, operation): + report["stage"] = name + if progress: + progress({"stage": name}) + started = time.perf_counter() + try: + return operation() + finally: + report["stages_seconds"][name] = time.perf_counter() - started + + if mode == "download": + report["acquisition"] = stage("acquire", lambda: artifacts.acquire_model(model_dir, manifest=manifest)) + report["hashes"] = stage("hashes", lambda: artifacts.verify_files(model_dir, manifest)) + report["onnx_structure"] = stage("onnx-structure", lambda: artifacts.inspect_model(model_dir, report["hashes"])) + model = stage("graph-load", lambda: onnx.load(str(model_dir / "model.onnx"), load_external_data=False)) + report["graph"] = stage("graph-connectivity", lambda: audit_graph_structure(model)) + if report["graph"].get("passed") is not True: + raise ValueError("Complete Qwen3 graph connectivity did not pass") + parser = AuditedONNXParser(progress=progress) + try: + program = stage("scratchv-parse", lambda: parser.parse(str(model_dir / "model.onnx"))) + except Exception: + report["failure_context"] = dict(parser.current_context) + report["nodes_completed"] = sum(row.get("status") == "translated" for row in parser.node_records) + save_failure_evidence(out / "nodes.partial.json", parser.node_records, report) + raise + report["ir"] = {"functions": len(program.functions), "globals": len(program.global_values), + "blocks": sum(len(f.blocks) for f in program.functions), + "instructions": sum(len(b.instructions) for f in program.functions for b in f.blocks), + "operators": dict(sorted(Counter(i.opcode.value for f in program.functions + for b in f.blocks for i in b.instructions).items()))} + (out / "ir.txt").write_text(program.dump(), encoding="utf-8") + write_json(out / "nodes.json", parser.node_records) + verified, issues = stage("ir-verifier", lambda: verify_ir(program, stage="full-qwen3-after-parse")) + report["ir_verifier"] = {"passed": verified, "issue_count": len(issues), + "issues": [str(issue) for issue in issues]} + if not verified: + raise ValueError("ScratchV IR verifier rejected the complete model; see ir_verifier.issues") + try: + audit = stage("ir-and-bindings", lambda: audit_parsed_model(model, parser, program, model_dir)) + except Exception: + report["failure_context"] = dict(parser.current_context) + save_failure_evidence(out / "nodes.json", parser.node_records, report) + raise + nodes = audit.pop("node_records", parser.node_records) + bindings = audit.pop("tensor_bindings") + write_json(out / "nodes.json", nodes) + write_json(out / "bindings.json", bindings) + report["audit"] = audit + if audit.get("passed") is not True: + raise ValueError("IR/weight binding audit did not pass") + report["evidence"] = {name: {"sha256": artifacts.sha256(out / name), + "bytes": (out / name).stat().st_size} + for name in ("nodes.json", "bindings.json", "ir.txt")} + report["passed"] = True + report["stage"] = "complete" + + +def worker_main(args): + report = initial_report(args.mode) + out = args.output_dir.resolve() + started = time.perf_counter() + last_checkpoint = -math.inf + + def checkpoint(context): + nonlocal last_checkpoint + now = time.perf_counter() + # Stage boundaries are always recorded; frequent node progress is sampled. + if set(context) == {"stage"} or now - last_checkpoint >= 0.5: + write_json(out / "worker-progress.json", {"context": context, + "elapsed_seconds": now - started, "peak_memory": artifacts.peak_memory()}) + last_checkpoint = now + + try: + report["stage"] = "environment" + checkpoint({"stage": "environment"}) + report["environment"] = artifacts.environment() + report["checkout"] = checkout_evidence() + report["source_sha256"] = source_fingerprints() + run_validation(args.model_dir, out, report, mode=args.mode, progress=checkpoint) + except Exception as exc: + report["passed"] = False + report["error"] = f"{type(exc).__name__}: {exc}" + traceback.print_exc() + report["validation_seconds"] = time.perf_counter() - started + report["peak_memory"] = artifacts.peak_memory() + report["peak_memory"]["scope"] = "Validation worker process, including loading and real ScratchV parsing; parent excluded" + write_json(out / "worker-report.json", report) + return 0 if report["passed"] else 1 + + +def worker_command(args): + return [sys.executable, "-B", "-X", "utf8", str(Path(__file__).resolve()), "--worker", + "--mode", args.mode, "--model-dir", str(args.model_dir.resolve()), + "--output-dir", str(args.output_dir.resolve())] + + +def launch_worker(args): + from scratchv.runtime.riscv_tensor import _run_process + return _run_process(worker_command(args), cwd=ROOT, timeout=args.timeout) + + +def number(value): + return "未测得" if value is None else f"{value:.3f}" + + +def save_worker_logs(out, report, stdout, stderr): + """Keep reporting the validation result even when a log file is locked.""" + for name, content in (("worker.stdout", stdout), ("worker.stderr", stderr)): + try: + (out / name).write_bytes(content or b"") + except OSError as exc: + report.setdefault("log_errors", []).append(f"{name}: {type(exc).__name__}: {exc}") + if report.get("log_errors") and report.get("passed"): + report.update(passed=False, stage="log-write", error="Could not preserve validation worker logs") + + +def _render_reports(report): + graph, audit, ir = report.get("graph", {}), report.get("audit", {}), report.get("ir", {}) + layers = graph.get("layers", []) + peak = report.get("peak_memory", {}) + lines = ["# 完整 Qwen3 ONNX → ScratchV IR 解析验收", "", + f"Result: **{'PASS' if report['passed'] else 'FAIL'}**;阶段:`{report['stage']}`", "", + "只验证完整解析、结构、IR 和实际权重绑定;不证明完整 IR 数值或 QEMU 前向通过。", "", + f"- 模型:`{report['model_id']}`,revision `{report['revision']}`。", + f"- 源码 HEAD:`{report.get('checkout', {}).get('head', 'unavailable')}`;" + f"工作树 clean:`{report.get('checkout', {}).get('clean', 'unavailable')}`。", + "- dirty 工作树的结果须结合 source_sha256 和本地 diff,不能只归属 HEAD。", + f"- 完整层链:{len(layers) if isinstance(layers, list) else layers} 层;" + f"ONNX 节点:{report.get('onnx_structure', {}).get('operators') and sum(report['onnx_structure']['operators'].values()) or '未完成'}。", + f"- IR:{ir.get('instructions', '未完成')} 条指令、{ir.get('globals', '未完成')} 个 globals;" + f"verifier:{report.get('ir_verifier', {}).get('passed', '未完成')}。", + f"- Supervisor 总耗时:{number(report.get('seconds'))} 秒;" + f"实际解析:{number(report.get('stages_seconds', {}).get('scratchv-parse'))} 秒。", + f"- 验证进程峰值 RSS:{peak.get('bytes', '未测得')} bytes;{peak.get('method', '')}。", "", + "| 阶段 | 耗时(秒) |", "|---|---:|"] + lines += [f"| {name} | {number(seconds)} |" for name, seconds in report.get("stages_seconds", {}).items()] + if report.get("error"): + lines += ["", "## 失败信息", "", report["error"], "", + "```json", json.dumps(report.get("failure_context", report.get("last_progress", {})), + ensure_ascii=False, indent=2), "```"] + if report.get("log_errors"): + lines += ["", "子进程日志落盘失败:", *report["log_errors"]] + if report.get("evidence_errors"): + lines += ["", "失败明细保存异常(原始验证错误保留):", *report["evidence_errors"]] + if report.get("report_write_errors"): + lines += ["", "报告保存异常(原始验证错误保留):", *report["report_write_errors"]] + concise_audit = {key: value for key, value in audit.items() if key != "aliases"} + lines += ["", "## 审计摘要", "", "```json", json.dumps(concise_audit, ensure_ascii=False, indent=2), "```", "", + "`nodes.json` 保存节点翻译记录;`bindings.json` 保存逐张量绑定和内容哈希;" + "`ir.txt` 保存 IR 文本,均不包含原始权重数组。", "", + "缺文件、解析错误、审计失败、超时或进程异常均失败;未执行的检查不能视为通过。" + "耗时和内存为观察数据,没有新增性能通过阈值。"] + markdown = "\n".join(lines) + "\n" + details = json.dumps({"graph": graph, "audit": audit, "ir_verifier": report.get("ir_verifier")}, + ensure_ascii=False, indent=2) + html = ( + "Qwen3 完整解析验收" + "" + f"

frontend:parse-qwen3 — {'PASS' if report['passed'] else 'FAIL'}

" + f"
{escape(markdown)}
结构与绑定详情" + f"
{escape(details)}
") + return {"report.md": markdown, "report.html": html} + + +def write_reports(out, report): + """Publish JSON last, so incomplete evidence cannot leave a stale PASS.""" + failures = [] + + def record_failure(name, exc): + failures.append(f"{name}: {type(exc).__name__}: {exc}") + report["report_write_errors"] = failures + if report["passed"]: + report.update(passed=False, stage="report-write", + error="Could not preserve complete validation reports") + + def write_views(*, record_errors): + for name, content in _render_reports(report).items(): + try: + (out / name).write_text(content, encoding="utf-8") + except OSError as exc: + if record_errors: + record_failure(name, exc) + + write_views(record_errors=True) + if failures: + # A view written before another failed may still contain PASS. Refresh + # every writable view, preserving the original validation failure. + write_views(record_errors=False) + try: + write_json(out / "report.json", report) + except OSError as exc: + record_failure("report.json", exc) + write_views(record_errors=False) + for error in failures: + print(error, file=sys.stderr) + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--mode", choices=("verify", "download"), default="verify") + cli.add_argument("--model-dir", type=Path, required=True) + cli.add_argument("--output-dir", type=Path, default=ROOT / "output/qwen3-parse") + cli.add_argument("--timeout", type=float, default=300) + cli.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) + args = cli.parse_args(argv) + if not math.isfinite(args.timeout) or args.timeout <= 0: + cli.error("--timeout must be finite and positive") + if args.worker: + return worker_main(args) + out = args.output_dir.resolve() + if out.exists() and (not out.is_dir() or any(out.iterdir())): + cli.error("--output-dir must be absent or empty; preserve previous evidence") + out.mkdir(parents=True, exist_ok=True) + report = initial_report(args.mode) + report.update(stage="worker-launch", timeout_seconds=args.timeout) + started = time.perf_counter() + result = None + try: + # Keep the attempted run identifiable even if the child never starts or + # exits before it can validate dependencies and write its own report. + report["attempt"] = {"command": worker_command(args), "cwd": str(ROOT), + "model_dir": str(args.model_dir.resolve()), + "python": sys.version, "checkout": checkout_evidence(), + "source_sha256": source_fingerprints(), + "scope": "Supervisor metadata; not proof of worker initialization or validation"} + result = launch_worker(args) + report["worker_returncode"] = result.returncode + worker = json.loads((out / "worker-report.json").read_text(encoding="utf-8")) + if worker.get("gate") != GATE or worker.get("schema_version") != 1: + raise ValueError("Invalid validation worker report") + report.update(worker) + if result.returncode or report.get("passed") is not True or report.get("stage") != "complete": + report["passed"] = False + report.setdefault("error", f"Validation worker did not pass (exit {result.returncode})") + elif any(not (out / name).is_file() for name in ("nodes.json", "bindings.json", "ir.txt")): + raise ValueError("Validation worker omitted required evidence files") + except subprocess.TimeoutExpired as exc: + report.update(passed=False, stage="timeout", error=f"Validation worker exceeded {args.timeout} seconds") + save_worker_logs(out, report, exc.stdout, exc.stderr) + except Exception as exc: + report["passed"] = False + report["error"] = f"{type(exc).__name__}: {exc}" + finally: + if result is not None: + save_worker_logs(out, report, result.stdout, result.stderr) + report["seconds"] = time.perf_counter() - started + if not report["passed"] and (out / "worker-progress.json").is_file(): + try: + report["last_progress"] = json.loads((out / "worker-progress.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + pass + write_reports(out, report) + print(f"[{GATE}] {'PASS' if report['passed'] else 'FAIL'}; stage={report['stage']}; " + f"seconds={report['seconds']:.3f}; report={out / 'report.json'}", flush=True) + if report.get("error"): + print(report["error"], file=sys.stderr) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w2_qwen3_parse/validation.py b/probes/w2_qwen3_parse/validation.py new file mode 100644 index 00000000..dfc3dfe5 --- /dev/null +++ b/probes/w2_qwen3_parse/validation.py @@ -0,0 +1,427 @@ +"""Structural acceptance of the pinned, decomposed Qwen3 ONNX graph. + +This deliberately recognizes the exported architecture, not arbitrary graphs +whose node names contain ``layers.N``. It follows actual operands from the +weights through both residuals in every decoder layer to the returned logits. +No external tensor data is read and no full-model numerical claim is made. +""" + +from __future__ import annotations + +from collections import Counter, defaultdict +import math + +import numpy as np +import onnx + + +QWEN3_06B_CONFIG = { + "num_hidden_layers": 28, + "hidden_size": 1024, + "intermediate_size": 3072, + "num_attention_heads": 16, + "num_key_value_heads": 8, + "head_dim": 128, + "vocab_size": 151936, + "sequence_length": 256, + "rms_norm_eps": 1e-6, +} + + +class Qwen3StructureError(ValueError): + """A missing or incorrectly connected part of the Qwen3 architecture.""" + + +def _require(condition, message): + if not condition: + raise Qwen3StructureError(message) + + +def _attrs(node): + return {a.name: onnx.helper.get_attribute_value(a) for a in node.attribute} + + +def _one(nodes, context): + _require(len(nodes) == 1, f"{context}: expected exactly one node, found {len(nodes)}") + return nodes[0] + + +class _Graph: + def __init__(self, graph): + self.graph = graph + self.initializers = {t.name: t for t in graph.initializer} + self.nodes = list(graph.node) + self.producers = {} + self.consumers = defaultdict(list) + self.by_op = defaultdict(list) + self.bits = {} + self.ancestors = {} + self.constants = {} + self.indices = {} + roots = list(dict.fromkeys([*(v.name for v in graph.input), *self.initializers])) + for index, name in enumerate(roots): + self.bits[name] = self.ancestors[name] = 1 << index + for index, node in enumerate(self.nodes): + label = node.name or f"node[{index}]" + self.indices[id(node)] = index + _require(node.domain in ("", "ai.onnx"), f"{label}: unsupported domain {node.domain!r}") + _require(len(node.output) == 1 and node.output[0], + f"{label}: this pinned graph requires one nonempty output per node") + output = node.output[0] + _require(output not in self.ancestors, f"{label}: duplicate definition {output!r}") + bit = 1 << (len(roots) + index) + inherited = bit + for value in node.input: + if not value: + continue + _require(value in self.ancestors, + f"{label}: input {value!r} has no preceding definition") + inherited |= self.ancestors[value] + self.consumers[value].append(node) + self.bits[output] = bit + self.ancestors[output] = inherited + self.producers[output] = node + self.by_op[node.op_type].append(node) + + def depends(self, output, value): + return bool(self.ancestors.get(output, 0) & self.bits.get(value, 0)) + + def strip(self, value, extra=()): + while value in self.producers: + node = self.producers[value] + if node.op_type not in ("Identity", "Cast", *extra): + break + value = node.input[0] + return value + + def producer(self, value, op, context): + node = self.producers.get(self.strip(value)) + _require(node is not None and node.op_type == op, + f"{context}: expected {op} producer for {value!r}") + return node + + def same(self, left, right, extra=()): + return self.strip(left, extra) == self.strip(right, extra) + + def constant(self, value): + """Resolve only tiny control constants; never open external weights.""" + if value in self.constants: + return self.constants[value] + if value in self.initializers: + tensor = self.initializers[value] + _require(not tensor.external_data and math.prod(tensor.dims) <= 1024, + f"constant {value!r}: external or large tensor is not a control constant") + result = onnx.numpy_helper.to_array(tensor) + else: + node = self.producers.get(value) + _require(node is not None, f"constant {value!r}: missing definition") + attrs = _attrs(node) + op = node.op_type + if op == "Constant": + if "value" in attrs: + tensor = attrs["value"] + _require(not tensor.external_data and math.prod(tensor.dims) <= 1024, + f"constant {value!r}: external or large tensor") + result = onnx.numpy_helper.to_array(tensor) + elif "value_int" in attrs: + result = np.asarray(attrs["value_int"], dtype=np.int64) + elif "value_ints" in attrs: + result = np.asarray(attrs["value_ints"], dtype=np.int64) + elif "value_float" in attrs: + result = np.asarray(attrs["value_float"], dtype=np.float32) + elif "value_floats" in attrs: + result = np.asarray(attrs["value_floats"], dtype=np.float32) + else: + raise Qwen3StructureError(f"constant {value!r}: unsupported Constant encoding") + elif op in ("Identity", "Cast", "Abs"): + result = self.constant(node.input[0]) + if op == "Cast": + result = result.astype(onnx.helper.tensor_dtype_to_np_dtype(attrs["to"])) + elif op == "Abs": + result = np.abs(result) + elif op == "Reshape": + data = self.constant(node.input[0]) + shape = self.constant(node.input[1]).reshape(-1).tolist() + shape = [data.shape[i] if d == 0 else d for i, d in enumerate(shape)] + result = data.reshape(shape) + elif op == "Concat": + result = np.concatenate([self.constant(v) for v in node.input], axis=attrs["axis"]) + elif op == "Unsqueeze": + axes = self.constant(node.input[1]).reshape(-1).tolist() + result = np.expand_dims(self.constant(node.input[0]), tuple(axes)) + else: + raise Qwen3StructureError(f"constant {value!r}: unexpected {op} dependency") + _require(result.size <= 1024, f"constant {value!r}: exceeds control-constant size limit") + self.constants[value] = result + return result + + def scalar(self, value, expected, context): + data = self.constant(value) + _require(data.size == 1 and np.isfinite(data).all() + and math.isclose(float(data.reshape(-1)[0]), expected, rel_tol=1e-6, abs_tol=0), + f"{context}: {value!r} must be scalar {expected}") + + def projection(self, weight, context): + candidates = [] + for node in self.by_op["MatMul"]: + if len(node.input) != 2: + continue + transposed = self.producers.get(self.strip(node.input[1])) + if (transposed is not None and transposed.op_type == "Transpose" + and self.same(transposed.input[0], weight)): + _require(tuple(_attrs(transposed).get("perm", (1, 0))) == (1, 0), + f"{context}: weight transpose must use perm=[1, 0]") + candidates.append(node) + return _one(candidates, f"{context}, weight {weight!r}") + + def norm(self, weight, epsilon, context): + node = _one([n for n in self.by_op["Mul"] + if len(n.input) == 2 and any(self.same(v, weight) for v in n.input)], + f"{context}, weight {weight!r}") + normalized = _one([v for v in node.input if not self.same(v, weight)], + f"{context}: normalized activation") + multiply = self.producer(normalized, "Mul", context) + reciprocals = [v for v in multiply.input + if self.producers.get(self.strip(v)) is not None + and self.producers[self.strip(v)].op_type == "Reciprocal"] + reciprocal_value = _one(reciprocals, f"{context}: RMS inverse square root") + reciprocal = self.producer(reciprocal_value, "Reciprocal", context) + raw = next(v for v in multiply.input if v != reciprocal_value) + sqrt = self.producer(reciprocal.input[0], "Sqrt", context) + add = self.producer(sqrt.input[0], "Add", context) + means = [v for v in add.input if self.producers.get(self.strip(v)) is not None + and self.producers[self.strip(v)].op_type == "ReduceMean"] + mean_value = _one(means, f"{context}: RMS mean") + self.scalar(next(v for v in add.input if v != mean_value), epsilon, context) + mean = self.producer(mean_value, "ReduceMean", context) + attrs = _attrs(mean) + axes = (self.constant(mean.input[1]).reshape(-1).tolist() + if len(mean.input) == 2 else list(attrs.get("axes", []))) + _require(axes == [-1] and attrs.get("keepdims", 1) == 1, + f"{context}: RMS ReduceMean must use axes=[-1], keepdims=1") + power = self.producer(mean.input[0], "Pow", context) + _require(self.same(power.input[0], raw), f"{context}: RMS numerator/denominator input mismatch") + self.scalar(power.input[1], 2.0, context) + return node, self.strip(raw) + + def residual(self, branch, bypass, context): + candidates = [n for n in self.by_op["Add"] if len(n.input) == 2 + and ((self.same(n.input[0], branch) and self.same(n.input[1], bypass)) + or (self.same(n.input[1], branch) and self.same(n.input[0], bypass)))] + return _one(candidates, context) + + +def make_control_constant_reader(model): + """Return ``reader(onnx_value_name) -> ndarray`` for small control values. + + Resolution uses the original ONNX graph, never the parser, its cached + constants, or the emitted IR. Every intermediate is limited to 1,024 + elements and external control tensors are rejected. This is the bounded + constant subset needed by the pinned export's axes, slices and shapes, + not a general ONNX interpreter or a way to load model weights. + """ + return _Graph(model.graph).constant + + +def _metadata(graph, config): + hidden, head = config["hidden_size"], config["head_dim"] + query, kv = config["num_attention_heads"] * head, config["num_key_value_heads"] * head + middle = config["intermediate_size"] + per_layer = { + "self_attn.q_proj.weight": (query, hidden), + "self_attn.k_proj.weight": (kv, hidden), + "self_attn.v_proj.weight": (kv, hidden), + "self_attn.o_proj.weight": (hidden, query), + "self_attn.q_norm.weight": (head,), + "self_attn.k_norm.weight": (head,), + "mlp.gate_proj.weight": (middle, hidden), + "mlp.up_proj.weight": (middle, hidden), + "mlp.down_proj.weight": (hidden, middle), + "input_layernorm.weight": (hidden,), + "post_attention_layernorm.weight": (hidden,), + } + expected = {f"model.model.layers.{i}.{name}": (shape, onnx.TensorProto.FLOAT) + for i in range(config["num_hidden_layers"]) for name, shape in per_layer.items()} + expected.update({ + "model.model.norm.weight": ((hidden,), onnx.TensorProto.FLOAT), + "model.lm_head.weight": ((config["vocab_size"], hidden), onnx.TensorProto.FLOAT), + "positions": ((1, config["sequence_length"]), onnx.TensorProto.INT64), + "model.model.rotary_emb.inv_freq": ((head // 2,), onnx.TensorProto.FLOAT), + }) + actual = {t.name: t for t in graph.initializer} + _require(len(actual) == len(graph.initializer), "initializers: duplicate weight names") + _require(set(actual) == set(expected), + f"initializers: missing={sorted(set(expected) - set(actual))}, " + f"unexpected={sorted(set(actual) - set(expected))}") + for name, (shape, dtype) in expected.items(): + tensor = actual[name] + _require(tuple(tensor.dims) == shape and tensor.data_type == dtype, + f"weight {name!r}: expected shape={shape}, dtype={dtype}; " + f"got shape={tuple(tensor.dims)}, dtype={tensor.data_type}") + length = config["sequence_length"] + signature = {"input_ids": ((1, length), onnx.TensorProto.INT64), + "attention_mask": ((1, 1, length, length), onnx.TensorProto.FLOAT), + "logits": ((1, length, config["vocab_size"]), onnx.TensorProto.FLOAT)} + _require([v.name for v in graph.input] == ["input_ids", "attention_mask"], + "graph inputs must be input_ids and attention_mask, in that order") + _require([v.name for v in graph.output] == ["logits"], "graph output must be logits") + for value in [*graph.input, *graph.output]: + tensor = value.type.tensor_type + shape, dtype = signature[value.name] + _require(all(d.HasField("dim_value") for d in tensor.shape.dim) + and tuple(d.dim_value for d in tensor.shape.dim) == shape + and tensor.elem_type == dtype, f"{value.name}: incorrect static shape or dtype") + + +def _attention(index, q, k, v, out, q_norm, k_norm, config, context): + qv, kv = q_norm.output[0], k_norm.output[0] + softmax = _one([n for n in index.by_op["Softmax"] + if index.depends(out.input[0], n.output[0]) + and index.depends(n.input[0], qv) and index.depends(n.input[0], kv)], + f"{context}: attention Softmax") + _require(_attrs(softmax).get("axis", -1) == -1, + f"{context}: attention Softmax must use axis=-1") + scores = _one([n for n in index.by_op["MatMul"] + if index.depends(softmax.input[0], n.output[0]) + and index.depends(n.input[0], qv) and index.depends(n.input[1], kv)], + f"{context}: QK attention MatMul") + _require(not index.depends(scores.input[0], kv) and not index.depends(scores.input[1], qv), + f"{context}: Q and K score operands must retain separate paths") + weighted = _one([n for n in index.by_op["MatMul"] + if index.depends(out.input[0], n.output[0]) + and index.same(n.input[0], softmax.output[0]) + and index.depends(n.input[1], v.output[0])], + f"{context}: attention probabilities times V") + mask_add = index.producer(softmax.input[0], "Add", context) + masks = [value for value in mask_add.input + if index.depends(value, "attention_mask") and not index.depends(value, scores.output[0])] + mask = _one(masks, f"{context}: additive attention mask") + scale = index.producer(next(value for value in mask_add.input if value != mask), "Mul", context) + scale_inputs = [value for value in scale.input if not index.same(value, scores.output[0])] + _require(len(scale_inputs) == 1, f"{context}: scores must be multiplied by a scalar scale") + index.scalar(scale_inputs[0], config["head_dim"] ** -0.5, f"{context}: attention scale") + for op in ("Cos", "Sin"): + rope = _one([n for n in index.by_op[op] + if index.depends(scores.input[0], n.output[0]) + and index.depends(scores.input[1], n.output[0])], f"{context}: shared RoPE {op}") + _require(index.depends(rope.input[0], "positions") + and index.depends(rope.input[0], "model.model.rotary_emb.inv_freq"), + f"{context}: RoPE {op} must use positions and inv_freq") + if config["num_attention_heads"] != config["num_key_value_heads"]: + expected = (1, config["num_key_value_heads"], + config["num_attention_heads"] // config["num_key_value_heads"], + config["sequence_length"], config["head_dim"]) + key_transpose = index.producer(scores.input[1], "Transpose", f"{context}: K score transpose") + _require(tuple(_attrs(key_transpose).get("perm", ())) == (0, 1, 3, 2), + f"{context}: K score transpose must use perm=[0, 1, 3, 2]") + for value, source, label in ((key_transpose.input[0], kv, "K"), + (weighted.input[1], v.output[0], "V")): + reshape = index.producer(value, "Reshape", f"{context}: GQA {label} flattened heads") + shape = tuple(index.constant(reshape.input[1]).reshape(-1).tolist()) + target = (1, config["num_attention_heads"], config["sequence_length"], config["head_dim"]) + _require(shape == target, f"{context}: GQA {label} head reshape {shape} != {target}") + expand = index.producer(reshape.input[0], "Expand", f"{context}: GQA {label} expansion") + _require(index.depends(expand.input[0], source), + f"{context}: GQA {label} expansion does not consume its projection") + shape = tuple(index.constant(expand.input[1]).reshape(-1).tolist()) + _require(shape == expected, f"{context}: GQA {label} expansion shape {shape} != {expected}") + source_value = index.strip(expand.input[0], ("Slice",)) + unsqueeze = index.producer(source_value, "Unsqueeze", f"{context}: GQA {label} group axis") + axes = tuple(index.constant(unsqueeze.input[1]).reshape(-1).tolist()) + _require(axes == (2,), f"{context}: GQA {label} repeat group must be axis 2") + return {"scores": scores.name, "softmax": softmax.name, "weighted_values": weighted.name} + + +def audit_graph_structure(model, config=None): + """Check the complete decoder dataflow without loading external weights. + + ``config`` overrides architecture dimensions for small test fixtures. This + gate recognizes the pinned export's decomposed RMSNorm/RoPE/GQA/SwiGLU; + a different exporter representation needs a reviewed new pattern. + """ + config = dict(QWEN3_06B_CONFIG) | dict(config or {}) + for name, value in config.items(): + if name == "rms_norm_eps": + _require(isinstance(value, (float, int)) and not isinstance(value, bool) + and math.isfinite(value) and value > 0, f"config: invalid {name}") + else: + _require(isinstance(value, int) and not isinstance(value, bool) and value > 0, + f"config: invalid {name}") + _require(config["head_dim"] % 2 == 0, "config: head_dim must be even for RoPE") + _require(config["num_attention_heads"] % config["num_key_value_heads"] == 0, + "config: attention heads must be divisible by KV heads") + _metadata(model.graph, config) + index = _Graph(model.graph) + shared_weight = "model.lm_head.weight" + embedding = _one([n for n in index.by_op["Gather"] + if list(n.input) == [shared_weight, "input_ids"]], + "embedding: tied LM Head weight and input_ids Gather") + _require(_attrs(embedding).get("axis", 0) == 0, "embedding: Gather axis must be zero") + previous = embedding.output[0] + rows = [] + for layer in range(config["num_hidden_layers"]): + context = f"layer {layer}" + prefix = f"model.model.layers.{layer}." + projections = {name: index.projection(prefix + name + ".weight", f"{context} {name}") + for name in ("self_attn.q_proj", "self_attn.k_proj", "self_attn.v_proj", + "self_attn.o_proj", "mlp.gate_proj", "mlp.up_proj", "mlp.down_proj")} + norms = {name: index.norm(prefix + name + ".weight", config["rms_norm_eps"], f"{context} {name}") + for name in ("input_layernorm", "post_attention_layernorm", "self_attn.q_norm", "self_attn.k_norm")} + input_norm, raw = norms["input_layernorm"] + _require(index.same(raw, previous), f"{context}: input norm must consume previous layer residual") + q, k, v, out = (projections[f"self_attn.{name}_proj"] for name in ("q", "k", "v", "o")) + _require(all(index.same(n.input[0], input_norm.output[0]) for n in (q, k, v)), + f"{context}: Q/K/V projections must share this layer's input norm") + q_norm, q_raw = norms["self_attn.q_norm"] + k_norm, k_raw = norms["self_attn.k_norm"] + _require(index.same(q_raw, q.output[0], ("Reshape",)) + and index.same(k_raw, k.output[0], ("Reshape",)), + f"{context}: Q/K norm must consume the matching projection") + attention = _attention(index, q, k, v, out, q_norm, k_norm, config, context) + first_residual = index.residual(out.output[0], previous, f"{context}: attention residual") + post_norm, post_raw = norms["post_attention_layernorm"] + _require(index.same(post_raw, first_residual.output[0]), + f"{context}: post-attention norm must consume attention residual") + gate, up, down = (projections[f"mlp.{name}_proj"] for name in ("gate", "up", "down")) + _require(all(index.same(n.input[0], post_norm.output[0]) for n in (gate, up)), + f"{context}: gate/up projections must share post-attention norm") + product = index.producer(down.input[0], "Mul", f"{context}: SwiGLU") + activation_values = [value for value in product.input if not index.same(value, up.output[0])] + _require(len(activation_values) == 1, f"{context}: SwiGLU must multiply by up projection") + activation = index.producer(activation_values[0], "Mul", f"{context}: SiLU") + sigmoid_values = [value for value in activation.input if not index.same(value, gate.output[0])] + _require(len(sigmoid_values) == 1, f"{context}: SiLU must multiply gate by sigmoid(gate)") + sigmoid = index.producer(sigmoid_values[0], "Sigmoid", f"{context}: SiLU") + _require(index.same(sigmoid.input[0], gate.output[0]), f"{context}: SiLU sigmoid input must be gate") + second_residual = index.residual(down.output[0], first_residual.output[0], + f"{context}: MLP residual") + previous = second_residual.output[0] + rows.append({"layer": layer, "passed": True, "input": raw, "output": previous, + "projections": {name: node.name for name, node in projections.items()}, + "norms": {name: node.name for name, (node, _) in norms.items()}, + "attention": attention, + "residuals": [first_residual.name, second_residual.name]}) + final_norm, raw = index.norm("model.model.norm.weight", config["rms_norm_eps"], "final norm") + _require(index.same(raw, previous), "final norm must consume the last layer residual") + head = index.projection(shared_weight, "LM Head") + _require(index.same(head.input[0], final_norm.output[0], ("Slice",)), + "LM Head must consume final norm (allowing the exporter's logits slices)") + _require(index.same("logits", head.output[0]), "logits must return the tied LM Head projection") + live = [node for node in index.nodes if index.depends("logits", node.output[0])] + dead = [node.name or node.output[0] for node in index.nodes if not index.depends("logits", node.output[0])] + _require(not dead, f"graph has nodes disconnected from logits: {dead[:8]}") + for name in index.initializers: + _require(index.depends("logits", name), f"initializer {name!r} is disconnected from logits") + return {"passed": True, "config": config, + "graph": {"node_count": len(index.nodes), "initializer_count": len(index.initializers), + "operators": dict(sorted(Counter(n.op_type for n in index.nodes).items())), + "inputs": [v.name for v in model.graph.input], "outputs": ["logits"]}, + "layers": rows, "layer_count": len(rows), "live_node_count": len(live), + "embedding": embedding.name, "final_norm": final_norm.name, "lm_head": head.name, + "tied_embeddings": True, + "checks": ["exact weight inventory, shapes and dtypes", "static input/output signature", + "RMSNorm paths and constants", "Q/K/V and GQA attention dataflow", + "shared RoPE position/frequency dependencies", "SwiGLU dataflow", + "both residuals and ordered layer chain", "final norm and tied LM Head", + "every node and initializer reaches logits"], + "scope": "Structural acceptance of the pinned export; no numerical execution or external weight reads."} diff --git a/probes/w2_qwen3_small/README.md b/probes/w2_qwen3_small/README.md index 44779a75..bda658db 100644 --- a/probes/w2_qwen3_small/README.md +++ b/probes/w2_qwen3_small/README.md @@ -27,20 +27,22 @@ RV64GC ELF,在 QEMU `virt` 裸机执行。下文“不是 RISC-V 验证”的 ONNX 输入为 `input_ids: INT64 [1,256]` 与 `attention_mask: FP32 [1,1,256,256]`。mask 是加性因果和 key-padding 掩码(允许位置为 0,屏蔽位置为 FP32 最小有限值),不是 tokenizer 常用的二维 0/1 mask;位置编号固定为 `0..255`。普通图输出为 `logits: FP32 [1,256,128]`。 -## 本地运行 - -先确认用于建环境的 `python` 是 Python 3.12。下面是 PowerShell 命令;Linux/macOS 可将 `& $probePython` 换成 `output/qwen3-probe-venv/bin/python`。 - -```powershell -python -m venv output/qwen3-probe-venv -$probePython = ".\output\qwen3-probe-venv\Scripts\python.exe" -& $probePython -m pip install --upgrade pip -& $probePython -m pip install "torch==2.7.1" --index-url https://download.pytorch.org/whl/cpu -& $probePython -m pip install -r requirements/qwen3-small-probe.txt -& $probePython -m pip install --no-deps -e . -& $probePython -m pip check -& $probePython probes/w2_qwen3_small/run.py --output-dir output/qwen3-small -& $probePython -m pytest tests/test_qwen3_small_probe.py tests/test_qwen3_small_model.py tests/test_qwen3_small_gate.py -q +## Linux 复现 + +正式交付使用 Ubuntu 24.04 x86_64 / Bash / Python 3.12。通用工具准备见 [Linux 复现约定](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md);在仓库根目录执行: + +```bash +set -euo pipefail +python3.12 -m venv .venv-linux +source .venv-linux/bin/activate +python -m pip install --upgrade pip +python -m pip install "torch==2.7.1" --index-url https://download.pytorch.org/whl/cpu +python -m pip install -r requirements/qwen3-small-probe.txt +python -m pip install --no-deps -e . +python -m pip check +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +python probes/w2_qwen3_small/run.py --output-dir output/qwen3-small +python -m pytest tests/test_qwen3_small_probe.py tests/test_qwen3_small_model.py tests/test_qwen3_small_gate.py -q ``` CPU PyTorch 必须在安装依赖文件前单独安装;随后 `torch==2.7.1` 会保留已安装的 `2.7.1+cpu`。不要只执行依赖文件安装而让 pip 从默认索引另选 CUDA 依赖。导出相关包采用已验证的固定组合,避免环境升级改变导出图或数值基线。 @@ -121,15 +123,13 @@ ScratchV 承担 ONNX/IR、IR 优化、张量循环生成、内存规划与输入 Linux 工具准备(Python 导出依赖仍按前面的固定版本安装): ```bash +sudo apt-get update sudo apt-get install --no-install-recommends qemu-system-misc python -m pip install ziglang==0.14.1 export SCRATCHV_CC="$(python -c 'from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent / "zig")')" ``` -Windows 可使用便携 Zig 和 QEMU,设置 `SCRATCHV_CC` 为 `zig.exe` 的完整路径、 -`SCRATCHV_QEMU` 为 `qemu-system-riscv64.exe` 的完整路径。也会自动搜索仓库的 -`output/tools/`。不需要 WSL、Linux 镜像或系统安装;QEMU Windows 分发来源见 -[QEMU 下载页](https://www.qemu.org/download/#windows)。工具仅在本机准备,脚本不会隐式联网安装。 +设置 `SCRATCHV_QEMU="$(command -v qemu-system-riscv64)"`;所有工具均为 Linux 可执行文件。脚本不会隐式联网安装工具。 先验证矩阵乘,再导出模型并运行完整验收;输出目录必须为空,避免覆盖旧证据: @@ -152,12 +152,19 @@ padding 隔离。每个比较仍要求 FP32、形状一致、有限值、最大 失败、输入目录混用或哈希不一致应直接失败,不能只靠当前 ORT 比较通过。该绑定 用于避免意外混用产物,不是第三方认证;参考计算仍须在指定环境重新生成。 +当前执行环境与完整源码归属的报告增强单独见 [W1 PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93)。 +W2 分支未包含该报告代码修复;未合并前不能把原导出环境当成本次 QEMU 对照的执行环境。 + `output/qwen3-riscv/` 包含 `report.json/md/html`、生成的 C、启动汇编与链接脚本、 ELF、编译器/QEMU 命令与日志、每次执行的输入和 UART 二进制以及 QEMU/ORT 数组。 JSON 记录源码/模型/输入/ELF 哈希、工具版本、优化统计、工作区大小、最差元素与 首次发生偏差的检查点。缺工具、编译失败、trap、超时、损坏/截断的 UART、错误 退出或任一数值不匹配都会失败,不允许跳过后返回成功。 +编译器或 QEMU 的日志文件保存失败时,仍分别尝试保存 stdout 和 stderr。 +已有的编译错误、guest 状态、协议错误或超时保持为主要错误,日志保存错误另行附加; +执行成功但必需日志无法保存时,入口仍返回失败,避免把不完整证据当作完整验收。 + ### 查看执行时长 使用上面的 `--output-dir output/qwen3-riscv` 命令即可生成时长报告,无需另开计时 diff --git a/probes/w2_qwen3_small/diagnostics.py b/probes/w2_qwen3_small/diagnostics.py index 5766f66f..dbb8b83f 100644 --- a/probes/w2_qwen3_small/diagnostics.py +++ b/probes/w2_qwen3_small/diagnostics.py @@ -15,6 +15,8 @@ import onnx from onnx import TensorProto, helper, numpy_helper +from scratchv.verification.numeric_metrics import numeric_metrics, undefined_numeric_metrics + def _names(names, label): names = list(names) @@ -154,14 +156,18 @@ def tensor_diff(actual, expected, atol=1e-5) -> dict: actual_finite=finite[0], expected_finite=finite[1], finite=all(finite), empty=bool(comparable and actual.size == expected.size == 0), worst_index=None, actual_value=None, expected_value=None, reason=None, atol=atol) + report.update(undefined_numeric_metrics("comparison is invalid")) if not comparable: report["reason"] = "outputs must be NumPy arrays" + report.update(undefined_numeric_metrics(report["reason"])) return report if not shape_matches or not dtype_matches: report["reason"] = "shape mismatch" if not shape_matches else "dtype mismatch" + report.update(undefined_numeric_metrics(report["reason"])) return report if actual.dtype.kind not in "biuf": report["reason"] = "outputs must have numeric dtypes" + report.update(undefined_numeric_metrics(report["reason"])) return report if not all(finite): invalid = ~np.isfinite(actual) | ~np.isfinite(expected) @@ -169,7 +175,9 @@ def tensor_diff(actual, expected, atol=1e-5) -> dict: report.update(worst_index=[int(dim) for dim in index], actual_value=_json_scalar(actual[index]), expected_value=_json_scalar(expected[index]), reason="nonfinite output or reference") + report.update(undefined_numeric_metrics(report["reason"])) return report + report.update(numeric_metrics(actual, expected)) if not actual.size: report.update(passed=True, max_abs=0.0) return report diff --git a/probes/w2_qwen3_small/model.py b/probes/w2_qwen3_small/model.py index 2d708803..40043dd7 100644 --- a/probes/w2_qwen3_small/model.py +++ b/probes/w2_qwen3_small/model.py @@ -54,16 +54,23 @@ class DiagnosticQwen(nn.Module): instrumentation preserves the logits. """ - def __init__(self, model: Qwen3ForCausalLM, seed: int): + def __init__(self, model: Qwen3ForCausalLM, seed: int, sequence_length=SEQUENCE_LENGTH): super().__init__() self.model = model + self.sequence_length = sequence_length + config = model.config + length = sequence_length + hidden = config.hidden_size + heads = config.num_attention_heads + kv_heads = config.num_key_value_heads + width = config.head_dim self.register_buffer( - "positions", torch.arange(SEQUENCE_LENGTH).reshape(1, SEQUENCE_LENGTH), + "positions", torch.arange(length).reshape(1, length), persistent=False, ) self.config_metadata = { - **MODEL_CONFIG, - "sequence_length": SEQUENCE_LENGTH, + **{name: getattr(config, name) for name in MODEL_CONFIG}, + "sequence_length": length, "batch_size": 1, "seed": seed, "dtype": "float32", @@ -71,34 +78,34 @@ def __init__(self, model: Qwen3ForCausalLM, seed: int): "model_class": "transformers.Qwen3ForCausalLM", "transformers_version": TRANSFORMERS_VERSION, "weights": "fixed-seed random initialization; no pretrained weights", - "position_ids": "0..255, including right-padding positions", + "position_ids": f"0..{length - 1}, including right-padding positions", "mask": "4D FP32 additive causal + key padding; 0 / finfo(float32).min", } self.checkpoint_metadata = {} # "embedding" is also an exporter-generated internal value name; a # distinct checkpoint name avoids a torch 2.7 output-alias SSA clash. - self._describe("token_embedding", [1, 256, 32], 1, "Token embedding") - for index in range(MODEL_CONFIG["num_hidden_layers"]): + self._describe("token_embedding", [1, length, hidden], 1, "Token embedding") + for index in range(config.num_hidden_layers): prefix = f"layer_{index}." for name, shape, axis, description in ( - ("input_norm", [1, 256, 32], 1, "Input RMSNorm"), - ("q_norm", [1, 256, 4, 16], 1, "Per-head Q RMSNorm before transpose"), - ("k_norm", [1, 256, 2, 16], 1, "Per-head K RMSNorm before transpose"), - ("v_proj", [1, 256, 32], 1, "V projection before KV-head reshape"), - ("rope_q", [1, 4, 256, 16], 2, "Q after official RoPE"), - ("rope_k", [1, 2, 256, 16], 2, "K after official RoPE, before GQA repetition"), - ("attn_probs", [1, 4, 256, 256], 2, "Masked softmax probabilities"), - ("attn_context", [1, 256, 64], 1, "Attention context, input to output projection"), - ("attn_output", [1, 256, 32], 1, "Attention output after output projection"), - ("attn_residual", [1, 256, 32], 1, "Residual after attention"), - ("post_attention_norm", [1, 256, 32], 1, "RMSNorm before MLP"), - ("mlp", [1, 256, 32], 1, "Official SwiGLU MLP output"), - ("residual", [1, 256, 32], 1, "Decoder output after MLP residual"), + ("input_norm", [1, length, hidden], 1, "Input RMSNorm"), + ("q_norm", [1, length, heads, width], 1, "Per-head Q RMSNorm before transpose"), + ("k_norm", [1, length, kv_heads, width], 1, "Per-head K RMSNorm before transpose"), + ("v_proj", [1, length, kv_heads * width], 1, "V projection before KV-head reshape"), + ("rope_q", [1, heads, length, width], 2, "Q after official RoPE"), + ("rope_k", [1, kv_heads, length, width], 2, "K after official RoPE, before GQA repetition"), + ("attn_probs", [1, heads, length, length], 2, "Masked softmax probabilities"), + ("attn_context", [1, length, heads * width], 1, "Attention context, input to output projection"), + ("attn_output", [1, length, hidden], 1, "Attention output after output projection"), + ("attn_residual", [1, length, hidden], 1, "Residual after attention"), + ("post_attention_norm", [1, length, hidden], 1, "RMSNorm before MLP"), + ("mlp", [1, length, hidden], 1, "Official SwiGLU MLP output"), + ("residual", [1, length, hidden], 1, "Decoder output after MLP residual"), ): self._describe(prefix + name, shape, axis, description) self.checkpoint_metadata[prefix + "attn_probs"]["key_sequence_axis"] = 3 - self._describe("final_norm", [1, 256, 32], 1, "Final RMSNorm") - self._describe("logits", [1, 256, 128], 1, "Tied language-model head logits") + self._describe("final_norm", [1, length, hidden], 1, "Final RMSNorm") + self._describe("logits", [1, length, config.vocab_size], 1, "Tied language-model head logits") self.output_names = tuple(self.checkpoint_metadata) self._captured = {} self._capture_active = False @@ -202,14 +209,40 @@ def forward(self, input_ids, attention_mask): return outputs -def build_model(seed=0): - """Construct the fixed small architecture with reproducible FP32 weights.""" +def build_model(seed=0, model_config=None, sequence_length=SEQUENCE_LENGTH): + """Construct an official FP32 probe; omitted options preserve the W2 preset. + + ``model_config`` overrides named ``MODEL_CONFIG`` fields. Checkpoint shapes + derive from the constructed model, including independent Q/KV head widths. + The sequence length is a static export contract, not a dynamic dimension. + """ if transformers.__version__ != TRANSFORMERS_VERSION: raise RuntimeError( f"This probe requires transformers=={TRANSFORMERS_VERSION}; " f"found {transformers.__version__}" ) - config = Qwen3Config(**MODEL_CONFIG) + settings = dict(MODEL_CONFIG) + if model_config is not None: + unknown = set(model_config) - set(settings) + if unknown: + raise ValueError(f"Unsupported probe configuration fields: {sorted(unknown)}") + settings.update(model_config) + for name in ("vocab_size", "hidden_size", "intermediate_size", "num_hidden_layers", + "num_attention_heads", "num_key_value_heads", "head_dim", + "max_position_embeddings"): + if isinstance(settings[name], bool) or not isinstance(settings[name], int) or settings[name] < 1: + raise ValueError(f"{name} must be a positive integer") + if isinstance(sequence_length, bool) or not isinstance(sequence_length, int) or sequence_length < 1: + raise ValueError("sequence_length must be a positive integer") + if sequence_length > settings["max_position_embeddings"]: + raise ValueError("sequence_length exceeds max_position_embeddings") + if settings["num_attention_heads"] % settings["num_key_value_heads"]: + raise ValueError("num_attention_heads must be divisible by num_key_value_heads") + if settings["head_dim"] % 2: + raise ValueError("head_dim must be even for RoPE") + if settings["use_cache"] or not settings["tie_word_embeddings"]: + raise ValueError("The probe requires use_cache=False and tie_word_embeddings=True") + config = Qwen3Config(**settings) config._attn_implementation = "eager" # Preserve the caller's random state; CPU initialization never downloads. with torch.random.fork_rng(devices=[]): @@ -217,23 +250,24 @@ def build_model(seed=0): model = Qwen3ForCausalLM(config).float().eval() if model.lm_head.weight is not model.model.embed_tokens.weight: raise RuntimeError("The official model did not tie its embedding and LM-head weights") - return DiagnosticQwen(model, seed).eval() + return DiagnosticQwen(model, seed, sequence_length).eval() def export_onnx(wrapper, path, input_ids, attention_mask): """Export all named outputs using the same Dynamo path as the full model. - Inputs have fixed shapes [1,256] INT64 and [1,1,256,256] FP32. No dynamic + Inputs have fixed shapes [1,L] INT64 and [1,1,L,L] FP32. No dynamic shapes, fallback exporter, or ONNX optimizer is enabled. The small random weights are embedded in a single ONNX file rather than external data. """ - if input_ids.shape != (1, SEQUENCE_LENGTH) or input_ids.dtype != torch.int64: - raise ValueError("input_ids must have fixed shape [1,256] and dtype INT64") + length = wrapper.sequence_length + if input_ids.shape != (1, length) or input_ids.dtype != torch.int64: + raise ValueError(f"input_ids must have fixed shape [1,{length}] and dtype INT64") if ( - attention_mask.shape != (1, 1, SEQUENCE_LENGTH, SEQUENCE_LENGTH) + attention_mask.shape != (1, 1, length, length) or attention_mask.dtype != torch.float32 ): - raise ValueError("attention_mask must have fixed shape [1,1,256,256] and dtype FP32") + raise ValueError(f"attention_mask must have fixed shape [1,1,{length},{length}] and dtype FP32") if wrapper.training or wrapper.model.training: raise ValueError("The Qwen probe must be exported in eval mode") path = Path(path) diff --git a/probes/w2_qwen3_small/riscv.py b/probes/w2_qwen3_small/riscv.py index 71a7cc1b..d71b5e81 100644 --- a/probes/w2_qwen3_small/riscv.py +++ b/probes/w2_qwen3_small/riscv.py @@ -26,7 +26,10 @@ import onnxruntime as ort from probes.w2_qwen3_small.diagnostics import compare_outputs, tensor_diff, unpack_trace -from probes.w2_qwen3_small.run import ATOL, arrays_sha256, input_cases +from probes.w2_qwen3_small.run import ( + ATOL, _details_views, _page_html, _table_views, arrays_sha256, input_cases, +) +from probes.numeric_summary import METRIC_EXPLANATION, compact_metric_table, compact_metric_table_html from scratchv.analysis.ir_verifier import verify_ir from scratchv.compiler import CompilerConfig, CompilerDriver from scratchv.frontend.onnx_parser import ONNXParser @@ -98,6 +101,7 @@ def execution_evidence(): ROOT / "scratchv/ir/builder.py", ROOT / "scratchv/pass_manager.py", ROOT / "scratchv/pass_interface.py", ROOT / "scratchv/verification/ir_interpreter.py", ROOT / "scratchv/verification/ir_numpy_ops.py", + ROOT / "scratchv/verification/numeric_metrics.py", ROOT / "probes/numeric_summary.py", *sorted((ROOT / "scratchv/analysis").glob("*.py")), *sorted((ROOT / "scratchv/optimizer").glob("*.py")), ROOT / "scratchv/backend/tensor_c_codegen.py", ROOT / "scratchv/runtime/riscv_tensor.py"] @@ -316,98 +320,121 @@ def run_probe(model_dir, out, report, *, cc=None, qemu=None, timeout=180): def _report_views(report): timing = report["timing"] counts = timing["status_counts"] - lines = ["# Two-layer Qwen3: ScratchV IR → C tensor kernels → RV64GC → QEMU", "", - f"Result: **{'PASS' if report['passed'] else 'FAIL'}**; stage: {report['stage']}", "", - "## 仿真耗时", "", - f"- 探测流程总耗时:**{format_seconds(timing['pipeline_seconds'])} 秒**(不含报告渲染)。", - f"- 已记录 QEMU 进程耗时合计:**{format_seconds(timing['qemu_process_wall_seconds_total'])} 秒**。", - f"- 成功交叉编译记录合计:**{format_seconds(timing['cross_compile_seconds_total'])} 秒**;" - f"已记录 {timing['cross_compile_completed_count']}/{timing['cross_compile_planned_count']} 次构建," - "不含 ScratchV 解析/IR 优化/代码生成或失败构建的耗时。", - f"- 计划 {timing['planned_count']} 次,已尝试 {timing['attempted_count']} 次;" - f"通过 {counts['success']},数值失败 {counts['numeric_failed']}," - f"运行失败 {counts['runtime_error']},超时 {counts['timeout']}," - f"未启动 {counts['not_started']},未尝试 {timing['not_attempted_count']}。", - f"- 记录范围:{'部分结果,尚未完成全部执行/数值核验' if timing['incomplete'] else '全部执行/数值核验已有记录'};" - "失败样本与通过样本分别统计,未测得的时长不按零计。", "", - "QEMU 进程墙钟时长包含启动、guest 计算、输出打包/UART 和退出;超时还包括进程清理。" - "不包含主机输入准备或输出解码。diagnostic 包含检查点开销,须与 normal 分开。" - "这些数据仅用于监控,不代表纯前向时长或目标硬件性能,也不作为性能通过阈值。", "", - "| 图/优化级别 | 已尝试/计划 | 通过/失败 | 未尝试 | 通过平均/最大(s) | 失败平均/最大(s) | 已测合计(s) |", - "|---|---:|---:|---:|---:|---:|---:|"] - group_rows = [] + cases = report.get("cases", []) + comparisons = [] + for field, levels, label in ( + ("optimized_ir", ("none", "basic", "all"), "IR 原生 NumPy(优化回归)/ ORT"), + ("qemu", ("none", "all"), "生成 C → RV64/QEMU / ORT"), + ): + rows = [] + for case in cases + [{}] * max(0, QEMU_CASE_COUNT - len(cases)): + for level in levels: + rows.append(next((row for row in case.get(field, []) + if row.get("graph") == "normal" and row.get("optimization") == level), {})) + comparisons.append((label, rows)) + title = "两层 Qwen3:编译到 RISC-V 的数值精度" + status = "PASS" if report.get("passed") else "FAIL" + description = ("真实 Qwen3 结构的两层缩小模型,固定随机权重,FP32、L=256、无 KV cache。" + "同一 ONNX 模型、权重和输入分别经 ORT、原生 NumPy IR,以及 ScratchV IR → C 张量代码 → " + "C 交叉编译器 → RV64GC → QEMU 执行。C 行测的是 RISC-V 上的实际输出,不是单独的主机 C 测试。") + scope = ("核心表只汇总普通图最终 logits 的最坏指标(全部位置含 padding),比较按“被测 / 参考”排列。" + "七个输入场景覆盖两份满长输入、1/17/255 token 边界、因果与补齐隔离。" + "普通/诊断图、29 个检查点及隔离检查仍须全部通过严格 MaxAbs < 1e-5(rtol=0)。") + timing_line = (f"仿真耗时:探测流程 {format_seconds(timing['pipeline_seconds'])} 秒;" + f"已记录 QEMU 进程累计 {format_seconds(timing['qemu_process_wall_seconds_total'])} 秒" + "(含启动和传输,不代表纯前向或目标硬件性能)。") + coverage = (f"执行覆盖:{timing['attempted_count']}/{timing['planned_count']} 次已尝试," + f"{counts['success']} 次通过;" + + ("部分结果,尚未完成全部执行。" if timing['incomplete'] else "全部执行完成。")) + markdown = [f"# {title}", "", f"Result: **{status}**; stage: {report.get('stage')}", "", + description, "", scope, "", compact_metric_table(comparisons), "", METRIC_EXPLANATION, + "", coverage, "", timing_line] + html = [f"

{escape(title)}

Result: {status}; stage: {escape(str(report.get('stage')))}

", + f"

{escape(description)}

{escape(scope)}

", compact_metric_table_html(comparisons), + f"

{escape(METRIC_EXPLANATION)}

{escape(coverage)}

{escape(timing_line)}

"] + failures = [] + if report.get("error"): + failures.append(f"阶段 {report.get('stage')}: {report['error']}") + for case in cases: + if case.get("passed") is not True: + failures.append(f"{case.get('name')}: FAIL;有检查失败或未完成") + for row in case.get("qemu", []): + if row.get("passed") is not True: + reason = row.get("error") or row.get("reason") or "尚未完成数值核验" + first = row.get("checkpoints", {}).get("first_divergence") + failures.append(f"{case.get('name')} / {row.get('graph')}/{row.get('optimization')}: " + f"{row.get('status')}; {reason}" + (f";首次超限检查点 {first}" if first else "")) + for row in case.get("optimized_ir", []): + if row.get("passed") is not True: + failures.append(f"{case.get('name')} IR {row.get('graph')}/{row.get('optimization')}: " + f"{row.get('reason') or 'FAIL'}") + for row in report.get("invariants", []): + if row.get("passed") is not True: + failures.append(f"{row.get('name')} / {row.get('graph')}/{row.get('optimization')}: 隔离检查 FAIL") + if not report.get("passed") and not failures: + failures.append("尚未完成全部验收,请检查 report.json。") + if failures: + markdown += ["", "**失败/未完成项**", "", *[f"- {escape(item)}" for item in failures]] + html.append("

失败/未完成项

    " + + "".join(f"
  • {escape(item)}
  • " for item in failures) + "
") + + def append_detail(title, headers, rows, note): + md, body = _table_views(headers, rows) + md, body = _details_views(title, md + "\n\n" + note, body + f"

{escape(note)}

") + markdown.extend(["", md]) + html.append(body) + + graph_note = ("图指模型计算步骤及其连接关系:normal 只输出最终 logits;diagnostic 增加中间检查点以定位误差,含额外打包与传输开销。" + "none/basic/all 指 ScratchV IR 优化:none 不优化;basic 提前计算常量并删除不影响输出的计算;" + "all 再加入局部指令简化、乘加融合和循环内重复计算外提。它们不是 C 编译优化等级,本探测的 C 编译固定使用 -O2。") + time_note = (graph_note + " QEMU 墙钟时间包含启动、目标程序计算、输出打包/串口传输和退出;超时还含清理," + "不含主机输入准备或输出解码。未测得的时长不按零计;平均/最大值仅统计有测量值的样本。" + "七种输入不是重复采样,不能据此推导稳定加速比,也不设置性能通过阈值。" + f"成功交叉编译记录累计 {format_seconds(timing['cross_compile_seconds_total'])} 秒" + f"({timing['cross_compile_completed_count']}/{timing['cross_compile_planned_count']} 次)," + "不含 ScratchV 解析、IR 优化、代码生成及失败构建;流程时间不含报告渲染。") + groups = [] for group in timing["groups"]: successful, failed = group["successful"], group["failed"] - failed_count = group["attempted_count"] - group["status_counts"]["success"] - cells = [f"{group['graph']}/{group['optimization']}", - f"{group['attempted_count']}/{group['planned_count']}", - f"{group['status_counts']['success']}/{failed_count}", str(group['not_attempted_count']), - f"{format_seconds(successful['mean_seconds'])}/{format_seconds(successful['max_seconds'])}", - f"{format_seconds(failed['mean_seconds'])}/{format_seconds(failed['max_seconds'])}", - format_seconds(group['all_measured']['total_seconds'])] - group_rows.append(cells) - lines.append("| " + " | ".join(cells) + " |") - lines += ["", "平均/最大值只统计有实测时长的样本;失败数包含尚未启动但已尝试的条目。" - "七种输入不是重复采样,不据此宣称稳定加速比。", "", - "FP32, L=256, random weights, 29 checkpoints, strict max absolute error < 1e-5.", - "The C cross-compiler is Zig/LLVM; this does not validate the legacy scalar assembly selector.", - "IR optimization levels: none/basic/all. QEMU: none/all, normal and diagnostic graphs.", ""] - if report.get("error"): - lines += [f"Error: {report['error']}", ""] - lines += ["| Case | Graph | IR optimization | QEMU进程(s) | 超时设定(s) | Max abs | First divergent checkpoint | Status |", - "|---|---|---|---:|---:|---:|---|---|"] - execution_rows = [] - for case in report.get("cases", []): + groups.append([f"{group['graph']}/{group['optimization']}", + f"{group['attempted_count']}/{group['planned_count']}", + f"{group['status_counts']['success']}/{group['attempted_count'] - group['status_counts']['success']}", + group['not_attempted_count'], + f"{format_seconds(successful['mean_seconds'])}/{format_seconds(successful['max_seconds'])}", + f"{format_seconds(failed['mean_seconds'])}/{format_seconds(failed['max_seconds'])}", + format_seconds(group['all_measured']['total_seconds'])]) + append_detail("图与优化级别:仿真耗时明细", + ["图/优化级别", "已尝试/计划", "通过/失败", "未尝试", "通过平均/最大(s)", "失败平均/最大(s)", "已测合计(s)"], + groups, time_note) + executions = [] + for case in cases: for row in case.get("qemu", []): - cells = [case['name'], row['graph'], row['optimization'], - format_seconds(row.get('qemu_process_wall_seconds')), - format_seconds(row.get('timeout_seconds')), str(row.get('max_abs')), - row.get('checkpoints', {}).get('first_divergence') or 'none', row['status']] - execution_rows.append(cells) - lines.append("| " + " | ".join(cells) + " |") - markdown = "\n".join(lines) + "\n" - details = [] - for case in report.get("cases", []): - details.append(f"
{escape(case['name'])}: " - f"{'PASS' if case['passed'] else 'FAIL'}
"
-                       f"{escape(json.dumps(case, indent=2))}
") - - def html_table(headers, rows): - header = "".join(f"{escape(value)}" for value in headers) - body = "".join("" + "".join(f"{escape(value)}" for value in row) + "" - for row in rows) - return f"
{header}{body}
" - - overview = ("

仿真耗时

" - f"

探测流程总耗时:{format_seconds(timing['pipeline_seconds'])} 秒;" - f"已记录 QEMU 进程合计:{format_seconds(timing['qemu_process_wall_seconds_total'])} 秒;" - f"成功交叉编译记录合计:{format_seconds(timing['cross_compile_seconds_total'])} 秒" - f"({timing['cross_compile_completed_count']}/{timing['cross_compile_planned_count']} 次)。

" - f"

计划 {timing['planned_count']} 次,已尝试 {timing['attempted_count']} 次;" - f"通过 {counts['success']},数值失败 {counts['numeric_failed']}," - f"运行失败 {counts['runtime_error']},超时 {counts['timeout']}," - f"未启动 {counts['not_started']},未尝试 {timing['not_attempted_count']}。" - f"{'部分结果' if timing['incomplete'] else '完整执行记录'}。

" - "

QEMU 时长包含进程启动、guest 计算、输出打包/UART 和退出,超时包含进程清理;" - "不含主机输入准备或输出解码。diagnostic 含检查点开销,与 normal 分开统计。" - "流程总耗时不含报告渲染;交叉编译仅统计成功记录,不含 ScratchV 解析/IR 优化/代码生成。" - "未测得不按零计,失败与通过样本分别计算平均/最大值。七种输入不是重复采样," - "仅用于监控,不代表纯前向时长、稳定加速比或目标硬件性能,无性能通过阈值。

") - overview += html_table(["图/优化", "已尝试/计划", "通过/失败", "未尝试", "通过平均/最大(s)", - "失败平均/最大(s)", "已测合计(s)"], group_rows) - overview += "

逐次执行

" + html_table( - ["Case", "Graph", "IR优化", "QEMU进程(s)", "超时设定(s)", "Max abs", "首次偏差", "Status"], - execution_rows) - if report.get("error"): - overview += f"

Error: {escape(report['error'])}

" - html = ( - "Qwen3 RV64 probe" - "" - f"

Qwen3 RV64 QEMU: {'PASS' if report['passed'] else 'FAIL'}

" - + overview + "".join(details) + "") - return {"report.md": markdown, "report.html": html} + executions.append([case.get('name'), row.get('graph'), row.get('optimization'), + format_seconds(row.get('qemu_process_wall_seconds')), + format_seconds(row.get('timeout_seconds')), row.get('max_abs', '未统计'), + row.get('checkpoints', {}).get('first_divergence') or 'none', row.get('status')]) + append_detail("逐场景 QEMU 执行明细", + ["场景", "图", "IR 优化", "QEMU 进程(s)", "超时设定(s)", "本图输出 MaxAbs", "首次超限检查点", "结果"], + executions, graph_note + " 此明细中的 diagnostic MaxAbs 包含全部打包检查点;首页核心表只使用 normal logits。" + "超时设定是限制,QEMU 进程(s) 才是实测墙钟时间。") + checks = [] + for case in cases: + for row in case.get('optimized_ir', []): + checks.append([case.get('name'), f"IR {row.get('graph')}/{row.get('optimization')}", + row.get('max_abs'), 'PASS' if row.get('passed') else row.get('reason') or 'FAIL']) + for row in case.get('normal_vs_trace', []): + checks.append([case.get('name'), f"normal/diagnostic logits {row.get('optimization')}", + row.get('max_abs'), 'PASS' if row.get('passed') else row.get('reason') or 'FAIL']) + for row in report.get('invariants', []): + checks.append([row.get('name'), f"{row.get('graph')}/{row.get('optimization')}", + row.get('max_abs'), 'PASS' if row.get('passed') else row.get('reason') or 'FAIL']) + append_detail("优化 IR、普通/诊断图一致性与隔离检查", ["场景/检查", "路径", "MaxAbs", "结果"], checks, + "所有这些检查继续参与验收,完整逐检查点误差保存在 report.json。") + footer = ("完整数值明细、命令、环境、源码指纹、C/ELF 与原始数组随 CI artifact 提供;结构化报告为 report.json。" + "这条路径不验证旧的标量汇编选择器,也不声称完整预训练模型已在 QEMU 运行。") + markdown.extend(["", footer]) + html.append(f"

{escape(footer)}

") + return {"report.md": "\n".join(markdown) + "\n", "report.html": _page_html(title, "".join(html))} def write_reports(out, report): diff --git a/probes/w2_qwen3_small/run.py b/probes/w2_qwen3_small/run.py index 9aa4f38a..dbcea9f7 100644 --- a/probes/w2_qwen3_small/run.py +++ b/probes/w2_qwen3_small/run.py @@ -30,6 +30,9 @@ from probes.w2_qwen3_small.diagnostics import ( # noqa: E402 build_diagnostic_model, compare_outputs, tensor_diff, unpack_trace, ) +from probes.numeric_summary import ( # noqa: E402 + METRIC_EXPLANATION, compact_metric_table, compact_metric_table_html, +) ATOL = 1e-5 SEQ = 256 @@ -165,7 +168,9 @@ def provenance() -> dict: for path in [*sorted(Path(__file__).parent.glob("*.py")), ROOT / "scratchv/frontend/onnx_parser.py", ROOT / "scratchv/verification/ir_interpreter.py", - ROOT / "scratchv/verification/ir_numpy_ops.py"]} + ROOT / "scratchv/verification/ir_numpy_ops.py", + ROOT / "scratchv/verification/numeric_metrics.py", + ROOT / "probes/numeric_summary.py"]} return result @@ -311,90 +316,122 @@ def reference(feed): report.pop("current_case", None) -def _report_markdown(report: dict) -> str: - lines = ["# Real two-layer Qwen3 numerical probe", "", - f"Result: **{'PASS' if report['passed'] else 'FAIL'}**", "", - "Official transformers 4.51.3 Qwen3, seeded random weights, FP32, L=256, no KV cache.", - "All checkpoint positions must pass max absolute error < 1e-5 (rtol=0).", - "Padding queries are also checked and are reported separately in JSON.", - "Diagnostic outputs observe the same forward; ordinary logits-only IR is checked separately.", - "This probe does not validate pretrained language quality or RISC-V execution.", ""] +def _table_views(headers, rows): + """Render the same small, escaped table in both report formats.""" + def markdown_cell(value): + return escape(str(value)).replace("|", "|").replace("\n", "
") + + markdown = ["| " + " | ".join(map(markdown_cell, headers)) + " |", + "|" + "---|" * len(headers)] + markdown += ["| " + " | ".join(map(markdown_cell, row)) + " |" for row in rows] + header = "".join(f"{escape(str(value))}" for value in headers) + body = "".join("" + "".join(f"{escape(str(value))}" for value in row) + + "" for row in rows) + return "\n".join(markdown), f"{header}{body}
" + + +def _details_views(title, markdown, html, *, opened=False): + attribute = " open" if opened else "" + heading = f"{escape(title)}" + return f"{heading}\n\n{markdown}\n\n", f"{heading}{html}" + + +def _page_html(title, content): + css = ("body{font:16px/1.5 system-ui,sans-serif;max-width:1100px;margin:32px auto;padding:0 20px;color:#182536}" + "details{margin:16px 0;padding:12px;border:1px solid #dce3eb;overflow:auto}" + "summary{cursor:pointer;font-weight:650}table{border-collapse:collapse;width:100%;font-size:14px}" + "th,td{text-align:left;padding:8px;border:1px solid #dce3eb}" + "pre{white-space:pre-wrap;overflow-wrap:anywhere}.failure{color:#a42020;font-weight:bold}") + return ("" + "" + f"{escape(title)}{content}") + + +def _host_report_views(report): + cases = report.get("cases", []) + comparisons = [] + for name, label in (("pytorch_vs_ort", "ORT / PyTorch(Transformer 导出)"), + ("ir_vs_ort", "IR 原生 NumPy / ORT")): + rows = [case.get("ordinary_logits", {}).get(name, {}) for case in cases] + # Missing cases or old metric fields must not look like full coverage. + rows += [{}] * max(0, 7 - len(cases)) + comparisons.append((label, rows)) + description = ("官方 Qwen3 结构的两层缩小模型,固定随机权重,FP32、L=256、无 KV cache。" + "同一模型、同一权重和输入分别执行 PyTorch、ONNX Runtime(ORT)与 ScratchV IR 解释器。") + scope = ("核心表仅汇总普通图的最终 logits,覆盖全部位置(含 padding)。" + "比较名称按“被测 / 参考”排列;七个输入场景包括两份满长输入、1/17/255 token 边界、因果与补齐隔离检查。" + "验收仍要求所有检查点及普通/诊断图一致性通过严格 MaxAbs < 1e-5(rtol=0)。") + limitation = "本报告验证数值计算,不运行 C/RISC-V,也不评估随机权重模型的语言能力(PPL、zero-shot 未启用)。" + status = "PASS" if report.get("passed") else "FAIL" + title = "两层 Qwen3:Transformer 导出与 IR 精度" + markdown = [f"# {title}", "", f"Result: **{status}**", "", description, "", scope, "", + compact_metric_table(comparisons), "", METRIC_EXPLANATION, "", limitation] + html = [f"

{escape(title)}

Result: {status}

", + f"

{escape(description)}

{escape(scope)}

", compact_metric_table_html(comparisons), + f"

{escape(METRIC_EXPLANATION)}

{escape(limitation)}

"] + failures = [] if report.get("error"): - lines += [f"Failed stage: `{report['stage']}`", "", "```text", report["error"], "```", ""] - lines += ["| Case | Valid tokens | PyTorch / ORT max error | IR / ORT max error | First divergence | Result |", - "|---|---:|---:|---:|---|---|"] - for case in report.get("cases", []): - pairs = [case.get(name, {}) for name in ("pytorch_vs_ort", "ir_vs_ort")] - maxima = [max((row["max_abs"] for row in pair.get("checkpoints", []) - if row.get("max_abs") is not None), default=None) for pair in pairs] - values = [f"{value:.3e}" if value is not None else "n/a" for value in maxima] - first = next((pair.get("first_divergence") for pair in pairs if pair.get("first_divergence")), "none") - lines.append(f"| {case['name']} | {case['valid_length']} | {values[0]} | {values[1]} | " - f"{first} | {'PASS' if case['passed'] else 'FAIL'} |") - for case in report.get("cases", []): - lines += ["", f"
{case['name']}: checkpoint comparisons", "", - "| Comparison | Checkpoint | Max absolute error | Result |", "|---|---|---:|---|"] + failures.append(f"阶段 {report.get('stage', 'unknown')}: {report['error']}") + for case in cases: + if case.get("passed") is not True: + first = next((case.get(pair, {}).get("first_divergence") for pair in + ("pytorch_vs_ort", "ir_vs_ort") if case.get(pair, {}).get("first_divergence")), None) + failures.append(f"{case.get('name', 'unknown')}: FAIL;" + + (f"首次超限检查点 {first}" if first else "有检查失败或尚未完成,请展开明细")) + for check in report.get("invariants", []): + if check.get("passed") is not True: + failures.append(f"{check.get('name')} / {check.get('backend')}: 隔离检查 FAIL") + if not report.get("passed") and not failures: + failures.append(f"阶段 {report.get('stage', 'unknown')}: 尚未完成全部验收") + if failures: + markdown += ["", "**失败/未完成项**", "", *[f"- {escape(item)}" for item in failures]] + html.append("

失败/未完成项

    " + + "".join(f"
  • {escape(item)}
  • " for item in failures) + "
") + + def append_detail(title, headers, rows, note="", *, opened=False): + md, body = _table_views(headers, rows) + if note: + md += "\n\n" + note + body += f"

{escape(note)}

" + md, body = _details_views(title, md, body, opened=opened) + markdown.extend(["", md]) + html.append(body) + + case_rows = [] + for case in cases: + ordinary = case.get("ordinary_logits", {}) + case_rows.append([case.get("name"), case.get("valid_length"), + ordinary.get("pytorch_vs_ort", {}).get("max_abs", "未统计"), + ordinary.get("ir_vs_ort", {}).get("max_abs", "未统计"), + "PASS" if case.get("passed") else "FAIL"]) + append_detail("输入场景明细", ["场景", "有效 token", "ORT / PyTorch MaxAbs", "IR / ORT MaxAbs", "全部检查"], case_rows) + checkpoint_rows = [] + for case in cases: for pair in ("pytorch_vs_ort", "ir_vs_ort"): for row in case.get(pair, {}).get("checkpoints", []): - lines.append(f"| {pair} | {row['name']} | {row.get('max_abs')} | " - f"{'PASS' if row['passed'] else 'FAIL'} |") - lines += ["", "
"] - lines += ["", "| Invariant | Backend | Max absolute error | Result |", "|---|---|---:|---|"] - for check in report.get("invariants", []): - lines.append(f"| {check['name']} | {check['backend']} | {check.get('max_abs')} | " - f"{'PASS' if check['passed'] else 'FAIL'} |") - return "\n".join(lines) + "\n" + checkpoint_rows.append([case.get("name"), pair, row.get("name"), row.get("max_abs"), + "PASS" if row.get("passed") else row.get("reason") or "FAIL"]) + for name, row in case.get("ordinary_logits", {}).items(): + if "diagnostic_vs_ordinary" in name: + checkpoint_rows.append([case.get("name"), name, "logits", row.get("max_abs"), + "PASS" if row.get("passed") else row.get("reason") or "FAIL"]) + append_detail("检查点与普通/诊断图一致性", ["场景", "比较", "检查点", "MaxAbs", "结果"], checkpoint_rows, + "normal 为只输出最终 logits 的普通图;diagnostic 额外输出中间检查点以定位误差。两种图都参与验收。") + append_detail("因果与补齐隔离检查", ["检查", "执行路径", "MaxAbs", "结果"], + [[row.get("name"), row.get("backend"), row.get("max_abs"), + "PASS" if row.get("passed") else "FAIL"] for row in report.get("invariants", [])]) + footer = "完整诊断、位置明细、命令、环境与源码指纹保存在 report.json;原始数组随 CI artifact 提供。" + markdown.extend(["", footer]) + html.append(f"

{escape(footer)}

") + return "\n".join(markdown) + "\n", _page_html(title, "".join(html)) + + +def _report_markdown(report: dict) -> str: + return _host_report_views(report)[0] def _report_html(report: dict) -> str: - """A portable, dependency-free view of the same numeric evidence.""" - def cell(value): - return escape(str(value)) - - def table(rows, columns): - header = "".join(f"{cell(column)}" for column in columns) - body = "".join("" + "".join(f"{cell(value)}" for value in row) + "" - for row in rows) - return f"{header}{body}
" - - status = "PASS" if report["passed"] else "FAIL" - sections = [f"

Real two-layer Qwen3 probe

{status}

", - "

FP32 · L=256 · 29 checkpoints · max absolute error < 1e-5

", - "

Official Qwen3 with random weights. All positions, including padding queries, are checked. " - "Ordinary logits and diagnostic graphs must both pass. This is not a RISC-V or pretrained quality test.

"] - if report.get("error"): - sections.append(f"
{cell(report['stage'])}: {cell(report['error'])}
") - for case in report.get("cases", []): - case_status = "PASS" if case["passed"] else "FAIL" - details = [] - for pair in ("pytorch_vs_ort", "ir_vs_ort"): - comparison = case.get(pair, {}) - details.append(f"

{cell(pair)}

First divergence: " - f"{cell(comparison.get('first_divergence') or 'none')}

") - rows = [[row["name"], row.get("max_abs"), row.get("worst_index"), - row.get("actual_value"), row.get("expected_value"), - "PASS" if row["passed"] else row.get("reason")] - for row in comparison.get("checkpoints", [])] - details.append(table(rows, ["Checkpoint", "Max abs", "Worst index", "Actual", "Reference", "Result"])) - auxiliary = {"capture_preserves_logits": case.get("capture_preserves_logits"), - "ordinary_logits": case.get("ordinary_logits"), - "attention_checks": case.get("attention_checks")} - details.append(f"

Other checks

{cell(json.dumps(auxiliary, indent=2))}
") - opened = " open" if not case["passed"] else "" - sections.append(f"{cell(case['name'])} · " - f"{case['valid_length']} valid tokens · {case_status}{''.join(details)}") - rows = [[check["name"], check["backend"], check.get("max_abs"), - "PASS" if check["passed"] else "FAIL"] for check in report.get("invariants", [])] - sections.append("

Metamorphic checks

" + table(rows, ["Invariant", "Backend", "Max abs", "Result"])) - sections.append("

Full environment, schemas, hashes and valid/padding breakdowns are in report.json.

") - css = ("body{font:16px/1.5 system-ui,sans-serif;max-width:1200px;margin:40px auto;padding:0 24px;color:#182536;background:#f7f9fc}" - "h1{font-size:30px}h3{font-size:18px}details{background:white;margin:16px 0;padding:16px;border:1px solid #dce3eb;border-radius:8px;overflow:auto}" - "summary{cursor:pointer;font-weight:650}table{border-collapse:collapse;width:100%;font-size:13px;background:white}" - "th,td{text-align:left;padding:8px;border-bottom:1px solid #dce3eb}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}" - ".pass{color:#126538;font-weight:bold}.fail{color:#a42020;font-weight:bold}") - document = ("" - f"Qwen3 small probe · {status}{''.join(sections)}") - return document + return _host_report_views(report)[1] def write_reports(out: Path, report: dict) -> None: diff --git a/probes/w2_runtime/README.md b/probes/w2_runtime/README.md new file mode 100644 index 00000000..3ed2242e --- /dev/null +++ b/probes/w2_runtime/README.md @@ -0,0 +1,51 @@ +# W2 Host 运行时验收 + +`unit:runtime` 验证文本 tokenizer、固定输入准备、最后有效位置 greedy 采样及停止边界,不执行完整模型或生成循环。生产模块只依赖 NumPy 与可选的 `tokenizers`;Transformers 只用于此门禁的官方对照。 + +## 固定资产与环境 + +模型为 `Qwen/Qwen3-0.6B`,revision `c1899de289a04d12100db370d81485cdf75e47ca`。复用 [W1 manifest](../w1_qwen3_export/manifest.json) 的七个 `source_files` 哈希:配置、tokenizer JSON、merges、vocab 与许可证,合计约 15.2 MiB。下载模式只获取这些文件,不下载模型权重。已有资产必须全部匹配哈希;损坏目录保留并失败,不静默覆盖。 + +使用 [两层模型指南](../w2_qwen3_small/README.md) 中的 Python 3.12 固定 CPU 环境;其中已包含 `numpy==2.2.6`、`tokenizers==0.21.4`、`transformers==4.51.3`。仅使用生产 tokenizer 时可安装 `pip install -e ".[llm]"`,无需 Torch;复现 gate 仍使用固定版本和官方参考依赖。 + +正式复现使用 Ubuntu 24.04 x86_64 / Bash;先按 [Linux 复现约定](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md) 激活环境,在仓库根目录运行: + +```bash +.venv-linux/bin/python -B -X utf8 probes/w2_runtime/run.py \ + --mode download --tokenizer-dir output/qwen3-tokenizer --output-dir output/w2-runtime +``` + +已有七个文件时使用 `--mode verify`,该模式不访问网络。输出目录必须为空或不存在,每次复现选择新的目录。CLI 退出 0 且本次 `report.json` 的 `passed=true` 才代表通过。报告写入不完整、依赖缺失、下载或哈希校验失败均为失败。 + +## 验收内容 + +- 固定 20 组语料涵盖中英混合、空白、换行、代码、数字、CJK/RTL、emoji、组合字符和特殊 token;保存固定 IDs 与解码文本作为 golden corpus。 +- 每组均对照官方 `Qwen2TokenizerFast` 和独立 Python `Qwen2Tokenizer`,覆盖默认参数、`add_special_tokens`、`skip_special_tokens` 与空格清理组合。官方 JSON 的 NFC 规范化会把 `e` 加组合重音转换为 `é`;“100%”指与官方输出完全一致,不是任意原始 Unicode 字节可逆。 +- 分开核对基础词表 151643、含 added tokens 的词表 151669、模型输出维度 151936;模型中空余行不等于可解码 ID。未知 ID 报错,不丢弃;不静默屏蔽 logits 或改变 greedy。 +- tokenizer EOS 为 151645,generation EOS 为 `(151645,151643)`,pad 为 151643。普通文本编码不注入 BOS/EOS,不渲染聊天模板。 +- 检查有效长度 1/17/255/256、右 padding、INT64 IDs、FP32 加性 mask、最后有效位置和 greedy 平局。mask 仅当 `key <= query` 且 `key < valid_length` 时为 0,其余为 FP32 最小有限值。 +- 验证生成 EOS、256 容量和 token 预算停止。空 prompt、超长输入、非法 ID、非有限 logits 等反例在单测中必须失败。 + +## 生产接口 + +```python +from scratchv.runtime.qwen3_tokenizer import Qwen3Tokenizer +from scratchv.runtime.llm_inputs import prepare_inputs, greedy_next_token + +tokenizer = Qwen3Tokenizer.from_directory("output/qwen3-tokenizer") +ids = tokenizer.encode("The capital of France is", add_special_tokens=False) +inputs = prepare_inputs(ids, pad_token_id=tokenizer.pad_token_id, + vocab_size=tokenizer.model_vocab_size) +# outputs = your_verified_model_runner(inputs.as_feed()) +# next_id = greedy_next_token(outputs, inputs.valid_length, +# vocab_size=tokenizer.model_vocab_size) +# tokenizer.validate_token_ids([next_id]) +``` + +`generation_stop_reason` 供后续生成循环在前向前判断 EOS/容量/预算。模型输出仍按原 ABI 完整校验,最后有效位置选择是 host 操作。这里未实现聊天模板、多轮历史、完整模型前向或端到端文本生成;也不能将官方 tokenizer 的 IDs 输入词表只有 128 的随机小模型。 + +## 报告与 CI + +`report.json/md/html` 保留逐语料结果、对照次数、词表/特殊 ID、资产与执行源码 SHA256、checkout、环境、阶段及错误。报告目录不含模型或 tokenizer 大文件;失败保留已完成的记录,不把未执行项计作通过。 + +LLM workflow 的 `unit:runtime` 在固定环境运行,下载固定的七个资产,随后全程离线调用官方参考;`w2-unit-gates-output` artifact 保存报告和单测 XML。`tests/test_llm_inputs.py`、`tests/test_qwen3_tokenizer.py` 和 `tests/test_w2_runtime_*.py` 覆盖输入、适配层和门禁的反例。具体运行结果见 [W2 交付记录](../../docs/llm-deploy-v1.0/W2/README.md)。 diff --git a/probes/w2_runtime/corpus.json b/probes/w2_runtime/corpus.json new file mode 100644 index 00000000..dc33765e --- /dev/null +++ b/probes/w2_runtime/corpus.json @@ -0,0 +1,487 @@ +{ + "schema_version": 1, + "model_id": "Qwen/Qwen3-0.6B", + "revision": "c1899de289a04d12100db370d81485cdf75e47ca", + "reference": { + "transformers": "4.51.3", + "tokenizers": "0.21.4", + "classes": [ + "Qwen2Tokenizer", + "Qwen2TokenizerFast" + ], + "add_special_tokens": false, + "skip_special_tokens": false, + "clean_up_tokenization_spaces": false + }, + "cases": [ + { + "name": "empty", + "text": "", + "ids": [], + "decoded": "" + }, + { + "name": "english", + "text": "The capital of France is", + "ids": [ + 785, + 6722, + 315, + 9625, + 374 + ], + "decoded": "The capital of France is" + }, + { + "name": "chinese", + "text": "你好,世界!ScratchV 编译器。", + "ids": [ + 108386, + 3837, + 99489, + 6313, + 65508, + 754, + 53, + 84238, + 244, + 102610, + 31548, + 1773 + ], + "decoded": "你好,世界!ScratchV 编译器。" + }, + { + "name": "mixed", + "text": "Qwen3 在 RISC-V 上运行 0.6B 模型。", + "ids": [ + 48, + 16948, + 18, + 73562, + 431, + 27629, + 19625, + 64118, + 104001, + 220, + 15, + 13, + 21, + 33, + 6567, + 44401, + 24300, + 1773 + ], + "decoded": "Qwen3 在 RISC-V 上运行 0.6B 模型。" + }, + { + "name": "whitespace", + "text": " leading spaces\tand tabs\n\ntrailing ", + "ids": [ + 220, + 6388, + 220, + 12621, + 52477, + 22398, + 271, + 376, + 14277, + 256 + ], + "decoded": " leading spaces\tand tabs\n\ntrailing " + }, + { + "name": "line_endings", + "text": "line1\r\nline2\rline3\n", + "ids": [ + 1056, + 16, + 319, + 1056, + 17, + 201, + 1056, + 18, + 198 + ], + "decoded": "line1\r\nline2\rline3\n" + }, + { + "name": "code", + "text": "def f(x):\n return x * 2 + 1\n", + "ids": [ + 750, + 282, + 2075, + 982, + 262, + 470, + 856, + 353, + 220, + 17, + 488, + 220, + 16, + 198 + ], + "decoded": "def f(x):\n return x * 2 + 1\n" + }, + { + "name": "math", + "text": "x² + y² = z²; ∑ₖ αₖ ≈ 3.1415926", + "ids": [ + 87, + 29456, + 488, + 379, + 29456, + 284, + 1147, + 29456, + 26, + 11995, + 239, + 15896, + 244, + 19043, + 15896, + 244, + 21103, + 230, + 220, + 18, + 13, + 16, + 19, + 16, + 20, + 24, + 17, + 21 + ], + "decoded": "x² + y² = z²; ∑ₖ αₖ ≈ 3.1415926" + }, + { + "name": "emoji", + "text": "👩🏽‍💻 🧪 🚀 🇨🇳 👨‍👩‍👧‍👦", + "ids": [ + 145233, + 145375, + 378, + 235, + 145851, + 11162, + 100, + 103, + 11162, + 248, + 222, + 11162, + 229, + 101, + 145754, + 61804, + 101, + 378, + 235, + 145233, + 378, + 235, + 145665, + 378, + 235, + 145988 + ], + "decoded": "👩🏽‍💻 🧪 🚀 🇨🇳 👨‍👩‍👧‍👦" + }, + { + "name": "combining", + "text": "café café Å Å", + "ids": [ + 924, + 58858, + 51950, + 79252, + 79252 + ], + "decoded": "café café Å Å" + }, + { + "name": "cjk", + "text": "日本語のテスト。한국어 테스트。繁體中文測試。", + "ids": [ + 101059, + 102819, + 15767, + 140592, + 1773, + 23573, + 124785, + 31079, + 10764, + 72509, + 53189, + 1773, + 100112, + 100480, + 104811, + 107636, + 102854, + 1773 + ], + "decoded": "日本語のテスト。한국어 테스트。繁體中文測試。" + }, + { + "name": "rtl", + "text": "مرحبا بالعالم שלום עולם", + "ids": [ + 124122, + 29825, + 124671, + 124476, + 129634, + 124756, + 123881, + 123855, + 124907 + ], + "decoded": "مرحبا بالعالم שלום עולם" + }, + { + "name": "punctuation", + "text": "Hello , world ! Isn't this fun ?", + "ids": [ + 9707, + 1154, + 1879, + 753, + 220, + 52196, + 944, + 419, + 2464, + 937 + ], + "decoded": "Hello , world ! Isn't this fun ?" + }, + { + "name": "numbers", + "text": "0 1 -2 1e-5 3.14159 1234567890 00123", + "ids": [ + 15, + 220, + 16, + 481, + 17, + 220, + 16, + 68, + 12, + 20, + 220, + 18, + 13, + 16, + 19, + 16, + 20, + 24, + 220, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 15, + 220, + 15, + 15, + 16, + 17, + 18 + ], + "decoded": "0 1 -2 1e-5 3.14159 1234567890 00123" + }, + { + "name": "json", + "text": "{\"name\":\"Qwen3\",\"enabled\":true,\"items\":[1,2,3]}", + "ids": [ + 4913, + 606, + 3252, + 48, + 16948, + 18, + 2198, + 15868, + 788, + 1866, + 1335, + 3615, + 8899, + 16, + 11, + 17, + 11, + 18, + 13989 + ], + "decoded": "{\"name\":\"Qwen3\",\"enabled\":true,\"items\":[1,2,3]}" + }, + { + "name": "controls", + "text": "a\u0000b\t\u000b\f\n", + "ids": [ + 64, + 188, + 65, + 197, + 199, + 68762 + ], + "decoded": "a\u0000b\t\u000b\f\n" + }, + { + "name": "special", + "text": "<|endoftext|><|im_start|>user\nhello<|im_end|>", + "ids": [ + 151643, + 151644, + 872, + 198, + 14990, + 151645 + ], + "decoded": "<|endoftext|><|im_start|>user\nhello<|im_end|>" + }, + { + "name": "thinking", + "text": "Let me reason.\n42", + "ids": [ + 151667, + 10061, + 752, + 2874, + 13, + 151668, + 198, + 19, + 17 + ], + "decoded": "Let me reason.\n42" + }, + { + "name": "repeated", + "text": "hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello ", + "ids": [ + 14990, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 23811, + 220 + ], + "decoded": "hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello hello " + }, + { + "name": "all_added", + "text": "<|endoftext|><|im_start|><|im_end|><|object_ref_start|><|object_ref_end|><|box_start|><|box_end|><|quad_start|><|quad_end|><|vision_start|><|vision_end|><|vision_pad|><|image_pad|><|video_pad|><|fim_prefix|><|fim_middle|><|fim_suffix|><|fim_pad|><|repo_name|><|file_sep|>", + "ids": [ + 151643, + 151644, + 151645, + 151646, + 151647, + 151648, + 151649, + 151650, + 151651, + 151652, + 151653, + 151654, + 151655, + 151656, + 151657, + 151658, + 151659, + 151660, + 151661, + 151662, + 151663, + 151664, + 151665, + 151666, + 151667, + 151668 + ], + "decoded": "<|endoftext|><|im_start|><|im_end|><|object_ref_start|><|object_ref_end|><|box_start|><|box_end|><|quad_start|><|quad_end|><|vision_start|><|vision_end|><|vision_pad|><|image_pad|><|video_pad|><|fim_prefix|><|fim_middle|><|fim_suffix|><|fim_pad|><|repo_name|><|file_sep|>" + } + ] +} diff --git a/probes/w2_runtime/run.py b/probes/w2_runtime/run.py new file mode 100644 index 00000000..f6b93ec4 --- /dev/null +++ b/probes/w2_runtime/run.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""W2 host-runtime gate against pinned official tokenizer assets and two oracles.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import html +import importlib.metadata +import json +from pathlib import Path +import platform +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +MANIFEST_PATH = ROOT / "probes/w1_qwen3_export/manifest.json" +MANIFEST = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) +CORPUS = Path(__file__).with_name("corpus.json") +PINNED = {"numpy": "2.2.6", "tokenizers": "0.21.4", "transformers": "4.51.3"} +MAX_FILE_BYTES = 16 * 1024 * 1024 + + +def sha256(path): + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def verify_assets(directory, manifest=MANIFEST): + directory = Path(directory).resolve() + rows, seen = [], set() + for item in manifest["source_files"]: + name = item["name"] + if (not name or name in seen or name in (".", "..") + or any(c in name for c in "/\\:")): + raise ValueError(f"Non-flat or duplicate asset name: {name}") + seen.add(name) + path = (directory / name).resolve() + if not path.is_relative_to(directory) or not path.is_file(): + raise FileNotFoundError(f"Missing tokenizer asset: {name}") + size = path.stat().st_size + if size > MAX_FILE_BYTES or sha256(path) != item["sha256"]: + raise ValueError(f"Tokenizer asset SHA256/size mismatch: {name}") + rows.append({**item, "bytes": size}) + required = {"tokenizer.json", "tokenizer_config.json", "config.json", + "generation_config.json", "vocab.json", "merges.txt", "LICENSE"} + if seen != required: + raise ValueError("Tokenizer gate requires exactly the seven pinned source files") + return rows + + +def download_file(url, destination): + """Bound downloads; retry transient server failures without trusting a cache.""" + for attempt in range(3): + try: + request = urllib.request.Request(url, headers={"User-Agent": "ScratchV-W2-runtime"}) + with urllib.request.urlopen(request, timeout=30) as incoming, Path(destination).open("wb") as out: + count = 0 + while block := incoming.read(1024 * 1024): + count += len(block) + if count > MAX_FILE_BYTES: + raise ValueError("Tokenizer download exceeds per-file limit") + out.write(block) + return + except urllib.error.HTTPError as exc: + if exc.code not in (429, 500, 502, 503, 504) or attempt == 2: + raise + time.sleep(attempt + 1) + + +def acquire_assets(directory, manifest=MANIFEST): + directory = Path(directory).resolve() + if directory.exists(): + return {"reused": True, "files": verify_assets(directory, manifest)} + directory.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="qwen3-tokenizer-", dir=directory.parent) as tmp: + stage = Path(tmp).resolve() + for item in manifest["source_files"]: + name = item["name"] + if not name or name in (".", "..") or any(c in name for c in "/\\:"): + raise ValueError(f"Invalid tokenizer asset name: {name}") + url = f"https://huggingface.co/{manifest['model_id']}/resolve/{manifest['revision']}/{name}" + download_file(url, stage / name) + rows = verify_assets(stage, manifest) + if not stage.is_relative_to(directory.parent) or directory.exists(): + raise ValueError("Tokenizer destination changed during download") + stage.rename(directory) + return {"reused": False, "files": rows} + + +def require_environment(): + versions = {name: importlib.metadata.version(name) for name in PINNED} + if sys.version_info[:2] != (3, 12) or versions != PINNED: + raise RuntimeError(f"Use Python 3.12 and the pinned CPU probe environment: {versions}") + return {"python": platform.python_version(), "platform": platform.platform(), + "packages": versions, "reference": "Official Transformers fast and slow tokenizers, offline"} + + +def checkout_evidence(): + """Source archives remain runnable; never attribute them to a parent repo.""" + result = {"root": str(ROOT), "head": None, "status": None, "clean": None} + if not (ROOT / ".git").exists(): + result["unavailable"] = "Source directory has no .git entry; use source_sha256 for identity" + return result + try: + for key, arguments in (("head", ["rev-parse", "HEAD"]), + ("status", ["status", "--porcelain=v1", "--untracked-files=all"])): + result[key] = subprocess.check_output( + ["git", *arguments], cwd=ROOT, text=True, encoding="utf-8", errors="replace", + stderr=subprocess.PIPE, timeout=10, + ).rstrip("\r\n") + result["clean"] = not bool(result["status"]) + except (OSError, subprocess.SubprocessError) as exc: + result["unavailable"] = f"{type(exc).__name__}: {exc}" + return result + + +def tokenizer_cases(adapter, fast, slow, corpus, rows): + if not corpus["cases"]: + raise ValueError("Tokenizer corpus must not be empty") + names = set() + for case in corpus["cases"]: + if case["name"] in names: + raise ValueError("Duplicate corpus case name") + names.add(case["name"]) + row = {"name": case["name"], "passed": False, "checks": 0} + rows.append(row) + # Exercise public defaults as well as explicit flags. A default-only + # regression would otherwise leave every explicit-option check green. + default_ids = adapter.encode(case["text"]) + default_text = adapter.decode(default_ids) + for reference in (fast, slow): + if default_ids != reference.encode(case["text"]) or default_ids != case["ids"]: + raise ValueError(f"{case['name']}: default encoding differs from official/golden IDs") + row["checks"] += 1 + if default_text != reference.decode(default_ids) or default_text != case["decoded"]: + raise ValueError(f"{case['name']}: default decode differs from official/golden text") + row["checks"] += 1 + for special in (False, True): + actual = adapter.encode(case["text"], add_special_tokens=special) + for reference in (fast, slow): + expected = reference.encode(case["text"], add_special_tokens=special) + if actual != expected or actual != case["ids"]: + raise ValueError(f"{case['name']}: encoding differs from official/golden IDs") + row["checks"] += 1 + for skip in (False, True): + for cleanup in (False, True): + result = adapter.decode(actual, skip_special_tokens=skip, + clean_up_tokenization_spaces=cleanup) + for reference in (fast, slow): + expected = reference.decode(actual, skip_special_tokens=skip, + clean_up_tokenization_spaces=cleanup) + if result != expected: + raise ValueError(f"{case['name']}: decode differs (skip={skip}, cleanup={cleanup})") + row["checks"] += 1 + if not skip and not cleanup and result != case["decoded"]: + raise ValueError(f"{case['name']}: decode differs from golden text") + row.update(passed=True, token_count=len(case["ids"]), + raw_text_preserved=case["decoded"] == case["text"]) + + +def tokenizer_metadata(adapter, fast, slow, directory): + """Check metadata independently: model rows are not tokenizer membership.""" + directory = Path(directory) + model = json.loads((directory / "config.json").read_text(encoding="utf-8")) + generation = json.loads((directory / "generation_config.json").read_text(encoding="utf-8")) + expected_eos = generation["eos_token_id"] + if isinstance(expected_eos, int): + expected_eos = [expected_eos] + if adapter.model_vocab_size != model["vocab_size"]: + raise ValueError("Tokenizer model_vocab_size differs from the official model configuration") + if adapter.generation_eos_token_ids != tuple(expected_eos): + raise ValueError("Tokenizer generation EOS IDs differ from the official generation configuration") + if adapter.pad_token_id != generation["pad_token_id"]: + raise ValueError("Tokenizer pad ID differs from the official generation configuration") + for reference in (fast, slow): + if (adapter.base_vocab_size != reference.vocab_size + or adapter.tokenizer_vocab_size != len(reference) + or adapter.valid_token_ids != frozenset(reference.get_vocab().values())): + raise ValueError("Tokenizer vocabulary metadata differs from the official reference") + for name in ("pad_token_id", "eos_token_id", "bos_token_id", "unk_token_id"): + if getattr(adapter, name) != getattr(reference, name): + raise ValueError(f"Tokenizer {name} differs from the official reference") + return {"model_vocab_size": adapter.model_vocab_size, + "base_vocab_size": adapter.base_vocab_size, + "tokenizer_vocab_size": adapter.tokenizer_vocab_size, + "pad_token_id": adapter.pad_token_id, + "eos_token_id": adapter.eos_token_id, + "bos_token_id": adapter.bos_token_id, + "unk_token_id": adapter.unk_token_id, + "generation_eos_token_ids": list(adapter.generation_eos_token_ids)} + + +def input_cases(adapter, rows): + import numpy as np + from scratchv.runtime.llm_inputs import prepare_inputs, greedy_next_token, generation_stop_reason + # Explicit loop oracle, independent of the vectorized production mask. + for length in (1, 17, 255, 256): + ids = [42] * length + ids[0] = adapter.pad_token_id # A pad-like ID inside the prompt remains a valid token. + prepared = prepare_inputs(ids, pad_token_id=adapter.pad_token_id, + vocab_size=adapter.model_vocab_size) + expected = np.full((1, 1, 256, 256), np.finfo(np.float32).min, dtype=np.float32) + for query in range(256): + for key in range(min(query + 1, length)): + expected[0, 0, query, key] = 0 + if (prepared.valid_length != length or prepared.input_ids.dtype != np.int64 + or prepared.input_ids.shape != (1, 256) + or not np.array_equal(prepared.input_ids[0, :length], ids) + or not np.all(prepared.input_ids[0, length:] == adapter.pad_token_id) + or prepared.attention_mask.dtype != np.float32 + or not np.array_equal(prepared.attention_mask, expected)): + raise ValueError(f"Input/mask mismatch for valid length {length}") + logits = np.zeros((1, 256, 8), dtype=np.float32) + logits[0, -1, 7] = 100 + logits[0, length - 1, 7] = 0 + logits[0, length - 1, [2, 5]] = 1 + if greedy_next_token(logits, length, vocab_size=8) != 2: + raise ValueError("Greedy used a padding row or wrong tie rule") + rows.append({"name": f"mask-and-greedy-{length}", "passed": True}) + for eos in adapter.generation_eos_token_ids: + reason = generation_stop_reason(valid_length=2, generated_tokens=1, max_new_tokens=3, + last_token_id=eos, eos_token_ids=adapter.generation_eos_token_ids) + if reason != "eos": + raise ValueError(f"Generation EOS not recognized: {eos}") + if generation_stop_reason(valid_length=256, generated_tokens=0, max_new_tokens=1) != "capacity": + raise ValueError("Full context must stop before a new forward call") + if generation_stop_reason(valid_length=1, generated_tokens=0, max_new_tokens=0) != "max_new_tokens": + raise ValueError("Zero generation budget must stop") + rows.append({"name": "eos-capacity-budget", "passed": True}) + + +def run_checks(args, report): + report["stage"] = "environment" + report["environment"] = require_environment() + report["stage"] = "assets" + if args.mode == "download": + report["acquisition"] = acquire_assets(args.tokenizer_dir) + report["files"] = verify_assets(args.tokenizer_dir) + report["stage"] = "tokenizer" + from transformers import AutoTokenizer + from scratchv.runtime.qwen3_tokenizer import Qwen3Tokenizer + adapter = Qwen3Tokenizer.from_directory(args.tokenizer_dir) + fast = AutoTokenizer.from_pretrained(args.tokenizer_dir, use_fast=True, local_files_only=True, + trust_remote_code=False) + slow = AutoTokenizer.from_pretrained(args.tokenizer_dir, use_fast=False, local_files_only=True, + trust_remote_code=False) + corpus = json.loads(CORPUS.read_text(encoding="utf-8")) + if corpus["revision"] != MANIFEST["revision"] or corpus["model_id"] != MANIFEST["model_id"]: + raise ValueError("Corpus/model identity mismatch") + if (type(fast).__name__, type(slow).__name__) != ("Qwen2TokenizerFast", "Qwen2Tokenizer"): + raise ValueError("Expected two distinct official tokenizer reference classes") + report["tokenizer"] = tokenizer_metadata(adapter, fast, slow, args.tokenizer_dir) + tokenizer_cases(adapter, fast, slow, corpus, report["tokenizer_cases"]) + report["stage"] = "inputs-sampling" + input_cases(adapter, report["runtime_cases"]) + report.update(passed=True, stage="complete") + + +def save_report(directory, report): + """Keep the primary failure and make incomplete evidence an explicit FAIL.""" + def write(name, data): + try: + (directory / name).write_text(data, encoding="utf-8") + except OSError as exc: + return f"{name}: {type(exc).__name__}: {exc}" + return None + + lines = ["# W2 unit:runtime", "", f"Result: **{'PASS' if report['passed'] else 'FAIL'}**", + f"Stage: {report['stage']}; seconds: {report['seconds']:.3f}", "", + "Fixed official tokenizer assets; encode/decode vs Transformers fast + slow and golden corpus.", + "NFC normalization is official behavior; equivalence does not mean arbitrary raw-text byte preservation.", + "No pretrained weights, full-model inference or text generation is executed.", "", + "
Tokenizer 与输入准备逐项检查(展开详情)", "", + "| Case | Checks | Passed |", "|---|---:|---|"] + for row in report["tokenizer_cases"] + report["runtime_cases"]: + lines.append(f"| {row['name']} | {row.get('checks', 1)} | {row['passed']} |") + lines.extend(["", "
", ""]) + if "error" in report: + lines.extend(["", "Error: " + report["error"]]) + markdown = "\n".join(lines) + "\n" + intro, detail = markdown.split("
", 1) + detail, suffix = detail.split("
", 1) + caption, detail = detail.split("", 1) + caption = caption.removeprefix("") + failures = [] + documents = { + "report.md": markdown, + "report.html": 'W2 runtime gate
'
+        + html.escape(intro) + '
' + html.escape(caption) + + '
' + html.escape(detail) + '
' + html.escape(suffix) + '
', + } + for name, content in documents.items(): + if error := write(name, content): + failures.append(error) + def record_failure(): + report["report_write_errors"] = failures + if report["passed"]: + report.update(passed=False, stage="report-write", error="Incomplete runtime gate evidence") + + def recover_views(): + # Preserve the primary probe error. Try both human-readable formats even + # when one destination is unwritable. + failure_text = ( + "# W2 unit:runtime\n\nResult: **FAIL**\n\n" + + f"Stage: {report['stage']}\n\nError: {report['error']}\n\n" + + "Report write errors:\n" + "\n".join(f"- {failure}" for failure in failures) + "\n" + ) + recovery = { + "report.md": failure_text, + "report.html": '
' + html.escape(failure_text) + "
", + } + for name, content in recovery.items(): + write(name, content) + + if failures: + record_failure() + recover_views() + # Publish machine-readable status only after the view outcomes are known. + # In particular, never write PASS and rely on a later corrective write: that + # second write could fail and leave a misleading successful JSON artifact. + data = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n" + # A write can complete its payload and still fail while flushing/closing. + # Keep even a fully written PASS private until write_text has returned; + # same-directory replacement then publishes the completed evidence. + pending = directory / ".report.json.tmp" + error = None + try: + pending.write_text(data, encoding="utf-8") + pending.replace(directory / "report.json") + except OSError as exc: + error = f"report.json: {type(exc).__name__}: {exc}" + try: + pending.unlink(missing_ok=True) + except OSError as cleanup_exc: + error += f"; temporary cleanup: {type(cleanup_exc).__name__}: {cleanup_exc}" + if error: + failures.append(error) + record_failure() + recover_views() + if failures: + print(f"[unit:runtime] {report['error']}; report write errors: {'; '.join(failures)}", file=sys.stderr) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--tokenizer-dir", type=Path, required=True) + ap.add_argument("--mode", choices=("verify", "download"), default="verify") + ap.add_argument("--output-dir", type=Path, default=Path("output/w2-runtime")) + args = ap.parse_args(argv) + args.tokenizer_dir = args.tokenizer_dir.resolve() + out = args.output_dir.resolve() + if out.exists() and (not out.is_dir() or any(out.iterdir())): + ap.error("--output-dir must be absent or empty; preserve earlier evidence") + out.mkdir(parents=True, exist_ok=True) + report = {"gate": "unit:runtime", "passed": False, "stage": "initialize", + "created_at": datetime.now(timezone.utc).isoformat(), "mode": args.mode, + "model_id": MANIFEST["model_id"], "revision": MANIFEST["revision"], + "tokenizer_dir": str(args.tokenizer_dir), "tokenizer_cases": [], "runtime_cases": []} + started = time.perf_counter() + try: + report["source_sha256"] = {p: sha256(ROOT / p) for p in ( + "probes/w2_runtime/run.py", "probes/w2_runtime/corpus.json", + "probes/w1_qwen3_export/manifest.json", "scratchv/runtime/llm_inputs.py", + "scratchv/runtime/qwen3_tokenizer.py")} + report["checkout"] = checkout_evidence() + run_checks(args, report) + except Exception as exc: + report.update(passed=False, error=f"{type(exc).__name__}: {exc}") + report["seconds"] = time.perf_counter() - started + save_report(out, report) + print(f"[unit:runtime] {'PASS' if report['passed'] else 'FAIL'}; stage={report['stage']}; report={out / 'report.json'}") + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w2_runtime_model/README.md b/probes/w2_runtime_model/README.md new file mode 100644 index 00000000..d58cd4f0 --- /dev/null +++ b/probes/w2_runtime_model/README.md @@ -0,0 +1,42 @@ +# W2 Runtime → Model 单步接口探测 + +把已实现的 Tokenizer、输入准备、模型调用、最后有效位置选择及 greedy 解码真正串起来。模型使用 `transformers==4.51.3` 官方 `Qwen3ForCausalLM`,两层、hidden=32、FFN=96、Q/KV heads=4/2、head_dim=16、随机种子 0、绑定 embedding/LM head。保留**完整模型词表 151936**;官方 Token ID 直接进入模型,不取模、不重新编号、不裁剪词表。 + +这是 W2 的接口集成证据;权重随机,不代表预训练 Qwen3-0.6B 的语言能力。执行单步前向与一个 greedy token,不执行生成循环、KV cache 或 QEMU。 + +## Linux 复现 + +正式复现使用 Ubuntu 24.04 x86_64 / Bash;先按 [Linux 复现约定](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md) 激活 `.venv-linux`。使用仓库 [固定 CPU 环境](../../requirements/qwen3-small-probe.txt),Python 3.12。首先安装对应 CPU Torch,再安装固定依赖。Tokenizer 资产复用 [W2 Runtime 探测](../w2_runtime/README.md)下载的目录,或固定模型源目录;每次运行都会核验 [W1 manifest](../w1_qwen3_export/manifest.json) 的七个资产 SHA256,只读取小文件,不加载预训练权重。 + +```bash +python probes/w2_runtime/run.py --mode download --tokenizer-dir output/qwen3-tokenizer --output-dir output/w2-runtime +python probes/w2_runtime_model/run.py --tokenizer-dir output/qwen3-tokenizer --output-dir output/w2-runtime-model +``` + +输出目录必须不存在或为空。既有证据不会被覆盖;版本不匹配、资产缺失/损坏、数值或采样不一致、报告写入失败均返回非零。 + +## 验收内容 + +- 两个固定原始文本:中文混合文本,以及包含官方特殊 token 和 `endoftext` 的输入。后者包含 pad ID,仍必须按原始 token 数计算有效长度。 +- 生产 `Qwen3Tokenizer` 的编码与官方 fast、slow Tokenizer 相同。生产 `prepare_inputs` 的 ID、dtype、shape、因果 mask 和 padding 由独立标量循环核验。 +- 只导出普通 logits ONNX:INT64 `[1,256]` input_ids、FP32 `[1,1,256,256]` attention_mask,FP32 `[1,256,151936]` logits;FP32、opset 18、固定形状、无 KV cache、eager attention。 +- Torch、ORT、共享 IR `none` 和 `all` 真正执行相同输入。ORT 对 Torch、IR 对 Torch 和 ORT 均检查所有位置,含 padding,严格要求有限 FP32、shape 完全相同、`max_abs < 1e-5`、`rtol=0`。 +- 每条路径选择 `valid_length - 1` 的 logits,greedy ID 必须与独立索引的 Torch argmax 相同;解码必须与官方 fast、slow 相同。即使精度误差小于阈值,argmax 改变仍会失败;若抽中模型词表的未定义 Tokenizer 行,明确失败,不能修改候选 mask 来掩盖。 + +## 输出及资源 + +`report.json`、`report.md`、`report.html` 包含结果、阶段、首次失败、源码 SHA256、实际 checkout、环境版本、资产 SHA256、模型参数与权重 hash、输入 token IDs/hash、ONNX hash、全/有效/padding 误差、最差位置、greedy ID/解码/top-2 margin、构建/导出/解析/各引擎执行耗时。JSON 在完整写入并关闭后原子发布;报告生成失败不能留下 PASS。 + +无 `.git` 的源码快照也可复现:checkout HEAD 明确标为未知,身份由运行前记录的源码 SHA256 确定,不借用上层仓库的 Git 信息。报告同时记录实际导入的 parser、IR interpreter、Tokenizer 和输入模块的 `__file__`;若这些模块来自当前源码树之外则失败,防止误用旧安装。 + +报告中的引擎 `seconds` 仅计该次主机前向调用;总 `seconds` 还包含环境加载、资产校验、导出、报告前的数据保存与数值比较。都不是 QEMU 时间,也不是目标硬件性能数据。 + +`model.onnx`、`config.json`、每个文本的 `*.inputs.npz`、`*.torch.npy`、`*.ort.npy` 供独立复核。一个完整输出约 148.4 MiB;参考输出保存后通过 mmap 顺序读取,ORT 会话在 IR 前释放,各次完整输出按顺序释放,比较按 8 个 token 的块进行。两种参考、两个文本的持久化输出合计约 594 MiB,另加约 20 MiB 模型;预留至少 1 GiB 磁盘和数 GiB 空闲内存。这里没有声称限制了解释器内部内存峰值。 + +负例回归: + +```bash +python -m pytest tests/test_w2_runtime_model.py -q +``` + +负例覆盖 ID 映射、padding/因果 mask、错误行选择、argmax 翻转、非法采样 ID、非有限/错误 shape/dtype、只在 padding 超差、解码差异及报告失败。单测不冒充真实模型运行;必须同时执行上面的集成 CLI 才能得到接口集成 PASS。 diff --git a/probes/w2_runtime_model/run.py b/probes/w2_runtime_model/run.py new file mode 100644 index 00000000..ae8d0915 --- /dev/null +++ b/probes/w2_runtime_model/run.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Text -> official tokenizer -> static Qwen3 -> shared IR -> one greedy ID. + +This is an interface integration probe with seeded random weights, not a +pretrained 0.6B model or a generation loop. The complete vocabulary is retained. +""" +from __future__ import annotations + +import argparse +import copy +from datetime import datetime, timezone +import gc +import html +import json +from pathlib import Path +import sys +import time + +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_runtime.run import MANIFEST, checkout_evidence, require_environment, sha256, verify_assets +from probes.w2_qwen3_small.run import arrays_sha256, environment +from probes.numeric_summary import compact_metric_table, compact_metric_table_html, METRIC_EXPLANATION +from scratchv.runtime.llm_inputs import last_valid_logits, greedy_token, prepare_inputs +from scratchv.verification.numeric_metrics import numeric_metrics + +SEQ, VOCAB, ATOL = 256, 151936, 1e-5 +LEVELS = ("none", "all") +CASES = (("chinese", "你好,ScratchV!"), + ("special_pad_in_prompt", "<|im_start|>user\nTest<|endoftext|>!<|im_end|>\n")) + + +def checked_inputs(ids, prepared, pad_token_id): + """Independent ABI oracle: never infer valid length from pad-ID values.""" + length = len(ids) + if prepared.valid_length != length or not 1 <= length <= SEQ: + raise ValueError("Input preparation changed the prompt length") + actual = prepared.as_feed() + expected_ids = np.full((1, SEQ), pad_token_id, dtype=np.int64) + expected_ids[0, :length] = ids + expected_mask = np.full((1, 1, SEQ, SEQ), np.finfo(np.float32).min, dtype=np.float32) + for query in range(SEQ): + for key in range(min(query + 1, length)): + expected_mask[0, 0, query, key] = 0 + for name, expected in (("input_ids", expected_ids), ("attention_mask", expected_mask)): + value = actual[name] + if value.shape != expected.shape or value.dtype != expected.dtype or not np.array_equal(value, expected): + raise ValueError(f"Input preparation changed {name} ABI, IDs, causal mask or padding") + return actual + + +def compare_logits(actual, expected, valid_length, *, vocab_size=VOCAB): + """Compare every element in bounded FP64 chunks, including padding rows.""" + shape = (1, SEQ, vocab_size) + for name, array in (("actual", actual), ("expected", expected)): + if not isinstance(array, np.ndarray) or np.ma.isMaskedArray(array): + raise TypeError(f"{name} logits must be an ordinary ndarray") + if array.shape != shape or array.dtype != np.dtype(np.float32): + raise ValueError(f"{name} logits require FP32 {shape}; got {array.dtype} {array.shape}") + if not 1 <= valid_length <= SEQ: + raise ValueError("Invalid prompt length") + maxima = {"max_abs": 0.0, "valid_max_abs": 0.0, "padding_max_abs": 0.0} + worst = None + for start in range(0, SEQ, 8): + stop = min(start + 8, SEQ) + left, right = actual[0, start:stop], expected[0, start:stop] + if not np.isfinite(left).all() or not np.isfinite(right).all(): + raise ValueError(f"Nonfinite logits in positions {start}:{stop}") + difference = np.abs(left.astype(np.float64) - right.astype(np.float64)) + maximum = float(difference.max()) + if maximum > maxima["max_abs"]: + position, token = np.unravel_index(int(difference.argmax()), difference.shape) + worst = [0, start + int(position), int(token)] + maxima["max_abs"] = max(maxima["max_abs"], maximum) + boundary = min(max(valid_length - start, 0), stop - start) + if boundary: + maxima["valid_max_abs"] = max(maxima["valid_max_abs"], float(difference[:boundary].max())) + if boundary < stop - start: + maxima["padding_max_abs"] = max(maxima["padding_max_abs"], float(difference[boundary:].max())) + return {**maxima, **numeric_metrics(actual, expected), "worst_index": worst, "elements": int(actual.size), + "passed": maxima["max_abs"] < ATOL, "atol": ATOL, "rtol": 0} + + +def sample_checked(logits, reference, length, adapter, references, *, vocab_size=VOCAB): + vector = last_valid_logits(logits, length, vocab_size=vocab_size) + expected = reference[0, length - 1, :] + # Independently index the reference to catch an incorrect host row selector. + if vector.shape != expected.shape or not np.array_equal(vector, logits[0, length - 1, :]): + raise ValueError("Host selected the wrong logits row") + actual_id, expected_id = greedy_token(vector), int(np.argmax(expected)) + if actual_id != expected_id: + raise ValueError(f"Greedy ID differs: actual={actual_id}, reference={expected_id}") + adapter.validate_token_ids([actual_id]) # Never hide invalid padded model rows. + text = adapter.decode([actual_id], skip_special_tokens=False, clean_up_tokenization_spaces=False) + for tokenizer in references: + if text != tokenizer.decode([actual_id], skip_special_tokens=False, clean_up_tokenization_spaces=False): + raise ValueError("Sampled token decode differs from official tokenizer") + ordered = np.partition(expected, -2)[-2:] + return {"id": actual_id, "decoded": text, "position": length - 1, + "reference_top2_margin": float(ordered[-1] - ordered[-2]), "passed": True} + + +def build_model(adapter): + import torch + from transformers import Qwen3Config, Qwen3ForCausalLM + from probes.w2_qwen3_small.model import MODEL_CONFIG + + config_values = {**MODEL_CONFIG, "vocab_size": VOCAB, + "pad_token_id": adapter.pad_token_id, + "bos_token_id": None, "eos_token_id": list(adapter.generation_eos_token_ids)} + config = Qwen3Config(**config_values) + config._attn_implementation = "eager" + with torch.random.fork_rng(devices=[]): + torch.manual_seed(0) + model = Qwen3ForCausalLM(config).float().eval() + if model.lm_head.weight is not model.model.embed_tokens.weight: + raise ValueError("Expected official tied embedding and LM head") + + class NormalModel(torch.nn.Module): + def __init__(self, qwen): + super().__init__() + self.model = qwen + self.register_buffer("positions", torch.arange(SEQ).reshape(1, SEQ), persistent=False) + + def forward(self, input_ids, attention_mask): + return self.model(input_ids=input_ids, attention_mask=attention_mask, + position_ids=self.positions, use_cache=False, + output_attentions=False, output_hidden_states=False, + return_dict=False, logits_to_keep=0)[0] + + return NormalModel(model).eval(), config_values + + +def check_onnx_contract(model): + import onnx + onnx.checker.check_model(model, full_check=True) + standard_opsets = [item.version for item in model.opset_import if item.domain in ("", "ai.onnx")] + if standard_opsets != [18]: + raise ValueError(f"Expected exactly one standard ONNX opset 18: {standard_opsets}") + expected = {"input_ids": (onnx.TensorProto.INT64, [1, SEQ]), + "attention_mask": (onnx.TensorProto.FLOAT, [1, 1, SEQ, SEQ]), + "logits": (onnx.TensorProto.FLOAT, [1, SEQ, VOCAB])} + if [v.name for v in model.graph.input] != ["input_ids", "attention_mask"] or [v.name for v in model.graph.output] != ["logits"]: + raise ValueError("ONNX must have two ordinary inputs and only logits output") + for value in [*model.graph.input, *model.graph.output]: + element = value.type.tensor_type + actual = element.elem_type, [d.dim_value for d in element.shape.dim] + if actual != expected[value.name]: + raise ValueError(f"Unexpected static ONNX ABI: {value.name}: {actual}") + if any(t.data_location == onnx.TensorProto.EXTERNAL for t in model.graph.initializer): + raise ValueError("This small model must embed its weights") + + +def source_evidence(): + paths = [Path(__file__), ROOT / "probes/w2_runtime/run.py", ROOT / "probes/numeric_summary.py", + ROOT / "scratchv/verification/numeric_metrics.py", + ROOT / "probes/w2_qwen3_small/model.py", ROOT / "probes/w2_qwen3_small/run.py", + ROOT / "probes/w1_qwen3_export/manifest.json", + ROOT / "scratchv/runtime/qwen3_tokenizer.py", ROOT / "scratchv/runtime/llm_inputs.py", + ROOT / "scratchv/frontend/onnx_parser.py", ROOT / "scratchv/verification/ir_interpreter.py", + ROOT / "scratchv/verification/ir_numpy_ops.py", ROOT / "scratchv/pass_manager.py", + ROOT / "scratchv/pass_interface.py", ROOT / "scratchv/ir/types.py", ROOT / "scratchv/ir/builder.py", + ROOT / "requirements/qwen3-small-probe.txt", + *sorted((ROOT / "scratchv/optimizer").glob("*.py")), + *sorted((ROOT / "scratchv/analysis").glob("*.py"))] + return {str(p.relative_to(ROOT)).replace("\\", "/"): sha256(p) for p in paths} + + +def run_probe(directory, out, report): + report.update(stage="environment", environment=environment(), tokenizer_environment=require_environment()) + import onnx + import onnxruntime as ort + import torch + from transformers import AutoTokenizer + from scratchv.analysis.ir_verifier import verify_ir + from scratchv.frontend.onnx_parser import ONNXParser + from scratchv.pass_manager import create_optimization_pass_manager + from scratchv.runtime.qwen3_tokenizer import Qwen3Tokenizer + from scratchv.verification.ir_interpreter import IRInterpreter + + def require_valid(program): + valid, errors = verify_ir(program) + if not valid: + raise ValueError(f"Invalid shared IR: {errors}") + + from scratchv.runtime import llm_inputs + modules = {"onnx_parser": sys.modules[ONNXParser.__module__], + "ir_interpreter": sys.modules[IRInterpreter.__module__], + "llm_inputs": llm_inputs, + "qwen3_tokenizer": sys.modules[Qwen3Tokenizer.__module__]} + report["module_files"] = {name: str(Path(module.__file__).resolve()) for name, module in modules.items()} + for name, path in report["module_files"].items(): + if not Path(path).is_relative_to(ROOT): + raise ValueError(f"{name} imported outside the current source tree: {path}") + report.update(stage="tokenizer", assets=verify_assets(directory)) + adapter = Qwen3Tokenizer.from_directory(directory) + if adapter.model_vocab_size != VOCAB: + raise ValueError("The complete official model vocabulary is mandatory") + references = [AutoTokenizer.from_pretrained(directory, use_fast=fast, local_files_only=True, + trust_remote_code=False) for fast in (True, False)] + if [type(t).__name__ for t in references] != ["Qwen2TokenizerFast", "Qwen2Tokenizer"]: + raise ValueError("Require two distinct official tokenizer references") + report["cases"] = [] + feeds = [] + for name, text in CASES: + ids = adapter.encode(text) + if any(ids != reference.encode(text) for reference in references): + raise ValueError(f"Official prompt encoding differs: {name}") + prepared = prepare_inputs(ids, pad_token_id=adapter.pad_token_id) + feed = checked_inputs(ids, prepared, adapter.pad_token_id) + np.savez(out / f"{name}.inputs.npz", **feed) + feeds.append((prepared.valid_length, feed)) + report["cases"].append({"name": name, "text": text, "token_ids": ids, + "valid_length": prepared.valid_length, "input_sha256": arrays_sha256(feed), + "pad_id_inside_prompt": adapter.pad_token_id in ids, + "passed": False, "executions": []}) + if len(feeds) != 2 or not report["cases"][1]["pad_id_inside_prompt"]: + raise ValueError("Require two original-text cases including an in-prompt pad ID") + torch.set_num_threads(1) + torch.use_deterministic_algorithms(True) + report["stage"] = "model" + started = time.perf_counter() + wrapper, config = build_model(adapter) + report.update(config=config, model_seed=0, model_seconds=time.perf_counter() - started, + model_class=type(wrapper.model).__module__ + "." + type(wrapper.model).__name__, + weights="seed 0 random initialization; no pretrained weights", + weights_sha256=arrays_sha256({name: value.detach().numpy() + for name, value in wrapper.model.state_dict().items()})) + (out / "config.json").write_text(wrapper.model.config.to_json_string(use_diff=False), encoding="utf-8") + # Persist references and release their complete tensors before ORT/IR runs. + for case, (length, feed) in zip(report["cases"], feeds): + report.update(stage="torch", current_case=case["name"]) + started = time.perf_counter() + with torch.inference_mode(): + value = wrapper(*(torch.from_numpy(feed[n]) for n in ("input_ids", "attention_mask"))).numpy() + case["torch_seconds"] = time.perf_counter() - started + check = compare_logits(value, value, length) + case["torch"] = {"contract": check, + "sampling": sample_checked(value, value, length, adapter, references)} + np.save(out / f"{case['name']}.torch.npy", value) + del value + gc.collect() + report["stage"] = "export" + started = time.perf_counter() + with torch.inference_mode(): + exported = torch.onnx.export(wrapper, tuple(torch.from_numpy(feeds[0][1][n]) for n in ("input_ids", "attention_mask")), + dynamo=True, opset_version=18, input_names=["input_ids", "attention_mask"], + output_names=["logits"], dynamic_shapes=None, optimize=False, fallback=False) + exported.save(str(out / "model.onnx"), external_data=False) + report["export_seconds"] = time.perf_counter() - started + del exported, wrapper + gc.collect() + exported = onnx.load(out / "model.onnx") + 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, + "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} + del exported + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + options.enable_cpu_mem_arena = False + report["stage"] = "ort" + started = time.perf_counter() + session = ort.InferenceSession(str(out / "model.onnx"), options, providers=["CPUExecutionProvider"]) + report["ort_load_seconds"] = time.perf_counter() - started + for case, (length, feed) in zip(report["cases"], feeds): + report["current_case"] = case["name"] + started = time.perf_counter() + actual = session.run(["logits"], feed)[0] + row = {"engine": "ort", "seconds": time.perf_counter() - started, "passed": False} + case["executions"].append(row) + expected = np.load(out / f"{case['name']}.torch.npy", mmap_mode="r") + row["vs_torch"] = compare_logits(actual, expected, length) + row["sampling"] = sample_checked(actual, expected, length, adapter, references) + row["passed"] = row["vs_torch"]["passed"] + if not row["passed"]: + raise ValueError(f"ORT numeric error for {case['name']}") + # IR is compared independently to both references without retaining a + # second live full-size output array from another execution engine. + np.save(out / f"{case['name']}.ort.npy", actual) + del actual, expected + del session + gc.collect() + report["stage"] = "parse" + started = time.perf_counter() + parser = ONNXParser() + program = parser.parse(str(out / "model.onnx")) + require_valid(program) + report["parse_seconds"] = time.perf_counter() - started + report["ir_optimization_seconds"] = {} + for level in LEVELS: + report.update(stage="ir", current_level=level) + started = time.perf_counter() + manager = create_optimization_pass_manager(level) + manager.before_pass = lambda p, value: require_valid(value) + manager.after_pass = lambda p, value: require_valid(value) + optimized = manager.run_pipeline(copy.deepcopy(program)).data + require_valid(optimized) + report["ir_optimization_seconds"][level] = time.perf_counter() - started + for case, (length, feed) in zip(report["cases"], feeds): + report["current_case"] = case["name"] + row = {"engine": f"ir-{level}", "passed": False} + case["executions"].append(row) + started = time.perf_counter() + result = IRInterpreter(optimized).run(feed, initializers=parser.initializers) + row.update(seconds=time.perf_counter() - started, executed_steps=result.executed_steps) + actual = result.return_value + expected = np.load(out / f"{case['name']}.torch.npy", mmap_mode="r") + row["vs_torch"] = compare_logits(actual, expected, length) + row["sampling"] = sample_checked(actual, expected, length, adapter, references) + del expected + expected = np.load(out / f"{case['name']}.ort.npy", mmap_mode="r") + row["vs_ort"] = compare_logits(actual, expected, length) + row["passed"] = row["vs_torch"]["passed"] and row["vs_ort"]["passed"] + del result, actual, expected + gc.collect() + if not row["passed"]: + raise ValueError(f"IR {level} numeric error for {case['name']}") + print(f"[{case['name']}/ir-{level}] PASS max_abs={row['vs_torch']['max_abs']:.9g}", flush=True) + del optimized + for case in report["cases"]: + case["passed"] = ([row["engine"] for row in case["executions"]] == ["ort", "ir-none", "ir-all"] + and all(row["passed"] for row in case["executions"])) + report.update(passed=bool(report["cases"]) and all(case["passed"] for case in report["cases"]), stage="complete") + report["completed_model_invocations"] = len(report["cases"]) + sum(len(case["executions"]) for case in report["cases"]) + report.pop("current_case", None) + report.pop("current_level", None) + + +def save_reports(out, report): + failures = [] + + def views(): + status = "PASS" if report["passed"] else "FAIL" + description = ("两层随机权重 Qwen3,FP32、L=256、完整词表;两个文本场景使用同一权重和输入分别运行 " + "PyTorch、ORT、原生 NumPy IR,比较全部最终 logits(最大绝对误差 < 1e-5,rtol=0)," + "并核对最后有效位置选择分数最高 token 的结果。不是预训练模型或多步生成。") + comparisons = [] + for engine, label, reference in (("ort", "ORT 对 PyTorch", "vs_torch"), + ("ir-none", "IR 原生 NumPy(无优化)对 ORT", "vs_ort"), + ("ir-all", "IR 原生 NumPy(全部优化)对 ORT", "vs_ort")): + rows = [next((r.get(reference, {}) for r in case.get("executions", []) + if r.get("engine") == engine), {}) for case in report.get("cases", [])] + rows += [{}] * max(0, len(CASES) - len(rows)) + comparisons.append((label, rows)) + note = "PPL / zero-shot:未评测;随机权重模型仅用于验证计算和接口,不评价语言能力。" + error = str(report.get("error", "")) + payload = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + text = (f"# W2 runtime/model integration\n\nResult: **{status}**\n\n{description}\n\n" + + compact_metric_table(comparisons) + f"\n\n{METRIC_EXPLANATION}\n\n{note}\n\n" + + (f"失败原因:{error}\n\n" if error else "") + + "
文本、采样及完整诊断(展开详情)\n\n```json\n" + + payload + "\n```\n\n
\n\n") + page = ('

W2 runtime/model integration

' + + f'

Result: {status}

{html.escape(description)}

' + + compact_metric_table_html(comparisons) + f'

{html.escape(METRIC_EXPLANATION)}

' + + f'

{html.escape(note)}

' + (f'

失败原因:{html.escape(error)}

' if error else "") + + '
文本、采样及完整诊断(展开详情)
'
+                + html.escape(payload) + '
') + return {"report.md": text, "report.html": page} + + def failed(): + report["report_write_errors"] = failures + if report["passed"]: + report.update(passed=False, stage="report-write", error="Incomplete integration evidence") + + def write_views(): + for name, value in views().items(): + try: + (out / name).write_text(value, encoding="utf-8") + except OSError as exc: + failures.append(f"{name}: {type(exc).__name__}: {exc}") + + write_views() + if failures: + failed() + write_views() + pending = out / ".report.json.tmp" + try: + pending.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8") + pending.replace(out / "report.json") + except OSError as exc: + failures.append(f"report.json: {type(exc).__name__}: {exc}") + failed() + write_views() + finally: + try: + pending.unlink(missing_ok=True) + except OSError: + pass + if failures: + print("; ".join(failures), file=sys.stderr) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tokenizer-dir", required=True, type=Path) + parser.add_argument("--output-dir", default=Path("output/w2-runtime-model"), type=Path) + args = parser.parse_args(argv) + out = args.output_dir.resolve() + if out.exists() and (not out.is_dir() or any(out.iterdir())): + parser.error("--output-dir must be absent or empty; preserve earlier evidence") + out.mkdir(parents=True, exist_ok=True) + report = {"gate": "integration:runtime-model", "passed": False, "stage": "initialize", + "created_at": datetime.now(timezone.utc).isoformat(), "model_id": MANIFEST["model_id"], + "tokenizer_revision": MANIFEST["revision"], "cases": []} + started = time.perf_counter() + try: + report["source_sha256"] = source_evidence() + report["checkout"] = checkout_evidence() + run_probe(args.tokenizer_dir.resolve(), out, report) + except Exception as exc: + report.update(passed=False, error=f"{type(exc).__name__}: {exc}") + report["seconds"] = time.perf_counter() - started + save_reports(out, report) + print(f"[integration:runtime-model] {'PASS' if report['passed'] else 'FAIL'}; stage={report['stage']}; report={out / 'report.json'}") + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_attention/README.md b/probes/w3_attention/README.md new file mode 100644 index 00000000..06c12ae5 --- /dev/null +++ b/probes/w3_attention/README.md @@ -0,0 +1,16 @@ +# W3 组合 Attention 后端 + +复现统一采用 **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`。 + +入口 run.py 将 Q/K RMSNorm、全 head_dim RoPE、GQA 连续 KV 重复、因果/key-padding mask、Softmax 和 context MatMul 连成一个 ONNX 图。4 个 Q head、2 个 KV head、head_dim8,包含 L17 和 L256 边界;它是小尺寸组合验证,不是完整 Qwen3 前向。 + +~~~bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_attention/run.py --output-dir output/w3-attention-new --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" +~~~ + +6 图分别执行 none/all,要求 12 次真实 RV64 QEMU、每次 IR/QEMU 同时对照 ORT 和独立 FP64 NumPy 公式,全部 max_abs <1e-4、rtol=0。未来 token 和 padding key/value 扰动产生 10 项跨 ORT/IR/QEMU 的隔离检查。NumPy 按 query 的可见 key 范围独立计算,不复用导出 mask;单测注入“额外屏蔽合法 key”确认参考能捕获错误。 + +所有输入输出、ONNX、ELF、编译/QEMU 命令及工具日志保留在新输出目录。失败/超时的 QEMU 命令与实际耗时也记入 report.json,必需报告写入失败时不保留 PASS 视图。该门槛不修改 W2 的 <1e-5 测试。 + +参见 [统一准备说明](../../docs/llm-deploy-v1.0/W3/README.md)。 diff --git a/probes/w3_attention/cases.py b/probes/w3_attention/cases.py new file mode 100644 index 00000000..89a541a7 --- /dev/null +++ b/probes/w3_attention/cases.py @@ -0,0 +1,108 @@ +"""Combined small Qwen-style Attention, with an independent NumPy reference.""" +from __future__ import annotations +from dataclasses import dataclass +import numpy as np +from onnx import helper, numpy_helper, TensorProto + +@dataclass +class Case: + name: str + model: object + feed: dict + expected: np.ndarray + valid_length: int + relation: tuple | None = None + +def attention_case(name, length, valid, *, change=None, relation=None): + qh, kvh, dim = 4, 2, 8 + rng = np.random.default_rng(137 + length) + feed = { + "q": rng.normal(0, .4, (1, qh, length, dim)).astype(np.float32), + "k": rng.normal(0, .4, (1, kvh, length, dim)).astype(np.float32), + "v": rng.normal(0, .3, (1, kvh, length, dim)).astype(np.float32), + } + if change: + for key in (("q", "k", "v") if change == "future" else ("k", "v")): + feed[key][:, :, 5:] += np.float32(.8) + weights = {"qw": np.linspace(.7, 1.2, dim, dtype=np.float32), + "kw": np.linspace(1.1, .8, dim, dtype=np.float32)} + angle = np.arange(length, dtype=np.float32)[:, None] * ( + 1.0 / (1e6 ** (np.arange(0, dim, 2, dtype=np.float32) / dim)) + )[None, :] + angle = np.concatenate([angle, angle], axis=-1)[None, None] + cos, sin = np.cos(angle).astype(np.float32), np.sin(angle).astype(np.float32) + allowed = np.tril(np.ones((length, length), dtype=bool)) + allowed[:, valid:] = False + mask = np.where(allowed, 0, np.finfo(np.float32).min).astype(np.float32)[None, None] + constants = {**weights, "epsilon": np.array(1e-6, np.float32), + "axes": np.array([-1], np.int64), "scale": np.array(dim**-.5, np.float32), + "zero": np.array(0, np.float32), "cos": cos, "sin": sin, "mask": mask, + "start0": np.array([0], np.int64), "half": np.array([dim//2], np.int64), + "end": np.array([dim], np.int64), "steps": np.array([1], np.int64), + "repeat_axis": np.array([2], np.int64), + "expand_shape": np.array([1, kvh, qh//kvh, length, dim], np.int64), + "repeat_shape": np.array([1, qh, length, dim], np.int64)} + nodes = [] + rotated = {} + for key, w in (("q", "qw"), ("k", "kw")): + def node(op, ins, out, **attrs): + nodes.append(helper.make_node(op, ins, [out], **attrs)) + node("Mul", [key, key], key+"_sq") + node("ReduceMean", [key+"_sq", "axes"], key+"_mean", keepdims=1) + node("Add", [key+"_mean", "epsilon"], key+"_eps") + node("Sqrt", [key+"_eps"], key+"_sqrt") + node("Div", [key, key+"_sqrt"], key+"_unit") + node("Mul", [key+"_unit", w], key+"_norm") + node("Slice", [key+"_norm", "start0", "half", "axes", "steps"], key+"_first") + node("Slice", [key+"_norm", "half", "end", "axes", "steps"], key+"_second") + node("Sub", ["zero", key+"_second"], key+"_negative") + node("Concat", [key+"_negative", key+"_first"], key+"_halfrot", axis=-1) + node("Mul", [key+"_norm", "cos"], key+"_cos") + node("Mul", [key+"_halfrot", "sin"], key+"_sin") + node("Add", [key+"_cos", key+"_sin"], key+"_rope") + x = feed[key].astype(np.float64) + norm = x / np.sqrt(np.mean(x*x, axis=-1, keepdims=True) + 1e-6) + norm *= weights[w].astype(np.float64) + halfrot = np.concatenate([-norm[..., dim//2:], norm[..., :dim//2]], axis=-1) + rotated[key] = norm * cos.astype(np.float64) + halfrot * sin.astype(np.float64) + for key, source in (("k", "k_rope"), ("v", "v")): + nodes.extend([ + helper.make_node("Unsqueeze", [source, "repeat_axis"], [key+"_unsqueezed"]), + helper.make_node("Expand", [key+"_unsqueezed", "expand_shape"], [key+"_expanded"]), + helper.make_node("Reshape", [key+"_expanded", "repeat_shape"], [key+"_repeated"]), + ]) + nodes.extend([ + helper.make_node("Transpose", ["k_repeated"], ["kt"], perm=[0,1,3,2]), + helper.make_node("MatMul", ["q_rope", "kt"], ["raw_scores"]), + helper.make_node("Mul", ["raw_scores", "scale"], ["scores"]), + helper.make_node("Add", ["scores", "mask"], ["masked"]), + helper.make_node("Softmax", ["masked"], ["probabilities"], axis=-1), + helper.make_node("MatMul", ["probabilities", "v_repeated"], ["context"]), + ]) + expected = np.empty((1, qh, length, dim), np.float32) + for head in range(qh): + # Independently enumerate visible keys. Never reuse the exported mask: + # an erroneous but still causal mask could otherwise self-confirm. + for query in range(length): + visible = min(query + 1, valid) + scores = (rotated["q"][0,head,query] @ rotated["k"][0,head//2,:visible].T) * dim**-.5 + probabilities = np.exp(scores - scores.max()) + probabilities /= probabilities.sum() + expected[0,head,query] = probabilities @ feed["v"][0,head//2,:visible].astype(np.float64) + graph = helper.make_graph(nodes, name, + [helper.make_tensor_value_info(k, TensorProto.FLOAT, list(v.shape)) for k,v in feed.items()], + [helper.make_tensor_value_info("context", TensorProto.FLOAT, list(expected.shape))], + [numpy_helper.from_array(v, k) for k,v in constants.items()]) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",18)]) + model.ir_version = 10 + return Case(name, model, feed, expected, valid, relation) + +def build_cases(): + return [ + attention_case("full17",17,17), + attention_case("padding17",17,5), + attention_case("future17",17,17,change="future",relation=("full17",5)), + attention_case("padding_changed17",17,5,change="padding",relation=("padding17",17)), + attention_case("single17",17,1), + attention_case("boundary256",256,255), + ] diff --git a/probes/w3_attention/run.py b/probes/w3_attention/run.py new file mode 100644 index 00000000..a9c7bff2 --- /dev/null +++ b/probes/w3_attention/run.py @@ -0,0 +1,143 @@ +"""W3 combined small Attention on RV64 QEMU; strict max_abs < 1e-4.""" +from __future__ import annotations +import argparse +import os +from pathlib import Path +import sys +import time + +for name in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"): + os.environ[name] = "1" +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.w3_attention.cases import build_cases +from probes.w3_common import new_output_dir, source_evidence, write_reports, sha256_file, process_peak_rss_bytes +from probes.w2_backend_ops.run import reference_case, interpret +from probes.w2_qwen3_small.diagnostics import tensor_diff +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.runtime.riscv_tensor import ( + RiscVTensorExecutionError, RiscVTensorTimeoutError, + build_riscv_tensor, discover_toolchain, run_riscv_tensor, +) + +ATOL = 1e-4 +LEVELS = ("none", "all") + +def run_probe(out, report, *, cc=None, qemu=None, timeout=180): + cases = build_cases() + tools = discover_toolchain(cc=cc, qemu=qemu) + report.update(stage="execution", planned_cases=len(cases), + planned_executions=len(cases)*len(LEVELS), cases=[], invariants=[], + toolchain={"cc":list(tools.cc), "qemu":tools.qemu}) + results = {} + for case in cases: + folder = out / case.name + folder.mkdir() + row = {"name":case.name, "valid_length":case.valid_length, "passed":False, "executions":[]} + report["cases"].append(row) + try: + path = folder / "model.onnx" + expected, parser, program = reference_case(case, path) + row["model_sha256"] = sha256_file(path) + np.savez(folder / "inputs.npz", **case.feed) + row["input_sha256"] = sha256_file(folder / "inputs.npz") + np.save(folder / "numpy.npy", case.expected) + np.save(folder / "ort.npy", expected) + row["ort_vs_numpy"] = tensor_diff(expected, case.expected, ATOL) + if not row["ort_vs_numpy"]["passed"]: + raise ValueError("ORT disagrees with independent NumPy Attention") + results[(case.name, "ort")] = expected + for level in LEVELS: + target = folder / level + target.mkdir() + execution = {"optimization":level, "passed":False, "stage":"ir", + "status":"not_started", "timeout_seconds":timeout} + row["executions"].append(execution) + try: + actual_ir = interpret(program, parser.initializers, case.feed, level) + np.save(target / "ir.npy", actual_ir) + execution["ir_vs_ort"] = tensor_diff(actual_ir, expected, ATOL) + execution["ir_vs_numpy"] = tensor_diff(actual_ir, case.expected, ATOL) + if not all(execution[k]["passed"] for k in ("ir_vs_ort", "ir_vs_numpy")): + raise ValueError("IR Attention mismatch") + execution["stage"] = "compile" + driver = CompilerDriver(CompilerConfig(backend="tensor-c", optimize_level=level, verify_ir=True)) + compiled = driver.compile(str(path), str(target / "model.c")) + if not compiled.success: + raise RuntimeError("; ".join(compiled.errors)) + executable = build_riscv_tensor(driver.tensor_artifact, target / "build", tools) + execution.update(elf_sha256=executable.elf_sha256, compile_command=executable.compile_command, + compile_seconds=executable.compile_seconds, workspace_bytes=executable.workspace_bytes, + tool_versions=executable.tool_versions, stage="qemu") + actual = run_riscv_tensor(executable, case.feed, target / "run", timeout=timeout) + execution.update(qemu_process_wall_seconds=actual.elapsed_s, command=actual.command, stage="numeric") + np.save(target / "qemu.npy", actual.output) + execution["qemu_vs_ort"] = tensor_diff(actual.output, expected, ATOL) + execution["qemu_vs_numpy"] = tensor_diff(actual.output, case.expected, ATOL) + execution["passed"] = all(execution[k]["passed"] for k in ("qemu_vs_ort", "qemu_vs_numpy")) + execution["status"] = "success" if execution["passed"] else "numeric_failed" + execution["stage"] = "complete" + results[(case.name, "ir_"+level)] = actual_ir + results[(case.name, "qemu_"+level)] = actual.output + except Exception as exc: + execution["passed"] = False + execution["status"] = getattr(exc, "status", "runtime_error") + execution["error"] = f"{type(exc).__name__}: {exc}" + if isinstance(exc, (RiscVTensorExecutionError, RiscVTensorTimeoutError)): + execution["qemu_process_wall_seconds"] = float(exc.elapsed_s) + execution["command"] = list(exc.command) + if isinstance(exc, RiscVTensorTimeoutError): + execution["timeout_seconds"] = float(exc.timeout_s) + print(f"[{case.name}/{level}] {'PASS' if execution['passed'] else 'FAIL'}", flush=True) + row["passed"] = all(x["passed"] for x in row["executions"]) and len(row["executions"]) == len(LEVELS) + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + for case in cases: + if case.relation: + base, prefix = case.relation + for backend in ("ort", "ir_none", "ir_all", "qemu_none", "qemu_all"): + item = {"case":case.name, "reference":base, "backend":backend, "prefix":prefix, "passed":False} + report["invariants"].append(item) + if (case.name,backend) in results and (base,backend) in results: + item["comparison"] = tensor_diff(results[(case.name,backend)][:,:,:prefix], + results[(base,backend)][:,:,:prefix], ATOL) + item["passed"] = item["comparison"]["passed"] + else: + item["error"] = "Required execution missing" + executions = [e for row in report["cases"] for e in row["executions"]] + report["passed_executions"] = sum(e["passed"] for e in executions) + durations = [e["qemu_process_wall_seconds"] for e in executions if "qemu_process_wall_seconds" in e] + report["qemu_process_wall_seconds"] = {"total":sum(durations), "measured_count":len(durations)} + report["passed"] = (len(executions) == report["planned_executions"] and + all(row["passed"] for row in report["cases"]) and + all(row["passed"] for row in report["invariants"])) + report["stage"] = "complete" + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--output-dir", type=Path, required=True) + cli.add_argument("--cc") + cli.add_argument("--qemu") + cli.add_argument("--timeout", type=float, default=180) + args = cli.parse_args(argv) + if args.timeout <= 0 or not np.isfinite(args.timeout): + cli.error("--timeout must be finite and positive") + out = new_output_dir(args.output_dir) + started = time.perf_counter() + report = {"gate":"unit:attention-backend", "scope":"W3 small combined Attention; not full Qwen3", + "passed":False, "status":"FAIL", "stage":"source_evidence", "atol":ATOL, "rtol":0} + try: + report.update(source_evidence()) + run_probe(out, report, cc=args.cc, qemu=args.qemu, timeout=args.timeout) + 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, process_peak_rss_bytes=process_peak_rss_bytes()) + write_reports(out, report) + return 0 if report["passed"] else 1 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_common.py b/probes/w3_common.py new file mode 100644 index 00000000..eacf19a8 --- /dev/null +++ b/probes/w3_common.py @@ -0,0 +1,237 @@ +"""Evidence utilities for W3 preparation; no downloads or global runtime changes.""" +from __future__ import annotations +import ctypes +from contextlib import redirect_stdout +import hashlib +from html import escape +import importlib.metadata +import io +import json +import os +from pathlib import Path +import platform +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + +def sha256_file(path): + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for block in iter(lambda: stream.read(4 * 1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + +def new_output_dir(path): + out = Path(path).resolve() + out.mkdir(parents=True, exist_ok=False) + return out + +def _already_loaded_library(path): + """Get an existing module only; never load a candidate BLAS library.""" + if sys.platform == "win32": + from ctypes import wintypes + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + get_module = kernel.GetModuleHandleW + get_module.argtypes = [wintypes.LPCWSTR] + get_module.restype = wintypes.HMODULE + handle = get_module(str(path)) + if not handle: + raise OSError("NumPy library is not already loaded") + return ctypes.CDLL(str(path), handle=handle) + if sys.platform not in ("linux", "darwin") or not hasattr(os, "RTLD_NOLOAD"): + raise OSError("Loaded-only library lookup is unavailable on this platform") + return ctypes.CDLL(str(path), mode=os.RTLD_NOLOAD | getattr(os, "RTLD_LOCAL", 0)) + + +def _openblas_text(library, name): + for prefix in ("scipy_", ""): + for suffix in ("64_", "_64", ""): + symbol = f"{prefix}openblas_get_{name}{suffix}" + try: + function = getattr(library, symbol) + except AttributeError: + continue + function.argtypes = [] + function.restype = ctypes.c_char_p + value = function() + if not value: + raise ValueError(f"{symbol} returned no metadata") + return value.decode("utf-8", errors="replace") + raise AttributeError(f"OpenBLAS get_{name} symbol is unavailable") + + +def numpy_openblas_evidence(np): + """Optional loaded NumPy-wheel BLAS metadata, separate from SIMD support. + + Only NumPy's private library directories are inspected. Other builds (for + example system BLAS or MKL) remain explicitly unavailable rather than + loading a library or inferring an active kernel from CPU capabilities. + """ + result = {"status": "unavailable", "libraries": []} + try: + package = Path(np.__file__).resolve().parent + candidates = set() + for directory in (package.parent / "numpy.libs", package / ".libs", package / ".dylibs"): + if directory.is_dir(): + candidates.update(path for path in directory.iterdir() + if "openblas" in path.name.lower() and path.is_file() + and (path.name.lower().endswith((".dll", ".dylib", ".so")) + or ".so." in path.name.lower())) + for path in sorted(candidates): + row = {"path": str(path), "status": "unavailable"} + result["libraries"].append(row) + try: + library = _already_loaded_library(path) + row["core_name"] = _openblas_text(library, "corename") + row["config"] = _openblas_text(library, "config") + row["status"] = result["status"] = "available" + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + if not candidates: + result["reason"] = "No private NumPy OpenBLAS library found" + except Exception as exc: + result["error"] = f"{type(exc).__name__}: {exc}" + return result + + +def numpy_runtime_evidence(): + """Record host math details without importing the ORT backend. + + Package versions alone do not identify BLAS or SIMD implementations. + These public NumPy diagnostics are provenance, not proof of which ORT + kernel executed. Missing diagnostics never fabricate a backend. + """ + import numpy as np + + result = {"machine": platform.machine(), "processor": platform.processor(), + "thread_environment": {name: os.environ.get(name) for name in ( + "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS")}, + "blas_environment": {"OPENBLAS_CORETYPE": os.environ.get("OPENBLAS_CORETYPE")}, + "numpy_openblas": numpy_openblas_evidence(np)} + for field, function in (("numpy_build", "show_config"), ("numpy_runtime", "show_runtime")): + stream = io.StringIO() + try: + with redirect_stdout(stream): + getattr(np, function)() + result[field] = stream.getvalue().strip() + except (AttributeError, RuntimeError, OSError) as exc: + result[field] = None + result[field + "_error"] = f"{type(exc).__name__}: {exc}" + return result + + +def source_evidence(): + """Record this checkout's identity and content, including untracked W3 code.""" + versions = {} + for name in ("numpy", "onnx", "onnxruntime", "torch", "transformers", "safetensors"): + try: + versions[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + versions[name] = None + git = {"head": None, "dirty": None, "root": str(ROOT), + "status_scope": "source/docs; excludes LFS model binaries"} + if (ROOT / ".git").exists(): + for field, args in (("head", ["rev-parse", "HEAD"]), + ("dirty", ["status", "--porcelain", "--untracked-files=normal", "--", ".", + ":(exclude)**/*.onnx", ":(exclude)**/*.data", + ":(exclude)**/*.safetensors"])): + result = subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=15) + if result.returncode == 0: + git[field] = result.stdout.strip() if field == "head" else bool(result.stdout.strip()) + sources = [] + for folder in ("scratchv", "probes", "scripts"): + sources.extend((ROOT / folder).rglob("*.py")) + sources.extend((ROOT / "requirements").glob("*qwen*.txt")) + sources.extend((ROOT / "probes").rglob("manifest.json")) + sources = sorted(set(p for p in sources if "__pycache__" not in p.parts and "output" not in p.relative_to(ROOT).parts)) + return { + "git": git, + "environment": {"python": platform.python_version(), "executable": sys.executable, + "platform": platform.platform(), "versions": versions, + "numeric_runtime": numpy_runtime_evidence()}, + "source_sha256": {p.relative_to(ROOT).as_posix(): sha256_file(p) for p in sources}, + } + +def process_peak_rss_bytes(): + """Peak resident memory of this process, not IR workspace or an interval peak.""" + if os.name == "nt": + from ctypes import wintypes + class Counters(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), + ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)] + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.GetCurrentProcess.restype = 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 + counters = Counters() + counters.cb = ctypes.sizeof(counters) + if psapi.GetProcessMemoryInfo(kernel.GetCurrentProcess(), ctypes.byref(counters), counters.cb): + return int(counters.PeakWorkingSetSize) + return None + try: + import resource + value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return int(value if sys.platform == "darwin" else value * 1024) + except (ImportError, OSError): + return None + +def atomic_text(path, text): + path = Path(path) + fd, name = tempfile.mkstemp(prefix="." + path.name + ".", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + finally: + if os.path.exists(name): + os.unlink(name) + +def write_reports(out, report): + """Publish required views before JSON. A write failure can never report PASS.""" + out = Path(out) + try: + payload = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n" + title = str(report.get("gate", "W3 preparation")) + status = str(report.get("status", "PASS" if report.get("passed") else "FAIL")) + markdown = f"# {title}\n\nResult: **{status}**\n\n" + from probes.w3_summary import evidence_index_views, summary_views + summary_md, summary_html = summary_views(report) + index_md, index_html = evidence_index_views(report) + markdown += summary_md + index_md + html = ('' + escape(title) + '' + '' + '

' + escape(title) + '

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 00000000..02661f88 --- /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 00000000..8943d321 --- /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 00000000..94c18dd2 --- /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 00000000..be433389 --- /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 00000000..7be1e81f --- /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 00000000..83e78aa6 --- /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 00000000..0af00933 --- /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 00000000..c6c2369b --- /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/llm-deploy.yml) 的 `w3-full-numeric` job 已随 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、调大阈值、启用低精度或回退到小模型。 + +该 job 与 `w3-preparation`、`w3-acceptance` 同属 [llm-deploy.yml](../../.github/workflows/llm-deploy.yml),由显式启用(`run_w3_validation=true`)或定时 `W3_NIGHTLY_ENABLED` 触发;两套自动检查均成功才能通过汇总。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 00000000..67f94daa --- /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 00000000..aa5b5424 --- /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 00000000..478b8645 --- /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 00000000..c4fd7874 --- /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 00000000..4821ad9a --- /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 00000000..8695e808 --- /dev/null +++ b/probes/w3_qwen3_full/run.py @@ -0,0 +1,295 @@ +"""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): + command = [str(Path(args.python).resolve()), "-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 00000000..e86e2972 --- /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 00000000..c06072d5 --- /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_.npz` and `traces//reference.npz` (PyTorch), + `ort_normal.npz`, `ort_diagnostic.npz`, and + `ir__.npz`. +- `report.json`, `report.md`, and `report.html`, including config, environment, + source/weight/artifact hashes, failures, timings, and memory observations. + +Every named NPZ has exactly the keys described by its schema. The report's +`trace_artifacts` list supplies each path, schema, case/backend/graph, hashes, +and IR optimization level for an independent layer-diff tool. Actual numerical +failures retain their NPZ. Malformed IR diagnostic packs are instead saved as +`*_invalid_pack.npy` and explicitly fail the schema contract. + +IR memory statistics describe the interpreter's retained arrays at instruction +boundaries. Logical bytes and deduplicated NumPy backing storage are distinct; +the interpreter's existing copy/retention behavior is unchanged. Temporary +NumPy buffers, allocator overhead, PyTorch, and ORT are outside that accounting. +`process_peak_rss_bytes` is separately reported as the whole process high-water +mark, including export and every backend; it is not an IR memory measurement. + +## Regression tests + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -m pytest tests/test_qwen3_medium_model.py tests/test_qwen3_medium_probe.py tests/test_qwen3_small_model.py tests/test_qwen3_small_probe.py tests/test_qwen3_small_gate.py -q +``` + +Tests exercise all 81 medium shapes, a second configuration changing every +schema dimension and its real ONNX/ORT round trip, invalid configuration +rejection, failure isolation across all six IR paths, nonfinite/shape/value +faults, and the original small-model regressions. The command above includes +the real W2 gate and its injected-failure checks; it does not run QEMU. diff --git a/probes/w3_qwen3_medium/__init__.py b/probes/w3_qwen3_medium/__init__.py new file mode 100644 index 00000000..6f494608 --- /dev/null +++ b/probes/w3_qwen3_medium/__init__.py @@ -0,0 +1 @@ +"""Fixed six-layer Qwen3 random-weight host numerical probe.""" diff --git a/probes/w3_qwen3_medium/preset.py b/probes/w3_qwen3_medium/preset.py new file mode 100644 index 00000000..10655427 --- /dev/null +++ b/probes/w3_qwen3_medium/preset.py @@ -0,0 +1,21 @@ +"""Candidate medium preset: larger depth, hidden state, and feed-forward width. + +This describes randomly initialized official Qwen3, not a pretrained model. +It intentionally retains W2's vocabulary, sequence length, and GQA geometry. +""" + +from probes.w2_qwen3_small.model import MODEL_CONFIG, build_model + +SEQUENCE_LENGTH = 256 +MEDIUM_CONFIG = { + **MODEL_CONFIG, + "num_hidden_layers": 6, + "hidden_size": 64, + "intermediate_size": 192, +} +PRESET_NAME = "qwen3-medium-6l-h64-ffn192-q4-kv2-d16-random" + + +def build_medium_model(seed=0): + return build_model(seed=seed, model_config=MEDIUM_CONFIG, + sequence_length=SEQUENCE_LENGTH) diff --git a/probes/w3_qwen3_medium/run.py b/probes/w3_qwen3_medium/run.py new file mode 100644 index 00000000..2ab45d2a --- /dev/null +++ b/probes/w3_qwen3_medium/run.py @@ -0,0 +1,410 @@ +"""Gate a fixed six-layer random Qwen3 through PyTorch, ORT, and host IR. + +Every ordinary/diagnostic graph and none/basic/all IR path must pass strict +max_abs < 1e-5, rtol=0. No pretrained quality, full 0.6B, or QEMU claim is made. +""" + +from __future__ import annotations + +import argparse +from collections import Counter +import copy +import hashlib +import json +import os +from pathlib import Path +import sys +import time +import traceback + +# Set these before NumPy/torch/ORT imports; all numerical backends use one CPU thread. +for _name in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"): + os.environ[_name] = "1" + +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 ( + build_diagnostic_model, compare_outputs, tensor_diff, unpack_trace, +) +from probes.w2_qwen3_small.run import ( + ATOL, arrays_sha256, attention_checks, environment, input_cases, + position_comparison, split_positions, +) + +LEVELS = ("none", "basic", "all") +GRAPH_KINDS = ("normal", "diagnostic") +CASE_IDENTITIES = ( + ("full_seed_0", 256), ("full_seed_42", 256), ("one_token", 1), + ("short_17", 17), ("short_255", 255), ("changed_future", 256), + ("changed_padding", 17), +) +INVARIANT_IDENTITIES = { + (name, backend, graph) + for name in ("causality", "padding_isolation") + for backend, graph in ( + ("pytorch", "diagnostic"), ("ort", "diagnostic"), ("ort", "normal"), + *((f"ir_{level}", graph) for level in LEVELS for graph in GRAPH_KINDS), + ) +} + + +def trace_schema(metadata): + """Stable named-NPZ schema, distinct from the packed ONNX return schema.""" + checkpoints = [] + for name, entry in metadata.items(): + row = {"name": name, "shape": list(entry["shape"]), "dtype": entry["dtype"], + "sequence_axis": entry["sequence_axis"], "checkpoint": name.split(".")[-1]} + if name.startswith("layer_"): + row["layer"] = int(name.split(".")[0].split("_")[1]) + checkpoints.append(row) + return {"version": 1, "checkpoints": checkpoints} + + +def save_trace(out, relative, values, report, *, schema, case, backend, graph, level=None): + path = out / relative + path.parent.mkdir(parents=True, exist_ok=True) + np.savez(path, **values) + artifact = {"path": relative, "schema": schema, "case": case, "backend": backend, + "graph": graph, "optimization": level, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "arrays_sha256": arrays_sha256(values)} + report["trace_artifacts"].append(artifact) + return artifact + + +def require_valid(program, stage): + from scratchv.analysis.ir_verifier import verify_ir + passed, issues = verify_ir(program, stage=stage) + if not passed: + raise ValueError("; ".join(str(issue) for issue in issues)) + + +def optimized_copy(program, level): + from scratchv.pass_manager import create_optimization_pass_manager + manager = create_optimization_pass_manager(level) + manager.before_pass = lambda p, data: require_valid(data, f"before:{p.name}") + manager.after_pass = lambda p, data: require_valid(data, f"after:{p.name}") + result = manager.run_pipeline(copy.deepcopy(program)) + require_valid(result.data, f"after-pipeline:{level}") + return result.data + + +def run_ir_variant(program, initializers, feed, *, kind, level, schema, names, + metadata, valid_length, reference, ort_reference, out, case_name, report): + """One independently failing path; execution failures never hide sibling paths.""" + from scratchv.verification.ir_interpreter import IRInterpreter + row = {"passed": False, "status": "not_started", "graph": kind, "optimization": level} + started = time.perf_counter() + try: + result = IRInterpreter(program).run(feed, initializers=initializers, collect_memory_stats=True) + row["execution_seconds"] = time.perf_counter() - started + row["executed_steps"] = result.executed_steps + row["memory_stats"] = result.memory_stats + if kind == "diagnostic": + try: + values = unpack_trace(result.return_value, schema) + except Exception: + # Malformed packed output cannot honestly use the named schema. + # Preserve its original bytes for debugging before failing. + raw = out / f"traces/{case_name}/ir_{kind}_{level}_invalid_pack.npy" + raw.parent.mkdir(parents=True, exist_ok=True) + np.save(raw, result.return_value, allow_pickle=False) + row["invalid_packed_trace"] = str(raw.relative_to(out)).replace("\\", "/") + raise + else: + values = {"logits": result.return_value} + row["trace"] = save_trace( + out, f"traces/{case_name}/ir_{kind}_{level}.npz", values, report, + schema="trace_schema.json" if kind == "diagnostic" else "logits_schema.json", + case=case_name, backend="ir", graph=kind, level=level) + expected_names = names if kind == "diagnostic" else ["logits"] + pt = {name: reference[name] for name in expected_names} + row["pytorch_comparison"] = position_comparison(values, pt, expected_names, metadata, valid_length) + # Missing ORT is an explicit failed requirement, even if PyTorch agrees. + row["ort_comparison"] = ( + position_comparison(values, ort_reference, expected_names, metadata, valid_length) + if ort_reference is not None else {"passed": False, "error": "ORT reference unavailable"}) + row["passed"] = row["pytorch_comparison"]["passed"] and row["ort_comparison"]["passed"] + row["status"] = "success" if row["passed"] else "numeric_failed" + except Exception as exc: + row.update(status="runtime_error", error=f"{type(exc).__name__}: {exc}") + row["seconds"] = time.perf_counter() - started + return row + + +def case_passed(case): + """Do not allow one successful graph/optimization to mask another failure.""" + required = [case["capture_preserves_logits"], *case["attention_checks"], + *[case["ort"][kind] for kind in GRAPH_KINDS], + *[case["ir"][level][kind] for level in LEVELS for kind in GRAPH_KINDS]] + return all(item.get("passed") is True for item in required) + + +def finalize_numeric_report(report): + """Require the fixed experiment, not just success among rows that survived.""" + cases = report.get("cases", []) + invariants = report.get("invariants", []) + case_coverage = tuple((row.get("name"), row.get("valid_length")) for row in cases) == CASE_IDENTITIES + backend_coverage = all( + set(row.get("ort", {})) == set(GRAPH_KINDS) + and set(row.get("ir", {})) == set(LEVELS) + and all(set(row["ir"][level]) == set(GRAPH_KINDS) for level in LEVELS) + for row in cases + ) + invariant_coverage = ( + len(invariants) == len(INVARIANT_IDENTITIES) + and {(row.get("name"), row.get("backend"), row.get("graph")) for row in invariants} + == INVARIANT_IDENTITIES + ) + report["coverage"] = {"cases": case_coverage, "backends": backend_coverage, + "invariants": invariant_coverage, + "expected_cases": len(CASE_IDENTITIES), "actual_cases": len(cases), + "expected_invariants": len(INVARIANT_IDENTITIES), "actual_invariants": len(invariants)} + report["coverage_complete"] = case_coverage and backend_coverage and invariant_coverage + report["passed"] = ( + report["coverage_complete"] + and all(case.get("passed") is True for case in cases) + and all(row.get("passed") is True for row in invariants) + ) + + +def isolation_checks(out, metadata): + """Check valid prefixes at every checkpoint for each backend and IR variant.""" + groups = [("pytorch", "reference", "diagnostic"), + ("ort", "ort_diagnostic", "diagnostic"), ("ort", "ort_normal", "normal")] + groups += [(f"ir_{level}", f"ir_{kind}_{level}", kind) for level in LEVELS for kind in GRAPH_KINDS] + checks = [] + for invariant, left, right, length in ( + ("causality", "full_seed_0", "changed_future", 64), + ("padding_isolation", "short_17", "changed_padding", 17), + ): + for backend, filename, kind in groups: + row = {"name": invariant, "backend": backend, "graph": kind, "passed": False} + try: + with np.load(out / f"traces/{left}/{filename}.npz", allow_pickle=False) as a, np.load( + out / f"traces/{right}/{filename}.npz", allow_pickle=False + ) as b: + names = list(metadata) if kind == "diagnostic" else ["logits"] + before = {name: split_positions(a[name], metadata[name]["sequence_axis"], 0, length) for name in names} + after = {name: split_positions(b[name], metadata[name]["sequence_axis"], 0, length) for name in names} + row["comparison"] = compare_outputs(after, before, names, ATOL) + row["passed"] = row["comparison"]["passed"] + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + checks.append(row) + return checks + + +def run_probe(out, report, model_seed=0): + report["stage"] = "environment" + report["environment"] = environment() + report["environment"]["threads"] = 1 + import onnx + import onnxruntime as ort + import torch + from probes.w2_qwen3_small.model import export_onnx + from probes.w3_qwen3_medium.preset import PRESET_NAME, build_medium_model + from probes.w3_common import source_evidence + from scratchv.frontend.onnx_parser import ONNXParser + + torch.set_num_threads(1) + torch.use_deterministic_algorithms(True) + report.update(stage="model", preset=PRESET_NAME, model_seed=model_seed, + provenance=source_evidence(), trace_artifacts=[], timings={}) + started = time.perf_counter() + wrapper = build_medium_model(seed=model_seed) + report["timings"]["model_build_seconds"] = time.perf_counter() - started + names = list(wrapper.output_names) + metadata = wrapper.checkpoint_metadata + report["config"] = wrapper.config_metadata + report["checkpoint_count"] = len(names) + report["checkpoint_count_formula"] = "3 + 13 * num_hidden_layers" + report["parameter_count"] = sum(t.numel() for t in wrapper.parameters()) + report["weights_sha256"] = arrays_sha256({ + name: tensor.detach().cpu().numpy() for name, tensor in wrapper.model.state_dict().items()}) + report["checkpoint_metadata"] = metadata + (out / "config.json").write_text(wrapper.model.config.to_json_string(use_diff=False), encoding="utf-8") + for filename, value in (("trace_schema.json", trace_schema(metadata)), + ("logits_schema.json", trace_schema({"logits": metadata["logits"]})), + ("checkpoint_metadata.json", metadata)): + (out / filename).write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + cases = input_cases() + + def reference(feed): + args = [torch.from_numpy(feed[name]) for name in ("input_ids", "attention_mask")] + with torch.inference_mode(), wrapper.capture(): + tensors = wrapper(*args) + return {name: tensor.detach().cpu().numpy().copy() for name, tensor in zip(names, tensors)} + + started = time.perf_counter() + first_reference = reference(cases[0][2]) + first_reference_seconds = time.perf_counter() - started + report["stage"] = "export" + started = time.perf_counter() + tensors = [torch.from_numpy(cases[0][2][name]) for name in ("input_ids", "attention_mask")] + report["export"] = export_onnx(wrapper, out / "checkpoints.onnx", *tensors) + exported = onnx.load(out / "checkpoints.onnx") + onnx.checker.check_model(exported, full_check=True) + ordinary = copy.deepcopy(exported) + logits_info = copy.deepcopy(next(value for value in ordinary.graph.output if value.name == "logits")) + del ordinary.graph.output[:] + ordinary.graph.output.append(logits_info) + diagnostic, schema = build_diagnostic_model(exported, first_reference) + report["checkpoints"] = schema + (out / "checkpoints.json").write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") + report["onnx"] = {} + for kind, model, filename in (("normal", ordinary, "model.onnx"), + ("diagnostic", diagnostic, "diagnostics.onnx")): + onnx.checker.check_model(model, full_check=True) + onnx.save(model, out / filename) + report["onnx"][kind] = {"path": filename, "nodes": len(model.graph.node), + "operators": dict(sorted(Counter(node.op_type for node in model.graph.node).items())), + "sha256": hashlib.sha256((out / filename).read_bytes()).hexdigest()} + report["timings"]["export_seconds"] = time.perf_counter() - started + report["stage"] = "prepare_backends" + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + report["ort_graph_optimization"] = "ORT_DISABLE_ALL" + sessions, parsers, programs = {}, {}, {} + report["backends"] = {} + for kind, filename in (("normal", "model.onnx"), ("diagnostic", "diagnostics.onnx")): + info = report["backends"][kind] = {"ort": {"passed": False}, "ir": {}} + started = time.perf_counter() + try: + sessions[kind] = ort.InferenceSession(str(out / filename), options, providers=["CPUExecutionProvider"]) + info["ort"]["passed"] = True + except Exception as exc: + info["ort"]["error"] = f"{type(exc).__name__}: {exc}" + info["ort"]["prepare_seconds"] = time.perf_counter() - started + started = time.perf_counter() + try: + parsers[kind] = ONNXParser() + original = parsers[kind].parse(str(out / filename)) + require_valid(original, f"parsed:{kind}") + info["parse_seconds"] = time.perf_counter() - started + except Exception as exc: + info["parse_error"] = f"{type(exc).__name__}: {exc}" + original = None + for level in LEVELS: + started = time.perf_counter() + row = info["ir"][level] = {"passed": False} + try: + if original is None: + raise RuntimeError(info["parse_error"]) + programs[kind, level] = optimized_copy(original, level) + row["passed"] = True + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + row["prepare_seconds"] = time.perf_counter() - started + + report["stage"] = "numeric" + report["cases"] = [] + for case_name, valid_length, feed in cases: + report["current_case"] = case_name + case_started = time.perf_counter() + case = {"name": case_name, "valid_length": valid_length, "passed": False, + "input_sha256": arrays_sha256(feed), "ort": {}, "ir": {}} + report["cases"].append(case) + np.savez(out / f"inputs_{case_name}.npz", **feed) + started = time.perf_counter() + pt = first_reference if case_name == cases[0][0] else reference(feed) + case["pytorch_reference_seconds"] = (first_reference_seconds if case_name == cases[0][0] + else time.perf_counter() - started) + case["reference_trace"] = save_trace(out, f"traces/{case_name}/reference.npz", pt, report, + schema="trace_schema.json", case=case_name, backend="pytorch", graph="diagnostic") + with torch.inference_mode(): + plain = wrapper.model(input_ids=torch.from_numpy(feed["input_ids"]), + attention_mask=torch.from_numpy(feed["attention_mask"]), position_ids=wrapper.positions, + use_cache=False, return_dict=False, logits_to_keep=0)[0].numpy() + case["capture_preserves_logits"] = tensor_diff(pt["logits"], plain, ATOL) + case["attention_checks"] = attention_checks(pt, feed, wrapper.config_metadata) + ort_values = {} + for kind in GRAPH_KINDS: + row = case["ort"][kind] = {"passed": False} + started = time.perf_counter() + try: + if kind not in sessions: + raise RuntimeError(report["backends"][kind]["ort"]["error"]) + values = sessions[kind].run(None, feed) + row["execution_seconds"] = time.perf_counter() - started + if kind == "diagnostic": + actual_names = [output.name for output in sessions[kind].get_outputs()][1:] + if actual_names != names or len(values) != len(names) + 1: + raise ValueError("Diagnostic ONNX output order/count disagrees with schema") + named = dict(zip(names, values[1:])) + row["pack_layout"] = compare_outputs(unpack_trace(values[0], schema), named, names, ATOL) + else: + named = {"logits": values[0]} + ort_values[kind] = named + row["trace"] = save_trace(out, f"traces/{case_name}/ort_{kind}.npz", named, report, + schema="trace_schema.json" if kind == "diagnostic" else "logits_schema.json", + case=case_name, backend="ort", graph=kind) + ordered = names if kind == "diagnostic" else ["logits"] + row["pytorch_comparison"] = position_comparison(named, {n: pt[n] for n in ordered}, ordered, metadata, valid_length) + row["passed"] = row["pytorch_comparison"]["passed"] and row.get("pack_layout", {"passed": True})["passed"] + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + row["seconds"] = time.perf_counter() - started + for level in LEVELS: + case["ir"][level] = {} + for kind in GRAPH_KINDS: + if (kind, level) not in programs: + case["ir"][level][kind] = {"passed": False, "status": "preparation_failed", + "error": report["backends"][kind]["ir"][level]["error"]} + continue + case["ir"][level][kind] = run_ir_variant(programs[kind, level], parsers[kind].initializers, feed, + kind=kind, level=level, schema=schema, names=names, metadata=metadata, + valid_length=valid_length, reference=pt, ort_reference=ort_values.get(kind), + out=out, case_name=case_name, report=report) + case["passed"] = case_passed(case) + case["seconds"] = time.perf_counter() - case_started + print(f"[{case_name}] {'PASS' if case['passed'] else 'FAIL'} ({case['seconds']:.2f}s)", flush=True) + report["invariants"] = isolation_checks(out, metadata) + finalize_numeric_report(report) + report["stage"] = "complete" + report.pop("current_case", None) + + +def main(argv=None): + # The pinned exporter prints Unicode status markers even on redirected + # Windows GBK streams. Escaping unencodable log characters preserves the + # actual export operation and the caller's chosen text encoding. + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(errors="backslashreplace") + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--output-dir", type=Path, default=ROOT / "output/w3-medium") + cli.add_argument("--model-seed", type=int, default=0) + args = cli.parse_args(argv) + from probes.w3_common import new_output_dir + try: + out = new_output_dir(args.output_dir) + except FileExistsError: + cli.error("--output-dir must not exist; use a new directory to preserve evidence") + report = {"title": "Six-layer random Qwen3 medium host gate", "gate": "w3-medium-host", "passed": False, + "stage": "initialization", "atol": ATOL, "rtol": 0, + "acceptance": "strict max_abs < 1e-5 at all positions including padding queries", + "scope": "official random-weight Qwen3; host PyTorch/ORT/IR only; no QEMU or pretrained quality claim"} + started = time.perf_counter() + try: + run_probe(out, report, args.model_seed) + except Exception as exc: + report.update(passed=False, error=f"{type(exc).__name__}: {exc}") + traceback.print_exc() + report["seconds"] = time.perf_counter() - started + report["status"] = "PASS" if report["passed"] else "FAIL" + from probes.w3_common import process_peak_rss_bytes, write_reports + report["process_peak_rss_bytes"] = process_peak_rss_bytes() + report["process_peak_rss_scope"] = "whole process high-water mark, includes PyTorch exporter, ORT sessions and IR; not IR storage statistics" + write_reports(out, report) + print(f"[gate] {'PASS' if report['passed'] else 'FAIL'}; report: {out / 'report.json'}", flush=True) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_subgraphs/README.md b/probes/w3_qwen3_subgraphs/README.md new file mode 100644 index 00000000..5db8c482 --- /dev/null +++ b/probes/w3_qwen3_subgraphs/README.md @@ -0,0 +1,106 @@ +# W3 authenticated Qwen3 pretrained subgraphs + +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 offline gate exercises selected first-layer subgraphs of the pinned +`Qwen/Qwen3-0.6B` checkpoint at revision +`c1899de289a04d12100db370d81485cdf75e47ca`. It loads individual tensors with +`safetensors.safe_open`; it never constructs a full Torch model or downloads +anything. Checkpoint weights and source assets remain read-only. + +Before any tensor is accessed, the gate streams SHA256 verification of the +checkpoint and all seven source assets against the existing W1 manifest. +The checkpoint SHA256 is +`f47f71177f32bcd101b7573ec9171e6a57f4f4d31148d38e382306f42996874b`. +Each selected tensor also records its shape, original BF16 dtype, promoted +FP32 hash and byte count. No generated or random weights enter this gate. + +Run from this checkout with the pinned CPU export environment: + +```bash +set -euo pipefail +W3_SOURCE=/absolute/path/to/qwen3-source/c1899de289a04d12100db370d81485cdf75e47ca +"$SCRATCHV_PYTHON" -X utf8 -B \ + probes/w3_qwen3_subgraphs/run.py \ + --source-dir "$W3_SOURCE" \ + --output-dir output/w3-qwen3-subgraphs-l256 \ + --seq-len 256 +``` + +The output directory must not exist. Only L=256 can produce an official PASS. +`--seq-len 2..255` is a debug subset: even numerical success produces PARTIAL, +`passed=false`, and exit code 2. Formal success exits 0; every gate failure +exits 1. OMP, OpenBLAS, MKL, Torch and ORT are limited to one thread. + +## Coverage and inputs + +All 14 cases are mandatory, with ordinary output and diagnostic checkpoints +executed by ORT and the production ONNX parser/IR interpreter under +`none`, `basic` and `all` optimization levels. + +| Cases | Real dimensions / learned parameters | +|---|---| +| Q/K/V/O projections | 1024→2048, 1024→1024, 1024→1024, 2048→1024 | +| Hidden/Q/K RMSNorm | Last dimensions 1024/128/128; authentic learned weights; epsilon 1e-6 | +| Q/K RoPE | 16/8 heads, full head dimension 128; every position 0..255; theta 1e6 | +| GQA full and key-padded | 16 query heads, 8 KV heads, 128 coordinates, L256 causal masking | +| GQA changed future/padding | Explicit perturbations verify causality and key-padding isolation | +| SwiGLU | 1024→3072→1024, authentic gate/up/down weights and intermediate checkpoints | + +Activations are explicitly synthetic: NumPy PCG64 seed 20261004 generates +standard-normal hidden and O-projection input arrays. Q/K RMSNorm receives +the authentic Q/K projections of that hidden input. RoPE receives those +normalized Q/K arrays; GQA receives their rotations and the authentic V +projection. These preparatory activations are computed in Torch and saved +with shape/dtype/hash evidence. They are not claimed to come from token +embeddings or a complete decoder-layer forward. No residual, full-layer +composition, logits, generation, or language quality is established here. + +## Independent reference and acceptance + +Graphs are assembled from ONNX primitives. References separately use Torch +`F.linear`, `rsqrt`, direct half-pair rotations, `F.silu`, and per-query-head +attention. The independent attention loop maps query head `h` to KV head +`h // 2`, instead of reusing the graph's Expand/Reshape repetition. A manual +uniform-attention unit fixture additionally verifies this mapping and masks. + +For every checkpoint, the gate requires matching shapes and FP32 dtypes, +finite values, and **max absolute error strictly less than 1e-4, rtol=0**. +It compares ORT against Torch, every IR level against ORT and Torch, and +ordinary outputs against diagnostic outputs. Padding queries are included. +Blocked attention must be zero, probability rows must sum to one, and both +causality and padding isolation must pass for Torch, ORT and all IR levels. + +This W3 real-weight baseline does **not** change W2's strict 1e-5 threshold. +There is no full-dimension RISC-V execution claim for this gate. + +## Evidence and memory + +Every case saves `model.onnx`, `diagnostics.onnx`, `checkpoints.json`, +`inputs.npz`, `torch.npz`, `ort.npz`, three `ir_.npz` files, and ordinary +outputs. Failure evidence remains in the new output directory, including +the first failing checkpoint/index, values and errors. The runner continues +other optimization levels and cases after a case failure when possible. + +`report.json`, `report.md` and `report.html` contain source hashes, package +versions, authenticated weight identity, comparisons, timing and memory. +JSON is published atomically **last**, after the other required report files. +Report-write failure cannot leave a new PASS marker. + +`process_peak_rss_bytes` is the process lifetime resident-memory high water +mark, including Torch, ORT and IR. It is not per-case incremental memory. +`memory_stats` separately describes interpreter retained arrays and backing +storage; it excludes kernel temporaries and allocator overhead. Ordinary +graphs and packed diagnostic graphs are labeled separately because packing +adds output storage. No peak-memory improvement is claimed. + +Unit checks: + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -m pytest \ + tests/test_w3_qwen3_subgraphs.py -q +``` + +Unit-test safetensors and attention inputs are explicitly synthetic fixtures +for contract/error tests; they are never accepted as pretrained evidence. diff --git a/probes/w3_qwen3_subgraphs/__init__.py b/probes/w3_qwen3_subgraphs/__init__.py new file mode 100644 index 00000000..657cdd4c --- /dev/null +++ b/probes/w3_qwen3_subgraphs/__init__.py @@ -0,0 +1 @@ +"""Offline, pinned pretrained Qwen3 first-layer subgraph probes.""" diff --git a/probes/w3_qwen3_subgraphs/assets.py b/probes/w3_qwen3_subgraphs/assets.py new file mode 100644 index 00000000..7d3964b4 --- /dev/null +++ b/probes/w3_qwen3_subgraphs/assets.py @@ -0,0 +1,77 @@ +"""Authenticate an existing snapshot before reading individual tensors. + +No model constructor or download is used. Checkpoint hashing is streamed and +safetensors only materializes tensors explicitly requested by each subgraph. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST_PATH = ROOT / "probes/w1_qwen3_export/manifest.json" +MANIFEST = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) +DIMENSIONS = {"hidden_size": 1024, "intermediate_size": 3072, + "num_attention_heads": 16, "num_key_value_heads": 8, + "head_dim": 128, "num_hidden_layers": 28, + "rms_norm_eps": 1e-6, "rope_theta": 1000000} + + +def sha256(path): + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def verify_source(directory, manifest=MANIFEST): + directory = Path(directory).resolve() + if not directory.is_dir(): + raise FileNotFoundError(f"Pinned snapshot directory missing: {directory}") + rows = [] + for item in [{"name": "model.safetensors", "sha256": manifest["source_checkpoint_sha256"]}, + *manifest["source_files"]]: + name = item["name"] + if not name or name in (".", "..") or any(c in name for c in "/\\:"): + raise ValueError(f"Non-flat snapshot asset name: {name}") + path = (directory / name).resolve() + if not path.is_relative_to(directory) or not path.is_file(): + raise FileNotFoundError(f"Missing snapshot asset: {name}") + actual = sha256(path) + if actual != item["sha256"]: + raise ValueError(f"Pinned snapshot SHA256 mismatch: {name}: {actual}") + rows.append({"name": name, "sha256": actual, "bytes": path.stat().st_size}) + config = json.loads((directory / "config.json").read_text(encoding="utf-8")) + for key, expected in DIMENSIONS.items(): + if config.get(key) != expected: + raise ValueError(f"Pinned Qwen3 configuration mismatch: {key}") + return {"directory": str(directory), "model_id": manifest["model_id"], + "revision": manifest["revision"], "manifest_sha256": sha256(MANIFEST_PATH), + "verification": "W1 checkpoint and all seven source-file SHA256 values match; offline/read-only", + "files": rows, "config": config} + + +class Weights: + """Read selected first-layer weights, recording original dtype and FP32 bytes.""" + + def __init__(self, source): + self.path = Path(source) / "model.safetensors" + self.evidence = {} + + def get(self, suffix, shape): + from safetensors import safe_open + name = "model.layers.0." + suffix + with safe_open(str(self.path), framework="pt", device="cpu") as checkpoint: + tensor = checkpoint.get_tensor(name) + original_dtype = str(tensor.dtype) + array = tensor.float().numpy().copy() + if array.shape != tuple(shape) or not np.isfinite(array).all(): + raise ValueError(f"Unexpected/nonfinite source weight: {name}: {array.shape}") + self.evidence[name] = {"shape": list(array.shape), "source_dtype": original_dtype, + "execution_dtype": str(array.dtype), "fp32_sha256": + hashlib.sha256(array.tobytes()).hexdigest(), "bytes": array.nbytes} + return array diff --git a/probes/w3_qwen3_subgraphs/cases.py b/probes/w3_qwen3_subgraphs/cases.py new file mode 100644 index 00000000..ec810b88 --- /dev/null +++ b/probes/w3_qwen3_subgraphs/cases.py @@ -0,0 +1,238 @@ +"""Real dimensions/weights, explicit synthetic activations, independent Torch oracles. + +ONNX primitives are assembled directly. Their Torch oracle uses F.linear, +rsqrt, trigonometric pair rotation, and an explicit per-head attention loop; +it neither exports nor executes the same ONNX graph to define correctness. +""" +from __future__ import annotations + +from dataclasses import dataclass +import hashlib + +import numpy as np +import onnx +from onnx import TensorProto as T, helper, numpy_helper +import torch +import torch.nn.functional as F + +from .assets import DIMENSIONS + + +@dataclass +class Case: + name: str + family: str + model: onnx.ModelProto + feed: dict + expected: dict + metadata: dict + + +def node(op, inputs, output, **attrs): + return helper.make_node(op, inputs, [output], name=output, **attrs) + + +def graph(name, family, nodes, feed, expected, constants=None, **metadata): + model = helper.make_model(helper.make_graph( + nodes, name, + [helper.make_tensor_value_info(key, T.FLOAT, value.shape) for key, value in feed.items()], + [helper.make_tensor_value_info(key, T.FLOAT, value.shape) for key, value in expected.items()], + [numpy_helper.from_array(np.asarray(value), key) for key, value in (constants or {}).items()], + ), opset_imports=[helper.make_opsetid("", 18)], ir_version=10) + onnx.checker.check_model(model, full_check=True) + return Case(name, family, model, feed, expected, metadata) + + +def arr(tensor): + return tensor.detach().numpy().copy() + + +def rms_reference(x, weight): + value = torch.from_numpy(x) + return arr(value * torch.rsqrt(value.square().mean(dim=-1, keepdim=True) + 1e-6) + * torch.from_numpy(weight)) + + +def rope_inverse_frequency(): + """Materialize Qwen3's FP32 rotary buffer once, as in Transformers. + + NumPy power and Torch power need not round to the same FP32 constants. + The graph and independent rotation oracle must consume the same model + parameters, just as they consume the same pretrained linear weights. + """ + dimension, theta = DIMENSIONS["head_dim"], DIMENSIONS["rope_theta"] + exponent = torch.arange(0, dimension, 2, dtype=torch.int64).to(torch.float32) / dimension + return arr(1.0 / (theta ** exponent)) + + +def rope_reference(x, position, inv_freq=None): + # Pair formula is deliberately separate from ONNX Slice/Neg/Concat. + value = torch.from_numpy(x) + inv = torch.from_numpy(rope_inverse_frequency() if inv_freq is None else inv_freq) + angle = torch.from_numpy(position).reshape(-1, 1) * inv.reshape(1, -1) + first, second = value[..., :64], value[..., 64:] + return arr(torch.cat((first * angle.cos() - second * angle.sin(), + second * angle.cos() + first * angle.sin()), dim=-1)) + + +def projection_case(kind, x, weight): + expected = arr(F.linear(torch.from_numpy(x), torch.from_numpy(weight))) + return graph(f"projection_{kind}", "projection", [node("MatMul", ["x", "weight"], "y")], + {"x": x}, {"y": expected}, {"weight": weight.T.copy()}, + source_weights=[f"model.layers.0.self_attn.{kind}_proj.weight"], + purpose="FP32 projection using authentic first-layer checkpoint weight") + + +def norm_case(kind, x, weight, suffix): + return graph(f"rmsnorm_{kind}", "rmsnorm", [ + node("Mul", ["x", "x"], "square"), + node("ReduceMean", ["square", "last"], "variance", keepdims=1), + node("Add", ["variance", "epsilon"], "stabilized"), + node("Sqrt", ["stabilized"], "denominator"), + node("Div", ["x", "denominator"], "normalized"), + node("Mul", ["normalized", "weight"], "y"), + ], {"x": x}, {"y": rms_reference(x, weight)}, + {"last": np.array([-1], np.int64), "epsilon": np.array(1e-6, np.float32), "weight": weight}, + source_weights=["model.layers.0." + suffix], + purpose="Learned RMSNorm across the final dimension; official epsilon=1e-6") + + +def rope_case(kind, x): + length = x.shape[-2] + positions = np.arange(length, dtype=np.float32).reshape(1, length, 1) + inv = rope_inverse_frequency() + return graph(f"rope_{kind}", "rope", [ + node("Mul", ["position", "inv_freq"], "angle"), + node("Concat", ["angle", "angle"], "full_angle", axis=-1), + node("Cos", ["full_angle"], "cos_3d"), + node("Sin", ["full_angle"], "sin_3d"), + node("Unsqueeze", ["cos_3d", "head_axis"], "cos"), + node("Unsqueeze", ["sin_3d", "head_axis"], "sin"), + node("Slice", ["x", "zero", "half", "last"], "first"), + node("Slice", ["x", "half", "end", "last"], "second"), + node("Neg", ["second"], "negative_second"), + node("Concat", ["negative_second", "first"], "rotated", axis=-1), + node("Mul", ["x", "cos"], "real"), + node("Mul", ["rotated", "sin"], "imaginary"), + node("Add", ["real", "imaginary"], "y"), + ], {"x": x, "position": positions}, {"y": rope_reference(x, positions, inv)}, + {"inv_freq": inv.reshape(1, 1, 64), "head_axis": np.array([1], np.int64), + "zero": np.array([0], np.int64), "half": np.array([64], np.int64), + "end": np.array([128], np.int64), "last": np.array([-1], np.int64)}, + source_weights=[f"model.layers.0.self_attn.{kind}_proj.weight", + f"model.layers.0.self_attn.{kind}_norm.weight"], + purpose="Full head_dim=128 rotate-half, every position 0..L-1, theta=1e6", + rotary_parameters={"head_dim": DIMENSIONS["head_dim"], "theta": DIMENSIONS["rope_theta"], + "source": "Transformers default RoPE Torch FP32 inverse-frequency buffer", + "sharing": "One materialized buffer supplies ONNX and the independent Torch rotation", + "inv_freq_sha256": hashlib.sha256(inv.tobytes()).hexdigest()}, + activation_origin="Synthetic hidden -> authentic projection and Q/K RMSNorm, computed in Torch") + + +def swiglu_case(x, gate, up, down): + tx = torch.from_numpy(x) + gt = F.linear(tx, torch.from_numpy(gate)) + ut = F.linear(tx, torch.from_numpy(up)) + hidden = F.silu(gt) * ut + expected = {"gate": arr(gt), "up": arr(ut), "hidden": arr(hidden), + "y": arr(F.linear(hidden, torch.from_numpy(down)))} + return graph("swiglu", "swiglu", [ + node("MatMul", ["x", "gate_weight"], "gate"), + node("MatMul", ["x", "up_weight"], "up"), + node("Sigmoid", ["gate"], "activation"), + node("Mul", ["gate", "activation"], "silu"), + node("Mul", ["silu", "up"], "hidden"), + node("MatMul", ["hidden", "down_weight"], "y"), + ], {"x": x}, expected, + {"gate_weight": gate.T.copy(), "up_weight": up.T.copy(), "down_weight": down.T.copy()}, + source_weights=["model.layers.0.mlp." + name + "_proj.weight" for name in ("gate", "up", "down")], + purpose="Authentic 1024 -> 3072 -> 1024 SwiGLU, distinct gate/up learned weights") + + +def attention_reference(feed): + q, k, v, mask = (torch.from_numpy(feed[name]) for name in ("q", "k", "v", "mask")) + probabilities, contexts = [], [] + # Explicit mapping, unlike ONNX Expand/Reshape: heads (0,1) use KV0, etc. + for head in range(16): + scores = torch.matmul(q[:, head], k[:, head // 2].transpose(-1, -2)) * (128 ** -0.5) + weights = torch.softmax(scores + mask[:, 0], dim=-1) + probabilities.append(weights) + contexts.append(torch.matmul(weights, v[:, head // 2])) + return {"probabilities": arr(torch.stack(probabilities, dim=1)), + "y": arr(torch.stack(contexts, dim=1))} + + +def attention_case(name, feed, valid_length, **metadata): + length = feed["q"].shape[2] + nodes = [] + for value in ("k", "v"): + nodes += [node("Unsqueeze", [value, "repeat_axis"], value + "_5d"), + node("Expand", [value + "_5d", "expanded_shape"], value + "_expanded"), + node("Reshape", [value + "_expanded", "repeated_shape"], value + "_repeated")] + nodes += [node("Transpose", ["k_repeated"], "kt", perm=[0, 1, 3, 2]), + node("MatMul", ["q", "kt"], "unscaled"), + node("Mul", ["unscaled", "scale"], "scores"), + node("Add", ["scores", "mask"], "masked"), + node("Softmax", ["masked"], "probabilities", axis=-1), + node("MatMul", ["probabilities", "v_repeated"], "y")] + return graph(name, "gqa", nodes, feed, attention_reference(feed), + {"repeat_axis": np.array([2], np.int64), "scale": np.array(128 ** -0.5, np.float32), + "expanded_shape": np.array([1, 8, 2, length, 128], np.int64), + "repeated_shape": np.array([1, 16, length, 128], np.int64)}, + valid_length=valid_length, source_weights=["model.layers.0.self_attn." + suffix + ".weight" + for suffix in ("q_proj", "k_proj", "v_proj", "q_norm", "k_norm")], + purpose="Real 16Q/8KV, 128-dimensional heads; causal and key-padding masks", + activation_origin="Synthetic hidden -> authentic Q/K/V projections, Q/K norm and RoPE, in Torch", + **metadata) + + +def causal_mask(length, valid_length): + allowed = np.arange(length)[None, :] <= np.arange(length)[:, None] + allowed &= np.arange(length)[None, :] < valid_length + return np.where(allowed, np.float32(0), np.finfo(np.float32).min).reshape(1, 1, length, length) + + +def iter_cases(weights, length=256): + if not 2 <= length <= 256: + raise ValueError("Sequence length must be in [2, 256]; official gate requires 256") + rng = np.random.default_rng(20261004) + x = rng.standard_normal((1, length, 1024), dtype=np.float32) + attention_inputs = {} + for kind, width in (("q", 2048), ("k", 1024), ("v", 1024)): + weight = weights.get(f"self_attn.{kind}_proj.weight", (width, 1024)) + case = projection_case(kind, x, weight) + attention_inputs[kind] = case.expected["y"].reshape(1, length, width // 128, 128).transpose(0, 2, 1, 3).copy() + yield case + del weight, case + context = rng.standard_normal((1, length, 2048), dtype=np.float32) + yield projection_case("o", context, weights.get("self_attn.o_proj.weight", (1024, 2048))) + yield norm_case("hidden", x, weights.get("input_layernorm.weight", (1024,)), "input_layernorm.weight") + for kind in ("q", "k"): + suffix = f"self_attn.{kind}_norm.weight" + weight = weights.get(suffix, (128,)) + case = norm_case(kind, attention_inputs[kind], weight, suffix) + attention_inputs[kind] = case.expected["y"] + yield case + case = rope_case(kind, attention_inputs[kind]) + attention_inputs[kind] = case.expected["y"] + yield case + del weight, case + full = {**attention_inputs, "mask": causal_mask(length, length)} + yield attention_case("gqa_full", full, length) + valid = min(17, max(1, length // 2)) + padded = {**attention_inputs, "mask": causal_mask(length, valid)} + yield attention_case("gqa_padding", padded, valid) + changed_padding = {name: array.copy() for name, array in padded.items()} + for name in ("k", "v"): + changed_padding[name][:, :, valid:] += np.float32(3) + yield attention_case("gqa_changed_padding", changed_padding, valid, changed_from=valid) + changed_future = {name: array.copy() for name, array in full.items()} + future = max(1, length // 4) + for name in ("q", "k", "v"): + changed_future[name][:, :, future:] += np.float32(2) + yield attention_case("gqa_changed_future", changed_future, length, changed_from=future) + del attention_inputs, full, padded, changed_padding, changed_future + gate = weights.get("mlp.gate_proj.weight", (3072, 1024)) + up = weights.get("mlp.up_proj.weight", (3072, 1024)) + down = weights.get("mlp.down_proj.weight", (1024, 3072)) + yield swiglu_case(x, gate, up, down) diff --git a/probes/w3_qwen3_subgraphs/run.py b/probes/w3_qwen3_subgraphs/run.py new file mode 100644 index 00000000..7d18753e --- /dev/null +++ b/probes/w3_qwen3_subgraphs/run.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Strict offline pretrained-subgraph gate; no full model is loaded or executed.""" +from __future__ import annotations + +import argparse +import copy +from dataclasses import asdict, is_dataclass +import gc +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import sys +import time +import traceback + +# Set before importing numerical runtimes. The process intentionally uses one thread. +for variable in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"): + os.environ[variable] = "1" +os.environ["HF_HUB_OFFLINE"] = "1" +os.environ["TRANSFORMERS_OFFLINE"] = "1" + +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 +import onnxruntime as ort +import torch + +from probes.w2_qwen3_small.diagnostics import build_diagnostic_model, unpack_trace +from probes.w3_common import new_output_dir, process_peak_rss_bytes, source_evidence, write_reports +from probes.w3_qwen3_subgraphs.assets import DIMENSIONS, Weights, sha256, verify_source +from probes.w3_qwen3_subgraphs.cases import iter_cases +from scratchv.analysis.ir_verifier import verify_ir +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.pass_manager import create_optimization_pass_manager +from scratchv.verification.ir_interpreter import IRInterpreter +from scratchv.verification.numeric_metrics import numeric_metrics, undefined_numeric_metrics + +ATOL = 1e-4 +LEVELS = ("none", "basic", "all") +CASE_NAMES = ("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") +PINNED = {"numpy": "2.2.6", "torch": "2.7.1", "onnx": "1.18.0", + "onnxruntime": "1.22.1", "safetensors": "0.5.3"} + + +def array_evidence(arrays): + return {name: {"shape": list(value.shape), "dtype": str(value.dtype), + "sha256": hashlib.sha256(np.ascontiguousarray(value).tobytes()).hexdigest(), + "finite": bool(np.isfinite(value).all()), "bytes": value.nbytes} + for name, value in arrays.items()} + + +def tensor_diff(actual, expected, atol=ATOL): + """Strict absolute gate with JSON-safe evidence for shape/nonfinite failures.""" + actual, expected = np.asarray(actual), np.asarray(expected) + row = {"shape": list(actual.shape), "expected_shape": list(expected.shape), + "shape_matches": actual.shape == expected.shape, "dtype": str(actual.dtype), + "expected_dtype": str(expected.dtype), "atol": atol, "rtol": 0, + "finite": bool(np.isfinite(actual).all() and np.isfinite(expected).all()), + "max_abs_error": None, "firstdiff": None, "passed": False} + if not row["shape_matches"]: + row["firstdiff"] = {"reason": "shape mismatch"} + row.update(undefined_numeric_metrics("shape mismatch")) + return row + if actual.dtype != np.float32 or expected.dtype != np.float32: + row["firstdiff"] = {"reason": "FP32 dtype mismatch"} + row.update(undefined_numeric_metrics("FP32 dtype mismatch")) + return row + row.update(numeric_metrics(actual, expected)) + with np.errstate(invalid="ignore", over="ignore"): + error = np.abs(actual.astype(np.float64) - expected.astype(np.float64)) + bad = ~np.isfinite(actual) | ~np.isfinite(expected) | (error >= atol) + if row["finite"]: + row["max_abs_error"] = float(error.max(initial=0)) + if bad.any(): + index = tuple(int(value) for value in np.argwhere(bad)[0]) + def safe_number(value): + value = float(value) + return value if np.isfinite(value) else str(value) + row["firstdiff"] = {"index": list(index), "actual": safe_number(actual[index]), + "expected": safe_number(expected[index]), "abs_error": safe_number(error[index])} + row["passed"] = bool(row["finite"] and not bad.any()) + return row + + +def compare(actual, expected): + if list(actual) != list(expected): + raise ValueError("Checkpoint names/order differ") + rows = [{"name": name, **tensor_diff(actual[name], expected[name])} for name in expected] + failed = next((row for row in rows if not row["passed"]), None) + maxima = [row["max_abs_error"] for row in rows] + return {"passed": all(row["passed"] for row in rows), "checkpoints": rows, + "max_abs_error": None if any(value is None for value in maxima) else max(maxima, default=0), + "first_divergence": None if failed is None else + {"checkpoint": failed["name"], "firstdiff": failed["firstdiff"]}} + + +def require_valid(program): + passed, issues = verify_ir(program) + if not passed: + raise ValueError("; ".join(map(str, issues))) + + +def execute_ir(model_path, feed, level): + parser = ONNXParser() + program = parser.parse(str(model_path)) + require_valid(program) + manager = create_optimization_pass_manager(level) + manager.before_pass = lambda unused, value: require_valid(value) + manager.after_pass = lambda unused, value: require_valid(value) + optimized = manager.run_pipeline(program) + require_valid(optimized.data) + result = IRInterpreter(optimized.data).run(feed, initializers=parser.initializers, + collect_memory_stats=True) + memory = result.memory_stats + if is_dataclass(memory): + memory = asdict(memory) + return result.return_value, {"executed_steps": result.executed_steps, + "optimization_changes": optimized.report.total_changes, "memory_stats": memory, + "process_peak_rss_bytes": process_peak_rss_bytes()} + + +def attention_checks(values, feed): + probabilities = values["probabilities"] + blocked = feed["mask"][0, 0] < 0 + return {"blocked_probability": tensor_diff(probabilities[..., blocked], + np.zeros_like(probabilities[..., blocked])), + "probability_row_sum": tensor_diff(probabilities.sum(axis=-1), + np.ones(probabilities.shape[:-1], np.float32))} + + +def run_case(case, out, row): + directory = out / case.name + directory.mkdir() + row.update(name=case.name, family=case.family, metadata=case.metadata, + inputs=array_evidence(case.feed), reference=array_evidence(case.expected), + passed=False, comparisons={}, executions={}, semantic_checks={}) + np.savez(directory / "inputs.npz", **case.feed) + np.savez(directory / "torch.npz", **case.expected) + model = copy.deepcopy(case.model) + output = next(value for value in model.graph.output if value.name == "y") + del model.graph.output[:] + model.graph.output.append(output) + onnx.checker.check_model(model, full_check=True) + onnx.save(model, directory / "model.onnx") + diagnostic, schema = build_diagnostic_model(case.model, case.expected) + onnx.save(diagnostic, directory / "diagnostics.onnx") + (directory / "checkpoints.json").write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") + row["artifacts"] = {"directory": case.name, "model_sha256": sha256(directory / "model.onnx"), + "diagnostic_sha256": sha256(directory / "diagnostics.onnx")} + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + session = ort.InferenceSession(str(directory / "diagnostics.onnx"), options, + providers=["CPUExecutionProvider"]) + values = session.run(None, case.feed) + ort_values = {name: array for name, array in zip(case.expected, values[1:])} + packed = unpack_trace(values[0], schema) + row["comparisons"]["ort_pack_layout"] = compare(packed, ort_values) + row["comparisons"]["torch_vs_ort"] = compare(ort_values, case.expected) + np.savez(directory / "ort.npz", **ort_values) + del session, values, packed + session = ort.InferenceSession(str(directory / "model.onnx"), options, + providers=["CPUExecutionProvider"]) + ordinary_ort = session.run(None, case.feed)[0] + del session + row["comparisons"]["ordinary_ort_vs_torch"] = tensor_diff(ordinary_ort, case.expected["y"]) + row["comparisons"]["ordinary_ort_vs_diagnostic"] = tensor_diff(ordinary_ort, ort_values["y"]) + np.save(directory / "ordinary_ort.npy", ordinary_ort) + if case.family == "gqa": + row["semantic_checks"]["torch"] = attention_checks(case.expected, case.feed) + row["semantic_checks"]["ort"] = attention_checks(ort_values, case.feed) + for level in LEVELS: + try: + packed, execution = execute_ir(directory / "diagnostics.onnx", case.feed, level) + actual = unpack_trace(packed, schema) + np.savez(directory / f"ir_{level}.npz", **actual) + row["executions"][level] = {"diagnostic": execution} + row["comparisons"][f"ir_{level}_vs_ort"] = compare(actual, ort_values) + row["comparisons"][f"ir_{level}_vs_torch"] = compare(actual, case.expected) + ordinary, execution = execute_ir(directory / "model.onnx", case.feed, level) + row["executions"][level]["ordinary"] = execution + np.save(directory / f"ordinary_ir_{level}.npy", ordinary) + row["comparisons"][f"ordinary_ir_{level}_vs_ort"] = tensor_diff(ordinary, ordinary_ort) + row["comparisons"][f"ordinary_ir_{level}_vs_torch"] = tensor_diff(ordinary, case.expected["y"]) + row["comparisons"][f"ordinary_ir_{level}_vs_diagnostic"] = tensor_diff(ordinary, actual["y"]) + if case.family == "gqa": + row["semantic_checks"][f"ir_{level}"] = attention_checks(actual, case.feed) + del packed, actual, ordinary + except Exception as exc: + row["executions"].setdefault(level, {})["error"] = f"{type(exc).__name__}: {exc}" + row["comparisons"][f"ir_{level}_execution"] = {"passed": False, "error": traceback.format_exc()} + row["passed"] = (all(value["passed"] for value in row["comparisons"].values()) + and all(value["passed"] for checks in row["semantic_checks"].values() for value in checks.values())) + row["first_divergence"] = next(({"comparison": name, + "detail": value.get("first_divergence", value.get("firstdiff", value.get("error")))} + for name, value in row["comparisons"].items() if not value["passed"]), None) + + +def invariants(out, length): + rows = [] + for name, left, right, prefix in (("padding_isolation", "gqa_padding", "gqa_changed_padding", length), + ("causality", "gqa_full", "gqa_changed_future", max(1, length // 4))): + for backend in ("torch", "ort", "ir_none", "ir_basic", "ir_all"): + try: + with np.load(out / left / f"{backend}.npz") as a, np.load(out / right / f"{backend}.npz") as b: + result = compare({key: a[key][:, :, :prefix] for key in ("probabilities", "y")}, + {key: b[key][:, :, :prefix] for key in ("probabilities", "y")}) + rows.append({"name": name, "backend": backend, "query_prefix": prefix, **result}) + except Exception as exc: + rows.append({"name": name, "backend": backend, "passed": False, + "error": f"{type(exc).__name__}: {exc}"}) + return rows + + +def finalize_report(report, length): + report["coverage_complete"] = tuple(row["name"] for row in report["cases"]) == CASE_NAMES + report["numerical_passed"] = bool(report["coverage_complete"] and all(row["passed"] for row in report["cases"]) + and len(report["invariants"]) == 10 + and all(row["passed"] for row in report["invariants"])) + report["passed"] = report["numerical_passed"] and length == 256 + report["status"] = "PASS" if report["passed"] else ("PARTIAL" if report["numerical_passed"] else "FAIL") + report["stage"] = "complete" + report.pop("current_case", None) + + +def run_probe(source, out, report, length): + report.update(source_evidence()) + report["stage"] = "environment" + installed = {name: importlib.metadata.version(name).split("+")[0] for name in PINNED} + if sys.version_info[:2] != (3, 12) or installed != PINNED: + raise RuntimeError(f"Use pinned Python 3.12 qwen environment: {installed}") + torch.set_num_threads(1) + torch.set_num_interop_threads(1) + torch.use_deterministic_algorithms(True) + report["environment"]["threads"] = {"torch": 1, "torch_interop": 1, "ort": 1, + **{key: os.environ[key] for key in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS")}} + report["stage"] = "verify_source" + report["source"] = verify_source(source) + weights = Weights(source) + report["weight_tensors"] = weights.evidence + report["cases"] = [] + report["stage"] = "subgraphs" + for case in iter_cases(weights, length): + row = {"name": case.name, "passed": False} + report["cases"].append(row) + report["current_case"] = case.name + started = time.perf_counter() + try: + run_case(case, out, row) + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + row["traceback"] = traceback.format_exc() + row["seconds"] = time.perf_counter() - started + row["process_peak_rss_bytes"] = process_peak_rss_bytes() + print(f"[{case.name}] {'PASS' if row['passed'] else 'FAIL'} " + f"first divergence={row.get('first_divergence', row.get('error'))}", flush=True) + del case + gc.collect() + report["invariants"] = invariants(out, length) + finalize_report(report, length) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-dir", type=Path, required=True, help="Existing pinned HF snapshot; never downloaded") + parser.add_argument("--output-dir", type=Path, required=True, help="New directory, refuses to overwrite") + parser.add_argument("--seq-len", type=int, default=256, choices=range(2, 257), metavar="2..256", + help="Only 256 is an official gate; shorter debug runs can only be PARTIAL") + args = parser.parse_args(argv) + out = new_output_dir(args.output_dir) + report = {"gate": "w3_qwen3_subgraphs", "status": "FAIL", "passed": False, + "stage": "initializing", "scope": "official L256" if args.seq_len == 256 else "debug subset", + "atol": ATOL, "rtol": 0, "comparison": "strict max_abs_error < 1e-4; shapes/dtypes/finite required", + "sequence_length": args.seq_len, "dimensions": DIMENSIONS, "levels": list(LEVELS), + "weight_origin": "Authenticated pretrained checkpoint, layer 0 only, BF16 values promoted to FP32", + "input_origin": "NumPy PCG64 seed 20261004, synthetic N(0,1) hidden/context activations; Q/K/V cases derive from authentic projections", + "limitations": ["Selected pretrained subgraphs, not a complete pretrained decoder-layer forward", + "No full-model generation or language-quality claim", "No RISC-V claim for these full dimensions", + "W3 1e-4 baseline does not modify W2's strict 1e-5 threshold"], + "memory_scope": "process_peak_rss_bytes is the process lifetime peak, including Torch/ORT/IR; IR memory_stats is separately reported"} + started = time.perf_counter() + try: + run_probe(args.source_dir, out, report, args.seq_len) + except Exception as exc: + report.update(passed=False, status="FAIL", error=f"{type(exc).__name__}: {exc}", traceback=traceback.format_exc()) + report["seconds"] = time.perf_counter() - started + report["process_peak_rss_bytes"] = process_peak_rss_bytes() + write_reports(out, report) + print(f"{report['status']}: {out / 'report.json'}", flush=True) + return 0 if report["passed"] else (2 if report["status"] == "PARTIAL" else 1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_summary.py b/probes/w3_summary.py new file mode 100644 index 00000000..63679c1b --- /dev/null +++ b/probes/w3_summary.py @@ -0,0 +1,226 @@ +"""Small human summaries; complete machine evidence remains in report.json.""" +from __future__ import annotations + +from html import escape +import math + +from probes.numeric_summary import compact_metric_table, compact_metric_table_html, METRIC_EXPLANATION + +# The medium gate uses the seven fixed input_cases from w2_qwen3_small.run. +_MEDIUM_CASE_COUNT = 7 +_INDEX_LIMIT = 20 + + +def _short(value, limit=240): + """Bound human-facing text without truncating the machine evidence.""" + text = " ".join(str(value).split()) + return text if len(text) <= limit else text[:limit] + "…(完整内容见 report.json)" + + +def _first_issue(row, depth=0): + """Find one failed check, without printing its entire nested result.""" + if depth > 6: + return "" + if isinstance(row, list): + return next((text for item in row if (text := _first_issue(item, depth + 1))), "") + if not isinstance(row, dict) or row.get("passed") is True: + return "" + for key in ("error", "reason"): + if row.get(key): + return _short(row[key]) + first = row.get("first_divergence") + if first: + if isinstance(first, dict): + first = " / ".join(str(first[key]) for key in ("comparison", "name", "checkpoint", "reason") + if first.get(key)) or "结构化定位数据见 report.json" + return "首个差异:" + _short(first) + # Visit execution/check result structures, never provenance or array data. + for key in ("comparison", "comparisons", "ort", "ir", "normal", "diagnostic", + "none", "basic", "all", "executions", "workers", "pytorch_comparison", + "ort_comparison", "capture_preserves_logits", "pack_layout", "checkpoints"): + child = row.get(key) + if key == "comparisons" and isinstance(child, dict): + child = list(child.values()) + issue = _first_issue(child, depth + 1) + if issue: + return issue + return "" + + +def _index_status(row): + status = row.get("status") + if status in ("PARTIAL", "COMPLETE"): + return status + if row.get("passed") is True: + return "PASS" if status in (None, "PASS", "success") else "状态冲突(见 JSON)" + if row.get("passed") is False: + return _short(status) if status and status != "PASS" else "FAIL" + return _short(status) if status else "未确认" + + +def evidence_index_views(report): + """A bounded index of recorded results, with the JSON as source of detail.""" + entries = [] + for key, label in (("cases", "用例"), ("gates", "子门禁"), ("invariants", "隔离检查")): + for index, row in enumerate(report.get(key, []), 1): + name = row.get("name", row.get("case", f"{label} {index}")) + entries.append((f"{label}:{_short(name, 100)}", row)) + if report.get("gate") == "w3_layer_diff": + entries.extend(("检查点:" + _short(row.get("name", "未命名"), 100), row) + for row in report.get("checkpoints", [])) + if not entries: + entries.append(("流程阶段:" + _short(report.get("stage", "未记录"), 100), report)) + # A late failure must not disappear behind the first twenty successful rows. + entries.sort(key=lambda item: item[1].get("passed") is True) + selected = entries[:_INDEX_LIMIT] + rows = [(name, _index_status(row), _first_issue(row) or "—") for name, row in selected] + coverage = f"结果索引:共 {len(entries)} 项,展示 {len(selected)} 项(失败/未确认优先)。" + if len(entries) > len(selected): + coverage += f" 另 {len(entries) - len(selected)} 项见 report.json。" + explanation = ("完整逐用例、检查点、命令、环境和源码指纹保留在同目录 report.json。" + "在 GitHub Actions Summary 中,请到本次运行的 Artifacts 下载对应报告;" + "下面的相对链接用于下载后同目录查看。") + markdown = "
简短结果索引与完整证据入口(展开详情)\n\n" + markdown += coverage + "\n\n| 项目 | 结果 | 首个问题 |\n| --- | --- | --- |\n" + for row in rows: + markdown += "| " + " | ".join(escape(value).replace("|", "|") for value in row) + " |\n" + markdown += "\n" + explanation + "\n\n[完整机器报告](report.json)\n\n
\n\n" + header = "项目结果首个问题" + body = "".join("" + "".join("" + escape(value) + "" for value in row) + "" for row in rows) + page = ('
简短结果索引与完整证据入口(展开详情)

' + + escape(coverage) + '

' + header + '' + body + + '

' + escape(explanation) + + '

完整机器报告

') + return markdown, page + + +def _checkpoint(comparison, name="logits"): + return _named(comparison.get("checkpoints", []), name) + + +def _named(rows, name): + matches = [row for row in rows if row.get("name") == name] + if len(matches) > 1: + return {"passed": False, "reason": f"重复的输出比较:{name}"} + return matches[0] if matches else {} + + +def _subgraph_output(row): + """Adapt the existing subgraph schema without rewriting saved evidence.""" + result = dict(row) + if "max_abs_error" in row: + result["max_abs"] = row["max_abs_error"] + first = row.get("firstdiff") + if isinstance(first, dict) and first.get("reason"): + result.setdefault("reason", first["reason"]) + return result + + +def numerical_summary(report): + """Select explicit output comparisons, never a recursive max over checkpoints.""" + gate, cases = report.get("gate"), report.get("cases", []) + comparisons, notes = [], [] + description = _short(report.get("scope", "详细范围见完整报告。"), 600) + if gate in ("numeric:ir-full-qwen3", "audit:w3-full-saved-evidence"): + mode = report.get("fp32_mode", "未记录") + description = (f"完整预训练 28 层 Qwen3,FP32、L=256、无 KV cache;同一模型、权重和输入," + f"IR({mode} 模式)对照 ORT,比较全部位置,包括补齐位置。") + for filename, label in (("logits.npy", "普通最终 logits"), ("diagnostic_logits.npy", "诊断图最终 logits")): + comparisons.append((f"IR {mode} 对 ORT:{label}", [ + _named(case.get("comparison", {}).get("logits", []), filename) for case in cases])) + notes.append("native 是默认 NumPy 算术;reference 是对齐 ORT 浮点行为的兼容模式,两者的通过结果不能互相代替。") + notes.append("中间检查点用于定位,不混入最终 logits 指标。完整 RISC-V/QEMU 模型执行不在此项范围内。") + if gate.startswith("audit:"): + notes.append("本项只重算保存数组,没有重新执行模型,也不算第二人独立运行。") + notes.append("PPL / zero-shot:未评测;需另行固定真实语料、Tokenizer、上下文窗口和评分方式。") + elif gate == "w3-medium-host": + description = "六层随机权重 Qwen3,FP32、L=256;同权重同输入,对比 PyTorch、ORT 和原生 NumPy IR 的最终 logits。" + recorded_cases = cases + cases = cases + [{}] * max(0, _MEDIUM_CASE_COUNT - len(cases)) + comparisons.append(("ORT 对 PyTorch", [_checkpoint(c.get("ort", {}).get("normal", {}).get("pytorch_comparison", {})) for c in cases])) + for level in ("none", "basic", "all"): + comparisons.append((f"IR 原生 NumPy({level})对 ORT", [ + _checkpoint(c.get("ir", {}).get(level, {}).get("normal", {}).get("ort_comparison", {})) for c in cases])) + notes.extend(("none/basic/all 分别表示关闭、基础、全部 ScratchV IR 优化,不是 C 编译器的优化等级。", + "中间检查点和因果/补齐隔离检查仍参与原验收,逐项明细保留在 report.json。", + "PPL / zero-shot:未评测;随机权重小模型不用于评价语言能力。")) + notes.append(f"固定场景覆盖:已记录 {len(recorded_cases)}/{_MEDIUM_CASE_COUNT} 个;" + "尚未执行的场景或比较按未记录展示,不作为通过。") + elif gate == "w3_qwen3_subgraphs": + length = report.get("sequence_length", "未记录") + description = (f"预训练第一层的真实维度子图,FP32、L={length};分别检查 ORT、PyTorch" + "与原生 NumPy IR 的子图输出,不是完整模型执行。") + if length != 256: + notes.append("本次未记录完整 L=256 验收范围;短序列调试结果不能替代正式验收。") + comparisons.append(("ORT 对 PyTorch:子图输出", [ + _subgraph_output(c.get("comparisons", {}).get("ordinary_ort_vs_torch", {})) for c in cases])) + for level in ("none", "basic", "all"): + comparisons.append((f"IR 原生 NumPy({level})对 ORT:子图输出", [ + _subgraph_output(c.get("comparisons", {}).get(f"ordinary_ir_{level}_vs_ort", {})) for c in cases])) + elif gate == "unit:attention-backend": + description = "小 Attention 组合图:IR→C→RISC-V→QEMU,同一输入对照 ORT 和独立 NumPy 公式;不是完整 Qwen3。" + for reference, label in (("ir_vs_ort", "Attention IR 原生 NumPy 对 ORT"), + ("qemu_vs_ort", "Attention C→RISC-V/QEMU 对 ORT"), + ("qemu_vs_numpy", "Attention C→RISC-V/QEMU 对独立 NumPy")): + comparisons.append((label, [ + next((e.get(reference, {}) for e in c.get("executions", []) if e.get("optimization") == level), {}) + for c in cases for level in ("none", "all")])) + notes.append("每项汇总关闭/开启 ScratchV IR 优化的结果;C 交叉编译均使用 -O2。") + elif gate == "preparation:w3": + notes.append("本项汇总预备检查;子门禁结果见折叠索引,不代表完整模型或 W3 团队验收。") + elif gate == "w3_layer_diff": + coverage = report.get("coverage", {}) + notes.append(f"检查点覆盖:{coverage.get('compared_checkpoints', '未记录')}/" + f"{coverage.get('total_checkpoints', '未记录')}。只核对保存的检查点,不代表重新执行完整模型。") + if report.get("partial") is True or coverage.get("complete") is False: + notes.append("本次仅比较选定检查点(PARTIAL),不能作为完整覆盖通过。") + if report.get("first_divergence"): + notes.append("首个差异检查点:" + _short(report["first_divergence"])) + elif gate == "numeric:w3-full-worker": + notes.append("本项只执行单个后端并检查普通/诊断图一致性;IR 对 ORT 精度由上层门禁核验。") + elif gate == "preflight:w3-full-assets": + notes.append("本项只核验模型资产和静态规模,没有执行完整模型或数值验收。") + elif gate == "diagnostic:w3-full-localize": + notes.append("COMPLETE 只表示诊断完成,不表示数值门槛通过;numeric_gate_passed 和团队验收仍独立。") + return description, comparisons, notes + + +def summary_views(report): + description, comparisons, notes = numerical_summary(report) + if comparisons: + threshold = report.get("atol") + if isinstance(threshold, (int, float)) and math.isfinite(threshold): + notes.insert(0, f"原验收门槛:最大绝对误差 < {threshold:g},rtol=0;新增两项指标用于观察,不放宽门槛。") + notes.insert(0, METRIC_EXPLANATION) + notes.append("表内结果只表示对应输出比较;总体结论仍包含执行、覆盖率和原有诊断检查。") + if report.get("coverage_complete") is False: + notes.append("验收覆盖不完整,已测输出通过不能替代全部用例验收。") + required, selected = report.get("required_cases"), report.get("selected_cases") + if isinstance(required, list) and isinstance(selected, list): + notes.append(f"本次选择 {len(selected)}/{len(required)} 个规定用例;已记录 {len(report.get('cases', []))} 个用例。") + seconds = report.get("elapsed_seconds", report.get("seconds")) + if isinstance(seconds, (int, float)) and math.isfinite(seconds): + notes.append(f"本次流程耗时 {seconds:.3f} 秒,包含准备和核验,不代表纯推理速度。") + if report.get("error"): + notes.insert(0, "失败原因:" + _short(report["error"], 600)) + if report.get("passed") is not True and report.get("stage"): + notes.insert(0, "当前/失败阶段:" + _short(report["stage"])) + failed_cases = [_short(c.get("name", "未命名"), 100) for c in report.get("cases", []) if c.get("passed") is False] + if failed_cases: + names = "、".join(failed_cases[:_INDEX_LIMIT]) + remainder = f",另 {len(failed_cases) - _INDEX_LIMIT} 项" if len(failed_cases) > _INDEX_LIMIT else "" + notes.insert(0, "未通过的用例:" + names + remainder + "。首个问题见折叠索引,完整定位数据见 report.json。") + failed_gates = [row for row in report.get("gates", []) if row.get("passed") is not True] + if failed_gates: + notes.insert(0, f"子门禁未通过/未确认 {len(failed_gates)} 项,名称与首个错误见折叠索引。") + failed_invariants = [row for row in report.get("invariants", []) if row.get("passed") is False] + if failed_invariants: + notes.insert(0, f"因果/补齐隔离检查未通过 {len(failed_invariants)} 项,见折叠索引。") + md = escape(description) + "\n\n" + page = "

" + 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/pyproject.toml b/pyproject.toml index a588db51..b4320d5c 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", @@ -34,6 +35,7 @@ classifiers = [ riscv = ["tinyfive"] llvm = ["llvmlite"] # optional: LLVM IR JIT execution verify = ["onnxruntime"] # optional: ONNX Runtime comparison +llm = ["tokenizers>=0.21,<0.22"] # optional: local Qwen3 text tokenizer; no Torch all = ["tinyfive", "llvmlite", "onnxruntime"] topic06 = ["tinyfive", "pytest"] topic06-report = ["tinyfive", "pytest", "jinja2", "matplotlib"] @@ -45,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/kernels/ARCHITECTURE.md b/scratchv/backend/kernels/KERNEL_ARCHITECTURE.md similarity index 99% rename from scratchv/backend/kernels/ARCHITECTURE.md rename to scratchv/backend/kernels/KERNEL_ARCHITECTURE.md index 98eb6393..18e8e445 100644 --- a/scratchv/backend/kernels/ARCHITECTURE.md +++ b/scratchv/backend/kernels/KERNEL_ARCHITECTURE.md @@ -293,7 +293,7 @@ cost**,否则「结果对但慢一倍」会被判通过。 ``` scratchv/backend/kernels/ - ARCHITECTURE.md USAGE.md DEVELOPMENT.md OPTIMIZATION.md + KERNEL_ARCHITECTURE.md USAGE.md DEVELOPMENT.md OPTIMIZATION.md __init__.py 包声明(必须有,否则 python -m 起不来) target.py TargetDesc + TARGETS(rv32im / rv32imf) dtypes.py DtypePolicy + POLICIES + mac_instrs + zero_acc diff --git a/scratchv/backend/kernels/USAGE.md b/scratchv/backend/kernels/USAGE.md index 9f7b5c43..5655bf49 100644 --- a/scratchv/backend/kernels/USAGE.md +++ b/scratchv/backend/kernels/USAGE.md @@ -5,7 +5,7 @@ > > | 你想知道 | 看哪 | > |---|---| -> | 为什么这么分层、哪些东西做了哪些没做 | `ARCHITECTURE.md` | +> | 为什么这么分层、哪些东西做了哪些没做 | `KERNEL_ARCHITECTURE.md` | > | **怎么做一遍**(含全部代码) | **`DEVELOPMENT.md`** | > | 平台有哪些硬约束、字段含义、报错怎么查 | **本文档** | > | 性能从哪来、现在差在哪 | `OPTIMIZATION.md` | @@ -298,5 +298,5 @@ PROBLEMS = { |---|---| | `DEVELOPMENT.md` | **教程**(2000+ 行):从零写出六题,含全部可运行代码、实测数据、检查点 | | 本文档 | **参考手册**:平台契约、字段表、命令行、排查 | -| `ARCHITECTURE.md` | **设计**:四轴分层、pass 清单与状态、目录结构、分期 | +| `KERNEL_ARCHITECTURE.md` | **设计**:四轴分层、pass 清单与状态、目录结构、分期 | | `OPTIMIZATION.md` | **性能**:代价分解、实测数据、优化入口清单 | diff --git a/scratchv/backend/kernels/__init__.py b/scratchv/backend/kernels/__init__.py index 24eac7b8..4fd5982d 100644 --- a/scratchv/backend/kernels/__init__.py +++ b/scratchv/backend/kernels/__init__.py @@ -6,5 +6,5 @@ python -m scratchv.backend.kernels --list python -m scratchv.backend.kernels --problem add-fp32 -o add_fp32.s -文档见同目录 ARCHITECTURE.md / DEVELOPMENT.md / USAGE.md / OPTIMIZATION.md。 +文档见同目录 KERNEL_ARCHITECTURE.md / DEVELOPMENT.md / USAGE.md / OPTIMIZATION.md。 """ diff --git a/scratchv/backend/tensor_c_codegen.py b/scratchv/backend/tensor_c_codegen.py index f3339468..5b45db47 100644 --- a/scratchv/backend/tensor_c_codegen.py +++ b/scratchv/backend/tensor_c_codegen.py @@ -304,6 +304,15 @@ def _constant(self, value, data): spec = self.specs[value.name] if not isinstance(data, np.ndarray) or data.shape != spec.shape or data.dtype != spec.numpy_dtype: raise TensorCCodegenError(f"Initializer {value.name} dtype/shape disagrees with IR") + if value.is_constant: + # A scalar literal is part of the IR's meaning (and may have been + # used by optimizations). A caller binding cannot override it. + try: + expected = np.asarray(value.const_value, dtype=spec.numpy_dtype) + except (TypeError, ValueError, OverflowError) as exc: + 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") self.constant_bytes += spec.nbytes if self.constant_bytes > self.max_constant_bytes: raise TensorCCodegenError("Constant tensor storage exceeds configured limit") diff --git a/scratchv/frontend/onnx_parser.py b/scratchv/frontend/onnx_parser.py index 6bcfacc5..364dc0f5 100644 --- a/scratchv/frontend/onnx_parser.py +++ b/scratchv/frontend/onnx_parser.py @@ -3,7 +3,7 @@ from __future__ import annotations import math -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -34,9 +34,20 @@ def __init__(self): self._producers = {} self._constant_cache = {} self._base_dir = "" + self._mmap_external_data = False - def parse(self, model_path: str) -> Program: - """Parse an ONNX model file and return an IR Program.""" + 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. + """ + if not isinstance(mmap_external_data, bool): + raise ONNXParseError("mmap_external_data must be boolean") try: import onnx except ImportError: @@ -52,6 +63,7 @@ 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) @@ -93,7 +105,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 @@ -127,6 +142,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 +319,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"): diff --git a/scratchv/runtime/llm_inputs.py b/scratchv/runtime/llm_inputs.py new file mode 100644 index 00000000..c1fadb66 --- /dev/null +++ b/scratchv/runtime/llm_inputs.py @@ -0,0 +1,179 @@ +"""Host input preparation and greedy sampling for fixed-length Qwen3 graphs. + +These helpers do not tokenize text, invoke a model, or run a generation loop. +The model ABI remains batch 1, sequence length 256, INT64 IDs and FP32 tensors. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal + +import numpy as np + + +SEQ_LENGTH = 256 +QWEN3_VOCAB_SIZE = 151936 +StopReason = Literal["eos", "capacity", "max_new_tokens"] + + +def _integer(value: object, name: str, *, minimum: int = 0, + maximum: int | None = None) -> int: + if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)): + raise TypeError(f"{name} must be an integer, not {type(value).__name__}") + result = int(value) + if result < minimum or (maximum is not None and result > maximum): + bound = f"[{minimum}, {maximum}]" if maximum is not None else f">= {minimum}" + raise ValueError(f"{name} must be in {bound}; got {result}") + return result + + +def _vocabulary_size(vocab_size: object) -> int: + # A vocabulary size is a count; its largest valid ID must fit INT64. + return _integer(vocab_size, "vocab_size", minimum=1, + maximum=int(np.iinfo(np.int64).max) + 1) + + +def _valid_length(valid_length: object) -> int: + return _integer(valid_length, "valid_length", minimum=1, maximum=SEQ_LENGTH) + + +def _token_sequence(token_ids: Sequence[int] | np.ndarray, *, + vocab_size: int, name: str) -> list[int]: + if isinstance(token_ids, np.ndarray): + if np.ma.isMaskedArray(token_ids): + raise TypeError(f"{name} must not be a masked array") + if token_ids.ndim != 1: + raise ValueError(f"{name} must be a one-dimensional sequence") + if token_ids.dtype.kind not in "iu": + raise TypeError(f"{name} array must have an integer dtype") + elif isinstance(token_ids, (str, bytes)) or not isinstance(token_ids, Sequence): + raise TypeError(f"{name} must be a sequence of integer token IDs") + return [_integer(value, f"{name}[{index}]", maximum=vocab_size - 1) + for index, value in enumerate(token_ids)] + + +@dataclass(frozen=True) +class PreparedInputs: + """Owned model input arrays plus the unpadded token count. + + Arrays are mutable for the model caller. ``as_feed`` returns references to + these arrays, so changing the feed also changes this instance's tensors. + Construct instances through :func:`prepare_inputs` for validated inputs. + """ + + input_ids: np.ndarray + attention_mask: np.ndarray + valid_length: int + + def as_feed(self) -> dict[str, np.ndarray]: + return {"input_ids": self.input_ids, "attention_mask": self.attention_mask} + + +def build_attention_mask(valid_length: int) -> np.ndarray: + """Return causal + key-padding additive mask ``[1, 1, 256, 256]``. + + Query row ``q`` may attend to key ``k`` exactly when ``k <= q`` and + ``k < valid_length``. Padded query rows still see earlier valid keys; fully + masking those rows is not the fixed Qwen3 export's mask contract. + """ + length = _valid_length(valid_length) + positions = np.arange(SEQ_LENGTH) + allowed = (positions[None, :] <= positions[:, None]) & (positions[None, :] < length) + mask = np.where(allowed, np.float32(0), np.finfo(np.float32).min) + return mask.reshape(1, 1, SEQ_LENGTH, SEQ_LENGTH) + + +def prepare_inputs(token_ids: Sequence[int] | np.ndarray, *, pad_token_id: int, + vocab_size: int = QWEN3_VOCAB_SIZE) -> PreparedInputs: + """Validate unpadded IDs and return right-padded, contiguous ABI inputs. + + The length comes from the supplied sequence, never from occurrences of the + pad ID: a valid prompt may itself contain that ID. Empty or over-capacity + prompts fail instead of being silently truncated. Integer arrays of other + widths are accepted only after every ID passes the INT64 range checks. + """ + vocab = _vocabulary_size(vocab_size) + pad = _integer(pad_token_id, "pad_token_id", maximum=vocab - 1) + tokens = _token_sequence(token_ids, vocab_size=vocab, name="token_ids") + length = _valid_length(len(tokens)) + ids = np.full((1, SEQ_LENGTH), pad, dtype=np.int64) + ids[0, :length] = tokens + return PreparedInputs(ids, build_attention_mask(length), length) + + +def _fp32_finite(array: object, name: str) -> np.ndarray: + if not isinstance(array, np.ndarray): + raise TypeError(f"{name} must be a NumPy ndarray") + if np.ma.isMaskedArray(array): + raise TypeError(f"{name} must not be a masked array") + if array.dtype != np.dtype(np.float32): + raise TypeError(f"{name} must have dtype float32; got {array.dtype}") + if not np.isfinite(array).all(): + raise ValueError(f"{name} must contain only finite values") + return array + + +def last_valid_logits(logits: np.ndarray, valid_length: int, *, + vocab_size: int = QWEN3_VOCAB_SIZE) -> np.ndarray: + """Validate the full model output and copy the last valid token's logits. + + All positions, including padding, must be finite. Returning an owned vector + lets a runner release its full output buffer after selecting this row. + """ + length = _valid_length(valid_length) + vocab = _vocabulary_size(vocab_size) + if not isinstance(logits, np.ndarray): + raise TypeError("logits must be a NumPy ndarray") + expected = (1, SEQ_LENGTH, vocab) + if logits.shape != expected: + raise ValueError(f"logits must have shape {expected}; got {logits.shape}") + checked = _fp32_finite(logits, "logits") + return checked[0, length - 1, :].copy() + + +def greedy_token(logits: np.ndarray) -> int: + """Argmax of one finite FP32 vocabulary vector; ties choose the lowest ID.""" + if not isinstance(logits, np.ndarray): + raise TypeError("logits must be a NumPy ndarray") + if logits.ndim != 1 or logits.size == 0: + raise ValueError("logits must be a nonempty one-dimensional vocabulary vector") + checked = _fp32_finite(logits, "logits") + return int(np.argmax(checked)) + + +def greedy_next_token(logits: np.ndarray, valid_length: int, *, + vocab_size: int = QWEN3_VOCAB_SIZE) -> int: + """Select the next ID from ``logits[0, valid_length - 1]``, without softmax.""" + return greedy_token(last_valid_logits(logits, valid_length, vocab_size=vocab_size)) + + +def generation_stop_reason(*, valid_length: int, generated_tokens: int, + max_new_tokens: int, last_token_id: int | None = None, + eos_token_ids: Sequence[int] | np.ndarray = (), + vocab_size: int = QWEN3_VOCAB_SIZE) -> StopReason | None: + """Report whether a host loop must stop before its next forward call. + + Call initially with ``generated_tokens=0`` and after appending each newly + sampled ID. ``valid_length`` includes the nonempty prompt and generated IDs. + EOS applies only to a generated token, not an EOS-like token in the prompt. + Simultaneous stop conditions resolve as EOS, capacity, then token budget. + No truncation, model execution or token append is performed here. + """ + length = _valid_length(valid_length) + generated = _integer(generated_tokens, "generated_tokens", maximum=length - 1) + budget = _integer(max_new_tokens, "max_new_tokens") + vocab = _vocabulary_size(vocab_size) + eos = _token_sequence(eos_token_ids, vocab_size=vocab, name="eos_token_ids") + if generated and last_token_id is None: + raise ValueError("last_token_id is required after generating a token") + last = None if last_token_id is None else _integer(last_token_id, "last_token_id", + maximum=vocab - 1) + if generated and last in eos: + return "eos" + if length == SEQ_LENGTH: + return "capacity" + if generated >= budget: + return "max_new_tokens" + return None diff --git a/scratchv/runtime/qwen3_tokenizer.py b/scratchv/runtime/qwen3_tokenizer.py new file mode 100644 index 00000000..70ae2d74 --- /dev/null +++ b/scratchv/runtime/qwen3_tokenizer.py @@ -0,0 +1,223 @@ +"""Local Qwen3 text tokenization, without a Torch or Transformers dependency. + +The caller supplies a verified local snapshot containing tokenizer.json, +tokenizer_config.json, config.json and generation_config.json. This adapter never +downloads files or renders chat templates. Encoding follows tokenizer.json, +including its NFC normalization; a text round trip need not preserve decomposed +Unicode byte-for-byte. Model output width and the set of decodable token IDs are +different contracts (151936 versus 151669 entries in Qwen3-0.6B). +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping, Set +from numbers import Integral +from pathlib import Path + +from tokenizers import Tokenizer + + +def _read_config(path: Path) -> dict: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path.name} must contain a JSON object") + return value + + +def _boolean(value: object, name: str) -> bool: + if not isinstance(value, bool): + raise TypeError(f"{name} must be bool") + return value + + +def _cleanup(text: str) -> str: + # Match PreTrainedTokenizerBase.clean_up_tokenization in Transformers 4.51.3. + for before, after in ( + (" .", "."), (" ?", "?"), (" !", "!"), (" ,", ","), (" ' ", "'"), + (" n't", "n't"), (" 'm", "'m"), (" 's", "'s"), (" 've", "'ve"), + (" 're", "'re"), + ): + text = text.replace(before, after) + return text + + +class Qwen3Tokenizer: + """Tokenizer for a local Qwen3 snapshot; use :meth:`from_directory`. + + ``eos_token_id`` is the tokenizer's EOS. ``generation_eos_token_ids`` comes + from generation_config.json and includes *all* generation stop IDs. The + model's BOS metadata does not cause encoding to insert a BOS token. + """ + + @classmethod + def from_directory(cls, directory: str | Path) -> Qwen3Tokenizer: + directory = Path(directory) + config = _read_config(directory / "tokenizer_config.json") + model = _read_config(directory / "config.json") + generation = _read_config(directory / "generation_config.json") + if config.get("tokenizer_class") not in ("Qwen2Tokenizer", "Qwen2TokenizerFast"): + raise ValueError("tokenizer_class must identify the Qwen2 tokenizer used by Qwen3") + if model.get("model_type") != "qwen3": + raise ValueError("config.json model_type must be qwen3") + model_vocab_size = model.get("vocab_size") + if type(model_vocab_size) is not int or model_vocab_size <= 0: + raise ValueError("model vocab_size must be a positive integer") + for name in ("add_bos_token", "add_eos_token", "split_special_tokens", "add_prefix_space"): + if _boolean(config.get(name, False), name): + raise ValueError(f"Qwen3 text adapter does not support {name}=True") + # Transformers can add/mark these tokens during initialization. Loading + # tokenizer.json alone would silently ignore those extra semantics. + for name in ("mask_token", "sep_token", "cls_token"): + if config.get(name) is not None: + raise ValueError(f"Qwen3 text adapter does not support {name}") + if config.get("extra_special_tokens") not in (None, {}): + raise ValueError("Qwen3 text adapter does not support extra_special_tokens") + + self = cls() + self._tokenizer = Tokenizer.from_file(str(directory / "tokenizer.json")) + if self._tokenizer.padding is not None or self._tokenizer.truncation is not None: + raise ValueError("tokenizer.json must not enable automatic padding or truncation") + self._validate_prefix_space() + self.model_vocab_size = model_vocab_size + self.base_vocab_size = self._tokenizer.get_vocab_size(with_added_tokens=False) + self._valid_token_ids = frozenset(self._tokenizer.get_vocab(with_added_tokens=True).values()) + self.tokenizer_vocab_size = len(self._valid_token_ids) + if not self._valid_token_ids or any( + token_id < 0 or token_id >= model_vocab_size for token_id in self._valid_token_ids + ): + raise ValueError("tokenizer vocabulary does not fit the model vocab_size") + self.clean_up_tokenization_spaces = _boolean( + config.get("clean_up_tokenization_spaces", False), "clean_up_tokenization_spaces" + ) + self._validate_added_tokens(config) + self.pad_token_id = self._special_id(config, "pad_token", required=True) + self.eos_token_id = self._special_id(config, "eos_token", required=True) + self.bos_token_id = self._special_id(config, "bos_token", required=False) + # The official Qwen2 fast class defaults a missing unk_token to + # endoftext; an explicit JSON null intentionally disables it instead. + self.unk_token_id = self._special_id( + {"unk_token": config.get("unk_token", "<|endoftext|>")}, "unk_token", required=False + ) + eos_ids = generation.get("eos_token_id") + if type(eos_ids) is int: + eos_ids = [eos_ids] + if not isinstance(eos_ids, list) or not eos_ids: + raise ValueError("generation eos_token_id must be an integer or nonempty list") + self.generation_eos_token_ids = tuple(self.validate_token_ids(eos_ids)) + if self.eos_token_id not in self.generation_eos_token_ids: + raise ValueError("generation EOS IDs must include the tokenizer EOS") + if ( + generation.get("pad_token_id") != self.pad_token_id + or type(generation.get("pad_token_id")) is not int + ): + raise ValueError("generation pad_token_id must match the tokenizer pad token") + # Qwen3's JSON post-processor is ByteLevel and inserts no token IDs. + if self._tokenizer.num_special_tokens_to_add(is_pair=False) != 0: + raise ValueError("Qwen3 text encoding must not automatically insert BOS/EOS tokens") + return self + + def _validate_prefix_space(self) -> None: + # The fixed snapshot uses a Sequence containing ByteLevel(False). + # Transformers may override a top-level ByteLevel setting from config; + # refusing prefix insertion keeps this narrow adapter unambiguous for + # both that layout and the official nested layout. + pre_tokenizer = self._tokenizer.pre_tokenizer + pending = [] if pre_tokenizer is None else [json.loads(pre_tokenizer.__getstate__())] + while pending: + state = pending.pop() + if state.get("type") == "ByteLevel" and state.get("add_prefix_space"): + raise ValueError("tokenizer.json must not enable add_prefix_space") + if state.get("type") == "Sequence": + pending.extend(state["pretokenizers"]) + + @property + def valid_token_ids(self) -> frozenset[int]: + """Actual vocabulary members, including added tokens; not model rows.""" + return self._valid_token_ids + + def is_valid_token_id(self, token_id: object) -> bool: + return ( + isinstance(token_id, Integral) + and not isinstance(token_id, bool) + and int(token_id) in self._valid_token_ids + ) + + def validate_token_ids(self, token_ids: Iterable[int]) -> list[int]: + """Copy an ordered iterable to ints, refusing unknown IDs or bools.""" + if ( + isinstance(token_ids, (str, bytes, bytearray, Mapping, Set)) + or not isinstance(token_ids, Iterable) + ): + raise TypeError("token_ids must be an ordered iterable of integer IDs") + result = [] + for index, token_id in enumerate(token_ids): + if not isinstance(token_id, Integral) or isinstance(token_id, bool): + raise TypeError(f"token_ids[{index}] must be an integer (not bool)") + if not self.is_valid_token_id(token_id): + raise ValueError(f"token_ids[{index}]={token_id} is not in the tokenizer vocabulary") + result.append(int(token_id)) + return result + + def encode(self, text: str, *, add_special_tokens: bool = True) -> list[int]: + """Encode one string, with no padding, truncation, BOS/EOS, or chat template.""" + if not isinstance(text, str): + raise TypeError("text must be str") + text.encode("utf-8", errors="strict") # Reject lone surrogates explicitly. + _boolean(add_special_tokens, "add_special_tokens") + return self._tokenizer.encode(text, add_special_tokens=add_special_tokens).ids + + def decode( + self, + token_ids: Iterable[int] | int, + *, + skip_special_tokens: bool = False, + clean_up_tokenization_spaces: bool | None = None, + ) -> str: + """Decode valid IDs; defaults match the official fast tokenizer API. + + Unlike raw tokenizers.decode, unknown IDs are errors, including unused + rows below model_vocab_size. A sampled unused row must not disappear. + """ + _boolean(skip_special_tokens, "skip_special_tokens") + cleanup = self.clean_up_tokenization_spaces + if clean_up_tokenization_spaces is not None: + cleanup = _boolean(clean_up_tokenization_spaces, "clean_up_tokenization_spaces") + if isinstance(token_ids, Integral): + token_ids = [token_ids] + ids = self.validate_token_ids(token_ids) + text = self._tokenizer.decode(ids, skip_special_tokens=skip_special_tokens) + return _cleanup(text) if cleanup else text + + def _validate_added_tokens(self, config: dict) -> None: + decoder = config.get("added_tokens_decoder") + if not isinstance(decoder, dict): + raise ValueError("added_tokens_decoder must be an object") + actual = self._tokenizer.get_added_tokens_decoder() + if set(decoder) != {str(token_id) for token_id in actual}: + raise ValueError("tokenizer.json and tokenizer_config.json added token IDs differ") + for token_id, expected in decoder.items(): + if not isinstance(expected, dict): + raise ValueError(f"added token {token_id} must be an object") + token = actual[int(token_id)] + for name in ("content", "lstrip", "rstrip", "single_word", "normalized", "special"): + value = expected.get(name) + if type(value) is not type(getattr(token, name)) or value != getattr(token, name): + raise ValueError(f"added token {token_id} has inconsistent {name}") + additional = config.get("additional_special_tokens", []) + if not isinstance(additional, list): + raise ValueError("additional_special_tokens must be a list") + for content in additional: + self._special_id({"token": content}, "token", required=True) + + def _special_id(self, config: dict, name: str, *, required: bool) -> int | None: + content = config.get(name) + if content is None and not required: + return None + if not isinstance(content, str) or not content: + raise ValueError(f"{name} must be a nonempty token string") + token_id = self._tokenizer.token_to_id(content) + token = self._tokenizer.get_added_tokens_decoder().get(token_id) + if token_id is None or token is None or not token.special: + raise ValueError(f"{name}={content!r} is not a configured special token") + return token_id diff --git a/scratchv/runtime/riscv_tensor.py b/scratchv/runtime/riscv_tensor.py index 82d56aad..d6a401f8 100644 --- a/scratchv/runtime/riscv_tensor.py +++ b/scratchv/runtime/riscv_tensor.py @@ -17,6 +17,7 @@ import signal import struct import subprocess +import threading import time from typing import Any, Mapping, Sequence @@ -165,6 +166,13 @@ def _run_process(command, *, cwd, timeout, env=None) -> subprocess.CompletedProc with subprocess.Popen(command, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **options) as process: job = _windows_job(process) + # 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 + 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: @@ -182,12 +190,44 @@ def _run_process(command, *, cwd, timeout, env=None) -> subprocess.CompletedProc 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]) return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) +def _terminate_invocation(signum, frame): + raise SystemExit(128 + signum) + + +def _save_process_logs(directory: Path, prefix: str, stdout, stderr): + """Attempt both logs without allowing a secondary I/O error to hide execution.""" + errors = [] + for stream, content in (("stdout", stdout), ("stderr", stderr)): + path = directory / f"{prefix}.{stream}" + try: + path.write_bytes(content or b"") + except OSError as exc: + errors.append((path.name, exc)) + detail = "; ".join(f"{name}: {error}" for name, error in errors) + suffix = f"; failed to preserve {prefix} logs: {detail}" if errors else "" + return suffix, errors[0][1] if errors else None + + def toolchain_versions(toolchain: RiscVToolchain) -> dict[str, str]: compiler = ([toolchain.cc[0], "version"] if toolchain.is_zig else [*toolchain.cc, "--version"]) @@ -516,15 +556,15 @@ def build_riscv_tensor( try: process = _run_process(command, cwd=build_dir, timeout=timeout, env=environment) except subprocess.TimeoutExpired as exc: - (build_dir / "compiler.stdout").write_bytes(exc.stdout or b"") - (build_dir / "compiler.stderr").write_bytes(exc.stderr or b"") - raise TimeoutError(f"RISC-V compilation exceeded {timeout} seconds") from exc + log_message, _ = _save_process_logs(build_dir, "compiler", exc.stdout, exc.stderr) + raise TimeoutError(f"RISC-V compilation exceeded {timeout} seconds{log_message}") from exc elapsed = time.perf_counter() - started - (build_dir / "compiler.stdout").write_bytes(process.stdout) - (build_dir / "compiler.stderr").write_bytes(process.stderr) + log_message, log_error = _save_process_logs(build_dir, "compiler", process.stdout, process.stderr) if process.returncode: message = process.stderr.decode("utf-8", errors="replace")[-12000:] - raise RuntimeError(f"RISC-V tensor compilation failed ({process.returncode}):\n{message}") + raise RuntimeError(f"RISC-V tensor compilation failed ({process.returncode}):\n{message}{log_message}") + if log_error is not None: + raise RuntimeError(log_message.lstrip("; ")) from log_error validate_riscv_elf(elf) return RiscVTensorExecutable(elf, tuple(artifact.inputs), artifact.output, toolchain, tuple(command), elapsed, int(artifact.workspace_bytes), @@ -562,17 +602,12 @@ def run_riscv_tensor( except subprocess.TimeoutExpired as exc: elapsed = time.perf_counter() - started message = f"RISC-V QEMU execution exceeded {timeout} seconds" - try: - stdout_path.write_bytes(exc.stdout or b"") - stderr_path.write_bytes(exc.stderr or b"") - except OSError as log_error: - message += f"; failed to preserve QEMU logs: {log_error}" - raise RiscVTensorTimeoutError(message, elapsed_s=elapsed, + log_message, _ = _save_process_logs(run_dir, "qemu", exc.stdout, exc.stderr) + raise RiscVTensorTimeoutError(message + log_message, elapsed_s=elapsed, command=command, timeout_s=timeout) from exc elapsed = time.perf_counter() - started + log_message, log_error = _save_process_logs(run_dir, "qemu", process.stdout, process.stderr) try: - stdout_path.write_bytes(process.stdout) - stderr_path.write_bytes(process.stderr) if process.returncode: diagnostic = process.stderr.decode("utf-8", errors="replace")[-4000:] if uart_path.exists(): @@ -584,5 +619,8 @@ def run_riscv_tensor( raise RuntimeError("QEMU exited without producing UART tensor output") result = decode_tensor_frame(uart_path.read_bytes(), executable.output) except Exception as exc: - raise RiscVTensorExecutionError(str(exc), elapsed_s=elapsed, command=command) from exc + raise RiscVTensorExecutionError(str(exc) + log_message, elapsed_s=elapsed, command=command) from exc + if log_error is not None: + raise RiscVTensorExecutionError(log_message.lstrip("; "), elapsed_s=elapsed, + command=command) from log_error return RiscVTensorRun(result, elapsed, tuple(command), uart_path, stdout_path, stderr_path) diff --git a/scratchv/verification/fp32_eigen.py b/scratchv/verification/fp32_eigen.py new file mode 100644 index 00000000..c53960d0 --- /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 00000000..d035212f --- /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 00000000..24fbbc00 --- /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 1891229c..6b1a45f7 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,32 @@ 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. + + ``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 +227,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 +284,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) @@ -241,7 +380,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 +402,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,7 +495,8 @@ 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") @@ -335,13 +505,17 @@ def resolve(value): 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 +551,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 +568,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 92baeab1..432ff029 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/scratchv/verification/numeric_metrics.py b/scratchv/verification/numeric_metrics.py new file mode 100644 index 00000000..5a479655 --- /dev/null +++ b/scratchv/verification/numeric_metrics.py @@ -0,0 +1,140 @@ +"""Descriptive tensor metrics; these never decide numerical acceptance. + +Norms are accumulated in float64 relative to a power-of-two scale. Iteration +is bounded even for noncontiguous arrays and memory-mapped model outputs. +""" +from __future__ import annotations + +import math + +import numpy as np + + +def undefined_numeric_metrics(reason): + """JSON-safe fields for a comparison that has no numerical meaning.""" + return dict(relative_l2=None, cosine_similarity=None, + relative_l2_reason=reason, cosine_similarity_reason=reason) + + +class _ScaledSquares: + def __init__(self): + self.exponent = 0 + self.squares = 0.0 + + def add(self, values, exponent_shift=0): + maximum = float(np.max(np.abs(values), initial=0.0)) + if maximum == 0: + return + _, exponent = math.frexp(maximum) + normalized = np.ldexp(values, -exponent) + squares = float(np.sum(normalized * normalized, dtype=np.float64)) + exponent += exponent_shift + if self.squares == 0: + self.exponent, self.squares = exponent, squares + elif exponent > self.exponent: + self.squares = math.ldexp(self.squares, 2 * (self.exponent - exponent)) + squares + self.exponent = exponent + else: + self.squares += math.ldexp(squares, 2 * (exponent - self.exponent)) + + +def _chunks(actual, expected, block_elements): + return np.nditer([actual, expected], flags=["external_loop", "buffered"], + op_flags=[["readonly"], ["readonly"]], order="C", + buffersize=block_elements) + + +def numeric_metrics(actual, expected, *, block_elements=262144): + """Return cosine and ||actual-expected||_2 / ||expected||_2. + + Shapes and dtypes must match, with real numeric (at most FP64) data. Empty + and nonfinite tensors have undefined metrics. Cosine is undefined if either + norm is zero. A zero reference gives relative L2 zero only when actual is + also zero; otherwise it is undefined. Unrepresentable relative L2 values + are reported as null with a reason, never as infinity or an invented zero. + """ + if type(block_elements) is not int or block_elements < 1: + raise ValueError("block_elements must be a positive integer") + if not isinstance(actual, np.ndarray) or not isinstance(expected, np.ndarray): + return undefined_numeric_metrics("outputs must be NumPy arrays") + if actual.shape != expected.shape: + return undefined_numeric_metrics("shape mismatch") + if actual.dtype != expected.dtype: + return undefined_numeric_metrics("dtype mismatch") + if actual.dtype.kind not in "biuf" or (actual.dtype.kind == "f" and actual.dtype.itemsize > 8): + return undefined_numeric_metrics("metrics require real numeric dtypes up to float64") + if not actual.size: + return undefined_numeric_metrics("empty tensor") + + actual_norm, expected_norm, difference_norm = (_ScaledSquares() for _ in range(3)) + identical = True + for a, b in _chunks(actual, expected, block_elements): + if not np.isfinite(a).all() or not np.isfinite(b).all(): + return undefined_numeric_metrics("nonfinite output or reference") + equal = bool(np.array_equal(a, b)) + identical = identical and equal + a64, b64 = a.astype(np.float64), b.astype(np.float64) + actual_norm.add(a64) + expected_norm.add(b64) + if equal: + continue + if actual.dtype.kind in "biu": + # Subtract before rounding to FP64: int64/uint64 neighbours above + # 2**53 must retain their nonzero difference. Copies stay bounded. + difference = (a.astype(object) - b.astype(object)).astype(np.float64) + difference_norm.add(difference) + else: + with np.errstate(over="ignore", invalid="ignore"): + difference = a64 - b64 + overflow = ~np.isfinite(difference) + if overflow.any(): + difference_norm.add(difference[~overflow]) + # Only overflowing differences are halved, so unrelated tiny + # values are not lost to a premature division by two. + difference_norm.add(a64[overflow] * 0.5 - b64[overflow] * 0.5, 1) + else: + difference_norm.add(difference) + + result = dict(relative_l2=None, cosine_similarity=None, + relative_l2_reason=None, cosine_similarity_reason=None) + if expected_norm.squares == 0: + if actual_norm.squares == 0: + result["relative_l2"] = 0.0 + else: + result["relative_l2_reason"] = "zero reference norm with nonzero actual norm" + elif difference_norm.squares == 0: + result["relative_l2"] = 0.0 + else: + factor = math.sqrt(difference_norm.squares / expected_norm.squares) + try: + relative = math.ldexp(factor, difference_norm.exponent - expected_norm.exponent) + except OverflowError: + relative = math.inf + if not math.isfinite(relative): + result["relative_l2_reason"] = "relative L2 exceeds float64 range" + elif relative == 0: + result["relative_l2_reason"] = "nonzero relative L2 is below float64 range" + else: + result["relative_l2"] = relative + + if actual_norm.squares == 0 or expected_norm.squares == 0: + result["cosine_similarity_reason"] = "cosine is undefined for a zero norm" + elif identical: + result["cosine_similarity"] = 1.0 + else: + # Scale each vector independently; squaring unscaled FP64 values can + # overflow, and using one shared scale can erase the smaller vector. + dot, correction = 0.0, 0.0 + for a, b in _chunks(actual, expected, block_elements): + a64 = np.ldexp(a.astype(np.float64), -actual_norm.exponent) + b64 = np.ldexp(b.astype(np.float64), -expected_norm.exponent) + term = float(np.sum(a64 * b64, dtype=np.float64)) + total = dot + term + correction += ((dot - total) + term if abs(dot) >= abs(term) + else (term - total) + dot) + dot = total + cosine = ((dot + correction) / math.sqrt(actual_norm.squares) + / math.sqrt(expected_norm.squares)) + # Cauchy-Schwarz bounds the exact result; trim only FP64 roundoff. + result["cosine_similarity"] = min(1.0, max(-1.0, cosine)) + return result diff --git a/scripts/check_linux_repro_docs.py b/scripts/check_linux_repro_docs.py new file mode 100644 index 00000000..dfe8b6fb --- /dev/null +++ b/scripts/check_linux_repro_docs.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Reject host-specific reproduction instructions; optionally parse Bash blocks. + +This checks documentation, not execution or numerical acceptance. Historical +platform descriptions and portable implementation details remain valid. +""" +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import shutil +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] +FENCE = re.compile(r"^\s*(`{3,}|~{3,})\s*([\w+-]*)\s*$") +HOST_PATTERNS = ( + (re.compile(r"(?= len(active) and not match.group(2): + if language in ("bash", "sh", "shell"): + blocks.append((start, "\n".join(body) + "\n")) + active = None + elif active: + body.append(line) + if language in ("bash", "sh", "shell") and re.search(r"\b[\w.-]+\.exe\b", line, re.I): + problems.append(f"{path}:{number}: non-Linux executable in shell example") + if active: + problems.append(f"{path}:{start}: unterminated fenced block") + if bash: + for start, content in blocks: + try: + result = subprocess.run([bash, "--noprofile", "--norc", "-n"], input=content, + capture_output=True, text=True, encoding="utf-8", timeout=10) + except (OSError, subprocess.TimeoutExpired) as exc: + problems.append(f"{path}:{start}: Bash validation could not run: {exc}") + continue + if result.returncode: + problems.append(f"{path}:{start}: invalid Bash: {result.stderr.strip()}") + return problems, len(blocks) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--check-bash", action="store_true", help="Require Bash and syntax-check every shell block") + parser.add_argument("--bash", help="Explicit Bash executable; defaults to PATH") + args = parser.parse_args(argv) + bash = (args.bash or shutil.which("bash")) if args.check_bash else None + if args.check_bash and not bash: + parser.exit(1, "FAIL: Bash is required for --check-bash\n") + paths = document_paths(args.root.resolve()) + if not paths: + parser.exit(1, "FAIL: no reproduction documents found\n") + problems, blocks = [], 0 + for path in paths: + findings, count = inspect_document(path, bash=bash) + problems.extend(findings) + blocks += count + for problem in problems: + print(problem) + print(f"{'FAIL' if problems else 'PASS'}: {len(paths)} documents, {blocks} shell blocks; " + f"Bash syntax {'checked' if bash else 'not requested'}; no model execution") + return int(bool(problems)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_w2_ci_results.py b/scripts/check_w2_ci_results.py new file mode 100644 index 00000000..25a208ad --- /dev/null +++ b/scripts/check_w2_ci_results.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Fail W2 CI acceptance if a required job failed, was skipped or is absent.""" +from __future__ import annotations + +import json +import os +import sys + +REQUIRED_JOBS = ("llm-deploy", "full-qwen3-frontend") + + +def validate_results(results): + if not isinstance(results, dict): + raise ValueError("W2_JOB_RESULTS 必须是 JSON 对象") + failures = [] + for name in REQUIRED_JOBS: + job = results.get(name) + outcome = job.get("result") if isinstance(job, dict) else None + if outcome != "success": + failures.append(f"{name}: {outcome or 'missing result'}") + if failures: + raise ValueError("以下 W2 验收 job 未成功: " + "; ".join(failures)) + + +def main(): + print("## W2 测试项\n") + try: + validate_results(json.loads(os.environ.get("W2_JOB_RESULTS", "{}"))) + except (ValueError, TypeError) as exc: + print(f"FAIL: {exc}") + return 1 + print("PASS: 小模型数值/运行时门槛与完整 28 层前端审计均成功。") + print("\n这不证明他人复现,也不证明完整模型 IR/QEMU 推理。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/collect_w3_ci_evidence.py b/scripts/collect_w3_ci_evidence.py new file mode 100644 index 00000000..8a65315c --- /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 00000000..d21c1d71 --- /dev/null +++ b/scripts/package_w3_repro.py @@ -0,0 +1,220 @@ +"""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"} +# The W3 gates used to be the separate .github/workflows/w3-*.yml files. They +# are now jobs of the single LLM pipeline, so that file is what carries the CI +# recipe this handoff has to preserve. +WORKFLOW = ".github/workflows/llm-deploy.yml" + + +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 == WORKFLOW 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_w2_acceptance.py b/scripts/run_w2_acceptance.py new file mode 100644 index 00000000..ab254b61 --- /dev/null +++ b/scripts/run_w2_acceptance.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +"""Run fresh W2 acceptance gates, without installing dependencies or publishing. + +Use an existing pinned Python environment and explicit local asset/tool paths. +All seven gates are required for PASS. A successful --gates subset is PARTIAL +(exit 2); failures exit 1. Reports never certify independent-person signoff, +full-model numerical execution, or a complete text-generation loop. +""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +from html import escape +import json +import math +import os +from pathlib import Path +import signal +import subprocess +import sys +import threading +import time +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +GATES = ("frontend", "backend", "runtime", "runtime-model", "small-ir", "small-qemu", "full-parse") +SCRIPTS = { + "backend": "probes/w2_backend_ops/run.py", "runtime": "probes/w2_runtime/run.py", + "runtime-model": "probes/w2_runtime_model/run.py", "small-ir": "probes/w2_qwen3_small/run.py", + "small-qemu": "probes/w2_qwen3_small/riscv.py", "full-parse": "probes/w2_qwen3_parse/run.py", +} +SMALL_CASES = ("full_seed_0", "full_seed_42", "one_token", "short_17", "short_255", + "changed_future", "changed_padding") +FAMILIES = {"matmul", "elementwise", "softmax", "rmsnorm", "rope", "swiglu", "gqa"} + + +def sha256(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def source_fingerprints(root=ROOT): + files = {root / "pyproject.toml", root / "scripts/run_w2_acceptance.py", + root / "scripts/check_w2_ci_results.py"} + for directory in ("scratchv", "probes", "requirements"): + files.update(p for p in (root / directory).rglob("*") + if p.is_file() and p.suffix in {".py", ".json", ".txt"} + and not any(part in {"out", "output", "__pycache__"} for part in p.relative_to(root).parts)) + files.update((root / "tests").glob("test_qwen3*.py")) + files.update((root / "tests").glob("test_w2*.py")) + files.add(root / "tests/test_llm_inputs.py") + return {p.relative_to(root).as_posix(): sha256(p) for p in sorted(files) if p.is_file()} + + +def checkout_evidence(root=ROOT): + """A source archive is valid evidence too; Git absence is not a failure.""" + if not (root / ".git").exists(): + # A copied source tree may sit under another checkout's ignored output/. + # Git's parent-directory discovery must not attribute that archive to it. + return {"head": None, "status": "unknown", "reason": "Source directory has no .git metadata"} + try: + def git(*args): + return subprocess.run(["git", *args], cwd=root, capture_output=True, + check=True, timeout=15).stdout + return {"head": git("rev-parse", "HEAD").decode().strip(), + "status": git("status", "--porcelain=v1", "--untracked-files=all").decode("utf-8", "replace"), + "tracked_diff_sha256": hashlib.sha256(git("diff", "--binary", "HEAD", "--")).hexdigest(), + "diff_scope": "Tracked staged and unstaged changes; source_sha256 also covers new source files"} + except (OSError, subprocess.SubprocessError) as exc: + return {"head": None, "status": "unknown", "error": f"{type(exc).__name__}: {exc}"} + + +@dataclass(frozen=True) +class Gate: + name: str + command: tuple[str, ...] + output: Path + dependencies: tuple[str, ...] = () + + +def build_plan(args, root=ROOT): + selected = set(args.gates) + if "small-qemu" in selected: + selected.add("small-ir") + if "runtime-model" in selected: + selected.add("runtime") + plans = [] + for name in GATES: + if name not in selected: + continue + out = args.output_dir / name + command = [args.python, "-B", "-X", "utf8"] + if name == "frontend": + command += ["-m", "pytest", "tests/test_qwen3_frontend_patterns.py", "-q", "-p", "no:cacheprovider", + "--junit-xml", str(out / "tests.xml"), "--basetemp", str(args.output_dir / "frontend-pytest-temp")] + else: + command += [str(root / SCRIPTS[name]), "--output-dir", str(out)] + if name in {"backend", "small-qemu"}: + command += ["--timeout", str(args.qemu_timeout)] + for option in ("cc", "qemu"): + if getattr(args, option): + command += ["--" + option, getattr(args, option)] + if name in {"runtime", "runtime-model"}: + command += ["--tokenizer-dir", str(args.tokenizer_dir)] + if name == "runtime": + command += ["--mode", args.tokenizer_mode] + if name == "small-qemu": + command += ["--model-dir", str(args.output_dir / "small-ir")] + if name == "full-parse": + command += ["--model-dir", str(args.model_dir), "--mode", args.model_mode, + "--timeout", str(args.parse_timeout)] + dependencies = (("small-ir",) if name == "small-qemu" else + ("runtime",) if name == "runtime-model" else ()) + plans.append(Gate(name, tuple(command), out, dependencies)) + return plans + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def check_numeric_tree(value): + """Reject contradictory nested failures and NaN/Infinity numeric evidence.""" + if isinstance(value, dict): + if "passed" in value: + require(value["passed"] is True, "Nested validation did not pass") + if "max_abs" in value: + number = value["max_abs"] + require(type(number) in (int, float) and math.isfinite(number) and 0 <= number < 1e-5, + "Numeric error is missing, nonfinite or outside strict 1e-5 threshold") + for child in value.values(): + check_numeric_tree(child) + elif isinstance(value, list): + for child in value: + check_numeric_tree(child) + elif isinstance(value, float): + require(math.isfinite(value), "Nonfinite report value cannot be accepted as evidence") + + +def checked_rows(rows, count, names=None): + require(isinstance(rows, list) and len(rows) == count, f"Expected {count} complete cases") + require(all(isinstance(row, dict) and row.get("passed") is True for row in rows), "Incomplete case results") + if names is not None: + require([row.get("name") for row in rows] == list(names), "Missing, duplicated or reordered cases") + return rows + + +def validate_gate_report(gate, sources, root=ROOT): + if gate.name == "frontend": + path = gate.output / "tests.xml" + cases = ET.parse(path).getroot().findall(".//testcase") + require(len(cases) == 22, "Frontend must execute all 22 pattern tests") + require(len({(case.get("classname"), case.get("name")) for case in cases}) == 22, + "Duplicated frontend test results") + require(not any(case.find(tag) is not None for case in cases for tag in ("failure", "error", "skipped")), + "Frontend failures, errors and skips are not passing acceptance") + return {"tests": len(cases), "report_sha256": sha256(path)} + path = gate.output / "report.json" + data = json.loads(path.read_text(encoding="utf-8")) + require(data.get("passed") is True and data.get("stage") == "complete", + str(data.get("error", "Child gate did not complete successfully"))) + for name in ("report.md", "report.html"): + require((gate.output / name).is_file(), f"Missing child report {name}") + hashes = data.get("source_sha256", data.get("provenance", {}).get("source_sha256")) + require(isinstance(hashes, dict) and bool(hashes), "Missing child source fingerprints") + require(hashes.get(SCRIPTS[gate.name]) == sources.get(SCRIPTS[gate.name]), "Gate script fingerprint mismatch") + require(all(name in sources and sources[name] == value for name, value in hashes.items()), + "Child source fingerprints do not match this acceptance run") + check_numeric_tree(data) + summary = {"report_sha256": sha256(path), "child_seconds": data.get("seconds")} + if gate.name == "backend": + require(data.get("backend") == "ScratchV tensor-c -> RV64GC -> QEMU", "Unexpected backend gate identity") + rows = checked_rows(data.get("cases"), 16) + require(len({row.get("name") for row in rows}) == 16 and {row.get("family") for row in rows} == FAMILIES, + "Missing backend families or duplicated cases") + for row in rows: + runs = checked_rows(row.get("executions"), 2) + require([run.get("optimization") for run in runs] == ["none", "all"], "Incomplete optimization levels") + for run in runs: + require(all(run.get(key, {}).get("passed") is True for key in + ("ir_vs_ort", "ir_vs_numpy", "qemu_vs_ort", "qemu_vs_numpy")), "Missing backend comparisons") + require(bool(run.get("command")) and bool(run.get("elf_sha256")), "Missing real QEMU evidence") + require((gate.output / row["name"] / run["optimization"] / "qemu.npy").is_file(), "Missing QEMU output") + require(data.get("completed_executions") == data.get("passed_executions") == 32, "Incomplete backend executions") + summary.update(executions=32, qemu_seconds=data.get("qemu_process_wall_seconds", {}).get("total")) + elif gate.name == "runtime": + require(data.get("gate") == "unit:runtime", "Unexpected runtime gate identity") + corpus = json.loads((root / "probes/w2_runtime/corpus.json").read_text(encoding="utf-8")) + rows = checked_rows(data.get("tokenizer_cases"), 20, [row["name"] for row in corpus["cases"]]) + require(all(row.get("checks") == 24 for row in rows), "Incomplete tokenizer comparisons") + checked_rows(data.get("runtime_cases"), 5) + summary.update(corpus_cases=20, tokenizer_comparisons=480, runtime_cases=5) + elif gate.name == "runtime-model": + require(data.get("gate") == "integration:runtime-model", "Unexpected runtime-model gate identity") + require(data.get("config", {}).get("vocab_size") == 151936 + and data.get("config", {}).get("num_hidden_layers") == 2, "Missing real tokenizer/model vocabulary contract") + require(data.get("completed_model_invocations") == 8, "Incomplete runtime-model forward invocations") + rows = checked_rows(data.get("cases"), 2, ("chinese", "special_pad_in_prompt")) + require(rows[1].get("pad_id_inside_prompt") is True, "Missing in-prompt PAD test") + for row in rows: + require(row.get("torch", {}).get("contract", {}).get("passed") is True + and row.get("torch", {}).get("sampling", {}).get("passed") is True, + "Missing independent model and sampling reference") + executions = checked_rows(row.get("executions"), 3) + require([run.get("engine") for run in executions] == ["ort", "ir-none", "ir-all"], + "Missing runtime-model execution engines") + for run in executions: + require(run.get("vs_torch", {}).get("passed") is True + and run.get("sampling", {}).get("passed") is True, "Missing runtime-model comparisons") + if run["engine"].startswith("ir-"): + require(run.get("vs_ort", {}).get("passed") is True, "Missing IR/ORT comparison") + summary.update(cases=2, executions=6) + elif gate.name == "small-ir": + require(data.get("config", {}).get("num_hidden_layers") == 2, "Expected real two-layer configuration") + checked_rows(data.get("cases"), 7, SMALL_CASES) + checked_rows(data.get("invariants"), 6) + for filename, field in (("model.onnx", "model_sha256"), ("diagnostics.onnx", "diagnostics_sha256")): + require(sha256(gate.output / filename) == data.get("onnx", {}).get(field), "Export artifact hash mismatch") + require((gate.output / "checkpoints.json").is_file(), "Missing diagnostic checkpoint schema") + summary["cases"] = 7 + elif gate.name == "small-qemu": + rows = checked_rows(data.get("cases"), 7, SMALL_CASES) + groups = [(graph, level) for graph in ("normal", "diagnostic") for level in ("none", "all")] + for row in rows: + runs = checked_rows(row.get("qemu"), 4) + require([(run.get("graph"), run.get("optimization")) for run in runs] == groups, "Missing QEMU graph/level") + require(all(run.get("status") == "success" and run.get("command") for run in runs), "Missing QEMU execution") + for run in runs: + require((gate.output / "runs" / row["name"] / f"{run['graph']}-{run['optimization']}" / "qemu.npy").is_file(), + "Missing two-layer QEMU output") + timing = data.get("timing", {}) + require(timing.get("completed_count") == timing.get("status_counts", {}).get("success") == 28, + "Incomplete two-layer QEMU executions") + require(data.get("export_evidence", {}).get("report_sha256") == sha256(gate.output.parent / "small-ir/report.json"), + "QEMU did not use the fresh export from this run") + summary.update(executions=28, qemu_seconds=timing.get("qemu_process_wall_seconds_total")) + elif gate.name == "full-parse": + require(data.get("gate") == "frontend:parse-qwen3" and data.get("schema_version") == 1, + "Unexpected full parse gate identity") + require(data.get("graph", {}).get("config", {}).get("num_hidden_layers") == 28, "Missing complete model configuration") + layers = checked_rows(data.get("graph", {}).get("layers"), 28) + require([row.get("layer") for row in layers] == list(range(28)), "Incomplete 28-layer chain") + require(data.get("audit", {}).get("passed") is True and data.get("ir_verifier", {}).get("passed") is True, + "Missing binding audit or IR verification") + for name in ("nodes.json", "bindings.json", "ir.txt"): + require(sha256(gate.output / name) == data.get("evidence", {}).get(name, {}).get("sha256"), + f"Missing or altered parse evidence: {name}") + summary["layers"] = 28 + return summary + + +def terminate_process_tree(process): + """The gates spawn compilers/QEMU/workers; stop owned descendants on timeout.""" + try: + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True, timeout=20, check=True) + else: + try: + # Let the probe unwind _run_process and stop its separate + # compiler/QEMU/worker session before the final group kill. + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + pass + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=20) + + +def child_failure(row, gate, log): + try: + child = json.loads((gate.output / "report.json").read_text(encoding="utf-8")) + row.update(child_stage=child.get("stage"), child_error=child.get("error")) + except (OSError, ValueError): + pass + return row.get("child_error") or f"Child exited {row['returncode']}; inspect {log.name}" + + +def run_gate(gate, sources, *, timeout, root=ROOT): + started = time.perf_counter() + row = {"name": gate.name, "status": "FAIL", "passed": False, "command": list(gate.command), + "output": str(gate.output), "timeout_seconds": timeout} + log = gate.output.parent / "logs" / (gate.name + ".log") + row["log"] = str(log) + process = stream = interruption = None + try: + require(not gate.output.exists(), "Gate output already exists; cannot reuse previous evidence") + log.parent.mkdir(exist_ok=True) + env = os.environ.copy() + env.update(PYTHONHASHSEED="0", 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} + stream = log.open("xb") + process = subprocess.Popen(gate.command, cwd=root, stdout=stream, stderr=subprocess.STDOUT, + env=env, **options) + row["returncode"] = process.wait(timeout=timeout) + if row["returncode"] != 0: + row["error"] = child_failure(row, gate, log) + raise ValueError(row["error"]) + row["validation"] = validate_gate_report(gate, sources, root) + row.update(status="PASS", passed=True) + except BaseException as exc: + row.update(status="FAIL", passed=False) + row.setdefault("error", f"Gate exceeded {timeout} seconds" if isinstance(exc, subprocess.TimeoutExpired) + else f"{type(exc).__name__}: {exc}") + # Gates have their own process groups, so Ctrl-C sent to this supervisor + # does not reach them. Unwind their nested compiler/QEMU sessions too. + if process is not None and (process.poll() is None or not isinstance(exc, Exception)): + try: + terminate_process_tree(process) + except (OSError, subprocess.SubprocessError) as cleanup: + row["cleanup_error"] = f"{type(cleanup).__name__}: {cleanup}" + row["returncode"] = process.returncode + if not isinstance(exc, Exception): + row["interrupted"] = True + interruption = exc + finally: + # A log close/flush failure must not replace a cancellation or the + # original model error. It must still prevent a successful report. + if stream is not None: + try: + stream.close() + except OSError as exc: + row.update(status="FAIL", passed=False, log_error=f"{type(exc).__name__}: {exc}") + row.setdefault("error", row["log_error"]) + row["seconds"] = time.perf_counter() - started + if interruption is not None: + interruption.w2_gate_result = row + raise interruption + return row + + +def cancel_acceptance(signum, frame): + """Let the supervisor save failure evidence and stop its owned children.""" + raise SystemExit(128 + signum) + + +def finalize_status(report): + if report.get("error") or any(row["status"] in {"FAIL", "BLOCKED"} for row in report["gates"]): + report.update(status="FAIL", passed=False) + elif all(row["status"] == "PASS" for row in report["gates"]) and len(report["gates"]) == len(GATES): + report.update(status="PASS", passed=True) + else: + report.update(status="PARTIAL", passed=False) + + +def write_reports(out, report): + errors = [] + + def fail(name, exc): + errors.append(f"{name}: {type(exc).__name__}: {exc}") + report.setdefault("error", "Could not preserve complete W2 acceptance evidence") + report.update(passed=False, status="FAIL", report_write_errors=list(errors)) + + def write(name, text): + temporary = out / ("." + name + ".tmp") + try: + temporary.write_text(text, encoding="utf-8") + temporary.replace(out / name) + return True + except OSError as exc: + fail(name, exc) + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + return False + + def views(): + lines = ["# W2 local acceptance", "", f"Result: **{report['status']}**", "", + "Fresh executions only. Subsets and blocked gates cannot certify all W2 technical gates.", "", + "| Gate | Result | Wall seconds | Reason |", "|---|---|---:|---|"] + for row in report["gates"]: + reason = str(row.get("error", "")).replace("|", "/").replace("\n", " ") + lines.append(f"| {row['name']} | {row['status']} | {row.get('seconds', 0):.3f} | {reason} |") + if report.get("error"): + lines += ["", "Error: " + report["error"]] + lines += ["", "Source hashes and checkout metadata: report.json. Per-gate logs: logs/.", + "This does not establish team signoff, full-model IR/QEMU correctness or text generation."] + markdown = "\n".join(lines) + "\n" + return {"report.md": markdown, "report.html": 'W2 acceptance' + '
'
+                + escape(markdown) + "
"} + + for name, value in views().items(): + write(name, value) + if errors: + for name, value in views().items(): + write(name, value) + if not write("report.json", json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n"): + for name, value in views().items(): + write(name, value) + write("report.json", json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n") + if errors: + print("; ".join(errors), file=sys.stderr) + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--python", default=sys.executable, help="Existing pinned Python interpreter; no installation") + cli.add_argument("--output-dir", type=Path, required=True, help="Must not exist, even if empty") + cli.add_argument("--tokenizer-dir", type=Path, help="Verified local official tokenizer assets") + cli.add_argument("--tokenizer-mode", choices=("verify", "download"), default="verify") + cli.add_argument("--model-dir", type=Path, help="Pinned full Qwen3 ONNX release directory") + cli.add_argument("--model-mode", choices=("verify", "download"), default="verify") + cli.add_argument("--cc", help="RISC-V compiler / Zig executable") + cli.add_argument("--qemu", help="qemu-system-riscv64 executable") + cli.add_argument("--gate-timeout", type=float, default=1800, help="Wall seconds per gate including child workers") + cli.add_argument("--qemu-timeout", type=float, default=180, help="Seconds per QEMU execution") + cli.add_argument("--parse-timeout", type=float, default=300, help="Full frontend worker timeout") + cli.add_argument("--gates", nargs="+", choices=GATES, default=list(GATES), + help="Optional subset: dependencies auto-added; successful subset exits 2 (PARTIAL)") + args = cli.parse_args(argv) + for field in ("gate_timeout", "qemu_timeout", "parse_timeout"): + if not math.isfinite(getattr(args, field)) or getattr(args, field) <= 0: + cli.error("Timeouts must be finite and positive") + if len(set(args.gates)) != len(args.gates): + cli.error("Duplicate gates are not allowed") + args.output_dir = args.output_dir.resolve() + for field in ("tokenizer_dir", "model_dir"): + if getattr(args, field) is not None: + setattr(args, field, getattr(args, field).resolve()) + if args.output_dir.exists(): + cli.error("--output-dir must not exist; preserve previous evidence") + args.output_dir.mkdir(parents=True) + report = {"schema_version": 1, "gate": "acceptance:w2", "status": "FAIL", "passed": False, + "created_at": datetime.now(timezone.utc).isoformat(), "gates": [], "requested_gates": args.gates, + "python": args.python, "checkout": checkout_evidence()} + started = time.perf_counter() + interrupted_exit = None + previous_term = None + if os.name != "nt" and threading.current_thread() is threading.main_thread(): + previous_term = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, cancel_acceptance) + try: + report["source_sha256"] = source_fingerprints() + plan = {gate.name: gate for gate in build_plan(args)} + states = {} + for name in GATES: + gate = plan.get(name) + missing = ("--tokenizer-dir" if name in {"runtime", "runtime-model"} and args.tokenizer_dir is None else + "--model-dir" if name == "full-parse" and args.model_dir is None else None) + if gate is None: + row = {"name": name, "status": "NOT_RUN", "passed": False, "error": "Not selected"} + elif missing or any(states.get(dependency) != "PASS" for dependency in gate.dependencies): + row = {"name": name, "status": "BLOCKED", "passed": False, + "error": "Missing " + missing if missing else "Required preceding gate did not pass"} + else: + print(f"[W2] Running {name}", flush=True) + row = run_gate(gate, report["source_sha256"], timeout=args.gate_timeout) + report["gates"].append(row) + states[name] = row["status"] + print(f"[W2] {name}: {row['status']}", flush=True) + final_sources = source_fingerprints() + if final_sources != report["source_sha256"]: + report["error"] = "Source files changed while acceptance was running; rerun after edits finish" + report["final_source_sha256"] = final_sources + except (KeyboardInterrupt, SystemExit) as exc: + interrupted_exit = 130 if isinstance(exc, KeyboardInterrupt) else ( + exc.code if isinstance(exc.code, int) and exc.code != 0 else 1) + report.update(interrupted=True, error=f"{type(exc).__name__}: W2 acceptance cancelled") + current = getattr(exc, "w2_gate_result", None) + if current is not None: + report["gates"].append(current) + completed = {row["name"] for row in report["gates"]} + report["gates"].extend({"name": name, "status": "NOT_RUN", "passed": False, + "error": "Acceptance cancelled before execution"} + for name in GATES if name not in completed) + except Exception as exc: + report.setdefault("error", f"{type(exc).__name__}: {exc}") + finally: + if previous_term is not None: + signal.signal(signal.SIGTERM, previous_term) + report["seconds"] = time.perf_counter() - started + finalize_status(report) + write_reports(args.output_dir, report) + print(f"[W2] {report['status']}: {args.output_dir / 'report.json'}", flush=True) + return interrupted_exit if interrupted_exit is not None else ( + 0 if report["passed"] else 2 if report["status"] == "PARTIAL" else 1) + + +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 00000000..ebef39fb --- /dev/null +++ b/scripts/run_w3_preparation.py @@ -0,0 +1,368 @@ +"""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): + python = [str(Path(args.python).resolve()),"-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_w2_ci.py b/scripts/summarize_w2_ci.py new file mode 100644 index 00000000..040b48e0 --- /dev/null +++ b/scripts/summarize_w2_ci.py @@ -0,0 +1,336 @@ +"""Publish the W2 operator, precision and timing tables to the Actions summary. + +The W2 jobs run their own probes and publish raw evidence. This script is the +one place that answers what the W2 summary is for: + +* which operators each layer supports and which were exercised this run; +* how far the numbers are from ORT and the independent NumPy references; +* how long the whole chain takes, per model and in total. + +Everything else belongs in the step log, not on the Actions page. Reporting +never substitutes for, or changes, a probe's exit code. + +The acceptance job installs nothing, so this module imports only the standard +library plus ``probes.numeric_summary`` and ``scripts.summarize_w3_ci``. The +operator lists are read from the compiler sources with ``ast`` rather than +imported, so no ``scratchv`` or ``onnx`` import happens here. +""" +from __future__ import annotations + +import argparse +import ast +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 METRIC_EXPLANATION, compact_metric_table +from scripts.summarize_w3_ci import cell + +# Inputs are bound separately from the much smaller Actions page budget. +MAX_REPORT_BYTES = 64 * 1024 * 1024 +MAX_SUMMARY_BYTES = 64 * 1024 + +FRONTEND_SOURCE = ROOT / "scratchv/frontend/onnx_parser.py" +BACKEND_SOURCE = ROOT / "scratchv/backend/tensor_c_codegen.py" +KERNEL_BODIES = ROOT / "scratchv/backend/kernels/bodies" + +# Probe directories under --report-dir, in the order the chain runs. +DIRECTORIES = ("w2-backend-ops", "w2-runtime", "w2-runtime-model", + "qwen3-small", "qwen3-riscv", "qwen3-parse") +BACKEND_OPS, RUNTIME, RUNTIME_MODEL, QWEN3_SMALL, QWEN3_RISCV, QWEN3_PARSE = DIRECTORIES + +# probes/w2_backend_ops/cases.py builds these decomposed primitives, one graph +# per case, and names each case after its family. Only families with a passing +# execution may mark the assembly layer as measured this run. +FAMILY_OPS = { + "matmul": ("matmul",), + "elementwise": ("add", "sub", "mul", "div"), + "softmax": ("softmax",), + "rmsnorm": ("pow", "reduce_mean", "add", "sqrt", "div", "mul"), + "rope": ("mul", "concat", "cos", "sin", "unsqueeze", "slice", "neg", "add"), + "swiglu": ("matmul", "sigmoid", "mul"), + "gqa": ("unsqueeze", "expand", "reshape", "transpose", "matmul", "mul", "add", "softmax"), +} + + +def opcode_name(op_type): + """The IR opcode value the frontend lowers an ONNX op type to.""" + lowered = str(op_type).lower() + return "reduce_mean" if lowered == "reducemean" else lowered + + +def _parse(path): + try: + return ast.parse(path.read_text(encoding="utf-8")) + except (OSError, SyntaxError, UnicodeDecodeError, RecursionError): + return None + + +def frontend_operators(): + """ONNX op types the frontend accepts, from its ``_handle_`` methods.""" + tree = _parse(FRONTEND_SOURCE) + if tree is None: + return [] + names = {node.name[len("_handle_"):] for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name.startswith("_handle_")} + return sorted(names) + + +def backend_opcodes(): + """OpCode values the tensor C backend lowers, from ``TensorCCodegen._infer``.""" + tree = _parse(BACKEND_SOURCE) + if tree is None: + return [] + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "_infer": + found = {child.attr for child in ast.walk(node) + if isinstance(child, ast.Attribute) and isinstance(child.value, ast.Name) + and child.value.id == "OpCode"} + return sorted(attr.lower() for attr in found) + return [] + + +def kernel_bodies(): + """Hand-written RISC-V assembly kernels; W2 does not execute them.""" + try: + return sorted(path.stem for path in KERNEL_BODIES.glob("*.py") if path.stem != "__init__") + except OSError: + return [] + + +def load_report(directory, name): + """Return ``(report, problem)``; a missing or malformed report is a problem. + + Three of the W2 probes publish no ``gate`` field, so identity is the + directory plus the ``passed`` key every one of them records. + """ + path = directory / name / "report.json" + try: + with path.open("rb") as stream: + payload = stream.read(MAX_REPORT_BYTES + 1) + if len(payload) > MAX_REPORT_BYTES: + return None, "报告过大" + report = json.loads(payload.decode("utf-8")) + except FileNotFoundError: + return None, "报告缺失" + except (OSError, ValueError, UnicodeDecodeError, RecursionError): + return None, "报告无法解析" + if not isinstance(report, dict) or "passed" not in report: + return None, "报告结构不符" + return report, "" + + +def _cases(report): + rows = report.get("cases", []) if isinstance(report, dict) else [] + return [row for row in rows if isinstance(row, dict)] + + +def _mapping(value): + return value if isinstance(value, dict) else {} + + +def _rows(record, key): + value = record.get(key, []) if isinstance(record, dict) else [] + return [row for row in value if isinstance(row, dict)] + + +def _seconds(value): + if type(value) in (int, float) and math.isfinite(value) and value >= 0: + return float(value) + return None + + +def _onnx_operators(report, name): + """ONNX op types the report recorded for a parsed model.""" + if name == QWEN3_SMALL: + return set(_mapping(_mapping(report.get("onnx")).get("operators"))) + graph = _mapping(report.get("graph")) + inner = _mapping(graph.get("graph")) or graph + return set(_mapping(inner.get("operators"))) + + +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) + + opcodes = set() + # The two-layer model is the only graph the RISC-V QEMU probe compiles, so + # its opcodes count as measured only once a QEMU execution passed. + riscv = reports.get(QWEN3_RISCV) + if riscv is not None and any(row.get("passed") is True + for case in _cases(riscv) for row in _rows(case, "qemu")): + if reports.get(QWEN3_SMALL) is not None: + 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] + 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()) + + +def operator_table(reports): + supported_frontend, supported_backend = frontend_operators(), backend_opcodes() + exercised_frontend, exercised_backend = exercised_operators(reports) + + def listing(names): + return f"{len(names)} 个:" + (", ".join(names) if names else "无") + + 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 "无(本次未执行)" + + rows = [ + ("IR 层 (`ONNXParser._handle_*`)", listing(supported_frontend), + measured(supported_frontend, exercised_frontend)), + ("汇编层 (`tensor_c_codegen`)", listing(supported_backend), + measured(supported_backend, exercised_backend)), + ("`scratchv/backend/kernels`(手写汇编 kernel)", listing(kernel_bodies()), "无(W2 不执行)"), + ] + lines = ["| 层 | 代码里支持的算子 | 本次实测过的 |\n", "| --- | --- | --- |\n"] + lines.extend("| " + " | ".join(cell(value, 4000) for value in row) + " |\n" for row in rows) + lines.append("\n旧的标量后端(`llvm_codegen`、`instruction_select`)不接受带 shape 的张量图" + "(`scratchv/compiler.py:527-530` 会拒绝),所以不计入汇编层。\n") + return lines + + +def precision_comparisons(reports): + comparisons = [] + + def add(label, records): + comparisons.append((label, [row for row in records if isinstance(row, dict)])) + + backend = reports.get(BACKEND_OPS) + if backend is not None: + executions = [row for case in _cases(backend) for row in _rows(case, "executions")] + 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]) + + 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) + + 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")]) + + 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]) + + # These two chains publish structural evidence only; say so instead of omitting them. + add("w2-runtime(文本层,无数值比较)", []) + add("qwen3-parse(完整 28 层前端,无数值比较)", []) + return comparisons + + +def timing_rows(loaded): + rows = [] + + def add(label, name, pick): + report, problem = loaded.get(name, (None, "报告缺失")) + if report is None: + rows.append({"label": label, "status": problem, "seconds": None, "note": ""}) + return + note = "" + if name == QWEN3_RISCV: + timing = _mapping(report.get("timing")) + parts = [] + if _seconds(timing.get("cross_compile_seconds_total")) is not None: + parts.append(f"交叉编译 {timing['cross_compile_seconds_total']:.3f} s") + if _seconds(timing.get("qemu_process_wall_seconds_total")) is not None: + parts.append(f"QEMU 合计 {timing['qemu_process_wall_seconds_total']:.3f} s") + note = ";".join(parts) + rows.append({"label": label, "status": "PASS" if report.get("passed") is True else "FAIL", + "seconds": _seconds(pick(report)), "note": note}) + + add("w2-backend-ops 算子内核", BACKEND_OPS, lambda report: report.get("seconds")) + add("w2-runtime 文本层", RUNTIME, lambda report: report.get("seconds")) + add("w2-runtime-model 文本层↔小模型", RUNTIME_MODEL, lambda report: report.get("seconds")) + add("qwen3-small host IR", QWEN3_SMALL, lambda report: report.get("seconds")) + add("qwen3-small RV64 QEMU", QWEN3_RISCV, + lambda report: _mapping(report.get("timing")).get("pipeline_seconds", report.get("seconds"))) + add("qwen3-parse 完整 28 层前端", QWEN3_PARSE, lambda report: report.get("seconds")) + return rows + + +def timing_table(rows): + lines = ["| 链路 | 步骤结果 | 耗时 |\n", "| --- | --- | --- |\n"] + for row in rows: + seconds = "未记录" if row["seconds"] is None else f"{row['seconds']:.3f} s" + if row["note"]: + seconds += f"({row['note']})" + lines.append("| " + " | ".join(cell(value) for value in (row["label"], row["status"], seconds)) + " |\n") + measured = [row for row in rows if row["seconds"] is not None] + missing = [row["label"] for row in rows if row["seconds"] is None] + total = f"{sum(row['seconds'] for row in measured):.3f} s" + if missing: + total += f";不完整(缺:{', '.join(missing)})" + lines.append("| **总计** | | " + cell(total, 400) + " |\n") + lines.append("\nQEMU 秒数含进程启动、guest 计算、UART 传输和退出,不是纯计算耗时;" + "交叉编译只统计成功的 C→RV64 构建。总计是各链路相加,而两条流水线曾并行运行," + "不等于挂钟时间。\n") + return lines + + +def render(directory): + loaded = {name: load_report(directory, name) for name in DIRECTORIES} + reports = {name: report for name, (report, _) in loaded.items()} + try: + operators = operator_table(reports) + precision = compact_metric_table(precision_comparisons(reports)) + timing = timing_table(timing_rows(loaded)) + except (TypeError, KeyError, ValueError, AttributeError, RecursionError): + # A malformed report must not turn into an apparently complete page. + return ("## W2 测试项\n\n**报告结构不符** — 无法渲染算子、精度与耗时;" + "请检查各 W2 job 的日志与产物。\n") + + lines = ["## W2 测试项\n\n", "### 算子支持\n\n", *operators, + "\n### 精度\n\n", precision, "\n\n", METRIC_EXPLANATION, "\n\n", + "### 耗时\n\n", *timing] + rendered = "".join(lines) + if len(rendered.encode("utf-8")) > MAX_SUMMARY_BYTES: + return ("".join(lines[:1]) + + "内容超出 Actions 页面 64 KiB 的 summary budget,此处省略;" + "完整证据见各 W2 job 的产物。\n") + return rendered + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report-dir", type=Path, default=Path("output")) + parser.add_argument("--summary", type=Path, default=os.environ.get("GITHUB_STEP_SUMMARY")) + args = parser.parse_args(argv) + text = render(args.report_dir) + 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 a probe's exit code. + + +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 00000000..97d18ef5 --- /dev/null +++ b/scripts/summarize_w3_ci.py @@ -0,0 +1,198 @@ +"""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 层随机 Qwen3 / host IR"), + ("subgraphs", "w3_qwen3_subgraphs", "预训练 layer-0 子图 / host IR"), + ("attention", "unit:attention-backend", "小规模 Attention / RISC-V QEMU"), + ("full-preflight", "preflight:w3-full-assets", "完整资产 / 仅静态检查"), + ("layer-diff", "w3_layer_diff", "仅已保存的 medium 模型检查点"), +) + + +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 "未记录" + + +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 "不执行模型" + if gate == "unit:attention-backend": + count = sum(len(row.get("executions", [])) for row in cases) + return f"{len(cases)} cases / {count} target executions 已记录" + 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 已记录" + + +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"
{cell(title)}\n\n{cell(description, 700)}\n\n" + if comparisons: + text += compact_metric_table(comparisons) + "\n\n" + if report.get("error"): + text += "问题: " + cell(report["error"], 400) + "\n\n" + text += "\n\n".join(cell(note, 500) for note in notes) + "\n\n" + index + "
\n\n" + return text + + +def render(kind, directory, outcome): + artifact = "w3-preparation-reports" if kind == "preparation" else "w3-full-numeric-reports" + evidence = (f"产物: **{artifact}**(`report.json`、`report.md`、`report.html`)。" + "原始数组在保留时单独存放。本概要不能替代独立复现或团队验收。\n") + lines = ["## W3 测试项\n\n"] + if outcome in ("skipped", "", "cancelled"): + label = "CANCELLED" if outcome == "cancelled" else "NOT RUN" + lines.append(f"**{label}** — probe step did not complete;未声称数值验收通过。\n\n") + lines.append("既有文件即使存在,也不作为本步骤的结果。\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}** — 步骤结果: **{cell(outcome)}**。未声称验收通过,请检查步骤日志。\n") + return "".join(lines) + evidence + conclusion = status(report) + if outcome != "success" and conclusion == "PASS": + conclusion = "FAIL (step did not succeed)" + lines.append(f"**{conclusion}** · 步骤: {cell(outcome)} · 流水线耗时: {seconds(report)}\n\n") + comparisons, folded = [], [] + try: + if kind == "preparation": + lines.append("仅为准备;完整 28 层 IR 数值与完整模型 QEMU **在本 job 中不运行**。\n\n") + lines.append("| 检查 / 范围 | 结果 | Recorded coverage | 流水线耗时 |\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)** · 步骤: {cell(outcome)} · 流水线耗时: {seconds(report)}\n\n" + lines.append("| " + " | ".join(cell(x) for x in (scope, child_status, coverage(child) if child else "未记录", seconds(row) if row else "未记录")) + " |\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 层预训练 Qwen3,FP32 / L=256 / 无 KV cache;IR vs ORT。**本 job 无完整模型 QEMU 结果。**\n\n") + lines.append(f"覆盖: **{cell(coverage(report))}** · FP32 模式: **{cell(report.get('fp32_mode', '未记录'))}** · 最大绝对误差阈值: **{cell(report.get('atol', '未记录'))}**(严格 <,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, "用例、诊断、不变量与失败")) + if comparisons: + lines.append("\n" + compact_metric_table(comparisons) + "\n\n") + lines.append("指标只汇总最终输出(已记录比较中最差 MaxAbs / 相对 L2、最低余弦);检查点仍按原门槛判定。缺失指标保持显式。\n\n") + if report.get("error"): + lines.append("问题: " + 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"## W3 测试项\n\n**INVALID REPORT** — 无法渲染数值证据;步骤: {cell(outcome)}。未声称验收通过。\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]) + + "数值详情超出 Actions 页面 64 KiB 的 summary budget,此处省略。" + "上方记录的门槛结果不变;完整指标与范围请查看产物。\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/verify_w3_evidence.py b/scripts/verify_w3_evidence.py new file mode 100644 index 00000000..e4bb4fdd --- /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_ir_interpreter_fp32_eigen.py b/tests/test_ir_interpreter_fp32_eigen.py new file mode 100644 index 00000000..ab671f13 --- /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 00000000..86048cef --- /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 00000000..f499da72 --- /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 00000000..77396568 --- /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 00000000..c026fe52 --- /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_linux_repro_docs.py b/tests/test_linux_repro_docs.py new file mode 100644 index 00000000..b528a79b --- /dev/null +++ b/tests/test_linux_repro_docs.py @@ -0,0 +1,67 @@ +"""Check rejection of unusable public reproduction commands, not doc wording.""" +import shutil + +import pytest + +from scripts import check_linux_repro_docs as docs + + +@pytest.mark.parametrize("content", [ + "```powershell\npython run.py\n```\n", + "~~~pwsh\npython run.py\n~~~\n", + "Use D:/private/models for the checkpoint\n", + "python C:\\private\\run.py\n", + "$env:OMP_NUM_THREADS = '1'\n", + "$LASTEXITCODE\n", + "venv/Scripts/python.exe\n", + "```bash\nzig.exe version\n```\n", + "```bash\necho unfinished\n", +]) +def test_rejects_non_linux_or_broken_examples(tmp_path, content): + path = tmp_path / "README.md" + path.write_text(content, encoding="utf-8") + assert docs.inspect_document(path)[0] + + +def test_linux_examples_and_truthful_history_are_allowed(tmp_path): + path = tmp_path / "README.md" + path.write_text('Historical Windows results are not Linux evidence.\n' + 'https://example.com/source\n' + '```bash\nset -euo pipefail\n' + '"$SCRATCHV_PYTHON" run.py --model-dir /srv/models/qwen3\n```\n', encoding="utf-8") + assert docs.inspect_document(path) == ([], 1) + + +def test_required_bash_cannot_silently_skip(monkeypatch): + monkeypatch.setattr(docs.shutil, "which", lambda _: None) + with pytest.raises(SystemExit) as exc: + docs.main(["--check-bash"]) + assert exc.value.code == 1 + + +def test_empty_input_is_not_success(tmp_path): + with pytest.raises(SystemExit) as exc: + docs.main(["--root", str(tmp_path)]) + assert exc.value.code == 1 + + +def test_inventory_covers_shared_guides_and_benchmarks(tmp_path): + names = ("README.md", "CONTRIBUTING.md", "docs/guide/setup.md", + "docs/topics/topic/reproduce.md", "probes/w4/example.md", "benchmarks/sample/README.md") + for name in names: + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("Linux reproduction\n", encoding="utf-8") + assert {p.relative_to(tmp_path).as_posix() for p in docs.document_paths(tmp_path)} == set(names) + + +def test_actual_bash_rejects_bad_syntax_without_executing(tmp_path): + bash = shutil.which("bash") + if not bash: + pytest.skip("Bash is required; Linux CI exercises this case") + path = tmp_path / "README.md" + marker = tmp_path / "must-not-exist" + path.write_text(f'```bash\ntouch "{marker.as_posix()}"\nif then\n```\n', encoding="utf-8") + problems, count = docs.inspect_document(path, bash=bash) + assert count == 1 and any("invalid Bash" in item for item in problems) + assert not marker.exists() diff --git a/tests/test_llm_inputs.py b/tests/test_llm_inputs.py new file mode 100644 index 00000000..8aebbc5e --- /dev/null +++ b/tests/test_llm_inputs.py @@ -0,0 +1,228 @@ +"""Fixed Qwen3 host ABI, padding, finite greedy selection and stop boundaries.""" + +import numpy as np +import pytest + +from scratchv.runtime.llm_inputs import ( + QWEN3_VOCAB_SIZE, SEQ_LENGTH, build_attention_mask, generation_stop_reason, + greedy_next_token, greedy_token, last_valid_logits, prepare_inputs, +) + + +def test_prepare_inputs_preserves_prompt_and_does_not_infer_length_from_pad_id(): + original = np.array([7, 0, 9], dtype=np.int32) + prepared = prepare_inputs(original, pad_token_id=0, vocab_size=10) + original[:] = 1 + assert prepared.valid_length == 3 + assert prepared.input_ids.shape == (1, 256) + assert prepared.input_ids.dtype == np.int64 + np.testing.assert_array_equal(prepared.input_ids[0, :3], [7, 0, 9]) + assert np.all(prepared.input_ids[0, 3:] == 0) + assert prepared.input_ids.flags.c_contiguous + assert prepared.attention_mask.flags.c_contiguous + assert prepared.as_feed() == { + "input_ids": prepared.input_ids, "attention_mask": prepared.attention_mask, + } + assert set(prepared.as_feed()) == {"input_ids", "attention_mask"} + + +def test_prepare_inputs_accepts_maximum_int64_without_rounding_through_float(): + maximum = int(np.iinfo(np.int64).max) + prepared = prepare_inputs([maximum - 1, maximum], pad_token_id=maximum, + vocab_size=maximum + 1) + assert int(prepared.input_ids[0, 0]) == maximum - 1 + assert int(prepared.input_ids[0, 1]) == maximum + assert int(prepared.input_ids[0, -1]) == maximum + + +def test_prepare_inputs_defaults_to_full_qwen_vocabulary(): + prepared = prepare_inputs([QWEN3_VOCAB_SIZE - 1], pad_token_id=151643) + assert prepared.input_ids[0, 0] == 151935 + assert prepared.input_ids[0, 1] == 151643 + + +def test_prepare_inputs_accepts_full_capacity_without_truncation(): + prepared = prepare_inputs(tuple(range(256)), pad_token_id=0, vocab_size=256) + assert prepared.valid_length == 256 + np.testing.assert_array_equal(prepared.input_ids[0], np.arange(256)) + + +@pytest.mark.parametrize("tokens", [[], [1] * 257]) +def test_prepare_inputs_rejects_empty_and_oversize_prompts(tokens): + with pytest.raises(ValueError, match="valid_length"): + prepare_inputs(tokens, pad_token_id=0, vocab_size=2) + + +@pytest.mark.parametrize("tokens,exception", [ + ([-1], ValueError), ([4], ValueError), ([2**64], ValueError), + ([True], TypeError), ([np.bool_(False)], TypeError), ([1.0], TypeError), + (["1"], TypeError), ([None], TypeError), ("123", TypeError), + (b"123", TypeError), (iter([1]), TypeError), (1, TypeError), + (np.array([[1]], dtype=np.int64), ValueError), + (np.array([1.0], dtype=np.float32), TypeError), + (np.array([1], dtype=object), TypeError), + (np.array([2**64 - 1], dtype=np.uint64), ValueError), +]) +def test_prepare_inputs_rejects_invalid_ids_without_casting_them(tokens, exception): + with pytest.raises(exception): + prepare_inputs(tokens, pad_token_id=0, vocab_size=4) + + +@pytest.mark.parametrize("kwargs,exception", [ + ({"pad_token_id": -1}, ValueError), ({"pad_token_id": 4}, ValueError), + ({"pad_token_id": True}, TypeError), ({"pad_token_id": 0.0}, TypeError), + ({"vocab_size": 0}, ValueError), ({"vocab_size": -1}, ValueError), + ({"vocab_size": True}, TypeError), ({"vocab_size": 4.0}, TypeError), + ({"vocab_size": 2**63 + 1}, ValueError), +]) +def test_prepare_inputs_rejects_invalid_metadata(kwargs, exception): + options = {"pad_token_id": 0, "vocab_size": 4} + options.update(kwargs) + with pytest.raises(exception): + prepare_inputs([1], **options) + + +@pytest.mark.parametrize("length", [1, 17, 255, 256]) +def test_attention_mask_blocks_future_and_padding_keys_but_not_all_padded_queries(length): + mask = build_attention_mask(length) + blocked = np.finfo(np.float32).min + assert mask.shape == (1, 1, 256, 256) + assert mask.dtype == np.float32 + assert np.isfinite(mask).all() + assert set(np.unique(mask)) == {np.float32(0), blocked} + # Counts independently characterize every row, including padded queries. + counts = np.count_nonzero(mask[0, 0] == 0, axis=1) + np.testing.assert_array_equal(counts, [min(q + 1, length) for q in range(256)]) + assert mask[0, 0, 0, 0] == 0 + assert np.all(mask[0, 0, 0, 1:] == blocked) + assert np.all(mask[0, 0, 255, :length] == 0) + if length < SEQ_LENGTH: + assert np.all(mask[0, 0, :, length:] == blocked) + assert mask[0, 0, length, length - 1] == 0 + + +def test_mask_suppresses_large_padding_scores_and_never_hides_an_earlier_valid_key(): + mask = build_attention_mask(3)[0, 0] + scores = np.full((256, 256), 1000, dtype=np.float32) + scores[:, :3] = 0 + masked = scores + mask + weights = np.exp(masked - masked.max(axis=-1, keepdims=True)) + weights /= weights.sum(axis=-1, keepdims=True) + np.testing.assert_array_equal(weights[:, 3:], 0) + np.testing.assert_array_equal(weights[0, :3], [1, 0, 0]) + np.testing.assert_array_equal(weights[1, :3], [0.5, 0.5, 0]) + np.testing.assert_allclose(weights[2:, :3], np.float32(1 / 3), rtol=0, atol=0) + + +@pytest.mark.parametrize("length,exception", [ + (0, ValueError), (-1, ValueError), (257, ValueError), + (True, TypeError), (np.bool_(True), TypeError), (1.0, TypeError), (None, TypeError), +]) +def test_mask_rejects_invalid_lengths(length, exception): + with pytest.raises(exception, match="valid_length"): + build_attention_mask(length) + + +@pytest.mark.parametrize("length", [1, 17, 255, 256]) +def test_greedy_uses_last_valid_position_and_lowest_id_tie(length): + logits = np.zeros((1, 256, 8), dtype=np.float32) + logits[0, :, 7] = 100 + logits[0, length - 1, :] = [-9, 2, -1, 2, -5, 0, -3, 1] + selected = last_valid_logits(logits, length, vocab_size=8) + assert selected.shape == (8,) + assert selected.dtype == np.float32 + assert not np.shares_memory(selected, logits) + assert greedy_next_token(logits, length, vocab_size=8) == 1 + selected[:] = 0 + assert logits[0, length - 1, 1] == 2 + + +def test_greedy_supports_noncontiguous_fp32_view_and_negative_logits(): + values = np.array([-9, 0, -1, 0, -3, 0, -1, 0], dtype=np.float32)[::2] + assert not values.flags.c_contiguous + assert greedy_token(values) == 1 + assert greedy_token(np.array([-0.0, 0.0], dtype=np.float32)) == 0 + + +@pytest.mark.parametrize("logits,exception", [ + ([0, 1], TypeError), (np.zeros(3, dtype=np.float64), TypeError), + (np.zeros(3, dtype=np.float16), TypeError), (np.zeros(3, dtype=np.int64), TypeError), + (np.zeros(0, dtype=np.float32), ValueError), (np.zeros((), dtype=np.float32), ValueError), + (np.zeros((1, 3), dtype=np.float32), ValueError), + (np.array([np.nan, 0], dtype=np.float32), ValueError), + (np.array([0, np.inf], dtype=np.float32), ValueError), + (np.array([-np.inf, 0], dtype=np.float32), ValueError), +]) +def test_greedy_rejects_malformed_or_nonfinite_vocabulary(logits, exception): + with pytest.raises(exception): + greedy_token(logits) + + +def test_masked_arrays_cannot_hide_invalid_ids_or_nan_logits(): + ids = np.ma.array([1, -1], mask=[False, True], dtype=np.int64) + with pytest.raises(TypeError, match="masked array"): + prepare_inputs(ids, pad_token_id=0, vocab_size=8) + scores = np.ma.array([0, np.nan], mask=[False, True], dtype=np.float32) + with pytest.raises(TypeError, match="masked array"): + greedy_token(scores) + + +@pytest.mark.parametrize("shape", [(256, 8), (1, 255, 8), (2, 256, 8), (1, 256, 9)]) +def test_last_valid_logits_rejects_invalid_model_output_shape(shape): + with pytest.raises(ValueError, match="shape"): + last_valid_logits(np.zeros(shape, dtype=np.float32), 3, vocab_size=8) + + +def test_last_valid_logits_rejects_implicit_list_and_dtype_conversion(): + logits = np.zeros((1, 256, 8), dtype=np.float64) + with pytest.raises(TypeError, match="float32"): + last_valid_logits(logits, 3, vocab_size=8) + with pytest.raises(TypeError, match="ndarray"): + last_valid_logits(logits.tolist(), 3, vocab_size=8) + + +@pytest.mark.parametrize("position", [0, 2, 255]) +@pytest.mark.parametrize("invalid", [np.nan, np.inf, -np.inf]) +def test_full_output_must_be_finite_including_unused_padding(position, invalid): + logits = np.zeros((1, 256, 8), dtype=np.float32) + logits[0, position, 7] = invalid + with pytest.raises(ValueError, match="finite"): + greedy_next_token(logits, 3, vocab_size=8) + + +def stop_reason(**kwargs): + options = {"valid_length": 3, "generated_tokens": 0, "max_new_tokens": 10, + "vocab_size": 8, "eos_token_ids": [6, 7]} + options.update(kwargs) + return generation_stop_reason(**options) + + +def test_stop_reason_keeps_prompt_eos_and_stops_on_either_generated_eos(): + assert stop_reason(last_token_id=7) is None + assert stop_reason(generated_tokens=1, last_token_id=6) == "eos" + assert stop_reason(generated_tokens=1, last_token_id=7) == "eos" + assert stop_reason(generated_tokens=1, last_token_id=5) is None + + +def test_stop_reason_respects_zero_budget_exact_budget_and_capacity(): + assert stop_reason(max_new_tokens=0) == "max_new_tokens" + assert stop_reason(generated_tokens=2, max_new_tokens=2, last_token_id=1) == "max_new_tokens" + assert stop_reason(valid_length=255) is None + assert stop_reason(valid_length=256) == "capacity" + assert stop_reason(valid_length=256, generated_tokens=1, last_token_id=7, + max_new_tokens=1) == "eos" + assert stop_reason(valid_length=256, generated_tokens=1, last_token_id=1, + max_new_tokens=1) == "capacity" + + +@pytest.mark.parametrize("kwargs,exception", [ + ({"valid_length": 257}, ValueError), ({"generated_tokens": -1}, ValueError), + ({"generated_tokens": 3}, ValueError), ({"generated_tokens": True}, TypeError), + ({"generated_tokens": 1}, ValueError), ({"max_new_tokens": -1}, ValueError), + ({"max_new_tokens": 1.5}, TypeError), ({"last_token_id": 8}, ValueError), + ({"eos_token_ids": [8]}, ValueError), ({"eos_token_ids": [False]}, TypeError), + ({"eos_token_ids": 7}, TypeError), +]) +def test_stop_reason_rejects_inconsistent_state_or_invalid_configuration(kwargs, exception): + with pytest.raises(exception): + stop_reason(**kwargs) diff --git a/tests/test_numeric_metrics.py b/tests/test_numeric_metrics.py new file mode 100644 index 00000000..6f4aee84 --- /dev/null +++ b/tests/test_numeric_metrics.py @@ -0,0 +1,154 @@ +"""Independent metric formulas, extreme ranges and unchanged gate decisions.""" +import json +import math + +import numpy as np +import pytest + +from scratchv.verification.numeric_metrics import numeric_metrics + + +@pytest.mark.parametrize("actual,reference,cosine,relative", [ + ([3, 4], [3, 4], 1, 0), + ([6, 8], [3, 4], 1, 1), + ([-3, -4], [3, 4], -1, 2), + ([1, 0], [0, 1], 0, math.sqrt(2)), + ([0, 0], [3, 4], None, 1), + ([0, 0], [0, 0], None, 0), + ([3, 4], [0, 0], None, None), +]) +def test_analytic_vectors(actual, reference, cosine, relative): + result = numeric_metrics(np.array(actual, dtype=np.float64), + np.array(reference, dtype=np.float64), block_elements=1) + for key, value in (("cosine_similarity", cosine), ("relative_l2", relative)): + if value is None: + assert result[key] is None and result[key + "_reason"] + else: + assert result[key] == pytest.approx(value, rel=2e-15, abs=1e-15) + assert result[key + "_reason"] is None + json.dumps(result, allow_nan=False) + + +@pytest.mark.parametrize("scale", [np.finfo(np.float64).max, 1e-300, + np.nextafter(np.float64(0), np.float64(1))]) +def test_squares_and_differences_do_not_overflow_or_erase_tiny_values(scale): + reference = np.array([scale, scale], dtype=np.float64) + opposite = numeric_metrics(-reference, reference, block_elements=1) + assert opposite["relative_l2"] == pytest.approx(2) + assert opposite["cosine_similarity"] == pytest.approx(-1) + orthogonal = numeric_metrics(np.array([scale, -scale]), reference) + assert orthogonal["relative_l2"] == pytest.approx(math.sqrt(2)) + assert orthogonal["cosine_similarity"] == 0 + + +def test_each_vector_has_its_own_scale_for_cosine(): + maximum = np.finfo(np.float64).max + minimum = np.nextafter(np.float64(0), np.float64(1)) + result = numeric_metrics(np.array([maximum]), np.array([minimum])) + assert result["cosine_similarity"] == 1 + assert result["relative_l2"] is None + assert "exceeds" in result["relative_l2_reason"] + result = numeric_metrics(np.array([maximum, minimum]), np.array([maximum, 0])) + assert result["cosine_similarity"] == 1 + assert result["relative_l2"] is None + assert "below" in result["relative_l2_reason"] + + +@pytest.mark.parametrize("dtype,base", [(np.int64, 2**63 - 2), (np.uint64, 2**64 - 2)]) +def test_integer_neighbours_keep_nonzero_difference_above_fp64_exact_range(dtype, base): + actual, expected = np.array([base + 1], dtype), np.array([base], dtype) + result = numeric_metrics(actual, expected) + assert result["relative_l2"] == pytest.approx(1 / base, rel=2e-15, abs=0) + assert result["relative_l2"] > 0 + assert result["cosine_similarity"] == 1 + + +def test_signed_integer_subtraction_cannot_wrap(): + limits = np.iinfo(np.int64) + result = numeric_metrics(np.array([limits.min]), np.array([limits.max])) + assert result["relative_l2"] == pytest.approx((2**64 - 1) / (2**63 - 1)) + assert result["cosine_similarity"] == -1 + + +@pytest.mark.parametrize("actual,expected,reason", [ + (None, np.ones(1), "NumPy arrays"), + (np.ones(2), np.ones(1), "shape mismatch"), + (np.ones(1, np.float32), np.ones(1, np.float64), "dtype mismatch"), + (np.ones(1, complex), np.ones(1, complex), "real numeric"), + (np.empty((2, 0)), np.empty((2, 0)), "empty"), + (np.array([1, np.nan]), np.ones(2), "nonfinite"), + (np.array([1, np.inf]), np.array([1, np.inf]), "nonfinite"), +]) +def test_undefined_inputs_have_explicit_reasons(actual, expected, reason): + result = numeric_metrics(actual, expected) + for key in ("relative_l2", "cosine_similarity"): + assert result[key] is None and reason in result[key + "_reason"] + json.dumps(result, allow_nan=False) + + +@pytest.mark.parametrize("block", [0, -1, True, 1.5]) +def test_bad_block_size_is_rejected(block): + with pytest.raises(ValueError, match="positive integer"): + numeric_metrics(np.ones(1), np.ones(1), block_elements=block) + + +@pytest.mark.parametrize("block", [1, 7, 262144]) +def test_strided_memmap_matches_independent_vector_formula(tmp_path, block): + source = np.lib.format.open_memmap(tmp_path / "arrays.npy", mode="w+", + dtype=np.float32, shape=(2, 23, 37)) + source[:] = np.random.default_rng(487).normal(size=source.shape) + actual, expected = source[0, ::2, ::-3].T, source[1, ::2, ::-3].T + assert not actual.flags.c_contiguous + a, b = actual.astype(np.float64).ravel(), expected.astype(np.float64).ravel() + result = numeric_metrics(actual, expected, block_elements=block) + assert result["relative_l2"] == pytest.approx(np.linalg.norm(a - b) / np.linalg.norm(b), rel=3e-15) + assert result["cosine_similarity"] == pytest.approx(np.dot(a, b) / np.linalg.norm(a) / np.linalg.norm(b), + rel=3e-14, abs=1e-16) + + +def test_iteration_never_exceeds_requested_buffer(monkeypatch): + import scratchv.verification.numeric_metrics as module + original = module._chunks + lengths = [] + + def observed(*args): + for a, b in original(*args): + lengths.append(a.size) + yield a, b + + monkeypatch.setattr(module, "_chunks", observed) + array = np.arange(20000, dtype=np.float32).reshape(100, 200).T[:, ::2] + result = numeric_metrics(array, array + 1, block_elements=127) + assert result["relative_l2"] > 0 + assert len(lengths) > 2 and max(lengths) <= 127 + + +@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 + 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) + assert not result["passed"] and result["max_abs"] == 2 + assert result["cosine_similarity"] == 1 + assert result["relative_l2"] == 1 + passed = compare(actual, actual) + assert passed["passed"] and passed["max_abs"] == 0 + assert passed["cosine_similarity"] == 1 and passed["relative_l2"] == 0 + invalid = compare(actual, expected.astype(np.float64)) + assert not invalid["passed"] + for key in ("relative_l2", "cosine_similarity"): + assert invalid[key] is None and invalid[key + "_reason"] + + +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) + 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_numeric_summary.py b/tests/test_numeric_summary.py new file mode 100644 index 00000000..432055f9 --- /dev/null +++ b/tests/test_numeric_summary.py @@ -0,0 +1,123 @@ +"""Keep concise reports truthful when evidence is missing or undefined.""" +from html import unescape +import re + +import pytest + +from probes.numeric_summary import ( + METRIC_EXPLANATION, + compact_metric_table, + compact_metric_table_html, +) + + +def record(**updates): + row = dict(passed=True, max_abs=1e-6, relative_l2=2e-7, cosine_similarity=0.999) + row.update(updates) + return row + + +def test_worst_metrics_are_aggregated_independently_without_mutating_evidence(): + rows = [record(), {"passed": True, "max_abs": 3e-6, "relative_l2": 1e-8, "cosine_similarity": 0.9}] + original = [dict(row) for row in rows] + result = compact_metric_table([("IR vs ORT", rows)]) + assert format(3e-6, ".16e") in result + assert format(2e-7, ".16e") in result + assert format(0.9, ".17g") in result + assert "门槛通过(2/2)" in result + assert rows == original + + +@pytest.mark.parametrize("render", [compact_metric_table, compact_metric_table_html]) +def test_legacy_pass_does_not_claim_complete_metric_evidence(render): + result = render([("legacy", [{"passed": True, "max_abs": 0.0}])]) + assert "门槛通过(1/1);指标不完整" in result + assert result.count("未记录 1/1") == 2 + + +@pytest.mark.parametrize("rows", [[], [{}], [{"passed": "true"}], [{"passed": 1}]]) +def test_empty_missing_and_nonboolean_status_never_pass(rows): + result = compact_metric_table([("unexecuted", rows)]) + assert "门槛通过" not in result + assert "未测" in result or "未确认 1" in result + + +def test_failures_are_not_hidden_by_passing_rows(): + failed = record() + failed["passed"] = False + result = compact_metric_table([("mixed", [record(), failed, {}])]) + assert "门槛未通过(通过 1/3,失败 1,未确认 1)" in result + assert "已记录 2/3" in result + assert "未记录 1/3" 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") + result = compact_metric_table([("partial", [record(), undefined, {}])]) + assert "未定义 1/3(zero reference norm ×1)" in result + assert "未定义 1/3(zero norm ×1)" in result + assert "已记录 1/3" in result + assert "未记录 1/3" in result + + +def test_undefined_general_reason_is_preserved(): + result = compact_metric_table([("bad", [{"passed": False, "max_abs": None, "reason": "shape mismatch"}])]) + assert "未定义 1/1(shape mismatch ×1)" in result + + +@pytest.mark.parametrize("invalid", [float("nan"), float("inf"), -1.0, True, "0"]) +def test_invalid_error_values_cannot_be_reported_as_numeric_evidence(invalid): + row = record() + row["relative_l2"] = invalid + result = compact_metric_table([("bad", [row])]) + assert "未定义 1/1(数值无效 ×1)" in result + assert "指标不完整" in result + + +def test_small_nonzero_errors_and_near_one_cosines_remain_distinguishable(): + row = dict(passed=True, max_abs=5e-324, relative_l2=1e-100, cosine_similarity=0.9999999999999999) + result = compact_metric_table([("tiny", [row])]) + assert format(5e-324, ".16e") in result + assert format(1e-100, ".16e") in result + assert "0.99999999999999989" in result + + +def test_cosine_domain_and_existing_gate_flags_are_not_reinterpreted(): + # Cosine observes directional similarity; it does not impose a new gate. + result = compact_metric_table([("opposite", [record(cosine_similarity=-1.0)])]) + assert "门槛通过(1/1)" in result and "未定义" not in result + invalid = compact_metric_table([("invalid", [record(cosine_similarity=1.1)])]) + assert "未定义 1/1(数值无效 ×1)" in invalid + + +def test_entirely_undefined_metrics_do_not_acquire_a_numeric_value(): + row = dict(passed=False, max_abs=None, relative_l2=None, cosine_similarity=None) + result = compact_metric_table([("undefined", [row])]) + assert result.count("未定义 1/1(未提供原因 ×1)") == 3 + assert "已记录" not in result + + +def test_html_and_markdown_have_same_values_order_and_safe_untrusted_text(): + labels = ["second " + front = riscv._report_views(report)[filename].split("" not in front + assert "<script>" in front + + +def test_report_rendering_does_not_mutate_numeric_evidence(): + for report, render in ((host_report(), run._host_report_views), (qemu_report(), riscv._report_views)): + before = deepcopy(report) + render(report) + assert report == before diff --git a/tests/test_qwen3_tokenizer.py b/tests/test_qwen3_tokenizer.py new file mode 100644 index 00000000..81eb29d1 --- /dev/null +++ b/tests/test_qwen3_tokenizer.py @@ -0,0 +1,221 @@ +"""Offline contract tests; official assets are checked by the runtime probe.""" + +import json +from pathlib import Path + +import numpy as np +import pytest + +pytest.importorskip("tokenizers") +from tokenizers import AddedToken, Tokenizer, decoders, models, normalizers, pre_tokenizers, processors + +from scratchv.runtime.qwen3_tokenizer import Qwen3Tokenizer + + +@pytest.fixture +def source(tmp_path): + alphabet = sorted(pre_tokenizers.ByteLevel.alphabet()) + engine = Tokenizer(models.BPE(vocab={s: i for i, s in enumerate(alphabet)}, merges=[])) + engine.normalizer = normalizers.NFC() + engine.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False) + engine.decoder = decoders.ByteLevel() + engine.post_processor = processors.ByteLevel(trim_offsets=False) + engine.add_special_tokens([ + AddedToken("<|endoftext|>", special=True, normalized=False), + AddedToken("<|im_start|>", special=True, normalized=False), + AddedToken("<|im_end|>", special=True, normalized=False), + ]) + engine.add_tokens([AddedToken("", special=False, normalized=False)]) + engine.save(str(tmp_path / "tokenizer.json")) + added = { + str(token_id): {name: getattr(token, name) for name in ( + "content", "lstrip", "rstrip", "single_word", "normalized", "special" + )} + for token_id, token in engine.get_added_tokens_decoder().items() + } + _write(tmp_path, "tokenizer_config.json", { + "tokenizer_class": "Qwen2Tokenizer", "add_bos_token": False, + "bos_token": None, "unk_token": None, "eos_token": "<|im_end|>", + "pad_token": "<|endoftext|>", "clean_up_tokenization_spaces": False, + "added_tokens_decoder": added, "additional_special_tokens": ["<|im_start|>", "<|im_end|>"], + }) + _write(tmp_path, "config.json", {"model_type": "qwen3", "vocab_size": 288, "bos_token_id": 256}) + _write(tmp_path, "generation_config.json", {"eos_token_id": [258, 256], "pad_token_id": 256}) + return tmp_path + + +def _write(directory: Path, filename: str, value: dict): + (directory / filename).write_text(json.dumps(value), encoding="utf-8") + + +def _change(directory, filename, **changes): + data = json.loads((directory / filename).read_text(encoding="utf-8")) + data.update(changes) + _write(directory, filename, data) + + +def test_metadata_distinguishes_vocab_and_generation_eos(source): + tokenizer = Qwen3Tokenizer.from_directory(source) + assert tokenizer.base_vocab_size == 256 + assert tokenizer.tokenizer_vocab_size == 260 + assert tokenizer.model_vocab_size == 288 + assert tokenizer.pad_token_id == 256 + assert tokenizer.eos_token_id == 258 + assert tokenizer.generation_eos_token_ids == (258, 256) + assert tokenizer.bos_token_id is None + assert tokenizer.unk_token_id is None + assert tokenizer.valid_token_ids == frozenset(range(260)) + assert tokenizer.is_valid_token_id(np.int64(259)) + assert not tokenizer.is_valid_token_id(260) + assert not tokenizer.is_valid_token_id(True) + + +def test_missing_unk_uses_official_default_but_explicit_null_disables_it(source): + config = json.loads((source / "tokenizer_config.json").read_text(encoding="utf-8")) + assert Qwen3Tokenizer.from_directory(source).unk_token_id is None + del config["unk_token"] + _write(source, "tokenizer_config.json", config) + assert Qwen3Tokenizer.from_directory(source).unk_token_id == 256 + + +@pytest.mark.parametrize("text", ["", "Hello, world!", " a\t\r\nb ", "中文日本語", "😀👩‍💻", "a\x00b"]) +def test_text_round_trip_and_no_implicit_special_tokens(source, text): + tokenizer = Qwen3Tokenizer.from_directory(source) + ids = tokenizer.encode(text) + assert ids == tokenizer.encode(text, add_special_tokens=False) + assert not set(ids) & {256, 257, 258} + assert tokenizer.decode(ids) == text + + +def test_normalization_is_official_json_behavior(source): + tokenizer = Qwen3Tokenizer.from_directory(source) + assert tokenizer.encode("e\u0301") == tokenizer.encode("é") + assert tokenizer.decode(tokenizer.encode("e\u0301")) == "é" + + +def test_special_tokens_and_ordinary_added_tokens(source): + tokenizer = Qwen3Tokenizer.from_directory(source) + text = "a<|im_start|><|im_end|><|endoftext|>b" + ids = tokenizer.encode(text) + assert all(token_id in ids for token_id in (256, 257, 258, 259)) + assert tokenizer.decode(ids) == text + assert tokenizer.decode(ids, skip_special_tokens=True) == "ab" + assert tokenizer.decode(np.int64(258)) == "<|im_end|>" + assert tokenizer.decode(np.array([257, 258], dtype=np.int64)) == "<|im_start|><|im_end|>" + + +def test_cleanup_is_opt_in_and_matches_transformers_rules(source): + tokenizer = Qwen3Tokenizer.from_directory(source) + text = "I 'm sure , you 're here . We 've seen it ! He 's fine ? I do n't know ." + ids = tokenizer.encode(text) + assert tokenizer.decode(ids) == text + assert tokenizer.decode(ids, clean_up_tokenization_spaces=True) == "I'm sure, you're here. We've seen it! He's fine? I don't know." + _change(source, "tokenizer_config.json", clean_up_tokenization_spaces=True) + configured = Qwen3Tokenizer.from_directory(source) + assert configured.decode(ids) == tokenizer.decode(ids, clean_up_tokenization_spaces=True) + assert configured.decode(ids, clean_up_tokenization_spaces=False) == text + + +@pytest.mark.parametrize("text", [None, 12, b"text", ["text"]]) +def test_encode_rejects_non_text(source, text): + with pytest.raises(TypeError, match="text must be str"): + Qwen3Tokenizer.from_directory(source).encode(text) + + +def test_encode_rejects_invalid_unicode(source): + with pytest.raises(UnicodeEncodeError): + Qwen3Tokenizer.from_directory(source).encode("\ud800") + + +@pytest.mark.parametrize("ids", [ + [True], [1.0], ["1"], [[1]], [None], "123", b"123", bytearray(b"123"), + None, True, {1, 2}, {1: "one"}, +]) +def test_decode_rejects_noninteger_ids(source, ids): + with pytest.raises(TypeError): + Qwen3Tokenizer.from_directory(source).decode(ids) + + +@pytest.mark.parametrize("ids", [[-1], [260], [287], [288], [2**80]]) +def test_decode_refuses_unknown_or_unused_model_rows(source, ids): + with pytest.raises(ValueError, match="not in the tokenizer vocabulary"): + Qwen3Tokenizer.from_directory(source).decode(ids) + + +@pytest.mark.parametrize("option", ["add_special_tokens", "skip_special_tokens", "clean_up_tokenization_spaces"]) +def test_flags_are_boolean(source, option): + tokenizer = Qwen3Tokenizer.from_directory(source) + with pytest.raises(TypeError, match="must be bool"): + if option == "add_special_tokens": + tokenizer.encode("text", **{option: 1}) + else: + tokenizer.decode([], **{option: 1}) + + +@pytest.mark.parametrize(("filename", "changes", "message"), [ + ("config.json", {"model_type": "other"}, "model_type"), + ("config.json", {"vocab_size": True}, "vocab_size"), + ("config.json", {"vocab_size": 259}, "vocab_size"), + ("tokenizer_config.json", {"tokenizer_class": "OtherTokenizer"}, "tokenizer_class"), + ("tokenizer_config.json", {"add_bos_token": True}, "add_bos_token"), + ("tokenizer_config.json", {"add_prefix_space": True}, "add_prefix_space"), + ("tokenizer_config.json", {"split_special_tokens": True}, "split_special_tokens"), + ("tokenizer_config.json", {"mask_token": ""}, "mask_token"), + ("tokenizer_config.json", {"sep_token": ""}, "sep_token"), + ("tokenizer_config.json", {"cls_token": ""}, "cls_token"), + ("tokenizer_config.json", {"extra_special_tokens": {"image_token": ""}}, "extra_special_tokens"), + ("tokenizer_config.json", {"pad_token": "absent"}, "pad_token"), + ("tokenizer_config.json", {"additional_special_tokens": [""]}, "special token"), + ("tokenizer_config.json", {"added_tokens_decoder": {}}, "added token IDs differ"), + ("generation_config.json", {"eos_token_id": []}, "eos_token_id"), + ("generation_config.json", {"eos_token_id": [256]}, "include the tokenizer EOS"), + ("generation_config.json", {"eos_token_id": [258, 280]}, "not in the tokenizer vocabulary"), + ("generation_config.json", {"pad_token_id": 257}, "pad_token_id"), +]) +def test_configuration_mismatch_is_not_silently_ignored(source, filename, changes, message): + _change(source, filename, **changes) + with pytest.raises(ValueError, match=message): + Qwen3Tokenizer.from_directory(source) + + +def test_inconsistent_added_token_flags_rejected(source): + data = json.loads((source / "tokenizer_config.json").read_text(encoding="utf-8")) + data["added_tokens_decoder"]["259"]["special"] = True + _write(source, "tokenizer_config.json", data) + with pytest.raises(ValueError, match="inconsistent special"): + Qwen3Tokenizer.from_directory(source) + + +@pytest.mark.parametrize("setting", ["padding", "truncation", "post_processor"]) +def test_no_implicit_padding_truncation_or_bos(source, setting): + engine = Tokenizer.from_file(str(source / "tokenizer.json")) + if setting == "padding": + engine.enable_padding(length=10, pad_id=256, pad_token="<|endoftext|>") + elif setting == "truncation": + engine.enable_truncation(max_length=2) + else: + engine.post_processor = processors.TemplateProcessing(single="<|im_start|> $A", special_tokens=[("<|im_start|>", 257)]) + engine.save(str(source / "tokenizer.json")) + with pytest.raises(ValueError, match="automatic"): + Qwen3Tokenizer.from_directory(source) + + +@pytest.mark.parametrize("nested", [False, True]) +def test_json_cannot_insert_prefix_space_even_when_config_disables_it(source, nested): + engine = Tokenizer.from_file(str(source / "tokenizer.json")) + prefix = pre_tokenizers.ByteLevel(add_prefix_space=True) + engine.pre_tokenizer = pre_tokenizers.Sequence([prefix]) if nested else prefix + engine.save(str(source / "tokenizer.json")) + with pytest.raises(ValueError, match="add_prefix_space"): + Qwen3Tokenizer.from_directory(source) + + +def test_generation_single_eos_supported(source): + _change(source, "generation_config.json", eos_token_id=258) + assert Qwen3Tokenizer.from_directory(source).generation_eos_token_ids == (258,) + + +def test_files_are_local_and_required(source): + (source / "generation_config.json").unlink() + with pytest.raises(FileNotFoundError): + Qwen3Tokenizer.from_directory(source) diff --git a/tests/test_riscv_tensor_runtime.py b/tests/test_riscv_tensor_runtime.py index de1c8dee..517a9c3b 100644 --- a/tests/test_riscv_tensor_runtime.py +++ b/tests/test_riscv_tensor_runtime.py @@ -217,6 +217,32 @@ def _assert_timed_child_terminated(pid, started): _assert_posix_process_terminated(pid, starttime=started) +@pytest.mark.parametrize("interruption", [KeyboardInterrupt, SystemExit]) +def test_interruption_stops_actual_owned_process(monkeypatch, tmp_path, interruption): + communicate = subprocess.Popen.communicate + interrupted = [] + + def interrupt_once(process, *args, **kwargs): + if not interrupted: + interrupted.append(process) + raise interruption(143) + return communicate(process, *args, **kwargs) + + monkeypatch.setattr(subprocess.Popen, "communicate", interrupt_once) + with pytest.raises(interruption): + _run_process([sys.executable, "-B", "-c", "import time;time.sleep(60)"], + cwd=tmp_path, timeout=30) + assert len(interrupted) == 1 + assert interrupted[0].poll() is not None + + +def test_termination_signal_requests_normal_cleanup(): + from scratchv.runtime.riscv_tensor import _terminate_invocation + with pytest.raises(SystemExit) as caught: + _terminate_invocation(15, None) + assert caught.value.code == 143 + + @pytest.mark.parametrize("field,value", [ ("class", 1), ("endian", 2), ("machine", 62), ("entry", INPUT_BASE), ("abi", 0), ]) @@ -347,6 +373,68 @@ def process(command, *, cwd, timeout): assert error.__cause__ is not None +@pytest.mark.parametrize("outcome,message", [ + ("guest", "failure status 3"), ("nonzero", r"QEMU failed \(7\): rejected"), + ("bad_frame", "truncated or missing"), +]) +def test_qemu_log_failure_keeps_primary_error_and_other_log( + tmp_path, monkeypatch, timed_guest, outcome, message): + runtime, executable, feed, clock = timed_guest + original_write = Path.write_bytes + + def write(path, data): + if path.name == "qemu.stdout": + raise PermissionError("stdout is locked") + return original_write(path, data) + + def process(command, *, cwd, timeout): + clock.now += 2.5 + if outcome == "guest": + (cwd / "uart.bin").write_bytes(frame(b"", status=3)) + elif outcome == "bad_frame": + (cwd / "uart.bin").write_bytes(b"broken") + return subprocess.CompletedProcess(command, 7 if outcome != "bad_frame" else 0, + b"partial stdout", b"rejected") + + monkeypatch.setattr(Path, "write_bytes", write) + monkeypatch.setattr(runtime, "_run_process", process) + run_dir = tmp_path / "run" + with pytest.raises(RiscVTensorExecutionError, match=message) as captured: + run_riscv_tensor(executable, feed, run_dir) + assert "stdout is locked" in str(captured.value) + assert captured.value.elapsed_s == 2.5 and clock.reads == 2 + assert (run_dir / "qemu.stderr").read_bytes() == b"rejected" + + +@pytest.mark.parametrize("timeout", [False, True]) +def test_compiler_log_failure_keeps_primary_error_and_other_log(tmp_path, monkeypatch, timeout): + from scratchv.runtime import riscv_tensor as runtime + + original_write = Path.write_bytes + + def write(path, data): + if path.name == "compiler.stdout": + raise PermissionError("compiler stdout is locked") + return original_write(path, data) + + def process(command, **kwargs): + if timeout: + raise subprocess.TimeoutExpired(command, kwargs["timeout"], output=b"partial", stderr=b"rejected") + return subprocess.CompletedProcess(command, 11, b"partial", b"rejected") + + artifact = SimpleNamespace(source="int scratchv_run(void){return 0;}", inputs=(), + output=spec("result", (1,), "float32"), function_name="scratchv_run") + tools = SimpleNamespace(cc=("fixture-clang",), qemu="fixture-qemu", is_zig=False) + monkeypatch.setattr(Path, "write_bytes", write) + monkeypatch.setattr(runtime, "_run_process", process) + error = TimeoutError if timeout else RuntimeError + message = "compilation exceeded 2" if timeout else r"compilation failed \(11\)" + with pytest.raises(error, match=message) as captured: + build_riscv_tensor(artifact, tmp_path / "build", tools, timeout=2) + assert "compiler stdout is locked" in str(captured.value) + assert (tmp_path / "build/compiler.stderr").read_bytes() == b"rejected" + + @pytest.mark.parametrize("log_error", [False, True]) def test_timeout_reports_actual_time_including_cleanup(tmp_path, monkeypatch, timed_guest, log_error): runtime, executable, feed, clock = timed_guest @@ -380,7 +468,7 @@ def write(path, data): assert "cannot preserve timeout log" in str(error) else: assert (tmp_path / "run/qemu.stdout").read_bytes() == b"partial" - assert (tmp_path / "run/qemu.stderr").read_bytes() == b"timed out" + assert (tmp_path / "run/qemu.stderr").read_bytes() == b"timed out" @pytest.mark.parametrize("failure", ["elf", "input", "stale", "write_input", "spawn"]) diff --git a/tests/test_tensor_c_codegen.py b/tests/test_tensor_c_codegen.py index 6fccf718..06b037f9 100644 --- a/tests/test_tensor_c_codegen.py +++ b/tests/test_tensor_c_codegen.py @@ -1,7 +1,7 @@ """Execute generated tensor C against ORT, plus static/numeric rejection cases. -Host compilation uses clang/gcc, or SCRATCHV_ZIG (a zig executable). Repository -local output/tools/zig-python/ziglang/zig.exe is also recognized on Windows. +Host compilation uses clang/gcc, or SCRATCHV_ZIG (a zig executable). Optional +repository-local tools are resolved for the actual host platform. Validation tests do not require a C toolchain; execution tests skip explicitly when no compiler is available rather than reporting unexecuted cases as passes. """ @@ -35,7 +35,7 @@ def builder(*params): @pytest.fixture(scope="module") def compiler(): zig = os.environ.get("SCRATCHV_ZIG") - local = ROOT / "output/tools/zig-python/ziglang/zig.exe" + local = ROOT / "output/tools/zig-python/ziglang" / ("zig.exe" if os.name == "nt" else "zig") if zig or local.is_file(): return [zig or str(local), "cc"] for name in ("clang", "gcc", "cc"): @@ -330,3 +330,45 @@ def test_same_generator_is_reusable_and_never_mutates_ir(): first, second = generator.generate(), generator.generate() assert first == second assert b.program.dump() == before + + +@pytest.mark.parametrize("dtype,numpy_dtype", [(D.FLOAT32, np.float32), (D.INT32, np.int32), + (D.INT64, np.int64)]) +def test_scalar_initializer_cannot_override_ir_literal(dtype, numpy_dtype): + from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + scale = Value("scale", dtype, is_constant=True, const_value=1) + b = builder() + b.program.global_values.append(scale) + b.ret(scale) + bindings = {"scale": np.array(2, dtype=numpy_dtype)} + with pytest.raises(IRExecutionError, match="initializer disagrees with scalar constant"): + IRInterpreter(b.program).run({}, initializers=bindings) + with pytest.raises(TensorCCodegenError, match="scale disagrees with scalar constant"): + TensorCCodegen(b.program, bindings).generate() + + +def test_scalar_constant_binding_cannot_be_a_vector(): + scale = Value("scale", D.FLOAT32, is_constant=True, const_value=1.0, shape=(1,)) + b = builder() + b.program.global_values.append(scale) + b.ret(scale) + with pytest.raises(TensorCCodegenError, match="scale disagrees with scalar constant"): + TensorCCodegen(b.program, {"scale": np.array([1], np.float32)}).generate() + + +def test_matching_scalar_initializer_preserves_compiled_result(tmp_path, compiler): + from scratchv.verification.ir_interpreter import IRInterpreter + + x = Value("x", shape=(2,)) + scale = Value("scale", D.FLOAT32, is_constant=True, const_value=1.25) + b = builder(x) + b.program.global_values.append(scale) + b.ret(b.mul(x, scale)) + bindings = {"scale": np.array(1.25, np.float32)} + feed = {"x": np.array([2, -3], np.float32)} + expected = IRInterpreter(b.program).run(feed, initializers=bindings).return_value + with compiled(tmp_path, TensorCCodegen(b.program, bindings).generate(), compiler) as execute: + status, actual = execute(feed) + assert status == 0 + np.testing.assert_array_equal(actual, expected) diff --git a/tests/test_w1_qwen3_export.py b/tests/test_w1_qwen3_export.py index ef1faf3e..9fa0db27 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_w2_acceptance.py b/tests/test_w2_acceptance.py new file mode 100644 index 00000000..5ba2cb40 --- /dev/null +++ b/tests/test_w2_acceptance.py @@ -0,0 +1,451 @@ +"""Acceptance orchestration must not turn partial, stale or failed evidence into PASS.""" +import argparse +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +import pytest + +SPEC = importlib.util.spec_from_file_location("w2_acceptance", Path(__file__).parents[1] / "scripts/run_w2_acceptance.py") +runner = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = runner +SPEC.loader.exec_module(runner) + + +def arguments(tmp_path, gates=None): + return argparse.Namespace(output_dir=tmp_path / "fresh", gates=list(runner.GATES) if gates is None else gates, + python=sys.executable, tokenizer_dir=tmp_path / "assets", tokenizer_mode="verify", + model_dir=tmp_path / "model", model_mode="verify", cc="compiler", qemu="emulator", + qemu_timeout=180, parse_timeout=300) + + +def runtime_fixture(tmp_path): + root = tmp_path / "source" + corpus = root / "probes/w2_runtime/corpus.json" + corpus.parent.mkdir(parents=True) + names = [f"case_{i}" for i in range(20)] + corpus.write_text(json.dumps({"cases": [{"name": name} for name in names]}), encoding="utf-8") + folder = tmp_path / "run/runtime" + folder.mkdir(parents=True) + for name in ("report.md", "report.html"): + (folder / name).write_text("PASS", encoding="utf-8") + sources = {runner.SCRIPTS["runtime"]: "a" * 64} + data = {"gate": "unit:runtime", "passed": True, "stage": "complete", "source_sha256": sources, + "tokenizer_cases": [{"name": name, "passed": True, "checks": 24} for name in names], + "runtime_cases": [{"name": str(i), "passed": True} for i in range(5)]} + return root, runner.Gate("runtime", (), folder), sources, data + + +def validate_fixture(fixture, mutate=lambda data: None): + root, gate, sources, data = fixture + mutate(data) + (gate.output / "report.json").write_text(json.dumps(data), encoding="utf-8") + return runner.validate_gate_report(gate, sources, root) + + +def test_plan_is_fresh_serial_and_explicit(tmp_path): + args = arguments(tmp_path) + plan = runner.build_plan(args) + assert [gate.name for gate in plan] == list(runner.GATES) + for gate in plan: + assert gate.command[:4] == (sys.executable, "-B", "-X", "utf8") + assert str(gate.output) in " ".join(gate.command) + assert plan[5].dependencies == ("small-ir",) + assert str(args.output_dir / "small-ir") in plan[5].command + assert "--mode" in plan[-1].command and "verify" in plan[-1].command + assert "compiler" in plan[1].command and "emulator" in plan[1].command + + +def test_frontend_real_pytest_tmp_path_in_fresh_gate_directory(tmp_path): + """Pytest's basetemp.mkdir has no parents=True; its parent must already exist.""" + source = tmp_path / "source" + tests = source / "tests" + tests.mkdir(parents=True) + (tests / "test_qwen3_frontend_patterns.py").write_text( + "import pytest\n@pytest.mark.parametrize('number', range(22))\n" + "def test_real_tmp_path(tmp_path, number):\n" + " path = tmp_path / str(number)\n" + " path.write_text('fresh')\n" + " assert path.read_text() == 'fresh'\n", encoding="utf-8") + args = arguments(tmp_path, ["frontend"]) + args.output_dir.mkdir() + gate = runner.build_plan(args, source)[0] + assert not gate.output.exists() + result = runner.run_gate(gate, {}, timeout=30, root=source) + assert result["status"] == "PASS", Path(result["log"]).read_text(encoding="utf-8") + assert result["validation"]["tests"] == 22 + + +@pytest.mark.parametrize("chosen,expected", [(["small-qemu"], ["small-ir", "small-qemu"]), + (["runtime-model"], ["runtime", "runtime-model"]), + (["frontend"], ["frontend"])]) +def test_subset_auto_adds_only_required_dependencies(tmp_path, chosen, expected): + assert [gate.name for gate in runner.build_plan(arguments(tmp_path, chosen))] == expected + + +@pytest.mark.parametrize("status,expected", [("PASS", "PASS"), ("NOT_RUN", "PARTIAL"), ("BLOCKED", "FAIL"), ("FAIL", "FAIL")]) +def test_overall_requires_every_gate(status, expected): + report = {"gates": [{"name": name, "status": "PASS"} for name in runner.GATES]} + report["gates"][-1]["status"] = status + runner.finalize_status(report) + assert report["status"] == expected + assert report["passed"] is (expected == "PASS") + + +def test_top_level_source_error_blocks_pass(): + report = {"error": "source changed", "gates": [{"status": "PASS"} for _ in runner.GATES]} + runner.finalize_status(report) + assert report["status"] == "FAIL" + + +def test_git_unavailable_archive_is_not_failed(monkeypatch, tmp_path): + monkeypatch.setattr(runner.subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(FileNotFoundError("git"))) + result = runner.checkout_evidence(tmp_path) + assert result["head"] is None and result["status"] == "unknown" + + +def test_new_source_files_have_fingerprints(tmp_path): + path = tmp_path / "scratchv/runtime/new.py" + path.parent.mkdir(parents=True) + path.write_text("x = 1\n") + before = runner.source_fingerprints(tmp_path) + path.write_text("x = 2\n") + assert before != runner.source_fingerprints(tmp_path) + + +def test_complete_runtime_evidence(tmp_path): + assert validate_fixture(runtime_fixture(tmp_path))["tokenizer_comparisons"] == 480 + + +@pytest.mark.parametrize("mutation", [ + lambda d: d.update(gate="different"), lambda d: d.update(passed=False), + lambda d: d.update(stage="tokenizer"), lambda d: d["tokenizer_cases"].pop(), + lambda d: d["runtime_cases"].pop(), lambda d: d["tokenizer_cases"][0].update(checks=23), + lambda d: d["tokenizer_cases"][1].update(name="case_0"), + lambda d: d["runtime_cases"][0].update(passed=False), + lambda d: d.update(source_sha256={}), + lambda d: d.update(source_sha256={runner.SCRIPTS["runtime"]: "wrong"}), +]) +def test_runtime_missing_or_inconsistent_evidence_fails(tmp_path, mutation): + with pytest.raises(ValueError): + validate_fixture(runtime_fixture(tmp_path), mutation) + + +@pytest.mark.parametrize("number", [None, float("nan"), float("inf"), -1, 1e-5, 0.1, True]) +def test_invalid_numeric_evidence_fails(number): + with pytest.raises(ValueError): + runner.check_numeric_tree({"passed": True, "nested": [{"passed": True, "max_abs": number}]}) + + +@pytest.mark.parametrize("field", ["seconds", "qemu_process_wall_seconds"]) +def test_nonfinite_timing_cannot_break_machine_report(field): + with pytest.raises(ValueError): + runner.check_numeric_tree({"passed": True, field: float("nan")}) + + +@pytest.mark.parametrize("tag", ["skipped", "failure", "error"]) +def test_frontend_skips_are_failures(tmp_path, tag): + cases = [f'{"<" + tag + "/>" if i == 0 else ""}' for i in range(22)] + (tmp_path / "tests.xml").write_text("" + "".join(cases) + "") + with pytest.raises(ValueError): + runner.validate_gate_report(runner.Gate("frontend", (), tmp_path), {}) + + +def test_existing_output_is_not_read_as_pass(tmp_path): + folder = tmp_path / "old" + folder.mkdir() + (folder / "report.json").write_text('{"passed":true}') + result = runner.run_gate(runner.Gate("runtime", (), folder), {}, timeout=1) + assert result["status"] == "FAIL" and "already exists" in result["error"] + + +def test_zero_exit_without_report_is_failure(tmp_path): + gate = runner.Gate("runtime", (sys.executable, "-c", "print('PASS')"), tmp_path / "gate") + result = runner.run_gate(gate, {}, timeout=10) + assert result["returncode"] == 0 and result["status"] == "FAIL" + assert "PASS" in Path(result["log"]).read_text() + + +def test_child_error_is_preserved(tmp_path): + folder = tmp_path / "gate" + code = "import pathlib,json; p=pathlib.Path(" + repr(str(folder)) + ");p.mkdir();(p/'report.json').write_text(json.dumps({'passed':False,'stage':'assets','error':'Checksum mismatch'}));raise SystemExit(1)" + result = runner.run_gate(runner.Gate("runtime", (sys.executable, "-c", code), folder), {}, timeout=10) + assert result["status"] == "FAIL" and "Checksum mismatch" in result["error"] + assert result["child_stage"] == "assets" + + +def test_timeout_is_failure(tmp_path): + result = runner.run_gate(runner.Gate("runtime", (sys.executable, "-c", "import time;time.sleep(30)"), tmp_path / "gate"), {}, timeout=0.2) + assert result["status"] == "FAIL" and "exceeded" in result["error"] + + +@pytest.mark.parametrize("failure", [KeyboardInterrupt("cancelled"), SystemExit(143)]) +def test_cancellation_reclaims_live_gate_and_preserves_exception(tmp_path, monkeypatch, failure): + real_popen = subprocess.Popen + children = [] + + def launch(*args, **kwargs): + process = real_popen(*args, **kwargs) + if args[0][0] != sys.executable: + return process + children.append(process) + original_wait = process.wait + + def interrupt(*args, **kwargs): + process.wait = original_wait + raise failure + + process.wait = interrupt + return process + + monkeypatch.setattr(runner.subprocess, "Popen", launch) + try: + with pytest.raises(type(failure)) as caught: + runner.run_gate(runner.Gate("frontend", (sys.executable, "-c", "import time;time.sleep(60)"), + tmp_path / "frontend"), {}, timeout=90) + assert caught.value is failure + row = caught.value.w2_gate_result + assert row["status"] == "FAIL" and row["interrupted"] is True + assert row["seconds"] >= 0 + assert type(failure).__name__ in row["error"] + assert children[0].poll() is not None + finally: + for process in children: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + + +def test_cancellation_cleanup_and_log_errors_preserve_original(tmp_path, monkeypatch): + failure = KeyboardInterrupt("primary interruption") + + class Process: + returncode = None + + def poll(self): + return None + + def wait(self, **kwargs): + raise failure + + class Log: + def close(self): + raise OSError("secondary close error") + + monkeypatch.setattr(Path, "open", lambda *args, **kwargs: Log()) + monkeypatch.setattr(runner.subprocess, "Popen", lambda *args, **kwargs: Process()) + monkeypatch.setattr(runner, "terminate_process_tree", lambda process: (_ for _ in ()).throw(OSError("cleanup failed"))) + with pytest.raises(KeyboardInterrupt) as caught: + runner.run_gate(runner.Gate("frontend", ("unused",), tmp_path / "frontend"), {}, timeout=10) + assert caught.value is failure + row = failure.w2_gate_result + assert "primary interruption" in row["error"] + assert "cleanup failed" in row["cleanup_error"] + assert "secondary close error" in row["log_error"] + assert row["status"] == "FAIL" and row["passed"] is False + + +@pytest.mark.parametrize("failure, code", [(KeyboardInterrupt(), 130), (SystemExit(143), 143)]) +def test_cancelled_main_saves_failure_report_without_running_next_gate(tmp_path, monkeypatch, failure, code): + monkeypatch.setattr(runner, "checkout_evidence", lambda: {"head": None}) + monkeypatch.setattr(runner, "source_fingerprints", lambda: {}) + launched = [] + + def cancel(gate, *args, **kwargs): + launched.append(gate.name) + failure.w2_gate_result = {"name": gate.name, "status": "FAIL", "passed": False, + "interrupted": True, "error": "original cancellation"} + raise failure + + monkeypatch.setattr(runner, "run_gate", cancel) + previous_term = signal.getsignal(signal.SIGTERM) + out = tmp_path / "cancelled" + assert runner.main(["--output-dir", str(out)]) == code + assert signal.getsignal(signal.SIGTERM) == previous_term + report = json.loads((out / "report.json").read_text()) + assert report["status"] == "FAIL" and report["interrupted"] is True + assert launched == ["frontend"] + assert report["gates"][0]["error"] == "original cancellation" + assert all(row["status"] == "NOT_RUN" for row in report["gates"][1:]) + assert (out / "report.md").is_file() and (out / "report.html").is_file() + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux supervisor signal and nested-session integration") +@pytest.mark.parametrize("cancel_signal", [signal.SIGINT, signal.SIGTERM]) +def test_real_acceptance_supervisor_cancellation_cleans_nested_worker(tmp_path, cancel_signal): + pidfile = tmp_path / "worker.json" + out = tmp_path / "acceptance" + worker = ("import os,time,json;from pathlib import Path;" + "pid=os.getpid();stat=Path(f'/proc/{pid}/stat').read_text();" + f"Path({str(pidfile)!r}).write_text(json.dumps([pid,stat.rsplit(')',1)[1].split()[19]]));" + "time.sleep(60)") + probe = ("import sys;from scratchv.runtime.riscv_tensor import _run_process;" + f"_run_process([sys.executable,'-B','-c',{worker!r}],cwd=None,timeout=50)") + supervisor = ( + "import sys;from scripts import run_w2_acceptance as r;" + "r.source_fingerprints=lambda:{};r.checkout_evidence=lambda:{'head':None};" + f"r.build_plan=lambda a:[r.Gate('frontend',(sys.executable,'-B','-c',{probe!r}),a.output_dir/'frontend')];" + f"raise SystemExit(r.main(['--output-dir',{str(out)!r},'--gates','frontend']))") + unrelated = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(60)"], start_new_session=True) + process = subprocess.Popen([sys.executable, "-B", "-c", supervisor], cwd=runner.ROOT, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + pid = None + try: + deadline = time.monotonic() + 15 + while True: + assert process.poll() is None, "Supervisor exited before worker readiness" + assert time.monotonic() < deadline, "Worker did not become ready" + try: + pid, started = json.loads(pidfile.read_text()) + break + except (FileNotFoundError, ValueError): + time.sleep(0.01) + process.send_signal(cancel_signal) + stdout, stderr = process.communicate(timeout=25) + assert process.returncode == 128 + cancel_signal, (stdout, stderr) + report = json.loads((out / "report.json").read_text()) + assert report["status"] == "FAIL" and report["interrupted"] is True + assert report["gates"][0]["interrupted"] is True + assert report["gates"][0]["status"] == "FAIL" + assert all(row["status"] == "NOT_RUN" for row in report["gates"][1:]) + assert unrelated.poll() is None + deadline = time.monotonic() + 5 + while True: + try: + fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() + except FileNotFoundError: + break + if fields[0] in {"Z", "X"} or fields[19] != started: + break + assert time.monotonic() < deadline, "Detached worker survived supervisor cancellation" + time.sleep(0.01) + finally: + for child in (process, unrelated): + if child.poll() is None: + os.killpg(child.pid, signal.SIGKILL) + child.wait(timeout=5) + if pid is not None: + try: + fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() + if fields[19] == started and fields[0] not in {"Z", "X"}: + os.kill(pid, signal.SIGKILL) + except FileNotFoundError: + pass + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux nested-session SIGTERM integration") +def test_supervisor_cancels_nested_worker_session(tmp_path): + """Real probe -> _run_process -> different session, not a mocked killpg.""" + pidfile = tmp_path / "ready.json" + worker = ("import os,time,json;from pathlib import Path;" + "pid=os.getpid();stat=Path(f'/proc/{pid}/stat').read_text();" + f"Path({str(pidfile)!r}).write_text(json.dumps([pid,stat.rsplit(')',1)[1].split()[19]]));" + "time.sleep(60)") + probe = ("import sys;from scratchv.runtime.riscv_tensor import _run_process;" + f"_run_process([sys.executable,'-B','-c',{worker!r}],cwd=None,timeout=50)") + unrelated = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(60)"], + start_new_session=True) + process = subprocess.Popen([sys.executable, "-B", "-c", probe], cwd=runner.ROOT, + start_new_session=True) + pid = None + try: + deadline = time.monotonic() + 15 + while not pidfile.exists(): + assert process.poll() is None, "Probe exited before worker readiness" + assert time.monotonic() < deadline, "Worker did not become ready" + time.sleep(0.01) + # Existence can precede completion of the tiny JSON write. + while True: + try: + pid, started = json.loads(pidfile.read_text()) + break + except ValueError: + assert time.monotonic() < deadline + time.sleep(0.01) + assert os.getpgid(pid) != process.pid + runner.terminate_process_tree(process) + assert process.returncode == 143 + assert unrelated.poll() is None + deadline = time.monotonic() + 5 + while True: + try: + fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() + except (FileNotFoundError, ProcessLookupError): + break + if fields[0] in {"Z", "X"} or fields[19] != started: + break + assert time.monotonic() < deadline, "Detached worker survived supervisor cleanup" + time.sleep(0.01) + finally: + for child in (process, unrelated): + if child.poll() is None: + child.kill() + child.wait(timeout=5) + if pid is not None: + try: + fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() + if fields[19] == started and fields[0] not in {"Z", "X"}: + os.kill(pid, 9) + except (FileNotFoundError, ProcessLookupError): + pass + + +@pytest.mark.parametrize("filename", ["report.md", "report.html", "report.json"]) +def test_report_write_failure_cannot_publish_pass(tmp_path, monkeypatch, filename): + original = Path.replace + def replace(path, target): + if Path(target).name == filename: + raise OSError("disk failed") + return original(path, target) + monkeypatch.setattr(Path, "replace", replace) + report = {"status": "PASS", "passed": True, "gates": []} + runner.write_reports(tmp_path, report) + assert report["status"] == "FAIL" and not report["passed"] + if (tmp_path / "report.json").exists(): + assert json.loads((tmp_path / "report.json").read_text())["passed"] is False + + +def test_report_secondary_error_preserves_primary(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "replace", lambda *a: (_ for _ in ()).throw(OSError("disk failed"))) + report = {"status": "FAIL", "passed": False, "gates": [], "error": "original numerical divergence"} + runner.write_reports(tmp_path, report) + assert report["error"] == "original numerical divergence" + + +def test_subset_main_is_partial_and_exit_two(tmp_path, monkeypatch): + monkeypatch.setattr(runner, "checkout_evidence", lambda: {"head": None}) + monkeypatch.setattr(runner, "source_fingerprints", lambda: {"source": "hash"}) + monkeypatch.setattr(runner, "run_gate", lambda gate, *a, **k: {"name": gate.name, "status": "PASS", "passed": True}) + out = tmp_path / "new" + assert runner.main(["--output-dir", str(out), "--gates", "frontend"]) == 2 + report = json.loads((out / "report.json").read_text()) + assert report["status"] == "PARTIAL" and not report["passed"] + + +def test_source_mutation_during_run_fails(tmp_path, monkeypatch): + monkeypatch.setattr(runner, "checkout_evidence", lambda: {"head": None}) + values = iter([{"source": "old"}, {"source": "new"}]) + monkeypatch.setattr(runner, "source_fingerprints", lambda: next(values)) + monkeypatch.setattr(runner, "run_gate", lambda gate, *a, **k: {"name": gate.name, "status": "PASS", "passed": True}) + assert runner.main(["--output-dir", str(tmp_path / "new"), "--gates", "frontend"]) == 1 + + +def test_missing_assets_block_and_do_not_launch_dependent_gates(tmp_path, monkeypatch): + monkeypatch.setattr(runner, "checkout_evidence", lambda: {"head": None}) + monkeypatch.setattr(runner, "source_fingerprints", lambda: {}) + monkeypatch.setattr(runner, "run_gate", lambda *a, **k: pytest.fail("must not run without required assets")) + out = tmp_path / "new" + assert runner.main(["--output-dir", str(out), "--gates", "runtime-model"]) == 1 + data = json.loads((out / "report.json").read_text()) + assert [row["status"] for row in data["gates"] if row["name"] in {"runtime", "runtime-model"}] == ["BLOCKED", "BLOCKED"] + + +def test_help_has_no_heavy_imports(tmp_path): + code = "import runpy,sys;sys.argv=['run_w2_acceptance.py','--help'];\ntry: runpy.run_path(" + repr(str(Path(runner.__file__))) + ",run_name='__main__')\nexcept SystemExit: pass\nassert not ({'torch','numpy','onnx','transformers'} & sys.modules.keys())" + result = subprocess.run([sys.executable, "-S", "-c", code], capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr diff --git a/tests/test_w2_acceptance_summary.py b/tests/test_w2_acceptance_summary.py new file mode 100644 index 00000000..91da3732 --- /dev/null +++ b/tests/test_w2_acceptance_summary.py @@ -0,0 +1,216 @@ +"""The W2 acceptance summary must answer only operators, precision and timing. + +It also has to stay honest when the upstream jobs produced no evidence: a +missing report is never a pass, and the page never claims more coverage than +the run recorded. +""" +import ast +from copy import deepcopy +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from scripts.summarize_w2_ci import (FAMILY_OPS, MAX_SUMMARY_BYTES, backend_opcodes, + frontend_operators, main, opcode_name, render) + +ROOT = Path(__file__).resolve().parents[1] +DIRECTORIES = ("w2-backend-ops", "w2-runtime", "w2-runtime-model", + "qwen3-small", "qwen3-riscv", "qwen3-parse") + + +def metric(value, name="logits.npy"): + return {"name": name, "passed": True, "max_abs": value, + "relative_l2": 2e-7, "cosine_similarity": 0.99999} + + +def backend_ops_report(): + return {"passed": True, "seconds": 100.0, "cases": [ + {"name": "matmul_matrix", "passed": True, "executions": [ + {"optimization": level, "passed": True, "compile_seconds": 1.0, + "qemu_process_wall_seconds": 2.0, + "ir_vs_ort": metric(1e-6), "ir_vs_numpy": metric(2e-6), + "qemu_vs_ort": metric(3e-6), "qemu_vs_numpy": metric(4e-6)} + for level in ("none", "all")]}, + {"name": "elementwise_broadcast", "passed": True, "executions": [ + {"optimization": "none", "passed": True, "compile_seconds": 1.0, + "qemu_process_wall_seconds": 2.0, + "ir_vs_ort": metric(5e-6), "ir_vs_numpy": metric(6e-6), + "qemu_vs_ort": metric(7e-6), "qemu_vs_numpy": metric(8e-6)}]}, + ]} + + +def reports(): + return { + "w2-backend-ops": backend_ops_report(), + "w2-runtime": {"gate": "unit:runtime", "passed": True, "seconds": 5.0}, + "w2-runtime-model": {"gate": "integration:runtime-model", "passed": True, "seconds": 20.0, + "cases": [{"name": "c0", "executions": [ + {"engine": "ir", "passed": True, "vs_ort": metric(1e-6), + "vs_torch": metric(2e-6)}]}]}, + "qwen3-small": {"passed": True, "seconds": 30.0, + "onnx": {"operators": {"MatMul": 2, "Add": 3}}, + # The host IR comparison wraps one tensor_diff per checkpoint. + "cases": [{"name": "c0", "ir_vs_ort": { + "passed": True, "first_divergence": None, + "checkpoints": [dict(metric(1e-6), name="logits"), + dict(metric(9e-6), name="layer_0.output")]}}]}, + "qwen3-riscv": {"passed": True, + "timing": {"pipeline_seconds": 60.0, "cross_compile_seconds_total": 10.0, + "qemu_process_wall_seconds_total": 12.5}, + "cases": [{"name": "c0", "qemu": [metric(1e-6)]}]}, + "qwen3-parse": {"gate": "frontend:parse-qwen3", "passed": True, "seconds": 40.0, + "ir": {"operators": {"matmul": 42}}, + "graph": {"graph": {"operators": {"MatMul": 42, "Gather": 1}}}}, + } + + +def save(directory, payload): + for name, report in payload.items(): + folder = directory / name + folder.mkdir(parents=True, exist_ok=True) + (folder / "report.json").write_text(json.dumps(report), encoding="utf-8") + + +def row_of(text, prefix): + """The pipe-separated cells of the first table row starting with `prefix`.""" + for line in text.splitlines(): + if line.startswith(prefix): + return [value.strip() for value in line.strip().strip("|").split("|")] + raise AssertionError(f"no row starting with {prefix!r} in:\n{text}") + + +def test_three_sections_and_every_chain_are_present(tmp_path): + save(tmp_path, reports()) + text = render(tmp_path) + assert text.startswith("## W2 测试项\n") + for heading in ("### 算子支持", "### 精度", "### 耗时"): + assert heading in text + for label in ("w2-backend-ops", "w2-runtime-model", "qwen3-small host IR", + "qwen3-small RV64 QEMU", "qwen3-parse"): + assert any(line.startswith(f"| {label}") for line in text.splitlines()), label + # Every measured chain contributes its seconds, and the total is their sum. + assert "255.000 s" in row_of(text, "| **总计**")[2] + + +def test_exercised_operators_are_marked_and_unmeasured_ones_are_not(tmp_path): + save(tmp_path, reports()) + text = render(tmp_path) + frontend = row_of(text, "| IR 层")[2] + assert "add" in frontend and "matmul" in frontend and "gather" in frontend + # Supported by the parser, but no W2 report recorded it. + assert "conv" not in frontend and "maxpool" not in frontend + backend = row_of(text, "| 汇编层")[2] + assert "add" in backend and "matmul" in backend + assert "softmax" not in backend + kernels = row_of(text, "| `scratchv/backend/kernels`")[2] + assert kernels == "无(W2 不执行)" + + +def test_backend_layer_is_not_marked_without_a_passing_qemu_run(tmp_path): + payload = reports() + payload["qwen3-riscv"]["cases"] = [{"name": "c0", "qemu": [dict(metric(1e-6), passed=False)]}] + for execution in payload["w2-backend-ops"]["cases"][0]["executions"]: + execution["passed"] = False + payload["w2-backend-ops"]["cases"][1]["executions"] = [] + save(tmp_path, payload) + assert row_of(render(tmp_path), "| 汇编层")[2] == "无(本次未执行)" + + +def test_structural_chains_stay_visible_without_numeric_metrics(tmp_path): + save(tmp_path, reports()) + text = render(tmp_path) + assert "w2-runtime(文本层,无数值比较)" in text + assert "qwen3-parse(完整 28 层前端,无数值比较)" in text + assert "未测(0 条比较)" in text + + +def test_precision_table_reports_the_worst_recorded_comparison(tmp_path): + save(tmp_path, reports()) + text = render(tmp_path) + qemu = row_of(text, "| w2-backend-ops / QEMU vs ORT") + # Worst of 3e-6, 4e-6, 7e-6 leaves 8e-6 out: that is the NumPy row. + assert qemu[1] == format(7e-6, ".16e") + numpy_row = row_of(text, "| w2-backend-ops / QEMU vs NumPy") + assert numpy_row[1] == format(8e-6, ".16e") + # Only the final logits checkpoint counts, not the 9e-6 layer_0.output one. + small = row_of(text, "| qwen3-small host IR / IR vs ORT") + assert small[1] == format(1e-6, ".16e") + + +def test_missing_reports_are_reported_and_never_pass(tmp_path): + text = render(tmp_path) + assert text.count("报告缺失") == len(DIRECTORIES) + assert "PASS" not in text + assert "不完整" in text and "0.000 s" in text + + +@pytest.mark.parametrize("invalid", ["{", "[]", '"text"', '{"gate":"unit:runtime"}']) +def test_invalid_reports_do_not_become_a_pass(tmp_path, invalid): + folder = tmp_path / "w2-runtime" + folder.mkdir(parents=True) + (folder / "report.json").write_text(invalid, encoding="utf-8") + text = render(tmp_path) + assert "报告无法解析" in text or "报告结构不符" in text + assert "PASS" not in text + + +def test_a_failing_chain_is_never_hidden_by_its_report(tmp_path): + payload = reports() + payload["qwen3-small"]["passed"] = False + save(tmp_path, payload) + row = row_of(render(tmp_path), "| qwen3-small host IR |") + assert row[1] == "FAIL" + + +def test_summary_budget_falls_back_without_losing_the_header(tmp_path, monkeypatch): + import scripts.summarize_w2_ci as module + save(tmp_path, reports()) + monkeypatch.setattr(module, "MAX_SUMMARY_BYTES", 200) + text = render(tmp_path) + assert text.startswith("## W2 测试项\n") + assert "summary budget" in text + assert len(text.encode("utf-8")) < 400 + assert MAX_SUMMARY_BYTES == 64 * 1024 + + +def test_cli_appends_to_the_github_summary_and_always_succeeds(tmp_path, monkeypatch): + destination = tmp_path / "summary.md" + destination.write_text("Prior summary\n") + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(destination)) + assert main(["--report-dir", str(tmp_path / "absent")]) == 0 + assert destination.read_text().startswith("Prior summary\n## W2 测试项\n") + # -S excludes site-packages: the acceptance job installs no dependencies. + environment = {key: value for key, value in os.environ.items() if key != "GITHUB_STEP_SUMMARY"} + process = subprocess.run([sys.executable, "-S", "-X", "utf8", str(ROOT / "scripts/summarize_w2_ci.py"), + "--report-dir", str(tmp_path / "absent")], + cwd=tmp_path, env=environment, text=True, capture_output=True) + assert process.returncode == 0, process.stderr + assert process.stdout.startswith("## W2 测试项\n") + + +def test_backend_opcodes_are_pinned_to_the_codegen_set(): + """Changing the tensor C lowering set has to update this summary.""" + assert backend_opcodes() == sorted([ + "abs", "add", "cast", "concat", "cos", "div", "exp", "expand", "gather", "load_const", + "matmul", "mul", "neg", "pow", "reciprocal", "reduce_mean", "relu", "reshape", "sigmoid", + "sin", "slice", "softmax", "sqrt", "sub", "transpose", "unsqueeze"]) + + +def test_frontend_operators_come_from_the_parser_handlers(): + operators = frontend_operators() + assert len(operators) == len(set(operators)) + assert {"matmul", "softmax", "reducemean", "unsqueeze"} <= set(operators) + assert operators == sorted(operators) + + +def test_family_ops_cover_every_operator_the_cases_build(): + """The probe records only families, so this map has to stay true.""" + tree = ast.parse((ROOT / "probes/w2_backend_ops/cases.py").read_text(encoding="utf-8")) + built = {node.args[0].value for node in ast.walk(tree) + if isinstance(node, ast.Call) and getattr(node.func, "attr", "") == "make_node"} + assert built, "no ONNX nodes found in cases.py" + assert {opcode_name(op) for op in built} == {op for ops in FAMILY_OPS.values() for op in ops} diff --git a/tests/test_w2_actions_summary.py b/tests/test_w2_actions_summary.py new file mode 100644 index 00000000..f48ca53b --- /dev/null +++ b/tests/test_w2_actions_summary.py @@ -0,0 +1,71 @@ +"""The per-job W2 report dumps go to the step log; only the acceptance job summarizes.""" +import os +from pathlib import Path +import shutil +import subprocess +import textwrap + +import pytest + + +WORKFLOW = Path(__file__).parents[1] / ".github/workflows/llm-deploy.yml" +SUMMARIES = ( + ("Summarize W2 unit gates", ("w2-backend-ops", "w2-runtime", "w2-runtime-model")), + ("Summarize RISC-V numeric gate", ("qwen3-riscv",)), + ("Summarize Qwen3 small probe", ("qwen3-small",)), +) + + +def shell_block(name): + # Only read the literal run block: never evaluate Actions expressions. + source = WORKFLOW.read_text(encoding="utf-8") + step = source.split(" - name: " + name + "\n", 1)[1].split("\n - ", 1)[0] + return textwrap.dedent(step.split(" run: |\n", 1)[1]) + + +def execute(tmp_path, name, directories, outcome, have_report): + bash = os.environ.get("SCRATCHV_BASH") or shutil.which("bash") + if not bash: + pytest.skip("Bash is required to exercise GitHub Actions summary commands") + for directory in directories: + report = tmp_path / "output" / directory / "report.md" + report.parent.mkdir(parents=True) + if have_report: + report.write_text("SENTINEL measured report\n", encoding="utf-8") + summary = tmp_path / "summary.md" + env = dict(os.environ, GITHUB_STEP_SUMMARY=summary.as_posix()) + for key in ("FRONTEND_OUTCOME", "BACKEND_OUTCOME", "RUNTIME_OUTCOME", "RUNTIME_MODEL_OUTCOME", + "PROBE_OUTCOME", "TEST_OUTCOME", "ARTIFACT_TEST_OUTCOME"): + env[key] = outcome + result = subprocess.run([bash, "-eu", "-c", shell_block(name)], cwd=tmp_path, + env=env, capture_output=True, text=True, encoding="utf-8", timeout=10) + assert result.returncode == 0, result.stderr + # The report body belongs in the log; the Actions page is written elsewhere. + assert not summary.exists() + return result.stdout + + +@pytest.mark.parametrize("name,directories", SUMMARIES) +@pytest.mark.parametrize("outcome", ["success", "failure"]) +def test_ran_probe_keeps_available_report(tmp_path, name, directories, outcome): + summary = execute(tmp_path, name, directories, outcome, True) + assert summary.count("SENTINEL measured report") == len(directories) + assert outcome in summary + + +@pytest.mark.parametrize("name,directories", SUMMARIES) +@pytest.mark.parametrize("outcome", ["skipped", ""]) +def test_unrun_probe_never_reuses_stale_report(tmp_path, name, directories, outcome): + summary = execute(tmp_path, name, directories, outcome, True) + assert summary.count("未运行") == len(directories) + assert "SENTINEL" not in summary + assert "无" in summary and "数值验收结论" in summary + + +@pytest.mark.parametrize("name,directories", SUMMARIES) +@pytest.mark.parametrize("outcome", ["success", "failure", "cancelled"]) +def test_absent_report_does_not_invent_acceptance(tmp_path, name, directories, outcome): + summary = execute(tmp_path, name, directories, outcome, False) + assert summary.count("未生成报告") == len(directories) + assert "无" in summary and "数值验收结论" in summary + assert "SENTINEL" not in summary diff --git a/tests/test_w2_backend_ops_gate.py b/tests/test_w2_backend_ops_gate.py new file mode 100644 index 00000000..cf295fbf --- /dev/null +++ b/tests/test_w2_backend_ops_gate.py @@ -0,0 +1,220 @@ +"""Mandatory QEMU gate failure/report handling; target execution is a separate job.""" +import json +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from probes.w2_backend_ops import run as probe +from scratchv.runtime.riscv_tensor import RiscVTensorTimeoutError + + +@pytest.fixture +def fake_target(monkeypatch): + case = probe.build_cases()[0] + monkeypatch.setattr(probe, "build_cases", lambda: [case]) + monkeypatch.setattr(probe, "FAMILIES", (case.family,)) + monkeypatch.setattr(probe, "discover_toolchain", lambda **kwargs: SimpleNamespace(cc=("fixture-cc",), qemu="fixture-qemu")) + monkeypatch.setattr(probe, "build_riscv_tensor", lambda *args: SimpleNamespace( + elf_sha256="fixture", compile_command=("fixture-cc",), compile_seconds=0.1, + workspace_bytes=16, tool_versions={"fixture": "unit-test-only"})) + monkeypatch.setattr(probe, "run_riscv_tensor", lambda *args, **kwargs: SimpleNamespace( + output=case.expected.copy(), elapsed_s=0.25, command=("fixture-qemu",))) + return case + + +def result(tmp_path): + directory = tmp_path / "report" + code = probe.main(["--output-dir", str(directory)]) + report = json.loads((directory / "report.json").read_text(encoding="utf-8")) + assert (directory / "report.md").is_file() and (directory / "report.html").is_file() + return code, report + + +def test_missing_tools_fail_with_all_report_formats(tmp_path, monkeypatch): + def unavailable(**kwargs): + raise FileNotFoundError("QEMU unavailable") + monkeypatch.setattr(probe, "discover_toolchain", unavailable) + code, report = result(tmp_path) + assert code == 1 and report["passed"] is False and report["stage"] == "toolchain" + assert "QEMU unavailable" in report["error"] + assert report["planned_executions"] == 32 + + +def test_success_requires_both_optimization_levels(tmp_path, monkeypatch, fake_target): + code, report = result(tmp_path) + assert code == 0 and report["passed"] is True + assert report["completed_executions"] == report["planned_executions"] == 2 + assert report["passed_executions"] == 2 + assert report["qemu_process_wall_seconds"] == {"measured_count": 2, "total": 0.5} + assert report["atol"] == 1e-5 and report["rtol"] == 0 + + +@pytest.mark.parametrize("fault", ["numeric", "nan", "shape", "dtype", "second_level"]) +def test_incorrect_qemu_output_is_not_a_pass(tmp_path, monkeypatch, fake_target, fault): + calls = [] + def execute(*args, **kwargs): + actual = fake_target.expected.copy() + calls.append(1) + if fault == "numeric" or (fault == "second_level" and len(calls) == 2): + actual.flat[0] += np.float32(0.01) + elif fault == "nan": + actual.flat[0] = np.nan + elif fault == "shape": + actual = actual.flatten() + elif fault == "dtype": + actual = actual.astype(np.float64) + return SimpleNamespace(output=actual, elapsed_s=0.25, command=("fixture-qemu",)) + monkeypatch.setattr(probe, "run_riscv_tensor", execute) + code, report = result(tmp_path) + assert code == 1 and report["passed"] is False + assert report["completed_executions"] == 2 + assert report["passed_executions"] == (1 if fault == "second_level" else 0) + + +def test_qemu_timeout_preserves_measured_time_and_failures(tmp_path, monkeypatch, fake_target): + def timeout(*args, **kwargs): + raise RiscVTensorTimeoutError("QEMU test timeout", elapsed_s=0.125, + command=["fixture-qemu"], timeout_s=0.1) + monkeypatch.setattr(probe, "run_riscv_tensor", timeout) + code, report = result(tmp_path) + assert code == 1 + assert report["completed_executions"] == 0 + assert report["qemu_process_wall_seconds"] == {"measured_count": 2, "total": 0.25} + assert all(item["status"] == "timeout" for item in report["cases"][0]["executions"]) + + +def test_formula_disagreement_cannot_enter_qemu(tmp_path, monkeypatch, fake_target): + fake_target.expected.flat[0] += np.float32(1) + def unexpected(*args, **kwargs): + pytest.fail("Invalid reference must stop before QEMU") + monkeypatch.setattr(probe, "run_riscv_tensor", unexpected) + code, report = result(tmp_path) + assert code == 1 and report["cases"][0]["executions"] == [] + assert "NumPy formula" in report["cases"][0]["error"] + + +def test_compiler_failure_cannot_become_qemu_success(tmp_path, monkeypatch, fake_target): + monkeypatch.setattr(probe.CompilerDriver, "compile", lambda *args: SimpleNamespace( + success=False, errors=["fixture unsupported operator"])) + def unexpected(*args, **kwargs): + pytest.fail("Compiler failure must stop before QEMU") + monkeypatch.setattr(probe, "run_riscv_tensor", unexpected) + code, report = result(tmp_path) + assert code == 1 and report["completed_executions"] == 0 + assert all(item["stage"] == "compile" and "unsupported operator" in item["error"] + for item in report["cases"][0]["executions"]) + + +@pytest.mark.parametrize("fault", ["empty", "missing_family", "duplicate_name"]) +def test_invalid_coverage_fails_before_tools(tmp_path, monkeypatch, fault): + cases = probe.build_cases() + selected = [] if fault == "empty" else (cases[:-2] if fault == "missing_family" else cases + [cases[0]]) + monkeypatch.setattr(probe, "build_cases", lambda: selected) + def unexpected(**kwargs): + pytest.fail("Invalid coverage must stop before toolchain discovery") + monkeypatch.setattr(probe, "discover_toolchain", unexpected) + code, report = result(tmp_path) + assert code == 1 and "cover every required operator family" in report["error"] + + +@pytest.mark.parametrize("timeout", ["0", "-1", "nan", "inf"]) +def test_invalid_timeout_is_rejected(tmp_path, timeout): + with pytest.raises(SystemExit, match="2"): + probe.main(["--output-dir", str(tmp_path / "report"), "--timeout", timeout]) + + +def test_existing_report_is_preserved(tmp_path): + sentinel = tmp_path / "existing.txt" + sentinel.write_text("keep", encoding="utf-8") + with pytest.raises(SystemExit, match="2"): + probe.main(["--output-dir", str(tmp_path)]) + assert sentinel.read_text(encoding="utf-8") == "keep" + + +@pytest.mark.parametrize("filename", ["report.json", "report.md", "report.html"]) +def test_report_failure_invalidates_cli_success(tmp_path, monkeypatch, capsys, filename): + def successful_probe(out, report, **kwargs): + report.update(passed=True, stage="complete", cases=[]) + + original = Path.write_text + + def blocked(path, data, *args, **kwargs): + if path.name.removesuffix(".tmp") == filename: + raise PermissionError("blocked evidence") + return original(path, data, *args, **kwargs) + + monkeypatch.setattr(probe, "run_probe", successful_probe) + monkeypatch.setattr(Path, "write_text", blocked) + directory = tmp_path / "report" + assert probe.main(["--output-dir", str(directory)]) == 1 + for name in ("report.json", "report.md", "report.html"): + if name == filename: + continue + content = (directory / name).read_text(encoding="utf-8") + if name == "report.json": + saved = json.loads(content) + assert saved["passed"] is False and saved["stage"] == "report-write" + assert "blocked evidence" in saved["report_write_errors"][0] + else: + assert "Result: FAIL" in content + assert filename in capsys.readouterr().err + + +def test_report_failure_does_not_replace_primary_error(tmp_path, monkeypatch): + def failed_probe(out, report, **kwargs): + report.update(stage="toolchain", cases=[]) + raise RuntimeError("original missing compiler") + + original = Path.write_text + + def blocked(path, data, *args, **kwargs): + if path.name == "report.md": + raise OSError("disk full") + return original(path, data, *args, **kwargs) + + monkeypatch.setattr(probe, "run_probe", failed_probe) + monkeypatch.setattr(Path, "write_text", blocked) + directory = tmp_path / "report" + assert probe.main(["--output-dir", str(directory)]) == 1 + saved = json.loads((directory / "report.json").read_text(encoding="utf-8")) + assert saved["error"] == "RuntimeError: original missing compiler" + assert saved["stage"] == "toolchain" + assert "disk full" in saved["report_write_errors"][0] + + +def test_json_write_error_after_bytes_written_cannot_publish_pass(tmp_path, monkeypatch): + original = Path.write_text + + def written_then_failed(path, data, *args, **kwargs): + result = original(path, data, *args, **kwargs) + if path.name.removesuffix(".tmp") == "report.json": + raise OSError("late flush failure") + return result + + monkeypatch.setattr(Path, "write_text", written_then_failed) + report = {"passed": True, "stage": "complete", "cases": [], "seconds": 0.1} + probe.write_reports(tmp_path, report) + assert report["passed"] is False + assert not (tmp_path / "report.json").exists() + assert "Result: FAIL" in (tmp_path / "report.md").read_text(encoding="utf-8") + assert "late flush failure" in report["report_write_errors"][0] + + +def test_json_publish_failure_is_not_success(tmp_path, monkeypatch): + original = Path.replace + + def fail_publish(path, target): + if Path(target).name == "report.json": + raise PermissionError("JSON destination locked") + return original(path, target) + + monkeypatch.setattr(Path, "replace", fail_publish) + report = {"passed": True, "stage": "complete", "cases": [], "seconds": 0.1} + probe.write_reports(tmp_path, report) + assert report["passed"] is False + assert report["stage"] == "report-write" + assert not (tmp_path / "report.json").exists() + assert not (tmp_path / "report.json.tmp").exists() + assert "Result: FAIL" in (tmp_path / "report.md").read_text(encoding="utf-8") diff --git a/tests/test_w2_ci_results.py b/tests/test_w2_ci_results.py new file mode 100644 index 00000000..b411e657 --- /dev/null +++ b/tests/test_w2_ci_results.py @@ -0,0 +1,49 @@ +"""A skipped heavy frontend job must never yield green W2 acceptance.""" +import importlib.util +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +SCRIPT = Path(__file__).parents[1] / "scripts/check_w2_ci_results.py" +SPEC = importlib.util.spec_from_file_location("w2_ci_results", SCRIPT) +gate = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(gate) + + +def successful_jobs(): + return {name: {"result": "success"} for name in gate.REQUIRED_JOBS} + + +@pytest.mark.parametrize("name", gate.REQUIRED_JOBS) +@pytest.mark.parametrize("outcome", ["skipped", "failure", "cancelled", "pending", None, True]) +def test_required_job_must_actually_succeed(name, outcome): + results = successful_jobs() + results[name]["result"] = outcome + with pytest.raises(ValueError, match=name): + gate.validate_results(results) + + +@pytest.mark.parametrize("payload", [{}, [], None, {"llm-deploy": {"result": "success"}}, + {"llm-deploy": "success", "full-qwen3-frontend": {"result": "success"}}]) +def test_absent_or_malformed_job_results_fail(payload): + with pytest.raises(ValueError): + gate.validate_results(payload) + + +@pytest.mark.parametrize("payload,expected", [("invalid-json", 1), ("{}", 1), + (json.dumps(successful_jobs()), 0)]) +def test_cli_exit_matches_gate_status(monkeypatch, payload, expected): + monkeypatch.setenv("W2_JOB_RESULTS", payload) + result = subprocess.run([sys.executable, "-B", str(SCRIPT)], capture_output=True, + text=True, timeout=10) + assert result.returncode == expected + assert ("PASS:" if expected == 0 else "FAIL:") in result.stdout + + +def test_optional_full_ort_skip_does_not_replace_required_frontend(): + results = successful_jobs() + results["full-qwen3-onnx"] = {"result": "skipped"} + gate.validate_results(results) diff --git a/tests/test_w2_runtime_gate.py b/tests/test_w2_runtime_gate.py new file mode 100644 index 00000000..b33f4782 --- /dev/null +++ b/tests/test_w2_runtime_gate.py @@ -0,0 +1,192 @@ +"""Runtime acceptance must reject missing/corrupt assets and reference drift.""" +import copy +import hashlib +import io +import json +from pathlib import Path +import urllib.error + +import pytest + +from probes.w2_runtime import run as gate + + +@pytest.fixture +def assets(tmp_path): + data = {item["name"]: ("fixture:" + item["name"]).encode() for item in gate.MANIFEST["source_files"]} + manifest = copy.deepcopy(gate.MANIFEST) + for item in manifest["source_files"]: + item["sha256"] = hashlib.sha256(data[item["name"]]).hexdigest() + source = tmp_path / "source" + source.mkdir() + for name, payload in data.items(): + (source / name).write_bytes(payload) + return source, data, manifest + + +@pytest.mark.parametrize("problem", ["missing", "corrupt", "lfs", "duplicate", "traversal", "missing_manifest_entry"]) +def test_asset_verification_rejects_incomplete_or_altered_source(assets, problem): + source, _, manifest = assets + file = source / "tokenizer.json" + if problem == "missing": + file.unlink() + elif problem == "corrupt": + file.write_bytes(b"corrupted") + elif problem == "lfs": + file.write_bytes(b"version https://git-lfs.github.com/spec/v1\n") + elif problem == "duplicate": + manifest["source_files"].append(manifest["source_files"][0]) + elif problem == "traversal": + manifest["source_files"][0]["name"] = "../config.json" + else: + manifest["source_files"].pop() + with pytest.raises((ValueError, FileNotFoundError)): + gate.verify_assets(source, manifest) + + +def test_download_only_pinned_tokenizer_files_and_reuses_verified_assets(assets, tmp_path, monkeypatch): + _, data, manifest = assets + urls = [] + def download(url, path): + urls.append(url) + Path(path).write_bytes(data[Path(path).name]) + monkeypatch.setattr(gate, "download_file", download) + out = tmp_path / "download" + result = gate.acquire_assets(out, manifest) + assert not result["reused"] and len(result["files"]) == 7 + assert len(urls) == 7 and all(manifest["revision"] in url for url in urls) + assert not any("safetensors" in url or ".onnx" in url for url in urls) + assert gate.acquire_assets(out, manifest)["reused"] + assert len(urls) == 7 + + +def test_download_hash_failure_never_installs_partial_directory(assets, tmp_path, monkeypatch): + _, _, manifest = assets + monkeypatch.setattr(gate, "download_file", lambda url, path: Path(path).write_bytes(b"wrong")) + out = tmp_path / "download" + with pytest.raises(ValueError, match="SHA256"): + gate.acquire_assets(out, manifest) + assert not out.exists() + assert not list(tmp_path.glob("qwen3-tokenizer-*")) + + +def test_corrupt_existing_cache_is_preserved_not_silently_replaced(assets, monkeypatch): + source, _, manifest = assets + (source / "tokenizer.json").write_bytes(b"keep this evidence") + monkeypatch.setattr(gate, "download_file", lambda *a: pytest.fail("must not redownload")) + with pytest.raises(ValueError, match="SHA256"): + gate.acquire_assets(source, manifest) + assert (source / "tokenizer.json").read_bytes() == b"keep this evidence" + + +def test_download_retries_only_transient_http_errors(tmp_path, monkeypatch): + attempts = [] + def open_url(*args, **kwargs): + attempts.append(1) + if len(attempts) == 1: + raise urllib.error.HTTPError("https://example.invalid/", 503, "busy", {}, None) + return io.BytesIO(b"payload") + monkeypatch.setattr(gate.urllib.request, "urlopen", open_url) + monkeypatch.setattr(gate.time, "sleep", lambda _: None) + gate.download_file("https://example.invalid/test", tmp_path / "asset") + assert len(attempts) == 2 and (tmp_path / "asset").read_bytes() == b"payload" + + +def test_download_rejects_oversize_data(tmp_path, monkeypatch): + monkeypatch.setattr(gate, "MAX_FILE_BYTES", 4) + monkeypatch.setattr(gate.urllib.request, "urlopen", lambda *a, **k: io.BytesIO(b"12345")) + with pytest.raises(ValueError, match="exceeds"): + gate.download_file("https://example.invalid/test", tmp_path / "asset") + + +def test_missing_assets_produces_failed_report_with_source_identity(tmp_path, monkeypatch): + monkeypatch.setattr(gate, "require_environment", lambda: {"fixture": True}) + out = tmp_path / "report" + assert gate.main(["--tokenizer-dir", str(tmp_path / "absent"), "--output-dir", str(out)]) == 1 + r = json.loads((out / "report.json").read_text()) + assert not r["passed"] and r["stage"] == "assets" + assert "Missing tokenizer asset" in r["error"] + assert r["source_sha256"]["scratchv/runtime/llm_inputs.py"] + assert "FAIL" in (out / "report.md").read_text() + + +def test_validation_failure_preserves_completed_cases_and_escapes_html(tmp_path, monkeypatch): + def fail(args, report): + report["stage"] = "tokenizer" + report["tokenizer_cases"].append({"name": "first", "passed": True}) + raise ValueError("bad ") + monkeypatch.setattr(gate, "run_checks", fail) + out = tmp_path / "report" + assert gate.main(["--tokenizer-dir", str(tmp_path / "fixture"), "--output-dir", str(out)]) == 1 + r = json.loads((out / "report.json").read_text()) + assert r["stage"] == "tokenizer" and len(r["tokenizer_cases"]) == 1 and not r["passed"] + assert ""} + 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 "门槛通过" in markdown and "总体结论" 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 "