fix(ml): OOM guard on CPU PER fallback, clippy cleanup, PPO recurrent test fixes

- Add 4 GB pre-flight memory check in try_gpu_prioritized_with_fallback
  before attempting CPU PER fallback (prevents system OOM on absurd capacity)
- GPU pre-flight already rejects oversized buffers; CPU fallback was unguarded
- Fix integration test: assert Err for 500M capacity instead of unwrap
- Fix PPO recurrent tests: TradingAction → FactoredAction, num_actions 3/5 → 45
- Clippy: wildcard match arms → explicit variants, add missing else clause

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-03 00:55:36 +01:00
parent e4da2fb3ec
commit 410ede2ad7
6 changed files with 121 additions and 13 deletions

View File

@@ -51,10 +51,26 @@ impl GpuReplayBuffer {
///
/// All tensors are allocated on the given device (CPU or CUDA) at creation.
/// For 100K capacity × 51 state_dim, this uses ~47 MB VRAM.
/// Maximum VRAM budget for the replay buffer (4 GB).
/// Prevents accidental OOM from misconfigured capacity/state_dim.
const MAX_BYTES: usize = 4 * 1024 * 1024 * 1024;
pub fn new(config: GpuReplayBufferConfig, device: &Device) -> Result<Self, MLError> {
let cap = config.capacity;
let sdim = config.state_dim;
// Pre-flight memory check: 2 state matrices + 3 vectors + 1 priority vector
// states + next_states: 2 * cap * sdim * 4 bytes
// actions: cap * 4 (u32), rewards + dones + priorities: 3 * cap * 4
let bytes_needed = 2 * cap * sdim * 4 + 4 * cap * 4;
if bytes_needed > Self::MAX_BYTES {
return Err(MLError::ModelError(format!(
"GPU replay buffer would need {} MB (limit {} MB). Reduce capacity or state_dim.",
bytes_needed / (1024 * 1024),
Self::MAX_BYTES / (1024 * 1024),
)));
}
let states = Tensor::zeros(&[cap, sdim], DType::F32, device)?;
let next_states = Tensor::zeros(&[cap, sdim], DType::F32, device)?;
let actions = Tensor::zeros(&[cap], DType::U32, device)?;

View File

@@ -492,6 +492,10 @@ pub struct GradientResult {
/// GPU-resident buffer indices (GpuPrioritized path).
#[cfg(feature = "cuda")]
pub indices_gpu: Option<Tensor>,
/// Loss tensor on GPU for deferred batch readback.
/// When Some, the trainer accumulates on GPU and reads once at end.
#[cfg(feature = "cuda")]
pub loss_tensor_gpu: Option<Tensor>,
}
/// Internal result from forward pass + loss computation (no backward pass).
@@ -2084,6 +2088,8 @@ impl DQN {
td_errors_gpu: result.td_errors_gpu,
#[cfg(feature = "cuda")]
indices_gpu: result.indices_gpu,
#[cfg(feature = "cuda")]
loss_tensor_gpu: Some(result.loss_tensor.detach()),
})
}

View File

@@ -137,6 +137,65 @@ impl ReplayBufferType {
Ok(Self::GpuPrioritized(Arc::new(Mutex::new(buffer))))
}
/// Try GPU-resident PER, fall back to CPU PER on allocation failure.
///
/// On L40S/H100 this always succeeds (47 MB for 100K buffer).
/// On smaller GPUs or when VRAM is fragmented, gracefully degrades.
#[cfg(feature = "cuda")]
pub fn try_gpu_prioritized_with_fallback(
capacity: usize,
state_dim: usize,
alpha: f64,
beta: f64,
beta_max: f64,
beta_annealing_steps: usize,
device: &candle_core::Device,
) -> Result<Self, MLError> {
match Self::new_gpu_prioritized(capacity, state_dim, alpha, beta, beta_max, beta_annealing_steps, device) {
Ok(buf) => {
tracing::info!(
"GPU PER replay buffer allocated ({} capacity, {} state_dim)",
capacity, state_dim
);
Ok(buf)
}
Err(e) => {
tracing::warn!(
"GPU PER allocation failed ({}), falling back to CPU PER",
e
);
// Pre-flight: estimate CPU PER memory before attempting fallback.
// SegmentTree allocates 2 * next_power_of_two(capacity) f32 entries.
// PrioritizedReplayBuffer allocates capacity Option<Experience> slots.
const MAX_CPU_PER_BYTES: usize = 4 * 1024 * 1024 * 1024; // 4 GB
let tree_elems = capacity
.checked_next_power_of_two()
.and_then(|p| p.checked_mul(2))
.unwrap_or(usize::MAX);
let tree_bytes = tree_elems.saturating_mul(std::mem::size_of::<f32>());
// Each Option<Experience>: 2 state vecs (state_dim * 4 each) + ~64 bytes overhead
let exp_bytes = capacity.saturating_mul(
state_dim.saturating_mul(8).saturating_add(64),
);
let estimated_bytes = tree_bytes.saturating_add(exp_bytes);
if estimated_bytes > MAX_CPU_PER_BYTES {
return Err(MLError::ModelError(format!(
"GPU PER failed ({}) and CPU PER fallback would need ~{} MB (limit {} MB). \
Reduce capacity or state_dim.",
e,
estimated_bytes / (1024 * 1024),
MAX_CPU_PER_BYTES / (1024 * 1024),
)));
}
Self::new_prioritized(capacity, alpha, beta, beta_max, beta_annealing_steps)
}
}
}
/// Sample a batch from the buffer
pub fn sample(&self, batch_size: usize) -> Result<BatchSample, MLError> {
match self {
@@ -254,7 +313,7 @@ impl ReplayBufferType {
let mut buf = buffer.lock();
buf.update_priorities_gpu(indices, td_errors)
}
_ => Ok(()), // No-op for non-GPU buffers
Self::Uniform(_) | Self::Prioritized(_) => Ok(()), // No-op for non-GPU buffers
}
}
@@ -425,7 +484,7 @@ impl ReplayBufferType {
pub fn as_gpu_buffer(&self) -> Option<parking_lot::MutexGuard<'_, crate::cuda_pipeline::gpu_replay_buffer::GpuReplayBuffer>> {
match self {
Self::GpuPrioritized(buffer) => Some(buffer.lock()),
_ => None,
Self::Uniform(_) | Self::Prioritized(_) => None,
}
}

View File

@@ -3523,6 +3523,8 @@ impl DQNTrainer {
agent
.update_priorities(&all_indices, &all_td_errors)
.map_err(|e| anyhow::anyhow!("PER priority update failed: {}", e))?;
} else {
// No priority updates needed (uniform buffer or empty batch)
}
}
#[cfg(not(feature = "cuda"))]

View File

@@ -8,6 +8,7 @@
use candle_core::{Device, DType, Tensor};
use ml::cuda_pipeline::gpu_replay_buffer::{GpuReplayBuffer, GpuReplayBufferConfig};
use ml::dqn::replay_buffer_type::ReplayBufferType;
fn test_config(capacity: usize, state_dim: usize) -> GpuReplayBufferConfig {
GpuReplayBufferConfig {
@@ -234,3 +235,27 @@ fn test_gpu_per_ring_buffer_overwrites_correctly() {
);
}
}
#[test]
fn test_gpu_per_oom_rejects_absurd_capacity() {
// Absurd allocation that exceeds both GPU and CPU memory limits.
// Must return Err — not panic or OOM-kill the system.
let device = Device::Cpu;
// 500M capacity × 4 state_dim: GPU pre-flight rejects (~24 GB),
// CPU fallback pre-flight also rejects (~34 GB estimated).
let result = ReplayBufferType::try_gpu_prioritized_with_fallback(
500_000_000, // 500M capacity
4, // 4 state_dim
0.6,
0.4,
1.0,
1000,
&device,
);
assert!(
result.is_err(),
"500M capacity should be rejected by both GPU and CPU pre-flight checks"
);
}

View File

@@ -12,7 +12,7 @@
#![allow(unused_crate_dependencies)]
use candle_core::{Device, Tensor, DType};
use ml::dqn::TradingAction;
use ml::dqn::{FactoredAction, ExposureLevel, OrderType, Urgency};
use ml::ppo::{
ppo::{PPOConfig, PPO},
trajectories::{Trajectory, TrajectoryBatch, TrajectoryStep},
@@ -29,7 +29,7 @@ fn test_recurrent_ppo_single_episode() {
// Test that LSTM-enhanced PPO can train on a single episode
let config = PPOConfig {
state_dim: 32,
num_actions: 5,
num_actions: 45,
policy_hidden_dims: vec![64],
value_hidden_dims: vec![64],
use_lstm: true,
@@ -74,7 +74,7 @@ fn test_recurrent_ppo_single_episode() {
let mut trajectory = Trajectory::new();
for t in 0..10 {
let state = vec![t as f32; 32]; // Simple incrementing state
let action = TradingAction::Buy;
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
let log_prob = -1.0;
let value = 5.0 + t as f32;
let reward = 1.0;
@@ -112,7 +112,7 @@ fn test_recurrent_ppo_hidden_state_continuity() {
// Test that hidden states persist and evolve across timesteps within an episode
let config = PPOConfig {
state_dim: 16,
num_actions: 3,
num_actions: 45,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
use_lstm: true,
@@ -158,7 +158,7 @@ fn test_recurrent_ppo_episode_boundaries() {
// Test that hidden states reset between episodes
let config = PPOConfig {
state_dim: 16,
num_actions: 3,
num_actions: 45,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
use_lstm: true,
@@ -180,7 +180,7 @@ fn test_recurrent_ppo_episode_boundaries() {
for t in 0..5 {
episode1.add_step(TrajectoryStep::new(
vec![1.0; 16],
TradingAction::Buy,
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
-1.0,
5.0,
1.0,
@@ -192,7 +192,7 @@ fn test_recurrent_ppo_episode_boundaries() {
for t in 0..5 {
episode2.add_step(TrajectoryStep::new(
vec![2.0; 16],
TradingAction::Sell,
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal),
-1.0,
5.0,
1.0,
@@ -246,7 +246,7 @@ fn test_recurrent_vs_feedforward_ppo() {
// Compare LSTM vs non-LSTM PPO training behavior
let base_config = PPOConfig {
state_dim: 16,
num_actions: 3,
num_actions: 45,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
batch_size: 16,
@@ -283,7 +283,7 @@ fn test_recurrent_vs_feedforward_ppo() {
for t in 0..10 {
trajectory.add_step(TrajectoryStep::new(
vec![t as f32; 16],
TradingAction::Buy,
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
-1.0,
5.0,
1.0,
@@ -329,7 +329,7 @@ fn test_recurrent_ppo_checkpointing() {
// to enable loading LSTM checkpoints from safetensors files
let config = PPOConfig {
state_dim: 16,
num_actions: 3,
num_actions: 45,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
use_lstm: true,
@@ -351,7 +351,7 @@ fn test_recurrent_ppo_checkpointing() {
for t in 0..10 {
trajectory.add_step(TrajectoryStep::new(
vec![t as f32; 16],
TradingAction::Buy,
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal),
-1.0,
5.0,
1.0,