Skip to content

Commit 2310aac

Browse files
committed
test(model): cover the sharded vision encode branch in-process
The CP=2 equivalence test exercises the split/project/gather path, but it runs in a torchrun subprocess that coverage cannot trace, leaving those lines reported as untested. Add a CPU-only test that stubs the MCore split and gather to drive the same Bridge-side wiring in-process: the tower receives this rank's shard and its recomputed frame counts, a 1x1 placeholder grid is padded before pixel shuffle, and the projector runs before the gather. Signed-off-by: Huy Vu <huvu@nvidia.com>
1 parent ab975bf commit 2310aac

1 file changed

Lines changed: 81 additions & 0 deletions

File tree

tests/unit_tests/models/nemotron_omni/test_nemotron_omni_model.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
356437
def 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

Comments
 (0)