Files
foxhunt/crates/ml-alpha/tests/backward_finite_diff.rs
jgrusewski 753284f2ae feat(ml-alpha): backward kernels — heads + cfc_step (K=1 BPTT)
multi_horizon_heads_backward: sigmoid + linear chain rule. One block,
HIDDEN_DIM=128 threads. Computes grad_w, grad_b, grad_h_in. No
atomicAdd; per-thread accumulation only.

cfc_step_backward: truncated K=1 BPTT through one CfC time step.
Forward pre/decay/tanh recomputed inside the kernel; emits grad_w_in,
grad_w_rec, grad_b, grad_h_old. tau is held frozen (structural
log-uniform init per Hasani 2022; backprop through tau deferred to
Phase A v2 if the gate needs it). Uses dynamic shared memory for the
d_pre relay between threads (size = 2 * n_hid * 4 bytes).

Tests (4/4 on sm_86) validate via on-GPU finite-difference:
  - heads grad_h vs forward(h±eps) → matches at eps=1e-3, rel<=1%
  - heads grad_b vs forward(b±eps) → matches at eps=1e-3, rel<=1%
  - cfc grad_b vs forward(b±eps) → matches at eps=1e-3, rel<=5%
  - cfc grad_h_old vs forward(h_old±eps) → matches at eps=1e-3, rel<=5%

CPU is not the reference (per feedback_no_cpu_test_fallbacks.md). The
kernel is the truth; numerical perturbation validates the analytic
gradient against the kernel's own forward.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:20:54 +02:00

157 lines
5.8 KiB
Rust

//! Finite-difference validation of cfc_step_backward + multi_horizon_heads_backward.
//!
//! Per `feedback_no_cpu_test_fallbacks.md`: the kernel is the reference.
//! We perturb a single input, re-run the GPU forward, and compare the
//! resulting finite-difference gradient to the analytic backward.
use approx::assert_relative_eq;
use ml_alpha::cfc::step::{cfc_step_backward_gpu, cfc_step_gpu, CfcWeights};
use ml_alpha::heads::{
multi_horizon_heads_backward_gpu, multi_horizon_heads_gpu, HeadsWeights, HIDDEN_DIM, N_HORIZONS,
};
use ml_core::device::MlDevice;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
fn test_device() -> MlDevice {
MlDevice::cuda(0).expect("CUDA 0 required for ml-alpha tests")
}
fn rand_heads(seed: u64) -> HeadsWeights {
let mut r = ChaCha8Rng::seed_from_u64(seed);
HeadsWeights {
w: (0..N_HORIZONS * HIDDEN_DIM).map(|_| r.gen_range(-0.1..0.1)).collect(),
b: (0..N_HORIZONS).map(|_| r.gen_range(-0.01..0.01)).collect(),
}
}
fn rand_cfc(seed: u64, n_in: usize, n_hid: usize) -> CfcWeights {
let mut r = ChaCha8Rng::seed_from_u64(seed);
CfcWeights {
w_in: (0..n_hid * n_in).map(|_| r.gen_range(-0.1..0.1)).collect(),
w_rec: (0..n_hid * n_hid).map(|_| r.gen_range(-0.05..0.05)).collect(),
b: (0..n_hid).map(|_| r.gen_range(-0.01..0.01)).collect(),
tau: (0..n_hid).map(|_| r.gen_range(0.05..2.0)).collect(),
n_in,
n_hid,
}
}
#[test]
fn heads_backward_grad_h_matches_finite_diff() {
let dev = test_device();
let w = rand_heads(0x1111);
let mut r = ChaCha8Rng::seed_from_u64(0x2222);
let h: Vec<f32> = (0..HIDDEN_DIM).map(|_| r.gen_range(-1.0..1.0)).collect();
let probs = multi_horizon_heads_gpu(&dev, &w, &h).unwrap();
// Surrogate loss L = sum_k probs[k] -> dL/dp = 1 for all k.
let grad_probs = [1.0f32; N_HORIZONS];
let (_, _, grad_h_analytic) =
multi_horizon_heads_backward_gpu(&dev, &w, &probs, &h, &grad_probs).unwrap();
let eps = 1e-3_f32;
for &i in &[0usize, 32, 64, 96, 127] {
let mut h_up = h.clone();
h_up[i] += eps;
let mut h_dn = h.clone();
h_dn[i] -= eps;
let p_up = multi_horizon_heads_gpu(&dev, &w, &h_up).unwrap();
let p_dn = multi_horizon_heads_gpu(&dev, &w, &h_dn).unwrap();
let l_up: f32 = p_up.iter().sum();
let l_dn: f32 = p_dn.iter().sum();
let fd = (l_up - l_dn) / (2.0 * eps);
assert_relative_eq!(grad_h_analytic[i], fd, epsilon = 1e-3, max_relative = 1e-2);
}
}
#[test]
fn heads_backward_grad_b_matches_finite_diff() {
let dev = test_device();
let w = rand_heads(0x3333);
let mut r = ChaCha8Rng::seed_from_u64(0x4444);
let h: Vec<f32> = (0..HIDDEN_DIM).map(|_| r.gen_range(-1.0..1.0)).collect();
let probs = multi_horizon_heads_gpu(&dev, &w, &h).unwrap();
let grad_probs = [1.0f32; N_HORIZONS];
let (_, grad_b_analytic, _) =
multi_horizon_heads_backward_gpu(&dev, &w, &probs, &h, &grad_probs).unwrap();
let eps = 1e-3_f32;
for k in 0..N_HORIZONS {
let mut w_up = w.clone();
w_up.b[k] += eps;
let mut w_dn = w.clone();
w_dn.b[k] -= eps;
let p_up = multi_horizon_heads_gpu(&dev, &w_up, &h).unwrap();
let p_dn = multi_horizon_heads_gpu(&dev, &w_dn, &h).unwrap();
let l_up: f32 = p_up.iter().sum();
let l_dn: f32 = p_dn.iter().sum();
let fd = (l_up - l_dn) / (2.0 * eps);
assert_relative_eq!(grad_b_analytic[k], fd, epsilon = 1e-3, max_relative = 1e-2);
let _ = w; // suppress
let _ = w_up;
let _ = w_dn;
}
let _ = w;
}
#[test]
fn cfc_backward_grad_b_matches_finite_diff() {
let dev = test_device();
let n_in = 8;
let n_hid = 16;
let w = rand_cfc(0x5555, n_in, n_hid);
let mut r = ChaCha8Rng::seed_from_u64(0x6666);
let x: Vec<f32> = (0..n_in).map(|_| r.gen_range(-1.0..1.0)).collect();
let h_old: Vec<f32> = (0..n_hid).map(|_| r.gen_range(-1.0..1.0)).collect();
let dt_s = 0.02_f32;
// Surrogate loss L = sum_i h_new[i].
let grad_h_new = vec![1.0f32; n_hid];
let (_, _, grad_b_analytic, _) =
cfc_step_backward_gpu(&dev, &w, &x, &h_old, &grad_h_new, dt_s).unwrap();
let eps = 1e-3_f32;
for &i in &[0usize, 4, 8, 15] {
let mut w_up = w.clone();
w_up.b[i] += eps;
let mut w_dn = w.clone();
w_dn.b[i] -= eps;
let h_up = cfc_step_gpu(&dev, &w_up, &x, &h_old, dt_s).unwrap();
let h_dn = cfc_step_gpu(&dev, &w_dn, &x, &h_old, dt_s).unwrap();
let l_up: f32 = h_up.iter().sum();
let l_dn: f32 = h_dn.iter().sum();
let fd = (l_up - l_dn) / (2.0 * eps);
assert_relative_eq!(grad_b_analytic[i], fd, epsilon = 5e-3, max_relative = 5e-2);
}
}
#[test]
fn cfc_backward_grad_h_old_matches_finite_diff() {
let dev = test_device();
let n_in = 8;
let n_hid = 16;
let w = rand_cfc(0x7777, n_in, n_hid);
let mut r = ChaCha8Rng::seed_from_u64(0x8888);
let x: Vec<f32> = (0..n_in).map(|_| r.gen_range(-1.0..1.0)).collect();
let h_old: Vec<f32> = (0..n_hid).map(|_| r.gen_range(-1.0..1.0)).collect();
let dt_s = 0.02_f32;
let grad_h_new = vec![1.0f32; n_hid];
let (_, _, _, grad_h_old_analytic) =
cfc_step_backward_gpu(&dev, &w, &x, &h_old, &grad_h_new, dt_s).unwrap();
let eps = 1e-3_f32;
for &i in &[0usize, 4, 8, 15] {
let mut h_up = h_old.clone();
h_up[i] += eps;
let mut h_dn = h_old.clone();
h_dn[i] -= eps;
let h_new_up = cfc_step_gpu(&dev, &w, &x, &h_up, dt_s).unwrap();
let h_new_dn = cfc_step_gpu(&dev, &w, &x, &h_dn, dt_s).unwrap();
let l_up: f32 = h_new_up.iter().sum();
let l_dn: f32 = h_new_dn.iter().sum();
let fd = (l_up - l_dn) / (2.0 * eps);
assert_relative_eq!(grad_h_old_analytic[i], fd, epsilon = 5e-3, max_relative = 5e-2);
}
}