Files
foxhunt/infra/k8s/argo/gpu-test-pipeline-template.yaml
jgrusewski adce841e6b fix: H100 GPU test failures — stale profile assertions + smoke test stability
- test_embedded_h100_parses: update assertions to match h100.toml values
  (gpu_n_episodes=2048, gpu_timesteps_per_episode=100)
- dqn_training_smoke_test: apply dqn-smoketest profile to cap hidden_dim=32.
  H100's gpu profile sets hidden_dim_base=256 which causes loss explosion
  (375x in 3 epochs) with lr=0.001.
- Revert gpu-test-pipeline DAG to compile-and-test (RWO PVC constraint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:08:23 +01:00

527 lines
21 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# infra/k8s/argo/gpu-test-pipeline-template.yaml
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: gpu-test-pipeline
namespace: foxhunt
labels:
app.kubernetes.io/name: gpu-test-pipeline
app.kubernetes.io/part-of: foxhunt
spec:
entrypoint: pipeline
onExit: notify-result
serviceAccountName: argo-workflow
podMetadata:
labels:
app.kubernetes.io/part-of: foxhunt
app.kubernetes.io/component: gpu-test
securityContext:
fsGroup: 0
podGC:
strategy: OnPodCompletion
ttlStrategy:
secondsAfterCompletion: 3600
activeDeadlineSeconds: 7200
arguments:
parameters:
- name: commit-ref
value: HEAD
- name: models
value: "dqn,ppo,tft"
- name: test-scope
value: all
- name: gpu-pool
value: ci-training-h100
- name: cuda-compute-cap
value: "90"
- name: notify
value: "true"
volumes:
- name: git-ssh-key
secret:
secretName: argo-git-ssh-key
defaultMode: 256
- name: cargo-target
persistentVolumeClaim:
claimName: cargo-target-cuda-test
- name: test-data
persistentVolumeClaim:
claimName: test-data-pvc
readOnly: true
templates:
# ── pipeline: DAG entrypoint ──
# compile-and-test runs on GPU node (RWO PVC can't be shared cross-node).
# gpu-warmup ensures H100 is scaled up before compile starts.
- name: pipeline
dag:
tasks:
- name: gpu-warmup
template: gpu-warmup
- name: compile-and-test
template: compile-and-test
dependencies: [gpu-warmup]
- name: perf-benchmark
template: perf-benchmark
dependencies: [compile-and-test]
# ── gpu-warmup: trigger H100 autoscale ──
# Requests GPU to force autoscaler to provision node, then releases it.
- 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: alpine:3.21
command: ["/bin/sh", "-c"]
args:
- |
echo "GPU warmup: triggering node autoscale..."
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader || true
echo "GPU node ready, releasing for compile-and-test"
resources:
requests:
nvidia.com/gpu: "1"
cpu: 100m
memory: 64Mi
limits:
nvidia.com/gpu: "1"
cpu: 200m
memory: 128Mi
# ── compile-and-test: compile + run GPU tests in single H100 pod ──
- name: compile-and-test
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
outputs:
parameters:
- name: results
valueFrom:
path: /tmp/outputs/results
default: "unknown:FAIL"
- name: failures
valueFrom:
path: /tmp/outputs/failures
default: "1"
container:
image: gitlab-registry.foxhunt.svc.cluster.local:5000/root/foxhunt/ci-builder:latest
command: ["/bin/bash", "-c"]
env:
- name: SQLX_OFFLINE
value: "true"
- name: CARGO_TERM_COLOR
value: always
- name: CARGO_TARGET_DIR
value: /cargo-target
- name: CARGO_HOME
value: /cargo-target/cargo-home
- name: CUDA_VISIBLE_DEVICES
value: "0"
- name: CUBLAS_WORKSPACE_CONFIG
value: ":4096:8"
- name: TEST_DATA_DIR
value: /data/test-data
- name: RUST_LOG
value: info
- name: LD_LIBRARY_PATH
value: /usr/local/nvidia/lib64:/usr/local/cuda/lib64:/usr/local/cuda/extras/CUPTI/lib64
- name: CARGO_PROFILE_TEST_OPT_LEVEL
value: "2"
volumeMounts:
- name: git-ssh-key
mountPath: /etc/git-ssh
readOnly: true
- name: cargo-target
mountPath: /cargo-target
- name: test-data
mountPath: /data/test-data
readOnly: true
resources:
requests:
nvidia.com/gpu: "1"
cpu: "4"
memory: 32Gi
limits:
nvidia.com/gpu: "1"
cpu: "8"
memory: 64Gi
args:
- |
set -e
REF="{{workflow.parameters.commit-ref}}"
MODELS="{{workflow.parameters.models}}"
SCOPE="{{workflow.parameters.test-scope}}"
# --- SSH setup (same as compile-and-train) ---
mkdir -p ~/.ssh
cp /etc/git-ssh/ssh-privatekey ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf 'Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n' > ~/.ssh/config
chmod 600 ~/.ssh/config
REPO="ssh://git@gitlab-gitlab-shell.foxhunt.svc.cluster.local:2222/root/foxhunt.git"
BUILD="/cargo-target/src"
git config --global --add safe.directory "$BUILD"
# --- Persistent checkout on PVC ---
if [ -d "$BUILD/.git" ]; then
cd "$BUILD"
echo "=== Fetching latest refs ==="
git fetch origin
# Resolve REF after fetch so we always get the latest commit.
# Try origin/$REF (branch), then $REF directly (tag or SHA).
if git rev-parse --verify "origin/$REF" >/dev/null 2>&1; then
TARGET=$(git rev-parse "origin/$REF")
else
TARGET=$(git rev-parse --verify "$REF" 2>/dev/null || echo "$REF")
fi
CURRENT=$(git rev-parse HEAD 2>/dev/null || echo "none")
if [ "$CURRENT" = "$TARGET" ]; then
echo "=== Already at $REF ($TARGET) ==="
else
echo "=== Updating checkout to $REF ($TARGET) ==="
git checkout --force --detach "$TARGET"
git clean -fd
fi
else
echo "=== Initial clone ==="
git clone --filter=blob:none "$REPO" "$BUILD"
cd "$BUILD"
git fetch origin
if git rev-parse --verify "origin/$REF" >/dev/null 2>&1; then
git checkout --force --detach "origin/$REF"
else
git checkout "$REF"
fi
fi
echo "Checked out $(git rev-parse --short HEAD)"
export PATH="${CARGO_HOME}/bin:${PATH}"
export CUDA_COMPUTE_CAP={{workflow.parameters.cuda-compute-cap}}
# --- PTX cache invalidation ---
# Purge stale cached PTX if any CUDA kernel source changed since last run.
bash scripts/ptx-cache-invalidate.sh "${CARGO_TARGET_DIR}/.ptx_cache"
# --- PVC size guard ---
TARGET_SIZE_MB=$(du -sm "$CARGO_TARGET_DIR" 2>/dev/null | cut -f1 || echo 0)
echo "PVC usage: ${TARGET_SIZE_MB}MB"
if [ "$TARGET_SIZE_MB" -gt 25000 ]; then
echo "PVC exceeds 25GB, pruning..."
rm -rf "$CARGO_TARGET_DIR/release" "$CARGO_TARGET_DIR/debug"
fi
# --- Compile ---
echo "=== Compiling test binaries (--features cuda) ==="
cargo test -p ml -p ml-dqn -p ml-core --features cuda --no-run 2>&1 | tee /cargo-target/gpu-compile.log
echo "=== Compilation done ==="
# --- Expand "all" ---
if [ "$MODELS" = "all" ]; then
MODELS="dqn,ppo,tft,mamba2,tggn,tlob,liquid,kan,xlstm,diffusion"
fi
# --- Test runner ---
RESULTS=""
FAILURES=0
# Reset CUDA context between test binaries to prevent cuBLAS
# CUBLAS_STATUS_NOT_INITIALIZED cascades. Each test binary creates
# and destroys hundreds of cuBLAS handles; without a reset, the
# driver fails to re-init for the next binary.
gpu_context_drain() {
nvidia-smi -rgc >/dev/null 2>&1 || true
sleep 1
}
run_tests() {
local NAME="$1"; shift
echo ""
echo "========================================"
echo " TEST: $NAME"
echo "========================================"
# --nocapture is a test-binary flag, must come after --
# If args already contain --, append after it; otherwise add -- first
local HAS_SEP=false
for arg in "$@"; do
[ "$arg" = "--" ] && HAS_SEP=true && break
done
set +e
if $HAS_SEP; then
"$@" --nocapture 2>&1
else
"$@" -- --nocapture 2>&1
fi
EXIT=$?
set -e
if [ $EXIT -eq 0 ]; then
RESULTS="${RESULTS}${NAME}:PASS\n"
else
RESULTS="${RESULTS}${NAME}:FAIL\n"
FAILURES=$((FAILURES + 1))
fi
# Drain CUDA context after each GPU test suite
gpu_context_drain
}
# --- Core tests (always run) ---
# --test-threads=1 for all GPU lib tests: prevents concurrent cuBLAS
# handle creation that causes CUBLAS_STATUS_NOT_INITIALIZED cascades.
run_tests "core-lib" cargo test -p ml-core --features cuda --lib -- --test-threads=1
run_tests "bayesian" cargo test -p ml --features cuda --test bayesian_changepoint_test -- --test-threads=1
# --- Per-model tests ---
IFS=',' read -ra MODEL_LIST <<< "$MODELS"
for MODEL in "${MODEL_LIST[@]}"; do
case "$MODEL" in
dqn)
if [ "$SCOPE" = "lib" ] || [ "$SCOPE" = "all" ]; then
# --test-threads=1: GPU lib tests must run serially — each test
# creates a cuBLAS handle via Device::new_cuda(0). Under parallel
# execution, concurrent cuBLAS init races cause
# CUBLAS_STATUS_NOT_INITIALIZED failures (51 test cascade).
run_tests "dqn-lib" cargo test -p ml-dqn --features cuda --lib -- --test-threads=1
run_tests "dqn-ml-lib" cargo test -p ml --features cuda --lib -- --test-threads=1 dqn
fi
if [ "$SCOPE" = "integration" ] || [ "$SCOPE" = "all" ]; then
# --test-threads=1 for ALL GPU integration tests: parallel execution
# corrupts the CUDA primary context (cuDevicePrimaryCtxRetain fails
# when multiple threads race on context init/teardown).
run_tests "dqn-smoke" cargo test -p ml --features cuda --test smoke_test_real_data -- --test-threads=1
# Run each pipeline test in its own cargo test process.
# CUDA Graph capture corrupts the async memory pool, making
# cuMemAllocAsync fail with CUDA_ERROR_INVALID_VALUE in
# subsequent DQNTrainer instances within the same process.
run_tests "dqn-pipeline-train" cargo test -p ml --features cuda --test dqn_training_pipeline_test test_dqn_trains_on_es_fut -- --test-threads=1 --exact
run_tests "dqn-pipeline-loss" cargo test -p ml --features cuda --test dqn_training_pipeline_test test_dqn_loss_decreases -- --test-threads=1 --exact
run_tests "dqn-pipeline-ckpt" cargo test -p ml --features cuda --test dqn_training_pipeline_test test_dqn_checkpoint_save_load -- --test-threads=1 --exact
run_tests "dqn-pipeline-qval" cargo test -p ml --features cuda --test dqn_training_pipeline_test test_dqn_q_value_predictions -- --test-threads=1 --exact
run_tests "dqn-pipeline-eps" cargo test -p ml --features cuda --test dqn_training_pipeline_test test_dqn_epsilon_greedy -- --test-threads=1 --exact
run_tests "dqn-smoke-train" cargo test -p ml --features cuda --test dqn_training_smoke_test -- --test-threads=1
run_tests "dqn-early-stop" cargo test -p ml --features cuda --test dqn_early_stopping_termination_test -- --test-threads=1
run_tests "dqn-collapse" cargo test -p ml --features cuda --test dqn_action_collapse_fix_test -- --test-threads=1
fi
;;
ppo)
if [ "$SCOPE" = "lib" ] || [ "$SCOPE" = "all" ]; then
run_tests "ppo-lib" cargo test -p ml --features cuda --lib -- --test-threads=1 ppo
fi
if [ "$SCOPE" = "integration" ] || [ "$SCOPE" = "all" ]; then
run_tests "ppo-barrier" cargo test -p ml --features cuda --test barrier_optimization_test -- --test-threads=1
fi
;;
*)
# Supervised models (TFT, Mamba2, TGGN, TLOB, Liquid, KAN, xLSTM, Diffusion)
# Map model names to Rust module names where they differ
LIB_FILTER="$MODEL"
[ "$MODEL" = "tggn" ] && LIB_FILTER="tgnn"
if [ "$SCOPE" = "lib" ] || [ "$SCOPE" = "all" ]; then
run_tests "${MODEL}-lib" cargo test -p ml --features cuda --lib -- --test-threads=1 "$LIB_FILTER"
fi
if [ "$SCOPE" = "integration" ] || [ "$SCOPE" = "all" ]; then
run_tests "${MODEL}-gpu" cargo test -p ml --features cuda --test supervised_gpu_smoke_test -- --test-threads=1 "test_${MODEL}_gpu_smoke"
# Also run model-specific integration tests if they exist
if cargo test -p ml --features cuda --test "${MODEL}_integration" --no-run 2>/dev/null; then
run_tests "${MODEL}-integ" cargo test -p ml --features cuda --test "${MODEL}_integration" -- --test-threads=1
fi
fi
;;
esac
done
# --- Summary ---
echo ""
echo "========================================"
echo " GPU TEST RESULTS"
echo "========================================"
printf "$RESULTS" | while IFS=: read -r name status; do
[ -z "$name" ] && continue
printf " %-25s %s\n" "$name" "$status"
done
echo "========================================"
echo " Total failures: $FAILURES"
echo "========================================"
# Write results for notification step
mkdir -p /tmp/outputs
printf "$RESULTS" > /tmp/outputs/results
echo "$FAILURES" > /tmp/outputs/failures
[ "$FAILURES" -gt 0 ] && exit 1 || exit 0
# ── perf-benchmark: DQN epoch/s on 3Q data (performance regression guard) ──
# Runs after tests pass. Trains DQN on 3Q of ES.FUT data and reports epoch time.
# Uses the same binary compiled by compile-and-test (shared cargo-target PVC).
# Fails the pipeline if epoch time exceeds 500ms (regression threshold for H100).
- name: perf-benchmark
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
outputs:
parameters:
- name: epoch-ms
valueFrom:
path: /tmp/outputs/epoch-ms
default: "9999"
container:
image: gitlab-registry.foxhunt.svc.cluster.local:5000/root/foxhunt/ci-builder:latest
command: ["/bin/bash", "-c"]
env:
- name: SQLX_OFFLINE
value: "true"
- name: CARGO_TARGET_DIR
value: /cargo-target
- name: CARGO_HOME
value: /cargo-target/cargo-home
- name: CUDA_VISIBLE_DEVICES
value: "0"
- name: LD_LIBRARY_PATH
value: /usr/local/nvidia/lib64:/usr/local/cuda/lib64:/usr/local/cuda/extras/CUPTI/lib64
volumeMounts:
- name: cargo-target
mountPath: /cargo-target
- name: test-data
mountPath: /data/test-data
readOnly: true
resources:
requests:
nvidia.com/gpu: "1"
cpu: "4"
memory: 16Gi
limits:
nvidia.com/gpu: "1"
cpu: "8"
memory: 32Gi
args:
- |
set -e
cd /cargo-target/src
export PATH="${CARGO_HOME}/bin:${PATH}"
export CUDA_COMPUTE_CAP={{workflow.parameters.cuda-compute-cap}}
echo "========================================"
echo " PERF BENCHMARK: DQN epoch/s (3Q ES.FUT)"
echo "========================================"
# Run 5 epochs × 100 steps on 3Q data (--train-months 3 fits in 1 quarter)
# Use --step-months 3 to avoid walk-forward window splits
OUTPUT=$(cargo run --release --example train_baseline_rl -p ml -- \
--model dqn \
--data-dir /data/test-data/ohlcv \
--mbp10-data-dir /data/test-data/mbp10 \
--trades-data-dir /data/test-data/trades \
--symbol ES.FUT \
--epochs 5 \
--max-steps-per-epoch 100 \
--batch-size 1024 \
--train-months 3 --val-months 1 --test-months 1 --step-months 3 \
2>&1)
# Extract epoch times (skip epoch 1 which includes init)
EPOCH_TIMES=$(echo "$OUTPUT" | grep "phase breakdown" | grep -v "Epoch 1/" | \
sed 's/.*total=\([0-9]*\)ms.*/\1/' | head -4)
if [ -z "$EPOCH_TIMES" ]; then
echo "ERROR: No phase breakdown output found"
echo "$OUTPUT" | tail -20
mkdir -p /tmp/outputs
echo "9999" > /tmp/outputs/epoch-ms
exit 1
fi
# Compute average epoch time (epochs 2-5)
SUM=0
COUNT=0
for T in $EPOCH_TIMES; do
SUM=$((SUM + T))
COUNT=$((COUNT + 1))
done
AVG=$((SUM / COUNT))
echo ""
echo " Epoch times (ms, excl. epoch 1): $EPOCH_TIMES"
echo " Average: ${AVG}ms"
echo ""
mkdir -p /tmp/outputs
echo "$AVG" > /tmp/outputs/epoch-ms
# Regression guard: fail if avg epoch > 500ms on H100
THRESHOLD=500
if [ "$AVG" -gt "$THRESHOLD" ]; then
echo "PERF REGRESSION: ${AVG}ms > ${THRESHOLD}ms threshold"
echo "========================================"
exit 1
fi
echo " PASS: ${AVG}ms <= ${THRESHOLD}ms threshold"
echo "========================================"
# ── notify-result: post test outcome to Mattermost (onExit) ──
- name: notify-result
nodeSelector:
k8s.scaleway.com/pool-name: platform
container:
image: curlimages/curl:8.12.1
command: ["/bin/sh", "-c"]
env:
- name: WEBHOOK_URL
valueFrom:
secretKeyRef:
name: notification-webhook
key: webhook-url
optional: true
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
args:
- |
NOTIFY="{{workflow.parameters.notify}}"
if [ "$NOTIFY" != "true" ]; then
echo "Notifications disabled, skipping"
exit 0
fi
STATUS="{{workflow.status}}"
NAME="{{workflow.name}}"
if [ -z "$WEBHOOK_URL" ] || echo "$WEBHOOK_URL" | grep -q "PLACEHOLDER"; then
echo "No webhook configured, skipping notification"
exit 0
fi
if [ "$STATUS" = "Succeeded" ]; then
EMOJI=":white_check_mark:"
else
EMOJI=":x:"
fi
PAYLOAD="{\"username\":\"Argo CI\",\"text\":\"${EMOJI} **GPU Tests** ${NAME} — ${STATUS} ({{workflow.duration}}s)\"}"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "$PAYLOAD" "$WEBHOOK_URL" || echo "WARN: webhook post failed"