Compare commits

...
Author SHA1 Message Date
hsliu_ustc 77dab60bf7 [Doc] Keep ROCm run examples on the published v0.28.0 image tag
The release pipeline builds and publishes only the CUDA image
(.buildkite/release/release-pipeline.yml builds docker/Dockerfile.cuda;
publish-release-images.sh targets vllm/vllm-omni). The prebuilt ROCm
image is uploaded out-of-band, and v0.28.0 is the latest tag confirmed
live on Docker Hub (#7405). Revert the two docker run examples to
v0.28.0 and state why the tag lags the 0.30.0 wheels, so users do not
hit a missing-tag pull failure. Source-build examples stay on the
upstream vllm/vllm-openai-rocm:v0.30.0 base, which is published.

Signed-off-by: hsliu_ustc <hsliu_ustc@noreply.gitcode.com>
2026-09-27 07:05:58 +08:00
hsliu_ustc c88408d825 [Doc] Update README and docs for v0.30.0
Announce the v0.30.0 release in the README news section (unified
full-duplex framework, cross-stage KV/multimodal transfer, LingBot
World interactive serving, MiniMax H3 Turbo on Blackwell/Ascend 950)
and refresh the supported-model examples (Tencent AuK, Breeze-TTS-2,
LingBot World, MAGI-2, pi0.5; drop removed dots.tts-era examples).

Point the pre-built wheel and image instructions at the published
0.30.0 artifacts (PyPI wheel verified; ROCm 0.30.0/rocm723 wheel index
verified), update the vLLM configuration and environment-variable
references from 0.29 to 0.30, and move the NPU source-clone tag from
v0.30.0rc1 to the final v0.30.0 release.

Signed-off-by: hsliu_ustc <hsliu_ustc@noreply.gitcode.com>
2026-09-26 17:53:59 +08:00
andyluo7 ad025defe6 [CI][Diffusion] Restore Krea2 model type marker (#8164)
Signed-off-by: andyluo7 <andy.luo@amd.com>
2026-09-26 12:55:06 +08:00
Khairul Kabirandkhairulkabir1661 bcb4ae288f [CI][Diffusion] Add tiny model builder for Krea2Pipeline (#6421)
Signed-off-by: khairulkabir1661 <khairulkabir1661@users.noreply.github.com>
Co-authored-by: khairulkabir1661 <khairulkabir1661@users.noreply.github.com>
2026-09-25 21:54:38 +00:00
Joshna-Medisetty 62ebad2d7c Add Intel Arc BMG recipes for validated P0 diffusion models. (#7537)
Signed-off-by: Joshna-Medisetty <joshna.medisetty@intel.com>
2026-09-25 15:42:08 -05:00
5e36b5a86b [Bugfix] Apply function module marker per-model instead of hardcoding diffusion (#5875)
Signed-off-by: khairulkabir1661 <khairulkabir1661@users.noreply.github.com>
Co-authored-by: khairulkabir1661 <khairulkabir1661@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 14:17:19 -06:00
Gao Han a8576ccb72 [Bugfix] Fix MiniCPM-o 4.5 seeded turns and window E2E inputs (#8160)
Signed-off-by: Gao Han <hgaoaf@connect.ust.hk>
2026-09-26 01:42:53 +08:00
Sy03 d31a2a3ba8 [Bugfix] Fix Nemotron VoiceChat duplex nightly handshake (#7884)
Signed-off-by: Sy03 <1370724210@qq.com>
2026-09-25 17:25:15 +00:00
Sy03 5d0963b3dc [Bugfix] Fix MRV2 payload slicing with CUDA Graph padding (#8159)
Signed-off-by: Sy03 <1370724210@qq.com>
2026-09-25 16:07:22 +00:00
wangyu 43e507117f [Bugfix] Restore torch default device between tests (#8157)
Signed-off-by: wangyu <410167048@qq.com>
2026-09-25 15:05:37 +00:00
chickeytonandClaude Opus 5.5 4be6bbe0e4 [Bugfix][MiniCPM-o] Build the Stage-0 duplex runtime at load time (#8146)
Signed-off-by: chickeyton <ngton2014@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:57:23 +00:00
bcsdhjew fe9e22df3d [Bugfix] Restore MiniCPM duplex Seed-TTS startup (#8152)
Signed-off-by: Nolen Liang <nliang@nvidia.com>
Signed-off-by: bcsdhjew <nliang@nvidia.com>
2026-09-25 14:52:46 +00:00
Yuanheng Zhao 3bd5ac9685 [Bugfix] Align Ming-Image prompt templates and RNG, fix number of layer to decompose (#8118)
Signed-off-by: Yuanheng <jonathan.zhaoyh@gmail.com>
2026-09-25 22:47:29 +08:00
b41e6707b1 [Bugfix][MammothModa2] Stop allocating AR KV cache for the replaced language model (#8095)
Signed-off-by: MrlixiangWE <mrdanaer@gmail.com>
Co-authored-by: zijianc2 <157244773+zijianc2@users.noreply.github.com>
Co-authored-by: hsliuustc0106 <222337142+hsliuustc0106@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-25 21:28:59 +08:00
29dacbd3ef [Bugfix][MammothModa2] Keep AR layer 0 in BF16 in the FP8 KV preset (#8083)
Signed-off-by: MrlixiangWE <mrdanaer@gmail.com>
Co-authored-by: zijianc2 <157244773+zijianc2@users.noreply.github.com>
Co-authored-by: hsliuustc0106 <222337142+hsliuustc0106@users.noreply.github.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-25 21:25:49 +08:00
NATURE 9f3f475685 [CI/Build] Fix MiniCPM-o codec readback test under default-device dispatch (#8147) 2026-09-25 08:45:05 +00:00
95781c31e0 [Bugfix][Diffusion] Skip AllGather DLO weight restore on worker shutdown (#8103)
Signed-off-by: specture724 <specture724@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
2026-09-25 08:33:32 +00:00
R0CKSTARandHongsheng Liu 3bf96080f3 feat(magi2): add fused mHC post-processing (#7261)
Signed-off-by: Xiaodong Ye <xiaodong.ye@mthreads.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-25 16:25:12 +08:00
0c7042d0d5 [Feature][Diffusion] Add BOOGU Image sequence parallel support (#5718)
Signed-off-by: Xenoryn <timberh2000@outlook.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-09-25 16:18:23 +08:00
NumberWan e5613794ca [BugFix][Cosmos3] Decode uploaded video paths in v2v preprocess (#8106)
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
2026-09-25 07:53:24 +00:00
Gao Han 69de153f6e [Bugfix] Stop cancelled MiniMax-H3 requests at denoising boundaries (#7844)
Signed-off-by: Gao Han <hgaoaf@connect.ust.hk>
2026-09-25 13:36:09 +08:00
BeatSeatandamy-why-3459 b308e196c9 [Model] Support request-level batching for MammothModa2 DiT on shared runtime (#7476)
Signed-off-by: BeatSeat <wendavid552@gmail.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-09-25 10:34:59 +08:00
9e7e3a802c [Bugfix] Compensate cancelled duplex open, append and resume; fix serving README drift (#7636 Issues 1, 2, 3, 21) (#7785)
Signed-off-by: chickeyton <ngton2014@gmail.com>
Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-09-24 19:23:08 -07:00
Sy03 551a03102f [Bugfix][Qwen3-TTS] Decode full payload from model buffer (#8132)
Signed-off-by: Sy03 <1370724210@qq.com>
2026-09-25 10:03:58 +08:00
f46c958b67 [Bugfix][Qwen3-Omni] Restore the talker sampling seed (#7474)
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-09-24 17:41:03 -07:00
513dba842a [BugFix] Add modalities and logprobs validation for /v1/chat/completions (#4743)
Signed-off-by: Shaun Walsh <shaunwalsh24@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alex Brooks <albrooks@redhat.com>
2026-09-24 18:09:19 -06:00
Jim BanandYueqian Lin 4aac1294f1 [Bugfix][Frontend] Surface streaming terminal submit failures (#7011)
Signed-off-by: BANANASJIM <bananasjim1@gmail.com>
Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-09-24 16:50:43 -07:00
6fb2b36a81 [CI][Diffusion] Add acceleration test groups for SD3 and Flux2 (#6625)
Signed-off-by: khairulkabir1661 <khairulkabir1661@users.noreply.github.com>
Co-authored-by: khairulkabir1661 <khairulkabir1661@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-24 17:19:45 -06:00
Khairul Kabirandkhairulkabir1661 29d13e3311 [CI][Diffusion] Add tiny model builder for StableDiffusion3Pipeline (#6423)
Signed-off-by: khairulkabir1661 <khairulkabir1661@users.noreply.github.com>
Co-authored-by: khairulkabir1661 <khairulkabir1661@users.noreply.github.com>
2026-09-24 19:23:28 +00:00
Joshna-Medisetty b539282a9a [Bugfix][XPU] Enable LTX-2 family on XPU (#7961)
Signed-off-by: Joshna Medisetty <joshna.medisetty@intel.com>
2026-09-24 13:57:38 -05:00
151 changed files with 9128 additions and 1514 deletions

No files matched your search

+1
View File
@@ -69,6 +69,7 @@ steps:
source_file_dependencies: diffusion_distributed_attention
commands:
- timeout 20m pytest -sv tests/diffusion/distributed -m 'core_model and cuda and L4 and cards_2' --run-level "core_model"
- timeout 10m pytest -sv tests/diffusion/attention/test_ulysses_uaa.py -m 'core_model and cuda and L4 and cards_2' --run-level "core_model"
mirror_hardwares: l4_2
- label: "Diffusion · Distributed & Attention Test · 4-GPU"
+4 -3
View File
@@ -16,6 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone
*Latest News* 🔥
- [2026/09] We released [0.30.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.30.0), rebased onto vLLM 0.30.0, featuring a unified full-duplex serving framework around engine-owned sessions for [MiniCPM-o 4.5](recipes/OpenBMB/MiniCPM-o-4_5.md) and AURA, native cross-stage KV and multimodal payload transfer (Mooncake AR-to-DiT handoff and NIXL connectors), interactive world-model serving with [LingBot World](recipes/Robbyant/LingBot-World-2.0.md), and realtime [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) Turbo inference on NVIDIA Blackwell and Ascend 950.
- [2026/08] We released [0.28.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.28.0), featuring production-ready [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) serving on GPU and NPU, a unified AR/DiT paged KV cache runtime, and enhanced realtime full-duplex serving for the [MiniCPM-o series](recipes/OpenBMB/MiniCPM-o-4_5.md).
- [2026/08] [VeRL-Omni](https://github.com/verl-project/verl-omni) `v0.2.0` is released: faster diffusion RL powered by vLLM-Omni (request-level/step-wise batching with FA3), rebuilt Qwen3-Omni multimodal training (DPO & GSPO), plus LTX-2.3, Qwen-Image-Edit support and more. See the [release notes](https://github.com/verl-project/verl-omni/releases/tag/v0.2.0).
- [2026/08] We released [0.26.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.26.0) - aligned with the vLLM 0.26 release line, featuring [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) joint video/audio generation, an experimental full-duplex realtime runtime for [MiniCPM-o 4.5](recipes/OpenBMB/MiniCPM-o-4_5.md), distributed layerwise diffusion offload, and broader model, hardware, streaming, TTS, and quantization support.
@@ -58,9 +59,9 @@ vLLM-Omni is flexible and easy to use with:
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- **Omni-modality models** (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL)
- **TTS models** (e.g. Qwen3-TTS, IndexTTS 2.5, CosyVoice3)
- **Diffusion models** — image, video, and audio generation (e.g. MiniMax H3, LTX-2.5, SANA-Video, Wan2.2)
- **Robot-policy and action models** (e.g. π0, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
- **TTS models** (e.g. Qwen3-TTS, Tencent AuK, Breeze-TTS-2, CosyVoice3)
- **Diffusion models** — image, video, and audio generation (e.g. MiniMax H3, LingBot World, MAGI-2, LTX-2.5, Wan2.2)
- **Robot-policy and action models** (e.g. π0.5, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
## Getting Started
+194
View File
@@ -0,0 +1,194 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Cold-cache torch.cuda timing and correctness gates for MAGI-2 microbenchmarks."""
import argparse
import importlib
import importlib.metadata
import json
import logging
import math
import os
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
from typing import Any
LOGGER = logging.getLogger(__name__)
def emit(**fields: Any) -> None:
LOGGER.info(json.dumps(fields, sort_keys=True))
def add_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--tokens", type=int, nargs="+", default=[1, 4, 16, 64, 129, 4096])
parser.add_argument("--dtype", choices=["bfloat16", "float16", "float32"], default="bfloat16")
parser.add_argument("--num-tests", type=int, default=30, help="Measured samples per implementation.")
parser.add_argument("--warmup", type=int, default=10, help="Interleaved warmup calls before sampling.")
parser.add_argument("--seed", type=int, default=42)
def load_musa(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Any:
if min(*args.tokens, args.num_tests) <= 0 or args.warmup < 0:
parser.error("tokens and num-tests must be positive; warmup must be nonnegative")
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
torch = importlib.import_module("torch")
cuda = getattr(torch, "cuda")
# torchada is needed only on MUSA; after it is loaded, use torch.cuda for
# both CUDA and MUSA as documented by torchada.
if hasattr(torch.version, "musa") and torch.version.musa is not None:
try:
importlib.import_module("torchada")
except ModuleNotFoundError as error:
if error.name != "torchada":
raise
has_gpu = cuda.is_available() or (
hasattr(torch.version, "musa") and torch.version.musa is not None and torch.musa.is_available()
)
if not has_gpu:
emit(status="skip", reason="CUDA/MUSA device is unavailable", torch=torch.__version__)
return None
torch.manual_seed(args.seed)
cuda.manual_seed_all(args.seed)
return torch
def report_environment(torch: Any, args: argparse.Namespace, module: ModuleType) -> None:
root = Path(__file__).resolve().parents[2]
packages = {}
for name in ("torch", "torch_musa", "torchada", "triton", "vllm", "vllm-omni"):
try:
packages[name] = importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
packages[name] = None
cuda = getattr(torch, "cuda")
properties = cuda.get_device_properties(cuda.current_device())
try:
source_sha = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
source_dirty = bool(subprocess.check_output(["git", "-C", str(root), "status", "--porcelain"], text=True))
except (subprocess.CalledProcessError, FileNotFoundError):
source_sha = os.environ.get("MAGI2_BENCH_SOURCE_SHA", "unknown")
source_dirty = None
emit(
status="environment",
command=sys.argv,
source_root=str(root),
source_sha=source_sha,
source_dirty=source_dirty,
kernel_source=module.__file__,
device=cuda.get_device_name(cuda.current_device()),
sm_count=getattr(properties, "multi_processor_count", None),
packages=packages,
env={name: os.environ.get(name) for name in ("MUSA_VISIBLE_DEVICES", "TORCHDYNAMO_DISABLE")},
config=vars(args),
mode="eager operator diagnostic; no compile or graph capture",
comparison="forward_native versus the default CustomOp dispatch",
timing="torch.cuda.Event; alternating native/fused calls; vanilla 512 MiB flush outside timed regions",
timing_scope="whole callable, including device launch gaps; not isolated kernel duration",
l2_flush=True,
l2_flush_bytes=512 * 1024 * 1024,
)
class _LaunchProbe:
def __init__(self, kernel: Any) -> None:
self.kernel = kernel
self.count = 0
def __getitem__(self, grid: Any) -> Any:
self.count += 1
return self.kernel[grid]
def check_pair(
torch: Any,
reference: Callable,
fused: Callable,
module: ModuleType,
kernels: tuple[str, ...],
*,
rtol: float,
atol: float,
) -> dict:
cuda = getattr(torch, "cuda")
expected = reference()
probes = {name: _LaunchProbe(getattr(module, name)) for name in kernels}
try:
for name, probe in probes.items():
setattr(module, name, probe)
actual = fused()
cuda.synchronize()
finally:
for name, probe in probes.items():
setattr(module, name, probe.kernel)
if any(probe.count != 1 for probe in probes.values()):
raise RuntimeError(f"Expected one launch per fused kernel; got {[(k, p.count) for k, p in probes.items()]}")
expected = expected if isinstance(expected, tuple) else (expected,)
actual = actual if isinstance(actual, tuple) else (actual,)
errors = []
for result, baseline in zip(actual, expected, strict=True):
for value in (baseline, result):
if not torch.isfinite(value).all().item():
raise AssertionError("Nonfinite benchmark output")
if value.numel() > 1 and (value == value.flatten()[0]).all().item():
raise AssertionError("Constant benchmark output")
torch.testing.assert_close(result, baseline, rtol=rtol, atol=atol)
difference = (result.float() - baseline.float()).abs()
errors.append(
{
"max_abs": difference.max().item(),
"max_rel": (difference / baseline.float().abs().clamp_min(1e-6)).max().item(),
}
)
return {"parity": "pass", "errors": errors, "rtol": rtol, "atol": atol, "kernels": list(kernels)}
def _percentile(samples: list[float], quantile: float) -> float:
ordered = sorted(samples)
position = (len(ordered) - 1) * quantile
lower, upper = math.floor(position), math.ceil(position)
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
def measure_pair(torch: Any, reference: Callable, fused: Callable, args: argparse.Namespace) -> dict:
cuda = getattr(torch, "cuda")
functions = (reference, fused)
order: list[int] = []
def alternate() -> Any:
mode = len(order) % 2
order.append(mode)
return functions[mode]()
times_ms = []
for _ in range(args.warmup):
alternate()
cuda.synchronize()
order.clear()
flush = torch.empty(512 * 1024 * 1024, dtype=torch.uint8, device="cuda")
for _ in range(2 * args.num_tests):
flush.zero_()
cuda.synchronize()
start = cuda.Event(enable_timing=True)
end = cuda.Event(enable_timing=True)
start.record()
alternate()
end.record()
end.synchronize()
times_ms.append(start.elapsed_time(end))
samples: tuple[list[float], list[float]] = ([], [])
for mode, milliseconds in zip(order[-len(times_ms) :], times_ms, strict=True):
if not math.isfinite(milliseconds) or milliseconds <= 0:
raise RuntimeError(f"Invalid GPU event sample: {milliseconds}")
samples[mode].append(milliseconds * 1000)
stats = {}
for label, values in zip(("native", "fused"), samples, strict=True):
if len(values) != args.num_tests:
raise RuntimeError("GPU timing harness returned an unexpected sample count")
p50 = _percentile(values, 0.5)
stats[label] = {"median_us": p50, "p50_us": p50, "p90_us": _percentile(values, 0.9), "samples": len(values)}
stats["speedup_p50"] = stats["native"]["p50_us"] / stats["fused"]["p50_us"]
return stats
+141
View File
@@ -0,0 +1,141 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Compare native and fused MAGI-2 mHC post-processing on CUDA or MUSA.
Run from the checkout root with PyTorch and the selected backend dependencies.
torchada is loaded automatically only on MUSA::
python -m benchmarks.kernels.benchmark_magi2_mhc \
--tokens 1 4 16 64 129 4096 --hidden-size 3072 --num-tests 30
The released config supplies hidden=3072, four streams and 20 Sinkhorn steps.
Token counts are synthetic; supply per-rank sizes from the target trace when
comparing production shapes. FP32 post/residual logits are strided views of the
same [tokens, 24] projection, as in MHCHandler.compute_logits. Mix inputs use
the selected dtype. Both mix implementations consume identical coefficients.
The three comparisons cover coefficient preparation, stream mixing, and their
combined pipeline (including intermediate allocations, excluding projection,
normalization and apply_pre). This is an eager operator diagnostic, without
compile or graph capture. It does not measure full model performance.
No CUDA/MUSA device (or no PyTorch) produces a JSON skip record and exit status zero.
"""
import argparse
import math
from functools import partial
from typing import Any
from benchmarks.kernels._magi2_gpu_bench import (
add_arguments,
check_pair,
emit,
load_musa,
measure_pair,
report_environment,
)
def benchmark_shape(torch: Any, mhc: Any, args: argparse.Namespace, tokens: int) -> None:
dtype = getattr(torch, args.dtype)
device = torch.device("cuda")
packed = torch.randn(tokens, 24, device=device, dtype=torch.float32)
# Preserve the token stride of the model's shared projection allocation.
post_inputs = (
packed[:, 4:8],
packed[:, 8:].view(tokens, 4, 4),
torch.tensor([0.8], device=device),
torch.randn(4, device=device),
torch.tensor([1.1], device=device),
torch.randn(4, 4, device=device),
)
streams = torch.randn(tokens, 4, args.hidden_size, device=device, dtype=dtype)
branch = torch.randn(tokens, args.hidden_size, device=device, dtype=dtype)
post_op, mix_op = mhc.MHCPostResidual(), mhc.MHCMix()
scale = 1 / math.sqrt(4 * args.hidden_size)
kwargs = dict(scale=scale, iterations=args.sinkhorn_iterations, epsilon=1e-12, out_dtype=dtype)
prepare_reference = partial(post_op.forward_native, *post_inputs, **kwargs)
prepare_fused = partial(post_op, *post_inputs, **kwargs)
coefficients = prepare_reference()
mix_reference = partial(mix_op.forward_native, streams, branch, *coefficients)
mix_fused = partial(mix_op, streams, branch, *coefficients)
post_kernel, mix_kernel = "_mhc_post_residual_kernel", "_mhc_mix_kernel"
cases = {
"post_residual": (prepare_reference, prepare_fused, (post_kernel,)),
"mix": (mix_reference, mix_fused, (mix_kernel,)),
"pipeline": (
lambda: mix_op.forward_native(streams, branch, *prepare_reference()),
lambda: mix_op(streams, branch, *prepare_fused()),
(post_kernel, mix_kernel),
),
}
rtol = {torch.bfloat16: 1.6e-2, torch.float16: 2e-3, torch.float32: 1e-5}[dtype]
for name, (reference, fused, kernels) in cases.items():
if args.op != "all" and args.op != name:
continue
# BF16 MUSA custom ops can quantize values near zero to the adjacent
# representable value; retain the strict relative gate while allowing
# one BF16 ulp in the absolute comparison.
atol = (
(1e-2 if dtype == torch.bfloat16 else 1e-6)
if name == "post_residual"
else (1e-2 if dtype == torch.bfloat16 else 1e-5)
)
parity = check_pair(torch, reference, fused, mhc, kernels, rtol=rtol, atol=atol)
timing = measure_pair(torch, reference, fused, args)
# Logical minimum traffic: input reads plus output writes. Native
# intermediates and repeat parameter loads can add actual DRAM traffic.
element_bytes = streams.element_size()
prepare_bytes = tokens * 20 * (4 + element_bytes) + 22 * 4
mix_bytes = tokens * (9 * args.hidden_size + 20) * element_bytes
minimum_bytes = {"post_residual": prepare_bytes, "mix": mix_bytes, "pipeline": prepare_bytes + mix_bytes}[name]
# Sigmoid/exp costs and max reductions are not represented as FLOPs.
prepare_flops = tokens * (72 + 64 * args.sinkhorn_iterations)
mix_flops = tokens * args.hidden_size * 36
scalar_flops = {"post_residual": prepare_flops, "mix": mix_flops, "pipeline": prepare_flops + mix_flops}[name]
emit(
status="pass",
op=name,
tokens=tokens,
hidden_size=args.hidden_size,
num_streams=4,
dtype=args.dtype,
logits_dtype="float32",
packed_shape=list(packed.shape),
post_stride=list(post_inputs[0].stride()),
residual_stride=list(post_inputs[1].stride()),
scale=scale,
sinkhorn_iterations=args.sinkhorn_iterations,
epsilon=1e-12,
minimum_io_bytes=minimum_bytes,
scalar_flops_excluding_exp_and_max=scalar_flops,
effective_fused_gbps=minimum_bytes / timing["fused"]["p50_us"] / 1000,
**parity,
**timing,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
add_arguments(parser)
parser.add_argument("--hidden-size", type=int, default=3072)
parser.add_argument("--sinkhorn-iterations", type=int, default=20)
parser.add_argument("--op", choices=["all", "post_residual", "mix", "pipeline"], default="all")
args = parser.parse_args()
if args.hidden_size <= 0 or not 0 <= args.sinkhorn_iterations <= 64:
parser.error("hidden-size must be positive; sinkhorn-iterations must be in [0, 64]")
torch = load_musa(args, parser)
if torch is None:
return
cuda = getattr(torch, "cuda")
from vllm_omni.diffusion.layers import mhc
report_environment(torch, args, mhc)
with torch.inference_mode():
for tokens in args.tokens:
benchmark_shape(torch, mhc, args, tokens)
cuda.empty_cache()
if __name__ == "__main__":
main()
+3 -3
View File
@@ -56,9 +56,9 @@ vLLM-Omni is flexible and easy to use with:
vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including:
- Omni-modality models (e.g. Qwen3-Omni, MiniCPM-o 4.5, Cosmos3, HunyuanImage, BAGEL)
- TTS models (e.g. Qwen3-TTS, IndexTTS 2.5, CosyVoice3)
- Diffusion models — image, video, and audio generation (e.g. MiniMax H3, LTX-2.5, SANA-Video, Wan2.2)
- Robot-policy and action models (e.g. π0, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
- TTS models (e.g. Qwen3-TTS, Tencent AuK, Breeze-TTS-2, CosyVoice3)
- Diffusion models — image, video, and audio generation (e.g. MiniMax H3, LingBot World, MAGI-2, LTX-2.5, Wan2.2)
- Robot-policy and action models (e.g. π0.5, GR00T-N1.7, DreamZero-DROID, InternVLA-A1)
For more information, checkout the following:
+1 -1
View File
@@ -2,7 +2,7 @@
This section lists the most common options for running vLLM-Omni.
For options within a vLLM Engine, please refer to the [vLLM 0.29 configuration guide](https://docs.vllm.ai/en/v0.29.0/configuration/index.html).
For options within a vLLM Engine, please refer to the [vLLM 0.30 configuration guide](https://docs.vllm.ai/en/v0.30.0/configuration/index.html).
Each model defines fixed topology in a registered `PipelineConfig` and runtime
overrides in a deploy YAML.
+1 -1
View File
@@ -162,7 +162,7 @@ their keys only.
## Inherited vLLM variables
vLLM-Omni also reads variables through its aligned vLLM dependency. Refer to
the [vLLM 0.29 environment-variable reference](https://docs.vllm.ai/en/v0.29.0/configuration/env_vars.html)
the [vLLM 0.30 environment-variable reference](https://docs.vllm.ai/en/v0.30.0/configuration/env_vars.html)
for their definitions. This includes vLLM launch, cache, logging, plugin, ROCm,
XPU, ModelScope, and FlashInfer workspace settings.
+16 -9
View File
@@ -242,10 +242,12 @@ vllm_omni/
│ └── overlap_policy.py / commit_policy.py / playback_ledger.py
├── config/stage_config.py PipelineConfig.duplex_plugin; DuplexSessionRuntimeConfig
├── model_executor/models/minicpmo_4_5/duplex/plugin.py MiniCPMO45DuplexPlugin (+ data_plane, input, policy, ...)
├── model_executor/models/nemotron_voicechat/duplex/plugin.py NemotronVoiceChatDuplexPlugin (+ data_plane, input, capabilities)
└── clients/
├── duplex.py DuplexClientBase (ABC), DuplexClient (websocket), client-side events
├── inline_duplex.py InlineDuplexClient (in-process, over DuplexOmni)
├── minicpmo_4_5.py MiniCPM-o 4.5 session preset
├── nemotron_voicechat.py Nemotron VoiceChat session preset
└── personaplex.py PersonaPlex session preset
```
@@ -283,8 +285,13 @@ command handle.submit(DuplexCommand)
-> DuplexSessionManager.dispatch: unknown_session / input_backpressure checks, then runner mailbox
output DuplexOrchestrator._intercept_stage_output -> runner.on_stage_output -> mailbox -> typed events
-> output_sink (DuplexSessionEventMessage) -> DuplexOmni._route_engine_message -> handle.events()
detach DuplexOmni.detach_session -> touch(DETACH): engine-owned disconnect grace; expiry -> SessionExpired
resume DuplexOmni.resume_session(expected_lease_generation) -> lease CAS; the existing handle is re-entered
detach DuplexOmni.detach_session(expected_lease_generation) -> touch(DETACH): engine-owned disconnect
grace, refused for a lease newer than the one the caller held; expiry -> SessionExpired
resume DuplexOmni.resume_session(expected_lease_generation) -> lease CAS keyed by the control id (a replay
answers with the generation it produced); the existing handle is re-entered. A caller cancelled
mid-RPC observes the outcome afterwards (replaying until the engine answers) and settles a landed
resume: the generation goes to the resume waiting to activate, else to the connection still
attached, else the lease is detached again
close DuplexOmni.close_session -> close RPC; the manager tears the runner down, then the stage cleanup
(abort submitted requests, release reserved ids), then SessionClosed, then the RPC result.
SessionClosed is emitted after the cleanup attempt (in a finally), so it is also sent when the
@@ -383,7 +390,7 @@ policy that used to be two separately configured objects:
| Half | Members |
| --- | --- |
| engine policy | `configure_sampling_params(runtime_config, defaults)`, `plan_append(...) -> DuplexAppendPlan` (the resumable Stage0 prompt for one unit), `decide_output(...) -> DuplexOutputDecision \| None` (e.g. the listen decision on a finished Stage0 segment) |
| session policy | `capabilities(max_sessions)`, `validate_client_extra_body`, `prepare_runtime_config(config, model_config)` (server-owned runtime keys, reference audio resolution), `runtime_config_for_update`, `runtime_config_for_function_output`, `create_session_state() -> DuplexModelSessionState`, `data_plane: DuplexDataPlane` (projects raw stage outputs into internal events), `data_plane_context(...)` |
| session policy | `capabilities(max_sessions)`, `validate_client_extra_body`, `prepare_runtime_config(config, model_config)` (server-owned runtime keys, reference audio resolution), `runtime_config_for_update`, `runtime_config_for_function_output`, `runtime_config_after_model_output` (consumption acknowledgement), `create_session_state() -> DuplexModelSessionState`, `data_plane: DuplexDataPlane` (projects raw stage outputs into internal events), `data_plane_context(...)` |
`DuplexOmniEngine._validate_deployment` loads the plugin before any stage
starts; `DuplexSessionManager.__init__` validates it against the stage
@@ -391,12 +398,12 @@ sampling defaults once the stage pools exist. Plugin hooks that may block
(`prepare_runtime_config` fetching `ref_audio`) are awaited in `open()` and
offloaded from the loop.
The MiniCPM-o 4.5 plugin (`model_executor/models/minicpmo_4_5/duplex/plugin.py`)
is the only integration in this framework version. PersonaPlex and Nemotron
VoiceChat still carry their pre-framework duplex code (runtime extension plus
serving adapter) and are therefore not served over this framework yet: their
pipelines declare no `duplex_plugin`, so they run turn-based until the
follow-up PRs port them (RFC vllm-omni#7181, PR 2/3).
Frame-based plugins use `engine/duplex/intermediate.py::build_duplex_append_prompt`
for the shared request identity, sequencing and config snapshots. Token budgets,
PCM framing and model-specific worker fields remain in the plugins.
See [supported models and deployments](../serving/full_duplex_api.md#enable-full-duplex)
for the current plugin integrations and deployment configurations.
## Serving
+1 -1
View File
@@ -1,7 +1,7 @@
# Installation
!!! important
vLLM-Omni is released against the matching upstream vLLM major/minor version. The 0.30 development line requires vLLM 0.30.x; the source-install instructions pin vLLM 0.30.0. Published vLLM-Omni 0.28.0 wheels and images still require vLLM 0.28.x.
vLLM-Omni is released against the matching upstream vLLM major/minor version. The vLLM-Omni 0.30.x release line uses vLLM 0.30.x; the source-install instructions pin vLLM 0.30.0.
vLLM-Omni supports the following hardware platforms:
+1 -3
View File
@@ -34,9 +34,7 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr
### Pre-built wheels
The source-install instructions target vLLM 0.30.0. Until vLLM-Omni 0.30.0 artifacts are published, the CUDA/ROCm pre-built examples below remain on the matching 0.28.0 release pair.
Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, 0.25.0, 0.26.0, and 0.28.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source).
Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, 0.25.0, 0.26.0, 0.28.0, and 0.30.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source).
=== "NVIDIA CUDA"
@@ -5,7 +5,7 @@
# --8<-- [end:requirements]
# --8<-- [start:set-up-using-python]
vLLM-Omni depends on the matching major/minor release of vLLM. The 0.30 development line uses vLLM 0.30.x. Published 0.28.0 wheels and images use vLLM 0.28.x.
vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni 0.30.x release line uses vLLM 0.30.x.
!!! note
PyTorch installed via `conda` will statically link `NCCL` library, which can cause issues when vLLM tries to use `NCCL`. See <gh-issue:8420> for more details.
@@ -18,22 +18,22 @@ Therefore, it is recommended to install vLLM and vLLM-Omni with a **fresh new**
#### Installation of vLLM
These pre-built wheel instructions install the published vLLM-Omni 0.28.0 release. For the 0.30 development line, use the source-install instructions below.
These pre-built wheel instructions install the published vLLM-Omni 0.30.0 release.
vLLM-Omni is built based on vLLM. Please install it with command below.
```bash
uv pip install vllm==0.28.0 --torch-backend=auto
uv pip install vllm==0.30.0 --torch-backend=auto
```
#### Installation of vLLM-Omni
```bash
uv pip install vllm-omni==0.28.0
uv pip install vllm-omni==0.30.0
```
To run Gradio demos, also install the optional extras:
```bash
uv pip install 'vllm-omni[demo]==0.28.0'
uv pip install 'vllm-omni[demo]==0.30.0'
```
# --8<-- [end:pre-built-wheels]
@@ -111,7 +111,7 @@ docker run --runtime nvidia --gpus 2 \
--env "HF_TOKEN=$HF_TOKEN" \
-p 8091:8091 \
--ipc=host \
vllm/vllm-omni:v0.28.0 \
vllm/vllm-omni:v0.30.0 \
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
```
@@ -10,9 +10,8 @@
For ROCm, vLLM-Omni currently recommends the setup steps through Docker Images.
vLLM-Omni depends on the matching major/minor release of vLLM. The 0.30
development line uses vLLM 0.30.x. Published 0.28.0 wheels and images use vLLM
0.28.x.
vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni
0.30.x release line uses vLLM 0.30.x.
The Dockerfile's `BASE_IMAGE` pin applies only to Docker builds. The
`vllm-omni` package does not install vLLM as a dependency, so non-Docker source
@@ -23,12 +22,12 @@ installing vLLM-Omni, as shown below.
#### Installation of vLLM
These pre-built wheel instructions install the published vLLM-Omni 0.28.0 release. For the 0.30 development line, use the source-install instructions below.
These pre-built wheel instructions install the published vLLM-Omni 0.30.0 release.
vLLM-Omni is built based on vLLM. Please install it with command below.
```bash
uv pip install vllm==0.28.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.28.0/rocm723
uv pip install vllm==0.30.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.30.0/rocm723
```
#### Installation of vLLM-Omni
@@ -37,7 +36,7 @@ uv pip install vllm==0.28.0+rocm723 --extra-index-url https://wheels.vllm.ai/roc
# we need to add --no-build-isolation as the torch
# is not obtained from pypi, we have to install using the
# torch installed in our environment
uv pip install vllm-omni==0.28.0
uv pip install vllm-omni==0.30.0
# Optional if want to run Qwen3 TTS
uv pip uninstall onnxruntime # should be removed before we can install onnxruntime-rocm
@@ -150,6 +149,8 @@ vllm-omni-rocm
vLLM-Omni offers an official docker image for deployment. These images are built on top of vLLM docker images and available on Docker Hub as [vllm/vllm-omni-rocm](https://hub.docker.com/r/vllm/vllm-omni-rocm/tags). The version of vLLM-Omni indicates which release of vLLM it is based on.
The prebuilt ROCm image is published separately from the release pipeline: `v0.28.0` is the latest tag currently available on Docker Hub, and newer-tag availability is tracked in [#7405](https://github.com/vllm-project/vllm-omni/issues/7405).
#### Launch vLLM-Omni Server
Here's an example deployment command that has been verified on 2 x MI300's:
@@ -53,7 +53,7 @@ docker run --rm \
# Inside the container, install vLLM-Omni from source
cd /vllm-workspace
git clone -b v0.30.0rc1 https://github.com/vllm-project/vllm-omni.git
git clone -b v0.30.0 https://github.com/vllm-project/vllm-omni.git
cd vllm-omni
pip install -v -e . --no-build-isolation
# or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e .
+21 -21
View File
@@ -19,9 +19,9 @@ th {
| Architecture | Models | Example HF Models | NVIDIA GPU | AMD GPU | Ascend NPU | Intel GPU | Recipe |
| -------------- | -------- | ------------------- | ------------ | --------- | ----- | ----------- | -------- |
| `Qwen3OmniMoeForConditionalGeneration` | Qwen3-Omni | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Qwen/Qwen3-Omni.md) |
| `Qwen3OmniMoeForConditionalGeneration` | Qwen3-Omni | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Qwen/Qwen3-Omni.md) |
| `Qwen2_5OmniForConditionalGeneration` | Qwen2.5-Omni | `Qwen/Qwen2.5-Omni-7B`, `Qwen/Qwen2.5-Omni-3B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `MingFlashOmniForConditionalGeneration` + `MingImagePipeline` | Ming-flash-omni-2.0 (omni-speech + imagegen<sup>1</sup>) | `Jonathan1909/Ming-flash-omni-2.0` | ✅︎ | | | | — |
| `MingFlashOmniForConditionalGeneration` + `MingImagePipeline` | Ming-flash-omni-2.0 (omni-speech + imagegen<sup>1</sup>) | `Jonathan1909/Ming-flash-omni-2.0` | ✅︎ | | | ✅︎ | — |
| `BagelForConditionalGeneration` | BAGEL (DiT-only) | `ByteDance-Seed/BAGEL-7B-MoT` | ✅︎ | ✅︎ | | ✅︎ | — |
| `InternVLAA1Pipeline` | InternVLA-A1 | `InternRobotics/InternVLA-A1-3B` | ✅︎ | ✅︎ | | | — |
| `Gr00tN1d7Pipeline` | GR00T N1.7 | `nvidia/GR00T-N1.7-3B` | ✅︎ | | | | — |
@@ -33,9 +33,9 @@ th {
| `QwenImageLayeredPipeline` | Qwen-Image-Layered | `Qwen/Qwen-Image-Layered` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `QwenImageEditPlusPipeline` | Qwen-Image-Edit-2511 | `Qwen/Qwen-Image-Edit-2511` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `GlmImagePipeline` | GLM-Image | `zai-org/GLM-Image` | ✅︎ | ✅︎ | | | — |
| `ZImagePipeline` | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Krea2Pipeline` | Krea 2 (Raw + Turbo) | `krea/Krea-2-Raw`, `krea/Krea-2-Turbo` | ✅︎ | | | ✅︎ | — |
| `WanPipeline` | Wan2.1-T2V, Wan2.2-T2V, Wan2.2-TI2V | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers`, `Wan-AI/Wan2.1-T2V-14B-Diffusers`, `Wan-AI/Wan2.2-T2V-A14B-Diffusers`, `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `ZImagePipeline` | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tongyi-MAI/Z-Image-Turbo.md) |
| `Krea2Pipeline` | Krea 2 (Raw + Turbo) | `krea/Krea-2-Raw`, `krea/Krea-2-Turbo` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/krea/Krea-2.md) |
| `WanPipeline` | Wan2.1-T2V, Wan2.2-T2V, Wan2.2-TI2V | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers`, `Wan-AI/Wan2.1-T2V-14B-Diffusers`, `Wan-AI/Wan2.2-T2V-A14B-Diffusers`, `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Wan-AI/Wan2.2-T2V.md) |
| `WanImageToVideoPipeline` | Wan2.2-I2V | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `LingBotWorldCausalDMDPipeline` | LingBot-World 2.0 v1 (experimental) | `robbyant/lingbot-world-v2-14b-causal-fast-diffusers` | Experimental | | | | — |
| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, V2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano`, `nvidia/Cosmos3-Super` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
@@ -43,16 +43,16 @@ th {
| `Wan22S2VPipeline` | Wan2.2-S2V | `Wan-AI/Wan2.2-S2V-14B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Wan22VACEPipeline` | Wan2.1-VACE | `Wan-AI/Wan2.1-VACE-1.3B-diffusers`, `Wan-AI/Wan2.1-VACE-14B-diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Wan22VACEPipeline` | Wan2.2-VACE | `Pyros13/Wan2.2-VACE-Fun-A14B-Diffusers` | ✅︎ | | | ✅︎ | — |
| `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | | — |
| `LTX2TwoStagePipeline` | LTX-2 / LTX-2.3 ordinary two-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` + matching Lightricks LoRA and upsampler | ✅︎ | ✅︎ | | | — |
| `LTX2DistilledOneStagePipeline` | LTX-2 / LTX-2.3 merged-distilled one-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled`, `diffusers/LTX-2.3-Distilled-Diffusers` | ✅︎ | ✅︎ | | | — |
| `LTX2DistilledTwoStagePipeline` | LTX-2 / LTX-2.3 merged-distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled`, `diffusers/LTX-2.3-Distilled-Diffusers` + matching Lightricks upsampler | ✅︎ | ✅︎ | | | — |
| `LTX2Pipeline` | LTX-2.5 Full/SFT one-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
| `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.md) |
| `LTX2TwoStagePipeline` | LTX-2 / LTX-2.3 ordinary two-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` + matching Lightricks LoRA and upsampler | ✅︎ | ✅︎ | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.md) |
| `LTX2DistilledOneStagePipeline` | LTX-2 / LTX-2.3 merged-distilled one-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled`, `diffusers/LTX-2.3-Distilled-Diffusers` | ✅︎ | ✅︎ | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.md) |
| `LTX2DistilledTwoStagePipeline` | LTX-2 / LTX-2.3 merged-distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled`, `diffusers/LTX-2.3-Distilled-Diffusers` + matching Lightricks upsampler | ✅︎ | ✅︎ | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.md) |
| `LTX2Pipeline` | LTX-2.5 Full/SFT one-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
| `LTX2TwoStagePipeline` | LTX-2.5 Full/SFT two-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` + `Lightricks/LTX-2.5` LoRA | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
| `LTX2DistilledOneStagePipeline` | LTX-2.5 merged-distilled one-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
| `LTX2DistilledTwoStagePipeline` | LTX-2.5 merged-distilled two-stage T2V and I2V | `Lightricks/LTX-2.5-Diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/LTX/LTX-2.5.md) |
| `LingBotVideoPipeline` | LingBot-Video dense and MoE T2I, T2V, TI2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | | — |
| `SanaVideoPipeline` | SANA-Video-2B T2V (native 480p/720p; Diffusers adapter validated at both resolutions) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers`, `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/NVIDIA/SANA-Video-2B.md) |
| `SanaVideoPipeline` | SANA-Video-2B T2V (native 480p/720p; Diffusers adapter validated at both resolutions) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers`, `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/NVIDIA/SANA-Video-2B.md) |
| `SanaImageToVideoPipeline` | SANA-Video-2B I2V (native and Diffusers adapter validated at 480p/720p) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers`, `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/NVIDIA/SANA-Video-2B.md) |
| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, first/last-keyframe FL2VA, and mixed-reference Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | ✅︎ | | | [Published](https://recipes.vllm.ai/MiniMaxAI/MiniMax-H3) |
| `Magi2Pipeline` | MAGI-2 native Preview-stage T2VA and I2VA (272p/540p) | `sand-ai/MAGI-2-preview` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SandAI/MAGI-2-preview-L20X.md) |
@@ -65,18 +65,18 @@ th {
| `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `LongCatVideoAvatarPipeline` | LongCat-Video-Avatar-1.5 A2V/AI2V (native single-speaker and multi-speaker AI2V/AVC) | `meituan-longcat/LongCat-Video-Avatar-1.5` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/meituan-longcat/LongCat-Video-Avatar-1.5.md) |
| `BooguImagePipeline` | Boogu-Image | `Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit`, `Boogu/Boogu-Image-0.1-Edit-Turbo` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Boogu/Boogu-Image.md) |
| `BooguImageTurboPipeline` | Boogu-Image Turbo distilled T2I | `Boogu/Boogu-Image-0.1-Turbo` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Boogu/Boogu-Image.md) |
| `BooguImageTurboPipeline` | Boogu-Image Turbo distilled T2I | `Boogu/Boogu-Image-0.1-Turbo` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Boogu/Boogu-Image.md) |
| `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | — |
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/StabilityAI/Stable-Diffusion-3.5.md) |
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | — |
| `AuKForConditionalGeneration` | AuK / AuK-Flash (instruction-driven speech generation and editing) | `tencent/AuK`, `tencent/AuK-Flash` (with `Qwen/Qwen2.5-Omni-3B`; assemble with `tools/prepare_auk_checkpoint.py`) | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/AuK-H100.md) |
| `OmniVoiceModel` | OmniVoice | `k2-fsa/OmniVoice` | ✅︎ | | | ✅︎ | — |
| `VoxCPM2TalkerForConditionalGeneration` | VoxCPM2 | `openbmb/VoxCPM2` | ✅︎ | | | ✅︎ | — |
| `BreezeForConditionalGeneration` | Breeze-TTS-2 (voice design, voice clone, voice direction, CFG) | `BreezeBlue/Breeze-TTS-2` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/BreezeBlue/Breeze-TTS-2.md) |
| `MammothModa2ForConditionalGeneration` | MammothModa2-Preview | `bytedance-research/MammothModa2-Preview` | ✅︎ | ✅︎ | | | — |
| `MammothModa2ForConditionalGeneration` | MammothModa2-Preview | `bytedance-research/MammothModa2-Preview` | ✅︎ | ✅︎ | | ✅︎ | — |
| `MammothModa2ForConditionalGeneration` | MammothModa2-Dev (AR-only image understanding) | `bytedance-research/MammothModa2-Dev` | ✅︎ | | | | — |
| `Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `FluxKontextPipeline` | FLUX.1-Kontext-dev | `black-forest-labs/FLUX.1-Kontext-dev` | ✅︎ | ✅︎ | | | — |
| `Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/black-forest-labs/FLUX.2-klein.md) |
| `FluxKontextPipeline` | FLUX.1-Kontext-dev | `black-forest-labs/FLUX.1-Kontext-dev` | ✅︎ | ✅︎ | | ✅︎ | — |
| `FluxPipeline` | FLUX.1-dev | `black-forest-labs/FLUX.1-dev` | ✅︎ | ✅︎ | | ✅︎ | — |
| `FluxPipeline` | FLUX.1-schnell | `black-forest-labs/FLUX.1-schnell` | ✅︎ | ✅︎ | | ✅︎ | — |
| `OmniGen2Pipeline` | OmniGen2 | `OmniGen2/OmniGen2` | ✅︎ | ✅︎ | | ✅︎ | — |
@@ -97,13 +97,13 @@ th {
| `NextStep11Pipeline` | NextStep-1.1 | `stepfun-ai/NextStep-1.1` | ✅︎ | ✅︎ | | ✅︎ | — |
| `MiMoAudioModel` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | ✅︎ | ✅︎ | | | — |
| `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | — |
| `Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | ✅︎ | ✅︎ | | | — |
| `Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | ✅︎ | ✅︎ | | ✅︎ | — |
| `FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✅︎ | ✅︎ | | ✅︎ | — |
| `Audio8TTSSlowARForConditionalGeneration` | Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | ✅︎ | | | | — |
| `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only), SenseNova-U1.5 | `SenseNova/SenseNova-U1-8B-MoT`, `sensenova/SenseNova-U1.5-8B-MoT` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SenseNova/SenseNova-U1.5.md) |
| `Audio8TTSSlowARForConditionalGeneration` | Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | ✅︎ | | | ✅︎ | — |
| `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only), SenseNova-U1.5 | `SenseNova/SenseNova-U1-8B-MoT`, `sensenova/SenseNova-U1.5-8B-MoT` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SenseNova/SenseNova-U1.5.md) |
| `LancePipeline` | Lance | `bytedance-research/Lance` | ✅︎ | | | | — |
| `HunyuanVideo15Pipeline` | HunyuanVideo-1.5-T2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | ✅︎ | ✅︎ | | | — |
| `HunyuanVideo15ImageToVideoPipeline` | HunyuanVideo-1.5-I2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v` | ✅︎ | ✅︎ | | | — |
| `HunyuanVideo15Pipeline` | HunyuanVideo-1.5-T2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | ✅︎ | ✅︎ | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/HunyuanVideo-1.5.md) |
| `HunyuanVideo15ImageToVideoPipeline` | HunyuanVideo-1.5-I2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v` | ✅︎ | ✅︎ | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/HunyuanVideo-1.5.md) |
| `VoxtralTTSForConditionalGeneration` | Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | ✅︎ | ✅︎ | | | — |
| `CovoAudioForConditionalGeneration` | Covo-Audio-Chat | `tencent/Covo-Audio-Chat` | ✅︎ | | | | — |
| `MiniCPMO45OmniForConditionalGeneration` | MiniCPM-o 4.5 | `openbmb/MiniCPM-o-4_5` | ✅︎ | | ✅︎ | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/OpenBMB/MiniCPM-o-4_5.md) |
+16 -11
View File
@@ -150,17 +150,22 @@ interchangeably.
| Robot policy inference | `WS /v1/realtime/robot/openpi` | Send MessagePack observations and receive action arrays | [OpenPI Robot Policy](openpi_api.md) |
All six routes are model- or configuration-dependent. In particular,
`/v1/realtime` is full duplex when the model is a duplex model (its pipeline
declares a `duplex_plugin` and the deploy configuration sets
`session_mode: duplex`); a stock Realtime client needs no vendor query
parameter, and `duplex=0` is the explicit opt-out. Such a server serves the
websocket route plus `POST /v1/chat/completions`, and no other turn-based
HTTP route. On a duplex server that chat route is not the turn-based path:
each request runs on a short-lived duplex session, so it holds one of
`duplex_session.max_sessions` for its lifetime and answers at the model's
real-time pace -- see [Full Duplex](full_duplex_api.md). Clients should
also verify the duplex capability payload because the query-parameter form
falls back to the ordinary realtime handler when duplex is unavailable.
`/v1/realtime` is full duplex when the server is a duplex server: the model's
pipeline declares a `duplex_plugin` and its deploy configuration sets
`session_mode: duplex` (with `session_mode: turn` the same model boots the
ordinary turn-based serving stack instead). On a duplex server a stock
Realtime client needs no vendor query parameter; `duplex=0` selects the
turn-based Realtime handler, which a duplex server does not mount, so that
connection is refused with `Realtime API is not available`. Such a server
serves the websocket route plus `POST /v1/chat/completions`, `/v1/models`
and `/health`, and no other turn-based HTTP route. The chat route is the
ordinary chat service running on the duplex engine: a request is a
turn-based generation on the same stages, served alongside the live
websocket sessions; it opens no duplex session and holds no
`duplex_session.max_sessions` slot -- see [Full Duplex](full_duplex_api.md).
On a server that is not duplex, an explicit `?duplex=1` is refused rather
than answered by the turn-based handler, so a client that requested duplex
never silently gets the other protocol.
## Related Endpoints
+13 -4
View File
@@ -72,12 +72,21 @@ does not switch engines. The Python `Omni` / `AsyncOmni` APIs are unchanged.
`session.created.session.capabilities` is present before treating the
connection as full duplex.
**MiniCPM-o 4.5** (`vllm_omni/deploy/minicpmo_4_5.yaml`) is the only model
served over this endpoint today. PersonaPlex and Nemotron VoiceChat still carry
their pre-framework duplex code: their pipelines declare no `duplex_plugin`, so
they run turn-based until the follow-up PRs port them to the plugin contract
**MiniCPM-o 4.5** (`vllm_omni/deploy/minicpmo_4_5.yaml`), **Qwen3-Omni**
(`vllm_omni/deploy/qwen3_omni_duplex.yaml`), and **Nemotron VoiceChat**
(`vllm_omni/deploy/nemotron_labs_voicechat_duplex.yaml`) are served
over this endpoint today. PersonaPlex still carries its pre-framework duplex
code: its pipeline declares no `duplex_plugin`, so it runs turn-based until the
follow-up PR ports it to the plugin contract
(RFC [vllm-omni#7181](https://github.com/vllm-project/vllm-omni/issues/7181)).
For Nemotron VoiceChat Python clients, use
`vllm_omni.clients.nemotron_voicechat.create_duplex_session_config()` with
`DuplexClient`. The preset selects 16 kHz float32 input, 22.05 kHz PCM16 output,
and automatic responses. Append 1,280-sample (80 ms) input frames; when using
`stream_pcm`, set `chunk_ms=80`. Pass `instructions` and optional `tools` to
the preset before opening the session.
JoyVL is a separate HTTP interaction orchestrator and does not use these
WebSocket endpoints. See [Standalone Experimental Servers](standalone_servers.md).
+3 -8
View File
@@ -8,11 +8,9 @@ history stays honest, and a dropped connection can resume the same session.
This page covers how to run a duplex deployment, how to drive it from Python
with `vllm_omni.clients.duplex.DuplexClient`, and the complete wire contract.
The endpoint is served for models that ship a duplex plugin — including
MiniCPM-o 4.5 and Qwen3-Omni — and just for deploy configurations that declare
`session_mode: duplex`. PersonaPlex and Nemotron VoiceChat are ported to the
plugin contract in follow-up PRs and are not served over this endpoint yet. The runtime architecture
is described in [Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md).
The endpoint requires a duplex plugin and a deployment with `session_mode: duplex`.
See [supported models and deployments](full_duplex_api.md#enable-full-duplex)
and the [runtime architecture](../design/fullduplex.md).
## Qwen3-Omni conversation history
@@ -1340,9 +1338,6 @@ them out into typed events before they reach a client.
## Known Limitations
- Only MiniCPM-o 4.5 is served over this endpoint today; PersonaPlex and
Nemotron VoiceChat arrive with the follow-up PRs that port them to the
plugin contract.
- Several surfaces are capability-gated per model (see *Capability
negotiation by model* above): PersonaPlex does not support session resume,
barge-in, or audio truncation; Nemotron VoiceChat does not support barge-in
+12 -1
View File
@@ -120,7 +120,18 @@ request for execution.
`DELETE /v1/videos/{video_id}` issues a bounded engine abort
(`VLLM_OMNI_ABORT_TIMEOUT`, default 2s), then cancels the frontend
task. Cancellation cleanup is also bounded and best-effort: it confirms
the abort was queued, and the current request batch may still drain.
the abort was submitted. In request execution mode, MiniMax-H3 checks
cancellation at model boundaries,
including before and after input preparation, after each denoising step, and
before decode, and skips the remaining generation when cancelled. Input
preparation or decoding already in progress may finish before the next boundary.
Successful steps do not force device synchronization for cancellation.
Parallel ranks agree before stopping; independent requests sharing a distributed
AllGather offload wave can stop that wave early only when all its requests are
cancelled, so a cancelled request cannot strand its live peers in a collective.
In step execution mode, cancellation is handled by the scheduler between steps;
the request-mode component-boundary checks do not apply within an active step.
Other pipelines may still drain their current request batch.
The job is then re-read so a completed save is not orphaned.
### Synchronous Response
+28
View File
@@ -89,6 +89,7 @@ Install matching vLLM and vLLM-Omni versions, and ensure `ffmpeg` and
| NVIDIA B200 or H200 | Capacity-based recommendation; not yet verified | All four canonical pipelines |
| NVIDIA GB200 or GB300 | Capacity-based recommendation; not yet verified | All four canonical pipelines |
| NVIDIA H100 80 GB | FP8 recipe | Distilled one-stage at 960x544 |
| Intel Arc Pro B70 32 GB | Verified | Offline Full/SFT one-stage T2V at 768x512 |
The 1920x1088 two-stage examples require about 114 GB of peak GPU memory.
80 GB GPUs do not have enough safety margin for the canonical two-stage
@@ -133,6 +134,33 @@ SP attention head counts must be divisible by `ulysses_degree`. Use each
phase's `H, W` (half/full resolution for two-stage); non-divisible shapes fail.
Each GPU must still fit the resident weights. Warm up with the production shape.
### 1x Intel Arc Pro B70 32 GB (XPU)
Offline Full/SFT one-stage T2V at 768x512, 17 frames. Select the legacy
convolutional VAE with `ltx2_use_conv_vae`; the default DiffVAE decoder loads a
NATTEN kernel, which has no XPU build. FP8 with layerwise offload streams both
the transformer and the Gemma-4 text encoder.
Environment: torch 2.14.0+xpu, vLLM 0.29.0, vLLM-Omni `main` at `d8d162d8`.
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model Lightricks/LTX-2.5-Diffusers \
--model-class-name LTX2Pipeline \
--stage-overrides '{"0":{"extras":{"ltx2_use_conv_vae":true}}}' \
--prompt "A serene lakeside sunrise with mist over the water." \
--num-frames 17 \
--quantization fp8 \
--enable-layerwise-offload \
--vae-use-tiling \
--vae-use-slicing \
--enforce-eager \
--output ltx25_output.mp4
```
Peaks at 16.3 GiB in 163 s. Two-stage, distilled, and I2V paths were not
exercised on this hardware.
## Offline inference
Choose values from the pipeline table. For example, the distilled two-stage
+57
View File
@@ -165,6 +165,63 @@ curl -X POST http://localhost:8000/v1/videos/sync \
Use `image_reference` for a URL or JSON-safe image reference. Do not provide it
together with `input_reference`.
## Hardware Support
## XPU
### 1x Intel Arc Pro B70 (32 GB)
Offline one-stage T2V at 768x512, 17 frames. FP8 with layerwise offload streams
both the transformer and the Gemma text encoder, which otherwise stays resident
beside it.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.14.0+xpu
- vLLM: 0.29.0
- vLLM-Omni: `main` at `d8d162d8`
#### Command
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model diffusers/LTX-2.3-Diffusers \
--prompt "A serene lakeside sunrise with mist over the water." \
--num-frames 17 \
--quantization fp8 \
--enable-layerwise-offload \
--vae-use-tiling \
--vae-use-slicing \
--enforce-eager \
--output ltx23_output.mp4
```
The same flags cover every one-stage checkpoint; only `--model` changes. The
distilled checkpoints additionally need their class and an explicit size,
because their preset defaults to 1536x1024:
```bash
--model diffusers/LTX-2.3-Distilled-Diffusers \
--model-class-name LTX2DistilledOneStagePipeline \
--height 512 --width 768
```
| Checkpoint | Peak | Time |
| --- | ---: | ---: |
| `Lightricks/LTX-2` | 14.8 GiB | 160 s |
| `diffusers/LTX-2.3-Diffusers` | 16.4 GiB | 167 s |
| `dg845/LTX-2.3-Diffusers` | 16.4 GiB | 155 s |
| `diffusers/LTX-2.3-Distilled-Diffusers` | 14.9 GiB | 128 s |
#### Verification
Each command writes its `--output` MP4. Confirm it decodes and that the sampled
frames match the prompt.
## Guidance
One-stage and ordinary Stage 1 support independent video/audio CFG,
+30 -7
View File
@@ -59,7 +59,9 @@ the measured results are summarized below.
### Optional FP8 AR KV cache
For CUDA deployments, `mammoth_moda2_fp8_kv.yaml` is an opt-in preset that
stores the Stage 0 AR KV cache as FP8 E4M3. Stage 1 remains on
keeps the Stage 0 AR KV cache of decoder layer 0 in BF16 and stores the other
27 layers as FP8 E4M3 (`kv_cache_dtype_skip_layers: ["0"]`; write the layer
indices as quoted strings). Stage 1 remains on
`kv_cache_dtype=auto`; its DiT execution is unaffected. This setting quantizes
only the autoregressive KV cache. It is neither FP8 weight/activation
quantization nor vLLM-Omni diffusion KV-cache quantization.
@@ -78,9 +80,9 @@ python examples/offline_inference/text_to_image/text_to_image.py \
--output mammoth_t2i.png
```
The preset was validated on one NVIDIA H800 80GB with CUDA and
FlashAttention 3. The native and FP8 runs used the same model, code revision,
and downstream configuration.
The preset was first validated on one NVIDIA H800 80GB with CUDA and
FlashAttention 3, with every layer in FP8. The native and FP8 runs used the
same model, code revision, and downstream configuration.
| Metric | Native BF16 (`kv_cache_dtype=auto`) | FP8 E4M3 (`fp8_e4m3`) | Change |
| --- | ---: | ---: | ---: |
@@ -97,9 +99,30 @@ twice as many tokens because FP8 reduces the bytes per cached token. This is a
capacity/concurrency tradeoff: the measured AR and end-to-end latencies were
higher than the native-BF16 baseline.
1024x1024 fixed-seed smoke test completed successfully with no obvious visual
failure. FP8 is lossy, so numerical or image-quality equivalence with BF16 is
not implied.
On H800, a 1024x1024 fixed-seed smoke test with every layer in FP8 completed
successfully with no obvious visual failure. FP8 is lossy, so numerical or
image-quality equivalence with BF16 is not implied.
On one A800 80GB (vLLM 0.30.0), FP8 KV layers run on FlashInfer and BF16 layers
on FlashAttention 2. Text-to-image at 1024x1024, 50 steps, guidance 4.0, seeds 42
and 1-5, with a studio tabby cat prompt and a peephole-view Samoyed prompt;
an image counts when it shows the prompted subject and scene.
| Stage 0 KV cache | GPU KV cache size | Cat images that follow the prompt | Samoyed images that follow the prompt |
| --- | ---: | ---: | ---: |
| BF16 on all 28 layers (`auto`) | 147,408 tokens | 6/6 | 6/6 |
| FP8 E4M3 on all 28 layers | 294,816 tokens | 1/6 | 0/6 |
| Layer 0 BF16, other 27 layers FP8 E4M3 (this preset) | 284,640 tokens | 6/6 | 6/6 |
The KV cache sizes above still count the 28 attention layers of the replaced
Qwen-VL language model, which #8095 removes: with it, BF16 goes from 147,408 to
294,816 tokens and all-FP8 from 294,816 to 589,632. The prompt-following
columns do not depend on it.
With all 28 layers in FP8, most cat images become a framed print on a wall.
Keeping layer 0 in BF16 restores them; keeping only layer 27, which has the
largest key magnitude, does not. The H800 numbers above were measured with all 28
layers in FP8; this preset has been run on A800 only.
### 1x L40S 48GB
+63 -3
View File
@@ -7,7 +7,8 @@
- Vendor: Qwen
- Model: `Qwen/Qwen-Image`
- Task: Text-to-image generation
- Mode: Online serving with optional step-wise continuous batching
- Mode: Online serving with optional step-wise continuous batching; offline
inference
- Maintainer: Community
## When to use this recipe
@@ -25,11 +26,14 @@ the benchmark assets already bundled in this repository.
[`examples/online_serving/text_to_image/README.md`](../../examples/online_serving/text_to_image/README.md)
- Related benchmark:
[`benchmarks/diffusion/diffusion_benchmark_serving.py`](../../benchmarks/diffusion/diffusion_benchmark_serving.py)
- Offline inference example used by the XPU section:
[`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py)
## Hardware Support
This recipe currently documents one CUDA GPU serving configuration. Extend it
with more hardware sections as community validation lands.
This recipe documents CUDA GPU serving configurations and one Intel XPU
offline-inference configuration. Extend it with more hardware sections as
community validation lands.
## GPU
@@ -235,3 +239,59 @@ CUTLASS was the fastest validated backend for this checkpoint.
- The benchmark table intentionally reports Qwen-Image-2512 as
concurrency-1 request-level data; do not compare it directly with an online
HTTP concurrency benchmark.
## XPU
### 1x Intel Arc Pro B70 (32 GB)
Offline text-to-image at 1024x1024, with layerwise offload streaming the
transformer blocks so the 20B model fits one card.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.13.0+xpu
- vLLM: 0.29.0 (`98dff2a8`)
- vLLM-Omni: `main` at `4c7a98c2`
#### Command
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "a cup of coffee on the table" \
--num-inference-steps 50 \
--enable-layerwise-offload \
--vae-use-tiling \
--vae-use-slicing \
--enforce-eager \
--output qwen_image_output.png
```
Given spare cards, `--enable-cpu-offload` shards the transformer instead of
streaming it. It shortens the denoise loop but not the run, because the
per-request model swap costs more than the loop saves.
```bash
--tensor-parallel-size 4 \
--enable-cpu-offload
```
| Offload | Cards | Peak VRAM | Time |
| --- | ---: | ---: | ---: |
| `--enable-layerwise-offload` | 1 | 16.6 GiB | 50 s |
| `--enable-cpu-offload` | 2 | 26.4 GiB | 85 s |
| `--enable-cpu-offload` | 4 | 19.8 GiB | 70 s |
| `--enable-cpu-offload` | 8 | 16.9 GiB | 87 s |
#### Verification
Confirm `qwen_image_output.png` is written and looks coherent for the prompt.
#### Notes
- Memory usage: peak 16.6 GiB, about 55 s per image.
- Known limitations: only offline generation was qualified. Step-wise continuous
batching, ModelOpt quantization, and online serving are out of scope for this
profile.
+51 -5
View File
@@ -53,7 +53,7 @@ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
--deploy-config /path/to/your_qwen3_omni_overrides.yaml
```
#### Runtime tuning
### Runtime tuning
Prefer CLI overrides for day-to-day tuning:
@@ -75,7 +75,7 @@ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
}'
```
#### Stage-based launch (one stage per process)
### Stage-based launch (one stage per process)
Use three terminals (one per stage). Start with the default commands below, then
add `--max-num-seqs` only if you need explicit per-stage concurrency control.
@@ -135,7 +135,7 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \
If you use custom deploy YAML, add `--deploy-config` to each stage command.
#### Verification
### Verification
After server startup, run a multimodal example client:
@@ -185,7 +185,7 @@ python examples/online_serving/qwen3_omni/openai_realtime_client.py \
--output-wav realtime_output.wav
```
#### Benchmark with `vllm bench`
### Benchmark with `vllm bench`
After the server is up, you can run online serving benchmarks with
`vllm bench serve --omni`.
@@ -240,8 +240,54 @@ vllm bench serve \
--percentile-metrics ttft,tpot,itl,e2el
```
#### Notes
### Notes
- `/v1/realtime` is unsupported while `async_chunk` is enabled.
- The default deploy uses `SharedMemoryConnector`; this is for single-host
stage wiring.
## XPU
### 5x Intel Arc Pro B70 (32 GB)
Offline end-to-end text query returning both text and speech. The 62 GiB of
BF16 thinker weights need four cards, so the `xpu:` block of
`vllm_omni/deploy/qwen3_omni_moe.yaml` runs the thinker at TP=4 on cards 0-3
and the talker and code2wav on card 4. It is the registry default here, so
`--deploy-config` is not needed.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.13.0+xpu
- vLLM: 0.29.0 (`98dff2a8`)
- vLLM-Omni: `main` at `4c7a98c2`
#### Command
The raised timeouts absorb the first decode step, which JIT-compiles the
Triton MoE kernels.
```bash
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS=1800 \
python examples/offline_inference/qwen3_omni/end2end.py \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct \
--query-type text \
--stage-init-timeout 1800 \
--init-timeout 1800 \
--output-wav qwen3omni_output
```
#### Verification
`qwen3omni_output/` holds one `.txt` answer and the matching `.wav`. Confirm
the transcript of the audio tracks the text response.
#### Notes
- Runtime: about 260 s for a single text query.
- The thinker MoE layers have no tuned Triton config for this shape and log
`Using default MoE config. Performance might be sub-optimal!`.
- Known limitations: only `--query-type text` was qualified. The audio, image,
and video query types and online serving are out of scope for this profile.
+10 -7
View File
@@ -45,6 +45,7 @@ recipes/
| [`Baidu/ERNIE-Image.md`](./Baidu/ERNIE-Image.md) | Text-to-image online serving (ERNIE-Image 8B) | 1x or 2x RTX 4090 24GB |
| [`Bagel/BAGEL-7B-MoT.md`](./Bagel/BAGEL-7B-MoT.md) | Text-to-image with shared online/offline examples | 1x A100 80GB / 2x CUDA GPUs |
| [`black-forest-labs/FLUX.1-schnell.md`](./black-forest-labs/FLUX.1-schnell.md) | Offline text-to-image generation with CPU offload | 1x NVIDIA GeForce RTX 5090 32GB |
| [`black-forest-labs/FLUX.2-klein.md`](./black-forest-labs/FLUX.2-klein.md) | Offline image editing (FLUX.2-klein-4B) | 1x Intel Arc Pro B70 32GB |
| [`Boogu/Boogu-Image.md`](./Boogu/Boogu-Image.md) | Text-to-image online serving (Boogu-Image-0.1-Base) | 1x A100/H100 40GB+ |
| [`BosonAI/Higgs-Audio-V3-TTS.md`](./BosonAI/Higgs-Audio-V3-TTS.md) | Online + offline multilingual TTS with voice cloning | 1x H100 80GB |
| [`BreezeBlue/Breeze-TTS-2.md`](./BreezeBlue/Breeze-TTS-2.md) | Online + offline TTS (plain / voice design / voice clone / voice direction, streaming PCM) | 1x NVIDIA L20 48GB |
@@ -62,9 +63,9 @@ recipes/
| [`MiniMaxAI/MiniMax-H3.md`](./MiniMaxAI/MiniMax-H3.md) | T2VA, FL2VA, image+audio Ref2VA, and multi-video Ref2VA serving | 1x GPU with CPU offload / 4x B300 |
| [`MiniMaxAI/MiniMax-H3-Spark-GB10.md`](./MiniMaxAI/MiniMax-H3-Spark-GB10.md) | MiniMax H3 T2VA and Ref2VA on DGX Spark | 1x or 2x DGX Spark (GB10) |
| [`MiniMaxAI/MiniMax-H3-MUSA.md`](./MiniMaxAI/MiniMax-H3-MUSA.md) | MiniMax H3 T2VA, FL2VA, and Ref2VA on MUSA | 4x MTT S5000 (TP4 Ref2VA profile) |
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB |
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU |
| [`LTX/LTX-2.5.md`](./LTX/LTX-2.5.md) | LTX-2.5-Diffusers: Full/SFT one-stage and distilled two-stage T2V/I2V with synchronized audio | NVIDIA B300; cuDNN-qualified |
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB / 1x Intel Arc Pro B70 32GB |
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU / 1x Intel Arc Pro B70 32GB |
| [`LTX/LTX-2.5.md`](./LTX/LTX-2.5.md) | LTX-2.5-Diffusers: Full/SFT one-stage and distilled two-stage T2V/I2V with synchronized audio | NVIDIA B300; cuDNN-qualified / 1x Intel Arc Pro B70 32GB |
| [`MammothModa2/MammothModa2.md`](./MammothModa2/MammothModa2.md) | Preview and Dev text-to-image (AR → DiT); Dev text/image understanding | Preview: 1x L40S 48GB / 1x ≥40GB GPU; Dev: 1x NVIDIA GPU with sufficient cache headroom |
| [`NVIDIA/SANA-Video-2B.md`](./NVIDIA/SANA-Video-2B.md) | Native and Diffusers-adapter 480p/720p T2V+I2V | 1x RTX 5090 32GB |
| [`meituan-longcat/LongCat-Video-Avatar-1.5.md`](./meituan-longcat/LongCat-Video-Avatar-1.5.md) | Audio-driven avatar video generation (AT2V / AI2V, single- and multi-speaker, AVC continuation) | 1x H100 80GB |
@@ -82,10 +83,10 @@ recipes/
| [`NVIDIA/Nemotron-Labs-Audex.md`](./NVIDIA/Nemotron-Labs-Audex.md) | TTS / text-to-audio / audio understanding / cascaded S2S (Audex 2B + 30B-A3B) | 1x H100 80GB |
| [`NVIDIA/NemotronLabs-VoiceChat.md`](./NVIDIA/NemotronLabs-VoiceChat.md) | Offline speech-to-speech voice chat (11B, frame-locked 12.5 Hz, 3-stage thinker/talker/code2wav) | 1x H100 80GB |
| [`NVIDIA/SANA-WM.md`](./NVIDIA/SANA-WM.md) | First-frame image-to-video serving with camera control | 1x 24GB+ CUDA GPU; ~40GB disk |
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt mixed FP8/NVFP4 | 1x A100 80GB / 2x B200 |
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt mixed FP8/NVFP4; offline text-to-image on XPU | 1x A100 80GB / 2x B200 / 1x Intel Arc Pro B70 32GB |
| [`Qwen/Qwen-Image-2512.md`](./Qwen/Qwen-Image-2512.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt FP8 / mixed FP8/NVFP4 | 1x A800 80GB / 2x B200 |
| [`Qwen/Qwen-Image-Edit.md`](./Qwen/Qwen-Image-Edit.md) | Text-guided single-image editing | 1x or 2x H200 141GB |
| [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md) | Online serving for multimodal chat | 1x A100 80GB |
| [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md) | Online serving for multimodal chat; offline end-to-end text-to-speech on XPU | 1x A100 80GB / 5x Intel Arc Pro B70 32GB |
| [`Qwen/Qwen3-TTS.md`](./Qwen/Qwen3-TTS.md) | Text-to-speech serving (CustomVoice / VoiceDesign / Base) | 1x H100/A100 80GB / 1x AMD MI300X |
| [`SenseNova/SenseNova-U1.md`](./SenseNova/SenseNova-U1.md) | Unified image generation and understanding | 1x or 2x H200 / 1x AMD MI300X |
| [`SenseNova/SenseNova-U1.5.md`](./SenseNova/SenseNova-U1.5.md) | Unified image generation and understanding, with an 8-step distilled LoRA | 1x A800 80GB |
@@ -93,13 +94,15 @@ recipes/
| [`Tencent/AuK-H100.md`](./Tencent/AuK-H100.md) | Offline instruction-driven TTS, speech editing, enhancement and separation (AuK base and Flash) | 1x H100 80GB |
| [`Tencent/Covo-Audio-Chat.md`](./Tencent/Covo-Audio-Chat.md) | Online serving for audio chat | 1x A100 80GB |
| [`Tencent/HunyuanImage-3.0-Instruct.md`](./Tencent/HunyuanImage-3.0-Instruct.md) | DiT-only text-to-image serving and benchmark, including ModelOpt mixed FP8/NVFP4 | 4x H100/H800 80GB / 2x B200 |
| [`Wan-AI/Wan2.2-T2V.md`](./Wan-AI/Wan2.2-T2V.md) | Text-to-video serving (Wan2.2 14B) with Skip-Softmax sparse attention | 1x datacenter Blackwell (B200/B300) |
| [`Tencent/HunyuanVideo-1.5.md`](./Tencent/HunyuanVideo-1.5.md) | Offline text-to-video (HunyuanVideo-1.5 Diffusers) | 1x Intel Arc Pro B70 32GB |
| [`Tongyi-MAI/Z-Image-Turbo.md`](./Tongyi-MAI/Z-Image-Turbo.md) | Offline few-step text-to-image (Z-Image-Turbo) | 1x Intel Arc Pro B70 32GB |
| [`Wan-AI/Wan2.2-T2V.md`](./Wan-AI/Wan2.2-T2V.md) | Text-to-video serving (Wan2.2 14B) with Skip-Softmax sparse attention | 8x H20/H100/A100 / 1x datacenter Blackwell (B200/B300) / 1x Intel Arc Pro B70 32GB |
| [`Wan-AI/Wan2.2-TI2V-5B-A100.md`](./Wan-AI/Wan2.2-TI2V-5B-A100.md) | Unified 720p text-to-video and image-to-video serving (Wan2.2 5B) | 1x A100-SXM4-80GB |
| [`Wan-AI/Wan2.2-I2V.md`](./Wan-AI/Wan2.2-I2V.md) | Image-to-video serving (Wan2.2 14B) | 8x Ascend NPU (A2/A3) |
| [`Wan-AI/Wan2.2-S2V.md`](./Wan-AI/Wan2.2-S2V.md) | Speech-to-video serving (Wan2.2 14B) | 2x A100/H100 80GB |
| [`Wan-AI/Wan2.1-VACE.md`](./Wan-AI/Wan2.1-VACE.md) | Unified T2V, I2V, V2LF, FLF2V, inpaint, and R2V | 1x RTX 5090 (1.3B) / 1x L40S 48GB with layerwise offload (14B) |
| [`XiaomiMiMo/MiMo-Audio.md`](./XiaomiMiMo/MiMo-Audio.md) | Offline + online omni audio (TTS, ASR, dialogue) | 1x RTX 5090 / 5090D 32GB |
| [`StabilityAI/Stable-Diffusion-3.5.md`](./StabilityAI/Stable-Diffusion-3.5.md) | Text-to-image serving (SD 3.5-medium and SD 3.5-large) | 1x RTX A6000 48GB |
| [`StabilityAI/Stable-Diffusion-3.5.md`](./StabilityAI/Stable-Diffusion-3.5.md) | Text-to-image serving (SD 3.5-medium and SD 3.5-large) | 1x RTX A6000 48GB / 1x Intel Arc Pro B70 32GB |
| [`zai-org/GLM-TTS.md`](./zai-org/GLM-TTS.md) | Online serving for Chinese/English zero-shot voice-cloned TTS | 1x A40 48GB / 2x non-standard RTX 4090 48GB (49,140 MiB each) |
| [`GLM/GLM-Image.md`](./GLM/GLM-Image.md) | Online serving for image generation | 1x A800 80GB / 2x A800 80GB |
| [`JD/JoyAI-VL-Interaction.md`](./JD/JoyAI-VL-Interaction.md) | Real-time streaming video-language interaction (proactive speak/silence/delegate) | 1x GPU 24GB+ |
@@ -137,3 +137,39 @@ print('saved output.png')
- Image quality is noticeably better than medium, with sharper details.
- `negative_prompt` requires `guidance_scale > 1` to take effect; SD3 defaults to 1.0 (no CFG).
- 2048×2048 fits within 48 GB but leaves little headroom (~45.5 GiB used out of 49 GiB).
## XPU
### 1x Intel Arc Pro B70 (32 GB) — stable-diffusion-3.5-medium
Offline text-to-image at 1024x1024 with BF16 weights fully resident.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.13.0+xpu
- vLLM: 0.29.0 (`98dff2a8`)
- vLLM-Omni: `main` at `4c7a98c2`
#### Command
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model stabilityai/stable-diffusion-3.5-medium \
--prompt "a sunset over mountains, photorealistic" \
--num-inference-steps 28 \
--vae-use-tiling \
--output sd35_medium_output.png
```
#### Verification
Confirm `sd35_medium_output.png` is written and matches the prompt.
#### Notes
- Memory usage: peak 19.5 GiB against 29.8 GiB free.
- Known limitations: only `stable-diffusion-3.5-medium` offline generation was
qualified. `stable-diffusion-3.5-large` and online serving are out of scope
for this profile.
+72
View File
@@ -0,0 +1,72 @@
# HunyuanVideo 1.5
> Text-to-video with the HunyuanVideo-1.5 Diffusers checkpoints
## Summary
- Vendor: Tencent (community Diffusers ports)
- Model: `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v`
- Task: Text-to-video
- Mode: Offline inference
- Maintainer: Community
## When to use this recipe
Use this recipe to generate a short clip with HunyuanVideo-1.5 on a memory-
constrained card, trading output size for capacity.
## References
- Model card:
<https://huggingface.co/hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v>
- Shared runnable example:
[`examples/offline_inference/text_to_video/text_to_video.py`](../../examples/offline_inference/text_to_video/text_to_video.py)
- CPU offload guide:
[`docs/user_guide/diffusion/cpu_offload.md`](../../docs/user_guide/diffusion/cpu_offload.md)
## Hardware Support
This recipe documents one validated Intel XPU configuration. Extend it with
more hardware sections as community validation lands.
## XPU
### 1x Intel Arc Pro B70 (32 GB)
BF16 weights with model-level CPU offload at a reduced 320x240, 17-frame
shape. The 480p and 720p T2V and I2V checkpoints all use the same command; only
`--model` changes.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.13.0+xpu
- vLLM: 0.29.0 (`98dff2a8`)
- vLLM-Omni: `main` at `4c7a98c2`
#### Command
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v \
--prompt "A serene lakeside sunrise with mist over the water." \
--height 240 --width 320 --num-frames 17 \
--num-inference-steps 50 \
--guidance-scale 6.0 \
--enable-cpu-offload \
--vae-use-tiling \
--vae-use-slicing \
--output hunyuan_video_15_output.mp4
```
#### Verification
Confirm `hunyuan_video_15_output.mp4` decodes and that the sampled frames match
the prompt.
#### Notes
- Memory usage: 18.3 GiB loaded, 25.7 GiB peak, about 39 s per clip.
- Known limitations: the native 720p geometry does not fit a 32 GB card with
CPU offload. Online serving is out of scope for this profile.
+68
View File
@@ -0,0 +1,68 @@
# Z-Image Turbo
> Few-step text-to-image with `Tongyi-MAI/Z-Image-Turbo`
## Summary
- Vendor: Tongyi-MAI
- Model: `Tongyi-MAI/Z-Image-Turbo`
- Task: Text-to-image
- Mode: Offline inference
- Maintainer: Community
## When to use this recipe
Use this recipe to generate 1024x1024 images with the distilled Z-Image Turbo
checkpoint through the shared offline text-to-image example.
## References
- Model card: <https://huggingface.co/Tongyi-MAI/Z-Image-Turbo>
- Shared runnable example:
[`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py)
- CPU offload guide:
[`docs/user_guide/diffusion/cpu_offload.md`](../../docs/user_guide/diffusion/cpu_offload.md)
## Hardware Support
This recipe documents one validated Intel XPU configuration. Extend it with
more hardware sections as community validation lands.
## XPU
### 1x Intel Arc Pro B70 (32 GB)
BF16 weights with model-level CPU offload and VAE tiling/slicing, 25 steps.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.13.0+xpu
- vLLM: 0.29.0 (`98dff2a8`)
- vLLM-Omni: `main` at `4c7a98c2`
#### Command
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Tongyi-MAI/Z-Image-Turbo \
--prompt "a cup of coffee on the table" \
--num-inference-steps 25 \
--enable-cpu-offload \
--vae-use-tiling \
--vae-use-slicing \
--enforce-eager \
--output z_image_turbo_output.png
```
#### Verification
Confirm `z_image_turbo_output.png` is written as a 1024x1024 PNG matching the
prompt.
#### Notes
- Memory usage: peak 15.6 GiB, about 58 s per image.
- Known limitations: only offline generation was qualified. Online serving is
out of scope for this profile.
+73 -9
View File
@@ -85,14 +85,78 @@ For online serving client examples and request formats, see
#### Notes
- **Key flags:**
- `--omni` — enables vLLM-Omni diffusion serving.
- `--use-hsdp` — Hybrid Sharded Data Parallelism for the 14B DiT weights.
- `--usp <N>` — Unified (Ulysses) Sequence Parallelism degree.
- `--cfg-parallel-size <N>` — CFG parallelism; set to 2 for the official model,
- `--omni` — enables vLLM-Omni diffusion serving.
- `--use-hsdp` — Hybrid Sharded Data Parallelism for the 14B DiT weights.
- `--usp <N>` — Unified (Ulysses) Sequence Parallelism degree.
- `--cfg-parallel-size <N>` — CFG parallelism; set to 2 for the official model,
omit for distilled checkpoints.
- `--vae-patch-parallel-size 8` / `--vae-use-tiling` — parallel + tiled VAE
- `--vae-patch-parallel-size 8` / `--vae-use-tiling` — parallel + tiled VAE
decoding; disabling patch parallelism can significantly increase VAE latency.
## XPU
### 1x Intel Arc Pro B70 (32 GB)
Offline text-to-video at a reduced 576x320, 17-frame shape. Layerwise offload
streams the two 14B expert transformers; VAE slicing bounds the decode
activations.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.13.0+xpu
- vLLM: 0.29.0 (`98dff2a8`)
- vLLM-Omni: `main` at `4c7a98c2`
#### Command
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model Wan-AI/Wan2.2-T2V-A14B-Diffusers \
--prompt "A serene lakeside sunrise with mist over the water." \
--negative-prompt "blurry, noisy, artifacts, distorted, low quality, streaks, sparkles, blocky, pixelated" \
--height 320 --width 576 --num-frames 17 \
--num-inference-steps 60 \
--guidance-scale 4.0 \
--flow-shift 5.0 \
--boundary-ratio 0.875 \
--enable-layerwise-offload \
--vae-use-slicing \
--enforce-eager \
--seed 12345 \
--output wan22_t2v_output.mp4
```
Given spare cards, `--enable-cpu-offload` shards the two expert transformers
instead of streaming them, which nearly halves the denoise loop. Four is the
fastest count; eight is slower.
```bash
--tensor-parallel-size 4 \
--enable-cpu-offload
```
| Offload | Cards | Peak VRAM | Denoise |
| --- | ---: | ---: | ---: |
| `--enable-layerwise-offload` | 1 | 9.9 GiB | 160 s |
| `--enable-cpu-offload` | 2 | 18.9 GiB | 113 s |
| `--enable-cpu-offload` | 4 | 12.4 GiB | 86 s |
#### Verification
Confirm `wan22_t2v_output.mp4` decodes and that frame 0 is clean.
#### Notes
- Key flags: `--boundary-ratio 0.875` sets the high-noise to low-noise expert
switch. `--flow-shift 5.0` with `--guidance-scale 4.0` over 60 steps clears
the frame-0 VAE corruption seen at the stock 50-step, `flow_shift=12.0`
settings.
- Known limitations: only offline T2V at 576x320 was qualified. Larger shapes,
TI2V, HSDP, CFG parallelism, and online serving are out of scope for this
profile.
## Accelerating with TRTLLM_ATTN + Skip-Softmax (datacenter Blackwell)
On **datacenter Blackwell** (sm_100 / sm_103), the mask-free Wan pipeline
@@ -153,7 +217,7 @@ instead of `target_sparsity`):
### Skip-Softmax controls
| Key | Valid values | Meaning |
|---|---|---|
| --- | --- | --- |
| `target_sparsity` | finite, `[0, 1]` | Operating point on the checkpoint's calibrated curve (`a·exp(b·s)`). |
| `threshold` | finite, `≥ 0` | Direct skip threshold, no calibration needed. Mutually exclusive with `target_sparsity`. |
| `disabled_until_timestep` | finite, `[0, 1]` | Keeps early, high-noise steps dense; skip turns on once the normalized timestep `t ≤ D`. |
@@ -169,7 +233,7 @@ Measured on B300 / SM103, 1280×720 / 81f / 50 steps, torch.compile, official
ModelOpt calibration (speedup and divergence relative to dense):
| config | speedup | LPIPS (divergence vs dense) |
|---|---|---|
| --- | --- | --- |
| dense | 1.000x | - |
| `s=0.65, D=0.86` | 1.062x | 0.041 |
| `s=0.75, D=1.00` | 1.181x | 0.377 |
@@ -181,6 +245,6 @@ ModelOpt calibration (speedup and divergence relative to dense):
`flashinfer`. Elsewhere, or without FlashInfer, selecting `TRTLLM_ATTN` raises
rather than silently degrading.
- **Known limitations:**
- Skip-Softmax is not supported under ring sequence parallelism; use Ulysses SP
- Skip-Softmax is not supported under ring sequence parallelism; use Ulysses SP
(`--usp`), or run Skip-Softmax without ring.
- `TRTLLM_ATTN` is BF16-only (no attention-level quantization).
- `TRTLLM_ATTN` is BF16-only (no attention-level quantization).
+69
View File
@@ -0,0 +1,69 @@
# FLUX.2-klein
> Text-guided image editing with `black-forest-labs/FLUX.2-klein-4B`
## Summary
- Vendor: Black Forest Labs
- Model: `black-forest-labs/FLUX.2-klein-4B`
- Task: Image editing (image-to-image)
- Mode: Offline inference
- Maintainer: Community
## When to use this recipe
Use this recipe to run single-image edits with FLUX.2-klein-4B through the
shared offline image-edit example.
## References
- Model card: <https://huggingface.co/black-forest-labs/FLUX.2-klein-4B>
- Shared runnable example:
[`examples/offline_inference/image_to_image/image_edit.py`](../../examples/offline_inference/image_to_image/image_edit.py)
- CPU offload guide:
[`docs/user_guide/diffusion/cpu_offload.md`](../../docs/user_guide/diffusion/cpu_offload.md)
## Hardware Support
This recipe documents one validated Intel XPU configuration. Extend it with
more hardware sections as community validation lands.
## XPU
### 1x Intel Arc Pro B70 (32 GB)
BF16 weights with model-level CPU offload. `--image` takes any RGB image.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.13.0+xpu
- vLLM: 0.29.0 (`98dff2a8`)
- vLLM-Omni: `main` at `4c7a98c2`
#### Command
```bash
python examples/offline_inference/image_to_image/image_edit.py \
--model black-forest-labs/FLUX.2-klein-4B \
--image test_input.png \
--prompt "Add a sunset sky with orange and purple clouds" \
--num-inference-steps 28 \
--guidance-scale 3.5 \
--enable-cpu-offload \
--enforce-eager \
--seed 42 \
--output image_edit_output.png
```
#### Verification
Confirm `image_edit_output.png` follows the prompt while preserving the rest
of the input image.
#### Notes
- Memory usage: 7.7 GiB loaded in 8.4 s, about 18 s per edit.
- Known limitations: only offline editing of the 4B checkpoint was qualified.
`FLUX.2-klein-9B` and online serving are out of scope for this profile.
+5 -5
View File
@@ -27,7 +27,7 @@ For Design-Layer, the first returned image is the reconstructed composite and th
- Python: 3.10+
- CUDA: 13.0
- vLLM version: 0.29.0
- vLLM-Omni version or commit: 59db6a428
- vLLM-Omni version or commit: 6daf5b30f
- 2x H100 80GB (1xH100 to be validated)
### Commands
@@ -52,7 +52,7 @@ curl -s http://127.0.0.1:8091/v1/chat/completions \
"modalities": ["image"],
"extra_body": {
"height": 1024, "width": 1024,
"steps": 12, "cfg": 1.0, "seed": 42
"num_inference_steps": 12, "guidance_scale": 1.0, "seed": 42
}
}' \
| jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' \
@@ -79,7 +79,7 @@ jq -n \
modalities: ["image"],
extra_body: {
height: 1024, width: 1024,
steps: 12, cfg: 1.0, seed: 42
num_inference_steps: 12, guidance_scale: 1.0, seed: 42
}
}' |
curl -sS http://127.0.0.1:8091/v1/chat/completions \
@@ -97,7 +97,7 @@ Note that the prompt should better depict each layer to be decomposed, we will r
```bash
MODEL=inclusionAI/Ming-Image-0.1-Design-Layer
INPUT_IMAGE=/path/to/input.png
PROMPT="Decompose this design into editable visual layers"
PROMPT="Decompose this image into 6 layers with the following specifications: Number of layers: 6 \nLayer 1: Central text ... Layer 2: ... Layer 6: ..."
jq -n \
--arg model "$MODEL" \
@@ -113,7 +113,7 @@ jq -n \
extra_body: {
num_layers: 6,
height: 1024, width: 1024,
steps: 12, cfg: 2.0, seed: 42
num_inference_steps: 12, guidance_scale: 2.0, seed: 42
}
}' |
curl -sS http://127.0.0.1:8091/v1/chat/completions \
+55
View File
@@ -141,3 +141,58 @@ confirm a valid 1024×1024 PNG is written.
no-CFG, True for Raw CFG).
- Known limitations: TP / SP / CFG-Parallel are not yet wired for Krea 2. HSDP,
layerwise CPU offload, and VAE-patch-parallel (decode) are supported.
## XPU
### 1x Intel Arc Pro B70 (32 GB)
Offline text-to-image for the distilled Turbo checkpoint at 1024x1024, with
layerwise offload streaming the transformer blocks.
#### Environment
- OS: Linux
- Python: 3.10+
- torch: 2.13.0+xpu
- vLLM: 0.29.0 (`98dff2a8`)
- vLLM-Omni: `main` at `4c7a98c2`
#### Command
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model krea/Krea-2-Turbo \
--prompt "a photo of a sunset over mountains" \
--height 1024 --width 1024 \
--num-inference-steps 8 \
--enable-layerwise-offload \
--vae-use-tiling \
--enforce-eager \
--output krea2_turbo_output.png
```
Given spare cards, `--enable-cpu-offload` replaces layerwise streaming. At 8
steps the swap dominates the run, and cards past the second shard nothing
further.
```bash
--tensor-parallel-size 2 \
--enable-cpu-offload
```
| Offload | Cards | Peak VRAM | Time |
| --- | ---: | ---: | ---: |
| `--enable-layerwise-offload` | 1 | 12.3 GiB | 27 s |
| `--enable-cpu-offload` | 2 | 25.6 GiB | 59 s |
| `--enable-cpu-offload` | 4 | 25.6 GiB | 59 s |
#### Verification
Confirm `krea2_turbo_output.png` is written as a 1024x1024 PNG matching the
prompt.
#### Notes
- Memory usage: 7.8 GiB loaded, 12.3 GiB peak, about 26 s per image.
- Known limitations: only offline Turbo generation was qualified. Krea-2-Raw,
LoRA, Cache-DiT, and online serving are out of scope for this profile.
+15 -2
View File
@@ -11,6 +11,9 @@ from vllm_omni.clients.duplex import AudioFormat
from vllm_omni.clients.minicpmo_4_5 import (
create_duplex_session_config as create_minicpmo45_session_config,
)
from vllm_omni.clients.nemotron_voicechat import (
create_duplex_session_config as create_nemotron_voicechat_session_config,
)
from vllm_omni.clients.personaplex import (
create_duplex_session_config as create_personaplex_session_config,
)
@@ -38,12 +41,22 @@ def test_personaplex_session_config_matches_deployment():
assert payload["instructions"] == "You are calm."
def test_nemotron_voicechat_session_config_matches_deployment():
tools = [{"type": "function", "name": "lookup", "parameters": {"type": "object"}}]
config = create_nemotron_voicechat_session_config(instructions="hi", tools=tools)
assert config.input_audio == AudioFormat("pcm_f32le", 16_000)
assert config.output_audio == AudioFormat("pcm16", 22_050)
payload = config.to_session_payload(model="nemotron")
assert payload["model"] == "nemotron" and payload["instructions"] == "hi"
assert payload["extra_body"] == {"auto_response": True, "realtime_tools": tools}
@pytest.mark.parametrize(
"duplex_package",
[
# PersonaPlex and Nemotron VoiceChat rejoin this list with the follow-up
# PRs that port them to the duplex plugin framework.
# PersonaPlex rejoins with its port to the duplex plugin framework.
"vllm_omni.model_executor.models.minicpmo_4_5.duplex",
"vllm_omni.model_executor.models.nemotron_voicechat.duplex",
],
)
def test_model_duplex_packages_stay_clear_of_client_library(duplex_package: str):
+22
View File
@@ -2857,6 +2857,28 @@ class TestPlatformOverrides:
config = deploy.stages[0].compilation_config or {}
assert "+rotary_embedding" not in config.get("custom_ops", [])
def test_qwen3_omni_talker_sampling_is_seeded(self):
"""The only stochastic Qwen3-Omni stage must stay reproducible.
Dropping this seed (as #4986 did) leaves the talker sampling codec
tokens unseeded at temperature 0.9, which reopened the audio-vs-text
nightly failures in #6090. Pin it so a cleanup cannot remove it again
without failing here.
"""
deploy_path = Path(get_deploy_config_path("qwen3_omni_moe.yaml"))
pipeline = resolve_pipeline_config(
"qwen3_omni_moe",
Q3_OMNI_ALL_STAGES_HF_CONFIG,
)
assert isinstance(pipeline, PipelineConfig)
for platform in ("cpu", "cuda", "musa", "npu", "rocm", "xpu"):
deploy = _apply_platform_overrides(load_deploy_config(deploy_path), platform=platform)
stages = merge_pipeline_deploy(pipeline, deploy)
talker_sampling = stages[1].yaml_extras["default_sampling_params"]
assert talker_sampling["temperature"] > 0.0, "talker is expected to sample, not decode greedily"
assert talker_sampling.get("seed") == 42, f"talker sampling lost its seed on {platform}"
def test_minicpmo_4_5_cuda_caps_talker_kv_cache(self):
pipeline = resolve_pipeline_config("minicpmo_4_5")
assert isinstance(pipeline, PipelineConfig)
+1 -1
View File
@@ -127,7 +127,7 @@ def test_mammothmoda2_diffusion_stage_projects_native_backend_config() -> None:
assert stage.diffusion_config.model_class_name == "MammothModa2DiTPipeline"
assert stage.diffusion_config.model == "/models/MammothModa2-Preview"
assert stage.diffusion_config.step_execution is False
assert stage.scheduler_config.max_num_seqs == 1
assert stage.scheduler_config.max_num_seqs == 8
assert stage.connector_config.omni_kv_config == {"need_recv_cache": False}
@@ -15,8 +15,6 @@ from tests.helpers.stage_config import get_deploy_config_path
pytestmark = [pytest.mark.slow, pytest.mark.omni]
_SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649")
def _minimal_chat_json(omni_server: OmniServer) -> dict[str, object]:
"""Minimal valid chat body; individual tests override one offending field."""
@@ -121,9 +119,8 @@ def _chat_completions_request_without_expectations(omni_server: OmniServer, case
pytest.param(
"modalities_list_bad",
400,
("modalities", "value_error", ""),
("modalities", "list of strings"),
id="modalities_list_bad_element",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"response_format_json_schema_incomplete",
@@ -131,7 +128,7 @@ def _chat_completions_request_without_expectations(omni_server: OmniServer, case
("response_format", "BadRequestError", "json_schema"),
id="invalid_response_format_json_schema",
),
pytest.param("logprobs_wrong_type", 400, "logprobs", id="logprobs_wrong_type", marks=_SKIP_ISSUE_3649),
pytest.param("logprobs_wrong_type", 400, ("logprobs", "boolean"), id="logprobs_wrong_type"),
pytest.param(
"logprobs_top_without_enabled",
400,
+114 -3
View File
@@ -5,13 +5,16 @@ from __future__ import annotations
import os
import tempfile
from contextlib import AbstractContextManager
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import get_distributed_init_method
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
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
@@ -578,6 +581,7 @@ def _run_attention_case(
num_kv_heads: int | None = None,
has_joint: bool = False,
joint_strategy: str = "front",
mask_layout: str | None = None,
) -> None:
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
current_omni_platform.set_device(device)
@@ -620,6 +624,7 @@ def _run_attention_case(
q_full = torch.from_numpy(payload["q"]).to(device=device)
k_full = torch.from_numpy(payload["k"]).to(device=device)
v_full = torch.from_numpy(payload["v"]).to(device=device)
mask_full = torch.from_numpy(payload["mask"]).to(device=device) if "mask" in payload else None
if has_joint:
joint_q = torch.from_numpy(payload["joint_q"]).to(device=device)
joint_k = torch.from_numpy(payload["joint_k"]).to(device=device)
@@ -640,6 +645,7 @@ def _run_attention_case(
v = torch.cat([v_full, joint_v], dim=1)
else:
q, k, v = q_full, k_full, v_full
mask = mask_full
else:
if split_sizes is None:
# NOTE: torch.chunk may return fewer than `world_size` chunks for some
@@ -659,14 +665,18 @@ def _run_attention_case(
q = torch.split(q_full, split_sizes, dim=1)[local_rank].contiguous()
k = torch.split(k_full, split_sizes, dim=1)[local_rank].contiguous()
v = torch.split(v_full, split_sizes, dim=1)[local_rank].contiguous()
if mask_full is None:
mask = None
elif mask_layout == "global":
mask = mask_full
else:
raise ValueError("mask_layout must be 'global' when a mask is provided.")
# The Attention layer only enables SP communication when ForwardContext.sp_active is True.
# In production this is managed by SequenceParallelSplitHook/GatherHook, but here we
# shard manually for testing.
get_forward_context()._sp_shard_depth = 1
if has_joint:
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
attn_metadata = AttentionMetadata(
joint_query=joint_q,
joint_key=joint_k,
@@ -682,6 +692,8 @@ def _run_attention_case(
raise ValueError(f"Invalid sdp_kernel_mode: {sdp_kernel_mode!r}")
with sdp_ctx:
if mask is not None:
attn_metadata = AttentionMetadata(attn_mask=mask)
out_local = attn(q, k, v, attn_metadata=attn_metadata).contiguous()
if world_size == 1:
@@ -908,7 +920,7 @@ def _run_contract_skip_case(
ctx = get_forward_context()
ctx._sp_shard_depth = 1
def make_sdp_ctx() -> object:
def make_sdp_ctx() -> AbstractContextManager[None]:
# Fresh instance per phase: the @contextmanager instance releases
# its state on first exit and cannot be re-entered.
return torch.backends.cuda.sdp_kernel(enable_flash=False, enable_mem_efficient=False, enable_math=True)
@@ -1011,6 +1023,105 @@ def test_ulysses_uaa_contract_skips_length_gather_matches_slow_path(num_heads: i
pass
@pytest.mark.core_model
@pytest.mark.parametrize("mask_layout", ["global"])
@hardware_test(res={"cuda": "L4"}, num_cards=2)
def test_ulysses_uaa_2d_mask_layout_matches_baseline(
mask_layout: str,
) -> None:
world_size = 2
batch_size = 2
seq_len = 5
split_sizes = [2, 3]
num_heads = 3
head_size = 8
base_init_method = get_distributed_init_method()
sp_init_method = get_distributed_init_method()
with tempfile.NamedTemporaryFile(delete=False, suffix=".npz") as f_in:
input_file = f_in.name
with tempfile.NamedTemporaryFile(delete=False, suffix=".npy") as f_base:
baseline_file = f_base.name
with tempfile.NamedTemporaryFile(delete=False, suffix=".npy") as f_sp:
sp_file = f_sp.name
try:
torch.manual_seed(0)
q = torch.randn(batch_size, seq_len, num_heads, head_size, dtype=torch.float32)
k = torch.randn(batch_size, seq_len, num_heads, head_size, dtype=torch.float32)
v = torch.randn(batch_size, seq_len, num_heads, head_size, dtype=torch.float32)
mask = torch.tensor(
[
[True, True, True, True, False],
[True, True, True, False, False],
],
dtype=torch.bool,
)
np.savez(
input_file,
q=q.numpy(),
k=k.numpy(),
v=v.numpy(),
mask=mask.numpy(),
)
# Baseline (no SP), mask is used verbatim.
torch.multiprocessing.spawn(
_run_attention_case,
args=(
1,
base_init_method,
input_file,
baseline_file,
num_heads,
head_size,
1,
"strict",
1,
None,
"math",
None,
False,
"front",
"global",
),
nprocs=1,
)
# SP (Ulysses-P with UAA), mask is shared globally across ranks.
torch.multiprocessing.spawn(
_run_attention_case,
args=(
world_size,
sp_init_method,
input_file,
sp_file,
num_heads,
head_size,
world_size,
"advanced_uaa",
1,
split_sizes,
"math",
None,
False,
"front",
mask_layout,
),
nprocs=world_size,
)
baseline = torch.from_numpy(np.load(baseline_file, allow_pickle=False))
sp = torch.from_numpy(np.load(sp_file, allow_pickle=False))
torch.testing.assert_close(sp, baseline, atol=1e-5, rtol=1e-5)
finally:
for path in (input_file, baseline_file, sp_file):
try:
os.remove(path)
except OSError:
pass
@pytest.mark.parametrize(
"num_heads,num_kv_heads,joint_len",
[
-15
View File
@@ -1,15 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
@pytest.fixture(autouse=True)
def restore_torch_default_dtype():
"""Keep process-global dtype changes from leaking between diffusion tests."""
default_dtype = torch.get_default_dtype()
try:
yield
finally:
torch.set_default_dtype(default_dtype)
@@ -0,0 +1,317 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import os
import socket
import tempfile
import numpy as np
import pytest
import torch
from vllm.config import DeviceConfig, VllmConfig, set_current_vllm_config
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.config import set_current_diffusion_config
from vllm_omni.diffusion.data import (
DiffusionParallelConfig,
OmniDiffusionConfig,
TransformerConfig,
)
from vllm_omni.diffusion.distributed.parallel_state import (
destroy_distributed_env,
init_distributed_environment,
initialize_model_parallel,
)
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig
from vllm_omni.diffusion.forward_context import (
get_forward_context,
set_forward_context,
)
from vllm_omni.diffusion.hooks.sequence_parallel import apply_sequence_parallel
from vllm_omni.platforms import current_omni_platform
pytestmark = [
pytest.mark.core_model,
pytest.mark.diffusion,
pytest.mark.parallel,
]
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _set_dist_env(rank: int, world_size: int, port: int) -> None:
os.environ.update(
{
"RANK": str(rank),
"LOCAL_RANK": str(rank),
"WORLD_SIZE": str(world_size),
"MASTER_ADDR": "127.0.0.1",
"MASTER_PORT": str(port),
"DIFFUSION_ATTENTION_BACKEND": "TORCH_SDPA",
}
)
def _transformer_config() -> TransformerConfig:
return TransformerConfig.from_dict(
{
"patch_size": 2,
"in_channels": 4,
"hidden_size": 64,
"num_layers": 2,
"num_double_stream_layers": 1,
"num_refiner_layers": 1,
"num_attention_heads": 4,
"num_kv_heads": 1,
"multiple_of": 32,
"norm_eps": 1e-5,
"axes_dim_rope": [8, 4, 4],
"axes_lens": [64, 32, 32],
"instruction_feature_configs": {
"instruction_feat_dim": 32,
"reduce_type": "mean",
"num_instruction_feature_layers": 1,
},
"prompt_tuning_configs": {"use_prompt_tuning": False},
"timestep_scale": 1.0,
}
)
def _od_config(world_size: int) -> OmniDiffusionConfig:
return OmniDiffusionConfig(
model="boogu-tiny",
dtype=torch.float32,
tf_model_config=_transformer_config(),
parallel_config=DiffusionParallelConfig(
sequence_parallel_size=world_size,
ulysses_degree=world_size,
ulysses_mode="advanced_uaa",
),
)
def _checkpoint_name(name: str) -> str:
if ".to_out." in name:
name = name.replace(".to_out.", ".to_out.0.")
for projection in (
"img_to_q",
"img_to_k",
"img_to_v",
"instruct_to_q",
"instruct_to_k",
"instruct_to_v",
"instruct_out",
"img_out",
):
token = f".img_instruct_attn.{projection}."
if token in name:
return name.replace(
token,
f".img_instruct_attn.processor.{projection}.",
)
return name
def _random_checkpoint(model: torch.nn.Module) -> list[tuple[str, torch.Tensor]]:
generator = torch.Generator(device=next(model.parameters()).device).manual_seed(2026)
return [
(
_checkpoint_name(name),
torch.empty_like(param).uniform_(
-0.02,
0.02,
generator=generator,
),
)
for name, param in model.named_parameters()
]
def _worker(
rank: int,
world_size: int,
port: int,
output_file: str,
) -> None:
device = torch.device(f"{current_omni_platform.device_type}:{rank}")
current_omni_platform.set_device(device)
_set_dist_env(rank, world_size, port)
init_distributed_environment(world_size=world_size, rank=rank)
initialize_model_parallel(
data_parallel_size=1,
cfg_parallel_size=1,
sequence_parallel_size=world_size,
ulysses_degree=world_size,
ring_degree=1,
tensor_parallel_size=1,
pipeline_parallel_size=1,
)
try:
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
BooguImageDoubleStreamRotaryPosEmbed,
BooguImageTransformer2DModel,
)
od_config = _od_config(world_size)
with (
set_current_vllm_config(VllmConfig(device_config=DeviceConfig(device=str(device)))),
set_forward_context(omni_diffusion_config=od_config),
set_current_diffusion_config(od_config),
):
source = BooguImageTransformer2DModel(od_config).to(device)
checkpoint = _random_checkpoint(source)
del source
model = BooguImageTransformer2DModel(od_config).to(device)
assert model.load_weights(checkpoint) == set(dict(model.named_parameters()))
model.eval()
if world_size > 1:
apply_sequence_parallel(
model,
SequenceParallelConfig(ulysses_degree=world_size),
model._sp_plan,
)
get_forward_context().sp_plan_hooks_applied = True
generator = torch.Generator(device=device).manual_seed(17)
latents = torch.randn(
1,
4,
8,
8,
device=device,
generator=generator,
)
timestep = torch.tensor([0.5], device=device)
instruction = torch.randn(
1,
7,
32,
device=device,
generator=generator,
)
instruction_mask = torch.ones(
1,
7,
dtype=torch.bool,
device=device,
)
ref_image = [
[
torch.randn(
4,
6,
10,
device=device,
generator=generator,
)
]
]
# Two differently sized references: their packed segments straddle
# the SP rank boundary, which is what the per-rank reference-refiner
# mask has to reproduce.
ref_images_multi = [
[
torch.randn(4, 6, 10, device=device, generator=generator),
torch.randn(4, 4, 8, device=device, generator=generator),
]
]
freqs_real = BooguImageDoubleStreamRotaryPosEmbed.get_freqs_real(
model.axes_dim_rope,
model.axes_lens,
theta=10000,
)
with torch.inference_mode():
t2i = model(
latents,
timestep,
instruction,
freqs_real,
instruction_mask,
)
edit = model(
latents,
timestep,
instruction,
freqs_real,
instruction_mask,
ref_image_hidden_states=ref_image,
)
edit_multi = model(
latents,
timestep,
instruction,
freqs_real,
instruction_mask,
ref_image_hidden_states=ref_images_multi,
)
if rank == 0:
np.savez(
output_file,
t2i=t2i.cpu().numpy(),
edit=edit.cpu().numpy(),
edit_multi=edit_multi.cpu().numpy(),
)
finally:
destroy_distributed_env()
@hardware_test(res={"cuda": "L4"}, num_cards=2)
def test_boogu_sp_matches_single_gpu() -> None:
if not current_omni_platform.is_cuda() or current_omni_platform.get_device_count() < 2:
pytest.skip("BOOGU SP test requires two CUDA GPUs.")
output_files = []
try:
for _ in range(2):
with tempfile.NamedTemporaryFile(
delete=False,
suffix=".npz",
) as handle:
output_files.append(handle.name)
torch.multiprocessing.spawn(
_worker,
args=(1, _find_free_port(), output_files[0]),
nprocs=1,
)
torch.multiprocessing.spawn(
_worker,
args=(2, _find_free_port(), output_files[1]),
nprocs=2,
)
with (
np.load(output_files[0], allow_pickle=False) as baseline,
np.load(output_files[1], allow_pickle=False) as sp_output,
):
for key in ("t2i", "edit", "edit_multi"):
# fp32 SDPA: the observed SP1-vs-SP2 gap is ~3e-8, so 2e-4
# would also pass with the SP masking logic entirely removed
# (that mutation measures ~5e-7). Mask correctness is pinned
# structurally in tests/diffusion/models/boogu_image/
# test_sp_layout.py; this bound just stays close to the real
# reduction-order noise instead of hiding logic errors.
np.testing.assert_allclose(
sp_output[key],
baseline[key],
rtol=1e-5,
atol=1e-5,
)
finally:
for path in output_files:
try:
os.remove(path)
except OSError:
pass
@@ -115,6 +115,47 @@ class TestSequenceParallelPlanValidation:
with pytest.raises(ValueError, match="split_output=True"):
validate_sp_plan(plan)
def test_keyed_metadata_requires_auto_pad(self):
plan = {
"prepare": {
0: SequenceParallelInput(
split_dim=1,
split_output=True,
shard_group="image",
),
},
"output": SequenceParallelOutput(
gather_dim=1,
shard_group="image",
),
}
with pytest.raises(ValueError, match="requires auto_pad=True"):
validate_sp_plan(plan)
def test_shard_group_is_scoped_to_one_split_boundary(self):
plan = {
"prepare_image": {
0: SequenceParallelInput(
split_dim=1,
split_output=True,
auto_pad=True,
shard_group="image",
),
},
"prepare_rope": {
0: SequenceParallelInput(
split_dim=1,
split_output=True,
auto_pad=True,
shard_group="image",
),
},
}
with pytest.raises(ValueError, match="multiple split boundaries"):
validate_sp_plan(plan)
@pytest.mark.cpu
class TestGetSpPlanFromModel:
@@ -1006,6 +1047,8 @@ class TestDimensionValidation:
return "NO_MASK_BACKEND"
monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 2)
monkeypatch.setattr(parallel_state, "get_sequence_parallel_rank", lambda: 0)
monkeypatch.setattr(parallel_state, "get_ring_parallel_world_size", lambda: 1)
monkeypatch.setattr(selector, "get_attn_backend_for_capability", lambda **kwargs: NoMaskBackend)
hook = SequenceParallelSplitHook(
@@ -1015,7 +1058,7 @@ class TestDimensionValidation:
tensor = torch.randn(1, 3, 8)
with pytest.raises(ValueError, match="does not support attention_mask"):
hook._shard_with_auto_pad(tensor, dim=1)
hook._shard_with_auto_pad(tensor, dim=1, shard_group=None)
def test_auto_pad_allows_explicit_cudnn(self, monkeypatch):
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, OmniDiffusionConfig
@@ -1040,7 +1083,7 @@ class TestDimensionValidation:
)
with set_forward_context(omni_diffusion_config=od_config):
result = hook._shard_with_auto_pad(tensor, dim=1)
result = hook._shard_with_auto_pad(tensor, dim=1, shard_group=None)
# seq_len=3 padded to 4, then sharded by world_size=2 → rank 0 gets 2.
assert result.shape == (1, 2, 8)
@@ -1070,7 +1113,7 @@ class TestDimensionValidation:
with set_forward_context(omni_diffusion_config=od_config):
with pytest.raises(ValueError, match="does not support attention_mask"):
hook._shard_with_auto_pad(tensor, dim=1)
hook._shard_with_auto_pad(tensor, dim=1, shard_group=None)
@pytest.mark.cpu
@@ -1227,6 +1270,158 @@ class TestEqualPadContract:
assert not ctx.sp_rank_local_seq_lens_equal
@pytest.mark.cpu
class TestKeyedShardMetadata:
"""Per-shard_group padding metadata, for models with several sequences.
A model that splits image, reference and context tokens at one boundary
needs each one's pre-padding global length to rebuild masks; the singleton
sp_original_seq_len can only describe one of them.
"""
def test_auto_pad_records_the_global_length_under_its_key(self, monkeypatch):
from vllm_omni.diffusion.attention import selector
from vllm_omni.diffusion.distributed import parallel_state
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig
from vllm_omni.diffusion.forward_context import (
get_forward_context,
get_sp_shard_original_seq_len,
set_forward_context,
)
from vllm_omni.diffusion.hooks.sequence_parallel import (
SequenceParallelSplitHook,
)
class MaskBackend:
supports_attention_mask = True
@staticmethod
def get_name():
return "test"
monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 2)
monkeypatch.setattr(parallel_state, "get_sequence_parallel_rank", lambda: 1)
monkeypatch.setattr(parallel_state, "get_ring_parallel_world_size", lambda: 1)
monkeypatch.setattr(selector, "get_attn_backend_for_role", lambda **_: (MaskBackend(), None))
hook = SequenceParallelSplitHook(
{
0: SequenceParallelInput(
split_dim=1,
expected_dims=3,
split_output=True,
auto_pad=True,
shard_group="image",
)
},
SequenceParallelConfig(ulysses_degree=2),
)
hook.initialize_hook(nn.Identity())
with set_forward_context():
hook.pre_forward(nn.Identity())
shard = hook.post_forward(nn.Identity(), torch.arange(5).reshape(1, 5, 1))
ctx = get_forward_context()
assert shard.shape[1] == 3
# The *pre*-padding length, which is what a mask has to clip to.
assert ctx.sp_shard_metadata["image"] == 5
assert get_sp_shard_original_seq_len("image") == 5
assert get_sp_shard_original_seq_len("context") is None
def test_gather_trims_to_its_key_and_leaves_other_groups(self, monkeypatch):
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig
from vllm_omni.diffusion.forward_context import (
get_forward_context,
set_forward_context,
)
from vllm_omni.diffusion.hooks import sequence_parallel
from vllm_omni.diffusion.hooks.sequence_parallel import (
SequenceParallelGatherHook,
)
monkeypatch.setattr(
sequence_parallel,
"sp_gather",
lambda tensor, dim, validate: torch.cat([tensor, tensor], dim=dim),
)
hook = SequenceParallelGatherHook(
SequenceParallelOutput(gather_dim=1, expected_dims=3, shard_group="image"),
SequenceParallelConfig(ulysses_degree=2),
)
with set_forward_context():
ctx = get_forward_context()
ctx.sp_shard_metadata["image"] = 5
ctx.sp_shard_metadata["context"] = 7
ctx._sp_shard_depth = 1
output = hook.post_forward(nn.Identity(), torch.zeros(1, 3, 4))
# 2x3 gathered, trimmed back to "image"'s recorded 5.
assert output.shape == (1, 5, 4)
# A concurrent group is untouched.
assert ctx.sp_shard_metadata == {"context": 7}
def test_gather_without_registered_metadata_is_an_error(self, monkeypatch):
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.hooks import sequence_parallel
from vllm_omni.diffusion.hooks.sequence_parallel import (
SequenceParallelGatherHook,
)
monkeypatch.setattr(
sequence_parallel,
"sp_gather",
lambda tensor, dim, validate: torch.cat([tensor, tensor], dim=dim),
)
hook = SequenceParallelGatherHook(
SequenceParallelOutput(gather_dim=1, expected_dims=3, shard_group="image"),
SequenceParallelConfig(ulysses_degree=2),
)
with set_forward_context(), pytest.raises(RuntimeError, match="shard_group='image'"):
hook.post_forward(nn.Identity(), torch.zeros(1, 3, 4))
def test_conflicting_lengths_in_one_group_are_rejected(self, monkeypatch):
from vllm_omni.diffusion.attention import selector
from vllm_omni.diffusion.distributed import parallel_state
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.hooks.sequence_parallel import (
SequenceParallelSplitHook,
)
class MaskBackend:
supports_attention_mask = True
@staticmethod
def get_name():
return "test"
monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 2)
monkeypatch.setattr(parallel_state, "get_sequence_parallel_rank", lambda: 1)
monkeypatch.setattr(parallel_state, "get_ring_parallel_world_size", lambda: 1)
monkeypatch.setattr(selector, "get_attn_backend_for_role", lambda **_: (MaskBackend(), None))
spm = SequenceParallelInput(
split_dim=1,
expected_dims=3,
split_output=True,
auto_pad=True,
shard_group="image",
)
hook = SequenceParallelSplitHook({0: spm, 1: spm}, SequenceParallelConfig(ulysses_degree=2))
hook.initialize_hook(nn.Identity())
with set_forward_context(), pytest.raises(ValueError, match="same global sequence length"):
hook.pre_forward(nn.Identity())
hook.post_forward(
nn.Identity(),
(torch.arange(5).reshape(1, 5, 1), torch.arange(6).reshape(1, 6, 1)),
)
@pytest.mark.cpu
class TestSequenceParallelConfig:
"""Test SequenceParallelConfig dataclass."""
+374
View File
@@ -0,0 +1,374 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.diffusion.layers import mhc
from vllm_omni.diffusion.models.magi2 import layers as model_layers
pytestmark = [pytest.mark.diffusion, pytest.mark.core_model]
@pytest.fixture(autouse=True)
def clear_mhc_failure_cache():
"""Keep the process-lifetime production cache isolated between tests."""
mhc._FAILED_MHC_KERNELS.clear()
mhc._WARNED_MHC_KERNELS.clear()
yield
mhc._FAILED_MHC_KERNELS.clear()
mhc._WARNED_MHC_KERNELS.clear()
def coefficients(tokens=7, streams=4):
torch.manual_seed(71)
# Actual model split views: post and residual share a wider FP32 row.
packed = torch.randn(tokens, streams * (streams + 2))
post = packed[:, streams : 2 * streams]
residual = packed[:, 2 * streams :].view(tokens, streams, streams)
return post, residual, torch.tensor([0.8]), torch.randn(streams), torch.tensor([1.1]), torch.randn(streams, streams)
def reference(args, *, scale=0.125, iterations=20, epsilon=1e-12, out_dtype=torch.float32):
post, residual, ap, bp, ar, br = args
post = 2.0 * torch.sigmoid(ap * scale * post + bp.unsqueeze(0))
residual = ar * scale * residual.float() + br.unsqueeze(0).float()
residual = torch.exp(residual - residual.amax(dim=(-2, -1), keepdim=True))
for _ in range(iterations):
residual = residual / (residual.sum(-2, keepdim=True) + epsilon)
residual = residual / (residual.sum(-1, keepdim=True) + epsilon)
return post.to(out_dtype), residual.to(out_dtype)
def mix_reference(args):
streams, output, post, matrix = args
branch = torch.einsum("tn,tc->tnc", post, output)
mixed = torch.einsum("tij,tjc->tic", matrix, streams)
return mixed + branch
def mix_inputs(tokens=7, hidden=70, dtype=torch.float32, streams=4):
torch.manual_seed(41)
return (
torch.randn(tokens, streams, hidden).to(dtype),
torch.randn(tokens, hidden).to(dtype),
torch.rand(tokens, streams).to(dtype),
torch.randn(tokens, streams, streams).to(dtype),
)
@pytest.mark.cpu
@pytest.mark.parametrize("streams", [3, 4])
@pytest.mark.parametrize("iterations", [-1, 0, 1, 20, 65])
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
def test_native_coefficient_contract(streams, iterations, dtype):
args = coefficients(streams=streams)
kwargs = dict(scale=0.125, iterations=iterations, out_dtype=dtype)
actual = mhc.MHCPostResidual().forward_cuda(*args, **kwargs)
for a, b in zip(actual, reference(args, **kwargs)):
torch.testing.assert_close(a, b, rtol=0, atol=0)
@pytest.mark.cpu
def test_native_gradients_and_reused_parameters():
op = mhc.MHCPostResidual()
args = tuple(x.detach().clone().requires_grad_() for x in coefficients())
expected_args = tuple(x.detach().clone().requires_grad_() for x in args)
result = op.forward_cuda(*args, scale=0.125, out_dtype=torch.float32)
expected = reference(expected_args)
sum(x.square().sum() for x in result).backward()
sum(x.square().sum() for x in expected).backward()
for a, b in zip(args, expected_args):
torch.testing.assert_close(a.grad, b.grad, rtol=0, atol=0)
with torch.no_grad():
args[3].add_(2)
result = op.forward_cuda(*args, scale=0.125, out_dtype=torch.float32)
for a, b in zip(result, reference(args)):
torch.testing.assert_close(a, b, rtol=0, atol=0)
@pytest.mark.cpu
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
def test_native_mix_dtype_and_noncontiguous(dtype):
args = mix_inputs(dtype=dtype)
args = (args[0][..., ::2], args[1][..., ::2], *args[2:])
torch.testing.assert_close(mhc.MHCMix().forward_cuda(*args), mix_reference(args), rtol=0, atol=0)
@pytest.mark.cpu
def test_meta_shapes_and_empty():
args = tuple(x.to("meta") for x in coefficients())
post, matrix = mhc.MHCPostResidual().forward_cuda(*args, scale=0.125, out_dtype=torch.bfloat16)
assert post.shape == (7, 4) and matrix.shape == (7, 4, 4)
assert post.dtype == matrix.dtype == torch.bfloat16
mix = mhc.MHCMix().forward_cuda(*[x.to("meta") for x in mix_inputs()])
assert mix.shape == (7, 4, 70) and mix.device.type == "meta"
args = coefficients(tokens=0)
post, matrix = mhc.MHCPostResidual().forward_cuda(*args, scale=0.125, out_dtype=torch.float32)
assert post.shape == (0, 4) and matrix.shape == (0, 4, 4)
assert mhc.MHCMix().forward_cuda(*mix_inputs(tokens=0)).shape == (0, 4, 70)
@pytest.mark.cpu
def test_sinkhorn_alias_and_rectangular_native_contract():
assert model_layers.sinkhorn_knopp is mhc.sinkhorn_knopp
logits = torch.randn(2, 3, 4)
expected = torch.exp(logits - logits.amax((-2, -1), keepdim=True))
torch.testing.assert_close(mhc.sinkhorn_knopp(logits, 0, 1e-12), expected, rtol=0, atol=0)
@pytest.mark.cpu
def test_model_wiring_uses_fused_eager_path(monkeypatch):
calls = []
class Prepare:
def __call__(self, *args, **kwargs):
calls.append("dispatch")
return reference(args, **kwargs)
def forward_native(self, *args, **kwargs):
calls.append("native")
return reference(args, **kwargs)
monkeypatch.setattr(model_layers, "_mhc_post_residual", Prepare())
handler = model_layers.MHCHandler(4, 16)
post, residual, ap, bp, ar, br = coefficients()
handler.compute_post_residual((ap, bp, post), (ar, br, residual), out_dtype=torch.float32)
assert calls == ["dispatch"]
@pytest.mark.cpu
def test_compile_keeps_native_graph():
torch._dynamo.reset()
targets: list[str] = []
def backend(gm, _inputs):
targets.extend(str(node.target) for node in gm.graph.nodes)
return gm.forward
op = torch.compile(mhc.MHCPostResidual(), fullgraph=True, backend=backend)
args = coefficients()
actual = op(*args, scale=0.125, iterations=2, out_dtype=torch.float32)
for a, b in zip(actual, reference(args, iterations=2)):
torch.testing.assert_close(a, b, rtol=0, atol=0)
assert any("sigmoid" in x for x in targets) and any("sum" in x for x in targets)
assert not any("triton" in x for x in targets)
DEVICES = [pytest.param("cuda", marks=pytest.mark.cuda), pytest.param("musa", marks=pytest.mark.musa)]
def device(name):
api = getattr(torch, name, None)
if api is None or not api.is_available():
pytest.skip(f"requires {name}")
return torch.device(name)
def spy(monkeypatch, name):
original = getattr(mhc, name)
launches = []
class Spy:
def __getitem__(self, grid):
launches.append(grid)
return original[grid]
monkeypatch.setattr(mhc, name, Spy())
return launches
@pytest.mark.parametrize("device_name", DEVICES)
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
@pytest.mark.parametrize("tokens,iterations", [(0, 20), (1, 0), (17, 20)])
def test_coefficients_gpu(monkeypatch, device_name, dtype, tokens, iterations):
dev = device(device_name)
# Preserve the model's strided split views on the GPU too.
args = coefficients(tokens)
packed = torch.zeros(tokens, 24, device=dev)
packed[:, 4:8] = args[0].to(dev)
packed[:, 8:] = args[1].reshape(tokens, 16).to(dev)
args = (packed[:, 4:8], packed[:, 8:].view(tokens, 4, 4), *(x.to(dev) for x in args[2:]))
launches = spy(monkeypatch, "_mhc_post_residual_kernel")
actual = mhc.MHCPostResidual()(*args, scale=0.125, iterations=iterations, out_dtype=dtype)
getattr(torch, device_name).synchronize()
assert len(launches) == bool(tokens)
tolerance = {torch.float32: 1e-5, torch.float16: 2e-3, torch.bfloat16: 1.6e-2}[dtype]
for a, b in zip(actual, reference(args, iterations=iterations, out_dtype=dtype)):
torch.testing.assert_close(a, b, rtol=tolerance, atol=1e-6)
@pytest.mark.parametrize("device_name", DEVICES)
def test_sinkhorn_gpu_shift_and_extremes(device_name):
dev = device(device_name)
args = tuple(x.to(dev) for x in coefficients())
op = mhc.MHCPostResidual()
post, matrix = op(*args, scale=1.0, out_dtype=torch.float32)
shifted = (args[0], args[1] + 16.0, *args[2:])
shifted_post, shifted_matrix = op(*shifted, scale=1.0, out_dtype=torch.float32)
torch.testing.assert_close(shifted_matrix, matrix, rtol=1e-5, atol=3e-6)
torch.testing.assert_close(shifted_post, post, rtol=0, atol=0)
torch.testing.assert_close(matrix.sum(-1), torch.ones_like(post), rtol=1e-5, atol=1e-5)
# A well-conditioned case converges in both dimensions at the requested
# iteration count. Extreme matrices below need not converge in 20 steps.
balanced_args = list(args)
balanced_args[1] = args[1] * 0.125
balanced_args[5] = torch.zeros_like(args[5])
_, balanced = op(*balanced_args, scale=1.0, out_dtype=torch.float32)
for dim in (-2, -1):
torch.testing.assert_close(balanced.sum(dim), torch.ones_like(post), rtol=1e-5, atol=1e-5)
extreme = list(args)
extreme[1] = torch.eye(4, device=dev).expand(7, -1, -1).contiguous() * 1000
extreme[1][0, 0, 0] = float("nan")
extreme[1][1, 0, 0] = float("inf")
for iters in (0, 20):
actual = op(*extreme, scale=1.0, iterations=iters, out_dtype=torch.float32)
expected = reference(extreme, scale=1.0, iterations=iters)
for a, b in zip(actual, expected):
torch.testing.assert_close(a, b, rtol=1e-5, atol=1e-5, equal_nan=True)
@pytest.mark.parametrize("device_name", DEVICES)
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
@pytest.mark.parametrize("tokens,hidden", [(0, 70), (2, 70), (17, 3072)])
def test_mix_gpu(monkeypatch, device_name, dtype, tokens, hidden):
dev = device(device_name)
args = tuple(x.to(dev) for x in mix_inputs(tokens, hidden, dtype))
launches = spy(monkeypatch, "_mhc_mix_kernel")
actual = mhc.MHCMix()(*args)
getattr(torch, device_name).synchronize()
assert len(launches) == bool(tokens)
tolerance = {torch.float32: 1e-5, torch.float16: 2e-3, torch.bfloat16: 1.6e-2}[dtype]
torch.testing.assert_close(actual, mix_reference(args), rtol=tolerance, atol=1e-5)
@pytest.mark.parametrize("device_name", DEVICES)
def test_mix_preserves_separate_bf16_rounding(device_name):
dev = device(device_name)
streams = torch.ones(1, 4, 1, device=dev, dtype=torch.bfloat16)
branch = torch.full((1, 1), 1.0078125, device=dev, dtype=torch.bfloat16)
post = torch.full((1, 4), 1.03125, device=dev, dtype=torch.bfloat16)
matrix = torch.eye(4, device=dev, dtype=torch.bfloat16).unsqueeze(0)
args = (streams, branch, post, matrix)
expected = mix_reference(args)
once = (
torch.einsum("tij,tjc->tic", matrix.float(), streams.float())
+ torch.einsum("tn,tc->tnc", post.float(), branch.float())
).bfloat16()
assert not torch.equal(expected, once), "fixture must distinguish the cast boundaries"
torch.testing.assert_close(mhc.MHCMix()(*args), expected, rtol=0, atol=0)
@pytest.mark.parametrize("device_name", DEVICES)
def test_gpu_unsupported_layout_falls_back(monkeypatch, device_name):
dev = device(device_name)
class Forbidden:
def __getitem__(self, _grid):
pytest.fail("unsupported layouts must not launch the kernel")
monkeypatch.setattr(mhc, "_mhc_post_residual_kernel", Forbidden())
monkeypatch.setattr(mhc, "_mhc_mix_kernel", Forbidden())
args = tuple(x.to(dev) for x in coefficients(streams=3))
actual = mhc.MHCPostResidual()(*args, scale=0.125, out_dtype=torch.float32)
for a, b in zip(actual, reference(args)):
torch.testing.assert_close(a, b, rtol=0, atol=0)
args = tuple(x.to(dev) for x in mix_inputs())
args = (args[0][..., ::2], args[1][..., ::2], *args[2:])
torch.testing.assert_close(mhc.MHCMix()(*args), mix_reference(args), rtol=0, atol=0)
# A singleton alpha with extra rank changes broadcasting, so it cannot be
# treated like the scalar alpha loaded by the eager kernel.
broadcast_args = list(x.to(dev) for x in coefficients())
broadcast_args[2] = broadcast_args[2].view(1, 1, 1)
actual = mhc.MHCPostResidual()(*broadcast_args, scale=0.125, out_dtype=torch.float32)
for a, b in zip(actual, reference(broadcast_args)):
torch.testing.assert_close(a, b, rtol=0, atol=0)
@pytest.mark.cpu
def test_no_triton_uses_native_fallback(monkeypatch):
monkeypatch.setattr(mhc, "HAS_TRITON", False)
args = coefficients(tokens=2)
actual = mhc.MHCPostResidual().forward_cuda(*args, scale=0.125, out_dtype=torch.float32)
for got, expected in zip(actual, reference(args)):
torch.testing.assert_close(got, expected, rtol=0, atol=0)
mix_args = mix_inputs(tokens=2)
torch.testing.assert_close(mhc.MHCMix().forward_cuda(*mix_args), mix_reference(mix_args), rtol=0, atol=0)
@pytest.mark.cpu
@pytest.mark.parametrize(
("device_type", "native_required"),
[("cpu", True), ("meta", True), ("hip", True), ("xpu", True), ("cuda", False), ("musa", False)],
)
def test_unvalidated_backend_uses_native(device_type, native_required):
tensor = SimpleNamespace(device=SimpleNamespace(type=device_type), requires_grad=False)
assert mhc._native_required((tensor,)) is native_required
@pytest.mark.cpu
def test_kernel_failure_is_cached_and_falls_back(monkeypatch):
monkeypatch.setattr(mhc, "HAS_TRITON", True)
monkeypatch.setattr(mhc, "_native_required", lambda tensors: False)
warnings = []
monkeypatch.setattr(mhc.logger, "warning", lambda *args, **kwargs: warnings.append((args, kwargs)))
class Broken:
def __getitem__(self, _grid):
def launch(*args, **kwargs):
raise RuntimeError("synthetic kernel failure")
return launch
monkeypatch.setattr(mhc, "_mhc_post_residual_kernel", Broken())
args = tuple(x.contiguous() for x in coefficients(tokens=2))
op = mhc.MHCPostResidual()
expected = reference(args)
for _ in range(2):
actual = op.forward_cuda(*args, scale=0.125, out_dtype=torch.float32)
for got, want in zip(actual, expected):
torch.testing.assert_close(got, want, rtol=0, atol=0)
assert ("cpu", 0, "post_residual") in mhc._FAILED_MHC_KERNELS
assert len(warnings) == 1
assert "post_residual" in str(warnings[0])
@pytest.mark.cpu
def test_kernel_oom_is_propagated_without_caching(monkeypatch):
monkeypatch.setattr(mhc, "HAS_TRITON", True)
monkeypatch.setattr(mhc, "_native_required", lambda tensors: False)
class OutOfMemory:
def __getitem__(self, _grid):
def launch(*args, **kwargs):
raise RuntimeError("synthetic CUDA out of memory")
return launch
monkeypatch.setattr(mhc, "_mhc_post_residual_kernel", OutOfMemory())
args = tuple(x.contiguous() for x in coefficients(tokens=2))
with pytest.raises(RuntimeError, match="out of memory"):
mhc.MHCPostResidual().forward_cuda(*args, scale=0.125, out_dtype=torch.float32)
assert ("cpu", 0, "post_residual") not in mhc._FAILED_MHC_KERNELS
@pytest.mark.parametrize("device_name", DEVICES)
def test_gpu_compiled_parameters_do_not_get_cached(monkeypatch, device_name):
dev = device(device_name)
class Forbidden:
def __getitem__(self, _grid):
pytest.fail("compilation must retain the native expression")
monkeypatch.setattr(mhc, "_mhc_post_residual_kernel", Forbidden())
op = torch.compile(mhc.MHCPostResidual(), backend="eager", fullgraph=True)
args = list(x.to(dev) for x in coefficients())
for bias in (0.0, 2.0, -1.0):
args[3].fill_(bias)
actual = op(*args, scale=0.125, out_dtype=torch.float32)
for a, b in zip(actual, reference(args)):
torch.testing.assert_close(a, b, rtol=0, atol=0)
@@ -365,8 +365,12 @@ def test_load_weights_maps_mllm_and_preserves_transformer():
("parallel_config", "cache_backend", "message"),
[
(DiffusionParallelConfig(tensor_parallel_size=2), "none", "Tensor parallelism"),
(DiffusionParallelConfig(ulysses_degree=2), "none", "Sequence parallelism"),
(DiffusionParallelConfig(ring_degree=2), "none", "Sequence parallelism"),
(DiffusionParallelConfig(ring_degree=2), "none", "Ulysses only"),
(
DiffusionParallelConfig(ulysses_degree=2, cfg_parallel_size=2),
"none",
"CFG parallelism is not validated",
),
(
DiffusionParallelConfig(use_hsdp=True, hsdp_shard_size=2),
"none",
@@ -421,6 +425,96 @@ def test_constructor_accepts_cfg_parallel(mock_dependencies, cfg_parallel_size):
assert hasattr(pipeline, "predict_noise_with_multi_branch_cfg")
def test_constructor_accepts_ulysses_uaa(
mock_dependencies,
monkeypatch,
):
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
BooguImagePipeline,
)
monkeypatch.setattr(
f"{_MODULE}.current_omni_platform.is_cuda",
lambda: True,
)
od_config = OmniDiffusionConfig(
model="dummy-boogu",
tf_model_config=TransformerConfig(
params={
"num_attention_heads": 28,
"num_kv_heads": 7,
}
),
dtype=torch.float32,
parallel_config=DiffusionParallelConfig(
ulysses_degree=2,
ulysses_mode="advanced_uaa",
),
)
pipeline = BooguImagePipeline(od_config=od_config)
assert pipeline.transformer is mock_dependencies["transformer"]
def test_constructor_requires_uaa_for_boogu_gqa(
mock_dependencies,
monkeypatch,
):
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
BooguImagePipeline,
)
monkeypatch.setattr(
f"{_MODULE}.current_omni_platform.is_cuda",
lambda: True,
)
od_config = OmniDiffusionConfig(
model="dummy-boogu",
tf_model_config=TransformerConfig(
params={
"num_attention_heads": 28,
"num_kv_heads": 7,
}
),
dtype=torch.float32,
parallel_config=DiffusionParallelConfig(
ulysses_degree=2,
ulysses_mode="strict",
),
)
with pytest.raises(ValueError, match="advanced_uaa"):
BooguImagePipeline(od_config=od_config)
def test_constructor_rejects_non_cuda_sequence_parallelism(
mock_dependencies,
monkeypatch,
):
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
BooguImagePipeline,
)
monkeypatch.setattr(
f"{_MODULE}.current_omni_platform.is_cuda",
lambda: False,
)
od_config = OmniDiffusionConfig(
model="dummy-boogu",
tf_model_config=TransformerConfig(params={}),
dtype=torch.float32,
parallel_config=DiffusionParallelConfig(
ulysses_degree=2,
ulysses_mode="advanced_uaa",
),
)
with pytest.raises(NotImplementedError, match="requires CUDA"):
BooguImagePipeline(od_config=od_config)
mock_dependencies["mllm_wrapper"].model.to.assert_not_called()
# ---------------------------------------------------------------------------
# Prompt-encoding tests (deterministic fakes, no constructor)
# ---------------------------------------------------------------------------
@@ -0,0 +1,132 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""L1 unit tests for BOOGU's SP shard-layout arithmetic.
These cover the property the model relies on to build attention masks without
communicating: every rank can derive *every* rank's segment bounds from the
global sequence length alone.
"""
import pytest
import torch
from vllm_omni.diffusion.models.boogu_image.sp_layout import (
ShardLayout,
rank_concat_mask_or_none,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class TestShardLayout:
def test_padding_and_local_extent(self):
layout = ShardLayout(original_seq_len=5, world_size=2, rank=0)
assert layout.padded_seq_len == 6
assert layout.local_seq_len == 3
assert layout.padding_size == 1
assert layout.bounds(0) == (0, 3)
assert layout.bounds(1) == (3, 6)
def test_valid_lengths_clip_to_each_rank(self):
layout = ShardLayout(original_seq_len=5, world_size=2, rank=0)
# Sample 0 has 5 valid tokens (3 on rank 0, 2 on rank 1); sample 1 has 2.
assert layout.valid_lengths([5, 2], rank=0) == [3, 2]
assert layout.valid_lengths([5, 2], rank=1) == [2, 0]
def test_segment_lengths_split_across_the_boundary(self):
layout = ShardLayout(original_seq_len=5, world_size=2, rank=0)
# Two reference images of 2 and 3 tokens; the second straddles rank 0/1.
assert layout.segment_lengths([[2, 3]], rank=0) == [[2, 1]]
assert layout.segment_lengths([[2, 3]], rank=1) == [[0, 2]]
@pytest.mark.parametrize("world_size", [1, 2, 4])
def test_per_rank_lengths_sum_to_the_global_length(self, world_size):
layout = ShardLayout(original_seq_len=13, world_size=world_size, rank=0)
for global_length in (0, 1, 7, 13):
total = sum(layout.valid_lengths([global_length], rank=r)[0] for r in range(world_size))
assert total == global_length
class TestRankConcatMask:
"""The mask must describe the sequence Ulysses forms after its all-to-all.
That sequence is rank0's local shard followed by rank1's, etc. -- not the
natural global ordering -- because each rank packs [context|ref|noise]
locally before attention.
"""
def test_mask_is_concatenation_of_per_rank_segments(self):
mask = rank_concat_mask_or_none(
[[2], [1]],
capacity=3,
like=torch.zeros(1, 1),
)
# rank 0 contributes [T, T, F], rank 1 contributes [T, F, F]
assert mask.tolist() == [[True, True, False, True, False, False]]
def test_all_valid_mask_is_dropped(self):
assert (
rank_concat_mask_or_none(
[[3], [3]],
capacity=3,
like=torch.zeros(1, 1),
)
is None
)
def test_required_keeps_an_all_valid_mask(self):
mask = rank_concat_mask_or_none(
[[3], [3]],
capacity=3,
like=torch.zeros(1, 1),
required=True,
)
assert mask is not None
assert bool(mask.all())
class TestRankConcatEquivalence:
"""The built mask must equal an all-gather of per-rank local masks.
BOOGU used to hand rank-local masks to the attention layer, which
all-gathered them into the global sequence. The model now builds that
global mask directly -- saving one collective per attention call -- so the
two constructions must agree exactly. This is asserted structurally
because the end-to-end SP1-vs-SP2 comparison is numerically insensitive to
masking (a run with masks entirely disabled still matches to ~5e-7).
"""
@staticmethod
def _all_gather_of_local_masks(per_rank_lengths, capacity):
"""What the deleted `dist.all_gather` path used to produce."""
batch_size = len(per_rank_lengths[0])
local_masks = []
for lengths in per_rank_lengths:
local = torch.zeros(batch_size, capacity, dtype=torch.bool)
for i, length in enumerate(lengths):
local[i, :length] = True
local_masks.append(local)
return torch.cat(local_masks, dim=1)
@pytest.mark.parametrize(
("global_len", "world_size", "sample_lengths"),
[
(5, 2, [5, 2]),
(8, 2, [8, 1]),
(13, 4, [13, 7, 0]),
(16, 4, [16]),
(7, 1, [7, 3]),
],
)
def test_matches_all_gather_of_local_masks(self, global_len, world_size, sample_lengths):
layout = ShardLayout(original_seq_len=global_len, world_size=world_size, rank=0)
per_rank = [layout.valid_lengths(sample_lengths, rank=r) for r in range(world_size)]
built = rank_concat_mask_or_none(
per_rank,
layout.local_seq_len,
like=torch.zeros(1, 1),
required=True,
)
assert torch.equal(built, self._all_gather_of_local_masks(per_rank, layout.local_seq_len))
@@ -1121,7 +1121,7 @@ def test_pipeline_init_passes_tokenizer_attrs_into_transformer(
def test_preprocess_i2v_image_and_action_video_inputs() -> None:
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import get_cosmos3_pre_process_func
preprocess = get_cosmos3_pre_process_func(SimpleNamespace())
preprocess = get_cosmos3_pre_process_func(SimpleNamespace(model_config={"guardrails": False}, tf_model_config=None))
i2v = SimpleNamespace(
prompt={"prompt": "A slow camera push.", "multi_modal_data": {"image": Image.new("RGB", (320, 160))}},
sampling_params=make_sampling_params(height=None, width=None, extra_args={}),
@@ -1155,6 +1155,130 @@ def test_preprocess_i2v_image_and_action_video_inputs() -> None:
assert additional["condition_frame_indexes_vision"] == [0, 1]
def test_preprocess_v2v_decodes_uploaded_video_path(tmp_path) -> None:
"""Serving may pass multipart uploads as /tmp/...mp4 path lists (#8073)."""
imageio = pytest.importorskip("imageio.v3")
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import get_cosmos3_pre_process_func
frames = [np.full((16, 32, 3), i * 40, dtype=np.uint8) for i in range(6)]
video_path = tmp_path / "vllm_omni_video_reference_test.mp4"
imageio.imwrite(video_path, frames, fps=4, codec="libx264")
preprocess = get_cosmos3_pre_process_func(SimpleNamespace(model_config={"guardrails": False}, tf_model_config=None))
request = SimpleNamespace(
prompt={"prompt": "Continue.", "multi_modal_data": {"video": [str(video_path)]}},
sampling_params=make_sampling_params(
height=16,
width=32,
extra_args={"condition_frame_indexes_vision": [0, 1], "condition_video_keep": "first"},
),
)
additional = preprocess(request).prompt["additional_information"]
# condition_frame_indexes_vision=[0,1] => 5 pixel frames after VAE indexing.
assert tuple(additional["preprocessed_video"].shape) == (1, 3, 5, 16, 32)
assert additional["condition_frame_indexes_vision"] == [0, 1]
def test_decode_path_video_frames_honors_max_frames_and_keep(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("imageio.v3")
import imageio.v3 as iio
from vllm_omni.diffusion.utils.video_decode import decode_path_video_frames
source = tmp_path / "clip.mp4"
source.write_bytes(b"placeholder")
seen = {"n": 0}
def fake_imiter(_path):
for idx in range(20):
seen["n"] += 1
yield np.full((4, 4, 3), idx, dtype=np.uint8)
monkeypatch.setattr(iio, "imiter", fake_imiter)
first = decode_path_video_frames(source, max_frames=5, keep="first")
assert len(first) == 5
assert seen["n"] == 5
assert first[0][0, 0, 0] == 0
assert first[-1][0, 0, 0] == 4
seen["n"] = 0
last = decode_path_video_frames(source, max_frames=5, keep="last")
assert len(last) == 5
assert seen["n"] == 20
assert last[0][0, 0, 0] == 15
assert last[-1][0, 0, 0] == 19
def test_preprocess_v2v_video_path_honors_prompt_level_keep_and_indexes(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
pytest.importorskip("imageio.v3")
import imageio.v3 as iio
from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3 as cosmos_pipeline
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import get_cosmos3_pre_process_func
from vllm_omni.diffusion.utils.video_decode import decode_path_video_frames
source = tmp_path / "vllm_omni_video_reference_prompt.mp4"
source.write_bytes(b"placeholder")
captured: dict[str, Any] = {}
def fake_imiter(_path):
for idx in range(20):
yield np.full((16, 32, 3), idx, dtype=np.uint8)
def spy_decode(*args, **kwargs):
frames = decode_path_video_frames(*args, **kwargs)
captured["keep"] = kwargs.get("keep")
captured["max_frames"] = kwargs.get("max_frames")
captured["n"] = len(frames)
captured["first"] = int(frames[0][0, 0, 0])
captured["last"] = int(frames[-1][0, 0, 0])
return frames
monkeypatch.setattr(iio, "imiter", fake_imiter)
monkeypatch.setattr(cosmos_pipeline, "decode_path_video_frames", spy_decode)
preprocess = get_cosmos3_pre_process_func(SimpleNamespace(model_config={"guardrails": False}, tf_model_config=None))
last_keep = SimpleNamespace(
prompt={
"prompt": "Continue.",
"condition_video_keep": "last",
"condition_frame_indexes_vision": [0, 1],
"multi_modal_data": {"video": [str(source)]},
},
sampling_params=make_sampling_params(height=16, width=32, extra_args={}),
)
additional = preprocess(last_keep).prompt["additional_information"]
assert captured["keep"] == "last"
assert captured["max_frames"] == 5
assert captured["n"] == 5
assert captured["first"] == 15
assert captured["last"] == 19
assert tuple(additional["preprocessed_video"].shape) == (1, 3, 5, 16, 32)
assert additional["condition_frame_indexes_vision"] == [0, 1]
wider_indexes = SimpleNamespace(
prompt={
"prompt": "Continue.",
"condition_frame_indexes_vision": [0, 1, 2],
"multi_modal_data": {"video": [str(source)]},
},
sampling_params=make_sampling_params(height=16, width=32, extra_args={}),
)
additional = preprocess(wider_indexes).prompt["additional_information"]
assert captured["keep"] == "first"
assert captured["max_frames"] == 9
assert captured["n"] == 9
assert captured["first"] == 0
assert captured["last"] == 8
assert tuple(additional["preprocessed_video"].shape) == (1, 3, 9, 16, 32)
assert additional["condition_frame_indexes_vision"] == [0, 1, 2]
def test_transfer_config_media_helpers_and_preprocess_budget(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.models.cosmos3 import transfer
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import (
@@ -1224,7 +1348,7 @@ def test_transfer_config_media_helpers_and_preprocess_budget(monkeypatch: pytest
)
assert tuple(loaded.shape) == (3, 2, 8, 8)
preprocess = get_cosmos3_pre_process_func(SimpleNamespace())
preprocess = get_cosmos3_pre_process_func(SimpleNamespace(model_config={"guardrails": False}, tf_model_config=None))
class FramesWithFps(list):
fps = 12.5
@@ -4,6 +4,7 @@
from contextlib import contextmanager
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import patch
import cache_dit
import pytest
@@ -26,7 +27,10 @@ from vllm_omni.diffusion.models.mammoth_moda2 import pipeline_mammothmoda2_dit
from vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit import (
MammothModa2DiTPipeline,
_build_mammoth_config,
_MammothRequest,
_pad_cond_sequence,
_root_weight_source,
get_mammoth_moda2_pre_process_func,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
@@ -62,9 +66,7 @@ def _od_config() -> OmniDiffusionConfig:
def test_build_mammoth_config_uses_shared_transformer_projection() -> None:
config = _build_mammoth_config(_od_config())
assert config.model_type == "mammothmoda2"
assert config.get_text_config().hidden_size == 8
assert config.gen_dit_config == {"hidden_size": 8, "in_channels": 4}
assert config.gen_dit_config["hidden_size"] == 8
def test_build_mammoth_config_rejects_empty_shared_projection() -> None:
@@ -88,11 +90,11 @@ def test_root_weight_source_forwards_revision() -> None:
assert _root_weight_source(config).revision == "rev-7"
def test_pipeline_declares_native_components_and_single_request_mode_only() -> None:
def test_pipeline_declares_native_components_and_batch_request_mode() -> None:
assert MammothModa2DiTPipeline._dit_modules == ["gen_transformer"]
assert MammothModa2DiTPipeline._encoder_modules == ["gen_image_condition_refiner"]
assert MammothModa2DiTPipeline._vae_modules == ["gen_vae"]
assert MammothModa2DiTPipeline.supports_request_batch is False
assert MammothModa2DiTPipeline.supports_request_batch is True
assert MammothModa2DiTPipeline.supports_step_execution is False
@@ -216,13 +218,17 @@ def test_parse_request_standard_fields_precede_request_level_fallbacks() -> None
assert parsed.num_inference_steps == 7
def test_parse_request_rejects_multiple_requests_and_outputs() -> None:
def test_parse_request_parses_each_request_and_rejects_multi_output() -> None:
pipeline = _pipeline_shell()
batch = _batch()
batch.requests.append(_batch(request_id="req-8").requests[0])
with pytest.raises(ValueError, match="exactly one request"):
pipeline._parse_request(batch)
with pytest.raises(ValueError, match="num_outputs_per_prompt=1"):
req0 = pipeline._parse_request(batch, index=0)
req1 = pipeline._parse_request(batch, index=1)
assert req0.request_id == "req-7"
assert req1.request_id == "req-8"
assert req0.index == 0
assert req1.index == 1
with pytest.raises(ValueError, match="num_outputs_per_prompt == 1"):
pipeline._parse_request(_batch(sampling=OmniDiffusionSamplingParams(num_outputs_per_prompt=2)))
@@ -347,10 +353,14 @@ class _FakeVae(nn.Module):
super().__init__()
self.anchor = nn.Parameter(torch.zeros(1))
self.config = _FakeVaeConfig()
self.last_decode_dtype: torch.dtype | None = None
def decode(self, latents, return_dict=False):
assert return_dict is False
return (torch.zeros(1, 3, 32, 48, dtype=latents.dtype),)
self.last_decode_dtype = latents.dtype
b = latents.shape[0]
base = latents[:, :1, :1, :1]
return (torch.zeros(b, 3, 32, 48, dtype=latents.dtype) + base,)
class _FakeScheduler:
@@ -392,8 +402,9 @@ def test_forward_returns_diffusion_output_with_request_sampling(mocker) -> None:
)
)
assert isinstance(result, DiffusionOutput)
assert result.output.shape == (1, 3, 32, 48)
assert len(result) == 1
assert isinstance(result[0], DiffusionOutput)
assert result[0].output.shape == (1, 3, 32, 48)
assert captured["shape"] == (1, 4, 4, 6)
assert captured["seed"] == 42
assert scheduler.requested_steps == 2
@@ -527,7 +538,7 @@ def test_forward_transitions_request_scoped_cache_dit_across_requests(request: p
# Reference run for phase 2: identical request while hooks were never
# installed on this pipeline.
assert not pipeline.is_cache_dit_enabled()
reference = pipeline.forward(_batch(sampling=unguided_sampling)).output
reference = pipeline.forward(_batch(sampling=unguided_sampling))[0].output
assert seq_lens == [cond_tokens] * 8
assert torch.isfinite(reference.float()).all()
@@ -551,7 +562,7 @@ def test_forward_transitions_request_scoped_cache_dit_across_requests(request: p
# Phase 2: no-CFG request disables the hooks and must reproduce the
# never-enabled reference bit-for-bit.
seq_lens.clear()
unguided = pipeline.forward(_batch(sampling=unguided_sampling)).output
unguided = pipeline.forward(_batch(sampling=unguided_sampling))[0].output
assert seq_lens == [cond_tokens] * 8
assert not pipeline.is_cache_dit_enabled()
assert not BlockAdapter.is_cached(pipeline.gen_transformer)
@@ -560,7 +571,7 @@ def test_forward_transitions_request_scoped_cache_dit_across_requests(request: p
# Phase 3: CFG with a new step count and image size re-installs the hooks
# on a fresh context; output shape follows the new request geometry.
seq_lens.clear()
regenerated = pipeline.forward(_batch(sampling=changed_cfg_sampling, height=48, width=64)).output
regenerated = pipeline.forward(_batch(sampling=changed_cfg_sampling, height=48, width=64))[0].output
assert seq_lens == [cond_tokens, uncond_tokens] * 10
assert pipeline.is_cache_dit_enabled()
assert BlockAdapter.is_cached(pipeline.gen_transformer)
@@ -570,3 +581,442 @@ def test_forward_transitions_request_scoped_cache_dit_across_requests(request: p
assert torch.isfinite(regenerated.float()).all()
hook.remove()
def test_pre_process_registers_batch_compatibility_key() -> None:
pre_process = get_mammoth_moda2_pre_process_func(_od_config())
req = _batch().requests[0]
pre_process(req)
assert req.batch_compatibility_key == ("mammoth_moda2_dit", 32, 48, 7)
def test_pre_process_rejects_missing_ar_conditions() -> None:
pre_process = get_mammoth_moda2_pre_process_func(_od_config())
req = _batch(request_id="req-bad-ar", prompt={"prompt": "test"}).requests[0]
with pytest.raises(ValueError, match="Missing additional_information AR conditions.*req-bad-ar"):
pre_process(req)
@pytest.mark.parametrize(
("height", "width", "message"),
[
(0, 32, "Invalid image size.*req-dim"),
(32, -1, "Invalid image size.*req-dim"),
(30, 32, "multiples of 16.*req-dim"),
(32, 31, "multiples of 16.*req-dim"),
],
)
def test_pre_process_rejects_invalid_dimensions(height, width, message) -> None:
pre_process = get_mammoth_moda2_pre_process_func(_od_config())
batch = _batch(request_id="req-dim")
batch.prompts[0].update(height=height, width=width)
with pytest.raises(ValueError, match=message):
pre_process(batch.requests[0])
def test_pre_process_rejects_non_positive_steps() -> None:
pre_process = get_mammoth_moda2_pre_process_func(_od_config())
req = _batch(
request_id="req-zero-steps",
sampling=OmniDiffusionSamplingParams(num_inference_steps=0),
).requests[0]
with pytest.raises(ValueError, match="num_inference_steps must be positive.*req-zero-steps"):
pre_process(req)
def test_pre_process_allows_dummy_run() -> None:
pre_process = get_mammoth_moda2_pre_process_func(_od_config())
req = _batch(
request_id="dummy_req_id",
prompt={"prompt": "dummy run"},
sampling=OmniDiffusionSamplingParams(height=512, width=512, num_inference_steps=20),
).requests[0]
pre_process(req)
assert req.batch_compatibility_key == ("mammoth_moda2_dit", 512, 512, 20)
def test_forward_casts_latents_to_vae_dtype() -> None:
pipeline = _pipeline_shell()
pipeline.gen_transformer = _FakeTransformer()
pipeline.gen_image_condition_refiner = None
vae = _FakeVae()
vae.anchor = nn.Parameter(torch.zeros(1, dtype=torch.float16))
pipeline.gen_vae = vae
pipeline.gen_freqs_cis = torch.zeros(1)
scheduler = _FakeScheduler()
module = "vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit"
with (
patch(f"{module}.FlowMatchEulerDiscreteScheduler", return_value=scheduler),
patch(
f"{module}.randn_tensor",
side_effect=lambda s, **kw: torch.zeros(s, device=kw.get("device"), dtype=kw.get("dtype")),
),
):
result = pipeline.forward(_batch())
assert len(result) == 1
assert vae.last_decode_dtype == torch.float16
def test_group_requests_by_geometry_and_steps() -> None:
def make_req(idx: int, h: int, w: int, s: int) -> _MammothRequest:
return _MammothRequest(
index=idx,
request_id=f"r{idx}",
full_hidden_states=torch.zeros(1, 8),
full_token_ids=[1],
answer_start_index=0,
height=h,
width=w,
text_guidance_scale=1.0,
cfg_range=(0.0, 1.0),
num_inference_steps=s,
seed=None,
generator=None,
generator_device=None,
)
reqs = [make_req(0, 32, 32, 5), make_req(1, 32, 32, 5), make_req(2, 64, 64, 5)]
groups = MammothModa2DiTPipeline._group_requests(reqs)
assert sorted(groups) == [[0, 1], [2]]
def test_pad_cond_sequence_right_pads() -> None:
embeds = [torch.ones(1, 2, 8), torch.ones(1, 4, 8) * 2]
masks = [torch.ones(1, 2, dtype=torch.bool), torch.ones(1, 4, dtype=torch.bool)]
padded, mask = _pad_cond_sequence(embeds, masks)
assert padded.shape == (2, 4, 8)
assert mask.tolist() == [[True, True, False, False], [True, True, True, True]]
def test_forward_batched_matches_solo_output() -> None:
pipeline = _pipeline_shell()
pipeline.gen_transformer = _FakeTransformer()
pipeline.gen_image_condition_refiner = None
pipeline.gen_vae = _FakeVae()
pipeline.gen_freqs_cis = torch.zeros(1)
scheduler = _FakeScheduler()
def fake_randn_tensor(shape, *, generator, device, dtype):
if isinstance(generator, list):
tensors = [
torch.full((1, *shape[1:]), float(g.initial_seed() if g else 0), device=device, dtype=dtype)
for g in generator
]
return torch.cat(tensors, dim=0)
val = float(generator.initial_seed() if generator else 0)
return torch.full(shape, val, device=device, dtype=dtype)
module = "vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit"
with (
patch(f"{module}.FlowMatchEulerDiscreteScheduler", return_value=scheduler),
patch(f"{module}.randn_tensor", side_effect=fake_randn_tensor),
):
req1 = _batch(
request_id="r1",
sampling=OmniDiffusionSamplingParams(
height=32, width=48, seed=10, guidance_scale=1.0, num_inference_steps=2
),
).requests[0]
req2 = _batch(
request_id="r2",
sampling=OmniDiffusionSamplingParams(
height=32, width=48, seed=20, guidance_scale=1.0, num_inference_steps=2
),
).requests[0]
batched_out = pipeline.forward(DiffusionRequestBatch([req1, req2]))
solo1 = pipeline.forward(DiffusionRequestBatch([req1]))
solo2 = pipeline.forward(DiffusionRequestBatch([req2]))
assert len(batched_out) == 2
torch.testing.assert_close(batched_out[0].output, solo1[0].output)
torch.testing.assert_close(batched_out[1].output, solo2[0].output)
assert not torch.equal(batched_out[0].output, batched_out[1].output)
def test_refiner_output_length_preserved_when_input_len_differs_from_queries() -> None:
pipeline = _pipeline_shell()
pipeline.gen_transformer = _FakeTransformer()
class _MockRefiner(nn.Module):
def __init__(self) -> None:
super().__init__()
self.anchor = nn.Parameter(torch.zeros(1))
def forward(self, features, mask=None):
b, _, d = features.shape
return torch.zeros(b, 8, d, device=features.device, dtype=features.dtype)
pipeline.gen_image_condition_refiner = _MockRefiner()
pipeline.gen_vae = _FakeVae()
pipeline.gen_freqs_cis = torch.zeros(1)
scheduler = _FakeScheduler()
captured_cond_lens = []
def fake_forward(*, hidden_states, text_hidden_states, **kwargs):
captured_cond_lens.append(text_hidden_states.shape[1])
return torch.zeros_like(hidden_states)
module = "vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit"
with (
patch(f"{module}.FlowMatchEulerDiscreteScheduler", return_value=scheduler),
patch(
f"{module}.randn_tensor",
side_effect=lambda s, **kw: torch.zeros(s, device=kw.get("device"), dtype=kw.get("dtype")),
),
patch.object(pipeline.gen_transformer, "forward", fake_forward),
):
prompt = {
"prompt": "",
"additional_information": {
"full_hidden_states": torch.zeros(6, 8),
"full_token_ids": [10, 11, 100, 101, 102, 103],
"answer_start_index": 2,
},
}
batch = _batch(
prompt=prompt,
sampling=OmniDiffusionSamplingParams(height=32, width=48, guidance_scale=1.0, num_inference_steps=1),
)
pipeline.forward(batch)
# 2 text tokens + 8 refined queries = 10 total conditioning tokens seen by transformer
assert captured_cond_lens == [10]
def test_nested_image_embedder_compacts_unequal_text_lengths() -> None:
from vllm_omni.diffusion.models.mammoth_moda2.mammothmoda2_dit_model import (
Transformer2DModel,
)
model = object.__new__(Transformer2DModel)
nn.Module.__init__(model)
object.__setattr__(model, "_internal_dict", SimpleNamespace(patch_size=2))
dim = 8
batch_size = 2
max_text_len = 4
class _MockTimeCaptionEmbed(nn.Module):
def forward(self, timestep, text_hidden_states, dtype):
return torch.zeros(batch_size, dim), text_hidden_states
def image_embedder(self, feats, mask=None):
return torch.full((batch_size, 3, dim), 99.0)
model.time_caption_embed = _MockTimeCaptionEmbed()
model.x_embedder = nn.Identity()
def mock_rope(freqs, mask, *args):
return None, None, None, None, mask.sum(dim=-1).tolist(), []
model.rope_embedder = mock_rope
hidden_states = torch.zeros(batch_size, 4, 4, 4)
timestep = torch.tensor([1.0, 1.0])
text_hidden_states = torch.zeros(batch_size, max_text_len, dim)
text_hidden_states[0, 0, :] = 1.0
text_hidden_states[0, 1, :] = 2.0
text_hidden_states[1, 0, :] = 10.0
text_hidden_states[1, 1, :] = 20.0
text_hidden_states[1, 2, :] = 30.0
text_hidden_states[1, 3, :] = 40.0
text_attention_mask = torch.tensor(
[
[True, True, False, False],
[True, True, True, True],
]
)
ar_image_hidden_states = torch.zeros(batch_size, 5, dim)
res = model._prepare_embeddings(
hidden_states=hidden_states,
timestep=timestep,
text_hidden_states=text_hidden_states,
text_attention_mask=text_attention_mask,
freqs_cis=torch.zeros(1),
batch_size=batch_size,
height=4,
width=4,
ar_image_hidden_states=ar_image_hidden_states,
)
compacted_text = res[1]
compacted_mask = res[2]
encoder_seq_lengths = res[9]
assert encoder_seq_lengths == [5, 7]
assert compacted_text.shape == (2, 7, dim)
assert compacted_text[0, 0, 0].item() == 1.0
assert compacted_text[0, 1, 0].item() == 2.0
assert compacted_text[0, 2, 0].item() == 99.0
assert compacted_text[0, 3, 0].item() == 99.0
assert compacted_text[0, 4, 0].item() == 99.0
assert compacted_mask[0].tolist() == [True, True, True, True, True, False, False]
assert compacted_mask[1].tolist() == [True, True, True, True, True, True, True]
def test_pre_process_rejects_missing_visual_tokens_and_multi_output() -> None:
pre_process = get_mammoth_moda2_pre_process_func(_od_config())
multi_out = _batch(
request_id="req-multi",
sampling=OmniDiffusionSamplingParams(num_outputs_per_prompt=2),
).requests[0]
with pytest.raises(ValueError, match="num_outputs_per_prompt == 1.*req-multi"):
pre_process(multi_out)
no_vis_prompt = {
"prompt": "",
"additional_information": {
"full_hidden_states": torch.zeros(3, 8),
"full_token_ids": [10, 11, 12],
"answer_start_index": 1,
},
}
no_vis_req = _batch(request_id="req-no-vis", prompt=no_vis_prompt).requests[0]
with pytest.raises(ValueError, match="no visual-token hidden states.*req-no-vis"):
pre_process(no_vis_req)
valid_prompt = {
"prompt": "",
"additional_information": {
"full_hidden_states": torch.zeros(3, 8),
"full_token_ids": [10, 100, 101],
"answer_start_index": 1,
},
}
valid_req = _batch(request_id="req-valid", prompt=valid_prompt).requests[0]
pre_process(valid_req)
assert valid_req.batch_compatibility_key is not None
def test_cfg_precomputation_uses_cpu_booleans_without_device_sync() -> None:
pipeline = _pipeline_shell()
pipeline.gen_transformer = _FakeTransformer()
pipeline.gen_image_condition_refiner = None
pipeline.gen_vae = _FakeVae()
pipeline.gen_freqs_cis = torch.zeros(1)
scheduler = _FakeScheduler()
raw_any = torch.Tensor.any
tensor_any_called = []
def wrapped_any(self, *args, **kwargs):
tensor_any_called.append(self)
return raw_any(self, *args, **kwargs)
module = "vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit"
with (
patch(f"{module}.FlowMatchEulerDiscreteScheduler", return_value=scheduler),
patch(
f"{module}.randn_tensor",
side_effect=lambda s, **kw: torch.zeros(s, device=kw.get("device"), dtype=kw.get("dtype")),
),
patch.object(torch.Tensor, "any", wrapped_any),
):
req1 = _batch(
request_id="r1",
sampling=OmniDiffusionSamplingParams(
height=32,
width=48,
seed=1,
guidance_scale=4.0,
num_inference_steps=5,
extra_args={"cfg_range": [0.2, 0.8]},
),
).requests[0]
req2 = _batch(
request_id="r2",
sampling=OmniDiffusionSamplingParams(
height=32, width=48, seed=2, guidance_scale=1.0, num_inference_steps=5
),
).requests[0]
pipeline.forward(DiffusionRequestBatch([req1, req2]))
assert len(tensor_any_called) == 0
def test_dummy_request_splits_valid_ar_conditions_without_error() -> None:
"""Startup warmup dummy run must split into valid non-empty AR conditions."""
pipeline = _pipeline_shell()
batch = _batch(
request_id="dummy_req_id",
prompt={"prompt": "dummy run"},
sampling=OmniDiffusionSamplingParams(height=512, width=512, seed=1, guidance_scale=0.0, num_inference_steps=2),
)
parsed = pipeline._parse_request(batch)
text_cond, image_cond = pipeline._split_request_conditions(parsed)
assert text_cond.shape == (1, pipeline._llm_hidden_size)
assert image_cond.shape == (1, pipeline._llm_hidden_size)
@pytest.mark.parametrize(
("llm_cfg_patch", "expected_threshold"),
[
# Parent-level llm_config threshold
({"gen_vocab_start_index": 200}, 200),
# Nested text_config threshold
({"text_config": {"model_type": "mammothmoda2_qwen2_5_vl_text", "gen_vocab_start_index": 350}}, 350),
# Default-derived threshold (fallback to vocab_size / 152064)
({}, 152064),
],
)
def test_admission_and_inference_threshold_resolution_matches(llm_cfg_patch: dict, expected_threshold: int) -> None:
"""Verify identical threshold resolution for parent-level, nested, and default configs."""
raw = _raw_config()
raw["llm_config"] = {"model_type": "mammothmoda2_qwen2_5_vl", **llm_cfg_patch}
od_cfg = OmniDiffusionConfig(
model="/models/MammothModa2-Preview",
model_class_name="MammothModa2DiTPipeline",
tf_model_config=TransformerConfig.from_dict(raw),
)
# 1. Check admission preprocessor resolution
pre_process = get_mammoth_moda2_pre_process_func(od_cfg)
# Token just below threshold -> rejected
below_prompt = {
"prompt": "",
"additional_information": {
"full_hidden_states": torch.zeros(2, 8),
"full_token_ids": [10, expected_threshold - 1],
"answer_start_index": 1,
},
}
below_req = _batch(request_id="req-below", prompt=below_prompt).requests[0]
with pytest.raises(ValueError, match="no visual-token hidden states.*req-below"):
pre_process(below_req)
# Token at or above threshold -> accepted
at_prompt = {
"prompt": "",
"additional_information": {
"full_hidden_states": torch.zeros(2, 8),
"full_token_ids": [10, expected_threshold],
"answer_start_index": 1,
},
}
at_req = _batch(request_id="req-at", prompt=at_prompt).requests[0]
pre_process(at_req)
assert at_req.batch_compatibility_key is not None
# 2. Check inference pipeline model config resolution
pipeline = _pipeline_shell()
pipeline.config = _build_mammoth_config(od_cfg)
# Token below threshold rejected by pipeline
parsed_below = pipeline._parse_request(_batch(request_id="req-inf-below", prompt=below_prompt))
with pytest.raises(ValueError, match="no visual-token hidden states.*req-inf-below"):
pipeline._split_request_conditions(parsed_below)
# Token at threshold accepted by pipeline
parsed_at = pipeline._parse_request(_batch(request_id="req-inf-at", prompt=at_prompt))
text_cond, image_cond = pipeline._split_request_conditions(parsed_at)
assert text_cond.shape == (1, 8)
assert image_cond.shape == (1, 8)
@@ -3494,3 +3494,48 @@ def test_long_video_shape_requires_explicit_opt_in(duration):
assert frames >= duration * 24 and frames % 17 == 5
assert video_t == (frames - 5) // 17 * 5 + 2
assert audio_t == round(frames / 24 * 40)
@pytest.mark.parametrize("preencode", [False, True])
@pytest.mark.parametrize("cancel_phase", ["before_prepare", "prepare", "diffuse"])
def test_request_cancellation_at_prepare_and_decode_boundaries(preencode, cancel_phase, monkeypatch):
from vllm_omni.diffusion.cancellation import RequestCancellationRegistry, request_cancellation_scope
from vllm_omni.diffusion.data import DiffusionRequestAbortedError
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
from vllm_omni.platforms import current_omni_platform
synchronize = Mock()
monkeypatch.setattr(current_omni_platform, "synchronize", synchronize)
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
registry = RequestCancellationRegistry()
signal = registry.create("request")
def prepare(*args):
if cancel_phase == "prepare":
registry.cancel(["request"])
return {"num_outputs": 1, "seed": 1101, "preencode_mp4": preencode}
def diffuse(**kwargs):
registry.cancel(["request"])
return torch.zeros(1), torch.zeros(1)
pipeline._prepare_request_inputs = Mock(side_effect=prepare)
pipeline._denoise_kwargs = Mock(return_value={})
pipeline.diffuse = Mock(side_effect=diffuse)
pipeline.decode = Mock()
pipeline.decode_to_mp4 = Mock()
try:
if cancel_phase == "before_prepare":
registry.cancel(["request"])
with request_cancellation_scope([signal]), pytest.raises(DiffusionRequestAbortedError):
pipeline.forward(_t2va_batch())
synchronize.assert_called_once_with()
if cancel_phase == "before_prepare":
pipeline._prepare_request_inputs.assert_not_called()
if cancel_phase != "diffuse":
pipeline.diffuse.assert_not_called()
pipeline.decode.assert_not_called()
pipeline.decode_to_mp4.assert_not_called()
finally:
registry.close()
@@ -171,6 +171,41 @@ def test_step_execution_matches_request_mode_denoise_loop(num_steps, mocker):
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], reference_audio)
def test_request_mode_cancellation_stops_before_next_denoise_step(monkeypatch):
from vllm_omni.diffusion.cancellation import RequestCancellationRegistry, request_cancellation_scope
from vllm_omni.diffusion.data import DiffusionRequestAbortedError
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
from vllm_omni.platforms import current_omni_platform
# This test runs real packing/Euler updates on CPU with the small DiT above.
monkeypatch.setattr(current_omni_platform, "synchronize", lambda: None)
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=5)
registry = RequestCancellationRegistry()
signal = registry.create("request")
steps = []
def cancel_after_first_step(step, video, audio):
steps.append(step)
registry.cancel(["request"])
try:
with request_cancellation_scope([signal]), pytest.raises(DiffusionRequestAbortedError):
minimax_h3_denoise_loop(
model=_SegmentMeanModel(),
positive=branch,
initial_video_rows=video_rows,
initial_audio_rows=audio_rows,
keyframe_cond_rows=None,
sigmas_video=_sigmas(6, 12.0),
sigmas_audio=_sigmas(6, 3.0),
device=torch.device("cpu"),
on_step=cancel_after_first_step,
)
finally:
registry.close()
assert steps == [0]
def test_step_execution_matches_request_mode_with_latent_edits():
"""Masked model rows, row timesteps, and scheduler math match both paths."""
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
@@ -55,6 +55,7 @@ from vllm_omni.diffusion.offloader.offload_plan import (
)
from vllm_omni.diffusion.offloader.plan_resolver import ResolvedComponent, resolve_offload_plan
from vllm_omni.diffusion.offloader.startup import OffloadStartupState, attach_offload_startup_state
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker
from vllm_omni.host_weight_runtime import MappedHostRegion
from vllm_omni.platforms import current_omni_platform
@@ -2866,6 +2867,34 @@ class TestDistributedComponentSelection:
with pytest.raises(RuntimeError, match="recreate the backend and reload the pipeline"):
backend.enable(nn.Module())
def test_worker_shutdown_skips_allgather_restore(self, patched_offload_runtime, mocker):
# Restoring AllGather blocks rebuilds the full DiT on every rank's host,
# which outlived the executor's shutdown grace period (issue 8081).
backend = DistributedLayerwiseOffloadBackend(
OffloadConfig(
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
pin_cpu_memory=False,
dp_size=2,
components=frozenset({"dit", "text_encoder"}),
dlo_transfers={"dit": "allgather", "text_encoder": "rank-local"},
),
torch.device("cpu"),
)
allgather_hook = Mock(dp_size=2, next_block=nn.Linear(1, 1))
rank_local_hook = Mock(dp_size=1, next_block=nn.Linear(1, 1))
backend._all_hook_groups = [[allgather_hook], [rank_local_hook]]
backend.enabled = True
mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.destroy_distributed_env")
worker = DiffusionWorker.__new__(DiffusionWorker)
worker.model_runner = SimpleNamespace(offload_backend=backend, kv_transfer_manager=None)
worker.shutdown()
allgather_hook.restore_next_block_to_cpu.assert_not_called()
rank_local_hook.restore_next_block_to_cpu.assert_called_once_with()
assert not backend.enabled
assert not backend._all_hook_groups
def test_disable_runs_collectives_before_best_effort_local_cleanup(
self,
patched_offload_runtime,
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import queue
@@ -432,3 +432,68 @@ def test_finalize_aborted_request_without_async_output_skips_drop() -> None:
assert output.aborted is True
engine.executor.drop_output.assert_not_called()
@pytest.mark.parametrize("scheduler_fails", [False, True])
def test_fail_engine_releases_cancellation_signals_after_workers_stop(scheduler_fails):
from multiprocessing.shared_memory import SharedMemory
from vllm_omni.diffusion.cancellation import RequestCancellationRegistry
engine = _make_engine()
engine._fail_pending_rpcs = Mock()
registry = RequestCancellationRegistry()
engine._request_cancellations = registry
name = registry.create("running")
def shutdown():
# Cancellation is visible before shutdown, and late readers can attach.
reader = SharedMemory(name=name)
try:
assert reader.buf[0] == 1
finally:
reader.close()
engine.executor.shutdown.side_effect = shutdown
engine.scheduler.close = Mock(side_effect=RuntimeError("scheduler cleanup failed") if scheduler_fails else None)
try:
if scheduler_fails:
with pytest.raises(RuntimeError, match="scheduler cleanup failed"):
engine._fail_engine(RuntimeError("engine failed"))
else:
engine._fail_engine(RuntimeError("engine failed"))
engine.close()
engine.executor.shutdown.assert_called_once_with()
with pytest.raises(FileNotFoundError):
SharedMemory(name=name)
assert not registry._signals
finally:
registry.close()
def test_close_retains_cancellation_signal_until_stuck_worker_stops():
from multiprocessing.shared_memory import SharedMemory
from vllm_omni.diffusion.cancellation import RequestCancellationRegistry
engine = _make_engine()
registry = RequestCancellationRegistry()
engine._request_cancellations = registry
name = registry.create("running")
engine.worker_thread = Mock()
engine.worker_thread.is_alive.side_effect = [True, True, False, False]
try:
engine.close()
engine.executor.shutdown.assert_not_called()
reader = SharedMemory(name=name)
try:
assert reader.buf[0] == 1
finally:
reader.close()
engine.close()
engine.executor.shutdown.assert_called_once_with()
with pytest.raises(FileNotFoundError):
SharedMemory(name=name)
assert not registry._signals
finally:
registry.close()
@@ -1411,3 +1411,44 @@ def test_vllm_set_forward_context_implementation(monkeypatch):
),
),
], ERROR_MESSAGE
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize("cancel_all", [False, True])
def test_execute_model_batch_cancellation_preserves_live_peer(monkeypatch, cancel_all):
from vllm_omni.diffusion.cancellation import (
RequestCancellationRegistry,
check_request_cancellation,
)
from vllm_omni.platforms import current_omni_platform
monkeypatch.setattr(current_omni_platform, "synchronize", lambda: None)
class CancellableBatchPipeline(_BatchPipeline):
supports_request_cancellation = True
def forward(self, batch):
check_request_cancellation()
return super().forward(batch)
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
monkeypatch.setattr(model_runner_module, "current_omni_platform", _fake_platform_for_peak_memory())
pipeline = CancellableBatchPipeline(outputs=[DiffusionOutput(output="a"), DiffusionOutput(output="b")])
runner = _make_batch_runner(pipeline)
sched = _make_scheduler_output(num_reqs=2)
registry = RequestCancellationRegistry()
try:
for entry in sched.scheduled_new_reqs:
entry.req.cancellation_signal = registry.create(entry.request_id)
registry.cancel(["req-0", "req-1"] if cancel_all else ["req-0"])
result = runner.execute_model_batch(sched, runner.od_config)
assert len(result.runner_outputs) == 2
assert [output.result.aborted for output in result.runner_outputs] == [cancel_all, cancel_all]
if not cancel_all:
assert result.runner_outputs[1].request_id == "req-1"
assert result.runner_outputs[1].result.output == "b"
else:
assert pipeline.last_batch is None
finally:
registry.close()
@@ -0,0 +1,276 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Cancellation transport, ownership and collective agreement regressions."""
import multiprocessing as mp
import queue
import threading
from concurrent.futures import ThreadPoolExecutor
from multiprocessing.shared_memory import SharedMemory
from unittest.mock import patch
import pytest
from vllm_omni.diffusion.cancellation import (
RequestCancellationRegistry,
check_request_cancellation,
request_cancellation_scope,
)
from vllm_omni.diffusion.data import DiffusionRequestAbortedError, OmniDiffusionConfig
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@pytest.fixture
def registry(monkeypatch):
from vllm_omni.platforms import current_omni_platform
# These tests exercise the cancellation protocol without device work.
monkeypatch.setattr(current_omni_platform, "synchronize", lambda: None)
signals = RequestCancellationRegistry()
yield signals
signals.close()
def test_cancel_is_request_scoped_and_id_reuse_is_fresh(registry):
first = registry.create("first")
other = registry.create("other")
registry.cancel(["missing", "first"])
with pytest.raises(ValueError, match="Duplicate cancellation request"):
registry.create("first")
with request_cancellation_scope([first]):
with pytest.raises(DiffusionRequestAbortedError):
check_request_cancellation()
with request_cancellation_scope([other]):
check_request_cancellation()
registry.finish("first")
registry.finish("first")
with pytest.raises(FileNotFoundError):
SharedMemory(name=first)
replacement = registry.create("first")
with request_cancellation_scope([replacement]):
check_request_cancellation()
def test_scope_restores_outer_state_and_does_not_leak_to_threads(registry):
signal = registry.create("request")
registry.cancel_all()
with request_cancellation_scope([signal]):
with request_cancellation_scope([None]):
check_request_cancellation()
with ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(check_request_cancellation).result(timeout=10)
with pytest.raises(DiffusionRequestAbortedError):
check_request_cancellation()
check_request_cancellation()
def test_mixed_batch_keeps_uncancelled_request_running(registry):
first = registry.create("first")
other = registry.create("other")
with request_cancellation_scope([first, other]):
registry.cancel(["first"])
check_request_cancellation()
registry.cancel(["other"])
with pytest.raises(DiffusionRequestAbortedError):
check_request_cancellation()
def test_checkpoint_observes_cancel_during_device_wait(registry, monkeypatch):
from vllm_omni.platforms import current_omni_platform
first = registry.create("first")
signal = registry.create("request")
registry.cancel(["first"])
# A local cancellation triggers the device wait. A peer can be cancelled
# during that wait; re-read before deciding whether the whole batch stops.
monkeypatch.setattr(current_omni_platform, "synchronize", lambda: registry.cancel(["request"]))
with request_cancellation_scope([first, signal]):
with pytest.raises(DiffusionRequestAbortedError):
check_request_cancellation(synchronize=True)
@pytest.mark.parametrize("synchronize_early", [False, True])
def test_checkpoint_drains_once_before_abort_only(registry, monkeypatch, synchronize_early):
from unittest.mock import Mock
from vllm_omni.platforms import current_omni_platform
synchronize = Mock()
monkeypatch.setattr(current_omni_platform, "synchronize", synchronize)
signal = registry.create("request")
with request_cancellation_scope([signal]):
check_request_cancellation(synchronize=synchronize_early)
synchronize.assert_not_called()
# Simulate work queued after a clean checkpoint. Cancellation at the
# next checkpoint must drain it before exception unwinding drops refs.
pending_work = [object()]
synchronize.side_effect = pending_work.clear
registry.cancel(["request"])
with pytest.raises(DiffusionRequestAbortedError):
try:
check_request_cancellation(synchronize=synchronize_early)
finally:
assert not pending_work
synchronize.assert_called_once_with()
def test_close_removes_all_owned_signals(registry):
names = [registry.create(str(i)) for i in range(3)]
registry.cancel_all()
registry.close()
registry.close()
for name in names:
with pytest.raises(FileNotFoundError):
SharedMemory(name=name)
def _admission_engine(registry):
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.sched.request_scheduler import RequestScheduler
engine = object.__new__(DiffusionEngine)
engine.scheduler = RequestScheduler()
engine.scheduler.initialize(OmniDiffusionConfig())
engine._request_cancellations = registry
engine._cv = threading.Condition(threading.RLock())
engine._closed = False
engine._out_streams = {}
engine.abort_queue = queue.Queue()
return engine
def _request(request_id):
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
return OmniDiffusionRequest(
prompt="A fox in the snow.", sampling_params=OmniDiffusionSamplingParams(), request_id=request_id
)
def test_engine_signals_worker_before_busy_loop_drains_abort(registry):
from vllm_omni.diffusion.sched.interface import DiffusionRequestStatus
engine = _admission_engine(registry)
request = _request("running")
engine._add_prepared_request(request)
engine.scheduler.schedule()
signal = request.cancellation_signal
assert signal is not None
with request_cancellation_scope([signal]):
engine.abort("running")
# The busy loop is still occupied by execution. Only the signal has
# changed; scheduler mutation must stay on that loop's thread.
assert engine.scheduler.get_request_state("running").status == DiffusionRequestStatus.RUNNING
with pytest.raises(DiffusionRequestAbortedError):
check_request_cancellation()
engine._process_aborts_queue()
assert engine._finalize_finished_request("running").aborted
assert engine.scheduler.get_request_state("running") is None
with pytest.raises(FileNotFoundError):
SharedMemory(name=signal)
def test_rejected_admission_releases_signal(registry, monkeypatch):
engine = _admission_engine(registry)
allocated_names = []
def reject(request):
allocated_names.append(request.cancellation_signal)
raise ValueError("admission rejected")
monkeypatch.setattr(engine.scheduler, "add_request", reject)
request = _request("rejected")
with pytest.raises(ValueError, match="admission rejected"):
engine._add_prepared_request(request)
assert request.cancellation_signal is None
with pytest.raises(FileNotFoundError):
SharedMemory(name=allocated_names[0])
registry.create("rejected")
def _collective_worker(rank, name, rendezvous, ready, first_check, second_check, results):
import torch
import torch.distributed as dist
from vllm_omni.diffusion.distributed import parallel_state
from vllm_omni.platforms import current_omni_platform
dist.init_process_group("gloo", init_method=f"file://{rendezvous}", rank=rank, world_size=2)
# Exercise a real CPU collective without constructing GPU model groups.
group = object.__new__(parallel_state.GroupCoordinator)
group.world_size = 2
group.cpu_group = dist.group.WORLD
parallel_state._WORLD = group
try:
with (
torch.inference_mode(),
patch.object(current_omni_platform, "synchronize") as synchronize,
request_cancellation_scope([name]),
):
dist.barrier()
if rank == 0:
ready.set()
assert first_check.wait(120)
check_request_cancellation()
results.put((rank, "partial_wave_kept_running"))
# A replica missing its token (or all local requests) still votes
# false. Both ranks must reach each collective without deadlocking.
for absent in ([None], []):
with request_cancellation_scope([name] if rank == 0 else absent):
check_request_cancellation()
results.put((rank, "missing_signal_kept_running"))
synchronize.assert_not_called()
assert second_check.wait(120)
try:
check_request_cancellation()
except DiffusionRequestAbortedError:
synchronize.assert_called_once_with()
results.put((rank, "fully_cancelled_wave_stopped"))
else:
raise AssertionError("All cancelled ranks must stop")
finally:
dist.destroy_process_group()
def test_spawned_ranks_agree_before_leaving_collective_wave(registry, tmp_path):
ctx = mp.get_context("spawn")
names = [registry.create(str(rank)) for rank in range(2)]
ready, first_check, second_check = ctx.Event(), ctx.Event(), ctx.Event()
results = ctx.Queue()
workers = [
ctx.Process(
target=_collective_worker,
args=(rank, names[rank], str(tmp_path / "rendezvous"), ready, first_check, second_check, results),
)
for rank in range(2)
]
for worker in workers:
worker.start()
try:
assert ready.wait(120)
registry.cancel(["0"])
first_check.set()
assert {results.get(timeout=120) for _ in range(2 * len(workers))} == {
(0, "partial_wave_kept_running"),
(1, "partial_wave_kept_running"),
(0, "missing_signal_kept_running"),
(1, "missing_signal_kept_running"),
}
registry.cancel(["1"])
second_check.set()
assert {results.get(timeout=120) for _ in workers} == {
(0, "fully_cancelled_wave_stopped"),
(1, "fully_cancelled_wave_stopped"),
}
for worker in workers:
worker.join(timeout=30)
assert worker.exitcode == 0
finally:
for worker in workers:
if worker.is_alive():
worker.terminate()
worker.join(timeout=30)
results.close()
results.join_thread()
+2 -2
View File
@@ -290,7 +290,7 @@ class TestWorkerWrapperBaseDelegation:
def test_worker_shutdown_releases_model_before_cumem_pools(self, mocker: MockerFixture, native_kv):
events: list[str] = []
offload_backend = mocker.Mock()
offload_backend.disable.side_effect = lambda: events.append("offload")
offload_backend.shutdown.side_effect = lambda: events.append("offload")
kv_manager = mocker.Mock()
kv_manager.close.side_effect = lambda: events.append("kv")
worker = DiffusionWorker.__new__(DiffusionWorker)
@@ -389,7 +389,7 @@ class TestWorkerWrapperBaseDelegation:
mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.shutdown_kv_connector")
offload_backend = mocker.Mock()
if cleanup_fails:
offload_backend.disable.side_effect = RuntimeError("offload cleanup failed")
offload_backend.shutdown.side_effect = RuntimeError("offload cleanup failed")
worker = DiffusionWorker.__new__(DiffusionWorker)
weights = torch.ones(1)
weights_ref = weakref.ref(weights)
@@ -38,10 +38,13 @@ async def run_window_turn(
temperature=0.0,
extra_body={
"sliding_window_mode": mode,
"context_max_units": 1,
# Keep a complete ~5.5 s fixture utterance available to the
# native listen/speak policy. These limits still rebuild KV
# during the two-repeat input and its response.
"context_max_units": 8,
"context_previous_max_tokens": 16,
"basic_window_high_tokens": 64,
"basic_window_low_tokens": 32,
"basic_window_high_tokens": 192,
"basic_window_low_tokens": 128,
},
)
client = DuplexClient(
@@ -58,6 +61,10 @@ async def run_window_turn(
is_speech=True,
video_frames=video_frames,
)
# The fixture ends in speech. A native duplex commit seals input
# but does not force an answer; keep the microphone clock running
# through a pause so the model can choose to speak.
await client.stream_pcm(bytes(5 * 32000), chunk_ms=200, realtime=True, is_speech=False)
await client.commit(final=True)
await wait_for_condition(
lambda: collector.count("input_audio_buffer.committed") > 0 or bool(collector.errors()),
@@ -6,17 +6,16 @@ import asyncio
import base64
import json
import math
import uuid
import wave
from collections.abc import Sequence
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import numpy as np
from scipy.signal import resample_poly
from vllm_omni.experimental.fullduplex.client import RealtimeDuplexClient, wait_for, write_pcm16_wav
from vllm_omni.clients.duplex import DuplexClient, EventCollector, wait_for_condition, write_pcm16_wav
from vllm_omni.clients.nemotron_voicechat import create_duplex_session_config
INPUT_SAMPLE_RATE_HZ = 16_000
OUTPUT_SAMPLE_RATE_HZ = 22_050
@@ -45,13 +44,6 @@ DEFAULT_FUNCTION_INSTRUCTIONS = (
)
def _url(base_url: str, model: str, session_id: str) -> str:
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.update(duplex="1", model=model, autostart="0", session_id=session_id)
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _read_wav(path: Path, *, input_channel: int = 0) -> np.ndarray:
with wave.open(str(path), "rb") as wav_file:
channels = wav_file.getnchannels()
@@ -73,50 +65,42 @@ def _read_wav(path: Path, *, input_channel: int = 0) -> np.ndarray:
return np.ascontiguousarray(pcm, dtype="<f4")
async def _stream(client: RealtimeDuplexClient, pcm: np.ndarray, *, max_frames: int | None, realtime: bool) -> int:
async def _stream(client: DuplexClient, pcm: np.ndarray, *, max_frames: int | None, realtime: bool) -> int:
count = math.ceil(pcm.size / FRAME_SAMPLES)
if max_frames is not None:
count = min(count, max_frames)
for seq in range(count):
frame = pcm[seq * FRAME_SAMPLES : (seq + 1) * FRAME_SAMPLES]
frame = np.pad(frame, (0, FRAME_SAMPLES - frame.size)).astype("<f4")
await client.send(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(frame).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": INPUT_SAMPLE_RATE_HZ,
"duration_ms": 80,
"audio_end_ms": (seq + 1) * 80,
}
)
await client.append_audio(frame.tobytes())
if realtime:
await asyncio.sleep(FRAME_PERIOD_S)
return count
def _events(client: RealtimeDuplexClient, event_type: str) -> list[dict[str, object]]:
return [event for event in client.events.events if event.get("type") == event_type]
def _events(collector: EventCollector, event_type: str) -> list[dict[str, object]]:
return [event for event in collector.events if event.get("type") == event_type]
async def _return_function_output_when_ready(
client: RealtimeDuplexClient,
client: DuplexClient,
collector: EventCollector,
*,
output: str,
timeout_s: float,
) -> tuple[str, int]:
await wait_for(
lambda: bool(client.events.errors()) or client.events.count("response.function_call_arguments.done") > 0,
await wait_for_condition(
lambda: bool(collector.errors()) or collector.count("response.function_call_arguments.done") > 0,
timeout_s=timeout_s,
label="function call to execute",
)
if client.events.errors():
raise AssertionError(f"function call failed before tool execution: {client.events.errors()}")
function_done = _events(client, "response.function_call_arguments.done")[-1]
if collector.errors():
raise AssertionError(f"function call failed before tool execution: {collector.errors()}")
function_done = _events(collector, "response.function_call_arguments.done")[-1]
call_id = function_done.get("call_id")
if not isinstance(call_id, str) or not call_id:
raise AssertionError(f"completed function call has no call_id: {function_done}")
event_count_before_output = len(client.events.events)
event_count_before_output = len(collector.events)
await client.send(
{
"type": "conversation.item.create",
@@ -130,22 +114,26 @@ async def _return_function_output_when_ready(
return call_id, event_count_before_output
def _write_events(path: Path, client: RealtimeDuplexClient) -> None:
def _write_events(path: Path, collector: EventCollector) -> None:
path.write_text(
"".join(json.dumps(event, ensure_ascii=False) + "\n" for event in client.events.events),
"".join(json.dumps(event, ensure_ascii=False) + "\n" for event in collector.events),
encoding="utf-8",
)
@asynccontextmanager
async def _managed_client(client: RealtimeDuplexClient, *, timeout_s: float):
async with client:
try:
async def _managed_client(client: DuplexClient, collector: EventCollector):
consume_task = asyncio.create_task(collector.consume(client))
# Subscribe before the handshake so the collector sees session.created.
await asyncio.sleep(0)
try:
async with client:
yield
finally:
if client.events.count("session.created") and not client.events.count("session.closed"):
with suppress(Exception):
await client.close_session(timeout_s=min(timeout_s, 30.0))
await consume_task
finally:
consume_task.cancel()
with suppress(asyncio.CancelledError):
await consume_task
async def run(args: argparse.Namespace) -> dict[str, object]:
@@ -156,33 +144,22 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
else:
instructions = args.instructions
tools = DEFAULT_FUNCTION_TOOLS if args.expect_function_call else None
session_id = f"nemotron-voicechat-{uuid.uuid4().hex}"
client = RealtimeDuplexClient(_url(args.url, args.model, session_id))
async with _managed_client(client, timeout_s=args.timeout_s):
session_payload: dict[str, object] = {
"session_id": session_id,
"model": args.model,
"modalities": ["audio", "text"],
"input_audio_format": "pcm_f32le",
"output_audio_format": "pcm16",
"instructions": instructions,
"idle_timeout_s": args.timeout_s,
"turn_detection": None,
"extra_body": {"auto_response": True},
}
if tools is not None:
session_payload["tools"] = tools
await client.send({"type": "session.update", "session": session_payload})
await wait_for(
lambda: client.events.count("session.created") > 0 or bool(client.events.errors()),
timeout_s=args.timeout_s,
label="session.created",
)
if client.events.errors():
raise AssertionError(f"session setup failed: {client.events.errors()}")
created = _events(client, "session.created")[-1]
session = created.get("session")
capabilities = session.get("capabilities") if isinstance(session, dict) else None
collector = EventCollector()
client = DuplexClient(
args.url,
model=args.model,
config=create_duplex_session_config(
instructions=instructions,
tools=tools,
idle_timeout_s=args.timeout_s,
),
handshake_timeout_s=args.timeout_s,
reconnect=None,
heartbeat_interval_s=None,
)
async with _managed_client(client, collector):
session_id = client.session_id
capabilities = client.session_info.get("capabilities")
expected = dict(
implementation_level="model_native_duplex",
chunk_period_ms=80,
@@ -197,6 +174,7 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
asyncio.create_task(
_return_function_output_when_ready(
client,
collector,
output=args.function_output,
timeout_s=args.timeout_s,
)
@@ -206,19 +184,19 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
)
pcm = _read_wav(Path(args.input_wav), input_channel=args.input_channel)
frame_count = await _stream(client, pcm, max_frames=args.max_frames, realtime=not args.no_realtime)
completed_responses_at_commit = client.events.count("response.done")
await client.send({"type": "input_audio_buffer.commit", "final": True})
await wait_for(
completed_responses_at_commit = collector.count("response.done")
await client.commit()
await wait_for_condition(
lambda: (
bool(client.events.errors())
bool(collector.errors())
or (
client.events.count("response.function_call_arguments.done") > 0
collector.count("response.function_call_arguments.done") > 0
if args.expect_function_call
else (
client.events.count("response.output_audio.delta") >= args.minimum_audio_chunks
collector.count("response.output_audio.delta") >= args.minimum_audio_chunks
and (
args.allow_incomplete_response
or client.events.count("response.done") > completed_responses_at_commit
or collector.count("response.done") > completed_responses_at_commit
)
)
)
@@ -227,9 +205,9 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
label="model output",
)
await asyncio.sleep(args.drain_s)
if client.events.errors():
raise AssertionError(f"Realtime session emitted errors: {client.events.errors()}")
done_events = _events(client, "response.done")
if collector.errors():
raise AssertionError(f"Realtime session emitted errors: {collector.errors()}")
done_events = _events(collector, "response.done")
if not args.expect_function_call and not args.allow_incomplete_response:
response = done_events[-1].get("response") if done_events else None
status = response.get("status") if isinstance(response, dict) else None
@@ -237,24 +215,22 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
raise AssertionError(f"response did not complete successfully: {done_events[-1:]}")
function_events = [
event for event in client.events.events if str(event.get("type", "")).startswith("response.function_call")
event for event in collector.events if str(event.get("type", "")).startswith("response.function_call")
]
function_items = [
event
for event in _events(client, "response.output_item.done")
if isinstance(event.get("item"), dict) and event["item"].get("type") == "function_call"
item
for event in _events(collector, "response.output_item.done")
if isinstance((item := event.get("item")), dict) and item.get("type") == "function_call"
]
if args.expect_function_call and not any(
event.get("type") == "response.function_call_arguments.done" for event in function_events
):
raise AssertionError(f"no completed function call: {function_events}")
if args.expect_function_call:
matching_items = [
event for event in function_items if event["item"].get("name") == args.expected_function_name
]
matching_items = [item for item in function_items if item.get("name") == args.expected_function_name]
if not matching_items:
raise AssertionError(f"expected {args.expected_function_name!r}, got {function_items}")
function_item = matching_items[-1]["item"]
function_item: dict[str, object] = matching_items[-1]
try:
function_arguments = json.loads(str(function_item.get("arguments", "")))
except json.JSONDecodeError as exc:
@@ -275,7 +251,7 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
assert returned_call_id == call_id
def tool_result_completed() -> bool:
later = client.events.events[event_count_before_output:]
later = collector.events[event_count_before_output:]
transcript = "".join(
str(event.get("delta", ""))
for event in later
@@ -287,23 +263,23 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
and (args.expected_post_tool_text is None or args.expected_post_tool_text.lower() in transcript)
)
await wait_for(
lambda: bool(client.events.errors()) or tool_result_completed(),
await wait_for_condition(
lambda: bool(collector.errors()) or tool_result_completed(),
timeout_s=args.timeout_s,
label="completed response after function output",
)
if client.events.errors():
raise AssertionError(f"function output failed: {client.events.errors()}")
if collector.errors():
raise AssertionError(f"function output failed: {collector.errors()}")
await asyncio.sleep(args.drain_s)
audio = client.events.audio_bytes()
audio_events = _events(client, "response.output_audio.delta")
audio = collector.audio_bytes()
audio_events = _events(collector, "response.output_audio.delta")
rates = {event.get("sample_rate_hz") for event in audio_events}
if not args.expect_function_call and audio and rates != {OUTPUT_SAMPLE_RATE_HZ}:
raise AssertionError(f"unexpected output sample rates: {rates}")
if not args.expect_function_call and args.minimum_audio_chunks and not audio:
raise AssertionError("model produced no audio")
expected_bytes = 2 * OUTPUT_SAMPLE_RATE_HZ * expected["chunk_period_ms"] // 1000
expected_bytes = 2 * OUTPUT_SAMPLE_RATE_HZ * int(str(expected["chunk_period_ms"])) // 1000
packet_sizes = [len(base64.b64decode(str(event.get("delta", "")), validate=True)) for event in audio_events]
if not args.expect_function_call and any(size != expected_bytes for size in packet_sizes):
raise AssertionError(f"audio deltas are not fixed 80 ms PCM16 packets: {packet_sizes}")
@@ -314,17 +290,17 @@ async def run(args: argparse.Namespace) -> dict[str, object]:
f"model output RMS {audio_rms:.6f} is below {args.minimum_audio_rms:.6f}; "
"received packets contain only silence"
)
_write_events(output_dir / "events.jsonl", client)
_write_events(output_dir / "events.jsonl", collector)
if audio:
write_pcm16_wav(output_dir / "output.wav", audio, sample_rate_hz=OUTPUT_SAMPLE_RATE_HZ)
result = {
result: dict[str, object] = {
"ok": True,
"session_id": session_id,
"session_id": session_id if isinstance(session_id, str) else None,
"input_frames": frame_count,
"capabilities": capabilities,
"event_counts": {
event_type: client.events.count(event_type)
for event_type in sorted({str(event.get("type")) for event in client.events.events})
event_type: collector.count(event_type)
for event_type in sorted({str(event.get("type")) for event in collector.events})
},
"audio_bytes": len(audio),
"audio_rms": audio_rms,
@@ -29,16 +29,7 @@ _SERVER_ENV: dict[str, str] = {"VLLM_OMNI_INPUT_WAIT_TIMEOUT_S": str(SERVER_INPU
if TOKENIZER:
_SERVER_ENV["NEMOTRON_VOICECHAT_LLM_PATH"] = TOKENIZER
pytestmark = [
pytest.mark.full_model,
pytest.mark.omni,
# Since #7413 a duplex-capable model is served by the duplex engine only when
# its pipeline declares a duplex_plugin. The Nemotron VoiceChat pipeline does
# not, so the server starts the turn engine and the /v1/realtime websocket
# falls through to vllm's speech-to-text realtime handler, which rejects the
# native duplex client's session.update ("Missing required field: model").
pytest.mark.skip(reason="Nemotron VoiceChat is not served by the duplex engine yet (no duplex_plugin)"),
]
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
@hardware_test(res={"cuda": ["H100", "B200"]}, num_cards=1)
@@ -71,6 +62,10 @@ def test_native_duplex_turn_taking_streams_model_audio(omni_server, tmp_path: Pa
)
result = asyncio.run(run(args))
assert result["ok"] is True
assert result["input_frames"] == 190
assert result["event_counts"]["response.speak"] > 0
assert result["audio_bytes"] >= result["input_frames"] * 1764 * 2 // 4
input_frames = result["input_frames"]
event_counts = result["event_counts"]
audio_bytes = result["audio_bytes"]
assert isinstance(input_frames, int) and input_frames == 190
assert isinstance(event_counts, dict) and int(str(event_counts.get("response.speak", 0))) > 0
assert isinstance(audio_bytes, int)
assert audio_bytes >= input_frames * 1764 * 2 // 4
+26
View File
@@ -85,6 +85,32 @@ def test_detach_grace_and_resume_advance_the_lease_generation() -> None:
session.resume_lease(expected_lease_generation=0)
def test_a_resume_replayed_under_its_control_id_answers_without_resuming_again() -> None:
"""A caller that lost the answer (cancelled or timed-out waiter) asks again with the same id.
The engine must tell it the generation that resume produced, not bump the
lease a second time, and must still refuse any other resume against the
old generation.
"""
clock = FakeMonotonicClock(10.0)
session = _session("sid-replay", clock)
session.detach_lease()
assert session.resume_lease(expected_lease_generation=0, control_id="rpc-a") == 1
clock.advance(5.0)
session.detach_lease()
assert session.resume_lease(expected_lease_generation=0, control_id="rpc-a") == 1
assert session.lease_generation == 1
assert session.lease.detached_at == 15.0, "a replay is an answer, not a new resume"
with pytest.raises(ValueError, match="lease generation mismatch"):
session.resume_lease(expected_lease_generation=0, control_id="rpc-b")
assert session.resume_lease(expected_lease_generation=1, control_id="rpc-b") == 2
with pytest.raises(ValueError, match="lease generation mismatch"):
session.resume_lease(expected_lease_generation=0, control_id="rpc-a")
def test_active_operation_prevents_mid_transaction_expiry() -> None:
clock = FakeMonotonicClock(0.0)
session = _session("sid-operation", clock, idle_ttl_s=5.0)
+194 -4
View File
@@ -296,6 +296,7 @@ class Harness:
clock: FakeClock
output_sink: asyncio.Queue = field(default_factory=asyncio.Queue)
result_sink: asyncio.Queue = field(default_factory=asyncio.Queue)
_control_seq: int = 0
@classmethod
def create(
@@ -344,20 +345,30 @@ class Harness:
)
return await self.result()
async def resume(self, session_id: str, *, expected_lease_generation: int) -> DuplexControlResultMessage:
async def resume(
self, session_id: str, *, expected_lease_generation: int, control_id: str | None = None
) -> DuplexControlResultMessage:
# Distinct by default: the engine answers a repeated control id as a
# replay of the same resume, which only the replay test wants.
self._control_seq += 1
await self.manager.handle(
ResumeDuplexSessionMessage(
control_id=f"resume-{session_id}-{expected_lease_generation}",
control_id=control_id or f"resume-{session_id}-{expected_lease_generation}-{self._control_seq}",
session_id=session_id,
expected_lease_generation=expected_lease_generation,
)
)
return await self.result()
async def touch(self, session_id: str, activity: str) -> DuplexControlResultMessage:
async def touch(
self, session_id: str, activity: str, *, expected_lease_generation: int | None = None
) -> DuplexControlResultMessage:
await self.manager.handle(
TouchDuplexSessionMessage(
control_id=f"touch-{session_id}-{activity}", session_id=session_id, activity=activity
control_id=f"touch-{session_id}-{activity}-{expected_lease_generation}",
session_id=session_id,
activity=activity,
expected_lease_generation=expected_lease_generation,
)
)
return await self.result()
@@ -1656,6 +1667,168 @@ async def test_reaper_loop_survives_one_cleanup_failure(first_cleanup_delay: flo
await asyncio.wait_for(task, timeout=5.0)
# --------------------------------------------------------------------------- #
# Cancelled open (#7636 Issue 1) #
# --------------------------------------------------------------------------- #
class _BlockingResultSink(asyncio.Queue):
"""A result sink whose ``put`` parks, so an open can be cancelled after it admitted the session."""
def __init__(self) -> None:
super().__init__()
self.entered = asyncio.Event()
self.gate = asyncio.Event()
async def put(self, item: object) -> None:
self.entered.set()
await self.gate.wait()
await super().put(item)
async def test_an_open_cancelled_while_awaiting_the_plugin_frees_the_admission_slot() -> None:
"""``CancelledError`` is not an ``Exception``: the rollback used to be skipped.
Admission counts ``runners | closing | admitting``, so every open cancelled
mid-flight (engine teardown, task cancellation) burned one slot for good;
after ``max_sessions`` of them every new session was refused.
"""
async with Harness.create(max_sessions=1) as harness:
harness.plugin.blocked_session_ids.add("blocked")
config = DuplexSessionConfig(model="fake-model", instructions="blocked")
open_task = asyncio.create_task(harness.open("sid-cancelled", config))
await asyncio.wait_for(harness.plugin.runtime_config_started.wait(), timeout=1.0)
open_task.cancel()
with pytest.raises(asyncio.CancelledError):
await open_task
assert harness.manager.get("sid-cancelled") is None
assert harness.manager.active_count() == 0
assert harness.result_sink.empty(), "a cancelled open answers nobody"
assert (await harness.open("sid-replacement")).ok is True
async def test_an_open_cancelled_after_admission_releases_the_runner_and_its_stage_reservation() -> None:
"""The worst landing spot for the cancel: the runner is registered and Stage0 is reserved.
Both have to be undone, or the runner stays in ``runners`` (one slot gone)
and the Stage0 request stays reserved in the orchestrator.
"""
async with Harness.create(max_sessions=1) as harness:
sink = _BlockingResultSink()
original_sink = harness.manager._result_sink
harness.manager._result_sink = sink
open_task = asyncio.create_task(
harness.manager.handle(
OpenDuplexSessionMessage(
control_id="open-cancelled",
session_id="sid-cancelled",
session_config=DuplexSessionConfig(model="fake-model"),
)
)
)
await asyncio.wait_for(sink.entered.wait(), timeout=1.0)
assert "sid-cancelled" in harness.manager.runners
assert [context.request_id for context in harness.stage_port.ensure_calls] == [
stage0_request_id("sid-cancelled")
]
open_task.cancel()
with pytest.raises(asyncio.CancelledError):
await open_task
harness.manager._result_sink = original_sink
assert "sid-cancelled" not in harness.manager.runners
assert harness.manager.active_count() == 0
assert harness.stage_port.cleanup_calls == [([stage0_request_id("sid-cancelled")], False)]
assert stage0_request_id("sid-cancelled") not in harness.manager._request_index
assert (await harness.open("sid-replacement")).ok is True
async def test_an_open_cancelled_again_during_its_rollback_leaves_the_stage_cleanup_to_the_reaper() -> None:
"""A second cancellation landing in the rollback's awaits must not orphan the Stage0 request.
The runner, the reservations and the request index are already gone by
then, so nothing would ever clean the orchestrator's request state up.
The rollback records the ids as a pending request cleanup before it
awaits anything, and the reaper's retry finishes the job.
"""
async with Harness.create(max_sessions=1) as harness:
sink = _BlockingResultSink()
original_sink = harness.manager._result_sink
harness.manager._result_sink = sink
open_task = asyncio.create_task(
harness.manager.handle(
OpenDuplexSessionMessage(
control_id="open-cancelled-twice",
session_id="sid-cancelled",
session_config=DuplexSessionConfig(model="fake-model"),
)
)
)
await asyncio.wait_for(sink.entered.wait(), timeout=1.0)
runner = harness.manager.runners["sid-cancelled"]
shutdown_started = asyncio.Event()
async def parked_shutdown() -> None:
shutdown_started.set()
await asyncio.Event().wait()
runner.shutdown = parked_shutdown # type: ignore[method-assign]
open_task.cancel()
await asyncio.wait_for(shutdown_started.wait(), timeout=1.0)
open_task.cancel() # lands inside the rollback, before the stage cleanup
with pytest.raises(asyncio.CancelledError):
await open_task
harness.manager._result_sink = original_sink
assert "sid-cancelled" not in harness.manager.runners
assert harness.manager.active_count() == 0
assert harness.stage_port.cleanup_calls == [], "the cleanup await was never reached"
key = ("sid-cancelled", 0)
assert key in harness.manager._pending_request_cleanups
await harness.manager.reap_expired()
assert harness.stage_port.cleanup_calls == [([stage0_request_id("sid-cancelled")], False)]
assert key not in harness.manager._pending_request_cleanups
assert (await harness.open("sid-replacement")).ok is True
# --------------------------------------------------------------------------- #
# Detach fenced on the lease generation #
# --------------------------------------------------------------------------- #
async def test_detach_is_refused_for_a_lease_the_caller_no_longer_holds() -> None:
"""A connection giving up its lease must not start the grace for the lease a later resume owns.
Resume is a CAS on the lease generation; detach is now fenced the same
way, so a stale caller (its resume was superseded by another connection's)
is refused instead of detaching the winner.
"""
async with Harness.create() as harness:
await harness.open("sid-fence")
session = harness.session("sid-fence")
assert (await harness.resume("sid-fence", expected_lease_generation=0)).ok is True
assert session.lease_generation == 1
stale = await harness.touch("sid-fence", "detach", expected_lease_generation=0)
assert stale.ok is False
assert stale.error_code == "session_resume_conflict"
assert session.lease.detached_at is None, "the current lease is untouched"
current = await harness.touch("sid-fence", "detach", expected_lease_generation=1)
assert current.ok is True
assert session.lease.detached_at is not None
# An unfenced detach keeps its meaning: whatever generation is current.
assert (await harness.resume("sid-fence", expected_lease_generation=1)).ok is True
assert (await harness.touch("sid-fence", "detach")).ok is True
assert session.lease.detached_at is not None
# --------------------------------------------------------------------------- #
# Cancellation waits #
# --------------------------------------------------------------------------- #
@@ -1685,3 +1858,20 @@ async def test_cancel_append_tasks_absorbs_a_task_that_outlives_the_wait() -> No
assert await tasks.cancel_append_tasks(timeout_s=0.05) is True
assert tasks.append_tail is None
await asyncio.wait_for(asyncio.gather(task, return_exceptions=True), timeout=5.0)
async def test_a_resume_replayed_under_its_control_id_reports_the_generation_it_produced() -> None:
"""The client side of an abandoned resume: it lost the answer and asks again with the same id."""
async with Harness.create() as harness:
await harness.open("sid-replay")
session = harness.session("sid-replay")
first = await harness.resume("sid-replay", expected_lease_generation=0, control_id="rpc-a")
assert first.ok is True and first.lease_generation == 1
replay = await harness.resume("sid-replay", expected_lease_generation=0, control_id="rpc-a")
assert replay.ok is True
assert replay.lease_generation == 1
assert session.lease_generation == 1, "a replay does not resume again"
other = await harness.resume("sid-replay", expected_lease_generation=0, control_id="rpc-b")
assert other.ok is False and other.error_code == "session_resume_conflict"
+138 -2
View File
@@ -73,6 +73,9 @@ class RecordingStagePort(DuplexStagePort):
self.cleanups: list[tuple[list[str], bool]] = []
self.aborts: list[list[str]] = []
self.fail_submit: Exception | None = None
#: When set, ``submit`` parks on it after signalling ``submit_started``.
self.submit_gate: asyncio.Event | None = None
self.submit_started = asyncio.Event()
@property
def stage_count(self) -> int:
@@ -87,6 +90,9 @@ class RecordingStagePort(DuplexStagePort):
async def submit(self, submission: DuplexStageSubmission) -> DuplexStageSubmissionResult:
if self.fail_submit is not None:
raise self.fail_submit
if self.submit_gate is not None:
self.submit_started.set()
await self.submit_gate.wait()
self.submissions.append(submission)
return DuplexStageSubmissionResult(
request_id=submission.context.request_id,
@@ -163,7 +169,7 @@ class Harness:
def deliver(
self,
output: object,
output: SimpleNamespace,
*,
stage_id: int = 1,
segment_finished: bool = False,
@@ -186,7 +192,7 @@ class Harness:
raise AssertionError("stage output is missing request_id")
return self.runner.on_stage_output(stage_id, output, metrics, request_id=request_id, context=context)
async def deliver_and_settle(self, output: object, **kwargs: Any) -> list[DuplexEvent]:
async def deliver_and_settle(self, output: SimpleNamespace, **kwargs: Any) -> list[DuplexEvent]:
self.deliver(output, **kwargs)
return await self.settle()
@@ -1624,3 +1630,133 @@ async def test_server_vad_speech_stopped_still_commits_a_turn_mode_session() ->
assert len(_final_submissions(h)) == 1, "the detector's stop commits the turn and starts the response"
finally:
await close_harness(h)
# --------------------------------------------------------------------------- #
# Cancelled append and its precreated response (#7636 Issue 2) #
# --------------------------------------------------------------------------- #
async def _commit_with_pending_response(h: Harness) -> str:
"""Commit a turn whose final append parks in the stage port; return the precreated response id."""
await h.run(append_audio())
h.port.submit_gate = asyncio.Event()
h.submit(commands.Commit(create_response=True))
await asyncio.wait_for(h.port.submit_started.wait(), timeout=2.0)
# The parked append keeps the runner "busy": settle only until the mailbox is quiet.
events = await h.settle(timeout_s=0.3)
assert "response.created" in types(events)
response_id = h.session.active_response_id
assert response_id is not None
assert h.runner.tasks.append_tail is not None and not h.runner.tasks.append_tail.done()
return response_id
@pytest.mark.asyncio
async def test_an_append_cancelled_from_outside_fails_the_response_it_precreated() -> None:
"""A cancelled append gives back the response it precreated, as its docstring promises.
The ``CancelledError`` branch used to roll back only the PCM reservation,
so ``active_response_id`` stayed pinned to a response nobody would ever
finish: the client had seen ``response.created`` and waited forever.
"""
h = await open_harness(auto_response=False)
try:
response_id = await _commit_with_pending_response(h)
pending = h.runner.tasks.append_tail
assert pending is not None
pending.cancel()
events = await h.settle()
done = find(events, "response.done")
assert done.response_id == response_id
assert done.status == "failed"
assert done.response["status_details"]["reason"] == "append_cancelled"
assert h.session.active_response_id is None
assert h.session.state == DuplexSessionState.OPEN
# The cancelled append counts as a failed one: the chain behind it is
# refused with the documented error, not left hanging.
h.port.submit_gate = None
await h.run(append_audio())
events = await h.run(commands.Commit(create_response=True))
assert "response.created" not in types(events)
assert find(events, "error").code == "commit_aborted"
assert h.session.active_response_id is None
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_response_cancel_of_a_pending_append_ends_the_response_once_as_cancelled() -> None:
"""When the runner cancels the append itself, it owns the response: one terminal, status cancelled.
The append must not also fail the response on its way out, or the client
would get a failed ``response.done`` for a response it cancelled.
"""
h = await open_harness(auto_response=False)
try:
response_id = await _commit_with_pending_response(h)
events = await h.run(commands.CancelResponse())
terminals = [event for event in events if event.type == "response.done"]
assert [event.response_id for event in terminals] == [response_id]
assert terminals[0].status == "cancelled"
assert h.session.active_response_id is None
assert h.session.state == DuplexSessionState.OPEN
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_closing_the_session_with_a_pending_append_emits_no_failed_response() -> None:
"""A close ends the active response itself; the append it cancels stays quiet."""
h = await open_harness(auto_response=False)
try:
await _commit_with_pending_response(h)
events = await h.run(commands.CloseSession(reason="client_close"))
events += await h.settle()
assert [event.type for event in events if event.type == "response.done"] == []
assert "session.closed" in types(events)
assert h.session.active_response_id is None
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_input_clear_racing_a_queued_append_drops_it_as_the_clients_doing() -> None:
"""An ``input_audio_buffer.clear`` that lands while an append waits its turn is not a runtime failure.
The clear deactivates the queued append's reservation; when its turn
comes it gives everything back and stops, without an error and without
failing the session, and its reason is the client's clear rather than
``runtime_append_failed``.
"""
h = await open_harness()
try:
h.port.submit_gate = asyncio.Event()
h.submit(append_audio())
await asyncio.wait_for(h.port.submit_started.wait(), timeout=2.0)
h.submit(append_audio())
await h.settle(timeout_s=0.3)
assert len(h.runner.tasks.append_tasks) == 2, "the second append is queued behind the parked one"
queued = h.runner.tasks.append_tail
assert queued is not None and not queued.done()
h.submit(commands.ClearInput())
events = await h.settle(timeout_s=0.3)
assert types(events) == ["input_audio_buffer.cleared"]
h.port.submit_gate.set()
events = await h.settle()
assert queued.done() and queued.result() is False, "the cleared append never ran"
assert len(h.port.submissions) == 1, "only the append that was already in flight reached the stage"
assert [event.type for event in events if event.type in {"error", "response.done", "session.closed"}] == []
assert h.session.pending_input_bytes == 0
assert h.session.state == DuplexSessionState.OPEN
finally:
await close_harness(h)
@@ -0,0 +1,167 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Essential Nemotron regressions through the real session runner and plugin."""
import asyncio
from types import SimpleNamespace
import pytest
import pytest_asyncio
import torch
from tests.engine.duplex.test_session_runner import (
SESSION_ID,
Harness,
RecordingStagePort,
_fake_encode_audio,
append_audio,
types,
)
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex import commands
from vllm_omni.engine.duplex.config import DuplexSessionConfig
from vllm_omni.engine.duplex.messages import OpenDuplexSessionMessage
from vllm_omni.engine.duplex.plugin import load_duplex_plugin
from vllm_omni.engine.duplex.session.manager import DuplexSessionManager
from vllm_omni.model_executor.models.nemotron_voicechat.duplex import plugin as plugin_module
from vllm_omni.model_executor.models.nemotron_voicechat.nemotron_voicechat_thinker import (
NemotronVoiceChatThinkerForConditionalGeneration as Thinker,
)
from vllm_omni.model_executor.models.nemotron_voicechat.pipeline import NEMOTRON_VOICECHAT_PIPELINE
from vllm_omni.protocol.realtime.items import normalize_conversation_item
from vllm_omni.utils.mm_outputs import build_mm_cpu
from vllm_omni.worker.output.payload_build import build_omni_mm_payload
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest_asyncio.fixture
async def harness(monkeypatch):
monkeypatch.setenv("NEMOTRON_VOICECHAT_LLM_PATH", "test-tokenizer")
special_ids = dict(
nvc_text_bos_id=0,
nvc_text_eos_id=1,
nvc_text_pad_id=12,
nvc_function_sotc_id=20,
nvc_function_eotc_id=21,
nvc_function_eotr_id=22,
nvc_tokenizer_ref="test-tokenizer",
)
tokenizer = SimpleNamespace(
encode=lambda text, **kwargs: [42],
decode=lambda ids, **kwargs: '[{"name":"lookup","arguments":{"x":1}}]',
)
monkeypatch.setattr(plugin_module, "_load_tokenizer_runtime", lambda _: (special_ids, tokenizer))
plugin = load_duplex_plugin(NEMOTRON_VOICECHAT_PIPELINE.duplex_plugin, _fake_encode_audio)
port = RecordingStagePort(stage_count=3)
output: asyncio.Queue = asyncio.Queue()
results: asyncio.Queue = asyncio.Queue()
manager = DuplexSessionManager(
plugin=plugin,
stage_port=port,
output_sink=output,
result_sink=results,
runtime_config=DuplexSessionRuntimeConfig(),
model_config=SimpleNamespace(hf_config=SimpleNamespace(stt_cfg={}), max_model_len=8192),
)
try:
config = DuplexSessionConfig(model="nemotron", instructions="hi", extra_body={"auto_response": True})
await manager.handle(OpenDuplexSessionMessage(control_id="open", session_id=SESSION_ID, session_config=config))
result = await asyncio.wait_for(results.get(), timeout=2)
assert result.ok, result
h = Harness(manager, port, output, results, manager.runners[SESSION_ID])
await h.settle()
yield h
finally:
await manager.shutdown()
async def deliver(h, token_id=12, **metadata):
output = SimpleNamespace(
request_id=h.stage0_request_id(),
finished=False,
multimodal_output={},
outputs=[SimpleNamespace(text="", token_ids=[token_id], multimodal_output={})],
)
return await h.deliver_and_settle(
output,
stage_id=0,
segment_finished=True,
segment_token_ids=[token_id],
segment_output_metadata=metadata,
)
@pytest.mark.asyncio
async def test_frame_buffering_prefill_and_resumable_append(harness):
h = harness
await h.run(append_audio(samples=1000))
assert h.port.submissions == []
await h.run(append_audio(samples=280))
await h.run(append_audio(samples=1280))
first, second = h.port.submissions
assert first.prompt["prompt_token_ids"] == [0, 42, 1, 12]
assert second.prompt["prompt_token_ids"] == [12]
assert first.prompt["model_intermediate_buffer"]["duplex"]["source_input_seq"] == 1
assert second.prompt["model_intermediate_buffer"]["duplex"]["source_input_seq"] == 2
assert not first.already_submitted and second.already_submitted
assert first.context.request_id == second.context.request_id == h.stage0_request_id()
assert "error" not in types(h.events)
@pytest.mark.asyncio
@pytest.mark.parametrize("request_ids", [("active",), ("other", "active"), ("active", "other")])
async def test_tool_batches_reach_worker_and_drain_before_ninth_result(harness, request_ids):
h = harness
await h.run(append_audio(samples=1280))
worker_state = {"function_response_generation": 0, "forced_function_tokens": []}
thinker = Thinker.__new__(Thinker)
torch.nn.Module.__init__(thinker)
thinker._sessions = {"active": worker_state, "other": {"function_response_generation": 2}}
thinker._use_function_head, thinker._dtype = True, torch.float32
thinker.function_head = torch.nn.Identity()
key = "nvc_function_response_consumed_generation"
assert key in thinker.omni_client_multimodal_output_keys
infos = [{"request_id": rid} for rid in request_ids]
hidden = torch.zeros((len(infos) + 1, 2))
for generation in range(1, 10):
for token_id in (20, 7, 21):
events = await deliver(h, nvc_function_token=[token_id])
done = next(event for event in events if event.type == "response.function_call_arguments.done")
item = {"type": "function_call_output", "call_id": done.call_id, "output": "20"}
events = await h.run(commands.CreateItem(item=normalize_conversation_item(item)))
assert "error" not in types(events)
runtime = h.session.runtime_config
assert runtime["nvc_function_response_generation"] == generation
assert runtime["nvc_function_response_batches"][-1] == {"generation": generation, "token_ids": [42]}
if generation < 8:
continue
if generation == 8:
with pytest.raises(plugin_module.NemotronVoiceChatClientRuntimeConfigError, match="faster than"):
h.runner.plugin.runtime_config_for_function_output(h.session.config, runtime, item)
# Copy into model state exactly once, even if the same runtime snapshot is replayed.
Thinker._sync_forced_function_response(worker_state, runtime)
Thinker._sync_forced_function_response(worker_state, runtime)
assert worker_state["forced_function_tokens"] == [42] * generation
assert worker_state["function_response_generation"] == generation
output = thinker.make_omni_output(hidden, model_intermediate_buffer=infos)
for idx, rid in enumerate(request_ids):
payload = build_omni_mm_payload(
combined_multimodal_outputs=None,
mm_cpu=build_mm_cpu(output.multimodal_outputs),
rid=rid,
idx=idx,
start=0 if idx == 0 else 2,
end=2 if idx == 0 else 3,
audio_sparse_output=False,
sparse_mm_index={},
hidden_seq_len=len(hidden),
scheduled_seq_len=len(hidden),
)
assert int(payload[key]) == (generation if rid == "active" else 2)
if rid == "active":
await deliver(h, **payload)
assert h.session.runtime_config["nvc_function_response_batches"] == []
assert h.session.runtime_config["nvc_function_response_generation"] == generation
assert "error" not in types(h.events)
+11
View File
@@ -20,6 +20,7 @@ from vllm.config import ParallelConfig as VllmParallelConfig
from vllm.config import ProfilerConfig as VllmProfilerConfig
from vllm.config import SchedulerConfig as VllmSchedulerConfig
from vllm.engine.arg_utils import EngineArgs
from vllm.model_executor.models.utils import extract_layer_index
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.config.omni_config import (
@@ -404,7 +405,9 @@ def test_mammoth_fp8_kv_deploy_projects_only_ar_stage(monkeypatch):
)
assert deploy.stages[0].engine_extras["kv_cache_dtype"] == "fp8_e4m3"
assert deploy.stages[0].engine_extras["kv_cache_dtype_skip_layers"] == ["0"]
assert "kv_cache_dtype" not in deploy.stages[1].engine_extras
assert "kv_cache_dtype_skip_layers" not in deploy.stages[1].engine_extras
legacy_args = [build_legacy_engine_args_dict(stage, "test-model") for stage in legacy_stages]
typed_args = [
@@ -420,14 +423,22 @@ def test_mammoth_fp8_kv_deploy_projects_only_ar_stage(monkeypatch):
assert ar_stage.stage_pipeline_config.execution_type == StageExecutionType.LLM_AR
assert legacy_args[0]["kv_cache_dtype"] == "fp8_e4m3"
assert legacy_args[0]["kv_cache_dtype_skip_layers"] == ["0"]
assert ar_stage.cache_config.cache_dtype == "fp8_e4m3"
assert ar_stage.cache_config.kv_cache_dtype_skip_layers == ["0"]
assert "cache_dtype" in ar_stage.cache_config._omni_explicit_fields
assert typed_args[0]["kv_cache_dtype"] == "fp8_e4m3"
assert typed_args[0]["kv_cache_dtype_skip_layers"] == ["0"]
# vLLM matches the list against the index it parses from each attention prefix.
assert str(extract_layer_index("ar.language_model.layers.0.self_attn.attn")) == "0"
assert "kv_cache_dtype" not in legacy_args[1]
assert "kv_cache_dtype_skip_layers" not in legacy_args[1]
assert dit_stage.cache_config.cache_dtype == "auto"
assert dit_stage.cache_config.kv_cache_dtype_skip_layers == []
assert "cache_dtype" not in dit_stage.cache_config._omni_explicit_fields
assert "kv_cache_dtype" not in typed_args[1]
assert "kv_cache_dtype_skip_layers" not in typed_args[1]
def test_typed_llm_projection_rejects_explicit_fields_owned_by_another_boundary():
+422 -3
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import base64
import json
from collections.abc import Awaitable, Callable
from contextlib import suppress
from typing import Any
@@ -131,7 +132,15 @@ class FakeOmni:
self.handles: dict[str, FakeHandle] = {}
self.resumed: list[tuple[str, int]] = []
self.detached: list[str] = []
#: Detaches the engine refused because the caller's lease generation was stale.
self.detach_refused: list[tuple[str, int]] = []
self.open_error: DuplexSessionError | None = None
#: When set, ``resume_session`` parks on it once the engine applied the
#: resume: the lease generation is bumped, the result is still in flight.
self.resume_gate: asyncio.Event | None = None
self.resume_started = asyncio.Event()
#: Settlements of resumes whose caller was cancelled mid-RPC (DuplexOmni's contract).
self.compensations: list[asyncio.Task[None]] = []
async def open_session(self, config: Any) -> FakeHandle:
self.opened.append(dict(config))
@@ -146,13 +155,46 @@ class FakeOmni:
def get_session(self, session_id: str) -> FakeHandle | None:
return self.handles.get(session_id)
async def resume_session(self, session_id: str, *, expected_lease_generation: int) -> FakeHandle:
async def resume_session(
self,
session_id: str,
*,
expected_lease_generation: int,
on_abandoned: Callable[[int], Awaitable[None]] | None = None,
) -> FakeHandle:
self.resumed.append((session_id, expected_lease_generation))
handle = self.handles[session_id]
handle.lease_generation = expected_lease_generation + 1
lease_generation = expected_lease_generation + 1
handle.lease_generation = lease_generation
if self.resume_gate is not None:
self.resume_started.set()
gate = self.resume_gate
try:
await gate.wait()
except asyncio.CancelledError:
# DuplexOmni's contract: the engine applied the resume; once
# its answer arrives the landed generation is settled off the
# cancelled task, through the caller's callback if it gave one.
async def settle() -> None:
await gate.wait()
if on_abandoned is not None:
await on_abandoned(lease_generation)
else:
with suppress(DuplexSessionError):
await self.detach_session(session_id, expected_lease_generation=lease_generation)
self.compensations.append(asyncio.create_task(settle()))
raise
return handle
async def detach_session(self, session_id: str) -> None:
async def detach_session(self, session_id: str, *, expected_lease_generation: int | None = None) -> None:
handle = self.handles[session_id]
if expected_lease_generation is not None and expected_lease_generation != handle.lease_generation:
# The engine's fence: a detach of a lease the caller no longer holds is refused.
self.detach_refused.append((session_id, expected_lease_generation))
raise DuplexSessionError(
"duplex lease generation mismatch", code="session_resume_conflict", session_id=session_id
)
self.detached.append(session_id)
@@ -607,3 +649,380 @@ async def test_a_resume_that_fails_to_activate_does_not_detach_the_live_attachme
pending.cancel()
with suppress(asyncio.CancelledError, Exception):
await pending
# --------------------------------------------------------------------------- #
# Cancelled resume and the engine disconnect grace (#7636 Issue 3) #
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_a_resume_cancelled_during_activation_puts_the_engine_lease_back_into_grace() -> None:
"""``_resume`` resumes the engine lease first; a cancel mid-activation must detach it again.
The rollback used to catch ``Exception`` only. A handler task cancelled
while sending ``session.resumed`` or a replay entry left the engine with
``detached_at=None`` and the outer handler with no attachment to clean up:
the reaper reclaimed nothing after the grace, and with one slot the next
open was refused with ``resource_exhausted``.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
registry = handler._attachment_registry
activating = asyncio.Event()
async def parked_resume(*args, **kwargs):
activating.set()
await asyncio.Event().wait()
original_resume = registry.resume
registry.resume = parked_resume # type: ignore[method-assign]
try:
ws2, task2 = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(activating.wait(), timeout=2.0)
assert omni.resumed == [(handle.session_id, 0)], "the engine lease was resumed before activation"
task2.cancel()
with suppress(asyncio.CancelledError):
await task2
finally:
registry.resume = original_resume # type: ignore[method-assign]
# The lease is detached again, so the disconnect grace runs for it.
assert omni.detached == [handle.session_id, handle.session_id]
assert not await registry.has_attachment(handle.session_id)
assert handle.close_reasons == []
# And the session is still resumable with the token the cancelled attempt presented.
ws3, task3 = await _resume(handler, handle.session_id, token)
resumed = await ws3.wait_for("session.resumed")
assert resumed["session_id"] == handle.session_id
ws3.disconnect()
await asyncio.wait_for(task3, timeout=2.0)
@pytest.mark.asyncio
async def test_a_resume_cancelled_after_activation_detaches_the_attachment_it_made() -> None:
"""A cancel after the registry activated the new attachment must undo that attachment too.
Otherwise the session stays attached to a socket nobody serves, with the
engine lease resumed: no grace, no expiry, until the idle TTL.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
# The takeover notifies the replaced socket after activation; park there.
notifying = asyncio.Event()
async def parked_send(payload: dict[str, Any]) -> None:
notifying.set()
await asyncio.Event().wait()
ws.send_json = parked_send # type: ignore[method-assign]
ws2, task2 = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(notifying.wait(), timeout=2.0)
assert await registry.is_current_attachment(handle.session_id, 2), "the new socket is attached"
task2.cancel()
with suppress(asyncio.CancelledError):
await task2
assert not await registry.has_attachment(handle.session_id)
assert omni.detached == [handle.session_id]
assert handle.close_reasons == []
task.cancel()
with suppress(asyncio.CancelledError, Exception):
await task
@pytest.mark.asyncio
async def test_an_abandoned_resume_never_detaches_the_lease_a_later_resume_owns() -> None:
"""Two connections resuming the same session: the loser's rollback must not touch the winner's lease.
Interleaving: A has activated and is notifying the socket it replaced; B,
holding A's rotated token, has completed its engine resume but its RPC
result has not reached the handler yet, so nothing is attached for B.
Cancelling A detaches A's registry generation, which used to make the
engine detach unconditional: it landed on the lease B had just resumed,
and the reaper expired B after the grace even though it heartbeats. The
engine now refuses a detach fenced on a generation it has moved past.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
# A takes over and parks while notifying the replaced socket (after activation).
notifying = asyncio.Event()
async def parked_send(payload: dict[str, Any]) -> None:
notifying.set()
await asyncio.Event().wait()
ws.send_json = parked_send # type: ignore[method-assign]
ws_a, task_a = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(notifying.wait(), timeout=2.0)
resumed_a = await ws_a.wait_for("session.resumed")
assert handle.lease_generation == 1
# B resumes with A's rotated token: the engine applies it, the result is still in flight.
omni.resume_gate = asyncio.Event()
ws_b, task_b = await _resume(handler, handle.session_id, resumed_a["resume_token"])
await asyncio.wait_for(omni.resume_started.wait(), timeout=2.0)
assert handle.lease_generation == 2
task_a.cancel()
with suppress(asyncio.CancelledError):
await task_a
assert omni.detached == [], "A's rollback must not detach the lease B resumed"
assert omni.detach_refused == [(handle.session_id, 1)]
assert not await registry.has_attachment(handle.session_id), "A's attachment is gone"
omni.resume_gate.set()
resumed_b = await ws_b.wait_for("session.resumed")
assert resumed_b["attachment_generation"] == 3
assert await registry.is_current_attachment(handle.session_id, 3)
assert omni.detached == []
# B's own disconnect detaches the lease B holds.
ws_b.disconnect()
await asyncio.wait_for(task_b, timeout=2.0)
assert omni.detached == [handle.session_id]
assert omni.detach_refused == [(handle.session_id, 1)]
task.cancel()
with suppress(asyncio.CancelledError, Exception):
await task
@pytest.mark.asyncio
async def test_an_abandoned_takeover_leaves_the_live_connection_serving_and_owning_the_lease() -> None:
"""A takeover cancelled mid-RPC must not put the connection it never replaced into disconnect grace.
A is attached at generation 0. B's engine resume lands generation 1 and B
is cancelled before it reaches the registry, so A stays the current
attachment. Settling B's generation must hand it to A rather than detach
it: A keeps serving, and A's own disconnect later detaches generation 1,
which is the lease the engine actually holds.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
omni.resume_gate = asyncio.Event()
ws_b, task_b = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(omni.resume_started.wait(), timeout=2.0)
assert handle.lease_generation == 1
task_b.cancel()
with suppress(asyncio.CancelledError):
await task_b
omni.resume_gate.set()
await asyncio.gather(*omni.compensations)
assert omni.detached == [], "A is still serving: its lease must not enter disconnect grace"
assert omni.detach_refused == []
assert await registry.is_current_attachment(handle.session_id, 1), "A is still the attachment"
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
await ws.wait_for("response.output_audio.delta")
# A now owns generation 1: its disconnect detaches the lease the engine holds.
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
assert omni.detach_refused == []
@pytest.mark.asyncio
async def test_a_send_failure_detaches_the_failed_sockets_own_lease_not_a_pending_resumes() -> None:
"""The pump's detach is fenced on the lease of the socket whose send failed, captured with it.
The pump holds the registry's outbound lock while sending to A. B's
engine resume lands generation 1 meanwhile and B waits for that lock to
activate. When A's send fails, the pump drops A and must detach A's
generation 0, which the engine refuses, not B's generation 1, which would
put B into disconnect grace the moment it activates.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
sending = asyncio.Event()
fail_send = asyncio.Event()
async def parked_failing_send(payload: dict[str, Any]) -> None:
sending.set()
await fail_send.wait()
raise RuntimeError("socket closed")
ws.send_json = parked_failing_send # type: ignore[method-assign]
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
await asyncio.wait_for(sending.wait(), timeout=2.0)
# B: the engine resume lands; activation waits for the outbound lock the pump holds.
ws_b, task_b = await _resume(handler, handle.session_id, token)
await asyncio.sleep(0.05)
assert omni.resumed == [(handle.session_id, 0)]
assert handle.lease_generation == 1
assert await registry.is_current_attachment(handle.session_id, 1), "B has not activated yet"
fail_send.set()
resumed_b = await ws_b.wait_for("session.resumed")
assert resumed_b["attachment_generation"] == 2
assert omni.detached == [], "A's lease (generation 0) is refused, B's lease is untouched"
assert omni.detach_refused == [(handle.session_id, 0)]
assert await registry.is_current_attachment(handle.session_id, 2)
# B serves; its own disconnect detaches the lease it resumed.
ws_b.disconnect()
await asyncio.wait_for(task_b, timeout=2.0)
assert omni.detached == [handle.session_id]
ws.disconnect()
with suppress(asyncio.CancelledError, Exception):
await asyncio.wait_for(task, timeout=2.0)
@pytest.mark.asyncio
async def test_an_abandoned_resume_goes_to_the_resume_waiting_to_activate_not_the_socket_it_replaces() -> None:
"""Three connections: A sending, B resumed and waiting for the outbound lock, C abandoned mid-RPC.
C's generation 2 must be parked for B's activation. If it were handed to
A instead, A's send failure would detach generation 2 (accepted by the
engine), and B would then activate on a lease already in disconnect grace
and expire despite heartbeating.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
sending = asyncio.Event()
fail_send = asyncio.Event()
async def parked_failing_send(payload: dict[str, Any]) -> None:
sending.set()
await fail_send.wait()
raise RuntimeError("socket closed")
ws.send_json = parked_failing_send # type: ignore[method-assign]
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
await asyncio.wait_for(sending.wait(), timeout=2.0)
# B: engine resume lands generation 1; activation waits for the lock the pump holds.
ws_b, task_b = await _resume(handler, handle.session_id, token)
await asyncio.sleep(0.05)
assert handle.lease_generation == 1
# C: same unrotated token, engine resume lands generation 2, cancelled mid-RPC.
omni.resume_gate = asyncio.Event()
ws_c, task_c = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(omni.resume_started.wait(), timeout=2.0)
assert handle.lease_generation == 2
task_c.cancel()
with suppress(asyncio.CancelledError):
await task_c
omni.resume_gate.set()
await asyncio.gather(*omni.compensations)
assert omni.detached == [] and omni.detach_refused == [], "generation 2 is parked for B"
# A's send fails: the pump drops A and detaches A's own generation 0, refused.
fail_send.set()
resumed_b = await ws_b.wait_for("session.resumed")
assert resumed_b["attachment_generation"] == 2
assert omni.detach_refused == [(handle.session_id, 0)]
assert omni.detached == [], "the lease B serves is untouched"
assert await registry.is_current_attachment(handle.session_id, 2)
# B serves generation 2 (inherited from C): its disconnect detaches that lease.
ws_b.disconnect()
await asyncio.wait_for(task_b, timeout=2.0)
assert omni.detached == [handle.session_id]
assert omni.detach_refused == [(handle.session_id, 0)]
ws.disconnect()
with suppress(asyncio.CancelledError, Exception):
await asyncio.wait_for(task, timeout=2.0)
@pytest.mark.asyncio
async def test_a_resume_cancelled_during_replay_settles_the_newer_lease_it_was_handed() -> None:
"""Activation rollback must give back the lease the provisional attachment actually owned.
A resumed generation 1, delivered ``session.resumed`` and is blocked
replaying events. B, with A's rotated token, resumed generation 2 and was
cancelled mid-RPC; its settlement handed 2 to A. When A is then cancelled
during replay, the registry rolls A back; detaching A's own generation 1
would be refused and generation 2 would never enter disconnect grace.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
# Journaled while detached: A's activation has something to replay.
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="one"))
await asyncio.sleep(0.05)
# A: session.resumed goes out, the replay entry parks.
ws_a = FakeWebSocket({"duplex": "1", "resume": "1"})
replaying = asyncio.Event()
sends = 0
original_send = ws_a.send_json
async def send_parking_on_replay(payload: dict[str, Any]) -> None:
nonlocal sends
sends += 1
if sends == 2:
replaying.set()
await asyncio.Event().wait()
await original_send(payload)
ws_a.send_json = send_parking_on_replay # type: ignore[method-assign]
task_a = asyncio.create_task(handler.handle_realtime_session(ws_a))
ws_a.feed(
{
"type": "session.resume",
"session_id": handle.session_id,
"resume_token": token,
"last_received_server_event_seq": 0,
}
)
await asyncio.wait_for(replaying.wait(), timeout=2.0)
resumed_a = await ws_a.wait_for("session.resumed")
assert handle.lease_generation == 1
# B: A's rotated token, engine resume lands generation 2, cancelled mid-RPC.
omni.resume_gate = asyncio.Event()
ws_b, task_b = await _resume(handler, handle.session_id, resumed_a["resume_token"])
await asyncio.wait_for(omni.resume_started.wait(), timeout=2.0)
assert handle.lease_generation == 2
task_b.cancel()
with suppress(asyncio.CancelledError):
await task_b
omni.resume_gate.set()
await asyncio.gather(*omni.compensations)
assert omni.detached == [handle.session_id], "generation 2 went to A's pending activation"
# A is cancelled during replay: its rollback settles generation 2, the lease A owned.
task_a.cancel()
with suppress(asyncio.CancelledError):
await task_a
assert not await registry.has_attachment(handle.session_id)
assert omni.detached == [handle.session_id, handle.session_id]
assert omni.detach_refused == [], "generation 1 was never detached: the engine would refuse it"
@@ -552,3 +552,90 @@ async def test_registry_resume_cancelled_mid_delivery_rolls_back_like_a_failure(
close=close,
)
assert recovered.attachment_generation == 3
@pytest.mark.asyncio
async def test_release_hands_back_the_lease_generation_the_dropped_connection_was_serving(mocker) -> None:
"""The lease generation travels with the attachment, so a detach is fenced on the right lease."""
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
send = mocker.AsyncMock()
created = await registry.create("sid-lease", send=send, close=mocker.AsyncMock(), lease_generation=0)
# An abandoned takeover hands its generation to the connection still attached.
assert await registry.settle_lease_generation("sid-lease", 1) is None
assert await registry.settle_lease_generation("sid-lease", 0) is None, "generations only move forward"
released = await registry.release_attachment("sid-lease", attachment_generation=created.attachment_generation)
assert released is not None
assert (released.attachment_generation, released.lease_generation) == (1, 1)
assert await registry.release_attachment("sid-lease", attachment_generation=None) is None
assert await registry.settle_lease_generation("sid-lease", 2) == 2, "nobody is attached: the caller detaches it"
resumed = await registry.resume(
"sid-lease",
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
close=mocker.AsyncMock(),
lease_generation=3,
)
released = await registry.release_attachment("sid-lease", attachment_generation=resumed.attachment_generation)
assert released is not None and released.lease_generation == 3
@pytest.mark.asyncio
async def test_an_orphaned_generation_waits_for_the_resume_that_is_about_to_activate(mocker) -> None:
"""While a resume is pending, an orphan goes to its activation, not to the socket it will replace."""
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
send = mocker.AsyncMock()
created = await registry.create("sid-pending", send=send, close=mocker.AsyncMock(), lease_generation=0)
await registry.begin_resume("sid-pending")
assert await registry.settle_lease_generation("sid-pending", 2) is None, "parked for the pending activation"
released = await registry.release_attachment("sid-pending", attachment_generation=created.attachment_generation)
assert released is not None and released.lease_generation == 0, "the replaced socket keeps its own lease"
resumed = await registry.resume(
"sid-pending",
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=send,
close=mocker.AsyncMock(),
lease_generation=1,
)
assert await registry.end_resume("sid-pending") is None, "the activation absorbed the orphan"
released = await registry.release_attachment("sid-pending", attachment_generation=resumed.attachment_generation)
assert released is not None and released.lease_generation == 2
# A pending resume that never activates leaves the orphan to whoever ends the claim.
await registry.begin_resume("sid-pending")
assert await registry.settle_lease_generation("sid-pending", 3) is None
assert await registry.end_resume("sid-pending") == 3
@pytest.mark.asyncio
async def test_a_rolled_back_activation_orphans_the_lease_it_was_handed(mocker) -> None:
"""A provisional attachment may own a newer generation than its own resume produced."""
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
created = await registry.create("sid-rollback", send=mocker.AsyncMock(), close=mocker.AsyncMock())
await registry.detach("sid-rollback", attachment_generation=created.attachment_generation)
async def failing_activation(payload):
raise RuntimeError("socket closed during replay")
await registry.begin_resume("sid-rollback")
with pytest.raises(RuntimeError):
await registry.resume(
"sid-rollback",
resume_token=created.resume_token.plaintext,
last_received_server_event_seq=0,
send=failing_activation,
close=mocker.AsyncMock(),
activation_payload_factory=lambda token, generation: {"type": "session.resumed"},
lease_generation=1,
)
# Meanwhile a resume abandoned mid-RPC handed generation 2 to the provisional attachment.
# (Order does not matter: both land in the orphan slot while the claim is open.)
assert await registry.settle_lease_generation("sid-rollback", 2) is None
assert await registry.settle_lease_generation("sid-rollback", 1) is None
assert await registry.end_resume("sid-rollback") == 2, "the newest lease is the one to detach"
@@ -821,6 +821,46 @@ def test_speech_without_handler_preserves_not_found_http_error() -> None:
assert exc_info.value.detail == "The model does not support Speech API"
@pytest.mark.parametrize(
("field", "value", "detail"),
[
("modalities", [123], "modalities must be a list of strings"),
("logprobs", "yes", "logprobs must be a boolean"),
],
)
def test_chat_completion_raw_body_guards_reject_lax_types(field, value, detail) -> None:
"""The HTTP boundary rejects values that upstream Pydantic would coerce."""
app = FastAPI()
app.state.openai_serving_chat = None
app.state.serving_tokenization = None
app.add_api_route("/v1/chat/completions", api_server.create_chat_completion, methods=["POST"])
client = TestClient(app)
payload = {
"model": "demo-model",
"messages": [{"role": "user", "content": "hello"}],
"stream": False,
field: value,
}
response = client.post("/v1/chat/completions", json=payload)
assert response.status_code == 400
assert detail in response.json()["detail"]
@pytest.mark.parametrize(
"raw_body",
[
{"modalities": None},
{"logprobs": None},
{"modalities": None, "logprobs": None},
],
)
def test_chat_completion_raw_body_guards_allow_null_defaults(raw_body) -> None:
"""Explicit JSON null keeps the upstream request model's default behavior."""
api_server._validate_chat_completion_raw_body(raw_body)
@pytest.mark.asyncio
async def test_multi_api_rejects_runtime_voice_upload() -> None:
app = FastAPI()
+57
View File
@@ -7,6 +7,7 @@ from types import SimpleNamespace
import anyio
import pytest
from vllm.engine.protocol import StreamingInput
from vllm.lora.request import LoRARequest
from vllm.sampling_params import RequestOutputKind, SamplingParams
@@ -67,6 +68,7 @@ def get_async_omni_instance(fake_add_request=_noop, fake_abort_request=_noop) ->
omni._paused = False
omni.engine = SimpleNamespace(
num_stages=1,
stage_configs=[],
add_request_async=fake_add_request,
abort_async=fake_abort_request,
)
@@ -473,6 +475,61 @@ def test_generate_accepts_request_after_repeated_cancellations():
asyncio.run(run_test())
@pytest.mark.cpu
@pytest.mark.parametrize("has_chunk", [False, True])
@pytest.mark.parametrize("input_fails", [False, True])
def test_streaming_input_terminal_submit_failure_reaches_generate(mocker, has_chunk, input_fails):
async def run_test():
submissions: list[tuple[str, bool]] = []
async def add_request_async(*, resumable, **kwargs):
del kwargs
submissions.append(("add", resumable))
if not resumable:
raise RuntimeError("terminal streaming submit failed")
async def add_streaming_update_async(*, resumable, **kwargs):
del kwargs
submissions.append(("update", resumable))
assert not resumable
raise RuntimeError("terminal streaming submit failed")
omni = get_async_omni_instance(fake_add_request=add_request_async)
omni.engine.add_streaming_update_async = add_streaming_update_async
omni.engine.stage_clients = []
omni.engine.model_config = object()
del omni._process_orchestrator_results
mocker.patch(
"vllm_omni.entrypoints.async_omni.extract_prompt_components",
return_value=(None, None, None),
)
error_metadata = mocker.spy(async_omni_mod, "client_error_metadata")
async def input_stream():
if has_chunk:
yield StreamingInput(prompt={"prompt": "chunk"})
if input_fails:
raise RuntimeError("streaming input failed")
async def consume():
async for _ in omni.generate(
prompt=input_stream(),
request_id="stream-submit-error",
sampling_params_list=[SamplingParams(output_kind=RequestOutputKind.DELTA)],
output_modalities=["text"],
):
pass
expected_error = "streaming input failed" if input_fails else "terminal streaming submit failed"
with pytest.raises(RuntimeError, match=expected_error):
await asyncio.wait_for(consume(), timeout=1.0)
assert error_metadata.call_count == 1
assert submissions == ([("add", True), ("update", False)] if has_chunk else [("add", False)])
assert omni.request_states == {}
asyncio.run(run_test())
@pytest.mark.cpu
def test_generate_cancel_bounds_cleanup_abort(monkeypatch):
"""Cancelled generate() must not wait forever on a wedged orchestrator abort."""
+175 -3
View File
@@ -43,7 +43,20 @@ class FakeDuplexEngine(FakeAsyncOmniEngine):
self.opened: list[tuple[str, DuplexSessionConfig]] = []
self.closed: list[tuple[str, str]] = []
self.resumed: list[tuple[str, int]] = []
#: The control id of every resume RPC, in order (a replay repeats one).
self.resume_control_ids: list[str | None] = []
#: Per session: the control id that produced the current lease generation, and that generation.
self.lease: dict[str, tuple[str | None, int]] = {}
#: How many resume RPCs, after applying the resume, answer with a
#: timeout: the engine landed it but the waiter never saw the result.
self.resume_timeouts_remaining = 0
self.touched: list[tuple[str, str]] = []
#: The lease-generation fence of every detach, in order (``None`` = unfenced).
self.detach_generations: list[int | None] = []
#: When set, ``resume_session_async`` parks on it after signalling ``resume_started``:
#: the engine has applied the resume, its result has not reached the caller.
self.resume_gate: asyncio.Event | None = None
self.resume_started = asyncio.Event()
self.commands: list[tuple[str, commands.DuplexCommand]] = []
self.open_error: DuplexSessionError | None = None
self.close_error: DuplexSessionError | None = None
@@ -71,12 +84,33 @@ class FakeDuplexEngine(FakeAsyncOmniEngine):
self.emit(session_id, SessionClosed(reason=reason))
return self._result("close", session_id)
async def resume_session_async(self, session_id, *, expected_lease_generation, timeout=None):
async def resume_session_async(self, session_id, *, expected_lease_generation, control_id=None, timeout=None):
self.resumed.append((session_id, expected_lease_generation))
return self._result("resume", session_id, lease_generation=expected_lease_generation + 1)
self.resume_control_ids.append(control_id)
resumed_by, generation = self.lease.get(session_id, (None, 0))
if control_id is not None and control_id == resumed_by:
# The engine's replay answer: the generation this resume produced.
# Its answer can be lost to a timeout exactly like the first one.
if self.resume_timeouts_remaining > 0:
self.resume_timeouts_remaining -= 1
raise DuplexSessionError("duplex resume timed out", code="timeout", retryable=True)
return self._result("resume", session_id, lease_generation=generation)
if expected_lease_generation != generation:
raise DuplexSessionError("duplex lease generation mismatch", code="session_resume_conflict")
generation += 1
self.lease[session_id] = (control_id, generation)
if self.resume_gate is not None:
self.resume_started.set()
await self.resume_gate.wait()
if self.resume_timeouts_remaining > 0:
self.resume_timeouts_remaining -= 1
raise DuplexSessionError("duplex resume timed out", code="timeout", retryable=True)
return self._result("resume", session_id, lease_generation=generation)
async def touch_session_async(self, session_id, *, activity, timeout=None):
async def touch_session_async(self, session_id, *, activity, expected_lease_generation=None, timeout=None):
self.touched.append((session_id, activity))
if activity == "detach":
self.detach_generations.append(expected_lease_generation)
return self._result("touch", session_id)
async def submit_command_async(self, session_id, command):
@@ -324,3 +358,141 @@ async def test_shutdown_closes_every_handle(monkeypatch) -> None:
assert first.close_reason == "shutdown"
assert omni.sessions == {}
assert engine.shutdown_called
@pytest.mark.asyncio
async def test_detach_session_forwards_the_lease_generation_fence(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
await omni.detach_session(handle.session_id, expected_lease_generation=3)
await omni.detach_session(handle.session_id)
assert engine.detach_generations == [3, None]
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_a_resume_cancelled_mid_rpc_is_adopted_and_detached_once_it_lands(monkeypatch) -> None:
"""The resume RPC runs in an executor: cancelling its waiter does not stop the engine.
Without compensation the engine ends up with the lease resumed (no
disconnect grace) for a connection that is gone, and the handle keeps the
old generation, so a later reconnect resumes against a stale lease. The
outcome has to be observed off the cancelled task: adopt the generation
the resume produced and detach exactly that lease.
"""
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
engine.resume_gate = asyncio.Event()
resume = asyncio.create_task(omni.resume_session(handle.session_id, expected_lease_generation=0))
await asyncio.wait_for(engine.resume_started.wait(), timeout=2.0)
resume.cancel()
with pytest.raises(asyncio.CancelledError):
await resume
# Nothing to give back yet: the engine has not answered.
assert handle.lease_generation == 0
assert engine.detach_generations == []
engine.resume_gate.set()
await asyncio.gather(*list(omni._resume_compensations))
assert handle.lease_generation == 1, "the generation the resume produced is adopted"
assert engine.touched == [(handle.session_id, "detach")]
assert engine.detach_generations == [1], "the detach is fenced on that generation"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_a_resume_cancelled_then_timed_out_is_settled_by_replaying_it(monkeypatch) -> None:
"""A timeout after the cancel leaves the outcome unknown: the resume is replayed under its id.
The engine may have applied the resume after the waiter gave up, and the
RPC router drops the late answer. Replaying the same control id makes the
engine report the generation that resume produced (or land it now), so
the handle adopts the real generation and the lease is detached again.
"""
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
engine.resume_gate = asyncio.Event()
engine.resume_timeouts_remaining = 1
resume = asyncio.create_task(omni.resume_session(handle.session_id, expected_lease_generation=0))
await asyncio.wait_for(engine.resume_started.wait(), timeout=2.0)
resume.cancel()
with pytest.raises(asyncio.CancelledError):
await resume
engine.resume_gate.set()
await asyncio.gather(*list(omni._resume_compensations))
assert len(engine.resume_control_ids) == 2, "the resume was replayed once"
assert engine.resume_control_ids[0] == engine.resume_control_ids[1] is not None
assert engine.lease[handle.session_id][1] == 1, "the replay did not resume a second time"
assert handle.lease_generation == 1
assert engine.detach_generations == [1]
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_an_abandoned_resume_is_settled_through_the_callers_callback(monkeypatch) -> None:
"""A caller that owns attachments decides what the landed generation means; DuplexOmni does not detach."""
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
settled: list[int] = []
async def on_abandoned(lease_generation: int) -> None:
settled.append(lease_generation)
engine.resume_gate = asyncio.Event()
resume = asyncio.create_task(
omni.resume_session(handle.session_id, expected_lease_generation=0, on_abandoned=on_abandoned)
)
await asyncio.wait_for(engine.resume_started.wait(), timeout=2.0)
resume.cancel()
with pytest.raises(asyncio.CancelledError):
await resume
engine.resume_gate.set()
await asyncio.gather(*list(omni._resume_compensations))
assert settled == [1]
assert handle.lease_generation == 1
assert engine.detach_generations == [], "the callback owns the decision"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_an_abandoned_resume_keeps_replaying_across_repeated_timeouts(monkeypatch) -> None:
"""A replay can time out too; giving up then would reopen the lost-answer window.
The engine applied the resume, the abandoned RPC and the first replay both
lose their answers to timeouts, the second replay is answered: the handle
adopts the generation and the lease is settled, with the lease bumped once.
"""
omni, engine = _make_omni(monkeypatch)
try:
omni._resume_replay_backoff_s = (0.01, 0.02)
handle = await omni.open_session()
engine.resume_gate = asyncio.Event()
engine.resume_timeouts_remaining = 2
resume = asyncio.create_task(omni.resume_session(handle.session_id, expected_lease_generation=0))
await asyncio.wait_for(engine.resume_started.wait(), timeout=2.0)
resume.cancel()
with pytest.raises(asyncio.CancelledError):
await resume
engine.resume_gate.set()
await asyncio.wait_for(asyncio.gather(*list(omni._resume_compensations)), timeout=5.0)
assert len(engine.resume_control_ids) == 3, "the abandoned RPC, a replay that timed out, the replay answered"
assert len(set(engine.resume_control_ids)) == 1
assert engine.lease[handle.session_id][1] == 1, "the lease was bumped once"
assert handle.lease_generation == 1
assert engine.detach_generations == [1]
finally:
omni.shutdown()
+25 -1
View File
@@ -1,7 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Opt-in cleanup fixtures for GPU memory, pipeline registry, and speaker cache."""
"""Cleanup fixtures for process-global test state.
Torch dtype/device restoration is autouse. GPU memory, the pipeline registry,
and the speaker cache stay opt-in.
"""
from __future__ import annotations
@@ -10,6 +14,26 @@ import pytest
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES
@pytest.fixture(autouse=True)
def restore_torch_default_dtype_and_device():
"""Restore process-global dtype and device after every test.
``get_default_device()`` reports ``cpu`` both when no default device was
set and when ``cpu`` was set explicitly. Writing that value back enables
default-device dispatch, so snapshot the active context instead.
"""
import torch
default_dtype = torch.get_default_dtype()
device_context = getattr(torch._GLOBAL_DEVICE_CONTEXT, "device_context", None)
default_device = None if device_context is None else torch.get_default_device()
try:
yield
finally:
torch.set_default_dtype(default_dtype)
torch.set_default_device(default_device)
@pytest.fixture
def clean_gpu_memory_between_tests():
"""Opt-in GPU pre/post hooks for a test (no environment-variable gate).
+14 -10
View File
@@ -44,14 +44,16 @@ def build_tiny_from_configs(
config or transform) that don't have vendored configs.
configs_dir: Path to a directory containing vendored config files
(model_index.json and per-component config.json).
transform: Maps a component (subfolder) name to a function that
takes that component's raw config dict (fetched from model_id)
and returns a shrunk one, applied only when that component has
no vendored config in configs_dir. Lets each pipeline encode
exactly the shrink it needs (layer counts, and any component-
internal width that isn't read by another component), including
keeping sibling config fields in sync (e.g. layer_types matching
num_hidden_layers).
transform: Maps a component (subfolder) name or None to a function that
transforms a config dict. For subfolder keys, the function takes
that component's raw config dict (fetched from model_id) and returns
a shrunk one, applied only when that component has no vendored config
in configs_dir. For None key, the function transforms the pipeline's
model_index.json config dict. Lets each pipeline encode exactly the
transforms it needs (layer counts, config-level fields, and any
component-internal width that isn't read by another component),
including keeping sibling config fields in sync (e.g. layer_types
matching num_hidden_layers).
Returns:
Path to the saved tiny model directory with safetensors weights.
@@ -72,7 +74,7 @@ def build_tiny_from_configs(
subfolder = kwargs.get("subfolder")
load_kwargs = {"subfolder": subfolder} if subfolder else {}
config = cls.load_config(pretrained_model_name_or_path, **load_kwargs)
if subfolder in transform:
if subfolder is not None and subfolder in transform:
config = transform[subfolder](dict(config))
return cls.from_config(config)
@@ -86,7 +88,7 @@ def build_tiny_from_configs(
subfolder = kwargs.get("subfolder")
load_kwargs = {"subfolder": subfolder} if subfolder else {}
config_dict, _ = PretrainedConfig.get_config_dict(pretrained_model_name_or_path, **load_kwargs)
if subfolder in transform:
if subfolder is not None and subfolder in transform:
config_dict = transform[subfolder](config_dict)
config = cls.config_class.from_dict(config_dict)
return cls(config)
@@ -96,6 +98,8 @@ def build_tiny_from_configs(
# can vendor just one component without also needing its own model_index.json.
vendored_index = config_dir is not None and (config_dir / "model_index.json").exists()
config_dict = DiffusionPipeline.load_config(config_dir if vendored_index else model_id)
if None in transform:
config_dict = transform[None](dict(config_dict))
pipeline_cls = _get_pipeline_class(DiffusionPipeline, config=config_dict)
init_dict, _, _ = pipeline_cls.extract_init_dict(config_dict)
@@ -0,0 +1,145 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The AR stage must register attention layers for its own language model only.
The Qwen-VL parent ``__init__`` builds a stock language model before MammothModa2
replaces it. Its attention layers stay in ``static_forward_context`` unless they are
removed, and the KV cache is then sized and allocated for both models. The parent
``__init__`` and the replacement are stubbed here so only the registry is exercised.
"""
import gc
import weakref
from types import SimpleNamespace
import pytest
import torch
from torch import nn
from vllm.model_executor.models.utils import StageMissingLayer
import vllm_omni.model_executor.models.mammoth_moda2.mammoth_moda2 as mm2
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
NUM_LAYERS = 3
VISION_LAYER = "ar.visual.blocks.0.attn"
AR_CLASSES = [
(mm2.MammothModa2ARForConditionalGeneration, mm2.Qwen2_5_VLForConditionalGeneration),
(mm2.MammothModa2Qwen3ARForConditionalGeneration, mm2.Qwen3VLForConditionalGeneration),
]
AR_IDS = ["preview_qwen2_5_vl", "dev_qwen3_vl"]
class _FakeAttention(nn.Module):
def __init__(self, registry: dict, name: str):
super().__init__()
registry[name] = self
class _FakeLM(nn.Module):
def __init__(self, registry: dict, prefix: str):
super().__init__()
self.layers = nn.ModuleList(
_FakeAttention(registry, f"{prefix}.layers.{i}.self_attn.attn") for i in range(NUM_LAYERS)
)
self.make_empty_intermediate_tensors = lambda *args, **kwargs: None
class _FakeCompiledLM(_FakeLM):
cleanups = 0
def cleanup(self):
type(self).cleanups += 1
def _build(
monkeypatch,
ar_class,
parent,
*,
encoder_only=False,
parent_lm=_FakeLM,
parent_registers=True,
pp_rank=(1, True),
stray_parent_layer=False,
):
registry: dict = {}
built = {}
def parent_init(self, *, vllm_config, prefix=""):
nn.Module.__init__(self)
self.visual = _FakeAttention(registry, VISION_LAYER)
stock = nn.Module()
stock.model = parent_lm(registry if parent_registers else {}, f"{prefix}.language_model.model")
built["parent_lm"] = weakref.ref(stock)
if stray_parent_layer:
# A parent-prefixed layer that is not part of the module being dropped.
registry[f"{prefix}.language_model.model.layers.9.self_attn.attn"] = nn.Module()
self.language_model = StageMissingLayer("language_model", stock) if encoder_only else stock
# The model module only imports get_current_vllm_config with this fix; keep the
# reverted source testable so the registry assertion is what fails there.
monkeypatch.setattr(
mm2,
"get_current_vllm_config",
lambda: SimpleNamespace(compilation_config=SimpleNamespace(static_forward_context=registry)),
raising=False,
)
monkeypatch.setattr(parent, "__init__", parent_init)
monkeypatch.setattr(mm2, "init_vllm_registered_model", lambda *, prefix, **kwargs: _FakeLM(registry, prefix))
monkeypatch.setattr(mm2, "MammothModa2Qwen3ForCausalLM", lambda *, vllm_config, prefix: _FakeLM(registry, prefix))
monkeypatch.setattr(mm2, "TorchCompileWithNoGuardsWrapper", _FakeCompiledLM, raising=False)
monkeypatch.setattr(torch.accelerator, "empty_cache", lambda: None)
world_size, is_first_rank = pp_rank
monkeypatch.setattr(
mm2, "get_pp_group", lambda: SimpleNamespace(world_size=world_size, is_first_rank=is_first_rank)
)
hf_config = SimpleNamespace(llm_config=SimpleNamespace(text_config=SimpleNamespace()))
vllm_config = SimpleNamespace(
model_config=SimpleNamespace(hf_config=hf_config, architectures=["MammothModa2Model"])
)
vllm_config.with_hf_config = lambda hf, architectures=None: vllm_config
model = ar_class(vllm_config=vllm_config, prefix="ar")
return model, registry, built
@pytest.mark.parametrize("encoder_only", [False, True], ids=["default", "mm_encoder_only"])
@pytest.mark.parametrize(("ar_class", "parent"), AR_CLASSES, ids=AR_IDS)
def test_ar_stage_registers_only_the_replacement_language_model(monkeypatch, ar_class, parent, encoder_only):
model, registry, built = _build(monkeypatch, ar_class, parent, encoder_only=encoder_only)
assert set(registry) == {f"ar.language_model.layers.{i}.self_attn.attn" for i in range(NUM_LAYERS)} | {VISION_LAYER}
gc.collect()
assert built["parent_lm"]() is None
@pytest.mark.parametrize(("ar_class", "parent"), AR_CLASSES, ids=AR_IDS)
def test_compiled_parent_language_model_releases_its_hook(monkeypatch, ar_class, parent):
_FakeCompiledLM.cleanups = 0
_build(monkeypatch, ar_class, parent, parent_lm=_FakeCompiledLM)
assert _FakeCompiledLM.cleanups == 1
@pytest.mark.parametrize(("ar_class", "parent"), AR_CLASSES, ids=AR_IDS)
def test_unexpected_parent_registry_fails_loudly(monkeypatch, ar_class, parent):
with pytest.raises(RuntimeError, match="Unexpected attention registry"):
_build(monkeypatch, ar_class, parent, parent_registers=False)
@pytest.mark.parametrize(("ar_class", "parent"), AR_CLASSES, ids=AR_IDS)
def test_later_pipeline_rank_without_parent_layers_starts(monkeypatch, ar_class, parent):
_, registry, _ = _build(monkeypatch, ar_class, parent, parent_registers=False, pp_rank=(2, False))
assert set(registry) == {f"ar.language_model.layers.{i}.self_attn.attn" for i in range(NUM_LAYERS)} | {VISION_LAYER}
@pytest.mark.parametrize(("ar_class", "parent"), AR_CLASSES, ids=AR_IDS)
def test_first_pipeline_rank_without_parent_layers_fails(monkeypatch, ar_class, parent):
with pytest.raises(RuntimeError, match="Unexpected attention registry"):
_build(monkeypatch, ar_class, parent, parent_registers=False, pp_rank=(2, True))
@pytest.mark.parametrize(("ar_class", "parent"), AR_CLASSES, ids=AR_IDS)
def test_later_pipeline_rank_still_rejects_a_leftover_parent_layer(monkeypatch, ar_class, parent):
with pytest.raises(RuntimeError, match="Unexpected attention registry"):
_build(monkeypatch, ar_class, parent, parent_registers=False, pp_rank=(2, False), stray_parent_layer=True)
@@ -5,20 +5,27 @@ from types import SimpleNamespace
import pytest
import torch
from vllm.multimodal.processing import ProcessorInputs
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.models.ming_image.pipeline import (
MingImageDiffusionPipeline,
_validate_variant_config,
)
from vllm_omni.diffusion.models.z_image.pipeline_z_image import ZImagePipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_executor.models.ming_flash_omni.ming_flash_omni_thinker import (
MingFlashOmniThinkerForConditionalGeneration,
MingFlashOmniThinkerMultiModalProcessor,
)
from vllm_omni.model_executor.models.ming_image import checkpoint
from vllm_omni.model_executor.models.ming_image.model import MingImageForConditionalGeneration
from vllm_omni.model_executor.models.ming_image.model import (
MingImageForConditionalGeneration,
MingImageMultiModalProcessor,
)
from vllm_omni.model_executor.models.ming_image.pipeline import MING_IMAGE_PIPELINE
from vllm_omni.model_executor.stage_input_processors.ming_image import thinker2image
from vllm_omni.model_extras import (
@@ -26,6 +33,7 @@ from vllm_omni.model_extras import (
should_init_extra_args_for_non_diffusion_stages,
)
from vllm_omni.transformers_utils.configs.ming_flash_omni import BailingMM2Config
from vllm_omni.transformers_utils.processors.ming import MingImageProcessor
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -119,6 +127,21 @@ def test_pipeline_rejects_multiple_outputs_per_prompt():
pipeline.forward(request)
@pytest.mark.parametrize(
("guidance_scale", "ming_expected", "z_image_expected"),
((0.0, False, False), (1.0, False, True), (1.0001, True, True), (2.0, True, True)),
)
def test_ming_cfg_is_only_enabled_above_one(guidance_scale, ming_expected, z_image_expected):
# Ming-Image follows the vendor threshold; the shared Z-Image base keeps diffusers semantics.
ming_pipeline = MingImageDiffusionPipeline.__new__(MingImageDiffusionPipeline)
ming_pipeline._guidance_scale = guidance_scale
z_image_pipeline = ZImagePipeline.__new__(ZImagePipeline)
z_image_pipeline._guidance_scale = guidance_scale
assert ming_pipeline.do_classifier_free_guidance is ming_expected
assert z_image_pipeline.do_classifier_free_guidance is z_image_expected
def _source_output(prefix_len: int = 4):
prompt_ids = [11] * prefix_len + [157158] + [157157] * 256 + [157159]
length = len(prompt_ids)
@@ -157,6 +180,78 @@ def test_bridge_rejects_text_negative_condition():
)
def test_image_generation_template_matches_upstream_layout():
processor = MingImageProcessor.__new__(MingImageProcessor)
processor.tokenizer = SimpleNamespace(eos_token="<eos>")
text = processor._apply_image_generation_template("draw a cabin")
assert text == (
"<role>SYSTEM</role>你是一个友好的AI助手。\n\ndetailed thinking off"
"<eos><role>HUMAN</role>draw a cabin"
"<eos><role>ASSISTANT</role>"
)
assert processor._apply_image_generation_template(text) == text
assert "<role>HUMAN</role><IMAGE>edit this image<eos>" in processor._apply_image_generation_template(
"edit this image",
has_reference_image=True,
)
def test_multimodal_processor_applies_generation_template(monkeypatch: pytest.MonkeyPatch):
template_calls = []
def _format(prompt, *, has_reference_image):
template_calls.append((prompt, has_reference_image))
return "formatted prompt"
tokenizer = SimpleNamespace(
decode=lambda token_ids, **kwargs: "edit this image",
encode=lambda text, **kwargs: [101, 102],
)
processor = MingImageMultiModalProcessor.__new__(MingImageMultiModalProcessor)
processor.info = SimpleNamespace(
get_tokenizer=lambda: tokenizer,
get_hf_processor=lambda: SimpleNamespace(_apply_image_generation_template=_format),
)
captured = {}
def _parent_apply(_self, inputs, timing_ctx):
captured["inputs"] = inputs
return "processed"
monkeypatch.setattr(MingFlashOmniThinkerMultiModalProcessor, "apply", _parent_apply)
inputs = ProcessorInputs(
prompt=[11, 12],
mm_data_items=None,
hf_processor_mm_kwargs={"modalities": ["img2img"]},
)
assert processor.apply(inputs, None) == "processed"
assert template_calls == [("edit this image", True)]
assert captured["inputs"].prompt == [101, 102]
assert captured["inputs"].hf_processor_mm_kwargs["modalities"] == ["image"]
@pytest.mark.parametrize(
("pipeline_config", "expected_class_name"),
((MING_IMAGE_PIPELINE, "MingImageDiffusionPipeline"), (None, None)),
)
def test_engine_od_config_falls_back_to_registered_pipeline_class(monkeypatch, pipeline_config, expected_class_name):
# Ming-Image ships neither a root config.json nor model_index.json for now,
# so class-name resolution returns None; serving must still find the extras (e.g. num_layers).
monkeypatch.setattr("vllm_omni.diffusion.data.resolve_model_class_name", lambda model, **kwargs: None)
engine = object.__new__(AsyncOmniEngine)
engine.model = "inclusionAI/Ming-Image-0.1-Design-Layer"
engine.pipeline_config = pipeline_config
engine._diffusion_od_config_view = None
od_config = engine.get_diffusion_od_config()
assert od_config.model_class_name == expected_class_name
assert ("num_layers" in get_extra_body_params(od_config.model_class_name)) is (expected_class_name is not None)
assert engine.get_diffusion_od_config() is od_config
def test_two_stage_topology_and_request_metadata():
assert MING_IMAGE_PIPELINE.model_type == "ming_image"
assert [stage.model_stage for stage in MING_IMAGE_PIPELINE.stages] == ["mllm", "dit"]
@@ -168,6 +263,8 @@ def test_two_stage_topology_and_request_metadata():
assert MING_IMAGE_PIPELINE.hf_architectures == ("MingImageForConditionalGeneration",)
for class_name in MING_IMAGE_PIPELINE.diffusers_class_aliases + (MING_IMAGE_PIPELINE.diffusers_class_name,):
assert "num_layers" in get_extra_body_params(class_name)
assert "steps" not in get_extra_body_params(class_name)
assert "cfg" not in get_extra_body_params(class_name)
assert should_init_extra_args_for_non_diffusion_stages(class_name)
@@ -0,0 +1,61 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The MiniCPM-o 4.5 Stage-0 duplex runtime is built with the model, not in the first session."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.model_executor.models.minicpmo_4_5 import minicpmo_4_5_omni
from vllm_omni.model_executor.models.minicpmo_4_5.duplex import compat
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import (
MiniCPMO45OmniForConditionalGeneration,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _StageModel(torch.nn.Module):
def make_empty_intermediate_tensors(self):
return None
@pytest.mark.parametrize(
("model_stage", "session_mode", "expected_builds"),
[
("llm", "duplex", 1),
("llm", "turn", 0),
("tts", "duplex", 0),
],
)
def test_init_builds_duplex_runtime_only_for_duplex_thinker(
monkeypatch: pytest.MonkeyPatch,
model_stage: str,
session_mode: str,
expected_builds: int,
) -> None:
monkeypatch.setattr(minicpmo_4_5_omni, "init_vllm_registered_model", lambda **kwargs: _StageModel())
monkeypatch.setattr(compat, "patch_minicpmo_remote_config", lambda config: None)
build_devices: list[torch.device] = []
monkeypatch.setattr(
MiniCPMO45OmniForConditionalGeneration,
"_duplex_data_plane_helper",
lambda self: build_devices.append(torch.get_default_device()),
)
vllm_config = SimpleNamespace(
model_config=SimpleNamespace(
hf_config=SimpleNamespace(),
multimodal_config=None,
model_stage=model_stage,
session_mode=session_mode,
)
)
# The loader constructs the model under the target-device context.
with torch.device("meta"):
MiniCPMO45OmniForConditionalGeneration(vllm_config=vllm_config)
assert build_devices == [torch.device("cpu")] * expected_builds
@@ -4,12 +4,14 @@
from __future__ import annotations
from contextlib import nullcontext
from dataclasses import dataclass
from typing import Any
import pytest
import torch
import torch.nn as nn
from torch.overrides import TorchFunctionMode
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import (
MiniCPMO45OmniForConditionalGeneration,
@@ -684,7 +686,8 @@ def test_make_omni_output_packs_the_previous_codec_id() -> None:
@pytest.mark.parametrize("device", ["cpu", "cuda"])
def test_make_omni_output_reads_history_from_runner_cpu_buffer(monkeypatch, device) -> None:
@pytest.mark.parametrize("default_device", [None, "cpu"])
def test_make_omni_output_reads_history_from_runner_cpu_buffer(device, default_device) -> None:
if device == "cuda" and not torch.cuda.is_available():
pytest.skip("requires CUDA codec output")
from types import SimpleNamespace
@@ -719,16 +722,20 @@ def test_make_omni_output_reads_history_from_runner_cpu_buffer(monkeypatch, devi
assert source.device.type == "cpu"
torch.testing.assert_close(source, torch.tensor([[3]], dtype=torch.long))
original_tolist = torch.Tensor.tolist
readback_devices = []
def cpu_only_tolist(tensor):
readback_devices.append(tensor.device.type)
assert tensor.device.type == "cpu", "codec history must use the CPU transport delta"
return original_tolist(tensor)
class CpuOnlyToList(TorchFunctionMode):
def __torch_function__(self, func, types, args=(), kwargs=None):
if func is torch.Tensor.tolist:
tensor = args[0]
readback_devices.append(tensor.device.type)
assert tensor.device.type == "cpu", "codec history must use the CPU transport delta"
return func(*args, **(kwargs or {}))
with monkeypatch.context() as patch:
patch.setattr(torch.Tensor, "tolist", cpu_only_tolist)
# A Tensor.tolist monkeypatch is re-entered by default-device dispatch.
# Observe dispatch directly so each CPU readback is counted once.
device_context = torch.device(default_device) if default_device is not None else nullcontext()
with device_context, CpuOnlyToList():
output = model.make_omni_output(
torch.ones(1, 2, device=device),
model_intermediate_buffer=infos,
@@ -1,20 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Nemotron VoiceChat duplex is not on the duplex plugin framework yet.
RFC vllm-omni#7181 splits the port: this PR lands the unified framework with
MiniCPM-o 4.5, and the follow-up PR moves Nemotron VoiceChat onto
``DuplexModelPlugin``. Until then the modules under test still import the
pre-framework duplex runtime / serving adapter that this PR removed, so
collecting them would fail at import time and abort the whole pytest session.
The tests themselves are unchanged and come back with the port.
Listed per file rather than globbed, so the coverage loss is visible and shrinks
file by file as the port lands. ``test_input.py`` imports nothing that was
removed and keeps running.
"""
#: Both import ``DuplexFence`` from ``engine.duplex.messages``, which no longer
#: exports it; ``test_data_plane`` also imports ``engine.duplex.runtime``.
collect_ignore = ["test_data_plane.py", "test_runtime_contract.py"]
@@ -7,9 +7,8 @@ from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.engine.duplex.runtime import duplex_resource_request_id
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.engine.duplex.contracts import DuplexFence, duplex_resource_request_id
from vllm_omni.model_executor.models.nemotron_voicechat.duplex.data_plane import (
NemotronVoiceChatDataPlaneContext,
NemotronVoiceChatDataPlaneSession,
@@ -45,6 +45,15 @@ def test_irregular_browser_packets_emit_exact_ordered_80ms_frame() -> None:
np.testing.assert_array_equal(decoded, np.arange(1280, dtype=np.float32))
assert buffer.pending_byte_count == 0
later = _append(buffer, np.ones(1280), "later")
later.commit()
later.rollback()
reservation.rollback()
assert not buffer.has_reserved()
retry = _append(buffer, [], "retry")
assert retry.payload == reservation.payload
assert not buffer.has_pending()
def test_append_rejects_invalid_frame_contract() -> None:
cases = (
@@ -1,115 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import base64
from types import SimpleNamespace
import numpy as np
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.duplex.contracts import DuplexInputMode
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.model_executor.models.nemotron_voicechat.duplex.runtime import (
NemotronVoiceChatDuplexRuntimeExtension,
)
from vllm_omni.model_executor.models.nemotron_voicechat.duplex.serving_adapter import (
NemotronVoiceChatServingRuntimeAdapter,
_render_tool_response,
)
from vllm_omni.model_executor.models.nemotron_voicechat.nemotron_voicechat_thinker import (
NemotronVoiceChatThinkerForConditionalGeneration,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _frame() -> dict[str, object]:
raw = np.zeros(1280, dtype=np.float32).tobytes()
return {
"type": "audio",
"audio": base64.b64encode(raw).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
def _plan(extension, *, input_seq: int):
return extension.plan_append(
request_id="req",
fence=DuplexFence("sid", incarnation=2, epoch=3),
session_config={},
runtime_config={
"nvc_prompt_token_ids": [0, 42, 1],
"nvc_text_pad_id": 12,
"nvc_max_model_len": 8192,
},
seq=input_seq,
turn_seq=input_seq,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
payload=_frame(),
final=False,
sampling_params=SamplingParams(),
)
def test_first_append_prefills_prompt_then_each_append_consumes_one_frame() -> None:
extension = NemotronVoiceChatDuplexRuntimeExtension()
params = extension.configure_sampling_params(
runtime_config={}, defaults=(SamplingParams(temperature=0.7, max_tokens=4),)
)[0]
assert params.temperature == 0.0 and params.max_tokens == 1 and params.top_p == 1.0 and params.top_k == 0
first = _plan(extension, input_seq=1)
later = _plan(extension, input_seq=2)
assert first.prompt["prompt_token_ids"] == [0, 42, 1, 12]
assert later.prompt["prompt_token_ids"] == [12]
assert first.prompt["model_intermediate_buffer"]["duplex"]["source_input_seq"] == 1
assert later.prompt["model_intermediate_buffer"]["duplex"]["source_input_seq"] == 2
def test_append_rejects_stage0_context_overflow() -> None:
with pytest.raises(ValueError, match="max_model_len"):
_plan(NemotronVoiceChatDuplexRuntimeExtension(), input_seq=8190)
def test_function_output_becomes_versioned_nvidia_channel_tokens() -> None:
encoded: list[str] = []
adapter = NemotronVoiceChatServingRuntimeAdapter(lambda *_: None)
adapter._tokenizer = SimpleNamespace(
encode=lambda text, **_kwargs: encoded.append(text) or [31, 32, 33],
)
first = adapter.runtime_config_for_function_output(
{"type": "function_call_output", "call_id": "call-1", "output": '{"result":20}'},
{},
)
second = adapter.runtime_config_for_function_output(
{"type": "function_call_output", "call_id": "call-2", "output": "plain text"},
first,
)
assert _render_tool_response('{"result":20}') == '<TOOL_RESPONSE>[{"result":20}]</TOOL_RESPONSE>'
assert encoded == [
'<TOOL_RESPONSE>[{"result":20}]</TOOL_RESPONSE>',
'<TOOL_RESPONSE>["plain text"]</TOOL_RESPONSE>',
]
assert first["nvc_function_response_generation"] == 1
assert second["nvc_function_response_generation"] == 2
assert second["nvc_function_response_token_ids"] == [31, 32, 33]
assert second["nvc_function_response_call_id"] == "call-2"
assert second["nvc_function_response_batches"] == [
{"generation": 1, "call_id": "call-1", "token_ids": [31, 32, 33]},
{"generation": 2, "call_id": "call-2", "token_ids": [31, 32, 33]},
]
session = {"function_response_generation": 0, "forced_function_tokens": []}
NemotronVoiceChatThinkerForConditionalGeneration._sync_forced_function_response(session, second)
assert session["function_response_generation"] == 2
assert session["forced_function_token"] == 31
assert session["forced_function_tokens"] == [31, 32, 33, 31, 32, 33]
@@ -25,6 +25,7 @@ class _FakeDecoder(nn.Module):
self.total_upsample = total_upsample
self.decode_calls: list[dict[str, object]] = []
self.batched_decode_calls: list[dict[str, object]] = []
self.batched_decode_codes: list[torch.Tensor] = []
self.decode_codes: list[torch.Tensor] = []
self.cudagraph_calls: list[dict[str, int | torch.device]] = []
@@ -64,6 +65,7 @@ class _FakeDecoder(nn.Module):
left_context_size: int = 25,
max_batch_size: int = 0,
) -> list[torch.Tensor]:
self.batched_decode_codes.append(codes.detach().cpu().clone())
self.batched_decode_calls.append(
{
"chunk_size": chunk_size,
@@ -203,6 +205,26 @@ def test_forward_uses_decoder_audio_contract_without_context():
torch.testing.assert_close(audio, expected)
def test_forward_reads_current_model_intermediate_buffer_for_full_payload():
"""Full-payload sync mode must decode connector codec ids, not placeholders."""
model = _make_model()
placeholder_ids = torch.zeros(12, dtype=torch.long)
payload_codes = torch.arange(12, dtype=torch.long) + 17
model.forward(
input_ids=placeholder_ids,
runtime_additional_information=[],
model_intermediate_buffer=[
{
"codes": {"audio": payload_codes},
"meta": {"left_context_size": 0},
}
],
)
torch.testing.assert_close(model.decoder.batched_decode_codes[-1], payload_codes.reshape(1, _NUM_QUANTIZERS, 6))
@pytest.mark.parametrize("skipped_length", [0, 1], ids=["empty", "malformed"])
def test_stateless_batch_uses_original_request_index_for_left_context(skipped_length):
model = _make_model()
+19 -3
View File
@@ -20,7 +20,12 @@ import itertools
import pytest
from tests.helpers.mark import hardware_marks
from tests.model_tests.diffusion.config_types import DiffusionAccs, DiffusionModelTestOpts, get_required_device_count
from tests.model_tests.diffusion.config_types import (
DiffusionAccs,
DiffusionModelTestOpts,
ModelTypeMarker,
get_required_device_count,
)
from vllm_omni.platforms import current_omni_platform
# These tests are intended to run on L4 GPUs since they test parallelism (i.e., need GPUs),
@@ -32,7 +37,12 @@ from vllm_omni.platforms import current_omni_platform
MAX_CI_DEVICES = 4
def get_test_group_marks(model_name: str, test_group: list[DiffusionAccs] | None, model_marks: list | None) -> list:
def get_test_group_marks(
model_name: str,
test_group: list[DiffusionAccs] | None,
model_marks: list | None,
model_type_marker: ModelTypeMarker,
) -> list:
"""Build the full set of pytest marks for a test group.
For now, single device groups default to core model, and multi device groups
@@ -40,6 +50,7 @@ def get_test_group_marks(model_name: str, test_group: list[DiffusionAccs] | None
the max number of GPUs for the CI to prevent accidentally adding configurations that
would be skipped in the CI."""
marks = list(model_marks) if model_marks is not None else []
marks.append(getattr(pytest.mark, model_type_marker.value))
required_devices = get_required_device_count(test_group)
if required_devices > MAX_CI_DEVICES:
@@ -83,7 +94,12 @@ def get_model_parametrization(model_name: str, test_info: DiffusionModelTestOpts
# The divergence check compares generated outputs, which only the offline test does.
*([] if online else [test_info.check_i2v_t2v_divergence]),
id=f"{model_name}[{'+'.join(test_group)}]" if test_group else model_name,
marks=get_test_group_marks(model_name, test_group, test_info.marks),
marks=get_test_group_marks(
model_name,
test_group,
test_info.marks,
model_type_marker=test_info.model_type_marker,
),
)
for test_group in test_groups
]
@@ -42,6 +42,14 @@ class DiffusionTasks(str, Enum):
# Text to audio, etc should be added here as needed
class ModelTypeMarker(str, Enum):
"""Model type markers for pytest parametrization."""
DIFFUSION = "diffusion"
OMNI = "omni"
TTS = "tts"
class DiffusionModelTestOpts(NamedTuple):
"""Configuration for one Diffusion model's tests."""
@@ -59,6 +67,10 @@ class DiffusionModelTestOpts(NamedTuple):
# Actual tasks which controls the tests actually run
supported_tasks: list[DiffusionTasks]
# Function module marker applied per-test-case via parametrization.
# Set to ModelTypeMarker.OMNI or ModelTypeMarker.TTS for non-diffusion models.
model_type_marker: ModelTypeMarker
# Additional acceleration groups to run beyond the base case (no acceleration).
# The base case is always run for every model in the test settings. None means
# we only run the base case.
@@ -256,6 +256,28 @@ def tiny_flux_kontext_builder() -> str:
)
def _shrink_sd3_transformer(config: dict) -> dict:
config["num_layers"] = 2
config["attention_head_dim"] = 32
config["num_attention_heads"] = 4
config["caption_projection_dim"] = 128
config["dual_attention_layers"] = [0]
return config
def tiny_sd3_builder() -> str:
return build_tiny_from_configs(
"StableDiffusion3Pipeline",
"stabilityai/stable-diffusion-3.5-medium",
transform={
"text_encoder": _shrink_flux_clip_text_encoder,
"text_encoder_2": _shrink_flux_clip_text_encoder,
"text_encoder_3": _shrink_flux_t5_text_encoder,
"transformer": _shrink_sd3_transformer,
},
)
def tiny_flux2_builder() -> str:
def shrink_text_encoder(config: dict) -> dict:
config["tie_word_embeddings"] = False
@@ -280,3 +302,69 @@ def tiny_flux2_builder() -> str:
"transformer": partial(_shrink_dit_rope_config, num_single_layers=2, joint_attention_dim=96),
},
)
def _shrink_krea2_text_encoder(config: dict) -> dict:
tc = config["text_config"]
old_head_dim = tc["head_dim"]
tc["num_hidden_layers"] = 3
tc["hidden_size"] = 32
tc["intermediate_size"] = 64
tc["num_attention_heads"] = 2
tc["num_key_value_heads"] = 2
tc["head_dim"] = 16
if "rope_parameters" in tc:
rp = tc["rope_parameters"]
if "mrope_section" in rp:
factor = old_head_dim / 16
rp["mrope_section"] = [max(1, round(d / factor)) for d in rp["mrope_section"]]
vc = config["vision_config"]
vc["depth"] = 2
vc["intermediate_size"] = 64
vc["num_heads"] = 2
vc["out_hidden_size"] = 32
if "deepstack_visual_indexes" in vc:
vc["deepstack_visual_indexes"] = [0, 1]
return config
def _shrink_krea2_transformer(config: dict) -> dict:
config["num_layers"] = 2
config["attention_head_dim"] = 32
config["num_attention_heads"] = 4
config["num_key_value_heads"] = 2
config["intermediate_size"] = 128
config["timestep_embed_dim"] = 32
config["text_hidden_dim"] = 32
config["num_text_layers"] = 3
config["text_num_attention_heads"] = 2
config["text_num_key_value_heads"] = 2
config["text_intermediate_size"] = 64
config["num_layerwise_text_blocks"] = 1
config["num_refiner_text_blocks"] = 1
config["axes_dims_rope"] = [8, 12, 12]
return config
def _shrink_krea2_vae(config: dict) -> dict:
config["base_dim"] = 32
return config
def _patch_krea2_model_index(config: dict) -> dict:
config["text_encoder_select_layers"] = [0, 1, 2]
return config
def tiny_krea2_builder() -> str:
"""Build a tiny Krea2 model with random weights using diffusers building blocks."""
return build_tiny_from_configs(
"Krea2Pipeline",
"krea/Krea-2-Turbo",
transform={
None: _patch_krea2_model_index,
"text_encoder": _shrink_krea2_text_encoder,
"transformer": _shrink_krea2_transformer,
"vae": _shrink_krea2_vae,
},
)
@@ -7,6 +7,7 @@ from tests.model_tests.diffusion.config_types import (
DiffusionAccs,
DiffusionModelTestOpts,
DiffusionTasks,
ModelTypeMarker,
)
# This object defines the (tiny) model configurations for common tests.
@@ -37,12 +38,14 @@ DIFFUSION_TEST_SETTINGS = {
model=real_anima_model,
builder=tiny_anima_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
checkpoint_filename=CHECKPOINT_FILENAME,
),
"Flux2KleinPipeline": DiffusionModelTestOpts(
model="black-forest-labs/FLUX.2-klein-4B",
builder=diff_model_builders.tiny_flux2_klein_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE, DiffusionTasks.IMAGE_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.HSDP, DiffusionAccs.TEA_CACHE],
[DiffusionAccs.SEQUENCE_PARALLEL, DiffusionAccs.CACHE_DIT, DiffusionAccs.LAYERWISE_OFFLOAD],
@@ -53,6 +56,7 @@ DIFFUSION_TEST_SETTINGS = {
model="Lightricks/LTX-2",
builder=diff_model_builders.tiny_ltx2_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_VIDEO, DiffusionTasks.IMAGE_TO_VIDEO],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.HSDP, DiffusionAccs.CACHE_DIT],
[DiffusionAccs.SEQUENCE_PARALLEL, DiffusionAccs.CACHE_DIT, DiffusionAccs.LAYERWISE_OFFLOAD],
@@ -63,6 +67,7 @@ DIFFUSION_TEST_SETTINGS = {
model="Efficient-Large-Model/SANA-Video_2B_480p_diffusers",
builder=diff_model_builders.tiny_sana_video_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_VIDEO],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.TENSOR_PARALLEL],
[DiffusionAccs.CFG_PARALLEL],
@@ -82,6 +87,7 @@ DIFFUSION_TEST_SETTINGS = {
model="Efficient-Large-Model/SANA-Video_2B_480p_diffusers",
builder=diff_model_builders.tiny_sana_video_i2v_builder,
supported_tasks=[DiffusionTasks.IMAGE_TO_VIDEO],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.CFG_PARALLEL],
[DiffusionAccs.TENSOR_PARALLEL, DiffusionAccs.CFG_PARALLEL],
@@ -95,11 +101,13 @@ DIFFUSION_TEST_SETTINGS = {
model="Qwen/Qwen-Image",
builder=diff_model_builders.tiny_qwen_image_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
),
"LongCatImagePipeline": DiffusionModelTestOpts(
model="meituan-longcat/LongCat-Image",
builder=diff_model_builders.tiny_longcat_image_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.TEA_CACHE],
[DiffusionAccs.SEQUENCE_PARALLEL, DiffusionAccs.CACHE_DIT, DiffusionAccs.LAYERWISE_OFFLOAD],
@@ -110,6 +118,7 @@ DIFFUSION_TEST_SETTINGS = {
model="meituan-longcat/LongCat-Image-Edit",
builder=diff_model_builders.tiny_longcat_image_edit_builder,
supported_tasks=[DiffusionTasks.IMAGE_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.TEA_CACHE],
[DiffusionAccs.SEQUENCE_PARALLEL, DiffusionAccs.CACHE_DIT, DiffusionAccs.LAYERWISE_OFFLOAD],
@@ -120,11 +129,13 @@ DIFFUSION_TEST_SETTINGS = {
model="black-forest-labs/FLUX.1-schnell",
builder=diff_model_builders.tiny_flux_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
),
"FluxKontextPipeline": DiffusionModelTestOpts(
model="black-forest-labs/FLUX.1-Kontext-dev",
builder=diff_model_builders.tiny_flux_kontext_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE, DiffusionTasks.IMAGE_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.TENSOR_PARALLEL, DiffusionAccs.CPU_OFFLOAD],
[DiffusionAccs.CFG_PARALLEL, DiffusionAccs.CPU_OFFLOAD],
@@ -135,15 +146,39 @@ DIFFUSION_TEST_SETTINGS = {
model="black-forest-labs/FLUX.2-dev",
builder=diff_model_builders.tiny_flux2_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.CACHE_DIT, DiffusionAccs.LAYERWISE_OFFLOAD],
[DiffusionAccs.CFG_PARALLEL, DiffusionAccs.SEQUENCE_PARALLEL, DiffusionAccs.CPU_OFFLOAD],
[DiffusionAccs.TENSOR_PARALLEL, DiffusionAccs.VAE_PATCH_PARALLEL],
],
),
"QwenImageEditPipeline": DiffusionModelTestOpts(
model="Qwen/Qwen-Image-Edit",
builder=diff_model_builders.tiny_qwen_image_edit_builder,
supported_tasks=[DiffusionTasks.IMAGE_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
),
"QwenImageEditPlusPipeline": DiffusionModelTestOpts(
model="Qwen/Qwen-Image-Edit-2511",
builder=diff_model_builders.tiny_qwen_image_edit_plus_builder,
supported_tasks=[DiffusionTasks.IMAGE_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
),
"StableDiffusion3Pipeline": DiffusionModelTestOpts(
model="stabilityai/stable-diffusion-3.5-medium",
builder=diff_model_builders.tiny_sd3_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
extra_test_groups=[
[DiffusionAccs.CACHE_DIT, DiffusionAccs.LAYERWISE_OFFLOAD],
[DiffusionAccs.CFG_PARALLEL, DiffusionAccs.TENSOR_PARALLEL, DiffusionAccs.CPU_OFFLOAD],
],
),
"Krea2Pipeline": DiffusionModelTestOpts(
model="krea/Krea-2-Turbo",
builder=diff_model_builders.tiny_krea2_builder,
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
model_type_marker=ModelTypeMarker.DIFFUSION,
),
}
@@ -56,12 +56,10 @@ EXCLUDED_MODELS = [
"MingImageDiffusionPipeline",
"MingImageLayeredDiffusionPipeline",
"InternVLAA1Pipeline",
"StableDiffusion3Pipeline",
"HunyuanImage3ForCausalMM",
"ErnieImagePipeline",
"NextStep11Pipeline",
"FluxDMD2Pipeline",
"Krea2Pipeline",
"QwenImageDMD2Pipeline",
"OmniGen2Pipeline",
"HeliosPipeline",
@@ -21,8 +21,8 @@ from tests.model_tests.diffusion.task_runners import (
run_and_validate_text_to_video_request,
)
# NOTE : Hardware marks are added dynamically based on test requirements
pytestmark = [pytest.mark.diffusion, pytest.mark.xdist]
# NOTE: Hardware and model type marks are added dynamically based on test requirements and model type
pytestmark = [pytest.mark.xdist]
@pytest.mark.parametrize(
@@ -31,8 +31,8 @@ from tests.model_tests.diffusion.task_runners import (
)
from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata
# NOTE : Hardware marks are added dynamically based on test requirements
pytestmark = [pytest.mark.diffusion, pytest.mark.xdist]
# NOTE: Hardware and model type marks are added dynamically based on test requirements and model type
pytestmark = [pytest.mark.xdist]
@pytest.fixture(autouse=True)
+153
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import ast
import inspect
from dataclasses import replace
from types import SimpleNamespace
import numpy as np
@@ -1071,6 +1072,158 @@ def _stage0_vision_runtime():
return runtime
def _minicpmo_seeded_silence_sampling_case(initial_user_text: str | None, *, force_listen_count: int = 0):
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.plugin import build_duplex_data_plane_prompt
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import _MiniCPMO45Stage0SessionState
runtime = _stage0_vision_runtime()
state = _MiniCPMO45Stage0SessionState(session_id="sid-seeded-text")
runtime_config = {"initial_user_text": initial_user_text}
runtime._prepare_session_context(state, {"instructions": "Speak exactly."}, runtime_config=runtime_config)
prefill = runtime._stage_prefill_embeddings_only(
state, np.zeros(4, dtype=np.float32), epoch=0, seq=1, is_speech=False
)
assert prefill["success"] is True
prompt = build_duplex_data_plane_prompt(
request_id="req-seeded-text",
fence=DuplexFence(state.session_id),
session_config={"extra_body": {"force_listen_count": force_listen_count}},
runtime_config=runtime_config,
seq=1,
turn_seq=1,
payload={"type": "audio", "is_speech": False},
final=False,
)
payload = prompt["model_intermediate_buffer"]["duplex"]["payload"]
model, row = _minicpmo_duplex_policy_case(state, payload)
model._minicpmo45_native_duplex_token_ids_cache = runtime._special_token_ids()
return runtime, state, model, row
@pytest.mark.parametrize("initial_user_text", [None, "", "Get the trust fund to the bank early."])
@pytest.mark.parametrize("force_listen_count", [0, 1])
def test_minicpmo_seeded_text_reaches_sampling_on_silence(initial_user_text, force_listen_count):
"""A seeded user turn must reach the model despite its silent audio clock."""
runtime, state, model, row = _minicpmo_seeded_silence_sampling_case(
initial_user_text, force_listen_count=force_listen_count
)
logits = torch.zeros((1, 32), dtype=torch.float32)
logits[0, 20] = 10.0
original_logits = logits.clone()
model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,))
if initial_user_text and not force_listen_count:
assert torch.equal(logits, original_logits)
assert state.pending_speech_context is True
else:
assert logits[0, runtime.listen_token_id] == 0
assert torch.isfinite(logits).sum() == 1
def test_minicpmo_seeded_text_is_consumed_before_post_turn_silence():
"""The opening text allows one answer; silence after its end stays quiet."""
from dataclasses import replace
runtime, state, model, row = _minicpmo_seeded_silence_sampling_case("Read this sentence.")
token_ids = runtime._special_token_ids()
logits = torch.zeros((1, 32), dtype=torch.float32)
model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,))
assert torch.isfinite(logits).all()
model._record_minicpmo45_duplex_terminator(0, runtime.tts_bos_token_id, token_ids)
assert state.pending_speech_context is True
model._record_minicpmo45_duplex_terminator(0, 20, token_ids)
assert state.pending_speech_context is False
model._record_minicpmo45_duplex_terminator(0, runtime.turn_eos_token_id, token_ids)
assert state.current_turn_ended is True
prefill = runtime._stage_prefill_embeddings_only(
state, np.zeros(4, dtype=np.float32), epoch=0, seq=2, is_speech=False
)
assert prefill["success"] is True
next_logits = torch.zeros((1, 32), dtype=torch.float32)
model.prepare_duplex_sampling(next_logits, SimpleNamespace(), (replace(row, seq=row.seq + 1),))
assert next_logits[0, runtime.listen_token_id] == 0
assert torch.isfinite(next_logits).sum() == 1
def test_minicpmo_seeded_text_survives_explicit_initial_listen():
"""The force-listen prefix delays the seed without losing its answer."""
from dataclasses import replace
runtime, state, model, row = _minicpmo_seeded_silence_sampling_case("Read this sentence.", force_listen_count=1)
token_ids = runtime._special_token_ids()
logits = torch.zeros((1, 32), dtype=torch.float32)
model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,))
assert torch.isfinite(logits).sum() == 1
model._record_minicpmo45_duplex_terminator(0, runtime.listen_token_id, token_ids)
prefill = runtime._stage_prefill_embeddings_only(
state, np.zeros(4, dtype=np.float32), epoch=0, seq=2, is_speech=False
)
assert prefill["success"] is True
next_logits = torch.zeros((1, 32), dtype=torch.float32)
next_row = replace(row, seq=row.seq + 1, payload={"is_speech": False, "force_listen": False})
model.prepare_duplex_sampling(next_logits, SimpleNamespace(), (next_row,))
assert torch.isfinite(next_logits).all()
assert state.pending_speech_context is True
@pytest.mark.parametrize("initial_text", [None, "", "Please say hello.", "请说你好。"])
@pytest.mark.parametrize("speech_envelope", [False, True])
def test_minicpmo_seeded_context_keeps_assistant_turn_open_until_eos(initial_text, speech_envelope, mocker):
from vllm.v1.sample.metadata import SamplingMetadata
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.stage0 import _MiniCPMO45Stage0SessionState
runtime = _stage0_vision_runtime()
state = _MiniCPMO45Stage0SessionState(session_id="seeded-context")
runtime._prepare_session_context(state, {}, runtime_config={"initial_user_text": initial_text})
result = runtime._stage_prefill_embeddings_only(
state, np.zeros(4, dtype=np.float32), epoch=0, seq=1, is_speech=False
)
assert result["success"] is True
model, row = _minicpmo_duplex_policy_case(state, {"is_speech": False})
logits = torch.zeros((1, 16), dtype=torch.float32)
logits[0, 10] = 20.0
original_logits = logits.clone()
sampling_metadata = mocker.Mock(spec=SamplingMetadata)
model.prepare_duplex_sampling(logits, sampling_metadata, (row,))
token_ids = model._minicpmo45_native_duplex_token_ids_cache
sampled = model._finalize_minicpmo45_native_duplex_sample(0, 7, token_ids)
if not initial_text:
assert sampled == 7
assert logits[0, 7].item() == 0.0
assert torch.isneginf(logits[0, :7]).all()
assert torch.isneginf(logits[0, 8:]).all()
return
assert torch.equal(logits, original_logits)
assert sampled == 8
if speech_envelope:
model._record_minicpmo45_duplex_terminator(0, 8, token_ids)
assert state.current_turn_ended is False
assert state.pending_speech_context is True
model._record_minicpmo45_duplex_terminator(0, 10, token_ids)
assert state.pending_speech_context is False
model._record_minicpmo45_duplex_terminator(0, 9, token_ids)
# A later silent unit must not re-admit the seed after the turn ended.
result = runtime._stage_prefill_embeddings_only(
state, np.zeros(4, dtype=np.float32), epoch=0, seq=2, is_speech=False
)
assert result["success"] is True
logits = original_logits.clone()
model.prepare_duplex_sampling(logits, sampling_metadata, (replace(row, seq=4),))
assert logits[0, 7].item() == 0.0
assert torch.isneginf(logits[0, :7]).all()
assert torch.isneginf(logits[0, 8:]).all()
assert model._finalize_minicpmo45_native_duplex_sample(0, 7, token_ids) == 7
def test_minicpmo_stage0_puts_every_frame_of_an_append_in_one_unit():
"""Official streaming_prefill feeds the whole frame_list into one unit.
@@ -217,6 +217,51 @@ def test_build_async_chunk_outputs_slices_padded_axis_and_splits_channels() -> N
assert client is None and torch.equal(inter_stage[0]["codes.audio"], req_codes[0])
@pytest.mark.parametrize("async_chunk", [False, True])
@pytest.mark.parametrize(
"hidden_padded,codes_padded",
[(False, False), (True, False), (False, True), (True, True)],
ids=["unpadded", "hidden_padded", "codes_padded", "both_padded"],
)
@pytest.mark.parametrize("lengths", [(1, 1, 1), (3, 1, 1)], ids=["decode", "mixed_prefill_decode"])
def test_async_output_slices_request_payloads_with_graph_padding(
monkeypatch, mocker, async_chunk, hidden_padded, codes_padded, lengths
):
"""Sync and async transfers must exclude other requests and graph padding."""
from vllm.v1.worker.gpu.input_batch import InputBatch
monkeypatch.setattr(torch.cuda, "set_stream", lambda _stream: None)
total = sum(lengths)
padded_total = 8 if hidden_padded or codes_padded else total
offsets = np.cumsum([0, *lengths])
hidden_rows = padded_total if hidden_padded else total
code_rows = padded_total if codes_padded else total
hidden = torch.arange(hidden_rows * 4, dtype=torch.float32).reshape(hidden_rows, 4)
codes = torch.arange(code_rows * 16).reshape(code_rows, 16)
# Reference frames are request-local, even if their length matches the
# padded token count. They must not be sliced along the batch token axis.
refs = [torch.arange(padded_total * 16).reshape(padded_total, 16), torch.empty(0), torch.empty(0)]
batch = mocker.Mock(spec=InputBatch)
batch.query_start_loc_np = offsets
batch.num_scheduled_tokens = np.array(lengths)
batch.num_reqs = len(lengths)
batch.num_tokens_after_padding = padded_total
output = _async_output(
req_ids=[f"req-{i}" for i in range(len(lengths))],
sampler_output=SamplerOutput(torch.ones(len(lengths), 1, dtype=torch.long), None, None, None),
text_hidden=hidden,
multimodal_outputs={"codes": {"audio": codes, "ref": refs}},
input_batch=batch,
async_chunk=async_chunk,
).get_output()
for i, payload in enumerate(output.inter_stage_outputs):
torch.testing.assert_close(payload["codes.audio"], codes[offsets[i] : offsets[i + 1]])
torch.testing.assert_close(payload["codes.ref"], refs[i])
if not async_chunk:
torch.testing.assert_close(payload["hidden"], hidden[offsets[i] : offsets[i + 1]])
def test_async_chunk_output_stages_mm_on_copy_stream_before_get_output(monkeypatch) -> None:
monkeypatch.setattr(torch.cuda, "set_stream", lambda _stream: None)
calls = []
+29
View File
@@ -0,0 +1,29 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Client-side duplex session preset for Nemotron VoiceChat."""
from __future__ import annotations
from vllm_omni.clients.duplex import AudioFormat, SessionConfig
__all__ = ["create_duplex_session_config"]
def create_duplex_session_config(
*,
instructions: str | None = None,
tools: list[dict[str, object]] | None = None,
idle_timeout_s: float | None = None,
extra_body: dict[str, object] | None = None,
) -> SessionConfig:
"""Use 16 kHz float32 input and 22.05 kHz PCM16 output; append 80 ms frames."""
session_extra_body = dict(extra_body or {})
if tools is not None:
session_extra_body["realtime_tools"] = tools
return SessionConfig(
input_audio=AudioFormat("pcm_f32le", 16_000),
output_audio=AudioFormat("pcm16", 22_050),
instructions=instructions,
idle_timeout_s=idle_timeout_s,
extra_body=session_extra_body,
)
+4 -4
View File
@@ -312,10 +312,10 @@ class PipelineConfig:
# ``hf_config_predicate=lambda c: getattr(c, "version", "") == "4.5"``
# to avoid misrouting 2.6 checkpoints.
hf_config_predicate: Callable[[Any], bool] | None = None
# Diffusers pipeline class name: for models that ship a ``model_index.json``
# (no root ``config.json``), the ``_class_name`` field is matched against
# this value to auto-detect the pipeline. Only needed for diffusers-style
# multi-component repos (e.g. GLM-Image). ``None`` = not a diffusers model.
# Canonical diffusion runtime class for this registered pipeline.
# Serving uses it as a metadata fallback; model_index.json discovery
# also matches its _class_name against this value and its aliases.
# None delegates class discovery to the checkpoint metadata.
diffusers_class_name: str | None = None
diffusers_class_aliases: tuple[str, ...] = ()
endpoint_restrictions: tuple[EndpointRestriction, ...] = ()
+2 -1
View File
@@ -15,7 +15,8 @@ stages:
- stage_id: 1
devices: "0"
max_num_seqs: 1
max_num_seqs: 8
request_batch_max_wait_ms: 20
gpu_memory_utilization: 0.3
enforce_eager: true
trust_remote_code: true
@@ -5,3 +5,6 @@ base_config: mammoth_moda2.yaml
stages:
- stage_id: 0
kv_cache_dtype: fp8_e4m3
# Layer 0 stays BF16: with every layer in FP8, most text-to-image samples
# lose the prompt (A800 results in recipes/MammothModa2/MammothModa2.md).
kv_cache_dtype_skip_layers: ["0"]
+10
View File
@@ -46,6 +46,16 @@ stages:
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
# The talker is the only stochastic stage (the thinker and code2wav both
# decode greedily at temperature 0). Without a seed, every request draws
# fresh codec tokens, and a small fraction of draws derail into fluent
# nonsense part-way through an utterance while the thinker text stays
# correct -- the audio-vs-text nightly failures in #6090. This seed was
# present from the original deploy schema and was dropped as collateral
# in #4986; keep it so Qwen3-Omni speech is reproducible for a given
# request. It bounds sampling only: it does not make the talker immune
# to the underlying degeneration, which is tracked separately.
seed: 42
temperature: 0.9
top_k: 50
max_tokens: 4096
+141
View File
@@ -0,0 +1,141 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Cooperative cancellation for local, full-forward diffusion workers.
The engine owns each signal until the worker has returned. Unlike an executor
RPC, writing the signal does not wait behind the forward being cancelled.
Opted-in pipelines call ``check_request_cancellation`` at safe boundaries.
"""
from __future__ import annotations
import threading
from collections.abc import Iterable, Iterator, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from multiprocessing.shared_memory import SharedMemory
class RequestCancellationRegistry:
"""Engine-owned signals; scheduler mutation remains on the engine thread."""
def __init__(self) -> None:
self._signals: dict[str, SharedMemory] = {}
self._lock = threading.Lock()
def create(self, request_id: str) -> str:
with self._lock:
if request_id in self._signals:
raise ValueError(f"Duplicate cancellation request: {request_id}")
signal = SharedMemory(create=True, size=1)
signal.buf[0] = 0
self._signals[request_id] = signal
return signal.name
def cancel(self, request_ids: Iterable[str]) -> None:
with self._lock:
for request_id in request_ids:
signal = self._signals.get(request_id)
if signal is not None:
signal.buf[0] = 1
def cancel_all(self) -> None:
with self._lock:
for signal in self._signals.values():
signal.buf[0] = 1
@staticmethod
def _dispose(signal: SharedMemory) -> None:
try:
signal.unlink()
except FileNotFoundError:
# A process-level resource tracker may already have removed it.
pass
finally:
signal.close()
def finish(self, request_id: str) -> None:
"""Release only after execution returns, including aborted execution."""
with self._lock:
signal = self._signals.pop(request_id, None)
if signal is not None:
self._dispose(signal)
def close(self) -> None:
"""Release remaining signals after the executor has shut down."""
with self._lock:
for signal in self._signals.values():
self._dispose(signal)
self._signals.clear()
_current_signals: ContextVar[tuple[SharedMemory | None, ...] | None] = ContextVar(
"diffusion_request_cancellation", default=None
)
@contextmanager
def request_cancellation_scope(signal_names: Sequence[str | None], *, enabled: bool = True) -> Iterator[None]:
"""Attach worker readers without taking ownership of the engine's names."""
signals: list[SharedMemory | None] = []
try:
for name in signal_names if enabled else ():
signals.append(SharedMemory(name=name) if name is not None else None)
# An opted-in rank without a local signal must still join every
# checkpoint collective and vote "not cancelled". None means only
# that this execution does not use cooperative cancellation at all.
token = _current_signals.set(tuple(signals) if enabled else None)
try:
yield
finally:
_current_signals.reset(token)
finally:
for signal in signals:
if signal is not None:
signal.close()
def check_request_cancellation(*, synchronize: bool = False) -> None:
"""Stop a cancelled execution wave without stranding a peer's collectives.
``synchronize=True`` drains queued device work only when cancellation has
been requested locally, before re-reading the flags. Every abort drains queued
device work before unwinding. Successful steps retain asynchronous execution
when no local cancellation is observed.
Independent requests coupled by an AllGather offload wave must all be cancelled
before the wave can exit; cancelling one must not abort its live peers.
"""
signals = _current_signals.get()
if signals is None:
return
import torch
locally_requested = any(signal is not None and signal.buf[0] for signal in signals)
device_drained = False
if synchronize and locally_requested:
from vllm_omni.platforms import current_omni_platform
current_omni_platform.synchronize()
device_drained = True
cancelled = bool(signals) and all(signal is not None and signal.buf[0] for signal in signals)
if torch.distributed.is_initialized():
from vllm_omni.diffusion.distributed.parallel_state import get_world_group
group = get_world_group()
if group.world_size > 1:
# Reading a shared byte alone is insufficient: ranks can observe a
# concurrent write on different steps. Make one collective decision.
flag = torch.tensor([int(cancelled)], dtype=torch.int32, device="cpu")
torch.distributed.all_reduce(flag, op=torch.distributed.ReduceOp.MIN, group=group.cpu_group)
cancelled = bool(flag.item())
if cancelled:
from vllm_omni.diffusion.data import DiffusionRequestAbortedError
if not device_drained:
from vllm_omni.platforms import current_omni_platform
current_omni_platform.synchronize()
raise DiffusionRequestAbortedError("Request cancelled at a model execution boundary")
+50 -2
View File
@@ -25,6 +25,7 @@ from vllm.utils.import_utils import resolve_obj_by_qualname
from vllm.v1.engine.exceptions import EngineDeadError
from vllm.v1.kv_cache_interface import KVCacheConfig
from vllm_omni.diffusion.cancellation import RequestCancellationRegistry
from vllm_omni.diffusion.data import (
DIFFUSION_REQUEST_LIFECYCLE_KEY,
DIFFUSION_REQUEST_STARTED,
@@ -180,6 +181,19 @@ def _max_num_seqs(od_config: OmniDiffusionConfig) -> int:
return 1
def supports_request_cancellation(od_config: OmniDiffusionConfig) -> bool:
"""Whether the local pipeline checks cooperative cancellation boundaries."""
model_cls = _resolve_custom_pipeline_cls(getattr(od_config, "custom_pipeline_args", None))
if model_cls is None:
name = (
"DiffusersAdapterPipeline"
if uses_diffusers_adapter(od_config)
else getattr(od_config, "model_class_name", None)
)
model_cls = DiffusionModelRegistry._try_load_model_cls(name)
return getattr(model_cls, "supports_request_cancellation", False) is True
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)
@@ -230,6 +244,8 @@ class DiffusionEngine:
# don't hit AttributeError when _busy_loop accesses them.
dp_concurrent: bool = False
_scheduling_paused: bool = False
# Disabled until runtime initialization resolves the pipeline capability.
_request_cancellations: RequestCancellationRegistry | None = None
def __init__(
self,
@@ -393,6 +409,9 @@ class DiffusionEngine:
self._shutting_down = False
self._shutdown_complete = False
self.abort_queue: queue.Queue[str] = queue.Queue()
self._request_cancellations = (
RequestCancellationRegistry() if supports_request_cancellation(self.od_config) else None
)
self._rpc_queue: queue.Queue[_RpcTask] = queue.Queue()
# pause_scheduler(mode="keep"): no new batch is scheduled while set.
self._scheduling_paused = False
@@ -913,6 +932,8 @@ class DiffusionEngine:
# path, and repeating executor.shutdown() is unsafe.
self._shutting_down = True
self._closed = True
if self._request_cancellations is not None:
self._request_cancellations.cancel_all()
if self.stop_event is not None:
self.stop_event.set()
streams = list(self._out_streams.values())
@@ -925,7 +946,13 @@ class DiffusionEngine:
# If Worker shutdown fails, retain the Scheduler reservations. A
# remote producer may still be writing into those allocations.
self.executor.shutdown()
self.scheduler.close()
try:
self.scheduler.close()
finally:
# Workers are down, so readers cannot attach or use the signals.
# Release them even if scheduler cleanup itself fails.
if self._request_cancellations is not None:
self._request_cancellations.close()
self._shutdown_complete = True
def _emit_finished_outputs(
@@ -1106,7 +1133,15 @@ class DiffusionEngine:
if self._closed:
raise RuntimeError("DiffusionEngine is closed.")
queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue()
request_id = self.scheduler.add_request(request)
if self._request_cancellations is not None:
request.cancellation_signal = self._request_cancellations.create(request.request_id)
try:
request_id = self.scheduler.add_request(request)
except BaseException:
if self._request_cancellations is not None:
self._request_cancellations.finish(request.request_id)
request.cancellation_signal = None
raise
self._out_streams[request_id] = queue
self._cv.notify_all()
@@ -1517,6 +1552,8 @@ class DiffusionEngine:
return
if not self._closed:
self._closed = True
if self._request_cancellations is not None:
self._request_cancellations.cancel_all()
if self.stop_event is not None:
self.stop_event.set()
pending_streams = list(self._out_streams.values())
@@ -1532,6 +1569,9 @@ class DiffusionEngine:
if worker_thread.is_alive():
worker_thread.join(timeout=10)
if worker_thread.is_alive():
# Keep cancellation names available: an in-flight worker may
# not have attached its readers yet. A later close releases
# them after execution and executor shutdown have completed.
logger.warning(
"Worker thread did not terminate within 10s; scheduler and executor shutdown will be deferred."
)
@@ -1550,6 +1590,8 @@ class DiffusionEngine:
else:
self.scheduler.close()
self.executor.shutdown()
if self._request_cancellations is not None:
self._request_cancellations.close()
self._shutdown_complete = True
def abort(self, request_id: str | Iterable[str]) -> None:
@@ -1558,6 +1600,10 @@ class DiffusionEngine:
with self._cv:
if self._closed:
return
if self._request_cancellations is not None:
# Do not queue this behind the full-forward executor call.
# Scheduler state is still mutated only by the busy loop.
self._request_cancellations.cancel(request_ids)
for req_id in request_ids:
self.abort_queue.put(req_id)
self._cv.notify_all()
@@ -1601,6 +1647,8 @@ class DiffusionEngine:
state = self.scheduler.get_request_state(request_id)
popped_state = self.scheduler.pop_request_state(request_id)
state = state or popped_state
if self._request_cancellations is not None:
self._request_cancellations.finish(request_id)
if state is None:
raise RuntimeError(f"Diffusion scheduler lost state for request {request_id}.")
+46 -8
View File
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM and The HuggingFace Team
# Type definitions in this module are adapted from HuggingFace diffusers library:
# diffusers/src/diffusers/models/_modeling_parallel.py
@@ -36,7 +37,7 @@ Example:
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, TypeGuard
if TYPE_CHECKING:
import torch
@@ -220,10 +221,13 @@ class SequenceParallelInput:
This is useful for layers whose outputs should be split after preprocessing
(e.g., RoPE embeddings).
auto_pad: If True, automatically pad the tensor if its size along split_dim
is not divisible by world_size. Creates an attention mask to indicate
valid vs padding positions. The mask is stored in ForwardContext.
is not divisible by world_size. Padding metadata is stored in
ForwardContext so models can construct attention masks when needed.
Note: Ring attention does not support attention mask, so auto_pad
should only be used with Ulysses SP.
shard_group: Optional key shared by tensors representing the same global
sequence. Keyed groups track independent padding metadata; omitting
it preserves the legacy single-sequence padding behavior.
Example:
# Split hidden_states along sequence dimension (dim 1)
@@ -240,12 +244,13 @@ class SequenceParallelInput:
expected_dims: int | None = None
split_output: bool = False
auto_pad: bool = False
shard_group: str | None = None
def __repr__(self) -> str:
return (
f"SequenceParallelInput(split_dim={self.split_dim}, "
f"expected_dims={self.expected_dims}, split_output={self.split_output}, "
f"auto_pad={self.auto_pad})"
f"auto_pad={self.auto_pad}, shard_group={self.shard_group!r})"
)
@@ -262,6 +267,8 @@ class SequenceParallelOutput:
gather_dim: The dimension along which to gather the tensor.
expected_dims: Expected number of dimensions. If provided, validates that
the tensor has this many dimensions before gathering.
shard_group: Optional key whose padding metadata determines the gathered
sequence length. Omitting it preserves legacy singleton trimming.
Example:
# Gather output along sequence dimension (dim 1)
@@ -270,9 +277,13 @@ class SequenceParallelOutput:
gather_dim: int
expected_dims: int | None = None
shard_group: str | None = None
def __repr__(self) -> str:
return f"SequenceParallelOutput(gather_dim={self.gather_dim}, expected_dims={self.expected_dims})"
return (
f"SequenceParallelOutput(gather_dim={self.gather_dim}, "
f"expected_dims={self.expected_dims}, shard_group={self.shard_group!r})"
)
@dataclass(frozen=True)
@@ -372,12 +383,14 @@ SequenceParallelModelPlan = dict[str, SequenceParallelInputType | SequenceParall
# =============================================================================
def _is_valid_input_config(value: object) -> bool:
def _is_valid_input_config(value: object) -> TypeGuard[AnySequenceParallelInput]:
"""Check if a value is a valid input configuration type."""
return isinstance(value, (SequenceParallelInput, SequenceParallelPartialInput))
def _is_valid_input_config_list(value: object) -> bool:
def _is_valid_input_config_list(
value: object,
) -> TypeGuard[list[AnySequenceParallelInput] | tuple[AnySequenceParallelInput, ...]]:
"""Check if a value is a list/tuple of valid input configurations."""
if not isinstance(value, (list, tuple)):
return False
@@ -396,6 +409,27 @@ def validate_sp_plan(plan: SequenceParallelModelPlan) -> None:
if not isinstance(plan, dict):
raise ValueError(f"_sp_plan must be a dict, got {type(plan).__name__}")
# A shard_group names one split boundary's global sequence. Reusing it
# across boundaries would silently overwrite the recorded length.
shard_group_owners: dict[str, str] = {}
def _validate_input(sp_input: AnySequenceParallelInput, *, module_id: str) -> None:
if not isinstance(sp_input, SequenceParallelInput) or sp_input.shard_group is None:
return
if not sp_input.auto_pad:
raise ValueError(
f"SequenceParallelInput for module {module_id!r} declares "
f"shard_group={sp_input.shard_group!r}, but keyed shard "
"metadata currently requires auto_pad=True."
)
owner = shard_group_owners.setdefault(sp_input.shard_group, module_id)
if owner != module_id:
raise ValueError(
f"shard_group={sp_input.shard_group!r} is used by multiple "
f"split boundaries ({owner!r} and {module_id!r}). Use a unique "
"name for each boundary."
)
for module_id, module_plan in plan.items():
if not isinstance(module_id, str):
raise ValueError(f"_sp_plan keys must be strings, got {type(module_id).__name__}")
@@ -407,6 +441,8 @@ def validate_sp_plan(plan: SequenceParallelModelPlan) -> None:
if all(isinstance(x, SequenceParallelOutput) for x in module_plan):
continue
if _is_valid_input_config_list(module_plan):
for sp_input in module_plan:
_validate_input(sp_input, module_id=module_id)
# List of inputs for a specific parameter (when output is tuple)
continue
@@ -428,8 +464,10 @@ def validate_sp_plan(plan: SequenceParallelModelPlan) -> None:
f"Integer keys (output indices) require split_output=True, "
f"got split_output=False for module '{module_id}'[{key}]"
)
_validate_input(value, module_id=module_id)
elif _is_valid_input_config_list(value):
pass # Valid list of input configs
for sp_input in value:
_validate_input(sp_input, module_id=module_id)
else:
raise ValueError(
f"Input spec values must be SequenceParallelInput/PartialInput or list thereof, "
+11
View File
@@ -64,6 +64,10 @@ class ForwardContext:
sp_padding_size: int = 0
# Original sequence length before padding (for removing padding in gather)
sp_original_seq_len: int | None = None
# Pre-padding global sequence length per SequenceParallelInput shard_group,
# for models that auto-pad several independent sequences in one boundary.
# The singleton fields above remain for single-sequence models.
sp_shard_metadata: dict[str, int] = field(default_factory=dict)
# Set by registry when _sp_plan hooks are applied.
# When True, sp_active is determined by _sp_shard_depth (for _sp_plan hooks)
@@ -127,6 +131,13 @@ def is_forward_context_available() -> bool:
return _forward_context is not None
def get_sp_shard_original_seq_len(shard_group: str) -> int | None:
"""Pre-padding global length of `shard_group`, or None if it was not split."""
if not is_forward_context_available():
return None
return get_forward_context().sp_shard_metadata.get(shard_group)
def build_local_sp_padding_mask(
batch_size: int,
local_seq_len: int,
+59 -17
View File
@@ -182,6 +182,12 @@ class SequenceParallelSplitHook(ModelHook):
self.module_forward_metadata: ModuleForwardMetadata | None = None
# Cache for text lengths resolved from kwargs
self._text_len_cache: dict[str, int] = {}
self._shard_groups = {
sp_input.shard_group
for value in metadata.values()
for sp_input in (value if isinstance(value, (list, tuple)) else (value,))
if isinstance(sp_input, SequenceParallelInput) and sp_input.shard_group is not None
}
def initialize_hook(self, module: nn.Module) -> nn.Module:
cls = _unwrap_module(module).__class__
@@ -193,6 +199,16 @@ class SequenceParallelSplitHook(ModelHook):
args_list = list(args)
# Clear text length cache for this forward pass
self._text_len_cache.clear()
if self._shard_groups:
from vllm_omni.diffusion.forward_context import (
get_forward_context,
is_forward_context_available,
)
if is_forward_context_available():
ctx = get_forward_context()
for shard_group in self._shard_groups:
ctx.sp_shard_metadata.pop(shard_group, None)
for name, spm in self.metadata.items():
# Skip if this is a split_output entry (handled in post_forward)
@@ -376,7 +392,11 @@ class SequenceParallelSplitHook(ModelHook):
if isinstance(sp_input, SequenceParallelInput):
# Full split with optional auto-padding
if sp_input.auto_pad:
return self._shard_with_auto_pad(x, sp_input.split_dim)
return self._shard_with_auto_pad(
x,
sp_input.split_dim,
sp_input.shard_group,
)
_maybe_validate_strict_divisibility(dim=sp_input.split_dim, seq_len=x.size(sp_input.split_dim))
return sp_shard(x, sp_input.split_dim, validate=False)
elif isinstance(sp_input, SequenceParallelPartialInput):
@@ -397,13 +417,17 @@ class SequenceParallelSplitHook(ModelHook):
else:
raise ValueError(f"Unsupported input config type: {type(sp_input).__name__}")
def _shard_with_auto_pad(self, x: torch.Tensor, dim: int) -> torch.Tensor:
"""Shard tensor with automatic padding and attention mask creation.
def _shard_with_auto_pad(
self,
x: torch.Tensor,
dim: int,
shard_group: str | None,
) -> torch.Tensor:
"""Pad and shard a tensor while recording its global sequence length.
When sequence length is not divisible by SP world size, this method:
1. Pads the tensor to make it divisible
2. Creates an attention mask indicating valid vs padding positions
3. Stores the mask and padding info in ForwardContext
Models with several independent sequences read the recorded length back
via `shard_group` to build masks. Legacy singleton padding fields are
also populated for single-sequence models.
"""
from vllm_omni.diffusion.attention.selector import get_attn_backend_for_capability
from vllm_omni.diffusion.distributed.parallel_state import (
@@ -418,10 +442,20 @@ class SequenceParallelSplitHook(ModelHook):
return x
seq_len = x.size(dim)
rank = get_sequence_parallel_rank()
remainder = seq_len % world_size
if shard_group is not None and is_forward_context_available():
ctx = get_forward_context()
existing = ctx.sp_shard_metadata.setdefault(shard_group, seq_len)
if existing != seq_len:
raise ValueError(
f"All tensors in shard_group={shard_group!r} must have the "
f"same global sequence length, but got {existing} and {seq_len}."
)
if remainder == 0:
# No padding needed
# Keyed groups record their length even when no padding is needed.
return sp_shard(x, dim, validate=False)
# Check backend compatibility
@@ -464,8 +498,11 @@ class SequenceParallelSplitHook(ModelHook):
x_padded = torch.cat([x, padding], dim=dim)
# Store padding info in forward context (only once, for primary tensor)
# Attention layers will create masks dynamically using this info
if is_forward_context_available():
# Attention layers will create masks dynamically using this info.
# Keyed groups record their length in sp_shard_metadata above and must
# not clobber the legacy singleton fields, whose semantics assume a
# single sequence per boundary.
if shard_group is None and is_forward_context_available():
ctx = get_forward_context()
# Only set if not already set (first auto_pad tensor wins)
if ctx.sp_original_seq_len is None:
@@ -477,7 +514,6 @@ class SequenceParallelSplitHook(ModelHook):
)
# Shard the padded tensor
rank = get_sequence_parallel_rank()
return x_padded.chunk(world_size, dim=dim)[rank]
@@ -523,12 +559,6 @@ class SequenceParallelGatherHook(ModelHook):
if len(output) != len(self.metadata):
raise ValueError(f"Expected {len(self.metadata)} outputs, got {len(output)}.")
# Check if padding was applied during split
original_seq_len = None
if is_forward_context_available():
ctx = get_forward_context()
original_seq_len = ctx.sp_original_seq_len
actually_gathered = False
for i, spm in enumerate(self.metadata):
@@ -546,6 +576,15 @@ class SequenceParallelGatherHook(ModelHook):
gathered = sp_gather(x, spm.gather_dim, validate=False)
# Remove padding if it was applied
original_seq_len = None
if is_forward_context_available():
ctx = get_forward_context()
if spm.shard_group is None:
original_seq_len = ctx.sp_original_seq_len
else:
original_seq_len = ctx.sp_shard_metadata.get(spm.shard_group)
if original_seq_len is None:
raise RuntimeError(f"No SP shard metadata was registered for shard_group={spm.shard_group!r}.")
if original_seq_len is not None and gathered.size(spm.gather_dim) > original_seq_len:
gathered = gathered.narrow(spm.gather_dim, 0, original_seq_len)
logger.debug(f"Removed padding: gathered shape {gathered.shape} (original_seq_len={original_seq_len})")
@@ -559,6 +598,9 @@ class SequenceParallelGatherHook(ModelHook):
ctx._sp_shard_depth = max(0, ctx._sp_shard_depth - 1)
if ctx._sp_equal_pad_stack:
ctx._sp_equal_pad_stack.pop()
for spm in self.metadata:
if spm is not None and spm.shard_group is not None:
ctx.sp_shard_metadata.pop(spm.shard_group, None)
return output[0] if is_tensor else type(output)(output)
+279
View File
@@ -0,0 +1,279 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
# Copyright (c) 2026 SandAI. All Rights Reserved.
"""Native and eager Triton mHC post-processing, without projection or tensor caches."""
import threading
import torch
from vllm.logger import init_logger
from vllm.triton_utils import HAS_TRITON, tl, triton
from vllm_omni.diffusion.layers.custom_op import CustomOp
_FAILED_MHC_KERNELS: set[tuple[str, int, str]] = set()
_WARNED_MHC_KERNELS: set[tuple[str, int, str]] = set()
_MHC_WARNING_LOCK = threading.Lock()
logger = init_logger(__name__)
def _mhc_kernel_key(tensor: torch.Tensor, name: str) -> tuple[str, int, str]:
return (tensor.device.type, tensor.device.index or 0, name)
def _is_mhc_oom_error(error: Exception) -> bool:
message = str(error).lower()
error_name = type(error).__name__.lower()
return isinstance(error, MemoryError) or "out of memory" in message or "outofmemoryerror" in error_name
def _cache_mhc_kernel_failure(failure_key: tuple[str, int, str], error: Exception) -> None:
_FAILED_MHC_KERNELS.add(failure_key)
with _MHC_WARNING_LOCK:
should_warn = failure_key not in _WARNED_MHC_KERNELS
_WARNED_MHC_KERNELS.add(failure_key)
if should_warn:
logger.warning(
"mHC fused kernel failed for key=%s; falling back to native: %s",
failure_key,
repr(error),
)
def sinkhorn_knopp(matrix_logits: torch.Tensor, iterations: int, epsilon: float) -> torch.Tensor:
matrix = torch.exp(matrix_logits - matrix_logits.amax(dim=(-2, -1), keepdim=True))
for _ in range(iterations):
matrix = matrix / (matrix.sum(dim=-2, keepdim=True) + epsilon)
matrix = matrix / (matrix.sum(dim=-1, keepdim=True) + epsilon)
return matrix
@triton.jit
def _mhc_post_residual_kernel(
post_ptr,
residual_ptr,
alpha_post_ptr,
bias_post_ptr,
alpha_residual_ptr,
bias_residual_ptr,
post_out_ptr,
residual_out_ptr,
post_stride,
residual_stride,
scale,
epsilon,
iterations: tl.constexpr,
):
token = tl.program_id(0).to(tl.int64)
index = tl.arange(0, 4)
post = tl.load(post_ptr + token * post_stride + index)
ap = tl.load(alpha_post_ptr)
bp = tl.load(bias_post_ptr + index)
post = 2.0 * tl.sigmoid(ap * scale * post + bp)
offsets = index[:, None] * 4 + index[None, :]
matrix = tl.load(residual_ptr + token * residual_stride + offsets)
ar = tl.load(alpha_residual_ptr)
br = tl.load(bias_residual_ptr + offsets)
matrix = ar * scale * matrix + br
# Native amax propagates any NaN to the whole token's normalization.
has_nan = tl.sum(tl.sum((matrix != matrix).to(tl.int32), axis=0), axis=0) != 0
maximum = tl.max(tl.max(matrix, axis=0), axis=0)
maximum = tl.where(has_nan, float("nan"), maximum)
matrix = tl.exp(matrix - maximum)
for _ in tl.static_range(iterations):
matrix = matrix / (tl.sum(matrix, axis=0)[None, :] + epsilon)
matrix = matrix / (tl.sum(matrix, axis=1)[:, None] + epsilon)
tl.store(post_out_ptr + token * 4 + index, post)
tl.store(residual_out_ptr + token * 16 + offsets, matrix)
@triton.jit
def _mhc_mix_kernel(streams_ptr, branch_ptr, post_ptr, matrix_ptr, out_ptr, hidden, block_size: tl.constexpr):
token = tl.program_id(0).to(tl.int64)
channels = tl.program_id(1).to(tl.int64) * block_size + tl.arange(0, block_size)
valid = channels < hidden
stream_ids = tl.arange(0, 4)
mixed = tl.zeros((4, block_size), tl.float32)
for j in tl.static_range(4):
x = tl.load(streams_ptr + (token * 4 + j) * hidden + channels, mask=valid, other=0).to(tl.float32)
weight = tl.load(matrix_ptr + token * 16 + stream_ids * 4 + j).to(tl.float32)
mixed += weight[:, None] * x[None, :]
branch = tl.load(branch_ptr + token * hidden + channels, mask=valid, other=0).to(tl.float32)
post = tl.load(post_ptr + token * 4 + stream_ids).to(tl.float32)
branch = post[:, None] * branch[None, :]
# Preserve native einsum materialization before the final addition.
mixed = mixed.to(out_ptr.dtype.element_ty).to(tl.float32)
branch = branch.to(out_ptr.dtype.element_ty).to(tl.float32)
tl.store(
out_ptr + (token * 4 + stream_ids[:, None]) * hidden + channels[None, :], mixed + branch, mask=valid[None, :]
)
def _native_required(tensors: tuple[torch.Tensor, ...]) -> bool:
return (
torch.compiler.is_compiling()
or tensors[0].device.type not in ("cuda", "musa")
or (torch.is_grad_enabled() and any(t.requires_grad for t in tensors))
)
class MHCPostResidual(CustomOp):
"""Prepare post coefficients and the residual matrix with FP32 arithmetic."""
@staticmethod
def forward_native(
post_logits: torch.Tensor,
residual_logits: torch.Tensor,
alpha_post: torch.Tensor,
bias_post: torch.Tensor,
alpha_residual: torch.Tensor,
bias_residual: torch.Tensor,
*,
scale: float,
iterations: int = 20,
epsilon: float = 1e-12,
out_dtype: torch.dtype,
) -> tuple[torch.Tensor, torch.Tensor]:
post = 2.0 * torch.sigmoid(alpha_post * scale * post_logits + bias_post.unsqueeze(0))
residual = sinkhorn_knopp(
alpha_residual * scale * residual_logits.float() + bias_residual.unsqueeze(0).float(),
iterations,
epsilon,
)
return post.to(out_dtype), residual.to(out_dtype)
def forward_cuda(
self,
post_logits: torch.Tensor,
residual_logits: torch.Tensor,
alpha_post: torch.Tensor,
bias_post: torch.Tensor,
alpha_residual: torch.Tensor,
bias_residual: torch.Tensor,
*,
scale: float,
iterations: int = 20,
epsilon: float = 1e-12,
out_dtype: torch.dtype,
) -> tuple[torch.Tensor, torch.Tensor]:
tensors = (post_logits, residual_logits, alpha_post, bias_post, alpha_residual, bias_residual)
if _native_required(tensors):
return self.forward_native(
*tensors, scale=scale, iterations=iterations, epsilon=epsilon, out_dtype=out_dtype
)
failure_key = _mhc_kernel_key(post_logits, "post_residual")
if (
not HAS_TRITON
or failure_key in _FAILED_MHC_KERNELS
or not (
post_logits.ndim == 2
and post_logits.shape[1] == 4
and residual_logits.shape == (post_logits.shape[0], 4, 4)
and post_logits.stride(1) == 1
and residual_logits.stride()[1:] == (4, 1)
and alpha_post.numel() == 1
and alpha_residual.numel() == 1
and alpha_post.ndim <= 1
and alpha_residual.ndim <= 1
and bias_post.shape == (4,)
and bias_residual.shape == (4, 4)
and all(t.is_contiguous() for t in tensors[2:])
and all(t.dtype == torch.float32 and t.device == post_logits.device for t in tensors)
and out_dtype in (torch.float32, torch.bfloat16, torch.float16)
and isinstance(scale, (int, float))
and isinstance(epsilon, (int, float))
and isinstance(iterations, int)
and 0 <= iterations <= 64
)
):
return self.forward_native(
*tensors, scale=scale, iterations=iterations, epsilon=epsilon, out_dtype=out_dtype
)
tokens = post_logits.shape[0]
post_out = torch.empty((tokens, 4), device=post_logits.device, dtype=out_dtype)
residual_out = torch.empty((tokens, 4, 4), device=post_logits.device, dtype=out_dtype)
if tokens:
try:
_mhc_post_residual_kernel[(tokens,)](
*tensors,
post_out,
residual_out,
post_logits.stride(0),
residual_logits.stride(0),
scale,
epsilon,
iterations=iterations,
num_warps=1,
enable_fp_fusion=False,
)
except Exception as error:
if _is_mhc_oom_error(error):
raise
_cache_mhc_kernel_failure(failure_key, error)
return self.forward_native(
*tensors, scale=scale, iterations=iterations, epsilon=epsilon, out_dtype=out_dtype
)
return post_out, residual_out
forward_npu = forward_native
class MHCMix(CustomOp):
"""Mix four streams and add a separately rounded branch term."""
@staticmethod
def forward_native(
streams: torch.Tensor,
branch_output: torch.Tensor,
post_coefficients: torch.Tensor,
residual_matrix: torch.Tensor,
) -> torch.Tensor:
branch = torch.einsum("tn,tc->tnc", post_coefficients, branch_output)
mixed = torch.einsum("tij,tjc->tic", residual_matrix, streams)
return mixed + branch
def forward_cuda(
self,
streams: torch.Tensor,
branch_output: torch.Tensor,
post_coefficients: torch.Tensor,
residual_matrix: torch.Tensor,
) -> torch.Tensor:
tensors = (streams, branch_output, post_coefficients, residual_matrix)
if _native_required(tensors):
return self.forward_native(*tensors)
failure_key = _mhc_kernel_key(streams, "mix")
if (
not HAS_TRITON
or failure_key in _FAILED_MHC_KERNELS
or not (
streams.ndim == 3
and streams.shape[1] == 4
and branch_output.shape == (streams.shape[0], streams.shape[2])
and post_coefficients.shape == streams.shape[:2]
and residual_matrix.shape == (streams.shape[0], 4, 4)
and all(t.is_contiguous() and t.device == streams.device and t.dtype == streams.dtype for t in tensors)
and streams.dtype in (torch.float32, torch.bfloat16, torch.float16)
)
):
return self.forward_native(*tensors)
output = torch.empty_like(streams)
if output.numel():
try:
_mhc_mix_kernel[(streams.shape[0], triton.cdiv(streams.shape[2], 256))](
*tensors,
output,
streams.shape[2],
block_size=256,
num_warps=4,
enable_fp_fusion=False,
)
except Exception as error:
if _is_mhc_oom_error(error):
raise
_cache_mhc_kernel_failure(failure_key, error)
return self.forward_native(*tensors)
return output
forward_npu = forward_native
@@ -38,13 +38,22 @@ from vllm.triton_utils import HAS_TRITON
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelInput,
SequenceParallelOutput,
)
from vllm_omni.diffusion.layers.fused_qk_norm_rope import (
_fused_cuda_supported,
fused_qk_norm_rope,
fused_qk_norm_rope_min_tokens,
)
from vllm_omni.diffusion.layers.norm import RMSNorm
from vllm_omni.diffusion.models.utils import make_attention_mask
from vllm_omni.diffusion.models.boogu_image.sp_layout import (
RotaryEmbedding,
ShardLayout,
pack_local_rotary,
rank_concat_mask_or_none,
)
from vllm_omni.platforms import current_omni_platform
if TYPE_CHECKING:
@@ -52,10 +61,6 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
# (cos, sin) — optionally (cos, sin, packed_table) where packed_table is the
# fp32 [tokens, head_dim] = [cos(theta) | sin(theta)] layout the fused
# qk-norm+RoPE op consumes; the eager path ignores the third element.
RotaryEmbedding = tuple[torch.Tensor, ...]
RotaryFrequencyTables = list[RotaryEmbedding]
@@ -540,12 +545,16 @@ def _concat_instruction_image_features(
assert len(img_tensors) == len(instruct_tensors)
batch_size = img_tensors[0].shape[0]
max_seq_len = max(seq_lengths)
joint_capacity = instruct_tensors[0].shape[1] + img_tensors[0].shape[1]
concatenated_list = []
for img_tensor, instruct_tensor in zip(img_tensors, instruct_tensors):
feature_dim = img_tensor.shape[-1]
concatenated = img_tensor.new_zeros(batch_size, max_seq_len, feature_dim)
concatenated = img_tensor.new_zeros(
batch_size,
joint_capacity,
feature_dim,
)
for i, (encoder_seq_len, seq_len) in enumerate(zip(encoder_seq_lengths, seq_lengths)):
concatenated[i, :encoder_seq_len] = instruct_tensor[i, :encoder_seq_len]
concatenated[i, encoder_seq_len:seq_len] = img_tensor[i, : seq_len - encoder_seq_len]
@@ -558,16 +567,24 @@ def _split_instruction_image_features(
hidden_states: torch.Tensor,
encoder_seq_lengths: list[int],
seq_lengths: list[int],
*,
instruct_capacity: int,
img_capacity: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Unpack a joint sequence back into (instruction, image) streams."""
batch_size = hidden_states.shape[0]
feature_dim = hidden_states.shape[-1]
max_instruct_len = max(encoder_seq_lengths)
max_img_len = max(seq_len - encoder_seq_len for seq_len, encoder_seq_len in zip(seq_lengths, encoder_seq_lengths))
instruct_hidden_states = hidden_states.new_zeros(batch_size, max_instruct_len, feature_dim)
img_hidden_states = hidden_states.new_zeros(batch_size, max_img_len, feature_dim)
instruct_hidden_states = hidden_states.new_zeros(
batch_size,
instruct_capacity,
feature_dim,
)
img_hidden_states = hidden_states.new_zeros(
batch_size,
img_capacity,
feature_dim,
)
for i, (encoder_seq_len, seq_len) in enumerate(zip(encoder_seq_lengths, seq_lengths)):
img_len = seq_len - encoder_seq_len
@@ -772,7 +789,11 @@ class BooguImageJointAttention(nn.Module):
attn_output = attn_output.flatten(2, 3).to(dtype)
instruct_attn_out, img_attn_out = _split_instruction_image_features(
attn_output, encoder_seq_lengths, seq_lengths
attn_output,
encoder_seq_lengths,
seq_lengths,
instruct_capacity=instruct_hidden_states.shape[1],
img_capacity=img_hidden_states.shape[1],
)
instruct_projected, _ = self.instruct_out(instruct_attn_out)
img_projected, _ = self.img_out(img_attn_out)
@@ -1139,6 +1160,20 @@ _BOOGU_STACKED_PARAMS_MAPPING = (
)
class _BooguImageSPBoundary(nn.Module):
"""Identity module used by framework SP split hooks."""
def forward(self, *tensors: torch.Tensor) -> tuple[torch.Tensor, ...]:
return tensors
class _BooguImageSPGather(nn.Module):
"""Identity module used by the framework SP gather hook."""
def forward(self, tensor: torch.Tensor) -> torch.Tensor:
return tensor
class BooguImageTransformer2DModel(nn.Module):
"""Boogu-Image transformer with mixed stream topology.
@@ -1156,6 +1191,46 @@ class BooguImageTransformer2DModel(nn.Module):
"BooguImageSingleStreamTransformerBlock",
]
_layerwise_offload_blocks_attrs = ["single_stream_layers", "double_stream_layers"]
# Boundary outputs are hidden states, masks, and RoPE; each triplet is in
# image/reference/context order so tensors sharing a sequence reuse metadata.
# Twelve entries: 3 hidden states (dim=3), 3 masks (dim=2), then the
# rotary cos/sin pairs for image/reference/context (dim=3 each). Real-valued
# RoPE returns tuples, so each rotary embedding contributes two tensors.
_sp_plan = {
"sp_shard_boundary": {
index: SequenceParallelInput(
split_dim=1,
expected_dims=dims,
split_output=True,
auto_pad=True,
shard_group=shard_group,
)
for index, (dims, shard_group) in enumerate(
zip(
(3, 3, 3, 2, 2, 2, 3, 3, 3, 3, 3, 3),
(
"image",
"reference",
"context",
"image",
"reference",
"context",
"image",
"image",
"reference",
"reference",
"context",
"context",
),
)
)
},
"sp_gather_boundary": SequenceParallelOutput(
gather_dim=1,
expected_dims=3,
shard_group="image",
),
}
def __init__(
self,
@@ -1226,6 +1301,8 @@ class BooguImageTransformer2DModel(nn.Module):
axes_lens=axes_lens,
patch_size=patch_size,
)
self.sp_shard_boundary = _BooguImageSPBoundary()
self.sp_gather_boundary = _BooguImageSPGather()
self.x_embedder = nn.Linear(
in_features=patch_size * patch_size * in_channels,
@@ -1472,6 +1549,7 @@ class BooguImageTransformer2DModel(nn.Module):
l_effective_ref_img_len: list[list[int]],
l_effective_img_len: list[int],
temb: torch.Tensor,
per_rank_ref_lengths: list[list[list[int]]] | None = None,
):
"""Embed image patches and run the refiner blocks.
@@ -1481,9 +1559,7 @@ class BooguImageTransformer2DModel(nn.Module):
avoiding a degenerate zero-length attention.
"""
batch_size = len(hidden_states)
max_combined_img_len = max(
img_len + sum(ref_img_len) for img_len, ref_img_len in zip(l_effective_img_len, l_effective_ref_img_len)
)
max_combined_img_len = hidden_states.shape[1] + ref_image_hidden_states.shape[1]
hidden_states = self.x_embedder(hidden_states)
ref_image_hidden_states = self.ref_image_patch_embedder(ref_image_hidden_states)
@@ -1502,6 +1578,14 @@ class BooguImageTransformer2DModel(nn.Module):
flat_l_effective_ref_img_len = list(itertools.chain(*l_effective_ref_img_len))
num_ref_images = len(flat_l_effective_ref_img_len)
max_ref_img_len = max(flat_l_effective_ref_img_len)
sp_sharded = per_rank_ref_lengths is not None and len(per_rank_ref_lengths) > 1
if sp_sharded:
# Keep the packed reference batch at the padded local capacity so
# every SP rank retains the equal-length contract.
max_ref_img_len = max(
max_ref_img_len,
padded_ref_img_mask.shape[1],
)
if max_ref_img_len > 0:
batch_ref_img_mask = ref_image_hidden_states.new_zeros(num_ref_images, max_ref_img_len, dtype=torch.bool)
@@ -1544,9 +1628,27 @@ class BooguImageTransformer2DModel(nn.Module):
# table; the forward-level tuples do not flow into this batch.
batch_ref_img_rotary_emb = _with_packed_rope_table(batch_ref_img_rotary_emb)
if sp_sharded:
# Ulysses will concatenate each rank's packed reference batch,
# so the mask must cover all ranks' segments.
ref_attention_mask = rank_concat_mask_or_none(
[list(itertools.chain(*rank_lengths)) for rank_lengths in per_rank_ref_lengths],
max_ref_img_len,
like=batch_ref_img_mask,
)
else:
ref_attention_mask = (
batch_ref_img_mask
if any(length != max_ref_img_len for length in flat_l_effective_ref_img_len)
else None
)
for layer in self.ref_image_refiner:
batch_ref_image_hidden_states = layer(
batch_ref_image_hidden_states, batch_ref_img_mask, batch_ref_img_rotary_emb, batch_temb
batch_ref_image_hidden_states,
ref_attention_mask,
batch_ref_img_rotary_emb,
batch_temb,
)
# Restore reference-image sequence layout.
@@ -1614,11 +1716,11 @@ class BooguImageTransformer2DModel(nn.Module):
context_rotary_emb,
ref_img_rotary_emb,
noise_rotary_emb,
rotary_emb,
encoder_seq_lengths,
seq_lengths,
combined_img_rotary_emb,
combined_img_seq_lengths,
_,
global_encoder_seq_lengths,
_,
_,
_,
) = self.rope_embedder(
freqs_real,
instruction_attention_mask,
@@ -1629,10 +1731,91 @@ class BooguImageTransformer2DModel(nn.Module):
device,
)
# RoPE is real-valued (cos, sin) tuples; unpack them to shard cos and
# sin independently through the split hook, then re-pair below.
noise_cos, noise_sin = noise_rotary_emb
ref_cos, ref_sin = ref_img_rotary_emb
context_cos, context_sin = context_rotary_emb
(
hidden_states,
ref_image_hidden_states,
instruction_hidden_states,
img_mask,
ref_img_mask,
instruction_attention_mask,
noise_cos,
noise_sin,
ref_cos,
ref_sin,
context_cos,
context_sin,
) = self.sp_shard_boundary(
hidden_states,
ref_image_hidden_states,
instruction_hidden_states,
img_mask,
ref_img_mask,
instruction_attention_mask,
noise_cos,
noise_sin,
ref_cos,
ref_sin,
context_cos,
context_sin,
)
noise_rotary_emb = (noise_cos, noise_sin)
ref_img_rotary_emb = (ref_cos, ref_sin)
context_rotary_emb = (context_cos, context_sin)
image_layout = ShardLayout.resolve("image", local_seq_len=img_mask.shape[1])
reference_layout = ShardLayout.resolve("reference", local_seq_len=ref_img_mask.shape[1])
context_layout = ShardLayout.resolve("context", local_seq_len=instruction_attention_mask.shape[1])
sp_world_size = image_layout.world_size
sp_rank = image_layout.rank
context_mask_required = context_layout.padding_size > 0 or any(
length != context_layout.original_seq_len for length in global_encoder_seq_lengths
)
noise_mask_required = image_layout.padding_size > 0 or any(
length != image_layout.original_seq_len for length in l_effective_img_len
)
ref_mask_required = reference_layout.padding_size > 0 or any(
sum(lengths) != reference_layout.original_seq_len for lengths in l_effective_ref_img_len
)
global_img_lengths = list(l_effective_img_len)
# Ulysses concatenates the rank-local sequences after its all-to-all, so
# attention masks must describe that rank-concatenated layout. Every
# rank's shard bounds are pure arithmetic over the global lengths, so
# each rank derives all ranks' segments without any communication.
ranks = range(sp_world_size)
per_rank_img_lengths = [image_layout.valid_lengths(global_img_lengths, rank=r) for r in ranks]
per_rank_ref_lengths = [reference_layout.segment_lengths(l_effective_ref_img_len, rank=r) for r in ranks]
per_rank_encoder_lengths = [context_layout.valid_lengths(global_encoder_seq_lengths, rank=r) for r in ranks]
local_img_lengths = per_rank_img_lengths[sp_rank]
local_ref_lengths = per_rank_ref_lengths[sp_rank]
local_encoder_lengths = per_rank_encoder_lengths[sp_rank]
(
rotary_emb,
combined_img_rotary_emb,
seq_lengths,
combined_img_seq_lengths,
) = pack_local_rotary(
context_rotary_emb,
ref_img_rotary_emb,
noise_rotary_emb,
local_encoder_lengths,
local_ref_lengths,
local_img_lengths,
)
if _FUSED_QK_NORM_ROPE:
# One packed [cos|sin] table per rotary embedding that reaches an
# attention, built once per forward; the tuples grow a third
# element that the fused path consumes and the eager path ignores.
# Under SP, cos/sin are already the rank-local sharded tensors, so
# the packed table matches the fused kernel's local Q/K geometry.
# (ref_img_rotary_emb is packed inside img_patch_embed_and_refine,
# on the rebuilt per-reference-image batch tuple.)
context_rotary_emb = _with_packed_rope_table(context_rotary_emb)
@@ -1640,32 +1823,68 @@ class BooguImageTransformer2DModel(nn.Module):
rotary_emb = _with_packed_rope_table(rotary_emb)
combined_img_rotary_emb = _with_packed_rope_table(combined_img_rotary_emb)
context_attention_mask = rank_concat_mask_or_none(
per_rank_encoder_lengths,
context_layout.local_seq_len,
like=instruction_attention_mask,
required=context_mask_required,
)
noise_attention_mask = rank_concat_mask_or_none(
per_rank_img_lengths,
image_layout.local_seq_len,
like=img_mask,
required=noise_mask_required,
)
# Context refinement.
for layer in self.context_refiner:
instruction_hidden_states = layer(instruction_hidden_states, instruction_attention_mask, context_rotary_emb)
instruction_hidden_states = layer(
instruction_hidden_states,
context_attention_mask,
context_rotary_emb,
)
# Image patch embedding and refinement.
combined_img_hidden_states = self.img_patch_embed_and_refine(
hidden_states,
ref_image_hidden_states,
img_mask,
noise_attention_mask,
ref_img_mask,
noise_rotary_emb,
ref_img_rotary_emb,
l_effective_ref_img_len,
l_effective_img_len,
local_ref_lengths,
local_img_lengths,
temb,
per_rank_ref_lengths=per_rank_ref_lengths,
)
instruct_hidden_states = instruction_hidden_states
img_hidden_states = combined_img_hidden_states
# Joint mask for [instruct + image].
joint_attention_mask = make_attention_mask(hidden_states, seq_lengths)
# Joint mask for [instruct + image], over the rank-concatenated sequence.
per_rank_combined_img_lengths = [
[sum(ref_lengths) + img_length for ref_lengths, img_length in zip(ref_per_sample, img_per_sample)]
for ref_per_sample, img_per_sample in zip(per_rank_ref_lengths, per_rank_img_lengths)
]
per_rank_seq_lengths = [
[encoder_length + combined_length for encoder_length, combined_length in zip(enc_per_sample, comb)]
for enc_per_sample, comb in zip(per_rank_encoder_lengths, per_rank_combined_img_lengths)
]
joint_attention_mask = rank_concat_mask_or_none(
per_rank_seq_lengths,
rotary_emb[0].shape[1],
like=hidden_states,
required=(context_mask_required or noise_mask_required or ref_mask_required),
)
# Dual-stream (double-stream) stage.
if self.num_double_stream_layers > 0:
img_attention_mask = make_attention_mask(hidden_states, combined_img_seq_lengths)
img_attention_mask = rank_concat_mask_or_none(
per_rank_combined_img_lengths,
combined_img_rotary_emb[0].shape[1],
like=hidden_states,
required=noise_mask_required or ref_mask_required,
)
for layer in self.double_stream_layers:
img_hidden_states, instruct_hidden_states = layer(
@@ -1676,13 +1895,17 @@ class BooguImageTransformer2DModel(nn.Module):
combined_img_rotary_emb,
rotary_emb,
temb,
encoder_seq_lengths,
local_encoder_lengths,
seq_lengths,
)
# Fuse streams to joint sequence.
joint_hidden_states = hidden_states.new_zeros(batch_size, max(seq_lengths), self.hidden_size)
for i, (encoder_seq_len, seq_len) in enumerate(zip(encoder_seq_lengths, seq_lengths)):
joint_hidden_states = hidden_states.new_zeros(
batch_size,
rotary_emb[0].shape[1],
self.hidden_size,
)
for i, (encoder_seq_len, seq_len) in enumerate(zip(local_encoder_lengths, seq_lengths)):
joint_hidden_states[i, :encoder_seq_len] = instruct_hidden_states[i, :encoder_seq_len]
joint_hidden_states[i, encoder_seq_len:seq_len] = img_hidden_states[i, : seq_len - encoder_seq_len]
@@ -1694,12 +1917,23 @@ class BooguImageTransformer2DModel(nn.Module):
# Output projection.
hidden_states = self.norm_out(hidden_states, temb)
# Extract this rank's noise-image shard and gather it once.
local_img_output = hidden_states.new_zeros(
batch_size,
img_mask.shape[1],
hidden_states.shape[-1],
)
for i, (img_len, seq_len) in enumerate(zip(local_img_lengths, seq_lengths)):
local_img_output[i, :img_len] = hidden_states[i, seq_len - img_len : seq_len]
image_tokens = self.sp_gather_boundary(local_img_output)
# Reshape back to image format.
p = self.patch_size
output = []
for i, (img_size, img_len, seq_len) in enumerate(zip(img_sizes, l_effective_img_len, seq_lengths)):
for i, (img_size, img_len) in enumerate(zip(img_sizes, global_img_lengths)):
height, width = img_size
img_tokens = hidden_states[i][seq_len - img_len : seq_len]
img_tokens = image_tokens[i, :img_len]
img_output = rearrange(
img_tokens,
"(h w) (p1 p2 c) -> c (h p1) (w p2)",
@@ -62,6 +62,7 @@ from vllm_omni.diffusion.models.utils import create_transformers_model_with_vllm
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch, split_diffusion_output_by_request
from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.component_config import resolve_component_quant_config
logger = init_logger(__name__)
@@ -382,7 +383,36 @@ class BooguImagePipeline(CFGParallelMixin, nn.Module, ProgressBarMixin, Supports
if parallel_config.tensor_parallel_size > 1:
raise NotImplementedError("Tensor parallelism is not supported by BooguImagePipeline.")
if (parallel_config.sequence_parallel_size or 1) > 1:
raise NotImplementedError("Sequence parallelism is not supported by BooguImagePipeline.")
if parallel_config.cfg_parallel_size > 1:
# SP × CFG-Parallel is unvalidated: the combined path runs
# without crashing but drifts far from the SP=1 baseline
# (~19 dB PSNR at 4 steps), so guidance semantics differ.
# Remove this guard only alongside a parity test.
raise NotImplementedError(
"BooguImagePipeline: sequence parallelism combined with CFG parallelism is not validated."
)
if parallel_config.ring_degree > 1 or getattr(parallel_config, "allgather_degree", 1) > 1:
raise NotImplementedError(
"BooguImagePipeline currently supports sequence parallelism through Ulysses only."
)
if not current_omni_platform.is_cuda():
raise NotImplementedError("BooguImagePipeline sequence parallelism currently requires CUDA.")
if parallel_config.ulysses_mode == "strict":
# Subscript rather than get(): a missing key means the config
# schema has drifted and we would silently validate against a
# stale default, not the actual head count.
tf_params = self.od_config.tf_model_config.params
num_heads = tf_params["num_attention_heads"]
num_kv_heads = tf_params["num_kv_heads"]
if (
num_heads % parallel_config.ulysses_degree != 0
or num_kv_heads % parallel_config.ulysses_degree != 0
):
raise ValueError(
"BooguImagePipeline GQA heads are not divisible by the "
"configured Ulysses degree; set "
"parallel_config.ulysses_mode='advanced_uaa'."
)
if parallel_config.use_hsdp:
raise NotImplementedError("HSDP is not supported by BooguImagePipeline.")
if self.od_config.cache_backend not in (None, "", "none"):
@@ -0,0 +1,188 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Shard-layout arithmetic for BOOGU Image sequence parallelism.
BOOGU splits three independent sequences (noise image, reference images, and
instruction context) at one SP boundary. The framework records each sequence's
pre-padding global length under a ``shard_group`` key; everything else -- how
that global length maps onto a given rank's shard -- is arithmetic, so every
rank can derive the layout of *all* ranks without communicating.
That property is what lets the model build attention masks for the global,
rank-concatenated sequence that Ulysses produces after its all-to-all, instead
of all-gathering rank-local masks inside the attention layer.
"""
from __future__ import annotations
from dataclasses import dataclass
import torch
from vllm_omni.diffusion.forward_context import get_sp_shard_original_seq_len
# (cos, sin) -- optionally (cos, sin, packed_table) where packed_table is the
# fp32 [tokens, head_dim] = [cos(theta) | sin(theta)] layout the fused
# qk-norm+RoPE op consumes; the eager path ignores the third element.
RotaryEmbedding = tuple[torch.Tensor, ...]
@dataclass(frozen=True, slots=True)
class ShardLayout:
"""How one globally padded sequence maps onto the SP ranks."""
original_seq_len: int
world_size: int
rank: int
@classmethod
def resolve(cls, shard_group: str, *, local_seq_len: int) -> ShardLayout:
"""Read a boundary's layout from the ForwardContext.
Falls back to an unsharded layout (world_size=1) when SP is off or the
tensor was never split, in which case `local_seq_len` is the whole
sequence.
"""
from vllm_omni.diffusion.distributed.parallel_state import (
get_sequence_parallel_rank,
get_sequence_parallel_world_size,
)
original_seq_len = get_sp_shard_original_seq_len(shard_group)
if original_seq_len is None:
return cls(original_seq_len=local_seq_len, world_size=1, rank=0)
return cls(
original_seq_len=original_seq_len,
world_size=get_sequence_parallel_world_size(),
rank=get_sequence_parallel_rank(),
)
@property
def padded_seq_len(self) -> int:
ws = self.world_size
return ((self.original_seq_len + ws - 1) // ws) * ws
@property
def local_seq_len(self) -> int:
return self.padded_seq_len // self.world_size
@property
def padding_size(self) -> int:
return self.padded_seq_len - self.original_seq_len
def bounds(self, rank: int) -> tuple[int, int]:
"""Half-open [start, end) span of the global sequence owned by `rank`."""
start = rank * self.local_seq_len
return start, start + self.local_seq_len
def valid_lengths(self, global_lengths: list[int], *, rank: int) -> list[int]:
"""Per-sample valid prefix lengths clipped to `rank`'s shard."""
start, end = self.bounds(rank)
return [max(0, min(int(length), end) - start) for length in global_lengths]
def segment_lengths(self, global_segments: list[list[int]], *, rank: int) -> list[list[int]]:
"""Per-sample contiguous segments intersected with `rank`'s shard."""
start, end = self.bounds(rank)
local: list[list[int]] = []
for sample in global_segments:
sample_local: list[int] = []
offset = 0
for length in sample:
segment_end = offset + int(length)
sample_local.append(max(0, min(segment_end, end) - max(offset, start)))
offset = segment_end
local.append(sample_local)
return local
def rank_concat_mask_or_none(
per_rank_lengths: list[list[int]],
capacity: int,
*,
like: torch.Tensor,
required: bool = False,
) -> torch.Tensor | None:
"""Mask over the rank-concatenated sequence Ulysses builds post all-to-all.
``per_rank_lengths[r][i]`` is sample ``i``'s valid length inside rank ``r``'s
shard, and every shard has the same ``capacity`` (guaranteed by auto_pad),
so the global sequence is ``world_size * capacity`` long. Returns ``None``
for an all-valid mask unless a padding contract requires one.
"""
if not required and all(int(length) == capacity for lengths in per_rank_lengths for length in lengths):
return None
batch_size = len(per_rank_lengths[0])
mask = like.new_zeros(batch_size, len(per_rank_lengths) * capacity, dtype=torch.bool)
for rank, lengths in enumerate(per_rank_lengths):
base = rank * capacity
for i, length in enumerate(lengths):
mask[i, base : base + int(length)] = True
return mask
def pack_local_rotary(
context_rotary_emb: RotaryEmbedding,
ref_img_rotary_emb: RotaryEmbedding,
noise_rotary_emb: RotaryEmbedding,
encoder_seq_lengths: list[int],
ref_img_seq_lengths: list[list[int]],
img_seq_lengths: list[int],
) -> tuple[RotaryEmbedding, RotaryEmbedding, list[int], list[int]]:
"""Pack local RoPE as ``[context, references, noise]``.
Capacities stay equal across ranks; returned lengths exclude padding.
Rotary embeddings arrive as ``(cos, sin)`` pairs -- the same packing is
applied component-wise so both halves stay index-aligned.
"""
context_cos, context_sin = context_rotary_emb
ref_cos, ref_sin = ref_img_rotary_emb
noise_cos, noise_sin = noise_rotary_emb
combined_img_seq_lengths = [
sum(ref_lengths) + img_length
for ref_lengths, img_length in zip(
ref_img_seq_lengths,
img_seq_lengths,
)
]
seq_lengths = [
context_length + combined_length
for context_length, combined_length in zip(
encoder_seq_lengths,
combined_img_seq_lengths,
)
]
def _pack_component(
context_t: torch.Tensor, ref_t: torch.Tensor, noise_t: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor]:
batch_size = context_t.shape[0]
rotary_dim = context_t.shape[-1]
combined_img_capacity = ref_t.shape[1] + noise_t.shape[1]
combined_img = context_t.new_zeros(batch_size, combined_img_capacity, rotary_dim)
rotary = context_t.new_zeros(
batch_size,
context_t.shape[1] + combined_img_capacity,
rotary_dim,
)
for i, (context_length, ref_lengths, img_length) in enumerate(
zip(encoder_seq_lengths, ref_img_seq_lengths, img_seq_lengths)
):
ref_length = sum(ref_lengths)
combined_length = ref_length + img_length
combined_img[i, :ref_length] = ref_t[i, :ref_length]
combined_img[i, ref_length:combined_length] = noise_t[i, :img_length]
rotary[i, :context_length] = context_t[i, :context_length]
rotary[i, context_length : context_length + combined_length] = combined_img[i, :combined_length]
return rotary, combined_img
rotary_cos, combined_img_cos = _pack_component(context_cos, ref_cos, noise_cos)
rotary_sin, combined_img_sin = _pack_component(context_sin, ref_sin, noise_sin)
return (
(rotary_cos, rotary_sin),
(combined_img_cos, combined_img_sin),
seq_lengths,
combined_img_seq_lengths,
)
@@ -72,6 +72,7 @@ from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep impor
from vllm_omni.diffusion.offloader.config import OffloadStrategy, resolve_offload
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.utils.video_decode import decode_path_video_frames, is_video_file_path
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.entrypoints.openai.video_api_utils import positive_float
from vllm_omni.experimental.world_models.adapters.state_cosmos3_adapter import (
@@ -465,10 +466,39 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig):
return nested
return video
def _video_payload_to_frames(video: Any) -> list[Any]:
def _decode_video_file_to_frames(
path: str | os.PathLike,
*,
max_frames: int | None,
keep: str,
) -> list[PIL.Image.Image]:
rgb_frames = decode_path_video_frames(path, max_frames=max_frames, keep=keep)
return [PIL.Image.fromarray(frame).convert("RGB") for frame in rgb_frames]
def _expand_video_payload_item(
item: Any,
*,
max_frames: int | None,
keep: str,
) -> list[Any]:
if is_video_file_path(item):
return _decode_video_file_to_frames(item, max_frames=max_frames, keep=keep)
return [item]
def _video_payload_to_frames(
video: Any,
*,
max_frames: int | None,
keep: str,
) -> list[Any]:
video = _unwrap_video_payload(video)
if is_video_file_path(video):
return _decode_video_file_to_frames(video, max_frames=max_frames, keep=keep)
if isinstance(video, list):
return video
frames: list[Any] = []
for item in video:
frames.extend(_expand_video_payload_item(item, max_frames=max_frames, keep=keep))
return frames
if isinstance(video, torch.Tensor):
tensor = video.detach().cpu()
if tensor.ndim == 5:
@@ -531,11 +561,31 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig):
if "additional_information" not in prompt:
prompt["additional_information"] = {}
extra = _extra_args(request)
transfer_requested = action_mode is None and has_transfer_hints(extra)
condition_frame_indexes_vision = normalize_condition_frame_indexes_vision(
extra.get("condition_frame_indexes_vision", prompt.get("condition_frame_indexes_vision"))
)
condition_video_keep = normalize_condition_video_keep(
extra.get("condition_video_keep", prompt.get("condition_video_keep"))
)
raw_video_frames: list[Any] | None = None
transfer_input_fps: float | None = None
if raw_video is not None:
transfer_input_fps = _video_payload_fps(raw_video)
raw_video_frames = _video_payload_to_frames(raw_video)
decode_extra = dict(extra)
decode_extra["condition_frame_indexes_vision"] = list(condition_frame_indexes_vision)
decode_extra["condition_video_keep"] = condition_video_keep
decode_spec = Cosmos3OmniDiffusersPipeline.reference_video_decode_spec(
num_frames=getattr(request.sampling_params, "num_frames", None),
extra_args=decode_extra,
)
raw_video_frames = _video_payload_to_frames(
raw_video,
max_frames=decode_spec.max_frames,
keep=decode_spec.keep,
)
if not raw_video_frames:
raise TypeError("Cosmos3 video input must be a non-empty list of PIL images or image paths.")
@@ -544,8 +594,6 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig):
image = _pil_to_rgb(raw_video_frames[0])
else:
image = _pil_to_rgb(raw_image)
extra = _extra_args(request)
transfer_requested = action_mode is None and has_transfer_hints(extra)
# Resolve missing H/W.
if transfer_requested:
@@ -610,22 +658,13 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig):
dtype=torch.float32,
)
else:
condition_frame_indexes_vision = normalize_condition_frame_indexes_vision(
extra.get(
"condition_frame_indexes_vision",
prompt.get("condition_frame_indexes_vision"),
)
)
keep = normalize_condition_video_keep(
extra.get("condition_video_keep", prompt.get("condition_video_keep"))
)
max_frames = condition_pixel_frame_count(condition_frame_indexes_vision)
prompt["additional_information"]["preprocessed_video"] = _preprocess_condition_video(
raw_video_frames,
int(target_h),
int(target_w),
max_frames,
keep,
condition_video_keep,
)
prompt["additional_information"]["condition_frame_indexes_vision"] = list(
condition_frame_indexes_vision
+6 -18
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Cosmos3 transfer inference helpers.
The reference Cosmos Framework transfer path accepts one or more control hints
@@ -25,6 +25,8 @@ import PIL.Image
import torch
import torch.nn.functional as F
from vllm_omni.diffusion.utils.video_decode import decode_path_video_frames
TRANSFER_HINT_KEYS: tuple[str, ...] = ("edge", "blur", "depth", "seg", "wsm")
_TRANSFER_HINT_COMMON_FIELDS = frozenset({"control_path", "control", "control_weight"})
_TRANSFER_HINT_FIELDS: dict[str, frozenset[str]] = {
@@ -538,23 +540,9 @@ def _path_media_to_uint8_cthw(path: str | Path, max_frames: int | None) -> torch
array = _pil_to_uint8_rgb(media_path)
return torch.from_numpy(array).permute(2, 0, 1).unsqueeze(1).contiguous()
try:
import imageio.v3 as iio
except ImportError as exc:
raise ImportError(
"Cosmos3 transfer video control_path loading requires imageio. "
"Install imageio[ffmpeg] or provide decoded control frames."
) from exc
frames: list[torch.Tensor] = []
limit = max_frames if max_frames is not None else None
for frame in iio.imiter(media_path):
frames.append(torch.from_numpy(_pil_to_uint8_rgb(frame)).permute(2, 0, 1))
if limit is not None and len(frames) >= int(limit):
break
if not frames:
raise ValueError(f"Cosmos3 transfer control_path produced no frames: {media_path}")
return torch.stack(frames, dim=1).contiguous()
rgb_frames = decode_path_video_frames(media_path, max_frames=max_frames, keep="first")
stacked = [torch.from_numpy(frame).permute(2, 0, 1) for frame in rgb_frames]
return torch.stack(stacked, dim=1).contiguous()
def media_to_uint8_cthw(value: Any, *, height: int, width: int, max_frames: int | None = None) -> torch.Tensor:
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more