# Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: Apache-2.0 # DeepSpeed Team """ ModalityFusionSPAdapter — Phase 2 Handles the sequence scatter/gather at the vision-language boundary so that the LLM decoder's :class:`~deepspeed.sequence.layer.DistributedAttention` receives a uniformly sharded fused (visual + text) sequence. Workflow -------- :: [visual tokens, sharded] ──all-gather──► [visual tokens, full] │ splice into text │ [fused embeds, full] ──scatter──► [fused embeds, sharded per rank] │ LLM decoder (SP-aware) Usage ----- After calling :func:`~deepspeed.sequence.auto_sp.auto_wrap_model_for_sp` to wrap the ViT attention layers, attach the appropriate fusion adapter to the vision-language projection layer **before** the first forward pass. Choose the adapter that matches your model architecture:: from deepspeed.sequence.auto_sp import auto_wrap_model_for_sp from deepspeed.sequence.autosp_fusion import ( LlavaFusionAdapter, InternVLFusionAdapter, Qwen2VLFusionAdapter, ) from deepspeed.utils import groups # 1. Wrap ViT and LLM attention layers automatically. sp_group = groups._get_sequence_parallel_group() auto_wrap_model_for_sp(model, process_group=sp_group) # 2. Attach the fusion adapter for the vision-language projection layer. # LLaVA — replaces image-placeholder tokens with visual tokens: model.mm_projector = LlavaFusionAdapter( model.mm_projector, sp_group, image_token_id=IMAGE_TOKEN_ID ) # InternVL — replaces IMG_CONTEXT tokens 1-to-1 with visual tokens: model.mm_projector = InternVLFusionAdapter( model.mm_projector, sp_group, image_token_id=IMG_CONTEXT_TOKEN_ID ) # Qwen2-VL — replaces tokens between vision_start/end pairs 1-to-1: model.visual.merger = Qwen2VLFusionAdapter( model.visual.merger, sp_group, vision_start_token_id=VISION_START_ID, vision_end_token_id=VISION_END_ID, ) # 3. Use the model as normal; the adapter handles all SP gather/scatter. outputs = model(input_ids=input_ids, pixel_values=pixel_values, ...) Status: Phase 2. ``_splice_visual_into_text`` is intentionally left as a ``NotImplementedError``; override it per model architecture (see docstring). """ import torch import torch.nn as nn import torch.nn.functional as F import deepspeed.comm as dist # Default image placeholder token ID used by LLaVA-style models. _DEFAULT_IMAGE_TOKEN_ID = -200 class ModalityFusionSPAdapter(nn.Module): """Wraps the vision projection layer and handles cross-modal sequence fusion. After projecting visual features, this adapter: 1. Gathers the sharded visual token slices from all SP ranks into a single full visual token tensor. 2. Splices the visual tokens into the text embedding sequence at the positions marked by ``image_token_id`` placeholders. 3. Pads and re-shards the fused sequence so that the subsequent LLM decoder layers receive uniformly distributed sequence slices. Parameters ---------- projection: The vision projection module (e.g. ``mm_projector``). process_group: The sequence-parallel process group. image_token_id: The token ID used as an image placeholder in the input IDs tensor. Defaults to ``-200`` (LLaVA convention). Notes ----- Subclass this and override :meth:`_splice_visual_into_text` to adapt to a specific multimodal architecture (LLaVA, InternVL, Qwen-VL, …). """ def __init__(self, projection: nn.Module, process_group, image_token_id: int = _DEFAULT_IMAGE_TOKEN_ID) -> None: super().__init__() self.projection = projection self.process_group = process_group self.world_size = dist.get_world_size(process_group) self.image_token_id = image_token_id def forward(self, visual_features: torch.Tensor, text_embeds: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor: """Project visual features and return a sharded fused embedding. Parameters ---------- visual_features: Raw visual features from the ViT encoder. Shape: ``[bs, local_visual_tokens, vit_hidden]``. text_embeds: Full text token embeddings (not sharded yet). Shape: ``[bs, text_seq_len, lm_hidden]``. input_ids: Token IDs used to locate image placeholder positions. Shape: ``[bs, text_seq_len]``. Returns ------- Sharded fused embedding for this rank. Shape: ``[bs, local_fused_len, lm_hidden]``. """ # 1. Project visual features to the LLM hidden dimension visual_embeds = self.projection(visual_features) # [bs, local_v, lm_hidden] # 2. All-gather visual slices from all SP ranks parts = [torch.zeros_like(visual_embeds) for _ in range(self.world_size)] dist.all_gather(parts, visual_embeds.contiguous(), group=self.process_group) full_visual = torch.cat(parts, dim=1) # [bs, total_visual_tokens, lm_hidden] # 3. Splice visual tokens into text embedding sequence fused = self._splice_visual_into_text(text_embeds, full_visual, input_ids) # [bs, fused_len, lm_hidden] # 4. Pad fused length to be divisible by world_size, then scatter total_len = fused.shape[1] pad = (self.world_size - total_len % self.world_size) % self.world_size if pad > 0: fused = F.pad(fused, (0, 0, 0, pad)) rank = dist.get_rank(self.process_group) local_len = fused.shape[1] // self.world_size return fused[:, rank * local_len:(rank + 1) * local_len, :].contiguous() def _splice_visual_into_text(self, text_embeds: torch.Tensor, visual_embeds: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor: """Replace image placeholder positions in *text_embeds* with *visual_embeds*. This is intentionally architecture-specific. The default raises ``NotImplementedError``; override this method for each supported model. Reference implementations: * LLaVA: ``LlavaMetaForCausalLM.prepare_inputs_embeds`` * InternVL: ``InternVLChatModel.extract_feature`` * Qwen-VL: ``Qwen2VLForConditionalGeneration.get_rope_index`` """ raise NotImplementedError(f"{type(self).__name__}._splice_visual_into_text is not implemented. " "Subclass ModalityFusionSPAdapter and override this method to match " "your model's prepare_inputs_embeds logic.") class LlavaFusionAdapter(ModalityFusionSPAdapter): """LLaVA-style splice: replace each image placeholder token with visual tokens. Follows the logic of ``LlavaMetaForCausalLM.prepare_inputs_labels_for_multimodal``: for each sample, locate ``image_token_id`` placeholders in ``input_ids``, remove them, and insert the corresponding visual token chunk in their place. Visual tokens for a sample are split evenly across the number of image placeholders found. This matches the common single-image case (one placeholder per sample) and simple multi-image cases where every image contributes the same number of tokens. Parameters are inherited from :class:`ModalityFusionSPAdapter`. """ def _splice_visual_into_text(self, text_embeds: torch.Tensor, visual_embeds: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor: bs, text_len, hidden = text_embeds.shape device = text_embeds.device fused_samples = [] for i in range(bs): img_pos = (input_ids[i] == self.image_token_id).nonzero(as_tuple=True)[0] num_images = img_pos.numel() if num_images == 0: # No image in this sample — keep text embeddings unchanged. fused_samples.append(text_embeds[i]) continue # Split all visual tokens evenly across the image placeholders. visual_chunks = torch.chunk(visual_embeds[i], num_images, dim=0) segments = [] prev = 0 for j, pos in enumerate(img_pos.tolist()): # Text segment before this placeholder. if pos > prev: segments.append(text_embeds[i, prev:pos]) # Visual tokens replacing this placeholder. segments.append(visual_chunks[j]) # Skip the placeholder token itself. prev = pos + 1 # Remaining text after the last placeholder. if prev < text_len: segments.append(text_embeds[i, prev:]) fused_samples.append(torch.cat(segments, dim=0)) # Pad all samples to the same length so they stack into a tensor. max_len = max(s.shape[0] for s in fused_samples) out = torch.zeros(bs, max_len, hidden, dtype=text_embeds.dtype, device=device) for i, s in enumerate(fused_samples): out[i, :s.shape[0]] = s return out class InternVLFusionAdapter(ModalityFusionSPAdapter): """InternVL-style splice: replace IMG_CONTEXT token runs with visual tokens. InternVL encodes each image as `` ×N `` inside the token sequence. Each ``IMG_CONTEXT`` token (``image_token_id``) is a 1-to-1 placeholder for one ViT visual token. This adapter locates every contiguous run of ``image_token_id`` tokens and replaces them with the corresponding slice of *visual_embeds*, while preserving the ``IMG_START`` / ``IMG_END`` boundary embeddings unchanged. Because the replacement is 1-to-1, the output sequence length equals the input sequence length (no length change). Parameters are inherited from :class:`ModalityFusionSPAdapter`. Set ``image_token_id`` to the ``IMG_CONTEXT`` token id used by the model (e.g. the id of ````). """ def _splice_visual_into_text(self, text_embeds: torch.Tensor, visual_embeds: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor: # Start from a clone of text embeddings; we only overwrite context positions. out = text_embeds.clone() bs = text_embeds.shape[0] for i in range(bs): ctx_pos = (input_ids[i] == self.image_token_id).nonzero(as_tuple=True)[0] if ctx_pos.numel() == 0: continue # ctx_pos lists every IMG_CONTEXT index in order. visual_embeds[i] # has exactly ctx_pos.numel() tokens (one per context position). out[i, ctx_pos] = visual_embeds[i, :ctx_pos.numel()] return out class Qwen2VLFusionAdapter(nn.Module): """Qwen2-VL-style splice: visual tokens enclosed by vision_start/end tokens. Qwen2-VL wraps each image's visual tokens with a pair of special boundary tokens in ``input_ids``: ``vision_start_token_id`` and ``vision_end_token_id``. The placeholder tokens between each (start, end) pair are replaced 1-to-1 by the projected visual token embeddings. The boundary token embeddings are kept unchanged. Because the replacement is 1-to-1, the output sequence length equals the input sequence length. Parameters ---------- projection: The vision projection module (e.g. ``visual.merger``). process_group: The sequence-parallel process group. vision_start_token_id: Token id of ``<|vision_start|>``. vision_end_token_id: Token id of ``<|vision_end|>``. """ def __init__(self, projection: nn.Module, process_group, vision_start_token_id: int, vision_end_token_id: int) -> None: super().__init__() self.projection = projection self.process_group = process_group self.world_size = dist.get_world_size(process_group) self.vision_start_token_id = vision_start_token_id self.vision_end_token_id = vision_end_token_id def forward(self, visual_features: torch.Tensor, text_embeds: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor: """Project visual features and return a sharded fused embedding. Parameters ---------- visual_features: Raw visual features from the ViT encoder. Shape: ``[bs, local_visual_tokens, vit_hidden]``. text_embeds: Full text token embeddings (not sharded yet). Shape: ``[bs, text_seq_len, lm_hidden]``. input_ids: Token IDs used to locate vision_start/end boundaries. Shape: ``[bs, text_seq_len]``. Returns ------- Sharded fused embedding for this rank. Shape: ``[bs, local_fused_len, lm_hidden]``. """ # 1. Project visual features to the LLM hidden dimension. visual_embeds = self.projection(visual_features) # [bs, local_v, lm_hidden] # 2. All-gather visual slices from all SP ranks. parts = [torch.zeros_like(visual_embeds) for _ in range(self.world_size)] dist.all_gather(parts, visual_embeds.contiguous(), group=self.process_group) full_visual = torch.cat(parts, dim=1) # [bs, total_visual_tokens, lm_hidden] # 3. Replace placeholder positions in text with visual tokens (length-preserving). fused = self._splice_visual_into_text(text_embeds, full_visual, input_ids) # 4. Pad fused length to be divisible by world_size, then scatter. total_len = fused.shape[1] pad = (self.world_size - total_len % self.world_size) % self.world_size if pad > 0: fused = F.pad(fused, (0, 0, 0, pad)) rank = dist.get_rank(self.process_group) local_len = fused.shape[1] // self.world_size return fused[:, rank * local_len:(rank + 1) * local_len, :].contiguous() def _splice_visual_into_text(self, text_embeds: torch.Tensor, visual_embeds: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor: """Replace inner placeholder tokens between vision_start/end pairs with visual embeddings.""" out = text_embeds.clone() bs = text_embeds.shape[0] for i in range(bs): start_pos = (input_ids[i] == self.vision_start_token_id).nonzero(as_tuple=True)[0] end_pos = (input_ids[i] == self.vision_end_token_id).nonzero(as_tuple=True)[0] if start_pos.numel() == 0: continue # Accumulate inner placeholder positions across all start/end pairs. # Inner positions are (start+1) .. (end-1) inclusive, i.e. excluding # the boundary tokens themselves. inner_positions = [] for s, e in zip(start_pos.tolist(), end_pos.tolist()): inner_positions.extend(range(s + 1, e)) if not inner_positions: continue inner_pos = torch.tensor(inner_positions, dtype=torch.long, device=text_embeds.device) out[i, inner_pos] = visual_embeds[i, :len(inner_positions)] return out