Compare commits

...

2 Commits

Author SHA1 Message Date
1eef10afd9 docs: T21 — record DDP-dropout wiring gap + fix (known-issues / evolution / dropout doc)
- known-issues.md: new "DDP-dropout wiring" Fixed entry (gap + fix +
  regression test), with the meta-lesson that op/single-GPU unit tests can
  miss launcher-level integration gaps — only the V9-PILOT end-to-end run on
  the real launcher path exposed it.
- 17-dropout.md: annotate the DDP-combination note with the T18 wiring gap
  and its T21 fix.
- evolution.md: T21 row (Infra) recording the fix + meta-lesson.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:08:35 +08:00
1b58bd8626 test: T21 — DDP-dropout regression (live under DDP + p=0 bit-identical)
Adds ddp_dropout_is_live_and_p0_bit_identical, run via the real launcher
path (train_rank, world=2). It would have caught the original bug:

- GATE A: a p=0 DDP run is BIT-IDENTICAL (loss trace + final params) to the
  no-dropout path — the regression guard for --dropout 0 (default).
- GATE B: a p=0.2 DDP run's loss trace DIFFERS measurably (>1e-3) from p=0.
  On the pre-T21 code the model stays in eval mode, so p=0.2 would be an
  identity and the trace would be bit-identical to p=0 — this gate fails.
- GATE C: model.is_training() == true after the run (direct proof that
  train_rank called model.train() and it survived the final-step eval).
- p>0 run is finite (no NaN/Inf).

eval_every < steps so a periodic eval fires mid-run (flipping to eval mode),
exercising the per-step model.train() restore discipline the pilot called out.
Run with --test-threads=1 like the other DDP tests (shared-GPU deadlock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:08:27 +08:00
4 changed files with 180 additions and 0 deletions

View File

@@ -38,6 +38,46 @@ fn test_config(vocab: usize) -> Config {
cfg
}
/// Run `cfg`/`dcfg` as a 2-rank DDP job (the same launcher path as production) and
/// return rank 0's (loss trace, final params on host, final `is_training()` flag).
/// `cfg` carries the dropout prob; `dcfg` carries the loop knobs. Caller asserts.
fn run_ddp2(
cfg: Config,
corpus: &Corpus,
valid: Option<&Corpus>,
dcfg: &DdpConfig,
) -> (Vec<f32>, Vec<Vec<f32>>, bool) {
let world = 2usize;
let devices = [0u32, 1u32];
let id = get_unique_id();
let results: Vec<(Vec<f32>, Vec<Vec<f32>>, bool)> = std::thread::scope(|s| {
let handles: Vec<_> = devices
.iter()
.enumerate()
.map(|(rank, &dev)| {
let dcfg = dcfg.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);
// Only rank 0 holds the val corpus (mirrors launch()).
let v = if rank == 0 { valid } else { None };
let res = train_rank(&ctx, &model, device, corpus, v, &dcfg);
let host = model
.params()
.iter()
.map(|p| p.value().to_device(Device::Cpu).as_slice::<f32>().to_vec())
.collect::<Vec<_>>();
(res.losses, host, model.is_training())
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
results.into_iter().next().unwrap()
}
// Single-GPU baseline: the SAME loop as the DDP rank but world=1, so the global
// batch is processed on one device. Returns (loss trace, final params on host).
fn run_single_gpu(cfg: Config, corpus: &Corpus, dcfg: &DdpConfig) -> (Vec<f32>, Vec<Vec<f32>>) {
@@ -386,3 +426,128 @@ fn ddp_throughput_scaling() {
);
}
}
/// T21 regression: prove dropout is actually LIVE under DDP, and that p=0 is
/// bit-identical to the no-dropout path. Guards the V9-PILOT launcher-wiring gap —
/// `train_ddp` had no `--dropout` flag and `train_rank` never called `model.train()`,
/// so under DDP every forward ran in the default eval mode and dropout was a silent
/// identity regardless of config. Op/single-GPU tests never exercised dropout-under-
/// DDP, so it slipped through; this test runs the real launcher path (`train_rank`).
///
/// With dropout fixed across the 4 sub-runs, all three checks below would FAIL on the
/// pre-T21 code: (a) the p>0 trace would be bit-identical to p=0 (model stuck in eval
/// mode → identity), and (c) `is_training()` would be false after the run.
#[test]
fn ddp_dropout_is_live_and_p0_bit_identical() {
let world = 2usize;
if device::device_count().unwrap_or(0) < world as i32 {
eprintln!("skip: need >= {world} GPUs");
return;
}
let vocab = 64usize;
let corpus = synth_corpus(vocab, 4096);
let steps = 20usize;
// eval_every < steps so a periodic eval fires MID-run (flipping the model to
// eval mode via eval_loss → model.eval()). The per-step model.train() must
// restore training mode so dropout stays live across the eval boundary — this is
// exactly the train/eval discipline the pilot called out. A held-out slice gives
// rank 0 something to eval on.
let valid = synth_corpus(vocab, 512);
let base_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,
min_lr: 3e-4,
warmup: 3,
total: steps,
},
weight_decay: 0.1,
max_grad_norm: 1.0,
log_every: 1_000_000, // silence per-step logging
seed: 7,
eval_every: 7, // fires at steps 6, 13, 19 — flips to eval mode mid-run
eval_batches: 4,
ckpt_path: None,
};
// (1) p=0 config — the no-dropout baseline. cfg.dropout defaults to 0.0.
let cfg_p0 = test_config(vocab);
assert_eq!(cfg_p0.dropout, 0.0, "baseline cfg must have dropout 0");
let (loss_p0, params_p0, _) = run_ddp2(cfg_p0, &corpus, Some(&valid), &base_dcfg);
// (2) Same config, dropout disabled by p=0 but explicitly set — must be the
// SAME run (sanity: setting dropout=0 doesn't perturb anything).
let mut cfg_p0b = test_config(vocab);
cfg_p0b.dropout = 0.0;
let (loss_p0b, params_p0b, _) = run_ddp2(cfg_p0b, &corpus, Some(&valid), &base_dcfg);
// (3) Same config + data + seed, but dropout p=0.2 ON.
let mut cfg_p = test_config(vocab);
cfg_p.dropout = 0.2;
let (loss_p, _params_p, train_flag_p) = run_ddp2(cfg_p, &corpus, Some(&valid), &base_dcfg);
// GATE A — p=0 is bit-identical to the no-dropout path (regression guard).
// ops::dropout(p=0) is a clone no-op regardless of training mode, so these two
// runs must agree to the last bit on BOTH the loss trace and the final params.
let mut max_loss_diff = 0.0f32;
for (a, b) in loss_p0.iter().zip(&loss_p0b) {
max_loss_diff = max_loss_diff.max((a - b).abs());
}
let mut max_param_diff = 0.0f32;
for (a, b) in params_p0.iter().zip(&params_p0b) {
for (x, y) in a.iter().zip(b) {
max_param_diff = max_param_diff.max((x - y).abs());
}
}
println!(
"T21 GATE A (p=0 bit-identical): max |loss diff| = {max_loss_diff:.3e}, \
max |param diff| = {max_param_diff:.3e}"
);
assert_eq!(
max_loss_diff, 0.0,
"p=0 DDP loss trace not bit-identical to no-dropout path"
);
assert_eq!(
max_param_diff, 0.0,
"p=0 DDP final params not bit-identical to no-dropout path"
);
// GATE B — dropout is LIVE with p>0 under DDP. If model.train() were not wired
// (the pre-T21 bug), the model would stay in eval mode and the p=0.2 forward
// would be IDENTITY → loss trace bit-identical to p=0. A real, sizeable
// difference proves dropout masks are actually applied during the training
// forward (and survive the mid-run eval flips, since model.train() is re-asserted
// each step). Inverted scaling + masking perturbs every step, so the gap is large.
let mut max_live_diff = 0.0f32;
for (a, b) in loss_p0.iter().zip(&loss_p) {
max_live_diff = max_live_diff.max((a - b).abs());
}
println!(
"T21 GATE B (dropout live): p0[last]={:.6} p0.2[last]={:.6} max |loss diff| = {max_live_diff:.3e}",
loss_p0.last().unwrap(),
loss_p.last().unwrap()
);
assert!(
max_live_diff > 1e-3,
"p=0.2 DDP loss trace matches p=0 — dropout is NOT live under DDP \
(model.train() not wired): max |loss diff| {max_live_diff:.3e}"
);
// GATE C — train_rank leaves the model in TRAINING mode (direct proof that
// model.train() was called and survives the final-step eval). On the pre-T21
// code this would be false (model never left the default eval mode).
assert!(
train_flag_p,
"model not in training mode after DDP run — model.train() not wired in train_rank"
);
// No NaN/Inf in the p>0 run (dropout converges normally under DDP).
assert!(
loss_p.iter().all(|l| l.is_finite()),
"p=0.2 DDP loss has non-finite values"
);
}

View File

@@ -131,6 +131,11 @@ forward 里保持不变**——本设计天然满足mask 只由 `(seed, i)`
- **DDPT8**:每 rank 独立跑自己的 forward/backward各自的 mask 由各 rank 的 `base_seed` 决定。
本任务的 DDP 闸门是「loss 对单卡 / 跨 rank 参数一致」,在 **dropout 关(默认 p=0** 的回归配置下跑,
不引入跨 rank mask 同步需求p>0 时各 rank mask 本就该不同,属正常 DDP 语义)。
- **⚠️ T18 的 launcher wiring gap → FIXED in T21**T18 只把 dropout 接进**单卡** `train.rs`
`train_ddp` bin/`train_rank` loop **没接**(无 `--dropout` flag、从不调 `model.train()`
所以 DDP 路径下 dropout 被静默忽略——V9-PILOT 全栈实跑才暴露op + 单卡测试覆盖不到 launcher 级)。
**T21** 补齐:`train_ddp``--dropout``train_rank` 每步 `model.train()`eval 后 restore
并加 DDP-dropout 回归测试p>0 下 dropout live + p=0 逐位一致)。见 known-issues「DDP-dropout wiring」。
- **梯度累积T16/ flashT14**:本分支独立于二者,不依赖其未合并改动。
## 验证方法

View File

@@ -29,6 +29,7 @@
| T16 | 算法/Infra | **梯度累积**N micro-step每个 micro-loss `×1/N` backwardtape SUM 累加 一次 AdamW step+zero`--accum-steps`**DDP 只在累积边界 all-reduce**中间 micro-step 不发 NCCL`/world` `1/N` 正交显存随 micro 不随有效 batch | 等效大 batch**逐位贴合**loss rel 8.5e-8grad rel 3.8e-5`accum=1` 逐位回归(0.00)DDP+accum 对单卡 loss 5.7e-7/ rank 一致**显存平**同有效 batch 64big-batch 27.7GBaccum(4×16) **7.2GB(74%)**big-batch OOM accum 装下全回归+xserv 闭环 md5 一致 |
| T18 | 算法 | **dropout**手写 counter-based 设备 RNG Bernoulli mask训练 inverted 1/(1-p) scalingeval 恒等 autodiff `dropout` 算子fwd 生成+施加 maskbwd 用同 mask residual/ffn 两处`--dropout` flag 默认 0 | 固定 seed grad-check E[out]≈input + keep1-p**p=0 与无 dropout 逐位一致**recompute(T13) 组合下梯度仍逐位一致counter-based seed 重算复现同 mask全回归 + xserv 闭环绿导出/推理 dropout |
| T17 | Infra | **process-per-GPU**torchrun `launch_processes` 每卡 spawn 一个 worker 进程=独立 CUDA contextlauncher 一次性铸 `ncclUniqueId` **hex 编码注入子进程 env**——无共享 FS/TCP无竞态worker envbind device`DdpContext::init`+`build_model`+`train_rank` **全复用 T8 零改动** `train_ddp_mp` bin/`ddp_proc` test**保留 thread-per-GPU 旧路径**scope=process-per-GPU onlyZeRO-1 用户 dropPhase 2 | 正确性全绿proc vs 单卡 loss 5.67e-7、**proc vs thread-per-GPU 1.5e-7**、 rank 1.19e-7(<1e-6)、全回归+xserv 闭环 md5 逐位一致 `b04fc9f9`。**⚠️关键发现实测证伪原假设本尺度 process-per-GPU 对吞吐中性**——thread vs proc @ {1,2,4,8} = {1.00/1.61/2.98/**5.27**}× vs {1.00/1.60/2.94/**5.31**}×<1% 噪声内8 卡全 9599% util 残留 ~5.3×@8 非线性是 **NCCL all-reduce + 本机 PCIe 拓扑墙****** CUDA context 串行KI-5/T11 doc 的猜想被钉死推翻方法论同 T11 证伪分桶 all-reduce」)。净价值=落地 torchrun 式标准链路 + 把误导性 backlog 项实测关闭默认训练路径不变 |
| T21 | Infra | **DDP-dropout wiring fix**V9-PILOT 暴露T18 只把 dropout 接进单卡 `train.rs``train_ddp` bin `--dropout` flag`train_rank` 从不调 `model.train()` DDP dropout 被静默忽略`--dropout` flag + `train_rank` 每步 `model.train()`镜像单卡 train/eval 纪律——`eval_loss` eval 后由每步 `train()` restore DDP-dropout 回归测试堵缺口 | DDP-dropout 回归测试绿p>0 下 dropout **live**loss 轨迹对 p=0 有可观差异pre-T21 会逐位相同、p=0 对无 dropout 路径**逐位一致**、run 后 `is_training()==true`;既有 DDP loss-match/跨 rank 测试不变。**元教训op/单卡单元测试漏掉 launcher 级 integration gap只有真实启动器端到端跑pilot才暴露** |
---

View File

@@ -13,6 +13,15 @@ _(KI-1 fixed in T10. KI-5 fixed in T11. KI-2 fixed in T12. **KI-3激活重计
## Fixed
### DDP-dropout wiringlauncher 漏接 dropout— `FIXED` (T21)
- **背景V9-PILOT 暴露)**T18 dropout 在**单卡** `train.rs` 完整接好(`--dropout` flag → `cfg.dropout`,每步 `model.train()`eval 用 `model.eval()`op 级 + 单卡都测过。但 V9-PILOT 全栈端到端跑DDP 8 卡 + dropout0.1 + flash + GQA + accum + bf16时发现 **DDP 路径根本没接 dropout**`train_ddp` bin **无 `--dropout` flag、从不设 `cfg.dropout`**,且 `ddp.rs::train_rank` **从不调 `model.train()`** → 模型停在默认 eval 模式,`ops::dropout` 恒等 → DDP 下 dropout **被静默忽略,无论 config 怎么设**。模型 + autodiff 完全支持 dropoutT18漏的纯是 **DDP launcher / 训练 loop 的 wiring**
- **为何 op/单卡测试没抓到**dropout 的测试只覆盖**单卡训练循环 + op 级 grad-check**,从没在 **DDP 路径下**跑过 dropout。`train_rank` 是独立于单卡 `train()` 的另一条 loop二者共享 model/autodiff 但**各自布线 train/eval 纪律** —— 单卡那条对了不代表 DDP 那条对。**元教训op 级 + 单 GPU 单元测试能漏掉 launcher 级 integration gap**;只有把特性放进**真实启动器路径**端到端跑pilot 做的事)才暴露。修复随手补了 DDP 路径的回归测试堵这个缺口。
- **修复([docs/17-dropout.md](17-dropout.md)**:① `train_ddp.rs``--dropout <p>` flag默认 0 = 关,对齐旧行为)并设 `cfg.dropout`;② `ddp.rs::train_rank` 每步 micro-batch 循环前调 `model.train()`,镜像单卡 loop 的 train/eval 纪律——**关键**`eval_loss()` 内部 `model.eval()` 翻成 eval 模式且**不还原**,所以每步重新 assert `model.train()`dropout 才能跨 eval 边界保持活跃。
- **正确性(新增 DDP-dropout 回归测试 `ddp_dropout_is_live_and_p0_bit_identical`,跑真实 `train_rank` 启动器路径)**:① **GATE A**——`p=0` 下 DDP loss 轨迹 + 末态参数对无 dropout 路径**逐位一致**`ops::dropout(p=0)` 是 clone no-op回归保护**GATE B**——`p=0.2` 的 loss 轨迹对 `p=0` **有可观差异**>1e-3证 dropout mask 真在训练 forward 应用pre-T21 代码停在 eval 模式 → 二者会逐位相同,此 gate 会 FAIL**GATE C**——run 后 `model.is_training()==true`(直接证 `model.train()` 被调用且跨末步 eval 存活);④ p>0 run 无 NaN/Inf。测试故意启用 `eval_every < steps` 让 eval 中途翻 eval 模式,验证每步 `model.train()` 的 restore 纪律。默认 `--dropout 0` 下既有 DDP loss-match + 跨 rank 测试**不变**(回归保护)。
- **commit**:见 T21 提交链(`distributed: --dropout flag + model.train() per step in train_rank` / `test: DDP-dropout regression (live under DDP + p=0 bit-identical)` / 文档更新)。
---
### process-per-GPUtorchrun 式独立 CUDA context— `CLOSED / 实测负结果` (T17)
- **背景**KI-5T11修掉 per-op `cudaMalloc` 串行后8 卡 scaling 从 ~1.3× 恢复到 **~5×@8**,但残留 ~5×@8 非完美线性。T11 doc / KI-5「残留」推测下一步是 **process-per-GPU**(每 rank 独立进程 + 独立 CUDA contexttorchrun 式——理由是「N rank 线程共享单 CUDA primary contextkernel-launch/cuBLAS 仍在 context 级串行」。**T17 把这条 torchrun 式链路落地并实测,证伪了该推测。**
- **实现([docs/16-process-per-gpu.md](16-process-per-gpu.md)**`xtrain-distributed``proc.rs`——`launch_processes` 每卡 spawn 一个 worker 进程re-exec current_exe + `XTRAIN_{RANK,WORLD,LOCAL_RANK,NCCL_ID}` env**launcher 一次性铸 `ncclUniqueId` 后 hex 编码注入子进程 env**(无共享 FS/TCP、无轮询、无竞态——id 在子进程出生前就原子就绪worker 读 env → bind device独立 CUDA context`DdpContext::init` + `build_model` + `train_rank` **全部复用 T8 零改动**。新 `train_ddp_mp` bin + `ddp_proc` test**保留 thread-per-GPU 旧路径**(回归 baseline。scope=process-per-GPU onlyZeRO-1 用户 drop