test: grad-accum equivalence + accum=1 bit-identity + DDP+accum
- grad_accum.rs: accum=N×B grads bit-close to a single N·B big batch; accum_steps=1 bit-identical (max|Δ|==0) to no-accum; real train() loop with accum tracks a big-batch baseline over 20 AdamW steps. - ddp_correctness.rs: world=2 + accum=2 matches a single-GPU big batch of the same effective size (loss + cross-rank + vs-baseline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,7 @@ fn ddp_matches_single_gpu_and_params_consistent() {
|
||||
let dcfg = DdpConfig {
|
||||
seq_len: 32,
|
||||
batch_size: 8, // global; 4 per rank with world=2
|
||||
accum_steps: 1,
|
||||
steps,
|
||||
schedule: LrSchedule {
|
||||
max_lr: 3e-3,
|
||||
@@ -195,6 +196,127 @@ fn ddp_matches_single_gpu_and_params_consistent() {
|
||||
assert!(max_sdiff < 1e-2, "DDP params diverged from single-GPU");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ddp_with_accum_matches_single_gpu_big_batch() {
|
||||
// T16: DDP + gradient accumulation must match a single-GPU big-batch baseline
|
||||
// of the SAME effective batch. world=2, accum=2, per-rank micro-batch 2 →
|
||||
// effective global batch = world·accum·b_local = 2·2·2 = 8. Compared against a
|
||||
// single-GPU run with batch 8, accum 1 (the big-batch baseline). The all-reduce
|
||||
// fires only at the accumulation boundary (once per optimizer step, not per
|
||||
// micro-step) — enforced by the train_rank implementation; the load-bearing
|
||||
// gate here is that loss + final params still match the big-batch baseline.
|
||||
let world = 2usize;
|
||||
if device::device_count().unwrap_or(0) < world as i32 {
|
||||
eprintln!("skip: need >= {world} GPUs");
|
||||
return;
|
||||
}
|
||||
|
||||
let vocab = 64usize;
|
||||
let cfg = test_config(vocab);
|
||||
let corpus = synth_corpus(vocab, 4096);
|
||||
let steps = 20usize;
|
||||
let effective_batch = 8usize; // world(2) · accum(2) · b_local(2)
|
||||
let sched = LrSchedule {
|
||||
max_lr: 3e-3,
|
||||
min_lr: 3e-4,
|
||||
warmup: 3,
|
||||
total: steps,
|
||||
};
|
||||
|
||||
// Single-GPU big-batch baseline: world=1, accum=1, batch = effective_batch.
|
||||
let baseline_cfg = DdpConfig {
|
||||
seq_len: 32,
|
||||
batch_size: effective_batch,
|
||||
accum_steps: 1,
|
||||
steps,
|
||||
schedule: sched,
|
||||
weight_decay: 0.1,
|
||||
max_grad_norm: 1.0,
|
||||
log_every: 1_000_000,
|
||||
seed: 7,
|
||||
eval_every: 0,
|
||||
eval_batches: 0,
|
||||
ckpt_path: None,
|
||||
};
|
||||
let (single_losses, single_params) = run_single_gpu(cfg, &corpus, &baseline_cfg);
|
||||
|
||||
// DDP + accumulation: world=2, accum=2 → per-rank micro-batch = batch/world = 2.
|
||||
let ddp_cfg = DdpConfig {
|
||||
batch_size: effective_batch / 2, // per-step global batch; ×accum = effective
|
||||
accum_steps: 2,
|
||||
..baseline_cfg
|
||||
};
|
||||
let devices = [0u32, 1u32];
|
||||
let id = get_unique_id();
|
||||
let results: Vec<(Vec<f32>, Vec<Vec<f32>>)> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(rank, &dev)| {
|
||||
let ddp_cfg = ddp_cfg.clone();
|
||||
let corpus = &corpus;
|
||||
s.spawn(move || {
|
||||
let ctx = DdpContext::init(rank, world, id, dev);
|
||||
let device = Device::Cuda(dev);
|
||||
let model = build_model(cfg, device);
|
||||
let res = train_rank(&ctx, &model, device, corpus, None, &ddp_cfg);
|
||||
let host = model
|
||||
.params()
|
||||
.iter()
|
||||
.map(|p| p.value().to_device(Device::Cpu).as_slice::<f32>().to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
(res.losses, host)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
|
||||
let (ddp_losses, ddp_p0) = &results[0];
|
||||
let (_, ddp_p1) = &results[1];
|
||||
|
||||
// (a) Loss trajectory matches the single-GPU big-batch baseline.
|
||||
let mut max_rel = 0.0f32;
|
||||
for (s, d) in single_losses.iter().zip(ddp_losses) {
|
||||
max_rel = max_rel.max((s - d).abs() / s.abs().max(1e-6));
|
||||
}
|
||||
println!(
|
||||
"DDP+accum(w2·a2·b2) vs single-GPU big-batch(8): single[last]={:.6} ddp[last]={:.6} max_rel={max_rel:.2e}",
|
||||
single_losses.last().unwrap(),
|
||||
ddp_losses.last().unwrap()
|
||||
);
|
||||
assert!(
|
||||
max_rel < 1e-3,
|
||||
"DDP+accum loss diverged from big-batch baseline: {max_rel:.3e}"
|
||||
);
|
||||
|
||||
// (b) Cross-rank parameter agreement (same KI-5 ULP tolerance as the base test).
|
||||
let mut max_pdiff = 0.0f32;
|
||||
for (a, b) in ddp_p0.iter().zip(ddp_p1) {
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
max_pdiff = max_pdiff.max((x - y).abs());
|
||||
}
|
||||
}
|
||||
println!("DDP+accum cross-rank max |param diff| = {max_pdiff:.3e}");
|
||||
assert!(
|
||||
max_pdiff < 1e-6,
|
||||
"ranks' params drifted apart: {max_pdiff:.3e}"
|
||||
);
|
||||
|
||||
// (c) Final params match single-GPU big-batch within fp tolerance.
|
||||
let mut max_sdiff = 0.0f32;
|
||||
for (a, b) in ddp_p0.iter().zip(&single_params) {
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
max_sdiff = max_sdiff.max((x - y).abs() / y.abs().max(1e-6));
|
||||
}
|
||||
}
|
||||
println!("DDP+accum vs single-GPU big-batch max rel |param diff| = {max_sdiff:.3e}");
|
||||
assert!(
|
||||
max_sdiff < 1e-2,
|
||||
"DDP+accum params diverged from big-batch baseline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ddp_throughput_scaling() {
|
||||
let max_gpus = device::device_count().unwrap_or(0) as usize;
|
||||
@@ -230,6 +352,7 @@ fn ddp_throughput_scaling() {
|
||||
let dcfg = DdpConfig {
|
||||
seq_len,
|
||||
batch_size: per_gpu_batch * world,
|
||||
accum_steps: 1,
|
||||
steps,
|
||||
schedule: LrSchedule {
|
||||
max_lr: 1e-3,
|
||||
|
||||
Reference in New Issue
Block a user