diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50111f7..6aa2777 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,16 @@ jobs: $PY -m pip install -e ".[all]" $PY -m pip install "pytest>=7,<10" "PyYAML==6.0.3" + - 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__" + # Unconditional: a docs-only change never selects the deployment scope, so # this check cannot live behind a path filter. - name: Check public instructions and Bash syntax @@ -192,6 +202,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 \ diff --git a/.github/workflows/llm-deploy.yml b/.github/workflows/llm-deploy.yml index 22a0227..8c86a43 100644 --- a/.github/workflows/llm-deploy.yml +++ b/.github/workflows/llm-deploy.yml @@ -15,9 +15,8 @@ name: LLM Deploy v1.0 # 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. +# Explicit manual runs can also call the delivered W3 workflows from the same +# commit. The default W1/W2 checks do not imply full-model IR acceptance. on: workflow_call: @@ -25,6 +24,12 @@ on: # 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 permissions: contents: read @@ -39,6 +44,11 @@ env: QWEN3_PY: output/qwen3-probe-venv/bin/python jobs: + w3-validation: + name: "W3 / explicit full validation" + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_w3_validation }} + uses: ./.github/workflows/w3-nightly.yml + llm-deploy: name: "LLM Deploy v1.0 / W1 + W2" runs-on: ubuntu-24.04 @@ -85,6 +95,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 @@ -534,3 +547,33 @@ jobs: env: W2_JOB_RESULTS: ${{ toJSON(needs) }} run: python3 scripts/check_w2_ci_results.py + + w3-execution-summary: + name: "W3 / execution scope" + needs: [w3-validation] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: State whether W3 actually ran + env: + W3_RESULT: ${{ needs.w3-validation.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 execution scope\n\n") + stream.write(f"**{label}** — W3 preparation and full 28-layer IR numeric.\n\n") + if result == "skipped": + stream.write("W3 is opt-in and is not executed by default on pull requests. W1/W2 success is not W3 acceptance.\n\n") + elif result == "success": + stream.write("See the W3 preparation and W3 complete IR numeric summaries in this run for numerical data.\n\n") + else: + stream.write("No W3 acceptance claimed; inspect the called jobs and any partial evidence.\n\n") + stream.write("
Scope and evidence\n\nManual LLM Deploy runs can enable `run_w3_validation`. " + "Numerical reports are retained in `w3-preparation-reports` and `w3-full-numeric-reports` when generated. " + "Full-model QEMU is a separate W4 gate; independent reproduction and team sign-off remain separate.\n\n
\n") + PY diff --git a/.github/workflows/w3-full-numeric.yml b/.github/workflows/w3-full-numeric.yml new file mode 100644 index 0000000..2f99f0b --- /dev/null +++ b/.github/workflows/w3-full-numeric.yml @@ -0,0 +1,146 @@ +name: W3 full IR numeric +on: + workflow_dispatch: + inputs: + case: + description: Full suite or one-case diagnostic (subset returns PARTIAL if numerically correct) + type: choice + default: all + options: + - all + - short_17 + workflow_call: + inputs: + case: + description: Full suite or short_17 diagnostic; partial coverage never passes acceptance + type: string + required: false + default: all +permissions: + contents: read +concurrency: + group: w3-full-numeric-${{ github.ref }} + cancel-in-progress: false +jobs: + full-numeric: + 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.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 diff --git a/.github/workflows/w3-nightly.yml b/.github/workflows/w3-nightly.yml new file mode 100644 index 0000000..e145354 --- /dev/null +++ b/.github/workflows/w3-nightly.yml @@ -0,0 +1,43 @@ +name: W3 nightly +on: + workflow_dispatch: + workflow_call: + schedule: + # 03:23 Asia/Shanghai. Jobs stay disabled until the repository opts in. + - cron: '23 19 * * *' +permissions: + contents: read +concurrency: + group: w3-nightly-${{ github.ref }} + cancel-in-progress: false +jobs: + preparation: + if: ${{ github.event_name == 'workflow_dispatch' || vars.W3_NIGHTLY_ENABLED == 'true' }} + uses: ./.github/workflows/w3-preparation.yml + full-numeric: + if: ${{ github.event_name == 'workflow_dispatch' || vars.W3_NIGHTLY_ENABLED == 'true' }} + uses: ./.github/workflows/w3-full-numeric.yml + with: + case: all + acceptance: + if: ${{ always() && (github.event_name == 'workflow_dispatch' || vars.W3_NIGHTLY_ENABLED == 'true') }} + needs: [preparation, full-numeric] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + PREPARATION_RESULT: ${{ needs.preparation.result }} + FULL_NUMERIC_RESULT: ${{ needs.full-numeric.result }} + steps: + - name: Require both complete gates + run: | + set -euo pipefail + { + printf '## W3 automated gates\n\n' + printf 'Commit: `%s`\n\n' "$GITHUB_SHA" + printf '| Gate | Result |\n| --- | --- |\n' + printf '| Preparation | %s |\n' "$PREPARATION_RESULT" + printf '| Complete seven-case Qwen3 IR numeric | %s |\n\n' "$FULL_NUMERIC_RESULT" + printf 'Only two successful gates pass this run. This does not approve the W3 team milestone or replace independent reproduction.\n' + } >> "$GITHUB_STEP_SUMMARY" + test "$PREPARATION_RESULT" = success + test "$FULL_NUMERIC_RESULT" = success diff --git a/.github/workflows/w3-preparation.yml b/.github/workflows/w3-preparation.yml new file mode 100644 index 0000000..97751c2 --- /dev/null +++ b/.github/workflows/w3-preparation.yml @@ -0,0 +1,112 @@ +name: W3 preparation +on: + workflow_dispatch: + workflow_call: +permissions: + contents: read +concurrency: + group: w3-preparation-${{ github.ref }} + cancel-in-progress: false +jobs: + preparation: + 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/ diff --git a/LICENSES/Eigen-MPL-2.0.txt b/LICENSES/Eigen-MPL-2.0.txt new file mode 100644 index 0000000..d0a1fa1 --- /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 0000000..6eaafd6 --- /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 de813f9..dea51f7 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/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 7a5b29e..f393af2 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/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 0000000..0c3fc66 --- /dev/null +++ "b/docs/llm-deploy-v1.0/W3/Linux-CI\344\270\216Nightly.md" @@ -0,0 +1,250 @@ +# W3 Linux CI 与 Nightly + +普通 PR 的通用测试、部署与文档检查归入统一 **CI** 运行页,详见 [Actions 入口说明](../../CI.md)。本页完整 W3 手动验证和 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-11 / run 38131237341](https://github.com/yuki-328/ScratchV/actions/runs/38131237341),执行提交为 `210baa5fbd87844e61e5dfb273b49748a978b708`;该提交的 preparation、完整七组 full-numeric 及汇总均通过。后续 PR 修改和普通 CI 通过不自动继承这次完整模型结论。下面按日期和执行提交分别记录证据;没有启用定时 Nightly,手动调用及作者数组复核不替代他人独立执行。团队确认内容见 [独立复现与验收清单](独立复现与验收清单.md)。 + +## 2026-10-11 Linux 实测记录 + +运行:[38131237341](https://github.com/yuki-328/ScratchV/actions/runs/38131237341),执行提交 `210baa5fbd87844e61e5dfb273b49748a978b708`,通过既有手动入口显式开启 `run_w3_validation=true`。Actions 页面分别提供 **W3 preparation** 和 **W3 complete IR numeric** 摘要;它们不是完整模型 QEMU 的结果。 + +| 检查 | 实际结果 | 流程耗时 | +|---|---|---:| +| preparation | medium、真实权重子图、Attention QEMU、完整资产预检和 layer-diff,五项 PASS | 150.176 s | +| 完整七组 full-numeric | 真实 28 层、FP32、L256、无 KV cache;reference-v4/avx512、IR none;普通/诊断全部 logits 最大绝对误差 0,相对 L2 误差 0,余弦相似度 1;严格 `max_abs < 1e-4`、`rtol=0` | 621.381 s | +| 保存数组离线复核 | 七组及四项不变量 PASS;只读取已有数组,没有重新执行模型 | 44.257 s | + +完整输出比较覆盖全部 256 个位置,包括 padding query;每组 30 个诊断检查点用于定位,不能把最终 logits 的指标与检查点混成同一统计。耗时包含准备、执行、比较与证据写盘,不是单次前向性能 benchmark;也不能与不同 CPU 策略的历史运行直接作为加速比。 + +报告保存在该运行的 `w3-preparation-reports`、`w3-full-numeric-reports`;原始数组单独保留于 `w3-full-numeric-raw`,受各自产物保留期限制。这些是执行提交的历史数据,本次文档更新未重跑完整模型,也未补记独立复现或定时 Nightly 成功。 + +## 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-preparation.yml](../../../.github/workflows/w3-preparation.yml) | `workflow_dispatch` | `workflow_call`,无参数 | 相关回归和五项 preparation 全部成功,证据收集完整 | +| [w3-full-numeric.yml](../../../.github/workflows/w3-full-numeric.yml) | `case=all` 为默认;`short_17` 仅诊断 | `case` 字符串,默认 `all` | 完整七组真实 28 层 IR/ORT 数值门槛及不变量全部通过 | +| [w3-nightly.yml](../../../.github/workflows/w3-nightly.yml) | 显式运行完整编排,不必先启用定时 | `workflow_call`;调用上述两套同提交工作流 | 两套调用都为 `success`;失败、取消或跳过均不能通过汇总 | + +Nightly 使用仓库内相对路径调用,因此被调用工作流与调用者来自同一提交;没有从其他分支临时取最新版脚本。[GitHub reusable workflow 说明](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows) + +完整比较固定 `--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 页面运行 `W3 nightly` 的手动入口,可一次执行两套流程;或分别运行 `W3 preparation` 和 `W3 full IR numeric`,后者选择 `all`。记录 run URL、commit SHA 和 attempt,确认两套结果来自同一代码版本。 +4. 检查资源日志、依赖安装、资产获取与哈希、回归、QEMU、数值比较和产物上传。需要整个流程完成,不能只看某一步为绿。 +5. 在原始产物过期前下载留存,按下文核对。出现 Linux 数值差异时保留失败数组和环境信息,用已有 layer-diff/localize 入口定位;不得通过缩减 case、忽略 padding 或放宽阈值取得通过。 + +需要命令行时,可在确定运行仓库和分支后使用以下模板;替换占位符。实际已执行记录见本文首节,命令模板本身不是运行证据: + +```bash +set -euo pipefail +gh workflow run w3-nightly.yml --repo YOUR_ACCOUNT/ScratchV --ref YOUR_W3_BRANCH +gh run list --repo YOUR_ACCOUNT/ScratchV --workflow w3-nightly.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/w3-preparation.yml .github/workflows/w3-full-numeric.yml .github/workflows/w3-nightly.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 0000000..fa1405d --- /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 数值验证、准备探测、诊断与复现交付,代码见 [PR #96](https://github.com/ScratchV-Compiler/ScratchV/pull/96)。完整模型通过结果按实际执行提交登记;PR 后续修改及普通 CI 通过不自动成为完整模型重新执行的证据。复现时固定并记录实际完整 SHA,不能只记录分支名或旧基线。 + +最近一次已登记的完整作者运行是 **2026-10-11** 的 [Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/38131237341),执行提交为 `210baa5fbd87844e61e5dfb273b49748a978b708`:preparation 五项及完整七组均通过;reference-v4/avx512 下普通/诊断完整 logits 最大绝对误差均为 0,完整 runner 耗时 621.381 秒。下载原始数组的离线复核也通过,但不代替另一位开发者执行。10 月 5 日第四轮及此前失败记录保留原提交、环境和数值,见 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。本次仅更新文档,没有重新执行完整模型;定时 Nightly 和 E1/E2/E5 独立复现仍待登记。 + +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 的第四轮 avx2-fma3 和 2026-10-11 avx512 七组通过分别属于对应提交与环境,原始数组离线复核结果也分别登记。统一 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 与后续阶段 + +新增 [.github/workflows/w3-preparation.yml](../../../.github/workflows/w3-preparation.yml) 保留手动准备验收入口,并允许 [Nightly 编排](../../../.github/workflows/w3-nightly.yml) 复用。它安装固定依赖,下载并校验固定 checkpoint 和 ONNX 资产,运行回归与五项入口。本轮通过已注册的 `llm-deploy.yml` 显式开启 `run_w3_validation=true`,从同一提交调用 W3 编排;preparation 已实际通过,完整模型状态另行记录。Nightly 定时任务需要仓库变量显式启用,手动调用不构成 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 0000000..f20ddbc --- /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 0000000..188ece4 --- /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,94 @@ +# W3 工作总结与验收进度 + +对外复现统一使用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 统一环境与资产准备](../LINUX_REPRODUCTION.md),并在同一个 Bash 会话、仓库根目录执行以下命令;`SCRATCHV_PYTHON`、`SCRATCHV_CC`、`SCRATCHV_QEMU` 由该指南设置。 + +更新日期:2026-10-13;本次补齐已有 Linux 运行的索引与提交归属,未重新执行模型。[PR #96](https://github.com/ScratchV-Compiler/ScratchV/pull/96) 仍需团队契约确认、E1/E2/E5 独立执行及实际定时 Nightly 验收,正式阶段出口仍为 false。 + +最近登记的完整作者运行是 [2026-10-11 / run 38131237341](https://github.com/yuki-328/ScratchV/actions/runs/38131237341),执行提交 `210baa5fbd87844e61e5dfb273b49748a978b708`:preparation 五项、完整七组和汇总通过;reference-v4/avx512 下普通/诊断最终 logits 最大绝对误差均为 0,完整 runner 耗时 621.381 秒,下载数组离线复核也通过。它只证明该提交与环境的结果,不等于当前 PR head 已重跑完整模型。复现应固定并记录实际完整 SHA;各轮记录见 [Linux CI 与 Nightly](Linux-CI与Nightly.md)。 + +## 1. 按开发计划核对 + +下表保留 2026-10-05 第四轮的固定提交证据;最新 2026-10-11 记录见上文入口。历史数据不因 PR 更新而变成当前提交的通过结论。 + +| 计划项 | 已完成内容 | 实测证据 | 当前状态 | +|---|---|---|---| +| 基础 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 0000000..b6a7adf --- /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 0000000..5838e35 --- /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 绿;本地通过、读取作者的报告或校验作者的数组,都不能替代三人的独立执行。 + +代码见 [PR #96](https://github.com/ScratchV-Compiler/ScratchV/pull/96)。最近登记的完整作者运行是 [2026-10-11 / run 38131237341](https://github.com/yuki-328/ScratchV/actions/runs/38131237341),执行提交 `210baa5fbd87844e61e5dfb273b49748a978b708`:preparation、完整七组 full 和汇总通过,reference-v4/avx512 下普通/诊断完整 logits 最大绝对误差均为 0;下载数组离线复核通过。历史 v2/v3 及 10 月 5 日各轮报告保留原样,见 [Linux 实测及产物核对](Linux-CI与Nightly.md)。每位复现者必须固定并记录自己的实际 SHA;作者旧运行不代表后续 PR head 重新执行通过,也不替代 E1/E2/E5 独立复现或定时 schedule 验收。 + +## 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/avx2-fma3 记录属于提交 `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` 和最新索引中的 `210baa5` 各自标识一次具体运行,不是所有后续修改的验收标签。 + +如需交付未提交修改或离线源码,可从所需 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/w3-preparation.yml](../../../.github/workflows/w3-preparation.yml) 相同:从仓库 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 编排准备项与完整七组。作者 [2026-10-11 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/38131237341)已在其执行提交通过两套 gate 和汇总,完整 raw 数组离线复核已通过;应按每次实际 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 硬门槛 | 2026-10-11 作者 v4/avx512 七组及手动编排通过;普通/诊断完整 logits 最大绝对误差均为 0;保存数组离线复核通过;团队验收仍待完成 | 执行提交 `210baa5`;待团队核对对应报告版本 | +| medium、reference 模式与比较范围 | 候选契约已给出 | 待团队确认 | +| W1 人工前置和遗留问题处理 | 本文件不代替既有阶段确认 | 填入团队已有记录或未完成项 | +| E1 / E2 / E5 独立复现 | 本文件未记录三人通过 | 各自填写第 5 节 | +| W3 Linux 手动 CI | 2026-10-11 执行提交的两套 gate 及汇总通过;不等于后续 PR head 重新执行 | [run 38131237341](https://github.com/yuki-328/ScratchV/actions/runs/38131237341),提交 `210baa5` | +| 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 a3ebed1..9a4507f 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" @@ -17,7 +17,7 @@ ### 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 目标 @@ -157,7 +157,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 已提供 `w3-preparation.yml`、`w3-full-numeric.yml` 和显式启用的 `w3-nightly.yml`,并可通过既有 `llm-deploy.yml` 手动设置 `run_w3_validation=true`,从同一提交调用 W3 编排。作者[第四轮 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 @@ -206,6 +206,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 单次前向(硬门槛) | 项目 | 内容 | @@ -300,7 +304,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 | @@ -335,4 +339,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/probes/w1_qwen3_export/run.py b/probes/w1_qwen3_export/run.py index ff35916..8c9e2da 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_qwen3_small/README.md b/probes/w2_qwen3_small/README.md index bda658d..0a4335b 100644 --- a/probes/w2_qwen3_small/README.md +++ b/probes/w2_qwen3_small/README.md @@ -152,8 +152,9 @@ padding 隔离。每个比较仍要求 FP32、形状一致、有限值、最大 失败、输入目录混用或哈希不一致应直接失败,不能只靠当前 ORT 比较通过。该绑定 用于避免意外混用产物,不是第三方认证;参考计算仍须在指定环境重新生成。 -当前执行环境与完整源码归属的报告增强单独见 [W1 PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93)。 -W2 分支未包含该报告代码修复;未合并前不能把原导出环境当成本次 QEMU 对照的执行环境。 +当前代码已包含 [W1 PR #93](https://github.com/ScratchV-Compiler/ScratchV/pull/93) 的执行环境与源码归属修复。 +报告顶层 `environment` 和 `source_sha256` 记录本次 RISC-V 探测进程及其 ORT/IR 参考的环境与源码; +`export_evidence.environment` 仅记录上游导出环境,不能当成本次 QEMU 对照的执行环境。 `output/qwen3-riscv/` 包含 `report.json/md/html`、生成的 C、启动汇编与链接脚本、 ELF、编译器/QEMU 命令与日志、每次执行的输入和 UART 二进制以及 QEMU/ORT 数组。 diff --git a/probes/w2_qwen3_small/model.py b/probes/w2_qwen3_small/model.py index 2d70880..40043dd 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 d71b5e8..0b1e055 100644 --- a/probes/w2_qwen3_small/riscv.py +++ b/probes/w2_qwen3_small/riscv.py @@ -386,7 +386,7 @@ def append_detail(title, headers, rows, note): graph_note = ("图指模型计算步骤及其连接关系:normal 只输出最终 logits;diagnostic 增加中间检查点以定位误差,含额外打包与传输开销。" "none/basic/all 指 ScratchV IR 优化:none 不优化;basic 提前计算常量并删除不影响输出的计算;" - "all 再加入局部指令简化、乘加融合和循环内重复计算外提。它们不是 C 编译优化等级,本探测的 C 编译固定使用 -O2。") + "all 运行已注册的全部优化 pass,包括局部指令简化和循环不变计算外提;乘加融合目前保留原指令、不执行融合。它们不是 C 编译优化等级,本探测的 C 编译固定使用 -O2。") time_note = (graph_note + " QEMU 墙钟时间包含启动、目标程序计算、输出打包/串口传输和退出;超时还含清理," "不含主机输入准备或输出解码。未测得的时长不按零计;平均/最大值仅统计有测量值的样本。" "七种输入不是重复采样,不能据此推导稳定加速比,也不设置性能通过阈值。" diff --git a/probes/w3_attention/README.md b/probes/w3_attention/README.md new file mode 100644 index 0000000..06c12ae --- /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 0000000..89a541a --- /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 0000000..a9c7bff --- /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 0000000..eacf19a --- /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 0000000..02661f8 --- /dev/null +++ b/probes/w3_full_preflight/README.md @@ -0,0 +1,15 @@ +# W3 完整资产与静态尺寸预检 + +复现统一采用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 环境与资产准备](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md),再在同一个 Bash 会话、仓库根目录执行命令;该指南设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC` 和 `SCRATCHV_QEMU`。 + +~~~bash +set -euo pipefail +W3_MODEL=/absolute/path/to/qwen3-0.6b-onnx +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_full_preflight/run.py --model-dir "$W3_MODEL" --output-dir output/w3-full-preflight-new +~~~ + +离线流式校验 W1 固定 manifest 的模型和三个 external-data 文件,复用 ONNX 静态契约/范围检查,统计 initializer、嵌入/外部 tensor、具名 value 与输出的逻辑字节。不加载外部数组进行 IR 运算,也不执行完整 ORT。 + +报告始终标记 full_ir_executed=false、w3_exit_accepted=false。PASS 只代表准备检查通过;sum_named_value_logical_bytes 包含别名、可能重复,不能作为所需物理内存上限或峰值承诺。process_peak_rss_bytes 只属于这个 metadata-only 预检进程。 + +完整数值验收仍需 W1 人工前置确认、中等与真实子图基线、完整 IR/ORT <1e-4、三人复现和 Nightly。参见 [W3 准备说明](../../docs/llm-deploy-v1.0/W3/README.md)。 diff --git a/probes/w3_full_preflight/run.py b/probes/w3_full_preflight/run.py new file mode 100644 index 0000000..8943d32 --- /dev/null +++ b/probes/w3_full_preflight/run.py @@ -0,0 +1,101 @@ +"""Verify fixed full-model assets and size accounting without executing full IR.""" +from __future__ import annotations +import argparse +from collections import Counter +import math +from pathlib import Path +import sys +import time +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +import numpy as np +import onnx +from probes.w1_qwen3_export.run import MANIFEST, verify_files, inspect_model, tensors +from probes.w3_common import new_output_dir, source_evidence, write_reports, process_peak_rss_bytes + +def tensor_size(tensor): + return math.prod(tensor.dims) * np.dtype(onnx.helper.tensor_dtype_to_np_dtype(tensor.data_type)).itemsize + +def value_size(value): + kind = value.type.tensor_type + if not kind.HasField("shape"): + return None + dimensions = [] + for dim in kind.shape.dim: + if not dim.HasField("dim_value"): + return None + dimensions.append(dim.dim_value) + return math.prod(dimensions) * np.dtype(onnx.helper.tensor_dtype_to_np_dtype(kind.elem_type)).itemsize + +def size_accounting(model): + names = {} + unknown = [] + for value in (*model.graph.input, *model.graph.value_info, *model.graph.output): + count = value_size(value) + if count is None: + unknown.append(value.name) + else: + if value.name in names and names[value.name] != count: + raise ValueError(f"Conflicting shape information: {value.name}") + names[value.name] = count + initializer_bytes = sum(tensor_size(t) for t in model.graph.initializer) + all_tensor_bytes = sum(tensor_size(t) for t in tensors(model)) + graph_names = {v.name for v in model.graph.input} | {t.name for t in model.graph.initializer} + graph_names.update(name for node in model.graph.node for name in node.output if name) + known = set(names) | {t.name for t in model.graph.initializer} + return { + "initializer_logical_bytes": initializer_bytes, + "all_embedded_and_external_tensor_logical_bytes": all_tensor_bytes, + "sum_named_value_logical_bytes": sum(names.values()), + "named_values_with_static_size": len(names), "unknown_size_values": unknown, + "graph_value_count": len(graph_names), + "values_without_persisted_static_size": sorted(graph_names - known), + "output_logical_bytes": {v.name:value_size(v) for v in model.graph.output}, + "largest_named_values": sorted(names.items(), key=lambda x:(-x[1],x[0]))[:10], + "scope": "Static ONNX logical sizes, including aliases. Not measured IR live storage or a process RAM requirement.", + } + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args(argv) + out = new_output_dir(args.output_dir) + report = { + "gate":"preflight:w3-full-assets", "status":"FAIL", "passed":False, "stage":"source_evidence", + "full_ir_executed":False, "w3_exit_accepted":False, + "scope":"Asset integrity and static size preparation only; no ORT/IR/full-model numerical acceptance.", + "required_before_full_acceptance":[ + "W1 human acceptance and interface/risk decisions", + "Medium and pretrained subgraph numeric/memory baselines", + "Measured host capacity and bounded full IR/ORT runner", + "Full IR vs ORT <1e-4, three independent reproductions and successful Nightly", + ], + } + start = time.perf_counter() + try: + report.update(source_evidence()) + report["stage"] = "asset_hashes" + files = verify_files(args.model_dir) + report["files"] = files + report["source_revision"] = MANIFEST["revision"] + report["stage"] = "onnx_contract" + report["onnx_contract"] = inspect_model(args.model_dir, files) + report["stage"] = "static_size_accounting" + model = onnx.load(str(args.model_dir / "model.onnx"), load_external_data=False) + report["node_count"] = len(model.graph.node) + report["operator_counts"] = dict(Counter(node.op_type for node in model.graph.node)) + report["sizes"] = size_accounting(model) + report["weights_file_bytes"] = sum(item["bytes"] for item in files if item["name"] != "model.onnx") + report.update(passed=True, status="PASS", stage="complete") + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + report.update(elapsed_seconds=time.perf_counter()-start, + process_peak_rss_bytes=process_peak_rss_bytes(), + peak_scope="This metadata-only preflight process, not full parser/IR execution") + write_reports(out,report) + return 0 if report["passed"] else 1 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_layer_diff/README.md b/probes/w3_layer_diff/README.md new file mode 100644 index 0000000..94c18dd --- /dev/null +++ b/probes/w3_layer_diff/README.md @@ -0,0 +1,65 @@ +# W3 offline checkpoint comparison + +Reproduction targets **Ubuntu 24.04 x86_64, Bash and Python 3.12**. Complete the [Linux environment and asset setup](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md) first. Run commands from the repository root in the same Bash session; the setup exports `SCRATCHV_PYTHON`, `SCRATCHV_CC` and `SCRATCHV_QEMU`. + +This tool compares recorded NPZ tensors without executing a model. It reuses +`probes.w2_qwen3_small.diagnostics.compare_outputs`: exact shape and dtype, +finite numeric values, and strict `max_abs < atol` (default `1e-5`). + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" probes/w3_layer_diff/run.py --actual output/w3/medium/traces/full_seed_0/ir_diagnostic_none.npz --reference output/w3/medium/traces/full_seed_0/reference.npz --schema output/w3/medium/trace_schema.json --out output/w3/layerdiff-full +``` + +`--out` must be new. The shared W3 publisher writes `report.json`, `report.md` +and `report.html` atomically per file, with JSON last. Publication errors fail +the command. No network, pickle loading or model execution is involved. + +The schema is UTF-8 JSON with an explicit ordered checkpoint list: + +```json +{"version":1,"checkpoints":[{"name":"layer_0.hidden","shape":[1,256,128],"dtype":"float32","layer":0,"checkpoint":"hidden","sequence_axis":1}]} +``` + +Only `name`, `shape`, and `dtype` are mandatory in each entry. `layer` is a +nonnegative integer; `checkpoint` is a local selector name; `sequence_axis` is +an optional valid axis or null. `--reference-schema` can provide a second schema +which must match exactly, including checkpoint order and metadata. Every NPZ +must have exactly the schema names, no missing/extra arrays or duplicate ZIP +members. Duplicate JSON keys/checkpoint names, object arrays, shape/type +disagreements and nonfinite values fail before comparison. ZIP expanded size +and schema logical size are limited by `--max-bytes` (default 2 GiB per NPZ); +schema size is limited to 8 MiB. NPY v1/v2 headers and payload sizes are checked +against the schema before NumPy allocates an array. This is validation of local evidence, not a +claim to safely process arbitrary hostile archives. + +Repeat `--layer N` or `--checkpoint NAME` to select checkpoints. A checkpoint +selector matches an exact name or the optional local `checkpoint` field. +Layer and checkpoint filters intersect; unknown, duplicate and empty selections +fail. All evidence names, shapes, dtypes and finite values are still validated. +Every explicitly selected comparison reports **PARTIAL**, even when the +selection happens to cover every checkpoint; it cannot produce a full PASS. + +Exit codes: `0` = all recorded checkpoints compared and passed; `1` = numeric +difference, invalid evidence or report publication failure; `2` = selected +checkpoints passed with partial coverage. Reports include ordered per-checkpoint +errors, the first divergence, the worst absolute-error element, source evidence +and artifact SHA-256 hashes. A full checkpoint PASS establishes no claim about +whole-model correctness, causal/padding tests or backend acceptance. + +## IR memory observations + +`IRInterpreter.run(..., collect_memory_stats=True)` populates +`ExecutionResult.memory_stats`; it is `None` by default. The optional observation +records input/initializer logical bytes, peak retained logical bytes, peak +deduplicated NumPy backing storage, and retained value counts. Inputs retain +their existing copy semantics; initializers count referenced bound globals +(including scalar global constants). Views count in logical bytes but share one +storage owner. ALLOCA buffers are counted. Final return copies are included in +peaks and separately reported, but excluded from retained counts. + +These are instruction-boundary snapshots of values retained by the interpreter, +not RSS or total process allocation. They exclude caller arrays, transient +NumPy kernel buffers, Python object sizes and allocator overhead. The observer +does not release values, change kernels, alter control flow or add tensor copies. +Use the separate W3 process RSS metric for process-level memory evidence. diff --git a/probes/w3_layer_diff/__init__.py b/probes/w3_layer_diff/__init__.py new file mode 100644 index 0000000..be43338 --- /dev/null +++ b/probes/w3_layer_diff/__init__.py @@ -0,0 +1 @@ +"""Offline W3 checkpoint evidence comparison.""" diff --git a/probes/w3_layer_diff/run.py b/probes/w3_layer_diff/run.py new file mode 100644 index 0000000..7be1e81 --- /dev/null +++ b/probes/w3_layer_diff/run.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Compare recorded checkpoint arrays without executing a model or loading pickle.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +from pathlib import Path +import sys +import zipfile + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.w2_qwen3_small.diagnostics import compare_outputs, tensor_diff +from probes.w3_common import source_evidence, write_reports + +DEFAULT_MAX_BYTES = 2 * 1024**3 + + +class EvidenceError(ValueError): + def __init__(self, message, checkpoint=None, details=None): + super().__init__(message) + self.checkpoint = checkpoint + self.details = details + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise EvidenceError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _integer(value): + return isinstance(value, int) and not isinstance(value, bool) + + +def load_schema(path: Path) -> list[dict]: + if path.stat().st_size > 8 * 1024**2: + raise EvidenceError("schema exceeds 8 MiB limit") + document = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique_object, + parse_constant=lambda value: (_ for _ in ()).throw( + EvidenceError(f"nonfinite JSON constant: {value}"))) + if (not isinstance(document, dict) or not _integer(document.get("version")) + or document["version"] != 1): + raise EvidenceError("schema must be an object with version=1") + entries = document.get("checkpoints") + if not isinstance(entries, list) or not entries: + raise EvidenceError("schema checkpoints must be a nonempty ordered list") + seen = set() + for entry in entries: + if not isinstance(entry, dict) or not {"name", "shape", "dtype"} <= set(entry): + raise EvidenceError("each checkpoint requires name, shape and dtype") + name = entry["name"] + if not isinstance(name, str) or not name or name in seen: + raise EvidenceError(f"empty, invalid or duplicate checkpoint name: {name!r}") + seen.add(name) + shape = entry["shape"] + if (not isinstance(shape, list) + or any(not _integer(dim) or dim < 0 for dim in shape)): + raise EvidenceError("shape must contain nonnegative integer dimensions", name) + try: + if not isinstance(entry["dtype"], str): + raise TypeError("dtype must be a string") + dtype = np.dtype(entry["dtype"]) + except (TypeError, ValueError) as exc: + raise EvidenceError(f"invalid dtype: {entry['dtype']!r}", name) from exc + if dtype.kind not in "biuf" or dtype.hasobject: + raise EvidenceError("dtype must be real numeric or boolean, without objects", name) + if "layer" in entry and (not _integer(entry["layer"]) or entry["layer"] < 0): + raise EvidenceError("layer must be a nonnegative integer", name) + if "checkpoint" in entry and (not isinstance(entry["checkpoint"], str) + or not entry["checkpoint"]): + raise EvidenceError("checkpoint must be a nonempty string", name) + axis = entry.get("sequence_axis") + if axis is not None and (not _integer(axis) or not -len(shape) <= axis < len(shape)): + raise EvidenceError("sequence_axis is outside checkpoint rank", name) + return entries + + +def load_arrays(path: Path, entries: list[dict], max_bytes=DEFAULT_MAX_BYTES) -> dict: + """Preflight ZIP membership/size, then load numeric NPY arrays with no pickle.""" + if not _integer(max_bytes) or max_bytes < 1: + raise EvidenceError("max_bytes must be a positive integer") + names = [entry["name"] for entry in entries] + by_name = {entry["name"]: entry for entry in entries} + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + raw_names = [member.filename for member in members] + if len(set(raw_names)) != len(raw_names): + raise EvidenceError("NPZ contains duplicate ZIP members") + if any(not name.endswith(".npy") or name == ".npy" for name in raw_names): + raise EvidenceError("NPZ must contain only named .npy array members") + keys = [name[:-4] for name in raw_names] + # NpzFile accepts both member paths and names without the .npy suffix; + # keys such as x and x.npy would otherwise resolve to the same member. + if set(keys) & set(raw_names): + raise EvidenceError("NPZ contains ambiguous checkpoint/member name aliases") + if len(set(keys)) != len(keys) or set(keys) != set(names): + missing, extra = sorted(set(names) - set(keys)), sorted(set(keys) - set(names)) + first = next((name for name in names if name not in keys), None) + raise EvidenceError(f"NPZ names must exactly match schema; missing={missing}, extra={extra}", first) + if sum(member.file_size for member in members) > max_bytes: + raise EvidenceError("NPZ expanded data exceeds max_bytes") + if sum(math.prod(entry["shape"]) * np.dtype(entry["dtype"]).itemsize + for entry in entries) > max_bytes: + raise EvidenceError("schema logical data exceeds max_bytes") + # Inspect headers before NumPy can allocate arrays. A tiny forged member + # claiming a huge shape must not bypass the expanded ZIP size limit. + for member in members: + name = member.filename[:-4] + entry = by_name[name] + with archive.open(member) as stream: + version = np.lib.format.read_magic(stream) + if version == (1, 0): + shape, _, dtype = np.lib.format.read_array_header_1_0(stream, max_header_size=10000) + elif version == (2, 0): + shape, _, dtype = np.lib.format.read_array_header_2_0(stream, max_header_size=10000) + else: + raise EvidenceError(f"unsupported NPY version: {version}", name) + if list(shape) != entry["shape"] or dtype != np.dtype(entry["dtype"]): + raise EvidenceError(f"{name}: NPY header shape/dtype disagrees with schema", name) + if dtype.hasobject or dtype.kind not in "biuf": + raise EvidenceError(f"{name}: NPY dtype is not safe numeric data", name) + payload_bytes = math.prod(shape) * dtype.itemsize + if stream.tell() + payload_bytes != member.file_size: + raise EvidenceError(f"{name}: NPY payload size disagrees with header", name) + result = {} + with np.load(path, allow_pickle=False, max_header_size=10000) as archive: + for entry in entries: + name = entry["name"] + try: + array = archive[name] + except (ValueError, TypeError, EOFError) as exc: + raise EvidenceError(f"cannot safely load checkpoint {name}: {exc}", name) from exc + if list(array.shape) != entry["shape"]: + raise EvidenceError(f"{name}: array shape {list(array.shape)} disagrees with schema {entry['shape']}", name) + if array.dtype != np.dtype(entry["dtype"]): + raise EvidenceError(f"{name}: array dtype {array.dtype} disagrees with schema {entry['dtype']}", name) + if array.dtype.kind not in "biuf" or not np.isfinite(array).all(): + diagnostic = tensor_diff(array, array) + raise EvidenceError(f"{name}: nonfinite or unsupported array", name, + dict(name=name, evidence_path=str(path.resolve()), + worst_index=diagnostic["worst_index"], + value=diagnostic["actual_value"], + reason=diagnostic["reason"])) + result[name] = array + return result + + +def select_names(entries: list[dict], layers=(), checkpoints=()) -> list[str]: + if len(set(layers)) != len(layers) or len(set(checkpoints)) != len(checkpoints): + raise EvidenceError("duplicate layer/checkpoint selections are not allowed") + known_layers = {entry["layer"] for entry in entries if "layer" in entry} + if any(not _integer(layer) or layer < 0 or layer not in known_layers for layer in layers): + raise EvidenceError(f"layer selection is outside schema layers {sorted(known_layers)}") + known_checkpoints = {entry["name"] for entry in entries} + known_checkpoints.update(entry["checkpoint"] for entry in entries if "checkpoint" in entry) + if set(checkpoints) - known_checkpoints: + raise EvidenceError(f"unknown checkpoint selections: {sorted(set(checkpoints) - known_checkpoints)}") + names = [entry["name"] for entry in entries + if (not layers or entry.get("layer") in layers) + and (not checkpoints or entry["name"] in checkpoints + or entry.get("checkpoint") in checkpoints)] + if not names: + raise EvidenceError("layer/checkpoint selection has an empty intersection") + return names + + +def compare_evidence(actual_path, reference_path, schema_path, *, reference_schema_path=None, + layers=(), checkpoints=(), atol=1e-5, max_bytes=DEFAULT_MAX_BYTES): + # tensor_diff owns the W2 finite positive tolerance contract, even for bad inputs. + tensor_diff(np.array([], dtype="float32"), np.array([], dtype="float32"), atol) + entries = load_schema(Path(schema_path)) + reference_entries = load_schema(Path(reference_schema_path)) if reference_schema_path else entries + if entries != reference_entries: + raise EvidenceError("actual/reference schemas must match exactly in checkpoint order and metadata") + names = select_names(entries, layers, checkpoints) + # Always validate every member, including unselected evidence, before comparison. + actual = load_arrays(Path(actual_path), entries, max_bytes) + expected = load_arrays(Path(reference_path), reference_entries, max_bytes) + comparison = compare_outputs({name: actual[name] for name in names}, + {name: expected[name] for name in names}, names, atol=atol) + partial = bool(layers or checkpoints) + passing = comparison["passed"] + worst = max(comparison["checkpoints"], + key=lambda row: (math.inf if row["reason"] == "absolute error overflow" + else row["max_abs"] if row["max_abs"] is not None else -1), + default=None) + return dict(gate="w3_layer_diff", status="FAIL" if not passing else ("PARTIAL" if partial else "PASS"), + passed=passing and not partial, selected_passed=passing, partial=partial, + coverage=dict(total_checkpoints=len(entries), compared_checkpoints=len(names), + complete=not partial, selected_names=names, + evidence_validated_checkpoints=len(entries)), + atol=float(atol), first_divergence=comparison["first_divergence"], + worst_element=worst, checkpoints=comparison["checkpoints"], + claim="Comparison covers recorded checkpoints only; no whole-model or backend acceptance is implied.") + + +def _file_evidence(path): + path = Path(path) + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024**2), b""): + digest.update(chunk) + return dict(path=str(path.resolve()), size_bytes=path.stat().st_size, sha256=digest.hexdigest()) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--actual", required=True, type=Path) + parser.add_argument("--reference", required=True, type=Path) + parser.add_argument("--schema", required=True, type=Path) + parser.add_argument("--reference-schema", type=Path) + parser.add_argument("--out", required=True, type=Path) + parser.add_argument("--layer", type=int, action="append", default=[]) + parser.add_argument("--checkpoint", action="append", default=[]) + parser.add_argument("--atol", type=float, default=1e-5) + parser.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES) + args = parser.parse_args(argv) + report = dict(gate="w3_layer_diff", status="FAIL", passed=False) + try: + # Existing evidence is never overwritten, including on report errors. + args.out.mkdir(parents=True, exist_ok=False) + except OSError as exc: + print(f"FAIL: cannot create fresh output directory: {exc}", file=sys.stderr) + return 1 + try: + report.update(source_evidence()) + report["artifacts"] = {name: _file_evidence(path) for name, path in + (("actual", args.actual), ("reference", args.reference), + ("schema", args.schema), ("reference_schema", args.reference_schema)) + if path is not None} + report.update(compare_evidence(args.actual, args.reference, args.schema, + reference_schema_path=args.reference_schema, + layers=args.layer, checkpoints=args.checkpoint, + atol=args.atol, max_bytes=args.max_bytes)) + except Exception as exc: + report.update(status="FAIL", passed=False, error=f"{type(exc).__name__}: {exc}", + first_divergence=getattr(exc, "checkpoint", None), + worst_element=getattr(exc, "details", None)) + try: + write_reports(args.out, report) + except Exception as exc: + print(f"FAIL: report publication failed: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + print(f"{report['status']}: {args.out / 'report.json'}") + return 0 if report["passed"] else (2 if report["status"] == "PARTIAL" else 1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_full/EVIDENCE.md b/probes/w3_qwen3_full/EVIDENCE.md new file mode 100644 index 0000000..83e78aa --- /dev/null +++ b/probes/w3_qwen3_full/EVIDENCE.md @@ -0,0 +1,58 @@ +# 完整 W3 结果的离线复核 + +复现统一采用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 环境与资产准备](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md),再在同一个 Bash 会话、仓库根目录执行命令;该指南设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC` 和 `SCRATCHV_QEMU`。 + +`scripts/verify_w3_evidence.py` 对**已有七案例原始产物**重新计算误差,不执行模型、不下载资产。 +它复用完整模型 runner 的 `validate_worker`、`compare_case`、`invariants`,以及安全 NPZ 读取器。 + +在仓库根目录,使用已有 Python 3.12 和项目锁定依赖运行: + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B scripts/verify_w3_evidence.py \ + --evidence-dir output/downloaded-w3-run/raw/w3-full \ + --output-dir output/w3-evidence-audit-01 +``` + +输出目录必须不存在;不会覆盖被审核的原报告。 +输出为 `report.json`、`report.md`、`report.html`,退出码 `0` 表示复核通过,`1` 表示证据无效或不足。 +非法参数、已有输出目录等启动错误同样返回非零。 + +## 所需内容与检查 + +需要 full runner 的完整输出目录:顶层报告,以及每个案例的 `inputs.npz`、两个后端的报告、 +普通/诊断 `logits.npy`、`diagnostic_logits.npy`、`checkpoints.npz` 和 `checkpoint_schema.json`。 +仅下载 reports artifact 不足以复核,必须同时保留 raw artifact 并保持目录结构。CI raw artifact 内含 `w3-full/` 前缀:指定 `gh run download ... -n w3-full-numeric-raw --dir output/downloaded-w3-run/raw` 后,证据根是 `output/downloaded-w3-run/raw/w3-full`。完整下载和报告 SHA256 获取方法见 [Linux CI 产物说明](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md#产物与失败定位)。 + +- 七组案例完整、顺序固定,输入 dtype/shape/有限值以及每个数值均符合确定性输入生成规则。 +- 模型文件描述符与固定 manifest 一致。可选 `--model-dir PATH` 进一步核对本地模型文件哈希,仍不执行模型。 +- worker 报告哈希、产物大小/哈希、checkpoint schema、执行步数、耗时和 ORT 配置符合 runner 的验证规则。 +- 每组普通/诊断 logits 一致,IR/ORT 对全部 256 个位置重新比较,严格 `max_abs < 1e-4`,`rtol=0`。 +- 30 个检查点的 dtype、shape、有限值和误差重新计算;其误差用于诊断定位,不额外替代或改变 logits 验收门槛。 +- 两个后端各自的未来 token 隔离、padding 隔离,共四项 invariant 重新计算。 +- 重新计算结果必须与原报告一致;仅有 `passed: true` 不能通过。 +- 解释器数值模式和策略一致,并受当前验证器支持。当前已提交 reference 契约为 `numpy-fp32-reference-v4`,所有 IR worker 的 `cpu_strategy` 必须一致,且完整 profile 的字段和值须与 `avx2-fma3` 或 `avx512` 的规范契约匹配;不是仅检查一个策略名字。 + +v4 复核读取生产报告保存的 `cpu_strategy`,不从审计机器 CPU 或 `SCRATCHV_FP32_REFERENCE_CPU` 环境变量推断生产策略。完整 profile 校验包括 `matmul_k_block`、`matmul_row_tile`、`matmul_row_tail`、`matmul_column_tail`、`matmul_layout` 和 `matmul_vector_policy`,不能缺字段或仅靠版本名字通过。因此可以在另一种 CPU 上核对已保存的数组,无需控制或执行本机 ORT。这里的跨机器数组核对不证明该机器实际执行模型也能通过。 + +当前 v4 验证器不接受历史 v2/v3 profile,须使用报告对应源码版本中的验证器,或重新生成 v4 完整证据。不能手动改旧报告的 profile、CPU 策略或源码哈希。`SCRATCHV_FP32_REFERENCE_CPU=auto|avx2-fma3|avx512` 是实际 reference 执行的选项,默认 `auto`;它不用于把旧证据转换到新契约,也不控制 ORT dispatch。 + +## 结论边界 + +`audit:w3-full-saved-evidence` 的 PASS 只说明**保存的文件和数值结果自洽**。 +它不证明生产者身份,也不代表第二人独立执行模型、当前源码已运行、Linux 通过、Nightly 通过或团队验收。 +本工具无法通过保存文件独立证明报告中的历史耗时与执行声明确实发生。 + +默认 SHA-256 检查发现文件与报告不一致;若报告和数据同时被替换,它不能充当数字签名。 +可从可信渠道预先独立保留原始顶层报告哈希,然后传入: + +```bash +set -euo pipefail +"$SCRATCHV_PYTHON" -B scripts/verify_w3_evidence.py --evidence-dir output/downloaded-w3-run/raw/w3-full --output-dir output/w3-evidence-audit-02 --expected-report-sha256 ORIGINAL_REPORT_SHA256 +``` + +`source_comparison` 明确列出原报告中生产源码与当前审计源码的更改、新增、缺失。 +源码不一致时,只要保存的数值契约仍受当前验证器支持,就可复核旧数组;但**不能将旧报告升级成当前源码执行的证明**。不受支持的旧 profile 仍会失败。 +报告同时保存 producer 环境、审计器环境和版本化数值策略。生产环境中的 `environment.numeric_runtime.blas_environment.OPENBLAS_CORETYPE` 是环境变量原值;`numpy_openblas` 的 `status/libraries` 及可用库项的 `core_name/config` 是可选运行诊断。审计不得以本机 CPU SIMD 或 BLAS 内核替填生产环境,也不能从该环境变量推断生产库已采用某个内核。诊断不可用或历史字段缺失不豁免任何数组数值门槛。 + +当前已提交 v4 的[第四轮 Linux 执行](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)(`1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`)已在七组完整执行、preparation 与手动汇总通过;完整 raw 数组已下载验真、离线 audit 通过。实际执行 PASS 和保存证据 audit PASS 分别记录,实际 audit 报告及 SHA256、原样保留的源码换行差异说明见 [第四轮实测记录](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md)。本说明本身不替代该原始记录。 diff --git a/probes/w3_qwen3_full/LOCALIZE.md b/probes/w3_qwen3_full/LOCALIZE.md new file mode 100644 index 0000000..0af0093 --- /dev/null +++ b/probes/w3_qwen3_full/LOCALIZE.md @@ -0,0 +1,46 @@ +# 完整模型的局部数值定位 + +复现统一采用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 环境与资产准备](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md),再在同一个 Bash 会话、仓库根目录执行命令;该指南设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC` 和 `SCRATCHV_QEMU`。 + +`localize.py` 将同一个完整 ORT 层输入送入原图切片的 ORT 和 ScratchV IR,帮助区分当前层局部误差与上游累计误差。它保留完整 FP32 运算、真实权重和所有 padding query。不会接受 W3 完整数值 gate,也不会修改固定模型或门槛。 + +先用本目录的 `run.py` 生成完整模型证据,再运行: + +```bash +set -euo pipefail +W3_MODEL=/absolute/path/to/qwen3-0.6b-onnx +export OMP_NUM_THREADS='1' +export OPENBLAS_NUM_THREADS='1' +export MKL_NUM_THREADS='1' +export PYTHONIOENCODING='utf-8' +export SCRATCHV_FP32_REFERENCE_CPU='auto' +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_qwen3_full/localize.py \ + --model-dir "$W3_MODEL" \ + --case-dir output/full-run/short_17 \ + --output-dir output/full-localize-new \ + --layer 0 --layer 21 +``` + +依赖使用完整探测的固定 CPU 环境。输出目录必须不存在;不指定 `--layer` 时仅诊断第一层,层编号从 0 到 27,可重复指定不同层。原始 case 必须包含 `inputs.npz` 和 `ort/`、`ir/` 下完整 worker 的报告、logits、检查点及 schema。工具重新核验固定资产 SHA256、输入/mask、报告记录的数组哈希、普通/诊断 logits 一致性和固定图的 30 检查点结构。历史 IR/ORT 的源码指纹必须彼此一致,但不要求与新增诊断工具后的源码集相同;旧报告和当前诊断的源码指纹分别保留。 + +诊断沿用原 IR worker 记录的 `fp32_mode`,并核对完整 reference profile;无模式字段的旧报告按 `native` 解释。当前已提交 reference 版本为 `numpy-fp32-reference-v4`,`SCRATCHV_FP32_REFERENCE_CPU` 可设为 `auto`(默认)、`avx2-fma3` 或 `avx512`。本机自动解析出的策略必须与原报告 `ir.fp32_profile.cpu_strategy` 一致;若跨 CPU 诊断,先读取原报告,在启动 Python 前显式设置同一策略。否则工具会拒绝不一致的 profile,不会静默换用当前机器策略。 + +复现时另外保留原报告与本机 `environment.numeric_runtime` 中的 `blas_environment.OPENBLAS_CORETYPE`、`numpy_openblas.status/libraries` 及可用库项的 `core_name/config`。CPU SIMD 支持、参考策略和实际 BLAS 内核不同;环境变量原值不证明库已采用该内核。可选诊断不可用时保留原说明,不把它作为跳过数值检查的理由。 + +这个环境变量只改变本次 IR reference 算术策略,不控制本机 ORT dispatch;局部诊断会实际执行本机 ORT/IR,因此不能把“重放保存的 IR 策略”当作两后端必然一致的保证。这与只读取原始数组的 [离线 audit](EVIDENCE.md) 不同,后者无需在当前机器选择或执行生产 CPU 策略。 + +未知、互相矛盾或不受当前实现支持的 profile 会失败,不能自动换成最新策略。历史 v2/v3 的局部诊断应使用对应的代码版本,或重新生成 v4 完整证据;原报告的模式、profile 和源码哈希不得改写。 + +v4 的 profile 还包含 MatMul 分块、尾行/单列处理与布局字段;即使 `cpu_strategy` 相同,也必须与保存证据的完整契约一致。当前 v4 实现不能直接用 v3 证据运行并宣称是在复现原算术策略。 + +所选层按固定图的依赖关系切片;计算节点与权重不重写,以保存的前一层 ORT hidden state 作为共同输入。每层记录 13 个检查点,包含 Q/K RMSNorm、RoPE、Attention probabilities/context、残差及 MLP。第一次归约的 `Pow/ReduceMean/epsilon/rsqrt` 在选择第 0 层时额外记录。 + +选择第 0 层时还自动运行五项相同输入原语实验:input RMSNorm 的 ReduceMean、Q/K 投影 MatMul、Attention QK MatMul、Softmax。FP64 仅用于估计原语的数学误差,不参与 IR/ORT 执行、不替换任何输出或正式参考。Softmax 另记录不可见 key 概率和行和偏差。 + +`report.json`、`report.md`、`report.html` 保存误差、最差索引、有效/padding 分项、耗时、进程峰值 RSS 和源码/资产指纹。`layer-XX-values.npz` 保存原始检查点,`op-*-values.npz` 保存原语输入和双方输出,`.onnx` 是本次使用的实际 FP32 局部图。大文件仅在本地输出目录生成。 + +退出码 0 与 `status=COMPLETE` 仅表示诊断完成;局部误差超过 `1e-4` 会照实记录为比较不通过,不伪装成工具执行失败。资产、输入、schema、执行或有限值检查失败时退出 1。无论诊断完成与否,`numeric_gate_passed=false`、`w3_exit_accepted=false`,原完整模型失败报告保持原样。 + +不能单凭一个大值中间张量超 `1e-4` 判定语义错误。例如 6000 量级 FP32 数值的一个 ULP 已可能超过该阈值。应同时查看同输入对照、原语误差、值域和原始 mask 概率,最终仍以完整 runner 的全部 logits `<1e-4` 验收。 + +当前已提交 v4 的[第四轮 Linux 完整运行](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)(`1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a`)已七组完整执行通过;raw 数组 audit 也已通过。该结论来自完整 runner,局部诊断和本说明不替代完整 gate 或团队验收。 diff --git a/probes/w3_qwen3_full/README.md b/probes/w3_qwen3_full/README.md new file mode 100644 index 0000000..5b51e48 --- /dev/null +++ b/probes/w3_qwen3_full/README.md @@ -0,0 +1,87 @@ +# 完整 Qwen3 IR 数值验证 + +复现统一采用 **Ubuntu 24.04 x86_64、Bash、Python 3.12**。先完成 [Linux 环境与资产准备](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md),再在同一个 Bash 会话、仓库根目录执行命令;该指南设置 `SCRATCHV_PYTHON`、`SCRATCHV_CC` 和 `SCRATCHV_QEMU`。 + +该入口实际执行固定发布的 28 层 Qwen3-0.6B FP32 ONNX,比较 ScratchV IR 与 ORT 的完整 logits,包括右侧 padding query。它使用优化前 IR(none),不会把资产检查、两层模型或部分输入通过视为完整模型数值验收。 + +## 验证范围 + +默认运行七组输入:两组满长随机输入、单 token、17/255 个有效 token,以及改变未来 token、改变 padding token 的输入。固定形状为 INT64 input_ids `[1,256]` 和 FP32 加性 mask `[1,1,256,256]`,输出为 FP32 `[1,256,151936]`。输入是可复现的原始 embedding ID,用于数值测试,不作为 tokenizer 或语言生成质量证据。 + +每组输入分别启动 ORT 和 IR 独立进程;每个后端运行普通与诊断两次,要求两份完整 logits 逐元素完全一致。跨后端对普通、诊断 logits 分别执行严格的 `max_abs < 1e-4`、`rtol=0`,同时报告有效位置和 padding 位置的误差。因果性和 padding 隔离另有四项跨输入检查。 + +诊断保存 embedding、28 层 residual 输出、final norm 共 30 个检查点,按固定图的实际连接定位。中间张量的 `1e-4` 标记用于定位误差,正式数值门槛作用于完整 logits;某些中间激活很大,单个 FP32 ULP 已可能大于 `1e-4`。报告保留这些标记,不将它们隐藏,也不把中间误差与最终输出误差混为一谈。 + +## 运行 + +在 W3 工作树根目录,使用 [固定依赖](../../requirements/qwen3-small-probe.txt)。模型目录必须包含已发布且匹配 manifest 的完整 ONNX 与三份权重分片;入口不下载、不修改模型。 + +完整入口默认使用 `--fp32-mode reference`,当前已提交策略为 `numpy-fp32-reference-v4`;请在复现命令中显式写出模式。解释器 API 默认仍为 `native`,追加 `--fp32-mode native` 才复现原 NumPy 策略。两种模式、不同版本和不同 CPU 策略的 profile 必须分别记录与验收,不能将一份报告的通过结论用于另一种模式。该参考模式不是跨 CPU、BLAS 或 ORT 版本逐元素一致的保证。 + +环境变量 `SCRATCHV_FP32_REFERENCE_CPU` 只作用于 `reference` IR 的算术策略,允许以下取值: + +| 值 | 行为 | +|---|---| +| `auto`(未设置时的默认值) | 从 NumPy 报告的可用 CPU 能力推断;具有 AVX2/FMA3 时,根据 AVX512F 是否可用选择下列一种策略 | +| `avx2-fma3` | 显式采用对应的参考算术顺序,profile 记录该值 | +| `avx512` | 显式采用对应的参考算术顺序,profile 记录该值 | + +显式设置不会控制 ORT 的 CPU dispatch,也不是启用或禁用真实硬件指令的开关;两条参考路径仍由 NumPy 实现。跨机器重放保存的算术策略时,可以在启动 Python 前显式指定报告中的 `cpu_strategy`,但必须重新比较本机 ORT 输出。未知变量取值会报错;`auto` 模式无法识别所需 CPU 能力,或发现 `NPY_DISABLE_CPU_FEATURES` 遮蔽相关 AVX/FMA 能力时也会明确报错,要求显式选择并记录策略。 + +v4 把矩阵乘的分块与内存布局纳入版本化 profile;数值策略名字与 CPU 策略相同仍不足以替代完整字段核对: + +| profile 字段 | `avx2-fma3` | `avx512` | +|---|---|---| +| `matmul_k_block` | 128 | 128;K≤128 时保留原 NumPy 路径 | +| `matmul_row_tile` | 2 | `null`,保留原宽矩阵路径 | +| `matmul_row_tail` | `zero-pad-to-two-then-trim` | `native` | +| `matmul_column_tail` | `zero-pad-single-column-to-two-then-trim` | `native` | +| `matmul_layout` | `contiguous-matrix-operands-per-k-block` | `native-strides` | +| `matmul_vector_policy` | `native-numpy-vector-promotion` | `native-numpy-vector-promotion` | + +AVX2 矩阵路径按两个输出行计算,尾行和单列临时补零后裁回原输出形状,每个 K 分块的两个矩阵操作数使用 C 连续切片;向量提升继续遵循 NumPy。以上是参考计算契约,不改模型图、权重、输出形状或误差门槛,也不是性能优化验收。本节描述已提交的 v4 实现;第四轮 Linux 已取得同提交七组完整运行 PASS,普通/诊断完整 logits 及四项不变量最大误差均为 0;这是 v4 的新实测证据,历史 v3 或单例结果不能替代它。 + +~~~bash +set -euo pipefail +W3_MODEL=/absolute/path/to/qwen3-0.6b-onnx +export SCRATCHV_FP32_REFERENCE_CPU=auto +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_qwen3_full/run.py \ + --model-dir "$W3_MODEL" --output-dir output/w3-full-new \ + --fp32-mode reference --worker-timeout 1800 --max-worker-memory-gib 10 +~~~ + +需要复现单个失败时追加 `--case short_17`。成功但仅覆盖部分 case 返回 PARTIAL/2,完整七组和不变量全部通过才返回 PASS/0;实际执行失败或数值超标返回 FAIL/1。中断返回 130。输出目录必须是新目录。 + +每个 worker 的超时和采样内存上限覆盖本次拥有的整棵进程树。Linux 使用独立进程组,采样整个进程树的 RSS,并在超时、超限或中断时清理本次拥有的进程。采样间隔为 0.2 秒,共享内存页可能按进程重复计数。它是超限终止措施,不是操作系统的硬内存配额,短暂峰值仍可能超过界限。失败保留阶段进度、日志、已完成产物和报告;中断、超时及超限会清理本次拥有的进程树。 + +## 证据与时长 + +统一 `report.json`、`report.md` 和 `report.html` 包含每组输入、每个 worker、完整比较和四项不变量。`cases[].comparison.logits` 是最终精度验收数据,`checkpoints` 及 `first_divergence` 定位最早超过诊断阈值的检查点。 + +Summary 将普通图与诊断图的最终 logits 分开,明确实际 `native/reference` 模式,并报告最大绝对误差、余弦相似度和相对 L2 误差;逐用例索引默认折叠,完整检查点和机器证据仅保存在 `report.json`,不重复嵌入摘要。后两项由实际输出和参考数组分块计算,使用 FP64 累积,不改变原有通过门槛。不能把参考兼容模式的通过结果归给默认 NumPy 模式。旧报告不补写或重新命名为本轮运行;离线重算保存数组也不代表重新执行了模型。 + +PPL(真实文本预测的困惑度)和 zero-shot(无示例任务评分)本轮未评测。这里的随机 token 输入用于数值对齐,不能作为语言质量语料;加入这类评测前需要固定真实文本、Tokenizer、L=256 的分窗及计分方式。 + +每组目录内有 `inputs.npz`、ORT/IR 日志,以及两个后端各自的完整 logits、诊断 logits、检查点 NPZ、schema、报告和阶段进度。报告包含固定模型、输入、输出及源码哈希;统一入口重新校验原始数组与报告一致性。 + +`cases[].workers[].stages_seconds` 可查看模型解析、普通执行、诊断执行和写盘时间;统一 `elapsed_seconds` 包括所有 worker 及文件核对。`process_peak_rss_bytes` 为该 worker 的全生命周期峰值,`resource_monitor` 是父进程采样观测。两者测量口径不同。 + +每个 IR worker 的 `ir.fp32_profile` 记录实际数值策略,包括 `name=numpy-fp32-reference-v4` 与已解析的 `cpu_strategy`;`auto` 是选择方式,不是保存后的策略名称。`environment.numeric_runtime` 补充 CPU 架构、NumPy 构建/运行诊断和线程环境。`blas_environment.OPENBLAS_CORETYPE` 保存环境变量原值,未设置时为 `null`,不是实际生效内核的证明;`numpy_openblas` 保存可选诊断的总体 `status`、`libraries`,可用库项包含 `path`、`status`、`core_name`、`config`,不可用时保存 `reason` 或 `error`。该诊断只读取已装载的 NumPy 私有 OpenBLAS 库,系统 BLAS/MKL 等可明确为不可用。 + +CPU SIMD 能力、IR 的 `cpu_strategy` 与实际 BLAS 内核须分别看待;上述字段也不证明 ORT 使用了哪一种内部内核。可选诊断不可用或旧证据缺少该字段,不能掩盖数值失败,所有门槛照常执行。独立复现应一并保留这些字段、环境变量原值、依赖版本与源码哈希。 + +离线 audit 按生产报告中保存的 CPU 策略核对整个 profile,不从审计机器 CPU 推断生产策略,也不会重新运行模型。当前 v4 工具严格验证 v4 契约;历史 v2/v3 原始证据应在其对应源码版本下核对,不能把报告字段改为 v4 或直接沿用旧版本的 PASS。每个提交和策略的完整七组以对应运行结果验收。 + +完整执行使用只读映射权重、显式借用 initializer、最后一次使用后释放中间 binding;默认解释器 API 仍复制和保留原有数据。只读映射文件在调用期间必须保持不变。解释器的 `peak_numpy_storage_bytes` 计入当时已绑定的借用 buffer,排除已解除绑定但仍由调用方持有的数组、内核临时缓冲和观察回调的拷贝;请同时参考实际进程 RSS。observer 是受信任的同步回调,只读 view 用于防止误写,不提供隔离沙箱。 + +需要继续定位失败时,使用 [同输入逐层与原语诊断](LOCALIZE.md)。该工具读取已保存的完整执行证据,保存层内检查点和局部 FP64 参考误差;诊断完成不代表完整数值验收通过。 + +## CI 与阶段结论 + +[完整模型手动 CI](../../.github/workflows/w3-full-numeric.yml) 已随 PR #96 发布。2026-10-05,作者在提交 `1c49e8acbcd9174491b2d0c2a5a0072178ae5f9a` 的[第四轮 Linux 手动编排](https://github.com/yuki-328/ScratchV/actions/runs/37299235103)中通过 preparation、完整七组 full 和汇总;v4/avx2-fma3、OpenBLAS Haswell 环境下普通/诊断 logits、30 检查点与四不变量最大误差均为 0,完整 runner 用时 `1477.314971525 s`(约 24.6 分钟)。reports 已下载并核对,完整 raw 已下载验真、离线 audit 通过。前三轮失败及旧 v2/v3 记录保留在 [Linux 实测记录](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md)。实际完整数值失败仍使 job 失败;没有自动忽略 padding、调大阈值、启用低精度或回退到小模型。 + +该流程同时支持 `workflow_call`,供显式启用的 [Nightly 编排](../../.github/workflows/w3-nightly.yml) 复用;两套自动检查均成功才能通过汇总。Linux 运行、定时启用和资源限制说明见 [Linux CI 与 Nightly](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md)。收到完整保存目录后,可按 [离线证据复核](EVIDENCE.md) 重新检查数组;这不执行模型,也不替代独立复现。 + +CI 将报告、哈希和 schema 放入 `w3-full-numeric-reports`(保留 30 天),完整原始输入、logits 和检查点另放入 `w3-full-numeric-raw`(保留 3 天,也包含报告)。需要独立复核原始数值时,应在过期前下载 raw 产物;其解压目录中保留 `w3-full/` 前缀,所以 `--evidence-dir` 应为 `下载目录/w3-full`。下载命令、报告 SHA256 获取与完整 audit 示例见 [产物与失败定位](../../docs/llm-deploy-v1.0/W3/Linux-CI与Nightly.md#产物与失败定位)。仅有摘要和哈希不足以重新比较数组。失败或中断运行的产物可能不完整,须先核对报告状态与覆盖范围。 + +即使本地数值 PASS,`w3_exit_accepted` 仍为 false:W1 人工前置验收、团队确认、E1/E2/E5 独立复现及 Nightly 状态由团队单独核对。[Linux 完整执行记录](../../docs/llm-deploy-v1.0/W3/W3-完整模型执行与数值诊断报告.md) 保留固定提交、模式和数据,不代表之后版本已经重跑。当前结论以本次完整运行生成的 `report.json`、实际 profile 和源码指纹为准。 diff --git a/probes/w3_qwen3_full/__init__.py b/probes/w3_qwen3_full/__init__.py new file mode 100644 index 0000000..67f94da --- /dev/null +++ b/probes/w3_qwen3_full/__init__.py @@ -0,0 +1 @@ +"""Bounded full Qwen3 numerical verification workers and gate.""" diff --git a/probes/w3_qwen3_full/cases.py b/probes/w3_qwen3_full/cases.py new file mode 100644 index 0000000..aa5b542 --- /dev/null +++ b/probes/w3_qwen3_full/cases.py @@ -0,0 +1,29 @@ +"""Fixed numerical input suite for the released FP32 L256 model.""" +from __future__ import annotations + +import numpy as np + +from scratchv.runtime.llm_inputs import prepare_inputs, QWEN3_VOCAB_SIZE + +CASE_NAMES = ("full_seed_0", "full_seed_42", "one_token", "short_17", "short_255", + "changed_future", "changed_padding") +SEQ = 256 + + +def input_cases(): + """Raw embedding IDs; this suite makes no tokenizer or language-quality claim.""" + result = [] + for name, seed, length in ((CASE_NAMES[0], 0, 256), (CASE_NAMES[1], 42, 256), + (CASE_NAMES[2], 7, 1), (CASE_NAMES[3], 7, 17), + (CASE_NAMES[4], 7, 255)): + tokens = np.random.default_rng(seed).integers(1, QWEN3_VOCAB_SIZE, length, dtype=np.int64) + feed = prepare_inputs(tokens, pad_token_id=0).as_feed() + result.append((name, length, feed)) + future = {key: value.copy() for key, value in result[0][2].items()} + future["input_ids"][:, 64:] = future["input_ids"][:, 64:] % (QWEN3_VOCAB_SIZE - 1) + 1 + result.append((CASE_NAMES[5], 256, future)) + padding = {key: value.copy() for key, value in result[3][2].items()} + padding["input_ids"][:, 17:] = np.random.default_rng(99).integers( + 1, QWEN3_VOCAB_SIZE, (1, 239), dtype=np.int64) + result.append((CASE_NAMES[6], 17, padding)) + return result diff --git a/probes/w3_qwen3_full/comparison.py b/probes/w3_qwen3_full/comparison.py new file mode 100644 index 0000000..478b864 --- /dev/null +++ b/probes/w3_qwen3_full/comparison.py @@ -0,0 +1,62 @@ +"""Bounded-memory absolute-error comparisons, including padded query rows.""" +from __future__ import annotations + +import math +import numpy as np + +from scratchv.verification.numeric_metrics import numeric_metrics, undefined_numeric_metrics + +ATOL = 1e-4 + + +def compare_tensor(actual, reference, *, atol=ATOL, chunk_elements=262144): + if not math.isfinite(atol) or atol <= 0: + raise ValueError("atol must be finite and positive") + if type(chunk_elements) is not int or chunk_elements < 1: + raise ValueError("chunk_elements must be a positive integer") + row = {"passed": False, "atol": atol, "rtol": 0, + "shape": list(actual.shape), "reference_shape": list(reference.shape), + "dtype": str(actual.dtype), "reference_dtype": str(reference.dtype), + "elements": int(actual.size), "max_abs": None, "finite": False} + row.update(undefined_numeric_metrics("comparison is invalid")) + if actual.shape != reference.shape or actual.dtype != np.float32 or reference.dtype != np.float32: + row["reason"] = "shape or FP32 dtype mismatch" + row.update(undefined_numeric_metrics(row["reason"])) + return row + if not actual.size: + row["reason"] = "empty comparison is not numerical evidence" + row.update(undefined_numeric_metrics(row["reason"])) + return row + # nditer handles strided slices without a full-size ravel/copy allocation. + chunks = np.nditer([actual, reference], flags=["external_loop", "buffered"], + op_flags=[["readonly"], ["readonly"]], order="C", buffersize=chunk_elements) + maximum, offset, worst = -1.0, 0, None + for a, b in chunks: + finite = np.isfinite(a) & np.isfinite(b) + if not finite.all(): + index = offset + int(np.flatnonzero(~finite)[0]) + row.update(reason="nonfinite value", worst_index=list(np.unravel_index(index, actual.shape))) + row["worst_index"] = [int(x) for x in row["worst_index"]] + row.update(undefined_numeric_metrics(row["reason"])) + return row + difference = np.abs(a.astype(np.float64) - b.astype(np.float64)) + local = int(np.argmax(difference)) + if float(difference[local]) > maximum: + maximum = float(difference[local]) + worst = (offset + local, float(a[local]), float(b[local])) + offset += a.size + row.update(passed=maximum < atol, finite=True, max_abs=maximum, + worst_index=[int(x) for x in np.unravel_index(worst[0], actual.shape)], + actual_value=worst[1], reference_value=worst[2]) + row.update(numeric_metrics(actual, reference, block_elements=chunk_elements)) + return row + + +def compare_positions(actual, reference, valid_length, *, atol=ATOL): + if actual.ndim != 3 or reference.ndim != 3 or not 1 <= valid_length <= actual.shape[1]: + raise ValueError("expected [batch, sequence, width] and a valid query length") + total = compare_tensor(actual, reference, atol=atol) + total["valid_queries"] = compare_tensor(actual[:, :valid_length], reference[:, :valid_length], atol=atol) + total["padding_queries"] = (compare_tensor(actual[:, valid_length:], reference[:, valid_length:], atol=atol) + if valid_length < actual.shape[1] else None) + return total diff --git a/probes/w3_qwen3_full/localize.py b/probes/w3_qwen3_full/localize.py new file mode 100644 index 0000000..c4fd787 --- /dev/null +++ b/probes/w3_qwen3_full/localize.py @@ -0,0 +1,390 @@ +"""Localize full-Qwen3 numerical differences using identical layer inputs. + +A completed diagnosis is not a numerical gate: large intermediate activations +may differ by more than 1e-4 in a few FP32 ULPs. The full-model runner remains +the authority for full logits. This tool never changes execution precision. +""" +from __future__ import annotations + +import argparse +import copy +import gc +import json +from pathlib import Path +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np +import onnx + +from probes.w1_qwen3_export.run import verify_files +from probes.w3_common import (new_output_dir, process_peak_rss_bytes, sha256_file, + source_evidence, write_reports) +from probes.w3_layer_diff.run import load_arrays, load_schema +from probes.w3_qwen3_full.comparison import compare_tensor +from probes.w3_qwen3_full.run import validate_worker +from probes.w3_qwen3_full.worker import checkpoint_schema, load_inputs + + +def require(value, message): + if not value: + raise ValueError(message) + + +def selected_layers(values): + values = [0] if values is None else list(values) + require(bool(values) and all(type(v) is int and 0 <= v < 28 for v in values), + "Layers must be integer indices in [0, 27]") + require(len(values) == len(set(values)), "Duplicate layer selection") + return sorted(values) + + +def compare_queries(actual, reference, valid, axis): + require(actual.ndim > axis and actual.shape[axis] == 256 and 1 <= valid <= 256, + "Expected complete L256 query axis and valid length") + row = compare_tensor(actual, reference) + prefix = [slice(None)] * actual.ndim + suffix = prefix.copy() + prefix[axis], suffix[axis] = slice(0, valid), slice(valid, None) + row["valid_queries"] = compare_tensor(actual[tuple(prefix)], reference[tuple(prefix)]) + row["padding_queries"] = (compare_tensor(actual[tuple(suffix)], reference[tuple(suffix)]) + if valid < 256 else None) + row["query_axis"] = axis + return row + + +def probability_diagnostics(probabilities, mask): + require(probabilities.dtype == np.float32 and probabilities.ndim == 4 + and probabilities.shape[0] == 1 and probabilities.shape[2:] == (256, 256) + and np.isfinite(probabilities).all(), "Invalid attention probabilities") + require(mask.shape == (1, 1, 256, 256) and mask.dtype == np.float32, + "Invalid attention mask") + invisible = np.broadcast_to(mask < 0, probabilities.shape) + return {"masked_probability_max_abs": float(np.abs(probabilities[invisible]).max()), + "row_sum_max_abs_error": float(np.abs(probabilities.sum(-1, dtype=np.float64) - 1).max()), + "minimum_probability": float(probabilities.min())} + + +def fp64_error_oracle(op, feed, ir_value, ort_value): + """Double precision is only an independent local error estimate.""" + require(ir_value.dtype == ort_value.dtype == np.float32, + "Execution outputs must remain FP32") + if op == "MatMul": + truth = np.matmul(feed["a"].astype(np.float64), feed["b"].astype(np.float64)) + elif op == "ReduceMean": + truth = feed["x"].astype(np.float64).mean(-1, keepdims=True) + elif op == "Softmax": + x = feed["x"].astype(np.float64) + exp = np.exp(x - x.max(-1, keepdims=True)) + truth = exp / exp.sum(-1, keepdims=True) + else: + raise ValueError("Unsupported local diagnostic operation") + return {"scope": "diagnostic only; neither execution result nor acceptance output", + "ir_max_abs": float(np.abs(ir_value - truth).max()), + "ort_max_abs": float(np.abs(ort_value - truth).max())} + + +class LayerSlice: + """Extract exact pinned-graph ancestors; never reconstruct model arithmetic.""" + + def __init__(self, model, model_dir): + self.model, self.model_dir = model, Path(model_dir) + self.nodes = list(model.graph.node) + self.producers = {name: node for node in self.nodes for name in node.output} + self.info = {v.name: v for v in [*model.graph.value_info, *model.graph.input, *model.graph.output]} + self.initializers = {v.name: v for v in model.graph.initializer} + require(len(self.producers) == sum(len(n.output) for n in self.nodes), + "Duplicate graph values") + + @staticmethod + def one(nodes, description): + rows = list(nodes) + require(len(rows) == 1, f"Expected unique {description}") + return rows[0] + + def norm(self, weight): + node = self.one((n for n in self.nodes if n.op_type == "Mul" and weight in n.input), weight) + value = next(x for x in node.input if x != weight) + cast = self.producers[value] + require(cast.op_type == "Cast", "Unexpected normalization cast") + mul = self.producers[cast.input[0]] + require(mul.op_type == "Mul", "Unexpected normalization product") + cast = self.producers[mul.input[0]] + require(cast.op_type == "Cast", "Unexpected normalization input") + return cast.input[0], node.output[0] + + def projection(self, weight): + tr = self.one((n for n in self.nodes if n.op_type == "Transpose" and weight in n.input), weight) + mat = self.one((n for n in self.nodes if n.op_type == "MatMul" and tr.output[0] in n.input), weight + " MatMul") + return mat.input[0], mat.output[0] + + def describe(self, index): + p = f"model.model.layers.{index}." + hidden, input_norm = self.norm(p + "input_layernorm.weight") + residual, postnorm = self.norm(p + "post_attention_layernorm.weight") + _, qnorm = self.norm(p + "self_attn.q_norm.weight") + _, knorm = self.norm(p + "self_attn.k_norm.weight") + _, vproj = self.projection(p + "self_attn.v_proj.weight") + context, attn_output = self.projection(p + "self_attn.o_proj.weight") + _, mlp = self.projection(p + "mlp.down_proj.weight") + final = self.one((n for n in self.nodes if n.op_type == "Add" and mlp in n.input), "MLP residual").output[0] + begin, end = self.nodes.index(self.producers[input_norm]), self.nodes.index(self.producers[final]) + block = self.nodes[begin:end + 1] + softmax = self.one((n for n in block if n.op_type == "Softmax"), "layer Softmax").output[0] + probs = self.one((n for n in block if n.op_type == "Cast" and softmax in n.input), "Softmax cast").output[0] + + def rope(norm): + tr = self.one((n for n in block if n.op_type == "Transpose" and norm in n.input), "norm transpose") + mul = self.one((n for n in block if n.op_type == "Mul" and tr.output[0] in n.input), "RoPE cosine product") + return self.one((n for n in block if n.op_type == "Add" and mul.output[0] in n.input), "RoPE sum").output[0] + + return hidden, {"input_norm": input_norm, "q_norm": qnorm, "k_norm": knorm, "v_proj": vproj, + "rope_q": rope(qnorm), "rope_k": rope(knorm), "attn_probs": probs, + "attn_context": context, "attn_output": attn_output, "attn_residual": residual, + "post_attention_norm": postnorm, "mlp": mlp, "residual": final} + + def build(self, hidden, outputs): + stop, needed, queue, chosen = {hidden, "attention_mask"}, set(outputs), list(outputs), set() + while queue: + value = queue.pop() + if value in stop or value not in self.producers: + continue + node = self.producers[value] + if node.name in chosen: + continue + chosen.add(node.name) + for name in node.input: + if name and name not in needed: + needed.add(name) + queue.append(name) + nodes = [copy.deepcopy(n) for n in self.nodes if n.name in chosen] + weights = [onnx.numpy_helper.from_array(onnx.numpy_helper.to_array(t, base_dir=str(self.model_dir)), name) + for name, t in self.initializers.items() if name in needed and name not in stop] + for node in nodes: + for attr in node.attribute: + if attr.type == onnx.AttributeProto.TENSOR and attr.t.external_data: + attr.t.CopyFrom(onnx.numpy_helper.from_array( + onnx.numpy_helper.to_array(attr.t, base_dir=str(self.model_dir)), attr.t.name)) + graph = onnx.helper.make_graph(nodes, "pinned_layer_slice", + [copy.deepcopy(self.info[x]) for x in sorted(stop) if x in needed], + [copy.deepcopy(self.info[x]) for x in outputs], initializer=weights) + result = onnx.helper.make_model(graph, opset_imports=self.model.opset_import) + result.ir_version = self.model.ir_version + onnx.checker.check_model(result) + return result + + +def run_pair(model, feed, outputs, path, *, fp32_mode="native"): + """Actual ORT and production Parser/IR execution, with FP32 feeds unchanged.""" + import onnxruntime as ort + from scratchv.frontend.onnx_parser import ONNXParser + from scratchv.verification.ir_interpreter import IRInterpreter + + require(all(v.dtype == np.float32 and np.isfinite(v).all() for v in feed.values()), + "Local execution feeds must be finite FP32") + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + options.enable_cpu_mem_arena = False + options.log_severity_level = 3 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + diagnostic_path = path.with_name(path.stem + "-diagnostic.onnx") + onnx.save_model(model, diagnostic_path) + session = ort.InferenceSession(model.SerializeToString(), sess_options=options, providers=["CPUExecutionProvider"]) + ort_values = dict(zip(outputs, session.run(list(outputs.values()), feed))) + ordinary = copy.deepcopy(model) + wanted = outputs[next(reversed(outputs))] + last = next(v for v in ordinary.graph.output if v.name == wanted) + last = copy.deepcopy(last) + del ordinary.graph.output[:] + ordinary.graph.output.append(last) + onnx.save_model(ordinary, path) + parser = ONNXParser() + program = parser.parse(str(path)) + for value in parser.initializers.values(): + value.setflags(write=False) + bindings = {parser._value_map[value].name: name for name, value in outputs.items()} + require(len(bindings) == len(outputs), "Aliased/ambiguous diagnostic checkpoints") + ir_values = {} + + def observe(name, value): + if name in bindings: + require(bindings[name] not in ir_values, "Checkpoint executed twice") + ir_values[bindings[name]] = value.copy() + + result = IRInterpreter(program).run(feed, initializers=parser.initializers, + memory_mode="last_use", copy_initializers=False, observer=observe, + fp32_mode=fp32_mode) + require(set(ir_values) == set(outputs), "Missing executed checkpoint") + require(all(v.dtype == np.float32 and np.isfinite(v).all() for v in [*ir_values.values(), *ort_values.values()]), + "Nonfinite/non-FP32 execution output") + return ir_values, ort_values, {"executed_steps": result.executed_steps, + "fp32_mode": fp32_mode, + "model_sha256": sha256_file(path), "model_file": path.name, + "ort_model_file": diagnostic_path.name, + "ort_model_sha256": sha256_file(diagnostic_path), + "checkpoint_onnx_names": dict(outputs)} + + +def primitive(op, feed, shape): + inputs, initializers, attrs = list(feed), [], {} + if op == "ReduceMean": + inputs += ["axes"] + initializers = [onnx.numpy_helper.from_array(np.array([-1], dtype=np.int64), "axes")] + attrs["keepdims"] = 1 + elif op == "Softmax": + attrs["axis"] = -1 + graph = onnx.helper.make_graph([onnx.helper.make_node(op, inputs, ["y"], **attrs)], op, + [onnx.helper.make_tensor_value_info(k, 1, v.shape) for k, v in feed.items()], + [onnx.helper.make_tensor_value_info("y", 1, shape)], initializer=initializers) + model = onnx.helper.make_model(graph, opset_imports=[onnx.helper.make_opsetid("", 18)]) + model.ir_version = 10 + return model + + +def local_primitives(extract, ort_values, mask, valid, out, *, fp32_mode="native"): + rows = [] + + def run(name, op, feed, shape, axis=1): + ir, ort_values, evidence = run_pair(primitive(op, feed, shape), feed, {"result": "y"}, + out / f"op-{name}.onnx", fp32_mode=fp32_mode) + a, b = ir["result"], ort_values["result"] + row = {"name": name, "op": op, "comparison": compare_queries(a, b, valid, axis), + "fp64_error_oracle": fp64_error_oracle(op, feed, a, b), **evidence} + if op == "Softmax": + row["probabilities"] = {"ir": probability_diagnostics(a, mask), "ort": probability_diagnostics(b, mask)} + rows.append(row) + values_path = out / f"op-{name}-values.npz" + np.savez(values_path, ir=a, ort=b, **feed) + row["values"] = {"file": values_path.name, "bytes": values_path.stat().st_size, + "sha256": sha256_file(values_path)} + return b + + run("input_norm_reduce_mean", "ReduceMean", {"x": ort_values["square"]}, [1, 256, 1]) + for key, width in (("q", 2048), ("k", 1024)): + name = f"model.model.layers.0.self_attn.{key}_proj.weight" + weight = onnx.numpy_helper.to_array(extract.initializers[name], base_dir=str(extract.model_dir)).T.copy() + run(f"projection_{key}", "MatMul", {"a": ort_values["input_norm"], "b": weight}, [1, 256, width]) + q, k = ort_values["rope_q"], np.repeat(ort_values["rope_k"], 2, axis=1) + scores = run("attention_scores", "MatMul", {"a": q, "b": k.transpose(0, 1, 3, 2).copy()}, [1, 16, 256, 256], 2) + scores = scores * np.float32(128 ** -0.5) + mask + run("attention_softmax", "Softmax", {"x": scores}, [1, 16, 256, 256], 2) + return rows + + +def validate_reference_metadata(record, schema, expected_schema, files, input_hash): + require(record.get("gate") == "numeric:w3-full-worker" and record.get("backend") == "ort" + and record.get("passed") is True and record.get("status") == "PASS", "Expected executed ORT worker") + require(record.get("files") == files, "Reference model assets differ") + require(record.get("input", {}).get("sha256") == input_hash, "Reference inputs differ") + require(schema == expected_schema and record.get("checkpoints") == schema, + "Reference schema differs from fixed graph dataflow") + + +def validate_producer_pair(ort_record, ir_record): + require(bool(ort_record.get("source_sha256")) + and ort_record["source_sha256"] == ir_record.get("source_sha256"), + "Historical IR/ORT producer source fingerprints differ") + + +def producer_fp32_mode(ir_record): + details = ir_record.get("ir", {}) + mode = details.get("fp32_mode", "native") + require(mode in ("native", "reference"), "Unknown producer FP32 mode") + require(ir_record.get("fp32_mode", mode) == mode, "Producer FP32 modes disagree") + if mode == "reference": + from scratchv.verification.fp32_reference import profile + require(details.get("fp32_profile") == profile(), + "Unsupported historical FP32 reference profile") + else: + require("fp32_profile" not in details or details["fp32_profile"] == {"name": "numpy-native"}, + "Unsupported historical native FP32 profile") + return mode + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-dir", type=Path, required=True) + parser.add_argument("--case-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--layer", type=int, action="append", help="Repeat for selected indices 0..27; default 0") + args = parser.parse_args(argv) + try: + layers = selected_layers(args.layer) + except ValueError as exc: + parser.error(str(exc)) + out = new_output_dir(args.output_dir) + report = {"gate": "diagnostic:w3-full-localize", "status": "FAIL", "passed": False, + "diagnostic_complete": False, "numeric_gate_passed": False, "w3_exit_accepted": False, + "scope": "Selected exact layer slices and equal-input FP32 primitives; cannot accept the full-model gate", + "selected_layers": layers, "layers": [], "primitives": [], + "execution_dtype": "float32", "fp64_scope": "local error oracle only"} + start = time.perf_counter() + try: + report.update(source_evidence()) + files = verify_files(args.model_dir) + feed, valid = load_inputs(args.case_dir / "inputs.npz") + model = onnx.load(args.model_dir / "model.onnx", load_external_data=False) + expected = checkpoint_schema(model) + folder = args.case_dir / "ort" + schema = load_schema(folder / "checkpoint_schema.json") + record = json.loads((folder / "report.json").read_text(encoding="utf-8")) + ir_folder = args.case_dir / "ir" + ir_record = json.loads((ir_folder / "report.json").read_text(encoding="utf-8")) + validate_producer_pair(record, ir_record) + input_hash = sha256_file(args.case_dir / "inputs.npz") + validate_reference_metadata(record, schema, expected, files, input_hash) + # Historical source identity is retained rather than falsely claiming a + # new localizer file existed when that complete reference was generated. + validate_worker(folder, "ort", args.case_dir / "inputs.npz", record["source_sha256"], files) + validate_worker(ir_folder, "ir", args.case_dir / "inputs.npz", record["source_sha256"], files) + fp32_mode = producer_fp32_mode(ir_record) + report["fp32_mode"] = fp32_mode + states = load_arrays(folder / "checkpoints.npz", schema, max_bytes=64 * 1024**2) + report.update(files=files, valid_length=valid, input_sha256=input_hash, + reference_report_sha256=sha256_file(folder / "report.json"), + paired_ir_report_sha256=sha256_file(ir_folder / "report.json"), + reference_source_sha256=record["source_sha256"]) + extract = LayerSlice(model, args.model_dir) + for index in layers: + hidden, names = extract.describe(index) + require(hidden == expected[index]["onnx_name"] and names["residual"] == expected[index + 1]["onnx_name"], + "Extracted layer boundary differs from audited graph") + if index == 0: + names = {"square": "pow_1", "mean": "mean", "epsilon_add": "add", "rsqrt": "rsqrt", **names} + state = states["embedding" if index == 0 else f"layer_{index - 1}.output"] + pair_feed = {hidden: state, "attention_mask": feed["attention_mask"]} + graph = extract.build(hidden, list(names.values())) + ir, ort_values, evidence = run_pair(graph, pair_feed, names, out / f"layer-{index:02d}.onnx", + fp32_mode=fp32_mode) + comparisons = {name: compare_queries(ir[name], ort_values[name], valid, + 2 if name in ("rope_q", "rope_k", "attn_probs") else 1) for name in names} + report["layers"].append({"layer": index, "same_input": comparisons, **evidence, + "probabilities": {"ir": probability_diagnostics(ir["attn_probs"], feed["attention_mask"]), + "ort": probability_diagnostics(ort_values["attn_probs"], feed["attention_mask"])}}) + values_path = out / f"layer-{index:02d}-values.npz" + np.savez(values_path, **{"ir_" + k: v for k, v in ir.items()}, + **{"ort_" + k: v for k, v in ort_values.items()}) + report["layers"][-1]["values"] = {"file": values_path.name, "bytes": values_path.stat().st_size, + "sha256": sha256_file(values_path)} + if index == 0: + report["primitives"] = local_primitives(extract, ort_values, feed["attention_mask"], valid, + out, fp32_mode=fp32_mode) + del graph, ir, ort_values + gc.collect() + require(source_evidence()["source_sha256"] == report["source_sha256"], "Sources changed during diagnosis") + report.update(status="COMPLETE", passed=True, diagnostic_complete=True) + except KeyboardInterrupt: + report.update(interrupted=True, error="KeyboardInterrupt: local diagnosis cancelled") + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + report.update(elapsed_seconds=time.perf_counter() - start, process_peak_rss_bytes=process_peak_rss_bytes()) + write_reports(out, report) + return 130 if report.get("interrupted") else (0 if report["diagnostic_complete"] else 1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_full/resources.py b/probes/w3_qwen3_full/resources.py new file mode 100644 index 0000000..4821ad9 --- /dev/null +++ b/probes/w3_qwen3_full/resources.py @@ -0,0 +1,403 @@ +"""Observe and bound an owned full-model worker; no extra monitor dependency.""" +from __future__ import annotations + +import ctypes +import os +from pathlib import Path +import subprocess +import time + +from scripts.run_w2_acceptance import terminate_process_tree as _legacy_terminate + + +class _ResidentMemoryUnavailable(OSError): + """Linux can drop VmRSS while exit is still in progress, before zombie state.""" + + +_RSS_EXIT_GRACE_SECONDS = 0.2 +_RSS_EXIT_RECHECK_SECONDS = 0.01 + + +class _VerifiedProcessHandle: + """A handle already verified as a member of the owned Windows Job.""" + def __init__(self, handle): + self.handle = handle + + +class _WindowsJob: + """Own descendants before the suspended launcher executes any user code. + + Windows 8+ supports nesting under an existing sandbox Job. No breakaway + flags or UI limits are used. Assignment failure is fatal, not a fallback + to monitoring only the launcher. Handles are non-inheritable. + """ + def __init__(self): + from ctypes import wintypes as w + self.k = k = ctypes.WinDLL("kernel32", use_last_error=True) + prototypes = { + "CreateJobObjectW": ([ctypes.c_void_p, w.LPCWSTR], w.HANDLE), + "SetInformationJobObject": ([w.HANDLE, ctypes.c_int, ctypes.c_void_p, w.DWORD], w.BOOL), + "AssignProcessToJobObject": ([w.HANDLE, w.HANDLE], w.BOOL), + "QueryInformationJobObject": ([w.HANDLE, ctypes.c_int, ctypes.c_void_p, w.DWORD, ctypes.c_void_p], w.BOOL), + "IsProcessInJob": ([w.HANDLE, w.HANDLE, ctypes.POINTER(w.BOOL)], w.BOOL), + "TerminateJobObject": ([w.HANDLE, w.UINT], w.BOOL), + "OpenProcess": ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE), + "GetExitCodeProcess": ([w.HANDLE, ctypes.POINTER(w.DWORD)], w.BOOL), + "CloseHandle": ([w.HANDLE], w.BOOL), + "CreateToolhelp32Snapshot": ([w.DWORD, w.DWORD], w.HANDLE), + "Thread32First": ([w.HANDLE, ctypes.c_void_p], w.BOOL), + "Thread32Next": ([w.HANDLE, ctypes.c_void_p], w.BOOL), + "OpenThread": ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE), + "GetProcessIdOfThread": ([w.HANDLE], w.DWORD), + "ResumeThread": ([w.HANDLE], w.DWORD), + } + for name, (args, result) in prototypes.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + + class Basic(ctypes.Structure): + _fields_ = [("process_time", ctypes.c_longlong), ("job_time", ctypes.c_longlong), + ("flags", w.DWORD), ("min_working", ctypes.c_size_t), + ("max_working", ctypes.c_size_t), ("active", w.DWORD), + ("affinity", ctypes.c_size_t), ("priority", w.DWORD), ("scheduling", w.DWORD)] + class Limits(ctypes.Structure): + _fields_ = [("basic", Basic), ("io", ctypes.c_ulonglong * 6), + ("process_memory", ctypes.c_size_t), ("job_memory", ctypes.c_size_t), + ("peak_process", ctypes.c_size_t), ("peak_job", ctypes.c_size_t)] + self.handle = k.CreateJobObjectW(None, None) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + limits = Limits() + limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not k.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)): + error = ctypes.WinError(ctypes.get_last_error()) + self.close() + raise error + + def assign_and_resume(self, process): + from ctypes import wintypes as w + if not self.k.AssignProcessToJobObject(self.handle, int(process._handle)): + raise ctypes.WinError(ctypes.get_last_error()) + class ThreadEntry(ctypes.Structure): + _fields_ = [("size", w.DWORD), ("usage", w.DWORD), ("tid", w.DWORD), + ("pid", w.DWORD), ("priority", w.LONG), ("delta", w.LONG), ("flags", w.DWORD)] + snapshot = self.k.CreateToolhelp32Snapshot(4, 0) # TH32CS_SNAPTHREAD + if snapshot == ctypes.c_void_p(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + tids = [] + try: + entry = ThreadEntry() + entry.size = ctypes.sizeof(entry) + ok = self.k.Thread32First(snapshot, ctypes.byref(entry)) + while ok: + if entry.pid == process.pid: + tids.append(entry.tid) + ok = self.k.Thread32Next(snapshot, ctypes.byref(entry)) + if ctypes.get_last_error() != 18: # ERROR_NO_MORE_FILES + raise ctypes.WinError(ctypes.get_last_error()) + finally: + self.k.CloseHandle(snapshot) + if len(tids) != 1: + raise OSError("Suspended launcher must have exactly one primary thread") + thread = self.k.OpenThread(0x0002 | 0x0040, False, tids[0]) + if not thread: + raise ctypes.WinError(ctypes.get_last_error()) + try: + if self.k.GetProcessIdOfThread(thread) != process.pid: + raise OSError("Suspended primary thread ownership changed") + if self.k.ResumeThread(thread) != 1: + raise OSError("Suspended launcher did not have exactly one suspension") + finally: + self.k.CloseHandle(thread) + + def pids(self): + # QueryInformationJobObject includes descendant nested Jobs. Retry if + # more processes arrived between sizing and reading the PID list. + for capacity in (16, 64, 256, 1024, 4096, 16384): + buffer = ctypes.create_string_buffer(8 + capacity * ctypes.sizeof(ctypes.c_size_t)) + ok = self.k.QueryInformationJobObject(self.handle, 3, buffer, len(buffer), None) + assigned, count = (ctypes.c_ulong * 2).from_buffer(buffer) + if ok and count == assigned and count <= capacity: + return list((ctypes.c_size_t * count).from_buffer(buffer, 8)) + if not ok and ctypes.get_last_error() != 234: # ERROR_MORE_DATA + raise ctypes.WinError(ctypes.get_last_error()) + raise OSError("Owned worker Job exceeds bounded process enumeration") + + def sample(self): + from ctypes import wintypes as w + total = {"rss_bytes": 0, "private_commit_bytes": 0, "pids": []} + for pid in self.pids(): + handle = self.k.OpenProcess(0x1010, False, pid) + if not handle: + if pid not in self.pids(): + continue # Exited after the Job snapshot. + raise ctypes.WinError(ctypes.get_last_error()) + try: + member = w.BOOL() + if not self.k.IsProcessInJob(handle, self.handle, ctypes.byref(member)): + raise ctypes.WinError(ctypes.get_last_error()) + if not member.value: + # PID reuse cannot substitute an unrelated process: never + # inspect or terminate the new process's memory. + continue + try: + value = process_memory(_VerifiedProcessHandle(handle)) + except OSError: + code = w.DWORD() + if self.k.GetExitCodeProcess(handle, ctypes.byref(code)) and code.value != 259: + continue + raise + total["rss_bytes"] += value["rss_bytes"] + total["private_commit_bytes"] += value["private_commit_bytes"] or 0 + total["pids"].append(pid) + finally: + self.k.CloseHandle(handle) + return total + + def terminate(self): + if not self.k.TerminateJobObject(self.handle, 1): + raise ctypes.WinError(ctypes.get_last_error()) + deadline = time.monotonic() + 5 + while self.pids(): + if time.monotonic() > deadline: + raise OSError("Owned worker Job still has active descendants after termination") + time.sleep(0.01) + + def close(self): + if self.handle: + handle, self.handle = self.handle, None + if not self.k.CloseHandle(handle): + raise ctypes.WinError(ctypes.get_last_error()) + + +def spawn_owned(command, **kwargs): + """Create a worker tree whose ownership precedes all executed child code.""" + if os.name != "nt": + kwargs["start_new_session"] = True + process = subprocess.Popen(command, **kwargs) + process._scratchv_owned_group = True + return process + flags = kwargs.pop("creationflags", 0) + if flags & (0x01000000 | 0x00000004): + raise ValueError("Caller cannot request breakaway or manage suspended owned workers") + job, process = _WindowsJob(), None + try: + process = subprocess.Popen(command, creationflags=flags | 0x00000004, **kwargs) + process._scratchv_job = job + job.assign_and_resume(process) + return process + except BaseException as original: + # Before ResumeThread the launcher cannot have started descendants; + # after assignment closing the Job owns every descendant regardless. + try: + job.close() + except BaseException as cleanup: + original.add_note(f"Closing failed worker Job: {cleanup}") + try: + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + except BaseException as cleanup: + original.add_note(f"Reaping failed launcher: {cleanup}") + raise + + +def terminate_process_tree(process): + job = getattr(process, "_scratchv_job", None) + if job is not None: + try: + job.terminate() + finally: + job.close() + process._scratchv_job = None + process.wait(timeout=5) + else: + _legacy_terminate(process) + + +class _PosixGroup: + """Linux session/group membership, including children outliving launcher.""" + def __init__(self, process): + self.process = process + self.rss_exit_rechecks = 0 + + @staticmethod + def identity(pid): + text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + fields = text[text.rindex(")") + 2:].split() + return fields[0], int(fields[2]), int(fields[3]), int(fields[19]) + + def pids(self): + found = [] + for entry in Path("/proc").iterdir(): + if not entry.name.isdecimal(): + continue + try: + state, group, session, _ = self.identity(int(entry.name)) + except (FileNotFoundError, ProcessLookupError): + continue + if group == session == self.process.pid and state not in {"Z", "X", "x"}: + found.append(int(entry.name)) + return found + + def _member_memory(self, pid, before): + # exit_mm() can remove VmRSS before the process becomes a zombie or + # waitpid observes its exit. Only this specific missing-field case gets + # a short grace period; permission errors and malformed RSS fail closed. + deadline = None + while True: + try: + value = process_memory(pid) + except _ResidentMemoryUnavailable: + after = self.identity(pid) + if before[1:] != after[1:] or after[0] in {"Z", "X", "x"}: + return None + if deadline is None: + deadline = time.perf_counter() + _RSS_EXIT_GRACE_SECONDS + if time.perf_counter() >= deadline: + raise # Still the same live process and still unobservable. + self.rss_exit_rechecks += 1 + time.sleep(_RSS_EXIT_RECHECK_SECONDS) + continue + after = self.identity(pid) + if before[1:] != after[1:] or after[0] in {"Z", "X", "x"}: + return None + return value + + def sample(self): + total = {"rss_bytes": 0, "private_commit_bytes": None, "pids": []} + for pid in self.pids(): + try: + before = self.identity(pid) + if (before[1] != self.process.pid or before[2] != self.process.pid + or before[0] in {"Z", "X", "x"}): + continue + value = self._member_memory(pid, before) + if value is None: + continue # PID was reused/exited during sampling. + except (FileNotFoundError, ProcessLookupError): + continue + except OSError: + try: + after = self.identity(pid) + except (FileNotFoundError, ProcessLookupError): + continue + if after[0] in {"Z", "X", "x"}: + continue + raise + total["rss_bytes"] += value["rss_bytes"] + total["pids"].append(pid) + return total + + +def process_memory(pid): + """Current RSS, and private committed bytes on Windows (not virtual size).""" + if os.name == "nt": + from ctypes import wintypes + class Counters(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("faults", wintypes.DWORD), + ("peak_rss", ctypes.c_size_t), ("rss", ctypes.c_size_t), + ("peak_paged", ctypes.c_size_t), ("paged", ctypes.c_size_t), + ("peak_nonpaged", ctypes.c_size_t), ("nonpaged", ctypes.c_size_t), + ("pagefile", ctypes.c_size_t), ("peak_pagefile", ctypes.c_size_t), + ("private", ctypes.c_size_t)] + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel.OpenProcess.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + psapi = ctypes.WinDLL("psapi", use_last_error=True) + psapi.GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(Counters), wintypes.DWORD] + psapi.GetProcessMemoryInfo.restype = wintypes.BOOL + borrowed = isinstance(pid, _VerifiedProcessHandle) + handle = pid.handle if borrowed else kernel.OpenProcess(0x1010, False, pid) + if not handle: + raise OSError(ctypes.get_last_error(), "Cannot observe worker memory") + try: + values = Counters() + values.cb = ctypes.sizeof(values) + if not psapi.GetProcessMemoryInfo(handle, ctypes.byref(values), values.cb): + raise OSError(ctypes.get_last_error(), "Cannot read worker memory") + return {"rss_bytes": int(values.rss), "private_commit_bytes": int(values.private)} + finally: + if not borrowed: + kernel.CloseHandle(handle) + status = Path(f"/proc/{pid}/status").read_text(encoding="utf-8") + rows = dict(line.split(":", 1) for line in status.splitlines() if ":" in line) + if "VmRSS" not in rows: + raise _ResidentMemoryUnavailable(f"Cannot observe worker {pid} resident memory") + try: + resident = rows["VmRSS"].split() + if len(resident) != 2 or resident[1] != "kB": + raise ValueError("VmRSS must use the Linux kB unit") + rss = int(resident[0]) * 1024 + if rss < 0: + raise ValueError("VmRSS cannot be negative") + except ValueError as exc: + raise OSError(f"Cannot observe worker {pid} resident memory") from exc + return {"rss_bytes": rss, "private_commit_bytes": None} + + +def wait_bounded(process, *, timeout, max_memory_bytes, row, interval=0.2): + """A sampled guard, not an OS hard limit. Stop the owned tree on any failure.""" + started = time.perf_counter() + job = getattr(process, "_scratchv_job", None) + group = bool(getattr(process, "_scratchv_owned_group", False)) + tree = job if job is not None else _PosixGroup(process) if group else None + observation = {"samples": 0, "rss_peak_sampled_bytes": 0, "private_commit_peak_sampled_bytes": None, + "interval_seconds": interval, "limit_bytes": max_memory_bytes, + "ownership": "windows_job" if job else "posix_process_group", + "observed_pids": [], "peak_sampled_processes": 0, + "scope": ("Owned Windows Job and descendants; summed sampled RSS/private commit, shared pages may be counted per process, not a hard OS quota" + if job else "Owned Linux process group/session; summed sampled RSS, shared pages may be counted per process, not a hard OS quota")} + row["resource_monitor"] = observation + try: + if os.name == "nt" and isinstance(process, subprocess.Popen) and job is None: + raise ValueError("Windows workers must be created with spawn_owned before execution") + while process.poll() is None or (tree is not None and tree.pids()): + if time.perf_counter() - started > timeout: + raise TimeoutError(f"Full worker exceeded {timeout} seconds") + try: + values = tree.sample() if tree is not None else process_memory(process.pid) + except OSError: + if process.poll() is not None and (tree is None or not tree.pids()): + break + raise + observation["samples"] += 1 + observation["rss_peak_sampled_bytes"] = max(observation["rss_peak_sampled_bytes"], values["rss_bytes"]) + private = values["private_commit_bytes"] + pids = values.get("pids", [process.pid]) + observation["observed_pids"] = sorted(set(observation["observed_pids"]) | set(pids)) + observation["peak_sampled_processes"] = max(observation["peak_sampled_processes"], len(pids)) + if private is not None: + observation["private_commit_peak_sampled_bytes"] = max( + observation["private_commit_peak_sampled_bytes"] or 0, private) + if max(values["rss_bytes"], private or 0) > max_memory_bytes: + raise MemoryError(f"Full worker exceeded sampled memory limit {max_memory_bytes} bytes") + if process.poll() is not None: + time.sleep(interval) # Job children can outlive their launcher. + else: + try: + process.wait(timeout=interval) + except subprocess.TimeoutExpired: + pass + return process.wait(timeout=5) + except BaseException: + if process.poll() is None or job is not None or group: + try: + terminate_process_tree(process) + except (OSError, subprocess.SubprocessError) as cleanup: + # Preserve timeout/interruption rather than replacing its + # cause with any secondary cleanup diagnostic. + row["cleanup_error"] = f"{type(cleanup).__name__}: {cleanup}" + raise + finally: + if isinstance(tree, _PosixGroup): + observation["rss_exit_rechecks"] = tree.rss_exit_rechecks + if getattr(process, "_scratchv_job", None) is not None: + try: + job.close() + except OSError as cleanup: + row["cleanup_error"] = f"{type(cleanup).__name__}: {cleanup}" + process._scratchv_job = None + row["returncode"] = process.poll() + row["elapsed_seconds"] = time.perf_counter() - started diff --git a/probes/w3_qwen3_full/run.py b/probes/w3_qwen3_full/run.py new file mode 100644 index 0000000..8695e80 --- /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 0000000..e86e297 --- /dev/null +++ b/probes/w3_qwen3_full/worker.py @@ -0,0 +1,327 @@ +"""Execute one complete pinned Qwen3 backend in an isolated process. + +A worker PASS proves execution and ordinary/diagnostic consistency only. The +parent compares independent IR and ORT results; no worker accepts W3 itself. +""" +from __future__ import annotations + +import argparse +import gc +import json +import os +from pathlib import Path +import sys +import tempfile +import time +import traceback + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np +import onnx + +from probes.w1_qwen3_export import run as assets +from probes.w2_qwen3_parse.validation import audit_graph_structure +from probes.w3_common import (atomic_text, new_output_dir, process_peak_rss_bytes, + sha256_file, source_evidence, write_reports) +from probes.w3_layer_diff.run import load_arrays + +GATE = "numeric:w3-full-worker" +INPUT_SCHEMA = [ + {"name": "input_ids", "shape": [1, 256], "dtype": "int64"}, + {"name": "attention_mask", "shape": [1, 1, 256, 256], "dtype": "float32"}, +] +LOGITS_SHAPE = (1, 256, 151936) + + +def load_inputs(path): + """Validate the pinned export's finite additive, right-padded causal mask.""" + feed = load_arrays(Path(path), INPUT_SCHEMA, max_bytes=1024 * 1024) + ids, mask = feed["input_ids"], feed["attention_mask"] + if np.any(ids < 0) or np.any(ids >= 151936): + raise ValueError("input_ids must be in the fixed Qwen3 vocabulary") + length = int(np.count_nonzero(mask[0, 0, -1] == 0)) + if not 1 <= length <= 256: + raise ValueError("attention_mask must expose at least one valid token") + query, key = np.arange(256)[:, None], np.arange(256)[None, :] + wanted = np.where((key <= query) & (key < length), np.float32(0), + np.finfo(np.float32).min).reshape(1, 1, 256, 256) + if not np.array_equal(mask, wanted): + raise ValueError("attention_mask is not the pinned causal/right-padding additive mask") + return feed, length + + +def checkpoint_schema(model, audit=None): + """Locate checkpoints from audited dataflow, never layer-name substrings.""" + audit = audit_graph_structure(model) if audit is None else audit + layers = audit.get("layers", []) + if (audit.get("passed") is not True or audit.get("layer_count") != 28 + or [row.get("layer") for row in layers] != list(range(28))): + raise ValueError("Checkpoint coverage requires the complete ordered 28-layer audit") + by_name = {} + for node in model.graph.node: + by_name.setdefault(node.name, []).append(node) + + def output(name): + nodes = by_name.get(name, []) + if len(nodes) != 1 or len(nodes[0].output) != 1: + raise ValueError(f"Checkpoint node must be unique with one output: {name}") + return nodes[0].output[0] + + rows = [{"name": "embedding", "onnx_name": output(audit["embedding"]), + "checkpoint": "embedding"}] + rows += [{"name": f"layer_{row['layer']}.output", "onnx_name": row["output"], + "layer": row["layer"], "checkpoint": "output"} for row in layers] + rows += [{"name": "final_norm", "onnx_name": output(audit["final_norm"]), + "checkpoint": "final_norm"}] + definitions = {name for node in model.graph.node for name in node.output} + names = [row["onnx_name"] for row in rows] + if len(names) != len(set(names)) or not set(names) <= definitions: + raise ValueError("Checkpoint tensors must be unique graph-produced values") + for row in rows: + row.update(shape=[1, 256, 1024], dtype="float32", sequence_axis=1) + return rows + + +def checked_tensor(array, shape, name): + if not isinstance(array, np.ndarray) or array.shape != tuple(shape) or array.dtype != np.float32: + raise ValueError(f"{name}: expected FP32 {tuple(shape)}, got " + f"{getattr(array, 'shape', None)}/{getattr(array, 'dtype', None)}") + for start in range(0, shape[1], 8): + if not np.isfinite(array[:, start:start + 8]).all(): + raise ValueError(f"{name}: nonfinite output") + return array + + +def save_array(path, value, *, archive=False): + """Atomically persist numeric arrays; leave existing evidence untouched.""" + path = Path(path) + if path.exists(): + raise FileExistsError(path) + fd, temporary = tempfile.mkstemp(prefix="." + path.name, suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + if archive: + np.savez(stream, **value) + else: + np.save(stream, value, allow_pickle=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def evidence(path, *, array=None): + path = Path(path) + record = {"path": path.name, "sha256": sha256_file(path), "bytes": path.stat().st_size} + if array is not None: + record.update(shape=list(array.shape), dtype=str(array.dtype), all_finite=True) + return record + + +def save_logits(out, name, array, report): + checked_tensor(array, LOGITS_SHAPE, name) + path = out / f"{name}.npy" + save_array(path, array) + report["artifacts"][name] = evidence(path, array=array) + + +def save_checkpoints(out, arrays, schema, report): + if set(arrays) != {row["name"] for row in schema} or len(arrays) != 30: + raise ValueError("Diagnostic output omitted/duplicated a required checkpoint") + for row in schema: + checked_tensor(arrays[row["name"]], row["shape"], row["name"]) + path = out / "checkpoints.npz" + save_array(path, arrays, archive=True) + report["artifacts"]["checkpoints"] = evidence(path) + + +def exact_ordinary_diagnostic(out): + ordinary = np.load(out / "logits.npy", mmap_mode="r", allow_pickle=False) + diagnostic = np.load(out / "diagnostic_logits.npy", mmap_mode="r", allow_pickle=False) + checked_tensor(ordinary, LOGITS_SHAPE, "ordinary logits") + checked_tensor(diagnostic, LOGITS_SHAPE, "diagnostic logits") + for start in range(0, LOGITS_SHAPE[1], 8): + if not np.array_equal(ordinary[:, start:start + 8], diagnostic[:, start:start + 8]): + raise ValueError(f"Ordinary and diagnostic logits differ at sequence chunk {start}") + return {"passed": True, "comparison": "exact", "max_abs": 0.0, + "positions_compared": 256, "elements_compared": int(np.prod(LOGITS_SHAPE))} + + +def diagnostic_graph(model, schema, model_dir, out): + """Add graph outputs without re-exporting or copying the external weights.""" + model = onnx.ModelProto.FromString(model.SerializeToString()) + for row in schema: + model.graph.output.append(onnx.helper.make_tensor_value_info( + row["onnx_name"], onnx.TensorProto.FLOAT, row["shape"])) + # External references are now relative to the derived metadata's directory. + # Source manifest verification already bounded and validated these files. + for tensor in assets.tensors(model): + if tensor.data_location == onnx.TensorProto.EXTERNAL: + for entry in tensor.external_data: + if entry.key == "location": + original = (model_dir / entry.value).resolve() + if not original.is_relative_to(model_dir.resolve()) or not original.is_file(): + raise ValueError("Invalid external tensor location in diagnostic model") + entry.value = os.path.relpath(original, out).replace("\\", "/") + target = out / "diagnostic.onnx" + target.write_bytes(model.SerializeToString()) + return target + + +def execute_ort(model_dir, out, feed, schema, model, report, stage): + import onnxruntime as ort + + def session(path): + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + options.enable_cpu_mem_arena = False + return ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"]) + + runtime = stage("ort_ordinary_session", lambda: session(model_dir / "model.onnx")) + logits = stage("ort_ordinary_execute", lambda: runtime.run(["logits"], feed)[0]) + stage("ordinary_save", lambda: save_logits(out, "logits", logits, report)) + del logits, runtime + gc.collect() + diagnostic = stage("diagnostic_graph", lambda: diagnostic_graph(model, schema, model_dir, out)) + report["artifacts"]["diagnostic_model"] = evidence(diagnostic) + runtime = stage("ort_diagnostic_session", lambda: session(diagnostic)) + names = ["logits", *[row["onnx_name"] for row in schema]] + results = stage("ort_diagnostic_execute", lambda: runtime.run(names, feed)) + if len(results) != 31: + raise ValueError("ORT diagnostic did not return all 31 outputs") + stage("diagnostic_save", lambda: save_logits(out, "diagnostic_logits", results[0], report)) + arrays = {row["name"]: value for row, value in zip(schema, results[1:])} + stage("checkpoints_save", lambda: save_checkpoints(out, arrays, schema, report)) + report["ort"] = {"provider": "CPUExecutionProvider", "graph_optimization": "disabled", + "intra_op_threads": 1, "inter_op_threads": 1, "cpu_mem_arena": False} + + +def execute_ir(model_dir, out, feed, schema, model, report, stage): + from scratchv.frontend.onnx_parser import ONNXParser + from scratchv.verification.ir_interpreter import IRInterpreter + from scratchv.verification.fp32_reference import profile + + fp32_mode = report.get("fp32_mode", "reference") + + parser = ONNXParser() + program = stage("scratchv_parse", lambda: parser.parse(str(model_dir / "model.onnx"), + mmap_external_data=True)) + for value in parser.initializers.values(): + value.setflags(write=False) + mapping = {} + for row in schema: + value = parser._value_map.get(row["onnx_name"]) + if value is None or value.name in mapping: + raise ValueError(f"Missing/non-unique IR checkpoint mapping: {row['name']}") + mapping[value.name] = row + report["ir_checkpoint_bindings"] = {name: row["name"] for name, row in mapping.items()} + report["executions"] = {} + for kind in ("ordinary", "diagnostic"): + arrays = {} + + def observe(name, value): + if name in mapping: + row = mapping[name] + if row["name"] in arrays: + raise ValueError(f"Checkpoint executed multiple times: {row['name']}") + arrays[row["name"]] = checked_tensor(value, row["shape"], row["name"]).copy() + + result = stage(f"ir_{kind}_execute", lambda: IRInterpreter(program).run( + feed, initializers=parser.initializers, collect_memory_stats=True, + memory_mode="last_use", copy_initializers=False, + fp32_mode=fp32_mode, + observer=observe if kind == "diagnostic" else None)) + report["executions"][kind] = {"executed_steps": result.executed_steps, + "memory_stats": result.memory_stats} + label = "logits" if kind == "ordinary" else "diagnostic_logits" + stage(f"{kind}_save", lambda: save_logits(out, label, result.return_value, report)) + if kind == "diagnostic": + stage("checkpoints_save", lambda: save_checkpoints(out, arrays, schema, report)) + del result, arrays + gc.collect() + report["ir"] = {"optimization_level": "none", "memory_mode": "last_use", + "fp32_mode": fp32_mode, + "fp32_profile": profile() if fp32_mode == "reference" else {"name": "numpy-native"}, + "copy_initializers": False, "mmap_external_data": True, + "instruction_count": sum(len(b.instructions) for f in program.functions for b in f.blocks)} + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--backend", choices=("ort", "ir"), required=True) + cli.add_argument("--model-dir", type=Path, required=True) + cli.add_argument("--input-file", type=Path, required=True) + cli.add_argument("--output-dir", type=Path, required=True) + cli.add_argument("--optimization-level", choices=("none",), default="none") + cli.add_argument("--fp32-mode", choices=("native", "reference"), default="reference") + args = cli.parse_args(argv) + out = new_output_dir(args.output_dir) + started = time.perf_counter() + report = {"schema_version": 1, "gate": GATE, "backend": args.backend, + "optimization_level": args.optimization_level, "status": "FAIL", "passed": False, + "fp32_mode": args.fp32_mode if args.backend == "ir" else None, + "stage": "initialization", "stages_seconds": {}, "artifacts": {}, + "full_ir_executed": False, "full_model_executed": False, "w3_exit_accepted": False, + "scope": "Complete single-backend execution and exact ordinary/diagnostic consistency. " + "Independent IR/ORT comparison belongs to the parent gate; not W3 team acceptance.", + "model_id": assets.MANIFEST["model_id"], "revision": assets.MANIFEST["revision"]} + + def stage(name, operation): + report["stage"] = name + atomic_text(out / "progress.json", json.dumps({"stage": name, + "elapsed_seconds": time.perf_counter() - started, + "process_peak_rss_bytes": process_peak_rss_bytes()}) + "\n") + print(f"[{args.backend}] {name}", flush=True) + begin = time.perf_counter() + try: + return operation() + finally: + report["stages_seconds"][name] = time.perf_counter() - begin + + try: + report.update(stage("source_evidence", source_evidence)) + model_dir = args.model_dir.resolve() + report["model_dir"] = str(model_dir) + report["files"] = stage("asset_hashes", lambda: assets.verify_files(model_dir)) + report["onnx_contract"] = stage("onnx_contract", lambda: assets.inspect_model(model_dir, report["files"])) + feed, length = stage("input_validation", lambda: load_inputs(args.input_file)) + report["input"] = evidence(args.input_file) + report["input"].update(path=str(args.input_file.resolve()), valid_length=length) + model = stage("metadata_load", lambda: onnx.load(str(model_dir / "model.onnx"), load_external_data=False)) + report["graph_audit"] = stage("graph_audit", lambda: audit_graph_structure(model)) + schema = stage("checkpoint_selection", lambda: checkpoint_schema(model, report["graph_audit"])) + report["checkpoints"] = schema + atomic_text(out / "checkpoint_schema.json", json.dumps({"version": 1, "checkpoints": schema}, indent=2) + "\n") + report["artifacts"]["checkpoint_schema"] = evidence(out / "checkpoint_schema.json") + executor = execute_ort if args.backend == "ort" else execute_ir + executor(model_dir, out, feed, schema, model, report, stage) + report["full_model_executed"] = True + report["full_ir_executed"] = args.backend == "ir" + report["ordinary_diagnostic"] = stage("ordinary_diagnostic_consistency", lambda: exact_ordinary_diagnostic(out)) + final_sources = stage("source_recheck", source_evidence)["source_sha256"] + if final_sources != report["source_sha256"]: + raise ValueError("Production sources changed during numerical execution") + report.update(passed=True, status="PASS", stage="complete") + except BaseException as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + traceback.print_exc() + if isinstance(exc, KeyboardInterrupt): + report["interrupted"] = True + report.update(elapsed_seconds=time.perf_counter() - started, + process_peak_rss_bytes=process_peak_rss_bytes(), + peak_scope="Lifetime resident high-water mark of this single backend worker, " + "including both ordinary and diagnostic runs; not isolated kernel memory") + write_reports(out, report) + print(f"[{GATE}] {report['status']}; {out / 'report.json'}", flush=True) + return 130 if report.get("interrupted") else 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_medium/README.md b/probes/w3_qwen3_medium/README.md new file mode 100644 index 0000000..c06072d --- /dev/null +++ b/probes/w3_qwen3_medium/README.md @@ -0,0 +1,110 @@ +# W3 medium Qwen3 host numerical probe + +Reproduction targets **Ubuntu 24.04 x86_64, Bash and Python 3.12**. Complete the [Linux environment and asset setup](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md) first. Run commands from the repository root in the same Bash session; the setup exports `SCRATCHV_PYTHON`, `SCRATCHV_CC` and `SCRATCHV_QEMU`. + +This is a fixed candidate for the W3 six-layer experiment. The plan fixes six +layers; the dimensions below are the candidate chosen for this experiment, +not a claim that the plan mandated a specific hidden or feed-forward width. + +| Property | W2 small default | W3 medium candidate | +|---|---:|---:| +| Decoder layers | 2 | 6 | +| Hidden width | 32 | 64 | +| FFN intermediate width | 96 | 192 | +| Query heads / KV heads | 4 / 2 | 4 / 2 | +| Head dimension | 16 | 16 | +| Vocabulary / sequence length | 128 / 256 | 128 / 256 | +| Batch / precision | 1 / FP32 | 1 / FP32 | +| Named checkpoints | 29 | 81 | + +Weights are fixed-seed random initialization of the official +`transformers==4.51.3` `Qwen3ForCausalLM`. No weights or tokenizer are downloaded. +This gate measures architecture and numerical semantics. It does not establish +pretrained language quality, full Qwen3-0.6B execution, or RISC-V/QEMU correctness. +Attention is eager, positions are `0..255`, embeddings and LM head are tied, and +there is no KV cache. All numerical backends use one CPU thread. + +## Run + +Use Python 3.12 and the existing pinned +`requirements/qwen3-small-probe.txt` environment. From the W3 worktree: + +```bash +set -euo pipefail +export OMP_NUM_THREADS='1' +export OPENBLAS_NUM_THREADS='1' +export MKL_NUM_THREADS='1' +"$SCRATCHV_PYTHON" -m probes.w3_qwen3_medium.run --output-dir output/w3-medium --model-seed 0 +``` + +The output directory must not already exist, even if it is empty. Preserve a +failed directory and choose a new path for a retry. The runner exits 0 only if the entire gate passes; numeric, +parsing, optimization, execution, and missing-reference failures exit 1. + +## Acceptance and evidence + +The gate uses the existing seven W2 inputs: two full random sequences, lengths +1, 17, and 255 with right padding, an altered future suffix, and altered masked +padding tokens. It exports the official model with named observations, then +produces ordinary logits-only and diagnostic packed-output ONNX graphs. + +Both graphs run in ORT with graph optimization disabled and in ScratchV IR at +`none`, `basic`, and `all`. Every IR path compares against both PyTorch and its +own ORT graph. Ordinary graph, diagnostic graph, and every optimization level +record independent results. One failure cannot be masked by another passing +path. Independent NumPy GQA/probability/context and blocked-attention checks +also run against PyTorch. + +The report uses uppercase `PASS` / `FAIL`. Its `coverage_complete` field requires +the exact seven named cases and lengths, both ORT graphs, all six IR variants +per case, and all 18 named/backend/graph isolation checks. Missing or duplicated +evidence cannot pass by reducing the set of checks being summarized. + +All comparisons require matching shape and dtype, finite values, and strict +`max_abs < 1e-5`, with `rtol=0`. The threshold is fixed and is not a CLI option. +All positions are included, with valid-token and padding-query errors reported +separately. Causal and padding isolation compare the valid prefix at **every +checkpoint**, for PyTorch, both ORT graphs, and all six IR variants. + +Checkpoint count is `3 + 13 * num_hidden_layers`: embedding, 13 observations per +layer, final norm, and logits. Shapes are derived from the actual model config, +including query/KV head count, head dimension, hidden width, vocabulary, and +sequence length. `probes/w2_qwen3_small/model.py` retains its two-layer defaults +and accepts explicit `model_config` overrides plus `sequence_length`. + +The output contains: + +- `model.onnx`, `checkpoints.onnx`, and `diagnostics.onnx`. +- `config.json`, `checkpoint_metadata.json`, packed `checkpoints.json`, and + versioned named-NPZ `trace_schema.json` / `logits_schema.json`. +- `inputs_.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 0000000..6f49460 --- /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 0000000..1065542 --- /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 0000000..2ab45d2 --- /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 0000000..5db8c48 --- /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 0000000..657cdd4 --- /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 0000000..7d3964b --- /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 0000000..ec810b8 --- /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 0000000..7d18753 --- /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 0000000..63679c1 --- /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 670aa63..b4320d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "scratchv" version = "0.3.0" description = "A compiler from ONNX models to RISC-V assembly and LLVM IR" readme = "README.md" -license = {text = "MIT"} +license = {text = "MIT AND MPL-2.0"} keywords = ["compiler", "risc-v", "onnx", "llvm", "machine-learning"] requires-python = ">=3.8" dependencies = [ @@ -20,6 +20,7 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: Education", "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -46,6 +47,9 @@ Documentation = "https://github.com/kinsomwang/ScratchV/tree/main/docs/INDEX.md" [project.scripts] scratchv = "scratchv.main:main" +[tool.setuptools] +license-files = ["LICENSE", "LICENSES/*.txt", "THIRD_PARTY_NOTICES.txt"] + [tool.setuptools.packages.find] include = ["scratchv", "scratchv.*", "scratchv_dag", "scratchv_dag.*", "benchmarks", "benchmarks.*"] diff --git a/scratchv/frontend/onnx_parser.py b/scratchv/frontend/onnx_parser.py index 6bcfacc..364dc0f 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/verification/fp32_eigen.py b/scratchv/verification/fp32_eigen.py new file mode 100644 index 0000000..c53960d --- /dev/null +++ b/scratchv/verification/fp32_eigen.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: MPL-2.0 +# Adapted from Eigen (https://gitlab.com/libeigen/eigen), commit +# 1d8b82b0740839c0de7f1242a3585e3390ff5f33: +# Eigen/src/Core/Redux.h +# Eigen/src/Core/arch/SSE/PacketMath.h +# Eigen/src/Core/arch/Default/GenericPacketMathFunctions.h +# Copyright (C) 2006-2008 Benoit Jacob +# Copyright (C) 2007 Julien Pommier +# Copyright (C) 2008-2009 Gael Guennebaud +# Copyright (C) 2009-2019 Gael Guennebaud +# Copyright (C) 2014 Pedro Gonnet (pedro.gonnet@gmail.com) +# +# This Source Code Form is subject to the terms of the Mozilla +# Public License, v. 2.0. If a copy of the MPL was not distributed +# with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +# The full license is also distributed in LICENSES/Eigen-MPL-2.0.txt. +"""Deterministic FP32 reductions and trigonometric approximations. + +This NumPy adaptation preserves Eigen's SSE reduction order and its non-FMA +``psincos_float`` polynomial. All floating-point computation remains FP32. +It makes no promise of bitwise agreement with every platform's math library. +The reduction uses canonical contiguous row alignment, independent of the +input's memory address or strides. Trigonometry uses NumPy outside a shared +conservative interval for the non-FMA approximation. +""" + +import numpy as np + + +def _fp32_array(values): + array = np.asarray(values) + if array.dtype != np.dtype(np.float32): + raise TypeError("FP32 math requires a float32 array") + return array + + +def _sum_last(array, aligned_start=0): + """Eigen's two Packet4f accumulators, scalar prefix/tail, and predux.""" + width = array.shape[-1] + if width - aligned_start < 4: + result = array[..., 0].copy() + for index in range(1, width): + result = result + array[..., index] + return result + + packet_end = aligned_start + ((width - aligned_start) // 4) * 4 + pair_end = aligned_start + ((width - aligned_start) // 8) * 8 + first = array[..., aligned_start : aligned_start + 4].copy() + if packet_end - aligned_start > 4: + second = array[..., aligned_start + 4 : aligned_start + 8].copy() + for index in range(aligned_start + 8, pair_end, 8): + first = first + array[..., index : index + 4] + second = second + array[..., index + 4 : index + 8] + first = first + second + if packet_end > pair_end: + first = first + array[..., pair_end : pair_end + 4] + + result = (first[..., 0] + first[..., 2]) + (first[..., 1] + first[..., 3]) + for index in range(aligned_start): + result = result + array[..., index] + for index in range(packet_end, width): + result = result + array[..., index] + return result + + +def mean_last(values, keepdims=True): + """Reduce a nonempty last dimension using an explicit FP32 sum order. + + Leading dimensions may be empty. Scalar inputs and an empty reduction + dimension are rejected rather than inventing a mean for an empty set. + Inputs, including read-only and strided arrays, are never modified. + """ + array = _fp32_array(values) + if array.ndim == 0 or array.shape[-1] == 0: + raise ValueError("mean_last requires a nonempty last dimension") + if not isinstance(keepdims, (bool, np.bool_)): + raise TypeError("keepdims must be boolean") + + width = array.shape[-1] + if width % 4 == 0 or width < 4: + sums = _sum_last(array) + else: + # Model a 16-byte-aligned first row and contiguous successive rows. + # Shape alone determines the prefix; array pointer alignment does not. + rows = array.reshape(-1, width) + row_starts = (-np.arange(rows.shape[0]) * width) % 4 + sums = np.empty(rows.shape[0], dtype=np.float32) + for start in range(4): + selected = np.flatnonzero(row_starts == start) + if selected.size: + sums[selected] = _sum_last(rows[selected], start) + + result = np.asarray(sums / np.float32(width)).reshape(array.shape[:-1] + (1,)) + return result if keepdims else result.squeeze(axis=-1) + + +def trig(values, sine: bool): + """FP32 sine or cosine with Eigen's non-FMA range reduction polynomial. + + The shared interval ``abs(x) < 18000`` is below both upstream non-FMA + cutoffs. Larger arguments and nonfinite values retain NumPy semantics. + This approximation can differ from NumPy by a few ULP; it is not a claim + of universally improved accuracy. Scalar and empty arrays are supported. + """ + array = _fp32_array(values) + if not isinstance(sine, (bool, np.bool_)): + raise TypeError("sine must be boolean") + selected = np.isfinite(array) & (np.abs(array) < np.float32(18000)) + result = np.array(np.sin(array) if sine else np.cos(array), dtype=np.float32, copy=True) + reduced = np.abs(array[selected]) + rounded = reduced * np.float32(0.636619746685028076171875) + rounded = rounded + np.float32(12582912) + quadrant = rounded.view(np.uint32) + multiple = rounded - np.float32(12582912) + for coefficient in ( + -1.5703125, + -0.000483989715576171875, + 1.62865035235881805419921875e-7, + 5.5644315544167710640977020375430583953857421875e-11, + ): + reduced = multiple * np.float32(coefficient) + reduced + + squared = reduced * reduced + cosine = np.full_like(reduced, np.float32(2.4372266125283204019069671630859375e-5)) + for coefficient in ( + -0.00138865201734006404876708984375, + 0.041666619479656219482421875, + -0.5, + 1.0, + ): + cosine = cosine * squared + np.float32(coefficient) + + sinpoly = np.full_like( + reduced, + np.float32(-0.0001959234114083702898469196984621021329076029360294342041015625), + ) + sinpoly = sinpoly * squared + np.float32( + 0.00833268736556168516937947998712843400426208972930908203125 + ) + sinpoly = sinpoly * squared + np.float32( + -0.166666620398229825550373561782180331647396087646484375 + ) + sinpoly = sinpoly * squared + sinpoly = sinpoly * reduced + reduced + + even = (quadrant & np.uint32(1)) == 0 + if sine: + output = np.where(even, sinpoly, cosine) + sign = (array[selected].view(np.uint32) ^ (quadrant << np.uint32(30))) & np.uint32( + 0x80000000 + ) + else: + output = np.where(even, cosine, sinpoly) + sign = ((quadrant + np.uint32(1)) << np.uint32(30)) & np.uint32(0x80000000) + result[selected] = (output.view(np.uint32) ^ sign).view(np.float32) + return result diff --git a/scratchv/verification/fp32_fma.py b/scratchv/verification/fp32_fma.py new file mode 100644 index 0000000..d035212 --- /dev/null +++ b/scratchv/verification/fp32_fma.py @@ -0,0 +1,214 @@ +"""Correctly rounded finite FP32 fused multiply-add using NumPy only. + +No floating-point precision promotion is used. The integer fallback aligns +exact significands in uint64 (or nine uint64 limbs), then performs one binary32 +round-to-nearest-even operation. It supports broadcasting, normals, +subnormals and signed zero; NaN/Inf inputs and finite-result overflow raise. + +The FP32 error-free-transform fast path requires all three absolute inputs +in [2**-40, 2**40], hence a/b low-part products cannot be smaller than 2**-126 +and no intermediate can overflow. Dekker TwoProduct and Knuth TwoSum give +exact product_error and sum_error. Let s=fl(fl(a*b)+c), E=product_error+sum_error +(exact), and r=fl(E). Only cases abs(fl(a*b))<=abs(s), with s not a power of two, +are accepted. Then s has equal adjacent spacing u, abs(E)<=u, and abs(r)<=u. +The only rounding boundaries reachable from s are s +/- u/2. If abs(r)==u/2, +use the integer fallback. Otherwise r's distance from either boundary is at +least one ulp(r), larger than abs(E-r)<=0.5 ulp(r); fl(s+r) is therefore the +correct single-rounded result. Excluding powers of two avoids asymmetric +binade spacing. Cancellation, zero, range boundaries not satisfying these +conditions, midpoint ambiguity and all other cases use exact integers. + +The implementation assumes normal NumPy IEEE binary32 round-to-nearest-even +operations. It does not change the host rounding mode or enable FTZ/DAZ. +""" +import numpy as np + +_U = np.uint64 +_I = np.int64 + + +def _topbit(x): + """floor(log2(x)), choosing zero for x=0; only integer shifts.""" + x = x.copy() + result = np.zeros(x.shape, dtype=np.int64) + for shift in (32, 16, 8, 4, 2, 1): + high = x >> _U(shift) + selected = high != 0 + result += np.where(selected, shift, 0) + x = np.where(selected, high, x) + return result + + +def _unpack(array): + bits = array.view(np.uint32).reshape(-1) + exponent = (bits >> np.uint32(23)) & np.uint32(255) + if np.any(exponent == 255): + raise ValueError("finite float32 inputs required") + mantissa = (bits & np.uint32(0x7fffff)).astype(np.uint64) + mantissa |= np.where(exponent != 0, _U(1 << 23), _U(0)) + scale = np.where(exponent != 0, exponent.astype(np.int64) - _I(150), _I(-149)) + return bits >> np.uint32(31), mantissa, scale + + +def _pack(sign, q, quantum): + carry = q == _U(1 << 24) + q = np.where(carry, q >> _U(1), q) + quantum = quantum + carry.astype(np.int64) + exponent = np.where(q >= _U(1 << 23), quantum + _I(150), _I(0)) + if np.any(exponent >= 255): + raise FloatingPointError("fused result overflows finite float32") + bits = ((sign.astype(np.uint32) << np.uint32(31)) + | (exponent.astype(np.uint32) << np.uint32(23)) + | (q.astype(np.uint32) & np.uint32(0x7fffff))) + return bits.view(np.float32) + + +def _round64(sign, magnitude, scale): + quantum = np.maximum(_topbit(magnitude) + scale - _I(23), _I(-149)) + shift = quantum - scale + right = np.maximum(shift, _I(0)).astype(np.uint64) + left = np.maximum(-shift, _I(0)).astype(np.uint64) + q = (magnitude >> right) << left + guard_shift = np.maximum(shift - _I(1), _I(0)).astype(np.uint64) + guard = (shift > 0) & (((magnitude >> guard_shift) & _U(1)) != 0) + # For shift >=65 all magnitude bits are sticky, but the guard is zero. + mask = np.where(guard_shift >= _U(64), _U(0xffffffffffffffff), + (_U(1) << np.minimum(guard_shift, _U(63))) - _U(1)) + sticky = (magnitude & mask) != 0 + q += (guard & (sticky | ((q & _U(1)) != 0))).astype(np.uint64) + return _pack(sign, q, quantum) + + +def _limbs(magnitude, scale): + # Smallest exact product quantum is 2^-298. Highest possible product bit + # index is 553; 9 _limbs (576 bits) also have room for the final addition. + result = np.zeros((9, magnitude.size), dtype=np.uint64) + shifts = scale + _I(298) + indices = np.arange(magnitude.size) + low = shifts // _I(64) + rem = (shifts % _I(64)).astype(np.uint64) + result[low, indices] = magnitude << rem + high = magnitude >> (_U(64) - rem) + selected = (low + 1 < 9) & (high != 0) + result[low[selected] + 1, indices[selected]] = high[selected] + return result + + +def _slow(sign_p, product, ep, sign_c, cm, ec): + p, c = _limbs(product, ep), _limbs(cm, ec) + greater, less = np.zeros(product.size, dtype=bool), np.zeros(product.size, dtype=bool) + for index in range(8, -1, -1): + undecided = ~(greater | less) + greater |= undecided & (p[index] > c[index]) + less |= undecided & (p[index] < c[index]) + p_ge = ~less + same = sign_p == sign_c + sign = np.where(same | p_ge, sign_p, sign_c) + mag = np.zeros_like(p) + carry, borrow = np.zeros(product.size, dtype=np.uint64), np.zeros(product.size, dtype=np.uint64) + for index in range(9): + total = p[index] + c[index] + addition = total + carry + carry = ((total < p[index]) | (addition < total)).astype(np.uint64) + hi, lo = np.where(p_ge, p[index], c[index]), np.where(p_ge, c[index], p[index]) + diff = hi - lo + subtraction = diff - borrow + borrow = ((hi < lo) | (subtraction > diff)).astype(np.uint64) + mag[index] = np.where(same, addition, subtraction) + exact_zero = ~np.any(mag != 0, axis=0) + sign = np.where(exact_zero & ~same, np.uint32(0), sign) + high_index = np.zeros(product.size, dtype=np.int64) + for index in range(9): + high_index = np.where(mag[index] != 0, index, high_index) + columns = np.arange(product.size) + highest = high_index * _I(64) + _topbit(mag[high_index, columns]) + shift = np.maximum(highest - _I(23), _I(149)) + index = shift // _I(64) + rem = (shift % _I(64)).astype(np.uint64) + padded = np.vstack((mag, np.zeros((1, product.size), dtype=np.uint64))) + q = (padded[index, columns] >> rem) | (padded[index + 1, columns] << (_U(64) - rem)) + guard_index = (shift - _I(1)) // _I(64) + guard_rem = ((shift - _I(1)) % _I(64)).astype(np.uint64) + guard = ((mag[guard_index, columns] >> guard_rem) & _U(1)) != 0 + mask = (_U(1) << guard_rem) - _U(1) + sticky = np.zeros(product.size, dtype=bool) + for index in range(9): + sticky |= ((index < guard_index) & (mag[index] != 0)) + sticky |= ((index == guard_index) & ((mag[index] & mask) != 0)) + q += (guard & (sticky | ((q & _U(1)) != 0))).astype(np.uint64) + return _pack(sign, q, shift - _I(298)) + + +def _integer_fma(a, b, c): + arrays = [np.asarray(x) for x in (a, b, c)] + if any(x.dtype != np.dtype("float32") for x in arrays): + raise TypeError("FMA requires native float32 inputs") + a, b, c = np.broadcast_arrays(*arrays) + sa, am, ae = _unpack(a) + sb, bm, be = _unpack(b) + sc, cm, ce = _unpack(c) + product, pe, sp = am * bm, ae + be, sa ^ sb + scale = np.where(product == 0, ce, np.where(cm == 0, pe, np.minimum(pe, ce))) + ps, cs = pe - scale, ce - scale + # Each aligned operand has <=63 significant bits. Their sum fits uint64 + # (max 2^64-2), and opposite-sign subtraction is exact magnitude arithmetic. + fast = (((product == 0) | (_topbit(product) + ps < 63)) + & ((cm == 0) | (_topbit(cm) + cs < 63))) + result = np.empty(product.shape, dtype=np.float32) + if np.any(fast): + p = product[fast] << np.maximum(ps[fast], _I(0)).astype(np.uint64) + cv = cm[fast] << np.maximum(cs[fast], _I(0)).astype(np.uint64) + same = sp[fast] == sc[fast] + p_ge = p >= cv + magnitude = np.where(same, p + cv, np.where(p_ge, p - cv, cv - p)) + sign = np.where(same | p_ge, sp[fast], sc[fast]) + sign = np.where((magnitude == 0) & ~same, np.uint32(0), sign) + result[fast] = _round64(sign, magnitude, scale[fast]) + if np.any(~fast): + result[~fast] = _slow(sp[~fast], product[~fast], pe[~fast], sc[~fast], cm[~fast], ce[~fast]) + return result.reshape(a.shape) + + +def fma(a, b, c): + """Return round32(a*b+c), rejecting non-f32/nonfinite inputs and overflow.""" + values = [np.asarray(value) for value in (a, b, c)] + if any(value.dtype != np.dtype("float32") for value in values): + raise TypeError("FMA requires native float32 inputs") + arrays = np.broadcast_arrays(*values) + shape = arrays[0].shape + a, b, c = [value.reshape(-1) for value in arrays] + lower = np.array(87 << 23, dtype=np.uint32).view(np.float32) + upper = np.array(167 << 23, dtype=np.uint32).view(np.float32) + bounded = np.ones(a.shape, dtype=bool) + for value in (a, b, c): + bounded &= (np.abs(value) >= lower) & (np.abs(value) <= upper) + indices = np.flatnonzero(bounded) + result = np.empty(a.shape, dtype=np.float32) + accepted = np.zeros(a.shape, dtype=bool) + if indices.size: + av, bv, cv = a[indices], b[indices], c[indices] + product = av * bv + split_a = np.float32(4097) * av + high_a = split_a - (split_a - av) + low_a = av - high_a + split_b = np.float32(4097) * bv + high_b = split_b - (split_b - bv) + low_b = bv - high_b + product_error = low_a * low_b - ( + ((product - high_a * high_b) - low_a * high_b) - high_a * low_b) + total = product + cv + virtual_c = total - product + sum_error = (product - (total - virtual_c)) + (cv - virtual_c) + residual = product_error + sum_error + total_bits = total.view(np.uint32) + exponent = (total_bits >> np.uint32(23)) & np.uint32(255) + half_ulp = ((np.maximum(exponent, np.uint32(24)) - np.uint32(24)) + << np.uint32(23)).view(np.float32) + safe = ((np.abs(product) <= np.abs(total)) + & ((total_bits & np.uint32(0x7fffff)) != 0) + & (np.abs(residual) != half_ulp)) + accepted[indices[safe]] = True + result[indices[safe]] = (total + residual)[safe] + if np.any(~accepted): + result[~accepted] = _integer_fma(a[~accepted], b[~accepted], c[~accepted]) + return result.reshape(shape) diff --git a/scratchv/verification/fp32_reference.py b/scratchv/verification/fp32_reference.py new file mode 100644 index 0000000..24fbbc0 --- /dev/null +++ b/scratchv/verification/fp32_reference.py @@ -0,0 +1,269 @@ +"""Opt-in, fixed-order NumPy FP32 reference evaluation. + +This profile specifies evaluation order and polynomial approximations, not +universally greater mathematical accuracy or bitwise equality to every ORT +build/CPU. No model names, positions, weights, or expected outputs are used. +Default interpreter execution continues to use the native NumPy kernels. + +MLAS polynomial constants/evaluation are adapted from ONNX Runtime v1.22.1: +core/mlas/lib/logistic.cpp, compute.cpp, x86_64/TransKernelFma3.S and +x86_64/TransKernelAvx512F.S (including their amd64 Windows equivalents). +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from __future__ import annotations + +import os +import re + +import numpy as np + +from scratchv.ir.types import OpCode +from scratchv.verification.fp32_eigen import mean_last, trig +from scratchv.verification.fp32_fma import fma + + +_CPU_ENV = "SCRATCHV_FP32_REFERENCE_CPU" +_CPU_STRATEGIES = ("avx2-fma3", "avx512") + + +def _cpu_features(): + # NumPy checks both hardware capabilities and usable OS vector state. + # This private diagnostic is optional: an explicit strategy works without it. + try: + from numpy._core._multiarray_umath import __cpu_features__ + except ImportError: + try: + from numpy.core._multiarray_umath import __cpu_features__ + except ImportError: + return {} + return __cpu_features__ + + +def _cpu_strategy(requested=None): + """Resolve a reference arithmetic policy; never change ORT dispatch. + + Explicit policies also allow reproducing saved arithmetic on another CPU. + NumPy's disable list does not constrain MLAS, so automatic inference must + not treat deliberately hidden NumPy capabilities as hardware evidence. + """ + requested = os.environ.get(_CPU_ENV, "auto") if requested is None else requested + if requested in _CPU_STRATEGIES: + return requested + if requested != "auto": + raise ValueError(f"{_CPU_ENV} must be auto, avx2-fma3, or avx512") + disabled = re.split(r"[\s,]+", os.environ.get("NPY_DISABLE_CPU_FEATURES", "").upper()) + if any(feature in ("AVX", "AVX2", "FMA3") or feature.startswith("AVX512") + for feature in disabled): + raise ValueError(f"NPY_DISABLE_CPU_FEATURES obscures MLAS capabilities; set {_CPU_ENV} explicitly") + features = _cpu_features() + if features.get("AVX2") and features.get("FMA3"): + return "avx512" if features.get("AVX512F") else "avx2-fma3" + raise ValueError(f"Cannot infer a supported FP32 CPU strategy; set {_CPU_ENV} explicitly") + + +def profile(cpu_strategy=None): + """Return a canonical arithmetic contract, independent of selection method. + + Passing a supported explicit cpu_strategy validates a saved profile on a + different host without claiming that the local ORT uses that strategy. + """ + strategy = _cpu_strategy(cpu_strategy) + lanes = 8 if strategy == "avx2-fma3" else 16 + return { + "name": "numpy-fp32-reference-v4", + "dtype": "float32", + "cpu_strategy": strategy, + "matmul_k_block": 128, + "matmul_row_tile": 2 if lanes == 8 else None, + "matmul_row_tail": "zero-pad-to-two-then-trim" if lanes == 8 else "native", + "matmul_column_tail": "zero-pad-single-column-to-two-then-trim" if lanes == 8 else "native", + "matmul_layout": "contiguous-matrix-operands-per-k-block" if lanes == 8 else "native-strides", + "matmul_vector_policy": "native-numpy-vector-promotion", + "mean": "two-four-lane-last-axis", + "trig": "non-fused-range-reduction-polynomial", + "softmax": f"exp-polynomial-{lanes}-lane-sum-reciprocal-multiply", + "softmax_lanes": lanes, + "softmax_exp_lower_bound": float(np.float32( + -88.3762626647949 if lanes == 8 else -103.9720840454)), + "softmax_exp_scaling": "binary32-exponent-bits" if lanes == 8 else "ldexp", + "sigmoid": "bounded-rational-polynomial-clamped-to-[0,1]", + "multiply_add": "binary32-round-to-nearest-even", + "reference_basis": "ORT 1.22.1 CPU arithmetic; no ORT runtime dependency", + "scope": "Explicit FP32 reference evaluation; other dtypes unchanged. " + "CPU strategy specifies reference arithmetic, not ORT dispatch. " + "Not a promise of bitwise matching every CPU or ORT version.", + } + + +def matmul(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError + a, b = xs + if "m" in attrs: + m, n, k = (attrs[key] for key in ("m", "n", "k")) + if a.ndim == b.ndim == 1: + a, b = a.reshape(m, k), b.reshape(k, n) + elif a.shape != (m, k) or b.shape != (k, n): + raise OpError("ShapeError", "MATMUL m/n/k disagree with tensor shapes") + # Preserve NumPy's existing vector-promotion arithmetic and shape errors. + if a.ndim < 2 or b.ndim < 2: + return np.matmul(a, b) + if a.shape[-1] != b.shape[-2]: + raise OpError("ShapeError", "MATMUL reduction dimensions differ") + strategy = _cpu_strategy() + if strategy == "avx512" and a.shape[-1] <= 128: + return np.matmul(a, b) + shape = np.broadcast_shapes(a.shape[:-2], b.shape[:-2]) + (a.shape[-2], b.shape[-1]) + result = np.zeros(shape, dtype=np.float32) + if strategy == "avx512": + # Retain the existing wide-matrix policy. SkylakeX small-matrix kernels + # have a different reduction order from its ordinary wide GEMM path. + for start in range(0, a.shape[-1], 128): + result += np.matmul(a[..., start:start + 128], b[..., start:start + 128, :]) + return result + # Haswell OpenBLAS 0.3.29's wider SGEMM tiles use two interleaved K + # accumulators on selected boundaries (sgemm_kernel_8x4_haswell_2.c). + # A fixed two-row tile avoids those boundaries; padding a single-row + # tail also avoids switching to a different GEMV reduction. This applies + # to K<=128 too: attention score products use exactly K=128. + if not a.shape[-2] or not b.shape[-1]: + return result + for start in range(0, a.shape[-1], 128): + # Packing once per K block also handles stepped/transposed inputs: + # non-BLAS NumPy strided loops have a different arithmetic order. + left = np.ascontiguousarray(a[..., start:start + 128]) + right = np.ascontiguousarray(b[..., start:start + 128, :]) + if left.shape[-2] % 2: + padded = np.zeros(left.shape[:-2] + (left.shape[-2] + 1, left.shape[-1]), dtype=np.float32) + padded[..., :-1, :] = left + left = padded + if right.shape[-1] == 1: + padded = np.zeros(right.shape[:-1] + (2,), dtype=np.float32) + padded[..., :1] = right + right = padded + for row in range(0, a.shape[-2], 2): + rows = min(2, a.shape[-2] - row) + product = np.matmul(left[..., row:row + 2, :], right) + result[..., row:row + rows, :] += product[..., :rows, :b.shape[-1]] + return result + + +def reduce_mean(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError, axes, flag, reduce_mean as native + x = xs[0] + requested = attrs.get("axes") + selected = tuple(range(x.ndim)) if not requested else axes(requested, x.ndim) + if any(x.shape[a] == 0 for a in selected): + raise OpError("NumericError", "cannot reduce an empty dimension") + if x.ndim and selected == (x.ndim - 1,): + return mean_last(x, keepdims=flag(attrs.get("keepdims", True), "keepdims")) + return native(xs, attrs, dtype) + + +_SIGMOID_P = tuple(np.float32(v) for v in ( + 4.37031012579801e-11, 1.15627324459942e-7, 6.08574864600143e-5, + 8.51377133304701e-3, 2.48287947061529e-1)) +_SIGMOID_Q = tuple(np.float32(v) for v in ( + 6.10247389755681e-13, 5.76102136993427e-9, 6.29106785017040e-6, + 1.70198817374094e-3, 1.16817656904453e-1, 9.93151921023180e-1)) +_EXP = tuple(np.float32(float.fromhex(v)) for v in ( + "0x1.694000p-10", "0x1.125edcp-7", "0x1.555b5ap-5", "0x1.555450p-3", + "0x1.fffff6p-2", "0x1.000000p+0", "0x1.000000p+0")) + + +def sigmoid(xs, attrs, dtype): + x = np.clip(xs[0], np.float32(-18), np.float32(18)) + squared = x * x + numerator = np.full_like(x, _SIGMOID_P[0]) + denominator = np.full_like(x, _SIGMOID_Q[0]) + for coefficient in _SIGMOID_P[1:]: + numerator = fma(numerator, squared, coefficient) + for coefficient in _SIGMOID_Q[1:]: + denominator = fma(denominator, squared, coefficient) + # Rational-approximation roundoff can cross either saturation endpoint. + return np.clip((numerator * x) / denominator + np.float32(0.5), + np.float32(0), np.float32(1)) + + +def _exp_nonpositive(x, *, cpu_strategy="avx512"): + # The bounded polynomial's smallest result rounds to zero; masked -inf + # from stable subtraction is consequently normalized to probability zero. + x = np.maximum(x, np.float32( + -88.3762626647949 if cpu_strategy == "avx2-fma3" else -103.9720840454)) + bias = np.float32(12582912) + rounded = fma(x, np.float32(1.44269504088896341), bias) + exponent = rounded - bias + x = fma(exponent, np.float32(-6.93145752e-1), x) + x = fma(exponent, np.float32(-1.42860677e-6), x) + polynomial = np.full_like(x, _EXP[0]) + for coefficient in _EXP[1:]: + polynomial = fma(polynomial, x, coefficient) + if cpu_strategy == "avx2-fma3": + # FMA3 reconstructs 2**m directly in the binary32 exponent field. + # In particular m=-127 becomes zero, unlike AVX512's VSCALEFPS. + bits = (rounded.view(np.uint32) << np.uint32(23)) + np.uint32(0x3f800000) + return polynomial * bits.view(np.float32) + return np.ldexp(polynomial, exponent.astype(np.int32)) + + +def _sum_lanes(values, lanes): + sums = np.zeros(values.shape[:-1] + (lanes,), dtype=np.float32) + for start in range(0, values.shape[-1], lanes): + block = values[..., start:start + lanes] + sums[..., :block.shape[-1]] += block + if lanes == 16: + sums = sums[..., :8] + sums[..., 8:] + first = (sums[..., 0] + sums[..., 1]) + (sums[..., 2] + sums[..., 3]) + last = (sums[..., 4] + sums[..., 5]) + (sums[..., 6] + sums[..., 7]) + return (first + last)[..., None] + + +def _sum_sixteen(values): + # Retain the named primitive for existing standalone arithmetic diagnostics. + return _sum_lanes(values, 16) + + +def softmax(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError, axis + x = xs[0] + selected = axis(attrs.get("axis", -1), x.ndim) + if x.shape[selected] == 0: + raise OpError("NumericError", "SOFTMAX cannot normalize an empty axis") + x = np.moveaxis(x, selected, -1) + maximum = np.max(x, axis=-1, keepdims=True) + if not np.isfinite(maximum).all(): + raise OpError("NumericError", "SOFTMAX row is entirely masked or nonfinite") + with np.errstate(over="ignore"): + reduced = x - maximum + strategy = _cpu_strategy() + weights = _exp_nonpositive(reduced, cpu_strategy=strategy) + result = weights * (np.float32(1) / _sum_lanes( + weights, 8 if strategy == "avx2-fma3" else 16)) + return np.moveaxis(result, -1, selected) + + +HANDLERS = { + OpCode.MATMUL: matmul, + OpCode.REDUCE_MEAN: reduce_mean, + OpCode.SOFTMAX: softmax, + OpCode.SIGMOID: sigmoid, + OpCode.COS: lambda xs, attrs, dtype: trig(xs[0], sine=False), + OpCode.SIN: lambda xs, attrs, dtype: trig(xs[0], sine=True), +} diff --git a/scratchv/verification/ir_interpreter.py b/scratchv/verification/ir_interpreter.py index 1891229..6b1a45f 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 92baeab..432ff02 100644 --- a/scratchv/verification/ir_numpy_ops.py +++ b/scratchv/verification/ir_numpy_ops.py @@ -183,12 +183,15 @@ def check_instruction(instr: Instruction): raise OpError("AttributeError", f"{key} must be finite numeric") -def compute(instr, operands): +def compute(instr, operands, *, fp32_mode="native"): + handler = KERNELS[instr.opcode].handler + dtype = DTYPES[instr.dest.dtype] + if fp32_mode == "reference" and dtype == np.dtype("float32"): + from scratchv.verification.fp32_reference import HANDLERS + handler = HANDLERS.get(instr.opcode, handler) with np.errstate(over="raise", divide="raise", invalid="raise", under="ignore"): result = np.asarray( - KERNELS[instr.opcode].handler( - operands, instr.attrs, DTYPES[instr.dest.dtype] - ) + handler(operands, instr.attrs, dtype) ) if np.issubdtype(result.dtype, np.floating) and not np.isfinite(result).all(): raise OpError("NumericError", "operation produced nonfinite output") diff --git a/scripts/ci_scopes.json b/scripts/ci_scopes.json index f83c8f5..578d71f 100644 --- a/scripts/ci_scopes.json +++ b/scripts/ci_scopes.json @@ -28,7 +28,11 @@ "tests/test_w2_*.py", "tests/test_numeric_metrics.py", "tests/test_numeric_summary.py", - ".github/workflows/llm-deploy.yml" + ".github/workflows/llm-deploy.yml", + "scripts/summarize_w3_ci.py", + "tests/test_w3_actions_summary.py", + "tests/test_w3_workflows.py", + ".github/workflows/w3-*.yml" ], "w4": [] } diff --git a/scripts/collect_w3_ci_evidence.py b/scripts/collect_w3_ci_evidence.py new file mode 100644 index 0000000..8a65315 --- /dev/null +++ b/scripts/collect_w3_ci_evidence.py @@ -0,0 +1,172 @@ +"""Retain bounded diagnostic evidence, with an explicit inventory of omissions.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat + + +MIB = 1024**2 +MAX_FILE_BYTES = 256 * MIB +MAX_TOTAL_BYTES = 512 * MIB +MAX_REPORT_BYTES = 32 * MIB +FAILURE_SUFFIXES = {".npz", ".npy", ".onnx", ".log", ".stdout", ".stderr"} + + +def collect(source, destination, *, max_file_bytes=MAX_FILE_BYTES, max_total_bytes=MAX_TOTAL_BYTES): + """Copy selected evidence; omissions remain visible even after raw artifacts expire. + + This inventories copied bytes, but does not rerun any numerical gate. The CLI + exits unsuccessfully when selection is incomplete; other safe files survive. + """ + for limit in (max_file_bytes, max_total_bytes): + if type(limit) is not int or limit <= 0: + raise ValueError("Evidence byte limits must be positive integers") + source, destination = Path(source).resolve(), Path(destination).resolve() + if destination.is_relative_to(source): + raise ValueError("Evidence destination must be outside its source directory") + destination.mkdir(parents=True, exist_ok=False) + selected, omitted = {}, [] + + def omit(path, reason, **details): + omitted.append({"path": str(path.relative_to(source)), "reason": reason, **details}) + + def safe(path): + if not path.resolve().is_relative_to(source): + raise ValueError("Evidence path escapes source directory") + + def scan(folder, suffixes, names=(), *, required=False): + try: + safe(folder) + if not folder.exists(): + if required: + omit(folder, "Missing case evidence directory") + return + # Never follow linked directories while searching for failure evidence. + def walk_error(error): + omit(folder, f"Cannot scan evidence: {error}") + + for root, dirs, files in os.walk(folder, followlinks=False, onerror=walk_error): + root = Path(root) + for name in list(dirs): + child = root / name + if child.is_symlink() or not child.resolve().is_relative_to(source): + dirs.remove(name) + omit(child, "Linked or external evidence directory") + dirs.sort() + for name in sorted(files): + path = root / name + if path.suffix in suffixes or name in names: + selected[path] = None + except (OSError, ValueError) as exc: + omit(folder, str(exc)) + + # First priority: the exact trace selected by the independent layer-diff gate. + for name in ("trace_schema.json", "traces/short_17/ir_diagnostic_all.npz", + "traces/short_17/ort_diagnostic.npz"): + selected[source / "medium" / name] = None + # Original target logs permit inspection of UART/protocol failures. + scan(source / "attention", {".log", ".stdout", ".stderr"}, {"uart.bin"}) + for gate in ("medium", "subgraphs", "attention"): + folder = source / gate + report_path = folder / "report.json" + if not report_path.exists(): + # Interrupted runs may have raw evidence but no final report. + omit(report_path, "Missing gate report; collected available diagnostics") + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + continue + try: + safe(report_path) + info = report_path.stat() + if not stat.S_ISREG(info.st_mode): + raise ValueError("Report is not a regular file") + if info.st_size > MAX_REPORT_BYTES: + raise ValueError("Report exceeds size limit") + report = json.loads(report_path.read_text(encoding="utf-8")) + if not isinstance(report, dict): + raise ValueError("Report must be an object") + if report.get("passed") is True: + continue + rows = report.get("cases", []) + if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): + raise ValueError("Report cases must be a list of objects") + failed = [row for row in rows if row.get("passed") is not True] + # An invariant can fail after all individual comparisons have passed. + for row in failed or rows: + name = row.get("name") + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name): + raise ValueError("Invalid case evidence directory name") + case_folder = folder / ("traces" if gate == "medium" else "") / name + if not case_folder.resolve().is_relative_to(folder.resolve()): + raise ValueError("Case evidence escapes its gate directory") + scan(case_folder, FAILURE_SUFFIXES, {"uart.bin"}, required=True) + if not rows: + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + except (OSError, ValueError) as exc: + omit(report_path, f"Cannot select cases from report: {exc}") + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + + records, total = [], 0 + for path in selected: + relative = path.relative_to(source) + target = destination / relative + try: + safe(path) + info = path.stat() + if not stat.S_ISREG(info.st_mode): + raise ValueError("Evidence is not a regular file") + if info.st_size > max_file_bytes: + raise ValueError("Evidence exceeds per-file byte limit") + if info.st_size > max_total_bytes - total: + raise ValueError("Evidence exceeds remaining total byte limit") + target.parent.mkdir(parents=True, exist_ok=True) + digest, copied = hashlib.sha256(), 0 + with path.open("rb") as src, target.open("xb") as dst: + while chunk := src.read(MIB): + copied += len(chunk) + if copied > min(max_file_bytes, max_total_bytes - total): + raise ValueError("Evidence grew beyond byte limit during collection") + dst.write(chunk) + digest.update(chunk) + total += copied + records.append({"path": str(relative), "bytes": copied, "sha256": digest.hexdigest()}) + except (OSError, ValueError) as exc: + target.unlink(missing_ok=True) + omit(path, str(exc)) + + files = [record["path"] for record in records] + manifest = { + "schema_version": 1, + "scope": "Selected preparation trace and failure diagnostics only; not complete W3 raw evidence.", + "independent_full_revalidation": False, + "validation": "Copy inventory and SHA256 only; this collector does not perform numerical validation.", + "selection_complete": not omitted, + "limits": {"max_file_bytes": max_file_bytes, "max_total_bytes": max_total_bytes}, + "retained_bytes": total, + "files": records, + "omitted": omitted, + } + (destination / "retained-files.json").write_text(json.dumps(files, indent=2) + "\n", encoding="utf-8") + (destination / "retained-evidence.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return files + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--destination", type=Path, required=True) + args = parser.parse_args(argv) + collect(args.source, args.destination) + manifest = json.loads((args.destination / "retained-evidence.json").read_text(encoding="utf-8")) + if not manifest["selection_complete"]: + print(f"Evidence selection incomplete: {len(manifest['omitted'])} omissions; see retained-evidence.json") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/package_w3_repro.py b/scripts/package_w3_repro.py new file mode 100644 index 0000000..3573c65 --- /dev/null +++ b/scripts/package_w3_repro.py @@ -0,0 +1,216 @@ +"""Create/verify a bounded, content-addressed W3 source handoff without committing. + +This preserves source bytes, not Git history, model assets or execution results. +The manifest is an integrity inventory, not a signature or numerical acceptance. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import subprocess +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = "W3_SOURCE_MANIFEST.json" +SCHEMA_VERSION = 2 +ROOT_FILES = {"pyproject.toml", "README.md", "LICENSE", "THIRD_PARTY_NOTICES.txt", + ".gitignore", ".gitattributes"} +TREES = {"scratchv", "scratchv_dag", "benchmarks", "examples", "probes", "scripts", "requirements", + "tests", "LICENSES"} +EXCLUDED_PARTS = {".git", "__pycache__", "output", "out", "build", "dist", ".venv", + "venv", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +EXCLUDED_SUFFIXES = {".onnx", ".data", ".safetensors", ".npy", ".npz", ".pyc", + ".elf", ".bin", ".zip", ".pt", ".pth", ".exe", ".dll"} +MAX_FILE_BYTES = 8 * 1024**2 +MAX_TOTAL_BYTES = 64 * 1024**2 +REQUIRED = {"pyproject.toml", "LICENSE", "scripts/package_w3_repro.py", + "probes/w3_qwen3_full/run.py", "requirements/qwen3-small-probe.txt", + "examples/run_ir_interpreter.py", + "docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md", + "docs/llm-deploy-v1.0/W3/README.md"} +REPRODUCTION = {"platform": "linux", "shell": "bash", + "guide": "docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md", + "stage_guide": "docs/llm-deploy-v1.0/W3/README.md"} + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def safe_name(name): + if not isinstance(name, str): + raise ValueError("Unsafe manifest path") + path = PurePosixPath(name) + if (not name or "\\" in name or ":" in name + or path.is_absolute() or any(x in ("", ".", "..") for x in name.split("/"))): + raise ValueError("Unsafe manifest path") + return path + + +def included(name): + path = safe_name(name) + if any(x in EXCLUDED_PARTS or x.startswith(".env") for x in path.parts): + return False + if path.suffix.lower() in EXCLUDED_SUFFIXES: + return False + return (name in ROOT_FILES or path.parts[0] in TREES + or name.startswith("docs/llm-deploy-v1.0/") + or (name.startswith(".github/workflows/w3-") and path.suffix in (".yml", ".yaml"))) + + +def git(root, *args): + result = subprocess.run(["git", "-C", str(root), *args], check=True, + capture_output=True, timeout=30) + return result.stdout + + +def candidates(root): + raw = git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard") + return sorted({name for value in raw.split(b"\0") if value + if included(name := value.decode("utf-8")) + and ((root/name).exists() or (root/name).is_symlink())}) + + +def read_source(root, name): + path = root / name + # Reject links including links in parents, even when pointing inside the tree. + for part in (path, *path.parents): + if part == root: + break + if part.is_symlink(): + raise ValueError(f"Linked source is not supported: {name}") + if not path.resolve().is_relative_to(root) or not path.is_file(): + raise ValueError(f"Missing or external source: {name}") + if path.stat().st_size > MAX_FILE_BYTES: + raise ValueError(f"Source exceeds file limit: {name}") + with path.open("rb") as stream: + value = stream.read(MAX_FILE_BYTES + 1) + if len(value) > MAX_FILE_BYTES: + raise ValueError(f"Source grew beyond file limit: {name}") + return value + + +def snapshot_id(files): + return digest(json.dumps(files, ensure_ascii=False, sort_keys=True, + separators=(",", ":")).encode("utf-8")) + + +def create(root, output): + root, output = Path(root).resolve(), Path(output).resolve() + checksum = output.with_suffix(output.suffix + ".sha256") + if output.exists() or checksum.exists(): + raise ValueError("Archive and checksum must use fresh paths") + payload, total = {}, 0 + for name in candidates(root): + value = read_source(root, name) + total += len(value) + if total > MAX_TOTAL_BYTES: + raise ValueError("Source snapshot exceeds total size limit") + payload[name] = value + for required in REQUIRED: + if required not in payload: + raise ValueError(f"Missing required source: {required}") + files = [{"path": name, "bytes": len(value), "sha256": digest(value)} + for name, value in payload.items()] + manifest = {"schema_version": SCHEMA_VERSION, "snapshot_id": snapshot_id(files), + "base_commit": git(root, "rev-parse", "HEAD").decode().strip(), + "scope": "Selected current working-tree source bytes; includes uncommitted W3 files.", + "excludes": "Git history, model weights/binaries, environments and run evidence.", + "reproduction": dict(REPRODUCTION), + "is_commit": False, "numerical_acceptance": False, "files": files} + if candidates(root) != list(payload): + raise ValueError("Source inventory changed during packaging; retry after edits finish") + for name, value in payload.items(): + if digest(read_source(root, name)) != digest(value): + raise ValueError(f"Source changed during packaging: {name}") + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_DEFLATED) as archive: + for name, value in [*payload.items(), (MANIFEST, json.dumps( + manifest, ensure_ascii=False, indent=2).encode("utf-8") + b"\n")]: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, value) + archive_hash = digest(output.read_bytes()) + with checksum.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(f"{archive_hash} {output.name}\n") + return {"snapshot_id": manifest["snapshot_id"], "files": len(files), + "source_bytes": total, "archive": str(output), "archive_sha256": archive_hash, + "base_commit": manifest["base_commit"], "is_commit": False} + + +def verify(root, expected_snapshot_id=None): + root = Path(root).resolve() + manifest = json.loads(read_source(root, MANIFEST)) + if (not isinstance(manifest, dict) or manifest.get("schema_version") != SCHEMA_VERSION + or not isinstance(manifest.get("files"), list)): + raise ValueError("Unsupported source manifest") + if manifest.get("reproduction") != REPRODUCTION: + raise ValueError("Source manifest must declare the Linux reproduction contract") + files = manifest["files"] + if not files: + raise ValueError("Empty source manifest") + actual_id = snapshot_id(files) + if actual_id != manifest.get("snapshot_id") or (expected_snapshot_id is not None + and actual_id != expected_snapshot_id): + raise ValueError("Snapshot identity mismatch") + seen, total = set(), 0 + for entry in files: + if not isinstance(entry, dict) or set(entry) != {"path", "bytes", "sha256"}: + raise ValueError("Invalid source file descriptor") + name = entry["path"] + if not isinstance(name, str) or not included(name) or name in seen: + raise ValueError("Unexpected or duplicate source path") + seen.add(name) + value = read_source(root, name) + total += len(value) + if total > MAX_TOTAL_BYTES or type(entry["bytes"]) is not int: + raise ValueError("Invalid source byte inventory") + if len(value) != entry["bytes"] or digest(value) != entry["sha256"]: + raise ValueError(f"Source bytes differ: {name}") + if not REQUIRED.issubset(seen): + raise ValueError("Manifest is missing required source files") + # Output directories and external models are allowed; added source is not. + for folder in [*(root/x for x in TREES), root/"docs/llm-deploy-v1.0", root/".github/workflows"]: + if folder.exists(): + for directory, dirs, names in os.walk(folder, followlinks=False): + dirs[:] = [name for name in dirs if name not in EXCLUDED_PARTS] + if any((Path(directory)/name).is_symlink() for name in dirs): + raise ValueError("Linked source directory is not supported") + for filename in names: + path = Path(directory) / filename + name = path.relative_to(root).as_posix() + if included(name) and name not in seen: + raise ValueError(f"Unlisted source file: {name}") + for name in ROOT_FILES: + if (root/name).exists() and name not in seen: + raise ValueError(f"Unlisted root source: {name}") + return {"passed": True, "snapshot_id": actual_id, "files": len(seen), + "source_bytes": total, "claim": "Source integrity only; no numerical or team acceptance."} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + make = sub.add_parser("create") + make.add_argument("--source-root", type=Path, default=ROOT) + make.add_argument("--output", type=Path, required=True) + check = sub.add_parser("verify") + check.add_argument("--root", type=Path, default=ROOT) + check.add_argument("--expected-snapshot-id") + args = parser.parse_args(argv) + try: + result = create(args.source_root, args.output) if args.command == "create" else verify( + args.root, args.expected_snapshot_id) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + parser.exit(1, f"W3 source snapshot failed: {exc}\n") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_w3_preparation.py b/scripts/run_w3_preparation.py new file mode 100644 index 0000000..ebef39f --- /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_w3_ci.py b/scripts/summarize_w3_ci.py new file mode 100644 index 0000000..1271a89 --- /dev/null +++ b/scripts/summarize_w3_ci.py @@ -0,0 +1,199 @@ +"""Publish bounded W3 numerical reports to GitHub Actions without changing gates.""" +from __future__ import annotations + +import argparse +from html import escape +import json +import math +import os +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.numeric_summary import compact_metric_table +from probes.w3_summary import evidence_index_views, numerical_summary + +# The medium report currently exceeds 12 MiB because it retains checkpoints. +# Bound input allocation separately from the much smaller Actions page budget. +MAX_REPORT_BYTES = 64 * 1024 * 1024 +MAX_SUMMARY_BYTES = 64 * 1024 + +GATES = ( + ("medium", "w3-medium-host", "6-layer random Qwen3 / host IR"), + ("subgraphs", "w3_qwen3_subgraphs", "Pretrained layer-0 subgraphs / host IR"), + ("attention", "unit:attention-backend", "Small Attention / RISC-V QEMU"), + ("full-preflight", "preflight:w3-full-assets", "Full assets / static checks only"), + ("layer-diff", "w3_layer_diff", "Saved medium-model checkpoints only"), +) + + +def cell(value, limit=180): + text = " ".join(str(value).split()) + if len(text) > limit: + text = text[:limit] + "…" + return escape(text).replace("|", "|") + + +def load_report(path, gate): + try: + with path.open("rb") as stream: + payload = stream.read(MAX_REPORT_BYTES + 1) + if len(payload) > MAX_REPORT_BYTES: + return None, "INVALID REPORT (size limit)" + report = json.loads(payload.decode("utf-8")) + except FileNotFoundError: + return None, "MISSING REPORT" + except (OSError, ValueError, RecursionError): + return None, "INVALID REPORT" + if not isinstance(report, dict) or report.get("gate") != gate: + return None, "INVALID REPORT (gate identity)" + # Fail closed on malformed structures before calling the shared renderer. + for key in ("cases", "gates", "invariants"): + rows = report.get(key, []) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + return None, "INVALID REPORT (rows)" + return report, "" + + +def status(report): + if report.get("status") == "PARTIAL": + return "PARTIAL (not acceptance)" + if report.get("status") == "PASS" and report.get("passed") is True: + if report.get("gate") == "numeric:ir-full-qwen3" and report.get("coverage_complete") is not True: + return "INCOMPLETE (not acceptance)" + return "PASS" + return "FAIL" if report.get("passed") is False else "UNCONFIRMED" + + +def seconds(report): + value = report.get("elapsed_seconds", report.get("seconds")) + if type(value) in (int, float) and math.isfinite(value) and value >= 0: + return f"{value:.3f} s" + return "not recorded" + + +def coverage(report): + gate = report.get("gate") + cases = report.get("cases", []) + if gate == "w3_layer_diff": + values = report.get("coverage", {}) + return f"{values.get('compared_checkpoints', '?')}/{values.get('total_checkpoints', '?')} checkpoints" + if gate == "preflight:w3-full-assets": + return "no model execution" + if gate == "unit:attention-backend": + count = sum(len(row.get("executions", [])) for row in cases) + return f"{len(cases)} cases / {count} target executions recorded" + required = report.get("required_cases") + expected = len(required) if isinstance(required, list) else {"w3-medium-host": 7, "w3_qwen3_subgraphs": 14}.get(gate, "?") + return f"{len(cases)}/{expected} cases recorded" + + +def details(report, title): + """Reuse bounded semantic selectors, never dump arbitrary report dictionaries.""" + description, comparisons, notes = numerical_summary(report) + index, _ = evidence_index_views(report) + text = f"
{cell(title)}\n\n{cell(description, 700)}\n\n" + if comparisons: + text += compact_metric_table(comparisons) + "\n\n" + if report.get("error"): + text += "Issue: " + cell(report["error"], 400) + "\n\n" + text += "\n\n".join(cell(note, 500) for note in notes) + "\n\n" + index + "
\n\n" + return text + + +def render(kind, directory, outcome): + title = "W3 preparation" if kind == "preparation" else "W3 complete IR numeric" + artifact = "w3-preparation-reports" if kind == "preparation" else "w3-full-numeric-reports" + evidence = (f"Artifacts: **{artifact}** (`report.json`, `report.md`, `report.html`). " + "Raw arrays are separate where retained. This summary does not replace independent reproduction or team acceptance.\n") + lines = [f"## {title}\n\n"] + if outcome in ("skipped", "", "cancelled"): + label = "CANCELLED" if outcome == "cancelled" else "NOT RUN" + lines.append(f"**{label}** — probe step did not complete; no numerical acceptance claimed.\n\n") + lines.append("Existing files, if any, are not used as results for this step.\n") + return "".join(lines) + evidence + identity = "preparation:w3" if kind == "preparation" else "numeric:ir-full-qwen3" + report, problem = load_report(directory / "report.json", identity) + if report is None: + lines.append(f"**{problem}** — step outcome: **{cell(outcome)}**. No acceptance claimed; inspect step logs.\n") + return "".join(lines) + evidence + conclusion = status(report) + if outcome != "success" and conclusion == "PASS": + conclusion = "FAIL (step did not succeed)" + lines.append(f"**{conclusion}** · step: {cell(outcome)} · pipeline: {seconds(report)}\n\n") + comparisons, folded = [], [] + try: + if kind == "preparation": + lines.append("Preparation only; full 28-layer IR numeric and full-model QEMU are **NOT RUN by this job**.\n\n") + lines.append("| Check / scope | Result | Recorded coverage | Pipeline time |\n| --- | --- | --- | --- |\n") + for name, identity, scope in GATES: + rows = [row for row in report.get("gates", []) if row.get("name") == name] + row = rows[0] if len(rows) == 1 else None + child, problem = load_report(directory / name / "report.json", identity) if row else (None, "NOT RUN" if not rows else "INVALID REPORT (duplicate gate)") + child_status = problem or status(child) + if row and status(row) != "PASS" and child_status == "PASS": + child_status = "FAIL (parent validation)" + if child_status != "PASS" and conclusion == "PASS": + lines[1] = f"**INCOMPLETE REPORTS (not acceptance)** · step: {cell(outcome)} · pipeline: {seconds(report)}\n\n" + lines.append("| " + " | ".join(cell(x) for x in (scope, child_status, coverage(child) if child else "not recorded", seconds(row) if row else "not recorded")) + " |\n") + if child: + _, numeric, _ = numerical_summary(child) + selected = numeric[1:] if name in ("medium", "subgraphs") else numeric[1:2] if name == "attention" else [] + records = [item for _, items in selected for item in items] + if selected: + comparisons.append((scope + " vs ORT", records)) + folded.append(details(child, scope + " — details")) + folded.insert(0, details(report, "Preparation execution / failures")) + else: + lines.append("28-layer pretrained Qwen3, FP32 / L=256 / no KV cache; IR vs ORT. **No full-model QEMU result in this job.**\n\n") + lines.append(f"Coverage: **{cell(coverage(report))}** · FP32 mode: **{cell(report.get('fp32_mode', 'not recorded'))}** · maximum absolute error threshold: **{cell(report.get('atol', 'not recorded'))}** (strict <, rtol=0).\n\n") + display = dict(report) + recorded = {row.get("name") for row in report.get("cases", [])} + display["cases"] = list(report.get("cases", [])) + [ + {"name": name, "status": "NOT RUN"} for name in report.get("required_cases", []) + if name not in recorded] + _, comparisons, _ = numerical_summary(display) + folded.append(details(display, "Cases, diagnostics, invariants and failures")) + if comparisons: + lines.append("\n" + compact_metric_table(comparisons) + "\n\n") + lines.append("Metrics summarize final outputs only (worst MaxAbs / relative L2, lowest cosine across recorded comparisons); checkpoints still affect the original gate. Missing metrics remain explicit.\n\n") + if report.get("error"): + lines.append("Issue: " + cell(report["error"], 300) + "\n\n") + lines.extend(folded) + except (TypeError, KeyError, ValueError, AttributeError, RecursionError): + # Unexpected report schemas must not make an apparently passing page. + return f"## {title}\n\n**INVALID REPORT** — could not render numerical evidence; step: {cell(outcome)}. No acceptance claimed.\n\n" + evidence + lines.append(evidence) + rendered = "".join(lines) + if len(rendered.encode("utf-8")) > MAX_SUMMARY_BYTES: + # Do not slice Markdown or HTML: a metric's free-form reason can be + # enormous even when report structure and all numeric values are valid. + # Retain the authoritative, already bounded status (including parent + # validation failures) and replace the entire detail body atomically. + return ("".join(lines[:2]) + + "Numerical details exceed the 64 KiB Actions summary budget and are omitted here. " + "The recorded gate result above is unchanged; inspect the complete artifact for metrics and scope.\n\n" + evidence) + return rendered + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--kind", choices=("preparation", "full"), required=True) + parser.add_argument("--report-dir", type=Path, required=True) + parser.add_argument("--outcome", default=os.environ.get("PROBE_OUTCOME", "")) + parser.add_argument("--summary", type=Path, default=os.environ.get("GITHUB_STEP_SUMMARY")) + args = parser.parse_args(argv) + text = render(args.kind, args.report_dir, args.outcome) + if args.summary: + with args.summary.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(text) + else: + print(text, end="") + return 0 # Reporting never substitutes for or changes the probe's exit code. + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_w3_evidence.py b/scripts/verify_w3_evidence.py new file mode 100644 index 0000000..e4bb4fd --- /dev/null +++ b/scripts/verify_w3_evidence.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Recheck saved W3 full-model evidence without executing either model backend.""" +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import re +import sys +import time + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np + +from probes.w1_qwen3_export.run import MANIFEST, verify_files +from probes.w3_common import new_output_dir, sha256_file, source_evidence, write_reports +from probes.w3_layer_diff.run import load_arrays +from probes.w3_qwen3_full import run as full +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases + +MAX_JSON_BYTES = 8 * 1024**2 +HASH = re.compile(r"[0-9a-f]{64}\Z") +OBSERVATIONAL_FIELDS = frozenset(("relative_l2", "cosine_similarity", + "relative_l2_reason", "cosine_similarity_reason")) +SCOPE = ("Offline consistency and numerical recheck of saved seven-case arrays. " + "No model execution, independent-person reproduction, current-source execution, " + "Linux execution, Nightly run, or team acceptance is established. " + "Hashes establish consistency, not producer authenticity; retain a trusted report hash.") + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def _unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, f"Duplicate JSON key: {key}") + result[key] = value + return result + + +def read_json(path): + require(path.is_file() and path.stat().st_size <= MAX_JSON_BYTES, + f"Missing or oversized JSON: {path.name}") + def reject(value): + raise ValueError(f"Nonfinite JSON constant: {value}") + def finite_float(value): + number = float(value) + require(math.isfinite(number), f"Nonfinite JSON number: {value}") + return number + value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique, + parse_constant=reject, parse_float=finite_float) + require(isinstance(value, dict), f"JSON object required: {path.name}") + return value + + +def safe_file(root, relative): + path = root / relative + require(path.is_file() and path.resolve().is_relative_to(root.resolve()), + f"Missing/external evidence file: {relative}") + return path + + +def source_difference(recorded, current): + require(isinstance(recorded, dict) and bool(recorded), "Missing producer source hashes") + for name, digest in recorded.items(): + require(isinstance(name, str) and name and "\\" not in name and ":" not in name + and not name.startswith("/") and all(p not in ("", ".", "..") for p in name.split("/")) + and isinstance(digest, str) and HASH.fullmatch(digest), "Invalid producer source hash") + return {"matches_current": recorded == current, + "changed": [{"path": name, "recorded_sha256": recorded[name], "current_sha256": current[name]} + for name in sorted(recorded.keys() & current.keys()) if recorded[name] != current[name]], + "added_since_recording": sorted(current.keys() - recorded.keys()), + "missing_from_current": sorted(recorded.keys() - current.keys()), + "scope": "Source mismatch is reported, not silently treated as current-source execution. " + "This audit recomputes stored-array comparisons only."} + + +def comparison_matches_saved(recomputed, saved): + """Permit only the absent four-field observation group in legacy rows. + + Old reports predate these descriptive metrics. Existing fields and any + recorded metrics must still match the arrays; a partial group is invalid. + This does not relax profiles, source evidence, thresholds or PASS checks. + """ + if isinstance(recomputed, dict): + if not isinstance(saved, dict): + return False + wanted = set(recomputed) + observed = OBSERVATIONAL_FIELDS & saved.keys() + if OBSERVATIONAL_FIELDS <= wanted: + if not observed: + wanted -= OBSERVATIONAL_FIELDS + elif observed != OBSERVATIONAL_FIELDS: + return False + else: + # JSON booleans must not impersonate numeric 0/1 observations. + if any(type(saved[key]) is not type(recomputed[key]) + for key in OBSERVATIONAL_FIELDS): + return False + if set(saved) != wanted: + return False + return all(comparison_matches_saved(recomputed[key], saved[key]) for key in wanted) + if isinstance(recomputed, list): + return (isinstance(saved, list) and len(recomputed) == len(saved) + and all(comparison_matches_saved(a, b) for a, b in zip(recomputed, saved))) + return recomputed == saved + + +def verify(directory, *, model_dir=None, expected_report_sha256=None, progress=None): + """Reuse the runner's numerical validators; never call its worker launcher.""" + directory = Path(directory).resolve() + top_path = safe_file(directory, "report.json") + digest = sha256_file(top_path) + if expected_report_sha256 is not None: + require(isinstance(expected_report_sha256, str) and HASH.fullmatch(expected_report_sha256), + "Expected report SHA-256 must be 64 lowercase hexadecimal characters") + require(digest == expected_report_sha256, "Trusted report SHA-256 differs") + top = read_json(top_path) + require(top.get("gate") == "numeric:ir-full-qwen3" and top.get("stage") == "complete", + "Wrong/incomplete full-model report") + require(top.get("passed") is True and top.get("status") == "PASS" + and top.get("coverage_complete") is True and top.get("selected_numerical_passed") is True + and top.get("full_ir_executed") is True and top.get("w3_exit_accepted") is False, + "Producer report is not a complete passing numerical run") + require(top.get("optimization_level") == "none" and top.get("atol") == full.ATOL + and top.get("rtol") == 0 and top.get("fp32_mode") in ("native", "reference"), + "Wrong numerical contract") + require(top.get("required_cases") == list(CASE_NAMES) and top.get("selected_cases") == list(CASE_NAMES), + "Seven-case coverage required") + rows = top.get("cases") + require(isinstance(rows, list) and all(isinstance(row, dict) for row in rows) + and [row.get("name") for row in rows] == list(CASE_NAMES), "Missing/duplicate/reordered cases") + require(top.get("files") == MANIFEST["files"], "Producer assets differ from pinned manifest") + current = source_evidence() + differences = source_difference(top.get("source_sha256"), current["source_sha256"]) + if model_dir is not None: + require(verify_files(model_dir) == top["files"], "Local model assets differ") + + verified, profiles = [], [] + for row, (name, valid, wanted) in zip(rows, input_cases()): + require(row.get("name") == name and type(row.get("valid_length")) is int + and row["valid_length"] == valid and row.get("passed") is True, "Wrong case contract") + folder = directory / name + input_path = safe_file(directory, f"{name}/inputs.npz") + require(row.get("input_sha256") == sha256_file(input_path), f"Altered input: {name}") + schema = [{"name": key, "shape": list(value.shape), "dtype": str(value.dtype)} + for key, value in wanted.items()] + feed = load_arrays(input_path, schema, max_bytes=1024**2) + require(all(np.array_equal(feed[key], value) for key, value in wanted.items()), + f"Saved inputs differ from deterministic case: {name}") + workers = row.get("workers") + require(isinstance(workers, list) and all(isinstance(w, dict) for w in workers) + and [w.get("backend") for w in workers] == ["ort", "ir"], "Missing/duplicate case workers") + for saved in workers: + backend = saved["backend"] + report_path = safe_file(directory, f"{name}/{backend}/report.json") + require(saved.get("passed") is True and type(saved.get("returncode")) is int + and saved["returncode"] == 0 and saved.get("report_sha256") == sha256_file(report_path), + f"Invalid/altered worker report: {name}/{backend}") + report = read_json(report_path) + # Preflight strict JSON and containment before invoking the shared reader. + for filename in ("report.md", "report.html", "logits.npy", "diagnostic_logits.npy", + "checkpoints.npz", "checkpoint_schema.json"): + safe_file(directory, f"{name}/{backend}/{filename}") + read_json(folder / backend / "checkpoint_schema.json") + saved_profile = (full.saved_arithmetic_profile(top["fp32_mode"], + report.get("ir", {}).get("fp32_profile")) if backend == "ir" else None) + evidence = full.validate_worker(folder / backend, backend, input_path, + top["source_sha256"], top["files"], + expected_fp32_mode=top["fp32_mode"], + expected_fp32_profile=saved_profile) + require(evidence == report, "Worker report changed during audit") + require(evidence["input"].get("valid_length") == valid, "Wrong worker valid length") + if backend == "ir": + profiles.append(evidence["ir"]["fp32_profile"]) + comparison = full.compare_case(folder, valid) + require(comparison["passed"], f"Recomputed logits fail threshold: {name}") + require(comparison_matches_saved(comparison, row.get("comparison")), + f"Stored comparison differs from arrays: {name}") + verified.append({"name": name, "passed": True, "valid_length": valid, + "input_sha256": row["input_sha256"], "comparison": comparison}) + if progress: + progress(name) + require(len(profiles) == len(CASE_NAMES) and all(p == profiles[0] for p in profiles), + "Inconsistent numerical profiles") + invariants = full.invariants(directory, set(CASE_NAMES)) + require(len(invariants) == 4 and all(row["passed"] for row in invariants), "Recomputed invariants fail") + require(comparison_matches_saved(invariants, top.get("invariants")), "Stored invariants differ from arrays") + require(sha256_file(top_path) == digest, "Producer report changed during audit") + return {"gate": "audit:w3-full-saved-evidence", "schema_version": 1, "status": "PASS", "passed": True, + "scope": SCOPE, "model_executed": False, "independent_reproduction": False, + "w3_exit_accepted": False, "producer_report_sha256": digest, + "trusted_report_hash_checked": expected_report_sha256 is not None, + "pinned_model_files_checked": model_dir is not None, "producer_environment": top.get("environment"), + "producer_git": top.get("git"), "auditor": current, "source_comparison": differences, + "fp32_mode": top["fp32_mode"], "fp32_profile": profiles[0], "cases": verified, + "invariants": invariants, "coverage_complete": True, + "comparison_compatibility": { + "allowed_legacy_missing_fields": sorted(OBSERVATIONAL_FIELDS), + "scope": "Only a wholly absent observation group is accepted for legacy tensor rows. " + "All observations in this audit are recomputed from saved arrays, not a new model run."}, + "checkpoint_threshold_scope": "Checkpoints are finite FP32 diagnostics; the strict numerical gate " + "applies to every ordinary/diagnostic output logit, including padding."} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--model-dir", type=Path, help="Optionally verify local pinned model file hashes; never execute it") + parser.add_argument("--expected-report-sha256", help="Optional independently retained producer report digest") + args = parser.parse_args(argv) + out = new_output_dir(args.output_dir) + start = time.perf_counter() + result = {"gate": "audit:w3-full-saved-evidence", "schema_version": 1, "passed": False, + "status": "FAIL", "scope": SCOPE, "model_executed": False, + "independent_reproduction": False, "w3_exit_accepted": False} + try: + result = verify(args.evidence_dir, model_dir=args.model_dir, + expected_report_sha256=args.expected_report_sha256, + progress=lambda name: print(f"[{name}] saved arrays verified", flush=True)) + except Exception as exc: + result["error"] = f"{type(exc).__name__}: {exc}" + result["elapsed_seconds"] = time.perf_counter() - start + write_reports(out, result) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py index 4c38853..d601f74 100644 --- a/tests/test_ci_workflows.py +++ b/tests/test_ci_workflows.py @@ -74,7 +74,7 @@ def test_the_w1_w2_job_writes_no_step_summary(): data = workflow('llm-deploy.yml') writers = {name for name, job in data['jobs'].items() for step in job.get('steps', []) if 'GITHUB_STEP_SUMMARY' in step.get('run', '')} - assert writers == {'full-qwen3-onnx'} + assert writers == {'full-qwen3-onnx', 'w3-execution-summary'} def test_linux_reproduction_check_runs_without_a_path_filter(): diff --git a/tests/test_ir_interpreter_fp32_eigen.py b/tests/test_ir_interpreter_fp32_eigen.py new file mode 100644 index 0000000..ab671f1 --- /dev/null +++ b/tests/test_ir_interpreter_fp32_eigen.py @@ -0,0 +1,134 @@ +"""Independent numerical and shape checks for deterministic FP32 kernels.""" + +import numpy as np +import pytest + +from scratchv.verification.fp32_eigen import mean_last, trig + + +@pytest.mark.parametrize("width", [1, 2, 3, 4, 5, 7, 8, 9, 12, 15, 16, 17, 31, 32, 127, 128, 255, 1024, 3072]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_accuracy_including_packet_prefixes_and_tails(width, keepdims): + rng = np.random.default_rng(2201 + width) + values = rng.uniform(-3, 5, (2, 7, width)).astype(np.float32) + actual = mean_last(values, keepdims=keepdims) + reference = np.mean(values.astype(np.float64), axis=-1, keepdims=keepdims) + assert actual.dtype == np.float32 + assert actual.shape == reference.shape + np.testing.assert_allclose(actual, reference, atol=6e-7, rtol=6e-7) + + +@pytest.mark.parametrize("width", [1, 3, 5, 8, 17]) +def test_mean_scalar_result_and_empty_leading_dimensions(width): + values = np.arange(width, dtype=np.float32) + actual = mean_last(values, keepdims=False) + assert isinstance(actual, np.ndarray) + assert actual.shape == () + assert actual == np.float32((width - 1) / 2) + assert mean_last(values).shape == (1,) + for leading in [(0,), (2, 0)]: + empty = np.empty(leading + (width,), dtype=np.float32) + assert mean_last(empty).shape == leading + (1,) + assert mean_last(empty, keepdims=False).shape == leading + + +@pytest.mark.parametrize("shape", [(), (0,), (2, 0)]) +def test_mean_rejects_missing_or_empty_reduction_dimension(shape): + with pytest.raises(ValueError, match="nonempty last dimension"): + mean_last(np.empty(shape, dtype=np.float32)) + + +@pytest.mark.parametrize("width", [3, 5, 8, 17]) +def test_mean_does_not_depend_on_input_alignment_or_strides(width): + backing = np.arange(1 + 6 * width * 2, dtype=np.float32) + values = backing[1:].reshape(6, width * 2)[:, ::-2] + assert not values.flags.c_contiguous + values.setflags(write=False) + original = values.copy() + actual = mean_last(values) + np.testing.assert_array_equal(actual, mean_last(original)) + np.testing.assert_allclose(actual, np.mean(original.astype(np.float64), axis=-1, keepdims=True)) + np.testing.assert_array_equal(values, original) + assert not np.shares_memory(actual, values) + + +@pytest.mark.parametrize("width", [1, 3, 4, 5, 17]) +def test_mean_preserves_negative_zero(width): + actual = mean_last(np.full((4, width), -0.0, dtype=np.float32)) + assert np.all(actual == 0) + assert np.all(np.signbit(actual)) + + +@pytest.mark.parametrize("sine", [True, False]) +@pytest.mark.parametrize("bound", [1e-5, 255, 17999]) +def test_trig_accuracy_against_independent_double_reference(sine, bound): + values = np.random.default_rng(4101).uniform(-bound, bound, 32768).astype(np.float32) + function = np.sin if sine else np.cos + reference = function(values.astype(np.float64)) + actual = trig(values, sine=sine) + assert actual.dtype == np.float32 + np.testing.assert_allclose(actual, reference, rtol=0, atol=1.25e-7) + assert np.all(np.abs(actual) <= np.float32(1)) + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_scalar_empty_and_signed_zero(sine): + for scalar in [np.float32(0), np.float32(-0.0), np.float32(1)]: + actual = trig(scalar, sine=sine) + assert isinstance(actual, np.ndarray) + assert actual.shape == () + assert actual.dtype == np.float32 + zeros = trig(np.array([0.0, -0.0], dtype=np.float32), sine=sine) + np.testing.assert_array_equal(zeros, [0.0, 0.0] if sine else [1.0, 1.0]) + np.testing.assert_array_equal(np.signbit(zeros), [False, True] if sine else [False, False]) + for shape in [(0,), (2, 0), (0, 3)]: + actual = trig(np.empty(shape, dtype=np.float32), sine=sine) + assert actual.shape == shape + assert actual.dtype == np.float32 + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_readonly_strided_input_is_not_mutated(sine): + values = np.linspace(-100, 100, 187, dtype=np.float32).reshape(11, 17).T[:, ::-2] + assert not values.flags.c_contiguous + values.setflags(write=False) + original = values.copy() + actual = trig(values, sine=sine) + np.testing.assert_array_equal(actual, trig(original, sine=sine)) + np.testing.assert_array_equal(values, original) + assert not np.shares_memory(actual, values) + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_interval_boundary_and_numpy_fallback(sine): + edge = np.float32(18000) + interior = np.nextafter(edge, np.float32(0)) + values = np.array( + [-np.finfo(np.float32).max, -1e20, -edge, -interior, interior, edge, 1e20, + np.finfo(np.float32).max, np.inf, -np.inf, np.nan], dtype=np.float32 + ) + function = np.sin if sine else np.cos + with np.errstate(invalid="ignore"): + actual = trig(values, sine=sine) + fallback_reference = function(values) + exact_reference = function(values.astype(np.float64)) + fallback = ~np.isfinite(values) | (np.abs(values) >= edge) + np.testing.assert_array_equal(actual[fallback], fallback_reference[fallback]) + np.testing.assert_allclose(actual[~fallback], exact_reference[~fallback], rtol=0, atol=1.25e-7) + + +@pytest.mark.parametrize("dtype", [np.float64, np.float16, np.int32, np.int64, np.bool_]) +def test_fp32_functions_reject_implicit_precision_conversion(dtype): + values = np.ones(4, dtype=dtype) + with pytest.raises(TypeError, match="float32"): + mean_last(values) + with pytest.raises(TypeError, match="float32"): + trig(values, sine=True) + + +def test_boolean_parameters_reject_ambiguous_values(): + values = np.ones(4, dtype=np.float32) + with pytest.raises(TypeError, match="keepdims"): + mean_last(values, keepdims=1) + with pytest.raises(TypeError, match="sine"): + trig(values, sine="sin") diff --git a/tests/test_ir_interpreter_fp32_fma.py b/tests/test_ir_interpreter_fp32_fma.py new file mode 100644 index 0000000..86048ce --- /dev/null +++ b/tests/test_ir_interpreter_fp32_fma.py @@ -0,0 +1,154 @@ +"""Portable bit-exact FMA checks against a Python arbitrary-integer oracle.""" +import itertools + +import numpy as np +import pytest + +from scratchv.verification.fp32_fma import fma + + +def exact_fma_bits(a_bits, b_bits, c_bits): + """Independent exact dyadic arithmetic, no host floating-point reference.""" + def decode(bits): + sign = bits >> 31 + exponent = (bits >> 23) & 255 + fraction = bits & 0x7fffff + assert exponent != 255 + return sign, fraction | ((1 << 23) if exponent else 0), exponent - 150 if exponent else -149 + + sa, a, ea = decode(int(a_bits)) + sb, b, eb = decode(int(b_bits)) + sc, c, ec = decode(int(c_bits)) + ep, sp = ea + eb, sa ^ sb + base = min(ep, ec) + exact = ((-1 if sp else 1) * ((a * b) << (ep - base)) + + (-1 if sc else 1) * (c << (ec - base))) + if exact == 0: + return (sp & sc) << 31 + sign, magnitude = int(exact < 0), abs(exact) + quantum = max(magnitude.bit_length() - 1 + base - 23, -149) + shift = quantum - base + if shift < 0: + quotient = magnitude << -shift + else: + quotient, remainder = divmod(magnitude, 1 << shift) + midpoint_twice = 1 << shift + if 2 * remainder > midpoint_twice or (2 * remainder == midpoint_twice and quotient & 1): + quotient += 1 + if quotient == 1 << 24: + quotient >>= 1 + quantum += 1 + exponent = quantum + 150 if quotient >= 1 << 23 else 0 + if exponent >= 255: + raise OverflowError("exact result rounds to infinity") + return sign << 31 | exponent << 23 | (quotient & 0x7fffff) + + +def check_bits(rows): + finite, expected, overflowing = [], [], [] + for row in rows: + try: + answer = exact_fma_bits(*row) + except OverflowError: + overflowing.append(row) + else: + finite.append(row) + expected.append(answer) + if finite: + data = np.asarray(finite, dtype=np.uint32).view(np.float32) + actual = fma(*data.T) + np.testing.assert_array_equal(actual.view(np.uint32), np.asarray(expected, dtype=np.uint32)) + if overflowing: + data = np.asarray(overflowing, dtype=np.uint32).view(np.float32) + with pytest.raises(FloatingPointError, match="overflows"): + fma(*data.T) + + +def test_float32_fma_boundaries_signed_zero_subnormal_cancellation_and_overflow(): + bits = [0, 0x80000000, 1, 0x80000001, 0x007fffff, 0x807fffff, + 0x00800000, 0x80800000, 0x3f000000, 0xbf000000, 0x3f800000, + 0xbf800000, 0x3f800001, 0x3f7fffff, 0x7f7fffff, 0xff7fffff] + check_bits(itertools.product(bits, repeat=3)) + + +def test_compensated_double_rounding_midpoint_uses_exact_result(): + values = np.array([0x33800001, 0x3f7fffff, 0x3f800000], dtype=np.uint32).view(np.float32) + result = fma(*values) + assert result.view(np.uint32) == 0x3f800001 + assert exact_fma_bits(*values.view(np.uint32)) == 0x3f800001 + + +@pytest.mark.parametrize("bounded", [False, True]) +def test_random_finite_inputs_against_exact_integer_reference(bounded): + rng = np.random.default_rng(20261005) + rows = rng.integers(0, 2**32, size=(10_000, 3), dtype=np.uint32) + if bounded: + rows &= np.uint32(0x807fffff) + rows |= rng.integers(86, 169, size=rows.shape, dtype=np.uint32) << np.uint32(23) + rows = rows[np.all(((rows >> 23) & 255) != 255, axis=1)] + check_bits(rows) + + +def test_range_endpoints_and_adjacent_values_against_exact_reference(): + values = [np.float32(2**-40), np.float32(2**40)] + choices = [item for value in values for item in ( + np.nextafter(value, np.float32(0)), value, np.nextafter(value, np.float32(np.inf)))] + choices += [-value for value in choices] + bits = np.asarray(choices, dtype=np.float32).view(np.uint32) + check_bits(itertools.product(bits, repeat=3)) + + +def test_product_cancellation_and_adjacent_addends_against_exact_reference(): + # Uniform random triples rarely almost cancel. Place c at the negated + # rounded product and its immediate neighbors to exercise that boundary. + rng = np.random.default_rng(6301) + operands = rng.integers(0, 2**32, size=(4096, 2), dtype=np.uint32) + operands &= np.uint32(0x807fffff) + operands |= rng.integers(100, 151, size=operands.shape, + dtype=np.uint32) << np.uint32(23) + floats = operands.view(np.float32) + canceled = (-(floats[:, 0] * floats[:, 1])).view(np.uint32) + rows = [np.column_stack((operands, canceled + offset)) + for offset in (np.uint32(0), np.uint32(1))] + rows.append(np.column_stack((operands, canceled - np.uint32(1)))) + check_bits(np.concatenate(rows)) + + +def test_mixed_fast_and_integer_fallback_and_input_immutability(monkeypatch): + import scratchv.verification.fp32_fma as module + + original, observed = module._integer_fma, [] + def capture(a, b, c): + observed.append(len(a)) + return original(a, b, c) + monkeypatch.setattr(module, "_integer_fma", capture) + values = np.array([[0.75, 0.25, 0.125], [0, 1, 1], [2, 0.5, 1], + [0.5, 0.5, -0.25]], dtype=np.float32) + before = values.copy() + values.setflags(write=False) + result = fma(*values.T) + expected = [exact_fma_bits(*row) for row in values.view(np.uint32)] + np.testing.assert_array_equal(result.view(np.uint32), expected) + np.testing.assert_array_equal(values, before) + assert observed == [3] + + +def test_broadcast_scalar_and_empty_shapes(): + result = fma(np.array([[1], [-1]], dtype=np.float32), + np.array([[2, 3, 4]], dtype=np.float32), np.float32(1)) + np.testing.assert_array_equal(result, [[3, 4, 5], [-1, -2, -3]]) + assert result.dtype == np.float32 + assert fma(np.float32(1), np.float32(2), np.float32(3)).shape == () + assert fma(np.empty((0, 3), dtype=np.float32), np.float32(1), np.float32(2)).shape == (0, 3) + + +@pytest.mark.parametrize("bad", [np.float32(np.nan), np.float32(np.inf), np.float32(-np.inf)]) +def test_nonfinite_inputs_are_rejected(bad): + with pytest.raises(ValueError, match="finite float32"): + fma(np.float32(1), bad, np.float32(1)) + + +@pytest.mark.parametrize("bad", [1.0, 1, np.float64(1), np.array([1], dtype=">f4")]) +def test_precision_promotion_is_rejected(bad): + with pytest.raises(TypeError, match="float32"): + fma(bad, np.float32(1), np.float32(1)) diff --git a/tests/test_ir_interpreter_fp32_reference.py b/tests/test_ir_interpreter_fp32_reference.py new file mode 100644 index 0000000..f499da7 --- /dev/null +++ b/tests/test_ir_interpreter_fp32_reference.py @@ -0,0 +1,538 @@ +"""The opt-in FP32 profile preserves IR semantics and per-run isolation.""" + +import subprocess +import sys +import os +from pathlib import Path + +import numpy as np +import pytest + +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as D, Instruction, OpCode as O, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +DTYPES = {np.dtype("float32"): D.FLOAT32, np.dtype("float64"): D.FLOAT64, + np.dtype("int32"): D.INT32, np.dtype("int64"): D.INT64} + + +def operation(op, arrays, attrs=None): + params = [Value(f"x{i}", DTYPES[x.dtype], shape=x.shape) + for i, x in enumerate(arrays)] + builder = IRBuilder() + builder.new_function("main", params) + builder.new_block() + dest = Value("result", params[0].dtype) + builder.current_block.add(Instruction(op, dest, params, attrs or {})) + builder.ret(dest) + return builder.program, dict(zip((p.name for p in params), arrays)) + + +def execute(op, arrays, attrs=None, **options): + program, inputs = operation(op, arrays, attrs) + return IRInterpreter(program).run(inputs, **options).return_value + + +@pytest.mark.parametrize("mode", [None, [], True, "", "Reference", "automatic"]) +def test_unknown_profile_is_an_explicit_option_error(mode): + program, inputs = operation(O.SIGMOID, [np.array([0], "float32")]) + with pytest.raises(IRExecutionError, match="fp32_mode") as caught: + IRInterpreter(program).run(inputs, fp32_mode=mode) + assert caught.value.code == "InvalidOptions" + + +@pytest.mark.parametrize("axis", [0, 1, 2, -1, -2, -3]) +def test_softmax_normalizes_the_requested_axis(axis): + x = np.linspace(-4, 3, 30, dtype="float32").reshape(2, 3, 5) + actual = execute(O.SOFTMAX, [x], {"axis": axis}, fp32_mode="reference") + wide = x.astype(np.float64) + weights = np.exp(wide - np.max(wide, axis=axis, keepdims=True)) + expected = weights / np.sum(weights, axis=axis, keepdims=True) + assert actual.shape == x.shape and actual.dtype == x.dtype + np.testing.assert_allclose(actual, expected, rtol=3e-7, atol=3e-8) + np.testing.assert_allclose(actual.sum(axis=axis), 1, rtol=0, atol=2e-7) + + +def test_softmax_finite_extremes_allow_only_negative_overflow_in_shift(): + maximum = np.finfo(np.float32).max + x = np.array([[-maximum, maximum], [maximum, maximum], + [-maximum, -maximum]], "float32") + actual = execute(O.SOFTMAX, [x], {"axis": 1}, fp32_mode="reference") + np.testing.assert_array_equal(actual, [[0, 1], [0.5, 0.5], [0.5, 0.5]]) + + +def test_softmax_partial_negative_infinity_mask_is_preserved(): + x = np.array([[0, -np.inf, 0], [-np.inf, 0, -np.inf]], "float32") + actual = execute(O.SOFTMAX, [x], fp32_mode="reference") + np.testing.assert_array_equal(actual, [[0.5, 0, 0.5], [0, 1, 0]]) + + +@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf]) +def test_softmax_invalid_or_entirely_masked_rows_still_fail(bad): + x = np.full((2, 3), bad, "float32") + with pytest.raises(IRExecutionError): + execute(O.SOFTMAX, [x], fp32_mode="reference") + + +@pytest.mark.parametrize("axis", [3, -4]) +def test_softmax_invalid_axis_is_not_silently_reinterpreted(axis): + with pytest.raises(IRExecutionError, match="ShapeError"): + execute(O.SOFTMAX, [np.ones((2, 3, 4), "float32")], + {"axis": axis}, fp32_mode="reference") + + +@pytest.mark.parametrize("shape,axis", [ + ((0, 7), 1), ((5, 0), 0), ((3, 0, 129), 2), ((3, 129, 0), 1), +]) +def test_softmax_preserves_empty_dimensions_outside_normalization_axis(shape, axis): + actual = execute(O.SOFTMAX, [np.empty(shape, dtype="float32")], + {"axis": axis}, fp32_mode="reference") + assert actual.shape == shape and actual.dtype == np.float32 + + +@pytest.mark.parametrize("left,right", [ + ((3,), (3,)), ((3,), (3, 4)), ((2, 3), (3,)), + ((2, 1, 3, 4), (1, 5, 4, 2)), + ((3, 257), (257, 7)), ((2, 1025), (1025, 3)), + ((257,), (257,)), ((257,), (2, 257, 7)), ((2, 3, 257), (257,)), + ((2, 1, 3, 257), (1, 5, 257, 2)), +]) +def test_matmul_vector_batch_and_reduction_tail_semantics(left, right): + # Integer-valued inputs give an exact, independent integer arithmetic oracle. + rng = np.random.default_rng(1221) + a = rng.integers(-3, 4, left, dtype=np.int64) + b = rng.integers(-3, 4, right, dtype=np.int64) + expected = a @ b + actual = execute(O.MATMUL, [a.astype("float32"), b.astype("float32")], + fp32_mode="reference") + np.testing.assert_array_equal(actual, expected) + assert actual.shape == expected.shape and actual.dtype == np.float32 + + +@pytest.mark.parametrize("left,right,shape", [ + ((2, 0), (0, 3), (2, 3)), ((0,), (0,), ()), + ((2, 0), (0,), (2,)), ((0,), (0, 3), (3,)), + ((0, 4), (4, 3), (0, 3)), ((2, 4), (4, 0), (2, 0)), + ((0, 129), (129, 3), (0, 3)), ((2, 129), (129, 0), (2, 0)), + ((0, 2, 129), (1, 129, 3), (0, 2, 3)), +]) +def test_matmul_empty_dimensions_keep_numpy_shapes(left, right, shape): + actual = execute(O.MATMUL, [np.zeros(left, "float32"), + np.zeros(right, "float32")], + fp32_mode="reference") + np.testing.assert_array_equal(actual, np.zeros(shape, "float32")) + + +def test_matmul_legacy_flat_shapes_are_still_honored(): + a = np.arange(6, dtype="float32") + b = np.arange(12, dtype="float32") + result = execute(O.MATMUL, [a, b], {"m": 2, "n": 4, "k": 3}, + fp32_mode="reference") + np.testing.assert_array_equal(result, [[20, 23, 26, 29], [56, 68, 80, 92]]) + + +def test_matmul_consumes_an_ir_transpose_view_without_mutation(): + a = np.arange(18, dtype="float32").reshape(6, 3) + b = np.arange(24, dtype="float32").reshape(6, 4) + x, w = Value("x", shape=a.shape), Value("w", shape=b.shape) + builder = IRBuilder() + builder.new_function("main", [x, w]) + builder.new_block() + transposed = builder.transpose(x, (1, 0)) + builder.ret(builder.matmul(transposed, w)) + observed = [] + + def observe(name, value): + if name == transposed.name: + observed.append(value.flags.c_contiguous) + + actual = IRInterpreter(builder.program).run( + {"x": a, "w": b}, fp32_mode="reference", observer=observe).return_value + assert observed == [False] + np.testing.assert_array_equal(actual, a.astype(np.int64).T @ b.astype(np.int64)) + np.testing.assert_array_equal(a, np.arange(18).reshape(6, 3)) + np.testing.assert_array_equal(b, np.arange(24).reshape(6, 4)) + + +def test_matmul_random_fp32_remains_accurate_beyond_one_reduction_block(): + rng = np.random.default_rng(128) + a = rng.normal(size=(2, 3, 257)).astype("float32") + b = rng.normal(size=(257, 129)).astype("float32") + actual = execute(O.MATMUL, [a, b], fp32_mode="reference") + expected = a.astype(np.float64) @ b.astype(np.float64) + np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5) + + +@pytest.mark.parametrize("length", [1, 3, 7, 8, 9, 15, 17, 127, 129, 1025]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_last_axis_tails_and_keepdims(length, keepdims): + x = np.arange(2 * length, dtype="float32").reshape(2, length) / np.float32(8) + actual = execute(O.REDUCE_MEAN, [x], + {"axes": (-1,), "keepdims": keepdims}, fp32_mode="reference") + expected = np.mean(x.astype(np.float64), axis=-1, keepdims=keepdims) + assert actual.shape == expected.shape and actual.dtype == x.dtype + np.testing.assert_allclose(actual, expected, rtol=2e-7, atol=1e-7) + + +@pytest.mark.parametrize("axes", [None, (), (0,), (1,), (0, 2), (-1, 0)]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_multiple_axes_and_empty_axes_contract(axes, keepdims): + x = np.arange(30, dtype="float32").reshape(2, 3, 5) + attrs = {"keepdims": keepdims} + if axes is not None: + attrs["axes"] = axes + expected = np.mean(x.astype(np.float64), axis=axes or None, keepdims=keepdims) + actual = execute(O.REDUCE_MEAN, [x], attrs, fp32_mode="reference") + np.testing.assert_allclose(actual, expected, rtol=2e-7, atol=1e-7) + assert actual.shape == expected.shape and actual.dtype == x.dtype + + +def test_mean_scalar_and_empty_dimension_semantics(): + scalar = np.array(7.25, "float32") + actual = execute(O.REDUCE_MEAN, [scalar], fp32_mode="reference") + assert actual.shape == () and actual.dtype == scalar.dtype and actual == scalar + actual = execute(O.REDUCE_MEAN, [np.empty((0, 3), "float32")], + {"axes": (-1,)}, fp32_mode="reference") + assert actual.shape == (0, 1) + with pytest.raises(IRExecutionError, match="NumericError"): + execute(O.REDUCE_MEAN, [np.empty((2, 0), "float32")], + {"axes": (-1,)}, fp32_mode="reference") + + +def test_sigmoid_remains_bounded_monotone_and_close_to_mathematics(): + maximum = np.finfo(np.float32).max + x = np.concatenate([np.array([-maximum], "float32"), + np.linspace(-30, 30, 1001, dtype="float32"), + np.array([maximum], "float32")]) + actual = execute(O.SIGMOID, [x], fp32_mode="reference") + exp = np.exp(-np.abs(x.astype(np.float64))) + expected = np.where(x >= 0, 1 / (1 + exp), exp / (1 + exp)) + assert np.isfinite(actual).all() and np.all((actual >= 0) & (actual <= 1)) + # Approximation roundoff can jitter saturated tails by a few ULP. + assert np.all(np.diff(actual) >= -np.float32(2e-7)) + np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-7) + assert execute(O.SIGMOID, [np.array(0, "float32")], fp32_mode="reference") == 0.5 + + +def test_sigmoid_saturated_roundoff_never_leaves_probability_range(): + # This neighborhood includes a rational-approximation result above one. + # Probe both signs and adjacent binary32 values against sigmoid itself. + center = np.float32(17.868923).view(np.uint32) + positive = np.arange(center - 16, center + 17, dtype=np.uint32).view(np.float32) + x = np.concatenate((-positive[::-1], positive)) + actual = execute(O.SIGMOID, [x], fp32_mode="reference") + expected = 1 / (1 + np.exp(-x.astype(np.float64))) + assert np.all((actual >= 0) & (actual <= 1)) + np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-7) + + +@pytest.mark.parametrize("opcode", [O.MATMUL, O.REDUCE_MEAN, O.SOFTMAX, + O.SIGMOID, O.SIN, O.COS]) +def test_float64_results_are_exactly_the_existing_native_results(opcode): + x = np.linspace(-2, 2, 15, dtype="float64").reshape(3, 5) + arrays = [x, x.T.copy()] if opcode is O.MATMUL else [x] + expected = execute(opcode, arrays) + result = execute(opcode, arrays, fp32_mode="reference") + np.testing.assert_array_equal(result, expected) + assert result.dtype == np.float64 + + +@pytest.mark.parametrize("dtype", ["int32", "int64"]) +def test_integer_matmul_preserves_native_wrap_and_dtype(dtype): + x = np.array([[np.iinfo(dtype).max, 2], [3, -4]], dtype) + y = np.array([[2, -1], [4, 7]], dtype) + expected = execute(O.MATMUL, [x, y]) + actual = execute(O.MATMUL, [x, y], fp32_mode="reference") + np.testing.assert_array_equal(actual, expected) + assert actual.dtype == x.dtype + + +@pytest.mark.parametrize("opcode", [O.SIGMOID, O.REDUCE_MEAN, O.SOFTMAX, O.SIN, O.COS]) +def test_profile_does_not_enable_unsupported_integer_float_operations(opcode): + with pytest.raises(IRExecutionError) as native: + execute(opcode, [np.array([1, 2], "int64")]) + with pytest.raises(IRExecutionError) as reference: + execute(opcode, [np.array([1, 2], "int64")], fp32_mode="reference") + assert reference.value.code == native.value.code + + +@pytest.mark.parametrize("opcode", [O.MATMUL, O.REDUCE_MEAN, O.SOFTMAX, + O.SIGMOID, O.SIN, O.COS]) +def test_reference_profile_never_mutates_registry_or_another_run(opcode): + from scratchv.verification import ir_numpy_ops + + x = np.random.default_rng(48).normal(size=(2, 257)).astype("float32") + arrays = [x, x.T.copy()] if opcode is O.MATMUL else [x] + program, inputs = operation(opcode, arrays) + interpreter = IRInterpreter(program) + kernels = dict(ir_numpy_ops.KERNELS) + original_matmul = np.matmul + baseline = interpreter.run(inputs).return_value + explicit = interpreter.run(inputs, fp32_mode="native").return_value + np.testing.assert_array_equal(explicit, baseline) + observations = [] + + def observe(_name, _value): + assert ir_numpy_ops.KERNELS == kernels + assert np.matmul is original_matmul + nested = interpreter.run(inputs).return_value + np.testing.assert_array_equal(nested, baseline) + observations.append(True) + + interpreter.run(inputs, fp32_mode="reference", observer=observe) + assert observations == [True] + np.testing.assert_array_equal(interpreter.run(inputs).return_value, baseline) + assert ir_numpy_ops.KERNELS == kernels + + +def test_reference_profile_imports_and_executes_without_onnxruntime(): + # A clean interpreter catches eager imports as well as hidden runtime calls. + script = ''' +import sys +class BlockORT: + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] == "onnxruntime": + raise AssertionError("Reference IR must not import ONNX Runtime") +sys.meta_path.insert(0, BlockORT()) +import numpy as np +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.verification.ir_interpreter import IRInterpreter +x, w = Value("x", shape=(2,3)), Value("w", shape=(3,3)) +b = IRBuilder(); b.new_function("main", [x,w]); b.new_block() +v = b.matmul(x,w); v = b.sin(v); v = b.cos(v); v = b.sigmoid(v) +v = b.softmax(v, axis=1); v = b.reduce_mean(v, axes=(1,), keepdims=False); b.ret(v) +out = IRInterpreter(b.program).run( + {"x":np.arange(6,dtype="float32").reshape(2,3),"w":np.eye(3,dtype="float32")}, + fp32_mode="reference").return_value +np.testing.assert_allclose(out, [1/3,1/3], rtol=3e-7) +assert out.dtype == np.float32 +assert not any(name.split(".")[0] == "onnxruntime" for name in sys.modules) +''' + completed = subprocess.run([sys.executable, "-B", "-c", script], + cwd=Path(__file__).resolve().parents[1], + capture_output=True, text=True, timeout=30) + assert completed.returncode == 0, completed.stdout + completed.stderr + + +@pytest.mark.parametrize("avx512,strategy,lanes", [ + (False, "avx2-fma3", 8), (True, "avx512", 16), +]) +def test_auto_profile_records_the_resolved_cpu_arithmetic(monkeypatch, avx512, strategy, lanes): + from scratchv.verification import fp32_reference as reference + + monkeypatch.delenv("SCRATCHV_FP32_REFERENCE_CPU", raising=False) + monkeypatch.delenv("NPY_DISABLE_CPU_FEATURES", raising=False) + monkeypatch.setattr(reference, "_cpu_features", lambda: { + "AVX2": True, "FMA3": True, "AVX512F": avx512}) + actual = reference.profile() + assert actual["name"] == "numpy-fp32-reference-v4" + assert actual["matmul_row_tile"] == (2 if lanes == 8 else None) + assert actual["matmul_row_tail"] == ("zero-pad-to-two-then-trim" if lanes == 8 else "native") + assert actual["cpu_strategy"] == strategy and actual["softmax_lanes"] == lanes + assert actual == reference.profile(cpu_strategy=strategy) + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + assert reference.profile() == actual + + +@pytest.mark.parametrize("disabled", ["AVX2", "FMA3", "AVX512F", "AVX512_SKX", "AVX2, FMA3"]) +def test_auto_policy_rejects_cpu_features_hidden_only_from_numpy(monkeypatch, disabled): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + monkeypatch.setenv("NPY_DISABLE_CPU_FEATURES", disabled) + monkeypatch.setattr(reference, "_cpu_features", lambda: {"AVX2": True, "FMA3": True}) + with pytest.raises(ValueError, match="obscures MLAS"): + reference.profile() + # The explicit contract is reproducible on other CPUs and independent of + # NumPy's feature controls; it makes no claim to reconfigure ORT. + assert reference.profile(cpu_strategy="avx512")["softmax_lanes"] == 16 + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx2-fma3") + assert reference.profile()["softmax_lanes"] == 8 + + +@pytest.mark.parametrize("features", [{}, {"AVX2": True}, {"FMA3": True}, {"AVX512F": True}]) +def test_unknown_hardware_requires_an_explicit_policy(monkeypatch, features): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + monkeypatch.delenv("NPY_DISABLE_CPU_FEATURES", raising=False) + monkeypatch.setattr(reference, "_cpu_features", lambda: features) + with pytest.raises(ValueError, match="Cannot infer"): + reference.profile() + assert reference.profile(cpu_strategy="avx2-fma3")["softmax_lanes"] == 8 + + +@pytest.mark.parametrize("bad", ["", "AVX512", "sse", "automatic"]) +def test_invalid_cpu_policy_cannot_silently_select_another_kernel(monkeypatch, bad): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", bad) + with pytest.raises(ValueError, match="SCRATCHV_FP32_REFERENCE_CPU"): + reference.profile() + with pytest.raises(IRExecutionError, match="SCRATCHV_FP32_REFERENCE_CPU"): + execute(O.SOFTMAX, [np.array([1, 2], "float32")], fp32_mode="reference") + # The pre-existing native interpreter never consults reference policy. + np.testing.assert_allclose(execute(O.SOFTMAX, [np.array([1, 2], "float32")]), + [0.26894142, 0.73105858], rtol=2e-7) + + +@pytest.mark.parametrize("strategy", ["avx2-fma3", "avx512"]) +@pytest.mark.parametrize("width", [1, 7, 8, 9, 15, 16, 17, 23, 24, 47, 48, 255, 256]) +def test_both_cpu_softmax_policies_preserve_shapes_masks_and_accuracy(monkeypatch, strategy, width): + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + rng = np.random.default_rng(7600 + width) + x = rng.normal(size=(2, width, 3)).astype(np.float32) + if width > 1: + x[:, -1, :] = -np.inf + original = x.copy() + x.setflags(write=False) + actual = execute(O.SOFTMAX, [x], {"axis": 1}, fp32_mode="reference") + wide = x.astype(np.float64) + weights = np.exp(wide - np.max(wide, axis=1, keepdims=True)) + expected = weights / np.sum(weights, axis=1, keepdims=True) + np.testing.assert_allclose(actual, expected, rtol=4e-7, atol=3e-8) + np.testing.assert_array_equal(x, original) + assert actual.dtype == np.float32 and actual.shape == x.shape + if width > 1: + np.testing.assert_array_equal(actual[:, -1, :], 0) + + +def test_fma3_exponent_scaling_preserves_its_distinct_underflow_contract(monkeypatch): + # AVX2 MLAS packs m into binary32 exponent bits: m=-127 is zero. + # AVX512 VSCALEFPS retains representable subnormal results instead. + # This catches replacing the FMA3 scaling step with the AVX512 ldexp. + x = np.array([[0, -88, -90, -100, -104, -np.inf]], np.float32) + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx2-fma3") + avx2 = execute(O.SOFTMAX, [x], fp32_mode="reference") + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx512") + avx512 = execute(O.SOFTMAX, [x], fp32_mode="reference") + np.testing.assert_array_equal(avx2, [[1, 0, 0, 0, 0, 0]]) + assert np.all(avx512[0, 1:4] > 0) + assert np.all(avx512[0, 1:4] < np.finfo(np.float32).tiny) + np.testing.assert_array_equal(avx512[0, 4:], 0) + + +def test_host_softmax_reference_matches_ort1221_random_tail_and_mask_kernels(monkeypatch): + # The CPU matrix matters: CI's EPYC exercises FMA3, the Windows AVX512 host + # exercises VSCALEFPS. ORT is only the independent test oracle. + ort = pytest.importorskip("onnxruntime") + if ort.__version__ != "1.22.1": + pytest.skip("The explicit arithmetic contract targets ORT 1.22.1") + from onnx import helper, TensorProto + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + # Deliberately retain NPY_DISABLE_CPU_FEATURES: if present, automatic + # inference must fail rather than claim an unverified ORT dispatch. + reference.profile() + model = helper.make_model(helper.make_graph( + [helper.make_node("Softmax", ["x"], ["y"], axis=-1)], "softmax_cpu_arithmetic", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, None)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, None)]), + opset_imports=[helper.make_opsetid("", 17)]) + model.ir_version = 10 + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + session = ort.InferenceSession(model.SerializeToString(), sess_options=options, + providers=["CPUExecutionProvider"]) + rng = np.random.default_rng(20261005) + for width in (1, 3, 7, 8, 9, 15, 16, 17, 23, 24, 31, 32, 47, 48, 127, 128, 255, 256): + x = (rng.normal(size=(32, width)) * 8).astype(np.float32) + x[1::2, width // 2 + 1:] = np.finfo(np.float32).min + actual = execute(O.SOFTMAX, [x], fp32_mode="reference") + expected = session.run(None, {"x": x})[0] + np.testing.assert_array_equal(actual, expected, err_msg=f"host kernel width={width}") + + +@pytest.mark.parametrize("strategy", ["avx2-fma3", "avx512"]) +@pytest.mark.parametrize("rows,cols,reduction", [ + (1, 1, 1), (1, 7, 127), (3, 9, 128), (5, 17, 129), (17, 31, 257), +]) +def test_fixed_row_matmul_preserves_broadcast_strides_and_odd_tail(monkeypatch, strategy, rows, cols, reduction): + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + rng = np.random.default_rng(7000 + rows) + a = rng.uniform(-1, 1, (2, 1, reduction, rows)).astype(np.float32).swapaxes(-1, -2) + b = rng.uniform(-1, 1, (1, 3, cols, reduction)).astype(np.float32).swapaxes(-1, -2) + a, b = a[..., ::-1], b[..., ::-1, :] + left, right = a.copy(), b.copy() + a.setflags(write=False) + b.setflags(write=False) + actual = execute(O.MATMUL, [a, b], fp32_mode="reference") + expected = left.astype(np.float64) @ right.astype(np.float64) + np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5) + np.testing.assert_array_equal(a, left) + np.testing.assert_array_equal(b, right) + assert actual.shape == (2, 3, rows, cols) and actual.dtype == np.float32 + + +def test_haswell_matmul_regression_executes_the_real_blas_kernel_in_a_child(): + # Force only this child's NumPy OpenBLAS to Haswell, even on AVX512 CI. + # This catches a CPU-specific accumulation-order regression that an + # AVX512-only ORT/NumPy comparison cannot see. No real gate is reconfigured. + from scratchv.verification import fp32_reference as reference + if np.__version__ != "2.2.6": + pytest.skip("The Haswell dispatch regression targets the pinned NumPy 2.2.6 wheel") + features = reference._cpu_features() + if not (features.get("AVX2") and features.get("FMA3")): + pytest.skip("Haswell BLAS regression requires usable AVX2/FMA3") + ort = pytest.importorskip("onnxruntime") + if ort.__version__ != "1.22.1": + pytest.skip("Exact arithmetic oracle is pinned to ORT 1.22.1") + libraries = list((Path(np.__file__).parent.parent / "numpy.libs").glob("*openblas*")) + if not libraries: + pytest.skip("Haswell regression requires the NumPy OpenBLAS wheel") + script = r''' +import ctypes, json +from pathlib import Path +import numpy as np +import onnxruntime as ort +from onnx import helper, TensorProto +from scratchv.verification.fp32_reference import matmul +libraries = list((Path(np.__file__).parent.parent / "numpy.libs").glob("*openblas*")) +assert len(libraries) == 1, libraries +library = ctypes.CDLL(str(libraries[0])) +get_core = library.scipy_openblas_get_corename64_ +get_core.restype = ctypes.c_char_p +assert get_core().decode().lower() == "haswell", get_core() +model = helper.make_model(helper.make_graph( + [helper.make_node("MatMul", ["a", "b"], ["y"])], "haswell_matmul_regression", + [helper.make_tensor_value_info(name, TensorProto.FLOAT, None) for name in ("a", "b")], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, None)]), + opset_imports=[helper.make_opsetid("", 17)]) +model.ir_version = 10 +options = ort.SessionOptions() +options.intra_op_num_threads = options.inter_op_num_threads = 1 +options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL +session = ort.InferenceSession(model.SerializeToString(), sess_options=options, + providers=["CPUExecutionProvider"]) +rng = np.random.default_rng(373) +old_mismatch = 0 +for shape in ((256, 128, 256), (17, 128, 256), (1, 128, 256), (17, 1024, 256)): + m, k, n = shape + a = rng.normal(size=(m, k)).astype(np.float32) + b = rng.normal(size=(k, n)).astype(np.float32) + expected = session.run(None, {"a": a, "b": b})[0] + actual = matmul([a, b], {}, np.dtype("float32")) + if m == 1: + # ORT itself selects GEMV for an entire one-row operation. Padding + # retains our matrix contract; its mathematical accuracy still holds. + truth = a.astype(np.float64) @ b.astype(np.float64) + np.testing.assert_allclose(actual, truth, rtol=2e-5, atol=2e-5) + else: + np.testing.assert_array_equal(actual, expected, err_msg=str(shape)) + if shape == (256, 128, 256): + old_mismatch = int(np.count_nonzero(np.matmul(a, b) != expected)) +assert old_mismatch > 0, "Fixture must expose the original Haswell accumulation bug" +print(json.dumps({"core": get_core().decode(), "old_mismatch": old_mismatch, "cases": 4})) +''' + environment = dict(os.environ, SCRATCHV_FP32_REFERENCE_CPU="avx2-fma3", + OPENBLAS_CORETYPE="Haswell", OPENBLAS_NUM_THREADS="1", + OMP_NUM_THREADS="1", MKL_NUM_THREADS="1") + completed = subprocess.run([sys.executable, "-B", "-c", script], + cwd=Path(__file__).resolve().parents[1], env=environment, + capture_output=True, text=True, timeout=60) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert '"core": "Haswell"' in completed.stdout diff --git a/tests/test_ir_interpreter_memory.py b/tests/test_ir_interpreter_memory.py new file mode 100644 index 0000000..7739656 --- /dev/null +++ b/tests/test_ir_interpreter_memory.py @@ -0,0 +1,132 @@ +"""Optional memory observations preserve execution and count view owners once.""" + +import numpy as np +import pytest + +from benchmarks.ir_interpreter_cases import CASE_FACTORIES, builder, make_case +from scratchv.ir.types import DataType as D, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def test_default_has_no_stats_and_does_not_observe(monkeypatch): + import scratchv.verification.ir_interpreter as module + + def unexpected(*args, **kwargs): + raise AssertionError("default execution must not collect memory") + + monkeypatch.setattr(module, "_MemoryObservation", unexpected) + b = builder() + b.ret(b.make_const(2.0)) + assert IRInterpreter(b.program).run({}).memory_stats is None + + +def test_views_count_logical_bindings_but_not_repeated_storage(): + x = Value("x", shape=(2, 3)) + b = builder(x) + reshaped = b.reshape(x, (3, 2)) + transposed = b.transpose(reshaped, (1, 0)) + b.ret(transposed) + source = np.arange(6, dtype="float32").reshape(2, 3) + interpreter = IRInterpreter(b.program) + result = interpreter.run({"x": source}, collect_memory_stats=True) + stats = result.memory_stats + assert stats["input_logical_bytes"] == 24 + assert stats["initializer_logical_bytes"] == 0 + assert stats["retained_values"] == 3 + assert stats["retained_logical_bytes"] == 72 + assert stats["retained_numpy_storage_bytes"] == 24 + assert stats["return_logical_bytes"] == 24 + assert stats["peak_live_logical_bytes"] == 96 + assert stats["peak_numpy_storage_bytes"] == 48 + assert not np.shares_memory(result.return_value, source) + result.return_value[:] = 99 + np.testing.assert_array_equal(source, np.arange(6).reshape(2, 3)) + second = interpreter.run({"x": source}, collect_memory_stats=True) + np.testing.assert_array_equal(second.return_value, source.reshape(3, 2).T) + assert second.memory_stats == stats + + +def test_initializer_bytes_and_independent_initializer_copy(): + x, weight = Value("x", shape=(2,)), Value("weight", shape=(2,)) + b = builder(x) + b.program.global_values.append(weight) + b.ret(weight) + # Even aliases supplied by the caller keep the pre-existing separate copies. + source = np.array([1, 2], dtype="float32") + result = IRInterpreter(b.program).run({"x": source}, initializers={"weight": source}, + collect_memory_stats=True) + assert result.memory_stats["input_logical_bytes"] == 8 + assert result.memory_stats["initializer_logical_bytes"] == 8 + assert result.memory_stats["retained_numpy_storage_bytes"] == 16 + assert result.memory_stats["peak_numpy_storage_bytes"] == 24 + result.return_value[0] = 123 + assert source[0] == 1 + + +@pytest.mark.parametrize("name", list(CASE_FACTORIES)) +def test_observer_preserves_all_direct_cases(name): + case = make_case(name) + interpreter = IRInterpreter(case.program) + baseline = interpreter.run(case.inputs, initializers=case.initializers) + observed = interpreter.run(case.inputs, initializers=case.initializers, collect_memory_stats=True) + np.testing.assert_array_equal(observed.return_value, baseline.return_value) + assert observed.executed_steps == baseline.executed_steps + assert observed.diagnostics == baseline.diagnostics + assert observed.memory_stats["retained_values"] >= len(case.inputs) + assert observed.memory_stats["peak_numpy_storage_bytes"] >= observed.memory_stats["retained_numpy_storage_bytes"] + + +@pytest.mark.parametrize("flag", [0, 1]) +def test_only_executed_branch_contributes_memory(flag): + condition = Value("condition", D.INT32) + x = Value("x", shape=(2,)) + b = builder(condition, x) + b.br_if(condition, "left", "right") + b.new_block("left") + b.ret(b.neg(x)) + b.new_block("right") + b.ret(x) + result = IRInterpreter(b.program).run({"condition": np.array(flag, dtype="int32"), + "x": np.array([2, 3], dtype="float32")}, + collect_memory_stats=True) + assert result.memory_stats["retained_values"] == 2 + flag + assert result.memory_stats["retained_logical_bytes"] == 12 + 8 * flag + + +def test_loop_counts_retained_slots_and_overwrites_not_cumulative_allocations(): + def run(end): + b = builder() + slot = b.alloca(16, D.INT32) + b.store(slot, b.make_const(0, D.INT32)) + i = b.for_loop(0, end) + b.store(slot, b.add(b.load(slot), i)) + b.endfor() + b.ret(b.load(slot)) + return IRInterpreter(b.program).run({}, collect_memory_stats=True) + + short, long = run(3), run(30) + assert short.return_value == 3 + assert long.return_value == 435 + assert short.memory_stats == long.memory_stats + assert short.memory_stats["retained_logical_bytes"] >= 16 + assert long.executed_steps > short.executed_steps + + +def test_void_and_empty_arrays_are_counted(): + x = Value("x", shape=(0,)) + b = builder(x) + b.ret() + result = IRInterpreter(b.program).run({"x": np.empty(0, dtype="float32")}, + collect_memory_stats=True) + assert result.return_value is None + assert result.memory_stats["retained_values"] == 1 + assert result.memory_stats["peak_live_logical_bytes"] == 0 + assert result.memory_stats["peak_numpy_storage_bytes"] == 0 + + +@pytest.mark.parametrize("bad", [0, 1, None, "yes"]) +def test_invalid_observation_options(bad): + b = builder() + b.ret() + with pytest.raises(IRExecutionError, match="collect_memory_stats must be boolean"): + IRInterpreter(b.program).run({}, collect_memory_stats=bad) diff --git a/tests/test_ir_interpreter_memory_scaling.py b/tests/test_ir_interpreter_memory_scaling.py new file mode 100644 index 0000000..c026fe5 --- /dev/null +++ b/tests/test_ir_interpreter_memory_scaling.py @@ -0,0 +1,423 @@ +"""Opt-in weight borrowing / last-use execution never weaken default or D8.""" + +import weakref + +import numpy as np +import pytest + +from benchmarks.ir_interpreter_cases import builder +from scratchv.frontend.onnx_parser import ONNXParseError, ONNXParser +from scratchv.ir.types import DataType as D, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def test_last_use_releases_chain_without_changing_result_or_steps(): + x = Value("x", shape=(1024,)) + b = builder(x) + current = x + for _ in range(20): + current = b.neg(current) + b.ret(current) + inputs = {"x": np.arange(1024, dtype=np.float32)} + interp = IRInterpreter(b.program) + old = interp.run(inputs, collect_memory_stats=True) + new = interp.run(inputs, memory_mode="last_use", collect_memory_stats=True) + np.testing.assert_array_equal(old.return_value, new.return_value) + assert old.executed_steps == new.executed_steps + assert old.diagnostics == new.diagnostics + assert old.memory_stats["retained_values"] == 21 + assert new.memory_stats["retained_values"] == 1 + assert new.memory_stats["reclaimed_bindings"] == 20 + assert new.memory_stats["peak_numpy_storage_bytes"] == 2 * inputs["x"].nbytes + assert old.memory_stats["peak_numpy_storage_bytes"] == 22 * inputs["x"].nbytes + assert not np.shares_memory(new.return_value, inputs["x"]) + + +def test_repeated_operand_residual_and_alias_views_survive_last_use(): + x = Value("x", shape=(2, 3)) + b = builder(x) + doubled = b.add(x, x) + view = b.transpose(b.reshape(doubled, (3, 2)), (1, 0)) + result = b.add(view, x) + b.ret(result) + source = np.arange(6, dtype="float32").reshape(2, 3) + interp = IRInterpreter(b.program) + old = interp.run({"x": source}) + new = interp.run({"x": source}, memory_mode="last_use", collect_memory_stats=True) + np.testing.assert_array_equal(old.return_value, new.return_value) + assert new.memory_stats["retained_values"] == 1 + assert not np.shares_memory(new.return_value, source) + + +def test_borrowed_weights_are_shared_readonly_and_returns_are_copied(monkeypatch): + import scratchv.verification.ir_interpreter as module + + weight = Value("w", shape=(2, 2)) + b = builder() + b.program.global_values.append(weight) + b.ret(b.transpose(weight, (1, 0))) + source = np.arange(4, dtype="float32").reshape(2, 2) + source.setflags(write=False) + original_compute = module.compute + seen = [] + + def check(instr, operands): + seen.append((np.shares_memory(operands[0], source), operands[0].flags.writeable)) + return original_compute(instr, operands) + + monkeypatch.setattr(module, "compute", check) + result = IRInterpreter(b.program).run({}, initializers={"w": source}, + memory_mode="last_use", copy_initializers=False, collect_memory_stats=True) + assert seen == [(True, False)] + assert not np.shares_memory(result.return_value, source) + result.return_value[0, 0] = 99 + assert source[0, 0] == 0 and not source.flags.writeable + assert result.memory_stats["copy_initializers"] is False + # Default still owns a separate initializer copy. + IRInterpreter(b.program).run({}, initializers={"w": source}) + assert seen[-1] == (False, True) + + +def test_borrowing_rejects_writable_arrays_without_changing_flags(): + weight = Value("w", shape=(2,)) + b = builder() + b.program.global_values.append(weight) + b.ret(weight) + source = np.ones(2, dtype=np.float32) + with pytest.raises(IRExecutionError, match="borrowed initializer must be read-only"): + IRInterpreter(b.program).run({}, initializers={"w": source}, copy_initializers=False) + assert source.flags.writeable + + +@pytest.mark.parametrize("options", [{"memory_mode": "last_use"}, {"copy_initializers": False}]) +@pytest.mark.parametrize("kind", ["branch", "loop", "memory"]) +def test_optimized_modes_fail_closed_for_control_flow_and_memory(kind, options): + b = builder() + if kind == "branch": + b.br("next") + b.new_block("next") + elif kind == "loop": + b.for_loop(0, 2) + b.endfor() + else: + b.alloca(4, D.INT32) + b.ret() + assert IRInterpreter(b.program).run({}).return_value is None + with pytest.raises(IRExecutionError, match="memory optimization requires"): + IRInterpreter(b.program).run({}, **options) + + +def test_observer_runs_before_release_is_readonly_and_does_not_pin_values(): + x = Value("x", shape=(32,)) + b = builder(x) + dead = b.neg(x) + y = b.add(x, x) + z = b.neg(y) + b.ret(z) + seen, refs = [], [] + + def observe(name, array): + assert not array.flags.writeable + assert array.base is not None + seen.append(name) + refs.append(weakref.ref(array.base)) + if name == y.name: + assert refs[0]() is None # Unused dead result was really released. + + result = IRInterpreter(b.program).run({"x": np.ones(32, dtype="float32")}, + memory_mode="last_use", observer=observe) + assert seen == [dead.name, y.name, z.name] + assert all(reference() is None for reference in refs) + np.testing.assert_array_equal(result.return_value, np.full(32, -2, dtype="float32")) + + +def test_observer_failure_has_ir_location(): + b = builder() + output = b.neg(b.make_const(1.0)) + b.ret(output) + + def broken(name, array): + raise OSError("checkpoint disk full") + + with pytest.raises(IRExecutionError, match="ObserverError.*checkpoint disk full") as caught: + IRInterpreter(b.program).run({}, memory_mode="last_use", observer=broken) + assert caught.value.value_name == output.name + assert caught.value.instruction_index == 0 + + +def test_unused_operation_still_raises_numeric_error(): + b = builder() + b.div(b.make_const(1.0), b.make_const(0.0)) + b.ret(b.make_const(7.0)) + for mode in ("retain_all", "last_use"): + with pytest.raises(IRExecutionError, match="NumericError.*division by zero") as caught: + IRInterpreter(b.program).run({}, memory_mode=mode) + assert caught.value.instruction_index == 0 + + +@pytest.mark.parametrize("options", [ + {"copy_initializers": 1}, {"memory_mode": None}, {"memory_mode": []}, + {"memory_mode": "automatic"}, {"observer": 1}, +]) +def test_invalid_options_are_rejected(options): + b = builder() + b.ret() + with pytest.raises(IRExecutionError, match="InvalidOptions"): + IRInterpreter(b.program).run({}, **options) + + +def external_model(tmp_path, *, fields=None, shape=(2, 3), trailing=0): + onnx = pytest.importorskip("onnx") + from onnx import TensorProto, helper + + data = np.arange(6, dtype="float32") + (tmp_path / "weights.bin").write_bytes(b"HEAD" + data.tobytes() + b"T" * trailing) + tensor = TensorProto(name="weight", data_type=TensorProto.FLOAT, dims=shape, + data_location=TensorProto.EXTERNAL) + for key, value in fields if fields is not None else [ + ("location", "weights.bin"), ("offset", "4"), ("length", "24") + ]: + entry = tensor.external_data.add() + entry.key, entry.value = key, value + graph = helper.make_graph([helper.make_node("Neg", ["weight"], ["output"])], + "external", [], [helper.make_tensor_value_info("output", TensorProto.FLOAT, shape)], + initializer=[tensor]) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + model.ir_version = 10 + path = tmp_path / "model.onnx" + path.write_bytes(model.SerializeToString()) + return path, data.reshape(shape) + + +def test_external_mapping_matches_default_and_borrowed_execution(tmp_path): + path, data = external_model(tmp_path) + normal, mapped = ONNXParser(), ONNXParser() + normal.parse(str(path)) + program = mapped.parse(str(path), mmap_external_data=True) + weight = mapped.initializers["weight"] + assert isinstance(weight, np.memmap) and weight.mode == "r" + assert not weight.flags.writeable + np.testing.assert_array_equal(weight, normal.initializers["weight"]) + result = IRInterpreter(program).run({}, initializers=mapped.initializers, + copy_initializers=False, memory_mode="last_use") + np.testing.assert_array_equal(result.return_value, -data) + assert not np.shares_memory(result.return_value, weight) + with pytest.raises(ValueError): + weight[0, 0] = 999 + + +def test_external_mapping_does_not_require_python39_path_api(tmp_path, monkeypatch): + from pathlib import Path + + import scratchv.frontend.onnx_parser as parser_module + + class Python38Path(type(Path())): + @property + def is_relative_to(self): + raise AttributeError("is_relative_to is unavailable on Python 3.8") + + def relative_to(self, *other): + # Newer pathlib implementations internally call is_relative_to; + # delegate through an ordinary Path while hiding that newer API + # from the parser under test, rather than patching global pathlib. + return Path(str(self)).relative_to(*other) + + path, expected = external_model(tmp_path) + parser = ONNXParser() + monkeypatch.setattr(parser_module, "Path", Python38Path) + assert not hasattr(Python38Path(), "is_relative_to") + parser.parse(str(path), mmap_external_data=True) + weight = parser.initializers["weight"] + assert isinstance(weight, np.memmap) and not weight.flags.writeable + np.testing.assert_array_equal(weight, expected) + + +@pytest.mark.parametrize("fields, message", [ + ([("location", "../weights.bin")], "model directory"), + ([("location", "C:\\weights.bin")], "model directory"), + ([("location", "weights.bin"), ("location", "weights.bin")], "Duplicate external"), + ([("location", "weights.bin"), ("offset", "-1")], "nonnegative integer"), + ([("location", "weights.bin"), ("length", "20")], "disagrees"), + ([("location", "weights.bin"), ("offset", "5")], "exceeds file"), + ([("location", "missing.bin")], "exceeds file"), + ([("location", "weights.bin"), ("length", "24"), ("unknown", "x")], "Unsupported external"), +]) +def test_external_mapping_rejects_unsafe_or_mismatched_metadata(tmp_path, fields, message): + path, _ = external_model(tmp_path, fields=fields) + with pytest.raises(ONNXParseError, match=message): + ONNXParser().parse(str(path), mmap_external_data=True) + + +def test_default_external_loader_option_is_strict(tmp_path): + path, _ = external_model(tmp_path) + with pytest.raises(ONNXParseError, match="mmap_external_data must be boolean"): + ONNXParser().parse(str(path), mmap_external_data=1) + + +def test_external_constant_node_uses_same_readonly_mapping(tmp_path): + import onnx + from onnx import helper + + path, expected = external_model(tmp_path) + model = onnx.load(str(path), load_external_data=False) + tensor = model.graph.initializer[0] + node = helper.make_node("Constant", [], ["weight"], value=tensor) + model.graph.node.insert(0, node) + del model.graph.initializer[:] + path.write_bytes(model.SerializeToString()) + parser = ONNXParser() + parser.parse(str(path), mmap_external_data=True) + assert isinstance(parser.initializers["weight"], np.memmap) + assert not parser.initializers["weight"].flags.writeable + np.testing.assert_array_equal(parser.initializers["weight"], expected) + + +def test_mapper_does_not_materialize_proto_or_read_outside_declared_range(tmp_path, monkeypatch): + import onnx + + path, expected = external_model(tmp_path, trailing=64) + tensor = onnx.load(str(path), load_external_data=False).graph.initializer[0] + parser = ONNXParser() + parser._base_dir = str(tmp_path) + + def forbidden(*args, **kwargs): + raise AssertionError("mmap loader must not materialize external protobuf data") + + monkeypatch.setattr(onnx.external_data_helper, "load_external_data_for_tensor", forbidden) + array = parser._map_external_initializer(tensor) + assert not tensor.HasField("raw_data") + assert array.nbytes == 24 and array.offset == 4 + np.testing.assert_array_equal(array, expected) + + +def test_parser_mapping_option_resets_between_calls(tmp_path): + path, _ = external_model(tmp_path) + parser = ONNXParser() + parser.parse(str(path), mmap_external_data=True) + assert isinstance(parser.initializers["weight"], np.memmap) + parser.parse(str(path)) + assert not isinstance(parser.initializers["weight"], np.memmap) + + +@pytest.mark.parametrize("dtype", ["float32", "float64", "int32", "int64"]) +@pytest.mark.parametrize("shape", [(), (0,), (2, 3)]) +def test_mapping_all_supported_numeric_types_scalar_and_empty_shapes(tmp_path, dtype, shape): + from onnx import TensorProto, helper + + values = np.arange(np.prod(shape, dtype=int), dtype=dtype).reshape(shape) + (tmp_path / "weights.bin").write_bytes(b"HEAD" + values.tobytes()) + tensor_dtype = helper.np_dtype_to_tensor_dtype(values.dtype) + tensor = TensorProto(name="weight", data_type=tensor_dtype, dims=shape, + data_location=TensorProto.EXTERNAL) + for key, value in [("location", "weights.bin"), ("offset", "4"), + ("length", str(values.nbytes))]: + entry = tensor.external_data.add() + entry.key, entry.value = key, value + graph = helper.make_graph([helper.make_node("Neg", ["weight"], ["output"])], + "external_types", [], [helper.make_tensor_value_info("output", tensor_dtype, shape)], + initializer=[tensor]) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + model.ir_version = 10 + path = tmp_path / "model.onnx" + path.write_bytes(model.SerializeToString()) + parser = ONNXParser() + program = parser.parse(str(path), mmap_external_data=True) + assert not parser.initializers["weight"].flags.writeable + result = IRInterpreter(program).run({}, initializers=parser.initializers, + memory_mode="last_use", copy_initializers=False) + assert result.return_value.dtype == values.dtype + assert result.return_value.shape == shape + np.testing.assert_array_equal(result.return_value, -values) + + +def test_memmap_backing_accounting_includes_mapping_alignment_and_deduplicates_views(tmp_path): + import mmap + + from scratchv.verification.ir_interpreter import _MemoryObservation, _backing_storage + + offset = mmap.ALLOCATIONGRANULARITY + 4 + path = tmp_path / "aligned.bin" + path.write_bytes(b"x" * offset + np.arange(6, dtype="float32").tobytes()) + mapped = np.memmap(path, mode="r", dtype="float32", offset=offset, shape=(2, 3)) + alias = mapped.T + owner, mapped_bytes = _backing_storage(mapped) + assert owner == _backing_storage(alias)[0] + assert mapped_bytes == mapped.nbytes + offset % mmap.ALLOCATIONGRANULARITY + logical, storage = _MemoryObservation.snapshot({"mapped": mapped, "alias": alias}) + assert logical == 2 * mapped.nbytes + assert storage == mapped_bytes # Mapping span, deliberately not physical RSS. + second = np.memmap(path, mode="r", dtype="float32", offset=offset, shape=(2, 3)) + assert _backing_storage(second)[0] != owner + assert _MemoryObservation.snapshot({"first": mapped, "second": second})[1] == 2 * mapped_bytes + + +def test_executed_dead_nonfinite_constant_is_not_skipped(): + b = builder() + b.load_const(float("nan")) + b.ret(b.make_const(7.0)) + for mode in ("retain_all", "last_use"): + with pytest.raises(IRExecutionError, match="NumericError.*nonfinite"): + IRInterpreter(b.program).run({}, memory_mode=mode) + + +def test_unreferenced_nan_initializer_keeps_original_binding_scope(): + unused = Value("unused", shape=(2,)) + b = builder() + b.program.global_values.append(unused) + b.ret(b.make_const(7.0)) + values = np.array([np.nan, np.nan], dtype="float32") + values.setflags(write=False) + # Neither mode validates/evaluates global tensors never referenced by this + # function; dead *instructions* remain evaluated, as tested separately. + for mode in ("retain_all", "last_use"): + result = IRInterpreter(b.program).run({}, initializers={"unused": values}, + memory_mode=mode, copy_initializers=False) + assert result.return_value == 7 + + +def test_mapper_rejects_symlink_resolving_outside_model_directory(tmp_path, monkeypatch): + from pathlib import Path + + from onnx import TensorProto + + base = tmp_path / "model" + base.mkdir() + outside = tmp_path / "outside.bin" + outside.write_bytes(np.ones(1, dtype="float32").tobytes()) + link_path = base / "weights.bin" + original = Path.resolve + + def emulate_symlink(path, *args, **kwargs): + # Cross-platform exercise of the resolved-path boundary; creating real + # Windows symlinks can require a privilege unrelated to this contract. + if path == link_path: + return outside + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", emulate_symlink) + tensor = TensorProto(name="weight", data_type=TensorProto.FLOAT, dims=(1,), + data_location=TensorProto.EXTERNAL) + entry = tensor.external_data.add() + entry.key, entry.value = "location", "weights.bin" + parser = ONNXParser() + parser._base_dir = str(base) + with pytest.raises(ONNXParseError, match="escapes the model directory"): + parser._map_external_initializer(tensor) + + +def test_observer_accidental_write_fails_and_preserves_borrowed_weight(): + weight = Value("w", shape=(2, 2)) + b = builder() + b.program.global_values.append(weight) + b.ret(b.transpose(weight, (1, 0))) + source = np.arange(4, dtype="float32").reshape(2, 2) + expected = source.copy() + source.setflags(write=False) + + def observe(name, array): + array[0, 0] = 999 + + with pytest.raises(IRExecutionError, match="ObserverError.*read-only"): + IRInterpreter(b.program).run({}, initializers={"w": source}, + memory_mode="last_use", copy_initializers=False, observer=observe) + np.testing.assert_array_equal(source, expected) diff --git a/tests/test_numeric_metrics.py b/tests/test_numeric_metrics.py index 488f2d6..6f4aee8 100644 --- a/tests/test_numeric_metrics.py +++ b/tests/test_numeric_metrics.py @@ -123,9 +123,11 @@ def observed(*args): assert len(lengths) > 2 and max(lengths) <= 127 -def test_new_metrics_do_not_weaken_absolute_gate(): +@pytest.mark.parametrize("gate", ["w2", "w3"]) +def test_new_metrics_do_not_weaken_absolute_gate(gate): from probes.w2_qwen3_small.diagnostics import tensor_diff - compare = tensor_diff + from probes.w3_qwen3_full.comparison import compare_tensor + compare = tensor_diff if gate == "w2" else compare_tensor actual = np.array([2, 4], dtype=np.float32) expected = np.array([1, 2], dtype=np.float32) result = compare(actual, expected) @@ -143,8 +145,10 @@ def test_new_metrics_do_not_weaken_absolute_gate(): def test_empty_gate_policies_are_preserved(): from probes.w2_qwen3_small.diagnostics import tensor_diff + from probes.w3_qwen3_full.comparison import compare_tensor array = np.empty(0, dtype=np.float32) - row = tensor_diff(array, array) - assert row["passed"] - assert row["relative_l2"] is None and row["cosine_similarity"] is None - assert row["relative_l2_reason"] and row["cosine_similarity_reason"] + w2, w3 = tensor_diff(array, array), compare_tensor(array, array) + assert w2["passed"] and not w3["passed"] + for row in (w2, w3): + assert row["relative_l2"] is None and row["cosine_similarity"] is None + assert row["relative_l2_reason"] and row["cosine_similarity_reason"] diff --git a/tests/test_onnx_operator_benchmark.py b/tests/test_onnx_operator_benchmark.py index f0067b6..e50e398 100644 --- a/tests/test_onnx_operator_benchmark.py +++ b/tests/test_onnx_operator_benchmark.py @@ -146,6 +146,8 @@ def test_failed_round_cannot_retain_success_median(): assert merged["status"] == "NUMERIC_ERROR" and merged["run_median_s"] is None +@pytest.mark.skipif(not (bench.ROOT / ".git").exists(), + reason="Historical baseline integration requires this source root's Git checkout; archives omit history") def test_actual_git_baseline_and_dirty_snapshot_are_isolated(tmp_path): original_hash = worker.source_hash(bench.ROOT) selected = ["add_float32", "gather", "scalar_fp32_add_chain", "singleton_initializer", "constant"] @@ -171,21 +173,58 @@ def test_actual_git_baseline_and_dirty_snapshot_are_isolated(tmp_path): def test_worker_timeout_is_visible_in_new_report(tmp_path, monkeypatch): actual_run = subprocess.run + attempts = [] + + def fake_snapshots(work, baseline_ref): + # Timeout handling is independent of Git history. Real tiny source + # directories still satisfy the runner's post-execution hash check. + versions = [] + for side in ("before", "after"): + source = work / side + (source / "scratchv").mkdir(parents=True) + (source / "scratchv" / "__init__.py").write_text("# timeout fixture\n") + versions.append({"source_path": str(source), "source_sha256": worker.source_hash(source)}) + versions[0]["commit"] = baseline_ref + versions[1].update(head="fixture-current", dirty=False) + return versions def timeout(command, **kwargs): if command[0] == sys.executable: + attempts.append(command) raise subprocess.TimeoutExpired(command, 60) return actual_run(command, **kwargs) + monkeypatch.setattr(bench, "snapshots", fake_snapshots) monkeypatch.setattr(subprocess, "run", timeout) assert bench.main(["--output-dir", str(tmp_path), "--case", "add_float32", "--warmup", "0", "--repeats", "1"]) == 1 report = json.loads((tmp_path / "report.json").read_text(encoding="utf-8")) assert not report["passed"] + assert len(attempts) == 4 + assert report["results"][0]["before"]["status"] == "WORKER_ERROR" assert report["results"][0]["after"]["status"] == "WORKER_ERROR" assert "timed out" in report["results"][0]["after"]["error"] +def test_archive_cannot_discover_parent_git_and_reports_clear_failure(tmp_path, monkeypatch): + (tmp_path / ".git").mkdir() + archive = tmp_path / "extracted-source" + archive.mkdir() + monkeypatch.setattr(bench, "ROOT", archive) + + def no_parent_git(*args, **kwargs): + pytest.fail("Source archive must reject history comparison before invoking Git") + + monkeypatch.setattr(subprocess, "run", no_parent_git) + with pytest.raises(RuntimeError, match="requires a Git checkout"): + bench.git("rev-parse", "HEAD") + output = tmp_path / "report" + assert bench.main(["--output-dir", str(output), "--case", "add_float32"]) == 1 + report = json.loads((output / "report.json").read_text(encoding="utf-8")) + assert not report["passed"] and not report["results"] + assert "extracted source archive has no Git history" in report["runner_error"] + + def test_preparation_failure_replaces_stale_pass_report(tmp_path, monkeypatch): (tmp_path / "report.html").write_text("stale PASS", encoding="utf-8") diff --git a/tests/test_onnx_qwen_numeric_ops.py b/tests/test_onnx_qwen_numeric_ops.py index b5831f1..3455b9d 100644 --- a/tests/test_onnx_qwen_numeric_ops.py +++ b/tests/test_onnx_qwen_numeric_ops.py @@ -33,8 +33,11 @@ def compare(path, feed, *, exact=False, reference_evaluator=False): program = parser.parse(str(path)) assert verify_ir(program) == (True, []) actual = IRInterpreter(program).run(feed, initializers=parser.initializers).return_value + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 session = (ReferenceEvaluator(str(path)) if reference_evaluator else - ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])) + ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"])) reference = session.run(None, feed)[0] assert actual.shape == reference.shape assert actual.dtype == reference.dtype diff --git a/tests/test_qwen3_medium_model.py b/tests/test_qwen3_medium_model.py new file mode 100644 index 0000000..43a1b67 --- /dev/null +++ b/tests/test_qwen3_medium_model.py @@ -0,0 +1,104 @@ +"""Pinned-stack regressions for config-driven observers and the medium preset.""" + +import numpy as np +import pytest + +torch = pytest.importorskip("torch") +transformers = pytest.importorskip("transformers") +if transformers.__version__ != "4.51.3": + pytest.skip("Qwen observers require transformers==4.51.3", allow_module_level=True) + +from probes.w2_qwen3_small.model import MODEL_CONFIG, build_model, export_onnx +from probes.w2_qwen3_small.diagnostics import tensor_diff +from probes.w3_qwen3_medium.preset import MEDIUM_CONFIG, build_medium_model +from probes.w3_qwen3_medium.run import trace_schema + + +@pytest.fixture(autouse=True) +def one_thread(): + previous = torch.get_num_threads() + torch.set_num_threads(1) + yield + torch.set_num_threads(previous) + + +def execute(wrapper): + length = wrapper.sequence_length + ids = (torch.arange(length).reshape(1, length) % (wrapper.model.config.vocab_size - 1) + 1).long() + mask = torch.zeros((1, 1, length, length), dtype=torch.float32) + mask.masked_fill_(torch.triu(torch.ones((length, length), dtype=torch.bool), 1), + torch.finfo(torch.float32).min) + with torch.inference_mode(), wrapper.capture(): + tensors = wrapper(ids, mask) + return (ids, mask), dict(zip(wrapper.output_names, tensors)) + + +def test_medium_preset_and_all_observed_shapes(): + wrapper = build_medium_model(seed=31) + assert (wrapper.model.config.num_hidden_layers, wrapper.model.config.hidden_size, + wrapper.model.config.intermediate_size) == (6, 64, 192) + assert len(wrapper.output_names) == 3 + 13 * 6 == 81 + assert wrapper.config_metadata["weights"].startswith("fixed-seed random") + assert wrapper.config_metadata["dtype"] == "float32" + assert wrapper.config_metadata["use_cache"] is False + assert wrapper.model.lm_head.weight is wrapper.model.model.embed_tokens.weight + _, values = execute(wrapper) + for name, value in values.items(): + assert list(value.shape) == wrapper.checkpoint_metadata[name]["shape"], name + assert value.dtype == torch.float32 and bool(torch.isfinite(value).all()) + schema = trace_schema(wrapper.checkpoint_metadata) + assert len(schema["checkpoints"]) == 81 + assert [row["layer"] for row in schema["checkpoints"] if row["checkpoint"] == "residual"] == list(range(6)) + assert wrapper.config_metadata["hidden_size"] == 64 + assert MODEL_CONFIG["hidden_size"] == 32 + + +def test_config_changes_every_schema_dimension_and_export_contract(tmp_path): + onnx = pytest.importorskip("onnx") + ort = pytest.importorskip("onnxruntime") + settings = {"hidden_size": 48, "intermediate_size": 80, "num_hidden_layers": 1, + "num_attention_heads": 6, "num_key_value_heads": 3, "head_dim": 8, + "vocab_size": 29, "max_position_embeddings": 11} + original = dict(settings) + wrapper = build_model(seed=41, model_config=settings, sequence_length=7) + assert settings == original + assert len(wrapper.output_names) == 16 + assert wrapper.config_metadata["position_ids"] == "0..6, including right-padding positions" + inputs, values = execute(wrapper) + assert list(values["layer_0.q_norm"].shape) == [1, 7, 6, 8] + assert list(values["layer_0.k_norm"].shape) == [1, 7, 3, 8] + assert list(values["layer_0.v_proj"].shape) == [1, 7, 24] + assert list(values["layer_0.attn_context"].shape) == [1, 7, 48] + assert list(values["logits"].shape) == [1, 7, 29] + for name, value in values.items(): + assert list(value.shape) == wrapper.checkpoint_metadata[name]["shape"] + path = tmp_path / "custom.onnx" + export_onnx(wrapper, path, *inputs) + model = onnx.load(path) + assert [dim.dim_value for dim in model.graph.input[0].type.tensor_type.shape.dim] == [1, 7] + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + session = ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"]) + actual = session.run(None, {"input_ids": inputs[0].numpy(), "attention_mask": inputs[1].numpy()}) + assert all(tensor_diff(got, values[name].numpy(), atol=1e-5)["passed"] + for name, got in zip(wrapper.output_names, actual)) + + +@pytest.mark.parametrize("override,match", [ + ({"num_hidden_layers": 0}, "positive integer"), + ({"num_attention_heads": 5}, "divisible"), + ({"head_dim": 15}, "even"), + ({"hidden_size": True}, "positive integer"), + ({"use_cache": True}, "use_cache=False"), + ({"mystery": 5}, "Unsupported"), +]) +def test_invalid_probe_configs_fail_before_initialization(override, match): + with pytest.raises(ValueError, match=match): + build_model(model_config=override) + + +def test_sequence_length_cannot_exceed_model_positions(): + with pytest.raises(ValueError, match="exceeds"): + build_model(sequence_length=257) diff --git a/tests/test_qwen3_medium_probe.py b/tests/test_qwen3_medium_probe.py new file mode 100644 index 0000000..7df391b --- /dev/null +++ b/tests/test_qwen3_medium_probe.py @@ -0,0 +1,174 @@ +"""Fail-closed numerical evidence and independent ordinary/diagnostic paths.""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +pytest.importorskip("onnx") + +from probes.w3_qwen3_medium import run as probe +from scratchv.verification.ir_interpreter import IRInterpreter + + +@pytest.mark.parametrize("kind", probe.GRAPH_KINDS) +@pytest.mark.parametrize("level", probe.LEVELS) +def test_one_failed_graph_or_optimization_makes_case_fail(kind, level): + case = {"capture_preserves_logits": {"passed": True}, "attention_checks": [{"passed": True}], + "ort": {key: {"passed": True} for key in probe.GRAPH_KINDS}, + "ir": {opt: {key: {"passed": True} for key in probe.GRAPH_KINDS} for opt in probe.LEVELS}} + assert probe.case_passed(case) + case["ir"][level][kind]["passed"] = False + assert not probe.case_passed(case) + + +@pytest.mark.parametrize("kind", probe.GRAPH_KINDS) +@pytest.mark.parametrize("fault", ["value", "nonfinite", "shape", "exception"]) +def test_ir_fault_is_failed_and_actual_trace_is_preserved(tmp_path, monkeypatch, kind, fault): + expected = np.ones((1, 2, 3), np.float32) + actual = expected.copy() + if fault == "value": + actual.flat[1] += 0.25 + elif fault == "nonfinite": + actual.flat[1] = np.nan + elif fault == "shape": + actual = np.ones((1, 2, 4), np.float32) + + def execute(self, *args, **kwargs): + assert kwargs["collect_memory_stats"] is True + if fault == "exception": + raise RuntimeError("injected execution failure") + return SimpleNamespace(return_value=actual.ravel() if kind == "diagnostic" else actual, + executed_steps=5, memory_stats={"retained_values": 2}) + + monkeypatch.setattr(IRInterpreter, "run", execute) + report = {"trace_artifacts": []} + result = probe.run_ir_variant(None, {}, {}, kind=kind, level="all", + schema=[{"name": "logits", "shape": [1, 2, 3], "offset": 0, "size": 6}], + names=["logits"], metadata={"logits": {"sequence_axis": 1}}, valid_length=1, + reference={"logits": expected}, ort_reference={"logits": expected}, + out=tmp_path, case_name="fault", report=report) + assert result["passed"] is False + assert result["status"] in ("runtime_error", "numeric_failed") + if fault in ("value", "nonfinite") or (fault == "shape" and kind == "normal"): + assert len(report["trace_artifacts"]) == 1 + with np.load(tmp_path / result["trace"]["path"]) as data: + np.testing.assert_array_equal(data["logits"], actual) + assert not result["pytorch_comparison"]["passed"] + if fault == "exception": + assert "injected execution failure" in result["error"] + + +def test_missing_ort_reference_fails_even_when_ir_matches_pytorch(tmp_path, monkeypatch): + expected = np.ones((1, 2, 3), np.float32) + monkeypatch.setattr(IRInterpreter, "run", lambda *args, **kwargs: SimpleNamespace( + return_value=expected.copy(), executed_steps=1, memory_stats={})) + result = probe.run_ir_variant(None, {}, {}, kind="normal", level="none", schema=[], + names=["logits"], metadata={"logits": {"sequence_axis": 1}}, valid_length=2, + reference={"logits": expected}, ort_reference=None, out=tmp_path, + case_name="missing", report={"trace_artifacts": []}) + assert result["pytorch_comparison"]["passed"] + assert not result["passed"] + assert result["ort_comparison"]["error"] == "ORT reference unavailable" + + +@pytest.mark.parametrize("query_index,passed", [(12, False), (100, True)]) +def test_causal_isolation_observes_nonlogit_checkpoint_on_its_sequence_axis(tmp_path, query_index, passed): + reference = {"layer_0.rope_q": np.ones((1, 4, 256, 16), np.float32), + "logits": np.ones((1, 256, 128), np.float32)} + changed = {name: array.copy() for name, array in reference.items()} + changed["layer_0.rope_q"][0, 0, query_index, 0] += 0.5 + for case_name, data in (("full_seed_0", reference), ("changed_future", changed)): + folder = tmp_path / "traces" / case_name + folder.mkdir(parents=True) + np.savez(folder / "reference.npz", **data) + rows = probe.isolation_checks(tmp_path, {"layer_0.rope_q": {"sequence_axis": 2}, + "logits": {"sequence_axis": 1}}) + row = next(row for row in rows if row["name"] == "causality" and row["backend"] == "pytorch") + assert row["passed"] is passed + assert row["comparison"]["first_divergence"] == (None if passed else "layer_0.rope_q") + + +def test_cli_exception_writes_failure_reports_and_refuses_overwrite(tmp_path, monkeypatch): + import json + + def fail(out, report, seed): + report["stage"] = "injected" + raise RuntimeError("injected gate failure") + + monkeypatch.setattr(probe, "run_probe", fail) + out = tmp_path / "evidence" + assert probe.main(["--output-dir", str(out)]) == 1 + report = json.loads((out / "report.json").read_text(encoding="utf-8")) + assert report["passed"] is False + assert report["status"] == "FAIL" + assert report["error"] == "RuntimeError: injected gate failure" + assert report["stage"] == "injected" + assert (out / "report.md").is_file() and (out / "report.html").is_file() + with pytest.raises(SystemExit) as caught: + probe.main(["--output-dir", str(out)]) + assert caught.value.code == 2 + + +def complete_numeric_report(): + return { + "cases": [ + {"name": name, "valid_length": length, "passed": True, + "ort": {graph: {"passed": True} for graph in probe.GRAPH_KINDS}, + "ir": {level: {graph: {"passed": True} for graph in probe.GRAPH_KINDS} + for level in probe.LEVELS}} + for name, length in probe.CASE_IDENTITIES + ], + "invariants": [{"name": name, "backend": backend, "graph": graph, "passed": True} + for name, backend, graph in sorted(probe.INVARIANT_IDENTITIES)], + } + + +@pytest.mark.parametrize("omission", ["case", "all_cases", "invariant", "all_invariants", + "duplicate_case", "duplicate_invariant", "case_length", "ir_level", "ort_graph"]) +def test_missing_or_duplicated_coverage_cannot_pass(omission): + report = complete_numeric_report() + probe.finalize_numeric_report(report) + assert report["coverage_complete"] and report["passed"] + if omission == "case": + report["cases"].pop() + elif omission == "all_cases": + report["cases"] = [] + elif omission == "invariant": + report["invariants"].pop() + elif omission == "all_invariants": + report["invariants"] = [] + elif omission == "duplicate_case": + report["cases"][-1] = report["cases"][0] + elif omission == "duplicate_invariant": + report["invariants"][-1] = report["invariants"][0] + elif omission == "case_length": + report["cases"][0]["valid_length"] = 255 + elif omission == "ir_level": + del report["cases"][0]["ir"]["all"] + else: + del report["cases"][0]["ort"]["normal"] + probe.finalize_numeric_report(report) + assert report["coverage_complete"] is False + assert report["passed"] is False + + +def test_success_status_and_existing_empty_directory_contract(tmp_path, monkeypatch): + import json + + def succeed(out, report, seed): + report.update(complete_numeric_report()) + probe.finalize_numeric_report(report) + + monkeypatch.setattr(probe, "run_probe", succeed) + existing = tmp_path / "already-created" + existing.mkdir() + with pytest.raises(SystemExit) as caught: + probe.main(["--output-dir", str(existing)]) + assert caught.value.code == 2 + assert not list(existing.iterdir()) + out = tmp_path / "new-evidence" + assert probe.main(["--output-dir", str(out)]) == 0 + report = json.loads((out / "report.json").read_text(encoding="utf-8")) + assert report["status"] == "PASS" and report["passed"] is True + assert report["coverage_complete"] is True diff --git a/tests/test_qwen3_small_gate.py b/tests/test_qwen3_small_gate.py index dff815f..de5fcf9 100644 --- a/tests/test_qwen3_small_gate.py +++ b/tests/test_qwen3_small_gate.py @@ -1,7 +1,7 @@ """Exercise the real export and prove corrupt IR cannot produce a green gate. -The LLM workflow runs this file in the pinned CPU environment. The generic -compiler job has no torch/transformers dependency and skips this module. +The LLM workflow and full compiler CI run this file in the pinned CPU stack. +Minimal local compiler installations may omit torch/transformers and skip it. """ import json diff --git a/tests/test_qwen3_small_model.py b/tests/test_qwen3_small_model.py index b4d4daf..46f5c29 100644 --- a/tests/test_qwen3_small_model.py +++ b/tests/test_qwen3_small_model.py @@ -1,7 +1,7 @@ """Optional pinned-stack checks for the official Qwen3 diagnostic model. -The ordinary compiler test environment does not need torch/transformers. The -dedicated Qwen probe environment runs these tests with transformers 4.51.3. +Minimal compiler installations do not need torch/transformers. The full CI +and dedicated Qwen probe environments run these tests with transformers 4.51.3. """ import numpy as np diff --git a/tests/test_w1_qwen3_export.py b/tests/test_w1_qwen3_export.py index ef1faf3..9fa0db2 100644 --- a/tests/test_w1_qwen3_export.py +++ b/tests/test_w1_qwen3_export.py @@ -5,6 +5,7 @@ import stat import zipfile from argparse import Namespace +from pathlib import Path import numpy as np import onnx @@ -218,6 +219,117 @@ def test_dependency_mismatch_fails_before_model_load(monkeypatch): probe.environment() +@pytest.mark.parametrize("name", ["report.md", "report.json"]) +@pytest.mark.parametrize("failure", ["write", "close", "publish"]) +@pytest.mark.parametrize("numeric_passed", [False, True]) +def test_report_io_failure_cannot_leave_pass(artifact, tmp_path, monkeypatch, + name, failure, numeric_passed): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + if not numeric_passed: + def fail_ort(*args, **kwargs): + raise RuntimeError("original ORT failure") + monkeypatch.setattr(probe, "execute_ort", fail_ort) + output = tmp_path / "report-fault" + output.mkdir() + # Reusing this CLI's output directory must not retain an older success. + (output / "report.json").write_text('{"passed": true}', encoding="utf-8") + original_write, original_replace = Path.write_text, Path.replace + + def broken_write(path, content, *args, **kwargs): + if path == output / f".{name}.tmp" and failure in ("write", "close"): + if failure == "close": + original_write(path, content, *args, **kwargs) + raise OSError(f"injected {failure} failure") + return original_write(path, content, *args, **kwargs) + + def broken_replace(path, target): + if Path(target) == output / name and failure == "publish": + raise OSError("injected publish failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "write_text", broken_write) + monkeypatch.setattr(Path, "replace", broken_replace) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 1 + if name == "report.json": + assert not (output / "report.json").exists() + text = (output / "report.md").read_text(encoding="utf-8") + assert "Result: FAIL" in text + if not numeric_passed: + assert "original ORT failure" in text + else: + report = json.loads((output / "report.json").read_text(encoding="utf-8")) + assert report["passed"] is False and report["report_write_errors"] + if numeric_passed: + assert report["failed_stage"] == "report-write" + else: + assert report["failed_stage"] == "ort" + assert report["error"] == "RuntimeError: original ORT failure" + assert not list(output.glob(".report.*.tmp")) + + +def test_report_json_is_published_after_required_view(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-order" + original_replace = Path.replace + names = [] + + def record(path, target): + names.append(Path(target).name) + if Path(target).name == "report.json": + assert (output / "report.md").is_file() + assert not (output / "report.json").exists() + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", record) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 0 + assert names == ["report.md", "report.json"] + + +def test_transient_json_publish_error_retries_only_as_fail(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-retry" + original_replace = Path.replace + attempts = [] + + def fail_once(path, target): + if Path(target).name == "report.json": + attempts.append(json.loads(path.read_text(encoding="utf-8"))["passed"]) + if len(attempts) == 1: + raise OSError("transient publish failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", fail_once) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 1 + assert attempts == [True, False] + assert json.loads((output / "report.json").read_text(encoding="utf-8"))["passed"] is False + assert "Result: FAIL" in (output / "report.md").read_text(encoding="utf-8") + + +def test_interruption_does_not_leave_previous_success(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-interrupted" + output.mkdir() + (output / "report.json").write_text('{"passed": true}', encoding="utf-8") + (output / "report.md").write_text("Result: PASS", encoding="utf-8") + + def interrupted(*args, **kwargs): + raise KeyboardInterrupt() + + monkeypatch.setattr(probe, "execute_ort", interrupted) + with pytest.raises(KeyboardInterrupt): + probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) + assert not (output / "report.json").exists() + assert not (output / "report.md").exists() + + def test_source_fingerprints_report_missing_sources_honestly(tmp_path, monkeypatch): monkeypatch.setattr(probe, "ROOT", tmp_path) result = probe.source_fingerprints() diff --git a/tests/test_w3_actions_summary.py b/tests/test_w3_actions_summary.py new file mode 100644 index 0000000..c00aa87 --- /dev/null +++ b/tests/test_w3_actions_summary.py @@ -0,0 +1,188 @@ +"""W3 Actions pages must distinguish measured results, partial coverage and skips.""" +from copy import deepcopy +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from scripts.summarize_w3_ci import GATES, main, render +from scripts import summarize_w3_ci + +ROOT = Path(__file__).resolve().parents[1] + + +def save(folder, report): + folder.mkdir(parents=True, exist_ok=True) + (folder / "report.json").write_text(json.dumps(report), encoding="utf-8") + + +def metric(value, name): + return {"name": name, "passed": True, "max_abs": value, + "relative_l2": 2e-7, "cosine_similarity": 0.99999} + + +def full_report(**extra): + return {"gate": "numeric:ir-full-qwen3", "status": "PASS", "passed": True, + "coverage_complete": True, "fp32_mode": "reference", "atol": 1e-4, + "elapsed_seconds": 75.125, "required_cases": ["one", "two"], "selected_cases": ["one", "two"], + "cases": [{"name": name, "passed": True, "comparison": { + "logits": [metric(value, "logits.npy"), metric(value * 2, "diagnostic_logits.npy")], + "checkpoints": [metric(0.7, "layer_3.output")]}} + for name, value in [("one", 1e-6), ("two", 3e-6)]], **extra} + + +def test_full_key_metrics_are_visible_and_checkpoint_details_are_folded(tmp_path): + report = full_report() + before = deepcopy(report) + save(tmp_path, report) + text = render("full", tmp_path, "success") + visible = text.split("
", 1)[0] + assert "**PASS**" in visible and "75.125 s" in visible + assert "2/2 cases" in visible and "reference" in visible + assert format(3e-6, ".16e") in visible and format(6e-6, ".16e") in visible + assert format(0.7, ".16e") not in visible + assert "No full-model QEMU result" in visible + assert "w3-full-numeric-reports" in text and "
" in text + assert json.loads((tmp_path / "report.json").read_text()) == before + + +@pytest.mark.parametrize("outcome,expected", [("skipped", "NOT RUN"), ("", "NOT RUN"), ("cancelled", "CANCELLED")]) +def test_skipped_step_does_not_reuse_a_passing_report(tmp_path, outcome, expected): + save(tmp_path, full_report()) + text = render("full", tmp_path, outcome) + assert f"**{expected}**" in text and "PASS" not in text + assert "最大绝对误差" not in text + + +@pytest.mark.parametrize("outcome", ["success", "failure"]) +def test_missing_report_does_not_claim_pass(tmp_path, outcome): + text = render("full", tmp_path, outcome) + assert "MISSING REPORT" in text and "PASS" not in text and outcome in text + + +@pytest.mark.parametrize("invalid", ["{", "[]", '{"gate":"wrong"}', '{"gate":"numeric:ir-full-qwen3","cases":null}']) +def test_invalid_reports_do_not_crash_summary(tmp_path, invalid): + (tmp_path / "report.json").write_text(invalid) + text = render("full", tmp_path, "failure") + assert "INVALID REPORT" in text and "PASS" not in text + + +def test_failed_step_overrides_passing_report(tmp_path): + save(tmp_path, full_report()) + text = render("full", tmp_path, "failure") + assert "**FAIL (step did not succeed)**" in text + assert "**PASS**" not in text + + +def test_partial_coverage_shows_unexecuted_case_and_missing_metrics(tmp_path): + report = full_report(status="PARTIAL", passed=False, coverage_complete=False, selected_cases=["one"]) + report["cases"] = report["cases"][:1] + save(tmp_path, report) + text = render("full", tmp_path, "failure") + assert "**PARTIAL (not acceptance)**" in text + assert "1/2 cases recorded" in text and "NOT RUN" in text and "two" in text + assert "未记录 1/2" in text and "**PASS**" not in text + + +def test_claimed_pass_with_incomplete_coverage_is_not_pass(tmp_path): + save(tmp_path, full_report(coverage_complete=False)) + assert "**INCOMPLETE (not acceptance)**" in render("full", tmp_path, "success") + + +def test_preparation_lists_all_checks_and_never_claims_full_model_execution(tmp_path): + report = {"gate": "preparation:w3", "status": "FAIL", "passed": False, "elapsed_seconds": 32, + "gates": [{"name": "attention", "passed": False, "status": "FAIL", "elapsed_seconds": 1.5}]} + save(tmp_path, report) + save(tmp_path / "attention", {"gate": "unit:attention-backend", "status": "PASS", "passed": True, + "cases": [{"name": "one", "executions": [{"optimization": level, "qemu_vs_ort": metric(3e-6, "logits")} + for level in ("none", "all")]}]}) + # A stale medium child without a corresponding parent invocation is ignored. + save(tmp_path / "medium", {"gate": "w3-medium-host", "status": "PASS", "passed": True}) + text = render("preparation", tmp_path, "failure") + visible = text.split("
", 1)[0] + assert "NOT RUN by this job" in visible + assert "FAIL (parent validation)" in visible and "1 cases / 2 target executions recorded" in visible + for _, _, scope in GATES: + assert scope in visible + assert visible.count("NOT RUN") == 5 + assert "w3-preparation-reports" in text + + +def test_missing_child_report_cannot_leave_overall_pass(tmp_path): + save(tmp_path, {"gate": "preparation:w3", "status": "PASS", "passed": True, + "gates": [{"name": "medium", "status": "PASS", "passed": True}]}) + text = render("preparation", tmp_path, "success") + assert "**INCOMPLETE REPORTS (not acceptance)**" in text and "MISSING REPORT" in text + assert "**PASS**" not in text + + +def test_errors_are_escaped_and_arbitrary_machine_evidence_is_not_dumped(tmp_path): + report = full_report(status="FAIL", passed=False, error=""} + before = deepcopy(report) + write_reports(tmp_path, report) + assert json.loads((tmp_path / "report.json").read_text(encoding="utf-8")) == before + markdown = (tmp_path / "report.md").read_text(encoding="utf-8") + page = (tmp_path / "report.html").read_text(encoding="utf-8") + assert "Result: **FAIL**" in markdown + assert "通过(1/1)" in markdown and "总体结论" in markdown + assert "隔离检查未通过 1 项" in markdown + assert "
" in markdown and "
" in page + assert markdown.index("最大绝对误差") < markdown.index("
") + assert '"checkpoints"' not in markdown + assert "[完整机器报告](report.json)" in markdown + assert 'href="report.json"' in page + assert "