Skip to content

Commit 95dc69d

Browse files
committed
[distributed] Test expert parallelism with context parallelism
The tiny Qwen3-MoE against the same weights on the full sequences, with token dispatch on `cp2 x ep2`, `cp2 x fsdp2 x ep4`, `cp2 x fsdp2 x ep2` (efsdp), `cp2 x tp2 x ep4` and `cp2 x hsdp2 x ep2`, plus the all-reduce plan with `ep_size=tp_size=2` where the ep group holds one cp slice. Every case stays within four ranks.
1 parent f81544a commit 95dc69d

1 file changed

Lines changed: 96 additions & 0 deletions

File tree

‎tests/context_parallel/test_context_parallel.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
LlamaForSequenceClassification,
3131
LlamaModel,
3232
MistralConfig,
33+
Qwen3MoeConfig,
3334
)
3435
from transformers.distributed import DistributedConfig
3536
from transformers.distributed.context_parallel import (
@@ -406,6 +407,9 @@ def test_styles_are_registered(self):
406407

407408
CP_RTOL = 1e-5
408409
CP_ATOL = 1e-5
410+
# The all-reduce plan of `Qwen3Moe`, which overrides the token-dispatch rules of its own `ep_plan`: the router masks the
411+
# experts of the other ranks and the partial expert outputs are all-reduced.
412+
ALL_REDUCE_EP_PLAN = {"model.layers.*.mlp.gate": "ep_router", "model.layers.*.mlp.experts": "moe_tp_experts"}
409413

410414

411415
def _tiny_config(**kwargs):
@@ -521,6 +525,7 @@ def _compare_with_unsharded(init_dir, batch, distributed_config, keep=None):
521525
continue
522526
grad = _average_over_group(grad, groups, group_size)
523527
torch.testing.assert_close(grad, expected, rtol=CP_RTOL, atol=CP_ATOL, msg=f"Gradient mismatch for {name}")
528+
return model
524529

525530

526531
def _test_matches_unsharded_with_tp(rank, tp_size, cp_size, fsdp_size, packed):
@@ -600,6 +605,64 @@ def _test_padded(rank, cp_size, config=None, packed=False):
600605
dist.barrier()
601606

602607

608+
def _tiny_moe_config(**kwargs):
609+
"""A `Qwen3MoeConfig` small enough for CPU tests, whose default `ep_plan` dispatches the tokens to the experts."""
610+
return Qwen3MoeConfig(
611+
**{
612+
"hidden_size": 32,
613+
"moe_intermediate_size": 16,
614+
"num_hidden_layers": 2,
615+
"num_attention_heads": 4,
616+
"num_key_value_heads": 4,
617+
"vocab_size": 99,
618+
"num_experts": 8,
619+
"num_experts_per_tok": 2,
620+
"attn_implementation": "sdpa",
621+
**kwargs,
622+
}
623+
)
624+
625+
626+
def _test_matches_unsharded_with_ep(rank, cp_size, fsdp_size, ep_size, tp_size=1, dp_replicate_size=1):
627+
"""
628+
Token dispatch with context parallelism: every rank sends the tokens of its own slice to the owners of the experts
629+
they selected, and the experts' gradients reduce over the ranks that hold a copy of them.
630+
"""
631+
config = _tiny_moe_config()
632+
batch_size, seq_len = 2, 8
633+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
634+
rows = batch_size * fsdp_size * dp_replicate_size
635+
input_ids, _ = _tiny_batch(config, rows, seq_len)
636+
distributed_config = DistributedConfig(
637+
cp_size=cp_size,
638+
fsdp_size=fsdp_size,
639+
ep_size=ep_size,
640+
tp_size=tp_size,
641+
dp_replicate_size=dp_replicate_size,
642+
)
643+
batch = {"input_ids": input_ids, "labels": input_ids.clone(), "use_cache": False}
644+
model = _compare_with_unsharded(init_dir, batch, distributed_config)
645+
assert model.model.layers[0].mlp.experts.num_experts == config.num_experts // ep_size
646+
dist.barrier()
647+
648+
649+
def _test_matches_unsharded_with_all_reduce_ep(rank, cp_size, ep_size, tp_size):
650+
"""
651+
All-reduce experts need every rank of an ep group to see the same tokens: with `ep_size == tp_size` that group is
652+
the tp group, whose ranks hold the same cp slice.
653+
"""
654+
config = _tiny_moe_config()
655+
batch_size, seq_len = 2, 8
656+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
657+
input_ids, _ = _tiny_batch(config, batch_size, seq_len)
658+
distributed_config = DistributedConfig(
659+
cp_size=cp_size, ep_size=ep_size, tp_size=tp_size, ep_plan=ALL_REDUCE_EP_PLAN
660+
)
661+
batch = {"input_ids": input_ids, "labels": input_ids.clone(), "use_cache": False}
662+
_compare_with_unsharded(init_dir, batch, distributed_config)
663+
dist.barrier()
664+
665+
603666
def _test_inputs_embeds(rank, cp_size):
604667
"""`inputs_embeds` is sharded along its sequence dim, not along its last, hidden one."""
605668
config = _tiny_config()
@@ -761,6 +824,16 @@ def test_accepts_tensor_parallelism(self):
761824
# the tp ranks hold the same batch: they are not data parallel ranks
762825
self.assertEqual(distributed_config.dp_size, 2)
763826

827+
def test_accepts_expert_parallelism(self):
828+
distributed_config = DistributedConfig(cp_size=2, ep_size=2)
829+
self.assertEqual((distributed_config.cp_size, distributed_config.ep_size), (2, 2))
830+
# the cp ranks are data parallel ranks, and the experts shard over the axis ep leaves of fsdp * cp * tp
831+
self.assertEqual(distributed_config.dp_size, 2)
832+
self.assertEqual(distributed_config.efsdp_size, 1)
833+
self.assertEqual(DistributedConfig(cp_size=2, fsdp_size=2, ep_size=2).efsdp_size, 2)
834+
with self.assertRaisesRegex(ValueError, r"must divide `fsdp_size \* cp_size \* tp_size`"):
835+
DistributedConfig(cp_size=2, ep_size=4)
836+
764837
def test_refuses_size_below_one(self):
765838
with self.assertRaisesRegex(ValueError, "must be >= 1"):
766839
DistributedConfig(cp_size=0)
@@ -909,6 +982,29 @@ def test_matches_unsharded_with_tensor_parallelism(self):
909982
def test_tensor_parallelism_and_context_parallelism_compose(self):
910983
run_distributed(world_size=4, backend="gloo")(_test_tp_and_cp_compose)(2, 2)
911984

985+
def test_matches_unsharded_with_expert_parallelism(self):
986+
run_distributed(world_size=2, backend="gloo")(_test_matches_unsharded_with_ep)(2, 1, 2)
987+
988+
def test_matches_unsharded_with_fsdp_and_expert_parallelism(self):
989+
# ep_size = fsdp_size * cp_size, so efsdp_size is 1 and every expert lives whole on a single rank
990+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_ep)(2, 2, 4)
991+
992+
def test_matches_unsharded_with_expert_fsdp(self):
993+
# efsdp_size = fsdp_size * cp_size * tp_size / ep_size = 2: pairs of ranks hold the same experts, sharded
994+
# between them
995+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_ep)(2, 2, 2)
996+
997+
def test_matches_unsharded_with_tensor_parallelism_and_expert_parallelism(self):
998+
# ep_size = 2 * tp_size puts both cp slices of the batch, two ranks each, in the same ep group
999+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_ep)(2, 1, 4, 2)
1000+
1001+
def test_matches_unsharded_with_hsdp_and_expert_parallelism(self):
1002+
# the expert gradient reduces over efsdp and then over the two replicas: dp_size = 2 * 1 * 2 ranks
1003+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_ep)(2, 1, 2, 1, 2)
1004+
1005+
def test_matches_unsharded_with_all_reduce_expert_parallelism(self):
1006+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_all_reduce_ep)(2, 2, 2)
1007+
9121008
def test_inputs_embeds(self):
9131009
run_distributed(world_size=2, backend="gloo")(_test_inputs_embeds)(2)
9141010

0 commit comments

Comments
 (0)