X11's original plan said "capture_graph_a covers full v2 forward" but
the X11 commit (4f888abbf) only shipped forward_only + from_checkpoint.
Graph capture is now actually implemented for the inference path.
Mirrors the pattern already in step_batched (perception.rs:1221-1257):
- First call: eager dispatch + set forward_warmed flag.
- Second call: begin_capture -> dispatch_forward_kernels -> end_capture
-> store CudaGraph.
- Subsequent calls: graph.launch() — captured replay.
forward_only now performs its own staging-fill of the mapped-pinned
host buffers (input data varies per call), then dispatches through
the three-state machine. The captured region is the new private
dispatch_forward_kernels helper: a copy of evaluate_batched's
forward chain (VSN -> Mamba2 x2 -> LN x2 -> attn-pool -> CfC K-loop
-> heads) that omits labels, BCE, and any stream syncs. The final
sync + dtoh of probs_per_k_d happens OUTSIDE capture in forward_only.
Per pearl_no_host_branches_in_captured_graph: no host branches /
scalar-arg-changes / host-mallocs inside the captured region; all
kernel launches use pre-bound device pointers stable across replays.
Per pearl_cudarc_disable_event_tracking_for_graph_capture: event
tracking is already disabled for the trainer's lifetime at
construction (see PerceptionTrainer::new ~line 529), so the captured
region is free of cuStreamWaitEvent / event.record() insertions.
Vestigial loader.rs:272 doc comment referencing the never-shipped
CfcTrunk::capture_graph_a updated to point at the now-real
PerceptionTrainer::forward_only warmup path.
Regression: forward_captured_matches_uncaptured — eager (call 1) vs
captured replay (call 3) agree within 1e-5 relative tolerance per
element. NOT strict bit-identity because CUDA Graph capture can
reorder kernel launches and flip f32 reductions by 1 ULP harmlessly.
Local RTX 3050 Ti run: 160 elements, max rel_err = 0e0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
4.6 KiB
Rust
134 lines
4.6 KiB
Rust
//! P5 regression: CUDA Graph capture of `PerceptionTrainer::forward_only`.
|
|
//!
|
|
//! The captured-graph replay (3rd call) must agree with the eager
|
|
//! warmup dispatch (1st call) within 1e-5 relative tolerance per
|
|
//! element. Strict bit-identity is NOT asserted because CUDA Graph
|
|
//! capture can reorder kernel launches and flip f32 reductions by
|
|
//! 1 ULP harmlessly — that's why the spec specifies tolerance, not
|
|
//! equality.
|
|
//!
|
|
//! Runtime invocation (GPU required):
|
|
//!
|
|
//! SQLX_OFFLINE=true cargo test -p ml-alpha \
|
|
//! --test forward_graph_capture -- --ignored --nocapture
|
|
//!
|
|
//! The same `PerceptionTrainer` instance is used for all three calls;
|
|
//! the three-state machine inside `forward_only` is:
|
|
//! call 1: eager warmup (sets `forward_warmed = true`)
|
|
//! call 2: begin_capture → dispatch → end_capture (stores graph)
|
|
//! call 3: graph.launch() — captured replay
|
|
//!
|
|
//! Asserting call 1 vs call 3 ensures the captured kernel chain is
|
|
//! semantically equivalent to the eager dispatch.
|
|
|
|
use anyhow::{Context, Result};
|
|
use ml_alpha::cfc::snap_features::{Mbp10RawInput, REGIME_DIM};
|
|
use ml_alpha::heads::N_HORIZONS;
|
|
use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
|
|
use ml_core::device::MlDevice;
|
|
|
|
const REL_TOL: f32 = 1e-5;
|
|
|
|
/// Deterministic synthetic window: bid/ask prices decrement by 0.01 per
|
|
/// step, fixed sizes, monotonically increasing ts_ns. Same data on each
|
|
/// call — but the per-snapshot index is encoded into prices/timestamps
|
|
/// so a "shadow position 0 everywhere" bug would still produce
|
|
/// distinguishable output across positions.
|
|
fn make_test_window(n: usize) -> Vec<Mbp10RawInput> {
|
|
let mut prev_ts_ns: u64 = 1_000_000_000;
|
|
let mut prev_mid: f32 = 100.0;
|
|
(0..n)
|
|
.map(|i| {
|
|
let mid = 100.0 - (i as f32) * 0.01;
|
|
let ts_ns = prev_ts_ns + 1_000_000;
|
|
let bid_px: [f32; 10] = std::array::from_fn(|j| mid - 0.125 - (j as f32) * 0.25);
|
|
let ask_px: [f32; 10] = std::array::from_fn(|j| mid + 0.125 + (j as f32) * 0.25);
|
|
let bid_sz: [f32; 10] = [10.0; 10];
|
|
let ask_sz: [f32; 10] = [10.0; 10];
|
|
let regime: [f32; REGIME_DIM] = [0.1; REGIME_DIM];
|
|
let snap = Mbp10RawInput {
|
|
bid_px,
|
|
bid_sz,
|
|
ask_px,
|
|
ask_sz,
|
|
prev_mid,
|
|
trade_signed_vol: 0.0,
|
|
trade_count: 0,
|
|
ts_ns,
|
|
prev_ts_ns,
|
|
regime,
|
|
};
|
|
prev_mid = mid;
|
|
prev_ts_ns = ts_ns;
|
|
snap
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn forward_captured_matches_uncaptured() -> Result<()> {
|
|
let dev = match MlDevice::cuda(0) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
eprintln!("skipping: cuda device unavailable ({e})");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let cfg = PerceptionTrainerConfig::default();
|
|
let total = cfg.seq_len * cfg.n_batch;
|
|
let window = make_test_window(total);
|
|
|
|
let mut trainer = PerceptionTrainer::new(&dev, &cfg)
|
|
.context("build PerceptionTrainer")?;
|
|
|
|
// Call 1: eager warmup (no capture, no replay).
|
|
let probs_eager = trainer
|
|
.forward_only(&window)
|
|
.context("forward_only call 1 (warmup)")?;
|
|
|
|
// Call 2: capture pass (begin_capture → dispatch → end_capture).
|
|
// The dispatch executes once; output is also valid here but not asserted.
|
|
let _probs_capture = trainer
|
|
.forward_only(&window)
|
|
.context("forward_only call 2 (capture)")?;
|
|
|
|
// Call 3: replay the captured graph.
|
|
let probs_replay = trainer
|
|
.forward_only(&window)
|
|
.context("forward_only call 3 (replay)")?;
|
|
|
|
assert_eq!(
|
|
probs_eager.len(),
|
|
probs_replay.len(),
|
|
"probs length mismatch: eager={} replay={}",
|
|
probs_eager.len(),
|
|
probs_replay.len()
|
|
);
|
|
let expected_len = cfg.seq_len * cfg.n_batch * N_HORIZONS;
|
|
assert_eq!(probs_eager.len(), expected_len, "probs len != K*B*N_HORIZONS");
|
|
|
|
let mut max_rel: f32 = 0.0;
|
|
let mut worst_idx: usize = 0;
|
|
for (i, (a, b)) in probs_eager.iter().zip(probs_replay.iter()).enumerate() {
|
|
let denom = a.abs().max(1e-9);
|
|
let rel = (a - b).abs() / denom;
|
|
if rel > max_rel {
|
|
max_rel = rel;
|
|
worst_idx = i;
|
|
}
|
|
assert!(
|
|
rel < REL_TOL,
|
|
"probs[{i}] eager={a} replay={b} rel_err={rel:e} > {REL_TOL:e}"
|
|
);
|
|
}
|
|
eprintln!(
|
|
"forward_captured_matches_uncaptured: {} elements, max rel_err = {:e} at index {}",
|
|
probs_eager.len(),
|
|
max_rel,
|
|
worst_idx
|
|
);
|
|
Ok(())
|
|
}
|