# H100 GPU Pipeline Optimization Design

**Goal:** Reduce DQN training epoch time from 2 min to <10s on H100 by eliminating GPU idle time, maximizing SM utilization (52% → 90%+), and exploiting H100's multi-stream capabilities.

**Architecture:** Async CUDA event pipeline replaces blocking synchronize calls. Multi-stream dispatch for independent network passes. CUDA Graph capture for experience collection. Larger batch sizes to saturate tensor cores.

**Current state:** H100 PCIe, 80GB VRAM, 52% SM utilization, 0% memory bandwidth, 102W/350W TDP. 341 training steps/epoch at batch=1024. 204K experiences collected per epoch via 100-timestep host-driven loop.

---

## Root Cause Analysis

The primary bottleneck is **CPU→GPU serialization**: `stream.synchronize()` is called 341 times per epoch in `replay_adam_and_readback()`. The H100 completes each graph pair in microseconds but then idles while Rust re-enters the loop, re-acquires locks, and reissues kernels. Secondary: the experience collection has a 100-iteration host loop dispatching 4-5 kernels each, creating launch latency bubbles.

nvidia-smi confirms: 52% SM utilization (should be 90%+), 0% memory bandwidth (no large tensor ops saturating HBM3), 102W/350W TDP (GPU is thermally idling).

---

## Optimizations (ordered by impact)

### 1. CUDA Events replace per-step stream.synchronize()

**File:** `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:2872`

**Current:** After every training step, `stream.synchronize()` blocks the CPU until both CUDA graphs complete. Loss + grad_norm scalars are read back synchronously. The CPU then re-enters Rust, re-acquires locks, and reissues the next step. This creates a bubble between every step.

**Fix:** Record a CUDA event after `graph_adam` completes. The CPU immediately proceeds to issue the next step's batch sampling + graph launches without waiting. Loss/grad scalars from step N are read when event N signals completion (typically before step N+2 needs them for PER priority update).

**Validation:** Loss values must be identical to the synchronous path. Run 10 epochs on RTX 3050 and compare loss trajectories bit-for-bit.

**Risk:** PER priority updates lag by 1-2 steps. Acceptable — PER priorities are approximate by design (stale priorities are standard in distributed RL).

### 2. CUDA Graph for experience collection

**File:** `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs:1506`

**Current:** `launch_timestep_loop` runs a host-driven `for t in 0..100` loop. Each iteration dispatches 4-5 kernels through Rust. The GPU executes each kernel batch quickly but waits for the CPU to increment `t`, compute pointers, and reissue.

**Fix:** Capture the entire 100-timestep loop as a single CUDA Graph. All per-timestep parameters (epsilon, step counter, state buffer pointers) become GPU-resident buffers updated via a single DtoH memcpy before graph launch. One graph launch replaces 400-500 individual kernel dispatches.

**Complexity:** High. The experience kernel uses dynamic epsilon (decays per step) and reads from the replay buffer which changes size. These must be converted to GPU-side indirection (pointer-to-pointer) or parameterized via graph node updates (CUDA 12+ `cudaGraphExecKernelNodeSetParams`).

**Validation:** Compare collected experiences (states, actions, rewards) with the sequential path for 1 epoch. Must be numerically identical given the same RNG seed.

**Risk:** Graph capture may fail if any kernel uses dynamic shared memory or conditional branches that differ between timesteps. Fall back to the sequential path if capture fails.

### 3. Multi-stream branch parallelism in forward/backward pass

**File:** `crates/ml/src/cuda_pipeline/batched_forward.rs:270`

**Current:** The 3 advantage branches (exposure=9, order=3, urgency=3) are dispatched sequentially in a `for d in 0..3` loop. Each branch does: GEMM → bias+ReLU → GEMM → bias (4 ops). Total: 12 sequential ops.

**Fix:** Fork 3 child streams from the main training stream. Each branch dispatches its 4 ops on its own stream. A join event synchronizes all 3 before the loss kernel. Within a CUDA Graph, this uses `cudaStreamBeginCaptureToGraph` with fork/join topology (CUDA 12+ feature).

**Applies to:** Forward pass (line 270), f32 forward (line 325), backward pass (line 461) — all 3 branch loops.

**Validation:** Output logits must be bit-exact vs sequential dispatch. The branches write to non-overlapping regions of `b_logits_buf` (offset by `logit_byte_offset`), so no race conditions.

**Risk:** CUDA Graph fork/join is complex. If cudarc doesn't expose the necessary APIs, we may need raw CUDA driver calls via `cudarc::driver::sys`.

### 4. Multi-stream target/online forward parallelism

**File:** `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:4307-4323`

**Current:** Pass 2 (target forward on next_states) and Pass 3 (online forward on next_states for Double DQN) execute sequentially. They read from the same `next_states_buf` but write to completely independent output buffers.

**Fix:** Dispatch Pass 2 and Pass 3 on separate streams. Join before the loss kernel. Within the CUDA Graph, this is another fork/join.

**Validation:** Target Q-values and online Q-values must be bit-exact vs sequential. No shared write buffers — safe to parallelize.

### 5. Dynamic batch sizing via AutoBatchSizer

**File:** `crates/ml/src/trainers/dqn/trainer/constructor.rs`, `crates/ml-core/src/memory_optimization/auto_batch_size.rs`

**Current:** `AutoBatchSizer` already computes optimal batch size from VRAM — the H100 log shows `VRAM ceiling = 2,085,808` — but CLI `--batch-size 128` overrides it. The h100.toml sets `batch_size = 1024` which also caps it far below optimal.

**Fix:** Let AutoBatchSizer drive the batch size. Remove the static h100.toml override. The sizer already accounts for replay buffer VRAM, per-batch tensor allocations, and kernel shared memory. On H100 with 80GB VRAM and 10M buffer (~35GB), the sizer should yield batch sizes of 4096-8192+ for the remaining ~45GB.

Ensure the C51/MSE loss kernels' shared memory scales correctly with larger batches (shared memory per block is fixed, but grid size grows linearly with batch — OK for H100's 228KB/SM).

**Validation:** Log the auto-computed batch size. Train 5 epochs at auto size vs 1024, compare loss. Apply linear LR scaling rule if batch increases >4x (LR × batch_ratio).

### 6. Q-stats async readback

**File:** `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:3657`

**Current:** `reduce_current_q_stats()` calls `memcpy_dtoh` every 50 steps — implicit stream sync.

**Fix:** Use the same CUDA event pattern. Dispatch the reduction kernel, record an event, read scalars on the next call (50 steps later — always completed by then).

**Validation:** Q-value stats must match synchronous readback. Logging-only data — no training impact if delayed by 50 steps.

### 7. Experience collection → training phase overlap

**File:** `crates/ml/src/trainers/dqn/trainer/training_loop.rs:1196`

**Current:** `stream.synchronize()` blocks between experience collection and training. The CPU waits for all 204K experience kernels to complete before starting training setup.

**Fix:** Record an event after the last experience kernel. The CPU proceeds with training setup (lock acquisition, batch pre-sampling, graph preparation). Wait on the event only when the first training step actually needs replay buffer data.

**Validation:** Replay buffer must be fully populated before the first training step samples from it. The event ensures this without a full stream sync.

### 8. Remove lock contention in prefetch loop

**File:** `crates/ml/src/trainers/dqn/trainer/training_loop.rs:1326`

**Current:** PREFETCH_K=32 loop acquires/releases read/write locks per chunk. Between chunks there's a lock release → re-acquire gap.

**Fix:** With async events, batch pre-sampling can happen in a dedicated thread that holds the read lock for the entire epoch. The training thread holds the write lock. No contention if the PER buffer is lock-free (GPU-resident segment tree already is).

**Validation:** No training impact — this is a CPU-side optimization. Verify no deadlocks under stress (run 100 epochs).

### 9. fxcache PVC alignment

**File:** `infra/k8s/argo/precompute-features-template.yaml`

**Current:** Precompute uses `--data-dir /data/futures-baseline`, but `train_baseline_rl` internally resolves to `data_dir/ES.FUT`. Cache key mismatch → falls back to 5min DBN extraction on H100.

**Fix:** Already committed — precompute template now passes `data-dir/SYMBOL`. Need to regenerate the PVC cache and validate the training binary hits fxcache.

**Validation:** H100 training log must show "fxcache hit" instead of "Loading OHLCV bars from DBN files".

---

## Implementation Order

**Phase 1 (biggest impact, lowest risk):**
1. CUDA Events (item 1) — eliminates 341 syncs/epoch
5. Batch size increase (item 5) — config change only
6. Q-stats async (item 6) — small, safe
9. fxcache alignment (item 9) — already committed, just regenerate

**Phase 2 (medium complexity):**
7. Experience → training overlap (item 7)
8. Lock contention removal (item 8)

**Phase 3 (high complexity, high reward):**
2. CUDA Graph for experience collection (item 2)
3. Multi-stream branch parallelism (item 3)
4. Multi-stream target/online parallelism (item 4)

---

## Success Criteria

- Epoch time: <10s on H100 (from 2 min)
- SM utilization: >85% during training (from 52%)
- Memory bandwidth: >50% during GEMM (from 0%)
- TDP: >250W during training (from 102W)
- Loss trajectory: identical to baseline within floating-point tolerance
- No GPU memory leaks over 200 epochs
