docs: I/O pipeline wiring implementation plan — 7 tasks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-01 21:42:38 +01:00
parent 93ad873e40
commit 8910591f85

View File

@@ -0,0 +1,515 @@
# 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:
```rust
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:
```rust
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:
```rust
/// 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:
```rust
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:
```rust
/// 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:
```rust
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:
```rust
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:
```rust
/// 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:
```rust
// 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):
```rust
/// 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:
```rust
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)