Files
foxhunt/docs/superpowers/specs/2026-03-10-zero-cpu-hotpath-design.md
jgrusewski 41440e9ae7 docs: add zero-CPU DQN training hot path design spec
Defines architecture for eliminating all 10 GPU→CPU sync barriers from
the training loop via 4 new GPU components: Training Guard (pinned
memory predicates), Q-Value Monitor (on-device accumulator), GPU-resident
action selection, and async experience collector readback.

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

261 lines
12 KiB
Markdown
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.
# Zero-CPU DQN Training Hot Path — Design Spec
**Goal:** Eliminate all GPU→CPU synchronization barriers (`to_scalar()`, `to_vec1()`, `to_vec2()`, `memcpy_dtoh`) from the DQN training hot path so the GPU pipeline never stalls between `backward()` and `optimizer.step()`.
**Architecture:** Four new GPU components replace 10 CPU readback sites with on-device predicates, pinned-memory polling, and GPU-resident accumulators. All monitoring/safety checks (NaN detection, loss clipping, gradient collapse, Q-value divergence) execute as CUDA kernels writing boolean halt flags to pinned host memory — zero `cudaStreamSynchronize` barriers per training step.
**Tech Stack:** CUDA (NVRTC compiled), cudarc 0.17, Candle v0.9.1 (Rust ML framework), pinned host memory via `cudarc::driver::CudaHostBuf`
---
## Readback Inventory (10 sites to eliminate)
| # | File:Line | Pattern | Frequency | Replacement |
|---|-----------|---------|-----------|-------------|
| 1 | trainer.rs:4531 | `stacked.to_vec1::<f32>()` | Per train step | GpuTrainingGuard::check() |
| 2 | gradient_utils.rs:108 | `total_sum.to_scalar::<f32>()` | Per accumulation boundary | GpuTrainingGuard NaN predicate |
| 3 | trainer.rs:4739 | `mean_all().to_scalar()` | Per accumulation boundary | GpuTrainingGuard::read_accumulators() |
| 4 | trainer.rs:4751 | `mean_all().to_scalar()` | Per accumulation boundary | GpuTrainingGuard::read_accumulators() |
| 5 | trainer.rs:4860 | `mean_all().to_scalar::<f32>()` | Every 50 steps | GPU Q-value accumulator |
| 6 | dqn.rs:3618 | `to_vec2::<f32>()` | Every 50 steps | GPU Q-value stats reduction |
| 7 | trainer.rs:4113 | `to_vec1::<u32>()` | Per experience batch | GPU-resident action tensor |
| 8 | trainer.rs:4302-4303 | `to_vec1::<u32>()` | Per experience batch | GPU-resident action tensor |
| 9 | trainer.rs:4334-4335 | `to_vec1::<u32>()` | CPU fallback (non-cuda) | N/A (CPU-only path) |
| 10 | gpu_experience_collector.rs:769-772 | `memcpy_dtoh` | Per GPU collection | Async deferred readback |
**Note:** Site #9 is the CPU-only fallback path (no GPU selector). This only runs when `#[cfg(not(feature = "cuda"))]` or when GPU selector init fails. We leave this path as-is since it's not a GPU hot path.
---
## Component A: GPU Training Guard
**New files:**
- `crates/ml/src/cuda_pipeline/training_guard_kernel.cu`
- `crates/ml/src/cuda_pipeline/gpu_training_guard.rs`
### CUDA Kernel: `training_guard_check`
Single-thread kernel (1 block × 1 thread) that reads GPU-resident loss and grad_norm scalars and writes results to pinned host memory.
**Inputs (device pointers):**
- `loss_val: *const f32` — scalar loss from train_step (GPU-resident)
- `grad_norm_val: *const f32` — scalar grad_norm from clip_grad_norm (GPU-resident)
- `loss_clip_threshold: f32` — 1e6 (constant)
- `grad_collapse_threshold: f32` — LR × multiplier (passed from Rust)
- `warmup_active: i32` — 1 if within warmup period (skip grad collapse check)
**Outputs (pinned host memory, 7 × f32 = 28 bytes):**
- `[0]`: halt_nan (1.0 if loss or grad_norm is NaN/Inf, else 0.0)
- `[1]`: halt_loss_clip (1.0 if loss > threshold, else 0.0)
- `[2]`: halt_grad_collapse (1.0 if grad_norm < threshold && !warmup, else 0.0)
- `[3]`: clipped_loss (min(loss, threshold))
- `[4]`: raw_loss (unclipped, for logging)
- `[5]`: raw_grad_norm (for logging)
- `[6]`: reserved (padding to 32 bytes)
**On-device accumulators** (persistent CudaSlice, not pinned):
- `loss_sum: CudaSlice<f32>` — running sum of losses across steps
- `grad_norm_sum: CudaSlice<f32>` — running sum of grad norms
- `step_count: CudaSlice<i32>` — number of steps accumulated
### CUDA Kernel: `training_guard_accumulate`
Adds current loss and grad_norm to running sums, increments counter.
### Rust Wrapper: `GpuTrainingGuard`
```rust
pub struct GpuTrainingGuard {
check_func: CudaFunction,
accumulate_func: CudaFunction,
pinned_output: CudaHostBuf<f32>, // 7 × f32 pinned
loss_sum: CudaSlice<f32>,
grad_norm_sum: CudaSlice<f32>,
step_count: CudaSlice<i32>,
device: Device,
}
```
**Methods:**
- `new(device) -> Result<Self>` — compile PTX, allocate pinned + device buffers
- `check(loss_gpu: &Tensor, grad_norm_gpu: &Tensor, threshold: f32, warmup: bool) -> Result<GuardResult>` — launches kernel, reads pinned memory (no cudaStreamSync)
- `accumulate(loss_gpu: &Tensor, grad_norm_gpu: &Tensor) -> Result<()>` — adds to running sums
- `read_accumulators() -> Result<(f64, f64)>` — single DtoH of mean loss + mean grad_norm (epoch boundary only)
- `reset_accumulators() -> Result<()>` — zeros device buffers
```rust
pub struct GuardResult {
pub halt_nan: bool,
pub halt_loss_clip: bool,
pub halt_grad_collapse: bool,
pub clipped_loss: f32,
pub raw_grad_norm: f32,
}
```
### Wiring (trainer.rs)
**train_step_optimized** (replaces sites #1):
```
// BEFORE: stacked.to_vec1::<f32>() — GPU sync barrier
// AFTER:
let guard_result = self.training_guard.check(
&gpu_result.loss_gpu,
&gpu_result.grad_norm_gpu,
grad_collapse_threshold,
!past_warmup,
)?;
if guard_result.halt_nan { return Err(...) }
if guard_result.halt_loss_clip { log warning }
if guard_result.halt_grad_collapse { increment counter, check patience }
self.training_guard.accumulate(&gpu_result.loss_gpu, &gpu_result.grad_norm_gpu)?;
```
**train_step_with_accumulation** (replaces sites #3, #4):
```
// BEFORE: gpu_loss_tensors concat → mean_all().to_scalar()
// AFTER: accumulate() called per sub-step, read_accumulators() at boundary
let (avg_loss, avg_grad_norm) = self.training_guard.read_accumulators()?;
self.training_guard.reset_accumulators()?;
```
**check_gradients_finite** (replaces site #2):
Add `#[cfg(feature = "cuda")]` bypass: when CUDA is active, the training guard kernel already checks NaN/Inf on the loss and grad_norm scalars. The per-parameter NaN check in `check_gradients_finite` is redundant with the post-clip norm check — if any gradient was NaN, the norm would be NaN, which the guard catches.
---
## Component B: GPU Q-Value Monitor
Extends `training_guard_kernel.cu` with a Q-value accumulation kernel.
### CUDA Kernel: `qvalue_accumulate`
**Inputs:**
- `q_values: *const f32` — [batch × actions] Q-value matrix
- `batch_size: i32, num_actions: i32`
- `qvalue_sum: *mut f32` — device accumulator (running sum of max-Q per sample)
- `qvalue_count: *mut i32` — device counter
**Operation:** Each thread handles one sample. Finds max Q across actions, atomicAdd to qvalue_sum.
### CUDA Kernel: `qvalue_divergence_check`
**Inputs:**
- `q_values: *const f32` — [1, actions] single-sample Q-values
- `num_actions: i32`
- `divergence_threshold: f32` — 10000.0
**Output (pinned, 4 × f32):**
- `[0]`: q_min
- `[1]`: q_max
- `[2]`: q_mean
- `[3]`: divergence_detected (1.0 if |q_min| or |q_max| > threshold)
### Wiring
**estimate_avg_q_value_with_early_stopping** (replaces site #5):
- Forward pass stays as-is (Candle tensor)
- Replace `max(1) → mean_all() → to_scalar()` with `qvalue_accumulate` kernel call
- Read accumulator at epoch boundary, not per 50-step check
**log_q_values** (replaces site #6):
- Replace `to_vec2::<f32>()` with `qvalue_divergence_check` kernel
- Pinned readback: 4 floats instead of entire Q-matrix
- Prometheus gauges set from pinned values
---
## Component C: GPU-Resident Action Selection
No new kernels needed — `select_actions()`, `select_actions_routed()`, and `select_actions_branching()` already return GPU-resident `Tensor`. The readback happens in the **caller** (trainer.rs) which does `to_vec1::<u32>()` to iterate actions on CPU.
### Solution: Keep actions on GPU, defer routing to experience storage
**select_actions_batch_gpu** (replaces sites #7, #8):
Currently at trainer.rs:4302:
```rust
let action_indices = action_indices_tensor.to_vec1::<u32>()?; // GPU sync!
for &idx in &action_indices { ... route_action(exposure, spread) ... }
```
Replace with:
1. Keep `action_indices_tensor` on GPU
2. When `use_routed` is true: actions are already factored indices (0-44), store tensor directly
3. When `use_routed` is false AND `use_branching` is false: actions are exposure indices (0-4). Port `route_action()` to a batch CUDA kernel that converts exposure → factored index on-device.
4. When `use_branching` is true: actions are already factored (0-44) from branching kernel
**New CUDA kernel: `batch_route_exposure_to_factored`**
Add to `epsilon_greedy_kernel.cu`:
```cuda
extern "C" __global__ void batch_route_exposure_to_factored(
const unsigned int* exposure_actions, // [N] exposure indices 0-4
unsigned int* factored_actions, // [N] output factored indices 0-44
float spread, float median_spread,
float volatility, float median_volatility,
int N
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= N) return;
// Port of OrderRouter::route()
int order_type;
if (median_spread <= 0.0f) order_type = 0; // Market
else if (spread < median_spread) order_type = 1; // LimitMaker
else if (spread > 2.0f * median_spread) order_type = 0; // Market
else order_type = 2; // IoC
int urgency;
if (median_volatility <= 0.0f) urgency = 1; // Normal
else if (volatility > 1.5f * median_volatility) urgency = 2; // Aggressive
else if (volatility < 0.5f * median_volatility) urgency = 0; // Patient
else urgency = 1; // Normal
factored_actions[i] = exposure_actions[i] * 9 + order_type * 3 + urgency;
}
```
**Downstream consumer change:** The experience loop at trainer.rs:2184+ currently uses `Vec<FactoredAction>` from `select_actions_batch_gpu()`. With GPU-resident actions, we need to either:
- (a) Change the experience loop to accept GPU tensor indices + batch DtoH at the end, or
- (b) Keep `actions` as a `Vec<FactoredAction>` but derive it from a single batch readback at the loop boundary instead of per-batch
**Chosen approach:** (b) — Single batch readback. The experience loop needs CPU-side `FactoredAction` values for reward calculation (`calculate_reward_for_action`) and portfolio tracking. These are unavoidable CPU operations. The readback happens ONCE per experience collection (not per training step), so it's outside the critical train_step hot path. The key win is eliminating the per-step readbacks in the training loop (#1-#6).
---
## Component D: Experience Collector Async Readback
**gpu_experience_collector.rs:769-772**`memcpy_dtoh` for rewards_cpu/actions_cpu.
These are ~0.5MB downloads needed for `pnl_history` (Sharpe ratio) and `monitor.track_reward/track_action`. They happen once per experience collection (every epoch), not per training step.
### Solution: Async stream + deferred poll
1. Launch `memcpy_dtoh_async` on a separate CUDA stream
2. Return `GpuExperienceBatch` immediately (with empty rewards_cpu/actions_cpu)
3. Add `poll_monitoring_data(&mut self) -> Option<(Vec<f32>, Vec<i32>)>` that checks stream completion
4. Trainer calls `poll_monitoring_data()` at epoch boundary, updates pnl_history lazily
**Alternative (simpler):** Keep synchronous but move to AFTER `insert_batch_tensors()`. The DtoD copies for replay buffer insertion don't depend on the monitoring downloads. This reorders operations so the GPU pipeline isn't stalled during the insert.
**Chosen approach:** Reorder — simpler, same effect. The `stream.synchronize()` at line 777 already waits for DtoD copies. Move the monitoring downloads AFTER the sync, or use a separate stream for monitoring downloads that doesn't block the main pipeline.
---
## Success Criteria
1. `grep -rn 'to_scalar\|to_vec0\|to_vec1\|to_vec2' trainer.rs` → 0 matches in `#[cfg(feature = "cuda")]` blocks of `train_step_optimized` and `train_step_with_accumulation`
2. `check_gradients_finite` bypassed under `#[cfg(feature = "cuda")]` (guard catches NaN)
3. `log_q_values` uses GPU reduction instead of `to_vec2`
4. All existing tests pass: `SQLX_OFFLINE=true cargo test -p ml -p ml-dqn -p ml-core --lib`
5. 0 clippy warnings in modified files
6. Training semantics unchanged (loss values, action distributions identical)
## Non-Goals
- CPU-only fallback paths (`#[cfg(not(feature = "cuda"))]`) are unchanged
- CPU argmax fallback in `select_actions_batch_gpu` (line 4334) is for when GPU selector fails to init — not a hot path
- `select_actions_batch` (the older, non-GPU-tensor path at line 4090+) is the CPU experience fallback — readback there is unavoidable since it feeds CPU route_action loop
- Experience collector monitoring downloads are low-frequency (once per epoch) and can tolerate ~1ms latency