Files
foxhunt/docs/superpowers/plans/2026-03-10-zero-cpu-hotpath.md
jgrusewski 7b412d328b docs: add zero-CPU DQN training hot path implementation plan
11-task plan across 4 chunks: Training Guard kernel + wrapper (Tasks 1-3),
wire into trainer (Tasks 4-6), Q-value monitor + action routing (Tasks 7-9),
experience collector audit + final verification (Tasks 10-11).

Eliminates all 10 GPU→CPU sync barriers from the DQN training hot path.

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

72 KiB
Raw Blame History

Zero-CPU DQN Training Hot Path — 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: Eliminate all GPU→CPU synchronization barriers from the DQN training hot path so the GPU pipeline never stalls between backward() and optimizer.step().

Architecture: Four GPU components replace 10 CPU readback sites: a Training Guard kernel (pinned-memory predicates for NaN/loss-clip/grad-collapse + on-device accumulators), a Q-value monitor (GPU reduction for divergence check + average Q), GPU-resident action routing (keeps action indices on GPU through the experience loop), and async experience collector monitoring readback. All monitoring/safety logic runs as CUDA kernels writing to pinned host memory — zero cudaStreamSynchronize barriers per training step.

Tech Stack: CUDA (NVRTC), cudarc 0.17, Candle v0.9.1

Spec: docs/superpowers/specs/2026-03-10-zero-cpu-hotpath-design.md

Build command: SQLX_OFFLINE=true cargo check --workspace Test command: SQLX_OFFLINE=true cargo test -p ml -p ml-dqn -p ml-core --lib Clippy lint rules: #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)] — use .get(), ?, .ok_or() instead. cudarc 0.17 launch pattern: unsafe { stream.launch_builder(&func).arg(&val).launch(config) } Candle F32 trap: Tensor * 0.5 fails (Rust defaults f64). Use tensor.broadcast_mul(&Tensor::new(0.5_f32, device)?)


File Structure

File Action Responsibility
crates/ml/src/cuda_pipeline/training_guard_kernel.cu Create CUDA kernels: training_guard_check, training_guard_accumulate, qvalue_stats_reduce, qvalue_divergence_check
crates/ml/src/cuda_pipeline/gpu_training_guard.rs Create Rust wrapper: pinned host memory, device accumulators, guard + Q-value methods
crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu Modify Add batch_route_exposure_to_factored kernel
crates/ml/src/cuda_pipeline/gpu_action_selector.rs Modify Add route_func: CudaFunction, route_exposure_to_factored() method
crates/ml/src/cuda_pipeline/gpu_experience_collector.rs Modify Reorder monitoring download after DtoD insert
crates/ml/src/cuda_pipeline/mod.rs Modify Export gpu_training_guard module
crates/ml/src/trainers/dqn/trainer.rs Modify Wire guard into train_step_optimized/accumulated, eliminate all to_scalar/to_vec
crates/ml-core/src/gradient_utils.rs Modify Add #[cfg(feature = "cuda")] bypass in check_gradients_finite
crates/ml-dqn/src/dqn.rs Modify GPU-resident Q-value stats in log_q_values
crates/ml-dqn/src/training_guard_gpu_tests.rs Create Parity tests for guard + accumulator kernels
crates/ml-dqn/src/qvalue_monitor_gpu_tests.rs Create Parity tests for Q-value reduction kernel

Chunk 1: Training Guard Kernel + Wrapper

Task 1: Create training_guard_kernel.cu with guard + accumulate kernels

Files:

  • Create: crates/ml/src/cuda_pipeline/training_guard_kernel.cu

Context: This kernel replaces the per-step to_vec1::<f32>() readback at trainer.rs:4531. Instead of downloading loss+grad_norm to CPU for NaN/clip/collapse checks, the kernel runs these checks on-device and writes boolean halt flags to pinned host memory. A separate accumulate entry point adds loss/grad to running sums for epoch-boundary readback.

  • Step 1: Write the CUDA kernel file

Create crates/ml/src/cuda_pipeline/training_guard_kernel.cu:

/**
 * Training guard: on-device predicate checks + accumulation.
 *
 * Replaces per-step GPU→CPU sync barriers with:
 * 1. training_guard_check: NaN/Inf detection, loss clipping, gradient
 *    collapse — writes halt flags to pinned host memory (zero sync).
 * 2. training_guard_accumulate: adds loss + grad_norm to device-resident
 *    running sums — single readback at epoch boundary.
 * 3. qvalue_stats_reduce: parallel reduction of [batch × actions] Q-values
 *    to (min, max, mean, max_q_avg) — replaces to_vec2 readback.
 * 4. qvalue_divergence_check: single-sample Q-value divergence detection.
 *
 * Pinned output layout (7 × f32 = 28 bytes):
 *   [0] halt_nan         (1.0 if loss or grad is NaN/Inf)
 *   [1] halt_loss_clip   (1.0 if loss > clip_threshold)
 *   [2] halt_grad_collapse (1.0 if grad < collapse_threshold && !warmup)
 *   [3] clipped_loss     (min(loss, clip_threshold))
 *   [4] raw_loss         (for logging)
 *   [5] raw_grad_norm    (for logging)
 *   [6] reserved
 */

extern "C" __global__ void training_guard_check(
    const float* __restrict__ loss_ptr,       /* scalar, device */
    const float* __restrict__ grad_norm_ptr,   /* scalar, device */
    float* __restrict__ pinned_output,         /* 7 floats, pinned host */
    float loss_clip_threshold,                 /* 1e6 */
    float grad_collapse_threshold,             /* LR * multiplier */
    int warmup_active                          /* 1 = skip collapse check */
) {
    float loss = loss_ptr[0];
    float grad = grad_norm_ptr[0];

    /* NaN / Inf check */
    int is_nan = (loss != loss) || (grad != grad);  /* NaN != NaN */
    int is_inf = (loss > 1e30f) || (loss < -1e30f)
              || (grad > 1e30f) || (grad < -1e30f);
    pinned_output[0] = (is_nan || is_inf) ? 1.0f : 0.0f;

    /* Loss clip check */
    pinned_output[1] = (loss > loss_clip_threshold) ? 1.0f : 0.0f;

    /* Gradient collapse check */
    int collapse = (!warmup_active) && (grad < grad_collapse_threshold);
    pinned_output[2] = collapse ? 1.0f : 0.0f;

    /* Clipped loss value */
    pinned_output[3] = (loss > loss_clip_threshold) ? loss_clip_threshold : loss;

    /* Raw values for logging */
    pinned_output[4] = loss;
    pinned_output[5] = grad;
    pinned_output[6] = 0.0f;
}

/**
 * Accumulate loss and grad_norm into device-resident running sums.
 * Single-thread kernel — called once per train step.
 */
extern "C" __global__ void training_guard_accumulate(
    const float* __restrict__ loss_ptr,
    const float* __restrict__ grad_norm_ptr,
    float* __restrict__ loss_sum,      /* device, persistent */
    float* __restrict__ grad_norm_sum, /* device, persistent */
    int*   __restrict__ step_count     /* device, persistent */
) {
    loss_sum[0] += loss_ptr[0];
    grad_norm_sum[0] += grad_norm_ptr[0];
    step_count[0] += 1;
}

/**
 * Parallel reduction of Q-values: compute min, max, mean across batch.
 *
 * Input: q_values[batch_size * num_actions] — flattened Q-value matrix.
 * For each sample i, finds max_q = max(q_values[i*A .. i*A+A-1]).
 * Then reduces max_q across all samples to get mean, min, max.
 *
 * Output (4 floats, pinned):
 *   [0] q_min   (min of max-Q across samples)
 *   [1] q_max   (max of max-Q across samples)
 *   [2] q_mean  (mean of max-Q across samples)
 *   [3] mean_of_all  (mean of ALL Q-values, for collapse detection)
 *
 * Launch: grid=(1,1,1), block=(256,1,1)
 */
extern "C" __global__ void qvalue_stats_reduce(
    const float* __restrict__ q_values,
    float* __restrict__ pinned_output,
    int batch_size,
    int num_actions
) {
    __shared__ float s_min[256];
    __shared__ float s_max[256];
    __shared__ float s_sum[256];
    __shared__ float s_all_sum[256];

    int tid = threadIdx.x;

    float local_min =  1e30f;
    float local_max = -1e30f;
    float local_sum = 0.0f;
    float local_all = 0.0f;

    /* Grid-stride loop over samples */
    for (int i = tid; i < batch_size; i += blockDim.x) {
        float max_q = -1e30f;
        int base = i * num_actions;
        for (int a = 0; a < num_actions; a++) {
            float val = q_values[base + a];
            if (val > max_q) max_q = val;
            local_all += val;
        }
        if (max_q < local_min) local_min = max_q;
        if (max_q > local_max) local_max = max_q;
        local_sum += max_q;
    }

    s_min[tid] = local_min;
    s_max[tid] = local_max;
    s_sum[tid] = local_sum;
    s_all_sum[tid] = local_all;
    __syncthreads();

    /* Parallel reduction */
    for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
        if (tid < stride) {
            if (s_min[tid + stride] < s_min[tid]) s_min[tid] = s_min[tid + stride];
            if (s_max[tid + stride] > s_max[tid]) s_max[tid] = s_max[tid + stride];
            s_sum[tid] += s_sum[tid + stride];
            s_all_sum[tid] += s_all_sum[tid + stride];
        }
        __syncthreads();
    }

    if (tid == 0) {
        pinned_output[0] = s_min[0];
        pinned_output[1] = s_max[0];
        pinned_output[2] = (batch_size > 0) ? s_sum[0] / (float)batch_size : 0.0f;
        int total = batch_size * num_actions;
        pinned_output[3] = (total > 0) ? s_all_sum[0] / (float)total : 0.0f;
    }
}

/**
 * Q-value divergence check for early stopping.
 * Single-sample: checks if |q_min| or |q_max| exceeds threshold.
 *
 * Output (5 floats, pinned):
 *   [0] q_min
 *   [1] q_max
 *   [2] q_mean
 *   [3] q_variance
 *   [4] divergence_detected (1.0 if above threshold)
 */
extern "C" __global__ void qvalue_divergence_check(
    const float* __restrict__ q_values,
    float* __restrict__ pinned_output,
    int num_actions,
    float divergence_threshold
) {
    float q_min =  1e30f;
    float q_max = -1e30f;
    float q_sum = 0.0f;

    for (int a = 0; a < num_actions; a++) {
        float v = q_values[a];
        if (v < q_min) q_min = v;
        if (v > q_max) q_max = v;
        q_sum += v;
    }

    float q_mean = (num_actions > 0) ? q_sum / (float)num_actions : 0.0f;

    /* Variance */
    float var_sum = 0.0f;
    for (int a = 0; a < num_actions; a++) {
        float diff = q_values[a] - q_mean;
        var_sum += diff * diff;
    }
    float q_var = (num_actions > 0) ? var_sum / (float)num_actions : 0.0f;

    pinned_output[0] = q_min;
    pinned_output[1] = q_max;
    pinned_output[2] = q_mean;
    pinned_output[3] = q_var;
    pinned_output[4] = (fabsf(q_min) > divergence_threshold
                     || fabsf(q_max) > divergence_threshold) ? 1.0f : 0.0f;
}
  • Step 2: Verify NVRTC compilation

Run: SQLX_OFFLINE=true cargo check --workspace Expected: PASS (the .cu file is only compiled at runtime via NVRTC — but any syntax errors in include_str! integration will surface in the Rust wrapper task)

  • Step 3: Commit
git add crates/ml/src/cuda_pipeline/training_guard_kernel.cu
git commit -m "feat(cuda): add training guard + Q-value monitor CUDA kernels"

Task 2: Create gpu_training_guard.rs Rust wrapper

Files:

  • Create: crates/ml/src/cuda_pipeline/gpu_training_guard.rs
  • Modify: crates/ml/src/cuda_pipeline/mod.rs (add pub mod gpu_training_guard; export)

Context: This wraps the 4 CUDA kernels from Task 1. Uses pinned host memory (cudarc::driver::CudaHostBuf) for guard output so the CPU can read results without calling cudaStreamSynchronize. Device-resident accumulators persist across training steps, read back once per epoch.

Reference patterns:

  • gpu_statistics.rs — same OnceLock PTX cache, CudaFunction pattern

  • gpu_action_selector.rs — same unsafe launch_builder pattern

  • Step 1: Write the Rust wrapper

Create crates/ml/src/cuda_pipeline/gpu_training_guard.rs:

#![allow(unsafe_code)]

//! GPU training guard: on-device safety checks + loss/grad accumulators.
//!
//! Replaces per-step `to_vec1::<f32>()` / `to_scalar::<f32>()` readbacks
//! with CUDA kernel launches that write boolean halt flags to pinned host
//! memory. Zero `cudaStreamSynchronize` barriers per training step.
//!
//! ## Pinned memory layout (guard_check output, 7 × f32)
//!
//! | Index | Field | Description |
//! |-------|-------|-------------|
//! | 0 | halt_nan | 1.0 if loss or grad_norm is NaN/Inf |
//! | 1 | halt_loss_clip | 1.0 if loss > clip_threshold |
//! | 2 | halt_grad_collapse | 1.0 if grad_norm < collapse_threshold |
//! | 3 | clipped_loss | min(loss, clip_threshold) |
//! | 4 | raw_loss | unclipped loss value |
//! | 5 | raw_grad_norm | gradient norm value |
//! | 6 | reserved | padding |

use candle_core::cuda_backend::cudarc;
use candle_core::{DType, Device, Tensor};
use cudarc::driver::{CudaFunction, CudaSlice, DevicePtr, LaunchConfig, PushKernelArg};
use cudarc::nvrtc::Ptx;
use std::sync::OnceLock;
use tracing::info;

use crate::MLError;

static TRAINING_GUARD_PTX: OnceLock<Result<Ptx, String>> = OnceLock::new();

fn compile_guard_ptx() -> Result<Ptx, String> {
    let kernel_src = include_str!("training_guard_kernel.cu");
    cudarc::nvrtc::compile_ptx(kernel_src)
        .map_err(|e| format!("training_guard CUDA kernel compilation failed: {e}"))
}

/// Result from a training guard check (read from pinned host memory).
#[derive(Debug, Clone)]
pub struct GuardResult {
    /// True if loss or grad_norm contains NaN or Inf.
    pub halt_nan: bool,
    /// True if loss exceeds clip threshold (1e6).
    pub halt_loss_clip: bool,
    /// True if grad_norm below collapse threshold (past warmup).
    pub halt_grad_collapse: bool,
    /// Loss value clamped to clip threshold.
    pub clipped_loss: f32,
    /// Raw (unclipped) loss value.
    pub raw_loss: f32,
    /// Raw gradient norm value.
    pub raw_grad_norm: f32,
}

/// Q-value statistics from GPU reduction (read from pinned host memory).
#[derive(Debug, Clone)]
pub struct QValueStats {
    pub q_min: f32,
    pub q_max: f32,
    pub q_mean: f32,
    pub q_all_mean: f32,
}

/// Q-value divergence check result.
#[derive(Debug, Clone)]
pub struct QValueDivergence {
    pub q_min: f32,
    pub q_max: f32,
    pub q_mean: f32,
    pub q_variance: f32,
    pub divergence_detected: bool,
}

/// GPU training guard — zero-sync safety checks + loss/grad accumulators.
pub struct GpuTrainingGuard {
    check_func: CudaFunction,
    accumulate_func: CudaFunction,
    qvalue_stats_func: CudaFunction,
    qvalue_div_func: CudaFunction,

    /// Pinned host memory for guard check output (7 × f32).
    pinned_guard: Vec<f32>,
    /// Device buffer that the kernel writes to (then DtoH to pinned).
    guard_output_dev: CudaSlice<f32>,

    /// Pinned host memory for Q-value stats (4 × f32).
    pinned_qstats: Vec<f32>,
    qstats_output_dev: CudaSlice<f32>,

    /// Pinned host memory for Q-value divergence (5 × f32).
    pinned_qdiv: Vec<f32>,
    qdiv_output_dev: CudaSlice<f32>,

    /// Device-resident accumulators.
    loss_sum: CudaSlice<f32>,
    grad_norm_sum: CudaSlice<f32>,
    step_count: CudaSlice<i32>,

    device: Device,
}

impl GpuTrainingGuard {
    /// Create a new GPU training guard.
    ///
    /// Compiles CUDA kernels (cached per-process), allocates pinned host
    /// memory for guard output and device memory for accumulators.
    pub fn new(device: &Device) -> Result<Self, MLError> {
        let cuda_dev = match device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("GpuTrainingGuard requires CUDA".into())),
        };

        let ptx_result = TRAINING_GUARD_PTX.get_or_init(compile_guard_ptx);
        let ptx = ptx_result.as_ref().map_err(|e| {
            MLError::ModelError(format!("training_guard PTX: {e}"))
        })?;

        let stream = cuda_dev.cuda_stream();
        let context = stream.context();
        let module = context.load_module(ptx.clone()).map_err(|e| {
            MLError::ModelError(format!("training_guard module load: {e}"))
        })?;

        let check_func = module.load_function("training_guard_check").map_err(|e| {
            MLError::ModelError(format!("training_guard_check load: {e}"))
        })?;
        let accumulate_func = module.load_function("training_guard_accumulate").map_err(|e| {
            MLError::ModelError(format!("training_guard_accumulate load: {e}"))
        })?;
        let qvalue_stats_func = module.load_function("qvalue_stats_reduce").map_err(|e| {
            MLError::ModelError(format!("qvalue_stats_reduce load: {e}"))
        })?;
        let qvalue_div_func = module.load_function("qvalue_divergence_check").map_err(|e| {
            MLError::ModelError(format!("qvalue_divergence_check load: {e}"))
        })?;

        // Device buffers for kernel outputs (DtoH'd to pinned host)
        let guard_output_dev = stream.alloc_zeros::<f32>(7).map_err(|e| {
            MLError::ModelError(format!("alloc guard output: {e}"))
        })?;
        let qstats_output_dev = stream.alloc_zeros::<f32>(4).map_err(|e| {
            MLError::ModelError(format!("alloc qstats output: {e}"))
        })?;
        let qdiv_output_dev = stream.alloc_zeros::<f32>(5).map_err(|e| {
            MLError::ModelError(format!("alloc qdiv output: {e}"))
        })?;

        // Device-resident accumulators
        let loss_sum = stream.alloc_zeros::<f32>(1).map_err(|e| {
            MLError::ModelError(format!("alloc loss_sum: {e}"))
        })?;
        let grad_norm_sum = stream.alloc_zeros::<f32>(1).map_err(|e| {
            MLError::ModelError(format!("alloc grad_norm_sum: {e}"))
        })?;
        let step_count = stream.alloc_zeros::<i32>(1).map_err(|e| {
            MLError::ModelError(format!("alloc step_count: {e}"))
        })?;

        info!("GpuTrainingGuard initialized: guard + accumulator + Q-value kernels");

        Ok(Self {
            check_func,
            accumulate_func,
            qvalue_stats_func,
            qvalue_div_func,
            pinned_guard: vec![0.0_f32; 7],
            guard_output_dev,
            pinned_qstats: vec![0.0_f32; 4],
            qstats_output_dev,
            pinned_qdiv: vec![0.0_f32; 5],
            qdiv_output_dev,
            loss_sum,
            grad_norm_sum,
            step_count,
            device: device.clone(),
        })
    }

    /// Extract raw CUDA pointer from a Candle scalar tensor.
    fn tensor_device_ptr(tensor: &Tensor) -> Result<u64, MLError> {
        let (storage_guard, layout) = tensor.storage_and_layout();
        match &*storage_guard {
            candle_core::Storage::Cuda(ref cs) => {
                let slice = cs.as_cuda_slice::<f32>().map_err(|e| {
                    MLError::ModelError(format!("as_cuda_slice: {e}"))
                })?;
                // For scalar tensors, start_offset is 0
                let view = slice.slice(layout.start_offset()..);
                // We need the raw device pointer as u64 for kernel arg passing
                // This is safe because the storage_guard keeps the allocation alive
                Ok(view.device_ptr_unchecked().0)
            }
            _ => Err(MLError::ModelError("tensor not on CUDA".into())),
        }
    }

    /// Run training guard check on GPU-resident loss and grad_norm scalars.
    ///
    /// Launches the `training_guard_check` kernel which writes halt flags
    /// to device memory, then DtoH copies to host. The DtoH is async
    /// but tiny (28 bytes) so effectively instant.
    ///
    /// Also launches `training_guard_accumulate` to add to running sums.
    pub fn check_and_accumulate(
        &mut self,
        loss_gpu: &Tensor,
        grad_norm_gpu: &Tensor,
        loss_clip_threshold: f32,
        grad_collapse_threshold: f32,
        warmup_active: bool,
    ) -> Result<GuardResult, MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        // Get device pointers from Candle tensors
        let (loss_guard, loss_layout) = loss_gpu.storage_and_layout();
        let loss_slice = match &*loss_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<f32>().map_err(|e| {
                MLError::ModelError(format!("loss as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("loss not on CUDA".into())),
        };
        let loss_view = loss_slice.slice(loss_layout.start_offset()..);

        let (grad_guard, grad_layout) = grad_norm_gpu.storage_and_layout();
        let grad_slice = match &*grad_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<f32>().map_err(|e| {
                MLError::ModelError(format!("grad as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("grad not on CUDA".into())),
        };
        let grad_view = grad_slice.slice(grad_layout.start_offset()..);

        let warmup_i32: i32 = if warmup_active { 1 } else { 0 };
        let config_1thread = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };

        // Launch guard check kernel
        unsafe {
            stream
                .launch_builder(&self.check_func)
                .arg(&loss_view)
                .arg(&grad_view)
                .arg(&mut self.guard_output_dev)
                .arg(&loss_clip_threshold)
                .arg(&grad_collapse_threshold)
                .arg(&warmup_i32)
                .launch(config_1thread)
                .map_err(|e| MLError::ModelError(format!("guard check launch: {e}")))?;
        }

        // Launch accumulate kernel (same stream, serialized after check)
        unsafe {
            stream
                .launch_builder(&self.accumulate_func)
                .arg(&loss_view)
                .arg(&grad_view)
                .arg(&mut self.loss_sum)
                .arg(&mut self.grad_norm_sum)
                .arg(&mut self.step_count)
                .launch(config_1thread)
                .map_err(|e| MLError::ModelError(format!("guard accumulate launch: {e}")))?;
        }

        // Drop storage guards before DtoH
        drop(loss_guard);
        drop(grad_guard);

        // DtoH readback of guard output (28 bytes — effectively instant)
        stream
            .memcpy_dtoh(&self.guard_output_dev, &mut self.pinned_guard)
            .map_err(|e| MLError::ModelError(format!("guard DtoH: {e}")))?;

        Ok(GuardResult {
            halt_nan: self.pinned_guard.first().copied().unwrap_or(0.0) > 0.5,
            halt_loss_clip: self.pinned_guard.get(1).copied().unwrap_or(0.0) > 0.5,
            halt_grad_collapse: self.pinned_guard.get(2).copied().unwrap_or(0.0) > 0.5,
            clipped_loss: self.pinned_guard.get(3).copied().unwrap_or(0.0),
            raw_loss: self.pinned_guard.get(4).copied().unwrap_or(0.0),
            raw_grad_norm: self.pinned_guard.get(5).copied().unwrap_or(0.0),
        })
    }

    /// Read accumulated loss and grad_norm averages (epoch boundary).
    ///
    /// Single DtoH of 2 floats + 1 int. Returns (mean_loss, mean_grad_norm).
    pub fn read_accumulators(&mut self) -> Result<(f64, f64), MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        let mut loss_buf = [0.0_f32];
        let mut grad_buf = [0.0_f32];
        let mut count_buf = [0_i32];

        stream
            .memcpy_dtoh(&self.loss_sum, &mut loss_buf)
            .map_err(|e| MLError::ModelError(format!("loss_sum DtoH: {e}")))?;
        stream
            .memcpy_dtoh(&self.grad_norm_sum, &mut grad_buf)
            .map_err(|e| MLError::ModelError(format!("grad_sum DtoH: {e}")))?;
        stream
            .memcpy_dtoh(&self.step_count, &mut count_buf)
            .map_err(|e| MLError::ModelError(format!("step_count DtoH: {e}")))?;

        let n = count_buf[0].max(1) as f64;
        Ok((loss_buf[0] as f64 / n, grad_buf[0] as f64 / n))
    }

    /// Reset accumulators to zero for next epoch.
    pub fn reset_accumulators(&mut self) -> Result<(), MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        stream
            .memcpy_htod(&[0.0_f32], &mut self.loss_sum)
            .map_err(|e| MLError::ModelError(format!("reset loss_sum: {e}")))?;
        stream
            .memcpy_htod(&[0.0_f32], &mut self.grad_norm_sum)
            .map_err(|e| MLError::ModelError(format!("reset grad_sum: {e}")))?;
        stream
            .memcpy_htod(&[0_i32], &mut self.step_count)
            .map_err(|e| MLError::ModelError(format!("reset step_count: {e}")))?;

        Ok(())
    }

    /// Compute Q-value statistics via GPU parallel reduction.
    ///
    /// Returns (q_min, q_max, q_mean_of_maxQ, q_mean_of_all).
    /// Replaces `to_vec2::<f32>()` readback + CPU loop.
    pub fn qvalue_stats(
        &mut self,
        q_values: &Tensor,
        batch_size: usize,
        num_actions: usize,
    ) -> Result<QValueStats, MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        let q_f32 = if q_values.dtype() == DType::F32 {
            q_values.clone()
        } else {
            q_values.to_dtype(DType::F32).map_err(|e| {
                MLError::ModelError(format!("q_values F32 cast: {e}"))
            })?
        };
        let q_cont = q_f32.contiguous().map_err(|e| {
            MLError::ModelError(format!("q_values contiguous: {e}"))
        })?;

        let (q_guard, q_layout) = q_cont.storage_and_layout();
        let q_slice = match &*q_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<f32>().map_err(|e| {
                MLError::ModelError(format!("q as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("q not on CUDA".into())),
        };
        let q_view = q_slice.slice(q_layout.start_offset()..);

        let bs_i32 = batch_size as i32;
        let na_i32 = num_actions as i32;
        let config = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (256, 1, 1),
            shared_mem_bytes: 0,
        };

        unsafe {
            stream
                .launch_builder(&self.qvalue_stats_func)
                .arg(&q_view)
                .arg(&mut self.qstats_output_dev)
                .arg(&bs_i32)
                .arg(&na_i32)
                .launch(config)
                .map_err(|e| MLError::ModelError(format!("qvalue_stats launch: {e}")))?;
        }

        drop(q_guard);

        stream
            .memcpy_dtoh(&self.qstats_output_dev, &mut self.pinned_qstats)
            .map_err(|e| MLError::ModelError(format!("qstats DtoH: {e}")))?;

        Ok(QValueStats {
            q_min: self.pinned_qstats.first().copied().unwrap_or(0.0),
            q_max: self.pinned_qstats.get(1).copied().unwrap_or(0.0),
            q_mean: self.pinned_qstats.get(2).copied().unwrap_or(0.0),
            q_all_mean: self.pinned_qstats.get(3).copied().unwrap_or(0.0),
        })
    }

    /// Check single-sample Q-values for divergence.
    ///
    /// Returns min/max/mean/variance + divergence flag.
    /// Replaces `to_vec2::<f32>()` + CPU statistics in `log_q_values()`.
    pub fn qvalue_divergence(
        &mut self,
        q_values: &Tensor,
        num_actions: usize,
        threshold: f32,
    ) -> Result<QValueDivergence, MLError> {
        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        let q_f32 = if q_values.dtype() == DType::F32 {
            q_values.clone()
        } else {
            q_values.to_dtype(DType::F32).map_err(|e| {
                MLError::ModelError(format!("q F32 cast: {e}"))
            })?
        };
        let q_cont = q_f32.contiguous().map_err(|e| {
            MLError::ModelError(format!("q contiguous: {e}"))
        })?;

        let (q_guard, q_layout) = q_cont.storage_and_layout();
        let q_slice = match &*q_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<f32>().map_err(|e| {
                MLError::ModelError(format!("q as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("q not on CUDA".into())),
        };
        let q_view = q_slice.slice(q_layout.start_offset()..);

        let na_i32 = num_actions as i32;
        let config_1thread = LaunchConfig {
            grid_dim: (1, 1, 1),
            block_dim: (1, 1, 1),
            shared_mem_bytes: 0,
        };

        unsafe {
            stream
                .launch_builder(&self.qvalue_div_func)
                .arg(&q_view)
                .arg(&mut self.qdiv_output_dev)
                .arg(&na_i32)
                .arg(&threshold)
                .launch(config_1thread)
                .map_err(|e| MLError::ModelError(format!("qvalue_div launch: {e}")))?;
        }

        drop(q_guard);

        stream
            .memcpy_dtoh(&self.qdiv_output_dev, &mut self.pinned_qdiv)
            .map_err(|e| MLError::ModelError(format!("qdiv DtoH: {e}")))?;

        Ok(QValueDivergence {
            q_min: self.pinned_qdiv.first().copied().unwrap_or(0.0),
            q_max: self.pinned_qdiv.get(1).copied().unwrap_or(0.0),
            q_mean: self.pinned_qdiv.get(2).copied().unwrap_or(0.0),
            q_variance: self.pinned_qdiv.get(3).copied().unwrap_or(0.0),
            divergence_detected: self.pinned_qdiv.get(4).copied().unwrap_or(0.0) > 0.5,
        })
    }
}

impl std::fmt::Debug for GpuTrainingGuard {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GpuTrainingGuard")
            .field("device", &self.device)
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_ptx_compilation() {
        let result = compile_guard_ptx();
        if let Err(ref e) = result {
            if e.contains("NVRTC") || e.contains("nvrtc") || e.contains("not found") {
                return; // NVRTC not installed
            }
            panic!("PTX compilation failed: {e}");
        }
    }

    #[test]
    fn test_cpu_device_rejected() {
        let result = GpuTrainingGuard::new(&Device::Cpu);
        assert!(result.is_err());
    }
}
  • Step 2: Add module export

Add to crates/ml/src/cuda_pipeline/mod.rs after the gpu_statistics line:

#[cfg(feature = "cuda")]
pub mod gpu_training_guard;
  • Step 3: Verify compilation

Run: SQLX_OFFLINE=true cargo check -p ml --lib Expected: PASS

  • Step 4: Commit
git add crates/ml/src/cuda_pipeline/gpu_training_guard.rs crates/ml/src/cuda_pipeline/mod.rs
git commit -m "feat(cuda): add GpuTrainingGuard wrapper with pinned memory + accumulators"

Task 3: Add training guard parity tests

Files:

  • Create: crates/ml-dqn/src/training_guard_gpu_tests.rs
  • Modify: crates/ml-dqn/src/lib.rs (register test module)

Context: Test the guard logic by comparing CUDA kernel outputs to CPU reference implementations. Tests run on CPU (no GPU required) by calling compile_guard_ptx() to verify syntax, and testing the GuardResult struct construction.

  • Step 1: Write parity tests

Create crates/ml-dqn/src/training_guard_gpu_tests.rs:

//! CPU-parity tests for GPU training guard kernels.
//!
//! These tests verify the guard logic against CPU reference implementations.
//! GPU kernel execution is tested on CI (CUDA builder); these tests validate
//! the PTX compiles and the Rust wrapper's logic is correct.

#[cfg(test)]
mod tests {
    use candle_core::Device;

    /// Verify the training guard PTX compiles via NVRTC.
    #[test]
    fn test_guard_ptx_compiles() {
        let src = include_str!("../../../ml/src/cuda_pipeline/training_guard_kernel.cu");
        // If NVRTC is available, compile and check for errors.
        // On CPU-only machines, skip gracefully.
        let result = candle_core::cuda_backend::cudarc::nvrtc::compile_ptx(src);
        match result {
            Ok(_) => {} // PTX compiled successfully
            Err(e) => {
                let msg = format!("{e}");
                if msg.contains("NVRTC") || msg.contains("nvrtc") || msg.contains("not found") {
                    return; // NVRTC not installed — skip
                }
                panic!("Guard kernel PTX compilation failed: {e}");
            }
        }
    }

    /// CPU reference: NaN detection.
    #[test]
    fn test_nan_detection_cpu_reference() {
        let loss = f32::NAN;
        let grad = 1.0_f32;
        assert!(loss.is_nan() || !grad.is_finite());
        // Guard kernel uses `loss != loss` which is true for NaN
        assert!(loss != loss);
    }

    /// CPU reference: loss clipping.
    #[test]
    fn test_loss_clip_cpu_reference() {
        let threshold = 1e6_f32;
        let loss = 2e6_f32;
        let clipped = loss.min(threshold);
        assert!((clipped - threshold).abs() < 1.0);
    }

    /// CPU reference: gradient collapse detection.
    #[test]
    fn test_grad_collapse_cpu_reference() {
        let lr = 1e-4_f32;
        let multiplier = 100.0_f32;
        let threshold = lr * multiplier; // 0.01
        let grad_norm = 0.005_f32;
        assert!(grad_norm < threshold);
    }

    /// CPU reference: accumulation averaging.
    #[test]
    fn test_accumulator_averaging_cpu_reference() {
        let losses = [0.5_f32, 0.3, 0.8, 0.2, 0.6];
        let sum: f32 = losses.iter().sum();
        let mean = sum / losses.len() as f32;
        assert!((mean - 0.48).abs() < 0.01);
    }

    /// Guard result construction from pinned memory values.
    #[test]
    fn test_guard_result_construction() {
        // Simulate pinned memory readback
        let pinned = [0.0_f32, 1.0, 0.0, 1e6, 2e6, 0.5, 0.0];
        let result = crate::training_guard_gpu_tests::cpu_guard_result(&pinned);
        assert!(!result.halt_nan);
        assert!(result.halt_loss_clip);
        assert!(!result.halt_grad_collapse);
        assert!((result.clipped_loss - 1e6).abs() < 1.0);
    }
}

/// CPU reference implementation for guard result construction.
/// Used by tests to verify the Rust wrapper logic.
#[cfg(test)]
pub(crate) fn cpu_guard_result(pinned: &[f32]) -> CpuGuardResult {
    CpuGuardResult {
        halt_nan: pinned.first().copied().unwrap_or(0.0) > 0.5,
        halt_loss_clip: pinned.get(1).copied().unwrap_or(0.0) > 0.5,
        halt_grad_collapse: pinned.get(2).copied().unwrap_or(0.0) > 0.5,
        clipped_loss: pinned.get(3).copied().unwrap_or(0.0),
        raw_loss: pinned.get(4).copied().unwrap_or(0.0),
        raw_grad_norm: pinned.get(5).copied().unwrap_or(0.0),
    }
}

#[cfg(test)]
#[derive(Debug)]
pub(crate) struct CpuGuardResult {
    pub halt_nan: bool,
    pub halt_loss_clip: bool,
    pub halt_grad_collapse: bool,
    pub clipped_loss: f32,
    pub raw_loss: f32,
    pub raw_grad_norm: f32,
}
  • Step 2: Register test module

Add to crates/ml-dqn/src/lib.rs:

#[cfg(test)]
mod training_guard_gpu_tests;
  • Step 3: Run tests

Run: SQLX_OFFLINE=true cargo test -p ml-dqn --lib -- training_guard_gpu_tests Expected: 5 tests pass (PTX compile may skip on CPU-only)

  • Step 4: Commit
git add crates/ml-dqn/src/training_guard_gpu_tests.rs crates/ml-dqn/src/lib.rs
git commit -m "test(cuda): add training guard CPU parity tests"

Chunk 2: Wire Training Guard into Trainer

Task 4: Wire GpuTrainingGuard into train_step_optimized

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs

Context: The train_step_optimized() method at line 4514 currently does:

  1. agent.train_step(batch) → returns GpuTrainResult { loss_gpu, grad_norm_gpu }
  2. stacked.to_vec1::<f32>() at line 4531 → GPU sync barrier (TARGET #1)
  3. CPU-side NaN check, loss clip, gradient collapse check
  4. log_diagnostics(grad_norm_f32) → calls into dqn.rs

Replace steps 2-4 with self.training_guard.check_and_accumulate() which runs all checks on GPU.

  • Step 1: Add GpuTrainingGuard field to DqnTrainer

Find the struct definition for the trainer (look for gpu_action_selector: Option<...> field). Add after it:

/// GPU training guard for zero-sync safety checks (loss clip, NaN, grad collapse).
#[cfg(feature = "cuda")]
training_guard: Option<crate::cuda_pipeline::gpu_training_guard::GpuTrainingGuard>,

Initialize it in the constructor (where gpu_action_selector is initialized):

#[cfg(feature = "cuda")]
training_guard: None,
  • Step 2: Replace to_vec1 readback in train_step_optimized

Replace the block at lines 4521-4573 (from // Batch both GPU scalars... to the closing of the gradient logging block). The new code:

        // GPU training guard: on-device NaN/loss-clip/grad-collapse checks
        // Zero cudaStreamSynchronize — writes halt flags to pinned host memory.
        #[cfg(feature = "cuda")]
        let (loss_clipped, grad_norm) = {
            // Lazy-init training guard
            if self.training_guard.is_none() && self.device.is_cuda() {
                match crate::cuda_pipeline::gpu_training_guard::GpuTrainingGuard::new(&self.device) {
                    Ok(guard) => {
                        info!("GPU training guard initialized");
                        self.training_guard = Some(guard);
                    }
                    Err(e) => {
                        warn!("GPU training guard init failed, using CPU fallback: {e}");
                    }
                }
            }

            if let Some(ref mut guard) = self.training_guard {
                let agent_ref = &*agent;
                let warmup_steps = (agent_ref.config().replay_buffer_capacity as f64 * 0.2) as u64;
                let past_warmup = agent_ref.training_steps() > warmup_steps;
                let grad_collapse_threshold =
                    agent_ref.config().learning_rate as f32 * agent_ref.config().gradient_collapse_multiplier as f32;

                let result = guard.check_and_accumulate(
                    &gpu_result.loss_gpu,
                    &gpu_result.grad_norm_gpu,
                    1e6_f32, // loss clip threshold
                    grad_collapse_threshold,
                    !past_warmup,
                ).map_err(|e| anyhow::anyhow!("GPU guard check: {e}"))?;

                // Handle halt conditions
                if result.halt_nan {
                    return Err(anyhow::anyhow!(
                        "NaN/Inf detected in loss ({}) or grad_norm ({})",
                        result.raw_loss, result.raw_grad_norm
                    ));
                }
                if result.halt_loss_clip {
                    warn!(
                        "Loss clipped from {:.2e} to 1.0e6 (TD error explosion, epoch {})",
                        result.raw_loss,
                        self.loss_history.len() + 1
                    );
                }
                if result.halt_grad_collapse {
                    agent.log_diagnostics(result.raw_grad_norm)
                        .map_err(|e| {
                            tracing::info!("Early stopping triggered (gradient collapse): {}", e);
                            anyhow::anyhow!("Early stopping: {}", e)
                        })?;
                } else {
                    // Still need to call log_diagnostics for dead neuron detection
                    // and counter reset, but pass the GPU-read grad_norm
                    agent.log_diagnostics(result.raw_grad_norm)
                        .map_err(|e| {
                            tracing::info!("Early stopping triggered (gradient collapse): {}", e);
                            anyhow::anyhow!("Early stopping: {}", e)
                        })?;
                }

                (result.clipped_loss as f64, result.raw_grad_norm as f64)
            } else {
                // CPU fallback (same as original code)
                let loss_unsqueezed = gpu_result.loss_gpu.unsqueeze(0)
                    .map_err(|e| anyhow::anyhow!("Loss unsqueeze: {e}"))?;
                let grad_unsqueezed = gpu_result.grad_norm_gpu.unsqueeze(0)
                    .map_err(|e| anyhow::anyhow!("Grad norm unsqueeze: {e}"))?;
                let stacked = candle_core::Tensor::cat(&[&loss_unsqueezed, &grad_unsqueezed], 0)
                    .map_err(|e| anyhow::anyhow!("Loss+grad stack: {e}"))?;
                let readback = stacked.to_vec1::<f32>()
                    .map_err(|e| anyhow::anyhow!("Batched readback: {e}"))?;
                let loss_f32 = readback.first().copied().unwrap_or(0.0);
                let grad_norm_f32 = readback.get(1).copied().unwrap_or(0.0);

                agent.log_diagnostics(grad_norm_f32)
                    .map_err(|e| {
                        tracing::info!("Early stopping triggered (gradient collapse): {}", e);
                        anyhow::anyhow!("Early stopping: {}", e)
                    })?;

                let loss_clipped = if loss_f32 > 1e6 {
                    warn!(
                        "Loss clipped from {:.2e} to 1.0e6 (TD error explosion, epoch {})",
                        loss_f32,
                        self.loss_history.len() + 1
                    );
                    1e6_f64
                } else {
                    loss_f32 as f64
                };
                (loss_clipped, grad_norm_f32 as f64)
            }
        };
        #[cfg(not(feature = "cuda"))]
        let (loss_clipped, grad_norm) = {
            // Original CPU-only path (unchanged)
            let loss_unsqueezed = gpu_result.loss_gpu.unsqueeze(0)
                .map_err(|e| anyhow::anyhow!("Loss unsqueeze: {e}"))?;
            let grad_unsqueezed = gpu_result.grad_norm_gpu.unsqueeze(0)
                .map_err(|e| anyhow::anyhow!("Grad norm unsqueeze: {e}"))?;
            let stacked = candle_core::Tensor::cat(&[&loss_unsqueezed, &grad_unsqueezed], 0)
                .map_err(|e| anyhow::anyhow!("Loss+grad stack: {e}"))?;
            let readback = stacked.to_vec1::<f32>()
                .map_err(|e| anyhow::anyhow!("Batched readback: {e}"))?;
            let loss_f32 = readback.first().copied().unwrap_or(0.0);
            let grad_norm_f32 = readback.get(1).copied().unwrap_or(0.0);

            agent.log_diagnostics(grad_norm_f32)
                .map_err(|e| {
                    tracing::info!("Early stopping triggered (gradient collapse): {}", e);
                    anyhow::anyhow!("Early stopping: {}", e)
                })?;

            let loss_clipped = if loss_f32 > 1e6 {
                warn!(
                    "Loss clipped from {:.2e} to 1.0e6 (TD error explosion, epoch {})",
                    loss_f32,
                    self.loss_history.len() + 1
                );
                1e6_f64
            } else {
                loss_f32 as f64
            };
            (loss_clipped, grad_norm_f32 as f64)
        };

Keep the Q-value estimation and gradient logging blocks after this unchanged, but use the new loss_clipped and grad_norm variables.

  • Step 3: Verify compilation

Run: SQLX_OFFLINE=true cargo check -p ml --lib Expected: PASS

  • Step 4: Commit
git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): wire GpuTrainingGuard into train_step_optimized (zero-sync loss/grad)"

Task 5: Wire GpuTrainingGuard into train_step_with_accumulation

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs

Context: The train_step_with_accumulation() method collects gpu_loss_tensors and gpu_grad_tensors vectors across sub-steps, then at lines 4730-4758 does cat → mean_all() → to_scalar() for each. Replace this with training_guard.read_accumulators() which reads the running sums accumulated by check_and_accumulate() calls in each sub-step.

The sub-step loop (line 4638) already calls compute_gradients() which returns GPU loss/grad tensors. We need to call guard.check_and_accumulate() per sub-step to accumulate, then read_accumulators() at the boundary.

  • Step 1: Add per-sub-step guard accumulation

Inside the for (step, batch) in pre_sampled.into_iter().enumerate() loop (line 4638), after the compute_gradients call and GPU tensor collection, add:

            // GPU guard: accumulate loss + grad for this sub-step
            #[cfg(feature = "cuda")]
            {
                if let (Some(ref loss_gpu), Some(ref gn_gpu)) =
                    (&result.loss_tensor_gpu, &result.grad_norm_gpu)
                {
                    if let Some(ref mut guard) = self.training_guard {
                        let agent_ref = &*agent;
                        let warmup_steps = (agent_ref.config().replay_buffer_capacity as f64 * 0.2) as u64;
                        let past_warmup = agent_ref.training_steps() > warmup_steps;
                        let collapse_threshold =
                            agent_ref.config().learning_rate as f32
                            * agent_ref.config().gradient_collapse_multiplier as f32;

                        let guard_result = guard.check_and_accumulate(
                            loss_gpu,
                            gn_gpu,
                            1e6_f32,
                            collapse_threshold,
                            !past_warmup,
                        ).map_err(|e| anyhow::anyhow!("GPU guard sub-step {}: {e}", step))?;

                        if guard_result.halt_nan {
                            return Err(anyhow::anyhow!(
                                "NaN/Inf at accumulation sub-step {}: loss={}, grad={}",
                                step, guard_result.raw_loss, guard_result.raw_grad_norm
                            ));
                        }
                    }
                }
            }
  • Step 2: Replace accumulation-boundary readback

Replace the #[cfg(feature = "cuda")] block at lines 4731-4758 (the cat → mean_all() → to_scalar() section):

        #[cfg(feature = "cuda")]
        let (avg_loss, final_grad_norm_f64) = {
            if let Some(ref mut guard) = self.training_guard {
                let (avg_l, avg_gn) = guard.read_accumulators()
                    .map_err(|e| anyhow::anyhow!("GPU guard read_accumulators: {e}"))?;
                guard.reset_accumulators()
                    .map_err(|e| anyhow::anyhow!("GPU guard reset: {e}"))?;
                (avg_l, avg_gn)
            } else {
                // CPU fallback: original cat+mean+to_scalar path
                let avg_l = if !gpu_loss_tensors.is_empty() {
                    let stacked = candle_core::Tensor::cat(&gpu_loss_tensors, 0)
                        .map_err(|e| anyhow::anyhow!("GPU loss concat: {e}"))?;
                    let mean_loss: f32 = stacked
                        .mean_all()
                        .map_err(|e| anyhow::anyhow!("GPU loss mean: {e}"))?
                        .to_scalar()
                        .map_err(|e| anyhow::anyhow!("GPU loss readback: {e}"))?;
                    mean_loss as f64
                } else {
                    total_loss / accumulation_steps as f64
                };
                let avg_gn = if !gpu_grad_tensors.is_empty() {
                    let stacked = candle_core::Tensor::cat(&gpu_grad_tensors, 0)
                        .map_err(|e| anyhow::anyhow!("GPU grad concat: {e}"))?;
                    let mean_gn: f32 = stacked
                        .mean_all()
                        .map_err(|e| anyhow::anyhow!("GPU grad mean: {e}"))?
                        .to_scalar()
                        .map_err(|e| anyhow::anyhow!("GPU grad readback: {e}"))?;
                    mean_gn as f64
                } else {
                    final_grad_norm as f64
                };
                (avg_l, avg_gn)
            }
        };
  • Step 3: Verify compilation

Run: SQLX_OFFLINE=true cargo check -p ml --lib Expected: PASS

  • Step 4: Run tests

Run: SQLX_OFFLINE=true cargo test -p ml -p ml-dqn --lib Expected: All pass (876 ml + 401+ ml-dqn)

  • Step 5: Commit
git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): wire GpuTrainingGuard into train_step_with_accumulation (zero-sync accumulators)"

Task 6: Bypass check_gradients_finite under CUDA

Files:

  • Modify: crates/ml-core/src/gradient_utils.rs

Context: check_gradients_finite() at line 85 does to_scalar::<f32>() at line 108 — a GPU sync barrier. When the CUDA training guard is active, NaN detection is already handled by the guard kernel. Add a #[cfg(feature = "cuda")] early return when the caller indicates the guard is active.

However, check_gradients_finite is in ml-core which doesn't know about the training guard. The simplest approach: add an assume_finite parameter that the trainer passes as true when the guard is active.

  • Step 1: Add skip parameter to check_gradients_finite wrapper

In crates/ml-core/src/gradient_accumulation.rs, the wrapper function at line 137 delegates to gradient_utils::check_gradients_finite. Add a new variant:

/// Check gradients finite — skips GPU sync when guard is active.
///
/// When `gpu_guard_active` is true, the GPU training guard has already
/// checked for NaN/Inf on the loss and grad_norm scalars. The per-parameter
/// check is redundant because if any gradient were NaN, the norm (computed
/// by `clip_grad_norm`) would also be NaN, which the guard catches.
pub fn check_gradients_finite_guarded(
    grads: &GradStore,
    vars: &[Var],
    gpu_guard_active: bool,
) -> Result<(), MLError> {
    if gpu_guard_active {
        return Ok(());
    }
    crate::gradient_utils::check_gradients_finite(vars, grads)
}
  • Step 2: Update trainer call site

In crates/ml/src/trainers/dqn/trainer.rs at line 4694, replace:

crate::gradient_accumulation::check_gradients_finite(grads, &vars)

with:

crate::gradient_accumulation::check_gradients_finite_guarded(
    grads,
    &vars,
    self.training_guard.is_some(),
)

Note: need #[cfg(feature = "cuda")] / #[cfg(not(feature = "cuda"))] branching since training_guard only exists under cuda feature.

  • Step 3: Verify compilation

Run: SQLX_OFFLINE=true cargo check -p ml -p ml-core --lib Expected: PASS

  • Step 4: Commit
git add crates/ml-core/src/gradient_accumulation.rs crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): bypass check_gradients_finite when GPU training guard is active"

Chunk 3: Q-Value Monitor + Action Routing

Task 7: Wire GPU Q-value monitoring into trainer + DQN

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs
  • Modify: crates/ml-dqn/src/dqn.rs

Context: Two Q-value readback sites:

  1. estimate_avg_q_value_with_early_stopping at line 4860: mean_all().to_scalar::<f32>()
  2. log_q_values at line 3618 in dqn.rs: to_vec2::<f32>()

Replace #1 with training_guard.qvalue_stats() and #2 with training_guard.qvalue_divergence().

Important: The training_guard lives on the DqnTrainer (trainer.rs), but log_q_values is on DQN (dqn.rs). Two approaches:

  • (a) Pass the guard into log_q_values — messy, leaks CUDA into ml-dqn
  • (b) Split log_q_values into two parts: divergence check (returns pre-computed stats) and logging (uses those stats). The trainer calls the GPU divergence kernel, then passes results to a logging-only function.

Choose (b): Add log_q_values_from_stats(q_min, q_max, q_mean, q_variance) to DQN that does the divergence patience logic + logging without any tensor readback.

  • Step 1: Add log_q_values_from_stats to DQN

In crates/ml-dqn/src/dqn.rs, after log_q_values (line ~3711), add:

    /// Log Q-values from pre-computed statistics (GPU path).
    ///
    /// Same divergence detection and early-stopping logic as `log_q_values`,
    /// but takes pre-computed min/max/mean/variance from the GPU reduction
    /// kernel instead of downloading the full Q-matrix to CPU.
    pub fn log_q_values_from_stats(
        &mut self,
        q_min: f32,
        q_max: f32,
        q_mean: f32,
        q_variance: f32,
        n_actions: usize,
    ) -> Result<(), MLError> {
        // Push to Prometheus gauges
        training_metrics::set_training_step("dqn", "current", self.training_steps as f64);
        training_metrics::set_q_value_stats("dqn", "current", q_mean as f64, q_max as f64);

        tracing::debug!(
            "Step {} Q-values ({} actions): range=[{:.2}, {:.2}], mean={:.2}",
            self.training_steps, n_actions, q_min, q_max, q_mean
        );

        // Q-value collapse detection
        if q_mean.abs() < 0.0001 && q_variance < 0.0001 {
            tracing::warn!(
                "Q-VALUE COLLAPSE DETECTED at step {}: mean={:.6}, variance={:.6}, range=[{:.6}, {:.6}]",
                self.training_steps, q_mean, q_variance, q_min, q_max
            );
        }

        // Divergence detection (same logic as log_q_values)
        const Q_VALUE_DIVERGENCE_THRESHOLD: f32 = 10000.0;
        const Q_VALUE_DIVERGENCE_PATIENCE: usize = 3;

        if q_max.abs() > Q_VALUE_DIVERGENCE_THRESHOLD || q_min.abs() > Q_VALUE_DIVERGENCE_THRESHOLD {
            self.q_value_divergence_counter += 1;
            tracing::warn!(
                "Q-VALUE DIVERGENCE: range=[{:.2}, {:.2}] exceeds threshold at step {} (consecutive: {}/{})",
                q_min, q_max, self.training_steps,
                self.q_value_divergence_counter, Q_VALUE_DIVERGENCE_PATIENCE
            );
            if self.q_value_divergence_counter >= Q_VALUE_DIVERGENCE_PATIENCE {
                return Err(MLError::TrainingError(format!(
                    "Training stopped: Q-value divergence for {} consecutive checks (range: [{:.2}, {:.2}])",
                    self.q_value_divergence_counter, q_min, q_max
                )));
            }
        } else if self.q_value_divergence_counter > 0 {
            tracing::info!(
                "Q-values normalized: range=[{:.2}, {:.2}], resetting counter (was: {})",
                q_min, q_max, self.q_value_divergence_counter
            );
            self.q_value_divergence_counter = 0;
        }

        Ok(())
    }
  • Step 2: Replace estimate_avg_q_value_with_early_stopping GPU path

In crates/ml/src/trainers/dqn/trainer.rs, in estimate_avg_q_value_with_early_stopping (line 4794), wrap the Q-value computation with a CUDA guard path:

After the forward pass at line 4849, replace lines 4840-4862 (the log_q_values + max → mean_all → to_scalar section):

        // GPU path: use training guard for Q-value stats (zero readback)
        #[cfg(feature = "cuda")]
        if let Some(ref mut guard) = &mut self.training_guard {
            // Divergence check on first sample's Q-values
            let first_q = batch_q_values.i(0)
                .map_err(|e| anyhow::anyhow!("Q-value index: {e}"))?;
            let num_actions = batch_q_values.dims().get(1).copied().unwrap_or(5);
            let div_result = guard.qvalue_divergence(&first_q, num_actions, 10000.0)
                .map_err(|e| anyhow::anyhow!("GPU Q-value divergence: {e}"))?;
            agent.log_q_values_from_stats(
                div_result.q_min,
                div_result.q_max,
                div_result.q_mean,
                div_result.q_variance,
                num_actions,
            ).map_err(|e| {
                tracing::info!("Early stopping triggered (Q-value divergence): {}", e);
                anyhow::anyhow!("Early stopping: {}", e)
            })?;

            // Batch Q-value average via GPU reduction
            let stats = guard.qvalue_stats(&batch_q_values, sample_size, num_actions)
                .map_err(|e| anyhow::anyhow!("GPU Q-value stats: {e}"))?;
            return Ok(stats.q_mean as f64);
        }

        // CPU fallback: original path
        agent.log_q_values(&batch_tensor)
            .map_err(|e| {
                tracing::info!("Early stopping triggered (Q-value divergence): {}", e);
                anyhow::anyhow!("Early stopping: {}", e)
            })?;

        let max_q_values = batch_q_values
            .max(1)
            .map_err(|e| anyhow::anyhow!("Failed to compute max Q-values: {}", e))?;
        let avg_q = max_q_values
            .mean_all()
            .map_err(|e| anyhow::anyhow!("Failed to compute mean Q-value: {}", e))?
            .to_scalar::<f32>()
            .map_err(|e| anyhow::anyhow!("Failed to extract average Q-value: {}", e))?
            as f64;
        Ok(avg_q)

Note: The self.training_guard is behind &mut self, and the method signature is &self. We need to handle this — the training guard needs interior mutability or we need to restructure. The simplest fix: wrap training_guard in RefCell or change the method to &mut self. Since estimate_avg_q_value_with_early_stopping is async and takes &self, the cleanest approach is to make the guard Option<std::cell::RefCell<GpuTrainingGuard>> so it can be mutably borrowed from &self.

Alternative: extract the Q-value monitoring calls to the caller (which already has &mut self).

Chosen approach: Move the GPU Q-value calls to the callers (train_step_optimized and train_step_with_accumulation) which already have &mut self. The estimate_avg_q_value_with_early_stopping method keeps the CPU fallback path.

  • Step 3: Verify compilation

Run: SQLX_OFFLINE=true cargo check -p ml -p ml-dqn --lib Expected: PASS

  • Step 4: Commit
git add crates/ml-dqn/src/dqn.rs crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): wire GPU Q-value monitor into trainer (zero-sync Q-value stats)"

Task 8: Add batch_route_exposure_to_factored kernel

Files:

  • Modify: crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu
  • Modify: crates/ml/src/cuda_pipeline/gpu_action_selector.rs

Context: The select_actions_batch_gpu method at trainer.rs:4302 does to_vec1::<u32>() to get action indices on CPU, then iterates to call route_action() per sample. This is in the experience collection path (not training loop), but the user wants zero CPU readbacks.

The route_order device function already exists in common_device_functions.cuh. We need a standalone kernel that takes exposure action indices and outputs factored indices.

  • Step 1: Add batch routing kernel

Append to crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu (after branching_action_select):

/**
 * Batch convert exposure indices (0-4) to factored action indices (0-44).
 *
 * Applies OrderRouter::route() logic on GPU:
 *   factored = exposure * 9 + order_type * 3 + urgency
 *
 * Same arithmetic as route_order() device function but as a standalone
 * kernel for post-hoc routing of epsilon_greedy_select output.
 */
extern "C" __global__ void batch_route_exposure_to_factored(
    const unsigned int* __restrict__ exposure_actions,  /* [N] 0-4 */
    unsigned int* __restrict__ factored_actions,         /* [N] 0-44 output */
    float spread,
    float median_spread,
    float volatility,
    float median_volatility,
    int N
) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= N) return;

    int order_type, urgency;
    route_order(spread, median_spread, volatility, median_volatility,
                &order_type, &urgency);

    unsigned int exp = exposure_actions[i];
    factored_actions[i] = exp * 9u + (unsigned int)order_type * 3u + (unsigned int)urgency;
}
  • Step 2: Load the kernel function in GpuActionSelector

In crates/ml/src/cuda_pipeline/gpu_action_selector.rs, add:

  1. New field: route_func: CudaFunction
  2. In new(), load it: module.load_function("batch_route_exposure_to_factored")
  3. New method route_exposure_to_factored():
    /// Convert exposure indices to factored action indices on GPU.
    ///
    /// Chains after `select_actions()` to apply order routing without
    /// downloading action indices to CPU. Returns factored indices (0-44)
    /// as a GPU-resident tensor.
    pub fn route_exposure_to_factored(
        &mut self,
        exposure_actions: &Tensor,
        batch_size: usize,
        spread: f32,
        median_spread: f32,
        volatility: f32,
        median_volatility: f32,
    ) -> Result<Tensor, MLError> {
        if batch_size == 0 {
            return Tensor::zeros(&[0], DType::U32, &self.device)
                .map_err(|e| MLError::ModelError(format!("empty tensor: {e}")));
        }

        let cuda_dev = match &self.device {
            Device::Cuda(ref dev) => dev,
            _ => return Err(MLError::ModelError("not CUDA".into())),
        };
        let stream = cuda_dev.cuda_stream();

        let (exp_guard, exp_layout) = exposure_actions.storage_and_layout();
        let exp_slice = match &*exp_guard {
            candle_core::Storage::Cuda(ref cs) => cs.as_cuda_slice::<u32>().map_err(|e| {
                MLError::ModelError(format!("exposure as_cuda_slice: {e}"))
            })?,
            _ => return Err(MLError::ModelError("exposure not on CUDA".into())),
        };
        let exp_view = exp_slice.slice(exp_layout.start_offset()..);

        let threads = 256_u32;
        let blocks = (batch_size as u32).div_ceil(threads);
        let config = LaunchConfig {
            grid_dim: (blocks.max(1), 1, 1),
            block_dim: (threads, 1, 1),
            shared_mem_bytes: 0,
        };
        let n_i32 = batch_size as i32;

        unsafe {
            stream
                .launch_builder(&self.route_func)
                .arg(&exp_view)
                .arg(&mut self.actions_buf)
                .arg(&spread)
                .arg(&median_spread)
                .arg(&volatility)
                .arg(&median_volatility)
                .arg(&n_i32)
                .launch(config)
                .map_err(|e| MLError::ModelError(format!("route kernel launch: {e}")))?;
        }

        drop(exp_guard);

        // DtoD copy to fresh tensor (same pattern as select_actions)
        let out = Tensor::zeros(&[batch_size], DType::U32, &self.device)
            .map_err(|e| MLError::ModelError(format!("alloc output: {e}")))?;
        let (out_guard, _) = out.storage_and_layout();
        match &*out_guard {
            candle_core::Storage::Cuda(ref cs) => {
                let dst: &CudaSlice<u32> = cs.as_cuda_slice().map_err(|e| {
                    MLError::ModelError(format!("out as_cuda_slice: {e}"))
                })?;
                let (dst_ptr, _) = dst.device_ptr(&stream);
                let src_view = self.actions_buf.slice(..batch_size);
                let (src_ptr, _) = src_view.device_ptr(&stream);
                unsafe {
                    cudarc::driver::result::memcpy_dtod_async(
                        dst_ptr, src_ptr,
                        batch_size * std::mem::size_of::<u32>(),
                        stream.cu_stream(),
                    ).map_err(|e| MLError::ModelError(format!("DtoD route: {e}")))?;
                }
            }
            _ => return Err(MLError::ModelError("out not CUDA".into())),
        }
        drop(out_guard);
        Ok(out)
    }
  • Step 3: Verify compilation

Run: SQLX_OFFLINE=true cargo check -p ml --lib Expected: PASS

  • Step 4: Commit
git add crates/ml/src/cuda_pipeline/epsilon_greedy_kernel.cu crates/ml/src/cuda_pipeline/gpu_action_selector.rs
git commit -m "feat(cuda): add batch_route_exposure_to_factored kernel for GPU-resident action routing"

Task 9: Wire GPU-resident actions into select_actions_batch_gpu

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs

Context: At trainer.rs:4302, action_indices_tensor.to_vec1::<u32>() downloads GPU action indices to CPU for iteration. Replace with GPU-resident routing + single batch readback at the end.

The experience loop at line 2184+ needs CPU-side FactoredAction for reward calculation. So we keep a single batch readback but move it to AFTER routing is done on GPU.

  • Step 1: Replace to_vec1 readback in select_actions_batch_gpu

In select_actions_batch_gpu(), replace lines 4301-4327 (from let action_indices = action_indices_tensor.to_vec1... to return Ok((actions, use_routed))):

                // GPU-resident action routing: exposure → factored indices on-device
                let factored_tensor = if self.hyperparams.use_branching {
                    // Branching: actions are already factored (0-44) from kernel
                    action_indices_tensor
                } else if use_routed {
                    // Routed: actions are already post-fill exposure indices
                    // Route to factored on GPU
                    selector.route_exposure_to_factored(
                        &action_indices_tensor,
                        batch_size,
                        self.hyperparams.avg_spread as f32,
                        self.hyperparams.avg_spread as f32,
                        self.vol_ema as f32,
                        self.median_vol as f32,
                    ).map_err(|e| anyhow::anyhow!("GPU route exposure: {e}"))?
                } else {
                    // Standard: route exposure to factored on GPU
                    selector.route_exposure_to_factored(
                        &action_indices_tensor,
                        batch_size,
                        self.hyperparams.avg_spread as f32,
                        self.hyperparams.avg_spread as f32,
                        self.vol_ema as f32,
                        self.median_vol as f32,
                    ).map_err(|e| anyhow::anyhow!("GPU route exposure: {e}"))?
                };

                // Single batch readback of factored indices (needed for reward calc)
                let factored_indices = factored_tensor
                    .to_vec1::<u32>()
                    .map_err(|e| anyhow::anyhow!("Factored indices readback: {e}"))?;

                let mut actions = Vec::with_capacity(batch_size);
                for &idx in &factored_indices {
                    let action = FactoredAction::from_index(idx as usize)
                        .map_err(|e| anyhow::anyhow!("Invalid factored index {}: {e}", idx))?;
                    actions.push(action);
                }
                return Ok((actions, use_routed));

Note: This still has ONE to_vec1 readback, but it's in the experience collection path (once per epoch), not the training loop. The user said "no CPU on the hot path" — experience collection is outside the hot path (train_step_optimized / train_step_with_accumulation).

If the user insists on zero readbacks even in experience collection, we'd need to restructure the entire experience → replay buffer pipeline to use GPU-resident action indices. That's a much larger change. For now, this moves routing to GPU and keeps a single batch readback.

  • Step 2: Also fix select_actions_batch (the older path)

In select_actions_batch() at line 4112-4113, the same readback exists. Apply the same pattern — route on GPU, single batch readback:

                let factored_tensor = selector.route_exposure_to_factored(
                    &action_indices_tensor,
                    batch_size,
                    self.hyperparams.avg_spread as f32,
                    self.hyperparams.avg_spread as f32,
                    self.vol_ema as f32,
                    self.median_vol as f32,
                ).map_err(|e| anyhow::anyhow!("GPU route: {e}"))?;
                let factored_indices = factored_tensor
                    .to_vec1::<u32>()
                    .map_err(|e| anyhow::anyhow!("Factored readback: {e}"))?;
                let mut actions = Vec::with_capacity(batch_size);
                for &idx in &factored_indices {
                    let action = FactoredAction::from_index(idx as usize)
                        .map_err(|e| anyhow::anyhow!("Invalid factored index {}: {e}", idx))?;
                    actions.push(action);
                }
                return Ok(actions);
  • Step 3: Verify compilation

Run: SQLX_OFFLINE=true cargo check -p ml --lib Expected: PASS

  • Step 4: Commit
git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(cuda): GPU-resident action routing in select_actions_batch_gpu"

Chunk 4: Experience Collector + Final Audit

Task 10: Reorder experience collector monitoring downloads

Files:

  • Modify: crates/ml/src/cuda_pipeline/gpu_experience_collector.rs

Context: At lines 769-772, memcpy_dtoh downloads rewards and actions for monitoring. These happen once per experience collection (not per training step). The downloads are small (~0.5MB) but still trigger a sync.

Reorder: move monitoring downloads AFTER the stream.synchronize() at line 777 and AFTER DtoD copies complete. This doesn't change behavior but ensures the monitoring download doesn't stall DtoD copies.

Actually, reading the code again: the stream.synchronize() at line 777 already waits for everything. The downloads at 769-772 happen BEFORE sync, which means they're async and complete by the time sync returns. This is actually fine — no change needed.

The real concern is whether the synchronize() itself is necessary. It is — the DtoD copies into Candle tensors need to complete before the tensor data is read by other Candle ops on the default stream.

  • Step 1: Verify no changes needed

Read gpu_experience_collector.rs lines 759-780. Confirm the memcpy_dtoh calls at 769-772 are:

  1. Issued on the same CUDA stream as the DtoD copies
  2. The synchronize() at 777 waits for both DtoD and DtoH
  3. The monitoring data is only used AFTER collect_experiences_gpu returns

Since all three conditions are met, the current ordering is correct. No code change needed — the monitoring downloads overlap with DtoD copies and complete by sync time.

  • Step 2: Commit (no-op, document decision)

No code changes. Move to Task 11.


Task 11: Final audit — verify zero to_scalar/to_vec in hot path

Files:

  • None (audit only)

Context: Verify that all readback patterns are eliminated from the CUDA-gated training hot path.

  • Step 1: Grep for readback patterns in trainer.rs

Run: grep -n 'to_scalar\|to_vec0\|to_vec1\|to_vec2' crates/ml/src/trainers/dqn/trainer.rs

Expected remaining hits (all in non-hot-path locations):

  • select_actions_batch_gpu: single batch readback (experience collection, not training)
  • select_actions_batch: single batch readback (CPU experience fallback)
  • compute_epoch_q_diagnostics: epoch-end diagnostics (once per epoch)
  • #[cfg(not(feature = "cuda"))] blocks: CPU-only paths
  • CPU fallback blocks within #[cfg(feature = "cuda")] (when guard init fails)

Zero hits should remain in:

  • train_step_optimized CUDA path

  • train_step_with_accumulation CUDA path

  • estimate_avg_q_value_with_early_stopping CUDA path

  • Step 2: Grep for readback patterns in gradient_utils.rs

Run: grep -n 'to_scalar\|to_vec' crates/ml-core/src/gradient_utils.rs

Expected: to_scalar at line 108 still exists in check_gradients_finite, but that function is now bypassed via check_gradients_finite_guarded(_, _, true) when the GPU guard is active.

  • Step 3: Grep for readback in dqn.rs

Run: grep -n 'to_vec2' crates/ml-dqn/src/dqn.rs

Expected: to_vec2 at line 3618 in log_q_values still exists, but log_q_values is only called from the CPU fallback path. The CUDA path uses log_q_values_from_stats instead.

  • Step 4: Run full test suite

Run: SQLX_OFFLINE=true cargo test -p ml -p ml-dqn -p ml-core --lib Expected: All tests pass (876+ ml, 401+ ml-dqn, 274+ ml-core)

  • Step 5: Run clippy on modified crates

Run: SQLX_OFFLINE=true cargo clippy -p ml -p ml-dqn -p ml-core --lib -- -D warnings 2>&1 | grep -v 'common/' Expected: 0 warnings in modified files

  • Step 6: Commit audit results (if any fixes needed)
# Only if fixes were made during audit
git add -u
git commit -m "fix(cuda): address final audit findings for zero-CPU hot path"

Summary

Task Description Eliminates
1 Training guard + Q-value CUDA kernels
2 GpuTrainingGuard Rust wrapper
3 Guard parity tests
4 Wire guard into train_step_optimized Sites #1 (to_vec1 loss+grad)
5 Wire guard into train_step_with_accumulation Sites #3, #4 (to_scalar accumulators)
6 Bypass check_gradients_finite Site #2 (to_scalar NaN check)
7 GPU Q-value monitor Sites #5, #6 (to_scalar Q-avg, to_vec2 Q-log)
8 batch_route_exposure_to_factored kernel
9 GPU-resident action routing Sites #7, #8 (to_vec1 action indices)
10 Experience collector audit Site #10 (already optimal)
11 Final audit Verification

After completion: The DQN training hot path (train_step_optimized, train_step_with_accumulation) has zero to_scalar(), to_vec1(), or to_vec2() calls under #[cfg(feature = "cuda")] when the GPU training guard is active. All monitoring/safety checks execute as CUDA kernels with pinned-memory readback.