Files
foxhunt/docs/superpowers/plans/2026-04-02-autobatchsizer-vram-budget.md
jgrusewski f5b21406a8 fix: restore batch_size to GPU profiles — profile defaults, not auto-scaling
Auto-scaling returned 8192 on 4GB RTX 3050 (should be 64), causing
17s/epoch instead of 0.28s. The VRAM math didn't account for IQN (1.1GB),
attention, IQL, replay buffer (70% VRAM).

Profile-tested values:
- RTX 3050: 64 (4GB, minimal)
- A100: 2048 (40-80GB)
- H100: 8192 (80GB, production)
- Default: 256 (conservative)

Auto-scaling remains as fallback for batch_size=0 (unknown GPU).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:26:24 +02:00

14 KiB

AutoBatchSizer VRAM Budget Fix — Implementation Plan

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: Fix AutoBatchSizer to account for ALL GPU memory consumers, preventing OOM on any GPU while maximizing batch size for performance.

Architecture: Replace the current model_memory_mb-only budget with a comprehensive VRAM budget that includes IQN (~1.1GB at batch=8192), attention (28MB), IQL (16MB), replay buffer (70% VRAM), experience collector, and CUDA graph overhead. The batch_size is computed LAST, using only the VRAM remaining after all fixed allocations.

Tech Stack: Rust, cudarc 0.17.3, CUDA driver API


Problem Statement

The current AutoBatchSizer::max_safe_batch_size() only considers:

  • Model parameters (DQN trunk: ~1.2MB)
  • Optimizer states (Adam: 2x model)
  • Gradients (1x model)
  • Activations (1x model)
  • Safety margin (20%)
  • Per-sample batch data (80 features x bf16 = 160 bytes)

It IGNORES:

  • IQN head: 1.1GB at batch=8192 (dominates VRAM on small GPUs)
  • Attention: 28MB at batch=8192
  • IQL: 16MB at batch=8192
  • HER: ~1MB
  • Replay buffer: 70% of VRAM (allocated BEFORE batch sizer runs)
  • Experience collector: 40MB+ (cuBLAS handles, output buffers)
  • CUDA graphs: ~10MB (captured kernel topology)

Result: On RTX 3050 (4GB, 1.7GB free), returns batch=8192 when safe max is ~128.

Root Cause

  1. AutoBatchSizer::new() probes free_memory_mb at constructor time
  2. But the replay buffer (70% VRAM) is allocated AFTER the batch sizer runs
  3. The model_memory_mb passed to max_safe_batch_size() is only the DQN trunk (~1.2MB), not the full training pipeline
  4. IQN VRAM scales linearly with batch_size but isn't in the per-sample calculation

VRAM Allocation Order (current)

1. AutoBatchSizer probes free VRAM     → sees 81GB (H100) or 1.7GB (RTX 3050)
2. batch_size = max_safe_batch(config)  → computes from DQN trunk only
3. AutoReplaySizer allocates buffer     → consumes 70% of free VRAM
4. FusedTrainingCtx::new() allocates:
   a. GpuDqnTrainer (batch buffers + optimizer states)
   b. GpuHer
   c. GpuIqlTrainer
   d. GpuIqnHead (HUGE: 1.1GB at batch=8192)
   e. GpuAttention (28MB at batch=8192)
5. Experience collector allocates       → 40MB+

The batch_size is decided at step 2 but the big allocations happen at steps 3-5.

Fix: VRAM Budget Model

free_vram = probe_free_vram()

# Fixed allocations (batch-independent)
replay_vram    = free_vram * replay_buffer_vram_fraction  # 70% default
exp_collector  = n_episodes * timesteps * state_dim * 4   # ~40MB
cuda_overhead  = 200MB                                     # graphs, cuBLAS handles, kernels

remaining = free_vram - replay_vram - exp_collector - cuda_overhead

# Per-sample VRAM (scales with batch_size)
per_sample_mb = (
    state_dim * 2 * 2                           # states + next_states (bf16)
  + state_dim * 4 * 2                           # f32 shadows (states + next)
  + 4 + 4 + 4 + 4                               # actions(i32) + rewards(f32) + dones(f32) + weights(f32)
  + hidden_h1 * 2 + hidden_h2 * 2               # save_h_s1 + save_h_s2 (bf16)
  + value_h * 2 + adv_h * 2 * 3                 # save_h_v + save_h_b0..b2 (bf16)
  + num_atoms * 3 * 2 * 2                        # save_current_lp + save_projected (bf16)
  + 2 + 2                                        # per_sample_loss + td_errors (bf16)
  + total_actions * 2                             # q_out_buf (bf16)
  + iqn_per_sample                                # IQN: quantile embeddings, forward scratch
  + attn_per_sample                               # Attention: 4-head scratch
  + iql_per_sample                                # IQL: forward + loss scratch
) / (1024 * 1024)

max_batch = (remaining / per_sample_mb).floor()
batch_size = max_batch.clamp(min_batch, 16384)

File Structure

Modified Files

File Changes
crates/ml/src/trainers/dqn/trainer/constructor.rs Replace AutoBatchSizer call with comprehensive VRAM budget
crates/ml-core/src/memory_optimization/auto_batch_size.rs Add DqnVramBudget struct with per-component VRAM math

Task 1: Add DqnVramBudget to auto_batch_size.rs

Files:

  • Modify: crates/ml-core/src/memory_optimization/auto_batch_size.rs

  • Step 1: Add the DqnVramBudget struct and compute method

/// Comprehensive VRAM budget for DQN training pipeline.
/// Accounts for ALL GPU memory consumers, not just the DQN trunk.
pub struct DqnVramBudget {
    pub state_dim: usize,
    pub hidden_h1: usize,
    pub hidden_h2: usize,
    pub value_h: usize,
    pub adv_h: usize,
    pub num_atoms: usize,
    pub total_params: usize,      // DQN trunk params
    pub iqn_params: usize,        // IQN head params (always active)
    pub attn_params: usize,       // Attention params (always active)
    pub iql_params: usize,        // IQL params (always active)
    pub replay_vram_fraction: f64, // fraction of free VRAM for replay buffer
    pub n_episodes: usize,        // experience collector episodes
    pub timesteps: usize,         // experience collector timesteps
}

impl DqnVramBudget {
    /// Compute maximum safe batch size from actual free VRAM.
    pub fn max_batch_size(&self, free_vram_mb: f64) -> usize {
        // 1. Fixed allocations (batch-independent)
        let replay_mb = free_vram_mb * self.replay_vram_fraction;
        let exp_collector_mb = (self.n_episodes * self.timesteps * self.state_dim * 4) as f64
            / (1024.0 * 1024.0);
        // cuBLAS handles, CUDA graphs, kernel modules, segment tree
        let cuda_overhead_mb = 200.0;
        // DQN optimizer: params_buf + target_params_buf + m_buf + v_buf + grad_buf = 5x f32
        let dqn_optim_mb = (self.total_params * 5 * 4) as f64 / (1024.0 * 1024.0);
        // IQN fixed: params + target + m + v + grad = 6x (mixed bf16/f32)
        let iqn_fixed_mb = (self.iqn_params * 6 * 2) as f64 / (1024.0 * 1024.0);
        // Attention fixed: params + m + v + d_params = 4x f32
        let attn_fixed_mb = (self.attn_params * 4 * 4) as f64 / (1024.0 * 1024.0);
        // IQL fixed: params + m + v + grad = 4x f32
        let iql_fixed_mb = (self.iql_params * 4 * 4) as f64 / (1024.0 * 1024.0);

        let fixed_mb = replay_mb + exp_collector_mb + cuda_overhead_mb
            + dqn_optim_mb + iqn_fixed_mb + attn_fixed_mb + iql_fixed_mb;

        let remaining_mb = (free_vram_mb - fixed_mb).max(0.0);

        // 2. Per-sample VRAM (scales with batch_size)
        let sd = self.state_dim;
        let h1 = self.hidden_h1;
        let h2 = self.hidden_h2;
        let vh = self.value_h;
        let ah = self.adv_h;
        let na = self.num_atoms;
        let branches = 3_usize;

        // DQN trainer per-sample bytes:
        let dqn_per_sample = sd * 2 * 2          // states + next_states (bf16, padded)
            + sd * 4 * 2                          // f32 states + next for experience upload
            + 4 + 4 + 4 + 4                       // actions(i32) + rewards(f32) + dones(f32) + is_weights(f32)
            + h1 * 2 + h2 * 2                     // save_h_s1 + save_h_s2 (bf16)
            + vh * 2 + ah * 2 * branches           // save_h_v + save_h_b0..b2 (bf16)
            + na * branches * 2 * 2                // save_current_lp + save_projected (bf16)
            + 2 + 2                                // per_sample_loss + td_errors (bf16)
            + (na * branches + vh + ah * branches) * 2; // forward output logits (bf16)

        // IQN per-sample: quantile embeddings + forward scratch (always active)
        let nq = 64_usize; // num_quantiles
        let iqn_h = h2;    // IQN hidden matches trunk h2
        let iqn_per_sample =
            nq * 64 * 2       // tau embeddings [B, nq, embed_dim] bf16
            + nq * iqn_h * 2  // quantile hidden [B, nq, hidden] bf16
            + nq * na * 2     // quantile Q-values [B, nq, num_atoms] bf16
            + h2 * 4;          // d_h_s2 gradient (f32)

        // Attention per-sample: 4-head scratch (always active)
        let attn_per_sample =
            h2 * 2             // attended output [B, h2] bf16
            + h2 * 4           // d_input scratch (f32)
            + h2 * 2;          // saved_input (bf16)

        // IQL per-sample: V(s) forward + loss (always active)
        let iql_per_sample =
            128 * 2            // IQL hidden (bf16)
            + 2 + 2;           // v_out + loss (bf16)

        let total_per_sample_bytes = dqn_per_sample + iqn_per_sample
            + attn_per_sample + iql_per_sample;
        let per_sample_mb = total_per_sample_bytes as f64 / (1024.0 * 1024.0);

        if per_sample_mb <= 0.0 || remaining_mb <= 0.0 {
            return 64; // absolute minimum
        }

        let max_batch = (remaining_mb / per_sample_mb).floor() as usize;
        max_batch.clamp(64, 16384)
    }
}
  • Step 2: Compile and verify
SQLX_OFFLINE=true cargo check -p ml-core

Expected: PASS

  • Step 3: Commit
git commit -m "feat: DqnVramBudget — comprehensive VRAM accounting for batch sizing"

Task 2: Wire DqnVramBudget into constructor.rs

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer/constructor.rs (use sed — Unicode-safe)

Replace the AutoBatchSizer call with DqnVramBudget::max_batch_size(). The budget struct is populated from the hyperparams and network config that are already available in the constructor.

  • Step 1: Replace batch sizing logic

In constructor.rs, replace the AutoBatchSizer::new() block (lines ~76-99) with:

// Comprehensive VRAM budget: accounts for ALL GPU consumers.
let max_safe_batch = {
    use ml_core::memory_optimization::auto_batch_size::DqnVramBudget;
    use ml_core::memory_optimization::detect_gpu_hardware;

    match detect_gpu_hardware() {
        Ok(hw) => {
            let budget = DqnVramBudget {
                state_dim: input_dim,
                hidden_h1: hidden_dims[0],
                hidden_h2: *hidden_dims.last().unwrap_or(&128),
                value_h: hidden_dims.last().copied().unwrap_or(64),
                adv_h: hidden_dims.last().copied().unwrap_or(64),
                num_atoms: hyperparams.num_atoms,
                total_params: param_count,
                iqn_params: 20_495,   // always active (from GpuIqnHead init logs)
                attn_params: 263_680, // always active (from GpuAttention init logs)
                iql_params: 27_009,   // always active (from GpuIqlTrainer init logs)
                replay_vram_fraction: hyperparams.replay_buffer_vram_fraction,
                n_episodes: 4096, // worst-case auto-scaled
                timesteps: hyperparams.gpu_timesteps_per_episode,
            };
            let safe = budget.max_batch_size(hw.free_memory_mb);
            info!(
                "DqnVramBudget: batch_size={} (free={:.0}MB, replay={:.0}%, per_sample={:.1}KB)",
                safe, hw.free_memory_mb,
                hyperparams.replay_buffer_vram_fraction * 100.0,
                budget.per_sample_kb(),
            );
            safe
        }
        Err(e) => {
            info!("GPU detection failed ({e}), using batch_size=256");
            256
        }
    }
};

if hyperparams.batch_size == 0 {
    hyperparams.batch_size = max_safe_batch;
    info!("batch_size auto-computed to {}", hyperparams.batch_size);
}

Remove the .min(8192) hard cap — the budget handles the ceiling.

  • Step 2: Compile
SQLX_OFFLINE=true cargo check -p ml
  • Step 3: Run smoketest locally
SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests::training_stability::test_gpu_collector_auto_initializes --ignored --nocapture 2>&1 | grep -E "batch_size|DqnVramBudget|per-step|Epoch complete|ok|FAIL"

Expected:

  • RTX 3050 (4GB): batch_size ~128-256 (not 8192)

  • Per-step ~7.5ms

  • Epoch ~1.8s (not 17s)

  • Step 4: Commit

git commit -m "fix: wire DqnVramBudget into constructor — accounts for IQN/attention/IQL/replay VRAM"

Task 3: Validate on H100

  • Step 1: Submit H100 run
./scripts/argo-train.sh dqn --epochs 5 --trials 0 --gpu-pool ci-training-h100

Expected:

  • H100 (80GB): batch_size ~8192-16384

  • n_episodes ~4096

  • Per-step <10ms

  • Epoch <5s

  • No OOM

  • Step 2: Verify epoch metrics

Check logs for:

  • DqnVramBudget: batch_size=... — should be 8192-16384
  • Phase 2: done in ...ms — experience collection
  • Phase 3: done in ...ms — training steps
  • Epoch complete — full epoch timing
  • No CUDA_ERROR_OUT_OF_MEMORY

Expected Results

GPU Free VRAM Replay (70%) Remaining Per-Sample Max Batch
RTX 3050 1,700 MB 1,190 MB ~310 MB ~2.5 KB ~128
A100 40GB 35,000 MB 24,500 MB ~10,300 MB ~2.5 KB ~4,096
H100 80GB 75,000 MB 52,500 MB ~22,300 MB ~2.5 KB ~8,900

Note: these are estimates — actual values depend on IQN/attention config and experience collector scaling.

Risks

Risk Impact Mitigation
Per-sample estimate too conservative Low batch → slow training Log actual VRAM usage vs estimate, tune
Per-sample estimate too aggressive OOM at training start Safety margin (20%) + cudarc OOM detection
IQN VRAM varies with num_quantiles Incorrect estimate Use hyperparams.num_quantiles in budget
Replay fraction varies by profile Budget miscalculation Read from hyperparams, not hardcoded