diff --git a/crates/xtrain-model/tests/flash.rs b/crates/xtrain-model/tests/flash.rs index b84f728..a5e3c37 100644 --- a/crates/xtrain-model/tests/flash.rs +++ b/crates/xtrain-model/tests/flash.rs @@ -55,7 +55,84 @@ fn host(t: &xtrain_tensor::Tensor) -> Vec { .to_vec() } -fn run(dtype: DType, logit_tol: f32, grad_tol: f32) { +// fp32: same SDPA math, differs only by reduction order → tight per-element check. +fn run_fp32(logit_tol: f32, grad_tol: f32) { + let (off_logits, off_loss, off_grads, on_logits, on_loss, on_grads) = run_both(DType::F32); + + let logit_rel = off_logits + .iter() + .zip(&on_logits) + .map(|(a, b)| (a - b).abs() / a.abs().max(1e-4)) + .fold(0.0f32, f32::max); + let loss_rel = (off_loss - on_loss).abs() / off_loss.abs().max(1e-4); + println!( + "[F32] flash on/off: loss {off_loss:.6}/{on_loss:.6} (rel {loss_rel:.2e}), \ + logits max rel {logit_rel:.2e}" + ); + assert!( + logit_rel < logit_tol, + "[F32] logits diverged: {logit_rel:.2e}" + ); + assert!(loss_rel < logit_tol, "[F32] loss diverged: {loss_rel:.2e}"); + + let mut max_grad_rel = 0.0f32; + for (off_g, on_g) in off_grads.iter().zip(&on_grads) { + for (a, b) in off_g.iter().zip(on_g) { + max_grad_rel = max_grad_rel.max((a - b).abs() / a.abs().max(1e-3)); + } + } + println!("[F32] flash on/off: grad max rel err = {max_grad_rel:.3e}"); + assert!( + max_grad_rel < grad_tol, + "[F32] flash grads diverged from composed: {max_grad_rel:.3e}" + ); +} + +// bf16: ~2-3 decimal digits → robust comparison (mean + p99 with abs().max(1.0) +// for logits, per-tensor scale-relative mean for grads), the same convention as +// the repo's bf16.rs gate (per-element max-rel blows up on near-zero bf16 logits). +fn run_bf16() { + let (off_logits, off_loss, off_grads, on_logits, on_loss, on_grads) = run_both(DType::BF16); + + let loss_rel = (off_loss - on_loss).abs() / off_loss.abs().max(1e-4); + println!("[BF16] flash on/off: loss {off_loss:.5}/{on_loss:.5} (rel {loss_rel:.3e})"); + assert!(loss_rel < 2e-2, "[BF16] loss diverged: {loss_rel:.3e}"); + + let n = off_logits.len(); + let mut rels: Vec = off_logits + .iter() + .zip(&on_logits) + .map(|(f, b)| (b - f).abs() / f.abs().max(1.0)) + .collect(); + rels.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let p99 = rels[(n as f32 * 0.99) as usize]; + let mean: f32 = rels.iter().sum::() / n as f32; + println!("[BF16] flash on/off logits: mean rel {mean:.3e}, p99 rel {p99:.3e}"); + assert!(mean < 1e-2, "[BF16] logits mean rel too high: {mean:.3e}"); + assert!(p99 < 5e-2, "[BF16] logits p99 rel too high: {p99:.3e}"); + + let mut worst = 0.0f32; + for (off_g, on_g) in off_grads.iter().zip(&on_grads) { + let scale = off_g + .iter() + .map(|v| v.abs()) + .fold(0.0f32, f32::max) + .max(1e-6); + let mean_err: f32 = off_g + .iter() + .zip(on_g) + .map(|(f, b)| (f - b).abs()) + .sum::() + / off_g.len() as f32 + / scale; + worst = worst.max(mean_err); + } + println!("[BF16] flash on/off grads: worst per-tensor scaled-mean err = {worst:.3e}"); + assert!(worst < 3e-2, "[BF16] flash grads diverged: {worst:.3e}"); +} + +#[allow(clippy::type_complexity)] +fn run_both(dtype: DType) -> (Vec, f32, Vec>, Vec, f32, Vec>) { assert!(device::device_count().unwrap() > 0, "no CUDA device"); device::set_device(0).unwrap(); let device = Device::Cuda(0); @@ -107,51 +184,26 @@ fn run(dtype: DType, logit_tol: f32, grad_tol: f32) { .map(|p| host(&p.grad().expect("on grad"))) .collect(); - // 1. Forward logits. - let logit_rel = off_logits - .iter() - .zip(&on_logits) - .map(|(a, b)| (a - b).abs() / a.abs().max(1e-4)) - .fold(0.0f32, f32::max); - // 2. Loss. - let loss_rel = (off_loss_val - on_loss_val).abs() / off_loss_val.abs().max(1e-4); - println!( - "[{dtype:?}] flash on/off: loss {off_loss_val:.6}/{on_loss_val:.6} (rel {loss_rel:.2e}), \ - logits max rel {logit_rel:.2e}" - ); - assert!( - logit_rel < logit_tol, - "[{dtype:?}] logits diverged: {logit_rel:.2e}" - ); - assert!( - loss_rel < logit_tol, - "[{dtype:?}] loss diverged: {loss_rel:.2e}" - ); - - // 3. Every parameter grad — the load-bearing gate. - let mut max_grad_rel = 0.0f32; - for (off_g, on_g) in off_grads.iter().zip(&on_grads) { - for (a, b) in off_g.iter().zip(on_g) { - let rel = (a - b).abs() / a.abs().max(1e-3); - max_grad_rel = max_grad_rel.max(rel); - } - } - println!("[{dtype:?}] flash on/off: grad max rel err = {max_grad_rel:.3e}"); - assert!( - max_grad_rel < grad_tol, - "[{dtype:?}] flash grads diverged from composed: {max_grad_rel:.3e}" - ); + ( + off_logits, + off_loss_val, + off_grads, + on_logits, + on_loss_val, + on_grads, + ) } #[test] fn flash_matches_composed_fp32() { // fp32: same SDPA math, differs only by reduction order (in-kernel fp32 FMA vs - // cuBLAS, dK/dV atomicAdd order). Tight but not bit-exact. - run(DType::F32, 1e-3, 2e-2); + // cuBLAS, dK/dV atomicAdd order). Tight per-element check, not bit-exact. + run_fp32(1e-3, 2e-2); } #[test] fn flash_matches_composed_bf16() { - // bf16 (T12 composition): bf16 rounding band on top of the fp32-softmax core. - run(DType::BF16, 2e-2, 5e-2); + // bf16 (T12 composition): bf16 rounding band on the fp32-softmax core; robust + // (mean/p99/scaled-mean) comparison per the repo's bf16 convention. + run_bf16(); }