1111# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212# See the License for the specific language governing permissions and
1313# limitations under the License.
14+ import types
1415import unittest
1516from unittest import mock
1617
4142 UlyssesCP ,
4243 all_to_all ,
4344 check_context_parallelism ,
45+ context_parallel_pre_forward ,
4446)
4547from transformers .distributed .parallel_utils import ALL_PARALLEL_STYLES
4648from transformers .distributed .utils import gather_full_state_dict
@@ -435,7 +437,8 @@ def _tiny_batch(config, batch_size, seq_len, packed=False, seed=0):
435437 if not packed :
436438 return input_ids , None
437439 # two documents back to back, which `position_ids` has to describe for the whole sequence
438- position_ids = torch .cat ([torch .arange (seq_len // 2 ), torch .arange (seq_len // 2 )]).expand (batch_size , - 1 )
440+ first = seq_len // 2
441+ position_ids = torch .cat ([torch .arange (first ), torch .arange (seq_len - first )]).expand (batch_size , - 1 )
439442 return input_ids , position_ids
440443
441444
@@ -483,7 +486,8 @@ def _compare_with_unsharded(init_dir, batch, distributed_config, keep=None):
483486 Run the full `batch` through an unsharded model and through the same weights sharded with context parallelism,
484487 then compare the loss, the logits and every gradient.
485488
486- `keep` masks the logits of the positions the comparison is about, e.g. the ones padding does not cover.
489+ `keep` is the 2D `attention_mask` of the unsharded batch: the logits of its padded positions are not part of what
490+ the model is trained on.
487491 """
488492 tokens = batch ["input_ids" ] if batch .get ("input_ids" ) is not None else batch ["inputs_embeds" ]
489493
@@ -506,11 +510,17 @@ def _compare_with_unsharded(init_dir, batch, distributed_config, keep=None):
506510 output = model (** local_batch )
507511 output .loss .backward ()
508512
509- # every rank computed the loss of the full sequences, and its own slice of the logits
513+ # every rank computed the loss of the full sequences, and its own slice of the logits of the padded sequence: the
514+ # padding at the end of the last rank's slice has no counterpart in the unsharded run
510515 cp_rank = model ._mesh_manager .get_mesh ("cp" ).get_local_rank ()
511- expected_logits = reference_logits [rows ].chunk (distributed_config .cp_size , dim = 1 )[cp_rank ]
512- logits = _local (output .logits )
516+ local_seq = output .logits .shape [1 ]
517+ start = cp_rank * local_seq
518+ # the real positions of this rank's slice, which can be none of them when the padding fills it
519+ real = max (0 , min (local_seq , reference_logits .shape [1 ] - start ))
520+ expected_logits = reference_logits [rows ][:, start : start + real ]
521+ logits = _local (output .logits )[:, :real ]
513522 if keep is not None :
523+ keep = (keep [:, start : start + real ] == 1 )[..., None ]
514524 expected_logits , logits = expected_logits * keep , logits * keep
515525 torch .testing .assert_close (logits , expected_logits , rtol = CP_RTOL , atol = CP_ATOL , msg = "Logits mismatch" )
516526
@@ -566,9 +576,10 @@ def _test_tp_and_cp_compose(rank, tp_size, cp_size):
566576 dist .barrier ()
567577
568578
569- def _test_matches_unsharded (rank , cp_size , fsdp_size , dp_replicate_size , packed ):
570- config = _tiny_config ()
571- batch_size , seq_len = 2 , 8
579+ def _test_matches_unsharded (rank , cp_size , fsdp_size , dp_replicate_size , packed , seq_len = 8 ):
580+ # Ulysses splits the heads over the ranks, so four ranks need four key/value heads
581+ config = _tiny_config (num_key_value_heads = 4 ) if cp_size > 2 else _tiny_config ()
582+ batch_size = 2
572583 with _deterministic_init_model_dir (rank , config , torch .float32 ) as init_dir :
573584 input_ids , position_ids = _tiny_batch (config , batch_size * fsdp_size * dp_replicate_size , seq_len , packed )
574585 distributed_config = DistributedConfig (
@@ -582,10 +593,10 @@ def _test_matches_unsharded(rank, cp_size, fsdp_size, dp_replicate_size, packed)
582593 dist .barrier ()
583594
584595
585- def _test_padded (rank , cp_size , config = None , packed = False ):
596+ def _test_padded (rank , cp_size , config = None , packed = False , seq_len = 8 ):
586597 """Left and right padding through a 2D `attention_mask`; the padded positions carry no label."""
587598 config = config or _tiny_config ()
588- batch_size , seq_len = 2 , 8
599+ batch_size = 2
589600 with _deterministic_init_model_dir (rank , config , torch .float32 ) as init_dir :
590601 input_ids , position_ids = _tiny_batch (config , batch_size , seq_len , packed )
591602 attention_mask = torch .ones_like (input_ids )
@@ -599,9 +610,7 @@ def _test_padded(rank, cp_size, config=None, packed=False):
599610 }
600611 if position_ids is not None :
601612 batch ["position_ids" ] = position_ids
602- # the logits of the padded positions are not part of what the model is trained on
603- keep = (attention_mask .chunk (cp_size , dim = 1 )[rank ] == 1 )[..., None ]
604- _compare_with_unsharded (init_dir , batch , DistributedConfig (cp_size = cp_size ), keep = keep )
613+ _compare_with_unsharded (init_dir , batch , DistributedConfig (cp_size = cp_size ), keep = attention_mask )
605614 dist .barrier ()
606615
607616
@@ -663,10 +672,10 @@ def _test_matches_unsharded_with_all_reduce_ep(rank, cp_size, ep_size, tp_size):
663672 dist .barrier ()
664673
665674
666- def _test_inputs_embeds (rank , cp_size ):
675+ def _test_inputs_embeds (rank , cp_size , seq_len = 8 ):
667676 """`inputs_embeds` is sharded along its sequence dim, not along its last, hidden one."""
668- config = _tiny_config ()
669- batch_size , seq_len = 2 , 8
677+ config = _tiny_config (num_key_value_heads = 4 ) if cp_size > 2 else _tiny_config ( )
678+ batch_size = 2
670679 with _deterministic_init_model_dir (rank , config , torch .float32 ) as init_dir :
671680 input_ids , _ = _tiny_batch (config , batch_size , seq_len )
672681 reference = AutoModelForCausalLM .from_pretrained (init_dir )
@@ -757,9 +766,6 @@ def _test_forward_refusals(rank, cp_size):
757766 model = AutoModelForCausalLM .from_pretrained (init_dir , distributed_config = DistributedConfig (cp_size = cp_size ))
758767 input_ids , _ = _tiny_batch (config , 2 , 8 )
759768
760- with pytest .raises (ValueError , match = "must be divisible by `cp_size`" ):
761- model (input_ids = input_ids [:, :- 1 ])
762-
763769 with pytest .raises (ValueError , match = "`past_key_values` are not allowed" ):
764770 model (input_ids = input_ids , past_key_values = DynamicCache ())
765771
@@ -809,6 +815,51 @@ def _test_save_load_distributed_checkpoint(rank, cp_size):
809815 dist .barrier ()
810816
811817
818+ class TestContextParallelPadding (unittest .TestCase ):
819+ """The root pre-hook pads every sequence-shaped input to a multiple of `cp_size`."""
820+
821+ def setUp (self ):
822+ self .addCleanup (ALL_ATTENTION_FUNCTIONS .register , "sdpa" , ALL_ATTENTION_FUNCTIONS ["sdpa" ])
823+
824+ def _prepare (self , module , args = (), ** kwargs ):
825+ return context_parallel_pre_forward (module , args , kwargs , cp_mesh = _MeshStub (2 ))
826+
827+ def _module (self , pad_token_id = 7 ):
828+ config = types .SimpleNamespace (_attn_implementation = "sdpa" , pad_token_id = pad_token_id )
829+ return types .SimpleNamespace (config = config )
830+
831+ def test_pads_input_ids_labels_and_the_mask (self ):
832+ input_ids = torch .tensor ([[1 , 2 , 3 , 4 , 5 ]])
833+ kwargs = {
834+ "input_ids" : input_ids ,
835+ "position_ids" : torch .tensor ([[0 , 1 , 2 , 0 , 1 ]]),
836+ "labels" : input_ids .clone (),
837+ "attention_mask" : torch .ones_like (input_ids ),
838+ }
839+ _ , padded = self ._prepare (self ._module (), ** kwargs )
840+
841+ # the id of the padding is the pad token, and the positions continue those of the last document
842+ torch .testing .assert_close (padded ["input_ids" ], torch .tensor ([[1 , 2 , 3 , 4 , 5 , 7 ]]))
843+ torch .testing .assert_close (padded ["position_ids" ], torch .tensor ([[0 , 1 , 2 , 0 , 1 , 2 ]]))
844+ torch .testing .assert_close (padded ["attention_mask" ], torch .tensor ([[1 , 1 , 1 , 1 , 1 , 0 ]]))
845+ torch .testing .assert_close (padded ["labels" ], torch .tensor ([[1 , 2 , 3 , 4 , 5 , - 100 ]]))
846+ assert padded ["use_cache" ] is False
847+
848+ def test_pads_positionally_and_without_a_pad_token (self ):
849+ input_ids = torch .tensor ([[1 , 2 , 3 , 4 , 5 ]])
850+ args , padded = self ._prepare (self ._module (pad_token_id = None ), (input_ids ,), labels = input_ids .clone ())
851+
852+ # no `pad_token_id` on the config: the padding token is 0, and a positional input stays positional
853+ torch .testing .assert_close (args [0 ], torch .tensor ([[1 , 2 , 3 , 4 , 5 , 0 ]]))
854+ torch .testing .assert_close (padded ["labels" ], torch .tensor ([[1 , 2 , 3 , 4 , 5 , - 100 ]]))
855+
856+ def test_leaves_a_divisible_sequence_alone (self ):
857+ input_ids = torch .tensor ([[1 , 2 , 3 , 4 , 5 , 6 ]])
858+ _ , padded = self ._prepare (self ._module (), input_ids = input_ids , labels = input_ids .clone ())
859+ torch .testing .assert_close (padded ["input_ids" ], input_ids )
860+ assert "position_ids" not in padded
861+
862+
812863@unittest .skipUnless (is_torch_greater_or_equal ("2.10" ), "Context parallelism requires torch>=2.10" )
813864class TestContextParallelConfig (unittest .TestCase ):
814865 def test_refuses_pipeline_parallelism (self ):
@@ -899,6 +950,20 @@ def test_padded(self):
899950 with self .subTest (packed = packed ):
900951 run_distributed (world_size = 2 , backend = "gloo" )(_test_padded )(2 , None , packed )
901952
953+ def test_padded_sequences (self ):
954+ for packed in (False , True ):
955+ with self .subTest (packed = packed ):
956+ run_distributed (world_size = 2 , backend = "gloo" )(_test_matches_unsharded )(2 , 1 , 1 , packed , 7 )
957+
958+ def test_padded_sequences_with_four_ranks (self ):
959+ run_distributed (world_size = 4 , backend = "gloo" )(_test_matches_unsharded )(4 , 1 , 1 , False , 5 )
960+
961+ def test_padded_sequences_with_a_padding_mask (self ):
962+ run_distributed (world_size = 2 , backend = "gloo" )(_test_padded )(2 , None , False , 7 )
963+
964+ def test_padded_inputs_embeds (self ):
965+ run_distributed (world_size = 2 , backend = "gloo" )(_test_inputs_embeds )(2 , 7 )
966+
902967 def test_sliding_window (self ):
903968 config = MistralConfig (
904969 hidden_size = 32 ,
0 commit comments