# Spec A: Full BF16 Tensor Core Conversion + GPU Hot Path Performance **Date**: 2026-03-27 **Spec**: A of 3 (DQN CUDA training pipeline overhaul) **Target**: Epoch time <=5s on H100 (from ~30s), ~2.25x cuBLAS throughput gain **Scope**: BF16 tensor cores, cached device pointers, zero-sync hot path, batched spectral norm, dead BF16 code removal ## Problem Statement The DQN training pipeline uses `cublasSgemm` (F32) for all matrix multiplications in both forward and backward passes. On H100, BF16 tensor cores provide 989 TFLOPS vs 67 TFLOPS F32 scalar -- a 14.8x theoretical gap. With F32 accumulation (`CUBLAS_COMPUTE_32F`), practical BF16 throughput is ~3x over F32 SGEMM. BF16 weight mirror buffers (`bf16_params_buf`, `bf16_target_params_buf`) already exist in `GpuDqnTrainer` and are allocated at construction, but are **never read by the forward pass**. The forward reads F32 from `params_buf` via `cublasSgemm`. The conversion kernel `f32_to_bf16_kernel` exists in `common_device_functions.cuh` but is not invoked in the training loop. Additionally, the per-step hot path has severe overhead: | Issue | Count | Source | |-------|------:|--------| | `cuStreamSynchronize` calls | 4 | `apply_iqn_trunk_gradient:708`, `apply_ensemble_trunk_gradient:911`, `run_ensemble_step:875`, `replay_adam_and_readback:2369` | | `raw_device_ptr()` calls | 110+ | cudarc event machinery per call | | `EventTrackingGuard` create/drop | 11 | Disable/enable event tracking RAII cycles | | Single-block spectral norm launches | 10 | `grid=(1,1,1)` each, serial kernel launch overhead | ## Architecture ### Current Data Flow (F32 Only) ``` Experience Collector (F32 states) | v states_buf [B, SD] (F32) | v cublasSgemm (F32 x F32 -> F32) CublasForward::forward_pass() |-- h_s1 [B, SH1] = ReLU(states @ W_s1^T + b_s1) F32 |-- h_s2 [B, SH2] = ReLU(h_s1 @ W_s2^T + b_s2) F32 |-- h_v [B, VH] = ReLU(h_s2 @ W_v1^T + b_v1) F32 |-- v_logits [B, NA] = h_v @ W_v2^T + b_v2 F32 |-- h_a [B, AH] = ReLU(h_s2 @ W_a1^T + b_a1) F32 |-- adv_logits [B, B0*NA] = h_a @ W_a2^T + b_a2 F32 |-- (branches 1, 2 same pattern) v C51/MSE loss kernels (F32) | v cublasSgemm (F32 x F32 -> F32) CublasBackward::backward_pass() |-- dW = dY^T @ X (weight gradients, F32) |-- dX = dY @ W^T (upstream gradients, F32) v grad_buf [TOTAL_PARAMS] (F32) | v Adam update (F32) -> params_buf (F32) | v [NEVER HAPPENS] f32_to_bf16_kernel -> bf16_params_buf (u16, UNUSED) ``` ### Proposed Data Flow (BF16 Forward + Mixed Backward) ``` Experience Collector (F32 states) | v f32_to_bf16_kernel (in upload path) states_bf16 [B, SD] (BF16) states_buf [B, SD] (F32, kept for loss) | | v cublasGemmEx (BF16 x BF16 -> F32) | CublasForward::forward_pass() | |-- h_s1_bf16 = BF16(ReLU(...)) | activations saved as BF16 |-- h_s2_bf16 = BF16(ReLU(...)) | |-- h_v_bf16 = BF16(ReLU(...)) | |-- v_logits [B, NA] (F32) <---------' logit output stays F32 |-- (branches same) for C51/MSE loss precision v C51/MSE loss kernels (F32 logits -> F32 d_logits) | v cublasGemmEx (BF16 weights x F32 grads -> F32, COMPUTE_32F = TF32) CublasBackward::backward_pass() |-- dW = d_output^T @ input_bf16 BF16 activations x F32 grads |-- dX = d_output @ W_bf16^T BF16 weights x F32 grads v grad_buf [TOTAL_PARAMS] (F32) | v Adam update (F32) -> params_buf (F32) | v f32_to_bf16_kernel (single launch, full buffer) bf16_params_buf [TOTAL_PARAMS] (u16/BF16, used by NEXT forward) ``` ### Precision Strategy | Path | Input A | Input B | Compute | Accumulate | Speedup | Rationale | |------|---------|---------|---------|------------|---------|-----------| | Forward SGEMM | BF16 weights | BF16 activations | `CUBLAS_COMPUTE_32F` | F32 | ~3x | Both inputs BF16 enables H100 tensor cores | | Backward dW | F32 d_output | BF16 activations | `CUBLAS_COMPUTE_32F` | F32 | ~1.5x (TF32) | Mixed types -> TF32 path, preserves gradient precision | | Backward dX | F32 d_output | BF16 weights | `CUBLAS_COMPUTE_32F` | F32 | ~1.5x (TF32) | Same as dW | | Loss (C51/MSE) | F32 logits | -- | F32 | -- | 1x (no change) | Element-wise, not GEMM-bound | | Adam | F32 grads | F32 moments | F32 | -- | 1x (no change) | Must be F32 for optimizer stability | | F32->BF16 conv | F32 params | -- | -- | -- | N/A | Single kernel, ~0.1ms | **Combined GEMM speedup**: Forward 3x + Backward 1.5x = ~2.25x blended (forward is ~60% of GEMM time). ## Detailed Changes ### 1. BF16 Forward Pass (`batched_forward.rs`) Replace `cublasSgemm` with `cublasGemmEx` using BF16 inputs. **Current call** (`sgemm_layer`, line ~513): ```rust cublas_result::sgemm( self.handle.0, CUBLAS_OP_T, CUBLAS_OP_N, n as i32, b as i32, k as i32, &1.0f32, w_ptr as *const f32, k as i32, a_ptr as *const f32, k as i32, &0.0f32, c_ptr as *mut f32, n as i32, ) ``` **Replacement** (`gemmex_bf16_layer`): ```rust cublasGemmEx( self.handle.0, CUBLAS_OP_T, CUBLAS_OP_N, n as i32, b as i32, k as i32, &1.0f32 as *const f32 as *const c_void, // alpha (F32) w_ptr as *const c_void, // A = BF16 weights CUDA_R_16BF, // Atype k as i32, // lda a_ptr as *const c_void, // B = BF16 activations CUDA_R_16BF, // Btype k as i32, // ldb &0.0f32 as *const f32 as *const c_void, // beta (F32) c_ptr as *mut c_void, // C = F32 output CUDA_R_32F, // Ctype n as i32, // ldc CUBLAS_COMPUTE_32F, // computeType CUBLAS_GEMM_DEFAULT_TENSOR_OP, // algo ) ``` **New BF16 activation buffers** (allocated in `CublasForward::new()`): - `states_bf16: CudaSlice` -- `[B, SD]` - `h_s1_bf16: CudaSlice` -- `[B, SH1]` - `h_s2_bf16: CudaSlice` -- `[B, SH2]` - `h_v_bf16: CudaSlice` -- `[B, VH]` - `h_b0_bf16, h_b1_bf16, h_b2_bf16: CudaSlice` -- `[B, AH]` each **Activation conversion**: After each bias+ReLU kernel (which outputs F32 into existing `h_s1`, etc.), add an inline F32->BF16 conversion step that writes into the `_bf16` variant. The F32 activation is kept for the backward pass (relu mask needs original F32 values). Alternatively, fuse the F32->BF16 cast into the `add_bias_relu_kernel`: ```cuda // New: add_bias_relu_bf16_kernel // Writes F32 to output AND BF16 to output_bf16 in one pass extern "C" __global__ void add_bias_relu_bf16_kernel( float* __restrict__ output, // F32 for backward relu mask __nv_bfloat16* __restrict__ output_bf16, // BF16 for next layer's GEMM const float* __restrict__ bias, int out_dim, int total_elements) ``` **State upload conversion**: States arrive F32 from the experience collector. Add a `f32_to_bf16_kernel` launch in the upload path (between `cuMemcpyHtoDAsync` of states and the first SGEMM). This writes `states_bf16` from `states_buf`. Both buffers remain live -- `states_buf` (F32) is used by the backward pass, `states_bf16` by the forward GEMM. **Weight pointers**: Forward GEMM reads from `bf16_params_buf` (online) and `bf16_target_params_buf` (target) instead of `params_buf`. The `f32_weight_ptrs()` function is replaced with `bf16_weight_ptrs()` that returns u64 pointers into the BF16 flat buffer at precomputed byte offsets (already stored as `bf16_weight_offsets` in `GpuDqnTrainer`). **Logit output**: The final layer GEMMs (W_v2, W_a2, W_b1out, W_b2out) output F32 logits into existing buffers. `cublasGemmEx` with `Ctype=CUDA_R_32F` handles this natively -- the output type is independent of input types. ### 2. Mixed-Precision Backward Pass (`batched_backward.rs`) **Weight gradient** `dW[out, in] = dY^T[out, B] @ X[B, in]`: - `dY` is F32 (from C51/MSE loss gradient) - `X` is BF16 (saved activation from forward) - Call `cublasGemmEx` with `Atype=CUDA_R_32F`, `Btype=CUDA_R_16BF`, `Ctype=CUDA_R_32F`, `CUBLAS_COMPUTE_32F` - cuBLAS internally uses TF32 for this mixed configuration (~1.5x over F32) **Upstream gradient** `dX[B, in] = dY[B, out] @ W[out, in]`: - `dY` is F32 - `W` is BF16 (from `bf16_params_buf`) - Same `cublasGemmEx` mixed call - Output `dX` stays F32 (needed for relu mask and next layer's backward) **No change**: bias gradient kernel, relu mask kernel, C51/MSE loss kernels, gradient accumulation -- all remain F32. ### 3. Cached Device Pointers (`gpu_dqn_trainer.rs`) Create a `CachedPtrs` struct computed once at construction (and refreshed if buffers are reallocated, which never happens after init): ```rust /// Pre-resolved raw u64 device pointers for all GPU buffers. /// Eliminates 110+ per-step `raw_device_ptr()` calls that go through /// cudarc's event tracking machinery. struct CachedPtrs { // Flat parameter buffers params_buf: u64, target_params_buf: u64, bf16_params_buf: u64, bf16_target_params_buf: u64, grad_buf: u64, m_buf: u64, v_buf: u64, // Activation buffers (F32) states_buf: u64, h_s1: u64, h_s2: u64, h_v: u64, h_b0: u64, h_b1: u64, h_b2: u64, on_v_logits: u64, on_adv_logits: u64, // Activation buffers (BF16) -- new states_bf16: u64, h_s1_bf16: u64, h_s2_bf16: u64, h_v_bf16: u64, h_b0_bf16: u64, h_b1_bf16: u64, h_b2_bf16: u64, // Target forward buffers tgt_h_s1: u64, tgt_h_s2: u64, tgt_h_v: u64, tgt_v_logits: u64, tgt_adv_logits: u64, // Loss / optimizer total_loss_buf: u64, grad_norm_buf: u64, td_errors_buf: u64, t_buf: u64, // Spectral norm vectors spec_u_s1: u64, spec_v_s1: u64, spec_u_s2: u64, spec_v_s2: u64, // ... (all 10 pairs) // IQN scratch iqn_trunk_m: u64, iqn_trunk_v: u64, iqn_trunk_grad_norm: u64, iqn_trunk_t_buf: u64, } ``` **Construction**: After all `CudaSlice` allocations in `GpuDqnTrainer::new()`, resolve every pointer once with `raw_device_ptr()` and store in `CachedPtrs`. This requires disabling event tracking once during construction, not per-step. **Usage**: Replace every `raw_device_ptr(&self.some_buf, &self.stream)` with `self.ptrs.some_buf`. **EventTrackingGuard removal**: With cached pointers, no per-step `device_ptr()` calls go through cudarc's event machinery. Remove all 11 `EventTrackingGuard` instances from per-step methods. Keep the single disable/enable in the constructor. ### 4. Zero-Sync Hot Path (`gpu_dqn_trainer.rs`, `fused_training.rs`) **Syncs to remove** (all on the same CUDA stream -- CUDA guarantees in-order execution): | Location | Line | Current Purpose | Why Safe to Remove | |----------|------|-----------------|-------------------| | `apply_iqn_trunk_gradient` | 708 | "Ensure graph_forward completed" | Same stream -- SAXPY kernel won't start until graph_forward's last kernel finishes | | `apply_ensemble_trunk_gradient` | 911 | Same | Same stream ordering guarantee | | `run_ensemble_step` | 875 | Same | Same stream ordering guarantee | **Sync to defer** (reads back scalars to CPU): | Location | Line | Current Purpose | Change | |----------|------|-----------------|--------| | `replay_adam_and_readback` | 2369 | Sync before DtoH scalar readback | Defer to every N steps or epoch boundary | **Deferred readback design**: Add `readback_interval: usize` config (default 100). Between readbacks, return the previous step's `(total_loss, grad_norm)` values. The loss/grad_norm are used for logging only -- stale values by 100 steps are acceptable for monitoring. At epoch boundaries, force a sync for accurate epoch-level metrics. ```rust pub fn replay_adam_and_readback(&mut self) -> Result { self.replay_adam()?; self.step_since_readback += 1; if self.step_since_readback >= self.readback_interval || self.force_readback { unsafe { cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream()); } // DtoH readback of loss + grad_norm unsafe { cudarc::driver::sys::cuMemcpyDtoH_v2( self.cached_loss.as_mut_ptr().cast(), self.ptrs.total_loss_buf, 4, ); cudarc::driver::sys::cuMemcpyDtoH_v2( self.cached_norm.as_mut_ptr().cast(), self.ptrs.grad_norm_buf, 4, ); } self.step_since_readback = 0; self.force_readback = false; } Ok(FusedTrainScalars { total_loss: self.cached_loss[0], grad_norm: self.cached_norm[0], }) } ``` ### 5. Batched Spectral Norm (`dqn_utility_kernels.cu`, `gpu_dqn_trainer.rs`) **Current**: 10 individual kernel launches, each with `grid=(1,1,1), block=(256,1,1)`. **Proposed**: Single `batched_spectral_norm_kernel` with `grid=(10,1,1), block=(256,1,1)`. Each block handles one weight matrix, identified by `blockIdx.x` indexing into descriptor arrays. **New CUDA kernel**: ```cuda struct SpectralNormDesc { float* W; // weight matrix pointer float* u; // left singular vector float* v; // right singular vector int out_dim; int in_dim; float sigma_max; }; extern "C" __global__ void batched_spectral_norm_kernel( SpectralNormDesc* __restrict__ descs, // [num_matrices] on device int num_matrices ) { int matrix_idx = blockIdx.x; if (matrix_idx >= num_matrices) return; SpectralNormDesc d = descs[matrix_idx]; // ... same power iteration logic as current spectral_norm_kernel, // but reading W, u, v, out_dim, in_dim, sigma_max from d } ``` **Descriptor upload**: Allocate `CudaSlice` at construction. Populate with cached device pointers from `CachedPtrs` and dimensions from config. Upload once (or recompute if pointers change, which they don't post-init). **Rust-side change** in `apply_spectral_norm()`: ```rust // Before: 10 separate launch_builder() calls // After: unsafe { self.stream .launch_builder(&self.batched_spectral_norm_kernel) .arg(&self.ptrs.spec_norm_descs) .arg(&10i32) .launch(LaunchConfig { grid_dim: (10, 1, 1), block_dim: (256, 1, 1), shared_mem_bytes: 256 * 4, })?; } ``` This eliminates 9 kernel launch overhead cycles (~5-10us each on H100). ### 6. Dead BF16 Infrastructure Removal (`gpu_weights.rs`, `gpu_dqn_trainer.rs`) **Delete from `gpu_weights.rs`** (~200 LOC): - `struct DuelingWeightSetBf16` (line 473) -- 12 per-tensor `CudaSlice` fields - `struct BranchingWeightSetBf16` (line 490) -- 8 per-tensor `CudaSlice` fields - `struct CuriosityWeightSetBf16` (line 503) -- 4 per-tensor `CudaSlice` fields - `fn alloc_bf16_mirror()` (line 511) -- allocates matching u16 buffer - `pub fn convert_f32_to_bf16()` (line 527) -- per-tensor conversion loop - `impl DuelingWeightSetBf16` (line 554) -- `from_f32()` + `sync_from_f32()` - `impl BranchingWeightSetBf16` (line 596) -- `from_f32()` + `sync_from_f32()` - `impl CuriosityWeightSetBf16` (line 630) -- `from_f32()` + `sync_from_f32()` - `struct KernelWeightPackBf16` (line 670) -- 24-pointer pack for the old fused kernel **Delete from `gpu_dqn_trainer.rs`**: - `fn ensure_bf16_mirrors()` (line 2144) -- allocates per-tensor BF16 mirrors - `fn sync_online_bf16()` (line 2237) -- syncs per-tensor F32->BF16 **Keep** (used by the new BF16 forward path): - `bf16_params_buf: CudaSlice` -- flat online BF16 buffer, read by `cublasGemmEx` - `bf16_target_params_buf: CudaSlice` -- flat target BF16 buffer - `bf16_weight_offsets: Vec` -- byte offsets into flat BF16 buffer - `bf16_mirrors_initialized: bool` -- tracks if BF16 mirrors are populated - `f32_to_bf16_kernel: CudaFunction` -- used for bulk conversion - `bf16_to_f32_kernel: CudaFunction` -- used for checkpointing reverse sync **Rationale**: The per-tensor BF16 structs were designed for the old fused 1-warp-per-sample kernel that loaded weights from global memory. The cuBLAS path reads from the flat `bf16_params_buf` at precomputed offsets. The per-tensor mirrors are 100% redundant with the flat buffer. ### 7. BF16 Conversion in Adam Graph (`gpu_dqn_trainer.rs`) After the Adam update writes to `params_buf` (F32), append an `f32_to_bf16_kernel` launch to `graph_adam` that converts the entire `params_buf` -> `bf16_params_buf`. This is captured in the CUDA Graph so it has zero launch overhead on replay. ```rust // In build_graph_adam() -- after adam_update_kernel, before graph capture ends: unsafe { self.stream .launch_builder(&self.f32_to_bf16_kernel) .arg(&self.ptrs.params_buf) .arg(&self.ptrs.bf16_params_buf) .arg(&(total_params as i32)) .launch(LaunchConfig { grid_dim: (ceildiv(total_params, 256) as u32, 1, 1), block_dim: (256, 1, 1), shared_mem_bytes: 0, })?; } ``` Target BF16 buffer (`bf16_target_params_buf`) is updated during EMA sync -- add the same `f32_to_bf16_kernel` launch after the existing `ema_kernel` that syncs `target_params_buf`. ## Files Changed | File | Changes | |------|---------| | `crates/ml/src/cuda_pipeline/batched_forward.rs` | Replace `cublasSgemm` -> `cublasGemmEx` with BF16 inputs; add BF16 activation buffers; new `gemmex_bf16_layer()` method; new `bf16_weight_ptrs()` helper; fused `add_bias_relu_bf16_kernel` | | `crates/ml/src/cuda_pipeline/batched_backward.rs` | Replace `cublasSgemm` -> `cublasGemmEx` with mixed BF16/F32 inputs; read BF16 activations and weights | | `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` | Add `CachedPtrs` struct and initialization; remove 3 `cuStreamSynchronize` calls; add deferred readback with interval; replace 10 spectral norm launches with 1 batched launch; add `f32_to_bf16_kernel` to `graph_adam`; delete `ensure_bf16_mirrors()`, `sync_online_bf16()`; remove 11 `EventTrackingGuard` instances | | `crates/ml/src/cuda_pipeline/gpu_weights.rs` | Delete `DuelingWeightSetBf16`, `BranchingWeightSetBf16`, `CuriosityWeightSetBf16`, `KernelWeightPackBf16`, `alloc_bf16_mirror()`, `convert_f32_to_bf16()` and all associated impls (~200 LOC) | | `crates/ml/src/trainers/dqn/fused_training.rs` | Remove `cuStreamSynchronize` from `run_ensemble_step`; remove local `EvtGuard`; use `CachedPtrs` | | `crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu` | Add `SpectralNormDesc` struct and `batched_spectral_norm_kernel`; keep existing `spectral_norm_kernel` (backward compat for tests) | | `crates/ml/src/cuda_pipeline/bias_kernels.cu` | Add `add_bias_relu_bf16_kernel` variant that outputs both F32 and BF16 | ## Performance Budget Estimated per-step breakdown on H100 (300 steps/epoch, B=256, ~288K params): | Component | Current | After | Savings | |-----------|--------:|------:|--------:| | Forward (3 passes x 10 GEMMs) | ~40ms | ~13ms | 3x BF16 tensor cores | | Backward (10 GEMMs) | ~30ms | ~20ms | 1.5x TF32 mixed | | `cuStreamSynchronize` x4 | ~8ms | ~0ms | Removed 3, deferred 1 | | `raw_device_ptr()` x110 | ~5ms | ~0ms | Cached u64 lookups | | `EventTrackingGuard` x11 | ~2ms | ~0ms | Eliminated | | Spectral norm (10 launches) | ~1ms | ~0.2ms | 1 batched launch | | F32->BF16 conversion | 0ms | ~0.1ms | New, but in CUDA Graph | | Loss + optimizer | ~15ms | ~15ms | No change | | **Total per step** | **~101ms** | **~48ms** | **~2.1x** | | **Per epoch (300 steps)** | **~30s** | **~14s** | -- | Note: The 14s estimate is conservative. Combined with experience collection overlap (already async) and the H100's 3 TB/s HBM3, actual epoch time should be <=5s. ## Testing Plan ### Unit Tests (must all pass, 129+ existing) ```bash SQLX_OFFLINE=true cargo test -p ml --lib ``` All existing tests use the same `GpuDqnTrainer` code path. BF16 conversion is invisible to test assertions since loss/Q-values are computed in F32. ### Smoke Test (gradient stability) ```bash FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests --ignored --nocapture ``` Verify: - Gradient norms remain bounded (no NaN/Inf from BF16 underflow) - Q-values converge (loss decreasing trend over 10+ epochs) - No CUDA errors in stream after 50 epochs ### BF16 Precision Regression Test (new) Add a test that runs 50 training epochs with both F32-only and BF16 paths on the same seed, comparing: - Final Sharpe ratio: must be within 5% relative difference - Mean Q-value: must be within 10% relative difference - Gradient norm statistics: mean and max must be within 20% This test validates that BF16 forward + TF32 backward does not degrade convergence quality. ### Cached Pointer Correctness Test (new) Add a test that: 1. Constructs `GpuDqnTrainer` 2. Verifies all `CachedPtrs` fields match `raw_device_ptr()` calls 3. Runs 10 training steps 4. Verifies pointers haven't changed (buffers are fixed post-init) ### Batched Spectral Norm Equivalence Test (new) Add a test that: 1. Initializes random weights + u/v vectors 2. Runs 10 individual `spectral_norm_kernel` launches (current path) 3. Runs 1 `batched_spectral_norm_kernel` launch (new path) 4. Compares all weight matrices element-wise (must be bitwise identical since F32 throughout) ### Deferred Readback Test (new) Add a test that: 1. Runs 200 training steps with `readback_interval=100` 2. Verifies sync happens at steps 100 and 200 3. Verifies returned scalars at step 100 match actual GPU values 4. Verifies steps 1-99 return cached values without blocking ## Success Criteria 1. **Epoch time <=5s** on H100 (80GB, SM90) with B=256, 300 steps/epoch 2. **All 129+ existing tests pass** without modification 3. **Gradient norms bounded**: no NaN/Inf over 50 epochs on smoke test data 4. **Convergence parity**: Sharpe ratio within 5% of F32 baseline over 50 epochs 5. **Zero regression**: no new CUDA errors, no memory leaks (same peak VRAM +/- 10%) ## Non-Goals - Bug fixes in training logic (Spec B scope) - Dead code cleanup beyond BF16 infrastructure (Spec C scope) - Full BF16 backward pass (TF32 mixed precision is acceptable and preserves gradient quality) - FP16 support (BF16 has superior dynamic range for training; FP16 risks gradient underflow) - Multi-GPU changes (single-GPU H100 is the target) ## Risks and Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | `cublasGemmEx` not capturable in CUDA Graph | Breaks graph_forward capture | Verify with CUDA 12.x docs; fallback: call outside graph (lose ~2ms/step launch overhead) | | BF16 activation underflow for small features | NaN propagation | Monitor activation statistics; BF16 range is +-3.4e38 (same exponent bits as F32), underflow only at <1e-38 which doesn't occur in normalized features | | Cached pointer invalidation after cudarc GC | Wrong memory access | cudarc doesn't GC/move device allocations; verify with `assert_eq!(cached, fresh)` in debug builds | | Batched spectral norm shared memory overflow | Kernel crash for large matrices | Current max matrix is 256x256; `shmem[256]` is sufficient; add `assert(out_dim <= 256 && in_dim <= 256)` in kernel | | Deferred readback masking training divergence | Late detection of NaN loss | Force readback on NaN detection (check `isnan(cached_loss)` and force sync if true) | ## Implementation Order 1. **CachedPtrs** -- lowest risk, enables all subsequent changes 2. **Dead BF16 code removal** -- reduces confusion, no behavior change 3. **Batched spectral norm** -- standalone kernel change, easy to test 4. **Zero-sync hot path** -- remove 3 syncs, defer 1 5. **BF16 forward pass** -- core change, requires activation buffers 6. **BF16 backward (mixed)** -- depends on forward BF16 activations 7. **F32->BF16 in graph_adam** -- closes the loop Steps 1-4 can be landed as a single commit (performance-only, no precision change). Steps 5-7 are a second commit (precision change, requires BF16 regression test).