Files
foxhunt/docs/superpowers/plans/2026-03-21-h100-epoch-optimization-phase1.md
jgrusewski 8b44a8c9c5 docs: Phase 1 implementation plan — eliminate PER CPU roundtrips
7 tasks: Philox RNG kernel, pre-allocated buffers, GPU-native sampling,
adam_step cleanup, profiling validation, to_host deprecation.

Target: 37s → 12-15s/epoch on H100.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:26:11 +01:00

335 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# H100 Epoch Optimization Phase 1: Eliminate CPU Roundtrips
> **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:** Eliminate all CPU roundtrips in the PER sampling hot path, reducing DQN training epoch time from 37s to ~12-15s on H100.
**Architecture:** Replace CPU-side random threshold generation + DtoH total_sum readback with a GPU Philox RNG kernel. Pre-allocate all PER sampling buffers to eliminate per-step `cuMemAlloc`. Keep the total_sum on GPU as a buffer pointer arg instead of a scalar.
**Tech Stack:** Rust, cudarc 0.19, CUDA (nvcc), Philox 4×32 PRNG
**Spec:** `docs/superpowers/specs/2026-03-21-h100-epoch-optimization-design.md`
---
### Task 1: Add Philox threshold generation kernel to replay buffer kernels
**Files:**
- Create: `crates/ml-dqn/src/per_threshold_kernel.cu`
- Modify: `crates/ml-dqn/src/gpu_replay_buffer.rs:195-240` (add kernel to PERKernels struct)
- [ ] **Step 1: Write the CUDA kernel**
Create `crates/ml-dqn/src/per_threshold_kernel.cu`:
```cuda
// Philox 4×32-10 PRNG for PER threshold generation.
// Generates batch_size uniform random values in [0, total_sum).
// Zero CPU involvement — total_sum read from GPU buffer.
__device__ __forceinline__
unsigned int philox_single(unsigned int counter, unsigned int key) {
unsigned int hi, lo;
lo = counter * 0xD2511F53u;
hi = __umulhi(counter, 0xD2511F53u);
for (int r = 0; r < 10; r++) {
unsigned int t = hi ^ key;
hi = lo * 0xCD9E8D57u;
lo = __umulhi(lo, 0xCD9E8D57u);
lo ^= t;
key += 0x9E3779B9u;
}
return lo ^ hi;
}
extern "C" __global__
void per_generate_thresholds(
float* __restrict__ thresholds,
const float* __restrict__ total_sum_buf, // [1] GPU scalar
unsigned int seed,
int batch_size)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= batch_size) return;
float total_sum = total_sum_buf[0];
unsigned int bits = philox_single((unsigned int)i, seed);
float u = (float)(bits >> 8) * (1.0f / 16777216.0f);
thresholds[i] = u * total_sum;
}
```
- [ ] **Step 2: Verify kernel file exists**
Run: `ls -la crates/ml-dqn/src/per_threshold_kernel.cu`
- [ ] **Step 3: Commit**
```bash
git add crates/ml-dqn/src/per_threshold_kernel.cu
git commit -m "feat: Philox PRNG kernel for GPU-native PER threshold generation"
```
---
### Task 2: Add kernel to PERKernels struct and compile at init
**Files:**
- Modify: `crates/ml-dqn/src/gpu_replay_buffer.rs:185-240` (PERKernels struct + compilation)
- [ ] **Step 1: Add field to PERKernels**
In `gpu_replay_buffer.rs`, add to the `PERKernels` struct (after line ~208):
```rust
per_gen_thresholds: CudaFunction,
```
- [ ] **Step 2: Compile and load in PERKernels::new()**
After the existing kernel loads (line ~237), add:
```rust
let per_thresh_ptx = crate::compile_ptx_for_device(
include_str!("per_threshold_kernel.cu"), ctx,
)?;
let per_thresh_mod = ctx.load_module(per_thresh_ptx)?;
let per_gen_thresholds = per_thresh_mod.load_function("per_generate_thresholds")?;
```
And add `per_gen_thresholds` to the struct initialization.
- [ ] **Step 3: Verify compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml-dqn 2>&1 | tail -3`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add crates/ml-dqn/src/gpu_replay_buffer.rs
git commit -m "feat: compile Philox threshold kernel in PERKernels init"
```
---
### Task 3: Pre-allocate PER sampling buffers
**Files:**
- Modify: `crates/ml-dqn/src/gpu_replay_buffer.rs` (GpuReplayBuffer struct + new())
Currently `sample_proportional` allocates ~10 buffers per call via `a32f()`, `a16()`, `a32u()`. These must be pre-allocated at construction and reused.
- [ ] **Step 1: Add pre-allocated fields to GpuReplayBuffer**
Add to the struct (after existing fields):
```rust
// Pre-allocated PER sampling buffers (reused across sample() calls)
sample_thresholds: CudaSlice<f32>, // [max_batch_size]
sample_indices_i64: CudaSlice<i64>, // [max_batch_size]
sample_indices_u32: CudaSlice<u32>, // [max_batch_size]
sample_states: CudaSlice<u16>, // [max_batch_size * state_dim]
sample_next_states: CudaSlice<u16>, // [max_batch_size * state_dim]
sample_actions: CudaSlice<u32>, // [max_batch_size]
sample_rewards: CudaSlice<f32>, // [max_batch_size]
sample_dones: CudaSlice<f32>, // [max_batch_size]
sample_priorities: CudaSlice<f32>, // [max_batch_size]
sample_weights: CudaSlice<f32>, // [max_batch_size]
sample_max_weight: CudaSlice<f32>, // [1]
total_sum_buf: CudaSlice<f32>, // [1] — holds cs_buf[n-1] via DtoD
rng_step: u32, // counter for Philox seed
```
- [ ] **Step 2: Allocate in constructor**
In `GpuReplayBuffer::new()`, after existing allocations:
```rust
let max_bs = config.max_batch_size.unwrap_or(1024);
let sd = config.state_dim;
let sample_thresholds = stream.alloc_zeros::<f32>(max_bs)?;
let sample_indices_i64 = stream.alloc_zeros::<i64>(max_bs)?;
// ... (all fields above)
let total_sum_buf = stream.alloc_zeros::<f32>(1)?;
let rng_step = 0_u32;
```
- [ ] **Step 3: Verify compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml-dqn 2>&1 | tail -3`
- [ ] **Step 4: Commit**
```bash
git add crates/ml-dqn/src/gpu_replay_buffer.rs
git commit -m "feat: pre-allocate all PER sampling buffers (zero cuMemAlloc per step)"
```
---
### Task 4: Rewrite sample_proportional to use GPU RNG + pre-allocated buffers
**Files:**
- Modify: `crates/ml-dqn/src/gpu_replay_buffer.rs:414-514` (sample_proportional)
This is the core change. Replace:
1. `cs_total()` DtoH readback → DtoD copy of `cs_buf[n-1]` to `total_sum_buf`
2. CPU `rand::thread_rng()``per_generate_thresholds` kernel launch
3. Per-call `a32f()`/`a16()`/`a32u()` allocs → pre-allocated buffers
- [ ] **Step 1: Replace cs_total with DtoD copy**
Replace lines 429-430:
```rust
// OLD: let ts = self.cs_total(n)?;
// NEW: DtoD copy of cs_buf[n-1] to total_sum_buf (zero CPU)
let cs_last = self.cs_buf.slice((n-1)..n);
self.stream.memcpy_dtod(&cs_last, &mut self.total_sum_buf)?;
```
- [ ] **Step 2: Replace CPU RNG with GPU kernel**
Replace lines 431-435:
```rust
// OLD: CPU rand + HtoD upload
// NEW: GPU Philox kernel
self.rng_step = self.rng_step.wrapping_add(1);
let seed = self.rng_step;
let blocks = ((batch_size + 255) / 256) as u32;
unsafe {
self.stream.launch_builder(&self.kernels.per_gen_thresholds)
.arg(&mut self.sample_thresholds)
.arg(&self.total_sum_buf)
.arg(&seed)
.arg(&bsi)
.launch(LaunchConfig { grid_dim: (blocks,1,1), block_dim: (256,1,1), shared_mem_bytes: 0 })?;
}
```
- [ ] **Step 3: Replace all per-call allocations with pre-allocated buffers**
Replace `a32f(&self.stream, batch_size, "t")?` etc. with `self.sample_thresholds`, `self.sample_indices_i64`, etc. throughout the method. Update all kernel `.arg()` calls to reference `self.sample_*` fields.
- [ ] **Step 4: Update is_weights to read total_sum from GPU buffer**
The `is_weights_f32` kernel currently takes `ts: f32` as scalar arg. Change to `total_sum_buf: *const f32` pointer arg. Update kernel source to read `total_sum_buf[0]`.
- [ ] **Step 5: Remove cs_total() method**
Delete the `fn cs_total()` method entirely — it was the DtoH readback.
- [ ] **Step 6: Verify compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml-dqn 2>&1 | tail -3`
- [ ] **Step 7: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml-dqn --lib -- --test-threads=2 2>&1 | tail -3`
Expected: 359 passed, 0 failed
- [ ] **Step 8: Commit**
```bash
git add crates/ml-dqn/src/gpu_replay_buffer.rs
git commit -m "perf: GPU-native PER sampling — zero CPU roundtrips per training step"
```
---
### Task 5: Remove HtoD for adam_step counter
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:790-793,855-856`
- [ ] **Step 1: Add GPU-side adam_step increment kernel**
Add a tiny kernel or use `cuMemsetD32` to increment the step counter on GPU instead of `memcpy_htod(&[self.adam_step], &mut self.t_buf)`.
Alternative (simpler): keep the HtoD but document it as 4-byte cold-path. The adam_step is 1 i32 per training step — negligible vs the PER fix. Mark as `// gpu-entry: 4B adam_step (acceptable — 1 scalar/step)`.
- [ ] **Step 2: Commit**
```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "perf: document adam_step HtoD as acceptable 4B entry point"
```
---
### Task 6: Profile and validate
**Files:**
- Read: training_loop.rs phase breakdown output
- [ ] **Step 1: Run on RTX 3050 with phase profiling**
```bash
SQLX_OFFLINE=true cargo run --release --example train_baseline_rl -p ml -- \
--model dqn --data-dir test_data/futures-baseline --symbol ES.FUT \
--epochs 3 --max-steps-per-epoch 50 --batch-size 64 \
--train-months 3 --val-months 1 --test-months 1 --step-months 3 \
2>&1 | grep "phase breakdown"
```
Expected: training phase time reduced by 2-3× compared to baseline 14,858ms.
- [ ] **Step 2: Run full test suite**
```bash
SQLX_OFFLINE=true cargo test -p ml-core --lib && \
SQLX_OFFLINE=true cargo test -p ml-dqn --lib && \
SQLX_OFFLINE=true cargo test -p ml --lib && \
SQLX_OFFLINE=true cargo test -p ml --test smoke_test_real_data -- --test-threads=1
```
Expected: all pass, 0 failures.
- [ ] **Step 3: Push and run H100 CI**
```bash
git push origin main
argo submit --from workflowtemplate/gpu-test-pipeline -p revision=main -n foxhunt
```
Expected: phase breakdown shows training < 15s (down from 37.56s).
- [ ] **Step 4: Commit profiling results to spec**
Update `docs/superpowers/specs/2026-03-21-h100-epoch-optimization-design.md` with actual measured speedup.
---
### Task 7: Deprecate to_host() and add guard
**Files:**
- Modify: `crates/ml-core/src/cuda_autograd/gpu_tensor.rs` (deprecate to_host)
- Modify: `crates/ml-core/src/cuda_autograd/stream_ops.rs` (deprecate to_vec)
- Modify: `scripts/gpu-hotpath-guard.sh` (add to_host pattern)
- [ ] **Step 1: Rename to_host → to_host_checkpoint, add #[deprecated]**
```rust
#[deprecated(note = "Use to_host_checkpoint() for cold-path exports only. Hot-path DtoH is forbidden.")]
pub fn to_host(&self, stream: &Arc<CudaStream>) -> Result<Vec<f32>, MLError> {
self.to_host_checkpoint(stream)
}
/// Cold-path only: download tensor to host for checkpoint export.
/// NOT for use in training hot paths.
pub fn to_host_checkpoint(&self, stream: &Arc<CudaStream>) -> Result<Vec<f32>, MLError> {
// ... existing implementation
}
```
- [ ] **Step 2: Add to_host to gpu-hotpath-guard.sh**
Add `\.to_host(` to the HOT_PATH patterns in `scripts/gpu-hotpath-guard.sh`.
- [ ] **Step 3: Fix all existing callers to use to_host_checkpoint**
Update all legitimate callers (checkpoint save, test assertions, epoch-boundary metrics) to use `to_host_checkpoint()`.
- [ ] **Step 4: Run full test suite**
- [ ] **Step 5: Commit**
```bash
git commit -m "perf: deprecate to_host(), add to_host_checkpoint() for cold paths only"
```