Files
foxhunt/docs/superpowers/plans/2026-03-27-gpu-hot-path-performance.md
jgrusewski be8f3310d5 feat: gradient stability + 9-agent audit bug fixes
Per-component gradient clipping:
- 2 new CUDA kernels (dqn_clipped_saxpy, dqn_clip_grad)
- CQL gradient isolated into separate scratch buffer
- Budget allocation: C51=70%, CQL=15%, IQN=10%, Ens=5%
- Dynamic budget — inactive components' share goes to C51

Spectral norm extended to all 10 weight matrices:
- Was trunk-only (W_s1, W_s2), now covers all heads
- 16 u/v power iteration buffers, batched GOFF sync-back
- Uniform sigma_max, end-to-end Lipschitz bounded

Bug fixes from 9-agent audit:
- Backtest episode reset: all 8 fields (was 5), max_equity updated before floor check
- gradient_clip_norm unified: 10.0 everywhere (was 10.0 vs 1.0)
- entropy_coefficient: Option<f64> → f64, single default 0.001
- Close price: unwrap_or(1.0) → direct indexing (no silent wrong rewards)
- step_count: only increments during training (was incrementing on inference)
- Quantile loss: single CUDA context (was 3×), silent fallbacks removed
- MaybeNoisyLinear: single-variant enum removed → direct NoisyLinear
- Budget fractions: exported as pub(crate) constants, referenced not hardcoded

Monitoring:
- Per-component gradient Prometheus gauges (C51 raw, combined)
- Diagnostic logging every 1000 steps

Tests: 7 new gradient budget tests, 1 gradient bounds smoke test
All 488 tests pass (359 ml-dqn + 129 ml)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:52:44 +01:00

30 KiB
Raw Blame History

GPU Hot Path Performance — Zero-Sync Training Step

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Eliminate all CPU sync points and F32 compute waste from the per-step training hot path, restoring 3s/epoch performance on H100.

Architecture: Cache all CUDA device pointers at construction/graph-capture time into a CachedPtrs struct. Replace per-step cuStreamSynchronize + EventTrackingGuard + raw_device_ptr calls with cached u64 pointer lookups. Convert cuBLAS cublasSgemm (F32) to cublasGemmEx (BF16 compute with F32 accumulate) for 2-3× tensor core throughput. Batch 8 head spectral norm launches into a single multi-matrix kernel.

Tech Stack: Rust, CUDA, cudarc 0.19.3, cuBLAS (cublasGemmEx), BF16 tensor cores

Verification requirement: Every task MUST compile (SQLX_OFFLINE=true cargo check -p ml) and pass tests (SQLX_OFFLINE=true cargo test -p ml --lib -- dqn). No exceptions. No skipping.


Root Cause Analysis

Measured: Epoch time regressed from 3s → 30s. Per-step hot path has:

Issue Per-step cost Source Lines
cuStreamSynchronize in apply_iqn_trunk_gradient ~50μs (stalls entire GPU pipeline) Pre-existing gpu_dqn_trainer.rs:708
cuStreamSynchronize in apply_ensemble_trunk_gradient ~50μs Pre-existing gpu_dqn_trainer.rs:911
cuStreamSynchronize in run_ensemble_step ~50μs Pre-existing fused_training.rs:875
cuStreamSynchronize in replay_adam_and_readback ~50μs Pre-existing gpu_dqn_trainer.rs:2369
99× raw_device_ptr() calls ~1μs each but forces device_ptr() event machinery Per-step Throughout
11× EventTrackingGuard create/drop ~0.1μs each (atomic ops) Per-step Throughout
F32 cublasSgemm instead of BF16 cublasGemmEx 2-3× slower SGEMM on H100 Pre-existing batched_forward.rs:513
8× single-block spectral norm launches ~5μs each New gpu_dqn_trainer.rs:1439-1453
10× DtoD spectral norm sync-back ~2μs each New gpu_dqn_trainer.rs:1490-1503

Total per-step overhead: ~200μs sync stalls + 2-3× slower SGEMM. Over 10K steps/epoch: 2s sync + 2-3× slower forward/backward = easily 30s epochs.


File Map

File Action
crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs Major: add CachedPtrs struct, remove syncs from IQN/ensemble/CQL paths, cache all pointers at capture time
crates/ml/src/cuda_pipeline/batched_forward.rs Major: replace cublasSgemm with cublasGemmEx (BF16 input, F32 accumulate)
crates/ml/src/cuda_pipeline/batched_backward.rs Major: same cublasSgemmcublasGemmEx conversion
crates/ml/src/trainers/dqn/fused_training.rs Moderate: remove cuStreamSynchronize from run_ensemble_step, dynamic budget fix
crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu Minor: add batched spectral norm kernel
crates/ml/src/trainers/dqn/smoke_tests/gradient_budget.rs Minor: add performance regression test

Task 1: Cache all device pointers at graph-capture time

The root cause of most overhead: raw_device_ptr() calls slice.device_ptr(stream) which goes through cudarc's event tracking machinery. Even with events disabled, it creates a SyncOnDrop guard that's immediately leaked via ManuallyDrop. This happens 99 times per step.

Fix: Compute all u64 pointers ONCE at construction (or re-capture), store them in a CachedPtrs struct, and use the cached values in all per-step methods.

Files:

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

  • Step 1: Define CachedPtrs struct

After the GpuDqnTrainConfig struct (~line 219), add:

/// Pre-computed raw CUDA device pointers for all buffers used in the per-step hot path.
/// Eliminates 99 `raw_device_ptr()` → `device_ptr()` calls per training step.
/// Pointers are invalidated when buffers are reallocated (which never happens after init).
struct CachedPtrs {
    // Flat parameter buffers
    params: u64,
    target_params: u64,
    grad: u64,
    grad_norm: u64,
    m: u64,
    v: u64,
    t: u64,
    total_loss: u64,
    cql_grad_scratch: u64,

    // Batch input buffers
    states: u64,
    next_states: u64,
    actions: u64,
    rewards: u64,
    dones: u64,
    is_weights: u64,

    // Activation save buffers
    save_h_s1: u64,
    save_h_s2: u64,
    save_h_v: u64,
    save_h_b0: u64,
    save_h_b1: u64,
    save_h_b2: u64,

    // Backward scratch
    bw_d_h_s1: u64,
    bw_d_h_s2: u64,
    bw_d_h_v: u64,
    bw_d_h_b0: u64,
    bw_d_h_b1: u64,
    bw_d_h_b2: u64,

    // IQN trunk scratch
    iqn_trunk_m: u64,
    iqn_trunk_grad_norm: u64,

    // Loss outputs
    td_errors: u64,

    // Per-weight pointers (20 online + 20 target, computed from params base)
    on_weight_ptrs: [u64; 20],
    tg_weight_ptrs: [u64; 20],
}
  • Step 2: Build CachedPtrs in constructor

At the end of new(), before Ok(Self { ... }), compute all pointers. Since event tracking is enabled at this point and all buffers were just allocated on the same stream, device_ptr() is safe:

        let cached_ptrs = {
            let _evt_guard = EventTrackingGuard::new(stream.context());
            let param_sizes_arr = compute_param_sizes(&config);
            CachedPtrs {
                params: raw_device_ptr(&params_buf, &stream),
                target_params: raw_device_ptr(&target_params_buf, &stream),
                grad: raw_device_ptr(&grad_buf, &stream),
                grad_norm: raw_device_ptr(&grad_norm_buf, &stream),
                m: raw_device_ptr(&m_buf, &stream),
                v: raw_device_ptr(&v_buf, &stream),
                t: raw_device_ptr_i32(&t_buf, &stream),
                total_loss: raw_device_ptr(&total_loss_buf, &stream),
                cql_grad_scratch: raw_device_ptr(&cql_grad_scratch, &stream),
                states: raw_device_ptr(&states_buf, &stream),
                next_states: raw_device_ptr(&next_states_buf, &stream),
                actions: raw_device_ptr_i32(&actions_buf, &stream),
                rewards: raw_device_ptr(&rewards_buf, &stream),
                dones: raw_device_ptr(&dones_buf, &stream),
                is_weights: raw_device_ptr(&is_weights_buf, &stream),
                save_h_s1: raw_device_ptr(&save_h_s1, &stream),
                save_h_s2: raw_device_ptr(&save_h_s2, &stream),
                save_h_v: raw_device_ptr(&save_h_v, &stream),
                save_h_b0: raw_device_ptr(&save_h_b0, &stream),
                save_h_b1: raw_device_ptr(&save_h_b1, &stream),
                save_h_b2: raw_device_ptr(&save_h_b2, &stream),
                bw_d_h_s1: raw_device_ptr(&bw_d_h_s1, &stream),
                bw_d_h_s2: raw_device_ptr(&bw_d_h_s2, &stream),
                bw_d_h_v: raw_device_ptr(&bw_d_h_v, &stream),
                bw_d_h_b0: raw_device_ptr(&bw_d_h_b0, &stream),
                bw_d_h_b1: raw_device_ptr(&bw_d_h_b1, &stream),
                bw_d_h_b2: raw_device_ptr(&bw_d_h_b2, &stream),
                iqn_trunk_m: raw_device_ptr(&iqn_trunk_m, &stream),
                iqn_trunk_grad_norm: raw_device_ptr(&iqn_trunk_grad_norm, &stream),
                td_errors: raw_device_ptr(&td_errors_buf, &stream),
                on_weight_ptrs: f32_weight_ptrs(&params_buf, &param_sizes_arr, &stream),
                tg_weight_ptrs: f32_weight_ptrs(&target_params_buf, &param_sizes_arr, &stream),
            }
        };

Add cached_ptrs: CachedPtrs to the struct and Ok(Self { ... }).

  • Step 3: Replace raw_device_ptr calls in apply_iqn_trunk_gradient

Replace all raw_device_ptr(&self.xxx, &self.stream) with self.cached_ptrs.xxx. Remove the cuStreamSynchronize (line 708) and EventTrackingGuard (line 712) — they were only needed because device_ptr() could trigger event validation. With cached pointers, no events are involved.

CRITICAL: The cuStreamSynchronize at line 708 has the comment "ensure graph_forward replay completed before we touch buffers." With cached pointers, we don't "touch" buffers via device_ptr — we just use pre-computed addresses. All kernel launches go to the same stream, so ordering is guaranteed by CUDA stream semantics. The sync is unnecessary.

  • Step 4: Replace raw_device_ptr calls in apply_ensemble_trunk_gradient

Same pattern. Remove the cuStreamSynchronize (line 911) and EventTrackingGuard (line 914).

  • Step 5: Replace raw_device_ptr calls in apply_cql_gradient and apply_cql_clipped_saxpy

Same pattern for both methods.

  • Step 6: Replace raw_device_ptr calls in clip_grad_buf_inplace and read_grad_norm_sync

For clip_grad_buf_inplace: use self.cached_ptrs.grad and self.cached_ptrs.grad_norm. Remove EventTrackingGuard.

For read_grad_norm_sync: keep the cuStreamSynchronize (this is a diagnostic method that explicitly syncs for readback) but use cached pointers. This method only runs every 1000 steps.

  • Step 7: Replace raw_device_ptr calls in apply_spectral_norm

Use self.cached_ptrs.params for params_base in the sync-back section. The spectral norm kernel launches on individual weight set buffers (DuelingWeightSet/BranchingWeightSet) — these are passed as references, so their device_ptr() calls remain. BUT: spectral norm runs ONCE per step before graph_forward. The sync at the method start is the graph_forward guard — remove it and rely on stream ordering.

  • Step 8: Replace raw_device_ptr in kernel launch methods

Update launch_grad_norm, launch_adam_update, and the SAXPY kernel launches (IQN clipped, ensemble clipped) to use cached pointers. Remove the EventTrackingGuard in each.

  • Step 9: Verify compilation + tests

Run: SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn Expected: Compiles clean, all 129+ tests pass.

  • Step 10: Commit
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "perf: cache all CUDA device pointers — eliminate 99 raw_device_ptr + 4 cuStreamSync per step"

Task 2: Remove cuStreamSynchronize from run_ensemble_step

The ensemble step in fused_training.rs:875 syncs before touching save_h_s2. With cached pointers and single-stream execution, this sync is unnecessary — CUDA guarantees ordering on the same stream.

Files:

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

  • Step 1: Remove the sync + EventTrackingGuard in run_ensemble_step

At ~line 874-876, remove:

unsafe { cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream()); }
let _ = self.stream.context().check_err();

And the local EvtGuard struct + its creation (~lines 881-893). The ensemble heads use device_ptr() on their own weight buffers — since these are created after event tracking is disabled by the top-level guard in apply_spectral_norm, they have no events. Use raw u64 pointers from the ensemble head structs instead.

Note: the device_ptr() calls in the ensemble loop (on ensemble_extra_heads[k] weights) cannot be cached in CachedPtrs because ensemble heads are optional and variable-count. For these, use a SINGLE EventTrackingGuard scope at the top of run_ensemble_step and call device_ptr() within it — but do NOT sync the stream.

  • Step 2: Verify compilation + tests

Run: SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn Expected: All pass.

  • Step 3: Commit
git add crates/ml/src/trainers/dqn/fused_training.rs
git commit -m "perf: remove cuStreamSynchronize from run_ensemble_step"

Task 3: Convert cuBLAS forward from cublasSgemm (F32) to cublasGemmEx (BF16 tensors, F32 accumulate)

The forward pass uses cublasSgemm (F32×F32→F32). On H100, cublasGemmEx with BF16 inputs and F32 accumulate uses tensor cores at 2-3× the throughput. The BF16 weight buffers (bf16_params_buf, bf16_target_params_buf) already exist but are never used.

Key constraints:

  • Weights: BF16 (pre-converted from F32 via f32_to_bf16_kernel)
  • Activations/states: MUST stay F32 (written by upstream kernels in F32)
  • Accumulator: F32 (no precision loss in output)
  • cuBLAS GemmEx can do BF16×F32→F32 using CUBLAS_COMPUTE_32F

Actually — cublasGemmEx requires both A and B to have the same type when using tensor cores. We need BF16×BF16→F32. This means states and activations must also be BF16 for the forward SGEMM. But:

  • States come from the experience collector in F32
  • Activations (h_s1, h_s2) are written by SGEMM and consumed by the next layer

Practical approach: Use mixed-precision GemmEx with:

  • A = weights (BF16, from bf16_params_buf)
  • B = input (F32, states/activations)
  • C = output (F32, activations/logits)
  • Compute: CUBLAS_COMPUTE_32F (uses tensor cores on H100 for BF16×F32 with TF32 accumulate)

On H100, CUBLAS_COMPUTE_32F with mixed types uses TF32 tensor cores, which is ~1.5× faster than pure F32. For full BF16 tensor core speed (3×), both inputs must be BF16. That requires converting activations to BF16 too — a larger change.

This task implements the TF32 mixed-precision path (1.5× speedup, minimal changes).

Files:

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

  • Step 1: Replace cublasSgemm with cublasGemmEx using TF32 compute

In sgemm_layer (~line 495), change from:

cublas_result::sgemm(
    self.handle.0,
    cublas_sys::cublasOperation_t::CUBLAS_OP_T,
    cublas_sys::cublasOperation_t::CUBLAS_OP_N,
    n as i32, b as i32, k as i32,
    &alpha,
    w_ptr as *const f32, k as i32,
    a_ptr as *const f32, k as i32,
    &beta,
    c_ptr as *mut f32, n as i32,
)

To:

cublas_sys::cublasGemmEx(
    self.handle.0,
    cublas_sys::cublasOperation_t::CUBLAS_OP_T,
    cublas_sys::cublasOperation_t::CUBLAS_OP_N,
    n as i32, b as i32, k as i32,
    &alpha as *const f32 as *const std::ffi::c_void,
    w_ptr as *const std::ffi::c_void,
    cublas_sys::cudaDataType_t::CUDA_R_32F,  // A type
    k as i32,
    a_ptr as *const std::ffi::c_void,
    cublas_sys::cudaDataType_t::CUDA_R_32F,  // B type
    k as i32,
    &beta as *const f32 as *const std::ffi::c_void,
    c_ptr as *mut std::ffi::c_void,
    cublas_sys::cudaDataType_t::CUDA_R_32F,  // C type
    n as i32,
    cublas_sys::cublasComputeType_t::CUBLAS_COMPUTE_32F_FAST_TF32,  // TF32 tensor cores
    cublas_sys::cublasGemmAlgo_t::CUBLAS_GEMM_DEFAULT_TENSOR_OP,
)

CUBLAS_COMPUTE_32F_FAST_TF32 enables TF32 tensor cores on H100/A100. With F32 inputs, cuBLAS internally truncates to TF19 mantissa for the multiply, accumulates in F32. ~1.5× speedup over pure F32 SGEMM.

Do the same for sgemm_layer_raw.

  • Step 2: Set cuBLAS math mode for tensor ops

In the CublasForward::new() constructor, set the math mode:

unsafe {
    cublas_sys::cublasSetMathMode(
        handle.0,
        cublas_sys::cublasMath_t::CUBLAS_TF32_TENSOR_OP_MATH,
    );
}

This allows cublasSgemm to use TF32 even without switching to cublasGemmEx.

  • Step 3: Do the same for batched_backward.rs

The backward pass also uses cublasSgemm. Apply the same TF32 math mode or cublasGemmEx conversion.

  • Step 4: Verify compilation + tests

Run: SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn Expected: All pass. TF32 has ≤0.1% numerical difference from F32 — tests should still pass.

  • Step 5: Commit
git add crates/ml/src/cuda_pipeline/batched_forward.rs crates/ml/src/cuda_pipeline/batched_backward.rs
git commit -m "perf: enable TF32 tensor cores for cuBLAS SGEMM — 1.5x forward+backward throughput"

Task 4: Batch spectral norm into fewer kernel launches

Currently 8 separate kernel launches for head spectral norm, each with grid=(1,1,1) — massively underutilized GPU. Batch them into a single kernel that processes all 8 matrices sequentially (same single block, but loop over matrices using a matrix descriptor array).

Files:

  • Modify: crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu

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

  • Step 1: Add batched spectral norm kernel

In dqn_utility_kernels.cu, add:

/* ══════════════════════════════════════════════════════════════════════
 * BATCHED SPECTRAL NORM KERNEL
 *
 * Processes N weight matrices in a single kernel launch.
 * Grid = (N, 1, 1), Block = (256, 1, 1).
 * Each block handles one matrix via the same power iteration as
 * spectral_norm_kernel but indexed by blockIdx.x into descriptor arrays.
 * ══════════════════════════════════════════════════════════════════════ */

extern "C" __global__ void batched_spectral_norm_kernel(
    /* Arrays of N pointers, one per matrix */
    float** __restrict__ W_arr,       /* [N] pointers to weight matrices */
    float** __restrict__ u_arr,       /* [N] pointers to left singular vectors */
    float** __restrict__ v_arr,       /* [N] pointers to right singular vectors */
    const int* __restrict__ out_dims, /* [N] out_dim per matrix */
    const int* __restrict__ in_dims,  /* [N] in_dim per matrix */
    float sigma_max,
    int n_matrices
) {
    int mat_idx = blockIdx.x;
    if (mat_idx >= n_matrices) return;

    float* W = W_arr[mat_idx];
    float* u = u_arr[mat_idx];
    float* v = v_arr[mat_idx];
    int out_dim = out_dims[mat_idx];
    int in_dim = in_dims[mat_idx];
    int n_total = out_dim * in_dim;

    /* Same power iteration logic as spectral_norm_kernel... */
    /* (copy the body of spectral_norm_kernel here, replacing
       the fixed arguments with the per-matrix values) */
    __shared__ float shmem[256];
    int tid = threadIdx.x;
    int bd = blockDim.x;

    // Step 1: v_new = W^T u
    for (int col = tid; col < in_dim; col += bd) {
        float val = 0.0f;
        for (int row = 0; row < out_dim; row++)
            val += W[row * in_dim + col] * u[row];
        v[col] = val;
    }
    __syncthreads();

    // Normalize v
    float local_v2 = 0.0f;
    for (int col = tid; col < in_dim; col += bd)
        local_v2 += v[col] * v[col];
    shmem[tid] = local_v2;
    __syncthreads();
    for (int s = bd / 2; s > 0; s >>= 1) {
        if (tid < s) shmem[tid] += shmem[tid + s];
        __syncthreads();
    }
    float v_norm = sqrtf(shmem[0] + 1e-12f);
    for (int col = tid; col < in_dim; col += bd)
        v[col] /= v_norm;
    __syncthreads();

    // Step 2: u_new = W v_new
    for (int row = tid; row < out_dim; row += bd) {
        float val = 0.0f;
        for (int col = 0; col < in_dim; col++)
            val += W[row * in_dim + col] * v[col];
        u[row] = val;
    }
    __syncthreads();

    // sigma = ||u_new||
    float local_u2 = 0.0f;
    for (int row = tid; row < out_dim; row += bd)
        local_u2 += u[row] * u[row];
    shmem[tid] = local_u2;
    __syncthreads();
    for (int s = bd / 2; s > 0; s >>= 1) {
        if (tid < s) shmem[tid] += shmem[tid + s];
        __syncthreads();
    }
    float sigma = sqrtf(shmem[0] + 1e-12f);

    // Normalize u
    for (int row = tid; row < out_dim; row += bd)
        u[row] /= (sigma + 1e-12f);
    __syncthreads();

    // Scale W if sigma > sigma_max
    if (sigma < 1e-6f) return;
    float scale = (sigma > sigma_max) ? (sigma_max / sigma) : 1.0f;
    if (scale < 1.0f) {
        for (int i = tid; i < n_total; i += bd)
            W[i] *= scale;
    }
}
  • Step 2: Load the kernel in Rust

Add batched_spectral_norm_kernel loading in compile_training_kernels. Add the function field to the struct.

  • Step 3: Allocate descriptor arrays on GPU

Allocate CudaSlice<u64> arrays for the 10 W/u/v pointer arrays and CudaSlice<i32> for out_dims/in_dims. Upload once at construction.

  • Step 4: Replace 10 individual spec_norm! calls with single batched launch

In apply_spectral_norm, replace the 2 trunk + 8 head launches with:

// Single batched launch: 10 blocks × 256 threads
unsafe {
    self.stream
        .launch_builder(&self.batched_spectral_norm_kernel)
        .arg(&self.spec_w_ptrs_ptr)
        .arg(&self.spec_u_ptrs_ptr)
        .arg(&self.spec_v_ptrs_ptr)
        .arg(&self.spec_out_dims_ptr)
        .arg(&self.spec_in_dims_ptr)
        .arg(&sigma_max)
        .arg(&10_i32)
        .launch(LaunchConfig {
            grid_dim: (10, 1, 1),
            block_dim: (256, 1, 1),
            shared_mem_bytes: 256 * 4,
        })?;
}

This replaces 10 kernel launches with 1.

  • Step 5: Verify compilation + tests

Run: SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn Expected: All pass.

  • Step 6: Commit
git add crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "perf: batch 10 spectral norm launches into single kernel"

Task 5: Dynamic gradient budget allocation

When IQN/ensemble are disabled, their budget is wasted — C51 only gets 70% even as the sole gradient source.

Files:

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

NOTE: This is already partially implemented (dynamic c51_frac computation at Step 2b). Verify it's correct and add a test.

  • Step 1: Verify the dynamic budget code

Read the current Step 2b in fused_training.rs and confirm:

let cql_frac = if self.trainer.has_cql() { CQL_GRAD_BUDGET } else { 0.0 };
let iqn_frac = if self.gpu_iqn.is_some() { IQN_GRAD_BUDGET } else { 0.0 };
let ens_frac = if !self.ensemble_extra_heads.is_empty() { ENS_GRAD_BUDGET } else { 0.0 };
let c51_frac = 1.0 - cql_frac - iqn_frac - ens_frac;

This is correct. C51 gets 100% when nothing else is active.

  • Step 2: Add test for dynamic budget

In gradient_budget.rs, add a test that constructs a trainer with CQL disabled (cql_alpha=0) and verifies C51 can use the full max_grad_norm:

#[test]
fn test_c51_gets_full_budget_when_no_auxiliaries() -> anyhow::Result<()> {
    let dev = cuda_device();
    let stream = Arc::clone(dev.cuda_stream().expect("cuda stream"));
    let mut cfg = test_config();
    cfg.cql_alpha = 0.0; // Disable CQL
    cfg.iqn_lambda = 0.0; // Disable IQN
    let mut trainer = GpuDqnTrainer::new(stream.clone(), cfg)?;

    // Fill grad_buf with large gradient
    let total_params = trainer.total_params();
    stream.memcpy_htod(&vec![1.0_f32; total_params], trainer.grad_buf_mut())
        .map_err(|e| anyhow::anyhow!("{e}"))?;

    // Clip to full max_grad_norm (what C51 should get when solo)
    trainer.clip_grad_buf_inplace(10.0)?; // max_grad_norm * 1.0

    let norm = trainer.read_grad_norm_sync()?;
    assert!(
        norm > 9.9,
        "C51 solo should use full max_grad_norm, got {norm}"
    );

    Ok(())
}
  • Step 3: Verify compilation + tests

Run: SQLX_OFFLINE=true cargo test -p ml --lib -- gradient_budget Expected: All pass including new test.

  • Step 4: Commit
git add crates/ml/src/trainers/dqn/fused_training.rs crates/ml/src/trainers/dqn/smoke_tests/gradient_budget.rs
git commit -m "fix: dynamic gradient budget — C51 gets full max_grad_norm when auxiliaries disabled"

Task 6: Remove cuStreamSynchronize from replay_adam_and_readback

The Adam readback (replay_adam_and_readback) syncs the stream and reads back loss + grad_norm (8 bytes DtoH). This is the ONLY legitimate sync in the per-step path — we need the loss for logging. But we can defer it to the NEXT step's start, overlapping GPU work with CPU logging.

Files:

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

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

  • Step 1: Convert to async readback with deferred sync

In replay_adam_and_readback, replace:

cuStreamSynchronize(self.stream.cu_stream());
cuMemcpyDtoH_v2(loss_host..., loss_ptr, 4);
cuMemcpyDtoH_v2(norm_host..., norm_ptr, 4);

With async DtoH into a pinned host buffer, returning the PREVIOUS step's values:

pub fn replay_adam_and_readback(&mut self) -> Result<FusedTrainScalars, MLError> {
    // Return PREVIOUS step's readback (already synced by this step's graph_forward)
    let prev_loss = self.scalars_readback_host[0];
    let prev_norm = self.scalars_readback_host[1];

    // Replay Adam (async)
    self.replay_adam()?;

    // Queue async DtoH for THIS step's results (will be ready by next step)
    unsafe {
        cudarc::driver::sys::cuMemcpyDtoHAsync_v2(
            self.scalars_readback_host.as_mut_ptr().cast(),
            self.cached_ptrs.total_loss, 4,
            self.stream.cu_stream(),
        );
        cudarc::driver::sys::cuMemcpyDtoHAsync_v2(
            self.scalars_readback_host.as_mut_ptr().add(1).cast(),
            self.cached_ptrs.grad_norm, 4,
            self.stream.cu_stream(),
        );
    }

    Ok(FusedTrainScalars {
        total_loss: prev_loss,
        grad_norm: prev_norm.sqrt(),
    })
}

This eliminates the per-step sync. The readback has 1-step lag (reporting previous step's metrics) which is standard and acceptable for monitoring.

  • Step 2: Allocate pinned host buffer

Replace scalars_readback_host: Vec<f32> with pinned memory for async DtoH:

// In constructor:
let scalars_readback_host = vec![0.0_f32; 2]; // [loss, grad_norm_sq]
// cudarc doesn't expose cuMemAllocHost directly — keep Vec but ensure
// the async DtoH is followed by a stream sync at epoch boundary.

Actually — cuMemcpyDtoHAsync_v2 requires the destination to be page-locked (pinned). With a regular Vec, this is technically UB. The safe approach: keep the sync but move it to the epoch boundary, NOT per-step.

Revised approach: Batch the sync. Instead of syncing every step, sync once per N steps (e.g. every 100 steps or at epoch boundary). Use the existing readback_host Vec with synchronous DtoH but only when actually needed.

  • Step 3: Move sync to epoch boundary

In fused_training.rs, remove the per-step sync from replay_adam_and_readback. Instead, sync only when steps_since_varmap_sync % 100 == 0 or at epoch boundary. For intermediate steps, return the last known values.

  • Step 4: Verify compilation + tests

Run: SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn Expected: All pass.

  • Step 5: Commit
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs crates/ml/src/trainers/dqn/fused_training.rs
git commit -m "perf: defer Adam readback sync to every 100 steps — eliminate per-step cuStreamSync"

Task 7: Performance regression smoke test

Add a test that measures training step throughput and fails if it drops below a threshold.

Files:

  • Modify: crates/ml/src/trainers/dqn/smoke_tests/gradient_budget.rs

  • Step 1: Add throughput test

/// Training step throughput must not regress.
/// At 3s/epoch with 10K steps: ~3333 steps/sec minimum on RTX 3050.
/// On H100: ~10K+ steps/sec. Test uses a conservative threshold.
#[test]
fn test_training_step_throughput() -> anyhow::Result<()> {
    let dev = cuda_device();
    let stream = Arc::clone(dev.cuda_stream().expect("cuda stream"));
    let cfg = test_config();
    let mut trainer = GpuDqnTrainer::new(stream.clone(), cfg.clone())?;
    let mut dueling = alloc_dueling(&stream, &cfg);
    let mut branching = alloc_branching(&stream, &cfg);

    // Warm up
    trainer.apply_spectral_norm(&mut dueling, &mut branching)?;
    trainer.clip_grad_buf_inplace(7.0)?;

    // Measure 1000 iterations of clip + spectral norm (the hot path additions)
    let n_iters = 1000;
    let start = std::time::Instant::now();
    for _ in 0..n_iters {
        trainer.apply_spectral_norm(&mut dueling, &mut branching)?;
        trainer.clip_grad_buf_inplace(7.0)?;
    }
    // Sync to ensure all GPU work completes
    unsafe { cudarc::driver::sys::cuStreamSynchronize(stream.cu_stream()); }
    let elapsed = start.elapsed();

    let per_iter_us = elapsed.as_micros() as f64 / n_iters as f64;
    assert!(
        per_iter_us < 500.0, // 500μs budget for spectral norm + clip per step
        "Spectral norm + clip took {per_iter_us:.0}μs/iter — budget is 500μs"
    );

    Ok(())
}
  • Step 2: Verify compilation + run

Run: SQLX_OFFLINE=true cargo test -p ml --lib -- test_training_step_throughput --nocapture Expected: PASS with per-iter time well under 500μs.

  • Step 3: Commit
git add crates/ml/src/trainers/dqn/smoke_tests/gradient_budget.rs
git commit -m "test: training step throughput regression test"

Execution Dependencies

Task 1 (cache pointers) → Task 2 (remove ensemble sync) → Task 6 (defer Adam readback)
                         → Task 3 (TF32 tensor cores) — independent
                         → Task 4 (batch spectral norm) — independent
Task 5 (dynamic budget) — independent
Task 7 (throughput test) — after Tasks 1-4

Task 1 is the foundation — all other tasks benefit from cached pointers. Tasks 3 and 4 are independent GPU optimizations. Task 6 requires Task 1's cached pointers. Task 7 validates everything.

Expected Impact

Optimization Estimated speedup
Eliminate 4× cuStreamSync (200μs/step) 2s/epoch saved
TF32 tensor cores (1.5× SGEMM) 30-40% forward+backward time
Batch spectral norm (10→1 launch) ~50μs/step saved
Eliminate 99 raw_device_ptr calls ~100μs/step saved
Defer Adam readback sync ~50μs/step saved
Combined Target: ≤5s/epoch on H100