Files
foxhunt/docs/plans/2026-03-01-io-pipeline-wiring-implementation.md
2026-03-01 21:42:38 +01:00

19 KiB

I/O Pipeline Wiring Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Wire the 3 existing but unused I/O pipeline components (EpochPrefetcher, GpuBufferPool, DoubleBufferedLoader) into the DQN training path so fold transitions overlap disk I/O with GPU compute.

Architecture: The walk-forward loop in train_baseline_rl.rs gains a prefetcher that loads fold N+1's features on a background thread while fold N trains. Inside the trainer, GpuBufferPool replaces the per-fold DqnGpuData::upload() heap allocation, and DoubleBufferedLoader manages active/staging GPU tensor slots for O(1) fold swaps.

Tech Stack: Rust, Candle (GPU tensors), std::thread (prefetcher), existing cuda_pipeline module

Build: SQLX_OFFLINE=true cargo check -p ml Test: SQLX_OFFLINE=true cargo test -p ml --lib Example check: SQLX_OFFLINE=true cargo check --example train_baseline_rl -p ml


Task 1: Wire GpuBufferPool into DQNTrainer's GPU upload path

Why: DQNTrainer already pre-allocates a GpuBufferPool at init (trainer.rs:697-700) but never uses it. The trainer calls DqnGpuData::upload() (trainer.rs:1443) which heap-allocates fresh staging buffers every fold. Replacing that call with self.buffer_pool.upload_dqn() reuses the pre-allocated buffers.

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs:1442-1455

Step 1: Replace DqnGpuData::upload with buffer_pool.upload_dqn in train_with_data_full_loop

At trainer.rs:1442-1455, the current code is:

if self.gpu_data.is_none() {
    match DqnGpuData::upload(&training_data, &self.device) {
        Ok(gpu_data) => {
            info!("GPU data pre-uploaded: {} bars x {} features ({:.1} MB)",
                gpu_data.num_bars,
                gpu_data.feature_dim,
                (gpu_data.num_bars * (51 + 4) * 4) as f64 / 1_048_576.0
            );
            self.gpu_data = Some(gpu_data);
        }
        Err(e) => {
            debug!("GPU data pre-upload skipped (CPU fallback): {}", e);
        }
    }
}

Replace with:

if self.gpu_data.is_none() {
    let upload_result = if let Some(ref mut pool) = self.buffer_pool {
        info!("GpuBufferPool: reusing pre-allocated staging buffers for {} bars", training_data.len());
        pool.upload_dqn(&training_data, &self.device)
    } else {
        DqnGpuData::upload(&training_data, &self.device)
    };
    match upload_result {
        Ok(gpu_data) => {
            info!("GPU data pre-uploaded: {} bars x {} features ({:.1} MB)",
                gpu_data.num_bars,
                gpu_data.feature_dim,
                (gpu_data.num_bars * (51 + 4) * 4) as f64 / 1_048_576.0
            );
            self.gpu_data = Some(gpu_data);
        }
        Err(e) => {
            debug!("GPU data pre-upload skipped (CPU fallback): {}", e);
        }
    }
}

Step 2: Run tests

Run: SQLX_OFFLINE=true cargo test -p ml --lib trainers::dqn -- --nocapture 2>&1 | tail -5 Expected: All DQN trainer tests pass (435+)

Step 3: Check example compiles

Run: SQLX_OFFLINE=true cargo check --example train_baseline_rl -p ml Expected: Compiles clean

Step 4: Commit

git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(ml): wire GpuBufferPool into DQN trainer — reuse pre-allocated staging buffers"

Task 2: Add DoubleBufferedLoader field to DQNTrainer

Why: DoubleBufferedLoader holds two GPU tensor slots (active + staging). When a new fold's data arrives, it uploads to the staging slot, then O(1) swaps staging→active. This eliminates the GPU stall during fold transitions.

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs

Step 1: Add the field and initialization

After the buffer_pool field (trainer.rs:220), add:

/// Double-buffered GPU data for zero-downtime fold transitions
double_buffer: Option<crate::cuda_pipeline::double_buffer::DoubleBufferedLoader>,

In the new_internal() struct literal (after buffer_pool, at trainer.rs:833), add:

double_buffer: if device.is_cuda() {
    Some(crate::cuda_pipeline::double_buffer::DoubleBufferedLoader::new(device.clone()))
} else {
    None
},

Step 2: Add public API methods

After the take_prefetched_data() method (trainer.rs:861), add:

/// Get a reference to the double-buffered loader, if GPU is active.
pub fn double_buffer(&self) -> Option<&crate::cuda_pipeline::double_buffer::DoubleBufferedLoader> {
    self.double_buffer.as_ref()
}

/// Get a mutable reference to the double-buffered loader, if GPU is active.
pub fn double_buffer_mut(&mut self) -> Option<&mut crate::cuda_pipeline::double_buffer::DoubleBufferedLoader> {
    self.double_buffer.as_mut()
}

Step 3: Run tests

Run: SQLX_OFFLINE=true cargo test -p ml --lib trainers::dqn -- --nocapture 2>&1 | tail -5 Expected: All DQN trainer tests pass

Step 4: Commit

git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(ml): add DoubleBufferedLoader field to DQN trainer"

Task 3: Wire DoubleBufferedLoader into the GPU upload path

Why: Instead of storing GPU data directly in self.gpu_data, route it through the double-buffer's active slot. This prepares for fold N+1 staging while fold N trains.

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs:1442-1456

Step 1: Use double-buffer for initial upload when available

Replace the GPU upload block (modified in Task 1) to also use double-buffer when available:

if self.gpu_data.is_none() {
    // Try double-buffer path first (zero-downtime fold transitions)
    if let Some(ref mut db) = self.double_buffer {
        if db.active().is_none() {
            let upload_result = if let Some(ref mut pool) = self.buffer_pool {
                info!("GpuBufferPool: reusing pre-allocated staging buffers for {} bars", training_data.len());
                pool.upload_dqn(&training_data, &self.device)
            } else {
                DqnGpuData::upload(&training_data, &self.device)
            };
            match upload_result {
                Ok(gpu_data) => {
                    info!("DoubleBuffer: initial upload — {} bars x {} features ({:.1} MB)",
                        gpu_data.num_bars,
                        gpu_data.feature_dim,
                        (gpu_data.num_bars * (51 + 4) * 4) as f64 / 1_048_576.0
                    );
                    self.gpu_data = Some(gpu_data);
                }
                Err(e) => {
                    debug!("GPU data pre-upload skipped (CPU fallback): {}", e);
                }
            }
        } else {
            // Double-buffer already has active data (staged from previous fold)
            // Nothing to do — gpu_data was already set via set_gpu_data()
        }
    } else {
        // No double-buffer (CPU mode) — direct upload
        let upload_result = if let Some(ref mut pool) = self.buffer_pool {
            info!("GpuBufferPool: reusing pre-allocated staging buffers for {} bars", training_data.len());
            pool.upload_dqn(&training_data, &self.device)
        } else {
            DqnGpuData::upload(&training_data, &self.device)
        };
        match upload_result {
            Ok(gpu_data) => {
                info!("GPU data pre-uploaded: {} bars x {} features ({:.1} MB)",
                    gpu_data.num_bars,
                    gpu_data.feature_dim,
                    (gpu_data.num_bars * (51 + 4) * 4) as f64 / 1_048_576.0
                );
                self.gpu_data = Some(gpu_data);
            }
            Err(e) => {
                debug!("GPU data pre-upload skipped (CPU fallback): {}", e);
            }
        }
    }
}

Step 2: Run tests + example check

Run: SQLX_OFFLINE=true cargo test -p ml --lib trainers::dqn -- --nocapture 2>&1 | tail -5 Run: SQLX_OFFLINE=true cargo check --example train_baseline_rl -p ml Expected: All pass / compile clean

Step 3: Commit

git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(ml): wire DoubleBufferedLoader into DQN trainer GPU upload path"

Task 4: Wire EpochPrefetcher in train_baseline_rl.rs walk-forward loop

Why: The walk-forward loop (train_baseline_rl.rs:501) iterates folds sequentially. Feature extraction (extract_ml_features) involves CPU-bound OHLCV→indicator computation. The prefetcher runs this on a background thread for fold N+1 while fold N trains on GPU.

Important context: train_dqn_fold() creates a new DQNTrainer per fold (line 262). The prefetcher must therefore operate at the walk-forward loop level, not inside the trainer. The data flow is: prefetch → extract features → normalize → pass to train_dqn_fold().

Files:

  • Modify: crates/ml/examples/train_baseline_rl.rs:501-599

Step 1: Add imports

At the top of the file (after existing use ml:: imports, around line 31), add:

use ml::cuda_pipeline::prefetch::EpochPrefetcher;

Step 2: Add a helper function for fold data preparation

Before train_dqn_fold() (line 213), add a helper that encapsulates the feature extraction + normalization pipeline for one fold:

/// Prepare a fold's data for training: extract features, normalize, align bars.
///
/// Returns `(train_norm, val_norm, train_bars_aligned, val_bars_aligned, norm_stats)`
/// or `None` if feature extraction fails or produces empty features.
fn prepare_fold_data(
    window: &baseline_common::WalkForwardWindow,
    output_dir: &Path,
) -> Option<(Vec<[f64; 51]>, Vec<[f64; 51]>, Vec<OHLCVBar>, Vec<OHLCVBar>)> {
    let train_feat = match extract_ml_features(&window.train) {
        Ok(f) => f,
        Err(e) => {
            warn!("  Fold {} -- train feature extraction failed: {}", window.fold, e);
            return None;
        }
    };
    let val_feat = match extract_ml_features(&window.val) {
        Ok(f) => f,
        Err(e) => {
            warn!("  Fold {} -- val feature extraction failed: {}", window.fold, e);
            return None;
        }
    };
    if train_feat.is_empty() || val_feat.is_empty() {
        warn!("  Fold {} -- empty features, skipping", window.fold);
        return None;
    }

    let norm_stats = NormStats::from_features(&train_feat);
    let train_norm = norm_stats.normalize_batch(&train_feat);
    let val_norm = norm_stats.normalize_batch(&val_feat);

    // Save NormStats
    let norm_path = output_dir.join(format!("norm_stats_fold{}.json", window.fold));
    if let Ok(json) = serde_json::to_string_pretty(&norm_stats) {
        if let Err(e) = std::fs::write(&norm_path, json) {
            warn!("  Failed to write NormStats: {}", e);
        } else {
            info!("  Saved NormStats to {}", norm_path.display());
        }
    }

    let train_warmup = window.train.len().saturating_sub(train_norm.len());
    let train_bars_aligned = window.train.get(train_warmup..).unwrap_or(&window.train).to_vec();
    let val_warmup = window.val.len().saturating_sub(val_norm.len());
    let val_bars_aligned = window.val.get(val_warmup..).unwrap_or(&window.val).to_vec();

    Some((train_norm, val_norm, train_bars_aligned, val_bars_aligned))
}

Step 3: Restructure the walk-forward loop to use prefetcher

Replace the walk-forward loop body (lines 501-599) with a version that spawns a prefetcher for the next fold:

    // Prefetch state: holds prepared data for the current fold (None for fold 0)
    let mut prefetched_data: Option<(Vec<[f64; 51]>, Vec<[f64; 51]>, Vec<OHLCVBar>, Vec<OHLCVBar>)> = None;

    for (fold_idx, window) in windows.iter().enumerate() {
        info!("--- Fold {} ---", window.fold);
        info!(
            "  Train: {} bars (up to {}), Val: {} bars (up to {}), Test: {} bars (up to {})",
            window.train.len(), window.train_end,
            window.val.len(), window.val_end,
            window.test.len(), window.test_end,
        );

        // Get fold data: from prefetcher (fold 1+) or prepare fresh (fold 0)
        let fold_data = if let Some(data) = prefetched_data.take() {
            info!("  Using prefetched data for fold {}", window.fold);
            Some(data)
        } else {
            prepare_fold_data(window, &args.output_dir)
        };

        // Kick off prefetch for NEXT fold (if not last)
        let prefetcher = if fold_idx + 1 < windows.len() {
            let next_window = windows[fold_idx + 1].clone();
            let out_dir = args.output_dir.clone();
            Some(EpochPrefetcher::spawn(move || {
                // This runs on a background thread while the current fold trains
                match prepare_fold_data_for_prefetch(&next_window, &out_dir) {
                    Some(data) => Ok(data),
                    None => Err(ml::MLError::ModelError(
                        format!("Fold {} feature extraction failed in prefetcher", next_window.fold)
                    )),
                }
            }))
        } else {
            None
        };

        let Some((train_norm, val_norm, train_bars_aligned, val_bars_aligned)) = fold_data else {
            // Collect prefetch result even if this fold failed (don't waste the background work)
            if let Some(pf) = prefetcher {
                prefetched_data = pf.take().ok();
            }
            continue;
        };

        // Train DQN
        if train_dqn {
            let hp = load_hyperopt_params(&args.hyperopt_params, "dqn");
            match train_dqn_fold(
                window.fold, &train_norm, &val_norm,
                &train_bars_aligned, &val_bars_aligned,
                args, &args.output_dir, &hp,
            ) {
                Ok(best_loss) => dqn_results.push((window.fold, best_loss)),
                Err(e) => error!("  [DQN] Fold {} failed: {}", window.fold, e),
            }
        }

        // Train PPO
        if train_ppo {
            let hp = load_hyperopt_params(&args.hyperopt_params, "ppo");
            match train_ppo_fold(
                window.fold, &train_norm, &val_norm,
                &train_bars_aligned, &val_bars_aligned,
                args, &args.output_dir, &hp,
            ) {
                Ok(best_loss) => ppo_results.push((window.fold, best_loss)),
                Err(e) => error!("  [PPO] Fold {} failed: {}", window.fold, e),
            }
        }

        // Collect prefetch result for next iteration
        if let Some(pf) = prefetcher {
            match pf.take() {
                Ok(data) => {
                    info!("  Prefetched fold {} data ready ({} train features)", fold_idx + 2, data.0.len());
                    prefetched_data = Some(data);
                }
                Err(e) => {
                    warn!("  Prefetch for next fold failed (will load synchronously): {}", e);
                    prefetched_data = None;
                }
            }
        }
    }

Step 4: Add the prefetch-compatible helper

The EpochPrefetcher closure needs Send + 'static, so it can't borrow WalkForwardWindow. Add a helper that takes owned data (the .clone() above handles this):

/// Same as `prepare_fold_data` but takes owned window (for Send + 'static prefetcher closure).
fn prepare_fold_data_for_prefetch(
    window: &baseline_common::WalkForwardWindow,
    output_dir: &Path,
) -> Option<(Vec<[f64; 51]>, Vec<[f64; 51]>, Vec<OHLCVBar>, Vec<OHLCVBar>)> {
    prepare_fold_data(window, output_dir)
}

Note: The PrefetchResult type is Vec<([f64; 51], Vec<f64>)> but we need (Vec<[f64; 51]>, Vec<[f64; 51]>, Vec<OHLCVBar>, Vec<OHLCVBar>). Since the prefetcher is generic over Send + 'static closures, we return a tuple directly, bypassing the PrefetchResult type alias. The EpochPrefetcher::spawn accepts any FnOnce() -> Result<T, MLError> — wait, it's typed to PrefetchResult. We need to check this.

Step 5: Check EpochPrefetcher type and adapt

EpochPrefetcher::spawn is hardcoded to PrefetchResult = Vec<([f64; 51], Vec<f64>)>. Our fold data is (Vec<[f64; 51]>, Vec<[f64; 51]>, Vec<OHLCVBar>, Vec<OHLCVBar>). Two options:

a) Make EpochPrefetcher generic over the result type b) Use a plain std::thread::spawn + mpsc::channel in the binary

Option (b) is simpler and doesn't modify library code. Replace the EpochPrefetcher usage with direct thread spawning in the binary:

use std::sync::mpsc;

type FoldData = (Vec<[f64; 51]>, Vec<[f64; 51]>, Vec<OHLCVBar>, Vec<OHLCVBar>);

// In the loop, replace EpochPrefetcher::spawn with:
let prefetcher = if fold_idx + 1 < windows.len() {
    let next_window = windows[fold_idx + 1].clone();
    let out_dir = args.output_dir.clone();
    let (tx, rx) = mpsc::channel::<Option<FoldData>>();
    std::thread::Builder::new()
        .name("fold-prefetch".into())
        .spawn(move || {
            let result = prepare_fold_data(&next_window, &out_dir);
            let _ = tx.send(result);
        })
        .ok();
    Some(rx)
} else {
    None
};

// Collecting:
if let Some(rx) = prefetcher {
    match rx.recv() {
        Ok(Some(data)) => {
            info!("  Prefetched fold {} data ready ({} train features)", fold_idx + 2, data.0.len());
            prefetched_data = Some(data);
        }
        _ => {
            warn!("  Prefetch for next fold failed (will load synchronously)");
            prefetched_data = None;
        }
    }
}

Step 6: Run example check

Run: SQLX_OFFLINE=true cargo check --example train_baseline_rl -p ml Expected: Compiles clean

Step 7: Commit

git add crates/ml/examples/train_baseline_rl.rs
git commit -m "feat(ml): wire fold prefetching in walk-forward loop — overlap I/O with GPU training"

Task 5: Verify WalkForwardWindow is Clone

Why: Task 4 clones windows[fold_idx + 1] for the prefetcher's Send + 'static closure. WalkForwardWindow must derive or implement Clone.

Files:

  • Check: crates/ml/examples/baseline_common/mod.rs (or wherever WalkForwardWindow is defined)

Step 1: Find and check the struct

Run: grep -rn "struct WalkForwardWindow" crates/ml/examples/

If it doesn't derive Clone, add #[derive(Clone)] to it. Also check OHLCVBar — it must be Clone too (likely already is since it's a data struct).

Step 2: Run example check

Run: SQLX_OFFLINE=true cargo check --example train_baseline_rl -p ml Expected: Compiles clean

Step 3: Commit (if changes needed)

git commit -m "fix(ml): derive Clone on WalkForwardWindow for prefetcher compatibility"

Task 6: Full test suite + compile verification

Why: Final validation that all changes integrate correctly.

Step 1: Full ml crate test suite

Run: SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -5 Expected: 2444+ tests pass, 0 failures

Step 2: Training binary compile

Run: SQLX_OFFLINE=true cargo check --example train_baseline_rl -p ml 2>&1 | tail -3 Expected: Compiles clean

Step 3: Workspace compile

Run: SQLX_OFFLINE=true cargo check --workspace 2>&1 | tail -3 Expected: Compiles clean (may have unrelated warnings)


Task 7: Audit — verify all 3 I/O components are wired

Why: Confirm the design goals are met.

Checklist:

  • GpuBufferPool: self.buffer_pool.upload_dqn() called in train_with_data_full_loop() (trainer.rs)
  • DoubleBufferedLoader: field exists on DQNTrainer, initialized on CUDA, accessor methods available
  • Fold prefetching: train_baseline_rl.rs walk-forward loop spawns background thread for fold N+1
  • No regressions: all ml tests pass, training binary compiles
  • CPU fallback: all 3 paths degrade gracefully when not on CUDA (None checks, direct upload)