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

176 lines
7.4 KiB
Python

# 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