Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Yueqian Lin committed 2026-08-30 03:36:08 -07:00
commit aef61597d3
1396 files changed
+151246 -39046

No files matched your search

+25 -17
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import json
import os
import re
@@ -14,7 +17,7 @@ from hashlib import sha1
from io import BytesIO
from mimetypes import guess_type
from pathlib import Path
from typing import Any
from typing import IO, Any
from urllib.parse import urlparse
import numpy as np
@@ -35,11 +38,14 @@ class IdentityFtfy:
def apply_ftfy_mock(*, wan_i2v_module: Any | None = None) -> None:
"""Mock ftfy library for test cases in the main process"""
if wan_i2v_module is None:
from diffusers.pipelines.wan import pipeline_wan_i2v as wan_i2v_module
module: Any = wan_i2v_module
if module is None:
from diffusers.pipelines.wan import pipeline_wan_i2v as wan_pipeline
if not hasattr(wan_i2v_module, "ftfy"):
wan_i2v_module.ftfy = IdentityFtfy()
module = wan_pipeline
if not hasattr(module, "ftfy"):
module.ftfy = IdentityFtfy()
print("ftfy (text encoding sanitizer) is not installed. Using mock ftfy implementation (identity function)")
else:
print("ftfy (text encoding sanitizer) is installed. Using actual ftfy implementation.")
@@ -118,6 +124,7 @@ def probe_binary(binary: str, *, test_name: str = "video similarity e2e test") -
resolved = shutil.which(binary)
if resolved is None:
pytest.skip(f"{binary} is required for {test_name}.")
assert resolved is not None
return resolved
@@ -235,7 +242,7 @@ def video_artifact_dir(result_root: Path, source: str) -> Path:
def send_video_request_with_timeout(
openai_client,
online_client,
request_config: dict[str, Any],
*,
timeout_seconds: int,
@@ -266,7 +273,7 @@ def send_video_request_with_timeout(
normalized_form_data["image_reference"] = json.dumps({"image_url": image_reference})
start_time = time.perf_counter()
create_url = openai_client._build_url("/v1/videos")
create_url = online_client._build_url("/v1/videos")
response = requests.post(
create_url,
data=normalized_form_data,
@@ -280,8 +287,8 @@ def send_video_request_with_timeout(
raise requests.HTTPError(f"{exc}; response body: {response.text}", response=response) from exc
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)
online_client._wait_until_video_completed(video_id, timeout_seconds=timeout_seconds)
video_content = online_client._download_video_content(video_id)
print(f"online_video_e2e_latency_s={time.perf_counter() - start_time:.3f}")
return video_content
@@ -571,11 +578,11 @@ class SemanticSimilarityScorer:
raise ImportError("FlagEmbedding not installed. Install with: pip install FlagEmbedding")
return self._text_model
def text_similarity(self, text1: str, text2: str) -> float:
def text_similarity(self, text1: str, text2: str) -> dict[str, float | int]:
"""
Compute semantic similarity between two texts using BGE-M3
"""
results = {}
results: dict[str, float | int] = {}
# 1. Text prefix matching (maximum matching character length)
match_count = 0
for char_vllm, char_ref in zip(text1, text2):
@@ -653,7 +660,7 @@ def dreamzero_cfg_parallel_size() -> int:
def _dreamzero_parity_vllm_env() -> dict[str, str]:
from tests.helpers.env import pick_least_used_device_indices
from tests.helpers.clean import pick_least_used_device_indices
from tests.helpers.runtime import get_open_port
parallel_size = dreamzero_cfg_parallel_size()
@@ -683,8 +690,9 @@ def dreamzero_parity_vllm_params(model: str | None = None):
]
if parallel_size > 1:
server_args.extend(["--cfg-parallel-size", str(parallel_size)])
resolved_model = model or os.environ.get("VLLM_DREAMZERO_MODEL") or _DREAMZERO_DEFAULT_MODEL
return OmniServerParams(
model=model or os.environ.get("VLLM_DREAMZERO_MODEL", _DREAMZERO_DEFAULT_MODEL),
model=resolved_model,
stage_config_path=get_deploy_config_path("dreamzero_tp1_cfg2.yaml"),
server_args=server_args,
env_dict=_dreamzero_parity_vllm_env(),
@@ -725,9 +733,9 @@ def dreamzero_parity_vllm_client(
def _dreamzero_create_openpi_vllm_client(host: str, port: int) -> Any:
from openpi_client import msgpack_numpy
_, WebsocketClientPolicy = load_dreamzero_upstream_modules()
_, websocket_client_policy_cls = load_dreamzero_upstream_modules()
class _OpenPIWebsocketClientPolicy(WebsocketClientPolicy):
class _OpenPIWebsocketClientPolicy(websocket_client_policy_cls): # type: ignore[misc, valid-type]
def __init__(
self,
host: str = "127.0.0.1",
@@ -869,7 +877,7 @@ class _DreamZeroParityServer(ABC):
self.port = get_open_port() if port is None else port
self.ready_timeout_s = ready_timeout_s
self.proc: subprocess.Popen[str] | None = None
self._log_file = None
self._log_file: IO[str] | None = None
@abstractmethod
def _build_argv(self) -> list[str]:
@@ -932,7 +940,7 @@ class DreamZeroUpstreamServer(_DreamZeroParityServer):
]
def _build_env(self) -> dict[str, str]:
from tests.helpers.env import pick_least_used_device_indices
from tests.helpers.clean import pick_least_used_device_indices
dreamzero_repo, _ = require_dreamzero_parity_env()
env = os.environ.copy()
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import os
@@ -149,7 +152,7 @@ def _generate_offline_video(*, image_source: str) -> Path:
def _generate_online_video(
*,
omni_server,
openai_client,
online_client,
image_source: str,
timeout_seconds: int,
) -> Path:
@@ -171,7 +174,7 @@ def _generate_online_video(
"image_reference": build_online_image_reference(online_image_source),
}
online_video_bytes = send_video_request_with_timeout(
openai_client,
online_client,
request_config,
timeout_seconds=timeout_seconds,
)
@@ -204,7 +207,7 @@ def test_hunyuanvideo15_i2v_diffusers_offline_generates_video(
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
def test_hunyuanvideo15_i2v_online_serving_generates_video(
omni_server,
openai_client,
online_client,
hunyuanvideo15_i2v_image_source: str | None,
hunyuanvideo15_online_timeout_seconds: int,
) -> None:
@@ -216,7 +219,7 @@ def test_hunyuanvideo15_i2v_online_serving_generates_video(
validate_image_source(image_source)
online_path = _generate_online_video(
omni_server=omni_server,
openai_client=openai_client,
online_client=online_client,
image_source=image_source,
timeout_seconds=hunyuanvideo15_online_timeout_seconds,
)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import os
@@ -115,7 +118,7 @@ def _generate_offline_video() -> Path:
return offline_path
def _generate_online_video(*, omni_server, openai_client, timeout_seconds: int) -> Path:
def _generate_online_video(*, omni_server, online_client, timeout_seconds: int) -> Path:
online_path, _ = _artifact_paths()
request_config = {
"model": omni_server.model,
@@ -132,7 +135,7 @@ def _generate_online_video(*, omni_server, openai_client, timeout_seconds: int)
},
}
online_video_bytes = send_video_request_with_timeout(
openai_client,
online_client,
request_config,
timeout_seconds=timeout_seconds,
)
@@ -161,7 +164,7 @@ def test_hunyuanvideo15_t2v_diffusers_offline_generates_video() -> None:
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
def test_hunyuanvideo15_t2v_online_serving_generates_video(
omni_server,
openai_client,
online_client,
hunyuanvideo15_online_timeout_seconds: int,
) -> None:
if not torch.cuda.is_available():
@@ -170,7 +173,7 @@ def test_hunyuanvideo15_t2v_online_serving_generates_video(
probe_binary("ffprobe")
online_path = _generate_online_video(
omni_server=omni_server,
openai_client=openai_client,
online_client=online_client,
timeout_seconds=hunyuanvideo15_online_timeout_seconds,
)
assert online_path.exists(), f"Expected online video artifact at {online_path}"
@@ -0,0 +1,591 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Generate raw LTX video and audio outputs with the official or Omni runtime."""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
import numpy as np
import torch
from PIL import Image
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--backend", choices=("official", "omni"), required=True)
parser.add_argument("--request", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--model")
parser.add_argument("--model-class-name")
parser.add_argument("--official-root", type=Path)
parser.add_argument("--checkpoint", type=Path)
parser.add_argument("--gemma-root", type=Path)
parser.add_argument("--transformer-path", type=Path)
parser.add_argument("--text-encoder-path", type=Path)
parser.add_argument("--video-vae-path", type=Path)
parser.add_argument("--audio-vae-path", type=Path)
parser.add_argument("--spatial-upsampler-path", type=Path)
parser.add_argument("--distilled-lora-path", type=Path)
parser.add_argument("--connector-model", type=Path)
parser.add_argument(
"--official-pipeline",
choices=("distilled_two_stage", "full_one_stage", "full_two_stage"),
default="distilled_two_stage",
)
parser.add_argument("--enable-layerwise-offload", action="store_true")
return parser.parse_args()
def _save_outputs(
output_dir: Path,
*,
video: torch.Tensor,
audio: torch.Tensor,
audio_sample_rate: int,
fps: float,
metadata: dict[str, Any],
) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
video = video.detach().float().cpu().clamp(0.0, 1.0)
audio = audio.detach().float().cpu()
np.save(output_dir / "video.npy", video.numpy())
np.save(output_dir / "audio.npy", audio.numpy())
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
frames_u8 = video.mul(255.0).round().to(torch.uint8).numpy()
audio_array = audio.numpy()
while audio_array.ndim > 2 and audio_array.shape[0] == 1:
audio_array = audio_array[0]
(output_dir / "output.mp4").write_bytes(
mux_video_audio_bytes(
frames_u8,
audio_array,
fps=float(fps),
audio_sample_rate=int(audio_sample_rate),
video_codec_options={"preset": "ultrafast"},
)
)
frame_indices = sorted({0, video.shape[0] // 2, video.shape[0] - 1})
for index in frame_indices:
frame = video[index].mul(255.0).round().to(torch.uint8).numpy()
Image.fromarray(frame).save(output_dir / f"frame_{index:04d}.png")
metadata.update(
{
"video_shape": list(video.shape),
"audio_shape": list(audio.shape),
"audio_sample_rate": int(audio_sample_rate),
"frame_indices": frame_indices,
"mp4": "output.mp4",
}
)
(output_dir / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n")
def _reset_accelerator_peak_memory_stats() -> None:
if torch.accelerator.is_available():
torch.accelerator.reset_peak_memory_stats()
def _accelerator_peak_memory_stats() -> dict[str, float]:
if not torch.accelerator.is_available():
return {}
torch.accelerator.synchronize()
mib = 1024**2
return {
"peak_memory_allocated_mb": torch.accelerator.max_memory_allocated() / mib,
"peak_memory_reserved_mb": torch.accelerator.max_memory_reserved() / mib,
}
def _insert_official_paths(official_root: Path) -> None:
for relative_path in ("packages/ltx-core/src", "packages/ltx-pipelines/src"):
path = str((official_root / relative_path).resolve())
if path not in sys.path:
sys.path.insert(0, path)
def _configure_official_sdpa(pipeline: Any) -> None:
"""Pin official connector and denoiser attention to cuDNN, with MATH fallback."""
from ltx_core.loader.attention_ops import set_attention_module_op
from ltx_core.model.transformer.attention import PytorchAttention
from torch.nn.attention import SDPBackend
attention = PytorchAttention(priority=[SDPBackend.CUDNN_ATTENTION, SDPBackend.MATH])
module_op = set_attention_module_op(
attention=attention,
masked_attention=attention,
)
transformer_owners = [
owner
for attribute in ("stage", "stage_1", "stage_2")
if (owner := getattr(pipeline, attribute, None)) is not None
]
owners_and_attributes = [
*((owner, "_transformer_builder") for owner in transformer_owners),
(pipeline.prompt_encoder, "_embeddings_processor_builder"),
]
for owner, attribute in owners_and_attributes:
builder = getattr(owner, attribute)
setattr(
owner,
attribute,
builder.with_module_ops((*builder.module_ops, module_op)),
)
def _use_diffusers_connector_weights(prompt_encoder: Any, model: Path) -> None:
"""Run official connector code with the checkpoint under test."""
from safetensors import safe_open
connector_root = model / "connectors"
shards = sorted(connector_root.glob("*.safetensors"))
if not shards:
raise ValueError(f"No connector safetensors found under {connector_root}")
original_build_embeddings_processor = prompt_encoder._build_embeddings_processor
def build_embeddings_processor():
processor = original_build_embeddings_processor()
parameters = dict(processor.named_parameters())
expected = {name for name in parameters if name.startswith(("video_connector.", "audio_connector."))}
loaded: set[str] = set()
with torch.no_grad():
for shard in shards:
with safe_open(str(shard), framework="pt", device="cpu") as weights:
for source_name in weights.keys():
if not source_name.startswith(("video_connector.", "audio_connector.")):
continue
target_name = (
source_name.replace(".transformer_blocks.", ".transformer_1d_blocks.")
.replace(".attn1.norm_q.", ".attn1.q_norm.")
.replace(".attn1.norm_k.", ".attn1.k_norm.")
)
parameter = parameters[target_name]
parameter.copy_(weights.get_tensor(source_name).to(parameter.device, parameter.dtype))
loaded.add(target_name)
if loaded != expected:
missing = sorted(expected - loaded)
unexpected = sorted(loaded - expected)
raise ValueError(f"Connector weight mismatch: missing={missing}, unexpected={unexpected}")
return processor
prompt_encoder._build_embeddings_processor = build_embeddings_processor
@torch.inference_mode()
def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None:
if args.official_root is None:
raise ValueError("Official backend requires --official-root")
_insert_official_paths(args.official_root)
# Keep Gemma on the same SDPA path in both subprocesses. DiT attention is
# still pinned independently to the explicit cuDNN backend below.
torch.backends.cuda.enable_cudnn_sdp(False)
_reset_accelerator_peak_memory_stats()
from ltx_pipelines.utils.args import ImageConditioningInput
from ltx_pipelines.utils.model_paths import ModelPaths
from ltx_pipelines.utils.types import OffloadMode
offload_mode = OffloadMode.CPU if args.enable_layerwise_offload else OffloadMode.NONE
image_path = request.get("image")
images = (
[]
if image_path is None
else [
ImageConditioningInput(
path=str(image_path),
frame_idx=0,
strength=1.0,
crf=request.get("image_crf", 18),
)
]
)
if args.transformer_path is not None:
required = {
"--text-encoder-path": args.text_encoder_path,
"--video-vae-path": args.video_vae_path,
"--audio-vae-path": args.audio_vae_path,
}
if args.official_pipeline in {"distilled_two_stage", "full_two_stage"}:
required["--spatial-upsampler-path"] = args.spatial_upsampler_path
if args.official_pipeline == "full_two_stage":
required["--distilled-lora-path"] = args.distilled_lora_path
missing = [name for name, value in required.items() if value is None]
if missing:
raise ValueError(f"Official LTX-2.5 split backend is missing: {', '.join(missing)}")
model_paths = ModelPaths.from_split(
transformer_path=str(args.transformer_path),
text_encoder_path=str(args.text_encoder_path),
video_vae_path=str(args.video_vae_path),
audio_vae_path=str(args.audio_vae_path),
)
if args.official_pipeline == "distilled_two_stage":
from ltx_pipelines.distilled import DistilledPipeline
pipeline = DistilledPipeline(
model_paths=model_paths,
spatial_upsampler_path=str(args.spatial_upsampler_path),
loras=(),
offload_mode=offload_mode,
)
if args.connector_model is not None:
_use_diffusers_connector_weights(pipeline.prompt_encoder, args.connector_model)
_configure_official_sdpa(pipeline)
video, audio, _, _ = pipeline(
prompt=request["prompt"],
seed=request["seed"],
height=request["height"],
width=request["width"],
num_frames=request["num_frames"],
frame_rate=request["fps"],
images=images,
)
pipeline_name = "DistilledPipeline"
elif args.official_pipeline == "full_two_stage":
from ltx_core.components.guiders import MultiModalGuiderParams
from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps
from ltx_pipelines.ti2vid_two_stages import TI2VidTwoStagesPipeline
pipeline = TI2VidTwoStagesPipeline(
model_paths=model_paths,
distilled_lora=[
LoraPathStrengthAndSDOps(
path=str(args.distilled_lora_path),
strength=1.0,
sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
)
],
spatial_upsampler_path=str(args.spatial_upsampler_path),
loras=[],
offload_mode=offload_mode,
)
if args.connector_model is not None:
_use_diffusers_connector_weights(pipeline.prompt_encoder, args.connector_model)
_configure_official_sdpa(pipeline)
video, audio, _, _ = pipeline(
prompt=request["prompt"],
negative_prompt=request["negative_prompt"],
seed=request["seed"],
height=request["height"],
width=request["width"],
num_frames=request["num_frames"],
frame_rate=request["fps"],
num_inference_steps=request["num_inference_steps"],
video_guider_params=MultiModalGuiderParams(
cfg_scale=request["video_cfg_scale"],
stg_scale=request["video_stg_scale"],
rescale_scale=request["video_rescale_scale"],
modality_scale=request["video_modality_scale"],
skip_step=0,
stg_blocks=request["video_stg_blocks"],
),
audio_guider_params=MultiModalGuiderParams(
cfg_scale=request["audio_cfg_scale"],
stg_scale=request["audio_stg_scale"],
rescale_scale=request["audio_rescale_scale"],
modality_scale=request["audio_modality_scale"],
skip_step=0,
stg_blocks=request["audio_stg_blocks"],
),
images=images,
max_batch_size=4,
stage_1_sigmas=torch.tensor(request["stage_1_sigmas"], dtype=torch.float32),
stage_2_sigmas=torch.tensor(request["stage_2_sigmas"], dtype=torch.float32),
)
pipeline_name = "TI2VidTwoStagesPipeline"
else:
from ltx_core.components.guiders import MultiModalGuiderParams
from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline
pipeline = TI2VidOneStagePipeline(
model_paths=model_paths,
loras=(),
offload_mode=offload_mode,
)
if args.connector_model is not None:
_use_diffusers_connector_weights(pipeline.prompt_encoder, args.connector_model)
_configure_official_sdpa(pipeline)
request_sigmas = request.get("sigmas")
video, audio, _ = pipeline(
prompt=request["prompt"],
negative_prompt=request["negative_prompt"],
seed=request["seed"],
height=request["height"],
width=request["width"],
num_frames=request["num_frames"],
frame_rate=request["fps"],
num_inference_steps=request["num_inference_steps"],
video_guider_params=MultiModalGuiderParams(
cfg_scale=request["video_cfg_scale"],
stg_scale=request["video_stg_scale"],
rescale_scale=request["video_rescale_scale"],
modality_scale=request["video_modality_scale"],
skip_step=0,
stg_blocks=request["video_stg_blocks"],
),
audio_guider_params=MultiModalGuiderParams(
cfg_scale=request["audio_cfg_scale"],
stg_scale=request["audio_stg_scale"],
rescale_scale=request["audio_rescale_scale"],
modality_scale=request["audio_modality_scale"],
skip_step=0,
stg_blocks=request["audio_stg_blocks"],
),
images=images,
max_batch_size=4,
sigmas=None if request_sigmas is None else torch.tensor(request_sigmas, dtype=torch.float32),
)
pipeline_name = "TI2VidOneStagePipeline"
checkpoint_metadata = {
"transformer_path": str(args.transformer_path),
"video_vae_path": str(args.video_vae_path),
"connector_model": None if args.connector_model is None else str(args.connector_model),
"pipeline": pipeline_name,
}
else:
if args.checkpoint is None or args.gemma_root is None:
raise ValueError("Official monolith backend requires --checkpoint and --gemma-root")
from ltx_core.components.guiders import MultiModalGuiderParams
from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline
pipeline = TI2VidOneStagePipeline(
model_paths=ModelPaths.from_monolith(str(args.checkpoint), str(args.gemma_root)),
loras=(),
offload_mode=offload_mode,
)
_configure_official_sdpa(pipeline)
video, audio, _ = pipeline(
prompt=request["prompt"],
negative_prompt=request["negative_prompt"],
seed=request["seed"],
height=request["height"],
width=request["width"],
num_frames=request["num_frames"],
frame_rate=request["fps"],
num_inference_steps=request["num_inference_steps"],
video_guider_params=MultiModalGuiderParams(
cfg_scale=request["video_cfg_scale"],
stg_scale=request["video_stg_scale"],
rescale_scale=request["video_rescale_scale"],
modality_scale=request["video_modality_scale"],
skip_step=0,
stg_blocks=request["video_stg_blocks"],
),
audio_guider_params=MultiModalGuiderParams(
cfg_scale=request["audio_cfg_scale"],
stg_scale=request["audio_stg_scale"],
rescale_scale=request["audio_rescale_scale"],
modality_scale=request["audio_modality_scale"],
skip_step=0,
stg_blocks=request["audio_stg_blocks"],
),
images=images,
max_batch_size=4,
)
checkpoint_metadata = {
"checkpoint": str(args.checkpoint),
"pipeline": "TI2VidOneStagePipeline",
}
video_tensor = torch.cat([chunk.detach().cpu() for chunk in video], dim=0)
_save_outputs(
args.output_dir,
video=_canonical_video(video_tensor),
audio=audio.waveform,
audio_sample_rate=audio.sampling_rate,
fps=float(request["fps"]),
metadata={
"backend": "official",
"attention_backend": "torch_sdpa_cudnn_bf16",
"official_revision": os.environ.get("VLLM_TEST_LTX_OFFICIAL_REVISION"),
"seed": request["seed"],
"sigmas": request.get("sigmas"),
"stage_1_sigmas": request.get("stage_1_sigmas"),
"stage_2_sigmas": request.get("stage_2_sigmas"),
**_accelerator_peak_memory_stats(),
**checkpoint_metadata,
},
)
def _omni_worker_peak_memory_mb(output: Any) -> float:
result = output[0] if isinstance(output, list) and output else output
return float(getattr(result, "peak_memory_mb", 0.0) or 0.0)
def _unwrap_omni_output(output: Any) -> tuple[Any, Any, int]:
from vllm_omni.outputs import OmniRequestOutput
audio = None
audio_sample_rate = None
frames = output[0] if isinstance(output, list) and output else output
if isinstance(frames, OmniRequestOutput):
multimodal_output = frames.multimodal_output or {}
audio = multimodal_output.get("audio")
audio_sample_rate = multimodal_output.get("audio_sample_rate")
nested_output = getattr(frames, "request_output", None)
if frames.is_pipeline_output and isinstance(nested_output, OmniRequestOutput):
frames = nested_output
multimodal_output = frames.multimodal_output or {}
audio = multimodal_output.get("audio", audio)
audio_sample_rate = multimodal_output.get("audio_sample_rate", audio_sample_rate)
if isinstance(frames, OmniRequestOutput):
if not frames.images:
raise ValueError("No video frames found in OmniRequestOutput")
frames = frames.images
if isinstance(frames, list) and len(frames) == 1:
frames = frames[0]
if isinstance(frames, tuple) and len(frames) == 2:
frames, audio = frames
elif isinstance(frames, dict):
audio = frames.get("audio", audio)
audio_sample_rate = frames.get("audio_sample_rate", audio_sample_rate)
frames = frames.get("frames", frames.get("video"))
if frames is None or audio is None or audio_sample_rate is None:
raise ValueError("Omni output did not contain video, audio, and audio_sample_rate")
return frames, audio, int(audio_sample_rate)
def _canonical_video(video: Any) -> torch.Tensor:
if isinstance(video, list):
if len(video) == 1:
return _canonical_video(video[0])
tensors = [torch.as_tensor(np.asarray(item) if not isinstance(item, torch.Tensor) else item) for item in video]
if all(item.ndim == 3 for item in tensors):
video = torch.stack(tensors)
elif all(item.ndim == 4 for item in tensors):
video = torch.cat(tensors)
else:
raise ValueError(f"Cannot combine video items with dimensions {[item.ndim for item in tensors]}")
tensor = torch.as_tensor(np.asarray(video) if not isinstance(video, torch.Tensor) else video).detach().cpu()
if tensor.ndim == 5:
tensor = tensor[0]
if tensor.ndim != 4:
raise ValueError(f"Expected a 4D video tensor, got {tuple(tensor.shape)}")
if tensor.shape[-1] in (3, 4):
tensor = tensor[..., :3]
elif tensor.shape[1] in (3, 4):
tensor = tensor[:, :3].permute(0, 2, 3, 1)
elif tensor.shape[0] in (3, 4):
tensor = tensor[:3].permute(1, 2, 3, 0)
else:
raise ValueError(f"Cannot infer video channel dimension from {tuple(tensor.shape)}")
tensor = tensor.float()
if tensor.numel() and tensor.max() > 1:
tensor = tensor / 255.0
elif tensor.numel() and tensor.min() < 0:
tensor = tensor.clamp(-1.0, 1.0).add(1.0).mul(0.5)
return tensor.clamp(0.0, 1.0)
@torch.inference_mode()
def _run_omni(args: argparse.Namespace, request: dict[str, Any]) -> None:
if args.model is None or args.model_class_name is None:
raise ValueError("Omni backend requires --model and --model-class-name")
# vLLM imports can otherwise change the process-wide Gemma dispatch.
torch.backends.cuda.enable_cudnn_sdp(False)
_reset_accelerator_peak_memory_stats()
attention_config = {"default": {"backend": "CUDNN_ATTN"}}
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_extras import get_extra_body_params
from vllm_omni.platforms import current_omni_platform
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(request["seed"])
omni = Omni(
model=args.model,
model_class_name=args.model_class_name,
enforce_eager=True,
enable_layerwise_offload=args.enable_layerwise_offload,
diffusion_attention_config=attention_config,
parallel_config=DiffusionParallelConfig(),
)
try:
# The explicit public class argument selects the worker pipeline. The
# parent Omni proxy can only expose the checkpoint's base class, which
# is insufficient to distinguish LTX execution modes.
model_class_name = args.model_class_name
sampling_params = OmniDiffusionSamplingParams(
height=request["height"],
width=request["width"],
generator=generator,
guidance_scale=None,
num_inference_steps=request["num_inference_steps"],
num_frames=request["num_frames"],
fps=request["fps"],
frame_rate=float(request["fps"]),
output_type="np",
)
extra_args = {
key: value for key, value in request.items() if key.startswith("video_") or key.startswith("audio_")
}
for key in ("sigmas", "stage_1_sigmas", "stage_2_sigmas", "image_crf"):
if key in request:
extra_args[key] = request[key]
apply_declared_extra_args(sampling_params, get_extra_body_params(model_class_name), extra_args)
prompt: dict[str, Any] = {"prompt": request["prompt"]}
if "negative_prompt" in request:
prompt["negative_prompt"] = request["negative_prompt"]
image_path = request.get("image")
if image_path is not None:
with Image.open(str(image_path)) as source_image:
image = source_image.convert("RGB")
image.load()
prompt["multi_modal_data"] = {"image": image}
output = omni.generate(prompt, sampling_params)
video, audio, audio_sample_rate = _unwrap_omni_output(output)
audio_tensor = torch.as_tensor(np.asarray(audio) if not isinstance(audio, torch.Tensor) else audio)
_save_outputs(
args.output_dir,
video=_canonical_video(video),
audio=audio_tensor,
audio_sample_rate=audio_sample_rate,
fps=float(request["fps"]),
metadata={
"backend": "omni",
"attention_backend": "torch_sdpa_cudnn_bf16",
"seed": request["seed"],
"sigmas": request.get("sigmas"),
"model": args.model,
"stage_1_sigmas": request.get("stage_1_sigmas"),
"stage_2_sigmas": request.get("stage_2_sigmas"),
"model_class_name": model_class_name,
"worker_peak_memory_mb": _omni_worker_peak_memory_mb(output),
**_accelerator_peak_memory_stats(),
},
)
finally:
omni.shutdown()
def main() -> None:
args = _parse_args()
request = json.loads(args.request.read_text())
if args.backend == "official":
_run_official(args, request)
else:
_run_omni(args, request)
if __name__ == "__main__":
main()
+3 -9
View File
@@ -260,15 +260,9 @@ def _unwrap_omni_output(output: Any) -> tuple[Any, Any, int]:
multimodal_output = frames.multimodal_output or {}
audio = multimodal_output.get("audio")
audio_sample_rate = multimodal_output.get("audio_sample_rate")
if frames.is_pipeline_output and isinstance(frames.request_output, OmniRequestOutput):
frames = frames.request_output
multimodal_output = frames.multimodal_output or {}
audio = multimodal_output.get("audio", audio)
audio_sample_rate = multimodal_output.get("audio_sample_rate", audio_sample_rate)
if isinstance(frames, OmniRequestOutput):
if not frames.images:
raise ValueError("No video frames found in OmniRequestOutput")
frames = frames.images
if not frames.images:
raise ValueError("No video frames found in OmniRequestOutput")
frames = frames.images
if isinstance(frames, list) and len(frames) == 1:
frames = frames[0]
@@ -0,0 +1,748 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""E2E accuracy guards against pinned official Lightricks LTX references.
The LTX-2/2.3 comparison runs both runtimes through PyTorch SDPA and uses
``max_batch_size=4`` in the official reference to match Omni's fused guidance
batch. Video and audio guidance use the official non-HQ one-stage defaults;
only the generation shape and step count are reduced for CI runtime.
LTX-2.5 runs the official split-artifact pipelines with connector weights from
the same Diffusers checkpoint under test. The distilled case covers the fixed
two-stage schedule; the Full/SFT case covers raw dev weights and an explicit
shared one-stage schedule.
"""
from __future__ import annotations
import json
import math
import os
import shutil
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
import numpy as np
import pytest
import torch
from huggingface_hub import hf_hub_download, snapshot_download
from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure
from tests.e2e.accuracy.helpers import reset_artifact_dir
from tests.helpers import skip_if_gated_repo_inaccessible
from tests.helpers.mark import hardware_test
OFFICIAL_REPOSITORY = "https://github.com/Lightricks/LTX-2.git"
LTX25_OFFICIAL_REVISION = "7954dcb0d986bdc36ef272564a9789ade07fcc65"
LTX25_OMNI_MODEL_ID = "Lightricks/LTX-2.5-Diffusers"
LTX25_OMNI_MODEL_REVISION = "a6de4b5354f078db24d9cf4778c14846788aea3d"
LTX25_OFFICIAL_MODEL_ID = "Lightricks/LTX-2.5"
LTX25_OFFICIAL_MODEL_REVISION = "8a4ff96f581e72bedc1b44367581c49d544a05f1"
LTX25_OFFICIAL_COMMON_FILES = {
"text_encoder": "text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors",
"video_vae": "vae/ltx-2.5-video-vae-bf16.safetensors",
"audio_vae": "vae/ltx-2.5-audio-vae-bf16.safetensors",
}
LTX25_OFFICIAL_VARIANT_FILES = {
"distilled": {
"transformer": "diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors",
"spatial_upsampler": "latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors",
},
"full": {
"transformer": "diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors",
},
"full_two_stage": {
"transformer": "diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors",
"spatial_upsampler": "latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors",
"distilled_lora": "loras/ltx-2.5-22b-distilled-lora-450-bf16.safetensors",
},
}
# Version selected by the pinned official source's uv.lock. Keep it isolated
# from Omni's runtime and development dependencies.
OFFICIAL_OPENIMAGEIO_VERSION = "3.1.11.0"
LTX25_PROMPT = "A cinematic shot of a red fox walking through a snowy forest at dawn, the camera tracking alongside."
LTX25_I2V_IMAGE_REPO = "huggingface/documentation-images"
LTX25_I2V_IMAGE_FILENAME = "diffusers/svd/rocket.png"
LTX25_I2V_IMAGE_REVISION = "645d8364f0c7a101180b364811b5a11a362e4010"
NEGATIVE_PROMPT = (
"blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, "
"grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, "
"deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, "
"wrong hand count, artifacts around text, inconsistent perspective, camera shake, incorrect depth of field, "
"background too sharp, background clutter, distracting reflections, harsh shadows, inconsistent lighting "
"direction, color banding, cartoonish rendering, 3D CGI look, unrealistic materials, uncanny valley effect, "
"incorrect ethnicity, wrong gender, exaggerated expressions, wrong gaze direction, mismatched lip sync, "
"silent or muted audio, distorted voice, robotic voice, echo, background noise, off-sync audio, incorrect "
"dialogue, added dialogue, repetitive speech, jittery movement, awkward pauses, incorrect timing, unnatural "
"transitions, inconsistent framing, tilted camera, flat lighting, inconsistent tone, cinematic oversaturation, "
"stylized filters, or AI artifacts."
)
# Release parity exercises the accelerated BF16 cuDNN path in both runtimes.
# Degenerate one-token attention falls back to PyTorch SDPA MATH.
ATTENTION_BACKEND = "torch_sdpa_cudnn_bf16"
LTX25_STAGE_2_SIGMAS = [0.909375, 0.725, 0.421875, 0.0]
@dataclass(frozen=True)
class LTXAccuracyThresholds:
video_ssim_mean: float
video_ssim_min: float
video_psnr_mean_db: float
audio_relative_l2: float
audio_cosine_similarity: float
STRICT_THRESHOLDS = LTXAccuracyThresholds(
video_ssim_mean=0.99,
video_ssim_min=0.99,
video_psnr_mean_db=40.0,
audio_relative_l2=0.10,
audio_cosine_similarity=0.99,
)
def test_ltx_reference_runner_unwraps_flattened_pipeline_output() -> None:
from vllm_omni.outputs import OmniRequestOutput
from .run_ltx25_reference import _omni_worker_peak_memory_mb, _unwrap_omni_output
frame = object()
audio = object()
output = OmniRequestOutput(
stage_id=0,
images=[frame],
_multimodal_output={"audio": audio, "audio_sample_rate": 48_000},
)
frames, actual_audio, sample_rate = _unwrap_omni_output(output)
assert frames is frame
assert actual_audio is audio
assert sample_rate == 48_000
output.peak_memory_mb = 12_345.0
assert _omni_worker_peak_memory_mb([output]) == 12_345.0
def _run(command: list[str], *, env: dict[str, str], timeout: int = 1800) -> None:
start = time.perf_counter()
subprocess.run(command, env=env, timeout=timeout, check=True)
print(f"{' '.join(command[:3])} finished in {time.perf_counter() - start:.1f}s")
def _clone_pinned_source(root: Path, repository: str, revision: str) -> None:
root.mkdir(parents=True)
subprocess.run(["git", "init", "-q", str(root)], check=True)
subprocess.run(["git", "-C", str(root), "remote", "add", "origin", repository], check=True)
last_error: subprocess.CalledProcessError | None = None
for attempt in range(3):
try:
subprocess.run(
["git", "-C", str(root), "fetch", "--depth", "1", "origin", revision],
check=True,
)
last_error = None
break
except subprocess.CalledProcessError as error:
last_error = error
if attempt < 2:
time.sleep(5 * (attempt + 1))
if last_error is not None:
raise last_error
subprocess.run(["git", "-C", str(root), "checkout", "-q", "--detach", "FETCH_HEAD"], check=True)
def _git_revision(root: Path) -> str | None:
try:
return subprocess.check_output(
["git", "-C", str(root), "rev-parse", "HEAD"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
except subprocess.CalledProcessError:
return None
def _ltx25_official_source(artifact_root: Path) -> tuple[Path, str]:
repository = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_REPOSITORY", OFFICIAL_REPOSITORY)
revision = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_REVISION", LTX25_OFFICIAL_REVISION)
configured_root = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_ROOT")
root = Path(configured_root) if configured_root else artifact_root / f"official-source-{revision[:12]}"
actual_revision = _git_revision(root) if root.exists() else None
if actual_revision != revision and configured_root:
raise AssertionError(f"Official LTX-2.5 source revision mismatch: {actual_revision} != {revision}")
if actual_revision != revision:
if root.exists():
shutil.rmtree(root)
_clone_pinned_source(root, repository, revision)
actual_revision = _git_revision(root)
assert actual_revision == revision, f"Official LTX-2.5 source revision mismatch: {actual_revision} != {revision}"
return root, revision
def _official_runner_prefix() -> list[str]:
"""Run the reference with its missing binary dependency isolated from CI."""
uv = shutil.which("uv")
assert uv is not None, "uv is required to run the pinned official LTX reference"
return [
uv,
"run",
"--no-project",
"--with",
f"openimageio=={OFFICIAL_OPENIMAGEIO_VERSION}",
"--python",
sys.executable,
"python",
]
def _resolve_ltx25_omni_model(*, transformer_subfolder: str = "transformer") -> tuple[Path, str, str | None]:
if transformer_subfolder not in {"transformer", "transformer_full"}:
raise ValueError(f"Unsupported LTX-2.5 transformer subfolder: {transformer_subfolder!r}")
configured_model = os.environ.get("VLLM_TEST_LTX25_MODEL")
configured_revision = os.environ.get("VLLM_TEST_LTX25_MODEL_REVISION")
if configured_model and Path(configured_model).exists():
model = Path(configured_model).resolve()
snapshot_revision = model.name if model.parent.name == "snapshots" else None
assert (model / transformer_subfolder).is_dir(), f"LTX-2.5 component not found: {model / transformer_subfolder}"
assert (model / "diffusion_decoder" / "config.json").is_file(), (
f"LTX-2.5 DiffVAE config not found: {model / 'diffusion_decoder' / 'config.json'}"
)
return model, configured_model, configured_revision or snapshot_revision
model_id = configured_model or LTX25_OMNI_MODEL_ID
revision = configured_revision
if revision is None and model_id == LTX25_OMNI_MODEL_ID:
revision = LTX25_OMNI_MODEL_REVISION
model = Path(
snapshot_download(
repo_id=model_id,
revision=revision,
allow_patterns=[
"model_index.json",
"audio_vae/*",
"connectors/config.json",
"connectors/diffusion_pytorch_model.safetensors.index.json",
"connectors/diffusion_pytorch_model-*.safetensors",
"diffusion_decoder/config.json",
"scheduler/*",
"text_encoder/config.json",
"text_encoder/generation_config.json",
"text_encoder/model*",
"tokenizer/*",
f"{transformer_subfolder}/*",
"vae/*",
"vocoder/*",
],
)
).resolve()
snapshot_revision = model.name if model.parent.name == "snapshots" else None
return model, model_id, snapshot_revision or revision
def _resolve_ltx25_official_artifacts(
*, checkpoint_variant: str = "distilled"
) -> tuple[dict[str, Path], str, str | None]:
try:
variant_files = LTX25_OFFICIAL_VARIANT_FILES[checkpoint_variant]
except KeyError as exc:
raise ValueError(f"Unsupported official LTX-2.5 checkpoint variant: {checkpoint_variant!r}") from exc
files = {**LTX25_OFFICIAL_COMMON_FILES, **variant_files}
configured_model = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL")
configured_revision = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL_REVISION")
if configured_model and Path(configured_model).exists():
root = Path(configured_model).resolve()
model_source = configured_model
snapshot_revision = root.name if root.parent.name == "snapshots" else None
resolved_revision = configured_revision or snapshot_revision
else:
model_source = configured_model or LTX25_OFFICIAL_MODEL_ID
revision = configured_revision
if revision is None and model_source == LTX25_OFFICIAL_MODEL_ID:
revision = LTX25_OFFICIAL_MODEL_REVISION
root = Path(
snapshot_download(
repo_id=model_source,
revision=revision,
allow_patterns=list(files.values()),
)
).resolve()
snapshot_revision = root.name if root.parent.name == "snapshots" else None
resolved_revision = snapshot_revision or revision
artifacts = {name: root / relative_path for name, relative_path in files.items()}
missing = [f"{name}: {path}" for name, path in artifacts.items() if not path.is_file()]
assert not missing, f"Official LTX-2.5 artifacts are missing: {', '.join(missing)}"
return artifacts, model_source, resolved_revision
def _resolve_gemma_root(model: Path) -> Path:
configured_root = os.environ.get("VLLM_TEST_LTX_GEMMA_ROOT")
if configured_root:
root = Path(configured_root)
assert root.is_dir(), f"Gemma root not found: {root}"
return root
return model
def _resolve_ltx25_image() -> Path:
configured_image = os.environ.get("VLLM_TEST_LTX25_I2V_IMAGE")
if configured_image:
image = Path(configured_image)
assert image.is_file(), f"LTX-2.5 I2V conditioning image not found: {image}"
return image
return Path(
hf_hub_download(
repo_id=LTX25_I2V_IMAGE_REPO,
repo_type="dataset",
filename=LTX25_I2V_IMAGE_FILENAME,
revision=LTX25_I2V_IMAGE_REVISION,
)
)
def _require_ltx25_model_access() -> None:
configured_omni_model = os.environ.get("VLLM_TEST_LTX25_MODEL")
if not (configured_omni_model and Path(configured_omni_model).exists()):
omni_model = configured_omni_model or LTX25_OMNI_MODEL_ID
omni_revision = os.environ.get("VLLM_TEST_LTX25_MODEL_REVISION")
if omni_revision is None and omni_model == LTX25_OMNI_MODEL_ID:
omni_revision = LTX25_OMNI_MODEL_REVISION
skip_if_gated_repo_inaccessible(
omni_model,
revision=omni_revision,
filename="model_index.json",
)
configured_official_model = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL")
if not (configured_official_model and Path(configured_official_model).exists()):
official_model = configured_official_model or LTX25_OFFICIAL_MODEL_ID
official_revision = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL_REVISION")
if official_revision is None and official_model == LTX25_OFFICIAL_MODEL_ID:
official_revision = LTX25_OFFICIAL_MODEL_REVISION
skip_if_gated_repo_inaccessible(
official_model,
revision=official_revision,
filename=LTX25_OFFICIAL_COMMON_FILES["text_encoder"],
)
def _ltx25_request(image: Path | None = None) -> dict[str, object]:
request: dict[str, object] = {
"prompt": LTX25_PROMPT,
# Keep the nightly parity test small enough to run the pinned official
# pipeline and Omni sequentially on one H100. The public recipe covers
# the release-resolution 1920x1088x121 configuration.
"width": 1024,
"height": 576,
"num_frames": 25,
"fps": 24,
"num_inference_steps": 8,
"seed": 42,
}
if image is not None:
request.update(image=str(image.resolve()), image_crf=18)
return request
def _ltx25_full_sigmas(num_inference_steps: int) -> list[float]:
"""Materialize the official LTX-2.5 one-stage schedule in FP32."""
sigmas = torch.linspace(1.0, 0.0, num_inference_steps + 1)
base_anchor = 1024
max_anchor = 4096
base_shift = 0.95
max_shift = 2.05
slope = (max_shift - base_shift) / (max_anchor - base_anchor)
sigma_shift = max_anchor * slope + (base_shift - slope * base_anchor)
exp_shift = math.exp(sigma_shift)
sigmas = torch.where(sigmas != 0, exp_shift / (exp_shift + (1 / sigmas - 1)), 0)
non_zero = sigmas != 0
one_minus_sigmas = 1.0 - sigmas[non_zero]
scale = one_minus_sigmas[-1] / (1.0 - 0.1)
sigmas[non_zero] = 1.0 - one_minus_sigmas / scale
return [float(sigma) for sigma in sigmas]
def _ltx25_full_request(image: Path | None = None) -> dict[str, object]:
num_inference_steps = 30
request: dict[str, object] = {
"prompt": LTX25_PROMPT,
"negative_prompt": NEGATIVE_PROMPT,
# Full guidance expands to a four-way batch. Keep the decoded output
# small enough for the pinned official and Omni runs to share one H100.
"width": 512,
"height": 384,
"num_frames": 25,
"fps": 24,
"num_inference_steps": num_inference_steps,
"seed": 42,
"video_cfg_scale": 3.0,
"audio_cfg_scale": 7.0,
"video_stg_scale": 1.0,
"audio_stg_scale": 1.0,
"video_modality_scale": 3.0,
"audio_modality_scale": 3.0,
"video_rescale_scale": 0.7,
"audio_rescale_scale": 0.7,
"video_stg_blocks": [28],
"audio_stg_blocks": [28],
"sigmas": _ltx25_full_sigmas(num_inference_steps),
}
if image is not None:
request.update(image=str(image.resolve()), image_crf=18)
return request
def test_ltx25_full_request_pins_official_schedule() -> None:
request = _ltx25_full_request()
sigmas = request["sigmas"]
assert isinstance(sigmas, list)
assert isinstance(request["num_inference_steps"], int)
assert len(sigmas) == request["num_inference_steps"] + 1
assert math.isclose(sigmas[0], 1.0, rel_tol=0.0, abs_tol=1e-6)
assert sigmas[-1] == 0.0
assert all(sigmas[index] > sigmas[index + 1] for index in range(len(sigmas) - 1))
def test_ltx25_i2v_requests_pin_official_crf() -> None:
image = Path("conditioning.png")
for request in (_ltx25_request(image), _ltx25_full_request(image)):
assert request["image"] == str(image.resolve())
assert request["image_crf"] == 18
def _video_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, float]:
assert reference.shape == prediction.shape
assert reference.ndim == 4 and reference.shape[-1] == 3
ssim_scores: list[float] = []
psnr_scores: list[float] = []
for reference_frame, prediction_frame in zip(reference, prediction, strict=True):
reference_tensor = torch.from_numpy(reference_frame).permute(2, 0, 1).unsqueeze(0)
prediction_tensor = torch.from_numpy(prediction_frame).permute(2, 0, 1).unsqueeze(0)
ssim_scores.append(float(StructuralSimilarityIndexMeasure(data_range=1.0)(prediction_tensor, reference_tensor)))
psnr_scores.append(float(PeakSignalNoiseRatio(data_range=1.0)(prediction_tensor, reference_tensor)))
difference = np.abs(reference.astype(np.float64) - prediction.astype(np.float64))
return {
"ssim_mean": float(np.mean(ssim_scores)),
"ssim_min": float(np.min(ssim_scores)),
"psnr_mean_db": float(np.mean(psnr_scores)),
"max_abs": float(difference.max()),
"mean_abs": float(difference.mean()),
}
def _canonical_audio(audio: np.ndarray) -> np.ndarray:
while audio.ndim > 2 and audio.shape[0] == 1:
audio = audio[0]
return audio.astype(np.float64)
def _audio_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, float | bool]:
reference = _canonical_audio(reference)
prediction = _canonical_audio(prediction)
assert reference.shape == prediction.shape
difference = reference - prediction
reference_norm = max(float(np.linalg.norm(reference)), 1e-12)
prediction_norm = max(float(np.linalg.norm(prediction)), 1e-12)
return {
"bitwise_equal": bool(np.array_equal(reference, prediction)),
"max_abs": float(np.abs(difference).max()),
"mean_abs": float(np.abs(difference).mean()),
"relative_l2": float(np.linalg.norm(difference) / reference_norm),
"cosine_similarity": float(np.vdot(reference.ravel(), prediction.ravel()) / (reference_norm * prediction_norm)),
}
def _assert_strict_similarity(
video_metrics: dict[str, float],
audio_metrics: dict[str, float | bool],
) -> None:
assert video_metrics["ssim_mean"] >= STRICT_THRESHOLDS.video_ssim_mean
assert video_metrics["ssim_min"] >= STRICT_THRESHOLDS.video_ssim_min
assert video_metrics["psnr_mean_db"] >= STRICT_THRESHOLDS.video_psnr_mean_db
assert audio_metrics["relative_l2"] <= STRICT_THRESHOLDS.audio_relative_l2
assert audio_metrics["cosine_similarity"] >= STRICT_THRESHOLDS.audio_cosine_similarity
@pytest.mark.slow
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("task", ["t2v", "i2v"])
def test_ltx25_distilled_two_stage_matches_official(accuracy_artifact_root: Path, task: str) -> None:
"""Compare Omni with official canonical DiffVAE and matching connector weights."""
_require_ltx25_model_access()
artifact_parent = accuracy_artifact_root / "ltx_official"
output_root = reset_artifact_dir(artifact_parent / f"ltx2_5_distilled_{task}")
official_root, official_revision = _ltx25_official_source(artifact_parent)
official_artifacts, official_model_source, official_model_revision = _resolve_ltx25_official_artifacts()
omni_model, omni_model_source, omni_model_revision = _resolve_ltx25_omni_model()
image = _resolve_ltx25_image() if task == "i2v" else None
request_path = output_root / "request.json"
request_path.write_text(json.dumps(_ltx25_request(image), indent=2) + "\n")
runner = Path(__file__).with_name("run_ltx25_reference.py")
runner_args = [
str(runner),
"--request",
str(request_path),
"--enable-layerwise-offload",
]
env = os.environ.copy()
env["VLLM_TEST_LTX_OFFICIAL_REVISION"] = official_revision
env["PYTHONUNBUFFERED"] = "1"
repository_root = Path(__file__).resolve().parents[4]
existing_pythonpath = env.get("PYTHONPATH")
env["PYTHONPATH"] = (
str(repository_root) if not existing_pythonpath else f"{repository_root}{os.pathsep}{existing_pythonpath}"
)
official_output = output_root / "official"
_run(
_official_runner_prefix()
+ runner_args
+ [
"--backend",
"official",
"--output-dir",
str(official_output),
"--official-root",
str(official_root),
"--transformer-path",
str(official_artifacts["transformer"]),
"--text-encoder-path",
str(official_artifacts["text_encoder"]),
"--video-vae-path",
str(official_artifacts["video_vae"]),
"--audio-vae-path",
str(official_artifacts["audio_vae"]),
"--spatial-upsampler-path",
str(official_artifacts["spatial_upsampler"]),
"--connector-model",
str(omni_model),
],
env=env,
)
omni_output = output_root / "omni"
_run(
[sys.executable]
+ runner_args
+ [
"--backend",
"omni",
"--output-dir",
str(omni_output),
"--model",
str(omni_model),
"--model-class-name",
"LTX2DistilledTwoStagePipeline",
],
env=env,
)
official_metadata = json.loads((official_output / "metadata.json").read_text())
omni_metadata = json.loads((omni_output / "metadata.json").read_text())
assert official_metadata["official_revision"] == official_revision
assert official_metadata["pipeline"] == "DistilledPipeline"
assert official_metadata["video_vae_path"] == str(official_artifacts["video_vae"])
assert official_metadata["attention_backend"] == ATTENTION_BACKEND
assert official_metadata["connector_model"] == str(omni_model)
assert omni_metadata["model_class_name"] == "LTX2DistilledTwoStagePipeline"
assert omni_metadata["attention_backend"] == ATTENTION_BACKEND
assert official_metadata["audio_sample_rate"] == omni_metadata["audio_sample_rate"]
video_metrics = _video_metrics(
np.load(official_output / "video.npy"),
np.load(omni_output / "video.npy"),
)
audio_metrics = _audio_metrics(
np.load(official_output / "audio.npy"),
np.load(omni_output / "audio.npy"),
)
result = {
"case": "ltx2_5_distilled",
"task": task,
"attention_backend": ATTENTION_BACKEND,
"official_revision": official_revision,
"official_model": official_model_source,
"official_model_revision": official_model_revision,
"official_connector_model": omni_model_source,
"official_connector_revision": omni_model_revision,
"omni_model": omni_model_source,
"omni_model_revision": omni_model_revision,
"resolved_omni_model_path": str(omni_model),
"thresholds": asdict(STRICT_THRESHOLDS),
"peak_memory_mb": {
"official_allocated": official_metadata["peak_memory_allocated_mb"],
"official_reserved": official_metadata["peak_memory_reserved_mb"],
"omni_worker_reported": omni_metadata["worker_peak_memory_mb"],
"omni_parent_allocated": omni_metadata["peak_memory_allocated_mb"],
"omni_parent_reserved": omni_metadata["peak_memory_reserved_mb"],
},
"video": video_metrics,
"audio": audio_metrics,
}
(output_root / "metrics.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))
_assert_strict_similarity(video_metrics, audio_metrics)
@pytest.mark.slow
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("task", ["t2v", "i2v"])
@pytest.mark.parametrize("pipeline_mode", ["full_one_stage", "full_two_stage"])
def test_ltx25_full_matches_official(accuracy_artifact_root: Path, task: str, pipeline_mode: str) -> None:
"""Compare raw official Full/SFT and DiffVAE weights with Omni."""
_require_ltx25_model_access()
artifact_parent = accuracy_artifact_root / "ltx_official"
output_root = reset_artifact_dir(artifact_parent / f"ltx2_5_{pipeline_mode}_{task}")
model_class_name = {
"full_one_stage": "LTX2Pipeline",
"full_two_stage": "LTX2TwoStagePipeline",
}[pipeline_mode]
official_root, official_revision = _ltx25_official_source(artifact_parent)
official_artifacts, official_model_source, official_model_revision = _resolve_ltx25_official_artifacts(
checkpoint_variant="full" if pipeline_mode == "full_one_stage" else "full_two_stage"
)
omni_model, omni_model_source, omni_model_revision = _resolve_ltx25_omni_model(
transformer_subfolder="transformer_full"
)
image = _resolve_ltx25_image() if task == "i2v" else None
request = _ltx25_full_request(image)
if pipeline_mode == "full_two_stage":
request["stage_1_sigmas"] = request.pop("sigmas")
request["stage_2_sigmas"] = LTX25_STAGE_2_SIGMAS
request_path = output_root / "request.json"
request_path.write_text(json.dumps(request, indent=2) + "\n")
runner = Path(__file__).with_name("run_ltx25_reference.py")
runner_args = [
str(runner),
"--request",
str(request_path),
"--enable-layerwise-offload",
]
env = os.environ.copy()
env["VLLM_TEST_LTX_OFFICIAL_REVISION"] = official_revision
env["PYTHONUNBUFFERED"] = "1"
repository_root = Path(__file__).resolve().parents[4]
existing_pythonpath = env.get("PYTHONPATH")
env["PYTHONPATH"] = (
str(repository_root) if not existing_pythonpath else f"{repository_root}{os.pathsep}{existing_pythonpath}"
)
official_output = output_root / "official"
official_sidecar_args: list[str] = []
if pipeline_mode == "full_two_stage":
official_sidecar_args = [
"--spatial-upsampler-path",
str(official_artifacts["spatial_upsampler"]),
"--distilled-lora-path",
str(official_artifacts["distilled_lora"]),
]
_run(
_official_runner_prefix()
+ runner_args
+ [
"--backend",
"official",
"--output-dir",
str(official_output),
"--official-root",
str(official_root),
"--official-pipeline",
pipeline_mode,
"--transformer-path",
str(official_artifacts["transformer"]),
"--text-encoder-path",
str(official_artifacts["text_encoder"]),
"--video-vae-path",
str(official_artifacts["video_vae"]),
"--audio-vae-path",
str(official_artifacts["audio_vae"]),
"--connector-model",
str(omni_model),
]
+ official_sidecar_args,
env=env,
)
omni_output = output_root / "omni"
_run(
[sys.executable]
+ runner_args
+ [
"--backend",
"omni",
"--output-dir",
str(omni_output),
"--model",
str(omni_model),
"--model-class-name",
model_class_name,
],
env=env,
)
official_metadata = json.loads((official_output / "metadata.json").read_text())
omni_metadata = json.loads((omni_output / "metadata.json").read_text())
assert official_metadata["official_revision"] == official_revision
assert (
official_metadata["pipeline"]
== {
"full_one_stage": "TI2VidOneStagePipeline",
"full_two_stage": "TI2VidTwoStagesPipeline",
}[pipeline_mode]
)
assert official_metadata["video_vae_path"] == str(official_artifacts["video_vae"])
assert official_metadata["attention_backend"] == ATTENTION_BACKEND
assert official_metadata["connector_model"] == str(omni_model)
assert omni_metadata["model_class_name"] == model_class_name
assert omni_metadata["attention_backend"] == ATTENTION_BACKEND
assert official_metadata["seed"] == omni_metadata["seed"]
for schedule_name in ("sigmas", "stage_1_sigmas", "stage_2_sigmas"):
assert official_metadata[schedule_name] == omni_metadata[schedule_name]
assert official_metadata["audio_sample_rate"] == omni_metadata["audio_sample_rate"]
video_metrics = _video_metrics(
np.load(official_output / "video.npy"),
np.load(omni_output / "video.npy"),
)
audio_metrics = _audio_metrics(
np.load(official_output / "audio.npy"),
np.load(omni_output / "audio.npy"),
)
result = {
"case": f"ltx2_5_{pipeline_mode}",
"task": task,
"attention_backend": ATTENTION_BACKEND,
"official_revision": official_revision,
"official_model": official_model_source,
"official_model_revision": official_model_revision,
"official_connector_model": omni_model_source,
"official_connector_revision": omni_model_revision,
"omni_model": omni_model_source,
"omni_model_revision": omni_model_revision,
"resolved_omni_model_path": str(omni_model),
"thresholds": asdict(STRICT_THRESHOLDS),
"peak_memory_mb": {
"official_allocated": official_metadata["peak_memory_allocated_mb"],
"official_reserved": official_metadata["peak_memory_reserved_mb"],
"omni_worker_reported": omni_metadata["worker_peak_memory_mb"],
"omni_parent_allocated": omni_metadata["peak_memory_allocated_mb"],
"omni_parent_reserved": omni_metadata["peak_memory_reserved_mb"],
},
"video": video_metrics,
"audio": audio_metrics,
}
(output_root / "metrics.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))
_assert_strict_similarity(video_metrics, audio_metrics)
@@ -3,9 +3,8 @@
"""E2E accuracy guard against a pinned Lightricks LTX pipeline revision.
The original reduced one-stage guards remain unchanged. Four complementary
default-shape cases cover both model versions, both two-stage weight families,
and T2V/I2V without expanding every combination into a duplicate golden.
Four complementary default-shape cases cover both model versions, both
two-stage weight families, and T2V/I2V without duplicating one-stage coverage.
"""
from __future__ import annotations
@@ -69,25 +68,23 @@ class LTXAccuracyThresholds:
video_ssim_mean: float
video_ssim_min: float
video_psnr_mean_db: float
# Zero disables gating for waveform-sensitive audio that is not expected
# to align with the official output.
audio_relative_l2: float
audio_cosine_similarity: float
STRICT_THRESHOLDS = LTXAccuracyThresholds(
video_ssim_mean=0.95,
video_ssim_min=0.90,
video_psnr_mean_db=30.0,
audio_relative_l2=0.20,
audio_cosine_similarity=0.95,
video_ssim_mean=0.99,
video_ssim_min=0.99,
video_psnr_mean_db=40.0,
audio_relative_l2=0.10,
audio_cosine_similarity=0.99,
)
@dataclass(frozen=True)
class LTXAccuracyCase:
name: str
pipeline_kind: Literal["one_stage", "distilled", "two_stage"]
pipeline_kind: Literal["distilled", "two_stage"]
model_id: str
model_revision: str
model_env: str
@@ -99,8 +96,6 @@ class LTXAccuracyCase:
num_inference_steps: int
seed: int
stg_block: int | None
thresholds: LTXAccuracyThresholds
prompt: str = PROMPT
gemma_model_id: str | None = None
gemma_model_revision: str | None = None
gemma_model_env: str | None = None
@@ -168,59 +163,6 @@ I2V_IMAGE = LTXArtifact(
)
LEGACY_CASES = (
LTXAccuracyCase(
name="ltx2",
pipeline_kind="one_stage",
model_id="Lightricks/LTX-2",
model_revision=LTX2_REVISION,
model_env="VLLM_TEST_LTX2_MODEL",
model_class_name="LTX2Pipeline",
checkpoint=LTX2_CHECKPOINT,
width=512,
height=384,
num_frames=25,
num_inference_steps=20,
seed=42,
stg_block=29,
thresholds=STRICT_THRESHOLDS,
),
LTXAccuracyCase(
name="ltx2_3",
pipeline_kind="one_stage",
model_id="diffusers/LTX-2.3-Diffusers",
model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf",
model_env="VLLM_TEST_LTX23_MODEL",
model_class_name="LTX2Pipeline",
checkpoint=LTX23_CHECKPOINT,
width=512,
height=384,
num_frames=25,
num_inference_steps=20,
seed=42,
stg_block=28,
thresholds=STRICT_THRESHOLDS,
),
LTXAccuracyCase(
name="ltx2_3_i2v",
pipeline_kind="one_stage",
model_id="diffusers/LTX-2.3-Diffusers",
model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf",
model_env="VLLM_TEST_LTX23_MODEL",
model_class_name="LTX2Pipeline",
checkpoint=LTX23_CHECKPOINT,
width=512,
height=384,
num_frames=25,
num_inference_steps=20,
seed=42,
stg_block=28,
thresholds=STRICT_THRESHOLDS,
image=I2V_IMAGE,
),
)
TWO_STAGE_CASES = (
LTXAccuracyCase(
name="ltx2_distilled_t2v",
@@ -240,7 +182,6 @@ TWO_STAGE_CASES = (
gemma_model_id="Lightricks/LTX-2",
gemma_model_revision=LTX2_REVISION,
gemma_model_env="VLLM_TEST_LTX2_MODEL",
thresholds=STRICT_THRESHOLDS,
),
LTXAccuracyCase(
name="ltx23_distilled_i2v",
@@ -260,7 +201,6 @@ TWO_STAGE_CASES = (
gemma_model_id="diffusers/LTX-2.3-Diffusers",
gemma_model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf",
gemma_model_env="VLLM_TEST_LTX23_MODEL",
thresholds=STRICT_THRESHOLDS,
image=I2V_IMAGE,
),
LTXAccuracyCase(
@@ -280,7 +220,6 @@ TWO_STAGE_CASES = (
seed=10,
stg_block=29,
enable_layerwise_offload=True,
thresholds=STRICT_THRESHOLDS,
),
LTXAccuracyCase(
name="ltx23_two_stage_layer_fused_i2v",
@@ -299,12 +238,11 @@ TWO_STAGE_CASES = (
seed=10,
stg_block=28,
enable_layerwise_offload=True,
thresholds=STRICT_THRESHOLDS,
image=I2V_IMAGE,
),
)
CASES = (*LEGACY_CASES, *TWO_STAGE_CASES)
WEEKLY_CASES = TWO_STAGE_CASES
def _run(command: list[str], *, env: dict[str, str], timeout: int = 1800) -> None:
@@ -460,10 +398,49 @@ def _resolve_image(case: LTXAccuracyCase) -> Path | None:
return _resolve_artifact(case.image)
def _omni_model_with_pinned_artifacts(
model: Path,
output_root: Path,
artifacts: tuple[Path | None, ...],
) -> Path:
"""Expose the already-resolved sidecars to Omni without copying the model."""
resolved_artifacts = tuple(artifact for artifact in artifacts if artifact is not None)
if not resolved_artifacts:
return model
overlay = output_root / "omni-model"
overlay.mkdir()
for source in model.iterdir():
(overlay / source.name).symlink_to(source.resolve(), target_is_directory=source.is_dir())
for artifact in resolved_artifacts:
target = overlay / artifact.name
if target.exists():
assert target.samefile(artifact), f"Model sidecar does not match pinned artifact: {target} != {artifact}"
else:
target.symlink_to(artifact.resolve())
return overlay
def test_omni_model_overlay_pins_artifacts(tmp_path: Path) -> None:
model = tmp_path / "model"
model.mkdir()
component = model / "model_index.json"
component.write_text("{}\n")
artifact = tmp_path / "sidecar.safetensors"
artifact.write_bytes(b"pinned")
output_root = tmp_path / "output"
output_root.mkdir()
overlay = _omni_model_with_pinned_artifacts(model, output_root, (artifact,))
assert (overlay / component.name).samefile(component)
assert (overlay / artifact.name).samefile(artifact)
def _request(case: LTXAccuracyCase, image: Path | None) -> dict[str, object]:
request: dict[str, object] = {
"pipeline_kind": case.pipeline_kind,
"prompt": case.prompt,
"prompt": PROMPT,
"negative_prompt": "" if case.pipeline_kind == "distilled" else NEGATIVE_PROMPT,
"width": case.width,
"height": case.height,
@@ -473,14 +450,17 @@ def _request(case: LTXAccuracyCase, image: Path | None) -> dict[str, object]:
"seed": case.seed,
}
if case.pipeline_kind != "distilled":
assert case.stg_block is not None
request.update(
video_cfg_scale=3.0,
audio_cfg_scale=7.0,
video_stg_scale=1.0,
audio_stg_scale=1.0,
video_modality_scale=3.0,
audio_modality_scale=3.0,
)
if case.pipeline_kind == "two_stage":
assert case.stg_block is not None
request.update(
video_stg_scale=1.0,
audio_stg_scale=1.0,
video_rescale_scale=0.7,
audio_rescale_scale=0.7,
video_stg_blocks=[case.stg_block],
@@ -543,8 +523,8 @@ def _audio_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, f
}
@pytest.mark.parametrize("case", CASES, ids=lambda case: case.name)
@pytest.mark.full_model
@pytest.mark.parametrize("case", WEEKLY_CASES, ids=lambda case: case.name)
@pytest.mark.slow
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@@ -561,9 +541,11 @@ def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Pat
checkpoint = _resolve_artifact(case.checkpoint, model)
spatial_upsampler = _resolve_artifact(case.spatial_upsampler, model) if case.spatial_upsampler is not None else None
distilled_lora = _resolve_artifact(case.distilled_lora, model) if case.distilled_lora is not None else None
omni_model = _omni_model_with_pinned_artifacts(model, output_root, (spatial_upsampler, distilled_lora))
image = _resolve_image(case)
request = _request(case, image)
request_path = output_root / "request.json"
request_path.write_text(json.dumps(_request(case, image), indent=2) + "\n")
request_path.write_text(json.dumps(request, indent=2) + "\n")
runner = Path(__file__).with_name("run_ltx_reference.py")
runner_args = [
@@ -619,7 +601,7 @@ def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Pat
"--output-dir",
str(omni_output),
"--model",
str(model),
str(omni_model),
"--model-class-name",
case.model_class_name,
]
@@ -651,18 +633,16 @@ def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Pat
"spatial_upsampler_revision": (case.spatial_upsampler.revision if case.spatial_upsampler is not None else None),
"distilled_lora_revision": case.distilled_lora.revision if case.distilled_lora is not None else None,
"enable_layerwise_offload": enable_layerwise_offload,
"thresholds": asdict(case.thresholds),
"request": _request(case, image),
"thresholds": asdict(STRICT_THRESHOLDS),
"request": request,
"video": video_metrics,
"audio": audio_metrics,
}
(output_root / "metrics.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))
assert video_metrics["ssim_mean"] >= case.thresholds.video_ssim_mean
assert video_metrics["ssim_min"] >= case.thresholds.video_ssim_min
assert video_metrics["psnr_mean_db"] >= case.thresholds.video_psnr_mean_db
if case.thresholds.audio_relative_l2 > 0:
assert audio_metrics["relative_l2"] <= case.thresholds.audio_relative_l2
if case.thresholds.audio_cosine_similarity > 0:
assert audio_metrics["cosine_similarity"] >= case.thresholds.audio_cosine_similarity
assert video_metrics["ssim_mean"] >= STRICT_THRESHOLDS.video_ssim_mean
assert video_metrics["ssim_min"] >= STRICT_THRESHOLDS.video_ssim_min
assert video_metrics["psnr_mean_db"] >= STRICT_THRESHOLDS.video_psnr_mean_db
assert audio_metrics["relative_l2"] <= STRICT_THRESHOLDS.audio_relative_l2
assert audio_metrics["cosine_similarity"] >= STRICT_THRESHOLDS.audio_cosine_similarity
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""MiniCPM-o 4.5 Daily-Omni + Seed-TTS accuracy regression coverage.
Daily-Omni settings follow the MiniCPM interleaved AV recipe that reaches
@@ -21,13 +22,25 @@ from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import (
build_acc_benchmark_cli_argv,
find_vllm_cli,
)
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import SERVER_PARAMS as DUPLEX_TEST_PARAMS
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
DEPLOY_CONFIG as _DUPLEX_DEPLOY_CONFIG,
)
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS as DUPLEX_TEST_PARAMS,
)
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
_MODEL = os.environ.get("VLLM_TEST_MINICPMO_4_5_MODEL", "openbmb/MiniCPM-o-4_5")
_DEPLOY_CONFIG = get_deploy_config_path("minicpmo_4_5.yaml")
_DUPLEX_BF16_DEPLOY_CONFIG = modify_stage_config(
_DUPLEX_DEPLOY_CONFIG,
updates={
"base_config": _DEPLOY_CONFIG,
"connectors.connector_of_shared_memory.extra.code2wav_bfloat16_attention_cache": True,
},
)
_RESULT_DIR = Path(
os.environ.get(
"ACC_BENCH_RESULT_DIR",
@@ -91,6 +104,20 @@ seed_test_params = [
]
duplex_accuracy_test_params = [
*DUPLEX_TEST_PARAMS,
pytest.param(
OmniServerParams(
model=_MODEL,
stage_config_path=_DUPLEX_BF16_DEPLOY_CONFIG,
use_stage_cli=False,
server_args=list(_SEED_TTS_SERVER_ARGS),
),
id="three-stage-single-gpu-bf16-attention-cache",
),
]
def _require_vllm_cli() -> None:
try:
find_vllm_cli()
@@ -174,7 +201,7 @@ def test_minicpmo_4_5_seed_tts_wer_bench(omni_server) -> None:
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", DUPLEX_TEST_PARAMS, indirect=True)
@pytest.mark.parametrize("omni_server", duplex_accuracy_test_params, indirect=True)
def test_minicpmo_4_5_duplex_seed_tts_wer_bench(omni_server) -> None:
"""Gate Seed-TTS WER through the explicit Realtime TTS contract."""
_require_vllm_cli()
@@ -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
from __future__ import annotations
@@ -119,6 +119,9 @@ def _model_name(model_env_var: str = MODEL_ENV_VAR, component: str = "FL2VA") ->
def _server_args() -> list[str]:
return [
"--trust-remote-code",
# Both official-reference requests use fixed shapes. Keep regional
# compilation static so cold Inductor codegen is stable across CI nodes.
"--no-diffusion-compile-dynamic",
"--num-gpus",
"4",
"--usp",
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Shared helpers for Qwen3-Omni Daily-Omni / Seed-TTS ``vllm bench serve --omni`` accuracy runs.
Local dataset paths are **optional**. When ``VLLM_DAILY_OMNI_QA_JSON`` + ``VLLM_DAILY_OMNI_VIDEO_DIR``
@@ -28,7 +29,7 @@ DEFAULT_SEED_TTS_HF_REPO = "zhaochenyang20/seed-tts-eval"
class OmniBenchServerEndpoint(Protocol):
"""Anything with ``host`` / ``port`` / ``model`` (e.g. :class:`tests.conftest.OmniServer`)."""
"""Anything with ``host`` / ``port`` / ``model`` (e.g. :class:`tests.helpers.runtime.OmniServer`)."""
host: str
port: int
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Qwen3-Omni accuracy benchmarks (Daily-Omni MCQ + Seed-TTS WER) via ``vllm bench serve --omni``.
Starts a **module-scoped** Omni OpenAI-compatible server (same pattern as ``tests/dfx/perf`` and
@@ -67,12 +68,8 @@ def get_chunk_config(config_path: str | None = None):
"""Load the qwen3_omni CI deploy yaml with async_chunk modifications for streaming mode."""
if config_path is None:
config_path = _CI_DEPLOY
# TODO: remove this workaround once legacy `stage_args` path is deleted.
# The pipeline (qwen3_omni/pipeline.py) already wires
# thinker2talker_async_chunk / talker2code2wav_async_chunk on stage 0/1,
# so only async_chunk needs flipping. Writing nested `engine_args:` into
# the new-schema overlay trips _parse_stage_deploy's legacy branch and
# drops flat fields (load_format, max_num_seqs, ...).
# Pipeline already wires thinker2talker_async_chunk / talker2code2wav_async_chunk
# on stage 0/1; only the top-level async_chunk flag needs flipping here.
return modify_stage_config(config_path, updates={"async_chunk": True})
+63
View File
@@ -0,0 +1,63 @@
# SANA-Video golden outputs
The v2 golden set covers both text-to-video and image-to-video for each
checkpoint variant. Generate references with the Diffusers version pinned by
this repository and the immutable Hub revisions embedded in
`generate_goldens.py`:
```bash
python tests/e2e/accuracy/sana_video/generate_goldens.py \
--variant 480p --task t2v
python tests/e2e/accuracy/sana_video/generate_goldens.py \
--variant 480p --task i2v --input-image /path/to/fixed-input.png
python tests/e2e/accuracy/sana_video/generate_goldens.py \
--variant 720p --task t2v
python tests/e2e/accuracy/sana_video/generate_goldens.py \
--variant 720p --task i2v --input-image /path/to/fixed-input.png
```
Use the same reviewed input image for both I2V variants. The generator
normalizes it to an RGB PNG and includes that exact file and its SHA256 in the
case manifest, so an I2V reference is self-contained.
Each case is written under:
```text
<output-root>/<variant>/<task>/
```
It contains:
- `transformer_case.safetensors`: deterministic transformer inputs and output.
- `pipeline.mp4`: the encoded reference used by online similarity tests.
- `pipeline_reference.safetensors`: final denoised latents and decoded,
pre-MP4 frames quantized to the exact uint8 video input.
- `input.png`: the normalized conditioning image for I2V only.
- `metadata.json`: model, generator, scheduler, input and sampling provenance.
- `manifest.json`: the size and SHA256 of every artifact.
## Publication requirements
A canonical golden must be generated from the pinned Hugging Face model ID and
immutable commit. The generator also requires a clean vLLM-Omni worktree and
records the repository revision plus the generator script hash.
`--model` is available for local development. It hashes every file in the local
checkpoint directory and records optional `--local-source-model` and
`--local-source-revision` claims, but marks the result `publishable: false`.
The golden test rejects such a manifest. A local directory hash proves which
bytes were used; it does not prove that those bytes match a claimed upstream
commit.
After reviewing the metadata, input image and generated videos, upload each
publishable case directory without renaming files to:
```text
s3://vllm-public-assets/omni-assets/sana-video/v2/<variant>/<task>/
```
Set `SANA_VIDEO_GOLDEN_BASE_URL` to the corresponding `v2` HTTP base URL when
running the tests. Uploading is intentionally separate from generation so test
runs cannot overwrite a frozen reference. The storage location must prevent
in-place mutation; publish a new version prefix instead of replacing any v2
file when a model, dependency, input or sampling parameter changes.
@@ -0,0 +1,380 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Generate frozen SANA-Video transformer and pipeline reference artifacts."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import time
from pathlib import Path
from typing import Any
import numpy as np
import torch
from diffusers import SanaImageToVideoPipeline, SanaVideoPipeline
from diffusers.utils import export_to_video
from PIL import Image
from safetensors.torch import save_file
PROMPT = "A cat walking on the grass, facing the camera. motion score: 30."
NEGATIVE_PROMPT = (
"A chaotic sequence with misshapen, deformed limbs in heavy motion blur, sudden disappearance, jump cuts, "
"jerky movements, rapid shot changes, frames out of sync, inconsistent character shapes, temporal artifacts, "
"jitter, and ghosting effects, creating a disorienting visual experience."
)
SCHEMA_VERSION = 2
SEED = 42
NUM_FRAMES = 81
FPS = 16
NUM_INFERENCE_STEPS = 50
GUIDANCE_SCALE = 6.0
VARIANTS = {
"480p": {
"model": "Efficient-Large-Model/SANA-Video_2B_480p_diffusers",
"revision": "fed3bce411c58a0f688a31afe8f52e61acc2b15f",
"height": 480,
"width": 832,
"vae_dtype": torch.float32,
"transformer_case_shape": (1, 16, 3, 8, 8),
},
"720p": {
"model": "Efficient-Large-Model/SANA-Video_2B_720p_diffusers",
"revision": "8bda5e623d0f48cd6da3b387b10ca35d15cf1c4e",
"height": 704,
"width": 1280,
"vae_dtype": torch.bfloat16,
"transformer_case_shape": (1, 128, 2, 4, 4),
},
}
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _json_sha256(payload: Any) -> str:
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()
return hashlib.sha256(encoded).hexdigest()
def _directory_fingerprint(root: Path) -> dict[str, Any]:
"""Fingerprint every local checkpoint file without recording host paths."""
if not root.is_dir():
raise ValueError(f"Local checkpoint must be a directory: {root}")
files = []
digest = hashlib.sha256()
for path in sorted(candidate for candidate in root.rglob("*") if candidate.is_file()):
relative = path.relative_to(root).as_posix()
file_sha256 = _sha256(path)
size = path.stat().st_size
entry = f"{relative}\0{size}\0{file_sha256}\n".encode()
digest.update(entry)
files.append({"path": relative, "size": size, "sha256": file_sha256})
if not files:
raise ValueError(f"Local checkpoint contains no files: {root}")
return {
"algorithm": "sha256-tree-v1",
"sha256": digest.hexdigest(),
"file_count": len(files),
"total_size": sum(entry["size"] for entry in files),
}
def _repository_provenance() -> dict[str, Any]:
repo_root = Path(__file__).resolve().parents[4]
revision = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
).stdout.strip()
dirty = bool(
subprocess.run(
["git", "status", "--porcelain", "--untracked-files=no"],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
).stdout.strip()
)
return {
"repository": "vllm-project/vllm-omni",
"revision": revision,
"dirty": dirty,
"generator_sha256": _sha256(Path(__file__)),
}
def _cpu(tensor: torch.Tensor) -> torch.Tensor:
return tensor.detach().cpu().contiguous()
def _normalize_input_image(source: Path, destination: Path) -> Image.Image:
if not source.is_file():
raise ValueError(f"I2V input image does not exist: {source}")
with Image.open(source) as image:
normalized = image.convert("RGB")
normalized.load()
normalized.save(destination, format="PNG", optimize=False)
return normalized
def _frames_to_uint8(frames: np.ndarray) -> torch.Tensor:
tensor = torch.from_numpy(np.asarray(frames))
if tensor.is_floating_point():
# Match diffusers.utils.export_to_video, which truncates rather than
# rounds when converting float frames to the encoder's uint8 input.
tensor = tensor.clamp(0, 1) * 255
return tensor.to(torch.uint8).contiguous()
def _model_provenance(
config: dict[str, Any],
model: str | None,
local_source_model: str | None,
local_source_revision: str | None,
) -> tuple[dict[str, Any], bool]:
if model is None:
return (
{
"kind": "huggingface_hub",
"model_id": config["model"],
"revision": config["revision"],
},
True,
)
local_path = Path(model).expanduser().resolve()
if bool(local_source_model) != bool(local_source_revision):
raise ValueError("--local-source-model and --local-source-revision must be specified together.")
provenance = {
"kind": "local_checkpoint",
"checkpoint_fingerprint": _directory_fingerprint(local_path),
"claimed_source_model": local_source_model,
"claimed_source_revision": local_source_revision,
"verification": "unverified-local-copy",
}
# A directory hash proves which local bytes were used, but not that those
# bytes match a claimed Hub commit. Such output is useful for development
# comparisons but must never be uploaded as the canonical frozen reference.
return provenance, False
def generate(
variant: str,
task: str,
model: str | None,
input_image: Path | None,
output_root: Path,
local_source_model: str | None = None,
local_source_revision: str | None = None,
) -> Path:
config = dict(VARIANTS[variant])
model_provenance, publishable = _model_provenance(
config,
model,
local_source_model,
local_source_revision,
)
repository_provenance = _repository_provenance()
if publishable and repository_provenance["dirty"]:
raise RuntimeError("Refusing to generate publishable golden assets from a dirty vllm-omni worktree.")
if model is not None:
config["model"] = str(Path(model).expanduser().resolve())
config["revision"] = None
output_dir = output_root / variant / task
output_dir.mkdir(parents=True, exist_ok=True)
normalized_image: Image.Image | None = None
input_path: Path | None = None
if task == "i2v":
if input_image is None:
raise ValueError("--input-image is required for task=i2v.")
input_path = output_dir / "input.png"
normalized_image = _normalize_input_image(input_image, input_path)
elif input_image is not None:
raise ValueError("--input-image is only valid for task=i2v.")
pipeline_class = SanaImageToVideoPipeline if task == "i2v" else SanaVideoPipeline
pipe = pipeline_class.from_pretrained(
config["model"],
revision=config["revision"],
torch_dtype=torch.bfloat16,
)
pipe.text_encoder.to(torch.bfloat16)
pipe.vae.to(config["vae_dtype"])
pipe.to("cuda")
torch.manual_seed(1234)
hidden_states = torch.randn(config["transformer_case_shape"], dtype=torch.bfloat16, device="cuda")
encoder_hidden_states = torch.randn(1, 8, 2304, dtype=torch.bfloat16, device="cuda")
encoder_attention_mask = torch.tensor([[1, 1, 1, 1, 1, 1, 1, 0]], device="cuda")
timestep = torch.tensor([500.0], device="cuda")
with torch.inference_mode():
transformer_output = pipe.transformer(
hidden_states,
encoder_hidden_states,
timestep,
encoder_attention_mask=encoder_attention_mask,
).sample
transformer_path = output_dir / "transformer_case.safetensors"
save_file(
{
"hidden_states": _cpu(hidden_states),
"encoder_hidden_states": _cpu(encoder_hidden_states),
"encoder_attention_mask": _cpu(encoder_attention_mask),
"timestep": _cpu(timestep),
"output": _cpu(transformer_output),
},
transformer_path,
)
final_latents: dict[str, torch.Tensor] = {}
def capture_final_latents(_pipe, step, _timestep, callback_kwargs):
if step == NUM_INFERENCE_STEPS - 1:
final_latents["latents"] = _cpu(callback_kwargs["latents"])
return callback_kwargs
torch.accelerator.reset_peak_memory_stats()
generator = torch.Generator(device="cuda").manual_seed(SEED)
generation_kwargs = {
"prompt": PROMPT,
"negative_prompt": NEGATIVE_PROMPT,
"height": config["height"],
"width": config["width"],
"frames": NUM_FRAMES,
"guidance_scale": GUIDANCE_SCALE,
"num_inference_steps": NUM_INFERENCE_STEPS,
"generator": generator,
"output_type": "np",
"callback_on_step_end": capture_final_latents,
"callback_on_step_end_tensor_inputs": ["latents"],
}
if normalized_image is not None:
generation_kwargs["image"] = normalized_image
torch.accelerator.synchronize()
started = time.perf_counter()
with torch.inference_mode():
frames = pipe(**generation_kwargs).frames[0]
torch.accelerator.synchronize()
generation_seconds = time.perf_counter() - started
if "latents" not in final_latents:
raise RuntimeError("Diffusers did not expose the final denoised latents to the generation callback.")
video_path = output_dir / "pipeline.mp4"
export_to_video(frames, video_path, fps=FPS)
reference_path = output_dir / "pipeline_reference.safetensors"
save_file(
{
"final_latents": final_latents["latents"],
"decoded_frames_uint8": _frames_to_uint8(frames),
},
reference_path,
)
scheduler_config = dict(pipe.scheduler.config)
input_metadata = None
if input_path is not None:
input_metadata = {
"filename": input_path.name,
"sha256": _sha256(input_path),
"size": input_path.stat().st_size,
"format": "png-rgb",
}
metadata = {
"schema_version": SCHEMA_VERSION,
"variant": variant,
"task": task,
"publishable": publishable,
"reference_implementation": {
"library": "diffusers",
"version": __import__("diffusers").__version__,
"pipeline_class": pipeline_class.__name__,
"torch_version": torch.__version__,
},
"model_provenance": model_provenance,
"generator_provenance": repository_provenance,
"scheduler": {
"class": type(pipe.scheduler).__name__,
"config_sha256": _json_sha256(scheduler_config),
"config": scheduler_config,
},
"prompt": PROMPT,
"negative_prompt": NEGATIVE_PROMPT,
"seed": SEED,
"generator_device": "cuda",
"dtype": "bfloat16",
"vae_dtype": str(config["vae_dtype"]).removeprefix("torch."),
"height": config["height"],
"width": config["width"],
"num_frames": NUM_FRAMES,
"fps": FPS,
"num_inference_steps": NUM_INFERENCE_STEPS,
"guidance_scale": GUIDANCE_SCALE,
"input_image": input_metadata,
"generation_seconds": generation_seconds,
"peak_reserved_gib": torch.accelerator.max_memory_reserved() / 1024**3,
}
metadata_path = output_dir / "metadata.json"
metadata_path.write_text(json.dumps(metadata, indent=2, default=str) + "\n")
artifact_paths = [transformer_path, video_path, reference_path, metadata_path]
if input_path is not None:
artifact_paths.append(input_path)
manifest = {
"schema_version": SCHEMA_VERSION,
"variant": variant,
"task": task,
"publishable": publishable,
"model_provenance": model_provenance,
"metadata_sha256": _sha256(metadata_path),
"files": {path.name: {"sha256": _sha256(path), "size": path.stat().st_size} for path in artifact_paths},
}
manifest_path = output_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
return manifest_path
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--variant", choices=VARIANTS, required=True)
parser.add_argument("--task", choices=("t2v", "i2v"), required=True)
parser.add_argument(
"--model",
help=(
"Optional local checkpoint. Local output is fingerprinted and marked development-only; "
"it cannot be published as the canonical golden."
),
)
parser.add_argument("--input-image", type=Path, help="Fixed local RGB input for task=i2v.")
parser.add_argument("--local-source-model", help="Claimed upstream model ID for local checkpoint audit records.")
parser.add_argument("--local-source-revision", help="Claimed upstream commit for local checkpoint audit records.")
parser.add_argument("--output-root", type=Path, default=Path("/tmp/sana-video-goldens-v2"))
args = parser.parse_args()
print(
generate(
args.variant,
args.task,
args.model,
args.input_image,
args.output_root,
args.local_source_model,
args.local_source_revision,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,328 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import base64
import hashlib
import json
import os
from pathlib import Path
from typing import Any
import pytest
import requests
import torch
from safetensors.torch import load_file
from torch import nn
from tests.e2e.accuracy.helpers import assert_video_metadata, assert_video_similarity_metrics, probe_video
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
GOLDEN_BASE_URL = os.getenv("SANA_VIDEO_GOLDEN_BASE_URL")
SCHEMA_VERSION = 2
PROMPT = "A cat walking on the grass, facing the camera. motion score: 30."
NEGATIVE_PROMPT = (
"A chaotic sequence with misshapen, deformed limbs in heavy motion blur, sudden disappearance, jump cuts, "
"jerky movements, rapid shot changes, frames out of sync, inconsistent character shapes, temporal artifacts, "
"jitter, and ghosting effects, creating a disorienting visual experience."
)
VARIANTS = {
"480p": ("Efficient-Large-Model/SANA-Video_2B_480p_diffusers", 480, 832),
"720p": ("Efficient-Large-Model/SANA-Video_2B_720p_diffusers", 704, 1280),
}
REVISIONS = {
"480p": "fed3bce411c58a0f688a31afe8f52e61acc2b15f",
"720p": "8bda5e623d0f48cd6da3b387b10ca35d15cf1c4e",
}
SSIM_THRESHOLDS = {
("480p", "t2v"): 0.91,
("480p", "i2v"): 0.93,
("720p", "t2v"): 0.93,
("720p", "i2v"): 0.93,
}
pytestmark = [
pytest.mark.full_model,
pytest.mark.diffusion,
pytest.mark.skipif(
not GOLDEN_BASE_URL,
reason="Set SANA_VIDEO_GOLDEN_BASE_URL after publishing the frozen v2 S3 assets.",
),
]
class _TransformerCheckpoint(nn.Module):
def __init__(self, transformer: nn.Module, model: str, revision: str) -> None:
super().__init__()
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
self.transformer = transformer
self.weights_sources = [
DiffusersPipelineLoader.ComponentSource(
model_or_path=model,
subfolder="transformer",
revision=revision,
prefix="transformer.",
fall_back_to_pt=True,
)
]
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _json_sha256(payload: Any) -> str:
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()
return hashlib.sha256(encoded).hexdigest()
def _assert_hex_digest(value: Any, *, length: int = 64) -> None:
assert isinstance(value, str) and len(value) == length
assert all(character in "0123456789abcdef" for character in value)
def _validate_manifest(payload: Any, variant: str, task: str) -> dict[str, Any]:
assert isinstance(payload, dict)
assert payload["schema_version"] == SCHEMA_VERSION
assert payload["variant"] == variant
assert payload["task"] == task
# Local checkpoint generation is deliberately marked non-publishable. Do
# not permit such output to become the canonical reference by accident.
assert payload["publishable"] is True
assert payload["model_provenance"] == {
"kind": "huggingface_hub",
"model_id": VARIANTS[variant][0],
"revision": REVISIONS[variant],
}
_assert_hex_digest(payload["metadata_sha256"])
expected_files = {
"metadata.json",
"pipeline.mp4",
"pipeline_reference.safetensors",
"transformer_case.safetensors",
}
if task == "i2v":
expected_files.add("input.png")
files = payload["files"]
assert isinstance(files, dict)
assert set(files) == expected_files
for filename, expected in files.items():
# Prevent a compromised manifest from writing outside output_dir.
assert filename == Path(filename).name
assert isinstance(expected, dict) and set(expected) == {"sha256", "size"}
_assert_hex_digest(expected["sha256"])
assert isinstance(expected["size"], int) and expected["size"] > 0
return payload
def _validate_metadata(payload: Any, manifest: dict[str, Any], variant: str, task: str) -> None:
assert isinstance(payload, dict)
_, height, width = VARIANTS[variant]
assert payload["schema_version"] == SCHEMA_VERSION
assert payload["variant"] == variant
assert payload["task"] == task
assert payload["publishable"] is True
assert payload["model_provenance"] == manifest["model_provenance"]
assert payload["prompt"] == PROMPT
assert payload["negative_prompt"] == NEGATIVE_PROMPT
assert payload["seed"] == 42
assert payload["generator_device"] == "cuda"
assert payload["dtype"] == "bfloat16"
assert payload["height"] == height
assert payload["width"] == width
assert payload["num_frames"] == 81
assert payload["fps"] == 16
assert payload["num_inference_steps"] == 50
assert payload["guidance_scale"] == 6.0
implementation = payload["reference_implementation"]
assert implementation["library"] == "diffusers"
assert implementation["pipeline_class"] == ("SanaImageToVideoPipeline" if task == "i2v" else "SanaVideoPipeline")
assert implementation["version"]
assert implementation["torch_version"]
generator = payload["generator_provenance"]
assert generator["repository"] == "vllm-project/vllm-omni"
_assert_hex_digest(generator["revision"], length=40)
_assert_hex_digest(generator["generator_sha256"])
assert generator["dirty"] is False
scheduler = payload["scheduler"]
assert scheduler["class"] == "DPMSolverMultistepScheduler"
_assert_hex_digest(scheduler["config_sha256"])
assert scheduler["config_sha256"] == _json_sha256(scheduler["config"])
input_image = payload["input_image"]
if task == "i2v":
assert input_image["filename"] == "input.png"
_assert_hex_digest(input_image["sha256"])
assert input_image["size"] == manifest["files"]["input.png"]["size"]
assert input_image["sha256"] == manifest["files"]["input.png"]["sha256"]
assert input_image["format"] == "png-rgb"
else:
assert input_image is None
def _download_case(variant: str, task: str, output_dir: Path, filenames: set[str]) -> dict[str, Any]:
output_dir.mkdir(parents=True, exist_ok=True)
base_url = f"{GOLDEN_BASE_URL.rstrip('/')}/{variant}/{task}"
response = requests.get(f"{base_url}/manifest.json", timeout=60)
response.raise_for_status()
payload = _validate_manifest(response.json(), variant, task)
filenames = filenames | {"metadata.json"}
if task == "i2v":
filenames.add("input.png")
assert filenames <= payload["files"].keys()
for filename in filenames:
expected = payload["files"][filename]
path = output_dir / filename
response = requests.get(f"{base_url}/{filename}", timeout=300)
response.raise_for_status()
path.write_bytes(response.content)
assert path.stat().st_size == expected["size"]
assert _sha256(path) == expected["sha256"]
assert _sha256(output_dir / "metadata.json") == payload["metadata_sha256"]
metadata = json.loads((output_dir / "metadata.json").read_text())
_validate_metadata(metadata, payload, variant, task)
return payload
@pytest.mark.benchmark
@pytest.mark.parametrize("variant", VARIANTS)
@pytest.mark.parametrize(
"_hardware",
[pytest.param(None, marks=hardware_marks(res={"cuda": "H100"}))],
)
def test_sana_video_transformer_matches_frozen_golden(
variant: str,
_hardware,
accuracy_artifact_root: Path,
) -> None:
del _hardware
from vllm.config import LoadConfig
from vllm.transformers_utils.config import get_hf_file_to_dict
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel
model, _, _ = VARIANTS[variant]
revision = REVISIONS[variant]
output_dir = accuracy_artifact_root / "sana_video" / variant / "t2v"
_download_case(variant, "t2v", output_dir, {"transformer_case.safetensors"})
case = load_file(output_dir / "transformer_case.safetensors")
transformer_config = get_hf_file_to_dict("transformer/config.json", model, revision=revision)
assert transformer_config is not None
transformer = SanaVideoTransformer3DModel.from_config(transformer_config).to(
device="cuda",
dtype=torch.bfloat16,
)
checkpoint = _TransformerCheckpoint(transformer, model, revision)
od_config = OmniDiffusionConfig(
model=model,
model_class_name="SanaVideoPipeline",
dtype=torch.bfloat16,
revision=revision,
)
loader = DiffusersPipelineLoader(LoadConfig(), od_config=od_config)
transformer.load_weights(
(name.removeprefix("transformer."), tensor)
for name, tensor in loader.get_all_weights(checkpoint)
if name.startswith("transformer.")
)
transformer.eval()
with torch.inference_mode():
actual = transformer(
case["hidden_states"].to("cuda"),
case["encoder_hidden_states"].to("cuda"),
case["timestep"].to("cuda"),
encoder_attention_mask=case["encoder_attention_mask"].to("cuda"),
).sample.float()
expected = case["output"].to("cuda").float()
error = actual - expected
max_abs = error.abs().max()
relative_l2 = torch.linalg.vector_norm(error) / torch.linalg.vector_norm(expected)
cosine = torch.nn.functional.cosine_similarity(actual.flatten(), expected.flatten(), dim=0)
# CUDNN_ATTN BF16 reduction order is not bitwise deterministic on
# Blackwell. Bound rare outliers separately while keeping the aggregate
# relative-L2 and cosine checks strict.
max_abs_threshold = 0.2 if variant == "480p" else 0.1
relative_l2_threshold = 0.017 if variant == "480p" else 0.015
assert max_abs.item() <= max_abs_threshold
assert relative_l2.item() <= relative_l2_threshold
assert cosine.item() >= 0.999
SERVER_CASES = [
pytest.param(
OmniServerParams(
model=model,
server_args=[
"--model-class-name",
pipeline_class,
],
),
id=f"{variant}-{task}",
marks=hardware_marks(res={"cuda": "H100"}),
)
for variant, (model, _, _) in VARIANTS.items()
for task, pipeline_class in (
("t2v", "SanaVideoPipeline"),
("i2v", "SanaImageToVideoPipeline"),
)
]
@pytest.mark.benchmark
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
def test_sana_video_pipeline_matches_frozen_golden(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
accuracy_artifact_root: Path,
) -> None:
variant = "720p" if "720p" in omni_server.model else "480p"
task = "i2v" if "SanaImageToVideoPipeline" in omni_server.serve_args else "t2v"
_, height, width = VARIANTS[variant]
output_dir = accuracy_artifact_root / "sana_video" / variant / task
_download_case(variant, task, output_dir, {"pipeline.mp4"})
request_config = {
"model": omni_server.model,
"form_data": {
"prompt": PROMPT,
"negative_prompt": NEGATIVE_PROMPT,
"height": height,
"width": width,
"num_frames": 81,
"fps": 16,
"num_inference_steps": 50,
"guidance_scale": 6.0,
"seed": 42,
},
}
if task == "i2v":
encoded = base64.b64encode((output_dir / "input.png").read_bytes()).decode()
request_config["image_reference"] = f"data:image/png;base64,{encoded}"
result = openai_client.send_video_diffusion_request(request_config)[0]
actual_path = output_dir / "actual.mp4"
actual_path.write_bytes(result.videos[0])
golden_path = output_dir / "pipeline.mp4"
metadata = probe_video(actual_path)
assert_video_metadata(metadata, width=width, height=height, fps=16, frame_count=81)
assert_video_similarity_metrics(
label=f"sana_video_{variant}_{task}",
online_path=actual_path,
offline_path=golden_path,
# The native prompt cross-attention uses vLLM-Omni Attention rather
# than Diffusers SDPA. Frozen stage checks prove exact inputs and
# scheduler state, while the BF16 backend reduction difference grows
# across CFG denoising. Calibrate only the affected 480p T2V case;
# its frozen result is 0.919861 SSIM / 28.7442 dB PSNR.
ssim_threshold=SSIM_THRESHOLDS[(variant, task)],
psnr_threshold=28.0,
)
@@ -0,0 +1,170 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
from types import SimpleNamespace
import pytest
import torch
from tests.helpers.mark import hardware_marks
pytestmark = [
pytest.mark.full_model,
pytest.mark.diffusion,
pytest.mark.benchmark,
]
VARIANTS = {
"480p": {
"model": "Efficient-Large-Model/SANA-Video_2B_480p_diffusers",
"vae_class": "AutoencoderKLWan",
"native_vae_class": "DistributedAutoencoderKLWan",
"dtype": torch.float32,
"latent_channels": 16,
"spatial_scale": 8,
"temporal_scale": 4,
"frames": 5,
"max_abs": 1e-6,
"relative_l2": 1e-6,
},
"720p": {
"model": "Efficient-Large-Model/SANA-Video_2B_720p_diffusers",
"vae_class": "AutoencoderKLLTX2Video",
"native_vae_class": "DistributedAutoencoderKLLTX2Video",
"dtype": torch.bfloat16,
"latent_channels": 128,
"spatial_scale": 32,
"temporal_scale": 8,
"frames": 9,
"max_abs": 0.04,
"relative_l2": 0.004,
},
}
@pytest.mark.parametrize("variant", VARIANTS)
@pytest.mark.parametrize(
"_hardware",
[pytest.param(None, marks=hardware_marks(res={"cuda": "H100"}))],
)
def test_sana_video_i2v_first_frame_latents_match_diffusers(variant: str, _hardware) -> None:
del _hardware
from diffusers import (
AutoencoderKLLTX2Video,
AutoencoderKLWan,
)
from diffusers import (
SanaImageToVideoPipeline as DiffusersSanaImageToVideoPipeline,
)
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import (
DistributedAutoencoderKLLTX2Video,
)
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import (
DistributedAutoencoderKLWan,
)
from vllm_omni.diffusion.models.sana_video import SanaImageToVideoPipeline
config = VARIANTS[variant]
vae_classes = {
"AutoencoderKLLTX2Video": AutoencoderKLLTX2Video,
"AutoencoderKLWan": AutoencoderKLWan,
}
native_vae_classes = {
"DistributedAutoencoderKLLTX2Video": DistributedAutoencoderKLLTX2Video,
"DistributedAutoencoderKLWan": DistributedAutoencoderKLWan,
}
reference_vae = (
vae_classes[config["vae_class"]]
.from_pretrained(
config["model"],
subfolder="vae",
torch_dtype=config["dtype"],
)
.to("cuda")
)
native_vae = (
native_vae_classes[config["native_vae_class"]]
.from_config(dict(reference_vae.config))
.to(device="cuda", dtype=config["dtype"])
)
native_vae.load_state_dict(reference_vae.state_dict())
reference_vae.eval()
native_vae.eval()
height, width = 192, 320
num_latent_frames = (config["frames"] - 1) // config["temporal_scale"] + 1
image = torch.linspace(
-1,
1,
3 * height * width,
dtype=torch.float32,
device="cuda",
).reshape(1, 3, height, width)
noise = torch.randn(
(
1,
config["latent_channels"],
num_latent_frames,
height // config["spatial_scale"],
width // config["spatial_scale"],
),
generator=torch.Generator(device="cuda").manual_seed(7),
device="cuda",
dtype=torch.float32,
)
reference_pipeline = SimpleNamespace(
vae=reference_vae,
vae_scale_factor_temporal=config["temporal_scale"],
vae_scale_factor_spatial=config["spatial_scale"],
)
native_pipeline = SimpleNamespace(
vae=native_vae,
vae_scale_factor_temporal=config["temporal_scale"],
vae_scale_factor_spatial=config["spatial_scale"],
device=torch.device("cuda"),
)
with torch.inference_mode():
expected = DiffusersSanaImageToVideoPipeline.prepare_latents(
reference_pipeline,
image,
1,
config["latent_channels"],
height,
width,
config["frames"],
torch.float32,
torch.device("cuda"),
None,
noise.clone(),
)
actual = SanaImageToVideoPipeline._prepare_i2v_latents(
native_pipeline,
image,
1,
config["latent_channels"],
height,
width,
config["frames"],
torch.float32,
None,
noise.clone(),
)
# Conditioning replaces only the first latent frame.
torch.testing.assert_close(actual[:, :, 1:], noise[:, :, 1:], rtol=0, atol=0)
error = (actual[:, :, :1] - expected[:, :, :1]).float()
expected_first_frame = expected[:, :, :1].float()
max_abs = error.abs().max()
relative_l2 = torch.linalg.vector_norm(error) / torch.linalg.vector_norm(expected_first_frame)
cosine = torch.nn.functional.cosine_similarity(
actual[:, :, :1].flatten(),
expected[:, :, :1].flatten(),
dim=0,
)
assert max_abs.item() <= config["max_abs"]
assert relative_l2.item() <= config["relative_l2"]
assert cosine.item() >= 0.9999
@@ -0,0 +1,432 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import gc
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import pytest
import torch
import torch.nn.functional as F
from tests.helpers.mark import hardware_marks
pytestmark = [
pytest.mark.full_model,
pytest.mark.diffusion,
pytest.mark.benchmark,
]
PROMPT = "A small red panda walks through a bamboo forest."
NEGATIVE_PROMPT = "blur, distortion, abrupt cuts"
SEED = 42
GUIDANCE_SCALE = 6.0
NUM_INFERENCE_STEPS = 2
HEIGHT = 192
WIDTH = 320
NUM_FRAMES = 9
VARIANTS = {
"480p": {
"model": "Efficient-Large-Model/SANA-Video_2B_480p_diffusers",
"revision": "fed3bce411c58a0f688a31afe8f52e61acc2b15f",
"model_env": "SANA_VIDEO_480P_MODEL",
"max_abs": 0.2,
"relative_l2": 0.02,
"cosine": 0.999,
"cfg_max_abs": 0.5,
"cfg_relative_l2": 0.05,
"cfg_cosine": 0.999,
"step_max_abs": 0.075,
"step_relative_l2": 0.012,
"step_cosine": 0.9999,
},
"720p": {
"model": "Efficient-Large-Model/SANA-Video_2B_720p_diffusers",
"revision": "8bda5e623d0f48cd6da3b387b10ca35d15cf1c4e",
"model_env": "SANA_VIDEO_720P_MODEL",
"max_abs": 0.1,
"relative_l2": 0.015,
"cosine": 0.999,
"cfg_max_abs": 0.6,
"cfg_relative_l2": 0.065,
"cfg_cosine": 0.998,
"step_max_abs": 0.08,
"step_relative_l2": 0.014,
"step_cosine": 0.9999,
},
}
@dataclass(frozen=True)
class ErrorMetrics:
shape: tuple[int, ...]
actual_dtype: torch.dtype
expected_dtype: torch.dtype
max_abs: float
mean_abs: float
relative_l2: float
cosine: float
def __str__(self) -> str:
return (
f"shape={self.shape}, actual_dtype={self.actual_dtype}, "
f"expected_dtype={self.expected_dtype}, max_abs={self.max_abs:.8g}, "
f"mean_abs={self.mean_abs:.8g}, relative_l2={self.relative_l2:.8g}, "
f"cosine={self.cosine:.8g}"
)
def _metrics(actual: torch.Tensor, expected: torch.Tensor) -> ErrorMetrics:
assert actual.shape == expected.shape
actual_float = actual.detach().float()
expected_float = expected.detach().float()
error = actual_float - expected_float
expected_norm = torch.linalg.vector_norm(expected_float)
relative_l2 = torch.linalg.vector_norm(error) / expected_norm.clamp_min(torch.finfo(torch.float32).tiny)
if actual_float.numel() == 1:
cosine = torch.tensor(
1.0 if bool(torch.equal(actual_float, expected_float)) else 0.0,
device=actual.device,
)
else:
cosine = F.cosine_similarity(actual_float.flatten(), expected_float.flatten(), dim=0)
return ErrorMetrics(
shape=tuple(actual.shape),
actual_dtype=actual.dtype,
expected_dtype=expected.dtype,
max_abs=error.abs().max().item(),
mean_abs=error.abs().mean().item(),
relative_l2=relative_l2.item(),
cosine=cosine.item(),
)
def _assert_stage(
stage: str,
actual: torch.Tensor,
expected: torch.Tensor,
*,
max_abs: float,
relative_l2: float,
cosine: float,
) -> ErrorMetrics:
result = _metrics(actual, expected)
print(f"\nSANA-Video alignment [{stage}]: {result}")
assert result.actual_dtype == result.expected_dtype, f"{stage}: {result}"
assert result.max_abs <= max_abs, f"{stage}: {result}"
assert result.relative_l2 <= relative_l2, f"{stage}: {result}"
assert result.cosine >= cosine, f"{stage}: {result}"
return result
def _model_source(variant: str) -> tuple[str, str | None]:
config = VARIANTS[variant]
model = os.getenv(config["model_env"], config["model"])
revision = None if Path(model).exists() else config["revision"]
return model, revision
def _native_method_proxy(reference_pipeline: Any):
"""Build the minimum native-pipeline surface needed by stage helpers.
Sharing the tokenizer and text encoder makes this a comparison of pipeline
orchestration rather than a second copy of the same multi-billion-parameter
encoder. Transformer execution still uses separate Diffusers and native
modules below.
"""
from vllm_omni.diffusion.models.sana_video import SanaVideoPipeline
proxy = object.__new__(SanaVideoPipeline)
torch.nn.Module.__init__(proxy)
proxy.tokenizer = reference_pipeline.tokenizer
proxy.text_encoder = reference_pipeline.text_encoder
proxy.device = torch.device("cuda")
proxy.vae_scale_factor_temporal = reference_pipeline.vae_scale_factor_temporal
proxy.vae_scale_factor_spatial = reference_pipeline.vae_scale_factor_spatial
return proxy
def _cfg_prediction(
prediction: torch.Tensor,
*,
guidance_scale: float,
latent_channels: int,
out_channels: int,
) -> torch.Tensor:
unconditional, conditional = prediction.float().chunk(2)
prediction = unconditional + guidance_scale * (conditional - unconditional)
if out_channels // 2 == latent_channels:
prediction = prediction.chunk(2, dim=1)[0]
return prediction
@pytest.mark.parametrize("variant", VARIANTS)
@pytest.mark.parametrize(
"_hardware",
[pytest.param(None, marks=hardware_marks(res={"cuda": "H100"}))],
)
def test_sana_video_quick_pipeline_stages_match_diffusers(
variant: str,
_hardware,
) -> None:
"""Compare the first denoising step without replacing native attention.
This deliberately stops after the first scheduler step. It keeps the test
useful as a stage-localizing PR/nightly check while the frozen 81-frame,
50-step golden test covers the final latent and decoded-video boundary.
"""
del _hardware
from diffusers import SanaVideoPipeline as DiffusersSanaVideoPipeline
from diffusers.pipelines.sana_video.pipeline_sana_video import (
retrieve_timesteps as diffusers_retrieve_timesteps,
)
from diffusers.schedulers import DPMSolverMultistepScheduler
from vllm_omni.diffusion.models.sana_video import (
SanaVideoPipeline,
SanaVideoTransformer3DModel,
)
from vllm_omni.diffusion.models.sana_video.pipeline_sana_video import (
retrieve_timesteps as native_retrieve_timesteps,
)
model, revision = _model_source(variant)
config = VARIANTS[variant]
load_kwargs: dict[str, Any] = {
"torch_dtype": torch.bfloat16,
# No stage in this test decodes latents. Avoid loading a multi-GB VAE
# that would add memory and runtime without increasing coverage.
"vae": None,
}
if revision is not None:
load_kwargs["revision"] = revision
reference = DiffusersSanaVideoPipeline.from_pretrained(model, **load_kwargs).to("cuda")
assert type(reference) is DiffusersSanaVideoPipeline
assert type(reference.transformer).__module__.startswith("diffusers.")
native_proxy = _native_method_proxy(reference)
assert type(native_proxy) is SanaVideoPipeline
assert type(native_proxy).__module__.startswith("vllm_omni.")
try:
with torch.inference_mode():
reference_prompt = reference.encode_prompt(
PROMPT,
True,
negative_prompt=NEGATIVE_PROMPT,
device=torch.device("cuda"),
clean_caption=False,
max_sequence_length=300,
complex_human_instruction=None,
)
native_prompt = SanaVideoPipeline.encode_prompt(
native_proxy,
PROMPT,
True,
negative_prompt=NEGATIVE_PROMPT,
device=torch.device("cuda"),
clean_caption=False,
max_sequence_length=300,
complex_human_instruction=None,
)
prompt_stage_names = (
"prompt_embeddings",
"prompt_attention_mask",
"negative_prompt_embeddings",
"negative_prompt_attention_mask",
)
for stage, actual, expected in zip(prompt_stage_names, native_prompt, reference_prompt, strict=True):
_assert_stage(
stage,
actual,
expected,
max_abs=0.0,
relative_l2=0.0,
cosine=0.999999,
)
reference_prompt_embeds, reference_prompt_mask, reference_negative_embeds, reference_negative_mask = (
reference_prompt
)
encoder_hidden_states = torch.cat([reference_negative_embeds, reference_prompt_embeds])
encoder_attention_mask = torch.cat([reference_negative_mask, reference_prompt_mask])
latent_channels = reference.transformer.config.in_channels
reference_latents = reference.prepare_latents(
1,
latent_channels,
HEIGHT,
WIDTH,
NUM_FRAMES,
torch.float32,
torch.device("cuda"),
torch.Generator(device="cuda").manual_seed(SEED),
)
native_latents = SanaVideoPipeline.prepare_latents(
native_proxy,
1,
latent_channels,
HEIGHT,
WIDTH,
NUM_FRAMES,
torch.float32,
torch.device("cuda"),
torch.Generator(device="cuda").manual_seed(SEED),
)
_assert_stage(
"initial_noise_latents",
native_latents,
reference_latents,
max_abs=0.0,
relative_l2=0.0,
cosine=0.999999,
)
reference_scheduler = DPMSolverMultistepScheduler.from_config(reference.scheduler.config)
native_scheduler = DPMSolverMultistepScheduler.from_config(reference.scheduler.config)
isolated_scheduler = DPMSolverMultistepScheduler.from_config(reference.scheduler.config)
reference_timesteps, reference_num_steps = diffusers_retrieve_timesteps(
reference_scheduler,
NUM_INFERENCE_STEPS,
torch.device("cuda"),
)
native_timesteps, native_num_steps = native_retrieve_timesteps(
native_scheduler,
NUM_INFERENCE_STEPS,
torch.device("cuda"),
)
isolated_retrieve_timesteps, isolated_num_steps = native_retrieve_timesteps(
isolated_scheduler,
NUM_INFERENCE_STEPS,
torch.device("cuda"),
)
assert reference_num_steps == native_num_steps == isolated_num_steps
_assert_stage(
"timesteps",
native_timesteps,
reference_timesteps,
max_abs=0.0,
relative_l2=0.0,
cosine=0.999999,
)
_assert_stage(
"sigmas",
native_scheduler.sigmas,
reference_scheduler.sigmas,
max_abs=0.0,
relative_l2=0.0,
cosine=0.999999,
)
torch.testing.assert_close(isolated_retrieve_timesteps, reference_timesteps, rtol=0, atol=0)
native_transformer = SanaVideoTransformer3DModel.from_config(dict(reference.transformer.config)).to(
device="cuda",
dtype=torch.bfloat16,
)
incompatible = native_transformer.load_state_dict(reference.transformer.state_dict(), strict=True)
assert not incompatible.missing_keys
assert not incompatible.unexpected_keys
assert type(native_transformer) is SanaVideoTransformer3DModel
assert type(native_transformer).__module__.startswith("vllm_omni.")
assert type(native_transformer) is not type(reference.transformer)
reference_parameter = next(reference.transformer.parameters())
native_parameter = next(native_transformer.parameters())
assert native_parameter.data_ptr() != reference_parameter.data_ptr()
torch.testing.assert_close(native_parameter, reference_parameter, rtol=0, atol=0)
native_transformer.eval()
reference.transformer.eval()
latent_model_input = torch.cat([reference_latents, reference_latents])
timestep = reference_timesteps[0].expand(latent_model_input.shape[0])
with torch.inference_mode():
reference_raw_prediction = reference.transformer(
latent_model_input.to(dtype=torch.bfloat16),
encoder_hidden_states=encoder_hidden_states.to(dtype=torch.bfloat16),
encoder_attention_mask=encoder_attention_mask,
timestep=timestep,
return_dict=False,
)[0]
native_raw_prediction = native_transformer(
latent_model_input.to(dtype=torch.bfloat16),
encoder_hidden_states=encoder_hidden_states.to(dtype=torch.bfloat16),
encoder_attention_mask=encoder_attention_mask,
timestep=timestep,
return_dict=False,
)[0]
_assert_stage(
"first_transformer_raw_prediction",
native_raw_prediction,
reference_raw_prediction,
max_abs=config["max_abs"],
relative_l2=config["relative_l2"],
cosine=config["cosine"],
)
reference_cfg_prediction = _cfg_prediction(
reference_raw_prediction,
guidance_scale=GUIDANCE_SCALE,
latent_channels=latent_channels,
out_channels=reference.transformer.config.out_channels,
)
native_cfg_prediction = _cfg_prediction(
native_raw_prediction,
guidance_scale=GUIDANCE_SCALE,
latent_channels=latent_channels,
out_channels=native_transformer.config.out_channels,
)
_assert_stage(
"first_cfg_prediction",
native_cfg_prediction,
reference_cfg_prediction,
max_abs=config["cfg_max_abs"],
relative_l2=config["cfg_relative_l2"],
cosine=config["cfg_cosine"],
)
reference_first_step = reference_scheduler.step(
reference_cfg_prediction,
reference_timesteps[0],
reference_latents,
return_dict=False,
)[0]
# Isolate scheduler orchestration from native-attention error by
# feeding the Diffusers prediction through an independently configured
# scheduler initialized by the native helper.
isolated_first_step = isolated_scheduler.step(
reference_cfg_prediction,
isolated_retrieve_timesteps[0],
reference_latents,
return_dict=False,
)[0]
_assert_stage(
"first_scheduler_step_isolated",
isolated_first_step,
reference_first_step,
max_abs=0.0,
relative_l2=0.0,
cosine=0.999999,
)
native_first_step = native_scheduler.step(
native_cfg_prediction,
native_timesteps[0],
native_latents,
return_dict=False,
)[0]
_assert_stage(
"first_scheduler_step_end_to_end",
native_first_step,
reference_first_step,
max_abs=config["step_max_abs"],
relative_l2=config["step_relative_l2"],
cosine=config["step_cosine"],
)
finally:
del native_proxy
del reference
gc.collect()
torch.accelerator.empty_cache()
@@ -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
"""
Run vLLM-Omni with diffusers backend, then run diffusers directly. Compare the output similarity.
@@ -39,9 +39,9 @@ from tests.e2e.accuracy.helpers import (
from tests.e2e.accuracy.helpers import (
run_ffmpeg_similarity as _run_ffmpeg_similarity,
)
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.clean import cleanup_test_environment
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer, OpenAIClientHandler
from tests.helpers.runtime import OmniServer, OnlineOmniClient
from vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter import (
CUDA_FLASH_ATTENTION_BACKEND_ATTEMPTS,
)
@@ -128,7 +128,7 @@ def _run_vllm_omni_wan22_i2v(
"seed": SEED,
}
with OmniServer(model, server_args, env_dict=env_to_apply_ftfy_mock_in_subproc(), use_omni=True) as omni_server:
client = OpenAIClientHandler(
client = OnlineOmniClient(
host=omni_server.host,
port=omni_server.port,
run_level="full_model",
@@ -139,16 +139,20 @@ def _run_vllm_omni_wan22_i2v(
"image_reference": f"data:image/png;base64,{pil_to_base64(conditioning_image, 'png')}",
}
result = client.send_video_diffusion_request(request_config)[0]
video_bytes = result.videos[0] # pyright: ignore[reportOptionalSubscript] # Guaranteed not None
videos = result.videos
assert videos is not None
video_bytes = videos[0]
output_path.write_bytes(video_bytes)
return result.e2e_latency # pyright: ignore[reportReturnType] # Guaranteed not None
latency = result.e2e_latency
assert latency is not None
return latency
def _run_diffusers_wan22_i2v(*, model: str, output_path: Path, conditioning_image: Image.Image) -> float:
from diffusers import WanImageToVideoPipeline # pyright: ignore[reportPrivateImportUsage]
from diffusers.schedulers.scheduling_unipc_multistep import UniPCMultistepScheduler
run_pre_test_cleanup()
cleanup_test_environment()
apply_ftfy_mock()
pipe: WanImageToVideoPipeline | None = None
try:
@@ -190,7 +194,7 @@ def _run_diffusers_wan22_i2v(*, model: str, output_path: Path, conditioning_imag
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
run_post_test_cleanup()
cleanup_test_environment()
def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> tuple[Image.Image, float]:
@@ -248,7 +252,7 @@ def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> tuple[Image.I
def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> tuple[Image.Image, float]:
run_pre_test_cleanup()
cleanup_test_environment()
pipe: DiffusionPipeline | None = None
try:
pipe = DiffusionPipeline.from_pretrained(
@@ -288,7 +292,7 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> tuple[Image.I
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
run_post_test_cleanup()
cleanup_test_environment()
@pytest.mark.benchmark
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import os
from pathlib import Path
import pytest
from PIL import Image
from tests.e2e.accuracy.helpers import assert_similarity, model_output_dir
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.diffusion.utils.image_output import extract_images_from_outputs
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
MODEL_ID = "HiDream-ai/HiDream-O1-Image"
PROMPT = "A cat is sitting next to a sign that says 'HiDream-O1 vLLM-Omni'"
BASELINE_PATH = Path(__file__).resolve().parents[2] / "assets" / "hidream_o1" / "hidream_o1_baseline.png"
SSIM_THRESHOLD = 0.8
PSNR_THRESHOLD = 19.0
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_hidream_o1_image_tp2_matches_baseline(
accuracy_artifact_root: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("DIFFUSION_ATTENTION_BACKEND", "TORCH_SDPA")
model = os.environ.get("HIDREAM_O1_IMAGE_MODEL", MODEL_ID)
with OmniRunner(
model,
parallel_config=DiffusionParallelConfig(tensor_parallel_size=2),
enforce_eager=True,
) as runner:
outputs = runner.omni.generate(
{"prompt": PROMPT},
OmniDiffusionSamplingParams(
height=2048,
width=2048,
num_inference_steps=50,
guidance_scale=5.0,
seed=42,
),
)
images = extract_images_from_outputs(outputs)
assert images
image = images[0].convert("RGB")
output_dir = model_output_dir(accuracy_artifact_root, MODEL_ID)
image.save(output_dir / "vllm_omni_tp2.png")
assert BASELINE_PATH.exists()
baseline = Image.open(BASELINE_PATH).convert("RGB")
resized = image.resize(baseline.size, Image.Resampling.LANCZOS)
assert_similarity(
model_name=MODEL_ID,
vllm_image=resized,
diffusers_image=baseline,
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
)
+15 -12
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import base64
@@ -13,7 +16,7 @@ from diffusers.pipelines.pipeline_utils import DiffusionPipeline
from PIL import Image
from tests.e2e.accuracy.helpers import assert_images_pixel_close, assert_similarity, model_output_dir
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.clean import cleanup_test_environment
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer
@@ -29,14 +32,14 @@ HEIGHT = 512
NUM_INFERENCE_STEPS = 20
TRUE_CFG_SCALE = 4.0
SEED = 42
SSIM_THRESHOLD = 0.97
# The FA3-hub-matched comparison clears 0.97 with margin. The SDPA-matched
# fallback (hub kernel unavailable for the image's torch) landed at 0.9687 in
# build 2954 — the attention math matches but the surrounding stacks (vLLM
# compiled path vs eager diffusers) still differ slightly. Keep the strict
# gate for the FA3 path and a slightly looser one for the fallback only.
SSIM_THRESHOLD = 0.94
# With --fa-deterministic, H100 nightlies stably land near SSIM 0.958 / PSNR 27.8
# vs Diffusers (see #5734 / #5963), so the primary gates are 0.94 / 27.0.
# The SDPA-matched fallback (hub kernel unavailable for the image's torch)
# landed at SSIM 0.9687 in build 2954 — keep a slightly tighter SSIM gate for
# that path only.
SSIM_THRESHOLD_SDPA_FALLBACK = 0.96
PSNR_THRESHOLD = 30.0
PSNR_THRESHOLD = 27.0
MODEL_2512_ID = "Qwen/Qwen-Image-2512"
MODEL_2512_ENV_VAR = "QWEN_IMAGE_2512_MODEL"
@@ -149,7 +152,7 @@ def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> Image.Image:
def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image:
run_pre_test_cleanup()
cleanup_test_environment()
pipe: DiffusionPipeline | None = None
try:
pipe = DiffusionPipeline.from_pretrained(
@@ -179,7 +182,7 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image:
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
run_post_test_cleanup()
cleanup_test_environment()
def _run_vllm_omni_qwen_image_2512(*, model: str, output_path: Path) -> Image.Image:
@@ -211,7 +214,7 @@ def _run_vllm_omni_qwen_image_2512(*, model: str, output_path: Path) -> Image.Im
def _run_diffusers_qwen_image_2512(*, model: str, output_path: Path) -> Image.Image:
run_pre_test_cleanup()
cleanup_test_environment()
pipe: DiffusionPipeline | None = None
try:
pipe = DiffusionPipeline.from_pretrained(
@@ -241,7 +244,7 @@ def _run_diffusers_qwen_image_2512(*, model: str, output_path: Path) -> Image.Im
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
run_post_test_cleanup()
cleanup_test_environment()
@pytest.mark.benchmark
+11 -6
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import gc
@@ -12,12 +15,12 @@ from PIL import Image
from benchmarks.accuracy.common import decode_base64_image, pil_to_png_bytes
from tests.e2e.accuracy.helpers import assert_images_pixel_close, assert_similarity, model_output_dir
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.clean import cleanup_test_environment
from tests.helpers.mark import hardware_test
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniServer
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
pytestmark = [pytest.mark.diffusion]
SINGLE_MODEL = "Qwen/Qwen-Image-Edit"
@@ -109,7 +112,7 @@ def _run_diffusers_image_edit(
input_images: list[Image.Image],
output_path: Path,
) -> Image.Image:
run_pre_test_cleanup()
cleanup_test_environment()
pipe: QwenImageEditPipeline | QwenImageEditPlusPipeline | None = None
try:
images = input_images[0] if len(input_images) == 1 else input_images
@@ -140,7 +143,7 @@ def _run_diffusers_image_edit(
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
run_post_test_cleanup()
cleanup_test_environment()
def _load_edit_2511_input_images() -> list[Image.Image]:
@@ -189,7 +192,7 @@ def _run_diffusers_image_edit_2511(
input_images: list[Image.Image],
output_path: Path,
) -> Image.Image:
run_pre_test_cleanup()
cleanup_test_environment()
pipe: QwenImageEditPlusPipeline | None = None
try:
pipe = QwenImageEditPlusPipeline.from_pretrained(
@@ -222,7 +225,7 @@ def _run_diffusers_image_edit_2511(
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
run_post_test_cleanup()
cleanup_test_environment()
def _vllm_omni_output_single_image(
@@ -253,6 +256,7 @@ def _diffusers_output_single_image(accuracy_artifact_root: Path, qwen_bear_image
)
@pytest.mark.slow
@pytest.mark.benchmark
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_edit_single_matches_diffusers(
@@ -278,6 +282,7 @@ def test_qwen_image_edit_single_matches_diffusers(
)
@pytest.mark.slow
@pytest.mark.benchmark
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_edit_2511_matches_diffusers_pixelwise(accuracy_artifact_root: Path) -> None:
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import base64
@@ -13,11 +16,11 @@ from diffusers.pipelines.pipeline_utils import DiffusionPipeline
from PIL import Image
from tests.e2e.accuracy.helpers import assert_image_sequence_similarity, model_output_dir
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.clean import cleanup_test_environment
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
MODEL_ID = "Qwen/Qwen-Image-Layered"
@@ -93,7 +96,7 @@ def _run_vllm_omni_qwen_image_layered(*, model: str, input_image: Image.Image, o
def _run_diffusers_qwen_image_layered(*, model: str, input_image: Image.Image, output_dir: Path) -> list[Image.Image]:
run_pre_test_cleanup()
cleanup_test_environment()
pipe: DiffusionPipeline | None = None
try:
pipe = DiffusionPipeline.from_pretrained(
@@ -126,7 +129,7 @@ def _run_diffusers_qwen_image_layered(*, model: str, input_image: Image.Image, o
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
run_post_test_cleanup()
cleanup_test_environment()
@pytest.mark.benchmark
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Online serving smoke test for streaming video output similarity.
"""
@@ -9,9 +12,9 @@ import pytest
from tests.e2e.accuracy.helpers import assert_video_similarity_metrics, probe_video
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import DiffusionResponse, OmniServer, OpenAIClientHandler
from tests.helpers.runtime import DiffusionResponse, OmniServer, OnlineOmniClient, get_model_prefix
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
MODEL = "BestWishYsh/Helios-Distilled"
PROMPT = "A serene lakeside sunrise with mist over the water."
@@ -67,8 +70,8 @@ def _request_config(model: str, *, serialize_extra_params: bool) -> dict:
}
def _client_for(server: OmniServer, *, run_level: str) -> OpenAIClientHandler:
return OpenAIClientHandler(
def _client_for(server: OmniServer, *, run_level: str) -> OnlineOmniClient:
return OnlineOmniClient(
host=server.host,
port=server.port,
api_key="EMPTY",
@@ -95,12 +98,11 @@ def _write_video_artifact(response: DiffusionResponse, output_path: Path) -> Pat
)
def test_helios_streaming_output_matches_non_streaming(
model: str,
model_prefix: str,
run_level: str,
tmp_path: Path,
record_property,
):
model = model_prefix + model
model = get_model_prefix() + model
with OmniServer(model, _server_args(streaming_output=True), use_omni=True) as server:
client = _client_for(server, run_level=run_level)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import os
@@ -338,7 +341,7 @@ def _artifact_paths(image_source: str) -> tuple[Path, Path, Path]:
def _generate_online_video(
*,
omni_server,
openai_client,
online_client,
image_source: str,
online_timeout_seconds: int,
) -> Path:
@@ -360,7 +363,7 @@ def _generate_online_video(
"image_reference": _build_online_image_reference(image_source),
}
online_video_bytes = _send_video_request_with_timeout(
openai_client,
online_client,
request_config,
timeout_seconds=_online_timeout_seconds(online_timeout_seconds),
)
@@ -412,7 +415,7 @@ def test_wan22_i2v_diffusers_offline_generates_video(
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
def test_wan22_i2v_online_serving_generates_video(
omni_server,
openai_client,
online_client,
wan22_i2v_image_source: str | None,
wan22_i2v_online_timeout_seconds: int,
) -> None:
@@ -424,7 +427,7 @@ def test_wan22_i2v_online_serving_generates_video(
_validate_image_source(image_source)
online_path = _generate_online_video(
omni_server=omni_server,
openai_client=openai_client,
online_client=online_client,
image_source=image_source,
online_timeout_seconds=wan22_i2v_online_timeout_seconds,
)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Integration tests for ComfyUI nodes that use the Omni API client, with a mocked AsyncOmni and a real API server running in a background process.
These tests cover the integration between ComfyUI node and the API server, without actual model inference logic.
@@ -39,6 +42,7 @@ from pytest_mock import MockerFixture
from vllm import SamplingParams
from vllm.outputs import CompletionOutput, RequestOutput
from tests.helpers.runtime import get_open_port
from vllm_omni.entrypoints.async_omni import AsyncOmni as RealAsyncOmni
from vllm_omni.entrypoints.cli.serve import OmniServeCommand
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams
@@ -515,7 +519,7 @@ def mock_async_omni(
@pytest.fixture
def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni, tmp_path):
def api_server(server_case: ServerCase, mock_async_omni, tmp_path):
"""Set up a API server in background process from command line with parametrized model name and mocked AsyncOmni."""
# Override the STORAGE_MANAGER path to a writable temp directory before
# forking the server subprocess. The default /tmp/storage may be owned
@@ -533,7 +537,7 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
cmd = OmniServeCommand()
cmd.subparser_init(subparsers)
port = unused_tcp_port_factory()
port = get_open_port(host="0.0.0.0")
args = parser.parse_args(["serve", server_case.served_model, "--omni", "--port", str(port)])
def run_server():
@@ -550,11 +554,12 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
wait_poll_interval = 1
for _ in range(wait_time // wait_poll_interval):
try:
response = requests.get(f"http://127.0.0.1:{port}/health")
response = requests.get(f"http://127.0.0.1:{port}/health", timeout=wait_poll_interval)
if response.status_code == 200:
break
except requests.ConnectionError:
time.sleep(wait_poll_interval)
except requests.RequestException:
pass
time.sleep(wait_poll_interval)
else:
if server_process.is_alive():
server_process.terminate()
@@ -809,7 +814,7 @@ async def test_tts_nodes(api_server: str, node_cls, call_kwargs: dict, sampling_
ServerCase(
served_model="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
stage_list=["diffusion"],
stage_configs=[{"stage_type": "diffusion"}],
stage_configs=[{"stage_type": "diffusion", "final_output": True, "final_output_type": "video"}],
outputs=[_build_diffusion_video_output()],
),
"Wan-AI/Wan2.2-T2V-A14B-Diffusers",
@@ -820,7 +825,7 @@ async def test_tts_nodes(api_server: str, node_cls, call_kwargs: dict, sampling_
ServerCase(
served_model="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
stage_list=["diffusion"],
stage_configs=[{"stage_type": "diffusion"}],
stage_configs=[{"stage_type": "diffusion", "final_output": True, "final_output_type": "video"}],
outputs=[_build_diffusion_video_output()],
),
"Wan-AI/Wan2.2-I2V-A14B-Diffusers",
@@ -881,7 +886,7 @@ async def test_video_generation_node(api_server: str, model: str, image_input: b
ServerCase(
served_model="MiniMaxAI/MiniMax-H3",
stage_list=["diffusion"],
stage_configs=[{"stage_type": "diffusion"}],
stage_configs=[{"stage_type": "diffusion", "final_output": True, "final_output_type": "video"}],
outputs=[_build_diffusion_video_output()],
),
SamplingCase(kind=SamplingKind.VIDEO_REF2VA_IMAGE_AUDIO, sampling_params=None),
@@ -892,7 +897,7 @@ async def test_video_generation_node(api_server: str, model: str, image_input: b
ServerCase(
served_model="MiniMaxAI/MiniMax-H3",
stage_list=["diffusion"],
stage_configs=[{"stage_type": "diffusion"}],
stage_configs=[{"stage_type": "diffusion", "final_output": True, "final_output_type": "video"}],
outputs=[_build_diffusion_video_output()],
),
SamplingCase(kind=SamplingKind.VIDEO_REF2VA_MULTI_VIDEO, sampling_params=None),
@@ -166,8 +166,8 @@ async def test_sleep_memory_reclaimed_custom_pipeline():
The fix moves custom_pipeline init outside the CUDA context so all weights
go through the caching allocator and are therefore fully reclaimed by
sleep(level=1). A non-zero ``CuMemAllocator.get_current_usage()`` after
sleep is the direct signal that the bypass is still occurring.
sleep(level=1). The tracked allocations must transition to the allocator's
asleep state and report physical reclamation.
"""
with ExitStack() as after:
engine = AsyncOmni(
@@ -187,62 +187,72 @@ async def test_sleep_memory_reclaimed_custom_pipeline():
free_before, total = torch.cuda.mem_get_info()
used_before_gib = (total - free_before) / 1024**3
# Measure CuMemAllocator-tracked usage before sleep. In inline mode
# the worker runs in a thread pool inside this process, so the allocator
# singleton is shared and can be read directly.
# Measure CuMemAllocator-tracked usage before sleep. In inline / uni
# mode the worker runs in this process, so the allocator singleton is
# shared and can be read directly.
allocator = None
tracked_before = 0
tracked_ptrs: set[int] = set()
try:
from vllm.device_allocator.cumem import CuMemAllocator
allocator = CuMemAllocator.get_instance()
tracked_before = allocator.get_current_usage()
tracked_ptrs = {ptr for ptr, data in allocator.pointer_to_data.items() if not data.is_asleep}
tracked_before = sum(allocator.pointer_to_data[ptr].handle[1] for ptr in tracked_ptrs)
except Exception:
pass
# Put the engine to sleep; all weights should be offloaded via the pool.
acks = await engine.sleep(level=1)
await asyncio.sleep(0.5) # allow the CUDA driver to settle
torch.accelerator.synchronize()
slept = False
try:
# Put the engine to sleep; this engine's weights should be
# offloaded via the pool.
acks = await engine.sleep(level=1)
slept = True
await asyncio.sleep(0.5) # allow the CUDA driver to settle
torch.accelerator.synchronize()
# Measure after sleep.
free_after, _ = torch.cuda.mem_get_info()
used_after_gib = (total - free_after) / 1024**3
drop_gib = used_before_gib - used_after_gib
# Measure after sleep.
free_after, _ = torch.cuda.mem_get_info()
used_after_gib = (total - free_after) / 1024**3
drop_gib = used_before_gib - used_after_gib
# --- Primary assertion: allocator reports zero tracked memory. ---
# If this fails it means weights were allocated outside the CuMem pool
# (safetensors direct-to-GPU bypass) — the exact regression this test
# is designed to catch.
if allocator is not None:
tracked_after = allocator.get_current_usage()
assert tracked_after == 0, (
f"CuMemAllocator still tracks {tracked_after / 1024**3:.3f} GiB "
f"after sleep(level=1) on custom_pipeline path "
f"(was {tracked_before / 1024**3:.3f} GiB before sleep). "
"Weights were allocated outside the CuMem pool via the "
"safetensors direct-to-GPU fast path — loader-context fix "
"may not be applied."
# get_current_usage() includes unmapped sleeping handles, so it
# does not decrease on sleep. Verify that allocations mapped
# before the call instead transitioned to the asleep state.
if allocator is not None:
assert tracked_before > 0, (
"Expected custom_pipeline weights to use the CuMem pool, "
"but no mapped allocations were tracked before sleep."
)
still_awake = [
ptr
for ptr in tracked_ptrs
if ptr in allocator.pointer_to_data and not allocator.pointer_to_data[ptr].is_asleep
]
assert not still_awake, (
f"{len(still_awake)} CuMem allocation(s) remained mapped "
"after sleep(level=1) on the custom_pipeline path."
)
# Physical VRAM or ACK freed_bytes confirms reclamation at the
# driver level.
total_freed_bytes = sum(
(ack.freed_bytes if hasattr(ack, "freed_bytes") else ack.get("freed_bytes", 0))
for ack in acks
if ack is not None
)
freed_gib = total_freed_bytes / 1024**3
assert freed_gib > 0 or drop_gib > 0, (
f"Expected GPU memory to be reclaimed after sleep(level=1) on "
f"custom_pipeline + enable_sleep_mode=True. "
f"CuMemAllocator tracked before={tracked_before / 1024**3:.3f} GiB, "
f"ACK freed={freed_gib:.3f} GiB, global VRAM drop={drop_gib:.3f} GiB."
)
# --- Secondary assertion: physical VRAM or ACK freed_bytes confirms
# reclamation at the driver level.
total_freed_bytes = sum(
(ack.freed_bytes if hasattr(ack, "freed_bytes") else ack.get("freed_bytes", 0))
for ack in acks
if ack is not None
)
freed_gib = total_freed_bytes / 1024**3
assert freed_gib > 0 or drop_gib > 0, (
f"Expected GPU memory to be reclaimed after sleep(level=1) on "
f"custom_pipeline + enable_sleep_mode=True. "
f"CuMemAllocator tracked before={tracked_before / 1024**3:.3f} GiB, "
f"ACK freed={freed_gib:.3f} GiB, global VRAM drop={drop_gib:.3f} GiB."
)
# Engine must report it is sleeping.
assert await engine.is_sleeping()
# Wake up and confirm the engine is functional again.
await engine.wake_up()
assert not await engine.is_sleeping()
assert await engine.is_sleeping()
await engine.wake_up()
slept = False
assert not await engine.is_sleeping()
finally:
if slept:
await engine.wake_up()
@@ -17,9 +17,12 @@ from functools import lru_cache
import numpy as np
import pytest
import torch.distributed as dist
from transformers import AutoTokenizer
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.executor.uniproc_executor import UniProcDiffusionExecutor
from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -131,6 +134,19 @@ async def _generate_once(
return last_output
def _assert_live_uniproc_worker(engine: AsyncOmni) -> None:
"""Default single-GPU path must use the in-process executor, not mp IPC."""
clients = engine.engine.stage_clients
assert len(clients) == 1
client = clients[0]
assert isinstance(client, InlineStageDiffusionClient)
executor = client._engine.executor
assert isinstance(executor, UniProcDiffusionExecutor)
assert executor.driver_worker is not None
assert dist.is_initialized()
assert dist.get_world_size() == 1
def _assert_valid_image_output(output: OmniRequestOutput) -> None:
assert output.final_output_type == "image"
assert output.images, "Expected at least one generated image"
@@ -158,6 +174,7 @@ async def test_async_omni_generate():
)
after.callback(engine.shutdown)
_assert_live_uniproc_worker(engine)
output = await _generate_once(
engine,
"a beautiful sunset over the ocean with vibrant orange and purple clouds "
@@ -8,6 +8,7 @@ from dataclasses import FrozenInstanceError
import pytest
from tests.helpers.stage_config import get_deploy_duplex_max_sessions
from vllm_omni.config.stage_config import (
DuplexSessionRuntimeConfig,
load_deploy_config,
@@ -66,3 +67,18 @@ def test_duplex_session_runtime_rejects_non_positive_values(tmp_path, name: str,
with pytest.raises(ValueError, match=rf"duplex_session\.{name} must be positive"):
load_deploy_config(deploy_path)
@pytest.mark.parametrize(
("deploy_yaml", "expected"),
[
("minicpmo_4_5.yaml", 4),
("minicpmo_4_5_8x4090.yaml", 1),
("minicpmo_4_5_3gpu_stage1_replicas.yaml", 4),
],
)
def test_deploy_duplex_max_sessions_tracks_the_deploy_config(deploy_yaml: str, expected: int) -> None:
# Guards the admission probe. A capacity edit, a config that declares none,
# or an overlay inheriting one from its base must surface here rather than
# as another nightly duplex admission timeout.
assert get_deploy_duplex_max_sessions(deploy_yaml) == expected
@@ -0,0 +1,94 @@
from __future__ import annotations
from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import duplex_resource_request_id
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.nemotron_voicechat.data_plane import (
NemotronVoiceChatDataPlaneContext,
NemotronVoiceChatDataPlaneSession,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_RUNTIME = {
"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_tokenizer_ref": "test-tokenizer",
}
def _projector(encode_audio=lambda *_: None) -> NemotronVoiceChatDataPlaneSession:
projector = NemotronVoiceChatDataPlaneSession(encode_audio)
projector.configure_runtime(_RUNTIME, tokenizer=SimpleNamespace(decode=lambda *_args, **_kwargs: "text"))
return projector
def _stage0_output(request_id: str = "req-0", *, text_token: int = 12, function_token: int | None = None) -> object:
metadata: dict[str, object] = {"nvc_text_token_ids": [text_token]}
if function_token is not None:
metadata["nvc_function_token"] = [function_token]
completion = SimpleNamespace(multimodal_output=metadata)
return SimpleNamespace(
stage_id=0,
request_output=SimpleNamespace(request_id=request_id, outputs=[completion]),
)
def test_function_channel_projects_completed_call_without_ending_speech() -> None:
projector = _projector()
projector._decode = lambda token_ids: (
'[{"name":"weather","arguments":{"city":"Shanghai"}}]' if token_ids == [99] else ""
)
context = NemotronVoiceChatDataPlaneContext(epoch=0)
events = []
for function_token in (20, 99, 21):
events.extend(projector.project_output(_stage0_output(function_token=function_token), context=context))
listen = [event for event in events if event.get("is_listen") is True]
function = [event for event in events if event.get("function_call") is True]
assert len(listen) == 3
assert len(function) == 1
assert function[0]["name"] == "weather"
assert function[0]["arguments"] == '{"city":"Shanghai"}'
projector._decode = lambda _token_ids: "not-json"
bad_events = []
for function_token in (20, 98, 21):
bad_events.extend(projector.project_output(_stage0_output(function_token=function_token)))
assert any(event.get("error_code") == "nemotron_function_call_parse_error" for event in bad_events)
def test_new_epoch_request_does_not_inherit_partial_function_or_frame_state() -> None:
projector = _projector(lambda *_: "audio")
old_request = duplex_resource_request_id(DuplexFence("sid", epoch=1), "stage0")
new_request = duplex_resource_request_id(DuplexFence("sid", epoch=2), "stage0")
projector.begin_request(old_request)
list(projector.project_output(_stage0_output(old_request, text_token=42, function_token=20)))
projector.begin_request(new_request)
# EOTC in the new epoch must not close the old epoch's partial function.
new_events = list(projector.project_output(_stage0_output(new_request, text_token=1, function_token=21)))
assert not [event for event in new_events if event.get("function_call") is True]
assert not [event for event in new_events if event.get("end_of_turn") is True]
audio = SimpleNamespace(
stage_id=2,
request_output=SimpleNamespace(
request_id=new_request,
outputs=[
SimpleNamespace(multimodal_output={"model_outputs": [np.ones(1764, dtype=np.float32)], "sr": [22050]})
],
),
)
audio_events = list(projector.project_output(audio))
assert sum(event.get("end_of_turn") is True for event in audio_events) == 1
@@ -0,0 +1,73 @@
from __future__ import annotations
import base64
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.nemotron_voicechat.input import (
NemotronVoiceChatPcmAppendBuffer,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _payload(samples: np.ndarray, *, sample_rate_hz: int = 16000, fmt: str = "pcm_f32le") -> dict[str, object]:
return {
"type": "audio",
"audio": base64.b64encode(np.asarray(samples, dtype=np.float32).tobytes()).decode("ascii"),
"format": fmt,
"sample_rate_hz": sample_rate_hz,
}
def _append(buffer, samples, operation_id):
return buffer.prepare_append(
_payload(np.asarray(samples, dtype=np.float32)),
operation_id=operation_id,
chunk_period_ms=80,
allow_emit=True,
)
def test_irregular_browser_packets_emit_exact_ordered_80ms_frame() -> None:
buffer = NemotronVoiceChatPcmAppendBuffer()
assert _append(buffer, np.arange(1000), "packet-1") is None
reservation = _append(buffer, np.arange(1000, 1280), "packet-2")
assert reservation is not None
assert reservation.operation_id == "packet-2"
decoded = np.frombuffer(base64.b64decode(reservation.payload["audio"]), dtype=np.float32)
np.testing.assert_array_equal(decoded, np.arange(1280, dtype=np.float32))
assert buffer.pending_byte_count == 0
def test_append_rejects_invalid_frame_contract() -> None:
cases = (
(1281, 16000, "pcm_f32le", 0.0, "at most 1280"),
(1, 8000, "pcm_f32le", 0.0, "sample_rate_hz"),
(1, 16000, "audio/pcm", 0.0, "format"),
(1, 16000, "pcm_f32le", np.nan, "finite"),
)
for samples, sample_rate_hz, fmt, value, message in cases:
with pytest.raises(ValueError, match=message):
NemotronVoiceChatPcmAppendBuffer().prepare_append(
_payload(np.full(samples, value, dtype=np.float32), sample_rate_hz=sample_rate_hz, fmt=fmt),
operation_id="invalid",
chunk_period_ms=80,
allow_emit=True,
)
def test_terminal_commit_rejects_more_than_one_frame_tail() -> None:
buffer = NemotronVoiceChatPcmAppendBuffer()
for index, samples in enumerate((1000, 281)):
buffer.prepare_append(
_payload(np.zeros(samples, dtype=np.float32)),
operation_id=f"tail-{index}",
chunk_period_ms=80,
allow_emit=False,
)
with pytest.raises(ValueError, match="more than one 80 ms frame"):
buffer.prepare_commit(operation_id="commit", chunk_period_ms=80)
@@ -0,0 +1,115 @@
# 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.experimental.fullduplex.engine.contracts import DuplexInputMode
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.nemotron_voicechat.runtime import (
NemotronVoiceChatDuplexRuntimeExtension,
)
from vllm_omni.experimental.fullduplex.nemotron_voicechat.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]
@@ -18,6 +18,10 @@ from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
DuplexInputMode,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.openai.runtime_adapter import (
ServingRuntimeConfigError,
)
from vllm_omni.experimental.fullduplex.personaplex.config import DEFAULT_PERSONA
from vllm_omni.experimental.fullduplex.personaplex.data_plane import (
PersonaPlexDataPlaneContext,
PersonaPlexDataPlaneSession,
@@ -242,6 +246,53 @@ def test_personaplex_capabilities_are_honest() -> None:
assert multi.supports_barge_in is False
def test_personaplex_runtime_config_update_rejects_changed_persona() -> None:
adapter = PersonaPlexServingRuntimeAdapter(lambda *_: None)
short_persona = "You are a helpful assistant."
longer_persona = (
"You are now a swashbuckling pirate captain who speaks entirely in "
"nautical slang, references buried treasure constantly, and never "
"breaks character no matter what the user asks."
)
current = {"personaplex_persona": short_persona, "personaplex_voice_prompt": "NATF2.pt"}
with pytest.raises(ServingRuntimeConfigError, match="persona"):
adapter.runtime_config_for_update(
SimpleNamespace(instructions=longer_persona, extra_body={}),
current,
)
unchanged = adapter.runtime_config_for_update(
SimpleNamespace(instructions=short_persona, extra_body={}),
current,
)
assert unchanged["personaplex_persona"] == short_persona
def test_personaplex_runtime_config_update_rejects_changed_voice() -> None:
adapter = PersonaPlexServingRuntimeAdapter(lambda *_: None)
current = {"personaplex_persona": DEFAULT_PERSONA, "personaplex_voice_prompt": "NATF2.pt"}
with pytest.raises(ServingRuntimeConfigError, match="voice"):
adapter.runtime_config_for_update(
SimpleNamespace(instructions=None, voice="OtherVoice.pt", extra_body={}),
current,
)
unchanged = adapter.runtime_config_for_update(
SimpleNamespace(instructions=None, voice="NATF2.pt", extra_body={}),
current,
)
assert unchanged["personaplex_voice_prompt"] == "NATF2.pt"
def test_personaplex_rejects_minicpmo_only_opt_out_flag() -> None:
adapter = PersonaPlexServingRuntimeAdapter(lambda *_: None)
with pytest.raises(ServingRuntimeConfigError, match="minicpmo45_native_duplex"):
adapter.validate_client_extra_body({"minicpmo45_native_duplex": False})
def test_pcm_buffer_emits_one_80ms_frame_transactionally() -> None:
buffer = PersonaPlexPcmAppendBuffer()
reservation = buffer.prepare_append(
+144 -3
View File
@@ -1,3 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import base64
import wave
from urllib.parse import parse_qs, urlsplit
@@ -14,13 +18,17 @@ from vllm_omni.experimental.fullduplex.client import (
from vllm_omni.experimental.fullduplex.minicpmo45.policy import (
MiniCPMO45DuplexPolicy,
)
from vllm_omni.experimental.fullduplex.video_stacking import (
concat_frames,
unit_subframe_offsets,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_realtime_client_builds_explicit_native_duplex_url():
url = build_realtime_url(
"ws://localhost:8099/v1/realtime?custom=1",
"ws://localhost:8099/v1/realtime?custom=1&duplex=0&model=stale&minicpmo45_native_duplex=0&session_id=stale",
"openbmb/MiniCPM-o-4_5",
session_id="session-a",
)
@@ -50,7 +58,7 @@ def test_seed_tts_initial_text_is_part_of_native_duplex_context():
def test_realtime_client_builds_resume_only_url_when_autostart_disabled():
url = build_realtime_url(
"ws://localhost:8099/v1/realtime?duplex=1",
"ws://localhost:8099/v1/realtime?duplex=1&autostart=1",
"openbmb/MiniCPM-o-4_5",
autostart=False,
)
@@ -73,10 +81,15 @@ async def test_realtime_client_configure_omits_ref_audio_by_default():
client = Client()
await client.configure("openbmb/MiniCPM-o-4_5", timeout_s=1)
await client.configure(
"openbmb/MiniCPM-o-4_5",
idle_timeout_s=900,
timeout_s=1,
)
session = client.sent[0]["session"]
assert "ref_audio" not in session
assert session["idle_timeout_s"] == 900
@pytest.mark.asyncio
@@ -100,6 +113,74 @@ async def test_realtime_client_configure_sends_explicit_ref_audio():
session = client.sent[0]["session"]
assert session["ref_audio"] == "data:audio/wav;base64,AAAA"
assert "idle_timeout_s" not in session
@pytest.mark.asyncio
async def test_realtime_client_reports_reader_exit_to_event_waiters():
client = RealtimeDuplexClient("ws://unused")
async def stopped():
return None
client._reader_task = asyncio.create_task(stopped())
await client._reader_task
with pytest.raises(ConnectionError, match="closed before"):
client.raise_if_reader_stopped()
@pytest.mark.asyncio
async def test_realtime_client_configure_reports_reader_failure_immediately():
class Client(RealtimeDuplexClient):
async def send(self, event):
async def fail():
raise RuntimeError("reader failed")
self._reader_task = asyncio.create_task(fail())
await asyncio.sleep(0)
client = Client("ws://unused")
with pytest.raises(ConnectionError, match="reader failed"):
await client.configure("model", timeout_s=10.0)
@pytest.mark.asyncio
async def test_realtime_client_close_reports_reader_cancellation_immediately():
class Client(RealtimeDuplexClient):
async def send(self, event):
async def wait_forever():
await asyncio.Future()
self._reader_task = asyncio.create_task(wait_forever())
self._reader_task.cancel()
with pytest.raises(asyncio.CancelledError):
await self._reader_task
client = Client("ws://unused")
with pytest.raises(ConnectionError, match="reader was cancelled"):
await client.close_session(timeout_s=10.0)
@pytest.mark.asyncio
async def test_realtime_client_close_waits_for_a_new_session_closed_event():
class Client(RealtimeDuplexClient):
async def send(self, event):
assert event["type"] == "session.close"
async def acknowledge_close():
await asyncio.sleep(0.02)
self.events.add({"type": "session.closed"})
asyncio.create_task(acknowledge_close())
client = Client("ws://unused")
client.events.add({"type": "session.closed", "reason": "old"})
started_at = asyncio.get_running_loop().time()
await client.close_session(timeout_s=0.2)
assert asyncio.get_running_loop().time() - started_at >= 0.01
@pytest.mark.asyncio
@@ -235,6 +316,7 @@ def test_realtime_event_collector_reports_engine_token_and_audio_intervals():
"output_token_count": 4,
"ttft_ms": 120.0,
"tpot_ms": 15.0,
"itls_ms": [10.0, 14.0, 18.0],
"inter_token_interval_ms": {
"count": 3,
"mean": 14.0,
@@ -340,3 +422,62 @@ def test_realtime_client_pcm16_wav_round_trip(tmp_path):
assert wav_file.getnchannels() == 1
assert wav_file.getframerate() == 16_000
assert read_pcm16_wav(path) == pcm16
def _stack_test_image(color, size=(40, 30)):
from PIL import Image
return Image.new("RGB", size, color)
def test_video_stacking_tiles_a_units_subframes_into_one_image():
frames = [_stack_test_image((255, 0, 0)), _stack_test_image((0, 255, 0))]
composite = concat_frames(frames)
# Landscape cells stack into a column because that canvas is squarer, and
# the interior seam carries the separator band that lets the model tell the
# sub-frames apart.
assert composite.size == (40, 30 * 2 + 6)
assert composite.getpixel((20, 30 + 3)) == (0, 0, 0)
def test_video_stacking_picks_the_squarest_grid():
portrait = [_stack_test_image((255, 0, 0), size=(20, 60)) for _ in range(2)]
four = [_stack_test_image((0, 0, 255)) for _ in range(4)]
# A portrait clip tiles side by side, four frames always tile 2x2.
assert concat_frames(portrait).size == (20 * 2 + 6, 60)
assert concat_frames(four).size == (40 * 2 + 6, 30 * 2 + 6)
def test_video_stacking_skips_the_subframe_that_duplicates_the_base_frame():
# Official samples stack_frames=5 as 0.2/0.4/0.6/0.8 s: offset 0 is the base
# frame, already sent as frame_list[0].
assert unit_subframe_offsets(5) == pytest.approx([0.2, 0.4, 0.6, 0.8])
assert unit_subframe_offsets(1) == []
def test_realtime_client_sends_each_units_composite_beside_its_base_frame():
sent: list[dict] = []
class _Client(RealtimeDuplexClient):
def __init__(self):
pass
async def send(self, event):
sent.append(event)
asyncio.run(
_Client().stream_pcm16(
b"\x01\x00" * (16_000 * 3),
chunk_ms=200,
realtime=False,
video_frames=["f0", "f1"],
stacked_video_frames=["s0", None],
)
)
# A composite belongs to the unit it was captured in, so it rides the same
# append as that unit's base frame; a unit without one sends the base alone.
assert [event["video_frames"] for event in sent if "video_frames" in event] == [["f0", "s0"], ["f1"]]
@@ -0,0 +1,194 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import argparse
import base64
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from vllm_omni.benchmarks.duplex import omni_duplex_eval_runner as runner
from vllm_omni.benchmarks.duplex.omni_duplex_eval_dataset import DuplexSample
from vllm_omni.benchmarks.duplex.omni_duplex_eval_judge import DuplexJudge
from vllm_omni.entrypoints.cli.benchmark import omni_duplex_eval as cli
from vllm_omni.entrypoints.cli.benchmark.omni_duplex_eval import OmniDuplexEvalSubcommand
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.benchmark]
def test_cli_generate_evaluate_summarize_flow(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys):
manifest = tmp_path / "manifest.json"
manifest.write_text(
json.dumps(
[
{
"id": "sample-1",
"split": "PR_correction",
"question_text": "What changed?",
"answer1": "The object moved.",
}
]
),
encoding="utf-8",
)
response_root = tmp_path / "responses"
score_root = tmp_path / "scores"
async def fake_generate(sample, *, output_root, **kwargs):
output = Path(output_root) / sample.split / f"{sample.id}.json"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps([{"sentence": "It moved.", "start": 0, "end": 1}]), encoding="utf-8")
output.with_name(output.stem + ".meta.json").write_text(json.dumps({"clock": "media"}), encoding="utf-8")
return output
class FakeJudge:
def __init__(self, *args, **kwargs):
pass
def chat(self, *args, **kwargs):
return '{"success_score": 1, "is_relevant": 1}'
monkeypatch.setattr(cli, "generate_sample", fake_generate)
monkeypatch.setattr(cli, "DuplexJudge", FakeJudge)
parser = argparse.ArgumentParser()
OmniDuplexEvalSubcommand.add_cli_args(parser)
common = ["--dataset", str(manifest), "--family", "pr"]
generate = parser.parse_args(
[
"generate",
*common,
"--model",
"mock",
"--ref-audio",
str(manifest),
"--response-root",
str(response_root),
"--concurrency",
"2",
]
)
OmniDuplexEvalSubcommand.cmd(generate)
evaluate = parser.parse_args(
[
"evaluate",
*common,
"--response-root",
str(response_root),
"--score-root",
str(score_root),
"--judge-model",
"mock-judge",
"--eval-workers",
"2",
]
)
OmniDuplexEvalSubcommand.cmd(evaluate)
OmniDuplexEvalSubcommand.cmd(parser.parse_args(["summarize", "--score-root", str(score_root)]))
summary = json.loads(capsys.readouterr().out)
assert summary["samples"] == 1
assert summary["pr"]["mean_all_success"] == 1.0
@pytest.mark.asyncio
async def test_generate_exercises_realtime_socket_and_media_clock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
import websockets
received = []
async def handler(websocket):
response_started = False
async for raw in websocket:
event = json.loads(raw)
received.append(event)
if event["type"] == "session.update":
await websocket.send(json.dumps({"type": "session.created"}))
elif event["type"] == "input_audio_buffer.append" and not response_started:
response_started = True
await websocket.send(json.dumps({"type": "response.created", "response": {"id": "r1"}}))
await websocket.send(
json.dumps({"type": "response.output_text.delta", "response_id": "r1", "delta": "Done."})
)
await websocket.send(
json.dumps(
{
"type": "response.audio.delta",
"response_id": "r1",
"delta": base64.b64encode(b"\0\0" * 240).decode(),
"sample_rate_hz": 24_000,
}
)
)
elif event["type"] == "input_audio_buffer.commit":
await websocket.send(json.dumps({"type": "response.done", "response": {"id": "r1"}}))
elif event["type"] == "session.close":
await websocket.send(json.dumps({"type": "session.closed"}))
return
monkeypatch.setattr(runner, "read_audio_pcm16", lambda path: b"\0\0" * (16_000 * 8 // 10))
monkeypatch.setattr(runner, "video_duration", lambda path: 0.8)
monkeypatch.setattr(runner, "iter_jpegs", lambda *args, **kwargs: iter([(0.0, b"jpeg")]))
audio = tmp_path / "question.wav"
video = tmp_path / "video.mp4"
ref = tmp_path / "ref.wav"
for path in (audio, video, ref):
path.write_bytes(b"data")
sample = DuplexSample("sample", "PR_correction", "pr", "correction", video, audio)
async with websockets.serve(handler, "127.0.0.1", 0) as server:
port = server.sockets[0].getsockname()[1]
output = await runner.generate_sample(
sample,
url=f"ws://127.0.0.1:{port}/v1/realtime?duplex=1",
model="mock",
ref_audio=ref,
output_root=tmp_path / "responses",
)
assert json.loads(output.read_text(encoding="utf-8")) == [{"sentence": "Done.", "start": 0.8, "end": 0.8}]
meta = json.loads(output.with_name("sample.meta.json").read_text(encoding="utf-8"))
assert meta["response_done"] is True
assert meta["drain_timeout"] is None
assert any(event["type"] == "playback.ack" for event in received)
def test_judge_exercises_openai_http_schema():
requests = []
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers["Content-Length"])
requests.append((self.path, self.headers["Authorization"], json.loads(self.rfile.read(length))))
payload = json.dumps({"choices": [{"message": {"content": '{"success_score": 1}'}}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
judge = DuplexJudge(f"http://127.0.0.1:{server.server_port}", "judge", api_key="token")
assert judge.chat("prompt") == '{"success_score": 1}'
finally:
server.shutdown()
thread.join()
server.server_close()
path, authorization, payload = requests[0]
assert path == "/v1/chat/completions"
assert authorization == "Bearer token"
assert payload["model"] == "judge"
assert payload["messages"] == [{"role": "user", "content": "prompt"}]
@@ -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
"""GPU e2e for PersonaPlex elastic batching (bit-exact isolation + recycle).
Greedy decoding makes every cross-slot leak observable as a bit difference:
@@ -24,12 +24,17 @@ from __future__ import annotations
import numpy as np
import pytest
from tests.helpers.mark import hardware_marks
torch = pytest.importorskip("torch")
# slow + H100 + omni routes this module into the weekly Buildkite E2E group
# ("Omni · H100" in .buildkite/cuda/test-weekly.yml: -m "slow and H100 and
# omni" --run-level full_model); the ready/merge CPU sweeps exclude it by mark.
pytestmark = [pytest.mark.full_model, pytest.mark.omni, pytest.mark.slow, pytest.mark.H100]
# slow + H100 + omni + cards_1 routes this module into weekly
# "Omni · H100 · Single-GPU" (test-weekly.yml).
pytestmark = [
pytest.mark.omni,
pytest.mark.slow,
*hardware_marks(res={"cuda": "H100"}),
]
if not torch.cuda.is_available(): # noqa: E402
pytest.skip("needs CUDA", allow_module_level=True)
@@ -1,71 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
import numpy as np
import pytest
import torch
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
# Prefer a tiny/random checkpoint for CI.
# Override in CI if needed: AUDIOX_TEST_MODEL=<model-or-local-path>
models = [os.environ.get("AUDIOX_TEST_MODEL", "zhangj1an/audiox_random")]
# (model, deploy_config_path, extra_omni_kwargs) for ``omni_runner`` indirect parametrize
_OMNI_RUNNER_PARAMS = [(m, None, {"model_class_name": "AudioXPipeline"}) for m in models]
pytestmark = [
pytest.mark.slow,
pytest.mark.diffusion,
pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True, ids=models),
]
@hardware_test(res={"cuda": "L4", "xpu": "B60"})
def test_audiox_model(omni_runner: OmniRunner) -> None:
# Keep runtime short for CI.
seconds_total = 2.0
# AudioXPipeline always emits 44.1 kHz stereo (advertised via class-level
# ``audio_sample_rate``); the trimmed output should match this rate.
sample_rate = 44100
outputs = omni_runner.omni.generate(
prompts={"prompt": "A dog barking in a quiet park."},
sampling_params_list=OmniDiffusionSamplingParams(
num_inference_steps=4,
guidance_scale=6.0,
generator=torch.Generator(current_omni_platform.device_type).manual_seed(42),
num_outputs_per_prompt=1,
extra_args={
"audiox_task": "t2a",
"seconds_start": 0.0,
"seconds_total": seconds_total,
},
),
)
assert outputs is not None
first_output = outputs[0]
assert first_output.final_output_type == "audio"
assert isinstance(first_output, OmniRequestOutput) and first_output
req_out = first_output
assert isinstance(req_out, OmniRequestOutput)
assert req_out.final_output_type == "audio"
assert hasattr(req_out, "multimodal_output") and req_out.multimodal_output
audio = req_out.multimodal_output.get("audio")
assert isinstance(audio, np.ndarray)
# audio shape: (batch, channels, samples)
assert audio.ndim == 3
assert audio.shape[0] == 1
assert audio.shape[1] == 2
assert audio.shape[2] > 0
expected_samples = int(seconds_total * sample_rate)
assert abs(audio.shape[2] - expected_samples) <= 2 * 1024
+1 -1
View File
@@ -46,7 +46,7 @@ REFERENCE_TEXT_IMG2TEXT = (
_OMNI_RUNNER_PARAM = (MODEL_NAME, STAGE_CONFIG)
pytestmark = [
pytest.mark.advanced_model,
pytest.mark.slow,
pytest.mark.diffusion,
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
]
@@ -138,7 +138,7 @@ def _make_file_lora_request(adapter_dir: Path) -> LoRARequest:
pytestmark = [
pytest.mark.full_model,
pytest.mark.slow,
pytest.mark.diffusion,
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
]
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
E2E tests for Covo-Audio-Chat model with audio input and audio/text output.
"""
@@ -25,7 +28,7 @@ def get_question():
@pytest.mark.omni
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_audio_to_audio(omni_runner, omni_runner_handler) -> None:
def test_audio_to_audio(omni_runner, offline_client) -> None:
"""
Test audio input → text + audio output.
Deploy Setting: default yaml
@@ -44,4 +47,4 @@ def test_audio_to_audio(omni_runner, omni_runner_handler) -> None:
"audios": (audio, 16000),
"modalities": ["audio"],
}
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@@ -1,290 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""E2E test for DreamID-Omni offline inference with Cache-Dit.
Uses the official DreamID-Omni ``test_case/twoip`` example assets instead of
synthetic inputs so the coverage matches the upstream demo structure.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import time
from functools import lru_cache
from pathlib import Path
from typing import NamedTuple
import numpy as np
import pytest
import soundfile as sf
from PIL import Image
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
pytestmark = [pytest.mark.diffusion, pytest.mark.cache, pytest.mark.slow]
REPO_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_MODEL_PATH = REPO_ROOT / "dreamid_omni"
MODEL_PATH_ENV = "DREAMID_OMNI_MODEL_PATH"
EXAMPLE_ROOT_ENV = "DREAMID_OMNI_EXAMPLE_ROOT"
NUM_INFERENCE_STEPS = 45
CACHE_CONFIG = {
"Fn_compute_blocks": 1,
"Bn_compute_blocks": 0,
"max_warmup_steps": 4,
"max_cached_steps": 20,
"residual_diff_threshold": 0.24,
"max_continuous_cached_steps": 3,
"enable_taylorseer": False,
"taylorseer_order": 1,
"scm_steps_mask_policy": None,
"scm_steps_policy": "dynamic",
}
class DreamIDGenerationResult(NamedTuple):
video: np.ndarray
audio: np.ndarray
fps: int
duration_s: float
elapsed_s: float
class DreamIDExampleCase(NamedTuple):
prompt: str
image_paths: tuple[Path, ...]
audio_paths: tuple[Path, ...]
def _require_model_path() -> str:
model_path = os.environ.get(MODEL_PATH_ENV, str(DEFAULT_MODEL_PATH))
if not Path(model_path).exists():
pytest.skip(f"DreamID-Omni model bundle not found at {model_path}. Set {MODEL_PATH_ENV}.")
if importlib.util.find_spec("dreamid_omni") is None:
pytest.skip(
"dreamid_omni dependency package is not importable. "
"Run examples/offline_inference/x_to_video_audio/download_dreamid_omni.py first."
)
return model_path
def _dreamid_repo_root_candidates() -> list[Path]:
candidates: list[Path] = []
example_root = os.environ.get(EXAMPLE_ROOT_ENV)
if example_root:
candidates.append(Path(example_root).expanduser().resolve())
spec = importlib.util.find_spec("dreamid_omni")
if spec is not None and spec.origin:
candidates.append(Path(spec.origin).resolve().parents[1] / "test_case" / "twoip")
return candidates
@lru_cache(maxsize=1)
def _load_example_case() -> DreamIDExampleCase:
for candidate_root in _dreamid_repo_root_candidates():
caption_path = candidate_root / "captions" / "20.json"
image_paths = (
candidate_root / "imgs" / "20" / "0.png",
candidate_root / "imgs" / "20" / "1.png",
)
audio_paths = (
candidate_root / "audios" / "20" / "0.wav",
candidate_root / "audios" / "20" / "1.wav",
)
if caption_path.exists() and all(path.exists() for path in image_paths + audio_paths):
prompt = _load_prompt_from_json(caption_path)
return DreamIDExampleCase(
prompt=prompt,
image_paths=image_paths,
audio_paths=audio_paths,
)
searched = ", ".join(str(path) for path in _dreamid_repo_root_candidates()) or "<none>"
pytest.skip(
"DreamID-Omni official example assets not found. "
f"Set {EXAMPLE_ROOT_ENV} to the local 'test_case/twoip' directory. "
f"Searched: {searched}"
)
def _load_prompt_from_json(path: Path) -> str:
raw_text = path.read_text(encoding="utf-8").strip()
parsed = json.loads(raw_text)
if isinstance(parsed, str):
prompt = parsed
elif isinstance(parsed, dict) and isinstance(parsed.get("content"), str):
prompt = parsed["content"]
else:
raise AssertionError(f"Unexpected DreamID caption format in {path}")
prompt = re.sub(
r"\[SPEAKER_TIMESTAMPS_START\].*?\[SPEAKER_TIMESTAMPS_END\]",
"",
prompt,
flags=re.DOTALL,
).strip()
prompt = re.sub(
r"\[AUDIO_DESCRIPTION_START].*?\[AUDIO_DESCRIPTION_END]",
"",
prompt,
flags=re.DOTALL,
).strip()
prompt = re.sub(r"\[[A-Z_]+\]", "", prompt)
prompt = re.sub(r"\n\s*\n", "\n", prompt).strip()
return prompt
def _load_example_images(example_case: DreamIDExampleCase) -> list[Image.Image]:
images: list[Image.Image] = []
for path in example_case.image_paths:
with Image.open(path) as image:
images.append(image.convert("RGB"))
return images
def _load_example_audios(example_case: DreamIDExampleCase) -> list[np.ndarray]:
audios: list[np.ndarray] = []
for path in example_case.audio_paths:
audio_array, sample_rate = sf.read(str(path), dtype="float32")
audio_np = np.asarray(audio_array, dtype=np.float32)
if audio_np.ndim == 2:
audio_np = np.mean(audio_np, axis=1)
audio_np = audio_np[int(sample_rate * 1) : int(sample_rate * 3)]
audios.append(audio_np.reshape(-1))
return audios
def _sampling_params(num_inference_steps: int) -> OmniDiffusionSamplingParams:
return OmniDiffusionSamplingParams(
height=704,
width=1280,
num_inference_steps=num_inference_steps,
seed=42,
extra_args={
"solver_name": "unipc",
"shift": 5.0,
},
)
def _request_payload(example_case: DreamIDExampleCase) -> dict[str, object]:
return {
"prompt": example_case.prompt,
"video_negative_prompt": "jitter, bad hands, blur, distortion",
"audio_negative_prompt": "robotic, muffled, echo, distorted",
"multi_modal_data": {
"image": _load_example_images(example_case),
"audio": _load_example_audios(example_case),
},
}
def _extract_generation(outputs: list[object], elapsed_s: float) -> DreamIDGenerationResult:
"""Extract video and audio from raw Omni outputs"""
assert outputs, "DreamID-Omni returned no outputs"
result = outputs[0]
assert hasattr(result, "images") and result.images, "not return video frames"
video = result.images[0]
assert isinstance(video, np.ndarray), f"Expected video ndarray, got {type(video)}"
assert video.ndim == 4, f"Expected 4D DreamID video tensor, got {video.shape}"
multimodal_output = getattr(result, "multimodal_output", None) or {}
assert "audio" in multimodal_output, "not return audio"
audio = np.asarray(multimodal_output["audio"], dtype=np.float32).reshape(-1)
assert audio.size > 0, "returned empty audio"
audio_sample_rate = int(multimodal_output.get("audio_sample_rate", 16000))
fps = multimodal_output.get("fps", 24)
duration_s = float(audio.size) / float(audio_sample_rate)
generation = DreamIDGenerationResult(
video=video.astype(np.float32, copy=False),
audio=audio,
fps=fps,
duration_s=duration_s,
elapsed_s=elapsed_s,
)
return generation
def _run_generation(
*,
model_path: str,
example_case: DreamIDExampleCase,
use_cache_dit: bool,
) -> DreamIDGenerationResult:
runner_kwargs = {
"model_type": "dreamid-omni",
"parallel_config": DiffusionParallelConfig(cfg_parallel_size=1),
}
if use_cache_dit:
runner_kwargs["cache_backend"] = "cache_dit"
runner_kwargs["cache_config"] = CACHE_CONFIG
with OmniRunner(model_path, **runner_kwargs) as runner:
warmup_payload = _request_payload(example_case)
_ = runner.omni.generate(warmup_payload, _sampling_params(2))
elapsed_samples: list[float] = []
extracted_result: DreamIDGenerationResult | None = None
request_payload = _request_payload(example_case)
start = time.perf_counter()
outputs = runner.omni.generate(request_payload, _sampling_params(NUM_INFERENCE_STEPS))
elapsed_s = time.perf_counter() - start
elapsed_samples.append(elapsed_s)
if extracted_result is None:
extracted_result = _extract_generation(outputs, elapsed_s)
assert extracted_result is not None
median_elapsed_s = float(np.median(np.asarray(elapsed_samples, dtype=np.float64)))
return extracted_result._replace(elapsed_s=median_elapsed_s)
def _video_mae(a: np.ndarray, b: np.ndarray) -> float:
assert a.shape == b.shape, f"Video shapes differ: {a.shape} vs {b.shape}"
return float(np.mean(np.abs(a - b)))
def _audio_rms(audio: np.ndarray) -> float:
audio = np.asarray(audio, dtype=np.float32).reshape(-1)
return float(np.sqrt(np.mean(audio**2)))
def _audio_rms_ratio(a: DreamIDGenerationResult, b: DreamIDGenerationResult) -> float:
rms_a = _audio_rms(a.audio)
rms_b = _audio_rms(b.audio)
return rms_b / max(rms_a, 1e-6)
@hardware_test(res={"cuda": "H100"})
def test_dreamid_omni_cache_dit_e2e() -> None:
model_path = _require_model_path()
example_case = _load_example_case()
baseline = _run_generation(model_path=model_path, example_case=example_case, use_cache_dit=False)
cached = _run_generation(model_path=model_path, example_case=example_case, use_cache_dit=True)
video_mae = _video_mae(baseline.video, cached.video)
audio_rms_ratio = _audio_rms_ratio(baseline, cached)
speedup = baseline.elapsed_s / cached.elapsed_s if cached.elapsed_s > 0 else float("inf")
assert video_mae <= 0.15, f"video drift too much: {video_mae:.6f}"
assert 0.4 <= audio_rms_ratio <= 2.5, f"audio quality regressed too much: rms_ratio={audio_rms_ratio:.6f}"
assert speedup >= 1.05, f"cache-dit improve not enough: {speedup:.3f}x"
@@ -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
"""
End-to-end test for ErnieImage text-to-image generation.
@@ -11,7 +11,7 @@ Equivalent to running:
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunnerHandler
from tests.helpers.runtime import OfflineOmniClient
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -37,9 +37,9 @@ pytestmark = [
@hardware_test(res={"cuda": "L4"}, num_cards=2)
def test_ernie_image_text_to_image(omni_runner_handler: OmniRunnerHandler) -> None:
def test_ernie_image_text_to_image(offline_client: OfflineOmniClient) -> None:
request_config = {
"model": omni_runner_handler.runner.model_name,
"model": offline_client.runner.model_name,
"prompt": "A photo of a cat sitting on a laptop",
"sampling_params": OmniDiffusionSamplingParams(
height=512,
@@ -49,4 +49,4 @@ def test_ernie_image_text_to_image(omni_runner_handler: OmniRunnerHandler) -> No
seed=42,
),
}
omni_runner_handler.send_diffusion_request(request_config)
offline_client.send_diffusion_request(request_config)
@@ -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
"""E2E tests for FLUX AutoRound W4A16 quantized inference.
These tests require:
@@ -19,8 +19,8 @@ import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.monitor import DeviceMemoryMonitor
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -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
"""
Tests for FluxKontext model pipeline.
@@ -15,7 +15,7 @@ import pytest
from vllm.assets.image import ImageAsset
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunnerHandler
from tests.helpers.runtime import OfflineOmniClient
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -47,18 +47,18 @@ def _sampling_512() -> OmniDiffusionSamplingParams:
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_flux_kontext_text_to_image(omni_runner_handler: OmniRunnerHandler):
def test_flux_kontext_text_to_image(offline_client: OfflineOmniClient):
"""Test FluxKontext text-to-image generation with real model."""
request_config = {
"model": MODEL,
"prompt": "A photo of a cat sitting on a laptop",
"sampling_params": _sampling_512(),
}
omni_runner_handler.send_diffusion_request(request_config)
offline_client.send_diffusion_request(request_config)
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_flux_kontext_image_edit(omni_runner_handler: OmniRunnerHandler):
def test_flux_kontext_image_edit(offline_client: OfflineOmniClient):
"""Test FluxKontext image-to-image editing with real model."""
input_image = ImageAsset("2560px-Gfp-wisconsin-madison-the-nature-boardwalk").pil_image.convert("RGB")
request_config = {
@@ -67,4 +67,4 @@ def test_flux_kontext_image_edit(omni_runner_handler: OmniRunnerHandler):
"modalities": ["img2img"],
"sampling_params": _sampling_512(),
}
omni_runner_handler.send_diffusion_request(request_config)
offline_client.send_diffusion_request(request_config)
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Tests for Flux1 Schnell."""
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunnerHandler
from tests.helpers.runtime import OfflineOmniClient
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = "black-forest-labs/FLUX.1-schnell"
@@ -21,7 +21,7 @@ pytestmark = [
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_flux_schnell_text_to_image(omni_runner_handler: OmniRunnerHandler):
def test_flux_schnell_text_to_image(offline_client: OfflineOmniClient):
"""Test FLUX.1-schnell text-to-image generation."""
request_config = {
"model": MODEL,
@@ -33,4 +33,4 @@ def test_flux_schnell_text_to_image(omni_runner_handler: OmniRunnerHandler):
seed=42,
),
}
omni_runner_handler.send_diffusion_request(request_config)
offline_client.send_diffusion_request(request_config)
+21 -17
View File
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
End-to-end test for Flux2 Klein inpainting.
Inpainting uses ``omni_runner_handler.send_diffusion_request`` with
Inpainting uses ``offline_client.send_diffusion_request`` with
``multi_modal_data`` containing ``image`` and ``mask_image``; see
:meth:`OmniRunnerHandler.send_diffusion_request` in ``tests.helpers.runtime``.
:meth:`OfflineOmniClient.send_diffusion_request` in ``tests.helpers.runtime``.
"""
import pytest
@@ -14,7 +14,7 @@ import torch
from PIL import Image, ImageDraw
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import DiffusionResponse, OmniRunnerHandler
from tests.helpers.runtime import DiffusionResponse, OfflineOmniClient
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
@@ -47,15 +47,17 @@ def _create_test_inputs(color: tuple = (100, 150, 200)):
def _images_from_response(response: DiffusionResponse) -> list[Image.Image]:
if isinstance(response.images[0], list):
return [f for fr in response.images for f in fr]
return list(response.images)
images = response.images
assert images is not None
if images and isinstance(images[0], list):
return [frame for frames in images for frame in frames]
return list(images)
def _send_inpaint_with_generator(
omni_runner_handler: OmniRunnerHandler, prompt: str, input_image, mask_image, generator: torch.Generator
offline_client: OfflineOmniClient, prompt: str, input_image, mask_image, generator: torch.Generator
) -> DiffusionResponse:
return omni_runner_handler.send_diffusion_request(
return offline_client.send_diffusion_request(
{
"model": MODEL,
"prompt": prompt,
@@ -72,18 +74,19 @@ def _send_inpaint_with_generator(
)
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.diffusion
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_flux2_klein_inpaint_deterministic(omni_runner_handler: OmniRunnerHandler):
def test_flux2_klein_inpaint_deterministic(offline_client: OfflineOmniClient):
input_image, mask_image = _create_test_inputs()
seed = 12345
gen1 = torch.Generator(current_omni_platform.device_type).manual_seed(seed)
gen2 = torch.Generator(current_omni_platform.device_type).manual_seed(seed)
r1 = _send_inpaint_with_generator(omni_runner_handler, "A red flower in a field", input_image, mask_image, gen1)
r2 = _send_inpaint_with_generator(omni_runner_handler, "A red flower in a field", input_image, mask_image, gen2)
r1 = _send_inpaint_with_generator(offline_client, "A red flower in a field", input_image, mask_image, gen1)
r2 = _send_inpaint_with_generator(offline_client, "A red flower in a field", input_image, mask_image, gen2)
images1 = _images_from_response(r1)
images2 = _images_from_response(r2)
@@ -93,17 +96,18 @@ def test_flux2_klein_inpaint_deterministic(omni_runner_handler: OmniRunnerHandle
)
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.diffusion
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_flux2_klein_inpaint_different_seeds_different_output(omni_runner_handler: OmniRunnerHandler):
def test_flux2_klein_inpaint_different_seeds_different_output(offline_client: OfflineOmniClient):
input_image, mask_image = _create_test_inputs()
gen1 = torch.Generator(current_omni_platform.device_type).manual_seed(42)
gen2 = torch.Generator(current_omni_platform.device_type).manual_seed(99999)
r1 = _send_inpaint_with_generator(omni_runner_handler, "A beautiful landscape", input_image, mask_image, gen1)
r2 = _send_inpaint_with_generator(omni_runner_handler, "A beautiful landscape", input_image, mask_image, gen2)
r1 = _send_inpaint_with_generator(offline_client, "A beautiful landscape", input_image, mask_image, gen1)
r2 = _send_inpaint_with_generator(offline_client, "A beautiful landscape", input_image, mask_image, gen2)
images1 = _images_from_response(r1)
images2 = _images_from_response(r2)
@@ -0,0 +1,345 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""E2E offline inference tests for the Gepard-1.0 single-stage native-AR TTS.
Zero-shot only (the model's default learned voice). These tests need a GPU and
the NeMo NanoCodec, so the whole file runs in the weekly tier -- see the comment
above ``pytestmark``. The window arithmetic behind the streaming decode is
covered on CPU in ``tests/model_executor/models/test_gepard_window.py``.
"""
from __future__ import annotations
import functools
import tempfile
from pathlib import Path
from typing import NamedTuple
import pytest
import soundfile as sf
import torch
from transformers import AutoTokenizer
from tests.helpers.mark import hardware_test
from tests.helpers.media import convert_audio_file_to_text
from tests.helpers.runtime import OmniRunner # noqa: F401 (indirect fixture)
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni import Omni
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
from vllm_omni.model_executor.models.gepard.prompt import build_gepard_prompt_ids
# The codec decoder is an optional dependency; skip rather than fail the engine
# inside the worker process when it is absent. The CI image does not carry it,
# so the weekly step installs it first -- see .buildkite/cuda/test-weekly.yml.
# Install recipe: examples/offline_inference/text_to_speech/README.md.
pytest.importorskip("nemo.collections.tts.models")
MODEL_NAME = "nineninesix/gepard-1.0"
STAGE_CONFIG = get_deploy_config_path("gepard.yaml")
SAMPLE_RATE = 22050
# NanoCodec upsamples one FSQ frame to exactly this many samples.
SAMPLES_PER_FRAME = 1024
@functools.cache
def _config() -> GepardConfig:
"""The checkpoint's own layout, not the class defaults.
The worker builds its config from the ``gepard_config.json`` sidecar, so a
prompt assembled from the defaults would silently use a different layout
the day the sidecar moves — and a mismatched layout degrades the speech
rather than failing, which is the hardest way for this to be found.
"""
return GepardConfig.from_checkpoint(MODEL_NAME)
# Prompt -> a word that appears in no other prompt, so a clip can be traced
# back to the request that asked for it.
#
# These are not interchangeable. Batching perturbs every request's sampling
# trajectory, and on some trajectories this checkpoint's stop head never fires;
# this set was screened over 10 batched runs with no runaway. Re-screen the
# whole set when changing one, not just the row changed.
_DISTINGUISHABLE_PROMPTS = {
"He drinks coffee every morning.": "coffee",
"Machine learning is interesting.": "learning",
"Please close the window before leaving.": "window",
"My favorite color is purple.": "purple",
}
# Above text_repetition.apply_below, so the layout is voiced once instead of
# repeated. Every other prompt here takes the repeated branch.
_LONG_TEXT = (
"The morning train was late again, so I walked the last two miles along the "
"river and reached the office just before nine."
)
_OMNI_RUNNER_PARAM = (
MODEL_NAME,
STAGE_CONFIG,
{"trust_remote_code": True},
)
# Only a common word is checked, which needs less ASR than the shared helper's
# ``small`` default; a miss escalates to it.
_ASR_MODEL = "base"
_ASR_ESCALATION_MODEL = "small"
# Transcription dominates this file's runtime -- Whisper runs on CPU on a
# single-GPU runner -- so only two of the four concurrent clips are read as
# speech. The pairwise waveform comparison covers all four.
_TRANSCRIBED_CLIPS = 2
# No level marker at module scope: each test carries the one matching how often
# its claim needs re-checking, though the weekly step collects the file whole.
# There is deliberately no core_model row -- under dummy weights the stop head is
# random, so every request would run to gepard.yaml's max_tokens.
pytestmark = [
pytest.mark.tts,
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
]
def _build_request(text: str) -> dict:
"""Assemble the [speaker slots, SOT, text, EOT, SOS] prompt."""
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
prompt_token_ids = build_gepard_prompt_ids(
tokenizer(text, add_special_tokens=False)["input_ids"],
config=_config(),
)
return {
"prompt_token_ids": prompt_token_ids,
"additional_information": {"text": [text]},
}
def _extract_audio(mm) -> torch.Tensor | None:
# The consolidation path renames "model_outputs" to "audio".
audio = mm.get("audio")
if audio is None:
audio = mm.get("model_outputs")
if isinstance(audio, list):
audio = audio[0] if len(audio) else None
return audio
class _Clip(NamedTuple):
wav: torch.Tensor
# Counts the frames the codec was handed using only what the engine
# returned, never the model's internals.
committed_frames: int
def _synthesize_all(omni: Omni, texts: list[str]) -> list[_Clip]:
"""Submit every text in one call; return one clip per request, in
submission order.
``Omni.generate`` yields in completion order and never sorts, so the pairing
is restored from the request id prefix it assigns (``f"{i}_{uuid4()}"``).
Without that, the only testable claim is that the batch as a whole looks
plausible, which a stable swap between two requests satisfies.
Nothing here authors a SamplingParams: a fresh one would replace the stage
defaults rather than merge over them, dropping the pipeline's stop token.
"""
outputs = omni.generate([_build_request(t) for t in texts])
assert len(outputs) == len(texts), f"expected {len(texts)} outputs, got {len(outputs)}"
by_index: dict[int, _Clip] = {}
for stage_outputs in outputs:
index = int(str(stage_outputs.request_id).split("_", 1)[0])
waveform = None
frames = 0
for out in stage_outputs.outputs:
# Explicit None check: `x or []` would ask an array for a truth value.
token_ids = out.token_ids if out.token_ids is not None else []
# STOP sits just past head0's range, so a token equal to it is
# never a committed frame.
frames += sum(1 for t in token_ids if int(t) != _config().stop_token)
mm = out.multimodal_output
if mm is None:
continue
audio = _extract_audio(mm)
if audio is None:
continue
sr_t = mm.get("sr")
if hasattr(sr_t, "item"):
assert int(sr_t.item()) == SAMPLE_RATE
waveform = audio.cpu().float()
assert waveform is not None, "no audio output produced"
by_index[index] = _Clip(wav=waveform, committed_frames=frames)
assert sorted(by_index) == list(range(len(texts))), (
f"request ids did not cover every submitted prompt exactly once: got {sorted(by_index)}"
)
return [by_index[i] for i in range(len(texts))]
def _synthesize(omni: Omni, text: str) -> _Clip:
return _synthesize_all(omni, [text])[0]
def _assert_clip_is_sane(clip: _Clip) -> None:
"""The checks every clip has to pass, whatever produced it."""
wav = clip.wav
assert wav.dim() == 1, f"expected mono 1-D, got shape {tuple(wav.shape)}"
assert wav.numel() > 0, "empty waveform"
assert torch.isfinite(wav).all(), "non-finite samples"
# Frame conservation against the engine's own token count, not just
# divisibility. This is what catches a payload carrying non-audio data or a
# dropped tail; both pass every other check here.
assert wav.numel() == clip.committed_frames * SAMPLES_PER_FRAME, (
f"{wav.numel()} samples for {clip.committed_frames} committed frames — "
f"expected exactly {clip.committed_frames * SAMPLES_PER_FRAME}"
)
# Anything far outside the waveform range is not codec output.
assert float(wav.abs().max()) <= 1.5, f"peak {float(wav.abs().max()):.2f} is out of range"
def _assert_stopped_on_its_own(clip: _Clip) -> None:
"""The request must stop on its own rather than run to ``max_tokens``.
Separate from the structural checks because it reads the stop head's
judgement, not the payload's shape: under ``load_format: dummy`` that head
is random and never fires, so every clip runs to the token cap.
"""
seconds = clip.wav.numel() / SAMPLE_RATE
assert 0.5 < seconds < 30.0, f"implausible duration {seconds:.2f}s — did STOP fire?"
def _transcribe(wav: torch.Tensor, tmp_dir: str, name: str, model_size: str = _ASR_MODEL) -> str:
path = str(Path(tmp_dir) / f"{name}.wav")
sf.write(path, wav.numpy(), SAMPLE_RATE)
return convert_audio_file_to_text(path, model_size=model_size, language="en").lower()
def _transcribe_for(wav: torch.Tensor, tmp_dir: str, name: str, accepts) -> str:
"""Transcribe with the fast model, retrying once with the stronger one.
``accepts`` decides whether the fast transcript is usable. Escalating only
on a miss keeps the average clip at ``_ASR_MODEL`` while a genuine model
artifact still fails the caller's assertion, since the stronger ASR
mistranscribes it too.
"""
transcript = _transcribe(wav, tmp_dir, name)
if accepts(transcript):
return transcript
return _transcribe(wav, tmp_dir, name, model_size=_ASR_ESCALATION_MODEL)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize(
("text", "keyword"),
[
# The canonical path: one request, one transcript, the cheapest check
# that reads the audio as speech.
pytest.param(
"Hello, this is Gepard speaking.",
"hello",
marks=pytest.mark.advanced_model,
id="canonical",
),
# No keyword: this row carries the structural and stopping checks over a
# second text and leaves the transcript to the row above.
pytest.param(
"The quick brown fox jumps over the lazy dog.",
None,
marks=pytest.mark.full_model,
id="second_text",
),
],
)
def test_gepard_offline_zero_shot(omni_runner, run_level: str, text: str, keyword: str | None) -> None:
"""Zero-shot synthesis produces a finite mono waveform at 22.05 kHz.
The transcript check is the one assertion here that reads the audio as
speech rather than as a tensor. It stands in for a parity comparison, which
needs a reference implementation this PR cannot run — noise, a repeated
loop or the wrong clip all pass every numeric check above it.
"""
clip = _synthesize(omni_runner, text)
_assert_clip_is_sane(clip)
if run_level not in {"advanced_model", "full_model"}:
return # dummy weights: the audio is structural only, so do not read it
_assert_stopped_on_its_own(clip)
if keyword is None:
return
with tempfile.TemporaryDirectory() as tmp_dir:
transcript = _transcribe_for(clip.wav, tmp_dir, "zero_shot", lambda t: keyword in t)
assert keyword in transcript, f"expected {keyword!r} in transcript, got {transcript!r}"
@pytest.mark.full_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_gepard_offline_long_text_skips_repetition(omni_runner, run_level: str) -> None:
"""The other branch of the prompt layout, end to end.
A text at or above ``apply_below`` tokens is voiced once; shorter ones are
repeated to carry enough mass against the speaker prefix. The layout
assertion keeps this row honest — if a tokenizer change pushed this text
back under the threshold it would silently retest the repeated path.
"""
prompt_ids = _build_request(_LONG_TEXT)["prompt_token_ids"]
assert prompt_ids.count(_config().start_of_text) == 1, "_LONG_TEXT is no longer above apply_below"
clip = _synthesize(omni_runner, _LONG_TEXT)
_assert_clip_is_sane(clip)
if run_level not in {"advanced_model", "full_model"}:
return
_assert_stopped_on_its_own(clip)
with tempfile.TemporaryDirectory() as tmp_dir:
transcript = _transcribe_for(clip.wav, tmp_dir, "long_text", lambda t: "river" in t)
assert "river" in transcript, f"expected 'river' in transcript, got {transcript!r}"
@pytest.mark.slow
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_gepard_offline_concurrent_requests_stay_isolated(omni_runner, run_level: str) -> None:
"""Four requests in one batch, each holding its own generation state.
The prompts are deliberately unlike each other: cross-talk between the
per-request frame history, the codec window or the output routing shows up
as a transcript matching the wrong prompt, which similar prompts would
hide. ``max_num_seqs`` is 4, so these really do run together.
"""
texts = list(_DISTINGUISHABLE_PROMPTS)
clips = _synthesize_all(omni_runner, texts)
for clip in clips:
_assert_clip_is_sane(clip)
if run_level not in {"advanced_model", "full_model"}:
return # dummy weights: the audio is structural only, so do not read it
for clip in clips:
_assert_stopped_on_its_own(clip)
# All four clips, not just the transcribed ones: delivering one request's
# audio for another leaves two byte-identical waveforms.
for i in range(len(clips)):
for j in range(i + 1, len(clips)):
assert not torch.equal(clips[i].wav, clips[j].wav), (
f"clips {i} and {j} are the same waveform — one request's audio was delivered for another"
)
# Each clip must say what its own prompt asked for: accepting any of the
# four would pass a stable permutation, which is what a mix-up produces.
keywords = set(_DISTINGUISHABLE_PROMPTS.values())
with tempfile.TemporaryDirectory() as tmp_dir:
for i, clip in enumerate(clips[:_TRANSCRIBED_CLIPS]):
expected = _DISTINGUISHABLE_PROMPTS[texts[i]]
# Escalate on the expected word being absent, not on "no keyword
# at all" -- a neighbour's word is the failure under test.
transcript = _transcribe_for(clip.wav, tmp_dir, f"concurrent_{i}", lambda t: expected in t)
present = sorted(k for k in keywords if k in transcript)
assert present == [expected], (
f"clip for {texts[i]!r} transcribed as {transcript!r}: expected {expected!r} and "
f"no other prompt's keyword, found {present} — requests bled into each other"
)
@@ -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
"""E2E tests for GLM-Image AutoRound W4A16 quantized inference.
These tests cover text-to-image and image-to-image generation with
@@ -19,10 +19,10 @@ import pytest
from PIL import Image
from vllm import SamplingParams
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import OmniRunnerHandler
from tests.helpers.monitor import DeviceMemoryMonitor
from tests.helpers.runtime import OfflineOmniClient
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -109,7 +109,7 @@ def _diffusion_sampling_params(
pytestmark = [
pytest.mark.full_model,
pytest.mark.slow,
pytest.mark.diffusion,
]
@@ -121,7 +121,7 @@ pytestmark = [
@pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True)
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_glm_image_autoround_w4a16_generates_image(omni_runner_handler: OmniRunnerHandler):
def test_glm_image_autoround_w4a16_generates_image(offline_client: OfflineOmniClient):
"""Load the W4A16 quantized GLM-Image model and verify it produces a valid image."""
gc.collect()
current_omni_platform.empty_cache()
@@ -152,7 +152,7 @@ def test_glm_image_autoround_w4a16_generates_image(omni_runner_handler: OmniRunn
seed=42,
)
outputs = omni_runner_handler.runner.generate(
outputs = offline_client.runner.generate(
[prompt_dict],
[ar_params, diffusion_params],
)
@@ -186,7 +186,7 @@ def test_glm_image_autoround_w4a16_generates_image(omni_runner_handler: OmniRunn
@pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True)
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_glm_image_autoround_w4a16_image_to_image(omni_runner_handler: OmniRunnerHandler):
def test_glm_image_autoround_w4a16_image_to_image(offline_client: OfflineOmniClient):
"""Load the W4A16 quantized GLM-Image and verify image-to-image generation works."""
ref_image_arr = generate_synthetic_image(WIDTH, HEIGHT)["np_array"]
@@ -217,7 +217,7 @@ def test_glm_image_autoround_w4a16_image_to_image(omni_runner_handler: OmniRunne
seed=42,
)
outputs = omni_runner_handler.runner.generate(
outputs = offline_client.runner.generate(
[prompt_dict],
[ar_params, diffusion_params],
)
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
E2E Offline expansion tests for higgs-audio v2.
Mirrors examples/offline_inference/text_to_speech/higgs_audio_v2/end2end.py:
build Stage-0 prompt_token_ids with the model's own processor + plain-text
chat template, drive the engine via ``Omni.generate``, and inspect the
Stage-1 PCM payload directly. The OmniRunnerHandler.send_audio_speech_request
Stage-1 PCM payload directly. The OfflineOmniClient.send_audio_speech_request
helper applies a Qwen-style ChatML wrap that is not valid for higgs, so this
test bypasses it (same approach as test_voxtral_tts.py).
@@ -1,21 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Smoke test for HunyuanImage-3.0 Image-to-Text (I2T) pipeline."""
from collections.abc import Generator
from pathlib import Path
import pytest
import torch
from PIL import Image
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni import Omni
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import build_prompt
MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct"
REPO_ROOT = Path(__file__).resolve().parents[3]
STAGE_CONFIG_PATH = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "hunyuan_image3_i2t.yaml"
DEPLOY_CONFIG_PATH = get_deploy_config_path("hunyuan_image3_ar.yaml")
# First 20 generated token IDs from the HF greedy reference on this input.
# vllm-omni AR output matches this prefix bitwise; the two implementations
@@ -52,13 +51,13 @@ pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
def omni() -> Generator[Omni, None, None]:
with OmniRunner(
MODEL_NAME,
deploy_config=str(STAGE_CONFIG_PATH),
deploy_config=DEPLOY_CONFIG_PATH,
trust_remote_code=True,
) as runner:
yield runner.omni
@pytest.mark.skipif(torch.accelerator.device_count() < 4, reason="Need at least 4 CUDA GPUs.")
@hardware_test(res={"cuda": "H100"}, num_cards=4)
def test_i2t_generates_text(omni: Omni) -> None:
"""Verify I2T output's first 20 token IDs match the HF greedy baseline."""
# Solid-color image keeps the input self-contained and reproducible.
@@ -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
"""E2E offline inference tests for IndexTTS2 two-stage pipeline.
Stage 0 (GPT AR) → Stage 1 (S2Mel + BigVGAN). Output is 22050 Hz mono WAV.
@@ -8,12 +8,11 @@ Covers: basic TTS.
from __future__ import annotations
from pathlib import Path
import pytest
import torch
from tests.helpers.mark import hardware_test
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni import Omni
@@ -29,8 +28,7 @@ _OMNI_RUNNER_PARAM = (
STAGE_CONFIG,
)
ASSETS_DIR = Path(__file__).resolve().parents[2] / "assets" / "indextts2"
REF_AUDIO = str(ASSETS_DIR / "ref_audio.wav")
REF_AUDIO = str(get_asset_path("indextts2/ref_audio.wav"))
pytestmark = [
pytest.mark.slow,
@@ -55,9 +53,12 @@ def _sample_rate_from_mm(mm: dict) -> int:
sr = mm.get("sr")
if isinstance(sr, list):
sr = sr[-1] if sr else None
if hasattr(sr, "item"):
return int(sr.item())
return int(sr) if sr is not None else SAMPLE_RATE
if sr is None:
return SAMPLE_RATE
item = getattr(sr, "item", None)
if callable(item):
return int(item())
return int(sr)
def _build_request(text: str, ref_audio: str, **extra) -> dict:
@@ -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
"""L4 functionality tests for the Krea 2 text-to-image diffusion pipeline (``Krea2Pipeline``).
@@ -15,7 +15,7 @@ import numpy as np
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunnerHandler
from tests.helpers.runtime import OfflineOmniClient
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
@@ -49,9 +49,9 @@ def _sampling() -> OmniDiffusionSamplingParams:
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_runner", [(MODEL, None)], indirect=True)
def test_krea2_text_to_image_001(omni_runner_handler: OmniRunnerHandler) -> None:
def test_krea2_text_to_image_001(offline_client: OfflineOmniClient) -> None:
"""Basic functional smoke: a single prompt produces a decoded image."""
omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
offline_client.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
@hardware_test(res={"cuda": "H100"})
@@ -60,22 +60,22 @@ def test_krea2_text_to_image_001(omni_runner_handler: OmniRunnerHandler) -> None
[(MODEL, None, {"enable_layerwise_offload": True})],
indirect=True,
)
def test_krea2_layerwise_offload(omni_runner_handler: OmniRunnerHandler) -> None:
def test_krea2_layerwise_offload(offline_client: OfflineOmniClient) -> None:
"""Exercise layerwise CPU offload on the DiT (SupportsComponentDiscovery + _layerwise_offload_blocks_attrs)."""
omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
offline_client.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
@hardware_test(res={"cuda": "H100"})
@pytest.mark.cache
@pytest.mark.parametrize("omni_runner", [(MODEL, None, {"cache_backend": "cache_dit"})], indirect=True)
def test_krea2_cache_dit(omni_runner_handler: OmniRunnerHandler) -> None:
def test_krea2_cache_dit(offline_client: OfflineOmniClient) -> None:
"""Exercise Cache-DiT on Krea 2 via the custom Krea2Pipeline enabler (``enable_cache_for_krea2``).
Validates the docs' Cache-DiT support claim for Krea 2. ``has_separate_cfg`` is checkpoint-aware
(False for the distilled Turbo no-CFG path, True for the Raw CFG path); the default Turbo checkpoint
exercises the no-CFG branch.
"""
omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
offline_client.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@@ -88,13 +88,13 @@ def test_krea2_cache_dit(omni_runner_handler: OmniRunnerHandler) -> None:
[(MODEL, None, {"use_hsdp": True, "hsdp_shard_size": 2})],
indirect=True,
)
def test_krea2_hsdp(omni_runner_handler: OmniRunnerHandler) -> None:
def test_krea2_hsdp(offline_client: OfflineOmniClient) -> None:
"""Exercise Hybrid Sharded Data Parallel (HSDP) weight sharding on the DiT across 2 GPUs.
Validates the docs' HSDP-support claim for Krea 2. Scheduled on the multi-GPU (distributed_cuda)
shard by ``num_cards=2``; skipped automatically on boxes with fewer than 2 CUDA devices.
Validates the docs' HSDP-support claim for Krea 2. Scheduled on the multi-GPU
shard by ``num_cards=2`` (``not cards_1``); skipped automatically on boxes with fewer than 2 CUDA devices.
"""
omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
offline_client.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@@ -107,27 +107,29 @@ def test_krea2_hsdp(omni_runner_handler: OmniRunnerHandler) -> None:
[(MODEL, None, {"tensor_parallel_size": 2, "vae_patch_parallel_size": 2})],
indirect=True,
)
def test_krea2_vae_patch_parallel(omni_runner_handler: OmniRunnerHandler) -> None:
def test_krea2_vae_patch_parallel(offline_client: OfflineOmniClient) -> None:
"""Exercise VAE patch-parallel (decode) across 2 GPUs.
Validates the docs' VAE-patch-parallel (decode) support claim for Krea 2. Scheduled on the
multi-GPU (distributed_cuda) shard by ``num_cards=2``; skipped automatically on boxes with
multi-GPU shard by ``num_cards=2`` (``not cards_1``); skipped automatically on boxes with
fewer than 2 CUDA devices.
"""
omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
offline_client.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()})
def _generate(handler: OmniRunnerHandler, lora_request: LoRARequest | None, lora_scale: float = 1.0) -> np.ndarray:
def _generate(handler: OfflineOmniClient, lora_request: LoRARequest | None, lora_scale: float = 1.0) -> np.ndarray:
sp = _sampling()
sp.lora_request = lora_request
sp.lora_scale = lora_scale
resp = handler.send_diffusion_request({"model": MODEL, "prompt": LORA_PROMPT, "sampling_params": sp})
return np.asarray(resp.images[0].convert("RGB"), dtype=np.int16)
images = resp.images
assert images is not None
return np.asarray(images[0].convert("RGB"), dtype=np.int16)
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_runner", [(MODEL, None)], indirect=True)
def test_krea2_lora(omni_runner_handler: OmniRunnerHandler) -> None:
def test_krea2_lora(offline_client: OfflineOmniClient) -> None:
"""Validate diffusion LoRA on Krea 2: visible effect, scale sensitivity, clean deactivation.
Uses ``LORA`` (a vLLM-Omni-compatible PEFT repackaging of ``krea/Krea-2-LoRA-darkbrush``,
@@ -137,10 +139,10 @@ def test_krea2_lora(omni_runner_handler: OmniRunnerHandler) -> None:
"""
lora_request = LoRARequest(lora_name="darkbrush", lora_int_id=stable_lora_int_id(LORA), lora_path=LORA)
baseline = _generate(omni_runner_handler, None)
img_1x = _generate(omni_runner_handler, lora_request, lora_scale=1.0)
img_2x = _generate(omni_runner_handler, lora_request, lora_scale=2.0)
restored = _generate(omni_runner_handler, None)
baseline = _generate(offline_client, None)
img_1x = _generate(offline_client, lora_request, lora_scale=1.0)
img_2x = _generate(offline_client, lora_request, lora_scale=2.0)
restored = _generate(offline_client, None)
diff_1x = np.abs(baseline - img_1x).mean()
diff_2x = np.abs(baseline - img_2x).mean()
@@ -0,0 +1,84 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""End-to-end smoke test for LingBot-World v2 generation."""
from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunnerHandler
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = os.environ.get(
"VLLM_OMNI_LINGBOT_WORLD_V2_CHECKPOINT_PATH",
"robbyant/lingbot-world-v2-14b-causal-fast-diffusers",
)
_IMAGE_PATH = os.environ.get("VLLM_OMNI_LINGBOT_WORLD_V2_IMAGE_PATH")
_ACTION_DIR_VALUE = os.environ.get("VLLM_OMNI_LINGBOT_WORLD_V2_ACTION_DIR")
_ACTION_DIR = Path(_ACTION_DIR_VALUE).expanduser().resolve() if _ACTION_DIR_VALUE else None
_HAS_INPUT_ASSETS = _IMAGE_PATH is not None and _ACTION_DIR is not None
_OMNI_RUNNER_PARAM = (
MODEL,
None,
{
"flow_shift": 5.0,
"parallel_config": DiffusionParallelConfig(tensor_parallel_size=1),
"model_config": {
"lingbot_action_root": str(_ACTION_DIR.parent) if _ACTION_DIR is not None else "",
},
},
)
pytestmark = [
pytest.mark.slow,
pytest.mark.diffusion,
pytest.mark.skipif(
not _HAS_INPUT_ASSETS,
reason=("VLLM_OMNI_LINGBOT_WORLD_V2_IMAGE_PATH and VLLM_OMNI_LINGBOT_WORLD_V2_ACTION_DIR are required"),
),
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
]
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_lingbot_world_v2_tp1_one_block(omni_runner_handler: OmniRunnerHandler) -> None:
"""Load the real checkpoint and generate one causal block on one GPU."""
assert _IMAGE_PATH is not None
assert _ACTION_DIR is not None
response = omni_runner_handler.send_diffusion_request(
{
"model": omni_runner_handler.runner.model_name,
"prompt": "The camera moves slowly forward through the scene.",
"multi_modal_data": {"image": str(Path(_IMAGE_PATH).expanduser().resolve())},
"sampling_params": OmniDiffusionSamplingParams(
height=480,
width=832,
num_frames=9,
num_inference_steps=4,
max_sequence_length=512,
seed=42,
fps=16,
extra_args={
"action_path": _ACTION_DIR.name,
"flow_shift": 5.0,
},
),
}
)
assert response.success
assert response.images is not None
assert len(response.images) == 1
video = np.asarray(response.images[0])
assert video.shape == (1, 9, 480, 832, 3)
assert np.isfinite(video).all()
assert float(np.ptp(video)) > 1e-6
assert float(np.mean(np.abs(np.diff(video, axis=1)))) > 1e-6
@@ -1,162 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""End-to-end tests for MagiHuman pipeline via vLLM-Omni."""
import io
import av
import numpy as np
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = "SII-GAIR/daVinci-MagiHuman-Base-1080p"
# (model, stage_config_path, extra_omni_kwargs) — see ``omni_runner`` in tests.helpers.fixtures.runtime
_OMNI_RUNNER_PARAM = (
MODEL,
None,
{
"init_timeout": 1200,
"tensor_parallel_size": 2,
"vae_use_tiling": True,
},
)
_OMNI_RUNNER_PARAM_OFFLOAD = (
MODEL,
None,
{
"init_timeout": 1200,
"tensor_parallel_size": 2,
"enable_cpu_offload": True,
"vae_use_tiling": True,
},
)
pytestmark = [
pytest.mark.slow,
pytest.mark.diffusion,
pytest.mark.parametrize(
"omni_runner",
[_OMNI_RUNNER_PARAM, _OMNI_RUNNER_PARAM_OFFLOAD],
indirect=True,
),
]
def _validate_mp4(video_bytes: bytes, min_frames: int = 10) -> None:
"""Validate that the MP4 contains meaningful video and audio tracks."""
container = av.open(io.BytesIO(video_bytes))
v_streams = [s for s in container.streams if s.type == "video"]
assert len(v_streams) >= 1, "No video stream found in MP4"
a_streams = [s for s in container.streams if s.type == "audio"]
assert len(a_streams) >= 1, "No audio stream found in MP4"
v_stream = v_streams[0]
assert v_stream.width >= 1080, f"Unexpected video width: {v_stream.width}"
assert v_stream.height >= 1056, f"Unexpected video height: {v_stream.height}"
frame_count = 0
for frame in container.decode(video=0):
frame_count += 1
if frame_count >= min_frames:
break
assert frame_count >= min_frames, f"Video has only {frame_count} frames (expected >= {min_frames})"
container.close()
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_magi_human_e2e(omni_runner: OmniRunner):
"""End-to-end test for MagiHuman generating video and audio."""
prompt = (
"A young woman with long, wavy golden blonde hair and bright blue eyes, "
"wearing a fitted ivory silk blouse with a delicate lace collar, sits "
"stationary in front of a softly lit, blurred warm-toned interior. Her "
"overall disposition is warm, composed, and gently confident. The camera "
"holds a static medium close-up, framing her from the shoulders up, "
"with shallow depth of field keeping her face in sharp focus. Soft "
"directional key light falls from the upper left, casting a gentle "
"highlight along her cheekbone and nose bridge. She draws a quiet breath, "
"the levator labii superiors relaxing as her lips part. She speaks in "
"clear, warm, unhurried American English: "
"\"The most beautiful things in life aren't things at all — "
"they're moments, feelings, and the people who make you feel truly alive.\" "
"Her jaw descends smoothly on each stressed syllable; the orbicularis oris "
"shapes each vowel with precision. A faint, genuine smile engages the "
"zygomaticus major, lifting her lip corners fractionally. Her brows rest "
"in a soft, neutral arch throughout. She maintains steady, forward-facing "
"eye contact. Head position remains level; no torso displacement occurs.\n\n"
"Dialogue:\n"
"<Young blonde woman, American English>: "
"\"The most beautiful things in life aren't things at all — "
"they're moments, feelings, and the people who make you feel truly alive.\"\n\n"
"Background Sound:\n"
"<Soft, warm indoor ambience with a faint distant piano melody>"
)
sampling_params = OmniDiffusionSamplingParams(
height=256,
width=448,
num_inference_steps=8,
seed=52,
extra_args={
"seconds": 5,
"sr_height": 1080,
"sr_width": 1920,
"sr_num_inference_steps": 5,
},
)
omni = omni_runner.omni
outputs = list(
omni.generate(
prompts=[prompt],
sampling_params_list=[sampling_params],
)
)
assert len(outputs) > 0, "No outputs returned"
first = outputs[0]
assert hasattr(first, "images") and first.images, "No video frames in output"
video_frames = first.images[0]
assert isinstance(video_frames, np.ndarray), f"Expected numpy array, got {type(video_frames)}"
assert video_frames.ndim == 4, f"Expected 4D array (T,H,W,3), got shape {video_frames.shape}"
mm = first.multimodal_output
assert mm, "multimodal_output is empty or missing"
audio_waveform = mm.get("audio")
assert audio_waveform is not None, "No audio waveform in multimodal_output"
audio_sample_rate = mm.get("audio_sample_rate")
assert audio_sample_rate is not None, (
"audio_sample_rate not found in multimodal_output; model post-process must propagate it"
)
assert isinstance(audio_sample_rate, (int, float)), (
f"audio_sample_rate should be numeric, got {type(audio_sample_rate)}"
)
assert int(audio_sample_rate) > 0, f"audio_sample_rate must be positive, got {audio_sample_rate}"
fps = mm.get("fps")
assert fps is not None, "fps not found in multimodal_output; model post-process must propagate it"
assert isinstance(fps, (int, float)), f"fps should be numeric, got {type(fps)}"
assert int(fps) > 0, f"fps must be positive, got {fps}"
video_bytes = mux_video_audio_bytes(
video_frames,
audio_waveform,
fps=float(fps),
audio_sample_rate=int(audio_sample_rate),
)
assert isinstance(video_bytes, bytes), f"Expected MP4 bytes, got {type(video_bytes)}"
assert len(video_bytes) > 1000, f"MP4 too small ({len(video_bytes)} bytes)"
_validate_mp4(video_bytes)
@@ -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 os
@@ -53,7 +53,7 @@ test_params = [(m, c) for m in models for c in [get_eager_config()]]
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_text_to_text(omni_runner, omni_runner_handler) -> None:
def test_text_to_text(omni_runner, offline_client) -> None:
"""
Test text-only input processing and text output generation.
Input Modal: text
@@ -62,14 +62,14 @@ def test_text_to_text(omni_runner, omni_runner_handler) -> None:
prompt = build_prompt("请详细介绍鹦鹉的生活习性。")
request_config = {"prompts": prompt, "modalities": ["text"]}
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@pytest.mark.slow
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_image_to_text(omni_runner, omni_runner_handler) -> None:
def test_image_to_text(omni_runner, offline_client) -> None:
"""
Test image understanding with text output.
Input Modal: image + text
@@ -79,14 +79,14 @@ def test_image_to_text(omni_runner, omni_runner_handler) -> None:
prompt = build_prompt(f"{IMAGE_TOKEN}Describe this image briefly.")
request_config = {"prompts": prompt, "images": image, "modalities": ["text"]}
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@pytest.mark.slow
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
def test_audio_to_text(omni_runner, offline_client) -> None:
"""
Test audio understanding with text output.
Input Modal: audio + text
@@ -98,14 +98,14 @@ def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
prompt = build_prompt(f"{AUDIO_TOKEN}Please recognize the language of this speech and transcribe it. Format: oral.")
request_config = {"prompts": prompt, "audios": audio, "modalities": ["text"]}
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@pytest.mark.slow
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_video_to_text(omni_runner, omni_runner_handler) -> None:
def test_video_to_text(omni_runner, offline_client) -> None:
"""
Test video understanding with text output.
Input Modal: video + text
@@ -115,14 +115,14 @@ def test_video_to_text(omni_runner, omni_runner_handler) -> None:
prompt = build_prompt(f"{VIDEO_TOKEN}Describe what is happening in this video.")
request_config = {"prompts": prompt, "videos": video, "modalities": ["text"]}
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@pytest.mark.slow
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True)
def test_mixed_to_text(omni_runner, omni_runner_handler) -> None:
def test_mixed_to_text(omni_runner, offline_client) -> None:
"""
Test mixed modality input (image + audio) with text output.
Input Modal: image + audio + text
@@ -135,14 +135,14 @@ def test_mixed_to_text(omni_runner, omni_runner_handler) -> None:
prompt = build_prompt(f"{IMAGE_TOKEN}{AUDIO_TOKEN}Describe the image and transcribe the audio.")
request_config = {"prompts": prompt, "images": image, "audios": audio, "modalities": ["text"]}
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@pytest.mark.slow
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
def test_text_to_audio(omni_runner, offline_client) -> None:
"""
Test text input with audio output via the thinker+talker pipeline.
Input Modal: text
@@ -151,14 +151,14 @@ def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
prompt = build_prompt("请简单介绍一下北京。")
request_config = {"prompts": prompt, "modalities": ["audio"]}
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@pytest.mark.slow
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_image_to_audio(omni_runner, omni_runner_handler) -> None:
def test_image_to_audio(omni_runner, offline_client) -> None:
"""
Test image + text input with audio output via the thinker+talker pipeline.
Input Modal: image + text
@@ -168,4 +168,4 @@ def test_image_to_audio(omni_runner, omni_runner_handler) -> None:
prompt = build_prompt(f"{IMAGE_TOKEN}Describe this image briefly.")
request_config = {"prompts": prompt, "images": image, "modalities": ["audio"]}
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@@ -35,7 +35,6 @@ MIN_AUDIO_SAMPLES = 1000
pytestmark = [
pytest.mark.slow,
pytest.mark.tts,
pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4704"),
]
@@ -96,7 +96,7 @@ def _get_test_config() -> str:
pytestmark = [
pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4700"),
pytest.mark.full_model,
pytest.mark.slow,
pytest.mark.tts,
pytest.mark.parametrize(
"omni_runner",
@@ -271,7 +271,6 @@ def _collect_audio(omni_runner: OmniRunner, request: dict) -> tuple[torch.Tensor
# ---------------------------------------------------------------------------
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_moss_tts_realtime_english(omni_runner: OmniRunner, ref_audio_path: str) -> None:
"""MossTTSRealtime: English voice_clone produces non-empty 24 kHz audio."""
@@ -283,7 +282,6 @@ def test_moss_tts_realtime_english(omni_runner: OmniRunner, ref_audio_path: str)
assert not torch.all(audio == 0), "Audio is silence"
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_moss_tts_realtime_chinese(omni_runner: OmniRunner, ref_audio_path: str) -> None:
"""MossTTSRealtime: Chinese input produces non-empty audio."""
@@ -28,10 +28,14 @@ _OMNI_RUNNER_PARAM = (
{"stage_init_timeout": 600, "trust_remote_code": True},
)
_SKIP_ISSUE_6417 = pytest.mark.skip(
reason="https://github.com/vllm-project/vllm-omni/issues/6417",
)
pytestmark = [
pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4643"),
pytest.mark.slow,
pytest.mark.tts,
_SKIP_ISSUE_6417,
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
]
@@ -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
"""E2E tests for Qwen2.5-Omni AutoRound W4A16 quantized inference.
These tests cover text, audio, image, video, and mixed-modality inputs
@@ -36,8 +36,8 @@ BASELINE_MODEL = os.environ.get("QWEN2_5_OMNI_BASELINE_MODEL", BASELINE_MODEL)
_CI_DEPLOY = get_deploy_config_path("ci/qwen2_5_omni.yaml")
_SKIP_ISSUE_4663 = pytest.mark.skip(
reason="https://github.com/vllm-project/vllm-omni/issues/4663",
_SKIP_ISSUE_5652 = pytest.mark.skip(
reason="https://github.com/vllm-project/vllm-omni/issues/5652",
)
@@ -65,16 +65,16 @@ quant_params = [(QUANTIZED_MODEL, stage_config)]
# ------------------------------------------------------------------
@_SKIP_ISSUE_4663
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "L4"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_text_to_text(omni_runner, omni_runner_handler):
def test_text_to_text(omni_runner, offline_client):
"""Text input → text output with W4A16 quantized Qwen2.5-Omni."""
request_config = {
"prompts": "What is the capital of China?",
"modalities": ["text"],
}
response = omni_runner_handler.send_omni_request(request_config)
response = offline_client.send_omni_request(request_config)
assert response.success, "Request failed"
assert response.text_content and len(response.text_content.strip()) > 0
@@ -84,10 +84,10 @@ def test_text_to_text(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_4663
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "L4"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_audio_to_text(omni_runner, omni_runner_handler):
def test_audio_to_text(omni_runner, offline_client):
"""Audio input → text output with W4A16 quantized Qwen2.5-Omni."""
audio = generate_synthetic_audio(1, 1, 16000)["np_array"]
if len(audio.shape) == 2:
@@ -98,7 +98,7 @@ def test_audio_to_text(omni_runner, omni_runner_handler):
"audios": (audio, 16000),
"modalities": ["text"],
}
response = omni_runner_handler.send_omni_request(request_config)
response = offline_client.send_omni_request(request_config)
assert response.success, "Request failed"
assert response.text_content and len(response.text_content.strip()) > 0
@@ -108,10 +108,10 @@ def test_audio_to_text(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_4663
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "L4"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_image_to_text(omni_runner, omni_runner_handler):
def test_image_to_text(omni_runner, offline_client):
"""Image input → text output with W4A16 quantized Qwen2.5-Omni."""
image = generate_synthetic_image(16, 16)["np_array"]
@@ -120,7 +120,7 @@ def test_image_to_text(omni_runner, omni_runner_handler):
"images": image,
"modalities": ["text"],
}
response = omni_runner_handler.send_omni_request(request_config)
response = offline_client.send_omni_request(request_config)
assert response.success, "Request failed"
assert response.text_content and len(response.text_content.strip()) > 0
@@ -130,10 +130,10 @@ def test_image_to_text(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_4663
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "L4"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_video_to_text(omni_runner, omni_runner_handler):
def test_video_to_text(omni_runner, offline_client):
"""Video input → text output with W4A16 quantized Qwen2.5-Omni."""
video = generate_synthetic_video(16, 16, 30)["np_array"]
@@ -142,7 +142,7 @@ def test_video_to_text(omni_runner, omni_runner_handler):
"videos": video,
"modalities": ["text"],
}
response = omni_runner_handler.send_omni_request(request_config)
response = offline_client.send_omni_request(request_config)
assert response.success, "Request failed"
assert response.text_content and len(response.text_content.strip()) > 0
@@ -152,10 +152,10 @@ def test_video_to_text(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_4663
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "L4"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_mix_to_audio(omni_runner, omni_runner_handler):
def test_mix_to_audio(omni_runner, offline_client):
"""Mixed-modality input → audio output with W4A16 quantized Qwen2.5-Omni."""
video = generate_synthetic_video(16, 16, 30)["np_array"]
image = generate_synthetic_image(16, 16)["np_array"]
@@ -170,5 +170,5 @@ def test_mix_to_audio(omni_runner, omni_runner_handler):
"audios": (audio, 16000),
"modalities": ["audio"],
}
response = omni_runner_handler.send_omni_request(request_config)
response = offline_client.send_omni_request(request_config)
assert response.success, "Request failed"
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
E2E tests for Qwen2.5-Omni model with mixed modality inputs, audio and text output.
"""
@@ -55,7 +58,7 @@ def get_question(prompt_type="mix"):
@pytest.mark.omni
@hardware_test(res={"cuda": "L4", "rocm": "MI325", "xpu": "B60"}, num_cards={"cuda": 4, "rocm": 2, "xpu": 3})
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_mix_to_audio(omni_runner, omni_runner_handler) -> None:
def test_mix_to_audio(omni_runner, offline_client) -> None:
"""
Test multi-modal input processing and text/audio output generation via OpenAI API.
Deploy Setting: default yaml
@@ -79,14 +82,14 @@ def test_mix_to_audio(omni_runner, omni_runner_handler) -> None:
}
# Test single completion
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@pytest.mark.slow
@pytest.mark.omni
@hardware_test(res={"cuda": "L4", "rocm": "MI325", "xpu": "B60"}, num_cards={"cuda": 4, "rocm": 2, "xpu": 3})
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_text(omni_runner, omni_runner_handler) -> None:
def test_text_to_text(omni_runner, offline_client) -> None:
"""
Test text input processing and text output generation via OpenAI API.
Deploy Setting: default yaml
@@ -99,4 +102,4 @@ def test_text_to_text(omni_runner, omni_runner_handler) -> None:
request_config = {"prompts": get_question("text_only"), "modalities": ["text"]}
# Test single completion
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
+10 -4
View File
@@ -1,5 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
E2E offline tests for Omni model with video input and audio output.
Abort / sleep-admission lives in ``test_qwen3_omni_colocate_async.py`` so it
does not overlap this module's OmniRunners.
"""
import os
@@ -57,23 +63,23 @@ def get_question(prompt_type="video"):
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_video_to_audio(omni_runner, omni_runner_handler) -> None:
def test_video_to_audio(omni_runner, offline_client) -> None:
"""Test processing video, generating audio output."""
video = generate_synthetic_video(224, 224, 300)["np_array"]
request_config = {"prompts": get_question(), "videos": video, "modalities": ["audio"]}
# Test single completion
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", thinker_test_params, indirect=True)
def test_thinker_only_model_request(omni_runner, omni_runner_handler) -> None:
def test_thinker_only_model_request(omni_runner, offline_client) -> None:
"""Test that we can load and run a request through a model that only has the thinker stage."""
request_config = {"prompts": "what color is the sky?", "modalities": ["text"]}
# Test single completion
omni_runner_handler.send_omni_request(request_config)
offline_client.send_omni_request(request_config)
@@ -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
"""E2E tests for Qwen3-Omni AutoRound W4A16 quantized inference.
These tests cover text, audio, image, video, and mixed-modality inputs
@@ -27,8 +27,8 @@ pytestmark = [
pytest.mark.omni,
]
_SKIP_ISSUE_3195 = pytest.mark.skip(
reason="https://github.com/vllm-project/vllm-omni/issues/3195",
_SKIP_ISSUE_5652 = pytest.mark.skip(
reason="https://github.com/vllm-project/vllm-omni/issues/5652",
)
QUANTIZED_MODEL = "Intel/Qwen3-Omni-30B-A3B-Instruct-int4-AutoRound"
@@ -77,16 +77,16 @@ quant_params = [(QUANTIZED_MODEL, stage_config)]
# ------------------------------------------------------------------
@_SKIP_ISSUE_3195
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_text_to_text(omni_runner, omni_runner_handler):
def test_text_to_text(omni_runner, offline_client):
"""Text input → text output with W4A16 quantized Qwen3-Omni."""
request_config = {
"prompts": "What is the capital of France?",
"modalities": ["text"],
}
response = omni_runner_handler.send_request(request_config)
response = offline_client.send_request(request_config)
assert response.success, "Request failed"
assert response.text_content and len(response.text_content.strip()) > 0
@@ -96,10 +96,10 @@ def test_text_to_text(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_3195
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_audio_to_text(omni_runner, omni_runner_handler):
def test_audio_to_text(omni_runner, offline_client):
"""Audio input → text output with W4A16 quantized Qwen3-Omni."""
audio = generate_synthetic_audio(1, 1, 16000)["np_array"]
if len(audio.shape) == 2:
@@ -110,7 +110,7 @@ def test_audio_to_text(omni_runner, omni_runner_handler):
"audios": (audio, 16000),
"modalities": ["text"],
}
response = omni_runner_handler.send_request(request_config)
response = offline_client.send_request(request_config)
assert response.success, "Request failed"
assert response.text_content and len(response.text_content.strip()) > 0
@@ -120,10 +120,10 @@ def test_audio_to_text(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_3195
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_image_to_text(omni_runner, omni_runner_handler):
def test_image_to_text(omni_runner, offline_client):
"""Image input → text output with W4A16 quantized Qwen3-Omni."""
image = generate_synthetic_image(16, 16)["np_array"]
@@ -132,7 +132,7 @@ def test_image_to_text(omni_runner, omni_runner_handler):
"images": image,
"modalities": ["text"],
}
response = omni_runner_handler.send_request(request_config)
response = offline_client.send_request(request_config)
assert response.success, "Request failed"
assert response.text_content and len(response.text_content.strip()) > 0
@@ -142,10 +142,10 @@ def test_image_to_text(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_3195
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_video_to_text(omni_runner, omni_runner_handler):
def test_video_to_text(omni_runner, offline_client):
"""Video input → text output with W4A16 quantized Qwen3-Omni."""
video = generate_synthetic_video(224, 224, 300)["np_array"]
@@ -154,7 +154,7 @@ def test_video_to_text(omni_runner, omni_runner_handler):
"videos": video,
"modalities": ["text"],
}
response = omni_runner_handler.send_request(request_config)
response = offline_client.send_request(request_config)
assert response.success, "Request failed"
assert response.text_content and len(response.text_content.strip()) > 0
@@ -164,10 +164,10 @@ def test_video_to_text(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_3195
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_video_to_audio(omni_runner, omni_runner_handler):
def test_video_to_audio(omni_runner, offline_client):
"""Video input → audio output with W4A16 quantized Qwen3-Omni."""
video = generate_synthetic_video(224, 224, 300)["np_array"]
@@ -176,7 +176,7 @@ def test_video_to_audio(omni_runner, omni_runner_handler):
"videos": video,
"modalities": ["audio"],
}
response = omni_runner_handler.send_request(request_config)
response = offline_client.send_request(request_config)
assert response.success, "Request failed"
@@ -185,10 +185,10 @@ def test_video_to_audio(omni_runner, omni_runner_handler):
# ------------------------------------------------------------------
@_SKIP_ISSUE_3195
@_SKIP_ISSUE_5652
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_mix_to_audio(omni_runner, omni_runner_handler):
def test_mix_to_audio(omni_runner, offline_client):
"""Mixed-modality input → audio output with W4A16 quantized Qwen3-Omni."""
video = generate_synthetic_video(224, 224, 300)["np_array"]
image = generate_synthetic_image(16, 16)["np_array"]
@@ -203,5 +203,5 @@ def test_mix_to_audio(omni_runner, omni_runner_handler):
"audios": (audio, 16000),
"modalities": ["audio"],
}
response = omni_runner_handler.send_request(request_config)
response = offline_client.send_request(request_config)
assert response.success, "Request failed"
@@ -0,0 +1,182 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Isolated AsyncOmni abort / sleep-admission regression for Qwen3-Omni.
This lives in its own module so the L3 merge job can run it *after*
``test_qwen3_omni.py`` (offline) and the online serving file. Those modules keep
module-scoped OmniRunner / OmniServer instances until teardown; overlapping a
CuMem sleep-mode engine with them kills StageEngineCoreProc at handshake
(``Failed core proc(s): {}``).
"""
import asyncio
import os
from contextlib import ExitStack
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import pytest
from vllm import SamplingParams
from vllm.inputs import TokensPrompt
from tests.helpers.clean import wait_for_gpu_memory_to_clear
from tests.helpers.mark import hardware_test
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.platforms import current_omni_platform
MODEL = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
def _output_token_ids(output) -> list[int]:
if not getattr(output, "outputs", None):
return []
completion = output.outputs[0]
token_ids = list(completion.token_ids or [])
if token_ids:
return token_ids
return list(getattr(completion, "cumulative_token_ids", None) or [])
def _finish_reason(output) -> str | None:
if not getattr(output, "outputs", None):
return None
return getattr(output.outputs[0], "finish_reason", None)
def _colocate_async_deploy() -> str:
"""Thinker-only Instruct deploy with sleep mode on a single GPU.
Abort / sleep admission is a thinker control-plane contract. The 3-stage
CI yaml plus CuMem on every stage fails engine-core startup in the L3
merge job: Stage 1 resolves ``devices: "1"`` while only GPU 0 is visible.
"""
return modify_stage_config(
get_deploy_config_path("qwen3_omni_moe_thinking.yaml"),
updates={
"stages": {
0: {
"enable_sleep_mode": True,
"enforce_eager": True,
"trust_remote_code": True,
"tensor_parallel_size": 1,
"devices": "0",
"gpu_memory_utilization": 0.9,
"max_num_seqs": 1,
"max_model_len": 8192,
"max_num_batched_tokens": 8192,
"enable_prefix_caching": False,
"skip_mm_profiling": True,
},
},
},
)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1)
@pytest.mark.usefixtures("clean_gpu_memory_between_tests")
@pytest.mark.asyncio
async def test_colocate_async_abort_tokens_and_sleep_admission() -> None:
"""**required** regression. Control-plane APIs (``abort`` / ``sleep`` /
``wake_up`` / ``resume_generation``) are not in ``send_omni_request``.
On ``origin/main`` this test fails:
- abort: ``generate()`` never yields ``finish_reason="abort"`` with the
tokens produced so far, so resume has an empty prefix (or the stream
hangs after frontend state is dropped).
- sleep: ``generate()`` is admitted into EngineCore while weights/KV are
offloaded, so the task errors (asleep / corrupted ADD frame) instead of
waiting for ``resume_generation()``.
On this PR both legs pass: abort returns a resumeable prefix, and sleep
holds ``generate()`` until wake + resume.
"""
prompt = "What color is the sky? Write a long, detailed explanation."
device_count = current_omni_platform.device_count()
if device_count > 0:
# Fail closed if a prior module-scoped runner is still on the GPU.
# ``clean_gpu_memory_between_tests`` only logs a note on timeout.
wait_for_gpu_memory_to_clear(
devices=list(range(device_count)),
threshold_ratio=0.15,
timeout_s=120,
)
with ExitStack() as after:
engine = AsyncOmni(
model=MODEL,
deploy_config=_colocate_async_deploy(),
enable_sleep_mode=True,
)
after.callback(engine.shutdown)
request_id = "qwen3-abort-partial"
outputs: list = []
async def _generate(max_tokens: int, req_id: str, gen_prompt) -> None:
async for output in engine.generate(
prompt=gen_prompt,
request_id=req_id,
sampling_params=SamplingParams(temperature=0.0, max_tokens=max_tokens),
output_modalities=["text"],
):
outputs.append(output)
abort_task = asyncio.create_task(_generate(256, request_id, prompt))
prefix: list[int] = []
prompt_token_ids: list[int] = []
for _ in range(600):
if abort_task.done():
break
if outputs:
latest = outputs[-1]
prefix = _output_token_ids(latest)
prompt_token_ids = list(getattr(latest, "prompt_token_ids", None) or [])
if prefix:
break
await asyncio.sleep(0.1)
assert prefix, "generate produced no tokens before abort"
assert not abort_task.done(), "generate finished before abort; raise max_tokens"
await engine.abort(request_id)
await asyncio.wait_for(abort_task, timeout=90)
final = outputs[-1]
assert final.finished
assert _finish_reason(final) == "abort"
abort_prefix = _output_token_ids(final)
assert abort_prefix, "abort dropped the generated prefix (main-branch behavior)"
resume_prompt: str | TokensPrompt = (
TokensPrompt(prompt_token_ids=prompt_token_ids + abort_prefix) if prompt_token_ids else prompt
)
outputs.clear()
async for output in engine.generate(
prompt=resume_prompt,
request_id="qwen3-abort-resume",
sampling_params=SamplingParams(temperature=0.0, max_tokens=8),
output_modalities=["text"],
):
outputs.append(output)
assert outputs
assert any(_output_token_ids(out) for out in outputs)
# Trainer order is pause → abort → sleep. Pause here after the
# resume generate so EngineCore is idle before CuMem offload.
await engine.pause_generation(mode="abort", clear_cache=True)
await engine.sleep(level=1)
outputs.clear()
sleep_task = asyncio.create_task(_generate(8, "qwen3-sleep-admission", prompt))
await asyncio.sleep(1.0)
assert not sleep_task.done(), "generate() ran while EngineCore was sleeping (main-branch admission race)"
await engine.wake_up()
await asyncio.sleep(0.5)
assert not sleep_task.done(), "generate() resumed before resume_generation()"
await engine.resume_generation()
await asyncio.wait_for(sleep_task, timeout=180)
assert outputs
assert _finish_reason(outputs[-1]) != "abort"
await asyncio.sleep(5)
@@ -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
"""E2E tests for Qwen3-Omni ModelOpt NVFP4 W4A4 — Marlin fallback path.
Loads the published full-thinker NVFP4 W4A4 checkpoint on H100 (sm_90),
@@ -74,7 +74,7 @@ quant_params = [(QUANTIZED_MODEL, _stage_config())]
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", quant_params, indirect=True)
def test_marlin_fallback_no_nan_collapse(omni_runner, omni_runner_handler):
def test_marlin_fallback_no_nan_collapse(omni_runner, offline_client):
"""H100 Marlin FP4 fallback: text in -> coherent text out.
On H100 the upstream NVFP4 PWAL routes through
@@ -89,7 +89,7 @@ def test_marlin_fallback_no_nan_collapse(omni_runner, omni_runner_handler):
degenerate-output signature that indicates NaN propagation through
the FP4 GEMM.
"""
response = omni_runner_handler.send_omni_request({"prompts": "Why is the sky blue?", "modalities": ["text"]})
response = offline_client.send_omni_request({"prompts": "Why is the sky blue?", "modalities": ["text"]})
assert response.success, "request failed"
text = (response.text_content or "").strip()
assert text, "empty response — likely server returned only EOS"
@@ -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
"""
E2E offline tests for Qwen3-TTS Base model with text input and audio output.
@@ -15,12 +15,12 @@ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import load_test_audio_data_url
from tests.helpers.media import get_asset_path
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
# See tests/e2e/online_serving/test_qwen3_tts_base.py for the vendored-asset rationale.
REF_AUDIO_URL = load_test_audio_data_url("qwen3_tts/clone_2.wav")
REF_AUDIO_URL = get_asset_path("qwen3_tts/clone_2.wav", as_data_url=True)
REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
@@ -70,7 +70,7 @@ def get_prompt():
@pytest.mark.tts
@hardware_test(res={"cuda": "L4", "xpu": "B60"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
def test_text_to_audio_001(omni_runner, offline_client) -> None:
"""
Test text input processing and audio output via offline Omni runner.
Deploy Setting: qwen3_tts_no_async_chunk.yaml + enforce_eager=true
@@ -87,4 +87,4 @@ def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
omni_runner_handler.send_audio_speech_request(request_config)
offline_client.send_audio_speech_request(request_config)
@@ -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
"""
E2E offline tests for Qwen3-TTS CustomVoice model with text input and audio output.
@@ -64,7 +64,7 @@ def get_prompt():
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
def test_text_to_audio_001(omni_runner, offline_client) -> None:
"""
Test text input processing and audio output via offline Omni runner.
Deploy Setting: qwen3_tts_no_async_chunk.yaml + enforce_eager=true
@@ -74,4 +74,4 @@ def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
Datasets: few requests
"""
request_config = {"input": get_prompt(), "voice": "vivian"}
omni_runner_handler.send_audio_speech_request(request_config)
offline_client.send_audio_speech_request(request_config)
+159
View File
@@ -0,0 +1,159 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import os
from typing import Any
import numpy as np
import pytest
pytestmark = [
pytest.mark.advanced_model,
pytest.mark.diffusion,
pytest.mark.gpu,
]
def _make_sana_wm_e2e_image() -> Any:
from PIL import Image
from vllm_omni.diffusion.models.sana_wm import SANA_WM_OUTPUT_HEIGHT, SANA_WM_OUTPUT_WIDTH
return Image.new("RGB", (SANA_WM_OUTPUT_WIDTH, SANA_WM_OUTPUT_HEIGHT), (96, 128, 160))
def _sana_wm_e2e_tensor_parallel_size() -> int:
return int(
os.environ.get(
"SANA_WM_E2E_TENSOR_PARALLEL_SIZE",
os.environ.get("SANA_WM_E2E_TP", "1"),
)
)
def _coerce_video_array(video: Any) -> np.ndarray:
import torch
if isinstance(video, list):
assert video, "SANA-WM e2e produced an empty video list."
video = video[0]
if isinstance(video, torch.Tensor):
video = video.detach().cpu().float().numpy()
video_array = np.asarray(video)
if video_array.ndim == 5:
assert video_array.shape[0] == 1
video_array = video_array[0]
assert video_array.ndim == 4
return video_array
def _assert_sana_wm_e2e_shape(
video: np.ndarray,
*,
output_type: str,
num_frames: int,
) -> None:
if output_type == "latent":
# Latent output is channel-first: (C, T, H, W) after removing the
# batch dimension. Stage-1 emits LTX-2 latents with 128 channels.
assert video.shape[0] == 128
assert 0 < video.shape[1] <= num_frames
return
assert 0 < video.shape[0] <= num_frames
def _run_sana_wm_e2e(*, output_type: str) -> np.ndarray:
import torch
from vllm_omni.diffusion.models.sana_wm import (
SANA_WM_MODEL_ID,
SANA_WM_OUTPUT_HEIGHT,
SANA_WM_OUTPUT_WIDTH,
)
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
if not torch.cuda.is_available():
pytest.skip("Sana-WM e2e requires CUDA.")
model = os.environ.get("SANA_WM_E2E_MODEL", SANA_WM_MODEL_ID)
model_class_name = os.environ.get("SANA_WM_E2E_MODEL_CLASS", "SanaWmPipeline")
num_frames = int(os.environ.get("SANA_WM_E2E_NUM_FRAMES", "9"))
stage1_steps = int(
os.environ.get(
"SANA_WM_E2E_STAGE1_STEPS",
os.environ.get("SANA_WM_E2E_NUM_INFERENCE_STEPS", "1"),
)
)
action = os.environ.get("SANA_WM_E2E_ACTION", f"w-{max(num_frames - 1, 1)}")
tensor_parallel_size = _sana_wm_e2e_tensor_parallel_size()
image = _make_sana_wm_e2e_image()
omni = Omni(
model=model,
model_class_name=model_class_name,
enforce_eager=True,
tensor_parallel_size=tensor_parallel_size,
)
extra_args: dict[str, Any] = {}
# The native Stage-1 path is latent-token capped; raise it for full-size
# e2e runs (704x1280 exceeds the default 4096-token cap).
native_max_tokens = os.environ.get("SANA_WM_E2E_NATIVE_MAX_TOKENS", "").strip()
if native_max_tokens:
extra_args["sana_wm_native_max_tokens"] = int(native_max_tokens)
# Return the requested output type so the shape assertion matches the
# pipeline output instead of the raw Stage-1 latent default.
output = omni.generate(
{
"prompt": "A slow forward camera move through a quiet city street.",
"multi_modal_data": {"image": image},
"sana_wm": {
"action": action,
"num_frames": num_frames,
"translation_speed": 0.055,
"rotation_speed_deg": 1.2,
# Avoid the optional Pi3X dependency by passing deterministic
# camera intrinsics directly.
"intrinsics": {
"fx": SANA_WM_OUTPUT_WIDTH / 2,
"fy": SANA_WM_OUTPUT_WIDTH / 2,
"cx": SANA_WM_OUTPUT_WIDTH / 2,
"cy": SANA_WM_OUTPUT_HEIGHT / 2,
},
},
},
OmniDiffusionSamplingParams(
height=SANA_WM_OUTPUT_HEIGHT,
width=SANA_WM_OUTPUT_WIDTH,
num_frames=num_frames,
seed=0,
fps=16,
num_inference_steps=stage1_steps,
guidance_scale=1.0,
guidance_scale_provided=True,
output_type=output_type,
extra_args=extra_args,
),
)
request_output = output[0] if isinstance(output, list) else output
assert isinstance(request_output, OmniRequestOutput)
assert request_output.error is None
assert request_output.images
frames = request_output.images[0]
video = _coerce_video_array(frames)
_assert_sana_wm_e2e_shape(video, output_type=output_type, num_frames=num_frames)
return video
def test_sana_wm_native_generates_video() -> None:
output_type = os.environ.get("SANA_WM_E2E_OUTPUT_TYPE", "np")
video = _run_sana_wm_e2e(output_type=output_type)
if output_type == "latent":
assert video.ndim == 4
else:
assert video.shape[-1] in (3, 4)
@@ -1,241 +0,0 @@
"""E2E offline inference tests for SoulX-Singer (single-stage, preprocess inline)."""
import functools
import importlib
import json
import os
import shutil
from pathlib import Path
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import numpy as np
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
PROMPT_AUDIO = get_asset_path("soulxsinger/zh_prompt.mp3")
TARGET_AUDIO = get_asset_path("soulxsinger/music.mp3")
PROMPT_METADATA = get_asset_path("soulxsinger/zh_prompt.json")
TARGET_METADATA = get_asset_path("soulxsinger/music.json")
SAMPLE_RATE = 24_000
# phone_set.json (phoneme vocab) is not shipped on HuggingFace; SVS loads it
# from the model dir. Stage it from this pinned upstream commit so the SVS DiT
# can run in CI; the SVS tests skip gracefully if it cannot be fetched.
_PHONE_SET_URL = (
"https://raw.githubusercontent.com/Soul-AILab/SoulX-Singer/"
"81aeb3ae772c70093c3de74dc23c92d983801ae4/soulxsinger/utils/phoneme/phone_set.json"
)
if not PROMPT_AUDIO.is_file() or not TARGET_AUDIO.is_file():
pytest.skip(
f"Missing SoulX-Singer audio assets: {PROMPT_AUDIO.name}, {TARGET_AUDIO.name}",
allow_module_level=True,
)
pytestmark = [pytest.mark.slow, pytest.mark.diffusion, pytest.mark.tts]
_CASES = (
pytest.param(
"SoulXSingerPipeline",
"soulxsinger_svs.yaml",
{
"language": "Mandarin",
"vocal_sep": False,
"control": "score",
"auto_shift": False,
"pitch_shift": 0,
},
("g2pM", "g2p_en"),
id="svs",
),
pytest.param(
"SoulXSingerSVCPipeline",
"soulxsinger_svc.yaml",
{"vocal_sep": False, "auto_shift": False, "pitch_shift": 0},
(),
id="svc",
),
)
@functools.lru_cache(maxsize=1)
def _resolve_weights() -> tuple[Path, Path, Path]:
from huggingface_hub import snapshot_download
base = Path(snapshot_download("Soul-AILab/SoulX-Singer", allow_patterns=["*"]))
# phone_set.json is not on HF; best-effort stage it from pinned upstream so
# SVS can load. SVS tests skip (not fail) if both this and a manual copy are absent.
phone_set = base / "phoneme" / "phone_set.json"
if not phone_set.is_file() and not (base / "phone_set.json").is_file():
try:
from urllib.request import urlretrieve
phone_set.parent.mkdir(exist_ok=True)
urlretrieve(_PHONE_SET_URL, phone_set)
except Exception:
pass
# make a temporary svc directory
svc_dir = base.parent / "SoulX-Singer-SVC"
svc_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(base / "config.yaml", svc_dir / "config.yaml")
shutil.move(base / "model-svc.pt", svc_dir / "model-svc.pt")
if raw := os.environ.get("SOULX_PREPROCESS_WEIGHTS_DIR"):
pre = Path(raw).expanduser().resolve()
if (pre / "rmvpe" / "rmvpe.pt").is_file():
return base, svc_dir, pre
from huggingface_hub import snapshot_download
pre = Path(snapshot_download("Soul-AILab/SoulX-Singer-Preprocess", allow_patterns=["*"]))
return base, svc_dir, pre
@pytest.fixture(scope="session")
def soulx_weights() -> tuple[Path, Path, Path]:
try:
return _resolve_weights()
except Exception as exc:
pytest.skip(f"Set SOULXSINGER_MODEL_DIR / SOULX_PREPROCESS_WEIGHTS_DIR. ({exc})")
def _flatten_audio(audio_val) -> np.ndarray:
import torch
if isinstance(audio_val, list):
chunks = [c.detach().cpu().float().numpy().reshape(-1) for c in audio_val if c is not None]
return np.concatenate(chunks) if chunks else np.array([], dtype=np.float32)
if isinstance(audio_val, torch.Tensor):
return audio_val.detach().cpu().float().numpy().reshape(-1)
return np.asarray(audio_val, dtype=np.float32).reshape(-1)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("architecture,deploy_yaml,extra_args,py_deps", _CASES)
def test_soulxsinger_multistage_from_audio(
soulx_weights: tuple[Path, Path, Path],
architecture: str,
deploy_yaml: str,
extra_args: dict,
py_deps: tuple[str, ...],
) -> None:
for mod in py_deps:
try:
importlib.import_module(mod)
except ImportError as exc:
pytest.fail(f"SoulX SVS requires {mod}: {exc}")
base_dir, svc_dir, preprocess_dir = soulx_weights
# SVS mode requires phone_set.json in the model directory
if architecture == "SoulXSingerPipeline":
json.dump(
{"model_type": "soulxsinger", "architectures": ["SoulXSingerPipeline"], "max_num_seqs": 1},
open(base_dir / "config.json", "w"),
indent=4,
)
model = str(base_dir)
if not (base_dir / "phoneme" / "phone_set.json").is_file() and not (base_dir / "phone_set.json").is_file():
pytest.skip(
"SoulX-Singer SVS test requires phoneme/phone_set.json. "
"Copy it from github.com/Soul-AILab/SoulX-Singer into the model dir. "
"See `examples/offline_inference/text_to_speech/README.md` for details."
)
if architecture == "SoulXSingerSVCPipeline":
json.dump(
{"model_type": "soulxsinger", "architectures": ["SoulXSingerSVCPipeline"], "max_num_seqs": 1},
open(svc_dir / "config.json", "w"),
indent=4,
)
model = str(svc_dir)
with OmniRunner(
model,
deploy_config=get_deploy_config_path(deploy_yaml),
async_chunk=False,
) as runner:
sampling = OmniDiffusionSamplingParams(
num_inference_steps=4,
guidance_scale=3.0,
seed=42,
extra_args={
"prompt_audio": str(PROMPT_AUDIO),
"target_audio": str(TARGET_AUDIO),
"preprocess_weights_dir": str(preprocess_dir),
**extra_args,
},
)
prompt = {"prompt_token_ids": [0]}
outputs = runner.generate([prompt], sampling)
assert outputs and outputs[0].error is None, outputs[0].error if outputs else "no output"
mm = outputs[0].multimodal_output
assert isinstance(mm, dict) and "audio" in mm
audio = _flatten_audio(mm["audio"])
assert 12_000 <= audio.size
assert np.isfinite(audio).all() and float(np.max(np.abs(audio))) > 1e-4
duration_s = audio.size / SAMPLE_RATE
assert 50.0 <= duration_s <= 52.0, f"duration={duration_s:.1f}s"
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_soulxsinger_svs_precomputed(soulx_weights: tuple[Path, Path, Path]) -> None:
"""SVS from precomputed note/lyric metadata.
Exercises the SVS DiT + vocoder serving path while skipping the heavy
upstream preprocess (funasr ASR + ROSVOT note transcription), so it runs in
the merge gate without those non-pip dependencies.
"""
base_dir, _svc_dir, preprocess_dir = soulx_weights
if not PROMPT_METADATA.is_file() or not TARGET_METADATA.is_file():
pytest.skip(f"Missing SoulX-Singer metadata fixtures: {PROMPT_METADATA.name}, {TARGET_METADATA.name}")
if not (base_dir / "phoneme" / "phone_set.json").is_file() and not (base_dir / "phone_set.json").is_file():
pytest.skip(
"SoulX-Singer SVS requires phoneme/phone_set.json (upstream fetch unavailable). "
"See `examples/offline_inference/text_to_speech/README.md`."
)
json.dump(
{"model_type": "soulxsinger", "architectures": ["SoulXSingerPipeline"], "max_num_seqs": 1},
open(base_dir / "config.json", "w"),
indent=4,
)
with OmniRunner(
str(base_dir),
deploy_config=get_deploy_config_path("soulxsinger_svs.yaml"),
async_chunk=False,
) as runner:
sampling = OmniDiffusionSamplingParams(
num_inference_steps=4,
guidance_scale=3.0,
seed=42,
extra_args={
"language": "Mandarin",
"control": "score",
"prompt_metadata_path": str(PROMPT_METADATA),
"target_metadata_path": str(TARGET_METADATA),
"audio_path": str(PROMPT_AUDIO),
"preprocess_weights_dir": str(preprocess_dir),
},
)
prompt = {"prompt_token_ids": [0]}
outputs = runner.generate([prompt], sampling)
assert outputs and outputs[0].error is None, outputs[0].error if outputs else "no output"
mm = outputs[0].multimodal_output
assert isinstance(mm, dict) and "audio" in mm
audio = _flatten_audio(mm["audio"])
assert 12_000 <= audio.size
assert np.isfinite(audio).all() and float(np.max(np.abs(audio))) > 1e-4
duration_s = audio.size / SAMPLE_RATE
assert 50.0 <= duration_s <= 52.0, f"duration={duration_s:.1f}s"
@@ -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
"""Stable Audio offline e2e: real weights, FP8 + TeaCache (single job to save GPU).
@@ -15,7 +15,6 @@ import numpy as np
import pytest
import torch
from tests.helpers import skip_if_gated_repo_inaccessible
from tests.helpers.assertions import assert_audio_valid
from tests.helpers.mark import hardware_test
from vllm_omni import Omni
@@ -25,8 +24,6 @@ from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
_MODEL_REPO = "stabilityai/stable-audio-open-1.0"
_SAMPLE_RATE = 44100
_CLIP_DURATION_S = 2.0
@@ -80,12 +77,10 @@ def test_stable_audio_quantization_and_teacache() -> None:
CI should provide ``HF_TOKEN`` if the checkpoint is gated.
"""
skip_if_gated_repo_inaccessible(_MODEL_REPO)
# ``model_class_name`` must be passed explicitly: the default-stage-cfg
# factory in ``async_omni_engine.py`` reads it out of ``kwargs`` when
# deciding ``final_output_type`` (#2077), and at construction time the
# auto-resolution from ``model_index.json`` has not run yet. AudioX's
# offline test follows the same pattern.
# auto-resolution from ``model_index.json`` has not run yet.
m = Omni(
model="stabilityai/stable-audio-open-1.0",
model_class_name="StableAudioPipeline",
@@ -39,8 +39,7 @@ def _extract_audio(multimodal_output: dict) -> torch.Tensor:
return audio
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voxcpm2_zero_shot_001(omni_runner: OmniRunner) -> None:
@@ -53,8 +52,7 @@ def test_voxcpm2_zero_shot_001(omni_runner: OmniRunner) -> None:
assert 0.5 < duration_s < 30.0, f"Audio duration out of range: {duration_s:.2f}s"
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voxcpm2_voice_clone_002(omni_runner: OmniRunner) -> None:
@@ -95,8 +93,7 @@ def test_voxcpm2_voice_clone_002(omni_runner: OmniRunner) -> None:
assert 0.5 < duration_s < 30.0, f"Audio duration out of range: {duration_s:.2f}s"
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voxcpm2_prefill_decode_mixed_batch_003(omni_runner: OmniRunner) -> None:
@@ -0,0 +1,51 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""E2E smoke test for Wan2.1 AutoRound MXFP4 inference."""
import os
import numpy as np
import pytest
import torch
from tests.helpers.mark import hardware_test
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
QUANTIZED_MODEL = os.environ.get(
"WAN21_AUTOROUND_MXFP4_MODEL",
"INCModel/Wan2.1-T2V-1.3B-Diffusers-MXFP4-AutoRound",
)
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
@hardware_test(res={"xpu": "B60"})
@pytest.mark.parametrize(
"omni_runner",
[(QUANTIZED_MODEL, None)],
indirect=True,
)
def test_wan21_autoround_mxfp4_generates_video(omni_runner, omni_runner_handler):
sampling_params = OmniDiffusionSamplingParams(
height=256,
width=256,
num_frames=5,
num_inference_steps=1,
guidance_scale=1.0,
generator=torch.Generator(device=current_omni_platform.device_type).manual_seed(42),
)
response = omni_runner_handler.send_diffusion_request(
{
"prompt": "A red toy car driving on a table",
"sampling_params": sampling_params,
}
)
assert response.success, response.error_message
assert response.images is not None and len(response.images) == 1
frames = np.asarray(response.images[0])
assert frames.shape[1:4] == (5, 256, 256)
assert frames.std() > 0.01
@@ -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
"""E2E tests for Wan2.2 AutoRound W4A16 quantized inference.
These tests cover I2V (image-to-video) and T2V (text-to-video) generation
@@ -19,8 +19,8 @@ import pytest
import torch
from PIL import Image
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.monitor import DeviceMemoryMonitor
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
@@ -91,7 +91,7 @@ def _create_test_image(width: int = WIDTH, height: int = HEIGHT) -> Image.Image:
return Image.fromarray(arr)
def _generate_i2v_video(omni_runner_handler, prompt: str = "A cat sitting on a table, smooth motion") -> tuple:
def _generate_i2v_video(offline_client, prompt: str = "A cat sitting on a table, smooth motion") -> tuple:
"""Generate one I2V video, return (frames, peak_memory_mb)."""
gc.collect()
current_omni_platform.empty_cache()
@@ -101,7 +101,7 @@ def _generate_i2v_video(omni_runner_handler, prompt: str = "A cat sitting on a t
monitor.start()
image = _create_test_image()
response = omni_runner_handler.send_diffusion_request(
response = offline_client.send_diffusion_request(
{
"prompt": prompt,
"images": image,
@@ -112,7 +112,7 @@ def _generate_i2v_video(omni_runner_handler, prompt: str = "A cat sitting on a t
peak = monitor.peak_used_mb
monitor.stop()
assert response.success, f"Request failed: {response.error_message}"
assert response.success, "Request failed"
assert response.images is not None and len(response.images) > 0, "Expected image output"
frames = response.images[0]
@@ -122,7 +122,7 @@ def _generate_i2v_video(omni_runner_handler, prompt: str = "A cat sitting on a t
return frames, peak
def _generate_t2v_video(omni_runner_handler, prompt: str = "A cat sitting on a table") -> tuple:
def _generate_t2v_video(offline_client, prompt: str = "A cat sitting on a table") -> tuple:
"""Generate one T2V video, return (frames, peak_memory_mb)."""
gc.collect()
current_omni_platform.empty_cache()
@@ -131,7 +131,7 @@ def _generate_t2v_video(omni_runner_handler, prompt: str = "A cat sitting on a t
monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02)
monitor.start()
response = omni_runner_handler.send_diffusion_request(
response = offline_client.send_diffusion_request(
{
"prompt": prompt,
"sampling_params": _sampling_params_t2v(),
@@ -141,7 +141,7 @@ def _generate_t2v_video(omni_runner_handler, prompt: str = "A cat sitting on a t
peak = monitor.peak_used_mb
monitor.stop()
assert response.success, f"Request failed: {response.error_message}"
assert response.success, "Request failed"
assert response.images is not None and len(response.images) > 0, "Expected image output"
frames = response.images[0]
@@ -158,9 +158,9 @@ def _generate_t2v_video(omni_runner_handler, prompt: str = "A cat sitting on a t
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_runner", quant_i2v_params, indirect=True)
def test_wan22_i2v_autoround_w4a16_generates_video(omni_runner, omni_runner_handler):
def test_wan22_i2v_autoround_w4a16_generates_video(omni_runner, offline_client):
"""Load the W4A16 quantized Wan2.2 I2V model and verify it produces a valid video."""
frames, _ = _generate_i2v_video(omni_runner_handler)
frames, _ = _generate_i2v_video(offline_client)
assert frames is not None, "Expected video frames output"
assert hasattr(frames, "shape"), "Expected frames to have a shape attribute"
@@ -182,9 +182,9 @@ def test_wan22_i2v_autoround_w4a16_generates_video(omni_runner, omni_runner_hand
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_runner", quant_t2v_params, indirect=True)
def test_wan22_t2v_autoround_w4a16_generates_video(omni_runner, omni_runner_handler):
def test_wan22_t2v_autoround_w4a16_generates_video(omni_runner, offline_client):
"""Load the W4A16 quantized Wan2.2 T2V model and verify it produces a valid video."""
frames, _ = _generate_t2v_video(omni_runner_handler)
frames, _ = _generate_t2v_video(offline_client)
assert frames is not None, "Expected video frames output"
assert hasattr(frames, "shape"), "Expected frames to have a shape attribute"
@@ -204,9 +204,9 @@ def test_wan22_t2v_autoround_w4a16_generates_video(omni_runner, omni_runner_hand
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_runner", quant_i2v_params, indirect=True)
def test_wan22_i2v_autoround_w4a16_quant_peak(omni_runner, omni_runner_handler):
def test_wan22_i2v_autoround_w4a16_quant_peak(omni_runner, offline_client):
"""Measure peak GPU memory of W4A16 quantized I2V model."""
frames, peak = _generate_i2v_video(omni_runner_handler)
frames, peak = _generate_i2v_video(offline_client)
assert frames is not None, "Expected video frames output"
_memory_results["quant_i2v"] = peak
@@ -220,9 +220,9 @@ def test_wan22_i2v_autoround_w4a16_quant_peak(omni_runner, omni_runner_handler):
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_runner", baseline_i2v_params, indirect=True)
def test_wan22_i2v_autoround_w4a16_baseline_peak(omni_runner, omni_runner_handler):
def test_wan22_i2v_autoround_w4a16_baseline_peak(omni_runner, offline_client):
"""Measure peak GPU memory of BF16 baseline I2V model."""
frames, peak = _generate_i2v_video(omni_runner_handler)
frames, peak = _generate_i2v_video(offline_client)
assert frames is not None, "Expected video frames output"
_memory_results["baseline_i2v"] = peak
@@ -261,9 +261,9 @@ def test_wan22_i2v_autoround_w4a16_memory_savings():
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_runner", quant_t2v_params, indirect=True)
def test_wan22_t2v_autoround_w4a16_quant_peak(omni_runner, omni_runner_handler):
def test_wan22_t2v_autoround_w4a16_quant_peak(omni_runner, offline_client):
"""Measure peak GPU memory of W4A16 quantized T2V model."""
frames, peak = _generate_t2v_video(omni_runner_handler)
frames, peak = _generate_t2v_video(offline_client)
assert frames is not None, "Expected video frames output"
_memory_results["quant_t2v"] = peak
@@ -277,9 +277,9 @@ def test_wan22_t2v_autoround_w4a16_quant_peak(omni_runner, omni_runner_handler):
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_runner", baseline_t2v_params, indirect=True)
def test_wan22_t2v_autoround_w4a16_baseline_peak(omni_runner, omni_runner_handler):
def test_wan22_t2v_autoround_w4a16_baseline_peak(omni_runner, offline_client):
"""Measure peak GPU memory of BF16 baseline T2V model."""
frames, peak = _generate_t2v_video(omni_runner_handler)
frames, peak = _generate_t2v_video(offline_client)
assert frames is not None, "Expected video frames output"
_memory_results["baseline_t2v"] = peak
@@ -1,7 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunnerHandler
from tests.helpers.runtime import OfflineOmniClient
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
@@ -13,7 +16,7 @@ NEGATIVE_PROMPT = "low quality, blurry, watermark, text"
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards={"cuda": 1, "rocm": 1})
@pytest.mark.parametrize("omni_runner", [(MODEL, None)], indirect=True)
def test_text_to_video_001(omni_runner_handler: OmniRunnerHandler):
def test_text_to_video_001(offline_client: OfflineOmniClient):
sampling = OmniDiffusionSamplingParams(
height=512,
width=512,
@@ -29,4 +32,4 @@ def test_text_to_video_001(omni_runner_handler: OmniRunnerHandler):
"negative_prompt": NEGATIVE_PROMPT,
"sampling_params": sampling,
}
omni_runner_handler.send_diffusion_request(request_config)
offline_client.send_diffusion_request(request_config)
+5 -5
View File
@@ -1,21 +1,21 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunnerHandler
from tests.helpers.runtime import OfflineOmniClient
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = "Tongyi-MAI/Z-Image-Turbo"
PROMPT = "A high-detail studio photo of an orange tabby cat sitting on a laptop keyboard."
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4", "rocm": "MI325", "xpu": "B60"}, num_cards={"cuda": 1, "rocm": 1, "xpu": 2})
@pytest.mark.parametrize("omni_runner", [(MODEL, None)], indirect=True)
def test_zimage(omni_runner_handler: OmniRunnerHandler):
def test_zimage(offline_client: OfflineOmniClient):
# high resolution may cause OOM on L4
sampling = OmniDiffusionSamplingParams(
height=256,
@@ -30,4 +30,4 @@ def test_zimage(omni_runner_handler: OmniRunnerHandler):
"prompt": PROMPT,
"sampling_params": sampling,
}
omni_runner_handler.send_diffusion_request(request_config)
offline_client.send_diffusion_request(request_config)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Shared fixtures and argument builders for MiniCPM-o 4.5 duplex E2E tests."""
from __future__ import annotations
@@ -11,21 +14,12 @@ from types import SimpleNamespace
import pytest
from huggingface_hub import snapshot_download
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from tests.helpers.runtime import OmniServerParams, get_model_prefix
from tests.helpers.stage_config import get_deploy_config_path, get_deploy_duplex_max_sessions
MODEL = "openbmb/MiniCPM-o-4_5"
DEPLOY_CONFIG = modify_stage_config(
get_deploy_config_path("minicpmo_4_5_duplex.yaml"),
updates={
"base_config": get_deploy_config_path("minicpmo_4_5.yaml"),
# Talker context is 4096 (tts_config.max_position_embeddings); KV sizing
# is left automatic so duplex matches the simplex deploy profiles.
"stages": {
1: {"max_model_len": 4096},
},
},
)
DEPLOY_CONFIG_REL = "minicpmo_4_5.yaml"
DEPLOY_CONFIG = get_deploy_config_path(DEPLOY_CONFIG_REL)
ASSET_DIR = Path(__file__).resolve().parents[3] / "assets" / "minicpmo_4_5"
RESPONSE_REQUIRED_WAV = ASSET_DIR / "response_required_16k.wav"
RESPONSE_REQUIRED_SHA256 = "2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941"
@@ -46,6 +40,16 @@ SERVER_PARAMS = [
]
def deploy_max_sessions() -> int:
"""Concurrent duplex sessions the deploy config under test admits.
The admission probe has to expect the capacity the server is actually
started with. Hardcoding it silently drifts the moment the deploy config
changes its capacity, turning that change into an unrelated probe timeout.
"""
return get_deploy_duplex_max_sessions(DEPLOY_CONFIG_REL)
def validated_wav(path: Path, expected_sha256: str) -> Path:
actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if actual_sha256 != expected_sha256:
@@ -73,10 +77,11 @@ def validated_soft_interrupt_wav() -> Path:
return validated_wav(SOFT_INTERRUPT_WAV, SOFT_INTERRUPT_SHA256)
def resolve_ref_audio(model_prefix: str) -> Path:
def resolve_ref_audio() -> Path:
# ``MODEL`` may be a local checkpoint directory instead of a Hub repo id
# (``Path("/prefix") / "/abs/path"`` collapses to the absolute path), so only
# fall back to the Hub cache when no such directory exists on disk.
model_prefix = get_model_prefix()
model_root = Path(model_prefix) / MODEL if model_prefix else Path(MODEL)
if not model_root.is_dir():
model_root = Path(snapshot_download(MODEL, local_files_only=True))
@@ -86,6 +91,20 @@ def resolve_ref_audio(model_prefix: str) -> Path:
return ref_audio
def duplex_camera_frames(*, seconds: int, cache_dir: Path) -> list[str]:
"""Base64 JPEG frames of a moving synthetic scene, one per second.
Mirrors what the realtime web client captures (1 fps, no client-side
resize: the server normalizes at ``scale_resolution=448``).
"""
from tests.e2e.online_serving.helpers.minicpmo_realtime_duplex_scenarios import _video_frames_from_file
from tests.helpers.media import generate_synthetic_video
video = generate_synthetic_video(448, 448, 30 * seconds, cache_dir=cache_dir)
frames, _ = _video_frames_from_file(Path(video["file_path"]))
return frames
def realtime_url(omni_server) -> str:
return f"ws://{omni_server.host}:{omni_server.port}/v1/realtime?duplex=1"
@@ -108,6 +127,8 @@ def demo_args(
output_audio_format="pcm16",
chunk_ms=200,
realtime_input=True,
input_video=None,
stack_frames=1,
first_turn_ms=1400,
turn_duration_ms=[],
first_turn_transcript="duplex CI speech",
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Strict MiniCPM-o 4.5 Realtime duplex E2E scenario harness.
This script is intentionally scenario-based instead of a generic chat client.
@@ -34,8 +37,10 @@ import json
import sys
import time
import wave
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace
_REPO_ROOT = Path(__file__).resolve().parents[4]
if str(_REPO_ROOT) not in sys.path:
@@ -52,19 +57,100 @@ from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
PCM16_SAMPLE_RATE,
RealtimeEventCollector,
build_realtime_url,
duplex_unit_boundary_ms,
read_pcm16_wav,
summarize_session_request_metrics,
)
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
reference_audio_data_url as _ref_audio_data_url,
)
from vllm_omni.experimental.fullduplex.video_stacking import ( # noqa: E402
concat_frames_b64,
unit_subframe_offsets,
)
_url_with_model = build_realtime_url
_read_wav_pcm16 = read_pcm16_wav
DemoArgs = argparse.Namespace | SimpleNamespace
def _ref_audio_data_url(path: str | None) -> str | None:
if path is None:
return None
ref_path = Path(path).expanduser()
return "data:audio/wav;base64," + base64.b64encode(ref_path.read_bytes()).decode("ascii")
def _video_frames_from_file(
path: Path,
*,
fps: float = 1.0,
jpeg_quality: int = 70,
stack_frames: int = 1,
) -> tuple[list[str], list[str | None]]:
"""Decode ``path`` into base64 JPEG frames at the omni duplex cadence.
One frame per second matches what the realtime web client captures, so the
stream stays representative of a live camera. ``stack_frames > 1`` also
samples each unit's interior sub-frames and tiles them into one composite,
the official way to raise visual refresh rate without changing the audio
cadence. Returns ``(base_frames, stacked_frames)`` with the tracks parallel.
"""
import cv2
offsets = unit_subframe_offsets(stack_frames)
capture = cv2.VideoCapture(str(path))
if not capture.isOpened():
raise ValueError(f"cannot open video: {path}")
def encode(frame_bgr, index: int) -> str:
ok, jpeg = cv2.imencode(".jpg", frame_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality])
if not ok:
raise ValueError(f"cannot JPEG-encode frame {index} of {path}")
return base64.b64encode(jpeg.tobytes()).decode("ascii")
try:
source_fps = capture.get(cv2.CAP_PROP_FPS) or 0.0
stride = max(1, round(source_fps / fps)) if source_fps > 0 else 1
# Sub-frame k of a unit sits ``offset * stride`` frames past its base.
sub_strides = {max(1, round(offset * stride)): position for position, offset in enumerate(offsets)}
frames: list[str] = []
interiors: list[list[str]] = []
index = 0
while True:
ok, frame_bgr = capture.read()
if not ok:
break
position, within_unit = divmod(index, stride)
if within_unit == 0:
frames.append(encode(frame_bgr, index))
interiors.append([])
elif within_unit in sub_strides and position < len(interiors):
interiors[position].append(encode(frame_bgr, index))
index += 1
finally:
capture.release()
if not frames:
raise ValueError(f"video has no decodable frames: {path}")
stacked = [concat_frames_b64(interior) if interior else None for interior in interiors]
return frames, stacked
def _resolve_video_frames(args: DemoArgs) -> tuple[list[str], list[str | None]]:
"""Base64 JPEG frames for the duplex camera track, newest last.
Returns the base track plus the parallel stacked composites. Only a video
source has sub-frames to stack; an explicit frame list or a still image
stacks nothing.
"""
frames = list(getattr(args, "video_frames_b64", None) or [])
if frames:
stacked = list(getattr(args, "video_stacked_frames_b64", None) or [])
stacked += [None] * (len(frames) - len(stacked))
return frames, stacked[: len(frames)]
input_video = getattr(args, "input_video", None)
if input_video:
return _video_frames_from_file(
Path(input_video).expanduser(),
stack_frames=max(1, int(getattr(args, "stack_frames", 1) or 1)),
)
frame_image = getattr(args, "frame_image", None)
if frame_image:
return [base64.b64encode(Path(frame_image).expanduser().read_bytes()).decode("ascii")], [None]
return [], []
@dataclass
@@ -374,17 +460,20 @@ class DemoState:
commit_indices = [
index for index, event in enumerate(self.events) if event.get("type") == "input_audio_buffer.committed"
]
decision_indices = [
index
for index, event in enumerate(self.events)
if event.get("type") == "response.created"
or (
event.get("type") == "response.listen"
and isinstance(event.get("response"), dict)
and isinstance(event["response"].get("metadata"), dict)
and event["response"]["metadata"].get("model_listen") is True
)
]
decision_indices: list[int] = []
for index, event in enumerate(self.events):
event_type = event.get("type")
if event_type == "response.created":
decision_indices.append(index)
continue
if event_type != "response.listen":
continue
response = event.get("response")
if not isinstance(response, dict):
continue
metadata = response.get("metadata")
if isinstance(metadata, dict) and metadata.get("model_listen") is True:
decision_indices.append(index)
first_input_index = self.first_index("input_audio_buffer.speech_started")
if first_input_index is None:
first_input_index = commit_indices[0] if commit_indices else None
@@ -501,7 +590,7 @@ class DemoState:
return stale
def _session_update_event(args: argparse.Namespace) -> dict[str, object]:
def _session_update_event(args: DemoArgs) -> dict[str, object]:
session_payload: dict[str, object] = {
"model": args.model,
"modalities": ["audio", "text"],
@@ -837,7 +926,8 @@ async def _send_pcm16(
hints: dict[str, object] | None = None,
first_chunk_hints: dict[str, object] | None = None,
on_model_unit_ready=None,
frame_b64: str | None = None,
frames_b64: Sequence[str] | None = None,
stacked_frames_b64: Sequence[str | None] | None = None,
) -> None:
hints = hints or {}
first_chunk_hints = first_chunk_hints or {}
@@ -845,6 +935,8 @@ async def _send_pcm16(
model_unit_bytes = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE
audio_ms = 0
next_model_unit_bytes = model_unit_bytes
frames = list(frames_b64 or [])
stacked = list(stacked_frames_b64 or [])
frames_sent = 0
for offset in range(0, len(pcm16), chunk_bytes):
chunk = pcm16[offset : offset + chunk_bytes]
@@ -853,9 +945,18 @@ async def _send_pcm16(
chunk_hints = dict(hints)
if offset == 0:
chunk_hints.update(first_chunk_hints)
if frame_b64 is not None and audio_ms > frames_sent * 1000:
# Omni duplex cadence: one camera frame per 1 s of audio.
chunk_hints["video_frames"] = [frame_b64]
if frames and audio_ms >= duplex_unit_boundary_ms(frames_sent):
# Omni duplex cadence: one camera frame per model unit, riding the
# append that closes it, so the frame and the second of audio it was
# captured during enter the same unit. A video advances one frame per
# unit and holds its last frame when the audio outlives the clip; a
# still image is a one-element list and therefore repeats.
# A stacked composite of that unit's interior sub-frames rides the
# same append right after the base frame (wire maximum of 2).
index = min(frames_sent, len(frames) - 1)
composite = stacked[index] if index < len(stacked) else None
base = frames[index]
chunk_hints["video_frames"] = [base] if composite is None else [base, composite]
frames_sent += 1
await ws.send(
json.dumps(
@@ -936,7 +1037,8 @@ async def _send_clean_turn(
realtime_input: bool = False,
model_policy_settle_s: float = 2.0,
commit_input: bool = True,
frame_b64: str | None = None,
frames_b64: Sequence[str] | None = None,
stacked_frames_b64: Sequence[str | None] | None = None,
) -> tuple[str | None, str]:
before_created = state.count("response.created")
before_model_listen = state.model_listen_count
@@ -958,7 +1060,8 @@ async def _send_clean_turn(
chunk_ms=chunk_ms,
realtime_delay=realtime_input,
hints={"transcript": transcript} if send_transcript_hint else {},
frame_b64=frame_b64,
frames_b64=frames_b64,
stacked_frames_b64=stacked_frames_b64,
)
if commit_input:
state.input_commit_sent_at_s.append(time.monotonic())
@@ -1164,7 +1267,8 @@ async def _send_listen_only_overlap_pair(
model_policy_settle_s: float,
validation_mode: str,
silence_ms: int,
frame_b64: str | None = None,
frames_b64: Sequence[str] | None = None,
stacked_frames_b64: Sequence[str | None] | None = None,
) -> tuple[list[str | None], list[str], bool]:
if not realtime_input:
raise ValueError("listen-only-overlap requires --realtime-input")
@@ -1195,7 +1299,8 @@ async def _send_listen_only_overlap_pair(
realtime_delay=True,
hints={"transcript": transcripts[0]} if send_transcript_hint else {},
on_model_unit_ready=continue_until_first_speak,
frame_b64=frame_b64,
frames_b64=frames_b64,
stacked_frames_b64=stacked_frames_b64,
)
state.input_commit_sent_at_s.append(time.monotonic())
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
@@ -1234,7 +1339,8 @@ async def _send_listen_only_overlap_pair(
realtime_delay=True,
hints={"transcript": transcripts[1]} if send_transcript_hint else {},
on_model_unit_ready=record_model_unit_ready,
frame_b64=frame_b64,
frames_b64=frames_b64,
stacked_frames_b64=stacked_frames_b64,
)
state.input_commit_sent_at_s.append(time.monotonic())
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
@@ -1298,10 +1404,8 @@ async def _send_listen_only_overlap_pair(
return [first_response_id, second_response_id], ["speak", second_outcome], overlap_ok
async def run_demo(args: argparse.Namespace) -> dict[str, object]:
demo_frame_b64: str | None = None
if getattr(args, "frame_image", None):
demo_frame_b64 = base64.b64encode(Path(args.frame_image).read_bytes()).decode("ascii")
async def run_demo(args: DemoArgs) -> dict[str, object]:
demo_frames_b64, demo_stacked_b64 = _resolve_video_frames(args)
turn_input_paths = _turn_input_paths(
Path(args.input_wav),
list(getattr(args, "turn_input_wav", []) or []),
@@ -1351,6 +1455,9 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
try:
await ws.send(json.dumps(_session_update_event(args)))
await _wait_for(state, lambda: state.count("session.created") > 0, timeout_s=20, label="session.created")
start_barrier = getattr(args, "start_barrier", None)
if start_barrier is not None:
await start_barrier.wait()
turn_transcripts = _turn_transcripts(args.first_turn_transcript, turns=args.turns)
turn_specs = list(zip(turn_transcripts, turn_durations, strict=True))
@@ -1369,7 +1476,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
validation_mode=validation_mode,
silence_ms=max(0, args.silence_ms),
frame_b64=demo_frame_b64,
frames_b64=demo_frames_b64,
stacked_frames_b64=demo_stacked_b64,
)
for turn_index in range(2, len(turn_specs)):
transcript, duration_ms = turn_specs[turn_index]
@@ -1387,7 +1495,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
realtime_input=realtime_input,
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
commit_input=not continuous_input,
frame_b64=demo_frame_b64,
frames_b64=demo_frames_b64,
stacked_frames_b64=demo_stacked_b64,
)
turn_response_ids.append(response_id)
turn_outcomes.append(outcome)
@@ -1407,7 +1516,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
realtime_input=realtime_input,
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
commit_input=not continuous_input,
frame_b64=demo_frame_b64,
frames_b64=demo_frames_b64,
stacked_frames_b64=demo_stacked_b64,
)
turn_response_ids.append(response_id)
turn_outcomes.append(outcome)
@@ -1582,6 +1692,8 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
"continuous_input": continuous_input,
"continuous_input_ok": continuous_input_ok,
"input_chunk_ms": args.chunk_ms,
"video_frame_count": len(demo_frames_b64),
"video_stacked_frame_count": sum(frame is not None for frame in demo_stacked_b64),
"model_policy_settle_ms": args.model_policy_settle_ms,
"turn_outcomes": turn_outcomes,
"turn_outcomes_ok": turn_outcomes_ok,
@@ -1639,6 +1751,19 @@ def parse_args() -> argparse.Namespace:
default=None,
help="Optional image file sent as an omni-duplex camera frame (one per 1 s of audio)",
)
parser.add_argument(
"--input-video",
default=None,
help="Optional video file streamed as omni-duplex camera frames (decoded at 1 fps, "
"overrides --frame-image; the last frame is held if the audio outlives the clip)",
)
parser.add_argument(
"--stack-frames",
type=int,
default=1,
choices=[1, 2],
help="Frames per append: 2 also resends the previous frame for motion context.",
)
parser.add_argument(
"--turn-input-wav",
action="append",
@@ -0,0 +1,364 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import argparse
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
INPUT_SAMPLE_RATE_HZ = 16_000
OUTPUT_SAMPLE_RATE_HZ = 22_050
FRAME_SAMPLES = 1_280
FRAME_PERIOD_S = FRAME_SAMPLES / INPUT_SAMPLE_RATE_HZ
DEFAULT_FUNCTION_TOOLS = [
{
"type": "function",
"name": "generate_random_number",
"description": "Generate a random integer between min and max (inclusive).",
"parameters": {
"type": "object",
"properties": {
"min": {"type": "integer", "description": "Minimum value (inclusive)"},
"max": {"type": "integer", "description": "Maximum value (inclusive)"},
},
"required": ["min", "max"],
},
}
]
DEFAULT_INSTRUCTIONS = "You are NVIDIA Voice Chat. Answer briefly. Start by greeting the user."
DEFAULT_FUNCTION_INSTRUCTIONS = (
"You are NVIDIA Voice Chat. If the user's request matches an available tool, "
"you MUST call that tool instead of answering from your own knowledge. "
"Use only argument values spoken by the user and never invent missing values."
)
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()
width = wav_file.getsampwidth()
source_rate = wav_file.getframerate()
raw = wav_file.readframes(wav_file.getnframes())
dtypes = {1: np.uint8, 2: np.dtype("<i2"), 4: np.dtype("<i4")}
if width not in dtypes:
raise ValueError(f"unsupported input sample width: {width}")
pcm = np.frombuffer(raw, dtype=dtypes[width]).astype(np.float32)
pcm = (pcm - 128.0) / 128.0 if width == 1 else pcm / float(1 << (width * 8 - 1))
if not 0 <= input_channel < channels:
raise ValueError(f"input channel {input_channel} is outside WAV channel count {channels}")
if channels > 1:
pcm = pcm.reshape(-1, channels)[:, input_channel]
if source_rate != INPUT_SAMPLE_RATE_HZ:
divisor = math.gcd(source_rate, INPUT_SAMPLE_RATE_HZ)
pcm = resample_poly(pcm, up=INPUT_SAMPLE_RATE_HZ // divisor, down=source_rate // divisor)
return np.ascontiguousarray(pcm, dtype="<f4")
async def _stream(client: RealtimeDuplexClient, 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,
}
)
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]
async def _return_function_output_when_ready(
client: RealtimeDuplexClient,
*,
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,
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]
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)
await client.send(
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": call_id,
"output": output,
},
}
)
return call_id, event_count_before_output
def _write_events(path: Path, client: RealtimeDuplexClient) -> None:
path.write_text(
"".join(json.dumps(event, ensure_ascii=False) + "\n" for event in client.events.events),
encoding="utf-8",
)
@asynccontextmanager
async def _managed_client(client: RealtimeDuplexClient, *, timeout_s: float):
async with client:
try:
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))
async def run(args: argparse.Namespace) -> dict[str, object]:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if args.expect_function_call and args.instructions == DEFAULT_INSTRUCTIONS:
instructions = DEFAULT_FUNCTION_INSTRUCTIONS
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
expected = dict(
implementation_level="model_native_duplex",
chunk_period_ms=80,
supports_core_resumable_request=True,
supports_core_kv_lease=False,
supports_multi_session=False,
)
if not isinstance(capabilities, dict) or any(capabilities.get(key) != value for key, value in expected.items()):
raise AssertionError(f"unexpected capabilities: {capabilities}")
function_output_task = (
asyncio.create_task(
_return_function_output_when_ready(
client,
output=args.function_output,
timeout_s=args.timeout_s,
)
)
if args.function_output is not None
else None
)
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(
lambda: (
bool(client.events.errors())
or (
client.events.count("response.function_call_arguments.done") > 0
if args.expect_function_call
else (
client.events.count("response.audio.delta") >= args.minimum_audio_chunks
and (client.events.count("response.done") > completed_responses_at_commit)
)
)
),
timeout_s=args.timeout_s,
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 not args.expect_function_call:
response = done_events[-1].get("response") if done_events else None
status = response.get("status") if isinstance(response, dict) else None
if status != "completed":
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")
]
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"
]
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
]
if not matching_items:
raise AssertionError(f"expected {args.expected_function_name!r}, got {function_items}")
function_item = matching_items[-1]["item"]
try:
function_arguments = json.loads(str(function_item.get("arguments", "")))
except json.JSONDecodeError as exc:
raise AssertionError(f"function arguments are not JSON: {function_item}") from exc
if args.expected_function_arguments is not None:
expected_arguments = json.loads(args.expected_function_arguments)
if function_arguments != expected_arguments:
raise AssertionError(
f"function arguments differ: expected={expected_arguments!r}, actual={function_arguments!r}"
)
if args.function_output is not None:
call_id = function_item.get("call_id")
if not isinstance(call_id, str) or not call_id:
raise AssertionError(f"function item has no call_id: {function_item}")
assert function_output_task is not None
returned_call_id, event_count_before_output = await function_output_task
assert returned_call_id == call_id
def tool_result_completed() -> bool:
later = client.events.events[event_count_before_output:]
transcript = "".join(
str(event.get("delta", ""))
for event in later
if event.get("type") == "response.audio_transcript.delta"
).lower()
return (
any(event.get("type") == "response.audio.delta" for event in later)
and any(event.get("type") == "response.done" for event in later)
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(),
timeout_s=args.timeout_s,
label="completed response after function output",
)
if client.events.errors():
raise AssertionError(f"function output failed: {client.events.errors()}")
await asyncio.sleep(args.drain_s)
audio = client.events.audio_bytes()
audio_events = _events(client, "response.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
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}")
audio_pcm = np.frombuffer(audio, dtype="<i2").astype(np.float32) / 32768.0
audio_rms = float(np.sqrt(np.mean(np.square(audio_pcm)))) if audio_pcm.size else 0.0
if not args.expect_function_call and audio and audio_rms < args.minimum_audio_rms:
raise AssertionError(
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)
if audio:
write_pcm16_wav(output_dir / "output.wav", audio, sample_rate_hz=OUTPUT_SAMPLE_RATE_HZ)
result = {
"ok": True,
"session_id": session_id,
"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})
},
"audio_bytes": len(audio),
"audio_rms": audio_rms,
"function_events": function_events,
"function_items": function_items,
"output_dir": str(output_dir),
}
(output_dir / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://127.0.0.1:8125/v1/realtime")
parser.add_argument("--model", required=True)
parser.add_argument("--input-wav", required=True)
parser.add_argument("--input-channel", type=int, default=0)
parser.add_argument("--output-dir", default="/tmp/nemotron-voicechat-duplex")
parser.add_argument("--instructions", default=DEFAULT_INSTRUCTIONS)
parser.add_argument("--max-frames", type=int)
parser.add_argument("--minimum-audio-chunks", type=int, default=1)
parser.add_argument("--minimum-audio-rms", type=float, default=1e-4)
parser.add_argument("--expect-function-call", action="store_true")
parser.add_argument("--expected-function-name", default="generate_random_number")
parser.add_argument("--expected-function-arguments")
parser.add_argument("--function-output")
parser.add_argument("--expected-post-tool-text")
parser.add_argument("--no-realtime", action="store_true")
parser.add_argument("--drain-s", type=float, default=2.0)
parser.add_argument("--timeout-s", type=float, default=600.0)
return parser.parse_args(argv)
def main() -> None:
print(json.dumps(asyncio.run(run(parse_args())), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Concurrent MiniCPM-o Realtime duplex and resumable-session E2E driver."""
from __future__ import annotations
@@ -14,6 +17,7 @@ from types import SimpleNamespace
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import websockets
from websockets.asyncio.client import ClientConnection
from websockets.exceptions import ConnectionClosed
SCRIPT_DIR = Path(__file__).resolve().parent
@@ -42,6 +46,67 @@ async def run_demo(args):
return await _scenario_module().run_demo(args)
class _SynchronizedStartGate:
"""One-shot, failure-propagating start gate for Python 3.10+."""
def __init__(self, parties: int, *, timeout_s: float):
if parties < 1:
raise ValueError("synchronized start parties must be positive")
if timeout_s <= 0:
raise ValueError("synchronized start timeout must be positive")
self._parties = parties
self._timeout_s = timeout_s
self._arrived = 0
self._released = False
self._failure: BaseException | None = None
self._event = asyncio.Event()
self._lock = asyncio.Lock()
async def wait(self) -> None:
async with self._lock:
if self._failure is not None:
raise RuntimeError(f"synchronized start aborted: {self._failure!r}") from self._failure
if self._released:
return
self._arrived += 1
if self._arrived == self._parties:
self._released = True
self._event.set()
return
try:
await asyncio.wait_for(self._event.wait(), timeout=self._timeout_s)
except asyncio.TimeoutError as exc:
failure = TimeoutError(
f"synchronized start timed out after {self._timeout_s:.1f}s "
f"({self._arrived}/{self._parties} sessions ready)"
)
await self.abort(failure)
if self._released and self._failure is None:
return
raise failure from exc
if self._failure is not None:
raise RuntimeError(f"synchronized start aborted: {self._failure!r}") from self._failure
async def abort(self, failure: BaseException) -> None:
async with self._lock:
if self._released or self._failure is not None:
return
self._failure = failure
self._event.set()
async def _run_demo_with_start_gate(args: SimpleNamespace):
start_gate = getattr(args, "start_barrier", None)
try:
return await run_demo(args)
except BaseException as exc:
if start_gate is not None:
await start_gate.abort(exc)
raise
def _with_resume_mode(url: str) -> str:
parts = urlsplit(url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
@@ -101,14 +166,24 @@ def _validate_semantic_isolation(
return True
if len(expected_tokens) != len(results):
return False
for result, expected_token in zip(results, expected_tokens, strict=True):
normalized_expected_tokens = [token.strip().casefold() for token in expected_tokens]
if any(not token for token in normalized_expected_tokens):
return False
for result_index, (result, expected_token) in enumerate(zip(results, normalized_expected_tokens, strict=True)):
details = result.get("transcript_integrity")
transcripts = (
[str(item.get("transcript", "")) for item in details if isinstance(item, dict)]
if isinstance(details, list)
else []
)
if expected_token not in "".join(transcripts):
joined_transcripts = "".join(transcripts).casefold()
if expected_token not in joined_transcripts:
return False
if any(
other_token in joined_transcripts
for other_index, other_token in enumerate(normalized_expected_tokens)
if other_index != result_index
):
return False
return True
@@ -130,10 +205,14 @@ async def _receive_until(ws, event_type: str, *, timeout_s: float) -> tuple[dict
return await asyncio.wait_for(receive(), timeout=timeout_s)
def _server_event_sequences(events: list[dict[str, object]]) -> list[int]:
return [sequence for event in events if isinstance(sequence := event.get("server_event_seq"), int)]
async def _open_admission_session(
args: argparse.Namespace,
session_id: str,
) -> tuple[object, dict[str, object]]:
) -> tuple[ClientConnection, dict[str, object]]:
url = _url_with_model(
args.url,
args.model,
@@ -159,7 +238,7 @@ async def _open_admission_session(
return ws, created
async def _close_admission_session(ws, *, timeout_s: float) -> None:
async def _close_admission_session(ws: ClientConnection, *, timeout_s: float) -> None:
await ws.send(json.dumps({"type": "session.close"}))
await _receive_until(ws, "session.closed", timeout_s=timeout_s)
await ws.close()
@@ -169,8 +248,8 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
if limit < 1:
raise ValueError("admission limit must be positive")
prefix = f"admission-{uuid.uuid4().hex}"
accepted: list[tuple[object, dict[str, object]]] = []
replacement: tuple[object, dict[str, object]] | None = None
accepted: list[tuple[ClientConnection, dict[str, object]]] = []
replacement: tuple[ClientConnection, dict[str, object]] | None = None
overflow_code = None
try:
for index in range(limit):
@@ -274,14 +353,7 @@ async def _resume_probe(
generation = created.get("attachment_generation")
if not isinstance(token, str) or not isinstance(incarnation, int):
raise RuntimeError("session.created omitted resumable credentials")
last_seq = max(
(
event.get("server_event_seq", 0)
for event in first_events
if isinstance(event.get("server_event_seq"), int)
),
default=0,
)
last_seq = max(_server_event_sequences(first_events), default=0)
delay_s = args.expire_after_s if expect_expired else args.resume_after_ms / 1000
if delay_s > 0:
@@ -324,7 +396,7 @@ async def _resume_probe(
await second.send(json.dumps({"type": "session.close"}))
closed, close_events = await _receive_until(second, "session.closed", timeout_s=args.timeout_s)
replay_sequences = [event["server_event_seq"] for event in replay if isinstance(event.get("server_event_seq"), int)]
replay_sequences = _server_event_sequences(replay)
return {
"ok": (
resumed.get("session_id") == session_id
@@ -386,14 +458,7 @@ async def _takeover_probe(
generation = created.get("attachment_generation")
if not isinstance(token, str) or not isinstance(incarnation, int) or not isinstance(generation, int):
raise RuntimeError("session.created omitted takeover credentials")
last_seq = max(
(
event.get("server_event_seq", 0)
for event in first_events
if isinstance(event.get("server_event_seq"), int)
),
default=0,
)
last_seq = max(_server_event_sequences(first_events), default=0)
second = await websockets.connect(_with_resume_mode(url), max_size=64 * 1024 * 1024)
await second.send(
@@ -457,7 +522,9 @@ async def _takeover_probe(
await first.close()
def _demo_args(args: argparse.Namespace, index: int) -> SimpleNamespace:
def _demo_args(
args: argparse.Namespace, index: int, start_barrier: _SynchronizedStartGate | None = None
) -> SimpleNamespace:
validation_mode = "response-required" if args.response_required else "model-policy"
input_wav = args.session_input_wav[index] if args.session_input_wav else args.input_wav
return SimpleNamespace(
@@ -477,14 +544,16 @@ def _demo_args(args: argparse.Namespace, index: int) -> SimpleNamespace:
omit_transcript_hints=True,
validation_mode=validation_mode,
temperature=args.temperature,
scenario="sequential",
scenario=getattr(args, "scenario", "sequential"),
silence_ms=getattr(args, "silence_ms", 500),
require_audio=args.response_required,
require_distinct_inputs=False,
expect_empty_turn=[],
expect_empty_turn=list(getattr(args, "expect_empty_turn", []) or []),
short_ack_ms=350,
turns=args.turns,
timeout_s=args.timeout_s,
model_policy_settle_ms=args.model_policy_settle_ms,
start_barrier=start_barrier,
)
@@ -511,8 +580,16 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
)
lifecycle_result = await run_lifecycle_probes(args)
start_barrier = (
_SynchronizedStartGate(
args.sessions,
timeout_s=min(float(args.timeout_s), 30.0),
)
if getattr(args, "synchronized_start", False)
else None
)
session_results = await asyncio.gather(
*(run_demo(_demo_args(args, index)) for index in range(args.sessions)),
*(_run_demo_with_start_gate(_demo_args(args, index, start_barrier)) for index in range(args.sessions)),
return_exceptions=True,
)
failures = [repr(result) for result in session_results if isinstance(result, BaseException)]
@@ -596,7 +673,14 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--turns", type=int, default=1)
parser.add_argument("--first-turn-ms", type=int, default=1400)
parser.add_argument("--turn-duration-ms", type=int, action="append", default=[])
parser.add_argument("--expect-empty-turn", type=int, action="append", default=[])
parser.add_argument("--response-required", action="store_true")
parser.add_argument(
"--scenario",
choices=["sequential", "listen-only-overlap"],
default="sequential",
)
parser.add_argument("--silence-ms", type=int, default=500)
parser.add_argument("--temperature", type=float, default=None)
parser.add_argument("--disconnect-session-index", type=int)
parser.add_argument("--takeover-session-index", type=int)
@@ -604,6 +688,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--expire-session-index", type=int)
parser.add_argument("--expire-after-s", type=float, default=6.0)
parser.add_argument("--verify-admission-limit", type=int)
parser.add_argument(
"--synchronized-start",
action="store_true",
help="Wait until every session is created before starting input.",
)
parser.add_argument("--model-policy-settle-ms", type=int, default=600)
parser.add_argument("--timeout-s", type=float, default=120.0)
args = parser.parse_args()
@@ -615,6 +704,13 @@ def parse_args() -> argparse.Namespace:
parser.error("provide exactly one --session-expected-token per session")
if args.session_expected_token and not args.session_input_wav:
parser.error("--session-expected-token requires --session-input-wav")
normalized_expected_tokens = [token.strip().casefold() for token in args.session_expected_token]
for token_index, token in enumerate(normalized_expected_tokens):
if any(
token in other_token or other_token in token
for other_token in normalized_expected_tokens[token_index + 1 :]
):
parser.error("--session-expected-token values must not overlap after normalization")
return args
@@ -0,0 +1,85 @@
import base64
from pathlib import Path
from vllm_omni.experimental.fullduplex.client import (
PCM16_BYTES_PER_SAMPLE,
PCM16_SAMPLE_RATE,
RealtimeDuplexClient,
RealtimeEventCollector,
build_realtime_url,
read_pcm16_wav,
wait_for,
)
def _events(events: list[dict[str, object]], response_id: str, kind: str) -> list[dict[str, object]]:
return [e for e in events if e.get("type") == kind and RealtimeEventCollector.response_id(e) == response_id]
async def run_server_vad_interrupt(args) -> dict[str, object]:
initial = read_pcm16_wav(Path(args.input_wav))
interrupt = read_pcm16_wav(Path(args.interrupt_wav))[: PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * 47 // 10]
session_id = f"server-vad-hard-interrupt-{id(args)}"
client = RealtimeDuplexClient(build_realtime_url(args.url, args.model, autostart=False, session_id=session_id))
async def until(predicate, label: str) -> None:
await wait_for(predicate, timeout_s=args.timeout_s, label=label)
try:
await client.__aenter__()
await client.configure(
args.model,
ref_audio="data:audio/wav;base64," + base64.b64encode(Path(args.ref_audio).read_bytes()).decode(),
session_id=session_id,
turn_detection={"type": "server_vad", "interrupt_response": True},
timeout_s=args.timeout_s,
)
await client.stream_pcm16(initial + bytes(16_000 * 2 * 1600 // 1000), chunk_ms=200, realtime=True)
await client.commit()
await until(lambda: any(e.get("type") == "response.created" for e in client.events.events), "response.created")
target_id = RealtimeEventCollector.response_id(
next(e for e in client.events.events if e.get("type") == "response.created")
)
assert target_id
await until(lambda: _events(client.events.events, target_id, "response.audio.delta"), "response.audio.delta")
cursor = len(client.events.events)
await client.stream_pcm16(interrupt + bytes(16_000 * 2 * 800 // 1000), chunk_ms=args.chunk_ms, realtime=True)
await until(lambda: _events(client.events.events, target_id, "response.done"), "cancelled response.done")
await client.commit()
await until(
lambda: any(
e.get("type") == "response.done" and RealtimeEventCollector.response_id(e) != target_id
for e in client.events.events[cursor:]
),
"follow-up response.done",
)
events = client.events.events
finally:
try:
await client.close_session(timeout_s=args.timeout_s)
finally:
await client.__aexit__(None, None, None)
done = _events(events, target_id, "response.done")
terminal = done[0] if len(done) == 1 else None
trailing = events[events.index(terminal) + 1 :] if terminal is not None else events
stale = any(
e.get("type") == "response.audio.delta" and RealtimeEventCollector.response_id(e) == target_id for e in trailing
)
followup_audio = any(
e.get("type") == "response.audio.delta" and RealtimeEventCollector.response_id(e) != target_id
for e in events[cursor:]
)
ok = (
terminal is not None
and isinstance(terminal.get("response"), dict)
and terminal["response"].get("status") == "cancelled"
and terminal["response"].get("status_details", {}).get("reason") == "turn_detected"
and not stale
and sum(e.get("type") == "input_audio_buffer.speech_started" for e in events[cursor:]) == 1
and sum(e.get("type") == "input_audio_buffer.speech_stopped" for e in events[cursor:]) == 1
and any(e.get("type") == "input_audio_buffer.committed" for e in events[cursor:])
and followup_audio
and not any(e.get("type") == "error" for e in events)
)
return {"ok": ok}
@@ -252,6 +252,19 @@ def summarize_artifacts(
(summary["done_indices"][0] for summary in reversed(response_summaries) if summary.get("done_indices")),
None,
)
# A soft interrupt's follow-up response can drain past the commit (residual
# model unit), so its response.done lands after input_audio_buffer.committed.
# Anchor the "listen after last done, before commit" sandwich on the last
# turn that closed its floor strictly before the commit, not the
# globally-last done (which may sit after the commit and leave the interval empty).
last_done_before_commit_index = next(
(
summary["done_indices"][0]
for summary in reversed(response_summaries)
if summary.get("done_indices") and commit_index is not None and summary["done_indices"][0] < commit_index
),
None,
)
listen_indices = [index for index, event in enumerate(events) if event.get("type") == "response.listen"]
effective_min_responses = min_responses if validation_mode == "response-required" else 1
enough_responses = len(response_summaries) >= effective_min_responses
@@ -279,9 +292,9 @@ def summarize_artifacts(
)
listen_after_last_done = last_done_index is not None and any(index > last_done_index for index in listen_indices)
listen_after_response_before_commit = (
last_done_index is not None
last_done_before_commit_index is not None
and commit_index is not None
and any(last_done_index < index < commit_index for index in listen_indices)
and any(last_done_before_commit_index < index < commit_index for index in listen_indices)
)
final_listen_after_commit = commit_index is not None and any(index > commit_index for index in listen_indices)
transcript = "".join(str(summary.get("transcript") or "") for summary in response_summaries)
@@ -1,63 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E online serving test for AudioX text-to-audio diffusion.
Mirrors `tests/e2e/online_serving/test_sd3_expansion.py` for image diffusion: spin up
`vllm-omni` with `--model-class-name AudioXPipeline` and validate via the standard
`send_diffusion_request` helper (now audio-aware).
"""
import os
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
# Tiny / random checkpoint usable in CI; override to a real bundle locally.
AUDIOX_TEST_MODEL = os.environ.get("AUDIOX_TEST_MODEL", "zhangj1an/audiox_random")
T2A_PROMPT = "A quiet living room with soft fabric rustle and gentle cat breathing."
SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "L4"})
def _audiox_server_cases(model: str):
return [
pytest.param(
OmniServerParams(
model=model,
server_args=["--model-class-name", "AudioXPipeline"],
),
id="t2a",
marks=SINGLE_CARD_FEATURE_MARKS,
),
]
@pytest.mark.slow
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _audiox_server_cases(AUDIOX_TEST_MODEL), indirect=True)
def test_audiox_t2a_online(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
"""AudioX text-to-audio: chat completion returns a non-empty WAV in `message.audio.data`.
AudioX is registered in ``vllm_omni/model_extras`` (``AudioXPipeline``), so the
``audiox_task`` / ``seconds_*`` / ``sigma_*`` keys below flow through the standard
``apply_declared_extra_args`` server path into ``sampling_params.extra_args`` rather
than the old top-level ``extra_args`` escape hatch.
"""
request_config = {
"model": omni_server.model,
"messages": dummy_messages_from_mix_data(content_text=T2A_PROMPT),
"extra_body": {
"num_inference_steps": 4,
"guidance_scale": 6.0,
"seed": 42,
"audiox_task": "t2a",
"seconds_start": 0.0,
"seconds_total": 2.0,
"sigma_min": 0.03,
"sigma_max": 1000.0,
},
}
openai_client.send_diffusion_request(request_config)
+7 -9
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
"""
End-to-end online serving test for Bagel text2img and img2img generation.
@@ -75,12 +75,11 @@ def _build_img2img_messages(prompt: str, image_b64: str) -> list[dict]:
]
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"})
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_bagel_text2img_online(omni_server, openai_client) -> None:
def test_bagel_text2img_online(omni_server, online_client) -> None:
"""Test Bagel text2img via OpenAI-compatible chat completions API."""
request_config = {
"model": omni_server.model,
@@ -95,15 +94,14 @@ def test_bagel_text2img_online(omni_server, openai_client) -> None:
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_bagel_img2img_online(omni_server, openai_client) -> None:
def test_bagel_img2img_online(omni_server, online_client) -> None:
"""Test Bagel img2img via OpenAI-compatible chat completions API."""
input_image = ImageAsset("2560px-Gfp-wisconsin-madison-the-nature-boardwalk").pil_image.convert("RGB")
buffer = BytesIO()
@@ -121,4 +119,4 @@ def test_bagel_img2img_online(omni_server, openai_client) -> None:
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Comprehensive tests of diffusion features that are available in online serving mode
and are supported by the following models:
@@ -19,10 +22,10 @@ assert_diffusion_response validates successful generation and the expected
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient, dummy_messages_from_mix_data
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
PROMPT = "A futuristic city skyline at twilight, cyberpunk style, ultra-detailed, high resolution."
NEGATIVE_PROMPT = "low quality, blurry, distorted, deformed, watermark"
@@ -183,7 +186,7 @@ def _get_diffusion_feature_cases(model: str):
)
def test_bagel(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
):
"""L4 diffusion feature coverage for Bagel on H100.
@@ -218,4 +221,4 @@ def test_bagel(
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@@ -1,6 +1,11 @@
"""Nightly-only e2e coverage for BAGEL diffusion multi-replica serving.
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Weekly e2e coverage for BAGEL diffusion multi-replica serving.
This test needs 4 H100 GPUs; keep it out of generic test_bagel_* jobs.
It is covered by the weekly Diffusion · H100 · Multi-GPU lane
(``.buildkite/cuda/test-weekly.yml``, ``mirror_hardwares: h100_4``).
"""
import os
@@ -17,7 +22,7 @@ MODEL = "ByteDance-Seed/BAGEL-7B-MoT"
MULTI_REPLICA_DEPLOY = get_deploy_config_path("ci/bagel_multi_replicas_4gpu.yaml")
ROUTE_STRESS_REQUESTS = 6
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
test_params = [
OmniServerParams(
@@ -31,7 +36,7 @@ test_params = [
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_multi_stage_diffusion_uses_multi_replica_dit_stage(omni_server, openai_client) -> None:
def test_multi_stage_diffusion_uses_multi_replica_dit_stage(omni_server, online_client) -> None:
request_config = {
"model": omni_server.model,
"messages": dummy_messages_from_mix_data(
@@ -47,7 +52,7 @@ def test_multi_stage_diffusion_uses_multi_replica_dit_stage(omni_server, openai_
},
}
responses = openai_client.send_diffusion_request(request_config, request_num=ROUTE_STRESS_REQUESTS)
responses = online_client.send_diffusion_request(request_config, request_num=ROUTE_STRESS_REQUESTS)
assert len(responses) == ROUTE_STRESS_REQUESTS
assert all(response.success for response in responses)
+2 -2
View File
@@ -16,7 +16,7 @@ parity vs. the Diffusers baseline is covered separately (see the support plan, s
From ``tests/``::
pytest -s -v e2e/online_serving/test_boogu_image.py \
-m "full_model and diffusion and H100" --run-level=full_model
-m "slow and diffusion and H100"
"""
import os
@@ -28,7 +28,7 @@ from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHand
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
MODEL = "Boogu/Boogu-Image-0.1-Base"
T2I_PROMPT = "A mountain lake at sunset, photorealistic, cinematic lighting"
@@ -19,7 +19,7 @@ reference image is supported.
From ``tests/``::
pytest -s -v e2e/online_serving/test_boogu_image_edit.py \
-m "full_model and diffusion and H100" --run-level=full_model
-m "slow and diffusion and H100"
"""
import base64
@@ -35,7 +35,7 @@ from tests.helpers.runtime import (
dummy_messages_from_mix_data,
)
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
EDIT_PROMPT = "Change the style to a colored pencil drawing."
SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"})
@@ -25,7 +25,7 @@ model and are intentionally omitted.
From ``tests/``::
pytest -s -v e2e/online_serving/test_boogu_image_edit_expansion.py \
-m "full_model and diffusion and H100" --run-level=full_model
-m "slow and diffusion and H100"
"""
import os
@@ -38,7 +38,7 @@ from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHand
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
MODEL = "Boogu/Boogu-Image-0.1-Edit"
EDIT_PROMPT = "Change the style to a colored pencil drawing."
@@ -30,7 +30,7 @@ omitted (add rows here when those features land).
From ``tests/``::
pytest -s -v e2e/online_serving/test_boogu_image_expansion.py \
-m "full_model and diffusion and H100" --run-level=full_model
-m "slow and diffusion and H100"
"""
import os
@@ -42,7 +42,7 @@ from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHand
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
MODEL = "Boogu/Boogu-Image-0.1-Base"
T2I_PROMPT = "A mountain lake at sunset, photorealistic, cinematic lighting"
+9 -8
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
"""
Online serving L2 smoke tests for ``nvidia/Cosmos3-Nano``.
@@ -8,6 +8,7 @@ broader image/video similarity checks stay in ``tests/e2e/accuracy``.
"""
import os
from typing import Any
import pytest
@@ -16,7 +17,7 @@ from tests.helpers.media import generate_synthetic_image, generate_synthetic_vid
from tests.helpers.runtime import (
OmniServer,
OmniServerParams,
OpenAIClientHandler,
OnlineOmniClient,
)
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
@@ -56,9 +57,9 @@ def _get_diffusion_feature_cases(model: str):
@pytest.mark.core_model
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
def test_text_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
def test_text_to_image_001(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
"""Default Cosmos3 T2I smoke through ``/v1/images/generations``."""
responses = openai_client.send_images_generations_http_request(
responses = online_client.send_images_generations_http_request(
{
"json": {
"model": omni_server.model,
@@ -85,12 +86,12 @@ def test_text_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientH
@pytest.mark.core_model
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True)
def test_video_generation_modes_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
def test_video_generation_modes_001(omni_server: OmniServer, online_client: OnlineOmniClient) -> None:
"""Default Cosmos3 T2V, I2V, and V2V smoke through the async ``/v1/videos`` endpoint."""
image_reference = f"data:image/jpeg;base64,{generate_synthetic_image(256, 256, seed=42)['base64']}"
video_reference = f"data:video/mp4;base64,{generate_synthetic_video(256, 256, 5)['base64']}"
video_cases = [
video_cases: list[dict[str, Any]] = [
{
"form_data": {
**VIDEO_SHAPE,
@@ -118,8 +119,8 @@ def test_video_generation_modes_001(omni_server: OmniServer, openai_client: Open
]
for case in video_cases:
request_config = {
request_config: dict[str, Any] = {
"model": omni_server.model,
**case,
}
openai_client.send_video_diffusion_request(request_config)
online_client.send_video_diffusion_request(request_config)
@@ -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
"""
E2E Online tests for CosyVoice3 TTS model with voice cloning.
@@ -14,18 +14,26 @@ os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import load_test_audio_data_url
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.slow, pytest.mark.tts]
if current_omni_platform.is_rocm():
os.environ.setdefault("COSYVOICE3_TRT", "0")
pytestmark = [
pytest.mark.slow,
pytest.mark.tts,
pytest.mark.core_model,
]
MODEL = "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
# Official CosyVoice zero-shot prompt audio and its transcript. Vendored under
# tests/assets/ so the server does not depend on raw.githubusercontent.com being
# reachable at request time (same rationale as issue #3263 for Qwen3-TTS).
REF_AUDIO_URL = load_test_audio_data_url("cosyvoice3/zero_shot_prompt.wav")
REF_AUDIO_URL = get_asset_path("cosyvoice3/zero_shot_prompt.wav", as_data_url=True)
REF_TEXT = "希望你以后能够做的比我还好呦。"
@@ -42,6 +50,12 @@ def get_prompt(prompt_type="zh"):
return prompts.get(prompt_type, prompts["zh"])
def _set_rocm_request_timeout(request_config: dict) -> None:
if current_omni_platform.is_rocm():
# Cold AITER MHA builds on loaded MI300 agents can exceed 20 minutes.
request_config["timeout"] = 1800.0
tts_server_params = [
pytest.param(
OmniServerParams(
@@ -65,10 +79,9 @@ tts_async_chunk_server_params = [
]
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4644")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_voice_clone_zh_001(omni_server, openai_client) -> None:
def test_voice_clone_zh_001(omni_server, online_client) -> None:
"""
Test voice cloning TTS with Chinese text via OpenAI API.
Deploy Setting: default yaml
@@ -85,13 +98,13 @@ def test_voice_clone_zh_001(omni_server, openai_client) -> None:
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config)
_set_rocm_request_timeout(request_config)
online_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4644")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_async_chunk_server_params, indirect=True)
def test_voice_clone_zh_002(omni_server, openai_client) -> None:
def test_voice_clone_zh_002(omni_server, online_client) -> None:
"""
Test voice cloning TTS with Chinese text via async_chunk streaming.
Deploy Setting: cosyvoice3.yaml with default ``async_chunk: true``
@@ -109,13 +122,13 @@ def test_voice_clone_zh_002(omni_server, openai_client) -> None:
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config)
_set_rocm_request_timeout(request_config)
online_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4644")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_voice_clone_en_001(omni_server, openai_client) -> None:
def test_voice_clone_en_001(omni_server, online_client) -> None:
"""
Test voice cloning TTS with English text via OpenAI API.
Deploy Setting: default yaml
@@ -132,4 +145,5 @@ def test_voice_clone_en_001(omni_server, openai_client) -> None:
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config)
_set_rocm_request_timeout(request_config)
online_client.send_audio_speech_request(request_config)
@@ -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
"""
E2E Online tests for Covo-Audio-Chat model.
"""
@@ -37,7 +37,7 @@ test_params = [
@pytest.mark.omni
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_audio_to_audio_001(omni_server, openai_client) -> None:
def test_audio_to_audio_001(omni_server, online_client) -> None:
"""
Input Modal: text + audio
Output Modal: audio
@@ -58,4 +58,4 @@ def test_audio_to_audio_001(omni_server, openai_client) -> None:
"stream": False,
}
openai_client.send_omni_request(request_config)
online_client.send_omni_request(request_config)
@@ -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
"""
Example online tests for Dynin-Omni model.
"""
@@ -64,7 +64,7 @@ def _build_i2i_messages(prompt: str) -> list[dict]:
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
def test_send_i2i_request_001(omni_server, openai_client) -> None:
def test_send_i2i_request_001(omni_server, online_client) -> None:
request_config = {
"model": omni_server.model,
"messages": _build_i2i_messages(I2I_PROMPT),
@@ -73,27 +73,27 @@ def test_send_i2i_request_001(omni_server, openai_client) -> None:
"sampling_params_list": [dict(_I2I_STAGE_SAMPLING) for _ in range(_STAGE_COUNT)],
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
def test_send_t2i_request_001(omni_server, openai_client) -> None:
def test_send_t2i_request_001(omni_server, online_client) -> None:
request_config = {
"model": omni_server.model,
"messages": _build_t2i_messages(T2I_PROMPT),
"modalities": ["image"],
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
def test_send_t2s_request_001(omni_server, openai_client) -> None:
def test_send_t2s_request_001(omni_server, online_client) -> None:
request_config = {
"model": omni_server.model,
"messages": _build_t2s_messages(T2S_PROMPT),
"modalities": ["audio"],
"audio_ref_text": T2S_PROMPT,
}
openai_client.send_omni_request(request_config)
online_client.send_omni_request(request_config)
@@ -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
"""
End-to-end test for ErnieImage text-to-image generation via online serving.
@@ -11,7 +11,7 @@ from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import (
OmniServer,
OmniServerParams,
OpenAIClientHandler,
OnlineOmniClient,
dummy_messages_from_mix_data,
)
@@ -45,7 +45,7 @@ def _get_diffusion_feature_cases(model: str):
_get_diffusion_feature_cases(MODEL),
indirect=True,
)
def test_ernie_image(omni_server: OmniServer, openai_client: OpenAIClientHandler):
def test_ernie_image(omni_server: OmniServer, online_client: OnlineOmniClient):
"""Test each diffusion feature with ERNIE-Image (text-to-image), one feature per case."""
messages = dummy_messages_from_mix_data(content_text=T2I_PROMPT)
request_config = {
@@ -60,4 +60,4 @@ def test_ernie_image(omni_server: OmniServer, openai_client: OpenAIClientHandler
"seed": 42,
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Tests for Flux2 Klein; currently Dev is implemented separately,
but ideally these models will fold together in the future.
@@ -15,7 +18,7 @@ from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import (
OmniServer,
OmniServerParams,
OpenAIClientHandler,
OnlineOmniClient,
dummy_messages_from_mix_data,
)
@@ -113,7 +116,7 @@ def _get_diffusion_feature_cases(model: str):
),
indirect=True,
)
def test_flux2_klein(omni_server: OmniServer, openai_client: OpenAIClientHandler):
def test_flux2_klein(omni_server: OmniServer, online_client: OnlineOmniClient):
messages = dummy_messages_from_mix_data(content_text=POSITIVE_PROMPT)
request_config = {
"model": omni_server.model,
@@ -128,4 +131,4 @@ def test_flux2_klein(omni_server: OmniServer, openai_client: OpenAIClientHandler
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
End-to-end diffusion coverage for FLUX.2-dev in online serving mode.
@@ -19,7 +22,7 @@ image resolution.
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient, dummy_messages_from_mix_data
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
@@ -116,7 +119,7 @@ def _get_flux_2_dev_feature_cases(model: str):
)
def test_flux_2_dev(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
):
"""Validate FLUX.2-dev online serving with CPU offload."""
@@ -135,7 +138,7 @@ def test_flux_2_dev(
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@pytest.mark.parametrize(
@@ -145,14 +148,14 @@ def test_flux_2_dev(
)
def test_flux_2_dev_batched_chat_completions(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
):
"""Validate batched chat completions for diffusion models."""
messages = [
[{"role": "user", "content": PROMPT}],
[{"role": "user", "content": "A sunset over the ocean."}],
]
responses = openai_client.send_batched_chat_completions_http_request(
responses = online_client.send_batched_chat_completions_http_request(
{
"json": {
"model": omni_server.model,
@@ -167,5 +170,7 @@ def test_flux_2_dev_batched_chat_completions(
assert responses and len(responses) == 1
resp = responses[0]
assert resp.success
choices = resp.json_body["choices"]
body = resp.json_body
assert isinstance(body, dict)
choices = body["choices"]
assert len(choices) == len(messages)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Comprehensive tests of diffusion features that are available in online serving mode
and are supported by the FluxKontext model.
@@ -7,7 +10,7 @@ import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient, dummy_messages_from_mix_data
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
@@ -32,7 +35,7 @@ def _get_diffusion_feature_cases(model: str):
_get_diffusion_feature_cases(MODEL),
indirect=True,
)
def test_flux_kontext_text_to_image(omni_server: OmniServer, openai_client: OpenAIClientHandler):
def test_flux_kontext_text_to_image(omni_server: OmniServer, online_client: OnlineOmniClient):
"""Test text-to-image generation with FluxKontext in regular end-user scenarios."""
messages = dummy_messages_from_mix_data(content_text="A photo of a cat sitting on a laptop keyboard")
@@ -47,7 +50,7 @@ def test_flux_kontext_text_to_image(omni_server: OmniServer, openai_client: Open
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@pytest.mark.parametrize(
@@ -55,7 +58,7 @@ def test_flux_kontext_text_to_image(omni_server: OmniServer, openai_client: Open
_get_diffusion_feature_cases(MODEL),
indirect=True,
)
def test_flux_kontext_image_edit(omni_server: OmniServer, openai_client: OpenAIClientHandler):
def test_flux_kontext_image_edit(omni_server: OmniServer, online_client: OnlineOmniClient):
"""Test image editing with FluxKontext in regular end-user scenarios."""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
@@ -74,7 +77,7 @@ def test_flux_kontext_image_edit(omni_server: OmniServer, openai_client: OpenAIC
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@pytest.mark.parametrize(
@@ -82,7 +85,7 @@ def test_flux_kontext_image_edit(omni_server: OmniServer, openai_client: OpenAIC
_get_diffusion_feature_cases(MODEL),
indirect=True,
)
def test_flux_kontext_image_edit_no_negative(omni_server: OmniServer, openai_client: OpenAIClientHandler):
def test_flux_kontext_image_edit_no_negative(omni_server: OmniServer, online_client: OnlineOmniClient):
"""Test image editing with FluxKontext without negative prompt."""
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
@@ -99,7 +102,7 @@ def test_flux_kontext_image_edit_no_negative(omni_server: OmniServer, openai_cli
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@pytest.mark.parametrize(
@@ -107,7 +110,7 @@ def test_flux_kontext_image_edit_no_negative(omni_server: OmniServer, openai_cli
_get_diffusion_feature_cases(MODEL),
indirect=True,
)
def test_flux_kontext_high_resolution(omni_server: OmniServer, openai_client: OpenAIClientHandler):
def test_flux_kontext_high_resolution(omni_server: OmniServer, online_client: OnlineOmniClient):
"""Test high-resolution generation with FluxKontext."""
messages = dummy_messages_from_mix_data(content_text="A beautiful landscape with mountains and a lake")
@@ -122,7 +125,7 @@ def test_flux_kontext_high_resolution(omni_server: OmniServer, openai_client: Op
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@pytest.mark.parametrize(
@@ -130,7 +133,7 @@ def test_flux_kontext_high_resolution(omni_server: OmniServer, openai_client: Op
_get_diffusion_feature_cases(MODEL),
indirect=True,
)
def test_flux_kontext_multiple_outputs(omni_server: OmniServer, openai_client: OpenAIClientHandler):
def test_flux_kontext_multiple_outputs(omni_server: OmniServer, online_client: OnlineOmniClient):
"""Test generating multiple outputs with FluxKontext."""
messages = dummy_messages_from_mix_data(content_text="A photo of a cat sitting on a laptop keyboard")
@@ -146,4 +149,4 @@ def test_flux_kontext_multiple_outputs(omni_server: OmniServer, openai_client: O
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Comprehensive tests of diffusion features that are available in online serving mode
and are supported by the following model:
@@ -25,12 +28,12 @@ from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import (
OmniServer,
OmniServerParams,
OpenAIClientHandler,
OnlineOmniClient,
dummy_messages_from_mix_data,
)
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
MODEL = os.environ.get("GLM_IMAGE_MODEL_PATH", "zai-org/GLM-Image")
@@ -152,7 +155,7 @@ MODES = ["t2i", "i2i"]
def test_glm_image(
omni_server: OmniServer,
mode: str,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
):
"""GLM-Image diffusion feature coverage for t2i and i2i on two H100s."""
@@ -180,4 +183,4 @@ def test_glm_image(
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@@ -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
"""
E2E expansion tests for GLM-TTS online serving (nightly CI).
@@ -14,7 +14,7 @@ os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import load_test_audio_data_url
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
@@ -24,7 +24,7 @@ MODEL = os.environ.get("GLM_TTS_MODEL_PATH", "zai-org/GLM-TTS")
REF_TEXT = "他当时还跟线下其他的站姐吵架,然后,打架进局子了。"
DEPLOY_CONFIG = get_deploy_config_path("glm_tts.yaml")
REF_AUDIO_URL = load_test_audio_data_url("glm_tts/jiayan_zh.wav")
REF_AUDIO_URL = get_asset_path("glm_tts/jiayan_zh.wav", as_data_url=True)
SYNC_EXTRA_ARGS = [
"--trust-remote-code",
@@ -64,7 +64,7 @@ tts_async_chunk_server_params = [
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_sync_server_params, indirect=True)
def test_voice_clone_zh_sync(omni_server, openai_client) -> None:
def test_voice_clone_zh_sync(omni_server, online_client) -> None:
"""
Test voice cloning TTS with Chinese text.
Deploy Setting: glm_tts.yaml with ``--no-async-chunk`` (sync two-stage)
@@ -80,12 +80,12 @@ def test_voice_clone_zh_sync(omni_server, openai_client) -> None:
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config)
online_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_async_chunk_server_params, indirect=True)
def test_voice_clone_zh_async_chunk(omni_server, openai_client) -> None:
def test_voice_clone_zh_async_chunk(omni_server, online_client) -> None:
"""
Test voice cloning TTS with Chinese text via async_chunk streaming.
Deploy Setting: glm_tts.yaml default ``async_chunk: true``
@@ -102,12 +102,12 @@ def test_voice_clone_zh_async_chunk(omni_server, openai_client) -> None:
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config)
online_client.send_audio_speech_request(request_config)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_sync_server_params, indirect=True)
def test_models_endpoint(omni_server, openai_client) -> None:
def test_models_endpoint(omni_server, online_client) -> None:
"""Test the /v1/models endpoint returns loaded model."""
models = openai_client.client.models.list()
models = online_client.client.models.list()
assert len(models.data) > 0
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
End-to-end diffusion coverage for HiDream-I1-Full in online serving mode.
@@ -12,7 +15,7 @@ image resolution.
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient, dummy_messages_from_mix_data
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
@@ -49,7 +52,7 @@ def _get_hidream_i1_image_feature_cases(model: str):
@pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/4662")
def test_hidream_i1_image(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
):
"""Validate HiDream-I1-Full online serving with CPU offload."""
@@ -67,4 +70,4 @@ def test_hidream_i1_image(
},
}
openai_client.send_diffusion_request(request_config)
online_client.send_diffusion_request(request_config)
@@ -1,17 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
E2E Online expansion tests for higgs-audio v2 against /v1/audio/speech.
v1 scope is plain text -> 24 kHz speech plus shallow voice clone via
ref_audio + ref_text (inline) or voice=<name> (after POST /v1/audio/voices).
The model-aware request validator
(vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v2::validate)
rejects multi-speaker tags, language overrides, task_type, and bare
voice=<name> for names that do not match an uploaded speaker — this suite
exercises both the happy path (plain text in, audio bytes out) and the
validator rejections, so a regression that loosens the schema will fail
loudly.
Model-aware validator rejections live in L1
``tests/entrypoints/openai_api/test_tts_adapter.py``
(``test_higgs_audio_v2_validate_*``).
"""
from __future__ import annotations
@@ -27,7 +23,7 @@ os.environ.setdefault("VLLM_MOE_USE_DEEP_GEMM", "0")
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import load_test_audio_data_url
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
@@ -63,7 +59,7 @@ _MIN_AUDIO_BYTES = 20_000
# speech) + its transcript. See tests/e2e/online_serving/test_qwen3_tts_base.py
# for the asset rationale — keeping a single shared reference clip across TTS
# tests avoids duplicating WAVs in the repo.
_REF_AUDIO_URL = load_test_audio_data_url("qwen3_tts/clone_2.wav")
_REF_AUDIO_URL = get_asset_path("qwen3_tts/clone_2.wav", as_data_url=True)
_REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
@@ -72,9 +68,9 @@ class TestHiggsAudioV2OnlineHappyPath:
"""Plain-text -> audio happy paths against the live HTTP server."""
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_plain_text_wav(self, omni_server, openai_client) -> None:
def test_plain_text_wav(self, omni_server, online_client) -> None:
"""Single non-streaming WAV request — covers the canonical TTS happy path."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
@@ -86,7 +82,7 @@ class TestHiggsAudioV2OnlineHappyPath:
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_plain_text_pcm_streaming(self, omni_server, openai_client) -> None:
def test_plain_text_pcm_streaming(self, omni_server, online_client) -> None:
"""Streaming PCM via the shared-memory connector's codec_streaming path.
higgs_audio_v2.yaml pins ``codec_streaming: true`` + ``async_chunk: false``,
@@ -102,7 +98,7 @@ class TestHiggsAudioV2OnlineHappyPath:
content correctness is separately guarded by the full_model transcript
check.
"""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Streaming the quick brown fox over the lazy dog.",
@@ -116,9 +112,9 @@ class TestHiggsAudioV2OnlineHappyPath:
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_plain_text_with_max_new_tokens(self, omni_server, openai_client) -> None:
def test_plain_text_with_max_new_tokens(self, omni_server, online_client) -> None:
"""max_new_tokens is one of the few extra fields the higgs validator accepts."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Innovation distinguishes between a leader and a follower.",
@@ -131,9 +127,9 @@ class TestHiggsAudioV2OnlineHappyPath:
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_concurrent_plain_text(self, omni_server, openai_client) -> None:
def test_concurrent_plain_text(self, omni_server, online_client) -> None:
"""Three concurrent non-streaming requests — guards the per-slot audio state."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "It was the night before my birthday.",
@@ -146,104 +142,6 @@ class TestHiggsAudioV2OnlineHappyPath:
)
# ---------------------------------------------------------------------------
# Validator rejections served over HTTP. Each case targets one out-of-scope
# field; the validator returns 4xx via the OpenAI error path. We keep the
# error-message substring loose so a phrasing tweak in the validator does not
# break CI, but tight enough to catch a regression that silently accepts the
# field.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
class TestHiggsAudioV2OnlineValidatorRejections:
"""Out-of-scope fields must come back as 4xx with a higgs-named message."""
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_rejects_ref_audio_without_ref_text(self, omni_server, openai_client) -> None:
"""Voice clone needs the transcript too — half-supplied is a 4xx."""
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
"ref_audio": _REF_AUDIO_URL,
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"status_code": (400, 422),
"err_message": "ref_text",
}
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_rejects_ref_text_without_ref_audio(self, omni_server, openai_client) -> None:
"""Symmetric guard: ref_text alone is not enough — must come with ref_audio."""
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
"ref_text": "some transcript",
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"status_code": (400, 422),
"err_message": "ref_audio",
}
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_rejects_task_type(self, omni_server, openai_client) -> None:
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
"task_type": "Base",
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"status_code": (400, 422),
"err_message": "task_type",
}
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_rejects_language_override(self, omni_server, openai_client) -> None:
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
"language": "Chinese",
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"status_code": (400, 422),
"err_message": "language",
}
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_rejects_multi_speaker_tag_in_text(self, omni_server, openai_client) -> None:
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "[SPEAKER0] hi",
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"status_code": (400, 422),
"err_message": "multi-speaker",
}
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_rejects_empty_input(self, omni_server, openai_client) -> None:
openai_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": " ",
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"status_code": (400, 422),
"err_message": "empty",
}
)
@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True)
class TestHiggsAudioV2OnlineVoiceClone:
"""Shallow voice clone: ref_audio + ref_text -> speech in the cloned voice.
@@ -256,9 +154,9 @@ class TestHiggsAudioV2OnlineVoiceClone:
"""
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voice_clone_basic(self, omni_server, openai_client) -> None:
def test_voice_clone_basic(self, omni_server, online_client) -> None:
"""Single non-streaming WAV request driven by the qwen3_tts ref clip."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
@@ -272,9 +170,9 @@ class TestHiggsAudioV2OnlineVoiceClone:
)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voice_clone_pcm_streaming(self, omni_server, openai_client) -> None:
def test_voice_clone_pcm_streaming(self, omni_server, online_client) -> None:
"""Voice clone over the streaming PCM path."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Innovation distinguishes a leader from a follower.",
+59 -61
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
"""End-to-end online tests for higgs-audio v3 against /v1/audio/speech.
Mirrors the higgs_audio_v2 test layout. Covers the plain-text-in / audio-out
@@ -21,7 +21,7 @@ os.environ.setdefault("VLLM_MOE_USE_DEEP_GEMM", "0")
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import load_test_audio_data_url
from tests.helpers.media import get_asset_path
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
@@ -51,7 +51,7 @@ _MIN_AUDIO_BYTES = 20_000
# Reuse the shared TTS reference clip (clean ~5 s 24 kHz mono human speech)
# vendored under tests/assets/qwen3_tts/. Keeps a single WAV across TTS
# tests rather than duplicating asset bytes.
_REF_AUDIO_URL = load_test_audio_data_url("qwen3_tts/clone_2.wav")
_REF_AUDIO_URL = get_asset_path("qwen3_tts/clone_2.wav", as_data_url=True)
_REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
@@ -59,13 +59,12 @@ _REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know w
class TestHiggsAudioV3OnlineHappyPath:
"""Plain-text -> audio happy paths against the live HTTP server."""
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_plain_text_wav(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_plain_text_wav(self, omni_server, online_client) -> None:
"""Single non-streaming WAV request - canonical TTS happy path."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
@@ -79,31 +78,29 @@ class TestHiggsAudioV3OnlineHappyPath:
}
)
@pytest.mark.core_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_plain_text_with_max_new_tokens(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_plain_text_with_max_new_tokens(self, omni_server, online_client) -> None:
"""``max_new_tokens`` is one of the few extra fields the v3 validator accepts."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Innovation distinguishes between a leader and a follower.",
"stream": False,
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"max_new_tokens": 32,
"max_new_tokens": 1024,
"min_audio_bytes": _MIN_AUDIO_BYTES,
}
)
@pytest.mark.skip(reason="issue#4411")
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_concurrent_plain_text(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_concurrent_plain_text(self, omni_server, online_client) -> None:
"""Three concurrent non-streaming requests - guards per-slot audio state and Stage-0 PC under batching."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "it was the night before my birthday",
@@ -114,11 +111,10 @@ class TestHiggsAudioV3OnlineHappyPath:
request_num=3,
)
@pytest.mark.skip(reason="issue#4411")
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_plain_text_pcm_streaming(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_plain_text_pcm_streaming(self, omni_server, online_client) -> None:
"""Streaming PCM via talker2code2wav_async_chunk + the connector's codec_streaming path.
The deploy YAML pins ``async_chunk: true`` and ``codec_streaming: true`` so chunks
@@ -134,7 +130,7 @@ class TestHiggsAudioV3OnlineHappyPath:
~0.16 dB margin over measured single-request output (1.16 dB on L4), so
flake risk is too high there.
"""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "The quick brown fox jumps over the lazy dog.",
@@ -146,11 +142,10 @@ class TestHiggsAudioV3OnlineHappyPath:
}
)
@pytest.mark.skip(reason="issue#4411")
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_concurrent_pcm_streaming(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_concurrent_pcm_streaming(self, omni_server, online_client) -> None:
"""Three concurrent streaming requests - guards per-request frame cursors
in ``talker2code2wav_async_chunk`` and per-slot delay-pattern state under batched AR.
@@ -160,7 +155,7 @@ class TestHiggsAudioV3OnlineHappyPath:
over the measured worst-of-3 on L4 (-0.42 dB) while still well above the
catastrophic-failure region.
"""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "She sells seashells by the seashore.",
@@ -184,17 +179,16 @@ class TestHiggsAudioV3OnlineInlineControlTokens:
tokens (pause, sfx) go inline. SFX tokens must pair with their written
onomatopoeia.
These tests check the *serving surface* - the validator accepts the
payload, the engine produces audio, and the WAV has non-trivial size.
They do not assert audio quality (out of scope for CI).
These tests check the serving surface and spoken text. They do not assert
whether the requested control changes audio quality.
"""
@pytest.mark.core_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_inline_emotion_and_expressive(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_inline_emotion_and_expressive(self, omni_server, online_client) -> None:
"""Delivery tokens (emotion + expressive_high) at the start of input."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": ("<|emotion:amusement|><|prosody:expressive_high|>Wait, that was actually hilarious."),
@@ -202,15 +196,16 @@ class TestHiggsAudioV3OnlineInlineControlTokens:
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_AUDIO_BYTES,
"transcript_expected_text": "Wait, that was actually hilarious.",
}
)
@pytest.mark.core_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_inline_style_whispering(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_inline_style_whispering(self, omni_server, online_client) -> None:
"""Style token at the start - ``whispering``."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "<|style:whispering|>It is just between you and me, alright?",
@@ -218,15 +213,16 @@ class TestHiggsAudioV3OnlineInlineControlTokens:
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_AUDIO_BYTES,
"transcript_expected_text": "It is just between you and me, alright?",
}
)
@pytest.mark.core_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_inline_prosody_speed_and_pitch(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_inline_prosody_speed_and_pitch(self, omni_server, online_client) -> None:
"""Two prosody tokens at the start - slow speed plus low pitch."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": (
@@ -236,15 +232,16 @@ class TestHiggsAudioV3OnlineInlineControlTokens:
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_AUDIO_BYTES,
"transcript_expected_text": "The radar shows a storm approaching from the east.",
}
)
@pytest.mark.core_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_inline_pause_mid_text(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_inline_pause_mid_text(self, omni_server, online_client) -> None:
"""Positional pause token placed inline between two clauses."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hold on a moment <|prosody:pause|> let me think about it.",
@@ -252,15 +249,16 @@ class TestHiggsAudioV3OnlineInlineControlTokens:
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_AUDIO_BYTES,
"transcript_expected_text": "Hold on a moment. Let me think about it.",
}
)
@pytest.mark.core_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_inline_sfx_with_onomatopoeia(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_inline_sfx_with_onomatopoeia(self, omni_server, online_client) -> None:
"""SFX token paired with its written onomatopoeia (``<|sfx:laughter|>Hehe``)."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": (
@@ -271,6 +269,7 @@ class TestHiggsAudioV3OnlineInlineControlTokens:
"response_format": "wav",
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_AUDIO_BYTES,
"transcript_expected_text": "I cannot believe that just happened. I am still recovering from it.",
}
)
@@ -279,13 +278,12 @@ class TestHiggsAudioV3OnlineInlineControlTokens:
class TestHiggsAudioV3OnlineVoiceClone:
"""Voice clone via the two payload shapes the model serves."""
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voice_clone_ref_audio_ref_text(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_voice_clone_ref_audio_ref_text(self, omni_server, online_client) -> None:
"""Canonical vllm-omni voice clone via ``ref_audio`` + ``ref_text``."""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
@@ -297,17 +295,17 @@ class TestHiggsAudioV3OnlineVoiceClone:
}
)
@pytest.mark.core_model
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_voice_clone_references_alias(self, omni_server, openai_client) -> None:
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
def test_voice_clone_references_alias(self, omni_server, online_client) -> None:
"""BosonAI cookbook payload: ``references=[{audio_path, text}]``.
``normalize_references_alias`` (``protocol/audio.py``) is supposed to
translate the cookbook field into ``ref_audio`` / ``ref_text``; without
it the request would silently fall through to zero-shot synthesis.
"""
openai_client.send_audio_speech_request(
online_client.send_audio_speech_request(
{
"model": omni_server.model,
"input": "Hello world.",
@@ -1,20 +1,32 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Tests of common diffusion feature combinations in online serving mode
for HunyuanVideo-1.5-T2V (480p).
Coverage (H100, since model cannot fit L4):
- CacheDiT + Layerwise CPU offloading (1 GPU)
- CacheDiT + TP=2 + VAE patch parallel=2 (2 GPUs)
- CPU offloading (1 GPU) — ``core_model`` + ``advanced_model``
- CacheDiT + Layerwise CPU offloading (1 GPU) — ``full_model``
- CacheDiT + TP=2 + VAE patch parallel=2 (2 GPUs) — ``full_model``
HunyuanVideo-1.5 is a high-priority model, so only the most basic single-card deployment
row runs on every PR (L2) and on merge (L3). The heavyweight feature combinations stay
nightly-only (L4), together with the video similarity suites in
``tests/e2e/accuracy/hunyuanvideo15_{t2v,i2v}/``.
From ``tests/``::
pytest -s -v e2e/online_serving/test_hunyuan_video_15_expansion.py -m "core_model and diffusion" --run-level=core_model
pytest -s -v e2e/online_serving/test_hunyuan_video_15_expansion.py -m "advanced_model and diffusion" --run-level=advanced_model
pytest -s -v e2e/online_serving/test_hunyuan_video_15_expansion.py -m "full_model and diffusion" --run-level=full_model
"""
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient
pytestmark = [pytest.mark.diffusion, pytest.mark.slow]
pytestmark = [pytest.mark.diffusion]
PROMPT = "A cat walking across a sunlit garden, cinematic lighting, slow motion."
NEGATIVE_PROMPT = "low quality, blurry, distorted"
@@ -24,9 +36,14 @@ MODEL = "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v"
SINGLE_CARD_MARKS = hardware_marks(res={"cuda": "H100"})
PARALLEL_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
# Only the most basic single-card deployment row is cheap enough for PR (L2) / merge (L3)
# CI; the CacheDiT / parallel feature combinations run nightly (L4).
_CORE_MARKS = [pytest.mark.core_model, pytest.mark.advanced_model]
_FULL_MARKS = [pytest.mark.full_model]
def _get_diffusion_feature_cases(model: str):
"""Return L4 diffusion feature cases for HunyuanVideo-1.5.
"""Return diffusion feature cases for HunyuanVideo-1.5.
Designed for 2x H100 environment per issue #1832.
"""
@@ -40,7 +57,7 @@ def _get_diffusion_feature_cases(model: str):
],
),
id="single_card_cpu_offload",
marks=SINGLE_CARD_MARKS,
marks=SINGLE_CARD_MARKS + _CORE_MARKS,
),
# (1 GPU) CacheDiT + Layerwise CPU offloading
pytest.param(
@@ -53,7 +70,7 @@ def _get_diffusion_feature_cases(model: str):
],
),
id="single_card_cachedit_layerwise",
marks=SINGLE_CARD_MARKS,
marks=SINGLE_CARD_MARKS + _FULL_MARKS,
),
# (2 GPUs) CacheDiT + TP=2 + VAE patch parallel=2
pytest.param(
@@ -70,7 +87,7 @@ def _get_diffusion_feature_cases(model: str):
],
),
id="parallel_cachedit_tp2_vae2",
marks=PARALLEL_MARKS,
marks=PARALLEL_MARKS + _FULL_MARKS,
),
]
@@ -82,9 +99,9 @@ def _get_diffusion_feature_cases(model: str):
)
def test_hunyuan_video_15_t2v(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
online_client: OnlineOmniClient,
):
"""L4 diffusion feature coverage for HunyuanVideo-1.5-T2V on H100."""
"""Diffusion feature coverage for HunyuanVideo-1.5-T2V on H100."""
form_data = {
"prompt": PROMPT,
"negative_prompt": NEGATIVE_PROMPT,
@@ -101,4 +118,4 @@ def test_hunyuan_video_15_t2v(
"form_data": form_data,
}
openai_client.send_video_diffusion_request(request_config)
online_client.send_video_diffusion_request(request_config)
Loaded 100 of 1396 files, more files were not shown because too many files have changed in this diff. Show more