Fix expert-parallel training: NaN gradients and missing gradient contributions - #48205
Fix expert-parallel training: NaN gradients and missing gradient contributions#48205qgallouedec wants to merge 5 commits into
Conversation
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
e79ef7a to
ab27829
Compare
Loading a model with DistributedConfig(tp_size=N, enable_expert_parallel=True) and handing it to Trainer fails on main in three places: 1. maybe_distribute_model never assigns model._tp_size, so the Trainer builds no ParallelismConfig and accelerate wraps the DTensor model in DDP: ValueError: Your model contains DTensor parameters, which is incompatible with DDP. 2. _get_grad_norm calls clip_grad_norm_ over the full parameter set, and _foreach_norm cannot span a mix of DTensor (experts) and plain parameters. 3. The fused/foreach AdamW kernels cannot span that mix either. Set _tp_size where the mesh is recorded, compute the gradient norm (and clip) per-gradient with replication-aware discounting when parameters live on different meshes, and fall back to per-parameter AdamW stepping for mixed parameter sets. With this and #48205, expert-parallel full fine-tuning through Trainer runs end-to-end and tracks a single-GPU control step by step (OLMoE-1B-7B, tp=4: 12.13 -> 12.45 -> 11.52 -> ... -> 11.04 vs 12.13 -> 12.45 -> 11.51 -> ... -> 11.09).
Loading a model with DistributedConfig(tp_size=N, enable_expert_parallel=True) and handing it to Trainer fails on main in three places: 1. maybe_distribute_model never assigns model._tp_size, so the Trainer builds no ParallelismConfig and accelerate wraps the DTensor model in DDP: ValueError: Your model contains DTensor parameters, which is incompatible with DDP. 2. _get_grad_norm calls clip_grad_norm_ over the full parameter set, and _foreach_norm cannot span a mix of DTensor (experts) and plain parameters. 3. The fused/foreach AdamW kernels cannot span that mix either. Set _tp_size where the mesh is recorded, compute the gradient norm (and clip) per-gradient with replication-aware discounting when parameters live on different meshes, and fall back to per-parameter AdamW stepping for mixed parameter sets. With this and #48205, expert-parallel full fine-tuning through Trainer runs end-to-end and tracks a single-GPU control step by step (OLMoE-1B-7B, tp=4: 12.13 -> 12.45 -> 11.52 -> ... -> 11.04 vs 12.13 -> 12.45 -> 11.51 -> ... -> 11.09).
ab27829 to
1b718f6
Compare
There was a problem hiding this comment.
do we need a pre and post masking 🥲 ? i feel like it's redundant, the sample_weights_g are already zero in sentinel rows
There was a problem hiding this comment.
The post-mask on weighted_out was indeed redundant once this PR zeroes `proj_out per mm, dropped it in 7398af0.
The pre-mask is important though: the danger isn't the values (where weights=0 would save us) but uninitialized kernel memory, and the grouped-mm backward writes garbage d_input rows for the skipped sentinel rows regardless of upstream grads.
With the allocator pre-poisoned with NaN buffers, masks on gives finite grads; ablating just the pre-mask gives NaN grads (tested locally).
ArthurZucker
left a comment
There was a problem hiding this comment.
Yep, this is great. @3outeille your tests need to catch this, can you make sure they now do?
Loading a model with DistributedConfig(tp_size=N, enable_expert_parallel=True) and handing it to Trainer fails on main in three places: 1. maybe_distribute_model never assigns model._tp_size, so the Trainer builds no ParallelismConfig and accelerate wraps the DTensor model in DDP: ValueError: Your model contains DTensor parameters, which is incompatible with DDP. 2. _get_grad_norm calls clip_grad_norm_ over the full parameter set, and _foreach_norm cannot span a mix of DTensor (experts) and plain parameters. 3. The fused/foreach AdamW kernels cannot span that mix either. Set _tp_size where the mesh is recorded, compute the gradient norm (and clip) per-gradient with replication-aware discounting when parameters live on different meshes, and fall back to per-parameter AdamW stepping for mixed parameter sets. With this and #48205, expert-parallel full fine-tuning through Trainer runs end-to-end and tracks a single-GPU control step by step (OLMoE-1B-7B, tp=4: 12.13 -> 12.45 -> 11.52 -> ... -> 11.04 vs 12.13 -> 12.45 -> 11.51 -> ... -> 11.09).
1b718f6 to
0f3bd58
Compare
|
Thanks for the reviews guys. @ArthurZucker I can also turn the harness into a slow 8-GPU test (EP fwd+bwd grads vs single-GPU reference on a tiny MoE) and add it to this PR, say the word and I'll write it in the repo's test style. In any case, here there are: from transformers.testing_utils import TestCasePlus, is_tensor_parallel_test
EP_TRAINING_WORKER = """
import torch
from transformers import AutoModelForCausalLM, Qwen3MoeConfig, Qwen3MoeForCausalLM
from transformers.distributed import DistributedConfig
config = Qwen3MoeConfig(
vocab_size=128, hidden_size=64, intermediate_size=128, moe_intermediate_size=32,
num_hidden_layers=2, num_attention_heads=4, num_key_value_heads=4, num_experts=16,
num_experts_per_tok=4, decoder_sparse_step=1, head_dim=16,
)
torch.manual_seed(0)
reference = Qwen3MoeForCausalLM(config).float()
import tempfile, os
tmp = os.environ["EP_TEST_CKPT"]
rank = int(os.environ["RANK"])
world_size = int(os.environ["WORLD_SIZE"])
if rank == 0:
reference.save_pretrained(tmp)
torch.distributed.init_process_group(backend="nccl")
torch.cuda.set_device(rank)
torch.distributed.barrier()
torch.manual_seed(1)
batch = torch.randint(0, 128, (4, 32))
# Single-model reference gradients (every rank computes its own copy, fp32)
reference = reference.cuda()
ref_out = reference(input_ids=batch.cuda(), labels=batch.cuda())
ref_out.loss.backward()
# Expert-parallel model over the whole world
model = AutoModelForCausalLM.from_pretrained(
tmp, dtype=torch.float32,
distributed_config=DistributedConfig(tp_size=world_size, fsdp_size=1, enable_expert_parallel=True),
)
out = model(input_ids=batch.to(model.device), labels=batch.to(model.device))
out.loss.backward()
torch.testing.assert_close(out.loss, ref_out.loss, rtol=1e-5, atol=1e-5)
ref_params = dict(reference.named_parameters())
checked = 0
for name, param in model.named_parameters():
if param.grad is None:
continue
assert param.grad.isfinite().all(), f"non-finite grad in {name}"
grad = param.grad
ref_grad = ref_params[name].grad
if hasattr(grad, "_local_tensor"): # EP-sharded experts: compare this rank's slice
local = grad._local_tensor
n_local = local.shape[0]
ref_grad = ref_grad[rank * n_local : (rank + 1) * n_local]
grad = local
torch.testing.assert_close(grad, ref_grad, rtol=1e-4, atol=1e-5, msg=name)
checked += 1
assert checked > 0
if rank == 0:
print(f"EP_GRADIENTS_MATCH ({checked} params)")
"""
@is_tensor_parallel_test
class TestExpertParallelTrainingGradients(TestCasePlus):
def test_ep_training_gradients_match_single_model(self):
"""Expert-parallel fwd+bwd must reproduce the single-model loss and gradients (router
included): guards the sentinel-row masking and the router-score gradient all-reduce."""
import tempfile
from transformers.testing_utils import backend_device_count, torch_device, torchrun
nproc = min(backend_device_count(torch_device), 8)
if nproc < 2:
self.skipTest("needs at least 2 accelerators")
with tempfile.TemporaryDirectory() as tmp:
import os
env = os.environ.copy()
env["EP_TEST_CKPT"] = tmp
torchrun(EP_TRAINING_WORKER, nproc, env=env) |
7398af0 to
1a958cd
Compare
Loading a model with DistributedConfig(tp_size=N, enable_expert_parallel=True) and handing it to Trainer fails on main in three places: 1. maybe_distribute_model never assigns model._tp_size, so the Trainer builds no ParallelismConfig and accelerate wraps the DTensor model in DDP: ValueError: Your model contains DTensor parameters, which is incompatible with DDP. 2. _get_grad_norm calls clip_grad_norm_ over the full parameter set, and _foreach_norm cannot span a mix of DTensor (experts) and plain parameters. 3. The fused/foreach AdamW kernels cannot span that mix either. Set _tp_size where the mesh is recorded, compute the gradient norm (and clip) per-gradient with replication-aware discounting when parameters live on different meshes, and fall back to per-parameter AdamW stepping for mixed parameter sets. With this and #48205, expert-parallel full fine-tuning through Trainer runs end-to-end and tracks a single-GPU control step by step (OLMoE-1B-7B, tp=4: 12.13 -> 12.45 -> 11.52 -> ... -> 11.04 vs 12.13 -> 12.45 -> 11.51 -> ... -> 11.09).
1a958cd to
b52614e
Compare
Loading a model with DistributedConfig(tp_size=N, enable_expert_parallel=True) and handing it to Trainer fails on main in three places: 1. maybe_distribute_model never assigns model._tp_size, so the Trainer builds no ParallelismConfig and accelerate wraps the DTensor model in DDP: ValueError: Your model contains DTensor parameters, which is incompatible with DDP. 2. _get_grad_norm calls clip_grad_norm_ over the full parameter set, and _foreach_norm cannot span a mix of DTensor (experts) and plain parameters. 3. The fused/foreach AdamW kernels cannot span that mix either. Set _tp_size where the mesh is recorded, compute the gradient norm (and clip) per-gradient with replication-aware discounting when parameters live on different meshes, and fall back to per-parameter AdamW stepping for mixed parameter sets. With this and #48205, expert-parallel full fine-tuning through Trainer runs end-to-end and tracks a single-GPU control step by step (OLMoE-1B-7B, tp=4: 12.13 -> 12.45 -> 11.52 -> ... -> 11.04 vs 12.13 -> 12.45 -> 11.51 -> ... -> 11.09).
…uped_mm rows Under EP, sentinel token-expert slots sit beyond offsets[-1] and torch._grouped_mm leaves those output rows (fwd output and bwd d_input) uninitialized. The forward relied on a single post-mask plus a single pre-mask, letting NaN/Inf from uninitialized memory transit the activation and down-projection backward. The gate product's backward (act_fn(gate) * up) turns 0 x Inf into NaN (torch.autograd anomaly mode names this exact Mul), and it escapes into finite gradients: full fine-tuning of any EP-sharded MoE produced nan grad_norm on the second step (the first step survives only because freshly-allocated CUDA memory happens to be zeroed) and the loss collapsed to 0. Mask the sentinel-tail rows after each grouped GEMM instead. Full fine-tuning of OLMoE-1B-7B under ep=4 now matches the single-GPU loss trajectory.
…training Under EP the router hook zeroes the routing scores of non-local experts, so in backward each rank's score gradient covers only the slots of its local experts, and nothing sums the per-rank partial gradients: the gate weights and, through the gate's input, every parameter upstream of each MoE block receive gradients missing the contributions that flow through remote experts. The existing _AllReduceBackward on the experts' hidden input covers the dispatch branch, and the top_k_weights branch is explicitly skipped when is_expert_parallel -- but under EP it is exactly as partial as under TP-MoE. Measured against a single-GPU reference (OLMoE-1B-7B, one batch, fp32 so rounding noise vanishes): before the fix, 3/179 parameters agree (relative max-abs errors 0.3-2.5 on attention, norms, embeddings and router gates, 10-100x above the run-to-run noise floor; only the last layer's experts and the final norm -- the parameters backward reaches before crossing an expert block -- are correct). After the fix: 179/179 agree, max relative error 2.7e-5. Fix: allreduce-sum the score gradient in the EP router hook, before the non-local mask (each slot has exactly one owning rank, so the sum is exact).
…-mask superseded by the per-mm masks
Loading a model with DistributedConfig(tp_size=N, enable_expert_parallel=True) and handing it to Trainer fails on main in three places: 1. maybe_distribute_model never assigns model._tp_size, so the Trainer builds no ParallelismConfig and accelerate wraps the DTensor model in DDP: ValueError: Your model contains DTensor parameters, which is incompatible with DDP. 2. _get_grad_norm calls clip_grad_norm_ over the full parameter set, and _foreach_norm cannot span a mix of DTensor (experts) and plain parameters. 3. The fused/foreach AdamW kernels cannot span that mix either. Set _tp_size where the mesh is recorded, compute the gradient norm (and clip) per-gradient with replication-aware discounting when parameters live on different meshes, and fall back to per-parameter AdamW stepping for mixed parameter sets. With this and #48205, expert-parallel full fine-tuning through Trainer runs end-to-end and tracks a single-GPU control step by step (OLMoE-1B-7B, tp=4: 12.13 -> 12.45 -> 11.52 -> ... -> 11.04 vs 12.13 -> 12.45 -> 11.51 -> ... -> 11.09).
b52614e to
e0b2e71
Compare
CI recapDashboard: View test results in Grafana |
Two bugs in expert-parallel training (
enable_expert_parallel=True), found while validating FSDP2 × EP (#48204): full fine-tuning of an EP-sharded MoE trains on wrong gradients, then collapses to NaN on step 2. +14 lines total.1. NaN from uninitialized memory.
torch._grouped_mmleaves the sentinel-tail rows of its output and backwardd_inputuninitialized, and the NaN escapes through theact_fn(gate) * upbackward (0 × Inf). Step 1 survives only because fresh CUDA memory is zeroed; step 2 reuses dirty memory →grad_norm=nan, loss → 0. Fix: mask the sentinel rows after each grouped GEMM.2. Missing gradient contributions for every non-expert parameter. The router hook zeroes non-local score slots, so each rank's score gradient covers only its local experts, and the per-rank partials are never summed: everything upstream of each MoE block loses the gradient flowing through remote experts. Fix: allreduce-sum the score gradient before the mask (each slot has one owning rank, so the sum is exact).
Certification
fp32 gradient diff vs a single-GPU reference (OLMoE-1B-7B, one batch,
tp_size=4) — median relative error per class:With this PR all 179 parameters match the reference (max rel err 2.5e-5). Same certification on real Qwen3-30B-A3B weights (first 4 layers): 47/47, max 2.7e-6; and on gpt-oss-20b (expert biases + sigmoid gate): 71/71, max 9.5e-3. In bf16, EP full fine-tuning goes
12.13 → nan/0on main; with this PR it tracks a single-GPU control step by step (12.13 → 12.45 → 11.52 → … → 11.04vs12.13 → 12.45 → 11.51 → … → 11.09). Also fixes NaN under fused losses consuming EP outputs (e.g. TRL's chunked cross-entropy).Certification script (raw forward/backward, EP through
Traineris separately broken on main, see #48204)