perf: cuBLAS SGEMM pipeline + dead code elimination — 37s → 86ms/epoch (430x)

Phase 2: Replace 1-warp/sample fused kernels with cuBLAS SGEMM batched forward/backward.
- batched_forward.rs: cuBLAS SGEMM forward (10 GEMM + bias/ReLU per pass)
- batched_backward.rs: cuBLAS SGEMM backward (chain rule via GEMM, no atomicAdd)
- c51_loss_kernel.cu: standalone C51 distributional loss (256 threads, 2KB shmem)
- c51_grad_kernel: dL/d_logits with dueling routing for cuBLAS backward
- BF16 alignment fix: pad offsets to even for short2 vectorized loads
- Training step: 10.7ms → 0.7ms (15x) on RTX 3050

Phase 3: Unified cuBLAS Q-forward + dead code elimination (-4,400 lines net).
- Rewrite experience collector: timestep loop + cuBLAS replaces monolithic 3,272-line kernel
- Delete dqn_training_kernel.cu (1,385 lines) — replaced by dqn_utility_kernels.cu (118 lines)
- Delete dqn_experience_kernel.cu (3,272 lines) — replaced by experience_kernels.cu (656 lines)
- Remove BF16 warp-matvec helpers from common_device_functions.cuh (-159 lines)
- Remove dead methods/fields from GpuDqnTrainer (-500 lines)
- Experience collection: 348ms → 12ms (29x) on RTX 3050
- No fallback paths — cuBLAS is the only Q-forward implementation
- All 1,514 tests pass, GPU smoke test verified with real data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-21 15:33:00 +01:00
parent e1b8b46255
commit c2d116dfdf
14 changed files with 4762 additions and 6241 deletions

View File

@@ -0,0 +1,892 @@
#![allow(unsafe_code)]
//! cuBLAS SGEMM-based batched backward pass for the DQN trainer.
//!
//! Replaces the 1-warp-per-sample `dqn_backward_kernel` with cuBLAS matrix
//! multiplications that process the entire batch in a single GEMM call per
//! layer. This eliminates `atomicAdd` serialization on the 288K gradient
//! buffer and raises GPU occupancy from 1.56% to >60% on H100.
//!
//! ## Layout convention
//!
//! All tensors use row-major (C-style) layout, same as the forward pass:
//! - `dY`: [B, out_dim] — upstream gradient
//! - `X`: [B, in_dim] — saved activation (input to this layer)
//! - `W`: [out_dim, in_dim] — weight matrix (row-major)
//! - `dW`: [out_dim, in_dim] — weight gradient accumulator
//!
//! ## cuBLAS row-major → col-major mapping
//!
//! ### Weight gradient `dW[out, in] = dY^T[out, B] @ X[B, in]`
//!
//! cuBLAS works column-major. We observe the dual:
//! - `dW'[in, out]` col-major = `X'[in, B]` col-major @ `dY'^T[B, out]`
//! - Row-major `dW[out, in]` shares the same memory as col-major `dW'[in, out]`.
//! - Row-major `X[B, in]` shares memory with col-major `X'[in, B]`.
//! - Row-major `dY[B, out]` shares memory with col-major `dY'[out, B]`.
//!
//! Therefore:
//! ```text
//! sgemm(N, T, m=in_dim, n=out_dim, k=B,
//! 1.0, X, lda=in_dim,
//! dY, ldb=out_dim,
//! 1.0, dW, ldc=in_dim)
//! ```
//! The `beta=1.0` means ACCUMULATE into `dW` (not overwrite), which is required
//! since multiple layers share the same flat `grad_buf`.
//!
//! ### Upstream gradient `dX[B, in] = dY[B, out] @ W[out, in]`
//!
//! ```text
//! sgemm(N, N, m=in_dim, n=B, k=out_dim,
//! 1.0, W, lda=in_dim,
//! dY, ldb=out_dim,
//! 0.0, dX, ldc=in_dim)
//! ```
//! Here `beta=0.0` because `dX` is a fresh scratch buffer per layer.
//!
//! ## Bias gradient
//!
//! `db[out] = sum_b(dY[b, out])` — a row-wise reduction.
//! Implemented as a custom NVRTC kernel (`bias_grad_reduce_kernel`):
//! each thread owns one output neuron and sums over the batch dimension.
//! Uses `atomicAdd` into `grad_buf[bias_offset + j]` so it accumulates
//! safely alongside the weight gradient GEMM.
//!
//! ## ReLU derivative mask
//!
//! After computing `dX = W @ dY`, gate by saved pre-ReLU activation:
//! `dX[i] *= (activation[i] > 0)`.
//! Implemented as `relu_mask_kernel` (1 thread per element).
//!
//! ## Phase status
//!
//! This module provides the building blocks for Phase 2 Task 2. The full
//! cuBLAS backward path requires a standalone C51 loss kernel that emits
//! `dL/d_logits` for each branch. Until that kernel exists, the existing
//! `dqn_backward_kernel` (atomicAdd path) remains active. The functions
//! here are wired via `GpuDqnTrainer::launch_cublas_backward()` with a
//! TODO gate that enables them once the C51 loss kernel is ready.
use std::mem::ManuallyDrop;
use std::sync::Arc;
use cudarc::cublas::result as cublas_result;
use cudarc::cublas::sys as cublas_sys;
use cudarc::driver::{CudaFunction, CudaSlice, CudaStream, DevicePtr, LaunchConfig, PushKernelArg};
use crate::MLError;
use super::gpu_dqn_trainer::GpuDqnTrainConfig;
// ── cuBLAS handle wrapper ─────────────────────────────────────────────────────
/// Wrapper to make `cublasHandle_t` Send + Sync.
///
/// Safety reasoning is identical to `CublasForward`: the handle is owned
/// exclusively by `CublasBackward`, which is in turn owned by `GpuDqnTrainer`
/// — a single-threaded owner in practice.
struct SendSyncCublasHandle(cublas_sys::cublasHandle_t);
unsafe impl Send for SendSyncCublasHandle {}
unsafe impl Sync for SendSyncCublasHandle {}
impl Drop for SendSyncCublasHandle {
fn drop(&mut self) {
unsafe {
let _ = cublas_result::destroy_handle(self.0);
}
}
}
// ── CublasBackward context ────────────────────────────────────────────────────
/// cuBLAS-based batched backward pass context for the branching dueling DQN.
///
/// Holds the cuBLAS handle bound to the trainer's stream plus two small NVRTC
/// kernels for the operations that cuBLAS cannot express directly (bias
/// gradient reduction and ReLU derivative masking).
///
/// ## Usage
///
/// 1. Call `backward_fc_layer()` in reverse layer order, passing the upstream
/// gradient `dY`, the saved forward activation `X`, the weight matrix `W`,
/// and gradient accumulator offsets into the flat `grad_buf`.
/// 2. After each weight layer (except the first), call `relu_mask()` to gate
/// the computed upstream gradient by the saved pre-ReLU activation.
/// 3. For the final full backward, call `backward_full()` which orchestrates
/// all layers automatically.
#[allow(missing_debug_implementations)] // handle is not Debug
pub struct CublasBackward {
handle: SendSyncCublasHandle,
/// `relu_mask_kernel(dx, activation, n)` — element-wise ReLU derivative gate.
relu_mask_kernel: CudaFunction,
/// `bias_grad_reduce_kernel(dy, db, out_dim, batch_size)` — reduce dY over batch.
bias_grad_kernel: CudaFunction,
// ── Network dimensions (baked at construction) ──
batch_size: usize,
state_dim: usize,
shared_h1: usize,
shared_h2: usize,
value_h: usize,
adv_h: usize,
num_atoms: usize,
branch_0_size: usize,
branch_1_size: usize,
branch_2_size: usize,
}
impl CublasBackward {
/// Create a new cuBLAS backward context.
///
/// Compiles the `relu_mask_kernel` and `bias_grad_reduce_kernel` from
/// inline NVRTC source. Binds the cuBLAS handle to the provided stream
/// so all GEMMs execute in-order on the same stream as the forward pass.
pub fn new(
stream: &Arc<CudaStream>,
config: &GpuDqnTrainConfig,
) -> Result<Self, MLError> {
// ── Create cuBLAS handle ────────────────────────────────────
let raw_handle = cublas_result::create_handle()
.map_err(|e| MLError::ModelError(format!("cublasCreate (backward): {e:?}")))?;
// Bind handle to stream — all backward GEMMs execute on this stream.
// SAFETY: same cast as CublasForward::new — CUstream pointers are
// identical across cudarc::driver::sys and cudarc::cublas::sys.
unsafe {
let cu_stream = stream.cu_stream() as *mut cublas_sys::CUstream_st;
cublas_result::set_stream(raw_handle, cu_stream)
.map_err(|e| MLError::ModelError(format!("cublasSetStream (backward): {e:?}")))?;
}
// ── Compile helper kernels ──────────────────────────────────
let (relu_mask_kernel, bias_grad_kernel) = compile_backward_kernels(stream)?;
Ok(Self {
handle: SendSyncCublasHandle(raw_handle),
relu_mask_kernel,
bias_grad_kernel,
batch_size: config.batch_size,
state_dim: config.state_dim,
shared_h1: config.shared_h1,
shared_h2: config.shared_h2,
value_h: config.value_h,
adv_h: config.adv_h,
num_atoms: config.num_atoms,
branch_0_size: config.branch_0_size,
branch_1_size: config.branch_1_size,
branch_2_size: config.branch_2_size,
})
}
/// Rebind the cuBLAS handle to a (potentially new) stream.
///
/// Must be called before CUDA Graph capture when the stream changes,
/// matching the convention in `CublasForward::set_stream`.
pub fn set_stream(&self, stream: &CudaStream) -> Result<(), MLError> {
unsafe {
let cu_stream = stream.cu_stream() as *mut cublas_sys::CUstream_st;
cublas_result::set_stream(self.handle.0, cu_stream)
.map_err(|e| MLError::ModelError(format!("cublasSetStream (backward): {e:?}")))
}
}
// ══════════════════════════════════════════════════════════════════════════
// Public building blocks
// ══════════════════════════════════════════════════════════════════════════
/// Backward through one fully-connected layer using cuBLAS SGEMM.
///
/// Computes:
/// - `dW += dY^T @ X` (weight gradient, accumulated with beta=1.0)
/// - `db += sum_b(dY)` (bias gradient, accumulated via atomicAdd)
/// - `dX = dY @ W^T` (upstream gradient, overwritten with beta=0.0)
///
/// This does NOT apply the ReLU derivative mask; call `relu_mask()` after
/// if the layer had a ReLU activation.
///
/// ## Arguments
///
/// - `dy` : raw u64 device pointer to `dY[B, out_dim]` (upstream gradient)
/// - `x` : raw u64 device pointer to `X[B, in_dim]` (saved input activation)
/// - `w` : raw u64 device pointer to `W[out_dim, in_dim]` (weight matrix)
/// - `dw` : raw u64 device pointer to `dW[out_dim, in_dim]` in `grad_buf`
/// - `db` : raw u64 device pointer to `db[out_dim]` in `grad_buf`
/// - `dx` : raw u64 device pointer to `dX[B, in_dim]` scratch (may be 0
/// if upstream gradient is not needed, e.g. input layer)
/// - `out_dim` : number of neurons in this layer's output
/// - `in_dim` : number of neurons in this layer's input
/// - `batch` : batch size B
#[allow(clippy::too_many_arguments)]
pub fn backward_fc_layer(
&self,
stream: &Arc<CudaStream>,
dy: u64,
x: u64,
w: u64,
dw: u64,
db: u64,
dx: u64, // 0 means skip upstream gradient computation
out_dim: usize,
in_dim: usize,
batch: usize,
) -> Result<(), MLError> {
// ── Weight gradient: dW[out, in] += dY^T[out, B] @ X[B, in] ──
//
// Row-major dW[out,in] viewed col-major as dW'[in,out]:
// dW'[in,out] = X'[in,B] @ dY'^T[B,out]
// => sgemm(N, T, m=in_dim, n=out_dim, k=B, X, lda=in_dim, dY, ldb=out_dim, dW, ldc=in_dim)
// beta=1.0 to ACCUMULATE (shared grad_buf with bias kernel)
unsafe {
let alpha = 1.0_f32;
let beta_acc = 1.0_f32; // accumulate
cublas_result::sgemm(
self.handle.0,
cublas_sys::cublasOperation_t::CUBLAS_OP_N, // no transpose on X'
cublas_sys::cublasOperation_t::CUBLAS_OP_T, // transpose dY'
in_dim as i32, // m: rows of op(X') = in_dim
out_dim as i32, // n: cols of op(dY'^T) = out_dim
batch as i32, // k: inner dim = B
&alpha,
x as *const f32,
in_dim as i32, // lda: leading dim of X' = in_dim
dy as *const f32,
out_dim as i32, // ldb: leading dim of dY' = out_dim
&beta_acc,
dw as *mut f32,
in_dim as i32, // ldc: leading dim of dW' = in_dim
)
.map_err(|e| MLError::ModelError(format!("backward sgemm dW: {e:?}")))?;
}
// ── Bias gradient: db[out] += sum_b(dY[b, out]) ──────────────
self.launch_bias_grad(stream, dy, db, out_dim, batch)?;
// ── Upstream gradient: dX[B, in] = dY[B, out] @ W[out, in] ──
// Skip when dx == 0 (input layer — states are not trainable).
if dx != 0 {
// Row-major dX[B,in] viewed col-major as dX'[in,B]:
// dX'[in,B] = W'[in,out] @ dY'[out,B]
// => sgemm(N, N, m=in_dim, n=B, k=out_dim, W, lda=in_dim, dY, ldb=out_dim, dX, ldc=in_dim)
// beta=0.0: overwrite dX scratch for this layer
unsafe {
let alpha = 1.0_f32;
let beta_zero = 0.0_f32;
cublas_result::sgemm(
self.handle.0,
cublas_sys::cublasOperation_t::CUBLAS_OP_N, // no transpose on W'
cublas_sys::cublasOperation_t::CUBLAS_OP_N, // no transpose on dY'
in_dim as i32, // m: rows of W' = in_dim
batch as i32, // n: cols of dY' = B
out_dim as i32, // k: inner dim = out_dim
&alpha,
w as *const f32,
in_dim as i32, // lda: leading dim of W' = in_dim
dy as *const f32,
out_dim as i32, // ldb: leading dim of dY' = out_dim
&beta_zero,
dx as *mut f32,
in_dim as i32, // ldc: leading dim of dX' = in_dim
)
.map_err(|e| MLError::ModelError(format!("backward sgemm dX: {e:?}")))?;
}
}
Ok(())
}
/// Apply the ReLU derivative mask in-place: `dx[i] *= (activation[i] > 0)`.
///
/// Called after `backward_fc_layer()` for layers that used ReLU activation.
/// The `activation` argument is the saved post-ReLU value from the forward
/// pass (i.e. `save_h_*`). For ReLU, `activation[i] > 0` iff the pre-ReLU
/// value was positive, so we can use the post-ReLU value directly.
///
/// Grid: `ceil(n / 256)`, Block: 256.
pub fn relu_mask(
&self,
stream: &Arc<CudaStream>,
dx: u64,
activation: u64,
n: usize,
) -> Result<(), MLError> {
let n_i32 = n as i32;
let blocks = ((n + 255) / 256) as u32;
unsafe {
stream
.launch_builder(&self.relu_mask_kernel)
.arg(&dx)
.arg(&activation)
.arg(&n_i32)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("relu_mask_kernel: {e}")))?;
}
Ok(())
}
// ══════════════════════════════════════════════════════════════════════════
// Full backward pass
// ══════════════════════════════════════════════════════════════════════════
/// Full backward pass through the branching dueling network via cuBLAS SGEMM.
///
/// Requires pre-computed `dL/d_logits` for each head from the C51 loss kernel.
/// Until the standalone C51 loss kernel exists (Phase 2 Task 2), this function
/// is gated behind a TODO comment in `GpuDqnTrainer::launch_cublas_backward`.
///
/// ## Backward order (reverse of forward)
///
/// For each branch d in {0, 1, 2}:
/// 1. Branch output layer: `d_adv_logits_d` → `h_bd`
/// 2. Branch FC layer: `h_bd` gradient → `h_s2` (accumulated)
///
/// Value head:
/// 3. Value output layer: `d_value_logits` → `h_v`
/// 4. Value FC layer: `h_v` gradient → `h_s2` (accumulated)
///
/// Shared trunk (using accumulated `d_h_s2`):
/// 5. Shared layer 2: `d_h_s2` → `h_s1`
/// 6. Shared layer 1: `d_h_s1` → (states — skip, not trainable)
///
/// ## Arguments
///
/// - `d_value_logits` : `[B, NA]` gradient from C51 loss for the value head
/// - `d_adv_logits` : three `[B, branch_k * NA]` gradients for each branch
/// - `states` : `[B, SD]` raw input states (for last shared layer dW)
/// - `save_h_s1` : `[B, SH1]` saved shared layer 1 ReLU output
/// - `save_h_s2` : `[B, SH2]` saved shared layer 2 ReLU output
/// - `save_h_v` : `[B, VH]` saved value FC ReLU output
/// - `save_h_b` : three `[B, AH]` saved branch FC ReLU outputs
/// - `w_ptrs` : 20-element array of raw F32 device pointers into
/// `params_buf` (online weights, read-only)
/// - `grad_buf` : flat gradient accumulator `[TOTAL_PARAMS]` — must be
/// zeroed before this call
/// - `scratch_dh` : scratch buffers for layer-to-layer gradient passing
#[allow(clippy::too_many_arguments)]
pub fn backward_full(
&self,
stream: &Arc<CudaStream>,
// dL/d_logits from C51 loss kernel
d_value_logits: u64, // [B, NA]
d_adv_logits: &[u64; 3], // [B, branch_k * NA] each
// Saved forward activations
states: u64, // [B, SD]
save_h_s1: u64, // [B, SH1]
save_h_s2: u64, // [B, SH2]
save_h_v: u64, // [B, VH]
save_h_b: &[u64; 3], // [B, AH] each
// Online weight pointers (read-only for W^T computation)
w_ptrs: &[u64; 20],
// Flat gradient accumulator (must be zeroed by caller)
grad_buf_base: u64,
// Scratch buffers for inter-layer gradients
scratch_d_h_s2: u64, // [B, SH2] — accumulated from value + all 3 branches
scratch_d_h_s1: u64, // [B, SH1]
scratch_d_h_v: u64, // [B, VH]
scratch_d_h_b: &[u64; 3], // [B, AH] each
) -> Result<(), MLError> {
let b = self.batch_size;
let na = self.num_atoms;
// ── Precomputed grad_buf offsets (match GOFF_* in dqn_training_kernel.cu) ──
//
// Layout: [w_s1, b_s1, w_s2, b_s2, w_v1, b_v1, w_v2, b_v2,
// w_b0fc, b_b0fc, w_b0out, b_b0out,
// w_b1fc, b_b1fc, w_b1out, b_b1out,
// w_b2fc, b_b2fc, w_b2out, b_b2out]
//
// The w_ptrs array (online weight pointers from params_buf) uses the
// same GOFF_* layout, so goff_ptr(i) == w_ptrs[i]. We store grad
// offsets as byte distances from grad_buf_base. Since param_sizes
// determines element counts, grad byte offsets are:
// goff_bytes[i] = sum_{j<i} param_sizes[j] * sizeof(f32)
//
// Rather than recompute these here, we leverage the fact that
// params_buf and grad_buf have the same GOFF_* layout. The grad
// pointer for tensor i is: grad_buf_base + f32_byte_offset(i).
//
// We compute these inline from the config dimensions.
let f32 = std::mem::size_of::<f32>() as u64;
let sd = self.state_dim as u64;
let sh1 = self.shared_h1 as u64;
let sh2 = self.shared_h2 as u64;
let vh = self.value_h as u64;
let ah = self.adv_h as u64;
let na64 = na as u64;
let b0 = self.branch_0_size as u64;
let b1 = self.branch_1_size as u64;
let b2 = self.branch_2_size as u64;
// GOFF byte offsets (must match compute_param_sizes order)
let goff_w_s1: u64 = 0;
let goff_b_s1: u64 = goff_w_s1 + sh1 * sd * f32;
let goff_w_s2: u64 = goff_b_s1 + sh1 * f32;
let goff_b_s2: u64 = goff_w_s2 + sh2 * sh1 * f32;
let goff_w_v1: u64 = goff_b_s2 + sh2 * f32;
let goff_b_v1: u64 = goff_w_v1 + vh * sh2 * f32;
let goff_w_v2: u64 = goff_b_v1 + vh * f32;
let goff_b_v2: u64 = goff_w_v2 + na64 * vh * f32;
let goff_w_b0fc: u64 = goff_b_v2 + na64 * f32;
let goff_b_b0fc: u64 = goff_w_b0fc + ah * sh2 * f32;
let goff_w_b0out: u64 = goff_b_b0fc + ah * f32;
let goff_b_b0out: u64 = goff_w_b0out + b0 * na64 * ah * f32;
let goff_w_b1fc: u64 = goff_b_b0out + b0 * na64 * f32;
let goff_b_b1fc: u64 = goff_w_b1fc + ah * sh2 * f32;
let goff_w_b1out: u64 = goff_b_b1fc + ah * f32;
let goff_b_b1out: u64 = goff_w_b1out + b1 * na64 * ah * f32;
let goff_w_b2fc: u64 = goff_b_b1out + b1 * na64 * f32;
let goff_b_b2fc: u64 = goff_w_b2fc + ah * sh2 * f32;
let goff_w_b2out: u64 = goff_b_b2fc + ah * f32;
let goff_b_b2out: u64 = goff_w_b2out + b2 * na64 * ah * f32;
let goff_w_bfc = [goff_w_b0fc, goff_w_b1fc, goff_w_b2fc];
let goff_b_bfc = [goff_b_b0fc, goff_b_b1fc, goff_b_b2fc];
let goff_w_bout = [goff_w_b0out, goff_w_b1out, goff_w_b2out];
let goff_b_bout = [goff_b_b0out, goff_b_b1out, goff_b_b2out];
let branch_n = [
self.branch_0_size * na,
self.branch_1_size * na,
self.branch_2_size * na,
];
// w_ptrs indices:
// [0]=w_s1, [1]=b_s1, [2]=w_s2, [3]=b_s2,
// [4]=w_v1, [5]=b_v1, [6]=w_v2, [7]=b_v2,
// [8]=w_b0fc, [9]=b_b0fc, [10]=w_b0out, [11]=b_b0out,
// [12]=w_b1fc, [13]=b_b1fc, [14]=w_b1out, [15]=b_b1out,
// [16]=w_b2fc, [17]=b_b2fc, [18]=w_b2out, [19]=b_b2out
let w_bfc_idx = [8_usize, 12, 16];
let w_bout_idx = [10_usize, 14, 18];
// ══════════════════════════════════════════════════════════════════
// BRANCHES (all 3, in parallel with shared h_s2 accumulation)
// ══════════════════════════════════════════════════════════════════
for d in 0..3 {
let n_d = branch_n[d];
let w_out = w_ptrs[w_bout_idx[d]]; // W_bdk_out[n_d, AH]
let w_fc = w_ptrs[w_bfc_idx[d]]; // W_bdk_fc[AH, SH2]
// ── Branch output layer (no ReLU — logit layer) ──────────
// dY = d_adv_logits[d] [B, n_d]
// X = save_h_b[d] [B, AH]
// W = W_bdk_out [n_d, AH]
// dW += dY^T @ X, db += sum(dY), dX_b = dY @ W^T
self.backward_fc_layer(
stream,
d_adv_logits[d], // dY [B, n_d]
save_h_b[d], // X [B, AH]
w_out, // W [n_d, AH]
grad_buf_base + goff_w_bout[d], // dW
grad_buf_base + goff_b_bout[d], // db
scratch_d_h_b[d], // dX [B, AH]
n_d, // out_dim
self.adv_h, // in_dim
b,
)?;
// No ReLU mask needed — branch output is a logit layer.
// ── Branch FC layer (ReLU activation) ────────────────────
// dY = scratch_d_h_b[d] [B, AH]
// X = save_h_s2 [B, SH2]
// W = W_bdk_fc [AH, SH2]
// dW += dY^T @ X, db += sum(dY), dX_s2 written to scratch_d_h_s2
//
// NOTE: We accumulate contributions from all 3 branches into
// scratch_d_h_s2. To do this safely, we use beta=1.0 in the
// backward_fc_layer call. However, backward_fc_layer always uses
// beta=0.0 for dX. We therefore use a temporary per-branch dX
// and manually add it into scratch_d_h_s2.
//
// For simplicity, we write into scratch_d_h_b[d] (reuse — output
// layer has already consumed it) and then accumulate.
//
// Actually, scratch_d_h_b[d] is already holding the branch FC
// upstream gradient dX that we need to produce. We write it
// there, apply ReLU, then accumulate into scratch_d_h_s2.
//
// So we need a separate branch_dh_s2 scratch. For now, use
// scratch_d_h_b[d] as the per-branch dh_s2 temporary: after the
// branch out backward we no longer need d_h_b[d] for anything,
// so we can repurpose it. But its size is [B, AH], not [B, SH2].
// We cannot reuse it directly.
//
// Solution: run the branch FC backward writing dX into
// scratch_d_h_s2 with beta=0 first, then for branches 1 and 2
// we need to add rather than overwrite. We handle this by
// setting dx=0 for the fc layer and doing the upstream gradient
// via a separate GEMM call with beta=1.0 for branches d>0.
// Apply ReLU mask to branch FC upstream: d_h_b[d] *= (save_h_b[d] > 0)
self.relu_mask(
stream,
scratch_d_h_b[d], // dx to gate
save_h_b[d], // saved post-ReLU activation
b * self.adv_h,
)?;
// dW for branch FC: dW[AH, SH2] += d_h_b[d]^T @ h_s2
// (dX is computed separately below to allow accumulation)
self.launch_dw_only(
stream,
scratch_d_h_b[d], // dY [B, AH]
save_h_s2, // X [B, SH2]
grad_buf_base + goff_w_bfc[d], // dW [AH, SH2]
grad_buf_base + goff_b_bfc[d], // db [AH]
self.adv_h, // out_dim
self.shared_h2, // in_dim
b,
)?;
// dX for branch FC: d_h_s2 += d_h_b[d] @ W_bdk_fc
// Use beta = if d==0 { 0.0 } else { 1.0 } to accumulate branches.
let beta_s2 = if d == 0 { 0.0_f32 } else { 1.0_f32 };
self.launch_dx_only(
stream,
scratch_d_h_b[d], // dY [B, AH]
w_fc, // W [AH, SH2]
scratch_d_h_s2, // dX [B, SH2]
self.adv_h, // out_dim
self.shared_h2, // in_dim
b,
beta_s2,
)?;
}
// ══════════════════════════════════════════════════════════════════
// VALUE HEAD
// ══════════════════════════════════════════════════════════════════
// ── Value output layer (no ReLU) ─────────────────────────────
// dY = d_value_logits [B, NA], X = save_h_v [B, VH], W = W_v2 [NA, VH]
self.backward_fc_layer(
stream,
d_value_logits,
save_h_v,
w_ptrs[6], // W_v2 [NA, VH]
grad_buf_base + goff_w_v2,
grad_buf_base + goff_b_v2,
scratch_d_h_v, // dX [B, VH]
na,
self.value_h,
b,
)?;
// ── Value FC layer (ReLU) ─────────────────────────────────────
self.relu_mask(stream, scratch_d_h_v, save_h_v, b * self.value_h)?;
// dW for value FC: dW[VH, SH2] += d_h_v^T @ h_s2
self.launch_dw_only(
stream,
scratch_d_h_v,
save_h_s2,
grad_buf_base + goff_w_v1,
grad_buf_base + goff_b_v1,
self.value_h,
self.shared_h2,
b,
)?;
// dX for value FC: accumulated into scratch_d_h_s2 (beta=1.0 — branches already wrote)
self.launch_dx_only(
stream,
scratch_d_h_v,
w_ptrs[4], // W_v1 [VH, SH2]
scratch_d_h_s2,
self.value_h,
self.shared_h2,
b,
1.0_f32, // accumulate on top of branch contributions
)?;
// ══════════════════════════════════════════════════════════════════
// SHARED TRUNK (backward through accumulated d_h_s2)
// ══════════════════════════════════════════════════════════════════
// ── Shared layer 2 (ReLU) ─────────────────────────────────────
self.relu_mask(stream, scratch_d_h_s2, save_h_s2, b * self.shared_h2)?;
self.backward_fc_layer(
stream,
scratch_d_h_s2,
save_h_s1,
w_ptrs[2], // W_s2 [SH2, SH1]
grad_buf_base + goff_w_s2,
grad_buf_base + goff_b_s2,
scratch_d_h_s1, // dX [B, SH1]
self.shared_h2,
self.shared_h1,
b,
)?;
// ── Shared layer 1 (ReLU) — input layer, no dX needed ────────
self.relu_mask(stream, scratch_d_h_s1, save_h_s1, b * self.shared_h1)?;
// dW for shared layer 1: states is the input
self.backward_fc_layer(
stream,
scratch_d_h_s1,
states,
w_ptrs[0], // W_s1 [SH1, SD]
grad_buf_base + goff_w_s1,
grad_buf_base + goff_b_s1,
0, // dx=0: states are not trainable
self.shared_h1,
self.state_dim,
b,
)?;
let _ = goff_b_b2out; // suppress dead_code for last computed offset
Ok(())
}
// ══════════════════════════════════════════════════════════════════════════
// Private SGEMM helpers
// ══════════════════════════════════════════════════════════════════════════
/// Compute only weight gradient + bias gradient (no upstream dX).
///
/// Used for branch FC layers where the upstream dX is computed separately
/// via `launch_dx_only` so multiple branches can accumulate into the
/// shared `d_h_s2` scratch buffer with controllable beta.
#[allow(clippy::too_many_arguments)]
fn launch_dw_only(
&self,
stream: &Arc<CudaStream>,
dy: u64,
x: u64,
dw: u64,
db: u64,
out_dim: usize,
in_dim: usize,
batch: usize,
) -> Result<(), MLError> {
// dW[out, in] += dY^T @ X (same mapping as in backward_fc_layer)
unsafe {
let alpha = 1.0_f32;
let beta_acc = 1.0_f32;
cublas_result::sgemm(
self.handle.0,
cublas_sys::cublasOperation_t::CUBLAS_OP_N,
cublas_sys::cublasOperation_t::CUBLAS_OP_T,
in_dim as i32,
out_dim as i32,
batch as i32,
&alpha,
x as *const f32,
in_dim as i32,
dy as *const f32,
out_dim as i32,
&beta_acc,
dw as *mut f32,
in_dim as i32,
)
.map_err(|e| MLError::ModelError(format!("backward sgemm dW_only: {e:?}")))?;
}
self.launch_bias_grad(stream, dy, db, out_dim, batch)
}
/// Compute only upstream gradient dX = dY @ W^T, with configurable beta.
///
/// `beta=0.0` overwrites dX; `beta=1.0` accumulates into dX. Used to merge
/// contributions from multiple branches into the shared `d_h_s2` buffer.
#[allow(clippy::too_many_arguments)]
fn launch_dx_only(
&self,
_stream: &Arc<CudaStream>,
dy: u64,
w: u64,
dx: u64,
out_dim: usize,
in_dim: usize,
batch: usize,
beta: f32,
) -> Result<(), MLError> {
// dX[B, in] = dY[B, out] @ W[out, in]
// col-major: dX'[in,B] = W'[in,out] @ dY'[out,B]
// sgemm(N, N, m=in_dim, n=B, k=out_dim, W, in_dim, dY, out_dim, beta, dX, in_dim)
unsafe {
let alpha = 1.0_f32;
cublas_result::sgemm(
self.handle.0,
cublas_sys::cublasOperation_t::CUBLAS_OP_N,
cublas_sys::cublasOperation_t::CUBLAS_OP_N,
in_dim as i32,
batch as i32,
out_dim as i32,
&alpha,
w as *const f32,
in_dim as i32,
dy as *const f32,
out_dim as i32,
&beta,
dx as *mut f32,
in_dim as i32,
)
.map_err(|e| MLError::ModelError(format!("backward sgemm dX_only: {e:?}")))?;
}
Ok(())
}
/// Launch `bias_grad_reduce_kernel`: db[j] += sum_b(dY[b * out_dim + j]).
fn launch_bias_grad(
&self,
stream: &Arc<CudaStream>,
dy: u64,
db: u64,
out_dim: usize,
batch: usize,
) -> Result<(), MLError> {
let out_dim_i32 = out_dim as i32;
let batch_i32 = batch as i32;
let blocks = ((out_dim + 255) / 256) as u32;
unsafe {
stream
.launch_builder(&self.bias_grad_kernel)
.arg(&dy)
.arg(&db)
.arg(&out_dim_i32)
.arg(&batch_i32)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("bias_grad_reduce_kernel: {e}")))?;
}
Ok(())
}
}
// ── Kernel compilation ────────────────────────────────────────────────────────
/// Compile `relu_mask_kernel` and `bias_grad_reduce_kernel` from inline NVRTC.
///
/// Both kernels are simple element-wise or reduction operations:
/// - `relu_mask_kernel`: 1 thread per element, gating dx by saved activation.
/// - `bias_grad_reduce_kernel`: 1 thread per output neuron, summing over batch.
///
/// These are NOT captured in the CUDA Graph because their launch overhead
/// (< 1 µs per kernel) is negligible compared to the GEMM time. For CUDA Graph
/// compatibility, they would need to be added to the captured region, but this
/// optimisation is deferred until Phase 2 Task 3.
fn compile_backward_kernels(
stream: &Arc<CudaStream>,
) -> Result<(CudaFunction, CudaFunction), MLError> {
let src = r#"
// ReLU derivative mask: dx[i] *= (activation[i] > 0.0f)
//
// `activation` is the SAVED POST-ReLU value from the forward pass.
// For standard ReLU, post-ReLU > 0 iff pre-ReLU > 0, so the saved
// post-ReLU value is a valid proxy for the derivative gate.
//
// Grid: ceil(n / 256), Block: 256
extern "C" __global__ void relu_mask_kernel(
float* __restrict__ dx,
const float* __restrict__ activation,
int n)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= n) return;
if (activation[i] <= 0.0f) dx[i] = 0.0f;
}
// Bias gradient: db[j] += sum_b dy[b * out_dim + j]
//
// Each thread owns one output neuron j and iterates over the batch.
// Uses atomicAdd so that multiple kernel calls (from different layers
// sharing the same grad_buf) accumulate correctly.
//
// Grid: ceil(out_dim / 256), Block: 256
extern "C" __global__ void bias_grad_reduce_kernel(
const float* __restrict__ dy,
float* __restrict__ db,
int out_dim,
int batch_size)
{
int j = blockIdx.x * blockDim.x + threadIdx.x;
if (j >= out_dim) return;
float sum = 0.0f;
for (int b = 0; b < batch_size; b++) {
sum += dy[b * out_dim + j];
}
atomicAdd(&db[j], sum);
}
"#;
let context = stream.context();
let ptx = crate::cuda_pipeline::compile_ptx_for_device(src, &context)
.map_err(|e| MLError::ModelError(format!("backward kernel compilation: {e}")))?;
let module = context
.load_module(ptx)
.map_err(|e| MLError::ModelError(format!("backward kernel module load: {e}")))?;
let relu_mask = module
.load_function("relu_mask_kernel")
.map_err(|e| MLError::ModelError(format!("relu_mask_kernel load: {e}")))?;
let bias_grad = module
.load_function("bias_grad_reduce_kernel")
.map_err(|e| MLError::ModelError(format!("bias_grad_reduce_kernel load: {e}")))?;
Ok((relu_mask, bias_grad))
}
// ── Raw device pointer helpers ────────────────────────────────────────────────
//
// Mirrors the helpers in batched_forward.rs. Declared here so this module
// can be compiled independently without `pub use`-ing the forward module's
// private functions.
/// Extract raw F32 device pointer from a CudaSlice (read-only).
pub(crate) fn raw_f32_ptr(slice: &CudaSlice<f32>, stream: &Arc<CudaStream>) -> u64 {
let (ptr, guard) = slice.device_ptr(stream);
let _no_drop = ManuallyDrop::new(guard);
ptr
}
// ── Scratch buffer allocation helper ─────────────────────────────────────────
/// Allocate zero-initialised scratch buffers for the cuBLAS backward pass.
///
/// Called from `GpuDqnTrainer::new()` alongside the other buffer allocations.
/// Returns `(d_h_s2, d_h_s1, d_h_v, d_h_b0, d_h_b1, d_h_b2)`.
pub fn alloc_backward_scratch(
stream: &Arc<CudaStream>,
config: &GpuDqnTrainConfig,
) -> Result<(
CudaSlice<f32>, // d_h_s2 [B, SH2]
CudaSlice<f32>, // d_h_s1 [B, SH1]
CudaSlice<f32>, // d_h_v [B, VH]
CudaSlice<f32>, // d_h_b0 [B, AH]
CudaSlice<f32>, // d_h_b1 [B, AH]
CudaSlice<f32>, // d_h_b2 [B, AH]
), MLError> {
let b = config.batch_size;
let alloc = |n: usize| -> Result<CudaSlice<f32>, MLError> {
stream.alloc_zeros::<f32>(n)
.map_err(|e| MLError::ModelError(format!("backward scratch alloc [{n}]: {e}")))
};
Ok((
alloc(b * config.shared_h2)?,
alloc(b * config.shared_h1)?,
alloc(b * config.value_h)?,
alloc(b * config.adv_h)?,
alloc(b * config.adv_h)?,
alloc(b * config.adv_h)?,
))
}

View File

@@ -0,0 +1,688 @@
#![allow(unsafe_code)]
//! cuBLAS SGEMM-based batched forward pass for the DQN trainer.
//!
//! Replaces the 1-warp-per-sample fused kernel with cuBLAS matrix multiplications
//! that process the entire batch in a single GEMM call per layer. This raises GPU
//! occupancy from ~1.56% to >60% on H100 by eliminating the per-sample block
//! bottleneck caused by 134 KB shared memory usage.
//!
//! ## Layout convention
//!
//! All tensors use row-major (C-style) layout:
//! - States: [B, SD] — B rows, SD columns
//! - Weights: [out_dim, in_dim] — stored as GOFF_* in the flat params_buf
//!
//! cuBLAS is column-major, so we use the standard row-major trick:
//! For `C[B, N] = A[B, K] @ W[N, K]^T`:
//! - cuBLAS sees A as col-major [K, B] (A^T)
//! - cuBLAS sees W as col-major [K, N] (W^T)
//! - We call: sgemm(CUBLAS_OP_T, CUBLAS_OP_N, N, B, K, 1.0, W, K, A, K, 0.0, C, N)
//! - Result C is col-major [N, B] which == row-major C[B, N] ✓
//!
//! ## Network forward order (online and target — same layers, different weights)
//!
//! ```text
//! h_s1[B, SH1] = ReLU(states[B, SD] @ W_s1[SH1, SD]^T + b_s1[SH1])
//! h_s2[B, SH2] = ReLU(h_s1[B, SH1] @ W_s2[SH2, SH1]^T + b_s2[SH2])
//!
//! h_v[B, VH] = ReLU(h_s2[B, SH2] @ W_v1[VH, SH2]^T + b_v1[VH])
//! v_logits[B, NA] = h_v[B, VH] @ W_v2[NA, VH]^T + b_v2[NA]
//!
//! For each branch d in {0, 1, 2}:
//! h_bd[B, AH] = ReLU(h_s2[B, SH2] @ W_bdk_fc[AH, SH2]^T + b_bdk_fc[AH])
//! adv_logits_d[B, BD*NA] = h_bd[B, AH] @ W_bdk_out[BD*NA, AH]^T + b_bdk_out[BD*NA]
//! ```
//!
//! The C51 distributional loss (log_softmax + cross-entropy with Bellman projection)
//! is computed by a separate NVRTC kernel that reads these activation buffers.
//!
//! ## cuBLAS CUDA Graph compatibility
//!
//! `cublasSgemm` is capturable in CUDA 12+ stream-capture mode.
//! The cuBLAS handle must be set to the capture stream via `cublasSetStream` before
//! capture begins. Handle creation itself is NOT capturable and must happen before.
use std::sync::Arc;
use std::mem::ManuallyDrop;
use cudarc::cublas::result as cublas_result;
use cudarc::cublas::sys as cublas_sys;
use cudarc::driver::{CudaFunction, CudaSlice, CudaStream, DevicePtr, LaunchConfig, PushKernelArg};
use crate::MLError;
// ── cuBLAS handle wrapper ─────────────────────────────────────────────────────
/// Wrapper to make `cublasHandle_t` Send + Sync.
///
/// cuBLAS handles are not thread-safe by default, but `CublasForward` is
/// owned exclusively by `GpuDqnTrainer` which is single-threaded in practice.
struct SendSyncCublasHandle(cublas_sys::cublasHandle_t);
unsafe impl Send for SendSyncCublasHandle {}
unsafe impl Sync for SendSyncCublasHandle {}
impl Drop for SendSyncCublasHandle {
fn drop(&mut self) {
unsafe {
let _ = cublas_result::destroy_handle(self.0);
}
}
}
// ── Batched forward context ───────────────────────────────────────────────────
/// cuBLAS-based batched forward pass context for the branching dueling DQN.
///
/// Holds the cuBLAS handle and references to the trainer's pre-allocated
/// activation buffers. GEMM output lands directly in those buffers.
///
/// The handle is set to the trainer's stream so all GEMMs execute in order.
#[allow(missing_debug_implementations)] // handle is not Debug
pub struct CublasForward {
handle: SendSyncCublasHandle,
// ── Compiled bias+activation kernels (loaded from the DQN module) ──
/// Kernel: `add_bias_relu(output, bias, out_dim, total_elements)`
/// Grid: ceil(B * out_dim / 256), Block: 256.
add_bias_relu_kernel: CudaFunction,
/// Kernel: `add_bias(output, bias, out_dim, total_elements)`
/// Same shape as add_bias_relu but no clamping (used for final logit layers).
add_bias_kernel: CudaFunction,
// ── Network dimensions (baked at construction) ──
batch_size: usize,
state_dim: usize,
shared_h1: usize,
shared_h2: usize,
value_h: usize,
adv_h: usize,
num_atoms: usize,
branch_0_size: usize,
branch_1_size: usize,
branch_2_size: usize,
}
impl CublasForward {
/// Create a new cuBLAS forward context.
///
/// Compiles the `add_bias_relu` and `add_bias` kernels from inline NVRTC source.
/// Binds the cuBLAS handle to the provided stream.
pub fn new(
stream: &Arc<CudaStream>,
batch_size: usize,
state_dim: usize,
shared_h1: usize,
shared_h2: usize,
value_h: usize,
adv_h: usize,
num_atoms: usize,
branch_0_size: usize,
branch_1_size: usize,
branch_2_size: usize,
) -> Result<Self, MLError> {
// ── Create cuBLAS handle ────────────────────────────────────────
let raw_handle = cublas_result::create_handle()
.map_err(|e| MLError::ModelError(format!("cublasCreate: {e:?}")))?;
// Bind handle to stream — ALL GEMMs will execute on this stream.
// SAFETY: cudarc::driver::sys and cudarc::cublas::sys both typedef
// CUstream to *mut CUstream_st, which is the same underlying type.
// The cast is safe as long as the pointer value is preserved.
unsafe {
let cu_stream = stream.cu_stream() as *mut cublas_sys::CUstream_st;
cublas_result::set_stream(raw_handle, cu_stream)
.map_err(|e| MLError::ModelError(format!("cublasSetStream: {e:?}")))?;
}
// ── Compile bias kernels ────────────────────────────────────────
let (add_bias_relu_kernel, add_bias_kernel) =
compile_bias_kernels(stream)?;
Ok(Self {
handle: SendSyncCublasHandle(raw_handle),
add_bias_relu_kernel,
add_bias_kernel,
batch_size,
state_dim,
shared_h1,
shared_h2,
value_h,
adv_h,
num_atoms,
branch_0_size,
branch_1_size,
branch_2_size,
})
}
/// Rebind the cuBLAS handle to a (potentially new) stream.
///
/// Must be called before CUDA Graph capture when the stream changes.
pub fn set_stream(&self, stream: &CudaStream) -> Result<(), MLError> {
unsafe {
let cu_stream = stream.cu_stream() as *mut cublas_sys::CUstream_st;
cublas_result::set_stream(self.handle.0, cu_stream)
.map_err(|e| MLError::ModelError(format!("cublasSetStream: {e:?}")))
}
}
// ══════════════════════════════════════════════════════════════════════════
// Forward pass (online network — saves activations for backward)
// ══════════════════════════════════════════════════════════════════════════
/// Run the online network forward pass using cuBLAS SGEMM.
///
/// Writes into the trainer's pre-allocated activation save buffers:
/// - `save_h_s1`: [B, SH1] — shared trunk layer 1 activations
/// - `save_h_s2`: [B, SH2] — shared trunk layer 2 activations
/// - `save_h_v`: [B, VH] — value head hidden activations
/// - `save_h_b0`: [B, AH] — branch 0 hidden activations
/// - `save_h_b1`: [B, AH] — branch 1 hidden activations
/// - `save_h_b2`: [B, AH] — branch 2 hidden activations
///
/// Value logits → written to `v_logits_buf` [B, NA]
/// Branch logits → written to `b_logits_buf` [B, (B0+B1+B2)*NA]
///
/// Weight pointers are raw u64 into the flat F32 `params_buf` at GOFF_* offsets.
#[allow(clippy::too_many_arguments)]
pub fn forward_online(
&self,
stream: &Arc<CudaStream>,
// ── Inputs ──────────────────────────────────────────────────────
states_buf: &CudaSlice<f32>, // [B, SD]
// ── F32 weight pointers (raw u64 into flat params_buf) ─────────
w_ptrs: &[u64; 20], // [w_s1, b_s1, w_s2, b_s2, ...]
// ── Activation save buffers (written by this function) ─────────
save_h_s1: &CudaSlice<f32>, // [B, SH1]
save_h_s2: &CudaSlice<f32>, // [B, SH2]
save_h_v: &CudaSlice<f32>, // [B, VH]
save_h_b0: &CudaSlice<f32>, // [B, AH]
save_h_b1: &CudaSlice<f32>, // [B, AH]
save_h_b2: &CudaSlice<f32>, // [B, AH]
// ── Logit output buffers ────────────────────────────────────────
v_logits_buf: &CudaSlice<f32>, // [B, NA] — value head logits
b_logits_buf: &CudaSlice<f32>, // [B, (B0+B1+B2)*NA] — branch logits
) -> Result<(), MLError> {
let b = self.batch_size;
// ── Shared trunk layer 1: h_s1[B, SH1] = ReLU(states @ W_s1^T + b_s1) ──
self.sgemm_layer(
stream,
w_ptrs[0], // W_s1[SH1, SD]
raw_f32_ptr(states_buf, stream),
raw_f32_ptr_mut(save_h_s1, stream),
self.shared_h1, b, self.state_dim,
"h_s1",
)?;
self.launch_add_bias_relu(stream, save_h_s1, w_ptrs[1], self.shared_h1, b)?; // b_s1
// ── Shared trunk layer 2: h_s2[B, SH2] = ReLU(h_s1 @ W_s2^T + b_s2) ──
self.sgemm_layer(
stream,
w_ptrs[2], // W_s2[SH2, SH1]
raw_f32_ptr(save_h_s1, stream),
raw_f32_ptr_mut(save_h_s2, stream),
self.shared_h2, b, self.shared_h1,
"h_s2",
)?;
self.launch_add_bias_relu(stream, save_h_s2, w_ptrs[3], self.shared_h2, b)?; // b_s2
// ── Value head layer 1: h_v[B, VH] = ReLU(h_s2 @ W_v1^T + b_v1) ──
self.sgemm_layer(
stream,
w_ptrs[4], // W_v1[VH, SH2]
raw_f32_ptr(save_h_s2, stream),
raw_f32_ptr_mut(save_h_v, stream),
self.value_h, b, self.shared_h2,
"h_v",
)?;
self.launch_add_bias_relu(stream, save_h_v, w_ptrs[5], self.value_h, b)?; // b_v1
// ── Value head layer 2: v_logits[B, NA] = h_v @ W_v2^T + b_v2 ──
self.sgemm_layer(
stream,
w_ptrs[6], // W_v2[NA, VH]
raw_f32_ptr(save_h_v, stream),
raw_f32_ptr_mut(v_logits_buf, stream),
self.num_atoms, b, self.value_h,
"v_logits",
)?;
self.launch_add_bias(stream, v_logits_buf, w_ptrs[7], self.num_atoms, b)?; // b_v2
// ── Branch heads ─────────────────────────────────────────────────────
let branch_sizes = [self.branch_0_size, self.branch_1_size, self.branch_2_size];
let branch_save_h = [save_h_b0, save_h_b1, save_h_b2];
// Weight indices: [w_b0fc=8, b_b0fc=9, w_b0out=10, b_b0out=11,
// w_b1fc=12, b_b1fc=13, w_b1out=14, b_b1out=15,
// w_b2fc=16, b_b2fc=17, w_b2out=18, b_b2out=19]
let branch_w_base = [8_usize, 12, 16];
let b_logits_ptr = raw_f32_ptr_mut(b_logits_buf, stream);
let na = self.num_atoms;
let mut logit_byte_offset: u64 = 0;
for d in 0..3 {
let n_d = branch_sizes[d];
let w_fc_idx = branch_w_base[d];
let b_fc_idx = w_fc_idx + 1;
let w_out_idx = w_fc_idx + 2;
let b_out_idx = w_fc_idx + 3;
// h_bd[B, AH] = ReLU(h_s2 @ W_bdk_fc^T + b_bdk_fc)
self.sgemm_layer(
stream,
w_ptrs[w_fc_idx], // W_bdk_fc[AH, SH2]
raw_f32_ptr(save_h_s2, stream),
raw_f32_ptr_mut(branch_save_h[d], stream),
self.adv_h, b, self.shared_h2,
"h_bd",
)?;
self.launch_add_bias_relu(stream, branch_save_h[d], w_ptrs[b_fc_idx], self.adv_h, b)?;
// adv_logits_d[B, n_d*NA] = h_bd @ W_bdk_out^T + b_bdk_out
// Output pointer: offset into b_logits_buf by (sum of prior branch atoms) * B * 4 bytes
let adv_out_ptr = b_logits_ptr + logit_byte_offset;
self.sgemm_layer_raw(
stream,
w_ptrs[w_out_idx], // W_bdk_out[n_d*NA, AH]
raw_f32_ptr(branch_save_h[d], stream),
adv_out_ptr,
n_d * na, b, self.adv_h,
"adv_logits",
)?;
// Bias for adv logits: b_logits_buf at current offset as a slice
// We use raw add_bias on the pointer + offset
self.launch_add_bias_raw(stream, adv_out_ptr, w_ptrs[b_out_idx], n_d * na, b)?;
logit_byte_offset += (b * n_d * na * std::mem::size_of::<f32>()) as u64;
}
Ok(())
}
/// Run the target network forward pass (inference only — no activation saves).
///
/// Same GEMM sequence as `forward_online` but writes to separate output buffers
/// and does NOT save h_s2 (it's not needed for the backward pass through target).
///
/// Outputs:
/// - `tg_h_s2_buf`: [B, SH2] — target trunk output (needed for Bellman target computation)
/// - `tg_h_v_buf`: [B, VH] — target value head hidden (scratch, reused)
/// - `tg_v_logits_buf`: [B, NA] — target value head logits
/// - `tg_b_logits_buf`: [B, (B0+B1+B2)*NA] — target branch logits
///
/// The caller also needs to run the target network on next_states for DDQN.
/// We write tg_h_s2_buf here so the caller can inspect it if needed.
#[allow(clippy::too_many_arguments)]
pub fn forward_target(
&self,
stream: &Arc<CudaStream>,
// ── Inputs ──────────────────────────────────────────────────────
next_states_buf: &CudaSlice<f32>, // [B, SD]
// ── F32 weight pointers (raw u64 into flat target_params_buf) ──
tg_w_ptrs: &[u64; 20],
// ── Scratch activation buffers (not saved for backward) ────────
tg_h_s1_scratch: &CudaSlice<f32>, // [B, SH1] scratch
tg_h_s2_buf: &CudaSlice<f32>, // [B, SH2] kept for Double-DQN
tg_h_v_scratch: &CudaSlice<f32>, // [B, VH] scratch
tg_h_b_scratch: &CudaSlice<f32>, // [B, AH] scratch (reused for all 3 branches)
// ── Output buffers ──────────────────────────────────────────────
tg_v_logits_buf: &CudaSlice<f32>, // [B, NA]
tg_b_logits_buf: &CudaSlice<f32>, // [B, (B0+B1+B2)*NA]
) -> Result<(), MLError> {
let b = self.batch_size;
// h_s1[B, SH1]
self.sgemm_layer(
stream,
tg_w_ptrs[0],
raw_f32_ptr(next_states_buf, stream),
raw_f32_ptr_mut(tg_h_s1_scratch, stream),
self.shared_h1, b, self.state_dim,
"tg_h_s1",
)?;
self.launch_add_bias_relu(stream, tg_h_s1_scratch, tg_w_ptrs[1], self.shared_h1, b)?;
// h_s2[B, SH2]
self.sgemm_layer(
stream,
tg_w_ptrs[2],
raw_f32_ptr(tg_h_s1_scratch, stream),
raw_f32_ptr_mut(tg_h_s2_buf, stream),
self.shared_h2, b, self.shared_h1,
"tg_h_s2",
)?;
self.launch_add_bias_relu(stream, tg_h_s2_buf, tg_w_ptrs[3], self.shared_h2, b)?;
// h_v[B, VH]
self.sgemm_layer(
stream,
tg_w_ptrs[4],
raw_f32_ptr(tg_h_s2_buf, stream),
raw_f32_ptr_mut(tg_h_v_scratch, stream),
self.value_h, b, self.shared_h2,
"tg_h_v",
)?;
self.launch_add_bias_relu(stream, tg_h_v_scratch, tg_w_ptrs[5], self.value_h, b)?;
// v_logits[B, NA]
self.sgemm_layer(
stream,
tg_w_ptrs[6],
raw_f32_ptr(tg_h_v_scratch, stream),
raw_f32_ptr_mut(tg_v_logits_buf, stream),
self.num_atoms, b, self.value_h,
"tg_v_logits",
)?;
self.launch_add_bias(stream, tg_v_logits_buf, tg_w_ptrs[7], self.num_atoms, b)?;
// Branch heads — reuse tg_h_b_scratch for all 3 (sequential, no aliasing)
let branch_sizes = [self.branch_0_size, self.branch_1_size, self.branch_2_size];
let branch_w_base = [8_usize, 12, 16];
let b_logits_ptr = raw_f32_ptr_mut(tg_b_logits_buf, stream);
let na = self.num_atoms;
let mut logit_byte_offset: u64 = 0;
for d in 0..3 {
let n_d = branch_sizes[d];
let w_fc_idx = branch_w_base[d];
self.sgemm_layer(
stream,
tg_w_ptrs[w_fc_idx],
raw_f32_ptr(tg_h_s2_buf, stream),
raw_f32_ptr_mut(tg_h_b_scratch, stream),
self.adv_h, b, self.shared_h2,
"tg_h_bd",
)?;
self.launch_add_bias_relu(stream, tg_h_b_scratch, tg_w_ptrs[w_fc_idx + 1], self.adv_h, b)?;
let adv_out_ptr = b_logits_ptr + logit_byte_offset;
self.sgemm_layer_raw(
stream,
tg_w_ptrs[w_fc_idx + 2],
raw_f32_ptr(tg_h_b_scratch, stream),
adv_out_ptr,
n_d * na, b, self.adv_h,
"tg_adv_logits",
)?;
self.launch_add_bias_raw(stream, adv_out_ptr, tg_w_ptrs[w_fc_idx + 3], n_d * na, b)?;
logit_byte_offset += (b * n_d * na * std::mem::size_of::<f32>()) as u64;
}
Ok(())
}
// ══════════════════════════════════════════════════════════════════════════
// cuBLAS SGEMM helpers
// ══════════════════════════════════════════════════════════════════════════
/// Single cuBLAS SGEMM: C[B, N] = A[B, K] @ W[N, K]^T (row-major).
///
/// Call convention (column-major cuBLAS, row-major data):
///
/// sgemm(CUBLAS_OP_T, CUBLAS_OP_N, N, B, K, 1.0, W, K, A, K, 0.0, C, N)
///
/// This outputs C in col-major [N, B] which equals row-major C[B, N]. ✓
///
/// Arguments:
/// - `w_ptr` : raw device pointer to W[N, K] (row-major, stride K)
/// - `a_ptr` : raw device pointer to A[B, K] (row-major, stride K)
/// - `c_buf` : output CudaSlice<f32> [B * N] (written in col-major [N, B])
/// - `n` : output cols (out_dim)
/// - `b` : batch size (rows of A / cols of output in col-major)
/// - `k` : inner dim (in_dim = cols of W = cols of A)
#[allow(clippy::too_many_arguments)]
fn sgemm_layer(
&self,
_stream: &Arc<CudaStream>,
w_ptr: u64,
a_ptr: u64,
c_ptr: u64,
n: usize,
b: usize,
k: usize,
_label: &str,
) -> Result<(), MLError> {
let alpha = 1.0_f32;
let beta = 0.0_f32;
// SAFETY: w_ptr, a_ptr, c_ptr are valid CUDA device pointers in the same
// context. Sizes are computed from config values that were validated at
// construction. The cuBLAS handle is bound to the correct stream.
unsafe {
cublas_result::sgemm(
self.handle.0,
cublas_sys::cublasOperation_t::CUBLAS_OP_T, // transa: transpose W
cublas_sys::cublasOperation_t::CUBLAS_OP_N, // transb: keep A as-is
n as i32, // m (rows of op(W) = N = out_dim)
b as i32, // n (cols of op(A) = B = batch)
k as i32, // k (inner dim)
&alpha,
w_ptr as *const f32,
k as i32, // lda: leading dim of W (before transpose) = K
a_ptr as *const f32,
k as i32, // ldb: leading dim of A = K
&beta,
c_ptr as *mut f32,
n as i32, // ldc: leading dim of C = N
)
.map_err(|e| MLError::ModelError(format!("cublasSgemm {_label}: {e:?}")))?;
}
Ok(())
}
/// Same as `sgemm_layer` but takes raw output pointer directly.
///
/// Used when writing into a sub-region of a larger buffer (branch logits).
#[allow(clippy::too_many_arguments)]
fn sgemm_layer_raw(
&self,
_stream: &Arc<CudaStream>,
w_ptr: u64,
a_ptr: u64,
c_ptr: u64,
n: usize,
b: usize,
k: usize,
_label: &str,
) -> Result<(), MLError> {
self.sgemm_layer(_stream, w_ptr, a_ptr, c_ptr, n, b, k, _label)
}
// ══════════════════════════════════════════════════════════════════════════
// Bias + activation kernel launchers
// ══════════════════════════════════════════════════════════════════════════
/// Launch `add_bias_relu` over an activation buffer.
///
/// `output[i] = max(0, output[i] + bias[i % out_dim])`
///
/// Grid: ceil(B * out_dim / 256), Block: 256.
fn launch_add_bias_relu(
&self,
stream: &Arc<CudaStream>,
output: &CudaSlice<f32>,
bias_ptr: u64,
out_dim: usize,
batch: usize,
) -> Result<(), MLError> {
let total = (batch * out_dim) as i32;
let out_dim_i32 = out_dim as i32;
let blocks = ((batch * out_dim + 255) / 256) as u32;
let out_ptr = raw_f32_ptr_mut(output, stream);
unsafe {
stream
.launch_builder(&self.add_bias_relu_kernel)
.arg(&out_ptr)
.arg(&bias_ptr)
.arg(&out_dim_i32)
.arg(&total)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("add_bias_relu kernel: {e}")))?;
}
Ok(())
}
/// Launch `add_bias` (no activation) over an output buffer.
///
/// `output[i] = output[i] + bias[i % out_dim]`
fn launch_add_bias(
&self,
stream: &Arc<CudaStream>,
output: &CudaSlice<f32>,
bias_ptr: u64,
out_dim: usize,
batch: usize,
) -> Result<(), MLError> {
let out_ptr = raw_f32_ptr_mut(output, stream);
self.launch_add_bias_raw(stream, out_ptr, bias_ptr, out_dim, batch)
}
/// Launch `add_bias` with a raw output pointer (sub-buffer support).
fn launch_add_bias_raw(
&self,
stream: &Arc<CudaStream>,
out_ptr: u64,
bias_ptr: u64,
out_dim: usize,
batch: usize,
) -> Result<(), MLError> {
let total = (batch * out_dim) as i32;
let out_dim_i32 = out_dim as i32;
let blocks = ((batch * out_dim + 255) / 256) as u32;
unsafe {
stream
.launch_builder(&self.add_bias_kernel)
.arg(&out_ptr)
.arg(&bias_ptr)
.arg(&out_dim_i32)
.arg(&total)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("add_bias kernel: {e}")))?;
}
Ok(())
}
}
// ── Raw device pointer helpers ────────────────────────────────────────────────
/// Extract raw F32 device pointer (read-only).
fn raw_f32_ptr(slice: &CudaSlice<f32>, stream: &Arc<CudaStream>) -> u64 {
let (ptr, guard) = slice.device_ptr(stream);
let _no_drop = ManuallyDrop::new(guard);
ptr
}
/// Extract raw F32 device pointer (mutable).
fn raw_f32_ptr_mut(slice: &CudaSlice<f32>, stream: &Arc<CudaStream>) -> u64 {
let (ptr, guard) = slice.device_ptr(stream);
let _no_drop = ManuallyDrop::new(guard);
ptr
}
// ── Kernel compilation ────────────────────────────────────────────────────────
/// Compile `add_bias_relu` and `add_bias` kernels from inline NVRTC source.
///
/// These kernels are tiny (1 FMA per thread) and launch after each GEMM to:
/// 1. Broadcast the bias across the batch dimension.
/// 2. Apply ReLU (for hidden layers) or no activation (for logit layers).
///
/// They are NOT captured in the CUDA Graph — they run inline in the stream.
/// For CUDA Graph compatibility, we'd add them to the captured region, but
/// the small launch overhead (< 1 µs per kernel) is negligible vs. GEMM time.
fn compile_bias_kernels(
stream: &Arc<CudaStream>,
) -> Result<(CudaFunction, CudaFunction), MLError> {
// IMPORTANT: these are standalone kernels, not requiring common_device_functions.cuh.
// They must be compiled without the STATE_DIM / MARKET_DIM guards.
let src = r#"
extern "C" __global__ void add_bias_relu_kernel(
float* __restrict__ output,
const float* __restrict__ bias,
int out_dim,
int total_elements)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= total_elements) return;
float val = output[i] + bias[i % out_dim];
output[i] = (val > 0.0f) ? val : 0.0f;
}
extern "C" __global__ void add_bias_kernel(
float* __restrict__ output,
const float* __restrict__ bias,
int out_dim,
int total_elements)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= total_elements) return;
output[i] += bias[i % out_dim];
}
"#;
let context = stream.context();
let ptx = crate::cuda_pipeline::compile_ptx_for_device(src, &context)
.map_err(|e| MLError::ModelError(format!("bias kernel compilation: {e}")))?;
let module = context
.load_module(ptx)
.map_err(|e| MLError::ModelError(format!("bias kernel module load: {e}")))?;
let add_bias_relu = module
.load_function("add_bias_relu_kernel")
.map_err(|e| MLError::ModelError(format!("add_bias_relu_kernel load: {e}")))?;
let add_bias = module
.load_function("add_bias_kernel")
.map_err(|e| MLError::ModelError(format!("add_bias_kernel load: {e}")))?;
Ok((add_bias_relu, add_bias))
}
// ── Compute F32 weight pointers from flat params_buf ─────────────────────────
/// Compute the 20 raw F32 device pointers into a flat params_buf at GOFF_* offsets.
///
/// The flat buffer layout matches `compute_param_sizes()`:
/// [w_s1, b_s1, w_s2, b_s2, w_v1, b_v1, w_v2, b_v2,
/// w_b0fc, b_b0fc, w_b0out, b_b0out,
/// w_b1fc, b_b1fc, w_b1out, b_b1out,
/// w_b2fc, b_b2fc, w_b2out, b_b2out]
///
/// Returns 20 raw u64 device pointers (base + byte_offset for each tensor).
pub fn f32_weight_ptrs(
params_buf: &CudaSlice<f32>,
param_sizes: &[usize; 20],
stream: &Arc<CudaStream>,
) -> [u64; 20] {
let base = {
let (ptr, guard) = params_buf.device_ptr(stream);
let _no_drop = ManuallyDrop::new(guard);
ptr
};
let mut ptrs = [0_u64; 20];
let mut byte_offset: u64 = 0;
for i in 0..20 {
ptrs[i] = base + byte_offset;
byte_offset += (param_sizes[i] * std::mem::size_of::<f32>()) as u64;
}
ptrs
}

View File

@@ -0,0 +1,597 @@
/**
* Standalone C51 distributional RL loss kernel for Branching DQN.
*
* Phase 2 Task 6: Extract C51 loss from dqn_forward_loss_kernel into a
* standalone kernel that operates on batch-level tensors (pre-computed forward
* pass outputs), eliminating the 46KB/thread register spill that reduces the
* fused kernel to 1.56% occupancy.
*
* Design:
* - Input: value_logits + advantage_logits per branch from both online,
* target, and online_next networks (pre-computed by cuBLAS forward pass).
* - Grid: (batch_size, 1, 1) — one block per sample.
* - Block: (256, 1, 1) — 256 threads cooperate on NUM_ATOMS=51 work.
* - Shared memory: ~2.8 KB per block (vs 46 KB/thread in fused kernel).
*
* Does NOT modify dqn_forward_loss_kernel — that kernel remains as fallback.
*
* Requires common_device_functions.cuh prepended via NVRTC source concatenation
* (same mechanism as dqn_training_kernel.cu). The following constants must be
* injected via NVRTC #define before this source is compiled:
* NUM_ATOMS, V_MIN, V_MAX, BRANCH_0_SIZE, BRANCH_1_SIZE, BRANCH_2_SIZE
*/
/* ── Compile-time defaults (overridden by NVRTC injection) ───────────── */
#ifndef NUM_ATOMS
#define NUM_ATOMS 51
#endif
#ifndef BRANCH_0_SIZE
#define BRANCH_0_SIZE 5
#endif
#ifndef BRANCH_1_SIZE
#define BRANCH_1_SIZE 3
#endif
#ifndef BRANCH_2_SIZE
#define BRANCH_2_SIZE 3
#endif
#ifndef V_MIN
#define V_MIN (-25.0f)
#endif
#ifndef V_MAX
#define V_MAX (25.0f)
#endif
#define DELTA_Z ((V_MAX - V_MIN) / (float)(NUM_ATOMS - 1))
/* Total atoms per branch output */
#define BRANCH_0_ATOMS (BRANCH_0_SIZE * NUM_ATOMS)
#define BRANCH_1_ATOMS (BRANCH_1_SIZE * NUM_ATOMS)
#define BRANCH_2_ATOMS (BRANCH_2_SIZE * NUM_ATOMS)
/* Largest branch output size (floats) — used to size shmem regions */
#define MAX_BRANCH_ATOMS (MAX_BRANCH_SIZE * NUM_ATOMS)
#define MAX_BRANCH_SIZE 5 /* max(BRANCH_0_SIZE, BRANCH_1_SIZE, BRANCH_2_SIZE) */
#define NUM_BRANCHES 3
/* Maximum warp count for BLOCK_THREADS = 256 */
#define NUM_WARPS 8 /* 256 / 32 */
/* ── Block-level thread count (must match launch config) ─────────────── */
/* 256 threads: 8 warps, enough to saturate the issue queue for small NUM_ATOMS.
* At NUM_ATOMS=51, each thread handles at most 1 atom in most passes. */
#define BLOCK_THREADS 256
/* ── Shared memory layout (all regions in floats) ───────────────────────
*
* Region Size (floats) Purpose
* ─────────────────────────────────────────────────────────────────
* shmem_support NUM_ATOMS=51 Precomputed z_j = V_MIN + j*DELTA_Z
* shmem_val NUM_ATOMS=51 Value logits for current network/state
* shmem_adv MAX_BRANCH_ATOMS=255 Adv logits for all actions in branch
* shmem_lp NUM_ATOMS=51 log_softmax result (current/target action)
* shmem_proj NUM_ATOMS=51 Bellman-projected target distribution
* shmem_current_lp NUM_ATOMS=51 Saved current action log-probs (for CE)
* shmem_blk_reduce NUM_WARPS=8 Block-level reduction scratch
* ─────────────────────────────────────────────────────────────────
* Total floats: 51+51+255+51+51+51+8 = 518
* Total bytes: 518 * 4 = 2072 bytes ≈ 2.0 KB per block
* H100 SM: 228 KB / 2.0 KB = 114 concurrent blocks per SM
*
* Note: shmem_blk_reduce is NUM_WARPS floats and reused for all block
* reductions (max-finding, sum-finding, cross-entropy). One reduction
* at a time — never overlapping.
*/
#define SHMEM_SUPPORT_OFF 0
#define SHMEM_VAL_OFF (SHMEM_SUPPORT_OFF + NUM_ATOMS)
#define SHMEM_ADV_OFF (SHMEM_VAL_OFF + NUM_ATOMS)
#define SHMEM_LP_OFF (SHMEM_ADV_OFF + MAX_BRANCH_SIZE * NUM_ATOMS)
#define SHMEM_PROJ_OFF (SHMEM_LP_OFF + NUM_ATOMS)
#define SHMEM_CURRENT_LP_OFF (SHMEM_PROJ_OFF + NUM_ATOMS)
#define SHMEM_REDUCE_OFF (SHMEM_CURRENT_LP_OFF + NUM_ATOMS)
#define SHMEM_TOTAL_FLOATS (SHMEM_REDUCE_OFF + NUM_WARPS)
/* ── Device helpers ───────────────────────────────────────────────────── */
/**
* Block-wide max reduction.
* All BLOCK_THREADS threads contribute `val`; returns the block max.
* Uses shmem_reduce[NUM_WARPS] as scratch (must be accessible by all threads).
*/
__device__ __forceinline__ float block_reduce_max(
float val, float* __restrict__ shmem_reduce, int tid
) {
unsigned int mask = 0xFFFFFFFF;
int lane = tid & 31;
int warp = tid >> 5;
/* Warp-level reduction */
for (int offset = 16; offset > 0; offset >>= 1)
val = fmaxf(val, __shfl_xor_sync(mask, val, offset));
if (lane == 0) shmem_reduce[warp] = val;
__syncthreads();
/* Thread 0's warp reduces across warp results */
float result = -1e30f;
if (tid < NUM_WARPS)
result = shmem_reduce[tid];
if (tid < 32) {
for (int offset = NUM_WARPS >> 1; offset > 0; offset >>= 1)
result = fmaxf(result, __shfl_xor_sync(mask, result, offset));
if (tid == 0) shmem_reduce[0] = result;
}
__syncthreads();
return shmem_reduce[0];
}
/**
* Block-wide sum reduction.
* All BLOCK_THREADS threads contribute `val`; returns the block sum.
*/
__device__ __forceinline__ float block_reduce_sum(
float val, float* __restrict__ shmem_reduce, int tid
) {
unsigned int mask = 0xFFFFFFFF;
int lane = tid & 31;
int warp = tid >> 5;
for (int offset = 16; offset > 0; offset >>= 1)
val += __shfl_xor_sync(mask, val, offset);
if (lane == 0) shmem_reduce[warp] = val;
__syncthreads();
float result = 0.0f;
if (tid < NUM_WARPS)
result = shmem_reduce[tid];
if (tid < 32) {
for (int offset = NUM_WARPS >> 1; offset > 0; offset >>= 1)
result += __shfl_xor_sync(mask, result, offset);
if (tid == 0) shmem_reduce[0] = result;
}
__syncthreads();
return shmem_reduce[0];
}
/**
* Block-cooperative log_softmax over NUM_ATOMS elements.
*
* Reads from `logits[0..NUM_ATOMS]` in shared memory.
* Writes result to `out[0..NUM_ATOMS]` in shared memory.
* Both `logits` and `out` may alias the same region.
* `shmem_reduce[NUM_WARPS]` is used as scratch.
* Caller must __syncthreads() before reading `out`.
*/
__device__ void block_log_softmax(
const float* __restrict__ logits, /* [NUM_ATOMS] shmem */
float* __restrict__ out, /* [NUM_ATOMS] shmem output */
float* __restrict__ shmem_reduce, /* [NUM_WARPS] scratch */
int tid
) {
/* Pass 1: block-max */
float local_max = -1e30f;
for (int i = tid; i < NUM_ATOMS; i += BLOCK_THREADS)
local_max = fmaxf(local_max, logits[i]);
float block_max = block_reduce_max(local_max, shmem_reduce, tid);
/* Pass 2: sum(exp(x - max)) */
float local_sum = 0.0f;
for (int i = tid; i < NUM_ATOMS; i += BLOCK_THREADS)
local_sum += expf(logits[i] - block_max);
float block_sum = block_reduce_sum(local_sum, shmem_reduce, tid);
float log_sum_exp = logf(block_sum + 1e-10f);
/* Pass 3: write log(softmax) = x - max - log_sum_exp */
for (int i = tid; i < NUM_ATOMS; i += BLOCK_THREADS)
out[i] = logits[i] - block_max - log_sum_exp;
__syncthreads();
}
/**
* Block-cooperative expected Q value: E[Q] = sum_j(exp(log_probs[j]) * z_j).
*
* `log_probs` and `support` are in shared memory.
* Returns the scalar expected Q (identical on all threads after call).
*/
__device__ float block_expected_q(
const float* __restrict__ log_probs, /* [NUM_ATOMS] shmem */
const float* __restrict__ support, /* [NUM_ATOMS] shmem */
float* __restrict__ shmem_reduce, /* [NUM_WARPS] scratch */
int tid
) {
float local_sum = 0.0f;
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
local_sum += expf(log_probs[j]) * support[j];
return block_reduce_sum(local_sum, shmem_reduce, tid);
}
/**
* Block-cooperative Bellman projection.
*
* Projects `target_probs[NUM_ATOMS]` (probabilities, not log-probs) onto the
* C51 support using T_z = clamp(r + gamma * z_j * (1 - done), V_MIN, V_MAX).
* Result accumulated into `projected[NUM_ATOMS]` (must be zero-initialized).
*
* With BLOCK_THREADS=256 and NUM_ATOMS=51, each thread handles at most 1 source
* atom. The per-thread `local_proj[NUM_ATOMS]` register array therefore has at
* most 2 non-zero entries (lower and upper neighbors for that one atom).
* The warp butterfly + block accumulate then merges them.
*/
__device__ void block_bellman_project(
const float* __restrict__ target_probs, /* [NUM_ATOMS] shmem probabilities */
float reward, float done, float gamma,
float* __restrict__ projected, /* [NUM_ATOMS] shmem, zero-initialized */
const float* __restrict__ support, /* [NUM_ATOMS] shmem */
float* __restrict__ shmem_reduce, /* [NUM_WARPS] scratch */
int tid
) {
/* Phase 1: each thread accumulates its atoms' contributions in registers.
* With BLOCK_THREADS=256 and NUM_ATOMS=51, each thread processes at most
* one source atom (tid 0..50) or zero atoms (tid 51..255). */
float local_proj[NUM_ATOMS];
for (int k = 0; k < NUM_ATOMS; k++) local_proj[k] = 0.0f;
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS) {
float z_j = support[j];
float t_z = reward + gamma * z_j * (1.0f - done);
t_z = fminf(fmaxf(t_z, V_MIN), V_MAX);
float b_pos = (t_z - V_MIN) / DELTA_Z;
int lower = (int)floorf(b_pos);
int upper = (int)ceilf(b_pos);
lower = max(min(lower, NUM_ATOMS - 1), 0);
upper = max(min(upper, NUM_ATOMS - 1), 0);
float frac = b_pos - floorf(b_pos);
float p_j = target_probs[j];
local_proj[lower] += p_j * (1.0f - frac);
if (upper != lower)
local_proj[upper] += p_j * frac;
}
/* Phase 2: reduce each bin across the block.
* For each atom k, sum local_proj[k] over all threads using the warp
* butterfly + block-level collect pattern. */
unsigned int mask = 0xFFFFFFFF;
int lane = tid & 31;
int warp = tid >> 5;
for (int k = 0; k < NUM_ATOMS; k++) {
/* Warp butterfly sum for atom k */
float val = local_proj[k];
for (int offset = 16; offset > 0; offset >>= 1)
val += __shfl_xor_sync(mask, val, offset);
/* Collect warp results via shmem_reduce */
if (lane == 0) shmem_reduce[warp] = val;
__syncthreads();
if (tid == 0) {
float total = 0.0f;
for (int w = 0; w < NUM_WARPS; w++)
total += shmem_reduce[w];
projected[k] = total;
}
__syncthreads();
}
/* projected[k] is now correct for all k — written by thread 0 only. */
}
/* ══════════════════════════════════════════════════════════════════════
* MAIN KERNEL: c51_loss_batched
*
* Takes pre-computed forward pass outputs (value logits + per-branch
* advantage logits) from three network/state combinations:
* - online network on current states (for current Q-distribution)
* - target network on next states (for target Q-distribution)
* - online network on next states (Double DQN action selection)
*
* Grid = (batch_size, 1, 1)
* Block = (BLOCK_THREADS, 1, 1) = (256, 1, 1)
*
* For each sample b:
* Per branch d:
* 1. Dueling: Q[a,j] = V[j] + A[a,j] - mean_a(A[*,j])
* 2. log_softmax(Q[a_d, :]) → current action log-probs
* 3. Double DQN: for each action a, dueling+expected_Q → argmax
* 4. Dueling for target network + log_softmax for best_next action
* 5. Bellman projection of target probs onto support
* 6. Cross-entropy: -sum(projected * current_lp)
* Average CE over branches, apply IS weight.
* ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void c51_loss_batched(
/* ── Online network outputs on current STATES ─────────────────── */
const float* __restrict__ on_value_logits, /* [B, NUM_ATOMS] */
const float* __restrict__ on_adv_logits_b0, /* [B, BRANCH_0_SIZE * NUM_ATOMS] */
const float* __restrict__ on_adv_logits_b1, /* [B, BRANCH_1_SIZE * NUM_ATOMS] */
const float* __restrict__ on_adv_logits_b2, /* [B, BRANCH_2_SIZE * NUM_ATOMS] */
/* ── Target network outputs on NEXT_STATES ────────────────────── */
const float* __restrict__ tg_value_logits, /* [B, NUM_ATOMS] */
const float* __restrict__ tg_adv_logits_b0, /* [B, BRANCH_0_SIZE * NUM_ATOMS] */
const float* __restrict__ tg_adv_logits_b1, /* [B, BRANCH_1_SIZE * NUM_ATOMS] */
const float* __restrict__ tg_adv_logits_b2, /* [B, BRANCH_2_SIZE * NUM_ATOMS] */
/* ── Online network outputs on NEXT_STATES (Double DQN selector) */
const float* __restrict__ on_next_value_logits, /* [B, NUM_ATOMS] */
const float* __restrict__ on_next_adv_logits_b0, /* [B, BRANCH_0_SIZE * NUM_ATOMS] */
const float* __restrict__ on_next_adv_logits_b1, /* [B, BRANCH_1_SIZE * NUM_ATOMS] */
const float* __restrict__ on_next_adv_logits_b2, /* [B, BRANCH_2_SIZE * NUM_ATOMS] */
/* ── Batch data ───────────────────────────────────────────────── */
const int* __restrict__ actions, /* [B] factored action indices 0-44 */
const float* __restrict__ rewards, /* [B] */
const float* __restrict__ dones, /* [B] */
const float* __restrict__ is_weights, /* [B] PER importance-sampling weights */
/* ── Outputs ──────────────────────────────────────────────────── */
float* __restrict__ per_sample_loss, /* [B] IS-weighted loss per sample */
float* __restrict__ td_errors, /* [B] unweighted, for PER priority update */
float* __restrict__ total_loss, /* [1] batch mean loss (atomicAdd) */
/* ── Saved tensors for backward pass ─────────────────────────── */
float* __restrict__ save_current_lp, /* [B, NUM_BRANCHES, NUM_ATOMS] */
float* __restrict__ save_projected, /* [B, NUM_BRANCHES, NUM_ATOMS] */
/* ── Config ───────────────────────────────────────────────────── */
float gamma,
int batch_size
) {
extern __shared__ float shmem[];
/* Named pointers into the flat shared memory region */
float* shmem_support = shmem + SHMEM_SUPPORT_OFF; /* [NUM_ATOMS] z_j */
float* shmem_val = shmem + SHMEM_VAL_OFF; /* [NUM_ATOMS] value logits */
float* shmem_adv = shmem + SHMEM_ADV_OFF; /* [MAX_BRANCH_SIZE*NUM_ATOMS] adv logits */
float* shmem_lp = shmem + SHMEM_LP_OFF; /* [NUM_ATOMS] log-prob result */
float* shmem_proj = shmem + SHMEM_PROJ_OFF; /* [NUM_ATOMS] Bellman projection */
float* shmem_current_lp = shmem + SHMEM_CURRENT_LP_OFF; /* [NUM_ATOMS] taken action lp */
float* shmem_reduce = shmem + SHMEM_REDUCE_OFF; /* [NUM_WARPS] reduction scratch */
int sample_id = blockIdx.x;
int tid = threadIdx.x;
if (sample_id >= batch_size) return;
/* ── Precompute C51 support atoms once per block ─────────────── */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
shmem_support[j] = V_MIN + (float)j * DELTA_Z;
__syncthreads();
/* ── Decode factored action into per-branch indices ──────────── */
int factored_action = actions[sample_id];
/* Clamp to valid range — guards against uninitialized replay buffer entries */
int max_action = BRANCH_0_SIZE * BRANCH_1_SIZE * BRANCH_2_SIZE;
if (factored_action < 0 || factored_action >= max_action)
factored_action = 0;
int a0 = factored_action / (BRANCH_1_SIZE * BRANCH_2_SIZE);
int a1 = (factored_action / BRANCH_2_SIZE) % BRANCH_1_SIZE;
int a2 = factored_action % BRANCH_2_SIZE;
/* Per-branch metadata (compile-time sizes, runtime-selected values) */
const int branch_action[NUM_BRANCHES] = { a0, a1, a2 };
const int branch_sizes[NUM_BRANCHES] = { BRANCH_0_SIZE, BRANCH_1_SIZE, BRANCH_2_SIZE };
/* Per-sample row pointers into each branch's advantage logit tensors */
const float* on_adv_row[NUM_BRANCHES] = {
on_adv_logits_b0 + (long long)sample_id * BRANCH_0_ATOMS,
on_adv_logits_b1 + (long long)sample_id * BRANCH_1_ATOMS,
on_adv_logits_b2 + (long long)sample_id * BRANCH_2_ATOMS
};
const float* tg_adv_row[NUM_BRANCHES] = {
tg_adv_logits_b0 + (long long)sample_id * BRANCH_0_ATOMS,
tg_adv_logits_b1 + (long long)sample_id * BRANCH_1_ATOMS,
tg_adv_logits_b2 + (long long)sample_id * BRANCH_2_ATOMS
};
const float* on_next_adv_row[NUM_BRANCHES] = {
on_next_adv_logits_b0 + (long long)sample_id * BRANCH_0_ATOMS,
on_next_adv_logits_b1 + (long long)sample_id * BRANCH_1_ATOMS,
on_next_adv_logits_b2 + (long long)sample_id * BRANCH_2_ATOMS
};
/* Value logit row pointers (same NUM_ATOMS for all branches) */
const float* on_val_row = on_value_logits + (long long)sample_id * NUM_ATOMS;
const float* tg_val_row = tg_value_logits + (long long)sample_id * NUM_ATOMS;
const float* on_next_val_row = on_next_value_logits + (long long)sample_id * NUM_ATOMS;
float reward = rewards[sample_id];
float done = dones[sample_id];
float is_weight = is_weights[sample_id];
float total_ce = 0.0f;
/* ═══════════════════════════════════════════════════════════════
* Process each branch sequentially to limit register pressure.
* ═══════════════════════════════════════════════════════════════ */
for (int d = 0; d < NUM_BRANCHES; d++) {
int n_d = branch_sizes[d];
int a_d = branch_action[d];
int n_atoms = n_d * NUM_ATOMS; /* total adv logit elements for this branch */
/* Global memory offset for save tensors: [sample, branch, atom] */
long long save_off = ((long long)sample_id * NUM_BRANCHES + d) * NUM_ATOMS;
/* ── Helper: compute dueling logits for a single action into shmem_lp
* shmem_val must already hold value logits [NUM_ATOMS]
* shmem_adv must already hold all adv logits [n_d * NUM_ATOMS]
* Result: dueling(a_sel)[j] = V[j] + A[a_sel,j] - mean_a(A[*,j])
* written into shmem_lp[j] */
/* (Inlined below to avoid device-function overhead for this hot path) */
/* ═══════════════════════════════════════════════════════════
* STEP a: Current log-probs for the taken action a_d
* (online network, current states)
* ═══════════════════════════════════════════════════════════ */
/* Load value logits */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
shmem_val[j] = on_val_row[j];
__syncthreads();
/* Load advantage logits for all n_d actions */
for (int i = tid; i < n_atoms; i += BLOCK_THREADS)
shmem_adv[i] = on_adv_row[d][i];
__syncthreads();
/* Dueling for action a_d: Q[j] = V[j] + A[a_d,j] - mean_a(A[*,j]) */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS) {
float a_mean = 0.0f;
for (int a = 0; a < n_d; a++)
a_mean += shmem_adv[a * NUM_ATOMS + j];
a_mean /= (float)n_d;
shmem_lp[j] = shmem_val[j] + shmem_adv[a_d * NUM_ATOMS + j] - a_mean;
}
__syncthreads();
/* log_softmax → result back into shmem_lp */
block_log_softmax(shmem_lp, shmem_lp, shmem_reduce, tid);
/* shmem_lp[0..NUM_ATOMS] = log_probs for taken action a_d */
/* Copy into shmem_current_lp for use in cross-entropy (step e) */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
shmem_current_lp[j] = shmem_lp[j];
/* Save for backward kernel */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
save_current_lp[save_off + j] = shmem_lp[j];
__syncthreads();
/* ═══════════════════════════════════════════════════════════
* STEP b: Double DQN — argmax action on online_next network
*
* For each action a (0..n_d): compute dueling logits, log_softmax,
* then expected Q. Select action with highest expected Q.
* ═══════════════════════════════════════════════════════════ */
/* Load online_next value and advantage logits */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
shmem_val[j] = on_next_val_row[j];
__syncthreads();
for (int i = tid; i < n_atoms; i += BLOCK_THREADS)
shmem_adv[i] = on_next_adv_row[d][i];
__syncthreads();
/* Compute average advantage across actions for dueling (same for all a) */
/* shmem_proj temporarily holds per-atom mean advantage — cleared next step */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS) {
float a_mean = 0.0f;
for (int a = 0; a < n_d; a++)
a_mean += shmem_adv[a * NUM_ATOMS + j];
a_mean /= (float)n_d;
shmem_proj[j] = a_mean; /* reuse proj region for mean advantage */
}
__syncthreads();
/* Now compute expected Q per action and find argmax.
* We store per-action expected Q in registers on thread 0 (via shared election).
* The MAX_BRANCH_SIZE actions fit in a small register array on thread 0. */
__shared__ float eq_per_action[MAX_BRANCH_SIZE];
__shared__ int best_next_a_shared;
for (int a = 0; a < n_d; a++) {
/* Dueling logits for action a: V[j] + A[a,j] - mean_j(A[*,j]) */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
shmem_lp[j] = shmem_val[j] + shmem_adv[a * NUM_ATOMS + j] - shmem_proj[j];
__syncthreads();
/* log_softmax in-place on shmem_lp */
block_log_softmax(shmem_lp, shmem_lp, shmem_reduce, tid);
/* Expected Q for action a */
float eq = block_expected_q(shmem_lp, shmem_support, shmem_reduce, tid);
if (tid == 0) eq_per_action[a] = eq;
__syncthreads();
}
/* Thread 0 finds argmax across actions */
if (tid == 0) {
int best = 0;
for (int a = 1; a < n_d; a++)
if (eq_per_action[a] > eq_per_action[best]) best = a;
best_next_a_shared = best;
}
__syncthreads();
int best_next_a = best_next_a_shared;
/* ═══════════════════════════════════════════════════════════
* STEP c: Target distribution for best_next action
* (target network, next states)
* ═══════════════════════════════════════════════════════════ */
/* Load target value and advantage logits */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
shmem_val[j] = tg_val_row[j];
__syncthreads();
for (int i = tid; i < n_atoms; i += BLOCK_THREADS)
shmem_adv[i] = tg_adv_row[d][i];
__syncthreads();
/* Per-atom mean advantage for target network */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS) {
float a_mean = 0.0f;
for (int a = 0; a < n_d; a++)
a_mean += shmem_adv[a * NUM_ATOMS + j];
a_mean /= (float)n_d;
shmem_lp[j] = shmem_val[j] + shmem_adv[best_next_a * NUM_ATOMS + j] - a_mean;
}
__syncthreads();
/* log_softmax → log-probs for target's best_next action */
block_log_softmax(shmem_lp, shmem_lp, shmem_reduce, tid);
/* Convert to probabilities for Bellman projection */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
shmem_proj[j] = expf(shmem_lp[j]);
__syncthreads();
/* ═══════════════════════════════════════════════════════════
* STEP d: Bellman projection
* T_z_j = clamp(r + gamma * z_j * (1-done), V_MIN, V_MAX)
* Project shmem_proj (target probs) onto shmem_proj (output).
*
* The projection writes back to shmem_proj in-place (the input
* probabilities are read from registers before the output is written).
* ═══════════════════════════════════════════════════════════ */
/* Zero-initialize projected output — block_bellman_project accumulates */
/* We use shmem_lp as the output projected array (shmem_proj still holds probs) */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
shmem_lp[j] = 0.0f;
__syncthreads();
block_bellman_project(shmem_proj, reward, done, gamma,
shmem_lp, shmem_support, shmem_reduce, tid);
/* shmem_lp[0..NUM_ATOMS] = projected target distribution */
/* Save projected for backward kernel */
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
save_projected[save_off + j] = shmem_lp[j];
__syncthreads();
/* ═══════════════════════════════════════════════════════════
* STEP e: Cross-entropy loss for this branch
* CE = -sum_j(projected[j] * current_lp[j])
* ═══════════════════════════════════════════════════════════ */
float local_ce = 0.0f;
for (int j = tid; j < NUM_ATOMS; j += BLOCK_THREADS)
local_ce -= shmem_lp[j] * shmem_current_lp[j];
float branch_ce = block_reduce_sum(local_ce, shmem_reduce, tid);
/* branch_ce is now the same on all threads */
total_ce += branch_ce;
} /* end branch loop (d = 0, 1, 2) */
/* ── Aggregate: average over branches, apply IS weight ───────── */
float avg_ce = total_ce / (float)NUM_BRANCHES;
/* ── Write outputs (thread 0 only to avoid races) ────────────── */
if (tid == 0) {
float weighted_loss = avg_ce * is_weight;
per_sample_loss[sample_id] = weighted_loss;
td_errors[sample_id] = avg_ce; /* unweighted — used by PER for priorities */
atomicAdd(total_loss, weighted_loss / (float)batch_size);
}
}

View File

@@ -214,12 +214,6 @@ __device__ void noisy_matvec_leaky_relu(
#ifndef SHMEM_TILE_ROWS
#define SHMEM_TILE_ROWS 64
#endif
/* BF16 weight tiles are half the byte-width of F32, so the same shared memory
* region fits 2× the rows. Forward kernels using BF16 weights tile with this
* doubled row count, halving tiling iterations for the largest matmuls. */
#ifndef SHMEM_TILE_ROWS_BF16
#define SHMEM_TILE_ROWS_BF16 (2 * SHMEM_TILE_ROWS)
#endif
#ifndef SHMEM_MAX_IN_DIM
#define SHMEM_MAX_IN_DIM 256 /* max(STATE_DIM, SHARED_H1, SHARED_H2) */
#endif
@@ -397,159 +391,6 @@ __device__ __forceinline__ void cooperative_load_tile(
#endif
}
/* ------------------------------------------------------------------ */
/* BF16 Shared Memory Weight Caching */
/* ------------------------------------------------------------------ */
/**
* Cooperatively load a BF16 weight tile from global to shared memory.
* Global source is BF16 (__nv_bfloat16*), shared dest is also BF16.
* Halves bandwidth vs F32 load. Must call __syncwarp() after.
*
* Uses short2 vectorized loads (4 bytes = 2 BF16 elements per load)
* for coalesced access on H100.
*/
__device__ __forceinline__ void cooperative_load_tile_bf16(
__nv_bfloat16* __restrict__ shmem_dst,
const __nv_bfloat16* __restrict__ global_src,
int count /* total bf16 elements to load */
) {
/* Vectorize as short2 (2 BF16 = 4 bytes per load) */
int vec2_count = count / 2;
int remainder = count & 1;
short2* dst2 = (short2*)shmem_dst;
const short2* src2 = (const short2*)global_src;
for (int i = threadIdx.x; i < vec2_count; i += blockDim.x)
dst2[i] = src2[i];
if (remainder && threadIdx.x == 0) {
int base = vec2_count * 2;
shmem_dst[base] = global_src[base];
}
}
/**
* Cooperatively load a BF16 bias tile from global to F32 shared memory.
* Biases are small and added in F32, so expand on load.
*/
__device__ __forceinline__ void cooperative_load_bias_bf16_to_f32(
float* __restrict__ shmem_dst,
const __nv_bfloat16* __restrict__ global_src,
int count
) {
for (int i = threadIdx.x; i < count; i += blockDim.x)
shmem_dst[i] = __bfloat162float(global_src[i]);
}
/* Forward declaration — defined below, used by BF16 matvec helpers */
__device__ float warp_reduce_sum_all(float val);
/**
* Warp-cooperative matrix-vector multiply with BF16 weights, F32 accumulation.
*
* Weights in shared memory are BF16 (__nv_bfloat16), input is F32 distributed,
* dot product accumulates in F32. Bias is F32 (pre-expanded on load).
* This matches the H100 tensor core accumulate mode: BF16 × BF16 → F32.
*
* @param shmem_W_bf16 BF16 weight tile [tile_rows × in_dim] in shared memory
* @param shmem_b_f32 F32 bias tile [tile_rows] in shared memory
* @param input_dist F32 distributed input [DIST_SIZE(in_dim)] per lane
* @param output_dist F32 distributed output [DIST_SIZE(out_dim)] per lane
*/
__device__ void warp_matvec_bf16_shmem(
const __nv_bfloat16* __restrict__ shmem_W_bf16,
const float* __restrict__ shmem_b_f32,
const float* input_dist,
float* output_dist,
int in_dim,
int out_dim,
int tile_offset,
int tile_rows,
int activate,
int lane_id
) {
(void)out_dim;
for (int j = 0; j < tile_rows; j++) {
const __nv_bfloat16* row = shmem_W_bf16 + j * in_dim;
/* F32 accumulation of BF16 dot product */
float partial = 0.0f;
for (int i = lane_id; i < in_dim; i += 32) {
float w_f32 = __bfloat162float(row[i]);
partial += w_f32 * input_dist[i / 32];
}
/* Add F32 bias (only lane 0) */
if (lane_id == 0)
partial += shmem_b_f32[j];
float sum = warp_reduce_sum_all(partial);
if (activate) sum = leaky_relu(sum);
int gj = tile_offset + j;
if (lane_id == (gj & 31))
output_dist[gj >> 5] = sum;
}
}
/**
* Warp-cooperative BF16 matvec with broadcast output (non-distributed).
* Same as warp_matvec_bf16_shmem but ALL lanes store every output element.
* Used for small output layers (C51 atom logits) where all lanes need results.
*/
__device__ void warp_matvec_bf16_broadcast_shmem(
const __nv_bfloat16* __restrict__ shmem_W_bf16,
const float* __restrict__ shmem_b_f32,
const float* input_dist,
float* output, /* NOT distributed — all lanes get full copy */
int in_dim,
int out_dim,
int tile_offset,
int tile_rows,
int activate,
int lane_id
) {
(void)out_dim;
for (int j = 0; j < tile_rows; j++) {
const __nv_bfloat16* row = shmem_W_bf16 + j * in_dim;
float partial = 0.0f;
for (int i = lane_id; i < in_dim; i += 32) {
float w_f32 = __bfloat162float(row[i]);
partial += w_f32 * input_dist[i / 32];
}
if (lane_id == 0)
partial += shmem_b_f32[j];
float sum = warp_reduce_sum_all(partial);
if (activate) sum = leaky_relu(sum);
output[tile_offset + j] = sum;
}
}
/* ------------------------------------------------------------------ */
/* BF16 Tiled Layer Macro */
/* ------------------------------------------------------------------ */
/* BF16 version of TILE_LAYER_WARP_CLEAN: loads BF16 weights from global,
* expands bias to F32, computes matvec with F32 accumulation.
* Shared memory layout: shmem_w holds BF16 weights (half the size),
* shmem_b holds F32 bias (expanded on load).
*
* NOTE: shmem_w is cast to __nv_bfloat16* — the caller's shared memory
* buffer must be large enough for tile_rows × in_dim × sizeof(bf16) elements.
* Since sizeof(bf16) = sizeof(float)/2, the existing F32 buffer is always
* large enough. */
#ifndef TILE_LAYER_WARP_BF16
#define TILE_LAYER_WARP_BF16(W_bf16, b_bf16, input_dist, output_dist, in_d, out_d, act, shmem_w, shmem_b, lane) \
do { \
__nv_bfloat16* _shmem_w_bf16 = (__nv_bfloat16*)(shmem_w); \
for (int _tile = 0; _tile < ((out_d) + SHMEM_TILE_ROWS_BF16 - 1) / SHMEM_TILE_ROWS_BF16; _tile++) { \
int _ts = _tile * SHMEM_TILE_ROWS_BF16; \
int _tr = SHMEM_MIN(SHMEM_TILE_ROWS_BF16, (out_d) - _ts); \
cooperative_load_tile_bf16(_shmem_w_bf16, (const __nv_bfloat16*)(W_bf16) + _ts * (in_d), _tr * (in_d)); \
cooperative_load_bias_bf16_to_f32(shmem_b, (const __nv_bfloat16*)(b_bf16) + _ts, _tr); \
__syncwarp(0xFFFFFFFF); \
warp_matvec_bf16_shmem(_shmem_w_bf16, shmem_b, input_dist, output_dist, \
in_d, out_d, _ts, _tr, act, lane); \
__syncwarp(0xFFFFFFFF); \
} \
} while (0)
#endif
/* ------------------------------------------------------------------ */
/* F32→BF16 Weight Conversion Kernel */
/* ------------------------------------------------------------------ */

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
/**
* DQN utility kernels — gradient norm reduction and AdamW update.
*
* These two kernels form the optimizer step in the DQN training pipeline.
* They operate on the flat parameter / gradient buffers produced by the
* cuBLAS backward pass and are compiled together with
* common_device_functions.cuh (which also provides the f32_to_bf16_kernel
* and bf16_to_f32_kernel converters needed by the same NVRTC module).
*
* No #include of common_device_functions.cuh is needed here — the build
* function in gpu_dqn_trainer.rs prepends it via string concatenation
* before passing the combined source to NVRTC.
*
* Extracted from dqn_training_kernel.cu (lines 1207-1293).
*/
/* ══════════════════════════════════════════════════════════════════════
* GRADIENT NORM KERNEL (Phase 3a)
*
* Computes gradient L2 norm (sum of squares) via warp + block reduction
* and atomicAdd into a single output float. Must be followed by
* dqn_adam_update_kernel which reads the completed norm.
*
* Launch config: grid=(ceil(TOTAL_PARAMS/256), 1, 1), block=(256, 1, 1).
* ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void dqn_grad_norm_kernel(
const float* __restrict__ grads, /* [TOTAL_PARAMS] accumulated gradients */
float* __restrict__ out_grad_norm, /* [1] output: sum of squares */
int total_params
) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
float g = (idx < total_params) ? grads[idx] : 0.0f;
float g2 = g * g;
/* Warp-level reduction via shuffle (no shared memory) */
for (int offset = 16; offset > 0; offset >>= 1)
g2 += __shfl_xor_sync(0xFFFFFFFF, g2, offset);
/* Block-level cross-warp reduction via shared memory.
* Pad warp_sums to 2 elements per warp (stride=2) to avoid bank conflicts:
* warp 0 → bank 0, warp 1 → bank 2, ... warp 7 → bank 14.
* Without padding, sequential warp writes to warp_sums[0..7] map to
* banks 0..7 which is conflict-free for writes, but the __syncthreads()
* + read-back pattern benefits from padding to avoid false sharing
* between adjacent cache lines on H100's 128-byte L1 lines. */
__shared__ float warp_sums[16]; /* 8 warps × 2 stride (padded) */
int warp_id = threadIdx.x / 32;
int warp_lane = threadIdx.x % 32;
if (warp_lane == 0) warp_sums[warp_id * 2] = g2;
__syncthreads();
/* First warp reduces across warps using shuffles (pure register path) */
if (warp_id == 0) {
float val = (warp_lane < blockDim.x / 32) ? warp_sums[warp_lane * 2] : 0.0f;
for (int offset = 16; offset > 0; offset >>= 1)
val += __shfl_xor_sync(0xFFFFFFFF, val, offset);
if (warp_lane == 0)
atomicAdd(out_grad_norm, val);
}
}
/* ══════════════════════════════════════════════════════════════════════
* ADAM UPDATE KERNEL (Phase 3b)
*
* Reads the COMPLETED gradient L2 norm from dqn_grad_norm_kernel,
* applies gradient clipping, then AdamW parameter update.
* Trivially parallel: 1 thread per parameter element.
*
* Launch config: grid=(ceil(TOTAL_PARAMS/256), 1, 1), block=(256, 1, 1).
* ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void dqn_adam_update_kernel(
float* __restrict__ params, /* [TOTAL_PARAMS] flattened parameters */
const float* __restrict__ grads, /* [TOTAL_PARAMS] accumulated gradients */
float* __restrict__ m, /* [TOTAL_PARAMS] first moment (Adam) */
float* __restrict__ v, /* [TOTAL_PARAMS] second moment (Adam) */
const float* __restrict__ grad_norm_sq, /* [1] completed sum of squares */
float lr,
float beta1,
float beta2,
float epsilon,
float weight_decay,
float max_grad_norm,
const int* __restrict__ t_ptr, /* Adam step counter on device (for CUDA Graph) */
int total_params
) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= total_params) return;
int t = *t_ptr;
float g = grads[idx];
/* Clip using the COMPLETED norm (no race — separate kernel launch) */
float norm = sqrtf(*grad_norm_sq + 1e-12f);
float clip_scale = (norm > max_grad_norm) ? (max_grad_norm / norm) : 1.0f;
float clipped_g = g * clip_scale;
/* Adam update */
float beta1_t = 1.0f - powf(beta1, (float)t);
float beta2_t = 1.0f - powf(beta2, (float)t);
float m_i = beta1 * m[idx] + (1.0f - beta1) * clipped_g;
float v_i = beta2 * v[idx] + (1.0f - beta2) * clipped_g * clipped_g;
m[idx] = m_i;
v[idx] = v_i;
float m_hat = m_i / beta1_t;
float v_hat = v_i / beta2_t;
/* AdamW weight decay (decoupled) */
params[idx] -= lr * (m_hat / (sqrtf(v_hat) + epsilon) + weight_decay * params[idx]);
}

View File

@@ -1,65 +1,511 @@
/**
* CUDA Kernels for DQN Experience Collection
* experience_kernels.cu
*
* Replaces CPU-bound per-bar portfolio simulation and reward
* computation in the DQN trainer hot loop.
* Three focused CUDA kernels that handle the non-Q-forward parts of DQN
* experience collection. The Q-network forward pass is performed by cuBLAS
* SGEMM from the Rust host between kernel launches.
*
* Architecture: One thread processes batch_size bars sequentially.
* Parallelism across hyperopt trials via separate CUDA stream launches.
* Kernels:
* 1. experience_state_gather — assemble batch state tensor for cuBLAS
* 2. experience_action_select — epsilon-greedy on cuBLAS Q-values
* 3. experience_env_step — portfolio simulation, reward, done detection
*
* Design:
* - Grid: ceil(N/256), Block: 256. One thread per episode — trivially parallel.
* - No shared memory required.
* - Standalone file: does NOT include common_device_functions.cuh.
* Constants normally injected via NVRTC are guarded with #ifndef
* fallback defaults; production use injects the correct values at
* NVRTC compile time.
*
* State layout (matches common_device_functions.cuh):
* [0 .. market_dim) : market features
* [market_dim .. market_dim+3) : portfolio features (value_norm, position, cash_norm)
* [market_dim+3 .. state_dim) : zero-pad for tensor-core alignment
*
* Portfolio state layout used by these kernels ([N, 3]):
* [0] position — current contract position (signed)
* [1] cash — cash balance
* [2] portfolio_value — mark-to-market total value (cash + position * price)
*
* Branching DQN (Tavakoli et al., 2018):
* 3 independent advantage heads: exposure (b0_size=5), order (b1_size=3),
* urgency (b2_size=3). cuBLAS produces q_values [N, 11] = concat([b0],[b1],[b2]).
* Factored action index = a0 * b1_size * b2_size + a1 * b2_size + a2.
* Exposure mapping: 0→-1.0, 1→-0.5, 2→0.0, 3→0.5, 4→1.0.
*
* Advanced features deferred to follow-up tasks (Phase 4+):
* DSR, curiosity, barrier tracking, diversity entropy, fill simulation,
* NoisyNet, C51, RMSNorm, N-step returns, action masking.
*/
/* ------------------------------------------------------------------ */
/* Compile-time constants — overridable via NVRTC #define injection. */
/* ------------------------------------------------------------------ */
#ifndef MARKET_DIM
#define MARKET_DIM 42
#endif
#ifndef PORTFOLIO_DIM
#define PORTFOLIO_DIM 3
#endif
#ifndef STATE_DIM
#define STATE_DIM 48
#endif
/* ------------------------------------------------------------------ */
/* Inline helpers — self-contained, no external dependencies. */
/* ------------------------------------------------------------------ */
/**
* Map DQN action index (0-4) to target exposure fraction.
* LCG random — returns float in [0, 1).
*
* DQN outputs 5 exposure actions directly:
* 0=Short100=-1.0, 1=Short50=-0.5, 2=Flat=0.0, 3=Long50=0.5, 4=Long100=1.0
* Identical implementation to gpu_random() in common_device_functions.cuh
* so episodes share the same statistical properties when mixing old and
* new kernels during the transition period.
*/
__device__ __forceinline__ float action_to_exposure(int action_idx) {
switch (action_idx) {
case 0: return -1.0f; // Short100
case 1: return -0.5f; // Short50
case 2: return 0.0f; // Flat
case 3: return 0.5f; // Long50
case 4: return 1.0f; // Long100
__device__ __forceinline__ float lcg_random(unsigned int* state) {
*state = *state * 1664525u + 1013904223u;
return (float)(*state & 0x00FFFFFFu) / 16777216.0f;
}
/**
* Argmax over a float array of length n.
* Returns the index of the maximum element; ties broken in favour of the
* lowest index (first maximum found).
*/
__device__ __forceinline__ int argmax_n(const float* arr, int n) {
int best_idx = 0;
float best_val = arr[0];
for (int j = 1; j < n; j++) {
if (arr[j] > best_val) {
best_val = arr[j];
best_idx = j;
}
}
return best_idx;
}
/**
* Map exposure branch index (04) to target exposure fraction.
* 0 → -1.0 (Short100)
* 1 → -0.5 (Short50)
* 2 → 0.0 (Flat)
* 3 → 0.5 (Long50)
* 4 → 1.0 (Long100)
*/
__device__ __forceinline__ float exposure_idx_to_fraction(int idx) {
switch (idx) {
case 0: return -1.0f;
case 1: return -0.5f;
case 2: return 0.0f;
case 3: return 0.5f;
case 4: return 1.0f;
default: return 0.0f;
}
}
/**
* Map DQN action index (0-4) to transaction cost rate.
*
* With 5-action DQN, order type is determined by CPU-side OrderRouter.
* GPU kernel uses fixed Market cost for all exposure actions.
*/
__device__ __forceinline__ float action_to_tx_cost(int action_idx) {
(void)action_idx;
return 0.0001f; // Market rate for all DQN exposure actions
}
/* ================================================================== */
/* Kernel 1: experience_state_gather */
/* ================================================================== */
/**
* Portfolio simulation kernel — processes batch_size bars sequentially.
* Gather market features and portfolio state into a flat batch tensor
* suitable for cuBLAS SGEMM (Q-network forward pass).
*
* For each bar:
* 1. Read action → target exposure
* 2. Execute trade (update cash, position)
* 3. Mark-to-market at next price
* 4. Compute normalized portfolio features
* 5. Compute raw PnL reward
* 6. Check episode boundary
* Grid: ceil(N / 256), Block: 256. One thread per episode.
*
* @param targets [total_bars, 4] — [preproc_close, preproc_next, raw_close, raw_next]
* @param actions [batch_size] — DQN action indices (0-4, exposure levels)
* @param portfolio_state [8] — IN/OUT: cash, position, entry_price, initial_capital,
* spread, last_price, reserve_pct, cum_costs
* @param portfolio_out [batch_size, 3] — normalized portfolio features per bar
* @param rewards_out [batch_size] — raw PnL reward per bar
* @param done_out [batch_size] — 1 if episode boundary, 0 otherwise
* @param batch_start First bar index in targets array
* @param batch_size Number of bars in this batch
* @param max_position Maximum position size (contracts)
* @param episode_length Bars per episode for time-based termination
* @param total_bars Total training bars (for data boundary check)
* @param market_features [total_bars, market_dim] raw market feature matrix
* @param episode_starts [N] global bar index of episode start
* @param current_timesteps [N] timestep offset within episode (read-only)
* @param portfolio_states [N, 3] {position, cash, portfolio_value}
* @param batch_states [N, state_dim] output: assembled state batch
* @param N number of episodes
* @param total_bars number of bars in market_features
* @param state_dim full state dimension (tensor-core aligned)
* @param market_dim number of market features per bar
*/
extern "C" __global__ void experience_state_gather(
const float* __restrict__ market_features,
const int* __restrict__ episode_starts,
const int* __restrict__ current_timesteps,
const float* __restrict__ portfolio_states,
float* batch_states,
int N,
int total_bars,
int state_dim,
int market_dim
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= N) return;
int bar_idx = episode_starts[i] + current_timesteps[i];
float* out = batch_states + (long long)i * state_dim;
/* Out-of-data: write zero state; cuBLAS will produce Q-values of ~0. */
if (bar_idx >= total_bars) {
for (int k = 0; k < state_dim; k++)
out[k] = 0.0f;
return;
}
/* -- Market features: [0 .. market_dim) -- */
const float* mf_row = market_features + (long long)bar_idx * market_dim;
for (int k = 0; k < market_dim; k++)
out[k] = mf_row[k];
/* -- Portfolio features: [market_dim .. market_dim+3) --
*
* slot +0: normalised portfolio value = portfolio_value / denom
* slot +1: raw position (contract units; network learns the scale)
* slot +2: normalised cash = cash / denom
*
* denom = max(portfolio_value, 1.0) avoids division by zero on an
* empty account. This mirrors the current_norm / pos_norm pattern
* from the monolithic kernel (spread is omitted in Phase 3). */
const float* ps = portfolio_states + (long long)i * 3;
float position = ps[0];
float cash = ps[1];
float portfolio_value = ps[2];
float denom = (portfolio_value > 1.0f) ? portfolio_value : 1.0f;
int portfolio_base = market_dim;
if (portfolio_base + 2 < state_dim) {
out[portfolio_base + 0] = portfolio_value / denom;
out[portfolio_base + 1] = position;
out[portfolio_base + 2] = cash / denom;
}
/* -- Zero-pad remaining dimensions (tensor-core alignment) -- */
int filled = market_dim + 3;
for (int k = filled; k < state_dim; k++)
out[k] = 0.0f;
}
/* ================================================================== */
/* Kernel 2: experience_action_select */
/* ================================================================== */
/**
* Epsilon-greedy action selection on Q-values produced by cuBLAS.
*
* Supports Branching Dueling DQN (use_branching=1) and flat DQN
* (use_branching=0, exposure branch only).
*
* Branching mode — q_values layout: [N, b0_size + b1_size + b2_size]
* Branch 0 (exposure): q_values[i*(b0+b1+b2) + 0 .. +b0)
* Branch 1 (order): q_values[i*(b0+b1+b2) + b0 .. +b0+b1)
* Branch 2 (urgency): q_values[i*(b0+b1+b2) + b0+b1 .. +b0+b1+b2)
* Factored action = a0 * b1_size * b2_size + a1 * b2_size + a2.
*
* Flat mode — q_values layout: [N, b0_size]
* Action index = argmax or random in [0, b0_size).
*
* Grid: ceil(N / 256), Block: 256. One thread per episode.
*
* @param q_values [N, q_stride] Q-values from cuBLAS
* @param out_actions [N] selected factored action index (output)
* @param rng_states [N] per-episode LCG RNG counter (updated in place)
* @param epsilon exploration probability in [0, 1]
* @param N number of episodes
* @param b0_size exposure branch size (default 5)
* @param b1_size order branch size (default 3)
* @param b2_size urgency branch size (default 3)
* @param use_branching 1 for Branching DQN, 0 for flat
*/
extern "C" __global__ void experience_action_select(
const float* __restrict__ q_values,
int* out_actions,
unsigned int* rng_states,
float epsilon,
int N,
int b0_size,
int b1_size,
int b2_size,
int use_branching
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= N) return;
unsigned int rng = rng_states[i];
int action_idx;
if (use_branching) {
/* ---------------------------------------------------------------- */
/* Branching mode: three independent epsilon-greedy selections. */
/* ---------------------------------------------------------------- */
int q_stride = b0_size + b1_size + b2_size;
const float* q_b0 = q_values + (long long)i * q_stride;
const float* q_b1 = q_b0 + b0_size;
const float* q_b2 = q_b1 + b1_size;
int a0, a1, a2;
/* Branch 0: exposure */
if (lcg_random(&rng) < epsilon) {
int r = (int)(lcg_random(&rng) * (float)b0_size);
a0 = (r >= b0_size) ? b0_size - 1 : r;
} else {
a0 = argmax_n(q_b0, b0_size);
}
/* Branch 1: order type — all order types always valid, no masking */
if (lcg_random(&rng) < epsilon) {
int r = (int)(lcg_random(&rng) * (float)b1_size);
a1 = (r >= b1_size) ? b1_size - 1 : r;
} else {
a1 = argmax_n(q_b1, b1_size);
}
/* Branch 2: urgency — all urgency levels always valid, no masking */
if (lcg_random(&rng) < epsilon) {
int r = (int)(lcg_random(&rng) * (float)b2_size);
a2 = (r >= b2_size) ? b2_size - 1 : r;
} else {
a2 = argmax_n(q_b2, b2_size);
}
/* Compose factored action (Tavakoli et al., 2018, §3.1) */
action_idx = a0 * b1_size * b2_size + a1 * b2_size + a2;
} else {
/* ---------------------------------------------------------------- */
/* Flat mode: single advantage head = exposure branch only. */
/* ---------------------------------------------------------------- */
const float* q_b0 = q_values + (long long)i * b0_size;
if (lcg_random(&rng) < epsilon) {
int r = (int)(lcg_random(&rng) * (float)b0_size);
action_idx = (r >= b0_size) ? b0_size - 1 : r;
} else {
action_idx = argmax_n(q_b0, b0_size);
}
}
out_actions[i] = action_idx;
rng_states[i] = rng;
}
/* ================================================================== */
/* Kernel 3: experience_env_step */
/* ================================================================== */
/**
* Portfolio simulation, reward computation and done detection.
*
* For each episode i this kernel:
* 1. Reads the current-bar raw prices from targets[bar_idx].
* 2. Decodes the factored action to an exposure branch index.
* 3. Computes target position = exposure_fraction * max_position.
* 4. Applies position adjustment delta with a flat proportional tx cost:
* tx_cost = |delta| * raw_close * tx_cost_multiplier
* cash -= delta * raw_close + tx_cost
* 5. Computes single-step PnL:
* pnl = position_after_trade * (raw_next - raw_close)
* 6. Adds hold_reward when position ≈ 0 (flat exposure).
* 7. Writes (batch_states, action, reward, done) to the output replay
* buffer at column current_t.
* 8. Updates portfolio_states in place.
* 9. Increments current_timesteps[i].
*
* Reversal-in-two-legs, cash-reserve checks, barrier tracking, DSR,
* curiosity, diversity, fill simulation and N-step returns are deferred
* to Phase 4+ follow-up tasks.
*
* Grid: ceil(N / 256), Block: 256. One thread per episode.
*
* targets layout: [total_bars, 4]
* [0] preproc_close — log-return-normalised close (used only by the
* network; env_step uses raw prices for PnL)
* [1] preproc_next — log-return-normalised next close (unused here)
* [2] raw_close — raw close price (for position cost + tx)
* [3] raw_next — raw next-bar close (for mark-to-market PnL)
*
* portfolio_states layout: [N, 3] (read-write)
* [0] position
* [1] cash
* [2] portfolio_value
*
* out_states: [N, L, state_dim] — written at column current_t
* out_actions: [N, L]
* out_rewards: [N, L]
* out_dones: [N, L]
*
* @param targets [total_bars, 4] price data
* @param episode_starts [N] global bar offset per episode
* @param current_timesteps [N] current episode step (incremented)
* @param actions [N] factored action from action_select
* @param portfolio_states [N, 3] position/cash/portfolio_value (read-write)
* @param out_states [N, L, state_dim] replay output: states
* @param out_actions [N, L] replay output: actions
* @param out_rewards [N, L] replay output: rewards
* @param out_dones [N, L] replay output: done flags
* @param batch_states [N, state_dim] current state batch from gather kernel
* @param max_position max allowed contract position (absolute)
* @param tx_cost_multiplier proportional transaction cost rate
* @param hold_reward reward bonus for holding flat
* @param L episode length (replay buffer columns)
* @param N number of episodes
* @param total_bars length of targets/market_features
* @param state_dim state vector length
* @param b0_size exposure branch size (default 5)
* @param b1_size order branch size (default 3)
* @param b2_size urgency branch size (default 3)
* @param current_t timestep index (0-based) in this batch
*/
extern "C" __global__ void experience_env_step(
const float* __restrict__ targets,
const int* __restrict__ episode_starts,
int* current_timesteps,
const int* __restrict__ actions,
float* portfolio_states,
float* out_states,
int* out_actions,
float* out_rewards,
int* out_dones,
const float* __restrict__ batch_states,
float max_position,
float tx_cost_multiplier,
float hold_reward,
int L,
int N,
int total_bars,
int state_dim,
int b0_size,
int b1_size,
int b2_size,
int current_t
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= N) return;
int t = current_timesteps[i];
int bar_idx = episode_starts[i] + t;
/* Flat index into [N, L] output arrays. */
long long out_off = (long long)i * L + current_t;
/* ---- Write current state to replay buffer ---- */
const float* src = batch_states + (long long)i * state_dim;
float* dst = out_states + out_off * state_dim;
for (int k = 0; k < state_dim; k++)
dst[k] = src[k];
/* ---- Write action ---- */
int action_idx = actions[i];
out_actions[out_off] = action_idx;
/* ---- Out-of-data check ---- */
if (bar_idx >= total_bars - 1) {
out_rewards[out_off] = 0.0f;
out_dones[out_off] = 1;
/* Do not advance timestep: episode is at the data boundary. */
return;
}
/* ---- Read raw prices from targets ---- */
const float* tgt = targets + (long long)bar_idx * 4;
float raw_close = tgt[2];
float raw_next = tgt[3];
/* Guard against degenerate prices from data gaps. */
if (raw_close <= 0.0f) raw_close = 1.0f;
if (raw_next <= 0.0f) raw_next = raw_close;
/* ---- Read portfolio state ---- */
float* ps = portfolio_states + (long long)i * 3;
float position = ps[0];
float cash = ps[1];
/* ---- Decode exposure index from factored action ---- */
int exposure_idx;
if (b1_size > 0 && b2_size > 0) {
/* Branching: exposure branch = action / (b1_size * b2_size) */
int denom = b1_size * b2_size;
exposure_idx = action_idx / denom;
} else {
/* Flat: action IS the exposure index */
exposure_idx = action_idx;
}
/* Clamp to valid range — defence against caller bugs. */
if (exposure_idx < 0) exposure_idx = 0;
if (exposure_idx >= b0_size) exposure_idx = b0_size - 1;
float target_exposure = exposure_idx_to_fraction(exposure_idx);
float target_position = target_exposure * max_position;
/* ---- Position adjustment with flat proportional transaction cost ---- */
float delta = target_position - position;
float tx_cost = 0.0f;
if (delta != 0.0f) {
/* Transaction cost: proportional to trade size × price × rate. */
tx_cost = fabsf(delta) * raw_close * tx_cost_multiplier;
/* Cash flows: receive/pay for position change + pay tx cost. */
cash -= delta * raw_close;
cash -= tx_cost;
position = target_position;
}
/* ---- Mark-to-market PnL for this timestep ---- */
float pnl = position * (raw_next - raw_close);
/* ---- Reward: PnL minus transaction costs ---- */
float reward = pnl - tx_cost;
/* Small bonus for holding flat, matching the monolithic kernel's
* hold_reward term that prevents Flat from being structurally
* disadvantaged (Flat always produces ~0 PnL). */
if (fabsf(position) < 0.001f) {
reward += hold_reward;
}
/* ---- Done detection ---- */
int next_bar = bar_idx + 1;
int done = (next_bar >= total_bars) ? 1 : 0;
/* ---- Update portfolio state ---- */
float new_portfolio_value = cash + position * raw_next;
ps[0] = position;
ps[1] = cash;
ps[2] = new_portfolio_value;
/* ---- Write reward and done flag ---- */
out_rewards[out_off] = reward;
out_dones[out_off] = done;
/* ---- Advance timestep counter ---- */
current_timesteps[i] = t + 1;
}
/* ══════════════════════════════════════════════════════════════════════════
* LEGACY: Portfolio simulation kernel (used by GpuPortfolioSimulator)
*
* Single-thread sequential processing for hyperopt/backtest paths.
* NOT part of the timestep-loop experience collection — kept for
* GpuPortfolioSimulator compatibility in gpu_portfolio.rs.
* ══════════════════════════════════════════════════════════════════════════ */
__device__ __forceinline__ float action_to_exposure(int action_idx) {
switch (action_idx) {
case 0: return -1.0f;
case 1: return -0.5f;
case 2: return 0.0f;
case 3: return 0.5f;
case 4: return 1.0f;
default: return 0.0f;
}
}
__device__ __forceinline__ float action_to_tx_cost(int action_idx) {
(void)action_idx;
return 0.0001f;
}
extern "C" __global__ void portfolio_sim_kernel(
const float* __restrict__ targets,
const int* __restrict__ actions,
@@ -73,10 +519,8 @@ extern "C" __global__ void portfolio_sim_kernel(
int episode_length,
int total_bars
) {
// Single thread — sequential processing
if (threadIdx.x != 0 || blockIdx.x != 0) return;
// Load portfolio state into registers
float cash = portfolio_state[0];
float position = portfolio_state[1];
float entry_price = portfolio_state[2];
@@ -90,38 +534,31 @@ extern "C" __global__ void portfolio_sim_kernel(
int global_idx = batch_start + b;
int action_idx = actions[b];
// Read prices from targets[global_idx, :]
int t_offset = global_idx * 4;
float current_close = targets[t_offset + 0]; // preprocessed
float next_close = targets[t_offset + 1]; // preprocessed
float current_close_raw = targets[t_offset + 2]; // raw (for barrier)
float next_close_raw = targets[t_offset + 3]; // raw
float current_close = targets[t_offset + 0];
float next_close = targets[t_offset + 1];
float current_close_raw = targets[t_offset + 2];
float next_close_raw = targets[t_offset + 3];
// Use raw prices if available, otherwise fall back to preprocessed
float price = (current_close_raw != 0.0f) ? current_close_raw : current_close;
if (price <= 0.0f) price = 1.0f; // Safety
if (price <= 0.0f) price = 1.0f;
// Capture current portfolio value BEFORE trade
float current_value = cash + position * price;
float current_norm = current_value / initial_cap;
// --- Execute action ---
float target_exposure = action_to_exposure(action_idx);
float target_position = target_exposure * max_position;
float tx_rate = action_to_tx_cost(action_idx);
// Detect reversal (sign change)
bool is_reversal = (position > 0.0f && target_position < 0.0f) ||
(position < 0.0f && target_position > 0.0f);
if (is_reversal) {
// Phase 1: Close current position
float close_cash = position * price; // + for long, - for short
float close_cash = position * price;
float close_cost = fabsf(position) * price * tx_rate;
cash += close_cash - close_cost;
cum_costs += close_cost;
// Phase 2: Open opposite position (may be partial)
float reserve = (reserve_pct > 0.0f) ? current_value * (reserve_pct / 100.0f) : 0.0f;
float affordable = fmaxf(cash - reserve, 0.0f);
float max_contracts = (price > 0.0f) ? affordable / (price * (1.0f + tx_rate)) : 0.0f;
@@ -140,14 +577,12 @@ extern "C" __global__ void portfolio_sim_kernel(
entry_price = 0.0f;
}
} else {
// Non-reversal: adjust position directly
float delta = target_position - position;
if (fabsf(delta) > 0.0f) {
float trade_cost = fabsf(delta) * price * tx_rate;
cum_costs += trade_cost;
cash -= trade_cost;
// Cash reserve check for buys
if (delta > 0.0f && reserve_pct > 0.0f) {
float pv = cash + position * price;
float reserve = pv * (reserve_pct / 100.0f);
@@ -170,41 +605,35 @@ extern "C" __global__ void portfolio_sim_kernel(
last_price = price;
// --- Mark-to-market at next price ---
float next_price = (next_close_raw != 0.0f) ? next_close_raw : next_close;
if (next_price <= 0.0f) next_price = price;
float next_value = cash + position * next_price;
float next_norm = next_value / initial_cap;
// --- Normalized portfolio features ---
float max_pos_norm = (price > 0.0f) ? initial_cap / price : 1.0f;
float pos_norm = position / max_pos_norm;
portfolio_out[b * 3 + 0] = next_norm; // normalized value
portfolio_out[b * 3 + 1] = pos_norm; // normalized position
portfolio_out[b * 3 + 2] = spread; // spread
portfolio_out[b * 3 + 0] = next_norm;
portfolio_out[b * 3 + 1] = pos_norm;
portfolio_out[b * 3 + 2] = spread;
// --- Raw PnL reward ---
float reward = 0.0f;
if (current_norm > 0.0f) {
reward = (next_norm - current_norm) / current_norm;
}
// Risk penalty: penalize excessive position (>80% exposure)
float abs_pos = fabsf(pos_norm);
if (abs_pos > 0.8f) {
reward -= (abs_pos - 0.8f) * 5.0f * 0.1f; // risk_weight=0.1 default
reward -= (abs_pos - 0.8f) * 5.0f * 0.1f;
}
rewards_out[b] = reward;
// --- Episode boundary ---
int step = global_idx + 1;
int time_done = (step % episode_length == 0) ? 1 : 0;
int data_done = (step >= total_bars) ? 1 : 0;
done_out[b] = (time_done || data_done) ? 1 : 0;
// Reset portfolio on episode boundary
if (done_out[b]) {
cash = initial_cap;
position = 0.0f;
@@ -213,7 +642,6 @@ extern "C" __global__ void portfolio_sim_kernel(
}
}
// Write back portfolio state
portfolio_state[0] = cash;
portfolio_state[1] = position;
portfolio_state[2] = entry_price;
@@ -223,3 +651,89 @@ extern "C" __global__ void portfolio_sim_kernel(
portfolio_state[6] = reserve_pct;
portfolio_state[7] = cum_costs;
}
/* ================================================================== */
/* Kernel 4: compute_expected_q */
/* ================================================================== */
/**
* Compute expected Q-values from C51 distributional logits.
*
* For each sample and each branch action, combines value + advantage
* logits via dueling aggregation, applies softmax over atoms, and
* computes expected Q = sum(softmax(logits) * z_support).
*
* When num_atoms == 1, v_logits and b_logits are already plain
* Q-values and this kernel is not needed (pass them directly
* to action_select).
*
* Grid: ceil(N / 256), Block: 256. One thread per episode.
*
* @param v_logits [N, NA] value head logits
* @param b_logits [N, (B0+B1+B2)*NA] branch advantage logits
* @param q_values [N, B0+B1+B2] output: expected Q per action
* @param N number of samples
* @param num_atoms C51 atom count (NA)
* @param b0_size exposure branch size
* @param b1_size order branch size
* @param b2_size urgency branch size
* @param v_min C51 minimum support
* @param v_max C51 maximum support
*/
extern "C" __global__ void compute_expected_q(
const float* __restrict__ v_logits,
const float* __restrict__ b_logits,
float* q_values,
int N,
int num_atoms,
int b0_size,
int b1_size,
int b2_size,
float v_min,
float v_max
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= N) return;
int total_actions = b0_size + b1_size + b2_size;
float dz = (num_atoms > 1) ? (v_max - v_min) / (float)(num_atoms - 1) : 0.0f;
/* Per-sample value logits */
const float* v_row = v_logits + (long long)i * num_atoms;
const float* b_row = b_logits + (long long)i * total_actions * num_atoms;
/* Compute advantage mean across all actions for dueling subtraction */
/* For each action a: dueling_logits[a][z] = v[z] + adv[a][z] - mean_adv[z]
* Expected Q = sum_z( softmax(dueling_logits[a])[z] * z_support[z] )
*
* For efficiency, compute mean advantage per atom first. */
for (int a = 0; a < total_actions; a++) {
const float* adv_a = b_row + (long long)a * num_atoms;
/* Combine value + advantage (no mean subtraction for action selection —
* the argmax is invariant to the shared value baseline). */
/* Softmax + expectation over atoms */
float max_logit = -1e30f;
for (int z = 0; z < num_atoms; z++) {
float logit = v_row[z] + adv_a[z];
if (logit > max_logit) max_logit = logit;
}
float sum_exp = 0.0f;
for (int z = 0; z < num_atoms; z++) {
float logit = v_row[z] + adv_a[z];
sum_exp += expf(logit - max_logit);
}
float expected_q = 0.0f;
for (int z = 0; z < num_atoms; z++) {
float logit = v_row[z] + adv_a[z];
float prob = expf(logit - max_logit) / sum_exp;
float z_val = v_min + (float)z * dz;
expected_q += prob * z_val;
}
q_values[(long long)i * total_actions + a] = expected_q;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
//!
//! Extracts neural network weights from `GpuVarStore` objects and copies
//! them as flat `CudaSlice<f32>` GPU buffers for the CUDA experience
//! collection kernel (`dqn_experience_kernel.cu`).
//! collection kernels (`experience_kernels.cu` + cuBLAS).
//!
//! Two weight sets are managed:
//! - **Dueling Q-Network** (online + target): shared layers, value head, advantage head (12 tensors each)

View File

@@ -27,6 +27,8 @@ pub mod gpu_backtest_evaluator;
pub mod gpu_curiosity_trainer;
pub mod gpu_walk_forward;
pub mod gpu_dqn_trainer;
pub mod batched_forward;
pub mod batched_backward;
pub mod gpu_her;
pub mod gpu_iql_trainer;
pub mod gpu_iqn_head;
@@ -974,10 +976,18 @@ mod tests {
#[test]
fn test_kernel_source_contains_entry_point() {
let src = include_str!("dqn_experience_kernel.cu");
let src = include_str!("experience_kernels.cu");
assert!(
src.contains("dqn_full_experience_kernel"),
"Kernel source must contain the entry-point function name"
src.contains("experience_state_gather"),
"Kernel source must contain state_gather entry point"
);
assert!(
src.contains("experience_action_select"),
"Kernel source must contain action_select entry point"
);
assert!(
src.contains("experience_env_step"),
"Kernel source must contain env_step entry point"
);
assert!(
src.contains("extern \"C\""),

View File

@@ -0,0 +1,497 @@
# H100 Epoch Optimization Phase 3: Unified cuBLAS + Dead Code Elimination
> **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:** Reduce full-epoch wall time from 423ms to <150ms by unifying on one cuBLAS SGEMM Q-forward, restructuring the experience kernel, and eliminating ~1,900 lines of dead code.
**Architecture:** Replace the embedded warp-matvec Q-forward in the experience kernel with timestep-batched cuBLAS SGEMM calls (reusing `CublasForward` from Phase 2). Split the 3,272-line monolithic experience kernel into 3 focused kernels (state_gather, action_select, env_step). Delete all dead BF16 warp-matvec code from training and common headers.
**Tech Stack:** Rust, cudarc 0.19 (cuBLAS), CUDA (NVRTC), Philox RNG
**Spec:** `docs/superpowers/specs/2026-03-21-h100-epoch-optimization-phase3-design.md`
---
## File Structure
### Files to Create
| File | Responsibility |
|------|---------------|
| `crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu` | `grad_norm` + `adam_update` kernels (extracted from dqn_training_kernel.cu) |
| `crates/ml/src/cuda_pipeline/experience_kernels.cu` | 3 focused kernels: `state_gather`, `action_select`, `env_step` |
### Files to Delete
| File | Lines | Reason |
|------|-------|--------|
| `crates/ml/src/cuda_pipeline/dqn_training_kernel.cu` | 1,385 | All kernels replaced. grad_norm + adam extracted to utility file. |
| `crates/ml/src/cuda_pipeline/dqn_experience_kernel.cu` | 3,272 | Monolithic kernel replaced by 3 focused kernels + cuBLAS |
### Files to Modify
| File | Changes |
|------|---------|
| `gpu_dqn_trainer.rs` (3,072 lines) | Remove ~600 lines: dead methods, dead kernel fields, BF16 sync from graph. Rewrite `compile_training_kernels` to load from `dqn_utility_kernels.cu`. Replace `forward_only_q` with cuBLAS. |
| `gpu_experience_collector.rs` (1,554 lines) | Rewrite `launch_kernel` to timestep loop with cuBLAS + 3 small kernels. Share `CublasForward`. |
| `common_device_functions.cuh` (1,718 lines) | Remove BF16 warp-matvec helpers (~300 lines): `cooperative_load_tile_bf16`, `cooperative_load_bias_bf16_to_f32`, `warp_matvec_bf16_shmem`, `BRANCHING_FORWARD_DISTRIBUTIONAL` macro. |
| `mod.rs` (cuda_pipeline) | Update module exports |
---
### Task 1: Extract grad_norm + adam_update to utility kernel file
**Files:**
- Create: `crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu`
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs``compile_training_kernels()` function
- [ ] **Step 1: Create `dqn_utility_kernels.cu`**
Copy `dqn_grad_norm_kernel` (lines 1207-1241 of dqn_training_kernel.cu) and `dqn_adam_update_kernel` (lines 1253-1293) into the new file. No other kernels. Add the same `#ifndef` guards for compile-time constants.
- [ ] **Step 2: Update `compile_training_kernels` in gpu_dqn_trainer.rs**
Change `include_str!("dqn_training_kernel.cu")` to `include_str!("dqn_utility_kernels.cu")`. Remove function loads for `dqn_forward_loss_kernel`, `dqn_forward_only_kernel`, `dqn_backward_kernel`. Only load `dqn_grad_norm_kernel` and `dqn_adam_update_kernel`.
Update the function return type from 7-tuple to 2-tuple `(grad_norm, adam_update)`.
- [ ] **Step 3: Update the constructor**
Change the constructor call site to destructure the 2-tuple. Remove `forward_loss_kernel`, `forward_only_kernel`, `backward_kernel` fields from the struct.
- [ ] **Step 4: Verify compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml 2>&1 | tail -3`
- [ ] **Step 5: Commit**
```bash
git add crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "refactor: extract grad_norm + adam_update to dqn_utility_kernels.cu"
```
---
### Task 2: Remove dead methods and fields from GpuDqnTrainer
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs`
- [ ] **Step 1: Remove dead struct fields**
Delete these fields from `GpuDqnTrainer`:
- `forward_loss_kernel: CudaFunction`
- `forward_only_kernel: CudaFunction` (already removed in Task 1)
- `backward_kernel: CudaFunction` (already removed in Task 1)
- `shmem_bytes: usize` (only used by old kernel launch configs)
- [ ] **Step 2: Remove dead methods**
Delete these methods entirely:
- `forward_only_q()` (~30 lines) — zero callers
- `launch_forward_only()` (~50 lines) — only called by dead `forward_only_q`
- `forward_loss()` (~80 lines) — zero callers outside file
- `launch_forward_loss()` (~70 lines) — only called by dead `forward_loss`
- `launch_backward()` (~65 lines) — replaced by `launch_cublas_backward`
- `q_out_buf()` accessor — only used by dead `forward_only_q`
- [ ] **Step 3: Remove BF16 sync from training graph**
In `submit_training_ops()`, remove step 8 (`self.sync_online_bf16(...)`). The cuBLAS forward reads F32 from `params_buf`, not BF16 from `bf16_params_buf`.
- [ ] **Step 4: Remove dead helper functions**
Delete:
- `compute_shmem_bytes()` — only used by old kernels
- `compute_shmem_tile_rows()` — only used by old kernels
- Any shared memory sizing constants only referenced by deleted code
- [ ] **Step 5: Replace `forward_only_q` with cuBLAS version**
Add a new `forward_only_q_cublas()` method that uses `CublasForward::forward_online()` to compute Q-values. Same signature as the old method but uses the cuBLAS path. This is for any future inference needs (e.g., GPU training guard Q-value monitoring).
Read `launch_cublas_forward` for the pattern — extract Q-values from `on_v_logits_buf` and `on_b_logits_buf` via DtoD readback.
- [ ] **Step 6: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check -p ml 2>&1 | tail -3`
Run: `SQLX_OFFLINE=true cargo test -p ml-dqn --lib 2>&1 | tail -3`
Run: `SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -3`
Expected: 359 + 855 passed, 0 failed
- [ ] **Step 7: Commit**
```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "refactor: remove ~500 lines of dead code from GpuDqnTrainer"
```
---
### Task 3: Remove BF16 warp-matvec helpers from common_device_functions.cuh
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/common_device_functions.cuh`
- [ ] **Step 1: Identify BF16 warp-matvec code to remove**
Delete these functions/macros (approximate line ranges from current file):
- `cooperative_load_tile_bf16()` (~line 412-428)
- `cooperative_load_bias_bf16_to_f32()` (~line 434-441)
- `warp_matvec_bf16_shmem()` and associated helpers (~line 446-550)
- `BRANCHING_FORWARD_DISTRIBUTIONAL` macro (~line 530-551)
- `SHMEM_TILE_ROWS_BF16` constant
- Any other BF16-specific forward pass helpers
Keep:
- `f32_to_bf16_kernel` — used by BF16 seg sync (for supervised validation)
- `bf16_to_f32_kernel` — used by BF16→F32 batch upload conversion
- Block reduction helpers (`warp_reduce_sum_all`, etc.)
- Philox RNG helpers
- All `#define` constants (STATE_DIM, MARKET_DIM, etc.)
- [ ] **Step 2: Verify compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml 2>&1 | tail -3`
This will catch any remaining references to deleted helpers.
- [ ] **Step 3: Commit**
```bash
git add crates/ml/src/cuda_pipeline/common_device_functions.cuh
git commit -m "refactor: remove BF16 warp-matvec helpers (~300 lines dead code)"
```
---
### Task 4: Delete dqn_training_kernel.cu
**Files:**
- Delete: `crates/ml/src/cuda_pipeline/dqn_training_kernel.cu`
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — remove `include_str!("dqn_training_kernel.cu")`
- [ ] **Step 1: Verify no remaining references**
Search for `dqn_training_kernel` in the codebase:
```bash
grep -rn "dqn_training_kernel" crates/ml/src/
```
Only `include_str!("dqn_training_kernel.cu")` should remain (already replaced in Task 1).
- [ ] **Step 2: Delete the file**
```bash
rm crates/ml/src/cuda_pipeline/dqn_training_kernel.cu
```
- [ ] **Step 3: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml-dqn --lib && SQLX_OFFLINE=true cargo test -p ml --lib`
- [ ] **Step 4: Commit**
```bash
git add -A crates/ml/src/cuda_pipeline/
git commit -m "refactor: delete dqn_training_kernel.cu (1,385 lines — all kernels replaced)"
```
---
### Task 5: Create experience_kernels.cu — 3 focused kernels
**Files:**
- Create: `crates/ml/src/cuda_pipeline/experience_kernels.cu`
This file contains the 3 small kernels that replace the monolithic experience kernel. The Q-forward is handled by cuBLAS between kernel calls.
- [ ] **Step 1: Write `state_gather_kernel`**
```cuda
// Gather market features at each episode's current timestep into a batch.
// Grid: ceil(N/256), Block: 256.
extern "C" __global__ void experience_state_gather(
const float* __restrict__ market_features, // [total_bars, MARKET_DIM]
const float* __restrict__ ofi_features, // [total_bars, OFI_DIM] or NULL
const int* __restrict__ episode_starts, // [N]
const int* __restrict__ current_timesteps, // [N] current t for each episode
const float* __restrict__ portfolio_states, // [N, PORTFOLIO_DIM]
float* __restrict__ batch_states, // [N, STATE_DIM] output
int N, int total_bars, int state_dim, int market_dim, int ofi_dim)
```
Each thread gathers one episode's state: market features at `episode_starts[i] + current_timesteps[i]`, concatenated with portfolio state and OFI features.
- [ ] **Step 2: Write `action_select_kernel`**
```cuda
// Epsilon-greedy action selection on cuBLAS Q-value output.
// Grid: ceil(N/256), Block: 256.
extern "C" __global__ void experience_action_select(
const float* __restrict__ q_values, // [N, TOTAL_ACTIONS(11)]
int* __restrict__ out_actions, // [N]
unsigned int* __restrict__ rng_states, // [N]
float epsilon,
int N, int total_actions,
int b0_size, int b1_size, int b2_size,
int use_branching)
```
Per-episode: with probability epsilon pick random factored action, otherwise argmax over Q-values per branch → combine into factored action.
- [ ] **Step 3: Write `env_step_kernel`**
This is the largest of the 3 — it handles portfolio simulation, barrier tracking, reward computation, curiosity bonus, DSR, and done detection. Extract the per-timestep body from the monolithic kernel (the part AFTER the Q-forward and action selection).
```cuda
// Environment step: execute action, compute reward, update portfolio/barriers.
// Grid: ceil(N/256), Block: 256.
extern "C" __global__ void experience_env_step(
// Market data
const float* __restrict__ targets, // [total_bars, 4]
const int* __restrict__ episode_starts, // [N]
const int* __restrict__ current_timesteps, // [N]
const int* __restrict__ actions, // [N]
// Episode state (read-write)
float* __restrict__ portfolio_states, // [N, PORTFOLIO_STATE_SIZE]
float* __restrict__ barrier_states, // [N, BARRIER_STATE_SIZE]
// Config (read-only)
const float* __restrict__ barrier_config, // [3]
float max_position, float gamma,
float barrier_scale, float risk_weight,
float hold_reward, float tx_cost_multiplier,
// Fill simulation config
float fill_median_spread, float fill_median_vol,
float fill_ioc_fill_prob, float fill_limit_fill_min,
float fill_limit_fill_max, float fill_spread_cost_frac,
float fill_spread_capture_frac, int fill_simulation_enabled,
// DSR config
int use_dsr, float dsr_eta,
// Output for this timestep
float* __restrict__ out_rewards, // [N]
int* __restrict__ out_dones, // [N]
float* __restrict__ out_states, // [N, STATE_DIM] (the state BEFORE action)
// Misc
int N, int total_bars, int state_dim,
int use_branching, int b0_size, int b1_size, int b2_size,
unsigned int* rng_states)
```
- [ ] **Step 4: Verify kernel syntax**
Compile with NVRTC dry run (the Rust compilation will validate when integrated in Task 6).
- [ ] **Step 5: Commit**
```bash
git add crates/ml/src/cuda_pipeline/experience_kernels.cu
git commit -m "feat: 3 focused experience kernels (state_gather + action_select + env_step)"
```
---
### Task 6: Rewrite GpuExperienceCollector to use timestep-loop + cuBLAS
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs`
This is the core task. Replace the monolithic `launch_kernel()` with a timestep loop.
- [ ] **Step 1: Add CublasForward as shared dependency**
The experience collector needs access to the same `CublasForward` instance used by the trainer. Add a field:
```rust
cublas_forward: Arc<CublasForward>, // shared with GpuDqnTrainer
```
Update the constructor to accept `Arc<CublasForward>` instead of creating its own cuBLAS handle.
- [ ] **Step 2: Compile the 3 new experience kernels**
Add `compile_experience_kernels()` function that compiles `experience_kernels.cu` via NVRTC with the same `#define` injection pattern as the training kernels. Store the 3 `CudaFunction` handles.
- [ ] **Step 3: Pre-allocate timestep buffers**
Add pre-allocated buffers for the timestep loop:
```rust
// Per-timestep batch buffers (reused each timestep)
batch_states: CudaSlice<f32>, // [N, STATE_DIM]
batch_q_values: CudaSlice<f32>, // [N, 11]
batch_actions: CudaSlice<i32>, // [N]
current_timesteps: CudaSlice<i32>, // [N]
// cuBLAS scratch (reused from CublasForward — already allocated)
```
Also pre-allocate cuBLAS activation scratch buffers for the forward pass:
```rust
exp_h_s1: CudaSlice<f32>, // [N, SH1]
exp_h_s2: CudaSlice<f32>, // [N, SH2]
exp_h_v: CudaSlice<f32>, // [N, VH]
exp_h_b: CudaSlice<f32>, // [N, AH]
exp_v_logits: CudaSlice<f32>, // [N, NA]
exp_b_logits: CudaSlice<f32>, // [N, total_branch_atoms]
```
- [ ] **Step 4: Rewrite `launch_kernel()` as timestep loop**
Replace the monolithic kernel launch with:
```rust
fn launch_timestep_loop(&mut self, ...) -> Result<(usize, usize), MLError> {
let n = n_episodes;
let l = timesteps;
// Upload episode starts + initial state
// ...
// Initialize current_timesteps to all zeros
self.stream.memset_zeros(&mut self.current_timesteps)?;
for t in 0..l {
// 1. Gather states: market features + portfolio → batch_states[N, SD]
self.launch_state_gather(n)?;
// 2. cuBLAS forward: batch_states → Q-values
let param_sizes = compute_param_sizes(&self.forward_config);
let w_ptrs = f32_weight_ptrs(&self.online_params_buf, &param_sizes, &self.stream);
self.cublas_forward.forward_online(
&self.stream,
&self.batch_states,
&w_ptrs,
&self.exp_h_s1, &self.exp_h_s2,
&self.exp_h_v, &self.exp_h_b, &self.exp_h_b, &self.exp_h_b,
&self.exp_v_logits, &self.exp_b_logits,
)?;
// 3. Action selection: epsilon-greedy on Q-values
self.launch_action_select(n, epsilon)?;
// 4. Environment step: execute action, compute reward
self.launch_env_step(n, t)?;
// 5. Increment current_timesteps
// (simple kernel or managed by env_step_kernel internally)
}
Ok((n_episodes, timesteps))
}
```
- [ ] **Step 5: Flatten weight sync to single DtoD**
Replace the per-tensor weight sync (`sync_dueling_weights_branching` + `sync_branching_weights`) with a single DtoD copy of the flat `params_buf`:
```rust
pub fn sync_weights_flat(&mut self, params_buf: &CudaSlice<f32>) -> Result<(), MLError> {
let bytes = self.total_params * std::mem::size_of::<f32>();
dtod_copy(
raw_device_ptr(&self.online_params_buf, &self.stream),
raw_device_ptr(params_buf, &self.stream),
bytes, &self.stream, 0, "weight_sync_flat",
)
}
```
- [ ] **Step 6: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check -p ml`
Run: `SQLX_OFFLINE=true cargo test -p ml-dqn --lib`
Run: `SQLX_OFFLINE=true cargo test -p ml --lib`
- [ ] **Step 7: Run GPU smoke test**
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_train_step_produces_finite_metrics --ignored --test-threads=1`
- [ ] **Step 8: Commit**
```bash
git add crates/ml/src/cuda_pipeline/gpu_experience_collector.rs
git commit -m "perf: rewrite experience collector — timestep loop + cuBLAS Q-forward"
```
---
### Task 7: Delete dqn_experience_kernel.cu
**Files:**
- Delete: `crates/ml/src/cuda_pipeline/dqn_experience_kernel.cu`
- [ ] **Step 1: Verify no remaining references**
```bash
grep -rn "dqn_experience_kernel\|dqn_full_experience_kernel" crates/ml/src/
```
- [ ] **Step 2: Delete the file**
```bash
rm crates/ml/src/cuda_pipeline/dqn_experience_kernel.cu
```
- [ ] **Step 3: Verify compilation + full test suite**
Run: `SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml-dqn --lib && SQLX_OFFLINE=true cargo test -p ml --lib`
- [ ] **Step 4: Commit**
```bash
git add -A crates/ml/src/cuda_pipeline/
git commit -m "refactor: delete dqn_experience_kernel.cu (3,272 lines — replaced by cuBLAS + 3 focused kernels)"
```
---
### Task 8: Profile and validate on local GPU
**Files:**
- Read: training_loop.rs phase breakdown output
- [ ] **Step 1: Run profiling on RTX 3050**
```bash
SQLX_OFFLINE=true cargo run --release --example train_baseline_rl -p ml -- \
--model dqn --data-dir test_data/futures-baseline --symbol ES.FUT \
--epochs 3 --max-steps-per-epoch 50 --batch-size 64 \
--train-months 3 --val-months 1 --test-months 1 --step-months 3 \
2>&1 | grep -E "phase breakdown|step breakdown"
```
Expected: experience phase < 100ms (down from 348ms), total epoch < 200ms.
- [ ] **Step 2: Run full test suite**
```bash
SQLX_OFFLINE=true cargo test -p ml-core --lib && \
SQLX_OFFLINE=true cargo test -p ml-dqn --lib && \
SQLX_OFFLINE=true cargo test -p ml --lib
```
Expected: 300 + 359 + 855 = 1,514 passed, 0 failed.
- [ ] **Step 3: Run GPU smoke test with real data**
```bash
SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline \
cargo test -p ml --lib -- test_train_step_produces_finite_metrics --ignored --test-threads=1
```
Expected: PASSED with finite loss.
- [ ] **Step 4: Commit profiling results**
Update spec with measured Phase 3 speedup.
```bash
git add docs/superpowers/specs/2026-03-21-h100-epoch-optimization-phase3-design.md
git commit -m "docs: Phase 3 profiling results"
```
---
## Summary
| Task | Lines removed | Lines added | Net |
|------|:---:|:---:|:---:|
| 1. Extract utility kernels | ~1,200 | ~90 | -1,110 |
| 2. Dead methods/fields | ~500 | ~30 | -470 |
| 3. BF16 warp-matvec helpers | ~300 | 0 | -300 |
| 4. Delete dqn_training_kernel.cu | 1,385 | 0 | -1,385 |
| 5. Experience kernels | 0 | ~400 | +400 |
| 6. Rewrite experience collector | ~800 | ~400 | -400 |
| 7. Delete dqn_experience_kernel.cu | 3,272 | 0 | -3,272 |
| 8. Profile | 0 | 0 | 0 |
| **Total** | **~7,457** | **~920** | **-6,537** |

View File

@@ -0,0 +1,134 @@
# H100 Epoch Optimization Phase 3: Unified cuBLAS + Dead Code Elimination
## Goal
Reduce full-epoch wall time from 423ms to <150ms by:
1. Replacing the embedded warp-matvec Q-forward in the experience kernel with cuBLAS SGEMM (fixing the 82% bottleneck)
2. Eliminating all dead code from Phase 1/2 transitions (6,000+ lines of stale CUDA + Rust)
3. Removing BF16 warp-matvec infrastructure that is no longer on any active code path
4. Unifying on one Q-forward implementation (cuBLAS) across training, experience collection, and inference
## Current State (Post-Phase 2)
### RTX 3050 profiling (50 steps, batch_size=64, num_atoms=11):
| Phase | Time (ms) | % | Notes |
|-------|-----------|---|-------|
| Init | 8 | 2% | GPU data upload |
| **Experience** | **348** | **82%** | Warp-matvec Q-forward at 1.56% occupancy |
| Training | 39 | 9% | cuBLAS SGEMM, 0.7ms/step |
| Validation | 27 | 6% | Candle framework (not GPU trainer) |
| **Total** | **423** | 100% | |
### Dead code inventory:
| Code | Lines | Status |
|------|-------|--------|
| `dqn_forward_loss_kernel` (BF16 fused) | ~200 | No callers in training path |
| `dqn_forward_only_kernel` | ~100 | Zero callers anywhere |
| `dqn_backward_kernel` (atomicAdd) | ~300 | Replaced by cuBLAS backward |
| `forward_only_q()` + `launch_forward_only()` | ~60 | Zero callers |
| `forward_loss()` (validation) | ~60 | Zero callers outside gpu_dqn_trainer.rs |
| `launch_forward_loss()` | ~70 | Only called by dead `forward_loss()` |
| `launch_backward()` | ~65 | Replaced by cuBLAS backward |
| BF16 warp-matvec helpers in `.cuh` | ~300 | Only used by dead kernels |
| BF16 sync in training graph | ~3 | cuBLAS reads F32, not BF16 |
| Experience kernel warp-matvec Q-forward | ~700 | Replaced by cuBLAS (this phase) |
| **Total dead code** | **~1,900** | |
## Architecture
### Single Q-Forward: cuBLAS SGEMM everywhere
After Phase 3, there is ONE way to run the DQN Q-network forward pass: `CublasForward::forward_online()`. It is used in:
1. **Training** — 3 passes per step (online, target, online-next for DDQN)
2. **Experience collection** — 1 pass per timestep, batched across N episodes
3. **Inference** (`forward_only_q`) — via cuBLAS (replaces BF16 kernel)
### Experience Kernel Restructuring
**Current**: Monolithic 3,272-line CUDA kernel. Each warp runs one episode for L timesteps. The Q-forward is embedded inside the kernel using warp-cooperative shared-memory matvec.
**New**: Timestep-level loop in Rust, calling cuBLAS between environment-step kernels:
```
for t in 0..timesteps_per_episode:
1. state_gather_kernel: read episode states → batch [N, SD]
2. CublasForward::forward_online(): [N, SD] → Q-values [N, 11]
3. action_select_kernel: epsilon-greedy on Q-values → actions [N]
4. env_step_kernel: portfolio sim, reward, done → new states [N, SD]
```
The monolithic experience kernel is split into 3 small focused kernels:
- `state_gather_kernel` — reads market features at each episode's current timestep
- `action_select_kernel` — epsilon-greedy with Philox RNG (already GPU-native)
- `env_step_kernel` — portfolio simulation, barrier tracking, curiosity reward, TD error
Episode state (position, cash, barriers) moves from thread registers to global memory buffers (pre-allocated, reused across timesteps).
**Kernel launch count**: L timesteps × (1 gather + 10 GEMM + 5 bias + 1 action + 1 env) = ~18L launches. With L=100: 1,800 launches × ~3μs = 5.4ms overhead. Acceptable vs 348ms baseline.
### Files to Delete
| File | Lines | Reason |
|------|-------|--------|
| `dqn_training_kernel.cu` | 1,385 | All kernels replaced: forward→cuBLAS, backward→cuBLAS, loss→c51_loss_kernel.cu. Only `grad_norm` and `adam_update` survive — move them to a new small file. |
| `dqn_experience_kernel.cu` | 3,272 | Monolithic kernel replaced by 3 focused kernels + cuBLAS |
### Files to Create
| File | Purpose |
|------|---------|
| `dqn_utility_kernels.cu` | grad_norm + adam_update (extracted from dqn_training_kernel.cu) |
| `experience_gather_kernel.cu` | state_gather + action_select + env_step (3 small kernels) |
### Files to Modify
| File | Changes |
|------|---------|
| `gpu_dqn_trainer.rs` | Remove: `forward_only_q`, `launch_forward_only`, `forward_loss`, `launch_forward_loss`, `launch_backward`, `forward_only_kernel` field, BF16 sync from submit_training_ops. Replace `forward_only_q` with cuBLAS-based version. |
| `gpu_experience_collector.rs` | Replace monolithic `launch_kernel` with timestep loop calling cuBLAS + 3 small kernels. Share `CublasForward` instance with trainer. |
| `common_device_functions.cuh` | Remove: `cooperative_load_tile_bf16`, `cooperative_load_bias_bf16_to_f32`, `warp_matvec_bf16_shmem`, `BRANCHING_FORWARD_DISTRIBUTIONAL` macro, all BF16 warp-matvec helpers. Keep: f32_to_bf16_kernel, bf16_to_f32_kernel (for supervised validation), block reductions, Philox RNG. |
| `mod.rs` (cuda_pipeline) | Update module declarations |
| `fused_training.rs` | Remove references to old kernels |
### Dead Code Removal from gpu_dqn_trainer.rs
Fields to remove:
- `forward_loss_kernel: CudaFunction`
- `forward_only_kernel: CudaFunction`
- `backward_kernel: CudaFunction`
- `bf16_mirrors_initialized: bool` (move to lazy init in forward_only_q only)
- `shmem_bytes: usize` (only used by old kernels)
Methods to remove:
- `forward_only_q()`, `launch_forward_only()`
- `forward_loss()`, `launch_forward_loss()`
- `launch_backward()`
- `ensure_bf16_mirrors()` (inline into the one remaining caller if any)
- `sync_online_bf16()` from `submit_training_ops` (keep the method, remove the call from training graph)
### Weight Sync Simplification
Current: `sync_gpu_weights()` → extract 20 tensors from VarStore → DtoD copy each.
New: DtoD copy flat `params_buf` directly to experience collector. Single 1.1MB memcpy.
### Pipeline Overlap (future — not in Phase 3 scope)
Experience collection and training CAN overlap on separate CUDA streams since DQN is off-policy. This is noted but deferred to avoid scope creep. Phase 3 focuses on making each phase fast, not on pipelining them.
## Expected Results
| Phase | Before | After | Speedup |
|-------|--------|-------|---------|
| Experience | 348ms | ~50ms | 7x (cuBLAS occupancy) |
| Training | 39ms | ~35ms | 1.1x (remove BF16 sync from graph) |
| Validation | 27ms | 27ms | — (unchanged, uses Candle) |
| Init | 8ms | 8ms | — |
| Compile | ~3s | ~1.5s | 2x (fewer CUDA files) |
| **Total epoch** | **423ms** | **~120ms** | **3.5x** |
| Dead code removed | — | ~1,900 lines | cleaner codebase |
## Non-Goals
- Pipeline overlap (experience + training on separate streams) — separate phase
- Supervised model cuBLAS conversion — those models have their own forward pass architecture, not DQN's branching dueling network
- cuBLAS HGEMM (BF16 tensor cores) — F32 SGEMM is sufficient; BF16 is a follow-up