chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:18:33 +08:00
commit 4ececc111a
2017 changed files with 331736 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
from deepspeed.sequence.autosp_detector import detect_model_sp_info, SPModelInfo
from deepspeed.sequence.autosp_vit import UlyssesSPViTAttention
from deepspeed.sequence.autosp_fusion import (ModalityFusionSPAdapter, LlavaFusionAdapter, InternVLFusionAdapter,
Qwen2VLFusionAdapter)
from deepspeed.sequence.auto_sp import auto_wrap_model_for_sp
+140
View File
@@ -0,0 +1,140 @@
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
"""
AutoSP: one-call sequence parallelism for multimodal models.
Usage::
from deepspeed.sequence.auto_sp import auto_wrap_model_for_sp
from deepspeed.utils import groups
model, _, _, _ = deepspeed.initialize(config=ds_config, model=model, ...)
sp_group = groups._get_sequence_parallel_group()
model = auto_wrap_model_for_sp(model, process_group=sp_group)
``auto_wrap_model_for_sp`` scans the model and injects:
* :class:`~deepspeed.sequence.autosp_vit.UlyssesSPViTAttention`
for ViT encoder attention layers.
* a warning for LLM decoder attention layers: HuggingFace-style
``hidden_states`` attention is incompatible with
:class:`~deepspeed.sequence.layer.DistributedAttention`'s Q/K/V interface;
configure LLM sequence parallelism manually.
The vision-language projection layer (Phase 2) is detected and a warning is
emitted; wrap it manually with
:class:`~deepspeed.sequence.autosp_fusion.ModalityFusionSPAdapter` until
Phase 2 automation is implemented.
"""
import logging
import torch.nn as nn
from deepspeed.sequence.autosp_detector import detect_model_sp_info, _VIT_HAS_CLS_TOKEN
from deepspeed.sequence.autosp_vit import UlyssesSPViTAttention
logger = logging.getLogger(__name__)
def auto_wrap_model_for_sp(model: nn.Module, process_group) -> nn.Module:
"""Inject sequence-parallel wrappers into *model* in-place.
Scans the model's named modules and replaces recognised attention layers
with their SP-aware equivalents:
* ViT attention → :class:`UlyssesSPViTAttention`
* LLM attention → warning only (HuggingFace ``hidden_states`` interface
is incompatible with :class:`DistributedAttention`'s Q/K/V interface)
The function modifies *model* in-place **and** returns it for convenience.
Parameters
----------
model:
The multimodal model to wrap. Must be on the correct device before
calling this function.
process_group:
The sequence-parallel process group (from
``groups._get_sequence_parallel_group()``).
Returns
-------
The same *model* object with attention layers replaced.
Raises
------
ValueError
If no recognisable attention modules are found. Register the model's
attention class names in ``autosp_detector._VIT_ATTN_CLASSNAMES`` or
``_LLM_ATTN_CLASSNAMES`` to fix this.
"""
info = detect_model_sp_info(model)
if not info.vit_attn_modules and not info.llm_attn_modules:
raise ValueError("auto_wrap_model_for_sp: no recognisable attention modules found. "
"Add the model's attention class name(s) to "
"_VIT_ATTN_CLASSNAMES or _LLM_ATTN_CLASSNAMES in "
"deepspeed/sequence/autosp_detector.py and retry.")
# ------------------------------------------------------------------
# Wrap ViT encoder attention layers
# ------------------------------------------------------------------
for name, module in info.vit_attn_modules:
cls_name = type(module).__name__
# Look up whether this ViT architecture uses a CLS token; default True
# (safe fallback) for unknown classes not yet in the registry.
has_cls = _VIT_HAS_CLS_TOKEN.get(cls_name, True)
wrapped = UlyssesSPViTAttention(module, process_group, has_cls_token=has_cls)
_set_module_by_name(model, name, wrapped)
logger.debug("AutoSP: wrapped ViT attention '%s' with UlyssesSPViTAttention (has_cls_token=%s)", name, has_cls)
logger.info("AutoSP: wrapped %d ViT attention layer(s).", len(info.vit_attn_modules))
# ------------------------------------------------------------------
# LLM decoder attention layers — warn, do not auto-wrap
# ------------------------------------------------------------------
# DistributedAttention expects a Megatron-style (query, key, value)
# interface, but every class in _LLM_ATTN_CLASSNAMES uses the
# HuggingFace hidden_states interface. Wrapping them silently would
# produce incorrect behaviour at the first forward pass. Emit a
# per-layer warning so the user can configure SP manually.
for name, module in info.llm_attn_modules:
logger.warning(
"AutoSP: LLM attention '%s' (class %s) uses a HuggingFace hidden_states "
"interface that is incompatible with DistributedAttention's Q/K/V interface. "
"Skipping auto-wrap. Configure sequence parallelism for this layer manually.", name,
type(module).__name__)
if info.llm_attn_modules:
logger.info("AutoSP: found %d LLM attention layer(s); skipped wrapping (see warnings above).",
len(info.llm_attn_modules))
# ------------------------------------------------------------------
# Warn about the vision projection layer (Phase 2)
# ------------------------------------------------------------------
if info.vision_projection_module is not None:
proj_name, _ = info.vision_projection_module
logger.warning(
"AutoSP detected vision projection layer '%s'. "
"ModalityFusionSPAdapter (Phase 2) is not yet automated. "
"Wrap this layer manually with ModalityFusionSPAdapter if you "
"need correct cross-modal sequence gather/scatter.", proj_name)
return model
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _set_module_by_name(model: nn.Module, dotted_name: str, new_module: nn.Module) -> None:
"""Replace the submodule at *dotted_name* with *new_module* in-place."""
parts = dotted_name.split(".")
parent = model
for part in parts[:-1]:
parent = getattr(parent, part)
setattr(parent, parts[-1], new_module)
+115
View File
@@ -0,0 +1,115 @@
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
"""
Automatically detect ViT encoder and LLM decoder attention modules in
multimodal models to guide AutoSP injection.
Extend _VIT_ATTN_CLASSNAMES / _LLM_ATTN_CLASSNAMES when adding support for
new model architectures.
"""
import torch.nn as nn
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
# ---------------------------------------------------------------------------
# Architecture registry
# ---------------------------------------------------------------------------
# Known ViT attention class names (HuggingFace transformers naming)
_VIT_ATTN_CLASSNAMES = {
"ViTAttention",
"CLIPAttention",
"SiglipAttention",
"InternVisionAttention",
"Qwen2VLVisionAttention",
"Idefics2VisionAttention",
"PaliGemmaVisionAttention",
}
# Whether each known ViT class uses a prepended CLS token.
# CLS is replicated on every rank and is NOT sharded across the sequence.
# Defaults to True for unknown classes (safe fallback).
_VIT_HAS_CLS_TOKEN = {
"ViTAttention": True,
"CLIPAttention": True,
"SiglipAttention": False,
"InternVisionAttention": False,
"Qwen2VLVisionAttention": False,
"Idefics2VisionAttention": False,
"PaliGemmaVisionAttention": False,
}
# Known LLM decoder attention class names
_LLM_ATTN_CLASSNAMES = {
"LlamaAttention",
"MistralAttention",
"Qwen2Attention",
"InternLM2Attention",
"GemmaAttention",
"Phi3Attention",
"GPTNeoXAttention",
"FalconAttention",
"MptAttention",
}
# Common attribute names that hold the vision-language projection layer
_VISION_PROJ_KEYWORDS = (
"visual_projection",
"mm_projector",
"vision_proj",
"multi_modal_projector",
"img_projection",
)
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class SPModelInfo:
"""Holds the detection results for a multimodal model."""
# (dotted_name, module) pairs for ViT attention layers
vit_attn_modules: List[Tuple[str, nn.Module]] = field(default_factory=list)
# (dotted_name, module) pairs for LLM decoder attention layers
llm_attn_modules: List[Tuple[str, nn.Module]] = field(default_factory=list)
# (dotted_name, module) for the outermost vision-language projection layer
vision_projection_module: Optional[Tuple[str, nn.Module]] = None
# ---------------------------------------------------------------------------
# Detection logic
# ---------------------------------------------------------------------------
def detect_model_sp_info(model: nn.Module) -> SPModelInfo:
"""Recursively scan *model* and return an :class:`SPModelInfo`.
The function identifies:
* ViT encoder attention layers → wrapped with :class:`UlyssesSPViTAttention`
* LLM decoder attention layers → wrapped with :class:`DistributedAttention`
* The vision-language projection layer → wrapped with
:class:`ModalityFusionSPAdapter` (Phase 2)
To add support for a new architecture, simply register its attention class
names in ``_VIT_ATTN_CLASSNAMES`` or ``_LLM_ATTN_CLASSNAMES``.
"""
info = SPModelInfo()
for name, module in model.named_modules():
cls_name = type(module).__name__
if cls_name in _VIT_ATTN_CLASSNAMES:
info.vit_attn_modules.append((name, module))
elif cls_name in _LLM_ATTN_CLASSNAMES:
info.llm_attn_modules.append((name, module))
# Record only the first (outermost) match to avoid double-wrapping
# nested projection modules.
if info.vision_projection_module is None:
if any(kw in name for kw in _VISION_PROJ_KEYWORDS):
info.vision_projection_module = (name, module)
return info
+366
View File
@@ -0,0 +1,366 @@
# 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
+175
View File
@@ -0,0 +1,175 @@
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
"""
Ulysses-style sequence-parallel wrapper for ViT encoder attention layers.
Design notes
------------
ViT self-attention is non-causal: every patch token attends to every other
patch token. This means a straightforward per-rank local attention (as used
for causal LLMs) would be *incorrect* — each rank must have access to the
full key/value context.
We therefore use a **gather-compute-scatter** pattern:
1. Input arrives already sharded along the sequence dimension (each rank owns
``local_patches = num_patches // world_size`` consecutive patches).
2. Before attention we **all-gather** patch tokens so that every rank runs the
full ViT attention over the complete patch sequence. This keeps the
computation equivalent to single-device execution.
3. The output is **scattered** back so that each rank returns only its local
slice, matching the sharded input contract expected by downstream layers.
Memory benefit: activations *outside* the attention block (e.g. feed-forward
layers, layer norms) are stored only locally, reducing per-rank memory
proportional to ``world_size``.
The ``cls`` token (if present) is replicated on every rank and is not split
across the sequence dimension. Each rank appends its local patches to the
same ``cls`` token before calling the wrapped attention.
Padding: when ``num_patches % world_size != 0``, shorter shards are
zero-padded to a uniform size for ``all_gather``. The padding is stripped
*before* the attention call by trimming each rank's contribution to its true
length, so the wrapped attention always sees exactly ``num_patches`` real
tokens — identical to single-device execution and free of softmax pollution
from dummy tokens.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import deepspeed.comm as dist
class UlyssesSPViTAttention(nn.Module):
"""Sequence-parallel wrapper for an opaque ViT attention module.
Parameters
----------
attn:
The original ViT attention layer (any ``nn.Module`` that maps
``hidden_states`` → ``hidden_states`` or a tuple whose first element
is the attention output tensor).
process_group:
The sequence-parallel process group.
has_cls_token:
Set to ``True`` (default) when the first token in the sequence is a
``[CLS]`` token that should be replicated on every rank rather than
sharded.
"""
def __init__(self, attn: nn.Module, process_group, has_cls_token: bool = True) -> None:
super().__init__()
self.attn = attn
self.process_group = process_group
self.world_size = dist.get_world_size(process_group)
self.has_cls_token = has_cls_token
# ------------------------------------------------------------------
# forward
# ------------------------------------------------------------------
def forward(self, hidden_states: torch.Tensor, **kwargs):
"""
Parameters
----------
hidden_states:
Shape ``[bs, local_seq_len, hidden_dim]`` where
``local_seq_len = (1 + local_patches)`` if ``has_cls_token`` else
``local_patches``. Each rank holds a contiguous slice of patches.
**kwargs:
Passed through to the wrapped attention (e.g. ``attention_mask``,
``head_mask``, ``output_attentions``).
Returns
-------
Same shape as input (or a tuple whose first element matches the input
shape, preserving whatever the wrapped module returns).
"""
bs, local_seq_len, hidden_dim = hidden_states.shape
if self.has_cls_token:
# CLS token is replicated on every rank — not part of the sharded seq
cls_token = hidden_states[:, :1, :]
local_patches = hidden_states[:, 1:, :]
else:
local_patches = hidden_states
local_patch_len = local_patches.shape[1]
# -------------------------------------------------------------------
# 1. All-gather patches from all ranks to reconstruct the full sequence
# -------------------------------------------------------------------
# When num_patches % world_size != 0, ranks hold different shard sizes.
# We all-gather every rank's local_patch_len so we can:
# (a) zero-pad shorter slices to uniform size for all_gather, and
# (b) strip the padding per rank *before* calling attention, so that
# the wrapped module never sees dummy tokens (which would corrupt
# the softmax normalisation).
len_bufs = [torch.zeros(1, dtype=torch.long, device=local_patches.device) for _ in range(self.world_size)]
dist.all_gather(len_bufs,
torch.tensor([local_patch_len], dtype=torch.long, device=local_patches.device),
group=self.process_group)
all_lens = [int(t.item()) for t in len_bufs]
max_local_len = max(all_lens)
pad_len = max_local_len - local_patch_len
if pad_len > 0:
# Append zero rows so this rank's buffer matches the largest shard.
local_patches_padded = F.pad(local_patches, (0, 0, 0, pad_len))
else:
local_patches_padded = local_patches
gathered = [
torch.zeros(bs, max_local_len, hidden_dim, dtype=local_patches.dtype, device=local_patches.device)
for _ in range(self.world_size)
]
dist.all_gather(gathered, local_patches_padded.contiguous(), group=self.process_group)
# Strip per-rank padding before concatenation so attention only sees
# the true num_patches tokens, identical to single-device execution.
real_parts = [gathered[r][:, :all_lens[r], :] for r in range(self.world_size)]
full_patches = torch.cat(real_parts, dim=1) # [bs, total_real_patches, hidden_dim]
# -------------------------------------------------------------------
# 2. Build the full input (prepend CLS if needed) and call attention
# -------------------------------------------------------------------
if self.has_cls_token:
full_input = torch.cat([cls_token, full_patches], dim=1)
else:
full_input = full_patches
attn_out = self.attn(full_input, **kwargs)
# Unwrap tuple: some ViT implementations return (attn_output, attn_weights)
if isinstance(attn_out, (tuple, list)):
full_out, *extra = attn_out
else:
full_out = attn_out
extra = []
# -------------------------------------------------------------------
# 3. Scatter output: each rank keeps its local slice of the real patches.
# Because padding was stripped before attention, scatter offsets are
# the cumulative sums of all_lens, not rank * max_local_len.
# -------------------------------------------------------------------
if self.has_cls_token:
cls_out = full_out[:, :1, :]
patch_out = full_out[:, 1:, :]
else:
patch_out = full_out
rank = dist.get_rank(self.process_group)
start = sum(all_lens[:rank])
local_out = patch_out[:, start:start + local_patch_len, :].contiguous()
if self.has_cls_token:
local_out = torch.cat([cls_out, local_out], dim=1)
if extra:
return (local_out, *extra)
return local_out
+60
View File
@@ -0,0 +1,60 @@
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
import torch
import deepspeed.comm as dist
class _VocabSequenceParallelCrossEntropy(torch.autograd.Function):
@staticmethod
def forward(ctx, vocab_seq_parallel_logits, target, sp_group):
# vocab_seq_parallel_logits: [S/P, B, V]
# target: [S/P, B]
# return: [S, B]
# Need softmax for backward
softmax = torch.nn.functional.softmax(vocab_seq_parallel_logits, dim=-1)
ctx.vocab_size = vocab_seq_parallel_logits.size(2)
loss = torch.nn.functional.nll_loss(softmax.log().view(-1, ctx.vocab_size), target.view(-1), reduction='none')
sp_world_size = dist.get_world_size(sp_group)
sp_rank = dist.get_rank(sp_group)
ctx.sp_world_size = sp_world_size
ctx.sp_rank = sp_rank
ctx.seqlen = vocab_seq_parallel_logits.size(0) * sp_world_size
batch_size = vocab_seq_parallel_logits.size(1)
loss_all = torch.empty(ctx.seqlen,
batch_size,
dtype=vocab_seq_parallel_logits.dtype,
device=vocab_seq_parallel_logits.device)
dist.all_gather_into_tensor(loss_all, loss, group=sp_group)
ctx.save_for_backward(softmax, target)
return loss_all
@staticmethod
def backward(ctx, grad_output):
softmax, target = ctx.saved_tensors
step_seqlen = ctx.seqlen // ctx.sp_world_size
sp_rank = ctx.sp_rank
grad_output_part = grad_output[step_seqlen * sp_rank:step_seqlen * (sp_rank + 1), :]
grad_input = softmax
grad_2d = grad_input.view(-1, ctx.vocab_size)
arange_1d = torch.arange(start=0, end=grad_2d.size()[0], device=grad_2d.device)
grad_2d[arange_1d, target.view(-1)] -= 1
grad_input.mul_(grad_output_part.unsqueeze(dim=-1))
return grad_input, None, None, None
def vocab_sequence_parallel_cross_entropy(vocab_parallel_logits, target, sp_group):
return _VocabSequenceParallelCrossEntropy.apply(vocab_parallel_logits, target, sp_group)
File diff suppressed because it is too large Load Diff
+460
View File
@@ -0,0 +1,460 @@
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: Apache-2.0
# DeepSpeed Team
import torch
from typing import Any, Tuple
from torch import Tensor
from torch.nn import Module
from einops import rearrange
import deepspeed.comm as dist
from deepspeed.accelerator import get_accelerator
from deepspeed.module_inject.tp_shard import get_shard_size_list, set_num_kv_heads, get_num_kv_heads
from deepspeed.utils import groups
try:
from torchembed._triton import fused_rope_forward as _torchembed_rope_forward
_torchembed_available = True
except ImportError:
_torchembed_available = False
def _generate_layout_params(scatter_idx, batch_dim_idx, seq_world_size, input):
"""
This function generates the parameters required for `permute` and `reshape` operations,
which are used to process data before and after `all2all` communication.
"""
if batch_dim_idx == 0:
if scatter_idx < 2:
bs, global_seq_len, num_local_head, head_dim = input.shape
pre_all2all_inp_shape = [bs, seq_world_size, global_seq_len // seq_world_size, num_local_head, head_dim]
pre_all2all_permute_idx = (1, 0, 2, 3, 4)
post_all2all_permute_idx = (1, 2, 0, 3, 4)
post_all2all_res_shape = [bs, global_seq_len // seq_world_size, seq_world_size * num_local_head, head_dim]
else:
bs, local_seq_len, num_total_head, head_dim = input.shape
assert num_total_head % seq_world_size == 0, f"Number of heads ({num_total_head}) must be divisible by the sequence parallel size ({seq_world_size})!"
pre_all2all_inp_shape = [bs, local_seq_len, seq_world_size, num_total_head // seq_world_size, head_dim]
pre_all2all_permute_idx = (2, 0, 1, 3, 4)
post_all2all_permute_idx = (1, 0, 2, 3, 4)
post_all2all_res_shape = [bs, seq_world_size * local_seq_len, num_total_head // seq_world_size, head_dim]
else:
if scatter_idx < 2:
global_seq_len, bs, num_local_head, head_dim = input.shape
pre_all2all_inp_shape = [seq_world_size, global_seq_len // seq_world_size, bs, num_local_head, head_dim]
pre_all2all_permute_idx = None
post_all2all_permute_idx = (1, 2, 0, 3, 4)
post_all2all_res_shape = [bs, seq_world_size * global_seq_len, num_local_head // seq_world_size, head_dim]
else:
local_seq_len, bs, num_total_head, head_dim = input.shape
assert num_total_head % seq_world_size == 0, f"Number of heads ({num_total_head}) must be divisible by the sequence parallel size ({seq_world_size})!"
pre_all2all_inp_shape = [local_seq_len, bs, seq_world_size, num_total_head // seq_world_size, head_dim]
pre_all2all_permute_idx = (2, 0, 1, 3, 4)
post_all2all_permute_idx = None
post_all2all_res_shape = [local_seq_len * seq_world_size, bs, num_total_head // seq_world_size, head_dim]
return pre_all2all_permute_idx, pre_all2all_inp_shape, post_all2all_permute_idx, post_all2all_res_shape
def post_all2all(permute_idx, res_shape):
"""
Post-processing function for `all2all` communication.
"""
def post_func(input):
if permute_idx is not None:
input = input.permute(permute_idx).contiguous()
output = input.reshape(res_shape).contiguous()
return output
return post_func
def pre_all2all_fun(permute_idx, inp_shape, input):
"""
Pre-processing function for `all2all` communication.
"""
input_t = input.reshape(inp_shape).contiguous()
if permute_idx is not None:
input_t = input_t.permute(permute_idx).contiguous()
return input_t
def _rotate_half(x):
"""
change sign so the last dimension becomes [-odd, +even]
"""
x = rearrange(x, '... (j d) -> ... j d', j=2)
x1, x2 = x.unbind(dim=-2)
return torch.cat((-x2, x1), dim=-1)
def apply_rotary_pos_emb(t, freqs_cos, freqs_sin):
"""
input tensor t is of shape [seq_length, ..., dim]
rotary positional embeding tensor freqs is of shape [seq_length, ..., dim]
check https://kexue.fm/archives/8265 for detailed formulas
"""
rot_dim = freqs_cos.shape[-1]
# ideally t_pass is empty so rotary pos embedding is applied to all tensor t
t, t_pass = t[..., :rot_dim], t[..., rot_dim:]
# torchembed's fused kernel takes cos/sin caches of shape [seq_length, rot_dim] and
# applies them along the second-to-last axis of t. Some callers (e.g. fpdt_layer.py)
# pass tensors where the sequence dim isn't at position 0, so only take the fused path
# when t's dim 0 unambiguously matches the freqs' sequence dim (with no other
# non-broadcast dims in freqs); otherwise fall back to the reference path.
if (_torchembed_available and get_accelerator().on_accelerator(t) and rot_dim % 2 == 0
and freqs_cos.shape[0] == t.shape[0] and freqs_cos.numel() == freqs_cos.shape[0] * rot_dim):
freqs_cos_2d = freqs_cos.reshape(freqs_cos.shape[0], rot_dim)
freqs_sin_2d = freqs_sin.reshape(freqs_sin.shape[0], rot_dim)
# torchembed expects the sequence dim second-to-last: (*leading, seq_len, dim)
t_kernel = t.movedim(0, -2).contiguous()
t_out, _ = _torchembed_rope_forward(t_kernel, t_kernel, freqs_cos_2d, freqs_sin_2d)
t = t_out.movedim(-2, 0).contiguous()
else:
# first part is cosine component
# second part is sine component, need to change signs with _rotate_half method
t = (t * freqs_cos) + (_rotate_half(t) * freqs_sin)
res = t if t_pass.shape[-1] == 0 else torch.cat((t, t_pass), dim=-1)
return res
def uneven_heads_all2all(input, scatter_idx, gather_idx, batch_dim_idx, group):
seq_world_size = dist.get_world_size(group)
inp_shape = list(input.shape)
assert batch_dim_idx in [0, 1], "batch_dim_idx must be either 0 or 1"
if not (scatter_idx < 2):
input_splits = get_shard_size_list(inp_shape[scatter_idx], seq_world_size)
input = input.transpose(0, scatter_idx).contiguous()
local_heads = input_splits[groups._get_sequence_parallel_rank()]
output_splits = [local_heads] * seq_world_size
output_buffer_shape = [seq_world_size * local_heads] + list(input.shape[1:])
output = torch.empty(output_buffer_shape, device=input.device, dtype=input.dtype)
dist.all_to_all_single(output,input,output_split_sizes=output_splits,\
input_split_sizes=input_splits,group=group)
###[seq_ws*local_heads, ...] to [seq_ws, local_heads, ...]
output = output.view(seq_world_size, local_heads, *output.shape[1:])
###[seq_ws,local_heads,b,seq_len,...] to [seq_ws,seq_len,b,local_heads,...]
### batch_dim_idx=0 [seq_ws,local_heads,seq_len,b,...] to [b, seq_ws, seq_len, local_heads ...]
### batch_dim_idx=1 [seq_ws,local_heads,b,seq_len,...] to [seq_ws,seq_len,b,local_heads,...]
if batch_dim_idx == 0:
order = [3, 0, 2, 1] + list(range(4, len(output.shape)))
output = output.permute(order).contiguous()
###[b, seq_ws*local_seq_len, local_heads,...]
output = output.view(output.shape[0], inp_shape[gather_idx] * seq_world_size,
*output.shape[3:]).contiguous()
elif batch_dim_idx == 1:
output = output.transpose(1, 3).contiguous()
###[seq_ws*local_seq_len, b, local_heads,...]
output = output.view(inp_shape[gather_idx] * seq_world_size, *output.shape[2:]).contiguous()
else:
# The compatibility handling of 4D and 3D tensors, standardizing to 3D.
input = input.reshape(input.shape[0], input.shape[1], -1)
if batch_dim_idx == 0: #b,s,h
input = input.permute(1, 2, 0).contiguous() #s,h,b
elif batch_dim_idx == 1: #s,b,h
input = input.transpose(1, 2).contiguous() #s,h,b
seq_len, h, batch_size = input.shape
num_local_heads_list = get_shard_size_list(get_num_kv_heads(), seq_world_size)
local_heads = num_local_heads_list[groups._get_sequence_parallel_rank()]
h_dim = h // local_heads
local_seq_len = seq_len // seq_world_size
input = input.view(seq_len * h, batch_size)
local_seq_len_with_heads = int(input.shape[0] / seq_world_size) # dim size of local_seq_len*local_heads*hdim
input_splits = [local_seq_len_with_heads] * seq_world_size
coeff = local_seq_len_with_heads // local_heads #per head: dim size of local_seq_len*hdim
#uneven seq_world_size coeff, total_heads/local_heads.
heads_scale_coeff = get_num_kv_heads() / local_heads
output_splits = [num_local_heads * coeff for num_local_heads in num_local_heads_list]
output_buff_d1_size = int(heads_scale_coeff * local_seq_len_with_heads)
total_h = int(inp_shape[gather_idx] * heads_scale_coeff)
output = torch.empty(output_buff_d1_size, input.shape[1], device=input.device, dtype=input.dtype)
dist.all_to_all_single(output,input,output_split_sizes=output_splits, \
input_split_sizes=input_splits,group=group)
##################
#suppose 7 heads divide into 4 ranks [2,2,2,1]
#chunk_num_heads_small=floor(7/4)=1
#chunk_num_heads_large=ceil(7/4)=2
#num_chunk_heads_large=len([2,2,2])=3, all2all_buffer_counts
#num_chunk_heads_small=len([1])=1, all2all_buffer_counts
#total_num_large_heads=sum([2,2,2])=7
#total_num_small_heads=sum([1])=1
chunk_num_heads_small = get_num_kv_heads() // seq_world_size # even heads compatible
chunk_num_heads_large = chunk_num_heads_small + 1
num_chunk_heads_large = get_num_kv_heads() % seq_world_size
num_chunk_heads_small = seq_world_size - num_chunk_heads_large
total_num_large_heads = num_chunk_heads_large * chunk_num_heads_large
total_num_small_heads = num_chunk_heads_small * chunk_num_heads_small
heads_large_combine_size = coeff * total_num_large_heads
heads_small_combine_size = coeff * total_num_small_heads
heads_large_chunk, heads_small_chunk = output.split([heads_large_combine_size, heads_small_combine_size],
dim=0)
heads_large_chunk = heads_large_chunk.view(num_chunk_heads_large, local_seq_len, chunk_num_heads_large, h_dim,
batch_size)
heads_small_chunk = heads_small_chunk.view(num_chunk_heads_small, local_seq_len, chunk_num_heads_small, h_dim,
batch_size)
if batch_dim_idx == 0:
#[all2all_buffer_counts, local_seq_len, n_heads,dim,batch]->[batch,local_seq_len,all2all_buffer_counts*n_heads,dim]
order = [4, 1, 0, 2, 3]
heads_large_chunk = heads_large_chunk.permute(order).contiguous().view(batch_size, local_seq_len,
total_num_large_heads, h_dim)
heads_small_chunk = heads_small_chunk.permute(order).contiguous().view(batch_size, local_seq_len,
total_num_small_heads, h_dim)
elif batch_dim_idx == 1:
#[all2all_buffer_counts, local_seq_len, n_heads,dim,batch]->[local_seq_len,batch,all2all_buffer_counts*n_heads,dim]
order = [1, 4, 0, 2, 3]
heads_large_chunk = heads_large_chunk.permute(order).contiguous().view(local_seq_len, batch_size,
total_num_large_heads, h_dim)
heads_small_chunk = heads_small_chunk.permute(order).contiguous().view(local_seq_len, batch_size,
total_num_small_heads, h_dim)
output = torch.cat([heads_large_chunk, heads_small_chunk], dim=2).contiguous()
inp_shape[scatter_idx] = inp_shape[scatter_idx] // seq_world_size
output_shape= inp_shape[: gather_idx] + \
[total_h,] + \
inp_shape[gather_idx + 1:]
output = output.view(output_shape)
return output
def single_all_to_all(input, scatter_idx, gather_idx, batch_dim_idx, group, async_op=False, handle=None, type=None):
seq_world_size = dist.get_world_size(group)
# we only need num_heads once
num_heads = input.shape[2]
if get_num_kv_heads() is not None or (num_heads % seq_world_size != 0 and not scatter_idx < 2):
# Assuming here that the number of heads for q is consistent with kv
# If not, additional logic is required for cases like GQA
if get_num_kv_heads() is None:
assert num_heads > seq_world_size, f"Number of heads ({num_heads}) must be larger than sequence parallel size ({seq_world_size})"
# set heads at first call by num_total_heads.
# then use ``get_num_kv_heads() is not None`` to re-entry uneven path.
set_num_kv_heads(num_heads)
assert async_op == False, "uneven head sp does not support async op"
return uneven_heads_all2all(input, scatter_idx, gather_idx, batch_dim_idx, group)
pre_all2all_permute_idx, pre_all2all_inp_shape, post_all2all_permute_idx, post_all2all_res_shape = _generate_layout_params(
scatter_idx, batch_dim_idx, seq_world_size, input)
input_t = pre_all2all_fun(pre_all2all_permute_idx, pre_all2all_inp_shape, input)
post_all2all_fun = post_all2all(post_all2all_permute_idx, post_all2all_res_shape)
output = torch.empty_like(input_t)
work = dist.all_to_all_single(output, input_t, group=group, async_op=async_op)
if async_op:
if type in ('dq', 'dk'):
handle[type + '_work'] = work
handle[type + '_grad'] = output
handle[type + '_post_all2all_func'] = post_all2all_fun
return output.view(post_all2all_res_shape)
res = post_all2all_fun(output)
return res
class _DimZeroAllToAll(torch.autograd.Function):
"""Differentiable All2All across dimension 0."""
@staticmethod
def forward(ctx: Any, group: dist.ProcessGroup, input: Tensor) -> Tensor:
world_size = dist.get_world_size(group)
assert input.shape[0] == world_size, f"Dim 0 {input.shape[0]} is not world size"
ctx.group = group
output = torch.empty_like(input).contiguous()
# torch.distributed.nn.functional.all_to_all_single(output, input.contiguous(), group=group)
dist.all_to_all_single(output, input.contiguous(), group=group)
return output
@staticmethod
def backward(ctx: Any, *grad_output: Tensor) -> Tuple[None, Tensor]:
return (None, _DimZeroAllToAll.apply(ctx.group, *grad_output))
class _SeqAllToAll(torch.autograd.Function):
@staticmethod
def forward(ctx: Any,
group: dist.ProcessGroup,
input: Tensor,
scatter_idx: int,
gather_idx: int,
batch_dim_idx: int,
stream=None,
handle=None,
type=None,
is_fwd=True) -> Tensor:
ctx.group = group
ctx.scatter_idx = scatter_idx
ctx.gather_idx = gather_idx
ctx.stream = stream
ctx.handle = handle
ctx.type = type
ctx.batch_dim_idx = batch_dim_idx
if ctx.handle is None:
res = single_all_to_all(input, scatter_idx, gather_idx, batch_dim_idx, group, False)
else:
# overlap communication path
if not is_fwd and type == 'o':
assert ctx.stream != None
res = single_all_to_all(input, scatter_idx, gather_idx, batch_dim_idx, group, False)
get_accelerator().current_stream().wait_stream(ctx.stream)
# The computation of d o_weight can overlap with the communication of d o_input
elif not is_fwd and type in ('q', 'k'):
# Achieve communication overlap by pipelining the matrix computation and communication of dq, dk, and dv
type = 'd' + type
res = single_all_to_all(input, scatter_idx, gather_idx, batch_dim_idx, group, True, handle, type)
elif is_fwd and type in ('q', 'k'):
# Achieve communication overlap by pipelining the matrix computation and communication of q, k, and v
type = 'fwd_' + type
res = single_all_to_all(input, scatter_idx, gather_idx, batch_dim_idx, group, False, handle, type)
else:
res = single_all_to_all(input, scatter_idx, gather_idx, batch_dim_idx, group, False)
return res
@staticmethod
def backward(ctx: Any, *grad_output: Tensor) -> Tuple[None, Tensor, None, None]:
return (None,
_SeqAllToAll.apply(ctx.group, *grad_output, ctx.gather_idx, ctx.scatter_idx, ctx.batch_dim_idx,
ctx.stream, ctx.handle, ctx.type, False), None, None, None, None, None, None, None)
class DistributedAttention(torch.nn.Module):
"""Initialization.
Arguments:
local_attention (Module): local attention with q,k,v
sequence_process_group (ProcessGroup): sequence parallel process group
scatter_idx (int): scatter_idx for all2all comm
gather_idx (int): gather_idx for all2all comm
"""
def __init__(
self,
local_attention: Module,
sequence_process_group: dist.ProcessGroup,
scatter_idx: int = 2,
gather_idx: int = 0,
sp_stream=None,
) -> None:
super(DistributedAttention, self).__init__()
self.local_attn = local_attention
self.spg = sequence_process_group
self.scatter_idx = scatter_idx
self.gather_idx = gather_idx
self.sp_overlap_comm = False
self.overlap_handles = None
self.sp_stream = sp_stream
if sp_stream is not None:
self.overlap_handles = {}
self.sp_overlap_comm = True
self.default_stream = get_accelerator().default_stream()
def layer_sync(self, layer):
if self.sp_overlap_comm and hasattr(layer, 'done_event'):
self.default_stream.wait_event(layer.done_event)
def forward(self,
query: Tensor,
key: Tensor,
value: Tensor,
batch_dim_idx: int,
rotary_pos_emb=None,
*args: Any,
**kwargs) -> Tensor:
""" forward
Arguments:
query (Tensor): query input to the layer
key (Tensor): key input to the layer
value (Tensor): value input to the layer
batch_dim_idx (int): indicating which dim is batch
args: other args
Returns:
* output (Tensor): context output
"""
# TODO Merge three alltoall calls into one
# TODO (Reza): change the api on the megatron-deepspeed side so that we only receive all data (q,k, and v) together!
#in shape : e.g., [s/p:h:]
def bwd_hook(layer_type):
def pre_hook_fun(grad):
type = 'd' + layer_type
self.overlap_handles[type + '_work'].wait()
self.sp_stream.wait_stream(self.default_stream)
all2all_output = self.overlap_handles[type + '_grad']
grad = list(grad)
grad[0] = self.overlap_handles[type + '_post_all2all_func'](all2all_output)
grad = tuple(grad)
return pre_hook_fun
self.layer_sync(query)
query_layer = _SeqAllToAll.apply(self.spg, query, self.scatter_idx, self.gather_idx, batch_dim_idx, None,
self.overlap_handles, 'q')
self.layer_sync(key)
key_layer = _SeqAllToAll.apply(self.spg, key, self.scatter_idx, self.gather_idx, batch_dim_idx, None,
self.overlap_handles, 'k')
if self.sp_overlap_comm:
self.default_stream.wait_stream(self.sp_stream)
value_layer = _SeqAllToAll.apply(self.spg, value, self.scatter_idx, self.gather_idx, batch_dim_idx, None,
self.overlap_handles, 'v')
if self.sp_overlap_comm:
# Register a hook to synchronize dq and dk after the all-to-all
# operation when the gradient data is used.
# Place this logic after the q, k, v all-to-all operation to
# improve interpreter speed to
# call and launch of the forward all-to-all communication.
grad_fn_q = query.grad_fn.next_functions[0][0]
grad_fn_q.register_prehook(bwd_hook(layer_type='q'))
grad_fn_k = key.grad_fn.next_functions[0][0]
grad_fn_k.register_prehook(bwd_hook(layer_type='k'))
#out shape : e.g., [s:h/p:]
if rotary_pos_emb is not None:
pos_emb_cos, pos_emb_sin = rotary_pos_emb[0].permute(1, 0, 2, 3), rotary_pos_emb[1].permute(1, 0, 2, 3)
query_layer = apply_rotary_pos_emb(query_layer, pos_emb_cos, pos_emb_sin)
key_layer = apply_rotary_pos_emb(key_layer, pos_emb_cos, pos_emb_sin)
context_layer = self.local_attn(query_layer, key_layer, value_layer, *args, **kwargs)
output = _SeqAllToAll.apply(self.spg, context_layer, self.gather_idx, self.scatter_idx, batch_dim_idx,
self.sp_stream, self.overlap_handles, 'o')
#out e.g., [s/p::h]
return output
+724
View File
@@ -0,0 +1,724 @@
# 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)