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>
12 KiB
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.cucrates/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 stepsgrad_norm_sum: CudaSlice<f32>— running sum of grad normsstep_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
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 bufferscheck(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 sumsread_accumulators() -> Result<(f64, f64)>— single DtoH of mean loss + mean grad_norm (epoch boundary only)reset_accumulators() -> Result<()>— zeros device buffers
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 matrixbatch_size: i32, num_actions: i32qvalue_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-valuesnum_actions: i32divergence_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()withqvalue_accumulatekernel call - Read accumulator at epoch boundary, not per 50-step check
log_q_values (replaces site #6):
- Replace
to_vec2::<f32>()withqvalue_divergence_checkkernel - 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:
let action_indices = action_indices_tensor.to_vec1::<u32>()?; // GPU sync!
for &idx in &action_indices { ... route_action(exposure, spread) ... }
Replace with:
- Keep
action_indices_tensoron GPU - When
use_routedis true: actions are already factored indices (0-44), store tensor directly - When
use_routedis false ANDuse_branchingis false: actions are exposure indices (0-4). Portroute_action()to a batch CUDA kernel that converts exposure → factored index on-device. - When
use_branchingis true: actions are already factored (0-44) from branching kernel
New CUDA kernel: batch_route_exposure_to_factored
Add to epsilon_greedy_kernel.cu:
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
actionsas aVec<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
- Launch
memcpy_dtoh_asyncon a separate CUDA stream - Return
GpuExperienceBatchimmediately (with empty rewards_cpu/actions_cpu) - Add
poll_monitoring_data(&mut self) -> Option<(Vec<f32>, Vec<i32>)>that checks stream completion - 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
grep -rn 'to_scalar\|to_vec0\|to_vec1\|to_vec2' trainer.rs→ 0 matches in#[cfg(feature = "cuda")]blocks oftrain_step_optimizedandtrain_step_with_accumulationcheck_gradients_finitebypassed under#[cfg(feature = "cuda")](guard catches NaN)log_q_valuesuses GPU reduction instead ofto_vec2- All existing tests pass:
SQLX_OFFLINE=true cargo test -p ml -p ml-dqn -p ml-core --lib - 0 clippy warnings in modified files
- 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