Files
vllm-omni/.claude/skills/add-diffusion-model/references/parallelism-patterns.md
T
2026-04-14 20:26:27 +08:00

18 KiB
Raw Blame History

Parallelism Patterns Reference

Overview

vLLM-Omni supports multiple parallelism strategies for diffusion models. Each targets a different bottleneck:

Strategy Splits Best For Constraint
Tensor Parallel (TP) Model layers across GPUs Latency reduction, large models Requires fast GPU interconnect, num_heads % tp == 0
Sequence Parallel (SP/USP) Sequence tokens across GPUs Long sequences (video, high-res) Near-linear scaling
CFG Parallel Positive/negative CFG branches Models using classifier-free guidance Exactly 2 GPUs
HSDP Weight shards via FSDP2 VRAM reduction Cannot combine with TP
VAE Patch Parallel VAE decode spatial tiles Large VAE outputs Auto-enables tiling

Recommended integration order: TP → SP → CFG Parallel → HSDP

Official design docs:


Tensor Parallelism (TP)

Replace standard nn.Linear with vLLM's parallel linear layers. This is the most invasive change but provides direct VRAM savings and compute speedup.

Layer replacement rules

Pattern vLLM Layer When to Use
Fan-out (first in FFN) ColumnParallelLinear Projection that splits output across ranks
Fan-in (second in FFN) RowParallelLinear Projection that gathers across ranks
QKV projection QKVParallelLinear Fused Q/K/V for self-attention
Single Q or K or V ColumnParallelLinear Separate projections (cross-attention)
Attention output RowParallelLinear Output projection after attention
Must not shard ReplicatedLinear Layers that must stay replicated

MLP Block (Up-Down Pattern)

from vllm.model_executor.layers.linear import (
    ColumnParallelLinear, RowParallelLinear,
)

class TPFeedForward(nn.Module):
    def __init__(self, dim, ffn_dim):
        super().__init__()
        self.fc1 = ColumnParallelLinear(dim, ffn_dim, bias=False, return_bias=False)
        self.fc2 = RowParallelLinear(
            ffn_dim, dim, bias=False,
            input_is_parallel=True,  # Input already sharded from fc1
            return_bias=False,
        )

    def forward(self, x):
        x, _ = self.fc1(x)
        x = torch.nn.functional.gelu(x)
        x, _ = self.fc2(x)
        return x

Attention Block (QKV-Out Pattern)

from vllm.model_executor.layers.linear import QKVParallelLinear, RowParallelLinear
from vllm_omni.diffusion.attention.layer import Attention

class TPSelfAttention(nn.Module):
    def __init__(self, dim, num_heads, num_kv_heads=None):
        super().__init__()
        num_kv_heads = num_kv_heads or num_heads
        self.head_dim = dim // num_heads

        self.to_qkv = QKVParallelLinear(
            hidden_size=dim,
            head_size=self.head_dim,
            total_num_heads=num_heads,
            total_num_kv_heads=num_kv_heads,
            bias=False,
            return_bias=False,
        )
        self.to_out = RowParallelLinear(
            dim, dim, bias=False,
            input_is_parallel=True,
            return_bias=False,
        )
        self.attn = Attention(
            num_heads=self.to_qkv.num_heads,      # Local heads per GPU
            head_size=self.head_dim,
            softmax_scale=1.0 / (self.head_dim ** 0.5),
            causal=False,
            num_kv_heads=self.to_qkv.num_kv_heads,  # Local KV heads per GPU
        )

    def forward(self, x):
        qkv, _ = self.to_qkv(x)
        q, k, v = qkv.split(
            [self.to_qkv.num_heads * self.head_dim,
             self.to_qkv.num_kv_heads * self.head_dim,
             self.to_qkv.num_kv_heads * self.head_dim],
            dim=-1,
        )
        B, S, _ = x.shape
        q = q.view(B, S, self.to_qkv.num_heads, self.head_dim)
        k = k.view(B, S, self.to_qkv.num_kv_heads, self.head_dim)
        v = v.view(B, S, self.to_qkv.num_kv_heads, self.head_dim)
        out = self.attn(q, k, v)
        out = out.reshape(B, S, -1)
        out, _ = self.to_out(out)
        return out

QKV Fusion in load_weights

When you fuse separate Q/K/V into QKVParallelLinear, map diffusers' separate weight names:

stacked_params_mapping = [
    ("to_qkv", "to_q", "q"),
    ("to_qkv", "to_k", "k"),
    ("to_qkv", "to_v", "v"),
]

def load_weights(self, weights):
    params = dict(self.named_parameters())
    loaded = set()
    for name, tensor in weights:
        for fused_name, orig_name, shard_id in stacked_params_mapping:
            if orig_name in name:
                name = name.replace(orig_name, fused_name)
                param = params[name]
                param.weight_loader(param, tensor, shard_id)
                loaded.add(name)
                break
        else:
            if name in params:
                param = params[name]
                if hasattr(param, "weight_loader"):
                    param.weight_loader(param, tensor)
                else:
                    default_weight_loader(param, tensor)
                loaded.add(name)
    return loaded

RMSNorm with TP

When RMSNorm sits between TP-sharded dimensions, use DistributedRMSNorm — it computes global RMS via all-reduce across TP ranks. See the Wan2.2 implementation for the pattern.

TP Constraints

  • num_heads % tp_size == 0
  • num_kv_heads % tp_size == 0
  • Use self.to_qkv.num_heads (local per-GPU count), not total heads, for split sizes

Testing TP

python text_to_image.py --model Your-org/your-model \
  --tensor-parallel-size 2 --output "tp_test.png"

Verify: speedup, memory reduction proportional to TP size, quality matches single-GPU.

Reference implementations

Model Path
Z-Image vllm_omni/diffusion/models/z_image/z_image_transformer.py
FLUX vllm_omni/diffusion/models/flux/flux_transformer.py
Qwen-Image vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py

Sequence Parallelism (SP / USP)

SP splits sequence tokens across GPUs using Ulysses (all-to-all) or Ring (P2P) communication. It is applied non-intrusively via the _sp_plan dict — no changes to forward() logic.

The framework automatically registers hooks to shard inputs and gather outputs at nn.Module boundaries.

Step 1: Identify module boundaries

Find where tensors need sharding/gathering:

class MyTransformer(nn.Module):
    def __init__(self):
        self.patch_embed = PatchEmbed()    # Before blocks
        self.pos_embed = RoPE()            # RoPE may need splitting
        self.blocks = nn.ModuleList([...]) # Blocks process sharded x
        self.norm_out = LayerNorm()
        self.proj_out = Linear()           # Gather after this

    def forward(self, x):
        x = self.patch_embed(x)
        pos = self.pos_embed(x)
        for block in self.blocks:
            x = block(x, pos)
        x = self.norm_out(x)
        return self.proj_out(x)

Step 2: Handle inline operations

_sp_plan hooks only work at nn.Module boundaries. Inline ops like torch.cat() must be extracted into submodules:

# BAD: Inline — hooks can't intercept
unified = torch.cat([x, cap_feats], dim=1)

# GOOD: Extract into submodule
class UnifiedPrepare(nn.Module):
    def forward(self, x, cap_feats):
        return torch.cat([x, cap_feats], dim=1)

self.unified_prepare = UnifiedPrepare()
unified = self.unified_prepare(x, cap_feats)

Common cases: torch.cat(), pad_sequence(), tensor.reshape(), complex preprocessing.

Step 3: Write _sp_plan

Pattern 1: Shard at first block, gather at output (most common)

from vllm_omni.diffusion.distributed.sp_plan import (
    SequenceParallelInput, SequenceParallelOutput,
)

class StandardTransformer(nn.Module):
    _sp_plan = {
        "blocks.0": {
            "hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
        },
        "proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
    }

Pattern 2: Shard RoPE outputs separately

class TransformerWithRoPE(nn.Module):
    _sp_plan = {
        "rope": {
            0: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True),
            1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True),
        },
        "blocks.0": {
            "hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
        },
        "proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
    }

Pattern 3: Dual-stream (shard image, replicate text)

class DualStreamTransformer(nn.Module):
    _sp_plan = {
        "rope_preparer": {
            2: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True),
            3: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True),
        },
        "transformer_blocks.0": {
            "hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
        },
        "proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
    }

API Reference

SequenceParallelInput:

Parameter Type Description
split_dim int Dimension to split (usually 1 for sequence)
expected_dims int/None Expected tensor rank for validation
split_output bool False: shard input params; True: shard output tensors
auto_pad bool Auto-pad if sequence not divisible by world_size

SequenceParallelOutput:

Parameter Type Description
gather_dim int Dimension to gather (usually 1 for sequence)
expected_dims int/None Expected tensor rank for validation

Module naming:

Key Meaning
"blocks.0" First element of ModuleList
"blocks.*" All elements of ModuleList
"rope" Named submodule

Dictionary value types:

Key type split_output Description
"param_name" (str) False Shard input parameter by name
0, 1, ... (int) True Shard output tuple by index

Approach 2: Intrusive Modification (Complex Cases)

For dynamic sharding logic that can't be expressed via _sp_plan:

from vllm_omni.diffusion.distributed.sp_sharding import sp_shard, sp_gather

def forward(self, hidden_states, ...):
    if self.parallel_config.sequence_parallel_size > 1:
        hidden_states = sp_shard(hidden_states, dim=1)
    for block in self.blocks:
        hidden_states = block(hidden_states)
    if self.parallel_config.sequence_parallel_size > 1:
        hidden_states = sp_gather(hidden_states, dim=1)
    return hidden_states

Use intrusive modification as a last resort — _sp_plan is preferred for maintainability.

UAA Mode (Experimental)

ulysses_mode="advanced_uaa" handles arbitrary sequence lengths and head counts that aren't divisible by ulysses_degree. Uses variable all-to-all split sizes and temporary head padding.

Combining SP methods

Ulysses and Ring can be combined: ulysses_degree × ring_degree = total SP GPUs.

DiffusionParallelConfig(ulysses_degree=2, ring_degree=2)  # 4 GPUs total

Testing SP

# Offline
python text_to_image.py --model Your-model --ulysses-degree 2

# Online serving
vllm serve Your-model --omni --usp 2

Reference implementations

Model Path
Qwen-Image vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py
Wan2.2 vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py
Z-Image vllm_omni/diffusion/models/z_image/z_image_transformer.py

CFG Parallelism

Distributes positive/negative Classifier-Free Guidance branches across 2 GPUs.

Implementation

Inherit CFGParallelMixin and implement diffuse():

from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin

class YourPipeline(nn.Module, CFGParallelMixin):
    def diffuse(self, latents, timesteps, prompt_embeds, negative_embeds,
                do_true_cfg, true_cfg_scale, **kwargs):
        for i, t in enumerate(timesteps):
            positive_kwargs = {
                "hidden_states": latents,
                "encoder_hidden_states": prompt_embeds,
                "timestep": t,
            }
            negative_kwargs = {
                "hidden_states": latents,
                "encoder_hidden_states": negative_embeds,
                "timestep": t,
            } if do_true_cfg else None

            noise_pred = self.predict_noise_maybe_with_cfg(
                do_true_cfg=do_true_cfg,
                true_cfg_scale=true_cfg_scale,
                positive_kwargs=positive_kwargs,
                negative_kwargs=negative_kwargs,
            )
            latents = self.scheduler_step_maybe_with_cfg(
                noise_pred, t, latents, do_true_cfg
            )
        return latents

Customization hooks

Method Override when
predict_noise() Non-standard transformer call (e.g., dual-transformer like Wan2.2)
cfg_normalize_function() Custom normalization (e.g., LongCat with clamping)
combine_cfg_noise() Multi-output models (e.g., video + audio: CFG on video, positive-only on audio)

Custom predict_noise (Wan2.2 — selects active transformer):

def predict_noise(self, current_model=None, **kwargs):
    if current_model is None:
        current_model = self.transformer
    return current_model(**kwargs)[0]

Custom combine_cfg_noise (multi-output):

def combine_cfg_noise(self, positive_pred, negative_pred, scale, normalize):
    video_pos, audio_pos = positive_pred
    video_neg, audio_neg = negative_pred
    video_combined = super().combine_cfg_noise(video_pos, video_neg, scale, normalize)
    return (video_combined, audio_pos)

Composite scheduler for multi-output

When each output has its own schedule:

class VideoAudioScheduler:
    def __init__(self, video_scheduler, audio_scheduler):
        self.video_scheduler = video_scheduler
        self.audio_scheduler = audio_scheduler

    def step(self, noise_pred, t, latents, return_dict=False, generator=None):
        video_out = self.video_scheduler.step(
            noise_pred[0], t[0], latents[0], return_dict=False, generator=generator
        )[0]
        audio_out = self.audio_scheduler.step(
            noise_pred[1], t[1], latents[1], return_dict=False, generator=generator
        )[0]
        return ((video_out, audio_out),)

Testing CFG Parallel

python text_to_image.py --model Your-model \
  --cfg-parallel-size 2 --cfg-scale 4.0 \
  --negative-prompt "ugly, unclear"

Constraint: guidance_scale > 1.0 and negative prompt must be provided.

Reference implementations

Model Path
Qwen-Image vllm_omni/diffusion/models/qwen_image/cfg_parallel.py
Wan2.2 vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py
Mixin base vllm_omni/diffusion/distributed/cfg_parallel.py

HSDP (Hybrid Sharded Data Parallel)

Shards model weights across GPUs using PyTorch FSDP2. Reduces per-GPU VRAM without changing computation.

Implementation

Add _hsdp_shard_conditions to the transformer class:

class YourTransformer(nn.Module):
    @staticmethod
    def _is_transformer_block(name: str, module) -> bool:
        return "blocks" in name and name.split(".")[-1].isdigit()

    _hsdp_shard_conditions = [_is_transformer_block]

For MoE models, add additional conditions:

class MoETransformer(nn.Module):
    @staticmethod
    def _is_transformer_block(name, module):
        return "blocks" in name and name.split(".")[-1].isdigit()

    @staticmethod
    def _is_moe_expert(name, module):
        return "experts" in name and name.split(".")[-1].isdigit()

    _hsdp_shard_conditions = [_is_transformer_block, _is_moe_expert]

A module is sharded if any condition returns True.

Constraints

  • Cannot combine with Tensor Parallelism
  • For standalone HSDP (no other parallelism), hsdp_shard_size must be specified explicitly
  • Can combine with SP: HSDP reduces memory while SP distributes sequence

Testing HSDP

from vllm_omni.diffusion.data import DiffusionParallelConfig

parallel_config = DiffusionParallelConfig(use_hsdp=True, hsdp_shard_size=8)
omni = Omni(model="your-model", parallel_config=parallel_config)

Or CLI:

vllm serve Your-model --omni --use-hsdp

Verify: logs show "HSDP Inference: replicate_size=..., shard_size=..." and "Sharded N modules + root". Check VRAM reduction.

Reference implementations

Model Path
Wan2.2 vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py
HSDP Core vllm_omni/diffusion/distributed/hsdp.py

VAE Patch Parallelism

Shards VAE decode spatially across ranks using tiling:

python text_to_image.py --model Your-model --vae-patch-parallel-size 4

Auto-enables --vae-use-tiling. Uses DistributedAutoencoderKLWan or similar distributed VAE. Set vae_patch_parallel_size in DiffusionParallelConfig.


Combining Parallelism Methods

Common multi-GPU recipes:

# 4 GPUs: CFG (2) × Ulysses (2)
python text_to_image.py --model Qwen/Qwen-Image \
  --cfg-parallel-size 2 --ulysses-degree 2

# 8 GPUs: Ulysses (4) × Ring (2) + VAE patch (8)
python text_to_video.py --model Wan-AI/Wan2.2-T2V-A14B-Diffusers \
  --ulysses-degree 4 --ring-degree 2 --vae-patch-parallel-size 8

# 2 GPUs: HSDP + Ulysses (cannot combine HSDP with TP)
vllm serve Your-model --omni --use-hsdp --usp 2

Discovering Parallelism Support

Check which parallelism methods a model supports:

Check How
Ulysses / Ring SP Transformer defines _sp_plan. Search: grep -r '_sp_plan' vllm_omni/diffusion/models/
CFG Parallel Pipeline inherits CFGParallelMixin. Search: grep -r 'CFGParallelMixin' vllm_omni/diffusion/models/
TP Uses ColumnParallelLinear / QKVParallelLinear. Search: grep -r 'ParallelLinear|QKVParallel' vllm_omni/diffusion/models/<model>/
HSDP Transformer defines _hsdp_shard_conditions. Search: grep -r '_hsdp_shard_conditions' vllm_omni/diffusion/models/

The canonical per-model support table is in docs/user_guide/diffusion/parallelism_acceleration.md.