Merge branch 'main' into lance-model-support

This commit is contained in:
SYLAR authored and GitHub committed 2026-05-22 04:13:58 +08:00
commit f0cec3ecd4
224 files changed
+19202 -7026

No files matched your search

@@ -52,5 +52,6 @@ time timeout -k 30 30m docker run \
pip install tblib==3.1.0
cd /workspace/vllm-omni
pytest -v -s -m "core_model and xpu and B60"
pytest -v -s -m "advanced_model and xpu and B60" -k "not omni_expansion"
export VLLM_XPU_USE_SAMPLER_KERNEL=0 # NOTE: Remove this after vLLM v0.21.1 is merged. Fixes Qwen2-5 omni-expansion tests.
pytest -v -s -m "advanced_model and xpu and B60"
'
+1 -1
View File
@@ -959,7 +959,7 @@ steps:
- label: ":full_moon: Diffusion X2V · Function Test"
timeout_in_minutes: 90
commands:
- pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py tests/e2e/online_serving/test_wan_2_1_vace_expansion.py tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m "full_model and cuda" --run-level "full_model"
- pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py tests/e2e/online_serving/test_wan_2_1_vace_expansion.py tests/e2e/online_serving/test_hunyuan_video_15_expansion.py tests/e2e/offline_inference/test_wan22_autoround_w4a16_expansion.py -m "full_model and cuda" --run-level "full_model"
agents:
queue: "mithril-h100-pool"
plugins:
+62
View File
@@ -84,3 +84,65 @@ steps:
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
# Invalid HTTP / WS payloads vs live Omni servers (Issue #3649 tracks known skips).
- group: "Reliability Test - Invalid parameters Test"
depends_on: upload-weekly-pipeline
if: build.env("WEEKLY") == "1" || build.pull_request.labels includes "weekly-test"
steps:
- label: "Invalid parameters Test · H100"
timeout_in_minutes: 120
commands:
- pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and H100"
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
# Stable Audio Open, Qwen3-TTS speech/batch/voices paths marked ``L4``.
- label: "Invalid parameters Test · L4"
timeout_in_minutes: 120
commands:
- pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and L4"
agents:
queue: "gpu_1_queue"
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
shm-size: "8gb"
environment:
- "HF_HOME=/fsx/hf_cache"
- "HF_TOKEN"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
+3 -2
View File
@@ -30,7 +30,7 @@ python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
**Notes**
- The benchmark talks to `http://<host>:<port>/v1/chat/completions`.
- By default, image tasks talk to `http://<host>:<port>/v1/chat/completions`; video tasks talk to `/v1/videos`.
- If you run the server on another host or port, pass `--base-url` accordingly.
## 2. Supported Datasets
@@ -95,8 +95,9 @@ You can point to your own trace using `--dataset-path`.
### Basic flags
- `--base-url`: Server address (the script calls `.../v1/chat/completions`).
- `--base-url`: Server address; `--endpoint` selects the path appended to this base URL.
- `--model`: The OpenAI-compatible `model` field.
- `--endpoint`: API endpoint path. Leading `/` is optional, e.g. `/v1/videos` or `v1/videos`.
- `--task`: Task type (e.g., `t2i`, `t2v`, `i2i`, `i2v`).
- `--dataset`: Dataset mode (`vbench` / `trace` / `random`).
- `--num-prompts`: Number of requests to send.
+123 -4
View File
@@ -10,6 +10,8 @@ from typing import Any
import aiohttp
from tqdm import tqdm
DEFAULT_EDITS_BOT_TASK = "think"
@dataclass
class RequestFuncInput:
@@ -27,6 +29,7 @@ class RequestFuncInput:
extra_body: dict[str, Any] = field(default_factory=dict)
image_paths: list[str] | None = None
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
default_bot_task: str | None = DEFAULT_EDITS_BOT_TASK
@dataclass
@@ -53,6 +56,95 @@ def _encode_image_as_data_url(path: str) -> str:
return f"data:{mime};base64,{encoded}"
async def async_request_image_edits(
input: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: tqdm | None = None,
enable_diffusion_pipeline_profiler: bool = False,
) -> RequestFuncOutput:
"""POST /v1/images/edits (multipart)."""
del enable_diffusion_pipeline_profiler
output = RequestFuncOutput()
output.start_time = time.perf_counter()
extra_body = dict(input.extra_body)
width = input.width or extra_body.get("width") or 1024
height = input.height or extra_body.get("height") or 1024
edits_url = input.api_url
form = aiohttp.FormData()
form.add_field("model", input.model)
form.add_field("prompt", input.prompt)
form.add_field("size", f"{width}x{height}")
form.add_field("response_format", "b64_json")
if input.num_inference_steps is not None:
form.add_field("num_inference_steps", str(input.num_inference_steps))
elif extra_body.get("num_inference_steps") is not None:
form.add_field("num_inference_steps", str(extra_body["num_inference_steps"]))
if input.seed is not None:
form.add_field("seed", str(input.seed))
elif extra_body.get("seed") is not None:
form.add_field("seed", str(extra_body["seed"]))
if extra_body.get("guidance_scale") is not None:
form.add_field("guidance_scale", str(extra_body["guidance_scale"]))
if extra_body.get("negative_prompt") is not None:
form.add_field("negative_prompt", str(extra_body["negative_prompt"]))
if extra_body.get("true_cfg_scale") is not None:
form.add_field("true_cfg_scale", str(extra_body["true_cfg_scale"]))
if extra_body.get("sys_type") is not None:
form.add_field("sys_type", str(extra_body["sys_type"]))
if extra_body.get("system_prompt") is not None:
form.add_field("system_prompt", str(extra_body["system_prompt"]))
bot_task = extra_body.get("bot_task")
if bot_task is None and input.default_bot_task is not None:
bot_task = input.default_bot_task
if bot_task is not None:
form.add_field("bot_task", str(bot_task))
assert input.image_paths is not None
for img_path in input.image_paths:
if not os.path.exists(img_path):
output.error = f"Image file not found: {img_path}"
output.success = False
if pbar:
pbar.update(1)
return output
with open(img_path, "rb") as img_f:
image_bytes = img_f.read()
form.add_field(
"image",
image_bytes,
filename=os.path.basename(img_path),
content_type=_guess_mime_type(img_path),
)
try:
async with session.post(edits_url, data=form) as response:
if response.status == 200:
resp_json = await response.json()
output.response_body = resp_json
output.success = True
else:
output.error = f"HTTP {response.status}: {await response.text()}"
output.success = False
except Exception as e:
output.error = str(e)
output.success = False
output.latency = time.perf_counter() - output.start_time
if output.success and input.slo_ms is not None:
output.slo_achieved = (output.latency * 1000.0) <= float(input.slo_ms)
if pbar:
pbar.update(1)
return output
async def async_request_chat_completions(
input: RequestFuncInput,
session: aiohttp.ClientSession,
@@ -151,7 +243,7 @@ async def async_request_chat_completions(
return output
async def async_request_openai_images(
async def async_request_openai_image_generations(
input: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: tqdm | None = None,
@@ -348,12 +440,39 @@ async def async_request_v1_videos(
return output
LEGACY_BACKEND_ENDPOINT_ALIASES = {
"vllm-omni": "/v1/chat/completions",
"openai": "/v1/images/generations",
}
def normalize_endpoint(value: str) -> str:
endpoint = str(value).strip()
if not endpoint:
raise ValueError("endpoint must not be empty.")
endpoint = LEGACY_BACKEND_ENDPOINT_ALIASES.get(
endpoint,
LEGACY_BACKEND_ENDPOINT_ALIASES.get(endpoint.lstrip("/"), endpoint),
)
if not endpoint.startswith("/"):
endpoint = f"/{endpoint}"
return endpoint
def endpoint_filename_token(value: str) -> str:
token = normalize_endpoint(value).lstrip("/")
for bad in ("/", "\\", ":", "*", "?", '"', "<", ">", "|"):
token = token.replace(bad, "_")
return token or "endpoint"
backends_function_mapping = {
"2i": {
"vllm-omni": (async_request_chat_completions, "/v1/chat/completions"),
"openai": (async_request_openai_images, "/v1/images/generations"),
"/v1/chat/completions": (async_request_chat_completions, "/v1/chat/completions"),
"/v1/images/generations": (async_request_openai_image_generations, "/v1/images/generations"),
"/v1/images/edits": (async_request_image_edits, "/v1/images/edits"),
},
"2v": {
"v1/videos": (async_request_v1_videos, "/v1/videos"),
"/v1/videos": (async_request_v1_videos, "/v1/videos"),
},
}
@@ -6,31 +6,34 @@
Benchmark online serving for diffusion models (Image/Video Generation).
If you want to use i2v, i2i dataset, you should `uv pip install gdown` first
Supports multiple backends:
- vllm-omni: Uses /v1/chat/completions endpoint (default)
- openai: Uses /v1/images/generations endpoint
- v1/videos: Use /v1/videos endpoint
Supports multiple endpoints:
- /v1/chat/completions: OpenAI chat-compatible image requests (e.g. t2i, Qwen i2i)
- /v1/images/edits: OpenAI image edit / IT2I (multipart; e.g. Hunyuan --bot-task think)
- /v1/images/generations: OpenAI image generation requests
- /v1/videos: Async video jobs
Legacy --backend vllm-omni and openai are aliases for chat/completions and images/generations.
Usage:
# Video (v1/videos backend)
# Video (/v1/videos endpoint)
t2v:
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend v1/videos --dataset vbench --task t2v --num-prompts 10 \
--endpoint /v1/videos --dataset vbench --task t2v --num-prompts 10 \
--height 480 --width 640 --fps 16 --num-frames 80
i2v:
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend v1/videos --dataset vbench --task i2v --num-prompts 10
--endpoint /v1/videos --dataset vbench --task i2v --num-prompts 10
# Image (vllm-omni backend)
# Image (/v1/chat/completions endpoint)
t2i:
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend vllm-omni --dataset vbench --task t2i --num-prompts 10 \
--endpoint /v1/chat/completions --dataset vbench --task t2i --num-prompts 10 \
--height 1024 --width 1024
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend vllm-omni --dataset random --task t2i --num-prompts 1 \
--endpoint /v1/chat/completions --dataset random --task t2i --num-prompts 1 \
--max-concurrency 1 --enable-negative-prompt \
--random-request-config '[
{"width":512,"height":512,"num_inference_steps":20,"weight":0.15},
@@ -39,20 +42,25 @@ Usage:
{"width":1536,"height":1536,"num_inference_steps":35,"weight":0.15}
]'
i2i:
ti2i (Hunyuan / OpenAI image edit API):
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend vllm-omni --dataset vbench --task i2i --num-prompts 10
--endpoint /v1/images/edits --dataset random --task ti2i --num-prompts 10 \
--bot-task think
# Image (openai backend)
i2i (chat-based models such as Qwen-Image-Edit):
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--endpoint /v1/chat/completions --dataset vbench --task i2i --num-prompts 10
# Image (/v1/images/generations endpoint)
t2i:
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend openai --dataset vbench --task t2i --num-prompts 10 \
--endpoint /v1/images/generations --dataset vbench --task t2i --num-prompts 10 \
--height 1024 --width 1024 --port 3000
# Video (v1/videos)
t2v:
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend v1/videos --dataset random --task t2v --num-prompts 1 \
--endpoint /v1/videos --dataset random --task t2v --num-prompts 1 \
--max-concurrency 1 --enable-negative-prompt \
--random-request-config '[
{"width":854,"height":480,"num_inference_steps":18,"num_frames":120,"fps":24,"weight":1}
@@ -80,7 +88,12 @@ from typing import Any
import aiohttp
import numpy as np
import requests
from backends import RequestFuncInput, RequestFuncOutput, backends_function_mapping
from backends import (
RequestFuncInput,
RequestFuncOutput,
backends_function_mapping,
normalize_endpoint,
)
from PIL import Image
from tqdm.asyncio import tqdm
@@ -127,7 +140,7 @@ class VBenchDataset(BaseDataset):
def _load_data(self) -> list[dict[str, Any]]:
if self.args.task == "t2v":
return self._load_t2v_prompts()
elif self.args.task in ["i2v", "ti2v", "ti2i", "i2i"]:
elif self.args.task in ["i2v", "ti2v", "ti2i", "i2i", "it2i"]:
return self._load_i2v_data()
else:
return self._load_t2v_prompts()
@@ -519,7 +532,7 @@ class TraceDataset(BaseDataset):
single = row.get("image_path")
image_paths = [single] if single else None
if not image_paths and self.args.task in ["i2v", "i2i", "ti2v", "ti2i"]:
if not image_paths and self.args.task in ["i2v", "i2i", "ti2v", "ti2i", "it2i"]:
raise ValueError(
f"Task {self.args.task} requires image input, but no image_path or image_paths found in trace row."
)
@@ -580,7 +593,7 @@ class RandomDataset(BaseDataset):
self._sampled_requests = None
# Random image generate
if self.args.task in ["i2v", "ti2v", "ti2i", "i2i"]:
if self.args.task in ["i2v", "ti2v", "ti2i", "i2i", "it2i"]:
self._random_image_path = self._generate_random_image_paths()
else:
self._random_image_path = None
@@ -883,13 +896,23 @@ def wait_for_service(base_url: str, timeout: int = 120) -> None:
time.sleep(1)
def _default_endpoint_for_task(task: str) -> str:
if task in {"t2v", "i2v", "ti2v"}:
return "/v1/videos"
if task in {"i2i", "ti2i", "it2i"}:
return "/v1/images/edits"
if task == "t2i":
return "/v1/chat/completions"
raise ValueError(f"Unsupported task for endpoint resolution: {task}")
async def benchmark(args):
# Construct base_url if not provided
if args.base_url is None:
args.base_url = f"http://{args.host}:{args.port}"
VIDEO_TASKS = {"t2v", "i2v", "ti2v"}
IMAGE_TASKS = {"t2i", "i2i", "ti2i"}
IMAGE_TASKS = {"t2i", "i2i", "ti2i", "it2i"}
if args.task in VIDEO_TASKS:
task_type = "2v"
@@ -902,18 +925,23 @@ async def benchmark(args):
f"Valid image tasks: {sorted(IMAGE_TASKS)}"
)
valid_backends = sorted(backends_function_mapping[task_type].keys())
raw_endpoint = args.endpoint if args.endpoint is not None else args.backend
if raw_endpoint is None:
raw_endpoint = _default_endpoint_for_task(args.task)
args.endpoint = normalize_endpoint(raw_endpoint)
if args.backend not in valid_backends:
valid_endpoints = sorted(backends_function_mapping[task_type].keys())
if args.endpoint not in valid_endpoints:
logger.error(
f"Invalid backend '{args.backend}' for task '{args.task}' (task type: '{task_type}').\n"
f"Valid backends for this task type: {valid_backends}\n"
f"Example usage: --task {args.task} --backend {valid_backends[0]}"
f"Invalid endpoint '{args.endpoint}' for task '{args.task}' (task type: '{task_type}').\n"
f"Valid endpoints for this task type: {valid_endpoints}\n"
f"Example usage: --task {args.task} --endpoint {valid_endpoints[0]}"
)
raise ValueError("Backend validation failed. See log above for valid options.")
raise ValueError("Endpoint validation failed. See log above for valid options.")
# Setup API URL and request function based on backend
request_func, api_url = backends_function_mapping[task_type][args.backend]
# Setup API URL and request function based on endpoint.
request_func, api_url = backends_function_mapping[task_type][args.endpoint]
api_url = f"{args.base_url}{api_url}"
if args.dataset == "vbench":
@@ -929,6 +957,10 @@ async def benchmark(args):
requests_list = dataset.get_requests()
print(f"Prepared {len(requests_list)} requests from {args.dataset} dataset.")
if args.endpoint == "/v1/images/edits":
for req in requests_list:
req.default_bot_task = args.bot_task
# Limit concurrency
if args.max_concurrency is not None:
semaphore = asyncio.Semaphore(args.max_concurrency)
@@ -976,15 +1008,17 @@ async def benchmark(args):
metrics = calculate_metrics(outputs, total_duration, requests_list, args, args.slo)
# Add configuration info to metrics for JSON output
metrics["backend"] = args.backend
metrics["endpoint"] = args.endpoint
metrics["model"] = args.model
metrics["dataset"] = args.dataset
metrics["task"] = args.task
if args.endpoint == "/v1/images/edits":
metrics["bot_task"] = args.bot_task
print("\n{s:{c}^{n}}".format(s=" Serving Benchmark Result ", n=60, c="="))
print("\n{s:{c}^{n}}".format(s=" Serving Benchmark Result ", n=50, c="="))
# Section 1: Configuration
print("{:<40} {:<15}".format("Backend:", args.backend))
print("{:<40} {:<15}".format("Endpoint:", args.endpoint))
print("{:<40} {:<15}".format("Model:", args.model))
print("{:<40} {:<15}".format("Dataset:", args.dataset))
print("{:<40} {:<15}".format("Task:", args.task))
@@ -1047,12 +1081,17 @@ if __name__ == "__main__":
parser.add_argument("--host", type=str, default="localhost", help="Server host.")
parser.add_argument("--port", type=int, default=8091, help="Server port.")
parser.add_argument("--model", type=str, default="default", help="Model name.")
parser.add_argument(
"--endpoint",
type=str,
default=None,
help=("Endpoint path to target. Leading '/' is optional, e.g. /v1/videos or v1/videos."),
)
parser.add_argument(
"--backend",
type=str,
default="vllm-omni",
choices=["vllm-omni", "openai", "v1/videos"],
help="Backend to target the benchmark to.",
default=None,
help=argparse.SUPPRESS,
)
parser.add_argument(
"--dataset",
@@ -1065,7 +1104,7 @@ if __name__ == "__main__":
"--task",
type=str,
default="t2v",
choices=["t2v", "i2v", "ti2v", "ti2i", "i2i", "t2i"],
choices=["t2v", "i2v", "ti2v", "ti2i", "i2i", "it2i", "t2i"],
help="Task type.",
)
parser.add_argument(
@@ -1172,9 +1211,15 @@ if __name__ == "__main__":
default=1,
help=(
"Number of synthetic input images to attach for image-conditioned tasks "
"(i2v, ti2v, ti2i, i2i) when using random dataset."
"(i2v, ti2v, ti2i, i2i, it2i) when using random dataset."
),
)
parser.add_argument(
"--bot-task",
type=str,
default="think",
help=("bot_task form field for --endpoint /v1/images/edits (think, recaption, think_recaption, vanilla)."),
)
args = parser.parse_args()
asyncio.run(benchmark(args))
@@ -58,7 +58,7 @@ Record these parameters when reporting performance results.
```bash
python benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend vllm-omni \
--endpoint /v1/chat/completions \
--dataset <DATASET_NAME> \
--task t2i \
--num-prompts <N> \
@@ -71,7 +71,7 @@ python benchmarks/diffusion/diffusion_benchmark_serving.py \
| Parameter | Description |
| ---------------------- | --------------------------------- |
| `--backend` | Serving backend (use `vllm-omni`) |
| `--endpoint` | API endpoint (use `/v1/chat/completions`; leading `/` is optional) |
| `--dataset` | Dataset name (`random` or custom) |
| `--task` | Task type (e.g., `t2i`) |
| `--num-prompts` | Total number of requests |
@@ -129,7 +129,7 @@ python benchmarks/diffusion/diffusion_benchmark_serving.py \
```bash
python benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend vllm-omni \
--endpoint /v1/chat/completions \
--dataset random \
--task t2i \
--num-prompts 1 \
@@ -53,7 +53,7 @@ Record these parameters when reporting performance results.
```bash
python benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend v1/videos \
--endpoint /v1/videos \
--dataset <DATASET_NAME> \
--task t2v \
--num-prompts <N> \
@@ -66,7 +66,7 @@ python benchmarks/diffusion/diffusion_benchmark_serving.py \
| Parameter | Description |
| ---------------------- | --------------------------------- |
| `--backend` | Serving backend (use `v1/videos`) |
| `--endpoint` | API endpoint (use `/v1/videos`; leading `/` is optional) |
| `--dataset` | Dataset name (`random` or custom) |
| `--task` | Task type (e.g., `t2v`) |
| `--num-prompts` | Total number of requests |
@@ -120,7 +120,7 @@ python benchmarks/diffusion/diffusion_benchmark_serving.py \
```bash
python benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend v1/videos \
--endpoint /v1/videos \
--dataset random \
--task t2v \
--num-prompts 1 \
+31 -6
View File
@@ -34,6 +34,16 @@ Video example (text-to-video):
--height 720 --width 1280 \
--num-frames 81 --num-inference-steps 40 --seed 42
LTX-2 example (text-to-video; audio output is generated but not scored by LPIPS):
python benchmarks/diffusion/quantization_quality.py \
--model Lightricks/LTX-Video-2 \
--task t2v \
--quantization fp8 int8 \
--prompts \
"A serene lakeside sunrise with mist over the water" \
--height 704 --width 1216 \
--num-frames 121 --num-inference-steps 40 --seed 42
Multiple quantization methods:
python benchmarks/diffusion/quantization_quality.py \
--model Tongyi-MAI/Z-Image-Turbo \
@@ -204,10 +214,19 @@ def _generate_video(omni, args, prompt, seed):
else:
frames = inner
elif hasattr(first, "images") and first.images:
frames = first.images
frames = first.images[0]
else:
raise ValueError("Could not extract video frames from output.")
# LTX-2 (and similar audio+video models) may surface a dict or (video, audio) tuple
if isinstance(frames, dict):
frames = frames.get("video") or frames.get("frames")
elif isinstance(frames, tuple) and len(frames) == 2:
frames = frames[0]
if frames is None:
raise ValueError("Could not extract video frames from output.")
if isinstance(frames, torch.Tensor):
video = frames.detach().cpu()
if video.dim() == 5:
@@ -225,9 +244,11 @@ def _generate_video(omni, args, prompt, seed):
return frames_array, elapsed, peak_mem
def _unload_omni(omni):
"""Delete Omni instance and free GPU memory."""
del omni
def _free_gpu_memory():
"""Force GC and release cached GPU memory.
Must be called AFTER the caller has dropped (i.e., via `del`)
every reference to the Omni instance"""
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
@@ -267,7 +288,9 @@ def run_benchmark(args):
bl_avg_time = np.mean([v[1] for v in baseline_outputs.values()])
bl_mem = baseline_outputs[prompts[0]][2] # use first prompt's memory
_unload_omni(omni_bl)
omni_bl.shutdown()
del omni_bl
_free_gpu_memory()
# Save baseline outputs
bl_dir = output_dir / "baseline"
@@ -307,7 +330,9 @@ def run_benchmark(args):
qt_avg_time = np.mean([v[1] for v in qt_outputs.values()])
qt_mem = qt_outputs[prompts[0]][2]
_unload_omni(omni_qt)
omni_qt.shutdown()
del omni_qt
_free_gpu_memory()
# Save quantized outputs
qt_dir = output_dir / config_label.replace(" ", "_")
+21 -4
View File
@@ -40,7 +40,7 @@ vllm bench serve --omni \
--num-prompts 20 --num-warmups 2 \
--extra-body '{"task_type":"Base"}' \
--max-concurrency 1 --request-rate inf \
--percentile-metrics ttft,e2el,audio_rtf,audio_ttfp,audio_duration \
--percentile-metrics ttft,e2el,audio_rtf,audio_ttfp,audio_duration,audio_underrun \
--save-result --result-dir ./results
```
@@ -58,7 +58,7 @@ vllm bench serve --omni \
--num-prompts 20 --num-warmups 2 \
--extra-body '{"voice":"Vivian","language":"English","task_type":"CustomVoice"}' \
--max-concurrency 1 --request-rate inf \
--percentile-metrics ttft,e2el,audio_rtf,audio_ttfp,audio_duration \
--percentile-metrics ttft,e2el,audio_rtf,audio_ttfp,audio_duration,audio_underrun \
--save-result --result-dir ./results
```
@@ -76,10 +76,28 @@ vllm bench serve --omni \
--num-prompts 20 --num-warmups 2 \
--extra-body '{"task_type":"VoiceDesign","language":"English"}' \
--max-concurrency 1 --request-rate inf \
--percentile-metrics ttft,e2el,audio_rtf,audio_ttfp,audio_duration \
--percentile-metrics ttft,e2el,audio_rtf,audio_ttfp,audio_duration,audio_underrun \
--save-result --result-dir ./results
```
#### Streaming continuity (`audio_underrun`)
`audio_underrun` (seconds) is the per-request worst-case buffer deficit
under a realtime-rate player simulation. The audio-speech backend records
chunk arrival times during the SSE stream and surfaces:
- `Mean / Median / P{50,99} AUDIO_UNDERRUN (s)` per the standard percentile
output - any positive value means at least one inter-chunk gap was longer
than the chunk's audio duration.
- `Streaming continuity OK rate` - fraction of requests whose worst-case
underrun stayed under the threshold (default 100 ms, the commonly cited
"audible gap" budget). Override the threshold with
`VLLM_OMNI_BENCH_AUDIO_CONTINUITY_THRESHOLD_S=<float>`.
This captures the failure mode where `RTF_p50 < 1` (server keeps up in
aggregate) but per-stream chunk arrival is bursty enough that listeners
still hear gaps - common at high concurrency on streaming TTS pipelines.
#### Add WER / SIM / UTMOS to any of the above
Append `--seed-tts-wer-eval` (and optionally `SEED_TTS_EVAL_DEVICE=cuda:0`
@@ -215,7 +233,6 @@ sentinel for regressions in this area.
benchmarks/tts/
├── README.md (this file)
├── bench_tts.py CLI — serve-mode benchmark driver
├── bench_voxcpm_offline.py CLI — offline VoxCPM benchmark (sync + streaming)
├── plot_results.py Generate per-task / per-concurrency curves
└── model_configs.yaml Model registry (supported tasks + extra body)
```
+1 -1
View File
@@ -118,7 +118,7 @@ def build_bench_args(
"--num-warmups",
"2",
"--percentile-metrics",
"ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"ttft,e2el,audio_rtf,audio_ttfp,audio_duration,audio_underrun",
]
if resolved_dataset_path:
-922
View File
@@ -1,922 +0,0 @@
"""Offline VoxCPM benchmark for vLLM Omni.
Supports both:
- sync one-shot (Omni.generate)
- streaming (AsyncOmni.generate with async_chunk config)
- text-only synthesis
- voice cloning
- text/clone batch inputs from txt or jsonl
Usage::
# Sync (default voice)
python benchmarks/tts/bench_voxcpm_offline.py \\
--model /path/to/VoxCPM \\
--text "Hello world" \\
--output-dir results/audio/
# Streaming (async_chunk)
python benchmarks/tts/bench_voxcpm_offline.py \\
--model /path/to/VoxCPM \\
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \\
--txt-prompts prompts.txt \\
--output-dir results/audio/
# Voice cloning batch via JSONL
python benchmarks/tts/bench_voxcpm_offline.py \\
--model /path/to/VoxCPM \\
--jsonl-prompts prompts.jsonl \\
--output-dir results/audio/
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import torch
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni import AsyncOmni, Omni
def _find_repo_root(start: Path) -> Path:
"""Walk up from ``start`` until a repo marker is found.
Falls back to ``parents[2]`` for backwards compatibility if no marker hits
(which can only happen in unusual checkouts — the tree should always have
pyproject.toml + vllm_omni/ at the top level).
"""
for candidate in [start, *start.parents]:
if (candidate / "pyproject.toml").is_file() and (candidate / "vllm_omni").is_dir():
return candidate
return start.parents[2]
REPO_ROOT = _find_repo_root(Path(__file__).resolve())
DEFAULT_STAGE_ASYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm_async_chunk.yaml"
DEFAULT_STAGE_SYNC = REPO_ROOT / "vllm_omni" / "deploy" / "voxcpm.yaml"
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class PromptSpec:
text: str
label: str
ref_audio: str | None = None
ref_text: str | None = None
def _require_soundfile():
try:
import soundfile as sf # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"soundfile is required to write VoxCPM benchmark WAV outputs. Install it with: pip install soundfile"
) from exc
return sf
def _build_prompt(
args,
*,
text: str,
ref_audio: str | None = None,
ref_text: str | None = None,
global_request_id: str | None = None,
) -> dict[str, Any]:
additional_information: dict[str, list[Any]] = {
"text": [text],
"cfg_value": [args.cfg_value],
"inference_timesteps": [args.inference_timesteps],
"min_len": [args.min_len],
"max_new_tokens": [args.max_new_tokens],
}
if args.streaming_prefix_len is not None:
additional_information["streaming_prefix_len"] = [args.streaming_prefix_len]
if ref_audio:
additional_information["ref_audio"] = [ref_audio]
if ref_text:
additional_information["ref_text"] = [ref_text]
if global_request_id is not None:
additional_information["global_request_id"] = [global_request_id]
return {
"prompt_token_ids": [1],
"additional_information": additional_information,
}
def _extract_audio_tensor(mm: dict[str, Any]) -> torch.Tensor:
audio = mm.get("audio", mm.get("model_outputs"))
if audio is None:
raise ValueError("No audio output found in multimodal output.")
if isinstance(audio, list):
parts = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio]
audio = torch.cat(parts, dim=-1) if parts else torch.zeros(0)
if not isinstance(audio, torch.Tensor):
audio = torch.as_tensor(audio)
return audio.float().cpu().reshape(-1)
def _extract_sample_rate(mm: dict[str, Any]) -> int:
sr_raw = mm.get("sr", 24000)
if isinstance(sr_raw, list) and sr_raw:
sr_raw = sr_raw[-1]
if hasattr(sr_raw, "item"):
return int(sr_raw.item())
return int(sr_raw)
def _emit_offline_metrics(
*,
request_id: str,
elapsed_s: float,
first_audio_elapsed: float | None,
audio_duration_s: float,
) -> None:
metrics = {
"request_id": request_id,
"ttfp_ms": round(first_audio_elapsed * 1000.0, 3) if first_audio_elapsed is not None else None,
"audio_duration_s": round(audio_duration_s, 6),
"rtf": round(elapsed_s / audio_duration_s, 6) if audio_duration_s > 0 else None,
}
print(f"[OfflineMetrics] {metrics}")
def _write_audio_tensor(output_path: Path, audio_tensor: Any, sample_rate: int) -> None:
sf = _require_soundfile()
if isinstance(audio_tensor, torch.Tensor):
audio_np = audio_tensor.float().cpu().clamp(-1.0, 1.0).numpy()
else:
audio_np = torch.as_tensor(audio_tensor).float().cpu().clamp(-1.0, 1.0).numpy()
sf.write(
output_path,
audio_np,
sample_rate,
format="WAV",
subtype="PCM_16",
)
def _save_wav(mm: dict[str, Any], output_dir: Path, request_id: str) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"output_{request_id}.wav"
_write_audio_tensor(output_path, _extract_audio_tensor(mm), _extract_sample_rate(mm))
return output_path
def _iter_request_multimodal_outputs(request_output: Any):
outputs = getattr(request_output, "outputs", None)
if outputs:
for output in outputs:
mm = getattr(output, "multimodal_output", None)
if isinstance(mm, dict):
yield mm
mm = getattr(request_output, "multimodal_output", None)
if isinstance(mm, dict):
yield mm
def _read_non_empty_lines(path: str) -> list[str]:
with open(path, encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
def _load_prompt_specs(args) -> list[PromptSpec]:
specs: list[PromptSpec] = []
if args.txt_prompts is not None:
texts = _read_non_empty_lines(args.txt_prompts)
if not texts:
raise ValueError(f"No prompts found in {args.txt_prompts}")
for idx, text in enumerate(texts, start=1):
specs.append(
PromptSpec(
text=text,
label=f"item{idx:03d}",
ref_audio=args.ref_audio,
ref_text=args.ref_text,
)
)
return specs
if args.jsonl_prompts is not None:
with open(args.jsonl_prompts, encoding="utf-8") as f:
for line_no, raw_line in enumerate(f, start=1):
line = raw_line.strip()
if not line:
continue
try:
item = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"{args.jsonl_prompts}:{line_no} is not valid JSON: {exc}") from exc
if not isinstance(item, dict):
raise ValueError(f"{args.jsonl_prompts}:{line_no} must be a JSON object")
text = item.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError(f"{args.jsonl_prompts}:{line_no} requires non-empty string field 'text'")
ref_audio = item.get("ref_audio", args.ref_audio)
ref_text = item.get("ref_text", args.ref_text)
if (ref_audio is None) != (ref_text is None):
raise ValueError(
f"{args.jsonl_prompts}:{line_no} must provide both 'ref_audio' and 'ref_text' together"
)
specs.append(
PromptSpec(
text=text.strip(),
label=f"item{len(specs) + 1:03d}",
ref_audio=ref_audio,
ref_text=ref_text,
)
)
if not specs:
raise ValueError(f"No prompts found in {args.jsonl_prompts}")
return specs
specs.append(
PromptSpec(
text=args.text,
label="item001",
ref_audio=args.ref_audio,
ref_text=args.ref_text,
)
)
return specs
def _build_prompt_for_spec(args, spec: PromptSpec, *, global_request_id: str | None = None) -> dict[str, Any]:
return _build_prompt(
args,
text=spec.text,
ref_audio=spec.ref_audio,
ref_text=spec.ref_text,
global_request_id=global_request_id,
)
def _count_voice_clone_prompts(prompt_specs: list[PromptSpec]) -> int:
return sum(1 for spec in prompt_specs if spec.ref_audio is not None)
def _get_warmup_specs(prompt_specs: list[PromptSpec]) -> list[PromptSpec]:
return prompt_specs[:1]
def _extract_stream_finished(stage_output: Any) -> bool:
request_output = getattr(stage_output, "request_output", None)
request_finished = getattr(request_output, "finished", None)
if request_finished is not None:
return bool(request_finished)
return bool(getattr(stage_output, "finished", False))
def _build_profiled_stage_config(
stage_configs_path: str,
profiler_dir: str,
) -> str:
stage_config_path = Path(stage_configs_path)
yaml_text = stage_config_path.read_text(encoding="utf-8")
injected_lines: list[str] = []
injected_count = 0
for line in yaml_text.splitlines():
injected_lines.append(line)
if line.strip() != "engine_args:":
continue
indent = line[: len(line) - len(line.lstrip())]
child_indent = indent + " "
grandchild_indent = child_indent + " "
injected_lines.extend(
[
f"{child_indent}profiler_config:",
f'{grandchild_indent}profiler: "torch"',
f'{grandchild_indent}torch_profiler_dir: "{profiler_dir}"',
f"{grandchild_indent}torch_profiler_with_stack: true",
]
)
injected_count += 1
if injected_count == 0:
raise ValueError(f"No engine_args block found in stage config: {stage_configs_path}")
tmp = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
delete=False,
suffix=".yaml",
prefix=f"{stage_config_path.stem}_profile_",
)
tmp.write("\n".join(injected_lines) + "\n")
tmp.close()
return tmp.name
def parse_args():
parser = FlexibleArgumentParser(
description="Offline split-stage VoxCPM inference with vLLM Omni (auto sync/streaming by stage config)"
)
parser.add_argument(
"--model",
type=str,
default=os.environ.get("VOXCPM_MODEL"),
help="Local VoxCPM model directory. Defaults to $VOXCPM_MODEL.",
)
parser.add_argument(
"--text",
type=str,
default="This is a split-stage VoxCPM synthesis example running on vLLM Omni.",
help="Text to synthesize. Ignored when --txt-prompts or --jsonl-prompts is used.",
)
parser.add_argument(
"--txt-prompts",
type=str,
default=None,
help="Path to a .txt file with one synthesis text per line.",
)
parser.add_argument(
"--jsonl-prompts",
type=str,
default=None,
help=(
"Path to a .jsonl file. Each line must contain at least {'text': ...}; "
"clone rows can also set ref_audio/ref_text, and ref_text must be the "
"real transcript of ref_audio."
),
)
parser.add_argument(
"--ref-audio",
type=str,
default=None,
help=(
"Optional reference audio path for voice cloning. With --txt-prompts, "
"the same reference is applied to every line."
),
)
parser.add_argument(
"--ref-text",
type=str,
default=None,
help=(
"Real transcript of the reference audio. Placeholder text or mismatched "
"text will usually produce noisy/electronic clone audio."
),
)
parser.add_argument(
"--stage-configs-path",
type=str,
default=str(DEFAULT_STAGE_SYNC),
help="Stage config YAML path. Routing is selected only from this path.",
)
parser.add_argument(
"--cfg-value",
type=float,
default=2.0,
help="Classifier-free guidance value for VoxCPM.",
)
parser.add_argument(
"--inference-timesteps",
type=int,
default=10,
help="Number of inference timesteps.",
)
parser.add_argument(
"--min-len",
type=int,
default=2,
help="Minimum generated token length.",
)
parser.add_argument(
"--max-new-tokens",
type=int,
default=4096,
help="Maximum generated token length.",
)
parser.add_argument(
"--streaming-prefix-len",
type=int,
default=None,
help="VoxCPM streaming window (optional, streaming mode only).",
)
parser.add_argument(
"--output-dir",
type=str,
default=None,
help="Directory for output WAV files.",
)
parser.add_argument(
"--stage-init-timeout",
type=int,
default=600,
help="Stage initialization timeout in seconds.",
)
parser.add_argument(
"--log-stats",
dest="log_stats",
action="store_true",
help="Enable vLLM Omni stats logging.",
)
parser.add_argument(
"--no-log-stats",
dest="log_stats",
action="store_false",
help="Disable vLLM Omni stats logging.",
)
parser.set_defaults(log_stats=True)
parser.add_argument(
"--num-runs",
type=int,
default=1,
help="Number of full inference runs (same prompt each time). Default 1.",
)
parser.add_argument(
"--warmup-runs",
type=int,
default=0,
help=(
"Optional number of warmup passes before measured runs. Warmup uses only "
"the first prompt and does not save outputs."
),
)
parser.add_argument(
"--enable-profiler",
action="store_true",
help=(
"Enable torch profiler for the configured stages. A temporary profiled "
"stage config is generated automatically."
),
)
parser.add_argument(
"--profiler-dir",
type=str,
default=None,
help="Directory for profiler traces. Defaults to <output-dir>/profiler when profiling is enabled.",
)
parser.add_argument(
"--profiler-stages",
type=int,
nargs="*",
default=None,
help="Optional stage ids to profile. Defaults to all stages that have profiler_config.",
)
parser.add_argument(
"--profiler-wait-seconds",
type=float,
default=30.0,
help="Seconds to wait after stop_profile for trace files to flush.",
)
args = parser.parse_args()
if not args.model:
parser.error("--model is required unless $VOXCPM_MODEL is set")
if args.txt_prompts is not None and args.jsonl_prompts is not None:
parser.error("--txt-prompts and --jsonl-prompts are mutually exclusive")
if (args.ref_audio is None) != (args.ref_text is None):
parser.error("--ref-audio and --ref-text must be provided together")
if args.num_runs < 1:
parser.error("--num-runs must be >= 1")
if args.warmup_runs < 0:
parser.error("--warmup-runs must be >= 0")
if args.output_dir is None:
args.output_dir = (
"output_audio_streaming" if _is_streaming_stage_config(args.stage_configs_path) else "output_audio"
)
if args.enable_profiler and args.profiler_dir is None:
args.profiler_dir = str(Path(args.output_dir) / "profiler")
try:
args.prompt_specs = _load_prompt_specs(args)
except ValueError as exc:
parser.error(str(exc))
return args
def _is_streaming_stage_config(stage_configs_path: str) -> bool:
cfg_name = Path(stage_configs_path).name.lower()
return "async_chunk" in cfg_name
async def _collect_streaming_audio(
omni: AsyncOmni,
args: Any,
spec: PromptSpec,
request_id: str,
*,
phase_label: str,
prompt_index: int,
prompt_count: int,
print_prompt: bool = False,
) -> tuple[torch.Tensor, int, float, float | None]:
prompt = _build_prompt_for_spec(args, spec, global_request_id=request_id)
delta_chunks: list[torch.Tensor] = []
sample_rate = 24000
chunk_i = 0
prev_total_samples = 0
t_start = time.perf_counter()
first_audio_elapsed: float | None = None
if print_prompt:
print(f"---prompt---:{prompt}")
async for stage_output in omni.generate(prompt, request_id=request_id):
mm = getattr(stage_output, "multimodal_output", None)
if not isinstance(mm, dict):
ro = getattr(stage_output, "request_output", None)
if ro is None:
continue
mm = getattr(ro, "multimodal_output", None)
if not isinstance(mm, dict) and getattr(ro, "outputs", None):
seq = ro.outputs[0]
mm = getattr(seq, "multimodal_output", None)
if not isinstance(mm, dict):
continue
sample_rate = _extract_sample_rate(mm)
try:
w = _extract_audio_tensor(mm)
n = int(w.numel())
if n == 0:
continue
finished = _extract_stream_finished(stage_output)
if n > prev_total_samples:
delta = w.reshape(-1)[prev_total_samples:]
prev_total_samples = n
elif finished and n == prev_total_samples:
delta = w.reshape(-1)[:0]
else:
delta = w.reshape(-1)
prev_total_samples += int(delta.numel())
if int(delta.numel()) > 0:
delta_chunks.append(delta)
if first_audio_elapsed is None and int(delta.numel()) > 0:
first_audio_elapsed = time.perf_counter() - t_start
logger.info(
"%s prompt=%d/%d chunk=%d delta_samples=%d buf_len=%d finished=%s",
phase_label,
prompt_index + 1,
prompt_count,
chunk_i,
int(delta.numel()),
n,
finished,
)
chunk_i += 1
except ValueError:
if not _extract_stream_finished(stage_output):
logger.debug("skip non-audio partial output chunk=%d", chunk_i)
if not delta_chunks:
raise RuntimeError("No audio chunks received; check stage config and logs.")
audio_cat = torch.cat([c.reshape(-1) for c in delta_chunks], dim=0)
elapsed = time.perf_counter() - t_start
return audio_cat, sample_rate, elapsed, first_audio_elapsed
async def _abort_streaming_residual_work(
omni: AsyncOmni,
request_id: str,
*,
settle_seconds: float = 0.1,
) -> None:
"""Stop any late stage-0 work once the final audio has been collected."""
await omni.engine.abort_async([request_id])
if settle_seconds > 0:
await asyncio.sleep(settle_seconds)
async def _run_streaming_single(
omni: AsyncOmni,
args: Any,
spec: PromptSpec,
output_dir: Path,
request_id: str,
*,
run_index: int,
num_runs: int,
prompt_index: int,
prompt_count: int,
) -> Path:
audio_cat, sample_rate, elapsed, first_audio_elapsed = await _collect_streaming_audio(
omni,
args,
spec,
request_id,
phase_label=f"run={run_index + 1}/{num_runs}",
prompt_index=prompt_index,
prompt_count=prompt_count,
print_prompt=(run_index == 0 and prompt_index == 0),
)
await _abort_streaming_residual_work(omni, request_id)
output_path = output_dir / f"output_run{run_index + 1}_{spec.label}.wav"
_write_audio_tensor(output_path, audio_cat, sample_rate)
audio_duration_s = float(audio_cat.numel()) / float(sample_rate) if sample_rate > 0 else 0.0
ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
rtf_text = f", rtf={elapsed / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
print(
f"Saved (streaming) run {run_index + 1}/{num_runs}, "
f"prompt {prompt_index + 1}/{prompt_count}: {output_path} ({elapsed:.2f}s{ttfp_text}{rtf_text})"
)
_emit_offline_metrics(
request_id=request_id,
elapsed_s=elapsed,
first_audio_elapsed=first_audio_elapsed,
audio_duration_s=audio_duration_s,
)
return output_path
async def _run_streaming_warmup(args, omni: AsyncOmni) -> None:
if args.warmup_runs == 0:
return
warmup_specs = _get_warmup_specs(args.prompt_specs)
print(
f"Warmup: {args.warmup_runs} run(s) using the first prompt "
f"({len(warmup_specs)} prompt(s)); outputs will be discarded."
)
for warmup_index in range(args.warmup_runs):
t_warmup = time.perf_counter()
tasks = []
request_ids: list[str] = []
for prompt_index, spec in enumerate(warmup_specs):
request_id = f"warmup_stream_{warmup_index + 1}_{spec.label}_{uuid.uuid4().hex[:8]}"
request_ids.append(request_id)
tasks.append(
_collect_streaming_audio(
omni,
args,
spec,
request_id,
phase_label=f"warmup={warmup_index + 1}/{args.warmup_runs}",
prompt_index=prompt_index,
prompt_count=len(warmup_specs),
)
)
results = await asyncio.gather(*tasks)
for request_id in request_ids:
await _abort_streaming_residual_work(omni, request_id)
total_samples = sum(int(audio.numel()) for audio, _, _, _ in results)
warmup_ttfps = [ttfp for _, _, _, ttfp in results if ttfp is not None]
ttfp_text = f", ttfp={min(warmup_ttfps):.2f}s" if warmup_ttfps else ""
print(
f"Warmup (streaming) {warmup_index + 1}/{args.warmup_runs} finished: "
f"{len(results)} prompt(s), {total_samples} sample(s) "
f"({time.perf_counter() - t_warmup:.2f}s{ttfp_text})"
)
async def _run_streaming(args) -> list[Path]:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
omni = AsyncOmni(
model=args.model,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
)
await _run_streaming_warmup(args, omni)
profiler_started = False
if args.enable_profiler:
profile_prefix = f"voxcpm_streaming_{int(time.time())}"
stages_text = args.profiler_stages if args.profiler_stages is not None else "all-configured"
print(f"Starting profiler (streaming): stages={stages_text}, dir={args.profiler_dir}")
await omni.start_profile(profile_prefix=profile_prefix, stages=args.profiler_stages)
profiler_started = True
t_total = time.perf_counter()
total_elapsed = 0.0
paths: list[Path] = []
prompt_specs: list[PromptSpec] = args.prompt_specs
try:
for run in range(args.num_runs):
for prompt_index, spec in enumerate(prompt_specs):
request_id = f"stream_{run + 1}_{spec.label}_{uuid.uuid4().hex[:8]}"
paths.append(
await _run_streaming_single(
omni,
args,
spec,
output_dir,
request_id,
run_index=run,
num_runs=args.num_runs,
prompt_index=prompt_index,
prompt_count=len(prompt_specs),
)
)
total_elapsed = time.perf_counter() - t_total
finally:
if profiler_started:
print("Stopping profiler (streaming)...")
await omni.stop_profile(stages=args.profiler_stages)
if args.profiler_wait_seconds > 0:
print(f"Waiting {args.profiler_wait_seconds:.1f}s for profiler traces to flush...")
await asyncio.sleep(args.profiler_wait_seconds)
print(
f"All streaming runs finished: {args.num_runs} run(s), "
f"{len(prompt_specs)} prompt(s), {len(paths)} file(s) in {total_elapsed:.2f}s total"
)
return paths
def _run_sync(args) -> list[Path]:
output_dir = Path(args.output_dir)
omni = Omni(
model=args.model,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
)
def _run_sync_single(
spec: PromptSpec,
*,
request_prefix: str,
save_outputs: bool,
run_index: int | None = None,
) -> tuple[list[Path], int, float | None, float, float, str]:
global_request_id = f"{request_prefix}_{spec.label}"
prompt = _build_prompt_for_spec(args, spec, global_request_id=global_request_id)
if save_outputs and run_index == 0 and spec.label == "item001":
print(f"---prompt---:{prompt}")
saved_paths: list[Path] = []
output_count = 0
first_audio_elapsed: float | None = None
total_audio_duration_s = 0.0
metrics_request_id = global_request_id
t_start = time.perf_counter()
for stage_outputs in omni.generate(prompt):
request_output = stage_outputs.request_output
if request_output is None:
continue
request_output_id = getattr(request_output, "request_id", None)
if isinstance(request_output_id, str) and request_output_id:
metrics_request_id = request_output_id
for j, mm in enumerate(_iter_request_multimodal_outputs(request_output)):
output_count += 1
if first_audio_elapsed is None:
try:
audio_tensor = _extract_audio_tensor(mm)
if int(audio_tensor.numel()) > 0:
first_audio_elapsed = time.perf_counter() - t_start
total_audio_duration_s += float(audio_tensor.numel()) / float(_extract_sample_rate(mm))
except ValueError:
pass
else:
try:
audio_tensor = _extract_audio_tensor(mm)
total_audio_duration_s += float(audio_tensor.numel()) / float(_extract_sample_rate(mm))
except ValueError:
pass
if not save_outputs:
continue
save_stem = f"run{run_index + 1}_{spec.label}" if j == 0 else f"run{run_index + 1}_{spec.label}_{j}"
saved_paths.append(_save_wav(mm, output_dir, save_stem))
if output_count == 0:
raise RuntimeError("No output from Omni.generate")
elapsed_s = time.perf_counter() - t_start
return saved_paths, output_count, first_audio_elapsed, elapsed_s, total_audio_duration_s, metrics_request_id
if args.warmup_runs:
warmup_specs = _get_warmup_specs(args.prompt_specs)
print(
f"Warmup: {args.warmup_runs} run(s) using the first prompt "
f"({len(warmup_specs)} prompt(s)); outputs will be discarded."
)
for warmup_index in range(args.warmup_runs):
t_warmup = time.perf_counter()
_, output_count, first_audio_elapsed, elapsed_s, audio_duration_s, _ = _run_sync_single(
warmup_specs[0],
request_prefix=f"warmup_sync{warmup_index + 1}",
save_outputs=False,
)
ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
rtf_text = f", rtf={elapsed_s / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
print(
f"Warmup (sync) {warmup_index + 1}/{args.warmup_runs} finished: "
f"{output_count} output(s) ({time.perf_counter() - t_warmup:.2f}s{ttfp_text}{rtf_text})"
)
profiler_started = False
if args.enable_profiler:
profile_prefix = f"voxcpm_sync_{int(time.time())}"
stages_text = args.profiler_stages if args.profiler_stages is not None else "all-configured"
print(f"Starting profiler (sync): stages={stages_text}, dir={args.profiler_dir}")
omni.start_profile(profile_prefix=profile_prefix, stages=args.profiler_stages)
profiler_started = True
t_total = time.perf_counter()
total_elapsed = 0.0
saved_paths: list[Path] = []
prompt_specs: list[PromptSpec] = args.prompt_specs
try:
for run in range(args.num_runs):
t_run = time.perf_counter()
run_paths: list[Path] = []
for prompt_index, spec in enumerate(prompt_specs):
prompt_paths, _, first_audio_elapsed, elapsed_s, audio_duration_s, metrics_request_id = (
_run_sync_single(
spec,
request_prefix=f"sync_run{run + 1}_{prompt_index + 1:03d}",
save_outputs=True,
run_index=run,
)
)
run_paths.extend(prompt_paths)
ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
rtf_text = f", rtf={elapsed_s / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
print(
f"Saved (sync) run {run + 1}/{args.num_runs}, "
f"prompt {prompt_index + 1}/{len(prompt_specs)}: {len(prompt_paths)} file(s){ttfp_text}{rtf_text}"
)
_emit_offline_metrics(
request_id=metrics_request_id,
elapsed_s=elapsed_s,
first_audio_elapsed=first_audio_elapsed,
audio_duration_s=audio_duration_s,
)
saved_paths.extend(run_paths)
print(
f"Run {run + 1}/{args.num_runs} finished: {len(run_paths)} file(s) ({time.perf_counter() - t_run:.2f}s)"
)
for path in run_paths:
print(f" {path}")
total_elapsed = time.perf_counter() - t_total
finally:
if profiler_started:
print("Stopping profiler (sync)...")
omni.stop_profile(stages=args.profiler_stages)
if args.profiler_wait_seconds > 0:
print(f"Waiting {args.profiler_wait_seconds:.1f}s for profiler traces to flush...")
time.sleep(args.profiler_wait_seconds)
print(
f"All sync runs finished: {args.num_runs} run(s), "
f"{len(prompt_specs)} prompt(s), {len(saved_paths)} file(s) in {total_elapsed:.2f}s total"
)
return saved_paths
def main(args) -> int:
logging.basicConfig(level=logging.INFO)
profiled_stage_config_path: str | None = None
original_stage_config_path = args.stage_configs_path
if args.enable_profiler:
Path(args.profiler_dir).mkdir(parents=True, exist_ok=True)
profiled_stage_config_path = _build_profiled_stage_config(
args.stage_configs_path,
str(Path(args.profiler_dir).resolve()),
)
args.stage_configs_path = profiled_stage_config_path
is_streaming = _is_streaming_stage_config(args.stage_configs_path)
voice_clone_count = _count_voice_clone_prompts(args.prompt_specs)
print(f"Model: {args.model}")
print(f"Stage config: {original_stage_config_path}")
print(f"Route: {'streaming' if is_streaming else 'sync'} (from stage-configs-path)")
print(f"Prompt count: {len(args.prompt_specs)}")
print("Batch mode: sequential (aligned with native VoxCPM)")
print(f"Warmup runs: {args.warmup_runs}")
print(f"Voice cloning prompts: {voice_clone_count}/{len(args.prompt_specs)}")
if args.enable_profiler:
print(f"Profiler: enabled (dir={args.profiler_dir}, stages={args.profiler_stages or 'all-configured'})")
print(f"Profiled stage config: {args.stage_configs_path}")
if voice_clone_count:
print("Voice cloning note: --ref-text/ref_text must match the spoken content of the reference audio.")
print(f"Num runs: {args.num_runs}")
try:
if is_streaming:
asyncio.run(_run_streaming(args))
else:
_run_sync(args)
finally:
if profiled_stage_config_path is not None and os.path.exists(profiled_stage_config_path):
os.unlink(profiled_stage_config_path)
return 0
if __name__ == "__main__":
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
raise SystemExit(main(parse_args()))
+5
View File
@@ -133,6 +133,11 @@ ENTRYPOINT []
FROM ${VLLM_BASE} AS vllm-omni
RUN apt-get update && \
apt-get install -y espeak-ng ffmpeg git jq && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /workspace/vllm-omni
COPY . .
+4 -1
View File
@@ -6,10 +6,11 @@ nav:
- getting_started/installation/*
- Serving:
- OpenAI-Compatible API:
- Diffusion Chat API: serving/diffusion_chat_api.md
- Chat Completions API: serving/chat_completions_api.md
- Image Generation: serving/image_generation_api.md
- Image Edit: serving/image_edit_api.md
- Text to Speech: serving/speech_api.md
- Videos: serving/videos_api.md
- Audio Generation: serving/audio_generate_api.md
- Streaming Video Input: serving/video_stream_api.md
- Examples:
@@ -79,6 +80,8 @@ nav:
- Quantized KV Cache: user_guide/quantization/quantized_kvcache.md
- FP8 W8A8: user_guide/quantization/fp8.md
- Int8 W8A8: user_guide/quantization/int8.md
- MXFP8 W8A8: user_guide/quantization/mxfp8.md
- MXFP4 W4A4: user_guide/quantization/mxfp4.md
- ModelOpt: user_guide/quantization/modelopt.md
- GGUF: user_guide/quantization/gguf.md
- AutoRound: user_guide/quantization/autoround.md
-1
View File
@@ -121,7 +121,6 @@ Configuration classes.
- [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Qwen2_5_VLTextConfig][]
- [vllm_omni.transformers_utils.configs.mammoth_moda2.Mammothmoda2Qwen2_5_VLVisionConfig][]
- [vllm_omni.transformers_utils.configs.omnivoice.OmniVoiceConfig][]
- [vllm_omni.transformers_utils.configs.voxcpm.VoxCPMConfig][]
- [vllm_omni.transformers_utils.configs.voxcpm2.VoxCPM2Config][]
## Workers
+26 -3
View File
@@ -39,11 +39,34 @@ Committers have write access and merge rights. They typically have deep expertis
- [@linyueqian](https://github.com/linyueqian): TTS and Omni Support
- [@lishunyang12](https://github.com/lishunyang12): Quantization and Configuration
- [@princepride](https://github.com/princepride): Diffusion and Omni Support
- [@SamitHuang](https://github.com/SamitHuang): RL and Diffusion
- [@RuixiangMa](https://github.com/RuixiangMa): Diffusion models, parallel, cache, and docs
- [@SamitHuang](https://github.com/SamitHuang): RL, Diffusion, and cache
- [@tzhouam](https://github.com/tzhouam): Engine and New Model Support
- [@wtomin](https://github.com/wtomin): Diffusion and Parallelism
- [@wtomin](https://github.com/wtomin): Diffusion models, parallel, and docs
- [@ZeldaHuang](https://github.com/ZeldaHuang): Omni Support
- [@ZJY0516](https://github.com/ZJY0516): Diffusion and CustomOp
- [@ZJY0516](https://github.com/ZJY0516): Diffusion attention backend, kernel fusion, and CustomOp
- [@yuanheng-zhao](https://github.com/yuanheng-zhao): Diffusion cache, offload, and Omni Support
## Diffusion Workload Division
This section breaks down diffusion-related responsibilities. If you have PRs touching these areas, please ping the listed owners for review.
### Runtime-related
- Diffusion models: @RuixiangMa, @wtomin
### Optimization
- Parallel: @RuixiangMa, @wtomin
- Attention backend: @ZJY0516
- Cache: @yuanheng-zhao, @RuixiangMa, @SamitHuang
- Offload: @yuanheng-zhao
- Quantization: @lishunyang12, @david6666666
- Kernel fusion / communication-computation: @ZJY0516
### Docs & Test
- Diffusion docs: @RuixiangMa, @wtomin
## Meetings
@@ -83,7 +83,10 @@ There are two important details:
Today that compatibility rule is still shape-sensitive. `height`, `width`,
`num_frames`, and CFG-related fields remain part of the key, so different
resolutions or incompatible guidance settings do **not** co-batch yet.
resolutions or incompatible guidance settings do **not** co-batch yet. The
key also covers LoRA identity (`lora_int_id`, `lora_scale`), so requests
targeting different adapters or scales run in separate batches and the
worker can activate exactly one adapter per step.
The current batching unit is one `OmniDiffusionRequest`. Requests with
multiple prompts do not participate in batching today.
@@ -9,6 +9,7 @@ Backend-specific setup lives in separate docs:
- [MooncakeStoreConnector](omni_connectors/mooncake_store_connector.md)
- [MooncakeTransferEngineConnector](omni_connectors/mooncake_transfer_engine_connector.md)
- [YuanrongConnector](omni_connectors/yuanrong_connector.md)
- [YuanrongTransferEngineConnector](omni_connectors/yuanrong_transfer_engine_connector.md)
## Overview
@@ -23,6 +24,7 @@ Current connectors operate in D2H2D (device to host to device) mode.
| Multi node (Mooncake Store) | MooncakeStoreConnector | TCP-based, requires Mooncake Master + metadata server. |
| Multi node (Mooncake RDMA) | MooncakeTransferEngineConnector | RDMA/TCP direct transfer with managed memory pool. Fastest. |
| Multi node (Yuanrong) | YuanrongConnector | Requires Yuanrong Datasystem + etcd. |
| Ascend NPU P2P (Yuanrong TE) | YuanrongTransferEngineConnector | Uses Yuanrong TransferEngine directly. Configure NPU device IPv4 and `memory_pool_device: "npu"`. |
## Core API
@@ -0,0 +1,211 @@
# YuanrongTransferEngineConnector
## When to Use
Use `YuanrongTransferEngineConnector` for high-performance peer-to-peer KV cache
transfer on Ascend NPU. It is different from `YuanrongConnector`:
- `YuanrongConnector` uses Yuanrong Datasystem as a distributed KV store and
requires Datasystem workers plus etcd.
- `YuanrongTransferEngineConnector` uses Yuanrong TransferEngine directly. It
registers local memory in each stage worker and lets the receiver pull data
from the sender with TransferEngine.
The implementation currently lives under
`vllm_omni/platforms/npu/omni_connectors/` because the supported backend is
Ascend NPU only. The connector is still registered with the generic
OmniConnector factory under the name `YuanrongTransferEngineConnector`.
For Ascend P2P transfer, the connector should use NPU memory as its transfer
pool. The Mooncake-style CPU pool is not supported for this connector.
## Prerequisites
Install Yuanrong Datasystem/TransferEngine Python bindings in the runtime
environment. The packaged install is:
```bash
pip install openyuanrong-datasystem
```
To build from source, follow the upstream project instructions:
- Source repository: [openeuler/yuanrong-datasystem](https://atomgit.com/openeuler/yuanrong-datasystem)
- Installation guide: [Yuanrong Datasystem Linux installation](https://pages.openeuler.openatom.cn/openyuanrong-datasystem/docs/zh-cn/latest/installation/installation_linux.html)
The Ascend driver, CANN runtime, HCCL/HCCP, and NPU device network must also be
configured on every machine participating in the transfer.
## Check NPU Device IPv4
Yuanrong TransferEngine with `protocol: "ascend"` uses the NPU device network,
not only the host management IP. Each participating NPU must have an IPv4 HCCN
device IP.
Check the device IP, link, and health state:
```bash
for i in {0..7}; do
echo "===== NPU $i ====="
hccn_tool -i "$i" -ip -g
hccn_tool -i "$i" -link -g
hccn_tool -i "$i" -net_health -g
done
```
Expected output for each NPU should include an IPv4 address and healthy link:
```text
ipaddr:10.86.1.80
netmask:255.255.255.0
link status: UP
net health status: Success
```
If the output says `Get ipconf failed, because no ip was preset there!`, the NPU
device IP is not configured and Ascend P2P will fail. If the address is IPv6,
configure IPv4 for this connector path or validate that the underlying Yuanrong
TransferEngine build supports the IPv6 HCCN path before using it.
## Check Device-to-Device Connectivity
After collecting the target-stage NPU IPv4 addresses, ping them from each
source-stage NPU:
```bash
# Example: AR uses NPU 0..3 and DiT uses NPU 4..7.
# Replace the destination IPs with the values reported by hccn_tool -i 4..7 -ip -g.
for src in 0 1 2 3; do
for dst_ip in 10.86.1.78 10.86.1.79 10.86.1.80 10.86.1.81; do
echo "===== NPU $src -> $dst_ip ====="
hccn_tool -i "$src" -ping -g address "$dst_ip" || true
done
done
```
The expected result is `0.00% packet loss` for every required source/target pair.
This verifies basic HCCN IPv4 connectivity. TransferEngine can still fail later
if RDMA/QP/HCCL setup is broken, but failed ping means the device network must be
fixed first.
## YAML Configuration
For Ascend P2P, configure the connector with `protocol: "ascend"` and an NPU
memory pool:
```yaml
runtime:
enabled: true
defaults:
window_size: -1
max_inflight: 1
connectors:
yuanrong_te_connector:
name: YuanrongTransferEngineConnector
extra:
host: "auto"
zmq_port: 50051
rpc_port: "auto"
protocol: "ascend"
device_name: "auto"
memory_pool_size: 1073741824
memory_pool_device: "npu"
edges:
- from: 0
to: 1
window_size: -1
```
Important fields:
| Parameter | Recommended Value | Notes |
|---|---|---|
| `host` | `"auto"` | Advertises a routable local host IP for ZMQ metadata exchange. |
| `zmq_port` | `50051` | Base ZMQ port. The runtime applies stage/rank offsets. |
| `rpc_port` | `"auto"` | Lets TransferEngine choose an available RPC port. |
| `protocol` | `"ascend"` | Uses the Ascend TransferEngine backend. |
| `device_name` | `"auto"` | Each TP worker resolves to its local logical NPU, for example `npu:0`, `npu:1`, etc. |
| `memory_pool_device` | `"npu"` | Required for the Ascend fast path. This registers NPU memory with TransferEngine. |
| `memory_pool_size` | `1073741824` | 1 GiB per worker is a practical starting point for KV-cache transfer validation. Increase if the transfer pool is exhausted. |
`memory_pool_device: "cpu"` is rejected for
`YuanrongTransferEngineConnector` with `protocol: "ascend"`. CPU pool registration
can lead to Ascend P2P failures such as HCCL RA init errors, QP timeouts, or
FFTS/SDMA runtime errors. `memory_pool_device: "cpu"` is common for
`MooncakeTransferEngineConnector`, but Yuanrong TE on Ascend only supports
`"npu"`.
## 4-Card AR-to-DiT TP Example
For AR 4-way TP sending KV cache to DiT 4-way TP, set the stage TP topology in
both stage `omni_kv_config` blocks:
```yaml
stage_args:
- stage_id: 0
runtime:
devices: "0,1,2,3"
engine_args:
tensor_parallel_size: 4
omni_kv_config:
need_send_cache: true
rank_mapping:
from_tp: 4
to_tp: 4
output_connectors:
to_stage_1: yuanrong_te_connector
- stage_id: 1
runtime:
devices: "4,5,6,7"
engine_args:
parallel_config:
tensor_parallel_size: 4
omni_kv_config:
need_recv_cache: true
rank_mapping:
from_tp: 4
to_tp: 4
input_connectors:
from_stage_0: yuanrong_te_connector
```
In this topology each DiT rank receives the corresponding AR rank shard:
```text
DiT rank 0 <- AR rank 0
DiT rank 1 <- AR rank 1
DiT rank 2 <- AR rank 2
DiT rank 3 <- AR rank 3
```
## Validate a Successful Run
Search the inference log for Yuanrong TE receive-side messages:
```bash
grep -E "\[YR GET\]|Successfully received KV cache|transfer_engine get failed" inference.log
```
A successful Ascend fast-path transfer contains lines like:
```text
[YR GET] ... fast_path, zero-copy
Successfully received KV cache ... across 1 key(s)
Applied CFG KV caches
[KV Reuse] ...
```
Sender-side `KV transfer OK` only means that the sender registered the payload
and metadata. The actual P2P data transfer happens on the receiver side during
`[YR GET]`.
## Troubleshooting
| Symptom | Likely Cause | Action |
|---|---|---|
| `IPv4 device IP not found` | NPU HCCN IPv4 is missing. | Run `hccn_tool -i <id> -ip -g` and configure IPv4 for every participating NPU. |
| `P2PCommInitRootInfo failed`, `ra init failed`, `RA_QP_STATUS_TIMEOUT` | Ascend P2P/RDMA/QP setup failed. | First verify device IPv4, link health, and device-to-device ping. Then test a minimal Yuanrong TE pair outside vLLM. |
| `fftsplus sdma error`, `context is abort` | Runtime SDMA/FFTS failure, often after an invalid or failed P2P transfer. | Use `memory_pool_device: "npu"` for Ascend TE, reduce `memory_pool_size` if OOM occurs, and isolate the failing NPU pair with a Yuanrong TE demo. |
| Sender logs `KV transfer OK` but receiver never logs `[YR GET]` | Sender `put()` succeeded, receiver P2P read failed or timed out. | Check receiver logs and search for `transfer_engine get failed`. |
| Pool allocation fails or OOM | Transfer pool is too small or too large for available NPU memory. | Start with 1 GiB (`1073741824`), reduce to 512 MiB for validation, or increase for larger KV payloads. |
+356
View File
@@ -0,0 +1,356 @@
# Pipeline Parallel
This section describes how to add Pipeline Parallelism (PP) to a diffusion pipeline. We use the Wan2.2 text-to-video and
image-to-video pipelines as the reference implementations.
## Implementation Checklist
Adding Pipeline Parallel support requires:
1. ✅ **Inherit the mixin** - add `PipelineParallelMixin` before `CFGParallelMixin` in the pipeline class
2. ✅ **Make stage forward resumable** - support `intermediate_tensors` in the transformer forward path
3. ✅ **Return the right object type** - `IntermediateTensors` on non-last PP ranks, final model output on the last rank
4. ✅ **Use the standard denoising loop** - call `predict_noise_maybe_with_cfg()` and `scheduler_step_maybe_with_cfg()`
5. ✅ **Test parity** - compare PP results against the single-GPU baseline
---
## Table of Contents
- [Overview](#overview)
- [Step-by-Step Implementation](#step-by-step-implementation)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Reference Implementations](#reference-implementations)
---
## Overview
### What is Pipeline Parallelism?
Pipeline Parallelism splits the denoising transformer into multiple sequential stages and places each stage on a
different rank. Instead of every rank holding the full DiT, each PP rank owns only a slice of the layers.
For each denoising step:
1. Rank 0 starts the forward pass using the current latents.
2. Each intermediate rank receives hidden states from the previous rank, runs its local layer slice, and forwards the
intermediate tensors downstream.
3. The last PP rank produces the final noise prediction.
4. The last PP rank applies the scheduler step and sends the updated latents back to rank 0 for the next timestep.
This reduces per-rank model memory and enables larger diffusion transformers to run across multiple GPUs. It can also be
combined with CFG-Parallel, where each PP pipeline carries one CFG branch.
### Architecture
vLLM-Omni provides `PipelineParallelMixin` to encapsulate the PP communication pattern for diffusion pipelines.
| Method | Purpose | Automatic Behavior |
|-----------------------------------|-------------------------------|---------------------------------------------------------------------------------|
| `diffuse()` | Denoising loop boundary | Wrapped by `PipelineParallelMixin` to flush pending async PP sends on exit |
| `predict_noise_maybe_with_cfg()` | Predict noise with PP support | Runs partial forwards on non-last PP ranks, combines with CFG logic when needed |
| `scheduler_step_maybe_with_cfg()` | Step scheduler with PP sync | Runs scheduler on the last PP rank, returns updated latents to rank 0 |
| `_sync_pp_send()` | Flush pending async sends | Waits for outstanding `isend` handles before later collectives or decode |
`PipelineParallelMixin` is intentionally a pipeline-level abstraction. Your model-specific `predict_noise()` still
defines how a local stage executes.
### How It Works
`predict_noise_maybe_with_cfg()` automatically switches between these modes:
- **PP disabled** (`pipeline_parallel_size == 1`):
- Falls back to `CFGParallelMixin.predict_noise_maybe_with_cfg()`
- **PP only** (`pipeline_parallel_size > 1`, `cfg_parallel_size == 1`):
- Rank 0 starts with the input latents
- Middle ranks receive `intermediate_tensors`, run their local layer range, and asynchronously send downstream
- The last rank returns the final noise prediction
- When CFG is enabled in this mode (sequential CFG), both the positive and negative branches run through the same
PP pipeline, doubling the communication volume per denoising step. Prefer PP + CFG-Parallel when
`cfg_parallel_size > 1` is available to avoid this overhead.
- **PP + CFG-Parallel** (`pipeline_parallel_size > 1`, `cfg_parallel_size > 1`):
- Each PP pipeline carries one CFG branch
- The last PP rank all-gathers across the CFG group
- CFG combination happens on every last PP rank in the CFG group, matching the non-PP CFG-parallel behavior
`scheduler_step_maybe_with_cfg()` keeps the denoising loop consistent:
- **PP disabled**:
- Falls back to `scheduler_step_maybe_with_cfg()`
- **PP enabled**:
- Only the last PP rank has `noise_pred` and runs the scheduler step
- The resulting latents are sent back to rank 0
- Rank 0 receives an `AsyncLatents` wrapper that resolves only when the tensor is next consumed
This asynchronous design avoids unnecessary blocking between denoising steps.
When a pipeline class is defined, `PipelineParallelMixin` wraps its `diffuse()` method and calls `_sync_pp_send()` in a
`finally` block after `diffuse()` returns or raises. This keeps model code free of explicit PP cleanup while still
ensuring that the final non-blocking PP send completes before decode or later collectives.
---
## Step-by-Step Implementation
### Step 1: Inherit `PipelineParallelMixin` and `CFGParallelMixin`
`PipelineParallelMixin` **requires** `CFGParallelMixin` and must appear before it in the class MRO. This is enforced at
class definition time via `__init_subclass__`: defining a pipeline that inherits `PipelineParallelMixin` without
`CFGParallelMixin`, or after `CFGParallelMixin`, raises a `TypeError` immediately on import.
`PipelineParallelMixin` delegates noise prediction, CFG combination, and scheduler stepping to `CFGParallelMixin`, which
supplies `predict_noise()`, `predict_noise_maybe_with_cfg()`, `scheduler_step_maybe_with_cfg()`, and
`combine_cfg_noise()`.
**Example:**
```python
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.pipeline_parallel import PipelineParallelMixin
import torch.nn as nn
class YourPipeline(nn.Module, PipelineParallelMixin, CFGParallelMixin):
...
```
The order matters: `PipelineParallelMixin` must be listed before `CFGParallelMixin` so calls to
`predict_noise_maybe_with_cfg()` and `scheduler_step_maybe_with_cfg()` resolve to the PP-aware wrappers, while their
`super()` calls delegate to the CFG implementation when PP is disabled or after the last PP stage.
### Step 2: Make model forward and `predict_noise()` PP-aware
The PP mixin injects `intermediate_tensors` into the normal `predict_noise()` call. Your model forward path must support
two inputs:
- Normal input from rank 0, typically passed as `hidden_states` or `x`
- `intermediate_tensors` from upstream PP ranks
The standard model forward pattern is:
1. If `intermediate_tensors` is present, read the local hidden state from it.
2. Run only this rank's layer slice.
3. Return `IntermediateTensors(...)` on non-last PP ranks.
4. Return the final model output on the last PP rank. `CFGParallelMixin.predict_noise()` already follows this contract
for common pipelines.
**Minimal example:**
```python
from vllm.sequence import IntermediateTensors
from vllm_omni.diffusion.distributed.parallel_state import get_pp_group
def forward(self, hidden_states=None, intermediate_tensors=None, **kwargs):
if intermediate_tensors is not None:
hidden_states = intermediate_tensors["hidden_states"]
for i in range(self.start_layer, self.end_layer):
hidden_states = self.layers[i](hidden_states)
pp_group = get_pp_group()
if not pp_group.is_last_rank:
return IntermediateTensors({"hidden_states": hidden_states})
return (hidden_states,)
```
### Step 3: Partition the transformer layers
The local module on each PP rank must expose only that rank's layer slice. In the reference tests and vLLM model
implementations, this is typically done with vLLM utilities such as `make_layers(...)`, which fill missing ranges with
`PPMissingLayer`. Besides partitioning the layers themselves, model authors should also wire
`make_empty_intermediate_tensors_factory(...)` for intermediate tensor allocation and `is_pp_missing_parameter(...)` for
PP-aware weight loading.
To prepare a transformer for PP, implement the following pieces in order.
#### 3.1 Split the transformer layers across PP ranks
Each PP rank should own only its local layer range, typically exposed as `[start_layer, end_layer)`. In practice this is
usually done with `make_layers(...)`, which constructs local layers and fills missing ranges with `PPMissingLayer`.
The goal is:
- every PP rank knows its `[start_layer, end_layer)` range
- non-local layers do not execute on this rank
- the forward can resume from incoming `intermediate_tensors`
By default, layers are distributed evenly across PP ranks using `get_pp_indices()`. When the number of layers is not
divisible by the PP size, remaining layers are assigned to middle partitions to balance compute and memory. You can
override this with the `VLLM_PP_LAYER_PARTITION` environment variable to specify an exact per-rank layer count:
```bash
# Example: 40 layers across 4 PP ranks, assigning 8 / 12 / 12 / 8 layers
export VLLM_PP_LAYER_PARTITION=8,12,12,8
```
The value must be a comma-separated list of integers whose length equals `pipeline_parallel_size` and whose sum equals
the total number of transformer layers.
#### 3.2 Expose `make_empty_intermediate_tensors`
Your transformer module should expose `self.make_empty_intermediate_tensors`, typically created with
`make_empty_intermediate_tensors_factory(...)`.
This is important because PP ranks need a consistent way to allocate placeholder `IntermediateTensors` with the expected
keys and hidden dimension.
**Example:**
```python
from vllm.model_executor.models.utils import make_empty_intermediate_tensors_factory
self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
["hidden_states"],
inner_dim,
)
```
For Wan2.2, the intermediate payload between PP stages is the token sequence stored under `"hidden_states"`, so the
factory is created with that key and the transformer hidden size.
#### 3.3 Return `IntermediateTensors` on non-last PP ranks
Your model `forward()` or custom `predict_noise()` implementation should consume `intermediate_tensors` on non-first
ranks and return `IntermediateTensors(...)` on non-last ranks.
This lets each PP stage resume from the upstream hidden states and pass the local result to the next stage.
#### 3.4 Skip non-local weights during `load_weights()`
When a model is PP-partitioned, many parameters in the checkpoint belong to layers that do not exist on the current
rank. `load_weights()` must therefore skip parameters for missing PP stages using `is_pp_missing_parameter(...)`.
If this is not done, weight loading will either fail or incorrectly try to load tensors into `PPMissingLayer`
placeholders.
The Wan2.2 transformer is the best reference here: it uses `is_pp_missing_parameter(...)` before loading both remapped
and fused parameters.
If your model has multiple transformer variants, PP still works as long as each selected transformer obeys the same
contract.
### Step 4: Use the standard denoising contract
vLLM-Omni diffusion pipelines already route denoising through `diffuse()`, `predict_noise_maybe_with_cfg()`, and
`scheduler_step_maybe_with_cfg()`. `PipelineParallelMixin` overrides those standard helpers when
`pipeline_parallel_size > 1`, so model integrations should not add separate PP-specific helper names or manual post-loop
synchronization.
In PP mode:
- Non-last PP ranks return `None` from `predict_noise_maybe_with_cfg()` after sending `IntermediateTensors` downstream.
- The last PP rank returns the final prediction, and with CFG-Parallel every last PP rank in the CFG group receives the
combined result.
- Rank 0 receives `AsyncLatents` from `scheduler_step_maybe_with_cfg()`, which resolves only when the latents are
consumed.
- Pending non-blocking sends are flushed automatically when `diffuse()` exits.
---
## Testing
Use an offline inference script with `pipeline_parallel_size > 1`.
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model=Wan-AI/Wan2.2-TI2V-5B-Diffusers \
--width=1280 \
--height=704 \
--guidance-scale=5.0 \
--prompt="Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage" \
--output=t2v_5B_pp2.mp4 \
--pipeline-parallel-size=2
```
For PP + CFG-Parallel together:
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model=Wan-AI/Wan2.2-TI2V-5B-Diffusers \
--width=1280 \
--height=704 \
--guidance-scale=5.0 \
--prompt="Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage" \
--output=t2v_5B_pp2_cfg2.mp4 \
--pipeline-parallel-size=2 \
--cfg-parallel-size=2
```
**Verify:**
1. The run completes without hangs at the PP stage boundary
2. Output quality matches the non-PP baseline within normal numerical variance
3. Peak memory per GPU drops compared with the single-rank model
4. There are no outstanding communication errors before decode
---
## Troubleshooting
### Issue: `TypeError` on import — `CFGParallelMixin` missing
**Symptoms:** Importing a pipeline that inherits `PipelineParallelMixin` raises:
```
TypeError: YourPipeline inherits PipelineParallelMixin but not CFGParallelMixin.
```
or:
```
TypeError: YourPipeline must inherit PipelineParallelMixin before CFGParallelMixin ...
```
**Cause:** `PipelineParallelMixin` enforces via `__init_subclass__` that the subclass also inherits `CFGParallelMixin`
and lists `PipelineParallelMixin` first in the MRO.
**Solution:** Add `CFGParallelMixin` to the pipeline's base classes after `PipelineParallelMixin`:
```python
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.pipeline_parallel import PipelineParallelMixin
class YourPipeline(nn.Module, PipelineParallelMixin, CFGParallelMixin):
...
```
### Issue: Non-last PP ranks crash when calling `predict_noise`
**Symptoms:** Shape or missing-input errors on ranks other than the first or last PP rank.
**Cause:** The model forward path assumes direct input tensors and ignores `intermediate_tensors`.
**Solution:** Update the transformer `forward()` or custom `predict_noise()` path to load hidden states from
`intermediate_tensors` when present.
### Issue: PP outputs differ from single-GPU baseline
**Symptoms:** The PP run finishes but produces numerically inconsistent outputs.
**Causes & Solutions:**
- **Local layer partitioning is wrong**
- Verify each rank runs only its `[start_layer, end_layer)` slice
- **Non-last ranks return a plain tensor instead of `IntermediateTensors`**
- Return `IntermediateTensors({...})` until the last PP stage
- **CFG branch wiring is incorrect**
- When CFG is enabled, confirm positive and negative kwargs are passed exactly as in the non-PP path
---
## Reference Implementations
Complete examples in the codebase:
| Component | Path | Notes |
|-------------------------|------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
| `PipelineParallelMixin` | `vllm_omni/diffusion/distributed/pipeline_parallel.py` | Core PP communication and scheduler helpers |
| `CFGParallelMixin` | `vllm_omni/diffusion/distributed/cfg_parallel.py` | Default `predict_noise()` tuple normalization and CFG helper fallback |
| Wan2.2 transformer | `vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py` | Reference for layer partitioning, `IntermediateTensors`, `make_empty_intermediate_tensors`, and PP-aware weight loading |
| Wan2.2 T2V pipeline | `vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py` | Reference PP + CFG integration for text-to-video |
| Wan2.2 I2V pipeline | `vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py` | Reference PP + CFG integration for image-to-video |
| PP tests | `tests/diffusion/distributed/test_pipeline_parallel.py` | Baseline parity and async communication tests |
@@ -1,4 +1,4 @@
# Diffusion Chat Completions API
# Chat Completions API
vLLM-Omni supports generating and editing images via the `/v1/chat/completions`
endpoint using diffusion models. This page explains how to pass generation
+7
View File
@@ -5,6 +5,9 @@ vLLM-Omni provides an OpenAI-compatible API for text-to-speech (TTS) generation.
- **Qwen3-TTS** (`Qwen/Qwen3-TTS-12Hz-*`) -- Qwen3-based TTS with CustomVoice, VoiceDesign, and Base (voice cloning) task types. Output: 24 kHz.
- **Fish Speech S2 Pro** (`fishaudio/s2-pro`) -- Dual-AR TTS with DAC codec. Supports text-to-speech and voice cloning via reference audio. Output: 44.1 kHz.
- **Voxtral TTS** (`mistralai/Voxtral-4B-TTS-2603`) -- AR + FlowMatching TTS with preset voices. Output: 24 kHz.
- **CosyVoice3** (`FunAudioLLM/Fun-CosyVoice3-0.5B-2512`) -- 2-stage talker + flow-matching code2wav. Voice cloning via `ref_audio` + `ref_text` (no presets). Output: 24 kHz.
See the [Supported Models](#supported-models) section below for the full list, including OmniVoice, VoxCPM2, and MOSS-TTS-Nano.
Each server instance runs a single model (specified at startup via `vllm serve <model> --omni`).
@@ -26,6 +29,10 @@ vllm serve fishaudio/s2-pro --omni --port 8091
# Voxtral TTS
vllm serve mistralai/Voxtral-4B-TTS-2603 --omni --port 8091
# CosyVoice3 (voice cloning only — supply ref_audio + ref_text per request)
vllm serve FunAudioLLM/Fun-CosyVoice3-0.5B-2512 \
--omni --port 8091 --trust-remote-code
```
### Generate Speech
+152
View File
@@ -0,0 +1,152 @@
# Videos API
vLLM-Omni provides an OpenAI-compatible video generation API for diffusion
video models. The API supports asynchronous video jobs through `/v1/videos` and
a synchronous benchmark-oriented endpoint through `/v1/videos/sync`.
Each server instance runs a single model specified at startup with
`vllm serve <model> --omni`.
## Quick Start
### Start the Server
```bash
vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --port 8091
```
### Create a Video Job
```bash
create_response=$(curl -s http://localhost:8091/v1/videos \
-F "prompt=A cinematic tracking shot of a mountain lake at sunrise" \
-F "width=1280" \
-F "height=720" \
-F "num_frames=80" \
-F "fps=16" \
-F "num_inference_steps=40")
video_id=$(echo "${create_response}" | jq -r '.id')
```
### Poll and Download
```bash
curl -s "http://localhost:8091/v1/videos/${video_id}" | jq .
curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4
```
## API Reference
### Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/videos` | `POST` | Create an asynchronous video generation job |
| `/v1/videos/sync` | `POST` | Generate a video synchronously and return raw video bytes |
| `/v1/videos/{video_id}` | `GET` | Retrieve job status and metadata |
| `/v1/videos` | `GET` | List stored video jobs |
| `/v1/videos/{video_id}/content` | `GET` | Download generated video content |
| `/v1/videos/{video_id}` | `DELETE` | Delete a video job and stored output |
### Request Parameters
`POST /v1/videos` and `POST /v1/videos/sync` accept `multipart/form-data`.
#### OpenAI-style fields
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | string | **required** | Text prompt for video generation |
| `model` | string | server's model | Optional model name |
| `seconds` | string | null | Requested clip duration in seconds |
| `size` | string | null | Requested output size in `WIDTHxHEIGHT` format |
| `user` | string | null | Optional user identifier |
#### vLLM-Omni extension fields
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `input_reference` | file | null | Uploaded reference image for image-to-video requests |
| `image_reference` | string | null | JSON-encoded reference image payload; do not combine with `input_reference` |
| `width` | integer | model default | Output video width |
| `height` | integer | model default | Output video height |
| `num_frames` | integer | model default | Number of generated frames |
| `fps` | integer | model default | Output frames per second |
| `num_inference_steps` | integer | model default | Number of diffusion steps |
| `guidance_scale` | number | null | CFG guidance scale for the low-noise stage |
| `guidance_scale_2` | number | null | CFG guidance scale for the high-noise stage |
| `boundary_ratio` | number | null | Boundary split ratio for multi-stage denoising |
| `flow_shift` | number | null | Scheduler flow-shift value |
| `true_cfg_scale` | number | null | True CFG scale when supported by the model |
| `seed` | integer | null | Random seed for reproducibility |
| `negative_prompt` | string | null | Text describing what to avoid in the generated video |
| `enable_frame_interpolation` | boolean | null | Enable post-generation frame interpolation |
| `frame_interpolation_exp` | integer | null | Interpolation exponent; `1=2x`, `2=4x`, and so on |
| `frame_interpolation_scale` | number | null | RIFE inference scale |
| `frame_interpolation_model_path` | string | null | Local path or Hugging Face repo for the interpolation model |
| `lora` | string | null | JSON-encoded LoRA configuration object |
| `extra_params` | string | null | JSON-encoded object for additional model-specific parameters |
### Create Response
`POST /v1/videos` returns a job record:
```json
{
"id": "video-123",
"status": "queued",
"created_at": 1701234567
}
```
The final content is available from `/v1/videos/{video_id}/content` after the
job status becomes `completed`.
### Synchronous Response
`POST /v1/videos/sync` blocks until generation finishes and returns raw video
bytes. It is useful for benchmarks and simple scripts that do not need job
storage or polling.
## Examples
### Image-to-Video
```bash
curl -s http://localhost:8091/v1/videos \
-F "prompt=animate this image with subtle camera movement" \
-F "input_reference=@input.png" \
-F "width=1280" \
-F "height=720" \
-F "num_frames=80" \
-F "fps=16"
```
### Synchronous Generation
```bash
curl -X POST http://localhost:8091/v1/videos/sync \
-F "prompt=A small robot walking through a neon city" \
-F "width=854" \
-F "height=480" \
-F "num_frames=80" \
-F "fps=16" \
-o output.mp4
```
## Storage
Set `VLLM_OMNI_STORAGE_PATH` to control where asynchronous video outputs are
stored:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
```
## Model-Specific Examples
For complete text-to-video and image-to-video walkthroughs, see:
- [Text-to-Video](../user_guide/examples/online_serving/text_to_video.md)
- [Image-to-Video](../user_guide/examples/online_serving/image_to_video.md)
@@ -28,7 +28,6 @@ Frame interpolation is currently supported for:
- `WanPipeline` (Wan2.2 text-to-video)
- `WanImageToVideoPipeline`
- `Wan22TI2VPipeline`
## Request Parameters
@@ -4,13 +4,14 @@ This guide covers the parallelism methods in vLLM-Omni for speeding up diffusion
## Supported Methods
| Method | Description |
|--------|-------------|
| **[Tensor Parallelism](tensor_parallel.md)** | Shards DiT weights across GPUs to reduce per-GPU memory |
| **[Sequence Parallelism](sequence_parallel.md)** | Splits sequence dimension across GPUs (Ulysses-SP, Ring-Attention, or hybrid) for high-resolution images and videos |
| **[CFG-Parallel](cfg_parallel.md)** | Runs CFG positive/negative branches on separate GPUs for ~1.8x speedup on guided generation |
| **[VAE Patch Parallelism](vae_patch_parallel.md)** | Distributes VAE decode spatially across GPUs to reduce peak VAE memory |
| **[HSDP](hsdp.md)** | Shards full model weights via PyTorch FSDP2 to enable large-model inference on memory-constrained GPUs |
| **[Expert Parallelism](expert_parallel.md)** | Shards MoE expert blocks across GPUs for MoE models (e.g. HunyuanImage3.0) |
| Method | Description |
|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
| **[Tensor Parallelism](tensor_parallel.md)** | Shards DiT weights across GPUs to reduce per-GPU memory |
| **[Sequence Parallelism](sequence_parallel.md)** | Splits sequence dimension across GPUs (Ulysses-SP, Ring-Attention, or hybrid) for high-resolution images and videos |
| **[CFG-Parallel](cfg_parallel.md)** | Runs CFG positive/negative branches on separate GPUs for ~1.8x speedup on guided generation |
| **[Pipeline Parallelism](pipeline_parallel.md)** | Splits the denoising transformer block-wise across sequential GPU stages to reduce per-GPU model memory |
| **[VAE Patch Parallelism](vae_patch_parallel.md)** | Distributes VAE decode spatially across GPUs to reduce peak VAE memory |
| **[HSDP](hsdp.md)** | Shards full model weights via PyTorch FSDP2 to enable large-model inference on memory-constrained GPUs |
| **[Expert Parallelism](expert_parallel.md)** | Shards MoE expert blocks across GPUs for MoE models (e.g. HunyuanImage3.0) |
See [Supported Models](../../diffusion_features.md#supported-models) for per-model compatibility.
@@ -0,0 +1,205 @@
# Pipeline Parallelism Guide
## Table of Content
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Example Script](#example-script)
- [Configuration Parameters](#configuration-parameters)
- [Best Practices](#best-practices)
- [Troubleshooting](#troubleshooting)
- [Summary](#summary)
---
## Overview
Pipeline Parallelism splits the denoising transformer block-wise into sequential stages across GPUs. Each rank owns only
part of the transformer, which reduces per-GPU model memory and enables larger diffusion models to run across multiple
devices.
It can also be combined with other distributed methods such as CFG-Parallel, Tensor Parallelism, and Sequence
Parallelism.
See supported models list in [Supported Models](../../diffusion_features.md#supported-models).
---
## Quick Start
### Basic Usage
Simplest working example:
```python
from vllm_omni import Omni
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="Wan-AI/Wan2.2-TI2V-5B-Diffusers",
parallel_config=DiffusionParallelConfig(
pipeline_parallel_size=2,
),
)
outputs = omni.generate(
{"prompt": "A cinematic drone shot over snowy mountains"},
OmniDiffusionSamplingParams(
num_inference_steps=40,
num_frames=81,
height=704,
width=1280,
),
)
```
---
## Example Script
### Offline Inference
Use python scripts under:
- `examples/offline_inference/text_to_video/text_to_video.py`
- `examples/offline_inference/image_to_video/image_to_video.py`
Text-to-video example:
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model=Wan-AI/Wan2.2-TI2V-5B-Diffusers \
--width=1280 \
--height=704 \
--guidance-scale=5.0 \
--prompt="Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage" \
--output=t2v_5B_pp2.mp4 \
--pipeline-parallel-size=2
```
Pipeline Parallelism can also be combined with CFG-Parallel:
```bash
python examples/offline_inference/text_to_video/text_to_video.py \
--model=Wan-AI/Wan2.2-TI2V-5B-Diffusers \
--width=1280 \
--height=704 \
--guidance-scale=5.0 \
--prompt="Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage" \
--output=t2v_5B_pp2_cfg2.mp4 \
--pipeline-parallel-size=2 \
--cfg-parallel-size=2
```
### Online Serving
Enable Pipeline Parallelism in online serving:
```bash
# Default PP configuration
vllm serve Wan-AI/Wan2.2-TI2V-5B-Diffusers --omni --port 8091 --pipeline-parallel-size 2
# PP + CFG-Parallel
vllm serve Wan-AI/Wan2.2-TI2V-5B-Diffusers --omni --port 8091 \
--pipeline-parallel-size 2 \
--cfg-parallel-size 2
```
---
## Configuration Parameters
In `DiffusionParallelConfig`
| Parameter | Type | Default | Description |
|--------------------------|------|---------|------------------------------------------------------------------------------------------------------------------|
| `pipeline_parallel_size` | int | 1 | Number of pipeline-parallel stages. Set to a value greater than 1 to split the denoising transformer across GPUs |
> [!NOTE]
> Total GPU count is the product of all enabled distributed dimensions, for example
`pipeline_parallel_size * cfg_parallel_size * tensor_parallel_size * ulysses_degree * ring_degree`.
### Manual Layer Partitioning
By default, transformer layers are distributed evenly across PP ranks. You can override this with the
`VLLM_PP_LAYER_PARTITION` environment variable to assign a specific number of layers to each rank:
```bash
# Example: 40 layers across 4 PP ranks, assigning 8 / 12 / 12 / 8 layers
export VLLM_PP_LAYER_PARTITION=8,12,12,8
```
The value must be a comma-separated list of integers whose length equals `pipeline_parallel_size` and whose sum equals
the total number of transformer layers. This is useful when you want to balance memory or compute asymmetrically across
ranks.
---
## Best Practices
### When to Use
**Good for:**
- Large diffusion transformers that do not fit comfortably on one GPU
- Multi-GPU setups where reducing per-GPU model memory is more important than minimizing communication
- Combining with CFG-Parallel or other distributed methods on supported models
**Not for:**
- Single GPU setups
- Models that do not support Pipeline Parallelism
(check [supported models](../../diffusion_features.md#supported-models))
- Very small models where inter-stage communication overhead may outweigh the benefit
### Expected Behavior
Pipeline Parallelism primarily reduces per-GPU memory usage by splitting the transformer into stage-local blocks.
Depending on the model, topology, and resolution, it may also help execution fit into available hardware, but it is not
primarily a latency optimization.
---
## Troubleshooting
### Common Issue 1: No benefit from Pipeline Parallelism
**Symptoms**: PP is enabled, but latency does not improve or becomes slightly worse.
**Solutions**:
1. **Check your goal:**
```python
# PP is mainly for memory scaling, not guaranteed latency speedup
parallel_config = DiffusionParallelConfig(pipeline_parallel_size=2)
```
2. **Check model support:**
- Verify your model in [supported models](../../diffusion_features.md#supported-models)
- PP is currently validated only on selected pipelines
3. **Combine with other methods when appropriate:**
- PP can be combined with CFG-Parallel, Tensor Parallelism, or Sequence Parallelism on supported models
### Common Issue 2: PP pipeline fails at import
**Symptoms**: Importing a custom pipeline raises a `TypeError` about `CFGParallelMixin` or mixin order.
**Solutions**:
1. Inherit both mixins.
2. Put `PipelineParallelMixin` before `CFGParallelMixin` in the class base list.
3. Use `class YourPipeline(nn.Module, PipelineParallelMixin, CFGParallelMixin): ...` as the reference pattern.
---
## Summary
1. ✅ **Enable Pipeline Parallelism** - Set `pipeline_parallel_size > 1` in `DiffusionParallelConfig`
2. ✅ **Use Supported Models** - Verify your model supports PP in
[supported models](../../diffusion_features.md#supported-models)
3. ✅ **Combine When Needed** - PP can be combined with CFG-Parallel and other distributed methods on supported pipelines
4. ✅ **Scale for Memory** - Use PP primarily to reduce per-GPU model memory and fit larger transformers
@@ -60,6 +60,9 @@ batching only becomes relevant when `--max-num-seqs > 1`.
- `cache_backend` is not supported together with step execution.
- Unsupported pipelines fail early during model loading.
- Request-mode extras such as KV transfer are not wired into step mode yet.
- LoRA is supported in step mode, but each batch must use a single adapter:
requests with different `lora_request` or `lora_scale` are scheduled into
separate batches.
## When To Use It
+50 -49
View File
@@ -35,14 +35,15 @@ Cache methods trade minimal quality for significant speedup. Quality loss is typ
Parallelism methods distribute computation across GPUs without quality loss (mathematically equivalent to single-GPU).
| Method | Description | Best For |
|--------|-------------|----------|
| **[Ulysses-SP](diffusion/parallelism/sequence_parallel.md)** | Sequence parallelism via all-to-all communication | High-resolution images (>1536px) or long videos with 2-8 GPUs |
| **[Ring-Attention](diffusion/parallelism/sequence_parallel.md)** | Sequence parallelism via ring-based communication | Videos, very long sequences, memory-constrained, with 2-8 GPUs |
| **[CFG-Parallel](diffusion/parallelism/cfg_parallel.md)** | Splits CFG positive/negative branches across devices | Image editing with CFG guidance (true_cfg_scale > 1) on 2 GPUs |
| **[Tensor Parallelism](diffusion/parallelism/tensor_parallel.md)** | Shards model weights across devices | Large models that don't fit in single GPU, with 2+ GPUs |
| **[HSDP](diffusion/parallelism/hsdp.md)** | Weight sharding via FSDP2, redistributed on-demand at runtime | Very large models (14B+) on limited VRAM, combinable with SP |
| **[Expert Parallelism](diffusion/parallelism/expert_parallel.md)** | Shards MoE expert MLP blocks across devices | MoE diffusion models (e.g., HunyuanImage3.0) |
| Method | Description | Best For |
|------------------------------------------------------------------------|--------------------------------------------------------------------------|-------------------------------------------------------------------|
| **[Ulysses-SP](diffusion/parallelism/sequence_parallel.md)** | Sequence parallelism via all-to-all communication | High-resolution images (>1536px) or long videos with 2-8 GPUs |
| **[Ring-Attention](diffusion/parallelism/sequence_parallel.md)** | Sequence parallelism via ring-based communication | Videos, very long sequences, memory-constrained, with 2-8 GPUs |
| **[CFG-Parallel](diffusion/parallelism/cfg_parallel.md)** | Splits CFG positive/negative branches across devices | Image editing with CFG guidance (true_cfg_scale > 1) on 2 GPUs |
| **[Tensor Parallelism](diffusion/parallelism/tensor_parallel.md)** | Shards model weights across devices | Large models that don't fit in single GPU, with 2+ GPUs |
| **[Pipeline Parallelism](diffusion/parallelism/pipeline_parallel.md)** | Splits the denoising transformer block-wise across sequential GPU stages | Large diffusion transformers that need lower per-GPU model memory |
| **[HSDP](diffusion/parallelism/hsdp.md)** | Weight sharding via FSDP2, redistributed on-demand at runtime | Very large models (14B+) on limited VRAM, combinable with SP |
| **[Expert Parallelism](diffusion/parallelism/expert_parallel.md)** | Shards MoE expert MLP blocks across devices | MoE diffusion models (e.g., HunyuanImage3.0) |
#### Startup Optimization
@@ -105,32 +106,32 @@ The following tables show which models support each feature:
### ImageGen
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
| **Bagel** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **FLUX.1-dev** | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.1-schnell** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.2-klein** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.1-Kontext-dev** | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **FLUX.2-dev** | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **GLM-Image** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
| **LongCat-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **LongCat-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **MagiHuman** | ❌ | ❌ | ❌ | ❓ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **MammothModa2(T2I)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Nextstep_1(T2I)** | ❓ | ❓ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **OmniGen2** | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Ovis-Image** | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Qwen-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ✅ |
| **Qwen-Image-2512** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ✅ |
| **Qwen-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **SenseNova-U1** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ✅ | ❌ | ✅ (decode) | ✅ | ❌ |
| **ERNIE-Image** | ❌ | ✅ | ✅ | ❓ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀Pipeline-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|--------------------------|:---------:|:----------:|:---------------------:|:--------------:|:-----------------:|:-------------------:|:------:|:-------------------------:|:--------------------:|:--------------:|:----------------:|
| **Bagel** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **FLUX.1-dev** | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.1-schnell** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.2-klein** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.1-Kontext-dev** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **FLUX.2-dev** | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **GLM-Image** | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
| **LongCat-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **LongCat-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **MagiHuman** | ❌ | ❌ | ❌ | ❓ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **MammothModa2(T2I)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Nextstep_1(T2I)** | ❓ | ❓ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **OmniGen2** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Ovis-Image** | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Qwen-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ✅ | ✅ |
| **Qwen-Image-2512** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ✅ | ✅ |
| **Qwen-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **SenseNova-U1** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ❌ | ✅ | ❌ | ✅ (decode) | ✅ | ❌ |
| **ERNIE-Image** | ❌ | ✅ | ✅ | ❓ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
> Notes:
> 1. Nextstep_1(T2I) does not support cache acceleration methods such as TeaCache or Cache-DiT.
@@ -138,15 +139,15 @@ The following tables show which models support each feature:
### VideoGen
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
| **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ |
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **LTX-2.3** | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | Pipeline-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|------------------------------|:---------:|:----------:|:---------------------:|:--------------:|:-----------------:|:-----------------:|:------:|:-------------------------:|:--------------------:|:--------------:|:----------------:|
| **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ |
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **LTX-2.3** | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
**Frame Interpolation Support**
@@ -155,9 +156,9 @@ The following tables show which models support each feature:
### AudioGen
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
| **Stable-Audio-Open** | ✅ | ❌ | ❓ | ❓ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ |
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀Pipeline-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|-----------------------|:---------:|:----------:|:---------------------:|:--------------:|:-----------------:|:-------------------:|:------:|:-------------------------:|:--------------------:|:--------------:|:----------------:|
| **Stable-Audio-Open** | ✅ | ❌ | ❓ | ❓ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ |
## Feature Compatibility
@@ -183,7 +184,7 @@ The following tables show which models support each feature:
| **💾VAE Patch Parallel** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | | | |
| **💾FP8 Quant** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | ✅ | ✅ | ✅ | | | |
| **🔧LoRA Inference** | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | |
| **🔄Step Execution** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | ✅ | ❓ | ✅ | ✅ | ❌ | |
| **🔄Step Execution** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | ✅ | ❓ | ✅ | ✅ | ✅ | |
!!! info
@@ -192,7 +193,7 @@ The following tables show which models support each feature:
3. CPU Offloading (Layerwise) and CPU Offloading (Module-wise) are not compatible.
4. CPU Offloading (Layerwise) supports single-card for now.
5. Using FP8-Quant as an example of qunatization methods.
6. Step Execution is not compatible with cache backends (TeaCache, Cache-DiT) or LoRA.
6. Step Execution is not compatible with cache backends (TeaCache, Cache-DiT). LoRA is supported, but each scheduled batch must use a single adapter (requests with different `lora_request` or `lora_scale` are kept in separate batches).
## Multi-Thread Weight Loading
@@ -257,7 +258,7 @@ Measured on NVIDIA H800:
**Parallelism Methods:**
- **[Parallelism Overview](diffusion/parallelism/overview.md)** - Tensor Parallelism, Sequence Parallelism, CFG Parallelism, HSDP, and Expert Parallelism
- **[Parallelism Overview](diffusion/parallelism/overview.md)** - Tensor Parallelism, Sequence Parallelism, CFG Parallelism, Pipeline Parallelism, HSDP, and Expert Parallelism
**Memory Optimization:**
@@ -17,7 +17,6 @@ list of supported architectures across all modalities, see
| Model | HuggingFace repo | Stages | Voice cloning | Streaming | Special modes | Sample rate |
|---|---|---|---|---|---|---|
| VoxCPM2 | `openbmb/VoxCPM2` | single (native AR) | ✓ | — | continuation (`--ref-audio` + `--ref-text`) | 48 kHz |
| VoxCPM | local model dir | split | ✓ | ✓ (`voxcpm_async_chunk.yaml`) | — | 24 kHz |
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ (`async_chunk: true` default) | — | 22.05 kHz |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ (`--streaming`) | — | 44.1 kHz |
| OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design (`--instruct`), language (`--lang`) | 24 kHz |
@@ -72,61 +71,6 @@ python examples/offline_inference/text_to_speech/voxcpm2/end2end.py \
---
## VoxCPM
Split-stage TTS. The hub example covers single TTS, single voice cloning, and streaming. Use `benchmarks/voxcpm/` for warmup, batch JSONL prompts, profiler injection, and offline TTFP / RTF measurement.
### Prerequisites
```bash
pip install voxcpm soundfile
# or use a local source tree:
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
```
If the native VoxCPM `config.json` does not contain HF metadata such as `model_type`, prepare a persistent HF-compatible config directory and point the stage configs to it via `VLLM_OMNI_VOXCPM_HF_CONFIG_PATH`:
```bash
export VOXCPM_MODEL=/path/to/voxcpm-model
export VLLM_OMNI_VOXCPM_HF_CONFIG_PATH=/tmp/voxcpm_hf_config
mkdir -p "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"
cp "$VOXCPM_MODEL/config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/config.json"
cp "$VOXCPM_MODEL/generation_config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/generation_config.json" 2>/dev/null || true
python3 -c 'import json, os; p=os.path.join(os.environ["VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"], "config.json"); cfg=json.load(open(p, "r", encoding="utf-8")); cfg["model_type"]="voxcpm"; cfg.setdefault("architectures", ["VoxCPMForConditionalGeneration"]); json.dump(cfg, open(p, "w", encoding="utf-8"), indent=2, ensure_ascii=False)'
```
### Quick start
```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model "$VOXCPM_MODEL" \
--text "This is a split-stage VoxCPM synthesis example running on vLLM Omni."
```
### Voice cloning
```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model "$VOXCPM_MODEL" \
--text "This sentence is synthesized with a cloned voice." \
--ref-audio /path/to/reference.wav \
--ref-text "The exact transcript spoken in reference.wav."
```
### Streaming
Pass the async-chunk stage config:
```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model "$VOXCPM_MODEL" \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
--text "This is a split-stage VoxCPM streaming example running on vLLM Omni."
```
### Notes
- `voxcpm.yaml` is the default non-streaming stage config; `voxcpm_async_chunk.yaml` enables streaming.
- Streaming is currently single-request oriented.
- `--ref-text` must be the real transcript of `--ref-audio`; mismatched text degrades quality.
- For online serving, see [`examples/online_serving/voxcpm`](https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/voxcpm/README.md). For benchmark reporting, see [`benchmarks/voxcpm`](https://github.com/vllm-project/vllm-omni/tree/main/benchmarks/voxcpm/README.md).
---
## CosyVoice3
2-stage TTS pipeline (`talker` + `code2wav`) at 22.05 kHz.
@@ -381,10 +325,6 @@ Available voice presets are listed on the HF model card (`mistralai/Voxtral-4B-T
``````py
--8<-- "examples/offline_inference/text_to_speech/qwen3_tts/end2end.py"
``````
??? abstract "voxcpm/end2end.py"
``````py
--8<-- "examples/offline_inference/text_to_speech/voxcpm/end2end.py"
``````
??? abstract "voxcpm2/end2end.py"
``````py
--8<-- "examples/offline_inference/text_to_speech/voxcpm2/end2end.py"
@@ -101,7 +101,7 @@ guides.
When using `/v1/chat/completions`, pass these inside `extra_body` in the curl
JSON, or via the `extra_body` keyword argument in the OpenAI Python SDK (see the
[Diffusion Chat API guide](../../../serving/diffusion_chat_api.md)).
[Chat Completions API guide](../../../serving/chat_completions_api.md)).
When using the dedicated [`/v1/images/generations`](../../../serving/image_generation_api.md)
or [`/v1/images/edits`](../../../serving/image_edit_api.md) endpoints, pass
the supported generation controls as top-level fields directly. For image
@@ -208,7 +208,7 @@ Wrap generation parameters inside `extra_body` in the request JSON:
```
For details on how generation parameters are handled across different clients, see the
[Diffusion Chat API guide](../../../../serving/diffusion_chat_api.md).
[Chat Completions API guide](../../../../serving/chat_completions_api.md).
### Layered Image Generation (Qwen-Image-Layered)
@@ -361,7 +361,7 @@ Provide multiple images in `content` (order matters):
When using `/v1/chat/completions`, pass these inside `extra_body` in the curl
JSON, or via the `extra_body` keyword argument in the OpenAI Python SDK (see the
[Diffusion Chat API guide](../../../../serving/diffusion_chat_api.md)).
[Chat Completions API guide](../../../../serving/chat_completions_api.md)).
When using the dedicated [`/v1/images/edits`](../../../../serving/image_edit_api.md)
endpoint, pass the supported generation controls as top-level form fields
directly. For image dimensions and count, use `size` and `n` rather than
@@ -216,7 +216,7 @@ Wrap generation parameters inside `extra_body` in the request JSON:
```
For details on how generation parameters are handled across different clients, see the
[Diffusion Chat API guide](../../../../serving/diffusion_chat_api.md).
[Chat Completions API guide](../../../../serving/chat_completions_api.md).
### Multimodal Input (Text + Structured Content)
@@ -237,7 +237,7 @@ Wrap generation parameters inside `extra_body` in the request JSON:
When using `/v1/chat/completions`, pass these inside `extra_body` in the curl
JSON, or via the `extra_body` keyword argument in the OpenAI Python SDK (see the
[Diffusion Chat API guide](../../../../serving/diffusion_chat_api.md)).
[Chat Completions API guide](../../../../serving/chat_completions_api.md)).
When using the dedicated [`/v1/images/generations`](../../../../serving/image_generation_api.md)
endpoint, pass the supported generation controls as top-level JSON fields
directly. For image dimensions and count, use `size` and `n` rather than
@@ -17,14 +17,14 @@ For the full list of supported architectures across all modalities, see
| Model | HuggingFace repo | Voice cloning | Streaming | Voice presets / upload | Gradio demo |
|---|---|---|---|---|---|
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | — |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | ✓ |
| OmniVoice | `k2-fsa/OmniVoice` | (offline only) | — | — | — |
| Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | ✓ (Base) | ✓ (PCM + WebSocket) | ✓ (presets + `/v1/audio/voices` upload) | ✓ (standard + FastRTC) |
| VoxCPM | local model dir | ✓ | ✓ (PCM stream) | — | — |
| VoxCPM2 | `openbmb/VoxCPM2` | ✓ | ✓ (AudioWorklet via gradio) | — | ✓ |
| Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | ✓ (gated upstream) | ✓ | ✓ (presets) | ✓ |
CosyVoice3 is intentionally absent: no online example exists for it yet. See its [offline section](https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/text_to_speech/README.md#cosyvoice3) instead.
For offline inference of any of these models, see the [offline TTS hub](https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/text_to_speech/README.md).
## Common Quick Start
@@ -96,6 +96,54 @@ For full request-shape documentation (all parameters, response formats, error co
---
## CosyVoice3
2-stage TTS (`talker` + flow-matching `code2wav`) at 24 kHz. Voice cloning only — every request needs `ref_audio` + `ref_text`; there are no built-in voice presets.
### Prerequisites
```bash
huggingface-cli download FunAudioLLM/Fun-CosyVoice3-0.5B-2512
```
If your downloaded checkpoint lacks `config.json`, add one with `{"model_type": "cosyvoice3", "architectures": ["CosyVoice3Model"]}` (the loader reads `model_type` to select the class).
### Launch
```bash
vllm serve FunAudioLLM/Fun-CosyVoice3-0.5B-2512 --omni --port 8091 --trust-remote-code
# or:
./cosyvoice3/run_server.sh
```
Streaming is on by default via `async_chunk: true` in `vllm_omni/deploy/cosyvoice3.yaml`. Pass `--no-async-chunk` (or `NO_ASYNC_CHUNK=1 ./cosyvoice3/run_server.sh`) for the legacy synchronous path.
### CLI client
The client defaults to the official upstream zero-shot prompt, so it runs without extra flags:
```bash
cd examples/online_serving/text_to_speech/cosyvoice3
python speech_client.py --text "收到好友从远方寄来的生日礼物。"
```
Pass your own reference clip and transcript for a different voice:
```bash
python speech_client.py --text "Hello, this is a cloned voice." \
--ref-audio /path/to/reference.wav \
--ref-text "Transcript of the reference audio."
```
Stream PCM instead of WAV:
```bash
python speech_client.py --text "Hello world" --stream --output output.pcm
```
The client supports `--api-base`, `--model`, `--text`, `--ref-audio`, `--ref-text`, `--response-format`, `--stream`, `--output`.
### Notes
- Stage 0 (`talker`) emits speech tokens; stage 1 (`code2wav`) runs flow matching + HiFiGAN to synthesize waveform.
- Deploy config auto-loads from `vllm_omni/deploy/cosyvoice3.yaml` based on HF `model_type`. Pass `--deploy-config <path>` to override.
- For offline inference and the end-to-end script, see the [offline CosyVoice3 section](https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/text_to_speech/README.md#cosyvoice3).
---
## Fish Speech S2 Pro
4B dual-AR TTS at 44.1 kHz. Server uses the DAC codec.
@@ -196,10 +244,10 @@ vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni --port 8091
./qwen3_tts/run_server.sh Base
```
### Choosing an executor backend (uniproc vs mp)
Stage configs ship with the chunked-streaming default. To use the uniproc executor (lower IPC overhead for the Base cloning task), pass `--stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml`. See [#2603](https://github.com/vllm-project/vllm-omni/issues/2603) and [#2604](https://github.com/vllm-project/vllm-omni/pull/2604) for the full investigation.
### Executor backend
Single-GPU serves now default to the uniproc executor (lower IPC overhead, the Base cloning use case from [#2603](https://github.com/vllm-project/vllm-omni/issues/2603) / [#2604](https://github.com/vllm-project/vllm-omni/pull/2604)). `vllm_omni/deploy/qwen3_tts.yaml` is the only Qwen3-TTS deploy config; pass `--deploy-config <path>` to override.
To opt out of chunked streaming, pass `--no-async-chunk` instead — the pipeline auto-dispatches to the end-to-end codec processor.
To opt out of chunked streaming, pass `--no-async-chunk` — the pipeline auto-dispatches to the end-to-end codec processor.
### Sending requests
```bash
@@ -281,71 +329,11 @@ python qwen3_tts/gradio_fastrtc_demo.py --api-base http://localhost:8000
`qwen3_tts/batch_speech_client.py` issues many concurrent requests for throughput measurement.
### Notes
- Base voice cloning has uniproc-vs-mp tradeoffs depending on per-request reference audio cost; see the executor-backend section above.
- Base voice cloning has per-request reference-audio cost; the uniproc default keeps IPC overhead off the critical path. See the executor-backend section above for background.
- `vllm_omni/deploy/qwen3_tts.yaml` is the default deploy config (loaded by HF `model_type`); per-stage runtime overrides are available via `--stage-N-<field> <value>`.
---
## VoxCPM
Split-stage TTS at 24 kHz.
### Prerequisites
```bash
pip install voxcpm
# or use a local source tree:
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
```
If the native VoxCPM `config.json` lacks HF `model_type`, set up an HF-compatible config dir:
```bash
export VOXCPM_MODEL=/path/to/voxcpm-model
export VLLM_OMNI_VOXCPM_HF_CONFIG_PATH=/tmp/voxcpm_hf_config
mkdir -p "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"
cp "$VOXCPM_MODEL/config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/config.json"
cp "$VOXCPM_MODEL/generation_config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/generation_config.json" 2>/dev/null || true
python3 -c 'import json, os; p=os.path.join(os.environ["VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"], "config.json"); cfg=json.load(open(p, "r", encoding="utf-8")); cfg["model_type"]="voxcpm"; cfg.setdefault("architectures", ["VoxCPMForConditionalGeneration"]); json.dump(cfg, open(p, "w", encoding="utf-8"), indent=2, ensure_ascii=False)'
```
### Launch
```bash
export VOXCPM_MODEL=/path/to/voxcpm-model
./voxcpm/run_server.sh # async-chunk streaming (default)
./voxcpm/run_server.sh sync # non-streaming
```
Or directly:
```bash
vllm serve "$VOXCPM_MODEL" \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
--trust-remote-code --enforce-eager --omni --port 8091
```
### Sending requests
```bash
# Basic TTS
python voxcpm/openai_speech_client.py \
--model "$VOXCPM_MODEL" \
--text "This is a VoxCPM online text-to-speech example."
# Voice cloning
python voxcpm/openai_speech_client.py \
--model "$VOXCPM_MODEL" \
--text "This sentence is synthesized with a cloned voice." \
--ref-audio /path/to/reference.wav \
--ref-text "The exact transcript spoken in reference.wav."
# Streaming PCM
python voxcpm/openai_speech_client.py \
--model "$VOXCPM_MODEL" \
--text "This is a streaming VoxCPM request." \
--stream --output voxcpm_stream.pcm
```
### Notes
- `voxcpm.yaml` for one-shot decode; `voxcpm_async_chunk.yaml` for single-request streaming. Do not use the async-chunk config for concurrent requests or `/v1/audio/speech/batch`.
- Generic TTS fields not supported by VoxCPM: `voice`, `instructions`, `language`, `speaker_embedding`, `x_vector_only_mode`.
- For benchmark measurement, see [`benchmarks/voxcpm`](https://github.com/vllm-project/vllm-omni/tree/main/benchmarks/voxcpm/README.md).
---
## VoxCPM2
@@ -407,6 +395,14 @@ The demo handles voice-preset selection and reference-audio upload. `voxtral_tts
## Example materials
??? abstract "cosyvoice3/run_server.sh"
--8<-- "examples/online_serving/text_to_speech/cosyvoice3/run_server.sh"
??? abstract "cosyvoice3/speech_client.py"
--8<-- "examples/online_serving/text_to_speech/cosyvoice3/speech_client.py"
??? abstract "fish_speech/gradio_demo.py"
``````py
--8<-- "examples/online_serving/text_to_speech/fish_speech/gradio_demo.py"
@@ -463,14 +459,6 @@ The demo handles voice-preset selection and reference-audio upload. `voxtral_tts
``````py
--8<-- "examples/online_serving/text_to_speech/qwen3_tts/tts_common.py"
``````
??? abstract "voxcpm/openai_speech_client.py"
``````py
--8<-- "examples/online_serving/text_to_speech/voxcpm/openai_speech_client.py"
``````
??? abstract "voxcpm/run_server.sh"
``````sh
--8<-- "examples/online_serving/text_to_speech/voxcpm/run_server.sh"
``````
??? abstract "voxcpm2/gradio_demo.py"
``````py
--8<-- "examples/online_serving/text_to_speech/voxcpm2/gradio_demo.py"
+3 -1
View File
@@ -32,7 +32,9 @@ guide. AutoRound is Intel-supported.
|-------|------------|-------|--------|---------|
| FLUX.1-dev | `vllm-project-org/FLUX.1-dev-AutoRound-w4a16` | Diffusion transformer | W4A16 | GPTQ-Marlin or Intel-supported AutoRound backend |
| Qwen-Image | Not listed | Diffusion transformer | W4A16 | Not validated |
| Wan2.2 | Not listed | Diffusion transformer | W4A16 | Not validated |
| Wan2.2-I2V | `Intel/Wan2.2-I2V-A14B-Diffusers-int4-AutoRound` | Diffusion transformer | W4A16 | GPTQ-Marlin or Intel-supported AutoRound backend |
| Wan2.2-T2V | `Intel/Wan2.2-T2V-A14B-Diffusers-int4-AutoRound` | Diffusion transformer | W4A16 | GPTQ-Marlin or Intel-supported AutoRound backend |
| Wan2.2-TI2V | `Intel/Wan2.2-TI2V-5B-Diffusers-int4-AutoRound` | Diffusion transformer | W4A16 | GPTQ-Marlin or Intel-supported AutoRound backend |
### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS)
+479
View File
@@ -0,0 +1,479 @@
# W4A4 MXFP4 Quantization
## Overview
W4A4 MXFP4 (Microscaling FP4) quantizes both weights and activations to FP4
(`float4_e2m1fn_x2`, packed 2 values per byte) using the OCP MX format: groups
of 32 K-dimension elements share a single `float8_e8m0fnu` exponent scale.
vLLM-Omni provides two quantization methods with different scale structures:
| Method | Scale structure | Mode | Use case |
|--------|----------------|------|----------|
| `mxfp4` | Single-scale (per-32 fine only) | Online only | Quick accuracy baseline; no checkpoint prep needed |
| `mxfp4_dualscale` | Dual-scale (fine per-32 + coarse per-512 + per-channel `mul_scale`) | Online + Offline | Production; better accuracy; offline recommended |
!!! tip "Recommended: `mxfp4_dualscale` offline"
For production deployments, use the `mxfp4_dualscale` offline mode with a
pre-quantized checkpoint produced by msModelSlim. Offline checkpoints load
calibrated `mul_scale` tensors from disk, providing measurably better accuracy
than any online method. The one-time preprocessing cost amortises across all
subsequent inference runs.
Use `mxfp4` online only for quick experimentation where preprocessing time
is not acceptable and accuracy loss is tolerable.
!!! warning "Online single-scale ≠ Offline dual-scale"
`mxfp4_dualscale` offline mode uses `NPUMxfp4DualScaleLinearMethod`:
fine scale (per-32 K), coarse scale (per-512 K), and per-input-channel
`mul_scale` from calibration — all loaded from the checkpoint.
`mxfp4_dualscale` online mode uses `NPUMxfp4DualScaleOnlineLinearMethod`:
dual-level scales computed on the fly from BF16 weights; no calibration
`mul_scale` is available. Loading an offline checkpoint with the online
method (or vice versa) will produce incorrect results or shape errors.
## Hardware Support
| Device | Support |
|--------|---------|
| NVIDIA Blackwell GPU (SM 100+) | ⭕ |
| NVIDIA Ada/Hopper GPU (SM 89+) | ⭕ |
| NVIDIA Ampere GPU (SM 80+) | ⭕ |
| AMD ROCm | ⭕ |
| Intel XPU | ⭕ |
| Ascend NPU (Atlas 950 A5) | ✅ |
Legend: `✅` supported, `❌` unsupported, `⭕` not verified in this guide.
## Model Type Support
### Diffusion Model (Wan2.2)
| Model | Online | Offline | Notes |
|-------|--------|---------|-------|
| Wan2.2-T2V-A14B | `mxfp4` / `mxfp4_dualscale` | `mxfp4_dualscale` | MoE cascade (`transformer` + `transformer_2`); both transformers quantized with the same config |
| Wan2.2-I2V-A14B | `mxfp4` / `mxfp4_dualscale` | `mxfp4_dualscale` | MoE cascade; same scheme as T2V-A14B |
| Wan2.2-TI2V-5B | ❌ | ❌ | Parameter count too small; W4A4 causes unacceptable accuracy loss |
The choice between `mxfp4` and `mxfp4_dualscale` in **online mode** is about
quantization quality, not model compatibility — both work on cascade (A14B) and
single-transformer models alike, the same as `mxfp8` online:
- `mxfp4`: single-scale, lower overhead, simpler compute, online only
- `mxfp4_dualscale`: dual-scale + optional BF16 fallback, better accuracy, online **and** offline
**Offline** checkpoints for A14B are always in `mxfp4_dualscale` format (produced
by the merge script); there is no offline `mxfp4` single-scale format.
!!! note "Per-layer BF16 fallback in offline cascade models"
The A14B offline checkpoint uses `quant_method: mxfp4_dualscale`. Most
linear layers are stored as W4A4 MXFP4 DualScale; precision-sensitive layers
retain their original BF16 weights and are listed in `ignored_layers` inside
each transformer's `config.json`. The two transformers may have different
`ignored_layers` sets — the pipeline reads each transformer's own `config.json`
and rebuilds the config locally when they differ, so routing is always
per-transformer-accurate.
!!! warning "TI2V-5B not supported"
Wan2.2-TI2V-5B is excluded from W4A4 quantization. Its smaller parameter
count makes it significantly more sensitive to 4-bit quantization noise,
resulting in unacceptable accuracy loss. Use [MXFP8](mxfp8.md) for TI2V-5B.
## Configuration
### `mxfp4` — Single-Scale Online Mode
Online mode requires no pre-processing. vLLM-Omni quantizes BF16 weights to
MXFP4 at load time using `npu_dynamic_mx_quant`. A single block scale
(`float8_e8m0fnu`, one per 32 K elements) is computed on the fly; no
calibration `mul_scale` is available. Applies equally to single-transformer
and cascade (A14B) models — both transformers in a cascade receive the same
quantization config automatically.
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(model="<your-model>", quantization="mxfp4")
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
```bash
# Single-transformer or cascade model — same command
python text_to_video.py --model <your-model> --quantization mxfp4
# Online serving
vllm serve <your-model> --omni --quantization mxfp4
```
### `mxfp4_dualscale` — DualScale Online Mode
Online DualScale mode computes both fine and coarse scales on the fly from BF16
weights using `npu_dynamic_dual_level_mx_quant`. Applies equally to
single-transformer and cascade (A14B) models. Compared to `mxfp4` online,
DualScale provides better quantization accuracy at higher compute cost.
The default configuration keeps the leading 5 transformer blocks in BF16
(`num_bf16_fallback_layers=5`). Accuracy evaluation on Wan2.2-A14B shows this
is sufficient to meet quality requirements and is the recommended setting.
```python
omni = Omni(model="<your-model>", quantization="mxfp4_dualscale")
```
```bash
python text_to_video.py --model <your-model> --quantization mxfp4_dualscale
```
If accuracy debugging identifies additional precision-sensitive layers, they
can be pinned to BF16 via the Python API:
```python
omni = Omni(
model="<your-model>",
quantization_config={
"method": "mxfp4_dualscale",
"ignored_layers": ["blocks.10.attn1.to_q"], # explicit per-layer override
},
)
```
BF16 fallback routing in online mode applies two rules in priority order:
1. **`ignored_layers`** (explicit per-layer override): any layer whose prefix
matches is kept in BF16 regardless of block index.
2. **`num_bf16_fallback_layers`** (coarse leading-block rule): the first N
transformer blocks (`blocks.0` … `blocks.N-1`) fall back to BF16. Defaults
to `5` (recommended). Layers outside `blocks.N.*`
(e.g. `condition_embedder`) are always quantized.
### `mxfp4_dualscale` — DualScale Offline Mode (Recommended)
Offline mode loads a pre-quantized DualScale checkpoint from msModelSlim. A
preprocessing step converts the raw quantized output to the diffusers format
expected by vLLM-Omni and injects the quantization config into each
`transformer/config.json` so that vLLM-Omni auto-detects the offline path
without a `--quantization` flag.
BF16 fallback layers may be interleaved anywhere in the transformer — they are
not restricted to leading blocks. The merge script detects them from
`quant_model_description.json` and writes their prefixes into `ignored_layers`
inside `config.json`. At runtime, each layer's prefix is matched against
`ignored_layers` to decide BF16 vs. MXFP4 DualScale.
#### Checkpoint tensor layout
Each quantized linear layer stores four tensors:
| Tensor | Shape | dtype | Description |
|--------|-------|-------|-------------|
| `weight` | `(N, K)` | float8_e4m3fn | FP4 packed (2 values per byte) |
| `weight_scale` | `(N, K//32)` | uint8 | Fine block scale (`float8_e8m0fnu` bit pattern) |
| `weight_dual_scale` | `(N, K//512, 1)` | float32 | Coarse block scale |
| `mul_scale` | `(K,)` | float32 | Per-input-channel smooth pre-scale (from calibration) |
BF16 fallback layers have no quantization tensors; only the original `weight`
(and optional `bias`) are present, loaded directly from the base checkpoint.
#### Step 1 — Quantize with msModelSlim
```bash
msmodelslim quant \
--model_path /path/to/Wan2.2-T2V-A14B-Diffusers \
--save_path /path/to/wan2_2_t2v_quantized_raw \
--device npu \
--model_type Wan2_2 \
--config_path /path/to/wan2_2_w4a4_mxfp4_dualscale.yaml \
--trust_remote_code True
```
After this step, `--save_path` contains raw quantized safetensors files,
scale files, and a metadata JSON (`quant_model_description*.json`).
For cascade MoE models (T2V-A14B, I2V-A14B), msModelSlim outputs two
subdirectories: `high_noise_model/` (transformer) and `low_noise_model/`
(transformer_2).
#### Step 2 — Preprocess with merge_mxfp4_dualscale_checkpoint.py
The script (`vllm_omni/quantization/tools/merge_mxfp4_dualscale_checkpoint.py`):
1. Copies the original diffusers model to `--output-path` (VAE, text encoder,
scheduler, etc. are preserved).
2. Remaps tensor names from msModelSlim convention to diffusers convention and
strips `.linear.` / `.div.` wrappers added by the quantization tool.
3. Overlays MXFP4 tensors (weight, fine/coarse scales, `mul_scale`) onto the
BF16 base checkpoint. Non-quantized layers keep their original BF16 weights.
4. Detects all linear layers that remain in BF16 and writes their prefixes into
`ignored_layers` in `config.json`.
5. Injects `quantization_config` so vLLM-Omni auto-detects offline MXFP4
DualScale.
For cascade MoE models, steps 2–5 run separately for each transformer.
```bash
python vllm_omni/quantization/tools/merge_mxfp4_dualscale_checkpoint.py \
--model-type Wan2.2-T2V-A14B \
--original-model /path/to/Wan2.2-T2V-A14B-Diffusers \
--quant-path /path/to/wan2_2_t2v_quantized_raw \
--output-path /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale
```
| Argument | Description |
|----------|-------------|
| `--model-type` | Model variant: `Wan2.2-T2V-A14B` or `Wan2.2-I2V-A14B` |
| `--original-model` | Root directory of the original BF16 diffusers model |
| `--quant-path` | Root directory of the msModelSlim quantized output |
| `--output-path` | Output directory for the merged model (created by the script) |
The script outputs a complete diffusers model directory at `--output-path`,
with each transformer subfolder containing:
- `diffusion_pytorch_model.safetensors` — MXFP4 weights + scale tensors, with BF16 fallback layers from the base checkpoint
- `config.json` — original transformer config with `quantization_config` injected
- `quant_model_description.json` — quantization metadata (reference only)
The `quantization_config` injected into `config.json` for each transformer:
```json
{
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": true,
"ignored_layers": [
"blocks.0.attn1.to_qkv",
"blocks.0.attn1.to_out",
"proj_out"
]
}
```
`ignored_layers` lists every linear layer that retains its original BF16 weight,
using vllm-omni model parameter names (QKV-fused, FFN underscored, `to_out`
unindexed). The exact entries are determined by the quantization tool (msModelSlim)
and may differ between `transformer` and `transformer_2` in a cascade model.
#### Step 3 — Serve
```bash
python text_to_video.py --model /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale
# Online serving
vllm serve /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale --omni
```
```python
omni = Omni(model="/path/to/Wan2.2-T2V-A14B-MXFP4-DualScale")
```
!!! note
No `--quantization` flag is needed for offline mode. The preprocessing
script injects `quantization_config` into each `transformer/config.json`,
which vLLM-Omni reads automatically to activate the correct offline path.
## Parameters
### `mxfp4` (single-scale, online only)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `method` | str | — | `"mxfp4"` |
| `ignored_layers` | list[str] | `[]` | Layer prefixes to keep in BF16 |
### `mxfp4_dualscale` (dual-scale, online + offline)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `method` | str | — | `"mxfp4_dualscale"` |
| `is_checkpoint_serialized` | bool | `False` | `True` for offline DualScale checkpoints; auto-set from `config.json` when using the preprocessing script |
| `ignored_layers` | list[str] | `[]` | Layer prefixes to keep in BF16. **Works in both modes**: offline — populated by the merge script for interleaved sensitive layers; online — user-supplied for explicit per-layer precision override |
| `num_bf16_fallback_layers` | int | `5` | **Online mode only**: leading N transformer blocks (`blocks.0` … `blocks.N-1`) kept in BF16. Applied after `ignored_layers`; ignored in offline mode. Default of `5` is the evaluated recommended value for Wan2.2-A14B |
#### BF16 fallback priority (online mode)
```
for each linear layer:
if prefix in ignored_layers → BF16 (explicit override, highest priority)
elif block_idx < num_bf16_fallback_layers → BF16 (coarse leading-block rule)
else → MXFP4 DualScale online
```
Layers outside `blocks.N.*` (e.g. `condition_embedder.*`) are always quantized
unless they appear in `ignored_layers`.
## Validation and Notes
1. **Online single-scale (`mxfp4`)** quantizes BF16 weights at load time using
`npu_dynamic_mx_quant` (single-scale). No calibration `mul_scale` is
available — all output partitions receive an identity pre-scale. No offline
checkpoint format exists for this method.
2. **Online dual-scale (`mxfp4_dualscale`, `is_checkpoint_serialized=False`)**
quantizes BF16 weights using `npu_dynamic_dual_level_mx_quant` (fine + coarse
scales computed on the fly). No calibration `mul_scale`; leading blocks or
explicit `ignored_layers` stay in BF16 for accuracy.
3. **Offline dual-scale (`mxfp4_dualscale`, `is_checkpoint_serialized=True`)** —
**recommended for production** — loads four tensors per quantized layer: FP4
weight, fine scale (`uint8` reinterpreted as `float8_e8m0fnu`), coarse scale
(`float32`), and per-input-channel `mul_scale` (`float32`). BF16 fallback
layers have no quantization tensors and are routed via `ignored_layers`.
4. **Scale dtype**: fine scales are stored as `uint8` in safetensors (same bit
layout as `float8_e8m0fnu`) and reinterpreted at load time without a lossy
float32 round-trip.
5. **Cascade model config propagation**: in a cascade model (transformer +
transformer_2), vLLM-Omni reads each transformer's own `config.json` and
rebuilds the quant config locally when `ignored_layers` differs between
transformers, ensuring per-layer routing is accurate for each. The first
transformer's config is propagated to `od_config` so the second transformer
can reuse it as a starting point.
6. **Self-attention QKV fusion**: Q, K, V projection weights are fused into a
single `QKVParallelLinear` layer at runtime. `ignored_layers` entries use the
fused name (`attn1.to_qkv`), written automatically by the merge script.
7. W4A4 carries higher quantization noise than W8A8 (16 vs 256 levels). The
DualScale offline method mitigates this with calibrated `mul_scale` smooth
quantization. Use `ignored_layers` and `num_bf16_fallback_layers` to trade
off compression vs. accuracy for precision-sensitive layers.
## Adapting MXFP4 for a New Model
This section is aimed at developers who want to add MXFP4 support to a model
other than Wan2.2. The three integration points are: (1) discovering the correct
runtime layer names, (2) wiring `ignored_layers` into the model, and (3) writing
a merge script for offline checkpoints.
### Step 1 — Discover runtime layer names
`ignored_layers` entries must match the **runtime parameter names** used inside
vllm-omni, which may differ from the names stored in the diffusers checkpoint.
The canonical source of truth is the model's own `named_parameters()`.
```python
from vllm_omni import Omni
# Load the model without quantization to inspect parameter names.
omni = Omni(model="/path/to/your-model") # no --quantization flag
for name, _ in omni.pipeline.transformer.named_parameters():
if "weight" in name and "scale" not in name:
print(name)
```
Compare the printed names against the diffusers checkpoint keys
(`safetensors.safe_open` or `torch.load`) to identify any renames your model
applies. Common patterns that differ in Wan2.2 (and may appear in other
models):
| Diffusers checkpoint name | vllm-omni runtime name | Reason |
|---------------------------|------------------------|--------|
| `attn1.to_q`, `attn1.to_k`, `attn1.to_v` | `attn1.to_qkv` | Self-attention Q/K/V fused into `QKVParallelLinear` |
| `ffn.net.0.proj` | `ffn.net_0.proj` | Dots in sub-module names replaced with underscores |
| `ffn.net.2` | `ffn.net_2` | Same underscore rule |
| `to_out.0` | `to_out` | Sequential index stripped |
If your model has different fusion patterns, inspect `packed_modules_mapping`
on the model class — this dict records how checkpoint keys are mapped to
fused runtime parameters.
!!! warning "Partial QKV fallback is not allowed"
If your model fuses Q, K, V into a single layer, `ignored_layers` must
include **all three or none**. A partial fallback (e.g. `to_q` in BF16 but
`to_k`, `to_v` quantized) cannot be expressed at runtime because they share
one `QKVParallelLinear`. The merge script enforces this and raises an error
if only some of the trio appear as non-quantized.
### Step 2 — Add ignored_layers to the model
#### Online mode
Pass `ignored_layers` directly in the quantization config using the **runtime
names** discovered in Step 1. No code changes to the model are required.
```python
omni = Omni(
model="/path/to/your-model",
quantization={
"method": "mxfp4_dualscale",
"ignored_layers": [
"blocks.0.attn1.to_qkv", # runtime name, not diffusers name
"blocks.0.attn1.to_out",
"blocks.0.ffn.net_0.proj",
],
},
)
```
```bash
# CLI does not support list-typed ignored_layers directly.
# Use the Python API or set ignored_layers in config.json (offline).
python your_script.py --model /path/to/your-model --quantization mxfp4_dualscale
```
The `num_bf16_fallback_layers` coarse rule is an alternative to listing layers
individually: set it to N to keep all linear layers in blocks 0 … N-1 in BF16.
The right value depends on the model's sensitivity; evaluate on a validation
set and pick the smallest N that meets your accuracy target.
#### Offline mode
For offline checkpoints, `ignored_layers` is written into each transformer's
`config.json` by the merge script (see Step 3). No manual editing is needed if
the merge script is correct. The injected block:
```json
{
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": true,
"ignored_layers": [
"blocks.0.attn1.to_qkv",
"blocks.0.attn1.to_out"
]
}
```
To add a layer manually (e.g. to pin an additional layer to BF16 without
re-running the merge script), edit `config.json` inside the transformer
subfolder. Use runtime names, not diffusers checkpoint names.
### Step 3 — Write a merge script for offline mode
The merge script for a new model mirrors
`vllm_omni/quantization/tools/merge_mxfp4_dualscale_checkpoint.py`. The four
things it must do:
1. **Remap tensor names** from the quantization tool convention to diffusers
convention (strip wrappers like `.linear.`, `.div.`; fix any prefix
differences).
2. **Collect ignored_layers**: after loading, enumerate all `*.weight` keys that
have no corresponding `*.weight_scale` (i.e. layers the tool left in BF16).
Convert diffusers names to vllm-omni runtime names (fuse QKV, rename FFN
sub-modules, etc.). Write the result to `config.json`.
3. **Inject `quantization_config`** into `config.json`:
```python
config["quantization_config"] = {
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": True,
"ignored_layers": ignored_layers, # runtime names
}
```
4. **Save** the merged safetensors and the updated `config.json`.
The key helper to implement is the diffusers-to-runtime name translator
(equivalent to `_diffusers_to_vllm_ignored` in the Wan2.2 merge script).
For each non-quantized diffusers weight key, apply your model's specific
renaming rules and collect the results.
!!! tip "Validate before serving"
After producing the offline checkpoint, load it without a `--quantization`
flag and verify that vLLM-Omni auto-detects the correct method. Check that
the layer count reported in the startup log matches expectations: quantized
layer count + `ignored_layers` count should equal total linear layer count.
Any mismatch indicates a name-mapping bug in the merge script.
+26 -11
View File
@@ -9,20 +9,24 @@ separate quantized checkpoint.
This mode is different from pre-quantized checkpoint formats such as GGUF,
AutoRound, msModelSlim, or serialized Int8 checkpoints. Those formats are
prepared before serving and are documented in their method-specific guides.
For MXFP8 and MXFP4, use this page for load-time quantization from BF16
checkpoints, and use the method-specific pages for offline checkpoints produced
by msModelSlim and the merge tools.
## Hardware Support
| Device | FP8 W8A8 | Int8 W8A8 |
|--------|----------|-----------|
| NVIDIA Blackwell GPU (SM 100+) | ✅ | ✅ |
| NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | ✅ |
| NVIDIA Ampere GPU (SM 80+) | ✅ | ✅ |
| AMD ROCm | ⭕ | ⭕ |
| Intel XPU | ⭕ | ⭕ |
| Ascend NPU | ❌ | ✅ |
| Device | FP8 W8A8 | Int8 W8A8 | MXFP8 W8A8 | MXFP4 W4A4 |
|--------|----------|-----------|------------|------------|
| NVIDIA Blackwell GPU (SM 100+) | ✅ | ✅ | ⭕ | ⭕ |
| NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | ✅ | ⭕ | ⭕ |
| NVIDIA Ampere GPU (SM 80+) | ✅ | ✅ | ⭕ | ⭕ |
| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ |
| Intel XPU | ⭕ | ⭕ | ⭕ | ⭕ |
| Ascend NPU | ❌ | ✅ | ✅ | ✅ |
Legend: `✅` supported, `❌` unsupported, `⭕` not verified in this
guide. FP8 on Ampere may use a weight-only path where available.
guide. FP8 on Ampere may use a weight-only path where available. MXFP8 and
MXFP4 are documented for the Ascend NPU path.
## Model Type Support
@@ -32,6 +36,8 @@ guide. FP8 on Ampere may use a weight-only path where available.
|--------|-------|----------------|--------|
| FP8 W8A8 | [FP8](fp8.md) | Qwen-Image; Wan2.2 is not validated | Validated for Qwen-Image family and other DiT models |
| Int8 W8A8 | [Int8](int8.md) | Qwen-Image; Wan2.2 is not validated | Validated for Qwen-Image and Z-Image |
| MXFP8 W8A8 | [MXFP8](mxfp8.md) | Wan2.2-T2V-A14B, Wan2.2-I2V-A14B, Wan2.2-TI2V-5B | Ascend NPU only |
| MXFP4 W4A4 | [MXFP4](mxfp4.md) | Wan2.2-T2V-A14B, Wan2.2-I2V-A14B | Ascend NPU only; TI2V-5B is not supported |
### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS)
@@ -53,6 +59,9 @@ from vllm_omni import Omni
omni_fp8 = Omni(model="<your-model>", quantization="fp8")
omni_int8 = Omni(model="<your-model>", quantization="int8")
omni_mxfp8 = Omni(model="<your-model>", quantization="mxfp8")
omni_mxfp4 = Omni(model="<your-model>", quantization="mxfp4")
omni_mxfp4_dualscale = Omni(model="<your-model>", quantization="mxfp4_dualscale")
```
CLI:
@@ -60,6 +69,9 @@ CLI:
```bash
vllm serve <your-model> --omni --quantization fp8
vllm serve <your-model> --omni --quantization int8
vllm serve <your-model> --omni --quantization mxfp8
vllm serve <your-model> --omni --quantization mxfp4
vllm serve <your-model> --omni --quantization mxfp4_dualscale
```
Per-component routing:
@@ -77,10 +89,11 @@ config = build_quant_config({
| Parameter | Methods | Description |
|-----------|---------|-------------|
| `method` | FP8, Int8 | Quantization method, usually `"fp8"` or `"int8"` |
| `ignored_layers` | FP8, Int8 | Layer name patterns to keep in BF16/FP16 |
| `method` | FP8, Int8, MXFP8, MXFP4 | Quantization method: `"fp8"`, `"int8"`, `"mxfp8"`, `"mxfp4"`, or `"mxfp4_dualscale"` |
| `ignored_layers` | FP8, Int8, MXFP8, MXFP4 | Layer name patterns to keep in BF16/FP16 |
| `activation_scheme` | FP8, Int8 | The runtime value `"dynamic"` selects online activation scaling |
| `weight_block_size` | FP8 | Optional block-wise FP8 weight quantization size |
| `num_bf16_fallback_layers` | MXFP4 DualScale | Leading transformer blocks to keep in BF16 for online `mxfp4_dualscale`; defaults to `5` |
## Validation and Notes
@@ -91,3 +104,5 @@ config = build_quant_config({
model as supported.
4. If a model already ships quantized weights, use the matching pre-quantized
method guide instead of online quantization.
5. For Ascend MXFP4 deployments, prefer offline `mxfp4_dualscale` checkpoints
when production quality is more important than avoiding preprocessing.
+109 -11
View File
@@ -9,20 +9,20 @@ type has a different quantization scope.
| Mode | Guide | Description | Methods |
|------|-------|-------------|---------|
| Online quantization | [Online Quantization](online.md) | vLLM-Omni computes quantized weights and scales while loading the model. | FP8 W8A8, Int8 W8A8, MXFP8 W8A8 |
| Online quantization | [Online Quantization](online.md) | vLLM-Omni computes quantized weights and scales while loading the model. | FP8 W8A8, Int8 W8A8, MXFP8 W8A8, MXFP4 W4A4 |
| Runtime attention quantization | [Quantized KV Cache](quantized_kvcache.md) | vLLM-Omni dynamically quantizes eligible diffusion Flash Attention tensors during inference. | FP8 FA |
| Pre-quantized checkpoints | Method-specific guides | The checkpoint or an offline quantizer provides quantized weights and scales before serving. | ModelOpt, GGUF, AutoRound, msModelSlim, serialized Int8, offline MXFP8 |
| Pre-quantized checkpoints | Method-specific guides | The checkpoint or an offline quantizer provides quantized weights and scales before serving. | ModelOpt, GGUF, AutoRound, msModelSlim, serialized Int8, offline MXFP8, offline MXFP4 DualScale |
## Hardware Support
| Device | FP8 W8A8 | Int8 W8A8 | ModelOpt | MXFP8 W8A8 | GGUF | AutoRound | msModelSlim |
|--------|----------|-----------|----------|------------|------|-----------|-------------|
| NVIDIA Blackwell GPU (SM 100+) | ✅ | ✅ | ✅ | ⭕ | ✅ | ✅ | ❌ |
| NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | ✅ | ✅ | ⭕ | ✅ | ✅ | ❌ |
| NVIDIA Ampere GPU (SM 80+) | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ❌ |
| Intel XPU | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ✅ | ❌ |
| Ascend NPU | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| Device | FP8 W8A8 | Int8 W8A8 | ModelOpt | MXFP8 W8A8 | MXFP4 W4A4 | GGUF | AutoRound | msModelSlim |
|--------|----------|-----------|----------|------------|------------|------|-----------|-------------|
| NVIDIA Blackwell GPU (SM 100+) | ✅ | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
| NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
| NVIDIA Ampere GPU (SM 80+) | ✅ | ✅ | ⭕ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ❌ |
| Intel XPU | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ✅ | ❌ |
| Ascend NPU | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ |
Legend: `✅` supported, `❌` unsupported, `⭕` not verified in this
guide. FP8 on Ampere may use a weight-only path where available.
@@ -42,6 +42,7 @@ otherwise.
| Int8 W8A8 | [Int8](int8.md) | Online or serialized W8A8 | Qwen-Image; Wan2.2 is not validated | Validated for Qwen-Image and Z-Image |
| ModelOpt | [ModelOpt](modelopt.md) | Pre-quantized FP8 checkpoints | Qwen-Image, Z-Image, FLUX.2, HunyuanImage-3.0 | Validated for ModelOpt FP8 diffusion checkpoints |
| MXFP8 W8A8 | [MXFP8](mxfp8.md) | Online W8A8 or offline pre-quantized | Wan2.2-T2V-A14B, I2V-A14B, TI2V-5B | Ascend NPU only; validated for Wan2.2 |
| MXFP4 W4A4 | [MXFP4](mxfp4.md) | `mxfp4`: online single-scale only; `mxfp4_dualscale`: online or offline dual-scale (offline recommended) | Wan2.2-T2V-A14B, I2V-A14B | Ascend NPU only; validated for Wan2.2 A14B cascade models; TI2V-5B not supported; offline `mxfp4_dualscale` uses calibrated `mul_scale` for best accuracy |
| GGUF | [GGUF](gguf.md) | Pre-quantized transformer weights | Qwen-Image | Validated where a model-specific GGUF adapter exists |
| AutoRound | [AutoRound](autoround.md) | Pre-quantized W4A16 checkpoints | FLUX.1-dev; Qwen-Image/Wan2.2 not validated | Checkpoint-driven |
| msModelSlim | [msModelSlim](msmodelslim.md) | Pre-quantized Ascend checkpoints | Wan2.2 recipe; HunyuanImage-3.0 inference target | Ascend/NPU path |
@@ -58,6 +59,7 @@ in BF16 unless the model guide explicitly adds support.
| ModelOpt | [ModelOpt](modelopt.md) | Thinker or language-model checkpoint config | Qwen3-Omni thinker | ModelOpt checkpoint path |
| Int8 | [Int8](int8.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
| MXFP8 | [MXFP8](mxfp8.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
| MXFP4 | [MXFP4](mxfp4.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
| GGUF | [GGUF](gguf.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
| AutoRound | [AutoRound](autoround.md) | Thinker or language-model checkpoint config | Qwen2.5-Omni, Qwen3-Omni | Supported through AutoRound checkpoints |
| msModelSlim | [msModelSlim](msmodelslim.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
@@ -73,6 +75,7 @@ attached to the intended stage rather than applied globally.
| Int8 | [Int8](int8.md) | Stage-specific DiT or transformer module | BAGEL, GLM-Image | Requires model-specific validation |
| ModelOpt | [ModelOpt](modelopt.md) | Checkpoint-defined diffusion stage | BAGEL, GLM-Image | Requires model-specific validation |
| MXFP8 | [MXFP8](mxfp8.md) | Stage-specific DiT or transformer module | BAGEL, GLM-Image | Not validated |
| MXFP4 | [MXFP4](mxfp4.md) | Stage-specific DiT or transformer module | BAGEL, GLM-Image | Not validated |
| GGUF | [GGUF](gguf.md) | Stage-specific transformer weights | BAGEL, GLM-Image | No validated adapter listed |
| AutoRound | [AutoRound](autoround.md) | Checkpoint-defined stage | BAGEL, GLM-Image | No validated checkpoint listed |
| msModelSlim | [msModelSlim](msmodelslim.md) | Ascend-generated stage weights | GLM-Image | Requires model-specific adaptation |
@@ -100,7 +103,7 @@ config = build_quant_config({
| Component | Default quantized? | Notes |
|-----------|--------------------|-------|
| Diffusion transformer | Yes | Primary target for FP8, Int8, ModelOpt, GGUF, AutoRound, and msModelSlim |
| Diffusion transformer | Yes | Primary target for FP8, Int8, ModelOpt, MXFP8, MXFP4, GGUF, AutoRound, and msModelSlim |
| Text encoder | No | Keep BF16 unless a method-specific guide documents support |
| VAE | No | Keep BF16; storage-only paths are method-specific |
| Scheduler/tokenizer | No | Loaded from the base model repository |
@@ -138,3 +141,98 @@ build_quant_config({"method": "gguf", "gguf_model": "/path/to/model.gguf"})
build_quant_config({"transformer": {"method": "fp8"}, "vae": None})
build_quant_config(None)
```
## Output Similarity Comparison Tool
Use `vllm_omni.quantization.tools.compare_diffusion_trajectory_similarity`
to compare a reference diffusion run with a quantized candidate run using the
same prompt, seed, resolution, scheduler settings, and inference steps. The
tool compares final decoded images or video frames, and also reports generation
latency and worker-reported peak memory when available.
This is useful when validating whether online quantization, an offline
pre-quantized checkpoint, or a new `ignored_layers` choice keeps generation
quality close to the BF16 reference.
### Online Quantization Example
```bash
python -m vllm_omni.quantization.tools.compare_diffusion_trajectory_similarity \
--task t2i \
--model Qwen/Qwen-Image \
--candidate-quantization fp8 \
--ignored-layers img_mlp \
--prompt "a cup of coffee on the table" \
--height 512 --width 512 \
--num-inference-steps 20 \
--seed 142 \
--output-json /tmp/qwen_image_fp8_similarity/result.json \
--save-output-dir /tmp/qwen_image_fp8_similarity/images \
--enforce-eager
```
### Offline Checkpoint Example
Use `--candidate-model` when the candidate is already quantized or lives at a
different model path:
```bash
python -m vllm_omni.quantization.tools.compare_diffusion_trajectory_similarity \
--task t2i \
--reference-model Qwen/Qwen-Image \
--candidate-model /path/to/qwen-image-fp8-checkpoint \
--prompt "a cup of coffee on the table" \
--height 512 --width 512 \
--num-inference-steps 20 \
--seed 142 \
--output-json /tmp/qwen_image_fp8_checkpoint_similarity/result.json
```
If the checkpoint does not include a loadable quantization config, pass one
explicitly:
```bash
--candidate-quantization-config-json '{"method":"fp8"}'
```
### Output Metrics
The output JSON includes `output_metrics`, `reference_generation`, and
`candidate_generation`.
| Metric | Direction | Meaning |
|--------|-----------|---------|
| `cosine_similarity` | Higher is better | Vector direction similarity between output pixels or frames. Useful as a broad sanity check. |
| `mae` | Lower is better | Mean absolute pixel or frame error. For decoded outputs, values are in uint8 pixel units. |
| `mse` / `rmse` | Lower is better | Squared error and its square root. These penalize localized large differences more than `mae`. |
| `max_abs` | Lower is better | Worst single-element absolute error. Treat it as an outlier/debug signal, not as a release gate. |
| `l2` / `relative_l2` | Lower is better | Absolute and reference-normalized L2 distance. `relative_l2` is easier to compare across resolutions. |
| `psnr_db` | Higher is better | Pixel-space signal-to-noise ratio in dB for uint8 images or frames. |
| `avg_generation_time_s` | Lower is better | Average wall-clock generation time across measured runs. |
| `max_peak_memory_mb` | Lower is better | Maximum worker-reported peak device memory across measured runs, when the worker reports it. |
Recommended starting thresholds for same-seed diffusion comparisons:
| Metric | Smoke threshold | Stricter target | Notes |
|--------|-----------------|-----------------|-------|
| `psnr_db` | `>= 20.0` | `>= 25.0` | Good for quick image or frame regression checks. |
| `mae` | `<= 12.0` | `<= 6.0` | Interpreted in decoded uint8 pixel units. |
| `cosine_similarity` | `>= 0.98` | `>= 0.995` | Less sensitive to global scale than L2-style metrics. |
| `relative_l2` | `<= 0.20` | `<= 0.08` | Useful when comparing across prompts or resolutions. |
These thresholds are heuristics. Tune them by model family, task, resolution,
quantization method, and deployment tolerance. For release gating, pair the
numeric report with visual inspection of saved reference and candidate outputs.
The tool intentionally reports separate quality, latency, and memory metrics
instead of a single consolidated similarity score. A single score can hide
important tradeoffs, for example a candidate with good PSNR but a meaningful
memory regression, or a candidate with low average error but localized visual
artifacts. If you need a project-specific pass/fail gate, define it as an
explicit policy over the individual metrics.
Pixel-level metrics do not measure semantic consistency. For higher-cost
evaluation, you can complement this report with a vision-language judge that
describes the reference and candidate outputs and compares those descriptions.
Keep that semantic check separate from this lightweight tool so users can
choose whether the additional model cost and latency are appropriate.
@@ -77,6 +77,24 @@ def parse_args():
parser.add_argument("--log-stats", action="store_true", default=False)
parser.add_argument("--init-timeout", type=int, default=300, help="Initialization timeout in seconds.")
parser.add_argument("--enforce-eager", action="store_true", help="Disable torch.compile.")
parser.add_argument(
"--diffusion-kv-cache-dtype",
type=str,
default=None,
help="Diffusion attention KV cache dtype, for example 'fp8'. Separate from vLLM --kv-cache-dtype.",
)
parser.add_argument(
"--diffusion-kv-cache-skip-steps",
type=str,
default=None,
help="Denoising step selector to keep diffusion KV cache in native dtype, for example '0,1,4-6'.",
)
parser.add_argument(
"--diffusion-kv-cache-skip-layers",
type=str,
default=None,
help="Transformer layer selector to keep diffusion KV cache in native dtype, for example '0-2,10'.",
)
parser.add_argument(
"--additional-config",
type=str,
@@ -140,6 +158,9 @@ def main():
"init_timeout": args.init_timeout,
"enforce_eager": args.enforce_eager,
"mode": _MODALITY_MODE[args.modality],
"diffusion_kv_cache_dtype": args.diffusion_kv_cache_dtype,
"diffusion_kv_cache_skip_steps": args.diffusion_kv_cache_skip_steps,
"diffusion_kv_cache_skip_layers": args.diffusion_kv_cache_skip_layers,
}
if additional_config is not None:
@@ -237,6 +258,9 @@ def main():
print(f" Inference steps: {args.steps}")
print(f" Guidance scale: {args.guidance_scale}")
print(f" Seed: {args.seed}")
print(f" diffusion_kv_cache_dtype: {args.diffusion_kv_cache_dtype}")
print(f" diffusion_kv_cache_skip_steps: {args.diffusion_kv_cache_skip_steps}")
print(f" diffusion_kv_cache_skip_layers: {args.diffusion_kv_cache_skip_layers}")
if args.modality == "text2img":
print(f" Output size: {args.width}x{args.height}")
if args.image_path:
@@ -142,6 +142,42 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
parser.add_argument(
"--enforce-eager",
action="store_true",
help="Disable torch.compile and force eager execution.",
)
parser.add_argument(
"--audio-sample-rate",
type=int,
default=24000,
help="Sample rate for audio output when saved (default: 24000).",
)
parser.add_argument(
"--cache-backend",
type=str,
default=None,
choices=["cache_dit", "tea_cache"],
help=(
"Cache backend to use for acceleration. "
"Options: 'cache_dit' (DBCache + SCM + TaylorSeer), 'tea_cache' (Timestep Embedding Aware Cache). "
"Default: None (no cache acceleration)."
),
)
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
)
parser.add_argument(
"--quantization",
type=str,
default=None,
choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8", "gguf"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 (NPU). mxfp4: W4A4 MXFP4 (NPU). mxfp4_dualscale: W4A4 MXFP4 dual-scale + BF16 fallback mixed (NPU). fp8: online FP8 (GPU).",
)
# Distributed and parallel execution
parser.add_argument(
"--ulysses-degree",
type=int,
@@ -173,28 +209,6 @@ def parse_args() -> argparse.Namespace:
default=1,
help="Number of GPUs used for VAE patch/tile parallelism (decode).",
)
parser.add_argument(
"--enforce-eager",
action="store_true",
help="Disable torch.compile and force eager execution.",
)
parser.add_argument(
"--audio-sample-rate",
type=int,
default=24000,
help="Sample rate for audio output when saved (default: 24000).",
)
parser.add_argument(
"--cache-backend",
type=str,
default=None,
choices=["cache_dit", "tea_cache"],
help=(
"Cache backend to use for acceleration. "
"Options: 'cache_dit' (DBCache + SCM + TaylorSeer), 'tea_cache' (Timestep Embedding Aware Cache). "
"Default: None (no cache acceleration)."
),
)
parser.add_argument(
"--use-hsdp",
action="store_true",
@@ -219,16 +233,10 @@ def parse_args() -> argparse.Namespace:
),
)
parser.add_argument(
"--quantization",
type=str,
default=None,
choices=["fp8", "mxfp8", "int8", "gguf"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 online quant (NPU). fp8: online FP8 (GPU).",
)
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
"--pipeline-parallel-size",
type=int,
default=1,
help="Number of pipeline parallel stages.",
)
parser.add_argument(
"--profiler-config",
@@ -326,6 +334,7 @@ def main():
use_hsdp=args.use_hsdp,
hsdp_shard_size=args.hsdp_shard_size,
hsdp_replicate_size=args.hsdp_replicate_size,
pipeline_parallel_size=args.pipeline_parallel_size,
)
omni_kwargs = dict(
model=args.model,
@@ -366,7 +375,8 @@ def main():
print(f" diffusion_kv_cache_skip_layers(config): {args.diffusion_kv_cache_skip_layers}")
print(
f" Parallel configuration: cfg_parallel_size={args.cfg_parallel_size},"
f" tensor_parallel_size={args.tensor_parallel_size}, vae_patch_parallel_size={args.vae_patch_parallel_size}"
f" tensor_parallel_size={args.tensor_parallel_size}, vae_patch_parallel_size={args.vae_patch_parallel_size},"
f" pipeline_parallel_size={args.pipeline_parallel_size}"
)
print(f" Video size: {args.width}x{args.height}")
print(f"{'=' * 60}\n")
@@ -14,13 +14,12 @@ list of supported architectures across all modalities, see
| Model | HuggingFace repo | Stages | Voice cloning | Streaming | Special modes | Sample rate |
|---|---|---|---|---|---|---|
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 22.05 kHz |
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 24 kHz |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ | — | 44.1 kHz |
| Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | single (talker only) | — (caption-controlled) | — | style / IP / basic captions | 44.1 kHz |
| MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | single (AR + codec) | ✓ (required) | ✓ | voice_clone, continuation | 48 kHz |
| OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design, language hint | 24 kHz |
| Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | 2 (talker + code2wav) | ✓ (Base) | ✓ | 3 task variants | 24 kHz |
| VoxCPM | `openbmb/VoxCPM-0.5B` | split | ✓ | ✓ | — | 24 kHz |
| VoxCPM2 | `openbmb/VoxCPM2` | single (native AR) | ✓ | ✓ (online) | continuation | 48 kHz |
| Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | varies | ✓ | ✓ | voice presets | 24 kHz |
@@ -41,7 +40,7 @@ python examples/offline_inference/text_to_speech/<model>/end2end.py \
## CosyVoice3
2-stage TTS pipeline (`talker` + `code2wav`) at 22.05 kHz.
2-stage TTS pipeline (`talker` + `code2wav`) at 24 kHz.
### Prerequisites
```bash
@@ -308,56 +307,6 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
---
## VoxCPM
Split-stage TTS. The hub example covers single TTS, single voice cloning, and streaming. Use `benchmarks/voxcpm/` for warmup, batch JSONL prompts, profiler injection, and offline TTFP / RTF measurement.
### Prerequisites
```bash
pip install voxcpm soundfile
# or use a local source tree:
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
```
### Quick start
```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model openbmb/VoxCPM-0.5B \
--text "This is a split-stage VoxCPM synthesis example running on vLLM Omni."
```
### Voice cloning
```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model openbmb/VoxCPM-0.5B \
--text "This sentence is synthesized with a cloned voice." \
--ref-audio /path/to/reference.wav \
--ref-text "The exact transcript spoken in reference.wav."
```
### Streaming
Pass `--streaming` together with the legacy async-chunk stage config:
```bash
python examples/offline_inference/text_to_speech/voxcpm/end2end.py \
--model openbmb/VoxCPM-0.5B \
--streaming \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
--text "This is a split-stage VoxCPM streaming example running on vLLM Omni."
```
### Persistent HF config (optional)
The engine auto-patches a missing `model_type` in the checkpoint's `config.json` in-memory at startup (see `vllm_omni/engine/arg_utils.py`), so no manual setup is required.
If you'd rather maintain a persistent patched config dir (e.g. to share across runs or to add fields the auto-patcher doesn't set), export `VLLM_OMNI_VOXCPM_HF_CONFIG_PATH` to its location — both the bundled deploy and async-chunk yamls read it via `${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}`.
### Notes
- Non-streaming auto-loads `vllm_omni/deploy/voxcpm.yaml` from the model directory name / HF `model_type`. Streaming uses the legacy `voxcpm_async_chunk.yaml` passed via `--stage-configs-path`.
- Streaming is currently single-request oriented.
- `--ref-text` must be the real transcript of `--ref-audio`; mismatched text degrades quality.
- For online serving, see the [VoxCPM section in the online hub](../../online_serving/text_to_speech/README.md#voxcpm). For benchmark reporting, see [`benchmarks/voxcpm`](../../../benchmarks/voxcpm/README.md).
---
## VoxCPM2
Single-stage native AR TTS at 48 kHz. Pipeline: `feat_encoder → MiniCPM4 → FSQ → residual_lm → LocDiT → AudioVAE`.
@@ -1,220 +0,0 @@
"""Minimal offline VoxCPM example for vLLM Omni."""
from __future__ import annotations
import asyncio
import time
from pathlib import Path
from typing import Any
import soundfile as sf
import torch
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni import AsyncOmni, Omni
from vllm_omni.engine.arg_utils import nullify_stage_engine_defaults
def _build_prompt(args) -> dict[str, Any]:
additional_information: dict[str, list[Any]] = {
"text": [args.text],
"cfg_value": [args.cfg_value],
"inference_timesteps": [args.inference_timesteps],
"min_len": [args.min_len],
"max_new_tokens": [args.max_new_tokens],
}
if args.streaming_prefix_len is not None:
additional_information["streaming_prefix_len"] = [args.streaming_prefix_len]
if args.ref_audio is not None:
additional_information["ref_audio"] = [args.ref_audio]
if args.ref_text is not None:
additional_information["ref_text"] = [args.ref_text]
return {
"prompt_token_ids": [1],
"additional_information": additional_information,
}
def _extract_audio_tensor(mm: dict[str, Any]) -> torch.Tensor:
audio = mm.get("audio", mm.get("model_outputs"))
if audio is None:
raise ValueError("No audio output found in multimodal output.")
if isinstance(audio, list):
parts = [torch.as_tensor(item).float().cpu().reshape(-1) for item in audio]
audio = torch.cat(parts, dim=-1) if parts else torch.zeros(0)
if not isinstance(audio, torch.Tensor):
audio = torch.as_tensor(audio)
return audio.float().cpu().reshape(-1)
def _extract_sample_rate(mm: dict[str, Any]) -> int:
sr_raw = mm.get("sr", 24000)
if isinstance(sr_raw, list) and sr_raw:
sr_raw = sr_raw[-1]
if hasattr(sr_raw, "item"):
return int(sr_raw.item())
return int(sr_raw)
def _save_audio(audio: torch.Tensor, sample_rate: int, output_dir: Path, request_id: str) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"output_{request_id}.wav"
sf.write(
output_path,
audio.float().cpu().clamp(-1.0, 1.0).numpy(),
sample_rate,
format="WAV",
subtype="PCM_16",
)
return output_path
async def _run_streaming(args) -> Path:
prompt = _build_prompt(args)
output_dir = Path(args.output_dir) if args.output_dir is not None else Path("output_audio_streaming")
request_id = "streaming_example"
sample_rate = 24000
buffered_samples = 0
chunks: list[torch.Tensor] = []
started = time.perf_counter()
omni = AsyncOmni(
model=args.model,
stage_configs_path=args.stage_configs_path,
deploy_config=args.deploy_config,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
)
try:
async for stage_output in omni.generate(prompt, request_id=request_id):
mm = getattr(stage_output, "multimodal_output", None)
if not isinstance(mm, dict):
request_output = getattr(stage_output, "request_output", None)
if request_output is None:
continue
mm = getattr(request_output, "multimodal_output", None)
if not isinstance(mm, dict) and getattr(request_output, "outputs", None):
mm = getattr(request_output.outputs[0], "multimodal_output", None)
if not isinstance(mm, dict):
continue
audio = _extract_audio_tensor(mm)
if audio.numel() == 0:
continue
sample_rate = _extract_sample_rate(mm)
if audio.numel() > buffered_samples:
delta = audio[buffered_samples:]
buffered_samples = int(audio.numel())
else:
delta = audio
buffered_samples += int(delta.numel())
if delta.numel() > 0:
chunks.append(delta)
if not chunks:
raise RuntimeError("No streaming audio chunks received from VoxCPM.")
output_audio = torch.cat(chunks, dim=0)
output_path = _save_audio(output_audio, sample_rate, output_dir, request_id)
print(f"Saved streaming audio to: {output_path} ({time.perf_counter() - started:.2f}s)")
return output_path
finally:
omni.shutdown()
def _run_sync(args) -> Path:
prompt = _build_prompt(args)
output_dir = Path(args.output_dir) if args.output_dir is not None else Path("output_audio")
request_id = "sync_example"
started = time.perf_counter()
last_mm: dict[str, Any] | None = None
omni = Omni(
model=args.model,
stage_configs_path=args.stage_configs_path,
deploy_config=args.deploy_config,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
)
for stage_outputs in omni.generate(prompt):
request_output = getattr(stage_outputs, "request_output", None)
if request_output is None:
continue
outputs = getattr(request_output, "outputs", None)
if outputs:
for output in outputs:
mm = getattr(output, "multimodal_output", None)
if isinstance(mm, dict):
last_mm = mm
mm = getattr(request_output, "multimodal_output", None)
if isinstance(mm, dict):
last_mm = mm
if last_mm is None:
raise RuntimeError("No audio output received from VoxCPM.")
output_path = _save_audio(
_extract_audio_tensor(last_mm),
_extract_sample_rate(last_mm),
output_dir,
request_id,
)
print(f"Saved audio to: {output_path} ({time.perf_counter() - started:.2f}s)")
return output_path
def parse_args():
parser = FlexibleArgumentParser(description="Minimal offline VoxCPM example for vLLM Omni.")
parser.add_argument("--model", type=str, required=True, help="Local VoxCPM model directory.")
parser.add_argument(
"--deploy-config",
type=str,
default=None,
help=("Override the deploy config path."),
)
parser.add_argument(
"--stage-configs-path",
type=str,
default=None,
help=(
"Legacy stage_args yaml path. Required for streaming "
"(vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml); "
"leave unset for non-streaming to use the auto-resolved deploy config."
),
)
parser.add_argument(
"--streaming",
action="store_true",
default=False,
help="Stream audio chunks as they arrive via AsyncOmni.",
)
parser.add_argument("--text", type=str, required=True, help="Input text for synthesis.")
parser.add_argument("--ref-audio", type=str, default=None, help="Reference audio path for voice cloning.")
parser.add_argument("--ref-text", type=str, default=None, help="Transcript of the reference audio.")
parser.add_argument("--output-dir", type=str, default=None, help="Output directory for generated wav files.")
parser.add_argument("--cfg-value", type=float, default=2.0, help="Guidance value passed to VoxCPM.")
parser.add_argument("--inference-timesteps", type=int, default=10, help="Number of diffusion timesteps.")
parser.add_argument("--min-len", type=int, default=2, help="Minimum latent length.")
parser.add_argument("--max-new-tokens", type=int, default=4096, help="Maximum latent length.")
parser.add_argument(
"--streaming-prefix-len",
type=int,
default=3,
help="Streaming prefix length used by voxcpm_async_chunk.yaml.",
)
parser.add_argument("--stage-init-timeout", type=int, default=600, help="Stage initialization timeout in seconds.")
parser.add_argument("--log-stats", action="store_true", help="Enable vLLM Omni stats logging.")
nullify_stage_engine_defaults(parser)
args = parser.parse_args()
if (args.ref_audio is None) != (args.ref_text is None):
raise ValueError("Voice cloning requires --ref-audio and --ref-text together.")
return args
def main(args) -> None:
route = "streaming" if args.streaming else "sync"
print(f"Model: {args.model}")
print(f"Deploy config: {args.deploy_config or '<auto from HF model_type>'}")
print(f"Stage configs path: {args.stage_configs_path or '<unused>'}")
print(f"Route: {route}")
if route == "streaming":
asyncio.run(_run_streaming(args))
else:
_run_sync(args)
if __name__ == "__main__":
main(parse_args())
@@ -142,6 +142,32 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Enable layerwise (blockwise) offloading on DiT modules.",
)
parser.add_argument(
"--audio-sample-rate",
type=int,
default=24000,
help="Sample rate for audio output when saved (default: 24000).",
)
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
)
parser.add_argument(
"--profiler-config",
type=parse_profiler_config,
default=None,
help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.',
)
parser.add_argument(
"--quantization",
type=str,
default=None,
choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8", "gguf"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 (NPU). mxfp4: W4A4 MXFP4 (NPU). mxfp4_dualscale: W4A4 MXFP4 dual-scale + BF16 fallback mixed (NPU). fp8: online FP8 (GPU).",
)
# Distributed and parallel execution
parser.add_argument(
"--ulysses-mode",
type=str,
@@ -174,41 +200,23 @@ def parse_args() -> argparse.Namespace:
default=1,
help="Number of GPUs used for tensor parallelism (TP) inside the DiT.",
)
parser.add_argument(
"--audio-sample-rate",
type=int,
default=24000,
help="Sample rate for audio output when saved (default: 24000).",
)
parser.add_argument(
"--vae-patch-parallel-size",
type=int,
default=1,
help="Number of GPUs used for VAE patch/tile parallelism (decode).",
)
parser.add_argument(
"--pipeline-parallel-size",
type=int,
default=1,
help="Number of pipeline parallel stages.",
)
parser.add_argument(
"--enable-expert-parallel",
action="store_true",
help="Enable expert parallelism for MoE layers.",
)
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
)
parser.add_argument(
"--profiler-config",
type=parse_profiler_config,
default=None,
help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.',
)
parser.add_argument(
"--quantization",
type=str,
default=None,
choices=["fp8", "mxfp8", "int8", "gguf"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 online quant (NPU). fp8: online FP8 (GPU).",
)
parser.add_argument(
"--use-hsdp",
action="store_true",
@@ -229,6 +237,27 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args()
def _extract_peak_memory_mb(result: Any) -> float:
"""Pull worker-reported peak VRAM (MiB) generation result.
Mirrors vllm_omni/entrypoints/openai/serving_video.py:_extract_peak_memory_mb.
"""
if isinstance(result, list):
result = result[0] if result else None
if result is None:
return 0.0
val = getattr(result, "peak_memory_mb", 0.0)
if not val:
inner = getattr(result, "request_output", None)
if isinstance(inner, list):
inner = inner[0] if inner else None
val = getattr(inner, "peak_memory_mb", 0.0)
try:
return float(val or 0.0)
except (TypeError, ValueError):
return 0.0
def main():
args = parse_args()
model_class_name = args.model_class_name
@@ -262,6 +291,7 @@ def main():
cfg_parallel_size=args.cfg_parallel_size,
tensor_parallel_size=args.tensor_parallel_size,
vae_patch_parallel_size=args.vae_patch_parallel_size,
pipeline_parallel_size=args.pipeline_parallel_size,
enable_expert_parallel=args.enable_expert_parallel,
)
@@ -307,7 +337,8 @@ def main():
print(
f" Parallel configuration: ulysses_degree={args.ulysses_degree}, ring_degree={args.ring_degree},"
f" cfg_parallel_size={args.cfg_parallel_size}, tensor_parallel_size={args.tensor_parallel_size},"
f" vae_patch_parallel_size={args.vae_patch_parallel_size}, enable_expert_parallel={args.enable_expert_parallel}"
f" vae_patch_parallel_size={args.vae_patch_parallel_size}, pipeline_parallel_size={args.pipeline_parallel_size},"
f" enable_expert_parallel={args.enable_expert_parallel}"
)
print(f" Video size: {args.width}x{args.height}")
print(f"{'=' * 60}\n")
@@ -338,6 +369,10 @@ def main():
# Print profiling results
print(f"Total generation time: {generation_time:.4f} seconds ({generation_time * 1000:.2f} ms)")
peak_mb = _extract_peak_memory_mb(frames)
if peak_mb:
print(f"Worker peak GPU memory (reserved): {peak_mb:.2f} MiB ({peak_mb / 1024:.2f} GiB)")
audio = None
if isinstance(frames, list):
frames = frames[0] if frames else None
@@ -19,7 +19,6 @@ For the full list of supported architectures across all modalities, see
| MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✓ (`ref_audio` required) | ✓ (PCM stream) | — | ✓ |
| OmniVoice | `k2-fsa/OmniVoice` | (offline only) | — | — | — |
| Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | ✓ (Base) | ✓ (PCM + WebSocket) | ✓ (presets + `/v1/audio/voices` upload) | ✓ (standard + FastRTC) |
| VoxCPM | `openbmb/VoxCPM-0.5B` | ✓ | ✓ (PCM stream) | — | — |
| VoxCPM2 | `openbmb/VoxCPM2` | ✓ | ✓ (AudioWorklet via gradio) | — | ✓ |
| Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | ✓ (gated upstream) | ✓ | ✓ (presets) | ✓ |
@@ -294,10 +293,10 @@ vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni --port 8091
./qwen3_tts/run_server.sh Base
```
### Choosing an executor backend (uniproc vs mp)
Stage configs ship with the chunked-streaming default. To use the uniproc executor (lower IPC overhead for the Base cloning task), pass `--stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml`. See [#2603](https://github.com/vllm-project/vllm-omni/issues/2603) and [#2604](https://github.com/vllm-project/vllm-omni/pull/2604) for the full investigation.
### Executor backend
Single-GPU serves now default to the uniproc executor (lower IPC overhead, the Base cloning use case from [#2603](https://github.com/vllm-project/vllm-omni/issues/2603) / [#2604](https://github.com/vllm-project/vllm-omni/pull/2604)). `vllm_omni/deploy/qwen3_tts.yaml` is the only Qwen3-TTS deploy config; pass `--deploy-config <path>` to override.
To opt out of chunked streaming, pass `--no-async-chunk` instead — the pipeline auto-dispatches to the end-to-end codec processor.
To opt out of chunked streaming, pass `--no-async-chunk` — the pipeline auto-dispatches to the end-to-end codec processor.
### Sending requests
```bash
@@ -375,73 +374,11 @@ python qwen3_tts/streaming_speech_client.py --text "..." --simulate-stt --stt-de
`qwen3_tts/batch_speech_client.py` issues many concurrent requests for throughput measurement.
### Notes
- Base voice cloning has uniproc-vs-mp tradeoffs depending on per-request reference audio cost; see the executor-backend section above.
- Base voice cloning has per-request reference-audio cost; the uniproc default keeps IPC overhead off the critical path. See the executor-backend section above for background.
- `vllm_omni/deploy/qwen3_tts.yaml` is the default deploy config (loaded by HF `model_type`); per-stage runtime overrides are available via `--stage-N-<field> <value>`.
---
## VoxCPM
Split-stage TTS at 24 kHz.
### Prerequisites
```bash
pip install voxcpm
# or use a local source tree:
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
```
If the native VoxCPM `config.json` lacks HF `model_type`, set up an HF-compatible config dir:
```bash
export VOXCPM_MODEL=/path/to/voxcpm-model
export VLLM_OMNI_VOXCPM_HF_CONFIG_PATH=/tmp/voxcpm_hf_config
mkdir -p "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"
cp "$VOXCPM_MODEL/config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/config.json"
cp "$VOXCPM_MODEL/generation_config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/generation_config.json" 2>/dev/null || true
python3 -c 'import json, os; p=os.path.join(os.environ["VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"], "config.json"); cfg=json.load(open(p, "r", encoding="utf-8")); cfg["model_type"]="voxcpm"; cfg.setdefault("architectures", ["VoxCPMForConditionalGeneration"]); json.dump(cfg, open(p, "w", encoding="utf-8"), indent=2, ensure_ascii=False)'
```
### Launch
```bash
export VOXCPM_MODEL=/path/to/voxcpm-model
./voxcpm/run_server.sh # async-chunk streaming (default)
./voxcpm/run_server.sh sync # non-streaming
```
Or directly:
```bash
vllm serve "$VOXCPM_MODEL" \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
--trust-remote-code --enforce-eager --omni --port 8091
```
### Sending requests
```bash
# Basic TTS
python voxcpm/openai_speech_client.py \
--model "$VOXCPM_MODEL" \
--text "This is a VoxCPM online text-to-speech example."
# Voice cloning
python voxcpm/openai_speech_client.py \
--model "$VOXCPM_MODEL" \
--text "This sentence is synthesized with a cloned voice." \
--ref-audio /path/to/reference.wav \
--ref-text "The exact transcript spoken in reference.wav."
# Streaming PCM
python voxcpm/openai_speech_client.py \
--model "$VOXCPM_MODEL" \
--text "This is a streaming VoxCPM request." \
--stream --output voxcpm_stream.pcm
```
### Notes
- `voxcpm.yaml` for one-shot decode; `voxcpm_async_chunk.yaml` for single-request streaming. Do not use the async-chunk config for concurrent requests or `/v1/audio/speech/batch`.
- Generic TTS fields not supported by VoxCPM: `voice`, `instructions`, `language`, `speaker_embedding`, `x_vector_only_mode`.
- For benchmark measurement, see [`benchmarks/voxcpm`](../../../benchmarks/voxcpm/README.md).
---
## VoxCPM2
Single-stage native AR TTS at 48 kHz.
@@ -0,0 +1,28 @@
#!/bin/bash
# Launch vLLM-Omni server for CosyVoice3 TTS
#
# Usage:
# ./run_server.sh
# CUDA_VISIBLE_DEVICES=0 ./run_server.sh
#
# Streaming (async-chunk) is on by default via vllm_omni/deploy/cosyvoice3.yaml.
# Set NO_ASYNC_CHUNK=1 to use the legacy synchronous path.
set -e
MODEL="${MODEL:-FunAudioLLM/Fun-CosyVoice3-0.5B-2512}"
PORT="${PORT:-8091}"
EXTRA_ARGS=()
if [[ -n "${NO_ASYNC_CHUNK:-}" ]]; then
EXTRA_ARGS+=(--no-async-chunk)
fi
echo "Starting CosyVoice3 server with model: $MODEL"
vllm serve "$MODEL" \
--host 0.0.0.0 \
--port "$PORT" \
--trust-remote-code \
--omni \
"${EXTRA_ARGS[@]}"
@@ -0,0 +1,142 @@
"""Client for CosyVoice3 TTS via /v1/audio/speech endpoint.
CosyVoice3 has no built-in voice presets: every request is voice cloning
driven by ``ref_audio`` + ``ref_text``. The defaults below point at the
official upstream zero-shot prompt so the script runs out of the box.
Examples:
# Voice cloning with the default upstream prompt
python speech_client.py --text "收到好友从远方寄来的生日礼物。"
# Custom reference clip + transcript
python speech_client.py --text "Hello, this is a cloned voice." \
--ref-audio /path/to/reference.wav \
--ref-text "Transcript of the reference audio."
# Streaming PCM output
python speech_client.py --text "Hello world" --stream --output output.pcm
"""
import argparse
import base64
import os
import httpx
DEFAULT_API_BASE = "http://localhost:8091"
DEFAULT_API_KEY = "EMPTY"
DEFAULT_MODEL = "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
# Official CosyVoice zero-shot prompt and its transcript.
DEFAULT_REF_AUDIO = "https://raw.githubusercontent.com/FunAudioLLM/CosyVoice/main/asset/zero_shot_prompt.wav"
DEFAULT_REF_TEXT = "希望你以后能够做的比我还好呦。"
def encode_audio_to_base64(audio_path: str) -> str:
"""Encode a local audio file to a base64 data URL."""
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
ext = audio_path.lower().rsplit(".", 1)[-1]
mime_map = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac", "ogg": "audio/ogg"}
mime_type = mime_map.get(ext, "audio/wav")
with open(audio_path, "rb") as f:
audio_b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime_type};base64,{audio_b64}"
def run_tts(args) -> None:
"""Generate speech via the /v1/audio/speech API."""
payload = {
"model": args.model,
"input": args.text,
"response_format": args.response_format,
}
if args.ref_audio.startswith(("http://", "https://")):
payload["ref_audio"] = args.ref_audio
else:
payload["ref_audio"] = encode_audio_to_base64(args.ref_audio)
payload["ref_text"] = args.ref_text
if args.stream:
payload["stream"] = True
payload["response_format"] = "pcm"
print(f"Model: {args.model}")
print(f"Text: {args.text}")
print(f"Voice cloning: ref_audio={args.ref_audio}, ref_text={args.ref_text}")
print("Generating audio...")
api_url = f"{args.api_base}/v1/audio/speech"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {args.api_key}",
}
if args.stream:
output_path = args.output or "output.pcm"
with httpx.Client(timeout=300.0) as client:
with client.stream("POST", api_url, json=payload, headers=headers) as resp:
if resp.status_code != 200:
print(f"Error: {resp.status_code}")
print(resp.read().decode())
return
total_bytes = 0
with open(output_path, "wb") as f:
for chunk in resp.iter_bytes():
f.write(chunk)
total_bytes += len(chunk)
print(f"Streamed {total_bytes} bytes to: {output_path}")
else:
with httpx.Client(timeout=300.0) as client:
response = client.post(api_url, json=payload, headers=headers)
if response.status_code != 200:
print(f"Error: {response.status_code}")
print(response.text)
return
try:
text = response.content.decode("utf-8")
if text.startswith('{"error"'):
print(f"Error: {text}")
return
except UnicodeDecodeError:
pass
output_path = args.output or "output.wav"
with open(output_path, "wb") as f:
f.write(response.content)
print(f"Audio saved to: {output_path}")
def main():
parser = argparse.ArgumentParser(description="CosyVoice3 TTS client")
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL")
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
parser.add_argument("--model", "-m", default=DEFAULT_MODEL, help="Model name")
parser.add_argument("--text", required=True, help="Text to synthesize")
parser.add_argument(
"--ref-audio",
default=DEFAULT_REF_AUDIO,
help="Reference audio for voice cloning (path or URL)",
)
parser.add_argument(
"--ref-text",
default=DEFAULT_REF_TEXT,
help="Transcript of the reference audio",
)
parser.add_argument("--stream", action="store_true", help="Enable streaming (PCM output)")
parser.add_argument(
"--response-format",
default="wav",
choices=["wav", "mp3", "flac", "pcm", "aac", "opus"],
help="Audio format (default: wav)",
)
parser.add_argument("--output", "-o", default=None, help="Output file path")
args = parser.parse_args()
run_tts(args)
if __name__ == "__main__":
main()
@@ -1,155 +0,0 @@
"""OpenAI-compatible client for VoxCPM via /v1/audio/speech.
Examples:
# Basic text-to-speech
python openai_speech_client.py --text "Hello from VoxCPM"
# Voice cloning
python openai_speech_client.py \
--text "This sentence uses the cloned voice." \
--ref-audio /path/to/reference.wav \
--ref-text "The exact transcript spoken in the reference audio."
# Streaming PCM output
python openai_speech_client.py \
--text "This is a streaming VoxCPM request." \
--stream \
--output output.pcm
"""
import argparse
import base64
import os
import httpx
DEFAULT_API_BASE = "http://localhost:8091"
DEFAULT_API_KEY = "EMPTY"
DEFAULT_MODEL = "OpenBMB/VoxCPM1.5"
def encode_audio_to_base64(audio_path: str) -> str:
"""Encode a local audio file to base64 data URL."""
if not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
ext = audio_path.lower().rsplit(".", 1)[-1]
mime_map = {
"wav": "audio/wav",
"mp3": "audio/mpeg",
"flac": "audio/flac",
"ogg": "audio/ogg",
}
mime_type = mime_map.get(ext, "audio/wav")
with open(audio_path, "rb") as f:
audio_b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime_type};base64,{audio_b64}"
def build_payload(args) -> dict[str, object]:
payload: dict[str, object] = {
"model": args.model,
"input": args.text,
"response_format": "pcm" if args.stream else args.response_format,
}
if args.ref_audio:
if args.ref_audio.startswith(("http://", "https://", "data:")):
payload["ref_audio"] = args.ref_audio
else:
payload["ref_audio"] = encode_audio_to_base64(args.ref_audio)
if args.ref_text:
payload["ref_text"] = args.ref_text
if args.max_new_tokens is not None:
payload["max_new_tokens"] = args.max_new_tokens
if args.stream:
payload["stream"] = True
return payload
def run_tts(args) -> None:
payload = build_payload(args)
api_url = f"{args.api_base}/v1/audio/speech"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {args.api_key}",
}
print(f"Model: {args.model}")
print(f"Text: {args.text}")
if args.ref_audio:
print("Mode: voice cloning")
print(f"Reference audio: {args.ref_audio}")
else:
print("Mode: text-to-speech")
if args.stream:
output_path = args.output or "voxcpm_output.pcm"
with httpx.Client(timeout=300.0) as client:
with client.stream("POST", api_url, json=payload, headers=headers) as response:
if response.status_code != 200:
print(f"Error: {response.status_code}")
print(response.read().decode("utf-8", errors="ignore"))
return
total_bytes = 0
with open(output_path, "wb") as f:
for chunk in response.iter_bytes():
if not chunk:
continue
f.write(chunk)
total_bytes += len(chunk)
print(f"Streamed {total_bytes} bytes to: {output_path}")
return
with httpx.Client(timeout=300.0) as client:
response = client.post(api_url, json=payload, headers=headers)
if response.status_code != 200:
print(f"Error: {response.status_code}")
print(response.text)
return
try:
text = response.content.decode("utf-8")
if text.startswith('{"error"'):
print(f"Error: {text}")
return
except UnicodeDecodeError:
pass
output_path = args.output or "voxcpm_output.wav"
with open(output_path, "wb") as f:
f.write(response.content)
print(f"Audio saved to: {output_path}")
def main():
parser = argparse.ArgumentParser(description="VoxCPM OpenAI-compatible speech client")
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL")
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
parser.add_argument("--model", "-m", default=DEFAULT_MODEL, help="Model name or path")
parser.add_argument("--text", required=True, help="Text to synthesize")
parser.add_argument("--ref-audio", default=None, help="Reference audio path, URL, or data URL")
parser.add_argument(
"--ref-text",
default=None,
help="The exact transcript spoken in the reference audio",
)
parser.add_argument("--stream", action="store_true", help="Enable streaming PCM output")
parser.add_argument(
"--response-format",
default="wav",
choices=["wav", "pcm", "flac", "mp3", "aac", "opus"],
help="Audio format for non-streaming mode (default: wav)",
)
parser.add_argument("--max-new-tokens", type=int, default=None, help="Maximum tokens to generate")
parser.add_argument("--output", "-o", default=None, help="Output file path")
args = parser.parse_args()
run_tts(args)
if __name__ == "__main__":
main()
@@ -1,38 +0,0 @@
#!/bin/bash
# Launch vLLM-Omni server for VoxCPM online speech serving.
#
# Usage:
# ./run_server.sh # default: async_chunk stage config
# ./run_server.sh async # async_chunk stage config
# ./run_server.sh sync # no-async-chunk stage config
# VOXCPM_MODEL=/path/to/model ./run_server.sh
set -e
MODE="${1:-async}"
MODEL="${VOXCPM_MODEL:-OpenBMB/VoxCPM1.5}"
case "$MODE" in
async)
STAGE_CONFIG="vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml"
;;
sync)
STAGE_CONFIG="vllm_omni/deploy/voxcpm.yaml"
;;
*)
echo "Unknown mode: $MODE"
echo "Supported: async, sync"
exit 1
;;
esac
echo "Starting VoxCPM server with model: $MODEL"
echo "Stage config: $STAGE_CONFIG"
vllm serve "$MODEL" \
--stage-configs-path "$STAGE_CONFIG" \
--host 0.0.0.0 \
--port 8091 \
--trust-remote-code \
--enforce-eager \
--omni
+8 -1
View File
@@ -63,7 +63,7 @@ dev = [
"funasr>=1.0.0",
"lpips>=0.1.4", # for quantization fp8
"orjson>=3.11.7", # to make v1/embeddings API fast
"voxcpm>=2.0.2", # for voxcpm model Test
"voxcpm>=2.0.2", # for VoxCPM2 path-locating in tests
"torchdiffeq>=0.2.5" # for covo_audio Test
]
@@ -71,6 +71,11 @@ demo = [
"gradio>=6.7.0",
]
# Local-test-only extras
local = [
"FlagEmbedding",
]
docs = [
"mkdocs>=1.5.0",
"mkdocs-api-autonav",
@@ -184,6 +189,7 @@ markers = [
"core_model: L1&L2 tests (run in each PR)",
"advanced_model: L3 level tests (run on each merge)",
"full_model: L4 level tests (run nightly)",
"local_model: tests (run locally)",
# function module markers
"diffusion: Diffusion model tests",
"omni: Omni model tests",
@@ -252,3 +258,4 @@ fullset = "fullset"
Vai = "Vai"
tockens = "tockens"
CANN = "CANN"
ND = "ND"
+171
View File
@@ -0,0 +1,171 @@
# Qwen-Image-Edit
> Image editing serving
## Summary
- Vendor: Qwen
- Model: `Qwen/Qwen-Image-Edit`
- Task: Text-guided single-image editing
- Mode: Online serving with the OpenAI-compatible multimodal endpoint
- Maintainer: Community
## When to use this recipe
Use this recipe as a baseline for serving `Qwen/Qwen-Image-Edit` for single-image editing on H200 141GB. Two configurations are validated below, sharing one software environment:
| Configuration | Parallelism | Notes |
| --- | --- | --- |
| `1x H200` | none | Single-card baseline; ~60 GiB peak VRAM on a 141 GB card |
| `2x H200` | TP=2 | Lower single-request latency (≈ 1.60× speedup) and lower per-GPU VRAM |
## References
- Model: <https://huggingface.co/Qwen/Qwen-Image-Edit>
- Example guide: [`examples/online_serving/image_to_image/README.md`](../../examples/online_serving/image_to_image/README.md)
- User guide: [`docs/user_guide/examples/online_serving/image_to_image.md`](../../docs/user_guide/examples/online_serving/image_to_image.md)
## Hardware Support
### GPU
The two hardware configurations below share the same software environment.
#### Environment
- OS: Linux
- Python: 3.12+
- Driver / runtime: CUDA-capable runtime matching the repository build
- vLLM: use versions from your current checkout, >=0.20.0
- vLLM-Omni: use versions from your current checkout
#### 1x H200 141GB
##### Command
```bash
CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen-Image-Edit --omni \
--port 8092
```
The bundled launcher is equivalent:
```bash
bash examples/online_serving/image_to_image/run_server.sh
```
The server is ready when the log shows `INFO: Application startup complete.` (preceded by `Started server process [PID]` and `Waiting for application startup.`).
##### Verification
Run a single-image edit against the public example asset:
```bash
wget https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/qwen-bear.png
PROMPT="Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles such as 'Be Kind'"
IMG_B64=$(base64 -w0 qwen-bear.png)
curl -s http://localhost:8092/v1/chat/completions \
-H "Content-Type: application/json" \
-d "$(jq -n --arg prompt "$PROMPT" --arg img "$IMG_B64" '{
messages: [{
role: "user",
content: [
{type: "text", text: $prompt},
{type: "image_url", image_url: {url: ("data:image/png;base64," + $img)}}
]
}],
extra_body: {
height: 1024,
width: 1024,
num_inference_steps: 50,
guidance_scale: 1.0,
seed: 42
}
}')" \
| jq -r '.choices[0].message.content[0].image_url.url' \
| cut -d',' -f2- | base64 -d > qwen_image_edit_1card.png
```
The decoded PNG is a 1024×1024 RGB image of the bear mascot dancing under stylized moonlight, surrounded by floating stars and a "Be Kind" text bubble.
You can also run the offline CLI without standing up a server:
```bash
python examples/offline_inference/image_to_image/image_edit.py \
--image qwen-bear.png \
--prompt "Let this mascot dance under the moon, ..." \
--output qwen_image_edit_offline.png \
--num-inference-steps 50 \
--cfg-scale 4.0
```
Validated reference numbers for 1024×1024, 50 denoising steps, batch size 1, `guidance_scale=1.0`, seed 42 (`Cold` = first request after startup, `Warm` = identical subsequent request):
| Cold latency | Warm latency | Peak GPU memory |
| ---: | ---: | ---: |
| 17.80 s | 17.78 s | 61,091 MiB (~59.66 GiB) |
##### Notes
- Generation parameters live under `extra_body` for `/v1/chat/completions`, or as top-level form fields when using `/v1/images/edits`. See [`examples/online_serving/image_to_image/README.md`](../../examples/online_serving/image_to_image/README.md) for the full request / response schema.
- Memory: a 1024×1024 / 50-step edit uses ~60 GiB on a single H200, well within the 141 GB headroom.
- Multi-image variants of Qwen-Image-Edit (e.g. `Qwen-Image-Edit-2509`) are intentionally out of scope for this recipe and are not validated here.
#### 2x H200 141GB (TP=2)
##### Command
```bash
CUDA_VISIBLE_DEVICES=0,1 vllm serve Qwen/Qwen-Image-Edit --omni \
--port 8092 \
--tensor-parallel-size 2
```
In addition to the `Application startup complete.` line, the TP=2 server's `non-default args` log line echoes `'tensor_parallel_size': 2`, and a separate `Worker N: Initialized device and distributed environment` line is emitted for each rank — one per visible GPU.
##### Verification
Run the same `curl` command as in the **1x H200** Verification section above against the TP=2 server (same prompt, same seed, same image). Save the result to `qwen_image_edit_2card_tp2.png` so the two outputs sit side by side for comparison. During TP=2 inference, `nvidia-smi` shows sustained `~99 %` utilization on both GPUs, which is the expected signal that TP wiring is active.
Same workload as the 1x table above (1024×1024, 50 steps, `guidance_scale=1.0`, seed 42):
| Configuration | Cold latency | Warm latency | Peak per-GPU memory |
| --- | ---: | ---: | ---: |
| 1x H200 | 17.80 s | 17.78 s | 61,091 MiB (~59.66 GiB) |
| 2x H200 TP=2 | 11.16 s | 11.09 s | 49,377 MiB (~48.22 GiB) |
TP=2 reduces single-request warm latency from `17.78 s` to `11.09 s` (≈ **1.60×**) and drops per-GPU peak VRAM by ~11.4 GiB.
##### Notes
- TP=2 shards model weights across both cards, which is what produces both the latency reduction and the per-GPU VRAM drop above.
- CFG-Parallel (`--cfg-parallel-size 2`) is supported on the Qwen-Image family but only when `cfg_scale > 1`. The verification command above uses `guidance_scale=1.0` to match the public example image, so it does not exercise CFG-parallel; a separate run with `cfg_scale > 1` is needed if you want to benchmark that path.
- For deeper acceleration knobs (Cache-DiT, sequence parallel, HSDP) see [`docs/user_guide/diffusion/parallelism/overview.md`](../../docs/user_guide/diffusion/parallelism/overview.md); this recipe intentionally documents only the validated TP=2 baseline.
#### Profiling
The numbers below are from a warm request at 1024×1024, 50 steps, and `guidance_scale=1.0`, measured with `--enable-diffusion-pipeline-profiler` and a 250 ms `nvidia-smi --query-gpu=memory.used` sampler. For TP=2, latency uses rank max, and VRAM is per GPU.
Per-stage latency:
| Stage | TP=1 | TP=2 | Notes |
| --- | ---: | ---: | --- |
| `text_encoder.forward` | 0.132 s | 0.135 s | ~1.0× |
| `vae.encode` | 0.040 s | 0.041 s | ~1.0× |
| `diffuse` | 17.105 s | 10.382 s | **1.65×** speedup |
| `vae.decode` | 0.064 s | 0.066 s | ~1.0× |
| `forward` | 17.372 s | 10.652 s | 1.63× speedup |
Per-GPU VRAM:
| Snapshot | TP=1 | TP=2 | Notes |
| --- | ---: | ---: | --- |
| Static VRAM | 58,777 MiB | 47,065 MiB | after model load, before request |
| Peak VRAM | 61,091 MiB | 49,377 MiB | during inference |
| Request-time increment | 2,314 MiB | 2,312 MiB | peak minus static |
The latency gain mainly comes from the DiT denoising stage (`diffuse` in the table above). The other measured stages, such as text encoder and VAE encode/decode, stay essentially unchanged between TP=1 and TP=2, which is consistent with them not benefiting meaningfully from TP in this pipeline. The `forward` row's 1.63× speedup tracks `diffuse`, since `diffuse` accounts for about 98% of total pipeline time.
For memory, TP=2 saves about 11.4 GiB per GPU, mostly from static model-state memory, likely dominated by tensor-parallel sharding of the DiT weights. The request-time increment is almost unchanged, so TP=2 does not materially reduce transient inference-time memory for this single-image, batch-1 workload.
+1 -1
View File
@@ -104,7 +104,7 @@ To replay the batching benefit with matched warmup:
```bash
python benchmarks/diffusion/diffusion_benchmark_serving.py \
--backend vllm-omni \
--endpoint /v1/chat/completions \
--dataset vbench \
--task t2i \
--model Qwen/Qwen-Image \
+1
View File
@@ -35,6 +35,7 @@ recipes/
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | Text-to-video and image-to-video serving | 1x H200 141GB |
| [`LTX/LTX-2.3.md`](./LTX/LTX-2.3.md) | Text-to-video with audio generation (22B) | 1x GPU (96GB VRAM) |
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay | 1x A100 80GB |
| [`Qwen/Qwen-Image-Edit.md`](./Qwen/Qwen-Image-Edit.md) | Text-guided single-image editing | 1x or 2x H200 141GB |
| [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md) | Online serving for multimodal chat | 1x A100 80GB |
| [`Qwen/Qwen3-TTS.md`](./Qwen/Qwen3-TTS.md) | Text-to-speech serving (CustomVoice / VoiceDesign / Base) | 1x H100/A100 80GB |
| [`SenseNova/SenseNova-U1.md`](./SenseNova/SenseNova-U1.md) | Unified image generation and understanding | 1x H200 (144GB) |
+32
View File
@@ -63,6 +63,38 @@ def test_total_input_aggregated_from_output_prompt_len():
)
def test_audio_continuity_aggregation():
"""Continuity rate and underrun percentile must aggregate from per-output fields."""
bad = _make_output(100)
bad.audio_underrun_s = 0.5
bad.audio_continuity_ok = False
good_a = _make_output(100)
good_a.audio_underrun_s = 0.02
good_a.audio_continuity_ok = True
good_b = _make_output(100)
good_b.audio_underrun_s = 0.0
good_b.audio_continuity_ok = True
metrics, _ = calculate_metrics(
input_requests=[],
outputs=[bad, good_a, good_b],
dur_s=10.0,
tokenizer=None,
selected_percentiles=[50.0, 99.0],
goodput_config_dict={},
task_type=TaskType.GENERATION,
selected_percentile_metrics=["audio_underrun"],
max_concurrency=None,
request_rate=float("inf"),
benchmark_duration=10.0,
)
assert metrics.audio_continuity_ok_rate == pytest.approx(2 / 3, abs=1e-6)
# p99 of [0.5, 0.02, 0.0] is dominated by the 0.5 outlier.
p99 = dict(metrics.percentiles_audio_underrun_s).get(99.0)
assert p99 is not None and p99 > 0.4
# ============================================================================
# TTFT suppression for pure-audio (TTS) benchmarks
# ============================================================================
+123
View File
@@ -0,0 +1,123 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the audio-streaming continuity helper.
The helper is intentionally vllm-free: it operates on a flat timeline of
``(arrival_time_s, num_bytes)`` chunks plus PCM format params. These tests
exercise the underrun arithmetic against hand-crafted timelines so future
refactors of the streaming backend cannot silently regress the metric.
"""
from __future__ import annotations
import pytest
from vllm_omni.benchmarks.audio_continuity import (
ContinuityStats,
compute_continuity_stats,
)
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
# PCM s16le mono at 24 kHz -> 48 000 bytes/sec, the Qwen3-TTS / VoxCPM2 default.
_SR = 24_000
_BPS = _SR * 2 # 48 000
def test_empty_timeline_is_continuous() -> None:
stats = compute_continuity_stats([], [], sample_rate=_SR)
assert stats == ContinuityStats(
max_underrun_s=0.0,
underrun_event_count=0,
is_continuous=True,
)
def test_single_chunk_has_no_underrun() -> None:
# One large chunk, no later arrivals to check against.
stats = compute_continuity_stats(
chunk_arrival_times_s=[0.05],
chunk_bytes=[_BPS], # one second of audio in a single chunk
sample_rate=_SR,
)
assert stats.max_underrun_s == 0.0
assert stats.underrun_event_count == 0
assert stats.is_continuous is True
def test_chunks_faster_than_realtime_have_no_underrun() -> None:
# 100ms of audio per chunk, but arrive every 50ms — buffer always builds.
stats = compute_continuity_stats(
chunk_arrival_times_s=[0.0, 0.05, 0.10, 0.15],
chunk_bytes=[_BPS // 10] * 4,
sample_rate=_SR,
)
assert stats.max_underrun_s == 0.0
assert stats.underrun_event_count == 0
assert stats.is_continuous is True
def test_single_gap_above_threshold_breaks_continuity() -> None:
# First chunk carries 100ms of audio at t=0. Next chunk arrives at t=0.5s,
# so the player ran dry from t=0.1 to t=0.5 -> 400ms underrun.
stats = compute_continuity_stats(
chunk_arrival_times_s=[0.0, 0.5],
chunk_bytes=[_BPS // 10, _BPS // 10], # 100ms each
sample_rate=_SR,
threshold_s=0.1,
)
assert stats.max_underrun_s == pytest.approx(0.4, abs=1e-6)
assert stats.underrun_event_count == 1
assert stats.is_continuous is False
def test_underrun_is_max_across_gaps_not_sum() -> None:
# Two gaps: a 200ms shortfall and a 50ms shortfall. max_underrun_s
# must surface the 200ms one, not the sum.
stats = compute_continuity_stats(
chunk_arrival_times_s=[0.0, 0.3, 0.45],
chunk_bytes=[_BPS // 10, _BPS // 10, _BPS // 10], # 100ms chunks
sample_rate=_SR,
threshold_s=0.1,
)
# At t=0.3 the player consumed 300ms but only 100ms received -> 200ms deficit
# At t=0.45 player consumed 450ms, received 200ms -> 250ms deficit.
assert stats.max_underrun_s == pytest.approx(0.25, abs=1e-6)
assert stats.underrun_event_count == 2
assert stats.is_continuous is False
def test_continuity_threshold_boundary_inclusive() -> None:
# First chunk = 100ms audio at t=0. Next arrives at t=0.2 -> 100ms deficit
# exactly. With threshold=0.1, continuity_ok stays True (boundary).
stats = compute_continuity_stats(
chunk_arrival_times_s=[0.0, 0.2],
chunk_bytes=[_BPS // 10, _BPS // 10],
sample_rate=_SR,
threshold_s=0.1,
)
assert stats.max_underrun_s == pytest.approx(0.1, abs=1e-6)
assert stats.is_continuous is True
def test_mismatched_lengths_returns_zero_stats() -> None:
# Defensive: never crash on malformed timeline.
stats = compute_continuity_stats(
chunk_arrival_times_s=[0.0, 0.1],
chunk_bytes=[100],
sample_rate=_SR,
)
assert stats == ContinuityStats(0.0, 0, True)
def test_threshold_is_configurable() -> None:
# 200ms underrun: violates 100ms threshold, OK against 500ms threshold.
arrivals = [0.0, 0.3]
bytes_list = [_BPS // 10, _BPS // 10]
strict = compute_continuity_stats(arrivals, bytes_list, sample_rate=_SR, threshold_s=0.1)
lenient = compute_continuity_stats(arrivals, bytes_list, sample_rate=_SR, threshold_s=0.5)
assert strict.is_continuous is False
assert lenient.is_continuous is True
assert strict.max_underrun_s == lenient.max_underrun_s
@@ -1,6 +1,11 @@
import pytest
from benchmarks.diffusion.backends import RequestFuncInput, async_request_chat_completions
from benchmarks.diffusion.backends import (
RequestFuncInput,
async_request_chat_completions,
endpoint_filename_token,
normalize_endpoint,
)
class _MockResponse:
@@ -29,6 +34,32 @@ class _MockSession:
return _MockResponse(self._payload)
@pytest.mark.core_model
@pytest.mark.benchmark
@pytest.mark.cpu
def test_endpoint_normalization_accepts_optional_leading_slash():
assert normalize_endpoint("v1/videos") == "/v1/videos"
assert normalize_endpoint("/v1/videos") == "/v1/videos"
assert normalize_endpoint("v1/chat/completions") == "/v1/chat/completions"
assert normalize_endpoint("v1/images/generations") == "/v1/images/generations"
@pytest.mark.core_model
@pytest.mark.benchmark
@pytest.mark.cpu
def test_endpoint_normalization_accepts_legacy_backend_aliases():
assert normalize_endpoint("vllm-omni") == "/v1/chat/completions"
assert normalize_endpoint("openai") == "/v1/images/generations"
@pytest.mark.core_model
@pytest.mark.benchmark
@pytest.mark.cpu
def test_endpoint_filename_token_drops_leading_slash():
assert endpoint_filename_token("/v1/videos") == "v1_videos"
assert endpoint_filename_token("v1/chat/completions") == "v1_chat_completions"
@pytest.mark.core_model
@pytest.mark.benchmark
@pytest.mark.cpu
+2
View File
@@ -36,6 +36,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config):
_ASSERTION_EXPORT_NAMES = (
"assert_audio_speech_response",
"assert_diffusion_response",
"assert_http_error",
"assert_image_diffusion_response",
"assert_image_valid",
"assert_omni_response",
@@ -54,6 +55,7 @@ _MEDIA_EXPORT_NAMES = (
_STAGE_CONFIG_EXPORT_NAMES = ("modify_stage_config",)
_RUNTIME_EXPORT_NAMES = (
"DiffusionResponse",
"HttpResponse",
"OmniResponse",
"OmniRunner",
"OmniRunnerHandler",
+32 -5
View File
@@ -279,6 +279,10 @@ def create_benchmark_indices(
return indices
# Omni perf/stability: ``vllm bench serve --omni`` result template (used if bench exits without writing JSON).
OMNI_RESULT_TEMPLATE_PATH = Path(__file__).resolve().parent / "perf" / "scripts" / "result_omni_template.json"
def _safe_filename_token(value: Any | None, *, default: str = "na") -> str:
"""Make a single path segment safe for result filenames on common filesystems."""
if value is None:
@@ -289,7 +293,7 @@ def _safe_filename_token(value: Any | None, *, default: str = "na") -> str:
return s if s else default
def _resolve_baseline_value(
def resolve_baseline_value(
baseline_raw: Any,
*,
sweep_index: int | None,
@@ -330,7 +334,7 @@ def _baseline_thresholds_for_step(
) -> dict[str, Any]:
"""Resolve baseline config to one threshold per metric for this iteration."""
return {
metric_name: _resolve_baseline_value(
metric_name: resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
@@ -354,7 +358,12 @@ def run_benchmark(
random_input_len: Any | None = None,
random_output_len: Any | None = None,
) -> dict[str, Any]:
"""Run one ``vllm bench serve --omni`` iteration and return parsed metrics."""
"""Run one ``vllm bench serve --omni`` iteration and return parsed metrics.
After ``vllm bench`` writes the JSON, ``result["baseline"]`` holds the resolved per-metric thresholds
(when ``baseline_config`` is provided). If the benchmark exits without writing a result file,
``result_omni_template.json`` is used as a fallback.
"""
current_dt = datetime.now().strftime("%Y%m%d-%H%M%S")
ri = _safe_filename_token(random_input_len)
ro = _safe_filename_token(random_output_len)
@@ -403,8 +412,17 @@ def run_benchmark(
result_dir = "./"
result_path = os.path.join(result_dir, result_filename)
with open(result_path, encoding="utf-8") as f:
result = json.load(f)
if not os.path.exists(result_path):
with open(OMNI_RESULT_TEMPLATE_PATH, encoding="utf-8") as f:
template_result: dict[str, Any] = json.load(f)
Path(result_path).parent.mkdir(parents=True, exist_ok=True)
with open(result_path, "w", encoding="utf-8") as f:
json.dump(template_result, f, ensure_ascii=False, indent=2)
print(f"Benchmark result file not generated, fallback to template: {result_path}")
result = template_result
else:
with open(result_path, encoding="utf-8") as f:
result = json.load(f)
if baseline_config:
result["baseline"] = _baseline_thresholds_for_step(
@@ -432,3 +450,12 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=None,
help=("Path to benchmark config JSON. Example: --test-config-file tests/dfx/perf/tests/test_tts.json"),
)
parser.addoption(
"--assert-baseline",
action="store_true",
default=False,
help=(
"When set, omni/diffusion perf runners compare metrics against the baseline block in the JSON config "
"(default: off)."
),
)
@@ -1,7 +1,7 @@
[
{
"test_name": null,
"backend": null,
"endpoint": null,
"timestamp": null,
"server_params": {
"model": null,
@@ -43,7 +43,7 @@
"stage_durations_mean": {},
"stage_durations_p50": {},
"stage_durations_p99": {},
"backend": null,
"endpoint": null,
"model": null,
"dataset": null,
"task": null
@@ -51,6 +51,7 @@
"log_file": null,
"Model": null,
"Framework": null,
"API Endpoint": null,
"Hardware": null,
"Deployment": null,
"Task": null,
+20 -75
View File
@@ -12,12 +12,17 @@ from tests.dfx.conftest import (
create_unique_server_params,
get_benchmark_params_for_server,
load_configs,
resolve_baseline_value,
run_benchmark,
)
from tests.helpers.runtime import OmniServer
pytestmark = [pytest.mark.full_model]
# Compare metrics to each test JSON ``baseline`` block only when pytest is run with ``--assert-baseline``
# (registered in ``tests/dfx/conftest.py``; default: off). ``run_benchmark`` and ``_resolve_baseline_value`` are
# defined in the same module.
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
@@ -46,7 +51,6 @@ if CONFIG_FILE_PATH is None:
CONFIG_FILE_PATH = _DEFAULT_CONFIG_FILE
BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH)
OMNI_RESULT_TEMPLATE_PATH = Path(__file__).parent / "result_omni_template.json"
DEPLOY_CONFIGS_DIR = Path(__file__).parent.parent / "deploy"
@@ -87,16 +91,6 @@ def omni_server(request):
print("OmniServer stopped")
def _safe_filename_token(value: Any | None, *, default: str = "na") -> str:
"""Make a single path segment safe for result filenames on common filesystems."""
if value is None:
return default
s = str(value).strip()
for bad in ("/", "\\", ":", "*", "?", '"', "<", ">", "|"):
s = s.replace(bad, "_")
return s if s else default
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
@@ -126,76 +120,23 @@ def benchmark_params(request, omni_server):
}
def _resolve_baseline_value(
baseline_raw: Any,
*,
sweep_index: int | None,
max_concurrency: Any = None,
request_rate: Any = None,
) -> Any:
"""Pick the baseline threshold for this sweep step.
Supported shapes per metric:
- **Scalar** — same threshold for every concurrency / QPS.
- **List** — aligned with ``max_concurrency`` / ``request_rate`` sweep order; use ``sweep_index``.
- **Dict** — keyed by concurrency or rate, e.g. ``{"1": 500, "4": 800}`` (keys are strings in JSON).
For dict lookup, ``max_concurrency`` is preferred when both are set (concurrency sweep).
"""
if baseline_raw is None:
# If no baseline is set, the maximum value will be used.
return 100000
if isinstance(baseline_raw, dict):
if max_concurrency is not None:
for key in (max_concurrency, str(max_concurrency)):
if key in baseline_raw:
return baseline_raw[key]
if request_rate is not None:
for key in (request_rate, str(request_rate)):
if key in baseline_raw:
return baseline_raw[key]
raise KeyError(
f"baseline dict has no key for max_concurrency={max_concurrency!r} "
f"or request_rate={request_rate!r}; keys={list(baseline_raw.keys())!r}"
)
if isinstance(baseline_raw, (list, tuple)):
return baseline_raw[sweep_index]
return baseline_raw
def _baseline_thresholds_for_step(
baseline_data: dict[str, Any],
*,
sweep_index: int | None = None,
max_concurrency: Any = None,
request_rate: Any = None,
) -> dict[str, Any]:
"""Resolve ``test.json`` ``baseline`` block to one threshold per metric (same as ``assert_result``)."""
return {
metric_name: _resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
request_rate=request_rate,
)
for metric_name, baseline_raw in baseline_data.items()
}
def assert_result(
result,
params,
num_prompt,
*,
assert_baseline: bool,
sweep_index: int | None = None,
max_concurrency: Any = None,
request_rate: Any = None,
max_concurrency: Any | None = None,
request_rate: Any | None = None,
) -> None:
assert result["completed"] == num_prompt, "Request failures exist"
if not assert_baseline:
return
baseline_data = params.get("baseline", {})
for metric_name, baseline_raw in baseline_data.items():
current_value = result[metric_name]
baseline_value = _resolve_baseline_value(
baseline_value = resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
@@ -214,7 +155,7 @@ def assert_result(
@pytest.mark.benchmark
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("benchmark_params", benchmark_indices, indirect=True)
def test_performance_benchmark(omni_server, benchmark_params):
def test_performance_benchmark(omni_server, benchmark_params, request):
test_name = benchmark_params["test_name"]
params = benchmark_params["params"]
dataset_name = params.get("dataset_name", "")
@@ -226,6 +167,8 @@ def test_performance_benchmark(omni_server, benchmark_params):
print(f"Running benchmark for model: {model}")
print(f"Benchmark parameters: {benchmark_params}")
assert_baseline = request.config.getoption("--assert-baseline", default=False)
def to_list(value, default=None):
if value is None:
return [] if default is None else [default]
@@ -264,7 +207,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
elif not isinstance(value, bool):
args.extend([arg_name, str(value)])
# QPS test (sweep_index aligns with qps_list / num_prompt_list for this loop)
# QPS / request-rate sweep
for i, (qps, num_prompt) in enumerate(zip(qps_list, num_prompt_list)):
args = args + ["--request-rate", str(qps), "--num-prompts", str(num_prompt)]
result = run_benchmark(
@@ -283,12 +226,13 @@ def test_performance_benchmark(omni_server, benchmark_params):
assert_result(
result,
params,
num_prompt=num_prompt,
num_prompt,
assert_baseline=assert_baseline,
sweep_index=i,
request_rate=qps,
)
# concurrency test (sweep_index aligns with max_concurrency_list for separate thresholds per concurrency)
# concurrency test
for i, (concurrency, num_prompt) in enumerate(zip(max_concurrency_list, num_prompt_list)):
args = args + ["--max-concurrency", str(concurrency), "--num-prompts", str(num_prompt), "--request-rate", "inf"]
result = run_benchmark(
@@ -307,7 +251,8 @@ def test_performance_benchmark(omni_server, benchmark_params):
assert_result(
result,
params,
num_prompt=num_prompt,
num_prompt,
assert_baseline=assert_baseline,
sweep_index=i,
max_concurrency=concurrency,
)
@@ -5,16 +5,17 @@ This runner separates two concepts:
1. ``server_type``: how the serving process is started.
Currently only ``vllm-omni`` is supported here.
2. ``benchmark_backend``: which serving API the benchmark client calls.
Examples: ``vllm-omni`` for ``/v1/chat/completions`` and ``v1/videos``
for async video jobs.
2. ``benchmark_endpoint``: which serving API the benchmark client calls.
Examples: ``/v1/chat/completions`` and ``/v1/videos``.
A config JSON file is REQUIRED via --test-config-file:
pytest run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
Optional: ``--assert-baseline`` compares metrics to the ``baseline`` block in each benchmark entry (default: off).
All benchmark results for a session are consolidated into a single JSON file under
BENCHMARK_RESULT_DIR (override via the DIFFUSION_BENCHMARK_DIR environment variable).
Each entry in the file contains the test metadata (test_name, backend, benchmark_params,
Each entry in the file contains the test metadata (test_name, endpoint, benchmark_params,
timestamp) together with the raw metrics returned by the benchmark script.
"""
@@ -33,6 +34,8 @@ from typing import Any, cast
import psutil
import pytest
from benchmarks.diffusion.backends import endpoint_filename_token, normalize_endpoint
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
@@ -400,7 +403,7 @@ def _unique_server_params(configs: list[dict[str, Any]]) -> list[dict[str, Any]]
"model": cfg["server_params"]["model"],
"serve_args_dict": serve_args_dict,
"serve_args": _build_serve_args(serve_args_dict),
"benchmark_backend": cfg.get("benchmark_backend"),
"benchmark_endpoint": cfg.get("benchmark_endpoint", cfg.get("benchmark_backend")),
"server_params": cfg["server_params"],
}
)
@@ -482,7 +485,7 @@ def run_benchmark(
model: str,
params: dict[str, Any],
test_name: str,
backend: str = "vllm-omni",
endpoint: str = "/v1/chat/completions",
server_cfg: dict[str, Any] | None = None,
source_file: str = "",
) -> dict[str, Any]:
@@ -490,18 +493,19 @@ def run_benchmark(
The raw metrics are written to a temporary file by the subprocess. After
the run completes the metrics are merged with full metadata (test_name,
backend, benchmark_params, timestamp, flat reporting fields) and appended
endpoint, benchmark_params, timestamp, flat reporting fields) and appended
to the session-wide aggregated JSON file (AGGREGATED_RESULT_FILE). The
temporary file is removed afterwards. Subprocess stdout/stderr are tee'd
to a .log file under BENCHMARK_RESULT_DIR/logs/; its path is stored in
the record.
"""
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
endpoint = normalize_endpoint(endpoint)
log_dir = BENCHMARK_RESULT_DIR / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
backend_label = backend.replace("/", "_")
log_file = log_dir / f"{test_name}_{backend_label}_{timestamp}.log"
endpoint_label = endpoint_filename_token(endpoint)
log_file = log_dir / f"{test_name}_{endpoint_label}_{timestamp}.log"
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", prefix="diffusion_bench_tmp_", delete=False) as tmp:
tmp_result_file = Path(tmp.name)
@@ -517,8 +521,8 @@ def run_benchmark(
str(port),
"--model",
model,
"--backend",
backend,
"--endpoint",
endpoint,
"--dataset",
params.get("dataset", "random"),
"--task",
@@ -545,7 +549,7 @@ def run_benchmark(
# cause truncated or out-of-order log output when stdout is piped).
cmd = [cmd[0], "-u"] + cmd[1:]
print(f"\nRunning benchmark (backend={backend}): {' '.join(cmd)}")
print(f"\nRunning benchmark (endpoint={endpoint}): {' '.join(cmd)}")
print(f" Log file: {log_file}")
# Redirect stdout + stderr directly to the log file at the OS level
@@ -597,7 +601,7 @@ def run_benchmark(
record: dict[str, Any] = {
"test_name": test_name,
"backend": backend,
"endpoint": endpoint,
"timestamp": timestamp,
"server_params": server_cfg.get("server_params"),
"benchmark_params": params,
@@ -605,7 +609,7 @@ def run_benchmark(
"log_file": str(log_file),
"Model": model,
"Framework": server_type,
"API Backend": backend,
"API Endpoint": endpoint,
"Hardware": "",
"Deployment": "",
"Task": params.get("task", "t2i"),
@@ -650,12 +654,14 @@ def run_benchmark(
# ---------------------------------------------------------------------------
def assert_result(result: dict[str, Any], params: dict[str, Any]) -> None:
"""Assert that benchmark metrics satisfy the configured baselines."""
def assert_result(result: dict[str, Any], params: dict[str, Any], *, assert_baseline: bool) -> None:
"""Assert request completion; optionally assert metrics against ``baseline`` in *params*."""
num_prompts = params.get("num-prompts", 10)
completed = result.get("completed_requests", result.get("completed", 0))
assert completed == num_prompts, f"Expected {num_prompts} completed requests, got {completed}"
if not assert_baseline:
return
if params.get("skip-performance-assertion", False):
print("Skipping performance assertions.")
return
@@ -669,21 +675,21 @@ def assert_result(result: dict[str, Any], params: dict[str, Any]) -> None:
assert current <= threshold, f"{metric}: {current:.4f} > baseline {threshold}"
def _default_benchmark_backend_for_task(task: str) -> str:
"""Return the default client-side benchmark backend for a diffusion task."""
def _default_benchmark_endpoint_for_task(task: str) -> str:
"""Return the default client-side benchmark endpoint for a diffusion task."""
if task in {"t2v", "i2v", "ti2v"}:
return "v1/videos"
return "/v1/videos"
if task in {"t2i", "i2i", "ti2i"}:
return "vllm-omni"
raise ValueError(f"Unsupported task for benchmark backend resolution: {task}")
return "/v1/chat/completions"
raise ValueError(f"Unsupported task for benchmark endpoint resolution: {task}")
def _resolve_benchmark_backend(server_cfg: dict[str, Any], params: dict[str, Any]) -> str:
def _resolve_benchmark_endpoint(server_cfg: dict[str, Any], params: dict[str, Any]) -> str:
"""Resolve which serving API the benchmark client should call."""
configured = server_cfg.get("benchmark_backend")
configured = server_cfg.get("benchmark_endpoint")
if configured:
return cast(str, configured)
return _default_benchmark_backend_for_task(cast(str, params.get("task", "t2i")))
return normalize_endpoint(cast(str, configured))
return _default_benchmark_endpoint_for_task(cast(str, params.get("task", "t2i")))
# ---------------------------------------------------------------------------
@@ -697,21 +703,19 @@ def _resolve_benchmark_backend(server_cfg: dict[str, Any], params: dict[str, Any
indirect=True,
)
@pytest.mark.parametrize("benchmark_params", benchmark_indices, indirect=True)
def test_diffusion_performance_benchmark(diffusion_server, benchmark_params):
"""Run the diffusion performance benchmark and assert against baselines.
def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, request):
"""Run the diffusion performance benchmark and verify request completion.
One server is started per unique parallel configuration (module scope).
For each server, all benchmark parameter sets defined in the config JSON
are executed sequentially; results are asserted against the baselines.
are executed sequentially; metrics are recorded to the aggregated result file.
Tracked metrics:
- throughput_qps (higher is better)
- latency_p50, latency_p99 (lower is better)
Pass ``--assert-baseline`` to compare ``throughput_qps``, ``latency_mean``, etc. to the JSON ``baseline`` block.
"""
test_name = benchmark_params["test_name"]
params = benchmark_params["params"]
server_cfg = getattr(diffusion_server, "server_cfg", {})
backend = _resolve_benchmark_backend(server_cfg, params)
endpoint = _resolve_benchmark_endpoint(server_cfg, params)
result = run_benchmark(
host=diffusion_server.host,
@@ -719,13 +723,13 @@ def test_diffusion_performance_benchmark(diffusion_server, benchmark_params):
model=diffusion_server.model,
params=params,
test_name=test_name,
backend=backend,
endpoint=endpoint,
server_cfg=server_cfg,
source_file=cast(str, CONFIG_FILE_PATH),
)
print(f"\n{'=' * 60}")
print(f"Results for {test_name} (server={diffusion_server.server_type}, backend={backend}):")
print(f"Results for {test_name} (server={diffusion_server.server_type}, endpoint={endpoint}):")
for key in (
"throughput_qps",
"latency_mean",
@@ -742,4 +746,5 @@ def test_diffusion_performance_benchmark(diffusion_server, benchmark_params):
print(f"\n Aggregated results: {AGGREGATED_RESULT_FILE}")
print("=" * 60)
assert_result(result, params)
assert_baseline = request.config.getoption("--assert-baseline", default=False)
assert_result(result, params, assert_baseline=assert_baseline)
@@ -3,31 +3,67 @@
"test_name": "test_qwen3_omni_chunk",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"extra_cli_args": ["--async-chunk"]
"extra_cli_args": [
"--async-chunk"
]
},
"benchmark_params": [
{
"dataset_name": "random",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [4, 16, 32, 64, 128],
"max_concurrency": [1, 4, 8, 16, 32],
"num_prompts": [
4,
16,
32,
64,
128
],
"max_concurrency": [
1,
4,
8,
16,
32
],
"random_input_len": 2500,
"random_output_len": 900,
"ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [1000, 3000, 5000, 7000, 9000],
"mean_audio_ttfp_ms": [1000, 3000, 5000, 7000, 9000],
"mean_audio_rtf": [0.2, 0.35, 0.6, 0.85, 0.9]
"mean_ttft_ms": [
400,
300,
400,
600,
900
],
"mean_e2el_ms": [
26000,
43000,
52000,
72000,
109000
],
"mean_audio_rtf": [
0.15,
0.30,
0.35,
0.50,
0.75
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [10],
"request_rate": [0.1],
"num_prompts": [
10
],
"request_rate": [
0.1
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -42,17 +78,27 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [2000],
"mean_audio_ttfp_ms": [2000],
"mean_audio_rtf": [0.25]
"mean_ttft_ms": [
600
],
"mean_e2el_ms": [
6000
],
"mean_audio_rtf": [
0.20
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [40],
"request_rate": [0.5],
"num_prompts": [
40
],
"request_rate": [
0.5
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -69,17 +115,27 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [6000],
"mean_audio_ttfp_ms": [6000],
"mean_audio_rtf": [0.7]
"mean_ttft_ms": [
300
],
"mean_e2el_ms": [
7000
],
"mean_audio_rtf": [
0.25
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [100],
"request_rate": [1.0],
"num_prompts": [
100
],
"request_rate": [
1.0
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -98,40 +154,79 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [12000],
"mean_audio_ttfp_ms": [12000],
"mean_audio_rtf": [1.0]
"mean_ttft_ms": [
700
],
"mean_e2el_ms": [
11000
],
"mean_audio_rtf": [
0.35
]
}
},
{
"dataset_name": "random",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [4, 16, 32, 64, 128],
"max_concurrency": [1, 4, 8, 16, 32],
"num_prompts": [
4,
16,
32,
64,
128
],
"max_concurrency": [
1,
4,
8,
16,
32
],
"random_input_len": 2500,
"random_output_len": 900,
"ignore_eos": true,
"extra_body": {
"modalities": ["text"]
"modalities": [
"text"
]
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [1000, 3000, 5000, 7000, 9000]
"mean_ttft_ms": [
100,
300,
500,
800,
1500
],
"mean_e2el_ms": [
5000,
8000,
10000,
14000,
20000
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [10],
"request_rate": [0.1],
"num_prompts": [
10
],
"request_rate": [
0.1
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
"ignore_eos": true,
"extra_body": {
"modalities": ["text"]
"modalities": [
"text"
]
},
"random_mm_base_items_per_request": 1,
"random_mm_num_mm_items_range_ratio": 0.5,
@@ -143,21 +238,32 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [2000]
"mean_ttft_ms": [
500
],
"mean_e2el_ms": [
1000
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [40],
"request_rate": [0.5],
"num_prompts": [
40
],
"request_rate": [
0.5
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
"ignore_eos": true,
"extra_body": {
"modalities": ["text"]
"modalities": [
"text"
]
},
"random_mm_base_items_per_request": 2,
"random_mm_num_mm_items_range_ratio": 0.5,
@@ -171,21 +277,32 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [6000]
"mean_ttft_ms": [
200
],
"mean_e2el_ms": [
800
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [100],
"request_rate": [1.0],
"num_prompts": [
100
],
"request_rate": [
1.0
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
"ignore_eos": true,
"extra_body": {
"modalities": ["text"]
"modalities": [
"text"
]
},
"random_mm_base_items_per_request": 3,
"random_mm_num_mm_items_range_ratio": 0.5,
@@ -201,7 +318,12 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [6000]
"mean_ttft_ms": [
600
],
"mean_e2el_ms": [
1300
]
}
}
]
@@ -3,31 +3,67 @@
"test_name": "test_qwen3_omni",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"extra_cli_args": ["--no-async-chunk"]
"extra_cli_args": [
"--no-async-chunk"
]
},
"benchmark_params": [
{
"dataset_name": "random",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [4, 16, 32, 64, 128],
"max_concurrency": [1, 4, 8, 16, 32],
"num_prompts": [
4,
16,
32,
64,
128
],
"max_concurrency": [
1,
4,
8,
16,
32
],
"random_input_len": 2500,
"random_output_len": 900,
"ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [1000, 3000, 5000, 7000, 9000],
"mean_audio_ttfp_ms": [30000, 60000, 90000, 120000, 150000],
"mean_audio_rtf": [0.35, 0.45, 0.55, 0.65, 0.75]
"mean_ttft_ms": [
400,
300,
400,
600,
900
],
"mean_e2el_ms": [
32000,
34000,
45000,
61000,
90000
],
"mean_audio_rtf": [
0.20,
0.25,
0.30,
0.45,
0.60
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [10],
"request_rate": [0.1],
"num_prompts": [
10
],
"request_rate": [
0.1
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -42,17 +78,27 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [2000],
"mean_audio_ttfp_ms": [10000],
"mean_audio_rtf": [0.25]
"mean_ttft_ms": [
500
],
"mean_e2el_ms": [
6000
],
"mean_audio_rtf": [
0.20
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [40],
"request_rate": [0.5],
"num_prompts": [
40
],
"request_rate": [
0.5
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -69,17 +115,27 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [6000],
"mean_audio_ttfp_ms": [15000],
"mean_audio_rtf": [0.45]
"mean_ttft_ms": [
300
],
"mean_e2el_ms": [
7000
],
"mean_audio_rtf": [
0.20
]
}
},
{
"dataset_name": "random-mm",
"backend": "openai-chat-omni",
"endpoint": "/v1/chat/completions",
"num_prompts": [100],
"request_rate": [1.0],
"num_prompts": [
100
],
"request_rate": [
1.0
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -98,9 +154,15 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [12000],
"mean_audio_ttfp_ms": [18000],
"mean_audio_rtf": [0.9]
"mean_ttft_ms": [
700
],
"mean_e2el_ms": [
9000
],
"mean_audio_rtf": [
0.30
]
}
}
]
@@ -4,7 +4,9 @@
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"use_omni": false,
"extra_cli_args": ["--no-enable-prefix-caching"]
"extra_cli_args": [
"--no-enable-prefix-caching"
]
},
"benchmark_params": [
{
@@ -12,8 +14,20 @@
"backend": "vllm",
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"endpoint": "/v1/completions",
"num_prompts": [4, 16, 32, 64, 128],
"max_concurrency": [1, 4, 8, 16, 32],
"num_prompts": [
4,
16,
32,
64,
128
],
"max_concurrency": [
1,
4,
8,
16,
32
],
"random_input_len": 2500,
"random_output_len": 900,
"temperature": 0.4,
@@ -23,11 +37,26 @@
"repetition_penalty": 1.05,
"ignore_eos": true,
"extra_body": {
"modalities": ["text"]
"modalities": [
"text"
]
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [1000, 3000, 5000, 7000, 9000]
"mean_ttft_ms": [
300,
300,
1000,
600,
800
],
"mean_e2el_ms": [
6000,
8000,
11000,
13000,
17000
]
}
},
{
@@ -35,8 +64,12 @@
"backend": "vllm",
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"endpoint": "/v1/completions",
"num_prompts": [10],
"request_rate": [0.1],
"num_prompts": [
10
],
"request_rate": [
0.1
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -47,7 +80,9 @@
"repetition_penalty": 1.05,
"ignore_eos": true,
"extra_body": {
"modalities": ["text"]
"modalities": [
"text"
]
},
"random_mm_base_items_per_request": 1,
"random_mm_num_mm_items_range_ratio": 0.5,
@@ -59,7 +94,12 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [2000]
"mean_ttft_ms": [
30
],
"mean_e2el_ms": [
540
]
}
},
{
@@ -67,8 +107,12 @@
"backend": "vllm",
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"endpoint": "/v1/completions",
"num_prompts": [40],
"request_rate": [0.5],
"num_prompts": [
40
],
"request_rate": [
0.5
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -79,7 +123,9 @@
"repetition_penalty": 1.05,
"ignore_eos": true,
"extra_body": {
"modalities": ["text"]
"modalities": [
"text"
]
},
"random_mm_base_items_per_request": 2,
"random_mm_num_mm_items_range_ratio": 0.5,
@@ -93,7 +139,12 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [6000]
"mean_ttft_ms": [
30
],
"mean_e2el_ms": [
600
]
}
},
{
@@ -101,8 +152,12 @@
"backend": "vllm",
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"endpoint": "/v1/completions",
"num_prompts": [100],
"request_rate": [1.0],
"num_prompts": [
100
],
"request_rate": [
1.0
],
"random_input_len": 100,
"random_output_len": 100,
"random_range_ratio": 0.0,
@@ -113,7 +168,9 @@
"repetition_penalty": 1.05,
"ignore_eos": true,
"extra_body": {
"modalities": ["text"]
"modalities": [
"text"
]
},
"random_mm_base_items_per_request": 3,
"random_mm_num_mm_items_range_ratio": 0.5,
@@ -129,7 +186,12 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [6000]
"mean_ttft_ms": [
40
],
"mean_e2el_ms": [
700
]
}
}
]
@@ -22,10 +22,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.05,
"latency_mean": 18,
"peak_memory_mb_max": 78500,
"peak_memory_mb_mean": 78500
"throughput_qps": 0.06,
"latency_mean": 20,
"peak_memory_mb_max": 57000,
"peak_memory_mb_mean": 57000
}
},
{
@@ -41,9 +41,9 @@
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.01,
"latency_mean": 70,
"peak_memory_mb_max": 81000,
"peak_memory_mb_mean": 81000
"latency_mean": 60,
"peak_memory_mb_max": 67000,
"peak_memory_mb_mean": 67000
}
}
]
@@ -75,10 +75,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1,
"latency_mean": 12,
"peak_memory_mb_max": 69000,
"peak_memory_mb_mean": 69000
"throughput_qps": 0.15,
"latency_mean": 10,
"peak_memory_mb_max": 55000,
"peak_memory_mb_mean": 55000
}
},
{
@@ -93,10 +93,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.03,
"latency_mean": 28,
"peak_memory_mb_max": 69000,
"peak_memory_mb_mean": 69000
"throughput_qps": 0.05,
"latency_mean": 20,
"peak_memory_mb_max": 55000,
"peak_memory_mb_mean": 55000
}
}
]
@@ -138,10 +138,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.10,
"latency_mean": 12,
"peak_memory_mb_max": 73000,
"peak_memory_mb_mean": 73000
"throughput_qps": 0.21,
"latency_mean": 10,
"peak_memory_mb_max": 57000,
"peak_memory_mb_mean": 57000
}
},
{
@@ -156,10 +156,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.05,
"throughput_qps": 0.08,
"latency_mean": 20,
"peak_memory_mb_max": 81000,
"peak_memory_mb_mean": 81000
"peak_memory_mb_max": 66000,
"peak_memory_mb_mean": 66000
}
}
]
@@ -21,10 +21,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.05,
"latency_mean": 15.0,
"peak_memory_mb_max": 72500,
"peak_memory_mb_mean": 72500
"throughput_qps": 0.1,
"latency_mean": 10,
"peak_memory_mb_max": 57000,
"peak_memory_mb_mean": 57000
}
},
{
@@ -38,10 +38,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.01,
"latency_mean": 65.6,
"peak_memory_mb_max": 80777,
"peak_memory_mb_mean": 80777
"throughput_qps": 0.02,
"latency_mean": 50,
"peak_memory_mb_max": 65000,
"peak_memory_mb_mean": 65000
}
}
]
@@ -72,10 +72,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.10,
"latency_mean": 7.2,
"peak_memory_mb_max": 68100,
"peak_memory_mb_mean": 68100
"throughput_qps": 0.21,
"latency_mean": 10,
"peak_memory_mb_max": 56000,
"peak_memory_mb_mean": 56000
}
},
{
@@ -89,10 +89,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.03,
"latency_mean": 24.0,
"peak_memory_mb_max": 68100,
"peak_memory_mb_mean": 68100
"throughput_qps": 0.06,
"latency_mean": 20,
"peak_memory_mb_max": 56000,
"peak_memory_mb_mean": 56000
}
}
]
@@ -133,10 +133,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1,
"latency_mean": 6.5,
"peak_memory_mb_max": 72600,
"peak_memory_mb_mean": 72600
"throughput_qps": 0.26,
"latency_mean": 10,
"peak_memory_mb_max": 58000,
"peak_memory_mb_mean": 58000
}
},
{
@@ -150,10 +150,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.05,
"latency_mean": 16.0,
"peak_memory_mb_max": 81000,
"peak_memory_mb_mean": 81000
"throughput_qps": 0.1,
"latency_mean": 10,
"peak_memory_mb_max": 67000,
"peak_memory_mb_mean": 67000
}
}
]
@@ -21,10 +21,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.02,
"latency_mean": 40.0,
"peak_memory_mb_max": 70000,
"peak_memory_mb_mean": 70000
"throughput_qps": 0.05,
"latency_mean": 20,
"peak_memory_mb_max": 63000,
"peak_memory_mb_mean": 63000
}
},
{
@@ -38,10 +38,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.005,
"latency_mean": 80.0,
"peak_memory_mb_max": 70000,
"peak_memory_mb_mean": 70000
"throughput_qps": 0.03,
"latency_mean": 30,
"peak_memory_mb_max": 63000,
"peak_memory_mb_mean": 63000
}
}
]
@@ -21,9 +21,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.30,
"latency_mean": 3.50,
"peak_memory_mb_mean": 67000
"throughput_qps": 0.35,
"latency_mean": 3,
"peak_memory_mb_mean": 56000
}
},
{
@@ -37,9 +37,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.037,
"latency_mean": 27.0,
"peak_memory_mb_mean": 74000
"throughput_qps": 0.03,
"latency_mean": 26,
"peak_memory_mb_mean": 65000
}
}
]
@@ -67,9 +67,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.30,
"latency_mean": 3.50,
"peak_memory_mb_mean": 67000
"throughput_qps": 0.35,
"latency_mean": 3,
"peak_memory_mb_mean": 56000
}
},
{
@@ -83,9 +83,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.037,
"latency_mean": 27.0,
"peak_memory_mb_mean": 74000
"throughput_qps": 0.03,
"latency_mean": 26,
"peak_memory_mb_mean": 65000
}
}
]
@@ -116,9 +116,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1,
"latency_mean": 9.1,
"peak_memory_mb_mean": 61000
"throughput_qps": 0.11,
"latency_mean": 9,
"peak_memory_mb_mean": 55000
}
}
]
@@ -159,9 +159,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1,
"latency_mean": 3.50,
"peak_memory_mb_mean": 67000
"throughput_qps": 0.46,
"latency_mean": 3,
"peak_memory_mb_mean": 56000
}
},
{
@@ -175,9 +175,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1,
"latency_mean": 6.15,
"peak_memory_mb_mean": 74000
"throughput_qps": 0.18,
"latency_mean": 6,
"peak_memory_mb_mean": 67000
}
}
]
+197 -52
View File
@@ -4,7 +4,9 @@
"server_params": {
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"stage_overrides": {
"1": {"devices": "1"}
"1": {
"devices": "1"
}
}
},
"benchmark_params": [
@@ -15,13 +17,21 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "linyueqian/seed-tts-eval-subset",
"num_prompts": [20],
"max_concurrency": [1],
"num_prompts": [
20
],
"max_concurrency": [
1
],
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [350],
"median_audio_rtf": [0.25]
"median_audio_ttfp_ms": [
3000
],
"median_audio_rtf": [
0.6
]
}
},
{
@@ -31,14 +41,34 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "linyueqian/seed-tts-eval-subset",
"num_prompts": [80, 128, 128],
"max_concurrency": [8, 16, 64],
"num_prompts": [
80,
128,
128
],
"max_concurrency": [
8,
16,
64
],
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [3500, 30000, 7000],
"median_audio_rtf": [0.75, 10.0, 1.5],
"audio_throughput": [10.0, 1.0, 40.0]
"median_audio_ttfp_ms": [
1000,
3000,
14000
],
"median_audio_rtf": [
0.70,
1.15,
3.90
],
"audio_throughput": [
12,
14,
14
]
}
},
{
@@ -49,13 +79,19 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "linyueqian/seed-tts-eval-subset",
"num_prompts": [200],
"max_concurrency": [4],
"num_prompts": [
200
],
"max_concurrency": [
4
],
"seed_tts_locale": "en",
"seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_rtf": [0.45]
"mean_audio_rtf": [
0.45
]
}
}
]
@@ -65,7 +101,9 @@
"server_params": {
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"stage_overrides": {
"1": {"devices": "1"}
"1": {
"devices": "1"
}
}
},
"benchmark_params": [
@@ -76,14 +114,26 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [20],
"max_concurrency": [1],
"num_prompts": [
20
],
"max_concurrency": [
1
],
"seed_tts_locale": "en",
"extra_body": {"voice": "Vivian", "language": "English", "task_type": "CustomVoice"},
"extra_body": {
"voice": "Vivian",
"language": "English",
"task_type": "CustomVoice"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [150],
"median_audio_rtf": [0.15]
"median_audio_ttfp_ms": [
2000
],
"median_audio_rtf": [
0.40
]
}
},
{
@@ -93,15 +143,39 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [80, 128, 128],
"max_concurrency": [8, 16, 64],
"num_prompts": [
80,
128,
128
],
"max_concurrency": [
8,
16,
64
],
"seed_tts_locale": "en",
"extra_body": {"voice": "Vivian", "language": "English", "task_type": "CustomVoice"},
"extra_body": {
"voice": "Vivian",
"language": "English",
"task_type": "CustomVoice"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [1500, 30000, 3000],
"median_audio_rtf": [0.30, 10.0, 0.60],
"audio_throughput": [30.0, 1.0, 100.0]
"median_audio_ttfp_ms": [
300,
1000,
5000
],
"median_audio_rtf": [
0.25,
0.35,
1.10
],
"audio_throughput": [
30,
40,
40
]
}
},
{
@@ -112,14 +186,24 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [200],
"max_concurrency": [4],
"num_prompts": [
200
],
"max_concurrency": [
4
],
"seed_tts_locale": "en",
"extra_body": {"voice": "Vivian", "language": "English", "task_type": "CustomVoice"},
"extra_body": {
"voice": "Vivian",
"language": "English",
"task_type": "CustomVoice"
},
"seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_rtf": [0.35]
"mean_audio_rtf": [
0.35
]
}
},
{
@@ -129,15 +213,29 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [100],
"request_rate": [2.0],
"num_prompts": [
100
],
"request_rate": [
2.0
],
"seed_tts_locale": "en",
"extra_body": {"voice": "Vivian", "language": "English", "task_type": "CustomVoice"},
"extra_body": {
"voice": "Vivian",
"language": "English",
"task_type": "CustomVoice"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [3000],
"median_audio_rtf": [0.25],
"audio_throughput": [4.0]
"median_audio_ttfp_ms": [
100
],
"median_audio_rtf": [
0.20
],
"audio_throughput": [
10
]
}
},
{
@@ -147,14 +245,25 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_design",
"num_prompts": [20],
"max_concurrency": [1],
"num_prompts": [
20
],
"max_concurrency": [
1
],
"seed_tts_locale": "en",
"extra_body": {"task_type": "VoiceDesign", "language": "English"},
"extra_body": {
"task_type": "VoiceDesign",
"language": "English"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [150],
"median_audio_rtf": [0.15]
"median_audio_ttfp_ms": [
100
],
"median_audio_rtf": [
0.15
]
}
},
{
@@ -164,15 +273,38 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_design",
"num_prompts": [80, 128, 128],
"max_concurrency": [8, 16, 64],
"num_prompts": [
80,
128,
128
],
"max_concurrency": [
8,
16,
64
],
"seed_tts_locale": "en",
"extra_body": {"task_type": "VoiceDesign", "language": "English"},
"extra_body": {
"task_type": "VoiceDesign",
"language": "English"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [1500, 30000, 3500],
"median_audio_rtf": [0.35, 10.0, 0.70],
"audio_throughput": [25.0, 1.0, 80.0]
"median_audio_ttfp_ms": [
100,
1000,
5000
],
"median_audio_rtf": [
0.25,
0.40,
1.20
],
"audio_throughput": [
30,
40,
40
]
}
},
{
@@ -182,15 +314,28 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_design",
"num_prompts": [100],
"request_rate": [2.0],
"num_prompts": [
100
],
"request_rate": [
2.0
],
"seed_tts_locale": "en",
"extra_body": {"task_type": "VoiceDesign", "language": "English"},
"extra_body": {
"task_type": "VoiceDesign",
"language": "English"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [3500],
"median_audio_rtf": [0.30],
"audio_throughput": [4.0]
"median_audio_ttfp_ms": [
100
],
"median_audio_rtf": [
0.20
],
"audio_throughput": [
9
]
}
}
]
@@ -30,9 +30,9 @@
}
],
"baseline": {
"throughput_qps": 0.034,
"latency_mean": 26.0,
"peak_memory_mb_mean": 80000
"throughput_qps": 0.04,
"latency_mean": 30,
"peak_memory_mb_mean": 75000
}
}
]
@@ -72,9 +72,9 @@
}
],
"baseline": {
"throughput_qps": 0.042,
"latency_mean": 21.6,
"peak_memory_mb_mean": 55300
"throughput_qps": 0.06,
"latency_mean": 20,
"peak_memory_mb_mean": 48000
}
},
{
@@ -97,9 +97,9 @@
}
],
"baseline": {
"throughput_qps": 0.0085,
"latency_mean": 101.6,
"peak_memory_mb_mean": 65200
"throughput_qps": 0.01,
"latency_mean": 90,
"peak_memory_mb_mean": 56000
}
}
]
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Shared env for ``http_invalid`` (real :func:`omni_server`)."""
from __future__ import annotations
import io
import os
import pytest
from PIL import Image
# Match ``tests/e2e/online_serving/*`` module-level env for subprocess serve.
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
os.environ.setdefault("VLLM_TEST_CLEAN_GPU_MEMORY", "0")
@pytest.fixture
def tiny_png_bytes() -> bytes:
img = Image.new("RGB", (32, 32), color="gray")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
@@ -0,0 +1,118 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""``POST /v1/audio/generate`` validation (live Stable Audio Open server)."""
from __future__ import annotations
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
# https://github.com/vllm-project/vllm-omni/issues/3649 — invalid inputs still HTTP 200 / timeout / missing validation.
_SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649")
_PARAMS = [
pytest.param(
OmniServerParams(
model="stabilityai/stable-audio-open-1.0",
server_args=[
"--trust-remote-code",
"--enforce-eager",
"--model-class-name",
"StableAudioPipeline",
],
),
id="stable_audio_open",
marks=hardware_marks(res={"cuda": "L4"}),
),
]
@pytest.mark.parametrize(
"overrides, err_message",
[
pytest.param({"input": ""}, ("cannot be empty", "input"), id="input_empty", marks=_SKIP_ISSUE_3649),
pytest.param(
{"input": " "}, ("cannot be empty", "input"), id="input_whitespace_only", marks=_SKIP_ISSUE_3649
),
pytest.param({"input": 123}, ("input", "a valid string"), id="input_wrong_type"),
pytest.param(
{"response_format": "mpeg"},
("response_format", "literal_error", "Input should be"),
id="response_format_invalid",
),
pytest.param({"speed": 0.1}, ("speed", "greater_than_equal", "0.25"), id="speed_too_low"),
pytest.param({"speed": 99.0}, ("speed", "less_than_equal", "4"), id="speed_too_high"),
pytest.param({"stream_format": 1}, ("stream_format", "literal_error", "audio"), id="stream_format_wrong_type"),
pytest.param(
{"stream_format": "sse"},
("sse", "stream_format", "value_error", "not a supported stream_format"),
id="stream_format_sse_blocked",
),
pytest.param(
{"audio_length": -1.0}, ("audio_end_in_s", "higher than", "audio_start_in_s"), id="audio_length_negative"
),
pytest.param({"audio_length": 0}, "audio_length", id="audio_length_zero", marks=_SKIP_ISSUE_3649),
# ``OmniOpenAIServingAudioGenerate`` only forwards ``audio_start`` when ``audio_length`` is set.
pytest.param(
{"audio_start": -0.5, "audio_length": 5.0},
("audio_start_in_s", "between", "0", "512"),
id="audio_start_negative",
),
pytest.param(
{"negative_prompt": ["noise"]}, ("negative_prompt", "a valid string"), id="negative_prompt_wrong_type"
),
pytest.param({"guidance_scale": -1.0}, "guidance_scale", id="guidance_scale_negative", marks=_SKIP_ISSUE_3649),
pytest.param({"guidance_scale": 0}, "guidance_scale", id="guidance_scale_zero", marks=_SKIP_ISSUE_3649),
pytest.param(
{"num_inference_steps": 0}, "num_inference_steps", id="num_inference_steps_zero", marks=_SKIP_ISSUE_3649
),
pytest.param(
{"num_inference_steps": -1}, ("number of steps", "non-negative"), id="num_inference_steps_negative"
),
pytest.param(
{"num_inference_steps": 6000},
"num_inference_steps",
id="num_inference_steps_above_max",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
{"guidance_scale": 1001.0}, "guidance_scale", id="guidance_scale_above_max", marks=_SKIP_ISSUE_3649
),
pytest.param(
{"audio_length": 86401.0}, ("Requested audio length", "exceeds maximum"), id="audio_length_above_max"
),
pytest.param(
{"audio_start": 86401.0, "audio_length": 1.0},
("audio_start_in_s", "between", "0", "512"),
id="audio_start_above_max",
),
],
)
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_audio_generate_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
body = {"model": omni_server.model, "input": "ambient electronic pad"}
body.update(overrides)
openai_client.send_audio_generate_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_audio_generate_missing_input(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_generate_http_request(
{
"json": {"model": omni_server.model},
"timeout": 120,
"err_code": 400,
"err_message": ("missing", "input", "field required"),
}
)
@@ -0,0 +1,862 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""HTTP + WebSocket validation for Qwen3-TTS Base: ``/v1/audio/speech``, ``/v1/audio/speech/stream``, batch, voices."""
from __future__ import annotations
import io
import json
import uuid
import wave
from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.media import load_test_audio_data_url
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.stage_config import get_deploy_config_path
# Speech / voice-upload numeric caps for these tests only (not exported from vllm_omni).
# Align where possible with serving_speech defaults: _TTS_MAX_INSTRUCTIONS_LENGTH (500),
# _TTS_MAX_NEW_TOKENS_MAX (4096). Voice form max lengths are not public constants in prod;
# values below keep tests self-contained and deterministic.
_SPEECH_API_MAX_INSTRUCTIONS_CHARS = 500
_SPEECH_API_MAX_NEW_TOKENS = 4096
_VOICE_UPLOAD_MAX_CONSENT_LEN = 1024
_VOICE_UPLOAD_MAX_REF_TEXT_CHARS = 8192
_VOICE_UPLOAD_MAX_SPEAKER_DESCRIPTION_CHARS = 2048
REF_AUDIO_URL = load_test_audio_data_url("qwen3_tts/clone_2.wav")
REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
# Qwen3-TTS 0.6B expects 1024-dim speaker embeddings (see serving_speech / talker hidden_size).
_QWEN3_TTS_06B_SPEAKER_EMBEDDING_DIM = 1024
# Pop-only marker for ``test_speech_invalid_field_values``: omit ``ref_audio`` / ``ref_text`` so Base
# ``speaker_embedding`` cases mirror standalone embedding-only JSON (no ref_audio clash).
_SPEECH_INVALID_EMBEDDING_ONLY_BODY = "__speech_invalid_embedding_only_body__"
# Batch-only: merged items inherit batch ``ref_audio`` / ``ref_text`` because ``_pick`` treats item ``None``
# as "unset". Strip clone fields from the batch envelope before asserting CustomVoice preset errors.
_SPEECH_BATCH_NO_CLONE_FIELDS = "__speech_batch_no_clone_fields__"
_SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649")
pytestmark = [pytest.mark.slow, pytest.mark.tts]
_L4_SPEECH_HW = hardware_marks(res={"cuda": "L4"})
_SPEECH_SERVER_ARGS = ["--trust-remote-code", "--disable-log-stats"]
_QWEN3_TTS_SPEECH = [
pytest.param(
OmniServerParams(
model="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
stage_config_path=get_deploy_config_path("qwen3_tts.yaml"),
server_args=_SPEECH_SERVER_ARGS,
),
id="qwen3_tts",
marks=_L4_SPEECH_HW,
),
]
def _apply_batch_overrides(body: dict[str, object], *, loc: str, overrides: dict[str, object]) -> None:
if loc == "batch":
body.update(overrides)
return
items = body["items"]
assert isinstance(items, list) and len(items) >= 1
first = items[0]
assert isinstance(first, dict)
body["items"] = [{**first, **overrides}]
def _pcm_wav_mono_bytes(*, duration_s: float = 1.1, sample_rate: int = 44100) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
nframes = max(1, int(sample_rate * duration_s))
wf.writeframes(b"\x00\x00" * nframes)
return buf.getvalue()
# ─────────────────────────────────────────────────────────────────────────────
# POST /v1/audio/speech (JSON)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_malformed_json(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_speech_http_request(
{
"raw_body": "{",
"timeout": 120,
"err_code": 400,
"err_message": ("json", "decode", "invalid"),
}
)
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_missing_required_fields(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_speech_http_request(
{
"json": {"model": omni_server.model},
"timeout": 120,
"err_code": 400,
"err_message": ("input", "field required", "missing"),
}
)
@pytest.mark.parametrize(
"overrides, err_message",
[
pytest.param({"input": ""}, ("input", "empty"), id="input_empty"),
pytest.param({"input": " "}, ("input", "empty"), id="input_whitespace_only"),
pytest.param({"voice": ""}, "Invalid voice", id="voice_empty", marks=_SKIP_ISSUE_3649),
pytest.param(
{"instructions": 123}, ("instructions", "string_type", "valid string"), id="instructions_wrong_type"
),
pytest.param(
{"response_format": "mpeg"}, ("response_format", "literal_error", "wav"), id="response_format_invalid"
),
pytest.param({"stream_format": 1}, ("stream_format", "literal_error", "audio"), id="stream_format_wrong_type"),
pytest.param(
{"stream_format": "sse"}, ("stream_format", "value_error", "audio"), id="stream_format_sse_blocked"
),
pytest.param({"stream": "wrong_type"}, ("stream", "bool_parsing", "validation error"), id="stream_wrong_type"),
pytest.param(
{
"voice": "vivian",
"task_type": "CustomVoice",
"ref_audio": None,
"ref_text": None,
},
("CustomVoice", "does not support"),
id="customvoice_task_not_supported",
),
pytest.param(
{"task_type": "InvalidEnum"}, ("task_type", "literal_error", "CustomVoice"), id="task_type_invalid"
),
pytest.param({"language": ""}, ("language", "invalid language", "chinese"), id="language_empty"),
pytest.param({"ref_audio": "ftp://example.com/a.wav"}, ("ref_audio", "URL"), id="ref_audio_bad_scheme"),
pytest.param(
{"ref_audio": "not_a_valid_uri"},
("ref_audio", "url"),
id="ref_audio_invalid_uri",
),
pytest.param({"ref_text": ["x"]}, ("ref_text", "string_type", "valid string"), id="ref_text_wrong_type"),
pytest.param(
{"x_vector_only_mode": "wrong"},
("x_vector_only_mode", "valid boolean"),
id="x_vector_only_mode_string_not_boolean",
),
pytest.param(
{"speaker_embedding": [0.01] * _QWEN3_TTS_06B_SPEAKER_EMBEDDING_DIM},
("mutually exclusive", "speaker_embedding", "ref_audio"),
id="speaker_embedding_with_ref_audio_mutually_exclusive",
),
pytest.param({"max_new_tokens": 0}, ("max_new_tokens", "least 1"), id="max_new_tokens_below_min"),
pytest.param(
{"max_new_tokens": _SPEECH_API_MAX_NEW_TOKENS + 1},
("max_new_tokens", "exceed 4096"),
id="max_new_tokens_above_max",
),
pytest.param({"seed": -1}, ("seed", "greater_than_equal", "0"), id="seed_negative"),
pytest.param({"seed": 2**63}, ("seed", "less_than_equal", "9223372036854775807"), id="seed_above_max"),
pytest.param(
{"initial_codec_chunk_frames": -1},
("initial_codec_chunk_frames", "greater_than_equal", "0"),
id="initial_codec_chunk_frames_negative",
),
pytest.param(
{"instructions": "x" * (_SPEECH_API_MAX_INSTRUCTIONS_CHARS + 1)},
("instructions", "too long"),
id="instructions_exceed_api_limit",
),
# Base clone: whitespace-only ref_text fails serving validation (not pydantic).
pytest.param(
{
"task_type": "Base",
"ref_audio": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
"ref_text": " ",
},
("ref_text", "base task", "non-empty"),
id="ref_text_whitespace_only_base_clone",
),
pytest.param(
{
_SPEECH_INVALID_EMBEDDING_ONLY_BODY: True,
"task_type": "Base",
"speaker_embedding": [],
},
("speaker_embedding", "non-empty"),
id="base_speaker_embedding_empty",
),
pytest.param(
{
_SPEECH_INVALID_EMBEDDING_ONLY_BODY: True,
"task_type": "Base",
"x_vector_only_mode": True,
"speaker_embedding": [0.01] * 512,
},
("speaker_embedding", "dimensions", str(_QWEN3_TTS_06B_SPEAKER_EMBEDDING_DIM)),
id="base_speaker_embedding_wrong_dim_short",
),
pytest.param(
{
_SPEECH_INVALID_EMBEDDING_ONLY_BODY: True,
"task_type": "Base",
"x_vector_only_mode": True,
"speaker_embedding": [0.01] * 2048,
},
("speaker_embedding", "2048", "dimensions", str(_QWEN3_TTS_06B_SPEAKER_EMBEDDING_DIM)),
id="base_speaker_embedding_wrong_dim_long",
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
ov = dict(overrides)
embedding_only = bool(ov.pop(_SPEECH_INVALID_EMBEDDING_ONLY_BODY, False))
body = {
"model": omni_server.model,
"input": "Hello.",
"voice": "clone",
"language": "English",
"response_format": "wav",
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
if embedding_only:
body.pop("ref_audio", None)
body.pop("ref_text", None)
body.update(ov)
openai_client.send_audio_speech_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
# ─────────────────────────────────────────────────────────────────────────────
# POST /v1/audio/speech/batch (JSON)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_batch_empty_items(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_speech_batch_http_request(
{"json": {"items": []}, "timeout": 120, "err_code": (400, 422), "err_message": ("items", "least 1 item")}
)
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_batch_missing_items(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_audio_speech_batch_http_request(
{
"json": {"model": omni_server.model},
"timeout": 120,
"err_code": (400, 422),
"err_message": ("items", "field required", "missing"),
}
)
# Align field-level checks with ``POST /v1/audio/speech`` where ``BatchSpeechRequest`` /
# ``SpeechBatchItem`` expose the same knobs. Not on batch schema (single-speech only):
# ``stream``, ``stream_format``, ``seed``, ``speaker_embedding`` — see dedicated speech tests.
@pytest.mark.parametrize(
"loc, overrides, err_message",
[
pytest.param("item", {"input": ""}, ("input", "empty"), id="item_input_empty", marks=_SKIP_ISSUE_3649),
pytest.param(
"item",
{"input": " "},
("input", "empty"),
id="item_input_whitespace_only",
marks=_SKIP_ISSUE_3649,
),
pytest.param("batch", {"voice": ""}, "Invalid voice", id="batch_voice_empty", marks=_SKIP_ISSUE_3649),
pytest.param("item", {"voice": ""}, "Invalid voice", id="item_voice_empty", marks=_SKIP_ISSUE_3649),
pytest.param(
"batch",
{
"voice": "nonexistent_voice_xyz",
"task_type": "CustomVoice",
"ref_audio": None,
"ref_text": None,
},
"Invalid voice",
id="batch_voice_unknown_preset",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{
_SPEECH_BATCH_NO_CLONE_FIELDS: True,
"voice": "nonexistent_voice_xyz",
"task_type": "CustomVoice",
},
"Invalid voice",
id="item_voice_unknown_preset",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"batch",
{"instructions": 123},
("instructions", "string_type", "valid string"),
id="batch_instructions_wrong_type",
),
pytest.param(
"item",
{"instructions": 123},
("instructions", "string_type", "valid string"),
id="item_instructions_wrong_type",
),
pytest.param(
"batch",
{"instructions": "x" * (_SPEECH_API_MAX_INSTRUCTIONS_CHARS + 1)},
("instructions", "too long"),
id="batch_instructions_exceed_api_limit",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{"instructions": "x" * (_SPEECH_API_MAX_INSTRUCTIONS_CHARS + 1)},
("instructions", "too long"),
id="item_instructions_exceed_api_limit",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"batch",
{"response_format": "mpeg"},
("response_format", "literal_error", "wav"),
id="batch_response_format_invalid",
),
pytest.param(
"item",
{"response_format": "mpeg"},
("response_format", "literal_error", "wav"),
id="item_response_format_invalid",
),
pytest.param("batch", {"speed": 0.1}, ("speed", "greater_than_equal", "0.25"), id="batch_speed_too_low"),
pytest.param("item", {"speed": 10.0}, ("speed", "less_than_equal", "4"), id="item_speed_too_high"),
pytest.param(
"batch",
{"task_type": "InvalidEnum"},
("task_type", "literal_error", "CustomVoice"),
id="batch_task_type_invalid",
),
pytest.param(
"item",
{"task_type": "InvalidEnum"},
("task_type", "literal_error", "CustomVoice"),
id="item_task_type_invalid",
),
pytest.param(
"batch",
{"language": ""},
("language", "invalid language", "chinese"),
id="batch_language_empty",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{"language": ""},
("language", "invalid language", "chinese"),
id="item_language_empty",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"batch",
{"ref_audio": "ftp://example.com/a.wav"},
("ref_audio", "URL"),
id="batch_ref_audio_bad_scheme",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{"ref_audio": "ftp://example.com/a.wav"},
("ref_audio", "URL"),
id="item_ref_audio_bad_scheme",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"batch",
{"ref_audio": "not_a_valid_uri"},
("ref_audio", "url"),
id="batch_ref_audio_invalid_uri",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{"ref_audio": "not_a_valid_uri"},
("ref_audio", "url"),
id="item_ref_audio_invalid_uri",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"batch",
{"ref_text": ["x"]},
("ref_text", "string_type", "valid string"),
id="batch_ref_text_wrong_type",
),
pytest.param(
"item",
{"ref_text": ["x"]},
("ref_text", "string_type", "valid string"),
id="item_ref_text_wrong_type",
),
pytest.param(
"batch",
{"x_vector_only_mode": "wrong_type"},
("x_vector_only_mode", "bool_parsing", "validation error"),
id="batch_x_vector_only_mode_wrong_type",
),
pytest.param(
"item",
{"x_vector_only_mode": "wrong_type"},
("x_vector_only_mode", "bool_parsing", "validation error"),
id="item_x_vector_only_mode_wrong_type",
),
pytest.param(
"batch",
{"max_new_tokens": 0},
("max_new_tokens", "least 1"),
id="batch_max_new_tokens_below_min",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{"max_new_tokens": 0},
("max_new_tokens", "least 1"),
id="item_max_new_tokens_below_min",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"batch",
{"max_new_tokens": _SPEECH_API_MAX_NEW_TOKENS + 1},
("max_new_tokens", "exceed 4096"),
id="batch_max_new_tokens_above_max",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{"max_new_tokens": _SPEECH_API_MAX_NEW_TOKENS + 1},
("max_new_tokens", "exceed 4096"),
id="item_max_new_tokens_above_max",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"batch",
{"initial_codec_chunk_frames": -1},
("initial_codec_chunk_frames", "greater_than_equal", "0"),
id="batch_initial_codec_chunk_frames_negative",
),
pytest.param(
"item",
{"initial_codec_chunk_frames": -1},
("initial_codec_chunk_frames", "greater_than_equal", "0"),
id="item_initial_codec_chunk_frames_negative",
),
# Base clone: whitespace-only ref_text (same serving check as single speech).
pytest.param(
"batch",
{
"task_type": "Base",
"ref_audio": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
"ref_text": " ",
},
("ref_text", "base task", "non-empty"),
id="batch_ref_text_whitespace_only_base_clone",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"item",
{
"task_type": "Base",
"ref_audio": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
"ref_text": " ",
},
("ref_text", "base task", "non-empty"),
id="item_ref_text_whitespace_only_base_clone",
marks=_SKIP_ISSUE_3649,
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_batch_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
loc: str,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
ov = dict(overrides)
strip_clone = bool(ov.pop(_SPEECH_BATCH_NO_CLONE_FIELDS, False))
body = {
"model": omni_server.model,
"voice": "clone",
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
"language": "English",
"response_format": "wav",
"items": [{"input": "Hello."}],
}
if strip_clone:
body.pop("ref_audio", None)
body.pop("ref_text", None)
_apply_batch_overrides(body, loc=loc, overrides=ov)
openai_client.send_audio_speech_batch_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
# ─────────────────────────────────────────────────────────────────────────────
# WebSocket /v1/audio/speech/stream
# ─────────────────────────────────────────────────────────────────────────────
# ``test_speech_stream_invalid_requests``: build ``session.config`` JSON with ``omni_server.model``.
_SPEECH_STREAM_WS_SESSION_STREAM_AUDIO_WAV_FRAMES = object()
@pytest.mark.parametrize(
"send_frames_spec, err_message",
[
pytest.param("{not-json", "Invalid JSON", id="invalid_json"),
pytest.param(
json.dumps({"type": "input.text", "text": "hello"}),
"Expected session.config",
id="wrong_first_message_type",
),
pytest.param(
_SPEECH_STREAM_WS_SESSION_STREAM_AUDIO_WAV_FRAMES,
("invalid session config", "response_format", "pcm"),
id="session_config_stream_audio_requires_pcm",
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_speech_stream_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
send_frames_spec: Any,
err_message: str | tuple[str, ...],
) -> None:
"""Bad first WebSocket text frames: non-JSON, wrong ``type``, or invalid ``session.config``."""
if send_frames_spec is _SPEECH_STREAM_WS_SESSION_STREAM_AUDIO_WAV_FRAMES:
send_frames = json.dumps(
{
"type": "session.config",
"model": omni_server.model,
"response_format": "wav",
"stream_audio": True,
}
)
else:
send_frames = send_frames_spec
assert isinstance(send_frames, str)
openai_client.send_audio_speech_stream_ws_request(
{
"send_frames": send_frames,
"timeout": 120,
"ws_max_size": None,
"ws_json_type": "error",
"err_message": err_message,
}
)
# ─────────────────────────────────────────────────────────────────────────────
# POST /v1/audio/voices (multipart + embedding-only) and DELETE /v1/audio/voices/{name}
# ─────────────────────────────────────────────────────────────────────────────
# Templates for ``test_voices_create_invalid_requests``: resolved in ``_finalize_voices_form_data``.
_VF_LONG_CONSENT = object()
_VF_LONG_REF_TEXT = object()
_VF_LONG_SPEAKER_DESC = object()
_VF_EMBEDDING_NAN_JSON = object()
def _finalize_voices_form_data(template: dict[str, Any], uuid_hex: str) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, val in template.items():
if val is _VF_LONG_CONSENT:
out[key] = "x" * (_VOICE_UPLOAD_MAX_CONSENT_LEN + 1)
elif val is _VF_LONG_REF_TEXT:
out[key] = "y" * (_VOICE_UPLOAD_MAX_REF_TEXT_CHARS + 1)
elif val is _VF_LONG_SPEAKER_DESC:
out[key] = "z" * (_VOICE_UPLOAD_MAX_SPEAKER_DESCRIPTION_CHARS + 1)
elif val is _VF_EMBEDDING_NAN_JSON:
out[key] = json.dumps([0.1] * 1023 + [float("nan")])
elif isinstance(val, str):
out[key] = val.replace("{uuid}", uuid_hex)
else:
out[key] = val
return out
def _voices_upload_multipart_files(kind: str | None) -> dict[str, Any] | None:
"""``None`` => no multipart file (embedding-only or neither)."""
if kind is None:
return None
if kind == "wav_ok":
return {"audio_sample": ("clip.wav", _pcm_wav_mono_bytes(), "audio/wav")}
if kind == "wav_short":
return {"audio_sample": ("clip.wav", _pcm_wav_mono_bytes(duration_s=0.3), "audio/wav")}
if kind == "wav_bad_body":
return {"audio_sample": ("clip.wav", b"not valid wav content", "audio/wav")}
if kind == "wav_pdf_type":
return {"audio_sample": ("clip.wav", _pcm_wav_mono_bytes(), "application/pdf")}
raise AssertionError(f"unknown multipart kind {kind!r}")
@pytest.mark.parametrize(
"multipart_kind, form_template, err_message",
[
pytest.param(
None,
{"consent": "c1", "name": "v1"},
("audio_sample", "speaker_embedding", "provided"),
id="neither_audio_nor_embedding",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
{"name": "v_miss_consent_{uuid}"},
("missing", "consent", "field required"),
id="missing_consent",
),
pytest.param(
"wav_ok",
{"consent": "consent_ok"},
("missing", "name", "field required"),
id="missing_name",
),
pytest.param(
"wav_ok",
{"consent": "", "name": "v_empty_consent_{uuid}"},
("missing", "consent", "field required"),
id="empty_consent",
),
pytest.param(
"wav_ok",
{"consent": " ", "name": "v_ws_consent_{uuid}"},
"consent",
id="whitespace_consent",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
{"consent": "bad/consent", "name": "v_bad_cons_{uuid}"},
"consent",
id="consent_path_sep",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
{"consent": _VF_LONG_CONSENT, "name": "v_long_cons_{uuid}"},
("consent", "too long", "Failed to save"),
id="consent_too_long",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
{"consent": "consent_ok", "name": ""},
("missing", "name", "field required"),
id="empty_name",
),
pytest.param(
"wav_ok",
{"consent": "consent_ok", "name": "evil/name"},
("voice name", "invalid voice name"),
id="invalid_name_path_sep",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
{"consent": "consent_ok", "name": "v_long_ref_{uuid}", "ref_text": _VF_LONG_REF_TEXT},
"ref_text",
id="ref_text_too_long",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
{
"consent": "consent_ok",
"name": "v_long_desc_{uuid}",
"speaker_description": _VF_LONG_SPEAKER_DESC,
},
"speaker_description",
id="speaker_description_too_long",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_pdf_type",
{"consent": "consent_ok", "name": "v_bad_mime_{uuid}"},
("mime", "unsupported", "audio/mp4"),
id="audio_unsupported_mime",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_short",
{"consent": "consent_ok", "name": "v_short_{uuid}"},
("too short", "reference audio"),
id="audio_too_short",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_bad_body",
{"consent": "consent_ok", "name": "v_bad_wav_{uuid}"},
("decode", "audio", "Format not recognised"),
id="audio_decode_error",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
None,
{
"speaker_embedding": "not valid json [[[",
"consent": "consent_emb",
"name": "v_bad_json_{uuid}",
},
("speaker_embedding", "valid JSON"),
id="speaker_embedding_invalid_json",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
None,
{
"speaker_embedding": json.dumps([]),
"consent": "consent_emb",
"name": "v_empty_emb_{uuid}",
},
("speaker_embedding", "non-empty"),
id="speaker_embedding_empty",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
None,
{
"speaker_embedding": _VF_EMBEDDING_NAN_JSON,
"consent": "consent_emb",
"name": "v_nan_emb_{uuid}",
},
("speaker_embedding", "finite"),
id="speaker_embedding_nan",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
None,
{
"speaker_embedding": json.dumps([0.01] * 512),
"consent": "consent_emb",
"name": "v_wrong_dim_{uuid}",
},
("expected", "dimensions", "1024"),
id="speaker_embedding_wrong_dim",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"wav_ok",
{
"consent": "consent_emb",
"name": "v_both_{uuid}",
"speaker_embedding": json.dumps([0.01] * 1024),
},
("mutually exclusive", "audio_sample", "speaker_embedding"),
id="audio_and_embedding_mutually_exclusive",
marks=_SKIP_ISSUE_3649,
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_voices_create_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
multipart_kind: str | None,
form_template: dict[str, Any],
err_message: str | tuple[str, ...],
) -> None:
"""Invalid ``POST /v1/audio/voices`` bodies (multipart or embedding-only)."""
uid = uuid.uuid4().hex[:8]
cfg: dict[str, Any] = {
"data": _finalize_voices_form_data(form_template, uid),
"timeout": 120,
"err_code": 400,
"err_message": err_message,
}
files = _voices_upload_multipart_files(multipart_kind)
if files is not None:
cfg["files"] = files
openai_client.send_audio_voices_create_http_request(cfg)
@pytest.mark.parametrize(
"voice_name, err_code, err_message",
[
pytest.param(
"missing-voice-404",
404,
"not found",
id="not_found",
),
pytest.param(
" ",
404,
"not found",
id="whitespace_only",
),
pytest.param(
"evil/name",
404,
"not found",
id="path_separator",
),
pytest.param(
".",
405,
("Method Not Allowed", "detail"),
id="dot_single",
),
pytest.param(
"..",
404,
"not found",
id="dot_dot",
),
pytest.param(
"bad\x00voice",
404,
"not found",
id="nul_byte",
),
pytest.param(
"",
405,
"Method Not Allowed",
id="empty_name_path",
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_TTS_SPEECH, indirect=True)
def test_voices_delete_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
voice_name: str,
err_code: int | tuple[int, ...],
err_message: str | tuple[str, ...],
) -> None:
"""DELETE ``/v1/audio/voices/{name}``: missing voice (404), odd segments (often 404), ``.``paths (405)."""
openai_client.send_audio_voices_delete_http_request(
{
"name": voice_name,
"timeout": 120,
"err_code": err_code,
"err_message": err_message,
}
)
@@ -0,0 +1,90 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""``POST /v1/images/edits`` multipart validation (live ``Qwen/Qwen-Image-Edit``)."""
from __future__ import annotations
from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
_PARAMS = [
pytest.param(
OmniServerParams(model="Qwen/Qwen-Image-Edit"),
id="qwen_image_edit",
marks=hardware_marks(res={"cuda": "H100"}),
),
]
# Populated in ``test_images_edits_invalid_requests`` from ``omni_server.model``.
_IMAGE_EDITS_SERVER_MODEL = object()
def _finalize_edits_form_data(template: dict[str, Any], server_model: str) -> dict[str, Any]:
return {k: (server_model if v is _IMAGE_EDITS_SERVER_MODEL else v) for k, v in template.items()}
@pytest.mark.parametrize(
"include_image, data_template, err_message, err_code",
[
pytest.param(
False,
{"prompt": "make it brighter", "model": _IMAGE_EDITS_SERVER_MODEL},
("image", "required", "Unprocessable Entity"),
422,
id="missing_image",
),
pytest.param(
True,
{"prompt": "edit", "model": "wrong-model-id"},
("model", "mismatch"),
400,
id="model_mismatch",
),
pytest.param(
True,
{"prompt": "edit", "response_format": "url"},
("response_format", "b64_json", "supported"),
400,
id="unsupported_response_format",
),
pytest.param(
True,
{"prompt": "edit", "model": _IMAGE_EDITS_SERVER_MODEL, "output_compression": "101"},
("less_than_equal", "output_compression", "100"),
400,
id="output_compression_above_max",
),
pytest.param(
True,
{"prompt": "edit", "model": _IMAGE_EDITS_SERVER_MODEL, "layers": "2"},
("Invalid layers", "3", "1"),
400,
id="invalid_layers",
),
],
)
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_images_edits_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
tiny_png_bytes: bytes,
include_image: bool,
data_template: dict[str, Any],
err_message: str | tuple[str, ...],
err_code: int | tuple[int, ...],
) -> None:
cfg: dict[str, Any] = {
"data": _finalize_edits_form_data(data_template, omni_server.model),
"timeout": 300,
"err_code": err_code,
"err_message": err_message,
}
if include_image:
cfg["files"] = {"image": ("x.png", tiny_png_bytes, "image/png")}
openai_client.send_images_edits_http_request(cfg)
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""``POST /v1/images/generations`` validation (live ``Qwen/Qwen-Image``)."""
from __future__ import annotations
from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
_SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649")
_PARAMS = [
pytest.param(
OmniServerParams(model="Qwen/Qwen-Image"),
id="qwen_image",
marks=hardware_marks(res={"cuda": "H100"}),
),
]
# Full replacement body for ``test_images_generations_invalid_requests`` (no minimal prompt/model merge).
_IMAGES_GEN_MISSING_PROMPT_JSON = object()
def _minimal_images_gen_json(omni_server: OmniServer) -> dict[str, object]:
return {"prompt": "a simple red apple icon", "model": omni_server.model}
# ─────────────────────────────────────────────────────────────────────────────
# POST /v1/images/generations (JSON)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"body_spec, err_message",
[
pytest.param(_IMAGES_GEN_MISSING_PROMPT_JSON, ("prompt", "Field required", "Missing"), id="missing_prompt"),
pytest.param(
{"prompt": "cat", "model": "not-the-loaded-checkpoint"},
("model", "mismatch"),
id="model_mismatch",
),
pytest.param({"prompt": ""}, ("prompt", "empty"), id="prompt_empty", marks=_SKIP_ISSUE_3649),
pytest.param({"prompt": " "}, ("prompt", "empty"), id="prompt_whitespace_only", marks=_SKIP_ISSUE_3649),
pytest.param({"prompt": 123}, ("prompt", "string_type", "valid string"), id="prompt_wrong_type"),
pytest.param({"n": 0}, ("n", "greater_than_equal", "1"), id="n_below_min"),
pytest.param({"n": 11}, ("n", "less_than_equal", "10"), id="n_above_max"),
pytest.param({"size": "1024"}, ("size", "value_error", "WIDTHxHEIGHT"), id="size_missing_height_token"),
pytest.param({"size": "not-a-size"}, ("size", "value_error", "WIDTHxHEIGHT"), id="size_missing_separator"),
pytest.param({"size": "1024xabc"}, ("Invalid size format", "integers"), id="size_non_integer_height"),
pytest.param({"size": "1024x"}, ("Invalid size format", "integers"), id="size_incomplete_height"),
pytest.param({"size": "0x1024"}, ("Invalid size", "positive integers"), id="size_non_positive_width"),
pytest.param(
{"response_format": "url"},
("response_format", "value_error", "b64_json"),
id="response_format_not_b64_json",
),
pytest.param(
{"use_system_prompt": "bogus"},
("use_system_prompt", "value_error", "dynamic"),
id="use_system_prompt_invalid",
),
pytest.param(
{"num_inference_steps": 0},
("num_inference_steps", "'greater_than_equal", "1"),
id="num_inference_steps_below_min",
),
pytest.param(
{"num_inference_steps": 201},
("num_inference_steps", "less_than_equal", "200"),
id="num_inference_steps_above_max",
),
pytest.param(
{"guidance_scale": -1.0}, ("guidance_scale", "greater_than_equal", "0"), id="guidance_scale_negative"
),
pytest.param(
{"guidance_scale": 21.0}, ("guidance_scale", "less_than_equal", "20"), id="guidance_scale_above_max"
),
pytest.param(
{"true_cfg_scale": -1.0}, ("true_cfg_scale", "greater_than_equal", "0"), id="true_cfg_scale_negative"
),
pytest.param(
{"true_cfg_scale": 21.0}, ("true_cfg_scale", "less_than_equal", "20"), id="true_cfg_scale_above_max"
),
pytest.param({"layers": 2}, ("layers", "value_error", "3", "10"), id="layers_below_min"),
pytest.param({"layers": 11}, ("layers", "value_error", "3", "10"), id="layers_above_max"),
pytest.param({"seed": -1}, ("seed", "greater_than_equal", "0"), id="seed_negative", marks=_SKIP_ISSUE_3649),
pytest.param(
{"seed": 2**32},
("seed", "less_than_equal", "4294967295"),
id="seed_above_uint32",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
{"output_format": "gif"},
("output_format", "value_error", "b64_json"),
id="output_format_invalid",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
{"vae_use_slicing": "wrong_type"},
("vae_use_slicing", "bool_parsing", "validation error"),
id="vae_use_slicing_wrong_type",
),
pytest.param({"user": 123}, ("user", "string_type", "valid string"), id="user_wrong_type"),
pytest.param(
{"negative_prompt": ["noise"]},
("negative_prompt", "string_type", "valid string"),
id="negative_prompt_wrong_type",
),
pytest.param(
{"generator_device": 1},
("generator_device", "string_type", "valid string"),
id="generator_device_wrong_type",
),
pytest.param(
{"lora": {"foo": "bar"}}, ("lora", "both name and path", "required"), id="lora_missing_required_fields"
),
],
)
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_images_generations_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
body_spec: Any,
err_message: str | tuple[str, ...],
) -> None:
"""Invalid JSON bodies for ``POST /v1/images/generations`` (missing prompt, model mismatch, bad fields)."""
if body_spec is _IMAGES_GEN_MISSING_PROMPT_JSON:
body: dict[str, object] = {"size": "1024x1024"}
else:
assert isinstance(body_spec, dict)
body = _minimal_images_gen_json(omni_server)
body.update(body_spec)
openai_client.send_images_generations_http_request(
{"json": body, "timeout": 300, "err_code": 400, "err_message": err_message}
)
@@ -0,0 +1,216 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Invalid inputs on Qwen3-Omni: ``POST /v1/chat/completions``, ``WS /v1/video/chat/stream``, ``WS /v1/realtime``."""
from __future__ import annotations
import json
from typing import Any
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
from tests.helpers.stage_config import get_deploy_config_path
pytestmark = [pytest.mark.slow, pytest.mark.omni]
_SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649")
def _minimal_chat_json(omni_server: OmniServer) -> dict[str, object]:
"""Minimal valid chat body; individual tests override one offending field."""
return {
"model": omni_server.model,
"messages": [{"role": "user", "content": "hello"}],
"stream": False,
}
_QWEN3_OMNI_SERVER = [
pytest.param(
OmniServerParams(
model="Qwen/Qwen3-Omni-30B-A3B-Instruct",
stage_config_path=get_deploy_config_path("qwen3_omni_moe.yaml"),
server_args=["--async-chunk"],
),
id="qwen3_omni",
),
]
def _chat_completions_request_without_expectations(omni_server: OmniServer, case_id: str) -> dict[str, Any]:
"""Build ``send_chat_completions_http_request`` kwargs excluding ``err_code`` / ``err_message``."""
if case_id == "malformed_json":
return {"raw_body": "{not-json", "timeout": 120}
if case_id == "missing_messages":
return {"json": {"model": omni_server.model, "stream": False}, "timeout": 120}
if case_id == "model_mismatch":
return {
"json": {
"model": "this-model-is-not-served-on-this-instance",
"messages": [{"role": "user", "content": "hello"}],
"stream": False,
},
"timeout": 120,
}
body = _minimal_chat_json(omni_server)
if case_id == "stream_wrong_type":
body["stream"] = "not-a-boolean" # type: ignore[assignment]
elif case_id == "temperature_wrong_type":
body["temperature"] = "hot" # type: ignore[assignment]
elif case_id == "max_tokens_wrong_type":
body["max_tokens"] = "not-an-int" # type: ignore[assignment] # type: ignore[assignment]
elif case_id == "modalities_list_bad":
body["modalities"] = [123] # type: ignore[assignment]
elif case_id == "response_format_json_schema_incomplete":
body["response_format"] = {"type": "json_schema"}
elif case_id == "logprobs_wrong_type":
body["logprobs"] = "yes" # type: ignore[assignment]
elif case_id == "logprobs_top_without_enabled":
body["logprobs"] = False
body["top_logprobs"] = 5
elif case_id == "speaker_unknown":
body["speaker"] = "zz_invalid_qwen3_omni_chat_speaker_xyz"
else:
raise AssertionError(f"unknown chat completions invalid case_id {case_id!r}")
return {"json": body, "timeout": 120}
# ─────────────────────────────────────────────────────────────────────────────
# POST /v1/chat/completions
# ─────────────────────────────────────────────────────────────────────────────
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize(
"case_id, err_code, err_message",
[
pytest.param(
"malformed_json",
400,
("json_invalid", "decode error"),
id="malformed_json",
),
pytest.param("missing_messages", 400, ("messages", "missing", "Field required"), id="missing_messages"),
pytest.param(
"model_mismatch",
404,
("model", "does not exist", "NotFoundError"),
id="model_mismatch",
),
pytest.param("stream_wrong_type", 400, ("stream", "bool_parsing", "not-a-boolean"), id="invalid_stream_type"),
pytest.param(
"temperature_wrong_type",
400,
("temperature", "float_parsing", "a valid number"),
id="invalid_temperature_type",
),
pytest.param(
"max_tokens_wrong_type", 400, ("max_tokens", "int_parsing", "integer"), id="invalid_max_tokens_type"
),
pytest.param(
"modalities_list_bad",
400,
("modalities", "value_error", ""),
id="modalities_list_bad_element",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"response_format_json_schema_incomplete",
400,
("response_format", "value_error", "json_schema"),
id="invalid_response_format_json_schema",
),
pytest.param("logprobs_wrong_type", 400, "logprobs", id="logprobs_wrong_type", marks=_SKIP_ISSUE_3649),
pytest.param(
"logprobs_top_without_enabled",
400,
("top_logprobs", "logprobs", "True"),
id="logprobs_top_without_logprobs_true",
),
pytest.param(
"speaker_unknown",
400,
("Invalid speaker", "Supported"),
id="speaker_unknown_preset",
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_OMNI_SERVER, indirect=True)
def test_chat_completions_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
case_id: str,
err_code: int | tuple[int, ...],
err_message: str | tuple[str, ...],
) -> None:
cfg = _chat_completions_request_without_expectations(omni_server, case_id)
cfg["err_code"] = err_code
cfg["err_message"] = err_message
openai_client.send_chat_completions_http_request(cfg)[0]
# ─────────────────────────────────────────────────────────────────────────────
# WS /v1/video/chat/stream
# ─────────────────────────────────────────────────────────────────────────────
# First-frame JSON with ``omni_server.model`` for ``test_video_chat_stream_invalid_requests``.
_VIDEO_CHAT_WS_SESSION_MODALITIES_SCALAR = object()
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize(
"send_frames_spec, err_message",
[
pytest.param("{not-json", "Invalid JSON", id="invalid_json"),
pytest.param(
_VIDEO_CHAT_WS_SESSION_MODALITIES_SCALAR,
"Invalid session config",
id="session_modalities_scalar",
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN3_OMNI_SERVER, indirect=True)
def test_video_chat_stream_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
send_frames_spec: Any,
err_message: str,
) -> None:
if send_frames_spec is _VIDEO_CHAT_WS_SESSION_MODALITIES_SCALAR:
send_frames = json.dumps({"type": "session.config", "model": omni_server.model, "modalities": "text"})
else:
send_frames = send_frames_spec
assert isinstance(send_frames, str)
openai_client.send_video_chat_stream_ws_request(
{
"send_frames": send_frames,
"timeout": 120,
"ws_max_size": None,
"ws_json_type": "error",
"err_message": err_message,
}
)
# ─────────────────────────────────────────────────────────────────────────────
# WS /v1/realtime
# ─────────────────────────────────────────────────────────────────────────────
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", _QWEN3_OMNI_SERVER, indirect=True)
def test_realtime_rejected_when_async_chunk_enabled(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
) -> None:
openai_client.send_realtime_ws_request(
{
"timeout": 120,
"ws_max_size": None,
"ws_json_type": "error",
"ws_error_code": "unsupported",
"err_message": "async_chunk",
}
)
@@ -0,0 +1,95 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Omni sleep/wakeup JSON validation (live ``Qwen/Qwen-Image`` process)."""
from __future__ import annotations
from typing import Any, Literal
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
pytestmark = [pytest.mark.slow, pytest.mark.diffusion, pytest.mark.omni]
_SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649")
_QWEN_IMAGE = [
pytest.param(
OmniServerParams(model="Qwen/Qwen-Image"),
id="qwen_image",
marks=hardware_marks(res={"cuda": "H100"}),
),
]
# ─────────────────────────────────────────────────────────────────────────────
# POST omni sleep / omni wakeup (invalid JSON)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"endpoint, json_body, err_message",
[
pytest.param("sleep", {"level": 2}, ("stage_ids", "Field required", "Missing"), id="sleep_missing_stage_ids"),
pytest.param(
"sleep",
{"stage_ids": [], "level": 2},
("stage_ids", "Field required", "Missing"),
id="sleep_empty_stage_ids",
marks=_SKIP_ISSUE_3649,
),
pytest.param(
"sleep",
{"stage_ids": "not-a-list", "level": 2},
("stage_ids", "list_type", "a valid list"),
id="sleep_invalid_stage_ids_type",
),
pytest.param(
"sleep",
{"stage_ids": [0], "level": "high"},
("level", "int_parsing", "a valid integer"),
id="sleep_invalid_level_type",
),
pytest.param(
"sleep",
{"stage_ids": [0], "level": -1},
("level", "greater"),
id="sleep_negative_level",
marks=_SKIP_ISSUE_3649,
),
pytest.param("wakeup", {}, ("stage_ids", "missing", "Field required"), id="wakeup_missing_stage_ids"),
pytest.param(
"wakeup",
{"stage_ids": None},
("stage_ids", "list_type", "a valid list"),
id="wakeup_invalid_stage_ids_type",
),
pytest.param(
"wakeup",
{"stage_ids": []},
("stage_ids", "length"),
id="wakeup_empty_stage_ids",
marks=_SKIP_ISSUE_3649,
),
],
)
@pytest.mark.parametrize("omni_server", _QWEN_IMAGE, indirect=True)
def test_omni_sleep_wakeup_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
endpoint: Literal["sleep", "wakeup"],
json_body: dict[str, Any],
err_message: str | tuple[str, ...],
) -> None:
cfg: dict[str, Any] = {
"json": json_body,
"timeout": 120,
"err_code": 400,
"err_message": err_message,
}
if endpoint == "sleep":
openai_client.send_omni_sleep_http_request(cfg)
else:
openai_client.send_omni_wakeup_http_request(cfg)
@@ -0,0 +1,171 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""HTTP video job routes (live ``Wan-AI/Wan2.2-T2V-A14B-Diffusers``).
WebSocket ``/v1/video/chat/stream`` and ``/v1/realtime`` invalid-input checks live in
``test_invalid_omni_chat.py`` with the same Qwen3-Omni server module fixture (Wan cannot serve those paths).
"""
from __future__ import annotations
from typing import Any
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
pytestmark = [pytest.mark.slow, pytest.mark.diffusion]
_WAN_T2V = [
pytest.param(
OmniServerParams(model="Wan-AI/Wan2.2-T2V-A14B-Diffusers"),
id="wan22_t2v",
marks=hardware_marks(res={"cuda": "H100"}),
),
]
def _minimal_video_form_data() -> dict[str, str]:
return {"prompt": "a cat walking on grass"}
# ─────────────────────────────────────────────────────────────────────────────
# POST /v1/videos (async)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"form_extra, err_message",
[
pytest.param(None, ("prompt", "Field required", "Missing"), id="missing_prompt"),
pytest.param({"seconds": "0"}, ("seconds", "string_pattern_mismatch", "1"), id="seconds_zero"),
pytest.param({"size": "bad"}, ("size", "string_pattern_mismatch"), id="size_bad"),
pytest.param(
{"num_inference_steps": "0"},
("num_inference_steps", "greater_than_equal", "1"),
id="num_inference_steps_zero",
),
pytest.param(
{"guidance_scale": "25"}, ("guidance_scale", "less_than_equal", "20"), id="guidance_scale_above_max"
),
],
)
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_post_videos_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
form_extra: dict[str, str] | None,
err_message: str | tuple[str, ...],
) -> None:
data: dict[str, str] = {} if form_extra is None else {**_minimal_video_form_data(), **form_extra}
openai_client.send_videos_create_http_request(
{"data": data, "timeout": 120, "err_code": 400, "err_message": err_message}
)
# ─────────────────────────────────────────────────────────────────────────────
# POST /v1/videos/sync
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"form_extra, err_message",
[
pytest.param(None, ("prompt", "Field required", "Missing"), id="missing_prompt"),
pytest.param({"seconds": "0"}, ("seconds", "string_pattern_mismatch", "1"), id="seconds_zero"),
pytest.param({"size": "bad"}, ("size", "string_pattern_mismatch"), id="size_bad"),
pytest.param(
{"num_inference_steps": "201"},
("num_inference_steps", "less_than_equal", "200"),
id="num_inference_steps_above_max",
),
],
)
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_post_videos_sync_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
form_extra: dict[str, str] | None,
err_message: str | tuple[str, ...],
) -> None:
data: dict[str, str] = {} if form_extra is None else {**_minimal_video_form_data(), **form_extra}
openai_client.send_videos_sync_http_request(
{"data": data, "timeout": 120, "err_code": 400, "err_message": err_message}
)
# ─────────────────────────────────────────────────────────────────────────────
# GET /v1/videos (list)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"params, err_message",
[
pytest.param({"limit": -1}, ("limit", "greater_than_equal", "0"), id="invalid_limit_negative"),
pytest.param({"limit": 101}, ("limit", "less_than_equal", "100"), id="limit_above_max"),
pytest.param({"order": "newest"}, ("order", "literal_error", "asc", "desc"), id="invalid_order"),
],
)
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_get_videos_list_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
params: dict[str, Any],
err_message: str | tuple[str, ...],
) -> None:
openai_client.send_videos_list_http_request(
{"params": params, "timeout": 120, "err_code": 400, "err_message": err_message}
)
# ─────────────────────────────────────────────────────────────────────────────
# GET /v1/videos/{video_id}
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_get_video_invalid_requests(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_video_retrieve_http_request(
{
"video_id": "does-not-exist",
"timeout": 120,
"err_code": 404,
"err_message": ("not found", "video"),
}
)
# ─────────────────────────────────────────────────────────────────────────────
# DELETE /v1/videos/{video_id}
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_delete_video_invalid_requests(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_video_delete_http_request(
{
"video_id": "does-not-exist",
"timeout": 120,
"err_code": 404,
"err_message": ("not found", "video"),
}
)
# ─────────────────────────────────────────────────────────────────────────────
# GET /v1/videos/{video_id}/content
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("omni_server", _WAN_T2V, indirect=True)
def test_get_video_content_invalid_requests(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
openai_client.send_video_content_http_request(
{
"video_id": "does-not-exist",
"timeout": 120,
"err_code": 404,
"err_message": ("not found", "video"),
}
)
@@ -420,3 +420,102 @@ class TestAttentionInitUsesCurrentDiffusionConfig:
assert attn.use_ring is True
assert attn.ring_runner is not None
assert attn.ring_runner.attn_backend_pref == "TORCH_SDPA"
class TestDiffusionKvCacheQuantization:
@staticmethod
def _install_attention_init_stubs(monkeypatch):
class _FakeAttentionImpl:
def __init__(self, **kwargs):
self.kwargs = kwargs
def forward(self, query, key, value, attn_metadata=None):
return query
class _FakeBackend:
@staticmethod
def get_name() -> str:
return "FAKE_BACKEND"
@staticmethod
def get_impl_cls():
return _FakeAttentionImpl
@staticmethod
def supports_kv_cache_dtype(kv_cache_dtype, platform_key) -> bool:
return True
class _FakeRingParallelAttention:
def __init__(self, sp_group, attn_backend_pref=None):
self.sp_group = sp_group
self.attn_backend_pref = attn_backend_pref
monkeypatch.setattr(
layer_mod,
"get_attn_backend_for_role",
lambda role, head_size, attention_config=None, role_category=None: (_FakeBackend, None),
)
monkeypatch.setattr(layer_mod.SDPABackend, "get_impl_cls", staticmethod(lambda: _FakeAttentionImpl))
monkeypatch.setattr(layer_mod, "build_parallel_attention_strategy", lambda **kwargs: object())
monkeypatch.setattr(layer_mod, "get_sp_group", lambda: SimpleNamespace(ring_group="ring-group"))
monkeypatch.setattr(layer_mod, "RingParallelAttention", _FakeRingParallelAttention)
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: False)
def test_diffusion_kv_cache_dtype_none_does_not_trigger_ring_quantization_error(self, monkeypatch):
self._install_attention_init_stubs(monkeypatch)
od_config = SimpleNamespace(
diffusion_attention_config=AttentionConfig(),
parallel_config=SimpleNamespace(ring_degree=2),
diffusion_kv_cache_dtype=None,
diffusion_kv_cache_skip_step_indices=None,
diffusion_kv_cache_skip_layer_indices=None,
)
with set_current_diffusion_config(od_config):
attn = Attention(
num_heads=4,
head_size=64,
causal=False,
softmax_scale=1.0,
)
assert attn._kv_cache_dtype is None
def test_diffusion_kv_cache_dtype_auto_does_not_trigger_ring_quantization_error(self, monkeypatch):
self._install_attention_init_stubs(monkeypatch)
od_config = SimpleNamespace(
diffusion_attention_config=AttentionConfig(),
parallel_config=SimpleNamespace(ring_degree=2),
diffusion_kv_cache_dtype="auto",
diffusion_kv_cache_skip_step_indices=None,
diffusion_kv_cache_skip_layer_indices=None,
)
with set_current_diffusion_config(od_config):
attn = Attention(
num_heads=4,
head_size=64,
causal=False,
softmax_scale=1.0,
)
assert attn._kv_cache_dtype is None
def test_diffusion_kv_cache_dtype_fp8_raises_with_ring_attention(self, monkeypatch):
self._install_attention_init_stubs(monkeypatch)
od_config = SimpleNamespace(
diffusion_attention_config=AttentionConfig(),
parallel_config=SimpleNamespace(ring_degree=2),
diffusion_kv_cache_dtype="fp8",
diffusion_kv_cache_skip_step_indices=None,
diffusion_kv_cache_skip_layer_indices=None,
)
with set_current_diffusion_config(od_config):
with pytest.raises(ValueError, match="KV quantization is not compatible with ring attention"):
Attention(
num_heads=4,
head_size=64,
causal=False,
softmax_scale=1.0,
)
@@ -0,0 +1,79 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_hunyuan import (
DistributedAutoencoderKLHunyuan,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _DummyDistributedAutoencoderKLHunyuan(DistributedAutoencoderKLHunyuan):
def __init__(self):
torch.nn.Module.__init__(self)
self.tile_latent_min_size = 8
self.tile_sample_min_size = 8
self.tile_overlap_factor = 0.25
self.use_spatial_tiling = False
@property
def dtype(self) -> torch.dtype:
return torch.float32
def decoder(self, x: torch.Tensor) -> torch.Tensor:
return x + 10
def encoder(self, x: torch.Tensor) -> torch.Tensor:
return x + 20
def blend_v(self, _a: torch.Tensor, b: torch.Tensor, _blend_extent: int) -> torch.Tensor:
return b
def blend_h(self, _a: torch.Tensor, b: torch.Tensor, _blend_extent: int) -> torch.Tensor:
return b
def test_hunyuan_vae_use_tiling_aliases_spatial_tiling():
# Verify use_tiling property maps to use_spatial_tiling.
vae = _DummyDistributedAutoencoderKLHunyuan()
assert not vae.use_tiling
vae.use_tiling = True
assert vae.use_spatial_tiling
def test_hunyuan_vae_decode_tiles_round_trip():
# Validate decode tile split/exec/merge returns expected reconstructed tensor.
vae = _DummyDistributedAutoencoderKLHunyuan()
z = torch.arange(144, dtype=torch.float32).reshape(1, 1, 1, 12, 12)
tile_tasks, grid_spec = vae.tile_split(z)
decoded_tiles = {task.grid_coord: vae.tile_exec(task) for task in tile_tasks}
output = vae.tile_merge(decoded_tiles, grid_spec)
assert grid_spec.split_dims == (3, 4)
assert grid_spec.grid_shape == (2, 2)
assert grid_spec.tile_spec == {"blend_extent": 2, "row_limit": 6}
assert len(tile_tasks) == 4
assert torch.equal(output, z + 10)
def test_hunyuan_vae_encode_tiles_round_trip():
# Validate encode tile split/exec/merge returns expected latent tensor.
vae = _DummyDistributedAutoencoderKLHunyuan()
x = torch.arange(144, dtype=torch.float32).reshape(1, 1, 1, 12, 12)
tile_tasks, grid_spec = vae.encode_tile_split(x)
encoded_tiles = {task.grid_coord: vae.encode_tile_exec(task) for task in tile_tasks}
output = vae.encode_tile_merge(encoded_tiles, grid_spec)
assert grid_spec.split_dims == (3, 4)
assert grid_spec.grid_shape == (2, 2)
assert grid_spec.tile_spec == {"blend_extent": 2, "row_limit": 6}
assert len(tile_tasks) == 4
assert torch.equal(output, x + 20)
@@ -0,0 +1,802 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
import socket
import pytest
import torch
from vllm.model_executor.models.utils import PPMissingLayer, make_empty_intermediate_tensors_factory, make_layers
from vllm.sequence import IntermediateTensors
from vllm.v1.worker.gpu_worker import AsyncIntermediateTensors
import vllm_omni.diffusion.distributed.pipeline_parallel as pp_module
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.parallel_state import (
destroy_distributed_env,
get_classifier_free_guidance_rank,
get_pp_group,
init_distributed_environment,
initialize_model_parallel,
)
from vllm_omni.diffusion.distributed.pipeline_parallel import AsyncLatents, PipelineParallelMixin
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.parallel]
def _find_free_port() -> str:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return str(s.getsockname()[1])
def update_environment_variables(envs_dict: dict[str, str]) -> None:
for k, v in envs_dict.items():
os.environ[k] = v
# ---------------------------------------------------------------------------
# Shared stubs used by both unit and distributed tests
# ---------------------------------------------------------------------------
class FakeWork:
"""Drop-in for torch.distributed.Work that records whether wait() was called."""
def __init__(self):
self.waited = False
def wait(self):
self.waited = True
class SimpleScheduler:
"""Minimal diffusion-step scheduler: latents -= 0.1 * noise_pred."""
def step(self, noise_pred: torch.Tensor, t, latents: torch.Tensor, return_dict: bool = False):
return (latents - 0.1 * noise_pred,)
class FakeVAE:
def __init__(self, distributed_enabled: bool = False):
self.calls = 0
self.distributed_enabled = distributed_enabled
def decode(self, z: torch.Tensor):
"""Original decode docstring."""
self.calls += 1
return (z + 1,)
def is_distributed_enabled(self) -> bool:
return self.distributed_enabled
class MockPipelineParallel(PipelineParallelMixin, CFGParallelMixin):
"""Minimal pipeline used to exercise PipelineParallelMixin.
Uses vLLM's ``make_layers`` for layer partitioning — the same utility used
by real DiT models — so the PP layer-split logic is exercised faithfully.
Each layer's weights are seeded by ``seed + layer_index`` so that layer ``i``
is initialized identically on every rank regardless of which ranks are active,
allowing the distributed output to be compared against the single-GPU baseline.
Args:
num_layers: Total number of Linear layers.
dim: Input / hidden dimension.
seed: Base RNG seed; layer ``i`` uses ``seed + i``.
device: Target device for layer weights (default: CPU).
dtype: Target dtype for layer weights (default: float32).
"""
def __init__(
self,
num_layers: int = 4,
dim: int = 64,
seed: int = 42,
device: torch.device | None = None,
dtype: torch.dtype = torch.float32,
):
self.start_layer, self.end_layer, self.layers = make_layers(
num_layers,
lambda prefix: torch.nn.Linear(dim, dim, bias=False),
prefix="layers",
)
for i, layer in enumerate(self.layers):
if not isinstance(layer, PPMissingLayer):
torch.manual_seed(seed + i)
torch.nn.init.normal_(layer.weight, mean=0.0, std=0.02)
self.layers.to(device=device, dtype=dtype)
self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(["hidden_states"], dim)
self.scheduler = SimpleScheduler()
def predict_noise(self, x=None, intermediate_tensors=None, **_kwargs) -> torch.Tensor | IntermediateTensors:
"""Layer-split forward pass.
* First PP rank: uses ``x`` from caller kwargs.
* Later PP ranks: overrides ``x`` with ``intermediate_tensors["hidden_states"]``
(which transparently waits for the async receive).
* Non-last PP ranks return ``IntermediateTensors``; the last rank
returns the plain noise-prediction tensor.
"""
if intermediate_tensors is not None:
x = intermediate_tensors["hidden_states"]
for i in range(self.start_layer, self.end_layer):
x = self.layers[i](x)
pp_group = get_pp_group()
if not pp_group.is_last_rank:
return IntermediateTensors({"hidden_states": x})
return x
# ---------------------------------------------------------------------------
# 1. AsyncLatents – unit tests (no distributed env required)
# ---------------------------------------------------------------------------
class TestAsyncLatents:
"""Verifies the lazy-resolution behaviour of AsyncLatents without a real process group."""
pytestmark = [pytest.mark.cpu]
def _make(self, tensor: torch.Tensor, handles: list | None = None, postproc: list | None = None) -> AsyncLatents:
return AsyncLatents({"latents": tensor}, handles or [], postproc or [])
def test_resolve_returns_wrapped_tensor(self):
t = torch.randn(2, 4)
al = self._make(t)
assert al._resolve() is t
def test_attribute_access_resolves(self):
t = torch.randn(2, 4)
al = self._make(t)
assert al.shape == t.shape
assert al.dtype == t.dtype
def test_torch_function_protocol(self):
"""torch ops that receive an AsyncLatents should see the underlying tensor."""
t = torch.randn(2, 4)
al = self._make(t)
mask = torch.ones_like(t)
result = mask * al # triggers __torch_function__
torch.testing.assert_close(result, mask * t)
def test_torch_function_with_list_arg(self):
"""__torch_function__ must unwrap AsyncLatents inside list/tuple args."""
t = torch.randn(2, 4)
al = self._make(t)
result = torch.cat([al, al], dim=0)
torch.testing.assert_close(result, torch.cat([t, t], dim=0))
def test_torch_tensor_conversion(self):
"""torch.as_tensor on an AsyncLatents must share storage with the underlying tensor (no copy)."""
t = torch.randn(2, 4)
al = self._make(t)
result = torch.as_tensor(al)
assert result.data_ptr() == t.data_ptr(), "torch.as_tensor copied the data instead of sharing storage"
def test_handles_are_waited_before_resolve(self):
t = torch.randn(2, 4)
h1, h2 = FakeWork(), FakeWork()
al = self._make(t, handles=[h1, h2])
_ = al.shape # trigger resolution
assert h1.waited and h2.waited, "Not all handles were waited on"
def test_postproc_callbacks_invoked_on_resolve(self):
t = torch.randn(2, 4)
log: list[int] = []
al = self._make(t, postproc=[lambda: log.append(1), lambda: log.append(2)])
_ = al.shape
assert log == [1, 2], f"postproc not called in order: {log}"
def test_idempotent_resolve(self):
"""handle.wait() must not be called twice if _resolve() is called twice."""
t = torch.randn(2, 4)
h = FakeWork()
al = self._make(t, handles=[h])
_ = al.shape # first resolve
h.waited = False # reset sentinel
_ = al.dtype # second resolve
assert not h.waited, "handle.wait() was called a second time"
# ---------------------------------------------------------------------------
# 2. _sync_pp_send / diffuse wrapper – unit tests (no distributed env required)
# ---------------------------------------------------------------------------
class TestSyncPPSend:
"""Verifies PipelineParallelMixin's internal PP-send flush."""
pytestmark = [pytest.mark.cpu]
@staticmethod
def _make_pipeline() -> PipelineParallelMixin:
# Instantiate a bare mixin — no layers, no distributed env needed.
# _sync_pp_send only touches _pp_send_work, so this is sufficient.
class _BarePP(PipelineParallelMixin, CFGParallelMixin):
pass
return _BarePP()
def test_noop_when_work_list_empty(self):
pipeline = self._make_pipeline()
pipeline._sync_pp_send()
assert pipeline._pp_send_work == []
def test_waits_all_pending_handles(self):
pipeline = self._make_pipeline()
works = [FakeWork(), FakeWork(), FakeWork()]
pipeline._pp_send_work = works
pipeline._sync_pp_send()
assert all(w.waited for w in works), "Some handles were not waited on"
def test_clears_work_list_after_sync(self):
pipeline = self._make_pipeline()
pipeline._pp_send_work = [FakeWork()]
pipeline._sync_pp_send()
assert pipeline._pp_send_work == []
class TestDiffuseWrapper:
"""Verifies that PipelineParallelMixin flushes pending sends when diffuse() exits."""
pytestmark = [pytest.mark.cpu]
def test_diffuse_flushes_pending_sends_on_success(self):
work = FakeWork()
class _DiffusePP(PipelineParallelMixin, CFGParallelMixin):
def diffuse(self):
self._pp_send_work = [work]
return "done"
pipeline = _DiffusePP()
assert pipeline.diffuse() == "done"
assert work.waited
assert pipeline._pp_send_work == []
def test_diffuse_flushes_pending_sends_on_exception(self):
work = FakeWork()
class _DiffusePP(PipelineParallelMixin, CFGParallelMixin):
def diffuse(self):
self._pp_send_work = [work]
raise RuntimeError("boom")
pipeline = _DiffusePP()
with pytest.raises(RuntimeError, match="boom"):
pipeline.diffuse()
assert work.waited
assert pipeline._pp_send_work == []
def test_diffuse_wrapper_preserves_metadata(self):
class _DiffusePP(PipelineParallelMixin, CFGParallelMixin):
def diffuse(self):
"""Original diffuse docstring."""
return "done"
assert _DiffusePP.diffuse.__name__ == "diffuse"
assert _DiffusePP.diffuse.__doc__ == "Original diffuse docstring."
class TestVaeDecodeGuard:
pytestmark = [pytest.mark.cpu]
@staticmethod
def _make_pipeline(distributed_enabled: bool = False) -> PipelineParallelMixin:
class _DecodePP(PipelineParallelMixin, CFGParallelMixin):
def __init__(self):
self.vae = FakeVAE(distributed_enabled=distributed_enabled)
return _DecodePP()
@staticmethod
def _set_rank(monkeypatch, world_size: int, first_stage: bool) -> None:
monkeypatch.setattr(pp_module, "get_pipeline_parallel_world_size", lambda: world_size)
monkeypatch.setattr(pp_module, "is_pipeline_first_stage", lambda: first_stage)
def test_calls_original_decode_when_pp_disabled(self, monkeypatch):
self._set_rank(monkeypatch, world_size=1, first_stage=True)
pipeline = self._make_pipeline()
z = torch.ones(2, 3)
output = pipeline.vae.decode(z)[0]
assert pipeline.vae.calls == 1
torch.testing.assert_close(output, z + 1)
def test_calls_original_decode_on_first_stage(self, monkeypatch):
self._set_rank(monkeypatch, world_size=2, first_stage=True)
pipeline = self._make_pipeline()
z = torch.ones(2, 3)
output = pipeline.vae.decode(z)[0]
assert pipeline.vae.calls == 1
torch.testing.assert_close(output, z + 1)
def test_skips_decode_on_non_first_stage(self, monkeypatch):
self._set_rank(monkeypatch, world_size=2, first_stage=False)
pipeline = self._make_pipeline()
z = torch.ones(2, 3)
output = pipeline.vae.decode(z)
assert pipeline.vae.calls == 0
assert output == (None,)
def test_calls_original_decode_when_distributed_vae_enabled(self, monkeypatch):
self._set_rank(monkeypatch, world_size=2, first_stage=False)
pipeline = self._make_pipeline(distributed_enabled=True)
z = torch.ones(2, 3)
output = pipeline.vae.decode(z)[0]
assert pipeline.vae.calls == 1
torch.testing.assert_close(output, z + 1)
def test_decode_wrapper_preserves_metadata(self):
pipeline = self._make_pipeline()
assert pipeline.vae.decode.__name__ == "decode"
assert pipeline.vae.decode.__doc__ == "Original decode docstring."
@pytest.mark.cpu
def test_pipeline_parallel_requires_cfg_mixin():
with pytest.raises(TypeError, match="inherits PipelineParallelMixin but not CFGParallelMixin"):
class _MissingCFG(PipelineParallelMixin):
pass
@pytest.mark.cpu
def test_pipeline_parallel_requires_mro_before_cfg_mixin():
with pytest.raises(TypeError, match="must inherit PipelineParallelMixin before CFGParallelMixin"):
class _WrongOrder(CFGParallelMixin, PipelineParallelMixin):
pass
# ---------------------------------------------------------------------------
# Distributed test helpers
# ---------------------------------------------------------------------------
def init_dist(local_rank: int, world_size: int, master_port: str) -> torch.device:
"""Initialise the distributed environment for a spawned worker."""
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
current_omni_platform.set_device(device)
update_environment_variables(
{
"RANK": str(local_rank),
"LOCAL_RANK": str(local_rank),
"WORLD_SIZE": str(world_size),
"MASTER_ADDR": "localhost",
"MASTER_PORT": master_port,
}
)
init_distributed_environment()
return device
def make_pipeline_and_inputs(
test_config: dict, dtype: torch.dtype, device: torch.device, do_true_cfg: bool = False
) -> tuple["MockPipelineParallel", dict, dict | None]:
"""Create a MockPipelineParallel and seeded inputs from a test_config dict.
Must be called after ``initialize_model_parallel`` so that ``make_layers``
can read the PP group to determine this rank's layer slice.
Returns ``(pipeline, positive_kwargs, negative_kwargs)``.
``negative_kwargs`` is ``None`` when ``do_true_cfg=False``.
"""
pipeline = MockPipelineParallel(
num_layers=test_config["num_layers"],
dim=test_config["dim"],
seed=test_config["model_seed"],
device=device,
dtype=dtype,
)
torch.manual_seed(test_config["input_seed"])
if torch.cuda.is_available():
torch.cuda.manual_seed_all(test_config["input_seed"])
pos_x = {"x": torch.randn(test_config["batch_size"], test_config["dim"], dtype=dtype, device=device)}
negative_kwargs = None
if do_true_cfg:
torch.manual_seed(test_config["input_seed"] + 1)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(test_config["input_seed"] + 1)
neg_x = torch.randn(test_config["batch_size"], test_config["dim"], dtype=dtype, device=device)
negative_kwargs = {"x": neg_x}
return pipeline, pos_x, negative_kwargs
# ---------------------------------------------------------------------------
# 3. isend_tensor_dict / irecv_tensor_dict (2 GPUs)
# ---------------------------------------------------------------------------
def isend_irecv_worker(local_rank: int, world_size: int, master_port: str, result_queue):
device = init_dist(local_rank, world_size, master_port)
initialize_model_parallel(pipeline_parallel_size=world_size)
pp_group = get_pp_group()
if pp_group.is_first_rank:
torch.manual_seed(77)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(77)
tensor = torch.randn(3, 5, dtype=torch.float32, device=device)
handles = pp_group.isend_tensor_dict({"t": tensor})
for h in handles:
h.wait()
result_queue.put(("sent", tensor.cpu()))
else:
received = AsyncIntermediateTensors(*pp_group.irecv_tensor_dict())
result_queue.put(("received", received["t"].cpu()))
if torch.distributed.is_initialized():
torch.distributed.barrier()
destroy_distributed_env()
@pytest.mark.gpu
@pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs")
@pytest.mark.parametrize("pp_size", [2])
def test_isend_irecv_tensor_dict(pp_size: int):
"""isend_tensor_dict / irecv_tensor_dict transfer a tensor dict without loss."""
mp_context = torch.multiprocessing.get_context("spawn")
manager = mp_context.Manager()
q = manager.Queue()
port = _find_free_port()
torch.multiprocessing.spawn(isend_irecv_worker, args=(pp_size, port, q), nprocs=pp_size)
results = {label: tensor for label, tensor in [q.get(), q.get()]}
torch.testing.assert_close(
results["received"], results["sent"], rtol=0, atol=0, msg="isend/irecv transferred tensor incorrectly"
)
# ---------------------------------------------------------------------------
# 4. predict_noise_maybe_with_cfg
# ---------------------------------------------------------------------------
_baseline_cache: dict[tuple, torch.Tensor] = {}
def compute_single_gpu_baseline(test_config: dict, dtype: torch.dtype, do_true_cfg: bool) -> torch.Tensor:
"""Compute expected single-GPU output using the same MockPipelineParallel.
Initializes a trivial distributed env (world_size=1) so that ``make_layers`` and the PP/CFG mixins work normally.
Results are cached so identical configs are only computed once.
"""
key = (
test_config["num_layers"],
test_config["dim"],
test_config["batch_size"],
test_config["model_seed"],
test_config["input_seed"],
test_config["cfg_scale"],
dtype,
do_true_cfg,
)
if key in _baseline_cache:
return _baseline_cache[key]
device = init_dist(0, 1, _find_free_port())
initialize_model_parallel(pipeline_parallel_size=1)
pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs(
test_config, dtype, device, do_true_cfg=do_true_cfg
)
with torch.inference_mode():
noise_pred = pipeline.predict_noise_maybe_with_cfg(
do_true_cfg=do_true_cfg,
true_cfg_scale=test_config["cfg_scale"],
positive_kwargs=positive_kwargs,
negative_kwargs=negative_kwargs,
cfg_normalize=False,
)
destroy_distributed_env()
_baseline_cache[key] = noise_pred.cpu()
return _baseline_cache[key]
def predict_noise_worker(
local_rank: int,
world_size: int,
master_port: str,
pp_size: int,
cfg_size: int,
do_true_cfg: bool,
dtype: torch.dtype,
test_config: dict,
result_queue,
):
"""Generic predict-noise worker parameterized by PP and CFG topology."""
device = init_dist(local_rank, world_size, master_port)
initialize_model_parallel(pipeline_parallel_size=pp_size, cfg_parallel_size=cfg_size)
pp_group = get_pp_group()
cfg_rank = get_classifier_free_guidance_rank()
pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs(
test_config, dtype, device, do_true_cfg=do_true_cfg
)
with torch.inference_mode():
noise_pred = pipeline.predict_noise_maybe_with_cfg(
do_true_cfg=do_true_cfg,
true_cfg_scale=test_config["cfg_scale"],
positive_kwargs=positive_kwargs,
negative_kwargs=negative_kwargs,
cfg_normalize=False,
)
# This worker exercises predict_noise_maybe_with_cfg directly, bypassing diffuse().
# Flush the non-last PP rank's async send before barrier / process teardown.
pipeline._sync_pp_send()
if pp_group.is_last_rank:
assert noise_pred is not None
if cfg_rank == 0:
result_queue.put(noise_pred.cpu())
else:
assert noise_pred is None
if torch.distributed.is_initialized():
torch.distributed.barrier()
destroy_distributed_env()
@pytest.mark.gpu
@pytest.mark.parametrize(
"pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol",
[
pytest.param(
2,
1,
False,
torch.float32,
4,
100,
1e-5,
1e-5,
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"),
id="pp2-no_cfg-float32",
),
pytest.param(
2,
1,
False,
torch.bfloat16,
4,
100,
1e-2,
1e-2,
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"),
id="pp2-no_cfg-bfloat16",
),
pytest.param(
2,
1,
True,
torch.bfloat16,
4,
100,
1e-2,
1e-2,
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"),
id="pp2-seq_cfg-bfloat16",
),
pytest.param(
2,
2,
True,
torch.bfloat16,
4,
100,
1e-2,
1e-2,
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 4, reason="Need at least 4 GPUs"),
id="pp2-cfg2-bfloat16",
),
pytest.param(
3,
1,
False,
torch.bfloat16,
6,
100,
1e-2,
1e-2,
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 3, reason="Need at least 3 GPUs"),
id="pp3-no_cfg-bfloat16",
),
],
)
def test_predict_noise(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol):
"""predict_noise_maybe_with_cfg output matches the single-GPU baseline across PP / CFG topologies."""
test_config = {
"num_layers": num_layers,
"dim": 64,
"batch_size": 2,
"cfg_scale": 7.5,
"model_seed": 42,
"input_seed": input_seed,
}
baseline_out = compute_single_gpu_baseline(test_config, dtype, do_true_cfg)
mp_context = torch.multiprocessing.get_context("spawn")
manager = mp_context.Manager()
pp_q = manager.Queue()
world_size = pp_size * cfg_size
port = _find_free_port()
torch.multiprocessing.spawn(
predict_noise_worker,
args=(world_size, port, pp_size, cfg_size, do_true_cfg, dtype, test_config, pp_q),
nprocs=world_size,
)
pp_out = pp_q.get()
assert baseline_out.shape == pp_out.shape
torch.testing.assert_close(
pp_out,
baseline_out,
rtol=rtol,
atol=atol,
msg=f"PP={pp_size} cfg={cfg_size} {'with' if do_true_cfg else 'no'} CFG output differs from baseline ({dtype=})",
)
# ---------------------------------------------------------------------------
# 5. scheduler_step_maybe_with_cfg
# ---------------------------------------------------------------------------
def compute_scheduler_step_baseline(test_config: dict, do_true_cfg: bool) -> torch.Tensor:
"""Single-GPU reference: predict_noise + scheduler_step."""
device = init_dist(0, 1, _find_free_port())
initialize_model_parallel(pipeline_parallel_size=1)
pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs(
test_config, torch.float32, device, do_true_cfg=do_true_cfg
)
latents = positive_kwargs["x"]
t = torch.tensor(500, device=device)
with torch.inference_mode():
noise_pred = pipeline.predict_noise_maybe_with_cfg(
do_true_cfg=do_true_cfg,
true_cfg_scale=test_config["cfg_scale"],
positive_kwargs=positive_kwargs,
negative_kwargs=negative_kwargs,
cfg_normalize=False,
)
result = pipeline.scheduler_step_maybe_with_cfg(
noise_pred=noise_pred, t=t, latents=latents, do_true_cfg=do_true_cfg
)
destroy_distributed_env()
return result.cpu()
def scheduler_step_worker(
local_rank: int,
world_size: int,
master_port: str,
pp_size: int,
cfg_size: int,
do_true_cfg: bool,
test_config: dict,
result_queue,
):
device = init_dist(local_rank, world_size, master_port)
initialize_model_parallel(pipeline_parallel_size=pp_size, cfg_parallel_size=cfg_size)
pp_group = get_pp_group()
cfg_rank = get_classifier_free_guidance_rank()
pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs(
test_config, torch.float32, device, do_true_cfg=do_true_cfg
)
latents = positive_kwargs["x"]
t = torch.tensor(500, device=device)
with torch.inference_mode():
noise_pred = pipeline.predict_noise_maybe_with_cfg(
do_true_cfg=do_true_cfg,
true_cfg_scale=test_config["cfg_scale"],
positive_kwargs=positive_kwargs,
negative_kwargs=negative_kwargs,
cfg_normalize=False,
)
latents = pipeline.scheduler_step_maybe_with_cfg(
noise_pred=noise_pred, t=t, latents=latents, do_true_cfg=do_true_cfg
)
# This worker exercises scheduler_step_maybe_with_cfg directly, bypassing diffuse().
# Flush the last PP rank's async latent send before barrier / process teardown.
pipeline._sync_pp_send()
if pp_group.is_first_rank and cfg_rank == 0:
resolved = latents.contiguous()
result_queue.put(resolved.cpu())
if torch.distributed.is_initialized():
torch.distributed.barrier()
destroy_distributed_env()
@pytest.mark.gpu
@pytest.mark.parametrize(
"pp_size, cfg_size, do_true_cfg, input_seed",
[
pytest.param(
2,
1,
False,
300,
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"),
id="pp2-no_cfg",
),
pytest.param(
2,
2,
True,
600,
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 4, reason="Need at least 4 GPUs"),
id="pp2-cfg2-true_cfg",
),
],
)
def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed):
"""Rank 0 latents after scheduler_step match the single-GPU baseline across PP / CFG topologies."""
test_config = {
"num_layers": 4,
"dim": 64,
"batch_size": 2,
"cfg_scale": 7.5,
"model_seed": 42,
"input_seed": input_seed,
}
baseline = compute_scheduler_step_baseline(test_config, do_true_cfg)
mp_context = torch.multiprocessing.get_context("spawn")
manager = mp_context.Manager()
q = manager.Queue()
port = _find_free_port()
world_size = pp_size * cfg_size
torch.multiprocessing.spawn(
scheduler_step_worker,
args=(world_size, port, pp_size, cfg_size, do_true_cfg, test_config, q),
nprocs=world_size,
)
result = q.get()
torch.testing.assert_close(
result,
baseline,
rtol=0,
atol=0,
msg=f"PP={pp_size} CFG={cfg_size} scheduler step latents on rank 0 do not match single-GPU baseline",
)
@@ -0,0 +1,119 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from unittest.mock import MagicMock
import pytest
from vllm_omni.diffusion.models.flux2_klein.pipeline_flux2_klein import (
Flux2KleinPipeline,
)
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class _StopAfterCheckInputsError(Exception):
pass
def _make_pipeline():
pipeline = object.__new__(Flux2KleinPipeline)
pipeline.vae_scale_factor = 8
pipeline.is_distilled = True
pipeline._guidance_scale = 0.0
return pipeline
def _make_minimal_request(
prompt="valid prompt",
*,
num_inference_steps=None,
):
"""Build a minimal OmniDiffusionRequest-like object that forward() reads from."""
params = OmniDiffusionSamplingParams(
height=512,
width=512,
num_inference_steps=num_inference_steps,
seed=42,
)
req = MagicMock()
req.sampling_params = params
req.prompts = [prompt]
req.multi_modal_data = {}
return req
def _capture_num_inference_steps(pipe, steps):
"""Run pipe.forward() and capture the num_inference_steps passed to check_inputs."""
captured = {}
def fake_check_inputs(**kwargs):
captured["num_inference_steps"] = kwargs.get("num_inference_steps")
raise _StopAfterCheckInputsError
original = pipe.check_inputs
pipe.check_inputs = fake_check_inputs
try:
req = _make_minimal_request(num_inference_steps=steps)
pipe.forward(req)
except _StopAfterCheckInputsError:
pass
finally:
pipe.check_inputs = original
return captured.get("num_inference_steps")
# --- Forward resolution tests (the actual regression) ---
def test_forward_preserves_zero_not_default():
"""#3703: num_inference_steps=0 must reach validation as 0, not as default 50."""
pipe = _make_pipeline()
captured = _capture_num_inference_steps(pipe, 0)
assert captured == 0, f"Expected 0, got {captured}"
def test_forward_preserves_negative():
pipe = _make_pipeline()
captured = _capture_num_inference_steps(pipe, -1)
assert captured == -1
def test_forward_none_uses_default():
"""None means unset — forward() should substitute the pipeline default."""
pipe = _make_pipeline()
captured = _capture_num_inference_steps(pipe, None)
assert captured == 50
def test_forward_preserves_positive():
pipe = _make_pipeline()
captured = _capture_num_inference_steps(pipe, 9)
assert captured == 9
# --- check_inputs validation tests ---
@pytest.mark.parametrize("steps", [0, -1])
def test_check_inputs_rejects_non_positive(steps):
pipe = _make_pipeline()
with pytest.raises(ValueError):
pipe.check_inputs(
prompt="valid prompt",
height=512,
width=512,
num_inference_steps=steps,
)
def test_check_inputs_accepts_none():
pipe = _make_pipeline()
pipe.check_inputs(prompt="valid prompt", height=512, width=512, num_inference_steps=None)
@pytest.mark.parametrize("steps", [1, 2])
def test_check_inputs_accepts_positive(steps):
pipe = _make_pipeline()
pipe.check_inputs(prompt="valid prompt", height=512, width=512, num_inference_steps=steps)
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
from vllm_omni.diffusion.models.flux2_klein.pipeline_flux2_klein import (
Flux2KleinPipeline,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _make_pipeline():
pipeline = object.__new__(Flux2KleinPipeline)
pipeline.vae_scale_factor = 8
pipeline.is_distilled = True
pipeline._guidance_scale = 0.0
return pipeline
def _check(pipeline, prompt, prompt_embeds=None):
pipeline.check_inputs(
prompt=prompt,
height=512,
width=512,
prompt_embeds=prompt_embeds,
num_inference_steps=4,
guidance_scale=0.0,
)
@pytest.mark.parametrize(
"prompt",
[
"",
" ",
],
)
def test_rejects_empty_or_whitespace_prompt(prompt):
pipe = _make_pipeline()
with pytest.raises(ValueError, match="empty or whitespace-only"):
_check(pipe, prompt)
@pytest.mark.parametrize(
"prompt",
[
["valid prompt", ""],
["valid prompt", " "],
[" "],
],
)
def test_rejects_list_with_empty_or_whitespace_element(prompt):
pipe = _make_pipeline()
with pytest.raises(ValueError, match="empty or whitespace-only"):
_check(pipe, prompt)
@pytest.mark.parametrize(
"prompt",
[
"valid prompt",
["valid prompt", "another valid prompt"],
],
)
def test_accepts_valid_prompts(prompt):
pipe = _make_pipeline()
_check(pipe, prompt)
def test_rejects_none_without_embeds():
pipe = _make_pipeline()
with pytest.raises(ValueError):
_check(pipe, None)
def test_accepts_none_with_prompt_embeds():
pipe = _make_pipeline()
_check(pipe, None, prompt_embeds=torch.randn(1, 256, 4096))
@@ -0,0 +1,383 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for transformer quant-config auto-detection.
The loader path at pipeline_wan2_2.py carries two quantization contracts:
create_transformer_from_config (~L137)
- Reads quantization_config from config.json (injected by the merge scripts)
- Auto-detects the quant method when no CLI quant_config is provided
- Rejects method mismatches (CLI vs disk)
- Upgrades online → offline when disk marks is_checkpoint_*_serialized=True
- Rebuilds when the active ignored_layers differs from the disk value
Wan22Pipeline._create_transformer (~L456)
- Passes od_config.quantization_config (set by CLI or externally) to
create_transformer_from_config for each transformer independently.
- When od_config.quantization_config is None, each transformer auto-detects
from its own config.json; od_config is NOT modified by this call.
All tests are pure-CPU and do not load model weights.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 as wan22_module
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import create_transformer_from_config
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
# Minimum config that create_transformer_from_config accepts without raising.
_MIN_CFG: dict = {"patch_size": [1, 2, 2]}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_fake_transformer():
"""Return (FakeTransformer class, captured list).
Each _FakeTransformer.__init__ call appends its **kwargs to captured,
letting tests inspect what quant_config was passed per transformer.
"""
captured: list[dict] = []
class _FakeTransformer:
def __init__(self, **kwargs):
captured.append(kwargs)
return _FakeTransformer, captured
class _FakePipeline:
"""Minimal stand-in exposing only what _create_transformer needs from self."""
def __init__(self, od_config: SimpleNamespace) -> None:
self.od_config = od_config
# Bind the real unbound method so the tests exercise production code.
_create_transformer = wan22_module.Wan22Pipeline._create_transformer
# ---------------------------------------------------------------------------
# create_transformer_from_config — auto-detection
# ---------------------------------------------------------------------------
def test_create_transformer_detects_mxfp8_serialized_from_config_json(monkeypatch):
"""When config.json carries MXFP8 quant and no CLI quant_config is provided,
the transformer must receive a DiffusionMXFP8Config with serialized=True."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
create_transformer_from_config(config)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP8Config)
assert qc.is_checkpoint_mxfp8_serialized is True
def test_create_transformer_detects_mxfp4_dualscale_from_config_json(monkeypatch):
"""config.json with mxfp4_dualscale + ignored_layers must produce
a DiffusionMXFP4DualScaleMixedConfig with the correct ignored_layers and
serialized flag."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q", "blocks.0.attn1.to_k"],
"is_checkpoint_serialized": True,
},
}
create_transformer_from_config(config)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP4DualScaleMixedConfig)
assert set(qc.ignored_layers) == {"blocks.0.attn1.to_q", "blocks.0.attn1.to_k"}
assert qc.is_checkpoint_serialized is True
def test_create_transformer_without_quantization_config_passes_no_quant(monkeypatch):
"""A plain BF16 config.json (no quantization_config key) must result in no
quant_config being passed to WanTransformer3DModel."""
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
create_transformer_from_config(_MIN_CFG)
assert "quant_config" not in captured[0]
# ---------------------------------------------------------------------------
# create_transformer_from_config — method-mismatch guard
# ---------------------------------------------------------------------------
def test_create_transformer_rejects_method_mismatch(monkeypatch):
"""Passing a CLI quant_config whose get_name() differs from the config.json
quant_method must raise ValueError immediately (prevents silent weight corruption).
fp8 (vLLM built-in, get_name()=='fp8') vs disk 'mxfp8' triggers the guard.
These are distinct methods; using the same method for both would not trigger it.
"""
from vllm.model_executor.layers.quantization.fp8 import Fp8Config
FakeTransformer, _ = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
fp8_cli = Fp8Config(is_checkpoint_fp8_serialized=True, activation_scheme="dynamic")
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
with pytest.raises(ValueError, match="quant_method"):
create_transformer_from_config(config, quant_config=fp8_cli)
# ---------------------------------------------------------------------------
# create_transformer_from_config — online → offline upgrade
# ---------------------------------------------------------------------------
def test_create_transformer_upgrades_to_serialized_when_disk_marks_it(monkeypatch):
"""CLI passes online (is_checkpoint_mxfp8_serialized=False) but config.json
marks is_checkpoint_mxfp8_serialized=True → must switch to offline (serialized)
so that pre-quantized FP8 tensors are loaded correctly."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
online_cli = DiffusionMXFP8Config(is_checkpoint_mxfp8_serialized=False)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
create_transformer_from_config(config, quant_config=online_cli)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP8Config)
assert qc.is_checkpoint_mxfp8_serialized is True
# ---------------------------------------------------------------------------
# create_transformer_from_config — ignored_layers rebuild
# ---------------------------------------------------------------------------
def test_create_transformer_rebuilds_when_ignored_layers_differ(monkeypatch):
"""When the active quant_config has different ignored_layers than config.json,
the config must be rebuilt from disk so per-layer BF16 routing is authoritative
for this specific transformer."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
stale = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=True,
ignored_layers=["blocks.0.attn1.to_q"],
)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q", "blocks.1.attn1.to_q"],
"is_checkpoint_serialized": True,
},
}
create_transformer_from_config(config, quant_config=stale)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP4DualScaleMixedConfig)
assert set(qc.ignored_layers) == {"blocks.0.attn1.to_q", "blocks.1.attn1.to_q"}
def test_create_transformer_does_not_rebuild_when_ignored_layers_match(monkeypatch):
"""When the active quant_config already has the same ignored_layers,
the same instance must be passed through unchanged (no unnecessary rebuild)."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
matching = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=True,
ignored_layers=["blocks.0.attn1.to_q"],
)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q"],
"is_checkpoint_serialized": True,
},
}
create_transformer_from_config(config, quant_config=matching)
assert captured[0].get("quant_config") is matching
# ---------------------------------------------------------------------------
# Wan22Pipeline._create_transformer — od_config passthrough
# ---------------------------------------------------------------------------
def test_pipeline_create_transformer_auto_detects_from_config_json(monkeypatch):
"""When od_config.quantization_config is None, _create_transformer must
auto-detect the quant method from config.json and pass it to the transformer.
od_config itself must remain unchanged (None)."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
od_config = SimpleNamespace(quantization_config=None)
pipeline = _FakePipeline(od_config)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
pipeline._create_transformer(config)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP8Config)
assert qc.is_checkpoint_mxfp8_serialized is True
# od_config must NOT be modified — each transformer auto-detects independently.
assert od_config.quantization_config is None
def test_pipeline_create_transformer_does_not_overwrite_existing_od_config(monkeypatch):
"""If od_config.quantization_config is already set (e.g. via CLI), _create_transformer
must pass it through to the transformer unchanged and leave od_config unmodified."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, _ = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
existing = DiffusionMXFP8Config(is_checkpoint_mxfp8_serialized=True)
od_config = SimpleNamespace(quantization_config=existing)
pipeline = _FakePipeline(od_config)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
pipeline._create_transformer(config)
assert od_config.quantization_config is existing
# ---------------------------------------------------------------------------
# Wan22Pipeline._create_transformer — cascade contracts
# ---------------------------------------------------------------------------
def test_pipeline_cascade_both_transformers_get_mxfp8_serialized_config(monkeypatch):
"""Cascade model (transformer + transformer_2) with MXFP8 checkpoint:
- Each transformer auto-detects from its own config.json independently.
Both must receive is_checkpoint_mxfp8_serialized=True."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
od_config = SimpleNamespace(quantization_config=None)
pipeline = _FakePipeline(od_config)
mxfp8_qc = {"quant_method": "mxfp8", "is_checkpoint_mxfp8_serialized": True}
pipeline._create_transformer({**_MIN_CFG, "quantization_config": mxfp8_qc})
pipeline._create_transformer({**_MIN_CFG, "quantization_config": mxfp8_qc})
assert len(captured) == 2
for i, kwargs in enumerate(captured):
qc = kwargs.get("quant_config")
assert isinstance(qc, DiffusionMXFP8Config), f"transformer[{i}]: expected DiffusionMXFP8Config, got {type(qc)}"
assert qc.is_checkpoint_mxfp8_serialized is True, f"transformer[{i}]: expected serialized=True"
def test_pipeline_cascade_mxfp4_dualscale_each_transformer_gets_correct_ignored_layers(monkeypatch):
"""Cascade with mxfp4_dualscale where transformer and transformer_2 have
different ignored_layers in their config.json.
Expected outcome:
transformer → ignored_layers=["blocks.0.attn1.to_q"] (auto-detected from its config.json)
transformer_2 → ignored_layers=["blocks.0.attn1.to_q", "blocks.1.attn1.to_q"]
(auto-detected from its own config.json independently)
od_config → quantization_config remains None (not modified by _create_transformer)
"""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
od_config = SimpleNamespace(quantization_config=None)
pipeline = _FakePipeline(od_config)
cfg1 = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q"],
"is_checkpoint_serialized": True,
},
}
cfg2 = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q", "blocks.1.attn1.to_q"],
"is_checkpoint_serialized": True,
},
}
pipeline._create_transformer(cfg1)
pipeline._create_transformer(cfg2)
assert len(captured) == 2
qc1 = captured[0].get("quant_config")
qc2 = captured[1].get("quant_config")
assert isinstance(qc1, DiffusionMXFP4DualScaleMixedConfig)
assert isinstance(qc2, DiffusionMXFP4DualScaleMixedConfig)
assert set(qc1.ignored_layers) == {"blocks.0.attn1.to_q"}, f"transformer expected 1 layer, got {qc1.ignored_layers}"
assert set(qc2.ignored_layers) == {
"blocks.0.attn1.to_q",
"blocks.1.attn1.to_q",
}, f"transformer_2 expected 2 layers, got {qc2.ignored_layers}"
# od_config must remain unchanged — _create_transformer does not modify it.
assert od_config.quantization_config is None
@@ -0,0 +1,301 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for Wan2.2 quant_config propagation through transformer creation.
Tests cover:
- create_transformer_from_config passes quant_config and prefix
- create_vace_transformer_from_config passes quant_config and prefix
- set_tf_model_config propagates quant_config to OmniDiffusionConfig
- patch_wan_rms_norm safely iterates sys.modules with concurrent modifications
- I2V transformer_2 quant_config is built from config dict
"""
import sys
from types import SimpleNamespace
import pytest
from pytest_mock import MockerFixture
import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 as wan22_module
import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace as wan22_vace_module
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
create_transformer_from_config,
)
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace import (
create_vace_transformer_from_config,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
# ---------------------------------------------------------------------------
# create_transformer_from_config: quant_config / prefix forwarding
# ---------------------------------------------------------------------------
class TestCreateTransformerQuant:
"""Verify quant_config and prefix are forwarded to WanTransformer3DModel."""
def test_quant_config_passed_through(self, mocker: MockerFixture):
captured = {}
class FakeTransformer:
def __init__(self, **kwargs):
captured.update(kwargs)
mocker.patch.object(wan22_module, "WanTransformer3DModel", FakeTransformer)
fake_qc = mocker.MagicMock()
create_transformer_from_config(
{"patch_size": [1, 2, 2], "num_layers": 2},
quant_config=fake_qc,
)
assert captured.get("quant_config") is fake_qc
def test_prefix_passed_through(self, mocker: MockerFixture):
captured = {}
class FakeTransformer:
def __init__(self, **kwargs):
captured.update(kwargs)
mocker.patch.object(wan22_module, "WanTransformer3DModel", FakeTransformer)
create_transformer_from_config(
{"patch_size": [1, 2, 2]},
prefix="model.transformer.",
)
assert captured.get("prefix") == "model.transformer."
def test_quant_config_none_by_default(self, mocker: MockerFixture):
captured = {}
class FakeTransformer:
def __init__(self, **kwargs):
captured.update(kwargs)
mocker.patch.object(wan22_module, "WanTransformer3DModel", FakeTransformer)
create_transformer_from_config({"patch_size": [1, 2, 2]})
# When quant_config is None and prefix is "", they are not added
assert "quant_config" not in captured or captured["quant_config"] is None
def test_quant_config_and_prefix_together(self, mocker: MockerFixture):
captured = {}
class FakeTransformer:
def __init__(self, **kwargs):
captured.update(kwargs)
mocker.patch.object(wan22_module, "WanTransformer3DModel", FakeTransformer)
fake_qc = mocker.MagicMock()
create_transformer_from_config(
{"patch_size": [1, 2, 2], "num_attention_heads": 4},
quant_config=fake_qc,
prefix="blocks.",
)
assert captured["quant_config"] is fake_qc
assert captured["prefix"] == "blocks."
# ---------------------------------------------------------------------------
# create_vace_transformer_from_config: quant_config / prefix forwarding
# ---------------------------------------------------------------------------
class TestCreateVaceTransformerQuant:
"""Verify quant_config and prefix are forwarded to WanVACETransformer3DModel."""
def test_quant_config_passed_through(self, mocker: MockerFixture):
captured = {}
class FakeVACETransformer:
def __init__(self, **kwargs):
captured.update(kwargs)
mocker.patch.object(wan22_vace_module, "WanVACETransformer3DModel", FakeVACETransformer)
fake_qc = mocker.MagicMock()
create_vace_transformer_from_config(
{"patch_size": [1, 2, 2], "num_layers": 2},
quant_config=fake_qc,
)
assert captured.get("quant_config") is fake_qc
def test_prefix_passed_through(self, mocker: MockerFixture):
captured = {}
class FakeVACETransformer:
def __init__(self, **kwargs):
captured.update(kwargs)
mocker.patch.object(wan22_vace_module, "WanVACETransformer3DModel", FakeVACETransformer)
create_vace_transformer_from_config(
{"patch_size": [1, 2, 2]},
prefix="vace.",
)
assert captured.get("prefix") == "vace."
# ---------------------------------------------------------------------------
# set_tf_model_config: propagation of quant_config
# ---------------------------------------------------------------------------
class TestSetTfModelConfig:
"""Test that set_tf_model_config propagates quant_config correctly."""
def _make_od_config(self):
"""Create a minimal OmniDiffusionConfig-like object for testing."""
from vllm_omni.diffusion.data import OmniDiffusionConfig
cfg = object.__new__(OmniDiffusionConfig)
cfg.quantization_config = None
cfg.tf_model_config = None
return cfg
def test_propagates_quant_config_when_none(self, mocker: MockerFixture):
cfg = self._make_od_config()
fake_qc = mocker.MagicMock()
tf_config = SimpleNamespace(quant_config=fake_qc, quant_method="auto-round")
cfg.set_tf_model_config(tf_config)
assert cfg.tf_model_config is tf_config
assert cfg.quantization_config is fake_qc
def test_does_not_overwrite_existing_quantization_config(self, mocker: MockerFixture):
cfg = self._make_od_config()
existing_qc = mocker.MagicMock()
cfg.quantization_config = existing_qc
tf_config = SimpleNamespace(quant_config=mocker.MagicMock())
cfg.set_tf_model_config(tf_config)
assert cfg.tf_model_config is tf_config
assert cfg.quantization_config is existing_qc # not overwritten
def test_no_propagation_when_tf_quant_config_is_none(self, mocker: MockerFixture):
cfg = self._make_od_config()
tf_config = SimpleNamespace(quant_config=None)
cfg.set_tf_model_config(tf_config)
assert cfg.tf_model_config is tf_config
assert cfg.quantization_config is None
# ---------------------------------------------------------------------------
# patch_wan_rms_norm: sys.modules snapshot safety
# ---------------------------------------------------------------------------
class TestPatchWanRmsNorm:
"""Test that patch_wan_rms_norm doesn't raise on concurrent module registration."""
def test_patches_modules_with_wan_rms_norm(self):
from vllm_omni.diffusion.layers.norm import RMSNormVAE
from vllm_omni.diffusion.models.wan2_2.patch_diffusers import patch_wan_rms_norm
# Create a fake module that has WanRMS_norm
fake_module = SimpleNamespace(WanRMS_norm=lambda x: x)
sys.modules["_test_fake_wan_module"] = fake_module
try:
patch_wan_rms_norm()
assert fake_module.WanRMS_norm is RMSNormVAE
finally:
del sys.modules["_test_fake_wan_module"]
def test_no_error_when_modules_change_during_iteration(self):
"""Regression test: list() snapshot prevents RuntimeError."""
from vllm_omni.diffusion.models.wan2_2.patch_diffusers import patch_wan_rms_norm
# Simulate a module being added during iteration by a side effect
original_items = sys.modules.items
def items_with_side_effect():
# This would cause RuntimeError without list() snapshot
result = list(original_items())
# Add a new module to simulate concurrent modification
sys.modules["_test_dynamic_module"] = SimpleNamespace()
return result
try:
# The function uses list(sys.modules.items()) so it takes a snapshot
# Just verify it doesn't raise
patch_wan_rms_norm()
finally:
sys.modules.pop("_test_dynamic_module", None)
# ---------------------------------------------------------------------------
# I2V transformer_2 quant_config extraction
# ---------------------------------------------------------------------------
class TestI2VTransformer2QuantConfig:
"""Test the transformer_2 quant_config build logic from pipeline_wan2_2_i2v."""
def test_transformer_2_quant_config_built_from_dict(self):
"""When transformer_2 config has quantization_config dict, build_quant_config is called."""
from vllm_omni.quantization.factory import build_quant_config
t2_config = {
"patch_size": [1, 2, 2],
"num_layers": 2,
"quantization_config": {
"quant_method": "auto-round",
"bits": 4,
"group_size": 128,
"sym": True,
"packing_format": "auto_round:auto_gptq",
},
}
# Replicate the logic from pipeline_wan2_2_i2v.py
t2_quant = t2_config.get("quantization_config")
if isinstance(t2_quant, dict) and "quant_method" in t2_quant:
method = t2_quant["quant_method"]
kwargs = {k: v for k, v in t2_quant.items() if k != "quant_method"}
t2_quant = build_quant_config(method, **kwargs)
else:
t2_quant = None
from vllm.model_executor.layers.quantization.inc import INCConfig
assert isinstance(t2_quant, INCConfig)
assert t2_quant.weight_bits == 4
assert t2_quant.group_size == 128
def test_transformer_2_quant_config_none_when_missing(self):
"""When transformer_2 config has no quantization_config, result is None."""
t2_config = {
"patch_size": [1, 2, 2],
"num_layers": 2,
}
t2_quant = t2_config.get("quantization_config")
if isinstance(t2_quant, dict) and "quant_method" in t2_quant:
pass # won't enter
else:
t2_quant = None
assert t2_quant is None
def test_transformer_2_quant_config_none_when_dict_lacks_method(self):
"""When quantization_config is a dict but missing quant_method, result is None."""
t2_config = {
"patch_size": [1, 2, 2],
"quantization_config": {"bits": 4}, # no quant_method key
}
t2_quant = t2_config.get("quantization_config")
if isinstance(t2_quant, dict) and "quant_method" in t2_quant:
pass
else:
t2_quant = None
assert t2_quant is None
@@ -1,97 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from PIL import Image
from torch import nn
from tests.diffusion.models.wan2_2.conftest import StubTransformer, StubVAE, noop_progress_bar
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_ti2v import (
Wan22TI2VPipeline,
get_wan22_ti2v_pre_process_func,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _make_ti2v_pipeline() -> Wan22TI2VPipeline:
pipeline = object.__new__(Wan22TI2VPipeline)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = StubTransformer(in_channels=4, out_channels=4)
pipeline.vae = StubVAE(z_dim=4)
pipeline.vae_scale_factor_temporal = 4
pipeline.vae_scale_factor_spatial = 8
pipeline.progress_bar = noop_progress_bar
return pipeline
def test_ti2v_preprocess_uses_720p_area_for_image_condition() -> None:
preprocess = get_wan22_ti2v_pre_process_func(SimpleNamespace())
request = SimpleNamespace(
prompts=[{"prompt": "p", "multi_modal_data": {"image": Image.new("RGB", (320, 160), "blue")}}],
sampling_params=SimpleNamespace(height=None, width=None),
)
result = preprocess(request)
assert result.sampling_params.height == 672
assert result.sampling_params.width == 1344
assert result.prompts[0]["multi_modal_data"]["image"].size == (1344, 672)
def test_ti2v_diffuse_without_image_condition_expands_patch_timesteps() -> None:
pipeline = _make_ti2v_pipeline()
latents = torch.zeros(1, 4, 2, 4, 4)
calls = []
def fake_predict_noise_maybe_with_cfg(**kwargs):
calls.append(kwargs)
return torch.ones_like(latents)
pipeline.predict_noise_maybe_with_cfg = fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
pipeline.scheduler_step_maybe_with_cfg = lambda noise, t, current, cfg: current + noise # type: ignore[method-assign]
result = pipeline.diffuse(
latents=latents,
timesteps=torch.tensor([7]),
prompt_embeds=torch.zeros(1, 2, 3),
negative_prompt_embeds=torch.zeros(1, 2, 3),
guidance_scale=3.0,
dtype=torch.float32,
attention_kwargs={"a": "b"},
num_latent_frames=2,
latent_height=4,
latent_width=4,
)
positive = calls[0]["positive_kwargs"]
assert calls[0]["do_true_cfg"] is True
assert positive["timestep"].shape == (1, 8)
torch.testing.assert_close(positive["timestep"], torch.full((1, 8), 7, dtype=positive["timestep"].dtype))
torch.testing.assert_close(positive["hidden_states"], latents)
torch.testing.assert_close(result, torch.ones_like(latents))
def test_ti2v_prepare_i2v_latents_encodes_condition_and_masks_first_frame() -> None:
pipeline = _make_ti2v_pipeline()
latents, latent_condition, first_frame_mask = pipeline.prepare_i2v_latents(
image=torch.zeros(1, 3, 16, 16),
batch_size=1,
num_channels_latents=4,
height=16,
width=16,
num_frames=5,
dtype=torch.float32,
device=torch.device("cpu"),
generator=None,
latents=torch.zeros(1, 4, 2, 2, 2),
)
torch.testing.assert_close(latents, torch.zeros(1, 4, 2, 2, 2))
assert latent_condition.shape == (1, 4, 1, 2, 2)
assert first_frame_mask[:, :, 0].sum() == 0
assert first_frame_mask[:, :, 1].sum() == 4
@@ -0,0 +1,539 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for MXFP4 quantization configs and the MXFP4 DualScale + BF16 mixed config."""
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@pytest.fixture(autouse=True)
def _patch_tp_state(monkeypatch):
"""Patch TP rank/world_size so ModelWeightParameter can be instantiated on CPU
without an initialized distributed group. Returns TP=1 rank=0 for all tests."""
monkeypatch.setattr("vllm.model_executor.parameter.get_tensor_model_parallel_rank", lambda: 0)
monkeypatch.setattr("vllm.model_executor.parameter.get_tensor_model_parallel_world_size", lambda: 1)
# ---------------------------------------------------------------------------
# DiffusionMXFP4Config
# ---------------------------------------------------------------------------
def test_mxfp4_config_get_name():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
assert DiffusionMXFP4Config.get_name() == "mxfp4"
def test_mxfp4_config_from_config_defaults():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = DiffusionMXFP4Config.from_config({})
assert cfg.is_checkpoint_mxfp4_serialized is False
assert cfg.ignored_layers == []
def test_mxfp4_config_from_config_serialized():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = DiffusionMXFP4Config.from_config({"is_checkpoint_mxfp4_serialized": True})
assert cfg.is_checkpoint_mxfp4_serialized is True
def test_mxfp4_config_from_config_ignored_layers():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = DiffusionMXFP4Config.from_config({"ignored_layers": ["proj_out"]})
assert cfg.ignored_layers == ["proj_out"]
def test_mxfp4_config_from_config_modules_to_not_convert_fallback():
"""modules_to_not_convert must be accepted as an alias for ignored_layers."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = DiffusionMXFP4Config.from_config({"modules_to_not_convert": ["proj_out"]})
assert cfg.ignored_layers == ["proj_out"]
# ---------------------------------------------------------------------------
# build_quant_config integration
# ---------------------------------------------------------------------------
def test_build_quant_config_mxfp4_string():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = build_quant_config("mxfp4")
assert isinstance(cfg, DiffusionMXFP4Config)
assert cfg.get_name() == "mxfp4"
assert cfg.is_checkpoint_mxfp4_serialized is False
def test_build_quant_config_mxfp4_dict():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = build_quant_config({"method": "mxfp4", "is_checkpoint_mxfp4_serialized": True})
assert isinstance(cfg, DiffusionMXFP4Config)
assert cfg.is_checkpoint_mxfp4_serialized is True
def test_build_quant_config_mxfp4_dualscale_string():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = build_quant_config("mxfp4_dualscale")
assert isinstance(cfg, DiffusionMXFP4DualScaleMixedConfig)
assert cfg.is_checkpoint_serialized is False
assert cfg.num_bf16_fallback_layers == 5
assert cfg.ignored_layers == []
def test_build_quant_config_mxfp4_dualscale_dict_offline():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = build_quant_config(
{
"method": "mxfp4_dualscale",
"is_checkpoint_serialized": True,
"ignored_layers": ["blocks.0.attn1.to_q", "blocks.0.attn1.to_k"],
}
)
assert isinstance(cfg, DiffusionMXFP4DualScaleMixedConfig)
assert cfg.is_checkpoint_serialized is True
assert cfg.ignored_layers == ["blocks.0.attn1.to_q", "blocks.0.attn1.to_k"]
def test_build_quant_config_mxfp4_dualscale_dict_online_custom_fallback():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = build_quant_config({"method": "mxfp4_dualscale", "num_bf16_fallback_layers": 10})
assert isinstance(cfg, DiffusionMXFP4DualScaleMixedConfig)
assert cfg.num_bf16_fallback_layers == 10
# ---------------------------------------------------------------------------
# Block-index dispatch (_parse_block_idx)
# ---------------------------------------------------------------------------
def test_parse_block_idx_valid():
from vllm_omni.quantization.mxfp4_config import _parse_block_idx
assert _parse_block_idx("blocks.0.attn1.to_q") == 0
assert _parse_block_idx("blocks.5.ffn.net.0.proj") == 5
assert _parse_block_idx("blocks.40.norm1.weight") == 40
def test_parse_block_idx_non_block_prefixes():
"""Prefixes that do not start with 'blocks.N.' must return None."""
from vllm_omni.quantization.mxfp4_config import _parse_block_idx
assert _parse_block_idx("condition_embedder.time_embedder.linear_1") is None
assert _parse_block_idx("proj_out.weight") is None
assert _parse_block_idx("model.layers.0.self_attn.q_proj") is None
assert _parse_block_idx("scale_shift_table") is None
# ---------------------------------------------------------------------------
# SUPPORTED_QUANTIZATION_METHODS
# ---------------------------------------------------------------------------
def test_supported_methods_include_mxfp4_variants():
from vllm_omni.quantization import SUPPORTED_QUANTIZATION_METHODS
assert "mxfp4" in SUPPORTED_QUANTIZATION_METHODS
assert "mxfp8" in SUPPORTED_QUANTIZATION_METHODS
assert "mxfp4_dualscale" in SUPPORTED_QUANTIZATION_METHODS
# ---------------------------------------------------------------------------
# DiffusionMXFP4DualScaleMixedConfig — config roundtrips
# ---------------------------------------------------------------------------
def test_mixed_dualscale_config_get_name():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
assert DiffusionMXFP4DualScaleMixedConfig.get_name() == "mxfp4_dualscale"
def test_mixed_dualscale_config_no_args_defaults():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig()
assert cfg.is_checkpoint_serialized is False
assert cfg.ignored_layers == []
assert cfg.num_bf16_fallback_layers == 5
def test_mixed_dualscale_config_from_config_offline():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig.from_config(
{
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": True,
"ignored_layers": ["blocks.0.attn1.to_q", "proj_out"],
}
)
assert cfg.is_checkpoint_serialized is True
assert cfg.ignored_layers == ["blocks.0.attn1.to_q", "proj_out"]
assert cfg.num_bf16_fallback_layers == 5 # default
def test_mixed_dualscale_config_from_config_online_custom_fallback():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig.from_config({"num_bf16_fallback_layers": 10})
assert cfg.is_checkpoint_serialized is False
assert cfg.num_bf16_fallback_layers == 10
def test_mixed_dualscale_config_from_config_modules_to_not_convert_fallback():
"""modules_to_not_convert must be accepted as an alias for ignored_layers."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig.from_config(
{"is_checkpoint_serialized": True, "modules_to_not_convert": ["proj_out"]}
)
assert cfg.ignored_layers == ["proj_out"]
# ---------------------------------------------------------------------------
# DiffusionMXFP4DualScaleMixedConfig — get_quant_method dispatch
# ---------------------------------------------------------------------------
def test_mixed_dualscale_offline_ignored_layer_returns_unquantized(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Offline: a prefix in ignored_layers must return UnquantizedLinearMethod."""
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=True,
ignored_layers=["blocks.0.attn1.to_q"],
)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = cfg.get_quant_method(layer, "blocks.0.attn1.to_q")
assert isinstance(method, UnquantizedLinearMethod)
def test_mixed_dualscale_offline_non_ignored_returns_mxfp4(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Offline: a prefix NOT in ignored_layers must return NPUMxfp4DualScaleLinearMethod."""
from vllm.model_executor.layers.linear import LinearBase
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
cfg = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=True,
ignored_layers=["blocks.0.attn1.to_q"],
)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = cfg.get_quant_method(layer, "blocks.1.attn1.to_q")
assert isinstance(method, NPUMxfp4DualScaleLinearMethod)
def test_mixed_dualscale_online_fallback_block_returns_unquantized(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Online: blocks < num_bf16_fallback_layers must return UnquantizedLinearMethod."""
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig(is_checkpoint_serialized=False, num_bf16_fallback_layers=5)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
assert isinstance(cfg.get_quant_method(layer, "blocks.0.attn1.to_q"), UnquantizedLinearMethod)
assert isinstance(cfg.get_quant_method(layer, "blocks.4.ffn.net.0.proj"), UnquantizedLinearMethod)
def test_mixed_dualscale_online_quantized_block_returns_mxfp4(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Online: blocks >= num_bf16_fallback_layers must return NPUMxfp4DualScaleOnlineLinearMethod."""
from vllm.model_executor.layers.linear import LinearBase
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import (
DiffusionMXFP4DualScaleMixedConfig,
NPUMxfp4DualScaleOnlineLinearMethod,
)
cfg = DiffusionMXFP4DualScaleMixedConfig(is_checkpoint_serialized=False, num_bf16_fallback_layers=5)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
assert isinstance(cfg.get_quant_method(layer, "blocks.5.attn1.to_q"), NPUMxfp4DualScaleOnlineLinearMethod)
assert isinstance(cfg.get_quant_method(layer, "blocks.40.ffn.net.0.proj"), NPUMxfp4DualScaleOnlineLinearMethod)
def test_mixed_dualscale_online_non_block_prefix_returns_mxfp4(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Online: layers outside 'blocks.N.*' (condition_embedder etc.) always use MXFP4 online."""
from vllm.model_executor.layers.linear import LinearBase
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import (
DiffusionMXFP4DualScaleMixedConfig,
NPUMxfp4DualScaleOnlineLinearMethod,
)
cfg = DiffusionMXFP4DualScaleMixedConfig(is_checkpoint_serialized=False, num_bf16_fallback_layers=5)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = cfg.get_quant_method(layer, "condition_embedder.time_embedder.linear_1")
assert isinstance(method, NPUMxfp4DualScaleOnlineLinearMethod)
def test_mixed_dualscale_online_ignored_layers_override(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Online: explicit ignored_layers must return UnquantizedLinearMethod regardless of block index.
A layer that is NOT in the leading-block range (block 10 >= num_bf16_fallback_layers=5)
but IS listed in ignored_layers must still fall back to BF16. This lets power users
pin specific interleaved layers to BF16 during online quantization without needing an
offline checkpoint.
"""
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=False,
num_bf16_fallback_layers=5,
ignored_layers=["blocks.10.attn1.to_q"],
)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
# block 10 is above the leading-block threshold but is in ignored_layers → BF16
assert isinstance(cfg.get_quant_method(layer, "blocks.10.attn1.to_q"), UnquantizedLinearMethod)
def test_mixed_dualscale_non_linear_returns_none(monkeypatch: pytest.MonkeyPatch):
"""Non-LinearBase layers (norms, embeddings) must return None → no quantization."""
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig()
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
norm_layer = torch.nn.LayerNorm(64)
assert cfg.get_quant_method(norm_layer, "blocks.0.norm1") is None
# ---------------------------------------------------------------------------
# TP=2 create_weights: parameter shapes and input_dim/output_dim
#
# Two scenarios mirror real Wan2.2 A14B linear layer types:
# Column-parallel (to_q, ffn.net_0): output is sharded (N/TP), input is full (K).
# Row-parallel (to_out, ffn.net_2): input is sharded (K/TP), output is full (N).
#
# Tests verify:
# 1. Registered parameter shapes are correct for each partition configuration.
# 2. input_dim/output_dim attributes are set so RowParallelLinear.weight_loader
# can shard scale tensors correctly (the fix for the TP>1 shape-mismatch bug).
# 3. Simulated loader slicing: slicing the full checkpoint tensor along the
# declared input_dim produces the exact shape stored in the parameter —
# proving the dim declaration is consistent with the allocation.
# ---------------------------------------------------------------------------
# K must be divisible by 32 (fine groups) and 512 (coarse groups).
_TP2_K, _TP2_N, _TP2 = 1024, 512, 2
class _FakeLayer(torch.nn.Module):
"""Bare nn.Module that accepts register_parameter without a real weight_loader."""
def _create_weights(method, *, input_size_per_partition, output_partition_sizes):
layer = _FakeLayer()
method.create_weights(
layer=layer,
input_size_per_partition=input_size_per_partition,
output_partition_sizes=output_partition_sizes,
input_size=_TP2_K,
output_size=_TP2_N,
params_dtype=torch.bfloat16,
)
return layer
def _shard(tensor, param, rank, tp, dim_attr):
"""Slice `tensor` along the dimension given by `param.<dim_attr>` for `rank`."""
dim = getattr(param, dim_attr)
if dim is None:
return tensor # not sharded along this axis
shard_size = param.shape[dim]
slices = [slice(None)] * tensor.ndim
slices[dim] = slice(rank * shard_size, (rank + 1) * shard_size)
return tensor[tuple(slices)]
# ── DualScale method ─────────────────────────────────────────────────────────
def test_dualscale_column_parallel_tp2_shapes():
"""Column-parallel TP=2: output halved, fine/coarse groups stay full, mul_scale full."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N // _TP2])
assert layer.weight.shape == (_TP2_N // _TP2, _TP2_K)
assert layer.weight_scale.shape == (_TP2_N // _TP2, _TP2_K // 32)
assert layer.weight_dual_scale.shape == (_TP2_N // _TP2, _TP2_K // 512, 1)
assert layer.mul_scale.shape == (_TP2_K,)
def test_dualscale_row_parallel_tp2_shapes():
"""Row-parallel TP=2: input halved, fine/coarse groups halved, mul_scale halved."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
assert layer.weight.shape == (_TP2_N, _TP2_K // _TP2)
assert layer.weight_scale.shape == (_TP2_N, (_TP2_K // _TP2) // 32)
assert layer.weight_dual_scale.shape == (_TP2_N, (_TP2_K // _TP2) // 512, 1)
assert layer.mul_scale.shape == (_TP2_K // _TP2,)
def test_dualscale_scale_parameter_input_dims():
"""weight_scale/weight_dual_scale must have input_dim=1; mul_scale must have input_dim=0.
RowParallelLinear.weight_loader only shards a parameter when input_dim is set.
Without these, loading a full checkpoint tensor into a per-rank shape causes a
shape mismatch for TP>1 row-parallel layers (to_out, ffn.net_2).
"""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N])
assert layer.weight_scale.input_dim == 1
assert layer.weight_scale.output_dim == 0
assert layer.weight_dual_scale.input_dim == 1
assert layer.weight_dual_scale.output_dim == 0
assert layer.mul_scale.input_dim == 0
assert layer.mul_scale.output_dim is None
def test_dualscale_row_parallel_tp2_loader_simulation():
"""Slicing full checkpoint tensors along input_dim must match row-parallel parameter shapes.
Simulates what RowParallelLinear.weight_loader does: for each scale parameter,
take the slice at rank*shard_size:(rank+1)*shard_size along input_dim.
The resulting shape must equal the per-rank parameter shape allocated by create_weights.
"""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
# Full checkpoint tensors (what the loader reads from disk).
ckpt_weight_scale = torch.zeros(_TP2_N, _TP2_K // 32)
ckpt_weight_dual_scale = torch.zeros(_TP2_N, _TP2_K // 512, 1)
ckpt_mul_scale = torch.zeros(_TP2_K)
for rank in range(_TP2):
assert _shard(ckpt_weight_scale, layer.weight_scale, rank, _TP2, "input_dim").shape == layer.weight_scale.shape
assert (
_shard(ckpt_weight_dual_scale, layer.weight_dual_scale, rank, _TP2, "input_dim").shape
== layer.weight_dual_scale.shape
)
assert _shard(ckpt_mul_scale, layer.mul_scale, rank, _TP2, "input_dim").shape == layer.mul_scale.shape
def test_dualscale_column_parallel_tp2_loader_simulation():
"""Slicing full checkpoint tensors along output_dim must match column-parallel parameter shapes.
For column-parallel layers, the loader shards along output_dim (rows).
mul_scale has output_dim=None → not sharded (full tensor, same for all ranks).
"""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N // _TP2])
ckpt_weight_scale = torch.zeros(_TP2_N, _TP2_K // 32)
ckpt_weight_dual_scale = torch.zeros(_TP2_N, _TP2_K // 512, 1)
ckpt_mul_scale = torch.zeros(_TP2_K)
for rank in range(_TP2):
assert _shard(ckpt_weight_scale, layer.weight_scale, rank, _TP2, "output_dim").shape == layer.weight_scale.shape
assert (
_shard(ckpt_weight_dual_scale, layer.weight_dual_scale, rank, _TP2, "output_dim").shape
== layer.weight_dual_scale.shape
)
# mul_scale: output_dim=None → no sharding → full tensor fits the column-parallel parameter
assert _shard(ckpt_mul_scale, layer.mul_scale, rank, _TP2, "output_dim").shape == layer.mul_scale.shape
# ── Single-scale method ───────────────────────────────────────────────────────
def test_single_scale_row_parallel_tp2_shapes():
"""Row-parallel TP=2: input halved → weight_scale groups halved."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, NPUMxfp4LinearMethod
method = NPUMxfp4LinearMethod(DiffusionMXFP4Config())
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
assert layer.weight.shape == (_TP2_N, _TP2_K // _TP2)
assert layer.weight_scale.shape == (_TP2_N, (_TP2_K // _TP2) // 32)
def test_single_scale_scale_parameter_input_dims():
"""Single-scale weight_scale must have input_dim=1 for RowParallel TP sharding."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, NPUMxfp4LinearMethod
method = NPUMxfp4LinearMethod(DiffusionMXFP4Config())
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N])
assert layer.weight_scale.input_dim == 1
assert layer.weight_scale.output_dim == 0
def test_single_scale_row_parallel_tp2_loader_simulation():
"""Slicing full checkpoint weight_scale along input_dim matches row-parallel parameter shape."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, NPUMxfp4LinearMethod
method = NPUMxfp4LinearMethod(DiffusionMXFP4Config())
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
ckpt_weight_scale = torch.zeros(_TP2_N, _TP2_K // 32)
for rank in range(_TP2):
assert _shard(ckpt_weight_scale, layer.weight_scale, rank, _TP2, "input_dim").shape == layer.weight_scale.shape
@@ -0,0 +1,399 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for merge_mxfp4_dualscale_checkpoint.py key-remapping helpers.
These are pure-Python unit tests that exercise the transformation functions
without loading any actual checkpoint files or requiring NPU hardware.
"""
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
# ---------------------------------------------------------------------------
# SUPPORTED_MODEL_TYPES
# ---------------------------------------------------------------------------
def test_supported_model_types_includes_a14b():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import SUPPORTED_MODEL_TYPES
assert "Wan2.2-T2V-A14B" in SUPPORTED_MODEL_TYPES
assert "Wan2.2-I2V-A14B" in SUPPORTED_MODEL_TYPES
def test_supported_model_types_excludes_ti2v_5b():
"""TI2V-5B is explicitly NOT supported under W4A4 quantization."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import SUPPORTED_MODEL_TYPES
assert "Wan2.2-TI2V-5B" not in SUPPORTED_MODEL_TYPES
# ---------------------------------------------------------------------------
# _apply_rename_dict
# ---------------------------------------------------------------------------
def test_apply_rename_dict_self_attn_qkvo():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("blocks.0.self_attn.q.weight") == "blocks.0.attn1.to_q.weight"
assert _apply_rename_dict("blocks.0.self_attn.k.weight") == "blocks.0.attn1.to_k.weight"
assert _apply_rename_dict("blocks.0.self_attn.v.weight") == "blocks.0.attn1.to_v.weight"
assert _apply_rename_dict("blocks.0.self_attn.o.weight") == "blocks.0.attn1.to_out.0.weight"
def test_apply_rename_dict_ffn():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("blocks.1.ffn.0.weight") == "blocks.1.ffn.net.0.proj.weight"
assert _apply_rename_dict("blocks.1.ffn.2.weight") == "blocks.1.ffn.net.2.weight"
def test_apply_rename_dict_norm_order_swap():
"""norm2↔norm3 swap: quant tool uses norm1/norm3/norm2 order,
Diffusers uses norm1/norm2/norm3."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("blocks.0.norm2.weight") == "blocks.0.norm3.weight"
assert _apply_rename_dict("blocks.0.norm3.weight") == "blocks.0.norm2.weight"
def test_apply_rename_dict_cross_attn():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("blocks.0.cross_attn.q.weight") == "blocks.0.attn2.to_q.weight"
assert _apply_rename_dict("blocks.0.cross_attn.k.weight") == "blocks.0.attn2.to_k.weight"
assert _apply_rename_dict("blocks.0.cross_attn.v.weight") == "blocks.0.attn2.to_v.weight"
assert _apply_rename_dict("blocks.0.cross_attn.o.weight") == "blocks.0.attn2.to_out.0.weight"
def test_apply_rename_dict_head_and_modulation():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("head.head.weight") == "proj_out.weight"
# ---------------------------------------------------------------------------
# _strip_mxfp4_wrapper
# ---------------------------------------------------------------------------
def test_strip_mxfp4_wrapper_linear_weight():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.linear.weight") == "blocks.0.attn1.to_q.weight"
def test_strip_mxfp4_wrapper_linear_weight_scale():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.linear.weight_scale") == "blocks.0.attn1.to_q.weight_scale"
def test_strip_mxfp4_wrapper_linear_weight_dual_scale():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert (
_strip_mxfp4_wrapper("blocks.0.attn1.to_q.linear.weight_dual_scale") == "blocks.0.attn1.to_q.weight_dual_scale"
)
def test_strip_mxfp4_wrapper_linear_bias():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.linear.bias") == "blocks.0.attn1.to_q.bias"
def test_strip_mxfp4_wrapper_div_mul_scale():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.div.mul_scale") == "blocks.0.attn1.to_q.mul_scale"
def test_strip_mxfp4_wrapper_noop_for_plain_weight():
"""MXFP8 / FLOAT tensors have no wrapper — must be returned unchanged."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.weight") == "blocks.0.attn1.to_q.weight"
assert _strip_mxfp4_wrapper("blocks.0.norm_q.weight") == "blocks.0.norm_q.weight"
assert _strip_mxfp4_wrapper("condition_embedder.time_embedder.linear_1.weight") == (
"condition_embedder.time_embedder.linear_1.weight"
)
# ---------------------------------------------------------------------------
# _classify_blocks
# ---------------------------------------------------------------------------
def test_classify_blocks_mixed_mxfp8_and_mxfp4():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _classify_blocks
quant_meta = {
"blocks.0.attn1.to_q.weight": "W8A8_MXFP8",
"blocks.1.attn1.to_q.weight": "W8A8_MXFP8",
"blocks.2.attn1.to_q.weight": "W4A4_MXFP4_DUALSCALE",
"blocks.3.attn1.to_q.weight": "W4A4_MXFP4_DUALSCALE",
"condition_embedder.time_embedder.linear_1.weight": "FLOAT",
}
block_types = _classify_blocks(quant_meta)
assert block_types[0] == "mxfp8"
assert block_types[1] == "mxfp8"
assert block_types[2] == "mxfp4_dualscale"
assert block_types[3] == "mxfp4_dualscale"
# Non-block key must not produce an entry
assert None not in block_types
def test_classify_blocks_float_entries_skipped():
"""FLOAT-typed tensors must not contribute to block classification."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _classify_blocks
quant_meta = {
"blocks.0.bias": "FLOAT",
"blocks.1.attn1.to_q.weight": "W4A4_MXFP4_DUALSCALE",
}
block_types = _classify_blocks(quant_meta)
assert 0 not in block_types
assert block_types[1] == "mxfp4_dualscale"
def test_classify_blocks_empty():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _classify_blocks
assert _classify_blocks({}) == {}
# ---------------------------------------------------------------------------
# _is_mxfp4_tensor
# ---------------------------------------------------------------------------
def test_is_mxfp4_tensor_quantized_weight():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _is_mxfp4_tensor
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.linear.weight", "W4A4_MXFP4_DUALSCALE") is True
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.linear.weight_scale", "W4A4_MXFP4_DUALSCALE") is True
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.linear.weight_dual_scale", "W4A4_MXFP4_DUALSCALE") is True
def test_is_mxfp4_tensor_companion_bias_and_mul_scale():
"""Companion tensors (bias, mul_scale) are FLOAT but belong to MXFP4 layers
and must be included in the merged checkpoint."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _is_mxfp4_tensor
# bias inside .linear. wrapper → must be included
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.linear.bias", "FLOAT") is True
# mul_scale inside .div. wrapper → must be included
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.div.mul_scale", "FLOAT") is True
def test_is_mxfp4_tensor_bf16_fallback_weight():
"""BF16 fallback linear layers have plain .weight keys (no wrapper) → must NOT be included."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _is_mxfp4_tensor
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.weight", "FLOAT") is False
assert _is_mxfp4_tensor("condition_embedder.time_embedder.linear_1.weight", "FLOAT") is False
def test_is_mxfp4_tensor_norm_weight():
"""Norm layers are always FLOAT with plain .weight → not MXFP4 related."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _is_mxfp4_tensor
assert _is_mxfp4_tensor("blocks.0.norm1.weight", "FLOAT") is False
# ---------------------------------------------------------------------------
# _collect_ignored_layers
# ---------------------------------------------------------------------------
def test_collect_ignored_layers_basic():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _collect_ignored_layers
# block 0: all three of Q/K/V are BF16 → fused as to_qkv in ignored_layers
# block 1: to_qkv is MXFP4 → not in ignored_layers
merged = {
"blocks.0.attn1.to_q.weight": torch.zeros(4, 4),
"blocks.0.attn1.to_k.weight": torch.zeros(4, 4),
"blocks.0.attn1.to_v.weight": torch.zeros(4, 4),
"blocks.1.attn1.to_q.weight": torch.zeros(4, 4),
"blocks.1.attn1.to_q.weight_scale": torch.zeros(4, 1),
}
mxfp4_prefixes = {"blocks.1.attn1.to_q"}
ignored = _collect_ignored_layers(merged, mxfp4_prefixes)
assert "blocks.0.attn1.to_qkv" in ignored # fused vllm-omni name
assert "blocks.0.attn1.to_q" not in ignored # diffusers name must not appear
assert "blocks.0.attn1.to_k" not in ignored
assert "blocks.1.attn1.to_qkv" not in ignored # MXFP4, not ignored
def test_collect_ignored_layers_empty_mxfp4():
"""When no layers are MXFP4 (all BF16), all .weight prefixes become ignored_layers."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _collect_ignored_layers
# Use non-attn layers to avoid the Q/K/V completeness requirement.
merged = {
"blocks.0.ffn.net.0.proj.weight": torch.zeros(4, 4),
"proj_out.weight": torch.zeros(4, 4),
}
ignored = _collect_ignored_layers(merged, set())
assert sorted(ignored) == ["blocks.0.ffn.net_0.proj", "proj_out"]
def test_collect_ignored_layers_returns_sorted():
"""ignored_layers must be sorted for deterministic config.json output."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _collect_ignored_layers
# Use FFN layers (no Q/K/V fusion concern) to isolate the sort guarantee.
merged = {
"blocks.2.ffn.net.0.proj.weight": torch.zeros(1),
"blocks.0.ffn.net.0.proj.weight": torch.zeros(1),
"blocks.1.ffn.net.0.proj.weight": torch.zeros(1),
}
ignored = _collect_ignored_layers(merged, set())
assert ignored == sorted(ignored)
# ---------------------------------------------------------------------------
# _build_quant_config (new mxfp4_dualscale format)
# ---------------------------------------------------------------------------
def test_build_quant_config_new_format():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _build_quant_config
config = _build_quant_config(["blocks.0.attn1.to_q", "proj_out"])
assert config["quant_method"] == "mxfp4_dualscale"
assert config["is_checkpoint_serialized"] is True
assert config["ignored_layers"] == ["blocks.0.attn1.to_q", "proj_out"]
assert "num_mxfp8_blocks" not in config
def test_build_quant_config_empty_ignored_layers():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _build_quant_config
config = _build_quant_config([])
assert config["ignored_layers"] == []
# ---------------------------------------------------------------------------
# _diffusers_to_vllm_ignored
# ---------------------------------------------------------------------------
def test_remap_self_attn_qkv_fusion():
"""All three of attn1.to_q/k/v in BF16 → fused attn1.to_qkv."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
result = _diffusers_to_vllm_ignored(["blocks.0.attn1.to_k", "blocks.0.attn1.to_q", "blocks.0.attn1.to_v"])
assert result == ["blocks.0.attn1.to_qkv"]
def test_remap_self_attn_qkv_partial_raises():
"""Partial Q/K/V in ignored_layers is invalid — must raise ValueError.
Self-attention Q/K/V are fused into a single to_qkv layer at runtime;
partial precision (some BF16, some MXFP4) cannot be expressed and must
be caught early in the merge script.
"""
import pytest
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
with pytest.raises(ValueError, match="Partial BF16 fallback"):
_diffusers_to_vllm_ignored(["blocks.0.attn1.to_q", "blocks.0.attn1.to_k"])
def test_remap_cross_attn_kept_separate():
"""Cross-attention (attn2) to_q/k/v must NOT be fused."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
result = _diffusers_to_vllm_ignored(["blocks.0.attn2.to_k", "blocks.0.attn2.to_q", "blocks.0.attn2.to_v"])
assert "blocks.0.attn2.to_qkv" not in result
assert "blocks.0.attn2.to_q" in result
assert "blocks.0.attn2.to_k" in result
assert "blocks.0.attn2.to_v" in result
def test_remap_ffn_dot_to_underscore():
"""ffn.net.0.proj → ffn.net_0.proj; ffn.net.2 → ffn.net_2."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
result = _diffusers_to_vllm_ignored(["blocks.0.ffn.net.0.proj", "blocks.0.ffn.net.2"])
assert "blocks.0.ffn.net_0.proj" in result
assert "blocks.0.ffn.net_2" in result
assert "blocks.0.ffn.net.0.proj" not in result
assert "blocks.0.ffn.net.2" not in result
def test_remap_to_out_strips_index():
"""attn1.to_out.0 and attn2.to_out.0 must have the .0 suffix removed."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
result = _diffusers_to_vllm_ignored(["blocks.0.attn1.to_out.0", "blocks.0.attn2.to_out.0"])
assert "blocks.0.attn1.to_out" in result
assert "blocks.0.attn2.to_out" in result
assert "blocks.0.attn1.to_out.0" not in result
assert "blocks.0.attn2.to_out.0" not in result
def test_remap_noop_for_non_block_layers():
"""Layers outside 'blocks.N.*' (condition_embedder, proj_out) pass through unchanged."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
inputs = [
"condition_embedder.time_embedder.linear_1",
"condition_embedder.text_embedder.linear_1",
"proj_out",
]
result = _diffusers_to_vllm_ignored(inputs)
assert result == sorted(inputs)
def test_remap_mixed_block_layers():
"""Typical mixed block: QKV fused, to_out stripped, FFN renamed, cross-attn separate."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
inputs = [
"blocks.0.attn1.to_k",
"blocks.0.attn1.to_out.0",
"blocks.0.attn1.to_q",
"blocks.0.attn1.to_v",
"blocks.0.attn2.to_k",
"blocks.0.attn2.to_out.0",
"blocks.0.attn2.to_q",
"blocks.0.attn2.to_v",
"blocks.0.ffn.net.0.proj",
"blocks.0.ffn.net.2",
]
result = _diffusers_to_vllm_ignored(inputs)
assert "blocks.0.attn1.to_qkv" in result
assert "blocks.0.attn1.to_out" in result
assert "blocks.0.attn2.to_q" in result
assert "blocks.0.attn2.to_k" in result
assert "blocks.0.attn2.to_v" in result
assert "blocks.0.attn2.to_out" in result
assert "blocks.0.ffn.net_0.proj" in result
assert "blocks.0.ffn.net_2" in result
# Old names must be gone
assert "blocks.0.attn1.to_q" not in result
assert "blocks.0.ffn.net.0.proj" not in result
def test_remap_returns_sorted():
"""Output of _diffusers_to_vllm_ignored must always be sorted."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
inputs = [
"blocks.2.ffn.net.0.proj",
"blocks.0.attn1.to_q",
"blocks.0.attn1.to_k",
"blocks.0.attn1.to_v",
"blocks.1.attn2.to_out.0",
]
result = _diffusers_to_vllm_ignored(inputs)
assert result == sorted(result)
@@ -0,0 +1,308 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for MXFP8 quantization config and linear method dispatch.
Coverage:
- DiffusionMXFP8Config.from_config roundtrips (CPU, no NPU required)
- get_quant_method dispatch (mocked platform)
- MXFPLinearMethodBase.apply() reshape skeleton (CPU)
- Weight / scale shape-transform arithmetic from process_weights_after_loading (CPU)
- build_quant_config integration
- MXFP8_QUANT_CONFIG structure as the auto-detection contract
"""
import pytest
import torch
from pytest_mock import MockerFixture
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization import build_quant_config
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
npu_available = pytest.mark.skipif(not current_omni_platform.is_npu(), reason="NPU platform not available")
# ---------------------------------------------------------------------------
# DiffusionMXFP8Config — from_config roundtrips
# ---------------------------------------------------------------------------
def test_mxfp8_config_get_name():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
assert DiffusionMXFP8Config.get_name() == "mxfp8"
def test_mxfp8_config_from_config_defaults():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = DiffusionMXFP8Config.from_config({})
assert cfg.is_checkpoint_mxfp8_serialized is False
assert cfg.ignored_layers == []
def test_mxfp8_config_from_config_serialized():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = DiffusionMXFP8Config.from_config({"is_checkpoint_mxfp8_serialized": True})
assert cfg.is_checkpoint_mxfp8_serialized is True
def test_mxfp8_config_from_config_ignored_layers():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = DiffusionMXFP8Config.from_config({"ignored_layers": ["proj_out"]})
assert cfg.ignored_layers == ["proj_out"]
def test_mxfp8_config_from_config_modules_to_not_convert_fallback():
"""modules_to_not_convert must be accepted as an alias for ignored_layers."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = DiffusionMXFP8Config.from_config({"modules_to_not_convert": ["proj_out"]})
assert cfg.ignored_layers == ["proj_out"]
# ---------------------------------------------------------------------------
# build_quant_config integration
# ---------------------------------------------------------------------------
def test_build_quant_config_mxfp8_string():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = build_quant_config("mxfp8")
assert isinstance(cfg, DiffusionMXFP8Config)
assert cfg.get_name() == "mxfp8"
assert cfg.is_checkpoint_mxfp8_serialized is False
def test_build_quant_config_mxfp8_dict():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = build_quant_config({"method": "mxfp8", "is_checkpoint_mxfp8_serialized": True})
assert isinstance(cfg, DiffusionMXFP8Config)
assert cfg.is_checkpoint_mxfp8_serialized is True
def test_build_quant_config_mxfp8_config_json_format():
"""Verify that the exact quantization_config injected by merge_mxfp8_checkpoint.py
is accepted by build_quant_config and selects the offline (serialized) path.
This is the critical auto-detection contract: TransformerConfig.from_dict()
reads quant_method + is_checkpoint_mxfp8_serialized to pick NPUMxfp8LinearMethod.
"""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import MXFP8_QUANT_CONFIG
cfg = build_quant_config(MXFP8_QUANT_CONFIG)
assert isinstance(cfg, DiffusionMXFP8Config)
assert cfg.is_checkpoint_mxfp8_serialized is True
def test_mxfp8_quant_config_structure():
"""MXFP8_QUANT_CONFIG must contain exactly the keys that auto-detection reads."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import MXFP8_QUANT_CONFIG
assert MXFP8_QUANT_CONFIG.get("quant_method") == "mxfp8"
assert MXFP8_QUANT_CONFIG.get("is_checkpoint_mxfp8_serialized") is True
# ---------------------------------------------------------------------------
# get_quant_method dispatch
# ---------------------------------------------------------------------------
def test_get_quant_method_npu_offline(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""Offline (serialized) path on NPU must return NPUMxfp8LinearMethod."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config, NPUMxfp8LinearMethod
config = DiffusionMXFP8Config(is_checkpoint_mxfp8_serialized=True)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = config.get_quant_method(layer, "blocks.0.attn1.to_q")
assert isinstance(method, NPUMxfp8LinearMethod)
def test_get_quant_method_npu_online(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""Online (BF16 checkpoint) path on NPU must return NPUMxfp8OnlineLinearMethod."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config, NPUMxfp8OnlineLinearMethod
config = DiffusionMXFP8Config(is_checkpoint_mxfp8_serialized=False)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = config.get_quant_method(layer, "blocks.0.attn1.to_q")
assert isinstance(method, NPUMxfp8OnlineLinearMethod)
def test_get_quant_method_non_npu_raises(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""Non-NPU platform must raise NotImplementedError (CUDA not yet supported)."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
config = DiffusionMXFP8Config()
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: False)
monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: False)
with pytest.raises(NotImplementedError):
config.get_quant_method(layer, "blocks.0.attn1.to_q")
def test_get_quant_method_ignored_layer(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""A prefix in ignored_layers must bypass quantization → UnquantizedLinearMethod."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
config = DiffusionMXFP8Config(ignored_layers=["proj_out"])
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = config.get_quant_method(layer, "proj_out")
assert isinstance(method, UnquantizedLinearMethod)
def test_get_quant_method_non_linear_returns_none(monkeypatch: pytest.MonkeyPatch):
"""Non-LinearBase layers (norms, embeddings) must get None → no quantization."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
config = DiffusionMXFP8Config()
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
norm_layer = torch.nn.LayerNorm(64)
assert config.get_quant_method(norm_layer, "blocks.0.norm1") is None
# ---------------------------------------------------------------------------
# MXFPLinearMethodBase.apply() — reshape skeleton (CPU, no NPU)
# ---------------------------------------------------------------------------
def test_apply_reshape_skeleton():
"""apply() must flatten batch dims → _apply_inner → restore original leading dims."""
from vllm_omni.quantization.mxfp8_config import MXFPLinearMethodBase
OUT_FEATURES = 4
class _StubMethod(MXFPLinearMethodBase):
def create_weights(
self,
layer,
input_size_per_partition,
output_partition_sizes,
input_size,
output_size,
params_dtype,
**extra_weight_attrs,
):
pass
def _quantize_activation(self, x):
return x, None
def _quant_matmul(self, x_q, x_scale, layer, bias, ori_dtype):
return torch.zeros(x_q.shape[0], OUT_FEATURES, dtype=ori_dtype)
method = _StubMethod()
x = torch.randn(2, 3, 8) # (batch=2, seq=3, K=8)
out = method.apply(None, x)
assert out.shape == (2, 3, OUT_FEATURES)
def test_apply_reshape_with_bias():
"""apply() must pass bias through to _apply_inner unchanged."""
from vllm_omni.quantization.mxfp8_config import MXFPLinearMethodBase
received_bias = []
class _StubMethod(MXFPLinearMethodBase):
def create_weights(
self,
layer,
input_size_per_partition,
output_partition_sizes,
input_size,
output_size,
params_dtype,
**extra_weight_attrs,
):
pass
def _quantize_activation(self, x):
return x, None
def _quant_matmul(self, x_q, x_scale, layer, bias, ori_dtype):
received_bias.append(bias)
return torch.zeros(x_q.shape[0], 4, dtype=ori_dtype)
method = _StubMethod()
bias = torch.zeros(4)
method.apply(None, torch.randn(2, 8), bias=bias)
assert received_bias[0] is bias
# ---------------------------------------------------------------------------
# process_weights_after_loading shape arithmetic (pure torch, no NPU ops)
#
# These tests replicate the CPU-safe portions of process_weights_after_loading
# to guard the key layout contract: (N,K) weight → (K,N) and (N,S) scale →
# (S/2,N,2). They do NOT call NPU ops; they test only the math.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"n, k",
[(64, 128), (32, 64), (16, 96)],
ids=["64x128", "32x64", "16x96"],
)
def test_weight_transpose_contract(n: int, k: int):
"""Weight must be transposed from (N, K) to (K, N) and be contiguous."""
w = torch.zeros(n, k, dtype=torch.uint8)
w = w.transpose(0, 1).contiguous()
assert w.shape == (k, n)
assert w.is_contiguous()
@pytest.mark.parametrize(
"n, k_groups, expected_groups",
[
(64, 4, 2), # even K_groups — no padding
(64, 3, 2), # odd K_groups — padded to 4
(32, 1, 1), # odd K_groups — padded to 2
],
ids=["even", "odd-to-4", "odd-to-2"],
)
def test_weight_scale_reshape_contract(n: int, k_groups: int, expected_groups: int):
"""Scale must be reshaped from (N, K_groups) to (K_groups_even//2, N, 2).
Odd K_groups must be padded to even before the reshape.
"""
s = torch.zeros(n, k_groups, dtype=torch.uint8)
if k_groups % 2 == 1:
s = torch.cat([s, torch.zeros(n, 1, dtype=s.dtype)], dim=1)
k_groups += 1
s = s.reshape(n, k_groups // 2, 2).transpose(0, 1).contiguous()
assert s.shape == (expected_groups, n, 2)
assert s.is_contiguous()
def test_num_groups_formula():
"""K_groups formula: ceil(K / 32) — spot-check boundary values."""
assert (31 + 31) // 32 == 1 # K=31 → 1 group
assert (32 + 31) // 32 == 1 # K=32 → 1 group
assert (33 + 31) // 32 == 2 # K=33 → 2 groups
assert (128 + 31) // 32 == 4 # K=128 → 4 groups (even)
assert (96 + 31) // 32 == 3 # K=96 → 3 groups (odd → needs padding)
# ---------------------------------------------------------------------------
# SUPPORTED_QUANTIZATION_METHODS
# ---------------------------------------------------------------------------
def test_supported_methods_include_mxfp8():
from vllm_omni.quantization import SUPPORTED_QUANTIZATION_METHODS
assert "mxfp8" in SUPPORTED_QUANTIZATION_METHODS
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for merge_mxfp8_checkpoint.py key-remapping helpers and model metadata.
These are pure-Python unit tests that exercise the transformation functions
without loading any actual checkpoint files or requiring NPU hardware.
Key contracts verified:
- SUPPORTED_MODEL_TYPES includes Wan2.2-TI2V-5B (MXFP8 supports it; MXFP4 does not)
- MXFP8_QUANT_CONFIG structure matches what TransformerConfig.from_dict() reads
- _remap_keys correctly translates msModelSlim naming → Diffusers naming
- _get_transformer_dirs routes cascade vs single-transformer models
- _get_quant_subdir maps high/low noise subdirs for cascade models
"""
import pathlib
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
# ---------------------------------------------------------------------------
# SUPPORTED_MODEL_TYPES and CASCADE_MODEL_TYPES
# ---------------------------------------------------------------------------
def test_supported_model_types_includes_all_wan22():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import SUPPORTED_MODEL_TYPES
assert "Wan2.2-T2V-A14B" in SUPPORTED_MODEL_TYPES
assert "Wan2.2-I2V-A14B" in SUPPORTED_MODEL_TYPES
def test_supported_model_types_includes_ti2v_5b():
"""MXFP8 supports TI2V-5B (contrast: MXFP4 explicitly excludes it)."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import SUPPORTED_MODEL_TYPES
assert "Wan2.2-TI2V-5B" in SUPPORTED_MODEL_TYPES
def test_cascade_model_types_excludes_ti2v():
"""TI2V-5B is a single-transformer model — must not be in CASCADE_MODEL_TYPES."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import CASCADE_MODEL_TYPES
assert "Wan2.2-TI2V-5B" not in CASCADE_MODEL_TYPES
assert "Wan2.2-T2V-A14B" in CASCADE_MODEL_TYPES
assert "Wan2.2-I2V-A14B" in CASCADE_MODEL_TYPES
# ---------------------------------------------------------------------------
# MXFP8_QUANT_CONFIG — auto-detection contract
# ---------------------------------------------------------------------------
def test_mxfp8_quant_config_has_required_keys():
"""MXFP8_QUANT_CONFIG must carry exactly the keys that auto-detection reads:
quant_method (selects DiffusionMXFP8Config) and is_checkpoint_mxfp8_serialized
(selects NPUMxfp8LinearMethod over the online path)."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import MXFP8_QUANT_CONFIG
assert MXFP8_QUANT_CONFIG["quant_method"] == "mxfp8"
assert MXFP8_QUANT_CONFIG["is_checkpoint_mxfp8_serialized"] is True
# ---------------------------------------------------------------------------
# _get_transformer_dirs
# ---------------------------------------------------------------------------
def test_get_transformer_dirs_cascade():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_transformer_dirs
assert _get_transformer_dirs("Wan2.2-T2V-A14B") == ["transformer", "transformer_2"]
assert _get_transformer_dirs("Wan2.2-I2V-A14B") == ["transformer", "transformer_2"]
def test_get_transformer_dirs_single():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_transformer_dirs
assert _get_transformer_dirs("Wan2.2-TI2V-5B") == ["transformer"]
# ---------------------------------------------------------------------------
# _get_quant_subdir
# ---------------------------------------------------------------------------
def test_get_quant_subdir_cascade_high_noise():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_quant_subdir
base = pathlib.Path("/quant")
result = _get_quant_subdir("Wan2.2-T2V-A14B", base, "transformer")
assert result == base / "high_noise_model"
def test_get_quant_subdir_cascade_low_noise():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_quant_subdir
base = pathlib.Path("/quant")
result = _get_quant_subdir("Wan2.2-T2V-A14B", base, "transformer_2")
assert result == base / "low_noise_model"
def test_get_quant_subdir_non_cascade():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_quant_subdir
base = pathlib.Path("/quant")
result = _get_quant_subdir("Wan2.2-TI2V-5B", base, "transformer")
assert result == base
# ---------------------------------------------------------------------------
# _remap_keys — msModelSlim naming → Diffusers naming
# ---------------------------------------------------------------------------
def test_remap_keys_self_attn_q():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {"blocks.0.self_attn.q.weight": torch.zeros(1)}
meta = {"blocks.0.self_attn.q.weight": "W8A8_MXFP8"}
new_state, new_meta = _remap_keys(state, meta)
assert "blocks.0.attn1.to_q.weight" in new_state
assert new_meta.get("blocks.0.attn1.to_q.weight") == "W8A8_MXFP8"
def test_remap_keys_self_attn_all_heads():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
pairs = {
"self_attn.q": "attn1.to_q",
"self_attn.k": "attn1.to_k",
"self_attn.v": "attn1.to_v",
"self_attn.o": "attn1.to_out.0",
}
for src_part, dst_part in pairs.items():
src_key = f"blocks.0.{src_part}.weight"
state = {src_key: torch.zeros(1)}
new_state, _ = _remap_keys(state, {})
expected = f"blocks.0.{dst_part}.weight"
assert expected in new_state, f"{src_key} → expected {expected}, got {list(new_state)}"
def test_remap_keys_ffn():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {
"blocks.1.ffn.0.weight": torch.zeros(1),
"blocks.1.ffn.2.weight": torch.zeros(1),
}
new_state, _ = _remap_keys(state, {})
assert "blocks.1.ffn.net.0.proj.weight" in new_state
assert "blocks.1.ffn.net.2.weight" in new_state
def test_remap_keys_norm_order_swap():
"""norm2↔norm3 swap: msModelSlim uses norm1/norm3/norm2 order,
Diffusers uses norm1/norm2/norm3."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {
"blocks.0.norm2.weight": torch.zeros(1),
"blocks.0.norm3.weight": torch.zeros(1),
}
new_state, _ = _remap_keys(state, {})
# norm2 → norm3 and norm3 → norm2
assert "blocks.0.norm3.weight" in new_state
assert "blocks.0.norm2.weight" in new_state
# Both must be present (swap, not collapse)
assert len([k for k in new_state if "norm" in k and "norm_q" not in k and "norm_k" not in k]) == 2
def test_remap_keys_head():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {"head.head.weight": torch.zeros(1)}
new_state, _ = _remap_keys(state, {})
assert "proj_out.weight" in new_state
def test_remap_keys_cross_attn():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {"blocks.0.cross_attn.q.weight": torch.zeros(1)}
new_state, _ = _remap_keys(state, {})
assert "blocks.0.attn2.to_q.weight" in new_state
def test_remap_keys_meta_only_mapped_for_existing_state_keys():
"""quant_meta entries are only emitted for keys present in state_dict."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {"blocks.0.self_attn.q.weight": torch.zeros(1)}
# meta has an extra key not in state_dict
meta = {
"blocks.0.self_attn.q.weight": "W8A8_MXFP8",
"blocks.0.self_attn.q.weight_scale": "W8A8_MXFP8",
}
_, new_meta = _remap_keys(state, meta)
assert "blocks.0.attn1.to_q.weight" in new_meta
# weight_scale was in meta but not state_dict → must be absent
assert "blocks.0.attn1.to_q.weight_scale" not in new_meta
def test_remap_keys_preserves_tensors():
"""Tensor values must survive the key rename unchanged."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
t = torch.randn(4, 8)
state = {"blocks.0.self_attn.q.weight": t}
new_state, _ = _remap_keys(state, {})
assert torch.equal(new_state["blocks.0.attn1.to_q.weight"], t)
@@ -109,6 +109,85 @@ def _generate_single_stage_image(
return images, peak_mem
def _generate_single_stage_video(
model: str,
quantization: str | None = None,
height: int = 256,
width: int = 256,
num_frames: int = 25,
num_inference_steps: int = 8,
guidance_scale: float = 4.0,
seed: int = 42,
prompt: str = "A serene lakeside sunrise with mist over the water",
**extra_omni_kwargs: Any,
) -> tuple[int, float]:
"""Generate a t2v output with a single-stage diffusion model.
Returns (num_frames_produced, peak_memory_gib)
"""
omni_kwargs: dict[str, Any] = dict(extra_omni_kwargs)
if quantization:
omni_kwargs["quantization"] = quantization
with OmniRunner(model, **omni_kwargs) as runner:
torch.accelerator.reset_peak_memory_stats()
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(seed)
outputs = runner.omni.generate(
{"prompt": prompt, "negative_prompt": ""},
OmniDiffusionSamplingParams(
height=height,
width=width,
num_frames=num_frames,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
generator=generator,
),
)
first = outputs[0]
# Unwrap pipeline-style outputs (multi-stage / OmniRequestOutput.request_output).
frames: Any = None
if hasattr(first, "request_output") and isinstance(first.request_output, list):
inner = first.request_output[0]
if isinstance(inner, OmniRequestOutput) and inner.images:
frames = inner.images[0]
if frames is None and hasattr(first, "images") and first.images:
frames = first.images[0]
assert frames is not None, "No video frames returned from generate()"
# LTX-2 (audio+video) may surface (video, audio) tuples or {"video": ...} dicts.
if isinstance(frames, dict):
frames = frames.get("video") or frames.get("frames")
elif isinstance(frames, tuple) and len(frames) == 2:
frames = frames[0]
assert frames is not None, "Could not extract video frames from output"
if isinstance(frames, torch.Tensor):
video = frames.detach().cpu()
if video.dim() == 5:
video = video[0]
if video.dim() == 4 and video.shape[0] in (3, 4):
video = video.permute(1, 2, 3, 0)
num_frames_produced = int(video.shape[0])
else:
import numpy as np
arr = np.asarray(frames)
if arr.ndim == 5:
arr = arr[0]
num_frames_produced = int(arr.shape[0])
peak_mem_mb = getattr(first, "peak_memory_mb", None)
if peak_mem_mb:
peak_mem = float(peak_mem_mb) / 1024.0
else:
peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)
return num_frames_produced, peak_mem
def _generate_bagel_image(
quantization_config: str | None = None,
num_inference_steps: int = 15,
@@ -277,6 +356,25 @@ def test_single_stage_flux_fp8_uses_less_memory():
assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)"
@hardware_test(res={"cuda": "H100"})
def test_single_stage_ltx2_fp8_uses_less_memory():
"""FP8 should use less peak memory than BF16 for LTX-2."""
_, mem_bf16 = _generate_single_stage_video(
model="Lightricks/LTX-2",
quantization=None,
)
torch.accelerator.empty_cache()
_, mem_fp8 = _generate_single_stage_video(
model="Lightricks/LTX-2",
quantization="fp8",
)
print(f"LTX-2 BF16 peak memory: {mem_bf16:.2f} GiB")
print(f"LTX-2 FP8 peak memory: {mem_fp8:.2f} GiB")
assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)"
# ─── Multi-stage model tests (BAGEL) ─────────────────────────────────────────
@@ -143,6 +143,18 @@ QUALITY_CONFIGS = [
seed=142,
num_inference_steps=20,
),
QualityTestConfig(
id="fp8_ltx2",
model="Lightricks/LTX-2",
quantization="fp8",
task="t2v",
prompt="A serene lakeside sunrise with mist over the water",
max_lpips=0.10,
height=256,
width=256,
num_frames=25,
num_inference_steps=8,
),
]
@@ -261,10 +273,19 @@ def _generate_video(omni, config: QualityTestConfig):
else:
frames = inner
elif hasattr(first, "images") and first.images:
frames = first.images
frames = first.images[0]
else:
raise ValueError("Could not extract video frames from output.")
# LTX-2 (audio+video) may surface (video, audio) tuples or {"video": ...} dicts
if isinstance(frames, dict):
frames = frames.get("video") or frames.get("frames")
elif isinstance(frames, tuple) and len(frames) == 2:
frames = frames[0]
if frames is None:
raise ValueError("Could not extract video frames from output.")
if isinstance(frames, torch.Tensor):
video = frames.detach().cpu()
if video.dim() == 5:
@@ -275,7 +296,11 @@ def _generate_video(omni, config: QualityTestConfig):
video = video.clamp(-1, 1) * 0.5 + 0.5
return video.float().numpy(), peak_mem
return np.asarray(frames), peak_mem
frames_array = np.asarray(frames)
if frames_array.ndim == 5:
# strip the leading batch dim
frames_array = frames_array[0]
return frames_array, peak_mem
def _compute_lpips(baseline, quantized, task: str) -> float:
@@ -318,8 +343,7 @@ def _compute_psnr_and_mae(baseline, quantized, task: str) -> tuple[float, float]
return psnr, mae
def _unload(omni):
del omni
def _free_gpu_memory():
gc.collect()
if torch.cuda.is_available():
torch.accelerator.empty_cache()
@@ -395,7 +419,9 @@ def test_quantization_quality(config: QualityTestConfig):
# --- BF16 baseline ---
omni_bl = Omni(model=config.baseline_ref())
baseline_out, bl_mem = generate_fn(omni_bl, config)
_unload(omni_bl)
omni_bl.shutdown()
del omni_bl
_free_gpu_memory()
_maybe_save_output(_OUTPUT_DIR, config, "baseline", baseline_out)
# --- Quantized ---
@@ -405,7 +431,9 @@ def test_quantization_quality(config: QualityTestConfig):
else:
omni_qt = Omni(model=config.quantized_ref(), quantization_config=quantization)
quant_out, qt_mem = generate_fn(omni_qt, config)
_unload(omni_qt)
omni_qt.shutdown()
del omni_qt
_free_gpu_memory()
_maybe_save_output(_OUTPUT_DIR, config, "quantized", quant_out)
# --- Similarity metrics ---
@@ -0,0 +1,126 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import math
import sys
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from PIL import Image
from vllm_omni.quantization.tools.compare_diffusion_trajectory_similarity import (
VariantRun,
_build_variant_config,
_request_peak_memory_mb,
_run_summary,
compute_tensor_metrics,
compute_uint8_image_metrics,
metric_guidance,
parse_args,
summarize_output_image_metrics,
)
def test_compute_tensor_metrics_identical_tensors():
metrics = compute_tensor_metrics(torch.ones(2, 3), torch.ones(2, 3))
assert metrics["cosine_similarity"] == pytest.approx(1.0)
assert metrics["mae"] == 0.0
assert metrics["mse"] == 0.0
assert metrics["rmse"] == 0.0
assert metrics["max_abs"] == 0.0
assert metrics["l2"] == 0.0
assert metrics["relative_l2"] == 0.0
def test_compute_uint8_image_metrics_adds_psnr():
lhs = np.zeros((2, 2, 3), dtype=np.uint8)
rhs = np.zeros((2, 2, 3), dtype=np.uint8)
metrics = compute_uint8_image_metrics(lhs, rhs)
assert math.isinf(metrics["psnr_db"])
def test_summarize_output_image_metrics_stacks_pil_images():
reference = [Image.fromarray(np.zeros((2, 2, 3), dtype=np.uint8))]
candidate = [Image.fromarray(np.ones((2, 2, 3), dtype=np.uint8))]
summary = summarize_output_image_metrics(reference, candidate)
assert summary["num_images"] == 1
assert summary["image0_metrics"]["mae"] == 1.0
assert summary["all_images_metrics"]["mse"] == 1.0
def test_run_summary_reports_worker_peak_memory():
summary = _run_summary(
VariantRun(
label="candidate",
result=object(),
generation_times_s=[1.0, 3.0],
peak_memory_mb=[100.0, 150.0],
)
)
assert summary["peak_memory_mb"] == 150.0
assert summary["avg_peak_memory_mb"] == 125.0
assert summary["max_peak_memory_mb"] == 150.0
assert summary["per_run_peak_memory_mb"] == [100.0, 150.0]
def test_request_peak_memory_prefers_inner_diffusion_output():
inner = type("InnerOutput", (), {"peak_memory_mb": 321.0})()
outer = type("OuterOutput", (), {"request_output": inner, "peak_memory_mb": 123.0})()
assert _request_peak_memory_mb(outer) == 321.0
def test_metric_guidance_describes_thresholds():
guidance = metric_guidance()
assert "cosine_similarity" in guidance["descriptions"]
assert guidance["recommended_thresholds"]["output_images_or_frames_uint8"]["psnr_db"]["recommended_min"] == 20.0
assert (
guidance["recommended_thresholds"]["performance"]["max_peak_memory_ratio_candidate_over_reference"][
"recommended_max"
]
== 1.00
)
def test_candidate_model_can_point_to_offline_checkpoint_without_online_quantization():
args = SimpleNamespace(
model="Qwen/Qwen-Image",
candidate_model="Qwen/Qwen-Image-FP8",
candidate_quantization=None,
candidate_quantization_config_json=None,
candidate_ignored_layers=None,
ignored_layers=None,
candidate_gguf_model=None,
candidate_load_format="default",
)
config = _build_variant_config(args, "candidate")
assert config.model == "Qwen/Qwen-Image-FP8"
assert config.quantization is None
assert config.quantization_config is None
def test_step_execution_defaults_to_false(monkeypatch):
monkeypatch.setattr(
sys,
"argv",
[
"compare_diffusion_trajectory_similarity.py",
"--output-json",
"result.json",
],
)
args = parse_args()
assert args.step_execution is False
@@ -136,6 +136,7 @@ def _make_engine_with_loop(
calls and a real ``RequestScheduler``.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._closed = False
engine.executor = _ConcurrencyTrackingExecutor(rpc_delay=rpc_delay)
sched = RequestScheduler()
@@ -371,6 +372,7 @@ def test_collective_rpc_before_loop_starts_calls_executor_directly():
on the caller's thread without enqueueing.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._closed = False
engine._loop_started = False
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
@@ -391,6 +393,7 @@ def test_collective_rpc_before_loop_starts_serializes_concurrent_callers():
serialize them so they cannot race on the shared executor MQ pair.
"""
engine = DiffusionEngine.__new__(DiffusionEngine)
engine._closed = False
engine._loop_started = False
engine._rpc_lock = threading.RLock()
engine._cv = threading.Condition(engine._rpc_lock)
+156
View File
@@ -153,6 +153,54 @@ class _StubScheduler(SchedulerInterface):
return None
class TestGetSamplingParamsKey:
"""Pure-function tests for the batch-compatibility key builder."""
@staticmethod
def _make(lora_int_id: int | None = None, lora_scale: float = 1.0) -> OmniDiffusionRequest:
from vllm_omni.lora.request import LoRARequest
sp = OmniDiffusionSamplingParams(num_inference_steps=2)
if lora_int_id is not None:
sp.lora_request = LoRARequest(
lora_name=f"adapter-{lora_int_id}",
lora_int_id=lora_int_id,
lora_path=f"/tmp/lora-{lora_int_id}",
)
sp.lora_scale = lora_scale
return OmniDiffusionRequest(
prompts=["prompt"],
sampling_params=sp,
request_ids=[f"req-{lora_int_id}-{lora_scale}"],
)
def test_distinguishes_lora_id(self) -> None:
from vllm_omni.diffusion.sched.base_scheduler import get_sampling_params_key
assert get_sampling_params_key(self._make(lora_int_id=1)) != get_sampling_params_key(self._make(lora_int_id=2))
def test_distinguishes_lora_scale(self) -> None:
from vllm_omni.diffusion.sched.base_scheduler import get_sampling_params_key
assert get_sampling_params_key(self._make(lora_int_id=1, lora_scale=0.5)) != get_sampling_params_key(
self._make(lora_int_id=1, lora_scale=1.0)
)
def test_treats_no_lora_as_distinct_bucket(self) -> None:
from vllm_omni.diffusion.sched.base_scheduler import get_sampling_params_key
assert get_sampling_params_key(self._make(lora_int_id=None)) != get_sampling_params_key(
self._make(lora_int_id=1)
)
def test_equal_for_same_lora_identity(self) -> None:
from vllm_omni.diffusion.sched.base_scheduler import get_sampling_params_key
a = get_sampling_params_key(self._make(lora_int_id=1, lora_scale=0.5))
b = get_sampling_params_key(self._make(lora_int_id=1, lora_scale=0.5))
assert a == b
class TestRequestScheduler:
def setup_method(self) -> None:
self.scheduler: RequestScheduler = RequestScheduler()
@@ -728,6 +776,114 @@ class TestStepScheduler:
assert second.num_running_reqs == 1
assert second.num_waiting_reqs == 1
def test_step_batch_co_schedules_requests_sharing_lora(self) -> None:
"""Multiple requests with the same LoRA (id + scale) co-batch."""
from vllm_omni.lora.request import LoRARequest
scheduler = StepScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=3))
lora = LoRARequest(lora_name="adapter", lora_int_id=42, lora_path="/tmp/lora")
def _with_lora(req_id: str) -> OmniDiffusionRequest:
sp = OmniDiffusionSamplingParams(num_inference_steps=4)
sp.lora_request = lora
sp.lora_scale = 0.5
return _make_step_request(req_id, sampling_params=sp)
req_a = scheduler.add_request(_with_lora("a"))
req_b = scheduler.add_request(_with_lora("b"))
req_c = scheduler.add_request(_with_lora("c"))
sched_output = scheduler.schedule()
assert _new_ids(sched_output) == [req_a, req_b, req_c]
assert sched_output.num_running_reqs == 3
assert sched_output.num_waiting_reqs == 0
def test_step_batch_separates_requests_with_different_lora_ids(self) -> None:
"""Different LoRA adapters → distinct batches admitted in FIFO order."""
from vllm_omni.lora.request import LoRARequest
scheduler = StepScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=4))
lora_a = LoRARequest(lora_name="adapter-A", lora_int_id=1, lora_path="/tmp/lora-a")
lora_b = LoRARequest(lora_name="adapter-B", lora_int_id=2, lora_path="/tmp/lora-b")
def _build(req_id: str, lora: LoRARequest) -> OmniDiffusionRequest:
sp = OmniDiffusionSamplingParams(num_inference_steps=2)
sp.lora_request = lora
return _make_step_request(req_id, sampling_params=sp)
req_a1 = scheduler.add_request(_build("a1", lora_a))
req_b1 = scheduler.add_request(_build("b1", lora_b))
req_a2 = scheduler.add_request(_build("a2", lora_a))
# Strict FIFO admission: a1 starts; b1 (different LoRA) blocks the
# queue head, so a2 (compatible with a1) is *not* skipped ahead.
first = scheduler.schedule()
assert _new_ids(first) == [req_a1]
assert first.num_waiting_reqs == 2
# Drain a1 → b1 becomes head-of-line and is admitted with its LoRA.
scheduler.update_from_output(first, _make_step_output(req_a1, step_index=2, finished=True))
second = scheduler.schedule()
assert _new_ids(second) == [req_b1]
assert second.num_waiting_reqs == 1
# Drain b1 → a2 is admitted next; LoRA-A is re-activated for it.
scheduler.update_from_output(second, _make_step_output(req_b1, step_index=2, finished=True))
third = scheduler.schedule()
assert _new_ids(third) == [req_a2]
assert third.num_waiting_reqs == 0
def test_step_batch_separates_requests_with_different_lora_scale(self) -> None:
"""Same adapter id but different scales → still separate batches."""
from vllm_omni.lora.request import LoRARequest
scheduler = StepScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=4))
lora = LoRARequest(lora_name="adapter", lora_int_id=7, lora_path="/tmp/lora")
def _build(req_id: str, scale: float) -> OmniDiffusionRequest:
sp = OmniDiffusionSamplingParams(num_inference_steps=2)
sp.lora_request = lora
sp.lora_scale = scale
return _make_step_request(req_id, sampling_params=sp)
req_full = scheduler.add_request(_build("full", 1.0))
req_half = scheduler.add_request(_build("half", 0.5))
sched_output = scheduler.schedule()
admitted = _new_ids(sched_output)
assert admitted == [req_full]
assert req_half not in admitted
assert sched_output.num_waiting_reqs == 1
def test_step_batch_separates_lora_from_no_lora(self) -> None:
"""A LoRA request and a no-LoRA request do not share a batch."""
from vllm_omni.lora.request import LoRARequest
scheduler = StepScheduler()
scheduler.initialize(SimpleNamespace(max_num_seqs=4))
lora = LoRARequest(lora_name="adapter", lora_int_id=3, lora_path="/tmp/lora")
sp_with = OmniDiffusionSamplingParams(num_inference_steps=2)
sp_with.lora_request = lora
req_with = scheduler.add_request(_make_step_request("with", sampling_params=sp_with))
req_without = scheduler.add_request(_make_step_request("without", num_inference_steps=2))
sched_output = scheduler.schedule()
admitted = _new_ids(sched_output)
assert admitted == [req_with]
assert req_without not in admitted
assert sched_output.num_waiting_reqs == 1
def test_preempt_request_preserves_step_index(self) -> None:
request = _make_step_request("preempt", num_inference_steps=3)
req_id = self.scheduler.add_request(request)
+118 -48
View File
@@ -285,6 +285,19 @@ def _make_scheduler_output(req, sched_req_id="req-1", step_id=0, finished_req_id
)
def _make_batch_scheduler_output(reqs, *, step_id=0, finished_req_ids=None):
"""Scheduler output for a homogeneous batch (one NewRequestData per req)."""
new_reqs = [NewRequestData(sched_req_id=r.request_ids[0], req=r) for r in reqs]
return DiffusionSchedulerOutput(
step_id=step_id,
scheduled_new_reqs=new_reqs,
scheduled_cached_reqs=CachedRequestData.make_empty(),
finished_req_ids=set() if finished_req_ids is None else set(finished_req_ids),
num_running_reqs=len(new_reqs),
num_waiting_reqs=0,
)
def _make_cached_scheduler_output(sched_req_id="req-1", step_id=1, finished_req_ids=None):
return DiffusionSchedulerOutput(
step_id=step_id,
@@ -489,71 +502,128 @@ class TestRunner:
DiffusionModelRunner.load_model(runner)
class _RecordingLoRAManager:
def __init__(self) -> None:
self.calls: list[tuple[object | None, float]] = []
def set_active_adapter(self, adapter, scale: float = 1.0) -> None:
self.calls.append((adapter, scale))
def _make_step_worker(lora_manager=None, *, expected_output=None):
"""Build a bare DiffusionWorker primed for execute_stepwise tests."""
worker = object.__new__(DiffusionWorker)
worker.lora_manager = lora_manager
worker._step_lora_state = {}
output = expected_output if expected_output is not None else RunnerOutput(req_id="req-1")
worker.model_runner = SimpleNamespace(execute_stepwise=lambda arg: output)
return worker
@pytest.mark.cpu
class TestWorker:
"""DiffusionWorker.execute_stepwise"""
def test_delegates_to_model_runner(self):
worker = object.__new__(DiffusionWorker)
expected = RunnerOutput(req_id="req-1", step_index=1, finished=False, result=None)
scheduler_output = SimpleNamespace(
scheduled_new_reqs=[
SimpleNamespace(
req=SimpleNamespace(
sampling_params=SimpleNamespace(lora_request=None),
)
)
]
)
worker.lora_manager = None
worker.model_runner = SimpleNamespace(
execute_stepwise=lambda arg: expected if arg is scheduler_output else None
)
worker = _make_step_worker(expected_output=expected)
scheduler_output = _make_scheduler_output(_make_engine_request("req-1"), sched_req_id="req-1")
output = DiffusionWorker.execute_stepwise(worker, scheduler_output)
assert output is expected
def test_clears_active_lora_before_stepwise_execution(self):
worker = object.__new__(DiffusionWorker)
scheduler_output = SimpleNamespace(
scheduled_new_reqs=[
SimpleNamespace(
req=SimpleNamespace(
sampling_params=SimpleNamespace(lora_request=None),
)
)
]
)
calls: list[object | None] = []
class _FakeLoRAManager:
def set_active_adapter(self, adapter):
calls.append(adapter)
worker.lora_manager = _FakeLoRAManager()
worker.model_runner = SimpleNamespace(execute_stepwise=lambda arg: RunnerOutput(req_id="req-1"))
def test_deactivates_lora_when_request_has_no_adapter(self):
manager = _RecordingLoRAManager()
worker = _make_step_worker(lora_manager=manager)
scheduler_output = _make_scheduler_output(_make_engine_request("req-1"), sched_req_id="req-1")
DiffusionWorker.execute_stepwise(worker, scheduler_output)
assert calls == [None]
assert manager.calls == [(None, 1.0)]
def test_rejects_lora_requests_in_step_mode(self):
worker = object.__new__(DiffusionWorker)
scheduler_output = SimpleNamespace(
scheduled_new_reqs=[
SimpleNamespace(
req=SimpleNamespace(
sampling_params=SimpleNamespace(lora_request=object()),
)
)
]
def test_activates_lora_for_step_requests(self):
from vllm_omni.lora.request import LoRARequest
lora_request = LoRARequest(lora_name="adapter", lora_int_id=7, lora_path="/tmp/lora")
request = _make_engine_request("req-1")
request.sampling_params.lora_request = lora_request
request.sampling_params.lora_scale = 0.75
manager = _RecordingLoRAManager()
worker = _make_step_worker(lora_manager=manager)
scheduler_output = _make_scheduler_output(request, sched_req_id="req-1")
DiffusionWorker.execute_stepwise(worker, scheduler_output)
assert manager.calls == [(lora_request, 0.75)]
def test_recovers_lora_for_cached_step_requests(self):
from vllm_omni.lora.request import LoRARequest
lora_request = LoRARequest(lora_name="adapter", lora_int_id=11, lora_path="/tmp/lora")
request = _make_engine_request("req-1")
request.sampling_params.lora_request = lora_request
request.sampling_params.lora_scale = 0.5
manager = _RecordingLoRAManager()
worker = _make_step_worker(lora_manager=manager)
first = _make_scheduler_output(request, sched_req_id="req-1")
second = _make_cached_scheduler_output(sched_req_id="req-1", step_id=1)
DiffusionWorker.execute_stepwise(worker, first)
DiffusionWorker.execute_stepwise(worker, second)
assert manager.calls == [(lora_request, 0.5), (lora_request, 0.5)]
def test_activates_single_lora_for_homogeneous_batch(self):
"""Multiple requests sharing the same LoRA → exactly one activation,
and every request id is registered in ``_step_lora_state``."""
from vllm_omni.lora.request import LoRARequest
lora_request = LoRARequest(lora_name="adapter", lora_int_id=9, lora_path="/tmp/lora")
reqs = []
for rid in ("req-1", "req-2", "req-3"):
r = _make_engine_request(rid)
r.sampling_params.lora_request = lora_request
r.sampling_params.lora_scale = 0.6
reqs.append(r)
manager = _RecordingLoRAManager()
worker = _make_step_worker(lora_manager=manager)
scheduler_output = _make_batch_scheduler_output(reqs)
DiffusionWorker.execute_stepwise(worker, scheduler_output)
assert manager.calls == [(lora_request, 0.6)]
assert set(worker._step_lora_state) == {"req-1", "req-2", "req-3"}
for entry in worker._step_lora_state.values():
assert entry == (lora_request, 0.6)
def test_evicts_step_lora_state_for_finished_requests(self):
from vllm_omni.lora.request import LoRARequest
lora_request = LoRARequest(lora_name="adapter", lora_int_id=3, lora_path="/tmp/lora")
finishing = _make_engine_request("req-1")
finishing.sampling_params.lora_request = lora_request
next_request = _make_engine_request("req-2")
next_request.sampling_params.lora_request = lora_request
worker = _make_step_worker(lora_manager=_RecordingLoRAManager())
first = _make_scheduler_output(finishing, sched_req_id="req-1")
next_batch = _make_scheduler_output(
next_request,
sched_req_id="req-2",
step_id=1,
finished_req_ids={"req-1"},
)
worker.lora_manager = None
worker.model_runner = SimpleNamespace(execute_stepwise=lambda arg: RunnerOutput(req_id="req-1"))
with pytest.raises(ValueError, match="does not support LoRA"):
DiffusionWorker.execute_stepwise(worker, scheduler_output)
DiffusionWorker.execute_stepwise(worker, first)
assert "req-1" in worker._step_lora_state
DiffusionWorker.execute_stepwise(worker, next_batch)
assert "req-1" not in worker._step_lora_state
assert worker._step_lora_state == {"req-2": (lora_request, 1.0)}
@pytest.mark.cpu
@@ -15,8 +15,9 @@ from vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_c
BufferAllocator,
ManagedBuffer,
)
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import KVCacheTransferData
# All tests in this file are pure-CPU unit tests for the memory allocator.
# Most tests in this file are pure-CPU unit tests; CUDA smoke tests are skip-guarded.
pytestmark = [pytest.mark.cpu, pytest.mark.parallel, pytest.mark.core_model]
@@ -225,3 +226,70 @@ class TestManagedBuffer:
# Space should be reusable
new_offset = self.allocator.alloc(128)
assert new_offset == offset
def test_to_bytes_cpu_pool(self):
"""Smoke-test Mooncake's ManagedBuffer byte extraction on CPU pools."""
expected = bytes(range(64))
offset = self.allocator.alloc(len(expected))
buf = ManagedBuffer(self.allocator, offset, len(expected), self.pool)
self.pool[offset : offset + len(expected)] = torch.tensor(list(expected), dtype=torch.uint8)
try:
assert buf.to_bytes() == expected
finally:
buf.release()
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for GPU pool smoke test")
def test_to_bytes_cuda_pool(self):
"""Smoke-test Mooncake's existing CUDA pool D2H byte path."""
expected = bytes(range(64))
allocator = BufferAllocator(total_size=4096, alignment=64)
pool = torch.zeros(4096, dtype=torch.uint8, device="cuda:0")
offset = allocator.alloc(len(expected))
buf = ManagedBuffer(allocator, offset, len(expected), pool)
pool[offset : offset + len(expected)].copy_(torch.tensor(list(expected), dtype=torch.uint8, device="cuda:0"))
try:
assert buf.to_bytes() == expected
finally:
buf.release()
def _make_kv_payload(device: str | torch.device = "cpu") -> tuple[KVCacheTransferData, torch.Tensor, torch.Tensor]:
key_tensor = torch.arange(12, dtype=torch.float32, device=device).reshape(3, 4)
value_tensor = key_tensor + 100
payload = KVCacheTransferData(
request_id="moon-smoke",
layer_blocks={"key_cache": [key_tensor], "value_cache": [value_tensor]},
block_ids=[1],
metadata={"seq_len": 3},
)
return payload, key_tensor, value_tensor
class TestMooncakePackedPayloadSmoke:
"""Smoke tests for Mooncake raw payload helpers without real Mooncake/RDMA."""
def test_load_header_from_cpu_tensor(self):
payload, _, _ = _make_kv_payload()
raw = payload.to_bytes()
packed = torch.tensor(list(raw), dtype=torch.uint8)
header, data_start = KVCacheTransferData._load_header_from_tensor(packed)
assert header["rid"] == "moon-smoke"
assert data_start == 4 + int.from_bytes(raw[:4], "big")
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for packed CUDA smoke test")
def test_from_bytes_device_cuda_round_trip(self):
payload, key_tensor, value_tensor = _make_kv_payload(device="cuda:0")
packed = payload.to_gpu_tensor()
header, data_start = KVCacheTransferData._load_header_from_tensor(packed)
decoded = KVCacheTransferData.from_bytes_device(packed)
assert header["rid"] == "moon-smoke"
assert data_start == 4 + int.from_bytes(payload.to_bytes()[:4], "big")
assert decoded["layer_blocks"]["key_cache"][0].is_cuda
assert decoded["layer_blocks"]["value_cache"][0].is_cuda
assert torch.equal(decoded["layer_blocks"]["key_cache"][0].cpu(), key_tensor.cpu())
assert torch.equal(decoded["layer_blocks"]["value_cache"][0].cpu(), value_tensor.cpu())
@@ -6,18 +6,18 @@ from time import time
import pytest
from vllm_omni.distributed.omni_coordinator import (
InstanceInfo,
LeastQueueLengthBalancer,
RandomBalancer,
ReplicaInfo,
ReplicaStatus,
RoundRobinBalancer,
StageStatus,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_load_balancer_select_returns_valid_index():
"""Verify RandomBalancer.select() returns a valid index for instances."""
"""Verify RandomBalancer.select() returns a valid index for replicas."""
# Task structure mirrors async_omni; RandomBalancer ignores task contents.
task: dict = {
"request_id": "test",
@@ -26,30 +26,30 @@ def test_load_balancer_select_returns_valid_index():
}
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=1,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=2,
last_heartbeat=now,
registered_at=now,
@@ -58,38 +58,38 @@ def test_load_balancer_select_returns_valid_index():
balancer = RandomBalancer()
index = balancer.select(task, instances)
index = balancer.select(task, replicas)
assert isinstance(index, int)
assert 0 <= index < len(instances)
assert 0 <= index < len(replicas)
def test_round_robin_balancer_cycles_instances():
def test_round_robin_balancer_cycles_replicas():
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=2,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=1,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
@@ -97,35 +97,35 @@ def test_round_robin_balancer_cycles_instances():
]
balancer = RoundRobinBalancer()
results = [balancer.select({}, instances) for _ in range(5)]
results = [balancer.select({}, replicas) for _ in range(5)]
# Default start_index=0 => 0,1,2,0,1
assert results == [0, 1, 2, 0, 1]
def test_round_robin_balancer_empty_instances_raises():
with pytest.raises(ValueError, match="instances must not be empty"):
def test_round_robin_balancer_empty_replicas_raises():
with pytest.raises(ValueError, match="replicas must not be empty"):
RoundRobinBalancer().select({}, [])
def test_round_robin_balancer_after_large_index_and_shorter_list():
"""Large start_index % len(instances) then counter wraps with shorter list."""
"""Large start_index % len(replicas) then counter wraps with shorter list."""
now = time()
two = [
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
@@ -138,30 +138,30 @@ def test_round_robin_balancer_after_large_index_and_shorter_list():
def test_least_queue_length_balancer_picks_min_queue():
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=2,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=5,
last_heartbeat=now,
registered_at=now,
@@ -169,41 +169,41 @@ def test_least_queue_length_balancer_picks_min_queue():
]
balancer = LeastQueueLengthBalancer()
index = balancer.select({}, instances)
index = balancer.select({}, replicas)
assert index == 1
def test_least_queue_length_balancer_empty_instances_raises():
with pytest.raises(ValueError, match="instances must not be empty"):
def test_least_queue_length_balancer_empty_replicas_raises():
with pytest.raises(ValueError, match="replicas must not be empty"):
LeastQueueLengthBalancer().select({}, [])
def test_least_queue_length_balancer_equal_queues_uses_choice(mocker):
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
ReplicaInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
@@ -214,21 +214,21 @@ def test_least_queue_length_balancer_equal_queues_uses_choice(mocker):
"vllm_omni.distributed.omni_coordinator.load_balancer.random.choice",
return_value=2,
)
assert balancer.select({}, instances) == 2
assert balancer.select({}, replicas) == 2
def test_least_queue_length_balancer_negative_queue_raises():
now = time()
instances = [
InstanceInfo(
replicas = [
ReplicaInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
status=ReplicaStatus.UP,
queue_length=-1,
last_heartbeat=now,
registered_at=now,
),
]
with pytest.raises(ValueError, match="queue_length must be non-negative"):
LeastQueueLengthBalancer().select({}, instances)
LeastQueueLengthBalancer().select({}, replicas)
@@ -8,8 +8,8 @@ import pytest
import zmq
from vllm_omni.distributed.omni_coordinator import (
InstanceList,
OmniCoordClientForHub,
ReplicaList,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -32,8 +32,8 @@ def _wait_for_condition(cond, timeout: float = 2.0, interval: float = 0.01) -> b
return False
def test_hub_client_caches_instance_list_from_pub():
"""Verify OmniCoordClientForHub receives instance list updates from OmniCoordinator and caches for get_instance_list()."""
def test_hub_client_caches_replica_list_from_pub():
"""Verify OmniCoordClientForHub receives replica list updates from OmniCoordinator and caches for get_replica_list()."""
ctx, pub, endpoint = _bind_pub()
client = OmniCoordClientForHub(endpoint)
@@ -41,7 +41,7 @@ def test_hub_client_caches_instance_list_from_pub():
time.sleep(0.2)
now = time.time()
instances_payload = [
replicas_payload = [
{
"input_addr": "tcp://stage:10001",
"output_addr": "tcp://stage:10001-out",
@@ -71,37 +71,37 @@ def test_hub_client_caches_instance_list_from_pub():
},
]
payload = {"instances": instances_payload, "timestamp": now}
payload = {"replicas": replicas_payload, "timestamp": now}
pub.send(json.dumps(payload).encode("utf-8"))
assert _wait_for_condition(lambda: len(client.get_instance_list().instances) == 3)
assert _wait_for_condition(lambda: len(client.get_replica_list().replicas) == 3)
inst_list = client.get_instance_list()
assert isinstance(inst_list, InstanceList)
assert len(inst_list.instances) == 3
rep_list = client.get_replica_list()
assert isinstance(rep_list, ReplicaList)
assert len(rep_list.replicas) == 3
for src, inst in zip(instances_payload, inst_list.instances, strict=True):
assert inst.input_addr == src["input_addr"]
assert inst.output_addr == src["output_addr"]
assert inst.stage_id == src["stage_id"]
assert inst.status.value == src["status"]
for src, rep in zip(replicas_payload, rep_list.replicas, strict=True):
assert rep.input_addr == src["input_addr"]
assert rep.output_addr == src["output_addr"]
assert rep.stage_id == src["stage_id"]
assert rep.status.value == src["status"]
stage0 = client.get_instances_for_stage(0)
stage1 = client.get_instances_for_stage(1)
stage0 = client.get_replicas_for_stage(0)
stage1 = client.get_replicas_for_stage(1)
assert all(inst.stage_id == 0 for inst in stage0.instances)
assert all(inst.stage_id == 1 for inst in stage1.instances)
assert all(rep.stage_id == 0 for rep in stage0.replicas)
assert all(rep.stage_id == 1 for rep in stage1.replicas)
# Send an updated list with fewer instances and verify cache refresh.
# Send an updated list with fewer replicas and verify cache refresh.
updated_payload = {
"instances": instances_payload[:2],
"replicas": replicas_payload[:2],
"timestamp": now + 1.0,
}
pub.send(json.dumps(updated_payload).encode("utf-8"))
assert _wait_for_condition(lambda: len(client.get_instance_list().instances) == 2)
updated_list = client.get_instance_list()
assert len(updated_list.instances) == 2
assert _wait_for_condition(lambda: len(client.get_replica_list().replicas) == 2)
updated_list = client.get_replica_list()
assert len(updated_list.replicas) == 2
client.close()
pub.close(0)
@@ -9,7 +9,7 @@ import zmq
from vllm_omni.distributed.omni_coordinator import (
OmniCoordClientForStage,
StageStatus,
ReplicaStatus,
)
from vllm_omni.distributed.omni_coordinator import (
omni_coord_client_for_stage as stage_client_module,
@@ -47,7 +47,7 @@ def test_stage_client_auto_register_on_init():
event = _recv_event(router)
assert event["event_type"] == "update"
assert event["status"] == StageStatus.UP.value
assert event["status"] == ReplicaStatus.UP.value
assert event["stage_id"] == stage_id
assert event["input_addr"] == input_addr
assert event["output_addr"] == output_addr
@@ -70,13 +70,13 @@ def test_stage_client_update_info_sends_correct_event():
# Discard initial registration event.
_recv_event(router)
client.update_info(status=StageStatus.ERROR)
client.update_info(status=ReplicaStatus.ERROR)
client.update_info(queue_length=10)
first = _recv_event(router)
second = _recv_event(router)
assert first["status"] == StageStatus.ERROR.value
assert first["status"] == ReplicaStatus.ERROR.value
assert first["stage_id"] == stage_id
assert first["input_addr"] == input_addr
assert first["output_addr"] == output_addr
@@ -107,7 +107,7 @@ def test_stage_client_close_sends_down_status():
client.close()
event = _recv_event(router)
assert event["status"] == StageStatus.DOWN.value
assert event["status"] == ReplicaStatus.DOWN.value
assert event["stage_id"] == stage_id
assert event["input_addr"] == input_addr
assert event["output_addr"] == output_addr
@@ -274,6 +274,7 @@ def test_heartbeat_loop_retries_after_transient_send_failure():
client._closed = False
client._heartbeat_interval = 0.0
client._stop_event = _FakeStopEvent()
client._on_heartbeat = None
calls = {"count": 0}
@@ -11,14 +11,14 @@ from vllm.v1.utils import get_engine_client_zmq_addr
from vllm_omni.distributed.omni_coordinator import (
OmniCoordClientForStage,
OmniCoordinator,
StageStatus,
ReplicaStatus,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _recv_instance_list(sub: zmq.Socket, timeout_ms: int = 2000) -> dict | None:
"""Receive InstanceList JSON from SUB socket. Returns None on timeout."""
def _recv_replica_list(sub: zmq.Socket, timeout_ms: int = 2000) -> dict | None:
"""Receive ReplicaList JSON from SUB socket. Returns None on timeout."""
sub.setsockopt(zmq.RCVTIMEO, timeout_ms)
try:
data = sub.recv()
@@ -27,16 +27,16 @@ def _recv_instance_list(sub: zmq.Socket, timeout_ms: int = 2000) -> dict | None:
return None
def _wait_for_instance_list(
def _wait_for_replica_list(
sub: zmq.Socket,
expected_count: int,
timeout: float = 3.0,
) -> dict | None:
"""Wait until received InstanceList with expected_count active instances."""
"""Wait until received ReplicaList with expected_count active replicas."""
start = time.time()
while time.time() - start < timeout:
msg = _recv_instance_list(sub, timeout_ms=500)
if msg is not None and len(msg.get("instances", [])) == expected_count:
msg = _recv_replica_list(sub, timeout_ms=500)
if msg is not None and len(msg.get("replicas", [])) == expected_count:
return msg
return None
@@ -45,7 +45,7 @@ def _drain_sub_messages(sub: zmq.Socket, max_seconds: float = 0.4) -> None:
"""Drain queued SUB messages for a short window."""
deadline = time.time() + max_seconds
while time.time() < deadline:
_recv_instance_list(sub, timeout_ms=50)
_recv_replica_list(sub, timeout_ms=50)
def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
@@ -81,7 +81,7 @@ def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
)
# Wait for initial registration broadcast and clear any queued messages.
msg = _wait_for_instance_list(sub, expected_count=1)
msg = _wait_for_replica_list(sub, expected_count=1)
assert msg is not None
_drain_sub_messages(sub)
@@ -96,7 +96,7 @@ def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
deadline = time.time() + window_s
recv_count = 0
while time.time() < deadline:
if _recv_instance_list(sub, timeout_ms=100) is not None:
if _recv_replica_list(sub, timeout_ms=100) is not None:
recv_count += 1
assert recv_count < update_count // 2, (
@@ -110,8 +110,8 @@ def test_omni_coordinator_pub_coalescing_on_rapid_queue_updates():
def test_omni_coordinator_registration_broadcast():
"""Verify that after multiple OmniCoordClientForStage instances register,
OmniCoordinator publishes an InstanceList containing all registered instances.
"""Verify that after multiple OmniCoordClientForStage replicas register,
OmniCoordinator publishes a ReplicaList containing all registered replicas.
"""
router_addr = get_engine_client_zmq_addr(
local_only=False,
@@ -144,12 +144,12 @@ def test_omni_coordinator_registration_broadcast():
OmniCoordClientForStage(router_addr, "tcp://stage:10003", "tcp://stage:10003-out", 1),
]
msg = _wait_for_instance_list(sub, expected_count=3)
assert msg is not None, "Expected InstanceList with 3 instances"
assert len(msg["instances"]) == 3
msg = _wait_for_replica_list(sub, expected_count=3)
assert msg is not None, "Expected ReplicaList with 3 replicas"
assert len(msg["replicas"]) == 3
assert isinstance(msg["timestamp"], (int, float))
input_addrs = {inst["input_addr"] for inst in msg["instances"]}
input_addrs = {rep["input_addr"] for rep in msg["replicas"]}
assert "tcp://stage:10001" in input_addrs
assert "tcp://stage:10002" in input_addrs
assert "tcp://stage:10003" in input_addrs
@@ -162,7 +162,7 @@ def test_omni_coordinator_registration_broadcast():
def test_omni_coordinator_heartbeat_timeout_handling():
"""Verify that when a stage instance stops sending heartbeats,
"""Verify that when a stage replica stops sending heartbeats,
OmniCoordinator marks it as unhealthy and excludes it from the active list.
"""
router_addr = get_engine_client_zmq_addr(
@@ -201,23 +201,23 @@ def test_omni_coordinator_heartbeat_timeout_handling():
"output_addr": "tcp://stage:c-out",
"stage_id": 0,
"event_type": "update",
"status": StageStatus.UP.value,
"status": ReplicaStatus.UP.value,
"queue_length": 0,
}
dealer_c.send(json.dumps(reg_event).encode("utf-8"))
msg = _wait_for_instance_list(sub, expected_count=3)
assert msg is not None, "Expected initial 3 instances"
assert len(msg["instances"]) == 3
msg = _wait_for_replica_list(sub, expected_count=3)
assert msg is not None, "Expected initial 3 replicas"
assert len(msg["replicas"]) == 3
# Wait for heartbeat timeout (timeout=5s, check interval ~2.5s).
time.sleep(8.0)
# Receive the update (C should be ERROR and excluded from active list).
msg_after_timeout = _wait_for_instance_list(sub, expected_count=2, timeout=5.0)
assert msg_after_timeout is not None, "Expected InstanceList with 2 instances after timeout"
instances = msg_after_timeout.get("instances", [])
input_addrs = {inst["input_addr"] for inst in instances}
msg_after_timeout = _wait_for_replica_list(sub, expected_count=2, timeout=5.0)
assert msg_after_timeout is not None, "Expected ReplicaList with 2 replicas after timeout"
replicas = msg_after_timeout.get("replicas", [])
input_addrs = {rep["input_addr"] for rep in replicas}
assert "tcp://stage:a" in input_addrs
assert "tcp://stage:b" in input_addrs
@@ -232,8 +232,8 @@ def test_omni_coordinator_heartbeat_timeout_handling():
sub_ctx.term()
def test_omni_coordinator_instance_shutdown_handling():
"""Verify that when a stage instance sends status='down',
def test_omni_coordinator_replica_shutdown_handling():
"""Verify that when a stage replica sends status='down',
OmniCoordinator removes it from the active list and broadcasts an updated list.
"""
router_addr = get_engine_client_zmq_addr(
@@ -261,18 +261,18 @@ def test_omni_coordinator_instance_shutdown_handling():
client = OmniCoordClientForStage(router_addr, "tcp://stage:shutdown", "tcp://stage:shutdown-out", 0)
msg = _wait_for_instance_list(sub, expected_count=1)
msg = _wait_for_replica_list(sub, expected_count=1)
assert msg is not None
assert len(msg["instances"]) == 1
assert msg["instances"][0]["input_addr"] == "tcp://stage:shutdown"
assert len(msg["replicas"]) == 1
assert msg["replicas"][0]["input_addr"] == "tcp://stage:shutdown"
# Send down status (simulating graceful shutdown).
client.update_info(status=StageStatus.DOWN)
client.update_info(status=ReplicaStatus.DOWN)
# Receive updated list (should have 0 active instances).
msg = _wait_for_instance_list(sub, expected_count=0)
# Receive updated list (should have 0 active replicas).
msg = _wait_for_replica_list(sub, expected_count=0)
assert msg is not None
assert len(msg["instances"]) == 0
assert len(msg["replicas"]) == 0
client.close()
coordinator.close()
+26 -9
View File
@@ -23,18 +23,31 @@ from tests.e2e.accuracy.helpers import (
download_images,
model_output_dir,
)
from tests.helpers.runtime import OmniRunner
from tests.helpers.mark import hardware_marks
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import build_prompt_tokens, resolve_stop_token_ids
os.environ["DIFFUSION_ATTENTION_BACKEND"] = "TORCH_SDPA"
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
pytestmark = [pytest.mark.local_model, pytest.mark.diffusion, *hardware_marks(res={"cuda": "H100"}, num_cards=8)]
# ============================================================================
# Configurable Parameters
# ============================================================================
AR_DEVICES = "0,1"
DIT_DEVICES = "2,3"
# Comma-separated logical CUDA device ids per stage: split visible GPUs (0..n-1), first half -> AR, second -> DiT.
def _default_ar_dit_devices() -> tuple[str, str]:
"""First floor(n/2) logical devices -> AR, rest -> DiT. ``device_count`` respects ``CUDA_VISIBLE_DEVICES``."""
n = torch.accelerator.device_count()
if n < 2:
return "0,1", "2,3"
split = n // 2
ar = ",".join(str(i) for i in range(split))
dit = ",".join(str(i) for i in range(split, n))
return ar, dit
AR_DEVICES, DIT_DEVICES = _default_ar_dit_devices()
MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct"
NUM_INFERENCE_STEPS = 50
GUIDANCE_SCALE = 2.5
@@ -75,7 +88,7 @@ _BASE_CONFIG = {
"scheduler_cls": "vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler",
"gpu_memory_utilization": 0.95, "enforce_eager": True, "trust_remote_code": True,
"engine_output_type": "latent", "enable_prefix_caching": False,
"max_num_batched_tokens": 32768, "tensor_parallel_size": 2, "pipeline_parallel_size": 1,
"max_num_batched_tokens": 32768, "tensor_parallel_size": AR_TP_SIZE, "pipeline_parallel_size": 1,
"hf_overrides": {"rope_parameters": {"mrope_section": [0, 32, 32], "rope_type": "default"}},
},
"is_comprehension": False, "final_output": True, "final_output_type": "text",
@@ -91,7 +104,7 @@ _BASE_CONFIG = {
"engine_args": {
"model_stage": "dit", "model_arch": "HunyuanImage3ForCausalMM",
"enforce_eager": True, "trust_remote_code": True, "distributed_executor_backend": "mp",
"parallel_config": {"tensor_parallel_size": 2, "enable_expert_parallel": True},
"parallel_config": {"tensor_parallel_size": DIT_TP_SIZE, "enable_expert_parallel": True},
},
"engine_input_source": [0],
"custom_process_input_func": "vllm_omni.model_executor.stage_input_processors.hunyuan_image3.ar2diffusion",
@@ -134,12 +147,13 @@ def _make_config(enable_kv_reuse: bool, path: Path) -> None:
def _run_offline(stage_configs_path: str, output_path: Path) -> tuple[Image.Image, str, float]:
from transformers import AutoTokenizer
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType
from vllm_omni.platforms import current_omni_platform
build_kwargs: dict = {"task": "it2i", "bot_task": "think_recaption", "sys_type": "en_unified", "num_images": 2}
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
result = build_prompt_tokens(
PROMPT,
tokenizer,
@@ -149,7 +163,7 @@ def _run_offline(stage_configs_path: str, output_path: Path) -> tuple[Image.Imag
system_prompt_type = result.system_prompt_type
ar_stop_token_ids = resolve_stop_token_ids(task="it2i", bot_task="think_recaption", tokenizer=tokenizer)
with OmniRunner(MODEL_NAME, stage_configs_path=stage_configs_path) as runner:
with OmniRunner(MODEL_PATH, stage_configs_path=stage_configs_path) as runner:
params_list = list(runner.omni.default_sampling_params_list)
for sp in params_list:
if isinstance(sp, OmniDiffusionSamplingParams):
@@ -206,7 +220,10 @@ def _run_offline(stage_configs_path: str, output_path: Path) -> tuple[Image.Imag
return image, cot_text, elapsed
@pytest.mark.skipif(torch.accelerator.device_count() < 4, reason="Needs 4+ GPUs (2 AR + 2 DiT)")
@pytest.mark.skipif(
torch.accelerator.device_count() < AR_TP_SIZE + DIT_TP_SIZE,
reason=f"Needs {AR_TP_SIZE + DIT_TP_SIZE}+ GPUs ({AR_TP_SIZE} AR + {DIT_TP_SIZE} DiT)",
)
def test_image_to_image_alignment(accuracy_artifact_root: Path, accuracy_assets_root: Path) -> None:
if importlib.util.find_spec("FlagEmbedding") is None:
raise ImportError("Missing dependency: FlagEmbedding\nInstall with: pip install FlagEmbedding")
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more