Files
foxhunt/crates/ml/src/trainers/dqn/smoke_tests/performance.rs
jgrusewski dd9c70df98 feat(sp20): Phase 3 Task 3.4 — replay buffer schema for per-bar aux_conf
Lands the producer→ring→consumer schema plumbing for per-bar aux_conf
(the K=2 peak-softmax-above-uniform confidence value the Phase 5
Aux→Q gate consumes at the Bellman target). Atomic across all struct
boundaries per `feedback_no_partial_refactor`.

Data flow (TWO struct boundaries, not one — plan errata Gap 11):

  ExperienceCollector  →  GpuExperienceBatch  →  insert_batch()
                            (.aux_conf field)      (8th arg)
       →  ring  →  sample()  →  GpuBatchPtrs  →  Trainer
                                  (.aux_conf_ptr)   (Phase 5 consumer)

Phase 5 lands the consumer (Aux→Q gate kernel at the Bellman target);
this commit is the producer-side schema plumbing only.

Spec §4.4 Phase 5 forward-reference contract:

  At each replay batch step:
    aux_conf  = ptrs.aux_conf_ptr[k]                 # SAMPLED bar
    threshold = ISV[AUX_CONF_THRESHOLD_INDEX]   # [0.01, 0.20]
    temp      = ISV[AUX_GATE_TEMP_INDEX]
    gate      = sigmoid((aux_conf - threshold) / temp)  # ∈ [0, 1]
    Q_target  = gate × Q_full + (1 - gate) × mean_a Q(s, a)

Producer (experience_env_step):
  - 1 new kernel arg `aux_conf_per_sample: float* [N×L×cf_mult]`.
  - Per-bar write at kernel entry: `aux_conf_per_sample[out_off] =
    sp20_compute_per_bar_aux_conf_k2(aux_logits + i*K)`.
  - CF slot write: `aux_conf_per_sample[cf_off] =
    aux_conf_per_sample[out_off]` (CF state shares the same env state
    so shares the same aux signal).
  - NULL-tolerant: defaults to 0.0f (K=2 uniform-prior sentinel).

GpuExperienceCollector:
  - +`aux_conf_per_sample: CudaSlice<f32>` field.
  - alloc with `total_output * cf_mult` for both on-policy + CF.
  - Threads as kernel arg of experience_env_step.
  - Clones into `GpuExperienceBatch.aux_conf` at end of
    collect_experiences_gpu (size `total = base_total × 2`).

GpuExperienceBatch: +`aux_conf: CudaSlice<f32>` field.

GpuReplayBuffer (crates/ml-dqn):
  - +`GpuBatchPtrs.aux_conf_ptr: u64`.
  - +ring storage `aux_conf: CudaSlice<f32>` [capacity].
  - +sample buffer `sample_aux_conf: CudaSlice<f32>` [max_batch_size].
  - +`trainer_aux_conf_ptr: u64` for direct path.
  - +`set_trainer_aux_conf_ptr` setter + `trainer_aux_conf_ptr`
    accessor (mirrors `set_isv_signals_ptr` pattern).
  - `insert_batch` adds 8th arg `aux_conf_in: &CudaSlice<f32>` —
    scatters via `scatter_insert_f32` (reuses the rewards/dones
    scatter kernel).
  - `sample_proportional` adds gather: direct-to-trainer if
    `trainer_aux_conf_ptr != 0`, else fallback into
    `sample_aux_conf`. Both paths populate `GpuBatchPtrs.aux_conf_ptr`.

Atomic caller updates (insert_batch arg from 7 to 8):
  - training_loop.rs:2522 (production)
  - gpu_residency.rs:77 (smoke)
  - performance.rs:144 (smoke)
  - training_stability.rs:154+201 (smoke ×2)
  - gpu_per_integration_test.rs:128 (integration)
  - sp15_phase1_oracle_tests.rs:2170+2189+2356 (oracle ×3)
  - 3 inline tests in gpu_replay_buffer.rs

Tests:
  - test_aux_conf_schema_round_trip (GPU): inserts 8 transitions with
    distinct aux_conf [0.0, 0.05, ..., 0.35]; samples 1; asserts the
    gathered slot value matches one of the inserted values
    (round-trip integrity invariant).
  - test_aux_conf_trainer_ptr_wiring_contract (CPU): asserts
    set_trainer_aux_conf_ptr / trainer_aux_conf_ptr setter+accessor
    behavior matches the SP15 Phase 3.5.5.b mirror pattern.

Verification:
  SQLX_OFFLINE=true cargo check -p ml --tests --features cuda    # green
  SQLX_OFFLINE=true cargo check -p ml-dqn --tests --features cuda # green
  SQLX_OFFLINE=true cargo test -p ml --lib sp20                  # 21/21 pass
  SQLX_OFFLINE=true cargo test -p ml-dqn test_aux_conf_trainer_ptr_wiring_contract  # CPU pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:56:19 +02:00

254 lines
9.1 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}"))?;
buf.insert_batch(&states, &next_states, &actions, &rewards, &dones, &aux_sign, &aux_conf, 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(())
}