Skip to content

Commit d0d2eb7

Browse files
committed
[distributed] Test context parallelism with expert parallelism
A tiny Qwen3 MoE against the same weights on full sequences: loss, this rank's logits slice and every gradient, with token dispatch (cp2 x ep2, cp2 x fsdp2 x ep4, cp2 x fsdp2 x ep2, cp2 x tp2 x ep4, cp2 x hsdp2 x ep2) and with the all-reduce plan (tp2 x cp2 x ep2).
1 parent ce21f3a commit d0d2eb7

1 file changed

Lines changed: 99 additions & 4 deletions

File tree

‎tests/context_parallel/test_context_parallel.py‎

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
LlamaForSequenceClassification,
2929
LlamaModel,
3030
MistralConfig,
31+
Qwen3MoeConfig,
3132
)
3233
from transformers.distributed import DistributedConfig, context_parallel
3334
from transformers.distributed.context_parallel import (
@@ -446,6 +447,9 @@ def test_packed_flash_attention(self):
446447

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

450454

451455
def _tiny_batch(config, batch_size, seq_len, packed=False, seed=0):
@@ -531,6 +535,7 @@ def _compare_with_unsharded(init_dir, batch, distributed_config, model_class=Aut
531535
continue
532536
grad = _average_over_data_parallel(grad, dp_group, fsdp_size * dp_replicate_size)
533537
torch.testing.assert_close(grad, expected, rtol=CP_RTOL, atol=CP_ATOL, msg=f"Gradient mismatch for {name}")
538+
return model
534539

535540

536541
def _test_matches_unsharded(rank, cp_size, fsdp_size, dp_replicate_size, packed):
@@ -565,6 +570,64 @@ def _test_matches_unsharded_with_tp(rank, tp_size, cp_size, fsdp_size, packed):
565570
dist.barrier()
566571

567572

573+
def _tiny_moe_config(**kwargs):
574+
"""A `Qwen3MoeConfig` small enough for CPU tests, whose default `ep_plan` dispatches the tokens to the experts."""
575+
return Qwen3MoeConfig(
576+
**{
577+
"hidden_size": 32,
578+
"moe_intermediate_size": 16,
579+
"num_hidden_layers": 2,
580+
"num_attention_heads": 4,
581+
"num_key_value_heads": 4,
582+
"vocab_size": 99,
583+
"num_experts": 8,
584+
"num_experts_per_tok": 2,
585+
"attn_implementation": "sdpa",
586+
**kwargs,
587+
}
588+
)
589+
590+
591+
def _test_matches_unsharded_with_ep(rank, cp_size, fsdp_size, ep_size, tp_size=1, dp_replicate_size=1):
592+
"""
593+
Token dispatch with context parallelism: every rank sends the tokens of its own slice to the owners of the experts
594+
they selected, and the experts' gradients reduce over the ranks that hold a copy of them.
595+
"""
596+
config = _tiny_moe_config()
597+
batch_size, seq_len = 2, 8
598+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
599+
rows = batch_size * fsdp_size * dp_replicate_size
600+
input_ids, _ = _tiny_batch(config, rows, seq_len)
601+
distributed_config = DistributedConfig(
602+
cp_size=cp_size,
603+
fsdp_size=fsdp_size,
604+
ep_size=ep_size,
605+
tp_size=tp_size,
606+
dp_replicate_size=dp_replicate_size,
607+
)
608+
batch = {"input_ids": input_ids, "labels": input_ids.clone(), "use_cache": False}
609+
model = _compare_with_unsharded(init_dir, batch, distributed_config)
610+
assert model.model.layers[0].mlp.experts.num_experts == config.num_experts // ep_size
611+
dist.barrier()
612+
613+
614+
def _test_matches_unsharded_with_all_reduce_ep(rank, cp_size, ep_size, tp_size):
615+
"""
616+
All-reduce experts need every rank of an ep group to see the same tokens: with `ep_size == tp_size` that group is
617+
the tp group, whose ranks hold the same cp slice.
618+
"""
619+
config = _tiny_moe_config()
620+
batch_size, seq_len = 2, 8
621+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
622+
input_ids, _ = _tiny_batch(config, batch_size, seq_len)
623+
distributed_config = DistributedConfig(
624+
cp_size=cp_size, ep_size=ep_size, tp_size=tp_size, ep_plan=ALL_REDUCE_EP_PLAN
625+
)
626+
batch = {"input_ids": input_ids, "labels": input_ids.clone(), "use_cache": False}
627+
_compare_with_unsharded(init_dir, batch, distributed_config)
628+
dist.barrier()
629+
630+
568631
def _test_inputs_embeds(rank, cp_size):
569632
"""`inputs_embeds` is sharded along its sequence dim, not along its last, hidden one."""
570633
config = _tiny_config()
@@ -726,17 +789,26 @@ def setUp(self):
726789
if not is_torch_greater_or_equal("2.10"):
727790
self.skipTest("Context parallelism requires torch>=2.10")
728791

729-
def test_refuses_other_parallelisms(self):
730-
for other in ({"pp_size": 2}, {"ep_size": 2}):
731-
with self.subTest(**other), self.assertRaisesRegex(ValueError, "not supported yet with"):
732-
DistributedConfig(cp_size=2, **other)
792+
def test_refuses_pipeline_parallelism(self):
793+
with self.assertRaisesRegex(ValueError, "not supported yet with `pp_size=2`"):
794+
DistributedConfig(cp_size=2, pp_size=2)
733795

734796
def test_accepts_tensor_parallelism(self):
735797
distributed_config = DistributedConfig(tp_size=2, cp_size=2)
736798
self.assertEqual((distributed_config.tp_size, distributed_config.cp_size), (2, 2))
737799
# the tp ranks hold the same batch: they are not data parallel ranks
738800
self.assertEqual(distributed_config.dp_size, 2)
739801

802+
def test_accepts_expert_parallelism(self):
803+
distributed_config = DistributedConfig(cp_size=2, ep_size=2)
804+
self.assertEqual((distributed_config.cp_size, distributed_config.ep_size), (2, 2))
805+
# the cp ranks are data parallel ranks, and the experts shard over the axis ep leaves of fsdp * cp * tp
806+
self.assertEqual(distributed_config.dp_size, 2)
807+
self.assertEqual(distributed_config.efsdp_size, 1)
808+
self.assertEqual(DistributedConfig(cp_size=2, fsdp_size=2, ep_size=2).efsdp_size, 2)
809+
with self.assertRaisesRegex(ValueError, r"must divide `fsdp_size \* cp_size \* tp_size`"):
810+
DistributedConfig(cp_size=2, ep_size=4)
811+
740812
def test_refuses_size_below_one(self):
741813
with self.assertRaisesRegex(ValueError, "must be >= 1"):
742814
DistributedConfig(cp_size=0)
@@ -770,6 +842,29 @@ def test_matches_unsharded_with_tensor_parallelism(self):
770842
def test_matches_unsharded_with_tensor_parallelism_and_fsdp(self):
771843
run_distributed(world_size=8, backend="gloo")(_test_matches_unsharded_with_tp)(2, 2, 2, False)
772844

845+
def test_matches_unsharded_with_expert_parallelism(self):
846+
run_distributed(world_size=2, backend="gloo")(_test_matches_unsharded_with_ep)(2, 1, 2)
847+
848+
def test_matches_unsharded_with_fsdp_and_expert_parallelism(self):
849+
# ep_size = fsdp_size * cp_size, so efsdp_size is 1 and every expert lives whole on a single rank
850+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_ep)(2, 2, 4)
851+
852+
def test_matches_unsharded_with_expert_fsdp(self):
853+
# efsdp_size = fsdp_size * cp_size * tp_size / ep_size = 2: pairs of ranks hold the same experts, sharded
854+
# between them
855+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_ep)(2, 2, 2)
856+
857+
def test_matches_unsharded_with_tensor_parallelism_and_expert_parallelism(self):
858+
# ep_size = 2 * tp_size puts both cp slices of the batch, two ranks each, in the same ep group
859+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_ep)(2, 1, 4, 2)
860+
861+
def test_matches_unsharded_with_hsdp_and_expert_parallelism(self):
862+
# the expert gradient reduces over efsdp and then over the two replicas: dp_size = 2 * 1 * 2 ranks
863+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_ep)(2, 1, 2, 1, 2)
864+
865+
def test_matches_unsharded_with_all_reduce_expert_parallelism(self):
866+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded_with_all_reduce_ep)(2, 2, 2)
867+
773868
def test_inputs_embeds(self):
774869
run_distributed(world_size=2, backend="gloo")(_test_inputs_embeds)(2)
775870

0 commit comments

Comments
 (0)