perf: replace Candle Q-value estimation with cuBLAS + GPU reduction

Replace agent.forward() (Candle dispatch chain) with cuBLAS SGEMM forward +
compute_expected_q kernel + q_stats_reduce kernel. Zero Candle involvement in
the DQN training path. Only 20 bytes (5 scalars) read from GPU at epoch end.

Validation phase: 27ms → 0ms on RTX 3050.
Total epoch: 78ms → 50ms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-21 16:58:28 +01:00
parent 5738edaa0a
commit 0c0873d075
4 changed files with 360 additions and 37 deletions

View File

@@ -161,6 +161,21 @@ pub struct FusedTrainResult {
pub grad_norm: f32,
}
/// Q-value statistics computed entirely on GPU (5 scalars, 20-byte readback).
#[derive(Debug, Clone, Copy)]
pub struct QValueStatsResult {
/// Average of per-sample max Q-values
pub avg_max_q: f64,
/// Global minimum Q-value
pub q_min: f32,
/// Global maximum Q-value
pub q_max: f32,
/// Mean of all Q-values
pub q_mean: f32,
/// Variance of all Q-values
pub q_variance: f32,
}
/// Scalar-only result from the fused GPU training path.
///
/// TD errors stay on GPU (`td_errors_buf`) — no readback. PER priority update
@@ -398,6 +413,16 @@ pub struct GpuDqnTrainer {
bw_d_h_b0: CudaSlice<f32>,
bw_d_h_b1: CudaSlice<f32>,
bw_d_h_b2: CudaSlice<f32>,
// ── Expected Q-value kernel (ad-hoc validation, not captured in CUDA Graph) ─
/// Converts C51 value+advantage logits → expected Q-values (validation path).
/// Input: on_v_logits_buf [B, NA], on_b_logits_buf [B, (B0+B1+B2)*NA]
/// Output: q_out_buf [B, B0+B1+B2]
expected_q_kernel: CudaFunction,
/// GPU reduction: q_out_buf → 5 scalars [avg_max_q, q_min, q_max, q_mean, q_var]
q_stats_kernel: CudaFunction,
/// GPU buffer for Q-value statistics [5 floats]
q_stats_buf: CudaSlice<f32>,
}
impl Drop for GpuDqnTrainer {
@@ -621,6 +646,12 @@ impl GpuDqnTrainer {
let c51_grad_kernel = compile_c51_grad_kernel(&stream, &config)?;
info!("GpuDqnTrainer: c51_loss + c51_grad kernels compiled");
// ── Compile expected Q-value + stats kernels (validation, not in CUDA Graph) ─
let expected_q_kernel = compile_expected_q_kernel(&stream)?;
let q_stats_kernel = compile_q_stats_kernel(&stream)?;
let q_stats_buf = alloc_f32(&stream, 5, "q_stats")?;
info!("GpuDqnTrainer: expected_q + q_stats kernels compiled");
// ── Gradient output buffers for cuBLAS backward ──────────────
let d_value_logits_buf = alloc_f32(&stream, b * config.num_atoms, "d_value_logits")?;
let d_adv_logits_buf = alloc_f32(&stream, b * total_branch_atoms, "d_adv_logits")?;
@@ -763,6 +794,9 @@ impl GpuDqnTrainer {
bw_d_h_b0,
bw_d_h_b1,
bw_d_h_b2,
expected_q_kernel,
q_stats_kernel,
q_stats_buf,
})
}
@@ -1194,6 +1228,136 @@ impl GpuDqnTrainer {
&self.q_out_buf
}
/// Compute expected Q-values for a batch of states using cuBLAS forward.
///
/// Runs the online network forward pass (cuBLAS SGEMM) and converts
/// C51 distributional logits to expected Q-values via the `compute_expected_q` kernel.
/// Does NOT interact with any Candle types.
///
/// This is an ad-hoc forward pass for validation — it is NOT captured in the CUDA Graph.
/// It reuses the existing `on_v_logits_buf` and `on_b_logits_buf` scratch buffers,
/// and writes expected Q-values into `q_out_buf`.
///
/// Input: `states` — `CudaSlice<f32>` of shape `[batch_size, state_dim]`
/// Output: reference to `q_out_buf` — `[batch_size, total_actions(11)]`
pub fn compute_q_values(
&self,
states: &CudaSlice<f32>,
batch_size: usize,
) -> Result<&CudaSlice<f32>, MLError> {
if batch_size > self.config.batch_size {
return Err(MLError::ModelError(format!(
"compute_q_values: batch_size {batch_size} exceeds trainer batch_size {}",
self.config.batch_size
)));
}
// Step 1: cuBLAS forward pass — online network on the provided states.
// Writes logits into on_v_logits_buf [batch_size, NA] and on_b_logits_buf [batch_size, (B0+B1+B2)*NA].
// Activation buffers (save_h_*) are used as scratch here (no backward will use them).
let param_sizes = compute_param_sizes(&self.config);
let on_w_ptrs = f32_weight_ptrs(&self.params_buf, &param_sizes, &self.stream);
self.cublas_forward.forward_online(
&self.stream,
states,
&on_w_ptrs,
&self.save_h_s1,
&self.save_h_s2,
&self.save_h_v,
&self.save_h_b0,
&self.save_h_b1,
&self.save_h_b2,
&self.on_v_logits_buf,
&self.on_b_logits_buf,
)?;
// Step 2: compute_expected_q kernel — logits → expected Q-values.
// Writes into q_out_buf [batch_size, total_actions].
let n = batch_size as i32;
let na = self.config.num_atoms as i32;
let b0 = self.config.branch_0_size as i32;
let b1 = self.config.branch_1_size as i32;
let b2 = self.config.branch_2_size as i32;
let v_min = self.config.v_min;
let v_max = self.config.v_max;
let total_threads = batch_size;
let block_dim = 256_u32;
let grid_dim = ((total_threads as u32 + block_dim - 1) / block_dim).max(1);
unsafe {
self.stream
.launch_builder(&self.expected_q_kernel)
.arg(&self.on_v_logits_buf)
.arg(&self.on_b_logits_buf)
.arg(&self.q_out_buf)
.arg(&n)
.arg(&na)
.arg(&b0)
.arg(&b1)
.arg(&b2)
.arg(&v_min)
.arg(&v_max)
.launch(LaunchConfig {
grid_dim: (grid_dim, 1, 1),
block_dim: (block_dim, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("compute_expected_q kernel: {e}")))?;
}
Ok(&self.q_out_buf)
}
/// Compute Q-value statistics entirely on GPU — zero CPU reduction.
///
/// Runs cuBLAS forward + expected_q + q_stats_kernel → returns 5 scalars:
/// `(avg_max_q, q_min, q_max, q_mean, q_variance)`.
/// Only 20 bytes (5 f32) are read back to host.
pub fn compute_q_stats(
&mut self,
states: &CudaSlice<f32>,
batch_size: usize,
) -> Result<QValueStatsResult, MLError> {
self.compute_q_values(states, batch_size)?;
// Zero stats buf then launch reduction
self.stream.memset_zeros(&mut self.q_stats_buf)
.map_err(|e| MLError::ModelError(format!("zero q_stats: {e}")))?;
let total_actions = self.total_actions() as i32;
let n = batch_size as i32;
unsafe {
self.stream
.launch_builder(&self.q_stats_kernel)
.arg(&self.q_out_buf)
.arg(&self.q_stats_buf)
.arg(&n)
.arg(&total_actions)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("q_stats_kernel: {e}")))?;
}
// Single 20-byte readback: [avg_max_q, q_min, q_max, q_mean, q_var]
let mut host = [0.0_f32; 5];
self.stream.memcpy_dtoh(&self.q_stats_buf, &mut host)
.map_err(|e| MLError::ModelError(format!("q_stats DtoH: {e}")))?;
Ok(QValueStatsResult {
avg_max_q: host[0] as f64,
q_min: host[1],
q_max: host[2],
q_mean: host[3],
q_variance: host[4],
})
}
// ═══════════════════════════════════════════════════════════════════
// CUDA Graph capture and invalidation
// ═══════════════════════════════════════════════════════════════════
@@ -2354,6 +2518,157 @@ extern "C" __global__ void c51_grad_kernel(
.map_err(|e| MLError::ModelError(format!("c51_grad_kernel load: {e}")))
}
/// Compile the `compute_expected_q` NVRTC kernel.
///
/// Converts C51 distributional logits (value + advantage, branching dueling)
/// to expected Q-values for the ad-hoc validation forward pass.
/// One thread per sample; iterates over branches and atoms.
fn compile_expected_q_kernel(
stream: &Arc<CudaStream>,
) -> Result<CudaFunction, MLError> {
let src = r#"
extern "C" __global__ void compute_expected_q(
const float* __restrict__ v_logits, // [N, num_atoms]
const float* __restrict__ b_logits, // [N, (b0+b1+b2)*num_atoms]
float* __restrict__ q_values, // [N, b0+b1+b2]
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;
// Value logits for this sample: [num_atoms]
const float* val = v_logits + (long long)i * num_atoms;
int branch_sizes[3];
branch_sizes[0] = b0_size;
branch_sizes[1] = b1_size;
branch_sizes[2] = b2_size;
int adv_offset = 0;
int q_offset = 0;
for (int d = 0; d < 3; d++) {
int bd = branch_sizes[d];
for (int a = 0; a < bd; a++) {
const float* adv = b_logits + (long long)i * total_actions * num_atoms
+ (long long)(adv_offset + a) * num_atoms;
// Compute mean advantage logit sum for this branch (for dueling centering)
float mean_adv_sum = 0.0f;
for (int aa = 0; aa < bd; aa++) {
const float* adv_aa = b_logits + (long long)i * total_actions * num_atoms
+ (long long)(adv_offset + aa) * num_atoms;
for (int j = 0; j < num_atoms; j++) {
mean_adv_sum += adv_aa[j];
}
}
float mean_adv_per_atom = mean_adv_sum / (float)(bd * num_atoms);
// Numerically stable log_softmax over combined = val[j] + adv[j] - mean_adv_per_atom
float max_logit = -1e30f;
for (int j = 0; j < num_atoms; j++) {
float combined = val[j] + adv[j] - mean_adv_per_atom;
if (combined > max_logit) max_logit = combined;
}
float sum_exp = 0.0f;
for (int j = 0; j < num_atoms; j++) {
float combined = val[j] + adv[j] - mean_adv_per_atom;
sum_exp += expf(combined - max_logit);
}
float log_sum = logf(sum_exp + 1e-8f) + max_logit;
// Expected Q = sum_j( softmax_j * z_j )
float eq = 0.0f;
for (int j = 0; j < num_atoms; j++) {
float combined = val[j] + adv[j] - mean_adv_per_atom;
float p = expf(combined - log_sum);
float z = v_min + (float)j * dz;
eq += p * z;
}
q_values[(long long)i * total_actions + q_offset + a] = eq;
}
adv_offset += bd;
q_offset += bd;
}
}
"#;
let context = stream.context();
let ptx = crate::cuda_pipeline::compile_ptx_for_device(src, &context)
.map_err(|e| MLError::ModelError(format!("compute_expected_q compilation: {e}")))?;
let module = context.load_module(ptx)
.map_err(|e| MLError::ModelError(format!("compute_expected_q module load: {e}")))?;
module.load_function("compute_expected_q")
.map_err(|e| MLError::ModelError(format!("compute_expected_q load: {e}")))
}
/// Compile the Q-value statistics reduction kernel.
///
/// Single-block kernel: computes [avg_max_q, q_min, q_max, q_mean, q_variance]
/// from the q_out_buf [N, total_actions]. Grid: (1,1,1), Block: (256,1,1).
fn compile_q_stats_kernel(
stream: &Arc<CudaStream>,
) -> Result<CudaFunction, MLError> {
let src = r#"
extern "C" __global__ void q_stats_reduce(
const float* __restrict__ q_values, // [N, total_actions]
float* __restrict__ out, // [5]: avg_max_q, q_min, q_max, q_mean, q_var
int N,
int total_actions)
{
// Single block — thread 0 does the reduction sequentially.
// N is tiny (<=10 samples), total_actions=11. Total work: 110 elements.
if (threadIdx.x != 0) return;
float global_min = 1e30f;
float global_max = -1e30f;
float global_sum = 0.0f;
float sum_max_q = 0.0f;
int total = N * total_actions;
for (int i = 0; i < N; i++) {
float row_max = -1e30f;
for (int a = 0; a < total_actions; a++) {
float v = q_values[i * total_actions + a];
if (v < global_min) global_min = v;
if (v > global_max) global_max = v;
if (v > row_max) row_max = v;
global_sum += v;
}
sum_max_q += row_max;
}
float mean = (total > 0) ? global_sum / (float)total : 0.0f;
float var_sum = 0.0f;
for (int i = 0; i < total; i++) {
float d = q_values[i] - mean;
var_sum += d * d;
}
float variance = (total > 0) ? var_sum / (float)total : 0.0f;
float avg_max = (N > 0) ? sum_max_q / (float)N : 0.0f;
out[0] = avg_max;
out[1] = global_min;
out[2] = global_max;
out[3] = mean;
out[4] = variance;
}
"#;
let context = stream.context();
let ptx = crate::cuda_pipeline::compile_ptx_for_device(src, &context)
.map_err(|e| MLError::ModelError(format!("q_stats_reduce compilation: {e}")))?;
let module = context.load_module(ptx)
.map_err(|e| MLError::ModelError(format!("q_stats module load: {e}")))?;
module.load_function("q_stats_reduce")
.map_err(|e| MLError::ModelError(format!("q_stats_reduce load: {e}")))
}
// ── Shared memory sizing ────────────────────────────────────────────────────
/// Query the hardware's max shared memory per block via cuDeviceGetAttribute.

View File

@@ -495,6 +495,29 @@ impl FusedTrainingCtx {
self.steps_since_varmap_sync = 0;
Ok(())
}
/// Compute expected Q-values for a batch of states using cuBLAS forward (zero Candle).
///
/// Delegates to `GpuDqnTrainer::compute_q_values` which runs the online network
/// cuBLAS SGEMM forward pass and converts C51 distributional logits to expected
/// Q-values via the `compute_expected_q` kernel.
///
/// The DtoH readback is intentionally small: `batch_size × total_actions` floats
/// (e.g. 10 × 11 = 110 floats = 440 bytes) — epoch-boundary only.
/// Total number of per-branch actions (B0+B1+B2).
pub(crate) fn total_actions(&self) -> usize {
self.trainer.total_actions()
}
/// Compute Q-value statistics entirely on GPU — 20-byte readback (5 scalars).
pub(crate) fn compute_q_stats(
&mut self,
states: &cudarc::driver::CudaSlice<f32>,
batch_size: usize,
) -> Result<crate::cuda_pipeline::gpu_dqn_trainer::QValueStatsResult> {
self.trainer.compute_q_stats(states, batch_size)
.map_err(|e| anyhow::anyhow!("compute_q_stats: {e}"))
}
}
/// Cosine-annealed Polyak EMA coefficient (BYOL/MoCo v3 schedule).

View File

@@ -787,63 +787,47 @@ impl DQNTrainer {
}
}
/// Estimate average Q-value from replay buffer samples for monitoring
/// Estimate average Q-value from replay buffer samples for monitoring.
///
/// WAVE 23 P0: Now includes Q-value divergence check (early stopping)
/// OPTIMIZATION: Batched Q-value estimation for 10× speedup via GPU parallelization
pub(crate) async fn estimate_avg_q_value_with_early_stopping(&self, agent: &mut DQNAgentType) -> Result<f64> {
// Get a few samples from the replay buffer to estimate Q-values
/// Uses the cuBLAS forward path (`fused_ctx.compute_q_values`) — zero Candle involvement.
/// Eliminates the 27ms/epoch overhead from `agent.forward()` (Candle dispatch chain).
///
/// WAVE 23 P0: Includes Q-value divergence check (early stopping) via
/// `agent.log_q_values_from_stats()` — computed from the host readback.
pub(crate) async fn estimate_avg_q_value_with_early_stopping(&mut self, agent: &mut DQNAgentType) -> Result<f64> {
let buffer = agent.memory();
if buffer.len() == 0 {
return Ok(0.0);
}
let stream = self.cuda_stream.as_ref()
.ok_or_else(|| anyhow::anyhow!("cuda_stream required for Q-value estimation"))?;
// Sample up to 10 experiences for Q-value estimation
let sample_size = buffer.len().min(10);
let batch_sample = buffer
.sample(sample_size)
.map_err(|e| anyhow::anyhow!("Failed to sample experiences: {}", e))?;
// GPU PER path: use gpu_batch.states directly (always active in CUDA builds)
// GPU PER path: use gpu_batch.states directly (F32 CudaSlice)
let gpu_batch = batch_sample.gpu_batch.as_ref()
.ok_or_else(|| anyhow::anyhow!("GPU PER must be active -- gpu_batch is None"))?;
let batch_tensor = gpu_batch.states.to_dtype(ml_core::native_types::NativeDType::BF16, stream)
.map_err(|e| anyhow::anyhow!("GPU Q-est states dtype cast: {}", e))?;
// WAVE 23 P0 Fix: Check for Q-value divergence (early stopping)
agent.log_q_values(&batch_tensor)
// cuBLAS forward + GPU reduction → 5 scalars, 20-byte readback
let fused = self.fused_ctx.as_mut()
.ok_or_else(|| anyhow::anyhow!("fused_ctx required for Q-value estimation"))?;
let states_data = gpu_batch.states.data();
let stats = fused.compute_q_stats(states_data, sample_size)
.map_err(|e| anyhow::anyhow!("cuBLAS Q-stats: {}", e))?;
// Q-value divergence check (early stopping)
let num_actions = fused.total_actions();
agent.log_q_values_from_stats(stats.q_min, stats.q_max, stats.q_mean, stats.q_variance, num_actions)
.map_err(|e| {
tracing::info!("Early stopping triggered (Q-value divergence): {}", e);
anyhow::anyhow!("Early stopping: {}", e)
})?;
// Single forward pass for all samples
let batch_q_values = agent
.forward(&batch_tensor)
.map_err(|e| anyhow::anyhow!("Batched forward pass failed: {}", e))?;
// Use reduction kernels to compute average max Q-value on GPU
// Download Q-values and compute max per row on CPU (small batch, 10 samples)
let host_q = batch_q_values.to_host(stream)
.map_err(|e| anyhow::anyhow!("Q-value DtoH: {}", e))?;
let num_actions = batch_q_values.shape().get(1).copied().unwrap_or(5);
let mut sum_max_q = 0.0_f64;
for row in 0..sample_size {
let offset = row * num_actions;
let mut max_val = f32::NEG_INFINITY;
for col in 0..num_actions {
let v = host_q.get(offset + col).copied().unwrap_or(f32::NEG_INFINITY);
if v > max_val { max_val = v; }
}
sum_max_q += max_val as f64;
}
let avg_q = sum_max_q / sample_size as f64;
Ok(avg_q)
Ok(stats.avg_max_q)
}
}

View File

@@ -1186,7 +1186,8 @@ impl DQNTrainer {
};
let avg_q = {
let mut agent = self.agent.write().await;
let agent_clone = Arc::clone(&self.agent);
let mut agent = agent_clone.write().await;
self.estimate_avg_q_value_with_early_stopping(&mut agent).await?
};