Files
2026-07-13 13:18:33 +08:00

367 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ``<IMG_START> <IMG_CONTEXT>×N <IMG_END>``
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 ``<IMG_CONTEXT>``).
"""
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