[Diffusion] Non-Intrusive Sequence Parallelism (SP) Model Support Abstraction for vLLM-Omni Framework (#779)

Signed-off-by: mxuax <mxuax@connect.ust.hk>
Signed-off-by: XU Mingshi <91017482+mxuax@users.noreply.github.com>
This commit is contained in:
XU Mingshi authored and GitHub committed 2026-01-21 14:43:09 +08:00
1 parent 334d306d9a
commit 25b08b3371
17 files changed
+3369 -479

No files matched your search

@@ -27,7 +27,7 @@ The following table shows which models are currently supported by parallelism me
| **Qwen-Image-Edit** | `Qwen/Qwen-Image-Edit` | ✅ | ✅ | ✅ | ❌ |
| **Qwen-Image-Edit-2509** | `Qwen/Qwen-Image-Edit-2509` | ✅ | ✅ | ✅ | ❌ |
| **Qwen-Image-Layered** | `Qwen/Qwen-Image-Layered` | ✅ | ✅ | ✅ | ❌ |
| **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ❌ | ❌ | ❌ | ✅ (TP=2 only) |
| **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ | ❌ | ✅ (TP=2 only) |
| **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ❌ | ❌ | ❌ |
@@ -220,100 +220,139 @@ To measure the parallelism methods, we run benchmarks with **Qwen/Qwen-Image** m
##### How to parallelize a new model
If a diffusion model has been deployed in vLLM-Omni and supports single-card inference, you can refer to the following instructions to parallelize it with [Ulysses-SP](https://arxiv.org/pdf/2309.14509).
NOTE: "Terminology: SP vs CP"
Our "Sequence Parallelism" (SP) corresponds to "Context Parallelism" (CP) in the [diffusers library](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/_modeling_parallel.py).
We use "Sequence Parallelism" to align with vLLM-Omni's terminology.
---
This section uses **Qwen-Image** (`QwenImageTransformer2DModel`) as the reference implementation. Qwen-Image is a **dual-stream** transformer (text + image) that performs **joint attention** across the concatenated sequences. Because of that, when enabling sequence parallel you typically:
###### Non-intrusive `_sp_plan` (Recommended)
- Chunk **image tokens** (`hidden_states`) across SP ranks along the **sequence dimension**.
- Keep **text embeddings** (`encoder_hidden_states`) **replicated** on all SP ranks for correctness (unless you implement full joint-SP semantics and explicitly split text too).
- Chunk **image RoPE freqs** to match the chunked image tokens.
- `all_gather` the final output back along the sequence dimension.
The `_sp_plan` mechanism allows SP without modifying `forward()` logic. The framework automatically registers hooks to shard inputs and gather outputs at module boundaries.
First, add the sequence-parallel helpers and (for Qwen-Image) the forward-context flag. Then, in the transformer's `forward()`:
**Requirements for `forward()` function:**
- Chunk `hidden_states` by SP world size.
- Set `get_forward_context().split_text_embed_in_sp = False` because Qwen-Image uses joint attention and we would use a full text embedding in each rank.
- After `pos_embed`, chunk `img_freqs` on `dim=0` (token axis) to match the chunked image tokens; only chunk `txt_freqs` if you explicitly split text embeddings in SP.
- Tensor operations that need sharding/gathering must happen at **`nn.Module` boundaries** (not inline Python operations)
- If your `forward()` contains inline tensor operations (e.g., `torch.cat`, `pad_sequence`) that need sharding, **extract them into a submodule**
Taking `vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py` as an example:
**When to create a submodule:**
```diff
from vllm_omni.diffusion.distributed.parallel_state import (
get_sequence_parallel_rank,
get_sequence_parallel_world_size,
get_sp_group,
```python
# ❌ BAD: Inline operations - hooks cannot intercept
def forward(self, x, cap_feats):
unified = torch.cat([x, cap_feats], dim=1) # Cannot be sharded via _sp_plan
...
# ✅ GOOD: Extract into a submodule
class UnifiedPrepare(nn.Module):
def forward(self, x, cap_feats):
return torch.cat([x, cap_feats], dim=1) # Now can be sharded via _sp_plan
class MyModel(nn.Module):
def __init__(self):
self.unified_prepare = UnifiedPrepare() # Submodule
def forward(self, x, cap_feats):
unified = self.unified_prepare(x, cap_feats) # Hook can intercept here
```
---
###### Defining `_sp_plan`
**Type definitions** (see [diffusers `_modeling_parallel.py`](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/_modeling_parallel.py) for reference):
```python
from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelInput, # Corresponds to diffusers' ContextParallelInput
SequenceParallelOutput, # Corresponds to diffusers' ContextParallelOutput
)
from vllm_omni.diffusion.forward_context import get_forward_context
class QwenImageTransformer2DModel(...):
def forward(
self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor = None,
encoder_hidden_states_mask: torch.Tensor = None,
timestep: torch.LongTensor = None,
img_shapes: list[tuple[int, int, int]] | None = None,
txt_seq_lens: list[int] | None = None,
...
):
+ if self.parallel_config.sequence_parallel_size > 1:
+ # Chunk image tokens along sequence dimension.
+ hidden_states = torch.chunk(
+ hidden_states,
+ get_sequence_parallel_world_size(),
+ dim=-2,
+ )[get_sequence_parallel_rank()]
+
+ # Qwen-Image uses *dual-stream* (text + image) and runs *joint attention*.
+ # Text embeddings should be replicated across SP ranks for correctness.
+ get_forward_context().split_text_embed_in_sp = False
hidden_states = self.img_in(hidden_states)
...
image_rotary_emb = self.pos_embed(img_shapes, txt_seq_lens, device=hidden_states.device)
+ def get_rotary_emb_chunk(freqs):
+ return torch.chunk(freqs, get_sequence_parallel_world_size(), dim=0)[
+ get_sequence_parallel_rank()
+ ]
+
+ if self.parallel_config.sequence_parallel_size > 1:
+ img_freqs, txt_freqs = image_rotary_emb
+ img_freqs = get_rotary_emb_chunk(img_freqs)
+ if get_forward_context().split_text_embed_in_sp:
+ txt_freqs = get_rotary_emb_chunk(txt_freqs)
+ image_rotary_emb = (img_freqs, txt_freqs)
```
Next, at the end of the `forward()` function, call `get_sp_group().all_gather` to gather the chunked outputs across devices and concatenate them along the sequence dimension (matching the earlier `dim=-2` chunking):
| Parameter | Description |
|-----------|-------------|
| `split_dim` | Dimension to split/gather (usually `1` for sequence) |
| `expected_dims` | Expected tensor rank for validation (optional) |
| `split_output` | `False`: shard **input** parameters; `True`: shard **output** tensors |
| `auto_pad` | Auto-pad if sequence not divisible by world_size (Ulysses only) |
```diff
class QwenImageTransformer2DModel(...):
def forward(...):
...
hidden_states = self.norm_out(hidden_states, temb)
output = self.proj_out(hidden_states)
**Key naming convention:**
+ if self.parallel_config.sequence_parallel_size > 1:
+ output = get_sp_group().all_gather(output, dim=-2)
return Transformer2DModelOutput(sample=output)
| Key | Meaning | Python equivalent |
|-----|---------|-------------------|
| `""` | Root model | `model` |
| `"blocks.0"` | First element of ModuleList | `model.blocks[0]` |
| `"blocks.*"` | All elements of ModuleList | `for b in model.blocks` |
| `"outputs.main"` | ModuleDict entry | `model.outputs["main"]` |
**Dictionary key types:**
| Key type | `split_output` | Description |
|----------|----------------|-------------|
| `"param_name"` (str) | `False` | Shard **input parameter** by name |
| `0`, `1` (int) | `True` | Shard **output tuple** by index |
**Example** (similar to [diffusers `transformer_wan.py`](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/transformers/transformer_wan.py)):
```python
class MyTransformer(nn.Module):
_sp_plan = {
# Shard rope module OUTPUTS (returns tuple)
"rope": {
0: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), # cos
1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), # sin
},
# Shard transformer block INPUT parameter
"blocks.0": {
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
},
# Gather at final projection
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
}
```
Finally, you can set the parallel configuration and pass it to `Omni` and start parallel inference with:
```diff
from vllm_omni import Omni
+from vllm_omni.diffusion.data import DiffusionParallelConfig
ulysses_degree = 2
---
omni = Omni(
model="Qwen/Qwen-Image",
+ parallel_config=DiffusionParallelConfig(ulysses_degree=2)
)
###### Hook flow
outputs = omni.generate(prompt="A cat sitting on a windowsill", num_inference_steps=50)
```
Input → [SequenceParallelSplitHook: pre_forward] → Module.forward() → [post_forward] → ...
↓
... → [SequenceParallelGatherHook: post_forward] → Output
```
1. **SplitHook** shards tensors before/after the target module
2. **Attention layers** handle Ulysses/Ring communication internally
3. **GatherHook** collects sharded outputs
The framework automatically applies these hooks when `sequence_parallel_size > 1`.
---
###### Method 2: Intrusive modification (For complex cases)
For models with dynamic sharding logic that cannot be expressed via `_sp_plan`:
```python
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)
# ... computation ...
output = sp_gather(output, dim=1)
return output
```
---
###### Choosing the right approach
| Scenario | Approach |
|----------|----------|
| Standard transformer | `_sp_plan` |
| Inline tensor ops need sharding | Extract to submodule + `_sp_plan` |
| Dynamic/conditional sharding | Intrusive modification |
### CFG-Parallel
File diff suppressed because it is too large. Load diff
@@ -9,6 +9,7 @@ correctly with diffusion models. It uses minimal settings to keep test time
short for CI.
"""
import gc
import os
import sys
import time
@@ -21,7 +22,7 @@ import torch.distributed as dist
from PIL import Image
# ruff: noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[2]
REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
@@ -30,7 +31,6 @@ from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.diffusion.distributed.parallel_state import device_count
from vllm_omni.diffusion.envs import get_device_name
# Use random weights model for testing
models = ["riverclouds/qwen_image_random"]
PROMPT = "a photo of a cat sitting on a laptop keyboard"
@@ -51,44 +51,145 @@ def _diff_metrics(a: Image.Image, b: Image.Image) -> tuple[float, float]:
return abs_diff.mean().item(), abs_diff.max().item()
def _get_images(output):
"""Extract images from output, handling both dict and SimpleNamespace types.
def _cleanup_distributed():
"""Clean up distributed environment and GPU resources."""
if dist.is_initialized():
dist.destroy_process_group()
The output structure varies depending on serialization path:
- Direct memory: SimpleNamespace with .images attribute
- SHM serialization: dict with "images" key (dataclass converted via asdict)
- Wrapped output: SimpleNamespace(output=...) which needs unwrapping
"""
# Check if output has direct images attribute (diffusion mode)
if hasattr(output, "images") and output.images:
return output.images
for key in ["MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"]:
os.environ.pop(key, None)
# Check request_output for pipeline mode
if output.request_output is None:
return None
gc.collect()
if isinstance(output.request_output, list) and len(output.request_output) == 0:
return None
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
item = output.request_output[0]
time.sleep(5)
# Handle wrapped SimpleNamespace (e.g. from omni_stage.py)
# Some items are wrapped as SimpleNamespace(request_id=..., output=...)
while hasattr(item, "output") and not hasattr(item, "images"):
item = item.output
if item is None:
return None
# Handle both dict (from SHM serialization) and object (direct) types
if isinstance(item, dict):
return item.get("images")
return getattr(item, "images", None)
def _run_baseline(model_name: str, dtype: torch.dtype, attn_backend: str, height: int, width: int, seed: int):
"""Run baseline inference (no SP)."""
baseline_parallel_config = DiffusionParallelConfig(ulysses_degree=1, ring_degree=1)
baseline = Omni(
model=model_name,
parallel_config=baseline_parallel_config,
dtype=dtype,
attention_backend=attn_backend,
)
try:
outputs = baseline.generate(
PROMPT,
height=height,
width=width,
num_inference_steps=4,
guidance_scale=0.0,
generator=torch.Generator(get_device_name()).manual_seed(seed),
num_outputs_per_prompt=1,
)
return outputs[0].request_output[0].images
finally:
baseline.close()
_cleanup_distributed()
def _run_sp(
model_name: str,
dtype: torch.dtype,
attn_backend: str,
height: int,
width: int,
seed: int,
ulysses_degree: int,
ring_degree: int,
):
"""Run SP inference."""
sp_parallel_config = DiffusionParallelConfig(ulysses_degree=ulysses_degree, ring_degree=ring_degree)
sp = Omni(
model=model_name,
parallel_config=sp_parallel_config,
dtype=dtype,
attention_backend=attn_backend,
)
try:
outputs = sp.generate(
PROMPT,
height=height,
width=width,
num_inference_steps=4,
guidance_scale=0.0,
generator=torch.Generator(get_device_name()).manual_seed(seed),
num_outputs_per_prompt=1,
)
return outputs[0].request_output[0].images
finally:
sp.close()
_cleanup_distributed()
@pytest.mark.parametrize("model_name", models)
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("attn_backend", ["sdpa"])
def test_baseline_only(model_name: str, dtype: torch.dtype, attn_backend: str):
"""Test baseline inference only (no SP)."""
height = 256
width = 256
seed = 42
images = _run_baseline(model_name, dtype, attn_backend, height, width, seed)
assert images is not None
assert len(images) == 1
assert images[0].width == width
assert images[0].height == height
@pytest.mark.parametrize("model_name", models)
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("attn_backend", ["sdpa"])
def test_sp_ulysses2_only(model_name: str, dtype: torch.dtype, attn_backend: str):
"""Test SP inference only (ulysses=2)."""
if device_count() < 2:
pytest.skip(f"Test requires 2 GPUs but only {device_count()} available")
height = 256
width = 256
seed = 42
images = _run_sp(model_name, dtype, attn_backend, height, width, seed, ulysses_degree=2, ring_degree=1)
assert images is not None
assert len(images) == 1
assert images[0].width == width
assert images[0].height == height
@pytest.mark.parametrize("model_name", models)
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("attn_backend", ["sdpa"])
def test_sp_ring2_only(model_name: str, dtype: torch.dtype, attn_backend: str):
"""Test SP inference only (ring=2)."""
if device_count() < 2:
pytest.skip(f"Test requires 2 GPUs but only {device_count()} available")
height = 256
width = 256
seed = 42
images = _run_sp(model_name, dtype, attn_backend, height, width, seed, ulysses_degree=1, ring_degree=2)
assert images is not None
assert len(images) == 1
assert images[0].width == width
assert images[0].height == height
@pytest.mark.parametrize("model_name", models)
@pytest.mark.parametrize("ulysses_degree", [1, 2])
@pytest.mark.parametrize("ring_degree", [1, 2])
@pytest.mark.parametrize("dtype", [torch.bfloat16]) # Only test bfloat16 to reduce CI time
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("attn_backend", ["sdpa"])
def test_sequence_parallel(
model_name: str,
@@ -100,214 +201,60 @@ def test_sequence_parallel(
"""Compare baseline (ulysses_degree=1) vs SP (ulysses_degree>1) outputs."""
if ulysses_degree <= 1 and ring_degree <= 1:
pytest.skip(
"This test compares ulysses_degree * ring_degree = 1 vs ulysses_degree * ring_degree > 1; provide ulysses_degree or ring_degree>1."
"This test compares ulysses_degree * ring_degree = 1 vs ulysses_degree * ring_degree > 1; "
"provide ulysses_degree or ring_degree>1."
)
# Skip if not enough GPUs available for SP run
if device_count() < ulysses_degree * ring_degree:
pytest.skip(f"Test requires {ulysses_degree * ring_degree} GPUs but only {device_count()} available")
sp_size = ulysses_degree * ring_degree
if device_count() < sp_size:
pytest.skip(f"Test requires {sp_size} GPUs but only {device_count()} available")
# Use minimal settings for fast testing
height = 256
width = 256
num_inference_steps = 4 # Minimal steps for fast test
seed = 42
# Step 1: Baseline (no Ulysses sequence parallel)
baseline_parallel_config = DiffusionParallelConfig(ulysses_degree=1, ring_degree=1)
baseline = Omni(
model=model_name,
parallel_config=baseline_parallel_config,
dtype=dtype,
attention_backend=attn_backend,
)
try:
outputs = baseline.generate(
PROMPT,
height=height,
width=width,
num_inference_steps=num_inference_steps,
guidance_scale=0.0,
generator=torch.Generator(get_device_name()).manual_seed(seed),
num_outputs_per_prompt=1,
)
baseline_images = outputs[0].request_output[0].images
finally:
baseline.close()
if dist.is_initialized():
dist.destroy_process_group()
for key in ["MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"]:
os.environ.pop(key, None)
time.sleep(2) # Wait for resources to release
baseline_images = _run_baseline(model_name, dtype, attn_backend, height, width, seed)
assert baseline_images is not None and len(baseline_images) == 1
assert baseline_images is not None
assert len(baseline_images) == 1
assert baseline_images[0].width == width
assert baseline_images[0].height == height
sp_images = _run_sp(model_name, dtype, attn_backend, height, width, seed, ulysses_degree, ring_degree)
assert sp_images is not None and len(sp_images) == 1
# Step 2: SP (Ulysses-SP + Ring-SP)
sp_parallel_config = DiffusionParallelConfig(ulysses_degree=ulysses_degree, ring_degree=ring_degree)
sp = Omni(
model=model_name,
parallel_config=sp_parallel_config,
dtype=dtype,
attention_backend=attn_backend,
)
try:
outputs = sp.generate(
PROMPT,
height=height,
width=width,
num_inference_steps=num_inference_steps,
guidance_scale=0.0,
generator=torch.Generator(get_device_name()).manual_seed(seed),
num_outputs_per_prompt=1,
)
sp_images = outputs[0].request_output[0].images
finally:
sp.close()
if dist.is_initialized():
dist.destroy_process_group()
for key in ["MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"]:
os.environ.pop(key, None)
time.sleep(2)
assert sp_images is not None
assert len(sp_images) == 1
assert sp_images[0].width == width
assert sp_images[0].height == height
# Step 3: Compare outputs
mean_abs_diff, max_abs_diff = _diff_metrics(baseline_images[0], sp_images[0])
# FP16/BF16 may differ slightly due to different computation order under parallelism.
if dtype in (torch.float16, torch.bfloat16):
mean_threshold = 2e-2
max_threshold = 2e-1
else:
mean_threshold = 1e-2
max_threshold = 1e-1
print(
"Image diff stats (baseline ulysses_degree*ring_degree=1 vs SP): "
f"mean_abs_diff={mean_abs_diff:.6e}, max_abs_diff={max_abs_diff:.6e}; "
f"thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e}; "
f"ulysses_degree={ulysses_degree}, ring_degree={ring_degree}, dtype={dtype}"
)
mean_threshold = 2e-2
max_threshold = 2e-1
assert mean_abs_diff <= mean_threshold and max_abs_diff <= max_threshold, (
f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, max_abs_diff={max_abs_diff:.6e} "
f"(thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e}); "
f"ulysses_degree={ulysses_degree}, ring_degree={ring_degree}, dtype={dtype}"
f"Image diff exceeded threshold: mean={mean_abs_diff:.6e}, max={max_abs_diff:.6e}"
)
@pytest.mark.parametrize("model_name", models)
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("attn_backend", ["sdpa"])
def test_sequence_parallel_ulysses_sp_only(
model_name: str,
dtype: torch.dtype,
attn_backend: str,
):
"""Test sequence parallel with ulysses_degree=4, ring_degree=1, and the image size (332x332) where the sequence length is NOT divisible by sp_size."""
def test_sequence_parallel_ulysses4(model_name: str, dtype: torch.dtype, attn_backend: str):
"""Test SP with ulysses_degree=4."""
ulysses_degree = 4
ring_degree = 1
# Skip if not enough GPUs available for SP run
if device_count() < ulysses_degree * ring_degree:
pytest.skip(f"Test requires {ulysses_degree * ring_degree} GPUs but only {device_count()} available")
# (272/8) * (272/8) = 17 * 17 = 289 Not divisible by sp_size=4
height = 272
width = 272
num_inference_steps = 4 # Minimal steps for fast test
seed = 42
# Step 1: Baseline (no Ulysses sequence parallel)
baseline_parallel_config = DiffusionParallelConfig(ulysses_degree=1, ring_degree=1)
baseline = Omni(
model=model_name,
parallel_config=baseline_parallel_config,
dtype=dtype,
attention_backend=attn_backend,
)
try:
outputs = baseline.generate(
PROMPT,
height=height,
width=width,
num_inference_steps=num_inference_steps,
guidance_scale=0.0,
generator=torch.Generator(get_device_name()).manual_seed(seed),
num_outputs_per_prompt=1,
)
baseline_images = outputs[0].request_output[0].images
finally:
baseline.close()
if dist.is_initialized():
dist.destroy_process_group()
for key in ["MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"]:
os.environ.pop(key, None)
time.sleep(2) # Wait for resources to release
baseline_images = _run_baseline(model_name, dtype, attn_backend, height, width, seed)
assert baseline_images is not None and len(baseline_images) == 1
assert baseline_images is not None
assert len(baseline_images) == 1
assert baseline_images[0].width == width
assert baseline_images[0].height == height
sp_images = _run_sp(model_name, dtype, attn_backend, height, width, seed, ulysses_degree, ring_degree)
assert sp_images is not None and len(sp_images) == 1
# Step 2: SP (Ulysses-SP + Ring-SP)
sp_parallel_config = DiffusionParallelConfig(ulysses_degree=ulysses_degree, ring_degree=ring_degree)
sp = Omni(
model=model_name,
parallel_config=sp_parallel_config,
dtype=dtype,
attention_backend=attn_backend,
)
try:
outputs = sp.generate(
PROMPT,
height=height,
width=width,
num_inference_steps=num_inference_steps,
guidance_scale=0.0,
generator=torch.Generator(get_device_name()).manual_seed(seed),
num_outputs_per_prompt=1,
)
sp_images = outputs[0].request_output[0].images
finally:
sp.close()
if dist.is_initialized():
dist.destroy_process_group()
for key in ["MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"]:
os.environ.pop(key, None)
time.sleep(2)
assert sp_images is not None
assert len(sp_images) == 1
assert sp_images[0].width == width
assert sp_images[0].height == height
# Step 3: Compare outputs
mean_abs_diff, max_abs_diff = _diff_metrics(baseline_images[0], sp_images[0])
# FP16/BF16 may differ slightly due to different computation order under parallelism.
if dtype in (torch.float16, torch.bfloat16):
mean_threshold = 2e-2
max_threshold = 2e-1
else:
mean_threshold = 1e-2
max_threshold = 1e-1
print(
"Image diff stats (baseline ulysses_degree*ring_degree=1 vs SP): "
f"mean_abs_diff={mean_abs_diff:.6e}, max_abs_diff={max_abs_diff:.6e}; "
f"thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e}; "
f"ulysses_degree={ulysses_degree}, ring_degree={ring_degree}, dtype={dtype}"
)
mean_threshold = 2e-2
max_threshold = 2e-1
assert mean_abs_diff <= mean_threshold and max_abs_diff <= max_threshold, (
f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, max_abs_diff={max_abs_diff:.6e} "
f"(thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e}); "
f"ulysses_degree={ulysses_degree}, ring_degree={ring_degree}, dtype={dtype}"
f"Image diff exceeded threshold: mean={mean_abs_diff:.6e}, max={max_abs_diff:.6e}"
)
+1 -1
View File
@@ -20,7 +20,7 @@ ALLOWED_FILES = {
"tests/e2e/offline_inference/utils.py",
"tests/utils.py",
"vllm_omni/diffusion/distributed/group_coordinator.py",
"tests/diffusion/attention/test_sequence_parallel.py",
"tests/diffusion/attention/test_attention_sp.py",
}
PICKLE_RE = re.compile(
@@ -0,0 +1,38 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Distributed utilities for vLLM-Omni diffusion models."""
from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelConfig,
SequenceParallelInput,
SequenceParallelModelPlan,
SequenceParallelOutput,
SequenceParallelPartialInput,
get_sp_plan_from_model,
validate_sp_plan,
)
from vllm_omni.diffusion.distributed.sp_sharding import (
ShardingValidator,
get_sharding_validator,
sp_gather,
sp_shard,
sp_shard_with_padding,
)
__all__ = [
# Config
"SequenceParallelConfig",
# Plan types
"SequenceParallelInput",
"SequenceParallelOutput",
"SequenceParallelPartialInput",
"SequenceParallelModelPlan",
"validate_sp_plan",
"get_sp_plan_from_model",
# Sharding utilities
"sp_shard",
"sp_gather",
"sp_shard_with_padding",
"ShardingValidator",
"get_sharding_validator",
]
+457
View File
@@ -0,0 +1,457 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM and The HuggingFace Team
# Type definitions in this module are adapted from HuggingFace diffusers library:
# diffusers/src/diffusers/models/_modeling_parallel.py
"""Sequence Parallelism configuration and plan type definitions.
This module provides:
1. SequenceParallelConfig: Configuration for SP (ulysses_degree, ring_degree)
2. SequenceParallelInput/Output: Type definitions for _sp_plan declarations
3. Validation utilities for _sp_plan
A _sp_plan is a dictionary that specifies how to shard/gather tensors at
different points in a model's forward pass. This allows automatic handling
of sequence parallelism without modifying the model's forward() method.
NOTE: Our "Sequence Parallelism" (SP) corresponds to "Context Parallelism" (CP)
in diffusers. We use "Sequence Parallelism" to align with vLLM-Omni terminology.
Example:
class MyTransformer(nn.Module):
_sp_plan = {
# Split inputs before model forward
"": {
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
"encoder_hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
},
# Split RoPE embeddings after pos_embed layer
"pos_embed": {
0: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True),
},
# Gather output after proj_out layer
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
}
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import torch
import torch.nn as nn
# =============================================================================
# Sequence Parallel Configuration
# =============================================================================
@dataclass
class SequenceParallelConfig:
"""Configuration for Sequence Parallelism using vLLM-Omni's parallel state.
This class provides a unified interface for SP configuration that integrates
with vLLM-Omni's existing SequenceParallelGroupCoordinator. Unlike diffusers'
DeviceMesh-based approach (ContextParallelConfig), this uses the existing
parallel state management.
Note: This corresponds to `ContextParallelConfig` in diffusers library.
Args:
ulysses_degree: Number of devices for Ulysses (All-to-All) attention.
Sequence is split across devices, with Q/K/V redistributed via
All-to-All communication. Best for moderate sequences with good
interconnect bandwidth.
ring_degree: Number of devices for Ring attention. Sequence is split
across devices, with K/V passed in a ring topology. Best for long
sequences with limited memory/bandwidth.
convert_to_fp32: Whether to convert output and LSE to float32 for
numerical stability in ring attention.
Note:
ulysses_degree * ring_degree = sequence_parallel_size
vLLM-Omni supports hybrid Ulysses-Ring attention (both > 1).
"""
ulysses_degree: int = 1
ring_degree: int = 1
convert_to_fp32: bool = True
# Internal state - populated by setup()
_rank: int | None = None
_world_size: int | None = None
_device: torch.device | None = None
def __post_init__(self) -> None:
if self.ulysses_degree < 1 or self.ring_degree < 1:
raise ValueError("`ulysses_degree` and `ring_degree` must be >= 1.")
if self.ulysses_degree == 1 and self.ring_degree == 1:
raise ValueError(
"At least one of `ulysses_degree` or `ring_degree` must be > 1 to use sequence parallelism."
)
@property
def sequence_parallel_size(self) -> int:
"""Total sequence parallel world size."""
return self.ulysses_degree * self.ring_degree
def get_world_size(self) -> int:
"""Get the sequence parallel world size from parallel state.
Returns:
The world size for sequence parallelism.
Raises:
RuntimeError: If parallel state is not initialized.
"""
from vllm_omni.diffusion.distributed.parallel_state import get_sequence_parallel_world_size
return get_sequence_parallel_world_size()
def get_rank(self) -> int:
"""Get the current rank in the sequence parallel group.
Returns:
The rank within the sequence parallel group.
Raises:
RuntimeError: If parallel state is not initialized.
"""
from vllm_omni.diffusion.distributed.parallel_state import get_sequence_parallel_rank
return get_sequence_parallel_rank()
def get_ulysses_world_size(self) -> int:
"""Get the Ulysses parallel world size.
Returns:
The world size for Ulysses (All-to-All) parallelism.
"""
from vllm_omni.diffusion.distributed.parallel_state import get_ulysses_parallel_world_size
return get_ulysses_parallel_world_size()
def get_ulysses_rank(self) -> int:
"""Get the current rank in the Ulysses parallel group.
Returns:
The rank within the Ulysses parallel group.
"""
from vllm_omni.diffusion.distributed.parallel_state import get_ulysses_parallel_rank
return get_ulysses_parallel_rank()
def get_ring_world_size(self) -> int:
"""Get the Ring parallel world size.
Returns:
The world size for Ring attention parallelism.
"""
from vllm_omni.diffusion.distributed.parallel_state import get_ring_parallel_world_size
return get_ring_parallel_world_size()
def get_ring_rank(self) -> int:
"""Get the current rank in the Ring parallel group.
Returns:
The rank within the Ring parallel group.
"""
from vllm_omni.diffusion.distributed.parallel_state import get_ring_parallel_rank
return get_ring_parallel_rank()
def setup(self, rank: int, world_size: int, device: torch.device) -> None:
"""Initialize the config with runtime parallel state.
This is called automatically when sequence parallelism is enabled.
Args:
rank: The global rank of this process.
world_size: Total world size.
device: The device for this rank.
"""
self._rank = rank
self._world_size = world_size
self._device = device
expected_sp_size = self.ulysses_degree * self.ring_degree
actual_sp_size = self.get_world_size()
if expected_sp_size != actual_sp_size:
raise ValueError(
f"Configuration mismatch: ulysses_degree ({self.ulysses_degree}) * "
f"ring_degree ({self.ring_degree}) = {expected_sp_size}, but "
f"actual sequence parallel world size is {actual_sp_size}."
)
def is_initialized(self) -> bool:
"""Check if the config has been initialized with runtime state.
Returns:
True if setup() has been called, False otherwise.
"""
return self._rank is not None
# =============================================================================
# Sequence Parallel Plan Type Definitions
# =============================================================================
@dataclass(frozen=True)
class SequenceParallelInput:
"""Configuration for splitting an input tensor across sequence parallel ranks.
This specifies how to shard a tensor in the pre-forward or post-forward hook
of a layer. The tensor will be split along the specified dimension.
Note: This corresponds to `ContextParallelInput` in diffusers library.
Args:
split_dim: The dimension along which to split the tensor.
expected_dims: Expected number of dimensions. If provided, validates that
the tensor has this many dimensions before splitting. If the tensor
has a different number of dimensions, splitting is skipped with a warning.
split_output: If True, split the output of the layer instead of the input.
This is useful for layers whose outputs should be split after preprocessing
(e.g., RoPE embeddings).
auto_pad: If True, automatically pad the tensor if its size along split_dim
is not divisible by world_size. Creates an attention mask to indicate
valid vs padding positions. The mask is stored in ForwardContext.
Note: Ring attention does not support attention mask, so auto_pad
should only be used with Ulysses SP.
Example:
# Split hidden_states along sequence dimension (dim 1)
SequenceParallelInput(split_dim=1, expected_dims=3)
# Split RoPE output along sequence dimension (dim 0)
SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True)
# Split with auto-padding for variable-length sequences
SequenceParallelInput(split_dim=1, expected_dims=3, auto_pad=True)
"""
split_dim: int
expected_dims: int | None = None
split_output: bool = False
auto_pad: bool = False
def __repr__(self) -> str:
return (
f"SequenceParallelInput(split_dim={self.split_dim}, "
f"expected_dims={self.expected_dims}, split_output={self.split_output}, "
f"auto_pad={self.auto_pad})"
)
@dataclass(frozen=True)
class SequenceParallelOutput:
"""Configuration for gathering an output tensor across sequence parallel ranks.
This specifies how to gather a tensor in the post-forward hook of a layer.
The tensor will be gathered along the specified dimension from all ranks.
Note: This corresponds to `ContextParallelOutput` in diffusers library.
Args:
gather_dim: The dimension along which to gather the tensor.
expected_dims: Expected number of dimensions. If provided, validates that
the tensor has this many dimensions before gathering.
Example:
# Gather output along sequence dimension (dim 1)
SequenceParallelOutput(gather_dim=1, expected_dims=3)
"""
gather_dim: int
expected_dims: int | None = None
def __repr__(self) -> str:
return f"SequenceParallelOutput(gather_dim={self.gather_dim}, expected_dims={self.expected_dims})"
@dataclass(frozen=True)
class SequenceParallelPartialInput:
"""Configuration for partially splitting a tensor (e.g., split image part, keep text part).
This is designed for models like LongCat/Qwen where RoPE embeddings need special handling:
- Text portion: kept full across all ranks (for joint attention)
- Image portion: split across ranks
The tensor is assumed to be concatenated as [text_part, image_part] along split_dim.
Note: This is an extension beyond diffusers' standard ContextParallelInput,
designed for vLLM-Omni's dual-stream attention models.
Args:
split_dim: The dimension along which to split the image portion.
text_len_source: How to determine text length:
- str: Name of a forward parameter that contains text length
- int: Fixed text length value
expected_dims: Expected number of dimensions for validation.
split_output: If True, split the output instead of input.
Example:
# Split RoPE: text portion (from txt_ids.shape[0]) kept full, image portion split
SequenceParallelPartialInput(
split_dim=0,
text_len_source="txt_ids", # Get text length from txt_ids.shape[0]
expected_dims=2,
split_output=True,
)
# Or with fixed text length
SequenceParallelPartialInput(
split_dim=0,
text_len_source=512, # Fixed text length
expected_dims=2,
split_output=True,
)
"""
split_dim: int
text_len_source: str | int
expected_dims: int | None = None
split_output: bool = False
def __repr__(self) -> str:
return (
f"SequenceParallelPartialInput(split_dim={self.split_dim}, "
f"text_len_source={self.text_len_source!r}, expected_dims={self.expected_dims}, "
f"split_output={self.split_output})"
)
# =============================================================================
# Type Aliases for _sp_plan Structure
# =============================================================================
# Any input config type
AnySequenceParallelInput = SequenceParallelInput | SequenceParallelPartialInput
# Input specification: maps parameter names (str) or output indices (int) to split config
SequenceParallelInputType = dict[
str | int,
AnySequenceParallelInput | list[AnySequenceParallelInput] | tuple[AnySequenceParallelInput, ...],
]
# Output specification: single or multiple gather configs
SequenceParallelOutputType = SequenceParallelOutput | list[SequenceParallelOutput] | tuple[SequenceParallelOutput, ...]
# Full model plan: maps module names to input/output specifications
# - Key "" refers to the model itself (root level)
# - Key "module_name" refers to a submodule
# - Key "module_name.*" refers to all children of a ModuleList
#
# Example of a complete _sp_plan:
#
# _sp_plan = {
# # Root level: split model inputs before any submodule
# "": {
# "hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
# },
# # Submodule: split outputs of pos_embed (RoPE) layer
# "pos_embed": {
# 0: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), # cos
# 1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), # sin
# },
# # Submodule: gather outputs of proj_out layer
# "proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
# }
#
SequenceParallelModelPlan = dict[str, SequenceParallelInputType | SequenceParallelOutputType]
# =============================================================================
# Validation Utilities
# =============================================================================
def _is_valid_input_config(value: object) -> bool:
"""Check if a value is a valid input configuration type."""
return isinstance(value, (SequenceParallelInput, SequenceParallelPartialInput))
def _is_valid_input_config_list(value: object) -> bool:
"""Check if a value is a list/tuple of valid input configurations."""
if not isinstance(value, (list, tuple)):
return False
return all(_is_valid_input_config(x) for x in value)
def validate_sp_plan(plan: SequenceParallelModelPlan) -> None:
"""Validate a _sp_plan dictionary for correctness.
Args:
plan: The _sp_plan dictionary to validate.
Raises:
ValueError: If the plan is invalid.
"""
if not isinstance(plan, dict):
raise ValueError(f"_sp_plan must be a dict, got {type(plan).__name__}")
for module_id, module_plan in plan.items():
if not isinstance(module_id, str):
raise ValueError(f"_sp_plan keys must be strings, got {type(module_id).__name__}")
# Check if it's an output specification (SequenceParallelOutput or list/tuple thereof)
if isinstance(module_plan, SequenceParallelOutput):
continue
if isinstance(module_plan, (list, tuple)):
if all(isinstance(x, SequenceParallelOutput) for x in module_plan):
continue
if _is_valid_input_config_list(module_plan):
# List of inputs for a specific parameter (when output is tuple)
continue
# Otherwise, should be an input specification dict
if isinstance(module_plan, dict):
for key, value in module_plan.items():
if not isinstance(key, (str, int)):
raise ValueError(
f"Input spec keys must be str or int, got {type(key).__name__} for module '{module_id}'"
)
if isinstance(key, int) and not _is_valid_input_config(value):
raise ValueError(
f"Integer keys (output indices) must map to SequenceParallelInput/PartialInput, "
f"got {type(value).__name__} for module '{module_id}'[{key}]"
)
if _is_valid_input_config(value):
if isinstance(key, int) and not value.split_output:
raise ValueError(
f"Integer keys (output indices) require split_output=True, "
f"got split_output=False for module '{module_id}'[{key}]"
)
elif _is_valid_input_config_list(value):
pass # Valid list of input configs
else:
raise ValueError(
f"Input spec values must be SequenceParallelInput/PartialInput or list thereof, "
f"got {type(value).__name__} for module '{module_id}'['{key}']"
)
else:
raise ValueError(
f"_sp_plan values must be dict (input spec) or SequenceParallelOutput, "
f"got {type(module_plan).__name__} for module '{module_id}'"
)
def get_sp_plan_from_model(model: nn.Module) -> SequenceParallelModelPlan | None:
"""Get the _sp_plan from a model if it exists.
Args:
model: The model to get the plan from.
Returns:
The _sp_plan dictionary, or None if not defined.
"""
plan = getattr(model, "_sp_plan", None)
if plan is not None:
validate_sp_plan(plan)
return plan
@@ -0,0 +1,268 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project and The HuggingFace Team
"""Sequence Parallelism sharding utilities.
This module provides low-level sharding and gathering functions for
Sequence Parallelism. These can be used directly in model forward methods
for semi-intrusive SP support, or internally by the SP hooks.
"""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass, field
import torch
from vllm.logger import init_logger
from vllm_omni.diffusion.distributed.parallel_state import (
get_sequence_parallel_rank,
get_sequence_parallel_world_size,
get_sp_group,
)
logger = init_logger(__name__)
def sp_shard(
tensor: torch.Tensor,
dim: int,
validate: bool = True,
) -> torch.Tensor:
"""Shard a tensor along the specified dimension for sequence parallelism.
The tensor is split into world_size chunks along dim, and this rank
receives its corresponding chunk.
Args:
tensor: The tensor to shard.
dim: The dimension along which to split.
validate: If True, validate that the tensor size is divisible by world_size.
Returns:
The shard for this rank.
Raises:
ValueError: If validate=True and tensor size is not divisible by world_size.
Example:
# In model forward:
hidden_states = sp_shard(hidden_states, dim=1)
"""
world_size = get_sequence_parallel_world_size()
if world_size == 1:
return tensor
rank = get_sequence_parallel_rank()
size = tensor.size(dim)
if validate and size % world_size != 0:
raise ValueError(
f"Tensor size along dim {dim} ({size}) must be divisible by "
f"world_size ({world_size}) for sequence parallel sharding."
)
return tensor.chunk(world_size, dim=dim)[rank]
def sp_gather(
tensor: torch.Tensor,
dim: int,
validate: bool = True,
) -> torch.Tensor:
"""Gather a tensor along the specified dimension from all sequence parallel ranks.
The sharded tensors from all ranks are concatenated along dim.
Args:
tensor: The local shard to gather.
dim: The dimension along which to gather.
validate: If True, validate tensor consistency (currently unused).
Returns:
The full tensor gathered from all ranks.
Example:
# At end of model forward:
output = sp_gather(output, dim=1)
"""
world_size = get_sequence_parallel_world_size()
if world_size == 1:
return tensor
sp_group = get_sp_group()
return sp_group.all_gather(tensor, dim=dim)
def sp_shard_with_padding(
tensor: torch.Tensor,
dim: int,
pad_value: float = 0.0,
) -> tuple[torch.Tensor, int]:
"""Shard a tensor with automatic padding if not divisible by world_size.
This is useful for variable-length sequences where padding may be needed.
Args:
tensor: The tensor to shard.
dim: The dimension along which to split.
pad_value: Value to use for padding.
Returns:
Tuple of (sharded_tensor, padding_size). The padding_size indicates
how much padding was added to the original tensor before sharding.
Example:
sharded, pad_size = sp_shard_with_padding(hidden_states, dim=1)
# ... process ...
output = sp_gather(output, dim=1)
if pad_size > 0:
output = output[..., :-pad_size] # Remove padding
"""
world_size = get_sequence_parallel_world_size()
if world_size == 1:
return tensor, 0
size = tensor.size(dim)
remainder = size % world_size
if remainder == 0:
return sp_shard(tensor, dim, validate=False), 0
# Pad to make divisible
pad_size = world_size - remainder
pad_shape = list(tensor.shape)
pad_shape[dim] = pad_size
padding = torch.full(pad_shape, pad_value, dtype=tensor.dtype, device=tensor.device)
tensor = torch.cat([tensor, padding], dim=dim)
return sp_shard(tensor, dim, validate=False), pad_size
# NOTE: This class is a vLLM-Omni extension for
# debugging intrusive SP implementations.
# Purpose:
# - Help developers detect bugs when implementing intrusive SP
# - Verify that every sharded tensor is properly gathered
# - Warn about common mistakes (double shard, gather without shard)
#
# When to use:
# - During development/debugging of intrusive SP code
# - In tests to verify shard/gather correctness
@dataclass
class ShardingValidator:
"""Validator for tracking and verifying sharding operations.
This class helps ensure that sharding and gathering operations are
correctly paired in model forward passes. It tracks which tensors
have been sharded and verifies that they are properly gathered.
Usage:
validator = ShardingValidator()
with validator.track():
hidden_states = validator.shard(hidden_states, "hidden_states", dim=1)
# ... model computation ...
output = validator.gather(output, "hidden_states", dim=1)
validator.validate() # Raises if any shard was not gathered
Attributes:
_sharded: Set of tensor names that have been sharded.
_gathered: Set of tensor names that have been gathered.
_enabled: Whether tracking is currently enabled.
"""
_sharded: set[str] = field(default_factory=set)
_gathered: set[str] = field(default_factory=set)
_enabled: bool = False
def reset(self) -> None:
"""Reset the validator state for a new forward pass."""
self._sharded.clear()
self._gathered.clear()
@contextmanager
def track(self):
"""Context manager to enable tracking for a forward pass."""
self._enabled = True
self.reset()
try:
yield
finally:
self._enabled = False
def shard(
self,
tensor: torch.Tensor,
name: str,
dim: int,
validate_divisible: bool = True,
) -> torch.Tensor:
"""Shard a tensor and track the operation.
Args:
tensor: The tensor to shard.
name: A name to identify this tensor for validation.
dim: The dimension along which to split.
validate_divisible: If True, validate divisibility.
Returns:
The sharded tensor.
"""
if self._enabled:
if name in self._sharded:
logger.warning(f"Tensor '{name}' sharded multiple times")
self._sharded.add(name)
return sp_shard(tensor, dim, validate=validate_divisible)
def gather(
self,
tensor: torch.Tensor,
name: str,
dim: int,
) -> torch.Tensor:
"""Gather a tensor and track the operation.
Args:
tensor: The local shard to gather.
name: The name used when sharding (for validation).
dim: The dimension along which to gather.
Returns:
The gathered tensor.
"""
if self._enabled:
if name not in self._sharded:
logger.warning(f"Tensor '{name}' gathered without being sharded")
self._gathered.add(name)
return sp_gather(tensor, dim)
def validate(self) -> None:
"""Validate that all sharded tensors were gathered.
Raises:
ValueError: If any sharded tensor was not gathered.
"""
unmatched = self._sharded - self._gathered
if unmatched:
raise ValueError(
f"The following tensors were sharded but not gathered: {unmatched}. "
f"This may indicate a bug in the model's SP implementation."
)
# Global validator instance for convenience
_global_validator = ShardingValidator()
def get_sharding_validator() -> ShardingValidator:
"""Get the global sharding validator instance.
Returns:
The global ShardingValidator.
"""
return _global_validator
+10
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
@@ -21,6 +23,14 @@ class ForwardContext:
split_text_embed_in_sp: bool = False
# whether to split the text embed in sequence parallel, if True, the text embed will be split in sequence parallel
# Sequence Parallel padding support
# When sequence length is not divisible by SP world size, padding is added
# These values are used by SequenceParallelGatherHook to remove padding,
# and by attention layers to create attention masks dynamically
sp_padding_size: int = 0
# Original sequence length before padding (for removing padding in gather)
sp_original_seq_len: int | None = None
def __post_init__(self):
pass
-102
View File
@@ -1,102 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import torch.nn as nn
class BaseState:
"""Base class for hook state containers."""
def reset(self) -> None: # pragma: no cover - default is no-op
pass
class StateManager:
"""Manage per-context hook state instances."""
def __init__(self, state_cls: Callable[[], BaseState]):
self._state_cls = state_cls
self._states: dict[str, BaseState] = {}
self._context: str = "default"
def set_context(self, name: str) -> None:
self._context = name or "default"
def get_state(self) -> BaseState:
if self._context not in self._states:
self._states[self._context] = self._state_cls()
return self._states[self._context]
def reset(self) -> None:
self._states.clear()
class ModelHook:
"""Base class for model hooks that can override a module's forward."""
def initialize_hook(self, module: nn.Module) -> nn.Module:
raise NotImplementedError
def new_forward(self, module: nn.Module, *args: Any, **kwargs: Any):
raise NotImplementedError
def reset_state(self, module: nn.Module) -> nn.Module:
raise NotImplementedError
@dataclass
class _WrappedForward:
module: nn.Module
def __call__(self, *args: Any, **kwargs: Any):
registry: HookRegistry | None = getattr(self.module, "_hook_registry", None)
if registry is None or not registry._hooks:
return self.module._original_forward(*args, **kwargs)
return registry.dispatch(*args, **kwargs)
class HookRegistry:
"""Registry of hooks attached to a module."""
def __init__(self, module: nn.Module):
self.module = module
self._hooks: dict[str, ModelHook] = {}
@classmethod
def get_or_create(cls, module: nn.Module) -> HookRegistry:
registry: HookRegistry | None = getattr(module, "_hook_registry", None)
if registry is None:
registry = cls(module)
setattr(module, "_hook_registry", registry)
# Wrap module.forward once so hooks can intercept calls.
if not hasattr(module, "_original_forward"):
module._original_forward = module.forward # type: ignore[attr-defined]
module.forward = _WrappedForward(module) # type: ignore[assignment]
return registry
def register_hook(self, name: str, hook: ModelHook) -> None:
hook.initialize_hook(self.module)
self._hooks[name] = hook
def get_hook(self, name: str) -> ModelHook | None:
return self._hooks.get(name)
def dispatch(self, *args: Any, **kwargs: Any):
# For now we support a single active hook and call it directly.
# This can be extended to a chain if needed.
if not self._hooks:
return self.module._original_forward(*args, **kwargs) # type: ignore[attr-defined]
# Deterministic order: sort by name.
name = sorted(self._hooks.keys())[0]
hook = self._hooks[name]
return hook.new_forward(self.module, *args, **kwargs)
def reset_hook(self, name: str) -> None:
hook = self._hooks.get(name)
if hook is not None:
hook.reset_state(self.module)
+33
View File
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Hook mechanism for model forward interception."""
from vllm_omni.diffusion.hooks.base import (
BaseState,
HookRegistry,
ModelHook,
StateManager,
)
from vllm_omni.diffusion.hooks.sequence_parallel import (
SequenceParallelGatherHook,
SequenceParallelSplitHook,
apply_sequence_parallel,
disable_sequence_parallel_for_model,
enable_sequence_parallel_for_model,
remove_sequence_parallel,
)
__all__ = [
# Base hooks
"BaseState",
"StateManager",
"ModelHook",
"HookRegistry",
# Sequence parallel hooks (corresponds to diffusers' context_parallel)
"SequenceParallelSplitHook",
"SequenceParallelGatherHook",
"apply_sequence_parallel",
"remove_sequence_parallel",
"enable_sequence_parallel_for_model",
"disable_sequence_parallel_for_model",
]
+247
View File
@@ -0,0 +1,247 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Base hook classes for model forward interception.
This module provides the foundational hook mechanism that allows intercepting
and modifying model forward passes without invasive changes to model code.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import torch.nn as nn
class BaseState:
"""Base class for hook state containers."""
def reset(self) -> None: # pragma: no cover - default is no-op
pass
class StateManager:
"""Manage per-context hook state instances."""
def __init__(self, state_cls: Callable[[], BaseState]):
self._state_cls = state_cls
self._states: dict[str, BaseState] = {}
self._context: str = "default"
def set_context(self, name: str) -> None:
self._context = name or "default"
def get_state(self) -> BaseState:
if self._context not in self._states:
self._states[self._context] = self._state_cls()
return self._states[self._context]
def reset(self) -> None:
self._states.clear()
class ModelHook:
"""Base class for model hooks that can override a module's forward.
Hooks can intercept the forward pass at two points:
- pre_forward: Called before the original forward, can modify args/kwargs
- post_forward: Called after the original forward, can modify output
Subclasses can override either or both methods. The default implementations
pass through args/kwargs/output unchanged.
For more complex behavior, override new_forward to completely replace
the forward logic.
"""
def initialize_hook(self, module: nn.Module) -> nn.Module:
"""Initialize the hook when it's registered to a module.
Args:
module: The module this hook is being attached to.
Returns:
The module (possibly modified).
"""
return module
def pre_forward(self, module: nn.Module, *args: Any, **kwargs: Any) -> tuple[tuple, dict]:
"""Called before the module's forward pass.
Args:
module: The module being called.
*args: Positional arguments to forward.
**kwargs: Keyword arguments to forward.
Returns:
Tuple of (args, kwargs) to pass to the forward method.
"""
return args, kwargs
def post_forward(self, module: nn.Module, output: Any) -> Any:
"""Called after the module's forward pass.
Args:
module: The module that was called.
output: The output from the forward method.
Returns:
The (possibly modified) output.
"""
return output
def new_forward(self, module: nn.Module, *args: Any, **kwargs: Any) -> Any:
"""Override the module's forward pass completely.
The default implementation calls pre_forward, then the original forward,
then post_forward. Override this method for more complex behavior.
Args:
module: The module being called.
*args: Positional arguments to forward.
**kwargs: Keyword arguments to forward.
Returns:
The output of the forward pass.
"""
args, kwargs = self.pre_forward(module, *args, **kwargs)
output = module._original_forward(*args, **kwargs) # type: ignore[attr-defined]
return self.post_forward(module, output)
def reset_state(self, module: nn.Module) -> nn.Module:
"""Reset any state associated with this hook.
Args:
module: The module this hook is attached to.
Returns:
The module.
"""
return module
@dataclass
class _WrappedForward:
"""Wrapper that intercepts forward calls and dispatches to hooks."""
module: nn.Module
def __call__(self, *args: Any, **kwargs: Any):
registry: HookRegistry | None = getattr(self.module, "_hook_registry", None)
if registry is None or not registry._hooks:
return self.module._original_forward(*args, **kwargs)
return registry.dispatch(*args, **kwargs)
class HookRegistry:
"""Registry of hooks attached to a module.
Manages multiple hooks that can intercept a module's forward pass.
Hooks are called in sorted order by name for determinism.
"""
def __init__(self, module: nn.Module):
self.module = module
self._hooks: dict[str, ModelHook] = {}
@classmethod
def get_or_create(cls, module: nn.Module) -> HookRegistry:
"""Get existing registry or create a new one for the module.
Args:
module: The module to get/create a registry for.
Returns:
The HookRegistry for this module.
"""
registry: HookRegistry | None = getattr(module, "_hook_registry", None)
if registry is None:
registry = cls(module)
setattr(module, "_hook_registry", registry)
# Wrap module.forward once so hooks can intercept calls.
if not hasattr(module, "_original_forward"):
module._original_forward = module.forward # type: ignore[attr-defined]
module.forward = _WrappedForward(module) # type: ignore[assignment]
return registry
def register_hook(self, name: str, hook: ModelHook) -> None:
"""Register a hook with the given name.
Args:
name: Unique name for this hook.
hook: The hook instance to register.
"""
hook.initialize_hook(self.module)
self._hooks[name] = hook
def remove_hook(self, name: str) -> None:
"""Remove a hook by name.
Args:
name: The name of the hook to remove.
"""
if name in self._hooks:
del self._hooks[name]
def get_hook(self, name: str) -> ModelHook | None:
"""Get a hook by name.
Args:
name: The name of the hook.
Returns:
The hook if found, None otherwise.
"""
return self._hooks.get(name)
def dispatch(self, *args: Any, **kwargs: Any) -> Any:
"""Dispatch a forward call through registered hooks.
Currently supports a single active hook. Multiple hooks are called
in sorted order by name, with each hook's output passed to the next.
Args:
*args: Positional arguments to forward.
**kwargs: Keyword arguments to forward.
Returns:
The output of the forward pass.
"""
if not self._hooks:
return self.module._original_forward(*args, **kwargs) # type: ignore[attr-defined]
# For single hook case, call directly
if len(self._hooks) == 1:
hook = next(iter(self._hooks.values()))
return hook.new_forward(self.module, *args, **kwargs)
# For multiple hooks, chain them in sorted order
# Each hook can modify args/kwargs via pre_forward
sorted_hooks = sorted(self._hooks.items(), key=lambda x: x[0])
# Apply all pre_forward hooks
for _, hook in sorted_hooks:
args, kwargs = hook.pre_forward(self.module, *args, **kwargs)
# Call original forward
output = self.module._original_forward(*args, **kwargs) # type: ignore[attr-defined]
# Apply all post_forward hooks in reverse order
for _, hook in reversed(sorted_hooks):
output = hook.post_forward(self.module, output)
return output
def reset_hook(self, name: str) -> None:
"""Reset a hook's state by name.
Args:
name: The name of the hook to reset.
"""
hook = self._hooks.get(name)
if hook is not None:
hook.reset_state(self.module)
@@ -0,0 +1,703 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project and the HuggingFace Team.
# All rights reserved.
#
# This module is adapted from HuggingFace diffusers library:
# diffusers/src/diffusers/hooks/context_parallel.py
#
# NOTE: Our "Sequence Parallelism" (SP) corresponds to "Context Parallelism" (CP) in diffusers.
# We use the term "Sequence Parallelism" to align with vLLM-Omni's existing terminology.
#
# Key adaptations from diffusers:
# - ModuleForwardMetadata: parameter lookup logic (adapted)
# - SequenceParallelSplitHook/GatherHook: hook structure (adapted from ContextParallel*)
# - apply_sequence_parallel: registration logic (adapted from apply_context_parallel)
#
# Key differences from diffusers:
# - Uses vLLM-Omni's SequenceParallelGroupCoordinator instead of DeviceMesh
# - Uses sp_shard/sp_gather from sp_sharding.py instead of funcol operations
# - Supports Ulysses + Ring hybrid parallelism
#
"""Sequence Parallelism hooks for non-intrusive SP support.
This module implements the hook-based mechanism for applying sequence parallelism
to models without modifying their forward() methods.
Usage:
1. Define _sp_plan on your model class (corresponds to diffusers' _cp_plan)
2. Call apply_sequence_parallel(model, config, plan) to enable SP
3. Call remove_sequence_parallel(model, plan) to disable SP
The hooks automatically shard inputs before forward and gather outputs after,
based on the plan specification.
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass
from typing import Any
import torch
import torch.nn as nn
from vllm.logger import init_logger
from vllm_omni.diffusion.distributed.sp_plan import (
AnySequenceParallelInput,
SequenceParallelConfig,
SequenceParallelInput,
SequenceParallelModelPlan,
SequenceParallelOutput,
SequenceParallelPartialInput,
)
from vllm_omni.diffusion.distributed.sp_sharding import sp_gather, sp_shard
from vllm_omni.diffusion.hooks.base import HookRegistry, ModelHook
logger = init_logger(__name__)
# Hook name templates for identifying SP hooks
_SP_INPUT_HOOK_TEMPLATE = "sp_input---{}"
_SP_OUTPUT_HOOK_TEMPLATE = "sp_output---{}"
@dataclass
class ModuleForwardMetadata:
"""Metadata for mapping forward() parameter names to args/kwargs positions.
This caches the inspection of a module's forward signature to efficiently
locate parameters by name in subsequent calls.
"""
cached_parameter_indices: dict[str, int] | None = None
_cls: type | None = None
def _get_parameter_from_args_kwargs(
self,
identifier: str,
args: tuple = (),
kwargs: dict | None = None,
) -> tuple[Any, bool, int | None]:
"""Get a parameter value from args or kwargs by name.
Args:
identifier: The parameter name to look up.
args: Positional arguments passed to forward.
kwargs: Keyword arguments passed to forward.
Returns:
Tuple of (value, is_kwarg, index).
- value: The parameter value (or None if not found)
- is_kwarg: True if found in kwargs
- index: Position in args if found there
Raises:
ValueError: If parameter not found in signature.
"""
kwargs = kwargs or {}
# First check kwargs
if identifier in kwargs:
return kwargs[identifier], True, None
# Check cached indices
if self.cached_parameter_indices is not None:
index = self.cached_parameter_indices.get(identifier, None)
if index is None:
raise ValueError(f"Parameter '{identifier}' not found in cached indices.")
if index < len(args):
return args[index], False, index
return None, False, index
# Build cache from forward signature
if self._cls is None:
raise ValueError("Model class is not set for metadata.")
parameters = list(inspect.signature(self._cls.forward).parameters.keys())
parameters = parameters[1:] # Skip `self`
self.cached_parameter_indices = {param: i for i, param in enumerate(parameters)}
if identifier not in self.cached_parameter_indices:
raise ValueError(f"Parameter '{identifier}' not found in function signature.")
index = self.cached_parameter_indices[identifier]
if index >= len(args):
return None, False, index
return args[index], False, index
def _unwrap_module(module: nn.Module) -> nn.Module:
"""Unwrap a module from any wrappers to get the original class.
Args:
module: Potentially wrapped module.
Returns:
The unwrapped module.
"""
# Handle common wrappers
while hasattr(module, "_modules") and len(module._modules) == 1:
inner = next(iter(module._modules.values()))
if inner is not None:
module = inner
else:
break
return module
class SequenceParallelSplitHook(ModelHook):
"""Hook for splitting inputs before a module's forward pass.
This hook is registered to modules that need their inputs sharded
across sequence parallel ranks. It intercepts the forward call,
shards specified inputs according to the plan, and passes the
sharded inputs to the original forward.
For split_output=True inputs, it shards the output instead.
Supports both SequenceParallelInput (full split) and SequenceParallelPartialInput
(partial split for text/image separation).
Note: This corresponds to `ContextParallelSplitHook` in diffusers.
"""
def __init__(
self,
metadata: dict[str | int, AnySequenceParallelInput | list[AnySequenceParallelInput]],
config: SequenceParallelConfig,
) -> None:
super().__init__()
self.metadata = metadata
self.config = config
self.module_forward_metadata: ModuleForwardMetadata | None = None
# Cache for text lengths resolved from kwargs
self._text_len_cache: dict[str, int] = {}
def initialize_hook(self, module: nn.Module) -> nn.Module:
cls = _unwrap_module(module).__class__
self.module_forward_metadata = ModuleForwardMetadata(_cls=cls)
return module
def pre_forward(self, module: nn.Module, *args: Any, **kwargs: Any) -> tuple[tuple, dict]:
"""Shard inputs before forward."""
args_list = list(args)
# Clear text length cache for this forward pass
self._text_len_cache.clear()
for name, spm in self.metadata.items():
# Skip if this is a split_output entry (handled in post_forward)
if isinstance(spm, (SequenceParallelInput, SequenceParallelPartialInput)) and spm.split_output:
continue
# Get the parameter value
input_val, is_kwarg, index = self.module_forward_metadata._get_parameter_from_args_kwargs(
name, args_list, kwargs
)
if input_val is None:
continue
# Shard the input
if isinstance(input_val, torch.Tensor):
input_val = self._prepare_sp_input(input_val, spm, args_list, kwargs)
elif isinstance(input_val, (list, tuple)):
# Handle list/tuple of tensors with per-element config
if not isinstance(spm, (list, tuple)):
raise ValueError(
f"Expected list/tuple of SequenceParallelInput for parameter '{name}' "
f"which is a list/tuple, but got {type(spm).__name__}"
)
if len(input_val) != len(spm):
raise ValueError(f"Expected {len(spm)} elements for parameter '{name}', got {len(input_val)}")
sharded_input_val = []
for i, x in enumerate(input_val):
if torch.is_tensor(x) and not spm[i].split_output:
x = self._prepare_sp_input(x, spm[i], args_list, kwargs)
sharded_input_val.append(x)
input_val = type(input_val)(sharded_input_val)
else:
raise ValueError(f"Unsupported input type for sharding: {type(input_val).__name__}")
# Update args or kwargs
if is_kwarg:
kwargs[name] = input_val
elif index is not None and index < len(args_list):
args_list[index] = input_val
else:
raise ValueError(f"Failed to update parameter '{name}' after sharding.")
# Store kwargs for post_forward to resolve text lengths
self._last_kwargs = kwargs
self._last_args = tuple(args_list)
return tuple(args_list), kwargs
def post_forward(self, module: nn.Module, output: Any) -> Any:
"""Shard outputs for split_output=True entries."""
is_tensor = isinstance(output, torch.Tensor)
is_tensor_list = isinstance(output, (list, tuple)) and all(isinstance(x, torch.Tensor) for x in output)
if not is_tensor and not is_tensor_list:
# No tensor outputs to shard
return output
output_list = [output] if is_tensor else list(output)
for index, spm in self.metadata.items():
if not isinstance(index, int):
continue
if not isinstance(spm, (SequenceParallelInput, SequenceParallelPartialInput)) or not spm.split_output:
continue
if index >= len(output_list):
raise ValueError(f"Index {index} out of bounds for output of length {len(output_list)}.")
output_list[index] = self._prepare_sp_input(output_list[index], spm, self._last_args, self._last_kwargs)
return output_list[0] if is_tensor else type(output)(output_list)
def _resolve_text_len(
self,
sp_input: SequenceParallelPartialInput,
args: tuple,
kwargs: dict,
) -> int:
"""Resolve text length from the source specification."""
source = sp_input.text_len_source
if isinstance(source, int):
return source
# String source - look up from kwargs or args
if source in self._text_len_cache:
return self._text_len_cache[source]
# Try to get from kwargs/args
try:
val, _, _ = self.module_forward_metadata._get_parameter_from_args_kwargs(source, args, kwargs)
if val is None:
raise ValueError(f"Parameter '{source}' is None, cannot determine text length.")
if isinstance(val, torch.Tensor):
# TODO: Currently assumes batch_size=1, where shape[0] is sequence length.
# For batch inference support, this should be updated to handle
# shape (batch_size, seq_len, ...) where text_len varies per sample.
text_len = val.shape[0]
elif isinstance(val, int):
text_len = val
else:
raise ValueError(f"Cannot determine text length from '{source}' of type {type(val).__name__}")
self._text_len_cache[source] = text_len
return text_len
except ValueError as e:
raise ValueError(f"Failed to resolve text_len_source '{source}': {e}") from e
def _prepare_sp_input(
self,
x: torch.Tensor,
sp_input: AnySequenceParallelInput,
args: tuple = (),
kwargs: dict | None = None,
) -> torch.Tensor:
"""Shard a tensor according to the input specification."""
kwargs = kwargs or {}
if sp_input.expected_dims is not None and x.dim() != sp_input.expected_dims:
logger.warning_once(f"Expected tensor with {sp_input.expected_dims} dims, got {x.dim()}. Skipping split.")
return x
if isinstance(sp_input, SequenceParallelInput):
# Full split with optional auto-padding
if sp_input.auto_pad:
return self._shard_with_auto_pad(x, sp_input.split_dim)
return sp_shard(x, sp_input.split_dim, validate=False)
elif isinstance(sp_input, SequenceParallelPartialInput):
# Partial split: keep text portion, split image portion
text_len = self._resolve_text_len(sp_input, args, kwargs)
dim = sp_input.split_dim
# Split tensor into text and image portions
text_part = x.narrow(dim, 0, text_len)
image_part = x.narrow(dim, text_len, x.size(dim) - text_len)
# Only shard the image portion
image_part_sharded = sp_shard(image_part, dim, validate=False)
# Concatenate back: [text_full, image_sharded]
return torch.cat([text_part, image_part_sharded], dim=dim)
else:
raise ValueError(f"Unsupported input config type: {type(sp_input).__name__}")
def _shard_with_auto_pad(self, x: torch.Tensor, dim: int) -> torch.Tensor:
"""Shard tensor with automatic padding and attention mask creation.
When sequence length is not divisible by SP world size, this method:
1. Pads the tensor to make it divisible
2. Creates an attention mask indicating valid vs padding positions
3. Stores the mask and padding info in ForwardContext
"""
from vllm_omni.diffusion.attention.selector import (
_BACKENDS_SUPPORT_ATTENTION_MASK,
get_attn_backend,
)
from vllm_omni.diffusion.distributed.parallel_state import (
get_ring_parallel_world_size,
get_sequence_parallel_rank,
get_sequence_parallel_world_size,
)
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
world_size = get_sequence_parallel_world_size()
if world_size == 1:
return x
seq_len = x.size(dim)
remainder = seq_len % world_size
if remainder == 0:
# No padding needed
return sp_shard(x, dim, validate=False)
# Check backend compatibility
attn_backend = get_attn_backend(-1)
if attn_backend.get_name() not in _BACKENDS_SUPPORT_ATTENTION_MASK:
raise ValueError(
f"Sequence length ({seq_len}) is not divisible by SP world size ({world_size}). "
f"Cannot use {attn_backend.get_name()} which does not support attention_mask. "
f"Please switch to SDPA or Ascend attention backend."
)
# Ring attention does not support attention_mask
if get_ring_parallel_world_size() > 1:
raise ValueError(
f"Sequence length ({seq_len}) is not divisible by SP world size ({world_size}). "
f"Cannot use Ring attention which does not support attention_mask. "
f"Please switch to Ulysses SP only."
)
# Calculate padding
pad_size = world_size - remainder
padded_seq_len = seq_len + pad_size
# Pad the tensor
pad_shape = list(x.shape)
pad_shape[dim] = pad_size
padding = torch.zeros(pad_shape, dtype=x.dtype, device=x.device)
x_padded = torch.cat([x, padding], dim=dim)
# Store padding info in forward context (only once, for primary tensor)
# Attention layers will create masks dynamically using this info
if is_forward_context_available():
ctx = get_forward_context()
# Only set if not already set (first auto_pad tensor wins)
if ctx.sp_original_seq_len is None:
ctx.sp_padding_size = pad_size
ctx.sp_original_seq_len = seq_len
logger.debug(
f"Auto-padded sequence from {seq_len} to {padded_seq_len} "
f"(pad_size={pad_size}, world_size={world_size}, dim={dim})"
)
# Shard the padded tensor
rank = get_sequence_parallel_rank()
return x_padded.chunk(world_size, dim=dim)[rank]
class SequenceParallelGatherHook(ModelHook):
"""Hook for gathering outputs after a module's forward pass.
This hook is registered to modules that need their outputs gathered
from all sequence parallel ranks. It intercepts the output and gathers
it according to the plan specification.
Note: This corresponds to `ContextParallelGatherHook` in diffusers.
"""
def __init__(
self,
metadata: SequenceParallelOutput | list[SequenceParallelOutput],
config: SequenceParallelConfig,
) -> None:
super().__init__()
if isinstance(metadata, SequenceParallelOutput):
metadata = [metadata]
self.metadata = metadata
self.config = config
def initialize_hook(self, module: nn.Module) -> nn.Module:
return module
def post_forward(self, module: nn.Module, output: Any) -> Any:
"""Gather outputs after forward and remove padding if applied."""
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
is_tensor = isinstance(output, torch.Tensor)
if is_tensor:
output = [output]
elif not (isinstance(output, (list, tuple)) and all(isinstance(x, torch.Tensor) for x in output)):
raise ValueError(f"Expected tensor or list/tuple of tensors, got {type(output).__name__}")
output = list(output)
if len(output) != len(self.metadata):
raise ValueError(f"Expected {len(self.metadata)} outputs, got {len(output)}.")
# Check if padding was applied during split
original_seq_len = None
if is_forward_context_available():
ctx = get_forward_context()
original_seq_len = ctx.sp_original_seq_len
for i, spm in enumerate(self.metadata):
if spm is None:
continue
x = output[i]
if spm.expected_dims is not None and x.dim() != spm.expected_dims:
logger.warning_once(
f"Expected output tensor with {spm.expected_dims} dims, got {x.dim()}. Skipping gather."
)
continue
# Gather from all ranks
gathered = sp_gather(x, spm.gather_dim, validate=False)
# Remove padding if it was applied
if original_seq_len is not None and gathered.size(spm.gather_dim) > original_seq_len:
gathered = gathered.narrow(spm.gather_dim, 0, original_seq_len)
logger.debug(f"Removed padding: gathered shape {gathered.shape} (original_seq_len={original_seq_len})")
output[i] = gathered
return output[0] if is_tensor else type(output)(output)
def _get_submodule_by_name(model: nn.Module, name: str) -> nn.Module | list[nn.Module]:
"""Get a submodule by dotted name, supporting wildcards.
Args:
model: The root module.
name: Dotted path to submodule. Use "*" to match all children
of a ModuleList.
Returns:
The submodule or list of submodules if wildcard used.
Raises:
ValueError: If the path is invalid or module not found.
"""
if name.count("*") > 1:
raise ValueError("Wildcard '*' can only be used once in the name")
return _find_submodule_by_name(model, name)
def _find_submodule_by_name(model: nn.Module, name: str) -> nn.Module | list[nn.Module]:
"""Recursive helper for _get_submodule_by_name."""
if name == "":
return model
first_atom, remaining_name = name.split(".", 1) if "." in name else (name, "")
if first_atom == "*":
if not isinstance(model, nn.ModuleList):
raise ValueError("Wildcard '*' can only be used with ModuleList")
submodules = []
for submodule in model:
subsubmodules = _find_submodule_by_name(submodule, remaining_name)
if not isinstance(subsubmodules, list):
subsubmodules = [subsubmodules]
submodules.extend(subsubmodules)
return submodules
else:
if hasattr(model, first_atom):
submodule = getattr(model, first_atom)
return _find_submodule_by_name(submodule, remaining_name)
else:
raise ValueError(f"'{first_atom}' is not a submodule of '{model.__class__.__name__}'")
def apply_sequence_parallel(
module: nn.Module,
config: SequenceParallelConfig,
plan: SequenceParallelModelPlan,
) -> None:
"""Apply sequence parallel hooks to a model according to the plan.
This function registers hooks on the specified submodules to automatically
shard inputs and gather outputs for sequence parallelism.
Note: This corresponds to `apply_context_parallel` in diffusers.
The complete SP flow is:
1. Input sharding (SequenceParallelSplitHook): Split sequence across SP ranks
2. Attention parallelism (handled by vLLM-Omni's Attention layer):
- Ulysses: All-to-All over Q/K/V heads
- Ring: K/V circulation in ring topology
- Hybrid: Both (Ulysses handles head redistribution, Ring handles K/V)
3. Output gathering (SequenceParallelGatherHook): Gather sequence from SP ranks
Args:
module: The model to apply SP to.
config: The sequence parallel configuration.
plan: Dictionary mapping module names to input/output specifications.
Example:
config = SequenceParallelConfig(ulysses_degree=2)
plan = {
"": {"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3)},
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
}
apply_sequence_parallel(model, config, plan)
Note:
vLLM-Omni's Attention layer automatically handles the internal
parallelism (Ulysses All-to-All or Ring attention) based on the
forward_context configuration. This function only handles input/output
sharding for the model as a whole.
"""
logger.debug(
f"Applying sequence parallel with config: ulysses={config.ulysses_degree}, "
f"ring={config.ring_degree}, plan keys: {list(plan.keys())}"
)
for module_id, sp_model_plan in plan.items():
submodule = _get_submodule_by_name(module, module_id)
if not isinstance(submodule, list):
submodule = [submodule]
logger.debug(f"Applying SP hooks to '{module_id}' ({len(submodule)} module(s))")
for m in submodule:
if isinstance(sp_model_plan, dict):
# Input specification
hook = SequenceParallelSplitHook(sp_model_plan, config)
hook_name = _SP_INPUT_HOOK_TEMPLATE.format(module_id)
elif isinstance(sp_model_plan, (SequenceParallelOutput, list, tuple)):
# Output specification
if isinstance(sp_model_plan, SequenceParallelOutput):
sp_model_plan = [sp_model_plan]
if not all(isinstance(x, SequenceParallelOutput) or x is None for x in sp_model_plan):
raise ValueError(f"Expected SequenceParallelOutput elements, got {sp_model_plan}")
hook = SequenceParallelGatherHook(sp_model_plan, config)
hook_name = _SP_OUTPUT_HOOK_TEMPLATE.format(module_id)
else:
raise ValueError(f"Unsupported plan type: {type(sp_model_plan).__name__}")
registry = HookRegistry.get_or_create(m)
registry.register_hook(hook_name, hook)
def remove_sequence_parallel(
module: nn.Module,
plan: SequenceParallelModelPlan,
) -> None:
"""Remove sequence parallel hooks from a model.
Note: This corresponds to `remove_context_parallel` in diffusers.
Args:
module: The model to remove SP from.
plan: The same plan used when applying SP.
"""
for module_id, sp_model_plan in plan.items():
submodule = _get_submodule_by_name(module, module_id)
if not isinstance(submodule, list):
submodule = [submodule]
for m in submodule:
registry = getattr(m, "_hook_registry", None)
if registry is None:
continue
if isinstance(sp_model_plan, dict):
hook_name = _SP_INPUT_HOOK_TEMPLATE.format(module_id)
elif isinstance(sp_model_plan, (SequenceParallelOutput, list, tuple)):
hook_name = _SP_OUTPUT_HOOK_TEMPLATE.format(module_id)
else:
continue
registry.remove_hook(hook_name)
def enable_sequence_parallel_for_model(
model: nn.Module,
config: SequenceParallelConfig | None = None,
) -> None:
"""Enable sequence parallelism for a model using its _sp_plan.
This is a convenience function that reads the model's _sp_plan attribute
and applies sequence parallelism automatically.
Note: This corresponds to `enable_context_parallel_for_model` in diffusers,
but uses vLLM-Omni's _sp_plan instead of diffusers' _cp_plan.
The function performs two main tasks:
1. Applies _sp_plan hooks to shard inputs and gather outputs
2. Ensures Attention layers are configured for the correct parallel mode
(handled automatically by vLLM-Omni's forward_context mechanism)
Args:
model: The model to enable SP for. Must have a _sp_plan attribute.
config: Optional config. If None, uses default based on current
parallel state.
Raises:
ValueError: If model has no _sp_plan defined.
Note:
vLLM-Omni supports Ulysses + Ring hybrid parallelism:
- ulysses_degree > 1: Uses All-to-All communication over Q/K/V heads
- ring_degree > 1: Uses Ring attention with K/V passing
- Both > 1: Hybrid mode (Ulysses handles head redistribution,
Ring handles K/V circulation)
"""
from vllm_omni.diffusion.distributed.parallel_state import (
get_ring_parallel_world_size,
get_ulysses_parallel_world_size,
)
from vllm_omni.diffusion.distributed.sp_plan import get_sp_plan_from_model
plan = get_sp_plan_from_model(model)
if plan is None:
raise ValueError(
f"Model {model.__class__.__name__} has no _sp_plan defined. "
f"Define _sp_plan as a class attribute or pass a plan explicitly."
)
if config is None:
# Create config from current parallel state
ulysses_degree = get_ulysses_parallel_world_size()
ring_degree = get_ring_parallel_world_size()
config = SequenceParallelConfig(
ulysses_degree=ulysses_degree,
ring_degree=ring_degree,
)
if ulysses_degree > 1 and ring_degree > 1:
mode = "hybrid"
elif ulysses_degree > 1:
mode = "ulysses"
else:
mode = "ring"
logger.info(
f"Created SP config from parallel state: "
f"ulysses_degree={ulysses_degree}, ring_degree={ring_degree}, "
f"mode={mode}"
)
apply_sequence_parallel(model, config, plan)
logger.info(f"Enabled sequence parallelism for {model.__class__.__name__}")
def disable_sequence_parallel_for_model(model: nn.Module) -> None:
"""Disable sequence parallelism for a model.
Note: This corresponds to `disable_context_parallel_for_model` in diffusers.
Args:
model: The model to disable SP for.
"""
from vllm_omni.diffusion.distributed.sp_plan import get_sp_plan_from_model
plan = get_sp_plan_from_model(model)
if plan is not None:
remove_sequence_parallel(model, plan)
@@ -27,14 +27,11 @@ from vllm_omni.diffusion.attention.backends.abstract import (
AttentionMetadata,
)
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.attention.selector import _BACKENDS_SUPPORT_ATTENTION_MASK, get_attn_backend
from vllm_omni.diffusion.cache.base import CachedTransformer
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.distributed.parallel_state import (
get_ring_parallel_world_size,
get_sequence_parallel_rank,
get_sequence_parallel_world_size,
get_sp_group,
from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelInput,
SequenceParallelOutput,
)
from vllm_omni.diffusion.forward_context import get_forward_context
from vllm_omni.diffusion.layers.adalayernorm import AdaLayerNorm
@@ -43,6 +40,55 @@ from vllm_omni.diffusion.layers.rope import RotaryEmbedding
logger = init_logger(__name__)
class ImageRopePrepare(nn.Module):
"""Prepares image hidden_states and RoPE embeddings for sequence parallel.
This module encapsulates the input linear projection and RoPE computation.
Similar to Z-Image's UnifiedPrepare, this creates a module boundary where
_sp_plan can shard outputs via split_output=True.
The key insight is that hidden_states and vid_freqs must be sharded together
to maintain dimension alignment for RoPE computation in attention layers.
Note: Our _sp_plan corresponds to diffusers' _cp_plan (Context Parallelism).
"""
def __init__(self, img_in: nn.Linear, pos_embed: nn.Module):
super().__init__()
self.img_in = img_in
self.pos_embed = pos_embed
def forward(
self,
hidden_states: torch.Tensor,
img_shapes: list[tuple[int, int, int]],
txt_seq_lens: list[int],
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Prepare hidden_states and RoPE for SP.
Args:
hidden_states: [batch, img_seq_len, channels]
img_shapes: List of (frame, height, width) tuples
txt_seq_lens: List of text sequence lengths
Returns:
hidden_states: Processed hidden states [batch, img_seq_len, dim]
vid_freqs: Image RoPE frequencies [img_seq_len, rope_dim]
txt_freqs: Text RoPE frequencies [txt_seq_len, rope_dim]
Note: _sp_plan will shard hidden_states and vid_freqs via split_output=True
txt_freqs is kept replicated for dual-stream attention
"""
# Apply input projection
hidden_states = self.img_in(hidden_states)
# Compute RoPE embeddings
image_rotary_emb = self.pos_embed(img_shapes, txt_seq_lens, device=hidden_states.device)
vid_freqs, txt_freqs = image_rotary_emb
return hidden_states, vid_freqs, txt_freqs
class QwenTimestepProjEmbeddings(nn.Module):
def __init__(self, embedding_dim, use_additional_t_cond=False):
super().__init__()
@@ -436,6 +482,25 @@ class QwenImageCrossAttention(nn.Module):
hidden_states_mask: torch.Tensor | None = None,
encoder_hidden_states_mask: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
# Check for SP auto_pad: create attention mask dynamically if padding was applied
# In Ulysses mode, attention is computed on the FULL sequence (after All-to-All)
if (
self.parallel_config is not None
and self.parallel_config.sequence_parallel_size > 1
and hidden_states_mask is None
):
ctx = get_forward_context()
if ctx.sp_original_seq_len is not None and ctx.sp_padding_size > 0:
# Create mask for the full (padded) sequence
# valid positions = True, padding positions = False
batch_size = hidden_states.shape[0]
padded_seq_len = ctx.sp_original_seq_len + ctx.sp_padding_size
hidden_states_mask = torch.ones(
batch_size, padded_seq_len, dtype=torch.bool, device=hidden_states.device
)
hidden_states_mask[:, ctx.sp_original_seq_len :] = False
# if mask is all true, set it to None
if hidden_states_mask is not None and hidden_states_mask.all():
hidden_states_mask = None
if encoder_hidden_states_mask is not None and encoder_hidden_states_mask.all():
@@ -718,6 +783,31 @@ class QwenImageTransformer2DModel(CachedTransformer):
# used for torch compile optimizations
_repeated_blocks = ["QwenImageTransformerBlock"]
# Sequence Parallelism plan (following diffusers' _cp_plan pattern)
# Similar to Z-Image's UnifiedPrepare, we use ImageRopePrepare to create
# a module boundary where _sp_plan can shard hidden_states and vid_freqs together.
#
# Key insight: hidden_states and vid_freqs MUST be sharded together to maintain
# dimension alignment for RoPE computation in attention layers.
#
# auto_pad=True enables automatic padding when sequence length is not divisible
# by SP world size. This creates an attention mask stored in ForwardContext
# that attention layers can use to ignore padding positions.
#
# Note: _sp_plan corresponds to diffusers' _cp_plan (Context Parallelism)
_sp_plan = {
# Shard ImageRopePrepare outputs (hidden_states and vid_freqs must be sharded together)
"image_rope_prepare": {
# hidden_states: auto_pad=True for variable sequence length support
0: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True, auto_pad=True),
# vid_freqs: auto_pad=True to match hidden_states padding
1: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True, auto_pad=True),
# txt_freqs (index 2) is NOT sharded - kept replicated for dual-stream attention
},
# Gather output at proj_out
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
}
def __init__(
self,
od_config: OmniDiffusionConfig,
@@ -773,6 +863,10 @@ class QwenImageTransformer2DModel(CachedTransformer):
self.gradient_checkpointing = False
self.zero_cond_t = zero_cond_t
# ImageRopePrepare module for _sp_plan to shard hidden_states and vid_freqs together
# This ensures RoPE dimensions align with hidden_states after sharding
self.image_rope_prepare = ImageRopePrepare(self.img_in, self.pos_embed)
def forward(
self,
hidden_states: torch.Tensor,
@@ -816,49 +910,17 @@ class QwenImageTransformer2DModel(CachedTransformer):
# else:
# lora_scale = 1.0
original_seq_len = None
seq_padding = 0
hidden_states_mask = None
# Set split_text_embed_in_sp = False for dual-stream attention
# QwenImage uses *dual-stream* (text + image) and runs a *joint attention*.
# Text embeddings must be replicated across SP ranks for correctness.
if self.parallel_config.sequence_parallel_size > 1:
batch_size, seq_len, channels = hidden_states.shape
sp_size = get_sequence_parallel_world_size()
if seq_len % sp_size != 0:
# ring_attn, sage_attn do not support attention_mask
if get_attn_backend(-1).get_name() not in _BACKENDS_SUPPORT_ATTENTION_MASK:
raise ValueError(
f"When generating image shape that the sequence length is NOT divisible by sp_size={sp_size},"
f"cannot use {get_attn_backend(-1).get_name()} which does not support attention_mask."
f"Please switch to SDPA or Ascend attention backend."
)
# ring attention does not support attention_mask
if get_ring_parallel_world_size() > 1:
raise ValueError(
f"When generating image shape that the sequence length is NOT divisible by sp_size={sp_size},"
f"cannot use ring attention which does not support attention_mask."
f"Please switch to Ulysses SP only."
)
seq_padding = sp_size - (seq_len % sp_size)
original_seq_len = seq_len
hidden_states_mask = torch.ones(
batch_size, seq_len + seq_padding, dtype=torch.bool, device=hidden_states.device
)
hidden_states_mask[:, seq_len:] = False
padding_tensor = torch.zeros(
batch_size, seq_padding, channels, dtype=hidden_states.dtype, device=hidden_states.device
)
hidden_states = torch.cat([hidden_states, padding_tensor], dim=1)
hidden_states = torch.chunk(hidden_states, sp_size, dim=-2)[get_sequence_parallel_rank()]
# NOTE:
# QwenImage uses *dual-stream* (text + image) and runs a *joint attention*.
# text embeddings to be replicated across SP ranks for correctness.
get_forward_context().split_text_embed_in_sp = False
hidden_states = self.img_in(hidden_states)
# Prepare hidden_states and RoPE via ImageRopePrepare module
# _sp_plan will shard hidden_states and vid_freqs together via split_output=True
# txt_freqs is kept replicated for dual-stream attention
hidden_states, vid_freqs, txt_freqs = self.image_rope_prepare(hidden_states, img_shapes, txt_seq_lens)
image_rotary_emb = (vid_freqs, txt_freqs)
# Ensure timestep tensor is on the same device and dtype as hidden_states
timestep = timestep.to(device=hidden_states.device, dtype=hidden_states.dtype)
@@ -885,23 +947,6 @@ class QwenImageTransformer2DModel(CachedTransformer):
else self.time_text_embed(timestep, guidance, hidden_states, additional_t_cond)
)
image_rotary_emb = self.pos_embed(img_shapes, txt_seq_lens, device=hidden_states.device)
def get_rotary_emb_chunk(freqs, padding=0):
# Pad rotary embeddings if needed
if padding > 0:
padding_tensor = torch.zeros(padding, freqs.shape[-1], dtype=freqs.dtype, device=freqs.device)
freqs = torch.cat([freqs, padding_tensor], dim=0)
freqs = torch.chunk(freqs, get_sequence_parallel_world_size(), dim=0)[get_sequence_parallel_rank()]
return freqs
if self.parallel_config.sequence_parallel_size > 1:
img_freqs, txt_freqs = image_rotary_emb
img_freqs = get_rotary_emb_chunk(img_freqs, seq_padding)
if get_forward_context().split_text_embed_in_sp:
txt_freqs = get_rotary_emb_chunk(txt_freqs)
image_rotary_emb = (img_freqs, txt_freqs)
for index_block, block in enumerate(self.transformer_blocks):
encoder_hidden_states, hidden_states = block(
hidden_states=hidden_states,
@@ -911,7 +956,6 @@ class QwenImageTransformer2DModel(CachedTransformer):
image_rotary_emb=image_rotary_emb,
joint_attention_kwargs=attention_kwargs,
modulate_index=modulate_index,
hidden_states_mask=hidden_states_mask,
)
if self.zero_cond_t:
@@ -920,14 +964,8 @@ class QwenImageTransformer2DModel(CachedTransformer):
hidden_states = self.norm_out(hidden_states, temb)
output = self.proj_out(hidden_states)
if self.parallel_config.sequence_parallel_size > 1:
output = get_sp_group().all_gather(output, dim=-2)
# Remove padding if it was added
if original_seq_len is not None:
output = output[:, :original_seq_len, :]
torch.cuda.empty_cache()
# Note: SP gather is handled automatically by _sp_plan's SequenceParallelGatherHook
# on proj_out output. No manual all_gather needed here.
return Transformer2DModelOutput(sample=output)
@@ -176,6 +176,9 @@ class ZImagePipeline(nn.Module):
self.transformer = ZImageTransformer2DModel()
self.tokenizer = AutoTokenizer.from_pretrained(model, subfolder="tokenizer", local_files_only=local_files_only)
# Note: Context parallelism is applied centrally in registry.initialize_model()
# following diffusers' pattern of enable_parallelism() at model loading time
self.vae_scale_factor = (
2 ** (len(self.vae.config.block_out_channels) - 1) if hasattr(self, "vae") and self.vae is not None else 8
)
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# _sp_plan definition adapted from HuggingFace diffusers library (_cp_plan)
# Copyright 2025 Alibaba Z-Image Team and The HuggingFace Team. All rights reserved.
#
@@ -32,7 +33,14 @@ from vllm.model_executor.layers.linear import (
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelInput,
SequenceParallelOutput,
)
from vllm_omni.diffusion.forward_context import (
get_forward_context,
is_forward_context_available,
)
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
ADALN_EMBED_DIM = 256
@@ -41,6 +49,63 @@ SEQ_MULTI_OF = 32
logger = init_logger(__name__)
class UnifiedPrepare(nn.Module):
"""Prepares unified tensors for transformer blocks.
This module encapsulates the unification of x and cap tensors into unified
sequences. Similar to how Wan's `rope` module outputs rotary embeddings,
this module outputs unified tensors that can be sharded via _sp_plan's
split_output=True mechanism.
This follows the diffusers pattern where tensor preparation happens in
a dedicated submodule, enabling _sp_plan hooks to work at module boundaries.
"""
def forward(
self,
x: torch.Tensor,
x_cos: torch.Tensor,
x_sin: torch.Tensor,
cap_feats: torch.Tensor,
cap_cos: torch.Tensor,
cap_sin: torch.Tensor,
x_item_seqlens: list[int],
cap_item_seqlens: list[int],
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""Combine x and cap tensors into unified sequences.
Returns:
unified: Combined hidden states [batch, seq_len, dim]
unified_cos: Combined RoPE cos [batch, seq_len, rope_dim]
unified_sin: Combined RoPE sin [batch, seq_len, rope_dim]
unified_attn_mask: Combined attention mask [batch, seq_len]
"""
bsz = x.shape[0]
device = x.device
unified = []
unified_cos = []
unified_sin = []
for i in range(bsz):
x_len = x_item_seqlens[i]
cap_len = cap_item_seqlens[i]
unified.append(torch.cat([x[i][:x_len], cap_feats[i][:cap_len]]))
unified_cos.append(torch.cat([x_cos[i][:x_len], cap_cos[i][:cap_len]]))
unified_sin.append(torch.cat([x_sin[i][:x_len], cap_sin[i][:cap_len]]))
unified_item_seqlens = [a + b for a, b in zip(cap_item_seqlens, x_item_seqlens)]
unified_max_item_seqlen = max(unified_item_seqlens)
unified = pad_sequence(unified, batch_first=True, padding_value=0.0)
unified_cos = pad_sequence(unified_cos, batch_first=True, padding_value=0.0)
unified_sin = pad_sequence(unified_sin, batch_first=True, padding_value=0.0)
unified_attn_mask = torch.zeros((bsz, unified_max_item_seqlen), dtype=torch.bool, device=device)
for i, seq_len in enumerate(unified_item_seqlens):
unified_attn_mask[i, :seq_len] = 1
return unified, unified_cos, unified_sin, unified_attn_mask
def _positive_divisors(n: int) -> set[int]:
if n <= 0:
return set()
@@ -456,8 +521,52 @@ class RopeEmbedder:
class ZImageTransformer2DModel(nn.Module):
"""Z-Image Transformer model for image generation.
Sequence Parallelism:
This model supports non-intrusive SP via _sp_plan. The plan specifies:
- Input splitting at first main transformer block (unified sequence)
- RoPE (cos/sin) splitting along sequence dimension
- Attention mask splitting along sequence dimension
- Output gathering at final_layer
The SP is applied to the main `layers` transformer blocks where the
unified image+caption sequence is processed jointly.
Note: noise_refiner and context_refiner are NOT parallelized as they
process image and caption separately before unification.
Important: The default _sp_plan assumes patch_size=2 and f_patch_size=1.
If using different patch configurations, update _sp_plan accordingly.
Note: Our "Sequence Parallelism" (SP) corresponds to "Context Parallelism" (CP) in diffusers.
"""
_repeated_blocks = ["ZImageTransformerBlock"]
# Sequence Parallelism for Z-Image (following diffusers' _cp_plan pattern)
# Similar to how Wan uses `rope` module's split_output to shard rotary embeddings,
# Z-Image uses `unified_prepare` module's split_output to shard unified tensors.
#
# The _sp_plan specifies sharding/gathering at module boundaries:
# - unified_prepare: Split all 4 outputs (unified, cos, sin, attn_mask) via split_output=True
# - layers.0: hidden_states input is already sharded from unified_prepare output
# - all_final_layer.2-1: Gather outputs after the final layer
#
# Note: _sp_plan corresponds to diffusers' _cp_plan (Context Parallelism)
_sp_plan = {
# Shard unified_prepare outputs (similar to Wan's rope module)
# This shards all 4 return values: unified, unified_cos, unified_sin, unified_attn_mask
"unified_prepare": {
0: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True), # unified
1: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True), # unified_cos
2: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True), # unified_sin
3: SequenceParallelInput(split_dim=1, expected_dims=2, split_output=True), # unified_attn_mask
},
# Gather output at final_layer (default: patch_size=2, f_patch_size=1)
"all_final_layer.2-1": SequenceParallelOutput(gather_dim=1, expected_dims=3),
}
def __init__(
self,
all_patch_size=(2,),
@@ -572,6 +681,11 @@ class ZImageTransformer2DModel(nn.Module):
self.rope_embedder = RopeEmbedder(theta=rope_theta, axes_dims=axes_dims, axes_lens=axes_lens)
# UnifiedPrepare module for combining x and cap tensors
# This enables _cp_plan to shard outputs via split_output=True
# Similar to how Wan's rope module enables rotary embedding sharding
self.unified_prepare = UnifiedPrepare()
def unpatchify(self, x: list[torch.Tensor], size: list[tuple], patch_size, f_patch_size) -> list[torch.Tensor]:
pH = pW = patch_size
pF = f_patch_size
@@ -772,31 +886,19 @@ class ZImageTransformer2DModel(nn.Module):
for layer in self.context_refiner:
cap_feats = layer(cap_feats, cap_attn_mask, cap_cos, cap_sin)
# unified
unified = []
unified_cos = []
unified_sin = []
for i in range(bsz):
x_len = x_item_seqlens[i]
cap_len = cap_item_seqlens[i]
unified.append(torch.cat([x[i][:x_len], cap_feats[i][:cap_len]]))
unified_cos.append(torch.cat([x_cos[i][:x_len], cap_cos[i][:cap_len]]))
unified_sin.append(torch.cat([x_sin[i][:x_len], cap_sin[i][:cap_len]]))
unified_item_seqlens = [a + b for a, b in zip(cap_item_seqlens, x_item_seqlens)]
assert unified_item_seqlens == [len(_) for _ in unified]
unified_max_item_seqlen = max(unified_item_seqlens)
unified = pad_sequence(unified, batch_first=True, padding_value=0.0)
unified_cos = pad_sequence(unified_cos, batch_first=True, padding_value=0.0)
unified_sin = pad_sequence(unified_sin, batch_first=True, padding_value=0.0)
unified_attn_mask = torch.zeros((bsz, unified_max_item_seqlen), dtype=torch.bool, device=device)
for i, seq_len in enumerate(unified_item_seqlens):
unified_attn_mask[i, :seq_len] = 1
# Prepare unified tensors via UnifiedPrepare module
# This enables _cp_plan to shard outputs via split_output=True
unified, unified_cos, unified_sin, unified_attn_mask = self.unified_prepare(
x, x_cos, x_sin, cap_feats, cap_cos, cap_sin, x_item_seqlens, cap_item_seqlens
)
# Main transformer blocks
for layer in self.layers:
unified = layer(unified, unified_attn_mask, unified_cos, unified_sin, adaln_input)
# Final layer
unified = self.all_final_layer[f"{patch_size}-{f_patch_size}"](unified, adaln_input)
unified = list(unified.unbind(dim=0))
x = self.unpatchify(unified, x_size, patch_size, f_patch_size)
+86 -1
View File
@@ -3,9 +3,15 @@
import importlib
import torch.nn as nn
from vllm.logger import init_logger
from vllm.model_executor.models.registry import _LazyRegisteredModel, _ModelRegistry
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig, get_sp_plan_from_model
from vllm_omni.diffusion.hooks.sequence_parallel import apply_sequence_parallel
logger = init_logger(__name__)
_DIFFUSION_MODELS = {
# arch:(mod_folder, mod_relname, cls_name)
@@ -100,7 +106,24 @@ DiffusionModelRegistry = _ModelRegistry(
def initialize_model(
od_config: OmniDiffusionConfig,
):
) -> nn.Module:
"""Initialize a diffusion model from the registry.
This function:
1. Loads the model class from the registry
2. Instantiates the model with the config
3. Configures VAE optimization settings
4. Applies sequence parallelism if enabled (similar to diffusers' enable_parallelism)
Args:
od_config: The OmniDiffusion configuration.
Returns:
The initialized pipeline model.
Raises:
ValueError: If the model class is not found in the registry.
"""
model_class = DiffusionModelRegistry._try_load_model_cls(od_config.model_class_name)
if model_class is not None:
model = model_class(od_config=od_config)
@@ -110,11 +133,73 @@ def initialize_model(
if hasattr(model.vae, "use_tiling"):
model.vae.use_tiling = od_config.vae_use_tiling
# Apply sequence parallelism if enabled
# This follows diffusers' pattern where enable_parallelism() is called
# at model loading time, not inside individual model files
_apply_sequence_parallel_if_enabled(model, od_config)
return model
else:
raise ValueError(f"Model class {od_config.model_class_name} not found in diffusion model registry.")
def _apply_sequence_parallel_if_enabled(model, od_config: OmniDiffusionConfig) -> None:
"""Apply sequence parallelism hooks if SP is enabled.
This is the centralized location for enabling SP, similar to diffusers'
ModelMixin.enable_parallelism() method. It applies _sp_plan hooks to
transformer models that define them.
Note: Our "Sequence Parallelism" (SP) corresponds to "Context Parallelism" (CP) in diffusers.
We use _sp_plan instead of diffusers' _cp_plan.
Args:
model: The pipeline model (e.g., ZImagePipeline).
od_config: The OmniDiffusion configuration.
"""
try:
sp_size = od_config.parallel_config.sequence_parallel_size
if sp_size <= 1:
return
# Find transformer model(s) in the pipeline that have _sp_plan
transformer_attrs = ["transformer", "dit", "unet"]
for attr in transformer_attrs:
if not hasattr(model, attr):
continue
transformer = getattr(model, attr)
if transformer is None:
continue
plan = get_sp_plan_from_model(transformer)
if plan is None:
continue
# Create SP config
sp_config = SequenceParallelConfig(
ulysses_degree=od_config.parallel_config.ulysses_degree,
ring_degree=od_config.parallel_config.ring_degree,
)
# Apply hooks according to the plan
mode = (
"hybrid"
if sp_config.ulysses_degree > 1 and sp_config.ring_degree > 1
else ("ulysses" if sp_config.ulysses_degree > 1 else "ring")
)
logger.info(
f"Applying sequence parallelism to {transformer.__class__.__name__} "
f"(sp_size={sp_size}, mode={mode}, ulysses={sp_config.ulysses_degree}, ring={sp_config.ring_degree})"
)
apply_sequence_parallel(transformer, sp_config, plan)
return # Only apply to first transformer found
except Exception as e:
logger.warning(f"Failed to apply sequence parallelism: {e}. Continuing without SP hooks.")
_DIFFUSION_POST_PROCESS_FUNCS = {
# arch: post_process_func
# `post_process_func` function must be placed in {mod_folder}/{mod_relname}.py,