Compare commits

..
Author SHA1 Message Date
Hongsheng Liu 01e7d09119 docs: define module ownership contracts
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-07 11:45:59 +08:00
hsliuustc0106 69766960f2 docs: update module design navigation
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-07-16 14:09:07 +08:00
hsliuustc0106 80fd96c1da docs: reorganize module design documents
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-07-16 12:08:45 +08:00
66 changed files with 245 additions and 3361 deletions

No files matched your search

+2 -4
View File
@@ -47,17 +47,15 @@ steps:
- timeout 15m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model"
mirror_hardwares: l4_1
- label: "Diffusion · Distributed & Attention Test"
- label: "Diffusion · Distributed Test"
source_file_dependencies:
- tests/diffusion/distributed/
- tests/diffusion/attention/test_attention_sp.py
- vllm_omni/diffusion/distributed/
- vllm_omni/diffusion/attention/
- vllm_omni/diffusion/attention/parallel/
- vllm_omni/diffusion/data.py
- vllm_omni/diffusion/models/
commands:
- timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda' --run-level "core_model"
- timeout 20m pytest -sv tests/diffusion/attention/test_attention_sp.py -m 'core_model and cuda' --run-level "core_model"
mirror_hardwares: l4_4
- label: "Diffusion · Model Test"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

@@ -17,7 +17,7 @@ The full set of backends and their platform defaults is in the **Backend Options
| `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). Dense BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Packed paths can provide `cu_seqlens` directly. Supports optional **Skip-Softmax** sparse attention — see below. Requires `flashinfer`. |
| `FLASH_ATTN` | Wraps FlashAttention 4 on Blackwell when `flash-attn-4` is installed, then falls back to FlashAttention 3/2. Default on Hopper / Ada / Ampere when a compatible FlashAttention package is installed. |
| `CUDNN_ATTN` | Pins `sdpa_kernel([CUDNN_ATTENTION])`. Default on Blackwell (sm_10x / sm_12x) with cuDNN ≥ 9.5. Wins on mask-heavy DiTs (HunyuanVideo-1.5: 2× e2e vs SDPA). |
| `FLASHINFER_ATTN` | Uses FlashInfer's batch-prefill wrapper and supports mixed Q/K and V input dtypes through backend-specific configuration. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer` >= 0.6.16rc1 for mixed-dtype configurations. |
| `FLASHINFER_ATTN` | Calls FlashInfer's dense `single_prefill_with_kv_cache` directly with `custom_mask` for non-causal masked attention. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer`. |
| `TORCH_SDPA` | PyTorch `scaled_dot_product_attention` with the default backend dispatcher. Most conservative; always available. |
| `SAGE_ATTN` | SageAttention 2.2 — INT8-quantized attention with FP16 accumulation. Lossy but typically visually indistinguishable on diffusion outputs. Requires `sageattention`. |
| `SAGE_ATTN_3` | Requires `sageattn3` from `SageAttention/sageattention3_blackwell`. CUDA only, intended for Blackwell GPUs, with GQA/MQA requests falling back to PyTorch SDPA. |
@@ -84,7 +84,7 @@ A backend that needs configuration exposes it as a typed field on the spec:
When constructing `OmniDiffusionConfig` directly:
```python
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, AttnQuantSpec, OmniDiffusionConfig
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, OmniDiffusionConfig
config = OmniDiffusionConfig(
diffusion_attention_config=AttentionConfig(
@@ -99,27 +99,6 @@ config = OmniDiffusionConfig(
A plain dict is also accepted and normalized to `AttentionConfig`.
#### FlashInfer QK16/V8 quantized attention
`FLASHINFER_ATTN` accepts quantization options through `AttentionSpec.quant`. For QK16/V8:
```python
config = OmniDiffusionConfig(
diffusion_attention_config=AttentionConfig(
default=AttentionSpec(
backend="FLASHINFER_ATTN",
quant=AttnQuantSpec(
dtype_qk="bfloat16",
dtype_vo="fp8_e4m3",
),
),
),
...,
)
```
`dtype_qk` configures the Q and K input dtypes. `dtype_vo` configures the V input dtype.
## Platform Defaults
### Blackwell (sm_100 / sm_103 / sm_120 / sm_121)
@@ -6,7 +6,6 @@
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Example Script](#example-script)
- [Request-Scoped Quality](#request-scoped-quality-minimax-h3)
- [Acceleration Methods](#acceleration-methods)
- [Configuration Parameters](#configuration-parameters)
- [Best Practices](#best-practices)
@@ -141,43 +140,6 @@ vllm serve Qwen/Qwen-Image --omni --port 8091 \
--cache-config '{"Fn_compute_blocks": 1, "residual_diff_threshold": 0.12}'
```
## Request-Scoped Quality (MiniMax H3)
MiniMax H3 can switch Cache-DiT at the request boundary without enabling a
cache backend at server startup. Add a quality field to an online request:
```bash
# Native reference path for this request.
-F 'quality=lossless'
# H3's conservative Cache-DiT profile for this request.
-F 'quality=high'
```
For offline inference, set the same field on the sampling parameters:
```python
OmniDiffusionSamplingParams(
quality="high",
num_inference_steps=50,
extra_args={"task": "t2va", "duration": 5.0, "audio_flow_shift": 3.0},
)
```
| Server startup | Request quality | Behavior |
|---|---|---|
| `--cache-backend cache_dit` | omitted | Use or restore the startup Cache-DiT profile |
| `--cache-backend cache_dit` | `lossless` | Remove Cache-DiT hooks and run the reference path |
| `--cache-backend cache_dit` | `high` | Use or install H3's conservative Cache-DiT profile |
| no Cache-DiT | omitted or `lossless` | Run the reference path |
| no Cache-DiT | `high` | Install H3's conservative Cache-DiT profile |
The startup option is only needed when omitted-quality requests should use the
server-configured Cache-DiT profile by default.
See the [MiniMax H3 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/MiniMaxAI/MiniMax-H3.md#request-scoped-quality)
for a complete request and measured trade-off.
---
## Acceleration Methods
@@ -1,240 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Reproduce MiniMax-H3 request-mode and DLO/DP2 lifecycle behavior.
Examples:
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \
VLLM_OMNI_DLO_DP_WAVE_TIMEOUT=600 \
CUDA_VISIBLE_DEVICES=0,1 \
python examples/offline_inference/minimax_h3/dlo_dp2_lifecycle.py \
--model /path/to/MiniMax-H3/FL2VA --mode dlo-dp2
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \
CUDA_VISIBLE_DEVICES=0,1 \
python examples/offline_inference/minimax_h3/dlo_dp2_lifecycle.py \
--model /path/to/MiniMax-H3/FL2VA --mode request
"""
from __future__ import annotations
import argparse
import asyncio
import copy
import json
import multiprocessing
import time
from pathlib import Path
from typing import Any
import numpy as np
from vllm_omni.entrypoints.async_omni import AsyncOmni
DEFAULT_PROMPTS = (
"At night, three cats march into a bedroom playing tiny brass instruments, "
"then abruptly file out, with synchronized room ambience.",
"A paper boat crosses a rain-filled street while distant traffic and water sounds remain synchronized.",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", required=True, help="Path to MiniMax-H3/FL2VA")
parser.add_argument(
"--mode",
choices=("dlo-dp2", "request"),
required=True,
help="DLO AllGather DP2 or ordinary non-DLO TP2 request mode",
)
parser.add_argument("--steps", type=int, default=2)
parser.add_argument("--duration", type=float, default=5.0)
parser.add_argument("--width", type=int, default=1344)
parser.add_argument("--height", type=int, default=768)
parser.add_argument("--batch-wait-ms", type=float, default=500.0)
parser.add_argument("--init-timeout", type=float, default=1800.0)
parser.add_argument("--output", type=Path)
return parser.parse_args()
def engine_kwargs(args: argparse.Namespace) -> dict[str, Any]:
common: dict[str, Any] = {
"model": args.model,
"trust_remote_code": True,
"num_gpus": 2,
"usp": 1,
"ring": 1,
"vae_parallel_mode": "tile",
"vae_use_tiling": True,
"diffusion_attention_backend": "CUDNN_ATTN",
"request_batch_max_wait_ms": args.batch_wait_ms,
"enforce_eager": True,
"stage_init_timeout": args.init_timeout,
"init_timeout": args.init_timeout,
}
if args.mode == "dlo-dp2":
common.update(
tensor_parallel_size=1,
data_parallel_size=2,
text_encoder_tp_size=1,
vae_patch_parallel_size=1,
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
dlo_resident_layers=0,
)
else:
common.update(
tensor_parallel_size=2,
data_parallel_size=1,
text_encoder_tp_size=2,
vae_patch_parallel_size=2,
enable_distributed_layerwise_offload=False,
)
return common
def sampling_params(
engine: AsyncOmni,
args: argparse.Namespace,
seed: int,
) -> list[Any]:
params = copy.deepcopy(engine.default_sampling_params_list)
diffusion = params[0]
diffusion.width = args.width
diffusion.height = args.height
diffusion.fps = 24
diffusion.num_inference_steps = args.steps
diffusion.seed = seed
diffusion.extra_args = {
"task": "t2va",
"duration": args.duration,
"aspect_ratio": "16:9",
"flow_shift": 12.0,
"audio_flow_shift": 3.0,
}
return params
async def generate_one(
engine: AsyncOmni,
args: argparse.Namespace,
*,
request_id: str,
prompt: str,
seed: int,
) -> Any:
final_output = None
async for output in engine.generate(
prompt=prompt,
request_id=request_id,
sampling_params_list=sampling_params(engine, args, seed),
):
if output.finished:
final_output = output
if final_output is None:
raise RuntimeError(f"{request_id} finished without an output")
return final_output
def output_summary(output: Any, args: argparse.Namespace) -> dict[str, Any]:
if not output.images:
raise RuntimeError(f"{output.request_id} returned no video")
frames = np.asarray(output.images[0])
audio = np.asarray(output.multimodal_output.get("audio"))
if frames.ndim != 4 or tuple(frames.shape[1:]) != (
args.height,
args.width,
3,
):
raise RuntimeError(f"{output.request_id} returned invalid video shape {tuple(frames.shape)}")
if audio.ndim != 3 or tuple(audio.shape[:2]) != (1, 2):
raise RuntimeError(f"{output.request_id} returned invalid audio shape {tuple(audio.shape)}")
if args.duration == 5.0 and args.height == 768 and args.width == 1344:
if tuple(frames.shape) != (124, 768, 1344, 3) or tuple(audio.shape) != (1, 2, 165600):
raise RuntimeError(
f"{output.request_id} default shape mismatch: video={tuple(frames.shape)}, audio={tuple(audio.shape)}"
)
return {
"request_id": output.request_id,
"frames_shape": list(frames.shape),
"audio_shape": list(audio.shape),
"peak_memory_mb": output.peak_memory_mb,
"stage_durations": output.stage_durations,
}
async def run(args: argparse.Namespace) -> dict[str, Any]:
engine = AsyncOmni(**engine_kwargs(args))
summary: dict[str, Any] = {
"mode": args.mode,
"engine_kwargs": engine_kwargs(args),
}
try:
if args.mode == "dlo-dp2":
started = time.perf_counter()
asymmetric = await asyncio.gather(
generate_one(
engine,
args,
request_id="invalid-empty",
prompt="",
seed=1001,
),
generate_one(
engine,
args,
request_id="invalid-peer",
prompt=DEFAULT_PROMPTS[0],
seed=1002,
),
return_exceptions=True,
)
summary["asymmetric_wave_s"] = time.perf_counter() - started
summary["asymmetric_errors"] = [
f"{type(result).__name__}: {result}" for result in asymmetric if isinstance(result, BaseException)
]
if len(summary["asymmetric_errors"]) != 2:
raise RuntimeError("Both requests in the asymmetric DP wave must fail before dispatch")
started = time.perf_counter()
request_count = 2 if args.mode == "dlo-dp2" else 1
outputs = await asyncio.gather(
*(
generate_one(
engine,
args,
request_id=f"recovery-{index}",
prompt=DEFAULT_PROMPTS[index],
seed=2000 + index,
)
for index in range(request_count)
)
)
summary["valid_wave_s"] = time.perf_counter() - started
summary["outputs"] = [output_summary(output, args) for output in outputs]
finally:
started = time.perf_counter()
engine.close()
summary["shutdown_s"] = time.perf_counter() - started
children = multiprocessing.active_children()
summary["active_children"] = [{"name": child.name, "pid": child.pid} for child in children]
if children:
raise RuntimeError(f"Worker processes remain after shutdown: {children}")
return summary
def main() -> None:
args = parse_args()
summary = asyncio.run(run(args))
rendered = json.dumps(summary, indent=2, sort_keys=True)
print(f"E2E_RESULT {rendered}", flush=True)
if args.output is not None:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
-33
View File
@@ -574,43 +574,10 @@ default. `short_edge` controls the 768-pixel canvas and must be `768`.
outputs; the synchronous raw-MP4 endpoint returns the first output when more
than one is requested.
## Request-scoped quality
Add one of these fields to any HTTP request above. No Cache-DiT startup option
is required; H3 installs its conservative profile when a `high` request
arrives and removes it for a `lossless` request.
```bash
-F 'quality=lossless' # Native reference path for this request.
-F 'quality=high' # H3's conservative Cache-DiT profile.
```
Omitting `quality` preserves the startup default: it uses the reference path
normally, or the server-configured profile when the server was started with
`--cache-backend cache_dit`.
The following result was measured on 4× NVIDIA H200 with SP4, text-encoder
TP4, 1344×768, 124 frames, 24 FPS, and 50 inference steps. One full
`lossless` warmup was excluded, followed by three fixed prompt/seed pairs in
balanced switch order.
| `quality` | Median inference latency | Speedup | SSIM vs `lossless` | PSNR vs `lossless` | Expected trade-off |
|---|---:|---:|---:|---:|---|
| `lossless` | 85.49 s | 1.00× | 1.0000 | exact | Native reference path |
| `high` | 63.36 s | 1.35× | 0.9709 | 34.98 dB | Faster with measured same-seed deviation |
> [!NOTE]
> `high` selects a fixed Cache-DiT profile, but its cache hit rate and
> resulting latency/quality trade-off may vary by hardware, topology, and
> workload. The values above apply to this deployment and are not universal
> guarantees. `lossless` remains the exact reference path.
## Key parameters
| Parameter | Recommended value | Notes |
|-----------|-------------------|-------|
| `quality` | omitted or `lossless` | Request-level quality intent; `high` dynamically installs H3's conservative Cache-DiT profile |
| `extra_params.force_refresh_step_hint` | omitted | Optional positive 1-based denoising-step hint for an active Cache-DiT request; pair with `extra_params.force_refresh_step_policy`=`once` or `repeat` |
| `task` | `t2va`, `fl2va`, or `ref2va` | Passed in `extra_params`; selects the task-specific DiT |
| `duration` | Workload-specific | Decimal seconds in `extra_params`; converted to H3-compatible frame count |
| `fps` | `24` | H3 output FPS is fixed |
+5 -16
View File
@@ -31,7 +31,6 @@ import tempfile
import pytest
import torch
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata, QueryRange
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.attention.parallel.allgather_kv import (
@@ -410,7 +409,6 @@ def test_allgather_kv_keeps_gathered_kv_compressed_for_gqa():
torch.testing.assert_close(v_full, expected_value)
@hardware_test(res={"cuda": "L4"}, num_cards=4)
@pytest.mark.parametrize(
"test_model_cls",
[
@@ -418,12 +416,10 @@ def test_allgather_kv_keeps_gathered_kv_compressed_for_gqa():
],
)
@pytest.mark.parametrize(
("ulysses_degree", "ring_degree", "allgather_degree", "num_kv_heads"),
("ulysses_degree", "ring_degree", "allgather_degree"),
[
pytest.param(2, 2, 1, None, id="ulysses-ring"),
pytest.param(1, 1, 2, None, id="allgather-kv"),
pytest.param(1, 2, 1, 2, id="ring-gqa"),
pytest.param(1, 2, 1, 1, id="ring-mqa"),
pytest.param(2, 2, 1, id="ulysses-ring"),
pytest.param(1, 1, 2, id="allgather-kv"),
],
)
@pytest.mark.parametrize("batch_size", [2])
@@ -449,7 +445,6 @@ def test_sequence_parallel(
seq_len: int,
num_heads: int,
head_size: int,
num_kv_heads: int | None,
):
"""Compare Ulysses/Ring and AllGather-KV SP against a single-rank run."""
sequence_parallel_size = allgather_degree if allgather_degree > 1 else ulysses_degree * ring_degree
@@ -481,7 +476,6 @@ def test_sequence_parallel(
seq_len,
num_heads,
head_size,
num_kv_heads,
dtype,
causal,
use_sync,
@@ -513,7 +507,6 @@ def test_sequence_parallel(
seq_len,
num_heads,
head_size,
num_kv_heads,
dtype,
causal,
use_sync,
@@ -610,7 +603,6 @@ def ulysses_attention_on_test_model(
seq_len: int,
num_heads: int,
head_size: int,
num_kv_heads: int | None,
dtype: torch.dtype,
causal: bool,
use_sync: bool,
@@ -666,13 +658,10 @@ def ulysses_attention_on_test_model(
cfg_parallel_size=1,
)
od_config = OmniDiffusionConfig.from_kwargs(
od_config = OmniDiffusionConfig(
model="test_model",
dtype=dtype,
parallel_config=parallel_config,
# This regression targets pytorch_attn_forward(). Do not let an
# installed FA/FA3 backend silently bypass the SDPA Ring path.
diffusion_attention_backend="TORCH_SDPA",
)
# Initialize model parallel
@@ -698,7 +687,7 @@ def ulysses_attention_on_test_model(
"head_size": head_size,
"hidden_size": hidden_size,
"causal": causal,
"num_kv_heads": num_kv_heads,
"num_kv_heads": None,
"scatter_idx": 2,
"gather_idx": 1,
"use_sync": use_sync,
+1 -32
View File
@@ -6,14 +6,12 @@ from __future__ import annotations
import os
import socket
import tempfile
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.attention.parallel.ulysses import UlyssesParallelAttention
from vllm_omni.diffusion.config import set_current_diffusion_config
from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.parallel_state import (
@@ -41,33 +39,6 @@ def _set_dist_env(*, rank: int, world_size: int, master_port: int) -> None:
os.environ["MASTER_PORT"] = str(master_port)
@pytest.mark.core_model
@pytest.mark.cpu
def test_advanced_uaa_hybrid_rejects_padded_mqa_heads(monkeypatch) -> None:
monkeypatch.setattr(
"vllm_omni.diffusion.attention.parallel.ulysses.get_ulysses_mode",
lambda default: "advanced_uaa",
)
sp_group = SimpleNamespace(
ulysses_group=None,
ulysses_world_size=2,
ulysses_rank=0,
ring_world_size=2,
)
strategy = UlyssesParallelAttention(
sp_group=sp_group,
scatter_idx=2,
gather_idx=1,
use_sync=False,
)
query = torch.zeros(1, 2, 8, 4)
key = torch.zeros(1, 2, 1, 4)
value = torch.zeros_like(key)
with pytest.raises(ValueError, match="does not support K/V head padding"):
strategy.pre_attention(query, key, value, None)
def _run_attention_case(
local_rank: int,
world_size: int,
@@ -270,9 +241,7 @@ def test_ulysses_uaa_hybrid_ring_matches_baseline() -> None:
batch_size = 2
head_size = 8
seq_len = 10
# This is the positive Hybrid case: K/V heads must not require padding,
# which is unsupported by advanced_uaa when Ring is also enabled.
num_heads = 4
num_heads = 3 # head_cnt not divisible by ulysses_degree=2 -> triggers head padding
# Ensure ring ranks see equal post-Ulysses seq_len:
# rank0/1 -> 3+2=5, rank2/3 -> 3+2=5
+3 -80
View File
@@ -11,7 +11,6 @@ This module tests the cache backend implementations:
- DiffusionCacheConfig: configuration dataclass
"""
from types import SimpleNamespace
from unittest.mock import Mock, patch
import cache_dit
@@ -22,7 +21,6 @@ from vllm_omni.diffusion.cache.cachedit import (
CacheDiTAdapterConfig,
CacheDiTBackend,
CacheDiTConfig,
cache_summary,
)
from vllm_omni.diffusion.cache.magcache import MagCacheBackend
from vllm_omni.diffusion.cache.selector import get_cache_backend
@@ -93,8 +91,8 @@ class TestCacheDiTBackend:
@patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
def test_lifecycle_applies_to_every_declared_dit(self, mock_cache_dit, mock_block_adapter):
"""Combined pipelines manage every exact Cache-DiT target."""
def test_enable_and_refresh_every_declared_dit(self, mock_cache_dit, mock_block_adapter):
"""Combined pipelines enable and refresh Cache-DiT for every DiT."""
pipeline = Mock()
pipeline.__class__.__name__ = "MiniMaxH3Pipeline"
pipeline._dit_modules = ["transformer", "transformers_ref"]
@@ -104,67 +102,18 @@ class TestCacheDiTBackend:
transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={"blocks": ForwardPattern.Pattern_3}
)
installed_adapters = [Mock(), Mock()]
mock_block_adapter.side_effect = installed_adapters
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
backend.enable(pipeline)
backend.refresh(pipeline, num_inference_steps=20)
backend.disable(pipeline)
assert len(backend._refresh_funcs) == 2
assert mock_cache_dit.enable_cache.call_count == 2
assert mock_cache_dit.refresh_context.call_count == 2
assert {call.args[0] for call in mock_cache_dit.refresh_context.call_args_list} == {
pipeline.transformer,
pipeline.transformers_ref,
}
assert [call.args[0] for call in mock_cache_dit.disable_cache.call_args_list] == installed_adapters
assert backend._refresh_funcs == []
assert backend._cache_targets == []
assert not backend.is_enabled()
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
def test_summary_skips_uncached_nested_dit(self, mock_cache_dit):
"""Only DiT modules with an active Cache-DiT context are summarized."""
language_model = Mock()
language_model._is_cached = False
transformer = Mock()
transformer._is_cached = True
transformer.language_model = language_model
pipeline = SimpleNamespace(
_dit_modules=["transformer.language_model", "transformer"],
transformer=transformer,
)
cache_summary(pipeline)
mock_cache_dit.summary.assert_called_once_with(transformer, details=True)
@patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
def test_enable_and_refresh_nested_declared_dit(self, mock_cache_dit, mock_block_adapter):
"""Dotted component paths resolve to their nested DiT module."""
transformer = Mock()
transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={"blocks": ForwardPattern.Pattern_3}
)
pipeline = SimpleNamespace(
_dit_modules=["language_model.model"],
language_model=SimpleNamespace(model=transformer),
)
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
backend.enable(pipeline)
backend.refresh(pipeline, num_inference_steps=20)
cache_summary(pipeline)
mock_cache_dit.enable_cache.assert_called_once()
mock_cache_dit.refresh_context.assert_called_once_with(
transformer,
num_inference_steps=20,
verbose=True,
)
mock_cache_dit.summary.assert_called_once_with(transformer, details=True)
@patch("vllm_omni.diffusion.cache.cachedit.backend.logger")
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
@@ -297,32 +246,6 @@ class TestCacheDiTBackend:
assert mock_cache_dit.refresh_context.call_count == 2
@patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
def test_refresh_rearms_force_refresh_hint(self, mock_cache_dit, mock_block_adapter):
"""A once hint must be restored for every repeated request."""
pipeline = Mock()
pipeline.__class__.__name__ = "DiTPipeline"
pipeline.transformer = Mock()
pipeline.transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={"layers": ForwardPattern.Pattern_0}
)
config = DiffusionCacheConfig(
force_refresh_step_hint=7,
force_refresh_step_policy="once",
)
backend = CacheDiTBackend(config)
backend.enable(pipeline)
backend.refresh(pipeline, num_inference_steps=20)
backend.refresh(pipeline, num_inference_steps=20)
assert mock_cache_dit.refresh_context.call_count == 2
for call in mock_cache_dit.refresh_context.call_args_list:
refresh_config = call.kwargs["cache_config"]
assert refresh_config.force_refresh_step_hint == 7
assert refresh_config.force_refresh_step_policy == "once"
@patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
def test_enable_hunyuan_pipeline_uses_model_transformer(self, mock_cache_dit, mock_block_adapter):
-2
View File
@@ -81,8 +81,6 @@ def test_cachedit_public_api_is_explicit():
"CacheDiTAdapterConfig",
"CacheDiTBackend",
"CacheDiTConfig",
"CacheDiTRequestSpec",
"RequestScopedCacheDiTRuntime",
"SensenovaCachedAdapter",
"cache_summary",
"enable_cache_for_dit",
-101
View File
@@ -1,101 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm_omni.diffusion.cache.cachedit import runtime as runtime_module
from vllm_omni.diffusion.cache.cachedit.runtime import (
CacheDiTRequestSpec,
RequestScopedCacheDiTRuntime,
)
from vllm_omni.diffusion.data import DiffusionCacheConfig
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _spec(key: str, steps: int = 50) -> CacheDiTRequestSpec:
return CacheDiTRequestSpec(
installation_key=key,
cache_config=DiffusionCacheConfig(),
num_inference_steps=steps,
)
def _recording_backend(events):
class FakeBackend:
def __init__(self, config):
events.append(("create", config))
self.enabled = False
def enable(self, pipeline):
events.append(("enable", pipeline))
self.enabled = True
def refresh(self, pipeline, num_inference_steps):
events.append(("refresh", pipeline, num_inference_steps))
def disable(self, pipeline):
events.append(("disable", pipeline))
self.enabled = False
def is_enabled(self):
return self.enabled
return FakeBackend
def test_startup_cache_follows_omit_lossless_high_omit_state_machine(monkeypatch):
events = []
FakeBackend = _recording_backend(events)
monkeypatch.setattr(runtime_module, "CacheDiTBackend", FakeBackend)
pipeline = object()
config = DiffusionCacheConfig()
runtime = RequestScopedCacheDiTRuntime(pipeline)
startup_backend = FakeBackend(config)
startup_backend.enabled = True
runtime.adopt(startup_backend, installation_key="generic")
runtime.prepare(_spec("generic", 50)) # omitted: keep startup cache
runtime.prepare(None) # lossless: uninstall
runtime.prepare(None) # repeated lossless: no-op
runtime.prepare(_spec("high", 40)) # high: install H3 profile
runtime.prepare(_spec("high", 30)) # repeated high: refresh only
runtime.prepare(_spec("generic", 20)) # omitted: restore startup profile
assert [event[0] for event in events] == [
"create",
"refresh",
"disable",
"create",
"enable",
"refresh",
"refresh",
"disable",
"create",
"enable",
"refresh",
]
assert events[1][-1] == 50
assert events[5][-1] == 40
assert events[6][-1] == 30
assert events[10][-1] == 20
assert runtime.is_enabled
def test_high_installs_without_startup_cache_and_lossless_uninstalls(monkeypatch):
events = []
monkeypatch.setattr(runtime_module, "CacheDiTBackend", _recording_backend(events))
pipeline = object()
runtime = RequestScopedCacheDiTRuntime(pipeline)
runtime.prepare(_spec("high", 40))
runtime.prepare(None)
assert [event[0] for event in events] == [
"create",
"enable",
"refresh",
"disable",
]
assert events[2][-1] == 40
assert not runtime.is_enabled
@@ -13,70 +13,6 @@ import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def test_h3_prepares_resolved_cache_state_immediately_before_denoise():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.partition = "fl2va"
pipeline.supported_tasks = frozenset({"t2va"})
pipeline.default_video_shift = 12.0
pipeline.default_audio_shift = 3.0
pipeline.device = torch.device("cpu")
pipeline.od_config = SimpleNamespace()
cache_spec = object()
quality_plan = SimpleNamespace(cache_dit=cache_spec)
pipeline._quality_policy = Mock()
pipeline._quality_policy.resolve.return_value = quality_plan
events = []
pipeline._cache_dit_runtime = SimpleNamespace(prepare=lambda spec: events.append(("prepare", spec)))
pipeline.encode_prompt = Mock(
return_value=(
torch.ones(1, 2),
torch.ones(1, dtype=torch.long),
)
)
def diffuse(**kwargs):
events.append(("diffuse", kwargs))
return torch.zeros(1), torch.zeros(1)
pipeline.diffuse = diffuse
pipeline.decode = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
sampling = OmniDiffusionSamplingParams(
quality="high",
width=1344,
height=768,
fps=24,
num_frames=124,
num_inference_steps=50,
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
)
batch = DiffusionRequestBatch(
[
OmniDiffusionRequest(
prompt="quality boundary",
sampling_params=sampling,
request_id="quality-boundary",
)
]
)
output = pipeline.forward(batch)
assert events[0] == ("prepare", cache_spec)
assert events[1][0] == "diffuse"
pipeline._quality_policy.resolve.assert_called_once_with(
quality="high",
num_inference_steps=50,
extra_args={"task": "t2va", "aspect_ratio": "16:9"},
)
assert output.output == pipeline.decode.return_value
def test_pipeline_import_registry_and_component_discovery():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
from vllm_omni.diffusion.registry import (
@@ -1,212 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
from vllm_omni.diffusion.data import DiffusionCacheConfig
from vllm_omni.errors import OmniClientError
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _quality_od_config(*, cache_backend: str = "cache_dit", **overrides):
values = {
"cache_backend": cache_backend,
"cache_config": DiffusionCacheConfig(),
"diffusion_attention_config": SimpleNamespace(default=None, per_role={}),
"enable_cpu_offload": False,
"enable_layerwise_offload": False,
"enable_distributed_layerwise_offload": False,
"enforce_eager": True,
"num_gpus": 4,
"quantization_config": None,
"parallel_config": SimpleNamespace(
allgather_degree=1,
cfg_parallel_size=1,
data_parallel_size=1,
pipeline_parallel_size=1,
ring_degree=1,
sequence_parallel_size=4,
tensor_parallel_size=1,
ulysses_degree=4,
use_hsdp=False,
),
}
values.update(overrides)
return SimpleNamespace(**values)
def _resolve_quality(policy, **overrides):
values = {
"quality": "high",
"num_inference_steps": 50,
"extra_args": None,
}
values.update(overrides)
return policy.resolve(**values)
def test_high_quality_policy_emits_cache_profile_without_deployment_gates():
from vllm_omni.diffusion.models.minimax_h3.quality_policy import (
MiniMaxH3QualityPolicy,
)
policy = MiniMaxH3QualityPolicy(
_quality_od_config(
cache_backend="none",
enable_layerwise_offload=True,
enforce_eager=False,
num_gpus=1,
)
)
plan = _resolve_quality(policy, num_inference_steps=17)
spec = plan.cache_dit
assert spec is not None
assert spec.installation_key == "minimax_h3.high"
assert spec.num_inference_steps == 17
assert spec.cache_config.Fn_compute_blocks == 1
assert spec.cache_config.Bn_compute_blocks == 0
assert spec.cache_config.max_warmup_steps == 4
assert spec.cache_config.residual_diff_threshold == pytest.approx(0.04)
assert spec.cache_config.max_continuous_cached_steps == 1
assert spec.cache_config.enable_taylorseer is False
assert spec.cache_config.scm_steps_mask_policy is None
def test_h3_force_refresh_is_model_owned_and_request_scoped():
from vllm_omni.diffusion.models.minimax_h3.quality_policy import MiniMaxH3QualityPolicy
policy = MiniMaxH3QualityPolicy(_quality_od_config())
plan = _resolve_quality(
policy,
quality="high",
num_inference_steps=17,
extra_args={"force_refresh_step_hint": 7},
)
assert plan.cache_dit is not None
assert plan.cache_dit.installation_key == "minimax_h3.high:force_refresh=7:once"
assert plan.cache_dit.cache_config.force_refresh_step_hint == 7
assert plan.cache_dit.cache_config.force_refresh_step_policy == "once"
def test_h3_force_refresh_policy_supports_repeat_and_does_not_mutate_generic_config():
from vllm_omni.diffusion.models.minimax_h3.quality_policy import MiniMaxH3QualityPolicy
config = _quality_od_config()
plan = _resolve_quality(
MiniMaxH3QualityPolicy(config),
quality=None,
num_inference_steps=20,
extra_args={"force_refresh_step_hint": 5, "force_refresh_step_policy": "repeat"},
)
assert plan.cache_dit is not None
assert plan.cache_dit.installation_key == "minimax_h3.generic:force_refresh=5:repeat"
assert plan.cache_dit.cache_config is not config.cache_config
assert plan.cache_dit.cache_config.force_refresh_step_hint == 5
assert plan.cache_dit.cache_config.force_refresh_step_policy == "repeat"
assert config.cache_config.force_refresh_step_hint is None
assert config.cache_config.force_refresh_step_policy == "once"
restored = _resolve_quality(MiniMaxH3QualityPolicy(config), quality=None)
assert restored.cache_dit is not None
assert restored.cache_dit.installation_key == "minimax_h3.generic"
assert restored.cache_dit.cache_config is config.cache_config
@pytest.mark.parametrize(
("extra_args", "message"),
[
({"force_refresh_step_hint": True}, "positive integer"),
({"force_refresh_step_hint": 0}, "between 1"),
({"force_refresh_step_hint": 51}, "between 1"),
({"force_refresh_step_hint": 5, "force_refresh_step_policy": "always"}, "one of"),
({"force_refresh_step_policy": "repeat"}, "requires force_refresh_step_hint"),
],
)
def test_h3_force_refresh_rejects_invalid_values(extra_args, message):
from vllm_omni.diffusion.models.minimax_h3.quality_policy import MiniMaxH3QualityPolicy
with pytest.raises(OmniClientError, match=message):
_resolve_quality(
MiniMaxH3QualityPolicy(_quality_od_config()),
extra_args=extra_args,
)
@pytest.mark.parametrize(
("cache_backend", "quality"),
[
("cache_dit", "lossless"),
("none", None),
],
)
def test_h3_force_refresh_requires_active_cache_target(cache_backend, quality):
from vllm_omni.diffusion.models.minimax_h3.quality_policy import MiniMaxH3QualityPolicy
policy = MiniMaxH3QualityPolicy(_quality_od_config(cache_backend=cache_backend))
with pytest.raises(OmniClientError, match="require an active Cache-DiT"):
_resolve_quality(
policy,
quality=quality,
extra_args={"force_refresh_step_hint": 7},
)
@pytest.mark.parametrize(
("cache_backend", "quality", "expected_key"),
[
("cache_dit", None, "minimax_h3.generic"),
("cache_dit", "lossless", None),
("cache_dit", "high", "minimax_h3.high"),
("none", None, None),
("none", "lossless", None),
("none", "high", "minimax_h3.high"),
],
)
def test_model_policy_owns_request_cache_target(cache_backend, quality, expected_key):
from vllm_omni.diffusion.models.minimax_h3.quality_policy import (
MiniMaxH3QualityPolicy,
)
config = _quality_od_config(cache_backend=cache_backend)
policy = MiniMaxH3QualityPolicy(config)
plan = _resolve_quality(
policy,
quality=quality,
)
if expected_key is None:
assert plan.cache_dit is None
else:
assert plan.cache_dit is not None
assert plan.cache_dit.installation_key == expected_key
if quality is None:
assert plan.cache_dit.cache_config is config.cache_config
def test_h3_adopts_runner_installed_cache_dit_backend():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
backend = object()
pipeline = object.__new__(MiniMaxH3Pipeline)
pipeline._cache_dit_runtime = SimpleNamespace(
adopt=lambda adopted, *, installation_key: setattr(
pipeline,
"adopted",
(adopted, installation_key),
),
is_enabled=True,
)
pipeline.adopt_cache_dit_backend(backend)
assert pipeline.adopted == (backend, "minimax_h3.generic")
assert pipeline.is_cache_dit_enabled()
+3 -42
View File
@@ -178,9 +178,10 @@ class TestAsyncOutputLoopLogic:
t = threading.Thread(target=proc._async_output_loop, daemon=True)
t.start()
# Give the loop time to process, then send the shutdown sentinel.
# Give the loop time to process, then unblock and stop.
time.sleep(0.5)
proc._async_output_queue.put(None)
proc._running = False
proc._async_output_queue.put((DiffusionOutput(), "sentinel", None))
t.join(timeout=2.0)
mock_pack.assert_called()
@@ -190,43 +191,3 @@ class TestAsyncOutputLoopLogic:
for c in proc.result_mq.enqueue.call_args_list
)
assert output_ready_enqueued, "Expected OUTPUT_READY with async_output_id='abc123' in enqueue calls"
class TestWorkerProcShutdown:
def test_shutdown_stops_async_thread_and_releases_queues(self):
import threading
proc = _make_worker_proc(step_execution=False)
proc.worker = MagicMock()
proc.mq = MagicMock()
broadcast_mq = proc.mq
result_mq = proc.result_mq
thread = threading.Thread(target=proc._async_output_queue.get)
proc._async_output_thread = thread
thread.start()
proc.shutdown()
assert not thread.is_alive()
proc.worker.shutdown.assert_called_once_with()
broadcast_mq.shutdown.assert_called_once_with()
result_mq.shutdown.assert_called_once_with()
assert proc.mq is None
assert proc.result_mq is None
def test_shutdown_releases_queues_when_worker_shutdown_fails(self):
proc = _make_worker_proc(step_execution=True)
proc.worker = MagicMock()
proc.worker.shutdown.side_effect = RuntimeError("shutdown failed")
proc.mq = MagicMock()
broadcast_mq = proc.mq
result_mq = proc.result_mq
with pytest.raises(RuntimeError, match="shutdown failed"):
proc.shutdown()
broadcast_mq.shutdown.assert_called_once_with()
result_mq.shutdown.assert_called_once_with()
assert proc.mq is None
assert proc.result_mq is None
@@ -15,10 +15,7 @@ from vllm_omni.diffusion.data import (
DiffusionParallelConfig,
OmniDiffusionConfig,
)
from vllm_omni.diffusion.model_metadata import (
HUNYUAN_IMAGE3_MAX_INPUT_IMAGES,
QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES,
)
from vllm_omni.diffusion.model_metadata import QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -139,30 +136,6 @@ def test_task_type_roundtrip():
assert od.task_type == "model-defined-task"
def test_architecture_name_resolves_via_pipeline_class_fallback():
# Direct hit: the architecture name itself is the metadata table key.
qwen_od_config = OmniDiffusionConfig(
model="Qwen/Qwen-Image-Edit-2511",
model_class_name="QwenImageEditPlusPipeline",
)
qwen_od_config.update_multimodal_support()
assert qwen_od_config.supports_multimodal_inputs is True
assert qwen_od_config.max_multimodal_image_inputs == QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES
# Fallback: the HF architecture name differs from the internal pipeline class
# name, so ``get_diffusion_model_metadata`` must consult ``_DIFFUSION_MODELS``
# and resolve the metadata via the pipeline class name.
hunyuan_od_config = OmniDiffusionConfig(
model="tencent/HunyuanImage-3.0-Instruct",
model_class_name="HunyuanImage3ForCausalMM",
)
hunyuan_od_config.update_multimodal_support()
assert hunyuan_od_config.supports_multimodal_inputs is True
assert hunyuan_od_config.max_multimodal_image_inputs == HUNYUAN_IMAGE3_MAX_INPUT_IMAGES
def test_additional_config_roundtrip():
additional_config = {"torchair_graph_config": {"enabled": True}}
od = _roundtrip_diffusion_config(model="x", additional_config=additional_config)
-49
View File
@@ -331,55 +331,6 @@ class TestRequestBatchCapability:
DiffusionEngine(od_config)
fake_executor_cls.assert_not_called()
def test_engine_allows_independent_dlo_dp_requests_for_single_request_pipeline(
self,
monkeypatch: pytest.MonkeyPatch,
mocker: MockerFixture,
) -> None:
od_config = SimpleNamespace(
model_class_name="SinglePipeline",
custom_pipeline_args=None,
streaming_output=False,
max_num_seqs=2,
request_batch_max_wait_ms=0,
parallel_config=SimpleNamespace(data_parallel_size=2),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
)
fake_executor = SimpleNamespace(
execute_request=mocker.Mock(return_value="per-request"),
execute_batch=mocker.Mock(return_value="batch"),
execute_step=mocker.Mock(return_value="step"),
)
fake_executor_cls = mocker.Mock(return_value=fake_executor)
monkeypatch.setattr(
"vllm_omni.diffusion.diffusion_engine.get_diffusion_post_process_func",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"vllm_omni.diffusion.diffusion_engine.get_diffusion_pre_process_func",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"vllm_omni.diffusion.diffusion_engine.DiffusionExecutor.get_class",
lambda *args, **kwargs: fake_executor_cls,
)
monkeypatch.setattr(
diffusion_engine_module.DiffusionModelRegistry,
"_try_load_model_cls",
lambda model_class_name: _SingleRequestPipeline,
)
engine = DiffusionEngine(od_config)
assert engine.execution_mode == DiffusionExecutionMode.REQUEST_BATCH
assert engine.supports_request_batch is False
assert engine.dp_concurrent is True
assert engine.scheduler.max_num_running_reqs == 2
assert od_config.request_batch_max_wait_ms == 0
fake_executor_cls.assert_called_once_with(od_config)
@pytest.mark.parametrize("request_ids", [("req-a",), ("req-a", "req-b")])
def test_engine_enables_batch_dispatch_for_request_batch_pipeline(
self,
-173
View File
@@ -2,19 +2,11 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import contextlib
import multiprocessing as mp
import queue
import threading
import time
from multiprocessing import shared_memory
import numpy as np
import pytest
import torch
from vllm.distributed.device_communicators.shm_broadcast import MessageQueue
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
from vllm_omni.diffusion.ipc import (
_SHM_TENSOR_THRESHOLD,
DIFFUSION_RPC_RESULT_ENVELOPE,
@@ -38,129 +30,6 @@ def _cleanup_shm_handle(value: object) -> None:
_unpack_if_shm_handle(value)
def _result_queue_worker(connection, rank: int) -> None:
"""Send one nested NumPy result through a worker-owned MessageQueue."""
result_mq = MessageQueue(n_reader=1, n_local_reader=1, local_reader_ranks=[0])
try:
connection.send(result_mq.export_handle())
assert connection.recv() == "reader-ready"
frames = np.full(300_000, rank, dtype=np.float32)
audio = np.arange(300_000, dtype=np.float32) + rank
output = DiffusionOutput(
output={
"rank": rank,
"nested": {"frames": frames},
"audio": [audio],
}
)
pack_diffusion_output_shm(output)
connection.send(
[
output.output["nested"]["frames"]["name"],
output.output["audio"][0]["name"],
]
)
result_mq.enqueue(output)
assert connection.recv() == "shutdown"
finally:
result_mq.shutdown()
connection.close()
def test_per_worker_result_queues_release_nested_numpy_shm_and_processes() -> None:
"""Exercise the production per-worker queue/pump/SHM lifecycle."""
ctx = mp.get_context("spawn")
parent_connections = []
processes = []
result_mqs = []
shm_names = []
executor = None
try:
for rank in range(2):
parent_connection, child_connection = ctx.Pipe()
process = ctx.Process(
target=_result_queue_worker,
args=(child_connection, rank),
name=f"IPCResultWorker-{rank}",
)
process.start()
child_connection.close()
parent_connections.append(parent_connection)
processes.append(process)
for connection in parent_connections:
handle = connection.recv()
result_mqs.append(MessageQueue.create_from_handle(handle, 0))
connection.send("reader-ready")
shm_names.extend(connection.recv())
executor = object.__new__(MultiprocDiffusionExecutor)
executor._result_mqs = result_mqs
executor._result_mq = result_mqs[0]
executor._pump_running = False
executor._pump_stop = threading.Event()
executor._sync_result_buffer = queue.Queue()
executor._is_failed = False
executor._futures_lock = threading.RLock()
executor._rpc_futures = {}
executor._output_futures = {}
executor._completed_outputs = {}
executor._batch_split_map = {}
executor._start_result_pump()
deadline = time.monotonic() + 10.0
while executor._sync_result_buffer.qsize() < 2 and time.monotonic() < deadline:
time.sleep(0.01)
assert executor._sync_result_buffer.qsize() == 2
outputs = []
for _ in range(2):
output = executor._sync_result_buffer.get_nowait()
unpack_diffusion_output_shm(output)
outputs.append(output)
assert sorted(output.output["rank"] for output in outputs) == [0, 1]
for output in outputs:
rank = output.output["rank"]
assert output.output["nested"]["frames"][0] == rank
assert output.output["audio"][0][0] == rank
executor._pump_stop.set()
for thread in executor._result_pump_threads:
thread.join(timeout=3.0)
assert all(not thread.is_alive() for thread in executor._result_pump_threads)
finally:
if executor is not None:
executor._pump_stop.set()
for thread in getattr(executor, "_result_pump_threads", []):
thread.join(timeout=3.0)
for connection in parent_connections:
with contextlib.suppress(BrokenPipeError, EOFError, OSError):
connection.send("shutdown")
for process in processes:
process.join(timeout=10.0)
if process.is_alive():
process.terminate()
process.join(timeout=5.0)
for result_mq in result_mqs:
with contextlib.suppress(Exception):
result_mq.shutdown()
for connection in parent_connections:
connection.close()
assert all(process.exitcode == 0 for process in processes)
for name in shm_names:
try:
leaked = shared_memory.SharedMemory(name=name)
except FileNotFoundError:
continue
leaked.close()
pytest.fail(f"shared memory segment {name} still exists after unpack")
def test_diffusion_output_dict_tensors_round_trip_through_shm() -> None:
image = torch.arange(300_000, dtype=torch.float32)
video = torch.arange(300_000, dtype=torch.float32) * 2
@@ -214,29 +83,6 @@ def test_diffusion_output_list_tensors_round_trip_through_shm() -> None:
torch.testing.assert_close(output.output[1], frames[1])
def test_diffusion_output_numpy_array_round_trips_through_shm() -> None:
frames = np.arange(300_000, dtype=np.float32)
output = DiffusionOutput(output=frames)
pack_diffusion_output_shm(output)
assert output.output["__ndarray_shm__"] is True
unpack_diffusion_output_shm(output)
assert isinstance(output.output, np.ndarray)
np.testing.assert_array_equal(output.output, frames)
def test_numpy_object_array_stays_inline() -> None:
values = np.empty(200_000, dtype=object)
values[:] = "safe-to-pickle"
packed = _pack_value_if_large(values)
assert packed is values
def test_rpc_result_envelope_diffusion_output_round_trips_through_shm() -> None:
tensor = torch.arange(300_000, dtype=torch.float32)
envelope = {
@@ -262,25 +108,6 @@ def test_rpc_result_envelope_diffusion_output_round_trips_through_shm() -> None:
assert unpacked["rank_statuses"] == [{"rank": 0, "ok": True}]
def test_rpc_result_envelope_dp_tagged_output_round_trips_through_shm() -> None:
frames = np.arange(300_000, dtype=np.float32)
envelope = {
"type": DIFFUSION_RPC_RESULT_ENVELOPE,
"result": {"dp_rank": 1, "output": DiffusionOutput(output=frames)},
"rank_statuses": [{"rank": 1, "ok": True}],
}
packed = pack_diffusion_output_shm(envelope)
tagged = packed["result"]
assert tagged["dp_rank"] == 1
assert tagged["output"].output["__ndarray_shm__"] is True
unpacked = unpack_diffusion_output_shm(packed)
tagged = unpacked["result"]
assert tagged["dp_rank"] == 1
np.testing.assert_array_equal(tagged["output"].output, frames)
def test_batch_runner_output_round_trips_nested_results_through_shm() -> None:
first = torch.arange(_large_numel(torch.float32), dtype=torch.float32)
second = torch.arange(_large_numel(torch.float32), dtype=torch.float32) + 1
@@ -162,21 +162,6 @@ def _fake_platform_for_peak_memory():
)
def test_request_scoped_cache_dit_lifecycle_is_pipeline_opt_in():
events = []
plain_pipeline = object()
request_scoped_pipeline = SimpleNamespace(
adopt_cache_dit_backend=lambda backend: events.append(backend),
is_cache_dit_enabled=lambda: True,
)
backend = object()
assert not model_runner_module.adopt_request_scoped_cache_dit(plain_pipeline, backend)
assert model_runner_module.adopt_request_scoped_cache_dit(request_scoped_pipeline, backend)
assert model_runner_module.is_request_scoped_cache_dit_enabled(request_scoped_pipeline)
assert events == [backend]
def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summary: bool = True):
runner = object.__new__(DiffusionModelRunner)
runner.vllm_config = object()
@@ -434,33 +419,6 @@ def test_execute_model_emits_cache_summary_with_active_cache_dit_backend(monkeyp
assert cache_backend.refresh_calls == [(runner.pipeline, 4, True)]
@pytest.mark.core_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("cache_dit_enabled", [False, True])
def test_execute_model_cache_summary_follows_pipeline_owned_cache_dit_state(monkeypatch, cache_dit_enabled):
runner = _make_runner(cache_backend=None, cache_backend_name="cache_dit")
runner.pipeline.adopt_cache_dit_backend = lambda backend: None
runner.pipeline.is_cache_dit_enabled = lambda: cache_dit_enabled
req = _make_request()
cache_summary_calls = []
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None)
monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_reserved", lambda: 0)
monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
monkeypatch.setattr(
model_runner_module,
"cache_summary",
lambda pipeline, details: cache_summary_calls.append((pipeline, details)),
)
output = DiffusionModelRunner.execute_model(runner, req)
assert output.output == "ok"
assert cache_summary_calls == ([(runner.pipeline, True)] if cache_dit_enabled else [])
@pytest.mark.core_model
@pytest.mark.cpu
def test_execute_model_passes_single_request_batch_to_non_admission_pipeline(monkeypatch):
@@ -380,63 +380,6 @@ class TestRequestScheduler:
assert first.num_running_reqs == 1
assert first.num_waiting_reqs == 1
def test_batches_different_quality_levels_separately(self) -> None:
scheduler = RequestScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=2))
high = scheduler.add_request(
_make_step_request(
"high",
sampling_params=OmniDiffusionSamplingParams(
num_inference_steps=2,
quality="high",
),
)
)
scheduler.add_request(
_make_step_request(
"lossless",
sampling_params=OmniDiffusionSamplingParams(
num_inference_steps=2,
quality="lossless",
),
)
)
first = scheduler.schedule()
assert _new_ids(first) == [high]
assert first.num_running_reqs == 1
assert first.num_waiting_reqs == 1
def test_batches_omitted_and_explicit_lossless_separately(self) -> None:
scheduler = RequestScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=2))
omitted = scheduler.add_request(
_make_step_request(
"omitted",
sampling_params=OmniDiffusionSamplingParams(
num_inference_steps=2,
),
)
)
scheduler.add_request(
_make_step_request(
"explicit",
sampling_params=OmniDiffusionSamplingParams(
num_inference_steps=2,
quality="lossless",
),
)
)
first = scheduler.schedule()
assert _new_ids(first) == [omitted]
assert first.num_running_reqs == 1
assert first.num_waiting_reqs == 1
def test_batches_different_request_local_seed_together(self) -> None:
scheduler = RequestScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=2))
@@ -987,35 +930,6 @@ class TestStepScheduler:
assert second.num_running_reqs == 1
assert second.num_waiting_reqs == 1
def test_step_batch_rejects_different_quality_levels(self) -> None:
scheduler = StepScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=2))
high = scheduler.add_request(
_make_step_request(
"high",
sampling_params=OmniDiffusionSamplingParams(
num_inference_steps=4,
quality="high",
),
)
)
scheduler.add_request(
_make_step_request(
"lossless",
sampling_params=OmniDiffusionSamplingParams(
num_inference_steps=4,
quality="lossless",
),
)
)
first = scheduler.schedule()
assert _new_ids(first) == [high]
assert first.num_running_reqs == 1
assert first.num_waiting_reqs == 1
def test_step_batch_co_schedules_requests_sharing_lora(self) -> None:
"""Multiple requests with the same LoRA (id + scale) co-batch."""
from vllm_omni.lora.request import LoRARequest
@@ -75,32 +75,6 @@ async def test_inline_dispatch_request_success(client, mock_engine):
assert output.request_id == "req-1"
@pytest.mark.asyncio
async def test_inline_requests_clone_shared_sampling_params(client, mock_engine):
requests = []
async def _step_streaming(request):
requests.append(request)
yield [OmniRequestOutput.from_diffusion(request_id=request.request_id, images=[MagicMock()])]
mock_engine.step_streaming = _step_streaming
shared = OmniDiffusionSamplingParams()
await client.add_request_async("req-1", "First prompt", shared)
await client.add_request_async("req-2", "Second prompt", shared)
for _ in range(10):
if len(requests) == 2:
break
await asyncio.sleep(0.01)
assert len(requests) == 2
assert requests[0].sampling_params is not requests[1].sampling_params
assert requests[0].sampling_params.guidance_scale_provided is False
assert requests[1].sampling_params.guidance_scale_provided is False
assert shared.guidance_scale is None
@pytest.mark.asyncio
async def test_inline_dispatch_request_streaming_success(client, mock_engine):
"""Inline StageDiffusionClient correctly receives streaming chunk output from Diffusion Engine"""
@@ -163,20 +137,6 @@ def test_inline_shutdown(client, mock_engine):
mock_engine.close.assert_called_once()
def test_inline_shutdown_runs_after_orchestrator_premark(client, mock_engine):
# The orchestrator pre-marks clients to suppress false worker-death errors
# before StagePool invokes the actual teardown.
client._shutting_down = True
client.shutdown()
mock_engine.close.assert_called_once()
assert client._shutdown_complete
client.shutdown()
mock_engine.close.assert_called_once()
def test_inline_registers_executor_failure_callback(client, mock_engine):
mock_engine.executor.register_failure_callback.assert_called_once()
-12
View File
@@ -137,18 +137,6 @@ class TestPackDiffusionOutputShmWithD2hStream:
d2h_stream=d2h,
)
@pytest.mark.parametrize("wrapped_in_rpc_envelope", [False, True])
def test_dp_tagged_output_path_with_stream(self, mocker, wrapped_in_rpc_envelope):
mock_pack_fields = mocker.patch(
"vllm_omni.diffusion.ipc._pack_diffusion_fields",
)
d2h = mocker.MagicMock()
result = DiffusionOutput()
tagged = {"dp_rank": 1, "output": result}
value = {"type": "diffusion_rpc_result", "result": tagged} if wrapped_in_rpc_envelope else tagged
pack_diffusion_output_shm(value, d2h_stream=d2h)
mock_pack_fields.assert_called_once_with(result, d2h_stream=d2h)
def test_runner_output_path_with_stream(self, mocker):
mock_pack_fields = mocker.patch(
"vllm_omni.diffusion.ipc._pack_diffusion_fields",
@@ -276,8 +276,6 @@ class TestRequestModeDispatch:
def worker():
request = req_q.get(timeout=2)
assert "rpc_id" not in request
assert request["output_rank"] is None
assert request["collect_rank_status"] is False
wave_id = request["wave_id"]
for dp_rank in range(2):
res_q.put(
@@ -299,181 +297,6 @@ class TestRequestModeDispatch:
assert [result.error for result in results] == ["0", "1"]
@pytest.mark.parametrize(
("tp_size", "sp_size", "pp_size", "cfg_size", "expected_primary_ranks"),
[
(2, 1, 1, 1, [0, 2]),
(2, 2, 1, 1, [0, 4]),
(1, 2, 2, 1, [0, 4]),
(1, 1, 2, 2, [0, 4]),
],
)
def test_step_execution_reads_each_dp_primary_result_queue(
self,
tp_size,
sp_size,
pp_size,
cfg_size,
expected_primary_ranks,
):
dp_size = 2
num_gpus = dp_size * tp_size * sp_size * pp_size * cfg_size
executor, _, _ = _make_executor(num_gpus=num_gpus)
executor.od_config = SimpleNamespace(
step_execution=True,
parallel_config=SimpleNamespace(
data_parallel_size=dp_size,
tensor_parallel_size=tp_size,
sequence_parallel_size=sp_size,
pipeline_parallel_size=pp_size,
cfg_parallel_size=cfg_size,
),
)
request: dict = {}
executor._broadcast_mq = SimpleNamespace(enqueue=lambda value: request.update(value))
def result_queue(global_rank):
result_mq = MagicMock()
result_mq.dequeue.side_effect = lambda timeout=None: {
"dp_rank": global_rank,
"output": _tagged_output(str(global_rank)),
"wave_id": request["wave_id"],
}
return result_mq
# Every queue returns a valid, rank-tagged result. If the executor
# selects a non-primary queue, the returned rank exposes the mistake.
executor._result_mqs = [result_queue(rank) for rank in range(num_gpus)]
executor._result_mq = executor._result_mqs[0]
results = executor.collective_rpc(
"execute_model",
unique_reply_rank=None,
exec_all_ranks=True,
)
assert [result.error for result in results] == [str(rank) for rank in expected_primary_ranks]
for rank, result_mq in enumerate(executor._result_mqs):
assert result_mq.dequeue.call_count == (1 if rank in expected_primary_ranks else 0)
@pytest.mark.parametrize("empty_prompt", ["", {"prompt": ""}])
def test_dlo_dp_rejects_empty_prompt_before_worker_dispatch(self, empty_prompt):
executor, _, _ = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
)
executor.collective_rpc = Mock()
scheduler_output = _make_sched_output("invalid", "valid")
scheduler_output.scheduled_new_reqs[0].req.prompt = empty_prompt
with pytest.raises(ValueError, match="non-empty prompt"):
executor.execute_request(scheduler_output)
executor.collective_rpc.assert_not_called()
def test_dlo_dp_valid_wave_still_runs_after_rejected_wave(self):
executor, _, _ = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
)
executor.collective_rpc = Mock(return_value=[_tagged_output("A"), _tagged_output("B")])
invalid_wave = _make_sched_output("invalid", "valid")
invalid_wave.scheduled_new_reqs[0].req.prompt = ""
with pytest.raises(ValueError, match="non-empty prompt"):
executor.execute_request(invalid_wave)
result = executor.execute_request(_make_sched_output("A", "B"))
assert [output.result.error for output in result.runner_outputs] == ["A", "B"]
executor.collective_rpc.assert_called_once()
def test_dlo_dp_allows_shared_default_denoise_steps(self):
executor, _, _ = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
)
executor.collective_rpc = Mock(return_value=[_tagged_output("A"), _tagged_output("B")])
scheduler_output = _make_sched_output("A", "B")
for new_req in scheduler_output.scheduled_new_reqs:
new_req.req.sampling_params.num_inference_steps = None
result = executor.execute_request(scheduler_output)
assert [output.result.error for output in result.runner_outputs] == ["A", "B"]
executor.collective_rpc.assert_called_once()
@pytest.mark.parametrize(
("field", "value"),
[
("num_inference_steps", 2),
("guidance_scale", 7.5),
("width", 1024),
],
)
def test_dlo_dp_rejects_incompatible_collective_wave(self, field, value):
executor, _, _ = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
)
executor.collective_rpc = Mock()
scheduler_output = _make_sched_output("A", "B")
setattr(scheduler_output.scheduled_new_reqs[1].req.sampling_params, field, value)
with pytest.raises(ValueError, match="compatible shape, CFG"):
executor.execute_request(scheduler_output)
executor.collective_rpc.assert_not_called()
def test_dlo_dp_partial_reply_times_out_and_fails_closed(self, monkeypatch):
from vllm_omni.diffusion.executor import multiproc_executor as executor_module
executor, req_q, res_q = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
)
executor._sync_result_buffer = res_q
executor._fail_closed_on_dp_wave_timeout = Mock()
monkeypatch.setattr(executor_module, "_DLO_DP_WAVE_TIMEOUT_S", 0.05)
def reply_from_only_one_dp_rank():
request = req_q.get(timeout=2.0)
res_q.put(
{
"dp_rank": 0,
"output": _tagged_output("rank-0"),
"wave_id": request["wave_id"],
}
)
worker = threading.Thread(target=reply_from_only_one_dp_rank, daemon=True)
worker.start()
started = time.monotonic()
result = executor.execute_request(_make_sched_output("A", "B"))
elapsed = time.monotonic() - started
worker.join(timeout=2.0)
assert elapsed < 1.0
assert all("timed out" in output.result.error for output in result.runner_outputs)
executor._fail_closed_on_dp_wave_timeout.assert_called_once()
assert isinstance(executor._fail_closed_on_dp_wave_timeout.call_args.args[0], TimeoutError)
# ───────────────── concurrent collective RPC ─────────────────
@@ -1125,42 +948,6 @@ class TestStageDiffusionClientErrorPropagation:
assert rpc_id not in client._pending_rpcs
class TestExecutorShutdownCleaner:
def test_worker_joins_share_one_global_deadline(self, monkeypatch):
from vllm_omni.diffusion.executor import multiproc_executor as executor_module
class FakeProcess:
def __init__(self, name):
self.name = name
self.alive = True
self.terminated = False
self.join_timeouts = []
def is_alive(self):
return self.alive
def join(self, timeout):
self.join_timeouts.append(timeout)
if self.terminated:
self.alive = False
def terminate(self):
self.terminated = True
monotonic = Mock(side_effect=[100.0, 100.0, 110.0, 120.0, 120.0, 124.0])
monkeypatch.setattr(executor_module, "time", SimpleNamespace(monotonic=monotonic))
first = FakeProcess("worker-0")
second = FakeProcess("worker-1")
cleaner = executor_module._ExecutorShutdownCleaner(processes=[first, second])
cleaner()
assert first.join_timeouts == [15.0, 5.0]
assert second.join_timeouts == [5.0, 1.0]
assert first.terminated and second.terminated
assert not first.is_alive() and not second.is_alive()
# ───────── monitor thread & death sentinel integration tests ─────────
-50
View File
@@ -35,7 +35,6 @@ def _make_executor(step_execution=False):
executor._pump_stop = threading.Event()
executor._sync_result_buffer = queue.Queue()
executor._result_mq = MagicMock()
executor._result_mqs = []
executor._broadcast_mq = MagicMock()
executor._closed = False
executor._is_failed = False
@@ -158,41 +157,6 @@ class TestResultPumpDispatch:
retrieved = executor._sync_result_buffer.get_nowait()
assert isinstance(retrieved, DiffusionOutput)
def test_start_result_pump_reads_every_worker_queue(self):
executor = _make_executor()
messages = [DiffusionOutput(output="rank0"), DiffusionOutput(output="rank1")]
result_mqs = [MagicMock(), MagicMock()]
def make_dequeue(message):
emitted = False
def dequeue(timeout=None):
nonlocal emitted
if not emitted:
emitted = True
return message
executor._pump_stop.wait(0.01)
raise TimeoutError
return dequeue
for result_mq, message in zip(result_mqs, messages, strict=True):
result_mq.dequeue = make_dequeue(message)
executor._result_mqs = result_mqs
executor._result_mq = result_mqs[0]
executor._start_result_pump()
deadline = time.monotonic() + 2.0
while executor._sync_result_buffer.qsize() < 2 and time.monotonic() < deadline:
time.sleep(0.01)
executor._pump_stop.set()
for thread in executor._result_pump_threads:
thread.join(timeout=2.0)
assert len(executor._result_pump_threads) == 2
received = [executor._sync_result_buffer.get_nowait().output for _ in range(2)]
assert sorted(received) == ["rank0", "rank1"]
def test_compute_done_routes_to_rpc_future(self):
executor = _make_executor()
rpc_id = "42"
@@ -269,20 +233,6 @@ class TestResultPumpDispatch:
class TestShutdownCleansUpFutures:
"""Test that shutdown cancels pending futures."""
def test_shutdown_joins_result_pump_threads(self):
executor = _make_executor()
pump = threading.Thread(
target=executor._pump_stop.wait,
name="test-result-pump",
)
executor._result_pump_threads = [pump]
pump.start()
executor.shutdown()
assert not pump.is_alive()
assert executor._result_pump_threads == []
def test_shutdown_sets_exception_on_pending_futures(self):
executor = _make_executor()
+2 -10
View File
@@ -29,7 +29,7 @@ HEIGHT = 512
NUM_INFERENCE_STEPS = 20
TRUE_CFG_SCALE = 4.0
SEED = 42
SSIM_THRESHOLD = 0.97
SSIM_THRESHOLD = 0.94
PSNR_THRESHOLD = 30.0
MODEL_2512_ID = "Qwen/Qwen-Image-2512"
@@ -69,15 +69,7 @@ def _local_files_only(model: str) -> bool:
def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> Image.Image:
server_args = [
"--num-gpus",
"1",
"--stage-init-timeout",
"300",
"--init-timeout",
"900",
"--fa-deterministic",
]
server_args = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
with OmniServer(model, server_args, use_omni=True) as omni_server:
response = requests.post(
f"http://{omni_server.host}:{omni_server.port}/v1/images/generations",
@@ -136,36 +136,3 @@ def test_flux_2_dev(
}
openai_client.send_diffusion_request(request_config)
@pytest.mark.parametrize(
"omni_server",
_get_flux_2_dev_feature_cases(MODEL),
indirect=True,
)
def test_flux_2_dev_batched_chat_completions(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
):
"""Validate batched chat completions for diffusion models."""
messages = [
[{"role": "user", "content": PROMPT}],
[{"role": "user", "content": "A sunset over the ocean."}],
]
responses = openai_client.send_batched_chat_completions_http_request(
{
"json": {
"model": omni_server.model,
"messages": messages,
"num_inference_steps": 2,
"height": 512,
"width": 512,
"seed": 42,
},
},
)
assert responses and len(responses) == 1
resp = responses[0]
assert resp.success
choices = resp.json_body["choices"]
assert len(choices) == len(messages)
@@ -250,101 +250,3 @@ def test_completions_rejected_for_thinker_talker(omni_server, openai_client) ->
err_code=400,
)
assert not responses[0].success
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_batched_completions_text(omni_server, openai_client) -> None:
"""Ensure that we can make a batch chat completions request (text only)."""
responses = openai_client.send_batched_chat_completions_http_request(
{
"json": {
"model": omni_server.model,
"messages": [
[{"role": "user", "content": "What color is the sky? Answer in one word."}],
[{"role": "user", "content": "What is 2+2? Answer in one word."}],
],
"modalities": ["text"],
"max_tokens": 20,
},
},
)
assert responses
resp = responses[0]
assert resp.success
body = resp.json_body
choices = body["choices"]
assert len(choices) == 2
for choice in choices:
assert choice["message"]["content"]
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_batched_completions_audio_out(omni_server, openai_client) -> None:
"""Ensure that we can make a batch chat completions request (audio + text)."""
messages = [
[{"role": "user", "content": "Say hello."}],
[{"role": "user", "content": "Say goodbye."}],
]
num_messages = len(messages)
responses = openai_client.send_batched_chat_completions_http_request(
{
"json": {
"model": omni_server.model,
"messages": messages,
"modalities": ["text", "audio"],
"max_tokens": 200,
},
},
)
assert responses and len(responses) == 1
resp = responses[0]
assert resp.success
choices = resp.json_body["choices"]
assert len(choices) == num_messages
# Every message in the batch should contain both the audio and text content
for choice in choices:
assert choice["message"]["audio"]["data"]
assert choice["message"]["content"]
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize(
"sad_opts",
[
{"stream": "not-a-bool"}, # Fails validation
{"n": 2}, # vLLM Batched endpoints doesn't support n > 1
],
)
def test_batched_completions_with_bad_values(omni_server, openai_client, sad_opts) -> None:
"""Ensure that a bad values are correctly handled as 400s."""
messages = [
[{"role": "user", "content": "Say hello."}],
[{"role": "user", "content": "Say goodbye."}],
]
responses = openai_client.send_batched_chat_completions_http_request(
{
"json": {
"model": omni_server.model,
"messages": messages,
**sad_opts,
},
},
)
assert responses and len(responses) == 1
resp = responses[0]
assert not responses[0].success
assert resp.status_code == 400
@@ -1,150 +0,0 @@
import asyncio
from unittest.mock import AsyncMock
import pytest
from openai.types.chat import ChatCompletionUserMessageParam
from openai.types.chat.chat_completion_audio import ChatCompletionAudio
from starlette.requests import Request
from vllm.entrypoints.chat_utils import ChatCompletionMessageParam
from vllm.entrypoints.openai.chat_completion.protocol import (
BatchChatCompletionRequest,
ChatCompletionResponse,
ChatCompletionResponseChoice,
ChatMessage,
)
from vllm.entrypoints.openai.engine.protocol import ErrorResponse, UsageInfo
from vllm_omni.entrypoints.openai.batch_serving import OmniOpenAIServingChatBatch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
MSG_BATCH: list[list[ChatCompletionMessageParam]] = [
[ChatCompletionUserMessageParam(role="user", content="What color is the sky?")],
[ChatCompletionUserMessageParam(role="user", content="What is 2+2?")],
]
collapse = OmniOpenAIServingChatBatch._maybe_collapse_choices
# Helpers for testing creating a __new__ serving instance and ensuring IDs are correct
def _make_raw_request(headers: list[tuple[bytes, bytes]]):
scope = {
"type": "http",
"method": "POST",
"path": "/v1/chat/completions/batch",
"headers": headers,
}
return Request(scope)
def _make_completion(text):
return ChatCompletionResponse(
model="test-model",
choices=[
ChatCompletionResponseChoice(
index=0,
message=ChatMessage(role="assistant", content=text),
)
],
usage=UsageInfo(prompt_tokens=0, completion_tokens=0, total_tokens=0),
)
def _make_handler(responses):
handler = OmniOpenAIServingChatBatch.__new__(OmniOpenAIServingChatBatch)
handler.create_chat_completion = AsyncMock(side_effect=responses)
return handler
# Helpers for creating choices of different modality types
def _text_choice(text="hello"):
return ChatCompletionResponseChoice(
index=0,
message=ChatMessage(role="assistant", content=text),
)
def _audio_choice():
audio = ChatCompletionAudio(id="a1", data="base64audio", expires_at=0, transcript="")
return ChatCompletionResponseChoice(
index=0,
message=ChatMessage(role="assistant", content=None, audio=audio),
)
def _image_choice():
content = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}]
msg = ChatMessage.model_construct(role="assistant")
object.__setattr__(msg, "content", content)
return ChatCompletionResponseChoice.model_construct(
index=0,
message=msg,
)
def _get_subrequest_ids(handler):
return [call.args[0].request_id for call in handler.create_chat_completion.call_args_list]
@pytest.mark.parametrize("has_header", [True, False])
def test_subrequest_ids_are_unique(has_header: bool):
"""Ensure that a submitted batch request creates unique subrequest IDs"""
header = None if not has_header else b"user-123"
headers = [] if not header else [(b"x-request-id", header)]
handler = _make_handler([_make_completion("hello"), _make_completion("world")])
request = BatchChatCompletionRequest(
model="test-model",
messages=MSG_BATCH,
seed=42,
)
result = asyncio.run(handler.create_batch_chat_completion(request, _make_raw_request(headers)))
assert not isinstance(result, ErrorResponse)
assert result.id.startswith("chatcmpl-batch")
# Ensure request IDs are unique and that if we had a header, it's in all reqs
req_ids = _get_subrequest_ids(handler)
assert len(set(req_ids)) == len(req_ids) == 2
if header is not None:
assert all(header.decode("utf-8") in req_id for req_id in req_ids)
### Tests for ensuring that the (currently batch specific) choice collapsing is correct
def test_single_text_passthrough():
result = collapse([_text_choice()])
assert result.message.content == "hello"
def test_single_image_passthrough():
result = collapse([_image_choice()])
assert isinstance(result.message.content, list)
assert result.message.content[0]["type"] == "image_url"
def test_text_plus_audio():
result = collapse([_text_choice(), _audio_choice()])
assert result.message.content == "hello"
assert result.message.audio.data == "base64audio"
def test_audio_plus_text_order_independent():
result = collapse([_audio_choice(), _text_choice()])
assert result.message.content == "hello"
assert result.message.audio.data == "base64audio"
def test_image_plus_audio():
result = collapse([_image_choice(), _audio_choice()])
assert isinstance(result.message.content, list)
assert result.message.content[0]["type"] == "image_url"
assert result.message.audio.data == "base64audio"
def test_two_content_choices_raises():
with pytest.raises(ValueError, match="Multiple content choices cannot be set"):
collapse([_text_choice(), _text_choice()])
def test_three_choices_raises():
with pytest.raises(ValueError, match="got 3"):
collapse([_text_choice(), _audio_choice(), _text_choice()])
@@ -95,7 +95,6 @@ def test_serving_boundary_normalizes_declared_root_and_nested_extras(serving_cha
messages=[],
modalities=["image"],
num_inference_steps=7,
quality="high",
size="768x512",
negative_prompt="avoid blur",
lora={"name": "adapter"},
@@ -111,7 +110,6 @@ def test_serving_boundary_normalizes_declared_root_and_nested_extras(serving_cha
}
assert diffusion_request_args["cfg_text_scale"] == 7.0
assert diffusion_request_args["num_inference_steps"] == 7
assert diffusion_request_args["quality"] == "high"
assert diffusion_request_args["size"] == "768x512"
assert diffusion_request_args["negative_prompt"] == "avoid blur"
assert diffusion_request_args["lora"] == {"name": "adapter"}
@@ -135,19 +133,6 @@ def test_unknown_root_extra_does_not_claim_canonical_extra(serving_chat):
assert "pipeline_option" not in diffusion_request_args
def test_serving_boundary_rejects_invalid_quality(serving_chat):
serving_chat._diffusion_extra_body_params = frozenset()
request = ChatCompletionRequest(
model="test",
messages=[],
modalities=["image"],
quality="medium",
)
with pytest.raises(ValueError, match="quality must be one of"):
serving_chat._normalize_diffusion_request_args(request)
def test_unregistered_cfg_scale_aliases_common_true_cfg_scale(serving_chat):
serving_chat._diffusion_extra_body_params = frozenset()
request = ChatCompletionRequest(
@@ -241,7 +226,6 @@ def test_pure_consumer_preserves_defaults_and_separate_cfg_owners(serving_chat,
negative_prompt="avoid blur",
cfg_scale=7.0,
true_cfg_scale=2.0,
quality="high",
extra_body={"extra_args": {"solver": "ddim"}},
)
@@ -255,7 +239,6 @@ def test_pure_consumer_preserves_defaults_and_separate_cfg_owners(serving_chat,
}
assert sampling_params.true_cfg_scale == 2.0
assert sampling_params.num_inference_steps == 7
assert sampling_params.quality == "high"
assert (sampling_params.height, sampling_params.width) == (512, 768)
assert captured["prompt"]["negative_prompt"] == "avoid blur"
assert response.error.message == "No output generated from AsyncOmni"
@@ -327,7 +310,6 @@ def test_mixed_consumer_keeps_root_common_args_with_nested_extras(serving_chat,
assert sampling_params_list[0].extra_args == {"target_h": 512, "target_w": 768}
diffusion_params = sampling_params_list[1]
assert diffusion_params.num_inference_steps == 7
assert diffusion_params.quality is None
assert (diffusion_params.height, diffusion_params.width) == (512, 768)
assert diffusion_params.extra_args == {"solver": "euler"}
assert captured["prompt"]["negative_prompt"] == "avoid blur"
@@ -17,7 +17,6 @@ from pytest_mock import MockerFixture
from starlette.testclient import TestClient
from tests.helpers.media import generate_synthetic_image
from vllm_omni.entrypoints.openai.protocol.videos import VideoGenerationRequest
from vllm_omni.entrypoints.openai.serving_video_output_stream import OmniStreamingVideoOutputHandler
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -104,55 +103,6 @@ def _build_test_app(
class TestStreamingVideoOutputWebSocket:
"""WebSocket protocol tests for streaming generated video output."""
def test_sampling_params_quality_reaches_h3_pipeline_boundary(
self,
mocker: MockerFixture,
) -> None:
async def mock_generate(*_args, **_kwargs):
if False:
yield None
_app, handler, _engine_client = _build_test_app(
mocker=mocker,
streaming_chunks=[],
mock_generate=mock_generate,
)
request_data = handler._coerce_request_data(
{
"type": "session.start",
"prompt": "H3 streaming quality",
"sampling_params": {"quality": "high"},
}
)
request = VideoGenerationRequest(**request_data)
_prompt, sampling_params, _video_params = asyncio.run(handler._build_prompt_and_sampling_params(request))
assert sampling_params.quality == "high"
def test_omitted_quality_preserves_engine_default(self, mocker: MockerFixture) -> None:
async def mock_generate(*_args, **_kwargs):
if False:
yield None
_app, handler, engine_client = _build_test_app(
mocker=mocker,
streaming_chunks=[],
mock_generate=mock_generate,
)
engine_client.default_sampling_params_list = [OmniDiffusionSamplingParams(quality="high")]
request_data = handler._coerce_request_data(
{
"type": "session.start",
"prompt": "H3 streaming default quality",
}
)
request = VideoGenerationRequest(**request_data)
_prompt, sampling_params, _video_params = asyncio.run(handler._build_prompt_and_sampling_params(request))
assert sampling_params.quality == "high"
def test_streaming_session_emits_video_start_binary_chunks_and_done(self, mocker: MockerFixture):
"""A full session delivers video.start, binary chunks, then session.done."""
@@ -868,7 +868,6 @@ def test_default_sampling_params_apply_to_video_requests(test_client, mocker: Mo
OmniDiffusionSamplingParams(
num_inference_steps=4,
guidance_scale=7.5,
quality="high",
generator_device="cpu",
enable_frame_interpolation=True,
frame_interpolation_exp=2,
@@ -891,7 +890,6 @@ def test_default_sampling_params_apply_to_video_requests(test_client, mocker: Mo
captured = engine.captured_sampling_params_list[0]
assert captured.num_inference_steps == 4
assert captured.guidance_scale == 7.5
assert captured.quality == "high"
assert captured.generator_device == "cpu"
assert captured.enable_frame_interpolation is True
assert captured.frame_interpolation_exp == 2
@@ -1447,7 +1445,6 @@ def test_invalid_uploaded_input_reference_returns_400(test_client):
def test_video_request_validation():
req = VideoGenerationRequest(prompt="test")
assert req.prompt == "test"
assert req.quality is None
assert req.generate_sound is False
assert req.sound_duration is None
assert VideoGenerationRequest(prompt="test", generate_sound=True, sound_duration=1.5).generate_sound is True
@@ -1467,8 +1464,6 @@ def test_video_request_validation():
VideoGenerationRequest(prompt="test", frame_interpolation_scale=0)
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", sound_duration=0)
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", quality="medium")
def test_list_videos_supports_order_after_and_limit(test_client, mocker: MockerFixture):
@@ -1980,7 +1975,6 @@ def test_sync_sampling_params_pass_through(test_client, mocker: MockerFixture):
"num_inference_steps": "30",
"guidance_scale": "6.5",
"seed": "42",
"quality": "high",
},
)
assert response.status_code == 200
@@ -1989,7 +1983,6 @@ def test_sync_sampling_params_pass_through(test_client, mocker: MockerFixture):
assert captured.num_inference_steps == 30
assert captured.guidance_scale == 6.5
assert captured.seed == 42
assert captured.quality == "high"
def test_sync_frame_interpolation_params_pass_to_sampling_params(test_client, mocker: MockerFixture):
@@ -2025,7 +2018,6 @@ def test_sync_default_sampling_params_apply_to_video_requests(test_client, mocke
OmniDiffusionSamplingParams(
num_inference_steps=4,
guidance_scale=7.5,
quality="high",
enable_frame_interpolation=True,
frame_interpolation_exp=2,
frame_interpolation_scale=0.5,
@@ -2046,7 +2038,6 @@ def test_sync_default_sampling_params_apply_to_video_requests(test_client, mocke
captured = engine.captured_sampling_params_list[0]
assert captured.num_inference_steps == 4
assert captured.guidance_scale == 7.5
assert captured.quality == "high"
assert captured.enable_frame_interpolation is True
assert captured.frame_interpolation_exp == 2
assert captured.frame_interpolation_scale == 0.5
-22
View File
@@ -1252,28 +1252,6 @@ class OpenAIClientHandler:
)
return [resp]
def send_batched_chat_completions_http_request(
self,
request_config: dict[str, Any],
*,
err_code: int | tuple[int, ...] | list[int] | None = None,
err_message: str | tuple[str, ...] | list[str] | None = None,
) -> list[HttpResponse]:
"""Post to batched chat completions."""
cfg = _merge_http_expectation_kwargs(
request_config,
err_code=err_code,
err_message=err_message,
)
r = self._post_json_endpoint("/v1/chat/completions/batch", cfg, default_timeout=120.0)
resp = self._http_response_from_requests(r)
assert_http_error(
resp,
err_code=cfg.get("err_code"),
err_message=cfg.get("err_message"),
)
return [resp]
def send_completions_http_request(
self,
request_config: dict[str, Any],
@@ -25,23 +25,6 @@ def test_from_params_returns_existing_omni_params() -> None:
assert converted is params
@pytest.mark.parametrize("quality", ["lossless", "high"])
def test_quality_accepts_supported_request_levels(quality: str) -> None:
params = OmniDiffusionSamplingParams(quality=quality)
assert params.quality == quality
def test_quality_defaults_to_model_owned_policy() -> None:
params = OmniDiffusionSamplingParams()
assert params.quality is None
def test_quality_rejects_unsupported_request_level() -> None:
with pytest.raises(ValueError, match="quality must be one of"):
OmniDiffusionSamplingParams(quality="medium")
def test_from_params_converts_sampling_params_seed_and_known_extra_args() -> None:
params = _sampling_params(
seed=1234,
@@ -50,7 +33,6 @@ def test_from_params_converts_sampling_params_seed_and_known_extra_args() -> Non
"height": 1024,
"width": 768,
"guidance_scale": 7.5,
"quality": "high",
},
)
@@ -61,7 +43,6 @@ def test_from_params_converts_sampling_params_seed_and_known_extra_args() -> Non
assert converted.height == 1024
assert converted.width == 768
assert converted.guidance_scale == 7.5
assert converted.quality == "high"
assert converted.extra_args == {}
+16 -1
View File
@@ -21,6 +21,7 @@ class RouteTarget(NamedTuple):
class OmniServingCapability(Enum):
"""Serving capabilities that pipelines can shut down."""
CHAT_COMPLETIONS_BATCH = RouteTarget("/v1/chat/completions/batch", frozenset({"POST"}))
COMPLETIONS = RouteTarget("/v1/completions", frozenset({"POST"}))
@property
@@ -38,6 +39,16 @@ class EndpointRestriction:
reason: str
# Routes that are not supported for any model, but are supported in vLLM.
# This is only temporary to avoid 500s for batched chat completions.
UNSUPPORTED_ROUTES: tuple[EndpointRestriction, ...] = (
EndpointRestriction(
OmniServingCapability.CHAT_COMPLETIONS_BATCH,
"Batched chat completions are not yet supported by vLLM Omni.",
),
)
def build_rejection_handler(reason: str):
"""Build a rejection handler for a given endpoint for the provided reason."""
@@ -60,7 +71,11 @@ def shutdown_unsupported_routes(
"""
from vllm_omni.entrypoints.openai.api_server import _remove_route_from_app
for end_restrict in endpoint_restrictions:
# Generally these should not overlap since there is no point. If they do,
# we use the reason message in UNSUPPORTED_ROUTES, for consistent error messages.
restricted_endpoints = (*endpoint_restrictions, *UNSUPPORTED_ROUTES)
for end_restrict in restricted_endpoints:
capability = end_restrict.capability
# Remove the route from the app
_remove_route_from_app(app, capability.path, capability.methods)
-1
View File
@@ -568,7 +568,6 @@ class _DiffusionConfigProjection:
pin_cpu_memory: bool = True
diffusion_compile_granularity: Literal["regional", "full"] = "regional"
diffusion_compile_dynamic: bool = Field(default=True, strict=True)
fa_deterministic: bool = False
vae_use_slicing: bool = False
vae_use_tiling: bool = False
mask_strategy_file_path: str | None = None
-1
View File
@@ -394,7 +394,6 @@ class StageDeployConfig:
# Diffusion execution, cache, and VAE behavior.
diffusion_compile_granularity: str | None = None
diffusion_compile_dynamic: bool | None = None
fa_deterministic: bool | None = None
cache_backend: str | None = None
cache_config: dict[str, Any] | None = None
enable_cache_dit_summary: bool | None = None
@@ -15,7 +15,6 @@ from vllm_omni.diffusion.attention.backends.sdpa import _maybe_reshape_attn_mask
from vllm_omni.diffusion.attention.backends.utils.piecewise_attn import (
piecewise_attn,
)
from vllm_omni.diffusion.config import get_current_diffusion_config_or_none
logger = init_logger(__name__)
@@ -72,20 +71,9 @@ class FlashAttentionImpl(AttentionImpl):
self.causal = causal
self.softmax_scale = softmax_scale
self.qkv_layout = qkv_layout
cfg = get_current_diffusion_config_or_none()
self.fa_deterministic = bool(getattr(cfg, "fa_deterministic", False)) if cfg is not None else False
if backend_kwargs:
logger.warning("FlashAttentionImpl ignoring backend_kwargs: %s", list(backend_kwargs.keys()))
def _warn_fa_deterministic_non_dense(self, path: str) -> None:
if not self.fa_deterministic:
return
logger.warning_once(
"fa_deterministic=True is ignored on the %s FlashAttention path; "
"only the dense flash_attn_func path passes deterministic=True.",
path,
)
@staticmethod
def _unwrap_flash_output(out: torch.Tensor | tuple[torch.Tensor, ...]) -> torch.Tensor:
# FA3 may return (out, lse), FA2 returns out
@@ -262,7 +250,6 @@ class FlashAttentionImpl(AttentionImpl):
# Try piecewise attention
if full_attn_spans is not None:
self._warn_fa_deterministic_non_dense("piecewise")
logger.debug("Using piecewise Flash Attention for mixed causal/full mask")
if flash_attn_func is not None:
attn_func = partial(
@@ -293,7 +280,6 @@ class FlashAttentionImpl(AttentionImpl):
if len(present_packed_keys) != len(packed_keys):
missing = sorted(set(packed_keys) - set(present_packed_keys))
raise ValueError(f"Incomplete packed FlashAttention metadata; missing {missing}")
self._warn_fa_deterministic_non_dense("packed-varlen")
return self._forward_varlen_packed(
query,
key,
@@ -305,7 +291,6 @@ class FlashAttentionImpl(AttentionImpl):
)
if attention_mask is not None and torch.any(~attention_mask):
self._warn_fa_deterministic_non_dense("masked-varlen")
return self._forward_varlen_masked(
query,
key,
@@ -314,16 +299,15 @@ class FlashAttentionImpl(AttentionImpl):
)
if flash_attn_func is not None:
fa_kwargs = {
"causal": self.causal,
"softmax_scale": self.softmax_scale,
}
if self.fa_deterministic:
fa_kwargs["deterministic"] = True
out = flash_attn_func(query, key, value, **fa_kwargs)
out = flash_attn_func(
query,
key,
value,
causal=self.causal,
softmax_scale=self.softmax_scale,
)
return self._unwrap_flash_output(out)
self._warn_fa_deterministic_non_dense("dense-varlen-fallback")
return self._forward_varlen_dense(
query,
key,
@@ -1,10 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from dataclasses import dataclass
import torch
from packaging.version import InvalidVersion, Version
from vllm.logger import init_logger
from vllm_omni.diffusion.attention.backends.abstract import (
@@ -16,8 +13,7 @@ from vllm_omni.diffusion.attention.backends.abstract import (
logger = init_logger(__name__)
try:
import flashinfer
from flashinfer.prefill import BatchPrefillWithRaggedKVCacheWrapper
from flashinfer.prefill import single_prefill_with_kv_cache
HAS_FLASHINFER = True
except Exception as e:
@@ -33,8 +29,10 @@ class FlashInferAttentionBackend(AttentionBackend):
@classmethod
def supports_attention_mask(cls) -> bool:
# Non-CuTe batch-prefill backends accept boolean custom masks. CuTe-DSL
# falls back to SDPA for nontrivial masks.
# FlashInfer single_prefill_with_kv_cache accepts a ``custom_mask`` kwarg
# (2D boolean, ``True`` = keep) for non-causal attention. See reviewer
# comment on #3079 and the flashinfer docs:
# https://docs.flashinfer.ai/generated/flashinfer.prefill.single_prefill_with_kv_cache.html
return True
@staticmethod
@@ -54,27 +52,6 @@ class FlashInferAttentionBackend(AttentionBackend):
class FlashInferAttentionImpl(AttentionImpl):
_QK_DTYPES = {torch.float16, torch.bfloat16}
_VO_DTYPES = {torch.float16, torch.bfloat16, torch.float8_e4m3fn}
@dataclass(frozen=True)
class _WrapperPlanKey:
batch_size: int
qo_len: int
kv_len: int
num_q_heads: int
num_kv_heads: int
head_dim_qk: int
head_dim_k: int
head_dim_vo: int
q_dtype: torch.dtype
k_dtype: torch.dtype
v_dtype: torch.dtype
o_dtype: torch.dtype
causal: bool
softmax_scale: float
has_custom_mask: bool
def __init__(
self,
num_heads: int,
@@ -83,92 +60,10 @@ class FlashInferAttentionImpl(AttentionImpl):
causal: bool = False,
num_kv_heads: int | None = None,
prefix: str = "",
backend_kwargs: dict | None = None,
**extra_impl_args,
) -> None:
self.causal = causal
self.softmax_scale = softmax_scale
self.device = torch.device("cuda", torch.accelerator.current_device_index())
backend_kwargs = backend_kwargs or {}
quant = backend_kwargs.get("quant") or {}
self.dtype_qk = self._check_dtype(quant.get("dtype_qk"), "dtype_qk", self._QK_DTYPES)
self.dtype_vo = self._check_dtype(quant.get("dtype_vo"), "dtype_vo", self._VO_DTYPES)
requested_backend = quant.get("flashinfer_backend", "auto")
if not HAS_FLASHINFER:
raise ImportError("FLASHINFER_ATTN backend requires flashinfer")
self._check_flashinfer_version()
self.flashinfer_backend = self._select_backend(requested_backend, self.device)
workspace_size = 0 if self.flashinfer_backend == "cute-dsl" else 128 * 1024 * 1024
self._workspace = torch.empty(
workspace_size,
device=self.device,
dtype=torch.uint8,
)
self._wrapper = BatchPrefillWithRaggedKVCacheWrapper(
self._workspace,
kv_layout="NHD",
backend=self.flashinfer_backend,
)
self._qo_indptr: torch.Tensor | None = None
self._kv_indptr: torch.Tensor | None = None
self._plan_key: FlashInferAttentionImpl._WrapperPlanKey | None = None
if self.dtype_qk is not None or self.dtype_vo is not None:
logger.info_once(
"FLASHINFER_ATTN dtype override: Q/K=%s, V=%s.",
self.dtype_qk,
self.dtype_vo,
)
logger.info_once(
"FLASHINFER_ATTN initialized backend=%s on %s.",
self.flashinfer_backend,
self.device,
)
def _check_flashinfer_version(self) -> None:
if self.dtype_qk == self.dtype_vo:
return
try:
flashinfer_version = Version(flashinfer.__version__)
except (AttributeError, InvalidVersion):
return
if flashinfer_version <= Version("0.6.15"):
raise RuntimeError(
f"FlashInfer {flashinfer_version} is too old for reliable mixed "
f"QK/V dtype attention (Q/K={self.dtype_qk}, V={self.dtype_vo}); "
"install flashinfer >= 0.6.16rc1."
)
@staticmethod
def _select_backend(requested_backend: str, device: torch.device) -> str:
if requested_backend != "auto":
return requested_backend
major, _minor = torch.cuda.get_device_capability(device)
if major >= 10:
return "cute-dsl"
if major >= 9:
return "fa3"
return "fa2"
@classmethod
def _check_dtype(
cls,
dtype: torch.dtype | str | None,
option_name: str,
allowed: set[torch.dtype],
) -> torch.dtype | None:
if dtype is None:
return None
if isinstance(dtype, str):
dtype = torch.float8_e4m3fn if dtype == "fp8_e4m3" else getattr(torch, dtype)
if dtype not in allowed:
choices = ", ".join(sorted(str(item) for item in allowed))
raise ValueError(f"Unsupported {option_name}={dtype}; expected one of: {choices}")
return dtype
@staticmethod
def _pack_mask_for_flashinfer(
@@ -233,126 +128,6 @@ class FlashInferAttentionImpl(AttentionImpl):
)
return impl.forward_cuda(query, key, value, attn_metadata)
@torch.compiler.disable
def _plan_wrapper(
self,
key: _WrapperPlanKey,
flat_mask: torch.Tensor | None,
) -> None:
self._wrapper.plan(
self._qo_indptr,
self._kv_indptr,
key.num_q_heads,
key.num_kv_heads,
key.head_dim_qk,
head_dim_vo=key.head_dim_vo,
custom_mask=flat_mask,
causal=key.causal,
sm_scale=key.softmax_scale,
q_data_type=key.q_dtype,
# FlashInfer versions newer than 0.6.15 can dispatch K and V with
# independent runtime dtypes even though plan() names this K/V.
kv_data_type=key.k_dtype,
o_data_type=key.o_dtype,
)
@torch.compiler.disable
def _make_indptr(self, batch_size: int, sequence_length: int) -> torch.Tensor:
return (
torch.arange(
batch_size + 1,
device=self.device,
dtype=torch.int32,
)
* sequence_length
)
def _ensure_plan(
self,
key: _WrapperPlanKey,
flat_mask: torch.Tensor | None,
) -> None:
key_changed = key != self._plan_key
if not key_changed and flat_mask is None:
return
if key_changed:
self._qo_indptr = self._make_indptr(key.batch_size, key.qo_len)
self._kv_indptr = self._make_indptr(key.batch_size, key.kv_len)
# A custom mask's contents may change without its shape changing, so
# copy/re-plan it for every masked invocation.
self._plan_wrapper(key, flat_mask)
self._plan_key = key
def _run_batch_prefill(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
custom_mask: torch.Tensor | None,
) -> torch.Tensor:
if query.device != self.device or key.device != self.device or value.device != self.device:
raise ValueError(
"FLASHINFER_ATTN inputs must remain on the layer initialization "
f"device {self.device}; got Q={query.device}, K={key.device}, "
f"V={value.device}"
)
batch_size, qo_len, num_q_heads, head_dim_qk = query.shape
kv_len = key.shape[1]
num_kv_heads = key.shape[2]
head_dim_k = key.shape[3]
head_dim_vo = value.shape[3]
q = query.reshape(batch_size * qo_len, num_q_heads, head_dim_qk)
k = key.reshape(batch_size * kv_len, num_kv_heads, head_dim_k)
v = value.reshape(batch_size * kv_len, num_kv_heads, head_dim_vo)
if self.dtype_qk is not None:
q = q.to(self.dtype_qk)
k = k.to(self.dtype_qk)
if self.dtype_vo is not None:
v = v.to(self.dtype_vo)
flat_mask = None
if custom_mask is not None:
if custom_mask.dim() == 2:
custom_mask = custom_mask.unsqueeze(0).expand(batch_size, -1, -1)
flat_mask = custom_mask.contiguous().view(-1)
self._ensure_plan(
FlashInferAttentionImpl._WrapperPlanKey(
batch_size=batch_size,
qo_len=qo_len,
kv_len=kv_len,
num_q_heads=num_q_heads,
num_kv_heads=num_kv_heads,
head_dim_qk=head_dim_qk,
head_dim_k=head_dim_k,
head_dim_vo=head_dim_vo,
q_dtype=q.dtype,
k_dtype=k.dtype,
v_dtype=v.dtype,
o_dtype=query.dtype,
causal=self.causal,
softmax_scale=self.softmax_scale,
has_custom_mask=flat_mask is not None,
),
flat_mask,
)
out = self._run_wrapper(q, k, v)
out = out.reshape(batch_size, qo_len, num_q_heads, head_dim_vo)
return out.to(query.dtype) if out.dtype != query.dtype else out
@torch.compiler.disable
def _run_wrapper(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
) -> torch.Tensor:
return self._wrapper.run(query, key, value)
def forward_cuda(
self,
query: torch.Tensor,
@@ -360,7 +135,18 @@ class FlashInferAttentionImpl(AttentionImpl):
value: torch.Tensor,
attn_metadata: AttentionMetadata | None = None,
) -> torch.Tensor:
if not HAS_FLASHINFER:
raise ImportError(
"FLASHINFER_ATTN backend requires flashinfer. "
"Install it or set DIFFUSION_ATTENTION_BACKEND to another backend."
)
# Try the custom_mask path; if the mask can't be packed into the
# (qo_len, kv_len) layout FlashInfer expects, fall back to SDPA
# instead of risking an illegal-memory-access crash in the kernel.
# Input layout is (B, S, H, D); FlashInfer dense prefill takes (S, H, D).
batch_size = query.shape[0]
custom_mask = None
if attn_metadata is not None and attn_metadata.attn_mask is not None:
try:
@@ -370,14 +156,28 @@ class FlashInferAttentionImpl(AttentionImpl):
qo_len=query.shape[1],
kv_len=key.shape[1],
)
except ValueError as error:
logger.debug("Falling back to SDPA for mask path: %s", error)
except ValueError as e:
logger.debug("Falling back to SDPA for mask path: %s", e)
return self._sdpa_fallback(query, key, value, attn_metadata)
# FlashInfer cannot combine causal masking with a custom_mask; rather
# than silently dropping the explicit mask (diverging from SDPA), let
# SDPA handle the causal+mask case correctly.
if custom_mask is not None and self.causal:
logger.debug("causal=True with explicit attn_mask; deferring to SDPA")
return self._sdpa_fallback(query, key, value, attn_metadata)
if custom_mask is not None and self.flashinfer_backend == "cute-dsl":
logger.debug("CuTe DSL does not support custom masks; deferring to SDPA")
return self._sdpa_fallback(query, key, value, attn_metadata)
outputs = []
for b in range(batch_size):
kwargs: dict = {
"sm_scale": self.softmax_scale,
"causal": self.causal,
"return_lse": False,
}
if custom_mask is not None:
kwargs["custom_mask"] = custom_mask if custom_mask.dim() == 2 else custom_mask[b]
out = single_prefill_with_kv_cache(query[b], key[b], value[b], **kwargs)
outputs.append(out)
return self._run_batch_prefill(query, key, value, custom_mask)
if batch_size == 1:
return outputs[0].unsqueeze(0)
return torch.stack(outputs, dim=0)
@@ -83,19 +83,6 @@ def pytorch_attn_forward(
k = k.transpose(1, 2)
v = v.transpose(1, 2)
# GQA/MQA support. Ring attention only ever *communicates* K/V with H_kv
# heads -- that is precisely the benefit of GQA -- so expanding here does
# not change communication volume. The expansion is needed because the aten
# SDPA ops below are required for the LSE output that ring accumulation
# consumes, and they do not accept `enable_gqa`.
if k.shape[1] != q.shape[1]:
num_heads, num_kv_heads = q.shape[1], k.shape[1]
if num_heads % num_kv_heads != 0:
raise ValueError(f"num_heads ({num_heads}) must be divisible by num_kv_heads ({num_kv_heads}) for GQA/MQA.")
n_rep = num_heads // num_kv_heads
k = k.repeat_interleave(n_rep, dim=1)
v = v.repeat_interleave(n_rep, dim=1)
if op_type == "flash":
out, lse = _scaled_dot_product_flash_attention(
q,
@@ -204,25 +204,6 @@ class UlyssesParallelAttention:
attn_metadata: AttentionMetadata | None,
):
mode = get_ulysses_mode(default="strict")
ulysses_world_size = self._sp_group.ulysses_world_size
# advanced_uaa pads non-divisible head counts before the Ulysses
# all-to-all. Padding K/V is not valid in hybrid Ulysses+Ring: the Ring
# GQA/MQA path would repeat the padded zero heads as real K/V heads.
# Reject this layout until K/V replication is performed before Ulysses.
if mode == "advanced_uaa" and self._sp_group.ring_world_size > 1:
for name, tensor in (("key", key), ("value", value)):
kv_head_cnt = int(tensor.shape[2])
if kv_head_cnt % ulysses_world_size != 0:
raise ValueError(
"ulysses_mode='advanced_uaa' with hybrid Ulysses+Ring "
"does not support K/V head padding. "
f"{name}_head_cnt={kv_head_cnt}, "
f"ulysses_degree={ulysses_world_size}. "
"Use ring_degree=1, choose a compatible ulysses_degree, "
"or replicate K/V heads before Ulysses."
)
joint_tensor_query = joint_tensor_key = joint_tensor_value = None
joint_strategy = "front"
joint_len = 0
@@ -245,6 +226,7 @@ class UlyssesParallelAttention:
# Slice joint_query for this Ulysses rank
# joint_query is (B, S, H, D). We split H (dim 2).
ulysses_world_size = self._sp_group.ulysses_world_size
ulysses_rank = self._sp_group.ulysses_rank
joint_head_cnt = int(joint_tensor_query.shape[-2])
joint_orig_head_cnt = joint_head_cnt
-6
View File
@@ -20,10 +20,6 @@ from vllm_omni.diffusion.cache.cachedit.model_specific import (
from vllm_omni.diffusion.cache.cachedit.model_specific import (
register_custom_dit_enablers as _register_custom_dit_enablers,
)
from vllm_omni.diffusion.cache.cachedit.runtime import (
CacheDiTRequestSpec,
RequestScopedCacheDiTRuntime,
)
_register_custom_dit_enablers()
@@ -33,8 +29,6 @@ __all__ = [
"CacheDiTAdapterConfig",
"CacheDiTBackend",
"CacheDiTConfig",
"CacheDiTRequestSpec",
"RequestScopedCacheDiTRuntime",
"SensenovaCachedAdapter",
"cache_summary",
"enable_cache_for_dit",
+12 -68
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
from collections.abc import Callable
from operator import attrgetter
from typing import TYPE_CHECKING, Any, TypeAlias, cast
import cache_dit
@@ -39,27 +38,14 @@ def _dit_module_names(pipeline: SupportsComponentDiscovery) -> tuple[str, ...]:
names = getattr(pipeline, "_dit_modules", None)
if not isinstance(names, (list, tuple)):
names = ("transformer",)
resolved_names = []
for name in names:
if not isinstance(name, str):
continue
try:
module = attrgetter(name)(pipeline)
except AttributeError:
continue
if module is not None:
resolved_names.append(name)
return tuple(resolved_names)
return tuple(name for name in names if isinstance(name, str) and getattr(pipeline, name, None) is not None)
def cache_summary(pipeline: SupportsComponentDiscovery, details: bool = True) -> None:
"""Log Cache-DiT statistics for every transformer on the pipeline."""
transformers = [attrgetter(name)(pipeline) for name in _dit_module_names(pipeline)]
transformers = [getattr(pipeline, name) for name in _dit_module_names(pipeline)]
for transformer in transformers:
if not BlockAdapter.is_cached(transformer):
continue
cache_dit.summary(transformer, details=details)
if not transformers:
@@ -78,7 +64,7 @@ def _make_pipeline_transformer_getter(
def get_pipeline_transformer(
pipeline: SupportsComponentDiscovery,
) -> nn.Module:
return cast(nn.Module, attrgetter(name)(pipeline))
return cast(nn.Module, getattr(pipeline, name))
return get_pipeline_transformer
@@ -99,37 +85,19 @@ def _build_cache_context_refresh(
# Cache-DiT has no predefined SCM mask for these small step counts.
scm_supported_steps = num_inference_steps >= 8 or num_inference_steps in (4, 6)
if projected_config.scm_steps_mask_policy is None or not scm_supported_steps:
if projected_config.force_refresh_step_hint is not None:
# Cache-DiT's ``once`` policy clears the hint after it fires.
# Reapply it at every request boundary so a repeated request
# with the same configured policy receives the same behavior.
cache_dit.refresh_context(
transformer,
cache_config=DBCacheConfig().reset(
num_inference_steps=num_inference_steps,
force_refresh_step_hint=projected_config.force_refresh_step_hint,
force_refresh_step_policy=projected_config.force_refresh_step_policy,
),
verbose=verbose,
)
return
cache_dit.refresh_context(transformer, num_inference_steps=num_inference_steps, verbose=verbose)
return
refresh_config = DBCacheConfig().reset(
num_inference_steps=num_inference_steps,
steps_computation_mask=cache_dit.steps_mask(
mask_policy=projected_config.scm_steps_mask_policy,
total_steps=num_inference_steps,
),
steps_computation_policy=projected_config.scm_steps_policy,
)
if projected_config.force_refresh_step_hint is not None:
refresh_config.force_refresh_step_hint = projected_config.force_refresh_step_hint
refresh_config.force_refresh_step_policy = projected_config.force_refresh_step_policy
cache_dit.refresh_context(
transformer,
cache_config=refresh_config,
cache_config=DBCacheConfig().reset(
num_inference_steps=num_inference_steps,
steps_computation_mask=cache_dit.steps_mask(
mask_policy=projected_config.scm_steps_mask_policy,
total_steps=num_inference_steps,
),
steps_computation_policy=projected_config.scm_steps_policy,
),
verbose=verbose,
)
@@ -245,23 +213,19 @@ class CacheDiTBackend(CacheBackend):
super().__init__(config)
self._refresh_funcs: list[RefreshCacheContextFunc] = []
self._cache_targets: list[Any] = []
def enable(self, pipeline: SupportsComponentDiscovery) -> None:
pipeline_name = type(pipeline).__name__
custom_enabler = CUSTOM_DIT_ENABLERS.get(pipeline_name)
self._refresh_funcs = []
self._cache_targets = []
if custom_enabler is not None:
logger.info("Using custom cache-dit enabler for model: %s", pipeline_name)
self._refresh_funcs = [custom_enabler(pipeline, self.config)]
self._cache_targets = [_default_get_pipeline_transformer(pipeline)]
else:
self._refresh_funcs = []
for name in _dit_module_names(pipeline):
get_transformer = _make_pipeline_transformer_getter(name)
block_adapter = _maybe_build_block_adapter(pipeline, get_transformer)
adapter_cls = _maybe_get_cached_adapter_cls(pipeline, get_transformer)
cache_target = get_transformer(pipeline) if block_adapter is None else block_adapter
self._refresh_funcs.append(
enable_cache_for_dit(
pipeline,
@@ -271,32 +235,12 @@ class CacheDiTBackend(CacheBackend):
get_transformer,
)
)
self._cache_targets.append(cache_target)
if not self._refresh_funcs:
raise ValueError(f"Pipeline {pipeline_name} has no declared DiT modules for Cache-DiT")
self.enabled = True
logger.info("Cache-dit enabled successfully on %s", pipeline_name)
def disable(self, pipeline: SupportsComponentDiscovery) -> None:
"""Remove Cache-DiT hooks so later requests use native forwards."""
if not self.enabled:
return
logger.info(
"Disabling cache-dit on %d DiT target(s) for %s",
len(self._cache_targets),
type(pipeline).__name__,
)
try:
for target in self._cache_targets:
cache_dit.disable_cache(target)
finally:
self._refresh_funcs = []
self._cache_targets = []
self.enabled = False
def refresh(self, pipeline: SupportsComponentDiscovery, num_inference_steps: int, verbose: bool = True) -> None:
if not self.enabled or not self._refresh_funcs:
logger.warning("Cache-dit is not enabled. Cannot refresh cache context.")
-94
View File
@@ -1,94 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Declarative request-scoped Cache-DiT lifecycle."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from vllm_omni.diffusion.cache.cachedit.backend import CacheDiTBackend
from vllm_omni.diffusion.data import DiffusionCacheConfig
@dataclass(frozen=True)
class CacheDiTRequestSpec:
"""Desired Cache-DiT state for one request.
``installation_key`` identifies configurations that require reinstalling
hooks when switching between them. Step-count changes only require a
context refresh.
"""
installation_key: str
cache_config: DiffusionCacheConfig
num_inference_steps: int
class RequestScopedCacheDiTRuntime:
"""Reconcile declarative request state with installed Cache-DiT hooks."""
def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline
self._backend: CacheDiTBackend | None = None
self._installation_key: str | None = None
@property
def is_enabled(self) -> bool:
return self._backend is not None and self._backend.is_enabled()
def adopt(self, backend: CacheDiTBackend, *, installation_key: str) -> None:
"""Assume ownership of an already-enabled startup backend."""
if self._backend is not None:
if self._backend is backend and self._installation_key == installation_key:
return
raise RuntimeError(
f"Cache-DiT is already installed with key {self._installation_key!r}; cannot adopt {installation_key!r}"
)
if not backend.is_enabled():
raise ValueError("Cannot adopt a Cache-DiT backend before it is enabled")
self._backend = backend
self._installation_key = installation_key
def prepare(self, spec: CacheDiTRequestSpec | None) -> None:
"""Apply the minimal transition needed before the request denoise.
``None`` disables installed hooks. An unchanged installation key only
refreshes request context; a changed key disables the previous hooks
before installing the requested profile.
"""
desired_key = None if spec is None else spec.installation_key
if self._installation_key != desired_key:
self.disable()
if spec is None:
return
if self._backend is None:
backend = CacheDiTBackend(spec.cache_config)
backend.enable(self._pipeline)
self.adopt(backend, installation_key=spec.installation_key)
assert self._backend is not None
self._backend.refresh(
self._pipeline,
num_inference_steps=spec.num_inference_steps,
)
def disable(self) -> None:
try:
if self._backend is not None:
self._backend.disable(self._pipeline)
finally:
self._backend = None
self._installation_key = None
__all__ = [
"CacheDiTRequestSpec",
"RequestScopedCacheDiTRuntime",
]
-1
View File
@@ -613,7 +613,6 @@ class OmniDiffusionConfig:
# Attention
diffusion_attention_config: "AttentionConfig" = field(default_factory=lambda: AttentionConfig())
fa_deterministic: bool = False
# Running mode
# mode: ExecutionMode = ExecutionMode.INFERENCE
+10 -13
View File
@@ -116,16 +116,6 @@ def _max_num_seqs(od_config: OmniDiffusionConfig) -> int:
return 1
def _uses_dlo_dp_concurrency(od_config: OmniDiffusionConfig) -> bool:
parallel_config = getattr(od_config, "parallel_config", None)
dp_size = getattr(parallel_config, "data_parallel_size", 1)
return (
dp_size > 1
and getattr(od_config, "enable_distributed_layerwise_offload", False)
and getattr(od_config, "dlo_use_allgather", True)
)
def _move_tensor_tree_to_cpu(value: object) -> object:
if isinstance(value, torch.Tensor):
return value.cpu() if value.device.type != "cpu" else value
@@ -212,7 +202,7 @@ class DiffusionEngine:
return DiffusionExecutionMode.STEP_BATCH
self.supports_request_batch = supports_request_batch(od_config)
if not self.supports_request_batch and _max_num_seqs(od_config) > 1 and not _uses_dlo_dp_concurrency(od_config):
if not self.supports_request_batch and _max_num_seqs(od_config) > 1:
raise ValueError(
f"{getattr(od_config, 'model_class_name', None)!r} does not support request-level batching. "
"Use max_num_seqs=1 for serial request execution, or choose a pipeline with "
@@ -243,10 +233,17 @@ class DiffusionEngine:
# for distributed layerwise offload (which shards weights and
# needs all ranks active simultaneously). Ordinary DP with a
# non-batch pipeline should not schedule multiple requests.
dp_size = getattr(getattr(self.od_config, "parallel_config", None), "data_parallel_size", 1)
if _uses_dlo_dp_concurrency(self.od_config):
dp_size = 1
if getattr(self.od_config, "parallel_config", None) is not None:
dp_size = getattr(self.od_config.parallel_config, "data_parallel_size", 1)
dist_offload = getattr(self.od_config, "enable_distributed_layerwise_offload", False)
if dp_size > 1 and dist_offload and getattr(self.od_config, "dlo_use_allgather", True):
self.scheduler.max_num_running_reqs = dp_size
self.dp_concurrent = True
# Set batch admission wait so concurrent requests accumulate
# before scheduling. The scheduler reads this from od_config.
if getattr(self.od_config, "request_batch_max_wait_ms", 0) == 0:
self.od_config.request_batch_max_wait_ms = 500.0
logger.info(
"dp_concurrent: max_num_running_reqs=%d, batch_wait=%sms",
dp_size,
@@ -4,7 +4,6 @@ import concurrent.futures
import json
import multiprocessing as mp
import multiprocessing.connection
import os
import queue
import threading
import time
@@ -22,7 +21,6 @@ from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.data import SHUTDOWN_MESSAGE, AsyncDiffusionOutput, AsyncOutputKind, DiffusionOutput
from vllm_omni.diffusion.executor.abstract import DiffusionExecutor
from vllm_omni.diffusion.ipc import DIFFUSION_RPC_RESULT_ENVELOPE, unpack_diffusion_output_shm
from vllm_omni.diffusion.sched.request_scheduler import build_request_batch_sampling_params_key
from vllm_omni.diffusion.worker import WorkerProc
if TYPE_CHECKING:
@@ -32,21 +30,6 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
_DEQUEUE_TIMEOUT_S = 5.0
_DLO_DP_WAVE_TIMEOUT_S = float(os.environ.get("VLLM_OMNI_DLO_DP_WAVE_TIMEOUT", 600.0))
_WORKER_SHUTDOWN_GRACE_S = 15.0
_WORKER_TERMINATE_GRACE_S = 5.0
_RESULT_PUMP_JOIN_TIMEOUT_S = 2.0
def _is_empty_dp_prompt(prompt: object) -> bool:
"""Return whether a DP request has no usable text prompt."""
if prompt is None:
return True
if isinstance(prompt, str):
return not prompt
if isinstance(prompt, dict) and "prompt" in prompt:
return not prompt["prompt"]
return False
@dataclass
@@ -69,20 +52,14 @@ class _ExecutorShutdownCleaner:
logger.warning("Failed to send shutdown signal: %s", exc)
if self.processes:
join_deadline = time.monotonic() + _WORKER_SHUTDOWN_GRACE_S
for proc in self.processes:
if not proc.is_alive():
continue
proc.join(max(0.0, join_deadline - time.monotonic()))
alive = [proc for proc in self.processes if proc.is_alive()]
for proc in alive:
logger.warning("Terminating diffusion worker %s after timeout", proc.name)
proc.terminate()
terminate_deadline = time.monotonic() + _WORKER_TERMINATE_GRACE_S
for proc in alive:
proc.join(max(0.0, terminate_deadline - time.monotonic()))
proc.join(5)
if proc.is_alive():
logger.warning("Terminating diffusion worker %s after timeout", proc.name)
proc.terminate()
proc.join(5)
class MultiprocDiffusionExecutor(DiffusionExecutor):
@@ -98,7 +75,6 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
self._is_failed = False
self._failure_callbacks: list[Callable[[], None]] = []
self._result_mq: MessageQueue | None = None
self._result_mqs: list[MessageQueue] = []
self._rpc_wave_id: int = 0
num_workers = cast(int, self.od_config.num_gpus)
@@ -108,7 +84,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
broadcast_handle = self._broadcast_mq.export_handle()
# Launch workers
processes, result_handles = self._launch_workers(broadcast_handle, self.wake_events)
processes, result_handle = self._launch_workers(broadcast_handle, self.wake_events)
self._processes = processes
shutdown_cleaner = _ExecutorShutdownCleaner(
@@ -120,8 +96,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
self._finalizer = weakref.finalize(self, shutdown_cleaner)
try:
self._result_mqs = [self._init_result_queue(handle) for handle in result_handles]
self._result_mq = self._result_mqs[0]
self._result_mq = self._init_result_queue(result_handle)
except Exception:
self.shutdown()
raise
@@ -137,8 +112,8 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
self._futures_lock = threading.RLock()
self._pump_running = False
self._pump_stop = threading.Event()
# When pumps are active they are the sole readers of the worker result
# queues; non-async messages are placed here for collective_rpc().
# When pump is active it is the sole reader of result_mq; non-async
# messages are placed here for collective_rpc() to consume.
self._sync_result_buffer: queue.Queue = queue.Queue()
if not self.od_config.step_execution:
self._start_result_pump()
@@ -165,17 +140,12 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
if self._broadcast_mq is None:
raise RuntimeError("Broadcast queue is closed")
def _dequeue_one_with_failure_polling(
self,
deadline: float | None,
method: str,
result_mq: MessageQueue | None = None,
) -> Any:
def _dequeue_one_with_failure_polling(self, deadline: float | None, method: str) -> Any:
"""Block until one result message, polling ``_is_failed`` between chunk timeouts.
When async output is enabled, the pumps are the sole readers of the
worker result queues; non-async messages are placed in
_sync_result_buffer, so this method reads from there instead.
When async output is enabled, pump is the sole reader of result_mq;
non-async messages are placed in _sync_result_buffer, so this method
reads from there instead of result_mq directly.
"""
while True:
if deadline is None:
@@ -193,11 +163,8 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
raise EngineDeadError()
continue
else:
queue_to_read = result_mq if result_mq is not None else self._result_mq
if queue_to_read is None:
raise RuntimeError("Result queue is closed")
try:
return queue_to_read.dequeue(timeout=chunk_timeout)
return self._result_mq.dequeue(timeout=chunk_timeout) # pyright: ignore[reportOptionalMemberAccess] MQ is not None before shutdown
except (TimeoutError, zmq.error.Again):
if self._is_failed:
raise EngineDeadError()
@@ -256,14 +223,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
_MAX_STALE_DISCARDS = 16
def _validate_wave_id(
self,
response: Any,
expected_wave_id: int,
deadline: float | None,
method: str,
result_mq: MessageQueue | None = None,
) -> Any:
def _validate_wave_id(self, response: Any, expected_wave_id: int, deadline: float | None, method: str) -> Any:
"""Discard stale RPC responses from a previous wave."""
discards = 0
while discards < self._MAX_STALE_DISCARDS:
@@ -279,7 +239,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
method,
)
discards += 1
response = self._dequeue_one_with_failure_polling(deadline, method, result_mq)
response = self._dequeue_one_with_failure_polling(deadline, method)
raise TimeoutError(
f"Discarded {self._MAX_STALE_DISCARDS} stale RPC responses "
f"without finding wave_id={expected_wave_id} for method={method}."
@@ -289,7 +249,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
self,
broadcast_handle: Handle,
wake_events: list[Event],
) -> tuple[list[mp.Process], list[Handle]]:
) -> tuple[list[mp.Process], Handle | None]:
od_config = self.od_config
logger.info("Starting server...")
@@ -327,7 +287,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
processes.append(process)
# Wait for all workers to be ready
result_handles: list[Handle] = []
result_handle = None
for writer in scheduler_pipe_writers:
writer.close()
@@ -343,16 +303,14 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
if data["status"] != "ready":
raise RuntimeError("Initialization failed. Please see the error messages above.")
result_handle = data.get("result_handle")
if result_handle is None:
raise RuntimeError(f"Rank {i} did not provide a result queue handle")
result_handles.append(result_handle)
if i == 0:
result_handle = data.get("result_handle")
reader.close()
logger.debug("All workers are ready")
return processes, result_handles
return processes, result_handle
@property
def is_dead(self) -> bool:
@@ -411,27 +369,6 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
t = threading.Thread(target=_monitor, daemon=True, name="diffusion-worker-monitor")
t.start()
def _fail_closed_on_dp_wave_timeout(self, exc: TimeoutError) -> None:
"""Shut down every rank after a partial DP wave times out.
Once one rank is stuck in a DLO AllGather, that process group cannot be
reused safely. Shutting down the executor converts an otherwise
permanent request hang into a bounded engine failure and lets the
caller restart.
"""
logger.error(
"DLO DP collective wave timed out after %.1fs; shutting down the worker group: %s",
_DLO_DP_WAVE_TIMEOUT_S,
exc,
)
self._is_failed = True
self.shutdown()
for callback in self._failure_callbacks:
try:
callback()
except Exception:
logger.exception("failure_callback raised")
def register_failure_callback(
self,
callback: Callable[[], None],
@@ -461,17 +398,21 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
and getattr(self.od_config, "enable_distributed_layerwise_offload", False)
and getattr(self.od_config, "dlo_use_allgather", True)
):
# Reuse the request scheduler's complete compatibility key. DLO
# AllGather requires every DP rank to execute the same collective
# schedule, including shape, CFG, denoise steps, output count,
# and LoRA settings. Two default (None) step counts are valid and
# resolve identically inside the same pipeline.
compatibility_keys = [build_request_batch_sampling_params_key(nr.req) for nr in new_reqs]
if any(key != compatibility_keys[0] for key in compatibility_keys[1:]):
# Validate: all concurrent requests must share the same
# num_inference_steps and identical extra_args, because
# AllGather is a collective that requires every rank to
# participate at each step.
step_counts = {
nr.req.sampling_params.num_inference_steps
for nr in new_reqs
if nr.req.sampling_params.num_inference_steps is not None
}
has_none = any(nr.req.sampling_params.num_inference_steps is None for nr in new_reqs)
if (len(step_counts) > 1) or has_none:
raise ValueError(
"DLO DP multi-concurrency requires compatible shape, CFG, "
"denoise schedule, output count, and LoRA settings for all "
"requests in one collective wave."
"DP multi-concurrency requires all concurrent requests to have "
"the same explicit num_inference_steps (None is not allowed), got "
f"{[nr.req.sampling_params.num_inference_steps for nr in new_reqs]}."
)
extra_args_signatures: set = set()
for nr in new_reqs:
@@ -483,18 +424,11 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
"share identical extra_args. Different extra_args can change "
"the forward schedule and cause AllGather deadlock."
)
empty_prompt_ids = [nr.request_id for nr in new_reqs if _is_empty_dp_prompt(nr.req.prompt)]
if empty_prompt_ids:
raise ValueError(
"DP multi-concurrency requires a non-empty prompt for every request; "
f"empty prompt request IDs: {empty_prompt_ids}."
)
reqs_list = [nr.req for nr in new_reqs]
try:
results = self.collective_rpc(
"execute_model",
timeout=_DLO_DP_WAVE_TIMEOUT_S,
args=(reqs_list, self.od_config, scheduler_output.kv_prefetch_job),
unique_reply_rank=None,
exec_all_ranks=True,
@@ -514,8 +448,6 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
else:
raise RuntimeError(f"Unexpected response type [{i}]: {type(res)!r}")
except Exception as exc:
if isinstance(exc, TimeoutError):
self._fail_closed_on_dp_wave_timeout(exc)
for new_req in new_reqs:
runner_outputs.append(
RunnerOutput(
@@ -659,18 +591,14 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
deadline = None if timeout is None else time.monotonic() + timeout
kwargs = kwargs or {}
multi_rank_reply = unique_reply_rank is None and exec_all_ranks
execute_all_ranks = unique_reply_rank is None or exec_all_ranks
# Status aggregation is for control-plane RPCs, where rank 0 sends
# one envelope representing every rank. DP request concurrency needs
# one independent reply per DP primary instead.
collect_rank_status = unique_reply_rank is None and not multi_rank_reply
collect_rank_status = unique_reply_rank is None
rpc_request = {
"type": "rpc",
"method": method,
"args": args,
"kwargs": kwargs,
"output_rank": None if multi_rank_reply else (unique_reply_rank if unique_reply_rank is not None else 0),
"output_rank": unique_reply_rank if unique_reply_rank is not None else 0,
"exec_all_ranks": execute_all_ranks,
"collect_rank_status": collect_rank_status,
}
@@ -680,6 +608,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
# so the D2H copy runs on a side stream in the worker background
# thread while the default stream is free for the next forward.
# pump routes the result back to a Future via rpc_id.
multi_rank_reply = unique_reply_rank is None and exec_all_ranks
if (
not self.od_config.step_execution
and method in ("execute_model", "execute_model_batch")
@@ -722,28 +651,11 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
responses: list = []
if unique_reply_rank is None and exec_all_ranks and num_responses > 1:
# DP multi-concurrency: collect num_responses replies, sort by dp_rank.
result_mqs: list[MessageQueue | None] = [None] * num_responses
if self.od_config.step_execution:
parallel_config = self.od_config.parallel_config
replica_size = (
getattr(parallel_config, "tensor_parallel_size", 1)
* getattr(parallel_config, "sequence_parallel_size", 1)
* getattr(parallel_config, "pipeline_parallel_size", 1)
* getattr(parallel_config, "cfg_parallel_size", 1)
)
primary_ranks = [dp_rank * replica_size for dp_rank in range(num_responses)]
all_result_mqs = getattr(self, "_result_mqs", [])
if not all_result_mqs or primary_ranks[-1] >= len(all_result_mqs):
raise RuntimeError(
"Missing result queues for DP primary ranks "
f"{primary_ranks}; available queues: {len(all_result_mqs)}"
)
result_mqs = [all_result_mqs[rank] for rank in primary_ranks]
tagged: list[tuple[int, Any]] = []
collected_errors: list[str] = []
for result_mq in result_mqs:
response = self._dequeue_one_with_failure_polling(deadline, method, result_mq)
response = self._validate_wave_id(response, wave_id, deadline, method, result_mq)
for _ in range(num_responses):
response = self._dequeue_one_with_failure_polling(deadline, method)
response = self._validate_wave_id(response, wave_id, deadline, method)
try:
unpack_diffusion_output_shm(response)
except Exception as e:
@@ -786,35 +698,20 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
def _start_result_pump(self) -> None:
self._pump_running = True
self._pump_stop.clear()
result_mqs = self._result_mqs or ([self._result_mq] if self._result_mq is not None else [])
self._result_pump_threads = [
threading.Thread(
target=self._result_pump,
args=(result_mq,),
daemon=True,
name=f"DiffusionResultPump-{rank}",
)
for rank, result_mq in enumerate(result_mqs)
]
for thread in self._result_pump_threads:
thread.start()
self._result_pump_thread = self._result_pump_threads[0] if self._result_pump_threads else None
logger.info("Async result pump started for %d worker queue(s)", len(self._result_pump_threads))
self._result_pump_thread = threading.Thread(target=self._result_pump, daemon=True, name="DiffusionResultPump")
self._result_pump_thread.start()
logger.info("Async result pump started")
def _result_pump(self, result_mq: MessageQueue | None = None) -> None:
"""Sole reader of one worker result queue when async output is enabled.
def _result_pump(self) -> None:
"""Sole reader of result_mq when async output is enabled.
Dispatches AsyncDiffusionOutput messages to the appropriate future:
* RPC_RESULT / COMPUTE_DONE → _rpc_futures[rpc_id]
* OUTPUT_READY → _output_futures[async_output_id]
"""
result_mq = result_mq or self._result_mq
if result_mq is None:
return
while not self._pump_stop.is_set():
try:
msg = result_mq.dequeue(timeout=1.0)
msg = self._result_mq.dequeue(timeout=1.0)
except TimeoutError:
if self._is_failed:
break
@@ -926,18 +823,8 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
try:
self._finalizer()
finally:
pump_threads = getattr(self, "_result_pump_threads", [])
for thread in pump_threads:
if thread is threading.current_thread():
continue
thread.join(timeout=_RESULT_PUMP_JOIN_TIMEOUT_S)
if thread.is_alive():
logger.warning("Result pump thread %s did not stop before shutdown", thread.name)
self._pump_running = False
self._broadcast_mq = None
self._result_mq = None
self._result_mqs = []
self._result_pump_threads = []
with self._futures_lock:
for fut in self._rpc_futures.values():
if not fut.done():
@@ -8,7 +8,6 @@ IPC overhead. Used when there is only a single diffusion stage.
from __future__ import annotations
import asyncio
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import TYPE_CHECKING, Any
@@ -67,8 +66,6 @@ class InlineStageDiffusionClient(StageClientBase):
self._tasks: dict[str, asyncio.Task] = {}
self._engine_dead = False
self._shutting_down = False
self._shutdown_complete = False
self._shutdown_lock = threading.Lock()
self._engine.executor.register_failure_callback(self._mark_engine_dead)
@@ -104,10 +101,6 @@ class InlineStageDiffusionClient(StageClientBase):
sampling_params: OmniDiffusionSamplingParams,
kv_sender_info: dict[int, dict[str, Any]] | None = None,
) -> None:
# Each request mutates its sampling state while it is normalized and
# executed. Callers commonly reuse one params object for concurrent
# requests, so take the copy synchronously before either task starts.
sampling_params = sampling_params.clone()
logger.debug(
"[InlineStageDiffusionClient] stage-%s [rep-%s] add request: %s",
self.stage_id,
@@ -305,25 +298,19 @@ class InlineStageDiffusionClient(StageClientBase):
raise
def shutdown(self) -> None:
with self._shutdown_lock:
if self._shutdown_complete:
return
self._shutting_down = True
self._shutting_down = True
# Cancel all pending tasks
for task in self._tasks.values():
task.cancel()
# Cancel all pending tasks
for task in self._tasks.values():
task.cancel()
try:
# Stop the engine first so any control RPC running in the thread
# pool can observe shutdown instead of keeping stage teardown
# blocked while the executor waits for that RPC.
self._engine.close()
except Exception:
pass
try:
# Cancel queued futures and wait for the running one to complete deterministically
self._executor.shutdown(wait=True, cancel_futures=True)
except Exception:
pass
try:
self._executor.shutdown(wait=True, cancel_futures=True)
except Exception:
pass
self._shutdown_complete = True
try:
self._engine.close()
except Exception:
pass
+42 -74
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
from typing import Any
import numpy as np
import torch
from vllm_omni.diffusion.data import DiffusionOutput
@@ -22,45 +21,6 @@ _SHM_TENSOR_THRESHOLD = 1_000_000 # 1 MB
DIFFUSION_RPC_RESULT_ENVELOPE = "diffusion_rpc_result"
def _array_to_shm(array: np.ndarray) -> dict[str, Any]:
"""Copy a contiguous NumPy-compatible array into shared memory."""
from multiprocessing import shared_memory
if array.dtype.hasobject:
raise TypeError("NumPy object arrays cannot be transferred through raw shared memory")
array = np.ascontiguousarray(array)
nbytes = array.nbytes
shm = shared_memory.SharedMemory(create=True, size=nbytes)
shm_array = np.ndarray(array.shape, dtype=array.dtype, buffer=shm.buf[:nbytes])
np.copyto(shm_array, array)
handle = {
"name": shm.name,
"shape": list(array.shape),
"numpy_dtype": str(array.dtype),
"nbytes": nbytes,
}
shm.close()
return handle
def _array_from_shm(handle: dict[str, Any]) -> np.ndarray:
"""Copy an array from shared memory, then close and unlink its segment."""
from multiprocessing import shared_memory
shm = shared_memory.SharedMemory(name=handle["name"])
try:
array = np.ndarray(
handle["shape"],
dtype=np.dtype(handle["numpy_dtype"]),
buffer=shm.buf[: handle["nbytes"]],
).copy()
finally:
shm.close()
shm.unlink()
return array
def _tensor_to_shm(
tensor: torch.Tensor,
d2h_stream: torch.Stream | None = None,
@@ -75,6 +35,10 @@ def _tensor_to_shm(
path. The caller must synchronize *d2h_stream* after all tensors are
packed.
"""
from multiprocessing import shared_memory
import numpy as np
original_dtype = tensor.dtype
if d2h_stream is not None:
# Non-blocking D2H: copy on side stream to pinned CPU memory.
@@ -94,26 +58,44 @@ def _tensor_to_shm(
tensor = tensor.detach().cpu().contiguous()
if original_dtype == torch.bfloat16:
tensor = tensor.to(torch.float32)
handle = _array_to_shm(tensor.numpy())
handle.update(
{
"__tensor_shm__": True,
"torch_dtype": str(original_dtype),
}
)
arr = tensor.numpy()
nbytes = arr.nbytes
shm = shared_memory.SharedMemory(create=True, size=nbytes)
shm_arr = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf[:nbytes])
np.copyto(shm_arr, arr)
handle = {
"__tensor_shm__": True,
"name": shm.name,
"shape": list(tensor.shape),
"torch_dtype": str(original_dtype),
"numpy_dtype": str(arr.dtype),
"nbytes": nbytes,
}
shm.close()
return handle
def _tensor_from_shm(handle: dict[str, Any]) -> torch.Tensor:
"""Reconstruct a tensor from a shared-memory handle and free the segment."""
tensor = torch.from_numpy(_array_from_shm(handle))
# Restore the original dtype if it differs from the numpy-compatible
# dtype used for the SHM transfer (e.g. bfloat16 → float32 → bfloat16).
torch_dtype_str = handle.get("torch_dtype", "")
if torch_dtype_str:
original_dtype = getattr(torch, torch_dtype_str.replace("torch.", ""), None)
if original_dtype is not None and tensor.dtype != original_dtype:
tensor = tensor.to(original_dtype)
from multiprocessing import shared_memory
import numpy as np
shm = shared_memory.SharedMemory(name=handle["name"])
try:
np_dtype = np.dtype(handle["numpy_dtype"])
arr = np.ndarray(handle["shape"], dtype=np_dtype, buffer=shm.buf[: handle["nbytes"]])
tensor = torch.from_numpy(arr.copy())
# Restore the original dtype if it differs from the numpy-compatible
# dtype used for the SHM transfer (e.g. bfloat16 → float32 → bfloat16).
torch_dtype_str = handle.get("torch_dtype", "")
if torch_dtype_str:
original_dtype = getattr(torch, torch_dtype_str.replace("torch.", ""), None)
if original_dtype is not None and tensor.dtype != original_dtype:
tensor = tensor.to(original_dtype)
finally:
shm.close()
shm.unlink()
return tensor
@@ -127,18 +109,6 @@ def _pack_tensor_if_large(
return val
def _ndarray_to_shm(array: np.ndarray) -> dict[str, Any]:
"""Copy a contiguous NumPy array into POSIX shared memory."""
handle = _array_to_shm(array)
handle["__ndarray_shm__"] = True
return handle
def _ndarray_from_shm(handle: dict[str, Any]) -> np.ndarray:
"""Reconstruct a NumPy array from shared memory and free the segment."""
return _array_from_shm(handle)
def _pack_value_if_large(
val: object,
d2h_stream: torch.Stream | None = None,
@@ -153,8 +123,6 @@ def _pack_value_if_large(
"""
if isinstance(val, torch.Tensor):
return _pack_tensor_if_large(val, d2h_stream=d2h_stream)
if isinstance(val, np.ndarray):
return _ndarray_to_shm(val) if not val.dtype.hasobject and val.nbytes > _SHM_TENSOR_THRESHOLD else val
if isinstance(val, dict):
return {key: _pack_value_if_large(value, d2h_stream=d2h_stream) for key, value in val.items()}
if isinstance(val, list):
@@ -168,8 +136,6 @@ def _unpack_if_shm_handle(val: object) -> object:
"""Reconstruct tensors from SHM handles, mirroring ``_pack_value_if_large``."""
if isinstance(val, dict) and val.get("__tensor_shm__"):
return _tensor_from_shm(val)
if isinstance(val, dict) and val.get("__ndarray_shm__"):
return _ndarray_from_shm(val)
if isinstance(val, dict):
return {key: _unpack_if_shm_handle(value) for key, value in val.items()}
if isinstance(val, list):
@@ -220,12 +186,13 @@ def pack_diffusion_output_shm(
if isinstance(output, dict) and "dp_rank" in output and "output" in output:
inner = output["output"]
if isinstance(inner, DiffusionOutput):
output["output"] = _pack_diffusion_fields(inner, d2h_stream=d2h_stream)
output["output"] = _pack_diffusion_fields(inner)
return output
if _is_rpc_result_envelope(output):
result = output.get("result")
output["result"] = pack_diffusion_output_shm(result, d2h_stream=d2h_stream)
if isinstance(result, DiffusionOutput):
output["result"] = _pack_diffusion_fields(result, d2h_stream=d2h_stream)
return output
result = getattr(output, "result", None)
@@ -261,7 +228,8 @@ def unpack_diffusion_output_shm(output: object) -> object:
if _is_rpc_result_envelope(output):
result = output.get("result")
output["result"] = unpack_diffusion_output_shm(result)
if isinstance(result, DiffusionOutput):
output["result"] = _unpack_diffusion_fields(result)
return output
result = getattr(output, "result", None)
+1 -18
View File
@@ -64,21 +64,4 @@ def get_diffusion_model_metadata(model_class_name: str | None) -> DiffusionModel
# pipelines do not accidentally inherit limits meant for other models.
if model_class_name is None:
return DiffusionModelMetadata()
metadata = _DIFFUSION_MODEL_METADATA.get(model_class_name)
if metadata is not None:
return metadata
# Some checkpoints report the HF architecture name diff from internal pipeline class name
# (e.g. HunyuanImage3ForCausalMM, WanVACEPipeline, OmniVoice ...).
from vllm_omni.diffusion.registry import _DIFFUSION_MODELS
entry = _DIFFUSION_MODELS.get(model_class_name)
if entry is not None:
# Unpack instead of indexing so a future change to the registry tuple
# shape fails loudly instead of silently reading the wrong element.
_, _, pipeline_cls_name = entry
# Note: the registry ``cls_name`` and the metadata keys are two separate
# key spaces. Aliases whose pipeline class has no metadata entry (e.g.
# Wan22VACEPipeline, OmniVoicePipeline) still fall back to the defaults;
# that is not a regression, it just means no capability override.
return _DIFFUSION_MODEL_METADATA.get(pipeline_cls_name, DiffusionModelMetadata())
return DiffusionModelMetadata()
return _DIFFUSION_MODEL_METADATA.get(model_class_name, DiffusionModelMetadata())
-29
View File
@@ -17,7 +17,6 @@ if TYPE_CHECKING:
import torch
from vllm_omni.diffusion.cache.cachedit import CacheDiTBackend
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.worker.input_batch import InputBatch
from vllm_omni.diffusion.worker.utils import StepRequestState
@@ -132,31 +131,3 @@ def supports_prompt_update(pipeline: object) -> bool:
"""Return whether ``pipeline`` implements :class:`SupportsPromptUpdate`."""
return isinstance(pipeline, SupportsPromptUpdate)
@runtime_checkable
class SupportsRequestScopedCacheDiT(Protocol):
"""Optional protocol for pipelines that own Cache-DiT hook transitions."""
def adopt_cache_dit_backend(self, backend: CacheDiTBackend) -> None:
"""Assume ownership of an enabled Cache-DiT backend."""
...
def is_cache_dit_enabled(self) -> bool:
"""Return whether this pipeline currently has Cache-DiT installed."""
...
def adopt_request_scoped_cache_dit(pipeline: object, backend: CacheDiTBackend) -> bool:
"""Transfer an enabled Cache-DiT backend to an opted-in pipeline."""
if not isinstance(pipeline, SupportsRequestScopedCacheDiT):
return False
pipeline.adopt_cache_dit_backend(backend)
return True
def is_request_scoped_cache_dit_enabled(pipeline: object) -> bool:
"""Read Cache-DiT state from a pipeline that owns its lifecycle."""
return isinstance(pipeline, SupportsRequestScopedCacheDiT) and pipeline.is_cache_dit_enabled()
@@ -22,10 +22,6 @@ from transformers import Qwen2TokenizerFast, Qwen3VLProcessor
from vllm.logger import init_logger
from vllm_omni.diffusion import envs
from vllm_omni.diffusion.cache.cachedit import (
CacheDiTBackend,
RequestScopedCacheDiTRuntime,
)
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.parallel_state import (
get_dit_group,
@@ -77,7 +73,6 @@ from .presentation import (
minimax_h3_ref2va_video_presentation,
minimax_h3_text_only_ids,
)
from .quality_policy import MINIMAX_H3_GENERIC_CACHE_KEY, MiniMaxH3QualityPolicy
from .reference_video import (
load_audio_file,
load_video_audio,
@@ -543,19 +538,6 @@ class MiniMaxH3Pipeline(
]
dummy_run_num_frames: ClassVar[int] = 0
def adopt_cache_dit_backend(self, backend: CacheDiTBackend) -> None:
"""Adopt runner-installed generic Cache-DiT for request transitions."""
self._cache_dit_runtime.adopt(
backend,
installation_key=MINIMAX_H3_GENERIC_CACHE_KEY,
)
def is_cache_dit_enabled(self) -> bool:
"""Return the request-scoped Cache-DiT installation state."""
return self._cache_dit_runtime.is_enabled
def __init__(
self,
*,
@@ -694,9 +676,6 @@ class MiniMaxH3Pipeline(
# Registry-side VAE patch-parallel discovery uses ``pipeline.vae``.
self.vae = self.video_vae
self._quality_policy = MiniMaxH3QualityPolicy(od_config)
self._cache_dit_runtime = RequestScopedCacheDiTRuntime(self)
self.setup_diffusion_pipeline_profiler(
enable_diffusion_pipeline_profiler=(od_config.enable_diffusion_pipeline_profiler)
)
@@ -1572,8 +1551,6 @@ class MiniMaxH3Pipeline(
raise OmniClientError("MiniMax H3 requires a non-empty prompt")
sampling = request.sampling_params
quality = sampling.quality
logger.debug("MiniMax H3 request quality=%s", quality)
extra = sampling.extra_args or {}
task = self._resolve_task(extra.get("task"), multi_modal_data)
@@ -1755,12 +1732,6 @@ class MiniMaxH3Pipeline(
num_steps = int(sampling.num_inference_steps or 50)
video_shift = float(extra.get("flow_shift", self.default_video_shift))
audio_shift = float(extra.get("audio_flow_shift", self.default_audio_shift))
quality_plan = self._quality_policy.resolve(
quality=quality,
num_inference_steps=num_steps,
extra_args=extra,
)
self._cache_dit_runtime.prepare(quality_plan.cache_dit)
num_outputs = _resolve_minimax_h3_num_outputs(sampling.num_outputs_per_prompt)
videos = []
audios = []
@@ -1,178 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Model-owned request quality policy for MiniMax H3."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from numbers import Integral
from typing import Any
from vllm_omni.diffusion.cache.cachedit import CacheDiTRequestSpec
from vllm_omni.diffusion.data import DiffusionCacheConfig
from vllm_omni.errors import OmniClientError
MINIMAX_H3_GENERIC_CACHE_KEY = "minimax_h3.generic"
MINIMAX_H3_HIGH_CACHE_KEY = "minimax_h3.high"
MINIMAX_H3_FORCE_REFRESH_STEP_HINT_ARG = "force_refresh_step_hint"
MINIMAX_H3_FORCE_REFRESH_STEP_POLICY_ARG = "force_refresh_step_policy"
MINIMAX_H3_FORCE_REFRESH_POLICIES = ("once", "repeat")
def _high_quality_cache_config() -> DiffusionCacheConfig:
return DiffusionCacheConfig(
Fn_compute_blocks=1,
Bn_compute_blocks=0,
max_warmup_steps=4,
residual_diff_threshold=0.04,
max_continuous_cached_steps=1,
enable_taylorseer=False,
scm_steps_mask_policy=None,
)
def _resolve_force_refresh(
extra_args: Mapping[str, Any] | None,
*,
num_inference_steps: int,
) -> tuple[int, str] | None:
"""Resolve H3's request-scoped Cache-DiT refresh hint.
These are model-specific ``extra_args`` rather than global sampling fields,
keeping other diffusion models unchanged.
"""
if not extra_args:
return None
raw_hint = extra_args.get(MINIMAX_H3_FORCE_REFRESH_STEP_HINT_ARG)
raw_policy = extra_args.get(MINIMAX_H3_FORCE_REFRESH_STEP_POLICY_ARG)
if raw_hint is None:
if raw_policy is not None:
raise OmniClientError("MiniMax H3 force_refresh_step_policy requires force_refresh_step_hint")
return None
if isinstance(raw_hint, bool) or not isinstance(raw_hint, Integral):
raise OmniClientError("MiniMax H3 force_refresh_step_hint must be a positive integer")
hint = int(raw_hint)
if not 1 <= hint <= num_inference_steps:
raise OmniClientError(
"MiniMax H3 force_refresh_step_hint must be between 1 and "
f"num_inference_steps ({num_inference_steps}), got {hint}"
)
policy = "once" if raw_policy is None else raw_policy
if not isinstance(policy, str) or policy not in MINIMAX_H3_FORCE_REFRESH_POLICIES:
raise OmniClientError(
"MiniMax H3 force_refresh_step_policy must be one of "
f"{list(MINIMAX_H3_FORCE_REFRESH_POLICIES)}, got {policy!r}"
)
return hint, policy
def _has_force_refresh_args(extra_args: Mapping[str, Any] | None) -> bool:
if not extra_args:
return False
return any(
extra_args.get(name) is not None
for name in (
MINIMAX_H3_FORCE_REFRESH_STEP_HINT_ARG,
MINIMAX_H3_FORCE_REFRESH_STEP_POLICY_ARG,
)
)
def _with_force_refresh(
cache_config: DiffusionCacheConfig,
force_refresh: tuple[int, str] | None,
) -> DiffusionCacheConfig:
if force_refresh is None:
return cache_config
hint, policy = force_refresh
return replace(
cache_config,
force_refresh_step_hint=hint,
force_refresh_step_policy=policy,
)
def _cache_installation_key(base_key: str, force_refresh: tuple[int, str] | None) -> str:
"""Make refresh-policy transitions explicit to the request runtime.
Cache-DiT cannot clear an existing ``force_refresh_step_hint`` through its
incremental context update API because ``None`` is treated as "keep the
old value". Including the request hint in the installation key therefore
makes a hint change perform a safe hook reinstall, while repeated requests
with the same hint still only refresh the context.
"""
if force_refresh is None:
return base_key
hint, policy = force_refresh
return f"{base_key}:force_refresh={hint}:{policy}"
@dataclass(frozen=True)
class MiniMaxH3QualityPlan:
"""Resolved execution choices for one MiniMax H3 request."""
cache_dit: CacheDiTRequestSpec | None
class MiniMaxH3QualityPolicy:
"""Resolve H3 request quality into a declarative Cache-DiT target.
When the server starts with Cache-DiT, omitted quality selects the
server-configured profile. Otherwise, omitted quality selects no cache.
In either case, ``lossless`` selects no cache and ``high`` selects H3's
high-quality profile. The policy therefore owns whether a request installs
Cache-DiT; the startup backend only controls the omitted-quality default.
H3-specific Cache-DiT refresh hints are read from request ``extra_args``;
they do not change the global diffusion request contract.
The pipeline owns applying the resulting target at the request boundary.
"""
def __init__(self, od_config: Any) -> None:
self._od_config = od_config
self._configured_backend = str(getattr(od_config, "cache_backend", "none") or "none").lower()
def resolve(
self,
*,
quality: str | None,
num_inference_steps: int,
extra_args: Mapping[str, Any] | None = None,
) -> MiniMaxH3QualityPlan:
if quality == "high":
base_key = MINIMAX_H3_HIGH_CACHE_KEY
base_config = _high_quality_cache_config()
elif self._configured_backend == "cache_dit" and quality is None:
base_key = MINIMAX_H3_GENERIC_CACHE_KEY
base_config = self._od_config.cache_config
else:
if _has_force_refresh_args(extra_args):
raise OmniClientError("MiniMax H3 force-refresh arguments require an active Cache-DiT request target")
return MiniMaxH3QualityPlan(cache_dit=None)
force_refresh = _resolve_force_refresh(
extra_args,
num_inference_steps=num_inference_steps,
)
return MiniMaxH3QualityPlan(
cache_dit=CacheDiTRequestSpec(
installation_key=_cache_installation_key(base_key, force_refresh),
cache_config=_with_force_refresh(base_config, force_refresh),
num_inference_steps=num_inference_steps,
),
)
__all__ = [
"MINIMAX_H3_GENERIC_CACHE_KEY",
"MINIMAX_H3_HIGH_CACHE_KEY",
"MiniMaxH3QualityPlan",
"MiniMaxH3QualityPolicy",
]
-9
View File
@@ -55,10 +55,6 @@ class StepBatchSamplingParamsKey:
true_cfg_scale: float | None = None
cfg_normalize: bool = False
# Request-scoped execution policy. A batch must use one quality mode
# because model acceleration hooks are shared by the whole worker batch.
quality: str | None = None
# Output count. Requests with different num_outputs_per_prompt produce
# differently shaped outputs and cannot share a batch.
num_outputs_per_prompt: int = 1
@@ -112,11 +108,6 @@ class RequestBatchSamplingParamsKey:
layers: int = 4
use_en_prompt: bool = False
# Request-scoped execution policy. Keep accelerated and reference-path
# requests in separate worker batches. ``None`` delegates the default to
# the model and must remain distinct from explicit ``lossless``.
quality: str | None = None
# LoRA identity.
lora_int_id: int | None = None
lora_scale: float = 1.0
+7 -11
View File
@@ -24,21 +24,17 @@ _REQUEST_BATCH_SAMPLING_PARAMS_KEY_FIELD_NAMES = frozenset(
) - {"lora_int_id"}
def build_request_batch_sampling_params_key(request: OmniDiffusionRequest) -> RequestBatchSamplingParamsKey:
"""Build the compatibility key shared by scheduling and DP dispatch."""
sampling = request.sampling_params
# LoRA identity is optional on sampling params (and on test stubs).
lora_request = getattr(sampling, "lora_request", None)
key_kwargs = {name: getattr(sampling, name) for name in _REQUEST_BATCH_SAMPLING_PARAMS_KEY_FIELD_NAMES}
key_kwargs["lora_int_id"] = lora_request.lora_int_id if lora_request is not None else None
return RequestBatchSamplingParamsKey(**key_kwargs)
class RequestScheduler(BaseScheduler):
"""Diffusion scheduler with vLLM-style waiting/running queues."""
def _build_sampling_params_key(self, request: OmniDiffusionRequest) -> RequestBatchSamplingParamsKey:
return build_request_batch_sampling_params_key(request)
"""Build a request-batch compatibility key from sampling parameters."""
sampling = request.sampling_params
# LoRA identity is optional on sampling params (and on test stubs).
lora_request = getattr(sampling, "lora_request", None)
key_kwargs = {name: getattr(sampling, name) for name in _REQUEST_BATCH_SAMPLING_PARAMS_KEY_FIELD_NAMES}
key_kwargs["lora_int_id"] = lora_request.lora_int_id if lora_request is not None else None
return RequestBatchSamplingParamsKey(**key_kwargs)
def update_from_output(self, sched_output: DiffusionSchedulerOutput, output: RunnerOutput) -> set[str]:
scheduled_request_ids = sched_output.scheduled_request_ids
@@ -32,13 +32,7 @@ from vllm_omni.diffusion.compile import regionally_compile
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.interface import (
SupportsPromptUpdate,
adopt_request_scoped_cache_dit,
is_request_scoped_cache_dit_enabled,
supports_prompt_update,
supports_step_execution,
)
from vllm_omni.diffusion.models.interface import SupportsPromptUpdate, supports_prompt_update, supports_step_execution
from vllm_omni.diffusion.offloader import get_offload_backend
from vllm_omni.diffusion.registry import _NO_CACHE_ACCELERATION
from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -310,19 +304,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
self.cache_backend = None
self.od_config.cache_backend = None
else:
# Install configured cache capability once at startup. A model
# may explicitly adopt the enabled Cache-DiT backend and then
# own all later request-boundary enable/disable transitions.
self.cache_backend.enable(self.pipeline)
if str(self.od_config.cache_backend).lower() == "cache_dit" and adopt_request_scoped_cache_dit(
self.pipeline,
self.cache_backend,
):
logger.info(
"Pipeline %s owns request-scoped Cache-DiT transitions.",
type(self.pipeline).__name__,
)
self.cache_backend = None
# Install prompt-embedding cache (transparent wrapper around
# ``pipeline.encode_prompt``). Enabled via config or env var; a no-op
@@ -541,11 +523,11 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
if is_primary and prompt_embed_cache is not None:
logger.debug("prompt-embed cache: %s", prompt_embed_cache.stats())
runner_cache_dit_enabled = self.cache_backend is not None and self.cache_backend.is_enabled()
if (
od_config.cache_backend == "cache_dit"
self.cache_backend is not None
and self.cache_backend.is_enabled()
and od_config.cache_backend == "cache_dit"
and od_config.enable_cache_dit_summary
and (runner_cache_dit_enabled or is_request_scoped_cache_dit_enabled(self.pipeline))
):
cache_summary(self.pipeline, details=True)
+5 -44
View File
@@ -63,8 +63,6 @@ from vllm_omni.worker.gpu_memory_utils import get_process_gpu_memory
logger = init_logger(__name__)
_ASYNC_OUTPUT_THREAD_JOIN_TIMEOUT_S = 10.0
@dataclass
class _DiffusionVllmModelConfig:
@@ -431,8 +429,8 @@ class DiffusionWorker:
ranks stay synchronised at each AllGather call while computing
different activations.
Each rank returns its OWN DiffusionOutput (no gather). The executor
collects N responses via the per-worker result queues.
Each rank returns its OWN DiffusionOutput (no gather). The executor
collects N responses via result_mq (all ranks share one queue).
"""
assert self.model_runner is not None, "Model runner not initialized"
@@ -897,11 +895,8 @@ class WorkerProc:
"""
device = torch.device(torch.accelerator.current_accelerator().type, self.gpu_id)
d2h_stream = torch.Stream(device=device)
while True:
item = self._async_output_queue.get()
if item is None:
break
output, async_output_id, gpu_event = item
while self._running:
output, async_output_id, gpu_event = self._async_output_queue.get()
try:
# Cross-stream ordering: wait for default stream to finish
# writing the output tensors before the side stream reads.
@@ -930,40 +925,6 @@ class WorkerProc:
)
)
def shutdown(self) -> None:
"""Stop background work and release worker-owned IPC resources."""
self._running = False
if self._async_output_queue is not None:
self._async_output_queue.put(None)
if self._async_output_thread is not None:
self._async_output_thread.join(timeout=_ASYNC_OUTPUT_THREAD_JOIN_TIMEOUT_S)
if self._async_output_thread.is_alive():
logger.warning(
"Worker %d: Async output thread did not stop before shutdown",
self.gpu_id,
)
try:
self.worker.shutdown()
finally:
if self.mq is not None:
self.mq.shutdown()
self.mq = None
# The worker creates this queue's shared-memory ring buffer.
# Dropping the final creator reference invokes
# ShmRingBuffer.__del__, which closes and unlinks it before
# multiprocessing.resource_tracker exits.
if self.result_mq is not None and (
self._async_output_thread is None or not self._async_output_thread.is_alive()
):
result_mq = self.result_mq
self.result_mq = None
result_mq.shutdown()
del result_mq
gc.collect()
def _gather_rpc_rank_statuses(self, status: dict[str, Any]) -> list[dict[str, Any]]:
if not torch.distributed.is_initialized():
return [status]
@@ -1235,7 +1196,7 @@ class WorkerProc:
finally:
if worker_proc is not None:
try:
worker_proc.shutdown()
worker_proc.worker.shutdown()
except Exception as exc:
logger.warning("Worker %d: Shutdown encountered an error: %s", rank, exc)
worker_proc.context.term()
-1
View File
@@ -188,7 +188,6 @@ class OmniEngineArgs(EngineArgs):
omni: bool = False
# Diffusion request-mode batch admission (forwarded to OmniDiffusionConfig).
request_batch_max_wait_ms: float = 0.0
fa_deterministic: bool = False
@classmethod
def _add_omni_specific_args(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
-1
View File
@@ -1043,7 +1043,6 @@ class AsyncOmniEngine:
"diffusion_compile_dynamic": (
True if kwargs.get("diffusion_compile_dynamic") is None else kwargs["diffusion_compile_dynamic"]
),
"fa_deterministic": bool(kwargs.get("fa_deterministic", False)),
"boundary_ratio": kwargs.get("boundary_ratio", None),
"flow_shift": kwargs.get("flow_shift", None),
"diffusion_load_format": kwargs.get("diffusion_load_format", "default"),
-11
View File
@@ -434,17 +434,6 @@ class OmniServeCommand(CLISubcommand):
"Disable for fixed-shape workloads with --no-diffusion-compile-dynamic."
),
)
omni_config_group.add_argument(
"--fa-deterministic",
dest="fa_deterministic",
action="store_true",
default=False,
help=(
"Request FlashAttention deterministic=True on the local FLASH_ATTN dense path. "
"Slower than the library default deterministic=False; intended for accuracy CI. "
"Serving default remains non-deterministic."
),
)
omni_config_group.add_argument(
"--diffusers-load-kwargs",
dest="diffusers_load_kwargs",
+23 -79
View File
@@ -39,7 +39,6 @@ from vllm.entrypoints.mcp.tool_server import DemoToolServer, MCPToolServer, Tool
from vllm.entrypoints.openai.api_server import build_app as build_openai_app
from vllm.entrypoints.openai.api_server import setup_server as setup_openai_server
from vllm.entrypoints.openai.chat_completion.protocol import (
BatchChatCompletionRequest,
ChatCompletionRequest,
ChatCompletionResponse,
)
@@ -96,7 +95,6 @@ from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
from vllm_omni.config.endpoint_policy import shutdown_unsupported_routes
from vllm_omni.diffusion.models.interface import ReferenceVideoDecodeSpec
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.batch_serving import OmniOpenAIServingChatBatch
from vllm_omni.entrypoints.openai.duplex_capability import should_enable_duplex_endpoint
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
from vllm_omni.entrypoints.openai.image_api_utils import (
@@ -515,7 +513,6 @@ async def omni_run_server_worker(listen_address, sock, args, client_config=None,
# OMNI: Remove upstream routes that we override with omni-specific handlers
_remove_route_from_app(app, "/v1/chat/completions", {"POST"})
_remove_route_from_app(app, "/v1/chat/completions/batch", {"POST"})
_remove_route_from_app(app, "/v1/models", {"GET"}) # Remove upstream /v1/models to use omni's handler
app.include_router(router)
@@ -780,10 +777,6 @@ async def omni_init_app_state(
diffusion_engine=engine_client, # type: ignore
model_name=model_name,
)
state.openai_serving_chat_batch = OmniOpenAIServingChatBatch.for_diffusion(
diffusion_engine=engine_client, # type: ignore
model_name=model_name,
)
# audio related
state.openai_serving_speech = None
@@ -957,33 +950,30 @@ async def omni_init_app_state(
if "generate" in supported_tasks
else None
)
_chat_kwargs = dict(
engine_client=engine_client,
models=state.openai_serving_models,
response_role=args.response_role,
online_renderer=state.online_renderer,
request_logger=request_logger,
chat_template=resolved_chat_template,
chat_template_content_format=args.chat_template_content_format,
default_chat_template_kwargs=args.default_chat_template_kwargs,
trust_request_chat_template=args.trust_request_chat_template,
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
enable_auto_tools=args.enable_auto_tool_choice,
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
tool_parser=args.tool_call_parser,
reasoning_parser=args.structured_outputs_config.reasoning_parser,
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
enable_force_include_usage=args.enable_force_include_usage,
enable_log_outputs=args.enable_log_outputs,
enable_log_deltas=args.enable_log_deltas,
state.openai_serving_chat = (
OmniOpenAIServingChat(
engine_client,
state.openai_serving_models,
args.response_role,
online_renderer=state.online_renderer,
request_logger=request_logger,
chat_template=resolved_chat_template,
chat_template_content_format=args.chat_template_content_format,
default_chat_template_kwargs=args.default_chat_template_kwargs,
trust_request_chat_template=args.trust_request_chat_template,
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
enable_auto_tools=args.enable_auto_tool_choice,
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
tool_parser=args.tool_call_parser,
reasoning_parser=args.structured_outputs_config.reasoning_parser,
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
enable_force_include_usage=args.enable_force_include_usage,
enable_log_outputs=args.enable_log_outputs,
enable_log_deltas=args.enable_log_deltas,
)
if "generate" in supported_tasks
else None
)
state.openai_serving_chat = OmniOpenAIServingChat(**_chat_kwargs) if "generate" in supported_tasks else None
state.openai_serving_chat_batch = (
OmniOpenAIServingChatBatch(**_chat_kwargs) if "generate" in supported_tasks else None
)
# Warm up chat template processing to avoid first-request latency
if state.openai_serving_chat is not None:
state.openai_serving_chat.warmup()
@@ -1172,10 +1162,6 @@ def Omnichat(request: Request) -> OmniOpenAIServingChat | None:
return request.app.state.openai_serving_chat
def OmniBatchChat(request: Request) -> OmniOpenAIServingChatBatch | None:
return request.app.state.openai_serving_chat_batch
def Omnispeech(request: Request) -> OmniOpenAIServingSpeech | None:
return request.app.state.openai_serving_speech
@@ -1259,45 +1245,6 @@ async def create_chat_completion(request: ChatCompletionRequest, raw_request: Re
return StreamingResponse(content=generator, media_type="text/event-stream")
@router.post(
"/v1/chat/completions/batch",
dependencies=[Depends(validate_json_request)],
responses={
HTTPStatus.OK.value: {},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
HTTPStatus.NOT_IMPLEMENTED.value: {"model": ErrorResponse},
},
)
@with_cancellation
@load_aware_call
async def create_batch_chat_completion(request: BatchChatCompletionRequest, raw_request: Request):
handler = OmniBatchChat(raw_request)
if handler is None:
base_server = getattr(raw_request.app.state, "serving_tokenization", None)
if base_server is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND.value,
detail="The model does not support Chat Completions API",
)
return base_server.create_error_response(message="The model does not support Chat Completions API")
try:
result = await handler.create_batch_chat_completion(request, raw_request)
except (EngineGenerateError, EngineDeadError) as exc:
return _create_engine_error_json_response(raw_request, exc)
except Exception as e:
logger.exception("Batched chat completion failed: %s", e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)) from e
if isinstance(result, ErrorResponse):
return JSONResponse(
content=result.model_dump(),
status_code=result.error.code if result.error else 400,
)
return JSONResponse(content=result.model_dump(mode="json"))
_remove_route_from_router(router, "/v1/audio/speech", {"POST"})
@@ -2864,7 +2811,6 @@ def video_response_from_request(model_name: str, req: VideoGenerationRequest) ->
status=VideoGenerationStatus.QUEUED,
size=req.size,
prompt=req.prompt,
quality=req.quality or "default",
)
resp.seconds = str(req.seconds or resp.seconds)
return resp
@@ -3204,7 +3150,6 @@ async def _parse_video_form(
short_edge: int | None = Form(default=None, ge=1),
num_outputs_per_prompt: int = Form(default=1, ge=1, le=10),
start_time_seconds: float | None = Form(default=None, ge=0.0),
quality: str | None = Form(default=None),
num_inference_steps: int | None = Form(default=None),
guidance_scale: float | None = Form(default=None),
guidance_scale_2: float | None = Form(default=None),
@@ -3273,7 +3218,6 @@ async def _parse_video_form(
"short_edge": short_edge,
"num_outputs_per_prompt": num_outputs_per_prompt,
"start_time_seconds": start_time_seconds,
"quality": quality,
"num_inference_steps": num_inference_steps,
"guidance_scale": guidance_scale,
"guidance_scale_2": guidance_scale_2,
@@ -1,151 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import time
from fastapi import Request
from pydantic import ValidationError
from vllm.entrypoints.openai.chat_completion.protocol import (
BatchChatCompletionRequest,
ChatCompletionRequest,
ChatCompletionResponse,
ChatCompletionResponseChoice,
)
from vllm.entrypoints.openai.engine.protocol import (
ErrorResponse,
UsageInfo,
)
from vllm.logger import init_logger
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
logger = init_logger(__name__)
class OmniOpenAIServingChatBatch(OmniOpenAIServingChat):
@staticmethod
def _maybe_collapse_choices(choices: list[ChatCompletionResponseChoice]):
"""Collapse content + audio choices into one choice.
NOTE: This is a hack for models that produce separate text and
audio choices to ensure we have 1:1 mapping between number of
inputs in the batch and responses.
"""
content_choice, audio_choice = None, None
if len(choices) == 1:
return choices[0]
if len(choices) != 2:
raise ValueError(f"Expected 1 or 2 choices to collapse, got {len(choices)}. ")
for choice in choices:
if choice.message.audio:
if audio_choice is not None:
raise ValueError("Multiple audio choices cannot be set in one completion message")
audio_choice = choice
else:
if content_choice is not None:
raise ValueError("Multiple content choices cannot be set in one completion message")
content_choice = choice
if content_choice is None or audio_choice is None:
raise ValueError(
"Expected one content and one audio choice, but got: "
f"content={content_choice is not None}, audio={audio_choice is not None}"
)
content_choice.message.audio = audio_choice.message.audio
return content_choice
@staticmethod
def _get_subrequest_ids(base_id, request: BatchChatCompletionRequest) -> list[str]:
"""Get the request ID for each entry in the batch request to be resubmitted to chat completions."""
return [f"{base_id}-idx-{idx}" for idx in range(len(request.messages))]
async def create_batch_chat_completion(
self,
request: BatchChatCompletionRequest,
raw_request: Request,
) -> ChatCompletionResponse | ErrorResponse:
"""Given a request, submit each request to chat completions & collect the results."""
model = ""
enabled_streaming = False
chat_requests: list[ChatCompletionRequest] = []
choices: list[ChatCompletionResponseChoice] = []
total_prompt_tokens = 0
total_completion_tokens = 0
# Get the base request from the raw_request before maybe mutating the header
base_id = OmniOpenAIServingChatBatch._base_request_id(
raw_request,
request.request_id,
)
batch_req_id = f"chatcmpl-batch-{base_id}"
sub_req_ids = self._get_subrequest_ids(base_id, request)
# Remove the raw request header if it exists now that we have subreq IDs,
# since this takes priority over the object request_ids
if raw_request.headers.get("X-Request-Id"):
copy_headers = raw_request.headers.mutablecopy()
del copy_headers["X-Request-Id"]
raw_request._headers = copy_headers
for idx, msg in enumerate(request.messages):
# Update the current request ID to avoid subrequest collisions
request.request_id = sub_req_ids[idx]
try:
chat_cmp_request = request.to_chat_completion_request(msg)
except ValidationError as e:
return self._create_error_response(
f"Message {idx} could not be converted to a chat completion request: {e}",
)
# Streaming isn't supported for batch chat completions,
# so always set to False & warn if it was requested.
enabled_streaming |= chat_cmp_request.stream
chat_cmp_request.stream = False
chat_requests.append(chat_cmp_request)
if enabled_streaming:
logger.warning("Streaming is not supported for batched chat completions; ignoring stream=True.")
# Submit each chat completion request as a task, then gather results.
# TODO (Alex): optimize this
tasks = [asyncio.create_task(self.create_chat_completion(c, raw_request)) for c in chat_requests]
try:
results = await asyncio.gather(*tasks)
finally:
# Ensure we cancel remaining tasks if needed, e.g., early exit due to bad behavior
for t in tasks:
if not t.done():
t.cancel()
for i, resp in enumerate(results):
if isinstance(resp, ErrorResponse):
return resp
completion: ChatCompletionResponse = resp
model = completion.model
# FIXME (Alex): We should probably handle this in chat completions,
# not here, but we need to ensure streaming is properly handled
try:
collapsed_choice = self._maybe_collapse_choices(completion.choices)
except ValueError as e:
return self._create_error_response(
f"Failed to collapse choices with error: {e}",
)
collapsed_choice.index = i
choices.append(collapsed_choice)
if completion.usage:
total_prompt_tokens += completion.usage.prompt_tokens
total_completion_tokens += completion.usage.completion_tokens
usage = UsageInfo(
prompt_tokens=total_prompt_tokens,
completion_tokens=total_completion_tokens,
total_tokens=total_prompt_tokens + total_completion_tokens,
)
return ChatCompletionResponse(
id=batch_req_id,
created=int(time.time()),
model=model,
choices=choices,
usage=usage,
)
@@ -7,10 +7,7 @@ from collections.abc import Collection, Mapping
from vllm.logger import init_logger
from vllm_omni.inputs.data import (
DIFFUSION_QUALITY_LEVELS,
OmniDiffusionSamplingParams,
)
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
logger = init_logger(__name__)
@@ -93,9 +90,6 @@ def normalize_diffusion_request_args(
for alias, canonical_name in aliases.items():
if alias in request_args:
request_args[canonical_name] = request_args.pop(alias)
quality = request_args.get("quality")
if quality is not None and quality not in DIFFUSION_QUALITY_LEVELS:
raise ValueError(f"quality must be one of {list(DIFFUSION_QUALITY_LEVELS)}, got {quality!r}")
return normalized_extra_args, request_args
@@ -15,10 +15,9 @@ from enum import Enum
from functools import lru_cache
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
from vllm_omni.entrypoints.openai.image_api_utils import parse_size
from vllm_omni.inputs.data import DIFFUSION_QUALITY_LEVELS
class VideoGenerationStatus(str, Enum):
@@ -165,24 +164,6 @@ class VideoGenerationRequest(BaseModel):
)
# vllm-omni extensions for diffusion control
quality: str | None = Field(
default=None,
description=(
"Request-level generation quality intent. Supported values are "
"'lossless' and 'high'; exact behavior is model-specific. "
"When omitted, the model chooses its default policy."
),
)
@field_validator("quality")
@classmethod
def validate_quality(cls, value: str | None) -> str | None:
if value is None:
return None
if value not in DIFFUSION_QUALITY_LEVELS:
raise ValueError(f"quality must be one of {list(DIFFUSION_QUALITY_LEVELS)}, got {value!r}")
return value
negative_prompt: str | None = Field(default=None, description="Text describing what to avoid in the video")
num_inference_steps: int | None = Field(
default=None,
@@ -170,7 +170,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
"width",
"num_outputs_per_prompt",
"seed",
"quality",
"num_inference_steps",
"guidance_scale",
"true_cfg_scale",
@@ -760,11 +759,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
if hasattr(sp, "num_inference_steps") and num_inference_steps is not None:
sp.num_inference_steps = num_inference_steps
if isinstance(sp, OmniDiffusionSamplingParams):
quality = extra_body.get("quality")
self._set_if_supported(
sp,
quality=quality,
)
apply_normalized_diffusion_request_extra_args(sp, normalized_extra_args)
else:
apply_declared_extra_args(
@@ -2943,7 +2937,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
generator_device = gen_params.generator_device
num_outputs_per_prompt = gen_params.num_outputs_per_prompt
num_inference_steps = extra_body.get("num_inference_steps")
quality = extra_body.get("quality")
guidance_scale = extra_body.get("guidance_scale")
true_cfg_scale = extra_body.get("true_cfg_scale") or extra_body.get("cfg_scale")
negative_prompt = extra_body.get("negative_prompt")
@@ -3094,7 +3087,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
seed=seed,
generator_device=generator_device,
num_outputs_per_prompt=num_outputs_per_prompt,
quality=quality,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
true_cfg_scale=true_cfg_scale,
@@ -3159,7 +3151,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
self._set_if_supported(
gen_params,
generator_device=generator_device,
quality=extra_body.get("quality"),
num_inference_steps=extra_body.get("num_inference_steps"),
guidance_scale=extra_body.get("guidance_scale"),
true_cfg_scale=extra_body.get("true_cfg_scale") or extra_body.get("cfg_scale"),
@@ -3498,7 +3489,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
# method apply its own model-specific default when the user does
# not provide a value.
num_inference_steps = extra_body.get("num_inference_steps")
quality = extra_body.get("quality")
guidance_scale = extra_body.get("guidance_scale")
true_cfg_scale = extra_body.get("true_cfg_scale")
seed = extra_body.get("seed")
@@ -3553,8 +3543,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
# Only override defaults when the user explicitly provides values
if num_inference_steps is not None:
gen_params.num_inference_steps = num_inference_steps
if quality is not None:
gen_params.quality = quality
if guidance_scale is not None:
gen_params.guidance_scale = guidance_scale
if true_cfg_scale is not None:
@@ -200,8 +200,6 @@ class OmniOpenAIServingVideo:
if "num_inference_steps" in provided_fields and request.num_inference_steps is not None:
gen_params.num_inference_steps = request.num_inference_steps
if "quality" in provided_fields:
gen_params.quality = request.quality
if "guidance_scale" in provided_fields and request.guidance_scale is not None:
gen_params.guidance_scale = request.guidance_scale
if "guidance_scale_2" in provided_fields and request.guidance_scale_2 is not None:
@@ -535,8 +535,6 @@ class OmniStreamingVideoOutputHandler:
provided_fields = request.model_fields_set
if "num_inference_steps" in provided_fields and request.num_inference_steps is not None:
gen_params.num_inference_steps = request.num_inference_steps
if "quality" in provided_fields:
gen_params.quality = request.quality
if "guidance_scale" in provided_fields and request.guidance_scale is not None:
gen_params.guidance_scale = request.guidance_scale
if "guidance_scale_2" in provided_fields and request.guidance_scale_2 is not None:
-10
View File
@@ -11,8 +11,6 @@ from vllm.sampling_params import SamplingParams
from vllm_omni.lora.request import LoRARequest
DIFFUSION_QUALITY_LEVELS: tuple[str, ...] = ("lossless", "high")
class OmniTextPrompt(TextPrompt):
"""Text prompt with optional embeddings and stage payloads.
@@ -210,10 +208,6 @@ class OmniDiffusionSamplingParams:
do_classifier_free_guidance: bool = False
output_type: str | None = None
# Request-scoped quality intent. ``None`` delegates the default behavior
# to the model; explicit levels select a model-owned quality policy.
quality: str | None = None
# Batch info
num_outputs_per_prompt: int = 1
seed: int | None = None
@@ -343,10 +337,6 @@ class OmniDiffusionSamplingParams:
# results
output: torch.Tensor | None = None
def __post_init__(self) -> None:
if self.quality is not None and self.quality not in DIFFUSION_QUALITY_LEVELS:
raise ValueError(f"quality must be one of {list(DIFFUSION_QUALITY_LEVELS)}, got {self.quality!r}")
@property
def batch_size(self):
# This class is changed to only represent a single prompt request