725 lines
30 KiB
Python
725 lines
30 KiB
Python
# Copyright (c) Microsoft Corporation.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
# DeepSpeed Team
|
|
"""
|
|
Unit tests for AutoSP multimodal sequence parallelism:
|
|
- autosp_detector: model scanning
|
|
- UlyssesSPViTAttention: ViT SP wrapper
|
|
- auto_wrap_model_for_sp: end-to-end wrapping
|
|
- ModalityFusionSPAdapter: cross-modal gather/scatter
|
|
- LlavaFusionAdapter: LLaVA-style visual token splice
|
|
- InternVLFusionAdapter: InternVL-style IMG_CONTEXT token splice
|
|
- Qwen2VLFusionAdapter: Qwen2-VL vision_start/end bounded splice
|
|
"""
|
|
|
|
import pytest
|
|
import torch
|
|
import torch.nn as nn
|
|
|
|
from deepspeed.sequence.autosp_detector import (SPModelInfo, _LLM_ATTN_CLASSNAMES, _VIT_ATTN_CLASSNAMES,
|
|
detect_model_sp_info)
|
|
from deepspeed.sequence.autosp_fusion import (InternVLFusionAdapter, LlavaFusionAdapter, ModalityFusionSPAdapter,
|
|
Qwen2VLFusionAdapter)
|
|
from deepspeed.sequence.autosp_vit import UlyssesSPViTAttention
|
|
from deepspeed.sequence.auto_sp import _set_module_by_name, auto_wrap_model_for_sp
|
|
from deepspeed.sequence.layer import DistributedAttention
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Minimal fake modules that mimic the interface of real attention layers
|
|
# without requiring a GPU or a real transformer model.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeViTAttn(nn.Module):
|
|
"""Identity ViT attention — returns hidden_states unchanged."""
|
|
|
|
def forward(self, hidden_states, **kwargs):
|
|
return hidden_states
|
|
|
|
|
|
class _FakeViTAttnTuple(nn.Module):
|
|
"""ViT attention that returns a (output, weights) tuple."""
|
|
|
|
def forward(self, hidden_states, **kwargs):
|
|
weights = torch.zeros(hidden_states.shape[0], 1, hidden_states.shape[1], hidden_states.shape[1])
|
|
return hidden_states, weights
|
|
|
|
|
|
class _FakeLLMAttn(nn.Module):
|
|
"""Identity LLM attention."""
|
|
|
|
def forward(self, query, key, value, *args, **kwargs):
|
|
return query
|
|
|
|
|
|
# Register fake class names so the detector recognises them
|
|
_VIT_ATTN_CLASSNAMES.add("_FakeViTAttn")
|
|
_VIT_ATTN_CLASSNAMES.add("_FakeViTAttnTuple")
|
|
_LLM_ATTN_CLASSNAMES.add("_FakeLLMAttn")
|
|
|
|
|
|
class _FakeMultimodalModel(nn.Module):
|
|
"""Minimal multimodal model with one ViT and one LLM attention layer."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.vision_encoder = nn.ModuleList([_FakeViTAttn()])
|
|
self.mm_projector = nn.Linear(64, 64)
|
|
self.llm = nn.ModuleList([_FakeLLMAttn()])
|
|
|
|
|
|
class _FakeViTOnlyModel(nn.Module):
|
|
|
|
def __init__(self, num_layers=3):
|
|
super().__init__()
|
|
self.layers = nn.ModuleList([_FakeViTAttn() for _ in range(num_layers)])
|
|
|
|
|
|
class _FakeLLMOnlyModel(nn.Module):
|
|
"""Minimal LLM-only model with multiple decoder attention layers."""
|
|
|
|
def __init__(self, num_layers=2):
|
|
super().__init__()
|
|
self.layers = nn.ModuleList([_FakeLLMAttn() for _ in range(num_layers)])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_process_group(world_size: int, rank: int):
|
|
"""Return a mock object that satisfies dist.get_world_size / get_rank."""
|
|
import unittest.mock as mock
|
|
import deepspeed.comm as dist
|
|
|
|
pg = mock.MagicMock()
|
|
dist.get_world_size = mock.MagicMock(return_value=world_size)
|
|
dist.get_rank = mock.MagicMock(return_value=rank)
|
|
|
|
def _fake_all_gather(tensor_list, tensor, group=None):
|
|
for t in tensor_list:
|
|
t.copy_(tensor)
|
|
|
|
dist.all_gather = _fake_all_gather
|
|
return pg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# autosp_detector tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAutospDetector:
|
|
|
|
def test_detects_vit_and_llm(self):
|
|
model = _FakeMultimodalModel()
|
|
info = detect_model_sp_info(model)
|
|
assert len(info.vit_attn_modules) == 1
|
|
assert len(info.llm_attn_modules) == 1
|
|
|
|
def test_detects_vision_projection(self):
|
|
model = _FakeMultimodalModel()
|
|
info = detect_model_sp_info(model)
|
|
assert info.vision_projection_module is not None
|
|
name, module = info.vision_projection_module
|
|
assert "mm_projector" in name
|
|
|
|
def test_detects_multiple_vit_layers(self):
|
|
model = _FakeViTOnlyModel(num_layers=4)
|
|
info = detect_model_sp_info(model)
|
|
assert len(info.vit_attn_modules) == 4
|
|
assert len(info.llm_attn_modules) == 0
|
|
assert info.vision_projection_module is None
|
|
|
|
def test_empty_model_returns_empty_info(self):
|
|
model = nn.Sequential(nn.Linear(8, 8))
|
|
info = detect_model_sp_info(model)
|
|
assert isinstance(info, SPModelInfo)
|
|
assert len(info.vit_attn_modules) == 0
|
|
assert len(info.llm_attn_modules) == 0
|
|
|
|
def test_only_first_projection_is_recorded(self):
|
|
"""Multiple projection-like names → only the outermost is recorded."""
|
|
|
|
class _M(nn.Module):
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.mm_projector = nn.Sequential(nn.Linear(8, 8))
|
|
self.mm_projector.visual_projection = nn.Linear(8, 8)
|
|
|
|
model = _M()
|
|
info = detect_model_sp_info(model)
|
|
assert info.vision_projection_module is not None
|
|
# Should be the outermost "mm_projector", not the nested one
|
|
name, _ = info.vision_projection_module
|
|
assert name == "mm_projector"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UlyssesSPViTAttention tests (CPU, rank-0 simulation via mocks)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUlyssesSPViTAttention:
|
|
|
|
@pytest.mark.parametrize("has_cls_token", [True, False])
|
|
@pytest.mark.parametrize("num_patches,world_size", [
|
|
(16, 4),
|
|
(16, 2),
|
|
(9, 3),
|
|
])
|
|
def test_output_shape_matches_input(self, has_cls_token, num_patches, world_size):
|
|
"""Output shape must equal input shape for any padding scenario."""
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
attn = _FakeViTAttn()
|
|
wrapper = UlyssesSPViTAttention(attn, pg, has_cls_token=has_cls_token)
|
|
|
|
local_patches = num_patches // world_size
|
|
seq_len = (1 + local_patches) if has_cls_token else local_patches
|
|
x = torch.randn(2, seq_len, 32)
|
|
|
|
out = wrapper(x)
|
|
assert out.shape == x.shape, f"Expected {x.shape}, got {out.shape}"
|
|
|
|
def test_tuple_output_unwrapped_correctly(self):
|
|
"""Wrappers that return (output, weights) tuples are handled."""
|
|
pg = _make_mock_process_group(world_size=2, rank=0)
|
|
attn = _FakeViTAttnTuple()
|
|
wrapper = UlyssesSPViTAttention(attn, pg, has_cls_token=False)
|
|
|
|
x = torch.randn(1, 8, 16) # 8 patches, 2 ranks → 4 local each
|
|
result = wrapper(x)
|
|
# Should return a tuple: (attention_output, attention_weights)
|
|
assert isinstance(result, tuple)
|
|
assert result[0].shape == x.shape
|
|
|
|
def test_identity_attn_preserves_values(self):
|
|
"""When attn is identity, output values should match input values."""
|
|
world_size = 2
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
attn = _FakeViTAttn()
|
|
wrapper = UlyssesSPViTAttention(attn, pg, has_cls_token=True)
|
|
|
|
# Each rank holds cls + 4 local patches
|
|
x = torch.arange(2 * 5 * 4, dtype=torch.float).reshape(2, 5, 4)
|
|
out = wrapper(x)
|
|
# CLS token should be identical
|
|
assert torch.allclose(out[:, :1, :], x[:, :1, :])
|
|
# Local patch slice should match input patches for identity attn
|
|
assert torch.allclose(out[:, 1:, :], x[:, 1:, :])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# auto_wrap_model_for_sp tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAutoWrapModelForSP:
|
|
|
|
def test_vit_layers_replaced(self):
|
|
pg = _make_mock_process_group(world_size=2, rank=0)
|
|
model = _FakeViTOnlyModel(num_layers=2)
|
|
auto_wrap_model_for_sp(model, pg)
|
|
for layer in model.layers:
|
|
assert isinstance(layer, UlyssesSPViTAttention)
|
|
|
|
def test_raises_on_unknown_model(self):
|
|
pg = _make_mock_process_group(world_size=2, rank=0)
|
|
model = nn.Sequential(nn.Linear(8, 8))
|
|
with pytest.raises(ValueError, match="no recognisable attention"):
|
|
auto_wrap_model_for_sp(model, pg)
|
|
|
|
def test_set_module_by_name_shallow(self):
|
|
model = _FakeViTOnlyModel(num_layers=1)
|
|
new_mod = nn.Linear(4, 4)
|
|
_set_module_by_name(model, "layers.0", new_mod)
|
|
assert model.layers[0] is new_mod
|
|
|
|
def test_set_module_by_name_deep(self):
|
|
model = _FakeMultimodalModel()
|
|
new_mod = nn.Identity()
|
|
_set_module_by_name(model, "vision_encoder.0", new_mod)
|
|
assert model.vision_encoder[0] is new_mod
|
|
|
|
def test_llm_layers_replaced_with_distributed_attention(self):
|
|
"""LLM attention layers must be wrapped with DistributedAttention."""
|
|
pg = _make_mock_process_group(world_size=2, rank=0)
|
|
model = _FakeLLMOnlyModel(num_layers=3)
|
|
auto_wrap_model_for_sp(model, pg)
|
|
for layer in model.layers:
|
|
assert isinstance(layer, DistributedAttention)
|
|
|
|
def test_multimodal_model_wraps_both_branches(self):
|
|
"""Both ViT and LLM attention layers must be replaced in a combined model."""
|
|
pg = _make_mock_process_group(world_size=2, rank=0)
|
|
model = _FakeMultimodalModel()
|
|
returned = auto_wrap_model_for_sp(model, pg)
|
|
# auto_wrap_model_for_sp must return the same object (in-place)
|
|
assert returned is model
|
|
assert isinstance(model.vision_encoder[0], UlyssesSPViTAttention)
|
|
assert isinstance(model.llm[0], DistributedAttention)
|
|
|
|
def test_original_module_preserved_inside_wrapper(self):
|
|
"""The wrapped module should still be accessible inside the wrapper."""
|
|
pg = _make_mock_process_group(world_size=2, rank=0)
|
|
model = _FakeViTOnlyModel(num_layers=1)
|
|
original_attn = model.layers[0]
|
|
auto_wrap_model_for_sp(model, pg)
|
|
assert model.layers[0].attn is original_attn
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ModalityFusionSPAdapter tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _ConcatFusionAdapter(ModalityFusionSPAdapter):
|
|
"""Concrete subclass that appends visual tokens after text tokens."""
|
|
|
|
def _splice_visual_into_text(self, text_embeds, visual_embeds, input_ids):
|
|
return torch.cat([text_embeds, visual_embeds], dim=1)
|
|
|
|
|
|
class TestModalityFusionSPAdapter:
|
|
|
|
def test_base_class_raises_not_implemented(self):
|
|
"""The base _splice_visual_into_text must raise NotImplementedError."""
|
|
pg = _make_mock_process_group(world_size=2, rank=0)
|
|
adapter = ModalityFusionSPAdapter(nn.Identity(), pg)
|
|
with pytest.raises(NotImplementedError):
|
|
adapter._splice_visual_into_text(None, None, None)
|
|
|
|
@pytest.mark.parametrize("world_size,local_v,text_len,hidden", [
|
|
(2, 4, 6, 8),
|
|
(4, 3, 5, 16),
|
|
(1, 8, 8, 4),
|
|
])
|
|
def test_output_shape(self, world_size, local_v, text_len, hidden):
|
|
"""Output local_len must equal ceil(fused_len / world_size)."""
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
adapter = _ConcatFusionAdapter(nn.Identity(), pg)
|
|
|
|
bs = 2
|
|
visual = torch.randn(bs, local_v, hidden)
|
|
text = torch.randn(bs, text_len, hidden)
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
|
|
out = adapter(visual, text, ids)
|
|
|
|
# all_gather mock copies local_v to each of world_size slots
|
|
fused_len = text_len + local_v * world_size
|
|
pad = (world_size - fused_len % world_size) % world_size
|
|
expected_local = (fused_len + pad) // world_size
|
|
assert out.shape == (bs, expected_local, hidden), f"Expected ({bs},{expected_local},{hidden}), got {out.shape}"
|
|
|
|
def test_padding_produces_valid_output_when_not_divisible(self):
|
|
"""When fused_len % world_size != 0, padding must not raise and output is well-formed."""
|
|
world_size = 4
|
|
# text_len=5, local_v=3 → fused_len = 5 + 3*4 = 17, needs padding of 3
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
adapter = _ConcatFusionAdapter(nn.Identity(), pg)
|
|
|
|
bs, local_v, text_len, hidden = 1, 3, 5, 4
|
|
out = adapter(
|
|
torch.randn(bs, local_v, hidden),
|
|
torch.randn(bs, text_len, hidden),
|
|
torch.zeros(bs, text_len, dtype=torch.long),
|
|
)
|
|
# padded_len = 20, local_len = 5
|
|
assert out.shape == (bs, 5, hidden)
|
|
|
|
def test_no_padding_when_divisible(self):
|
|
"""When fused_len is already divisible, no extra tokens should be added."""
|
|
world_size = 4
|
|
# text_len=4, local_v=4 → fused_len = 4 + 4*4 = 20, divisible by 4
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
adapter = _ConcatFusionAdapter(nn.Identity(), pg)
|
|
|
|
bs, local_v, text_len, hidden = 1, 4, 4, 8
|
|
out = adapter(
|
|
torch.randn(bs, local_v, hidden),
|
|
torch.randn(bs, text_len, hidden),
|
|
torch.zeros(bs, text_len, dtype=torch.long),
|
|
)
|
|
assert out.shape == (bs, 5, hidden) # 20 // 4 = 5
|
|
|
|
def test_different_ranks_return_different_slices(self):
|
|
"""Rank 0 and rank 1 must return different slices of the fused sequence."""
|
|
world_size = 2
|
|
bs, local_v, text_len, hidden = 1, 4, 4, 8
|
|
# Use distinct text vs visual values so slices clearly differ
|
|
text = torch.zeros(bs, text_len, hidden)
|
|
visual = torch.ones(bs, local_v, hidden)
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
|
|
outputs = {}
|
|
for rank in range(world_size):
|
|
pg = _make_mock_process_group(world_size=world_size, rank=rank)
|
|
adapter = _ConcatFusionAdapter(nn.Identity(), pg)
|
|
outputs[rank] = adapter(visual.clone(), text.clone(), ids.clone())
|
|
|
|
# fused = [0,0,0,0, 1,1,1,1, 1,1,1,1] (text zeros then visual ones x2)
|
|
# rank 0: indices 0-5, rank 1: indices 6-11
|
|
assert not torch.allclose(outputs[0], outputs[1])
|
|
|
|
def test_projection_is_applied(self):
|
|
"""Projection layer must transform visual features before gather."""
|
|
world_size = 2
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
|
|
# Use a projection that doubles all values
|
|
class _DoubleProjection(nn.Module):
|
|
|
|
def forward(self, x):
|
|
return x * 2.0
|
|
|
|
adapter = _ConcatFusionAdapter(_DoubleProjection(), pg)
|
|
bs, local_v, text_len, hidden = 1, 4, 4, 8
|
|
visual = torch.ones(bs, local_v, hidden)
|
|
text = torch.zeros(bs, text_len, hidden)
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
|
|
out = adapter(visual, text, ids)
|
|
# The visual part of the output should have value 2.0 (doubled), not 1.0
|
|
# rank 0 gets the first local_len tokens; fused = [text(0)*4, visual(2)*8]
|
|
# Since text_len=4 and local_len=6, rank0 slice starts with text zeros
|
|
# and ends with some visual twos.
|
|
assert out.max().item() == pytest.approx(2.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LlavaFusionAdapter tests (tests _splice_visual_into_text directly)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_IMAGE_ID = -200 # matches ModalityFusionSPAdapter default
|
|
|
|
|
|
def _make_llava_adapter(world_size=2, rank=0):
|
|
pg = _make_mock_process_group(world_size=world_size, rank=rank)
|
|
return LlavaFusionAdapter(nn.Identity(), pg, image_token_id=_IMAGE_ID)
|
|
|
|
|
|
class TestLlavaFusionAdapter:
|
|
|
|
def test_single_image_fused_shape(self):
|
|
"""One image placeholder per sample → fused length = text_len - 1 + num_visual."""
|
|
adapter = _make_llava_adapter()
|
|
bs, text_len, num_vis, hidden = 2, 6, 4, 8
|
|
# Place a single image placeholder at position 2.
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[:, 2] = _IMAGE_ID
|
|
text = torch.randn(bs, text_len, hidden)
|
|
visual = torch.randn(bs, num_vis, hidden)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
# placeholder is removed and replaced by num_vis tokens
|
|
assert fused.shape == (bs, text_len - 1 + num_vis, hidden)
|
|
|
|
def test_text_values_preserved_around_image(self):
|
|
"""Text tokens before and after the placeholder must be numerically intact."""
|
|
adapter = _make_llava_adapter()
|
|
bs, text_len, num_vis, hidden = 1, 5, 3, 4
|
|
# Placeholder at index 2: text = [A, B, <img>, C, D]
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 2] = _IMAGE_ID
|
|
text = torch.arange(bs * text_len * hidden, dtype=torch.float).reshape(bs, text_len, hidden)
|
|
visual = torch.ones(bs, num_vis, hidden) * 99.0
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
# fused = [A, B, vis0, vis1, vis2, C, D]
|
|
assert torch.allclose(fused[0, :2], text[0, :2]) # A, B preserved
|
|
assert torch.allclose(fused[0, 5:], text[0, 3:]) # C, D preserved
|
|
assert torch.allclose(fused[0, 2:5], visual[0]) # visual inserted
|
|
|
|
def test_no_image_token_returns_text_unchanged(self):
|
|
"""When input_ids has no placeholder, output equals text_embeds exactly."""
|
|
adapter = _make_llava_adapter()
|
|
bs, text_len, hidden = 2, 6, 8
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long) # no -200
|
|
text = torch.randn(bs, text_len, hidden)
|
|
visual = torch.randn(bs, 4, hidden)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert fused.shape == (bs, text_len, hidden)
|
|
assert torch.allclose(fused, text)
|
|
|
|
def test_multi_image_splice(self):
|
|
"""Two placeholders per sample → visual tokens split evenly between them."""
|
|
adapter = _make_llava_adapter()
|
|
bs, text_len, num_vis, hidden = 1, 7, 6, 4
|
|
# Placeholders at index 1 and 4: [t0, <img>, t2, t3, <img>, t5, t6]
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 1] = _IMAGE_ID
|
|
ids[0, 4] = _IMAGE_ID
|
|
text = torch.zeros(bs, text_len, hidden)
|
|
# First 3 visual tokens = 1.0, last 3 = 2.0 (so we can tell them apart)
|
|
visual = torch.cat([torch.ones(bs, 3, hidden), torch.full((bs, 3, hidden), 2.0)], dim=1)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
# Expected fused length: 7 - 2 placeholders + 6 visual = 11
|
|
assert fused.shape == (bs, 11, hidden)
|
|
# First chunk (indices 1-3) should be 1.0
|
|
assert torch.allclose(fused[0, 1:4], torch.ones(3, hidden))
|
|
# Second chunk (indices 6-8) should be 2.0
|
|
assert torch.allclose(fused[0, 6:9], torch.full((3, hidden), 2.0))
|
|
|
|
def test_batch_padding_when_lengths_differ(self):
|
|
"""Samples with different numbers of image tokens are padded to max length."""
|
|
adapter = _make_llava_adapter()
|
|
hidden = 4
|
|
# Sample 0: 1 placeholder in a 4-token sequence + 2 visual → fused len = 5
|
|
# Sample 1: no placeholder in a 4-token sequence → fused len = 4
|
|
ids = torch.zeros(2, 4, dtype=torch.long)
|
|
ids[0, 1] = _IMAGE_ID
|
|
text = torch.ones(2, 4, hidden)
|
|
visual = torch.ones(2, 2, hidden) * 3.0
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
# Max fused length is 5; sample 1 padded with zeros at the end.
|
|
assert fused.shape == (2, 5, hidden)
|
|
assert torch.all(fused[1, 4:] == 0) # padding tokens are zero
|
|
|
|
def test_forward_end_to_end_shape(self):
|
|
"""Full forward pass through LlavaFusionAdapter returns the correct shard shape."""
|
|
world_size = 2
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
adapter = LlavaFusionAdapter(nn.Identity(), pg, image_token_id=_IMAGE_ID)
|
|
|
|
bs, local_v, text_len, hidden = 1, 4, 6, 8
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 2] = _IMAGE_ID # one placeholder
|
|
visual = torch.randn(bs, local_v, hidden)
|
|
text = torch.randn(bs, text_len, hidden)
|
|
|
|
out = adapter(visual, text, ids)
|
|
# fused_len = text_len - 1 + local_v * world_size = 5 + 8 = 13
|
|
# padded to 14 (next multiple of 2), local = 7
|
|
assert out.shape == (bs, 7, hidden)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# InternVLFusionAdapter tests (tests _splice_visual_into_text directly)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CONTEXT_ID = 92546 # arbitrary IMG_CONTEXT token id for tests
|
|
_START_ID = 92545
|
|
_END_ID = 92547
|
|
|
|
|
|
def _make_internvl_adapter(world_size=2, rank=0):
|
|
pg = _make_mock_process_group(world_size=world_size, rank=rank)
|
|
return InternVLFusionAdapter(nn.Identity(), pg, image_token_id=_CONTEXT_ID)
|
|
|
|
|
|
class TestInternVLFusionAdapter:
|
|
|
|
def test_context_tokens_replaced_with_visual(self):
|
|
"""IMG_CONTEXT positions must carry visual embeddings after splice."""
|
|
adapter = _make_internvl_adapter()
|
|
bs, text_len, hidden = 1, 7, 4
|
|
# Layout: [t0, START, ctx, ctx, ctx, END, t6]
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 2] = _CONTEXT_ID
|
|
ids[0, 3] = _CONTEXT_ID
|
|
ids[0, 4] = _CONTEXT_ID
|
|
|
|
text = torch.zeros(bs, text_len, hidden)
|
|
visual = torch.ones(bs, 3, hidden) * 7.0
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert torch.allclose(fused[0, 2:5], visual[0])
|
|
|
|
def test_sequence_length_preserved(self):
|
|
"""Output length must equal input length (1-to-1 replacement)."""
|
|
adapter = _make_internvl_adapter()
|
|
bs, text_len, hidden = 2, 10, 8
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[:, 3:7] = _CONTEXT_ID # 4 context tokens per sample
|
|
text = torch.randn(bs, text_len, hidden)
|
|
visual = torch.randn(bs, 4, hidden)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert fused.shape == (bs, text_len, hidden)
|
|
|
|
def test_boundary_tokens_preserved(self):
|
|
"""IMG_START and IMG_END embeddings must be unchanged after splice."""
|
|
adapter = _make_internvl_adapter()
|
|
bs, text_len, hidden = 1, 5, 4
|
|
# [START, ctx, ctx, END, text]
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 1] = _CONTEXT_ID
|
|
ids[0, 2] = _CONTEXT_ID
|
|
|
|
text = torch.arange(bs * text_len * hidden, dtype=torch.float).reshape(bs, text_len, hidden)
|
|
visual = torch.ones(bs, 2, hidden) * 99.0
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
# Position 0 (START) and 3 (END) must be unchanged.
|
|
assert torch.allclose(fused[0, 0], text[0, 0])
|
|
assert torch.allclose(fused[0, 3], text[0, 3])
|
|
|
|
def test_no_context_tokens_returns_text_unchanged(self):
|
|
"""When there are no IMG_CONTEXT tokens the output must equal text_embeds."""
|
|
adapter = _make_internvl_adapter()
|
|
bs, text_len, hidden = 2, 6, 8
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
text = torch.randn(bs, text_len, hidden)
|
|
visual = torch.randn(bs, 4, hidden)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert torch.allclose(fused, text)
|
|
|
|
def test_multi_image_replacement(self):
|
|
"""Two separate runs of context tokens correspond to two images."""
|
|
adapter = _make_internvl_adapter()
|
|
bs, text_len, hidden = 1, 10, 4
|
|
# Image 1: positions 1-2, Image 2: positions 6-7
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 1] = _CONTEXT_ID
|
|
ids[0, 2] = _CONTEXT_ID
|
|
ids[0, 6] = _CONTEXT_ID
|
|
ids[0, 7] = _CONTEXT_ID
|
|
|
|
text = torch.zeros(bs, text_len, hidden)
|
|
# First 2 visual tokens = 1.0, next 2 = 2.0
|
|
visual = torch.cat([torch.ones(bs, 2, hidden), torch.full((bs, 2, hidden), 2.0)], dim=1)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert fused.shape == (bs, text_len, hidden)
|
|
assert torch.allclose(fused[0, 1:3], torch.ones(2, hidden))
|
|
assert torch.allclose(fused[0, 6:8], torch.full((2, hidden), 2.0))
|
|
|
|
def test_forward_end_to_end_shape(self):
|
|
"""Full forward pass returns the correct shard shape."""
|
|
world_size = 2
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
adapter = InternVLFusionAdapter(nn.Identity(), pg, image_token_id=_CONTEXT_ID)
|
|
|
|
bs, local_v, text_len, hidden = 1, 3, 8, 4
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 2:5] = _CONTEXT_ID # 3 context tokens; local_v * world_size = 6 total
|
|
visual = torch.randn(bs, local_v, hidden)
|
|
text = torch.randn(bs, text_len, hidden)
|
|
|
|
out = adapter(visual, text, ids)
|
|
# fused_len == text_len == 8 (length-preserving); padded to 8 (divisible by 2); local = 4
|
|
assert out.shape == (bs, 4, hidden)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Qwen2VLFusionAdapter tests (tests _splice_visual_into_text directly)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_VIS_START_ID = 151652
|
|
_VIS_END_ID = 151653
|
|
|
|
|
|
def _make_qwen2vl_adapter(world_size=2, rank=0):
|
|
pg = _make_mock_process_group(world_size=world_size, rank=rank)
|
|
return Qwen2VLFusionAdapter(nn.Identity(),
|
|
pg,
|
|
vision_start_token_id=_VIS_START_ID,
|
|
vision_end_token_id=_VIS_END_ID)
|
|
|
|
|
|
class TestQwen2VLFusionAdapter:
|
|
|
|
def test_inner_tokens_replaced_with_visual(self):
|
|
"""Tokens between vision_start and vision_end must become visual embeddings."""
|
|
adapter = _make_qwen2vl_adapter()
|
|
bs, text_len, hidden = 1, 7, 4
|
|
# [t0, t1, <vis_start>, pad, pad, <vis_end>, t6]
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 2] = _VIS_START_ID
|
|
ids[0, 5] = _VIS_END_ID
|
|
|
|
text = torch.zeros(bs, text_len, hidden)
|
|
visual = torch.ones(bs, 2, hidden) * 5.0
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert torch.allclose(fused[0, 3:5], visual[0])
|
|
|
|
def test_sequence_length_preserved(self):
|
|
"""Output length must equal input length (1-to-1 replacement)."""
|
|
adapter = _make_qwen2vl_adapter()
|
|
bs, text_len, hidden = 2, 12, 8
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[:, 2] = _VIS_START_ID
|
|
ids[:, 8] = _VIS_END_ID # 5 inner placeholder tokens
|
|
text = torch.randn(bs, text_len, hidden)
|
|
visual = torch.randn(bs, 5, hidden)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert fused.shape == (bs, text_len, hidden)
|
|
|
|
def test_boundary_tokens_preserved(self):
|
|
"""vision_start and vision_end embeddings must be unchanged after splice."""
|
|
adapter = _make_qwen2vl_adapter()
|
|
bs, text_len, hidden = 1, 6, 4
|
|
# [t0, <vis_start>, pad, pad, <vis_end>, t5]
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 1] = _VIS_START_ID
|
|
ids[0, 4] = _VIS_END_ID
|
|
|
|
text = torch.arange(bs * text_len * hidden, dtype=torch.float).reshape(bs, text_len, hidden)
|
|
visual = torch.ones(bs, 2, hidden) * 99.0
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert torch.allclose(fused[0, 1], text[0, 1]) # vision_start preserved
|
|
assert torch.allclose(fused[0, 4], text[0, 4]) # vision_end preserved
|
|
|
|
def test_no_vision_tokens_returns_text_unchanged(self):
|
|
"""When there are no vision_start/end tokens the output must equal text_embeds."""
|
|
adapter = _make_qwen2vl_adapter()
|
|
bs, text_len, hidden = 2, 8, 4
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
text = torch.randn(bs, text_len, hidden)
|
|
visual = torch.randn(bs, 4, hidden)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert torch.allclose(fused, text)
|
|
|
|
def test_multi_image_replacement(self):
|
|
"""Two vision blocks are handled independently."""
|
|
adapter = _make_qwen2vl_adapter()
|
|
bs, text_len, hidden = 1, 14, 4
|
|
# Block 1: positions 1 (start) .. 4 (end), 2 inner tokens at 2-3
|
|
# Block 2: positions 8 (start) .. 12 (end), 3 inner tokens at 9-11
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
ids[0, 1] = _VIS_START_ID
|
|
ids[0, 4] = _VIS_END_ID
|
|
ids[0, 8] = _VIS_START_ID
|
|
ids[0, 12] = _VIS_END_ID
|
|
|
|
text = torch.zeros(bs, text_len, hidden)
|
|
visual = torch.cat([torch.ones(bs, 2, hidden), torch.full((bs, 3, hidden), 2.0)], dim=1)
|
|
|
|
fused = adapter._splice_visual_into_text(text, visual, ids)
|
|
assert fused.shape == (bs, text_len, hidden)
|
|
assert torch.allclose(fused[0, 2:4], torch.ones(2, hidden))
|
|
assert torch.allclose(fused[0, 9:12], torch.full((3, hidden), 2.0))
|
|
|
|
def test_forward_end_to_end_shape(self):
|
|
"""Full forward pass returns the correct shard shape."""
|
|
world_size = 2
|
|
pg = _make_mock_process_group(world_size=world_size, rank=0)
|
|
adapter = Qwen2VLFusionAdapter(nn.Identity(),
|
|
pg,
|
|
vision_start_token_id=_VIS_START_ID,
|
|
vision_end_token_id=_VIS_END_ID)
|
|
|
|
bs, local_v, text_len, hidden = 1, 3, 10, 4
|
|
ids = torch.zeros(bs, text_len, dtype=torch.long)
|
|
# 6 inner placeholder tokens (local_v * world_size = 6)
|
|
ids[0, 1] = _VIS_START_ID
|
|
ids[0, 8] = _VIS_END_ID
|
|
visual = torch.randn(bs, local_v, hidden)
|
|
text = torch.randn(bs, text_len, hidden)
|
|
|
|
out = adapter(visual, text, ids)
|
|
# fused_len == text_len == 10 (length-preserving); padded to 10; local = 5
|
|
assert out.shape == (bs, 5, hidden)
|