diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50111f7..1d2b107 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,14 @@ jobs: name: Topic 06 benchmark uses: ./.github/workflows/topic06-benchmark.yml + w4: + name: W4 small QEMU checks + needs: scope + if: github.event_name == 'pull_request' && needs.scope.outputs.w4 == 'true' + uses: ./.github/workflows/w4-full-numeric.yml + with: + run_full: false + # ═════════════════════════════════════════════════════════════════════════ # 课题功能测试:所有14个模块的单元测试 + 集成测试 # ═════════════════════════════════════════════════════════════════════════ @@ -125,6 +133,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 +210,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 \ @@ -740,7 +761,7 @@ jobs: acceptance: name: CI acceptance if: always() - needs: [scope, test, benchmark, topic06, llm] + needs: [scope, test, benchmark, topic06, llm, w4] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: diff --git a/.github/workflows/llm-deploy.yml b/.github/workflows/llm-deploy.yml index 22a0227..6e5fe3d 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/W4 workflows from the same +# commit. Default W1/W2 checks do not imply full-model IR or QEMU acceptance. on: workflow_call: @@ -25,6 +24,31 @@ 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 + + run_w4_validation: + description: Also build and execute one complete W4 RV64 QEMU forward + type: boolean + required: false + default: false + w4_case: + description: Fixed input case for the explicitly enabled W4 validation + type: choice + required: false + default: full_seed_0 + options: + - full_seed_0 + - full_seed_42 + - one_token + - short_17 + - short_255 + - changed_future + - changed_padding permissions: contents: read @@ -39,6 +63,19 @@ 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 + + w4-validation: + name: "W4 / explicit full QEMU validation" + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_w4_validation }} + uses: ./.github/workflows/w4-full-numeric.yml + with: + run_full: true + case: ${{ inputs.w4_case || 'full_seed_0' }} + llm-deploy: name: "LLM Deploy v1.0 / W1 + W2" runs-on: ubuntu-24.04 @@ -85,6 +122,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 +574,71 @@ 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 + + w4-execution-summary: + name: "W4 / execution scope" + needs: [w4-validation] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require explicitly requested W4 execution + env: + W4_RESULT: ${{ needs.w4-validation.result }} + W4_REQUESTED: ${{ inputs.run_w4_validation }} + run: | + python3 - <<'PY' + import os + result = os.environ.get("W4_RESULT", "") + requested = (os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" + and os.environ.get("W4_REQUESTED", "").casefold() == "true") + passed = requested and result == "success" + not_run = not requested and result == "skipped" + label = "PASS" if passed else "NOT RUN" if not_run else "FAIL" + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as stream: + stream.write("## W4 execution scope\n\n") + stream.write(f"**{label}** — one complete 28-layer RV64 QEMU forward and retained-array audit.\n\n") + if passed: + stream.write("See the W4 complete Qwen3 RV64 forward summary in this run for measured error, ELF size and QEMU time.\n\n") + elif not_run: + stream.write("W4 is opt-in and was not requested. W1/W2/W3 success is not full QEMU acceptance.\n\n") + else: + stream.write("Requested validation did not complete successfully, or execution was unexpected; inspect the called W4 jobs. No full QEMU acceptance claimed.\n\n") + stream.write("
Scope and evidence\n\n" + "Manual LLM Deploy runs can enable `run_w4_validation` and choose `w4_case`. " + "The reusable workflow uses the same commit and retains measured reports in `w4-full-reports-*` " + "and raw arrays in `w4-full-raw-*`. This single case is not seven-case coverage, " + "Nightly activation, independent reproduction or team sign-off.\n\n
\n") + if not (passed or not_run): + raise SystemExit("Explicit W4 validation must actually run and pass") + 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/.github/workflows/w4-full-numeric.yml b/.github/workflows/w4-full-numeric.yml new file mode 100644 index 0000000..8c9f15b --- /dev/null +++ b/.github/workflows/w4-full-numeric.yml @@ -0,0 +1,442 @@ +name: W4 full RV64 numeric + +on: + workflow_call: + inputs: + run_full: + description: Explicitly execute one complete QEMU forward; false runs only unit and small QEMU regressions + type: boolean + required: false + default: false + case: + description: One CASE_NAMES member; used only when run_full is true + type: string + required: false + default: full_seed_0 + +permissions: + contents: read + +concurrency: + group: w4-full-numeric-${{ github.ref }}-${{ inputs.run_full && 'full' || 'unit' }}-${{ inputs.case || 'full_seed_0' }} + cancel-in-progress: false + +env: + OMP_NUM_THREADS: "1" + OPENBLAS_NUM_THREADS: "1" + MKL_NUM_THREADS: "1" + PYTHONIOENCODING: utf-8 + +jobs: + unit: + name: External ABI and small real QEMU regressions + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - name: Install fixed regression dependencies and tools + run: | + set -euo pipefail + mkdir -p output/w4-unit + sudo apt-get update + sudo apt-get install --no-install-recommends -y qemu-system-misc + python -m pip install numpy==2.2.6 onnx==1.18.0 onnxruntime==1.22.1 protobuf==5.29.5 pytest==9.1.1 PyYAML==6.0.3 ziglang==0.14.1 + python -m pip install --no-deps -e . + python -m pip check + python -m pip freeze > output/w4-unit/dependencies.txt + zig_dir=$(python -c 'from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent)') + printf '%s\n' "$zig_dir" >> "$GITHUB_PATH" + printf 'SCRATCHV_CC=%s/zig\nSCRATCHV_ZIG=%s/zig\nSCRATCHV_QEMU=qemu-system-riscv64\n' "$zig_dir" "$zig_dir" >> "$GITHUB_ENV" + { + python --version + python -m ziglang version + qemu-system-riscv64 --version + uname -a + } > output/w4-unit/tools.txt + - name: Run ABI, streaming bundle, runner and actual QEMU tests + id: unit_tests + run: | + set -euo pipefail + python -B -X utf8 -m pytest \ + tests/test_w1_qwen3_export.py tests/test_llm_inputs.py \ + tests/test_w2_runtime_metadata.py tests/test_w3_full_gate.py \ + tests/test_w3_atomic_text.py \ + tests/test_licm_safety.py tests/test_optimizer_numeric_semantics.py \ + tests/test_tensor_boundary_contracts.py tests/test_tensor_return_contract.py \ + tests/test_weight_bundle.py tests/test_tensor_c_external.py \ + tests/test_w4_contract.py tests/test_riscv_external_contract.py \ + tests/test_riscv_external.py tests/test_w4_full_probe.py \ + tests/test_w4_full_probe_contract.py tests/test_w4_diagnostic.py \ + tests/test_w4_audit.py tests/test_w4_audit_contract.py \ + tests/test_w4_large_address.py tests/test_w4_host_diagnostic.py \ + tests/test_w4_fma_conformance.py tests/test_w4_fma_cross_backend.py \ + tests/test_riscv_process_cleanup.py \ + tests/test_riscv_weight_transport.py \ + tests/test_w4_workflows.py tests/test_w4_ci_summary.py \ + -q -p no:cacheprovider --basetemp output/w4-unit/pytest \ + --junitxml output/w4-unit/tests.xml + python - <<'PY' + import xml.etree.ElementTree as ET + root = ET.parse("output/w4-unit/tests.xml").getroot() + required = ("test_external_rv64_matmul_and_reuse", "test_weight_pointer_above_32bit_limit", + "test_real_rv64_fma_exact_conformance", + "test_blocked_fma_host_and_rv64_are_bitwise_equal[left2-right2]", + "test_real_qemu_contiguous_loader_segments_and_success_cleanup[chunk31-3]", + "test_post_execution_transport_corruption_fails_and_retains_copies", + "test_success_copy_cleanup_failure_is_not_a_pass", + "test_actual_shared_library_executes_kernel_calls_and_unloads[sequential]", + "test_actual_shared_library_executes_kernel_calls_and_unloads[blocked_fma]") + for name in required: + real = [node for node in root.iter("testcase") if node.get("name") == name] + if len(real) != 1 or any(real[0].find(tag) is not None for tag in ("skipped", "error", "failure")): + raise SystemExit(f"Real QEMU/Host C regression must execute and pass: {name}") + PY + - name: Summarize unit and small QEMU evidence + if: always() + env: + UNIT_OUTCOME: ${{ steps.unit_tests.outcome }} + run: | + python -B -X utf8 scripts/summarize_w4_ci.py --kind unit \ + --report-dir output/w4-unit --output output/w4-unit/summary.md + - name: Upload regression evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: w4-unit-${{ github.run_id }}-${{ github.run_attempt }}-${{ inputs.case || 'pr' }} + if-no-files-found: warn + retention-days: 14 + path: | + output/w4-unit/dependencies.txt + output/w4-unit/tools.txt + output/w4-unit/tests.xml + output/w4-unit/summary.md + output/w4-unit/pytest/**/run.json + output/w4-unit/pytest/**/*.stderr + output/w4-unit/pytest/**/*.stdout + + full-numeric: + name: One complete Qwen3 RV64 forward + needs: unit + # Caller event alone never enables the expensive full-model forward. + if: ${{ inputs.run_full }} + runs-on: ubuntu-24.04 + timeout-minutes: 240 + env: + SELECTED_CASE: ${{ inputs.case || 'full_seed_0' }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - name: Validate case and record available Linux resources + run: | + set -euo pipefail + mkdir -p output/w4-ci + { + printf 'commit=%s\nrun_id=%s\nattempt=%s\ncase=%s\n' "$GITHUB_SHA" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$SELECTED_CASE" + cat /etc/os-release + uname -a + lscpu + free -b + df -B1 . + } | tee output/w4-ci/runner-resources.txt + python - <<'PY' + import json, os, shutil + from pathlib import Path + allowed = {"full_seed_0", "full_seed_42", "one_token", "short_17", "short_255", "changed_future", "changed_padding"} + mem = {line.split(":")[0]: int(line.split()[1]) * 1024 + for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith(("MemTotal:", "MemAvailable:"))} + evidence = {"case": os.environ["SELECTED_CASE"], "memory": mem, + "disk_free_bytes": shutil.disk_usage(".").free, + "minimum_available_memory_bytes": 8 * 1024**3, + "minimum_disk_free_bytes": 15 * 1024**3} + evidence["passed"] = (evidence["case"] in allowed + and mem.get("MemAvailable", 0) >= evidence["minimum_available_memory_bytes"] + and evidence["disk_free_bytes"] >= evidence["minimum_disk_free_bytes"]) + Path("output/w4-ci/resource-preflight.json").write_text(json.dumps(evidence, indent=2) + "\n") + if not evidence["passed"]: + raise SystemExit("Unsupported case or insufficient available RAM/disk; see resource-preflight.json") + output = f"output/w4-ci/{evidence['case']}-{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}" + with open(os.environ["GITHUB_ENV"], "a") as stream: + stream.write(f"W4_OUTPUT_DIR={output}\n") + PY + - name: Install W3-pinned CPU environment and Zig 0.14.1 + timeout-minutes: 20 + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --no-install-recommends -y qemu-system-misc time + 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 ziglang==0.14.1 + python -m pip install -e . + python -m pip check + python -m pip freeze > output/w4-ci/dependencies.txt + zig_dir=$(python -c 'from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent)') + printf '%s\n' "$zig_dir" >> "$GITHUB_PATH" + { + python --version + python -m ziglang version + qemu-system-riscv64 --version + } >> output/w4-ci/runner-resources.txt + - name: Acquire the fixed original ONNX assets + timeout-minutes: 15 + run: | + set -euo pipefail + python - <<'PY' + import json + from pathlib import Path + from probes.w1_qwen3_export.run import acquire_model + result = acquire_model(Path("output/assets/onnx")) + Path("output/w4-ci/acquisition.json").write_text(json.dumps(result, indent=2) + "\n") + PY + - name: Recheck free memory and disk after dependencies and assets + run: | + set -euo pipefail + python - <<'PY' + import json, shutil + from pathlib import Path + mem = {line.split(":")[0]: int(line.split()[1]) * 1024 + for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith(("MemTotal:", "MemAvailable:"))} + evidence = {"stage": "after_dependencies_and_assets", "memory": mem, + "disk_free_bytes": shutil.disk_usage(".").free, + "minimum_available_memory_bytes": 8 * 1024**3, + "minimum_disk_free_bytes": 8 * 1024**3, + "estimated_remaining_payload_bytes": 2 * 2384319432 + 2 * 256 * 151936 * 4, + "estimate_scope": "One fixed-model bundle, one temporary segmented transport copy, and two logits arrays; excludes build/cache/log/upload overhead. Runtime manifest remains authoritative."} + evidence["passed"] = (mem.get("MemAvailable", 0) >= evidence["minimum_available_memory_bytes"] + and evidence["disk_free_bytes"] >= evidence["minimum_disk_free_bytes"]) + Path("output/w4-ci/resource-preflight-after-assets.json").write_text(json.dumps(evidence, indent=2) + "\n") + if not evidence["passed"]: + raise SystemExit("Insufficient available RAM/disk after installation and acquisition; see resource-preflight-after-assets.json") + PY + - name: Build external weights and compare one full RV64 forward + id: full_probe + timeout-minutes: 200 + run: | + set -euo pipefail + set +e + timeout --signal=INT --kill-after=30s 195m \ + /usr/bin/time -v -o output/w4-ci/process-time.txt \ + python -B -X utf8 -m probes.w4_qwen3_full.run \ + --model-dir output/assets/onnx --output-dir "$W4_OUTPUT_DIR" \ + --case "$SELECTED_CASE" --cc zig --qemu qemu-system-riscv64 \ + --matmul-policy blocked_fma \ + --timeout 10800 2>&1 | tee output/w4-ci/runner.log + status=${PIPESTATUS[0]} + set -e + printf '%s\n' "$status" > output/w4-ci/runner-exit-code.txt + exit "$status" + - name: Require the complete forward and exact numerical gate + id: full_gate + run: | + set -euo pipefail + python - <<'PY' + import hashlib, json, math, os, re + from pathlib import Path + report = json.loads((Path(os.environ["W4_OUTPUT_DIR"]) / "report.json").read_text()) + commit = os.environ["GITHUB_SHA"] + if (not re.fullmatch(r"[0-9a-f]{40}", commit) + or report.get("git", {}).get("head") != commit + or report.get("git", {}).get("dirty") is not False): + raise SystemExit("Report must come from the clean checkout of this exact CI commit") + sources = report.get("source_sha256", {}) + if (not isinstance(sources, dict) or not sources + or "probes/w4_qwen3_full/run.py" not in sources + or "scratchv/runtime/riscv_external.py" not in sources): + raise SystemExit("Missing executable source identity") + root = Path.cwd().resolve() + for name, digest in sources.items(): + source = (root / name).resolve() + if (not source.is_relative_to(root) or not source.is_file() + or not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) + or hashlib.sha256(source.read_bytes()).hexdigest() != digest): + raise SystemExit(f"Source hash differs from this checkout: {name}") + snapshot = report.get("source_snapshot", {}) + archive = Path(os.environ["W4_OUTPUT_DIR"]) / "source-snapshot.zip" + if (snapshot.get("file") != archive.name or not archive.is_file() + or hashlib.sha256(archive.read_bytes()).hexdigest() != snapshot.get("sha256")): + raise SystemExit("Source snapshot is missing or differs from its report") + required = {"build:riscv-full", "smoke:qemu-full", "numeric:qemu-full-qwen3"} + if (report.get("passed") is not True or report.get("status") != "PASS" + or report.get("matmul_policy") != "blocked_fma" + or report.get("full_qemu_forward_executed") is not True + or any(report.get("gates", {}).get(key) is not True for key in required)): + raise SystemExit("Full RV64 build/smoke/numeric gates did not all pass") + fma = report.get("fma_conformance", {}) + comparison = fma.get("comparison", {}) + rows = comparison.get("rows", []) + actual_qemu_hash = report.get("execution_tool_binary_sha256", {}).get("qemu") + checked_qemu_hash = fma.get("tool_binary_sha256", {}).get("qemu") + if (fma.get("passed") is not True or fma.get("status") != "PASS" + or not isinstance(actual_qemu_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", actual_qemu_hash) + or checked_qemu_hash != actual_qemu_hash + or comparison.get("passed") is not True + or comparison.get("criterion") != "exact binary32 bits and FCSR" + or type(comparison.get("failed_rows")) is not int or comparison["failed_rows"] != 0 + or len(rows) != 12 or any(row.get("passed") is not True + or row.get("bits_match") is not True or row.get("csr_match") is not True for row in rows)): + raise SystemExit("Actual QEMU FMA bit/CSR conformance must pass before full-model acceptance") + cases = report.get("cases", []) + if (len(cases) != 1 or cases[0].get("name") != os.environ["SELECTED_CASE"] + or report.get("coverage") != os.environ["SELECTED_CASE"] + or type(report.get("cases_passed")) is not int or report["cases_passed"] != 1 + or report.get("seven_case_coverage") is not False + or cases[0].get("passed") is not True): + raise SystemExit("Report coverage differs from requested single case") + qemu = cases[0].get("qemu", {}) + if (qemu.get("passed") is not True or qemu.get("status") != "PASS" + or qemu.get("guest_completed") is not True + or type(qemu.get("exit_code")) is not int or qemu["exit_code"] != 0 + or qemu.get("cleanup_errors") or qemu.get("error")): + raise SystemExit("Actual QEMU completion, exit and cleanup must all pass") + numeric = cases[0].get("numeric", {}) + error = numeric.get("max_abs") + if (numeric.get("passed") is not True or numeric.get("atol") != 1e-3 + or numeric.get("rtol") != 0.0 or numeric.get("elements_compared") != 256 * 151936 + or isinstance(error, bool) or not isinstance(error, (int, float)) + or not math.isfinite(error) or not 0 <= error < 1e-3): + raise SystemExit("Every full logits element must satisfy strict max_abs < 1e-3") + PY + - name: Recompute retained numeric evidence without rerunning QEMU + id: full_audit + timeout-minutes: 5 + run: | + set -euo pipefail + python -B -X utf8 -m probes.w4_qwen3_full.audit \ + --report-dir "$W4_OUTPUT_DIR" --output-dir "${W4_OUTPUT_DIR}-audit" + - name: Summarize complete RV64 numerical evidence + if: always() + env: + PROBE_OUTCOME: ${{ steps.full_probe.outcome }} + GATE_OUTCOME: ${{ steps.full_gate.outcome }} + AUDIT_OUTCOME: ${{ steps.full_audit.outcome }} + run: | + python -B -X utf8 scripts/summarize_w4_ci.py --kind full \ + --report-dir "${W4_OUTPUT_DIR:-output/w4-ci/not-started}" \ + --output output/w4-ci/actions-summary.md + - name: Record scope, retention and final resources + if: always() + run: | + mkdir -p output/w4-ci + { + free -b + df -B1 . + } > output/w4-ci/final-resources.txt + cat > output/w4-ci/evidence-retention.json <<'JSON' + { + "reports_retention_days": 30, + "raw_numeric_retention_days": 3, + "scope": "One requested complete 28-layer forward, not the seven-case suite, team signoff, nightly activation, or a generation loop.", + "weights_retained": false, + "transport_binary_retained_in_ci_artifact": false, + "transport_scope": "512 MiB loader segments are temporary execution copies of the unchanged standard bundle. Small transport manifests and run reports are retained; transport .bin files are excluded from uploads. Successful runtime cleanup removes only that run's verified segments after QEMU exits; failed runs can retain segments locally.", + "weights_reproduction": "Reacquire pinned original assets and rebuild using the recorded source and toolchain. Manifest hashes alone do not authenticate model provenance.", + "raw_numeric_scope": "ORT logits, QEMU output and inputs allow independent numerical rechecking before expiry; incomplete failures may lack some files.", + "resource_scope": "Resource preflight and GNU time are recorded; GNU time maximum RSS is not a simultaneously sampled sum of the entire process tree.", + "failure_scope": "Always-upload steps are best effort. Runner loss, job-level cancellation or hard timeout may prevent final report or artifact upload." + } + JSON + - name: Upload reports and failure diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: w4-full-reports-${{ inputs.case || 'full_seed_0' }}-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: warn + retention-days: 30 + path: | + output/w4-ci/**/*.json + output/w4-ci/**/*.md + output/w4-ci/**/*.html + output/w4-ci/**/*.txt + output/w4-ci/**/*.log + output/w4-ci/**/*.stderr + output/w4-ci/**/*.stdout + output/w4-ci/**/*.c + output/w4-ci/**/*.s + output/w4-ci/**/*.S + output/w4-ci/**/*.ld + output/w4-ci/**/source-snapshot.zip + - name: Upload raw numerical evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: w4-full-raw-${{ inputs.case || 'full_seed_0' }}-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: warn + retention-days: 3 + compression-level: 1 + path: | + output/w4-ci/**/report.json + output/w4-ci/**/inputs.npz + output/w4-ci/**/ort.npy + output/w4-ci/**/qemu/inputs.bin + output/w4-ci/**/qemu/output.bin + output/w4-ci/**/qemu/uart.bin + output/w4-ci/**/qemu/run.json + output/w4-ci/**/weights/manifest.json + output/w4-ci/**/weight-transport/transport.json + output/w4-ci/**/source-snapshot.zip + output/w4-ci/**/observations.npy + output/w4-ci/**/input.npy + output/w4-ci/evidence-retention.json + + ci-status: + name: Summarize W4 gates, including failed or skipped dependencies + if: ${{ always() }} + needs: [unit, full-numeric] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + UNIT_RESULT: ${{ needs.unit.result }} + FULL_NUMERIC_RESULT: ${{ needs.full-numeric.result }} + FULL_REQUESTED: ${{ inputs.run_full }} + SELECTED_CASE: ${{ inputs.case || 'full_seed_0' }} + steps: + - name: Record W4 CI result without promoting skipped gates + run: | + set -euo pipefail + mkdir -p output/w4-status + python3 - <<'PY' + import json, os + from pathlib import Path + requested = os.environ.get("FULL_REQUESTED", "").casefold() == "true" + unit, full = os.environ["UNIT_RESULT"], os.environ["FULL_NUMERIC_RESULT"] + passed = unit == "success" and full == ("success" if requested else "skipped") + row = {"schema_version": 1, "passed": passed, "status": "PASS" if passed else "FAIL", + "scope": "One complete RV64 case plus retained-array audit" if requested else "Unit and small-graph regressions only", + "commit": os.environ["GITHUB_SHA"], "run_id": os.environ["GITHUB_RUN_ID"], + "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], "case": os.environ["SELECTED_CASE"] if requested else None, + "full_numeric_requested": requested, + "job_results": {"unit": unit, "full-numeric": full}, + "full_numeric_accepted": requested and passed, "team_accepted": False, + "independent_reproduction": False, "seven_case_coverage": False} + Path("output/w4-status/status.json").write_text(json.dumps(row, indent=2) + "\n") + labels = {"success": "PASS", "failure": "FAIL", "cancelled": "CANCELLED", "skipped": "NOT RUN (未运行)"} + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as stream: + stream.write(f"## W4 CI {row['status']}\n\nCommit: `{row['commit']}`. {row['scope']}.\n\n" + "| 检查 | 本次结果 |\n| --- | --- |\n" + f"| 单测与真实小图 QEMU | {labels.get(unit, 'UNKNOWN')} |\n" + f"| 完整 28 层 QEMU 与数组审计 | {labels.get(full, 'UNKNOWN')} |\n\n" + "数值和耗时见已执行任务的验收摘要;未运行项目没有本次数值结果。\n\n" + "
验收范围\n\n" + "PR 默认只执行单测与小图,完整前向通过手动工作流运行。" + "This does not approve team acceptance or independent reproduction.\n\n
\n") + if not passed: + raise SystemExit("Required W4 jobs failed, were cancelled, or did not run") + PY + - name: Upload W4 CI status even on gate failure + if: always() + uses: actions/upload-artifact@v4 + with: + name: w4-status-${{ inputs.case || 'pr' }}-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + retention-days: 30 + path: output/w4-status/status.json diff --git a/.github/workflows/w4-nightly.yml b/.github/workflows/w4-nightly.yml new file mode 100644 index 0000000..8d907bd --- /dev/null +++ b/.github/workflows/w4-nightly.yml @@ -0,0 +1,89 @@ +name: W4 nightly + +on: + workflow_dispatch: + inputs: + case: + description: One complete case; repository W4_NIGHTLY_ENABLED must also be true + type: choice + default: full_seed_0 + options: + - full_seed_0 + - full_seed_42 + - one_token + - short_17 + - short_255 + - changed_future + - changed_padding + workflow_call: + inputs: + case: + description: One CASE_NAMES member; repository opt-in is required + type: string + required: false + default: full_seed_0 + schedule: + # 04:43 Asia/Shanghai. No expensive job runs without repository opt-in. + - cron: '43 20 * * *' + +permissions: + contents: read + +concurrency: + group: w4-nightly-${{ github.ref }}-${{ inputs.case || 'full_seed_0' }} + cancel-in-progress: false + +jobs: + full-numeric: + # Even manual dispatch requires the repository variable. The separate + # LLM Deploy manual entry remains available for an individual run. + if: ${{ vars.W4_NIGHTLY_ENABLED == 'true' && github.event_name != 'pull_request' }} + uses: ./.github/workflows/w4-full-numeric.yml + with: + run_full: true + case: ${{ inputs.case || 'full_seed_0' }} + + nightly-status: + if: ${{ always() }} + needs: full-numeric + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + NIGHTLY_ENABLED: ${{ vars.W4_NIGHTLY_ENABLED }} + FULL_NUMERIC_RESULT: ${{ needs.full-numeric.result }} + SELECTED_CASE: ${{ inputs.case || 'full_seed_0' }} + steps: + - name: Require enabled nightly execution and preserve its scope + run: | + set -euo pipefail + mkdir -p output/w4-nightly + python3 - <<'PY' + import json, os + from pathlib import Path + enabled = os.environ.get("NIGHTLY_ENABLED", "").casefold() == "true" + result = os.environ["FULL_NUMERIC_RESULT"] + passed = enabled and result == "success" + disabled = not enabled and result == "skipped" + row = {"schema_version": 1, "enabled": enabled, "passed": passed, + "status": "PASS" if passed else "DISABLED" if disabled else "FAIL", + "commit": os.environ["GITHUB_SHA"], "run_id": os.environ["GITHUB_RUN_ID"], + "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], "case": os.environ["SELECTED_CASE"], + "full_workflow_result": result, "seven_case_coverage": False, + "team_accepted": False, "independent_reproduction": False, + "scope": "One complete RV64 case, unit regressions and raw-array audit on the same commit; not W3 seven-case revalidation or W4 team signoff"} + Path("output/w4-nightly/status.json").write_text(json.dumps(row, indent=2) + "\n") + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as stream: + stream.write(f"## W4 nightly {row['status']}\n\nCommit: `{row['commit']}`; case: `{row['case']}`.\n\n" + f"Repository opt-in: {enabled}; full workflow: {result}.\n\n{row['scope']}.\n\n" + "DISABLED is not a numeric PASS. No team acceptance or independent reproduction is implied.\n") + if not (passed or disabled): + raise SystemExit("Enabled nightly requires successful full execution; missing, cancelled and skipped gates fail") + PY + - name: Upload nightly status even on failure + if: always() + uses: actions/upload-artifact@v4 + with: + name: w4-nightly-status-${{ inputs.case || 'full_seed_0' }}-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + retention-days: 30 + path: output/w4-nightly/status.json 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/Linux\345\256\236\350\267\221\346\212\245\345\221\212-2026-10-09.md" "b/docs/llm-deploy-v1.0/Linux\345\256\236\350\267\221\346\212\245\345\221\212-2026-10-09.md" new file mode 100644 index 0000000..4a8e75d --- /dev/null +++ "b/docs/llm-deploy-v1.0/Linux\345\256\236\350\267\221\346\212\245\345\221\212-2026-10-09.md" @@ -0,0 +1,118 @@ +# Linux 验收记录:W4 完整前向与 W3 回归(2026-10-09) + +本记录对应下表固定提交在 Ubuntu 上执行的 W4 实际工具、外置权重与完整 QEMU 前向验证,以及 W3 完整 IR 数值回归。目标为固定 Qwen3-0.6B、FP32、batch=1、L=256、28 层、无 KV cache;目标程序运行于 RV64GC/LP64D、QEMU virt 裸机。**W4 的构建、完整前向、全量数值及离线审计均通过;W3 七组及离线审计也通过。** 本报告不代表团队出口验收。 + +## 源码、环境与资产 + +| 执行范围 | 实际源码身份 | +|---|---| +| W4 `full-run1` | `01aff1df755f6c049c5086cd8434ddf670a59f2a`;开始时工作区干净 | +| W3 `w3-full-run2` | `cf0f7d3e1287d4383bb2bd4356d9ae94a8320d75`;开始时工作区干净 | +| 报告与证据索引 | 本文及 [Linux 证据摘要](evidence/linux-2026-10-09.json);文档提交不改写以上执行身份 | + +W3 使用后继的虚拟环境启动器修复。两份报告的源码摘要仅 `probes/w3_qwen3_full/run.py`、`scripts/run_w3_preparation.py` 不同,`scratchv/` 与 W4 执行源码一致。各运行保留原 SHA,不改写成“全部同一提交实测”。W4 源码快照 SHA256 为 `2985abc5e759493e9cb8b146d7c7406eed2afa94679444c34253129e088834d9`;快照须结合对应 Git 基线恢复。 + +宿主为 Ubuntu 24.04.4 LTS x86_64(内核 7.0.0-28-generic),AMD Ryzen 5 7500F、约 30 GiB RAM,Python 3.12.3;NumPy 2.2.6、ONNX 1.18.0、ORT 1.22.1、Torch 2.7.1+cpu、Transformers 4.51.3、Zig 0.14.1。实际 QEMU 为 Ubuntu 包 `8.2.2 / 1:8.2.2+ds-0ubuntu1.18`。线程数固定为 1;启动资源预检通过。版本、依赖清单、工具二进制 SHA256 和资源观测保存在证据目录,不将 guest 容量当作宿主内存峰值。 + +模型来自仓库固定 manifest,revision 为 `c1899de289a04d12100db370d81485cdf75e47ca`,使用 `output/assets/onnx` 中的既有发布资产并校验摘要。三个原始权重分片共 2,384,201,728 bytes;未以小模型替代完整验收,也未重新导出改变模型身份。 + +## Linux 问题与修复 + +初次回归为 **971 passed、25 failed、4 skipped**。实际 guest 已完成并转储输出,但 Linux QEMU 在 `quit` 时先断开连接,原运行器将其误判为失败。[QMP 官方说明](https://www.qemu.org/docs/master/interop/qemu-qmp-ref.html#command-quit)允许退出时缺少回复。提交 `01aff1d` 仅接纳完整发送无参数 `quit` 后的 EOF/reset;普通命令断连、发送失败、协议错误、超时仍失败,且继续要求进程退出 0、输出有效、权重校验与清理成功。 + +首次 W3 完整尝试因启动器解析 Python 软链接而离开虚拟环境,worker 使用系统 Python、缺少依赖,完整 IR 未执行。提交 `cf0f7d3` 保留选定的虚拟环境解释器路径,增加真实软链接虚拟环境回归;没有修改模型算术。 + +| 补测范围 | 实际结果 | +|---|---| +| QMP 修复相关真实目标与失败边界 | 187 passed | +| W4 完整回归选择 | 1017 passed、1 skipped;12 项真实目标/Linux 必跑检查均执行 | +| 共享 parser/IR 边界 | 380 passed | +| W3 启动器与资源回归 | 80 passed、1 skipped | +| 两层随机权重小模型 | 七组 PyTorch/ORT/IR 及因果、padding 不变量通过 | +| 实际工具 FMA | 12 行 FP32 位模式与 FCSR 精确一致,0 失败 | + +两处剩余跳过均为不适用于 Linux 的平台专属链接或进程管理测试。最初因缺少小模型资产而跳过的三项优化测试,已在生成资产后补跑通过。上述测试集存在重叠,不能累加成互不重复的用例总数。 + +Linux 必跑检查覆盖真实 QEMU 外置矩阵乘、大于 4 GiB 的权重地址、融合乘加、跨 FP32 元素边界的分片装载、运输数据损坏及清理失败,以及共享库卸载。POSIX 信号/session 回收、权重软链接和运输副本链接替换也实际执行,避免把工具不可用或权限不足造成的跳过计入验收。 + +## 完整数值结果 + +W3 第二次运行七组输入及四项不变量全部通过。每组普通/诊断完整 logits、30 个检查点最大误差均为 0,保持严格 `max_abs < 1e-4`、`rtol=0`;耗时 540.219 秒。实际参考策略为 `numpy-fp32-reference-v4 / avx512`,NumPy OpenBLAS 为 `SkylakeX`,`OPENBLAS_CORETYPE` 未设置。离线数组审计通过,耗时 10.984 秒;结论仅适用于记录的源码、资产和环境。 + +本轮重跑 W3 的原因是 W4 候选曾同时收紧共享前端和解释器的输入、初始化张量及返回值形状约束。它们没有改变 FP32 算术,但完整解释执行仍需要重新验证,不能把较宽的 QEMU 误差门槛当成 IR 验证。本次七组包括两组完整随机输入、单 token、两种有效长度,以及未来 token 和 padding 改动,普通与诊断输出都保留包含 padding 的完整数组。 + +| W4 `full_seed_0` 项目 | 最终实测 | +|---|---| +| runner 退出码、stage、status | `0`、`complete`、`PASS` | +| `build:riscv-full` | PASS;ELF 11,378,920 bytes,低于 100,000,000 bytes | +| `smoke:qemu-full` | PASS;guest 完成、QEMU 退出 0、运输副本校验并清理成功 | +| `numeric:qemu-full-qwen3` | PASS;FP32 输出 shape 为 `[1,256,151936]`,全部有限 | +| 比较元素数 | 38,895,616,覆盖全部输出 | +| 最大绝对误差 | `8.58306884765625e-05`,严格小于 `1e-3`,`rtol=0` | +| 达到或超过阈值的元素数 | 0 | +| RMSE | `5.391918773554923e-06` | +| W4 原始数组 audit | PASS;输入绑定、输出摘要、完成记录及重算误差一致 | +| guest RAM / 工作区 | 3,488 MiB / 808,718,864 bytes | +| 权重 bundle | 2,384,319,432 bytes(含 IR 常量与布局对齐),5 个运输分片 | +| 编译耗时 | 73.327 秒 | +| QEMU 墙钟 | 3351.121 秒,约 55.85 分钟 | +| 整个 W4 runner 耗时 | 3448.775 秒,约 57.48 分钟 | +| 采样 QEMU RSS 峰值 | 5,813,882,880 bytes,约 5.415 GiB | + +完整运行内置 FMA 预检通过。bundle SHA256 为 `efe08b8e48fc2438bcc5175c4cdfa7e2dba61e9b3d5846474f0fb065bea1dd7d`;ELF、工具、模型分片和原始报告摘要列于 JSON 索引。此次 QEMU 未返回 `quit` 回复,但完成帧、完整输出、退出 0 和清理均有效,验证了本轮修复覆盖的实际 Linux 行为。 + +QEMU 时间仅表示模拟器墙钟;采样 RSS 仅统计 QEMU 进程,不代表整个进程树峰值。首次失败与修复后的结果分别保留,未覆盖失败记录。 + +## Linux 复现 + +下方联合复现命令使用 `cf0f7d3e1287d4383bb2bd4356d9ae94a8320d75`,或仅增加本报告的后继提交作为候选,以包含 Linux 虚拟环境启动器修复;W4 原始运行身份仍按上表保存。在干净 checkout 中,先按 [W4 复现步骤](W4/reproduction.md)准备 Python 3.12、固定依赖、Zig 0.14.1、QEMU 和固定模型。以下从仓库根目录执行,所有输出目录必须为新目录: + +```bash +set -euo pipefail +export SCRATCHV_PYTHON="$PWD/.venv-w4/bin/python" +export SCRATCHV_ZIG="$PWD/.venv-w4/lib/python3.12/site-packages/ziglang/zig" +export SCRATCHV_CC="$SCRATCHV_ZIG" +export SCRATCHV_QEMU=/usr/bin/qemu-system-riscv64 +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +export PYTHONIOENCODING=utf-8 + +"$SCRATCHV_PYTHON" -B -X utf8 probes/w2_qwen3_small/run.py \ + --output-dir output/linux-repro-small +export SCRATCHV_QWEN_ARTIFACT_DIR="$PWD/output/linux-repro-small" + +"$SCRATCHV_PYTHON" -B -X utf8 -m pytest -q \ + tests/test_weight_bundle.py tests/test_tensor_c_external.py \ + tests/test_riscv_external*.py tests/test_riscv_process_cleanup.py \ + tests/test_riscv_weight_transport.py tests/test_w4_*.py \ + tests/test_tensor_boundary_contracts.py tests/test_tensor_return_contract.py \ + tests/test_w1_qwen3_export.py tests/test_llm_inputs.py \ + tests/test_w2_runtime_metadata.py tests/test_w3_full_gate.py \ + tests/test_licm_safety.py tests/test_optimizer_numeric_semantics.py \ + --junitxml output/linux-repro-unit.xml + +"$SCRATCHV_PYTHON" -B -X utf8 -m probes.w4_qwen3_full.fma_conformance \ + --out output/linux-repro-fma --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" +"$SCRATCHV_PYTHON" -B -X utf8 -m probes.w4_qwen3_full.run \ + --model-dir output/assets/onnx --output-dir output/linux-repro-w4 \ + --case full_seed_0 --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" \ + --matmul-policy blocked_fma --timeout 10800 +"$SCRATCHV_PYTHON" -B -X utf8 -m probes.w4_qwen3_full.audit \ + --report-dir output/linux-repro-w4 --output-dir output/linux-repro-w4-audit + +"$SCRATCHV_PYTHON" -B -X utf8 probes/w3_qwen3_full/run.py \ + --model-dir output/assets/onnx --output-dir output/linux-repro-w3 \ + --fp32-mode reference --worker-timeout 1800 --max-worker-memory-gib 10 +"$SCRATCHV_PYTHON" -B -X utf8 scripts/verify_w3_evidence.py \ + --evidence-dir output/linux-repro-w3 --output-dir output/linux-repro-w3-audit \ + --model-dir output/assets/onnx +``` + +## 证据与验收边界 + +原始证据使用仓库相对根目录 `output/w4-linux-20261009/`:`environment/` 保存环境;`unit/`、`fma-preflight/`、`w3-full/` 保留首次失败;`unit-qmp-fixed/`、`qmp-fix-tests.xml`、`shared-boundary-tests.log` 保存回归;`qwen3-small/`、`fma-after-qmp-fix/`、`w3-full-run2/`、`w3-full-run2-audit/` 保存已完成结果。W4 原始目录为 `full-run1/`,最终审计目录为 `full-run1-audit/`;紧凑索引为 [evidence/linux-2026-10-09.json](evidence/linux-2026-10-09.json)。摘要和哈希不能代替原始数组;原始数据不随 Git 发布,大权重及运输副本不加入提交。该路径是运行产物索引,不表示这些文件已包含在仓库中。 + +独立审计需要主报告、源码快照、输入、ORT 参考、QEMU 输出、UART 完成帧和运行记录,并保持相对目录关系。紧凑索引记录每项状态、源码身份和文件摘要;只获取 Git 仓库可重建源码和重新下载固定模型,不能据此声称已经取得原始数组。 + +公开 JSON 为脱敏摘要:执行器和动态库路径转换为仓库相对路径,省略宿主名及带有构建路径的 NumPy 自由文本,保留依赖版本、BLAS/SIMD 信息、数值、提交及文件摘要。跳过原因采用平台中性的语义摘要,仍保留实际通过/失败/跳过数和未执行边界;它不是原始 JUnit 文本。原始报告及其 SHA256 未改写。 + +本次 W4 只覆盖 `full_seed_0`,不能称为七组 QEMU 通过;W3 的七组也不能替代 W4。离线 audit 不等于第二人重新构建执行。E1/E3/E5 独立复现、接口/布局确认、团队签字及实际远端 Nightly 成功记录仍未完成;本轮未启用 Nightly。W5 token 循环、Paris/多 prompt、会话与部署脚本,以及 W6 性能门槛均另行验收。 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/W1/interfaces.md b/docs/llm-deploy-v1.0/W1/interfaces.md index d69e7d5..47198ca 100644 --- a/docs/llm-deploy-v1.0/W1/interfaces.md +++ b/docs/llm-deploy-v1.0/W1/interfaces.md @@ -107,7 +107,7 @@ int scratchv_run(const void *const inputs[], void *output); `scratchv/runtime/riscv_tensor.py` 提供 `discover_toolchain`、`build_riscv_tensor`、`run_riscv_tensor`。工具可从环境变量或仓库 `output/tools/` 发现;缺工具显式失败,不隐式下载安装。 -当前 QEMU 使用 virt/TCG、512 MiB RAM、无 BIOS/OS,自带启动汇编和链接脚本。输入通过 raw loader 写入保留区域(地址 `0x9c000000`,容量 64 MiB),输出通过 UART 二进制帧返回。帧包含标识、状态、长度、校验和和结束标记;解析拒绝截断/损坏/异常状态,超时失败并清理本次子进程。该传输是现有探测协议,不应误写为通用 Linux FFI 或 mmap。 +本节小模型 QEMU 使用 virt/TCG、512 MiB RAM、无 BIOS/OS,自带启动汇编和链接脚本。输入通过 raw loader 写入保留区域(地址 `0x9c000000`,容量 64 MiB),输出通过 UART 二进制帧返回。帧包含标识、状态、长度、校验和和结束标记;解析拒绝截断/损坏/异常状态,超时失败并清理本次子进程。该传输是现有探测协议,不应误写为通用 Linux FFI 或 mmap。 ### 5.3 W2 Host 运行时辅助接口 @@ -119,7 +119,7 @@ int scratchv_run(const void *const inputs[], void *output); ### 5.4 后续运行时边界 -生成循环和完整模型权重装载仍待实现/验收。当前小模型权重静态嵌入;完整 0.6B 外部分片合计 2,384,201,728 bytes(约 2.22 GiB),不应直接套用 512 MiB guest 配置。W4 需设计代码与权重分离,明确名称/偏移/dtype/shape 描述、装载策略、容量和地址重叠检查、生命周期及分块输出;选择 Linux mmap 时须另建 Linux guest/user-mode 路线,裸机不具备该系统调用。完整 logits 对照仍是原数值门槛,后续生成专用“最后有效位置 logits”接口需独立验收,不能悄悄替换原输出。 +本节的二参数 ABI、512 MiB guest 和 UART 张量协议限定于小模型探测。W4 另提供代码/权重分离、名称/偏移/dtype/shape 绑定、完整地址规划及五参数外置 ABI;通过分段 raw loader 装载和完成帧后的 QMP 转储执行完整前向,详见 [W4 运行接口](../W4/runtime-contract.md)。两种入口并存,不能把 W4 的 workspace/weights 参数按旧签名传递,也不能套用旧固定地址。完整数值、跨平台和团队确认按 [W4 验收](../W4/validation.md) 单独记录;实现存在不代表团队已冻结接口。裸机不提供 Linux mmap,若选择 Linux guest/user-mode 须另建路线。生成循环仍属 W5;生成专用“最后有效位置 logits”接口需独立验收,不能悄悄替换完整 logits 数值门槛。 固定长度的 host 生成循环须复用已验收的最后有效位置、mask、ID 范围、空输入、EOS 和长度边界辅助函数,不能选择右侧 padding 的最后物理位置或静默截断。后续仍需将这些能力与完整模型前向组合验证;当前探测未提供端到端生成 API。 @@ -133,7 +133,8 @@ int scratchv_run(const void *const inputs[], void *output); | 完整模型 IR 数值与容量 | 未由小模型验收 | E2/E4/E5:逐步扩大配置及预训练权重验证 | | 完整 ONNX 门禁 | 历史导出、本地 verify,以及 `545e696` 的[Linux download/ORT 重型任务](https://github.com/yuki-328/ScratchV/actions/runs/36983119833/job/110761955767)均通过;三项验证源码至 `5ea22ec` 未变 | E1/E5:第二人复现及受影响门禁;不冒称重新导出或 `5ea22ec` 重跑重型任务 | | Tokenizer / mask / greedy | W2 基础模块与独立门禁已交付,结果按当前提交检查 | E4/E5:第二人复现,后续集成验收 | -| 生成 / 大权重加载 | 当前探测不覆盖 | E4:W4 权重装载、W5 生成集成及专项验收 | +| 完整权重加载 | W4 提供独立外置 ABI、布局及加载路径,小模型 ABI 保持兼容 | E1/E3/E4/E5:完整前向、接口确认和独立复现 | +| 生成循环 | 已有输入、greedy 和停止辅助函数,尚需 W5 集成 | E4:token 循环、持久会话、端到端专项验收 | ## 7. 团队待决议与确认 @@ -144,7 +145,7 @@ int scratchv_run(const void *const inputs[], void *output); - [ ] D3:接受 ONNX INT64 IDs;如另需 INT32 接口,显式设计转换与范围校验。 - [ ] D4:接受 tensor-c + Zig/LLVM FP32 实现;不宣称原标量选择器已修复。 - [ ] D5:接受基础算子图作为正确性基线,融合节点作为后续优化。 -- [ ] D6:确认小模型静态嵌入边界,另行设计完整权重加载和内存计划。 +- [ ] D6:确认小模型静态嵌入边界,评审 W4 外置权重加载、完整内存计划及与小模型接口的区别。 - [ ] D7:确认接口版本、变更审批及消费方测试责任。 - [ ] D8:确认未使用算子的数值错误是否可被优化消除,以及对应 effect/安全判定和验收范围。 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/W4/README.md b/docs/llm-deploy-v1.0/W4/README.md new file mode 100644 index 0000000..baca08f --- /dev/null +++ b/docs/llm-deploy-v1.0/W4/README.md @@ -0,0 +1,33 @@ +# W4:完整 Qwen3 的代码生成与 QEMU 单次前向 + +W4 使用固定 Qwen3-0.6B ONNX,沿用共享 ScratchV IR,通过 Tensor-C、Zig/LLVM 生成 +RV64GC/LP64D ELF,在 QEMU virt 裸机中计算完整 logits。范围为 FP32、batch=1、L=256、 +28 层、无 KV cache;阶段要求见[开发计划](../开发计划.md)。 + +本目录保存接口、复现和验收约定。原始运行产物保存在被 Git 忽略的 `output/` 中, +公开验收记录应引用固定源码、模型、工具和输入身份,并保留原始报告摘要;更新文档 +不会改变已有运行的执行身份。 + +| 阅读顺序 | 内容 | +|---|---| +| [运行接口](runtime-contract.md) | 权重格式、内存布局、外置 ABI、加载和清理规则 | +| [复现步骤](reproduction.md) | 固定依赖与模型、完整运行、诊断和原始数组复核 | +| [验收与 CI](validation.md) | 分层门槛、测试入口、默认关闭的 Nightly、证据边界 | +| [阶段衔接与后续准备](evolution.md) | W1–W4 接口对应、W5 前置工作、未来 MLIR 转换边界 | + +## 验收标准 + +| 门槛 | 判据 | +|---|---| +| `build:riscv-full` | 完整模型的无大权重 ELF,文件大小严格小于 100,000,000 bytes | +| `unit:weight-loading` | 固定资产身份、名称/dtype/shape/偏移、容量、损坏与生命周期测试 | +| `smoke:qemu-full` | guest 真正完成前向,完成帧及完整输出合法,退出码 0,清理成功 | +| `numeric:qemu-full-qwen3` | 全部 `[1,256,151936]` FP32 输出有限,严格 `max_abs < 1e-3`,`rtol=0` | +| 团队出口 | E1/E3/E5 独立复现、接口/布局确认、实际 Nightly 成功记录 | + +默认完整探测覆盖一个 `full_seed_0` 输入。七组输入入口不代表七组已经执行;前缀检查点、 +Host C 诊断、离线 audit、构建成功、top-1 一致均不能替代完整 QEMU 数值门槛。 +W5 的 token 循环、文本生成、多轮和部署脚本,以及 W6 的性能优化另行验收。 + +现有小模型默认 inline 权重和 sequential MatMul 保持兼容。W4 入口显式选择 external、 +独立 kernel helper 和 blocked_fma,不改变既有 W1–W3 数值阈值。 diff --git a/docs/llm-deploy-v1.0/W4/evolution.md b/docs/llm-deploy-v1.0/W4/evolution.md new file mode 100644 index 0000000..759caf8 --- /dev/null +++ b/docs/llm-deploy-v1.0/W4/evolution.md @@ -0,0 +1,65 @@ +# W1–W4 的衔接与后续准备 + +## 当前共同合同 + +| 阶段 | 提供的基础 | 后续阶段须保持 | +|---|---|---| +| W1 | 固定 ONNX 资产、共享 IR、小图解释/目标执行及接口约定 | 模型身份、输入/输出规格、原有小模型调用兼容 | +| W2 | 完整解析、张量类型/shape、Tokenizer 元数据、输入/mask、greedy 和停止辅助函数 | INT64 token、FP32 mask、logits 宽度 151936、有效长度与 padding 规则 | +| W3 | 完整固定输入、ORT 参考、IR 精度、检查点映射、资源及证据约定 | 相同资产/case 与参考配置,IR 的独立误差门槛与来源记录 | +| W4 | 外置权重 ABI、完整地址规划、RV64 前向、目标精度与生命周期 | 数学策略显式、调用方容量/所有权、实际 guest 输出、原始数据可复核 | + +完整模型输入为 `input_ids: INT64[1,256]` 和加性 `attention_mask: FP32[1,1,256,256]`, +输出为 `FP32[1,256,151936]`。指针次序来自 artifact,不由字典顺序或名字猜测。 +mask 使用有效 key 与因果关系;padding query 仍可看到前面的有效 key。有效长度来自 +未填充 token 数量,不能通过查找 pad ID 推断。greedy 取 `logits[0,valid_length-1]`, +不用固定最后一个位置;相同最大值取较小 ID。 + +模型 logits 宽度不等于 tokenizer 的可解码 ID 数。固定资产的 tokenizer 基础词表为 +151643,包含 added tokens 的 ID 数为 151669;其余输出位置不能凭宽度假定为合法 token。 +W5 必须显式检查采样 ID 能否解码,并与参考生成策略对齐,不能悄悄屏蔽这些 logits +或把无效 ID 替换为其他 token 后宣称输出等价。 + +后端接收共享 `Program` 和初始化张量,不依赖 ONNXParser 私有字段。图检查点映射是 +诊断元数据,不能据名称替代运算实现。W1 小模型二参数 ABI 与 W4 五参数 ABI 并存, +外置内存接口详见[运行合同](runtime-contract.md)。 + +## W5 前置工作 + +1. 先用 W2 的输入、最后有效位置采样与停止函数搭建调用适配,明确新 token 追加、 + EOS、最大生成数和 L=256 容量边界。原 prompt 中的 EOS 不应被当作已生成的 EOS。 +2. 当前 W4 runner 是单次前向:每次启动 guest、装载输入/权重、转储输出并退出。 + 持久 guest、权重驻留、多次调用命令协议、取消/复位及 output 生命周期尚需单独设计。 + 外置 C 函数可顺序复用 caller 内存,不代表运行器已实现持久会话。 +3. 默认范围仍是无 KV cache。生成循环应先保持该语义,与同一参考逐 token 核对,再做 + 复用与性能优化;不要在一次改动里同时引入 KV cache 和新的数值策略。 +4. 增加固定 prompt 的 token/停止边界记录及端到端验证,按 W5 表验收 Paris、3 组 prompt、 + 多轮与部署脚本。完整 logits 单例通过不自动证明文本生成已通过。 + +## 未来 MLIR/LLVM 边界 + +W4 保持 `IR → Tensor-C → Zig/LLVM → RV64`。不为准备工作引入 MLIR 依赖或通用双向 +转换器。已有 `Value` 包含 dtype/shape;部分 builder 中间值可暂留默认 `shape=()`, +转换前必须完成类型推导/验证,不能将默认值直接认定为标量。 + +静态 ONNX 输入必须有明确维度;未知维度不能当作零。参数和初始化张量需要精确形状, +显式返回签名也必须与实际结果一致。另有一项共享 IR 语义需要后续统一:现有参数或 +LOAD 结果的 `is_constant` 可作为提示,不能覆盖运行时绑定;原生指令选择路径对同名 +常量标记的优先级与解释器、Tensor-C 不一致。该路径未参与 W4 Tensor-C 链路,未来 +复用原生后端或接入转换器前,须先明确并回归测试常量标记与 SSA 定义的优先级。 + +未来可先定义静态纯张量子集的单向转换,明确保留: + +- op 语义与属性、广播、轴顺序、reshape 规则、输入输出次序及错误条件; +- dtype、整数位宽、静态 shape 和常量原始位模式,不经十进制/FP64 改写; +- SSA、use-def、副作用与返回合同;不支持的控制流明确拒绝; +- 逻辑 tensor 类型与物理布局/offset/strides、地址空间、对齐和所有权的区别; +- 外部权重逻辑名称与实际 bundle 的绑定、版本和摘要; +- MatMul/归约顺序、显式 FMA 与非 fast-math 策略,不能仅用 FP32 dtype 表示数值等价; +- 目标 index/指针宽度、端序、ABI 与 checkpoint 的可观察性。 + +可评估 `linalg/tensor → bufferization → memref/loop → LLVM`,转换必须满足目标 dialect +及接口要求。相似 IR 格式不会自动适配 pass;任意低层优化也不能保证可逆恢复高层张量 +语义。接口依据见 [Dialect conversion](https://mlir.llvm.org/docs/DialectConversion/)、 +[Bufferization](https://mlir.llvm.org/docs/Bufferization/)。具体转换实现应带独立语义及数值 +回归,并保留现有 ORT 与 RV64 验证链。 diff --git a/docs/llm-deploy-v1.0/W4/reproduction.md b/docs/llm-deploy-v1.0/W4/reproduction.md new file mode 100644 index 0000000..ab3c48a --- /dev/null +++ b/docs/llm-deploy-v1.0/W4/reproduction.md @@ -0,0 +1,116 @@ +# W4 复现步骤 + +从仓库根目录执行,每次使用全新输出目录,保存源码版本、工具环境与原始产物。 +完整模型执行需要较长时间和较多资源;CI 在开始时要求至少 8 GiB 可用内存、15 GiB +空闲磁盘,安装/下载后再次要求 8 GiB 可用内存和 8 GiB 磁盘。这是拒绝明显不足环境的 +预检,不保证任意机器峰值都在阈值内。当前记录 guest 地址规划与 QEMU 单进程采样 RSS, +没有提供整个进程树的并发内存峰值;CI 的 GNU time 数据也不能代替该口径。 + +## 先确定复现的源码 + +独立复现优先使用维护者指定的已发布仓库地址和完整候选提交 SHA。分支名、PR 编号或 +作者工作树的基线提交都不能唯一代表未提交修改。以下命令在一个新的父目录中执行, +`ScratchV-w4-repro` 必须尚不存在;两项变量由维护者提供,不在正在开发的工作树切换版本。 + +```bash +set -euo pipefail +: "${W4_CANDIDATE_REMOTE:?请填写维护者给出的候选仓库地址}" +: "${W4_CANDIDATE_SHA:?请填写维护者给出的完整候选提交 SHA}" +[[ "$W4_CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]] +GIT_LFS_SKIP_SMUDGE=1 git clone --no-checkout "$W4_CANDIDATE_REMOTE" ScratchV-w4-repro +cd ScratchV-w4-repro +git fetch origin "$W4_CANDIDATE_SHA" +GIT_LFS_SKIP_SMUDGE=1 git checkout --detach "$W4_CANDIDATE_SHA" +mkdir -p output/w4-reproduction-identity +git rev-parse HEAD | tee output/w4-reproduction-identity/commit.txt +git status --porcelain=v1 --untracked-files=normal | tee output/w4-reproduction-identity/status.txt +test "$(git rev-parse HEAD)" = "$W4_CANDIDATE_SHA" +test ! -s output/w4-reproduction-identity/status.txt +``` + +这里只取源码;模型在下一节按固定清单另外获取,不依赖 checkout 时的 LFS 下载。 +尚未提交的本地结果必须保留 `git.dirty`、`source_sha256`、`source_snapshot.sha256` 和 +基线提交,明确标为工作树快照。`source-snapshot.zip` 是执行来源记录,须叠加在对应 +Git 基线上,不能单独当作包含全部测试、CI 和依赖配置的仓库。 +现有 `scripts/package_w3_repro.py` 的契约仍为 W3,并不包含 W4 workflow;不要将其 +标为完整 W4 交付包。完成运行后按[回传模板](validation.md#第二人回传结果)提供结果。 + +## Linux 环境与固定资产 + +以下为 Ubuntu 24.04 / Bash 命令。系统 QEMU 包并未冻结为不可变镜像,必须保存实际 +版本及每次 FMA 预检,不能仅凭版本字符串跳过验证。 + +```bash +set -euo pipefail +sudo apt-get update +sudo apt-get install --no-install-recommends -y python3.12-venv qemu-system-misc time +python3.12 -m venv .venv-w4 +source .venv-w4/bin/activate +python -m pip install torch==2.7.1 --index-url https://download.pytorch.org/whl/cpu +python -m pip install -r requirements/qwen3-small-probe.txt PyYAML==6.0.3 ziglang==0.14.1 +python -m pip install -e . +python -m pip check +zig_dir=$(python -c 'from pathlib import Path; import ziglang; print(Path(ziglang.__file__).parent)') +export PATH="$zig_dir:$PATH" +export SCRATCHV_CC="$zig_dir/zig" SCRATCHV_ZIG="$zig_dir/zig" +export SCRATCHV_QEMU=qemu-system-riscv64 +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 +export PYTHONIOENCODING=utf-8 +mkdir -p output +python - <<'PY' +from pathlib import Path +from probes.w1_qwen3_export.run import acquire_model +acquire_model(Path("output/assets/onnx")) +PY +``` + +模型下载复用 W1 的固定清单,不重新导出或替换成小模型。测试命令与分层验收见 +[验证说明](validation.md)。完整复现使用本节准备的 Linux 工具链,并记录实际工具版本与摘要。 + +## 完整运行与复核 + +```bash +python -B -X utf8 -m probes.w4_qwen3_full.run \ + --model-dir output/assets/onnx --output-dir output/w4-full-run1 \ + --case full_seed_0 --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" \ + --matmul-policy blocked_fma --timeout 10800 +python -B -X utf8 -m probes.w4_qwen3_full.audit \ + --report-dir output/w4-full-run1 --output-dir output/w4-full-run1-audit +``` + +运行顺序为固定资产校验、28 层结构审计、IR 解析、外置代码生成/权重包、RV64 构建、 +实际工具 FMA 预检、ORT 参考、QEMU 前向、完整数值比较。主报告必须同时满足三个完整 +gate,且覆盖的 case 与请求一致。`--build-only` 的 BUILD_ONLY 不等于数值 PASS。 + +运行期间,`progress.json` 记录最近开始的当前阶段;`report.json` 是最后一次写入的 +阶段快照,内容可能落后于正在执行的阶段。报告初始化为保守的 `status: FAIL`、 +`passed: false`,因此仅看到运行中快照的 FAIL 不能判定进程已经失败。等待 runner 退出, +再结合退出码、最终 `error`/`stage`、三个 gate 和请求的 case 判断结果;完整通过须为 +退出码 0、`stage: complete`、`status: PASS`、`passed: true` 且三个 gate 全为 true。 +构建或 FMA 单独通过不能代表全程通过;进程已退出而最终报告缺失也不能作为成功。 + +`--case all` 在本地依次执行七组固定输入,需要预留七次前向与原始数组空间;CI 的 +完整单例入口只接受一个固定 case。不得把一个输入通过记成七组通过。 +`--reuse-build` 是受控本地诊断选项,检查旧 ELF、bundle、元数据、源码和模型身份; +显式更换 QEMU 后重新预检,复用时不能通过 `--cc` 更换编译器。干净独立复现从构建开始。 + +## 定位失败 + +```bash +python -B -X utf8 -m probes.w4_qwen3_full.fma_conformance \ + --out output/w4-fma-run1 --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" +python -B -X utf8 -m probes.w4_qwen3_full.diagnostic \ + --model-dir output/assets/onnx --output-dir output/w4-layer0-run1 \ + --checkpoint layer_0.output --case full_seed_0 \ + --cc "$SCRATCHV_CC" --qemu "$SCRATCHV_QEMU" --matmul-policy blocked_fma +``` + +前缀诊断按实际图依赖选择 embedding、层输出或 final norm;它不是一次完整执行中 +所有中间张量的插桩记录。Host C 的 `probes.w4_qwen3_full.host_diagnostic` 可帮助区分 +生成计算和目标运行问题,但不能替代 RV64 结果。FMA 预检失败时先修正工具环境,不跳过 +精确检查、不清浮点标志规避、不放宽容差。全模型失败时保留原始报告与数组,再定位首个 +出现明显偏差的检查点。 + +源码、工具、模型、输入或数值策略改变后,应记录新实验身份。保留旧失败与旧通过报告, +不要覆盖或混合阶段结果;没有重新执行的部分应明确说明。离线 audit 只核对保留数组、 +输入绑定、完成状态及数字一致性,不认证报告来源,也不替代另一人独立编译执行。 diff --git a/docs/llm-deploy-v1.0/W4/runtime-contract.md b/docs/llm-deploy-v1.0/W4/runtime-contract.md new file mode 100644 index 0000000..4ef42c8 --- /dev/null +++ b/docs/llm-deploy-v1.0/W4/runtime-contract.md @@ -0,0 +1,83 @@ +# W4 运行接口 + +## 权重与地址 + +`scratchv/runtime/weight_bundle.py` 将生成 artifact 的有序 `external_weights` 与 +`external_initializers` 写为 `manifest.json`、`weights.bin`。清单记录版本、小端、 +64 字节对齐、总字节数、SHA-256,以及每个张量的名称、dtype、shape、偏移和长度。 +模型原始分片另按 `probes/w1_qwen3_export/manifest.json` 校验;bundle 包含 IR literal, +不能把 ONNX initializer 的顺序当作调用顺序。 + +写入使用新目录和有界缓冲区;拒绝重复或错误绑定、非法数值、路径链接、溢出、截断及 +损坏。调用方在读取、生成、打包期间须保持源映射和文件稳定。前后身份检查能发现常见 +变化,不是防止恶意并发改写的文件锁。 + +地址布局单独预留代码、栈、输入、完整输出、workspace、权重和余量,校验 uint64 +溢出及区域边界。固定模型原始权重合计 2,384,201,728 bytes,完整输出为 155,582,464 +bytes;实际 bundle、workspace、地址和 guest 大小以生成的布局为准。guest 容量不等于 +宿主进程 RSS,增加 QEMU `-m` 不能代替完整规划。 + +`weight_transport.py` 保持标准 bundle 不变,生成至多 512 MiB 的连续 raw-loader 分片, +绕开部分 QEMU/host 的单文件读取上限。启动前和退出后分别核对分片及拼接摘要;成功 +回收仅删除本次创建的运输副本,保留标准 bundle 和运输清单。准备或运行失败保留诊断。 + +`copied_bytes` 与每段 `written_bytes` 统计已完整确认的写入块,不能作为失败后的文件 +长度。`retained_bytes` 通过不跟随链接的文件身份/长度观测得到;无法完整观测时为 +`null`,已知小计在 `retained_observed_bytes`,原因在 `retained_observation_errors`。 +观测失败保持 FAIL 并保留原始操作异常,不能把未知当成 0。清理前校验创建归属与文件 +身份,拒绝删除未成功创建、被替换或变为链接的路径。 + +## 外置 C ABI + +```c +int scratchv_run_external(const void *const inputs[], + const void *const weights[], + void *workspace, size_t workspace_bytes, + void *output); +``` + +小模型的二参数 `scratchv_run(inputs, output)` 继续存在。调用方应根据 artifact 选择 +入口,不将两种签名混用。host 通过装载/传输与 guest 交互,不能在宿主直接调用 RV64 函数。 + +| 参数 | 调用合同 | +|---|---| +| `inputs` | 按 `artifact.inputs` 排列;每个缓冲区满足对应 dtype/shape/nbytes,连续、目标小端、按元素大小对齐 | +| `weights` | 按 `artifact.external_weights` 排列,包含生成的 literal;整个同步调用期间只读且有效 | +| `workspace` | 非零需求时至少分配 `artifact.workspace_bytes`,起点按 8 字节对齐;显式容量参数不得小于需求 | +| `output` | 独立缓冲区,大小和类型取自 `artifact.output`;只有返回值为 0 才能使用结果 | +| 指针表 | 数量足够,按 `_Alignof(void *)` 对齐;表本身和所指数据均保持有效 | + +只读输入/权重允许共享有效存储;output 和使用的 workspace 必须彼此分离,并与只读 +张量及指针表分离。并发调用需要各自独立的 workspace 和 output。workspace 中间值 +按最后使用点复用,不能当作持久结果保存;顺序调用返回后可复用,但旧输出需另行保留。 + +裸指针不携带实际分配容量、shape/dtype 或表长度,生成期与 Python/manifest 边界校验 +这些元数据,调用方保证底层分配。非空检查不能证明指针有效;保留 ndarray 引用也不能 +防止调用方手动关闭 mmap。只有空表可以传空表指针,零 workspace 可以传 NULL;当前 +零元素张量/output 仍需要非空、正确对齐的占位地址。 + +## 编译与执行 + +`TensorCCodegen` 的 external 模式生成不内嵌大权重的循环,可用 `kernel_calls=True` +拆成独立 helper,控制完整图编译规模。`matmul_policy` 显式选择 sequential 或 +blocked_fma;后者按 128 项 K 分块、块内显式融合乘加、块间 FP32 相加。保持非 +fast-math、禁止隐式 FMA 合并,不能将两种累加顺序声称为逐位等价。 + +`riscv_external.py` 验证 ELF 架构、ABI、入口、装载段和布局,启动裸机 QEMU。guest +输出完成帧后,host 用 QMP 停止并转储精确大小输出,再退出;输出不是由 host C/NumPy +替算。实际 QEMU 在完整运行前须通过严格 FMA 位模式/FCSR 检查。 + +编译失败、超时、取消、协议错误、非有限输出、文件校验或清理错误均保持 FAIL;附加 +清理错误不能覆盖原始错误。报告文件本身写入失败也不能保留可误读的 PASS。 +当前完成通知仅在 QEMU 平台验证;未来真实硬件需单独确认设备内存屏障、MMIO 和缓存一致性。 + +## 观测字段 + +- `forward_wall_s`:从调用 QEMU 的 Popen 前到完成帧通过验证,含启动与装载。 +- `qemu_wall_s`:同一起点到观测进程退出,含输出转储。 +- `qemu.elapsed_s`:从运输准备前到进程/运输清理及运输报告保存后,不含函数前段的 + ELF/bundle 校验、输入打包/写入和最后 `run.json` 发布。 +- 主报告 `stages_seconds._qemu`:完整运行器调用耗时。 +- `sampled_peak_qemu_rss_bytes`:仅 QEMU 进程的采样 RSS;不包含父进程,不是硬性内存上限。 + +这些时间不能互换,也不能用 QEMU TCG 墙钟推算真实芯片周期或吞吐。 diff --git a/docs/llm-deploy-v1.0/W4/validation.md b/docs/llm-deploy-v1.0/W4/validation.md new file mode 100644 index 0000000..75c2b46 --- /dev/null +++ b/docs/llm-deploy-v1.0/W4/validation.md @@ -0,0 +1,118 @@ +# W4 分层验证与 CI + +## 按风险阅读实现和测试 + +| 范围 | 实现 | 重点测试 | +|---|---|---| +| 权重身份和布局 | `scratchv/runtime/weight_bundle.py` | `tests/test_weight_bundle.py` | +| 外置 ABI 与数学策略 | `scratchv/backend/tensor_c_codegen.py` | `test_tensor_c_external.py`、`test_w4_contract.py`、`test_w4_fma_cross_backend.py` | +| 裸机运行、ELF 与进程清理 | `scratchv/runtime/riscv_external.py`、`riscv_tensor.py` | `test_riscv_external*.py`、`test_riscv_process_cleanup.py`、`test_w4_large_address.py` | +| 分片与故障证据 | `scratchv/runtime/weight_transport.py` | `tests/test_riscv_weight_transport.py` | +| 完整探测、诊断与 audit | `probes/w4_qwen3_full/` | `test_w4_full_probe*.py`、`test_w4_diagnostic.py`、`test_w4_audit*.py`、`test_w4_host_diagnostic.py` | +| CI 条件及失败关闭 | `.github/workflows/w4-*.yml` | `tests/test_w4_workflows.py` | + +测试文件的完整路径均在 `tests/` 下。完整模型数值由探测入口实际执行,不是单测中人工 +小图或伪造的报告 fixture。可先执行以下回归,再按[复现步骤](reproduction.md)运行完整模型: + +```bash +python -B -X utf8 -m pytest -q \ + tests/test_weight_bundle.py tests/test_tensor_c_external.py \ + tests/test_riscv_external*.py tests/test_riscv_process_cleanup.py \ + tests/test_riscv_weight_transport.py tests/test_w4_*.py \ + tests/test_tensor_boundary_contracts.py tests/test_tensor_return_contract.py \ + tests/test_w1_qwen3_export.py tests/test_llm_inputs.py \ + tests/test_w2_runtime_metadata.py tests/test_w3_full_gate.py \ + tests/test_licm_safety.py tests/test_optimizer_numeric_semantics.py +``` + +真实 QEMU、FMA、跨 4 GiB 地址、跨后端及跨 FP32 元素边界的分片测试应实际执行。 +工具缺失导致的 skip 不能作为这些覆盖已完成的证据;平台特有信号/session 与文件权限 +测试应在适用环境补测。 + +## 门槛不能互相替代 + +W1/W2 小模型 IR 及算子门槛按各自定义保留;W3 完整 IR 为严格 `max_abs < 1e-4`, +W4 完整 QEMU 为严格 `max_abs < 1e-3`,均保持各自报告的输入范围。W4 对全部 +38,895,616 个 FP32 logits 比较,`rtol=0`,不接受 NaN/Inf、shape/dtype 错误、缺失输出 +或等于阈值。FMA 指令语义使用精确位/FCSR 判断,不使用模型容差。 + +离线 audit 同时核对用例名称对应的固定 token、mask 和有效长度,不能仅更新文件哈希 +就把另一组输入计入该用例。W3 独立探测和 W4 完整入口在成功前复核执行源码指纹; +运行中源码变动或无法读取时拒绝发布成功。W4 仅构建入口也执行该复核。 + +## Workflow + +普通 PR 的 W4 检查归入统一 `CI` 运行,不再单独触发一笔 W4 Actions。 +`w4-full-numeric.yml` 仅提供 `workflow_call`,其 unit job 保留合同、小图真实目标及 +跨阶段低成本回归。完整 job 依赖 unit 成功,且调用者必须明确传入 `run_full=true` +才执行单个固定 case;该参数默认关闭,调用者的 push、PR 或定时事件本身不会启用完整模型。 +完整 job 依次要求 build、实际工具 FMA、QEMU 完成、全 logits 数值和原始数组 audit, +并检查报告与请求 case、提交和工具身份一致。失败、取消、跳过不能替代成功。 + +需要运行完整模型时,从 `LLM Deploy v1.0` +(`llm-deploy.yml`)选择候选分支,手动设置 `run_w4_validation=true`,并选择 +`w4_case`(默认 `full_seed_0`)。此入口以 `run_full=true` 调用同一提交的 `w4-full-numeric.yml`; +W3 的 `run_w3_validation` 可独立选择,两者默认均关闭,不改变普通 PR 或定时任务的范围。 +W4 调用未请求时显示 `NOT RUN`;明确请求后失败、取消或跳过会令 W4 汇总失败。 + +Actions Summary 直接显示本次单测的通过/失败/错误/跳过数。完整任务实际执行时, +另显示请求用例、MaxAbs、RMSE、全量比较及超限元素数、不含权重 ELF 大小和 QEMU +墙钟;门禁、阶段耗时及错误摘要折叠展示。详细机器报告和原始数组仍从 Artifacts 下载。 +未执行的完整任务明确显示 `NOT RUN`,报告缺失显示 `MISSING`,不会引用历史运行的 +数值填补。本次摘要只读取产物,不改变数值计算、门槛或 Nightly 启用策略。 + +`w4-nightly.yml` 通过仓库内 `uses` 调用同一提交的完整 workflow,默认仅运行 +`full_seed_0`。**所有 Nightly 入口均需仓库变量 `W4_NIGHTLY_ENABLED=true`**,否则不执行 +昂贵任务;上述 `LLM Deploy v1.0` 手动入口不受此变量限制。定时配置存在或本地测试 +通过,不等于已经启用定时执行或取得远端成功记录。汇总成功也不代表团队已验收。 + +W4 不自动重跑昂贵的 W3 完整七例。发布评审应另外核对同一候选源码的 W3 证据;变更 +共享解析、IR、解释器或数值策略时,应补对应层级回归,不能只用 W4 较宽门槛覆盖 W3。 + +完整 QEMU 截止为 10800 秒,CI 的完整 runner 外层软截止 195 分钟、30 秒强制回收宽限, +step 200 分钟、job 240 分钟。正常失败上传已有报告与日志;宿主丢失、强制取消或整体 +超时可能阻止上传,产物缺失按未完成处理。 + +## 证据与保留 + +- 报告包保留源码快照、摘要、工具/依赖、构建源、布局、清单和阶段错误;原始数值包 + 另外保留输入、ORT、QEMU output、UART 完成帧及 FMA 观测。 +- CI 报告包保留 30 天、原始数值包 3 天。需要独立审计时,在到期前保存原始数组; + 只下载摘要不足以重算误差。 +- 不上传大权重 bundle 或运输副本;独立复现按固定清单重新获取模型。 +- `source-snapshot.zip` 仅覆盖报告记录的源码和固定配置,需结合其 Git 基线恢复, + 不是完整独立仓库归档。 +- 原始报告、工具下载和运行产物保存在忽略的 `output/`,不随源码提交发布;验收材料 + 应提供单独的产物获取位置和保留期限,公开摘要不能代替原始数组。 + +## 第二人回传结果 + +复现者可直接复制以下模板回传;各项填写实际结果,未执行写“未执行”,不要预填通过。 +最终验收由团队另行确认。直接重算作者提供的数组属于离线 audit,独立复现应由复现者 +在指定候选源码上重新构建并执行,不能仅复用作者的 ELF。 + +```text +复现者 / 日期: +源码:候选完整 SHA、工作树是否干净;若为未提交快照,附基线 SHA、源码清单和快照 SHA256: +环境:宿主 OS/架构、Python/依赖、Zig/QEMU 实际版本及二进制 SHA256(见主报告): +执行:完整命令、case、MatMul 策略;是否独立重新构建并执行 QEMU: +单测:通过/失败/跳过数,tests.xml 位置;每项相关跳过原因: +完整门槛:build:riscv-full / smoke:qemu-full / numeric:qemu-full-qwen3: +数值:elements_compared、max_abs、atol、rtol;FMA 精确检查和离线 audit 结果: +资源:ELF 字节数、guest 布局、qemu_wall_s、采样 QEMU RSS(不填成进程树峰值): +证据:主 report.json、source-snapshot.zip、模型/权重摘要、FMA 报告、audit 报告、原始数组包位置及保留期限: +关联证据:同一候选的 W1/W2 回归与 W3 精度结果,未执行项明确列出: +失败或不足:stage/error、退出码/超时、最后日志、相关 run.json/transport.json;缺失文件及原因: +结论:本次实际覆盖范围和未完成项;不代替团队签字或 Nightly 记录: +``` + +字段以主报告为索引:`git`/`source_sha256`/`source_snapshot` 记录源码;`environment`、 +`execution_tools`、`execution_tool_binary_sha256` 记录环境;`model_files` 和 `weight_sha256` +记录模型与 bundle 身份。数值及运行状态在 `cases[*].numeric` 和 `cases[*].qemu`。 +工具版本字符串相同仍需保留二进制摘要;不同平台的二进制摘要可以不同,分别通过实际工具的 +FMA 预检并满足相同门槛,不要求跨平台 ELF 或全部 logits 逐位一致。 + +成功时保留整个探测输出目录以及独立的 audit 目录,分享时按上述 CI 保留规则去除大 +权重和运输副本,保留相对目录结构。失败时先保留已生成的 `report.json`、`progress.json`、 +控制台日志及 case 下的 `qemu/run.json` 和运输清单(如存在),再使用新目录重试。 +没有生成原数组或主报告时如实注明,不能用一次重试的 PASS 覆盖首次失败。 diff --git a/docs/llm-deploy-v1.0/evidence/linux-2026-10-09.json b/docs/llm-deploy-v1.0/evidence/linux-2026-10-09.json new file mode 100644 index 0000000..b74701b --- /dev/null +++ b/docs/llm-deploy-v1.0/evidence/linux-2026-10-09.json @@ -0,0 +1,351 @@ +{ + "completed_at": "2026-10-09T11:19:40.699363+08:00", + "scope": "Fixed-commit Linux validation; W4 one complete full_seed_0 case and W3 seven complete reference-mode cases. No team acceptance or remote Nightly claim.", + "branch": "codex/w4-full-runtime", + "current_commit": "cf0f7d3e1287d4383bb2bd4356d9ae94a8320d75", + "environment": { + "os": "Ubuntu 24.04.4 LTS", + "cpu": "AMD Ryzen 5 7500F 6-Core Processor", + "runtime": { + "python": "3.12.3", + "executable": ".venv-w4/bin/python", + "platform": "Linux-7.0.0-28-generic-x86_64-with-glibc2.39", + "versions": { + "numpy": "2.2.6", + "onnx": "1.18.0", + "onnxruntime": "1.22.1", + "torch": "2.7.1+cpu", + "transformers": "4.51.3", + "safetensors": "0.5.3" + }, + "numeric_runtime": { + "machine": "x86_64", + "processor": "x86_64", + "thread_environment": { + "OMP_NUM_THREADS": "1", + "OPENBLAS_NUM_THREADS": "1", + "MKL_NUM_THREADS": "1" + }, + "blas_environment": { + "OPENBLAS_CORETYPE": null + }, + "numpy_openblas": { + "status": "available", + "libraries": [ + { + "path": ".venv-w4/lib/python3.12/site-packages/numpy.libs/libscipy_openblas64_-56d6093b.so", + "status": "available", + "core_name": "SkylakeX", + "config": "OpenBLAS 0.3.29 USE64BITINT DYNAMIC_ARCH NO_AFFINITY SkylakeX MAX_THREADS=64" + } + ] + }, + "numpy_build_summary": { + "blas": "scipy-openblas", + "lapack": "scipy-openblas", + "openblas_version": "0.3.29", + "integer_interface": "64-bit", + "build_configuration": "DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=64", + "c_compiler": "gcc 10.2.1", + "cxx_compiler": "gcc 10.2.1", + "cython": "3.1.0" + }, + "numpy_runtime_summary": { + "threadpoolctl_available": false, + "simd_extensions": { + "baseline": [ + "SSE", + "SSE2", + "SSE3" + ], + "found": [ + "SSSE3", + "SSE41", + "POPCNT", + "SSE42", + "AVX", + "F16C", + "FMA3", + "AVX2", + "AVX512F", + "AVX512CD", + "AVX512_SKX", + "AVX512_CLX", + "AVX512_CNL", + "AVX512_ICL" + ], + "not_found": [ + "AVX512_KNL", + "AVX512_KNM" + ] + } + } + } + }, + "execution_tools": { + "compiler": "0.14.1", + "qemu": "QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18)", + "target": "riscv64 / RV64GC / LP64D / QEMU virt / bare metal", + "math_library": "Zig-bundled musl" + }, + "execution_tool_binary_sha256": { + "cc": "9df4f1d2eaa7c6ee7649d0e13853ad40deed5d94e643e9ff2a2bab52ffd9feee", + "qemu": "49b77f94862a9ae0e6e31d031a31cce9a668d647b5e827d706bf1e9951e96710" + } + }, + "model_files": [ + { + "name": "model.onnx", + "bytes": 25053577, + "sha256": "0b2e02436055e02612764851384e158467b3f8f544a1c51f52276afc0d229eea" + }, + { + "name": "weights-00001.data", + "bytes": 1069704192, + "sha256": "7b224f4a23a7358463efadd5463998692cf9b861c185d7adcf8f849cf2a5c816" + }, + { + "name": "weights-00002.data", + "bytes": 692165632, + "sha256": "67d403833bac37c7b333c012b62ab05fc634af7fabe8afe0ee19fe0832004176" + }, + { + "name": "weights-00003.data", + "bytes": 622331904, + "sha256": "3cd1b7be0661489fbd0cc65701fdc8be2d79630f09295c2fd30f065cfd0070bf" + } + ], + "linux_fixes": [ + { + "commit": "01aff1df755f6c049c5086cd8434ddf670a59f2a", + "problem": "QEMU may disconnect before acknowledging quit; still require complete output, normal exit and cleanup.", + "original_failure": { + "path": "output/w4-linux-20261009/fma-preflight/report.json", + "sha256": "3593a35c79608f6e2a8dc22f62a10a9cdc39fc37651b84343a309a69469d22b3" + } + }, + { + "commit": "cf0f7d3e1287d4383bb2bd4356d9ae94a8320d75", + "problem": "Resolving the virtualenv Python symlink launched system Python; preserve the selected interpreter path.", + "original_failure": { + "path": "output/w4-linux-20261009/w3-full/report.json", + "sha256": "48d42b73ce7bc6a673e1d01bf84e1e8d283fd58bab7b973f215e2abdc3758fca" + } + } + ], + "regressions": { + "w4": { + "path": "output/w4-linux-20261009/unit-qmp-fixed/tests.xml", + "sha256": "61d0816cb08e9ae144325d8be2054dda2ace8f009e64dcc7c99a796c122708b9", + "passed": 1017, + "failed": 0, + "skipped": 1, + "skip_reasons": [ + "Non-Linux directory-junction behavior; platform-specific test not executed on this Linux host." + ] + }, + "required_target_and_linux": [ + { + "name": "test_external_rv64_matmul_and_reuse", + "executed_and_passed": true + }, + { + "name": "test_weight_pointer_above_32bit_limit", + "executed_and_passed": true + }, + { + "name": "test_real_rv64_fma_exact_conformance", + "executed_and_passed": true + }, + { + "name": "test_blocked_fma_host_and_rv64_are_bitwise_equal[left2-right2]", + "executed_and_passed": true + }, + { + "name": "test_real_qemu_contiguous_loader_segments_and_success_cleanup[chunk31-3]", + "executed_and_passed": true + }, + { + "name": "test_post_execution_transport_corruption_fails_and_retains_copies", + "executed_and_passed": true + }, + { + "name": "test_success_copy_cleanup_failure_is_not_a_pass", + "executed_and_passed": true + }, + { + "name": "test_actual_shared_library_executes_kernel_calls_and_unloads[sequential]", + "executed_and_passed": true + }, + { + "name": "test_actual_shared_library_executes_kernel_calls_and_unloads[blocked_fma]", + "executed_and_passed": true + }, + { + "name": "test_symlinked_weight_file_and_bundle_directory_are_rejected", + "executed_and_passed": true + }, + { + "name": "test_posix_sigterm_reaps_owned_process_session", + "executed_and_passed": true + }, + { + "name": "test_link_replacement_is_not_followed_or_removed", + "executed_and_passed": true + } + ], + "shared_parser_and_ir": { + "path": "output/w4-linux-20261009/shared-boundary-tests.xml", + "sha256": "fc0a8921b647389e8b89aabd9ce8372325cb9688aa05916d45955fbb342ed960", + "passed": 380, + "failed": 0, + "skipped": 0, + "skip_reasons": [] + }, + "venv_launchers_and_resources": { + "path": "output/w4-linux-20261009/environment/w3-venv-regression-after.xml", + "sha256": "e2edeea359cde7aec40a8a32976b57313608c8a67bdb457a40f6c8ea17a31e6c", + "passed": 80, + "failed": 0, + "skipped": 1, + "skip_reasons": [ + "Non-Linux process-job ownership; platform-specific test not executed on this Linux host." + ] + }, + "note": "Suites overlap; counts are not additive. Skip reasons are platform-neutral semantic summaries rather than verbatim JUnit messages." + }, + "w4": { + "executed_commit": "01aff1df755f6c049c5086cd8434ddf670a59f2a", + "git_dirty": false, + "report": { + "path": "output/w4-linux-20261009/full-run1/report.json", + "sha256": "0d5114fe0884ae619f4597ab60f368cfa0ceb9cfa7f3342dd63b3612f2167f79" + }, + "audit": { + "path": "output/w4-linux-20261009/full-run1-audit/report.json", + "sha256": "585eee381f16564c583b65ae60b051f5cb594e0a2d0de2cee0e7697c08d77079" + }, + "source_snapshot": { + "file": "source-snapshot.zip", + "sha256": "2985abc5e759493e9cb8b146d7c7406eed2afa94679444c34253129e088834d9", + "scope": "Hashed production/probe/script sources and fixed requirements/manifests; restore over recorded Git base, not a complete standalone repository" + }, + "elf_sha256": "bf1891f676d795551d833bb6094ec4b5439f53bc2b9b984afa7beb036f19baaf", + "weight_sha256": "efe08b8e48fc2438bcc5175c4cdfa7e2dba61e9b3d5846474f0fb065bea1dd7d", + "fma_passed": true, + "audit_passed": true, + "gates": { + "build:riscv-full": true, + "smoke:qemu-full": true, + "numeric:qemu-full-qwen3": true + }, + "coverage": "full_seed_0", + "numeric": [ + { + "case": "full_seed_0", + "passed": true, + "atol": 0.001, + "rtol": 0.0, + "criterion": "strict max_abs < atol across every output element", + "max_abs": 8.58306884765625e-05, + "worst_index": [ + 0, + 0, + 8974 + ], + "elements_at_or_above_atol": 0, + "rmse": 5.391918773554923e-06, + "elements_compared": 38895616 + } + ], + "elf_bytes": 11378920, + "qemu_wall_s": 3351.1213785799996, + "sampled_peak_qemu_rss_bytes": 5813882880, + "elapsed_seconds": 3448.77511255, + "exit_code": 0, + "current_source_changes_since_run": [ + "probes/w3_qwen3_full/run.py", + "scripts/run_w3_preparation.py" + ], + "numeric_and_w4_runtime_sources_unchanged": true, + "source_note": "W4 ran at its recorded commit. The later commit changes only W3 interpreter launch paths and tests; it does not relabel W4 as executed on the later commit." + }, + "w3": { + "executed_commit": "cf0f7d3e1287d4383bb2bd4356d9ae94a8320d75", + "git_dirty": false, + "report": { + "path": "output/w4-linux-20261009/w3-full-run2/report.json", + "sha256": "b4d5750365b1fbf86ecac7fb972e92530647315292a7d92cf8e51e19f1bbab12" + }, + "audit": { + "path": "output/w4-linux-20261009/w3-full-run2-audit/report.json", + "sha256": "a9bb716dd37e6b1e07fc6f7cc054806c38f51986beeb4e619f9e90bf4f2b2e9c" + }, + "audit_passed": true, + "fp32_mode": "reference", + "coverage_complete": true, + "cases": [ + { + "name": "full_seed_0", + "passed": true, + "max_logits_abs": 0.0, + "max_checkpoint_abs": 0.0 + }, + { + "name": "full_seed_42", + "passed": true, + "max_logits_abs": 0.0, + "max_checkpoint_abs": 0.0 + }, + { + "name": "one_token", + "passed": true, + "max_logits_abs": 0.0, + "max_checkpoint_abs": 0.0 + }, + { + "name": "short_17", + "passed": true, + "max_logits_abs": 0.0, + "max_checkpoint_abs": 0.0 + }, + { + "name": "short_255", + "passed": true, + "max_logits_abs": 0.0, + "max_checkpoint_abs": 0.0 + }, + { + "name": "changed_future", + "passed": true, + "max_logits_abs": 0.0, + "max_checkpoint_abs": 0.0 + }, + { + "name": "changed_padding", + "passed": true, + "max_logits_abs": 0.0, + "max_checkpoint_abs": 0.0 + } + ], + "invariants_passed": 4, + "elapsed_seconds": 540.218816481 + }, + "pending": [ + "Remote Nightly execution", + "Independent team reproduction and acceptance", + "W5 text generation" + ], + "publication": { + "kind": "redacted evidence summary, not an original execution report", + "path_base": "repository root; output paths index external run artifacts and do not imply files are tracked in Git", + "redaction": [ + "Executable and shared-library paths are repository-relative.", + "Host name and free-form NumPy build/runtime dumps containing machine or build paths are omitted; relevant versions, BLAS and SIMD details are retained as structured summaries.", + "Skip reasons are semantic summaries, not verbatim JUnit messages; counts and unexecuted platform coverage are preserved." + ], + "original_reports_unchanged": true, + "recorded_file_hashes_unchanged": true, + "current_commit_scope": "source state when this fixed evidence summary was first assembled; each run retains its own executed_commit" + } +} 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..be84d5e 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 目标 @@ -49,7 +49,7 @@ ScratchV 是一个面向计算机体系结构与编译原理学习的 AI 编译 ### 2.1 整体数据流 -下图是完整模型的目标数据流。当前已实现 Python host 装载输入、启动裸机 QEMU 和读取 UART 结果;W2 另提供固定官方 Tokenizer、输入/mask、最后有效位置 greedy 及停止边界辅助接口,见 [W2 交付记录](W2/README.md)。完整 token 循环与完整模型仍待交付。图中的 FFI 表示模型调用边界:host 通过 QEMU 装载/传输访问 guest,不能在宿主机直接调用 RISC-V 函数。实际模型 ABI 为 `int scratchv_run(const void *const inputs[], void *output)`,详见 [候选接口](W1/interfaces.md)。 +下图是完整模型的目标数据流。当前已实现 Python host 装载输入、启动裸机 QEMU 和读取 UART 结果;W2 另提供固定官方 Tokenizer、输入/mask、最后有效位置 greedy 及停止边界辅助接口,见 [W2 交付记录](W2/README.md)。完整 token 循环仍属 W5;完整模型 W4 的运行接口与验收要求见 [W4 说明](W4/README.md)。图中的 FFI 表示模型调用边界:host 通过 QEMU 装载/传输访问 guest,不能在宿主机直接调用 RISC-V 函数。已提交的小模型 inline ABI 为 `int scratchv_run(const void *const inputs[], void *output)`,详见 [候选接口](W1/interfaces.md);W4 外置路径提供 `scratchv_run_external(inputs, weights, workspace, workspace_bytes, output)` 五参数外置 ABI,与旧接口并存,精确 C 签名及生命周期见 [W4 运行接口](W4/runtime-contract.md)。 ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -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 全绿不能替代这些专项记录。W4 的 PR 小图回归由统一 CI 调用 `w4-full-numeric.yml`;`llm-deploy.yml` 手动设置 `run_w4_validation=true` 才调用完整前向,另有默认关闭的 `w4-nightly.yml`;所有 Nightly 入口需仓库变量 `W4_NIGHTLY_ENABLED=true`。配置和本地检查不代表已取得远端成功记录,完整门槛、独立复现与团队验收分别记录。生成门禁仍属 W5 待实现。实际工具为 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 单次前向(硬门槛) | 项目 | 内容 | @@ -216,7 +220,7 @@ Host 端循环: | **CI 验收** | `build:riscv-full`:生成完整 RISC-V 二进制,不含权重,大小 < 100 MB
`smoke:qemu-full`:QEMU 完整前向无崩溃,退出码 0
`unit:weight-loading`(原计划名 `unit:weight-mmap`):固定分片校验、名称/偏移/dtype/shape绑定、容量和生命周期正确;mmap 仅适用于另行选择的 Linux 路线
**`numeric:qemu-full-qwen3`(硬门槛)**:QEMU 单次前向 logits vs ORT,误差 < 1e-3 | | **出口标准** | **硬门槛通过**;E3、E5、E1 独立复现;Nightly 绿 | -当前小模型将权重展开为 C 常量,constant/workspace 默认各上限 256 MiB,guest 为 512 MiB。完整权重分片合计 2,384,201,728 bytes(约 2.22 GiB),完整 logits 约 148.4 MiB,另需激活/栈/输入/代码空间;不得仅放宽上限或修改 QEMU `-m` 就宣称支持完整模型。`unit:weight-loading` 是建议的后续门禁名称,尚未接入 CI;具体装载协议、地址布局和是否另建 Linux/mmap 分支须经过设计评审。 +已提交的小模型默认将权重展开为 C 常量,constant/workspace 默认各上限 256 MiB,guest 为 512 MiB。完整权重分片合计 2,384,201,728 bytes(约 2.22 GiB),完整 logits 约 148.4 MiB,另需激活/栈/输入/代码空间;不得仅放宽上限或修改 QEMU `-m` 就宣称支持完整模型。W4 外置路径提供标准 bundle、完整地址布局、512 MiB 分段加载与生命周期校验,`unit:weight-loading` 所需测试纳入 W4 workflow 的 unit job。采用 QEMU 裸机装载;Linux guest/mmap 不是此接口。完整数值结果、团队接口评审、独立复现及 Nightly 分别验收,接口、复现与证据要求见 [W4 说明](W4/README.md)。 #### W5:端到端推理(硬门槛) @@ -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 前向实现及验证入口,按其完整数值、独立复现和 Nightly 门槛另行验收,不能以 W3 通过替代。W3 历史证据范围见 [W3 工作总结](W3/W3-工作总结与验收进度.md),W4 接口和复现要求见 [W4 说明](W4/README.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_parse/README.md b/probes/w2_qwen3_parse/README.md index d303efe..7f7c1f0 100644 --- a/probes/w2_qwen3_parse/README.md +++ b/probes/w2_qwen3_parse/README.md @@ -39,6 +39,12 @@ python -X utf8 -B probes/w2_qwen3_parse/run.py --mode verify \ 这些是固定图的结构规则,不是对算子数值实现或所有可能 ONNX 导出形式的证明。 +通用前端还按算子版本与属性限制支持范围:`Softmax` 要求 opset ≥ 13;旧版的展平 +语义尚未实现,解析时明确拒绝。ONNX `Gelu` 仅接受显式 `approximate="tanh"`, +默认精确公式不自动替换成近似公式。`Gemm` 的 alpha/beta、转置与可选 bias 已传入 +IR 解释器;这不表示 Tensor-C 已支持 GEMM,旧标量后端也会拒绝不支持的缩放参数。 +固定 Qwen3 图使用 opset 18 的 Softmax、MatMul 与 SwiGLU,不依赖这些未实现变体。 + 转换审计记录实际 parser 的每个节点,核对 IR 指令区间、opcode、输入 Value、输出映射和静态属性,允许合法的 Constant 绑定与 Identity 别名而不把它们误报为漏转换。形状、切片和轴等控制参数从原 ONNX 小型控制子图独立求值,不复用被检查解析器的常量缓存。所有 ONNX value 的静态 shape/dtype、全局张量集合与唯一 RETURN 也会核对。权重和 Constant 绑定逐张量比较 shape、dtype 与内容 SHA256;外部分片按块读取校验,不为审计再保存一套权重。该审计采用明确支持的基础算子转换规则,未知转换方式应报错,由新增规则和测试后再接受。 | 文件 | 用途 | 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..39d99b7 --- /dev/null +++ b/probes/w3_attention/run.py @@ -0,0 +1,148 @@ +"""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, recheck_sources) +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) + if report["passed"]: + report["stage"] = "source_recheck" + recheck_sources(report) + report["stage"] = "complete" + except Exception as exc: + report.update(passed=False, 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..b9cccd5 --- /dev/null +++ b/probes/w3_common.py @@ -0,0 +1,275 @@ +"""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 +import time + +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 recheck_sources(report, *, provenance_key=None): + """Reject mixed-source runs while retaining the changed file identities.""" + recorded = report if provenance_key is None else report.get(provenance_key, {}) + before = recorded.get("source_sha256") + if not isinstance(before, dict) or not before: + raise ValueError("Missing execution source fingerprints") + after = source_evidence()["source_sha256"] + changed = {name: {"before_sha256": before.get(name), "after_sha256": after.get(name)} + for name in sorted(before.keys() | after.keys()) if before.get(name) != after.get(name)} + report["source_recheck"] = {"passed": not changed, "changed": changed} + if changed: + raise ValueError("Production sources changed during numerical execution") + + +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) + primary_error = None + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + # File scanners/readers can briefly deny replacement of a closed report + # on Windows. Retry only these OS errors, with a bounded total delay; + # permanent permission failures must still fail report publication. + delays = (0.01, 0.03, 0.1, 0.3) + for attempt in range(len(delays) + 1): + try: + os.replace(name, path) + break + except PermissionError as exc: + if (sys.platform != "win32" or getattr(exc, "winerror", None) not in (5, 32, 33) + or attempt == len(delays)): + raise + time.sleep(delays[attempt]) + except BaseException as exc: + primary_error = exc + raise + finally: + try: + os.unlink(name) + except FileNotFoundError: + pass + except OSError as exc: + if primary_error is None: + raise + if hasattr(primary_error, "add_note"): + primary_error.add_note(f"Could not remove pending report: {type(exc).__name__}: {exc}") + +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..2e1c364 --- /dev/null +++ b/probes/w3_qwen3_full/run.py @@ -0,0 +1,296 @@ +"""Full pinned Qwen3 IR/ORT comparison with isolated, resource-bounded workers.""" +from __future__ import annotations + +import argparse +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np +from probes.w1_qwen3_export.run import verify_files +from probes.w3_common import new_output_dir, sha256_file, source_evidence, write_reports +from probes.w3_layer_diff.run import load_schema, load_arrays +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases +from probes.w3_qwen3_full.comparison import ATOL, compare_tensor, compare_positions +from probes.w3_qwen3_full.resources import spawn_owned, wait_bounded + +CHECKPOINTS = ("embedding", *(f"layer_{i}.output" for i in range(28)), "final_norm") +LOGIT_SHAPE = (1, 256, 151936) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def checked_artifact(folder, descriptor, expected_name): + require(isinstance(descriptor, dict), f"Missing artifact {expected_name}") + require(descriptor.get("path") == expected_name, f"Unexpected artifact path: {expected_name}") + path = folder / expected_name + require(path.is_file() and path.resolve().parent == folder.resolve(), f"Missing/external artifact: {expected_name}") + require(path.stat().st_size == descriptor.get("bytes") and sha256_file(path) == descriptor.get("sha256"), + f"Altered artifact: {expected_name}") + return path + + +def load_logits(path): + value = np.load(path, mmap_mode="r", allow_pickle=False, max_header_size=10000) + require(value.shape == LOGIT_SHAPE and value.dtype == np.float32, "Wrong full logits shape/dtype") + return value + + +def saved_arithmetic_profile(mode, recorded): + """Validate saved arithmetic without guessing it from the auditing host CPU.""" + require(isinstance(recorded, dict), "Missing saved FP32 arithmetic profile") + if mode == "reference": + from scratchv.verification.fp32_reference import profile + strategy = recorded.get("cpu_strategy") + require(strategy in ("avx2-fma3", "avx512"), "Unsupported saved FP32 CPU profile") + canonical = profile(cpu_strategy=strategy) + else: + require(mode == "native", "Unsupported saved FP32 arithmetic profile") + canonical = {"name": "numpy-native"} + require(recorded == canonical and all(type(recorded[key]) is type(value) + for key, value in canonical.items()), "Noncanonical saved FP32 arithmetic profile") + return canonical + + +def validate_worker(folder, backend, input_path, sources, assets, *, expected_fp32_mode=None, + expected_fp32_profile=None): + require(expected_fp32_profile is None or expected_fp32_mode is not None, + "Saved FP32 profile requires an explicit mode") + for name in ("report.json", "report.md", "report.html"): + require((folder/name).is_file(), f"Missing worker {name}") + report = json.loads((folder/"report.json").read_text(encoding="utf-8")) + require(report.get("gate") == "numeric:w3-full-worker" and report.get("schema_version") == 1, + "Wrong worker identity") + require(report.get("backend") == backend and report.get("optimization_level") == "none", "Wrong backend") + require(report.get("passed") is True and report.get("status") == "PASS", "Worker not PASS") + require(report.get("full_model_executed") is True and report.get("full_ir_executed") is (backend == "ir"), + "Full model was not actually executed") + require(report.get("w3_exit_accepted") is False, "A worker cannot accept the team milestone") + require(report.get("source_sha256") == sources, "Worker production sources differ") + require(report.get("files") == assets, "Worker assets differ from fixed model") + require(report.get("input", {}).get("sha256") == sha256_file(input_path), "Worker input differs") + require(type(report.get("process_peak_rss_bytes")) is int and report["process_peak_rss_bytes"] > 0, + "Missing measured peak RSS") + timings = report.get("stages_seconds") + require(isinstance(timings, dict), "Missing worker execution timings") + for mode in ("ordinary", "diagnostic"): + name = f"{backend}_{mode}_execute" + duration = timings.get(name) + require(type(duration) in (int, float) and math.isfinite(duration) and duration > 0, + f"Missing/invalid worker execution timing: {name}") + if backend == "ort": + runtime = report.get("ort") + expected_runtime = {"provider": "CPUExecutionProvider", "graph_optimization": "disabled", + "intra_op_threads": 1, "inter_op_threads": 1, "cpu_mem_arena": False} + require(isinstance(runtime, dict) and all(type(runtime.get(key)) is type(value) + and runtime.get(key) == value for key, value in expected_runtime.items()), + "Worker ORT execution profile differs") + comparison = report.get("ordinary_diagnostic", {}) + require(comparison.get("passed") is True and comparison.get("comparison") == "exact" + and comparison.get("max_abs") == 0.0, "Diagnostic execution changed logits") + names = {"logits": "logits.npy", "diagnostic_logits": "diagnostic_logits.npy", + "checkpoints": "checkpoints.npz", "checkpoint_schema": "checkpoint_schema.json"} + artifacts = report.get("artifacts", {}) + for key, filename in names.items(): + checked_artifact(folder, artifacts.get(key), filename) + schema = load_schema(folder/"checkpoint_schema.json") + require(tuple(x["name"] for x in schema) == CHECKPOINTS, "Incomplete/out-of-order full checkpoints") + require(all(x["shape"] == [1, 256, 1024] and x["dtype"] == "float32" and x.get("sequence_axis") == 1 + for x in schema), "Wrong full checkpoint contract") + require(report.get("checkpoints") == schema, "Worker report/schema disagree") + ordinary, diagnostic = load_logits(folder/"logits.npy"), load_logits(folder/"diagnostic_logits.npy") + exact = compare_tensor(ordinary, diagnostic) + require(exact["passed"] and exact["max_abs"] == 0, "Ordinary/diagnostic arrays actually differ") + del ordinary, diagnostic + if backend == "ir": + ir = report.get("ir") + require(isinstance(ir, dict), "Missing IR execution profile") + instruction_count = ir.get("instruction_count") + require(type(instruction_count) is int and instruction_count > 0, + "Missing/invalid IR instruction count") + if expected_fp32_mode is not None: + from scratchv.verification.fp32_reference import profile + require(report.get("fp32_mode") == expected_fp32_mode, + "Worker FP32 execution mode differs") + # Live runs require the selected local strategy. Saved-array audits + # can explicitly supply a known producer strategy from another CPU; + # the complete canonical profile still has to match. + expected_profile = (saved_arithmetic_profile(expected_fp32_mode, expected_fp32_profile) + if expected_fp32_profile is not None else + profile() if expected_fp32_mode == "reference" else {"name": "numpy-native"}) + require(ir.get("fp32_mode") == expected_fp32_mode + and ir.get("fp32_profile") == expected_profile, + "Worker FP32 arithmetic profile differs") + for mode in ("ordinary", "diagnostic"): + execution = report.get("executions", {}).get(mode, {}) + # The pinned full graph is one straight-line block, including RETURN; + # every declared instruction must execute once in both runs. + require(type(execution.get("executed_steps")) is int + and execution["executed_steps"] == instruction_count, + "IR execution steps do not cover the declared instruction count") + memory = execution.get("memory_stats", {}) + require(type(memory.get("peak_numpy_storage_bytes")) is int and memory["peak_numpy_storage_bytes"] > 0, + "Missing IR storage measurement") + return report + + +def compare_case(folder, valid_length): + rows = [] + for filename in ("logits.npy", "diagnostic_logits.npy"): + actual, reference = load_logits(folder/"ir"/filename), load_logits(folder/"ort"/filename) + rows.append({"name": filename, **compare_positions(actual, reference, valid_length)}) + del actual, reference + schema = load_schema(folder/"ir/checkpoint_schema.json") + require(schema == load_schema(folder/"ort/checkpoint_schema.json"), "IR/ORT checkpoint schema differs") + actual = load_arrays(folder/"ir/checkpoints.npz", schema, max_bytes=64*1024**2) + reference = load_arrays(folder/"ort/checkpoints.npz", schema, max_bytes=64*1024**2) + checkpoints = [{"name": entry["name"], **compare_positions(actual[entry["name"]], reference[entry["name"]], valid_length)} + for entry in schema] + all_rows = checkpoints + rows + worst = max((r for r in all_rows if r["max_abs"] is not None), key=lambda x: x["max_abs"], default=None) + return {"passed": all(r["passed"] for r in rows), "logits": rows, "checkpoints": checkpoints, + "max_logits_abs": max(r["max_abs"] for r in rows), + "checkpoint_threshold_scope": "Diagnostic location only: large intermediate activations can exceed 1e-4 in one FP32 ULP; acceptance applies to complete output logits", + "first_divergence": next((r for r in all_rows if not r["passed"]), None), + "worst": worst, "atol": ATOL, "rtol": 0} + + +def invariants(out, successful): + rows = [] + for name, reference, prefix in (("changed_future", "full_seed_0", 64), ("changed_padding", "short_17", 17)): + if name not in successful or reference not in successful: + continue + for backend in ("ort", "ir"): + a = load_logits(out/name/backend/"logits.npy") + b = load_logits(out/reference/backend/"logits.npy") + rows.append({"case": name, "reference": reference, "prefix": prefix, "backend": backend, + **compare_tensor(a[:, :prefix], b[:, :prefix])}) + del a, b + return rows + + +def run_worker(args, folder, backend, input_path, row=None): + # Keep the selected venv symlink: resolving it runs the base interpreter. + command = [str(Path(args.python).absolute()), "-B", "-X", "utf8", + "probes/w3_qwen3_full/worker.py", "--backend", backend, + "--model-dir", str(args.model_dir.resolve()), "--input-file", str(input_path), + "--output-dir", str(folder/backend), "--optimization-level", "none", + "--fp32-mode", getattr(args, "fp32_mode", "reference")] + row = {} if row is None else row + row.update(backend=backend, passed=False, command=command) + env = dict(os.environ, PYTHONIOENCODING="utf-8", OMP_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1", MKL_NUM_THREADS="1") + options = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True} + with (folder/(backend+".log")).open("w", encoding="utf-8") as stream: + process = spawn_owned(command, cwd=ROOT, env=env, stdout=stream, stderr=subprocess.STDOUT, **options) + try: + row["returncode"] = wait_bounded(process, timeout=args.worker_timeout, + max_memory_bytes=int(args.max_worker_memory_gib*1024**3), row=row) + require(row["returncode"] == 0, f"{backend} worker exited {row['returncode']}") + row["passed"] = True + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + for filename in ("report.json", "progress.json"): + try: + child = json.loads((folder/backend/filename).read_text(encoding="utf-8")) + row["child_stage"] = child.get("stage") + row["child_error"] = child.get("error") + break + except (OSError, ValueError): + pass + return row + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-dir", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--python", default=sys.executable) + parser.add_argument("--worker-timeout", type=float, default=1800) + parser.add_argument("--max-worker-memory-gib", type=float, default=10) + parser.add_argument("--case", action="append", choices=CASE_NAMES) + parser.add_argument("--fp32-mode", choices=("native", "reference"), default="reference", + help="Explicit IR FP32 arithmetic policy; native reproduces the original NumPy baseline") + args = parser.parse_args(argv) + if any(not math.isfinite(v) or v <= 0 for v in (args.worker_timeout, args.max_worker_memory_gib)): + parser.error("Resource limits must be finite and positive") + if args.case and len(args.case) != len(set(args.case)): + parser.error("Duplicate case selection") + out = new_output_dir(args.output_dir) + selected = set(args.case or CASE_NAMES) + start = time.perf_counter() + report = {"gate": "numeric:ir-full-qwen3", "status": "FAIL", "passed": False, + "optimization_level": "none", "atol": ATOL, "rtol": 0, + "fp32_mode": args.fp32_mode, + "full_ir_executed": False, "w3_exit_accepted": False, + "scope": "Pinned 28-layer FP32 L256 IR vs ORT; strict 1e-4 on all ordinary/diagnostic logits including padding; 30 checkpoints localize errors", + "cases": [], "invariants": [], "required_cases": list(CASE_NAMES), + "selected_cases": [x for x in CASE_NAMES if x in selected], "stage": "sources"} + try: + report.update(source_evidence()) + report["stage"] = "assets" + assets = verify_files(args.model_dir) + report["files"] = assets + successful = set() + for name, valid, feed in input_cases(): + if name not in selected: + continue + report["stage"] = name + folder = out/name + folder.mkdir() + input_path = folder/"inputs.npz" + np.savez(input_path, **feed) + row = {"name": name, "valid_length": valid, "passed": False, "workers": [], + "input_sha256": sha256_file(input_path)} + report["cases"].append(row) + for backend in ("ort", "ir"): + worker = {"backend": backend, "passed": False} + row["workers"].append(worker) + run_worker(args, folder, backend, input_path, worker) + if worker["passed"]: + try: + evidence = validate_worker(folder/backend, backend, input_path, + report["source_sha256"], assets, + expected_fp32_mode=args.fp32_mode) + require(evidence["input"]["valid_length"] == valid, "Worker valid length differs") + worker.update(report_sha256=sha256_file(folder/backend/"report.json"), + process_peak_rss_bytes=evidence["process_peak_rss_bytes"], + stages_seconds=evidence["stages_seconds"]) + if backend == "ir": + report["full_ir_executed"] = True + except Exception as exc: + worker.update(passed=False, error=f"{type(exc).__name__}: {exc}") + print(f"[{name}/{backend}] {'EXECUTED' if worker['passed'] else 'FAIL'}", flush=True) + if all(w["passed"] for w in row["workers"]): + row["comparison"] = compare_case(folder, valid) + row["passed"] = row["comparison"]["passed"] + successful.add(name) + print(f"[{name}/compare] {'PASS' if row['passed'] else 'FAIL'}; logits_max_abs={row['comparison']['max_logits_abs']}; diagnostic_max_abs={row['comparison']['worst']['max_abs']}", flush=True) + write_reports(out, report) + report["invariants"] = invariants(out, successful) + require(source_evidence()["source_sha256"] == report["source_sha256"], "Sources changed during full execution") + coverage = set(selected) == set(CASE_NAMES) and len(report["cases"]) == len(CASE_NAMES) and len(report["invariants"]) == 4 + numeric = len(report["cases"]) == len(selected) and all(row["passed"] for row in report["cases"]) and all(x["passed"] for x in report["invariants"]) + report.update(coverage_complete=coverage, selected_numerical_passed=numeric, + passed=coverage and numeric, status="PASS" if coverage and numeric else ("PARTIAL" if numeric else "FAIL"), stage="complete") + except KeyboardInterrupt: + report.update(status="FAIL", passed=False, interrupted=True, error="KeyboardInterrupt: full verification cancelled") + except Exception as exc: + report.update(status="FAIL", passed=False, error=f"{type(exc).__name__}: {exc}") + report["elapsed_seconds"] = time.perf_counter() - start + write_reports(out, report) + return 130 if report.get("interrupted") else (0 if report["passed"] else (2 if report["status"] == "PARTIAL" else 1)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_full/worker.py b/probes/w3_qwen3_full/worker.py new file mode 100644 index 0000000..e86e297 --- /dev/null +++ b/probes/w3_qwen3_full/worker.py @@ -0,0 +1,327 @@ +"""Execute one complete pinned Qwen3 backend in an isolated process. + +A worker PASS proves execution and ordinary/diagnostic consistency only. The +parent compares independent IR and ORT results; no worker accepts W3 itself. +""" +from __future__ import annotations + +import argparse +import gc +import json +import os +from pathlib import Path +import sys +import tempfile +import time +import traceback + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np +import onnx + +from probes.w1_qwen3_export import run as assets +from probes.w2_qwen3_parse.validation import audit_graph_structure +from probes.w3_common import (atomic_text, new_output_dir, process_peak_rss_bytes, + sha256_file, source_evidence, write_reports) +from probes.w3_layer_diff.run import load_arrays + +GATE = "numeric:w3-full-worker" +INPUT_SCHEMA = [ + {"name": "input_ids", "shape": [1, 256], "dtype": "int64"}, + {"name": "attention_mask", "shape": [1, 1, 256, 256], "dtype": "float32"}, +] +LOGITS_SHAPE = (1, 256, 151936) + + +def load_inputs(path): + """Validate the pinned export's finite additive, right-padded causal mask.""" + feed = load_arrays(Path(path), INPUT_SCHEMA, max_bytes=1024 * 1024) + ids, mask = feed["input_ids"], feed["attention_mask"] + if np.any(ids < 0) or np.any(ids >= 151936): + raise ValueError("input_ids must be in the fixed Qwen3 vocabulary") + length = int(np.count_nonzero(mask[0, 0, -1] == 0)) + if not 1 <= length <= 256: + raise ValueError("attention_mask must expose at least one valid token") + query, key = np.arange(256)[:, None], np.arange(256)[None, :] + wanted = np.where((key <= query) & (key < length), np.float32(0), + np.finfo(np.float32).min).reshape(1, 1, 256, 256) + if not np.array_equal(mask, wanted): + raise ValueError("attention_mask is not the pinned causal/right-padding additive mask") + return feed, length + + +def checkpoint_schema(model, audit=None): + """Locate checkpoints from audited dataflow, never layer-name substrings.""" + audit = audit_graph_structure(model) if audit is None else audit + layers = audit.get("layers", []) + if (audit.get("passed") is not True or audit.get("layer_count") != 28 + or [row.get("layer") for row in layers] != list(range(28))): + raise ValueError("Checkpoint coverage requires the complete ordered 28-layer audit") + by_name = {} + for node in model.graph.node: + by_name.setdefault(node.name, []).append(node) + + def output(name): + nodes = by_name.get(name, []) + if len(nodes) != 1 or len(nodes[0].output) != 1: + raise ValueError(f"Checkpoint node must be unique with one output: {name}") + return nodes[0].output[0] + + rows = [{"name": "embedding", "onnx_name": output(audit["embedding"]), + "checkpoint": "embedding"}] + rows += [{"name": f"layer_{row['layer']}.output", "onnx_name": row["output"], + "layer": row["layer"], "checkpoint": "output"} for row in layers] + rows += [{"name": "final_norm", "onnx_name": output(audit["final_norm"]), + "checkpoint": "final_norm"}] + definitions = {name for node in model.graph.node for name in node.output} + names = [row["onnx_name"] for row in rows] + if len(names) != len(set(names)) or not set(names) <= definitions: + raise ValueError("Checkpoint tensors must be unique graph-produced values") + for row in rows: + row.update(shape=[1, 256, 1024], dtype="float32", sequence_axis=1) + return rows + + +def checked_tensor(array, shape, name): + if not isinstance(array, np.ndarray) or array.shape != tuple(shape) or array.dtype != np.float32: + raise ValueError(f"{name}: expected FP32 {tuple(shape)}, got " + f"{getattr(array, 'shape', None)}/{getattr(array, 'dtype', None)}") + for start in range(0, shape[1], 8): + if not np.isfinite(array[:, start:start + 8]).all(): + raise ValueError(f"{name}: nonfinite output") + return array + + +def save_array(path, value, *, archive=False): + """Atomically persist numeric arrays; leave existing evidence untouched.""" + path = Path(path) + if path.exists(): + raise FileExistsError(path) + fd, temporary = tempfile.mkstemp(prefix="." + path.name, suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + if archive: + np.savez(stream, **value) + else: + np.save(stream, value, allow_pickle=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def evidence(path, *, array=None): + path = Path(path) + record = {"path": path.name, "sha256": sha256_file(path), "bytes": path.stat().st_size} + if array is not None: + record.update(shape=list(array.shape), dtype=str(array.dtype), all_finite=True) + return record + + +def save_logits(out, name, array, report): + checked_tensor(array, LOGITS_SHAPE, name) + path = out / f"{name}.npy" + save_array(path, array) + report["artifacts"][name] = evidence(path, array=array) + + +def save_checkpoints(out, arrays, schema, report): + if set(arrays) != {row["name"] for row in schema} or len(arrays) != 30: + raise ValueError("Diagnostic output omitted/duplicated a required checkpoint") + for row in schema: + checked_tensor(arrays[row["name"]], row["shape"], row["name"]) + path = out / "checkpoints.npz" + save_array(path, arrays, archive=True) + report["artifacts"]["checkpoints"] = evidence(path) + + +def exact_ordinary_diagnostic(out): + ordinary = np.load(out / "logits.npy", mmap_mode="r", allow_pickle=False) + diagnostic = np.load(out / "diagnostic_logits.npy", mmap_mode="r", allow_pickle=False) + checked_tensor(ordinary, LOGITS_SHAPE, "ordinary logits") + checked_tensor(diagnostic, LOGITS_SHAPE, "diagnostic logits") + for start in range(0, LOGITS_SHAPE[1], 8): + if not np.array_equal(ordinary[:, start:start + 8], diagnostic[:, start:start + 8]): + raise ValueError(f"Ordinary and diagnostic logits differ at sequence chunk {start}") + return {"passed": True, "comparison": "exact", "max_abs": 0.0, + "positions_compared": 256, "elements_compared": int(np.prod(LOGITS_SHAPE))} + + +def diagnostic_graph(model, schema, model_dir, out): + """Add graph outputs without re-exporting or copying the external weights.""" + model = onnx.ModelProto.FromString(model.SerializeToString()) + for row in schema: + model.graph.output.append(onnx.helper.make_tensor_value_info( + row["onnx_name"], onnx.TensorProto.FLOAT, row["shape"])) + # External references are now relative to the derived metadata's directory. + # Source manifest verification already bounded and validated these files. + for tensor in assets.tensors(model): + if tensor.data_location == onnx.TensorProto.EXTERNAL: + for entry in tensor.external_data: + if entry.key == "location": + original = (model_dir / entry.value).resolve() + if not original.is_relative_to(model_dir.resolve()) or not original.is_file(): + raise ValueError("Invalid external tensor location in diagnostic model") + entry.value = os.path.relpath(original, out).replace("\\", "/") + target = out / "diagnostic.onnx" + target.write_bytes(model.SerializeToString()) + return target + + +def execute_ort(model_dir, out, feed, schema, model, report, stage): + import onnxruntime as ort + + def session(path): + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + options.enable_cpu_mem_arena = False + return ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"]) + + runtime = stage("ort_ordinary_session", lambda: session(model_dir / "model.onnx")) + logits = stage("ort_ordinary_execute", lambda: runtime.run(["logits"], feed)[0]) + stage("ordinary_save", lambda: save_logits(out, "logits", logits, report)) + del logits, runtime + gc.collect() + diagnostic = stage("diagnostic_graph", lambda: diagnostic_graph(model, schema, model_dir, out)) + report["artifacts"]["diagnostic_model"] = evidence(diagnostic) + runtime = stage("ort_diagnostic_session", lambda: session(diagnostic)) + names = ["logits", *[row["onnx_name"] for row in schema]] + results = stage("ort_diagnostic_execute", lambda: runtime.run(names, feed)) + if len(results) != 31: + raise ValueError("ORT diagnostic did not return all 31 outputs") + stage("diagnostic_save", lambda: save_logits(out, "diagnostic_logits", results[0], report)) + arrays = {row["name"]: value for row, value in zip(schema, results[1:])} + stage("checkpoints_save", lambda: save_checkpoints(out, arrays, schema, report)) + report["ort"] = {"provider": "CPUExecutionProvider", "graph_optimization": "disabled", + "intra_op_threads": 1, "inter_op_threads": 1, "cpu_mem_arena": False} + + +def execute_ir(model_dir, out, feed, schema, model, report, stage): + from scratchv.frontend.onnx_parser import ONNXParser + from scratchv.verification.ir_interpreter import IRInterpreter + from scratchv.verification.fp32_reference import profile + + fp32_mode = report.get("fp32_mode", "reference") + + parser = ONNXParser() + program = stage("scratchv_parse", lambda: parser.parse(str(model_dir / "model.onnx"), + mmap_external_data=True)) + for value in parser.initializers.values(): + value.setflags(write=False) + mapping = {} + for row in schema: + value = parser._value_map.get(row["onnx_name"]) + if value is None or value.name in mapping: + raise ValueError(f"Missing/non-unique IR checkpoint mapping: {row['name']}") + mapping[value.name] = row + report["ir_checkpoint_bindings"] = {name: row["name"] for name, row in mapping.items()} + report["executions"] = {} + for kind in ("ordinary", "diagnostic"): + arrays = {} + + def observe(name, value): + if name in mapping: + row = mapping[name] + if row["name"] in arrays: + raise ValueError(f"Checkpoint executed multiple times: {row['name']}") + arrays[row["name"]] = checked_tensor(value, row["shape"], row["name"]).copy() + + result = stage(f"ir_{kind}_execute", lambda: IRInterpreter(program).run( + feed, initializers=parser.initializers, collect_memory_stats=True, + memory_mode="last_use", copy_initializers=False, + fp32_mode=fp32_mode, + observer=observe if kind == "diagnostic" else None)) + report["executions"][kind] = {"executed_steps": result.executed_steps, + "memory_stats": result.memory_stats} + label = "logits" if kind == "ordinary" else "diagnostic_logits" + stage(f"{kind}_save", lambda: save_logits(out, label, result.return_value, report)) + if kind == "diagnostic": + stage("checkpoints_save", lambda: save_checkpoints(out, arrays, schema, report)) + del result, arrays + gc.collect() + report["ir"] = {"optimization_level": "none", "memory_mode": "last_use", + "fp32_mode": fp32_mode, + "fp32_profile": profile() if fp32_mode == "reference" else {"name": "numpy-native"}, + "copy_initializers": False, "mmap_external_data": True, + "instruction_count": sum(len(b.instructions) for f in program.functions for b in f.blocks)} + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--backend", choices=("ort", "ir"), required=True) + cli.add_argument("--model-dir", type=Path, required=True) + cli.add_argument("--input-file", type=Path, required=True) + cli.add_argument("--output-dir", type=Path, required=True) + cli.add_argument("--optimization-level", choices=("none",), default="none") + cli.add_argument("--fp32-mode", choices=("native", "reference"), default="reference") + args = cli.parse_args(argv) + out = new_output_dir(args.output_dir) + started = time.perf_counter() + report = {"schema_version": 1, "gate": GATE, "backend": args.backend, + "optimization_level": args.optimization_level, "status": "FAIL", "passed": False, + "fp32_mode": args.fp32_mode if args.backend == "ir" else None, + "stage": "initialization", "stages_seconds": {}, "artifacts": {}, + "full_ir_executed": False, "full_model_executed": False, "w3_exit_accepted": False, + "scope": "Complete single-backend execution and exact ordinary/diagnostic consistency. " + "Independent IR/ORT comparison belongs to the parent gate; not W3 team acceptance.", + "model_id": assets.MANIFEST["model_id"], "revision": assets.MANIFEST["revision"]} + + def stage(name, operation): + report["stage"] = name + atomic_text(out / "progress.json", json.dumps({"stage": name, + "elapsed_seconds": time.perf_counter() - started, + "process_peak_rss_bytes": process_peak_rss_bytes()}) + "\n") + print(f"[{args.backend}] {name}", flush=True) + begin = time.perf_counter() + try: + return operation() + finally: + report["stages_seconds"][name] = time.perf_counter() - begin + + try: + report.update(stage("source_evidence", source_evidence)) + model_dir = args.model_dir.resolve() + report["model_dir"] = str(model_dir) + report["files"] = stage("asset_hashes", lambda: assets.verify_files(model_dir)) + report["onnx_contract"] = stage("onnx_contract", lambda: assets.inspect_model(model_dir, report["files"])) + feed, length = stage("input_validation", lambda: load_inputs(args.input_file)) + report["input"] = evidence(args.input_file) + report["input"].update(path=str(args.input_file.resolve()), valid_length=length) + model = stage("metadata_load", lambda: onnx.load(str(model_dir / "model.onnx"), load_external_data=False)) + report["graph_audit"] = stage("graph_audit", lambda: audit_graph_structure(model)) + schema = stage("checkpoint_selection", lambda: checkpoint_schema(model, report["graph_audit"])) + report["checkpoints"] = schema + atomic_text(out / "checkpoint_schema.json", json.dumps({"version": 1, "checkpoints": schema}, indent=2) + "\n") + report["artifacts"]["checkpoint_schema"] = evidence(out / "checkpoint_schema.json") + executor = execute_ort if args.backend == "ort" else execute_ir + executor(model_dir, out, feed, schema, model, report, stage) + report["full_model_executed"] = True + report["full_ir_executed"] = args.backend == "ir" + report["ordinary_diagnostic"] = stage("ordinary_diagnostic_consistency", lambda: exact_ordinary_diagnostic(out)) + final_sources = stage("source_recheck", source_evidence)["source_sha256"] + if final_sources != report["source_sha256"]: + raise ValueError("Production sources changed during numerical execution") + report.update(passed=True, status="PASS", stage="complete") + except BaseException as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + traceback.print_exc() + if isinstance(exc, KeyboardInterrupt): + report["interrupted"] = True + report.update(elapsed_seconds=time.perf_counter() - started, + process_peak_rss_bytes=process_peak_rss_bytes(), + peak_scope="Lifetime resident high-water mark of this single backend worker, " + "including both ordinary and diagnostic runs; not isolated kernel memory") + write_reports(out, report) + print(f"[{GATE}] {report['status']}; {out / 'report.json'}", flush=True) + return 130 if report.get("interrupted") else 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w3_qwen3_medium/README.md b/probes/w3_qwen3_medium/README.md new file mode 100644 index 0000000..c06072d --- /dev/null +++ b/probes/w3_qwen3_medium/README.md @@ -0,0 +1,110 @@ +# W3 medium Qwen3 host numerical probe + +Reproduction targets **Ubuntu 24.04 x86_64, Bash and Python 3.12**. Complete the [Linux environment and asset setup](../../docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md) first. Run commands from the repository root in the same Bash session; the setup exports `SCRATCHV_PYTHON`, `SCRATCHV_CC` and `SCRATCHV_QEMU`. + +This is a fixed candidate for the W3 six-layer experiment. The plan fixes six +layers; the dimensions below are the candidate chosen for this experiment, +not a claim that the plan mandated a specific hidden or feed-forward width. + +| Property | W2 small default | W3 medium candidate | +|---|---:|---:| +| Decoder layers | 2 | 6 | +| Hidden width | 32 | 64 | +| FFN intermediate width | 96 | 192 | +| Query heads / KV heads | 4 / 2 | 4 / 2 | +| Head dimension | 16 | 16 | +| Vocabulary / sequence length | 128 / 256 | 128 / 256 | +| Batch / precision | 1 / FP32 | 1 / FP32 | +| Named checkpoints | 29 | 81 | + +Weights are fixed-seed random initialization of the official +`transformers==4.51.3` `Qwen3ForCausalLM`. No weights or tokenizer are downloaded. +This gate measures architecture and numerical semantics. It does not establish +pretrained language quality, full Qwen3-0.6B execution, or RISC-V/QEMU correctness. +Attention is eager, positions are `0..255`, embeddings and LM head are tied, and +there is no KV cache. All numerical backends use one CPU thread. + +## Run + +Use Python 3.12 and the existing pinned +`requirements/qwen3-small-probe.txt` environment. From the W3 worktree: + +```bash +set -euo pipefail +export OMP_NUM_THREADS='1' +export OPENBLAS_NUM_THREADS='1' +export MKL_NUM_THREADS='1' +"$SCRATCHV_PYTHON" -m probes.w3_qwen3_medium.run --output-dir output/w3-medium --model-seed 0 +``` + +The output directory must not already exist, even if it is empty. Preserve a +failed directory and choose a new path for a retry. The runner exits 0 only if the entire gate passes; numeric, +parsing, optimization, execution, and missing-reference failures exit 1. + +## Acceptance and evidence + +The gate uses the existing seven W2 inputs: two full random sequences, lengths +1, 17, and 255 with right padding, an altered future suffix, and altered masked +padding tokens. It exports the official model with named observations, then +produces ordinary logits-only and diagnostic packed-output ONNX graphs. + +Both graphs run in ORT with graph optimization disabled and in ScratchV IR at +`none`, `basic`, and `all`. Every IR path compares against both PyTorch and its +own ORT graph. Ordinary graph, diagnostic graph, and every optimization level +record independent results. One failure cannot be masked by another passing +path. Independent NumPy GQA/probability/context and blocked-attention checks +also run against PyTorch. + +The report uses uppercase `PASS` / `FAIL`. Its `coverage_complete` field requires +the exact seven named cases and lengths, both ORT graphs, all six IR variants +per case, and all 18 named/backend/graph isolation checks. Missing or duplicated +evidence cannot pass by reducing the set of checks being summarized. + +All comparisons require matching shape and dtype, finite values, and strict +`max_abs < 1e-5`, with `rtol=0`. The threshold is fixed and is not a CLI option. +All positions are included, with valid-token and padding-query errors reported +separately. Causal and padding isolation compare the valid prefix at **every +checkpoint**, for PyTorch, both ORT graphs, and all six IR variants. + +Checkpoint count is `3 + 13 * num_hidden_layers`: embedding, 13 observations per +layer, final norm, and logits. Shapes are derived from the actual model config, +including query/KV head count, head dimension, hidden width, vocabulary, and +sequence length. `probes/w2_qwen3_small/model.py` retains its two-layer defaults +and accepts explicit `model_config` overrides plus `sequence_length`. + +The output contains: + +- `model.onnx`, `checkpoints.onnx`, and `diagnostics.onnx`. +- `config.json`, `checkpoint_metadata.json`, packed `checkpoints.json`, and + versioned named-NPZ `trace_schema.json` / `logits_schema.json`. +- `inputs_.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..fb3efb9 --- /dev/null +++ b/probes/w3_qwen3_medium/run.py @@ -0,0 +1,414 @@ +"""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, recheck_sources + 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) + if report["passed"]: + report["stage"] = "source_recheck" + recheck_sources(report, provenance_key="provenance") + report["stage"] = "complete" + 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..2a948e0 --- /dev/null +++ b/probes/w3_qwen3_subgraphs/run.py @@ -0,0 +1,305 @@ +#!/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, recheck_sources) +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) + if report.get("numerical_passed") or report["passed"]: + report["stage"] = "source_recheck" + recheck_sources(report) + report["stage"] = "complete" + 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/probes/w4_qwen3_full/__init__.py b/probes/w4_qwen3_full/__init__.py new file mode 100644 index 0000000..2ae1a0d --- /dev/null +++ b/probes/w4_qwen3_full/__init__.py @@ -0,0 +1 @@ +"""W4 complete model external-weight RV64 probe.""" diff --git a/probes/w4_qwen3_full/audit.py b/probes/w4_qwen3_full/audit.py new file mode 100644 index 0000000..7dfb9e6 --- /dev/null +++ b/probes/w4_qwen3_full/audit.py @@ -0,0 +1,107 @@ +"""Recompute W4 numeric evidence from retained raw arrays, without QEMU or ORT. + +This proves consistency of supplied evidence only. It does not authenticate a +self-authored report or replace independent compiler/runtime reproduction. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.w3_common import sha256_file +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases +from probes.w3_qwen3_full.worker import load_inputs +from probes.w4_qwen3_full.run import compare_logits, write_reports +from scratchv.backend.tensor_c_codegen import TensorSpec +from scratchv.ir.types import DataType +from scratchv.runtime.riscv_external import decode_completion +from scratchv.runtime.riscv_tensor import pack_inputs + + +SHAPE = (1, 256, 151936) +NBYTES = int(np.prod(SHAPE)) * 4 +SPECS = (TensorSpec("input_ids", DataType.INT64, (1, 256)), + TensorSpec("attention_mask", DataType.FLOAT32, (1, 1, 256, 256))) + + +def audit(directory): + directory = Path(directory).resolve() + report = json.loads((directory / "report.json").read_text(encoding="utf-8")) + required = {"build:riscv-full", "smoke:qemu-full", "numeric:qemu-full-qwen3"} + if (report.get("passed") is not True or report.get("status") != "PASS" + or report.get("full_qemu_forward_executed") is not True + or any(report.get("gates", {}).get(name) is not True for name in required)): + raise ValueError("Source report does not claim all full-model gates passed") + coverage = report.get("coverage") + wanted = list(CASE_NAMES) if coverage == "all" else [coverage] + if not wanted or any(name not in CASE_NAMES for name in wanted): + raise ValueError("Invalid source coverage") + if [case.get("name") for case in report.get("cases", [])] != wanted: + raise ValueError("Case records do not match declared coverage") + fixed_inputs = {name: (length, feed) for name, length, feed in input_cases()} + results = [] + for case in report["cases"]: + out = directory / case["name"] + for filename, expected in (("inputs.npz", case["input_sha256"]), + ("ort.npy", case["ort_sha256"]), + ("qemu/output.bin", case["qemu"]["output_sha256"]), + ("qemu/inputs.bin", case["qemu"]["input_sha256"])): + if sha256_file(out / filename) != expected: + raise ValueError(f"Retained artifact hash mismatch: {case['name']}/{filename}") + feed, length = load_inputs(out / "inputs.npz") + fixed_length, fixed_feed = fixed_inputs[case["name"]] + if (type(case["valid_length"]) is not int or length != fixed_length + or case["valid_length"] != fixed_length + or set(feed) != set(fixed_feed) + or any(not np.array_equal(feed[name], value) for name, value in fixed_feed.items())): + raise ValueError("Retained input does not match the fixed input case") + if pack_inputs(SPECS, feed) != (out / "qemu/inputs.bin").read_bytes(): + raise ValueError("Retained raw loader input differs from reference feed") + qemu = json.loads((out / "qemu/run.json").read_text(encoding="utf-8")) + if (json.dumps(qemu, sort_keys=True) != json.dumps(case["qemu"], sort_keys=True) + or qemu.get("guest_completed") is not True or type(qemu.get("exit_code")) is not int + or qemu["exit_code"] != 0 or qemu.get("passed") is not True + or qemu.get("status") != "PASS" or qemu.get("cleanup_errors") or qemu.get("error")): + raise ValueError("Retained run record differs or has no guest completion") + decode_completion((out / "qemu/uart.bin").read_bytes(), NBYTES) + if (out / "qemu/output.bin").stat().st_size != NBYTES: + raise ValueError("QEMU output length differs from full logits contract") + actual = np.memmap(out / "qemu/output.bin", dtype=" +#include +int scratchv_run_external(const void *const inputs[], const void *const weights[], + void *workspace, size_t workspace_bytes, void *output) { + const int64_t *bits = (const int64_t *)inputs[0]; + int64_t *rows = (int64_t *)output; + (void)weights; (void)workspace; (void)workspace_bytes; + for (size_t t = 0; t < 2; ++t) { + union { uint32_t bits; float f; } a, b, c, result; + a.bits = (uint32_t)bits[t*3]; b.bits = (uint32_t)bits[t*3+1]; + c.bits = (uint32_t)bits[t*3+2]; + for (size_t i = 0; i < 6; ++i) { + unsigned long requested = (i == 1 || i == 4) ? 1 : 0; + unsigned long before, after; + if (i < 3) { + __asm__ volatile("csrw fcsr, %3\n\tcsrr %0, fcsr\n\t" + "fmadd.s %1, %4, %5, %6, rne\n\tcsrr %2, fcsr" + : "=&r"(before), "=&f"(result.f), "=&r"(after) + : "r"(requested), "f"(a.f), "f"(b.f), "f"(c.f) : "memory"); + } else { + __asm__ volatile("csrw fcsr, %3\n\tcsrr %0, fcsr\n\t" + "fmadd.s %1, %4, %5, %6, dyn\n\tcsrr %2, fcsr" + : "=&r"(before), "=&f"(result.f), "=&r"(after) + : "r"(requested), "f"(a.f), "f"(b.f), "f"(c.f) : "memory"); + } + size_t offset = (t*6+i)*5; + rows[offset] = i < 3 ? 0 : 7; + rows[offset+1] = requested; rows[offset+2] = before; + rows[offset+3] = result.bits; rows[offset+4] = after; + } + } + return 0; +} +''' + + +def exact_value(bits): + """Decode a finite binary32 bit pattern to an exact rational, no FP math.""" + sign, exponent, significand = bits >> 31, (bits >> 23) & 255, bits & 0x7FFFFF + if exponent == 255: + raise ValueError("Finite binary32 required") + if exponent: + significand |= 1 << 23 + shift = exponent - 150 if exponent else -149 + return (-1 if sign else 1) * Fraction(significand) * Fraction(2) ** shift + + +def verify_oracle(): + """Prove each recorded result is the unique nearest representable value.""" + for case in CASES: + a, b, c = map(exact_value, case["input_bits"]) + exact = a*b+c + bits = case["expected_bits"] + difference = abs(exact - exact_value(bits)) + if not all(difference < abs(exact - exact_value(neighbor)) for neighbor in (bits-1, bits+1)): + raise AssertionError("The fixed FMA oracle is not uniquely nearest") + if int(difference != 0) != case["inexact"]: + raise AssertionError("Incorrect oracle inexact flag") + + +def artifact(): + return TensorCArtifact(SOURCE, (TensorSpec("bits", DataType.INT64, (2, 3)),), + TensorSpec("observations", DataType.INT64, (2, 6, 5)), 0, 0, + function_name="scratchv_run_external", constant_storage="external") + + +def compare_observations(observations): + verify_oracle() + observations = np.asarray(observations) + if observations.shape != (2, 6, 5) or observations.dtype != np.dtype("int64"): + raise ValueError("FMA observations require int64 [2,6,5]") + rows = [] + for t, case in enumerate(CASES): + for i, (rm, initial_flags) in enumerate(MODES): + actual_rm, requested, before, bits, after = map(int, observations[t, i]) + expected_after = initial_flags | case["inexact"] + bits_match = bits == case["expected_bits"] + csr_match = (actual_rm == rm and requested == initial_flags + and before == initial_flags and after == expected_after) + rows.append({"case": case["name"], "repeat": i, "instruction_rm": rm, + "requested_fflags": initial_flags, "fcsr_before": before, "fcsr_after": after, + "expected_fcsr_after": expected_after, "actual_bits": f"0x{bits:08x}", + "expected_bits": f"0x{case['expected_bits']:08x}", + "bits_match": bits_match, "csr_match": csr_match, + "passed": bits_match and csr_match}) + return {"passed": all(row["passed"] for row in rows), "criterion": "exact binary32 bits and FCSR", + "rows": rows, "failed_rows": sum(not row["passed"] for row in rows)} + + +def fmadd_encodings(elf_path): + """Record actual single-precision FMADD encodings in executable ELF sections.""" + data = Path(elf_path).read_bytes() + if len(data) < 64 or data[:7] != b"\x7fELF\x02\x01\x01": + raise ValueError("Expected little-endian ELF64") + section_offset = struct.unpack_from(" len(data): + raise ValueError("Invalid ELF section headers") + result = [] + for i in range(count): + header = section_offset + i*entry_size + flags, address, offset, size = struct.unpack_from(" len(data): + raise ValueError("Truncated executable ELF section") + position = 0 + while position+2 <= size: + half = struct.unpack_from(" size: + raise ValueError("Unsupported/truncated RV64 instruction") + word = struct.unpack_from("> 25) & 3 == 0: + result.append({"address": hex(address+position), "word": f"0x{word:08x}", + "rm": (word >> 12) & 7}) + position += length + if not {0, 7} <= {row["rm"] for row in result}: + raise ValueError("ELF must contain both static RNE and dynamic FMADD.S") + return result + + +def run_probe(out, *, cc=None, qemu=None, timeout=30.0): + out = new_output_dir(out) + started = time.perf_counter() + report = {"schema_version": 1, "passed": False, "status": "FAIL", "stage": "oracle", + "scope": "RV64 FMADD.S conformance, not model accuracy or performance", + "cases": [{**case, "input_bits": [f"0x{x:08x}" for x in case["input_bits"]], + "expected_bits": f"0x{case['expected_bits']:08x}"} for case in CASES]} + try: + verify_oracle() + report["stage"] = "toolchain" + toolchain = discover_toolchain(cc=cc, qemu=qemu) + report["tool_binary_sha256"] = {"cc": sha256_file(toolchain.cc[0]), + "qemu": sha256_file(toolchain.qemu)} + report["probe_sha256"] = sha256_file(__file__) + model = artifact() + write_weight_bundle(model.external_weights, model.external_initializers, out / "weights") + report["stage"] = "build" + executable = build_external(model, out / "weights", out / "build", toolchain, timeout=timeout) + report["build"] = executable.evidence + report["fmadd_encodings"] = fmadd_encodings(executable.elf_path) + inputs = np.array([case["input_bits"] for case in CASES], dtype=np.int64) + np.save(out / "input.npy", inputs, allow_pickle=False) + report["stage"] = "qemu" + observations, run_report = run_external(executable, {"bits": inputs}, out / "run", timeout=timeout) + np.save(out / "observations.npy", observations, allow_pickle=False) + report["run"] = run_report + report["stage"] = "conformance" + report["comparison"] = compare_observations(observations) + report["passed"] = bool(report["comparison"]["passed"] and run_report["passed"]) + report["status"] = "PASS" if report["passed"] else "FAIL" + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + atomic_text(out / "traceback.txt", traceback.format_exc()) + report["elapsed_s"] = time.perf_counter() - started + atomic_text(out / "report.json", json.dumps(report, indent=2, allow_nan=False) + "\n") + return report + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", required=True) + parser.add_argument("--cc") + parser.add_argument("--qemu") + parser.add_argument("--timeout", type=float, default=30.0) + args = parser.parse_args(argv) + report = run_probe(args.out, cc=args.cc, qemu=args.qemu, timeout=args.timeout) + print(json.dumps({"status": report["status"], "stage": report["stage"], + "report": str(Path(args.out).resolve() / "report.json")})) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w4_qwen3_full/host_diagnostic.py b/probes/w4_qwen3_full/host_diagnostic.py new file mode 100644 index 0000000..214a2f7 --- /dev/null +++ b/probes/w4_qwen3_full/host_diagnostic.py @@ -0,0 +1,370 @@ +"""Optional full-graph Host C diagnostic; never an RV64/QEMU acceptance gate.""" + +from __future__ import annotations + +import argparse +import ctypes +from dataclasses import dataclass +import gc +from html import escape +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +import traceback + +import numpy as np + +from probes.w1_qwen3_export import run as assets +from probes.w2_qwen3_parse.validation import audit_graph_structure +from probes.w3_common import atomic_text, process_peak_rss_bytes, sha256_file, source_evidence +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases +from probes.w4_qwen3_full.run import compare_logits, ort_reference +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.frontend.onnx_parser import ONNXParser +from scratchv.runtime.riscv_tensor import _run_process, _save_process_logs + + +ROOT = Path(__file__).resolve().parents[2] +POINTERS = ctypes.POINTER(ctypes.c_void_p) + + +def new_report(case, policy): + return {"schema_version": 1, "diagnostic_kind": "host_c_full_graph", "host_only": True, + "status": "RUNNING", "passed": False, "host_diagnostic_passed": False, + "full_host_forward_executed": False, "full_qemu_forward_executed": False, + "w4_team_accepted": False, + "gates": {"build:riscv-full": False, "smoke:qemu-full": False, + "numeric:qemu-full-qwen3": False}, + "scope": "Native host shared-library diagnostic only; no RV64 code or QEMU forward", + "case": case, "matmul_policy": policy, "stage": "initialization", + "stages_seconds": {}, "host_process_peak_rss_bytes": {}} + + +def publish(out, report): + """Publish explicit Host C views, clearing stale success on any failure.""" + try: + payload = json.dumps(report, indent=2, ensure_ascii=False, allow_nan=False) + text = ("# Host C full-graph diagnostic\n\n**This is not an RV64/QEMU gate.**\n\n" + f"Status: **{report['status']}**; stage: `{report['stage']}`.\n\n" + "```json\n" + payload + "\n```\n") + atomic_text(out / "report.md", text) + atomic_text(out / "report.html", '' + 'Host C diagnostic, not RV64 acceptance' + '

Host C diagnostic — no RV64/QEMU claim

' + escape(payload) + '
') + atomic_text(out / "report.json", payload + "\n") + except Exception as exc: + report.update(status="HOST_FAIL", passed=False, host_diagnostic_passed=False) + for name in ("report.json", "report.md", "report.html"): + try: + (out / name).unlink(missing_ok=True) + except OSError: + pass + try: + fallback = new_report(report.get("case"), report.get("matmul_policy")) + fallback.update(status="HOST_FAIL", report_error=f"{type(exc).__name__}: {exc}") + atomic_text(out / "report.json", json.dumps(fallback) + "\n") + except Exception: + pass + raise + + +def compiler_command(cc=None): + candidates = [cc] if cc else [os.environ.get("SCRATCHV_ZIG"), os.environ.get("SCRATCHV_CC"), + "zig", "clang", "gcc", "cc"] + for candidate in candidates: + if not candidate: + continue + found = str(Path(candidate).resolve()) if Path(candidate).is_file() else shutil.which(candidate) + if found: + return [found, "cc"] if Path(found).stem.lower() == "zig" else [found] + raise FileNotFoundError("Host C diagnostic requires an explicit Zig/clang/gcc compiler") + + +def read_worker_report(path): + """Read local worker evidence without accepting ambiguous JSON fields.""" + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate Host C report key: {key}") + result[key] = value + return result + + def reject_constant(value): + raise ValueError(f"Nonfinite Host C report value: {value}") + + if path.stat().st_size > 16 * 1024**2: + raise ValueError("Host C worker report exceeds 16 MiB") + report = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object, + parse_constant=reject_constant) + if not isinstance(report, dict): + raise ValueError("Host C worker report must be an object") + return report + + +def build_library(artifact, directory, compiler, timeout=900.0): + if artifact.constant_storage != "external": + raise ValueError("Host diagnostic requires the external tensor ABI") + directory = Path(directory).resolve() + directory.mkdir(parents=True, exist_ok=False) + source = directory / "model.c" + source.write_text(artifact.source, encoding="utf-8") + library = directory / ("model.dll" if os.name == "nt" else "model.so") + flags = list(dict.fromkeys(["-fno-fast-math", "-ffp-contract=off", "-fno-strict-aliasing", + *artifact.compile_flags])) + command = [*compiler, "-shared", "-O2", "-std=c11", *flags] + if os.name != "nt": + command.append("-fPIC") + command += [str(source), "-o", str(library), "-lm"] + environment = dict(os.environ) + environment["ZIG_GLOBAL_CACHE_DIR"] = str(ROOT / "output/zig-global-cache") + environment["ZIG_LOCAL_CACHE_DIR"] = str(directory / "zig-cache") + try: + result = _run_process(command, cwd=directory, timeout=timeout, env=environment) + except subprocess.TimeoutExpired as exc: + _save_process_logs(directory, "compiler", exc.stdout, exc.stderr) + raise TimeoutError(f"Host C compilation exceeded {timeout}s") from exc + _save_process_logs(directory, "compiler", result.stdout, result.stderr) + if result.returncode != 0: + raise RuntimeError(f"Host C compiler failed ({result.returncode}): " + + result.stderr.decode(errors="replace")[-8000:]) + version_command = [compiler[0], "version"] if len(compiler) == 2 else [compiler[0], "--version"] + version = _run_process(version_command, cwd=directory, timeout=30, env=environment) + if version.returncode: + raise RuntimeError("Could not record the host compiler version") + return library, {"command": command, "compiler_version": version.stdout.decode(errors="replace").strip(), + "library_sha256": sha256_file(library), "library_bytes": library.stat().st_size, + "source_sha256": sha256_file(source), "compile_flags": flags, + "implicit_fma_contraction": False, "kernel_calls": artifact.kernel_calls, + "matmul_policy": artifact.matmul_policy} + + +def _aligned_bytes(nbytes): + if type(nbytes) is not int or nbytes < 0: + raise ValueError("Storage size must be a nonnegative integer") + owner = np.empty(max(1, nbytes) + 63, dtype=np.uint8) + offset = (-owner.ctypes.data) % 64 + return owner, owner[offset:offset + nbytes] + + +def _array_for_spec(array, spec): + if (not isinstance(array, np.ndarray) or array.shape != tuple(spec.shape) + or array.dtype != np.dtype(spec.numpy_dtype)): + raise ValueError(f"Host tensor {spec.name!r} shape/dtype mismatch") + result = array if array.flags.c_contiguous else np.ascontiguousarray(array) + if not result.flags.aligned: + raise ValueError(f"Host tensor {spec.name!r} must have aligned element storage") + return result + + +@dataclass +class NativeBuffers: + """Own every Python object whose address the synchronous C call may use.""" + inputs: tuple + weights: tuple + workspace_owner: np.ndarray + workspace: np.ndarray + output_owner: np.ndarray + output: np.ndarray + input_pointers: object + weight_pointers: object + copied_weight_bytes: int + + def arguments(self, workspace_bytes): + return (self.input_pointers, self.weight_pointers, self.workspace.ctypes.data, + workspace_bytes, self.output.ctypes.data) + + +def prepare_buffers(artifact, feed): + if set(feed) != {spec.name for spec in artifact.inputs}: + raise ValueError("Host input names must exactly match the generated ABI") + if len(artifact.external_weights) != len(artifact.external_initializers): + raise ValueError("External tensor specs and arrays differ in length") + inputs = tuple(_array_for_spec(feed[spec.name], spec) for spec in artifact.inputs) + weights = tuple(_array_for_spec(array, spec) + for spec, array in zip(artifact.external_weights, artifact.external_initializers)) + copied = sum(array.nbytes for array, original in zip(weights, artifact.external_initializers) + if array is not original) + workspace_owner, workspace = _aligned_bytes(artifact.workspace_bytes) + output_owner, output_bytes = _aligned_bytes(artifact.output.nbytes) + output = output_bytes.view(artifact.output.numpy_dtype).reshape(artifact.output.shape) + if output.dtype.kind == "f": + output.fill(np.nan) # An unwritten output element must not look valid. + input_pointers = (ctypes.c_void_p * len(inputs))(*(array.ctypes.data for array in inputs)) + weight_pointers = (ctypes.c_void_p * len(weights))(*(array.ctypes.data for array in weights)) + return NativeBuffers(inputs, weights, workspace_owner, workspace, output_owner, output, + input_pointers, weight_pointers, copied) + + +def invoke_library(artifact, buffers, library): + loaded = ctypes.CDLL(str(library)) + function = None + try: + function = getattr(loaded, artifact.function_name) + function.restype = ctypes.c_int + function.argtypes = [POINTERS, POINTERS, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p] + status = function(*buffers.arguments(artifact.workspace_bytes)) + if status != 0: + raise RuntimeError(f"Host C returned failure status {status}") + return buffers.output + finally: + del function + import _ctypes + if os.name == "nt": + _ctypes.FreeLibrary(loaded._handle) + else: + _ctypes.dlclose(loaded._handle) + loaded._handle = 0 + + +def worker(args): + out = args.output_dir.resolve() + out.mkdir(parents=True, exist_ok=False) + report = new_report(args.case, args.matmul_policy) + started = time.perf_counter() + publish(out, report) + + def stage(name, operation): + report["stage"] = name + atomic_text(out / "progress.json", json.dumps({"stage": name, + "elapsed_s": time.perf_counter() - started}) + "\n") + print(f"[Host C only] {name}", flush=True) + begin = time.perf_counter() + try: + return operation() + finally: + report["stages_seconds"][name] = time.perf_counter() - begin + report["host_process_peak_rss_bytes"][name] = process_peak_rss_bytes() + publish(out, report) + + try: + report.update(stage("source_evidence", source_evidence)) + model_dir = args.model_dir.resolve() + report["model_dir"] = str(model_dir) + report["model_files"] = stage("asset_hashes", lambda: assets.verify_files(model_dir)) + import onnx + metadata = stage("metadata_load", lambda: onnx.load(str(model_dir / "model.onnx"), load_external_data=False)) + report["graph_audit"] = stage("28_layer_audit", lambda: audit_graph_structure(metadata)) + if report["graph_audit"].get("passed") is not True or report["graph_audit"].get("layer_count") != 28: + raise ValueError("Host diagnostic requires the fixed complete 28-layer model") + selected = [case for case in input_cases() if case[0] == args.case] + if len(selected) != 1: + raise ValueError("Host diagnostic requires exactly one selected full input") + _, valid_length, feed = selected[0] + report["valid_length"] = valid_length + np.savez(out / "inputs.npz", **feed) + report["input_sha256"] = sha256_file(out / "inputs.npz") + del metadata + # Keep ORT's session/weights out of the native C allocation phase. + stage("ort_reference", lambda: ort_reference(model_dir, feed, out / "ort.npy")) + gc.collect() + report["ort_sha256"] = sha256_file(out / "ort.npy") + parser = ONNXParser() + program = stage("parse", lambda: parser.parse(str(model_dir / "model.onnx"), mmap_external_data=True)) + artifact = stage("external_codegen", lambda: TensorCCodegen(program, parser.initializers, + constant_storage="external", kernel_calls=True, matmul_policy=args.matmul_policy, + max_constant_bytes=3 * 1024**3, max_workspace_bytes=1024**3).generate()) + report["codegen"] = {"workspace_bytes": artifact.workspace_bytes, + "weight_bytes": artifact.constant_bytes, + "weights": len(artifact.external_weights), + "source_bytes": len(artifact.source.encode()), + "output_shape": list(artifact.output.shape), "kernel_calls": True} + compiler = compiler_command(args.cc) + library, report["build"] = stage("host_shared_library", lambda: + build_library(artifact, out / "build", compiler, args.compile_timeout)) + buffers = stage("allocate_native_buffers", lambda: prepare_buffers(artifact, feed)) + report["buffer_policy"] = {"workspace_alignment": 64, "copied_weight_bytes": buffers.copied_weight_bytes, + "borrowed_weight_count": sum(array is original for array, original in + zip(buffers.weights, artifact.external_initializers)), + "weight_lifetime": "Strong Python references retained through the native call"} + del program, parser + gc.collect() + actual = stage("host_c_forward", lambda: invoke_library(artifact, buffers, library)) + report["full_host_forward_executed"] = True + np.save(out / "host.npy", actual, allow_pickle=False) + report["host_output_sha256"] = sha256_file(out / "host.npy") + expected = np.load(out / "ort.npy", mmap_mode="r", allow_pickle=False) + report["numeric"] = stage("full_logits_comparison", lambda: compare_logits(actual, expected)) + report.update(passed=report["numeric"]["passed"], host_diagnostic_passed=report["numeric"]["passed"], + status="HOST_PASS" if report["numeric"]["passed"] else "HOST_FAIL", stage="complete") + except BaseException as exc: + report.update(passed=False, host_diagnostic_passed=False, status="HOST_FAIL", + error=f"{type(exc).__name__}: {exc}") + traceback.print_exc() + report["elapsed_seconds"] = time.perf_counter() - started + publish(out, report) + return 0 if report["host_diagnostic_passed"] else 1 + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--model-dir", type=Path, required=True) + cli.add_argument("--output-dir", type=Path, required=True) + cli.add_argument("--case", choices=CASE_NAMES, default="full_seed_0") + cli.add_argument("--cc") + cli.add_argument("--matmul-policy", choices=("sequential", "blocked_fma"), default="sequential") + cli.add_argument("--timeout", type=float, default=3600.0) + cli.add_argument("--compile-timeout", type=float, default=900.0) + cli.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) + args = cli.parse_args(argv) + if any(not np.isfinite(value) or value <= 0 for value in (args.timeout, args.compile_timeout)): + raise ValueError("Diagnostic timeouts must be positive and finite") + if args.worker: + return worker(args) + out = args.output_dir.resolve() + out.mkdir(parents=True, exist_ok=False) + report = new_report(args.case, args.matmul_policy) + report["stage"] = "isolated_worker" + publish(out, report) + command = [sys.executable, "-B", "-X", "utf8", "-m", "probes.w4_qwen3_full.host_diagnostic", + "--worker", "--model-dir", str(args.model_dir.resolve()), "--output-dir", str(out / "worker"), + "--case", args.case, "--matmul-policy", args.matmul_policy, + "--compile-timeout", str(args.compile_timeout)] + if args.cc: + command += ["--cc", args.cc] + environment = dict(os.environ, OMP_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1", MKL_NUM_THREADS="1") + start = time.perf_counter() + try: + result = _run_process(command, cwd=ROOT, timeout=args.timeout, env=environment) + _save_process_logs(out, "worker", result.stdout, result.stderr) + child = read_worker_report(out / "worker/report.json") + if (type(child.get("schema_version")) is not int or child["schema_version"] != 1 + or child.get("diagnostic_kind") != "host_c_full_graph" + or child.get("w4_team_accepted") is not False + or child.get("host_only") is not True or child.get("full_qemu_forward_executed") is not False + or child.get("case") != args.case or child.get("matmul_policy") != args.matmul_policy + or set(child.get("gates", {})) != set(report["gates"]) + or any(value is not False for value in child.get("gates", {}).values())): + raise ValueError("Worker report crossed the Host C diagnostic boundary") + report = child + if result.returncode or report.get("status") != "HOST_PASS" or report.get("host_diagnostic_passed") is not True: + report.update(passed=False, host_diagnostic_passed=False, status="HOST_FAIL") + else: + numeric = report.get("numeric", {}) + error = numeric.get("max_abs") + if (report.get("full_host_forward_executed") is not True or report.get("passed") is not True + or numeric.get("passed") is not True or numeric.get("atol") != 1e-3 + or numeric.get("rtol") != 0.0 or numeric.get("elements_compared") != 256 * 151936 + or isinstance(error, bool) or not isinstance(error, (int, float)) + or not np.isfinite(error) or not 0 <= error < 1e-3): + raise ValueError("Incomplete or invalid Host C full-logits numerical evidence") + report["worker_exit_code"] = result.returncode + except BaseException as exc: + if isinstance(exc, subprocess.TimeoutExpired): + _save_process_logs(out, "worker", exc.stdout, exc.stderr) + report.update(passed=False, host_diagnostic_passed=False, status="HOST_FAIL", + error=f"{type(exc).__name__}: {exc}") + report["worker_command"] = command + report["parent_elapsed_seconds"] = time.perf_counter() - start + report["worker_timeout_seconds"] = args.timeout + publish(out, report) + print(f"[Host C only] {report['status']}: {out / 'report.json'}", flush=True) + return 0 if report["host_diagnostic_passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/probes/w4_qwen3_full/run.py b/probes/w4_qwen3_full/run.py new file mode 100644 index 0000000..9dd9852 --- /dev/null +++ b/probes/w4_qwen3_full/run.py @@ -0,0 +1,324 @@ +"""Build and execute the pinned complete Qwen3 model on bare-metal RV64 QEMU.""" +from __future__ import annotations + +import argparse +from dataclasses import replace +import gc +import hashlib +from html import escape +import json +from pathlib import Path +import sys +import time +import traceback +import zipfile + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.w1_qwen3_export import run as assets +from probes.w2_qwen3_parse.validation import audit_graph_structure +from probes.w3_common import atomic_text, recheck_sources, source_evidence, sha256_file +from probes.w3_qwen3_full.cases import input_cases, CASE_NAMES +from probes.w4_qwen3_full.fma_conformance import run_probe as run_fma_probe +from scratchv.backend.tensor_c_codegen import TensorCCodegen, TensorSpec, _shape +from scratchv.ir.types import DataType +from scratchv.runtime.riscv_external import build_external, run_external, ExternalExecutable, validate_external_elf +from scratchv.runtime.riscv_tensor import discover_toolchain, RiscVToolchain, input_layout, toolchain_versions +from scratchv.runtime.weight_bundle import write_weight_bundle, validate_weight_bundle, plan_guest_memory + +ATOL = 1e-3 + + +def save_source_snapshot(out, hashes): + """Retain executed source contents as well as hashes; never a clean git claim.""" + target = out / "source-snapshot.zip" + with zipfile.ZipFile(target, "x", compression=zipfile.ZIP_DEFLATED) as archive: + for name, digest in sorted(hashes.items()): + path = ROOT / name + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != digest: + raise ValueError(f"Source changed while taking snapshot: {name}") + archive.writestr(name, data) + return {"file": target.name, "sha256": sha256_file(target), + "scope": "Hashed production/probe/script sources and fixed requirements/manifests; " + "restore over recorded Git base, not a complete standalone repository"} + + +def write_reports(out, report): + try: + payload = json.dumps(report, indent=2, ensure_ascii=False, allow_nan=False) + lines = ["# W4 complete Qwen3 RV64 probe", "", f"Status: **{report['status']}**", + f"Stage: `{report['stage']}`", "", "| Gate | Passed |", "|---|---|"] + lines += [f"| `{name}` | {value} |" for name, value in report["gates"].items()] + lines += ["", "External team reproduction and remote nightly remain pending.", "", + "```json", payload, "```", ""] + atomic_text(out / "report.md", "\n".join(lines)) + atomic_text(out / "report.html", 'W4 evidence' + '

W4 complete Qwen3 RV64 probe

' + escape(payload) + '
') + atomic_text(out / "report.json", payload + "\n") + except Exception as exc: + report.update(passed=False, status="FAIL", report_error=f"{type(exc).__name__}: {exc}") + for name in ("report.json", "report.md", "report.html"): + try: + (out / name).unlink(missing_ok=True) + except OSError: + pass + try: + fallback = {"passed": False, "status": "FAIL", "stage": str(report.get("stage")), + "report_error": report["report_error"]} + atomic_text(out / "report.json", json.dumps(fallback, indent=2, allow_nan=False) + "\n") + except Exception: + pass + raise + + +def compare_logits(actual, expected): + shape = (1, 256, 151936) + if actual.shape != shape or expected.shape != shape or actual.dtype != np.float32 or expected.dtype != np.float32: + raise ValueError("Full W4 comparison requires FP32 [1,256,151936] logits") + maximum, worst, bad, square_sum = 0.0, None, 0, 0.0 + for pos in range(256): + a, b = actual[0, pos], expected[0, pos] + if not np.isfinite(a).all() or not np.isfinite(b).all(): + raise ValueError("Nonfinite logits cannot pass numeric acceptance") + diff = np.abs(a.astype(np.float64) - b.astype(np.float64)) + index = int(diff.argmax()) + if diff[index] > maximum: + maximum, worst = float(diff[index]), [0, pos, index] + bad += int(np.count_nonzero(diff >= ATOL)) + square_sum += float(np.dot(diff, diff)) + return {"passed": maximum < ATOL, "atol": ATOL, "rtol": 0.0, + "criterion": "strict max_abs < atol across every output element", + "max_abs": maximum, "worst_index": worst, "elements_at_or_above_atol": bad, + "rmse": float(np.sqrt(square_sum / actual.size)), "elements_compared": int(actual.size)} + + +def spec_json(spec): + return {"name": spec.name, "dtype": spec.dtype.value, "shape": list(spec.shape)} + + +def save_executable(exe, out): + record = {"elf": str(exe.elf_path), "bundle_dir": str(exe.bundle_dir), "manifest": exe.manifest, + "layout": exe.layout, "inputs": [spec_json(s) for s in exe.inputs], + "weights": [spec_json(s) for s in exe.weights], "output": spec_json(exe.output), + "cc": list(exe.toolchain.cc), "qemu": exe.toolchain.qemu, "evidence": exe.evidence} + atomic_text(out / "executable.json", json.dumps(record, indent=2) + "\n") + return sha256_file(out / "executable.json") + + +def load_executable(path): + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate executable metadata key: {key}") + result[key] = value + return result + def reject_constant(value): + raise ValueError(f"Nonfinite executable metadata: {value}") + path = Path(path) + if path.stat().st_size > 16 * 1024**2: + raise ValueError("Executable metadata exceeds 16 MiB") + record = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object, + parse_constant=reject_constant) + def spec(value): + if (set(value) != {"name", "dtype", "shape"} or not isinstance(value["name"], str) + or not value["name"] or "\0" in value["name"]): + raise ValueError("Invalid executable tensor spec") + dtype = DataType(value["dtype"]) + if dtype not in (DataType.FLOAT32, DataType.INT32, DataType.INT64): + raise ValueError("Unsupported external tensor dtype") + return TensorSpec(value["name"], dtype, _shape(value["shape"])) + exe = ExternalExecutable(Path(record["elf"]), Path(record["bundle_dir"]), record["manifest"], + record["layout"], tuple(map(spec, record["inputs"])), spec(record["output"]), + tuple(map(spec, record["weights"])), RiscVToolchain(tuple(record["cc"]), record["qemu"]), + record["evidence"]) + manifest = validate_weight_bundle(exe.bundle_dir, exe.weights) + if manifest != exe.manifest: + raise ValueError("Reused weights do not match build manifest") + _, input_bytes = input_layout(exe.inputs) + layout = plan_guest_memory(workspace_bytes=exe.layout["regions"]["workspace"]["nbytes"], + weight_bytes=manifest["total_bytes"], input_bytes=input_bytes, output_bytes=exe.output.nbytes) + if layout != exe.layout: + raise ValueError("Reused memory layout is not the canonical non-overlapping plan") + evidence = validate_external_elf(exe.elf_path, layout) + if any(evidence[key] != exe.evidence.get(key) for key in evidence): + raise ValueError("Reused ELF changed after build") + if set(exe.evidence["sources"]) != {"model.c", "guest.c", "start.S", "link.ld"}: + raise ValueError("Reused generated source manifest must contain all four source files") + for name, digest in exe.evidence["sources"].items(): + if Path(name).name != name or sha256_file(exe.elf_path.parent / name) != digest: + raise ValueError("Reused generated source changed after build") + return exe + + +def ort_reference(model_dir, feed, output): + import onnxruntime as ort + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + options.enable_cpu_mem_arena = False + session = ort.InferenceSession(str(model_dir / "model.onnx"), options, + providers=["CPUExecutionProvider"]) + logits = session.run(["logits"], feed)[0] + if logits.shape != (1, 256, 151936) or logits.dtype != np.float32 or not np.isfinite(logits).all(): + raise ValueError("Invalid complete ORT reference output") + np.save(output, logits, allow_pickle=False) + del logits, session + gc.collect() + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--model-dir", type=Path, required=True) + cli.add_argument("--output-dir", type=Path, required=True) + cli.add_argument("--case", choices=(*CASE_NAMES, "all"), default="full_seed_0") + cli.add_argument("--cc") + cli.add_argument("--qemu") + cli.add_argument("--timeout", type=float, default=10800) + cli.add_argument("--build-timeout", type=float, default=900) + cli.add_argument("--build-only", action="store_true") + cli.add_argument("--matmul-policy", choices=("sequential", "blocked_fma"), default="sequential") + cli.add_argument("--reuse-build", type=Path, help="Local executable.json; verified before every run") + args = cli.parse_args(argv) + out = args.output_dir.resolve() + out.mkdir(parents=True, exist_ok=False) + start = time.perf_counter() + report = {"schema_version": 1, "gate": "numeric:qemu-full-qwen3", "passed": False, + "status": "FAIL", "stage": "initialization", "stages_seconds": {}, "cases": [], + "gates": {"build:riscv-full": False, "smoke:qemu-full": False, + "numeric:qemu-full-qwen3": False}, + "full_qemu_forward_executed": False, "team_reproduction": "PENDING", + "remote_nightly": "NOT_RUN", "w4_team_accepted": False, + "scope": "Local bare-metal RV64 full single-forward evidence; no Linux guest/mmap or generation loop"} + + def stage(name, operation): + report["stage"] = name + atomic_text(out / "progress.json", json.dumps({"stage": name, + "elapsed_s": time.perf_counter() - start}) + "\n") + print(f"[W4] {name}", flush=True) + begin = time.perf_counter() + try: + return operation() + finally: + report["stages_seconds"][name] = time.perf_counter() - begin + write_reports(out, report) + + try: + if any(not np.isfinite(value) or value <= 0 for value in (args.timeout, args.build_timeout)): + raise ValueError("Timeouts must be positive and finite") + report.update(stage("source_evidence", source_evidence)) + report["source_snapshot"] = stage("source_snapshot", lambda: save_source_snapshot(out, report["source_sha256"])) + model_dir = args.model_dir.resolve() + report["model_dir"] = str(model_dir) + report["matmul_policy"] = args.matmul_policy + report["model_files"] = stage("asset_hashes", lambda: assets.verify_files(model_dir)) + import onnx + model = stage("metadata_load", lambda: onnx.load(str(model_dir / "model.onnx"), load_external_data=False)) + report["graph_audit"] = stage("28_layer_audit", lambda: audit_graph_structure(model)) + if not report["graph_audit"]["passed"] or report["graph_audit"]["layer_count"] != 28: + raise ValueError("Complete 28-layer Qwen3 structural audit failed") + if args.reuse_build: + previous = json.loads(args.reuse_build.with_name("report.json").read_text(encoding="utf-8")) + if sha256_file(args.reuse_build) != previous.get("executable_sha256"): + raise ValueError("Reused executable metadata hash is missing or changed") + exe = stage("load_existing_build", lambda: load_executable(args.reuse_build)) + if args.cc: + raise ValueError("Do not change --cc while reusing an ELF; build a new executable instead") + if args.qemu: + replacement = discover_toolchain(exe.toolchain.cc, args.qemu) + report["qemu_override"] = {"built_with": exe.toolchain.qemu, "execute_with": replacement.qemu} + exe = replace(exe, toolchain=replacement) + if previous["model_files"] != report["model_files"]: + raise ValueError("Reused build model identity mismatch") + if previous.get("matmul_policy") != args.matmul_policy: + raise ValueError("Reused build arithmetic policy differs from requested policy") + # Relevant production files must still have exactly the build's contents. + production = lambda hashes: {key: value for key, value in hashes.items() if key.startswith("scratchv/")} + if production(previous["source_sha256"]) != production(report["source_sha256"]): + raise ValueError("Reused build production source inventory or contents changed") + else: + from scratchv.frontend.onnx_parser import ONNXParser + parser = ONNXParser() + program = stage("parse", lambda: parser.parse(str(model_dir / "model.onnx"), mmap_external_data=True)) + artifact = stage("external_codegen", lambda: TensorCCodegen(program, + initializers=parser.initializers, constant_storage="external", + max_constant_bytes=3 * 1024**3, max_workspace_bytes=1024**3, + kernel_calls=True, matmul_policy=args.matmul_policy).generate()) + report["codegen"] = {"source_bytes": len(artifact.source.encode()), + "constant_bytes": artifact.constant_bytes, + "workspace_bytes": artifact.workspace_bytes, + "weights": len(artifact.external_weights), + "kernel_calls": artifact.kernel_calls, + "output": spec_json(artifact.output)} + stage("write_weight_bundle", lambda: write_weight_bundle(artifact.external_weights, + artifact.external_initializers, out / "weights")) + toolchain = discover_toolchain(args.cc, args.qemu) + exe = stage("build_riscv_full", lambda: build_external(artifact, out / "weights", + out / "build", toolchain, timeout=args.build_timeout)) + del artifact, program, parser + gc.collect() + report["executable_sha256"] = save_executable(exe, out) + report.update(build=exe.evidence, memory_layout=exe.layout, weight_sha256=exe.manifest["sha256"]) + report["gates"]["build:riscv-full"] = True + report["execution_tools"] = stage("current_tool_versions", lambda: toolchain_versions(exe.toolchain)) + report["execution_tool_binary_sha256"] = { + "cc": sha256_file(exe.toolchain.cc[0]), "qemu": sha256_file(exe.toolchain.qemu)} + if args.build_only: + stage("source_postcheck", lambda: recheck_sources(report)) + report.update(status="BUILD_ONLY", stage="build_complete") + else: + report["fma_conformance"] = stage("fma_conformance", lambda: run_fma_probe( + out / "fma-conformance", cc=exe.toolchain.cc, qemu=exe.toolchain.qemu)) + conformance = report["fma_conformance"] + if (conformance.get("passed") is not True or conformance.get("status") != "PASS" + or conformance.get("tool_binary_sha256") != report["execution_tool_binary_sha256"]): + raise ValueError("Actual execution toolchain failed strict RV64 FMA conformance") + cases = input_cases() + if args.case != "all": + cases = [case for case in cases if case[0] == args.case] + requested = list(CASE_NAMES) if args.case == "all" else [args.case] + if [case[0] for case in cases] != requested: + raise ValueError("Selected cases must exactly match requested nonempty coverage") + for name, valid_length, feed in cases: + case_dir = out / name + case_dir.mkdir() + np.savez(case_dir / "inputs.npz", **feed) + row = {"name": name, "valid_length": valid_length, "passed": False, + "input_sha256": sha256_file(case_dir / "inputs.npz")} + report["cases"].append(row) + stage(f"{name}_ort", lambda: ort_reference(model_dir, feed, case_dir / "ort.npy")) + row["ort_sha256"] = sha256_file(case_dir / "ort.npy") + actual, run = stage(f"{name}_qemu", lambda: run_external(exe, feed, case_dir / "qemu", timeout=args.timeout)) + if sha256_file(exe.toolchain.qemu) != report["execution_tool_binary_sha256"]["qemu"]: + raise ValueError("QEMU executable changed after FMA preflight") + row["qemu"] = run + report["full_qemu_forward_executed"] = True + report["gates"]["smoke:qemu-full"] = True + expected = np.load(case_dir / "ort.npy", mmap_mode="r", allow_pickle=False) + row["numeric"] = stage(f"{name}_comparison", lambda: compare_logits(actual, expected)) + row["passed"] = row["numeric"]["passed"] + del actual, expected + if not row["passed"]: + raise ValueError(f"{name}: full QEMU error {row['numeric']['max_abs']} exceeds strict {ATOL}") + stage("source_postcheck", lambda: recheck_sources(report)) + report["gates"]["numeric:qemu-full-qwen3"] = all(row["passed"] for row in report["cases"]) + report.update(passed=True, status="PASS", stage="complete", coverage=args.case, + cases_passed=len(report["cases"]), seven_case_coverage=args.case == "all") + except BaseException as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + traceback.print_exc() + if isinstance(exc, KeyboardInterrupt): + report["interrupted"] = True + report["elapsed_seconds"] = time.perf_counter() - start + write_reports(out, report) + print(f"[W4] {report['status']}: {out / 'report.json'}", flush=True) + return 0 if report["passed"] or (args.build_only and report["status"] == "BUILD_ONLY") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 670aa63..b4320d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "scratchv" version = "0.3.0" description = "A compiler from ONNX models to RISC-V assembly and LLVM IR" readme = "README.md" -license = {text = "MIT"} +license = {text = "MIT AND MPL-2.0"} keywords = ["compiler", "risc-v", "onnx", "llvm", "machine-learning"] requires-python = ">=3.8" dependencies = [ @@ -20,6 +20,7 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: Education", "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -46,6 +47,9 @@ Documentation = "https://github.com/kinsomwang/ScratchV/tree/main/docs/INDEX.md" [project.scripts] scratchv = "scratchv.main:main" +[tool.setuptools] +license-files = ["LICENSE", "LICENSES/*.txt", "THIRD_PARTY_NOTICES.txt"] + [tool.setuptools.packages.find] include = ["scratchv", "scratchv.*", "scratchv_dag", "scratchv_dag.*", "benchmarks", "benchmarks.*"] diff --git a/scratchv/backend/instruction_select.py b/scratchv/backend/instruction_select.py index 4b15f8d..8569e80 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -28,6 +28,8 @@ def __init__(self, program: Program): self._max_temp_counter = 0 self._loop_stack: list[dict] = [] self._reserved_vreg_names = self._collect_ir_value_names() + self._bound_value_names: set[str] = set() + self._globals = {value.name: value for value in program.global_values} def _collect_ir_value_names(self) -> set[str]: """Reserve every user-visible IR name before creating temporaries.""" @@ -58,6 +60,18 @@ def _fresh_label(self, prefix: str = "L") -> str: return f".L{prefix}_{self._label_counter}" def _select_function(self, func: Function) -> None: + # Named definitions take precedence over constant metadata on a + # reference, matching the interpreter and LLVM backend. In particular, + # a parameter's constant flag is only a hint, not its runtime value. + # LOAD_CONST also defines a register: using that definition avoids + # emitting illegal immediates in ADD/STORE register operand positions. + self._bound_value_names = {value.name for value in func.params} + self._bound_value_names.update( + instr.dest.name + for block in func.blocks + for instr in (*block.phi_nodes, *block.instructions) + if instr.dest is not None + ) # Function prologue label self._emit_label(func.name) self._stack_offset = 0 @@ -138,6 +152,11 @@ def _emit_label(self, name: str) -> None: def _op(self, instr: Instruction, idx: int): """Get an operand from an IR instruction as a machine operand.""" op = instr.operands[idx] + if op.name in self._bound_value_names: + return MachineOperand.vreg(op.name) + # Global scalar literals are defined by Program.global_values, not by + # potentially incomplete or stale metadata on a separate reference. + op = self._globals.get(op.name, op) if op.is_constant and op.const_value is not None: return MachineOperand.immediate(int(op.const_value)) return MachineOperand.vreg(op.name) @@ -463,6 +482,8 @@ def _select_conv(self, instr: Instruction) -> None: def _select_gemm(self, instr: Instruction) -> None: """GEMM inline: real RISC-V MUL+ADD MAC.""" + if any(instr.attrs.get(name, 1) != 1 for name in ("alpha", "beta")): + raise ValueError("Legacy RISC-V GEMM does not support nondefault alpha/beta scaling") dst = self._dst(instr) a_reg = self._op(instr, 0) w_reg = self._op(instr, 1) diff --git a/scratchv/backend/llvm_codegen.py b/scratchv/backend/llvm_codegen.py index 1138f90..7bce9af 100644 --- a/scratchv/backend/llvm_codegen.py +++ b/scratchv/backend/llvm_codegen.py @@ -533,6 +533,8 @@ def _emit_conv(self, instr: Instruction) -> None: self._p(f" {dst} = fadd {ty} 0.0, 0.0 ; conv placeholder") def _emit_gemm(self, instr: Instruction) -> None: + if any(instr.attrs.get(name, 1) != 1 for name in ("alpha", "beta")): + raise ValueError("Legacy LLVM GEMM does not support nondefault alpha/beta scaling") dst = self._dest(instr) a = self._op(instr, 0) b = self._op(instr, 1) diff --git a/scratchv/backend/tensor_c_codegen.py b/scratchv/backend/tensor_c_codegen.py index 5b45db4..2d9cc9a 100644 --- a/scratchv/backend/tensor_c_codegen.py +++ b/scratchv/backend/tensor_c_codegen.py @@ -8,6 +8,14 @@ ``-fno-fast-math -ffp-contract=off -fno-strict-aliasing`` and link a conforming libm plus memcpy/memset. No tensor storage is placed on the stack. +``constant_storage="external"`` instead emits ``scratchv_run_external(inputs, +weights, workspace, workspace_bytes, output)``. Its caller supplies read-only +weight buffers in artifact order and an 8-byte-aligned workspace. There is no +mutable static storage. Input/output/weight buffer sizes are a caller contract: +the C ABI has no descriptors to discover allocation lengths. The loader must +validate them against TensorSpec before calling the function. Pointers, numeric +values, workspace capacity and writable-buffer overlap are checked in C. + Supported element types are FP32, INT32 and INT64. Control flow, dynamic shapes, empty reductions and nonfinite arithmetic are rejected explicitly. This backend does not silently lower tensor operations to scalar integer instructions. @@ -15,7 +23,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import math from typing import Mapping @@ -32,6 +40,7 @@ class TensorCCodegenError(ValueError): _TYPES = {DataType.FLOAT32: ("float", np.dtype("float32")), DataType.INT32: ("int32_t", np.dtype("int32")), DataType.INT64: ("int64_t", np.dtype("int64"))} +_UINT64_MAX = (1 << 64) - 1 @dataclass(frozen=True) @@ -62,6 +71,11 @@ class TensorCArtifact: constant_bytes: int function_name: str = "scratchv_run" compile_flags: tuple[str, ...] = ("-fno-fast-math", "-ffp-contract=off", "-fno-strict-aliasing") + constant_storage: str = "inline" + external_weights: tuple[TensorSpec, ...] = () + external_initializers: tuple[np.ndarray, ...] = field(default=(), repr=False, compare=False) + kernel_calls: bool = False + matmul_policy: str = "sequential" def _shape(shape): @@ -152,19 +166,50 @@ def _literal(value, dtype): } ''' +_EXTERNAL_PRELUDE = r''' +/* External storage targets 64-bit hosts/guests; lengths are caller-validated. */ +_Static_assert(sizeof(size_t) == 8 && sizeof(uintptr_t) == 8, + "external tensor ABI requires a 64-bit target"); +static int sv_buffer(const void *pointer, uint64_t bytes, size_t alignment) { + uintptr_t address = (uintptr_t)pointer; + return pointer && address % alignment == 0 && bytes <= UINTPTR_MAX - address; +} +static int sv_overlap(const void *a, uint64_t na, const void *b, uint64_t nb) { + uintptr_t pa = (uintptr_t)a, pb = (uintptr_t)b; + return na && nb && (pa <= pb ? pb - pa < na : pa - pb < nb); +} +static uint32_t sv_float_bits(float x) { + union { float f; uint32_t u; } value; value.f = x; return value.u; +} +''' + class TensorCCodegen: """Generate one straight-line function with strict tensor/dtype validation.""" def __init__(self, program: Program, initializers: Mapping[str, np.ndarray] | None = None, *, max_workspace_bytes=256 * 1024 * 1024, - max_constant_bytes=256 * 1024 * 1024): + max_constant_bytes=256 * 1024 * 1024, constant_storage="inline", + kernel_calls=False, matmul_policy="sequential"): self.program = program self.initializers = {} if initializers is None else dict(initializers) self.max_workspace_bytes = max_workspace_bytes self.max_constant_bytes = max_constant_bytes + self.constant_storage = constant_storage + self.kernel_calls = kernel_calls + self.matmul_policy = matmul_policy def generate(self) -> TensorCArtifact: + if self.constant_storage not in ("inline", "external"): + raise TensorCCodegenError("constant_storage must be 'inline' or 'external'") + if not isinstance(self.kernel_calls, bool): + raise TensorCCodegenError("kernel_calls must be boolean") + if self.kernel_calls and self.constant_storage != "external": + raise TensorCCodegenError("kernel_calls requires external constant storage") + if self.matmul_policy not in ("sequential", "blocked_fma"): + raise TensorCCodegenError("matmul_policy must be 'sequential' or 'blocked_fma'") + if self.matmul_policy != "sequential" and self.constant_storage != "external": + raise TensorCCodegenError("Experimental MatMul policies require external constant storage") if len(self.program.functions) != 1: raise TensorCCodegenError("Exactly one function is required") func = self.program.functions[0] @@ -180,9 +225,13 @@ def generate(self) -> TensorCArtifact: if any(value.is_constant for value in func.params): raise TensorCCodegenError("Function parameters cannot be constants") for bound in (self.max_workspace_bytes, self.max_constant_bytes): - if isinstance(bound, bool) or not isinstance(bound, int) or bound < 0: - raise TensorCCodegenError("Memory limits must be nonnegative integers") + if (isinstance(bound, bool) or not isinstance(bound, int) + or not 0 <= bound <= _UINT64_MAX): + raise TensorCCodegenError("Memory limits must be nonnegative 64-bit integers") self.specs, self.names, self.constants, self.body = {}, {}, [], [] + self.external_weights, self.external_initializers = [], [] + self.external_scalar_checks = [] + self.kernels = [] self.constant_bytes = 0 self._counter = 0 defined = set() @@ -252,14 +301,18 @@ def generate(self) -> TensorCArtifact: offset, block_size = free.pop(choice) if block_size > needed: free.append((offset + needed, block_size - needed)) - if high > self.max_workspace_bytes: + if high > self.max_workspace_bytes or high > _UINT64_MAX: raise TensorCCodegenError(f"Static workspace exceeds {self.max_workspace_bytes} bytes") allocations[value.name] = (offset, needed) self.body.append(f"/* {index}: {instruction.opcode.value} */") + arena = "sv_workspace" if self.constant_storage == "external" else "sv_arena.bytes" self.body.append(f"{_TYPES[spec.dtype][0]} *{self.names[value.name]} = " - f"({_TYPES[spec.dtype][0]} *)(sv_arena.bytes + {offset}ULL);") - self._emit(instruction, spec, details) - self._finite_check(spec) + f"({_TYPES[spec.dtype][0]} *)({arena} + {offset}ULL);") + if self.kernel_calls: + self._emit_kernel(instruction, spec, details, index) + else: + self._emit(instruction, spec, details) + self._finite_check(spec) for name in list(allocations): if last.get(name, index) <= index: free.append(allocations.pop(name)) @@ -279,10 +332,42 @@ def generate(self) -> TensorCArtifact: self._constant(returned, np.asarray(returned.const_value, dtype=self.specs[returned.name].numpy_dtype)) output = self.specs[returned.name] + if returned.dtype != output.dtype: + raise TensorCCodegenError(f"RETURN {returned.name}: dtype disagrees with defined value") + # Empty shape on a builder-created intermediate still means "infer". + # Parameters/globals and an explicit function signature are boundaries: + # their empty shape is a scalar, never a wildcard for a tensor buffer. + bound_names = {value.name for value in [*self.program.global_values, *func.params]} + inferred_return = isinstance(returned.shape, (tuple, list)) and len(returned.shape) == 0 + if ((not inferred_return or returned.name in bound_names) + and _shape(returned.shape) != output.shape): + raise TensorCCodegenError(f"RETURN {returned.name}: shape disagrees with defined value") + if func.returns: + if len(func.returns) != 1: + raise TensorCCodegenError("Function return signature must declare one tensor") + declared = func.returns[0] + if declared.dtype != output.dtype or _shape(declared.shape) != output.shape: + raise TensorCCodegenError("Function return signature dtype/shape disagrees with RETURN") ctype = _TYPES[output.dtype][0] self.body += ["if (!output) return 1;", f"for (size_t i=0; i<{output.size}ULL; ++i) (({ctype} *)output)[i] = {self.names[output.name]}[i];", "return 0;"] + if self.constant_storage == "external": + source = (_PRELUDE + _EXTERNAL_PRELUDE + "".join(self.kernels) + + "#if defined(_WIN32)\n__declspec(dllexport)\n#endif\n" + + "int scratchv_run_external(const void *const inputs[], " + "const void *const weights[], void *workspace, size_t workspace_bytes, " + "void *output) {\n " + + "\n ".join(self._external_prologue(inputs, output, high) + self.body) + + "\n}\n") + return TensorCArtifact( + source, inputs, output, high, self.constant_bytes, + function_name="scratchv_run_external", constant_storage="external", + external_weights=tuple(self.external_weights), + external_initializers=tuple(self.external_initializers), + kernel_calls=self.kernel_calls, + matmul_policy=self.matmul_policy, + ) source = (_PRELUDE + "\n" + "\n".join(self.constants) + f"\nstatic union {{ uint64_t align; unsigned char bytes[{max(high, 8)}]; }} sv_arena;\n" + "#if defined(_WIN32)\n__declspec(dllexport)\n#endif\n" @@ -295,6 +380,8 @@ def _register(self, value, shape=None): raise TensorCCodegenError(f"Unsupported dtype {value.dtype}; use FP32/INT32/INT64") shape = _shape(value.shape if shape is None else shape) spec = TensorSpec(value.name, value.dtype, shape) + if spec.nbytes > _UINT64_MAX: + raise TensorCCodegenError("Tensor byte count exceeds 64-bit address space") self.specs[value.name] = spec self.names[value.name] = f"sv_v{self._counter}" self._counter += 1 @@ -313,19 +400,98 @@ def _constant(self, value, data): raise TensorCCodegenError(f"Invalid scalar constant {value.name}") from exc if data.shape != () or expected.shape != () or not np.array_equal(data, expected): raise TensorCCodegenError(f"Initializer {value.name} disagrees with scalar constant") + if (self.constant_storage == "external" and spec.dtype == DataType.FLOAT32 + and data.view(np.uint32).item() != expected.view(np.uint32).item()): + raise TensorCCodegenError(f"Initializer {value.name} disagrees with scalar constant bits") self.constant_bytes += spec.nbytes - if self.constant_bytes > self.max_constant_bytes: + if self.constant_bytes > self.max_constant_bytes or self.constant_bytes > _UINT64_MAX: raise TensorCCodegenError("Constant tensor storage exceeds configured limit") + if self.constant_storage == "external": + # nditer bounds temporary memory even for a noncontiguous mmap/view. + if spec.dtype == DataType.FLOAT32: + for chunk in np.nditer(data, flags=["external_loop", "buffered", "zerosize_ok"], + op_flags=["readonly"], order="C", buffersize=262144): + if not np.isfinite(chunk).all(): + raise TensorCCodegenError("Nonfinite initializer/constant") + self.external_weights.append(spec) + self.external_initializers.append(data) + if value.is_constant: + name = self.names[value.name] + if spec.dtype == DataType.FLOAT32: + expected_bits = int(data.view(np.uint32).item()) + check = f"sv_float_bits({name}[0]) != {expected_bits}U" + else: + check = f"{name}[0] != {_literal(value.const_value, spec.dtype)}" + self.external_scalar_checks.append(f"if ({check}) return 2;") + return values = [_literal(v.item(), spec.dtype) for v in data.ravel()] lines = [", ".join(values[i:i + 8]) for i in range(0, len(values), 8)] self.constants.append(f"static const {_TYPES[spec.dtype][0]} {self.names[value.name]}" f"[{max(spec.size, 1)}] = {{\n" + ",\n".join(lines or ["0"]) + "\n};") + def _external_prologue(self, inputs, output, workspace_bytes): + """Validate all external addresses before dereferencing tensor data.""" + lines = [ + f"if (workspace_bytes < {workspace_bytes}ULL) return 1;", + f"if ({workspace_bytes}ULL && !sv_buffer(workspace, {workspace_bytes}ULL, 8)) return 1;", + f"if (!sv_buffer(output, {output.nbytes}ULL, {output.numpy_dtype.itemsize})) return 1;", + f"if (sv_overlap(workspace, {workspace_bytes}ULL, output, {output.nbytes}ULL)) return 1;", + "unsigned char *sv_workspace = (unsigned char *)workspace;", + ] + for table, specs in (("inputs", inputs), ("weights", self.external_weights)): + if specs: + lines.append(f"if (!sv_buffer({table}, {len(specs)}ULL * sizeof(void *), " + "_Alignof(void *))) return 1;") + lines.append(f"if (sv_overlap(workspace, {workspace_bytes}ULL, {table}, " + f"{len(specs)}ULL * sizeof(void *)) || sv_overlap(output, " + f"{output.nbytes}ULL, {table}, {len(specs)}ULL * sizeof(void *))) return 1;") + for index, spec in enumerate(specs): + pointer = f"{table}[{index}]" + lines.append(f"if (!sv_buffer({pointer}, {spec.nbytes}ULL, " + f"{spec.numpy_dtype.itemsize})) return 1;") + lines.append(f"if (sv_overlap(workspace, {workspace_bytes}ULL, {pointer}, " + f"{spec.nbytes}ULL) || sv_overlap(output, {output.nbytes}ULL, " + f"{pointer}, {spec.nbytes}ULL)) return 1;") + if table == "weights": + ctype, name = _TYPES[spec.dtype][0], self.names[spec.name] + lines.append(f"const {ctype} *{name} = (const {ctype} *){pointer};") + if spec.dtype == DataType.FLOAT32: + lines.append(f"for (size_t i=0; i<{spec.size}ULL; ++i) " + f"if (!sv_finite({name}[i])) return 2;") + return lines + self.external_scalar_checks + def _finite_check(self, spec): if spec.dtype == DataType.FLOAT32: self.body.append(f"for (size_t i=0; i<{spec.size}ULL; ++i) " f"if (!sv_finite({self.names[spec.name]}[i])) return 2;") + def _emit_kernel(self, instruction, output, details, index): + """Bound each C optimizer unit without changing the arithmetic body. + + Move the original operation and its immediate finite check verbatim. + Shape-specialized loops retain their reduction order and FP flags. + Explicit noinline prevents LLVM from rebuilding the giant graph body. + """ + start = len(self.body) + self._emit(instruction, output, details) + self._finite_check(output) + statements = self.body[start:] + del self.body[start:] + names = [self.names[output.name]] + parameters = [f"{_TYPES[output.dtype][0]} *{names[0]}"] + unique = {} + for value in instruction.operands: + unique.setdefault(value.name, value) + for value in unique.values(): + names.append(self.names[value.name]) + parameters.append(f"const {_TYPES[value.dtype][0]} *{names[-1]}") + self.kernels.append( + f"\nstatic __attribute__((noinline)) int sv_kernel_{index}({', '.join(parameters)}) {{\n " + + "\n ".join(statements) + "\n return 0;\n}\n" + ) + self.body.append(f"int sv_status_{index} = sv_kernel_{index}({', '.join(names)});") + self.body.append(f"if (sv_status_{index}) return sv_status_{index};") + def _infer(self, instruction): op, attrs = instruction.opcode, instruction.attrs xs = [self.specs[x.name] for x in instruction.operands] @@ -551,6 +717,37 @@ def _emit(self, instruction, output, details): m, k, n = a[-2], a[-1], b[-1] left = _broadcast_offset(batch, a[:-2], "batch") right = _broadcast_offset(batch, b[:-2], "batch") + if self.matmul_policy == "blocked_fma": + # Explicit experiment: four adjacent output columns reuse each + # left value. K-block products use fused FP32 multiply-add in K + # order, then each block is added to an FP32 running sum. This + # deliberately differs from sequential separate mul/add rounding. + # __builtin_fmaf keeps the explicit operation even with + # -fno-builtin and -ffp-contract=off used by the RV64 runtime. + lines = [ + f"for (size_t batch=0; batch<{math.prod(batch)}ULL; ++batch) " + f"for (size_t m=0; m<{m}ULL; ++m) " + f"for (size_t column=0; column<{n}ULL; column+=4) {{", + "float sum0=0.0f, sum1=0.0f, sum2=0.0f, sum3=0.0f;", + f"for (size_t block=0; block<{k}ULL; block+=128) {{", + "float acc0=0.0f, acc1=0.0f, acc2=0.0f, acc3=0.0f;", + f"size_t end=block+128 < {k}ULL ? block+128 : {k}ULL;", + "for (size_t k=block; k Program: - """Parse an ONNX model file and return an IR Program.""" + self._mmap_external_data = False + self._opsets: dict[str, int] = {} + + def parse(self, model_path: str, *, mmap_external_data: bool = False) -> Program: + """Parse ONNX, optionally mapping external weights as read-only arrays. + + The default loader is unchanged. The explicit mmap mode avoids + materializing external weights in TensorProto.raw_data. External files + must be within the model directory, with valid byte ranges matching + the tensor shape and dtype. The caller must keep those files unchanged + while this parser's initializer arrays are in use. Mapping is not an + integrity check; model/weight hashes must be validated separately. + Runtime inputs require explicit static shapes after ONNX inference; + symbolic dimensions or unknown rank are rejected, not treated as zero. + Inputs also listed as initializers use the initializer's fixed shape. + """ + if not isinstance(mmap_external_data, bool): + raise ONNXParseError("mmap_external_data must be boolean") try: import onnx except ImportError: @@ -52,9 +67,11 @@ def parse(self, model_path: str) -> Program: self._producers = {} self._constant_cache = {} self._base_dir = str(Path(model_path).resolve().parent) + self._mmap_external_data = mmap_external_data # Inspect/infer the graph before materializing external weights. This # avoids serializing a >2 GiB protobuf just to infer tensor shapes. model = onnx.load(model_path, load_external_data=False) + self._opsets = {item.domain: item.version for item in model.opset_import} domains = sorted({node.domain for node in model.graph.node if node.domain not in ("", "ai.onnx")}) if domains: @@ -70,6 +87,28 @@ def parse(self, model_path: str) -> Program: except onnx.shape_inference.InferenceError as exc: raise ONNXParseError(f"ONNX shape inference failed: {exc}") from exc graph = model.graph + # Protobuf returns dim_value=0 when a dimension is symbolic or absent. + # Check presence before reading it: an explicit zero-sized tensor is + # static, while a missing shape field is unknown rank, not a scalar. + input_shapes = {} + initializer_names = {init.name for init in graph.initializer} + for inp in graph.input: + if inp.name in initializer_names: + # Legacy exports list fixed weights as graph inputs as well. + # This parser binds those as initializers, not parameters; + # their tensor data defines the shape even if input metadata + # remains symbolic after ONNX shape inference. + continue + tensor = inp.type.tensor_type + if not tensor.HasField("shape") or any( + not dim.HasField("dim_value") or dim.dim_value < 0 + for dim in tensor.shape.dim + ): + raise ONNXParseError( + f"Input {inp.name!r} requires a static nonnegative shape; " + "dynamic dimensions and unknown rank are unsupported" + ) + input_shapes[inp.name] = tuple(dim.dim_value for dim in tensor.shape.dim) self._tensor_info = { value.name: value.type.tensor_type for value in [*graph.input, *graph.value_info, *graph.output] @@ -93,7 +132,10 @@ def parse(self, model_path: str) -> Program: # Map ONNX initializers (constants) to IR values for init in graph.initializer: - arr = onnx.numpy_helper.to_array(init, base_dir=self._base_dir) + if mmap_external_data and onnx.external_data_helper.uses_external_data(init): + arr = self._map_external_initializer(init) + else: + arr = onnx.numpy_helper.to_array(init, base_dir=self._base_dir) self._bind_constant(init.name, arr) # Map graph inputs to function params @@ -104,9 +146,7 @@ def parse(self, model_path: str) -> Program: if inp.type.tensor_type.elem_type: dtype = self._dtype(inp.type.tensor_type.elem_type) val = self.builder.make_value(name=inp.name, dtype=dtype) - # Infer shape from ONNX type - shape_dims = list(inp.type.tensor_type.shape.dim) - val.shape = tuple(d.dim_value for d in shape_dims) + val.shape = input_shapes[inp.name] func.params.append(val) self._value_map[inp.name] = val @@ -127,6 +167,66 @@ def parse(self, model_path: str) -> Program: return self.builder.program + def _map_external_initializer(self, tensor): + """Validate before mapping; never populate the TensorProto raw_data.""" + import numpy as np + from onnx import helper + + self._dtype(tensor.data_type) # Keep the frontend's supported dtype set. + fields = {} + for entry in tensor.external_data: + if entry.key in fields: + raise ONNXParseError(f"Duplicate external field for {tensor.name}: {entry.key}") + fields[entry.key] = entry.value + unknown = set(fields) - {"location", "offset", "length", "checksum"} + if unknown: + raise ONNXParseError(f"Unsupported external fields for {tensor.name}: {sorted(unknown)}") + location = fields.get("location", "") + windows = PureWindowsPath(location) + path = Path(location) + if (not location or path.is_absolute() or windows.is_absolute() + or windows.drive or ".." in path.parts or ".." in windows.parts): + raise ONNXParseError(f"External path must stay within the model directory: {tensor.name}") + # Recognize either separator on every host; resolve symlinks as well. + base = Path(self._base_dir).resolve() + candidate = (base / Path(*windows.parts)).resolve() + try: + candidate.relative_to(base) + except ValueError as exc: + raise ONNXParseError(f"External path escapes the model directory: {tensor.name}") from exc + if candidate == base: + raise ONNXParseError(f"External path escapes the model directory: {tensor.name}") + if tensor.HasField("raw_data") or tensor.HasField("segment"): + raise ONNXParseError(f"Ambiguous raw or segmented external tensor: {tensor.name}") + shape = tuple(tensor.dims) + if any(dimension < 0 for dimension in shape): + raise ONNXParseError(f"Negative external tensor dimension: {tensor.name}") + dtype = np.dtype(helper.tensor_dtype_to_np_dtype(tensor.data_type)).newbyteorder("<") + expected = math.prod(shape) * dtype.itemsize + + def size_field(name, default): + value = fields.get(name) + if value is None: + return default + if not value or not value.isascii() or not value.isdecimal(): + raise ONNXParseError(f"External {name} must be a nonnegative integer: {tensor.name}") + return int(value) + + offset = size_field("offset", 0) + length = size_field("length", expected) + if length != expected: + raise ONNXParseError(f"External length disagrees with tensor shape/dtype: {tensor.name}") + try: + if not candidate.is_file() or offset + expected > candidate.stat().st_size: + raise ONNXParseError(f"External tensor byte range exceeds file: {tensor.name}") + if expected == 0: + array = np.empty(shape, dtype=dtype) + array.setflags(write=False) + return array + return np.memmap(candidate, mode="r", dtype=dtype, offset=offset, shape=shape, order="C") + except (OSError, ValueError, OverflowError) as exc: + raise ONNXParseError(f"Cannot map external tensor {tensor.name}: {exc}") from exc + def _translate_node(self, node, output_names: set[str]) -> None: """Translate a single ONNX node to IR instructions.""" op_type = node.op_type @@ -244,13 +344,16 @@ def _constant_ints(self, value: Value) -> tuple[int, ...]: def _handle_constant(self, node, inputs, outputs): import numpy as np - from onnx import numpy_helper + from onnx import external_data_helper, numpy_helper attrs = self._attributes(node) if len(attrs) != 1 or len(outputs) != 1 or inputs: raise ONNXParseError("Constant requires one value attribute and one output") key, value = next(iter(attrs.items())) if key == "value": - data = numpy_helper.to_array(value, base_dir=self._base_dir) + if self._mmap_external_data and external_data_helper.uses_external_data(value): + data = self._map_external_initializer(value) + else: + data = numpy_helper.to_array(value, base_dir=self._base_dir) elif key in ("value_int", "value_ints"): data = np.asarray(value, dtype=np.int64) elif key in ("value_float", "value_floats"): @@ -323,11 +426,22 @@ def _handle_matmul(self, node, inputs: list[Value], def _handle_gelu(self, node, inputs: list[Value], outputs: list[str]) -> None: + # ONNX defaults to the exact erf formulation, while this IR's GELU + # deliberately implements the tanh approximation. Never substitute it + # for the default just because both operations share a name. + attrs = self._attributes(node) + if set(attrs) - {"approximate"} or attrs.get("approximate", b"none") != b"tanh": + raise ONNXParseError("Gelu supports only approximate='tanh'; exact Gelu is unsupported") result = self.builder.gelu(inputs[0]) self._define_outputs(outputs, result) def _handle_softmax(self, node, inputs: list[Value], outputs: list[str]) -> None: + # Before opset 13 Softmax flattens dimensions from axis onward and + # defaults to axis=1; the IR normalizes a single axis. Supporting the + # modern default for a legacy graph silently produces wrong values. + if self._opsets.get(node.domain, 0) < 13: + raise ONNXParseError("Softmax requires ONNX opset >= 13; legacy flatten semantics are unsupported") axis = -1 for attr in node.attribute: if attr.name == "axis": @@ -377,15 +491,17 @@ def _handle_conv(self, node, inputs: list[Value], def _handle_gemm(self, node, inputs: list[Value], outputs: list[str]) -> None: - a, w, b = inputs[0], inputs[1], inputs[2] - trans_a = False - trans_b = False - for attr in node.attribute: - if attr.name == "transA": - trans_a = attr.i != 0 - elif attr.name == "transB": - trans_b = attr.i != 0 - result = self.builder.gemm(a, w, b, trans_a, trans_b) + attrs = self._attributes(node) + if set(attrs) - {"alpha", "beta", "transA", "transB"}: + raise ONNXParseError("Unsupported Gemm attributes") + if len(inputs) not in (2, 3): + raise ONNXParseError("Gemm requires two matrices and an optional bias") + a, w = inputs[:2] + b = inputs[2] if len(inputs) == 3 else self.builder.make_const(0, a.dtype) + result = self.builder.gemm( + a, w, b, bool(attrs.get("transA", 0)), bool(attrs.get("transB", 0)), + alpha=attrs.get("alpha", 1.0), beta=attrs.get("beta", 1.0), + ) self._define_outputs(outputs, result) def _handle_sigmoid(self, node, inputs: list[Value], diff --git a/scratchv/ir/builder.py b/scratchv/ir/builder.py index 36e526c..4820297 100644 --- a/scratchv/ir/builder.py +++ b/scratchv/ir/builder.py @@ -256,10 +256,16 @@ def conv(self, x: Value, w: Value, b: Value, return dest def gemm(self, a: Value, w: Value, b: Value, - trans_a: bool = False, trans_b: bool = False) -> Value: + trans_a: bool = False, trans_b: bool = False, *, + alpha: float = 1.0, beta: float = 1.0) -> Value: dest = self.make_value(dtype=a.dtype) + scales = {} + if alpha != 1: + scales["alpha"] = alpha + if beta != 1: + scales["beta"] = beta self._emit(OpCode.GEMM, dest, [a, w, b], - trans_a=trans_a, trans_b=trans_b) + trans_a=trans_a, trans_b=trans_b, **scales) return dest def sigmoid(self, val: Value) -> Value: diff --git a/scratchv/runtime/riscv_external.py b/scratchv/runtime/riscv_external.py new file mode 100644 index 0000000..112ae1f --- /dev/null +++ b/scratchv/runtime/riscv_external.py @@ -0,0 +1,569 @@ +"""Bare-metal RV64 external-weight execution; QMP transports completed output. + +QMP only reads guest RAM after the guest's success frame. All model arithmetic +runs in RV64 instructions, without semihosting or host numerical substitution. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import ctypes +import hashlib +import json +import os +from pathlib import Path +import signal +import socket +import stat +import struct +import subprocess +import threading +import time + +import numpy as np + +from .riscv_tensor import ( + STARTUP_ASSEMBLY, LINKER_SCRIPT, RiscVToolchain, _creation_flags, + _run_process, _save_process_logs, _spec_layout, _stop_process_tree, + _terminate_invocation, _windows_job, input_layout, pack_inputs, toolchain_versions, +) +from .weight_bundle import plan_guest_memory, validate_weight_bundle +from .weight_transport import ( + stage_transport, validate_transport, remove_transport, save_transport_report, + refresh_transport_residuals, +) + + +FRAME = struct.Struct("<8sIQ") +MAGIC = b"SVEXT001" +MAX_ELF_BYTES = 100_000_000 + + +def sha256_file(path): + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def validate_external_elf(path, layout): + """Validate every load segment, including BSS, against code/stack bounds.""" + path = Path(path) + before = path.stat() + if not stat.S_ISREG(before.st_mode) or before.st_size >= MAX_ELF_BYTES: + raise ValueError("Code-only ELF must be smaller than 100,000,000 bytes") + def identity(info): + # Windows stat/fstat may expose different ctime meanings. Device, + # inode, size and mtime remain comparable across both APIs. + fields = (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns) + return fields + ((info.st_ctime_ns,) if os.name != "nt" else ()) + with path.open("rb") as stream: + if identity(os.fstat(stream.fileno())) != identity(before): + raise ValueError("ELF changed while opening for validation") + data = stream.read(MAX_ELF_BYTES) + if (len(data) != before.st_size + or identity(os.fstat(stream.fileno())) != identity(before)): + raise ValueError("ELF changed while reading for validation") + if len(data) < 64 or data[:7] != b"\x7fELF\x02\x01\x01": + raise ValueError("Expected little-endian ELF64") + kind, machine = struct.unpack_from(" len(data): + raise ValueError("Invalid ELF program header table") + segments = [] + for index in range(phcount): + typ, attrs, offset, vaddr, paddr, filesz, memsz, align = struct.unpack_from( + " memsz or offset + filesz > len(data) or vaddr != paddr + or paddr < layout["code_base"] or paddr + memsz > layout["code_limit"]): + raise ValueError("ELF load segment exceeds code region or has invalid sizes") + if align not in (0, 1) and (align & (align - 1) or (vaddr - offset) % align): + raise ValueError("Invalid ELF segment alignment") + segments.append({"address": paddr, "filesz": filesz, "memsz": memsz, "flags": attrs}) + ordered = sorted(segments, key=lambda item: item["address"]) + for left, right in zip(ordered, ordered[1:]): + if left["address"] + left["memsz"] > right["address"]: + raise ValueError("ELF load segments overlap") + if not any(row["flags"] & 1 and row["address"] <= entry < row["address"] + row["filesz"] + for row in segments): + raise ValueError("ELF entry is not in an executable file-backed segment") + if identity(path.stat()) != identity(before): + raise ValueError("ELF changed during validation") + # Hash precisely the bytes whose ELF structure was checked, not a second + # open of the pathname which could describe a different file by now. + return {"bytes": len(data), "entry": entry, "segments": segments, + "sha256": hashlib.sha256(data).hexdigest()} + + +@dataclass(frozen=True) +class ExternalExecutable: + elf_path: Path + bundle_dir: Path + manifest: dict + layout: dict + inputs: tuple + output: object + weights: tuple + toolchain: RiscVToolchain + evidence: dict + + +def guest_harness(artifact, manifest, layout): + offsets, _ = input_layout(artifact.inputs) + function = artifact.function_name + if not function.isascii() or not function.isidentifier(): + raise ValueError("Invalid C entry point") + inputs = ",".join(f"(const void *)0x{layout['input_base'] + n:x}UL" for n in offsets) or "0" + weights = ",".join(f"(const void *)0x{layout['weights_base'] + t['offset']:x}UL" + for t in manifest["tensors"]) or "0" + return r''' +typedef unsigned long usize; +typedef unsigned char u8; +typedef unsigned int u32; +typedef unsigned long long u64; +void *memcpy(void *dst, const void *src, usize n) { + u8 *d=dst; const u8 *s=src; for (usize i=0;i>(8*i))); + for (int i=0;i<8;++i) uart_byte((u8)(size>>(8*i))); + __asm__ volatile("fence rw,rw" ::: "memory"); + for (;;) __asm__ volatile("wfi"); +} +void sv_trap_report(u64 cause) { frame(0x80000000U|(u32)cause, 0); } +''' + f''' +extern int {function}(const void *const *, const void *const *, void *, usize, void *); +void sv_main(void) {{ + const void *inputs[]={{ {inputs} }}; + const void *weights[]={{ {weights} }}; + int status={function}(inputs,weights,(void *)0x{layout['workspace_base']:x}UL, + {artifact.workspace_bytes}UL,(void *)0x{layout['output_base']:x}UL); + frame((u32)status,{artifact.output.nbytes}ULL); +}} +''' + + +def build_external(artifact, bundle_dir, build_dir, toolchain, *, timeout=600.0): + if artifact.constant_storage != "external": + raise ValueError("External runtime requires external code generation") + manifest = validate_weight_bundle(bundle_dir, artifact.external_weights) + _, input_bytes = input_layout(artifact.inputs) + layout = plan_guest_memory(workspace_bytes=artifact.workspace_bytes, + weight_bytes=manifest["total_bytes"], input_bytes=input_bytes, + output_bytes=artifact.output.nbytes) + build_dir = Path(build_dir).resolve() + build_dir.mkdir(parents=True, exist_ok=False) + linker = LINKER_SCRIPT.replace("0x1c000000", hex(layout["code_capacity"])) + sources = {"model.c": artifact.source, "guest.c": guest_harness(artifact, manifest, layout), + "start.S": STARTUP_ASSEMBLY, "link.ld": linker} + for name, source in sources.items(): + (build_dir / name).write_text(source, encoding="utf-8") + target = (["-target", "riscv64-linux-musl", "-static"] if toolchain.is_zig else + ["--target=riscv64-unknown-elf", "-fuse-ld=lld", "-nostdlib"]) + arch = (["-mcpu=generic_rv64+m+a+f+d+c+zicsr+zifencei"] if toolchain.is_zig else ["-march=rv64gc"]) + command = [*toolchain.cc, *target, "-O2", "-std=c11", *arch, "-mabi=lp64d", "-mcmodel=medany", + "-msmall-data-limit=0", "-mno-relax", "-ffreestanding", "-fno-builtin", + "-fno-stack-protector", "-fno-pie", "-fPIC" if toolchain.is_zig else "-fno-pic", + "-fno-fast-math", "-ffp-contract=off", "-fno-strict-aliasing", + "-ffunction-sections", "-fdata-sections", *artifact.compile_flags, + "model.c", "guest.c", "start.S", "-Wl,-T,link.ld", "-Wl,-e,sv_boot", + "-Wl,--gc-sections", "-Wl,--build-id=none", *(["-lm"] if toolchain.is_zig else []), + "-o", "model.elf"] + env = dict(os.environ) + temporary = build_dir / "tmp" + temporary.mkdir() + env.update(TMP=str(temporary), TEMP=str(temporary), TMPDIR=str(temporary)) + if toolchain.is_zig: + shared_cache = Path(__file__).resolve().parents[2] / "output" / ".riscv-zig-cache" + env["ZIG_GLOBAL_CACHE_DIR"] = str( + Path(os.environ.get("SCRATCHV_ZIG_CACHE", os.environ.get("ZIG_GLOBAL_CACHE_DIR", shared_cache))).resolve() + ) + env["ZIG_LOCAL_CACHE_DIR"] = str(build_dir / ".zig-cache") + start = time.perf_counter() + try: + result = _run_process(command, cwd=build_dir, timeout=timeout, env=env) + except subprocess.TimeoutExpired as exc: + _save_process_logs(build_dir, "compiler", exc.stdout, exc.stderr) + raise TimeoutError(f"External RV64 build exceeded {timeout}s") from exc + _save_process_logs(build_dir, "compiler", result.stdout, result.stderr) + if result.returncode: + raise RuntimeError(result.stderr.decode(errors="replace")[-12000:]) + evidence = validate_external_elf(build_dir / "model.elf", layout) + evidence.update(compile_seconds=time.perf_counter() - start, command=command, + versions=toolchain_versions(toolchain), + sources={name: sha256_file(build_dir / name) for name in sources}) + (build_dir / "layout.json").write_text(json.dumps(layout, indent=2) + "\n", encoding="utf-8") + (build_dir / "build.json").write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + return ExternalExecutable(build_dir / "model.elf", Path(bundle_dir).resolve(), manifest, layout, + tuple(artifact.inputs), artifact.output, tuple(artifact.external_weights), + toolchain, evidence) + + +class QMP: + """A bounded localhost QMP client that ignores asynchronous events.""" + def __init__(self, port, deadline): + self.deadline = deadline + self.serial = 0 + self.socket = socket.create_connection(("127.0.0.1", port), timeout=self.remaining()) + self.stream = None + try: + self.stream = self.socket.makefile("rwb", buffering=0) + if "QMP" not in self.read(): + raise RuntimeError("Invalid QMP greeting") + self.command("qmp_capabilities") + except BaseException: + try: + self.close() + except BaseException: + pass + raise + + def remaining(self): + value = self.deadline - time.monotonic() + if value <= 0: + raise TimeoutError("QMP deadline exceeded") + return min(value, 30.0) + + def read(self): + self.socket.settimeout(self.remaining()) + line = self.stream.readline(1024 * 1024) + if not line: + raise EOFError("QMP connection closed before response") + if len(line) >= 1024 * 1024 or not line.endswith(b"\n"): + raise RuntimeError("Truncated/oversized QMP response") + return json.loads(line) + + def command(self, name, arguments=None): + self.serial += 1 + request = {"execute": name, "id": self.serial} + if arguments is not None: + request["arguments"] = arguments + payload = json.dumps(request).encode() + b"\n" + self.socket.settimeout(self.remaining()) + if self.stream.write(payload) != len(payload): + raise RuntimeError("Incomplete QMP command write") + while True: + try: + result = self.read() + except (EOFError, ConnectionResetError): + # QMP quit may terminate QEMU before its reply is delivered: + # https://www.qemu.org/docs/master/interop/qemu-qmp-ref.html#command-quit + # Only a completely written, argument-free quit gets this + # exception. The caller must still require a normal process + # exit and validate the completed output and resource cleanup. + if name == "quit" and arguments is None: + return None + raise + if result.get("id") == self.serial: + if "error" in result: + raise RuntimeError(f"QMP {name}: {result['error']}") + if "return" not in result: + raise RuntimeError("Invalid QMP reply") + return result["return"] + + def close(self): + first_error = None + for resource in (self.stream, self.socket): + if resource is not None: + try: + resource.close() + except BaseException as exc: + if first_error is None: + first_error = exc + if first_error is not None: + raise first_error + + +def decode_completion(data, output_bytes): + if len(data) != FRAME.size: + raise ValueError("Missing, truncated or trailing completion frame") + magic, status, size = FRAME.unpack(data) + if magic != MAGIC: + raise ValueError("Invalid completion magic") + if status: + raise RuntimeError(f"RV64 guest failed with status 0x{status:08x}") + if size != output_bytes: + raise ValueError("Guest output length differs from generated ABI") + + +def _linux_rss(status): + rows = dict(line.split(":", 1) for line in status.splitlines() if ":" in line) + try: + amount, unit = rows["VmRSS"].split() + amount = int(amount) + if amount < 0 or unit != "kB": + raise ValueError("Invalid VmRSS unit/value") + return amount * 1024 + except (KeyError, ValueError) as exc: + raise OSError("QEMU resident memory is unavailable") from exc + + +def _owned_qemu_rss(process): + """Read only an owned Popen child, avoiding PID reuse after it is reaped.""" + if os.name == "nt": + from ctypes import wintypes + + class Counters(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("faults", wintypes.DWORD), + ("peak_rss", ctypes.c_size_t), ("rss", ctypes.c_size_t), + ("peak_paged", ctypes.c_size_t), ("paged", ctypes.c_size_t), + ("peak_nonpaged", ctypes.c_size_t), ("nonpaged", ctypes.c_size_t), + ("pagefile", ctypes.c_size_t), ("peak_pagefile", ctypes.c_size_t)] + get_memory = ctypes.WinDLL("psapi", use_last_error=True).GetProcessMemoryInfo + get_memory.argtypes = [wintypes.HANDLE, ctypes.POINTER(Counters), wintypes.DWORD] + get_memory.restype = wintypes.BOOL + values = Counters() + values.cb = ctypes.sizeof(values) + if not get_memory(int(process._handle), ctypes.byref(values), values.cb): + raise OSError(ctypes.get_last_error(), "Cannot read owned QEMU resident memory") + return int(values.rss) + # Popen.poll/wait use this lock too. While held, a live child cannot be + # reaped and replaced by an unrelated process with the same PID. + with process._waitpid_lock: + if process.returncode is not None: + return None + return _linux_rss(Path(f"/proc/{process.pid}/status").read_text(encoding="utf-8")) + + +class _QemuMemoryMonitor: + """Sample process RSS; this is observability, not an enforced RAM limit.""" + def __init__(self, process, interval=0.2): + self.process, self.interval = process, interval + self.samples, self.errors, self.peak, self.last_error = 0, 0, None, None + self.stopped = threading.Event() + self.thread = threading.Thread(target=self._run, name="scratchv-qemu-rss", daemon=True) + + def _run(self): + while not self.stopped.is_set(): + try: + value = _owned_qemu_rss(self.process) + if value is not None: + self.samples += 1 + self.peak = value if self.peak is None else max(self.peak, value) + except (OSError, AttributeError, ValueError) as exc: + self.errors += 1 + self.last_error = f"{type(exc).__name__}: {exc}" + self.stopped.wait(self.interval) + + def start(self): + self.thread.start() + + def close(self): + self.stopped.set() + self.thread.join(timeout=1) + if self.thread.is_alive(): + raise RuntimeError("QEMU RSS monitor did not stop") + + def report(self): + return { + "sampled_peak_qemu_rss_bytes": self.peak, + "qemu_rss_observed_samples": self.samples, + "qemu_rss_availability": ("unavailable" if not self.samples else + "partial" if self.errors else "available"), + "qemu_rss_sample_errors": self.errors, + "qemu_rss_last_error": self.last_error, + "qemu_rss_sample_interval_s": self.interval, + "qemu_rss_scope": "QEMU process RSS; distinct from guest layout and not a hard limit", + } + + +def run_external(executable, inputs, run_dir, *, timeout=3600.0): + """Run a fresh guest, require completion, stop, dump exact output, then quit.""" + if not np.isfinite(timeout) or timeout <= 0: + raise ValueError("timeout must be positive and finite") + if sha256_file(executable.elf_path) != executable.evidence["sha256"]: + raise ValueError("ELF changed after build") + manifest = validate_weight_bundle(executable.bundle_dir, executable.weights) + if manifest != executable.manifest: + raise ValueError("Weight manifest changed after build") + payload = pack_inputs(executable.inputs, inputs) + run_dir = Path(run_dir).resolve() + run_dir.mkdir(parents=True, exist_ok=False) + (run_dir / "inputs.bin").write_bytes(payload or b"\0") + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + layout = executable.layout + command = [executable.toolchain.qemu, "-machine", "virt", "-cpu", "rv64", "-accel", "tcg", + "-smp", "1", "-m", str(layout["memory_mib"]), "-bios", "none", "-display", "none", + "-monitor", "none", "-serial", "file:uart.bin", "-no-reboot", + "-qmp", f"tcp:127.0.0.1:{port},server=on,wait=off", + "-kernel", str(executable.elf_path), "-device", + f"loader,file=inputs.bin,addr=0x{layout['input_base']:x},force-raw=on"] + options = {"creationflags": _creation_flags()} + if os.name != "nt": + options["start_new_session"] = True + started = time.perf_counter() + report = {"passed": False, "status": "FAIL", "command": command, "timeout_s": timeout, + "transport": "QMP physical memory dump after guest completion", "guest_completed": False} + transport_dir = run_dir / "weight-transport" + report["weight_transport"] = {} + qmp, process, job, primary_error, monitor = None, None, None, None, None + qemu_started, qemu_exited = None, None + previous_term = None + try: + # SIGTERM must unwind through cleanup: QEMU owns a separate POSIX + # session and would otherwise survive a supervisor cancelling Python. + if os.name != "nt" and threading.current_thread() is threading.main_thread(): + previous_term = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, _terminate_invocation) + report["stage"] = "weight_transport" + stage_transport(executable.bundle_dir / "weights.bin", manifest, transport_dir, + report["weight_transport"]) + for row in report["weight_transport"]["files"]: + path = str(transport_dir / row["file"]).replace(",", ",,") + command += ["-device", f"loader,file={path},addr=0x{layout['weights_base'] + row['offset']:x},force-raw=on"] + # Empty bundles intentionally emit no raw loader: QEMU rejects 0-byte files. + report["stage"] = "qemu" + deadline = time.monotonic() + timeout + with (run_dir / "qemu.stdout").open("wb") as stdout, (run_dir / "qemu.stderr").open("wb") as stderr: + qemu_started = time.perf_counter() + process = subprocess.Popen(command, cwd=run_dir, stdout=stdout, stderr=stderr, **options) + job = _windows_job(process) + monitor = _QemuMemoryMonitor(process) + monitor.start() + uart = run_dir / "uart.bin" + while True: + if time.monotonic() >= deadline: + raise TimeoutError(f"RV64 forward exceeded {timeout}s") + if uart.exists() and uart.stat().st_size >= FRAME.size: + decode_completion(uart.read_bytes(), executable.output.nbytes) + break + if process.poll() is not None: + raise RuntimeError(f"QEMU exited before guest completion ({process.returncode})") + time.sleep(0.05) + report.update(guest_completed=True, forward_wall_s=time.perf_counter() - qemu_started) + qmp = QMP(port, deadline) + qmp.command("stop") + dump_started = time.perf_counter() + output_path = run_dir / "output.bin" + if executable.output.nbytes: + reply = qmp.command("human-monitor-command", {"command-line": + f"pmemsave 0x{layout['output_base']:x} {executable.output.nbytes} output.bin"}) + if reply.strip(): + raise RuntimeError(f"QMP memory dump failed: {reply}") + else: + output_path.write_bytes(b"") + if output_path.stat().st_size != executable.output.nbytes: + raise ValueError("Output dump size mismatch") + report["output_dump_s"] = time.perf_counter() - dump_started + report["qmp_quit_reply_received"] = qmp.command("quit") is not None + process.wait(timeout=min(10, max(0.1, deadline - time.monotonic()))) + qemu_exited = time.perf_counter() + if process.returncode: + raise RuntimeError(f"QEMU shutdown failed ({process.returncode})") + shape, dtype, _ = _spec_layout(executable.output) + output = (np.memmap(output_path, dtype=dtype.newbyteorder("<"), mode="r", shape=shape) + if executable.output.nbytes else np.empty(shape, dtype=dtype)) + for offset in range(0, output.size, 262144): + if not np.isfinite(output.reshape(-1)[offset:offset + 262144]).all(): + raise ValueError("Nonfinite RV64 output") + if validate_weight_bundle(executable.bundle_dir, executable.weights) != manifest: + raise ValueError("Weight bundle changed during execution") + report["stage"] = "weight_transport_postcheck" + report["weight_transport"]["post_validation_elapsed_s"] = validate_transport( + transport_dir, report["weight_transport"]) + report.update(passed=True, status="PASS", exit_code=process.returncode, + output_sha256=sha256_file(output_path), elf_sha256=executable.evidence["sha256"], + weight_sha256=manifest["sha256"], input_sha256=sha256_file(run_dir / "inputs.bin")) + return output, report + except BaseException as exc: + primary_error = exc + report.update(passed=False, status="FAIL", error=f"{type(exc).__name__}: {exc}") + if report.get("stage") == "weight_transport_postcheck": + report["weight_transport"].update(status="FAIL", stage="postcheck", + error=report["error"]) + raise + finally: + cleanup_errors = [] + + def cleanup(label, action): + try: + action() + except BaseException as exc: + cleanup_errors.append(f"{label}: {type(exc).__name__}: {exc}") + + if qmp is not None: + cleanup("qmp.close", qmp.close) + report.update(sampled_peak_qemu_rss_bytes=None, qemu_rss_observed_samples=0, + qemu_rss_availability="unavailable", qemu_rss_sample_interval_s=0.2) + if monitor is not None: + cleanup("QEMU RSS monitor close", monitor.close) + cleanup("QEMU RSS monitor report", lambda: report.update(monitor.report())) + if process is not None: + # Each cleanup step is attempted even when a preceding one fails. + # Bounded waits prevent a failed kill from hanging report creation. + try: + alive = process.poll() is None + except BaseException as exc: + cleanup_errors.append(f"process.poll: {type(exc).__name__}: {exc}") + alive = True + if alive: + cleanup("process-tree termination", lambda: _stop_process_tree(process, job)) + try: + process.wait(timeout=10) + except BaseException as exc: + cleanup_errors.append(f"process.wait: {type(exc).__name__}: {exc}") + cleanup("process.kill fallback", process.kill) + cleanup("process.wait fallback", lambda: process.wait(timeout=5)) + if process.returncode is not None and qemu_exited is None: + qemu_exited = time.perf_counter() + report["qemu_wall_s"] = (qemu_exited - qemu_started + if qemu_exited is not None and qemu_started is not None else None) + if job is not None: + def close_job(): + if job[0].CloseHandle(job[1]) == 0: + raise OSError("CloseHandle failed") + cleanup("Windows job close", close_job) + if previous_term is not None: + cleanup("restore SIGTERM", lambda: signal.signal(signal.SIGTERM, previous_term)) + # Only a fully successful, stopped/reaped guest permits copy removal. + # Failed/still-running invocations retain transport data for inspection. + if (report["passed"] and primary_error is None and not cleanup_errors + and process is not None and process.returncode == 0): + cleanup("weight transport cleanup", lambda: remove_transport( + transport_dir, report["weight_transport"])) + # Staging evidence can become stale during the guest or its postcheck. + # Observe owned copies again even on failure, without deleting them or + # allowing a secondary stat error to replace the original exception. + if "files" in report["weight_transport"]: + cleanup("weight transport residual observation", lambda: refresh_transport_residuals( + transport_dir, report["weight_transport"])) + def preserve_transport_report(): + if (transport_dir / "transport.json").is_file(): + save_transport_report(transport_dir, report["weight_transport"]) + cleanup("weight transport report", preserve_transport_report) + report["elapsed_s"] = time.perf_counter() - started + if cleanup_errors: + report.update(passed=False, status="FAIL", cleanup_errors=cleanup_errors) + if primary_error is None: + report["error"] = "RuntimeError: runtime cleanup failed" + try: + (run_dir / "run.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + except BaseException as exc: + if primary_error is None: + raise + if hasattr(primary_error, "add_note"): + primary_error.add_note(f"Could not preserve run.json: {type(exc).__name__}: {exc}") + if cleanup_errors and primary_error is None: + raise RuntimeError("Runtime cleanup failed: " + "; ".join(cleanup_errors)) diff --git a/scratchv/runtime/riscv_tensor.py b/scratchv/runtime/riscv_tensor.py index d6a401f..1860821 100644 --- a/scratchv/runtime/riscv_tensor.py +++ b/scratchv/runtime/riscv_tensor.py @@ -158,56 +158,102 @@ def _stop_process_tree(process: subprocess.Popen, job=None) -> None: def _run_process(command, *, cwd, timeout, env=None) -> subprocess.CompletedProcess: + """Run one owned tree with bounded process waits, including setup failures.""" options = {"creationflags": _creation_flags()} if os.name == "nt": options["creationflags"] |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) else: options["start_new_session"] = True - with subprocess.Popen(command, cwd=cwd, env=env, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, **options) as process: - job = _windows_job(process) + process, job, primary_error, previous_term = None, None, None, None + try: # The W2 supervisor first sends SIGTERM to the probe. This invocation # owns a different session, so killing the probe group alone would - # orphan its compiler/QEMU. Unwind through cleanup before exiting. - previous_term = None + # orphan its compiler/QEMU. Install before spawning, so setup failures + # and interruptions also unwind through the same cleanup boundary. if os.name != "nt" and threading.current_thread() is threading.main_thread(): previous_term = signal.getsignal(signal.SIGTERM) signal.signal(signal.SIGTERM, _terminate_invocation) - try: - stdout, stderr = process.communicate(timeout=timeout) - except subprocess.TimeoutExpired as exc: - _stop_process_tree(process, job) - try: - stdout, stderr = process.communicate(timeout=10) - except subprocess.TimeoutExpired as drain: - # A failed OS-level tree termination must not turn a bounded - # compiler timeout into an unbounded pipe read. - process.kill() - if process.stdout: - process.stdout.close() - if process.stderr: - process.stderr.close() - process.wait(timeout=5) - stdout, stderr = drain.output or b"", drain.stderr or b"" - raise subprocess.TimeoutExpired(command, timeout, output=stdout, stderr=stderr) from exc - except BaseException: - # Cancellation and Ctrl-C must also close the owned invocation. - # Do not replace the primary interruption with a cleanup failure. - try: - _stop_process_tree(process, job) - process.communicate(timeout=5) - except (OSError, subprocess.SubprocessError): - process.kill() - for stream in (process.stdout, process.stderr): - if stream: - stream.close() - raise - finally: - if previous_term is not None: - signal.signal(signal.SIGTERM, previous_term) - if job is not None: - job[0].CloseHandle(job[1]) + # Do not use Popen's context manager: its implicit __exit__.wait() has + # no deadline, even if Job setup failed before communicate() began. + process = subprocess.Popen(command, cwd=cwd, env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, **options) + job = _windows_job(process) + stdout, stderr = process.communicate(timeout=timeout) return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) + except BaseException as exc: + primary_error = exc + raise + finally: + cleanup_errors = [] + drain_failed = False + + def cleanup(label, action): + try: + action() + except BaseException as exc: + cleanup_errors.append(f"{label}: {type(exc).__name__}: {exc}") + + def close_pipe(stream): + if not drain_failed or os.name != "nt": + stream.close() + return + # Windows communicate() owns reader threads. If a descendant kept + # a pipe open after a failed tree kill, BufferedReader.close() can + # block acquiring its reader's lock. Do not turn bounded recovery + # into an unbounded wait on that lock. The daemon keeps the stream + # alive and eventually closes it when the reader releases the lock. + failures = [] + def close(): + try: + stream.close() + except BaseException as exc: + failures.append(exc) + closer = threading.Thread(target=close, daemon=True) + closer.start() + closer.join(timeout=0.25) + if closer.is_alive(): + raise TimeoutError("Pipe close is pending on a Windows reader thread") + if failures: + raise failures[0] + + if process is not None: + if primary_error is not None: + cleanup("process-tree termination", lambda: _stop_process_tree(process, job)) + try: + stdout, stderr = process.communicate(timeout=10) + if isinstance(primary_error, subprocess.TimeoutExpired): + primary_error.output, primary_error.stderr = stdout, stderr + except BaseException as exc: + drain_failed = True + cleanup_errors.append(f"pipe drain: {type(exc).__name__}: {exc}") + if (isinstance(primary_error, subprocess.TimeoutExpired) + and isinstance(exc, subprocess.TimeoutExpired)): + if exc.output is not None: + primary_error.output = exc.output + if exc.stderr is not None: + primary_error.stderr = exc.stderr + cleanup("process.kill fallback", process.kill) + cleanup("process.wait fallback", lambda: process.wait(timeout=5)) + # Kill-on-close is a second chance to release inherited pipe handles + # before attempting their close, even if explicit tree termination failed. + if job is not None: + def close_job(): + if job[0].CloseHandle(job[1]) == 0: + raise OSError("CloseHandle failed") + cleanup("close Windows Job", close_job) + if process is not None: + for name in ("stdout", "stderr"): + stream = getattr(process, name, None) + if stream is not None: + cleanup(f"close {name}", lambda stream=stream: close_pipe(stream)) + if previous_term is not None: + cleanup("restore SIGTERM", lambda: signal.signal(signal.SIGTERM, previous_term)) + if cleanup_errors: + detail = "; ".join(cleanup_errors) + if primary_error is None: + raise RuntimeError("Process cleanup failed: " + detail) + if hasattr(primary_error, "add_note"): + primary_error.add_note("Process cleanup: " + detail) def _terminate_invocation(signum, frame): diff --git a/scratchv/runtime/weight_bundle.py b/scratchv/runtime/weight_bundle.py new file mode 100644 index 0000000..9346350 --- /dev/null +++ b/scratchv/runtime/weight_bundle.py @@ -0,0 +1,373 @@ +"""Versioned, streaming external tensor weights and an explicit RV64 RAM plan. + +A bundle has fixed public files ``manifest.json`` and ``weights.bin``. Hashes detect damage, +not provenance: callers must bind ``expected_specs`` and the model identity to a +trusted source. Validation is a snapshot, not a lock for a later QEMU loader. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import hashlib +import json +import math +import os +from pathlib import Path +import re +import stat + +import numpy as np + + +SCHEMA = "scratchv.weights" +VERSION = 1 +ALIGNMENT = 64 +CHUNK_BYTES = 1024 * 1024 +MAX_MANIFEST_BYTES = 16 * 1024 * 1024 +UINT64_MAX = (1 << 64) - 1 +MIB = 1024 * 1024 +_DTYPES = {"f32": np.dtype(" 16: + raise WeightBundleError(f"{label} must have at most 16 static dimensions") + result = [_integer(d, label, maximum=(1 << 31) - 1, numpy_ok=numpy_ok) for d in value] + if math.prod(result) > (1 << 31) - 1: + raise WeightBundleError(f"{label} exceeds the static tensor element limit") + return result + + +def _name(value): + if not isinstance(value, str) or not value or len(value) > 65536 or "\0" in value: + raise WeightBundleError("Tensor name must be a nonempty string without NUL") + return value + + +def _spec_metadata(specs): + result, names = [], set() + for index, spec in enumerate(specs): + try: + name = _name(spec.name) + dtype = getattr(spec.dtype, "value", spec.dtype) + if not isinstance(dtype, str) or dtype not in _DTYPES: + raise WeightBundleError(f"Unsupported dtype for {name!r}: {dtype!r}") + shape = _shape(spec.shape, f"Shape of {name!r}", numpy_ok=True) + numpy_dtype = np.dtype(spec.numpy_dtype) + if numpy_dtype.newbyteorder("<") != _DTYPES[dtype]: + raise WeightBundleError(f"Spec dtype/numpy_dtype disagree for {name!r}") + nbytes = math.prod(shape) * _DTYPES[dtype].itemsize + if _integer(spec.nbytes, f"nbytes of {name!r}", numpy_ok=True) != nbytes: + raise WeightBundleError(f"Spec nbytes/shape disagree for {name!r}") + except (AttributeError, TypeError) as exc: + raise WeightBundleError(f"Invalid tensor spec at index {index}") from exc + if name in names: + raise WeightBundleError(f"Tensor names must be unique: {name!r}") + names.add(name) + result.append({"index": index, "name": name, "dtype": dtype, + "shape": shape, "nbytes": nbytes}) + return result + + +def _iter_array_chunks(array, dtype): + """C-order, little-endian blocks, bounded even for strided/mapped arrays.""" + iterator = np.nditer(array, flags=["external_loop", "buffered", "zerosize_ok"], + op_flags=["readonly"], op_dtypes=[dtype], casting="equiv", + order="C", buffersize=CHUNK_BYTES // dtype.itemsize) + with iterator: + for block in iterator: + if dtype.kind == "f" and not np.isfinite(block).all(): + raise WeightBundleError("Nonfinite floating-point weight") + yield block.tobytes(order="C") + + +def _reject_links(path): + """Reject symlinks/junctions in existing components, including the root.""" + path = Path(os.path.abspath(os.fspath(path))) + for component in (*reversed(path.parents), path): + if component.is_symlink() or (hasattr(component, "is_junction") and component.is_junction()): + raise WeightBundleError(f"Bundle path must not traverse a link: {component}") + return path + + +def write_weight_bundle(specs, arrays, directory): + """Write a new bundle directory without copying the complete model. + + ``arrays`` is a name->ndarray mapping or an iterable in ``specs`` order. + Parent directories must exist; even an empty existing output is rejected. + On failure a new directory may contain partial weights, but no valid manifest + is published. The caller owns and must keep source arrays stable while writing. + """ + metadata = _spec_metadata(specs) + names = [entry["name"] for entry in metadata] + if isinstance(arrays, Mapping): + if set(arrays) != set(names): + raise WeightBundleError("Array names must exactly match tensor specs") + array_iter = (arrays[name] for name in names) + else: + array_iter = iter(arrays) + directory = _reject_links(directory) + directory.mkdir(exist_ok=False) + total_hash = hashlib.sha256() + tensors, cursor = [], 0 + with (directory / "weights.bin").open("xb") as stream: + for entry in metadata: + try: + array = next(array_iter) + except StopIteration as exc: + raise WeightBundleError("Fewer arrays than tensor specs") from exc + dtype = _DTYPES[entry["dtype"]] + if not isinstance(array, np.ndarray): + raise WeightBundleError(f"Weight {entry['name']!r} must be an ndarray") + if list(array.shape) != entry["shape"] or array.dtype.newbyteorder("<") != dtype: + raise WeightBundleError(f"Weight {entry['name']!r} shape/dtype mismatch") + offset = _align(cursor) + padding = b"\0" * (offset - cursor) + stream.write(padding) + total_hash.update(padding) + tensor_hash, written = hashlib.sha256(), 0 + for chunk in _iter_array_chunks(array, dtype): + stream.write(chunk) + tensor_hash.update(chunk) + total_hash.update(chunk) + written += len(chunk) + if written != entry["nbytes"]: + raise WeightBundleError("Weight changed size while being written") + tensors.append({**entry, "offset": offset, "sha256": tensor_hash.hexdigest()}) + cursor = offset + written + sentinel = object() + if next(array_iter, sentinel) is not sentinel: + raise WeightBundleError("More arrays than tensor specs") + stream.flush() + os.fsync(stream.fileno()) + manifest = {"schema": SCHEMA, "version": VERSION, "endian": "little", + "alignment": ALIGNMENT, "total_bytes": cursor, + "sha256": total_hash.hexdigest(), "tensors": tensors} + payload = (json.dumps(manifest, indent=2, ensure_ascii=True) + "\n").encode("utf-8") + if len(payload) > MAX_MANIFEST_BYTES: + raise WeightBundleError("Manifest exceeds size limit") + # Publish only after durable tensor and manifest writes. A hard link is an + # atomic, no-replace publication within the same directory on Linux/Windows. + # Any pre-publication failure leaves no manifest.json for a reader to accept. + pending = directory / "manifest.pending.json" + with pending.open("xb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.link(pending, directory / "manifest.json") + try: + pending.unlink() + except OSError: + # The published bundle is complete. Cleanup failure is not data failure; + # validators only consume the two fixed public filenames. + pass + return manifest + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise WeightBundleError(f"Duplicate JSON key: {key!r}") + result[key] = value + return result + + +def _reject_constant(value): + raise WeightBundleError(f"Invalid JSON numeric constant: {value}") + + +def _fingerprint(info): + # CPython/Windows lstat and fstat can report creation/change time differently. + # POSIX ctime is comparable and adds a useful mutation check there. + change_time = info.st_ctime_ns if os.name != "nt" else None + return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, change_time + + +def _open_regular(path): + before = path.lstat() + if not stat.S_ISREG(before.st_mode): + raise WeightBundleError(f"Expected regular file: {path.name}") + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags) + stream = os.fdopen(fd, "rb") + if _fingerprint(before) != _fingerprint(os.fstat(stream.fileno())): + stream.close() + raise WeightBundleError(f"File changed while opening: {path.name}") + return stream, before + + +def _check_unchanged(stream, path, before): + if (_fingerprint(os.fstat(stream.fileno())) != _fingerprint(before) + or _fingerprint(path.lstat()) != _fingerprint(before)): + raise WeightBundleError(f"File changed during validation: {path.name}") + + +def _sha(value, label): + if not isinstance(value, str) or not _HEX_SHA256.fullmatch(value): + raise WeightBundleError(f"Invalid SHA-256 for {label}") + + +def _validate_manifest(manifest, expected_specs): + if not isinstance(manifest, dict) or set(manifest) != _TOP_KEYS: + raise WeightBundleError("Manifest must contain exactly the supported schema fields") + if manifest["schema"] != SCHEMA or _integer(manifest["version"], "version") != VERSION: + raise WeightBundleError("Unsupported weight bundle schema/version") + if manifest["endian"] != "little" or _integer(manifest["alignment"], "alignment") != ALIGNMENT: + raise WeightBundleError("Unsupported endian/alignment") + total_bytes = _integer(manifest["total_bytes"], "total_bytes") + _sha(manifest["sha256"], "weights.bin") + tensors = manifest["tensors"] + if not isinstance(tensors, list): + raise WeightBundleError("tensors must be an ordered list") + names, cursor = set(), 0 + for index, tensor in enumerate(tensors): + if not isinstance(tensor, dict) or set(tensor) != _TENSOR_KEYS: + raise WeightBundleError("Tensor entry contains unsupported schema fields") + if _integer(tensor["index"], "tensor index") != index: + raise WeightBundleError("Tensor indices must be unique, ordered and contiguous") + name = _name(tensor["name"]) + if name in names: + raise WeightBundleError("Tensor names must be unique") + names.add(name) + dtype = tensor["dtype"] + if not isinstance(dtype, str) or dtype not in _DTYPES: + raise WeightBundleError(f"Unsupported tensor dtype: {dtype!r}") + if not isinstance(tensor["shape"], list): + raise WeightBundleError("Manifest tensor shape must be a list") + shape = _shape(tensor["shape"], f"Shape of {name!r}") + nbytes = _integer(tensor["nbytes"], "tensor nbytes") + if nbytes != math.prod(shape) * _DTYPES[dtype].itemsize: + raise WeightBundleError("Tensor shape/dtype/nbytes mismatch") + offset = _integer(tensor["offset"], "tensor offset") + if offset != _align(cursor): + raise WeightBundleError("Tensor offsets must be aligned, ordered and non-overlapping") + cursor = offset + nbytes + if cursor > total_bytes: + raise WeightBundleError("Tensor exceeds weights.bin bounds") + _sha(tensor["sha256"], name) + if cursor != total_bytes: + raise WeightBundleError("total_bytes does not match final tensor boundary") + if expected_specs is not None: + expected = _spec_metadata(expected_specs) + actual = [{key: tensor[key] for key in ("index", "name", "dtype", "shape", "nbytes")} + for tensor in tensors] + if actual != expected: + raise WeightBundleError("Manifest tensors differ from trusted expected specs") + + +def validate_weight_bundle(directory, expected_specs=None): + """Validate fixed bundle files, all byte hashes, padding and finite fp32. + + Checks are bounded-memory and strict, but cannot authenticate a self-authored + manifest or guarantee that a file stays unchanged after this call returns. + """ + directory = _reject_links(directory) + if not directory.is_dir(): + raise WeightBundleError("Bundle directory does not exist") + try: + manifest_path = directory / "manifest.json" + manifest_stream, manifest_before = _open_regular(manifest_path) + with manifest_stream: + if manifest_before.st_size > MAX_MANIFEST_BYTES: + raise WeightBundleError("Manifest exceeds size limit") + payload = manifest_stream.read(MAX_MANIFEST_BYTES + 1) + if len(payload) > MAX_MANIFEST_BYTES: + raise WeightBundleError("Manifest exceeds size limit") + manifest = json.loads(payload.decode("utf-8"), object_pairs_hook=_unique_object, + parse_constant=_reject_constant) + _validate_manifest(manifest, expected_specs) + weights_path = directory / "weights.bin" + weights_stream, weights_before = _open_regular(weights_path) + with weights_stream: + if weights_before.st_size != manifest["total_bytes"]: + raise WeightBundleError("weights.bin length mismatch (truncated or extra bytes)") + total_hash, cursor = hashlib.sha256(), 0 + for tensor in manifest["tensors"]: + padding = weights_stream.read(tensor["offset"] - cursor) + if len(padding) != tensor["offset"] - cursor or any(padding): + raise WeightBundleError("Invalid or truncated alignment padding") + total_hash.update(padding) + remaining, tensor_hash = tensor["nbytes"], hashlib.sha256() + while remaining: + chunk = weights_stream.read(min(remaining, CHUNK_BYTES)) + if len(chunk) != min(remaining, CHUNK_BYTES): + raise WeightBundleError("Truncated weight tensor") + if tensor["dtype"] == "f32" and not np.isfinite(np.frombuffer(chunk, dtype=" UINT64_MAX: + raise WeightBundleError("Guest address range overflows uint64") + regions[name] = {"base": base, "end": end, "nbytes": nbytes} + cursor = end + used_bytes = cursor - ram_base + margin_bytes = max(16 * MIB, (used_bytes + 19) // 20) + ram_bytes = _align(used_bytes + margin_bytes, MIB) + if ram_base + ram_bytes > UINT64_MAX: + raise WeightBundleError("Guest RAM including margin overflows uint64") + return {"ram_base": ram_base, "ram_end": ram_base + ram_bytes, + "ram_bytes": ram_bytes, "memory_mib": ram_bytes // MIB, + "alignment": ALIGNMENT, "used_bytes": used_bytes, + "margin_bytes": ram_bytes - used_bytes, "regions": regions, + "code_base": ram_base, "code_capacity": code_capacity, + "code_limit": regions["code"]["end"], + "stack_bottom": regions["stack"]["base"], "stack_top": regions["stack"]["end"], + "input_base": regions["inputs"]["base"], "output_base": regions["outputs"]["base"], + "workspace_base": regions["workspace"]["base"], "weights_base": regions["weights"]["base"]} diff --git a/scratchv/runtime/weight_transport.py b/scratchv/runtime/weight_transport.py new file mode 100644 index 0000000..3515bbb --- /dev/null +++ b/scratchv/runtime/weight_transport.py @@ -0,0 +1,260 @@ +"""Bounded QEMU raw-loader copies of an unchanged standard weight bundle. + +Some QEMU releases issue a single host read per raw file. Transport files stay +well below Linux's single-read limit; the public bundle remains weights.bin. +""" +from __future__ import annotations + +import hashlib +import json +import os +import stat +import time + +from .weight_bundle import _open_regular, _check_unchanged, _reject_links + +CHUNK_BYTES = 512 * 1024 * 1024 +COPY_BUFFER_BYTES = 1024 * 1024 + + +def save_transport_report(directory, report): + directory = _reject_links(directory) + pending = _reject_links(directory / "transport.pending.json") + pending.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n", encoding="utf-8") + os.replace(pending, directory / "transport.json") + + +def _files(directory, report): + directory = _reject_links(directory) + for index, row in enumerate(report["files"]): + name = f"weights-{index:05d}.bin" + if row["file"] != name: + raise ValueError("Invalid weight transport filename/order") + path = directory / name + # Exact files created by this stage only; no glob or recursive cleanup. + if path.parent.resolve() != directory.resolve() or path.is_symlink(): + raise ValueError("Weight transport path escaped its directory") + yield row, path + + +def _owned_stat(path, row): + """Inspect an owned pathname without following or accepting a replacement.""" + if row.get("created") is not True: + raise ValueError("Weight transport file was not created by this stage") + info = path.lstat() + if (not stat.S_ISREG(info.st_mode) + or getattr(info, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)): + raise ValueError("Owned weight transport path is a link or nonregular file") + expected = row.get("file_identity") + actual = {"device": info.st_dev, "inode": info.st_ino} + if expected != actual: + raise ValueError("Owned weight transport file identity changed") + return info + + +def _observe_retained(directory, report): + """Measure owned file lengths; unknown observations are never reported as 0.""" + report.update(retained=None, retained_bytes=None, retained_files=[], + retained_observed_bytes=0, retained_observation="unavailable", + retained_observation_errors=[]) + remaining, errors, observed = [], [], 0 + owned = [(index, row) for index, row in enumerate(report["files"]) if row.get("created") is True] + try: + directory = _reject_links(directory) + except (OSError, ValueError) as exc: + errors.append(f"directory: {type(exc).__name__}: {exc}") + directory = None + for index, row in owned: + name = f"weights-{index:05d}.bin" + try: + if row["file"] != name: + raise ValueError("Invalid owned weight transport filename/order") + if directory is None: + raise ValueError("Owned transport directory is not safely observable") + info = _owned_stat(directory / name, row) + except FileNotFoundError: + observed += 1 + except (OSError, ValueError) as exc: + detail = f"{name}: {type(exc).__name__}: {exc}" + errors.append(detail) + remaining.append({"file": name, "nbytes": None, "error": detail}) + else: + observed += 1 + remaining.append({"file": name, "nbytes": info.st_size}) + known = [row for row in remaining if row["nbytes"] is not None] + known_bytes = sum(row["nbytes"] for row in known) + report.update(retained=True if known else None if errors else False, + retained_files=remaining, retained_bytes=None if errors else known_bytes, + retained_observed_bytes=known_bytes, + retained_observation="partial" if errors and observed else "unavailable" if errors else "complete", + retained_observation_errors=errors) + if errors: + raise OSError("Weight transport residual observation failed: " + "; ".join(errors)) + + +def refresh_transport_residuals(directory, report): + """Refresh owned-file evidence without modifying or deleting any file. + + Observation failures leave unknown lengths explicit, mark the transport as + failed and propagate to the caller, which may already have a primary error. + """ + try: + _observe_retained(directory, report) + except BaseException: + report["status"] = "FAIL" + raise + + +def _finish_report(directory, report, primary): + """Publish residual evidence without replacing an existing operation error.""" + deferred = None + try: + refresh_transport_residuals(directory, report) + except BaseException as exc: + report["status"] = "FAIL" + if not report["retained_observation_errors"]: + report["retained_observation_errors"] = [f"{type(exc).__name__}: {exc}"] + if primary is None: + primary = deferred = exc + report["error"] = f"{type(exc).__name__}: {exc}" + elif hasattr(primary, "add_note"): + primary.add_note(f"Could not fully observe transport residuals: {exc}") + try: + save_transport_report(directory, report) + except BaseException as exc: + report.update(status="FAIL", report_error=f"{type(exc).__name__}: {exc}") + if primary is None: + primary = deferred = exc + elif hasattr(primary, "add_note"): + primary.add_note(f"Could not preserve transport.json: {exc}") + if deferred is not None: + raise deferred + + +def validate_transport(directory, report): + """Re-read actual copies, checking each segment and concatenated byte hash.""" + started = time.perf_counter() + whole, cursor = hashlib.sha256(), 0 + for row, path in _files(directory, report): + if row["offset"] != cursor or not 0 < row["nbytes"] <= report["chunk_bytes"]: + raise ValueError("Invalid weight transport extent") + stream, before = _open_regular(path) + digest, count = hashlib.sha256(), 0 + with stream: + if before.st_size != row["nbytes"]: + raise ValueError("Weight transport size mismatch") + while count < row["nbytes"]: + block = stream.read(min(COPY_BUFFER_BYTES, row["nbytes"] - count)) + if not block: + break + count += len(block) + digest.update(block) + whole.update(block) + if stream.read(1): + raise ValueError("Weight transport grew during validation") + _check_unchanged(stream, path, before) + if count != row["nbytes"] or digest.hexdigest() != row["sha256"]: + raise ValueError("Weight transport segment hash mismatch") + cursor += count + if cursor != report["total_bytes"] or whole.hexdigest() != report["sha256"]: + raise ValueError("Weight transport total length/hash mismatch") + return time.perf_counter() - started + + +def stage_transport(source, manifest, directory, report, *, chunk_bytes=None): + """Create and verify new copies; failures retain owned files for inspection. + + ``report`` is also held by the caller, so partial failure evidence survives + even if writing transport.json itself fails (e.g. a full output volume). + """ + limit = CHUNK_BYTES if chunk_bytes is None else chunk_bytes + if type(limit) is not int or not 0 < limit <= 512 * 1024 * 1024: + raise ValueError("Weight transport chunks must be between 1 byte and 512 MiB") + report.update(schema="scratchv.weight-transport", version=1, status="FAIL", stage="prepare", + chunk_bytes=limit, total_bytes=manifest["total_bytes"], sha256=manifest["sha256"], + copied_bytes=0, files=[], retained=False, retained_bytes=0, retained_files=[]) + source, directory = _reject_links(source), _reject_links(directory) + owned, primary = False, None + started = time.perf_counter() + try: + directory.mkdir(exist_ok=False) + owned = True + report["stage"] = "copy" + stream, before = _open_regular(source) + whole, cursor = hashlib.sha256(), 0 + with stream: + if before.st_size != report["total_bytes"]: + raise ValueError("Source weight bundle length changed before transport") + while cursor < report["total_bytes"]: + size = min(limit, report["total_bytes"] - cursor) + row = {"file": f"weights-{len(report['files']):05d}.bin", "offset": cursor, + "nbytes": size, "written_bytes": 0, "sha256": None, "created": False} + report["files"].append(row) + digest = hashlib.sha256() + output = (directory / row["file"]).open("xb") + row["created"] = True + with output: + info = os.fstat(output.fileno()) + row["file_identity"] = {"device": info.st_dev, "inode": info.st_ino} + while row["written_bytes"] < size: + block = stream.read(min(COPY_BUFFER_BYTES, size - row["written_bytes"])) + if not block: + raise ValueError("Source weight bundle truncated during transport") + if output.write(block) != len(block): + raise OSError("Short weight transport write") + digest.update(block) + whole.update(block) + row["written_bytes"] += len(block) + report["copied_bytes"] += len(block) + output.flush() + os.fsync(output.fileno()) + row["sha256"] = digest.hexdigest() + cursor += size + if stream.read(1): + raise ValueError("Source weight bundle grew during transport") + _check_unchanged(stream, source, before) + if whole.hexdigest() != report["sha256"]: + raise ValueError("Source weight bundle hash changed before/during transport") + report["copy_elapsed_s"] = time.perf_counter() - started + report["stage"] = "validate" + report["validation_elapsed_s"] = validate_transport(directory, report) + report.update(status="PASS", stage="ready") + return report + except BaseException as exc: + primary = exc + report.update(status="FAIL", error=f"{type(exc).__name__}: {exc}") + raise + finally: + report["prepare_elapsed_s"] = time.perf_counter() - started + if owned: + _finish_report(directory, report, primary) + + +def remove_transport(directory, report): + """After successful execution only, unlink exactly this run's owned copies.""" + errors, primary = [], None + try: + directory = _reject_links(directory) + # Preflight every path before deleting any file. Planned but never + # created files, replacements and links are not this invocation's data. + paths = list(_files(directory, report)) + for row, path in paths: + _owned_stat(path, row) + for row, path in paths: + try: + _owned_stat(path, row) + path.unlink() + except (OSError, ValueError) as exc: + errors.append(f"{row['file']}: {type(exc).__name__}: {exc}") + if primary is None: + primary = exc + if primary is not None: + raise primary + report["stage"] = "copies_removed" + except BaseException as exc: + primary = exc + report.update(status="FAIL", stage="cleanup", + cleanup_errors=errors or [f"{type(exc).__name__}: {exc}"]) + raise + finally: + _finish_report(directory, report, primary) diff --git a/scratchv/verification/fp32_eigen.py b/scratchv/verification/fp32_eigen.py new file mode 100644 index 0000000..c53960d --- /dev/null +++ b/scratchv/verification/fp32_eigen.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: MPL-2.0 +# Adapted from Eigen (https://gitlab.com/libeigen/eigen), commit +# 1d8b82b0740839c0de7f1242a3585e3390ff5f33: +# Eigen/src/Core/Redux.h +# Eigen/src/Core/arch/SSE/PacketMath.h +# Eigen/src/Core/arch/Default/GenericPacketMathFunctions.h +# Copyright (C) 2006-2008 Benoit Jacob +# Copyright (C) 2007 Julien Pommier +# Copyright (C) 2008-2009 Gael Guennebaud +# Copyright (C) 2009-2019 Gael Guennebaud +# Copyright (C) 2014 Pedro Gonnet (pedro.gonnet@gmail.com) +# +# This Source Code Form is subject to the terms of the Mozilla +# Public License, v. 2.0. If a copy of the MPL was not distributed +# with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +# The full license is also distributed in LICENSES/Eigen-MPL-2.0.txt. +"""Deterministic FP32 reductions and trigonometric approximations. + +This NumPy adaptation preserves Eigen's SSE reduction order and its non-FMA +``psincos_float`` polynomial. All floating-point computation remains FP32. +It makes no promise of bitwise agreement with every platform's math library. +The reduction uses canonical contiguous row alignment, independent of the +input's memory address or strides. Trigonometry uses NumPy outside a shared +conservative interval for the non-FMA approximation. +""" + +import numpy as np + + +def _fp32_array(values): + array = np.asarray(values) + if array.dtype != np.dtype(np.float32): + raise TypeError("FP32 math requires a float32 array") + return array + + +def _sum_last(array, aligned_start=0): + """Eigen's two Packet4f accumulators, scalar prefix/tail, and predux.""" + width = array.shape[-1] + if width - aligned_start < 4: + result = array[..., 0].copy() + for index in range(1, width): + result = result + array[..., index] + return result + + packet_end = aligned_start + ((width - aligned_start) // 4) * 4 + pair_end = aligned_start + ((width - aligned_start) // 8) * 8 + first = array[..., aligned_start : aligned_start + 4].copy() + if packet_end - aligned_start > 4: + second = array[..., aligned_start + 4 : aligned_start + 8].copy() + for index in range(aligned_start + 8, pair_end, 8): + first = first + array[..., index : index + 4] + second = second + array[..., index + 4 : index + 8] + first = first + second + if packet_end > pair_end: + first = first + array[..., pair_end : pair_end + 4] + + result = (first[..., 0] + first[..., 2]) + (first[..., 1] + first[..., 3]) + for index in range(aligned_start): + result = result + array[..., index] + for index in range(packet_end, width): + result = result + array[..., index] + return result + + +def mean_last(values, keepdims=True): + """Reduce a nonempty last dimension using an explicit FP32 sum order. + + Leading dimensions may be empty. Scalar inputs and an empty reduction + dimension are rejected rather than inventing a mean for an empty set. + Inputs, including read-only and strided arrays, are never modified. + """ + array = _fp32_array(values) + if array.ndim == 0 or array.shape[-1] == 0: + raise ValueError("mean_last requires a nonempty last dimension") + if not isinstance(keepdims, (bool, np.bool_)): + raise TypeError("keepdims must be boolean") + + width = array.shape[-1] + if width % 4 == 0 or width < 4: + sums = _sum_last(array) + else: + # Model a 16-byte-aligned first row and contiguous successive rows. + # Shape alone determines the prefix; array pointer alignment does not. + rows = array.reshape(-1, width) + row_starts = (-np.arange(rows.shape[0]) * width) % 4 + sums = np.empty(rows.shape[0], dtype=np.float32) + for start in range(4): + selected = np.flatnonzero(row_starts == start) + if selected.size: + sums[selected] = _sum_last(rows[selected], start) + + result = np.asarray(sums / np.float32(width)).reshape(array.shape[:-1] + (1,)) + return result if keepdims else result.squeeze(axis=-1) + + +def trig(values, sine: bool): + """FP32 sine or cosine with Eigen's non-FMA range reduction polynomial. + + The shared interval ``abs(x) < 18000`` is below both upstream non-FMA + cutoffs. Larger arguments and nonfinite values retain NumPy semantics. + This approximation can differ from NumPy by a few ULP; it is not a claim + of universally improved accuracy. Scalar and empty arrays are supported. + """ + array = _fp32_array(values) + if not isinstance(sine, (bool, np.bool_)): + raise TypeError("sine must be boolean") + selected = np.isfinite(array) & (np.abs(array) < np.float32(18000)) + result = np.array(np.sin(array) if sine else np.cos(array), dtype=np.float32, copy=True) + reduced = np.abs(array[selected]) + rounded = reduced * np.float32(0.636619746685028076171875) + rounded = rounded + np.float32(12582912) + quadrant = rounded.view(np.uint32) + multiple = rounded - np.float32(12582912) + for coefficient in ( + -1.5703125, + -0.000483989715576171875, + 1.62865035235881805419921875e-7, + 5.5644315544167710640977020375430583953857421875e-11, + ): + reduced = multiple * np.float32(coefficient) + reduced + + squared = reduced * reduced + cosine = np.full_like(reduced, np.float32(2.4372266125283204019069671630859375e-5)) + for coefficient in ( + -0.00138865201734006404876708984375, + 0.041666619479656219482421875, + -0.5, + 1.0, + ): + cosine = cosine * squared + np.float32(coefficient) + + sinpoly = np.full_like( + reduced, + np.float32(-0.0001959234114083702898469196984621021329076029360294342041015625), + ) + sinpoly = sinpoly * squared + np.float32( + 0.00833268736556168516937947998712843400426208972930908203125 + ) + sinpoly = sinpoly * squared + np.float32( + -0.166666620398229825550373561782180331647396087646484375 + ) + sinpoly = sinpoly * squared + sinpoly = sinpoly * reduced + reduced + + even = (quadrant & np.uint32(1)) == 0 + if sine: + output = np.where(even, sinpoly, cosine) + sign = (array[selected].view(np.uint32) ^ (quadrant << np.uint32(30))) & np.uint32( + 0x80000000 + ) + else: + output = np.where(even, cosine, sinpoly) + sign = ((quadrant + np.uint32(1)) << np.uint32(30)) & np.uint32(0x80000000) + result[selected] = (output.view(np.uint32) ^ sign).view(np.float32) + return result diff --git a/scratchv/verification/fp32_fma.py b/scratchv/verification/fp32_fma.py new file mode 100644 index 0000000..d035212 --- /dev/null +++ b/scratchv/verification/fp32_fma.py @@ -0,0 +1,214 @@ +"""Correctly rounded finite FP32 fused multiply-add using NumPy only. + +No floating-point precision promotion is used. The integer fallback aligns +exact significands in uint64 (or nine uint64 limbs), then performs one binary32 +round-to-nearest-even operation. It supports broadcasting, normals, +subnormals and signed zero; NaN/Inf inputs and finite-result overflow raise. + +The FP32 error-free-transform fast path requires all three absolute inputs +in [2**-40, 2**40], hence a/b low-part products cannot be smaller than 2**-126 +and no intermediate can overflow. Dekker TwoProduct and Knuth TwoSum give +exact product_error and sum_error. Let s=fl(fl(a*b)+c), E=product_error+sum_error +(exact), and r=fl(E). Only cases abs(fl(a*b))<=abs(s), with s not a power of two, +are accepted. Then s has equal adjacent spacing u, abs(E)<=u, and abs(r)<=u. +The only rounding boundaries reachable from s are s +/- u/2. If abs(r)==u/2, +use the integer fallback. Otherwise r's distance from either boundary is at +least one ulp(r), larger than abs(E-r)<=0.5 ulp(r); fl(s+r) is therefore the +correct single-rounded result. Excluding powers of two avoids asymmetric +binade spacing. Cancellation, zero, range boundaries not satisfying these +conditions, midpoint ambiguity and all other cases use exact integers. + +The implementation assumes normal NumPy IEEE binary32 round-to-nearest-even +operations. It does not change the host rounding mode or enable FTZ/DAZ. +""" +import numpy as np + +_U = np.uint64 +_I = np.int64 + + +def _topbit(x): + """floor(log2(x)), choosing zero for x=0; only integer shifts.""" + x = x.copy() + result = np.zeros(x.shape, dtype=np.int64) + for shift in (32, 16, 8, 4, 2, 1): + high = x >> _U(shift) + selected = high != 0 + result += np.where(selected, shift, 0) + x = np.where(selected, high, x) + return result + + +def _unpack(array): + bits = array.view(np.uint32).reshape(-1) + exponent = (bits >> np.uint32(23)) & np.uint32(255) + if np.any(exponent == 255): + raise ValueError("finite float32 inputs required") + mantissa = (bits & np.uint32(0x7fffff)).astype(np.uint64) + mantissa |= np.where(exponent != 0, _U(1 << 23), _U(0)) + scale = np.where(exponent != 0, exponent.astype(np.int64) - _I(150), _I(-149)) + return bits >> np.uint32(31), mantissa, scale + + +def _pack(sign, q, quantum): + carry = q == _U(1 << 24) + q = np.where(carry, q >> _U(1), q) + quantum = quantum + carry.astype(np.int64) + exponent = np.where(q >= _U(1 << 23), quantum + _I(150), _I(0)) + if np.any(exponent >= 255): + raise FloatingPointError("fused result overflows finite float32") + bits = ((sign.astype(np.uint32) << np.uint32(31)) + | (exponent.astype(np.uint32) << np.uint32(23)) + | (q.astype(np.uint32) & np.uint32(0x7fffff))) + return bits.view(np.float32) + + +def _round64(sign, magnitude, scale): + quantum = np.maximum(_topbit(magnitude) + scale - _I(23), _I(-149)) + shift = quantum - scale + right = np.maximum(shift, _I(0)).astype(np.uint64) + left = np.maximum(-shift, _I(0)).astype(np.uint64) + q = (magnitude >> right) << left + guard_shift = np.maximum(shift - _I(1), _I(0)).astype(np.uint64) + guard = (shift > 0) & (((magnitude >> guard_shift) & _U(1)) != 0) + # For shift >=65 all magnitude bits are sticky, but the guard is zero. + mask = np.where(guard_shift >= _U(64), _U(0xffffffffffffffff), + (_U(1) << np.minimum(guard_shift, _U(63))) - _U(1)) + sticky = (magnitude & mask) != 0 + q += (guard & (sticky | ((q & _U(1)) != 0))).astype(np.uint64) + return _pack(sign, q, quantum) + + +def _limbs(magnitude, scale): + # Smallest exact product quantum is 2^-298. Highest possible product bit + # index is 553; 9 _limbs (576 bits) also have room for the final addition. + result = np.zeros((9, magnitude.size), dtype=np.uint64) + shifts = scale + _I(298) + indices = np.arange(magnitude.size) + low = shifts // _I(64) + rem = (shifts % _I(64)).astype(np.uint64) + result[low, indices] = magnitude << rem + high = magnitude >> (_U(64) - rem) + selected = (low + 1 < 9) & (high != 0) + result[low[selected] + 1, indices[selected]] = high[selected] + return result + + +def _slow(sign_p, product, ep, sign_c, cm, ec): + p, c = _limbs(product, ep), _limbs(cm, ec) + greater, less = np.zeros(product.size, dtype=bool), np.zeros(product.size, dtype=bool) + for index in range(8, -1, -1): + undecided = ~(greater | less) + greater |= undecided & (p[index] > c[index]) + less |= undecided & (p[index] < c[index]) + p_ge = ~less + same = sign_p == sign_c + sign = np.where(same | p_ge, sign_p, sign_c) + mag = np.zeros_like(p) + carry, borrow = np.zeros(product.size, dtype=np.uint64), np.zeros(product.size, dtype=np.uint64) + for index in range(9): + total = p[index] + c[index] + addition = total + carry + carry = ((total < p[index]) | (addition < total)).astype(np.uint64) + hi, lo = np.where(p_ge, p[index], c[index]), np.where(p_ge, c[index], p[index]) + diff = hi - lo + subtraction = diff - borrow + borrow = ((hi < lo) | (subtraction > diff)).astype(np.uint64) + mag[index] = np.where(same, addition, subtraction) + exact_zero = ~np.any(mag != 0, axis=0) + sign = np.where(exact_zero & ~same, np.uint32(0), sign) + high_index = np.zeros(product.size, dtype=np.int64) + for index in range(9): + high_index = np.where(mag[index] != 0, index, high_index) + columns = np.arange(product.size) + highest = high_index * _I(64) + _topbit(mag[high_index, columns]) + shift = np.maximum(highest - _I(23), _I(149)) + index = shift // _I(64) + rem = (shift % _I(64)).astype(np.uint64) + padded = np.vstack((mag, np.zeros((1, product.size), dtype=np.uint64))) + q = (padded[index, columns] >> rem) | (padded[index + 1, columns] << (_U(64) - rem)) + guard_index = (shift - _I(1)) // _I(64) + guard_rem = ((shift - _I(1)) % _I(64)).astype(np.uint64) + guard = ((mag[guard_index, columns] >> guard_rem) & _U(1)) != 0 + mask = (_U(1) << guard_rem) - _U(1) + sticky = np.zeros(product.size, dtype=bool) + for index in range(9): + sticky |= ((index < guard_index) & (mag[index] != 0)) + sticky |= ((index == guard_index) & ((mag[index] & mask) != 0)) + q += (guard & (sticky | ((q & _U(1)) != 0))).astype(np.uint64) + return _pack(sign, q, shift - _I(298)) + + +def _integer_fma(a, b, c): + arrays = [np.asarray(x) for x in (a, b, c)] + if any(x.dtype != np.dtype("float32") for x in arrays): + raise TypeError("FMA requires native float32 inputs") + a, b, c = np.broadcast_arrays(*arrays) + sa, am, ae = _unpack(a) + sb, bm, be = _unpack(b) + sc, cm, ce = _unpack(c) + product, pe, sp = am * bm, ae + be, sa ^ sb + scale = np.where(product == 0, ce, np.where(cm == 0, pe, np.minimum(pe, ce))) + ps, cs = pe - scale, ce - scale + # Each aligned operand has <=63 significant bits. Their sum fits uint64 + # (max 2^64-2), and opposite-sign subtraction is exact magnitude arithmetic. + fast = (((product == 0) | (_topbit(product) + ps < 63)) + & ((cm == 0) | (_topbit(cm) + cs < 63))) + result = np.empty(product.shape, dtype=np.float32) + if np.any(fast): + p = product[fast] << np.maximum(ps[fast], _I(0)).astype(np.uint64) + cv = cm[fast] << np.maximum(cs[fast], _I(0)).astype(np.uint64) + same = sp[fast] == sc[fast] + p_ge = p >= cv + magnitude = np.where(same, p + cv, np.where(p_ge, p - cv, cv - p)) + sign = np.where(same | p_ge, sp[fast], sc[fast]) + sign = np.where((magnitude == 0) & ~same, np.uint32(0), sign) + result[fast] = _round64(sign, magnitude, scale[fast]) + if np.any(~fast): + result[~fast] = _slow(sp[~fast], product[~fast], pe[~fast], sc[~fast], cm[~fast], ce[~fast]) + return result.reshape(a.shape) + + +def fma(a, b, c): + """Return round32(a*b+c), rejecting non-f32/nonfinite inputs and overflow.""" + values = [np.asarray(value) for value in (a, b, c)] + if any(value.dtype != np.dtype("float32") for value in values): + raise TypeError("FMA requires native float32 inputs") + arrays = np.broadcast_arrays(*values) + shape = arrays[0].shape + a, b, c = [value.reshape(-1) for value in arrays] + lower = np.array(87 << 23, dtype=np.uint32).view(np.float32) + upper = np.array(167 << 23, dtype=np.uint32).view(np.float32) + bounded = np.ones(a.shape, dtype=bool) + for value in (a, b, c): + bounded &= (np.abs(value) >= lower) & (np.abs(value) <= upper) + indices = np.flatnonzero(bounded) + result = np.empty(a.shape, dtype=np.float32) + accepted = np.zeros(a.shape, dtype=bool) + if indices.size: + av, bv, cv = a[indices], b[indices], c[indices] + product = av * bv + split_a = np.float32(4097) * av + high_a = split_a - (split_a - av) + low_a = av - high_a + split_b = np.float32(4097) * bv + high_b = split_b - (split_b - bv) + low_b = bv - high_b + product_error = low_a * low_b - ( + ((product - high_a * high_b) - low_a * high_b) - high_a * low_b) + total = product + cv + virtual_c = total - product + sum_error = (product - (total - virtual_c)) + (cv - virtual_c) + residual = product_error + sum_error + total_bits = total.view(np.uint32) + exponent = (total_bits >> np.uint32(23)) & np.uint32(255) + half_ulp = ((np.maximum(exponent, np.uint32(24)) - np.uint32(24)) + << np.uint32(23)).view(np.float32) + safe = ((np.abs(product) <= np.abs(total)) + & ((total_bits & np.uint32(0x7fffff)) != 0) + & (np.abs(residual) != half_ulp)) + accepted[indices[safe]] = True + result[indices[safe]] = (total + residual)[safe] + if np.any(~accepted): + result[~accepted] = _integer_fma(a[~accepted], b[~accepted], c[~accepted]) + return result.reshape(shape) diff --git a/scratchv/verification/fp32_reference.py b/scratchv/verification/fp32_reference.py new file mode 100644 index 0000000..24fbbc0 --- /dev/null +++ b/scratchv/verification/fp32_reference.py @@ -0,0 +1,269 @@ +"""Opt-in, fixed-order NumPy FP32 reference evaluation. + +This profile specifies evaluation order and polynomial approximations, not +universally greater mathematical accuracy or bitwise equality to every ORT +build/CPU. No model names, positions, weights, or expected outputs are used. +Default interpreter execution continues to use the native NumPy kernels. + +MLAS polynomial constants/evaluation are adapted from ONNX Runtime v1.22.1: +core/mlas/lib/logistic.cpp, compute.cpp, x86_64/TransKernelFma3.S and +x86_64/TransKernelAvx512F.S (including their amd64 Windows equivalents). +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from __future__ import annotations + +import os +import re + +import numpy as np + +from scratchv.ir.types import OpCode +from scratchv.verification.fp32_eigen import mean_last, trig +from scratchv.verification.fp32_fma import fma + + +_CPU_ENV = "SCRATCHV_FP32_REFERENCE_CPU" +_CPU_STRATEGIES = ("avx2-fma3", "avx512") + + +def _cpu_features(): + # NumPy checks both hardware capabilities and usable OS vector state. + # This private diagnostic is optional: an explicit strategy works without it. + try: + from numpy._core._multiarray_umath import __cpu_features__ + except ImportError: + try: + from numpy.core._multiarray_umath import __cpu_features__ + except ImportError: + return {} + return __cpu_features__ + + +def _cpu_strategy(requested=None): + """Resolve a reference arithmetic policy; never change ORT dispatch. + + Explicit policies also allow reproducing saved arithmetic on another CPU. + NumPy's disable list does not constrain MLAS, so automatic inference must + not treat deliberately hidden NumPy capabilities as hardware evidence. + """ + requested = os.environ.get(_CPU_ENV, "auto") if requested is None else requested + if requested in _CPU_STRATEGIES: + return requested + if requested != "auto": + raise ValueError(f"{_CPU_ENV} must be auto, avx2-fma3, or avx512") + disabled = re.split(r"[\s,]+", os.environ.get("NPY_DISABLE_CPU_FEATURES", "").upper()) + if any(feature in ("AVX", "AVX2", "FMA3") or feature.startswith("AVX512") + for feature in disabled): + raise ValueError(f"NPY_DISABLE_CPU_FEATURES obscures MLAS capabilities; set {_CPU_ENV} explicitly") + features = _cpu_features() + if features.get("AVX2") and features.get("FMA3"): + return "avx512" if features.get("AVX512F") else "avx2-fma3" + raise ValueError(f"Cannot infer a supported FP32 CPU strategy; set {_CPU_ENV} explicitly") + + +def profile(cpu_strategy=None): + """Return a canonical arithmetic contract, independent of selection method. + + Passing a supported explicit cpu_strategy validates a saved profile on a + different host without claiming that the local ORT uses that strategy. + """ + strategy = _cpu_strategy(cpu_strategy) + lanes = 8 if strategy == "avx2-fma3" else 16 + return { + "name": "numpy-fp32-reference-v4", + "dtype": "float32", + "cpu_strategy": strategy, + "matmul_k_block": 128, + "matmul_row_tile": 2 if lanes == 8 else None, + "matmul_row_tail": "zero-pad-to-two-then-trim" if lanes == 8 else "native", + "matmul_column_tail": "zero-pad-single-column-to-two-then-trim" if lanes == 8 else "native", + "matmul_layout": "contiguous-matrix-operands-per-k-block" if lanes == 8 else "native-strides", + "matmul_vector_policy": "native-numpy-vector-promotion", + "mean": "two-four-lane-last-axis", + "trig": "non-fused-range-reduction-polynomial", + "softmax": f"exp-polynomial-{lanes}-lane-sum-reciprocal-multiply", + "softmax_lanes": lanes, + "softmax_exp_lower_bound": float(np.float32( + -88.3762626647949 if lanes == 8 else -103.9720840454)), + "softmax_exp_scaling": "binary32-exponent-bits" if lanes == 8 else "ldexp", + "sigmoid": "bounded-rational-polynomial-clamped-to-[0,1]", + "multiply_add": "binary32-round-to-nearest-even", + "reference_basis": "ORT 1.22.1 CPU arithmetic; no ORT runtime dependency", + "scope": "Explicit FP32 reference evaluation; other dtypes unchanged. " + "CPU strategy specifies reference arithmetic, not ORT dispatch. " + "Not a promise of bitwise matching every CPU or ORT version.", + } + + +def matmul(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError + a, b = xs + if "m" in attrs: + m, n, k = (attrs[key] for key in ("m", "n", "k")) + if a.ndim == b.ndim == 1: + a, b = a.reshape(m, k), b.reshape(k, n) + elif a.shape != (m, k) or b.shape != (k, n): + raise OpError("ShapeError", "MATMUL m/n/k disagree with tensor shapes") + # Preserve NumPy's existing vector-promotion arithmetic and shape errors. + if a.ndim < 2 or b.ndim < 2: + return np.matmul(a, b) + if a.shape[-1] != b.shape[-2]: + raise OpError("ShapeError", "MATMUL reduction dimensions differ") + strategy = _cpu_strategy() + if strategy == "avx512" and a.shape[-1] <= 128: + return np.matmul(a, b) + shape = np.broadcast_shapes(a.shape[:-2], b.shape[:-2]) + (a.shape[-2], b.shape[-1]) + result = np.zeros(shape, dtype=np.float32) + if strategy == "avx512": + # Retain the existing wide-matrix policy. SkylakeX small-matrix kernels + # have a different reduction order from its ordinary wide GEMM path. + for start in range(0, a.shape[-1], 128): + result += np.matmul(a[..., start:start + 128], b[..., start:start + 128, :]) + return result + # Haswell OpenBLAS 0.3.29's wider SGEMM tiles use two interleaved K + # accumulators on selected boundaries (sgemm_kernel_8x4_haswell_2.c). + # A fixed two-row tile avoids those boundaries; padding a single-row + # tail also avoids switching to a different GEMV reduction. This applies + # to K<=128 too: attention score products use exactly K=128. + if not a.shape[-2] or not b.shape[-1]: + return result + for start in range(0, a.shape[-1], 128): + # Packing once per K block also handles stepped/transposed inputs: + # non-BLAS NumPy strided loops have a different arithmetic order. + left = np.ascontiguousarray(a[..., start:start + 128]) + right = np.ascontiguousarray(b[..., start:start + 128, :]) + if left.shape[-2] % 2: + padded = np.zeros(left.shape[:-2] + (left.shape[-2] + 1, left.shape[-1]), dtype=np.float32) + padded[..., :-1, :] = left + left = padded + if right.shape[-1] == 1: + padded = np.zeros(right.shape[:-1] + (2,), dtype=np.float32) + padded[..., :1] = right + right = padded + for row in range(0, a.shape[-2], 2): + rows = min(2, a.shape[-2] - row) + product = np.matmul(left[..., row:row + 2, :], right) + result[..., row:row + rows, :] += product[..., :rows, :b.shape[-1]] + return result + + +def reduce_mean(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError, axes, flag, reduce_mean as native + x = xs[0] + requested = attrs.get("axes") + selected = tuple(range(x.ndim)) if not requested else axes(requested, x.ndim) + if any(x.shape[a] == 0 for a in selected): + raise OpError("NumericError", "cannot reduce an empty dimension") + if x.ndim and selected == (x.ndim - 1,): + return mean_last(x, keepdims=flag(attrs.get("keepdims", True), "keepdims")) + return native(xs, attrs, dtype) + + +_SIGMOID_P = tuple(np.float32(v) for v in ( + 4.37031012579801e-11, 1.15627324459942e-7, 6.08574864600143e-5, + 8.51377133304701e-3, 2.48287947061529e-1)) +_SIGMOID_Q = tuple(np.float32(v) for v in ( + 6.10247389755681e-13, 5.76102136993427e-9, 6.29106785017040e-6, + 1.70198817374094e-3, 1.16817656904453e-1, 9.93151921023180e-1)) +_EXP = tuple(np.float32(float.fromhex(v)) for v in ( + "0x1.694000p-10", "0x1.125edcp-7", "0x1.555b5ap-5", "0x1.555450p-3", + "0x1.fffff6p-2", "0x1.000000p+0", "0x1.000000p+0")) + + +def sigmoid(xs, attrs, dtype): + x = np.clip(xs[0], np.float32(-18), np.float32(18)) + squared = x * x + numerator = np.full_like(x, _SIGMOID_P[0]) + denominator = np.full_like(x, _SIGMOID_Q[0]) + for coefficient in _SIGMOID_P[1:]: + numerator = fma(numerator, squared, coefficient) + for coefficient in _SIGMOID_Q[1:]: + denominator = fma(denominator, squared, coefficient) + # Rational-approximation roundoff can cross either saturation endpoint. + return np.clip((numerator * x) / denominator + np.float32(0.5), + np.float32(0), np.float32(1)) + + +def _exp_nonpositive(x, *, cpu_strategy="avx512"): + # The bounded polynomial's smallest result rounds to zero; masked -inf + # from stable subtraction is consequently normalized to probability zero. + x = np.maximum(x, np.float32( + -88.3762626647949 if cpu_strategy == "avx2-fma3" else -103.9720840454)) + bias = np.float32(12582912) + rounded = fma(x, np.float32(1.44269504088896341), bias) + exponent = rounded - bias + x = fma(exponent, np.float32(-6.93145752e-1), x) + x = fma(exponent, np.float32(-1.42860677e-6), x) + polynomial = np.full_like(x, _EXP[0]) + for coefficient in _EXP[1:]: + polynomial = fma(polynomial, x, coefficient) + if cpu_strategy == "avx2-fma3": + # FMA3 reconstructs 2**m directly in the binary32 exponent field. + # In particular m=-127 becomes zero, unlike AVX512's VSCALEFPS. + bits = (rounded.view(np.uint32) << np.uint32(23)) + np.uint32(0x3f800000) + return polynomial * bits.view(np.float32) + return np.ldexp(polynomial, exponent.astype(np.int32)) + + +def _sum_lanes(values, lanes): + sums = np.zeros(values.shape[:-1] + (lanes,), dtype=np.float32) + for start in range(0, values.shape[-1], lanes): + block = values[..., start:start + lanes] + sums[..., :block.shape[-1]] += block + if lanes == 16: + sums = sums[..., :8] + sums[..., 8:] + first = (sums[..., 0] + sums[..., 1]) + (sums[..., 2] + sums[..., 3]) + last = (sums[..., 4] + sums[..., 5]) + (sums[..., 6] + sums[..., 7]) + return (first + last)[..., None] + + +def _sum_sixteen(values): + # Retain the named primitive for existing standalone arithmetic diagnostics. + return _sum_lanes(values, 16) + + +def softmax(xs, attrs, dtype): + from scratchv.verification.ir_numpy_ops import OpError, axis + x = xs[0] + selected = axis(attrs.get("axis", -1), x.ndim) + if x.shape[selected] == 0: + raise OpError("NumericError", "SOFTMAX cannot normalize an empty axis") + x = np.moveaxis(x, selected, -1) + maximum = np.max(x, axis=-1, keepdims=True) + if not np.isfinite(maximum).all(): + raise OpError("NumericError", "SOFTMAX row is entirely masked or nonfinite") + with np.errstate(over="ignore"): + reduced = x - maximum + strategy = _cpu_strategy() + weights = _exp_nonpositive(reduced, cpu_strategy=strategy) + result = weights * (np.float32(1) / _sum_lanes( + weights, 8 if strategy == "avx2-fma3" else 16)) + return np.moveaxis(result, -1, selected) + + +HANDLERS = { + OpCode.MATMUL: matmul, + OpCode.REDUCE_MEAN: reduce_mean, + OpCode.SOFTMAX: softmax, + OpCode.SIGMOID: sigmoid, + OpCode.COS: lambda xs, attrs, dtype: trig(xs[0], sine=False), + OpCode.SIN: lambda xs, attrs, dtype: trig(xs[0], sine=True), +} diff --git a/scratchv/verification/ir_interpreter.py b/scratchv/verification/ir_interpreter.py index 1891229..bf91684 100644 --- a/scratchv/verification/ir_interpreter.py +++ b/scratchv/verification/ir_interpreter.py @@ -3,13 +3,15 @@ Integer ADD/SUB/MUL/NEG wrap at the declared width; DIV truncates toward zero and rejects zero and min/-1. ALLOCA sizes are bytes; LOAD/STORE access the first scalar slot only and reject reads before STORE. No host pointers exist. -Every run owns fresh state and copies external arrays and returned results. +Every run owns fresh state and, by default, copies external arrays and results. +Explicit straight-line execution options can borrow immutable weights and +release values after their final use. Returned results are always copied. """ from __future__ import annotations from dataclasses import dataclass -from typing import Mapping +from typing import Callable, Mapping import numpy as np @@ -66,6 +68,7 @@ class ExecutionResult: return_value: np.ndarray | None executed_steps: int diagnostics: tuple[VerificationError, ...] = () + memory_stats: dict | None = None @dataclass @@ -74,6 +77,80 @@ class _MemorySlot: initialized: bool = False +def _backing_storage(array): + """Identify the owner of a view without keeping new array references alive.""" + owner = array + seen = set() + while id(owner) not in seen: + seen.add(id(owner)) + base = getattr(owner, "base", None) + if base is None and isinstance(owner, memoryview): + base = owner.obj + if base is None: + break + owner = base + try: + size = memoryview(owner).nbytes + except TypeError: + # Normal NumPy views (including broadcast/strided views) terminate in + # a buffer owner. Fall back to the root array for unusual owners. + size = getattr(owner, "nbytes", array.nbytes) + return id(owner), int(size) + + +class _MemoryObservation: + """Optional instruction-boundary accounting; never releases/copies values.""" + + def __init__(self, values, params, globals_): + self.input_bytes = sum(values[name].nbytes for name in params) + self.initializer_bytes = sum( + values[name].nbytes for name in globals_ if name in values + ) + self.peak_logical = 0 + self.peak_storage = 0 + self.observe(values) + + @staticmethod + def snapshot(values, returned=None): + arrays = [value.storage if isinstance(value, _MemorySlot) else value + for value in values.values()] + if returned is not None: + arrays.append(returned) + storage = {} + for array in arrays: + key, size = _backing_storage(array) + storage[key] = max(size, storage.get(key, 0)) + return sum(int(array.nbytes) for array in arrays), sum(storage.values()) + + def observe(self, values, returned=None): + logical, storage = self.snapshot(values, returned) + self.peak_logical = max(self.peak_logical, logical) + self.peak_storage = max(self.peak_storage, storage) + + def finish(self, values, returned): + self.observe(values, returned) + logical, storage = self.snapshot(values) + return { + "input_logical_bytes": int(self.input_bytes), + "initializer_logical_bytes": int(self.initializer_bytes), + "peak_live_logical_bytes": self.peak_logical, + "peak_numpy_storage_bytes": self.peak_storage, + "retained_values": len(values), + "retained_logical_bytes": logical, + "retained_numpy_storage_bytes": storage, + "return_logical_bytes": 0 if returned is None else int(returned.nbytes), + "scope": "interpreter retained values at instruction boundaries; final return copy included in peaks", + "notes": [ + "Logical bytes count each retained binding, including views and ALLOCA storage.", + "Storage bytes deduplicate shared backing buffer owners, including NumPy views.", + "Initializer bytes count bound referenced globals, including scalar global constants.", + "Retained values exclude the independent return copy; peaks include it.", + "Counts currently bound buffers, including borrowed initializers; excludes other caller-held arrays, temporary kernel buffers, Python objects and allocator overhead; not process RSS.", + "Observation does not change existing copying, value retention, control flow or numeric semantics.", + ], + } + + _CONTROL_ATTRS = { OpCode.FOR: {"start", "end", "step"}, OpCode.ENDFOR: set(), @@ -85,6 +162,19 @@ class _MemorySlot: OpCode.STORE: set(), } +# An explicit allow-list makes future effectful instructions fail closed in +# the opt-in borrowing/liveness modes. Every listed kernel treats inputs as +# immutable and returns an array or a view of an input. +_PURE_OPS = { + OpCode.ADD, OpCode.SUB, OpCode.MUL, OpCode.DIV, OpCode.POW, + OpCode.NEG, OpCode.ABS, OpCode.COS, OpCode.SIN, OpCode.RECIPROCAL, + OpCode.EXP, OpCode.SQRT, OpCode.REDUCE_MEAN, OpCode.LOAD_CONST, + OpCode.CAST, OpCode.MATMUL, OpCode.RELU, OpCode.MAXPOOL, OpCode.SOFTMAX, + OpCode.GELU, OpCode.DOT, OpCode.CONV, OpCode.GEMM, OpCode.SIGMOID, + OpCode.TRANSPOSE, OpCode.RESHAPE, OpCode.CONCAT, OpCode.GATHER, + OpCode.SLICE, OpCode.UNSQUEEZE, OpCode.EXPAND, +} + class IRInterpreter: def __init__(self, program: Program): @@ -97,7 +187,38 @@ def run( initializers: Mapping[str, np.ndarray] | None = None, function_name: str | None = None, max_steps: int = 1_000_000, + collect_memory_stats: bool = False, + copy_initializers: bool = True, + memory_mode: str = "retain_all", + observer: Callable[[str, np.ndarray], None] | None = None, + fp32_mode: str = "native", ) -> ExecutionResult: + """Run verified IR with strict bindings. + + Parameters and bound initializers require their exact declared static + shape, including ``()`` for scalars. Builder intermediate values may + retain the default ``()`` until the operation infers its result shape. + An optional ``Function.returns`` signature is also exact; omitting it + preserves the actual returned tensor's inferred shape and dtype. + + ``copy_initializers=False`` borrows only read-only NumPy arrays. The + caller must not mutate their backing storage for the duration of this + synchronous call. ``memory_mode='last_use'`` releases bindings after + their final instruction use; it does not skip dead instructions or + their errors. Both options require a pure, single-block SSA function + ending in one RETURN; unsupported control flow fails explicitly. + + ``observer`` receives each numeric destination and a temporary + read-only view after validation, before reclamation. It must not mutate + backing storage or retain arrays if bounded memory is desired. Save + selected checkpoints synchronously; observer allocations are excluded + from interpreter accounting. Inputs and results are always copied. + + ``fp32_mode='reference'`` selects a versioned NumPy FP32 evaluation + order and polynomial profile for numerical comparisons. It does not + call ORT, change graph optimization, or change non-FP32 kernels. The + default ``native`` retains the original NumPy evaluation policy. + """ if not isinstance(inputs, Mapping) or ( initializers is not None and not isinstance(initializers, Mapping) ): @@ -112,6 +233,16 @@ def run( raise IRExecutionError( "InvalidOptions", "max_steps must be positive integer" ) + if not isinstance(collect_memory_stats, bool): + raise IRExecutionError("InvalidOptions", "collect_memory_stats must be boolean") + if not isinstance(copy_initializers, bool): + raise IRExecutionError("InvalidOptions", "copy_initializers must be boolean") + if not isinstance(memory_mode, str) or memory_mode not in ("retain_all", "last_use"): + raise IRExecutionError("InvalidOptions", "memory_mode must be retain_all or last_use") + if observer is not None and not callable(observer): + raise IRExecutionError("InvalidOptions", "observer must be callable or None") + if not isinstance(fp32_mode, str) or fp32_mode not in ("native", "reference"): + raise IRExecutionError("InvalidOptions", "fp32_mode must be native or reference") passed, issues = verify_ir(self.program, stage="before-execution") if not passed: issue = next(i for i in issues if i.level.value == "error") @@ -159,6 +290,20 @@ def error(code, message, position=None, instr=None, value_name=None): value_name=value_name, ) + last_uses = None + if memory_mode == "last_use" or not copy_initializers: + if len(function.blocks) != 1 or function.blocks[0].phi_nodes: + raise error("InvalidOptions", "memory optimization requires a pure single-block function") + instructions = function.blocks[0].instructions + if (not instructions or instructions[-1].opcode != OpCode.RETURN + or any(inst.opcode not in _PURE_OPS for inst in instructions[:-1])): + raise error("InvalidOptions", "memory optimization requires pure instructions and one final RETURN") + if memory_mode == "last_use": + last_uses = {} + for index, inst in enumerate(instructions): + for operand in inst.operands: + last_uses[operand.name] = index + for block in function.blocks: for index, instr in enumerate(block.instructions): position = IRSourcePosition(block.name, index) @@ -199,6 +344,29 @@ def error(code, message, position=None, instr=None, value_name=None): f"invalid initializer bindings: {sorted(extra | (set(initializers) & set(params)))}", ) + def checked_shape(value, actual_shape, position=None, instr=None): + # Boundary declarations describe actual tensors: () is a scalar, + # not an unspecified shape. Builder intermediates are inferred + # separately and do not enter this check unless explicitly shaped. + if not isinstance(value.shape, (tuple, list)) or any( + isinstance(d, (bool, np.bool_)) + or not isinstance(d, (int, np.integer)) or d < 0 + for d in value.shape + ): + raise error( + "ShapeError", "tensor boundary requires a static nonnegative shape", + position, instr, + value_name=value.name, + ) + shape = tuple(int(d) for d in value.shape) + if actual_shape != shape: + raise error( + "ShapeError", + f"expected {shape}, got {actual_shape}", + position, instr, + value_name=value.name, + ) + def checked_array(value, data, *, copy=False): if not isinstance(data, np.ndarray): raise error( @@ -210,16 +378,7 @@ def checked_array(value, data, *, copy=False): f"expected {DTYPES[value.dtype]}, got {data.dtype}", value_name=value.name, ) - if ( - value.shape - and all(isinstance(d, int) and d >= 0 for d in value.shape) - and data.shape != value.shape - ): - raise error( - "ShapeError", - f"expected {value.shape}, got {data.shape}", - value_name=value.name, - ) + checked_shape(value, data.shape) if np.issubdtype(data.dtype, np.floating) and ( np.isnan(data).any() or np.isposinf(data).any() ): @@ -241,7 +400,12 @@ def checked_array(value, data, *, copy=False): for name in referenced & set(globals_): value = globals_[name] if name in initializers: - data = checked_array(value, initializers[name], copy=True) + data = checked_array(value, initializers[name], copy=copy_initializers) + if not copy_initializers: + if data.flags.writeable: + raise error("BindingError", "borrowed initializer must be read-only", value_name=name) + data = data.view() + data.setflags(write=False) if value.is_constant: literal = np.asarray(value.const_value, dtype=DTYPES[value.dtype]) if data.shape != () or not np.array_equal(data, literal): @@ -258,6 +422,31 @@ def checked_array(value, data, *, copy=False): "BindingError", "global tensor data missing", value_name=name ) + memory = _MemoryObservation(values, params, globals_) if collect_memory_stats else None + reclaimed_bindings = 0 + + def reclaim(names): + nonlocal reclaimed_bindings + for name in names: + if name in values: + del values[name] + reclaimed_bindings += 1 + + if last_uses is not None: + reclaim([name for name in values if name not in last_uses]) + + def memory_result(returned): + if memory is None: + return None + stats = memory.finish(values, returned) + if memory_mode != "retain_all" or not copy_initializers or observer is not None: + stats.update(memory_mode=memory_mode, copy_initializers=copy_initializers, + reclaimed_bindings=reclaimed_bindings, observer_enabled=observer is not None) + stats["notes"].append( + "Opt-in policy applies: borrowed buffers remain resident if retained by the caller; " + "reclaimed bindings and observer allocations are not process RSS reductions.") + return stats + adapter = IRCFGAdapter(function) cfg = build_cfg(adapter) plan = adapter.execution_plan @@ -326,22 +515,34 @@ def resolve(value): operands = [resolve(v) for v in instr.operands] if op == OpCode.RETURN: if not operands: - return ExecutionResult(None, steps, tuple(issues)) + return ExecutionResult(None, steps, tuple(issues), + memory_result(None)) value = operands[0] if not isinstance(value, np.ndarray): raise OpError("MemoryError", "cannot return memory reference") + reference = instr.operands[0] + inferred_return = isinstance(reference.shape, (tuple, list)) and len(reference.shape) == 0 + if not inferred_return or reference.name in params or reference.name in globals_: + checked_shape(reference, value.shape, position, instr) + if function.returns: + # verify_ir already checks declaration count and dtype. + checked_shape(function.returns[0], value.shape, position, instr) if ( np.issubdtype(value.dtype, np.floating) and not np.isfinite(value).all() ): raise OpError("NumericError", "return value must be finite") - return ExecutionResult(value.copy(), steps, tuple(issues)) + returned = value.copy() + return ExecutionResult(returned, steps, tuple(issues), + memory_result(returned)) if op == OpCode.ALLOCA: size = instr.attrs.get("size", 4) dtype = DTYPES[instr.dest.dtype] values[instr.dest.name] = _MemorySlot( np.empty(size // dtype.itemsize, dtype=dtype) ) + if memory: + memory.observe(values) continue if op in (OpCode.LOAD, OpCode.STORE): slot = operands[0] @@ -377,7 +578,8 @@ def resolve(value): bounds = np.iinfo(np.int32) if total < bounds.min or total > bounds.max: raise OpError("NumericError", "loop counter overflows i32") - result = compute(instr, operands) + result = (compute(instr, operands) if fp32_mode == "native" + else compute(instr, operands, fp32_mode=fp32_mode)) if result.dtype != DTYPES[instr.dest.dtype]: raise OpError( "DTypeError", @@ -393,6 +595,25 @@ def resolve(value): f"result expected {instr.dest.shape}, got {result.shape}", ) values[instr.dest.name] = result + if observer is not None: + observed = result.view() + observed.setflags(write=False) + try: + observer(instr.dest.name, observed) + except Exception as exc: + raise error("ObserverError", str(exc), position, instr) from exc + finally: + del observed + if memory: + memory.observe(values) + if last_uses is not None: + index = position.instruction_index + reclaim({value.name for value in instr.operands + if last_uses.get(value.name) == index} + | ({instr.dest.name} if instr.dest.name not in last_uses else set())) + # Do not accidentally pin reclaimed arrays in loop locals. + operands = [] + result = None except OpError as exc: raise error(exc.code, str(exc), position, instr) from exc except FloatingPointError as exc: diff --git a/scratchv/verification/ir_numpy_ops.py b/scratchv/verification/ir_numpy_ops.py index 92baeab..432ff02 100644 --- a/scratchv/verification/ir_numpy_ops.py +++ b/scratchv/verification/ir_numpy_ops.py @@ -183,12 +183,15 @@ def check_instruction(instr: Instruction): raise OpError("AttributeError", f"{key} must be finite numeric") -def compute(instr, operands): +def compute(instr, operands, *, fp32_mode="native"): + handler = KERNELS[instr.opcode].handler + dtype = DTYPES[instr.dest.dtype] + if fp32_mode == "reference" and dtype == np.dtype("float32"): + from scratchv.verification.fp32_reference import HANDLERS + handler = HANDLERS.get(instr.opcode, handler) with np.errstate(over="raise", divide="raise", invalid="raise", under="ignore"): result = np.asarray( - KERNELS[instr.opcode].handler( - operands, instr.attrs, DTYPES[instr.dest.dtype] - ) + handler(operands, instr.attrs, dtype) ) if np.issubdtype(result.dtype, np.floating) and not np.isfinite(result).all(): raise OpError("NumericError", "operation produced nonfinite output") diff --git a/scripts/ci_scopes.json b/scripts/ci_scopes.json index f83c8f5..000e206 100644 --- a/scripts/ci_scopes.json +++ b/scripts/ci_scopes.json @@ -28,7 +28,39 @@ "tests/test_w2_*.py", "tests/test_numeric_metrics.py", "tests/test_numeric_summary.py", - ".github/workflows/llm-deploy.yml" + ".github/workflows/llm-deploy.yml", + "scripts/summarize_w3_ci.py", + "tests/test_w3_actions_summary.py", + "tests/test_w3_workflows.py", + ".github/workflows/w3-*.yml" ], - "w4": [] + "w4": [ + ".github/workflows/w4-full-numeric.yml", + ".github/workflows/w4-nightly.yml", + "scratchv/**", + "export_qwen3_onnx.py", + "probes/w1_qwen3_export/**", + "probes/w2_runtime/**", + "probes/w3_common.py", + "probes/w3_qwen3_full/**", + "probes/w3_layer_diff/**", + "probes/w4_qwen3_full/**", + "scripts/summarize_w4_ci.py", + "tests/test_w1_qwen3_export.py", + "tests/test_llm_inputs.py", + "tests/test_w2_runtime_metadata.py", + "tests/test_licm_safety.py", + "tests/test_optimizer_numeric_semantics.py", + "tests/test_w3_full_gate.py", + "tests/test_w3_atomic_text.py", + "tests/test_weight_bundle.py", + "tests/test_tensor_c_external.py", + "tests/test_tensor_boundary_contracts.py", + "tests/test_tensor_return_contract.py", + "tests/test_w4*.py", + "tests/test_riscv_external*.py", + "tests/test_riscv_process_cleanup.py", + "tests/test_riscv_weight_transport.py", + "requirements/qwen3-small-probe.txt" + ] } diff --git a/scripts/collect_w3_ci_evidence.py b/scripts/collect_w3_ci_evidence.py new file mode 100644 index 0000000..8a65315 --- /dev/null +++ b/scripts/collect_w3_ci_evidence.py @@ -0,0 +1,172 @@ +"""Retain bounded diagnostic evidence, with an explicit inventory of omissions.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat + + +MIB = 1024**2 +MAX_FILE_BYTES = 256 * MIB +MAX_TOTAL_BYTES = 512 * MIB +MAX_REPORT_BYTES = 32 * MIB +FAILURE_SUFFIXES = {".npz", ".npy", ".onnx", ".log", ".stdout", ".stderr"} + + +def collect(source, destination, *, max_file_bytes=MAX_FILE_BYTES, max_total_bytes=MAX_TOTAL_BYTES): + """Copy selected evidence; omissions remain visible even after raw artifacts expire. + + This inventories copied bytes, but does not rerun any numerical gate. The CLI + exits unsuccessfully when selection is incomplete; other safe files survive. + """ + for limit in (max_file_bytes, max_total_bytes): + if type(limit) is not int or limit <= 0: + raise ValueError("Evidence byte limits must be positive integers") + source, destination = Path(source).resolve(), Path(destination).resolve() + if destination.is_relative_to(source): + raise ValueError("Evidence destination must be outside its source directory") + destination.mkdir(parents=True, exist_ok=False) + selected, omitted = {}, [] + + def omit(path, reason, **details): + omitted.append({"path": str(path.relative_to(source)), "reason": reason, **details}) + + def safe(path): + if not path.resolve().is_relative_to(source): + raise ValueError("Evidence path escapes source directory") + + def scan(folder, suffixes, names=(), *, required=False): + try: + safe(folder) + if not folder.exists(): + if required: + omit(folder, "Missing case evidence directory") + return + # Never follow linked directories while searching for failure evidence. + def walk_error(error): + omit(folder, f"Cannot scan evidence: {error}") + + for root, dirs, files in os.walk(folder, followlinks=False, onerror=walk_error): + root = Path(root) + for name in list(dirs): + child = root / name + if child.is_symlink() or not child.resolve().is_relative_to(source): + dirs.remove(name) + omit(child, "Linked or external evidence directory") + dirs.sort() + for name in sorted(files): + path = root / name + if path.suffix in suffixes or name in names: + selected[path] = None + except (OSError, ValueError) as exc: + omit(folder, str(exc)) + + # First priority: the exact trace selected by the independent layer-diff gate. + for name in ("trace_schema.json", "traces/short_17/ir_diagnostic_all.npz", + "traces/short_17/ort_diagnostic.npz"): + selected[source / "medium" / name] = None + # Original target logs permit inspection of UART/protocol failures. + scan(source / "attention", {".log", ".stdout", ".stderr"}, {"uart.bin"}) + for gate in ("medium", "subgraphs", "attention"): + folder = source / gate + report_path = folder / "report.json" + if not report_path.exists(): + # Interrupted runs may have raw evidence but no final report. + omit(report_path, "Missing gate report; collected available diagnostics") + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + continue + try: + safe(report_path) + info = report_path.stat() + if not stat.S_ISREG(info.st_mode): + raise ValueError("Report is not a regular file") + if info.st_size > MAX_REPORT_BYTES: + raise ValueError("Report exceeds size limit") + report = json.loads(report_path.read_text(encoding="utf-8")) + if not isinstance(report, dict): + raise ValueError("Report must be an object") + if report.get("passed") is True: + continue + rows = report.get("cases", []) + if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): + raise ValueError("Report cases must be a list of objects") + failed = [row for row in rows if row.get("passed") is not True] + # An invariant can fail after all individual comparisons have passed. + for row in failed or rows: + name = row.get("name") + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name): + raise ValueError("Invalid case evidence directory name") + case_folder = folder / ("traces" if gate == "medium" else "") / name + if not case_folder.resolve().is_relative_to(folder.resolve()): + raise ValueError("Case evidence escapes its gate directory") + scan(case_folder, FAILURE_SUFFIXES, {"uart.bin"}, required=True) + if not rows: + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + except (OSError, ValueError) as exc: + omit(report_path, f"Cannot select cases from report: {exc}") + scan(folder, FAILURE_SUFFIXES, {"uart.bin"}) + + records, total = [], 0 + for path in selected: + relative = path.relative_to(source) + target = destination / relative + try: + safe(path) + info = path.stat() + if not stat.S_ISREG(info.st_mode): + raise ValueError("Evidence is not a regular file") + if info.st_size > max_file_bytes: + raise ValueError("Evidence exceeds per-file byte limit") + if info.st_size > max_total_bytes - total: + raise ValueError("Evidence exceeds remaining total byte limit") + target.parent.mkdir(parents=True, exist_ok=True) + digest, copied = hashlib.sha256(), 0 + with path.open("rb") as src, target.open("xb") as dst: + while chunk := src.read(MIB): + copied += len(chunk) + if copied > min(max_file_bytes, max_total_bytes - total): + raise ValueError("Evidence grew beyond byte limit during collection") + dst.write(chunk) + digest.update(chunk) + total += copied + records.append({"path": str(relative), "bytes": copied, "sha256": digest.hexdigest()}) + except (OSError, ValueError) as exc: + target.unlink(missing_ok=True) + omit(path, str(exc)) + + files = [record["path"] for record in records] + manifest = { + "schema_version": 1, + "scope": "Selected preparation trace and failure diagnostics only; not complete W3 raw evidence.", + "independent_full_revalidation": False, + "validation": "Copy inventory and SHA256 only; this collector does not perform numerical validation.", + "selection_complete": not omitted, + "limits": {"max_file_bytes": max_file_bytes, "max_total_bytes": max_total_bytes}, + "retained_bytes": total, + "files": records, + "omitted": omitted, + } + (destination / "retained-files.json").write_text(json.dumps(files, indent=2) + "\n", encoding="utf-8") + (destination / "retained-evidence.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return files + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--destination", type=Path, required=True) + args = parser.parse_args(argv) + collect(args.source, args.destination) + manifest = json.loads((args.destination / "retained-evidence.json").read_text(encoding="utf-8")) + if not manifest["selection_complete"]: + print(f"Evidence selection incomplete: {len(manifest['omitted'])} omissions; see retained-evidence.json") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/package_w3_repro.py b/scripts/package_w3_repro.py new file mode 100644 index 0000000..3573c65 --- /dev/null +++ b/scripts/package_w3_repro.py @@ -0,0 +1,216 @@ +"""Create/verify a bounded, content-addressed W3 source handoff without committing. + +This preserves source bytes, not Git history, model assets or execution results. +The manifest is an integrity inventory, not a signature or numerical acceptance. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import subprocess +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = "W3_SOURCE_MANIFEST.json" +SCHEMA_VERSION = 2 +ROOT_FILES = {"pyproject.toml", "README.md", "LICENSE", "THIRD_PARTY_NOTICES.txt", + ".gitignore", ".gitattributes"} +TREES = {"scratchv", "scratchv_dag", "benchmarks", "examples", "probes", "scripts", "requirements", + "tests", "LICENSES"} +EXCLUDED_PARTS = {".git", "__pycache__", "output", "out", "build", "dist", ".venv", + "venv", ".pytest_cache", ".mypy_cache", ".ruff_cache"} +EXCLUDED_SUFFIXES = {".onnx", ".data", ".safetensors", ".npy", ".npz", ".pyc", + ".elf", ".bin", ".zip", ".pt", ".pth", ".exe", ".dll"} +MAX_FILE_BYTES = 8 * 1024**2 +MAX_TOTAL_BYTES = 64 * 1024**2 +REQUIRED = {"pyproject.toml", "LICENSE", "scripts/package_w3_repro.py", + "probes/w3_qwen3_full/run.py", "requirements/qwen3-small-probe.txt", + "examples/run_ir_interpreter.py", + "docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md", + "docs/llm-deploy-v1.0/W3/README.md"} +REPRODUCTION = {"platform": "linux", "shell": "bash", + "guide": "docs/llm-deploy-v1.0/LINUX_REPRODUCTION.md", + "stage_guide": "docs/llm-deploy-v1.0/W3/README.md"} + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def safe_name(name): + if not isinstance(name, str): + raise ValueError("Unsafe manifest path") + path = PurePosixPath(name) + if (not name or "\\" in name or ":" in name + or path.is_absolute() or any(x in ("", ".", "..") for x in name.split("/"))): + raise ValueError("Unsafe manifest path") + return path + + +def included(name): + path = safe_name(name) + if any(x in EXCLUDED_PARTS or x.startswith(".env") for x in path.parts): + return False + if path.suffix.lower() in EXCLUDED_SUFFIXES: + return False + return (name in ROOT_FILES or path.parts[0] in TREES + or name.startswith("docs/llm-deploy-v1.0/") + or (name.startswith(".github/workflows/w3-") and path.suffix in (".yml", ".yaml"))) + + +def git(root, *args): + result = subprocess.run(["git", "-C", str(root), *args], check=True, + capture_output=True, timeout=30) + return result.stdout + + +def candidates(root): + raw = git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard") + return sorted({name for value in raw.split(b"\0") if value + if included(name := value.decode("utf-8")) + and ((root/name).exists() or (root/name).is_symlink())}) + + +def read_source(root, name): + path = root / name + # Reject links including links in parents, even when pointing inside the tree. + for part in (path, *path.parents): + if part == root: + break + if part.is_symlink(): + raise ValueError(f"Linked source is not supported: {name}") + if not path.resolve().is_relative_to(root) or not path.is_file(): + raise ValueError(f"Missing or external source: {name}") + if path.stat().st_size > MAX_FILE_BYTES: + raise ValueError(f"Source exceeds file limit: {name}") + with path.open("rb") as stream: + value = stream.read(MAX_FILE_BYTES + 1) + if len(value) > MAX_FILE_BYTES: + raise ValueError(f"Source grew beyond file limit: {name}") + return value + + +def snapshot_id(files): + return digest(json.dumps(files, ensure_ascii=False, sort_keys=True, + separators=(",", ":")).encode("utf-8")) + + +def create(root, output): + root, output = Path(root).resolve(), Path(output).resolve() + checksum = output.with_suffix(output.suffix + ".sha256") + if output.exists() or checksum.exists(): + raise ValueError("Archive and checksum must use fresh paths") + payload, total = {}, 0 + for name in candidates(root): + value = read_source(root, name) + total += len(value) + if total > MAX_TOTAL_BYTES: + raise ValueError("Source snapshot exceeds total size limit") + payload[name] = value + for required in REQUIRED: + if required not in payload: + raise ValueError(f"Missing required source: {required}") + files = [{"path": name, "bytes": len(value), "sha256": digest(value)} + for name, value in payload.items()] + manifest = {"schema_version": SCHEMA_VERSION, "snapshot_id": snapshot_id(files), + "base_commit": git(root, "rev-parse", "HEAD").decode().strip(), + "scope": "Selected current working-tree source bytes; includes uncommitted W3 files.", + "excludes": "Git history, model weights/binaries, environments and run evidence.", + "reproduction": dict(REPRODUCTION), + "is_commit": False, "numerical_acceptance": False, "files": files} + if candidates(root) != list(payload): + raise ValueError("Source inventory changed during packaging; retry after edits finish") + for name, value in payload.items(): + if digest(read_source(root, name)) != digest(value): + raise ValueError(f"Source changed during packaging: {name}") + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_DEFLATED) as archive: + for name, value in [*payload.items(), (MANIFEST, json.dumps( + manifest, ensure_ascii=False, indent=2).encode("utf-8") + b"\n")]: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, value) + archive_hash = digest(output.read_bytes()) + with checksum.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(f"{archive_hash} {output.name}\n") + return {"snapshot_id": manifest["snapshot_id"], "files": len(files), + "source_bytes": total, "archive": str(output), "archive_sha256": archive_hash, + "base_commit": manifest["base_commit"], "is_commit": False} + + +def verify(root, expected_snapshot_id=None): + root = Path(root).resolve() + manifest = json.loads(read_source(root, MANIFEST)) + if (not isinstance(manifest, dict) or manifest.get("schema_version") != SCHEMA_VERSION + or not isinstance(manifest.get("files"), list)): + raise ValueError("Unsupported source manifest") + if manifest.get("reproduction") != REPRODUCTION: + raise ValueError("Source manifest must declare the Linux reproduction contract") + files = manifest["files"] + if not files: + raise ValueError("Empty source manifest") + actual_id = snapshot_id(files) + if actual_id != manifest.get("snapshot_id") or (expected_snapshot_id is not None + and actual_id != expected_snapshot_id): + raise ValueError("Snapshot identity mismatch") + seen, total = set(), 0 + for entry in files: + if not isinstance(entry, dict) or set(entry) != {"path", "bytes", "sha256"}: + raise ValueError("Invalid source file descriptor") + name = entry["path"] + if not isinstance(name, str) or not included(name) or name in seen: + raise ValueError("Unexpected or duplicate source path") + seen.add(name) + value = read_source(root, name) + total += len(value) + if total > MAX_TOTAL_BYTES or type(entry["bytes"]) is not int: + raise ValueError("Invalid source byte inventory") + if len(value) != entry["bytes"] or digest(value) != entry["sha256"]: + raise ValueError(f"Source bytes differ: {name}") + if not REQUIRED.issubset(seen): + raise ValueError("Manifest is missing required source files") + # Output directories and external models are allowed; added source is not. + for folder in [*(root/x for x in TREES), root/"docs/llm-deploy-v1.0", root/".github/workflows"]: + if folder.exists(): + for directory, dirs, names in os.walk(folder, followlinks=False): + dirs[:] = [name for name in dirs if name not in EXCLUDED_PARTS] + if any((Path(directory)/name).is_symlink() for name in dirs): + raise ValueError("Linked source directory is not supported") + for filename in names: + path = Path(directory) / filename + name = path.relative_to(root).as_posix() + if included(name) and name not in seen: + raise ValueError(f"Unlisted source file: {name}") + for name in ROOT_FILES: + if (root/name).exists() and name not in seen: + raise ValueError(f"Unlisted root source: {name}") + return {"passed": True, "snapshot_id": actual_id, "files": len(seen), + "source_bytes": total, "claim": "Source integrity only; no numerical or team acceptance."} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + make = sub.add_parser("create") + make.add_argument("--source-root", type=Path, default=ROOT) + make.add_argument("--output", type=Path, required=True) + check = sub.add_parser("verify") + check.add_argument("--root", type=Path, default=ROOT) + check.add_argument("--expected-snapshot-id") + args = parser.parse_args(argv) + try: + result = create(args.source_root, args.output) if args.command == "create" else verify( + args.root, args.expected_snapshot_id) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + parser.exit(1, f"W3 source snapshot failed: {exc}\n") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_w3_preparation.py b/scripts/run_w3_preparation.py new file mode 100644 index 0000000..9e979da --- /dev/null +++ b/scripts/run_w3_preparation.py @@ -0,0 +1,369 @@ +"""Run the five W3 preparation checks; this is not W3 full-model acceptance.""" +from __future__ import annotations +import argparse +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import time +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0,str(ROOT)) +from probes.w3_common import new_output_dir, source_evidence, sha256_file, write_reports +from probes.w3_qwen3_full.resources import spawn_owned, terminate_process_tree, wait_bounded + +GATES = ("medium","subgraphs","attention","full-preflight","layer-diff") +IDENTITIES = {"medium":"w3-medium-host","subgraphs":"w3_qwen3_subgraphs", + "attention":"unit:attention-backend","full-preflight":"preflight:w3-full-assets", + "layer-diff":"w3_layer_diff"} +LEVELS = ("none", "basic", "all") +GRAPH_KINDS = ("normal", "diagnostic") +MEDIUM_CASES = (("full_seed_0",256),("full_seed_42",256),("one_token",1), + ("short_17",17),("short_255",255),("changed_future",256),("changed_padding",17)) +MEDIUM_CHECKPOINTS = ("token_embedding",) + tuple( + f"layer_{layer}.{name}" for layer in range(6) for name in + ("input_norm","q_norm","k_norm","v_proj","rope_q","rope_k","attn_probs","attn_context", + "attn_output","attn_residual","post_attention_norm","mlp","residual")) + ("final_norm","logits") +SUBGRAPH_CASES = ("projection_q","projection_k","projection_v","projection_o","rmsnorm_hidden", + "rmsnorm_q","rope_q","rmsnorm_k","rope_k","gqa_full","gqa_padding", + "gqa_changed_padding","gqa_changed_future","swiglu") +ATTENTION_CASES = (("full17",17),("padding17",5),("future17",17), + ("padding_changed17",5),("single17",1),("boundary256",255)) +MEMORY_FIELDS = ("input_logical_bytes","initializer_logical_bytes","peak_live_logical_bytes", + "peak_numpy_storage_bytes","retained_values","retained_logical_bytes", + "retained_numpy_storage_bytes","return_logical_bytes") + +def require(condition, message): + if not condition: + raise ValueError(message) + +def check_numeric(value, atol): + if isinstance(value,dict): + if "passed" in value: + require(value["passed"] is True, "A nested required comparison failed") + for key,item in value.items(): + if key in ("max_abs","max_abs_error") and item is not None: + require(type(item) in (int,float) and math.isfinite(item) and 0 <= item < atol, + f"Numeric error exceeds {atol}") + check_numeric(item,atol) + elif isinstance(value,list): + for item in value: + check_numeric(item,atol) + elif isinstance(value,float): + require(math.isfinite(value),"Nonfinite number in evidence") + + +def checked_keys(value, keys, label): + require(isinstance(value,dict) and set(value)==set(keys),f"Missing/extra {label}") + return value + + +def checked_rows(rows, identities, fields, label): + require(isinstance(rows,list) and all(isinstance(row,dict) for row in rows),f"Missing {label}") + actual = [tuple(row.get(field) for field in fields) for row in rows] + require(len(actual)==len(identities) and set(actual)==set(identities),f"Missing/duplicate {label}") + return rows + + +def checked_tensor(row, atol): + require(isinstance(row,dict) and row.get("passed") is True,"Missing passing tensor comparison") + require(row.get("finite") is True and row.get("shape_matches") is True,"Missing finite/shape proof") + actual_shape = row.get("actual_shape",row.get("shape")) + require(isinstance(actual_shape,list) and actual_shape==row.get("expected_shape"),"Tensor shape mismatch") + require(row.get("actual_dtype",row.get("dtype"))==row.get("expected_dtype")=="float32","Missing FP32 proof") + maximum = row.get("max_abs",row.get("max_abs_error")) + require(type(maximum) in (int,float) and math.isfinite(maximum) and 0 <= maximum < atol,"Missing strict numeric proof") + require(row.get("atol")==atol and row.get("rtol",0)==0,"Wrong comparison tolerance") + require(row.get("empty",False) is False,"Empty tensor comparison") + + +def checked_comparison(comparison, names, atol, *, positions=False, padding=False): + require(isinstance(comparison,dict) and comparison.get("passed") is True,"Missing passing comparison") + rows = comparison.get("checkpoints") + require(isinstance(rows,list) and tuple(row.get("name") for row in rows)==tuple(names), + "Missing/duplicate/reordered comparison checkpoints") + for row in rows: + checked_tensor(row,atol) + if positions: + checked_tensor(row.get("valid_tokens"),atol) + if padding: + checked_tensor(row.get("padding_queries"),atol) + + +def checked_memory(execution): + require(isinstance(execution,dict) and type(execution.get("executed_steps")) is int + and execution["executed_steps"]>0,"Missing IR execution") + memory = execution.get("memory_stats") + require(isinstance(memory,dict),"Missing memory statistics") + require(all(type(memory.get(key)) is int and memory[key]>=0 for key in MEMORY_FIELDS), + "Missing/invalid memory statistic") + require(isinstance(memory.get("scope"),str) and bool(memory["scope"]),"Missing memory scope") + + +def checked_file(folder, relative, digest=None): + require(isinstance(relative,str) and bool(relative),"Missing evidence path") + path = (folder/relative).resolve() + require(path.is_relative_to(folder.resolve()),"Evidence path escapes output") + require(path.is_file(),f"Missing evidence: {relative}") + if digest is not None: + require(isinstance(digest,str) and len(digest)==64 and sha256_file(path)==digest, + f"Evidence hash mismatch: {relative}") + return path + + +def checked_artifact(folder, artifact): + require(isinstance(artifact,dict) and isinstance(artifact.get("sha256"),str),"Missing artifact hash") + return checked_file(folder,artifact.get("path"),artifact["sha256"]) + + +def validate_medium(report,folder): + require(report.get("checkpoint_count")==81 and report.get("coverage_complete") is True,"Incomplete medium coverage") + require(tuple(report.get("checkpoint_metadata",{}))==MEDIUM_CHECKPOINTS,"Wrong medium checkpoints") + cases = checked_rows(report.get("cases"),MEDIUM_CASES,("name","valid_length"),"medium cases") + kinds = (("pytorch","diagnostic"),("ort","normal"),("ort","diagnostic"), + *((f"ir_{level}",kind) for level in LEVELS for kind in GRAPH_KINDS)) + identities = {(name,backend,kind) for name in ("causality","padding_isolation") for backend,kind in kinds} + invariants = checked_rows(report.get("invariants"),identities,("name","backend","graph"),"medium invariants") + trace_paths = {} + for name,_ in MEDIUM_CASES: + trace_paths[f"traces/{name}/reference.npz"] = (name,"pytorch","diagnostic",None) + for kind in GRAPH_KINDS: + trace_paths[f"traces/{name}/ort_{kind}.npz"] = (name,"ort",kind,None) + for level in LEVELS: + trace_paths[f"traces/{name}/ir_{kind}_{level}.npz"] = (name,"ir",kind,level) + traces = checked_rows(report.get("trace_artifacts"),{(name,) for name in trace_paths},("path",),"medium traces") + by_path = {row["path"]:row for row in traces} + for trace in traces: + require(tuple(trace.get(key) for key in ("case","backend","graph","optimization"))==trace_paths[trace["path"]], + "Trace identity does not match its path") + checked_artifact(folder,trace) + for artifact in checked_keys(report.get("onnx"),GRAPH_KINDS,"medium ONNX graphs").values(): + checked_artifact(folder,artifact) + checked_file(folder,"trace_schema.json") + checked_file(folder,"logits_schema.json") + for case in cases: + require(case.get("passed") is True,"Failed medium case") + checked_tensor(case.get("capture_preserves_logits"),1e-5) + attention_names = {(f"layer_{layer}.{suffix}",) for layer in range(6) + for suffix in ("gqa_probabilities","gqa_context","blocked_attention")} + for check in checked_rows(case.get("attention_checks"),attention_names,("name",),"medium attention checks"): + checked_tensor(check,1e-5) + reference = case.get("reference_trace",{}) + require(reference==by_path[f"traces/{case['name']}/reference.npz"],"Missing reference trace binding") + ort = checked_keys(case.get("ort"),GRAPH_KINDS,"medium ORT graphs") + ir = checked_keys(case.get("ir"),LEVELS,"medium IR levels") + for kind in GRAPH_KINDS: + names = MEDIUM_CHECKPOINTS if kind=="diagnostic" else ("logits",) + require(ort[kind].get("passed") is True,"Missing successful medium ORT execution") + checked_comparison(ort[kind].get("pytorch_comparison"),names,1e-5,positions=True,padding=case["valid_length"]<256) + require(ort[kind].get("trace")==by_path[f"traces/{case['name']}/ort_{kind}.npz"],"Missing ORT trace binding") + if kind=="diagnostic": + checked_comparison(ort[kind].get("pack_layout"),names,1e-5) + for level in LEVELS: + execution = checked_keys(ir[level],GRAPH_KINDS,"medium IR graphs")[kind] + require(execution.get("status")=="success" and execution.get("passed") is True,"Missing successful medium execution") + checked_memory(execution) + for comparison in ("pytorch_comparison","ort_comparison"): + checked_comparison(execution.get(comparison),names,1e-5,positions=True,padding=case["valid_length"]<256) + require(execution.get("trace")==by_path[f"traces/{case['name']}/ir_{kind}_{level}.npz"],"Missing IR trace binding") + for invariant in invariants: + checked_comparison(invariant.get("comparison"),MEDIUM_CHECKPOINTS if invariant["graph"]=="diagnostic" else ("logits",),1e-5) + + +def validate_subgraphs(report,folder): + require(report.get("sequence_length")==256 and report.get("coverage_complete") is True,"Not full L256 subgraph set") + require(report.get("levels")==list(LEVELS),"Missing/duplicate subgraph levels") + rows = checked_rows(report.get("cases"),{(name,) for name in SUBGRAPH_CASES},("name",),"subgraph cases") + base = {"ort_pack_layout","torch_vs_ort","ordinary_ort_vs_torch","ordinary_ort_vs_diagnostic"} + keys = base | {f"{prefix}_{level}_vs_{reference}" for level in LEVELS + for prefix,references in (("ir",("ort","torch")),("ordinary_ir",("ort","torch","diagnostic"))) + for reference in references} + for row in rows: + name = row["name"] + require(row.get("passed") is True,"Failed subgraph case") + names = ("probabilities","y") if name.startswith("gqa_") else (("gate","up","hidden","y") if name=="swiglu" else ("y",)) + for key,comparison in checked_keys(row.get("comparisons"),keys,"subgraph comparisons").items(): + if key.startswith("ordinary_"): + checked_tensor(comparison,1e-4) + else: + checked_comparison(comparison,names,1e-4) + for executions in checked_keys(row.get("executions"),LEVELS,"subgraph IR levels").values(): + for execution in checked_keys(executions,("ordinary","diagnostic"),"subgraph IR graphs").values(): + checked_memory(execution) + artifacts = row.get("artifacts",{}) + require(artifacts.get("directory")==name,"Wrong subgraph artifact directory") + for filename,key in (("model.onnx","model_sha256"),("diagnostics.onnx","diagnostic_sha256")): + checked_artifact(folder,{"path":f"{name}/{filename}","sha256":artifacts.get(key)}) + for filename in ("inputs.npz","torch.npz","ort.npz","ordinary_ort.npy","checkpoints.json", + *(f"ir_{level}.npz" for level in LEVELS),*(f"ordinary_ir_{level}.npy" for level in LEVELS)): + checked_file(folder,f"{name}/{filename}") + if name.startswith("gqa_"): + for checks in checked_keys(row.get("semantic_checks"),("torch","ort","ir_none","ir_basic","ir_all"),"GQA backends").values(): + for comparison in checked_keys(checks,("blocked_probability","probability_row_sum"),"GQA semantics").values(): + checked_tensor(comparison,1e-4) + identities = {(name,backend) for name in ("padding_isolation","causality") + for backend in ("torch","ort","ir_none","ir_basic","ir_all")} + for invariant in checked_rows(report.get("invariants"),identities,("name","backend"),"subgraph invariants"): + require(invariant.get("query_prefix")== (256 if invariant["name"]=="padding_isolation" else 64),"Wrong invariant prefix") + checked_comparison(invariant,("probabilities","y"),1e-4) + + +def validate_attention(report,folder): + require(report.get("planned_executions")==report.get("passed_executions")==12,"Missing QEMU runs") + rows = checked_rows(report.get("cases"),ATTENTION_CASES,("name","valid_length"),"Attention cases") + for row in rows: + name = row["name"] + require(row.get("passed") is True,"Failed Attention case") + checked_tensor(row.get("ort_vs_numpy"),1e-4) + for filename,key in (("model.onnx","model_sha256"),("inputs.npz","input_sha256")): + checked_artifact(folder,{"path":f"{name}/{filename}","sha256":row.get(key)}) + for execution in checked_rows(row.get("executions"),{("none",),("all",)},("optimization",),"Attention executions"): + require(execution.get("status")=="success" and execution.get("passed") is True,"Missing successful target execution") + for key in ("ir_vs_ort","ir_vs_numpy","qemu_vs_ort","qemu_vs_numpy"): + checked_tensor(execution.get(key),1e-4) + elapsed = execution.get("qemu_process_wall_seconds") + require(type(elapsed) in (int,float) and math.isfinite(elapsed) and elapsed>=0,"Missing QEMU wall time") + for key in ("command","compile_command"): + command = execution.get(key) + require(isinstance(command,list) and len(command)>1 and all(isinstance(x,str) and x for x in command),"Missing target command") + level = execution["optimization"] + checked_artifact(folder,{"path":f"{name}/{level}/build/model.elf","sha256":execution.get("elf_sha256")}) + for filename in ("qemu.npy","ir.npy"): + checked_file(folder,f"{name}/{level}/{filename}") + identities = {(name,reference,prefix,backend) for name,reference,prefix in + (("future17","full17",5),("padding_changed17","padding17",17)) + for backend in ("ort","ir_none","ir_all","qemu_none","qemu_all")} + for invariant in checked_rows(report.get("invariants"),identities,("case","reference","prefix","backend"),"Attention invariants"): + checked_tensor(invariant.get("comparison"),1e-4) + +def validate_report(name, folder, sources): + for filename in ("report.json","report.md","report.html"): + require((folder/filename).is_file(),f"Missing {filename}") + report = json.loads((folder/"report.json").read_text(encoding="utf-8")) + require(report.get("gate")==IDENTITIES[name],"Wrong gate identity") + require(report.get("status")=="PASS" and report.get("passed") is True,"Gate not PASS") + fingerprints = report.get("source_sha256") or report.get("provenance",{}).get("source_sha256") + require(fingerprints == sources,"Source fingerprints differ from this invocation") + check_numeric(report,1e-5 if name in ("medium","layer-diff") else 1e-4) + if name == "medium": + validate_medium(report,folder) + elif name == "subgraphs": + validate_subgraphs(report,folder) + elif name == "attention": + validate_attention(report,folder) + elif name == "full-preflight": + require(report.get("full_ir_executed") is False and report.get("w3_exit_accepted") is False, + "Preflight must not claim complete-model numerical acceptance") + require(report.get("node_count")==7847 and report.get("weights_file_bytes")==2384201728,"Unexpected fixed full assets") + else: + coverage = report.get("coverage",{}) + require(coverage.get("complete") is True and coverage.get("compared_checkpoints")==81,"Incomplete layer comparison") + return {"report_sha256":sha256_file(folder/"report.json"),"gate":report["gate"], + "child_seconds":report.get("seconds",report.get("elapsed_seconds")), + "process_peak_rss_bytes":report.get("process_peak_rss_bytes")} + + +def wait_child(process,timeout,row): + """Bound the child and also reclaim its owned process tree on cancellation.""" + if getattr(process,"_scratchv_job",None) is not None or getattr(process,"_scratchv_owned_group",False): + try: + # Share the full runner's ownership protocol, including children + # surviving their launcher. Preparation has no memory threshold; + # the added samples only describe the owned subprocess tree. + return wait_bounded(process,timeout=timeout,max_memory_bytes=sys.maxsize,row=row) + except TimeoutError: + raise subprocess.TimeoutExpired(process.args,timeout) from None + try: + return process.wait(timeout=timeout) + except BaseException: + if process.poll() is None: + try: + terminate_process_tree(process) + except (OSError,subprocess.SubprocessError) as cleanup: + row["cleanup_error"] = f"{type(cleanup).__name__}: {cleanup}" + row["returncode"] = process.returncode + raise + +def build_commands(args, out): + # Keep the selected venv symlink: resolving it runs the base interpreter. + python = [str(Path(args.python).absolute()),"-B","-X","utf8"] + tools = [] + if args.cc: + tools += ["--cc",args.cc] + if args.qemu: + tools += ["--qemu",args.qemu] + medium = out/"medium" + return { + "medium":python+["probes/w3_qwen3_medium/run.py","--output-dir",str(medium)], + "subgraphs":python+["probes/w3_qwen3_subgraphs/run.py","--source-dir",str(args.source_dir.resolve()), + "--output-dir",str(out/"subgraphs")], + "attention":python+["probes/w3_attention/run.py","--output-dir",str(out/"attention"), + "--timeout",str(args.qemu_timeout),*tools], + "full-preflight":python+["probes/w3_full_preflight/run.py","--model-dir",str(args.model_dir.resolve()), + "--output-dir",str(out/"full-preflight")], + "layer-diff":python+["probes/w3_layer_diff/run.py", + "--actual",str(medium/"traces/short_17/ir_diagnostic_all.npz"), + "--reference",str(medium/"traces/short_17/ort_diagnostic.npz"), + "--schema",str(medium/"trace_schema.json"),"--out",str(out/"layer-diff")], + } + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--python",default=sys.executable) + parser.add_argument("--source-dir",type=Path,required=True) + parser.add_argument("--model-dir",type=Path,required=True) + parser.add_argument("--output-dir",type=Path,required=True) + parser.add_argument("--cc") + parser.add_argument("--qemu") + parser.add_argument("--gate-timeout",type=float,default=1800) + parser.add_argument("--qemu-timeout",type=float,default=180) + args = parser.parse_args(argv) + if any(not math.isfinite(x) or x<=0 for x in (args.gate_timeout,args.qemu_timeout)): + parser.error("Timeouts must be finite and positive") + out = new_output_dir(args.output_dir) + (out/"logs").mkdir() + started = time.perf_counter() + report = {"gate":"preparation:w3","passed":False,"status":"FAIL","gates":[], + "w3_exit_accepted":False,"full_ir_executed":False, + "scope":"Five local preparation checks only; full IR, team sign-off and Nightly remain separate"} + try: + report.update(source_evidence()) + commands = build_commands(args,out) + env = dict(os.environ,PYTHONIOENCODING="utf-8",OMP_NUM_THREADS="1",OPENBLAS_NUM_THREADS="1",MKL_NUM_THREADS="1") + for name in GATES: + row = {"name":name,"passed":False,"status":"FAIL","command":commands[name]} + report["gates"].append(row) + tick = time.perf_counter() + try: + if name=="layer-diff": + require(report["gates"][0]["passed"],"Layer-diff blocked by failed medium evidence") + with (out/"logs"/(name+".log")).open("w",encoding="utf-8") as stream: + options = {"creationflags":subprocess.CREATE_NEW_PROCESS_GROUP} if os.name=="nt" else {"start_new_session":True} + process = spawn_owned(commands[name],cwd=ROOT,stdout=stream,stderr=subprocess.STDOUT,env=env,**options) + try: + row["returncode"] = wait_child(process,args.gate_timeout,row) + except subprocess.TimeoutExpired: + raise TimeoutError(f"{name} exceeded {args.gate_timeout} seconds") from None + require(row["returncode"]==0,f"{name} exited {row['returncode']}; see logs/{name}.log") + row["validation"] = validate_report(name,out/name,report["source_sha256"]) + row.update(passed=True,status="PASS") + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + row["elapsed_seconds"] = time.perf_counter()-tick + print(f"[{name}] {row['status']}: {row.get('error','verified')}",flush=True) + require(source_evidence()["source_sha256"]==report["source_sha256"],"Source changed during execution") + report["passed"] = len(report["gates"])==len(GATES) and all(r["passed"] for r in report["gates"]) + except KeyboardInterrupt: + report.update(passed=False,interrupted=True,error="KeyboardInterrupt: preparation cancelled") + if report["gates"]: + report["gates"][-1].update(passed=False,status="FAIL",interrupted=True) + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + report.update(status="PASS" if report["passed"] else "FAIL",elapsed_seconds=time.perf_counter()-started) + write_reports(out,report) + return 130 if report.get("interrupted") else (0 if report["passed"] else 1) + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/scripts/summarize_w3_ci.py b/scripts/summarize_w3_ci.py new file mode 100644 index 0000000..1271a89 --- /dev/null +++ b/scripts/summarize_w3_ci.py @@ -0,0 +1,199 @@ +"""Publish bounded W3 numerical reports to GitHub Actions without changing gates.""" +from __future__ import annotations + +import argparse +from html import escape +import json +import math +import os +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from probes.numeric_summary import compact_metric_table +from probes.w3_summary import evidence_index_views, numerical_summary + +# The medium report currently exceeds 12 MiB because it retains checkpoints. +# Bound input allocation separately from the much smaller Actions page budget. +MAX_REPORT_BYTES = 64 * 1024 * 1024 +MAX_SUMMARY_BYTES = 64 * 1024 + +GATES = ( + ("medium", "w3-medium-host", "6-layer random Qwen3 / host IR"), + ("subgraphs", "w3_qwen3_subgraphs", "Pretrained layer-0 subgraphs / host IR"), + ("attention", "unit:attention-backend", "Small Attention / RISC-V QEMU"), + ("full-preflight", "preflight:w3-full-assets", "Full assets / static checks only"), + ("layer-diff", "w3_layer_diff", "Saved medium-model checkpoints only"), +) + + +def cell(value, limit=180): + text = " ".join(str(value).split()) + if len(text) > limit: + text = text[:limit] + "…" + return escape(text).replace("|", "|") + + +def load_report(path, gate): + try: + with path.open("rb") as stream: + payload = stream.read(MAX_REPORT_BYTES + 1) + if len(payload) > MAX_REPORT_BYTES: + return None, "INVALID REPORT (size limit)" + report = json.loads(payload.decode("utf-8")) + except FileNotFoundError: + return None, "MISSING REPORT" + except (OSError, ValueError, RecursionError): + return None, "INVALID REPORT" + if not isinstance(report, dict) or report.get("gate") != gate: + return None, "INVALID REPORT (gate identity)" + # Fail closed on malformed structures before calling the shared renderer. + for key in ("cases", "gates", "invariants"): + rows = report.get(key, []) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + return None, "INVALID REPORT (rows)" + return report, "" + + +def status(report): + if report.get("status") == "PARTIAL": + return "PARTIAL (not acceptance)" + if report.get("status") == "PASS" and report.get("passed") is True: + if report.get("gate") == "numeric:ir-full-qwen3" and report.get("coverage_complete") is not True: + return "INCOMPLETE (not acceptance)" + return "PASS" + return "FAIL" if report.get("passed") is False else "UNCONFIRMED" + + +def seconds(report): + value = report.get("elapsed_seconds", report.get("seconds")) + if type(value) in (int, float) and math.isfinite(value) and value >= 0: + return f"{value:.3f} s" + return "not recorded" + + +def coverage(report): + gate = report.get("gate") + cases = report.get("cases", []) + if gate == "w3_layer_diff": + values = report.get("coverage", {}) + return f"{values.get('compared_checkpoints', '?')}/{values.get('total_checkpoints', '?')} checkpoints" + if gate == "preflight:w3-full-assets": + return "no model execution" + if gate == "unit:attention-backend": + count = sum(len(row.get("executions", [])) for row in cases) + return f"{len(cases)} cases / {count} target executions recorded" + required = report.get("required_cases") + expected = len(required) if isinstance(required, list) else {"w3-medium-host": 7, "w3_qwen3_subgraphs": 14}.get(gate, "?") + return f"{len(cases)}/{expected} cases recorded" + + +def details(report, title): + """Reuse bounded semantic selectors, never dump arbitrary report dictionaries.""" + description, comparisons, notes = numerical_summary(report) + index, _ = evidence_index_views(report) + text = f"
{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/summarize_w4_ci.py b/scripts/summarize_w4_ci.py new file mode 100644 index 0000000..234a683 --- /dev/null +++ b/scripts/summarize_w4_ci.py @@ -0,0 +1,185 @@ +"""Render bounded W4 Actions summaries from this run's evidence (stdlib only).""" +from __future__ import annotations + +import argparse +from html import escape +import json +import math +import os +from pathlib import Path +import xml.etree.ElementTree as ET + + +NOT_RUN = "NOT RUN(未运行)" +MISSING = "MISSING(报告缺失或无效)" + + +def cell(value): + text = " ".join(str(value).split())[:240] + return escape(text).replace("|", "|").replace("`", "`") + + +def table(headers, rows): + return "\n".join("| " + " | ".join(cell(value) for value in row) + " |" + for row in [headers, ["---"] * len(headers), *rows]) + + +def number(value, *, integer=False): + if type(value) not in (int, float): + return "未记录" + try: + if not math.isfinite(value) or value < 0 or (integer and int(value) != value): + return "无效" + except OverflowError: + return "无效" + return f"{int(value):,}" if integer else f"{value:.9g}" + + +def mapping(value): + return value if isinstance(value, dict) else {} + + +def records(value): + return [row for row in value if isinstance(row, dict)] if isinstance(value, list) else [] + + +def exit_code(value): + # Negative POSIX return codes identify a terminating signal. + return str(value) if type(value) is int else "未记录" + + +def read_report(path): + try: + if path.stat().st_size > 16 * 1024 * 1024: + return {} + return mapping(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError, RecursionError): + return {} + + +def outcome_status(outcome, report=None): + if outcome == "skipped": + return NOT_RUN + if outcome in ("failure", "cancelled"): + return "FAIL" if outcome == "failure" else "CANCELLED(已取消)" + if outcome != "success": + return "UNKNOWN(执行状态缺失)" + if not report: + return MISSING + return "PASS" if report.get("passed") is True and report.get("status") == "PASS" else "FAIL / 未完成" + + +def unit_summary(path, outcome): + """Count actual JUnit cases, including skipped cases; never infer zero passes.""" + counts = dict(passed=0, failed=0, errors=0, skipped=0) + status = outcome_status(outcome) + available = False + if outcome != "skipped": + try: + root = ET.parse(path).getroot() + cases = list(root.iter("testcase")) + available = True + for case in cases: + key = ("errors" if case.find("error") is not None else + "failed" if case.find("failure") is not None else + "skipped" if case.find("skipped") is not None else "passed") + counts[key] += 1 + valid = bool(cases) and not counts["failed"] and not counts["errors"] and counts["passed"] > 0 + status = outcome_status(outcome, {"passed": valid, "status": "PASS" if valid else "FAIL"}) + except (OSError, ET.ParseError): + pass + values = [counts[key] for key in ("passed", "failed", "errors", "skipped")] + if not available or status in (NOT_RUN, MISSING, "UNKNOWN(执行状态缺失)"): + values = ["—"] * 4 + return (f"## W4 单测与真实小图 QEMU:{status}\n\n" + + table(["通过", "失败", "错误", "跳过"], [values]) + + ("\n\nJUnit 报告缺失或无效,未取得测试计数。" if not available and outcome != "skipped" else "") + + "\n\n范围:外置权重 ABI、装载和小图回归;不代表完整 28 层前向通过。" + "详细用例见本次运行的 `w4-unit-*` artifact / `tests.xml`。\n") + + +def full_summary(report, audit, *, probe_outcome, gate_outcome, audit_outcome, commit, case): + # A skipped probe must not display metrics from an old or unrelated report. + if probe_outcome == "skipped": + report, audit = {}, {} + status = outcome_status(probe_outcome, report) + identity = mapping(report.get("git")) + identity_ok = bool(commit) and identity.get("head") == commit and identity.get("dirty") is False + if status == "PASS" and not identity_ok: + status = "INVALID(报告来源与本次提交不符)" + if status == "PASS" and gate_outcome != "success": + status = "未验收(完整门禁未通过)" + audit_status = outcome_status(audit_outcome, audit) + if status == "PASS" and audit_status != "PASS": + status = "未验收(数组审计未通过)" + gates = mapping(report.get("gates")) + gate_rows = [] + for key in ("build:riscv-full", "smoke:qemu-full", "numeric:qemu-full-qwen3"): + flag = gates.get(key) + value = "PASS" if flag is True else "未完成" if flag is False else NOT_RUN + gate_rows.append([key, value]) + fma = mapping(report.get("fma_conformance")) + gate_rows += [["FMA 位模式 / FCSR", outcome_status("success", fma) if fma else NOT_RUN], + ["完整来源与数值门禁", "PASS" if gate_outcome == "success" else outcome_status(gate_outcome)], + ["保留数组审计", audit_status]] + cases = records(report.get("cases")) + # Prioritize failures and cap presentation only; machine evidence stays intact. + selected = sorted(cases, key=lambda row: row.get("passed") is True)[:7] + numeric_rows, run_rows = [], [] + for row in selected: + numeric, run = mapping(row.get("numeric")), mapping(row.get("qemu")) + numeric_rows.append([row.get("name", "未命名"), number(numeric.get("max_abs")), + number(numeric.get("rmse")), number(numeric.get("elements_compared"), integer=True), + number(numeric.get("elements_at_or_above_atol"), integer=True), + "PASS" if numeric.get("passed") is True else "未通过 / 未运行"]) + run_rows.append([row.get("name", "未命名"), number(run.get("qemu_wall_s")), + number(run.get("forward_wall_s")), exit_code(run.get("exit_code"))]) + if not numeric_rows: + numeric_rows = [[case, "未测", "未测", "未测", "未测", NOT_RUN]] + build = mapping(report.get("build")) + lines = [f"## W4 完整 Qwen3 RV64 前向:{status}", "", + f"本次提交:`{cell(commit)}`;请求用例:`{cell(case)}`。", "", + "完整输出门槛:所有 38,895,616 个 FP32 logits 的 MaxAbs < 1e-3,rtol=0。", "", + table(["用例", "MaxAbs", "RMSE", "比较元素", "超限元素", "数值结果"], numeric_rows), "", + f"不含权重 ELF:{number(build.get('bytes'), integer=True)} bytes(要求 < 100,000,000);" + f"流程墙钟:{number(report.get('elapsed_seconds'))} s。", "", + "QEMU 进程墙钟包含启动、执行和输出转储,不是芯片性能指标。", "", + table(["用例", "QEMU 墙钟 / s", "到 guest 完成 / s", "退出码"], run_rows or [[case, "未测", "未测", "未测"]]), "", + "
门禁、阶段耗时与证据入口", "", table(["门禁", "结果"], gate_rows), "", + f"报告阶段:{cell(report.get('stage', '未记录'))};报告提交:{cell(identity.get('head', '未记录'))}。"] + stages = mapping(report.get("stages_seconds")) + if stages: + lines += ["", table(["阶段", "墙钟 / s"], [[name, number(value)] for name, value in list(stages.items())[:25]])] + for label, item in (("探测", report), ("审计", audit)): + if item.get("error") or item.get("report_error"): + lines += ["", f"{label}错误:{cell(item.get('error') or item.get('report_error'))}"] + lines += ["", "完整报告见本次运行的 `w4-full-reports-*` artifact;原始数组在 `w4-full-raw-*`。", + "Summary 只展示已有报告,不重新计算误差;失败时已显示的数值是部分证据。", + "", "
", "", "范围:请求的单次前向;不代表七组覆盖、团队验收或第二人独立复现。", ""] + return "\n".join(lines) + + +def main(argv=None): + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument("--kind", choices=("unit", "full"), required=True) + cli.add_argument("--report-dir", type=Path, required=True) + cli.add_argument("--output", type=Path, required=True) + args = cli.parse_args(argv) + if args.kind == "unit": + text = unit_summary(args.report_dir / "tests.xml", os.environ.get("UNIT_OUTCOME", "")) + else: + text = full_summary(read_report(args.report_dir / "report.json"), + read_report(args.report_dir.with_name(args.report_dir.name + "-audit") / "report.json"), + probe_outcome=os.environ.get("PROBE_OUTCOME", ""), gate_outcome=os.environ.get("GATE_OUTCOME", ""), + audit_outcome=os.environ.get("AUDIT_OUTCOME", ""), commit=os.environ.get("GITHUB_SHA", ""), + case=os.environ.get("SELECTED_CASE", "未记录")) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + if os.environ.get("GITHUB_STEP_SUMMARY"): + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as stream: + stream.write(text + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_w3_evidence.py b/scripts/verify_w3_evidence.py new file mode 100644 index 0000000..e4bb4fd --- /dev/null +++ b/scripts/verify_w3_evidence.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Recheck saved W3 full-model evidence without executing either model backend.""" +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import re +import sys +import time + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import numpy as np + +from probes.w1_qwen3_export.run import MANIFEST, verify_files +from probes.w3_common import new_output_dir, sha256_file, source_evidence, write_reports +from probes.w3_layer_diff.run import load_arrays +from probes.w3_qwen3_full import run as full +from probes.w3_qwen3_full.cases import CASE_NAMES, input_cases + +MAX_JSON_BYTES = 8 * 1024**2 +HASH = re.compile(r"[0-9a-f]{64}\Z") +OBSERVATIONAL_FIELDS = frozenset(("relative_l2", "cosine_similarity", + "relative_l2_reason", "cosine_similarity_reason")) +SCOPE = ("Offline consistency and numerical recheck of saved seven-case arrays. " + "No model execution, independent-person reproduction, current-source execution, " + "Linux execution, Nightly run, or team acceptance is established. " + "Hashes establish consistency, not producer authenticity; retain a trusted report hash.") + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def _unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, f"Duplicate JSON key: {key}") + result[key] = value + return result + + +def read_json(path): + require(path.is_file() and path.stat().st_size <= MAX_JSON_BYTES, + f"Missing or oversized JSON: {path.name}") + def reject(value): + raise ValueError(f"Nonfinite JSON constant: {value}") + def finite_float(value): + number = float(value) + require(math.isfinite(number), f"Nonfinite JSON number: {value}") + return number + value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique, + parse_constant=reject, parse_float=finite_float) + require(isinstance(value, dict), f"JSON object required: {path.name}") + return value + + +def safe_file(root, relative): + path = root / relative + require(path.is_file() and path.resolve().is_relative_to(root.resolve()), + f"Missing/external evidence file: {relative}") + return path + + +def source_difference(recorded, current): + require(isinstance(recorded, dict) and bool(recorded), "Missing producer source hashes") + for name, digest in recorded.items(): + require(isinstance(name, str) and name and "\\" not in name and ":" not in name + and not name.startswith("/") and all(p not in ("", ".", "..") for p in name.split("/")) + and isinstance(digest, str) and HASH.fullmatch(digest), "Invalid producer source hash") + return {"matches_current": recorded == current, + "changed": [{"path": name, "recorded_sha256": recorded[name], "current_sha256": current[name]} + for name in sorted(recorded.keys() & current.keys()) if recorded[name] != current[name]], + "added_since_recording": sorted(current.keys() - recorded.keys()), + "missing_from_current": sorted(recorded.keys() - current.keys()), + "scope": "Source mismatch is reported, not silently treated as current-source execution. " + "This audit recomputes stored-array comparisons only."} + + +def comparison_matches_saved(recomputed, saved): + """Permit only the absent four-field observation group in legacy rows. + + Old reports predate these descriptive metrics. Existing fields and any + recorded metrics must still match the arrays; a partial group is invalid. + This does not relax profiles, source evidence, thresholds or PASS checks. + """ + if isinstance(recomputed, dict): + if not isinstance(saved, dict): + return False + wanted = set(recomputed) + observed = OBSERVATIONAL_FIELDS & saved.keys() + if OBSERVATIONAL_FIELDS <= wanted: + if not observed: + wanted -= OBSERVATIONAL_FIELDS + elif observed != OBSERVATIONAL_FIELDS: + return False + else: + # JSON booleans must not impersonate numeric 0/1 observations. + if any(type(saved[key]) is not type(recomputed[key]) + for key in OBSERVATIONAL_FIELDS): + return False + if set(saved) != wanted: + return False + return all(comparison_matches_saved(recomputed[key], saved[key]) for key in wanted) + if isinstance(recomputed, list): + return (isinstance(saved, list) and len(recomputed) == len(saved) + and all(comparison_matches_saved(a, b) for a, b in zip(recomputed, saved))) + return recomputed == saved + + +def verify(directory, *, model_dir=None, expected_report_sha256=None, progress=None): + """Reuse the runner's numerical validators; never call its worker launcher.""" + directory = Path(directory).resolve() + top_path = safe_file(directory, "report.json") + digest = sha256_file(top_path) + if expected_report_sha256 is not None: + require(isinstance(expected_report_sha256, str) and HASH.fullmatch(expected_report_sha256), + "Expected report SHA-256 must be 64 lowercase hexadecimal characters") + require(digest == expected_report_sha256, "Trusted report SHA-256 differs") + top = read_json(top_path) + require(top.get("gate") == "numeric:ir-full-qwen3" and top.get("stage") == "complete", + "Wrong/incomplete full-model report") + require(top.get("passed") is True and top.get("status") == "PASS" + and top.get("coverage_complete") is True and top.get("selected_numerical_passed") is True + and top.get("full_ir_executed") is True and top.get("w3_exit_accepted") is False, + "Producer report is not a complete passing numerical run") + require(top.get("optimization_level") == "none" and top.get("atol") == full.ATOL + and top.get("rtol") == 0 and top.get("fp32_mode") in ("native", "reference"), + "Wrong numerical contract") + require(top.get("required_cases") == list(CASE_NAMES) and top.get("selected_cases") == list(CASE_NAMES), + "Seven-case coverage required") + rows = top.get("cases") + require(isinstance(rows, list) and all(isinstance(row, dict) for row in rows) + and [row.get("name") for row in rows] == list(CASE_NAMES), "Missing/duplicate/reordered cases") + require(top.get("files") == MANIFEST["files"], "Producer assets differ from pinned manifest") + current = source_evidence() + differences = source_difference(top.get("source_sha256"), current["source_sha256"]) + if model_dir is not None: + require(verify_files(model_dir) == top["files"], "Local model assets differ") + + verified, profiles = [], [] + for row, (name, valid, wanted) in zip(rows, input_cases()): + require(row.get("name") == name and type(row.get("valid_length")) is int + and row["valid_length"] == valid and row.get("passed") is True, "Wrong case contract") + folder = directory / name + input_path = safe_file(directory, f"{name}/inputs.npz") + require(row.get("input_sha256") == sha256_file(input_path), f"Altered input: {name}") + schema = [{"name": key, "shape": list(value.shape), "dtype": str(value.dtype)} + for key, value in wanted.items()] + feed = load_arrays(input_path, schema, max_bytes=1024**2) + require(all(np.array_equal(feed[key], value) for key, value in wanted.items()), + f"Saved inputs differ from deterministic case: {name}") + workers = row.get("workers") + require(isinstance(workers, list) and all(isinstance(w, dict) for w in workers) + and [w.get("backend") for w in workers] == ["ort", "ir"], "Missing/duplicate case workers") + for saved in workers: + backend = saved["backend"] + report_path = safe_file(directory, f"{name}/{backend}/report.json") + require(saved.get("passed") is True and type(saved.get("returncode")) is int + and saved["returncode"] == 0 and saved.get("report_sha256") == sha256_file(report_path), + f"Invalid/altered worker report: {name}/{backend}") + report = read_json(report_path) + # Preflight strict JSON and containment before invoking the shared reader. + for filename in ("report.md", "report.html", "logits.npy", "diagnostic_logits.npy", + "checkpoints.npz", "checkpoint_schema.json"): + safe_file(directory, f"{name}/{backend}/{filename}") + read_json(folder / backend / "checkpoint_schema.json") + saved_profile = (full.saved_arithmetic_profile(top["fp32_mode"], + report.get("ir", {}).get("fp32_profile")) if backend == "ir" else None) + evidence = full.validate_worker(folder / backend, backend, input_path, + top["source_sha256"], top["files"], + expected_fp32_mode=top["fp32_mode"], + expected_fp32_profile=saved_profile) + require(evidence == report, "Worker report changed during audit") + require(evidence["input"].get("valid_length") == valid, "Wrong worker valid length") + if backend == "ir": + profiles.append(evidence["ir"]["fp32_profile"]) + comparison = full.compare_case(folder, valid) + require(comparison["passed"], f"Recomputed logits fail threshold: {name}") + require(comparison_matches_saved(comparison, row.get("comparison")), + f"Stored comparison differs from arrays: {name}") + verified.append({"name": name, "passed": True, "valid_length": valid, + "input_sha256": row["input_sha256"], "comparison": comparison}) + if progress: + progress(name) + require(len(profiles) == len(CASE_NAMES) and all(p == profiles[0] for p in profiles), + "Inconsistent numerical profiles") + invariants = full.invariants(directory, set(CASE_NAMES)) + require(len(invariants) == 4 and all(row["passed"] for row in invariants), "Recomputed invariants fail") + require(comparison_matches_saved(invariants, top.get("invariants")), "Stored invariants differ from arrays") + require(sha256_file(top_path) == digest, "Producer report changed during audit") + return {"gate": "audit:w3-full-saved-evidence", "schema_version": 1, "status": "PASS", "passed": True, + "scope": SCOPE, "model_executed": False, "independent_reproduction": False, + "w3_exit_accepted": False, "producer_report_sha256": digest, + "trusted_report_hash_checked": expected_report_sha256 is not None, + "pinned_model_files_checked": model_dir is not None, "producer_environment": top.get("environment"), + "producer_git": top.get("git"), "auditor": current, "source_comparison": differences, + "fp32_mode": top["fp32_mode"], "fp32_profile": profiles[0], "cases": verified, + "invariants": invariants, "coverage_complete": True, + "comparison_compatibility": { + "allowed_legacy_missing_fields": sorted(OBSERVATIONAL_FIELDS), + "scope": "Only a wholly absent observation group is accepted for legacy tensor rows. " + "All observations in this audit are recomputed from saved arrays, not a new model run."}, + "checkpoint_threshold_scope": "Checkpoints are finite FP32 diagnostics; the strict numerical gate " + "applies to every ordinary/diagnostic output logit, including padding."} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--model-dir", type=Path, help="Optionally verify local pinned model file hashes; never execute it") + parser.add_argument("--expected-report-sha256", help="Optional independently retained producer report digest") + args = parser.parse_args(argv) + out = new_output_dir(args.output_dir) + start = time.perf_counter() + result = {"gate": "audit:w3-full-saved-evidence", "schema_version": 1, "passed": False, + "status": "FAIL", "scope": SCOPE, "model_executed": False, + "independent_reproduction": False, "w3_exit_accepted": False} + try: + result = verify(args.evidence_dir, model_dir=args.model_dir, + expected_report_sha256=args.expected_report_sha256, + progress=lambda name: print(f"[{name}] saved arrays verified", flush=True)) + except Exception as exc: + result["error"] = f"{type(exc).__name__}: {exc}" + result["elapsed_seconds"] = time.perf_counter() - start + write_reports(out, result) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py index 4c38853..a37caf2 100644 --- a/tests/test_ci_workflows.py +++ b/tests/test_ci_workflows.py @@ -74,7 +74,7 @@ def test_the_w1_w2_job_writes_no_step_summary(): data = workflow('llm-deploy.yml') writers = {name for name, job in data['jobs'].items() for step in job.get('steps', []) if 'GITHUB_STEP_SUMMARY' in step.get('run', '')} - assert writers == {'full-qwen3-onnx'} + assert writers == {'full-qwen3-onnx', 'w3-execution-summary', 'w4-execution-summary'} def test_linux_reproduction_check_runs_without_a_path_filter(): diff --git a/tests/test_ir_interpreter.py b/tests/test_ir_interpreter.py index c61a028..24c5699 100644 --- a/tests/test_ir_interpreter.py +++ b/tests/test_ir_interpreter.py @@ -111,7 +111,7 @@ def test_empty_invalid_and_void_returns(): def test_error_position_and_cause(): - x = Value("x") + x = Value("x", shape=(2,)) b = builder(x) b.ret(b.reshape(x, (3,))) with pytest.raises(IRExecutionError) as found: @@ -131,7 +131,7 @@ def test_missing_value_not_zero_and_shape_on_result(): b.ret(Value("missing")) with pytest.raises(IRExecutionError, match="InvalidProgram"): IRInterpreter(b.program).run({}) - x = Value("x") + x = Value("x", shape=(2,)) b = builder(x) result = b.neg(x) result.shape = (3,) diff --git a/tests/test_ir_interpreter_control_flow.py b/tests/test_ir_interpreter_control_flow.py index b8b533f..ecceafd 100644 --- a/tests/test_ir_interpreter_control_flow.py +++ b/tests/test_ir_interpreter_control_flow.py @@ -188,7 +188,7 @@ def test_memory_errors(mode): if mode == "numeric_address": b.ret(b.load(b.make_const(42, D.INT32))) if mode == "vector_store": - value = Value("value", D.INT32) + value = Value("value", D.INT32, shape=(2,)) b.current_func.params = [value] b.store(slot, value) b.ret() diff --git a/tests/test_ir_interpreter_fp32_eigen.py b/tests/test_ir_interpreter_fp32_eigen.py new file mode 100644 index 0000000..ab671f1 --- /dev/null +++ b/tests/test_ir_interpreter_fp32_eigen.py @@ -0,0 +1,134 @@ +"""Independent numerical and shape checks for deterministic FP32 kernels.""" + +import numpy as np +import pytest + +from scratchv.verification.fp32_eigen import mean_last, trig + + +@pytest.mark.parametrize("width", [1, 2, 3, 4, 5, 7, 8, 9, 12, 15, 16, 17, 31, 32, 127, 128, 255, 1024, 3072]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_accuracy_including_packet_prefixes_and_tails(width, keepdims): + rng = np.random.default_rng(2201 + width) + values = rng.uniform(-3, 5, (2, 7, width)).astype(np.float32) + actual = mean_last(values, keepdims=keepdims) + reference = np.mean(values.astype(np.float64), axis=-1, keepdims=keepdims) + assert actual.dtype == np.float32 + assert actual.shape == reference.shape + np.testing.assert_allclose(actual, reference, atol=6e-7, rtol=6e-7) + + +@pytest.mark.parametrize("width", [1, 3, 5, 8, 17]) +def test_mean_scalar_result_and_empty_leading_dimensions(width): + values = np.arange(width, dtype=np.float32) + actual = mean_last(values, keepdims=False) + assert isinstance(actual, np.ndarray) + assert actual.shape == () + assert actual == np.float32((width - 1) / 2) + assert mean_last(values).shape == (1,) + for leading in [(0,), (2, 0)]: + empty = np.empty(leading + (width,), dtype=np.float32) + assert mean_last(empty).shape == leading + (1,) + assert mean_last(empty, keepdims=False).shape == leading + + +@pytest.mark.parametrize("shape", [(), (0,), (2, 0)]) +def test_mean_rejects_missing_or_empty_reduction_dimension(shape): + with pytest.raises(ValueError, match="nonempty last dimension"): + mean_last(np.empty(shape, dtype=np.float32)) + + +@pytest.mark.parametrize("width", [3, 5, 8, 17]) +def test_mean_does_not_depend_on_input_alignment_or_strides(width): + backing = np.arange(1 + 6 * width * 2, dtype=np.float32) + values = backing[1:].reshape(6, width * 2)[:, ::-2] + assert not values.flags.c_contiguous + values.setflags(write=False) + original = values.copy() + actual = mean_last(values) + np.testing.assert_array_equal(actual, mean_last(original)) + np.testing.assert_allclose(actual, np.mean(original.astype(np.float64), axis=-1, keepdims=True)) + np.testing.assert_array_equal(values, original) + assert not np.shares_memory(actual, values) + + +@pytest.mark.parametrize("width", [1, 3, 4, 5, 17]) +def test_mean_preserves_negative_zero(width): + actual = mean_last(np.full((4, width), -0.0, dtype=np.float32)) + assert np.all(actual == 0) + assert np.all(np.signbit(actual)) + + +@pytest.mark.parametrize("sine", [True, False]) +@pytest.mark.parametrize("bound", [1e-5, 255, 17999]) +def test_trig_accuracy_against_independent_double_reference(sine, bound): + values = np.random.default_rng(4101).uniform(-bound, bound, 32768).astype(np.float32) + function = np.sin if sine else np.cos + reference = function(values.astype(np.float64)) + actual = trig(values, sine=sine) + assert actual.dtype == np.float32 + np.testing.assert_allclose(actual, reference, rtol=0, atol=1.25e-7) + assert np.all(np.abs(actual) <= np.float32(1)) + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_scalar_empty_and_signed_zero(sine): + for scalar in [np.float32(0), np.float32(-0.0), np.float32(1)]: + actual = trig(scalar, sine=sine) + assert isinstance(actual, np.ndarray) + assert actual.shape == () + assert actual.dtype == np.float32 + zeros = trig(np.array([0.0, -0.0], dtype=np.float32), sine=sine) + np.testing.assert_array_equal(zeros, [0.0, 0.0] if sine else [1.0, 1.0]) + np.testing.assert_array_equal(np.signbit(zeros), [False, True] if sine else [False, False]) + for shape in [(0,), (2, 0), (0, 3)]: + actual = trig(np.empty(shape, dtype=np.float32), sine=sine) + assert actual.shape == shape + assert actual.dtype == np.float32 + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_readonly_strided_input_is_not_mutated(sine): + values = np.linspace(-100, 100, 187, dtype=np.float32).reshape(11, 17).T[:, ::-2] + assert not values.flags.c_contiguous + values.setflags(write=False) + original = values.copy() + actual = trig(values, sine=sine) + np.testing.assert_array_equal(actual, trig(original, sine=sine)) + np.testing.assert_array_equal(values, original) + assert not np.shares_memory(actual, values) + + +@pytest.mark.parametrize("sine", [True, False]) +def test_trig_interval_boundary_and_numpy_fallback(sine): + edge = np.float32(18000) + interior = np.nextafter(edge, np.float32(0)) + values = np.array( + [-np.finfo(np.float32).max, -1e20, -edge, -interior, interior, edge, 1e20, + np.finfo(np.float32).max, np.inf, -np.inf, np.nan], dtype=np.float32 + ) + function = np.sin if sine else np.cos + with np.errstate(invalid="ignore"): + actual = trig(values, sine=sine) + fallback_reference = function(values) + exact_reference = function(values.astype(np.float64)) + fallback = ~np.isfinite(values) | (np.abs(values) >= edge) + np.testing.assert_array_equal(actual[fallback], fallback_reference[fallback]) + np.testing.assert_allclose(actual[~fallback], exact_reference[~fallback], rtol=0, atol=1.25e-7) + + +@pytest.mark.parametrize("dtype", [np.float64, np.float16, np.int32, np.int64, np.bool_]) +def test_fp32_functions_reject_implicit_precision_conversion(dtype): + values = np.ones(4, dtype=dtype) + with pytest.raises(TypeError, match="float32"): + mean_last(values) + with pytest.raises(TypeError, match="float32"): + trig(values, sine=True) + + +def test_boolean_parameters_reject_ambiguous_values(): + values = np.ones(4, dtype=np.float32) + with pytest.raises(TypeError, match="keepdims"): + mean_last(values, keepdims=1) + with pytest.raises(TypeError, match="sine"): + trig(values, sine="sin") diff --git a/tests/test_ir_interpreter_fp32_fma.py b/tests/test_ir_interpreter_fp32_fma.py new file mode 100644 index 0000000..86048ce --- /dev/null +++ b/tests/test_ir_interpreter_fp32_fma.py @@ -0,0 +1,154 @@ +"""Portable bit-exact FMA checks against a Python arbitrary-integer oracle.""" +import itertools + +import numpy as np +import pytest + +from scratchv.verification.fp32_fma import fma + + +def exact_fma_bits(a_bits, b_bits, c_bits): + """Independent exact dyadic arithmetic, no host floating-point reference.""" + def decode(bits): + sign = bits >> 31 + exponent = (bits >> 23) & 255 + fraction = bits & 0x7fffff + assert exponent != 255 + return sign, fraction | ((1 << 23) if exponent else 0), exponent - 150 if exponent else -149 + + sa, a, ea = decode(int(a_bits)) + sb, b, eb = decode(int(b_bits)) + sc, c, ec = decode(int(c_bits)) + ep, sp = ea + eb, sa ^ sb + base = min(ep, ec) + exact = ((-1 if sp else 1) * ((a * b) << (ep - base)) + + (-1 if sc else 1) * (c << (ec - base))) + if exact == 0: + return (sp & sc) << 31 + sign, magnitude = int(exact < 0), abs(exact) + quantum = max(magnitude.bit_length() - 1 + base - 23, -149) + shift = quantum - base + if shift < 0: + quotient = magnitude << -shift + else: + quotient, remainder = divmod(magnitude, 1 << shift) + midpoint_twice = 1 << shift + if 2 * remainder > midpoint_twice or (2 * remainder == midpoint_twice and quotient & 1): + quotient += 1 + if quotient == 1 << 24: + quotient >>= 1 + quantum += 1 + exponent = quantum + 150 if quotient >= 1 << 23 else 0 + if exponent >= 255: + raise OverflowError("exact result rounds to infinity") + return sign << 31 | exponent << 23 | (quotient & 0x7fffff) + + +def check_bits(rows): + finite, expected, overflowing = [], [], [] + for row in rows: + try: + answer = exact_fma_bits(*row) + except OverflowError: + overflowing.append(row) + else: + finite.append(row) + expected.append(answer) + if finite: + data = np.asarray(finite, dtype=np.uint32).view(np.float32) + actual = fma(*data.T) + np.testing.assert_array_equal(actual.view(np.uint32), np.asarray(expected, dtype=np.uint32)) + if overflowing: + data = np.asarray(overflowing, dtype=np.uint32).view(np.float32) + with pytest.raises(FloatingPointError, match="overflows"): + fma(*data.T) + + +def test_float32_fma_boundaries_signed_zero_subnormal_cancellation_and_overflow(): + bits = [0, 0x80000000, 1, 0x80000001, 0x007fffff, 0x807fffff, + 0x00800000, 0x80800000, 0x3f000000, 0xbf000000, 0x3f800000, + 0xbf800000, 0x3f800001, 0x3f7fffff, 0x7f7fffff, 0xff7fffff] + check_bits(itertools.product(bits, repeat=3)) + + +def test_compensated_double_rounding_midpoint_uses_exact_result(): + values = np.array([0x33800001, 0x3f7fffff, 0x3f800000], dtype=np.uint32).view(np.float32) + result = fma(*values) + assert result.view(np.uint32) == 0x3f800001 + assert exact_fma_bits(*values.view(np.uint32)) == 0x3f800001 + + +@pytest.mark.parametrize("bounded", [False, True]) +def test_random_finite_inputs_against_exact_integer_reference(bounded): + rng = np.random.default_rng(20261005) + rows = rng.integers(0, 2**32, size=(10_000, 3), dtype=np.uint32) + if bounded: + rows &= np.uint32(0x807fffff) + rows |= rng.integers(86, 169, size=rows.shape, dtype=np.uint32) << np.uint32(23) + rows = rows[np.all(((rows >> 23) & 255) != 255, axis=1)] + check_bits(rows) + + +def test_range_endpoints_and_adjacent_values_against_exact_reference(): + values = [np.float32(2**-40), np.float32(2**40)] + choices = [item for value in values for item in ( + np.nextafter(value, np.float32(0)), value, np.nextafter(value, np.float32(np.inf)))] + choices += [-value for value in choices] + bits = np.asarray(choices, dtype=np.float32).view(np.uint32) + check_bits(itertools.product(bits, repeat=3)) + + +def test_product_cancellation_and_adjacent_addends_against_exact_reference(): + # Uniform random triples rarely almost cancel. Place c at the negated + # rounded product and its immediate neighbors to exercise that boundary. + rng = np.random.default_rng(6301) + operands = rng.integers(0, 2**32, size=(4096, 2), dtype=np.uint32) + operands &= np.uint32(0x807fffff) + operands |= rng.integers(100, 151, size=operands.shape, + dtype=np.uint32) << np.uint32(23) + floats = operands.view(np.float32) + canceled = (-(floats[:, 0] * floats[:, 1])).view(np.uint32) + rows = [np.column_stack((operands, canceled + offset)) + for offset in (np.uint32(0), np.uint32(1))] + rows.append(np.column_stack((operands, canceled - np.uint32(1)))) + check_bits(np.concatenate(rows)) + + +def test_mixed_fast_and_integer_fallback_and_input_immutability(monkeypatch): + import scratchv.verification.fp32_fma as module + + original, observed = module._integer_fma, [] + def capture(a, b, c): + observed.append(len(a)) + return original(a, b, c) + monkeypatch.setattr(module, "_integer_fma", capture) + values = np.array([[0.75, 0.25, 0.125], [0, 1, 1], [2, 0.5, 1], + [0.5, 0.5, -0.25]], dtype=np.float32) + before = values.copy() + values.setflags(write=False) + result = fma(*values.T) + expected = [exact_fma_bits(*row) for row in values.view(np.uint32)] + np.testing.assert_array_equal(result.view(np.uint32), expected) + np.testing.assert_array_equal(values, before) + assert observed == [3] + + +def test_broadcast_scalar_and_empty_shapes(): + result = fma(np.array([[1], [-1]], dtype=np.float32), + np.array([[2, 3, 4]], dtype=np.float32), np.float32(1)) + np.testing.assert_array_equal(result, [[3, 4, 5], [-1, -2, -3]]) + assert result.dtype == np.float32 + assert fma(np.float32(1), np.float32(2), np.float32(3)).shape == () + assert fma(np.empty((0, 3), dtype=np.float32), np.float32(1), np.float32(2)).shape == (0, 3) + + +@pytest.mark.parametrize("bad", [np.float32(np.nan), np.float32(np.inf), np.float32(-np.inf)]) +def test_nonfinite_inputs_are_rejected(bad): + with pytest.raises(ValueError, match="finite float32"): + fma(np.float32(1), bad, np.float32(1)) + + +@pytest.mark.parametrize("bad", [1.0, 1, np.float64(1), np.array([1], dtype=">f4")]) +def test_precision_promotion_is_rejected(bad): + with pytest.raises(TypeError, match="float32"): + fma(bad, np.float32(1), np.float32(1)) diff --git a/tests/test_ir_interpreter_fp32_reference.py b/tests/test_ir_interpreter_fp32_reference.py new file mode 100644 index 0000000..f499da7 --- /dev/null +++ b/tests/test_ir_interpreter_fp32_reference.py @@ -0,0 +1,538 @@ +"""The opt-in FP32 profile preserves IR semantics and per-run isolation.""" + +import subprocess +import sys +import os +from pathlib import Path + +import numpy as np +import pytest + +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType as D, Instruction, OpCode as O, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +DTYPES = {np.dtype("float32"): D.FLOAT32, np.dtype("float64"): D.FLOAT64, + np.dtype("int32"): D.INT32, np.dtype("int64"): D.INT64} + + +def operation(op, arrays, attrs=None): + params = [Value(f"x{i}", DTYPES[x.dtype], shape=x.shape) + for i, x in enumerate(arrays)] + builder = IRBuilder() + builder.new_function("main", params) + builder.new_block() + dest = Value("result", params[0].dtype) + builder.current_block.add(Instruction(op, dest, params, attrs or {})) + builder.ret(dest) + return builder.program, dict(zip((p.name for p in params), arrays)) + + +def execute(op, arrays, attrs=None, **options): + program, inputs = operation(op, arrays, attrs) + return IRInterpreter(program).run(inputs, **options).return_value + + +@pytest.mark.parametrize("mode", [None, [], True, "", "Reference", "automatic"]) +def test_unknown_profile_is_an_explicit_option_error(mode): + program, inputs = operation(O.SIGMOID, [np.array([0], "float32")]) + with pytest.raises(IRExecutionError, match="fp32_mode") as caught: + IRInterpreter(program).run(inputs, fp32_mode=mode) + assert caught.value.code == "InvalidOptions" + + +@pytest.mark.parametrize("axis", [0, 1, 2, -1, -2, -3]) +def test_softmax_normalizes_the_requested_axis(axis): + x = np.linspace(-4, 3, 30, dtype="float32").reshape(2, 3, 5) + actual = execute(O.SOFTMAX, [x], {"axis": axis}, fp32_mode="reference") + wide = x.astype(np.float64) + weights = np.exp(wide - np.max(wide, axis=axis, keepdims=True)) + expected = weights / np.sum(weights, axis=axis, keepdims=True) + assert actual.shape == x.shape and actual.dtype == x.dtype + np.testing.assert_allclose(actual, expected, rtol=3e-7, atol=3e-8) + np.testing.assert_allclose(actual.sum(axis=axis), 1, rtol=0, atol=2e-7) + + +def test_softmax_finite_extremes_allow_only_negative_overflow_in_shift(): + maximum = np.finfo(np.float32).max + x = np.array([[-maximum, maximum], [maximum, maximum], + [-maximum, -maximum]], "float32") + actual = execute(O.SOFTMAX, [x], {"axis": 1}, fp32_mode="reference") + np.testing.assert_array_equal(actual, [[0, 1], [0.5, 0.5], [0.5, 0.5]]) + + +def test_softmax_partial_negative_infinity_mask_is_preserved(): + x = np.array([[0, -np.inf, 0], [-np.inf, 0, -np.inf]], "float32") + actual = execute(O.SOFTMAX, [x], fp32_mode="reference") + np.testing.assert_array_equal(actual, [[0.5, 0, 0.5], [0, 1, 0]]) + + +@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf]) +def test_softmax_invalid_or_entirely_masked_rows_still_fail(bad): + x = np.full((2, 3), bad, "float32") + with pytest.raises(IRExecutionError): + execute(O.SOFTMAX, [x], fp32_mode="reference") + + +@pytest.mark.parametrize("axis", [3, -4]) +def test_softmax_invalid_axis_is_not_silently_reinterpreted(axis): + with pytest.raises(IRExecutionError, match="ShapeError"): + execute(O.SOFTMAX, [np.ones((2, 3, 4), "float32")], + {"axis": axis}, fp32_mode="reference") + + +@pytest.mark.parametrize("shape,axis", [ + ((0, 7), 1), ((5, 0), 0), ((3, 0, 129), 2), ((3, 129, 0), 1), +]) +def test_softmax_preserves_empty_dimensions_outside_normalization_axis(shape, axis): + actual = execute(O.SOFTMAX, [np.empty(shape, dtype="float32")], + {"axis": axis}, fp32_mode="reference") + assert actual.shape == shape and actual.dtype == np.float32 + + +@pytest.mark.parametrize("left,right", [ + ((3,), (3,)), ((3,), (3, 4)), ((2, 3), (3,)), + ((2, 1, 3, 4), (1, 5, 4, 2)), + ((3, 257), (257, 7)), ((2, 1025), (1025, 3)), + ((257,), (257,)), ((257,), (2, 257, 7)), ((2, 3, 257), (257,)), + ((2, 1, 3, 257), (1, 5, 257, 2)), +]) +def test_matmul_vector_batch_and_reduction_tail_semantics(left, right): + # Integer-valued inputs give an exact, independent integer arithmetic oracle. + rng = np.random.default_rng(1221) + a = rng.integers(-3, 4, left, dtype=np.int64) + b = rng.integers(-3, 4, right, dtype=np.int64) + expected = a @ b + actual = execute(O.MATMUL, [a.astype("float32"), b.astype("float32")], + fp32_mode="reference") + np.testing.assert_array_equal(actual, expected) + assert actual.shape == expected.shape and actual.dtype == np.float32 + + +@pytest.mark.parametrize("left,right,shape", [ + ((2, 0), (0, 3), (2, 3)), ((0,), (0,), ()), + ((2, 0), (0,), (2,)), ((0,), (0, 3), (3,)), + ((0, 4), (4, 3), (0, 3)), ((2, 4), (4, 0), (2, 0)), + ((0, 129), (129, 3), (0, 3)), ((2, 129), (129, 0), (2, 0)), + ((0, 2, 129), (1, 129, 3), (0, 2, 3)), +]) +def test_matmul_empty_dimensions_keep_numpy_shapes(left, right, shape): + actual = execute(O.MATMUL, [np.zeros(left, "float32"), + np.zeros(right, "float32")], + fp32_mode="reference") + np.testing.assert_array_equal(actual, np.zeros(shape, "float32")) + + +def test_matmul_legacy_flat_shapes_are_still_honored(): + a = np.arange(6, dtype="float32") + b = np.arange(12, dtype="float32") + result = execute(O.MATMUL, [a, b], {"m": 2, "n": 4, "k": 3}, + fp32_mode="reference") + np.testing.assert_array_equal(result, [[20, 23, 26, 29], [56, 68, 80, 92]]) + + +def test_matmul_consumes_an_ir_transpose_view_without_mutation(): + a = np.arange(18, dtype="float32").reshape(6, 3) + b = np.arange(24, dtype="float32").reshape(6, 4) + x, w = Value("x", shape=a.shape), Value("w", shape=b.shape) + builder = IRBuilder() + builder.new_function("main", [x, w]) + builder.new_block() + transposed = builder.transpose(x, (1, 0)) + builder.ret(builder.matmul(transposed, w)) + observed = [] + + def observe(name, value): + if name == transposed.name: + observed.append(value.flags.c_contiguous) + + actual = IRInterpreter(builder.program).run( + {"x": a, "w": b}, fp32_mode="reference", observer=observe).return_value + assert observed == [False] + np.testing.assert_array_equal(actual, a.astype(np.int64).T @ b.astype(np.int64)) + np.testing.assert_array_equal(a, np.arange(18).reshape(6, 3)) + np.testing.assert_array_equal(b, np.arange(24).reshape(6, 4)) + + +def test_matmul_random_fp32_remains_accurate_beyond_one_reduction_block(): + rng = np.random.default_rng(128) + a = rng.normal(size=(2, 3, 257)).astype("float32") + b = rng.normal(size=(257, 129)).astype("float32") + actual = execute(O.MATMUL, [a, b], fp32_mode="reference") + expected = a.astype(np.float64) @ b.astype(np.float64) + np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5) + + +@pytest.mark.parametrize("length", [1, 3, 7, 8, 9, 15, 17, 127, 129, 1025]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_last_axis_tails_and_keepdims(length, keepdims): + x = np.arange(2 * length, dtype="float32").reshape(2, length) / np.float32(8) + actual = execute(O.REDUCE_MEAN, [x], + {"axes": (-1,), "keepdims": keepdims}, fp32_mode="reference") + expected = np.mean(x.astype(np.float64), axis=-1, keepdims=keepdims) + assert actual.shape == expected.shape and actual.dtype == x.dtype + np.testing.assert_allclose(actual, expected, rtol=2e-7, atol=1e-7) + + +@pytest.mark.parametrize("axes", [None, (), (0,), (1,), (0, 2), (-1, 0)]) +@pytest.mark.parametrize("keepdims", [True, False]) +def test_mean_multiple_axes_and_empty_axes_contract(axes, keepdims): + x = np.arange(30, dtype="float32").reshape(2, 3, 5) + attrs = {"keepdims": keepdims} + if axes is not None: + attrs["axes"] = axes + expected = np.mean(x.astype(np.float64), axis=axes or None, keepdims=keepdims) + actual = execute(O.REDUCE_MEAN, [x], attrs, fp32_mode="reference") + np.testing.assert_allclose(actual, expected, rtol=2e-7, atol=1e-7) + assert actual.shape == expected.shape and actual.dtype == x.dtype + + +def test_mean_scalar_and_empty_dimension_semantics(): + scalar = np.array(7.25, "float32") + actual = execute(O.REDUCE_MEAN, [scalar], fp32_mode="reference") + assert actual.shape == () and actual.dtype == scalar.dtype and actual == scalar + actual = execute(O.REDUCE_MEAN, [np.empty((0, 3), "float32")], + {"axes": (-1,)}, fp32_mode="reference") + assert actual.shape == (0, 1) + with pytest.raises(IRExecutionError, match="NumericError"): + execute(O.REDUCE_MEAN, [np.empty((2, 0), "float32")], + {"axes": (-1,)}, fp32_mode="reference") + + +def test_sigmoid_remains_bounded_monotone_and_close_to_mathematics(): + maximum = np.finfo(np.float32).max + x = np.concatenate([np.array([-maximum], "float32"), + np.linspace(-30, 30, 1001, dtype="float32"), + np.array([maximum], "float32")]) + actual = execute(O.SIGMOID, [x], fp32_mode="reference") + exp = np.exp(-np.abs(x.astype(np.float64))) + expected = np.where(x >= 0, 1 / (1 + exp), exp / (1 + exp)) + assert np.isfinite(actual).all() and np.all((actual >= 0) & (actual <= 1)) + # Approximation roundoff can jitter saturated tails by a few ULP. + assert np.all(np.diff(actual) >= -np.float32(2e-7)) + np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-7) + assert execute(O.SIGMOID, [np.array(0, "float32")], fp32_mode="reference") == 0.5 + + +def test_sigmoid_saturated_roundoff_never_leaves_probability_range(): + # This neighborhood includes a rational-approximation result above one. + # Probe both signs and adjacent binary32 values against sigmoid itself. + center = np.float32(17.868923).view(np.uint32) + positive = np.arange(center - 16, center + 17, dtype=np.uint32).view(np.float32) + x = np.concatenate((-positive[::-1], positive)) + actual = execute(O.SIGMOID, [x], fp32_mode="reference") + expected = 1 / (1 + np.exp(-x.astype(np.float64))) + assert np.all((actual >= 0) & (actual <= 1)) + np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-7) + + +@pytest.mark.parametrize("opcode", [O.MATMUL, O.REDUCE_MEAN, O.SOFTMAX, + O.SIGMOID, O.SIN, O.COS]) +def test_float64_results_are_exactly_the_existing_native_results(opcode): + x = np.linspace(-2, 2, 15, dtype="float64").reshape(3, 5) + arrays = [x, x.T.copy()] if opcode is O.MATMUL else [x] + expected = execute(opcode, arrays) + result = execute(opcode, arrays, fp32_mode="reference") + np.testing.assert_array_equal(result, expected) + assert result.dtype == np.float64 + + +@pytest.mark.parametrize("dtype", ["int32", "int64"]) +def test_integer_matmul_preserves_native_wrap_and_dtype(dtype): + x = np.array([[np.iinfo(dtype).max, 2], [3, -4]], dtype) + y = np.array([[2, -1], [4, 7]], dtype) + expected = execute(O.MATMUL, [x, y]) + actual = execute(O.MATMUL, [x, y], fp32_mode="reference") + np.testing.assert_array_equal(actual, expected) + assert actual.dtype == x.dtype + + +@pytest.mark.parametrize("opcode", [O.SIGMOID, O.REDUCE_MEAN, O.SOFTMAX, O.SIN, O.COS]) +def test_profile_does_not_enable_unsupported_integer_float_operations(opcode): + with pytest.raises(IRExecutionError) as native: + execute(opcode, [np.array([1, 2], "int64")]) + with pytest.raises(IRExecutionError) as reference: + execute(opcode, [np.array([1, 2], "int64")], fp32_mode="reference") + assert reference.value.code == native.value.code + + +@pytest.mark.parametrize("opcode", [O.MATMUL, O.REDUCE_MEAN, O.SOFTMAX, + O.SIGMOID, O.SIN, O.COS]) +def test_reference_profile_never_mutates_registry_or_another_run(opcode): + from scratchv.verification import ir_numpy_ops + + x = np.random.default_rng(48).normal(size=(2, 257)).astype("float32") + arrays = [x, x.T.copy()] if opcode is O.MATMUL else [x] + program, inputs = operation(opcode, arrays) + interpreter = IRInterpreter(program) + kernels = dict(ir_numpy_ops.KERNELS) + original_matmul = np.matmul + baseline = interpreter.run(inputs).return_value + explicit = interpreter.run(inputs, fp32_mode="native").return_value + np.testing.assert_array_equal(explicit, baseline) + observations = [] + + def observe(_name, _value): + assert ir_numpy_ops.KERNELS == kernels + assert np.matmul is original_matmul + nested = interpreter.run(inputs).return_value + np.testing.assert_array_equal(nested, baseline) + observations.append(True) + + interpreter.run(inputs, fp32_mode="reference", observer=observe) + assert observations == [True] + np.testing.assert_array_equal(interpreter.run(inputs).return_value, baseline) + assert ir_numpy_ops.KERNELS == kernels + + +def test_reference_profile_imports_and_executes_without_onnxruntime(): + # A clean interpreter catches eager imports as well as hidden runtime calls. + script = ''' +import sys +class BlockORT: + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] == "onnxruntime": + raise AssertionError("Reference IR must not import ONNX Runtime") +sys.meta_path.insert(0, BlockORT()) +import numpy as np +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.verification.ir_interpreter import IRInterpreter +x, w = Value("x", shape=(2,3)), Value("w", shape=(3,3)) +b = IRBuilder(); b.new_function("main", [x,w]); b.new_block() +v = b.matmul(x,w); v = b.sin(v); v = b.cos(v); v = b.sigmoid(v) +v = b.softmax(v, axis=1); v = b.reduce_mean(v, axes=(1,), keepdims=False); b.ret(v) +out = IRInterpreter(b.program).run( + {"x":np.arange(6,dtype="float32").reshape(2,3),"w":np.eye(3,dtype="float32")}, + fp32_mode="reference").return_value +np.testing.assert_allclose(out, [1/3,1/3], rtol=3e-7) +assert out.dtype == np.float32 +assert not any(name.split(".")[0] == "onnxruntime" for name in sys.modules) +''' + completed = subprocess.run([sys.executable, "-B", "-c", script], + cwd=Path(__file__).resolve().parents[1], + capture_output=True, text=True, timeout=30) + assert completed.returncode == 0, completed.stdout + completed.stderr + + +@pytest.mark.parametrize("avx512,strategy,lanes", [ + (False, "avx2-fma3", 8), (True, "avx512", 16), +]) +def test_auto_profile_records_the_resolved_cpu_arithmetic(monkeypatch, avx512, strategy, lanes): + from scratchv.verification import fp32_reference as reference + + monkeypatch.delenv("SCRATCHV_FP32_REFERENCE_CPU", raising=False) + monkeypatch.delenv("NPY_DISABLE_CPU_FEATURES", raising=False) + monkeypatch.setattr(reference, "_cpu_features", lambda: { + "AVX2": True, "FMA3": True, "AVX512F": avx512}) + actual = reference.profile() + assert actual["name"] == "numpy-fp32-reference-v4" + assert actual["matmul_row_tile"] == (2 if lanes == 8 else None) + assert actual["matmul_row_tail"] == ("zero-pad-to-two-then-trim" if lanes == 8 else "native") + assert actual["cpu_strategy"] == strategy and actual["softmax_lanes"] == lanes + assert actual == reference.profile(cpu_strategy=strategy) + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + assert reference.profile() == actual + + +@pytest.mark.parametrize("disabled", ["AVX2", "FMA3", "AVX512F", "AVX512_SKX", "AVX2, FMA3"]) +def test_auto_policy_rejects_cpu_features_hidden_only_from_numpy(monkeypatch, disabled): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + monkeypatch.setenv("NPY_DISABLE_CPU_FEATURES", disabled) + monkeypatch.setattr(reference, "_cpu_features", lambda: {"AVX2": True, "FMA3": True}) + with pytest.raises(ValueError, match="obscures MLAS"): + reference.profile() + # The explicit contract is reproducible on other CPUs and independent of + # NumPy's feature controls; it makes no claim to reconfigure ORT. + assert reference.profile(cpu_strategy="avx512")["softmax_lanes"] == 16 + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx2-fma3") + assert reference.profile()["softmax_lanes"] == 8 + + +@pytest.mark.parametrize("features", [{}, {"AVX2": True}, {"FMA3": True}, {"AVX512F": True}]) +def test_unknown_hardware_requires_an_explicit_policy(monkeypatch, features): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + monkeypatch.delenv("NPY_DISABLE_CPU_FEATURES", raising=False) + monkeypatch.setattr(reference, "_cpu_features", lambda: features) + with pytest.raises(ValueError, match="Cannot infer"): + reference.profile() + assert reference.profile(cpu_strategy="avx2-fma3")["softmax_lanes"] == 8 + + +@pytest.mark.parametrize("bad", ["", "AVX512", "sse", "automatic"]) +def test_invalid_cpu_policy_cannot_silently_select_another_kernel(monkeypatch, bad): + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", bad) + with pytest.raises(ValueError, match="SCRATCHV_FP32_REFERENCE_CPU"): + reference.profile() + with pytest.raises(IRExecutionError, match="SCRATCHV_FP32_REFERENCE_CPU"): + execute(O.SOFTMAX, [np.array([1, 2], "float32")], fp32_mode="reference") + # The pre-existing native interpreter never consults reference policy. + np.testing.assert_allclose(execute(O.SOFTMAX, [np.array([1, 2], "float32")]), + [0.26894142, 0.73105858], rtol=2e-7) + + +@pytest.mark.parametrize("strategy", ["avx2-fma3", "avx512"]) +@pytest.mark.parametrize("width", [1, 7, 8, 9, 15, 16, 17, 23, 24, 47, 48, 255, 256]) +def test_both_cpu_softmax_policies_preserve_shapes_masks_and_accuracy(monkeypatch, strategy, width): + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + rng = np.random.default_rng(7600 + width) + x = rng.normal(size=(2, width, 3)).astype(np.float32) + if width > 1: + x[:, -1, :] = -np.inf + original = x.copy() + x.setflags(write=False) + actual = execute(O.SOFTMAX, [x], {"axis": 1}, fp32_mode="reference") + wide = x.astype(np.float64) + weights = np.exp(wide - np.max(wide, axis=1, keepdims=True)) + expected = weights / np.sum(weights, axis=1, keepdims=True) + np.testing.assert_allclose(actual, expected, rtol=4e-7, atol=3e-8) + np.testing.assert_array_equal(x, original) + assert actual.dtype == np.float32 and actual.shape == x.shape + if width > 1: + np.testing.assert_array_equal(actual[:, -1, :], 0) + + +def test_fma3_exponent_scaling_preserves_its_distinct_underflow_contract(monkeypatch): + # AVX2 MLAS packs m into binary32 exponent bits: m=-127 is zero. + # AVX512 VSCALEFPS retains representable subnormal results instead. + # This catches replacing the FMA3 scaling step with the AVX512 ldexp. + x = np.array([[0, -88, -90, -100, -104, -np.inf]], np.float32) + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx2-fma3") + avx2 = execute(O.SOFTMAX, [x], fp32_mode="reference") + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "avx512") + avx512 = execute(O.SOFTMAX, [x], fp32_mode="reference") + np.testing.assert_array_equal(avx2, [[1, 0, 0, 0, 0, 0]]) + assert np.all(avx512[0, 1:4] > 0) + assert np.all(avx512[0, 1:4] < np.finfo(np.float32).tiny) + np.testing.assert_array_equal(avx512[0, 4:], 0) + + +def test_host_softmax_reference_matches_ort1221_random_tail_and_mask_kernels(monkeypatch): + # The CPU matrix matters: CI's EPYC exercises FMA3, the Windows AVX512 host + # exercises VSCALEFPS. ORT is only the independent test oracle. + ort = pytest.importorskip("onnxruntime") + if ort.__version__ != "1.22.1": + pytest.skip("The explicit arithmetic contract targets ORT 1.22.1") + from onnx import helper, TensorProto + from scratchv.verification import fp32_reference as reference + + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", "auto") + # Deliberately retain NPY_DISABLE_CPU_FEATURES: if present, automatic + # inference must fail rather than claim an unverified ORT dispatch. + reference.profile() + model = helper.make_model(helper.make_graph( + [helper.make_node("Softmax", ["x"], ["y"], axis=-1)], "softmax_cpu_arithmetic", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, None)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, None)]), + opset_imports=[helper.make_opsetid("", 17)]) + model.ir_version = 10 + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + session = ort.InferenceSession(model.SerializeToString(), sess_options=options, + providers=["CPUExecutionProvider"]) + rng = np.random.default_rng(20261005) + for width in (1, 3, 7, 8, 9, 15, 16, 17, 23, 24, 31, 32, 47, 48, 127, 128, 255, 256): + x = (rng.normal(size=(32, width)) * 8).astype(np.float32) + x[1::2, width // 2 + 1:] = np.finfo(np.float32).min + actual = execute(O.SOFTMAX, [x], fp32_mode="reference") + expected = session.run(None, {"x": x})[0] + np.testing.assert_array_equal(actual, expected, err_msg=f"host kernel width={width}") + + +@pytest.mark.parametrize("strategy", ["avx2-fma3", "avx512"]) +@pytest.mark.parametrize("rows,cols,reduction", [ + (1, 1, 1), (1, 7, 127), (3, 9, 128), (5, 17, 129), (17, 31, 257), +]) +def test_fixed_row_matmul_preserves_broadcast_strides_and_odd_tail(monkeypatch, strategy, rows, cols, reduction): + monkeypatch.setenv("SCRATCHV_FP32_REFERENCE_CPU", strategy) + rng = np.random.default_rng(7000 + rows) + a = rng.uniform(-1, 1, (2, 1, reduction, rows)).astype(np.float32).swapaxes(-1, -2) + b = rng.uniform(-1, 1, (1, 3, cols, reduction)).astype(np.float32).swapaxes(-1, -2) + a, b = a[..., ::-1], b[..., ::-1, :] + left, right = a.copy(), b.copy() + a.setflags(write=False) + b.setflags(write=False) + actual = execute(O.MATMUL, [a, b], fp32_mode="reference") + expected = left.astype(np.float64) @ right.astype(np.float64) + np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5) + np.testing.assert_array_equal(a, left) + np.testing.assert_array_equal(b, right) + assert actual.shape == (2, 3, rows, cols) and actual.dtype == np.float32 + + +def test_haswell_matmul_regression_executes_the_real_blas_kernel_in_a_child(): + # Force only this child's NumPy OpenBLAS to Haswell, even on AVX512 CI. + # This catches a CPU-specific accumulation-order regression that an + # AVX512-only ORT/NumPy comparison cannot see. No real gate is reconfigured. + from scratchv.verification import fp32_reference as reference + if np.__version__ != "2.2.6": + pytest.skip("The Haswell dispatch regression targets the pinned NumPy 2.2.6 wheel") + features = reference._cpu_features() + if not (features.get("AVX2") and features.get("FMA3")): + pytest.skip("Haswell BLAS regression requires usable AVX2/FMA3") + ort = pytest.importorskip("onnxruntime") + if ort.__version__ != "1.22.1": + pytest.skip("Exact arithmetic oracle is pinned to ORT 1.22.1") + libraries = list((Path(np.__file__).parent.parent / "numpy.libs").glob("*openblas*")) + if not libraries: + pytest.skip("Haswell regression requires the NumPy OpenBLAS wheel") + script = r''' +import ctypes, json +from pathlib import Path +import numpy as np +import onnxruntime as ort +from onnx import helper, TensorProto +from scratchv.verification.fp32_reference import matmul +libraries = list((Path(np.__file__).parent.parent / "numpy.libs").glob("*openblas*")) +assert len(libraries) == 1, libraries +library = ctypes.CDLL(str(libraries[0])) +get_core = library.scipy_openblas_get_corename64_ +get_core.restype = ctypes.c_char_p +assert get_core().decode().lower() == "haswell", get_core() +model = helper.make_model(helper.make_graph( + [helper.make_node("MatMul", ["a", "b"], ["y"])], "haswell_matmul_regression", + [helper.make_tensor_value_info(name, TensorProto.FLOAT, None) for name in ("a", "b")], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, None)]), + opset_imports=[helper.make_opsetid("", 17)]) +model.ir_version = 10 +options = ort.SessionOptions() +options.intra_op_num_threads = options.inter_op_num_threads = 1 +options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL +session = ort.InferenceSession(model.SerializeToString(), sess_options=options, + providers=["CPUExecutionProvider"]) +rng = np.random.default_rng(373) +old_mismatch = 0 +for shape in ((256, 128, 256), (17, 128, 256), (1, 128, 256), (17, 1024, 256)): + m, k, n = shape + a = rng.normal(size=(m, k)).astype(np.float32) + b = rng.normal(size=(k, n)).astype(np.float32) + expected = session.run(None, {"a": a, "b": b})[0] + actual = matmul([a, b], {}, np.dtype("float32")) + if m == 1: + # ORT itself selects GEMV for an entire one-row operation. Padding + # retains our matrix contract; its mathematical accuracy still holds. + truth = a.astype(np.float64) @ b.astype(np.float64) + np.testing.assert_allclose(actual, truth, rtol=2e-5, atol=2e-5) + else: + np.testing.assert_array_equal(actual, expected, err_msg=str(shape)) + if shape == (256, 128, 256): + old_mismatch = int(np.count_nonzero(np.matmul(a, b) != expected)) +assert old_mismatch > 0, "Fixture must expose the original Haswell accumulation bug" +print(json.dumps({"core": get_core().decode(), "old_mismatch": old_mismatch, "cases": 4})) +''' + environment = dict(os.environ, SCRATCHV_FP32_REFERENCE_CPU="avx2-fma3", + OPENBLAS_CORETYPE="Haswell", OPENBLAS_NUM_THREADS="1", + OMP_NUM_THREADS="1", MKL_NUM_THREADS="1") + completed = subprocess.run([sys.executable, "-B", "-c", script], + cwd=Path(__file__).resolve().parents[1], env=environment, + capture_output=True, text=True, timeout=60) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert '"core": "Haswell"' in completed.stdout diff --git a/tests/test_ir_interpreter_memory.py b/tests/test_ir_interpreter_memory.py new file mode 100644 index 0000000..7739656 --- /dev/null +++ b/tests/test_ir_interpreter_memory.py @@ -0,0 +1,132 @@ +"""Optional memory observations preserve execution and count view owners once.""" + +import numpy as np +import pytest + +from benchmarks.ir_interpreter_cases import CASE_FACTORIES, builder, make_case +from scratchv.ir.types import DataType as D, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def test_default_has_no_stats_and_does_not_observe(monkeypatch): + import scratchv.verification.ir_interpreter as module + + def unexpected(*args, **kwargs): + raise AssertionError("default execution must not collect memory") + + monkeypatch.setattr(module, "_MemoryObservation", unexpected) + b = builder() + b.ret(b.make_const(2.0)) + assert IRInterpreter(b.program).run({}).memory_stats is None + + +def test_views_count_logical_bindings_but_not_repeated_storage(): + x = Value("x", shape=(2, 3)) + b = builder(x) + reshaped = b.reshape(x, (3, 2)) + transposed = b.transpose(reshaped, (1, 0)) + b.ret(transposed) + source = np.arange(6, dtype="float32").reshape(2, 3) + interpreter = IRInterpreter(b.program) + result = interpreter.run({"x": source}, collect_memory_stats=True) + stats = result.memory_stats + assert stats["input_logical_bytes"] == 24 + assert stats["initializer_logical_bytes"] == 0 + assert stats["retained_values"] == 3 + assert stats["retained_logical_bytes"] == 72 + assert stats["retained_numpy_storage_bytes"] == 24 + assert stats["return_logical_bytes"] == 24 + assert stats["peak_live_logical_bytes"] == 96 + assert stats["peak_numpy_storage_bytes"] == 48 + assert not np.shares_memory(result.return_value, source) + result.return_value[:] = 99 + np.testing.assert_array_equal(source, np.arange(6).reshape(2, 3)) + second = interpreter.run({"x": source}, collect_memory_stats=True) + np.testing.assert_array_equal(second.return_value, source.reshape(3, 2).T) + assert second.memory_stats == stats + + +def test_initializer_bytes_and_independent_initializer_copy(): + x, weight = Value("x", shape=(2,)), Value("weight", shape=(2,)) + b = builder(x) + b.program.global_values.append(weight) + b.ret(weight) + # Even aliases supplied by the caller keep the pre-existing separate copies. + source = np.array([1, 2], dtype="float32") + result = IRInterpreter(b.program).run({"x": source}, initializers={"weight": source}, + collect_memory_stats=True) + assert result.memory_stats["input_logical_bytes"] == 8 + assert result.memory_stats["initializer_logical_bytes"] == 8 + assert result.memory_stats["retained_numpy_storage_bytes"] == 16 + assert result.memory_stats["peak_numpy_storage_bytes"] == 24 + result.return_value[0] = 123 + assert source[0] == 1 + + +@pytest.mark.parametrize("name", list(CASE_FACTORIES)) +def test_observer_preserves_all_direct_cases(name): + case = make_case(name) + interpreter = IRInterpreter(case.program) + baseline = interpreter.run(case.inputs, initializers=case.initializers) + observed = interpreter.run(case.inputs, initializers=case.initializers, collect_memory_stats=True) + np.testing.assert_array_equal(observed.return_value, baseline.return_value) + assert observed.executed_steps == baseline.executed_steps + assert observed.diagnostics == baseline.diagnostics + assert observed.memory_stats["retained_values"] >= len(case.inputs) + assert observed.memory_stats["peak_numpy_storage_bytes"] >= observed.memory_stats["retained_numpy_storage_bytes"] + + +@pytest.mark.parametrize("flag", [0, 1]) +def test_only_executed_branch_contributes_memory(flag): + condition = Value("condition", D.INT32) + x = Value("x", shape=(2,)) + b = builder(condition, x) + b.br_if(condition, "left", "right") + b.new_block("left") + b.ret(b.neg(x)) + b.new_block("right") + b.ret(x) + result = IRInterpreter(b.program).run({"condition": np.array(flag, dtype="int32"), + "x": np.array([2, 3], dtype="float32")}, + collect_memory_stats=True) + assert result.memory_stats["retained_values"] == 2 + flag + assert result.memory_stats["retained_logical_bytes"] == 12 + 8 * flag + + +def test_loop_counts_retained_slots_and_overwrites_not_cumulative_allocations(): + def run(end): + b = builder() + slot = b.alloca(16, D.INT32) + b.store(slot, b.make_const(0, D.INT32)) + i = b.for_loop(0, end) + b.store(slot, b.add(b.load(slot), i)) + b.endfor() + b.ret(b.load(slot)) + return IRInterpreter(b.program).run({}, collect_memory_stats=True) + + short, long = run(3), run(30) + assert short.return_value == 3 + assert long.return_value == 435 + assert short.memory_stats == long.memory_stats + assert short.memory_stats["retained_logical_bytes"] >= 16 + assert long.executed_steps > short.executed_steps + + +def test_void_and_empty_arrays_are_counted(): + x = Value("x", shape=(0,)) + b = builder(x) + b.ret() + result = IRInterpreter(b.program).run({"x": np.empty(0, dtype="float32")}, + collect_memory_stats=True) + assert result.return_value is None + assert result.memory_stats["retained_values"] == 1 + assert result.memory_stats["peak_live_logical_bytes"] == 0 + assert result.memory_stats["peak_numpy_storage_bytes"] == 0 + + +@pytest.mark.parametrize("bad", [0, 1, None, "yes"]) +def test_invalid_observation_options(bad): + b = builder() + b.ret() + with pytest.raises(IRExecutionError, match="collect_memory_stats must be boolean"): + IRInterpreter(b.program).run({}, collect_memory_stats=bad) diff --git a/tests/test_ir_interpreter_memory_scaling.py b/tests/test_ir_interpreter_memory_scaling.py new file mode 100644 index 0000000..c026fe5 --- /dev/null +++ b/tests/test_ir_interpreter_memory_scaling.py @@ -0,0 +1,423 @@ +"""Opt-in weight borrowing / last-use execution never weaken default or D8.""" + +import weakref + +import numpy as np +import pytest + +from benchmarks.ir_interpreter_cases import builder +from scratchv.frontend.onnx_parser import ONNXParseError, ONNXParser +from scratchv.ir.types import DataType as D, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def test_last_use_releases_chain_without_changing_result_or_steps(): + x = Value("x", shape=(1024,)) + b = builder(x) + current = x + for _ in range(20): + current = b.neg(current) + b.ret(current) + inputs = {"x": np.arange(1024, dtype=np.float32)} + interp = IRInterpreter(b.program) + old = interp.run(inputs, collect_memory_stats=True) + new = interp.run(inputs, memory_mode="last_use", collect_memory_stats=True) + np.testing.assert_array_equal(old.return_value, new.return_value) + assert old.executed_steps == new.executed_steps + assert old.diagnostics == new.diagnostics + assert old.memory_stats["retained_values"] == 21 + assert new.memory_stats["retained_values"] == 1 + assert new.memory_stats["reclaimed_bindings"] == 20 + assert new.memory_stats["peak_numpy_storage_bytes"] == 2 * inputs["x"].nbytes + assert old.memory_stats["peak_numpy_storage_bytes"] == 22 * inputs["x"].nbytes + assert not np.shares_memory(new.return_value, inputs["x"]) + + +def test_repeated_operand_residual_and_alias_views_survive_last_use(): + x = Value("x", shape=(2, 3)) + b = builder(x) + doubled = b.add(x, x) + view = b.transpose(b.reshape(doubled, (3, 2)), (1, 0)) + result = b.add(view, x) + b.ret(result) + source = np.arange(6, dtype="float32").reshape(2, 3) + interp = IRInterpreter(b.program) + old = interp.run({"x": source}) + new = interp.run({"x": source}, memory_mode="last_use", collect_memory_stats=True) + np.testing.assert_array_equal(old.return_value, new.return_value) + assert new.memory_stats["retained_values"] == 1 + assert not np.shares_memory(new.return_value, source) + + +def test_borrowed_weights_are_shared_readonly_and_returns_are_copied(monkeypatch): + import scratchv.verification.ir_interpreter as module + + weight = Value("w", shape=(2, 2)) + b = builder() + b.program.global_values.append(weight) + b.ret(b.transpose(weight, (1, 0))) + source = np.arange(4, dtype="float32").reshape(2, 2) + source.setflags(write=False) + original_compute = module.compute + seen = [] + + def check(instr, operands): + seen.append((np.shares_memory(operands[0], source), operands[0].flags.writeable)) + return original_compute(instr, operands) + + monkeypatch.setattr(module, "compute", check) + result = IRInterpreter(b.program).run({}, initializers={"w": source}, + memory_mode="last_use", copy_initializers=False, collect_memory_stats=True) + assert seen == [(True, False)] + assert not np.shares_memory(result.return_value, source) + result.return_value[0, 0] = 99 + assert source[0, 0] == 0 and not source.flags.writeable + assert result.memory_stats["copy_initializers"] is False + # Default still owns a separate initializer copy. + IRInterpreter(b.program).run({}, initializers={"w": source}) + assert seen[-1] == (False, True) + + +def test_borrowing_rejects_writable_arrays_without_changing_flags(): + weight = Value("w", shape=(2,)) + b = builder() + b.program.global_values.append(weight) + b.ret(weight) + source = np.ones(2, dtype=np.float32) + with pytest.raises(IRExecutionError, match="borrowed initializer must be read-only"): + IRInterpreter(b.program).run({}, initializers={"w": source}, copy_initializers=False) + assert source.flags.writeable + + +@pytest.mark.parametrize("options", [{"memory_mode": "last_use"}, {"copy_initializers": False}]) +@pytest.mark.parametrize("kind", ["branch", "loop", "memory"]) +def test_optimized_modes_fail_closed_for_control_flow_and_memory(kind, options): + b = builder() + if kind == "branch": + b.br("next") + b.new_block("next") + elif kind == "loop": + b.for_loop(0, 2) + b.endfor() + else: + b.alloca(4, D.INT32) + b.ret() + assert IRInterpreter(b.program).run({}).return_value is None + with pytest.raises(IRExecutionError, match="memory optimization requires"): + IRInterpreter(b.program).run({}, **options) + + +def test_observer_runs_before_release_is_readonly_and_does_not_pin_values(): + x = Value("x", shape=(32,)) + b = builder(x) + dead = b.neg(x) + y = b.add(x, x) + z = b.neg(y) + b.ret(z) + seen, refs = [], [] + + def observe(name, array): + assert not array.flags.writeable + assert array.base is not None + seen.append(name) + refs.append(weakref.ref(array.base)) + if name == y.name: + assert refs[0]() is None # Unused dead result was really released. + + result = IRInterpreter(b.program).run({"x": np.ones(32, dtype="float32")}, + memory_mode="last_use", observer=observe) + assert seen == [dead.name, y.name, z.name] + assert all(reference() is None for reference in refs) + np.testing.assert_array_equal(result.return_value, np.full(32, -2, dtype="float32")) + + +def test_observer_failure_has_ir_location(): + b = builder() + output = b.neg(b.make_const(1.0)) + b.ret(output) + + def broken(name, array): + raise OSError("checkpoint disk full") + + with pytest.raises(IRExecutionError, match="ObserverError.*checkpoint disk full") as caught: + IRInterpreter(b.program).run({}, memory_mode="last_use", observer=broken) + assert caught.value.value_name == output.name + assert caught.value.instruction_index == 0 + + +def test_unused_operation_still_raises_numeric_error(): + b = builder() + b.div(b.make_const(1.0), b.make_const(0.0)) + b.ret(b.make_const(7.0)) + for mode in ("retain_all", "last_use"): + with pytest.raises(IRExecutionError, match="NumericError.*division by zero") as caught: + IRInterpreter(b.program).run({}, memory_mode=mode) + assert caught.value.instruction_index == 0 + + +@pytest.mark.parametrize("options", [ + {"copy_initializers": 1}, {"memory_mode": None}, {"memory_mode": []}, + {"memory_mode": "automatic"}, {"observer": 1}, +]) +def test_invalid_options_are_rejected(options): + b = builder() + b.ret() + with pytest.raises(IRExecutionError, match="InvalidOptions"): + IRInterpreter(b.program).run({}, **options) + + +def external_model(tmp_path, *, fields=None, shape=(2, 3), trailing=0): + onnx = pytest.importorskip("onnx") + from onnx import TensorProto, helper + + data = np.arange(6, dtype="float32") + (tmp_path / "weights.bin").write_bytes(b"HEAD" + data.tobytes() + b"T" * trailing) + tensor = TensorProto(name="weight", data_type=TensorProto.FLOAT, dims=shape, + data_location=TensorProto.EXTERNAL) + for key, value in fields if fields is not None else [ + ("location", "weights.bin"), ("offset", "4"), ("length", "24") + ]: + entry = tensor.external_data.add() + entry.key, entry.value = key, value + graph = helper.make_graph([helper.make_node("Neg", ["weight"], ["output"])], + "external", [], [helper.make_tensor_value_info("output", TensorProto.FLOAT, shape)], + initializer=[tensor]) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + model.ir_version = 10 + path = tmp_path / "model.onnx" + path.write_bytes(model.SerializeToString()) + return path, data.reshape(shape) + + +def test_external_mapping_matches_default_and_borrowed_execution(tmp_path): + path, data = external_model(tmp_path) + normal, mapped = ONNXParser(), ONNXParser() + normal.parse(str(path)) + program = mapped.parse(str(path), mmap_external_data=True) + weight = mapped.initializers["weight"] + assert isinstance(weight, np.memmap) and weight.mode == "r" + assert not weight.flags.writeable + np.testing.assert_array_equal(weight, normal.initializers["weight"]) + result = IRInterpreter(program).run({}, initializers=mapped.initializers, + copy_initializers=False, memory_mode="last_use") + np.testing.assert_array_equal(result.return_value, -data) + assert not np.shares_memory(result.return_value, weight) + with pytest.raises(ValueError): + weight[0, 0] = 999 + + +def test_external_mapping_does_not_require_python39_path_api(tmp_path, monkeypatch): + from pathlib import Path + + import scratchv.frontend.onnx_parser as parser_module + + class Python38Path(type(Path())): + @property + def is_relative_to(self): + raise AttributeError("is_relative_to is unavailable on Python 3.8") + + def relative_to(self, *other): + # Newer pathlib implementations internally call is_relative_to; + # delegate through an ordinary Path while hiding that newer API + # from the parser under test, rather than patching global pathlib. + return Path(str(self)).relative_to(*other) + + path, expected = external_model(tmp_path) + parser = ONNXParser() + monkeypatch.setattr(parser_module, "Path", Python38Path) + assert not hasattr(Python38Path(), "is_relative_to") + parser.parse(str(path), mmap_external_data=True) + weight = parser.initializers["weight"] + assert isinstance(weight, np.memmap) and not weight.flags.writeable + np.testing.assert_array_equal(weight, expected) + + +@pytest.mark.parametrize("fields, message", [ + ([("location", "../weights.bin")], "model directory"), + ([("location", "C:\\weights.bin")], "model directory"), + ([("location", "weights.bin"), ("location", "weights.bin")], "Duplicate external"), + ([("location", "weights.bin"), ("offset", "-1")], "nonnegative integer"), + ([("location", "weights.bin"), ("length", "20")], "disagrees"), + ([("location", "weights.bin"), ("offset", "5")], "exceeds file"), + ([("location", "missing.bin")], "exceeds file"), + ([("location", "weights.bin"), ("length", "24"), ("unknown", "x")], "Unsupported external"), +]) +def test_external_mapping_rejects_unsafe_or_mismatched_metadata(tmp_path, fields, message): + path, _ = external_model(tmp_path, fields=fields) + with pytest.raises(ONNXParseError, match=message): + ONNXParser().parse(str(path), mmap_external_data=True) + + +def test_default_external_loader_option_is_strict(tmp_path): + path, _ = external_model(tmp_path) + with pytest.raises(ONNXParseError, match="mmap_external_data must be boolean"): + ONNXParser().parse(str(path), mmap_external_data=1) + + +def test_external_constant_node_uses_same_readonly_mapping(tmp_path): + import onnx + from onnx import helper + + path, expected = external_model(tmp_path) + model = onnx.load(str(path), load_external_data=False) + tensor = model.graph.initializer[0] + node = helper.make_node("Constant", [], ["weight"], value=tensor) + model.graph.node.insert(0, node) + del model.graph.initializer[:] + path.write_bytes(model.SerializeToString()) + parser = ONNXParser() + parser.parse(str(path), mmap_external_data=True) + assert isinstance(parser.initializers["weight"], np.memmap) + assert not parser.initializers["weight"].flags.writeable + np.testing.assert_array_equal(parser.initializers["weight"], expected) + + +def test_mapper_does_not_materialize_proto_or_read_outside_declared_range(tmp_path, monkeypatch): + import onnx + + path, expected = external_model(tmp_path, trailing=64) + tensor = onnx.load(str(path), load_external_data=False).graph.initializer[0] + parser = ONNXParser() + parser._base_dir = str(tmp_path) + + def forbidden(*args, **kwargs): + raise AssertionError("mmap loader must not materialize external protobuf data") + + monkeypatch.setattr(onnx.external_data_helper, "load_external_data_for_tensor", forbidden) + array = parser._map_external_initializer(tensor) + assert not tensor.HasField("raw_data") + assert array.nbytes == 24 and array.offset == 4 + np.testing.assert_array_equal(array, expected) + + +def test_parser_mapping_option_resets_between_calls(tmp_path): + path, _ = external_model(tmp_path) + parser = ONNXParser() + parser.parse(str(path), mmap_external_data=True) + assert isinstance(parser.initializers["weight"], np.memmap) + parser.parse(str(path)) + assert not isinstance(parser.initializers["weight"], np.memmap) + + +@pytest.mark.parametrize("dtype", ["float32", "float64", "int32", "int64"]) +@pytest.mark.parametrize("shape", [(), (0,), (2, 3)]) +def test_mapping_all_supported_numeric_types_scalar_and_empty_shapes(tmp_path, dtype, shape): + from onnx import TensorProto, helper + + values = np.arange(np.prod(shape, dtype=int), dtype=dtype).reshape(shape) + (tmp_path / "weights.bin").write_bytes(b"HEAD" + values.tobytes()) + tensor_dtype = helper.np_dtype_to_tensor_dtype(values.dtype) + tensor = TensorProto(name="weight", data_type=tensor_dtype, dims=shape, + data_location=TensorProto.EXTERNAL) + for key, value in [("location", "weights.bin"), ("offset", "4"), + ("length", str(values.nbytes))]: + entry = tensor.external_data.add() + entry.key, entry.value = key, value + graph = helper.make_graph([helper.make_node("Neg", ["weight"], ["output"])], + "external_types", [], [helper.make_tensor_value_info("output", tensor_dtype, shape)], + initializer=[tensor]) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) + model.ir_version = 10 + path = tmp_path / "model.onnx" + path.write_bytes(model.SerializeToString()) + parser = ONNXParser() + program = parser.parse(str(path), mmap_external_data=True) + assert not parser.initializers["weight"].flags.writeable + result = IRInterpreter(program).run({}, initializers=parser.initializers, + memory_mode="last_use", copy_initializers=False) + assert result.return_value.dtype == values.dtype + assert result.return_value.shape == shape + np.testing.assert_array_equal(result.return_value, -values) + + +def test_memmap_backing_accounting_includes_mapping_alignment_and_deduplicates_views(tmp_path): + import mmap + + from scratchv.verification.ir_interpreter import _MemoryObservation, _backing_storage + + offset = mmap.ALLOCATIONGRANULARITY + 4 + path = tmp_path / "aligned.bin" + path.write_bytes(b"x" * offset + np.arange(6, dtype="float32").tobytes()) + mapped = np.memmap(path, mode="r", dtype="float32", offset=offset, shape=(2, 3)) + alias = mapped.T + owner, mapped_bytes = _backing_storage(mapped) + assert owner == _backing_storage(alias)[0] + assert mapped_bytes == mapped.nbytes + offset % mmap.ALLOCATIONGRANULARITY + logical, storage = _MemoryObservation.snapshot({"mapped": mapped, "alias": alias}) + assert logical == 2 * mapped.nbytes + assert storage == mapped_bytes # Mapping span, deliberately not physical RSS. + second = np.memmap(path, mode="r", dtype="float32", offset=offset, shape=(2, 3)) + assert _backing_storage(second)[0] != owner + assert _MemoryObservation.snapshot({"first": mapped, "second": second})[1] == 2 * mapped_bytes + + +def test_executed_dead_nonfinite_constant_is_not_skipped(): + b = builder() + b.load_const(float("nan")) + b.ret(b.make_const(7.0)) + for mode in ("retain_all", "last_use"): + with pytest.raises(IRExecutionError, match="NumericError.*nonfinite"): + IRInterpreter(b.program).run({}, memory_mode=mode) + + +def test_unreferenced_nan_initializer_keeps_original_binding_scope(): + unused = Value("unused", shape=(2,)) + b = builder() + b.program.global_values.append(unused) + b.ret(b.make_const(7.0)) + values = np.array([np.nan, np.nan], dtype="float32") + values.setflags(write=False) + # Neither mode validates/evaluates global tensors never referenced by this + # function; dead *instructions* remain evaluated, as tested separately. + for mode in ("retain_all", "last_use"): + result = IRInterpreter(b.program).run({}, initializers={"unused": values}, + memory_mode=mode, copy_initializers=False) + assert result.return_value == 7 + + +def test_mapper_rejects_symlink_resolving_outside_model_directory(tmp_path, monkeypatch): + from pathlib import Path + + from onnx import TensorProto + + base = tmp_path / "model" + base.mkdir() + outside = tmp_path / "outside.bin" + outside.write_bytes(np.ones(1, dtype="float32").tobytes()) + link_path = base / "weights.bin" + original = Path.resolve + + def emulate_symlink(path, *args, **kwargs): + # Cross-platform exercise of the resolved-path boundary; creating real + # Windows symlinks can require a privilege unrelated to this contract. + if path == link_path: + return outside + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", emulate_symlink) + tensor = TensorProto(name="weight", data_type=TensorProto.FLOAT, dims=(1,), + data_location=TensorProto.EXTERNAL) + entry = tensor.external_data.add() + entry.key, entry.value = "location", "weights.bin" + parser = ONNXParser() + parser._base_dir = str(base) + with pytest.raises(ONNXParseError, match="escapes the model directory"): + parser._map_external_initializer(tensor) + + +def test_observer_accidental_write_fails_and_preserves_borrowed_weight(): + weight = Value("w", shape=(2, 2)) + b = builder() + b.program.global_values.append(weight) + b.ret(b.transpose(weight, (1, 0))) + source = np.arange(4, dtype="float32").reshape(2, 2) + expected = source.copy() + source.setflags(write=False) + + def observe(name, array): + array[0, 0] = 999 + + with pytest.raises(IRExecutionError, match="ObserverError.*read-only"): + IRInterpreter(b.program).run({}, initializers={"w": source}, + memory_mode="last_use", copy_initializers=False, observer=observe) + np.testing.assert_array_equal(source, expected) diff --git a/tests/test_ir_interpreter_ops.py b/tests/test_ir_interpreter_ops.py index cf8b764..999a605 100644 --- a/tests/test_ir_interpreter_ops.py +++ b/tests/test_ir_interpreter_ops.py @@ -20,7 +20,8 @@ def operation(op, arrays, attrs=None, result_dtype=None): "int32": D.INT32, "int64": D.INT64, } - params = [Value(f"x{i}", dtype_map[str(x.dtype)]) for i, x in enumerate(arrays)] + params = [Value(f"x{i}", dtype_map[str(x.dtype)], shape=x.shape) + for i, x in enumerate(arrays)] b = builder(*params) result = Value("result", result_dtype or params[0].dtype) b.current_block.add(Instruction(op, result, params, attrs or {})) @@ -289,7 +290,7 @@ def test_gather_static_types_and_builder_precision(): result_dtype=D.FLOAT64, ) assert not verify_ir(p)[0] - x = Value("x", D.FLOAT64) + x = Value("x", D.FLOAT64, shape=(2,)) b = builder(x) result = b.sqrt(b.reduce_mean(b.mul(x, x), (0,), False)) b.ret(result) diff --git a/tests/test_legacy_gemm_scales.py b/tests/test_legacy_gemm_scales.py new file mode 100644 index 0000000..1142cc5 --- /dev/null +++ b/tests/test_legacy_gemm_scales.py @@ -0,0 +1,27 @@ +"""Legacy scalar entry points must not discard preserved GEMM coefficients.""" + +import pytest + +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.llvm_codegen import LLVMCodegen +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value + + +@pytest.mark.parametrize("backend", ["riscv", "llvm"]) +@pytest.mark.parametrize("scales", [{"alpha": 2.0}, {"beta": 0.0}, {"alpha": -0.5, "beta": 3.0}]) +def test_direct_legacy_codegen_rejects_nondefault_gemm_scaling(backend, scales): + # The normal CompilerDriver already refuses tensor buffers for these + # backends. Exercise the public direct APIs too, without that protection. + a = Value("a", shape=(2, 3)) + b = Value("b", shape=(3, 2)) + bias = Value("bias", shape=(2,)) + builder = IRBuilder() + builder.new_function("main", [a, b, bias]) + builder.new_block("entry") + builder.ret(builder.gemm(a, b, bias, **scales)) + with pytest.raises(ValueError, match="GEMM.*alpha/beta"): + if backend == "riscv": + InstructionSelector(builder.program).run() + else: + LLVMCodegen(builder.program).emit() diff --git a/tests/test_licm_safety.py b/tests/test_licm_safety.py index 68fe4fb..0d04c82 100644 --- a/tests/test_licm_safety.py +++ b/tests/test_licm_safety.py @@ -199,9 +199,12 @@ def test_mean_checks_empty_dimensions_and_intermediate_sum_overflow(): assert not before_for(b, unbounded) and not before_for(b, empty_mean) -def test_unknown_shapes_and_runtime_indices_do_not_establish_proofs(): - x = Value("x", D.INT32) - y = Value("y", D.INT32) +@pytest.mark.parametrize("xshape,yshape", [((), ()), ((2,), (3,))]) +def test_unproven_shapes_and_runtime_indices_do_not_establish_proofs(xshape, yshape): + # HoistSafety conservatively treats () as unproven. Execution still binds + # a real scalar; the second case preserves the incompatible-vector hazard. + x = Value("x", D.INT32, shape=xshape) + y = Value("y", D.INT32, shape=yshape) data = Value("data", D.INT32, shape=(2,)) index = Value("index", D.INT64) b = builder(x, y, data, index) @@ -213,8 +216,8 @@ def test_unknown_shapes_and_runtime_indices_do_not_establish_proofs(): compare( b, { - "x": np.ones(2, dtype="int32"), - "y": np.ones(3, dtype="int32"), + "x": np.ones(xshape, dtype="int32"), + "y": np.ones(yshape, dtype="int32"), "data": np.ones(2, dtype="int32"), "index": np.array(5, dtype="int64"), }, diff --git a/tests/test_native_value_bindings.py b/tests/test_native_value_bindings.py new file mode 100644 index 0000000..9028d3e --- /dev/null +++ b/tests/test_native_value_bindings.py @@ -0,0 +1,96 @@ +"""Native scalar code must resolve named IR bindings before literal hints.""" + +import numpy as np +import pytest + +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.register_alloc import RegisterAllocator +from scratchv.backend.riscv_encoder import RISCVAEncoder +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Value +from scratchv.simulator.rv32_emulator import REG_ID, RV32Emulator +from scratchv.verification.ir_interpreter import IRInterpreter + + +def _execute_native(program, inputs=None): + """Encode actual RV32 instructions and supply scalar entry bindings.""" + allocator = RegisterAllocator(InstructionSelector(program).run(), mode="greedy") + assembly = AsmEmitter(allocator.run()).emit() + machine = RV32Emulator(mem_size=4 * 1024 * 1024) + machine.load_code(RISCVAEncoder().assemble(assembly)) + for name, value in (inputs or {}).items(): + # The scalar selector exposes virtual inputs; bind their allocated + # entry registers as the caller would, independently of literal hints. + if name in allocator.register_map: + machine.regs[REG_ID[allocator.register_map[name]]] = int(value) + assert machine.run(max_instr=128) < 128 + return machine.regs[REG_ID["a0"]] + + +@pytest.mark.parametrize("binding", ["parameter", "hinted_parameter", "load", "load_const", "arithmetic"]) +def test_named_binding_wins_over_conflicting_literal_hint(binding): + builder = IRBuilder() + inputs = {} + parameter = Value("x", DataType.INT32) + if binding == "hinted_parameter": + parameter.is_constant = True + parameter.const_value = 99 + builder.new_function("main", [parameter] if "parameter" in binding else []) + builder.new_block("entry") + if "parameter" in binding: + result = parameter + inputs["x"] = np.array(7, dtype=np.int32) + elif binding == "load": + pointer = builder.alloca(4, DataType.INT32) + builder.store(pointer, builder.load_const(7, DataType.INT32)) + result = builder.load(pointer) + elif binding == "load_const": + result = builder.load_const(7, DataType.INT32) + else: + result = builder.add(builder.load_const(3, DataType.INT32), + builder.load_const(4, DataType.INT32)) + # A reference can be a separate Value object; the name determines its + # binding. Existing IR contracts permit this stale constant hint. + builder.ret(Value(result.name, DataType.INT32, is_constant=True, const_value=99)) + reference = IRInterpreter(builder.program).run(inputs).return_value + assert reference.item() == 7 + assert _execute_native(builder.program, inputs) == reference.item() + + +def test_unbound_literal_still_executes_as_an_immediate(): + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + builder.ret(Value("literal", DataType.INT32, is_constant=True, const_value=23)) + assert _execute_native(builder.program) == 23 + + +@pytest.mark.parametrize("hint", [None, 99]) +def test_scalar_global_definition_wins_over_reference_metadata(hint): + builder = IRBuilder() + builder.program.global_values.append(Value( + "weight", DataType.INT32, is_constant=True, const_value=7, + )) + builder.new_function("main") + builder.new_block("entry") + builder.ret(Value("weight", DataType.INT32, is_constant=hint is not None, const_value=hint)) + reference = IRInterpreter(builder.program).run({}).return_value + assert reference.item() == 7 + assert _execute_native(builder.program) == reference.item() + + +def test_binding_names_are_scoped_to_the_current_function(): + builder = IRBuilder() + parameter = Value("shared", DataType.INT32) + builder.new_function("first", [parameter]) + builder.new_block("entry") + builder.ret(parameter) + builder.new_function("second") + builder.new_block("entry") + builder.ret(Value("shared", DataType.INT32, is_constant=True, const_value=23)) + selected = InstructionSelector(builder.program).run() + returns = [instruction for instruction in selected if instruction.comment == "return value"] + assert [(instruction.src1.kind, instruction.src1.value) for instruction in returns] == [ + ("vreg", "shared"), ("imm", 23), + ] diff --git a/tests/test_numeric_metrics.py b/tests/test_numeric_metrics.py index 488f2d6..6f4aee8 100644 --- a/tests/test_numeric_metrics.py +++ b/tests/test_numeric_metrics.py @@ -123,9 +123,11 @@ def observed(*args): assert len(lengths) > 2 and max(lengths) <= 127 -def test_new_metrics_do_not_weaken_absolute_gate(): +@pytest.mark.parametrize("gate", ["w2", "w3"]) +def test_new_metrics_do_not_weaken_absolute_gate(gate): from probes.w2_qwen3_small.diagnostics import tensor_diff - compare = tensor_diff + from probes.w3_qwen3_full.comparison import compare_tensor + compare = tensor_diff if gate == "w2" else compare_tensor actual = np.array([2, 4], dtype=np.float32) expected = np.array([1, 2], dtype=np.float32) result = compare(actual, expected) @@ -143,8 +145,10 @@ def test_new_metrics_do_not_weaken_absolute_gate(): def test_empty_gate_policies_are_preserved(): from probes.w2_qwen3_small.diagnostics import tensor_diff + from probes.w3_qwen3_full.comparison import compare_tensor array = np.empty(0, dtype=np.float32) - row = tensor_diff(array, array) - assert row["passed"] - assert row["relative_l2"] is None and row["cosine_similarity"] is None - assert row["relative_l2_reason"] and row["cosine_similarity_reason"] + w2, w3 = tensor_diff(array, array), compare_tensor(array, array) + assert w2["passed"] and not w3["passed"] + for row in (w2, w3): + assert row["relative_l2"] is None and row["cosine_similarity"] is None + assert row["relative_l2_reason"] and row["cosine_similarity_reason"] diff --git a/tests/test_onnx_operator_benchmark.py b/tests/test_onnx_operator_benchmark.py index f0067b6..e50e398 100644 --- a/tests/test_onnx_operator_benchmark.py +++ b/tests/test_onnx_operator_benchmark.py @@ -146,6 +146,8 @@ def test_failed_round_cannot_retain_success_median(): assert merged["status"] == "NUMERIC_ERROR" and merged["run_median_s"] is None +@pytest.mark.skipif(not (bench.ROOT / ".git").exists(), + reason="Historical baseline integration requires this source root's Git checkout; archives omit history") def test_actual_git_baseline_and_dirty_snapshot_are_isolated(tmp_path): original_hash = worker.source_hash(bench.ROOT) selected = ["add_float32", "gather", "scalar_fp32_add_chain", "singleton_initializer", "constant"] @@ -171,21 +173,58 @@ def test_actual_git_baseline_and_dirty_snapshot_are_isolated(tmp_path): def test_worker_timeout_is_visible_in_new_report(tmp_path, monkeypatch): actual_run = subprocess.run + attempts = [] + + def fake_snapshots(work, baseline_ref): + # Timeout handling is independent of Git history. Real tiny source + # directories still satisfy the runner's post-execution hash check. + versions = [] + for side in ("before", "after"): + source = work / side + (source / "scratchv").mkdir(parents=True) + (source / "scratchv" / "__init__.py").write_text("# timeout fixture\n") + versions.append({"source_path": str(source), "source_sha256": worker.source_hash(source)}) + versions[0]["commit"] = baseline_ref + versions[1].update(head="fixture-current", dirty=False) + return versions def timeout(command, **kwargs): if command[0] == sys.executable: + attempts.append(command) raise subprocess.TimeoutExpired(command, 60) return actual_run(command, **kwargs) + monkeypatch.setattr(bench, "snapshots", fake_snapshots) monkeypatch.setattr(subprocess, "run", timeout) assert bench.main(["--output-dir", str(tmp_path), "--case", "add_float32", "--warmup", "0", "--repeats", "1"]) == 1 report = json.loads((tmp_path / "report.json").read_text(encoding="utf-8")) assert not report["passed"] + assert len(attempts) == 4 + assert report["results"][0]["before"]["status"] == "WORKER_ERROR" assert report["results"][0]["after"]["status"] == "WORKER_ERROR" assert "timed out" in report["results"][0]["after"]["error"] +def test_archive_cannot_discover_parent_git_and_reports_clear_failure(tmp_path, monkeypatch): + (tmp_path / ".git").mkdir() + archive = tmp_path / "extracted-source" + archive.mkdir() + monkeypatch.setattr(bench, "ROOT", archive) + + def no_parent_git(*args, **kwargs): + pytest.fail("Source archive must reject history comparison before invoking Git") + + monkeypatch.setattr(subprocess, "run", no_parent_git) + with pytest.raises(RuntimeError, match="requires a Git checkout"): + bench.git("rev-parse", "HEAD") + output = tmp_path / "report" + assert bench.main(["--output-dir", str(output), "--case", "add_float32"]) == 1 + report = json.loads((output / "report.json").read_text(encoding="utf-8")) + assert not report["passed"] and not report["results"] + assert "extracted source archive has no Git history" in report["runner_error"] + + def test_preparation_failure_replaces_stale_pass_report(tmp_path, monkeypatch): (tmp_path / "report.html").write_text("stale PASS", encoding="utf-8") diff --git a/tests/test_onnx_qwen_numeric_ops.py b/tests/test_onnx_qwen_numeric_ops.py index b5831f1..3455b9d 100644 --- a/tests/test_onnx_qwen_numeric_ops.py +++ b/tests/test_onnx_qwen_numeric_ops.py @@ -33,8 +33,11 @@ def compare(path, feed, *, exact=False, reference_evaluator=False): program = parser.parse(str(path)) assert verify_ir(program) == (True, []) actual = IRInterpreter(program).run(feed, initializers=parser.initializers).return_value + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 session = (ReferenceEvaluator(str(path)) if reference_evaluator else - ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])) + ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"])) reference = session.run(None, feed)[0] assert actual.shape == reference.shape assert actual.dtype == reference.dtype diff --git a/tests/test_onnx_semantic_boundaries.py b/tests/test_onnx_semantic_boundaries.py new file mode 100644 index 0000000..b77ebb5 --- /dev/null +++ b/tests/test_onnx_semantic_boundaries.py @@ -0,0 +1,84 @@ +"""Operator versions and optional attributes must not silently change ONNX.""" + +import numpy as np +import onnx +import pytest +from onnx import TensorProto, helper, numpy_helper + +from scratchv.frontend.onnx_parser import ONNXParseError, ONNXParser +from scratchv.verification.ir_interpreter import IRInterpreter + +ort = pytest.importorskip("onnxruntime") + + +def _model(tmp_path, node, data, output_shape, *, arrays=None, opset=18): + graph = helper.make_graph( + [node], "semantic_boundary", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, data.shape)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, output_shape)], + [numpy_helper.from_array(value, name) for name, value in (arrays or {}).items()], + ) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", opset)], ir_version=10) + onnx.checker.check_model(model) + path = tmp_path / "model.onnx" + onnx.save(model, path) + expected = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"]).run(None, {"x": data})[0] + return path, expected + + +def _compare(path, data, expected): + parser = ONNXParser() + program = parser.parse(str(path)) + actual = IRInterpreter(program).run({"x": data}, initializers=parser.initializers).return_value + assert actual.shape == expected.shape and actual.dtype == expected.dtype + np.testing.assert_allclose(actual, expected, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("alpha,beta", [(2.0, 3.0), (-0.5, 0.25), (0.0, 2.0), (1.0, 1.0)]) +@pytest.mark.parametrize("trans_a,trans_b", [(0, 0), (1, 1)]) +@pytest.mark.parametrize("bias", [True, False]) +def test_gemm_preserves_coefficients_transpose_and_optional_bias(tmp_path, alpha, beta, trans_a, trans_b, bias): + data = np.array([[1, 2, 3], [4, 5, 6]], np.float32) + weights = np.array([[2, -1], [3, 2], [-2, 4]], np.float32) + data = data.T.copy() if trans_a else data + weights = weights.T.copy() if trans_b else weights + arrays = {"w": weights} + names = ["x", "w"] + if bias: + arrays["b"] = np.array([5, -3], np.float32) + names.append("b") + node = helper.make_node("Gemm", names, ["y"], alpha=alpha, beta=beta, transA=trans_a, transB=trans_b) + path, expected = _model(tmp_path, node, data, [2, 2], arrays=arrays) + _compare(path, data, expected) + + +@pytest.mark.parametrize("opset", [11, 12]) +def test_legacy_softmax_fails_explicitly_instead_of_normalizing_only_last_axis(tmp_path, opset): + data = np.arange(24, dtype=np.float32).reshape(2, 3, 4) + path, expected = _model(tmp_path, helper.make_node("Softmax", ["x"], ["y"]), data, data.shape, opset=opset) + # Legacy default axis=1 flattens dimensions 1..rank-1 into each row. + np.testing.assert_allclose(expected.reshape(2, -1).sum(axis=1), np.ones(2), atol=1e-6) + with pytest.raises(ONNXParseError, match="Softmax.*opset.*13"): + ONNXParser().parse(str(path)) + + +@pytest.mark.parametrize("opset", [13, 18]) +def test_modern_softmax_default_last_axis_matches_ort(tmp_path, opset): + data = np.arange(24, dtype=np.float32).reshape(2, 3, 4) + path, expected = _model(tmp_path, helper.make_node("Softmax", ["x"], ["y"]), data, data.shape, opset=opset) + _compare(path, data, expected) + + +@pytest.mark.parametrize("approximate", [None, "none"]) +def test_exact_gelu_fails_explicitly_instead_of_silently_using_tanh(tmp_path, approximate): + data = np.array([-3, -2, -1, 0, 1, 2, 3], np.float32) + attrs = {} if approximate is None else {"approximate": approximate} + path, _ = _model(tmp_path, helper.make_node("Gelu", ["x"], ["y"], **attrs), data, data.shape, opset=20) + with pytest.raises(ONNXParseError, match="Gelu.*tanh"): + ONNXParser().parse(str(path)) + + +def test_gelu_explicit_tanh_matches_ort(tmp_path): + data = np.array([-3, -2, -1, 0, 1, 2, 3], np.float32) + path, expected = _model(tmp_path, helper.make_node("Gelu", ["x"], ["y"], approximate="tanh"), data, data.shape, opset=20) + _compare(path, data, expected) diff --git a/tests/test_qwen3_medium_model.py b/tests/test_qwen3_medium_model.py new file mode 100644 index 0000000..43a1b67 --- /dev/null +++ b/tests/test_qwen3_medium_model.py @@ -0,0 +1,104 @@ +"""Pinned-stack regressions for config-driven observers and the medium preset.""" + +import numpy as np +import pytest + +torch = pytest.importorskip("torch") +transformers = pytest.importorskip("transformers") +if transformers.__version__ != "4.51.3": + pytest.skip("Qwen observers require transformers==4.51.3", allow_module_level=True) + +from probes.w2_qwen3_small.model import MODEL_CONFIG, build_model, export_onnx +from probes.w2_qwen3_small.diagnostics import tensor_diff +from probes.w3_qwen3_medium.preset import MEDIUM_CONFIG, build_medium_model +from probes.w3_qwen3_medium.run import trace_schema + + +@pytest.fixture(autouse=True) +def one_thread(): + previous = torch.get_num_threads() + torch.set_num_threads(1) + yield + torch.set_num_threads(previous) + + +def execute(wrapper): + length = wrapper.sequence_length + ids = (torch.arange(length).reshape(1, length) % (wrapper.model.config.vocab_size - 1) + 1).long() + mask = torch.zeros((1, 1, length, length), dtype=torch.float32) + mask.masked_fill_(torch.triu(torch.ones((length, length), dtype=torch.bool), 1), + torch.finfo(torch.float32).min) + with torch.inference_mode(), wrapper.capture(): + tensors = wrapper(ids, mask) + return (ids, mask), dict(zip(wrapper.output_names, tensors)) + + +def test_medium_preset_and_all_observed_shapes(): + wrapper = build_medium_model(seed=31) + assert (wrapper.model.config.num_hidden_layers, wrapper.model.config.hidden_size, + wrapper.model.config.intermediate_size) == (6, 64, 192) + assert len(wrapper.output_names) == 3 + 13 * 6 == 81 + assert wrapper.config_metadata["weights"].startswith("fixed-seed random") + assert wrapper.config_metadata["dtype"] == "float32" + assert wrapper.config_metadata["use_cache"] is False + assert wrapper.model.lm_head.weight is wrapper.model.model.embed_tokens.weight + _, values = execute(wrapper) + for name, value in values.items(): + assert list(value.shape) == wrapper.checkpoint_metadata[name]["shape"], name + assert value.dtype == torch.float32 and bool(torch.isfinite(value).all()) + schema = trace_schema(wrapper.checkpoint_metadata) + assert len(schema["checkpoints"]) == 81 + assert [row["layer"] for row in schema["checkpoints"] if row["checkpoint"] == "residual"] == list(range(6)) + assert wrapper.config_metadata["hidden_size"] == 64 + assert MODEL_CONFIG["hidden_size"] == 32 + + +def test_config_changes_every_schema_dimension_and_export_contract(tmp_path): + onnx = pytest.importorskip("onnx") + ort = pytest.importorskip("onnxruntime") + settings = {"hidden_size": 48, "intermediate_size": 80, "num_hidden_layers": 1, + "num_attention_heads": 6, "num_key_value_heads": 3, "head_dim": 8, + "vocab_size": 29, "max_position_embeddings": 11} + original = dict(settings) + wrapper = build_model(seed=41, model_config=settings, sequence_length=7) + assert settings == original + assert len(wrapper.output_names) == 16 + assert wrapper.config_metadata["position_ids"] == "0..6, including right-padding positions" + inputs, values = execute(wrapper) + assert list(values["layer_0.q_norm"].shape) == [1, 7, 6, 8] + assert list(values["layer_0.k_norm"].shape) == [1, 7, 3, 8] + assert list(values["layer_0.v_proj"].shape) == [1, 7, 24] + assert list(values["layer_0.attn_context"].shape) == [1, 7, 48] + assert list(values["logits"].shape) == [1, 7, 29] + for name, value in values.items(): + assert list(value.shape) == wrapper.checkpoint_metadata[name]["shape"] + path = tmp_path / "custom.onnx" + export_onnx(wrapper, path, *inputs) + model = onnx.load(path) + assert [dim.dim_value for dim in model.graph.input[0].type.tensor_type.shape.dim] == [1, 7] + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + session = ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"]) + actual = session.run(None, {"input_ids": inputs[0].numpy(), "attention_mask": inputs[1].numpy()}) + assert all(tensor_diff(got, values[name].numpy(), atol=1e-5)["passed"] + for name, got in zip(wrapper.output_names, actual)) + + +@pytest.mark.parametrize("override,match", [ + ({"num_hidden_layers": 0}, "positive integer"), + ({"num_attention_heads": 5}, "divisible"), + ({"head_dim": 15}, "even"), + ({"hidden_size": True}, "positive integer"), + ({"use_cache": True}, "use_cache=False"), + ({"mystery": 5}, "Unsupported"), +]) +def test_invalid_probe_configs_fail_before_initialization(override, match): + with pytest.raises(ValueError, match=match): + build_model(model_config=override) + + +def test_sequence_length_cannot_exceed_model_positions(): + with pytest.raises(ValueError, match="exceeds"): + build_model(sequence_length=257) diff --git a/tests/test_qwen3_medium_probe.py b/tests/test_qwen3_medium_probe.py new file mode 100644 index 0000000..b99c63a --- /dev/null +++ b/tests/test_qwen3_medium_probe.py @@ -0,0 +1,180 @@ +"""Fail-closed numerical evidence and independent ordinary/diagnostic paths.""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +pytest.importorskip("onnx") + +from probes.w3_qwen3_medium import run as probe +from scratchv.verification.ir_interpreter import IRInterpreter + + +@pytest.mark.parametrize("kind", probe.GRAPH_KINDS) +@pytest.mark.parametrize("level", probe.LEVELS) +def test_one_failed_graph_or_optimization_makes_case_fail(kind, level): + case = {"capture_preserves_logits": {"passed": True}, "attention_checks": [{"passed": True}], + "ort": {key: {"passed": True} for key in probe.GRAPH_KINDS}, + "ir": {opt: {key: {"passed": True} for key in probe.GRAPH_KINDS} for opt in probe.LEVELS}} + assert probe.case_passed(case) + case["ir"][level][kind]["passed"] = False + assert not probe.case_passed(case) + + +@pytest.mark.parametrize("kind", probe.GRAPH_KINDS) +@pytest.mark.parametrize("fault", ["value", "nonfinite", "shape", "exception"]) +def test_ir_fault_is_failed_and_actual_trace_is_preserved(tmp_path, monkeypatch, kind, fault): + expected = np.ones((1, 2, 3), np.float32) + actual = expected.copy() + if fault == "value": + actual.flat[1] += 0.25 + elif fault == "nonfinite": + actual.flat[1] = np.nan + elif fault == "shape": + actual = np.ones((1, 2, 4), np.float32) + + def execute(self, *args, **kwargs): + assert kwargs["collect_memory_stats"] is True + if fault == "exception": + raise RuntimeError("injected execution failure") + return SimpleNamespace(return_value=actual.ravel() if kind == "diagnostic" else actual, + executed_steps=5, memory_stats={"retained_values": 2}) + + monkeypatch.setattr(IRInterpreter, "run", execute) + report = {"trace_artifacts": []} + result = probe.run_ir_variant(None, {}, {}, kind=kind, level="all", + schema=[{"name": "logits", "shape": [1, 2, 3], "offset": 0, "size": 6}], + names=["logits"], metadata={"logits": {"sequence_axis": 1}}, valid_length=1, + reference={"logits": expected}, ort_reference={"logits": expected}, + out=tmp_path, case_name="fault", report=report) + assert result["passed"] is False + assert result["status"] in ("runtime_error", "numeric_failed") + if fault in ("value", "nonfinite") or (fault == "shape" and kind == "normal"): + assert len(report["trace_artifacts"]) == 1 + with np.load(tmp_path / result["trace"]["path"]) as data: + np.testing.assert_array_equal(data["logits"], actual) + assert not result["pytorch_comparison"]["passed"] + if fault == "exception": + assert "injected execution failure" in result["error"] + + +def test_missing_ort_reference_fails_even_when_ir_matches_pytorch(tmp_path, monkeypatch): + expected = np.ones((1, 2, 3), np.float32) + monkeypatch.setattr(IRInterpreter, "run", lambda *args, **kwargs: SimpleNamespace( + return_value=expected.copy(), executed_steps=1, memory_stats={})) + result = probe.run_ir_variant(None, {}, {}, kind="normal", level="none", schema=[], + names=["logits"], metadata={"logits": {"sequence_axis": 1}}, valid_length=2, + reference={"logits": expected}, ort_reference=None, out=tmp_path, + case_name="missing", report={"trace_artifacts": []}) + assert result["pytorch_comparison"]["passed"] + assert not result["passed"] + assert result["ort_comparison"]["error"] == "ORT reference unavailable" + + +@pytest.mark.parametrize("query_index,passed", [(12, False), (100, True)]) +def test_causal_isolation_observes_nonlogit_checkpoint_on_its_sequence_axis(tmp_path, query_index, passed): + reference = {"layer_0.rope_q": np.ones((1, 4, 256, 16), np.float32), + "logits": np.ones((1, 256, 128), np.float32)} + changed = {name: array.copy() for name, array in reference.items()} + changed["layer_0.rope_q"][0, 0, query_index, 0] += 0.5 + for case_name, data in (("full_seed_0", reference), ("changed_future", changed)): + folder = tmp_path / "traces" / case_name + folder.mkdir(parents=True) + np.savez(folder / "reference.npz", **data) + rows = probe.isolation_checks(tmp_path, {"layer_0.rope_q": {"sequence_axis": 2}, + "logits": {"sequence_axis": 1}}) + row = next(row for row in rows if row["name"] == "causality" and row["backend"] == "pytorch") + assert row["passed"] is passed + assert row["comparison"]["first_divergence"] == (None if passed else "layer_0.rope_q") + + +def test_cli_exception_writes_failure_reports_and_refuses_overwrite(tmp_path, monkeypatch): + import json + + def fail(out, report, seed): + report["stage"] = "injected" + raise RuntimeError("injected gate failure") + + monkeypatch.setattr(probe, "run_probe", fail) + out = tmp_path / "evidence" + assert probe.main(["--output-dir", str(out)]) == 1 + report = json.loads((out / "report.json").read_text(encoding="utf-8")) + assert report["passed"] is False + assert report["status"] == "FAIL" + assert report["error"] == "RuntimeError: injected gate failure" + assert report["stage"] == "injected" + assert (out / "report.md").is_file() and (out / "report.html").is_file() + with pytest.raises(SystemExit) as caught: + probe.main(["--output-dir", str(out)]) + assert caught.value.code == 2 + + +def complete_numeric_report(): + return { + "cases": [ + {"name": name, "valid_length": length, "passed": True, + "ort": {graph: {"passed": True} for graph in probe.GRAPH_KINDS}, + "ir": {level: {graph: {"passed": True} for graph in probe.GRAPH_KINDS} + for level in probe.LEVELS}} + for name, length in probe.CASE_IDENTITIES + ], + "invariants": [{"name": name, "backend": backend, "graph": graph, "passed": True} + for name, backend, graph in sorted(probe.INVARIANT_IDENTITIES)], + } + + +@pytest.mark.parametrize("omission", ["case", "all_cases", "invariant", "all_invariants", + "duplicate_case", "duplicate_invariant", "case_length", "ir_level", "ort_graph"]) +def test_missing_or_duplicated_coverage_cannot_pass(omission): + report = complete_numeric_report() + probe.finalize_numeric_report(report) + assert report["coverage_complete"] and report["passed"] + if omission == "case": + report["cases"].pop() + elif omission == "all_cases": + report["cases"] = [] + elif omission == "invariant": + report["invariants"].pop() + elif omission == "all_invariants": + report["invariants"] = [] + elif omission == "duplicate_case": + report["cases"][-1] = report["cases"][0] + elif omission == "duplicate_invariant": + report["invariants"][-1] = report["invariants"][0] + elif omission == "case_length": + report["cases"][0]["valid_length"] = 255 + elif omission == "ir_level": + del report["cases"][0]["ir"]["all"] + else: + del report["cases"][0]["ort"]["normal"] + probe.finalize_numeric_report(report) + assert report["coverage_complete"] is False + assert report["passed"] is False + + +def test_success_status_and_existing_empty_directory_contract(tmp_path, monkeypatch): + import json + from probes import w3_common + + sources = {"source_sha256": {"scratchv/example.py": "a" * 64}} + monkeypatch.setattr(w3_common, "source_evidence", lambda: sources) + + def succeed(out, report, seed): + report.update(complete_numeric_report()) + report["provenance"] = sources + probe.finalize_numeric_report(report) + + monkeypatch.setattr(probe, "run_probe", succeed) + existing = tmp_path / "already-created" + existing.mkdir() + with pytest.raises(SystemExit) as caught: + probe.main(["--output-dir", str(existing)]) + assert caught.value.code == 2 + assert not list(existing.iterdir()) + out = tmp_path / "new-evidence" + assert probe.main(["--output-dir", str(out)]) == 0 + report = json.loads((out / "report.json").read_text(encoding="utf-8")) + assert report["status"] == "PASS" and report["passed"] is True + assert report["coverage_complete"] is True + assert report["source_recheck"] == {"passed": True, "changed": {}} diff --git a/tests/test_qwen3_small_gate.py b/tests/test_qwen3_small_gate.py index dff815f..de5fcf9 100644 --- a/tests/test_qwen3_small_gate.py +++ b/tests/test_qwen3_small_gate.py @@ -1,7 +1,7 @@ """Exercise the real export and prove corrupt IR cannot produce a green gate. -The LLM workflow runs this file in the pinned CPU environment. The generic -compiler job has no torch/transformers dependency and skips this module. +The LLM workflow and full compiler CI run this file in the pinned CPU stack. +Minimal local compiler installations may omit torch/transformers and skip it. """ import json diff --git a/tests/test_qwen3_small_model.py b/tests/test_qwen3_small_model.py index b4d4daf..46f5c29 100644 --- a/tests/test_qwen3_small_model.py +++ b/tests/test_qwen3_small_model.py @@ -1,7 +1,7 @@ """Optional pinned-stack checks for the official Qwen3 diagnostic model. -The ordinary compiler test environment does not need torch/transformers. The -dedicated Qwen probe environment runs these tests with transformers 4.51.3. +Minimal compiler installations do not need torch/transformers. The full CI +and dedicated Qwen probe environments run these tests with transformers 4.51.3. """ import numpy as np diff --git a/tests/test_riscv_external.py b/tests/test_riscv_external.py new file mode 100644 index 0000000..3afea98 --- /dev/null +++ b/tests/test_riscv_external.py @@ -0,0 +1,427 @@ +"""Real QEMU execution of the external tensor ABI (explicit tool opt-in).""" +import json +import os +from pathlib import Path +import signal +import struct +import subprocess +import sys +import time +from types import SimpleNamespace + +import numpy as np +import pytest + +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.runtime.riscv_external import build_external, run_external +from scratchv.runtime.riscv_tensor import RiscVToolchain, discover_toolchain +from scratchv.runtime.weight_bundle import write_weight_bundle +from scratchv.runtime import riscv_external as runtime + + +def artifact(): + builder = IRBuilder() + x, w = Value("x", shape=(4, 4)), Value("w", shape=(4, 4)) + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(w) + builder.ret(builder.matmul(x, w)) + weights = np.arange(16, dtype=np.float32).reshape(4, 4) / 16 + return TensorCCodegen(builder.program, initializers={"w": weights}, + constant_storage="external").generate(), weights + + +@pytest.mark.parametrize("project_cache,standard_cache,selected", [ + (None, None, None), + (None, "standard cache", "standard cache"), + ("project cache", None, "project cache"), + ("project cache", "standard cache", "project cache"), +], ids=["default", "standard", "project", "project-overrides-standard"]) +def test_external_build_respects_zig_cache_environment( + tmp_path, monkeypatch, project_cache, standard_cache, selected): + monkeypatch.chdir(tmp_path) + for key, value in (("SCRATCHV_ZIG_CACHE", project_cache), + ("ZIG_GLOBAL_CACHE_DIR", standard_cache)): + if value is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, value) + model, _ = artifact() + write_weight_bundle(model.external_weights, model.external_initializers, tmp_path / "weights") + received = [] + + def compiler(command, *, cwd, timeout, env): + received.append(env) + # Inspect the actual subprocess environment without compiling a model. + return subprocess.CompletedProcess(command, 1, b"", b"controlled compile stop") + + monkeypatch.setattr(runtime, "_run_process", compiler) + with pytest.raises(RuntimeError, match="controlled compile stop"): + build_external(model, tmp_path / "weights", tmp_path / "build", + RiscVToolchain(("zig", "cc"), "unused-qemu")) + expected = (tmp_path / selected if selected else + Path(runtime.__file__).resolve().parents[2] / "output" / ".riscv-zig-cache") + assert len(received) == 1 + assert received[0]["ZIG_GLOBAL_CACHE_DIR"] == str(expected.resolve()) + assert received[0]["ZIG_LOCAL_CACHE_DIR"] == str(tmp_path / "build" / ".zig-cache") + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +def test_external_rv64_matmul_and_reuse(tmp_path): + model, weights = artifact() + manifest = write_weight_bundle(model.external_weights, model.external_initializers, tmp_path / "weights") + exe = build_external(model, tmp_path / "weights", tmp_path / "build", discover_toolchain()) + assert exe.evidence["bytes"] < 100_000_000 + for number in range(2): + x = np.arange(16, dtype=np.float32).reshape(4, 4) / (number + 1) + output, report = run_external(exe, {"x": x}, tmp_path / f"run{number}", timeout=30) + np.testing.assert_array_equal(output, x @ weights) + assert report["passed"] and report["guest_completed"] and report["exit_code"] == 0 + assert report["weight_sha256"] == manifest["sha256"] + assert report["elapsed_s"] >= report["output_dump_s"] > 0 + assert report["elapsed_s"] >= report["qemu_wall_s"] > 0 + assert report["qemu_rss_observed_samples"] > 0 + assert report["sampled_peak_qemu_rss_bytes"] > 0 + assert json.loads((tmp_path / f"run{number}/run.json").read_text())["passed"] + with pytest.raises(FileExistsError): + run_external(exe, {"x": x}, tmp_path / "run0", timeout=30) + (tmp_path / "weights/weights.bin").write_bytes(b"broken") + with pytest.raises(ValueError, match="length"): + run_external(exe, {"x": x}, tmp_path / "corrupt", timeout=30) + assert not (tmp_path / "corrupt").exists() + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +@pytest.mark.parametrize("empty", [False, True]) +def test_external_rv64_without_weights(tmp_path, empty): + builder = IRBuilder() + x = Value("x", shape=(0,) if empty else (3,)) + builder.new_function("main", [x]) + builder.new_block("entry") + builder.ret(builder.neg(x)) + model = TensorCCodegen(builder.program, constant_storage="external").generate() + manifest = write_weight_bundle(model.external_weights, model.external_initializers, tmp_path / "weights") + assert manifest["total_bytes"] == 0 + exe = build_external(model, tmp_path / "weights", tmp_path / "build", discover_toolchain()) + x_data = np.array([] if empty else [1.25, -2.5, 0.0], np.float32) + output, report = run_external(exe, {"x": x_data}, tmp_path / "run", timeout=30) + np.testing.assert_array_equal(output, -x_data) + assert report["passed"] and report["guest_completed"] + assert not any("loader,file=" in token and "weights.bin" in token for token in report["command"]) + + +def _stub_executable(tmp_path): + """A valid ELF/container for process-lifecycle tests; no numerical execution.""" + from scratchv.runtime.weight_bundle import plan_guest_memory + from scratchv.runtime.riscv_tensor import RiscVToolchain + builder = IRBuilder() + x = Value("x", shape=(1,)) + builder.new_function("main", [x]) + builder.new_block("entry") + builder.ret(x) + model = TensorCCodegen(builder.program, constant_storage="external").generate() + bundle = tmp_path / "weights" + manifest = write_weight_bundle(model.external_weights, model.external_initializers, bundle) + layout = plan_guest_memory(input_bytes=4, output_bytes=4) + elf = tmp_path / "model.elf" + ident = b"\x7fELF\x02\x01\x01" + b"\0" * 9 + header = struct.pack("<16sHHIQQQIHHHHHH", ident, 2, 243, 1, layout["code_base"], + 64, 0, 4, 64, 56, 1, 0, 0, 0) + segment = struct.pack("= 2 + assert report["sampled_peak_qemu_rss_bytes"] > 0 + assert report["qemu_rss_availability"] == "available" + finally: + monitor.close() + process.terminate() + process.wait(timeout=5) + + +@pytest.mark.skipif(os.name == "nt", reason="real POSIX SIGTERM/process-group test") +def test_posix_sigterm_reaps_owned_process_session(tmp_path): + executable = _stub_executable(tmp_path) + fake_qemu = tmp_path / "qemu-stub" + fake_qemu.write_text(f"#!{sys.executable}\nimport os,time\nfrom pathlib import Path\n" + "Path('guest.pid').write_text(str(os.getpid()))\ntime.sleep(300)\n") + fake_qemu.chmod(0o700) + worker = tmp_path / "worker.py" + worker.write_text( + "from pathlib import Path\nimport numpy as np\n" + "from scratchv.runtime.riscv_external import ExternalExecutable, run_external\n" + "from scratchv.runtime.riscv_tensor import RiscVToolchain\n" + "from scratchv.backend.tensor_c_codegen import TensorSpec\n" + "from scratchv.ir.types import DataType\n" + "spec=TensorSpec('x',DataType.FLOAT32,(1,))\n" + f"exe=ExternalExecutable(Path({str(executable.elf_path)!r})," + f"Path({str(executable.bundle_dir)!r}),{executable.manifest!r},{executable.layout!r}," + f"(spec,),spec,(),RiscVToolchain(('unused',),{str(fake_qemu)!r}),{executable.evidence!r})\n" + f"run_external(exe,{{'x':np.ones(1,np.float32)}},Path({str(tmp_path / 'run')!r}))\n" + ) + environment = dict(os.environ) + environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[1]) + process = subprocess.Popen([sys.executable, str(worker)], env=environment, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + child_pid = None + try: + deadline = time.monotonic() + 15 + pid_path = tmp_path / "run/guest.pid" + while not pid_path.exists(): + assert process.poll() is None + if time.monotonic() >= deadline: + pytest.fail("fake QEMU session did not start") + time.sleep(0.05) + child_pid = int(pid_path.read_text()) + process.send_signal(signal.SIGTERM) + process.communicate(timeout=20) + assert process.returncode == 143 + with pytest.raises(ProcessLookupError): + os.kill(child_pid, 0) + report = json.loads((tmp_path / "run/run.json").read_text()) + assert report["error"] == "SystemExit: 143" and report["status"] == "FAIL" + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + if child_pid is not None: + try: + os.killpg(child_pid, signal.SIGKILL) + except ProcessLookupError: + pass diff --git a/tests/test_riscv_external_contract.py b/tests/test_riscv_external_contract.py new file mode 100644 index 0000000..81312b9 --- /dev/null +++ b/tests/test_riscv_external_contract.py @@ -0,0 +1,580 @@ +"""Independent ELF, completion protocol and local QMP contract checks. + +No mocked numerical execution or complete-model claim is made by these tests. +""" + +from contextlib import contextmanager +import hashlib +import json +from pathlib import Path +import socket +import struct +import threading +import time +from types import SimpleNamespace + +import pytest + +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.runtime import riscv_external as runtime +from scratchv.runtime import weight_transport as transport +from scratchv.runtime.riscv_tensor import RiscVToolchain +from scratchv.runtime.weight_bundle import plan_guest_memory, write_weight_bundle + + +def _elf(path, layout, *, entry=None, machine=243, flags=5, segments=None): + base = layout["code_base"] + if segments is None: + segments = [(1, 5, 256, base, base, 8, 16, 1)] + data = bytearray(512) + ident = b"\x7fELF\x02\x01\x01" + bytes(9) + struct.pack_into("<16sHHIQQQIHHHHHH", data, 0, ident, 2, machine, 1, + base if entry is None else entry, 64, 0, flags, 64, 56, + len(segments), 0, 0, 0) + for index, segment in enumerate(segments): + struct.pack_into("= 0 + + +@pytest.mark.parametrize("stat_failure", [False, True], ids=["truncated", "unobservable"]) +def test_postcheck_failure_refreshes_residual_evidence_without_replacing_error( + tmp_path, monkeypatch, stat_failure): + """Exercise report/lifecycle handling with a stub guest, not numerical execution.""" + import numpy as np + + x, w = Value("x", shape=(2,)), Value("w", shape=(2,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(w) + builder.ret(builder.add(x, w)) + artifact = TensorCCodegen(builder.program, {"w": np.ones(2, np.float32)}, + constant_storage="external").generate() + bundle = tmp_path / "weights" + manifest = write_weight_bundle(artifact.external_weights, artifact.external_initializers, bundle) + original_weights = (bundle / "weights.bin").read_bytes() + assert manifest["total_bytes"] == 8 + layout = plan_guest_memory(input_bytes=8, output_bytes=8, weight_bytes=8, + workspace_bytes=artifact.workspace_bytes) + elf = tmp_path / "model.elf" + _elf(elf, layout) + executable = runtime.ExternalExecutable( + elf, bundle, manifest, layout, artifact.inputs, artifact.output, + artifact.external_weights, RiscVToolchain(("unused",), "stub-qemu"), + runtime.validate_external_elf(elf, layout)) + out = tmp_path / "run" + + class Process: + returncode = None + pid = 1234 + + def poll(self): + return self.returncode + + def wait(self, timeout): + self.returncode = 0 + return 0 + + class QMP: + def __init__(self, *args): + pass + + def command(self, command, *args): + if command == "human-monitor-command": + (out / "output.bin").write_bytes(bytes(8)) + return "" + return {} + + def close(self): + pass + + class Monitor: + def __init__(self, *args): + pass + + def start(self): + pass + + def close(self): + pass + + def report(self): + return {} + + def launch(*args, cwd, **kwargs): + (Path(cwd) / "uart.bin").write_bytes(runtime.FRAME.pack(runtime.MAGIC, 0, 8)) + return Process() + + verify, lstat = runtime.validate_transport, Path.lstat + original_errors = [] + + def inaccessible(path, *args, **kwargs): + if path.parent == out / "weight-transport" and path.suffix == ".bin": + raise PermissionError("injected residual stat failure") + return lstat(path, *args, **kwargs) + + def truncate_then_verify(directory, report): + path = directory / report["files"][0]["file"] + path.write_bytes(path.read_bytes()[:-1]) + try: + return verify(directory, report) + except ValueError as exc: + original_errors.append(exc) + if stat_failure: + monkeypatch.setattr(Path, "lstat", inaccessible) + raise + + monkeypatch.setattr(transport, "CHUNK_BYTES", 4) + monkeypatch.setattr(runtime.subprocess, "Popen", launch) + monkeypatch.setattr(runtime, "QMP", QMP) + monkeypatch.setattr(runtime, "_QemuMemoryMonitor", Monitor) + monkeypatch.setattr(runtime, "_windows_job", lambda process: None) + monkeypatch.setattr(runtime, "validate_transport", truncate_then_verify) + monkeypatch.setattr(transport, "remove_transport", lambda *args: pytest.fail("must retain copies")) + monkeypatch.setattr(runtime, "remove_transport", lambda *args: pytest.fail("must retain copies")) + with pytest.raises(ValueError, match="size mismatch") as caught: + runtime.run_external(executable, {"x": np.ones(2, np.float32)}, out) + assert original_errors == [caught.value] + report = json.loads((out / "run.json").read_text(encoding="utf-8")) + assert not report["passed"] and report["status"] == "FAIL" and report["guest_completed"] + assert report["error"] == "ValueError: Weight transport size mismatch" + residual = report["weight_transport"] + assert residual["status"] == "FAIL" and residual["stage"] == "postcheck" + assert residual["retained_bytes"] == (None if stat_failure else 7) + assert residual["retained_observation"] == ("unavailable" if stat_failure else "complete") + assert residual["retained"] is (None if stat_failure else True) + assert residual["retained_observed_bytes"] == (0 if stat_failure else 7) + if stat_failure: + assert len(residual["retained_observation_errors"]) == 2 + assert any("residual stat failure" in error for error in report["cleanup_errors"]) + else: + assert not residual["retained_observation_errors"] + assert json.loads((out / "weight-transport/transport.json").read_text(encoding="utf-8")) == residual + assert (out / "weight-transport/weights-00000.bin").read_bytes() == original_weights[:3] + assert (out / "weight-transport/weights-00001.bin").read_bytes() == original_weights[4:] + assert (bundle / "weights.bin").read_bytes() == original_weights + + +@pytest.mark.parametrize("failure", ["report_stat", "rss_report"]) +@pytest.mark.parametrize("has_primary", [False, True], ids=["completed", "primary_error"]) +def test_cleanup_observation_failure_preserves_error_and_finishes_resource_cleanup( + tmp_path, monkeypatch, failure, has_primary): + """Fault injection for cleanup ownership/reporting; no guest arithmetic.""" + import numpy as np + + executable = _unlaunchable_executable(tmp_path) + out = tmp_path / "run" + events = [] + cause = ValueError("primary QMP stop failure") + + class Process: + returncode = None + pid = 1234 + + def poll(self): + return self.returncode + + def wait(self, timeout): + events.append("process-wait") + self.returncode = 0 + return 0 + + class QMP: + def __init__(self, *args): + pass + + def command(self, command, *args): + if command == "stop" and has_primary: + raise cause + if command == "human-monitor-command": + (out / "output.bin").write_bytes(bytes(4)) + return "" + return {} + + def close(self): + events.append("qmp-close") + + class Monitor: + def __init__(self, *args): + pass + + def start(self): + pass + + def close(self): + events.append("monitor-close") + + def report(self): + events.append("monitor-report") + if failure == "rss_report": + raise OSError("injected RSS report failure") + return {} + + def launch(*args, cwd, **kwargs): + (Path(cwd) / "uart.bin").write_bytes(runtime.FRAME.pack(runtime.MAGIC, 0, 4)) + return Process() + + stat = Path.stat + + def inaccessible_report(path, *args, **kwargs): + if path == out / "weight-transport/transport.json": + raise PermissionError("injected report path stat failure") + return stat(path, *args, **kwargs) + + monkeypatch.setattr(runtime.subprocess, "Popen", launch) + monkeypatch.setattr(runtime, "QMP", QMP) + monkeypatch.setattr(runtime, "_QemuMemoryMonitor", Monitor) + monkeypatch.setattr(runtime, "_windows_job", lambda process: ( + SimpleNamespace(CloseHandle=lambda handle: events.append("job-close") or 1), 99)) + monkeypatch.setattr(runtime, "_stop_process_tree", lambda process, job: events.append("tree-kill")) + if failure == "report_stat": + monkeypatch.setattr(Path, "stat", inaccessible_report) + with pytest.raises(ValueError if has_primary else RuntimeError) as caught: + runtime.run_external(executable, {"x": np.ones(1, np.float32)}, out) + if has_primary: + assert caught.value is cause + assert "tree-kill" in events + else: + assert "Runtime cleanup failed" in str(caught.value) + assert all(event in events for event in ["qmp-close", "monitor-close", "monitor-report", + "process-wait", "job-close"]) + report = json.loads((out / "run.json").read_text(encoding="utf-8")) + assert not report["passed"] and report["status"] == "FAIL" + expected = "report path stat failure" if failure == "report_stat" else "RSS report failure" + assert any(expected in error for error in report["cleanup_errors"]) + if has_primary: + assert report["error"] == "ValueError: primary QMP stop failure" + # Transport observation/reporting is attempted even after monitor failure. + assert report["weight_transport"]["retained_observation"] == "complete" + assert report["weight_transport"]["retained_bytes"] == 0 diff --git a/tests/test_riscv_process_cleanup.py b/tests/test_riscv_process_cleanup.py new file mode 100644 index 0000000..929f9d9 --- /dev/null +++ b/tests/test_riscv_process_cleanup.py @@ -0,0 +1,163 @@ +"""Compiler process setup failures must remain bounded and preserve their cause.""" +import io +import subprocess +import sys +import threading +import time +from types import SimpleNamespace + +import pytest + +from scratchv.runtime import riscv_tensor as runtime + + +class Process: + def __init__(self): + self.returncode = None + self.stdout, self.stderr = io.BytesIO(), io.BytesIO() + self.events = [] + + def __enter__(self): + return self + + def __exit__(self, *args): + # Real Popen.__exit__ waits without a deadline. Record instead of hanging + # this test when exercising the pre-fix job-setup exception path. + self.events.append(("context_exit", self.returncode)) + + def poll(self): + return self.returncode + + def communicate(self, timeout=None): + assert timeout is not None + self.events.append(("communicate", timeout)) + self.returncode = 0 + return b"out", b"err" + + def kill(self): + self.events.append(("kill",)) + self.returncode = -9 + + def wait(self, timeout=None): + assert timeout is not None + self.events.append(("wait", timeout)) + return self.returncode + + +def test_job_setup_exception_terminates_child_without_context_wait(monkeypatch): + process = Process() + failure = OSError("Job setup failed") + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: process) + def no_job(child): + raise failure + monkeypatch.setattr(runtime, "_windows_job", no_job) + monkeypatch.setattr(runtime, "_stop_process_tree", lambda child, job: child.kill()) + with pytest.raises(OSError) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=0.1) + assert caught.value is failure + assert ("kill",) in process.events + assert not any(event[0] == "context_exit" for event in process.events) + assert process.stdout.closed and process.stderr.closed + + +def test_cleanup_failures_preserve_primary_and_try_remaining_resources(monkeypatch): + process = Process() + class OrderedPipe(io.BytesIO): + def close(self): + process.events.append(("close-pipe",)) + super().close() + process.stdout, process.stderr = OrderedPipe(), OrderedPipe() + failure = KeyboardInterrupt("original cancellation") + def broken_communicate(timeout=None): + process.events.append(("communicate", timeout)) + raise failure + process.communicate = broken_communicate + def broken_tree(child, job): + process.events.append(("stop-tree",)) + raise OSError("tree kill failed") + kernel = SimpleNamespace(CloseHandle=lambda handle: process.events.append(("close-job",)) or 1) + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(runtime, "_windows_job", lambda child: (kernel, 42)) + monkeypatch.setattr(runtime, "_stop_process_tree", broken_tree) + with pytest.raises(KeyboardInterrupt) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=0.1) + assert caught.value is failure + assert ("kill",) in process.events and ("close-job",) in process.events + assert process.stdout.closed and process.stderr.closed + assert process.events.index(("close-job",)) < process.events.index(("close-pipe",)) + assert any("tree kill failed" in note for note in failure.__notes__) + + +def test_timeout_keeps_complete_drained_logs(monkeypatch): + process = Process() + calls = [] + def communicate(timeout=None): + calls.append(timeout) + if len(calls) == 1: + raise subprocess.TimeoutExpired(["compiler"], timeout, output=b"partial") + process.returncode = -9 + return b"complete stdout", b"complete stderr" + process.communicate = communicate + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(runtime, "_windows_job", lambda child: None) + monkeypatch.setattr(runtime, "_stop_process_tree", lambda child, job: child.kill()) + with pytest.raises(subprocess.TimeoutExpired) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=0.1) + assert caught.value.output == b"complete stdout" + assert caught.value.stderr == b"complete stderr" + assert all(timeout is not None for timeout in calls) + + +def test_real_child_is_reaped_when_job_setup_raises(monkeypatch, tmp_path): + processes = [] + def no_job(process): + processes.append(process) + raise OSError("injected Job setup failure") + monkeypatch.setattr(runtime, "_windows_job", no_job) + with pytest.raises(OSError, match="injected Job"): + runtime._run_process([sys.executable, "-B", "-c", "import time;time.sleep(60)"], + cwd=tmp_path, timeout=0.1) + assert len(processes) == 1 and processes[0].poll() is not None + + +def test_windows_blocked_reader_close_cannot_stall_cleanup(monkeypatch): + process = Process() + release = threading.Event() + class BlockedPipe(io.BytesIO): + def close(self): + release.wait(timeout=3) + super().close() + process.stdout, process.stderr = BlockedPipe(), BlockedPipe() + def timeout(timeout=None): + raise subprocess.TimeoutExpired(["compiler"], timeout, output=b"partial") + process.communicate = timeout + monkeypatch.setattr(runtime, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(runtime, "_windows_job", lambda child: None) + monkeypatch.setattr(runtime, "_stop_process_tree", lambda child, job: child.kill()) + started = time.monotonic() + try: + with pytest.raises(subprocess.TimeoutExpired) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=0.1) + assert time.monotonic() - started < 2 + assert any("Pipe close is pending" in note for note in caught.value.__notes__) + finally: + release.set() + + +def test_popen_failure_restores_posix_handler_without_touching_a_child(monkeypatch): + events = [] + failure = OSError("compiler launch failed") + fake_signal = SimpleNamespace(SIGTERM=15, SIGKILL=9, getsignal=lambda sig: "original", + signal=lambda sig, handler: events.append(("signal", handler))) + monkeypatch.setattr(runtime, "os", SimpleNamespace(name="posix")) + monkeypatch.setattr(runtime, "signal", fake_signal) + def launch(*args, **kwargs): + events.append(("launch",)) + raise failure + monkeypatch.setattr(runtime.subprocess, "Popen", launch) + monkeypatch.setattr(runtime, "_windows_job", lambda child: pytest.fail("no child exists")) + with pytest.raises(OSError) as caught: + runtime._run_process(["compiler"], cwd=None, timeout=1) + assert caught.value is failure + assert events == [("signal", runtime._terminate_invocation), ("launch",), ("signal", "original")] diff --git a/tests/test_riscv_weight_transport.py b/tests/test_riscv_weight_transport.py new file mode 100644 index 0000000..ec477a3 --- /dev/null +++ b/tests/test_riscv_weight_transport.py @@ -0,0 +1,427 @@ +"""Small-threshold tests exercise the same bounded loader route as full weights.""" +import hashlib +import json +import os +from pathlib import Path +import stat + +import numpy as np +import pytest + +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import Value +from scratchv.runtime import riscv_external as runtime +from scratchv.runtime import weight_transport as transport +from scratchv.runtime.riscv_tensor import discover_toolchain +from scratchv.runtime.weight_bundle import write_weight_bundle + + +def source_bundle(tmp_path, data): + source = tmp_path / "weights.bin" + source.write_bytes(data) + return source, {"total_bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + + +@pytest.mark.parametrize("size", [0, 1, 31, 32, 33, 64, 65]) +def test_chunks_cover_exact_contiguous_bytes_and_hashes(tmp_path, size): + data = bytes(range(size)) + source, manifest = source_bundle(tmp_path, data) + report, out = {}, tmp_path / "transport" + transport.stage_transport(source, manifest, out, report, chunk_bytes=32) + assert report["status"] == "PASS" and report["total_bytes"] == size + assert report["copied_bytes"] == report["retained_bytes"] == size + assert len(report["files"]) == (size + 31) // 32 + assert b"".join((out / row["file"]).read_bytes() for row in report["files"]) == data + assert [row["offset"] for row in report["files"]] == list(range(0, size, 32)) + assert all(row["nbytes"] <= 32 for row in report["files"]) + transport.validate_transport(out, report) + transport.remove_transport(out, report) + assert not list(out.glob("*.bin")) + saved = json.loads((out / "transport.json").read_text()) + assert not saved["retained"] and saved["retained_bytes"] == 0 + assert saved["sha256"] == manifest["sha256"] + assert source.read_bytes() == data + + +@pytest.mark.parametrize("limit", [0, -1, True, 512*1024*1024+1]) +def test_chunk_bound_cannot_exceed_safe_transport_limit(tmp_path, limit): + source, manifest = source_bundle(tmp_path, b"data") + with pytest.raises(ValueError, match="512 MiB"): + transport.stage_transport(source, manifest, tmp_path / "out", {}, chunk_bytes=limit) + + +def test_source_hash_mismatch_keeps_copy_and_fail_manifest(tmp_path): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + manifest["sha256"] = "0" * 64 + report, out = {}, tmp_path / "out" + with pytest.raises(ValueError, match="Source weight bundle hash"): + transport.stage_transport(source, manifest, out, report, chunk_bytes=4) + saved = json.loads((out / "transport.json").read_text()) + assert saved["status"] == "FAIL" and saved["retained_bytes"] == 8 + assert len(list(out.glob("*.bin"))) == 2 + + +@pytest.mark.parametrize("damage", ["same-size", "truncate", "extra", "reorder", "whole-hash"]) +def test_transport_damage_is_rejected(tmp_path, damage): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=4) + first = out / report["files"][0]["file"] + if damage == "same-size": + first.write_bytes(b"wxyz") + elif damage == "truncate": + first.write_bytes(b"abc") + elif damage == "extra": + first.write_bytes(b"abcde") + elif damage == "reorder": + report["files"].reverse() + else: + report["sha256"] = "0" * 64 + with pytest.raises(ValueError): + transport.validate_transport(out, report) + + +def test_partial_copy_failure_retains_bounded_progress(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + original_open = Path.open + monkeypatch.setattr(transport, "COPY_BUFFER_BYTES", 4) + class FailingWriter: + def __init__(self, stream): + self.stream, self.writes = stream, 0 + def __enter__(self): + return self + def __exit__(self, *args): + self.stream.close() + def fileno(self): + return self.stream.fileno() + def write(self, value): + self.writes += 1 + if self.writes == 2: + raise OSError("injected disk full") + return self.stream.write(value) + def opened(path, *args, **kwargs): + stream = original_open(path, *args, **kwargs) + return FailingWriter(stream) if path.name == "weights-00000.bin" else stream + monkeypatch.setattr(Path, "open", opened) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError, match="disk full"): + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + saved = json.loads((out / "transport.json").read_text()) + assert saved["status"] == "FAIL" and saved["copied_bytes"] == 4 + assert saved["files"][0]["written_bytes"] == 4 + assert (out / "weights-00000.bin").stat().st_size == 4 + + +def partial_writer(monkeypatch, *, raise_after_write, fail_observation=False): + """Actually persist a prefix, then return a short count or raise the cause.""" + original_open, original_lstat = Path.open, Path.lstat + cause = OSError("injected error after partial write") + class PartialWriter: + def __init__(self, stream): + self.stream = stream + def __enter__(self): + return self + def __exit__(self, *args): + self.stream.close() + def fileno(self): + return self.stream.fileno() + def write(self, value): + count = self.stream.write(value[:3]) + if raise_after_write: + raise cause + return count + def opened(path, *args, **kwargs): + stream = original_open(path, *args, **kwargs) + if path.name == "weights-00000.bin" and args and args[0] == "xb": + return PartialWriter(stream) + return stream + monkeypatch.setattr(Path, "open", opened) + if fail_observation: + def lstat(path, *args, **kwargs): + if path.name == "weights-00000.bin": + raise PermissionError("injected residual stat failure") + return original_lstat(path, *args, **kwargs) + monkeypatch.setattr(Path, "lstat", lstat) + return cause + + +@pytest.mark.parametrize("raise_after_write", [False, True], ids=["short-count", "write-raises"]) +def test_actual_partial_write_residual_is_measured(tmp_path, monkeypatch, raise_after_write): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + cause = partial_writer(monkeypatch, raise_after_write=raise_after_write) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError) as caught: + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + if raise_after_write: + assert caught.value is cause + else: + assert "Short weight transport write" in str(caught.value) + assert (out / "weights-00000.bin").read_bytes() == b"abc" + saved = json.loads((out / "transport.json").read_text()) + assert saved["status"] == "FAIL" and saved["copied_bytes"] == 0 + assert saved["retained_bytes"] == 3 and saved["retained"] is True + assert saved["retained_observation"] == "complete" + assert saved["retained_files"] == [{"file": "weights-00000.bin", "nbytes": 3}] + assert source.read_bytes() == b"abcdefgh" + + +def test_residual_stat_failure_is_unknown_and_preserves_write_error(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + cause = partial_writer(monkeypatch, raise_after_write=True, fail_observation=True) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError) as caught: + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + assert caught.value is cause + assert report["status"] == "FAIL" and report["retained_bytes"] is None + assert report["retained"] is None and report["retained_observed_bytes"] == 0 + assert report["retained_observation"] == "unavailable" + assert any("residual stat failure" in error for error in report["retained_observation_errors"]) + assert any("residual stat failure" in note for note in cause.__notes__) + assert json.loads((out / "transport.json").read_text())["retained_bytes"] is None + assert (out / "weights-00000.bin").read_bytes() == b"abc" + + +def test_residual_observation_failure_after_copy_is_not_pass(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + validate, lstat = transport.validate_transport, Path.lstat + ready = False + def validated(directory, report): + nonlocal ready + elapsed = validate(directory, report) + ready = True + return elapsed + def observed(path, *args, **kwargs): + if ready and path.name == "weights-00000.bin": + raise PermissionError("injected final observation failure") + return lstat(path, *args, **kwargs) + monkeypatch.setattr(transport, "validate_transport", validated) + monkeypatch.setattr(Path, "lstat", observed) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError, match="observation"): + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + saved = json.loads((out / "transport.json").read_text()) + assert saved["status"] == "FAIL" and saved["retained_bytes"] is None + + +def test_uncreated_foreign_file_is_neither_counted_nor_removed(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + opened = Path.open + def collision(path, *args, **kwargs): + if path.name == "weights-00000.bin" and args and args[0] == "xb": + with opened(path, "xb") as stream: + stream.write(b"foreign") + return opened(path, *args, **kwargs) + monkeypatch.setattr(Path, "open", collision) + report, out = {}, tmp_path / "out" + with pytest.raises(FileExistsError): + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + assert report["retained_bytes"] == 0 and report["retained"] is False + with pytest.raises(ValueError, match="created|owned"): + transport.remove_transport(out, report) + assert (out / "weights-00000.bin").read_bytes() == b"foreign" + + +def test_replaced_file_is_not_removed_as_owned_copy(tmp_path): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=4) + replaced = out / "weights-00001.bin" + replaced.rename(out / "original-owned.bin") + replaced.write_bytes(b"foreign") + with pytest.raises(ValueError, match="identity|owned"): + transport.remove_transport(out, report) + assert replaced.read_bytes() == b"foreign" + assert (out / "weights-00000.bin").read_bytes() == b"abcd" + + +def test_partial_residual_observation_reports_known_bytes_separately(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=4) + original = Path.lstat + def lstat(path, *args, **kwargs): + if path.name == "weights-00000.bin": + raise PermissionError("one file unavailable") + return original(path, *args, **kwargs) + monkeypatch.setattr(Path, "lstat", lstat) + with pytest.raises(OSError, match="observation"): + transport._observe_retained(out, report) + assert report["retained_bytes"] is None and report["retained"] is True + assert report["retained_observed_bytes"] == 4 + assert report["retained_observation"] == "partial" + assert report["retained_files"][0]["nbytes"] is None + assert report["retained_files"][1] == {"file": "weights-00001.bin", "nbytes": 4} + + +def test_report_write_failure_does_not_replace_partial_write_error(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + cause = partial_writer(monkeypatch, raise_after_write=True) + def failed_report(*args, **kwargs): + raise OSError("injected evidence write failure") + monkeypatch.setattr(transport, "save_transport_report", failed_report) + report, out = {}, tmp_path / "out" + with pytest.raises(OSError) as caught: + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + assert caught.value is cause + assert report["retained_bytes"] == 3 + assert any("evidence write failure" in note for note in cause.__notes__) + assert "evidence write failure" in report["report_error"] + + +def test_link_replacement_is_not_followed_or_removed(tmp_path): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + path = out / "weights-00000.bin" + path.rename(out / "original-owned.bin") + target = tmp_path / "foreign.bin" + target.write_bytes(b"foreign") + try: + path.symlink_to(target) + except OSError as exc: + pytest.skip(f"Host cannot create test symlink: {exc}") + with pytest.raises(ValueError, match="escaped|link"): + transport.remove_transport(out, report) + assert path.is_symlink() and target.read_bytes() == b"foreign" + assert report["retained_bytes"] is None + assert report["retained_observation"] == "unavailable" + + +def test_link_observation_refuses_cleanup_without_symlink_privilege(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + lstat, unlink = Path.lstat, Path.unlink + def observed(path, *args, **kwargs): + info = lstat(path, *args, **kwargs) + if path.name == "weights-00000.bin": + fields = list(info) + fields[0] = stat.S_IFLNK | 0o777 + return os.stat_result(fields) + return info + def deleted(path, *args, **kwargs): + if path.name == "weights-00000.bin": + pytest.fail("A link observation must not reach unlink") + return unlink(path, *args, **kwargs) + monkeypatch.setattr(Path, "lstat", observed) + monkeypatch.setattr(Path, "unlink", deleted) + with pytest.raises(ValueError, match="escaped|link"): + transport.remove_transport(out, report) + assert report["retained_bytes"] is None + assert report["retained_observation"] == "unavailable" + assert (out / "weights-00000.bin").read_bytes() == b"abcdefgh" + + +def test_cleanup_directory_observation_failure_marks_residual_unknown(tmp_path, monkeypatch): + source, manifest = source_bundle(tmp_path, b"abcdefgh") + report, out = {}, tmp_path / "out" + transport.stage_transport(source, manifest, out, report, chunk_bytes=8) + reject = transport._reject_links + cause = PermissionError("injected directory observation failure") + def inaccessible(path): + if Path(path) == out: + raise cause + return reject(path) + monkeypatch.setattr(transport, "_reject_links", inaccessible) + with pytest.raises(PermissionError) as caught: + transport.remove_transport(out, report) + assert caught.value is cause + assert report["status"] == "FAIL" and report["retained_bytes"] is None + assert report["retained_observation"] == "unavailable" + assert (out / "weights-00000.bin").read_bytes() == b"abcdefgh" + + +def test_staging_error_is_inside_runtime_failure_report(tmp_path, monkeypatch): + from tests.test_riscv_external import _stub_executable + executable = _stub_executable(tmp_path) + def failure(source, manifest, directory, report): + report.update(status="FAIL", stage="copy", copied_bytes=0) + raise OSError("injected staging error") + monkeypatch.setattr(runtime, "stage_transport", failure) + monkeypatch.setattr(runtime.subprocess, "Popen", lambda *args, **kwargs: pytest.fail("must not launch")) + out = tmp_path / "run" + with pytest.raises(OSError, match="staging error"): + runtime.run_external(executable, {"x": np.zeros(1, np.float32)}, out) + report = json.loads((out / "run.json").read_text()) + assert not report["passed"] and not report["guest_completed"] + assert report["stage"] == "weight_transport" and report["qemu_wall_s"] is None + assert report["weight_transport"]["copied_bytes"] == 0 + + +def executable(tmp_path, *, columns): + builder = IRBuilder() + x, w = Value("x", shape=(2, 4)), Value("w", shape=(4, columns)) + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(w) + builder.ret(builder.matmul(x, w)) + data = np.arange(4 * columns, dtype=np.float32).reshape(4, columns) / 16 + artifact = TensorCCodegen(builder.program, {"w": data}, constant_storage="external").generate() + write_weight_bundle(artifact.external_weights, artifact.external_initializers, tmp_path / "weights") + return runtime.build_external(artifact, tmp_path / "weights", tmp_path / "build", discover_toolchain()), data + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +@pytest.mark.parametrize("columns", [0, 2, 3, 4]) +@pytest.mark.parametrize("chunk_bytes", [31, 32], ids=["chunk31", "chunk32"]) +def test_real_qemu_contiguous_loader_segments_and_success_cleanup(tmp_path, monkeypatch, columns, chunk_bytes): + exe, weights = executable(tmp_path, columns=columns) + monkeypatch.setattr(transport, "CHUNK_BYTES", chunk_bytes) + x = np.arange(8, dtype=np.float32).reshape(2, 4) + out = tmp_path / "run" + output, report = runtime.run_external(exe, {"x": x}, out, timeout=30) + np.testing.assert_array_equal(output, x @ weights) + assert report["passed"] + evidence = report["weight_transport"] + assert len(evidence["files"]) == (weights.nbytes + chunk_bytes - 1) // chunk_bytes + assert not evidence["retained"] and evidence["retained_bytes"] == 0 + assert evidence["copy_elapsed_s"] >= 0 and evidence["post_validation_elapsed_s"] >= 0 + commands = [part for part in report["command"] if "weight-transport" in part] + assert len(commands) == len(evidence["files"]) + for row, command in zip(evidence["files"], commands): + assert f"addr=0x{exe.layout['weights_base'] + row['offset']:x}" in command + assert not list((out / "weight-transport").glob("*.bin")) + assert (exe.bundle_dir / "weights.bin").exists() + assert json.loads((out / "weight-transport/transport.json").read_text())["stage"] == "copies_removed" + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +def test_post_execution_transport_corruption_fails_and_retains_copies(tmp_path, monkeypatch): + exe, _ = executable(tmp_path, columns=3) + monkeypatch.setattr(transport, "CHUNK_BYTES", 32) + verify = runtime.validate_transport + def corrupt(directory, report): + path = directory / report["files"][0]["file"] + value = bytearray(path.read_bytes()) + value[0] ^= 1 + path.write_bytes(value) + return verify(directory, report) + monkeypatch.setattr(runtime, "validate_transport", corrupt) + out = tmp_path / "run" + with pytest.raises(ValueError, match="segment hash"): + runtime.run_external(exe, {"x": np.ones((2, 4), np.float32)}, out, timeout=30) + report = json.loads((out / "run.json").read_text()) + assert not report["passed"] and report["guest_completed"] + assert report["weight_transport"]["status"] == "FAIL" + assert len(list((out / "weight-transport").glob("*.bin"))) == 2 + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +def test_success_copy_cleanup_failure_is_not_a_pass(tmp_path, monkeypatch): + exe, _ = executable(tmp_path, columns=3) + monkeypatch.setattr(transport, "CHUNK_BYTES", 32) + unlink = Path.unlink + def failure(path, *args, **kwargs): + if path.name == "weights-00000.bin": + raise OSError("injected locked transport copy") + return unlink(path, *args, **kwargs) + monkeypatch.setattr(Path, "unlink", failure) + out = tmp_path / "run" + with pytest.raises(RuntimeError, match="cleanup failed"): + runtime.run_external(exe, {"x": np.ones((2, 4), np.float32)}, out, timeout=30) + report = json.loads((out / "run.json").read_text()) + assert not report["passed"] and report["guest_completed"] + assert any("locked transport" in error for error in report["cleanup_errors"]) + assert report["weight_transport"]["retained_bytes"] == 32 + assert len(list((out / "weight-transport").glob("*.bin"))) == 1 diff --git a/tests/test_tensor_boundary_contracts.py b/tests/test_tensor_boundary_contracts.py new file mode 100644 index 0000000..99fac81 --- /dev/null +++ b/tests/test_tensor_boundary_contracts.py @@ -0,0 +1,198 @@ +"""Static tensor boundaries shared by the ONNX frontend, IR and C ABI.""" + +import numpy as np +import onnx +import pytest +from onnx import TensorProto, helper, numpy_helper + +from scratchv.backend.tensor_c_codegen import TensorCCodegen +from scratchv.frontend.onnx_parser import ONNXParseError, ONNXParser +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Value +from scratchv.runtime.riscv_tensor import pack_inputs +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def _builder(*parameters): + builder = IRBuilder() + builder.new_function("main", list(parameters)) + builder.new_block("entry") + return builder + + +def _neg_model(tmp_path, shape): + graph = helper.make_graph( + [helper.make_node("Neg", ["x"], ["y"])], "neg", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, shape)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, shape)], + ) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 18)], ir_version=10) + path = tmp_path / "neg.onnx" + onnx.save(model, path) + return path + + +@pytest.mark.parametrize("shape", [["N"], [None], [1, "sequence"], None], + ids=["symbolic", "unknown-dimension", "mixed", "unknown-rank"]) +def test_parser_rejects_unresolved_input_shape_before_static_codegen(tmp_path, shape): + # An absent shape field means unknown rank, not a rank-zero scalar. + with pytest.raises(ONNXParseError, match="static|dynamic|unknown"): + ONNXParser().parse(str(_neg_model(tmp_path, shape))) + + +@pytest.mark.parametrize("weight_shape", [["N"], [None]]) +def test_initializer_backed_graph_input_uses_fixed_initializer_shape(tmp_path, weight_shape): + # Older exporters also list initializer weights in graph.input. ONNX + # inference does not refine that input's symbolic/unknown dimension, but + # this frontend binds it as a fixed initializer rather than a parameter. + weights = np.array([1.0, 2.0], dtype=np.float32) + graph = helper.make_graph( + [helper.make_node("Add", ["x", "weights"], ["y"])], "legacy-weights", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, [2]), + helper.make_tensor_value_info("weights", TensorProto.FLOAT, weight_shape)], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, [2])], + [numpy_helper.from_array(weights, name="weights")], + ) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 18)], ir_version=10) + onnx.checker.check_model(model, full_check=True) + path = tmp_path / "legacy-weights.onnx" + onnx.save(model, path) + parser = ONNXParser() + program = parser.parse(str(path)) + assert [value.name for value in program.functions[0].params] == ["x"] + assert program.global_values[0].shape == (2,) + data = np.array([3.0, 4.0], dtype=np.float32) + returned = IRInterpreter(program).run({"x": data}, initializers=parser.initializers).return_value + np.testing.assert_array_equal(returned, [4.0, 6.0]) + artifact = TensorCCodegen(program, initializers=parser.initializers, + constant_storage="external").generate() + assert artifact.output.shape == (2,) + assert [spec.name for spec in artifact.inputs] == ["x"] + assert artifact.external_weights[0].shape == (2,) + + +@pytest.mark.parametrize("shape", [(), (0,), (2, 0, 3), (1, 256)]) +def test_explicit_static_scalar_zero_and_l256_shapes_survive_all_boundaries(tmp_path, shape): + parser = ONNXParser() + program = parser.parse(str(_neg_model(tmp_path, shape))) + assert program.functions[0].params[0].shape == shape + values = np.arange(np.prod(shape, dtype=int), dtype=np.float32).reshape(shape) + result = IRInterpreter(program).run({"x": values}, initializers=parser.initializers).return_value + assert result.shape == shape + np.testing.assert_array_equal(result, -values) + artifact = TensorCCodegen(program, initializers=parser.initializers, + constant_storage="external").generate() + assert artifact.inputs[0].shape == artifact.output.shape == shape + assert pack_inputs(artifact.inputs, {"x": values}) == values.tobytes() + + +@pytest.mark.parametrize("dtype,numpy_dtype", [(DataType.FLOAT32, np.float32), + (DataType.INT64, np.int64)]) +@pytest.mark.parametrize("binding", ["input", "initializer"]) +@pytest.mark.parametrize("array_shape", [(1,), (2,), (1, 1)]) +def test_scalar_bindings_reject_non_scalar_arrays(dtype, numpy_dtype, binding, array_shape): + value = Value("x", dtype=dtype, shape=()) + builder = _builder(value) if binding == "input" else _builder() + if binding == "initializer": + builder.program.global_values.append(value) + builder.ret(value) + data = np.ones(array_shape, dtype=numpy_dtype) + with pytest.raises(IRExecutionError, match="ShapeError.*expected \\(\\)"): + IRInterpreter(builder.program).run( + {"x": data} if binding == "input" else {}, + initializers={"x": data} if binding == "initializer" else {}, + ) + + +@pytest.mark.parametrize("binding", ["input", "initializer"]) +def test_scalar_binding_and_inferred_tensor_intermediate_remain_supported(binding): + scalar, tensor = Value("scale", shape=()), Value("x", shape=(2, 3)) + builder = _builder(scalar, tensor) if binding == "input" else _builder(tensor) + if binding == "initializer": + builder.program.global_values.append(scalar) + product = builder.mul(tensor, scalar) + assert product.shape == () # Builder has not inferred this intermediate yet. + builder.ret(product) + value = np.array(2.0, dtype=np.float32) + value.setflags(write=False) + data = np.arange(6, dtype=np.float32).reshape(2, 3) + feed = {"x": data} + initializers = {} + if binding == "input": + feed["scale"] = value + else: + initializers["scale"] = value + result = IRInterpreter(builder.program).run(feed, initializers=initializers, + copy_initializers=False, + memory_mode="last_use").return_value + np.testing.assert_array_equal(result, data * value) + assert result.shape == (2, 3) + artifact = TensorCCodegen(builder.program, initializers=initializers, + constant_storage="external").generate() + assert artifact.output.shape == (2, 3) + assert product.shape == () # Neither consumer mutates the shared IR shape. + + +@pytest.mark.parametrize("shape", [(-1,), (True,), (1.5,), ("N",), None]) +@pytest.mark.parametrize("binding", ["input", "initializer"]) +def test_static_bindings_reject_invalid_shape_metadata(shape, binding): + value = Value("x", shape=shape) + builder = _builder(value) if binding == "input" else _builder() + if binding == "initializer": + builder.program.global_values.append(value) + builder.ret(value) + data = np.ones(1, dtype=np.float32) + with pytest.raises(IRExecutionError, match="ShapeError"): + IRInterpreter(builder.program).run( + {"x": data} if binding == "input" else {}, + initializers={"x": data} if binding == "initializer" else {}, + ) + + +@pytest.mark.parametrize("shape", [(), (3,), (-1,), (True,), None]) +def test_explicit_function_return_shape_is_a_static_contract(shape): + x = Value("x", shape=(2,)) + builder = _builder(x) + builder.current_func.returns = [Value("result", shape=shape)] + builder.ret(builder.neg(x)) + with pytest.raises(IRExecutionError, match="ShapeError"): + IRInterpreter(builder.program).run({"x": np.ones(2, dtype=np.float32)}) + + +@pytest.mark.parametrize("shape", [(), (2, 3)]) +@pytest.mark.parametrize("declared", [False, True]) +def test_matching_or_omitted_return_signature_preserves_inferred_intermediates(shape, declared): + x = Value("x", shape=shape) + builder = _builder(x) + result = builder.neg(x) + assert result.shape == () + builder.ret(result) + if declared: + builder.current_func.returns = [Value("result", shape=shape)] + data = np.ones(shape, dtype=np.float32) + returned = IRInterpreter(builder.program).run({"x": data}).return_value + np.testing.assert_array_equal(returned, -data) + assert returned.shape == shape + assert result.shape == () + + +@pytest.mark.parametrize("mode", ["wrong-dtype", "multiple-returns", "missing-value"]) +def test_explicit_return_dtype_and_count_are_rejected(mode): + x = Value("x", shape=(2,)) + builder = _builder(x) + dtype = DataType.INT64 if mode == "wrong-dtype" else DataType.FLOAT32 + builder.current_func.returns = [Value("declared", dtype=dtype, shape=(2,))] + if mode == "multiple-returns": + builder.current_func.returns.append(Value("second", shape=(2,))) + builder.ret(None if mode == "missing-value" else x) + with pytest.raises(IRExecutionError, match="InvalidProgram"): + IRInterpreter(builder.program).run({"x": np.ones(2, dtype=np.float32)}) + + +@pytest.mark.parametrize("reference_shape", [(), (3,)]) +def test_return_reference_shape_cannot_override_parameter_shape(reference_shape): + x = Value("x", shape=(2,)) + builder = _builder(x) + builder.ret(Value("x", shape=reference_shape)) + with pytest.raises(IRExecutionError, match="ShapeError"): + IRInterpreter(builder.program).run({"x": np.ones(2, dtype=np.float32)}) diff --git a/tests/test_tensor_c_external.py b/tests/test_tensor_c_external.py new file mode 100644 index 0000000..50cdb94 --- /dev/null +++ b/tests/test_tensor_c_external.py @@ -0,0 +1,466 @@ +"""External weights and caller workspace: execute the generated host C ABI. + +Set SCRATCHV_ZIG to a Zig executable, or install a host clang/gcc. Executable +checks skip explicitly if no compiler is available; graph checks always run. +""" + +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import ctypes +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import numpy as np +import pytest + +from scratchv.backend.tensor_c_codegen import TensorCCodegen, TensorCCodegenError +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Value + + +ROOT = Path(__file__).resolve().parents[1] +POINTERS = ctypes.POINTER(ctypes.c_void_p) + + +def graph(shape=(2, 3)): + x = Value("x", shape=shape) + w, bias = Value("weight", shape=(3, 4)), Value("bias", shape=(4,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.extend([w, bias]) + literal = builder.make_const(0.5) + builder.ret(builder.mul(builder.add(builder.matmul(x, w), bias), literal)) + bindings = { + "weight": np.arange(12, dtype=np.float32).reshape(3, 4) / 8, + "bias": np.array([0.25, -0.5, 0.75, -1.0], dtype=np.float32), + } + return builder.program, bindings, literal + + +@pytest.fixture(scope="module") +def host_cc(): + zig = os.environ.get("SCRATCHV_ZIG") + if zig: + return [zig, "cc"] + for name in ("clang", "gcc", "cc"): + found = shutil.which(name) + if found: + return [found] + pytest.skip("Host C execution requires clang/gcc or SCRATCHV_ZIG") + + +@contextmanager +def library(tmp_path, artifact, compiler): + tmp_path.mkdir(parents=True, exist_ok=True) + source = tmp_path / "model.c" + source.write_text(artifact.source, encoding="utf-8") + binary = tmp_path / ("model.dll" if sys.platform == "win32" else "model.so") + environment = dict(os.environ) + environment.setdefault("ZIG_GLOBAL_CACHE_DIR", str(ROOT / "output/zig-global-cache")) + environment.setdefault("ZIG_LOCAL_CACHE_DIR", str(tmp_path / "zig-cache")) + command = [*compiler, "-shared", "-O2", "-std=c11", *artifact.compile_flags] + if sys.platform != "win32": + command.append("-fPIC") + command += [str(source), "-o", str(binary), "-lm"] + result = subprocess.run(command, capture_output=True, text=True, + timeout=180, env=environment) + assert result.returncode == 0, result.stdout + result.stderr + loaded = ctypes.CDLL(str(binary)) + function = getattr(loaded, artifact.function_name) + function.restype = ctypes.c_int + function.argtypes = ([POINTERS, POINTERS, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p] + if artifact.constant_storage == "external" + else [POINTERS, ctypes.c_void_p]) + try: + yield function + finally: + if sys.platform == "win32": + import _ctypes + _ctypes.FreeLibrary(loaded._handle) + + +def pointers(arrays): + return (ctypes.c_void_p * len(arrays))(*(array.ctypes.data for array in arrays)) + + +def arguments(artifact, x): + # Model packaging may receive strided initializers: materialize C-order only + # here at the ABI boundary, never silently reinterpret their underlying bytes. + weights = [np.array(array, copy=True, order="C") for array in artifact.external_initializers] + workspace = np.empty(max(1, (artifact.workspace_bytes + 7) // 8), dtype=np.uint64) + output = np.full(artifact.output.shape, -999, dtype=artifact.output.numpy_dtype) + return weights, workspace, output, [pointers([x]), pointers(weights), + workspace.ctypes.data, artifact.workspace_bytes, + output.ctypes.data] + + +@pytest.mark.parametrize("shape", [(2, 3), (2, 1, 3)]) +@pytest.mark.parametrize("kernel_calls", [False, True]) +def test_external_matches_inline_and_numpy(tmp_path, host_cc, shape, kernel_calls): + program, bindings, _ = graph(shape) + inline = TensorCCodegen(program, bindings).generate() + external = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=kernel_calls).generate() + x = np.arange(np.prod(shape), dtype=np.float32).reshape(shape) / 4 + weights, workspace, output, args = arguments(external, x) + expected = (x @ bindings["weight"] + bindings["bias"]) * np.float32(0.5) + inline_output = np.empty_like(output) + with library(tmp_path / "inline", inline, host_cc) as run: + assert run(pointers([x]), inline_output.ctypes.data) == 0 + with library(tmp_path / "external", external, host_cc) as run: + assert run(*args) == 0 + np.testing.assert_array_equal(output, inline_output) + np.testing.assert_allclose(output, expected, rtol=0, atol=1e-6) + # Runtime data is not baked into the generated function or cached. + weights[0] *= np.float32(2) + assert run(*args) == 0 + np.testing.assert_allclose(output, (x @ weights[0] + weights[1]) * weights[2], + rtol=0, atol=1e-6) + assert external.workspace_bytes == inline.workspace_bytes + + +@pytest.mark.parametrize("kernel_calls", [False, True]) +def test_external_abi_rejects_malformed_buffers_before_writing(tmp_path, host_cc, kernel_calls): + program, bindings, _ = graph() + artifact = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=kernel_calls).generate() + x = np.arange(6, dtype=np.float32).reshape(2, 3) + weights, workspace, output, valid = arguments(artifact, x) + bad_weight = (ctypes.c_void_p * 3)(0, weights[1].ctypes.data, weights[2].ctypes.data) + unaligned_weight = (ctypes.c_void_p * 3)(weights[0].ctypes.data + 1, + weights[1].ctypes.data, weights[2].ctypes.data) + bad_input = (ctypes.c_void_p * 1)(x.ctypes.data + 1) + overflowing_input = (ctypes.c_void_p * 1)((1 << 64) - 4) + cases = [ + (0, None), (1, None), (2, None), (3, artifact.workspace_bytes - 1), + (2, workspace.ctypes.data + 1), (4, None), (4, output.ctypes.data + 1), + (0, bad_input), (0, overflowing_input), (1, bad_weight), (1, unaligned_weight), + (4, x.ctypes.data), (4, weights[0].ctypes.data), (4, workspace.ctypes.data), + (2, weights[0].ctypes.data), (2, x.ctypes.data), + (4, ctypes.addressof(valid[1])), + ] + original_input = x.copy() + with library(tmp_path, artifact, host_cc) as run: + for argument_index, replacement in cases: + args = list(valid) + args[argument_index] = replacement + assert run(*args) == 1, (argument_index, replacement) + assert np.all(output == -999) + np.testing.assert_array_equal(x, original_input) + assert run(*valid) == 0 + + +@pytest.mark.parametrize("kernel_calls", [False, True]) +def test_external_runtime_checks_finite_weights_and_scalar_literals(tmp_path, host_cc, kernel_calls): + program, bindings, _ = graph() + artifact = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=kernel_calls).generate() + x = np.ones((2, 3), np.float32) + weights, workspace, output, args = arguments(artifact, x) + with library(tmp_path, artifact, host_cc) as run: + weights[0][0, 0] = np.nan + assert run(*args) == 2 + weights[0][0, 0] = bindings["weight"][0, 0] + weights[2][()] = 0.75 + assert run(*args) == 2 + weights[2][()] = 0.5 + assert run(*args) == 0 + + +def test_external_runs_with_independent_workspaces(tmp_path, host_cc): + program, bindings, _ = graph() + artifact = TensorCCodegen(program, bindings, constant_storage="external").generate() + with library(tmp_path, artifact, host_cc) as run: + def execute(index): + x = np.full((2, 3), index / 8, np.float32) + weights, workspace, output, args = arguments(artifact, x) + assert run(*args) == 0 + np.testing.assert_array_equal(output, (x @ bindings["weight"] + bindings["bias"]) * 0.5) + with ThreadPoolExecutor(max_workers=4) as executor: + list(executor.map(execute, range(32))) + + +def test_external_return_input_needs_no_weights_or_workspace(tmp_path, host_cc): + x = Value("x", shape=(3,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.ret(x) + artifact = TensorCCodegen(builder.program, constant_storage="external").generate() + assert not artifact.external_weights and artifact.workspace_bytes == 0 + data, output = np.array([1, 2, 3], np.float32), np.zeros(3, np.float32) + with library(tmp_path, artifact, host_cc) as run: + assert run(pointers([data]), None, None, 0, output.ctypes.data) == 0 + np.testing.assert_array_equal(output, data) + + +@pytest.mark.parametrize("dtype,numpy_dtype", [(DataType.FLOAT32, np.float32), + (DataType.INT32, np.int32), + (DataType.INT64, np.int64)]) +def test_return_only_external_literal_and_zero_workspace(tmp_path, host_cc, dtype, numpy_dtype): + builder = IRBuilder() + builder.new_function("main", []) + builder.new_block("entry") + builder.ret(builder.make_const(3, dtype)) + artifact = TensorCCodegen(builder.program, constant_storage="external").generate() + assert artifact.workspace_bytes == 0 + weight = np.array(3, dtype=numpy_dtype) + output = np.array(0, dtype=numpy_dtype) + with library(tmp_path, artifact, host_cc) as run: + assert run(None, pointers([weight]), None, 0, output.ctypes.data) == 0 + assert output.item() == 3 + weight[()] = 4 + assert run(None, pointers([weight]), None, 0, output.ctypes.data) == 2 + + +def test_external_preserves_initializer_order_and_storage_identity(): + program, bindings, literal = graph() + bindings["weight"] = np.arange(24, dtype=np.float32).reshape(3, 8)[:, ::2] + before = program.dump() + codegen = TensorCCodegen(program, bindings, constant_storage="external") + first, second = codegen.generate(), codegen.generate() + assert first == second + assert program.dump() == before + assert [spec.name for spec in first.external_weights] == ["weight", "bias", literal.name] + assert first.external_initializers[0] is bindings["weight"] + assert first.external_initializers[1] is bindings["bias"] + assert first.external_initializers[2].shape == () + assert "array(" not in repr(first) + assert "sv_arena" not in first.source + assert "static const float" not in first.source + assert "scratchv_run_external" in first.source + + +def test_inline_default_is_unchanged_by_opt_in(): + program, bindings, _ = graph() + default = TensorCCodegen(program, bindings).generate() + explicit = TensorCCodegen(program, bindings, constant_storage="inline").generate() + assert default == explicit + assert default.function_name == "scratchv_run" + assert default.constant_storage == "inline" + assert not default.external_weights and not default.external_initializers + assert "static const float" in default.source + assert "sv_arena" in default.source + assert "scratchv_run_external" not in default.source + assert default.kernel_calls is False + + +def test_kernel_calls_deduplicate_operands_and_keep_error_status(tmp_path, host_cc): + x = Value("x", shape=(2,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + squared = builder.mul(x, x) + builder.ret(builder.sqrt(builder.sub(squared, builder.make_const(1.0)))) + model = TensorCCodegen(builder.program, constant_storage="external", kernel_calls=True).generate() + assert model.source.count("static __attribute__((noinline)) int sv_kernel_") == 3 + input_data = np.array([2, 3], np.float32) + weights, workspace, output, args = arguments(model, input_data) + with library(tmp_path, model, host_cc) as run: + assert run(*args) == 0 + np.testing.assert_array_equal(output, np.sqrt(input_data * input_data - np.float32(1))) + input_data[0] = 0 + assert run(*args) == 2 + + +def test_kernel_calls_propagate_gather_bounds(tmp_path, host_cc): + x, indexes = Value("x", shape=(2,)), Value("indexes", DataType.INT64, shape=(1,)) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(indexes) + builder.ret(builder.gather(x, indexes)) + model = TensorCCodegen(builder.program, {"indexes": np.array([0], np.int64)}, + constant_storage="external", kernel_calls=True).generate() + input_data = np.array([2, 3], np.float32) + weights, workspace, output, args = arguments(model, input_data) + with library(tmp_path, model, host_cc) as run: + assert run(*args) == 0 and output[0] == 2 + weights[0][0] = 2 + assert run(*args) == 3 + + +def test_kernel_calls_are_explicit_external_opt_in(): + program, bindings, _ = graph() + with pytest.raises(TensorCCodegenError, match="requires external"): + TensorCCodegen(program, bindings, kernel_calls=True).generate() + with pytest.raises(TensorCCodegenError, match="boolean"): + TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=1).generate() + default = TensorCCodegen(program, bindings, constant_storage="external").generate() + explicit = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=False).generate() + assert default.source == explicit.source and not default.kernel_calls + assert default.matmul_policy == "sequential" + + +def matmul_experiment(left, right): + """Use identical seeded FP32 operands for both C policies and ORT.""" + x, weight = Value("x", shape=left), Value("weight", shape=right) + builder = IRBuilder() + builder.new_function("main", [x]) + builder.new_block("entry") + builder.program.global_values.append(weight) + builder.ret(builder.matmul(x, weight)) + generator = np.random.default_rng(4108) + data = generator.standard_normal(left).astype(np.float32) + weights = generator.standard_normal(right).astype(np.float32) + return builder.program, {"weight": weights}, data + + +def ort_matmul(data, weights): + onnx = pytest.importorskip("onnx") + ort = pytest.importorskip("onnxruntime") + output_shape = np.matmul(data, weights).shape + graph = onnx.helper.make_graph( + [onnx.helper.make_node("MatMul", ["x", "weight"], ["output"])], "matmul-policy", + [onnx.helper.make_tensor_value_info("x", onnx.TensorProto.FLOAT, data.shape)], + [onnx.helper.make_tensor_value_info("output", onnx.TensorProto.FLOAT, output_shape)], + [onnx.numpy_helper.from_array(weights, "weight")], + ) + model = onnx.helper.make_model(graph, opset_imports=[onnx.helper.make_opsetid("", 14)], ir_version=10) + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + session = ort.InferenceSession(model.SerializeToString(), options, providers=["CPUExecutionProvider"]) + return session.run(None, {"x": data})[0] + + +MATMUL_POLICY_CASES = [ + ((3, 127), (127, 5)), ((3, 128), (128, 7)), ((3, 129), (129, 5)), + ((3, 1024), (1024, 9)), ((2, 1, 3, 129), (1, 2, 129, 5)), + ((129,), (129,)), ((129,), (129, 5)), ((3, 129), (129,)), ((2, 0), (0, 3)), +] + + +@pytest.mark.parametrize("left,right", MATMUL_POLICY_CASES) +def test_blocked_fma_host_against_ort(tmp_path, host_cc, left, right): + program, bindings, data = matmul_experiment(left, right) + expected = ort_matmul(data, bindings["weight"]) + for policy in ("sequential", "blocked_fma"): + model = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=True, + matmul_policy=policy).generate() + weights, workspace, output, args = arguments(model, data) + with library(tmp_path / policy, model, host_cc) as run: + assert run(*args) == 0 + assert model.matmul_policy == policy + assert output.shape == expected.shape and output.dtype == expected.dtype + assert np.isfinite(output).all() + assert np.max(np.abs(output.astype(np.float64) - expected.astype(np.float64)), initial=0) < 1e-3 + + +def test_matmul_policy_is_explicit_external_experiment(): + program, bindings, _ = graph() + with pytest.raises(TensorCCodegenError, match="matmul_policy"): + TensorCCodegen(program, bindings, constant_storage="external", matmul_policy="unknown").generate() + with pytest.raises(TensorCCodegenError, match="require external"): + TensorCCodegen(program, bindings, matmul_policy="blocked_fma").generate() + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +@pytest.mark.parametrize("left,right", [((2, 127), (127, 5)), ((2, 128), (128, 7)), + ((2, 129), (129, 5)), ((2, 1024), (1024, 9))]) +def test_blocked_fma_rv64_against_ort(tmp_path, left, right): + from scratchv.runtime.riscv_external import build_external, run_external + from scratchv.runtime.riscv_tensor import discover_toolchain + from scratchv.runtime.weight_bundle import write_weight_bundle + program, bindings, data = matmul_experiment(left, right) + expected = ort_matmul(data, bindings["weight"]) + for policy in ("sequential", "blocked_fma"): + model = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=True, + matmul_policy=policy).generate() + root = tmp_path / policy + root.mkdir() + write_weight_bundle(model.external_weights, model.external_initializers, root / "weights") + executable = build_external(model, root / "weights", root / "build", discover_toolchain()) + output, report = run_external(executable, {"x": data}, root / "run", timeout=30) + assert report["passed"] + assert np.max(np.abs(output.astype(np.float64) - expected.astype(np.float64)), initial=0) < 1e-3 + + +@pytest.mark.skipif(not os.environ.get("SCRATCHV_QEMU"), reason="requires explicit SCRATCHV_QEMU/CC") +def test_kernel_calls_rv64_matches_monolithic(tmp_path): + from scratchv.runtime.riscv_external import build_external, run_external + from scratchv.runtime.riscv_tensor import discover_toolchain + from scratchv.runtime.weight_bundle import write_weight_bundle + program, bindings, _ = graph() + x = np.arange(6, dtype=np.float32).reshape(2, 3) / 4 + actuals = [] + for split in (False, True): + artifact = TensorCCodegen(program, bindings, constant_storage="external", kernel_calls=split).generate() + root = tmp_path / str(split) + root.mkdir() + write_weight_bundle(artifact.external_weights, artifact.external_initializers, root / "weights") + executable = build_external(artifact, root / "weights", root / "build", discover_toolchain()) + output, report = run_external(executable, {"x": x}, root / "run", timeout=30) + assert report["passed"] + actuals.append(np.array(output)) + np.testing.assert_array_equal(actuals[0].view(np.uint32), actuals[1].view(np.uint32)) + np.testing.assert_array_equal(actuals[0], (x @ bindings["weight"] + bindings["bias"]) * 0.5) + + +@pytest.mark.parametrize("failure", ["missing", "shape", "dtype", "nan", "infinity", "limit"]) +def test_external_preserves_initializer_validation(failure): + program, bindings, _ = graph() + kwargs = {} + if failure == "missing": + del bindings["weight"] + elif failure == "shape": + bindings["weight"] = bindings["weight"].reshape(4, 3) + elif failure == "dtype": + bindings["weight"] = bindings["weight"].astype(np.float64) + elif failure in ("nan", "infinity"): + bindings["weight"][2, 3] = np.nan if failure == "nan" else np.inf + else: + kwargs["max_constant_bytes"] = 1 + with pytest.raises(TensorCCodegenError): + TensorCCodegen(program, bindings, constant_storage="external", **kwargs).generate() + + +def test_external_large_strided_weight_is_scanned_including_final_chunk(): + count = 262144 + 17 + weight = Value("weight", shape=(count,)) + builder = IRBuilder() + builder.new_function("main", []) + builder.new_block("entry") + builder.program.global_values.append(weight) + builder.ret(weight) + data = np.ones(count * 2, np.float32)[::2] + codegen = TensorCCodegen(builder.program, {"weight": data}, constant_storage="external") + artifact = codegen.generate() + assert artifact.external_initializers[0] is data + assert len(artifact.source) < 10000 + data[-1] = np.nan + with pytest.raises(TensorCCodegenError, match="Nonfinite"): + codegen.generate() + + +def test_external_scalar_binding_preserves_signed_zero(tmp_path, host_cc): + value = Value("zero", is_constant=True, const_value=0.0) + builder = IRBuilder() + builder.new_function("main", []) + builder.new_block("entry") + builder.program.global_values.append(value) + builder.ret(value) + with pytest.raises(TensorCCodegenError, match="constant bits"): + TensorCCodegen(builder.program, {"zero": np.array(-0.0, np.float32)}, + constant_storage="external").generate() + artifact = TensorCCodegen(builder.program, constant_storage="external").generate() + negative_zero = np.array(-0.0, np.float32) + output = np.array(1.0, np.float32) + with library(tmp_path, artifact, host_cc) as run: + assert run(None, pointers([negative_zero]), None, 0, output.ctypes.data) == 2 + assert output == 1 + + +@pytest.mark.parametrize("storage", ["unknown", None, 1]) +def test_invalid_storage_mode(storage): + program, bindings, _ = graph() + with pytest.raises(TensorCCodegenError, match="constant_storage"): + TensorCCodegen(program, bindings, constant_storage=storage).generate() + + +@pytest.mark.parametrize("field", ["max_workspace_bytes", "max_constant_bytes"]) +@pytest.mark.parametrize("bound", [-1, True, 1 << 64]) +def test_memory_limits_fit_u64(field, bound): + program, bindings, _ = graph() + with pytest.raises(TensorCCodegenError, match="64-bit"): + TensorCCodegen(program, bindings, constant_storage="external", **{field: bound}).generate() diff --git a/tests/test_tensor_return_contract.py b/tests/test_tensor_return_contract.py new file mode 100644 index 0000000..9c6fea7 --- /dev/null +++ b/tests/test_tensor_return_contract.py @@ -0,0 +1,99 @@ +"""Return metadata is an ABI contract, not an alternative buffer interpretation.""" +import pytest +import numpy as np + +from scratchv.backend.tensor_c_codegen import TensorCCodegen, TensorCCodegenError +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Value +from scratchv.verification.ir_interpreter import IRExecutionError, IRInterpreter + + +def program_for(value, returned=None, declarations=()): + builder = IRBuilder() + function = builder.new_function("main", [value]) + builder.new_block("entry") + function.returns = list(declarations) + builder.ret(value if returned is None else returned) + return builder.program + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +@pytest.mark.parametrize("dtype", [DataType.INT32, DataType.INT64]) +def test_return_reference_cannot_reinterpret_defined_dtype(storage, dtype): + value = Value("x", DataType.FLOAT32, shape=(2,)) + returned = Value("x", dtype, shape=(2,)) + with pytest.raises(TensorCCodegenError, match="RETURN.*dtype"): + TensorCCodegen(program_for(value, returned), constant_storage=storage).generate() + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +@pytest.mark.parametrize("shape", [(3,), (1, 2), (), (True,), ("N",)]) +def test_return_reference_checks_known_input_shape(storage, shape): + value = Value("x", shape=(2,)) + returned = Value("x", shape=shape) + with pytest.raises(TensorCCodegenError, match="RETURN.*shape|static"): + TensorCCodegen(program_for(value, returned), constant_storage=storage).generate() + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +@pytest.mark.parametrize("declarations", [ + [Value("result", DataType.INT64, shape=(2,))], + [Value("result", shape=())], + [Value("result", shape=(1, 2))], + [Value("result", shape=(True,))], + [Value("result", shape=(2,)), Value("other", shape=(2,))], +]) +def test_function_return_signature_cannot_disagree_with_actual_output(storage, declarations): + value = Value("x", shape=(2,)) + with pytest.raises(TensorCCodegenError, match="return|RETURN|static"): + TensorCCodegen(program_for(value, declarations=declarations), constant_storage=storage).generate() + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +@pytest.mark.parametrize("shape", [(), (0,), (2, 3)]) +def test_explicit_return_signature_accepts_scalar_empty_and_tensor(storage, shape): + value = Value("x", shape=shape) + declaration = Value("public_result", shape=shape) + artifact = TensorCCodegen(program_for(value, declarations=[declaration]), constant_storage=storage).generate() + assert artifact.output.name == "x" + assert artifact.output.shape == shape + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +def test_builder_intermediate_return_keeps_inferred_shape(storage): + builder = IRBuilder() + builder.new_function("main", [Value("x", shape=(2, 3))]) + builder.new_block("entry") + result = builder.neg(builder.program.functions[0].params[0]) + assert result.shape == () # Builder default, not an explicit signature. + builder.ret(result) + artifact = TensorCCodegen(builder.program, constant_storage=storage).generate() + assert artifact.output.shape == (2, 3) + + +@pytest.mark.parametrize("storage", ["inline", "external"]) +def test_inferred_intermediate_return_rejects_explicit_wrong_shape(storage): + builder = IRBuilder() + builder.new_function("main", [Value("x", shape=(2, 3))]) + builder.new_block("entry") + result = builder.neg(builder.program.functions[0].params[0]) + builder.ret(Value(result.name, shape=(3, 2))) + with pytest.raises(TensorCCodegenError, match="RETURN.*shape"): + TensorCCodegen(builder.program, constant_storage=storage).generate() + + +@pytest.mark.parametrize("shape", [None, 0, False, ""]) +@pytest.mark.parametrize("consumer", ["inline", "external", "interpreter"]) +def test_only_empty_sequence_is_an_inferred_intermediate_shape(shape, consumer): + builder = IRBuilder() + x = Value("x", shape=(2,)) + builder.new_function("main", [x]) + builder.new_block("entry") + result = builder.neg(x) + builder.ret(Value(result.name, shape=shape)) + if consumer == "interpreter": + with pytest.raises(IRExecutionError, match="ShapeError"): + IRInterpreter(builder.program).run({"x": np.ones(2, dtype=np.float32)}) + else: + with pytest.raises(TensorCCodegenError, match="static"): + TensorCCodegen(builder.program, constant_storage=consumer).generate() diff --git a/tests/test_w1_qwen3_export.py b/tests/test_w1_qwen3_export.py index ef1faf3..9fa0db2 100644 --- a/tests/test_w1_qwen3_export.py +++ b/tests/test_w1_qwen3_export.py @@ -5,6 +5,7 @@ import stat import zipfile from argparse import Namespace +from pathlib import Path import numpy as np import onnx @@ -218,6 +219,117 @@ def test_dependency_mismatch_fails_before_model_load(monkeypatch): probe.environment() +@pytest.mark.parametrize("name", ["report.md", "report.json"]) +@pytest.mark.parametrize("failure", ["write", "close", "publish"]) +@pytest.mark.parametrize("numeric_passed", [False, True]) +def test_report_io_failure_cannot_leave_pass(artifact, tmp_path, monkeypatch, + name, failure, numeric_passed): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + if not numeric_passed: + def fail_ort(*args, **kwargs): + raise RuntimeError("original ORT failure") + monkeypatch.setattr(probe, "execute_ort", fail_ort) + output = tmp_path / "report-fault" + output.mkdir() + # Reusing this CLI's output directory must not retain an older success. + (output / "report.json").write_text('{"passed": true}', encoding="utf-8") + original_write, original_replace = Path.write_text, Path.replace + + def broken_write(path, content, *args, **kwargs): + if path == output / f".{name}.tmp" and failure in ("write", "close"): + if failure == "close": + original_write(path, content, *args, **kwargs) + raise OSError(f"injected {failure} failure") + return original_write(path, content, *args, **kwargs) + + def broken_replace(path, target): + if Path(target) == output / name and failure == "publish": + raise OSError("injected publish failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "write_text", broken_write) + monkeypatch.setattr(Path, "replace", broken_replace) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 1 + if name == "report.json": + assert not (output / "report.json").exists() + text = (output / "report.md").read_text(encoding="utf-8") + assert "Result: FAIL" in text + if not numeric_passed: + assert "original ORT failure" in text + else: + report = json.loads((output / "report.json").read_text(encoding="utf-8")) + assert report["passed"] is False and report["report_write_errors"] + if numeric_passed: + assert report["failed_stage"] == "report-write" + else: + assert report["failed_stage"] == "ort" + assert report["error"] == "RuntimeError: original ORT failure" + assert not list(output.glob(".report.*.tmp")) + + +def test_report_json_is_published_after_required_view(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-order" + original_replace = Path.replace + names = [] + + def record(path, target): + names.append(Path(target).name) + if Path(target).name == "report.json": + assert (output / "report.md").is_file() + assert not (output / "report.json").exists() + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", record) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 0 + assert names == ["report.md", "report.json"] + + +def test_transient_json_publish_error_retries_only_as_fail(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-retry" + original_replace = Path.replace + attempts = [] + + def fail_once(path, target): + if Path(target).name == "report.json": + attempts.append(json.loads(path.read_text(encoding="utf-8"))["passed"]) + if len(attempts) == 1: + raise OSError("transient publish failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", fail_once) + assert probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) == 1 + assert attempts == [True, False] + assert json.loads((output / "report.json").read_text(encoding="utf-8"))["passed"] is False + assert "Result: FAIL" in (output / "report.md").read_text(encoding="utf-8") + + +def test_interruption_does_not_leave_previous_success(artifact, tmp_path, monkeypatch): + directory, manifest = artifact + monkeypatch.setattr(probe, "MANIFEST", manifest) + monkeypatch.setattr(probe, "environment", lambda export=False: {"unit_test": True}) + output = tmp_path / "report-interrupted" + output.mkdir() + (output / "report.json").write_text('{"passed": true}', encoding="utf-8") + (output / "report.md").write_text("Result: PASS", encoding="utf-8") + + def interrupted(*args, **kwargs): + raise KeyboardInterrupt() + + monkeypatch.setattr(probe, "execute_ort", interrupted) + with pytest.raises(KeyboardInterrupt): + probe.main(["--model-dir", str(directory), "--output-dir", str(output)]) + assert not (output / "report.json").exists() + assert not (output / "report.md").exists() + + def test_source_fingerprints_report_missing_sources_honestly(tmp_path, monkeypatch): monkeypatch.setattr(probe, "ROOT", tmp_path) result = probe.source_fingerprints() diff --git a/tests/test_w3_actions_summary.py b/tests/test_w3_actions_summary.py new file mode 100644 index 0000000..c00aa87 --- /dev/null +++ b/tests/test_w3_actions_summary.py @@ -0,0 +1,188 @@ +"""W3 Actions pages must distinguish measured results, partial coverage and skips.""" +from copy import deepcopy +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from scripts.summarize_w3_ci import GATES, main, render +from scripts import summarize_w3_ci + +ROOT = Path(__file__).resolve().parents[1] + + +def save(folder, report): + folder.mkdir(parents=True, exist_ok=True) + (folder / "report.json").write_text(json.dumps(report), encoding="utf-8") + + +def metric(value, name): + return {"name": name, "passed": True, "max_abs": value, + "relative_l2": 2e-7, "cosine_similarity": 0.99999} + + +def full_report(**extra): + return {"gate": "numeric:ir-full-qwen3", "status": "PASS", "passed": True, + "coverage_complete": True, "fp32_mode": "reference", "atol": 1e-4, + "elapsed_seconds": 75.125, "required_cases": ["one", "two"], "selected_cases": ["one", "two"], + "cases": [{"name": name, "passed": True, "comparison": { + "logits": [metric(value, "logits.npy"), metric(value * 2, "diagnostic_logits.npy")], + "checkpoints": [metric(0.7, "layer_3.output")]}} + for name, value in [("one", 1e-6), ("two", 3e-6)]], **extra} + + +def test_full_key_metrics_are_visible_and_checkpoint_details_are_folded(tmp_path): + report = full_report() + before = deepcopy(report) + save(tmp_path, report) + text = render("full", tmp_path, "success") + visible = text.split("
", 1)[0] + assert "**PASS**" in visible and "75.125 s" in visible + assert "2/2 cases" in visible and "reference" in visible + assert format(3e-6, ".16e") in visible and format(6e-6, ".16e") in visible + assert format(0.7, ".16e") not in visible + assert "No full-model QEMU result" in visible + assert "w3-full-numeric-reports" in text and "
" in text + assert json.loads((tmp_path / "report.json").read_text()) == before + + +@pytest.mark.parametrize("outcome,expected", [("skipped", "NOT RUN"), ("", "NOT RUN"), ("cancelled", "CANCELLED")]) +def test_skipped_step_does_not_reuse_a_passing_report(tmp_path, outcome, expected): + save(tmp_path, full_report()) + text = render("full", tmp_path, outcome) + assert f"**{expected}**" in text and "PASS" not in text + assert "最大绝对误差" not in text + + +@pytest.mark.parametrize("outcome", ["success", "failure"]) +def test_missing_report_does_not_claim_pass(tmp_path, outcome): + text = render("full", tmp_path, outcome) + assert "MISSING REPORT" in text and "PASS" not in text and outcome in text + + +@pytest.mark.parametrize("invalid", ["{", "[]", '{"gate":"wrong"}', '{"gate":"numeric:ir-full-qwen3","cases":null}']) +def test_invalid_reports_do_not_crash_summary(tmp_path, invalid): + (tmp_path / "report.json").write_text(invalid) + text = render("full", tmp_path, "failure") + assert "INVALID REPORT" in text and "PASS" not in text + + +def test_failed_step_overrides_passing_report(tmp_path): + save(tmp_path, full_report()) + text = render("full", tmp_path, "failure") + assert "**FAIL (step did not succeed)**" in text + assert "**PASS**" not in text + + +def test_partial_coverage_shows_unexecuted_case_and_missing_metrics(tmp_path): + report = full_report(status="PARTIAL", passed=False, coverage_complete=False, selected_cases=["one"]) + report["cases"] = report["cases"][:1] + save(tmp_path, report) + text = render("full", tmp_path, "failure") + assert "**PARTIAL (not acceptance)**" in text + assert "1/2 cases recorded" in text and "NOT RUN" in text and "two" in text + assert "未记录 1/2" in text and "**PASS**" not in text + + +def test_claimed_pass_with_incomplete_coverage_is_not_pass(tmp_path): + save(tmp_path, full_report(coverage_complete=False)) + assert "**INCOMPLETE (not acceptance)**" in render("full", tmp_path, "success") + + +def test_preparation_lists_all_checks_and_never_claims_full_model_execution(tmp_path): + report = {"gate": "preparation:w3", "status": "FAIL", "passed": False, "elapsed_seconds": 32, + "gates": [{"name": "attention", "passed": False, "status": "FAIL", "elapsed_seconds": 1.5}]} + save(tmp_path, report) + save(tmp_path / "attention", {"gate": "unit:attention-backend", "status": "PASS", "passed": True, + "cases": [{"name": "one", "executions": [{"optimization": level, "qemu_vs_ort": metric(3e-6, "logits")} + for level in ("none", "all")]}]}) + # A stale medium child without a corresponding parent invocation is ignored. + save(tmp_path / "medium", {"gate": "w3-medium-host", "status": "PASS", "passed": True}) + text = render("preparation", tmp_path, "failure") + visible = text.split("
", 1)[0] + assert "NOT RUN by this job" in visible + assert "FAIL (parent validation)" in visible and "1 cases / 2 target executions recorded" in visible + for _, _, scope in GATES: + assert scope in visible + assert visible.count("NOT RUN") == 5 + assert "w3-preparation-reports" in text + + +def test_missing_child_report_cannot_leave_overall_pass(tmp_path): + save(tmp_path, {"gate": "preparation:w3", "status": "PASS", "passed": True, + "gates": [{"name": "medium", "status": "PASS", "passed": True}]}) + text = render("preparation", tmp_path, "success") + assert "**INCOMPLETE REPORTS (not acceptance)**" in text and "MISSING REPORT" in text + assert "**PASS**" not in text + + +def test_errors_are_escaped_and_arbitrary_machine_evidence_is_not_dumped(tmp_path): + report = full_report(status="FAIL", passed=False, error=""} + before = deepcopy(report) + write_reports(tmp_path, report) + assert json.loads((tmp_path / "report.json").read_text(encoding="utf-8")) == before + markdown = (tmp_path / "report.md").read_text(encoding="utf-8") + page = (tmp_path / "report.html").read_text(encoding="utf-8") + assert "Result: **FAIL**" in markdown + assert "通过(1/1)" in markdown and "总体结论" in markdown + assert "隔离检查未通过 1 项" in markdown + assert "
" in markdown and "
" in page + assert markdown.index("最大绝对误差") < markdown.index("
") + assert '"checkpoints"' not in markdown + assert "[完整机器报告](report.json)" in markdown + assert 'href="report.json"' in page + assert "