Branching DQN (Tavakoli et al., 2018):
- 3 independent advantage heads (exposure=5, order=3, urgency=3) in CUDA kernel
- `use_branching` as hyperopt-tunable parameter (index 11 in 29D space)
- Branch weight pointers packed into KernelWeightPack struct
KernelWeightPack refactor:
- 87→38 CUDA kernel params by packing 48 weight pointers into 384-byte #[repr(C)] struct
- UNPACK_WEIGHT_PTRS macro in CUDA header for clean kernel-side access
- Single .arg(&weight_pack) replaces 48 individual .arg() calls
Hyperopt metrics in JSON output:
- Added `metrics: Option<serde_json::Value>` to TrialResult<P>
- `extract_metrics()` trait method with default None (DQN overrides)
- JSON output now includes per-trial backtest metrics (Sharpe, Sortino,
Calmar, Omega, drawdown, win_rate, trades) + top-level best_metrics
Prometheus backtest gauges (Rust-side, no CUDA):
- 8 new gauges: foxhunt_hyperopt_best_{sharpe,sortino,calmar,omega,
max_drawdown_pct,win_rate,total_trades,total_return_pct}
Dynamic episode scaling:
- Removed hardcoded 256 episode cap on small GPUs
- VRAM budget calculation in optimal_n_episodes() handles scaling naturally
- Removed stale .min(256) in PPO trainer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
659 lines
26 KiB
Rust
659 lines
26 KiB
Rust
//! GPU kernel Q-value parity tests.
|
|
//!
|
|
//! Validates that the CUDA experience collection kernel produces the same
|
|
//! Q-values as the Candle network forward pass for all network variants:
|
|
//! 1. Standard dueling Q-network
|
|
//! 2. Distributional dueling Q-network (C51)
|
|
//! 3. NoisyNet dueling (non-deterministic — verify noise injection works)
|
|
//!
|
|
//! These tests require a CUDA GPU and are `#[ignore]`d by default.
|
|
//! Run with: `cargo test -p ml --test gpu_kernel_parity_test -- --ignored`
|
|
|
|
#[cfg(feature = "cuda")]
|
|
mod gpu_parity {
|
|
use std::sync::Arc;
|
|
|
|
use candle_core::Device;
|
|
|
|
use ml::cuda_pipeline::gpu_experience_collector::{
|
|
ExperienceCollectorConfig, GpuExperienceCollector,
|
|
};
|
|
use ml::cuda_pipeline::gpu_weights::extract_dueling_weights;
|
|
use ml::dqn::dueling::{DuelingConfig, DuelingQNetwork};
|
|
use ml::dqn::distributional_dueling::{
|
|
DistributionalDuelingConfig, DistributionalDuelingQNetwork,
|
|
};
|
|
|
|
type CudaStream = candle_core::cuda_backend::cudarc::driver::CudaStream;
|
|
|
|
/// Skip test if no CUDA device available.
|
|
fn require_cuda() -> Device {
|
|
match Device::cuda_if_available(0) {
|
|
Ok(dev) if dev.is_cuda() => dev,
|
|
_ => {
|
|
eprintln!("CUDA not available, skipping GPU test");
|
|
std::process::exit(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get the CudaStream from a Candle CUDA device.
|
|
fn cuda_stream(device: &Device) -> Arc<CudaStream> {
|
|
match device {
|
|
Device::Cuda(cuda_dev) => cuda_dev.cuda_stream(),
|
|
_ => panic!("Expected CUDA device"),
|
|
}
|
|
}
|
|
|
|
fn dueling_config() -> DuelingConfig {
|
|
DuelingConfig::new(54, 5, vec![256, 256], 128, 128)
|
|
}
|
|
|
|
fn dist_config() -> DistributionalDuelingConfig {
|
|
DistributionalDuelingConfig::new(54, 5, 51, vec![256, 256], 128, 128)
|
|
}
|
|
|
|
/// Default network dims matching dueling_config/dist_config: shared=[256,256], value=128, adv=128
|
|
const TEST_DIMS: (usize, usize, usize, usize) = (256, 256, 128, 128);
|
|
/// Default kernel dims: state_dim=54 (tests use 51 market + 3 portfolio), market_dim=51, atoms_max=51
|
|
const TEST_KERNEL_DIMS: (usize, usize, usize) = (54, 51, 51);
|
|
|
|
type CudaSlice<T> = candle_core::cuda_backend::cudarc::driver::CudaSlice<T>;
|
|
|
|
/// Build synthetic market features on GPU.
|
|
fn synthetic_market_data(
|
|
total_bars: usize,
|
|
device: &Device,
|
|
) -> (CudaSlice<f32>, CudaSlice<f32>) {
|
|
let stream = cuda_stream(device);
|
|
|
|
let market_len = total_bars * 51;
|
|
let mut market_data = vec![0.0_f32; market_len];
|
|
for i in 0..market_len {
|
|
market_data[i] = ((i as f32 * 0.7123 + 0.3).sin()) * 0.5;
|
|
}
|
|
let mut market_buf = stream.alloc_zeros::<f32>(market_len).unwrap();
|
|
stream.memcpy_htod(&market_data, &mut market_buf).unwrap();
|
|
|
|
let target_len = total_bars * 4;
|
|
let mut target_data = vec![0.0_f32; target_len];
|
|
for i in 0..total_bars {
|
|
target_data[i * 4] = 100.0 + (i as f32 * 0.01);
|
|
target_data[i * 4 + 1] = 100.5 + (i as f32 * 0.01);
|
|
target_data[i * 4 + 2] = 99.5 + (i as f32 * 0.01);
|
|
target_data[i * 4 + 3] = 1000.0;
|
|
}
|
|
let mut target_buf = stream.alloc_zeros::<f32>(target_len).unwrap();
|
|
stream.memcpy_htod(&target_data, &mut target_buf).unwrap();
|
|
|
|
(market_buf, target_buf)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 1: Standard dueling forward — verify kernel produces valid Q-values
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_gpu_dueling_forward_produces_valid_q_values() {
|
|
let device = require_cuda();
|
|
let stream = cuda_stream(&device);
|
|
|
|
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
let target_network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
|
|
let mut collector = GpuExperienceCollector::new(
|
|
stream.clone(),
|
|
network.vars(),
|
|
target_network.vars(),
|
|
None,
|
|
100_000.0,
|
|
0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
let total_bars = 2000;
|
|
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
|
|
|
|
let n_episodes = 4_usize;
|
|
let timesteps = 50;
|
|
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 100) as i32).collect();
|
|
|
|
let config = ExperienceCollectorConfig {
|
|
n_episodes: n_episodes as i32,
|
|
timesteps_per_episode: timesteps,
|
|
total_bars: total_bars as i32,
|
|
episode_length: timesteps,
|
|
epsilon: 0.1,
|
|
gamma: 0.99,
|
|
use_noisy_nets: false,
|
|
use_distributional: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let batch = collector
|
|
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config)
|
|
.expect("GPU experience collection failed");
|
|
|
|
let expected_total = n_episodes * timesteps as usize;
|
|
assert_eq!(batch.actions.len(), expected_total, "actions length mismatch");
|
|
assert_eq!(batch.rewards.len(), expected_total, "rewards length mismatch");
|
|
assert_eq!(batch.target_q_values.len(), expected_total, "target_q length mismatch");
|
|
assert_eq!(batch.td_errors.len(), expected_total, "td_errors length mismatch");
|
|
|
|
for (i, &q) in batch.target_q_values.iter().enumerate() {
|
|
assert!(q.is_finite(), "target_q[{i}] = {q} is not finite");
|
|
assert!(q.abs() < 1000.0, "target_q[{i}] = {q} unexpectedly large");
|
|
}
|
|
|
|
for (i, &a) in batch.actions.iter().enumerate() {
|
|
assert!((0..5).contains(&a), "action[{i}] = {a} out of range [0, 4]");
|
|
}
|
|
|
|
for (i, &td) in batch.td_errors.iter().enumerate() {
|
|
assert!(td.is_finite(), "td_error[{i}] = {td} is not finite");
|
|
}
|
|
|
|
let q_min = batch.target_q_values.iter().cloned().fold(f32::INFINITY, f32::min);
|
|
let q_max = batch.target_q_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
|
println!("Standard dueling: {expected_total} experiences, Q range [{q_min:.4}, {q_max:.4}]");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 2: Distributional dueling (C51) — the D6 correctness test
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_gpu_distributional_forward_produces_valid_q_values() {
|
|
let device = require_cuda();
|
|
let stream = cuda_stream(&device);
|
|
|
|
let network = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
|
|
let target = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
|
|
|
|
let mut collector = GpuExperienceCollector::new(
|
|
stream.clone(),
|
|
network.vars(),
|
|
target.vars(),
|
|
None,
|
|
100_000.0,
|
|
0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
let total_bars = 2000;
|
|
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
|
|
|
|
let n_episodes = 4_usize;
|
|
let timesteps = 50;
|
|
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 100) as i32).collect();
|
|
|
|
let config = ExperienceCollectorConfig {
|
|
n_episodes: n_episodes as i32,
|
|
timesteps_per_episode: timesteps,
|
|
total_bars: total_bars as i32,
|
|
episode_length: timesteps,
|
|
epsilon: 0.1,
|
|
gamma: 0.99,
|
|
use_noisy_nets: false,
|
|
use_distributional: true,
|
|
num_atoms: 51,
|
|
v_min: -25.0,
|
|
v_max: 25.0,
|
|
..Default::default()
|
|
};
|
|
|
|
let batch = collector
|
|
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config)
|
|
.expect("GPU experience collection (C51) failed");
|
|
|
|
let expected_total = n_episodes * timesteps as usize;
|
|
assert_eq!(batch.actions.len(), expected_total);
|
|
|
|
// C51 Q = sum(z_i * p_i), must be in [v_min, v_max]
|
|
for (i, &q) in batch.target_q_values.iter().enumerate() {
|
|
assert!(q.is_finite(), "C51 target_q[{i}] = {q} is not finite");
|
|
assert!(
|
|
q >= -25.0 && q <= 25.0,
|
|
"C51 target_q[{i}] = {q} outside atom support [-25, 25] — \
|
|
distributional forward pass likely wrong"
|
|
);
|
|
}
|
|
|
|
for (i, &a) in batch.actions.iter().enumerate() {
|
|
assert!((0..5).contains(&a), "C51 action[{i}] = {a} out of range [0, 4]");
|
|
}
|
|
|
|
let q_min = batch.target_q_values.iter().cloned().fold(f32::INFINITY, f32::min);
|
|
let q_max = batch.target_q_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
|
println!("C51 distributional: {expected_total} experiences, Q range [{q_min:.4}, {q_max:.4}]");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 3: Candle vs GPU kernel Q-value parity (standard dueling)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_candle_vs_kernel_q_value_parity() {
|
|
let device = require_cuda();
|
|
let stream = cuda_stream(&device);
|
|
|
|
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
let target_network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
|
|
// Candle forward pass for a known state
|
|
let state_data: Vec<f32> = (0..54).map(|i| (i as f32 * 0.1).sin() * 0.5).collect();
|
|
let state_tensor = candle_core::Tensor::new(&state_data[..], &device)
|
|
.unwrap()
|
|
.unsqueeze(0)
|
|
.unwrap();
|
|
let candle_q = network.forward(&state_tensor).unwrap();
|
|
let candle_q_vec: Vec<f32> = candle_q.squeeze(0).unwrap().to_vec1().unwrap();
|
|
|
|
let mut collector = GpuExperienceCollector::new(
|
|
stream.clone(),
|
|
network.vars(),
|
|
target_network.vars(),
|
|
None,
|
|
100_000.0,
|
|
0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
// Market data with test state as every bar
|
|
let total_bars = 200;
|
|
let market_len = total_bars * 51;
|
|
let mut market_data = vec![0.0_f32; market_len];
|
|
for bar in 0..total_bars {
|
|
for f in 0..51 {
|
|
market_data[bar * 51 + f] = state_data[f.min(53)];
|
|
}
|
|
}
|
|
let mut market_buf = stream.alloc_zeros::<f32>(market_len).unwrap();
|
|
stream.memcpy_htod(&market_data, &mut market_buf).unwrap();
|
|
|
|
let target_len = total_bars * 4;
|
|
let mut target_data = vec![0.0_f32; target_len];
|
|
for i in 0..total_bars {
|
|
target_data[i * 4] = 100.0;
|
|
target_data[i * 4 + 1] = 100.5;
|
|
target_data[i * 4 + 2] = 99.5;
|
|
target_data[i * 4 + 3] = 1000.0;
|
|
}
|
|
let mut target_buf = stream.alloc_zeros::<f32>(target_len).unwrap();
|
|
stream.memcpy_htod(&target_data, &mut target_buf).unwrap();
|
|
|
|
let episode_starts = vec![0_i32];
|
|
let config = ExperienceCollectorConfig {
|
|
n_episodes: 1,
|
|
timesteps_per_episode: 1,
|
|
total_bars: total_bars as i32,
|
|
episode_length: 1,
|
|
epsilon: 0.0,
|
|
gamma: 0.99,
|
|
use_noisy_nets: false,
|
|
use_distributional: false,
|
|
count_bonus_coefficient: 0.0,
|
|
..Default::default()
|
|
};
|
|
|
|
let batch = collector
|
|
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config)
|
|
.unwrap();
|
|
|
|
let candle_argmax = candle_q_vec
|
|
.iter()
|
|
.enumerate()
|
|
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
|
.map(|(idx, _)| idx)
|
|
.unwrap();
|
|
|
|
let kernel_action = batch.actions[0] as usize;
|
|
|
|
println!("Candle Q-values: {candle_q_vec:?} → argmax={candle_argmax}");
|
|
println!("Kernel chose action: {kernel_action}");
|
|
println!(
|
|
"Kernel target_q[0] = {:.4}, td_error[0] = {:.4}",
|
|
batch.target_q_values[0], batch.td_errors[0]
|
|
);
|
|
|
|
// The kernel assembles state as [51 market features, 3 portfolio features]
|
|
// while Candle uses the raw 54-dim test state. Portfolio at t=0 = [100000, 0, 0]
|
|
// differs from the test state's last 3 dims, so argmax may differ for close
|
|
// Q-values. Verify the kernel at least produces finite, bounded Q-values.
|
|
assert!(
|
|
batch.target_q_values[0].is_finite(),
|
|
"Kernel target_q[0] is not finite — forward pass broken"
|
|
);
|
|
assert!(
|
|
batch.target_q_values[0].abs() < 1000.0,
|
|
"Kernel target_q[0] = {} is unreasonably large",
|
|
batch.target_q_values[0]
|
|
);
|
|
// If Q-values are well-separated (>0.5 gap), actions SHOULD match
|
|
let q_gap = candle_q_vec[candle_argmax]
|
|
- candle_q_vec
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|&(i, _)| i != candle_argmax)
|
|
.map(|(_, &v)| v)
|
|
.fold(f32::NEG_INFINITY, f32::max);
|
|
if q_gap > 0.5 {
|
|
assert_eq!(
|
|
kernel_action, candle_argmax,
|
|
"Kernel action {kernel_action} != Candle argmax {candle_argmax} \
|
|
despite Q gap {q_gap:.3} — forward pass likely wrong"
|
|
);
|
|
} else {
|
|
println!(
|
|
"Q-values too close (gap={q_gap:.4}) for strict parity — \
|
|
portfolio feature perturbation can flip argmax. Kernel action={kernel_action}, \
|
|
Candle argmax={candle_argmax}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 4: NoisyNet exploration produces different actions than clean
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_gpu_noisy_net_exploration_differs_from_clean() {
|
|
let device = require_cuda();
|
|
let stream = cuda_stream(&device);
|
|
|
|
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
let target = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
|
|
let total_bars = 2000;
|
|
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
|
|
|
|
let n_episodes = 32_usize;
|
|
let timesteps = 100;
|
|
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 50) as i32).collect();
|
|
|
|
// Run WITHOUT noise
|
|
let mut collector_clean = GpuExperienceCollector::new(
|
|
stream.clone(), network.vars(), target.vars(), None,
|
|
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
let config_clean = ExperienceCollectorConfig {
|
|
n_episodes: n_episodes as i32,
|
|
timesteps_per_episode: timesteps,
|
|
total_bars: total_bars as i32,
|
|
episode_length: timesteps,
|
|
epsilon: 0.0,
|
|
gamma: 0.99,
|
|
use_noisy_nets: false,
|
|
use_distributional: false,
|
|
count_bonus_coefficient: 0.0,
|
|
..Default::default()
|
|
};
|
|
|
|
let batch_clean = collector_clean
|
|
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config_clean)
|
|
.unwrap();
|
|
|
|
// Run WITH noise
|
|
let mut collector_noisy = GpuExperienceCollector::new(
|
|
stream.clone(), network.vars(), target.vars(), None,
|
|
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
let config_noisy = ExperienceCollectorConfig {
|
|
n_episodes: n_episodes as i32,
|
|
timesteps_per_episode: timesteps,
|
|
total_bars: total_bars as i32,
|
|
episode_length: timesteps,
|
|
epsilon: 0.0,
|
|
gamma: 0.99,
|
|
use_noisy_nets: true,
|
|
noisy_sigma_init: 0.5,
|
|
use_distributional: false,
|
|
count_bonus_coefficient: 0.0,
|
|
..Default::default()
|
|
};
|
|
|
|
let batch_noisy = collector_noisy
|
|
.collect_experiences(&market_buf, &target_buf, &episode_starts, &config_noisy)
|
|
.unwrap();
|
|
|
|
let total = batch_clean.actions.len();
|
|
let diff_count = (0..total)
|
|
.filter(|&i| batch_clean.actions[i] != batch_noisy.actions[i])
|
|
.count();
|
|
|
|
let diff_pct = (diff_count as f64 / total as f64) * 100.0;
|
|
println!("NoisyNet: {diff_count}/{total} actions differ ({diff_pct:.1}%)");
|
|
|
|
assert!(
|
|
diff_count > 0,
|
|
"NoisyNet produced ZERO different actions vs clean — noise injection is broken"
|
|
);
|
|
assert!(
|
|
diff_pct > 2.0,
|
|
"NoisyNet only changed {diff_pct:.1}% of actions — noise may be too weak"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 5: Weight sync roundtrip
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_gpu_weight_extraction_and_sync() {
|
|
let device = require_cuda();
|
|
let stream = cuda_stream(&device);
|
|
|
|
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
let target = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
|
|
let weights = extract_dueling_weights(network.vars(), &stream)
|
|
.expect("Weight extraction failed");
|
|
|
|
// Download and verify w_s1 is non-zero and finite
|
|
let mut shared_0_w = vec![0.0_f32; 256 * 54];
|
|
stream.memcpy_dtoh(&weights.w_s1, &mut shared_0_w).unwrap();
|
|
assert!(!shared_0_w.iter().all(|&x| x == 0.0), "w_s1 is all zeros");
|
|
assert!(shared_0_w.iter().all(|x| x.is_finite()), "w_s1 has NaN/Inf");
|
|
|
|
// Verify sync works
|
|
let mut collector = GpuExperienceCollector::new(
|
|
stream.clone(), network.vars(), target.vars(), None,
|
|
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
collector.sync_online_weights(network.vars()).expect("Online sync failed");
|
|
collector.sync_target_weights(target.vars()).expect("Target sync failed");
|
|
|
|
println!("Weight extraction and sync roundtrip: OK");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 6: C51 distributional weight shapes and RMSNorm extraction
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_gpu_distributional_weight_shapes() {
|
|
let device = require_cuda();
|
|
let stream = cuda_stream(&device);
|
|
|
|
let network = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
|
|
|
|
let weights = extract_dueling_weights(network.vars(), &stream)
|
|
.expect("Distributional weight extraction failed");
|
|
|
|
// value_out: [51, 128] = 6528 elements
|
|
let mut value_out_w = vec![0.0_f32; 51 * 128];
|
|
stream.memcpy_dtoh(&weights.w_v2, &mut value_out_w).unwrap();
|
|
assert!(value_out_w.iter().any(|&x| x != 0.0), "w_v2 all zeros");
|
|
|
|
// advantage_out: [255, 128] = 32640 elements
|
|
let mut adv_out_w = vec![0.0_f32; 255 * 128];
|
|
stream.memcpy_dtoh(&weights.w_a2, &mut adv_out_w).unwrap();
|
|
assert!(adv_out_w.iter().any(|&x| x != 0.0), "w_a2 all zeros");
|
|
|
|
// RMSNorm gamma should exist and be ~1.0
|
|
let rmsnorm = ml::cuda_pipeline::gpu_weights::extract_rmsnorm_weights(
|
|
network.vars(), &stream,
|
|
)
|
|
.expect("RMSNorm extraction failed");
|
|
|
|
assert!(rmsnorm.is_some(), "Distributional network should have RMSNorm weights");
|
|
|
|
let rmsnorm = rmsnorm.unwrap();
|
|
let mut gamma_s0 = vec![0.0_f32; 256];
|
|
stream.memcpy_dtoh(&rmsnorm.gamma_s0, &mut gamma_s0).unwrap();
|
|
|
|
let mean_gamma: f32 = gamma_s0.iter().sum::<f32>() / gamma_s0.len() as f32;
|
|
assert!(
|
|
(mean_gamma - 1.0).abs() < 0.01,
|
|
"RMSNorm gamma_s0 mean = {mean_gamma} (expected ~1.0)"
|
|
);
|
|
|
|
println!("Distributional weights: value_out [51,128], adv_out [255,128], RMSNorm gamma mean = {mean_gamma:.4}");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 7: Multiple kernel launches produce finite results
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_gpu_kernel_repeated_launches_stable() {
|
|
let device = require_cuda();
|
|
let stream = cuda_stream(&device);
|
|
|
|
let network = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
let target = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
|
|
let total_bars = 2000;
|
|
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
|
|
|
|
let n_episodes = 4_usize;
|
|
let timesteps = 20;
|
|
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 100) as i32).collect();
|
|
|
|
let cfg = ExperienceCollectorConfig {
|
|
n_episodes: n_episodes as i32,
|
|
timesteps_per_episode: timesteps,
|
|
total_bars: total_bars as i32,
|
|
episode_length: timesteps,
|
|
epsilon: 0.0,
|
|
gamma: 0.99,
|
|
use_noisy_nets: false,
|
|
use_distributional: false,
|
|
count_bonus_coefficient: 0.0,
|
|
..Default::default()
|
|
};
|
|
|
|
let mut collector = GpuExperienceCollector::new(
|
|
stream.clone(), network.vars(), target.vars(), None,
|
|
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
for run in 0..5 {
|
|
let batch = collector
|
|
.collect_experiences(&market_buf, &target_buf, &episode_starts, &cfg)
|
|
.unwrap();
|
|
|
|
for (i, &q) in batch.target_q_values.iter().enumerate() {
|
|
assert!(q.is_finite(), "Run {run} target_q[{i}] = {q} is not finite");
|
|
}
|
|
}
|
|
|
|
println!("5 consecutive kernel launches: all produce finite Q-values");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test 8: Standard vs C51 both produce valid output
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_gpu_distributional_vs_standard_both_valid() {
|
|
let device = require_cuda();
|
|
let stream = cuda_stream(&device);
|
|
|
|
let std_net = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
let std_target = DuelingQNetwork::new(dueling_config(), device.clone()).unwrap();
|
|
let dist_net = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
|
|
let dist_target = DistributionalDuelingQNetwork::new(dist_config(), device.clone()).unwrap();
|
|
|
|
let total_bars = 2000;
|
|
let (market_buf, target_buf) = synthetic_market_data(total_bars, &device);
|
|
let n_episodes = 4_usize;
|
|
let timesteps = 50;
|
|
let episode_starts: Vec<i32> = (0..n_episodes).map(|i| (i * 100) as i32).collect();
|
|
|
|
let mut std_collector = GpuExperienceCollector::new(
|
|
stream.clone(), std_net.vars(), std_target.vars(), None,
|
|
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
let std_batch = std_collector
|
|
.collect_experiences(
|
|
&market_buf, &target_buf, &episode_starts,
|
|
&ExperienceCollectorConfig {
|
|
n_episodes: n_episodes as i32,
|
|
timesteps_per_episode: timesteps,
|
|
total_bars: total_bars as i32,
|
|
episode_length: timesteps,
|
|
use_distributional: false,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let mut dist_collector = GpuExperienceCollector::new(
|
|
stream.clone(), dist_net.vars(), dist_target.vars(), None,
|
|
100_000.0, 0.01, 0.05, TEST_DIMS, TEST_KERNEL_DIMS, 8, 50,
|
|
false,
|
|
).unwrap();
|
|
|
|
let dist_batch = dist_collector
|
|
.collect_experiences(
|
|
&market_buf, &target_buf, &episode_starts,
|
|
&ExperienceCollectorConfig {
|
|
n_episodes: n_episodes as i32,
|
|
timesteps_per_episode: timesteps,
|
|
total_bars: total_bars as i32,
|
|
episode_length: timesteps,
|
|
use_distributional: true,
|
|
num_atoms: 51,
|
|
v_min: -25.0,
|
|
v_max: 25.0,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(std_batch.actions.len(), dist_batch.actions.len());
|
|
|
|
// C51 Q-values bounded by atom support
|
|
let dist_min = dist_batch.target_q_values.iter().cloned().fold(f32::INFINITY, f32::min);
|
|
let dist_max = dist_batch.target_q_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
|
assert!(dist_min >= -25.0 && dist_max <= 25.0,
|
|
"C51 Q-values [{dist_min}, {dist_max}] exceed atom support [-25, 25]");
|
|
|
|
// Standard Q-values finite
|
|
let std_min = std_batch.target_q_values.iter().cloned().fold(f32::INFINITY, f32::min);
|
|
let std_max = std_batch.target_q_values.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
|
assert!(std_min.is_finite() && std_max.is_finite(), "Standard Q-values have NaN/Inf");
|
|
|
|
println!("Standard Q range: [{std_min:.4}, {std_max:.4}]");
|
|
println!("C51 Q range: [{dist_min:.4}, {dist_max:.4}]");
|
|
}
|
|
}
|