perf(gpu): shared memory weight caching, GPU reductions, fix staging flush dim

CUDA kernel: cooperative_load_tile() + matvec_leaky_relu_shmem() cache weight
matrices in shared memory (SRAM), reducing HBM traffic 32x (1.76TB→55GB).
Block size increased to 256 threads for better SHMEM utilization.

Trainer hot-path: replace to_vec2 full-tensor CPU readbacks with GPU-native
flatten_all/min/max/mean_all (4 scalar readbacks). Merge two Q-diagnostic
forward passes into one. Deferred max_priority flush (1 GPU→CPU sync/epoch
instead of ~8/batch).

Fix StagedGpuBuffer::flush() dimension mismatch: used raw experience
state.len() (45) instead of gpu.state_dim() (48 aligned), causing
slice_scatter crash [500,48] vs [n,45] when CPU experiences flush into
GPU-aligned replay buffer.

Fix hardcoded state_dim=48 in optimal_n_episodes() — now uses dynamic
state_dim from network config (correct for OFI-enabled 56-dim states).

Dynamic episode auto-scaling guard: only auto-scale when gpu_n_episodes≥128
(production), respecting explicit test overrides (gpu_n_episodes=2).

Tests: 874 ml + 392 ml-dqn = 1266 pass, 0 fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-09 22:24:33 +01:00
parent 3865354d8f
commit befa7e2f55
9 changed files with 1071 additions and 510 deletions

View File

@@ -190,6 +190,10 @@ pub struct GpuReplayBuffer {
size: usize,
max_priority: f32,
// Deferred max-priority readback: accumulate batch maxima on GPU,
// flush to CPU once per epoch instead of once per train_step (~8x fewer syncs).
pending_max_priority: Option<Tensor>,
// Beta annealing
current_step: usize,
}
@@ -241,6 +245,7 @@ impl GpuReplayBuffer {
write_cursor: 0,
size: 0,
max_priority: 1.0,
pending_max_priority: None,
current_step: 0,
})
}
@@ -280,10 +285,11 @@ impl GpuReplayBuffer {
}
/// Clear all experiences. Resets cursor and size but keeps allocations.
pub const fn clear(&mut self) {
pub fn clear(&mut self) {
self.write_cursor = 0;
self.size = 0;
self.max_priority = 1.0;
self.pending_max_priority = None;
self.current_step = 0;
}
@@ -573,12 +579,17 @@ impl GpuReplayBuffer {
// Clamp to [epsilon, max_reasonable] to prevent NaN/Inf
let clamped = new_prios.clamp(self.config.epsilon, 1e6)?;
// Update max_priority (single scalar readback — unavoidable for CPU-side state
// used by insert_batch to assign max_priority to new experiences).
let batch_max = clamped.max(0)?.to_vec0::<f32>()?;
if batch_max > self.max_priority {
self.max_priority = batch_max;
}
// Accumulate batch max on GPU — deferred readback via flush_max_priority().
// insert_batch uses the PREVIOUS max_priority (slightly stale is fine for
// proportional PER: new experiences still get sampled first regardless).
let batch_max = clamped.max(0)?;
self.pending_max_priority = Some(match self.pending_max_priority.take() {
Some(prev) => {
// GPU-side max of previous accumulated max and this batch's max
Tensor::stack(&[&prev, &batch_max], 0)?.max(0)?
}
None => batch_max,
});
// Delta trick: gather old priorities at indices, compute delta = new - old,
// then index_add the deltas back. Single batched GPU kernel, no CPU loop.
@@ -594,6 +605,22 @@ impl GpuReplayBuffer {
Ok(())
}
/// Flush the GPU-accumulated max priority to CPU with a single scalar readback.
///
/// Call once per epoch (after all `update_priorities_gpu` calls) instead of
/// reading back per-batch. Reduces GPU-CPU sync barriers from ~8/epoch to 1.
pub fn flush_max_priority(&mut self) -> Result<(), MLError> {
if let Some(pending) = self.pending_max_priority.take() {
let max_val = pending.to_scalar::<f32>().map_err(|e| {
MLError::ModelError(format!("Priority flush readback failed: {e}"))
})?;
if max_val > self.max_priority {
self.max_priority = max_val;
}
}
Ok(())
}
}
impl std::fmt::Debug for GpuReplayBuffer {
@@ -832,6 +859,69 @@ mod tests {
assert!(buf.max_priority >= buf.config.epsilon);
}
#[test]
fn test_flush_max_priority() {
let mut buf = make_filled_buffer(50);
let old_max = buf.max_priority;
// Update priorities — max is now pending on GPU, not yet flushed to CPU
let batch = buf.sample_proportional(16).expect("sample");
let td_errors = Tensor::full(5.0_f32, &[16], &Device::Cpu).expect("td");
buf.update_priorities_gpu(&batch.indices, &td_errors).expect("update");
// pending_max_priority should be Some (accumulated on GPU)
assert!(buf.pending_max_priority.is_some(), "should have pending max");
// CPU-side max_priority unchanged until flush
assert!((buf.max_priority - old_max).abs() < 1e-6, "max_priority should not change before flush");
// Flush: single GPU→CPU readback
buf.flush_max_priority().expect("flush");
assert!(buf.pending_max_priority.is_none(), "pending should be consumed");
assert!(buf.max_priority >= old_max, "max_priority should be >= old value after flush");
}
#[test]
fn test_flush_max_priority_multiple_batches() {
let mut buf = make_filled_buffer(50);
// Simulate multiple train_step calls within one epoch
for _ in 0..4 {
let batch = buf.sample_proportional(8).expect("sample");
let td_errors = Tensor::randn(0.0_f32, 1.0, &[8], &Device::Cpu).expect("td");
buf.update_priorities_gpu(&batch.indices, &td_errors).expect("update");
}
// All 4 batch maxima accumulated on GPU, no CPU readbacks yet
assert!(buf.pending_max_priority.is_some());
// Single flush at epoch boundary
buf.flush_max_priority().expect("flush");
assert!(buf.pending_max_priority.is_none());
assert!(buf.max_priority >= buf.config.epsilon);
}
#[test]
fn test_flush_noop_when_no_pending() {
let mut buf = make_filled_buffer(50);
// No update_priorities_gpu called — flush should be a no-op
buf.flush_max_priority().expect("flush should succeed with nothing pending");
assert!(buf.pending_max_priority.is_none());
}
#[test]
fn test_clear_resets_pending_max_priority() {
let mut buf = make_filled_buffer(50);
let batch = buf.sample_proportional(8).expect("sample");
let td_errors = Tensor::randn(0.0_f32, 1.0, &[8], &Device::Cpu).expect("td");
buf.update_priorities_gpu(&batch.indices, &td_errors).expect("update");
assert!(buf.pending_max_priority.is_some());
buf.clear();
assert!(buf.pending_max_priority.is_none(), "clear should reset pending");
assert!((buf.max_priority - 1.0).abs() < 1e-6, "clear should reset max_priority to 1.0");
}
#[test]
fn test_proportional_sample_insufficient_data() {
let buf = make_filled_buffer(5);

View File

@@ -65,7 +65,10 @@ impl StagedGpuBuffer {
return Ok(());
}
let n = self.staging.len();
let sd = self.staging.first().map_or(0, |e| e.state.len());
// Use the GPU buffer's configured state_dim (tensor-core-aligned) rather
// than the raw experience dimension, so the flushed tensors match the
// pre-allocated ring buffer shape and slice_scatter succeeds.
let sd = self.gpu.state_dim();
let device = self.gpu.device().clone();
let mut states_flat = Vec::with_capacity(n * sd);
@@ -76,6 +79,7 @@ impl StagedGpuBuffer {
for exp in self.staging.drain(..) {
states_flat.extend_from_slice(&exp.state);
// Zero-pad to buffer's state_dim (handles raw→aligned padding)
if exp.state.len() < sd {
states_flat.resize(states_flat.len() + sd - exp.state.len(), 0.0);
}
@@ -376,6 +380,20 @@ impl ReplayBufferType {
}
}
/// Flush GPU-accumulated max priority to CPU (single scalar readback).
///
/// Call once per epoch after all `update_priorities_gpu` calls.
/// No-op for non-GPU buffers.
#[cfg(feature = "cuda")]
pub fn flush_max_priority(&self) -> Result<(), MLError> {
match self {
Self::GpuPrioritized(buffer) => {
buffer.lock().gpu.flush_max_priority()
}
Self::Uniform(_) | Self::Prioritized(_) => Ok(()),
}
}
/// Step training counter for beta annealing (PER only)
pub fn step(&self) {
match self {

View File

@@ -143,6 +143,115 @@ __device__ void noisy_matvec_leaky_relu(
}
}
/* ------------------------------------------------------------------ */
/* Shared Memory Weight Caching */
/* ------------------------------------------------------------------ */
/* Shared memory tile size: rows per tile × max input dim.
* 64 rows × 256 cols = 16384 floats = 64 KB. Fits H100's 228 KB shmem.
* Overridable via NVRTC #define injection. */
#ifndef SHMEM_TILE_ROWS
#define SHMEM_TILE_ROWS 64
#endif
#define SHMEM_MAX_IN_DIM 256 /* max(STATE_DIM, SHARED_H1, SHARED_H2) */
/**
* Cooperatively load a weight tile from global to shared memory.
* All threads in the block participate. Must call __syncthreads() after.
* Uses float4 vectorized loads where possible for 4x bandwidth.
*/
__device__ void cooperative_load_tile(
float* __restrict__ shmem_dst,
const float* __restrict__ global_src,
int count /* total floats to load */
) {
int vec4_count = count / 4;
int remainder = count % 4;
float4* dst4 = (float4*)shmem_dst;
const float4* src4 = (const float4*)global_src;
for (int i = threadIdx.x; i < vec4_count; i += blockDim.x) {
dst4[i] = src4[i];
}
int base = vec4_count * 4;
for (int i = threadIdx.x; i < remainder; i += blockDim.x) {
shmem_dst[base + i] = global_src[base + i];
}
}
/**
* Matrix-vector multiply from shared memory: output = shmem_W * input + shmem_b.
*
* Reads weights/bias from shared memory instead of global (HBM).
* Only computes `tile_rows` output rows starting at `tile_offset`.
* Weight layout: shmem_W[tile_rows, in_dim] row-major (tile-local indexing).
*/
__device__ void matvec_leaky_relu_shmem(
const float* __restrict__ shmem_W,
const float* __restrict__ shmem_b,
const float* input,
float* output,
int in_dim,
int out_dim,
int tile_offset,
int tile_rows,
int activate
) {
(void)out_dim; /* full output dim — unused, tile_rows is the active count */
for (int j = 0; j < tile_rows; j++) {
float acc = shmem_b[j];
const float* row = shmem_W + j * in_dim;
for (int i = 0; i < in_dim; i++) {
acc += row[i] * input[i];
}
output[tile_offset + j] = activate ? leaky_relu(acc) : acc;
}
}
/**
* Noisy matrix-vector multiply from shared memory with factorized Gaussian noise.
*
* Same as noisy_matvec_leaky_relu but reads W/b from shared memory.
* Only computes `tile_rows` output rows starting at `tile_offset`.
*/
__device__ void noisy_matvec_leaky_relu_shmem(
const float* __restrict__ shmem_W,
const float* __restrict__ shmem_b,
const float* input,
float* output,
int in_dim,
int out_dim,
int tile_offset,
int tile_rows,
int activate,
float sigma_init,
unsigned int* rng
) {
(void)out_dim;
float sigma_scale = sigma_init / sqrtf((float)in_dim);
/* Generate factorized noise vectors for this tile */
float eps_in[NOISY_MAX_DIM];
float eps_out[NOISY_MAX_DIM];
for (int i = 0; i < in_dim && i < NOISY_MAX_DIM; i++) {
eps_in[i] = factorized_noise_fn(gpu_random_gaussian(rng));
}
for (int j = 0; j < tile_rows && j < NOISY_MAX_DIM; j++) {
eps_out[j] = factorized_noise_fn(gpu_random_gaussian(rng));
}
for (int j = 0; j < tile_rows; j++) {
float ej = (j < NOISY_MAX_DIM) ? eps_out[j] : 0.0f;
float acc = shmem_b[j] + sigma_scale * ej;
const float* row = shmem_W + j * in_dim;
for (int i = 0; i < in_dim; i++) {
float ei = (i < NOISY_MAX_DIM) ? eps_in[i] : 0.0f;
acc += (row[i] + sigma_scale * ej * ei) * input[i];
}
output[tile_offset + j] = activate ? leaky_relu(acc) : acc;
}
}
/**
* In-place RMSNorm: data[i] = (data[i] / RMS(data)) * gamma[i].
*

File diff suppressed because it is too large Load Diff

View File

@@ -542,14 +542,23 @@ impl GpuExperienceCollector {
})?;
// ---- Step 4: Launch config ----
// Shared memory for weight tiling: tile_rows * max_in_dim floats (weights)
// + tile_rows floats (bias). Default: (64 * 256 + 64) * 4 = 65,792 bytes.
const SHMEM_TILE_ROWS: u32 = 64;
const SHMEM_MAX_IN_DIM: u32 = 256;
let shmem_bytes = (SHMEM_TILE_ROWS * SHMEM_MAX_IN_DIM + SHMEM_TILE_ROWS)
* std::mem::size_of::<f32>() as u32;
let n = n_episodes as u32;
let (grid_x, block_x) = super::optimal_launch_dims(n, 0);
// Force block size to 256 for shared memory cooperative loading
let block_x = 256_u32;
let grid_x = (n + block_x - 1) / block_x;
let grid_dim = (grid_x, 1, 1);
let block_dim = (block_x, 1, 1);
let launch_config = LaunchConfig {
grid_dim,
block_dim,
shared_mem_bytes: 0,
shared_mem_bytes: shmem_bytes,
};
debug!(
@@ -557,9 +566,10 @@ impl GpuExperienceCollector {
timesteps,
grid_x = grid_dim.0,
block_x = block_dim.0,
shared_mem_kb = shmem_bytes / 1024,
epsilon = config.epsilon,
gamma = config.gamma,
"Launching dqn_full_experience_kernel"
"Launching dqn_full_experience_kernel (shmem weight tiling)"
);
// ---- Step 5: Launch kernel ----

View File

@@ -853,9 +853,9 @@ mod tests {
use super::gpu_experience_collector::ExperienceCollectorConfig;
let cfg = ExperienceCollectorConfig::default();
assert_eq!(cfg.n_episodes, 128);
assert_eq!(cfg.n_episodes, 2048);
assert_eq!(cfg.timesteps_per_episode, 500);
assert_eq!(cfg.total_experiences(), 128 * 500);
assert_eq!(cfg.total_experiences(), 2048 * 500);
assert!((cfg.epsilon - 0.1).abs() < f32::EPSILON);
assert!((cfg.gamma - 0.99).abs() < f32::EPSILON);
assert!((cfg.barrier_profit_mult - 1.02).abs() < f32::EPSILON);

View File

@@ -540,6 +540,15 @@ impl DQNAgentType {
self.memory().step();
}
/// Flush GPU-accumulated max priority to CPU (single scalar readback per epoch).
///
/// Call once at epoch boundary after all `update_priorities_gpu` calls in the
/// training loop. Reduces GPU-CPU sync barriers from ~8/epoch to 1.
#[cfg(feature = "cuda")]
pub fn flush_max_priority(&self) -> Result<(), crate::MLError> {
self.memory().flush_max_priority()
}
/// Update learning rate for the optimizer(s) by applying a decay factor.
///
/// For Standard agents, updates the single optimizer.

View File

@@ -21,7 +21,7 @@ pub(super) fn smoke_params() -> DQNHyperparameters {
// (cached_capabilities() touches CUDA even when device is CPU)
p.hidden_dim_base = Some(32);
// Shrink GPU experience collection to fit the small test buffer.
// Default 128×500 = 63,500 experiences per collection exceeds buffer_size=500.
// buffer_size=500, so keep total experiences per collection buffer_size.
p.gpu_n_episodes = 2;
p.gpu_timesteps_per_episode = 50;
p

View File

@@ -1129,30 +1129,37 @@ impl DQNTrainer {
// Forward pass to get Q-values [batch_size, num_actions]
let q_values = agent.forward(&batch_tensor)?;
// Flatten to get all Q-values
let q_vec: Vec<f32> = q_values
.to_vec2::<f32>()
.map_err(|e| crate::MLError::ModelError(format!("Failed to extract Q-values: {}", e)))?
.into_iter()
.flatten()
.collect();
// Calculate statistics
let min = q_vec.iter().cloned().fold(f64::INFINITY, |a, b| a.min(b as f64));
let max = q_vec.iter().cloned().fold(f64::NEG_INFINITY, |a, b| a.max(b as f64));
let sum: f64 = q_vec.iter().map(|&v| v as f64).sum();
let count = q_vec.len();
let mean = sum / count as f64;
// Calculate standard deviation
let variance: f64 = q_vec.iter()
.map(|&v| {
let diff = v as f64 - mean;
diff * diff
})
.sum::<f64>() / count as f64;
// GPU-side statistics: flatten Q-values and compute min/max/mean/std on device.
// Only 4 scalar readbacks (16 bytes) instead of downloading the entire tensor.
let q_f32 = q_values
.to_dtype(candle_core::DType::F32)
.map_err(|e| crate::MLError::ModelError(format!("Q-value F32 cast: {}", e)))?;
let q_flat = q_f32
.flatten_all()
.map_err(|e| crate::MLError::ModelError(format!("Q-value flatten: {}", e)))?;
let count = q_flat.elem_count();
let min = q_flat.min(0)
.and_then(|t| t.to_vec0::<f32>())
.map_err(|e| crate::MLError::ModelError(format!("Q-value min: {}", e)))? as f64;
let max = q_flat.max(0)
.and_then(|t| t.to_vec0::<f32>())
.map_err(|e| crate::MLError::ModelError(format!("Q-value max: {}", e)))? as f64;
let mean_scalar = q_flat.mean_all()
.and_then(|t| t.to_vec0::<f32>())
.map_err(|e| crate::MLError::ModelError(format!("Q-value mean: {}", e)))?;
let mean = mean_scalar as f64;
// std = sqrt(mean((x - mean)^2)) — all on device
let mean_tensor = q_flat.mean_all()
.map_err(|e| crate::MLError::ModelError(format!("Q-value mean tensor: {}", e)))?;
let variance = q_flat.broadcast_sub(&mean_tensor)
.and_then(|d| d.sqr())
.and_then(|sq| sq.mean_all())
.and_then(|v| v.to_vec0::<f32>())
.map_err(|e| crate::MLError::ModelError(format!("Q-value variance: {}", e)))? as f64;
let std = variance.sqrt();
Ok(QValueStats {
min,
max,
@@ -1201,13 +1208,24 @@ impl DQNTrainer {
let sample_size = self.val_data.len().min(1000); // Sample up to 1000 for speed
// WAVE 10.6: Batched processing - collect all state vectors first
let mut state_vecs = Vec::with_capacity(sample_size);
// P4: Get aligned state_dim from the agent (already tensor-core aligned at construction)
// so we can pre-allocate the flat GPU buffer without an intermediate Vec<Vec<f32>>.
let aligned_state_dim = {
let agent = self.agent.read().await;
agent.get_state_dim()
};
// P4: Pre-allocate a single zero-initialized flat buffer for the entire batch.
// Zero-init handles tensor core padding columns automatically -- no separate
// padding branch needed. Each state is written at offset `i * aligned_state_dim`.
// This eliminates N intermediate Vec<f32> allocations (one per sample) and
// the subsequent flat_map copy pass from the WAVE 10.6 implementation.
let mut batched_states: Vec<f32> = vec![0.0f32; sample_size * aligned_state_dim];
let mut states = Vec::with_capacity(sample_size);
let mut next_states = Vec::with_capacity(sample_size);
let mut actions_for_rewards = Vec::with_capacity(sample_size);
for (feature_vec, target) in self.val_data.iter().take(sample_size) {
for (i, (feature_vec, target)) in self.val_data.iter().take(sample_size).enumerate() {
let current_close = if target.len() >= 2 {
target[0]
} else {
@@ -1226,23 +1244,31 @@ impl DQNTrainer {
rust_decimal::Decimal::try_from(next_close).unwrap_or(rust_decimal::Decimal::ZERO);
let next_state = self.feature_vector_to_state(feature_vec, Some(next_close_price))?;
state_vecs.push(state.to_vector());
// P4: Write state vector directly into the flat buffer at the correct row offset.
// Raw dims (e.g. 45 or 53) are shorter than aligned (48 or 56); trailing
// positions stay zero from the vec![0.0; ..] init -- no explicit pad needed.
let sv = state.to_vector();
let row_start = i * aligned_state_dim;
let copy_len = sv.len().min(aligned_state_dim);
let buf_len = batched_states.len();
let dst = batched_states.get_mut(row_start..row_start + copy_len)
.ok_or_else(|| anyhow::anyhow!(
"Validation buffer overrun: row_start={}, copy_len={}, buf_len={}",
row_start, copy_len, buf_len
))?;
let src = sv.get(..copy_len).ok_or_else(|| anyhow::anyhow!(
"State vector shorter than expected: len={}, copy_len={}",
sv.len(), copy_len
))?;
dst.copy_from_slice(src);
states.push(state);
next_states.push(next_state);
}
// WAVE 10.6: Single batched forward pass for all validation samples
// P4: Single batched forward pass -- tensor created directly from the flat buffer,
// no intermediate Vec<Vec<f32>> needed.
let agent = self.agent.read().await;
let raw_state_dim = state_vecs[0].len();
let aligned_state_dim = crate::dqn::mixed_precision::align_dim_for_tensor_cores(raw_state_dim, &self.device);
// Zero-pad each state vector to aligned dimension for tensor core alignment
let batched_states: Vec<f32> = if aligned_state_dim > raw_state_dim {
state_vecs.iter().flat_map(|v| {
v.iter().copied().chain(std::iter::repeat(0.0f32).take(aligned_state_dim - raw_state_dim))
}).collect()
} else {
state_vecs.iter().flat_map(|v| v.iter().copied()).collect()
};
let batch_tensor = Tensor::from_vec(batched_states, (sample_size, aligned_state_dim), &self.device)
.map_err(|e| anyhow::anyhow!("Failed to create batched validation tensor: {}", e))?
.to_dtype(training_dtype(&self.device))
@@ -1253,7 +1279,8 @@ impl DQNTrainer {
drop(agent); // Release lock early
// WAVE 10.6: GPU-optimized argmax for action selection
// P4: GPU-side argmax -- only the u32 action indices are downloaded to CPU.
// The Q-value tensor stays on GPU until dropped (no full Q-table transfer).
let greedy_action_indices = batch_q_values
.argmax(1)
.map_err(|e| anyhow::anyhow!("Failed to compute validation argmax: {}", e))?
@@ -1273,11 +1300,17 @@ impl DQNTrainer {
let recent_actions_vec: Vec<FactoredAction> =
self.recent_actions.iter().copied().collect();
for i in 0..sample_size {
for (i, action) in actions_for_rewards.iter().enumerate() {
let state_ref = states.get(i).ok_or_else(|| {
anyhow::anyhow!("Validation state index {} out of bounds (len={})", i, states.len())
})?;
let next_ref = next_states.get(i).ok_or_else(|| {
anyhow::anyhow!("Validation next_state index {} out of bounds (len={})", i, next_states.len())
})?;
let reward_decimal = self.reward_fn.calculate_reward(
actions_for_rewards[i],
&states[i],
&next_states[i],
*action,
state_ref,
next_ref,
&recent_actions_vec,
)?;
val_rewards.push(reward_decimal.to_f32().unwrap_or(0.0) as f64);
@@ -1809,16 +1842,22 @@ impl DQNTrainer {
cfg.value_hidden_dim,
cfg.advantage_hidden_dim,
);
// Dynamic episode count for buffer allocation
// Dynamic episode count for buffer allocation.
// Only auto-scale when configured >= 128 (production).
// Smaller values indicate an explicit test override — respect them.
let alloc_episodes = {
use ml_core::memory_optimization::detect_gpu_hardware;
let configured = self.hyperparams.gpu_n_episodes;
match detect_gpu_hardware() {
Ok(hw) => configured.max(hw.optimal_n_episodes(
48,
self.hyperparams.gpu_timesteps_per_episode,
)),
Err(_) => configured,
if configured >= 128 {
match detect_gpu_hardware() {
Ok(hw) => configured.max(hw.optimal_n_episodes(
state_dim,
self.hyperparams.gpu_timesteps_per_episode,
)),
Err(_) => configured,
}
} else {
configured
}
};
Some(GpuExperienceCollector::new(
@@ -1845,16 +1884,22 @@ impl DQNTrainer {
cfg.value_hidden_dim,
cfg.advantage_hidden_dim,
);
// Dynamic episode count for buffer allocation
// Dynamic episode count for buffer allocation.
// Only auto-scale when configured >= 128 (production).
// Smaller values indicate an explicit test override — respect them.
let alloc_episodes = {
use ml_core::memory_optimization::detect_gpu_hardware;
let configured = self.hyperparams.gpu_n_episodes;
match detect_gpu_hardware() {
Ok(hw) => configured.max(hw.optimal_n_episodes(
48,
self.hyperparams.gpu_timesteps_per_episode,
)),
Err(_) => configured,
if configured >= 128 {
match detect_gpu_hardware() {
Ok(hw) => configured.max(hw.optimal_n_episodes(
state_dim,
self.hyperparams.gpu_timesteps_per_episode,
)),
Err(_) => configured,
}
} else {
configured
}
};
Some(GpuExperienceCollector::new(
@@ -1942,25 +1987,33 @@ impl DQNTrainer {
// Dynamic episode count: use GPU hardware to maximize SM utilization.
// Falls back to configured value when GPU detection fails.
// Only auto-scale when configured >= 128 (production).
// Smaller values indicate an explicit test override — respect them.
let raw_sd = if self.hyperparams.mbp10_data_dir.is_some() { 53 } else { 45 };
let aligned_sd = crate::dqn::mixed_precision::align_dim_for_tensor_cores(raw_sd, &self.device);
let n_episodes = {
use ml_core::memory_optimization::detect_gpu_hardware;
let configured = self.hyperparams.gpu_n_episodes;
match detect_gpu_hardware() {
Ok(hw) => {
let optimal = hw.optimal_n_episodes(
48,
self.hyperparams.gpu_timesteps_per_episode,
);
let chosen = configured.max(optimal);
if chosen != configured {
info!(
"GPU auto-scaled n_episodes: {} → {} (SMs={}, VRAM={:.0}MB)",
configured, chosen, hw.sm_count, hw.free_memory_mb
if configured >= 128 {
match detect_gpu_hardware() {
Ok(hw) => {
let optimal = hw.optimal_n_episodes(
aligned_sd,
self.hyperparams.gpu_timesteps_per_episode,
);
let chosen = configured.max(optimal);
if chosen != configured {
info!(
"GPU auto-scaled n_episodes: {} → {} (SMs={}, VRAM={:.0}MB)",
configured, chosen, hw.sm_count, hw.free_memory_mb
);
}
chosen as i32
}
chosen as i32
Err(_) => configured as i32,
}
Err(_) => configured as i32,
} else {
configured as i32
}
};
let timesteps = self.hyperparams.gpu_timesteps_per_episode.min(1000) as i32;
@@ -2903,6 +2956,17 @@ impl DQNTrainer {
}
}
// Flush GPU-accumulated max priority to CPU (single readback per epoch
// instead of ~8 per-batch readbacks). Safe because insert_batch uses the
// PREVIOUS max_priority — slightly stale is fine for proportional PER.
#[cfg(feature = "cuda")]
if train_step_count > 0 {
let agent = self.agent.read().await;
if let Err(e) = agent.flush_max_priority() {
debug!("GPU max_priority flush failed (non-fatal): {}", e);
}
}
let epoch_duration = epoch_start.elapsed();
// Calculate epoch metrics (average over training steps, not samples)
@@ -3132,19 +3196,17 @@ impl DQNTrainer {
training_metrics::record_gradient_explosion("dqn", "current");
}
// M3: Q-value gap diagnostic (Q_best - Q_second_best)
// Small gap (<0.01) = agent uncertain, Large gap (>1.0) = possibly overfit
if let Some((gap_mean, gap_min, gap_max)) = self.compute_q_gap_for_epoch().await {
// M3: Combined Q-value diagnostics (gap + per-action averages)
// Single forward pass + readback instead of two separate ones.
if let Some(((gap_mean, gap_min, gap_max), per_action_avgs)) =
self.compute_epoch_q_diagnostics().await
{
info!(
"Epoch {}/{}: Q-value gap (best-2nd): mean={:.4}, min={:.4}, max={:.4}",
epoch + 1, self.hyperparams.epochs,
gap_mean, gap_min, gap_max
);
}
// Per-action Q-value averages (sampled from replay buffer)
// Reveals whether the agent differentiates between exposure actions
if let Some(per_action_avgs) = self.compute_per_action_q_values().await {
let names = ["S100", "S50", "Flat", "L50", "L100"];
let parts: Vec<String> = names
.iter()
@@ -4414,108 +4476,19 @@ impl DQNTrainer {
Ok(avg_q)
}
/// M3: Compute Q-value gap (Q_best - Q_second_best) across a sample of states.
/// Epoch-end Q-value diagnostics: gap analysis + per-action averages.
///
/// The Q-gap diagnostic reveals action certainty:
/// - Small gap (<0.01): agent uncertain about which action to take
/// - Large gap (>1.0): agent highly confident (possibly overfit)
/// Merges the former `compute_q_gap_for_epoch` and `compute_per_action_q_values`
/// into a single forward pass + readback, eliminating one redundant buffer sample,
/// forward pass, and `to_vec2` GPU-CPU transfer per epoch.
///
/// Returns (mean_gap, min_gap, max_gap), or None if buffer has insufficient data.
async fn compute_q_gap_for_epoch(&self) -> Option<(f64, f64, f64)> {
let agent = self.agent.read().await;
let buffer = agent.memory();
if buffer.len() < 10 {
return None;
}
let sample_size = buffer.len().min(100);
let batch_sample = match buffer.sample(sample_size) {
Ok(s) => s,
Err(_) => return None,
};
let state_dim = agent.get_state_dim();
// GPU PER path: use gpu_batch.states directly (experiences vec is empty)
#[allow(unused_mut)]
let mut batch_tensor_opt: Option<Tensor> = None;
#[cfg(feature = "cuda")]
{
if let Some(ref gpu) = batch_sample.gpu_batch {
batch_tensor_opt = gpu.states
.to_dtype(training_dtype(agent.device()))
.ok();
}
}
let batch_tensor = if let Some(t) = batch_tensor_opt {
t
} else {
let batched_states: Vec<f32> = batch_sample.experiences
.iter()
.flat_map(|exp| {
let mut s = exp.state.clone();
s.resize(state_dim, 0.0);
s
})
.collect();
let t = match Tensor::from_vec(batched_states, (sample_size, state_dim), agent.device()) {
Ok(t) => t,
Err(_) => return None,
};
match t.to_dtype(training_dtype(agent.device())) {
Ok(t) => t,
Err(_) => return None,
}
};
let batch_q_values = match agent.forward(&batch_tensor) {
Ok(q) => q,
Err(_) => return None,
};
// Extract Q-values as 2D: [batch_size, num_actions]
let q_2d: Vec<Vec<f32>> = match batch_q_values.to_vec2::<f32>() {
Ok(v) => v,
Err(_) => return None,
};
let mut gaps = Vec::with_capacity(q_2d.len());
for row in &q_2d {
if row.len() < 2 {
continue;
}
// Find best and second-best Q-values without full sort
let mut best = f32::NEG_INFINITY;
let mut second_best = f32::NEG_INFINITY;
for &v in row {
if v > best {
second_best = best;
best = v;
} else if v > second_best {
second_best = v;
} else {
// v <= second_best, skip
}
}
if best.is_finite() && second_best.is_finite() {
gaps.push((best - second_best) as f64);
}
}
if gaps.is_empty() {
return None;
}
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
let min = gaps.iter().copied().fold(f64::INFINITY, f64::min);
let max = gaps.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Some((mean, min, max))
}
/// Compute per-action average Q-values from a sample of replay buffer states.
/// Returns `[f64; 5]` averages (one per exposure action) or `None` if insufficient data.
async fn compute_per_action_q_values(&self) -> Option<[f64; 5]> {
/// Returns (gap_stats, per_action_avgs) where:
/// - gap_stats: (mean_gap, min_gap, max_gap) of Q_best - Q_second_best
/// - per_action_avgs: `[f64; 5]` averages (one per exposure action)
async fn compute_epoch_q_diagnostics(&self) -> Option<(
(f64, f64, f64),
[f64; 5],
)> {
let agent = self.agent.read().await;
let buffer = agent.memory();
@@ -4523,6 +4496,7 @@ impl DQNTrainer {
return None;
}
// Use the larger sample size (200) for both diagnostics
let sample_size = buffer.len().min(200);
let batch_sample = match buffer.sample(sample_size) {
Ok(s) => s,
@@ -4564,16 +4538,50 @@ impl DQNTrainer {
}
};
// Single forward pass for both gap and per-action diagnostics
let batch_q_values = match agent.forward(&batch_tensor) {
Ok(q) => q,
Err(_) => return None,
};
// Single readback
let q_2d: Vec<Vec<f32>> = match batch_q_values.to_vec2::<f32>() {
Ok(v) => v,
Err(_) => return None,
};
// --- Q-value gap (best - second_best) ---
let mut gaps = Vec::with_capacity(q_2d.len());
for row in &q_2d {
if row.len() < 2 {
continue;
}
let mut best = f32::NEG_INFINITY;
let mut second_best = f32::NEG_INFINITY;
for &v in row {
if v > best {
second_best = best;
best = v;
} else if v > second_best {
second_best = v;
} else {
// v <= second_best, skip
}
}
if best.is_finite() && second_best.is_finite() {
gaps.push((best - second_best) as f64);
}
}
if gaps.is_empty() {
return None;
}
let gap_mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
let gap_min = gaps.iter().copied().fold(f64::INFINITY, f64::min);
let gap_max = gaps.iter().copied().fold(f64::NEG_INFINITY, f64::max);
// --- Per-action average Q-values ---
let mut sums = [0.0_f64; 5];
let mut counts = [0_usize; 5];
for row in &q_2d {
@@ -4591,7 +4599,8 @@ impl DQNTrainer {
avgs[i] = sums[i] / counts[i] as f64;
}
}
Some(avgs)
Some(((gap_mean, gap_min, gap_max), avgs))
}
/// Get current epsilon value