test: M2d — ragged-forward + batched-op equivalence gates + throughput bench
Two exact correctness gates (composed = the end-to-end batched GRPO step == looped): - xtrain-model forward_batched_ragged_matches_looped: forward_batched on RIGHT-padded ragged sequences == per-sequence single-seq forward on the real rows. fp32 max|Δlogit| = 3.7e-7, bf16 = 0.0, both composed + flash SDPA. Pins "right-pad is free under causal". - xtrain-autodiff clipped_pg_loss_batched_matches_looped: batched op == looped Σ_s (1/N)·clipped_pg_loss_s. loss Δ=1.5e-8, grad max|Δ|=7.5e-9 (f32). bench_grpo_batch: weight-independent micro-bench of the per-sample training forwards (loads v12 base as policy, N realistic ragged samples, teacher-forced argmax targets so the closeness smoke isn't −log-amplified by random low-prob tokens). Measured on dash5 (v12 1.05B, N=48, micro=16): capture 622→71 ms (8.7×), inner 1907→208 ms (9.2×), training forwards 2526→280 ms (9.0×). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
97
crates/xtrain-model/tests/ragged_batch.rs
Normal file
97
crates/xtrain-model/tests/ragged_batch.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
// M2d gate: does forward_batched on RIGHT-PADDED ragged sequences reproduce the
|
||||
// per-sequence single-seq forward on the real (non-pad) rows? The batched GRPO
|
||||
// training-side forwards depend on this "right-pad is free under causal attention"
|
||||
// property — a real completion row is at an earlier position than the trailing pad,
|
||||
// and causal masking forbids attending forward, so its logits should be unchanged.
|
||||
//
|
||||
// Tested in fp32 (exact) over both SDPA cores (composed + fused flash), since the
|
||||
// bench uses flash and a kernel could in principle leak the pad keys into the online
|
||||
// softmax.
|
||||
#![cfg(not(no_cuda))]
|
||||
|
||||
use xtrain_cuda::device;
|
||||
use xtrain_model::{Config, TinyTransformer, ids_tensor};
|
||||
use xtrain_tensor::{DType, Device, Tensor};
|
||||
|
||||
fn fill(n: usize, seed: u64, scale: f32) -> Vec<f32> {
|
||||
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, dtype: DType, flash: bool) -> TinyTransformer {
|
||||
let mut seed = 1u64;
|
||||
let m = 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)
|
||||
}
|
||||
});
|
||||
m.with_compute_dtype(dtype).with_flash(flash)
|
||||
}
|
||||
|
||||
fn host(t: &Tensor) -> Vec<f32> {
|
||||
t.to_dtype(DType::F32).to_device(Device::Cpu).as_slice::<f32>().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_batched_ragged_matches_looped() {
|
||||
if device::device_count().unwrap_or(0) == 0 {
|
||||
eprintln!("no CUDA device; skipping");
|
||||
return;
|
||||
}
|
||||
device::set_device(0).unwrap();
|
||||
let device = Device::Cuda(0);
|
||||
|
||||
let mut cfg = Config::tiny();
|
||||
cfg.vocab = 32;
|
||||
cfg.n_layers = 2;
|
||||
let vocab = cfg.vocab;
|
||||
|
||||
// Ragged lengths incl. one crossing the flash tile (>32) and short ones.
|
||||
let lens = [6usize, 40, 9, 4];
|
||||
let lmax = *lens.iter().max().unwrap();
|
||||
let n = lens.len();
|
||||
let seqs: Vec<Vec<i32>> = lens
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(b, &l)| (0..l).map(|i| ((b * 7 + i * 3 + 1) % vocab) as i32).collect())
|
||||
.collect();
|
||||
|
||||
for (dtype, tol) in [(DType::F32, 2e-3f32), (DType::BF16, 3e-1f32)] {
|
||||
for flash in [false, true] {
|
||||
let m = build(cfg, device, dtype, flash);
|
||||
// Looped: each sequence on its own (the ground truth).
|
||||
let looped: Vec<Vec<f32>> = seqs.iter().map(|s| host(&m.forward(&ids_tensor(s, device)).value())).collect();
|
||||
|
||||
// Batched: right-pad each to lmax (pad id 0), one forward_batched(batch = n).
|
||||
let mut flat = vec![0i32; n * lmax];
|
||||
for (i, s) in seqs.iter().enumerate() {
|
||||
flat[i * lmax..i * lmax + s.len()].copy_from_slice(s);
|
||||
}
|
||||
let ids = Tensor::from_slice(&flat, &[n * lmax]).to_device(device);
|
||||
let batched = host(&m.forward_batched(&ids, n).value()); // [n*lmax, vocab]
|
||||
|
||||
let mut dmax = 0f32;
|
||||
for (i, s) in seqs.iter().enumerate() {
|
||||
for r in 0..s.len() {
|
||||
for c in 0..vocab {
|
||||
let a = looped[i][r * vocab + c];
|
||||
let b = batched[(i * lmax + r) * vocab + c];
|
||||
dmax = dmax.max((a - b).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("dtype={dtype:?} flash={flash}: ragged right-pad vs looped, max|Δlogit| (real rows) = {dmax:.3e}");
|
||||
assert!(dmax < tol, "dtype={dtype:?} flash={flash}: right-pad NOT free under causal — max|Δ| = {dmax}");
|
||||
}
|
||||
}
|
||||
println!("forward_batched_ragged_matches_looped OK: right-pad is free under causal (fp32+bf16, composed + flash)");
|
||||
}
|
||||
Reference in New Issue
Block a user