Skip to content

Commit e2b848d

Browse files
committed
[distributed] Test context parallelism end to end
Each case compares a 2-rank run against the same weights on the full sequences: loss, this rank's slice of the logits and every gradient, for plain, packed and `inputs_embeds` batches, with `fsdp_size=2` and `dp_replicate_size=2`, and with left and right padding through a 2D `attention_mask`. Plus a sliding-window Mistral and a Gemma3 mix of sliding and full layers with `sdpa`, a custom and an unknown `cp_implementation`, the refusals, and the save / distributed checkpoint round trips.
1 parent 8442714 commit e2b848d

1 file changed

Lines changed: 348 additions & 0 deletions

File tree

‎tests/context_parallel/test_context_parallel.py‎

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@
2424
from torch.distributed.device_mesh import init_device_mesh
2525

2626
from transformers import (
27+
AutoModelForCausalLM,
2728
DynamicCache,
29+
Gemma3TextConfig,
2830
LlamaConfig,
2931
LlamaForCausalLM,
3032
LlamaForSequenceClassification,
3133
LlamaModel,
3234
MistralConfig,
3335
)
36+
from transformers.distributed import DistributedConfig
3437
from transformers.distributed.context_parallel import (
3538
ContextParallelAttention,
3639
ModelContextParallel,
@@ -40,13 +43,16 @@
4043
context_parallel_styles,
4144
)
4245
from transformers.distributed.parallel_utils import ALL_PARALLEL_STYLES
46+
from transformers.distributed.utils import gather_full_state_dict
4347
from transformers.integrations.sdpa_attention import sdpa_attention_forward
4448
from transformers.loss.loss_utils import ForCausalLMLoss
4549
from transformers.masking_utils import create_causal_mask, create_sliding_window_causal_mask
4650
from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
4751
from transformers.testing_utils import is_fsdp_test
52+
from transformers.utils import is_torch_greater_or_equal
4853

4954
from ..distributed_testing_utils import run_distributed
55+
from ..test_fsdp_mixin import _deterministic_init_model_dir, _distributed_tmpdir
5056

5157

5258
class _MeshStub:
@@ -487,3 +493,345 @@ def test_styles_are_registered(self):
487493
def test_a_registered_attention_style_is_a_context_parallel_style(self):
488494
with mock.patch.dict(ALL_PARALLEL_STYLES._global_mapping, {"cp_spy": _SpyAttention()}):
489495
self.assertIn("cp_spy", context_parallel_styles())
496+
497+
498+
# =============================================================================
499+
# A model sharded with context parallelism, against the same weights on full sequences
500+
# =============================================================================
501+
502+
CP_RTOL = 1e-5
503+
CP_ATOL = 1e-5
504+
505+
506+
def _tiny_batch(config, batch_size, seq_len, packed=False, seed=0):
507+
"""A batch every rank builds alike, with `batch_size` rows for each data parallel rank."""
508+
generator = torch.Generator().manual_seed(seed)
509+
input_ids = torch.randint(0, config.vocab_size, (batch_size, seq_len), generator=generator)
510+
if not packed:
511+
return input_ids, None
512+
# two documents back to back, which `position_ids` has to describe for the whole sequence
513+
position_ids = torch.cat([torch.arange(seq_len // 2), torch.arange(seq_len // 2)]).expand(batch_size, -1)
514+
return input_ids, position_ids
515+
516+
517+
def _local(tensor):
518+
"""The full value of a tensor FSDP2 holds as a `DTensor`, or `tensor` itself."""
519+
return tensor.full_tensor() if hasattr(tensor, "full_tensor") else tensor
520+
521+
522+
def _data_parallel_rank_and_group(model, distributed_config):
523+
"""
524+
Which rows of the batch this rank holds, and the group whose FSDP2 gradient contributions add up to the batch.
525+
526+
The cp ranks of a cp group hold the same rows: they are part of the averaging group but not of the row split.
527+
"""
528+
mesh_manager = model._mesh_manager
529+
fsdp_size, dp_replicate_size = distributed_config.fsdp_size, distributed_config.dp_replicate_size
530+
cp_size = distributed_config.cp_size
531+
dp_rank = 0
532+
if dp_replicate_size > 1:
533+
dp_rank = mesh_manager.get_mesh("dp_replicate").get_local_rank() * fsdp_size
534+
if fsdp_size > 1:
535+
dp_rank += mesh_manager.get_mesh("fsdp").get_local_rank()
536+
537+
group_size = dp_replicate_size * fsdp_size
538+
if distributed_config.cp_size > 1:
539+
dims = ("dp_replicate", "fsdp_cp") if dp_replicate_size > 1 else "fsdp_cp"
540+
mesh = mesh_manager.get_mesh(dims)
541+
return dp_rank, [mesh.get_group(dim) for dim in mesh.mesh_dim_names], group_size * cp_size
542+
543+
if group_size == 1:
544+
return dp_rank, [], 1
545+
mesh = mesh_manager.get_mesh("dp_replicate" if dp_replicate_size > 1 else "fsdp")
546+
return dp_rank, [mesh.get_group(dim) for dim in mesh.mesh_dim_names], group_size
547+
548+
549+
def _average_over_group(tensor, groups, group_size):
550+
"""Undo FSDP2's gradient averaging so the value covers the whole batch."""
551+
for group in groups:
552+
dist.all_reduce(tensor, group=group)
553+
return tensor / group_size
554+
555+
556+
def _compare_with_unsharded(init_dir, batch, distributed_config, keep=None):
557+
"""
558+
Run the full `batch` through an unsharded model and through the same weights sharded with context parallelism,
559+
then compare the loss, the logits and every gradient.
560+
561+
`keep` masks the logits of the positions the comparison is about, e.g. the ones padding does not cover.
562+
"""
563+
tokens = batch["input_ids"] if batch.get("input_ids") is not None else batch["inputs_embeds"]
564+
565+
reference = AutoModelForCausalLM.from_pretrained(init_dir)
566+
reference_output = reference(**batch)
567+
reference_output.loss.backward()
568+
reference_grads = {
569+
name: param.grad.clone() for name, param in reference.named_parameters() if param.grad is not None
570+
}
571+
reference_loss, reference_logits = reference_output.loss.detach(), reference_output.logits.detach()
572+
del reference
573+
574+
model = AutoModelForCausalLM.from_pretrained(init_dir, distributed_config=distributed_config)
575+
rows_per_rank = tokens.shape[0] // (distributed_config.fsdp_size * distributed_config.dp_replicate_size)
576+
dp_rank, groups, group_size = _data_parallel_rank_and_group(model, distributed_config)
577+
rows = slice(dp_rank * rows_per_rank, (dp_rank + 1) * rows_per_rank)
578+
# every entry is a full-sequence tensor on its first, batch dimension, apart from the flags
579+
local_batch = {name: value[rows] if isinstance(value, torch.Tensor) else value for name, value in batch.items()}
580+
581+
output = model(**local_batch)
582+
output.loss.backward()
583+
584+
# every rank computed the loss of the full sequences, and its own slice of the logits
585+
cp_rank = model._mesh_manager.get_mesh("cp").get_local_rank()
586+
expected_logits = reference_logits[rows].chunk(distributed_config.cp_size, dim=1)[cp_rank]
587+
logits = _local(output.logits)
588+
if keep is not None:
589+
expected_logits, logits = expected_logits * keep, logits * keep
590+
torch.testing.assert_close(logits, expected_logits, rtol=CP_RTOL, atol=CP_ATOL, msg="Logits mismatch")
591+
592+
loss = _average_over_group(output.loss.detach(), groups, group_size)
593+
torch.testing.assert_close(loss, reference_loss, rtol=CP_RTOL, atol=CP_ATOL, msg="Loss mismatch")
594+
595+
for name, param in model.named_parameters():
596+
grad = None if param.grad is None else _local(param.grad).float()
597+
expected = reference_grads.get(name)
598+
if grad is None or expected is None:
599+
assert grad is None and expected is None, f"Only one of the two runs has a gradient for {name}"
600+
continue
601+
grad = _average_over_group(grad, groups, group_size)
602+
torch.testing.assert_close(grad, expected, rtol=CP_RTOL, atol=CP_ATOL, msg=f"Gradient mismatch for {name}")
603+
604+
605+
def _test_matches_unsharded(rank, cp_size, fsdp_size, dp_replicate_size, packed):
606+
config = _tiny_config()
607+
batch_size, seq_len = 2, 8
608+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
609+
input_ids, position_ids = _tiny_batch(config, batch_size * fsdp_size * dp_replicate_size, seq_len, packed)
610+
distributed_config = DistributedConfig(
611+
cp_size=cp_size, fsdp_size=fsdp_size, dp_replicate_size=dp_replicate_size
612+
)
613+
batch = {"input_ids": input_ids, "labels": input_ids.clone(), "use_cache": False}
614+
if position_ids is not None:
615+
batch["position_ids"] = position_ids
616+
_compare_with_unsharded(init_dir, batch, distributed_config)
617+
# `init_dir` is rank 0's temporary directory: wait for the other ranks before it is removed
618+
dist.barrier()
619+
620+
621+
def _test_padded(rank, cp_size, config=None, packed=False):
622+
"""Left and right padding through a 2D `attention_mask`; the padded positions carry no label."""
623+
config = config or _tiny_config()
624+
batch_size, seq_len = 2, 8
625+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
626+
input_ids, position_ids = _tiny_batch(config, batch_size, seq_len, packed)
627+
attention_mask = torch.ones_like(input_ids)
628+
attention_mask[0, -3:] = 0 # right padding
629+
attention_mask[1, :2] = 0 # left padding
630+
batch = {
631+
"input_ids": input_ids,
632+
"attention_mask": attention_mask,
633+
"labels": input_ids.masked_fill(attention_mask == 0, -100),
634+
"use_cache": False,
635+
}
636+
if position_ids is not None:
637+
batch["position_ids"] = position_ids
638+
# the logits of the padded positions are not part of what the model is trained on
639+
keep = (attention_mask.chunk(cp_size, dim=1)[rank] == 1)[..., None]
640+
_compare_with_unsharded(init_dir, batch, DistributedConfig(cp_size=cp_size), keep=keep)
641+
dist.barrier()
642+
643+
644+
def _test_inputs_embeds(rank, cp_size):
645+
"""`inputs_embeds` is sharded along its sequence dim, not along its last, hidden one."""
646+
config = _tiny_config()
647+
batch_size, seq_len = 2, 8
648+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
649+
input_ids, _ = _tiny_batch(config, batch_size, seq_len)
650+
reference = AutoModelForCausalLM.from_pretrained(init_dir)
651+
inputs_embeds = reference.get_input_embeddings()(input_ids).detach()
652+
del reference
653+
654+
batch = {"inputs_embeds": inputs_embeds, "labels": input_ids.clone(), "use_cache": False}
655+
_compare_with_unsharded(init_dir, batch, DistributedConfig(cp_size=cp_size))
656+
dist.barrier()
657+
658+
659+
class _CountingUlysses(UlyssesAttention):
660+
"""An Ulysses style that counts the attention calls routed through it."""
661+
662+
def __init__(self):
663+
self.attention_calls = 0
664+
665+
def attention(self, *args, **kwargs):
666+
self.attention_calls += 1
667+
return super().attention(*args, **kwargs)
668+
669+
670+
def _test_custom_attention_style(rank, cp_size):
671+
"""A registered attention style runs the attention when the plan names it; an unknown style is refused."""
672+
config = _tiny_config()
673+
counting = _CountingUlysses()
674+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
675+
with mock.patch.dict(ALL_PARALLEL_STYLES._global_mapping, {"cp_counting": counting}):
676+
distributed_config = DistributedConfig(
677+
cp_size=cp_size, cp_plan={"model.layers.*.self_attn": "cp_counting"}
678+
)
679+
model = AutoModelForCausalLM.from_pretrained(init_dir, distributed_config=distributed_config)
680+
681+
input_ids, _ = _tiny_batch(config, 2, 8)
682+
model(input_ids=input_ids, labels=input_ids, use_cache=False)
683+
# every attention module runs through the style, once per forward
684+
assert all(layer.self_attn.cp_attention.func == counting.attention for layer in model.model.layers)
685+
assert counting.attention_calls == len(model.model.layers), counting.attention_calls
686+
687+
unknown = DistributedConfig(cp_size=cp_size, cp_plan={"model.layers.*.self_attn": "cp_missing"})
688+
with pytest.raises(ValueError, match="Unsupported `cp_plan` styles"):
689+
AutoModelForCausalLM.from_pretrained(init_dir, distributed_config=unknown)
690+
dist.barrier()
691+
692+
693+
def _test_save_pretrained(rank, cp_size):
694+
"""Weights saved from a context parallel model reload without a distributed config, unchanged."""
695+
config = _tiny_config()
696+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
697+
reference = AutoModelForCausalLM.from_pretrained(init_dir)
698+
expected = {name: value.clone() for name, value in reference.state_dict().items()}
699+
del reference
700+
701+
model = AutoModelForCausalLM.from_pretrained(init_dir, distributed_config=DistributedConfig(cp_size=cp_size))
702+
with _distributed_tmpdir(rank) as save_dir:
703+
model.save_pretrained(save_dir, is_main_process=(rank == 0))
704+
dist.barrier()
705+
reloaded = AutoModelForCausalLM.from_pretrained(save_dir)
706+
# rank 0 removes `save_dir` when it leaves this block
707+
dist.barrier()
708+
709+
for name, value in reloaded.state_dict().items():
710+
torch.testing.assert_close(value, expected[name], rtol=0, atol=0, msg=f"Weight mismatch for {name}")
711+
dist.barrier()
712+
713+
714+
def _test_save_load_distributed_checkpoint(rank, cp_size):
715+
"""A rank-local checkpoint written by a context parallel model loads back into the same sharding."""
716+
config = _tiny_config()
717+
distributed_config = DistributedConfig(cp_size=cp_size)
718+
with _deterministic_init_model_dir(rank, config, torch.float32) as init_dir:
719+
model = AutoModelForCausalLM.from_pretrained(init_dir, distributed_config=distributed_config)
720+
before = gather_full_state_dict(model)
721+
with _distributed_tmpdir(rank) as save_dir:
722+
model.save_pretrained(save_dir, is_main_process=(rank == 0), distributed_checkpoint=True)
723+
dist.barrier()
724+
reloaded = AutoModelForCausalLM.from_pretrained(save_dir, distributed_config=distributed_config)
725+
dist.barrier()
726+
after = gather_full_state_dict(reloaded)
727+
728+
for name, value in before.items():
729+
torch.testing.assert_close(value, after[name], rtol=0, atol=0, msg=f"Weight mismatch for {name}")
730+
dist.barrier()
731+
732+
733+
@unittest.skipUnless(is_torch_greater_or_equal("2.10"), "Context parallelism requires torch>=2.10")
734+
class TestContextParallelConfig(unittest.TestCase):
735+
def test_refuses_other_parallelisms(self):
736+
for other in ({"pp_size": 2}, {"tp_size": 2}, {"ep_size": 2}):
737+
with self.subTest(**other), self.assertRaisesRegex(ValueError, "not supported yet with"):
738+
DistributedConfig(cp_size=2, **other)
739+
740+
def test_refuses_a_model_without_a_cp_plan(self):
741+
config = _tiny_config()
742+
config.base_model_cp_plan = None
743+
model = LlamaForCausalLM(config)
744+
with self.assertRaisesRegex(ValueError, r"`cp_size=2` was requested, but `LlamaForCausalLM` has no `cp_plan`"):
745+
model._resolve_parallel_plans(DistributedConfig(cp_size=2))
746+
747+
748+
@unittest.skipUnless(is_torch_greater_or_equal("2.10"), "Context parallelism requires torch>=2.10")
749+
class TestContextParallelPlan(unittest.TestCase):
750+
def test_resolves_the_default_plan(self):
751+
model = LlamaForCausalLM(_tiny_config())
752+
_, _, _, cp_plan = model._resolve_parallel_plans(DistributedConfig(cp_size=2))
753+
expected = {}
754+
for layer in range(model.config.num_hidden_layers):
755+
expected[f"model.layers.{layer}"] = "cp_split_sequence"
756+
expected[f"model.layers.{layer}.self_attn"] = "cp_ulysses"
757+
self.assertEqual(cp_plan, expected)
758+
759+
def test_refuses_a_plan_missing_a_style(self):
760+
for base_model_cp_plan, missing in (
761+
({"layers.*": "cp_split_sequence"}, ["cp_ulysses"]),
762+
({"layers.*.self_attn": "cp_ulysses"}, ["cp_split_sequence"]),
763+
):
764+
config = _tiny_config()
765+
config.base_model_cp_plan = base_model_cp_plan
766+
model = LlamaForCausalLM(config)
767+
with self.subTest(missing=missing), self.assertRaisesRegex(ValueError, rf"has no `{missing[0]}` rule"):
768+
model._resolve_parallel_plans(DistributedConfig(cp_size=2))
769+
770+
771+
@is_fsdp_test
772+
class TestContextParallelModel(unittest.TestCase):
773+
"""A model sharded with context parallelism reproduces the same run on the full sequences."""
774+
775+
def setUp(self):
776+
if torch._C._get_accelerator().type == "mps":
777+
self.skipTest("Distributed meshes are built on the default accelerator, which MPS cannot back")
778+
if not is_torch_greater_or_equal("2.10"):
779+
self.skipTest("Context parallelism requires torch>=2.10")
780+
781+
def test_matches_unsharded(self):
782+
for packed in (False, True):
783+
with self.subTest(packed=packed):
784+
run_distributed(world_size=2, backend="gloo")(_test_matches_unsharded)(2, 1, 1, packed)
785+
786+
def test_matches_unsharded_with_fsdp(self):
787+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded)(2, 2, 1, False)
788+
789+
def test_matches_unsharded_with_hsdp(self):
790+
run_distributed(world_size=4, backend="gloo")(_test_matches_unsharded)(2, 1, 2, False)
791+
792+
def test_padded(self):
793+
for packed in (False, True):
794+
with self.subTest(packed=packed):
795+
run_distributed(world_size=2, backend="gloo")(_test_padded)(2, None, packed)
796+
797+
def test_sliding_window(self):
798+
config = MistralConfig(
799+
hidden_size=32,
800+
intermediate_size=64,
801+
num_hidden_layers=2,
802+
num_attention_heads=4,
803+
num_key_value_heads=2,
804+
vocab_size=99,
805+
sliding_window=4,
806+
attn_implementation="sdpa",
807+
)
808+
for packed in (False, True):
809+
with self.subTest(packed=packed):
810+
run_distributed(world_size=2, backend="gloo")(_test_padded)(2, config, packed)
811+
812+
def test_sliding_window_and_full_layers(self):
813+
config = Gemma3TextConfig(
814+
hidden_size=32,
815+
intermediate_size=64,
816+
num_hidden_layers=4,
817+
num_attention_heads=4,
818+
num_key_value_heads=2,
819+
head_dim=8,
820+
vocab_size=99,
821+
sliding_window=4,
822+
sliding_window_pattern=2,
823+
attn_implementation="sdpa",
824+
)
825+
run_distributed(world_size=2, backend="gloo")(_test_padded)(2, config, False)
826+
827+
def test_inputs_embeds(self):
828+
run_distributed(world_size=2, backend="gloo")(_test_inputs_embeds)(2)
829+
830+
def test_custom_attention_style(self):
831+
run_distributed(world_size=2, backend="gloo")(_test_custom_attention_style)(2)
832+
833+
def test_save_pretrained(self):
834+
run_distributed(world_size=2, backend="gloo")(_test_save_pretrained)(2)
835+
836+
def test_save_load_distributed_checkpoint(self):
837+
run_distributed(world_size=2, backend="gloo")(_test_save_load_distributed_checkpoint)(2)

0 commit comments

Comments
 (0)