Second half of Phase B4b. Wires the trade-outcome label column through
the replay buffer (struct field, allocation, scatter on insert, gather
on sample, direct-to-trainer pointer + setter) and connects the
trainer's aux_to_label_buf to receive per-batch sampled labels.
Completes the end-to-end producer → ring → trainer i32 path the K=3
sparse CE consumer reads.
Replay buffer changes (crates/ml-dqn/src/gpu_replay_buffer.rs):
- New struct fields: aux_outcome_labels (capacity-sized ring),
sample_aux_outcome_labels (mbs-sized fallback gather), trainer_aux_
outcome_labels_ptr (direct-path destination)
- New aux_outcome_labels_ptr field on GpuBatchPtrs
- insert_batch signature: new aux_outcome arg between aux_conf_in and
bs. Scatters via existing K-generic scatter_insert_i32 (same kernel
the K=2 aux_sign_labels uses).
- sample_proportional direct gather when trainer_aux_outcome_labels_ptr
!= 0; fallback gather otherwise. Mirrors aux_sign_labels direct/
fallback semantic exactly.
- New setter set_trainer_aux_outcome_labels_ptr mirrors
set_trainer_aux_conf_ptr.
Collector emission:
- New aux_outcome_labels field on GpuExperienceBatch
- Populated at end of collect_experiences_gpu via dtod_clone_i32 from
Phase B4b-1's per-(env, t) producer scratch.
Trainer wireup:
- aux_to_label_buf_ptr() accessor on GpuDqnTrainer (mirrors
aux_nb_label_buf_ptr)
- trainer_aux_to_label_buf_ptr() delegating accessor on
FusedTrainingCtx
- New set_trainer_aux_outcome_labels_ptr call in training_loop at the
same site where set_trainer_buffers + set_trainer_aux_conf_ptr fire.
Two call sites updated (lines ~835 + ~2857).
- insert_batch call in training_loop passes &gpu_batch.aux_outcome
_labels as new arg.
Test fixtures updated: 4 smoke test files + 3 unit-test fixtures in
gpu_replay_buffer.rs alloc zero-init aux_outcome i32 arg.
End-to-end chain complete:
trade_outcome_label_kernel (A2)
→ collector per-step launch (B3)
→ collector emission (B4b-1)
→ replay-buffer insert + scatter (B4b-2)
→ PER sample + direct gather (B4b-2)
→ trainer aux_heads_forward.loss_reduce (B4) reads sparse {-1,0,1,2}
→ trainer aux_heads_backward (B4) computes per-sample partials
→ Adam SAXPY (B1+B4) updates W1, b1, W2, b2 at [163..167)
The "degraded predict-Profit-everywhere" cold-start from Phase B4 is
resolved. K=3 head trains on real sparse trade-outcome labels.
Verification:
- cargo check -p ml clean (21 warnings, none new).
- cargo test -p ml --lib → 1016/0 on clean runs; pre-existing
NoisyLinear flake still surfaces ~30-50% of runs (unrelated to
vNext work — see ebc1b1502 / 20e1aea27 commit notes).
Remaining vNext work:
- B5: input concat 256 → 262 with plan_params
- Phase C: 3-slot state assembly (state[121..124])
- Phase D: 12-weight W atom-shift (4 actions × 3 outcomes)
- Phase E: dW backward + Adam for W[4, 3]
- Phase F: validation smoke at β=0.5 structural prior
Audit: docs/dqn-wire-up-audit.md Phase B4b-2 section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
9.2 KiB
Rust
255 lines
9.2 KiB
Rust
//! Performance smoke tests.
|
|
//!
|
|
//! Speed benchmarks for the DQN training pipeline. Requires GPU and
|
|
//! real DBN test data in `test_data/ES.FUT/`.
|
|
//!
|
|
//! ```sh
|
|
//! cargo test -p ml --lib smoke_tests::performance -- --nocapture
|
|
//! ```
|
|
use super::helpers::{assert_finite, smoke_params, test_data_dir};
|
|
use crate::trainers::dqn::DQNTrainer;
|
|
use ml_core::device::MlDevice;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
use tracing::info;
|
|
|
|
/// Resolve test data dir — hard error if missing.
|
|
fn data_dir() -> String {
|
|
test_data_dir()
|
|
.expect("FOXHUNT_TEST_DATA or test_data/ must exist — no synthetic fallback")
|
|
}
|
|
|
|
/// Measure end-to-end training throughput (3 epochs, real data).
|
|
///
|
|
/// Purpose: verify the training pipeline runs at non-trivial speed AND produces
|
|
/// plausible metrics. Previously only checked `loss.is_finite()` which passes on
|
|
/// trivial zeros or NaN-less garbage — giving no signal that anything healthy
|
|
/// happened. We now assert a conservative steps/second floor and bound the
|
|
/// Q-value magnitude.
|
|
#[test]
|
|
#[ignore] // Loads real training data — run via nightly CI or manual trigger
|
|
fn test_training_throughput_measurement() -> anyhow::Result<()> {
|
|
let mut trainer = DQNTrainer::new(smoke_params())?; // auto-detect GPU
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
|
|
let start = Instant::now();
|
|
let metrics = rt.block_on(trainer.train(&data_dir(), "ES.FUT", |_epoch, _bytes, _best| {
|
|
Ok("skip".to_owned())
|
|
}))?;
|
|
let elapsed = start.elapsed();
|
|
|
|
// ── loss: finite and non-negative ──
|
|
assert_finite(metrics.loss, "loss");
|
|
assert!(
|
|
metrics.loss >= 0.0,
|
|
"loss should be non-negative (sign-bug indicator), got {}",
|
|
metrics.loss,
|
|
);
|
|
|
|
// ── epochs must have run ──
|
|
assert!(
|
|
metrics.epochs_trained >= 1,
|
|
"no epoch completed — throughput cannot be measured. metrics: {:?}",
|
|
metrics,
|
|
);
|
|
|
|
// ── throughput floor ──
|
|
// RTX 3050 Ti smoketest target: > 10 epochs/s is aspirational, > 0.05 epochs/s
|
|
// (i.e. each epoch < 20s) is a safe local floor. Raise in CI if desired — the
|
|
// point of this floor is to catch catastrophic regressions (e.g. accidentally
|
|
// falling back to CPU, or a kernel that ran but pinned a CPU loop).
|
|
let epochs_per_sec = metrics.epochs_trained as f64 / elapsed.as_secs_f64().max(1e-6);
|
|
assert!(
|
|
epochs_per_sec > 0.05,
|
|
"Training throughput {:.3} epochs/s below floor 0.05 (laptop target). \
|
|
{} epochs in {:.2}s — CI may set a higher floor.",
|
|
epochs_per_sec,
|
|
metrics.epochs_trained,
|
|
elapsed.as_secs_f64(),
|
|
);
|
|
|
|
// ── avg_q_value must be present, finite, and bounded ──
|
|
// Rules out NaN-like explosions that happen to be finite-but-huge.
|
|
let avg_q = metrics
|
|
.additional_metrics
|
|
.get("avg_q_value")
|
|
.copied()
|
|
.unwrap_or(f64::NAN);
|
|
assert!(
|
|
avg_q.is_finite(),
|
|
"avg_q_value missing or NaN — diagnostic regressed: {:?}",
|
|
metrics.additional_metrics.get("avg_q_value"),
|
|
);
|
|
assert!(
|
|
avg_q.abs() < 1e6,
|
|
"avg_q_value {avg_q} is implausibly large (C51 atoms exhausted or \
|
|
NaN-like explosion). v_range is finite by construction.",
|
|
);
|
|
|
|
info!(
|
|
elapsed_secs = elapsed.as_secs_f64(),
|
|
epochs_trained = metrics.epochs_trained,
|
|
epochs_per_sec = epochs_per_sec,
|
|
"Training throughput"
|
|
);
|
|
info!(loss = metrics.loss, "Avg loss");
|
|
info!(avg_q_value = avg_q, "Avg Q-value");
|
|
if let Some(&final_eps) = metrics.additional_metrics.get("final_epsilon") {
|
|
info!(final_epsilon = final_eps, "Final epsilon");
|
|
}
|
|
drop(trainer);
|
|
drop(rt);
|
|
Ok(())
|
|
}
|
|
|
|
/// Measure per-sample latency of the GPU PER replay buffer.
|
|
#[test]
|
|
fn test_per_sample_latency() -> anyhow::Result<()> {
|
|
use crate::dqn::gpu_replay_buffer::{GpuReplayBuffer, GpuReplayBufferConfig};
|
|
|
|
let dev = MlDevice::new_cuda(0)?;
|
|
let stream = Arc::clone(dev.cuda_stream()?);
|
|
let state_dim: usize = 48;
|
|
let capacity: usize = 10_000;
|
|
let config = GpuReplayBufferConfig {
|
|
capacity,
|
|
alpha: 0.6,
|
|
beta_start: 0.4,
|
|
beta_max: 1.0,
|
|
beta_annealing_steps: 1000,
|
|
epsilon: 1e-6,
|
|
max_memory_bytes: 4 * 1024 * 1024 * 1024,
|
|
max_batch_size: 1024,
|
|
};
|
|
let mut buf = GpuReplayBuffer::new(config, &stream)?;
|
|
|
|
// Fill with 5000 experiences
|
|
let fill_count: usize = 5000;
|
|
let batch_insert: usize = 100;
|
|
for _ in 0..(fill_count / batch_insert) {
|
|
use rand::Rng;
|
|
let mut rng = rand::thread_rng();
|
|
let host_states: Vec<f32> = (0..batch_insert * state_dim).map(|_| rng.gen_range(-1.0_f32..1.0)).collect();
|
|
let host_rewards: Vec<f32> = (0..batch_insert).map(|_| rng.gen_range(-1.0_f32..1.0)).collect();
|
|
|
|
let states = stream.clone_htod(&host_states).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let next_states = stream.clone_htod(&host_states).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let actions = stream.alloc_zeros::<u32>(batch_insert).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let rewards = stream.clone_htod(&host_rewards).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let dones = stream.alloc_zeros::<f32>(batch_insert).map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let aux_sign = stream.alloc_zeros::<i32>(batch_insert).map_err(|e| anyhow::anyhow!("aux_sign alloc: {e}"))?;
|
|
let aux_conf = stream.alloc_zeros::<f32>(batch_insert).map_err(|e| anyhow::anyhow!("aux_conf alloc: {e}"))?;
|
|
let aux_outcome = stream.alloc_zeros::<i32>(batch_insert).map_err(|e| anyhow::anyhow!("aux_outcome alloc: {e}"))?;
|
|
buf.insert_batch(&states, &next_states, &actions, &rewards, &dones, &aux_sign, &aux_conf, &aux_outcome, batch_insert)?;
|
|
}
|
|
assert_eq!(buf.len(), fill_count);
|
|
|
|
// Warmup
|
|
for _ in 0..5 {
|
|
let _ = buf.sample_proportional(64)?;
|
|
}
|
|
|
|
// Timed: 100 proportional samples of batch_size=64
|
|
let sample_rounds: usize = 100;
|
|
let start = Instant::now();
|
|
for _ in 0..sample_rounds {
|
|
let _ = buf.sample_proportional(64)?;
|
|
}
|
|
let elapsed = start.elapsed();
|
|
let us_per_sample = elapsed.as_micros() as f64 / sample_rounds as f64;
|
|
|
|
info!(
|
|
us_per_sample = us_per_sample,
|
|
rounds = sample_rounds,
|
|
batch_size = 64,
|
|
"PER proportional sample latency"
|
|
);
|
|
assert!(
|
|
us_per_sample < 50_000.0,
|
|
"sample latency {us_per_sample:.1} us is unreasonably high"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Single-epoch training on real data.
|
|
/// Loads full 163K-bar dataset — too slow for CI (run via nightly or manual trigger).
|
|
///
|
|
/// Purpose: one epoch on real ES.FUT data produces valid training metrics.
|
|
/// Previously asserted only `loss.is_finite()`, which passes on `loss == 0.0`
|
|
/// (a sign-bug / accumulation-bug tell) or any bounded NaN-less value. A
|
|
/// healthy epoch must complete at least one step, produce bounded non-negative
|
|
/// loss, and a bounded finite avg_q_value.
|
|
#[test]
|
|
#[ignore]
|
|
fn test_real_data_single_epoch() -> anyhow::Result<()> {
|
|
let mut params = smoke_params();
|
|
params.epochs = 1;
|
|
let mut trainer = DQNTrainer::new(params)?;
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
|
|
let start = Instant::now();
|
|
let metrics = rt.block_on(trainer.train(&data_dir(), "ES.FUT", |_epoch, _bytes, _best| {
|
|
Ok("skip".to_owned())
|
|
}))?;
|
|
let elapsed = start.elapsed();
|
|
|
|
// ── loss: finite, bounded, non-negative ──
|
|
// Negative loss would signal a sign bug (loss is ≥0 by construction — MSE + C51
|
|
// KL + CQL are all non-negative). Zero loss on a real DQN training run is also
|
|
// a tell: either the network is predicting exactly the targets (impossible at
|
|
// step 1) or the backward pass is returning nothing (accumulation bug). The
|
|
// upper bound 1e8 rules out NaN-propagation disguised as "finite".
|
|
assert_finite(metrics.loss, "real_data_loss");
|
|
assert!(
|
|
metrics.loss >= 0.0,
|
|
"real_data loss is negative ({}) — sign bug in MSE / C51 / CQL accumulator",
|
|
metrics.loss,
|
|
);
|
|
assert!(
|
|
metrics.loss < 1e8,
|
|
"real_data loss {} exceeds bound 1e8 — finite but implausibly huge, check \
|
|
NaN propagation or reward scaling",
|
|
metrics.loss,
|
|
);
|
|
|
|
// ── at least one epoch completed ──
|
|
assert!(
|
|
metrics.epochs_trained >= 1,
|
|
"real_data single-epoch test produced 0 epochs. metrics: {:?}",
|
|
metrics,
|
|
);
|
|
|
|
// ── avg_q_value: finite + bounded ──
|
|
let avg_q = metrics
|
|
.additional_metrics
|
|
.get("avg_q_value")
|
|
.copied()
|
|
.unwrap_or(f64::NAN);
|
|
assert!(
|
|
avg_q.is_finite(),
|
|
"real_data avg_q_value missing/NaN: {:?}",
|
|
metrics.additional_metrics.get("avg_q_value"),
|
|
);
|
|
assert!(
|
|
avg_q.abs() < 1e6,
|
|
"real_data avg_q_value {avg_q} is implausibly large — C51 atoms exhausted \
|
|
or NaN-like explosion",
|
|
);
|
|
|
|
info!(
|
|
elapsed_secs = elapsed.as_secs_f64(),
|
|
loss = metrics.loss,
|
|
epochs_trained = metrics.epochs_trained,
|
|
avg_q_value = avg_q,
|
|
"Real-data single epoch"
|
|
);
|
|
drop(trainer);
|
|
drop(rt);
|
|
Ok(())
|
|
}
|