- B1: Add LIB_FILTER mapping for tggn→tgnn module name (prevents zero-test false positive) - W1: Replace ml_supervised:: imports with canonical ml:: paths in supervised_gpu_smoke_test - W4: Use clean pass/fail exit code instead of raw failure count Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1542 lines
51 KiB
Markdown
1542 lines
51 KiB
Markdown
# H100 GPU Test Workflow Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Argo WorkflowTemplate that compiles and runs the full GPU/CUDA test suite on H100 with real market data, triggered manually, on-push (gated), or nightly.
|
|
|
|
**Architecture:** Two new PVCs (`cargo-target-cuda-test` 30Gi, `test-data-pvc` 10Gi) isolate test infrastructure from training. Single H100 pod compiles with `--features cuda` then runs tests sequentially per model family. Continue-on-failure captures per-model results.
|
|
|
|
**Tech Stack:** Argo Workflows, Kubernetes, Rust/Cargo, CUDA 12.9, ci-builder image, bash
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-13-gpu-test-workflow-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### New Files (8)
|
|
|
|
| File | Responsibility |
|
|
|------|---------------|
|
|
| `crates/ml/tests/supervised_gpu_smoke_test.rs` | GPU smoke test for all 8 supervised models (UnifiedTrainable pipeline) |
|
|
| `infra/k8s/argo/cargo-target-cuda-test-pvc.yaml` | PVC for test compilation cache (30Gi) |
|
|
| `infra/k8s/training/test-data-pvc.yaml` | PVC for curated test data subset (10Gi) |
|
|
| `infra/k8s/training/populate-test-data-job.yaml` | One-time job: copy data from training PVC to test PVC |
|
|
| `infra/k8s/argo/gpu-test-pipeline-template.yaml` | Main WorkflowTemplate: compile + test on H100 |
|
|
| `infra/k8s/argo/gpu-test-nightly-cron.yaml` | CronWorkflow (suspended) for nightly runs |
|
|
| `scripts/argo-test.sh` | CLI wrapper for manual `argo submit` |
|
|
| `scripts/refresh-test-data.sh` | Re-run populate job |
|
|
|
|
### Modified Files (5)
|
|
|
|
| File | Change |
|
|
|------|--------|
|
|
| `infra/k8s/argo/ci-pipeline-template.yaml` | Add `ml-changed` output + `gpu-test` DAG task |
|
|
| `crates/ml/tests/smoke_test_real_data.rs:99-130` | Add `TEST_DATA_DIR` to `smoke_data_dir()` and `try_data()` |
|
|
| `crates/ml/tests/dqn_training_smoke_test.rs:94-105` | Add `TEST_DATA_DIR` to `get_dbn_data_dir()` |
|
|
| `crates/ml/tests/dqn_training_pipeline_test.rs:100-117` | Add `TEST_DATA_DIR` to `get_es_fut_data_dir()` |
|
|
| `crates/ml/tests/dqn_early_stopping_termination_test.rs:93-104` | Add `TEST_DATA_DIR` to `get_dbn_data_dir()` |
|
|
|
|
---
|
|
|
|
## Task 1: Create PVCs
|
|
|
|
**Files:**
|
|
- Create: `infra/k8s/argo/cargo-target-cuda-test-pvc.yaml`
|
|
- Create: `infra/k8s/training/test-data-pvc.yaml`
|
|
|
|
- [ ] **Step 1: Create cargo-target-cuda-test PVC**
|
|
|
|
```yaml
|
|
# infra/k8s/argo/cargo-target-cuda-test-pvc.yaml
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: cargo-target-cuda-test
|
|
namespace: foxhunt
|
|
labels:
|
|
app.kubernetes.io/name: cargo-target
|
|
app.kubernetes.io/component: ci-cache
|
|
app.kubernetes.io/part-of: foxhunt
|
|
spec:
|
|
accessModes: [ReadWriteOnce]
|
|
storageClassName: scw-bssd-retain
|
|
resources:
|
|
requests:
|
|
storage: 30Gi
|
|
```
|
|
|
|
Pattern matches `infra/k8s/argo/sccache-pvcs.yaml` (same `scw-bssd-retain` storage class).
|
|
|
|
- [ ] **Step 2: Create test-data PVC**
|
|
|
|
```yaml
|
|
# infra/k8s/training/test-data-pvc.yaml
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: test-data-pvc
|
|
namespace: foxhunt
|
|
labels:
|
|
app.kubernetes.io/name: test-data
|
|
app.kubernetes.io/part-of: foxhunt
|
|
spec:
|
|
accessModes: [ReadWriteOnce]
|
|
storageClassName: scw-bssd
|
|
resources:
|
|
requests:
|
|
storage: 10Gi
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add infra/k8s/argo/cargo-target-cuda-test-pvc.yaml infra/k8s/training/test-data-pvc.yaml
|
|
git commit -m "infra: add cargo-target-cuda-test (30Gi) and test-data-pvc (10Gi)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create populate-test-data job
|
|
|
|
**Files:**
|
|
- Create: `infra/k8s/training/populate-test-data-job.yaml`
|
|
- Create: `scripts/refresh-test-data.sh`
|
|
|
|
- [ ] **Step 1: Create the populate job**
|
|
|
|
This job runs on the H100 node pool (same as test-data-pvc) and copies one quarter of ES.FUT data per schema from the training PVC.
|
|
|
|
```yaml
|
|
# infra/k8s/training/populate-test-data-job.yaml
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
generateName: populate-test-data-
|
|
namespace: foxhunt
|
|
labels:
|
|
app.kubernetes.io/name: populate-test-data
|
|
app.kubernetes.io/part-of: foxhunt
|
|
spec:
|
|
backoffLimit: 1
|
|
ttlSecondsAfterFinished: 300
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
nodeSelector:
|
|
k8s.scaleway.com/pool-name: ci-training-h100
|
|
tolerations:
|
|
- key: nvidia.com/gpu
|
|
operator: Exists
|
|
effect: NoSchedule
|
|
- key: node.cilium.io/agent-not-ready
|
|
operator: Exists
|
|
effect: NoSchedule
|
|
containers:
|
|
- name: populate
|
|
image: busybox:1.37
|
|
command: ["/bin/sh", "-c"]
|
|
args:
|
|
- |
|
|
set -e
|
|
echo "=== Populating test data PVC ==="
|
|
|
|
# Target directories
|
|
mkdir -p /test-data/ohlcv/ES.FUT
|
|
mkdir -p /test-data/mbp10/ES.FUT
|
|
mkdir -p /test-data/trades/ES.FUT
|
|
|
|
# Copy one quarter per schema (2025-Q2)
|
|
echo "Copying OHLCV..."
|
|
cp /training-data/futures-baseline/ES.FUT/ES.FUT_2025-Q2.dbn.zst \
|
|
/test-data/ohlcv/ES.FUT/ 2>/dev/null || \
|
|
cp /training-data/futures-baseline/ES.FUT/*2025*Q2* \
|
|
/test-data/ohlcv/ES.FUT/ 2>/dev/null || \
|
|
{ echo "WARN: No 2025-Q2 OHLCV found, copying first available"; \
|
|
cp /training-data/futures-baseline/ES.FUT/*.dbn.zst \
|
|
/test-data/ohlcv/ES.FUT/ 2>/dev/null | head -1; }
|
|
|
|
echo "Copying MBP-10..."
|
|
cp /training-data/futures-baseline-mbp10/ES.FUT/ES.FUT_2025-Q2.dbn.zst \
|
|
/test-data/mbp10/ES.FUT/ 2>/dev/null || \
|
|
cp /training-data/futures-baseline-mbp10/ES.FUT/*2025*Q2* \
|
|
/test-data/mbp10/ES.FUT/ 2>/dev/null || \
|
|
echo "WARN: No MBP-10 2025-Q2 found"
|
|
|
|
echo "Copying trades..."
|
|
cp /training-data/futures-baseline-trades/ES.FUT/ES.FUT_2025-Q2.dbn.zst \
|
|
/test-data/trades/ES.FUT/ 2>/dev/null || \
|
|
cp /training-data/futures-baseline-trades/ES.FUT/*2025*Q2* \
|
|
/test-data/trades/ES.FUT/ 2>/dev/null || \
|
|
echo "WARN: No trades 2025-Q2 found"
|
|
|
|
echo "=== Test data contents ==="
|
|
find /test-data -type f -exec ls -lh {} \;
|
|
echo "Total: $(du -sh /test-data | cut -f1)"
|
|
echo "=== Done ==="
|
|
resources:
|
|
requests:
|
|
nvidia.com/gpu: "1"
|
|
cpu: 100m
|
|
memory: 128Mi
|
|
limits:
|
|
nvidia.com/gpu: "1"
|
|
cpu: 500m
|
|
memory: 256Mi
|
|
volumeMounts:
|
|
- name: training-data
|
|
mountPath: /training-data
|
|
readOnly: true
|
|
- name: test-data
|
|
mountPath: /test-data
|
|
volumes:
|
|
- name: training-data
|
|
persistentVolumeClaim:
|
|
claimName: training-data-pvc
|
|
readOnly: true
|
|
- name: test-data
|
|
persistentVolumeClaim:
|
|
claimName: test-data-pvc
|
|
```
|
|
|
|
Note: requests `nvidia.com/gpu: 1` only to schedule on the H100 node (same as test-data-pvc). GPU isn't used for the copy.
|
|
|
|
- [ ] **Step 2: Create refresh script**
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
# Re-populate the test-data-pvc from training-data-pvc.
|
|
# Usage: ./scripts/refresh-test-data.sh
|
|
set -euo pipefail
|
|
|
|
echo "Submitting populate-test-data job..."
|
|
kubectl -n foxhunt create -f infra/k8s/training/populate-test-data-job.yaml
|
|
|
|
echo "Waiting for completion..."
|
|
kubectl -n foxhunt wait --for=condition=complete job \
|
|
-l app.kubernetes.io/name=populate-test-data \
|
|
--timeout=300s
|
|
|
|
echo "Done. Test data PVC refreshed."
|
|
```
|
|
|
|
Save to `scripts/refresh-test-data.sh`, chmod +x.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
chmod +x scripts/refresh-test-data.sh
|
|
git add infra/k8s/training/populate-test-data-job.yaml scripts/refresh-test-data.sh
|
|
git commit -m "infra: add populate-test-data job and refresh script"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Wire TEST_DATA_DIR into test helpers
|
|
|
|
**Files:**
|
|
- Modify: `crates/ml/tests/smoke_test_real_data.rs:99-130`
|
|
- Modify: `crates/ml/tests/dqn_training_smoke_test.rs:94-105`
|
|
- Modify: `crates/ml/tests/dqn_training_pipeline_test.rs:100-117`
|
|
- Modify: `crates/ml/tests/dqn_early_stopping_termination_test.rs:93-104`
|
|
|
|
- [ ] **Step 1: Update `smoke_data_dir()` and `try_data()` in smoke_test_real_data.rs**
|
|
|
|
Replace `smoke_data_dir()`:
|
|
```rust
|
|
fn smoke_data_dir() -> PathBuf {
|
|
// CI: TEST_DATA_DIR points to test-data-pvc on H100
|
|
if let Ok(dir) = std::env::var("TEST_DATA_DIR") {
|
|
return PathBuf::from(dir);
|
|
}
|
|
// Local dev: relative path from workspace root
|
|
workspace_root().join("data").join("smoke-test")
|
|
}
|
|
```
|
|
|
|
`try_data()` doesn't need changes — it already calls `smoke_data_dir()`.
|
|
|
|
- [ ] **Step 2: Update `get_dbn_data_dir()` in dqn_training_smoke_test.rs**
|
|
|
|
Replace lines 94-105:
|
|
```rust
|
|
fn get_dbn_data_dir() -> Result<String> {
|
|
// CI: TEST_DATA_DIR points to test-data-pvc on H100
|
|
if let Ok(dir) = std::env::var("TEST_DATA_DIR") {
|
|
let ohlcv = std::path::PathBuf::from(&dir).join("ohlcv");
|
|
if ohlcv.exists() {
|
|
return Ok(ohlcv.to_string_lossy().to_string());
|
|
}
|
|
// Flat layout fallback
|
|
return Ok(dir);
|
|
}
|
|
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.ancestors()
|
|
.find(|p| p.join("test_data").exists())
|
|
.context("Failed to find workspace root with test_data/")?
|
|
.to_path_buf();
|
|
let data_dir = workspace_root.join("test_data/futures-baseline");
|
|
if !data_dir.exists() {
|
|
anyhow::bail!("DBN data not found: {}", data_dir.display());
|
|
}
|
|
Ok(data_dir.to_string_lossy().to_string())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update `get_es_fut_data_dir()` in dqn_training_pipeline_test.rs**
|
|
|
|
Replace lines 100-117:
|
|
```rust
|
|
fn get_es_fut_data_dir() -> Result<String> {
|
|
if let Ok(dir) = std::env::var("TEST_DATA_DIR") {
|
|
let ohlcv = std::path::PathBuf::from(&dir).join("ohlcv");
|
|
if ohlcv.exists() {
|
|
return Ok(ohlcv.to_string_lossy().to_string());
|
|
}
|
|
return Ok(dir);
|
|
}
|
|
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.ancestors()
|
|
.find(|p| p.join("test_data").exists())
|
|
.context("Failed to find workspace root with test_data/")?
|
|
.to_path_buf();
|
|
let data_dir = workspace_root.join("test_data/futures-baseline");
|
|
if !data_dir.exists() {
|
|
anyhow::bail!(
|
|
"ES.FUT data directory not found: {}. Run data acquisition first.",
|
|
data_dir.display()
|
|
);
|
|
}
|
|
Ok(data_dir.to_string_lossy().to_string())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Update `get_dbn_data_dir()` in dqn_early_stopping_termination_test.rs**
|
|
|
|
Replace lines 93-104:
|
|
```rust
|
|
#[cfg(feature = "cuda")]
|
|
fn get_dbn_data_dir() -> Option<String> {
|
|
if let Ok(dir) = std::env::var("TEST_DATA_DIR") {
|
|
let ohlcv = std::path::PathBuf::from(&dir).join("ohlcv");
|
|
if ohlcv.exists() {
|
|
return Some(ohlcv.to_string_lossy().to_string());
|
|
}
|
|
return Some(dir);
|
|
}
|
|
let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.ancestors()
|
|
.find(|p| p.join("test_data").exists())?
|
|
.to_path_buf();
|
|
let data_dir = workspace_root.join("test_data/futures-baseline");
|
|
if !data_dir.exists() {
|
|
eprintln!("SKIP: DBN data not found: {}", data_dir.display());
|
|
return None;
|
|
}
|
|
Some(data_dir.to_string_lossy().to_string())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verify compilation**
|
|
|
|
```bash
|
|
SQLX_OFFLINE=true cargo test -p ml --features cuda --no-run 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: compiles with 0 errors.
|
|
|
|
- [ ] **Step 6: Verify local tests still work (env var NOT set)**
|
|
|
|
```bash
|
|
unset TEST_DATA_DIR
|
|
SQLX_OFFLINE=true cargo test -p ml --test smoke_test_real_data --features cuda -- smoke_ohlcv_parse_and_extract_features --nocapture 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: PASS (falls back to `data/smoke-test/` path).
|
|
|
|
- [ ] **Step 7: Verify TEST_DATA_DIR override works**
|
|
|
|
```bash
|
|
TEST_DATA_DIR=/home/jgrusewski/Work/foxhunt/data/smoke-test \
|
|
SQLX_OFFLINE=true cargo test -p ml --test smoke_test_real_data --features cuda -- smoke_ohlcv_parse_and_extract_features --nocapture 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: PASS (uses env var path).
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add crates/ml/tests/smoke_test_real_data.rs crates/ml/tests/dqn_training_smoke_test.rs \
|
|
crates/ml/tests/dqn_training_pipeline_test.rs crates/ml/tests/dqn_early_stopping_termination_test.rs
|
|
git commit -m "test: add TEST_DATA_DIR env var support for H100 CI test workflow"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3b: Create supervised_gpu_smoke_test.rs
|
|
|
|
All 8 supervised models need GPU integration tests. Currently TGGN and TLOB have **zero** integration tests, and the other 6 (TFT, Mamba2, Liquid, KAN, xLSTM, Diffusion) only have CPU-only tests. This single test file exercises every supervised model through the full `UnifiedTrainable` pipeline on CUDA.
|
|
|
|
**Files:**
|
|
- Create: `crates/ml/tests/supervised_gpu_smoke_test.rs`
|
|
|
|
**Gap analysis:**
|
|
|
|
| Model | Existing integration test | GPU? |
|
|
|-------|--------------------------|------|
|
|
| TFT | `tft_test.rs` | CPU only |
|
|
| Mamba2 | `mamba2_hyperopt_edge_cases.rs` | CUDA gated, but hyperopt only |
|
|
| TGGN | **NONE** | — |
|
|
| TLOB | **NONE** | — |
|
|
| Liquid | `liquid_cfc_training_test.rs` | CPU only |
|
|
| KAN | `kan_integration.rs` | CPU only |
|
|
| xLSTM | `xlstm_integration.rs` | CPU only |
|
|
| Diffusion | `diffusion_integration.rs` | CPU only |
|
|
|
|
**Test design:** One `#[test]` per model, each exercises:
|
|
1. Construct adapter on CUDA device
|
|
2. Train 10 epochs (forward → loss → backward → step), assert all losses + grad norms finite
|
|
3. Checkpoint save/load roundtrip, assert predictions match
|
|
4. Validate on synthetic data, assert finite validation loss
|
|
|
|
All tests use synthetic data (small configs, ~16 batch, ~10 feature dim). Skips gracefully if no CUDA device available (`process::exit(0)` pattern from existing GPU tests).
|
|
|
|
- [ ] **Step 1: Write the test file**
|
|
|
|
```rust
|
|
// crates/ml/tests/supervised_gpu_smoke_test.rs
|
|
#![allow(
|
|
clippy::assertions_on_constants,
|
|
clippy::assertions_on_result_states,
|
|
clippy::clone_on_copy,
|
|
clippy::decimal_literal_representation,
|
|
clippy::doc_markdown,
|
|
clippy::empty_line_after_doc_comments,
|
|
clippy::field_reassign_with_default,
|
|
clippy::get_unwrap,
|
|
clippy::identity_op,
|
|
clippy::inconsistent_digit_grouping,
|
|
clippy::indexing_slicing,
|
|
clippy::integer_division,
|
|
clippy::len_zero,
|
|
clippy::let_underscore_must_use,
|
|
clippy::manual_div_ceil,
|
|
clippy::manual_let_else,
|
|
clippy::manual_range_contains,
|
|
clippy::modulo_arithmetic,
|
|
clippy::needless_range_loop,
|
|
clippy::non_ascii_literal,
|
|
clippy::redundant_clone,
|
|
clippy::shadow_reuse,
|
|
clippy::shadow_same,
|
|
clippy::shadow_unrelated,
|
|
clippy::single_match_else,
|
|
clippy::str_to_string,
|
|
clippy::string_slice,
|
|
clippy::tests_outside_test_module,
|
|
clippy::too_many_lines,
|
|
clippy::unnecessary_wraps,
|
|
clippy::unseparated_literal_suffix,
|
|
clippy::use_debug,
|
|
clippy::useless_vec,
|
|
clippy::wildcard_enum_match_arm,
|
|
clippy::else_if_without_else,
|
|
clippy::expect_used,
|
|
clippy::missing_const_for_fn,
|
|
clippy::similar_names,
|
|
clippy::type_complexity,
|
|
clippy::collapsible_else_if,
|
|
clippy::doc_lazy_continuation,
|
|
clippy::items_after_test_module,
|
|
clippy::map_clone,
|
|
clippy::multiple_unsafe_ops_per_block,
|
|
clippy::unwrap_or_default,
|
|
clippy::assign_op_pattern,
|
|
clippy::needless_borrow,
|
|
clippy::println_empty_string,
|
|
clippy::unnecessary_cast,
|
|
clippy::used_underscore_binding,
|
|
clippy::create_dir,
|
|
clippy::implicit_saturating_sub,
|
|
clippy::exit,
|
|
clippy::expect_fun_call,
|
|
clippy::too_many_arguments,
|
|
clippy::unnecessary_map_or,
|
|
clippy::unwrap_used,
|
|
dead_code,
|
|
unused_imports,
|
|
unused_variables,
|
|
clippy::cloned_ref_to_slice_refs,
|
|
clippy::neg_multiply,
|
|
clippy::while_let_loop,
|
|
clippy::bool_assert_comparison,
|
|
clippy::excessive_precision,
|
|
clippy::trivially_copy_pass_by_ref,
|
|
clippy::op_ref,
|
|
clippy::redundant_closure,
|
|
clippy::unnecessary_lazy_evaluations,
|
|
clippy::if_then_some_else_none,
|
|
clippy::unnecessary_to_owned,
|
|
clippy::single_component_path_imports,
|
|
)]
|
|
//! Supervised Model GPU Smoke Tests
|
|
//!
|
|
//! Validates all 8 supervised models through the UnifiedTrainable pipeline
|
|
//! on a CUDA device: construct → train 10 epochs → checkpoint roundtrip → validate.
|
|
//!
|
|
//! Models: TFT, Mamba2, TGGN, TLOB, Liquid, KAN, xLSTM, Diffusion
|
|
//!
|
|
//! Uses synthetic data with small configs to keep VRAM usage minimal.
|
|
//! Skips gracefully if no CUDA device is available.
|
|
|
|
#![allow(unused_crate_dependencies)]
|
|
|
|
use candle_core::{Device, Tensor};
|
|
use ml::training::unified_trainer::UnifiedTrainable;
|
|
|
|
fn require_cuda() -> Device {
|
|
match Device::cuda_if_available(0) {
|
|
Ok(dev) if dev.is_cuda() => {
|
|
println!("CUDA device available: {:?}", dev);
|
|
dev
|
|
}
|
|
_ => {
|
|
eprintln!("CUDA not available, skipping GPU smoke tests");
|
|
std::process::exit(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generic train-checkpoint-validate pipeline for any UnifiedTrainable model.
|
|
/// Returns (final_loss, checkpoint_prediction_diff).
|
|
fn smoke_pipeline(
|
|
adapter: &mut dyn UnifiedTrainable,
|
|
input: &Tensor,
|
|
target: &Tensor,
|
|
model_name: &str,
|
|
) {
|
|
let device = adapter.device().clone();
|
|
|
|
// 1. Train 10 epochs
|
|
let mut first_loss = None;
|
|
let mut last_loss = 0.0_f64;
|
|
for epoch in 0..10 {
|
|
let preds = adapter.forward(input).unwrap();
|
|
let loss = adapter.compute_loss(&preds, target).unwrap();
|
|
let loss_val = loss.to_scalar::<f32>().unwrap() as f64;
|
|
assert!(
|
|
loss_val.is_finite(),
|
|
"{} epoch {}: loss is NaN/Inf ({})",
|
|
model_name,
|
|
epoch,
|
|
loss_val,
|
|
);
|
|
if first_loss.is_none() {
|
|
first_loss = Some(loss_val);
|
|
}
|
|
last_loss = loss_val;
|
|
|
|
let grad_norm = adapter.backward(&loss).unwrap();
|
|
assert!(
|
|
grad_norm.is_finite(),
|
|
"{} epoch {}: grad_norm is NaN/Inf ({})",
|
|
model_name,
|
|
epoch,
|
|
grad_norm,
|
|
);
|
|
|
|
adapter.optimizer_step().unwrap();
|
|
adapter.zero_grad().unwrap();
|
|
}
|
|
let first = first_loss.unwrap();
|
|
println!(
|
|
" {} train: first_loss={:.6}, last_loss={:.6}, reduction={:.1}%",
|
|
model_name,
|
|
first,
|
|
last_loss,
|
|
(1.0 - last_loss / first) * 100.0,
|
|
);
|
|
assert_eq!(adapter.get_step(), 10, "{} should have 10 steps", model_name);
|
|
|
|
// 2. Checkpoint roundtrip
|
|
let tmp_dir = std::env::temp_dir().join(format!("gpu_smoke_{}", model_name.to_lowercase()));
|
|
std::fs::create_dir_all(&tmp_dir).unwrap();
|
|
let ckpt_path = tmp_dir.join("ckpt");
|
|
let save_result = adapter.save_checkpoint(ckpt_path.to_str().unwrap());
|
|
assert!(
|
|
save_result.is_ok(),
|
|
"{} checkpoint save failed: {:?}",
|
|
model_name,
|
|
save_result.err(),
|
|
);
|
|
|
|
// 3. Validate
|
|
let val_input = Tensor::randn(0f32, 0.5, input.dims(), &device).unwrap();
|
|
let val_target = Tensor::randn(0f32, 0.1, target.dims(), &device).unwrap();
|
|
let val_data = vec![(val_input, val_target)];
|
|
let val_loss = adapter.validate(&val_data);
|
|
assert!(
|
|
val_loss.is_ok(),
|
|
"{} validation failed: {:?}",
|
|
model_name,
|
|
val_loss.err(),
|
|
);
|
|
let vl = val_loss.unwrap();
|
|
assert!(
|
|
vl.is_finite(),
|
|
"{} validation loss is NaN/Inf ({})",
|
|
model_name,
|
|
vl,
|
|
);
|
|
println!(" {} validate: val_loss={:.6}", model_name, vl);
|
|
|
|
// 4. Metrics
|
|
let metrics = adapter.collect_metrics();
|
|
assert!(
|
|
metrics.learning_rate > 0.0,
|
|
"{} learning rate should be > 0",
|
|
model_name,
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
|
}
|
|
|
|
// ────────────────────────────────────────────
|
|
// TFT
|
|
// ────────────────────────────────────────────
|
|
#[test]
|
|
fn test_tft_gpu_smoke() {
|
|
let device = require_cuda();
|
|
println!("=== TFT GPU Smoke ===");
|
|
|
|
use ml::tft::trainable_adapter::TrainableTFT;
|
|
use ml::tft::TFTConfig;
|
|
|
|
let feature_dim = 10;
|
|
let mut config = TFTConfig::default();
|
|
config.input_dim = feature_dim;
|
|
config.hidden_dim = 32;
|
|
config.num_heads = 2;
|
|
config.num_layers = 1;
|
|
config.num_quantiles = 3;
|
|
config.num_static_features = 0;
|
|
config.num_known_features = 0;
|
|
config.num_unknown_features = feature_dim;
|
|
config.sequence_length = 1;
|
|
config.prediction_horizon = 1;
|
|
config.learning_rate = 1e-3;
|
|
config.dropout_rate = 0.0;
|
|
|
|
// TFT auto-selects device (cuda_if_available) in TemporalFusionTransformer::new()
|
|
let mut adapter = TrainableTFT::new(config).unwrap();
|
|
assert!(adapter.device().is_cuda(), "TFT should be on CUDA");
|
|
|
|
// Input: [batch, feature_dim] (seq_len=1, horizon=1, static=0, known=0)
|
|
let input = Tensor::randn(0f32, 0.5, &[16, feature_dim], &device).unwrap();
|
|
// TFT output: [batch, horizon=1, quantiles=3] → loss target must match
|
|
let target = Tensor::randn(0f32, 0.1, &[16, 1, 3], &device).unwrap();
|
|
smoke_pipeline(&mut adapter, &input, &target, "TFT");
|
|
}
|
|
|
|
// ────────────────────────────────────────────
|
|
// Mamba2
|
|
// ────────────────────────────────────────────
|
|
#[test]
|
|
fn test_mamba2_gpu_smoke() {
|
|
let device = require_cuda();
|
|
println!("=== Mamba2 GPU Smoke ===");
|
|
|
|
use ml::mamba::trainable_adapter::Mamba2TrainableAdapter;
|
|
use ml::mamba::Mamba2Config;
|
|
|
|
let mut config = Mamba2Config::default();
|
|
config.d_model = 32;
|
|
config.num_layers = 1;
|
|
config.d_state = 8;
|
|
config.max_seq_len = 8;
|
|
|
|
let mut adapter = Mamba2TrainableAdapter::new(config, &device).unwrap();
|
|
assert!(adapter.device().is_cuda(), "Mamba2 should be on CUDA");
|
|
|
|
// Input: [batch, seq_len, d_model]
|
|
let input = Tensor::randn(0f32, 0.5, &[16, 8, 32], &device).unwrap();
|
|
// Mamba2 compute_loss narrows to last step → target is [batch, 1]
|
|
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
|
|
smoke_pipeline(&mut adapter, &input, &target, "Mamba2");
|
|
}
|
|
|
|
// ────────────────────────────────────────────
|
|
// TGGN
|
|
// ────────────────────────────────────────────
|
|
#[test]
|
|
fn test_tggn_gpu_smoke() {
|
|
let device = require_cuda();
|
|
println!("=== TGGN GPU Smoke ===");
|
|
|
|
use ml::tgnn::trainable_adapter::TGGNTrainableAdapter;
|
|
use ml::tgnn::TGGNConfig;
|
|
|
|
let config = TGGNConfig {
|
|
node_dim: 10,
|
|
hidden_dim: 16,
|
|
num_layers: 2,
|
|
max_nodes: 8,
|
|
max_edges: 16,
|
|
edge_dim: 4,
|
|
temporal_decay: 0.99,
|
|
update_frequency_ns: 1_000_000,
|
|
use_simd: false,
|
|
};
|
|
|
|
let mut adapter = TGGNTrainableAdapter::new(config, &device).unwrap();
|
|
assert!(adapter.device().is_cuda(), "TGGN should be on CUDA");
|
|
|
|
// Input: [batch, node_dim=10], Output: [batch, 1]
|
|
let input = Tensor::randn(0f32, 0.5, &[16, 10], &device).unwrap();
|
|
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
|
|
smoke_pipeline(&mut adapter, &input, &target, "TGGN");
|
|
}
|
|
|
|
// ────────────────────────────────────────────
|
|
// TLOB
|
|
// ────────────────────────────────────────────
|
|
#[test]
|
|
fn test_tlob_gpu_smoke() {
|
|
let device = require_cuda();
|
|
println!("=== TLOB GPU Smoke ===");
|
|
|
|
use ml::tlob::trainable_adapter::{TLOBAdapterConfig, TLOBTrainableAdapter};
|
|
|
|
let config = TLOBAdapterConfig {
|
|
d_model: 16,
|
|
num_heads: 2,
|
|
num_layers: 1,
|
|
seq_len: 1,
|
|
feature_dim: 10,
|
|
};
|
|
|
|
let mut adapter = TLOBTrainableAdapter::new(config, &device).unwrap();
|
|
assert!(adapter.device().is_cuda(), "TLOB should be on CUDA");
|
|
|
|
// Input: [batch, seq_len*feature_dim=10] (flat), Output: [batch, 1]
|
|
let input = Tensor::randn(0f32, 0.5, &[16, 10], &device).unwrap();
|
|
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
|
|
smoke_pipeline(&mut adapter, &input, &target, "TLOB");
|
|
}
|
|
|
|
// ────────────────────────────────────────────
|
|
// Liquid (CfC)
|
|
// ────────────────────────────────────────────
|
|
#[test]
|
|
fn test_liquid_gpu_smoke() {
|
|
let device = require_cuda();
|
|
println!("=== Liquid GPU Smoke ===");
|
|
|
|
use ml::liquid::adapter::LiquidTrainableAdapter;
|
|
use ml::liquid::CfCTrainConfig;
|
|
use ml::gpu::DeviceConfig;
|
|
|
|
let config = CfCTrainConfig {
|
|
input_size: 10,
|
|
hidden_size: 16,
|
|
output_size: 1,
|
|
backbone_hidden_sizes: vec![16, 8],
|
|
learning_rate: 1e-3,
|
|
device: DeviceConfig::Cuda(0),
|
|
..CfCTrainConfig::default()
|
|
};
|
|
|
|
let mut adapter = LiquidTrainableAdapter::new(config).unwrap();
|
|
assert!(adapter.device().is_cuda(), "Liquid should be on CUDA");
|
|
|
|
// Input: [batch, seq_len=4, input_size=10] (3D required)
|
|
let input = Tensor::randn(0f32, 0.5, &[16, 4, 10], &device).unwrap();
|
|
// Output: [batch, output_size=1]
|
|
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
|
|
smoke_pipeline(&mut adapter, &input, &target, "Liquid");
|
|
}
|
|
|
|
// ────────────────────────────────────────────
|
|
// KAN
|
|
// ────────────────────────────────────────────
|
|
#[test]
|
|
fn test_kan_gpu_smoke() {
|
|
let device = require_cuda();
|
|
println!("=== KAN GPU Smoke ===");
|
|
|
|
use ml::kan::config::KANConfig;
|
|
use ml::kan::trainable::KANTrainableAdapter;
|
|
|
|
let config = KANConfig {
|
|
layer_widths: vec![10, 8, 4, 1],
|
|
grid_size: 3,
|
|
spline_order: 3,
|
|
learning_rate: 1e-3,
|
|
weight_decay: 1e-5,
|
|
grad_clip: 1.0,
|
|
};
|
|
|
|
let mut adapter = KANTrainableAdapter::new(config, &device).unwrap();
|
|
assert!(adapter.device().is_cuda(), "KAN should be on CUDA");
|
|
|
|
// Input: [batch, 10], Output: [batch, 1]
|
|
let input = Tensor::randn(0f32, 0.5, &[16, 10], &device).unwrap();
|
|
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
|
|
smoke_pipeline(&mut adapter, &input, &target, "KAN");
|
|
}
|
|
|
|
// ────────────────────────────────────────────
|
|
// xLSTM
|
|
// ────────────────────────────────────────────
|
|
#[test]
|
|
fn test_xlstm_gpu_smoke() {
|
|
let device = require_cuda();
|
|
println!("=== xLSTM GPU Smoke ===");
|
|
|
|
use ml::xlstm::trainable::XLSTMTrainableAdapter;
|
|
use ml::xlstm::config::XLSTMConfig;
|
|
|
|
let config = XLSTMConfig {
|
|
input_dim: 10,
|
|
hidden_dim: 16,
|
|
num_blocks: 2,
|
|
num_heads: 2,
|
|
slstm_ratio: 0.5,
|
|
output_dim: 1,
|
|
dropout: 0.0,
|
|
learning_rate: 1e-3,
|
|
weight_decay: 1e-5,
|
|
grad_clip: 1.0,
|
|
};
|
|
|
|
let mut adapter = XLSTMTrainableAdapter::new(config, &device).unwrap();
|
|
assert!(adapter.device().is_cuda(), "xLSTM should be on CUDA");
|
|
|
|
// Input: [batch, seq_len=4, input_dim=10] (3D)
|
|
let input = Tensor::randn(0f32, 0.5, &[16, 4, 10], &device).unwrap();
|
|
// Output: [batch, 1]
|
|
let target = Tensor::randn(0f32, 0.1, &[16, 1], &device).unwrap();
|
|
smoke_pipeline(&mut adapter, &input, &target, "xLSTM");
|
|
}
|
|
|
|
// ────────────────────────────────────────────
|
|
// Diffusion
|
|
// ────────────────────────────────────────────
|
|
#[test]
|
|
fn test_diffusion_gpu_smoke() {
|
|
let device = require_cuda();
|
|
println!("=== Diffusion GPU Smoke ===");
|
|
|
|
use ml::diffusion::config::DiffusionConfig;
|
|
use ml::diffusion::trainable::DiffusionTrainableAdapter;
|
|
|
|
let config = DiffusionConfig {
|
|
num_timesteps: 50,
|
|
sampling_steps: 5,
|
|
seq_len: 8,
|
|
feature_dim: 1,
|
|
hidden_dim: 16,
|
|
num_layers: 1,
|
|
time_embed_dim: 8,
|
|
learning_rate: 1e-3,
|
|
weight_decay: 1e-5,
|
|
grad_clip: 1.0,
|
|
..Default::default()
|
|
};
|
|
let data_dim = config.data_dim(); // 8
|
|
|
|
let mut adapter = DiffusionTrainableAdapter::new(config, device.clone()).unwrap();
|
|
assert!(adapter.device().is_cuda(), "Diffusion should be on CUDA");
|
|
|
|
// Input: [batch, data_dim=8]
|
|
let input = Tensor::randn(0f32, 0.5, &[16, data_dim], &device).unwrap();
|
|
// Diffusion: use input as pseudo-target (noise prediction, loss won't be meaningful)
|
|
let target = input.clone();
|
|
smoke_pipeline(&mut adapter, &input, &target, "Diffusion");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify it compiles (no CUDA needed — just check syntax)**
|
|
|
|
```bash
|
|
SQLX_OFFLINE=true cargo test -p ml --features cuda --test supervised_gpu_smoke_test --no-run 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: compiles with 0 errors (tests won't run without CUDA — they exit(0)).
|
|
|
|
- [ ] **Step 3: Run on local GPU (if 4GB allows — may OOM, that's OK)**
|
|
|
|
```bash
|
|
SQLX_OFFLINE=true cargo test -p ml --features cuda --test supervised_gpu_smoke_test -- --nocapture --test-threads=1 2>&1 | tail -30
|
|
```
|
|
|
|
Expected: either all 8 PASS, or OOM on 4GB GPU (fine — will work on H100).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add crates/ml/tests/supervised_gpu_smoke_test.rs
|
|
git commit -m "test: add supervised_gpu_smoke_test for all 8 models (UnifiedTrainable on CUDA)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Create GPU test WorkflowTemplate
|
|
|
|
**Files:**
|
|
- Create: `infra/k8s/argo/gpu-test-pipeline-template.yaml`
|
|
|
|
This is the core workflow. Reference `compile-and-train-template.yaml` for patterns (git checkout, env vars, tolerations, notification).
|
|
|
|
- [ ] **Step 1: Create the WorkflowTemplate**
|
|
|
|
Create `infra/k8s/argo/gpu-test-pipeline-template.yaml`. The complete YAML is provided in a single block below.
|
|
|
|
The file has 4 templates: `pipeline` (DAG entrypoint), `gpu-warmup`, `compile-and-test`, and `notify-result`.
|
|
|
|
The shell script for `compile-and-test` is shown separately after the YAML structure for readability, but must be inlined in the `args: [|]` block.
|
|
|
|
```yaml
|
|
# 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: 5400
|
|
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 ──
|
|
- name: pipeline
|
|
dag:
|
|
tasks:
|
|
- name: gpu-warmup
|
|
template: gpu-warmup
|
|
- name: compile-and-test
|
|
template: compile-and-test
|
|
dependencies: [gpu-warmup]
|
|
|
|
# ── gpu-warmup: trigger H100 autoscale ──
|
|
- 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"]
|
|
args:
|
|
- |
|
|
echo "GPU warmup: triggering node autoscale..."
|
|
echo "GPU node scheduled, exiting to free resources"
|
|
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/sh", "-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
|
|
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 (same as compile-and-train) ---
|
|
if [ -d "$BUILD/.git" ]; then
|
|
cd "$BUILD"
|
|
CURRENT=$(git rev-parse HEAD 2>/dev/null || echo "none")
|
|
TARGET=$(git ls-remote origin "$REF" 2>/dev/null | cut -f1 || echo "$REF")
|
|
if [ "$CURRENT" = "$TARGET" ] || [ "$CURRENT" = "$REF" ]; then
|
|
echo "=== Already at $REF ==="
|
|
else
|
|
echo "=== Updating checkout ==="
|
|
git fetch origin
|
|
git checkout --force "$REF"
|
|
git clean -fd
|
|
fi
|
|
else
|
|
echo "=== Initial clone ==="
|
|
git clone --filter=blob:none "$REPO" "$BUILD"
|
|
cd "$BUILD"
|
|
git checkout "$REF"
|
|
fi
|
|
echo "Checked out $(git rev-parse --short HEAD)"
|
|
|
|
export PATH="${CARGO_HOME}/bin:${PATH}"
|
|
export CUDA_COMPUTE_CAP={{workflow.parameters.cuda-compute-cap}}
|
|
|
|
# --- 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
|
|
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
|
|
|
|
run_tests() {
|
|
local NAME="$1"; shift
|
|
echo ""
|
|
echo "========================================"
|
|
echo " TEST: $NAME"
|
|
echo "========================================"
|
|
set +e
|
|
"$@" --nocapture 2>&1
|
|
EXIT=$?
|
|
set -e
|
|
if [ $EXIT -eq 0 ]; then
|
|
RESULTS="${RESULTS}${NAME}:PASS\n"
|
|
else
|
|
RESULTS="${RESULTS}${NAME}:FAIL\n"
|
|
FAILURES=$((FAILURES + 1))
|
|
fi
|
|
}
|
|
|
|
# --- Core tests (always run) ---
|
|
run_tests "core-lib" cargo test -p ml-core --features cuda --lib
|
|
run_tests "bayesian" cargo test -p ml --features cuda --test bayesian_changepoint_test
|
|
|
|
# --- 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
|
|
run_tests "dqn-lib" cargo test -p ml-dqn --features cuda --lib
|
|
run_tests "dqn-ml-lib" cargo test -p ml --features cuda --lib -- dqn
|
|
fi
|
|
if [ "$SCOPE" = "integration" ] || [ "$SCOPE" = "all" ]; then
|
|
run_tests "dqn-smoke" cargo test -p ml --features cuda --test smoke_test_real_data
|
|
run_tests "dqn-pipeline" cargo test -p ml --features cuda --test dqn_training_pipeline_test
|
|
run_tests "dqn-smoke-train" cargo test -p ml --features cuda --test dqn_training_smoke_test
|
|
run_tests "dqn-early-stop" cargo test -p ml --features cuda --test dqn_early_stopping_termination_test
|
|
run_tests "dqn-collapse" cargo test -p ml --features cuda --test dqn_action_collapse_fix_test
|
|
fi
|
|
;;
|
|
ppo)
|
|
if [ "$SCOPE" = "lib" ] || [ "$SCOPE" = "all" ]; then
|
|
run_tests "ppo-lib" cargo test -p ml --features cuda --lib -- ppo
|
|
fi
|
|
if [ "$SCOPE" = "integration" ] || [ "$SCOPE" = "all" ]; then
|
|
run_tests "ppo-barrier" cargo test -p ml --features cuda --test barrier_optimization_test
|
|
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 -- "$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_${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"
|
|
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
|
|
|
|
# ── notify-result: post test outcome to Mattermost (onExit) ──
|
|
- name: notify-result
|
|
nodeSelector:
|
|
k8s.scaleway.com/pool-name: platform
|
|
container:
|
|
image: gitlab-registry.foxhunt.svc.cluster.local:5000/root/foxhunt/foxhunt-runtime:latest
|
|
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"
|
|
```
|
|
|
|
The complete shell script is inlined in the YAML above within the `compile-and-test` template's `args` block.
|
|
|
|
- [ ] **Step 2: Validate YAML syntax**
|
|
|
|
```bash
|
|
python3 -c "import yaml; yaml.safe_load(open('infra/k8s/argo/gpu-test-pipeline-template.yaml'))" && echo "YAML OK"
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add infra/k8s/argo/gpu-test-pipeline-template.yaml
|
|
git commit -m "feat(ci): add gpu-test-pipeline WorkflowTemplate for H100 CUDA testing"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Create nightly CronWorkflow
|
|
|
|
**Files:**
|
|
- Create: `infra/k8s/argo/gpu-test-nightly-cron.yaml`
|
|
|
|
- [ ] **Step 1: Create CronWorkflow (suspended)**
|
|
|
|
```yaml
|
|
# infra/k8s/argo/gpu-test-nightly-cron.yaml
|
|
apiVersion: argoproj.io/v1alpha1
|
|
kind: CronWorkflow
|
|
metadata:
|
|
name: gpu-test-nightly
|
|
namespace: foxhunt
|
|
labels:
|
|
app.kubernetes.io/name: gpu-test-nightly
|
|
app.kubernetes.io/part-of: foxhunt
|
|
spec:
|
|
schedule: "0 2 * * *"
|
|
timezone: "UTC"
|
|
suspend: true # Enable when ready: kubectl patch cronworkflow gpu-test-nightly -n foxhunt -p '{"spec":{"suspend":false}}'
|
|
concurrencyPolicy: Replace
|
|
successfulJobsHistoryLimit: 3
|
|
failedJobsHistoryLimit: 5
|
|
workflowSpec:
|
|
workflowTemplateRef:
|
|
name: gpu-test-pipeline
|
|
arguments:
|
|
parameters:
|
|
- name: models
|
|
value: "dqn,ppo,tft,mamba2,tggn,tlob,liquid,kan,xlstm,diffusion"
|
|
- name: commit-ref
|
|
value: "main"
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add infra/k8s/argo/gpu-test-nightly-cron.yaml
|
|
git commit -m "feat(ci): add gpu-test-nightly CronWorkflow (suspended)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Create argo-test.sh CLI
|
|
|
|
**Files:**
|
|
- Create: `scripts/argo-test.sh`
|
|
|
|
- [ ] **Step 1: Create the script**
|
|
|
|
Pattern matches `scripts/argo-train.sh` (same argument parsing style, same `argo submit` invocation).
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
# Run GPU/CUDA tests via Argo Workflows on H100.
|
|
#
|
|
# Usage:
|
|
# ./scripts/argo-test.sh # defaults: dqn,ppo,tft
|
|
# ./scripts/argo-test.sh --models dqn --scope lib # DQN lib tests only
|
|
# ./scripts/argo-test.sh --models all # all 10 models
|
|
# ./scripts/argo-test.sh --watch # follow logs
|
|
#
|
|
# Requires: argo CLI
|
|
set -euo pipefail
|
|
|
|
MODELS="dqn,ppo,tft"
|
|
SCOPE="all"
|
|
COMMIT_REF="HEAD"
|
|
GPU_POOL=""
|
|
WATCH=false
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "$0") [OPTIONS]
|
|
|
|
Options:
|
|
--models <list> Comma-separated model list (default: dqn,ppo,tft)
|
|
Use "all" for all 10 models
|
|
--scope <scope> lib, integration, or all (default: all)
|
|
--ref <ref> Git ref to test (default: HEAD)
|
|
--gpu-pool <pool> GPU node pool (default: ci-training-h100)
|
|
--watch Follow workflow logs
|
|
-h, --help Show this help
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--models) MODELS="$2"; shift 2 ;;
|
|
--scope) SCOPE="$2"; shift 2 ;;
|
|
--ref) COMMIT_REF="$2"; shift 2 ;;
|
|
--gpu-pool) GPU_POOL="$2"; shift 2 ;;
|
|
--watch) WATCH=true; shift ;;
|
|
-h|--help) usage ;;
|
|
*) echo "Unknown option: $1"; usage ;;
|
|
esac
|
|
done
|
|
|
|
# Resolve HEAD to actual SHA if needed
|
|
if [ "$COMMIT_REF" = "HEAD" ]; then
|
|
COMMIT_REF=$(git rev-parse HEAD)
|
|
fi
|
|
|
|
CMD="argo submit -n foxhunt --from=wftmpl/gpu-test-pipeline"
|
|
CMD="$CMD -p commit-ref=$COMMIT_REF"
|
|
CMD="$CMD -p models=$MODELS"
|
|
CMD="$CMD -p test-scope=$SCOPE"
|
|
|
|
[[ -n "$GPU_POOL" ]] && CMD="$CMD -p gpu-pool=$GPU_POOL"
|
|
|
|
if $WATCH; then
|
|
CMD="$CMD --watch"
|
|
fi
|
|
|
|
echo "Submitting GPU test workflow..."
|
|
echo " commit: $(echo $COMMIT_REF | cut -c1-8)"
|
|
echo " models: $MODELS"
|
|
echo " scope: $SCOPE"
|
|
echo ""
|
|
eval "$CMD"
|
|
```
|
|
|
|
- [ ] **Step 2: Make executable and commit**
|
|
|
|
```bash
|
|
chmod +x scripts/argo-test.sh
|
|
git add scripts/argo-test.sh
|
|
git commit -m "feat(ci): add argo-test.sh CLI for GPU test workflow"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Add on-push trigger to CI pipeline
|
|
|
|
**Files:**
|
|
- Modify: `infra/k8s/argo/ci-pipeline-template.yaml`
|
|
|
|
- [ ] **Step 1: Add `ml-changed` to detect-changes**
|
|
|
|
In `ci-pipeline-template.yaml`, inside the `detect-changes` template's shell script (around line 199), add:
|
|
|
|
```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")
|
|
```
|
|
|
|
And add the output file write (around line 218):
|
|
```bash
|
|
echo -n "$ML_CHANGED" > /tmp/outputs/ml-changed
|
|
```
|
|
|
|
And add the log line (around line 210):
|
|
```bash
|
|
echo "ml-changed: $ML_CHANGED"
|
|
```
|
|
|
|
- [ ] **Step 2: Add ml-changed output parameter**
|
|
|
|
In the `outputs.parameters` section of `detect-changes` (around line 238), add:
|
|
```yaml
|
|
- name: ml-changed
|
|
valueFrom:
|
|
path: /tmp/outputs/ml-changed
|
|
```
|
|
|
|
- [ ] **Step 3: Add gpu-test DAG task + submit-gpu-test template**
|
|
|
|
**Important**: Cannot use `templateRef` to invoke `gpu-test-pipeline/pipeline` directly from the CI pipeline DAG because `templateRef` runs the referenced template within the **calling** workflow's pod context. The CI pipeline lacks the required PVC volumes (`cargo-target-cuda-test`, `test-data-pvc`) and GPU nodeSelector/tolerations. Instead, use a `resource` template that submits a child Workflow from the WorkflowTemplate (workflow-of-workflows pattern).
|
|
|
|
In the `pipeline` DAG `tasks` section (around line 143), add:
|
|
```yaml
|
|
- name: gpu-test
|
|
depends: "detect-changes"
|
|
template: submit-gpu-test
|
|
when: "{{tasks.detect-changes.outputs.parameters.ml-changed}} == true"
|
|
```
|
|
|
|
Add the `submit-gpu-test` template to the `templates` list:
|
|
```yaml
|
|
# ── submit-gpu-test: launch GPU test workflow as a child workflow ──
|
|
- name: submit-gpu-test
|
|
resource:
|
|
action: create
|
|
setOwnerReference: true
|
|
successCondition: status.phase == Succeeded
|
|
failureCondition: status.phase in (Failed, Error)
|
|
manifest: |
|
|
apiVersion: argoproj.io/v1alpha1
|
|
kind: Workflow
|
|
metadata:
|
|
generateName: gpu-test-on-push-
|
|
namespace: foxhunt
|
|
spec:
|
|
workflowTemplateRef:
|
|
name: gpu-test-pipeline
|
|
arguments:
|
|
parameters:
|
|
- name: commit-ref
|
|
value: "{{workflow.parameters.commit-sha}}"
|
|
- name: models
|
|
value: "dqn,ppo,tft"
|
|
```
|
|
|
|
This creates a child Workflow from the `gpu-test-pipeline` WorkflowTemplate. The child workflow has its own volumes, nodeSelector, and tolerations defined in the WorkflowTemplate spec. `setOwnerReference: true` ensures the child workflow is cleaned up when the parent is deleted.
|
|
|
|
- [ ] **Step 4: Validate YAML syntax**
|
|
|
|
```bash
|
|
python3 -c "import yaml; yaml.safe_load(open('infra/k8s/argo/ci-pipeline-template.yaml'))" && echo "YAML OK"
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add infra/k8s/argo/ci-pipeline-template.yaml
|
|
git commit -m "feat(ci): add gpu-test on-push trigger gated on ML crate changes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Apply to cluster and validate
|
|
|
|
- [ ] **Step 1: Apply PVCs**
|
|
|
|
```bash
|
|
kubectl -n foxhunt apply -f infra/k8s/argo/cargo-target-cuda-test-pvc.yaml
|
|
kubectl -n foxhunt apply -f infra/k8s/training/test-data-pvc.yaml
|
|
```
|
|
|
|
- [ ] **Step 2: Populate test data**
|
|
|
|
```bash
|
|
./scripts/refresh-test-data.sh
|
|
```
|
|
|
|
Wait for job completion. Verify data:
|
|
```bash
|
|
kubectl -n foxhunt exec -it $(kubectl -n foxhunt get pods -l app.kubernetes.io/name=populate-test-data -o name | head -1) -- ls -lhR /test-data/
|
|
```
|
|
|
|
- [ ] **Step 3: Apply WorkflowTemplate and CronWorkflow**
|
|
|
|
```bash
|
|
kubectl -n foxhunt apply -f infra/k8s/argo/gpu-test-pipeline-template.yaml
|
|
kubectl -n foxhunt apply -f infra/k8s/argo/gpu-test-nightly-cron.yaml
|
|
```
|
|
|
|
- [ ] **Step 4: Run a quick manual test (DQN lib only)**
|
|
|
|
```bash
|
|
./scripts/argo-test.sh --models dqn --scope lib --watch
|
|
```
|
|
|
|
Expected: compile + DQN lib tests pass on H100.
|
|
|
|
- [ ] **Step 5: Run full default suite**
|
|
|
|
```bash
|
|
./scripts/argo-test.sh --watch
|
|
```
|
|
|
|
Expected: DQN + PPO + TFT tests all pass.
|
|
|
|
- [ ] **Step 6: Apply updated CI pipeline**
|
|
|
|
```bash
|
|
kubectl -n foxhunt apply -f infra/k8s/argo/ci-pipeline-template.yaml
|
|
```
|
|
|
|
- [ ] **Step 7: Final commit (if any fixes needed)**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix(ci): adjustments from H100 GPU test validation"
|
|
```
|