diff --git a/.github/workflows/gpu-tests.yml b/.github/workflows/gpu-tests.yml new file mode 100644 index 000000000..3bb8707a5 --- /dev/null +++ b/.github/workflows/gpu-tests.yml @@ -0,0 +1,385 @@ +name: GPU Tests (merge queue) + +# Run the GPU test suite (which CPU CI can't, since GitHub runners have no GPU) on a rented +# Vast.ai RTX 5090 when a PR is in the merge queue, and block the merge if it fails. +# Groups (see scripts/gpu_test.sh): math-cuda kernel parity, cuda_path_integration (GPU proof +# verifies), cuda_fallback (CPU fallback verifies), the prover/stark/crypto/ecsm suite on the +# GPU path, and the comprehensive all-instructions prove. Orchestration runs on a GitHub-hosted +# runner; all GPU work happens on the rented box (provisioned by the template onstart). The box +# is ALWAYS destroyed at the end. +# +# Triggered on `merge_group` (one rental per merge, not per push) + `workflow_dispatch` for +# manual runs. To gate merges, add the job name `gpu-tests` to the branch-protection required +# status checks for `main` (GitHub UI). +# +# Requires repo secrets: +# VAST_API_KEY — https://cloud.vast.ai/manage-keys/ +# VAST_TEMPLATE_HASH — hash of the "NVIDIA CUDA Lambda VM 64GB" template + +on: + merge_group: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gpu-tests-${{ github.ref }} + cancel-in-progress: true + +env: + # Vast offer search: RTX 5090, >=16 cores, >=48GB RAM, >=64GB disk, verified + rentable, + # Blackwell-capable driver. + GPU_NAME: RTX_5090 + # Escalating price bands ($/hr, ascending). We prefer the cheapest band with capacity and + # only climb to a pricier one when a lower band is empty or its boxes fail to provision. + # The last value is the hard cap. (Idea borrowed from IDP's create-vast-server.yml.) + PRICE_THRESHOLDS: "0.6 0.8 1.0" + VAST_IMAGE_DISK: "64" + # Unique per-run label set on the instance, for identification + leak-proof teardown. + RUN_LABEL: "gpu-tests-${{ github.run_id }}-${{ github.run_attempt }}" + # Pin the Vast CLI to an immutable commit (a PyPI version can be re-published; a commit + # hash can't) — avoids pulling untrusted code at run time. + VAST_CLI_COMMIT: "28494d92c6c03d887f8375085243c22eb68c5874" + +jobs: + gpu-tests: + runs-on: ubuntu-latest + # Provisioning + cuda builds + 5 test groups; the prover suite (single-threaded, real + # ELF proves) dominates. Generous ceiling; teardown still always destroys the box. + timeout-minutes: 240 + steps: + - name: Install Vast CLI + # No secrets in this step's env: install-time code can't read the API key during pip + # install. Pinned to an immutable commit (see VAST_CLI_COMMIT) for the same reason. + # --break-system-packages: the ephemeral runner's Python may be PEP-668 "externally + # managed"; safe to override on a disposable runner. + run: pip install --quiet --break-system-packages "git+https://github.com/vast-ai/vast-cli.git@${VAST_CLI_COMMIT}" + + - name: Authenticate Vast CLI + env: + VAST_API_KEY: ${{ secrets.VAST_API_KEY }} + run: vastai set api-key "$VAST_API_KEY" + + - name: Generate ephemeral SSH key + id: sshkey + run: | + mkdir -p "$HOME/.ssh" + KEY="$HOME/.ssh/vast_gpu_tests" + ssh-keygen -t ed25519 -N "" -f "$KEY" -C "gh-actions-gpu-tests-${GITHUB_RUN_ID}" >/dev/null + echo "key_path=$KEY" >> "$GITHUB_OUTPUT" + + # Rent → provision → wait-for-ready, retrying across DIFFERENT offers. A box that never + # becomes ready within PROVISION_TIMEOUT (slow image pull, dead sshd, stuck onstart) is + # destroyed and a different host is tried — up to MAX_TRIES. We prefer the cheapest price + # band with capacity and only climb when a band is empty. Ideas from IDP's + # create-vast-server.yml (price bands + per-box readiness budget + tried-host exclusion). + - name: Provision instance (retry across offers) + id: provision + env: + VAST_TEMPLATE_HASH: ${{ secrets.VAST_TEMPLATE_HASH }} + KEY: ${{ steps.sshkey.outputs.key_path }} + # Distinct offers to try before giving up (each is a different physical host). + MAX_TRIES: "5" + # Per-box readiness budget (seconds): running + sshd + onstart done, else swap hosts. + PROVISION_TIMEOUT: "600" + # How many times to re-scan all bands for an offer before failing (transient scarcity). + OFFER_ATTEMPTS: "10" + OFFER_INTERVAL: "30" + # Require driver major >= this so cudarc matches the runtime driver (older drivers lack + # newer symbols and the GPU path falls back to CPU). Filtered client-side in jq because + # vast can't numerically compare the driver_version string server-side. + MIN_DRIVER: "580" + run: | + # We handle failures explicitly (retry/destroy), so don't let -e abort the step. + set +e + SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes" + PUB="$(cat "$KEY.pub")" + read -r -a THRESHOLDS <<< "$PRICE_THRESHOLDS" + # cpu_ram filter is in GB (the returned .cpu_ram field is in MB). + BASE="gpu_name=${GPU_NAME} num_gpus=1 cpu_cores_effective>=16 cpu_ram>=48 disk_space>=${VAST_IMAGE_DISK} verified=true rentable=true cuda_max_good>=13.1" + TRIED="" # space-separated machine_ids already attempted (never re-pick a flaky host) + + destroy() { # $1 = instance id; retry transient destroy failures so no box is stranded + for _ in 1 2 3; do + vastai destroy instance "$1" --yes && return 0 + sleep 10 + done + echo "::warning::failed to destroy instance $1 after 3 tries (label $RUN_LABEL)" + } + + # Pick the priciest offer in the cheapest non-empty band, excluding tried hosts. + # Premium hosts within a band tend to have faster disks/network + better reliability. + # Sets OFFER_ID / MACHINE_ID / OFFER_PRICE / SPECS. Returns 1 if no band has capacity. + pick_offer() { + OFFER_ID=""; MACHINE_ID=""; OFFER_PRICE=""; SPECS="" + local excl sel + excl="[$(echo "$TRIED" | tr -s ' ' ',' | sed 's/^,//; s/,$//')]" + for band in "${THRESHOLDS[@]}"; do + vastai search offers "$BASE dph_total<=$band" --raw -o dph_total > offers.json 2>/dev/null || true + sel=$(jq -c --argjson excl "$excl" ' + map(select((try (.driver_version|split(".")[0]|tonumber) catch 0) >= '"$MIN_DRIVER"')) + | map(select(([.machine_id] | inside($excl)) | not)) + | sort_by(.dph_total) | reverse | .[0] // empty' offers.json) + if [ -n "$sel" ]; then + OFFER_ID=$(echo "$sel" | jq -r '.id') + MACHINE_ID=$(echo "$sel" | jq -r '.machine_id') + OFFER_PRICE=$(echo "$sel" | jq -r '.dph_total') + SPECS=$(echo "$sel" | jq -r '"cores=\(.cpu_cores_effective) ram=\(.cpu_ram)MB disk=\(.disk_space)GB driver=\(.driver_version) cuda_max_good=\(.cuda_max_good) geo=\(.geolocation)"') + echo " band<=\$$band -> offer $OFFER_ID (machine $MACHINE_ID) at \$$OFFER_PRICE/hr | $SPECS" + return 0 + fi + echo " band<=\$$band -> no capacity" + done + return 1 + } + + # Wait until the box is running + sshd accepts our key + onstart bootstrap finished. + # Sets HOST/PORT. Returns 0 ready, 1 timeout, 2 unrecoverable image/scheduling failure. + wait_ready() { # $1 = instance id + local iid="$1" waited=0 status host port msg + HOST=""; PORT="" + while [ "$waited" -lt "$PROVISION_TIMEOUT" ]; do + vastai show instance "$iid" --raw > inst.json 2>/dev/null || true + status=$(jq -r '.actual_status // empty' inst.json) + msg=$(jq -r '.status_msg // empty' inst.json) + # Fail fast on an image that can't be pulled / a host that can't meet the ask — + # waiting the full budget won't help (borrowed from IDP wait_ready). + case "$msg" in + *"not started loading"*|*"cannot be met"*|*"Error response from daemon"*) + echo " unrecoverable: $msg"; return 2 ;; + esac + # --direct: SSH straight to the public IP + the host port mapped to container 22 + # (the .ssh_host/.ssh_port proxy fields are unreliable). + host=$(jq -r '.public_ipaddr // empty' inst.json) + port=$(jq -r '.ports["22/tcp"][0].HostPort // empty' inst.json) + if [ "$status" = "running" ] && [ -n "$host" ] && [ -n "$port" ]; then + HOST="$host"; PORT="$port" + # shellcheck disable=SC2086 # $SSH_OPTS is intentionally word-split into flags + if ssh $SSH_OPTS -i "$KEY" -p "$PORT" "root@$HOST" true 2>/dev/null; then + # onstart's final stdout line is "=== done ==="; fall back to its artifacts. + # shellcheck disable=SC2016,SC2086 # $HOME expands remotely; $SSH_OPTS word-splits + if ssh $SSH_OPTS -i "$KEY" -p "$PORT" "root@$HOST" \ + 'grep -q "=== done ===" /var/log/onstart.log 2>/dev/null || { test -x "$HOME/.cargo/bin/cargo" && test -f /opt/lambda-vm-sysroot/include/stdlib.h && test -d /workspace/lambda_vm/.git; }' 2>/dev/null; then + echo " ready at $HOST:$PORT (onstart done, ${waited}s)"; return 0 + fi + echo " status=$status ssh ok, onstart still running (${waited}s)" + else + echo " status=$status ssh=$HOST:$PORT sshd not accepting yet (${waited}s)" + fi + else + echo " status=$status host=$host port=$port (${waited}s)" + fi + sleep 15; waited=$((waited + 15)) + done + echo " not ready within ${PROVISION_TIMEOUT}s"; return 1 + } + + for try in $(seq 1 "$MAX_TRIES"); do + echo "=== provisioning attempt $try/$MAX_TRIES (bands: $PRICE_THRESHOLDS, driver>=$MIN_DRIVER) ===" + + # Scan bands for an offer, re-scanning to ride out transient RTX 5090 scarcity. + got="" + for scan in $(seq 1 "$OFFER_ATTEMPTS"); do + if pick_offer; then got=1; break; fi + echo " no capacity in any band (scan $scan/$OFFER_ATTEMPTS); retry in ${OFFER_INTERVAL}s" + sleep "$OFFER_INTERVAL" + done + if [ -z "$got" ]; then + echo "::error::No RTX 5090 offer matched after $OFFER_ATTEMPTS scans (bands: $PRICE_THRESHOLDS, >=16 cores, >=48GB RAM, >=${VAST_IMAGE_DISK}GB disk, driver>=${MIN_DRIVER})" + exit 1 + fi + TRIED="$TRIED $MACHINE_ID" + + vastai create instance "$OFFER_ID" \ + --template_hash "$VAST_TEMPLATE_HASH" \ + --disk "$VAST_IMAGE_DISK" \ + --label "$RUN_LABEL" \ + --ssh --direct --raw > create.json 2>/dev/null + # Log only the fields we need (the full --raw response could carry a sensitive field). + jq '{success, new_contract: (.new_contract // .instances.new_contract)}' create.json 2>/dev/null || true + IID=$(jq -r '.new_contract // .instances.new_contract // empty' create.json 2>/dev/null) + if [ -z "$IID" ]; then + echo "::warning::create failed for offer $OFFER_ID; trying another host" + continue + fi + # Persist immediately so the always() teardown destroys this box even if we're + # cancelled mid-wait. Cleared below if we destroy it ourselves and move on. + echo "$IID" > "$RUNNER_TEMP/vast_instance_id" + echo "created instance $IID (offer $OFFER_ID, machine $MACHINE_ID, label $RUN_LABEL)" + + # Attach the ephemeral pubkey to THIS instance only (its authorized_keys); it goes + # away when the box is destroyed, so there's no account-level key to clean up. + attached="" + for _ in $(seq 1 12); do + vastai attach ssh "$IID" "$PUB" && { attached=1; break; } + sleep 10 + done + if [ -z "$attached" ]; then + echo "::warning::could not attach ssh key to $IID; destroying and trying another host" + destroy "$IID"; rm -f "$RUNNER_TEMP/vast_instance_id"; continue + fi + + if wait_ready "$IID"; then + { + echo "host=$HOST"; echo "port=$PORT"; echo "id=$IID" + echo "price=$OFFER_PRICE"; echo "specs=$SPECS" + } >> "$GITHUB_OUTPUT" + { + echo "### GPU box" + echo "- offer \`$OFFER_ID\` (machine \`$MACHINE_ID\`) at \$$OFFER_PRICE/hr" + echo "- $SPECS" + } >> "$GITHUB_STEP_SUMMARY" + echo "instance $IID ready — proceeding to tests" + exit 0 + else + rc=$? + fi + echo "::warning::instance $IID not ready (rc=$rc: 1=timeout, 2=image/scheduling failure); destroying and trying another host" + destroy "$IID"; rm -f "$RUNNER_TEMP/vast_instance_id" + done + + echo "::error::Could not provision a ready GPU box after $MAX_TRIES distinct offers" + exit 1 + + # Print the box's hardware in its own step: the "Run GPU tests" log is huge and gets + # truncated/rotated in the UI, so nvidia-smi printed inside gpu_test.sh can be hard to + # recover. Its own short-lived step keeps the GPU/driver/CPU/RAM info easy to find. + - name: Print machine info + env: + HOST: ${{ steps.provision.outputs.host }} + PORT: ${{ steps.provision.outputs.port }} + KEY: ${{ steps.sshkey.outputs.key_path }} + run: | + SSH="ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes -i $KEY -p $PORT root@$HOST" + # shellcheck disable=SC2016 # $(nproc) etc. must expand on the remote box, not here + $SSH 'nvidia-smi; echo; nvcc --version | tail -n2; echo; \ + echo "CPU: $(nproc) cores"; \ + grep -m1 "model name" /proc/cpuinfo; \ + free -h' + + - name: Run GPU tests + id: tests + env: + HOST: ${{ steps.provision.outputs.host }} + PORT: ${{ steps.provision.outputs.port }} + KEY: ${{ steps.sshkey.outputs.key_path }} + # merge_group: refs/heads/gh-readonly-queue/main/pr-… (the merge commit = PR + main), + # so we test exactly what will land. workflow_dispatch: the chosen branch ref. + REF: ${{ github.ref }} + run: | + # ServerAlive*: ConnectTimeout only covers connection setup; without keepalives a box + # that wedges or drops off the network mid-suite would hang this step silently until + # the 240-minute job timeout. 60s x 10 fails the run ~10 minutes after the box goes dark. + SSH="ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o ServerAliveInterval=60 -o ServerAliveCountMax=10 -o BatchMode=yes -i $KEY -p $PORT root@$HOST" + # Defense-in-depth: never interpolate an unvalidated ref into the remote `bash -lc`. + case "$REF" in + ''|*[!A-Za-z0-9._/-]*) echo "::error::invalid ref: '$REF'"; exit 1 ;; + esac + # Check out the ref under test on the box, then run the CUDA test groups. + # gpu_test.sh owns the CUDARC_PIN / SYSROOT_DIR defaults — don't duplicate them here. + REMOTE="set -e; cd /workspace/lambda_vm; \ + git fetch --force origin '$REF'; \ + git checkout -f FETCH_HEAD; \ + bash scripts/gpu_test.sh" + + # pipefail so a test failure on the box propagates through the tee pipe and FAILS this + # step (which fails the job and blocks the merge), instead of being masked by tee. + # 2>&1 so remote stderr (build errors, panics) is captured too — both into the live + # step log and the file the run-summary step tails. + set -o pipefail + $SSH "bash -lc \"$REMOTE\"" 2>&1 | tee "$RUNNER_TEMP/gpu_test_out.txt" + + - name: Write run summary + if: always() && (steps.tests.outcome == 'success' || steps.tests.outcome == 'failure') + env: + OUTCOME: ${{ steps.tests.outcome }} + run: | + OUT="$RUNNER_TEMP/gpu_test_out.txt" + { + echo "## GPU tests (CUDA suite) — ${OUTCOME}" + if [ "$OUTCOME" = "success" ]; then + echo "All GPU test groups passed." + else + # Group the failed tests under the make target that ran them: gpu_test.sh prints + # "=== make ===" before each group, and cargo prints "test ... FAILED". + report=$(awk ' + /^=== make / { grp=$3; next } + / \.\.\. FAILED/ { fails[grp]=fails[grp] "\n - " $2; n[grp]++ } + END { for (g in fails) printf "- **%s** (%d failed):%s\n", g, n[g], fails[g] } + ' "$OUT" 2>/dev/null || true) + # Per-test panic/assertion messages: each "thread '…' panicked at …:" block plus + # its following message lines (assertion, left/right), capped per block. + details=$(awk ' + /^thread .* panicked at / { cap=1; lines=0; buf=$0; next } + cap { + if ($0 ~ /^note: run with/ || $0 ~ /^----/ || $0 ~ /^test / || $0 ~ /^=== / || $0 ~ /^[[:space:]]*$/) { printf "%s\n\n", buf; cap=0; next } + if (lines < 14) { buf=buf "\n" $0; lines++ } else if (lines==14) { buf=buf "\n ...(truncated)"; lines++ } + } + END { if (cap) printf "%s\n", buf } + ' "$OUT" 2>/dev/null || true) + if [ -n "$report" ]; then + echo; echo "### Failed tests by group"; echo "$report" + if [ -n "$details" ]; then + echo; echo "### Failure details"; echo '```'; echo "$details"; echo '```' + fi + else + # No per-test failures parsed (likely a build/infra error) — fall back to the + # failed-group markers plus a short log tail. + grps=$(grep -F '::error::GPU test group failed:' "$OUT" 2>/dev/null | sed 's/.*failed: /- /' | sort -u || true) + [ -n "$grps" ] && { echo; echo "### Failed groups"; echo "$grps"; } + echo; echo "No individual test failures parsed (build/infra error?). Last lines:" + echo '```'; tail -n 40 "$OUT" 2>/dev/null || echo "(no output captured)"; echo '```' + fi + echo; echo "Full output: \"Run GPU tests\" step log, or the \`gpu-test-log\` artifact (survives UI log truncation)." + fi + } >> "$GITHUB_STEP_SUMMARY" + + # The step log gets truncated/rotated in the UI for multi-hour runs (see the machine-info + # comment above); the artifact keeps the complete output retrievable. + - name: Upload full test log + if: always() && (steps.tests.outcome == 'success' || steps.tests.outcome == 'failure') + uses: actions/upload-artifact@v4 + with: + name: gpu-test-log + path: ${{ runner.temp }}/gpu_test_out.txt + if-no-files-found: ignore + retention-days: 14 + + # --- Teardown: ALWAYS destroy the instance (cost guardrail) --- + - name: Destroy instance + if: always() + run: | + # Retry transient failures (network/auth) so a paid box isn't stranded. + # --yes: skip the interactive [y/N] confirm (CI has no tty). + destroy() { + iid="$1"; destroyed="" + for attempt in 1 2 3; do + if vastai destroy instance "$iid" --yes; then destroyed=1; break; fi + echo "destroy attempt $attempt failed; retrying in 10s..." + sleep 10 + done + [ -n "$destroyed" ] || echo "::warning::Failed to destroy instance $iid after 3 attempts — check the Vast console (label $RUN_LABEL)" + } + if [ -f "$RUNNER_TEMP/vast_instance_id" ]; then + IID=$(cat "$RUNNER_TEMP/vast_instance_id") + echo "Destroying instance $IID" + destroy "$IID" + else + # The id file is written only AFTER create succeeds AND its JSON parses, so a box can + # exist unrecorded if the run was cancelled in that window or the parse failed. Fall + # back to destroying by our unique RUN_LABEL so the box can't leak (bill indefinitely). + echo "No instance id recorded; searching Vast for any box labelled $RUN_LABEL..." + vastai show instances --raw > all_inst.json 2>/dev/null || echo '[]' > all_inst.json + LEAKED=$(jq -r --arg L "$RUN_LABEL" \ + '(if type=="array" then . else (.instances // []) end) | .[] | select(.label == $L) | .id' \ + all_inst.json 2>/dev/null || true) + if [ -z "$LEAKED" ]; then + echo "No instance labelled $RUN_LABEL found; nothing to destroy." + else + for IID in $LEAKED; do + echo "Destroying leaked instance $IID (label $RUN_LABEL)" + destroy "$IID" + done + fi + fi diff --git a/Makefile b/Makefile index d725ca2d7..be6d53811 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,8 @@ compile-programs compile-recursion-elfs clean-asm clean-rust clean-bench clean-shared \ clean-recursion-elfs clean test test-asm \ test-rust test-executor test-flamegraph flamegraph-prover test-profile-recursion test-profile-recursion-single test-profile-recursion-multi \ -test-fast test-prover test-prover-all test-disk-spill test-math-cuda test-cuda-integration \ +test-fast test-prover test-prover-all test-prover-debug test-disk-spill test-math-cuda test-cuda-integration test-cuda-fallback \ +test-prover-cuda test-prover-comprehensive-cuda \ bench-math-cuda bench-prover bench-prover-cuda build check clippy fmt lint regen-ethrex-fixtures \ update-ethrex-fixture-checksums check-ethrex-fixture-checksums @@ -287,11 +288,32 @@ test-math-cuda: cargo test -p math-cuda --release # End-to-end cuda dispatch coverage (requires NVIDIA GPU + nvcc). -# Asserts every R1/R2/R3 GPU counter fired on a real prove. +# Asserts the R1-R4 GPU dispatch counters fired on a real prove. test-cuda-integration: cargo test -p lambda-vm-prover --release --features cuda \ --test cuda_path_integration -- --ignored --nocapture +# GPU error-path coverage (requires NVIDIA GPU + nvcc). +# Forces cuda dispatch errors and asserts the CPU fallback still produces a verifying proof. +test-cuda-fallback: + cargo test -p lambda-vm-prover --release --features test-cuda-faults \ + --test cuda_fallback_tests -- --ignored --nocapture --test-threads=1 + +# The prover/stark/crypto/ecsm test suite with the GPU (cuda) path enabled (requires NVIDIA +# GPU + nvcc). The GPU CI counterpart of CPU CI's sharded prover tests. Single-threaded: the +# GPU serializes proves and the dispatch counters are process-global. cuda on prover cascades +# to stark; crypto/ecsm build without it (they have no GPU path). +test-prover-cuda: + cargo test --release -p lambda-vm-prover -p stark -p crypto -p ecsm \ + --features lambda-vm-prover/cuda -- --test-threads=1 + +# The comprehensive all-instructions prove (ignored by default) on the GPU path (requires +# NVIDIA GPU + nvcc). GPU counterpart of the all-instructions half of CPU CI's merge-queue-only +# comprehensive job (the CPU job also runs test_recursion_execute; recursion has no GPU leg yet). +test-prover-comprehensive-cuda: + cargo test --release -p lambda-vm-prover --features cuda \ + test_prove_elfs_all_instructions_64_full -- --ignored --test-threads=1 --nocapture + # math-cuda quick microbench (median of 10 runs) bench-math-cuda: cargo test -p math-cuda --release --test bench_quick -- --ignored --nocapture diff --git a/README.md b/README.md index 151934433..2a9e3ed6e 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,11 @@ See [`spec/README.md`](./spec/README.md) for full setup instructions. | `make test-asm` | Compile and run ASM tests | | `make test-rust` | Compile and run Rust tests | | `make test-executor` | Compile all programs and run executor tests | -| `make test-math-cuda` | math-cuda parity tests (requires NVIDIA GPU + nvcc) | +| `make test-math-cuda` | math-cuda GPU kernel parity tests (requires NVIDIA GPU + nvcc; see GPU Tests) | +| `make test-cuda-integration` | End-to-end GPU dispatch + proof verification (requires NVIDIA GPU + nvcc) | +| `make test-cuda-fallback` | GPU error-path / CPU-fallback tests (requires NVIDIA GPU + nvcc) | +| `make test-prover-cuda` | Prover/stark/crypto/ecsm suite on the GPU path (requires NVIDIA GPU + nvcc) | +| `make test-prover-comprehensive-cuda` | Comprehensive all-instructions prove on the GPU path (requires NVIDIA GPU + nvcc) | | `make build` | Build all workspace crates | | `make check` | Check all crates (faster than build, no codegen) | | `make clippy` | Run clippy on all crates | @@ -219,6 +223,21 @@ You can run it with `make test-rust` +### GPU Tests + +The CUDA test groups run only on a machine with an NVIDIA GPU and `nvcc`: + +- `make test-math-cuda` — GPU-vs-CPU kernel parity (NTT, LDE, barycentric, FRI, …) +- `make test-cuda-integration` — proves a guest on GPU and checks every dispatch fired + the proof verifies +- `make test-cuda-fallback` — forces GPU dispatch errors and checks the CPU fallback still verifies +- `make test-prover-cuda` — the prover/stark/crypto/ecsm suite with the GPU path enabled +- `make test-prover-comprehensive-cuda` — the comprehensive all-instructions prove on the GPU path + +The kernels are compiled by `nvcc` into PTX that the driver JIT-compiles at load, so the GPU's +driver must be new enough for the toolkit — an older driver rejects the PTX with +`CUDA_ERROR_UNSUPPORTED_PTX_VERSION`. These groups run automatically on a rented GPU in the merge +queue via `.github/workflows/gpu-tests.yml` (which filters offers on `cuda_max_good`). + ## Benchmarking & Profiling You can create a flamegraph for proof generation using the following target: @@ -298,3 +317,4 @@ at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. + diff --git a/crypto/math-cuda/build.rs b/crypto/math-cuda/build.rs index b2f61f9a2..73cc10d3a 100644 --- a/crypto/math-cuda/build.rs +++ b/crypto/math-cuda/build.rs @@ -72,6 +72,11 @@ fn compile_ptx(src: &str, out_name: &str, have_nvcc: bool) { // compute capability. If unset, try `nvidia-smi` to match the host GPU // (avoids JIT failures like nvcc-13.0 PTX rejected on Blackwell drivers); // fall back to compute_89 (Ada) when detection fails. + // + // NOTE: this `-arch` only sets the *virtual arch*, not the PTX ISA version, which is + // fixed by this nvcc's CUDA toolkit. The runtime driver must support that toolkit's CUDA + // version or it rejects the PTX with CUDA_ERROR_UNSUPPORTED_PTX_VERSION — i.e. the box's + // driver CUDA must be >= the build toolkit's CUDA. See README "GPU Tests". let arch = env::var("CUDARC_NVCC_ARCH").unwrap_or_else(|_| detect_arch()); let status = Command::new(nvcc_path()) diff --git a/docs/roadmap.md b/docs/roadmap.md index 3658a946b..97ffa3138 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,8 +55,8 @@ The first version is going to use the primitives contained in [lambdaworks](http | Feature | Description | Status | |---------------------------- |-----------------------------------|--------------| | Fields | Improve field performance using assembly | Planned | -| GPU-Fast-Fourier transform | Implement GPU version of FFT | Planned | -| GPU-Merkle tree | Implement GPU version for Merkle trees | Planned | +| GPU-Fast-Fourier transform | Implement GPU version of FFT | Done | +| GPU-Merkle tree | Implement GPU version for Merkle trees | Done | | Parallel trace generation | Use GPU for fast trace generation | Planned | -| GPU-FRI | Perform FRI on GPU | Planned | +| GPU-FRI | Perform FRI on GPU | Done | \ No newline at end of file diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 2b8c16342..527c092c5 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1440,7 +1440,7 @@ fn test_verify_rejects_tampered_public_output() { /// - Division: DIV, DIVU, REM, REMU /// - Control: LUI, AUIPC, JALR #[test] -#[ignore] // Slow: run with `cargo test --ignored` or `make test-prover-all` +#[ignore] // Slow: run with `cargo test -- --ignored` or `make test-prover-all` fn test_prove_elfs_all_instructions_64_full() { let _ = env_logger::builder().is_test(true).try_init(); diff --git a/scripts/gpu_test.sh b/scripts/gpu_test.sh new file mode 100755 index 000000000..661339f11 --- /dev/null +++ b/scripts/gpu_test.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# gpu_test.sh — run the CUDA-only test groups on a GPU box. +# +# Exercises the CUDA path, which CPU CI can't (GitHub runners have no GPU): +# 1. math-cuda kernel parity (make test-math-cuda) +# 2. end-to-end GPU dispatch + proof (make test-cuda-integration) +# 3. GPU error-path / CPU fallback (make test-cuda-fallback) +# 4. prover/stark/crypto/ecsm suite (make test-prover-cuda) — CPU CI's prover tests on GPU +# 5. comprehensive all-instructions (make test-prover-comprehensive-cuda) +# +# Runs on the rented Vast box from the gpu-tests.yml merge-queue workflow. All groups +# run even if one fails (so the log shows every failure); the script exits non-zero if ANY +# group failed, which fails the workflow job and blocks the merge. +# +# Env: +# CUDARC_PIN cudarc CUDA-version feature to pin (default cuda-12080). See the sed below. +# SYSROOT_DIR rv64 sysroot (default /opt/lambda-vm-sysroot, provisioned by the template). + +set -euo pipefail + +CUDARC_PIN="${CUDARC_PIN:-cuda-12080}" +export SYSROOT_DIR="${SYSROOT_DIR:-/opt/lambda-vm-sysroot}" + +log() { printf '\n=== %s ===\n' "$*"; } + +# --- GPU toolchain sanity (fail loudly rather than silently falling back to CPU) --- +log "GPU toolchain" +if ! command -v nvcc >/dev/null 2>&1; then + for d in /usr/local/cuda/bin /usr/local/cuda-*/bin; do + [ -x "$d/nvcc" ] && export PATH="$d:$PATH" && break + done +fi +command -v nvcc >/dev/null 2>&1 || { echo "ERROR: nvcc not found — CUDA toolkit missing" >&2; exit 1; } +nvcc --version | tail -n 2 +# Full nvidia-smi up front: GPU model, driver + CUDA runtime version, memory — for the log. +nvidia-smi +nvidia-smi --query-gpu=name,driver_version,compute_cap --format=csv,noheader + +# --- Pin cudarc so it binds a fixed driver-symbol set -------------------------- +# crypto/math-cuda/Cargo.toml uses `cuda-version-from-build-system` + `fallback-latest`; +# when detection falls back to "latest", cudarc requests symbols some boxes' driver doesn't +# export (e.g. cuDevSmResourceSplit / cuCtxGetDevice_v2) -> runtime panic. Pinning to a fixed, +# conservative CUDA version binds a known driver-symbol set instead. (This is cudarc's +# host-side driver-API floor — independent of the PTX/driver version the offer filter targets.) +log "pinning cudarc to $CUDARC_PIN" +# Guard the sed anchors: if math-cuda's cudarc features are ever renamed/reformatted, a silent +# no-op here would bring the fallback-latest driver-symbol panic back with a confusing signature. +for anchor in '"cuda-version-from-build-system"' '"fallback-latest"'; do + grep -qF "$anchor" crypto/math-cuda/Cargo.toml \ + || { echo "ERROR: sed anchor $anchor not found in crypto/math-cuda/Cargo.toml — update this script's cudarc pin" >&2; exit 1; } +done +# Restore the tracked file on exit so a manual run on a dev box doesn't leave the tree dirty +# (CI doesn't need this — the workflow re-checks-out before every run — but it's harmless there). +CUDARC_TOML_BACKUP="$(mktemp)" +cp crypto/math-cuda/Cargo.toml "$CUDARC_TOML_BACKUP" +trap 'cp "$CUDARC_TOML_BACKUP" crypto/math-cuda/Cargo.toml; rm -f "$CUDARC_TOML_BACKUP"' EXIT +sed -i "s/\"cuda-version-from-build-system\"/\"${CUDARC_PIN}\"/; /\"fallback-latest\"/d" \ + crypto/math-cuda/Cargo.toml + +# --- Build the guest ELFs the tests prove --------------------------------------- +# math-cuda parity needs none; cuda_path_integration / cuda_fallback prove an asm ELF; the +# prover suite (Groups 4 & 5) proves asm AND rust guests. Build both up front. +log "compiling guest programs (asm + rust)" +make compile-programs-asm +make compile-programs-rust + +# --- Run the CUDA test groups via the Makefile targets -------------------------- +fail=0 +run() { # $1 = make target + log "make $1" + if ! make "$1"; then + echo "::error::GPU test group failed: $1" + fail=1 + fi +} +run test-math-cuda # Group 1: kernel parity +run test-cuda-integration # Group 2: end-to-end GPU dispatch + proof verifies +run test-cuda-fallback # Group 3: GPU error -> CPU fallback still verifies +run test-prover-cuda # Group 4: prover/stark/crypto/ecsm suite on the GPU path +run test-prover-comprehensive-cuda # Group 5: comprehensive all-instructions prove on GPU + +if [ "$fail" -ne 0 ]; then + log "FAILED — one or more GPU test groups failed" + exit 1 +fi +log "all GPU test groups passed"