// T10 batched-forward equivalence: a batched forward over B sequences must equal // the old single-sequence path (run each sequence on its own, concatenate the // logits) — both for the forward logits AND every parameter's gradient. // // This is THE on-GPU correctness gate for batching (no PyTorch needed): if the // per-sequence RoPE position, per-sequence causal masking, or any flattened op // were wrong, the batched logits/grads would drift from the looped reference. // // Forward equivalence: batched logits[b*S+i] == single-seq-b logits[i]. // Gradient equivalence: the batched loss is the mean over all B*S rows, i.e. // (1/B)·Σ_b mean_i(loss_b); summing the B single-sequence losses and scaling by // 1/B gives the SAME scalar, so their summed grads (tape fan-out) ×1/B match the // batched grads. We check that. #![cfg(not(no_cuda))] use xtrain_cuda::device; use xtrain_model::{Config, TinyTransformer, batched_ids_tensor, ids_tensor}; use xtrain_tensor::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 batched_matches_looped_single_sequence() { assert!(device::device_count().unwrap() > 0, "no CUDA device"); device::set_device(0).unwrap(); let device = Device::Cuda(0); let mut cfg = Config::tiny(); cfg.vocab = 16; let batch = 3usize; let seq = 5usize; // B distinct sequences (sequence-major), within vocab. 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(); // --- Batched forward: ONE pass over [B*S]. --- let bmodel = build(cfg, device); let bids = batched_ids_tensor(&seqs, device); let blogits = host(&bmodel.forward_batched(&bids, batch).value()); // --- Looped reference: each sequence on its own, concatenate logits. --- let smodel = build(cfg, device); let mut slogits = Vec::with_capacity(batch * seq * cfg.vocab); for s in &seqs { let ids = ids_tensor(s, device); slogits.extend(host(&smodel.forward(&ids).value())); } // Forward equivalence (fp GEMM rounding only differs in summation order). let max_rel = blogits .iter() .zip(&slogits) .map(|(b, s)| (b - s).abs() / s.abs().max(1e-4)) .fold(0.0f32, f32::max); println!("batched vs looped: logits max rel err = {max_rel:.3e}"); assert!(max_rel < 1e-3, "batched logits diverged: {max_rel:.3e}"); // --- Gradient equivalence. --- // Batched: loss = mean over B*S rows; one backward. let bparams = bmodel.params(); let btgt = batched_ids_tensor(&tgts, device); let bloss = bmodel.loss_batched(&bids, &btgt, batch); let bloss_val = host(&bloss.value())[0]; bloss.backward(); // Looped: Σ_b loss_b (each a per-sequence mean), then grad ×(1/B) == batched. let sparams = smodel.params(); let mut sloss_sum = 0.0f32; for (s, t) in seqs.iter().zip(&tgts) { let ids = ids_tensor(s, device); let tg = ids_tensor(t, device); let l = smodel.loss(&ids, &tg); sloss_sum += host(&l.value())[0]; l.backward(); } println!( "batched loss = {bloss_val:.6} looped mean = {:.6}", sloss_sum / batch as f32 ); assert!( (bloss_val - sloss_sum / batch as f32).abs() < 1e-4, "batched loss != looped mean" ); let mut max_grad_rel = 0.0f32; for (bp, sp) in bparams.iter().zip(&sparams) { let bg = host(&bp.grad().expect("batched grad")); let sg = host(&sp.grad().expect("looped grad")); for (g_b, g_s) in bg.iter().zip(&sg) { // looped grad is the SUM over B sequences; ×(1/B) recovers the mean. let g_s = g_s / batch as f32; let rel = (g_b - g_s).abs() / g_s.abs().max(1e-4); max_grad_rel = max_grad_rel.max(rel); } } println!("batched vs looped: grad max rel err = {max_grad_rel:.3e}"); assert!( max_grad_rel < 5e-3, "batched grads diverged: {max_grad_rel:.3e}" ); }