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>
254 lines
9.1 KiB
Rust
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(())
|
|
}
|