Compare commits

...
Author SHA1 Message Date
hsliuustc0106 8affabd527 update release
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-03-31 20:39:22 +08:00
noobHappylife 7cb7b3e8ad Fix Qwen3-TTS gradio demo (#2372)
Signed-off-by: noobhappylife <aratar1991@hotmail.com>
2026-03-31 11:42:09 +00:00
Reid de1ac5ee88 [Bugfix] Fix case-sensitivity in Qwen3 TTS speaker name lookup (#2358)
Signed-off-by: reidliu41 <reid201711@gmail.com>
2026-03-31 09:51:35 +00:00
WeiQing Chen e68ca4aa5c [CI]test: add wan22 i2v video similarity e2e (#2262)
Signed-off-by: David Chen <530634352@qq.com>
2026-03-31 16:15:47 +08:00
WeiQing Chen 0cbd1cbeb8 [CI] fix test: use minimum supported layered output count (#2350)
Signed-off-by: David Chen <530634352@qq.com>
2026-03-31 14:59:00 +08:00
wangyuandAlicia c1a978ae8d [Test] Add qwen3-omni tests for audio_in_video and one word prompt (#2097)
Signed-off-by: yenuo26 <410167048@qq.com>
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-03-31 14:30:12 +08:00
Yueqian Lin 1734c1ec23 [Test] Fix 4 broken Qwen3-TTS async chunk unit tests (#2351)
Signed-off-by: linyueqian <linyueqian@outlook.com>
2026-03-31 00:45:13 -04:00
LLandCanlin Guo 12a8f57934 Fix Qwen3-TTS Base on NPU running failed (#2353)
Signed-off-by: LL <rimchew@foxmail.com>
Signed-off-by: Canlin Guo <canlinguosdu@gmail.com>
Co-authored-by: Canlin Guo <canlinguosdu@gmail.com>
2026-03-31 12:42:21 +08:00
16 changed files with 1306 additions and 114 deletions

No files matched your search

+42
View File
@@ -355,6 +355,48 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: Wan22 I2V Accuracy Test with H100"
key: nightly-wan22-i2v-accuracy
timeout_in_minutes: 180
depends_on: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
commands:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py --run-level advanced_model
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:c392ce21e9cf9ea65c52b866447793db10e0261c
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: Qwen-Image Diffusion Perf Test with H100"
key: nightly-qwen-image-performance
timeout_in_minutes: 300
+36 -38
View File
@@ -216,44 +216,42 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
# - label: "Omni Model Test with H100"
# depends_on: upload-ready-pipeline
# commands:
# - |
# timeout 20m bash -c '
# export VLLM_WORKER_MULTIPROC_METHOD=spawn
# export VLLM_TEST_CLEAN_GPU_MEMORY="1"
# # - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py
# pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "core_model" --run-level "core_model"
# '
# agents:
# queue: "mithril-h100-pool"
# plugins:
# - kubernetes:
# podSpec:
# containers:
# - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
# resources:
# limits:
# nvidia.com/gpu: 2
# volumeMounts:
# - name: devshm
# mountPath: /dev/shm
# - name: hf-cache
# mountPath: /root/.cache/huggingface
# env:
# - name: HF_HOME
# value: /root/.cache/huggingface
# nodeSelector:
# node.kubernetes.io/instance-type: gpu-h100-sxm
# volumes:
# - name: devshm
# emptyDir:
# medium: Memory
# - name: hf-cache
# hostPath:
# path: /mnt/hf-cache
# type: DirectoryOrCreate
- label: "Omni Model Test with H100"
depends_on: upload-ready-pipeline
commands:
- |
timeout 20m bash -c '
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "core_model" --run-level "core_model"
'
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: "MiMo-Audio E2E Test with H100"
depends_on: upload-ready-pipeline
+1 -1
View File
@@ -16,7 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone
---
*Latest News* 🔥
- [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments.
- [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup!
- [2026/03] **[vllm-omni-skills](https://github.com/hsliuustc0106/vllm-omni-skills)** is a community-driven collection of AI assistant skills that help developers work with vLLM-Omni more effectively. These skills can be used with popular agentic AI coding assistants like **Cursor IDE**, **Claude**, **Codex**, and more.
- [2026/02] We released [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0) - A major alignment + capability release that rebases onto **upstream vLLM v0.16.0** and significantly expands performance, distributed execution, and production readiness across **Qwen3-Omni / Qwen3-TTS**, **Bagel**, **MiMo-Audio**, **GLM-Image** and the **Diffusion (DiT) image/video stack**—while also improving platform coverage (CUDA / ROCm / NPU / XPU), CI quality, and documentation.
@@ -125,7 +125,7 @@ PLAYER_HTML = """
def _build_player_js(sample_rate: int) -> str:
"""Build the JavaScript that powers the AudioWorklet player."""
return f"""
() => {{
<script>
const SR = {sample_rate};
const WC = {json.dumps(WORKLET_JS)};
let ctx = null, node = null, abort = null, gen = false, st = {{}};
@@ -284,7 +284,7 @@ def _build_player_js(sample_rate: int) -> str:
}}
}}
}};
}}
</script>
"""
@@ -435,10 +435,7 @@ def create_app(api_base: str):
)
with gr.Blocks(
css=css,
title="Qwen3-TTS Demo",
js=_build_player_js(PCM_SAMPLE_RATE),
theme=theme,
) as demo:
gr.HTML(f"""
<div style="display:flex; align-items:center; gap:16px; margin-bottom:8px;">
@@ -773,7 +770,14 @@ def create_app(api_base: str):
demo.queue()
return gr.mount_gradio_app(fastapi_app, demo, path="/")
return gr.mount_gradio_app(
fastapi_app,
demo,
path="/",
css=css,
theme=theme,
head=_build_player_js(PCM_SAMPLE_RATE),
)
def parse_args():
@@ -103,7 +103,7 @@ def build_payload(
if task_type == "CustomVoice":
if voice:
payload["speaker"] = voice
payload["voice"] = voice
if instructions and instructions.strip():
payload["instructions"] = instructions.strip()
+163 -38
View File
@@ -688,8 +688,112 @@ def generate_synthetic_audio(
return result
def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_file: bool = False) -> dict[str, Any]:
"""Generate synthetic video with bouncing balls and return base64 string."""
def _mux_mp4_bytes_with_synthetic_audio(
video_mp4_bytes: bytes,
*,
num_frames: int,
fps: float = 30.0,
sample_rate: int = 48000,
) -> bytes:
"""
Mux a video-only MP4 with mono TTS audio from :func:`generate_synthetic_audio` (AAC).
Audio length is at least the video duration in whole seconds (rounded up); ffmpeg
``-shortest`` trims to the video when the WAV is longer.
Uses ffmpeg from ``imageio_ffmpeg`` when available, else ``ffmpeg`` on PATH.
If TTS or mux fails, returns ``video_mp4_bytes`` unchanged.
Mux subprocess does **not** use ``capture_output=True``: ffmpeg can block writing
to a full stderr pipe while :func:`subprocess.run` waits for exit (classic deadlock).
"""
duration_sec = num_frames / fps if fps > 0 else 0.0
# generate_synthetic_audio(duration=int) uses at least 1s of buffer internally
duration_int = max(1, int(math.ceil(duration_sec)))
try:
audio_result = generate_synthetic_audio(
duration=duration_int,
num_channels=1,
sample_rate=sample_rate,
save_to_file=False,
)
audio_pcm = audio_result["np_array"]
except Exception as e:
logger.warning("Synthetic video: generate_synthetic_audio failed (%s); using video-only MP4.", e)
return video_mp4_bytes
try:
import imageio_ffmpeg
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
ffmpeg_exe = "ffmpeg"
import tempfile
try:
with tempfile.TemporaryDirectory(prefix="syn_vid_mux_") as tmp:
vid_path = os.path.join(tmp, "video.mp4")
wav_path = os.path.join(tmp, "audio.wav")
out_path = os.path.join(tmp, "out.mp4")
with open(vid_path, "wb") as f:
f.write(video_mp4_bytes)
sf.write(wav_path, audio_pcm, sample_rate, format="WAV", subtype="PCM_16")
cmd = [
ffmpeg_exe,
"-y",
"-nostdin",
"-hide_banner",
"-loglevel",
"error",
"-i",
vid_path,
"-i",
wav_path,
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-movflags",
"+faststart",
out_path,
]
subprocess.run(
cmd,
check=True,
stdin=subprocess.DEVNULL,
timeout=300,
)
with open(out_path, "rb") as f:
return f.read()
except (
FileNotFoundError,
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
OSError,
) as e:
logger.warning("Synthetic video: audio mux failed (%s); using video-only MP4.", e)
return video_mp4_bytes
def generate_synthetic_video(
width: int,
height: int,
num_frames: int,
save_to_file: bool = False,
*,
embed_audio: bool = False,
) -> dict[str, Any]:
"""Generate synthetic video with bouncing balls and base64 MP4.
When ``embed_audio`` is True, muxes mono AAC from :func:`generate_synthetic_audio`
(TTS + ffmpeg) into the MP4; otherwise returns video-only MP4 (faster when tests do
not need an audio track).
"""
import cv2
import imageio
@@ -762,13 +866,13 @@ def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_f
result = {
"np_array": video_array,
}
video_bytes = None
saved_file_path = None
fps = 30
buffer = io.BytesIO()
writer_kwargs = {
"format": "mp4",
"fps": 30,
"fps": fps,
"codec": "libx264",
"quality": 7,
"pixelformat": "yuv420p",
@@ -787,32 +891,31 @@ def generate_synthetic_video(width: int, height: int, num_frames: int, save_to_f
],
}
if save_to_file:
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"video_{width}x{height}_{timestamp}.mp4"
try:
with imageio.get_writer(output_path, **writer_kwargs) as writer:
for frame in video_frames:
writer.append_data(frame)
saved_file_path = output_path
print(f"Video saved to: {saved_file_path}")
with open(output_path, "rb") as f:
video_bytes = f.read()
except Exception as e:
print(f"Warning: Failed to save video to file {output_path}: {e}")
save_to_file = False
if not save_to_file or video_bytes is None:
try:
with imageio.get_writer(buffer, **writer_kwargs) as writer:
for frame in video_frames:
writer.append_data(frame)
buffer.seek(0)
video_bytes = buffer.read()
video_only_bytes = buffer.read()
except Exception as e:
print(f"Warning: Failed to encode synthetic video: {e}")
raise
if embed_audio:
video_bytes = _mux_mp4_bytes_with_synthetic_audio(video_only_bytes, num_frames=num_frames, fps=float(fps))
else:
video_bytes = video_only_bytes
if save_to_file:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"video_{width}x{height}_{timestamp}.mp4"
try:
with open(output_path, "wb") as f:
f.write(video_bytes)
saved_file_path = output_path
print(f"Video saved to: {saved_file_path}")
except Exception as e:
print(f"Warning: Failed to save video to file {output_path}: {e}")
base64_video = base64.b64encode(video_bytes).decode("utf-8")
@@ -1262,10 +1365,12 @@ def modify_stage_config(
# Direct top-level key
config[key] = value
# Save to new file with timestamp
timestamp = int(time.time())
# Unique suffix: multiple modify_stage_config calls in one process often run
# within the same second (e.g. test_qwen3_omni_expansion imports both
# get_chunk_config and get_batch_token_config). int(time.time()) would collide
# and the later write would overwrite the earlier YAML on disk.
base_name = yaml_path.rsplit(".", 1)[0] if "." in yaml_path else yaml_path
output_path = f"{base_name}_{timestamp}.yaml"
output_path = f"{base_name}_{time.time_ns()}.yaml"
with open(output_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=None, sort_keys=False, allow_unicode=True, indent=2)
@@ -1733,9 +1838,11 @@ def assert_omni_response(response: OmniResponse, request_config: dict[str, Any],
"The output does not contain any of the keywords."
)
# Verify similarity
# Verify similarity (Whisper transcript vs streamed/detokenized text)
if "text" in modalities and "audio" in modalities:
assert response.similarity > 0.9, "The audio content is not same as the text"
assert response.similarity is not None and response.similarity > 0.9, (
"The audio content is not same as the text"
)
print(f"similarity is: {response.similarity}")
@@ -2135,7 +2242,11 @@ class OpenAIClientHandler:
Send OpenAI requests.
Args:
request_config: Request configuration dictionary containing parameters like model, messages, stream
request_config: Request configuration dictionary containing parameters like model, messages, stream.
Optional ``use_audio_in_video`` (bool): when true, sets
``extra_body["mm_processor_kwargs"] = {"use_audio_in_video": True}`` for Qwen-Omni video+audio
extraction (merged with any existing ``extra_body`` / ``mm_processor_kwargs``).
Optional ``extra_body`` (dict): passed through to ``chat.completions.create`` after merge.
request_num: Number of requests, defaults to 1 (single request)
Returns:
@@ -2146,14 +2257,28 @@ class OpenAIClientHandler:
stream = request_config.get("stream", False)
modalities = request_config.get("modalities", ["text", "audio"])
extra_body: dict[str, Any] = {}
raw_extra = request_config.get("extra_body")
if raw_extra:
extra_body.update(raw_extra)
if request_config.get("use_audio_in_video"):
mm = dict(extra_body.get("mm_processor_kwargs") or {})
mm["use_audio_in_video"] = True
extra_body["mm_processor_kwargs"] = mm
extra_body_arg: dict[str, Any] | None = extra_body if extra_body else None
create_kwargs: dict[str, Any] = {
"model": request_config.get("model"),
"messages": request_config.get("messages"),
"stream": stream,
"modalities": modalities,
}
if extra_body_arg is not None:
create_kwargs["extra_body"] = extra_body_arg
if request_num == 1:
# Send single request
chat_completion = self.client.chat.completions.create(
model=request_config.get("model"),
messages=request_config.get("messages"),
stream=stream,
modalities=modalities,
)
chat_completion = self.client.chat.completions.create(**create_kwargs)
if stream:
response = self._process_stream_omni_response(chat_completion)
+24
View File
@@ -47,6 +47,19 @@ def pytest_addoption(parser):
help="Balanced sample count per GEdit task group",
)
group.addoption("--accuracy-workers", action="store", type=int, default=1, help="Worker count for accuracy benches")
group.addoption(
"--wan22-i2v-image-source",
action="store",
default=None,
help="Image source for Wan2.2 I2V accuracy tests. Can be local path or remote URL.",
)
group.addoption(
"--wan22-i2v-online-timeout-seconds",
action="store",
type=int,
default=1200,
help="Online serving timeout in seconds for Wan2.2 I2V accuracy tests.",
)
def _hf_cache_root() -> Path:
@@ -142,6 +155,17 @@ def accuracy_workers(request: pytest.FixtureRequest) -> int:
return int(request.config.getoption("accuracy_workers"))
@pytest.fixture(scope="session")
def wan22_i2v_image_source(request: pytest.FixtureRequest) -> str | None:
value = request.config.getoption("wan22_i2v_image_source")
return str(value) if value else None
@pytest.fixture(scope="session")
def wan22_i2v_online_timeout_seconds(request: pytest.FixtureRequest) -> int:
return int(request.config.getoption("wan22_i2v_online_timeout_seconds"))
@pytest.fixture(scope="session")
def gebench_samples_per_type(request: pytest.FixtureRequest) -> int:
return int(request.config.getoption("gebench_samples_per_type"))
Whitespace-only changes.
@@ -0,0 +1,165 @@
from __future__ import annotations
import argparse
import base64
import json
from io import BytesIO
from pathlib import Path
import requests
import torch
from diffusers import UniPCMultistepScheduler, WanImageToVideoPipeline
from diffusers.pipelines.wan import pipeline_wan_i2v as wan_i2v_module
from diffusers.utils import export_to_video, load_image
from PIL import Image
from tests.e2e.accuracy.wan22_i2v.wan22_i2v_video_similarity_common import BOUNDARY_RATIO
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Wan2.2 I2V diffusers offline generation.")
parser.add_argument("--model", required=True)
parser.add_argument("--image-source", required=True)
parser.add_argument("--prompt", required=True)
parser.add_argument("--negative-prompt", required=True)
parser.add_argument("--size", required=True)
parser.add_argument("--fps", type=int, required=True)
parser.add_argument("--num-frames", type=int, required=True)
parser.add_argument("--guidance-scale", type=float, required=True)
parser.add_argument("--guidance-scale-2", type=float, required=True)
parser.add_argument("--flow-shift", type=float, required=True)
parser.add_argument("--num-inference-steps", type=int, required=True)
parser.add_argument("--seed", type=int, required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--metadata-output", required=True)
return parser.parse_args()
def _parse_size(size: str) -> tuple[int, int]:
width_str, height_str = size.lower().split("x", 1)
return int(width_str), int(height_str)
class _IdentityFtfy:
@staticmethod
def fix_text(text: str) -> str:
return text
def _ensure_wan_ftfy_fallback() -> None:
if not hasattr(wan_i2v_module, "ftfy"):
wan_i2v_module.ftfy = _IdentityFtfy()
def _offline_cuda_device() -> torch.device:
return torch.device("cuda:0")
def _load_input_image(source: str) -> Image.Image:
if source.startswith("data:image"):
_, encoded = source.split(",", 1)
image = Image.open(BytesIO(base64.b64decode(encoded)))
image.load()
return image.convert("RGB")
source_path = Path(source)
if source_path.exists():
image = Image.open(source_path)
image.load()
return image.convert("RGB")
image = load_image(source)
if isinstance(image, Image.Image):
image.load()
return image.convert("RGB")
response = requests.get(source, timeout=60)
response.raise_for_status()
image = Image.open(BytesIO(response.content))
image.load()
return image.convert("RGB")
def _resize_to_target(image: Image.Image, *, width: int, height: int) -> Image.Image:
return image.resize((width, height), Image.Resampling.LANCZOS)
def _configure_scheduler(pipe: WanImageToVideoPipeline, *, flow_shift: float) -> None:
pipe.scheduler = UniPCMultistepScheduler.from_config(
pipe.scheduler.config,
flow_shift=flow_shift,
)
def _write_metadata(
path: Path,
*,
args: argparse.Namespace,
width: int,
height: int,
frame_count: int,
) -> None:
payload = {
"model": args.model,
"image_source": args.image_source,
"size": args.size,
"width": width,
"height": height,
"fps": args.fps,
"num_frames": args.num_frames,
"actual_frame_count": frame_count,
"guidance_scale": args.guidance_scale,
"guidance_scale_2": args.guidance_scale_2,
"boundary_ratio": BOUNDARY_RATIO,
"flow_shift": args.flow_shift,
"num_inference_steps": args.num_inference_steps,
"seed": args.seed,
"world_size": 1,
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def main() -> int:
args = _parse_args()
device = _offline_cuda_device()
torch.cuda.set_device(device)
_ensure_wan_ftfy_fallback()
pipe = WanImageToVideoPipeline.from_pretrained(args.model, torch_dtype=torch.bfloat16)
pipe.register_to_config(boundary_ratio=BOUNDARY_RATIO)
_configure_scheduler(pipe, flow_shift=args.flow_shift)
pipe.to(device)
pipe.set_progress_bar_config(disable=False)
input_image = _load_input_image(args.image_source)
width, height = _parse_size(args.size)
resized_image = _resize_to_target(input_image, width=width, height=height)
generator = torch.Generator(device=device.type).manual_seed(args.seed)
frames = pipe(
image=resized_image,
prompt=args.prompt,
negative_prompt=args.negative_prompt,
height=height,
width=width,
num_frames=args.num_frames,
guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_2,
num_inference_steps=args.num_inference_steps,
generator=generator,
).frames[0]
output_path = Path(args.output)
metadata_path = Path(args.metadata_output)
output_path.parent.mkdir(parents=True, exist_ok=True)
export_to_video(frames, str(output_path), fps=args.fps)
_write_metadata(metadata_path, args=args, width=width, height=height, frame_count=len(frames))
if hasattr(pipe, "maybe_free_model_hooks"):
pipe.maybe_free_model_hooks()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,672 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import time
from base64 import b64decode, b64encode
from fractions import Fraction
from hashlib import sha1
from io import BytesIO
from mimetypes import guess_type
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import pytest
import requests
import torch
from diffusers import UniPCMultistepScheduler
from PIL import Image
from tests.conftest import OmniServerParams
from tests.e2e.accuracy.wan22_i2v.run_wan22_i2v_diffusers_cp import (
_configure_scheduler,
_ensure_wan_ftfy_fallback,
_IdentityFtfy,
_offline_cuda_device,
_resize_to_target,
)
from tests.e2e.accuracy.wan22_i2v.wan22_i2v_video_similarity_common import (
FLOW_SHIFT,
FPS,
GUIDANCE_SCALE,
GUIDANCE_SCALE_2,
HEIGHT,
MODEL_NAME,
NEGATIVE_PROMPT,
NUM_FRAMES,
NUM_INFERENCE_STEPS,
PROMPT,
PSNR_THRESHOLD,
RABBIT_IMAGE_URL,
SEED,
SIZE,
SSIM_THRESHOLD,
WIDTH,
)
from tests.utils import hardware_test
def test_parse_video_metadata_extracts_dimensions_and_fps() -> None:
payload = {
"streams": [
{
"width": 832,
"height": 480,
"avg_frame_rate": "16/1",
"nb_read_frames": "93",
}
]
}
metadata = _parse_video_metadata(payload)
assert metadata["width"] == 832
assert metadata["height"] == 480
assert metadata["fps"] == 16.0
assert metadata["frame_count"] == 93
def test_parse_ssim_summary_extracts_all_score() -> None:
output = """
[Parsed_ssim_0 @ 000001] SSIM Y:0.971903 (15.512007) U:0.965077 (14.569044) V:0.962414 (14.252637) All:0.968311 (15.035654)
"""
assert _parse_ssim_score(output) == 0.968311
def test_parse_psnr_summary_extracts_average_score() -> None:
output = """
[Parsed_psnr_0 @ 000001] PSNR y:32.670157 u:31.844621 v:31.513839 average:32.148004 min:31.563744 max:33.201457
"""
assert _parse_psnr_score(output) == 32.148004
def test_build_diffusers_command_uses_python_runner_path(tmp_path: Path) -> None:
runner_path = tmp_path / "run_wan22_i2v_diffusers_cp.py"
command = _build_diffusers_command(
runner_path=runner_path,
image_source=RABBIT_IMAGE_URL,
output_path=tmp_path / "offline.mp4",
metadata_path=tmp_path / "offline.json",
)
assert command[:2] == [
sys.executable,
str(runner_path),
]
assert "--output" in command
assert "--metadata-output" in command
def test_resolve_image_source_prefers_existing_local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
image_path = tmp_path / "rabbit.png"
Image.new("RGB", (8, 8), color=(255, 128, 64)).save(image_path)
assert _resolve_image_source(str(image_path)) == str(image_path.resolve())
def test_build_online_image_reference_uses_data_url_for_local_path(tmp_path: Path) -> None:
image_path = tmp_path / "rabbit.png"
Image.new("RGB", (4, 2), color=(10, 20, 30)).save(image_path)
reference = _build_online_image_reference(str(image_path))
assert reference.startswith("data:image/png;base64,")
encoded = reference.split(",", 1)[1]
assert len(b64decode(encoded)) > 0
def test_send_video_request_with_timeout_uses_requested_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
class DummyResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, str]:
return {"id": "video_123"}
class DummyClient:
def __init__(self) -> None:
self.run_level = "core_model"
self.wait_args: tuple[str, int, int] | None = None
def _build_url(self, path: str) -> str:
return f"http://localhost:8000{path}"
def _wait_until_video_completed(
self,
video_id: str,
poll_interval_seconds: int = 2,
timeout_seconds: int = 300,
) -> None:
self.wait_args = (video_id, poll_interval_seconds, timeout_seconds)
def _download_video_content(self, video_id: str) -> bytes:
return b"video-bytes"
monkeypatch.setattr(requests, "post", lambda *args, **kwargs: DummyResponse())
client = DummyClient()
result = _send_video_request_with_timeout(
client,
{
"form_data": {"prompt": "test"},
"image_reference": RABBIT_IMAGE_URL,
},
timeout_seconds=1200,
)
assert result == b"video-bytes"
assert client.wait_args == ("video_123", 2, 1200)
def test_online_timeout_defaults_to_1200() -> None:
assert _online_timeout_seconds(None) == 1200
def test_artifact_dir_is_under_repo_result_folder(tmp_path: Path) -> None:
artifact_dir = _artifact_dir(str(tmp_path / "rabbit.png"))
assert artifact_dir.parent == Path(__file__).parent / "result"
assert artifact_dir.name.startswith("rabbit-")
def test_offline_cuda_device_uses_indexed_cuda_device() -> None:
assert _offline_cuda_device() == torch.device("cuda:0")
def test_ensure_wan_ftfy_fallback_sets_identity(monkeypatch: pytest.MonkeyPatch) -> None:
from diffusers.pipelines.wan import pipeline_wan_i2v as wan_i2v_module
monkeypatch.delattr(wan_i2v_module, "ftfy", raising=False)
_ensure_wan_ftfy_fallback()
assert hasattr(wan_i2v_module, "ftfy")
assert isinstance(wan_i2v_module.ftfy, _IdentityFtfy)
assert wan_i2v_module.ftfy.fix_text("abc") == "abc"
def test_resize_to_target_matches_requested_dimensions() -> None:
image = Image.new("RGB", (100, 50), color=(1, 2, 3))
resized = _resize_to_target(image, width=832, height=480)
assert resized.size == (832, 480)
def test_configure_scheduler_switches_to_unipc() -> None:
class DummyPipe:
def __init__(self) -> None:
self.scheduler = UniPCMultistepScheduler(num_train_timesteps=1000, flow_shift=1.0)
pipe = DummyPipe()
_configure_scheduler(pipe, flow_shift=5.0)
assert isinstance(pipe.scheduler, UniPCMultistepScheduler)
assert pipe.scheduler.config.flow_shift == 5.0
REPO_ROOT = Path(__file__).resolve().parents[4]
WORKSPACE_ROOT = REPO_ROOT.parent
RUNNER_PATH = Path(__file__).with_name("run_wan22_i2v_diffusers_cp.py")
RESULT_ROOT = Path(__file__).parent / "result"
VIDEO_TIMEOUT_SECONDS = 60 * 60
_SSIM_RE = re.compile(r"All:(?P<score>[0-9.]+)")
_PSNR_RE = re.compile(r"average:(?P<score>[0-9.]+)")
SERVER_CASES = [
pytest.param(
OmniServerParams(
model=MODEL_NAME,
server_args=[
"--usp",
"2",
"--use-hsdp",
"--hsdp-shard-size",
"2",
],
use_omni=True,
),
id="wan22_i2v_usp2_hsdp2",
)
]
def _parse_video_metadata(payload: dict[str, Any]) -> dict[str, int | float]:
streams = payload.get("streams")
if not isinstance(streams, list) or not streams:
raise ValueError(f"ffprobe payload did not include video streams: {payload}")
stream = streams[0]
width = int(stream["width"])
height = int(stream["height"])
fps = float(Fraction(stream["avg_frame_rate"]))
frame_count_value = stream.get("nb_read_frames") or stream.get("nb_frames")
if frame_count_value is None:
raise ValueError(f"ffprobe payload did not include frame count: {payload}")
frame_count = int(frame_count_value)
return {
"width": width,
"height": height,
"fps": fps,
"frame_count": frame_count,
}
def _parse_ssim_score(output: str) -> float:
match = _SSIM_RE.search(output)
if match is None:
raise ValueError(f"Could not parse SSIM score from ffmpeg output:\n{output}")
return float(match.group("score"))
def _parse_psnr_score(output: str) -> float:
match = _PSNR_RE.search(output)
if match is None:
raise ValueError(f"Could not parse PSNR score from ffmpeg output:\n{output}")
return float(match.group("score"))
def _probe_binary(binary: str) -> str:
resolved = shutil.which(binary)
if resolved is None:
pytest.skip(f"{binary} is required for Wan2.2 video similarity e2e test.")
return resolved
def _build_diffusers_command(
*,
runner_path: Path,
image_source: str,
output_path: Path,
metadata_path: Path,
) -> list[str]:
return [
sys.executable,
str(runner_path),
"--model",
MODEL_NAME,
"--image-source",
image_source,
"--prompt",
PROMPT,
"--negative-prompt",
NEGATIVE_PROMPT,
"--size",
SIZE,
"--fps",
str(FPS),
"--num-frames",
str(NUM_FRAMES),
"--guidance-scale",
str(GUIDANCE_SCALE),
"--guidance-scale-2",
str(GUIDANCE_SCALE_2),
"--flow-shift",
str(FLOW_SHIFT),
"--num-inference-steps",
str(NUM_INFERENCE_STEPS),
"--seed",
str(SEED),
"--output",
str(output_path),
"--metadata-output",
str(metadata_path),
]
def _runner_env() -> dict[str, str]:
env = os.environ.copy()
pythonpath_parts = [
str(REPO_ROOT),
str(WORKSPACE_ROOT / "diffusers" / "src"),
]
existing_pythonpath = env.get("PYTHONPATH")
if existing_pythonpath:
pythonpath_parts.append(existing_pythonpath)
env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
env.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
return env
def _resolve_image_source(configured: str | None) -> str:
configured = configured or RABBIT_IMAGE_URL
candidate = Path(configured)
if candidate.exists():
return str(candidate.resolve())
return configured
def _is_remote_image_source(source: str) -> bool:
return source.startswith("http://") or source.startswith("https://")
def _validate_image_source(source: str) -> None:
if _is_remote_image_source(source):
response = requests.get(source, timeout=60)
response.raise_for_status()
return
image_path = Path(source)
if not image_path.exists():
raise FileNotFoundError(f"Local image source does not exist: {image_path}")
def _build_online_image_reference(source: str) -> str:
if _is_remote_image_source(source) or source.startswith("data:image"):
return source
image_path = Path(source)
mime_type = guess_type(image_path.name)[0] or "image/png"
encoded = b64encode(image_path.read_bytes()).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
def _probe_video(path: Path) -> dict[str, int | float]:
_probe_binary("ffprobe")
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-count_frames",
"-show_entries",
"stream=width,height,avg_frame_rate,nb_read_frames",
"-of",
"json",
str(path),
],
capture_output=True,
text=True,
check=True,
)
return _parse_video_metadata(json.loads(result.stdout))
def _run_ffmpeg_similarity(filter_name: str, first: Path, second: Path) -> str:
_probe_binary("ffmpeg")
result = subprocess.run(
[
"ffmpeg",
"-hide_banner",
"-i",
str(first),
"-i",
str(second),
"-lavfi",
f"[0:v][1:v]{filter_name}",
"-f",
"null",
"-",
],
capture_output=True,
text=True,
check=True,
)
return result.stderr
def _online_timeout_seconds(configured: int | None) -> int:
return int(configured) if configured is not None else 1200
def _artifact_dir(image_source: str) -> Path:
if _is_remote_image_source(image_source):
source_name = Path(urlparse(image_source).path).stem or "remote"
else:
source_name = Path(image_source).stem or "local"
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", source_name)
digest = sha1(image_source.encode("utf-8")).hexdigest()[:8]
return RESULT_ROOT / f"{safe_name}-{digest}"
def _artifact_paths(image_source: str) -> tuple[Path, Path, Path]:
artifact_dir = _artifact_dir(image_source)
artifact_dir.mkdir(parents=True, exist_ok=True)
return (
artifact_dir / "online.mp4",
artifact_dir / "offline.mp4",
artifact_dir / "offline_metadata.json",
)
def _send_video_request_with_timeout(
openai_client,
request_config: dict[str, Any],
*,
timeout_seconds: int,
) -> bytes:
form_data = request_config.get("form_data")
if not isinstance(form_data, dict):
raise ValueError("Video request_config must contain 'form_data'")
if not form_data.get("prompt"):
raise ValueError("Video request_config['form_data'] must contain 'prompt'")
normalized_form_data = {key: str(value) for key, value in form_data.items() if value is not None}
files: dict[str, tuple[str, BytesIO, str]] = {}
image_reference = request_config.get("image_reference")
if image_reference:
if image_reference.startswith("data:image"):
header, encoded = image_reference.split(",", 1)
content_type = header.split(";")[0].removeprefix("data:")
extension = content_type.split("/")[-1]
file_data = b64decode(encoded)
files["input_reference"] = (
f"reference.{extension}",
BytesIO(file_data),
content_type,
)
else:
normalized_form_data["image_reference"] = json.dumps({"image_url": image_reference})
start_time = time.perf_counter()
create_url = openai_client._build_url("/v1/videos")
response = requests.post(
create_url,
data=normalized_form_data,
files=files,
headers={"Accept": "application/json"},
timeout=60,
)
response.raise_for_status()
job_data = response.json()
video_id = job_data["id"]
openai_client._wait_until_video_completed(video_id, timeout_seconds=timeout_seconds)
video_content = openai_client._download_video_content(video_id)
print(f"online_video_e2e_latency_s={time.perf_counter() - start_time:.3f}")
return video_content
def _generate_online_video(
*,
omni_server,
openai_client,
image_source: str,
online_timeout_seconds: int,
) -> Path:
online_path, _, _ = _artifact_paths(image_source)
request_config = {
"model": omni_server.model,
"form_data": {
"prompt": PROMPT,
"negative_prompt": NEGATIVE_PROMPT,
"size": SIZE,
"fps": FPS,
"num_frames": NUM_FRAMES,
"guidance_scale": GUIDANCE_SCALE,
"guidance_scale_2": GUIDANCE_SCALE_2,
"flow_shift": FLOW_SHIFT,
"num_inference_steps": NUM_INFERENCE_STEPS,
"seed": SEED,
},
"image_reference": _build_online_image_reference(image_source),
}
online_video_bytes = _send_video_request_with_timeout(
openai_client,
request_config,
timeout_seconds=_online_timeout_seconds(online_timeout_seconds),
)
online_path.write_bytes(online_video_bytes)
return online_path
def _generate_offline_video(*, image_source: str) -> tuple[Path, Path]:
_, offline_path, offline_metadata_path = _artifact_paths(image_source)
command = _build_diffusers_command(
runner_path=RUNNER_PATH,
image_source=image_source,
output_path=offline_path,
metadata_path=offline_metadata_path,
)
subprocess.run(
command,
cwd=REPO_ROOT,
env=_runner_env(),
check=True,
timeout=VIDEO_TIMEOUT_SECONDS,
)
return offline_path, offline_metadata_path
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_wan22_i2v_diffusers_offline_generates_video(
wan22_i2v_image_source: str | None,
) -> None:
if not torch.cuda.is_available():
pytest.skip("Wan2.2 I2V diffusers offline test requires CUDA.")
_probe_binary("ffprobe")
if not RUNNER_PATH.exists():
raise AssertionError(f"Offline diffusers runner does not exist: {RUNNER_PATH}")
image_source = _resolve_image_source(wan22_i2v_image_source)
_validate_image_source(image_source)
offline_path, offline_metadata_path = _generate_offline_video(image_source=image_source)
assert offline_path.exists(), f"Expected offline video artifact at {offline_path}"
assert offline_metadata_path.exists(), f"Expected offline metadata artifact at {offline_metadata_path}"
offline_metadata = _probe_video(offline_path)
assert offline_metadata["width"] == WIDTH
assert offline_metadata["height"] == HEIGHT
assert offline_metadata["fps"] == float(FPS)
assert offline_metadata["frame_count"] == NUM_FRAMES
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
def test_wan22_i2v_online_serving_generates_video(
omni_server,
openai_client,
wan22_i2v_image_source: str | None,
wan22_i2v_online_timeout_seconds: int,
) -> None:
if not torch.cuda.is_available() or torch.cuda.device_count() < 2:
pytest.skip("Wan2.2 I2V similarity e2e test requires >= 2 CUDA GPUs.")
_probe_binary("ffprobe")
image_source = _resolve_image_source(wan22_i2v_image_source)
_validate_image_source(image_source)
online_path = _generate_online_video(
omni_server=omni_server,
openai_client=openai_client,
image_source=image_source,
online_timeout_seconds=wan22_i2v_online_timeout_seconds,
)
assert online_path.exists(), f"Expected online video artifact at {online_path}"
online_metadata = _probe_video(online_path)
assert online_metadata["width"] == WIDTH
assert online_metadata["height"] == HEIGHT
assert online_metadata["fps"] == float(FPS)
assert online_metadata["frame_count"] == NUM_FRAMES
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_wan22_i2v_serving_matches_diffusers_video_similarity(
wan22_i2v_image_source: str | None,
) -> None:
if not torch.cuda.is_available() or torch.cuda.device_count() < 2:
pytest.skip("Wan2.2 I2V similarity e2e test requires >= 2 CUDA GPUs.")
_probe_binary("ffmpeg")
_probe_binary("ffprobe")
if not RUNNER_PATH.exists():
raise AssertionError(f"Offline diffusers runner does not exist: {RUNNER_PATH}")
image_source = _resolve_image_source(wan22_i2v_image_source)
_validate_image_source(image_source)
online_path, offline_path, offline_metadata_path = _artifact_paths(image_source)
if not online_path.exists():
pytest.skip(f"Missing online artifact from prerequisite test: {online_path}")
if not offline_path.exists() or not offline_metadata_path.exists():
pytest.skip(f"Missing offline artifacts from prerequisite test: {offline_path}, {offline_metadata_path}")
assert online_path.exists(), f"Expected online video artifact at {online_path}"
assert offline_path.exists(), f"Expected offline video artifact at {offline_path}"
assert offline_metadata_path.exists(), f"Expected offline metadata artifact at {offline_metadata_path}"
online_metadata = _probe_video(online_path)
offline_metadata = _probe_video(offline_path)
assert online_metadata == offline_metadata, (
f"Video metadata mismatch:\n"
f"online={online_metadata}\n"
f"offline={offline_metadata}\n"
f"online_path={online_path}\n"
f"offline_path={offline_path}"
)
assert online_metadata["width"] == WIDTH
assert online_metadata["height"] == HEIGHT
assert online_metadata["fps"] == float(FPS)
assert online_metadata["frame_count"] == NUM_FRAMES
ssim_output = _run_ffmpeg_similarity("ssim", online_path, offline_path)
psnr_output = _run_ffmpeg_similarity("psnr", online_path, offline_path)
ssim_score = _parse_ssim_score(ssim_output)
psnr_score = _parse_psnr_score(psnr_output)
print("wan22_i2v_similarity metrics:")
print(
" SSIM:"
f" value={ssim_score:.6f},"
f" threshold>={SSIM_THRESHOLD:.6f},"
" range=[0, 1],"
" higher_is_better=True,"
" interpretation=structural_similarity"
)
print(
" PSNR:"
f" value={psnr_score:.6f} dB,"
f" threshold>={PSNR_THRESHOLD:.6f} dB,"
" range=[0, +inf),"
" higher_is_better=True,"
" interpretation=pixel_error_in_decibels"
)
print(f"online_video={online_path}")
print(f"offline_video={offline_path}")
print(f"offline_metadata={offline_metadata_path}")
assert ssim_score >= SSIM_THRESHOLD, (
f"SSIM below threshold: got {ssim_score:.6f}, expected >= {SSIM_THRESHOLD:.6f}. "
f"online={online_path} offline={offline_path}"
)
assert psnr_score >= PSNR_THRESHOLD, (
f"PSNR below threshold: got {psnr_score:.6f}, expected >= {PSNR_THRESHOLD:.6f}. "
f"online={online_path} offline={offline_path}"
)
@@ -0,0 +1,24 @@
from __future__ import annotations
RABBIT_IMAGE_URL = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/rabbit.png"
MODEL_NAME = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
SIZE = "832x480"
WIDTH = 832
HEIGHT = 480
FPS = 16
NUM_FRAMES = 93
GUIDANCE_SCALE = 3.5
GUIDANCE_SCALE_2 = 3.5
FLOW_SHIFT = 5.0
NUM_INFERENCE_STEPS = 50
SEED = 42
BOUNDARY_RATIO = 0.875
SSIM_THRESHOLD = 0.94
PSNR_THRESHOLD = 28.0
PROMPT = """一只棕色野兔的正面特写镜头,采用低角度仰拍视角,营造亲密而庄严的视觉冲击。兔子一双圆润漆黑的大眼睛直视镜头深处,眼神中交织着野生动物的警觉与一丝难以言喻的温柔好奇,仿佛在与观者建立跨越物种的静默对话。它毛色呈现层次丰富的棕褐渐变,从浅奶油色腹部过渡到深棕背部,每根毛发纹理清晰可辨,在侧光下泛着丝绸般的光泽。细长洁白的胡须共有三对,随呼吸节奏微微颤动,偶尔因捕捉气流信息而轻轻摇摆。
两只标志性的长耳完全竖立,耳廓外侧覆盖短密棕毛,内侧则露出粉嫩的血管网络,薄如蝉翼的皮肤下血液流动隐约可见,耳朵以细微幅度不时转动,精准定位声源方向。背景是一片澄澈的蔚蓝天空,形态蓬松的白色积云以缓慢速度横向漂移,云影在兔子头顶交替变化,光线随之明暗流转。晴朗天气的明媚阳光从画面左上方45度角倾泻而下,在兔脸右侧形成柔和的伦勃朗式阴影,强化了面部立体感和皮毛质感。
兔子湿润的黑鼻子持续进行每秒三至四次的快速抽动,这是它们感知化学信号的本能动作,粉色三瓣嘴随之轻启,露出正在反刍的洁白门齿,下颌以稳定节奏左右研磨。摄影采用大光圈浅景深,焦点牢牢锁定在兔子双眼连线所在的焦平面,背景天空和远景绿色植被虚化成圆润的彩色光斑,前景几根嫩绿草叶闯入画面边缘,以缓慢弧线随风摇曳,暗示着和煦的春日微风。
整个场景弥漫着宁静致远的田园诗意,色彩温暖饱和,充满生命力。兔子在持续五秒的对视后,以典型 lagomorph 特征完成一次完整的瞬膜眨眼——第三眼睑从内侧横向滑过眼球,继而缓缓歪头向右十五度,这个行为在动物行为学中代表认知加工和好奇心表达,耳朵随之向同一方向倾斜,最终恢复正视姿态,胡须舒展,完成这段短暂而珍贵的自然纪录。"""
NEGATIVE_PROMPT = """色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"""
@@ -23,7 +23,7 @@ from tests.conftest import (
)
from tests.utils import hardware_test
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
model = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
AUDIO_KEY = ["test"]
IMAGE_KEY = ["square", "quadrate"]
@@ -49,16 +49,32 @@ def get_chunk_config(default_path):
return path
def get_batch_token_config(default_path):
path = modify_stage_config(
default_path,
updates={
"stage_args": {1: {"engine_args.max_num_batched_tokens": 64}},
},
)
return path
# CI stage config for 2*H100-80G GPUs
default_path = str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")
stage_configs = [default_path, get_chunk_config(default_path)]
if current_omni_platform.is_xpu():
stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")]
default_path = str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")
# Create parameter combinations for model and stage config
test_params = [
OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs
pytest.param(OmniServerParams(model=model, stage_config_path=default_path), id="default"),
pytest.param(OmniServerParams(model=model, stage_config_path=get_chunk_config(default_path)), id="async_chunk"),
]
test_token_params = [
pytest.param(
OmniServerParams(model=model, stage_config_path=get_batch_token_config(default_path)), id="batch_token_64"
)
]
@@ -85,6 +101,8 @@ def get_prompt(prompt_type="text_only"):
"text_video": "What is in this video? ",
"text_image": "What is in this image? ",
"text_audio": "What is in this audio? ",
"text_audio_video": "First, what is in this audio? Then, what is in this video? ",
"one_word": "What is the capital of UK? Answer in one word",
}
return prompts.get(prompt_type, prompts["text_only"])
@@ -121,7 +139,7 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_text_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text
@@ -288,7 +306,7 @@ def test_video_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, audio
@@ -313,7 +331,7 @@ def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_text_image_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, image
@@ -339,7 +357,7 @@ def test_text_image_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, video
@@ -367,7 +385,7 @@ def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, audio, image, video
@@ -393,3 +411,106 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"key_words": {"audio": AUDIO_KEY, "image": IMAGE_KEY, "video": VIDEO_KEY},
}
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_audio_in_video_001(omni_server, openai_client) -> None:
"""
Input Modal: text + video (synthetic MP4 with embedded audio; ``use_audio_in_video`` uses audio from the video).
Output Modal: text, audio
Input Setting: stream=False
Datasets: single request
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300, embed_audio=True)['base64']}"
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
video_data_url=video_data_url,
content_text=get_prompt("text_audio_video"),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": False,
"use_audio_in_video": True,
"key_words": {"video": VIDEO_KEY, "audio": AUDIO_KEY + ["beep", "electronic"]},
}
openai_client.send_omni_request(request_config)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_audio_in_video_002(omni_server, openai_client) -> None:
"""
Input Modal: text + video (synthetic MP4 with embedded audio; ``use_audio_in_video`` uses audio from the video).
Output Modal: text, audio
Input Setting: stream=True
Datasets: few requests
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300, embed_audio=True)['base64']}"
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
video_data_url=video_data_url,
content_text=get_prompt("text_audio_video"),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"use_audio_in_video": True,
"key_words": {"video": VIDEO_KEY, "audio": AUDIO_KEY + ["beep", "electronic"]},
}
# Retry when assert_omni_response fails on key_words (see tests/conftest.py).
_keyword_assert_msg = "The output does not contain any of the keywords."
_max_retries = 3
for attempt in range(_max_retries):
try:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
break
except AssertionError as e:
if _keyword_assert_msg not in str(e) or attempt == _max_retries - 1:
raise
print(f"Keyword assertion failed, retrying {attempt + 2}/{_max_retries}: {e!r}")
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_one_word_prompt_001(omni_server, openai_client) -> None:
"""
Input Modal: text only (one-word answer constraint).
Output Modal: text, audio (default ``modalities``); ``key_words`` only assert on text.
Input Setting: stream=True
Datasets: single request
"""
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
content_text=get_prompt("one_word"),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"key_words": {"text": ["london"]},
}
# Retry only when assert_omni_response fails on text/audio cosine similarity (see tests/conftest.py).
_similarity_assert_msg = "The audio content is not same as the text"
_max_retries = 3
for attempt in range(_max_retries):
try:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
break
except AssertionError as e:
if _similarity_assert_msg not in str(e) or attempt == _max_retries - 1:
raise
print(f"Similarity assertion failed, retrying {attempt + 2}/{_max_retries}: {e!r}")
@@ -7,7 +7,7 @@ and are supported by Qwen-Image-Layered model.
Kept cases (maximum feature coverage, no cpu-offload):
sp_001 : cache_dit + Ulysses-SP 2 (2×H100)
cfg_parallel_002 : cache_dit + CFG-Parallel 2 (2×H100)
layers_guard_001 : layerwise offload + layers=2 (1×H100, issue #1969 guard)
layers_guard_001 : layerwise offload + layers=3 (1×H100, issue #1969 guard)
Total distinct features covered: cache_dit, Ulysses-SP, CFG-Parallel, layerwise-offload.
"""
@@ -102,8 +102,8 @@ LAYERS_GUARD_CASES = [
model=MODEL,
server_args=["--enable-layerwise-offload"],
),
2,
id="layers_guard_001_layers2",
3,
id="layers_guard_001_layers3",
marks=SINGLE_CARD_FEATURE_MARKS,
),
]
+2 -2
View File
@@ -33,7 +33,7 @@ stage_args:
temperature: 0.4
top_p: 0.9
top_k: 1
max_tokens: 100
max_tokens: 150
seed: 42
ignore_eos: False
detokenize: True
@@ -74,7 +74,7 @@ stage_args:
devices: "1"
engine_args:
model_stage: code2wav
max_num_seqs: 1
max_num_seqs: 5
model_arch: Qwen3OmniMoeForConditionalGeneration
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
@@ -16,6 +16,8 @@ from vllm_omni.model_executor.stage_input_processors.qwen3_tts import (
talker2code2wav_async_chunk,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_FRAME = [1, 2, 3, 4]
_Q = len(_FRAME)
@@ -90,7 +92,7 @@ def test_flush_on_finish():
is_finished=True,
)
assert p is not None
assert p["finished"].item() is True
assert p["finished"] is True
assert len(p["code_predictor_codes"]) == _Q * 24
@@ -158,24 +160,29 @@ def test_dynamic_ic_adapts_to_load():
def test_ic_load_change_mid_request():
"""IC stateless: load spike mid-request shifts initial_coverage."""
"""IC is cached per request; a load spike only affects new requests."""
tm = _tm(chunk_frames=25, left_context=25, max_num_seqs=8)
# Low load -> IC=2 -> emit at frame 2
# Low load -> IC=2 (cached for "r"), emit at frame 2
p1 = _call(tm, "r", n_frames=2)
assert p1 is not None
# Spike load: 6 others -> IC=16 -> initial_coverage=16
# Spike load: 6 others running
for i in range(6):
tm.code_prompt_token_ids[f"other-{i}"] = [[0]] * 10
# adjusted=25-16=9, 9%25!=0 -> hold
# IC for "r" is still cached as 2.
# initial_coverage = ((25-1)//2)*2 = 24, first normal emit at 24+25=49
assert _call(tm, "r", n_frames=25) is None
# First normal emit at 16+25=41
p3 = _call(tm, "r", n_frames=41)
assert _call(tm, "r", n_frames=27) is None
p3 = _call(tm, "r", n_frames=49)
assert p3 is not None
assert p3["left_context_size"] == 16
# A *new* request under high load gets IC=16 (not IC=2).
# Frame 2 would emit under IC=2 but must hold under IC=16.
assert _call(tm, "new_req", n_frames=2) is None
p4 = _call(tm, "new_req", n_frames=16)
assert p4 is not None
@pytest.mark.parametrize(
@@ -295,8 +302,13 @@ def test_non_async_processor_prepends_ref_code_and_sets_trim_context():
],
dtype=torch.long,
)
output = SimpleNamespace(multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code})
stage = SimpleNamespace(engine_outputs=[SimpleNamespace(outputs=[output])])
output = SimpleNamespace(
multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code},
token_ids=list(range(3)),
)
stage = SimpleNamespace(
engine_outputs=[SimpleNamespace(outputs=[output], finished=True)],
)
prompts = talker2code2wav(stage_list=[stage], engine_input_source=[0])
@@ -335,8 +347,13 @@ def test_non_async_processor_filters_out_of_range_codec_values():
],
dtype=torch.long,
)
output = SimpleNamespace(multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code})
stage = SimpleNamespace(engine_outputs=[SimpleNamespace(outputs=[output])])
output = SimpleNamespace(
multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code},
token_ids=list(range(4)),
)
stage = SimpleNamespace(
engine_outputs=[SimpleNamespace(outputs=[output], finished=True)],
)
prompts = talker2code2wav(stage_list=[stage], engine_input_source=[0])
@@ -1122,7 +1122,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
)
# Prefer GPU for encoder if available; otherwise keep CPU.
dev = next(self.parameters()).device
if getattr(dev, "type", None) == "cuda":
if dev.type != "cpu":
try:
tok.model.to(dev)
tok.device = dev
@@ -1450,10 +1450,10 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module):
)
if not speaker:
raise ValueError("CustomVoice requires additional_information.speaker.")
spk_id_map = getattr(self.talker_config, "spk_id", None) or {}
spk_id_map = {k.lower(): v for k, v in (getattr(self.talker_config, "spk_id", None) or {}).items()}
if speaker not in spk_id_map:
raise ValueError(f"Unsupported speaker: {speaker}")
spk_id = spk_id_map[speaker.lower()]
spk_id = spk_id_map[speaker]
# Keep it at least 1D; embedding on a 0-d tensor can return 1D.
spk_tensor = torch.tensor([spk_id], device=input_ids.device, dtype=torch.long)
spk_embed = self.embed_input_ids(spk_tensor)