@@ -352,6 +352,87 @@ def test_vision_context_parallel_rejects_process_group_mismatch():
352352 )
353353
354354
355+ class _FakeDynamicVisionModel (nn .Module ):
356+ """Returns one feature row per patch implied by the sizes it is handed."""
357+
358+ temporal_patch_dim = 1
359+ add_class_token = False
360+
361+ def __init__ (self , hidden_size : int , patch_dim : int ):
362+ super ().__init__ ()
363+ self .scale = nn .Parameter (torch .zeros (1 ))
364+ self .hidden_size = hidden_size
365+ self .patch_dim = patch_dim
366+ self .seen = {}
367+
368+ def forward (self , images , * , imgs_sizes , packed_seq_params , num_frames ):
369+ self .seen = {"images" : images , "imgs_sizes" : imgs_sizes , "num_frames" : num_frames }
370+ patches = sum (
371+ (int (height ) // self .patch_dim ) * (int (width ) // self .patch_dim ) for height , width in imgs_sizes .tolist ()
372+ )
373+ return torch .arange (patches * self .hidden_size , dtype = torch .float32 ).reshape (1 , patches , self .hidden_size )
374+
375+
376+ def test_sharded_encode_projects_locally_then_gathers_the_global_features ():
377+ # The distributed equivalence test covers the numerics, but it runs in a
378+ # torchrun subprocess. This exercises the same Bridge-side wiring in-process:
379+ # the split feeds the tower, the placeholder grid gets padded, and the
380+ # projector runs before the gather rather than after it.
381+ from megatron .bridge .models .nemotron_omni import modeling_nemotron_omni as modeling
382+
383+ hidden_size = 8
384+ patch_dim = 16
385+ model = NemotronOmniModel .__new__ (NemotronOmniModel )
386+ nn .Module .__init__ (model )
387+ model .patch_dim = patch_dim
388+ model .context_parallel_lm = 2
389+ model .vision_context_parallel = True
390+ model .config = SimpleNamespace (fp8_recipe = None )
391+ model .pg_collection = SimpleNamespace (cp = SimpleNamespace (size = lambda : 2 ))
392+ model .vision_model = _FakeDynamicVisionModel (hidden_size , patch_dim )
393+ model .vision_projection = nn .Linear (hidden_size * 4 , 5 , bias = False )
394+
395+ # This rank keeps a single 1x1-patch placeholder, the shape MCore produces
396+ # when a microbatch has fewer images than CP ranks.
397+ local_num_frames = torch .tensor ([1 ], dtype = torch .int32 )
398+ calls = {}
399+
400+ def fake_split (images , imgs_sizes , packed_seq_params , ** kwargs ):
401+ calls ["split" ] = kwargs
402+ local_sizes = torch .tensor ([[patch_dim , patch_dim ]], dtype = torch .int32 )
403+ return images [:, :1 , :], local_sizes , packed_seq_params , False , 1 , local_num_frames
404+
405+ def fake_gather (projected , num_padded_ranks ):
406+ calls ["gather" ] = {"width" : projected .shape [- 1 ], "num_padded_ranks" : num_padded_ranks }
407+ return projected
408+
409+ original_split = modeling .split_to_context_parallel_ranks_dynamic_res
410+ original_gather = modeling .gather_from_context_parallel_ranks_dynamic_res
411+ modeling .split_to_context_parallel_ranks_dynamic_res = fake_split
412+ modeling .gather_from_context_parallel_ranks_dynamic_res = fake_gather
413+ try :
414+ encoded = model ._encode_images (
415+ torch .randn (1 , 3 , 32 , 32 ),
416+ torch .tensor ([[32 , 32 ]], dtype = torch .int32 ),
417+ None ,
418+ None ,
419+ )
420+ finally :
421+ modeling .split_to_context_parallel_ranks_dynamic_res = original_split
422+ modeling .gather_from_context_parallel_ranks_dynamic_res = original_gather
423+
424+ assert calls ["split" ]["patch_dim" ] == patch_dim
425+ # The tower must see this rank's shard, including the frame counts the
426+ # splitter recomputed for it rather than the microbatch-wide ones.
427+ assert model .vision_model .seen ["images" ].shape [1 ] == 1
428+ assert model .vision_model .seen ["num_frames" ] is local_num_frames
429+ # A 1x1 placeholder grid only survives pixel shuffle once padded to 2x2.
430+ assert encoded .shape == (1 , 5 )
431+ # Width 5 is the projector's output, so the gather ran on projected
432+ # features; gathering first would have handed it the wider encoder output.
433+ assert calls ["gather" ] == {"width" : 5 , "num_padded_ranks" : 1 }
434+
435+
355436@pytest .mark .gpu
356437def test_vision_context_parallel_is_disabled_when_cp_is_one (single_rank_model_parallel ):
357438 # Sharding images over a one-rank CP group is a no-op wrapped in two
0 commit comments