// T12 bf16 mixed-precision correctness gate (on-GPU, no PyTorch). // // The SAME model (identical fp32 master weights) run in fp32 vs bf16 compute // mode must agree within a LOOSE bf16 tolerance (bf16 = 7-bit mantissa ≈ 2-3 // decimal digits → ~1e-2 relative error is expected and acceptable), both for // the forward loss/logits AND every parameter's gradient. We also assert no // NaN/Inf leaks and that the fp32 grads are fp32 (the cast op upcast the bf16 // weight grad back to the fp32 master, so AdamW/clip/DDP stay fp32). // // This is the "bf16 within looser tol vs fp32 reference" gate; the short-run // convergence comparison is the train_loop-level bench on dash5. #![cfg(not(no_cuda))] use xtrain_cuda::device; use xtrain_model::{Config, TinyTransformer, batched_ids_tensor}; use xtrain_tensor::{DType, Device}; fn fill(n: usize, seed: u64, scale: f32) -> Vec { let mut state = seed .wrapping_mul(2862933555777941757) .wrapping_add(3037000493); (0..n) .map(|_| { state = state .wrapping_mul(6364136223846793005) .wrapping_add(1442695040888963407); (((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5) * 2.0 * scale }) .collect() } fn build(cfg: Config, device: Device) -> TinyTransformer { let mut seed = 1u64; TinyTransformer::new(cfg, device, |shape| { seed = seed.wrapping_add(1); let n: usize = shape.iter().product(); if shape.len() == 1 { fill(n, seed, 0.02).iter().map(|v| v + 1.0).collect() } else { fill(n, seed, 0.08) } }) } fn host(t: &xtrain_tensor::Tensor) -> Vec { t.to_device(Device::Cpu).as_slice::().to_vec() } #[test] fn bf16_matches_fp32_within_loose_tol() { assert!(device::device_count().unwrap() > 0, "no CUDA device"); device::set_device(0).unwrap(); let device = Device::Cuda(0); // A few layers / heads so the bf16 rounding accumulates through the depth // the real model has (not just a single matmul). let mut cfg = Config::tiny(); cfg.vocab = 32; cfg.n_layers = 3; let batch = 2usize; let seq = 8usize; let seqs: Vec> = (0..batch) .map(|b| { (0..seq) .map(|i| ((b * 7 + i * 3 + 1) % cfg.vocab) as i32) .collect() }) .collect(); let tgts: Vec> = (0..batch) .map(|b| { (0..seq) .map(|i| ((b * 5 + i * 2 + 2) % cfg.vocab) as i32) .collect() }) .collect(); let ids = batched_ids_tensor(&seqs, device); let tgt = batched_ids_tensor(&tgts, device); // fp32 reference. let fp32 = build(cfg, device); let f_logits = host(&fp32.forward_batched(&ids, batch).value()); let f_loss = fp32.loss_batched(&ids, &tgt, batch); let f_loss_val = host(&f_loss.value())[0]; f_loss.backward(); let f_params = fp32.params(); // bf16 — SAME init (build re-runs the same deterministic fill). The forward // now returns bf16 logits (CE upcasts internally); cast to f32 to read. let bf16 = build(cfg, device).with_compute_dtype(DType::BF16); let b_logits = host( &bf16 .forward_batched(&ids, batch) .value() .to_dtype(DType::F32), ); let b_loss = bf16.loss_batched(&ids, &tgt, batch); let b_loss_val = host(&b_loss.value())[0]; b_loss.backward(); let b_params = bf16.params(); // No NaN/Inf in the bf16 forward. assert!( b_logits.iter().all(|v| v.is_finite()) && b_loss_val.is_finite(), "bf16 forward produced non-finite values" ); // Forward loss within loose bf16 tol. let loss_rel = (b_loss_val - f_loss_val).abs() / f_loss_val.abs().max(1e-4); println!("bf16 vs fp32: loss {b_loss_val:.5} vs {f_loss_val:.5} (rel {loss_rel:.3e})"); assert!( loss_rel < 2e-2, "bf16 loss too far from fp32: {loss_rel:.3e}" ); // Logits: bf16 has ~2-3 decimal digits → compare on a robust (median-style) // basis, requiring the bulk to be within ~3e-2 and the mean error small. let n = f_logits.len(); let mut rels: Vec = f_logits .iter() .zip(&b_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 vs fp32 logits: mean rel {mean:.3e}, p99 rel {p99:.3e}"); assert!(mean < 1e-2, "bf16 logits mean rel err too high: {mean:.3e}"); assert!(p99 < 5e-2, "bf16 logits p99 rel err too high: {p99:.3e}"); // Gradients: fp32 master grads must be fp32 (cast op upcast), finite, and // within loose bf16 tol of the fp32 reference (mean over each param tensor). let mut worst_param_mean = 0.0f32; for (fp, bp) in f_params.iter().zip(&b_params) { let bg = bp.grad().expect("bf16 grad"); assert_eq!(bg.dtype(), DType::F32, "bf16-mode grad must be fp32 master"); let fg = host(&fp.grad().expect("fp32 grad")); let bg = host(&bg); assert!(bg.iter().all(|v| v.is_finite()), "bf16 grad has non-finite"); // Scale-relative mean error over the tensor (robust to a few small entries). let scale = fg.iter().map(|v| v.abs()).fold(0.0f32, f32::max).max(1e-6); let mean_err: f32 = fg.iter().zip(&bg).map(|(f, b)| (f - b).abs()).sum::() / fg.len() as f32 / scale; worst_param_mean = worst_param_mean.max(mean_err); } println!("bf16 vs fp32 grads: worst per-tensor scaled-mean err = {worst_param_mean:.3e}"); assert!( worst_param_mean < 3e-2, "bf16 grads too far from fp32: {worst_param_mean:.3e}" ); }