|
24 | 24 | from torch.distributed.device_mesh import init_device_mesh |
25 | 25 |
|
26 | 26 | from transformers import ( |
| 27 | + AutoModelForCausalLM, |
27 | 28 | DynamicCache, |
| 29 | + Gemma3TextConfig, |
28 | 30 | LlamaConfig, |
29 | 31 | LlamaForCausalLM, |
30 | 32 | LlamaForSequenceClassification, |
31 | 33 | LlamaModel, |
32 | 34 | MistralConfig, |
33 | 35 | ) |
| 36 | +from transformers.distributed import DistributedConfig |
34 | 37 | from transformers.distributed.context_parallel import ( |
35 | 38 | ContextParallelAttention, |
36 | 39 | ModelContextParallel, |
|
40 | 43 | context_parallel_styles, |
41 | 44 | ) |
42 | 45 | from transformers.distributed.parallel_utils import ALL_PARALLEL_STYLES |
| 46 | +from transformers.distributed.utils import gather_full_state_dict |
43 | 47 | from transformers.integrations.sdpa_attention import sdpa_attention_forward |
44 | 48 | from transformers.loss.loss_utils import ForCausalLMLoss |
45 | 49 | from transformers.masking_utils import create_causal_mask, create_sliding_window_causal_mask |
46 | 50 | from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS |
47 | 51 | from transformers.testing_utils import is_fsdp_test |
| 52 | +from transformers.utils import is_torch_greater_or_equal |
48 | 53 |
|
49 | 54 | from ..distributed_testing_utils import run_distributed |
| 55 | +from ..test_fsdp_mixin import _deterministic_init_model_dir, _distributed_tmpdir |
50 | 56 |
|
51 | 57 |
|
52 | 58 | class _MeshStub: |
@@ -487,3 +493,345 @@ def test_styles_are_registered(self): |
487 | 493 | def test_a_registered_attention_style_is_a_context_parallel_style(self): |
488 | 494 | with mock.patch.dict(ALL_PARALLEL_STYLES._global_mapping, {"cp_spy": _SpyAttention()}): |
489 | 495 | 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