Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
aef61597d3
1396 files changed
+151246
-39046
No files matched your search
@@ -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()
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user