From e9271794ae0aa4cc6adfd3c2bf125864c6f08b78 Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:30:53 -0700 Subject: [PATCH] Require full WSL2 qualification for v0.11 --- .github/actionlint.yaml | 1 + .github/workflows/build.yml | 3 + .github/workflows/gpu.yml | 187 ++++++---- .github/workflows/release.yml | 2 +- docs/release-qualification.md | 27 +- docs/single-gpu-guide.md | 7 +- .../known-good-rtx4080-wsl2-cu130.json | 41 ++ .../known-good-rtx4080-wsl2-cu130.txt | 11 + scripts/check_known_good_environment.py | 2 +- scripts/promote_full_gpu_qualification.py | 250 +++++++++++++ scripts/run_v011_profile_qualification.py | 351 ++++++++++++++++++ src/miniverl/qualification.py | 29 ++ src/miniverl/release_assets.py | 36 +- tests/unit/test_gpu_qualification.py | 215 +++++++++++ tests/unit/test_known_good_environment.py | 10 + tests/unit/test_release_assets.py | 35 ++ tests/unit/test_release_workflow.py | 19 + tests/unit/test_v011_profile_qualification.py | 33 ++ 18 files changed, 1169 insertions(+), 90 deletions(-) create mode 100644 environments/known-good-rtx4080-wsl2-cu130.json create mode 100644 environments/known-good-rtx4080-wsl2-cu130.txt create mode 100644 scripts/run_v011_profile_qualification.py create mode 100644 tests/unit/test_v011_profile_qualification.py diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 68210b6..1ac9995 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -2,3 +2,4 @@ self-hosted-runner: labels: - cuda - rtx4080 + - wsl2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c1660b..cb9d8e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,6 +45,9 @@ jobs: "PROJECT_STATE.md", "environments/known-good-rtx4080-cu130.json", "environments/known-good-rtx4080-cu130.txt", + "environments/known-good-rtx4080-wsl2-cu130.json", + "environments/known-good-rtx4080-wsl2-cu130.txt", + "scripts/run_v011_profile_qualification.py", "LICENSE", "README.md", ): diff --git a/.github/workflows/gpu.yml b/.github/workflows/gpu.yml index 6a17fa8..2ba2db4 100644 --- a/.github/workflows/gpu.yml +++ b/.github/workflows/gpu.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: qualification_level: - description: "Release smoke, or all three canonical full workloads" + description: "Release smoke, or the complete v0.11 WSL2 qualification" type: choice required: true default: smoke @@ -62,23 +62,21 @@ jobs: retention-days: 90 qualify: - name: qualify exact candidate on RTX 4080 + name: qualify exact candidate on WSL2 RTX 4080 needs: build-candidate - runs-on: [self-hosted, cuda, rtx4080] + runs-on: [self-hosted, cuda, rtx4080, wsl2] timeout-minutes: 360 env: PYTHONUTF8: "1" PYTHONPATH: "" PYTHONHOME: "" - defaults: - run: - shell: pwsh steps: - name: Require a fresh workflow dispatch run: | - if ($env:GITHUB_RUN_ATTEMPT -ne "1") { - throw "GPU qualification cannot use a rerun attempt; start a new workflow_dispatch run." - } + if [ "$GITHUB_RUN_ATTEMPT" != "1" ]; then + echo "::error::GPU qualification cannot use a rerun attempt; start a new workflow_dispatch run." + exit 1 + fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -89,58 +87,72 @@ jobs: path: candidate - name: Verify the dedicated measured device run: | - $name = nvidia-smi --query-gpu=name --format=csv,noheader - if (($name | Measure-Object).Count -ne 1) { throw "exactly one visible GPU is required" } - if ($name.Trim() -ne "NVIDIA GeForce RTX 4080") { throw "unexpected GPU: $name" } + mapfile -t names < <(nvidia-smi --query-gpu=name --format=csv,noheader) + if [ "${#names[@]}" -ne 1 ]; then echo "exactly one visible GPU is required"; exit 1; fi + if [ "${names[0]}" != "NVIDIA GeForce RTX 4080" ]; then + echo "unexpected GPU: ${names[0]}" + exit 1 + fi + grep -qi microsoft /proc/sys/kernel/osrelease nvidia-smi - python --version + python3 --version + test -x "$HOME/.local/bin/uv" + "$HOME/.local/bin/uv" --version - name: Validate candidate before installation run: | - python -m venv .gpu-qualification-venv - $python = (Resolve-Path '.gpu-qualification-venv/Scripts/python.exe').Path - & $python -m pip install --upgrade pip - & $python -m pip install pydantic==2.13.4 PyYAML==6.0.3 - & $python scripts/build_release_candidate.py ` - --output candidate ` - --commit $env:GITHUB_SHA ` - --repository $env:GITHUB_REPOSITORY ` - --workflow-path .github/workflows/gpu.yml ` - --run-id $env:GITHUB_RUN_ID ` - --run-attempt $env:GITHUB_RUN_ATTEMPT ` + "$HOME/.local/bin/uv" venv --seed --python 3.12.13 .gpu-qualification-venv + qualification_python="$PWD/.gpu-qualification-venv/bin/python" + "$qualification_python" -m pip install --upgrade pip + "$qualification_python" -m pip install pydantic==2.13.4 PyYAML==6.0.3 + "$qualification_python" scripts/build_release_candidate.py \ + --output candidate \ + --commit "$GITHUB_SHA" \ + --repository "$GITHUB_REPOSITORY" \ + --workflow-path .github/workflows/gpu.yml \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ --check - $manifest = Get-Content -Raw -LiteralPath 'candidate/candidate-manifest.json' | ConvertFrom-Json - $wheel = (Resolve-Path (Join-Path 'candidate' $manifest.wheel.filename)).Path - "QUALIFICATION_WHEEL=$wheel" | Out-File -FilePath $env:GITHUB_ENV -Append - "QUALIFICATION_PYTHON=$python" | Out-File -FilePath $env:GITHUB_ENV -Append + "$qualification_python" - <<'PY' + import json, os + from pathlib import Path + manifest = json.loads(Path("candidate/candidate-manifest.json").read_text()) + wheel = (Path("candidate") / manifest["wheel"]["filename"]).resolve() + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as handle: + handle.write(f"QUALIFICATION_WHEEL={wheel}\n") + handle.write(f"QUALIFICATION_WHEEL_SHA256={manifest['wheel']['sha256']}\n") + handle.write(f"QUALIFICATION_PYTHON={Path('.gpu-qualification-venv/bin/python').resolve()}\n") + PY - name: Install the exact candidate wheel run: | - & $env:QUALIFICATION_PYTHON -m pip install 'torch==2.13.0+cu130' --index-url https://download.pytorch.org/whl/cu130 - & $env:QUALIFICATION_PYTHON -m pip install "$env:QUALIFICATION_WHEEL[dev,train,cuda]" --constraint environments/known-good-rtx4080-cu130.txt - (Resolve-Path '.gpu-qualification-venv/Scripts').Path | Out-File -FilePath $env:GITHUB_PATH -Append + "$QUALIFICATION_PYTHON" -m pip install 'torch==2.13.0+cu130' --index-url https://download.pytorch.org/whl/cu130 + "$QUALIFICATION_PYTHON" -m pip install \ + "${QUALIFICATION_WHEEL}[dev,train,cuda,rollout-vllm]" \ + --constraint environments/known-good-rtx4080-wsl2-cu130.txt + echo "$PWD/.gpu-qualification-venv/bin" >> "$GITHUB_PATH" - name: Run wheel-installed release smoke run: | - $offline = @() - if ('${{ inputs.offline }}' -eq 'true') { $offline = @('--offline') } - New-Item -ItemType Directory -Path qualification | Out-Null - Copy-Item -LiteralPath $env:QUALIFICATION_WHEEL -Destination qualification - & $env:QUALIFICATION_PYTHON scripts/run_gpu_qualification.py ` - --commit $env:GITHUB_SHA ` - --wheel (Join-Path 'qualification' (Split-Path $env:QUALIFICATION_WHEEL -Leaf)) ` - --candidate-manifest candidate/candidate-manifest.json ` - --known-good environments/known-good-rtx4080-cu130.json ` - --output qualification ` - --work qualification-work ` - @offline - $candidate = Get-Content -Raw candidate/candidate-manifest.json | ConvertFrom-Json - $manifestSha = (Get-FileHash -Algorithm SHA256 candidate/candidate-manifest.json).Hash.ToLower() - & $env:QUALIFICATION_PYTHON scripts/validate_gpu_qualification.py ` - qualification/qualification.json ` - --commit $env:GITHUB_SHA ` - --wheel-sha256 $candidate.wheel.sha256 ` - --candidate-manifest-sha256 $manifestSha ` + offline=() + if [ '${{ inputs.offline }}' = 'true' ]; then offline=(--offline); fi + mkdir qualification + cp -- "$QUALIFICATION_WHEEL" qualification/ + wheel_copy="qualification/$(basename "$QUALIFICATION_WHEEL")" + "$QUALIFICATION_PYTHON" scripts/run_gpu_qualification.py \ + --commit "$GITHUB_SHA" \ + --wheel "$wheel_copy" \ + --candidate-manifest candidate/candidate-manifest.json \ + --known-good environments/known-good-rtx4080-wsl2-cu130.json \ + --output qualification \ + --work qualification-work \ + "${offline[@]}" + manifest_sha=$(sha256sum candidate/candidate-manifest.json | cut -d' ' -f1) + "$QUALIFICATION_PYTHON" scripts/validate_gpu_qualification.py \ + qualification/qualification.json \ + --commit "$GITHUB_SHA" \ + --wheel-sha256 "$QUALIFICATION_WHEEL_SHA256" \ + --candidate-manifest-sha256 "$manifest_sha" \ --required-gpu-name 'NVIDIA GeForce RTX 4080' - name: Run GPU regression tests - run: '& $env:QUALIFICATION_PYTHON -m pytest -q -m gpu -ra' + run: '"$QUALIFICATION_PYTHON" -m pytest -q -m gpu -ra' - name: Upload exact-commit release smoke uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -151,19 +163,42 @@ jobs: - name: Run full canonical qualification workloads if: ${{ inputs.qualification_level == 'full' }} run: | - $offline = @() - if ('${{ inputs.offline }}' -eq 'true') { $offline = @('--offline') } - & $env:QUALIFICATION_PYTHON -m pip install 'hydra-core>=1.3,<2' - & $env:QUALIFICATION_PYTHON -m pip install --no-deps 'git+https://github.com/verl-project/verl.git@7aed6b230776f963fa09509c10d9c3a767d1102c' - New-Item -ItemType Directory -Path qualification-full | Out-Null - & $env:QUALIFICATION_PYTHON scripts/run_verl_opd_reference_workload.py --out qualification-full-work/direct --result qualification-full/direct.json @offline - & $env:QUALIFICATION_PYTHON scripts/run_verl_pg_k1_workload.py --out qualification-full-work/pg-k1 --result qualification-full/pg-k1.json @offline - & $env:QUALIFICATION_PYTHON scripts/run_smollm2_opd_reference_workload.py --out qualification-full-work/smollm2 --result qualification-full/smollm2.json @offline - & $env:QUALIFICATION_PYTHON scripts/promote_full_gpu_qualification.py ` - --qualification qualification/qualification.json ` - --direct qualification-full/direct.json ` - --pg-k1 qualification-full/pg-k1.json ` - --smollm2 qualification-full/smollm2.json + offline=() + if [ '${{ inputs.offline }}' = 'true' ]; then offline=(--offline); fi + "$QUALIFICATION_PYTHON" -m pip install 'hydra-core>=1.3,<2' + "$QUALIFICATION_PYTHON" -m pip install --no-deps 'git+https://github.com/verl-project/verl.git@7aed6b230776f963fa09509c10d9c3a767d1102c' + mkdir qualification-full + "$QUALIFICATION_PYTHON" scripts/run_verl_opd_reference_workload.py --out qualification-full-work/direct --result qualification-full/direct.json "${offline[@]}" + "$QUALIFICATION_PYTHON" scripts/run_verl_pg_k1_workload.py --out qualification-full-work/pg-k1 --result qualification-full/pg-k1.json "${offline[@]}" + "$QUALIFICATION_PYTHON" scripts/run_smollm2_opd_reference_workload.py --out qualification-full-work/smollm2 --result qualification-full/smollm2.json "${offline[@]}" + "$QUALIFICATION_PYTHON" scripts/run_v011_profile_qualification.py \ + --commit "$GITHUB_SHA" \ + --wheel "$QUALIFICATION_WHEEL" \ + --wheel-sha256 "$QUALIFICATION_WHEEL_SHA256" \ + --output qualification-full/v011-profiles.json \ + --work qualification-full-work/v011-profiles \ + "${offline[@]}" + "$QUALIFICATION_PYTHON" scripts/benchmark_rollout_runtime_v2_selected.py \ + --backend hf_cached \ + --wheel "$QUALIFICATION_WHEEL" \ + --expected-commit "$GITHUB_SHA" \ + --output qualification-full/hf-cached-runtime.json \ + --repetitions 3 + "$QUALIFICATION_PYTHON" scripts/benchmark_rollout_runtime_v2_selected.py \ + --backend vllm \ + --wheel "$QUALIFICATION_WHEEL" \ + --expected-commit "$GITHUB_SHA" \ + --output qualification-full/vllm-runtime.json \ + --repetitions 3 + "$QUALIFICATION_PYTHON" scripts/promote_full_gpu_qualification.py \ + --qualification qualification/qualification.json \ + --direct qualification-full/direct.json \ + --pg-k1 qualification-full/pg-k1.json \ + --smollm2 qualification-full/smollm2.json \ + --v011-profiles qualification-full/v011-profiles.json \ + --hf-cached-runtime qualification-full/hf-cached-runtime.json \ + --vllm-runtime qualification-full/vllm-runtime.json \ + --hf-reference benchmarks/results/rollout-runtime-v2-hf-reference.json - name: Upload full qualification records uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ success() && inputs.qualification_level == 'full' }} @@ -175,11 +210,19 @@ jobs: - name: Remove runner-local environments and model work products if: always() run: | - foreach ($relative in @('.gpu-qualification-venv', 'qualification-work', 'qualification-full-work')) { - if (Test-Path -LiteralPath $relative) { - $target = (Resolve-Path -LiteralPath $relative).Path - $workspace = (Resolve-Path -LiteralPath $env:GITHUB_WORKSPACE).Path - if (-not $target.StartsWith($workspace, [System.StringComparison]::OrdinalIgnoreCase)) { throw "refusing cleanup outside workspace: $target" } - Remove-Item -LiteralPath $target -Recurse -Force - } - } + python3 - <<'PY' + import os, shutil + from pathlib import Path + workspace = Path(os.environ["GITHUB_WORKSPACE"]).resolve() + for relative in ( + ".gpu-qualification-venv", + "qualification-work", + "qualification-full-work", + "qualification-full", + ): + target = (workspace / relative).resolve() + if target.parent != workspace: + raise SystemExit(f"refusing cleanup outside workspace: {target}") + if target.exists(): + shutil.rmtree(target) + PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24fdcf6..778ddd4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: --qualification-artifact-name gpu-full-qualification \ --required-level full_qualification \ --required-gpu-name "NVIDIA GeForce RTX 4080" \ - --known-good environments/known-good-rtx4080-cu130.json \ + --known-good environments/known-good-rtx4080-wsl2-cu130.json \ --output verified-qualified-candidate - name: Preserve the verified byte chain uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/release-qualification.md b/docs/release-qualification.md index 2b1d07c..58b74c8 100644 --- a/docs/release-qualification.md +++ b/docs/release-qualification.md @@ -10,7 +10,7 @@ workflow is manual, not continuous GPU CI and not a pull-request required check. | level | cadence | executed scope | | --- | --- | --- | | release smoke | diagnostic use | install the hosted-runner candidate wheel, verify import/CLI origin, run CLI doctor/plan/dry-run, pinned Qwen actor and teacher, one rollout/score/update, PEFT export/reload and CUDA teardown | -| full qualification | every formal release | release smoke plus the unchanged direct-GKD, sampled-k1 and SmolLM2 canonical workloads, including interruption/resume and their existing export/materialization checks | +| full qualification | every formal release | release smoke, the three canonical resume workloads, and the v0.11 direct n=1, grouped PG n=4, rewarded PG n=4, `hf_cached` and vLLM runtime gates | The smoke budget is intentionally small and is never substituted for a frozen full workload or a scientific benchmark. Both levels record runtime @@ -26,7 +26,7 @@ from the same successful run and first attempt. GitHub-hosted `build-candidate` job creates exactly one wheel and one sdist with the pinned build toolchain, validates them with Twine, and uploads `candidate-distributions` with canonical checksums and a strict manifest. The -dependent `[self-hosted, cuda, rtx4080]` job downloads that same-run artifact, +dependent `[self-hosted, cuda, rtx4080, wsl2]` job downloads that same-run artifact, validates it before installation, then installs the candidate wheel in a fresh virtual environment using the [known-good stack](single-gpu-guide.md). It does not build a distribution. `qualification.json` binds the candidate manifest, @@ -34,9 +34,12 @@ full source SHA, exact wheel hash, workflow run, profile identity, model revisions, inputs, measured environment and output hashes. It also proves that both the imported package and `miniverl` executable came from the qualification environment rather than a checkout or user site. -For a full run, the workflow additionally validates each canonical result's -exact resume, resource, PEFT and scale-out fields, then promotes that same -record to `full_qualification`; three loose result files cannot claim the +For a v0.11 full run, the workflow additionally performs three real one-update +profiles, all 24 preregistered cells on each rollout backend, eight policy +refreshes per backend, conformance and teardown. Promotion verifies the exact +source, wheel and environment bindings; speed and memory thresholds; grouped +trajectory identity; reward/advantage completion; and vLLM's +policy-gradient fail-closed boundary. Loose result files cannot claim the higher level. Every dispatch is single-use. Both jobs reject `GITHUB_RUN_ATTEMPT` values @@ -54,7 +57,8 @@ SHA-256. Cross-origin artifact redirects retain ordinary API headers but strip authorization, proxy authorization and cookies. It publishes the accepted wheel and sdist without rebuilding them. Future release runs retain the full qualification record, four principal workload JSON files and a deterministic -subordinate-evidence archive in the canonical layout below. A committed +subordinate-evidence archive. For v0.11, that archive also contains the three +profile/backend qualification records. A committed JSON file, manual upload, fork run, different workflow or cross-run artifact pair cannot satisfy this gate. @@ -80,9 +84,10 @@ qualification-evidence-manifest.json ``` The four principal workload records remain directly inspectable. Adapter -configuration and safetensors, its miniVERL manifest, input prompts and the run -summary live once in the deterministic archive; its manifest binds every member -to its semantic role, byte count and SHA-256. `SHA256SUMS` covers only the wheel +configuration and safetensors, its miniVERL manifest, input prompts, run +summary and version-specific qualification records live once in the +deterministic archive; its manifest binds every member to its semantic role, +byte count and SHA-256. `SHA256SUMS` covers only the wheel and sdist. `qualification-SHA256SUMS` covers the nine provenance and evidence assets in canonical order. Hashes prove byte integrity, not code signing or third-party endorsement. @@ -127,6 +132,10 @@ portable bounded artifacts, and checks cleanup targets remain under `GITHUB_WORKSPACE`. Model caches remain runner-local and are not uploaded. Rotate the runner token after suspected exposure. +The v0.11 release runner is Ubuntu under WSL2 with Python 3.12.13 and the +checked `known-good-rtx4080-wsl2-cu130` stack. The older Windows qualification +record remains historical and cannot authorize v0.11. + ## Measured release state The first exact-commit full run completed for v0.10.1 on attempt 1: diff --git a/docs/single-gpu-guide.md b/docs/single-gpu-guide.md index 226c037..2e5bb1b 100644 --- a/docs/single-gpu-guide.md +++ b/docs/single-gpu-guide.md @@ -37,13 +37,16 @@ not select a CUDA-enabled PyTorch build on its own. For the exact stack measured by the maintainer on one RTX 4080, use the machine-readable [`environments/known-good-rtx4080-cu130.json`](https://github.com/DaoyuanLi2816/mini-verl/blob/main/environments/known-good-rtx4080-cu130.json) +is the historical Windows v0.10.1 record. The active v0.11 release +qualification uses +[`environments/known-good-rtx4080-wsl2-cu130.json`](https://github.com/DaoyuanLi2816/mini-verl/blob/main/environments/known-good-rtx4080-wsl2-cu130.json) and its constraints: ```bash python -m pip install "torch==2.13.0+cu130" \ --index-url https://download.pytorch.org/whl/cu130 -python -m pip install "miniverl[train,cuda]" \ - --constraint environments/known-good-rtx4080-cu130.txt +python -m pip install "miniverl[train,cuda,rollout-vllm]" \ + --constraint environments/known-good-rtx4080-wsl2-cu130.txt python scripts/check_known_good_environment.py ``` diff --git a/environments/known-good-rtx4080-wsl2-cu130.json b/environments/known-good-rtx4080-wsl2-cu130.json new file mode 100644 index 0000000..5723808 --- /dev/null +++ b/environments/known-good-rtx4080-wsl2-cu130.json @@ -0,0 +1,41 @@ +{ + "schema_version": 1, + "name": "rtx4080-wsl2-cu130-v1", + "status": "maintainer_measured", + "measured_at": "2026-08-29", + "source_evidence": { + "version": "0.11.0.dev0", + "commit": "3cfa09b0b7aa2ee63f5702a5766b73d9a32fa8b9", + "wheel_sha256": "33c30834dcd01001a5c2ae619a1c982ceb2ad5fcc457998baa9f960a87267eba" + }, + "required": { + "gpu_name": "NVIDIA GeForce RTX 4080", + "python": "3.12.13", + "packages": { + "torch": "2.13.0+cu130", + "transformers": "5.14.1", + "peft": "0.20.0", + "accelerate": "1.14.0", + "bitsandbytes": "0.50.2", + "numpy": "2.3.5", + "pyarrow": "25.0.1", + "safetensors": "0.8.0" + } + }, + "observed": { + "os": "Ubuntu on WSL2", + "vram_mib": 16376, + "driver": "596.49", + "cuda_runtime": "13.0" + }, + "pytorch": { + "package": "torch", + "index_url": "https://download.pytorch.org/whl/cu130" + }, + "constraints": "environments/known-good-rtx4080-wsl2-cu130.txt", + "scope": { + "classification": "maintainer-measured on one WSL2 RTX 4080", + "other_hardware": "unmeasured", + "distributed_execution": "not_tested" + } +} diff --git a/environments/known-good-rtx4080-wsl2-cu130.txt b/environments/known-good-rtx4080-wsl2-cu130.txt new file mode 100644 index 0000000..1e2c9ef --- /dev/null +++ b/environments/known-good-rtx4080-wsl2-cu130.txt @@ -0,0 +1,11 @@ +# miniVERL v0.11 WSL2 RTX 4080 qualification stack. +# Install the CUDA PyTorch wheel from its explicit index before this file. +torch==2.13.0+cu130 +transformers==5.14.1 +peft==0.20.0 +accelerate==1.14.0 +bitsandbytes==0.50.2 +numpy==2.3.5 +pyarrow==25.0.1 +safetensors==0.8.0 +vllm==0.28.0 diff --git a/scripts/check_known_good_environment.py b/scripts/check_known_good_environment.py index de9eb83..20a3903 100644 --- a/scripts/check_known_good_environment.py +++ b/scripts/check_known_good_environment.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any -MANIFEST = Path("environments/known-good-rtx4080-cu130.json") +MANIFEST = Path("environments/known-good-rtx4080-wsl2-cu130.json") REQUIRED_PACKAGES = { "torch", "transformers", diff --git a/scripts/promote_full_gpu_qualification.py b/scripts/promote_full_gpu_qualification.py index 4bd3e8b..58bbd2f 100644 --- a/scripts/promote_full_gpu_qualification.py +++ b/scripts/promote_full_gpu_qualification.py @@ -4,6 +4,7 @@ import argparse import json +import math import shutil from pathlib import Path from typing import Any @@ -21,6 +22,18 @@ "training_state_fields_identical", "trajectories_byte_identical", ) +_V011_CHECKS = ( + "v011_hf_cached_direct_n1", + "v011_hf_cached_pg_n4", + "v011_rewarded_pg_n4", + "v011_exact_wheel_runtime_gate", + "v011_vllm_direct_gkd_n4_r256", + "v011_policy_refresh_cache_invalidation", + "v011_external_engine_teardown", +) +_MAX_GPU_MEMORY_MIB = 14.5 * 1024 +_PREREGISTRATION_SHA256 = "8cc3ba738c69b59ed19c22c1de874fd00249404198a3e05983477dc8899bb7e5" +_FROZEN_CALCULATOR_SHA256 = "53fc1d4d5b7adee09618d77ad62d4086ba56b78569832d6fc7c3bcd5c2695bbc" def _load(path: Path) -> dict[str, Any]: @@ -71,12 +84,217 @@ def _validate_result(name: str, payload: dict[str, Any], qualification: GPUQuali raise ValueError("smollm2: distributed execution must remain not tested") +def _is_v011(version: str) -> bool: + match = version.split(".", 2) + try: + return (int(match[0]), int(match[1])) >= (0, 11) + except (IndexError, ValueError): + return False + + +def _validate_v011_profiles(payload: dict[str, Any], qualification: GPUQualification) -> None: + if payload.get("schema_version") != 1 or payload.get("status") != "passed": + raise ValueError("v0.11 profiles: unsupported schema or failed status") + if payload.get("kind") != "miniverl_v011_profile_qualification": + raise ValueError("v0.11 profiles: unexpected evidence kind") + if payload.get("source_commit") != qualification.source_commit: + raise ValueError("v0.11 profiles: source commit does not match release smoke") + if payload.get("miniverl_version") != qualification.miniverl_version: + raise ValueError("v0.11 profiles: miniVERL version does not match release smoke") + if payload.get("wheel_sha256") != qualification.wheel.sha256: + raise ValueError("v0.11 profiles: wheel binding does not match release smoke") + hardware = payload.get("hardware") or {} + if hardware.get("gpu") != qualification.environment.gpu_name or hardware.get("gpu_count") != 1: + raise ValueError("v0.11 profiles: hardware does not match release smoke") + if "microsoft" not in str(hardware.get("platform", "")).lower(): + raise ValueError("v0.11 profiles: execution was not measured under WSL2") + if hardware.get("python") != qualification.environment.python: + raise ValueError("v0.11 profiles: Python does not match release smoke") + if hardware.get("cuda_runtime") != qualification.environment.cuda_runtime: + raise ValueError("v0.11 profiles: CUDA runtime does not match release smoke") + if hardware.get("driver") != qualification.environment.driver: + raise ValueError("v0.11 profiles: driver does not match release smoke") + if hardware.get("packages") != qualification.environment.packages: + raise ValueError("v0.11 profiles: packages do not match release smoke") + expected = { + "direct_n1": 1, + "grouped_pg_n4": 4, + "rewarded_pg_n4": 4, + } + profiles = payload.get("profiles") or {} + if set(profiles) != set(expected): + raise ValueError("v0.11 profiles: required profile set is incomplete") + for name, samples in expected.items(): + result = profiles[name] + if result.get("rollout_backend") != "hf_cached": + raise ValueError(f"v0.11 profiles: {name} did not use hf_cached") + if result.get("samples_per_prompt") != samples: + raise ValueError(f"v0.11 profiles: {name} has the wrong sample count") + if result.get("optimizer_updates") != 1 or result.get("policy_version") != 1: + raise ValueError(f"v0.11 profiles: {name} did not commit one optimizer update") + if result.get("loss_finite") is not True or int(result.get("selected_positions", 0)) < 1: + raise ValueError(f"v0.11 profiles: {name} optimizer update is empty or non-finite") + if int(result.get("trajectories", 0)) != 2 * samples: + raise ValueError(f"v0.11 profiles: {name} trajectory group is incomplete") + if float(result.get("peak_reserved_gib", math.inf)) > 14.5: + raise ValueError(f"v0.11 profiles: {name} exceeded the 14.5 GiB limit") + if profiles["rewarded_pg_n4"].get("reward", {}).get("status") != "completed": + raise ValueError("v0.11 profiles: rewarded PG reward/advantage path did not complete") + if (payload.get("cuda_teardown") or {}).get("passed") is not True: + raise ValueError("v0.11 profiles: CUDA teardown did not pass") + + +def _cells(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: + cells = payload.get("cells") or [] + if len(cells) != 24: + raise ValueError("v0.11 runtime: expected exactly 24 workload cells") + indexed = {cell.get("cell_id"): cell for cell in cells} + if len(indexed) != 24 or None in indexed: + raise ValueError("v0.11 runtime: cell identities are not unique") + expected = { + f"p{prompt}-r{response}-n{samples}-{sampling}": ( + prompt, + response, + samples, + sampling, + ) + for prompt in (128, 512) + for response in (64, 256, 512) + for samples in (1, 4) + for sampling in ("greedy", "seeded_stochastic") + } + if set(indexed) != set(expected): + raise ValueError("v0.11 runtime: workload cell set does not match the preregistration") + for cell_id, (prompt, response, samples, sampling) in expected.items(): + cell = indexed[cell_id] + if ( + cell.get("prompt_length") != prompt + or cell.get("response_bound") != response + or cell.get("samples_per_prompt") != samples + or cell.get("sampling") != sampling + or cell.get("logical_prompts") != 4 + or cell.get("measured_repetitions") != 3 + or cell.get("generated_trajectories") != 4 * samples + or cell.get("generated_tokens") != 4 * samples * response + ): + raise ValueError(f"v0.11 runtime: cell {cell_id} does not match its declared workload") + return indexed + + +def _validate_refresh_and_teardown(payload: dict[str, Any], *, external: bool) -> None: + refresh = payload.get("refresh_probe") or {} + cycles = refresh.get("cycles") or [] + if ( + len(cycles) != 8 + or refresh.get("all_policy_identities_unique") is not True + or refresh.get("all_syncs_confirmed") is not True + or refresh.get("strictly_monotonic_memory_growth") is not False + ): + raise ValueError("v0.11 runtime: policy refresh/cache invalidation gate failed") + teardown = payload.get("teardown") or {} + if teardown.get("backend_state") != "closed" or ( + external and teardown.get("port_closed") is not True + ): + raise ValueError("v0.11 runtime: backend teardown gate failed") + + +def _validate_v011_runtime_pair( + hf_payload: dict[str, Any], + vllm_payload: dict[str, Any], + reference: dict[str, Any], + qualification: GPUQualification, +) -> None: + for name, payload in (("hf_cached", hf_payload), ("vllm", vllm_payload)): + if payload.get("schema_version") != 1 or payload.get("measurement_status") != "completed": + raise ValueError(f"v0.11 runtime: {name} measurement did not complete") + source = payload.get("source") or {} + if source.get("commit") != qualification.source_commit: + raise ValueError(f"v0.11 runtime: {name} source commit does not match") + if source.get("miniverl_version") != qualification.miniverl_version: + raise ValueError(f"v0.11 runtime: {name} version does not match") + if source.get("wheel_sha256") != qualification.wheel.sha256: + raise ValueError(f"v0.11 runtime: {name} wheel binding does not match") + if source.get("dirty") is not False: + raise ValueError(f"v0.11 runtime: {name} source tree was dirty") + if payload.get("preregistration_sha256") != _PREREGISTRATION_SHA256: + raise ValueError(f"v0.11 runtime: {name} preregistration binding does not match") + if payload.get("frozen_calculator_sha256") != _FROZEN_CALCULATOR_SHA256: + raise ValueError(f"v0.11 runtime: {name} frozen calculator binding does not match") + if (payload.get("backend") or {}).get("name") != name: + raise ValueError(f"v0.11 runtime: {name} backend identity does not match") + environment = payload.get("environment") or {} + if environment.get("gpu") != qualification.environment.gpu_name: + raise ValueError(f"v0.11 runtime: {name} hardware does not match") + if "microsoft" not in str(environment.get("platform", "")).lower(): + raise ValueError(f"v0.11 runtime: {name} was not measured under WSL2") + for field, expected in ( + ("python", qualification.environment.python), + ("driver_version", qualification.environment.driver), + ("cuda_runtime", qualification.environment.cuda_runtime), + ): + if environment.get(field) != expected: + raise ValueError(f"v0.11 runtime: {name} environment {field} does not match") + runtime_packages = environment.get("packages") or {} + for package, actual in runtime_packages.items(): + if package == "vllm": + if actual != "0.28.0": + raise ValueError("v0.11 runtime: vLLM version does not match") + elif qualification.environment.packages.get(package) != actual: + raise ValueError( + f"v0.11 runtime: {name} package {package} does not match release smoke" + ) + if ( + float((payload.get("memory") or {}).get("peak_total_gpu_memory_mib", math.inf)) + > _MAX_GPU_MEMORY_MIB + ): + raise ValueError(f"v0.11 runtime: {name} exceeded the 14.5 GiB limit") + hf_cells = _cells(hf_payload) + vllm_cells = _cells(vllm_payload) + reference_cells = {cell["cell_id"]: cell for cell in reference.get("cells") or []} + for cell_id, cell in hf_cells.items(): + if int(cell["response_bound"]) not in {256, 512}: + continue + baseline = reference_cells.get(cell_id) + if baseline is None: + raise ValueError(f"v0.11 runtime: missing reference cell {cell_id}") + baseline_rate = float(baseline["rates"]["output_tokens_per_second"]) + if float(cell["output_tokens_per_second"]) < 2.0 * baseline_rate: + raise ValueError(f"v0.11 runtime: hf_cached cell {cell_id} missed the 2.0x gate") + if float(vllm_cells[cell_id]["output_tokens_per_second"]) < 1.2 * float( + cell["output_tokens_per_second"] + ): + raise ValueError(f"v0.11 runtime: vLLM cell {cell_id} missed the 1.2x gate") + hf_conformance = hf_payload.get("conformance") or {} + if ( + hf_conformance.get("tokens_equal") is not True + or hf_conformance.get("threshold_passed") is not True + ): + raise ValueError("v0.11 runtime: hf_cached conformance gate failed") + vllm_conformance = vllm_payload.get("conformance") or {} + if vllm_conformance.get("token_agreement_fraction") != 1.0: + raise ValueError("v0.11 runtime: vLLM greedy token conformance failed") + if vllm_conformance.get("pg_threshold_passed") is not False: + raise ValueError("v0.11 runtime: vLLM PG path must remain fail closed") + lifecycle = (vllm_payload.get("timing") or {}).get("engine_lifecycle") or {} + if ( + lifecycle.get("execution_mode") != "cuda_graph" + or lifecycle.get("prefix_cache_enabled") is not False + ): + raise ValueError("v0.11 runtime: vLLM execution mode is not qualified") + _validate_refresh_and_teardown(hf_payload, external=False) + _validate_refresh_and_teardown(vllm_payload, external=True) + + def promote( qualification_path: Path, *, direct: Path, pg_k1: Path, smollm2: Path, + v011_profiles: Path | None = None, + hf_cached_runtime: Path | None = None, + vllm_runtime: Path | None = None, + hf_reference: Path | None = None, ) -> GPUQualification: problems = validate_qualification_file(qualification_path) if problems: @@ -89,6 +307,24 @@ def promote( results = {name: _load(path) for name, path in sources.items()} for name, result in results.items(): _validate_result(name, result, qualification) + v011_sources: dict[str, Path] = {} + if _is_v011(qualification.miniverl_version): + if None in (v011_profiles, hf_cached_runtime, vllm_runtime, hf_reference): + raise ValueError("v0.11 full qualification requires profile and runtime evidence") + assert v011_profiles is not None + assert hf_cached_runtime is not None + assert vllm_runtime is not None + assert hf_reference is not None + profile_payload = _load(v011_profiles) + hf_payload = _load(hf_cached_runtime) + vllm_payload = _load(vllm_runtime) + _validate_v011_profiles(profile_payload, qualification) + _validate_v011_runtime_pair(hf_payload, vllm_payload, _load(hf_reference), qualification) + v011_sources = { + "v011_profiles": v011_profiles, + "hf_cached_runtime": hf_cached_runtime, + "vllm_runtime": vllm_runtime, + } root = qualification_path.parent destination = root / "full" @@ -100,6 +336,10 @@ def promote( target = destination / f"{name}.json" shutil.copy2(source, target) additions.append((f"full_{name}_result", target)) + for name, source in v011_sources.items(): + target = destination / f"{name.replace('_', '-')}.json" + shutil.copy2(source, target) + additions.append((f"full_{name}_result", target)) for name, path in additions: payload["artifacts"].append( { @@ -118,6 +358,8 @@ def promote( "export_materialize_doctor", ] ) + if v011_sources: + payload["checks"]["executed"].extend(_V011_CHECKS) promoted = GPUQualification.model_validate(payload) write_json_atomic(qualification_path, promoted.model_dump(mode="json")) final_problems = validate_qualification_file(qualification_path) @@ -132,12 +374,20 @@ def main() -> int: parser.add_argument("--direct", required=True, type=Path) parser.add_argument("--pg-k1", required=True, type=Path) parser.add_argument("--smollm2", required=True, type=Path) + parser.add_argument("--v011-profiles", type=Path) + parser.add_argument("--hf-cached-runtime", type=Path) + parser.add_argument("--vllm-runtime", type=Path) + parser.add_argument("--hf-reference", type=Path) args = parser.parse_args() promoted = promote( args.qualification, direct=args.direct, pg_k1=args.pg_k1, smollm2=args.smollm2, + v011_profiles=args.v011_profiles, + hf_cached_runtime=args.hf_cached_runtime, + vllm_runtime=args.vllm_runtime, + hf_reference=args.hf_reference, ) print(json.dumps(promoted.model_dump(mode="json"), sort_keys=True, allow_nan=False)) return 0 diff --git a/scripts/run_v011_profile_qualification.py b/scripts/run_v011_profile_qualification.py new file mode 100644 index 0000000..ceede85 --- /dev/null +++ b/scripts/run_v011_profile_qualification.py @@ -0,0 +1,351 @@ +"""Qualify v0.11 local rollout profiles from one exact candidate wheel. + +This is a bounded systems run. It executes one real Qwen3 optimizer update for +direct GKD with ``n=1``, grouped sampled-k1 with ``n=4``, and rewarded +sampled-k1 with ``n=4``. It does not evaluate task quality. +""" + +from __future__ import annotations + +import argparse +import gc +import hashlib +import importlib.metadata +import json +import math +import platform +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from miniverl import __version__ +from miniverl.bridge.opd_pg_v08 import ( + load_verl_pg_k1_v08_source, +) +from miniverl.bridge.opd_plan import build_immutable_opd_plan, write_immutable_opd_plan +from miniverl.bridge.opd_runtime import build_system_plan +from miniverl.bridge.opd_v08 import load_verl_opd_v08_source +from miniverl.bridge.profiles import ( + VERL_OPD_PG_K1_GROUPED_V08_PROFILE, + VERL_OPD_PG_K1_REWARDED_V08_PROFILE, +) +from miniverl.trainer import OPDTrainer +from miniverl.utils.runs import read_jsonl, write_json_atomic + +DIRECT_SOURCE = "builtin:qwen3-0.6b-1.7b-opd" +PG_SOURCE = Path("examples/verl-opd-v0.8-single-gpu-pg-k1.yaml") +PROMPT_LIMIT = 128 +RESPONSE_LIMIT = 64 +LOGICAL_BATCH = 2 +SAMPLES_PER_PROMPT = 4 + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_dataset(path: Path, *, rewarded: bool) -> str: + import pyarrow as pa + import pyarrow.parquet as pq + + rows: list[dict[str, Any]] = [] + for index in range(8): + row: dict[str, Any] = { + "prompt": [ + {"role": "system", "content": "Answer clearly and briefly."}, + { + "role": "user", + "content": f"Qualification item {index + 1}: state the integer {index + 1}.", + }, + ], + "data_source": "miniverl_v011_qualification", + "ability": "short_answer", + "extra_info": {"qualification_item": index + 1}, + } + if rewarded: + row["reward_model"] = {"style": "exact", "ground_truth": str(index + 1)} + rows.append(row) + path.parent.mkdir(parents=True, exist_ok=True) + pq.write_table(pa.Table.from_pylist(rows), path, row_group_size=2) + return _sha256(path) + + +def _common_overrides(dataset: Path) -> list[str]: + return [ + f'data.train_files=["{dataset.resolve().as_posix()}"]', + "data.val_files=[]", + f"data.train_batch_size={LOGICAL_BATCH}", + f"data.max_prompt_length={PROMPT_LIMIT}", + f"data.max_response_length={RESPONSE_LIMIT}", + f"actor_rollout_ref.actor.ppo_mini_batch_size={LOGICAL_BATCH}", + "actor_rollout_ref.actor.ppo_max_token_len_per_gpu=768", + "actor_rollout_ref.rollout.max_model_len=192", + "actor_rollout_ref.rollout.max_num_batched_tokens=768", + "actor_rollout_ref.rollout.max_num_seqs=8", + "trainer.save_freq=1", + "trainer.total_training_steps=1", + "miniverl.batching.rollout_batch_size=4", + "miniverl.batching.teacher_score_batch_size=4", + "miniverl.batching.update_trajectory_batch_size=1", + ] + + +def _build_plan(kind: str, dataset: Path, plan_path: Path) -> tuple[Any, Any]: + overrides = _common_overrides(dataset) + if kind == "direct_n1": + overrides.extend( + [ + "distillation.teacher_models.teacher_model.inference.max_model_len=192", + "distillation.distillation_loss.topk=32", + "trainer.experiment_name=v011-direct-n1-qualification", + ] + ) + compiled = load_verl_opd_v08_source( + DIRECT_SOURCE, + overrides=overrides, + accept_local_reinterpretations=True, + ) + source: str | Path = DIRECT_SOURCE + else: + overrides.extend( + [ + f"actor_rollout_ref.rollout.n={SAMPLES_PER_PROMPT}", + f"trainer.experiment_name=v011-{kind}-qualification", + ] + ) + rewarded = kind == "rewarded_pg_n4" + if rewarded: + overrides.extend( + [ + "distillation.distillation_loss.use_task_rewards=true", + "distillation.distillation_loss.task_reward_coef=1.0", + "distillation.distillation_loss.task_advantage_mode=group_center", + "distillation.distillation_loss.reward_provider=exact_answer", + ] + ) + compiled = load_verl_pg_k1_v08_source( + PG_SOURCE, + overrides=overrides, + accept_local_reinterpretations=True, + allow_grouped_samples=True, + profile_name=( + VERL_OPD_PG_K1_REWARDED_V08_PROFILE + if rewarded + else VERL_OPD_PG_K1_GROUPED_V08_PROFILE + ), + rewarded=rewarded, + ) + source = PG_SOURCE + system = build_system_plan(compiled) + plan = build_immutable_opd_plan( + compiled, + source=source, + system_plan=system, + rollout_backend="hf_cached", + ) + write_immutable_opd_plan(plan_path, plan) + from miniverl.config import RunConfig + + return plan, RunConfig.model_validate(plan.resolved_native_config) + + +def _run_profile(kind: str, root: Path, *, offline: bool) -> dict[str, Any]: + run_root = root / kind + run_root.mkdir(parents=True, exist_ok=False) + dataset = run_root / "data.parquet" + dataset_sha256 = _write_dataset(dataset, rewarded=kind == "rewarded_pg_n4") + plan, config = _build_plan(kind, dataset, run_root / "plan.json") + trainer = OPDTrainer.from_config( + config, + output_dir=run_root / "runs", + run_id="qualification", + local_files_only=offline, + ) + with trainer: + write_json_atomic( + trainer.paths.root / "local-execution-plan.json", + plan.model_dump(mode="json"), + ) + result = trainer.train() + if result.global_step != 1 or result.policy_version != 1: + raise RuntimeError(f"{kind}: expected one committed optimizer update") + + manifest = json.loads((result.run_dir / "manifest.json").read_text(encoding="utf-8")) + runtime = manifest.get("rollout_runtime") or {} + if runtime.get("backend") != "hf_cached": + raise RuntimeError(f"{kind}: rollout backend was not hf_cached") + expected_n = 1 if kind == "direct_n1" else SAMPLES_PER_PROMPT + if runtime.get("samples_per_prompt") != expected_n: + raise RuntimeError(f"{kind}: unexpected samples_per_prompt") + + trajectories = read_jsonl(result.run_dir / "trajectories.jsonl") + expected_trajectories = LOGICAL_BATCH * expected_n + if len(trajectories) != expected_trajectories: + raise RuntimeError(f"{kind}: incomplete trajectory group") + if any(row.get("samples_per_prompt") != expected_n for row in trajectories): + raise RuntimeError(f"{kind}: trajectory group identity mismatch") + if ( + kind != "direct_n1" + and len({(row["prompt_group_id"], row["sample_index"]) for row in trajectories}) + != expected_trajectories + ): + raise RuntimeError(f"{kind}: duplicate grouped sample identity") + if kind != "direct_n1" and any( + len((row.get("metadata") or {}).get("actor_rollout_log_probs") or []) + != (row.get("metadata") or {}).get("response_token_count") + for row in trajectories + ): + raise RuntimeError(f"{kind}: sampled actor log-probabilities are incomplete") + + metrics = read_jsonl(result.run_dir / "metrics.jsonl") + update = next(row for row in metrics if row.get("phase") == "opd") + cycle = next(row for row in metrics if row.get("phase") == "opd_cycle") + if not math.isfinite(float(update["loss"])) or int(update["selected_positions"]) < 1: + raise RuntimeError(f"{kind}: optimizer update is not finite and non-empty") + grouped = cycle.get("grouped_rollouts") or {} + if grouped.get("samples_per_prompt") != expected_n: + raise RuntimeError(f"{kind}: grouped metrics do not match the profile") + + reward_summary: dict[str, Any] = {"status": "not_applicable"} + if kind == "rewarded_pg_n4": + rewards = read_jsonl(result.run_dir / "rewards.jsonl") + advantages = read_jsonl(result.run_dir / "advantages.jsonl") + if len(rewards) != expected_trajectories or len(advantages) != expected_trajectories: + raise RuntimeError("rewarded_pg_n4: reward/advantage records are incomplete") + if any(row.get("status") != "ok" for row in rewards): + raise RuntimeError("rewarded_pg_n4: reward provider did not complete") + if any(not math.isfinite(float(row["task_advantage"])) for row in advantages): + raise RuntimeError("rewarded_pg_n4: task advantage is not finite") + reward_summary = { + "status": "completed", + "records": len(rewards), + "provider_versions": sorted({row["provider"]["version"] for row in rewards}), + "advantage_versions": sorted({row["implementation_version"] for row in advantages}), + "nonzero_raw_rewards": sum(float(row["raw_reward"]) != 0.0 for row in rewards), + } + + checkpoint = result.run_dir / "checkpoints" / "final" + return { + "profile": plan.profile, + "profile_identity": plan.profile_identity, + "plan_sha256": plan.plan_digest, + "dataset_sha256": dataset_sha256, + "rollout_backend": runtime["backend"], + "backend_version": runtime["capabilities"]["backend_version"], + "samples_per_prompt": expected_n, + "prompt_groups": grouped.get("groups"), + "trajectories": len(trajectories), + "generated_tokens": grouped.get("generated_tokens"), + "optimizer_updates": result.global_step, + "policy_version": result.policy_version, + "selected_positions": int(update["selected_positions"]), + "loss_finite": True, + "peak_reserved_gib": max( + float((row.get("memory") or {}).get("peak_reserved_gib") or 0.0) for row in metrics + ), + "checkpoint_sha256": { + name: _sha256(checkpoint / name) + for name in ("adapter.safetensors", "optimizer.safetensors", "state.json") + }, + "reward": reward_summary, + } + + +def run(args: argparse.Namespace) -> dict[str, Any]: + import bitsandbytes.functional as bnb_functional + import torch + + if not torch.cuda.is_available() or torch.cuda.device_count() != 1: + raise RuntimeError("v0.11 profile qualification requires exactly one CUDA GPU") + if _sha256(args.wheel) != args.wheel_sha256: + raise RuntimeError("candidate wheel checksum does not match the declared binding") + args.work.mkdir(parents=True, exist_ok=False) + baseline_allocated = int(torch.cuda.memory_allocated()) + profiles = { + kind: _run_profile(kind, args.work, offline=args.offline) + for kind in ("direct_n1", "grouped_pg_n4", "rewarded_pg_n4") + } + bnb_functional.name2qmap.clear() + torch._C._cuda_clearCublasWorkspaces() + gc.collect() + torch.cuda.empty_cache() + torch.cuda.synchronize() + residual_allocated = int(torch.cuda.memory_allocated()) + tolerance = max(2 * 1024**2, int(baseline_allocated * 0.02)) + if residual_allocated > baseline_allocated + tolerance: + raise RuntimeError("v0.11 profile qualification left live CUDA allocations") + payload = { + "schema_version": 1, + "kind": "miniverl_v011_profile_qualification", + "status": "passed", + "measured_at": datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z"), + "source_commit": args.commit, + "miniverl_version": __version__, + "wheel_sha256": args.wheel_sha256, + "hardware": { + "gpu": torch.cuda.get_device_name(0), + "gpu_count": 1, + "platform": platform.platform(), + "python": platform.python_version(), + "cuda_runtime": str(torch.version.cuda), + "driver": subprocess.run( + ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines()[0], + "packages": { + name: importlib.metadata.version(name) + for name in ( + "torch", + "transformers", + "peft", + "accelerate", + "bitsandbytes", + "numpy", + "pyarrow", + "safetensors", + ) + }, + }, + "profiles": profiles, + "cuda_teardown": { + "allocated_before_bytes": baseline_allocated, + "allocated_after_bytes": residual_allocated, + "tolerance_bytes": tolerance, + "passed": True, + }, + "scientific_scope": { + "runtime_correctness_only": True, + "task_quality_evaluated": False, + "distributed_execution_tested": False, + }, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + write_json_atomic(args.output, payload) + return payload + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--commit", required=True) + parser.add_argument("--wheel", required=True, type=Path) + parser.add_argument("--wheel-sha256", required=True) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--work", required=True, type=Path) + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + print(json.dumps(run(args), sort_keys=True, allow_nan=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/miniverl/qualification.py b/src/miniverl/qualification.py index fda7975..2e58aeb 100644 --- a/src/miniverl/qualification.py +++ b/src/miniverl/qualification.py @@ -230,6 +230,35 @@ def _roles_and_gpu_are_exact(self) -> GPUQualification: raise ValueError( "full qualification evidence is missing: " + ", ".join(missing_artifacts) ) + version_parts = self.miniverl_version.split(".", 2) + try: + requires_v011 = (int(version_parts[0]), int(version_parts[1])) >= (0, 11) + except (IndexError, ValueError): + requires_v011 = False + if requires_v011: + v011_checks = { + "v011_hf_cached_direct_n1", + "v011_hf_cached_pg_n4", + "v011_rewarded_pg_n4", + "v011_exact_wheel_runtime_gate", + "v011_vllm_direct_gkd_n4_r256", + "v011_policy_refresh_cache_invalidation", + "v011_external_engine_teardown", + } + missing_v011_checks = sorted(v011_checks - set(self.checks.executed)) + v011_artifacts = { + "full_v011_profiles_result", + "full_hf_cached_runtime_result", + "full_vllm_runtime_result", + } + missing_v011_artifacts = sorted( + v011_artifacts - {artifact.name for artifact in self.artifacts} + ) + if missing_v011_checks or missing_v011_artifacts: + details = missing_v011_checks + missing_v011_artifacts + raise ValueError( + "v0.11 full qualification evidence is missing: " + ", ".join(details) + ) return self diff --git a/src/miniverl/release_assets.py b/src/miniverl/release_assets.py index 7beedb4..18cdbed 100644 --- a/src/miniverl/release_assets.py +++ b/src/miniverl/release_assets.py @@ -46,6 +46,14 @@ "inputs_prompts.parquet": ("input_prompts", "inputs/prompts.parquet"), "run_summary": ("run_summary", "run-summary.json"), } +V011_ARCHIVE_EVIDENCE = { + "full_v011_profiles_result": ("v011_profiles", "v011/profiles.json"), + "full_hf_cached_runtime_result": ( + "v011_hf_cached_runtime", + "v011/hf-cached-runtime.json", + ), + "full_vllm_runtime_result": ("v011_vllm_runtime", "v011/vllm-runtime.json"), +} _SPECIAL_EVIDENCE = {"candidate-manifest.json"} QUALIFICATION_SUM_FILES = ( "candidate-manifest.json", @@ -123,7 +131,10 @@ def validate_canonical_names(names: list[str] | tuple[str, ...]) -> None: def _validate_archive_mapping() -> None: seen_paths: set[str] = set() seen_roles: set[str] = set() - for original_name, (semantic_role, archive_path) in ARCHIVE_EVIDENCE.items(): + for original_name, (semantic_role, archive_path) in { + **ARCHIVE_EVIDENCE, + **V011_ARCHIVE_EVIDENCE, + }.items(): if ( not original_name or not semantic_role.isascii() @@ -142,6 +153,18 @@ def _validate_archive_mapping() -> None: seen_roles.add(semantic_role) +def _archive_evidence_for_version(version: str) -> dict[str, tuple[str, str]]: + mapping = dict(ARCHIVE_EVIDENCE) + version_parts = version.split(".", 2) + try: + is_v011 = (int(version_parts[0]), int(version_parts[1])) >= (0, 11) + except (IndexError, ValueError): + is_v011 = False + if is_v011: + mapping.update(V011_ARCHIVE_EVIDENCE) + return mapping + + def _safe_source(root: Path, relative: str) -> Path: candidate = root / relative try: @@ -268,7 +291,8 @@ def prepare_release_assets( ) artifacts = {item.name: item for item in record.artifacts} - expected_roles = set(PRIMARY_EVIDENCE) | set(ARCHIVE_EVIDENCE) | _SPECIAL_EVIDENCE + archive_evidence = _archive_evidence_for_version(record.miniverl_version) + expected_roles = set(PRIMARY_EVIDENCE) | set(archive_evidence) | _SPECIAL_EVIDENCE unknown = sorted(set(artifacts) - expected_roles) missing = sorted(expected_roles - set(artifacts)) if unknown: @@ -314,7 +338,7 @@ def prepare_release_assets( _copy(_safe_source(evidence_root, artifact.path), staging / destination) archive_members: list[tuple[str, str, str, Path, str, int]] = [] - for original_name, (semantic_role, member_path) in ARCHIVE_EVIDENCE.items(): + for original_name, (semantic_role, member_path) in archive_evidence.items(): artifact = artifacts[original_name] source = _safe_source(evidence_root, artifact.path) archive_members.append( @@ -471,7 +495,8 @@ def check_release_assets(output: str | Path) -> list[str]: qualification_artifacts = { artifact.name: artifact for artifact in qualification_record.artifacts } - expected_roles = set(PRIMARY_EVIDENCE) | set(ARCHIVE_EVIDENCE) | _SPECIAL_EVIDENCE + archive_evidence = _archive_evidence_for_version(qualification_record.miniverl_version) + expected_roles = set(PRIMARY_EVIDENCE) | set(archive_evidence) | _SPECIAL_EVIDENCE if set(qualification_artifacts) != expected_roles or len(qualification_artifacts) != len( qualification_record.artifacts ): @@ -575,7 +600,8 @@ def check_release_assets(output: str | Path) -> list[str]: ): problems.append("archive manifest release-chain binding mismatch") expected_member_rows = [] - for original_name, (semantic_role, member_path) in ARCHIVE_EVIDENCE.items(): + archive_evidence = _archive_evidence_for_version(qualification_record.miniverl_version) + for original_name, (semantic_role, member_path) in archive_evidence.items(): artifact = qualification_artifacts.get(original_name) if artifact is not None: expected_member_rows.append( diff --git a/tests/unit/test_gpu_qualification.py b/tests/unit/test_gpu_qualification.py index f8c9bb0..b91456d 100644 --- a/tests/unit/test_gpu_qualification.py +++ b/tests/unit/test_gpu_qualification.py @@ -227,6 +227,221 @@ def _full_result(name: str) -> dict[str, object]: return payload +def _v011_profile_result() -> dict[str, object]: + profiles: dict[str, object] = {} + for name, samples in ( + ("direct_n1", 1), + ("grouped_pg_n4", 4), + ("rewarded_pg_n4", 4), + ): + profiles[name] = { + "rollout_backend": "hf_cached", + "samples_per_prompt": samples, + "prompt_groups": 2, + "trajectories": 2 * samples, + "optimizer_updates": 1, + "policy_version": 1, + "selected_positions": 8, + "loss_finite": True, + "peak_reserved_gib": 12.0, + "reward": {"status": "completed" if name == "rewarded_pg_n4" else "not_applicable"}, + } + return { + "schema_version": 1, + "kind": "miniverl_v011_profile_qualification", + "status": "passed", + "source_commit": "a" * 40, + "miniverl_version": "0.11.0", + "wheel_sha256": "4" * 64, + "hardware": { + "gpu": "NVIDIA GeForce RTX 4080", + "gpu_count": 1, + "platform": "Linux-microsoft-standard-WSL2", + "python": "3.12.13", + "cuda_runtime": "13.0", + "driver": "596.49", + "packages": { + "torch": "2.13.0+cu130", + "transformers": "5.14.1", + "peft": "0.20.0", + "accelerate": "1.14.0", + "bitsandbytes": "0.50.2", + "numpy": "2.2.6", + "pyarrow": "25.0.0", + "safetensors": "0.8.0", + }, + }, + "profiles": profiles, + "cuda_teardown": {"passed": True}, + "scientific_scope": { + "runtime_correctness_only": True, + "task_quality_evaluated": False, + "distributed_execution_tested": False, + }, + } + + +def _v011_runtime_result(name: str) -> dict[str, object]: + source = Path( + "benchmarks/evidence/rollout-runtime-v2/" + + ( + "hf-cached-v2-rtx4080-candidate-raw.json" + if name == "hf_cached" + else "vllm-cudagraph-rtx4080-candidate-raw.json" + ) + ) + payload = json.loads(source.read_text(encoding="utf-8")) + payload["source"] = { + "commit": "a" * 40, + "dirty": False, + "miniverl_version": "0.11.0", + "wheel_sha256": "4" * 64, + } + return payload + + +def _promote_v011(tmp_path: Path, *, mutate=None): + from scripts.promote_full_gpu_qualification import promote + + payload, root = _payload(tmp_path) + payload["miniverl_version"] = "0.11.0" + payload["environment"]["python"] = "3.12.13" # type: ignore[index] + payload["environment"]["packages"].update( # type: ignore[index,union-attr] + { + "peft": "0.20.0", + "bitsandbytes": "0.50.2", + } + ) + wheel_sha256 = payload["wheel"]["sha256"] # type: ignore[index] + qualification = root / "qualification.json" + qualification.write_text(json.dumps(payload), encoding="utf-8") + paths: dict[str, Path] = {} + results: dict[str, dict[str, object]] = { + name: _full_result(name) for name in ("direct", "pg_k1", "smollm2") + } + for result in results.values(): + result["miniverl_version"] = "0.11.0" + results["v011_profiles"] = _v011_profile_result() + results["hf_cached_runtime"] = _v011_runtime_result("hf_cached") + results["vllm_runtime"] = _v011_runtime_result("vllm") + results["v011_profiles"]["wheel_sha256"] = wheel_sha256 + results["hf_cached_runtime"]["source"]["wheel_sha256"] = wheel_sha256 # type: ignore[index] + results["vllm_runtime"]["source"]["wheel_sha256"] = wheel_sha256 # type: ignore[index] + if mutate is not None: + mutate(results) + for name, result in results.items(): + path = tmp_path / f"{name}.json" + path.write_text(json.dumps(result), encoding="utf-8") + paths[name] = path + return promote( + qualification, + direct=paths["direct"], + pg_k1=paths["pg_k1"], + smollm2=paths["smollm2"], + v011_profiles=paths["v011_profiles"], + hf_cached_runtime=paths["hf_cached_runtime"], + vllm_runtime=paths["vllm_runtime"], + hf_reference=Path("benchmarks/results/rollout-runtime-v2-hf-reference.json"), + ) + + +def test_v011_full_qualification_binds_profile_and_runtime_evidence(tmp_path: Path) -> None: + promoted = _promote_v011(tmp_path) + assert {artifact.name for artifact in promoted.artifacts} >= { + "full_v011_profiles_result", + "full_hf_cached_runtime_result", + "full_vllm_runtime_result", + } + assert set(promoted.checks.executed) >= { + "v011_hf_cached_direct_n1", + "v011_hf_cached_pg_n4", + "v011_rewarded_pg_n4", + "v011_exact_wheel_runtime_gate", + "v011_vllm_direct_gkd_n4_r256", + "v011_policy_refresh_cache_invalidation", + "v011_external_engine_teardown", + } + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda r: r["v011_profiles"]["profiles"]["grouped_pg_n4"].update( # type: ignore[index,union-attr] + optimizer_updates=0 + ), + "optimizer update", + ), + ( + lambda r: r["hf_cached_runtime"]["source"].update(wheel_sha256="5" * 64), # type: ignore[index,union-attr] + "wheel", + ), + ( + lambda r: next( + cell + for cell in r["hf_cached_runtime"]["cells"] # type: ignore[index,union-attr] + if cell["response_bound"] == 256 + ).update(output_tokens_per_second=0.01), + "2.0x", + ), + ( + lambda r: r["hf_cached_runtime"]["refresh_probe"].update( # type: ignore[index,union-attr] + all_syncs_confirmed=False + ), + "refresh", + ), + ( + lambda r: r["vllm_runtime"]["conformance"].update(pg_threshold_passed=True), # type: ignore[index,union-attr] + "fail closed", + ), + ( + lambda r: r["vllm_runtime"]["teardown"].update(port_closed=False), # type: ignore[index,union-attr] + "teardown", + ), + ], +) +def test_v011_full_qualification_rejects_incomplete_evidence( + tmp_path: Path, mutate, message: str +) -> None: + with pytest.raises(ValueError, match=message): + _promote_v011(tmp_path, mutate=mutate) + + +def test_v011_full_schema_does_not_weaken_historical_full_record(tmp_path: Path) -> None: + from miniverl.qualification import GPUQualification + + historical, _ = _payload(tmp_path) + historical["level"] = "full_qualification" + historical["artifacts"].extend( # type: ignore[union-attr] + { + "name": name, + "path": "run-manifest.json", + "sha256": historical["artifacts"][0]["sha256"], # type: ignore[index] + "bytes": historical["artifacts"][0]["bytes"], # type: ignore[index] + } + for name in ( + "release_smoke_record", + "full_direct_result", + "full_pg_k1_result", + "full_smollm2_result", + ) + ) + historical["checks"]["executed"].extend( # type: ignore[index,union-attr] + [ + "direct_gkd_resume_equivalence", + "sampled_k1_resume_equivalence", + "smollm2_resume_equivalence", + "export_materialize_doctor", + ] + ) + GPUQualification.model_validate(historical) + + current = dict(historical) + current["miniverl_version"] = "0.11.0" + with pytest.raises(ValueError, match=r"v0\.11 full qualification"): + GPUQualification.model_validate(current) + + def test_full_qualification_promotion_binds_all_canonical_results(tmp_path: Path) -> None: from miniverl.qualification import sha256_file, validate_qualification_file from scripts.promote_full_gpu_qualification import promote diff --git a/tests/unit/test_known_good_environment.py b/tests/unit/test_known_good_environment.py index 6868686..fb5a7a7 100644 --- a/tests/unit/test_known_good_environment.py +++ b/tests/unit/test_known_good_environment.py @@ -23,3 +23,13 @@ def test_known_good_environment_is_scoped_to_one_measured_stack() -> None: assert payload["pytorch"]["index_url"].startswith("https://download.pytorch.org/whl/") assert "+cu" in payload["required"]["packages"]["torch"] assert payload["observed"]["driver"] == "596.49" + + +def test_v011_known_good_environment_is_wsl2_and_includes_vllm() -> None: + payload = json.loads( + (ROOT / "environments/known-good-rtx4080-wsl2-cu130.json").read_text(encoding="utf-8") + ) + assert payload["required"]["python"] == "3.12.13" + assert payload["observed"]["os"] == "Ubuntu on WSL2" + constraints = (ROOT / payload["constraints"]).read_text(encoding="utf-8") + assert "vllm==0.28.0" in constraints diff --git a/tests/unit/test_release_assets.py b/tests/unit/test_release_assets.py index 034f4dd..7a4e071 100644 --- a/tests/unit/test_release_assets.py +++ b/tests/unit/test_release_assets.py @@ -71,6 +71,18 @@ def _fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]: "full_direct_result": ("full/direct.json", b'{"kind":"direct"}\n'), "full_pg_k1_result": ("full/pg_k1.json", b'{"kind":"pg-k1"}\n'), "full_smollm2_result": ("full/smollm2.json", b'{"kind":"smollm2"}\n'), + "full_v011_profiles_result": ( + "full/v011-profiles.json", + b'{"kind":"v011-profiles"}\n', + ), + "full_hf_cached_runtime_result": ( + "full/hf-cached-runtime.json", + b'{"kind":"hf-cached-runtime"}\n', + ), + "full_vllm_runtime_result": ( + "full/vllm-runtime.json", + b'{"kind":"vllm-runtime"}\n', + ), } artifacts = [] for name, (relative, content) in evidence.items(): @@ -164,6 +176,13 @@ def _fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]: "sampled_k1_resume_equivalence", "smollm2_resume_equivalence", "export_materialize_doctor", + "v011_hf_cached_direct_n1", + "v011_hf_cached_pg_n4", + "v011_rewarded_pg_n4", + "v011_exact_wheel_runtime_gate", + "v011_vllm_direct_gkd_n4_r256", + "v011_policy_refresh_cache_invalidation", + "v011_external_engine_teardown", ], "skipped": [], "not_applicable": ["distributed_verl_execution"], @@ -245,6 +264,19 @@ def test_builds_exact_canonical_layout_and_checks_it(tmp_path: Path) -> None: ) +def test_release_evidence_mapping_is_versioned_for_historical_records() -> None: + from miniverl.release_assets import _archive_evidence_for_version + + historical = _archive_evidence_for_version("0.10.1") + current = _archive_evidence_for_version("0.11.0") + assert "full_v011_profiles_result" not in historical + assert set(current) - set(historical) == { + "full_v011_profiles_result", + "full_hf_cached_runtime_result", + "full_vllm_runtime_result", + } + + def test_archive_and_manifest_are_reproducible_and_explicit(tmp_path: Path) -> None: first = _build(tmp_path / "first") second = _build(tmp_path / "second") @@ -261,6 +293,9 @@ def test_archive_and_manifest_are_reproducible_and_explicit(tmp_path: Path) -> N "adapter_manifest", "input_prompts", "run_summary", + "v011_hf_cached_runtime", + "v011_profiles", + "v011_vllm_runtime", ] with tarfile.open(first / "qualification-evidence.tar.gz", "r:gz") as archive: members = archive.getmembers() diff --git a/tests/unit/test_release_workflow.py b/tests/unit/test_release_workflow.py index 283c490..e185081 100644 --- a/tests/unit/test_release_workflow.py +++ b/tests/unit/test_release_workflow.py @@ -115,6 +115,9 @@ def test_gpu_workflow_builds_candidate_only_on_hosted_job() -> None: assert "python -m build" not in qualify assert "--candidate-manifest candidate/candidate-manifest.json" in qualify assert 'PYTHONPATH: ""' in qualify and 'PYTHONHOME: ""' in qualify + assert "runs-on: [self-hosted, cuda, rtx4080, wsl2]" in qualify + assert "shell: pwsh" not in qualify + assert 'uv" venv --seed --python 3.12.13' in qualify def test_gpu_workflow_refuses_rerun_attempts_and_separates_smoke_from_full() -> None: @@ -137,6 +140,22 @@ def test_full_gpu_qualification_installs_only_the_exact_pinned_verl_source() -> assert "pip install --no-deps" in text assert requirement in text assert "scripts/promote_full_gpu_qualification.py" in text + for required in ( + "scripts/run_v011_profile_qualification.py", + "--backend hf_cached", + "--backend vllm", + "--v011-profiles", + "--hf-cached-runtime", + "--vllm-runtime", + "known-good-rtx4080-wsl2-cu130.txt", + ): + assert required in text + + +def test_release_accepts_only_the_v011_wsl2_known_good_stack() -> None: + qualification = _job(_workflow_text(), "verify-qualified-candidate", "validate-and-test") + assert "known-good-rtx4080-wsl2-cu130.json" in qualification + assert "known-good-rtx4080-cu130.json" not in qualification def test_release_recovery_preserves_full_qualification_evidence() -> None: diff --git a/tests/unit/test_v011_profile_qualification.py b/tests/unit/test_v011_profile_qualification.py new file mode 100644 index 0000000..9c0c7d7 --- /dev/null +++ b/tests/unit/test_v011_profile_qualification.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize( + ("kind", "samples_per_prompt", "rewarded"), + [ + ("direct_n1", 1, False), + ("grouped_pg_n4", 4, False), + ("rewarded_pg_n4", 4, True), + ], +) +def test_v011_profile_qualification_compiles_exact_intended_profile( + tmp_path: Path, + kind: str, + samples_per_prompt: int, + rewarded: bool, +) -> None: + from scripts.run_v011_profile_qualification import _build_plan, _write_dataset + + dataset = tmp_path / kind / "data.parquet" + dataset.parent.mkdir() + _write_dataset(dataset, rewarded=rewarded) + plan, config = _build_plan(kind, dataset, tmp_path / kind / "plan.json") + + assert config.rollout.backend == "hf_cached" + assert config.rollout.samples_per_prompt == samples_per_prompt + assert config.source.use_task_rewards is rewarded + assert config.train.cycles == 1 + assert plan.resolved_native_config == config.model_dump(mode="json")