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:
@@ -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)?;
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user