Files
foxhunt/docs/superpowers/specs/2026-03-13-gpu-test-workflow-design.md
jgrusewski 696d0d6bff docs: rev 2 GPU test workflow spec — fix all 4 blockers
- Compile+test in same H100 pod (eliminates cross-node PVC transfer)
- New cargo-target-cuda-test PVC (30Gi) — zero contention with training
- onExit notify, podGC, activeDeadlineSeconds, fsGroup, gpu-warmup
- Continue-on-failure with per-model exit code capture
- CUDA_COMPUTE_CAP=90, complete change detection paths
- TEST_DATA_DIR marked as required prerequisite code change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:10:01 +01:00

459 lines
16 KiB
Markdown

# H100 GPU Test Workflow — Design Spec
**Date**: 2026-03-13
**Status**: Approved (rev 2 — post spec-review fixes)
**Scope**: Argo WorkflowTemplate for compile + GPU/CUDA test execution on H100
## Problem
GPU smoke tests take 42+ minutes on a 4GB RTX 3050 Ti in debug mode and OOM on multi-test concurrency. Full model coverage (DQN, PPO, 8 supervised models) is impossible locally. We need a fast, reproducible GPU test path on H100 that validates CUDA hot-path correctness with real market data.
## Decision Summary
| Decision | Choice |
|----------|--------|
| Test scope | Parameterized — all model families by default, selectable subset |
| Test data | Dedicated `test-data-pvc` (10Gi) with curated subset |
| PVC strategy | Two new PVCs — zero contention with training workflows |
| Compile strategy | Compile + test on H100 (single pod, same node = no binary transfer) |
| Triggers | Manual (active) + on-push gated (active) + nightly (suspended) |
## 1. Test Data Infrastructure
### 1.1 test-data-pvc
New PersistentVolumeClaim:
- **Name**: `test-data-pvc`
- **Namespace**: `foxhunt`
- **Size**: 10Gi
- **StorageClass**: `scw-bssd`
- **AccessMode**: `ReadWriteOnce`
Separate from `training-data-pvc` — no RWO contention with training workflows.
### 1.2 Data Layout
```
/data/test-data/
├── ohlcv/ES.FUT/ES.FUT_2025-Q2.dbn.zst # ~2MB — OHLCV bars
├── mbp10/ES.FUT/ES.FUT_2025-Q2.dbn.zst # ~126MB — orderbook snapshots
└── trades/ES.FUT/ES.FUT_2025-Q2.dbn.zst # ~300MB — tick trades
```
**~430MB total** — ES.FUT 2025-Q2 across all three schemas. Exercises every code path: feature extraction, OFI calculation, VPIN, regime classification, walk-forward windows.
### 1.3 Setup Job
`infra/k8s/training/populate-test-data-job.yaml` — one-time Kubernetes Job that:
1. Runs on H100 node pool (same node as test-data-pvc to avoid RWO cross-node)
2. Mounts `training-data-pvc` read-only at `/data/training`
3. Mounts `test-data-pvc` read-write at `/data/test-data`
4. Copies ES.FUT 2025-Q2 files from each schema directory
5. Validates file integrity (size check, dbn header parse)
**Node affinity**: Must schedule on the same node pool as the GPU test workflow (`ci-training-h100`) to ensure the RWO PVC stays on the correct node.
Re-runnable via `scripts/refresh-test-data.sh`.
### 1.4 Test Code Integration
Tests discover data via `TEST_DATA_DIR` environment variable:
- **H100 workflow**: `TEST_DATA_DIR=/data/test-data`
- **Local dev**: Falls back to existing `data/smoke-test/` relative path discovery
**Required code change**: Add `TEST_DATA_DIR` env var lookup as primary path in `try_data()` / `get_dbn_data_dir()` / `get_es_fut_data_dir()` test helpers. This is a prerequisite before the workflow can run.
## 2. Workflow Architecture
### 2.1 WorkflowTemplate: `gpu-test-pipeline`
File: `infra/k8s/argo/gpu-test-pipeline-template.yaml`
DAG structure:
```
gpu-warmup ──────────┐
├──> compile-and-test (H100, single pod)
notify-result (onExit, always runs)
```
**Key design decision**: Compile AND test run in the **same pod** on the H100. This eliminates:
- Cross-node PVC transfer (BLOCKER in rev 1)
- Non-deterministic test binary path discovery
- GitLab packages upload/download for test binaries
- Complexity of separating compile artifacts from test harness
Cost overhead: ~5 min of H100 time for compilation (~$0.25/run). Acceptable given the simplicity gain.
### 2.2 cargo-target-cuda-test PVC
New PersistentVolumeClaim — **independent from training's `cargo-target-cuda`**:
- **Name**: `cargo-target-cuda-test`
- **Namespace**: `foxhunt`
- **Size**: 30Gi
- **StorageClass**: `scw-bssd-retain` (survives PVC deletion, matches sccache pattern)
- **AccessMode**: `ReadWriteOnce`
Contains persistent git checkout + cargo target directory (same pattern as `cargo-target-cuda` in compile-and-train). Only changed files get new mtimes → cargo skips recompiling unchanged crates.
**Zero contention**: Training workflows use `cargo-target-cuda`, test workflows use `cargo-target-cuda-test`. Neither blocks the other.
### 2.3 Compile-and-Test Step
Single pod on `ci-training-h100`:
- **Image**: `ci-builder` (CUDA 12.9 + Rust 1.89 + mold)
- **Node pool**: `ci-training-h100`
- **Resources**:
```yaml
requests:
nvidia.com/gpu: "1"
cpu: "4"
memory: 32Gi
limits:
nvidia.com/gpu: "1"
cpu: "8"
memory: 64Gi
```
- **Tolerations**:
```yaml
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
- key: node.cilium.io/agent-not-ready
operator: Exists
effect: NoSchedule
```
- **Volumes**:
- `cargo-target-cuda-test` PVC at `/cargo-target` — persistent checkout + incremental build
- `test-data-pvc` PVC read-only at `/data/test-data`
- `git-ssh-key` secret at `/etc/git-ssh`
- **Environment**:
```bash
SQLX_OFFLINE=true
CARGO_TARGET_DIR=/cargo-target
CARGO_HOME=/cargo-target/cargo-home
CARGO_TERM_COLOR=always
CUDA_COMPUTE_CAP=90 # H100 Hopper architecture
CUDA_VISIBLE_DEVICES=0
CUBLAS_WORKSPACE_CONFIG=:4096:8 # Deterministic cuBLAS for reproducible tests
TEST_DATA_DIR=/data/test-data
RUST_LOG=info
```
- **Execution flow**:
```bash
# 1. Checkout (persistent on PVC, incremental)
git fetch origin && git checkout --force $SHA
# 2. Compile test binaries (incremental, ~5-6 min warm)
cargo test -p ml -p ml-dqn -p ml-core --features cuda --no-run
# 3. Run tests sequentially per model (see section 4)
# Captures per-model exit codes, continues on failure
```
- **Expected duration**: ~5-6 min compile (warm) + ~15-20 min tests = **~20-25 min total**
### 2.4 GPU Warmup Step
Runs in parallel with nothing (triggers H100 autoscale while the workflow is starting):
```yaml
- name: gpu-warmup
nodeSelector:
k8s.scaleway.com/pool-name: "{{workflow.parameters.gpu-pool}}"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
- key: node.cilium.io/agent-not-ready
operator: Exists
effect: NoSchedule
container:
image: busybox:1.37
command: ["/bin/sh", "-c", "echo 'GPU warmup: node scheduled' && exit 0"]
resources:
requests:
nvidia.com/gpu: "1"
cpu: 100m
memory: 64Mi
limits:
nvidia.com/gpu: "1"
cpu: 200m
memory: 128Mi
```
Same pattern as `compile-and-train-template.yaml:265-291`. Saves 3-5 min of autoscale wait.
### 2.5 Notify Step (onExit)
Uses `onExit` handler (NOT a DAG dependency) — **always runs** regardless of test success/failure:
```yaml
onExit: notify-result
```
Same pattern as `compile-and-train-template.yaml:22` and `ci-pipeline-template.yaml:13`. Posts per-model pass/fail summary to Mattermost webhook.
### 2.6 Workflow Metadata
```yaml
securityContext:
fsGroup: 0 # Match compile-and-train
podGC:
strategy: OnPodCompletion # Match ci-pipeline
ttlStrategy:
secondsAfterCompletion: 3600 # 1 hour cleanup
activeDeadlineSeconds: 5400 # 90 min hard limit (generous for 25 min expected)
serviceAccountName: argo-workflow
```
## 3. Parameterization
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `models` | string | `dqn,ppo,tft` | Comma-separated model list |
| `test-scope` | enum | `all` | `lib`, `integration`, or `all` |
| `epochs` | int | `3` | Training epochs for e2e smoke tests |
| `commit-ref` | string | `HEAD` | Git ref to compile and test (SHA, branch, or tag) |
| `gpu-pool` | string | `ci-training-h100` | GPU node pool name |
| `cuda-compute-cap` | string | `90` | CUDA compute capability (90=H100) |
| `notify` | bool | `true` | Send Mattermost notification |
**Full model list**: `dqn,ppo,tft,mamba2,tggn,tlob,liquid,kan,xlstm,diffusion`
**Special value**: `models=all` expands to the full 10-model list.
## 4. Test Matrix
### 4.1 Execution Strategy
Tests run sequentially per model family in a single pod. **Continue on failure**: each model's tests run regardless of previous model failures. Exit codes are captured per-model and aggregated at the end.
```bash
RESULTS=""
run_model_tests() {
MODEL="$1"; shift
echo "=== Testing: $MODEL ==="
"$@" --nocapture 2>&1 | tee "/tmp/test-${MODEL}.log"
EXIT=${PIPESTATUS[0]}
RESULTS="${RESULTS}${MODEL}:${EXIT}\n"
return 0 # Always continue
}
```
### 4.2 DQN Tests
```bash
# lib tests
run_model_tests dqn-lib cargo test -p ml-dqn --features cuda --lib
run_model_tests dqn-ml-lib cargo test -p ml --features cuda --lib -- dqn
# integration tests (if scope=integration or scope=all)
run_model_tests dqn-smoke cargo test -p ml --features cuda --test smoke_test_real_data
run_model_tests dqn-pipeline cargo test -p ml --features cuda --test dqn_training_pipeline_test
run_model_tests dqn-smoke-train cargo test -p ml --features cuda --test dqn_training_smoke_test
run_model_tests dqn-early-stop cargo test -p ml --features cuda --test dqn_early_stopping_termination_test
run_model_tests dqn-collapse cargo test -p ml --features cuda --test dqn_action_collapse_fix_test
```
### 4.3 PPO Tests
```bash
run_model_tests ppo-lib cargo test -p ml --features cuda --lib -- ppo
run_model_tests ppo-barrier cargo test -p ml --features cuda --test barrier_optimization_test
```
### 4.4 Supervised Model Tests
Per model (tft, mamba2, tggn, tlob, liquid, kan, xlstm, diffusion):
```bash
run_model_tests ${MODEL}-lib cargo test -p ml --features cuda --lib -- $MODEL
run_model_tests ${MODEL}-integ cargo test -p ml --features cuda --test "${MODEL}_*" 2>/dev/null || true
```
### 4.5 Shared/Core Tests
Always run regardless of model selection:
```bash
run_model_tests core cargo test -p ml-core --features cuda --lib
run_model_tests bayesian cargo test -p ml --features cuda --test bayesian_changepoint_test
```
### 4.6 Final Aggregation
```bash
# Print summary
echo "=== GPU TEST RESULTS ==="
printf "$RESULTS" | while IFS=: read model exit; do
[ "$exit" = "0" ] && STATUS="PASS" || STATUS="FAIL"
printf " %-20s %s\n" "$model" "$STATUS"
done
# Exit with failure if ANY model failed
printf "$RESULTS" | grep -v ":0$" && exit 1 || exit 0
```
## 5. Trigger Configuration
### 5.1 Manual
`scripts/argo-test.sh` — CLI wrapper for `argo submit`:
```bash
# Full suite (default)
./scripts/argo-test.sh
# Quick DQN-only
./scripts/argo-test.sh --models dqn --scope lib
# All 10 models, 5 epochs
./scripts/argo-test.sh --models all --epochs 5
# Watch logs
./scripts/argo-test.sh --models dqn,ppo --watch
```
**Status**: Active from day one.
### 5.2 On-Push (Gated)
New DAG task in `ci-pipeline-template.yaml`:
```yaml
- name: gpu-test
depends: "detect-changes"
templateRef:
name: gpu-test-pipeline
template: pipeline
arguments:
parameters:
- name: commit-ref
value: "{{workflow.parameters.commit-sha}}"
- name: models
value: "dqn,ppo,tft"
- name: epochs
value: "3"
when: "{{tasks.detect-changes.outputs.parameters.ml-changed}} == true"
```
**Change detection**: New `ml-changed` output added to `detect-changes` template. `true` when `git diff` includes files matching:
- `crates/ml/**` (includes `src/cuda_pipeline/*.cu` and `*.cuh`)
- `crates/ml-dqn/**`
- `crates/ml-core/**`
- `crates/ml-regime/**`
- `crates/ml-features/**`
- `crates/ml-supervised/**`
- `crates/ml-ppo/**`
- `crates/ml-ensemble/**`
- `crates/ml-hyperopt/**`
- `crates/ml-labeling/**`
**Implementation**: Add to the `check_paths` section of `detect-changes`:
```bash
ML_CHANGED=$(check_paths "crates/ml crates/ml-dqn crates/ml-core crates/ml-regime crates/ml-features crates/ml-supervised crates/ml-ppo crates/ml-ensemble crates/ml-hyperopt crates/ml-labeling")
echo -n "$ML_CHANGED" > /tmp/outputs/ml-changed
```
And add the output parameter:
```yaml
- name: ml-changed
valueFrom:
path: /tmp/outputs/ml-changed
```
**Status**: Active from day one. Gated — zero cost when non-ML code changes.
### 5.3 Nightly
`infra/k8s/argo/gpu-test-nightly-cron.yaml`:
```yaml
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
name: gpu-test-nightly
namespace: foxhunt
spec:
schedule: "0 2 * * *"
suspend: true # Created but not active — enable when ready
workflowSpec:
workflowTemplateRef:
name: gpu-test-pipeline
arguments:
parameters:
- name: models
value: "dqn,ppo,tft,mamba2,tggn,tlob,liquid,kan,xlstm,diffusion"
- name: epochs
value: "5"
- name: commit-ref
value: "main"
```
**Status**: Created but `suspend: true`. Enable with:
```bash
kubectl patch cronworkflow gpu-test-nightly -n foxhunt -p '{"spec":{"suspend":false}}'
```
## 6. File Inventory
### New files to create
| File | Purpose |
|------|---------|
| `infra/k8s/argo/gpu-test-pipeline-template.yaml` | Main WorkflowTemplate |
| `infra/k8s/argo/gpu-test-nightly-cron.yaml` | CronWorkflow (suspended) |
| `infra/k8s/argo/cargo-target-cuda-test-pvc.yaml` | 30Gi build cache PVC |
| `infra/k8s/training/test-data-pvc.yaml` | 10Gi test data PVC |
| `infra/k8s/training/populate-test-data-job.yaml` | One-time data copy job |
| `scripts/argo-test.sh` | Manual trigger CLI |
| `scripts/refresh-test-data.sh` | Re-run populate job |
### Files to modify
| File | Change |
|------|--------|
| `infra/k8s/argo/ci-pipeline-template.yaml` | Add `gpu-test` DAG task + `ml-changed` output to detect-changes |
| `crates/ml/tests/smoke_test_real_data.rs` | Add `TEST_DATA_DIR` env var to `try_data()` |
| `crates/ml/tests/dqn_training_pipeline_test.rs` | Add `TEST_DATA_DIR` env var to `get_es_fut_data_dir()` |
| `crates/ml/tests/dqn_training_smoke_test.rs` | Add `TEST_DATA_DIR` env var to `get_dbn_data_dir()` |
| `crates/ml/tests/dqn_early_stopping_termination_test.rs` | Add `TEST_DATA_DIR` env var to `get_dbn_data_dir()` |
## 7. Cost Estimate
| Trigger | H100 time/run | Frequency | Monthly cost estimate |
|---------|--------------|-----------|----------------------|
| Manual | ~25 min | ~5/week | ~$10 |
| On-push (gated) | ~25 min | ~10/week | ~$20 |
| Nightly (when enabled) | ~35 min | 30/month | ~$50 |
Based on Scaleway H100 pricing for `ci-training-h100` node pool. Includes ~5 min compile overhead on GPU node (simpler architecture vs CPU-compile + binary transfer).
## 8. Blocker Resolutions (from rev 1 review)
| Blocker | Resolution |
|---------|-----------|
| Workspace PVC can't cross nodes (RWO) | Eliminated: compile + test in same H100 pod |
| `cargo-target-cuda` contention | New `cargo-target-cuda-test` PVC — zero sharing |
| sccache not configured | Removed: using cargo incremental compilation (persistent checkout on PVC), matching `compile-and-train` pattern |
| Non-deterministic test binary paths | Eliminated: `cargo test` runs in same env that compiled — no binary discovery needed |
| No `activeDeadlineSeconds` | Added: 5400s (90 min) |
| No `onExit` handler | Added: `onExit: notify-result` |
| No `podGC` | Added: `OnPodCompletion` |
| Missing `fsGroup` | Added: `fsGroup: 0` |
| Missing `gpu-warmup` | Added: parallel warmup step |
| Incomplete change detection | Added: ml-ensemble, ml-hyperopt, ml-labeling paths |
| Missing `CUDA_COMPUTE_CAP` | Added: `cuda-compute-cap` parameter, default `90` |
| TEST_DATA_DIR not in test helpers | Marked as required prerequisite code change |
| No partial failure handling | Added: continue-on-failure with per-model exit code capture |
## 9. Success Criteria
1. `scripts/argo-test.sh` submits workflow and all default tests pass on H100
2. On-push trigger fires on ML crate changes, skips on non-ML changes
3. All 8 smoke_test_real_data tests pass with test-data-pvc data
4. DQN e2e training loop completes 3 epochs in <5 min (vs 42 min local)
5. Per-model pass/fail reported to Mattermost
6. No interference with training workflows (separate PVCs)
7. `activeDeadlineSeconds` kills stuck workflows within 90 min
8. Completed pods cleaned up via `podGC`