Skip to content

[moe] Expert gradients are scaled by moe_dp/k when an expert is used by only k of the moe_dp replicas #6464

Closed

Description

@JazyJiang

Checklist

  • I have searched the existing issues and did not find a duplicate.
  • I have reproduced this on the latest main (250c903).

Describe the bug

With MoeHybridParallelPlugin (EP > 1, moe_dp > 1, ZeRO), the gradient of an expert is multiplied by moe_dp / k, where k is the number of moe_dp replicas that routed at least one token to that expert in this step. The correct gradient does not depend on k, so whenever an expert is cold on some replica, its gradient is inflated by up to moe_dp×.

The factor comes from DPGradScalerIn / DPGradScalerOut (colossalai/moe/_operation.py), applied around each expert in the EP forward of Mixtral, DeepSeek and DeepSeek-V3 (colossalai/shardformer/modeling/{mixtral,deepseek,deepseek_v3}.py). The k is obtained from an extra all-reduce of activate_experts over the moe_dp group in every MoE layer.

Why the factor is wrong. The target is the gradient of the global mean loss, i.e. the average of the per-rank gradients over the whole data-parallel group. A replica that sends no token to an expert contributes a zero gradient to that average; the zero is part of the correct result, not something to compensate for. ZeRO already reduces expert gradients over the moe_dp group and divides by moe_dp, and EPGradScaler supplies the remaining 1/ep, so the result is correct without DPGradScaler. Megatron-Core does the same: expert gradients are scaled by 1/dp_size regardless of routing (megatron/core/distributed/distributed_data_parallel.py), with no activation-dependent factor.

Why tests did not catch it. tests/test_moe/test_moe_ep_zero.py uses random routing, under which every expert is hit on every replica, so k == moe_dp and the factor is 1. The test is also skipped.

Impact. With fine-grained experts and small per-rank batches, "expert e got no token on some replica" is common, so expert gradients get a different, routing-dependent multiplier at every step. In addition, the per-layer all-reduce and the assert activated_experts != 0 on a GPU tensor in every chunk add host syncs; removing them makes the old EP layer about 1.5× faster (forward + backward of one layer, Qwen3.5-like shape, EP=4: 94.6 → 60.2 ms) with bit-identical outputs.

Minimal reproduction

# torchrun --nproc_per_node=4 repro_dp_grad_scale.py
# ep=2, moe_dp=2, ZeRO-1, bf16. The router is forced so that moe_dp replica 0 only uses expert 0 and replica 1 only
# uses expert 1, i.e. each expert is used by k=1 of the moe_dp=2 replicas. Reference: DDP over the whole dp group.
from copy import deepcopy

import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from transformers import MixtralConfig, MixtralModel

import colossalai
from colossalai.booster import Booster
from colossalai.booster.plugin import MoeHybridParallelPlugin

colossalai.launch_from_torch(seed=0)
rank = dist.get_rank()
torch.cuda.set_device(rank)
EP, H = 2, 8

plugin = MoeHybridParallelPlugin(pp_size=1, tp_size=1, ep_size=EP, zero_stage=1, precision="bf16")
booster = Booster(plugin=plugin)
torch.manual_seed(0)
config = MixtralConfig(hidden_size=H, intermediate_size=2 * H, num_hidden_layers=2, num_attention_heads=2,
                       num_key_value_heads=2, num_local_experts=4, num_experts_per_tok=1)
model = MixtralModel(config).to(torch.bfloat16).cuda()
with torch.no_grad():  # tokens with hidden[0] > 0 go to expert 0, the others to expert 1
    for layer in model.layers:
        layer.block_sparse_moe.gate.weight.zero_()
        layer.block_sparse_moe.gate.weight[0, 0], layer.block_sparse_moe.gate.weight[1, 0] = 10.0, -10.0

zero_model = deepcopy(model)
zero_model, zero_opt, *_ = booster.boost(zero_model, torch.optim.SGD(zero_model.parameters(), lr=1))
ddp_model = DDP(model, process_group=plugin.dp_group, find_unused_parameters=True)

torch.manual_seed(1 + rank)
x = torch.rand(4, 7, H).cuda().to(torch.bfloat16)
x[..., 0] = 5.0 if rank // EP == 0 else -5.0  # moe_dp replica 0 -> expert 0, replica 1 -> expert 1
zero_opt.backward(zero_model(inputs_embeds=x).last_hidden_state.mean())
ddp_model(inputs_embeds=x).last_hidden_state.mean().backward()

ref = dict(ddp_model.module.named_parameters())
for n, p in zero_model.named_parameters():
    n = n.removeprefix("module.")
    g = zero_opt.get_param_grad(p)  # collective, call on every rank
    if rank == 0 and n.startswith("layers.0.") and (".experts.0.w1" in n or "self_attn.q_proj" in n):
        print(f"{n:45s} |ZeRO grad| / |DDP grad| = {g.float().norm() / ref[n].grad.float().norm():.3f}")

Output

layers.0.self_attn.q_proj.weight              |ZeRO grad| / |DDP grad| = 1.000
layers.0.block_sparse_moe.experts.0.w1.weight |ZeRO grad| / |DDP grad| = 2.000

Expected: 1.000 for both. The expert gradient is exactly moe_dp / k = 2 / 1 times too large. With 8 GPUs (moe_dp=4) and experts used by k=1 and k=3 replicas, the ratios are 4.000 and 1.337 (≈ 4/3). To rule out the DDP reference itself, we also compared against a single-GPU fp32 model run on the concatenation of all ranks' batches (whose loss is the global mean loss by definition): DDP matches it within bf16 noise, ZeRO is 2× off on the experts.

Environment

  • ColossalAI: main @ 250c903
  • PyTorch: 2.11.0+cu128 (CUDA runtime 12.8, NCCL 2.28.9)
  • Transformers: 4.51.3
  • CUDA toolkit: 12.8; driver 595.71.05
  • GPU: 8 × NVIDIA H20, 1 node (4 used)
  • OS / Python: Ubuntu 22.04.5, Python 3.10.12
  • Plugin: MoeHybridParallelPlugin
  • Parallel config: ep=2, moe_dp=2, tp=1, pp=1, sp=1, ZeRO stage 1, bf16

Additional context

Activity

  1. added
    bugSomething isn't working
    module: moe(pilot) MoE models and expert parallelism: expert sharding, routing, Mixtral/DeepSeek/Qwen MoE
    priority: high(pilot) Affects mainstream usage or numerical correctness; handle first
    in-progress(pilot) Someone is working on it or a PR is open; exempt from stale
    and removed
    priority: high(pilot) Affects mainstream usage or numerical correctness; handle first
    on Oct 15, 2026
  2. removed
    in-progress(pilot) Someone is working on it or a PR is open; exempt from stale
    on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingmodule: moe(pilot) MoE models and expert parallelism: expert sharding, routing, Mixtral/DeepSeek/Qwen MoE

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions