New 64-hidden single-bucket CfC trunk to serve as the parallel parameter group for D-style aux supervision (Layer B of anti-cal plan). Separate-trunk pattern per pearl_separate_aux_trunk_when_shared_starves prevents BCE direction signal from starving the aux gradient flow. Architecture: - AUX_HIDDEN = 64 (vs main trunk 128) — keeps params/compute reasonable - Single bucket — no per-horizon channel splitting; aux supervision is per-K at the head (B4), not at the trunk state - Independent parameters: own w_in, w_rec, b, tau weights - Same CfC step math as cfc_step_per_branch, parameterized for single-block Files: - cuda/aux_trunk.cu: fused fwd+bwd, cooperative shmem staging of x+h_old, in-block tree-reduce for grad_x (no atomicAdd) - src/cfc/aux_trunk.rs: AuxTrunk struct + fwd/bwd wrappers + download_weights helper; xavier_uniform init for w_in/w_rec, zeros for b, log-uniform tau in [2s, 200s] (narrower than main trunk to focus on aux-supervised K=10-1000 range) - src/cfc/mod.rs: pub mod aux_trunk + re-exports - build.rs: KERNELS += "aux_trunk" - tests/aux_trunk.rs: 3 #[ignore]'d GPU oracle tests (fwd_matches_naive_reference, bwd_finite_diff_matches_bias_sample, fwd_smoke_large_batch_no_nan_no_oom) — 3/3 pass on RTX 3050 sm_86 Design decisions documented in subagent report: - Runtime feat_dim parameter (not compile-time #define) for single-cubin reuse across raw-snap (40) and post-encoder (128) inputs - grad_x reduced INSIDE bwd kernel via shmem tree-reduce (caller doesn't need separate reduction pass) — matches no-atomicAdd discipline - grad_h_old carries only direct dh*decay; cross-channel BPTT term left for caller (B5) when K>1 unroll is wired Not yet wired into trainer (B5) or supervised by aux head (B4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
327 lines
11 KiB
Rust
327 lines
11 KiB
Rust
//! GPU oracle tests for `aux_trunk.cu` (single-bucket CfC at AUX_HIDDEN=64).
|
|
//!
|
|
//! Run with:
|
|
//! SQLX_OFFLINE=true cargo test -p ml-alpha --test aux_trunk \
|
|
//! -- --ignored --nocapture
|
|
//!
|
|
//! These tests are `#[ignore]` because they require a CUDA device.
|
|
//! They drive the cubin directly through the host wrappers in
|
|
//! `crates/ml-alpha/src/cfc/aux_trunk.rs` and compare against a
|
|
//! deterministic CPU naive reference (per
|
|
//! `feedback_no_cpu_test_fallbacks.md` the production path stays GPU-
|
|
//! only; the CPU implementation here exists ONLY as a unit-test oracle).
|
|
|
|
use anyhow::Result;
|
|
use ml_alpha::cfc::aux_trunk::{
|
|
aux_trunk_bwd_gpu, aux_trunk_fwd_gpu, AuxTrunk, AuxTrunkConfig, AUX_HIDDEN,
|
|
};
|
|
use ml_core::device::MlDevice;
|
|
|
|
// Pick a representative feat_dim. Aux trunk's production caller (B5)
|
|
// will pass HIDDEN_DIM=128 (encoder output). Using a non-128 dim here
|
|
// also exercises the runtime-`feat_dim` parameterisation.
|
|
const TEST_FEAT_DIM: usize = 96;
|
|
|
|
fn upload(
|
|
stream: &std::sync::Arc<cudarc::driver::CudaStream>,
|
|
host: &[f32],
|
|
) -> Result<cudarc::driver::CudaSlice<f32>> {
|
|
let mut d = stream.alloc_zeros::<f32>(host.len())?;
|
|
stream.memcpy_htod(host, &mut d)?;
|
|
Ok(d)
|
|
}
|
|
|
|
fn download(
|
|
stream: &std::sync::Arc<cudarc::driver::CudaStream>,
|
|
d: &cudarc::driver::CudaSlice<f32>,
|
|
) -> Result<Vec<f32>> {
|
|
let mut h = vec![0.0_f32; d.len()];
|
|
stream.memcpy_dtoh(d, &mut h)?;
|
|
Ok(h)
|
|
}
|
|
|
|
/// Forward shape correctness + value match against a naive CPU oracle.
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fwd_matches_naive_reference() -> Result<()> {
|
|
let dev = MlDevice::cuda(0)?;
|
|
let stream = dev.cuda_stream()?.clone();
|
|
let trunk = AuxTrunk::new(
|
|
&dev,
|
|
AuxTrunkConfig {
|
|
feat_dim: TEST_FEAT_DIM,
|
|
seed: 0xAEB1_BEEF_u64,
|
|
},
|
|
)?;
|
|
|
|
let b_sz: usize = 3;
|
|
let x: Vec<f32> = (0..b_sz * TEST_FEAT_DIM)
|
|
.map(|i| ((i as f32) * 0.0011).sin() * 0.5)
|
|
.collect();
|
|
let h_old: Vec<f32> = (0..b_sz * AUX_HIDDEN)
|
|
.map(|i| ((i as f32) * 0.0037).cos() * 0.3)
|
|
.collect();
|
|
|
|
let x_d = upload(&stream, &x)?;
|
|
let h_old_d = upload(&stream, &h_old)?;
|
|
let mut h_new_d = stream.alloc_zeros::<f32>(b_sz * AUX_HIDDEN)?;
|
|
|
|
let dt: f32 = 1.0;
|
|
aux_trunk_fwd_gpu(
|
|
&stream,
|
|
&trunk.fwd_fn,
|
|
TEST_FEAT_DIM,
|
|
&trunk.w_in_d,
|
|
&trunk.w_rec_d,
|
|
&trunk.b_d,
|
|
&trunk.tau_d,
|
|
&x_d,
|
|
&h_old_d,
|
|
dt,
|
|
b_sz as i32,
|
|
&mut h_new_d,
|
|
)?;
|
|
stream.synchronize()?;
|
|
let h_new = download(&stream, &h_new_d)?;
|
|
|
|
// Pull weights down for the CPU oracle.
|
|
let weights = trunk.download_weights()?;
|
|
let w_in = &weights.w_in;
|
|
let w_rec = &weights.w_rec;
|
|
let b_vec = &weights.b;
|
|
let tau = &weights.tau;
|
|
|
|
for batch in 0..b_sz {
|
|
for c in 0..AUX_HIDDEN {
|
|
let mut pre = b_vec[c];
|
|
for k in 0..TEST_FEAT_DIM {
|
|
pre += w_in[c * TEST_FEAT_DIM + k] * x[batch * TEST_FEAT_DIM + k];
|
|
}
|
|
for k in 0..AUX_HIDDEN {
|
|
pre += w_rec[c * AUX_HIDDEN + k] * h_old[batch * AUX_HIDDEN + k];
|
|
}
|
|
let decay = (-dt / tau[c].max(1e-6)).exp();
|
|
let expected =
|
|
h_old[batch * AUX_HIDDEN + c] * decay + (1.0 - decay) * pre.tanh();
|
|
let got = h_new[batch * AUX_HIDDEN + c];
|
|
assert!(
|
|
(got - expected).abs() < 5e-4,
|
|
"h_new[{},{}] = {got} vs expected {expected} (diff {})",
|
|
batch,
|
|
c,
|
|
(got - expected).abs(),
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Backward grad shapes finite, non-zero, and bias-grad finite-difference
|
|
/// matches within tolerance. Full per-weight FD validation is expensive
|
|
/// (O(AUX_HIDDEN²·feat_dim) reference evaluations); we sample a handful
|
|
/// of bias entries which is sufficient to catch wiring breakage.
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn bwd_finite_diff_matches_bias_sample() -> Result<()> {
|
|
let dev = MlDevice::cuda(0)?;
|
|
let stream = dev.cuda_stream()?.clone();
|
|
let trunk = AuxTrunk::new(
|
|
&dev,
|
|
AuxTrunkConfig {
|
|
feat_dim: TEST_FEAT_DIM,
|
|
seed: 0x12_34_56_78,
|
|
},
|
|
)?;
|
|
|
|
let b_sz: usize = 2;
|
|
let x: Vec<f32> = (0..b_sz * TEST_FEAT_DIM)
|
|
.map(|i| ((i as f32) * 0.0021).sin() * 0.4)
|
|
.collect();
|
|
let h_old: Vec<f32> = (0..b_sz * AUX_HIDDEN)
|
|
.map(|i| ((i as f32) * 0.0049).cos() * 0.25)
|
|
.collect();
|
|
// Pick a structured grad_h_new (1.0 at one slot per batch, 0 else)
|
|
// so that grad_b finite-diff has a clean closed-form expectation.
|
|
let grad_h_new: Vec<f32> = (0..b_sz * AUX_HIDDEN)
|
|
.map(|i| ((i % 7) as f32) * 0.03)
|
|
.collect();
|
|
|
|
let weights = trunk.download_weights()?;
|
|
let w_in = weights.w_in.clone();
|
|
let w_rec = weights.w_rec.clone();
|
|
let b_vec = weights.b.clone();
|
|
let tau = weights.tau.clone();
|
|
|
|
let dt: f32 = 1.0;
|
|
let x_d = upload(&stream, &x)?;
|
|
let h_old_d = upload(&stream, &h_old)?;
|
|
let grad_h_new_d = upload(&stream, &grad_h_new)?;
|
|
|
|
let mut grad_w_in_d = stream.alloc_zeros::<f32>(b_sz * AUX_HIDDEN * TEST_FEAT_DIM)?;
|
|
let mut grad_w_rec_d = stream.alloc_zeros::<f32>(b_sz * AUX_HIDDEN * AUX_HIDDEN)?;
|
|
let mut grad_b_d = stream.alloc_zeros::<f32>(b_sz * AUX_HIDDEN)?;
|
|
let mut grad_tau_d = stream.alloc_zeros::<f32>(b_sz * AUX_HIDDEN)?;
|
|
let mut grad_h_old_d = stream.alloc_zeros::<f32>(b_sz * AUX_HIDDEN)?;
|
|
let mut grad_x_d = stream.alloc_zeros::<f32>(b_sz * TEST_FEAT_DIM)?;
|
|
|
|
aux_trunk_bwd_gpu(
|
|
&stream,
|
|
&trunk.bwd_fn,
|
|
TEST_FEAT_DIM,
|
|
&trunk.w_in_d,
|
|
&trunk.w_rec_d,
|
|
&trunk.b_d,
|
|
&trunk.tau_d,
|
|
&x_d,
|
|
&h_old_d,
|
|
&grad_h_new_d,
|
|
dt,
|
|
b_sz as i32,
|
|
&mut grad_w_in_d,
|
|
&mut grad_w_rec_d,
|
|
&mut grad_b_d,
|
|
&mut grad_tau_d,
|
|
&mut grad_h_old_d,
|
|
&mut grad_x_d,
|
|
)?;
|
|
stream.synchronize()?;
|
|
|
|
// All grads finite, no NaN/Inf.
|
|
let g_w_in = download(&stream, &grad_w_in_d)?;
|
|
let g_w_rec = download(&stream, &grad_w_rec_d)?;
|
|
let g_b = download(&stream, &grad_b_d)?;
|
|
let g_tau = download(&stream, &grad_tau_d)?;
|
|
let g_h_old = download(&stream, &grad_h_old_d)?;
|
|
let g_x = download(&stream, &grad_x_d)?;
|
|
for (name, v) in [
|
|
("grad_w_in", &g_w_in),
|
|
("grad_w_rec", &g_w_rec),
|
|
("grad_b", &g_b),
|
|
("grad_tau", &g_tau),
|
|
("grad_h_old", &g_h_old),
|
|
("grad_x", &g_x),
|
|
] {
|
|
assert!(
|
|
v.iter().all(|x| x.is_finite()),
|
|
"{name} contains NaN/Inf",
|
|
);
|
|
}
|
|
// Kernel must have written something non-zero for at least the bias
|
|
// (the only grad guaranteed to be non-trivial given non-zero
|
|
// grad_h_new entries).
|
|
assert!(
|
|
g_b.iter().any(|x| x.abs() > 0.0),
|
|
"grad_b is all zero — kernel didn't write?",
|
|
);
|
|
|
|
// Finite-difference oracle for grad_b on the first batch, channel 0.
|
|
// Hand-coded forward (CPU) to derive d/d b[c] of the implicit loss
|
|
// L = Σ_{batch,c} grad_h_new[batch, c] * h_new[batch, c].
|
|
let h_new_fwd = |b_local: &[f32]| -> Vec<f32> {
|
|
let mut out = vec![0.0_f32; b_sz * AUX_HIDDEN];
|
|
for batch in 0..b_sz {
|
|
for c in 0..AUX_HIDDEN {
|
|
let mut pre = b_local[c];
|
|
for k in 0..TEST_FEAT_DIM {
|
|
pre += w_in[c * TEST_FEAT_DIM + k] * x[batch * TEST_FEAT_DIM + k];
|
|
}
|
|
for k in 0..AUX_HIDDEN {
|
|
pre += w_rec[c * AUX_HIDDEN + k] * h_old[batch * AUX_HIDDEN + k];
|
|
}
|
|
let decay = (-dt / tau[c].max(1e-6)).exp();
|
|
out[batch * AUX_HIDDEN + c] =
|
|
h_old[batch * AUX_HIDDEN + c] * decay + (1.0 - decay) * pre.tanh();
|
|
}
|
|
}
|
|
out
|
|
};
|
|
let loss_for = |b_local: &[f32]| -> f32 {
|
|
let h_new = h_new_fwd(b_local);
|
|
let mut acc = 0.0_f32;
|
|
for i in 0..b_sz * AUX_HIDDEN {
|
|
acc += grad_h_new[i] * h_new[i];
|
|
}
|
|
acc
|
|
};
|
|
|
|
let eps = 1e-3_f32;
|
|
// Sample 4 channels at modest stride; full sweep is overkill for a
|
|
// smoke. The kernel writes per-batch grad slices — sum across batch
|
|
// to match the loss gradient w.r.t. the shared bias param.
|
|
for &c in &[0_usize, 17, 33, 50] {
|
|
let mut b_plus = b_vec.clone();
|
|
b_plus[c] += eps;
|
|
let mut b_minus = b_vec.clone();
|
|
b_minus[c] -= eps;
|
|
let fd = (loss_for(&b_plus) - loss_for(&b_minus)) / (2.0 * eps);
|
|
|
|
// Sum kernel's per-batch grad_b across batch to match dL/db[c].
|
|
let mut analytical = 0.0_f32;
|
|
for batch in 0..b_sz {
|
|
analytical += g_b[batch * AUX_HIDDEN + c];
|
|
}
|
|
let abs_err = (fd - analytical).abs();
|
|
let rel_tol = 5e-3 * fd.abs().max(1e-4);
|
|
assert!(
|
|
abs_err < rel_tol.max(2e-3),
|
|
"grad_b[{c}] mismatch: fd={fd}, analytical={analytical}, abs_err={abs_err}",
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Smoke that AUX_HIDDEN=64 path runs without OOM or NaN at a batch
|
|
/// size representative of the trainer (which uses B=32-128 per batch).
|
|
/// Designed to be cheap enough for RTX 3050 sm_86 local validation.
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fwd_smoke_large_batch_no_nan_no_oom() -> Result<()> {
|
|
let dev = MlDevice::cuda(0)?;
|
|
let stream = dev.cuda_stream()?.clone();
|
|
let trunk = AuxTrunk::new(
|
|
&dev,
|
|
AuxTrunkConfig {
|
|
feat_dim: 128, // matches HIDDEN_DIM — actual B5 wiring config
|
|
seed: 0xFEED_FACE_u64,
|
|
},
|
|
)?;
|
|
|
|
let b_sz: usize = 64;
|
|
let feat_dim = 128usize;
|
|
let x: Vec<f32> = (0..b_sz * feat_dim)
|
|
.map(|i| ((i as f32) * 0.0007).sin())
|
|
.collect();
|
|
let h_old: Vec<f32> = vec![0.0; b_sz * AUX_HIDDEN]; // cold start
|
|
let x_d = upload(&stream, &x)?;
|
|
let h_old_d = upload(&stream, &h_old)?;
|
|
let mut h_new_d = stream.alloc_zeros::<f32>(b_sz * AUX_HIDDEN)?;
|
|
|
|
aux_trunk_fwd_gpu(
|
|
&stream,
|
|
&trunk.fwd_fn,
|
|
feat_dim,
|
|
&trunk.w_in_d,
|
|
&trunk.w_rec_d,
|
|
&trunk.b_d,
|
|
&trunk.tau_d,
|
|
&x_d,
|
|
&h_old_d,
|
|
1.0,
|
|
b_sz as i32,
|
|
&mut h_new_d,
|
|
)?;
|
|
stream.synchronize()?;
|
|
let h_new = download(&stream, &h_new_d)?;
|
|
assert!(
|
|
h_new.iter().all(|v| v.is_finite()),
|
|
"h_new contains NaN/Inf after cold-start forward",
|
|
);
|
|
// Output is bounded by tanh ∈ (-1, 1); with h_old = 0 and small init,
|
|
// |h_new| should sit well inside that range.
|
|
assert!(
|
|
h_new.iter().all(|v| v.abs() < 1.5),
|
|
"h_new produced out-of-range values (|v| ≥ 1.5)",
|
|
);
|
|
Ok(())
|
|
}
|