Compare commits

..
Author SHA1 Message Date
Roger Wang c1f3dac1a3 add 2026-04-01 04:35:27 -07:00
Roger Wang 1f8fc437b1 add 2026-04-01 04:26:09 -07:00
Roger Wang a14336d9c8 add 2026-04-01 04:20:25 -07:00
R0CKSTAR 3def008b32 [Feat] Add MUSA platform support for Moore Threads GPUs (#2337)
Signed-off-by: Xiaodong Ye <yeahdongcn@gmail.com>
2026-04-01 17:55:00 +08:00
汪志鹏 bf5bd0a4c0 [BugFix]: Fix bagel single-stage img2img fallback to text2img bug (#2397)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
2026-04-01 02:29:02 -07:00
Wu JIAZHENandjader 3fd4a4dc27 [Feat] Support step-boundary abort in diffusion (#1769)
Signed-off-by: jader <yjader@foxmail.com>
Signed-off-by: asukaqaq-s <1311722138@qq.com>
Co-authored-by: jader <yjader@foxmail.com>
2026-04-01 17:25:02 +08:00
NATURE d40840b114 [Perf] Bagel KV-ready early forwarding and time step consistency for /v1/chat/completions (#2398)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
2026-04-01 08:50:45 +00:00
zdobaanddaixinning 08cb436d4e Fix: ensure input tensor is contiguous in GroupCoordinator.all_gather (#2367)
Signed-off-by: daixinning <daixinning@163.com>
Co-authored-by: daixinning <daixinning@163.com>
2026-04-01 16:41:25 +08:00
Ding Zuhao c3376a466b [Feature][HunyuanImage3.0] Add cfgP to HunyuanImage3.0 (#1751)
Signed-off-by: Ding Zuhao <e1583181@u.nus.edu>
2026-04-01 16:33:02 +08:00
Alicia 080593c364 [CI] [skip ci]Nightly Report Optim (#2406)
Signed-off-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-04-01 14:53:17 +08:00
WeiQing Chenanddavid6666666 183775ec36 [Docs] Update WeChat QR code for community support (#2402)
Signed-off-by: david6666666 <david6666666@users.noreply.github.com>
Co-authored-by: david6666666 <david6666666@users.noreply.github.com>
2026-04-01 12:19:47 +08:00
Samit 7b965a7b6c [Feat][Benchmark] Add synchronous video generation endpoint POST /v1/videos/sync for benchmark test (#2049)
Signed-off-by: samithuang <285365963@qq.com>
2026-04-01 10:47:33 +08:00
fattysand 7274e15840 [BugFix] qwen3_tts chunk boundary handling logic in initial chunk (IC) (#2378)
Signed-off-by: Fattysand <fattysand@users.noreply.github.com>
2026-04-01 09:40:12 +08:00
Lancer dd0b6fd4e8 [Bugfix] Fix layer-wise offload incompatibility with HSDP (#2021)
Signed-off-by: Lancer <maruixiang6688@gmail.com>
2026-04-01 09:25:34 +08:00
汪志鹏 369f301a1b [BugFix] Add BAGEL single-stage diffusion config and fix multiple <im_start><im_end> bug (#2381)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
2026-04-01 09:19:13 +08:00
Kevin H. LuuandClaude Opus 4.6 f8d0bf5389 [release] Add nightly wheel release index (#2345)
Signed-off-by: khluu <khluu000@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:49:48 +00:00
wangyu 642f16949d [Bugfix] Update Whisper model loading to support multi-GPU configurations and optimize CUDA memory management (#2354)
Signed-off-by: wangyu <410167048@qq.com>
2026-03-31 14:42:28 +00:00
Hongsheng Liu a5bf33f25a [skip ci] update release 0.18.0 (#2380)
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-03-31 20:45:07 +08:00
noobHappylife 7cb7b3e8ad Fix Qwen3-TTS gradio demo (#2372)
Signed-off-by: noobhappylife <aratar1991@hotmail.com>
2026-03-31 11:42:09 +00:00
Reid de1ac5ee88 [Bugfix] Fix case-sensitivity in Qwen3 TTS speaker name lookup (#2358)
Signed-off-by: reidliu41 <reid201711@gmail.com>
2026-03-31 09:51:35 +00:00
WeiQing Chen e68ca4aa5c [CI]test: add wan22 i2v video similarity e2e (#2262)
Signed-off-by: David Chen <530634352@qq.com>
2026-03-31 16:15:47 +08:00
WeiQing Chen 0cbd1cbeb8 [CI] fix test: use minimum supported layered output count (#2350)
Signed-off-by: David Chen <530634352@qq.com>
2026-03-31 14:59:00 +08:00
wangyuandAlicia c1a978ae8d [Test] Add qwen3-omni tests for audio_in_video and one word prompt (#2097)
Signed-off-by: yenuo26 <410167048@qq.com>
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-03-31 14:30:12 +08:00
Yueqian Lin 1734c1ec23 [Test] Fix 4 broken Qwen3-TTS async chunk unit tests (#2351)
Signed-off-by: linyueqian <linyueqian@outlook.com>
2026-03-31 00:45:13 -04:00
LLandCanlin Guo 12a8f57934 Fix Qwen3-TTS Base on NPU running failed (#2353)
Signed-off-by: LL <rimchew@foxmail.com>
Signed-off-by: Canlin Guo <canlinguosdu@gmail.com>
Co-authored-by: Canlin Guo <canlinguosdu@gmail.com>
2026-03-31 12:42:21 +08:00
Yangshen DengandClaude Opus 4.6 837679dbaa [Diffusion] Refactor CFG parallel for extensibility and performance (#2063)
Signed-off-by: Yangshen Deng <yangshen.d@outlook.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:48:58 +00:00
Zeyu Huang | 黃澤宇andCopilot 0cf99144d6 [skip ci][Docs] reorganize multiple L4 test guidelines (#2119)
Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
Signed-off-by: Zeyu Huang | 黃澤宇 <11222265+fhfuih@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 00:12:14 +08:00
WeiQing Chen 1ca942999a [Bugfix] fix: validate layered image layers range (#2334)
Signed-off-by: David Chen <530634352@qq.com>
2026-03-30 21:27:09 +08:00
WeiQing Chen 1eee8435e1 [Bugfix] fix: return 400 for unsupported multi-image edits such as Qwen-Image-Layered (#2298)
Signed-off-by: David Chen <530634352@qq.com>
2026-03-30 20:24:57 +08:00
Didan Deng 911be0d426 [skip ci][Doc] Refine the Diffusion Features User Guide (#1928)
Signed-off-by: Didan Deng <33117903+wtomin@users.noreply.github.com>
2026-03-30 14:28:04 +08:00
c9370f493e [Test] Add Qwen-tts test cases and unify the style of existing test cases (#2195)
Signed-off-by: yenuo26 <410167048@qq.com>
Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com>
Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Signed-off-by: wangyu <410167048@qq.com>
Co-authored-by: yenuo26 <410167048@example.com>
Co-authored-by: R2-Y <ruiruyang2@gmail.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-03-30 01:02:28 -04:00
135 changed files with 8215 additions and 2599 deletions

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
steps:
- label: "Build and upload wheel"
key: "build-wheel"
agents:
queue: cpu_queue_release
commands:
- "curl -LsSf https://astral.sh/uv/install.sh | sh"
- 'export PATH="$HOME/.local/bin:$PATH"'
- "uv venv --python=3.12 && source .venv/bin/activate"
- "uv pip install --upgrade build"
- "python3 -m build"
- "bash .buildkite/scripts/upload-nightly-wheels.sh"
- label: "Generate and upload wheel indices"
depends_on: "build-wheel"
allow_dependency_failure: true
agents:
queue: small_cpu_queue_release
commands:
- "bash .buildkite/scripts/generate-and-upload-nightly-index.sh"
-92
View File
@@ -1,92 +0,0 @@
steps:
- label: ":buildkit: Build and Push NPU Test Image"
key: image-build
agents:
queue: "ascend-a2b3"
resource_class: "npu-2"
plugins:
- kubernetes:
metadata:
annotations:
vault.hashicorp.com/agent-init-first: "true"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-inject-perms-ca.pem: "0400"
vault.hashicorp.com/agent-inject-perms-cert.pem: "0400"
vault.hashicorp.com/agent-inject-perms-config.json: "0400"
vault.hashicorp.com/agent-inject-perms-key.pem: "0400"
vault.hashicorp.com/agent-inject-secret-ca.pem: internal/data/ascend/buildkitd
vault.hashicorp.com/agent-inject-secret-cert.pem: internal/data/ascend/buildkitd
vault.hashicorp.com/agent-inject-secret-config.json: internal/data/ascend/buildkitd
vault.hashicorp.com/agent-inject-secret-key.pem: internal/data/ascend/buildkitd
vault.hashicorp.com/agent-inject-template-ca.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.RootCA }}\n{{- end }}"
vault.hashicorp.com/agent-inject-template-cert.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaCert }}\n{{- end }}"
vault.hashicorp.com/agent-inject-template-config.json: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.dockerConfig }}\n{{- end }}"
vault.hashicorp.com/agent-inject-template-key.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaKey }}\n{{- end }}"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-run-as-group: "1000"
vault.hashicorp.com/agent-run-as-user: "1000"
vault.hashicorp.com/agent-service-account-token-volume-name: token-vol
vault.hashicorp.com/role: ascend-gha-runners
vault.hashicorp.com/secret-volume-path: /home/user/.docker/
vault.hashicorp.com/tls-skip-verify: "true"
podSpecPatch:
volumes:
- name: token-vol
projected:
defaultMode: 420
sources:
- serviceAccountToken:
audience: api
expirationSeconds: 600
path: token
env:
VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}"
IMAGE_NAME: "vllm-omni-ci-npu"
IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry"
BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234"
command: |
set -ex
echo "--- Building and pushing NPU Test Image"
echo "Image: $${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}"
echo "buildkitd address: $${BUILDKITD_ADDR}"
git config --global url."https://gh-proxy.test.osinfra.cn/https://github.com/".insteadOf "https://github.com/"
if ! command -v buildctl &> /dev/null; then
echo "Installing buildctl..."
mkdir -p /tmp/buildkit
BUILDKIT_VERSION="v0.29.0"
wget -q "https://gh-proxy.test.osinfra.cn/https://github.com/moby/buildkit/releases/download/$${BUILDKIT_VERSION}/buildkit-$${BUILDKIT_VERSION}.linux-arm64.tar.gz" -O /tmp/buildkit.tar.gz
tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit
cp /tmp/buildkit/bin/buildctl /usr/local/bin/
fi
export DOCKER_CONFIG=/home/user/.docker
buildctl \
--addr="$${BUILDKITD_ADDR}" \
--tlscacert=/home/user/.docker/ca.pem \
--tlscert=/home/user/.docker/cert.pem \
--tlskey=/home/user/.docker/key.pem \
build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=./docker \
--opt filename=Dockerfile.npu \
--secret id=dockerconfig,src=/home/user/.docker/config.json \
--output type=image,name=$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG},push=true \
--progress=plain
echo "--- Image pushed successfully"
echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}"
- label: "Upload Ready Pipeline"
depends_on: image-build
key: upload-ready-pipeline
if: build.branch != "main" && build.pull_request.labels includes "ready"
agents:
queue: "ascend-a2b3"
resource_class: "npu-2"
command: |
buildkite-agent pipeline upload .buildkite/test-npu-ready.yaml
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -ex
# Generate and upload wheel indices for all vllm-omni wheels in the commit directory.
# This script should run once after all wheels have been built and uploaded.
# All paths are under the omni/ prefix in the vllm-wheels S3 bucket.
# ======== setup ========
BUCKET="vllm-wheels"
INDICES_OUTPUT_DIR="indices"
PYTHON="${PYTHON_PROG:-python3}"
SUBPATH="omni/$BUILDKITE_COMMIT"
S3_COMMIT_PREFIX="s3://$BUCKET/$SUBPATH/"
# detect if python3.12+ is available
has_new_python=$($PYTHON -c "print(1 if __import__('sys').version_info >= (3,12) else 0)")
if [[ "$has_new_python" -eq 0 ]]; then
# use new python from docker
docker pull python:3-slim
PYTHON="docker run --rm -v $(pwd):/app -w /app python:3-slim python3"
fi
echo "Using python interpreter: $PYTHON"
echo "Python version: $($PYTHON --version)"
# ======== generate and upload indices ========
# list all wheels in the commit directory
echo "Existing wheels on S3:"
aws s3 ls "$S3_COMMIT_PREFIX" || echo "(no objects found)"
obj_json="objects.json"
aws s3api list-objects-v2 --bucket "$BUCKET" --prefix "$SUBPATH/" --delimiter / --output json > "$obj_json"
mkdir -p "$INDICES_OUTPUT_DIR"
# HACK: we do not need regex module here, but it is required by pre-commit hook
# To avoid any external dependency, we simply replace it back to the stdlib re module
sed -i 's/import regex as re/import re/g' .buildkite/scripts/generate-nightly-index.py
# Generate indices -- the version is just the commit hash (not omni/{commit})
# because relative paths are computed between the index and wheel directories,
# both of which live under the omni/ prefix in S3.
$PYTHON .buildkite/scripts/generate-nightly-index.py \
--version "$BUILDKITE_COMMIT" \
--current-objects "$obj_json" \
--output-dir "$INDICES_OUTPUT_DIR" \
--comment "commit $BUILDKITE_COMMIT"
# copy indices to /omni/{commit}/ unconditionally
echo "Uploading indices to $S3_COMMIT_PREFIX"
aws s3 cp --recursive "$INDICES_OUTPUT_DIR/" "$S3_COMMIT_PREFIX"
# copy to /omni/nightly/ when NIGHTLY=1
if [[ "${NIGHTLY:-}" == "1" ]]; then
echo "Uploading indices to overwrite /omni/nightly/"
aws s3 cp --recursive "$INDICES_OUTPUT_DIR/" "s3://$BUCKET/omni/nightly/"
fi
# detect version from any wheel in the commit directory
first_wheel_key=$($PYTHON -c "import json; obj=json.load(open('$obj_json')); print(next((c['Key'] for c in obj.get('Contents', []) if c['Key'].endswith('.whl')), ''))")
if [[ -z "$first_wheel_key" ]]; then
echo "Error: No wheels found in $S3_COMMIT_PREFIX"
exit 1
fi
first_wheel=$(basename "$first_wheel_key")
aws s3 cp "s3://$BUCKET/${first_wheel_key}" "/tmp/${first_wheel}"
version=$(unzip -p "/tmp/${first_wheel}" '**/METADATA' | grep '^Version: ' | cut -d' ' -f2)
rm -f "/tmp/${first_wheel}"
echo "Version in wheel: $version"
pure_version="${version%%+*}"
echo "Pure version (without variant): $pure_version"
# re-generate and copy to /omni/{version}/ only if it does not have "dev" in the version
if [[ "$version" != *"dev"* ]]; then
echo "Re-generating indices for /omni/$pure_version/"
rm -rf "${INDICES_OUTPUT_DIR:?}"
mkdir -p "$INDICES_OUTPUT_DIR"
# wheel-dir is overridden to be the commit directory, so that the indices point to the correct wheel path
$PYTHON .buildkite/scripts/generate-nightly-index.py \
--version "$pure_version" \
--wheel-dir "$BUILDKITE_COMMIT" \
--current-objects "$obj_json" \
--output-dir "$INDICES_OUTPUT_DIR" \
--comment "version $pure_version"
aws s3 cp --recursive "$INDICES_OUTPUT_DIR/" "s3://$BUCKET/omni/$pure_version/"
fi
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import argparse
import json
import sys
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import quote
import regex as re
def normalize_package_name(name: str) -> str:
"""Normalize package name per PEP 503."""
return re.sub(r"[-_.]+", "-", name).lower()
if not sys.version_info >= (3, 12):
raise RuntimeError("This script requires Python 3.12 or higher.")
INDEX_HTML_TEMPLATE = """<!DOCTYPE html>
<html>
<!-- {comment} -->
<meta name="pypi:repository-version" content="1.0">
<body>
{items}
</body>
</html>
"""
@dataclass
class WheelFileInfo:
package_name: str
version: str
build_tag: str | None
python_tag: str
abi_tag: str
platform_tag: str
filename: str
def parse_from_filename(file: str) -> WheelFileInfo:
"""
Parse wheel filename per PEP 427:
{package_name}-{version}(-{build_tag})?-{python_tag}-{abi_tag}-{platform_tag}.whl
"""
wheel_file_re = re.compile(
r"^(?P<package_name>.+)-(?P<version>[^-]+?)(-(?P<build_tag>[^-]+))?-(?P<python_tag>[^-]+)-(?P<abi_tag>[^-]+)-(?P<platform_tag>[^-]+)\.whl$"
)
match = wheel_file_re.match(file)
if not match:
raise ValueError(f"Invalid wheel file name: {file}")
return WheelFileInfo(
package_name=match.group("package_name"),
version=match.group("version"),
build_tag=match.group("build_tag"),
python_tag=match.group("python_tag"),
abi_tag=match.group("abi_tag"),
platform_tag=match.group("platform_tag"),
filename=file,
)
def generate_project_list(package_names: list[str], comment: str = "") -> str:
"""Generate top-level PEP 503 project list HTML."""
href_tags = []
for name in sorted(package_names):
href_tags.append(f' <a href="{name}/">{name}/</a><br/>')
return INDEX_HTML_TEMPLATE.format(items="\n".join(href_tags), comment=comment)
def generate_package_index(
wheel_files: list[WheelFileInfo],
wheel_base_dir: Path,
index_base_dir: Path,
comment: str = "",
) -> tuple[str, str]:
"""Generate package index HTML and metadata JSON linking to wheel files."""
href_tags = []
metadata = []
for file in sorted(wheel_files, key=lambda x: x.filename):
relative_path = wheel_base_dir.relative_to(index_base_dir, walk_up=True) / file.filename
# handle '+' in URL; avoid double-encoding '/' and '%2B' (AWS S3 behavior)
file_path_quoted = quote(relative_path.as_posix(), safe=":%/")
href_tags.append(f' <a href="{file_path_quoted}">{file.filename}</a><br/>')
file_meta = asdict(file)
file_meta["path"] = file_path_quoted
metadata.append(file_meta)
index_str = INDEX_HTML_TEMPLATE.format(items="\n".join(href_tags), comment=comment)
metadata_str = json.dumps(metadata, indent=2)
return index_str, metadata_str
def generate_index(
whl_files: list[str],
wheel_base_dir: Path,
index_base_dir: Path,
comment: str = "",
):
"""
Generate PEP 503 index for all wheel files.
Output structure:
index_base_dir/
index.html # project list linking to vllm-omni/
vllm-omni/
index.html # package index linking to wheel files
metadata.json # machine-readable metadata
"""
parsed_files = [parse_from_filename(f) for f in whl_files]
if not parsed_files:
print("No wheel files found, skipping index generation.")
return
comment_str = f" ({comment})" if comment else ""
comment_tmpl = f"Generated on {datetime.now().isoformat()}{comment_str}"
# Group by normalized package name
packages: dict[str, list[WheelFileInfo]] = {}
for file in parsed_files:
name = normalize_package_name(file.package_name)
packages.setdefault(name, []).append(file)
print(f"Found packages: {list(packages.keys())}")
# Generate per-package index
for package, files in packages.items():
package_dir = index_base_dir / package
package_dir.mkdir(parents=True, exist_ok=True)
index_str, metadata_str = generate_package_index(files, wheel_base_dir, package_dir, comment)
with open(package_dir / "index.html", "w") as f:
f.write(index_str)
with open(package_dir / "metadata.json", "w") as f:
f.write(metadata_str)
# Generate top-level project list
project_list_str = generate_project_list(sorted(packages.keys()), comment_tmpl)
with open(index_base_dir / "index.html", "w") as f:
f.write(project_list_str)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate PEP 503 wheel index from S3 object listing.")
parser.add_argument("--version", type=str, required=True, help="Version string (e.g., commit hash)")
parser.add_argument("--current-objects", type=str, required=True, help="Path to JSON from S3 list-objects-v2")
parser.add_argument("--output-dir", type=str, required=True, help="Directory to write index files")
parser.add_argument("--wheel-dir", type=str, default=None, help="Wheel directory (defaults to --version)")
parser.add_argument("--comment", type=str, default="", help="Comment for generated HTML")
args = parser.parse_args()
version = args.version
if "\\" in version or "/" in version:
raise ValueError("Version string must not contain slashes or backslashes.")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
with open(args.current_objects) as f:
current_objects: dict[str, list[dict[str, Any]]] = json.load(f)
wheel_files = [
item["Key"].split("/")[-1] for item in current_objects.get("Contents", []) if item["Key"].endswith(".whl")
]
print(f"Found {len(wheel_files)} wheel files for version {version}: {wheel_files}")
# For release versions, filter to only matching non-dev wheels
PY_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+([a-zA-Z0-9.+-]*)?$")
if PY_VERSION_RE.match(version):
wheel_files = [f for f in wheel_files if version in f and "dev" not in f]
print(f"Non-nightly version detected, wheel files used: {wheel_files}")
else:
print("Nightly version detected, keeping all wheel files.")
wheel_dir = (args.wheel_dir or version).strip().rstrip("/")
wheel_base_dir = Path(output_dir).parent / wheel_dir
index_base_dir = Path(output_dir)
generate_index(
whl_files=wheel_files,
wheel_base_dir=wheel_base_dir,
index_base_dir=index_base_dir,
comment=args.comment.strip(),
)
print(f"Successfully generated index in {output_dir}")
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -ex
# Upload a single wheel to S3 under the omni/ prefix.
# Index generation is handled separately by generate-and-upload-nightly-index.sh.
BUCKET="vllm-wheels"
SUBPATH="omni/$BUILDKITE_COMMIT"
S3_COMMIT_PREFIX="s3://$BUCKET/$SUBPATH/"
# ========= collect & upload the wheel ==========
# python3 -m build outputs to dist/ by default
wheel_files=(dist/*.whl)
# Check that exactly one wheel is found
if [[ ${#wheel_files[@]} -ne 1 ]]; then
echo "Error: Expected exactly one wheel file in dist/, but found ${#wheel_files[@]}"
exit 1
fi
wheel="${wheel_files[0]}"
echo "Uploading wheel: $wheel"
# Extract the version from the wheel
version=$(unzip -p "$wheel" '**/METADATA' | grep '^Version: ' | cut -d' ' -f2)
echo "Version in wheel: $version"
# Upload wheel to S3
aws s3 cp "$wheel" "$S3_COMMIT_PREFIX"
echo "Wheel uploaded to $S3_COMMIT_PREFIX. Index generation is handled by a separate step."
+22 -4
View File
@@ -134,15 +134,33 @@ steps:
- timeout 10m pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py
- timeout 20m pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "advanced_model" --run-level "advanced_model"
- label: "Qwen3-TTS E2E Test"
- label: "Qwen3-TTS CustomVoice E2E Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- timeout 20m pytest -s -v tests/e2e/online_serving/test_qwen3_tts.py
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "advanced_model" --run-level "advanced_model" && pytest -s -v tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
'
- label: "Qwen3-TTS Base E2E Test"
agent_pool: mi325_2
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model" && pytest -s -v tests/e2e/offline_inference/test_qwen3_tts_base.py
'
- label: "Diffusion Image Edit Test"
agent_pool: mi325_1
+2 -1
View File
@@ -124,7 +124,8 @@ steps:
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- timeout 20m pytest -s -v tests/e2e/online_serving/test_qwen3_tts.py
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- timeout 20m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "core_model" --run-level "core_model"
- label: "Diffusion Image Edit Test"
agent_pool: mi325_1
+46 -1
View File
@@ -208,12 +208,57 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Qwen3-TTS CustomVoice E2E Test"
depends_on: upload-merge-pipeline
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "advanced_model" --run-level "advanced_model" && pytest -s -v tests/e2e/offline_inference/test_qwen3_tts_customvoice.py
'
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"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Qwen3-TTS Base E2E Test"
depends_on: upload-merge-pipeline
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model" && pytest -s -v tests/e2e/offline_inference/test_qwen3_tts_base.py
'
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"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Omni Model Test with H100"
timeout_in_minutes: 30
depends_on: upload-merge-pipeline
commands:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- export VLLM_TEST_CLEAN_GPU_MEMORY="1"
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py
- pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "advanced_model" --run-level "advanced_model"
- pytest -s -v tests/e2e/online_serving/test_mimo_audio.py -m "advanced_model" --run-level "advanced_model"
+48 -26
View File
@@ -50,13 +50,15 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: Omni Model Test"
timeout_in_minutes: 60
- label: ":full_moon: Omni Model Test with L4"
timeout_in_minutes: 90
depends_on: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
commands:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/examples/online_serving/test_qwen2_5_omni.py -m "advanced_model" --run-level "advanced_model"
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- pytest -s -v tests/examples/ -m "advanced_model and L4 and omni" --run-level "advanced_model"
- pytest -s -v tests/e2e/online_serving/test_*_expansion.py -m "advanced_model and L4 and omni" --run-level "advanced_model"
agents:
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
plugins:
@@ -217,29 +219,6 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: Qwen3-TTS Non-Async-Chunk E2E Test"
timeout_in_minutes: 30
depends_on: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
commands:
- |
huggingface-cli download Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_qwen3_tts.py::TestQwen3TTSNoAsyncChunk
agents:
queue: "gpu_4_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"
- label: ":full_moon: Omni Model Perf Test & Test Case Statistics"
key: nightly-omni-performance
timeout_in_minutes: 180
@@ -248,6 +227,7 @@ steps:
commands:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- export BENCHMARK_DIR=tests/dfx/perf/results
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- pytest -s -v tests/dfx/perf/scripts/run_benchmark.py
- buildkite-agent artifact upload "tests/dfx/perf/results/*.json"
- python tools/nightly/buildkite_testcase_statistics.py -o tests/dfx/perf/results/buildkite_testcase_statistics.html
@@ -375,6 +355,48 @@ steps:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: Wan22 I2V Accuracy Test with H100"
key: nightly-wan22-i2v-accuracy
timeout_in_minutes: 180
depends_on: upload-nightly-pipeline
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
commands:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
- pytest -s -v tests/e2e/accuracy/wan22_i2v/test_wan22_i2v_video_similarity.py --run-level advanced_model
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:c392ce21e9cf9ea65c52b866447793db10e0261c
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: ":full_moon: Qwen-Image Diffusion Perf Test with H100"
key: nightly-qwen-image-performance
timeout_in_minutes: 300
-14
View File
@@ -1,14 +0,0 @@
steps:
- label: "Simple Unit Test"
depends_on: upload-ready-pipeline
agents:
queue: "ascend-a2b3"
resource_class: "npu-2"
image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}"
plugins:
- kubernetes:
podSpecPatch:
imagePullSecrets:
- name: swr-secret
commands:
- "timeout 20m pytest -v -s -m 'core_model and cpu' --cov=vllm_omni --cov-branch --cov-report=term-missing --cov-report=html --cov-report=xml"
+39 -41
View File
@@ -216,44 +216,42 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
# - label: "Omni Model Test with H100"
# depends_on: upload-ready-pipeline
# commands:
# - |
# timeout 20m bash -c '
# export VLLM_WORKER_MULTIPROC_METHOD=spawn
# export VLLM_TEST_CLEAN_GPU_MEMORY="1"
# # - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py
# pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "core_model" --run-level "core_model"
# '
# agents:
# queue: "mithril-h100-pool"
# plugins:
# - kubernetes:
# podSpec:
# containers:
# - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
# resources:
# limits:
# nvidia.com/gpu: 2
# volumeMounts:
# - name: devshm
# mountPath: /dev/shm
# - name: hf-cache
# mountPath: /root/.cache/huggingface
# env:
# - name: HF_HOME
# value: /root/.cache/huggingface
# nodeSelector:
# node.kubernetes.io/instance-type: gpu-h100-sxm
# volumes:
# - name: devshm
# emptyDir:
# medium: Memory
# - name: hf-cache
# hostPath:
# path: /mnt/hf-cache
# type: DirectoryOrCreate
- label: "Omni Model Test with H100"
depends_on: upload-ready-pipeline
commands:
- |
timeout 20m bash -c '
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "core_model" --run-level "core_model"
'
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
- label: "MiMo-Audio E2E Test with H100"
depends_on: upload-ready-pipeline
@@ -303,13 +301,13 @@ steps:
commands:
- |
timeout 20m bash -c '
huggingface-cli download Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_qwen3_tts.py -k "not NoAsyncChunk"
export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "core_model" --run-level "core_model"
'
agents:
queue: "gpu_4_queue"
queue: "gpu_1_queue"
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
+1 -1
View File
@@ -16,7 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone
---
*Latest News* 🔥
- [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments.
- [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup!
- [2026/03] **[vllm-omni-skills](https://github.com/hsliuustc0106/vllm-omni-skills)** is a community-driven collection of AI assistant skills that help developers work with vLLM-Omni more effectively. These skills can be used with popular agentic AI coding assistants like **Cursor IDE**, **Claude**, **Codex**, and more.
- [2026/02] We released [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0) - A major alignment + capability release that rebases onto **upstream vLLM v0.16.0** and significantly expands performance, distributed execution, and production readiness across **Qwen3-Omni / Qwen3-TTS**, **Bagel**, **MiMo-Audio**, **GLM-Image** and the **Diffusion (DiT) image/video stack**—while also improving platform coverage (CUDA / ROCm / NPU / XPU), CI quality, and documentation.
+7 -15
View File
@@ -5,34 +5,26 @@ FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
WORKDIR /vllm-workspace/vllm
RUN git fetch origin --tags && git checkout v0.18.0
RUN git config --global url."https://gh-proxy.test.osinfra.cn/https://github.com/".insteadOf "https://github.com/"
WORKDIR /vllm-workspace/vllm-ascend
RUN git fetch origin releases/v0.18.0 && git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a
# Install vllm-ascend
RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
# Append `libascend_hal.so` path (devlib) to LD_LIBRARY_PATH
RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi && \
source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
source /usr/local/Ascend/nnal/atb/set_env.sh && \
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \
python3 -m pip install -U pip && \
python3 -m pip install -v -e /vllm-workspace/vllm-ascend/ \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--extra-index https://download.pytorch.org/whl/cpu/ \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
python3 -m pip install -v -e /vllm-workspace/vllm-ascend/ --extra-index https://download.pytorch.org/whl/cpu/ && \
python3 -m pip cache purge
ARG APP_DIR=/vllm-workspace/vllm-omni
WORKDIR ${APP_DIR}
COPY . .
RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi && \
source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
source /usr/local/Ascend/nnal/atb/set_env.sh && \
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \
python3 -m pip install -U pip && \
python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
+7 -15
View File
@@ -5,34 +5,26 @@ FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
WORKDIR /vllm-workspace/vllm
RUN git fetch origin --tags && git checkout v0.18.0
RUN git config --global url."https://gh-proxy.test.osinfra.cn/https://github.com/".insteadOf "https://github.com/"
WORKDIR /vllm-workspace/vllm-ascend
RUN git fetch origin releases/v0.18.0 && git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a
# Install vllm-ascend
RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
# Append `libascend_hal.so` path (devlib) to LD_LIBRARY_PATH
RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi && \
source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
source /usr/local/Ascend/nnal/atb/set_env.sh && \
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \
python3 -m pip install -U pip && \
python3 -m pip install -v -e /vllm-workspace/vllm-ascend/ \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--extra-index https://download.pytorch.org/whl/cpu/ \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
python3 -m pip install -v -e /vllm-workspace/vllm-ascend/ --extra-index https://download.pytorch.org/whl/cpu/ && \
python3 -m pip cache purge
ARG APP_DIR=/vllm-workspace/vllm-omni
WORKDIR ${APP_DIR}
COPY . .
RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi && \
source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
source /usr/local/Ascend/nnal/atb/set_env.sh && \
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \
python3 -m pip install -U pip && \
python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation \
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
--default-timeout=60
python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
+16 -6
View File
@@ -54,20 +54,28 @@ nav:
- Features:
- Sleep Mode: features/sleep_mode.md
- Diffusion Features:
- Acceleration Overview: user_guide/diffusion_acceleration.md
- TeaCache: user_guide/diffusion/teacache.md
- Cache-DiT: user_guide/diffusion/cache_dit_acceleration.md
- Overview: user_guide/diffusion_features.md
- Feature Compatibility: user_guide/feature_compatibility.md
- Cache Acceleration:
- TeaCache: user_guide/diffusion/cache_acceleration/teacache.md
- Cache-DiT: user_guide/diffusion/cache_acceleration/cache_dit.md
- Quantization:
- Overview: user_guide/diffusion/quantization/overview.md
- FP8: user_guide/diffusion/quantization/fp8.md
- Int8: user_guide/diffusion/quantization/int8.md
- GGUF: user_guide/diffusion/quantization/gguf.md
- Step Execution: user_guide/diffusion/step_execution.md
- Parallelism Acceleration: user_guide/diffusion/parallelism_acceleration.md
- Parallelism:
- Overview: user_guide/diffusion/parallelism/overview.md
- CFG Parallel: user_guide/diffusion/parallelism/cfg_parallel.md
- Expert Parallel: user_guide/diffusion/parallelism/expert_parallel.md
- Hybrid Sharded Data Parallel: user_guide/diffusion/parallelism/hsdp.md
- Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md
- Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.md
- VAE Patch Parallel: user_guide/diffusion/parallelism/vae_patch_parallel.md
- CPU Offloading: user_guide/diffusion/cpu_offload_diffusion.md
- LoRA: user_guide/diffusion/lora.md
- Hybrid Sharded Data Parallel: design/feature/hsdp.md
- Custom Pipeline: features/custom_pipeline.md
- Step Execution: user_guide/diffusion/step_execution.md
- ComfyUI: features/comfyui.md
- Developer Guide:
- General:
@@ -92,6 +100,8 @@ nav:
- design/feature/cfg_parallel.md
- design/feature/sequence_parallel.md
- design/feature/tensor_parallel.md
- design/feature/vae_parallel.md
- design/feature/hsdp.md
- design/feature/cache_dit.md
- design/feature/teacache.md
- design/feature/async_chunk_design.md
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+3 -1
View File
@@ -37,10 +37,12 @@ Committers have write access and merge rights. They typically have deep expertis
- [@gcanlin](https://github.com/gcanlin): Hardware plugin and NPU integration
- [@Isotr0py](https://github.com/Isotr0py): Diffusion and Quantization
- [@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
- [@tzhouam](https://github.com/tzhouam): Engine and New Model Support
- [@wtomin](https://github.com/wtomin):
- [@wtomin](https://github.com/wtomin): Diffusion and Parallelism
- [@ZeldaHuang](https://github.com/ZeldaHuang): Omni Support
- [@ZJY0516](https://github.com/ZJY0516): Diffusion and CustomOp
## Meetings
+1 -4
View File
@@ -20,7 +20,4 @@ For introduction, please check [Introduction for stage config](./stage_configs.m
## Optimization Features
- **[TeaCache Configuration](../user_guide/diffusion/teacache.md)** - Enable TeaCache adaptive caching for DiT models to achieve 1.5x-2.0x speedup with minimal quality loss
- **[Cache-DiT Configuration](../user_guide/diffusion/cache_dit_acceleration.md)** - Enable Cache-DiT as cache acceleration backends for DiT models
- **[Parallelism Configuration](../user_guide/diffusion/parallelism_acceleration.md)** - Enable parallelism (e.g., sequence parallelism) for for DiT models
- **[CPU Offloading](../user_guide/diffusion/cpu_offload_diffusion.md)** - Enable CPU offloading (model-level and layerwise) for for DiT models
- **[Diffusion Features Overview](../user_guide/diffusion_features.md)** - Complete overview of all diffusion model features and supported models
+7 -91
View File
@@ -548,103 +548,19 @@ L4 level testing is a comprehensive quality audit before a version release. It e
- ***Execution Environment***: ***GPU*** server clusters to meet the resource demands of performance testing.
- ***Script Example***:
???+ example "Test Examples"
??? example "Test Examples: Documentation Example Tests"
When adding L4-level ***documentation example Tests***, please pay attention to the following guides.
--8<-- "docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md"
--8<-- "docs/contributing/ci/test_examples/doc_example_tests.inc.md"
??? example "Test Examples: Performance Tests"
When you want to add L4-level ***performance test*** cases, you can refer to the following format for case addition in tests/dfx/perf/tests/test.json:
--8<-- "docs/contributing/ci/test_examples/l4_performance_tests.inc.md"
```JSON
{
"test_name": "test_qwen3_omni",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"stage_config_name": "qwen3_omni.yaml"
},
"benchmark_params": [
{
"dataset_name": "random",
"num_prompts": [10, 20],
"max_concurrency": [1, 4],
"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": [500, 800],
"mean_audio_ttfp_ms": [2000, 3500],
"mean_audio_rtf": [0.25, 0.35]
}
}
]
}
```
??? example "Test Examples: Functionality Tests"
**Parameter Explanation**
--8<-- "docs/contributing/ci/test_examples/l4_functionality_tests.inc.md"
*Overview*
| Field | Required | Description |
| ---------------- | -------- | --------------------------------------------------------------- |
| test_name | Yes | Unique identifier for the test case |
| server_params | Yes | Server-side configuration parameters |
| benchmark_params | Yes | Benchmark running parameters (supports multiple configurations) |
**server_params Configuration**
*Basic Parameters*
| Parameter | Required | Example | Description |
| ----------------- | -------- | ---------------------------------- | ----------------------------- |
| model | Yes | "Qwen/Qwen3-Omni-30B-A3B-Instruct" | Model name or path |
| stage_config_name | Yes | "qwen3_omni.yaml" | Stage configuration file name |
*Dynamic Configuration (update/delete)*
Supports incremental modifications based on the basic configuration:
| Operation | Description |
| --------- | ------------------------------------ |
| update | Update or add configuration items |
| delete | Delete specified configuration items |
***Example***:
```
"update": {
"async_chunk": true, // Enable asynchronous chunk processing
"stage_args": {
"0": {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
}
}
},
"delete": {
"stage_args": {
"2": ["custom_process_input_func"] // Delete this configuration for stage 2
}
}
```
**benchmark_params Configuration**
You can add any benchmark running parameters you need here. For all optional parameters, refer to the [benchmark documentation](https://github.com/vllm-project/vllm-omni/blob/main/docs/cli/bench/serve.md). General modifications are as follows:
1. Change the ---xxx-xx-xx running parameters to xxx_xx_xx format and fill them as keys in the JSON file.
2. For boolean variables in the running parameters, modify them to forms such as ignore_eos: true/false and fill them into the JSON file.
3. Optionally add a `baseline` object (see **Baseline thresholds** below). If you omit `baseline` or leave it empty, the performance test still runs but does not assert metric thresholds from this field.
4. The qps and concurrency modes are mutually exclusive. For detailed explanations, see the table below:
| Parameter | Type | Required | Example/Values | Description |
| --------------- | ----------- | -------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. |
| request_rate | int / array | No | 1, [1, 2, 3] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. |
| max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). |
| baseline | object | No | see above | Optional per-metric thresholds; keys must match benchmark output fields. Scalar, list (per sweep step), or object (keyed by concurrency or QPS string). |
- - ***Run Command***: (Specific commands would depend on the performance testing tool and configuration defined in `nightly.json`).
- ***Run Command***: (Specific commands would depend on the performance testing tool and configuration defined in `nightly.json`).
## Chapter 4: L5 Level Testing - Stability and Reliability Testing
@@ -0,0 +1,46 @@
**Scope**
For diffusion models, the L4 functionality test covers all or common *diffusion features* that are supported by this model, including several [parallelism acceleration methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism_acceleration/), [CPU offloading](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cpu_offload_diffusion/), [TeaCache](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/teacache/) and [Cache-DiT](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cache_dit_acceleration/) cache backends, [quantization methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/quantization/overview/).
**Test Case Design**
For a *high priority* model (currently listed in [issue #1832](https://github.com/vllm-project/vllm-omni/issues/1832)), we use several test cases, each with multiple features turned on, so that each supported feature is tested in at least one test case. This is to relieve the GPU workload on the CI machine. The suggested test case combination is as follows:
- If the model can fit into 4 L4 GPU (with quantization and tensor parallel always on) (20GB RAM each)
- (1 GPU) TeaCache + Layerwise CPU offloading + GGUF
- (4 GPUs) CacheDiT + Ulysses=2 + TP=2 + VAE=2 + FP8
- (4 GPUs) CacheDiT + Ring=2 + HSDP=2 + VAE=2 + GGUF
- (4 GPUs) TeaCache + CFG=2 + TP=2 + VAE=2 + FP8
- Otherwise, consider 2 H100 GPU environment (80GB RAM each) with the following tests
- (1 GPU) TeaCache + Layerwise CPU offloading + GGUF
- (2 GPUs) CacheDiT + Ulysses=2 + FP8
- (2 GPUs) CacheDiT + Ring=2 + GGUF
- (2 GPUs) TeaCache + CFG=2 + FP8
- (2 GPUs) CacheDiT + TP=2 + VAE=2 + FP8
- (2 GPUs) CacheDiT + HSDP=2 + VAE=2 + GGUF
- If 2 H100 GPU cannot handle the model either (e.g., HunyuanImage 3.0)
- Still design tests and feature combinations that can best fit real-world scenario.
- Do not include it in CI (or exclude it from the CI's filtering criteria). Instead, relevant PR authors are suggested to run these tests locally.
- Fallback plan
- If the model does not support layerwise CPU offloading, replace the corresponding test case with module-wise offloading
- If the model only supports specific or no caching feature, use this option or remove this option in all test cases.
- If the model only supports specific or no quantization feature, use this option or remove this option in all test cases.
- If the model does not support certain other features, remove this option from that test case. If, consequently, the coverage of this modified test case completely overlaps with others, remove this test case.
For a *normal priority* model, further reduce the number of test cases.
- Only write one or two test cases for the most common feature combinations.
- The author can explore themselves to see which feature combination balances output quality and performance. Alternatively, the author can refer to any example code in the PR that adds the model, or the example code in the PR that adds a feature (if the code involves this model of interest).
Currently all the features are available in online serving mode. Hence, only need to add `tests/e2e/online_serving/test_{model}_expansion.py`.
**Code Style**
- Validation: test that the multimodal output files of your model have the correct shapes. `OpenAIClientHandler.send_diffusion_request` should have taken care of this.
- Test marks: always add `advanced_model` and `diffusion`. Add GPU-related marks if needed. Ref: [Markers for Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/tests_markers/).
- To maximize code reuse, you may refer to
- `tests/conftest.py` for `omni_server` (running server in subprocess) and `openai_client` fixtures (sending requests and validating output), `generate_synthetic_image` and `assert_XXX_valid` helper.
- `tests/utils.py` for `@hardware_test(...)` and `hardware_marks`.
- [Parametrizing tests (pytest doc)](https://docs.pytest.org/en/stable/example/parametrize.html) to reuse test function implementation for different cases.
- Doc: add a concise docstring for each test function.
- Reference L4 test implementation: [tests/e2e/online_serving/test_qwen_image_edit_expansion.py](https://github.com/vllm-project/vllm-omni/blob/main/tests/e2e/online_serving/test_qwen_image_edit_expansion.py).
@@ -0,0 +1,89 @@
When you want to add L4-level ***performance test*** cases, you can refer to the following format for case addition in tests/dfx/perf/tests/test.json:
```JSON
{
"test_name": "test_qwen3_omni",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"stage_config_name": "qwen3_omni.yaml"
},
"benchmark_params": [
{
"dataset_name": "random",
"num_prompts": [10, 20],
"max_concurrency": [1, 4],
"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": [500, 800],
"mean_audio_ttfp_ms": [2000, 3500],
"mean_audio_rtf": [0.25, 0.35]
}
}
]
}
```
**Parameter Explanation**
*Overview*
| Field | Required | Description |
| ---------------- | -------- | --------------------------------------------------------------- |
| test_name | Yes | Unique identifier for the test case |
| server_params | Yes | Server-side configuration parameters |
| benchmark_params | Yes | Benchmark running parameters (supports multiple configurations) |
**`server_params` Configuration**
*Basic Parameters*
| Parameter | Required | Example | Description |
| ----------------- | -------- | ---------------------------------- | ----------------------------- |
| model | Yes | "Qwen/Qwen3-Omni-30B-A3B-Instruct" | Model name or path |
| stage_config_name | Yes | "qwen3_omni.yaml" | Stage configuration file name |
*Dynamic Configuration (update/delete)*
Supports incremental modifications based on the basic configuration:
| Operation | Description |
| --------- | ------------------------------------ |
| update | Update or add configuration items |
| delete | Delete specified configuration items |
**Example**:
```
"update": {
"async_chunk": true, // Enable asynchronous chunk processing
"stage_args": {
"0": {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
}
}
},
"delete": {
"stage_args": {
"2": ["custom_process_input_func"] // Delete this configuration for stage 2
}
}
```
**`benchmark_params` Configuration**
You can add any benchmark running parameters you need here. For all optional parameters, refer to the [benchmark documentation](https://github.com/vllm-project/vllm-omni/blob/main/docs/cli/bench/serve.md). General modifications are as follows:
1. Change the --xxx-xx-xx running parameters to xxx_xx_xx format and fill them as keys in the JSON file.
2. For boolean variables in the running parameters, modify them to forms such as ignore_eos: true/false and fill them into the JSON file.
3. Optionally add a `baseline` object (see **Baseline thresholds** below). If you omit `baseline` or leave it empty, the performance test still runs but does not assert metric thresholds from this field.
4. The qps and concurrency modes are recommended to be mutually exclusive. For detailed explanations, see the table below:
| Parameter | Type | Required | Example/Values | Description |
| --------------- | ----------- | -------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. |
| request_rate | float / array | No | 0.5, [0.5, 1, inf] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. |
| max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). |
| baseline | object | No | see above | Optional per-metric thresholds; keys must match benchmark output fields. Scalar, list (per sweep step), or object (keyed by concurrency or QPS string).
@@ -653,25 +653,7 @@ For a fair comparison, keep the same **prompt**, **seed**, **resolution**, **num
To ensure project maintainability and sustainable development, please submit test code (unit tests, system tests, or end-to-end tests) alongside their code changes.
For comprehensive testing guidelines and the definition of test levels (L1-L5), please refer to the [Test File Structure and Style Guide](../ci/tests_style.md).
The following tests are required to add:
- L4 test of the model's full *functionality* (i.e., all the *diffusion features* that are supported by this model), including several [parallelism acceleration methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism_acceleration/), [CPU offloading](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cpu_offload_diffusion/), [TeaCache](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/teacache/) and [Cache-DiT](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cache_dit_acceleration/) cache backends, [quantization methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/quantization/overview/).
- Test cases: Currently all the features are available in online serving mode. Hence, only need to add `tests/e2e/online_serving/test_{model}_expansion.py`. The following test cases shall cover all features:
- 1 GPU: TeaCache & GGUF (or fallback to FP8, or disable it) & Layer-wise CPU offloading (or fallback to Module-wise)
- 2 GPUs: Cache-DiT & FP8 (or fallback to GGUF, or disable it) & Ulysses = 2
- 2 GPUs: Cache-DiT & GGUF (or fallback to FP8, or disable it) & Ring = 2
- 2 GPUs: TeaCache & FP8 (or fallback to GGUF, or disable it) & CFG Parallel = 2
- 2 GPUs: Cache-DiT & FP8 (or fallback to GGUF, or disable it) & Tensor Parallel = 2 & VAE Patch Parallel = 2
- 2 GPUs: Cache-DiT & GGUF (or fallback to FP8, or disable it) & HSDP = 2 & VAE Patch Parallel = 2
- Validation: test that the multimodal output files of your model have the correct shapes.
- Test marks: always add `advanced_model` and `diffusion`. Add `parallel` and GPU-related marks if needed. Ref: [Markers for Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/tests_markers/)
- To maximize code reuse, you may refer to
- `tests/conftest.py` for `omni_server` and `openai_client` fixtures, `generate_synthetic_image` and `assert_XXX_valid` helper.
- `tests/utils.py` for `@hardware_test(...)` and `hardware_marks`.
- [Parametrizing tests (pytest doc)](https://docs.pytest.org/en/stable/example/parametrize.html) to reuse test function implementation for different cases.
- Doc: add a concise dostring for each test function.
- Reference L4 test implementation: [tests/e2e/online_serving/test_qwen_image_edit_expansion.py](https://github.com/vllm-project/vllm-omni/blob/main/tests/e2e/online_serving/test_qwen_image_edit_expansion.py).
For comprehensive testing guidelines and the definition of test levels (L1-L5), please refer to the [Multi-Level Automated Testing System Documentation](../ci/CI_5levels.md). You are at least required to add an L4 *functionality* test described in that document.
---
+1 -1
View File
@@ -256,7 +256,7 @@ cache_config={
}
```
Check the [user guide for cache_dit](../../user_guide/diffusion/cache_dit_acceleration.md) for more adjustable parameters.
Check the [user guide for cache_dit](../../user_guide/diffusion/cache_acceleration/cache_dit.md) for more adjustable parameters.
---
+67 -10
View File
@@ -34,7 +34,7 @@ vLLM-omni provides `CFGParallelMixin` that encapsulates all CFG parallel logic.
| Method | Purpose | Automatic Behavior |
|--------|---------|-------------------|
| [`predict_noise_maybe_with_cfg()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Predict noise with CFG | Detects parallel mode, distributes computation, gathers results |
| [`scheduler_step_maybe_with_cfg()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Step scheduler with sync | Rank 0 steps, broadcasts latents to all ranks |
| [`scheduler_step_maybe_with_cfg()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Step scheduler | All ranks step locally (no broadcast needed) |
| [`combine_cfg_noise()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Combine positive/negative | Applies CFG formula with optional normalization |
| [`predict_noise()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Forward pass wrapper | Override for custom transformer calls |
| [`cfg_normalize_function()`](https://docs.vllm.ai/projects/vllm-omni/en/latest/api/vllm_omni/diffusion/distributed/cfg_parallel/) | Normalize CFG output | Override for custom normalization |
@@ -47,7 +47,7 @@ vLLM-omni provides `CFGParallelMixin` that encapsulates all CFG parallel logic.
- Rank 0 computes positive prompt prediction
- Rank 1 computes negative prompt prediction
- Results are gathered via `all_gather()`
- Combined on rank 0 using CFG formula
- All ranks compute CFG combine locally (deterministic, identical results)
- **Sequential mode** (when `cfg_world_size == 1`):
- Single rank computes both positive and negative predictions
@@ -55,14 +55,7 @@ vLLM-omni provides `CFGParallelMixin` that encapsulates all CFG parallel logic.
`scheduler_step_maybe_with_cfg()` ensures consistent latent states across all ranks:
- **CFG-Parallel mode**:
- Only rank 0 performs the scheduler step (applies noise prediction to update latents)
- Updated latents are broadcast to all other ranks via `broadcast()`
- All ranks maintain synchronized latent states for the next iteration
- **Sequential mode**:
- Single rank directly performs the scheduler step
- No synchronization needed
- All ranks compute the scheduler step locally — no broadcast needed because `predict_noise_maybe_with_cfg` already ensures all ranks have identical noise predictions after `all_gather` + local combine.
---
@@ -177,6 +170,70 @@ class LongCatImagePipeline(nn.Module, CFGParallelMixin):
# return noise_pred
```
### Override `combine_cfg_noise()` for Multi-Output Models
When `predict_noise()` returns a tuple (e.g., video + audio), the default `combine_cfg_noise()` applies CFG to every element. Override it to apply different logic per element — for example, CFG on video but positive-only on audio:
```python
class MyVideoAudioPipeline(nn.Module, CFGParallelMixin):
def combine_cfg_noise(self, positive_noise_pred, negative_noise_pred, scale, normalize):
(video_pos, audio_pos) = positive_noise_pred
(video_neg, audio_neg) = negative_noise_pred
video_combined = super().combine_cfg_noise(video_pos, video_neg, scale, normalize)
return (video_combined, audio_pos) # audio: positive only, no CFG
```
This also requires `predict_noise()` to return a tuple (see [Override predict_noise](#override-predict_noise-for-custom-transformer-calls) above).
### Implement a Composite Scheduler for Multi-Output Models
When each output has its own denoising schedule, implement a composite scheduler that dispatches to per-output schedulers. Assign it to `self.scheduler` so the default `scheduler_step()` works without override.
**Complete example (video + audio with separate schedulers and diffuse loop):**
```python
class VideoAudioScheduler:
"""Composite scheduler dispatching to video and audio schedulers."""
def __init__(self, video_scheduler, audio_scheduler):
self.video_scheduler = video_scheduler
self.audio_scheduler = audio_scheduler
def step(self, noise_pred, t, latents, return_dict=False, generator=None):
video_out = self.video_scheduler.step(noise_pred[0], t[0], latents[0], return_dict=False, generator=generator)[0]
audio_out = self.audio_scheduler.step(noise_pred[1], t[1], latents[1], return_dict=False, generator=generator)[0]
return ((video_out, audio_out),)
class MyVideoAudioPipeline(nn.Module, CFGParallelMixin):
def __init__(self, ...):
self.scheduler = VideoAudioScheduler(video_sched, audio_sched)
def predict_noise(self, **kwargs):
video_pred, audio_pred = self.transformer(**kwargs)
return (video_pred, audio_pred)
def combine_cfg_noise(self, positive_noise_pred, negative_noise_pred, scale, normalize):
# ... (as above)
def diffuse(self, video_latents, audio_latents, timesteps_video, timesteps_audio, ...):
for t_v, t_a in zip(timesteps_video, timesteps_audio):
positive_kwargs = {...}
negative_kwargs = {...} if do_true_cfg else None
video_pred, audio_pred = self.predict_noise_maybe_with_cfg(
do_true_cfg=do_true_cfg, true_cfg_scale=self.guidance_scale,
positive_kwargs=positive_kwargs, negative_kwargs=negative_kwargs,
)
video_latents, audio_latents = self.scheduler_step_maybe_with_cfg(
(video_pred, audio_pred), (t_v, t_a),
(video_latents, audio_latents), do_true_cfg=do_true_cfg,
generator=generator,
)
return video_latents, audio_latents
```
> **Note:** If you use a non-deterministic scheduler, e.g., DDPM, please set `self.scheduler_step_maybe_with_cfg(..., generator=torch.Generator(device).manual_seed(seed))` explicitly to control the randomness of scheduler step among ranks.
---
## Testing
+1 -1
View File
@@ -369,7 +369,7 @@ images = omni.generate(
2. **Compare performance** - Measure speedup vs baseline (expect 1.5x-2.0x)
3. **Verify output quality** - Visually compare cached vs uncached outputs (should be nearly identical)
See more detailed examples in [user guide for teacache](../../user_guide/diffusion/teacache.md).
See more detailed examples in [user guide for teacache](../../user_guide/diffusion/cache_acceleration/teacache.md).
---
+2 -2
View File
@@ -192,7 +192,7 @@ class _BaseScheduler(SchedulerInterface):
self._waiting = deque()
self._running = []
self._finished_req_ids = set()
self._max_batch_size = 1
self.max_num_running_reqs = 1
```
**Design Features**:
@@ -201,7 +201,7 @@ class _BaseScheduler(SchedulerInterface):
- **Shared cleanup logic**: Request-id registration, finish handling, and state removal are centralized instead of duplicated in each policy.
- **Current constraint**: `_max_batch_size` remains `1` because the current engine path is still synchronous request-mode execution.
- **Current constraint**: `max_num_running_reqs` remains `1` because the current engine path is still synchronous request-mode execution.
#### 2.4 Current `RequestScheduler` Policy
@@ -0,0 +1,285 @@
# Cache-DiT Guide
## Table of Content
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Example Script](#example-script)
- [Acceleration Methods](#acceleration-methods)
- [Configuration Parameters](#configuration-parameters)
- [Best Practices](#best-practices)
- [Troubleshooting](#troubleshooting)
- [Summary](#summary)
- [Additional Resources](#additional-resources)
---
## Overview
Cache-DiT accelerates diffusion transformer models through intelligent caching mechanisms, providing significant speedup with minimal quality loss. It supports multiple acceleration techniques that can be combined for optimal performance:
- **DBCache**: Dual Block Cache for reducing redundant computations
- **TaylorSeer**: Taylor expansion-based forecasting for faster inference
- **SCM**: Step Computation Masking for selective step computation
See supported models list in [Supported Models](../../diffusion_features.md#supported-models).
---
## Quick Start
### Basic Usage
Enable cache-dit acceleration by simply setting `cache_backend="cache_dit"`:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="cache_dit", # Enable Cache-DiT with defaults
)
outputs = omni.generate(
"a beautiful landscape",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
**Note**: When `cache_config` is not provided, Cache-DiT uses optimized default values. See the [Configuration Parameters](#configuration-parameters) section for details.
### Custom Configuration
To customize cache-dit settings, provide a `cache_config` dictionary, for example:
```python
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="cache_dit",
cache_config={
"Fn_compute_blocks": 1,
"Bn_compute_blocks": 0,
"max_warmup_steps": 4,
"residual_diff_threshold": 0.12,
},
)
```
---
## Example Script
### Offline Inference
Use the example script under `examples/offline_inference/text_to_image`:
```bash
cd examples/offline_inference/text_to_image
python text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "a cup of coffee on the table" \
--cache-backend cache_dit \
--num-inference-steps 50
```
See the [text_to_image.py](https://github.com/vllm-project/vllm-omni/blob/main/examples/offline_inference/text_to_image/text_to_image.py) for detailed configuration options.
The script uses cache-dit acceleration with a hybrid configuration combining DBCache, SCM, and TaylorSeer:
```python
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="cache_dit",
cache_config={
# Scheme: Hybrid DBCache + SCM + TaylorSeer
"Fn_compute_blocks": 1, # Optimized for single-transformer models
"Bn_compute_blocks": 0, # Number of backward compute blocks
"max_warmup_steps": 4, # Maximum warmup steps (works for few-step models)
"residual_diff_threshold": 0.24, # Higher threshold for more aggressive caching
"max_continuous_cached_steps": 3, # Limit to prevent precision degradation
# TaylorSeer parameters [cache-dit only]
"enable_taylorseer": False, # Disabled by default (not suitable for few-step models)
"taylorseer_order": 1, # TaylorSeer polynomial order
# SCM (Step Computation Masking) parameters [cache-dit only]
"scm_steps_mask_policy": None, # SCM mask policy: None (disabled), "slow", "medium", "fast", "ultra"
"scm_steps_policy": "dynamic", # SCM steps policy: "dynamic" or "static"
}
)
```
You can customize the configuration by modifying the `cache_config` dictionary to use only specific methods (e.g., DBCache only, DBCache + SCM, etc.) based on your quality and speed requirements.
For image-to-image tasks, use the example script under `examples/offline_inference/image_to_image`:
```bash
cd examples/offline_inference/image_to_image
python image_edit.py \
--model Qwen/Qwen-Image-Edit \
--prompt "make the sky more colorful" \
--image path/to/input/image.jpg \
--cache-backend cache_dit \
--num-inference-steps 50 \
--cache-dit-max-continuous-cached-steps 3 \
--cache-dit-residual-diff-threshold 0.24 \
--cache-dit-enable-taylorseer
```
See the [image_edit.py](https://github.com/vllm-project/vllm-omni/blob/main/examples/offline_inference/image_to_image/image_edit.py) for detailed configuration options.
### Online Serving
```bash
# Default configuration (recommended)
vllm serve Qwen/Qwen-Image --omni --port 8091 --cache-backend cache_dit
# Custom configuration
vllm serve Qwen/Qwen-Image --omni --port 8091 \
--cache-backend cache_dit \
--cache-config '{"Fn_compute_blocks": 1, "residual_diff_threshold": 0.12}'
```
---
## Acceleration Methods
For comprehensive illustration, please view Cache-DiT [User Guide](https://cache-dit.readthedocs.io/en/latest/user_guide/OVERVIEWS/).
### 1. DBCache (Dual Block Cache)
DBCache intelligently caches intermediate transformer block outputs when the residual differences between consecutive steps are small, reducing redundant computations without sacrificing quality.
**Example Configuration**:
```python
cache_config={
"Fn_compute_blocks": 8, # Use first 8 blocks for difference computation
"Bn_compute_blocks": 0, # No additional fusion blocks
"max_warmup_steps": 8, # Cache after 8 warmup steps
"residual_diff_threshold": 0.12, # Lower threshold for faster inference
"max_cached_steps": -1, # No limit on cached steps
}
```
**Performance Tips**:
- Default `Fn_compute_blocks=1` works well for most cases. Some models (e.g., [FLUX.2-klein](https://github.com/wtomin/vllm-omni/blob/main/vllm_omni/diffusion/cache/cache_dit_backend.py#L363)) use a larger value for `Fn_compute_blocks` for a balanced performance.
- Increase `residual_diff_threshold` (e.g., 0.12-0.15) for faster inference with slight quality trade-off, or decrease from default 0.24 for higher quality.
- Default `max_warmup_steps=4` is optimized for few-step models. Increase to 6-8 for more steps if needed.
### 2. TaylorSeer
TaylorSeer uses Taylor expansion to forecast future hidden states, allowing the model to skip some computation steps while maintaining quality.
**Example Configuration**:
```python
cache_config={
"enable_taylorseer": True,
"taylorseer_order": 1, # First-order Taylor expansion
}
```
**Performance Tips**:
- TaylorSeer is **not suitable for few-step distilled models**.
- Use `taylorseer_order=1` for most cases (good balance of speed and quality).
- Combine with DBCache for maximum acceleration.
- Higher orders (2-3) may improve quality but reduce speed gains.
### 3. SCM (Step Computation Masking)
SCM allows you to specify which steps must be computed and which can use cached results, similar to LeMiCa/EasyCache style acceleration.
`scm_steps_mask_policy` options (number of compute steps out of 28):
| Policy | Compute Steps | Speed | Quality |
|--------|--------------|-------|---------|
| `None` (default) | All | Baseline | Best |
| `"slow"` | 18 / 28 | Moderate | High |
| `"medium"` | 15 / 28 | Balanced | Good |
| `"fast"` | 11 / 28 | Fast | Moderate |
| `"ultra"` | 8 / 28 | Fastest | Lower |
**Example Configuration**:
```python
cache_config={
"scm_steps_mask_policy": "medium", # Balanced speed/quality
"scm_steps_policy": "dynamic", # Use dynamic cache
}
```
**Performance Tips**:
- SCM is disabled by default. Enable it by setting a policy value if you need additional acceleration.
- Start with `"medium"` policy and adjust based on quality requirements.
- Use `"fast"` or `"ultra"` for maximum speed when quality can be slightly compromised.
- `"dynamic"` policy generally provides better quality than `"static"`.
- SCM mask is automatically regenerated when `num_inference_steps` changes during inference.
---
## Configuration Parameters
In `cache_config` passed to `Omni` constructor, it accepts the arguments of `DBCacheConfig` ([Cache-DiT API Reference](https://cache-dit.readthedocs.io/en/latest/user_guide/CACHE_API/)). Key parameters are listed below:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `Fn_compute_blocks` | int | 1 | First n blocks for difference computation (optimized for single-transformer models) |
| `Bn_compute_blocks` | int | 0 | Last n blocks for fusion |
| `max_warmup_steps` | int | 4 | Steps before caching starts (optimized for few-step distilled models) |
| `max_cached_steps` | int | -1 | Max cached steps (-1 = unlimited) |
| `max_continuous_cached_steps` | int | 3 | Max consecutive cached steps (prevents precision degradation) |
| `residual_diff_threshold` | float | 0.24 | Residual difference threshold (higher for more aggressive caching) |
| `num_inference_steps` | int \| None | None | Initial inference steps for SCM mask generation (optional, auto-refreshed during inference) |
| `enable_taylorseer` | bool | False | Enable TaylorSeer acceleration (not suitable for few-step distilled models) |
| `taylorseer_order` | int | 1 | Taylor expansion order |
| `scm_steps_mask_policy` | str \| None | None | SCM mask policy (None, "slow", "medium", "fast", "ultra") |
| `scm_steps_policy` | str | "dynamic" | SCM computation policy ("dynamic" or "static") |
---
## Best Practices
### When to Use
**Good for:**
- Production deployments requiring fast inference
- Diffusion transformer models (DiT architecture)
- Scenarios where 1.5x-3x speedup is valuable
**Not for:**
- Non-DiT architectures (use model-specific acceleration instead)
- Models already using few-step distillation (< 10 steps)
---
## Troubleshooting
### Common Issue 1: Quality Degradation
**Symptoms**: Generated images have visible artifacts or lower quality
**Solution**:
```python
# Reduce aggressiveness - use more conservative settings
cache_config={
"residual_diff_threshold": 0.20, # Lower threshold (closer to default 0.24)
"Fn_compute_blocks": 8, # Use more blocks for better decisions
"max_warmup_steps": 6, # Longer warmup
"scm_steps_mask_policy": "slow", # More compute steps
}
```
---
## Summary
Using Cache-DiT acceleration:
1. ✅ **Enable Cache-DiT** - Set `cache_backend="cache_dit"` to get 1.5x-3x speedup with optimized defaults
2. ✅ **(Optional) Customize** - Adjust `cache_config` parameters for specific speed/quality trade-offs
@@ -0,0 +1,194 @@
# TeaCache 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
TeaCache accelerates diffusion model inference by caching transformer computations when consecutive timesteps are similar, providing **1.5x-2.0x speedup** with minimal quality loss. It dynamically decides whether to reuse cached outputs based on input similarity, making it ideal for production deployments where inference speed matters without sacrificing generation quality.
See supported models list in [Supported Models](../../diffusion_features.md#supported-models).
---
## Quick Start
### Basic Usage
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="tea_cache",
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
### Custom Configuration
```python
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="tea_cache",
cache_config={
"rel_l1_thresh": 0.2, # Controls speed/quality tradeoff
},
)
```
### Using Environment Variable
You can also enable TeaCache via environment variable:
```bash
export DIFFUSION_CACHE_BACKEND=tea_cache
```
Then initialize without explicitly setting `cache_backend`:
```python
from vllm_omni import Omni
omni = Omni(
model="Qwen/Qwen-Image",
cache_config={"rel_l1_thresh": 0.2}
)
```
---
## Example Script
### Offline Inference
Use python script under `examples/offline_inference/text_to_image/` or `examples/offline_inference/image_to_image/` with CLI:
```bash
# Text-to-image example
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--cache-backend tea_cache
# Image-to-image example
python examples/offline_inference/image_to_image/image_edit.py \
--model Qwen/Qwen-Image-Edit \
--image input.png \
--prompt "Edit description" \
--cache-backend tea_cache \
--tea-cache-rel-l1-thresh 0.25
```
See the [text_to_image.py](https://github.com/vllm-project/vllm-omni/blob/main/examples/offline_inference/text_to_image/text_to_image.py) or [image_edit.py](https://github.com/vllm-project/vllm-omni/blob/main/examples/offline_inference/image_to_image/image_edit.py) for detailed configuration options.
### Online Serving
```bash
# Default configuration
vllm serve Qwen/Qwen-Image --omni --port 8091 --cache-backend tea_cache
# Custom configuration
vllm serve Qwen/Qwen-Image --omni --port 8091 \
--cache-backend tea_cache \
--cache-config '{"rel_l1_thresh": 0.2}'
```
---
## Configuration Parameters
In `OmniDiffusionConfig`
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `rel_l1_thresh` | float | `0.2` | Similarity threshold for cache reuse. Lower values prioritize quality (less caching), higher values prioritize speed (more caching). Suggested range: 0.1-0.8 |
| `coefficients` | list[float] \| None | `None` | Polynomial coefficients for rescaling L1 distance. Must contain exactly 5 elements if provided. If `None`, uses model-specific defaults based on transformer type. |
Users can find the default model coefficients in [`vllm_omni/diffusion/cache/teacache/config.py`](https://github.com/vllm-project/vllm-omni/blob/main/vllm_omni/diffusion/cache/teacache/config.py), for example:
```python
_MODEL_COEFFICIENTS = {
# Qwen-Image transformer coefficients from ComfyUI-TeaCache
# Tuned specifically for Qwen's dual-stream transformer architecture
# Used for all Qwen-Image Family pipelines, in general
"QwenImageTransformer2DModel": [
-4.50000000e02,
2.80000000e02,
-4.50000000e01,
3.20000000e00,
-2.00000000e-02,
],
...
}
```
---
## Best Practices
### When to Use
**Good for:**
- Production deployments requiring faster inference, tolerant of minimal quality loss
- Scenarios where 1.5-2x speedup is valuable
- Useful for single-card acceleration
**Not for:**
- Maximum quality requirements where no degradation is acceptable
- Very short inference runs (< 20 steps) where caching overhead may outweigh benefits
---
## Troubleshooting
### Common Issue 1: Quality Degradation
**Symptoms**: Generated images show artifacts, reduced detail, or inconsistent quality compared to non-cached results
**Solution**:
```python
# Lower the threshold for more conservative caching
cache_config={"rel_l1_thresh": 0.1}
```
### Common Issue 2: Limited Speedup
**Symptoms**: Actual speedup is less than expected (< 1.3x)
**Solutions**:
1. Increase the threshold to enable more aggressive caching:
```python
cache_config={"rel_l1_thresh": 0.8}
```
2. Ensure you're using sufficient inference steps (35+ recommended)
3. Check that your model architecture is supported (see Supported Models section)
---
## Summary
1. ✅ **Enable TeaCache** - Set `cache_backend="tea_cache"` to get 1.5x-2.0x speedup with optimized defaults
2. ✅ **(Optional) Customize** - Adjust thresholds and polynomial coefficients for specific speed/quality trade-offs
@@ -1,228 +0,0 @@
# Cache-DiT Acceleration Guide
This guide explains how to use cache-dit acceleration in vLLM-Omni to speed up diffusion model inference.
## Overview
Cache-dit is a library that accelerates diffusion transformer models through intelligent caching mechanisms. It supports multiple acceleration techniques that can be combined for optimal performance:
- **DBCache**: Dual Block Cache for reducing redundant computations
- **TaylorSeer**: Taylor expansion-based forecasting for faster inference
- **SCM**: Step Computation Masking for selective step computation
## Quick Start
### Basic Usage
Enable cache-dit acceleration by simply setting `cache_backend="cache_dit"`. Cache-dit will use its recommended default parameters:
```python
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# Simplest way: just enable cache-dit with default parameters
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="cache_dit",
)
images = omni.generate(
"a beautiful landscape",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
**Default Parameters**: When `cache_config` is not provided, cache-dit uses optimized default values. See the [Configuration Reference](#configuration-reference) section for a complete list of all parameters and their default values.
### Custom Configuration
To customize cache-dit settings, provide a `cache_config` dictionary, for example:
```python
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="cache_dit",
cache_config={
"Fn_compute_blocks": 1,
"Bn_compute_blocks": 0,
"max_warmup_steps": 4,
"residual_diff_threshold": 0.12,
},
)
```
## Online Serving (OpenAI-Compatible)
Enable Cache-DiT for online serving by passing `--cache-backend cache_dit` when starting the server:
```bash
# Use Cache-DiT default (recommended) parameters
vllm serve Qwen/Qwen-Image --omni --port 8091 --cache-backend cache_dit
```
To customize Cache-DiT settings for online serving, pass a JSON string via `--cache-config`:
```bash
vllm serve Qwen/Qwen-Image --omni --port 8091 \
--cache-backend cache_dit \
--cache-config '{"Fn_compute_blocks": 1, "Bn_compute_blocks": 0, "max_warmup_steps": 4, "residual_diff_threshold": 0.12}'
```
## Acceleration Methods
For comprehensive illustration, please view cache-dit [User_Guide](https://cache-dit.readthedocs.io/en/latest/user_guide/OVERVIEWS/)
### 1. DBCache (Dual Block Cache)
DBCache intelligently caches intermediate transformer block outputs when the residual differences between consecutive steps are small, reducing redundant computations without sacrificing quality.
**Key Parameters**:
- `Fn_compute_blocks` (int, default: 1): Number of **first n** transformer blocks used to compute stable feature differences. Higher values provide more accurate caching decisions but increase computation.
- `Bn_compute_blocks` (int, default: 0): Number of **last n** transformer blocks used for additional fusion. These blocks act as an auto-scaler for approximate hidden states.
- `max_warmup_steps` (int, default: 4): Number of initial steps where caching is disabled to ensure the model learns sufficient features before caching begins. Optimized for few-step distilled models.
- `residual_diff_threshold` (float, default: 0.24): Threshold for residual difference. Higher values lead to faster performance but may reduce precision. Default uses a relatively higher threshold for more aggressive caching.
- `max_cached_steps` (int, default: -1): Maximum number of cached steps. Set to -1 for unlimited caching.
- `max_continuous_cached_steps` (int, default: 3): Maximum number of consecutive cached steps. Limits consecutive caching to prevent precision degradation.
**Example Configuration**:
```python
cache_config={
"Fn_compute_blocks": 8, # Use first 8 blocks for difference computation
"Bn_compute_blocks": 0, # No additional fusion blocks
"max_warmup_steps": 8, # Cache after 8 warmup steps
"residual_diff_threshold": 0.12, # Higher threshold for faster inference
"max_cached_steps": -1, # No limit on cached steps
}
```
**Performance Tips**:
- Default `Fn_compute_blocks=1` works well for most cases. Increase to 8-12 for larger models or when more accuracy is needed
- Increase `residual_diff_threshold` (e.g., 0.12-0.15) for faster inference with slight quality trade-off, or decrease from default 0.24 for higher quality
- Default `max_warmup_steps=4` is optimized for few-step models. Increase to 6-8 for more steps if needed
### 2. TaylorSeer
TaylorSeer uses Taylor expansion to forecast future hidden states, allowing the model to skip some computation steps while maintaining quality.
**Key Parameters**:
- `enable_taylorseer` (bool, default: False): Enable TaylorSeer acceleration
- `taylorseer_order` (int, default: 1): Order of Taylor expansion. Higher orders provide better accuracy but require more computation.
**Example Configuration**:
```python
cache_config={
"enable_taylorseer": True,
"taylorseer_order": 1, # First-order Taylor expansion
}
```
**Performance Tips**:
- Use `taylorseer_order=1` for most cases (good balance of speed and quality)
- Combine with DBCache for maximum acceleration
- Higher orders (2-3) may improve quality but reduce speed gains
### 3. SCM (Step Computation Masking)
SCM allows you to specify which steps must be computed and which can use cached results, similar to LeMiCa/EasyCache style acceleration.
**Key Parameters**:
- `scm_steps_mask_policy` (str | None, default: None): Predefined mask policy. Options:
- `None`: SCM disabled (default)
- `"slow"`: More compute steps, higher quality (18 compute steps out of 28)
- `"medium"`: Balanced (15 compute steps out of 28)
- `"fast"`: More cache steps, faster inference (11 compute steps out of 28)
- `"ultra"`: Maximum speed (8 compute steps out of 28)
- `scm_steps_policy` (str, default: "dynamic"): Policy for cached steps:
- `"dynamic"`: Use dynamic cache for masked steps (recommended)
- `"static"`: Use static cache for masked steps
**Example Configuration**:
```python
cache_config={
"scm_steps_mask_policy": "medium", # Balanced speed/quality
"scm_steps_policy": "dynamic", # Use dynamic cache
}
```
**Performance Tips**:
- SCM is disabled by default (`scm_steps_mask_policy=None`). Enable it by setting a policy value if you need additional acceleration
- Start with `"medium"` policy and adjust based on quality requirements
- Use `"fast"` or `"ultra"` for maximum speed when quality can be slightly compromised
- `"dynamic"` policy generally provides better quality than `"static"`
- SCM mask is automatically regenerated when `num_inference_steps` changes during inference
## Configuration Reference
### DiffusionCacheConfig Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `Fn_compute_blocks` | int | 1 | First n blocks for difference computation (optimized for single-transformer models) |
| `Bn_compute_blocks` | int | 0 | Last n blocks for fusion |
| `max_warmup_steps` | int | 4 | Steps before caching starts (optimized for few-step distilled models) |
| `max_cached_steps` | int | -1 | Max cached steps (-1 = unlimited) |
| `max_continuous_cached_steps` | int | 3 | Max consecutive cached steps (prevents precision degradation) |
| `residual_diff_threshold` | float | 0.24 | Residual difference threshold (higher for more aggressive caching) |
| `num_inference_steps` | int \| None | None | Initial inference steps for SCM mask generation (optional, auto-refreshed during inference) |
| `enable_taylorseer` | bool | False | Enable TaylorSeer acceleration (not suitable for few-step distilled models) |
| `taylorseer_order` | int | 1 | Taylor expansion order |
| `scm_steps_mask_policy` | str \| None | None | SCM mask policy (None, "slow", "medium", "fast", "ultra") |
| `scm_steps_policy` | str | "dynamic" | SCM computation policy ("dynamic" or "static") |
## Example: Accelerate Text-to-Image Generation with CacheDiT
See `examples/offline_inference/text_to_image/text_to_image.py` for a complete working example with cache-dit acceleration.
```bash
# Enable cache-dit with hybrid acceleration
cd examples/offline_inference/text_to_image
python text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "a cup of coffee on the table" \
--cache-backend cache_dit \
--num-inference-steps 50
```
The script uses cache-dit acceleration with a hybrid configuration combining DBCache, SCM, and TaylorSeer:
```python
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="cache_dit",
cache_config={
# Scheme: Hybrid DBCache + SCM + TaylorSeer
# DBCache
"Fn_compute_blocks": 8,
"Bn_compute_blocks": 0,
"max_warmup_steps": 4,
"residual_diff_threshold": 0.12,
# TaylorSeer
"enable_taylorseer": True,
"taylorseer_order": 1,
# SCM
"scm_steps_mask_policy": "fast", # Set to None to disable SCM
"scm_steps_policy": "dynamic",
},
)
```
You can customize the configuration by modifying the `cache_config` dictionary to use only specific methods (e.g., DBCache only, DBCache + SCM, etc.) based on your quality and speed requirements.
To test another model, you can modify `--model` with the target model identifier like `Tongyi-MAI/Z-Image-Turbo` and update `cache_config` according the model architecture (e.g., number of transformer blocks).
## Additional Resources
- [Cache-DiT User Guide](https://cache-dit.readthedocs.io/en/latest/user_guide/OVERVIEWS/)
- [Cache-DiT Benchmark](https://cache-dit.readthedocs.io/en/latest/benchmark/HYBRID_CACHE/)
- [DBCache Technical Details](https://cache-dit.readthedocs.io/en/latest/user_guide/CACHE_API/)
@@ -0,0 +1,169 @@
# CFG-Parallel 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
CFG-Parallel accelerates diffusion models by distributing positive and negative classifier-free guidance (CFG) passes across different GPUs, providing ~1.8x speedup when CFG is enabled. It's ideal for image editing tasks that require guidance scales greater than 1.0.
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
from PIL import Image
omni = Omni(
model="Qwen/Qwen-Image-Edit",
parallel_config=DiffusionParallelConfig(cfg_parallel_size=2), # Enable CFG-Parallel
)
input_image = Image.open("input.png").convert("RGB")
outputs = omni.generate(
{
"prompt": "turn this cat to a dog",
"negative_prompt": "low quality, blurry",
"multi_modal_data": {"image": input_image},
},
OmniDiffusionSamplingParams(
true_cfg_scale=4.0,
num_inference_steps=50,
),
)
```
---
## Example Script
### Offline Inference
Use python script under `examples/offline_inference/image_to_image/image_edit.py`:
```bash
cd examples/offline_inference/image_to_image/
python image_edit.py \
--model "Qwen/Qwen-Image-Edit" \
--image "input.png" \
--prompt "turn this cat to a dog" \
--negative-prompt "low quality, blurry" \
--cfg-scale 4.0 \
--output "edited_image.png" \
--cfg-parallel-size 2
```
### Online Serving
Enable CFG-Parallel in online serving:
```bash
# Default configuration
vllm serve Qwen/Qwen-Image-Edit --omni --port 8091 --cfg-parallel-size 2
```
---
## Configuration Parameters
In `DiffusionParallelConfig`
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `cfg_parallel_size` | int | 1 | Number of GPUs for CFG parallelism. Set to 2 to enable CFG-Parallel (rank 0 for positive, rank 1 for negative branch) |
!!! info
Most models support `cfg_parallel_size=2` (positive branch on rank 0, negative branch on rank 1). **Bagel** is an exception: it supports `cfg_parallel_size=3`, which adds a third branch on rank 2 for full three-way CFG parallelism.
---
## Best Practices
### When to Use
**Good for:**
- Tasks requiring classifier-free guidance
- Multi-GPU setups (at least 2 GPUs available)
- Combining with other parallelism methods (sequence/tensor parallel)
**Not for:**
- Single GPU setups
- Models that don't support CFG-Parallel (check [supported models](../../diffusion_features.md#supported-models))
- Workloads without negative prompts or classifier-free guidance
- Very short inference runs (< 10 steps) where parallelism overhead may outweigh benefits
### Expected Performance
| Configuration | Speedup | Quality | Use Case |
|--------------|---------|---------|----------|
| CFG-Parallel (2 GPUs) | 1.5~1.8x | No degradation | Large model, VRAM limited |
---
## Troubleshooting
### Common Issue 1: No Speedup with CFG-Parallel
**Symptoms**: CFG-Parallel enabled but no performance improvement
**Solutions**:
1. **Ensure CFG scale is set correctly:**
```python
# Bad: No CFG effect
sampling_params = OmniDiffusionSamplingParams(num_inference_steps=50)
# Good: CFG-Parallel will work
sampling_params = OmniDiffusionSamplingParams(
num_inference_steps=50,
true_cfg_scale=4.0 # Must be > 1.0
)
```
2. **Add negative prompt:**
```python
outputs = omni.generate(
{
"prompt": "beautiful landscape",
"negative_prompt": "low quality, blurry", # Required for best results
"multi_modal_data": {"image": input_image}
},
sampling_params
)
```
3. **Check model support:**
- Verify your model in [supported models](../../diffusion_features.md#supported-models)
- Some models don't support CFG-Parallel
---
## Summary
1. ✅ **Enable CFG-Parallel** - Set `cfg_parallel_size=2` in `DiffusionParallelConfig` to get speedup when using CFG
2. ✅ **Set CFG Scale** - Ensure `true_cfg_scale > 1.0` in `OmniDiffusionSamplingParams` for CFG-Parallel to take effect
3. ✅ **Check Model Support** - Verify your model supports CFG-Parallel in [supported models](../../diffusion_features.md#supported-models)
@@ -0,0 +1,87 @@
# Expert Parallelism Guide
## Table of Content
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Configuration Parameters](#configuration-parameters)
- [Best Practices](#best-practices)
- [Summary](#summary)
---
## Overview
Unlike Tensor Parallelism which shards every layer's weights, Expert Parallelism (EP) only shards the MoE expert MLP blocks. This significantly reduces the memory footprint of MoE models (e.g., HunyuanImage3.0) while maintaining constant dense-equivalent compute efficiency.
During the forward pass, a gating mechanism routes tokens to their designated experts, requiring all-to-all communication to dispatch tokens to the correct ranks and combine results.
See supported models list in [Supported Models](../../diffusion_features.md#supported-models).
!!! note "EP Size Constraint"
The effective EP size equals `tp × sp × cfg × dp`. At least one of TP/SP/CFG/DP must be set when EP is enabled.
---
## Quick Start
### Basic Usage
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="tencent/HunyuanImage-3.0",
parallel_config=DiffusionParallelConfig(
tensor_parallel_size=8,
enable_expert_parallel=True,
),
)
outputs = omni.generate(
"A brown and white dog is running on the grass",
OmniDiffusionSamplingParams(
num_inference_steps=50,
width=1024,
height=1024,
),
)
```
---
## Configuration Parameters
In `DiffusionParallelConfig`:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enable_expert_parallel` | bool | False | Enable Expert Parallelism for MoE models |
EP size is derived automatically as `tp × sp × cfg × dp` — configure at least one of those to set the EP degree.
---
## Best Practices
### When to Use
**Good for:**
- MoE models (e.g., HunyuanImage3.0) with numbers of experts
- Memory-constrained multi-GPU setups where only expert blocks need sharding
**Not for:**
- Dense models (no MoE layers) — EP has no effect
- Single GPU setups
---
## Summary
1. ✅ **Enable EP** - Set `enable_expert_parallel=True` in `DiffusionParallelConfig` for MoE models
2. ✅ **Set parallelism degree** - At least one of `tensor_parallel_size` / `ulysses_degree` / `cfg_parallel_size` must be > 1 to define the EP size
@@ -0,0 +1,149 @@
# HSDP Guide
## Table of Content
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Example Script](#example-script)
- [Configuration Parameters](#configuration-parameters)
- [Best Practices](#best-practices)
- [Summary](#summary)
---
## Overview
HSDP (Hybrid Sharded Data Parallel) shards model weights across GPUs to reduce per-GPU memory usage. This enables inference of large models (e.g., Wan2.2 14B) on GPUs with limited memory.
Unlike Tensor Parallelism which splits computation, HSDP uses PyTorch's FSDP2 to shard and redistribute weights at runtime. Each GPU only holds a fraction of the model weights, and weights are gathered on-demand during forward passes.
See supported models list in [Supported Models](../../diffusion_features.md#supported-models).
**Operating Modes:**
- **Standalone Mode**: HSDP alone without other parallelism. Must specify `hsdp_shard_size` explicitly.
- **Combined Mode**: HSDP overlays on top of other parallelism (Ulysses-SP, CFG-Parallel). HSDP dimensions must match world_size.
---
## Quick Start
### Basic Usage
Simplest working example (standalone HSDP, shard across 4 GPUs):
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
parallel_config=DiffusionParallelConfig(
use_hsdp=True,
hsdp_shard_size=4, # Shard across 4 GPUs
),
)
outputs = omni.generate(
"A cat playing piano",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
### Combined with Sequence Parallel
```python
omni = Omni(
model="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
parallel_config=DiffusionParallelConfig(
ulysses_degree=4, # Sequence parallel
use_hsdp=True, # HSDP overlays on SP
),
)
```
---
## Example Script
### Offline Inference
Use Python script under `examples/offline_inference/image_to_video/`:
```bash
# Standalone HSDP: shard across 4 GPUs
python examples/offline_inference/image_to_video/image_to_video.py \
--model Wan-AI/Wan2.2-T2V-A14B-Diffusers \
--use-hsdp \
--hsdp-shard-size 4
# Combined HSDP + Sequence Parallel
python examples/offline_inference/image_to_video/image_to_video.py \
--model Wan-AI/Wan2.2-T2V-A14B-Diffusers \
--ulysses-degree 4 \
--use-hsdp
```
### Online Serving
**Standalone HSDP** (shard model across 4 GPUs):
```bash
vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --port 8091 \
--use-hsdp --hsdp-shard-size 4
```
**Combined with Sequence Parallel**:
```bash
vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --port 8091 \
--use-hsdp --usp 4
```
---
## Configuration Parameters
In `DiffusionParallelConfig`:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `use_hsdp` | bool | False | Enable HSDP |
| `hsdp_shard_size` | int | -1 | Number of GPUs to shard weights across. `-1` = auto (requires other parallelism > 1) |
| `hsdp_replicate_size` | int | 1 | Number of replica groups. Each group holds a full sharded copy |
**Constraints:**
- `hsdp_replicate_size × hsdp_shard_size == world_size`
- HSDP cannot be used with Tensor Parallelism (`tensor_parallel_size` must be 1)
---
## Best Practices
### When to Use
**Good for:**
- Very large models (e.g., Wan2.2 14B)
- Multi-GPU setups where memory reduction is the primary goal
- Combining with Sequence Parallelism for large video models
**Not for:**
- Models that fit comfortably in single-GPU memory
- Use cases requiring Tensor Parallelism (HSDP and TP are mutually exclusive)
### Adding HSDP Support to New Models
For detailed instructions on adding HSDP support to new models, see the [HSDP Contributing Guide](../../../design/feature/hsdp.md).
---
## Summary
1. ✅ **Enable HSDP** - Set `use_hsdp=True` and `hsdp_shard_size` to reduce per-GPU memory for large models
2. ✅ **Combine with SP** - Use together with `ulysses_degree` for video models requiring both memory reduction and sequence parallelism
3. ⚠️ **Incompatible with TP** - `tensor_parallel_size` must be 1 when HSDP is enabled
@@ -0,0 +1,16 @@
# Parallelism Acceleration Guide
This guide covers the parallelism methods in vLLM-Omni for speeding up diffusion model inference and reducing per-device memory requirements.
## 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) |
See [Supported Models](../../diffusion_features.md#supported-models) for per-model compatibility.
@@ -0,0 +1,233 @@
# Sequence Parallelism Guide
## Table of Content
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Example Script](#example-script)
- [Best Practices](#best-practices)
- [Troubleshooting](#troubleshooting)
- [Summary](#summary)
---
## Overview
Sequence parallelism splits the input along the sequence dimension across multiple GPUs, allowing each device to process only a portion of the sequence. vLLM-Omni provides 1.5x-3.6x speedup for large images and videos using DeepSpeed Ulysses, Ring-Attention, or hybrid approaches. Use sequence parallelism when generating high-resolution images/videos that don't fit on a single GPU or require faster inference.
See supported models list in [Diffusion Features - Supported Models](../../diffusion_features.md#supported-models).
**Supported Methods:**
- **DeepSpeed Ulysses Sequence Parallel (Ulysses-SP)** ([paper](https://arxiv.org/pdf/2309.14509)): Uses all-to-all communication for subset of attention heads per device
- **Ring-Attention** ([paper](https://arxiv.org/abs/2310.01889)): Uses ring-based P2P communication with sharded sequence dimension throughout
- **Hybrid Ulysses + Ring**: Combines both for larger scale parallelism (`ulysses_degree × ring_degree`)
---
## Quick Start
### Basic Usage - Ulysses-SP
Simplest working example with Ulysses Sequence Parallel:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Qwen/Qwen-Image",
parallel_config=DiffusionParallelConfig(ulysses_degree=2) # Enable Ulysses-SP
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=1024, height=1024),
)
```
!!! note "Experimental UAA mode"
`ulysses_mode="advanced_uaa"` is an experimental extension to Ulysses-SP. It lets Ulysses attention handle arbitrary sequence lengths and arbitrary attention head counts without relying on `attention_mask`-based token padding.
In hybrid Ulysses + Ring mode, Ring still requires every rank in the same ring group to observe the same post-Ulysses sequence length. If that condition is not met, vLLM-Omni raises a validation error instead of entering the ring kernel with inconsistent shapes.
To enable the experimental UAA mode, use a model/configuration that requires it. For example, `Tongyi-MAI/Z-Image-Turbo` has 30 attention heads, so `ulysses_degree=4` requires UAA because 30 is not divisible by 4:
```python
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
parallel_config=DiffusionParallelConfig(
ulysses_degree=4,
ulysses_mode="advanced_uaa",
),
)
```
### Alternative Methods
**Ring-Attention** (better for very long sequences):
```python
omni = Omni(
model="Qwen/Qwen-Image",
parallel_config=DiffusionParallelConfig(ring_degree=2) # Enable Ring-Attention
)
```
**Hybrid Ulysses + Ring** (for larger scale):
```python
omni = Omni(
model="Qwen/Qwen-Image",
parallel_config=DiffusionParallelConfig(ulysses_degree=2, ring_degree=2) # 4 GPUs total
)
```
---
## Example Script
### Offline Inference
Use Python script under `examples/offline_inference/text_to_image/text_to_image.py`:
**Ulysses-SP:**
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "A cat sitting on a windowsill" \
--ulysses-degree 2 \
--width 1024 --height 1024
```
**Ring-Attention:**
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "A cat sitting on a windowsill" \
--ring-degree 2 \
--width 1024 --height 1024
```
**Hybrid Ulysses + Ring:**
```bash
# Hybrid: 2 Ulysses × 2 Ring = 4 GPUs total
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "A cat sitting on a windowsill" \
--ulysses-degree 2 --ring-degree 2 \
--width 1024 --height 1024
```
### Online Serving
**Ulysses-SP:**
```bash
# Text-to-image (requires >= 2 GPUs)
vllm serve Qwen/Qwen-Image --omni --port 8091 --usp 2
```
**Ulysses-SP with UAA mode** (for models with non-divisible head counts):
```bash
vllm serve Tongyi-MAI/Z-Image-Turbo --omni --port 8091 --usp 4 --ulysses-mode advanced_uaa
```
**Ring-Attention:**
```bash
# Text-to-image (requires >= 2 GPUs)
vllm serve Qwen/Qwen-Image --omni --port 8091 --ring 2
```
**Hybrid Ulysses + Ring:**
```bash
# Text-to-image (requires >= 4 GPUs)
vllm serve Qwen/Qwen-Image --omni --port 8091 --usp 2 --ring 2
```
---
## Configuration Parameters
In `DiffusionParallelConfig`:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `ulysses_degree` | int | 1 | Number of GPUs for Ulysses-SP. Uses all-to-all communication. |
| `ring_degree` | int | 1 | Number of GPUs for Ring-Attention. Uses P2P ring communication. |
| `ulysses_mode` | str | `"default"` | Ulysses attention mode. Set to `"advanced_uaa"` to handle arbitrary sequence lengths and head counts without padding. |
**Notes:**
- Total sequence parallel size equals to `ulysses_degree × ring_degree`
- Degrees must evenly divide the sequence length for optimal performance (or use `ulysses_mode="advanced_uaa"` for Ulysses-SP)
## Best Practices
### When to Use
**Good for:**
- Large images (1024x1024 or higher) or videos
- Fast inter-GPU communication, larger bandwidth (e.g., NVLink)
**Not for:**
- Small images (<1024px) - overhead exceeds benefit, use single GPU with cache instead
---
## Troubleshooting
### Common Issue 1: Performance Not Scaling
**Symptoms**: Adding GPUs doesn't improve speed proportionally, or higher parallelism degree is slower
**Diagnosis:**
```bash
# Check GPU topology
nvidia-smi topo -m
```
**Solutions:**
1. Check inter-GPU communication - NVLink is better than PCIe
2. Reduce parallelism degree if over-parallelized:
```python
# If 4 GPUs is slower than 2
parallel_config=DiffusionParallelConfig(ulysses_degree=2)
```
3. Try to switch between Ring-Attention and Ulysses-SP
- Ring-Attention has advantages, like communication-computation overlap, but the block-wise loop overhead is relatively higher, especially for short sequences
- Ulysses-SP: can benefit from larger bandwidth (such as NVLink), with two major constraints, the sequence length should be divisible by usp size, and the number of heads should be divisible by usp size (or use `ulysses_mode="advanced_uaa"`)
### Common Issue 2: Out of Memory (OOM)
**Symptoms**: CUDA OOM errors or process crashes with memory errors
**Solutions:**
1. Increase parallelism degree to split sequence more:
```python
parallel_config=DiffusionParallelConfig(ulysses_degree=4) # From 2
```
2. Combine with other parallelism method, e.g., tensor parallel, and memory optimization methods, e.g., cpu offloading.
## Summary
1. ✅ **Enable Sequence Parallelism** - Set `ulysses_degree` or `ring_degree` for long sequence generation
2. ✅ **UAA mode** - Use `ulysses_mode="advanced_uaa"` when head count is not divisible by `ulysses_degree`
3. ✅ **Troubleshooting** - Check GPU topology with `nvidia-smi topo -m`, reduce degree if performance doesn't scale
@@ -0,0 +1,151 @@
# Tensor 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
Tensor Parallelism (TP) shards some model weights across multiple GPUs, usually the Linear layers. This enables running large models that don't fit on a single GPU. It's essential for memory-constrained setups or very large models.
See supported models list in [Supported Models](../../diffusion_features.md#supported-models).
!!! note "TP Limitations for Diffusion Models"
We currently implement Tensor Parallelism (TP) only for the DiT (Diffusion Transformer) blocks. This is because the `text_encoder` component in vLLM-Omni uses the original Transformers implementation, which does not yet support TP.
- Good news: The text_encoder typically has minimal impact on overall inference performance.
- Bad news: When TP is enabled, every TP process retains a full copy of the text_encoder weights, leading to significant GPU memory waste.
We are actively refactoring this design to address this. For details and progress, please refer to [Issue #771](https://github.com/vllm-project/vllm-omni/issues/771).
---
## Quick Start
### Basic Usage
Simplest working example:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
parallel_config=DiffusionParallelConfig(tensor_parallel_size=2), # Enable TP
)
outputs = omni.generate(
"a cat reading a book",
OmniDiffusionSamplingParams(num_inference_steps=9),
)
```
---
## Example Script
### Offline Inference
Use Python script under `examples/offline_inference`, and enable TP:
```bash
# Text-to-Image with Qwen-Image
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--tensor-parallel-size 2
# Image Editing with Qwen-Image-Edit
python examples/offline_inference/image_to_image/image_edit.py \
--model Qwen/Qwen-Image-Edit \
--image input.png \
--prompt "Edit description" \
--tensor-parallel-size 2
```
### Online Serving
You can enable tensor parallelism in online serving via `--tensor-parallel-size`:
```bash
# Text-to-Image with Qwen-Image on 2 GPUs
vllm serve Qwen/Qwen-Image --omni --port 8091 \
--tensor-parallel-size 2
# Text-to-Image with Z-Image (TP=2 only)
vllm serve Tongyi-MAI/Z-Image-Turbo --omni --port 8091 \
--tensor-parallel-size 2
```
---
## Configuration Parameters
In `DiffusionParallelConfig`:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `tensor_parallel_size` | int | 1 | Number of GPUs to shard model weights across. Must divide number of heads. |
---
## Best Practices
### When to Use
**Good for:**
- Large models that don't fit on a single GPU, especially for models with large DiT blocks (transformer layers)
- Memory-constrained environments
**Not for:**
- When maximum throughput is needed and memory is sufficient
- Models with incompatible dimensions (e.g., Z-Image `num_heads=30`, which now supports `tensor_parallel_size=2`)
## Troubleshooting
### Common Issue 1: Out of Memory (OOM)
**Symptoms**: CUDA OOM errors during model loading or inference, process crashes with memory errors
**Solution**:
```python
# Step 1: Enable TP with smallest degree
parallel_config=DiffusionParallelConfig(tensor_parallel_size=2)
# Step 2: If still OOM, increase TP degree
parallel_config=DiffusionParallelConfig(tensor_parallel_size=4)
```
### Common Issue 2: Divisibility Error
**Symptoms**: Error like "Model dimension X not divisible by tensor_parallel_size Y"
**Solutions**:
1. Check model-specific constraints (e.g., Z-Image only supports TP=2)
2. Use a smaller TP size that divides model dimensions
3. Consult [Supported Models](../../diffusion_features.md#supported-models) for compatible TP sizes
---
## Summary
1. ✅ **Enable TP** - Set `--tensor-parallel-size` to reduce per-GPU memory
2. ✅ **Increase TP size** - Only increase if OOM persists
3. ⚠️ **Text encoder not sharded** - Known limitation
@@ -0,0 +1,200 @@
# VAE Patch 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
VAE Patch Parallelism distributes the VAE (Variational AutoEncoder) decode/encode computation across multiple GPUs by splitting the latent space into spatial tiles or patches. Each GPU processes a subset of tiles in parallel, significantly reducing peak memory consumption during the VAE decode stage while maintaining output quality.
This is particularly useful for:
- **High-resolution image generation** where VAE decode can become a memory bottleneck
- **Memory-constrained environments** where the VAE decode activation peak exceeds available VRAM
- **Multi-GPU setups** where you want to leverage distributed resources for the VAE stage
See supported models list in [Supported Models](../../diffusion_features.md#supported-models).
VAE Patch Parallelism uses two strategies based on image size:
| Strategy | Use Case | How It Works | Overlap Handling | Output Quality |
|----------|----------|--------------|------------------|----------------|
| **Tiled Decode** | Large images (triggers VAE tiling) | Distributes existing VAE tiling computation across ranks. Each rank decodes a subset of overlapping tiles. | Uses VAE's native `blend_v` and `blend_h` functions to seamlessly merge overlapping regions | Bit-identical (same logic as single-GPU tiling) |
| **Patch Decode** | Small images (no VAE tiling) | Splits latent into spatial patches with halos. Each rank decodes one patch with boundary context. | Halo regions provide edge context; core regions are directly stitched without blending | Near-identical (diff < 0.5%, visually imperceptible) |
VAE Patch Parallelism **reuses the DiT process group** (`dit_group`) and does not initialize a separate ProcessGroup. This means:
- **Shared ranks**: VAE patch parallelism uses the same GPU ranks as DiT parallelism (Tensor Parallel, Sequence Parallel, etc.)
- **Combined usage**: VAE patch parallelism is typically used together with other parallelism methods
- **Configuration alignment**: The `vae_patch_parallel_size` should be no greater than the size of your DiT process group
---
## Quick Start
### Basic Usage
Simplest working example:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
# TP=2 for DiT, VAE patch parallel also uses these 2 GPUs
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
parallel_config=DiffusionParallelConfig(
tensor_parallel_size=2, # Enable tensor parallelism for DiT
vae_patch_parallel_size=2, # Enable VAE patch parallelism
),
vae_use_tiling=True, # Required for VAE patch parallelism
)
outputs = omni.generate(
"a futuristic city at sunset, high resolution, 8k",
OmniDiffusionSamplingParams(
num_inference_steps=9,
height=1152, # High resolution benefits from VAE patch parallel
width=1152,
),
)
```
---
## Example Script
### Offline Inference
Use Python script under `examples/offline_inference/text_to_image/`:
```bash
# Text-to-Image with Z-Image
python examples/offline_inference/text_to_image/text_to_image.py \
--model Tongyi-MAI/Z-Image-Turbo \
--prompt "a futuristic city at sunset" \
--height 1152 \
--width 1152 \
--tensor-parallel-size 2 \
--vae-patch-parallel-size 2 \
--vae-use-tiling
```
### Online Serving
You can enable VAE patch parallelism in online serving via `--vae-patch-parallel-size`:
```bash
# Text-to-Image with Z-Image, TP=2 + VAE patch parallel=2
vllm serve Tongyi-MAI/Z-Image-Turbo --omni --port 8091 \
--tensor-parallel-size 2 \
--vae-patch-parallel-size 2 \
--vae-use-tiling
```
---
## Configuration Parameters
In `DiffusionParallelConfig`:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `vae_patch_parallel_size` | int | 1 | Number of GPUs for VAE patch/tile parallelism. Set to 2 or higher to enable. Should typically match `tensor_parallel_size` as they share the same process group. |
Additional requirements:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `vae_use_tiling` | bool | False | Must be set to `True` when using VAE patch parallelism. |
!!! note "Automatic VAE Tiling"
When `vae_patch_parallel_size > 1` and the model has a distributed VAE (`DistributedVaeMixin`), the system automatically sets `vae_use_tiling=True` if not already enabled.
---
## Best Practices
### When to Use
**Good for:**
- High-resolution image generation and long video generation
- Memory-constrained setups where VAE decode causes OOM
- Multi-GPU environments
**Not for:**
- Low-resolution images/videos where VAE decode is not a bottleneck
- Single GPU setups should use vae tiling decode, but not parallel vae tiling decode
- Models that do not support vae patch parallel
---
## Troubleshooting
### Common Issue 1: Model Not Support VAE Patch Parallel
**Symptoms**:
```
WARNING: vae_patch_parallel_size=2 is set but VAE patch parallelism is NOT enabled for xxxPipeline; ignoring.
```
**Root Cause**: VAE Patch Parallelism requires the model's VAE to implement `DistributedVaeMixin`. At startup, `vllm_omni/diffusion/registry.py` checks whether the instantiated pipeline has a `.vae` attribute that is an instance of `DistributedVaeMixin`. If it does not, the setting is silently ignored:
```python
vae_pp_size = od_config.parallel_config.vae_patch_parallel_size
is_distributed_vae = hasattr(model, "vae") and isinstance(model.vae, DistributedVaeMixin)
if vae_pp_size > 1 and not is_distributed_vae:
logger.warning(
"vae_patch_parallel_size=%d is set but VAE patch parallelism is NOT enabled for %s; ignoring.",
vae_pp_size,
od_config.model_class_name,
)
```
**Solutions**:
1. **Use a supported model** (recommended): check [Supported Models](../../diffusion_features.md#supported-models) for the VAE-Patch-Parallel column.
2. To add support for a new model, implement `DistributedVaeMixin` on its VAE class (contributions are welcome).
### Common Issue 2: `vae_patch_parallel_size` Exceeds DiT Process Group Size
**Symptoms**: Shows warning message, and vae patch parallel size is resized to DiT process group size
**Root Cause**: VAE Patch Parallelism reuses the DiT process group.
**Recommendation**: Always set `vae_patch_parallel_size` to be no greater than your DiT process group size.
Note that the size of DiT process group size equals to:
```text
dit_parallel_size = data_parallel_size
× cfg_parallel_size
× sequence_parallel_size
× pipeline_parallel_size
× tensor_parallel_size
```
_sequence_parallel_size = ulysses_degree × ring_degree_
---
## Summary
1. ✅ **Enable VAE Patch Parallelism** - Set `vae_patch_parallel_size`, `vae_use_tiling=True` in `DiffusionParallelConfig` to reduce VAE decode peak memory
2. ✅ **Use Long Sequence** - VAE patch parallelism benefits are most apparent at long sequence decoding
3. ✅ **Combine with other parallelism methods** - Suggest to use together with Tensor Parallel or CFG-Parallel for maximum memory savings
@@ -1,477 +0,0 @@
# Parallelism Acceleration Guide
This guide includes how to use parallelism methods in vLLM-Omni to speed up diffusion model inference as well as reduce the memory requirement on each device.
## Overview
The following parallelism methods are currently supported in vLLM-Omni:
1. DeepSpeed Ulysses Sequence Parallel (DeepSpeed Ulysses-SP) ([arxiv paper](https://arxiv.org/pdf/2309.14509)): Ulysses-SP splits the input along the sequence dimension and uses all-to-all communication to allow each device to compute only a subset of attention heads.
2. [Ring-Attention](#ring-attention) - splits the input along the sequence dimension and uses ring-based P2P communication to accumulate attention results, keeping the sequence dimension sharded
3. Classifier-Free-Guidance Parallel (CFG-Parallel): CFG-Parallel runs the positive/negative prompts of classifier-free guidance (CFG) on different devices, then merges on a single device to perform the scheduler step.
4. [Tensor Parallelism](#tensor-parallelism): Tensor parallelism shards model weights across devices. This can reduce per-GPU memory usage. Note that for diffusion models we currently shard the majority of layers within the DiT.
5. [VAE Patch Parallelism](#vae-patch-parallelism): VAE patch parallelism shards VAE decode spatially across ranks. This can reduce the peak memory of VAE decode and (depending on resolution and communication overhead) speed up VAE decode.
6. [HSDP](#hsdp): Hybrid Sharded Data Parallel shards model weights across GPUs using PyTorch FSDP2. This reduces per-GPU memory usage, enabling inference of large models on GPUs with limited memory.
7. [Expert Parallel](#expert-parallelism): Expert Parallelism shards the Experts of a Mixture-of-Experts (MoE) layer across multiple devices. During the forward, a gating mechanism routes tokens to their designated experts, necessitating cross-cards communication(all-to-all) to dispatch tokens to the correct ranks and combine the results. This parallelism allows for massive scaling of model parameters without a proportional increase in the computational load per device.
The following table shows which models are currently supported by parallelism method:
### ImageGen
| Model | Model Identifier | Ulysses-SP | Ring-SP | CFG-Parallel | Tensor-Parallel | VAE-Patch-Parallel | Expert-Parallel | HSDP |
|--------------------------|--------------------------------------|:----------:|:-------:|:------------:|:---------------:|:------------------:|:---------------:|:----:|
| **LongCat-Image** | `meituan-longcat/LongCat-Image` | ✅ | ✅ | ❌ | ✅ | ❌ | N/A | ❌ |
| **LongCat-Image-Edit** | `meituan-longcat/LongCat-Image-Edit` | ✅ | ✅ | ❌ | ✅ | ❌ | N/A | ❌ |
| **Ovis-Image** | `OvisAI/Ovis-Image` | ❌ | ❌ | ❌ | ❌ | ❌ | N/A | ❌ |
| **Qwen-Image** | `Qwen/Qwen-Image` | ✅ | ✅ | ✅ | ✅ | ✅ | N/A | ❌ |
| **Qwen-Image-Edit** | `Qwen/Qwen-Image-Edit` | ✅ | ✅ | ✅ | ✅ | ❌ | N/A | ❌ |
| **Qwen-Image-Edit-2509** | `Qwen/Qwen-Image-Edit-2509` | ✅ | ✅ | ✅ | ✅ | ❌ | N/A | ❌ |
| **Qwen-Image-Layered** | `Qwen/Qwen-Image-Layered` | ✅ | ✅ | ✅ | ✅ | ❌ | N/A | ❌ |
| **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ | ❌ | ✅ (TP=2 only) | ✅ | N/A | ❌ |
| **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ❌ | ❌ | ✅ | ✅ | N/A | ❌ |
| **FLUX.2-klein** | `black-forest-labs/FLUX.2-klein-4B` | ✅ | ✅ | ❌ | ✅ | ❌ | N/A | ✅ |
| **FLUX.1-dev** | `black-forest-labs/FLUX.1-dev` | ❌ | ❌ | ✅ | ✅ | ❌ | N/A | ✅ |
| **FLUX.2-dev** | `black-forest-labs/FLUX.2-dev` | ❌ | ❌ | ❌ | ✅ | ❌ | N/A | ✅ |
| **HunyuanImage3.0** | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ |
| **Bagel** | `ByteDance-Seed/BAGEL-7B-MoT` | ✅ | ✅ | ✅ | ✅ | ❌ | N/A | ❌ |
| **DreamID-Omni** | `XuGuo699/DreamID-Omni` | ❌ | ❌ | ✅ | ❌ | ❌ | N/A | ❌ |
| **FLUX.1-Kontext-dev** | `black-forest-labs/FLUX.1-Kontext-dev` | ❌ | ❌ | ❌ | ✅ | ❌ | N/A | ✅ |
| **OmniGen2** | `OmniGen2/OmniGen2` | ❌ | ❌ | ❌ | ✅ | ❌ | N/A | ❌ |
!!! note "TP Limitations for Diffusion Models"
We currently implement Tensor Parallelism (TP) only for the DiT (Diffusion Transformer) blocks. This is because the `text_encoder` component in vLLM-Omni uses the original Transformers implementation, which does not yet support TP.
- Good news: The text_encoder typically has minimal impact on overall inference performance.
- Bad news: When TP is enabled, every TP process retains a full copy of the text_encoder weights, leading to significant GPU memory waste.
We are actively refactoring this design to address this. For details and progress, please refer to [Issue #771](https://github.com/vllm-project/vllm-omni/issues/771).
!!! note "Why Z-Image is TP=2 only"
Z-Image Turbo is currently limited to `tensor_parallel_size` of **1 or 2** due to model shape divisibility constraints.
For example, the model has `n_heads=30` and a final projection out dimension of `64`, so valid TP sizes must divide both 30 and 64; the only common divisors are **1 and 2**.
### VideoGen
| Model | Model Identifier | Ulysses-SP | Ring-Attention | Tensor-Parallel | HSDP | VAE-Patch-Parallel |
|-------|------------------|:----------:|:--------------:|:---------------:|:----:| :----:|
| **Wan2.1** | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Wan2.1** | `Wan-AI/Wan2.1-T2V-14B-Diffusers` | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Wan2.2** | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | ✅ | ✅ | ✅ | ✅ | ✅ |
| **LTX-2** | `Lightricks/LTX-2` | ✅ | ✅ | ✅ | ❌ | ❌ |
### Tensor Parallelism
Tensor parallelism splits model parameters across GPUs. In vLLM-Omni, tensor parallelism is configured via `DiffusionParallelConfig.tensor_parallel_size`.
#### Offline Inference
```python
from vllm_omni import Omni
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
parallel_config=DiffusionParallelConfig(tensor_parallel_size=2),
)
outputs = omni.generate(
"a cat reading a book",
OmniDiffusionSamplingParams(
num_inference_steps=9,
width=512,
height=512,
),
)
```
### VAE Patch Parallelism
VAE patch parallelism distributes the VAE decode workload across multiple ranks by splitting the latent spatially. It is configured via `DiffusionParallelConfig.vae_patch_parallel_size` and can be combined with other parallelism methods (e.g., TP).
!!! note "Enablement and feature gate"
- VAE patch parallelism is currently **enabled only for validated pipelines** (check [ImageGen](#imagegen) and [VideoGen](#videogen) for more information).
- If `vae_patch_parallel_size > 1` is set for a validated pipeline, vLLM-Omni will automatically enable `vae_use_tiling` as a safety gate. (We use `vae_use_tiling` because it indicates the VAE supports diffusers tiling parameters like `tile_latent_min_size` and `tile_overlap_factor`.)
#### Offline Inference
```python
from vllm_omni import Omni
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
parallel_config=DiffusionParallelConfig(
tensor_parallel_size=2,
vae_patch_parallel_size=2,
),
vae_use_tiling=True,
)
outputs = omni.generate(
prompt="a cat reading a book",
num_inference_steps=9,
width=1024,
height=1024,
)
```
#### How it works (method selection)
VAE patch parallelism automatically selects between two internal decode methods based on whether diffusers tiling would kick in:
- `_distributed_tiled_decode`: Used when the latent spatial size exceeds `vae.tile_latent_min_size` (i.e., diffusers tiled decode). Each rank decodes a subset of tiles; rank0 gathers and runs the same overlap+blend+stitch logic as diffusers. This matches the single-rank diffusers tiled output.
- `_distributed_patch_decode`: Used when diffusers tiling would not kick in. Each rank decodes a grid patch expanded with a latent-space halo; then rank0 gathers the cropped core patches and stitches them into the full image. This path has no blending and can introduce small numerical differences compared to the non-parallel decode.
### Sequence Parallelism
#### Ulysses-SP
!!! note "Experimental UAA mode"
`ulysses_mode="advanced_uaa"` is an experimental extension to Ulysses-SP. It lets Ulysses attention handle arbitrary sequence lengths and arbitrary attention head counts without relying on `attention_mask`-based token padding.
In hybrid Ulysses + Ring mode, Ring still requires every rank in the same ring group to observe the same post-Ulysses sequence length. If that condition is not met, vLLM-Omni raises a validation error instead of entering the ring kernel with inconsistent shapes.
##### Offline Inference
An example of offline inference script using [Ulysses-SP](https://arxiv.org/pdf/2309.14509) is shown below:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
ulysses_degree = 2
omni = Omni(
model="Qwen/Qwen-Image",
parallel_config=DiffusionParallelConfig(ulysses_degree=2)
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
```
See `examples/offline_inference/text_to_image/text_to_image.py` for a complete working example.
To enable the experimental UAA mode explicitly, use a model/configuration that actually requires it. For example, `Tongyi-MAI/Z-Image-Turbo` has 30 attention heads, so `ulysses_degree=4` requires UAA because 30 is not divisible by 4:
```python
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
parallel_config=DiffusionParallelConfig(
ulysses_degree=4,
ulysses_mode="advanced_uaa",
),
)
```
##### Online Serving
You can enable Ulysses-SP in online serving for diffusion models via `--usp`:
```bash
# Text-to-image (requires >= 2 GPUs)
vllm serve Qwen/Qwen-Image --omni --port 8091 --usp 2
# Experimental UAA mode for a model with 30 attention heads
vllm serve Tongyi-MAI/Z-Image-Turbo --omni --port 8091 --usp 4 --ulysses-mode advanced_uaa
```
##### Benchmarks
!!! note "Benchmark Disclaimer"
These benchmarks are provided for **general reference only**. The configurations shown use default or common parameter settings and have not been exhaustively optimized for maximum performance. Actual performance may vary based on:
- Specific model and use case
- Hardware configuration
- Careful parameter tuning
- Different inference settings (e.g., number of steps, image resolution)
To measure the parallelism methods, we run benchmarks with **Qwen/Qwen-Image** model generating images (**2048x2048** as long sequence input) with 50 inference steps. The hardware devices are NVIDIA H800 GPUs. `sdpa` is the attention backends.
| Configuration | Ulysses degree |Generation Time | Speedup |
|---------------|----------------|---------|---------|
| **Baseline (diffusers)** | - | 112.5s | 1.0x |
| Ulysses-SP | 2 | 65.2s | 1.73x |
| Ulysses-SP | 4 | 39.6s | 2.84x |
| Ulysses-SP | 8 | 30.8s | 3.65x |
#### Ring-Attention
Ring-Attention ([arxiv paper](https://arxiv.org/abs/2310.01889)) splits the input along the sequence dimension and uses ring-based P2P communication to accumulate attention results. Unlike Ulysses-SP which uses all-to-all communication, Ring-Attention keeps the sequence dimension sharded throughout the computation and circulates Key/Value blocks through a ring topology.
##### Offline Inference
An example of offline inference script using Ring-Attention is shown below:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
ring_degree = 2
omni = Omni(
model="Qwen/Qwen-Image",
parallel_config=DiffusionParallelConfig(ring_degree=2)
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
```
See `examples/offline_inference/text_to_image/text_to_image.py` for a complete working example.
##### Online Serving
You can enable Ring-Attention in online serving for diffusion models via `--ring`:
```bash
# Text-to-image (requires >= 2 GPUs)
vllm serve Qwen/Qwen-Image --omni --port 8091 --ring 2
```
##### Benchmarks
!!! note "Benchmark Disclaimer"
These benchmarks are provided for **general reference only**. The configurations shown use default or common parameter settings and have not been exhaustively optimized for maximum performance. Actual performance may vary based on:
- Specific model and use case
- Hardware configuration
- Careful parameter tuning
- Different inference settings (e.g., number of steps, image resolution)
To measure the parallelism methods, we run benchmarks with **Qwen/Qwen-Image** model generating images (**1024x1024** as long sequence input) with 50 inference steps. The hardware devices are NVIDIA A100 GPUs. `flash_attn` is the attention backends.
| Configuration | Ring degree |Generation Time | Speedup |
|---------------|----------------|---------|---------|
| **Baseline (diffusers)** | - | 45.2s | 1.0x |
| Ring-Attention | 2 | 29.9s | 1.51x |
| Ring-Attention | 4 | 23.3s | 1.94x |
#### Hybrid Ulysses + Ring
You can combine both Ulysses-SP and Ring-Attention for larger scale parallelism. The total sequence parallel size equals `ulysses_degree × ring_degree`.
!!! note "Experimental UAA in hybrid mode"
`ulysses_mode="advanced_uaa"` can also be used with hybrid Ulysses + Ring, but this does not remove Ring's shape requirement. Every rank in the same ring group must still have the same post-Ulysses sequence length.
##### Offline Inference
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
# Hybrid: 2 Ulysses × 2 Ring = 4 GPUs total
omni = Omni(
model="Qwen/Qwen-Image",
parallel_config=DiffusionParallelConfig(
ulysses_degree=2,
ring_degree=2,
)
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
```
##### Online Serving
```bash
# Text-to-image (requires >= 4 GPUs)
vllm serve Qwen/Qwen-Image --omni --port 8091 --usp 2 --ring 2
```
##### Benchmarks
!!! note "Benchmark Disclaimer"
These benchmarks are provided for **general reference only**. The configurations shown use default or common parameter settings and have not been exhaustively optimized for maximum performance. Actual performance may vary based on:
- Specific model and use case
- Hardware configuration
- Careful parameter tuning
- Different inference settings (e.g., number of steps, image resolution)
To measure the parallelism methods, we run benchmarks with **Qwen/Qwen-Image** model generating images (**1024x1024** as long sequence input) with 50 inference steps. The hardware devices are NVIDIA A100 GPUs. `flash_attn` is the attention backends.
| Configuration | Ulysses degree | Ring degree | Generation Time | Speedup |
|---------------|----------------|-------------|-----------------|---------|
| **Baseline (diffusers)** | - | - | 45.2s | 1.0x |
| Hybrid Ulysses + Ring | 2 | 2 | 24.3s | 1.87x |
### CFG-Parallel
#### Offline Inference
CFG-Parallel is enabled through `DiffusionParallelConfig(cfg_parallel_size=2)`, which runs one rank for the positive branch and one rank for the negative branch.
An example of offline inference using CFG-Parallel (image-to-image) is shown below:
```python
from vllm_omni import Omni
from vllm_omni.diffusion.data import DiffusionParallelConfig
image_path = "path_to_image.png"
omni = Omni(
model="Qwen/Qwen-Image-Edit",
parallel_config=DiffusionParallelConfig(cfg_parallel_size=2),
)
input_image = Image.open(image_path).convert("RGB")
outputs = omni.generate(
{
"prompt": "turn this cat to a dog",
"negative_prompt": "low quality, blurry",
"multi_modal_data": {"image": input_image},
},
OmniDiffusionSamplingParams(
true_cfg_scale=4.0,
num_inference_steps=50,
),
)
```
Notes:
- CFG-Parallel is only effective when a `negative_prompt` is provided AND a guidance scale (or `cfg_scale`) is greater than 1.
See `examples/offline_inference/image_to_image/image_edit.py` for a complete working example.
```bash
cd examples/offline_inference/image_to_image/
python image_edit.py \
--model "Qwen/Qwen-Image-Edit" \
--image "qwen_image_output.png" \
--prompt "turn this cat to a dog" \
--negative-prompt "low quality, blurry" \
--cfg-scale 4.0 \
--output "edited_image.png" \
--cfg-parallel-size 2
```
#### Online Serving
You can enable CFG-Parallel in online serving for diffusion models via `--cfg-parallel-size`:
```bash
vllm serve Qwen/Qwen-Image-Edit --omni --port 8091 --cfg-parallel-size 2
```
### HSDP
HSDP (Hybrid Sharded Data Parallel) shards model weights across GPUs to reduce per-GPU memory usage. This enables inference of large models (e.g., Wan2.2 14B) on GPUs with limited memory.
Unlike Tensor Parallelism which splits computation, HSDP uses PyTorch's FSDP2 to shard and redistribute weights at runtime. Each GPU only holds a fraction of the model weights, and weights are gathered on-demand during forward passes.
#### Configuration
HSDP is configured via `DiffusionParallelConfig`:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `use_hsdp` | bool | False | Enable HSDP |
| `hsdp_shard_size` | int | -1 | Number of GPUs to shard weights across. -1 = auto (requires other parallelism > 1) |
| `hsdp_replicate_size` | int | 1 | Number of replica groups. Each group holds a full sharded copy |
**Constraints:**
- `hsdp_replicate_size × hsdp_shard_size == world_size`
- HSDP cannot be used with Tensor Parallelism (`tensor_parallel_size` must be 1)
#### Operating Modes
HSDP can work in two modes:
- **Standalone Mode**: HSDP alone without other parallelism. Must specify `hsdp_shard_size` explicitly.
- **Combined Mode**: HSDP overlays on top of other parallelism (Ulysses Sequence Parallel, CFG Parallel). HSDP dimensions must match world_size.
#### Offline Inference
**Standalone HSDP** (shard across 4 GPUs, no other parallelism):
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
parallel_config=DiffusionParallelConfig(
use_hsdp=True,
hsdp_shard_size=4, # Shard across 4 GPUs
),
)
outputs = omni.generate(
"A cat playing piano",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
**Combined HSDP + Sequence Parallel**:
```python
omni = Omni(
model="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
parallel_config=DiffusionParallelConfig(
ulysses_degree=4, # Sequence parallel
use_hsdp=True, # HSDP overlays on SP
),
)
```
#### Online Serving
**Standalone HSDP** (shard model across 4 GPUs):
```bash
vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --port 8091 --use-hsdp --hsdp-shard-size 4
```
**Combined with Sequence Parallel**:
```bash
vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --port 8091 --use-hsdp --usp 4
```
#### Adding HSDP Support to New Models
For detailed instructions on adding HSDP support to new models, see the [HSDP Contributing Guide](../../design/feature/hsdp.md).
### Expert Parallelism
Unlike Tensor Parallelism which shards every layer's weights, EP only shards the MoE expert MLP blocks. This significantly reduces the memory footprint of MoE models (e.g., HunyuanImage3.0) while maintaining constant dense-equivalent compute efficiency. Expert Parallelism is enabled via `DiffusionParallelConfig.enable_expert_parallel`. And `self.ep = tp * sp * cfg * dp` for now, so at least one of TP/SP/CFG/DP should set when EP enabled.
#### Offline Inference
```python
from vllm_omni import Omni
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="tencent/HunyuanImage-3.0",
parallel_config=DiffusionParallelConfig(tensor_parallel_size=8, enable_expert_parallel=True),
)
outputs = omni.generate(
"A brown and white dog is running on the grass",
OmniDiffusionSamplingParams(
num_inference_steps=50,
width=1024,
height=1024,
),
)
```
+1 -1
View File
@@ -46,7 +46,7 @@ its stepwise request state machine. For normal diffusion inference, leave it
disabled unless your workflow depends on this mode.
If you are looking for general diffusion speedups, see
[Diffusion Acceleration Overview](../diffusion_acceleration.md).
[Diffusion Features Overview](../diffusion_features.md).
## Troubleshooting
-146
View File
@@ -1,146 +0,0 @@
# TeaCache Configuration Guide
TeaCache speeds up diffusion model inference by caching transformer computations when consecutive timesteps are similar. This typically provides **1.5x-2.0x speedup** with minimal quality loss.
## Quick Start
Enable TeaCache by setting `cache_backend` to `"tea_cache"`:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
# Simple configuration - model_type is automatically extracted from pipeline.__class__.__name__
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="tea_cache",
cache_config={
"rel_l1_thresh": 0.2 # Optional, defaults to 0.2
}
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
```
### Using Environment Variable
You can also enable TeaCache via environment variable:
```bash
export DIFFUSION_CACHE_BACKEND=tea_cache
```
Then initialize without explicitly setting `cache_backend`:
```python
from vllm_omni import Omni
omni = Omni(
model="Qwen/Qwen-Image",
cache_config={"rel_l1_thresh": 0.2} # Optional
)
```
## Online Serving (OpenAI-Compatible)
Enable TeaCache for online serving by passing `--cache-backend tea_cache` when starting the server:
```bash
vllm serve Qwen/Qwen-Image --omni --port 8091 \
--cache-backend tea_cache \
--cache-config '{"rel_l1_thresh": 0.2}'
```
## Configuration Parameters
### `rel_l1_thresh` (float, default: `0.2`)
Controls the balance between speed and quality. Lower values prioritize quality, higher values prioritize speed.
**Recommended values:**
- `0.2` - **~1.5x speedup** with minimal quality loss (recommended)
- `0.4` - **~1.8x speedup** with slight quality loss
- `0.6` - **~2.0x speedup** with noticeable quality loss
- `0.8` - **~2.25x speedup** with significant quality loss
## Examples
### Python API
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="tea_cache",
cache_config={"rel_l1_thresh": 0.2}
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
```
## Performance Tuning
Start with the default `rel_l1_thresh=0.2` and adjust based on your needs:
- **Maximum quality**: Use `0.1-0.2`
- **Balanced**: Use `0.2-0.4` (recommended)
- **Maximum speed**: Use `0.6-0.8` (may reduce quality)
## Troubleshooting
### Quality Degradation
If you notice quality issues, lower the threshold:
```python
cache_config={"rel_l1_thresh": 0.1} # More conservative caching
```
## Supported Models
### ImageGen
<style>
th {
white-space: nowrap;
min-width: 0 !important;
}
</style>
| Architecture | Models | Example HF Models |
|--------------|--------|-------------------|
| `QwenImagePipeline` | Qwen-Image | `Qwen/Qwen-Image` |
| `QwenImageEditPipeline` | Qwen-Image-Edit | `Qwen/Qwen-Image-Edit` |
| `QwenImageEditPlusPipeline` | Qwen-Image-Edit-2509 | `Qwen/Qwen-Image-Edit-2509` |
| `QwenImageLayeredPipeline` | Qwen-Image-Layered | `Qwen/Qwen-Image-Layered` |
| `BagelForConditionalGeneration` | BAGEL (DiT-only) | `ByteDance-Seed/BAGEL-7B-MoT` |
| `HunyuanImage3Pipeline` | HunyuanImage3 | `tencent/HunyuanImage-3.0-Instruct` |
### VideoGen
No VideoGen models are supported by TeaCache yet.
### Coming Soon
<style>
th {
white-space: nowrap;
min-width: 0 !important;
}
</style>
| Architecture | Models | Example HF Models |
|--------------|--------|-------------------|
| `FluxPipeline` | Flux | - |
| `CogVideoXPipeline` | CogVideoX | - |
-380
View File
@@ -1,380 +0,0 @@
# Diffusion Acceleration Overview
vLLM-Omni supports various acceleration methods to speed up diffusion model inference with minimal quality degradation. These include **cache methods** that intelligently cache intermediate computations to avoid redundant work across diffusion timesteps, **parallelism methods** that distribute the computation across multiple devices, and **quantization methods** that reduce memory footprint while preserving accuracy.
## Supported Acceleration Methods
vLLM-Omni currently supports two main cache acceleration backends:
1. **[TeaCache](diffusion/teacache.md)** - Hook-based adaptive caching that caches transformer computations when consecutive timesteps are similar
2. **[Cache-DiT](diffusion/cache_dit_acceleration.md)** - Library-based acceleration using multiple techniques:
- **DBCache** (Dual Block Cache): Caches intermediate transformer block outputs based on residual differences
- **TaylorSeer**: Uses Taylor expansion-based forecasting for faster inference
- **SCM** (Step Computation Masking): Selectively computes steps based on adaptive masking
Both methods can provide significant speedups (typically **1.5x-2.0x**) while maintaining high output quality.
vLLM-Omni also supports quantization methods:
3. **[Quantization](diffusion/quantization/overview.md)** - Reduces DiT linear layers from BF16 to FP8 or Int8, providing ~1.28x speedup with minimal quality loss. Supports per-layer skip for sensitive layers.
vLLM-Omni also supports parallelism methods for diffusion models, including:
1. [Ulysses-SP](diffusion/parallelism_acceleration.md#ulysses-sp) - splits the input along the sequence dimension and uses all-to-all communication to allow each device to compute only a subset of attention heads.
2. [Ring-Attention](diffusion/parallelism_acceleration.md#ring-attention) - splits the input along the sequence dimension and uses ring-based P2P communication to accumulate attention results, keeping the sequence dimension sharded.
3. [CFG-Parallel](diffusion/parallelism_acceleration.md#cfg-parallel) - runs the positive/negative prompts of classifier-free guidance (CFG) on different devices, then merges on a single device to perform the scheduler step.
4. [Tensor Parallelism](diffusion/parallelism_acceleration.md#tensor-parallelism) - shards DiT weights across devices to reduce per-GPU memory usage.
5. [VAE Patch Parallelism](diffusion/parallelism_acceleration.md#vae-patch-parallelism) - shards VAE decode/encode spatially across ranks to reduce VAE peak memory (and can speed up VAE decode).
6. [HSDP](diffusion/parallelism_acceleration.md#hsdp) - Hybrid Sharded Data Parallel shards model weights across GPUs to reduce per-GPU memory usage, enabling inference of large models on limited GPU memory.
## Quick Comparison
### Cache Methods
| Method | Configuration | Description | Best For |
|--------|--------------|-------------|----------|
| **TeaCache** | `cache_backend="tea_cache"` | Simple, adaptive caching with minimal configuration | Quick setup, balanced speed/quality |
| **Cache-DiT** | `cache_backend="cache_dit"` | Advanced caching with multiple techniques (DBCache, TaylorSeer, SCM) | Maximum acceleration, fine-grained control |
### Quantization Methods
| Method | Configuration | Description | Best For |
|--------|--------------|-------------|----------|
| **FP8** | `quantization="fp8"` | FP8 W8A8 on Ada/Hopper, weight-only on older GPUs | Memory reduction, inference speedup |
| **Int8** | `quantization="int8"` | Int8 W8A8 | Memory reduction, inference speedup |
## Supported Models
The following table shows which models are currently supported by each acceleration method:
### ImageGen
| Model | Model Identifier | TeaCache | Cache-DiT | Ulysses-SP | Ring-Attention | CFG-Parallel | Tensor-Parallel | VAE-Patch-Parallel |
|-------|------------------|:--------:|:---------:|:----------:|:--------------:|:------------:|:---------------:|:------------------:|
| **LongCat-Image** | `meituan-longcat/LongCat-Image` | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ |
| **LongCat-Image-Edit** | `meituan-longcat/LongCat-Image-Edit` | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ |
| **Ovis-Image** | `OvisAI/Ovis-Image` | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
| **Qwen-Image** | `Qwen/Qwen-Image` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Qwen-Image-2512** | `Qwen/Qwen-Image-2512` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Qwen-Image-Edit** | `Qwen/Qwen-Image-Edit` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Qwen-Image-Edit-2509** | `Qwen/Qwen-Image-Edit-2509` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Qwen-Image-Layered** | `Qwen/Qwen-Image-Layered` | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ (TP=2 only) | ✅ |
| **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
| **Bagel** | `ByteDance-Seed/BAGEL-7B-MoT` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| **FLUX.1-dev** | `black-forest-labs/FLUX.1-dev` | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ |
| **NextStep-1.1** | `stepfun-ai/NextStep-1.1` | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
| **FLUX.2-klein** | `black-forest-labs/FLUX.2-klein-4B` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.2-dev** | `black-forest-labs/FLUX.2-dev` | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.1-Kontext-dev** | `black-forest-labs/FLUX.1-Kontext-dev` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
| **GLM-Image** | `zai-org/GLM-Image` | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
### VideoGen
| Model | Model Identifier | TeaCache | Cache-DiT | Ulysses-SP | Ring-Attention | CFG-Parallel | HSDP | VAE-Patch-Parallel |
|-------|------------------|:--------:|:---------:|:----------:|:--------------:|:------------:|:----:|:----:|
| **Wan2.1-T2V** | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers` | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Wan2.1-T2V** | `Wan-AI/Wan2.1-T2V-14B-Diffusers` | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Wan2.2** | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **LTX-2** | `Lightricks/LTX-2` | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| **DreamID-Omni** | `XuGuo699/DreamID-Omni` | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
### Quantization
| Model | Model Identifier | FP8 | Int8 |
|-------|------------------|:---:|:---:|
| **Qwen-Image** | `Qwen/Qwen-Image` | ✅ | ✅ |
| **Qwen-Image-2512** | `Qwen/Qwen-Image-2512` | ✅ | ✅ |
| **Z-Image** | `Tongyi-MAI/Z-Image-Turbo` | ✅ | ✅ |
### AudioGen
| Model | Model Identifier | TeaCache | Cache-DiT | Ulysses-SP | Ring-Attention | CFG-Parallel |
|------------------------|------------------------------------------|:--------:|:---------:|:----------:|:--------------:|:------------:|
| **Stable-Audio-Open** | `stabilityai/stable-audio-open-1.0` | ✅ | | | | |
## Performance Benchmarks
The following benchmarks were measured on **Qwen/Qwen-Image** and **Qwen/Qwen-Image-Edit** models generating 1024x1024 images with 50 inference steps:
!!! note "Benchmark Disclaimer"
These benchmarks are provided for **general reference only**. The configurations shown use default or common parameter settings and have not been exhaustively optimized for maximum performance. Actual performance may vary based on:
- Specific model and use case
- Hardware configuration
- Careful parameter tuning
- Different inference settings (e.g., number of steps, image resolution)
For optimal performance in your specific scenario, we recommend experimenting with different parameter configurations as described in the detailed guides below.
| Model | Cache Backend | Cache Config | Generation Time | Speedup | Notes |
|-------|---------------|--------------|----------------|---------|-------|
| **Qwen/Qwen-Image** | None | None | 20.0s | 1.0x | Baseline (diffusers) |
| **Qwen/Qwen-Image** | TeaCache | `rel_l1_thresh=0.2` | 10.47s | **1.91x** | Recommended default setting |
| **Qwen/Qwen-Image** | Cache-DiT | DBCache + TaylorSeer (Fn=1, Bn=0, W=8, TaylorSeer order=1) | 10.8s | **1.85x** | - |
| **Qwen/Qwen-Image** | Cache-DiT | DBCache + TaylorSeer + SCM (Fn=8, Bn=0, W=4, TaylorSeer order=1, SCM fast) | 14.0s | **1.43x** | - |
| **Qwen/Qwen-Image-Edit** | None | No acceleration | 51.5s | 1.0x | Baseline (diffusers) |
| **Qwen/Qwen-Image-Edit** | Cache-DiT | Default (Fn=1, Bn=0, W=4, TaylorSeer disabled, SCM disabled) | 21.6s | **2.38x** | - |
To measure the parallelism methods, we run benchmarks with **Qwen/Qwen-Image** model generating images (**2048x2048** as long sequence input) with 50 inference steps. The hardware devices are NVIDIA H800 GPUs. `sdpa` is the attention backends.
| Configuration | Ulysses degree |Generation Time | Speedup |
|---------------|----------------|---------|---------|
| **Baseline (diffusers)** | - | 112.5s | 1.0x |
| Ulysses-SP | 2 | 65.2s | 1.73x |
| Ulysses-SP | 4 | 39.6s | 2.84x |
| Ulysses-SP | 8 | 30.8s | 3.65x |
## Quick Start
### Using TeaCache
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="tea_cache",
cache_config={"rel_l1_thresh": 0.2} # Optional, defaults to 0.2
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
```
### Using Cache-DiT
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="Qwen/Qwen-Image",
cache_backend="cache_dit",
cache_config={
"Fn_compute_blocks": 1,
"Bn_compute_blocks": 0,
"max_warmup_steps": 8,
"enable_taylorseer": True,
"taylorseer_order": 1,
}
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
```
### Using Ulysses-SP
Run text-to-image:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
ulysses_degree = 2
omni = Omni(
model="Qwen/Qwen-Image",
parallel_config=DiffusionParallelConfig(ulysses_degree=ulysses_degree)
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
```
Run image-to-image:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
ulysses_degree = 2
omni = Omni(
model="Qwen/Qwen-Image-Edit",
parallel_config=DiffusionParallelConfig(ulysses_degree=ulysses_degree)
)
outputs = omni.generate(
{
"prompt": "turn this cat to a dog",
"multi_modal_data": {"image": input_image}
},
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
### Using Ring-Attention
Run text-to-image:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
ring_degree = 2
omni = Omni(
model="Qwen/Qwen-Image",
parallel_config=DiffusionParallelConfig(ring_degree=2)
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50, width=2048, height=2048),
)
```
### Using Tensor Parallelism
```python
from vllm_omni import Omni
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
parallel_config=DiffusionParallelConfig(tensor_parallel_size=2),
)
outputs = omni.generate(
prompt="a cat reading a book",
num_inference_steps=9,
width=512,
height=512,
)
```
### Using VAE Patch Parallelism
```python
from vllm_omni import Omni
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Tongyi-MAI/Z-Image-Turbo",
parallel_config=DiffusionParallelConfig(
tensor_parallel_size=2,
vae_patch_parallel_size=2,
),
)
outputs = omni.generate(
prompt="a cat reading a book",
num_inference_steps=9,
width=1024,
height=1024,
)
```
### Using HSDP
HSDP (Hybrid Sharded Data Parallel) shards model weights across GPUs to reduce per-GPU memory usage. This enables inference of large models (e.g., Wan2.2 14B) on GPUs with limited memory.
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
omni = Omni(
model="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
parallel_config=DiffusionParallelConfig(
use_hsdp=True,
hsdp_replicate_size=1, # Number of replica groups
hsdp_shard_size=8, # Number of GPUs to shard across (or -1 for auto)
),
)
outputs = omni.generate(
"A cat playing piano",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
### Using CFG-Parallel
Run image-to-image:
CFG-Parallel splits the CFG positive/negative branches across GPUs. Use it when you set a non-trivial `true_cfg_scale`.
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.diffusion.data import DiffusionParallelConfig
cfg_parallel_size = 2
omni = Omni(
model="Qwen/Qwen-Image-Edit",
parallel_config=DiffusionParallelConfig(cfg_parallel_size=cfg_parallel_size)
)
outputs = omni.generate(
{
"prompt": "turn this cat to a dog",
"multi_modal_data": {"image": input_image}
},
OmniDiffusionSamplingParams(num_inference_steps=50, true_cfg_scale=4.0),
)
```
### Using FP8 Quantization
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="<your-model>",
quantization="fp8",
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
### Using Int8 Quantization
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="<your-model>",
quantization="int8",
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
## Documentation
For detailed information on each acceleration method:
- **[TeaCache Guide](diffusion/teacache.md)** - Complete TeaCache documentation, configuration options, and best practices
- **[Cache-DiT Acceleration Guide](diffusion/cache_dit_acceleration.md)** - Comprehensive Cache-DiT guide covering DBCache, TaylorSeer, SCM, and configuration parameters
- **[Quantization Guide](diffusion/quantization/overview.md)** - Quantization for DiT models with per-layer control
- **[Tensor Parallelism](diffusion/parallelism_acceleration.md#tensor-parallelism)** - Guidance on how to enable TP for diffusion models.
- **[Sequence Parallelism](diffusion/parallelism_acceleration.md#sequence-parallelism)** - Guidance on how to set sequence parallelism with configuration.
- **[CFG-Parallel](diffusion/parallelism_acceleration.md#cfg-parallel)** - Guidance on how to set CFG-Parallel to run positive/negative branches across ranks.
- **[VAE Patch Parallelism](diffusion/parallelism_acceleration.md#vae-patch-parallelism)** - Guidance on how to reduce VAE memory via patch/tile parallelism.
- **[HSDP](diffusion/parallelism_acceleration.md#hsdp)** - Hybrid Sharded Data Parallel for memory-efficient inference of large models.
+207
View File
@@ -0,0 +1,207 @@
# Diffusion Advanced Features
## Table of Contents
- [Overview](#overview)
- [Supported Features](#supported-features)
- [Supported Models](#supported-models)
- [Feature Compatibility](#feature-compatibility)
- [Learn More](#learn-more)
## Overview
vLLM-Omni supports various advanced features for diffusion models:
- Acceleration: **cache methods**, **parallelism methods**
- Memory optimization: **cpu offloading**, **quantization**
- Extensions: **LoRA inference**
- Execution modes: **step execution**
## Supported Features
### Acceleration
#### Lossy Acceleration
Cache methods trade minimal quality for significant speedup. Quality loss is typically imperceptible with proper tuning.
| Method | Description | Best For |
|--------|-------------|----------|
| **[TeaCache](diffusion/cache_acceleration/teacache.md)** | Adaptive caching using modulated inputs | Quick setup, balanced quality/speed on single GPU |
| **[Cache-DiT](diffusion/cache_acceleration/cache_dit.md)** | Multiple caching techniques: DBCache, TaylorSeer, SCM | Fine-grained control, tunable quality-speed tradeoff |
#### Lossless Acceleration
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) |
**Note:** Some acceleration methods can be combined together for optimized performance. See [Feature Compatibility Table](#feature-compatibility) and [Feature Compatibility Tutorial](feature_compatibility.md) for detailed configuration examples.
### Memory Optimization
Memory optimization methods help reduce GPU memory usage, enabling inference on resource-constrained hardware or larger models.
| Method | Description | Best For |
|--------|-------------|----------|
| **[CPU Offload](diffusion/cpu_offload_diffusion.md)** | Offloads model components to CPU memory | Limited VRAM, large models on consumer GPUs |
| **[Quantization](diffusion/quantization/overview.md)** | Reduces DiT layers from BF16 to FP8/INT8/etc. | Limited VRAM, minimal accuracy loss |
| **[VAE Patch Parallelism](diffusion/parallelism/vae_patch_parallel.md)** | Distributes VAE decode tiling across GPUs | High-resolution generation with reduced VAE memory peak |
### Extensions
Extension methods add specialized capabilities to diffusion models beyond standard inference.
| Method | Description | Best For |
|--------|-------------|----------|
| **[LoRA Inference](diffusion/lora.md)** | Enables inference with Low-Rank Adaptation (LoRA) adapters weights | Reinforcement learning extensions |
### Execution Modes
Execution modes control how the diffusion pipeline processes denoise steps.
| Method | Description | Best For |
|--------|-------------|----------|
| **[Step Execution](diffusion/step_execution.md)** | Per-step denoise execution with mid-request abort support | Request cancellation between denoise steps, fine-grained execution control |
**Note:** Step execution is currently supported by QwenImagePipeline only. See [Supported Models](#supported-models) for details.
### Quantization Methods
| Method | Configuration | Description | Best For |
|--------|--------------|-------------|----------|
| **[FP8](diffusion/quantization/fp8.md)** | `quantization="fp8"` | FP8 W8A8 on Ada/Hopper, weight-only on older GPUs | Memory reduction, inference speedup |
| **[INT8](diffusion/quantization/int8.md)** | `quantization="int8"` | INT8 weight-only, no calibration or pre-quantized checkpoint needed | Memory reduction, broad GPU compatibility |
| **[GGUF](diffusion/quantization/gguf.md)** | `quantization="gguf"` | Native GGUF transformer-only weights (Q4, Q8, etc.) | Memory reduction on consumer GPUs |
## Supported Models
The following tables show which models support each feature:
- **🔀SP (Ulysses & Ring)**: Includes both Ulysses-SP and Ring-Attention methods
- ✅ = Fully supported
- ❌ = Not supported
> Notes:
> 1. CPU Offload has two methods: Module-wise (default for models with DiT + text encoder) and Layerwise. The tables below show **Layerwise support** only.
> 2. The **💾Quantization** column is collapsed for readability. See [Quantization Overview](diffusion/quantization/overview.md) for per-method (FP8, GGUF, …) and per-model support details.
### 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.2-klein** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| **FLUX.1-Kontext-dev** | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **FLUX.2-dev** | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| **GLM-Image** | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
| **LongCat-Image** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **LongCat-Image-Edit** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **MammothModa2(T2I)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Nextstep_1(T2I)** | ❓ | ❓ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **OmniGen2** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Ovis-Image** | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Qwen-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
| **Qwen-Image-2512** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
| **Qwen-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ❌ | ❌ | ✅ | ✅ | ❌ |
> Notes:
> 1. Nextstep_1(T2I) does not support cache acceleration methods such as TeaCache or Cache-DiT.
> 2. `Tongyi-MAI/Z-Image-Turbo` is a distilled model with minimal NFEs; CFG-Parallel is not necessary.
### VideoGen
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
| **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Helios** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
### 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** | ❌ | ❌ | ❓ | ❓ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
## Feature Compatibility
**Legend:**
- ✅: Functionality is supported
- ❌: No support plan
- ❓: Not verified yet and Not Recommended
| | ⚡TeaCache | ⚡Cache-DiT | 🔀Ulysses-SP | 🔀Ring-Attn | 🔀CFG-Parallel | 🔀Tensor Parallel | 🔀HSDP | 🔀Expert Parallel | 💾CPU Offloading (Layerwise) | 💾CPU Offloading (Module-wise) | 💾VAE Patch Parallel | 💾FP8 Quant | 🔧LoRA Inference | 🔄Step Execution |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| **⚡TeaCache** | | | | | | | | | | | | | | |
| **⚡Cache-DiT** | ❌ | | | | | | | | | | | | | |
| **🔀Ulysses-SP** | ✅ | ✅ | | | | | | | | | | | | |
| **🔀Ring-Attn** | ✅ | ✅ | ✅ | | | | | | | | | | | |
| **🔀CFG-Parallel** | ✅ | ✅ | ✅ | ✅ | | | | | | | | | | |
| **🔀Tensor Parallel** | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | | | | |
| **🔀HSDP** | ❓ | ❓ | ❓ | ❓ | ❓ | ❌ | | | | | | | | |
| **🔀Expert Parallel** | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | | | | | | |
| **💾CPU Offloading (Layerwise)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | | | | | |
| **💾CPU Offloading (Module-wise)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | ❌ | | | | | |
| **💾VAE Patch Parallel** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | | | |
| **💾FP8 Quant** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | ✅ | ✅ | ✅ | | | |
| **🔧LoRA Inference** | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | |
| **🔄Step Execution** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❓ | ❓ | ✅ | ❓ | ✅ | ✅ | ❌ | |
!!! info
1. Tensor Parallel and HSDP are not compatible.
2. TeaCache and Cache-DiT are not compatible.
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.
## Learn More
**Cache Acceleration:**
- **[TeaCache Configuration Guide](diffusion/cache_acceleration/teacache.md)** - Parameter tuning, performance tips, troubleshooting
- **[Cache-DiT Advanced Guide](diffusion/cache_acceleration/cache_dit.md)** - DBCache, TaylorSeer, SCM techniques and optimization
**Parallelism Methods:**
- **[Parallelism Overview](diffusion/parallelism/overview.md)** - Tensor Parallelism, Sequence Parallelism, CFG Parallelism, HSDP, and Expert Parallelism
**Memory Optimization:**
- **[CPU Offload Guide](diffusion/cpu_offload_diffusion.md)** - Offload model components to CPU, reduce GPU memory usage
- **[VAE Patch Parallelism Guide](diffusion/parallelism/vae_patch_parallel.md)** - Distribute VAE decode tiling across GPUs for high-resolution images
- **[Quantization Overview](diffusion/quantization/overview.md)** - Overview of quantization methods for diffusion models
**Extensions:**
- **[LoRA Inference Guide](diffusion/lora.md)** - Low-Rank Adaptation for style customization and fine-tuning
**Execution Modes:**
- **[Step Execution Guide](diffusion/step_execution.md)** - Per-step denoise execution with mid-request abort support
**Advanced Topics:**
- **[Feature Compatibility](feature_compatibility.md)** - How to combine multiple features for maximum performance
@@ -37,12 +37,45 @@ artifact, poll the job status and then download the completed file from the
content endpoint.
The main endpoints are:
- `POST /v1/videos`: create a video generation job
- `POST /v1/videos`: create a video generation job (async)
- `POST /v1/videos/sync`: generate a video and return raw bytes (sync, for benchmarks)
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Sync API (Benchmark / Testing)
`POST /v1/videos/sync` is a synchronous alternative that blocks until generation
completes and returns the raw video bytes (`video/mp4`) directly in the response
body. It is designed for benchmark and testing scenarios where one-shot
request/response latency measurement is needed.
The sync endpoint accepts the same form parameters as `POST /v1/videos`. It does
not create any stored job record — the response is purely the generated video
file. Metadata is returned via response headers:
- `X-Request-Id`: unique identifier for this generation request
- `X-Model`: model name used for generation
- `X-Inference-Time-S`: wall-clock inference time in seconds
```bash
curl -X POST http://localhost:8091/v1/videos/sync \
-F "prompt=A bear playing with yarn, smooth motion" \
-F "input_reference=@/path/to/input.png" \
-F "size=832x480" \
-F "num_frames=33" \
-F "fps=16" \
-F "negative_prompt=low quality, blurry, static" \
-F "num_inference_steps=40" \
-F "guidance_scale=1.0" \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
-F "seed=42" \
-o sync_i2v_output.mp4
```
## Storage
Generated video files are stored on local disk by the async video API.
@@ -37,12 +37,43 @@ artifact, poll the job status and then download the completed file from the
content endpoint.
The main endpoints are:
- `POST /v1/videos`: create a video generation job
- `POST /v1/videos`: create a video generation job (async)
- `POST /v1/videos/sync`: generate a video and return raw bytes (sync, for benchmarks)
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Sync API (Benchmark / Testing)
`POST /v1/videos/sync` is a synchronous alternative that blocks until generation
completes and returns the raw video bytes (`video/mp4`) directly in the response
body. It is designed for benchmark and testing scenarios where one-shot
request/response latency measurement is needed.
The sync endpoint accepts the same form parameters as `POST /v1/videos`. It does
not create any stored job record — the response is purely the generated video
file. Metadata is returned via response headers:
- `X-Request-Id`: unique identifier for this generation request
- `X-Model`: model name used for generation
- `X-Inference-Time-S`: wall-clock inference time in seconds
```bash
curl -X POST http://localhost:8091/v1/videos/sync \
-F "prompt=Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \
-F "size=832x480" \
-F "num_frames=33" \
-F "fps=16" \
-F "num_inference_steps=40" \
-F "guidance_scale=4.0" \
-F "guidance_scale_2=4.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=5.0" \
-F "seed=42" \
-o sync_t2v_output.mp4
```
## Storage
Generated video files are stored on local disk by the async video API.
+218
View File
@@ -0,0 +1,218 @@
# Feature Compatibility
This guide explains the compatibility matrix of different diffusion features in vLLM-Omni. You can use cache methods together with parallelism methods and other features to achieve optimal speed and efficiency.
## Overview
vLLM-Omni supports combining:
- **Cache methods** (TeaCache, Cache-DiT) with **Parallelism methods** (Ulysses-SP, Ring-Attention, CFG-Parallel, Tensor Parallelism)
- **Multiple parallelism methods** together (e.g., Ulysses-SP + Ring-Attention, CFG-Parallel + Sequence Parallelism)
- **LoRA adapters** with most acceleration features
- **CPU offloading** with other memory optimization features
See the feature compatibility matrix in [Table](diffusion_features.md#feature-compatibility)
## Common Combinations
### 1. Cache + Sequence Parallelism (Recommended)
Best for: **Large images (>1536px) or videos**
Combines cache acceleration with sequence parallelism for maximum speedup on single-device-challenging workloads.
**Using TeaCache + Ulysses-SP:**
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "A beautiful mountain landscape" \
--cache-backend tea_cache \
--ulysses-degree 2
```
**Using Cache-DiT + Ring-Attention:**
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "A futuristic city" \
--cache-backend cache_dit \
--ring-degree 2
```
### 2. Cache + CFG-Parallel
Best for: **Image editing with Classifier-Free Guidance**
Accelerates both the diffusion process and CFG computation.
```bash
python examples/offline_inference/image_to_image/image_edit.py \
--model Qwen/Qwen-Image-Edit \
--prompt "make it sunset" \
--negative-prompt "low quality, blurry" \
--image input.png \
--cache-backend cache_dit \
--cfg-parallel-size 2 \
--cfg-scale 4.0
```
### 3. CFG-Parallel + Sequence Parallelism
Best for: **Large resolution image editing with CFG**
Combines both CFG branch splitting and sequence parallelism for maximum GPU utilization.
**CFG-Parallel + Ulysses-SP:**
```bash
python examples/offline_inference/image_to_image/image_edit.py \
--model Qwen/Qwen-Image-Edit \
--prompt "transform into autumn scene" \
--negative-prompt "low quality" \
--image input.png \
--cache-backend cache_dit \
--cfg-parallel-size 2 \
--ulysses-degree 2 \
--cfg-scale 4.0
```
### 4. Hybrid Ulysses + Ring + Vae tiling
Best for: **Very large images or videos on multiple devices**
Combines Ulysses-SP (all-to-all) with Ring-Attention (ring P2P) for scalable parallelism.
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Qwen/Qwen-Image \
--prompt "Epic fantasy landscape" \
--cache-backend cache_dit \
--ulysses-degree 2 \
--ring-degree 2 \
--num-inference-steps 50 \
--width 2048 \
--height 2048 \
--vae-use-tiling
```
### 5. Cache + Tensor Parallelism
Best for: **Large models that don't fit in single GPU memory**
Reduces per-GPU memory usage while maintaining cache acceleration.
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model Tongyi-MAI/Z-Image-Turbo \
--prompt "A cat reading a book" \
--cache-backend tea_cache \
--tensor-parallel-size 2 \
--num-inference-steps 9 \
```
## Online Serving
### Cache + Sequence Parallelism
```bash
# TeaCache + Ulysses-SP
vllm serve Qwen/Qwen-Image --omni --port 8091 \
--cache-backend tea_cache \
--cache-config '{"rel_l1_thresh": 0.2}' \
--usp 2
# Cache-DiT + Ring-Attention
vllm serve Qwen/Qwen-Image --omni --port 8091 \
--cache-backend cache_dit \
--cache-config '{"Fn_compute_blocks": 1, "max_warmup_steps": 8}' \
--ring 2
```
### Cache + CFG-Parallel
```bash
vllm serve Qwen/Qwen-Image-Edit --omni --port 8091 \
--cache-backend cache_dit \
--cfg-parallel-size 2
```
### Multiple Parallelism Methods
```bash
# CFG-Parallel + Ulysses-SP (4 GPUs total)
vllm serve Qwen/Qwen-Image-Edit --omni --port 8091 \
--cache-backend cache_dit \
--cfg-parallel-size 2 \
--usp 2
# Hybrid Ulysses + Ring (4 GPUs total)
vllm serve Qwen/Qwen-Image --omni --port 8091 \
--cache-backend cache_dit \
--usp 2 \
--ring 2
```
## Limitations
### Incompatibilities
- **TeaCache + Cache-DiT**: These two cache methods cannot be used together. Only one cache backend can be active at a time. Attempting to enable both will result in an error.
### Partial Support
- **Tensor Parallelism — Text Encoder Not Sharded**: TP currently only shards the DiT blocks. Each TP rank retains a **full copy of the text encoder weights**, leading to significant GPU memory overhead proportional to TP degree. Tracked in [Issue #771](https://github.com/vllm-project/vllm-omni/issues/771).
- **CPU Offloading — Two Modes Are Mutually Exclusive**: Model-level offload (`enable_cpu_offload`) and layerwise offload (`enable_layerwise_offload`) cannot be used simultaneously. If both are set, layerwise takes priority and model-level is silently ignored.
- **CPU Offloading — VAE stays on GPU**: Both offloading strategies keep the VAE on GPU at all times. For high-resolution generation, VAE decode can still cause OOM. Mitigate by combining with `vae_use_tiling=True` or VAE Patch Parallelism.
- **VAE Patch Parallelism — DistributedVaeExecutor Required**: VAE Patch Parallelism is only enabled for models that have `DistributedVaeExecutor`. Unsupported models will silently ignore `vae_patch_parallel_size`, and use sequential vae tiling instead.
### Configuration Constraints
- **GPU Count Must Match Parallel Degrees**: Total GPU count must satisfy:
```
total_gpus = ulysses_degree × ring_degree × cfg_parallel_size × tensor_parallel_size
```
Any mismatch will cause a configuration error at startup.
- **VAE Patch Parallel Size ≤ DiT Process Group Size**: `vae_patch_parallel_size` reuses the DiT process group and cannot exceed it. Larger values are automatically clamped with a warning.
- **Model-Specific TP Constraints**: Some models impose divisibility constraints on TP size. For example, Z-Image Turbo (`num_heads=30`) only supports `tensor_parallel_size=2`. Check [Supported Models](diffusion_features.md#supported-models) for per-model constraints.
## Troubleshooting
### Performance Not Scaling
**Symptoms:** Adding more GPUs doesn't improve speed proportionally
**Solutions:**
1. Check GPU communication bandwidth (use `nvidia-smi topo -m`)
2. Reduce parallelism degree if communication overhead is high
3. For very long sequences, prefer Ring-Attention over Ulysses-SP
4. Ensure batch size is large enough to saturate GPUs
### Out of Memory with Parallelism
**Symptoms:** OOM errors when combining methods
**Solutions:**
1. Enable Tensor Parallelism to shard weights
2. Reduce resolution or batch size
3. Combine with memory efficient methods, such as cpu offloading
### Configuration Errors
**Symptoms:** Errors about invalid parallel configuration
**Solutions:**
1. Verify total GPU count matches: `ulysses × ring × cfg × tp`
2. Check model supports all enabled methods
3. Ensure divisibility constraints (e.g., Z-Image TP=1 or 2 only)
## See Also
- [Diffusion Acceleration Overview](diffusion_features.md) - Main acceleration guide
+1 -2
View File
@@ -101,7 +101,7 @@ def main():
if not prompts:
# Default prompt for text2img test if none provided
prompts = ["<|im_start|>A cute cat<|im_end|>"]
prompts = ["A cute cat"]
print(f"[Info] No prompts provided, using default: {prompts}")
omni_outputs = []
@@ -168,7 +168,6 @@ def main():
params_list = omni.default_sampling_params_list
if args.modality in ("text2img", "img2img"):
params_list[0].max_tokens = 1 # type: ignore
if len(params_list) > 1:
diffusion_params = params_list[1]
diffusion_params.num_inference_steps = args.steps # type: ignore
@@ -125,7 +125,7 @@ def generate_image(
def main():
parser = argparse.ArgumentParser(description="Bagel multimodal chat client")
parser.add_argument("--prompt", "-p", default="<|im_start|>A cute cat<|im_end|>", help="Text prompt")
parser.add_argument("--prompt", "-p", default="A cute cat", help="Text prompt")
parser.add_argument("--output", "-o", default="bagel_output.png", help="Output file (for image results)")
parser.add_argument("--server", "-s", default="http://localhost:8091", help="Server URL")
@@ -34,12 +34,45 @@ artifact, poll the job status and then download the completed file from the
content endpoint.
The main endpoints are:
- `POST /v1/videos`: create a video generation job
- `POST /v1/videos`: create a video generation job (async)
- `POST /v1/videos/sync`: generate a video and return raw bytes (sync, for benchmarks)
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Sync API (Benchmark / Testing)
`POST /v1/videos/sync` is a synchronous alternative that blocks until generation
completes and returns the raw video bytes (`video/mp4`) directly in the response
body. It is designed for benchmark and testing scenarios where one-shot
request/response latency measurement is needed.
The sync endpoint accepts the same form parameters as `POST /v1/videos`. It does
not create any stored job record — the response is purely the generated video
file. Metadata is returned via response headers:
- `X-Request-Id`: unique identifier for this generation request
- `X-Model`: model name used for generation
- `X-Inference-Time-S`: wall-clock inference time in seconds
```bash
curl -X POST http://localhost:8091/v1/videos/sync \
-F "prompt=A bear playing with yarn, smooth motion" \
-F "input_reference=@/path/to/input.png" \
-F "size=832x480" \
-F "num_frames=33" \
-F "fps=16" \
-F "negative_prompt=low quality, blurry, static" \
-F "num_inference_steps=40" \
-F "guidance_scale=1.0" \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
-F "seed=42" \
-o sync_i2v_output.mp4
```
## Storage
Generated video files are stored on local disk by the async video API.
@@ -125,7 +125,7 @@ PLAYER_HTML = """
def _build_player_js(sample_rate: int) -> str:
"""Build the JavaScript that powers the AudioWorklet player."""
return f"""
() => {{
<script>
const SR = {sample_rate};
const WC = {json.dumps(WORKLET_JS)};
let ctx = null, node = null, abort = null, gen = false, st = {{}};
@@ -284,7 +284,7 @@ def _build_player_js(sample_rate: int) -> str:
}}
}}
}};
}}
</script>
"""
@@ -435,10 +435,7 @@ def create_app(api_base: str):
)
with gr.Blocks(
css=css,
title="Qwen3-TTS Demo",
js=_build_player_js(PCM_SAMPLE_RATE),
theme=theme,
) as demo:
gr.HTML(f"""
<div style="display:flex; align-items:center; gap:16px; margin-bottom:8px;">
@@ -773,7 +770,14 @@ def create_app(api_base: str):
demo.queue()
return gr.mount_gradio_app(fastapi_app, demo, path="/")
return gr.mount_gradio_app(
fastapi_app,
demo,
path="/",
css=css,
theme=theme,
head=_build_player_js(PCM_SAMPLE_RATE),
)
def parse_args():
@@ -103,7 +103,7 @@ def build_payload(
if task_type == "CustomVoice":
if voice:
payload["speaker"] = voice
payload["voice"] = voice
if instructions and instructions.strip():
payload["instructions"] = instructions.strip()
@@ -34,12 +34,43 @@ artifact, poll the job status and then download the completed file from the
content endpoint.
The main endpoints are:
- `POST /v1/videos`: create a video generation job
- `POST /v1/videos`: create a video generation job (async)
- `POST /v1/videos/sync`: generate a video and return raw bytes (sync, for benchmarks)
- `GET /v1/videos/{video_id}`: retrieve the current job status and metadata
- `GET /v1/videos`: list stored video jobs
- `GET /v1/videos/{video_id}/content`: download the generated video file
- `DELETE /v1/videos/{video_id}`: delete the job and any stored output
## Sync API (Benchmark / Testing)
`POST /v1/videos/sync` is a synchronous alternative that blocks until generation
completes and returns the raw video bytes (`video/mp4`) directly in the response
body. It is designed for benchmark and testing scenarios where one-shot
request/response latency measurement is needed.
The sync endpoint accepts the same form parameters as `POST /v1/videos`. It does
not create any stored job record — the response is purely the generated video
file. Metadata is returned via response headers:
- `X-Request-Id`: unique identifier for this generation request
- `X-Model`: model name used for generation
- `X-Inference-Time-S`: wall-clock inference time in seconds
```bash
curl -X POST http://localhost:8091/v1/videos/sync \
-F "prompt=Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \
-F "size=832x480" \
-F "num_frames=33" \
-F "fps=16" \
-F "num_inference_steps=40" \
-F "guidance_scale=4.0" \
-F "guidance_scale_2=4.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=5.0" \
-F "seed=42" \
-o sync_t2v_output.mp4
```
## Storage
Generated video files are stored on local disk by the async video API.
+5
View File
@@ -53,6 +53,7 @@ dev = [
"av", # for ComfyUI tests
"openpyxl>=3.0.0", # for nightly CI
"pyttsx3>=2.99",
"opencc>=1.2.0",
"mistune>=3.2.0", # for example tests
]
@@ -176,20 +177,24 @@ markers = [
"rocm: Tests that run on AMD/ROCm (auto-added)",
"xpu: Tests that run on XPU (auto-added)",
"npu: Tests that run on NPU/Ascend (auto-added)",
"musa: Tests that run on MUSA/Moore Threads (auto-added)",
# specified computation resources marks (auto-added)
"H100: Tests that require H100 GPU",
"L4: Tests that require L4 GPU",
"MI325: Tests that require MI325 GPU (AMD/ROCm)",
"S5000: Tests that require S5000 GPU (Moore Threads/MUSA)",
"A2: Tests that require A2 NPU",
"A3: Tests that require A3 NPU",
"distributed_cuda: Tests that require multi cards on CUDA platform",
"distributed_rocm: Tests that require multi cards on ROCm platform",
"distributed_xpu: Tests that require multi cards on XPU platform",
"distributed_npu: Tests that require multi cards on NPU platform",
"distributed_musa: Tests that require multi cards on MUSA platform",
"skipif_cuda: Skip if the num of CUDA cards is less than the required",
"skipif_rocm: Skip if the num of ROCm cards is less than the required",
"skipif_xpu: Skip if the num of XPU cards is less than the required",
"skipif_npu: Skip if the num of NPU cards is less than the required",
"skipif_musa: Skip if the num of MUSA cards is less than the required",
# more detailed markers
"slow: Slow tests (may skip in quick CI)",
"benchmark: Benchmark tests",
+4
View File
@@ -0,0 +1,4 @@
-r common.txt
# MUSA platform dependencies
torchada>=0.1.46
onnxruntime>=1.23.2
+15 -4
View File
@@ -3,7 +3,7 @@ Setup script for vLLM-Omni with hardware-dependent installation.
This setup.py implements platform-aware dependency routing so users can run
`pip install vllm-omni` and automatically receive the correct platform-specific
dependencies (CUDA/ROCm/CPU/XPU/NPU) without requiring extras like `[cuda]`.
dependencies (CUDA/ROCm/CPU/XPU/NPU/MUSA) without requiring extras like `[cuda]`.
"""
import os
@@ -46,16 +46,16 @@ def detect_target_device() -> str:
Priority order:
1. VLLM_OMNI_TARGET_DEVICE environment variable (highest priority)
2. Torch backend detection (cuda, rocm, npu, xpu)
2. Torch backend detection (cuda, rocm, npu, xpu, musa)
3. CPU fallback (default)
Returns:
str: Device name ('cuda', 'rocm', 'npu', 'xpu', or 'cpu')
str: Device name ('cuda', 'rocm', 'npu', 'xpu', 'musa', or 'cpu')
"""
# Priority 1: Explicit override via environment variable
target_device = os.environ.get("VLLM_OMNI_TARGET_DEVICE")
if target_device:
valid_devices = ["cuda", "rocm", "npu", "xpu", "cpu"]
valid_devices = ["cuda", "rocm", "npu", "xpu", "musa", "cpu"]
if target_device.lower() in valid_devices:
print(f"Using target device from VLLM_OMNI_TARGET_DEVICE: {target_device.lower()}")
return target_device.lower()
@@ -97,6 +97,15 @@ def detect_target_device() -> str:
except Exception:
pass
# Check for MUSA (Moore Threads)
if hasattr(torch, "musa"):
try:
if torch.musa.is_available():
print("Detected MUSA backend from torch")
return "musa"
except Exception:
pass
print("No GPU backend detected in torch, defaulting to CPU")
return "cpu"
@@ -152,6 +161,8 @@ def get_vllm_omni_version() -> str:
version += f"{sep}npu"
elif device == "xpu":
version += f"{sep}xpu"
elif device == "musa":
version += f"{sep}musa"
elif device == "cpu":
version += f"{sep}cpu"
else:
+861 -88
View File
File diff suppressed because it is too large. Load diff
@@ -209,12 +209,9 @@ def _test_cfg_parallel_worker(
cfg_normalize=test_config["cfg_normalize"],
)
# Only rank 0 has valid output in CFG parallel mode
if cfg_rank == 0:
assert noise_pred is not None
result_queue.put(noise_pred.cpu())
else:
assert noise_pred is None
# CFG parallel returns the combined prediction on every rank.
assert noise_pred is not None
result_queue.put((cfg_rank, noise_pred.cpu()))
destroy_distributed_env()
@@ -348,7 +345,18 @@ def test_predict_noise_maybe_with_cfg(cfg_parallel_size: int, dtype: torch.dtype
# Get results from queues
baseline_output = baseline_queue.get()
cfg_parallel_output = cfg_parallel_queue.get()
cfg_parallel_outputs = [cfg_parallel_queue.get() for _ in range(cfg_parallel_size)]
cfg_parallel_outputs.sort(key=lambda item: item[0])
cfg_parallel_output = cfg_parallel_outputs[0][1]
for cfg_rank, rank_output in cfg_parallel_outputs[1:]:
torch.testing.assert_close(
rank_output,
cfg_parallel_output,
rtol=0,
atol=0,
msg=f"CFG parallel ranks produced different outputs (rank 0 vs rank {cfg_rank})",
)
# Verify shapes match
assert baseline_output.shape == cfg_parallel_output.shape, (
@@ -0,0 +1,129 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for LayerwiseOffloadHook."""
import gc
import os
import socket
from contextlib import contextmanager
import pytest
import torch
import torch.distributed as dist
from torch import nn
from torch.distributed.tensor import DeviceMesh, DTensor, Replicate
import vllm_omni.diffusion.offloader.layerwise_backend as layerwise_backend_module
from vllm_omni.diffusion.offloader.layerwise_backend import LayerwiseOffloadHook
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
class DummyStream:
def wait_stream(self, _stream) -> None:
return None
def wait_event(self, _event) -> None:
return None
class DummyEvent:
def record(self, _stream) -> None:
return None
@contextmanager
def dummy_stream(_stream):
yield None
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def _set_dist_env(*, rank: int, world_size: int, master_port: int) -> None:
os.environ["RANK"] = str(rank)
os.environ["LOCAL_RANK"] = str(rank)
os.environ["WORLD_SIZE"] = str(world_size)
os.environ["MASTER_ADDR"] = "127.0.0.1"
os.environ["MASTER_PORT"] = str(master_port)
def _cleanup_distributed() -> None:
if dist.is_initialized():
dist.destroy_process_group()
for key in ["MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"]:
os.environ.pop(key, None)
gc.collect()
if current_omni_platform.is_available():
current_omni_platform.empty_cache()
current_omni_platform.synchronize()
@pytest.fixture(scope="module")
def dist_group():
master_port = _find_free_port()
_set_dist_env(rank=0, world_size=1, master_port=master_port)
dist.init_process_group("gloo", rank=0, world_size=1)
try:
yield
finally:
_cleanup_distributed()
@pytest.fixture
def patched_offload_runtime(mocker):
mocker.patch.object(layerwise_backend_module.current_omni_platform, "Stream", DummyStream)
mocker.patch.object(layerwise_backend_module.current_omni_platform, "Event", DummyEvent)
mocker.patch.object(layerwise_backend_module.current_omni_platform, "current_stream", lambda: DummyStream())
mocker.patch.object(layerwise_backend_module.current_omni_platform, "stream", dummy_stream)
class TinyBlock(nn.Module):
def __init__(self, values: torch.Tensor):
super().__init__()
mesh = DeviceMesh("cpu", [0])
dtensor = DTensor.from_local(values, mesh, [Replicate()])
self.weight = nn.Parameter(dtensor)
def _make_values(start: float) -> torch.Tensor:
return torch.arange(start, start + 4, dtype=torch.float32)
class TestLayerwiseOffloadHook:
def test_dtensor_wrapper_is_preserved_across_prefetch_and_offload(self, dist_group, patched_offload_runtime):
current_block = TinyBlock(_make_values(1.0))
next_block = TinyBlock(_make_values(10.0))
hook = LayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
stream=DummyStream(),
pin_memory=False,
)
hook.initialize_hook(current_block)
assert isinstance(next_block.weight, DTensor)
assert next_block.weight.to_local().is_meta
assert next_block.weight.to_local().shape == torch.Size([4])
assert hook.dtype_metadata[next_block.weight.dtype][0]["shape"] == torch.Size([4])
hook.prefetch_layer(non_blocking=False)
assert isinstance(next_block.weight, DTensor)
assert torch.equal(next_block.weight.to_local(), _make_values(10.0))
assert next_block.weight.to_local().shape == torch.Size([4])
hook.offload_layer()
assert isinstance(current_block.weight, DTensor)
assert current_block.weight.to_local().is_meta
assert current_block.weight.to_local().shape == torch.Size([4])
assert not hook.is_materialized
+358 -21
View File
@@ -1,12 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import queue
import threading
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
import torch
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.data import DiffusionOutput, DiffusionRequestAbortedError
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched import (
@@ -14,6 +17,7 @@ from vllm_omni.diffusion.sched import (
RequestScheduler,
Scheduler,
SchedulerInterface,
StepScheduler,
)
from vllm_omni.diffusion.sched.interface import CachedRequestData, NewRequestData
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -29,9 +33,46 @@ def _make_request(req_id: str) -> OmniDiffusionRequest:
)
def _make_request_output(req_id: str, *, error: str | None = None) -> DiffusionOutput:
del req_id
return DiffusionOutput(output=None, error=error)
def _make_request_output(req_id: str, *, error: str | None = None, finished: bool = True):
return SimpleNamespace(
req_id=req_id,
step_index=None,
finished=finished,
result=DiffusionOutput(output=None, error=error),
)
def _make_step_output(
req_id: str,
step_index: int,
*,
finished: bool = False,
error: str | None = None,
):
return SimpleNamespace(
req_id=req_id,
step_index=step_index,
finished=finished,
result=DiffusionOutput(output=None, error=error) if error is not None else None,
)
def _make_step_request(
req_id: str,
*,
num_inference_steps: int = 4,
step_index: int | None = None,
sampling_params: OmniDiffusionSamplingParams | None = None,
) -> OmniDiffusionRequest:
return OmniDiffusionRequest(
prompts=[f"prompt_{req_id}"],
sampling_params=sampling_params
or OmniDiffusionSamplingParams(
num_inference_steps=num_inference_steps,
step_index=step_index,
),
request_ids=[req_id],
)
def _new_ids(sched_output) -> list[str]:
@@ -43,7 +84,7 @@ def _cached_ids(sched_output) -> list[str]:
class _StubScheduler(SchedulerInterface):
def __init__(self, request: OmniDiffusionRequest, output: DiffusionOutput) -> None:
def __init__(self, request: OmniDiffusionRequest, output) -> None:
self._request = request
self._output = output
self.initialized_with = None
@@ -75,9 +116,10 @@ class _StubScheduler(SchedulerInterface):
is_empty=False,
)
def update_from_output(self, sched_output, output: DiffusionOutput) -> set[str]:
def update_from_output(self, sched_output, output) -> set[str]:
del sched_output
assert output is self._output
self._state.status = DiffusionRequestStatus.FINISHED_COMPLETED
return {self._sched_req_id}
def has_requests(self) -> bool:
@@ -185,9 +227,14 @@ class TestRequestScheduler:
state_b = self.scheduler.get_request_state(req_id_b)
assert state_b.status == DiffusionRequestStatus.FINISHED_ABORTED
first = self.scheduler.schedule()
assert first.finished_req_ids == {req_id_b}
# A should still run normally.
output_a = self.scheduler.schedule()
assert _new_ids(output_a) == [req_id_a]
assert _new_ids(first) == [req_id_a]
# B is already marked finished aborted, scheduling again should not pull it.
second = self.scheduler.schedule()
assert second.finished_req_ids == set()
# Abort running request.
self.scheduler.finish_requests(req_id_a, DiffusionRequestStatus.FINISHED_ABORTED)
@@ -233,33 +280,33 @@ class TestDiffusionEngine:
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.scheduler = RequestScheduler()
engine.scheduler.initialize(Mock())
engine.executor = Mock()
engine._rpc_lock = threading.Lock()
engine._rpc_lock = threading.RLock()
engine.abort_queue = queue.Queue()
request = _make_request("engine")
expected = DiffusionOutput(output=None)
engine.executor.add_req.return_value = expected
runner_output = _make_request_output("engine")
engine.execute_fn = Mock(return_value=runner_output)
output = engine.add_req_and_wait_for_response(request)
assert output is expected
engine.executor.add_req.assert_called_once_with(request)
assert output is runner_output.result
engine.execute_fn.assert_called_once()
def test_supports_scheduler_interface_injection(self) -> None:
request = _make_request("engine_iface")
expected = DiffusionOutput(output=None)
scheduler = _StubScheduler(request, expected)
runner_output = _make_request_output("engine_iface")
scheduler = _StubScheduler(request, runner_output)
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.scheduler = scheduler
engine.executor = Mock()
engine.executor.add_req = Mock(return_value=expected)
engine._rpc_lock = threading.Lock()
engine._rpc_lock = threading.RLock()
engine.abort_queue = queue.Queue()
engine.execute_fn = Mock(return_value=runner_output)
output = engine.add_req_and_wait_for_response(request)
assert output is expected
engine.executor.add_req.assert_called_once_with(request)
assert output is runner_output.result
engine.execute_fn.assert_called_once()
def test_initializes_injected_scheduler(self) -> None:
request = _make_request("init")
@@ -289,6 +336,59 @@ class TestDiffusionEngine:
assert req_id in finished
assert scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_COMPLETED
def test_step_raises_aborted_error(self) -> None:
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.pre_process_func = None
engine.add_req_and_wait_for_response = Mock(
return_value=DiffusionOutput(aborted=True, abort_message="Request req-abort aborted.")
)
with pytest.raises(DiffusionRequestAbortedError, match="Request req-abort aborted"):
engine.step(_make_request("req-abort"))
def test_abort_queue_marks_request_finished_aborted(self) -> None:
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.scheduler = RequestScheduler()
engine.scheduler.initialize(Mock())
engine.abort_queue = queue.Queue()
req_id = engine.scheduler.add_request(_make_request("req-abort"))
engine.abort("req-abort")
engine._process_aborts_queue()
assert engine.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_ABORTED
def test_finalize_finished_request_returns_aborted_output(self) -> None:
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.scheduler = RequestScheduler()
engine.scheduler.initialize(Mock())
req_id = engine.scheduler.add_request(_make_request("req-finalize"))
engine.scheduler.finish_requests(req_id, DiffusionRequestStatus.FINISHED_ABORTED)
output = engine._finalize_finished_request(req_id)
assert output.aborted is True
assert output.abort_message == "Request req-finalize aborted."
def test_initializes_step_scheduler_when_step_execution_enabled(self) -> None:
od_config = Mock(model_class_name="mock_model")
od_config.step_execution = True
fake_executor = Mock()
fake_executor_cls = Mock(return_value=fake_executor)
with (
patch("vllm_omni.diffusion.diffusion_engine.get_diffusion_post_process_func", return_value=None),
patch("vllm_omni.diffusion.diffusion_engine.get_diffusion_pre_process_func", return_value=None),
patch("vllm_omni.diffusion.diffusion_engine.DiffusionExecutor.get_class", return_value=fake_executor_cls),
patch.object(DiffusionEngine, "_dummy_run", return_value=None),
):
engine = DiffusionEngine(od_config)
assert isinstance(engine.scheduler, StepScheduler)
assert engine.execute_fn is fake_executor.execute_step
fake_executor_cls.assert_called_once_with(od_config)
def test_dummy_run_raises_on_output_error(self) -> None:
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.od_config = Mock(model_class_name="mock_model")
@@ -297,3 +397,240 @@ class TestDiffusionEngine:
with pytest.raises(RuntimeError, match="Dummy run failed: boom"):
engine._dummy_run()
class TestStepScheduler:
def setup_method(self) -> None:
self.scheduler: StepScheduler = StepScheduler()
self.scheduler.initialize(Mock())
def test_single_request_step_lifecycle(self) -> None:
request = _make_step_request("step", num_inference_steps=3)
req_id = self.scheduler.add_request(request)
first = self.scheduler.schedule()
assert _new_ids(first) == [req_id]
assert _cached_ids(first) == []
assert first.num_running_reqs == 1
assert first.num_waiting_reqs == 0
finished = self.scheduler.update_from_output(first, _make_step_output(req_id, step_index=1))
assert finished == set()
assert self.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.RUNNING
assert request.sampling_params.step_index == 1
assert self.scheduler.has_requests() is True
second = self.scheduler.schedule()
assert _new_ids(second) == []
assert _cached_ids(second) == [req_id]
assert second.num_running_reqs == 1
assert second.num_waiting_reqs == 0
finished = self.scheduler.update_from_output(second, _make_step_output(req_id, step_index=2))
assert finished == set()
assert request.sampling_params.step_index == 2
third = self.scheduler.schedule()
assert _new_ids(third) == []
assert _cached_ids(third) == [req_id]
finished = self.scheduler.update_from_output(
third,
_make_step_output(req_id, step_index=3, finished=True),
)
assert finished == {req_id}
assert self.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_COMPLETED
assert request.sampling_params.step_index == 3
assert self.scheduler.has_requests() is False
def test_fifo_single_request_scheduling(self) -> None:
req_id_a = self.scheduler.add_request(_make_step_request("a", num_inference_steps=2))
req_id_b = self.scheduler.add_request(_make_step_request("b", num_inference_steps=2))
first = self.scheduler.schedule()
assert _new_ids(first) == [req_id_a]
assert _cached_ids(first) == []
assert first.num_running_reqs == 1
assert first.num_waiting_reqs == 1
finished = self.scheduler.update_from_output(first, _make_step_output(req_id_a, step_index=1))
assert finished == set()
second = self.scheduler.schedule()
assert _new_ids(second) == []
assert _cached_ids(second) == [req_id_a]
assert second.num_running_reqs == 1
assert second.num_waiting_reqs == 1
finished = self.scheduler.update_from_output(
second,
_make_step_output(req_id_a, step_index=2, finished=True),
)
assert finished == {req_id_a}
third = self.scheduler.schedule()
assert _new_ids(third) == [req_id_b]
assert _cached_ids(third) == []
assert third.num_running_reqs == 1
assert third.num_waiting_reqs == 0
def test_error_output_marks_finished_error(self) -> None:
req_id = self.scheduler.add_request(_make_step_request("err", num_inference_steps=3))
sched_output = self.scheduler.schedule()
assert _new_ids(sched_output) == [req_id]
finished = self.scheduler.update_from_output(
sched_output,
_make_step_output(req_id, step_index=1, finished=True, error="worker failed"),
)
assert finished == {req_id}
state = self.scheduler.get_request_state(req_id)
assert state.status == DiffusionRequestStatus.FINISHED_ERROR
assert state.error == "worker failed"
assert self.scheduler.has_requests() is False
def test_missing_step_index_marks_finished_error(self) -> None:
req_id = self.scheduler.add_request(_make_step_request("missing", num_inference_steps=3))
sched_output = self.scheduler.schedule()
finished = self.scheduler.update_from_output(
sched_output,
SimpleNamespace(
req_id=req_id,
step_index=None,
finished=True,
result=None,
),
)
assert finished == {req_id}
state = self.scheduler.get_request_state(req_id)
assert state.status == DiffusionRequestStatus.FINISHED_ERROR
assert state.error == "Missing step_index in RunnerOutput"
def test_abort_request_for_waiting_and_running(self) -> None:
req_id_a = self.scheduler.add_request(_make_step_request("a", num_inference_steps=2))
req_id_b = self.scheduler.add_request(_make_step_request("b", num_inference_steps=2))
self.scheduler.finish_requests(req_id_b, DiffusionRequestStatus.FINISHED_ABORTED)
assert self.scheduler.get_request_state(req_id_b).status == DiffusionRequestStatus.FINISHED_ABORTED
running = self.scheduler.schedule()
assert _new_ids(running) == [req_id_a]
self.scheduler.finish_requests(req_id_a, DiffusionRequestStatus.FINISHED_ABORTED)
assert self.scheduler.get_request_state(req_id_a).status == DiffusionRequestStatus.FINISHED_ABORTED
assert self.scheduler.has_requests() is False
def test_has_requests_state_transition(self) -> None:
assert self.scheduler.has_requests() is False
req_id = self.scheduler.add_request(_make_step_request("has", num_inference_steps=2))
assert self.scheduler.has_requests() is True
sched_output = self.scheduler.schedule()
assert self.scheduler.has_requests() is True
finished = self.scheduler.update_from_output(
sched_output,
_make_step_output(req_id, step_index=2, finished=True),
)
assert finished == {req_id}
assert self.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_COMPLETED
assert self.scheduler.has_requests() is False
def test_scheduled_request_aborted_before_update_is_returned_finished(self) -> None:
req_id = self.scheduler.add_request(_make_step_request("abort-late", num_inference_steps=2))
sched_output = self.scheduler.schedule()
self.scheduler.finish_requests(req_id, DiffusionRequestStatus.FINISHED_ABORTED)
finished = self.scheduler.update_from_output(
sched_output,
_make_step_output(req_id, step_index=1),
)
assert finished == {req_id}
assert self.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_ABORTED
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)
first = self.scheduler.schedule()
assert self.scheduler.update_from_output(first, _make_step_output(req_id, step_index=1)) == set()
assert request.sampling_params.step_index == 1
second = self.scheduler.schedule()
assert _cached_ids(second) == [req_id]
assert self.scheduler.preempt_request(req_id) is True
assert self.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.PREEMPTED
assert request.sampling_params.step_index == 1
third = self.scheduler.schedule()
assert _cached_ids(third) == [req_id]
assert request.sampling_params.step_index == 1
@pytest.mark.parametrize(
("sampling_params", "expected_steps"),
[
(
OmniDiffusionSamplingParams(
timesteps=torch.tensor([1.0, 0.5, 0.0]),
sigmas=[1.0, 0.5, 0.25, 0.0],
num_inference_steps=5,
),
3,
),
(
OmniDiffusionSamplingParams(
sigmas=[1.0, 0.5],
num_inference_steps=5,
),
2,
),
(
OmniDiffusionSamplingParams(
num_inference_steps=4,
),
4,
),
],
)
def test_total_steps_priority(self, sampling_params: OmniDiffusionSamplingParams, expected_steps: int) -> None:
request = _make_step_request("priority", sampling_params=sampling_params)
req_id = self.scheduler.add_request(request)
for _ in range(expected_steps - 1):
sched_output = self.scheduler.schedule()
assert sched_output.scheduled_req_ids == [req_id]
next_step = request.sampling_params.step_index + 1
assert (
self.scheduler.update_from_output(
sched_output,
_make_step_output(req_id, step_index=next_step),
)
== set()
)
final_output = self.scheduler.schedule()
assert final_output.scheduled_req_ids == [req_id]
assert self.scheduler.update_from_output(
final_output,
_make_step_output(req_id, step_index=expected_steps, finished=True),
) == {req_id}
assert self.scheduler.get_request_state(req_id).status == DiffusionRequestStatus.FINISHED_COMPLETED
@pytest.mark.parametrize(
"sampling_params",
[
OmniDiffusionSamplingParams(num_inference_steps=0),
OmniDiffusionSamplingParams(num_inference_steps=3, step_index=3),
OmniDiffusionSamplingParams(num_inference_steps=3, step_index=-1),
],
)
def test_rejects_invalid_initial_step_state(self, sampling_params: OmniDiffusionSamplingParams) -> None:
request = _make_step_request("invalid", sampling_params=sampling_params)
with pytest.raises(ValueError):
self.scheduler.add_request(request)
+256 -1
View File
@@ -1,10 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for step-level diffusion runner and worker execution."""
"""Tests for step-level diffusion execution across runner / worker / executor / engine."""
import os
import queue
import threading
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import torch
@@ -12,6 +15,7 @@ import torch
import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
from tests.utils import hardware_test
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
from vllm_omni.diffusion.distributed.comm import RingComm, SeqAllToAll4D
from vllm_omni.diffusion.distributed.parallel_state import (
@@ -20,10 +24,13 @@ from vllm_omni.diffusion.distributed.parallel_state import (
init_distributed_environment,
initialize_model_parallel,
)
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
from vllm_omni.diffusion.ipc import (
pack_diffusion_output_shm,
unpack_diffusion_output_shm,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched import StepScheduler
from vllm_omni.diffusion.sched.interface import (
CachedRequestData,
DiffusionSchedulerOutput,
@@ -32,6 +39,8 @@ from vllm_omni.diffusion.sched.interface import (
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker
from vllm_omni.diffusion.worker.utils import RunnerOutput
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
@@ -86,6 +95,23 @@ class _StepPipeline:
return DiffusionOutput(output=torch.tensor([state.step_index], dtype=torch.float32))
class _InterruptingStepPipeline(_StepPipeline):
interrupt = True
def denoise_step(self, state, **kwargs):
del state, kwargs
self.denoise_calls += 1
return None
def step_scheduler(self, state, noise_pred, **kwargs):
del state, noise_pred, kwargs
raise AssertionError("step_scheduler should not run after interrupt")
def post_decode(self, state, **kwargs):
del state, kwargs
raise AssertionError("post_decode should not run after interrupt")
class _IdentityNoiseTransformer(torch.nn.Module):
def forward(self, x: torch.Tensor, **kwargs):
del kwargs
@@ -188,6 +214,21 @@ def _make_step_request(num_inference_steps: int = 2):
)
def _assert_aborted_output(output: DiffusionOutput, request_id: str) -> None:
assert output.output is None
assert output.error is None
assert output.aborted is True
assert output.abort_message == f"Request {request_id} aborted."
def _make_engine_request(req_id: str = "req-1", num_inference_steps: int = 2) -> OmniDiffusionRequest:
return OmniDiffusionRequest(
prompts=[f"prompt-{req_id}"],
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=num_inference_steps),
request_ids=[req_id],
)
def _make_runner():
runner = object.__new__(DiffusionModelRunner)
runner.vllm_config = object()
@@ -242,6 +283,18 @@ def _make_cached_scheduler_output(sched_req_id="req-1", step_id=1, finished_req_
)
def _make_engine(scheduler, execute_fn=None) -> DiffusionEngine:
engine = object.__new__(DiffusionEngine)
engine.od_config = SimpleNamespace(model_class_name="QwenImagePipeline")
engine.pre_process_func = None
engine.post_process_func = None
engine.scheduler = scheduler
engine.execute_fn = execute_fn
engine._rpc_lock = threading.RLock()
engine.abort_queue = queue.Queue()
return engine
def _expected_output_for_mode(mode: str) -> torch.Tensor:
if mode == "cfg":
return torch.tensor([[3.0]])
@@ -322,6 +375,52 @@ class TestRunner:
assert runner.pipeline.scheduler_calls == 2
assert runner.pipeline.decode_calls == 1
def test_rejects_multi_request_step_batch(self):
runner = _make_runner()
req_1 = _make_step_request()
req_2 = _make_step_request()
req_2.request_ids = ["req-2"]
scheduler_output = DiffusionSchedulerOutput(
step_id=0,
scheduled_new_reqs=[
NewRequestData(sched_req_id="req-1", req=req_1),
NewRequestData(sched_req_id="req-2", req=req_2),
],
scheduled_cached_reqs=CachedRequestData.make_empty(),
finished_req_ids=set(),
num_running_reqs=2,
num_waiting_reqs=0,
)
with pytest.raises(ValueError, match="batch_size=1"):
DiffusionModelRunner.execute_stepwise(runner, scheduler_output)
def test_rejects_missing_cached_state(self):
runner = _make_runner()
with pytest.raises(ValueError, match="Missing cached state"):
DiffusionModelRunner.execute_stepwise(runner, _make_cached_scheduler_output(sched_req_id="req-missing"))
def test_interrupt_marks_request_finished_and_clears_state(self, monkeypatch):
runner = _make_runner()
runner.pipeline = _InterruptingStepPipeline()
req = _make_step_request()
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
output = DiffusionModelRunner.execute_stepwise(runner, _make_scheduler_output(req, step_id=0))
assert output.req_id == "req-1"
assert output.step_index == 0
assert output.finished is True
assert output.result is not None
assert output.result.error == "stepwise denoise interrupted"
assert "req-1" not in runner.state_cache
assert runner.pipeline.prepare_calls == 1
assert runner.pipeline.denoise_calls == 1
assert runner.pipeline.scheduler_calls == 0
assert runner.pipeline.decode_calls == 0
def test_load_model_rejects_unsupported_step_execution(self, monkeypatch):
class _RequestOnlyPipeline:
pass
@@ -439,6 +538,153 @@ class TestWorker:
DiffusionWorker.execute_stepwise(worker, scheduler_output)
@pytest.mark.cpu
class TestExecutor:
"""MultiprocDiffusionExecutor.execute_step"""
def test_execute_step_passes_through_runner_output(self):
executor = object.__new__(MultiprocDiffusionExecutor)
executor._ensure_open = lambda: None
expected = RunnerOutput(req_id="req-step", step_index=1, finished=False, result=None)
executor.collective_rpc = Mock(return_value=expected)
request = _make_engine_request("req-step", num_inference_steps=2)
scheduler_output = _make_scheduler_output(request, sched_req_id="req-step")
output = MultiprocDiffusionExecutor.execute_step(executor, scheduler_output)
assert output is expected
@pytest.mark.cpu
class TestEngine:
"""Step-execution paths in DiffusionEngine.add_req_and_wait_for_response"""
@pytest.mark.parametrize(
("execute_fn", "expected_error"),
[
(
lambda _: RunnerOutput(
req_id="req-error",
step_index=1,
finished=True,
result=DiffusionOutput(error="boom"),
),
"boom",
),
(
lambda _: (_ for _ in ()).throw(RuntimeError("gpu on fire")),
"gpu on fire",
),
],
)
def test_step_engine_returns_error(self, execute_fn, expected_error):
scheduler = StepScheduler()
scheduler.initialize(Mock())
engine = _make_engine(scheduler, execute_fn=execute_fn)
output = engine.add_req_and_wait_for_response(_make_engine_request("req-error", num_inference_steps=2))
assert output.output is None
assert expected_error in output.error
def test_step_execution_completes(self):
scheduler = StepScheduler()
scheduler.initialize(Mock())
engine = _make_engine(scheduler)
request = _make_engine_request("req-step", num_inference_steps=2)
call_count = {"n": 0}
def execute_fn(_):
call_count["n"] += 1
finished = call_count["n"] == 2
return RunnerOutput(
req_id="req-step",
step_index=call_count["n"],
finished=finished,
result=(DiffusionOutput(output=torch.tensor([2.0])) if finished else None),
)
engine.execute_fn = execute_fn
output = engine.add_req_and_wait_for_response(request)
assert call_count["n"] == 2
assert output.error is None
assert torch.equal(output.output, torch.tensor([2.0]))
def test_step_abort_stops_rescheduling_after_first_step(self):
scheduler = StepScheduler()
scheduler.initialize(Mock())
engine = _make_engine(scheduler)
request = _make_engine_request("req-stop", num_inference_steps=4)
step = {"n": 0}
def execute_fn(_):
step["n"] += 1
engine.abort("req-stop")
return RunnerOutput(
req_id="req-stop",
step_index=1,
finished=False,
result=None,
)
engine.execute_fn = execute_fn
output = engine.add_req_and_wait_for_response(request)
assert step["n"] == 1
_assert_aborted_output(output, "req-stop")
def test_step_abort_after_reschedule_returns_aborted_output(self):
scheduler = StepScheduler()
scheduler.initialize(Mock())
engine = _make_engine(scheduler)
request = _make_engine_request("req-mid", num_inference_steps=4)
step = {"n": 0}
def execute_fn(sched_output):
step["n"] += 1
if step["n"] == 2:
assert sched_output == _make_cached_scheduler_output("req-mid", step_id=1)
engine.abort("req-mid")
return RunnerOutput(
req_id="req-mid",
step_index=step["n"],
finished=False,
result=None,
)
engine.execute_fn = execute_fn
output = engine.add_req_and_wait_for_response(request)
assert step["n"] == 2
_assert_aborted_output(output, "req-mid")
def test_finished_step_without_result_returns_error(self):
scheduler = StepScheduler()
scheduler.initialize(Mock())
engine = _make_engine(
scheduler,
execute_fn=lambda _: RunnerOutput(
req_id="req-missing",
step_index=1,
finished=True,
result=None,
),
)
output = engine.add_req_and_wait_for_response(_make_engine_request("req-missing", num_inference_steps=1))
assert output.output is None
assert output.error == "Diffusion execution finished without a final output."
@pytest.mark.cpu
class TestIPC:
def test_pack_unpack_runner_output_shm(self):
@@ -458,6 +704,15 @@ class TestIPC:
class TestSupportedPipelines:
"""Step-execution protocol checks for supported pipelines."""
def test_default_stage_config_includes_step_execution(self):
stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(
{
"step_execution": True,
}
)[0]
assert stage_cfg["engine_args"]["step_execution"] is True
def test_qwen_image_supports_step_execution(self):
from vllm_omni.diffusion.models.interface import SupportsStepExecution, supports_step_execution
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image import QwenImagePipeline
@@ -66,7 +66,9 @@ def _make_engine(num_gpus: int = 1):
sched.initialize(Mock())
engine.scheduler = sched
engine.executor = executor
engine._rpc_lock = threading.Lock()
engine._rpc_lock = threading.RLock()
engine.abort_queue = queue.Queue()
engine.execute_fn = executor.execute_request
return engine, executor, req_q, res_q
@@ -80,7 +82,7 @@ def _start_worker(req_q, res_q, count=2):
req = req_q.get(timeout=10)
method = req.get("method", "")
args = req.get("args", ())
if method == "generate" and args and hasattr(args[0], "request_ids"):
if method in {"generate", "execute_model"} and args and hasattr(args[0], "request_ids"):
tag = f"result_for_{args[0].request_ids[0]}"
elif args:
tag = f"result_for_{args[0]}"
@@ -116,11 +118,11 @@ def _inject_interleave(executor):
return a_enqueued, b_complete
# ──────────────────── bug-reproduction: concurrent add_req ────────────────
# ───────────────── concurrent request execution ─────────────────
class TestConcurrentAddReqBug:
"""Two concurrent ``add_req_and_wait_for_response()`` calls swap results."""
class TestConcurrentRequestExecution:
"""Concurrent request execution should not swap results."""
def test_results_are_correctly_routed(self):
engine, executor, req_q, res_q = _make_engine()
@@ -151,11 +153,11 @@ class TestConcurrentAddReqBug:
assert results["B"].error == "result_for_B"
# ──────────────── bug-reproduction: concurrent collective_rpc ─────────────
# ───────────────── concurrent collective RPC ─────────────────
class TestConcurrentCollectiveRpcBug:
"""Two concurrent ``collective_rpc()`` calls swap results."""
class TestConcurrentCollectiveRpc:
"""Concurrent ``collective_rpc()`` calls should not swap results."""
def test_results_are_correctly_routed(self):
engine, executor, req_q, res_q = _make_engine()
@@ -192,11 +194,11 @@ class TestConcurrentCollectiveRpcBug:
assert results["B"].error == "result_for_call_B"
# ──────── bug-reproduction: add_req vs collective_rpc concurrently ────────
# ──────────── concurrent request execution and collective RPC ────────────
class TestConcurrentAddReqVsCollectiveRpcBug:
"""``add_req`` and ``collective_rpc`` running concurrently swap results."""
class TestConcurrentRequestExecutionAndCollectiveRpc:
"""Request execution and ``collective_rpc()`` should not swap results."""
def test_results_are_correctly_routed(self):
engine, executor, req_q, res_q = _make_engine()
@@ -205,7 +207,7 @@ class TestConcurrentAddReqVsCollectiveRpcBug:
results: dict[str, object] = {}
def _a(): # add_req path
def _a(): # request execution path
results["A"] = engine.add_req_and_wait_for_response(_mock_request("A"))
def _b(): # collective_rpc path
@@ -230,10 +232,10 @@ class TestConcurrentAddReqVsCollectiveRpcBug:
assert results["B"].error == "result_for_call_B"
# ─────────────── backward-compatibility (serial) tests ────────────────────
# ─────────────────────── serial operation coverage ───────────────────────
class TestSerialOperations:
class TestSerialEngineOperations:
"""Verify correct behaviour for single-threaded (serial) usage.
These tests must pass both **before** and **after** any concurrency fix
@@ -385,18 +387,18 @@ class TestCollectiveRpcTimeoutWhileLockHeld:
executor._result_mq.dequeue = _hanging_dequeue
# Thread running add_req — acquires the lock, enqueues, then
# Thread running request execution — acquires the lock, enqueues, then
# blocks on dequeue forever (worker hang).
def _stalled_add_req():
def _stalled_request_execution():
try:
engine.add_req_and_wait_for_response(_mock_request("stalled"))
except Exception:
pass
t = threading.Thread(target=_stalled_add_req, daemon=True)
t = threading.Thread(target=_stalled_request_execution, daemon=True)
t.start()
# Wait until add_req is truly inside the lock and blocking.
# Wait until request execution is truly inside the lock and blocking.
add_req_blocked.wait(5)
# collective_rpc should time out at lock acquisition, not hang.
+24
View File
@@ -47,6 +47,19 @@ def pytest_addoption(parser):
help="Balanced sample count per GEdit task group",
)
group.addoption("--accuracy-workers", action="store", type=int, default=1, help="Worker count for accuracy benches")
group.addoption(
"--wan22-i2v-image-source",
action="store",
default=None,
help="Image source for Wan2.2 I2V accuracy tests. Can be local path or remote URL.",
)
group.addoption(
"--wan22-i2v-online-timeout-seconds",
action="store",
type=int,
default=1200,
help="Online serving timeout in seconds for Wan2.2 I2V accuracy tests.",
)
def _hf_cache_root() -> Path:
@@ -142,6 +155,17 @@ def accuracy_workers(request: pytest.FixtureRequest) -> int:
return int(request.config.getoption("accuracy_workers"))
@pytest.fixture(scope="session")
def wan22_i2v_image_source(request: pytest.FixtureRequest) -> str | None:
value = request.config.getoption("wan22_i2v_image_source")
return str(value) if value else None
@pytest.fixture(scope="session")
def wan22_i2v_online_timeout_seconds(request: pytest.FixtureRequest) -> int:
return int(request.config.getoption("wan22_i2v_online_timeout_seconds"))
@pytest.fixture(scope="session")
def gebench_samples_per_type(request: pytest.FixtureRequest) -> int:
return int(request.config.getoption("gebench_samples_per_type"))
Whitespace-only changes.
@@ -0,0 +1,165 @@
from __future__ import annotations
import argparse
import base64
import json
from io import BytesIO
from pathlib import Path
import requests
import torch
from diffusers import UniPCMultistepScheduler, WanImageToVideoPipeline
from diffusers.pipelines.wan import pipeline_wan_i2v as wan_i2v_module
from diffusers.utils import export_to_video, load_image
from PIL import Image
from tests.e2e.accuracy.wan22_i2v.wan22_i2v_video_similarity_common import BOUNDARY_RATIO
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Wan2.2 I2V diffusers offline generation.")
parser.add_argument("--model", required=True)
parser.add_argument("--image-source", required=True)
parser.add_argument("--prompt", required=True)
parser.add_argument("--negative-prompt", required=True)
parser.add_argument("--size", required=True)
parser.add_argument("--fps", type=int, required=True)
parser.add_argument("--num-frames", type=int, required=True)
parser.add_argument("--guidance-scale", type=float, required=True)
parser.add_argument("--guidance-scale-2", type=float, required=True)
parser.add_argument("--flow-shift", type=float, required=True)
parser.add_argument("--num-inference-steps", type=int, required=True)
parser.add_argument("--seed", type=int, required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--metadata-output", required=True)
return parser.parse_args()
def _parse_size(size: str) -> tuple[int, int]:
width_str, height_str = size.lower().split("x", 1)
return int(width_str), int(height_str)
class _IdentityFtfy:
@staticmethod
def fix_text(text: str) -> str:
return text
def _ensure_wan_ftfy_fallback() -> None:
if not hasattr(wan_i2v_module, "ftfy"):
wan_i2v_module.ftfy = _IdentityFtfy()
def _offline_cuda_device() -> torch.device:
return torch.device("cuda:0")
def _load_input_image(source: str) -> Image.Image:
if source.startswith("data:image"):
_, encoded = source.split(",", 1)
image = Image.open(BytesIO(base64.b64decode(encoded)))
image.load()
return image.convert("RGB")
source_path = Path(source)
if source_path.exists():
image = Image.open(source_path)
image.load()
return image.convert("RGB")
image = load_image(source)
if isinstance(image, Image.Image):
image.load()
return image.convert("RGB")
response = requests.get(source, timeout=60)
response.raise_for_status()
image = Image.open(BytesIO(response.content))
image.load()
return image.convert("RGB")
def _resize_to_target(image: Image.Image, *, width: int, height: int) -> Image.Image:
return image.resize((width, height), Image.Resampling.LANCZOS)
def _configure_scheduler(pipe: WanImageToVideoPipeline, *, flow_shift: float) -> None:
pipe.scheduler = UniPCMultistepScheduler.from_config(
pipe.scheduler.config,
flow_shift=flow_shift,
)
def _write_metadata(
path: Path,
*,
args: argparse.Namespace,
width: int,
height: int,
frame_count: int,
) -> None:
payload = {
"model": args.model,
"image_source": args.image_source,
"size": args.size,
"width": width,
"height": height,
"fps": args.fps,
"num_frames": args.num_frames,
"actual_frame_count": frame_count,
"guidance_scale": args.guidance_scale,
"guidance_scale_2": args.guidance_scale_2,
"boundary_ratio": BOUNDARY_RATIO,
"flow_shift": args.flow_shift,
"num_inference_steps": args.num_inference_steps,
"seed": args.seed,
"world_size": 1,
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def main() -> int:
args = _parse_args()
device = _offline_cuda_device()
torch.cuda.set_device(device)
_ensure_wan_ftfy_fallback()
pipe = WanImageToVideoPipeline.from_pretrained(args.model, torch_dtype=torch.bfloat16)
pipe.register_to_config(boundary_ratio=BOUNDARY_RATIO)
_configure_scheduler(pipe, flow_shift=args.flow_shift)
pipe.to(device)
pipe.set_progress_bar_config(disable=False)
input_image = _load_input_image(args.image_source)
width, height = _parse_size(args.size)
resized_image = _resize_to_target(input_image, width=width, height=height)
generator = torch.Generator(device=device.type).manual_seed(args.seed)
frames = pipe(
image=resized_image,
prompt=args.prompt,
negative_prompt=args.negative_prompt,
height=height,
width=width,
num_frames=args.num_frames,
guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_2,
num_inference_steps=args.num_inference_steps,
generator=generator,
).frames[0]
output_path = Path(args.output)
metadata_path = Path(args.metadata_output)
output_path.parent.mkdir(parents=True, exist_ok=True)
export_to_video(frames, str(output_path), fps=args.fps)
_write_metadata(metadata_path, args=args, width=width, height=height, frame_count=len(frames))
if hasattr(pipe, "maybe_free_model_hooks"):
pipe.maybe_free_model_hooks()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,672 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import time
from base64 import b64decode, b64encode
from fractions import Fraction
from hashlib import sha1
from io import BytesIO
from mimetypes import guess_type
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import pytest
import requests
import torch
from diffusers import UniPCMultistepScheduler
from PIL import Image
from tests.conftest import OmniServerParams
from tests.e2e.accuracy.wan22_i2v.run_wan22_i2v_diffusers_cp import (
_configure_scheduler,
_ensure_wan_ftfy_fallback,
_IdentityFtfy,
_offline_cuda_device,
_resize_to_target,
)
from tests.e2e.accuracy.wan22_i2v.wan22_i2v_video_similarity_common import (
FLOW_SHIFT,
FPS,
GUIDANCE_SCALE,
GUIDANCE_SCALE_2,
HEIGHT,
MODEL_NAME,
NEGATIVE_PROMPT,
NUM_FRAMES,
NUM_INFERENCE_STEPS,
PROMPT,
PSNR_THRESHOLD,
RABBIT_IMAGE_URL,
SEED,
SIZE,
SSIM_THRESHOLD,
WIDTH,
)
from tests.utils import hardware_test
def test_parse_video_metadata_extracts_dimensions_and_fps() -> None:
payload = {
"streams": [
{
"width": 832,
"height": 480,
"avg_frame_rate": "16/1",
"nb_read_frames": "93",
}
]
}
metadata = _parse_video_metadata(payload)
assert metadata["width"] == 832
assert metadata["height"] == 480
assert metadata["fps"] == 16.0
assert metadata["frame_count"] == 93
def test_parse_ssim_summary_extracts_all_score() -> None:
output = """
[Parsed_ssim_0 @ 000001] SSIM Y:0.971903 (15.512007) U:0.965077 (14.569044) V:0.962414 (14.252637) All:0.968311 (15.035654)
"""
assert _parse_ssim_score(output) == 0.968311
def test_parse_psnr_summary_extracts_average_score() -> None:
output = """
[Parsed_psnr_0 @ 000001] PSNR y:32.670157 u:31.844621 v:31.513839 average:32.148004 min:31.563744 max:33.201457
"""
assert _parse_psnr_score(output) == 32.148004
def test_build_diffusers_command_uses_python_runner_path(tmp_path: Path) -> None:
runner_path = tmp_path / "run_wan22_i2v_diffusers_cp.py"
command = _build_diffusers_command(
runner_path=runner_path,
image_source=RABBIT_IMAGE_URL,
output_path=tmp_path / "offline.mp4",
metadata_path=tmp_path / "offline.json",
)
assert command[:2] == [
sys.executable,
str(runner_path),
]
assert "--output" in command
assert "--metadata-output" in command
def test_resolve_image_source_prefers_existing_local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
image_path = tmp_path / "rabbit.png"
Image.new("RGB", (8, 8), color=(255, 128, 64)).save(image_path)
assert _resolve_image_source(str(image_path)) == str(image_path.resolve())
def test_build_online_image_reference_uses_data_url_for_local_path(tmp_path: Path) -> None:
image_path = tmp_path / "rabbit.png"
Image.new("RGB", (4, 2), color=(10, 20, 30)).save(image_path)
reference = _build_online_image_reference(str(image_path))
assert reference.startswith("data:image/png;base64,")
encoded = reference.split(",", 1)[1]
assert len(b64decode(encoded)) > 0
def test_send_video_request_with_timeout_uses_requested_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
class DummyResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, str]:
return {"id": "video_123"}
class DummyClient:
def __init__(self) -> None:
self.run_level = "core_model"
self.wait_args: tuple[str, int, int] | None = None
def _build_url(self, path: str) -> str:
return f"http://localhost:8000{path}"
def _wait_until_video_completed(
self,
video_id: str,
poll_interval_seconds: int = 2,
timeout_seconds: int = 300,
) -> None:
self.wait_args = (video_id, poll_interval_seconds, timeout_seconds)
def _download_video_content(self, video_id: str) -> bytes:
return b"video-bytes"
monkeypatch.setattr(requests, "post", lambda *args, **kwargs: DummyResponse())
client = DummyClient()
result = _send_video_request_with_timeout(
client,
{
"form_data": {"prompt": "test"},
"image_reference": RABBIT_IMAGE_URL,
},
timeout_seconds=1200,
)
assert result == b"video-bytes"
assert client.wait_args == ("video_123", 2, 1200)
def test_online_timeout_defaults_to_1200() -> None:
assert _online_timeout_seconds(None) == 1200
def test_artifact_dir_is_under_repo_result_folder(tmp_path: Path) -> None:
artifact_dir = _artifact_dir(str(tmp_path / "rabbit.png"))
assert artifact_dir.parent == Path(__file__).parent / "result"
assert artifact_dir.name.startswith("rabbit-")
def test_offline_cuda_device_uses_indexed_cuda_device() -> None:
assert _offline_cuda_device() == torch.device("cuda:0")
def test_ensure_wan_ftfy_fallback_sets_identity(monkeypatch: pytest.MonkeyPatch) -> None:
from diffusers.pipelines.wan import pipeline_wan_i2v as wan_i2v_module
monkeypatch.delattr(wan_i2v_module, "ftfy", raising=False)
_ensure_wan_ftfy_fallback()
assert hasattr(wan_i2v_module, "ftfy")
assert isinstance(wan_i2v_module.ftfy, _IdentityFtfy)
assert wan_i2v_module.ftfy.fix_text("abc") == "abc"
def test_resize_to_target_matches_requested_dimensions() -> None:
image = Image.new("RGB", (100, 50), color=(1, 2, 3))
resized = _resize_to_target(image, width=832, height=480)
assert resized.size == (832, 480)
def test_configure_scheduler_switches_to_unipc() -> None:
class DummyPipe:
def __init__(self) -> None:
self.scheduler = UniPCMultistepScheduler(num_train_timesteps=1000, flow_shift=1.0)
pipe = DummyPipe()
_configure_scheduler(pipe, flow_shift=5.0)
assert isinstance(pipe.scheduler, UniPCMultistepScheduler)
assert pipe.scheduler.config.flow_shift == 5.0
REPO_ROOT = Path(__file__).resolve().parents[4]
WORKSPACE_ROOT = REPO_ROOT.parent
RUNNER_PATH = Path(__file__).with_name("run_wan22_i2v_diffusers_cp.py")
RESULT_ROOT = Path(__file__).parent / "result"
VIDEO_TIMEOUT_SECONDS = 60 * 60
_SSIM_RE = re.compile(r"All:(?P<score>[0-9.]+)")
_PSNR_RE = re.compile(r"average:(?P<score>[0-9.]+)")
SERVER_CASES = [
pytest.param(
OmniServerParams(
model=MODEL_NAME,
server_args=[
"--usp",
"2",
"--use-hsdp",
"--hsdp-shard-size",
"2",
],
use_omni=True,
),
id="wan22_i2v_usp2_hsdp2",
)
]
def _parse_video_metadata(payload: dict[str, Any]) -> dict[str, int | float]:
streams = payload.get("streams")
if not isinstance(streams, list) or not streams:
raise ValueError(f"ffprobe payload did not include video streams: {payload}")
stream = streams[0]
width = int(stream["width"])
height = int(stream["height"])
fps = float(Fraction(stream["avg_frame_rate"]))
frame_count_value = stream.get("nb_read_frames") or stream.get("nb_frames")
if frame_count_value is None:
raise ValueError(f"ffprobe payload did not include frame count: {payload}")
frame_count = int(frame_count_value)
return {
"width": width,
"height": height,
"fps": fps,
"frame_count": frame_count,
}
def _parse_ssim_score(output: str) -> float:
match = _SSIM_RE.search(output)
if match is None:
raise ValueError(f"Could not parse SSIM score from ffmpeg output:\n{output}")
return float(match.group("score"))
def _parse_psnr_score(output: str) -> float:
match = _PSNR_RE.search(output)
if match is None:
raise ValueError(f"Could not parse PSNR score from ffmpeg output:\n{output}")
return float(match.group("score"))
def _probe_binary(binary: str) -> str:
resolved = shutil.which(binary)
if resolved is None:
pytest.skip(f"{binary} is required for Wan2.2 video similarity e2e test.")
return resolved
def _build_diffusers_command(
*,
runner_path: Path,
image_source: str,
output_path: Path,
metadata_path: Path,
) -> list[str]:
return [
sys.executable,
str(runner_path),
"--model",
MODEL_NAME,
"--image-source",
image_source,
"--prompt",
PROMPT,
"--negative-prompt",
NEGATIVE_PROMPT,
"--size",
SIZE,
"--fps",
str(FPS),
"--num-frames",
str(NUM_FRAMES),
"--guidance-scale",
str(GUIDANCE_SCALE),
"--guidance-scale-2",
str(GUIDANCE_SCALE_2),
"--flow-shift",
str(FLOW_SHIFT),
"--num-inference-steps",
str(NUM_INFERENCE_STEPS),
"--seed",
str(SEED),
"--output",
str(output_path),
"--metadata-output",
str(metadata_path),
]
def _runner_env() -> dict[str, str]:
env = os.environ.copy()
pythonpath_parts = [
str(REPO_ROOT),
str(WORKSPACE_ROOT / "diffusers" / "src"),
]
existing_pythonpath = env.get("PYTHONPATH")
if existing_pythonpath:
pythonpath_parts.append(existing_pythonpath)
env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
env.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
return env
def _resolve_image_source(configured: str | None) -> str:
configured = configured or RABBIT_IMAGE_URL
candidate = Path(configured)
if candidate.exists():
return str(candidate.resolve())
return configured
def _is_remote_image_source(source: str) -> bool:
return source.startswith("http://") or source.startswith("https://")
def _validate_image_source(source: str) -> None:
if _is_remote_image_source(source):
response = requests.get(source, timeout=60)
response.raise_for_status()
return
image_path = Path(source)
if not image_path.exists():
raise FileNotFoundError(f"Local image source does not exist: {image_path}")
def _build_online_image_reference(source: str) -> str:
if _is_remote_image_source(source) or source.startswith("data:image"):
return source
image_path = Path(source)
mime_type = guess_type(image_path.name)[0] or "image/png"
encoded = b64encode(image_path.read_bytes()).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
def _probe_video(path: Path) -> dict[str, int | float]:
_probe_binary("ffprobe")
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-count_frames",
"-show_entries",
"stream=width,height,avg_frame_rate,nb_read_frames",
"-of",
"json",
str(path),
],
capture_output=True,
text=True,
check=True,
)
return _parse_video_metadata(json.loads(result.stdout))
def _run_ffmpeg_similarity(filter_name: str, first: Path, second: Path) -> str:
_probe_binary("ffmpeg")
result = subprocess.run(
[
"ffmpeg",
"-hide_banner",
"-i",
str(first),
"-i",
str(second),
"-lavfi",
f"[0:v][1:v]{filter_name}",
"-f",
"null",
"-",
],
capture_output=True,
text=True,
check=True,
)
return result.stderr
def _online_timeout_seconds(configured: int | None) -> int:
return int(configured) if configured is not None else 1200
def _artifact_dir(image_source: str) -> Path:
if _is_remote_image_source(image_source):
source_name = Path(urlparse(image_source).path).stem or "remote"
else:
source_name = Path(image_source).stem or "local"
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", source_name)
digest = sha1(image_source.encode("utf-8")).hexdigest()[:8]
return RESULT_ROOT / f"{safe_name}-{digest}"
def _artifact_paths(image_source: str) -> tuple[Path, Path, Path]:
artifact_dir = _artifact_dir(image_source)
artifact_dir.mkdir(parents=True, exist_ok=True)
return (
artifact_dir / "online.mp4",
artifact_dir / "offline.mp4",
artifact_dir / "offline_metadata.json",
)
def _send_video_request_with_timeout(
openai_client,
request_config: dict[str, Any],
*,
timeout_seconds: int,
) -> bytes:
form_data = request_config.get("form_data")
if not isinstance(form_data, dict):
raise ValueError("Video request_config must contain 'form_data'")
if not form_data.get("prompt"):
raise ValueError("Video request_config['form_data'] must contain 'prompt'")
normalized_form_data = {key: str(value) for key, value in form_data.items() if value is not None}
files: dict[str, tuple[str, BytesIO, str]] = {}
image_reference = request_config.get("image_reference")
if image_reference:
if image_reference.startswith("data:image"):
header, encoded = image_reference.split(",", 1)
content_type = header.split(";")[0].removeprefix("data:")
extension = content_type.split("/")[-1]
file_data = b64decode(encoded)
files["input_reference"] = (
f"reference.{extension}",
BytesIO(file_data),
content_type,
)
else:
normalized_form_data["image_reference"] = json.dumps({"image_url": image_reference})
start_time = time.perf_counter()
create_url = openai_client._build_url("/v1/videos")
response = requests.post(
create_url,
data=normalized_form_data,
files=files,
headers={"Accept": "application/json"},
timeout=60,
)
response.raise_for_status()
job_data = response.json()
video_id = job_data["id"]
openai_client._wait_until_video_completed(video_id, timeout_seconds=timeout_seconds)
video_content = openai_client._download_video_content(video_id)
print(f"online_video_e2e_latency_s={time.perf_counter() - start_time:.3f}")
return video_content
def _generate_online_video(
*,
omni_server,
openai_client,
image_source: str,
online_timeout_seconds: int,
) -> Path:
online_path, _, _ = _artifact_paths(image_source)
request_config = {
"model": omni_server.model,
"form_data": {
"prompt": PROMPT,
"negative_prompt": NEGATIVE_PROMPT,
"size": SIZE,
"fps": FPS,
"num_frames": NUM_FRAMES,
"guidance_scale": GUIDANCE_SCALE,
"guidance_scale_2": GUIDANCE_SCALE_2,
"flow_shift": FLOW_SHIFT,
"num_inference_steps": NUM_INFERENCE_STEPS,
"seed": SEED,
},
"image_reference": _build_online_image_reference(image_source),
}
online_video_bytes = _send_video_request_with_timeout(
openai_client,
request_config,
timeout_seconds=_online_timeout_seconds(online_timeout_seconds),
)
online_path.write_bytes(online_video_bytes)
return online_path
def _generate_offline_video(*, image_source: str) -> tuple[Path, Path]:
_, offline_path, offline_metadata_path = _artifact_paths(image_source)
command = _build_diffusers_command(
runner_path=RUNNER_PATH,
image_source=image_source,
output_path=offline_path,
metadata_path=offline_metadata_path,
)
subprocess.run(
command,
cwd=REPO_ROOT,
env=_runner_env(),
check=True,
timeout=VIDEO_TIMEOUT_SECONDS,
)
return offline_path, offline_metadata_path
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_wan22_i2v_diffusers_offline_generates_video(
wan22_i2v_image_source: str | None,
) -> None:
if not torch.cuda.is_available():
pytest.skip("Wan2.2 I2V diffusers offline test requires CUDA.")
_probe_binary("ffprobe")
if not RUNNER_PATH.exists():
raise AssertionError(f"Offline diffusers runner does not exist: {RUNNER_PATH}")
image_source = _resolve_image_source(wan22_i2v_image_source)
_validate_image_source(image_source)
offline_path, offline_metadata_path = _generate_offline_video(image_source=image_source)
assert offline_path.exists(), f"Expected offline video artifact at {offline_path}"
assert offline_metadata_path.exists(), f"Expected offline metadata artifact at {offline_metadata_path}"
offline_metadata = _probe_video(offline_path)
assert offline_metadata["width"] == WIDTH
assert offline_metadata["height"] == HEIGHT
assert offline_metadata["fps"] == float(FPS)
assert offline_metadata["frame_count"] == NUM_FRAMES
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
def test_wan22_i2v_online_serving_generates_video(
omni_server,
openai_client,
wan22_i2v_image_source: str | None,
wan22_i2v_online_timeout_seconds: int,
) -> None:
if not torch.cuda.is_available() or torch.cuda.device_count() < 2:
pytest.skip("Wan2.2 I2V similarity e2e test requires >= 2 CUDA GPUs.")
_probe_binary("ffprobe")
image_source = _resolve_image_source(wan22_i2v_image_source)
_validate_image_source(image_source)
online_path = _generate_online_video(
omni_server=omni_server,
openai_client=openai_client,
image_source=image_source,
online_timeout_seconds=wan22_i2v_online_timeout_seconds,
)
assert online_path.exists(), f"Expected online video artifact at {online_path}"
online_metadata = _probe_video(online_path)
assert online_metadata["width"] == WIDTH
assert online_metadata["height"] == HEIGHT
assert online_metadata["fps"] == float(FPS)
assert online_metadata["frame_count"] == NUM_FRAMES
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_wan22_i2v_serving_matches_diffusers_video_similarity(
wan22_i2v_image_source: str | None,
) -> None:
if not torch.cuda.is_available() or torch.cuda.device_count() < 2:
pytest.skip("Wan2.2 I2V similarity e2e test requires >= 2 CUDA GPUs.")
_probe_binary("ffmpeg")
_probe_binary("ffprobe")
if not RUNNER_PATH.exists():
raise AssertionError(f"Offline diffusers runner does not exist: {RUNNER_PATH}")
image_source = _resolve_image_source(wan22_i2v_image_source)
_validate_image_source(image_source)
online_path, offline_path, offline_metadata_path = _artifact_paths(image_source)
if not online_path.exists():
pytest.skip(f"Missing online artifact from prerequisite test: {online_path}")
if not offline_path.exists() or not offline_metadata_path.exists():
pytest.skip(f"Missing offline artifacts from prerequisite test: {offline_path}, {offline_metadata_path}")
assert online_path.exists(), f"Expected online video artifact at {online_path}"
assert offline_path.exists(), f"Expected offline video artifact at {offline_path}"
assert offline_metadata_path.exists(), f"Expected offline metadata artifact at {offline_metadata_path}"
online_metadata = _probe_video(online_path)
offline_metadata = _probe_video(offline_path)
assert online_metadata == offline_metadata, (
f"Video metadata mismatch:\n"
f"online={online_metadata}\n"
f"offline={offline_metadata}\n"
f"online_path={online_path}\n"
f"offline_path={offline_path}"
)
assert online_metadata["width"] == WIDTH
assert online_metadata["height"] == HEIGHT
assert online_metadata["fps"] == float(FPS)
assert online_metadata["frame_count"] == NUM_FRAMES
ssim_output = _run_ffmpeg_similarity("ssim", online_path, offline_path)
psnr_output = _run_ffmpeg_similarity("psnr", online_path, offline_path)
ssim_score = _parse_ssim_score(ssim_output)
psnr_score = _parse_psnr_score(psnr_output)
print("wan22_i2v_similarity metrics:")
print(
" SSIM:"
f" value={ssim_score:.6f},"
f" threshold>={SSIM_THRESHOLD:.6f},"
" range=[0, 1],"
" higher_is_better=True,"
" interpretation=structural_similarity"
)
print(
" PSNR:"
f" value={psnr_score:.6f} dB,"
f" threshold>={PSNR_THRESHOLD:.6f} dB,"
" range=[0, +inf),"
" higher_is_better=True,"
" interpretation=pixel_error_in_decibels"
)
print(f"online_video={online_path}")
print(f"offline_video={offline_path}")
print(f"offline_metadata={offline_metadata_path}")
assert ssim_score >= SSIM_THRESHOLD, (
f"SSIM below threshold: got {ssim_score:.6f}, expected >= {SSIM_THRESHOLD:.6f}. "
f"online={online_path} offline={offline_path}"
)
assert psnr_score >= PSNR_THRESHOLD, (
f"PSNR below threshold: got {psnr_score:.6f}, expected >= {PSNR_THRESHOLD:.6f}. "
f"online={online_path} offline={offline_path}"
)
@@ -0,0 +1,24 @@
from __future__ import annotations
RABBIT_IMAGE_URL = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/rabbit.png"
MODEL_NAME = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
SIZE = "832x480"
WIDTH = 832
HEIGHT = 480
FPS = 16
NUM_FRAMES = 93
GUIDANCE_SCALE = 3.5
GUIDANCE_SCALE_2 = 3.5
FLOW_SHIFT = 5.0
NUM_INFERENCE_STEPS = 50
SEED = 42
BOUNDARY_RATIO = 0.875
SSIM_THRESHOLD = 0.94
PSNR_THRESHOLD = 28.0
PROMPT = """一只棕色野兔的正面特写镜头,采用低角度仰拍视角,营造亲密而庄严的视觉冲击。兔子一双圆润漆黑的大眼睛直视镜头深处,眼神中交织着野生动物的警觉与一丝难以言喻的温柔好奇,仿佛在与观者建立跨越物种的静默对话。它毛色呈现层次丰富的棕褐渐变,从浅奶油色腹部过渡到深棕背部,每根毛发纹理清晰可辨,在侧光下泛着丝绸般的光泽。细长洁白的胡须共有三对,随呼吸节奏微微颤动,偶尔因捕捉气流信息而轻轻摇摆。
两只标志性的长耳完全竖立,耳廓外侧覆盖短密棕毛,内侧则露出粉嫩的血管网络,薄如蝉翼的皮肤下血液流动隐约可见,耳朵以细微幅度不时转动,精准定位声源方向。背景是一片澄澈的蔚蓝天空,形态蓬松的白色积云以缓慢速度横向漂移,云影在兔子头顶交替变化,光线随之明暗流转。晴朗天气的明媚阳光从画面左上方45度角倾泻而下,在兔脸右侧形成柔和的伦勃朗式阴影,强化了面部立体感和皮毛质感。
兔子湿润的黑鼻子持续进行每秒三至四次的快速抽动,这是它们感知化学信号的本能动作,粉色三瓣嘴随之轻启,露出正在反刍的洁白门齿,下颌以稳定节奏左右研磨。摄影采用大光圈浅景深,焦点牢牢锁定在兔子双眼连线所在的焦平面,背景天空和远景绿色植被虚化成圆润的彩色光斑,前景几根嫩绿草叶闯入画面边缘,以缓慢弧线随风摇曳,暗示着和煦的春日微风。
整个场景弥漫着宁静致远的田园诗意,色彩温暖饱和,充满生命力。兔子在持续五秒的对视后,以典型 lagomorph 特征完成一次完整的瞬膜眨眼——第三眼睑从内侧横向滑过眼球,继而缓缓歪头向右十五度,这个行为在动物行为学中代表认知加工和好奇心表达,耳朵随之向同一方向倾斜,最终恢复正视姿态,胡须舒展,完成这段短暂而珍贵的自然纪录。"""
NEGATIVE_PROMPT = """色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"""
@@ -79,19 +79,17 @@ def _find_free_port() -> int:
return port
def _configure_sampling_params(omni: Omni, max_tokens: int = 1, num_inference_steps: int = 15) -> list:
def _configure_sampling_params(omni: Omni, num_inference_steps: int = 15) -> list:
"""Configure sampling parameters for Bagel img2img generation.
Args:
omni: The Omni instance to get default params from.
max_tokens: Maximum tokens for the first stage.
num_inference_steps: Number of inference steps for the diffusion stage.
Returns:
Configured sampling params list.
"""
params_list = omni.default_sampling_params_list
params_list[0].max_tokens = max_tokens # type: ignore
if len(params_list) > 1:
params_list[1].num_inference_steps = num_inference_steps # type: ignore
params_list[1].extra_args = { # type: ignore
@@ -80,19 +80,17 @@ def _find_free_port() -> int:
return port
def _configure_sampling_params(omni: Omni, max_tokens: int = 1, num_inference_steps: int = 15) -> list:
def _configure_sampling_params(omni: Omni, num_inference_steps: int = 15) -> list:
"""Configure sampling parameters for Bagel text2img generation.
Args:
omni: The Omni instance to get default params from.
max_tokens: Maximum tokens for the first stage.
num_inference_steps: Number of inference steps for the diffusion stage.
Returns:
Configured sampling params list.
"""
params_list = omni.default_sampling_params_list
params_list[0].max_tokens = max_tokens # type: ignore
if len(params_list) > 1:
params_list[1].num_inference_steps = num_inference_steps # type: ignore
params_list[1].extra_args = { # type: ignore
@@ -120,7 +120,6 @@ def _generate_bagel_image(
torch.cuda.reset_peak_memory_stats()
params_list = omni.default_sampling_params_list
params_list[0].max_tokens = 1 # type: ignore
if len(params_list) > 1:
params_list[1].num_inference_steps = num_inference_steps # type: ignore
params_list[1].extra_args = { # type: ignore
@@ -0,0 +1,83 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E offline tests for Qwen3-TTS Base model with text input and audio output.
Async_chunk disable, cuda_graph disabled (no_async_chunk stage config).
CUDA graph is disabled by setting engine_args.enforce_eager=true via modify_stage_config().
Same structure as test_qwen3_omni (models, stage_configs, test_params, parametrize omni_runner).
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.conftest import modify_stage_config
from tests.utils import hardware_test
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
REF_AUDIO_URL = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/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."
def get_cuda_graph_config():
path = modify_stage_config(
get_stage_config(),
updates={
"stage_args": {
0: {
"engine_args.enforce_eager": "true",
},
1: {"engine_args.enforce_eager": "true"},
},
},
)
return path
def get_stage_config(name: str = "qwen3_tts_no_async_chunk.yaml"):
"""Get the no_async_chunk stage config path (async_chunk disable, cuda_graph disabled)."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
# Same structure as test_qwen3_omni: models, stage_configs, test_params
tts_server_params = [
pytest.param(
(MODEL, get_cuda_graph_config()),
id="no_cuda_graph",
)
]
def get_prompt():
"""Text prompt for text-to-audio (same role as get_question in test_qwen3_omni)."""
return "Hello, this is a test for text to audio."
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
"""
Test text input processing and audio output via offline Omni runner.
Deploy Setting: qwen3_tts_no_async_chunk.yaml + enforce_eager=true
Input Modal: text
Output Modal: audio
Input Setting: stream=False
Extra Setting: task_type=Base, voice=clone, ref_audio/ref_text provided
Datasets: few requests
"""
request_config = {
"input": get_prompt(),
"task_type": "Base",
"voice": "clone",
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
omni_runner_handler.send_audio_speech_request(request_config)
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E offline tests for Qwen3-TTS CustomVoice model with text input and audio output.
Async_chunk disable, cuda_graph disabled (no_async_chunk stage config).
CUDA graph is disabled by setting engine_args.enforce_eager=true via modify_stage_config().
Same structure as test_qwen3_omni (models, stage_configs, test_params, parametrize omni_runner).
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.conftest import modify_stage_config
from tests.utils import hardware_test
MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
def get_cuda_graph_config():
path = modify_stage_config(
get_stage_config(),
updates={
"stage_args": {
0: {
"engine_args.enforce_eager": "true",
},
1: {"engine_args.enforce_eager": "true"},
},
},
)
return path
def get_stage_config(name: str = "qwen3_tts_no_async_chunk.yaml"):
"""Get the no_async_chunk stage config path (async_chunk disable, cuda_graph disabled)."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
# Same structure as test_qwen3_omni: models, stage_configs, test_params
tts_server_params = [
pytest.param(
(MODEL, get_cuda_graph_config()),
id="no_cuda_graph",
)
]
def get_prompt():
"""Text prompt for text-to-audio (same role as get_question in test_qwen3_omni)."""
return "Hello, this is a test for text to audio."
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
"""
Test text input processing and audio output via offline Omni runner.
Deploy Setting: qwen3_tts_no_async_chunk.yaml + enforce_eager=true
Input Modal: text
Output Modal: audio
Input Setting: stream=False
Datasets: few requests
"""
request_config = {"input": get_prompt(), "voice": "vivian"}
omni_runner_handler.send_audio_speech_request(request_config)
@@ -23,7 +23,7 @@ from tests.conftest import (
)
from tests.utils import hardware_test
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
model = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
AUDIO_KEY = ["test"]
IMAGE_KEY = ["square", "quadrate"]
@@ -49,16 +49,32 @@ def get_chunk_config(default_path):
return path
def get_batch_token_config(default_path):
path = modify_stage_config(
default_path,
updates={
"stage_args": {1: {"engine_args.max_num_batched_tokens": 64}},
},
)
return path
# CI stage config for 2*H100-80G GPUs
default_path = str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml")
stage_configs = [default_path, get_chunk_config(default_path)]
if current_omni_platform.is_xpu():
stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")]
default_path = str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")
# Create parameter combinations for model and stage config
test_params = [
OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs
pytest.param(OmniServerParams(model=model, stage_config_path=default_path), id="default"),
pytest.param(OmniServerParams(model=model, stage_config_path=get_chunk_config(default_path)), id="async_chunk"),
]
test_token_params = [
pytest.param(
OmniServerParams(model=model, stage_config_path=get_batch_token_config(default_path)), id="batch_token_64"
)
]
@@ -85,6 +101,8 @@ def get_prompt(prompt_type="text_only"):
"text_video": "What is in this video? ",
"text_image": "What is in this image? ",
"text_audio": "What is in this audio? ",
"text_audio_video": "First, what is in this audio? Then, what is in this video? ",
"one_word": "What is the capital of UK? Answer in one word",
}
return prompts.get(prompt_type, prompts["text_only"])
@@ -121,7 +139,7 @@ def test_text_to_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_text_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text
@@ -288,7 +306,7 @@ def test_video_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, audio
@@ -313,7 +331,7 @@ def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_text_image_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, image
@@ -339,7 +357,7 @@ def test_text_image_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, video
@@ -367,7 +385,7 @@ def test_text_video_to_text_audio_001(omni_server, openai_client) -> None:
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
@pytest.mark.parametrize("omni_server", test_params + test_token_params, indirect=True)
def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"""
Input Modal: text, audio, image, video
@@ -393,3 +411,106 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None:
"key_words": {"audio": AUDIO_KEY, "image": IMAGE_KEY, "video": VIDEO_KEY},
}
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_audio_in_video_001(omni_server, openai_client) -> None:
"""
Input Modal: text + video (synthetic MP4 with embedded audio; ``use_audio_in_video`` uses audio from the video).
Output Modal: text, audio
Input Setting: stream=False
Datasets: single request
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300, embed_audio=True)['base64']}"
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
video_data_url=video_data_url,
content_text=get_prompt("text_audio_video"),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": False,
"use_audio_in_video": True,
"key_words": {"video": VIDEO_KEY, "audio": AUDIO_KEY + ["beep", "electronic"]},
}
openai_client.send_omni_request(request_config)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_audio_in_video_002(omni_server, openai_client) -> None:
"""
Input Modal: text + video (synthetic MP4 with embedded audio; ``use_audio_in_video`` uses audio from the video).
Output Modal: text, audio
Input Setting: stream=True
Datasets: few requests
"""
video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 300, embed_audio=True)['base64']}"
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
video_data_url=video_data_url,
content_text=get_prompt("text_audio_video"),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"use_audio_in_video": True,
"key_words": {"video": VIDEO_KEY, "audio": AUDIO_KEY + ["beep", "electronic"]},
}
# Retry when assert_omni_response fails on key_words (see tests/conftest.py).
_keyword_assert_msg = "The output does not contain any of the keywords."
_max_retries = 3
for attempt in range(_max_retries):
try:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
break
except AssertionError as e:
if _keyword_assert_msg not in str(e) or attempt == _max_retries - 1:
raise
print(f"Keyword assertion failed, retrying {attempt + 2}/{_max_retries}: {e!r}")
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_one_word_prompt_001(omni_server, openai_client) -> None:
"""
Input Modal: text only (one-word answer constraint).
Output Modal: text, audio (default ``modalities``); ``key_words`` only assert on text.
Input Setting: stream=True
Datasets: single request
"""
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
content_text=get_prompt("one_word"),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"key_words": {"text": ["london"]},
}
# Retry only when assert_omni_response fails on text/audio cosine similarity (see tests/conftest.py).
_similarity_assert_msg = "The audio content is not same as the text"
_max_retries = 3
for attempt in range(_max_retries):
try:
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
break
except AssertionError as e:
if _similarity_assert_msg not in str(e) or attempt == _max_retries - 1:
raise
print(f"Similarity assertion failed, retrying {attempt + 2}/{_max_retries}: {e!r}")
-285
View File
@@ -1,285 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E Online tests for Qwen3-TTS model with text input and audio output.
These tests verify the /v1/audio/speech endpoint works correctly with
actual model inference, not mocks.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import httpx
import pytest
from tests.conftest import OmniServer
from tests.utils import hardware_test
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
def get_stage_config(name: str = "qwen3_tts.yaml"):
"""Get the stage config path for Qwen3-TTS."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
@pytest.fixture(scope="class")
def omni_server():
"""Start vLLM-Omni server with CustomVoice model."""
stage_config_path = get_stage_config()
print(f"Starting OmniServer with model: {MODEL}")
with OmniServer(
MODEL,
[
"--stage-configs-path",
stage_config_path,
"--stage-init-timeout",
"120",
"--trust-remote-code",
"--enforce-eager",
"--disable-log-stats",
],
) as server:
print("OmniServer started successfully")
yield server
print("OmniServer stopping...")
print("OmniServer stopped")
def make_speech_request(
host: str,
port: int,
text: str,
voice: str = "vivian",
language: str = "English",
task_type: str | None = None,
instructions: str | None = None,
timeout: float = 120.0,
) -> httpx.Response:
"""Make a request to the /v1/audio/speech endpoint."""
url = f"http://{host}:{port}/v1/audio/speech"
payload = {
"input": text,
"voice": voice,
"language": language,
}
if task_type:
payload["task_type"] = task_type
if instructions:
payload["instructions"] = instructions
with httpx.Client(timeout=timeout) as client:
return client.post(url, json=payload)
def verify_wav_audio(content: bytes) -> bool:
"""Verify that content is valid WAV audio data."""
# WAV files start with "RIFF" header
if len(content) < 44: # Minimum WAV header size
return False
return content[:4] == b"RIFF" and content[8:12] == b"WAVE"
# Minimum expected audio size for a short sentence (~1 second of 24kHz 16-bit mono WAV)
MIN_AUDIO_BYTES = 10000
class TestQwen3TTSCustomVoice:
"""E2E tests for Qwen3-TTS CustomVoice model."""
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_speech_english_basic(self, omni_server) -> None:
"""Test basic English TTS generation."""
response = make_speech_request(
host=omni_server.host,
port=omni_server.port,
text="Hello, how are you?",
voice="vivian",
language="English",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert response.headers.get("content-type") == "audio/wav"
assert verify_wav_audio(response.content), "Response is not valid WAV audio"
assert len(response.content) > MIN_AUDIO_BYTES, (
f"Audio content too small ({len(response.content)} bytes), expected at least {MIN_AUDIO_BYTES} bytes"
)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_speech_chinese_basic(self, omni_server) -> None:
"""Test basic Chinese TTS generation."""
response = make_speech_request(
host=omni_server.host,
port=omni_server.port,
text="你好,我是通义千问",
voice="vivian",
language="Chinese",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert response.headers.get("content-type") == "audio/wav"
assert verify_wav_audio(response.content), "Response is not valid WAV audio"
assert len(response.content) > MIN_AUDIO_BYTES, (
f"Audio content too small ({len(response.content)} bytes), expected at least {MIN_AUDIO_BYTES} bytes"
)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_speech_different_voices(self, omni_server) -> None:
"""Test TTS with different voice options."""
voices = ["vivian", "ryan"]
for voice in voices:
response = make_speech_request(
host=omni_server.host,
port=omni_server.port,
text="Testing voice selection.",
voice=voice,
language="English",
)
assert response.status_code == 200, f"Request failed for voice {voice}: {response.text}"
assert verify_wav_audio(response.content), f"Invalid WAV for voice {voice}"
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_speech_binary_response_not_utf8_error(self, omni_server) -> None:
"""
Regression test: Verify binary audio is returned, not UTF-8 error.
This test ensures the multimodal_output property correctly retrieves
audio from completion outputs, preventing the "TTS model did not
produce audio output" error.
"""
response = make_speech_request(
host=omni_server.host,
port=omni_server.port,
text="This should return binary audio, not a JSON error.",
voice="vivian",
language="English",
)
# Should NOT be a JSON error response
assert response.status_code == 200, f"Request failed: {response.text}"
# Verify it's binary audio, not JSON
try:
# If this succeeds and starts with {"error", it's a bug
text = response.content.decode("utf-8")
assert not text.startswith('{"error"'), f"Got error response instead of audio: {text}"
except UnicodeDecodeError:
# This is expected - binary audio can't be decoded as UTF-8
pass
assert verify_wav_audio(response.content), "Response is not valid WAV audio"
class TestQwen3TTSAPIEndpoints:
"""Test API endpoint functionality."""
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_list_voices_endpoint(self, omni_server) -> None:
"""Test the /v1/audio/voices endpoint returns available voices."""
url = f"http://{omni_server.host}:{omni_server.port}/v1/audio/voices"
with httpx.Client(timeout=30.0) as client:
response = client.get(url)
assert response.status_code == 200
data = response.json()
assert "voices" in data
assert isinstance(data["voices"], list)
assert len(data["voices"]) > 0
# Check some expected voices are present
voices_lower = [v.lower() for v in data["voices"]]
assert "vivian" in voices_lower or "ryan" in voices_lower
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_models_endpoint(self, omni_server) -> None:
"""Test the /v1/models endpoint returns loaded model."""
url = f"http://{omni_server.host}:{omni_server.port}/v1/models"
with httpx.Client(timeout=30.0) as client:
response = client.get(url)
assert response.status_code == 200
data = response.json()
assert "data" in data
assert len(data["data"]) > 0
@pytest.fixture(scope="class")
def omni_server_no_async_chunk():
"""Start vLLM-Omni server with non-async-chunk config."""
stage_config_path = get_stage_config("qwen3_tts_no_async_chunk.yaml")
with OmniServer(
MODEL,
[
"--stage-configs-path",
stage_config_path,
"--stage-init-timeout",
"120",
"--trust-remote-code",
"--enforce-eager",
"--disable-log-stats",
],
) as server:
yield server
class TestQwen3TTSNoAsyncChunk:
"""E2E tests for Qwen3-TTS in non-async-chunk (full decode) mode."""
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_speech_english(self, omni_server_no_async_chunk) -> None:
"""Test English TTS with non-async-chunk pipeline."""
response = make_speech_request(
host=omni_server_no_async_chunk.host,
port=omni_server_no_async_chunk.port,
text="Hello, how are you?",
voice="vivian",
language="English",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert response.headers.get("content-type") == "audio/wav"
assert verify_wav_audio(response.content), "Response is not valid WAV audio"
assert len(response.content) > MIN_AUDIO_BYTES
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_speech_chinese(self, omni_server_no_async_chunk) -> None:
"""Test Chinese TTS with non-async-chunk pipeline."""
response = make_speech_request(
host=omni_server_no_async_chunk.host,
port=omni_server_no_async_chunk.port,
text="你好,我是通义千问",
voice="vivian",
language="Chinese",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert response.headers.get("content-type") == "audio/wav"
assert verify_wav_audio(response.content), "Response is not valid WAV audio"
assert len(response.content) > MIN_AUDIO_BYTES
+74 -187
View File
@@ -1,10 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E tests for Qwen3-TTS Base voice-clone model.
E2E Online tests for Qwen3-TTS model with text input and audio output.
Regression test for #1663: speech tokenizer loaded in bfloat16 produced
all-silence PCM due to NaN overflow in SnakeBeta activation.
These tests verify the /v1/audio/speech endpoint works correctly with
actual model inference, not mocks.
"""
import os
@@ -12,212 +12,99 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
import struct
import tempfile
from pathlib import Path
import httpx
import numpy as np
import pytest
from tests.conftest import (
OmniServer,
convert_audio_file_to_text,
cosine_similarity_text,
)
from tests.conftest import OmniServerParams
from tests.utils import hardware_test
MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
# Official Qwen3-TTS reference audio/text from examples/offline_inference/qwen3_tts/end2end.py
REF_AUDIO_URL = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/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."
SYN_TEXT = "Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye."
def get_stage_config():
return str(
Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "qwen3_tts.yaml"
)
def get_stage_config(name: str = "qwen3_tts.yaml"):
"""Get the stage config path from vllm_omni model_executor stage_configs."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
@pytest.fixture(scope="module")
def omni_server():
"""Start vLLM-Omni server with Base voice-clone model."""
stage_config_path = get_stage_config()
with OmniServer(
MODEL,
[
"--stage-configs-path",
stage_config_path,
"--stage-init-timeout",
"120",
"--trust-remote-code",
"--enforce-eager",
"--disable-log-stats",
],
) as server:
yield server
def make_base_speech_request(
host: str,
port: int,
text: str = SYN_TEXT,
ref_text: str = REF_TEXT,
ref_audio: str = REF_AUDIO_URL,
response_format: str = "wav",
timeout: float = 120.0,
) -> httpx.Response:
url = f"http://{host}:{port}/v1/audio/speech"
payload = {
"model": MODEL,
"input": text,
"task_type": "Base",
"ref_text": ref_text,
"ref_audio": ref_audio,
"response_format": response_format,
def get_prompt(prompt_type="text"):
"""Text prompt for text-to-audio tests (same as test_qwen3_omni - beijing test case)."""
prompts = {
"text": "The weather is nice today, perfect for a walk in the park.",
}
with httpx.Client(timeout=timeout) as client:
return client.post(url, json=payload)
return prompts.get(prompt_type, prompts["text"])
def verify_wav_audio(content: bytes) -> bool:
if len(content) < 44:
return False
return content[:4] == b"RIFF" and content[8:12] == b"WAVE"
def get_max_batch_size(size_type="few"):
"""Batch size for concurrent requests (same as test_qwen3_omni)."""
batch_sizes = {"few": 5, "medium": 100, "large": 256}
return batch_sizes.get(size_type, 5)
def assert_not_silence(pcm_bytes: bytes):
"""Assert PCM16 samples are not all identical (e.g. all-silence)."""
samples = struct.unpack(f"<{len(pcm_bytes) // 2}h", pcm_bytes)
unique = set(samples)
assert len(unique) > 1, (
f"All-silence detected: {len(samples)} samples, unique values: {unique}. "
"See https://github.com/vllm-project/vllm-omni/issues/1663"
tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=get_stage_config("qwen3_tts.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="async_chunk",
)
]
MIN_AUDIO_BYTES = 10000
MIN_HNR_DB = 1.2 # Clean voice clone > 1.2 dB; distorted < 1.0 dB
def compute_hnr_db(pcm_samples: np.ndarray, sr: int = 24000) -> float:
"""Compute mean Harmonic-to-Noise Ratio (dB) for speech quality.
Clean cloned speech has HNR > 1.2 dB; distorted speech (e.g. lost
ref_code decoder context) drops below 1.0 dB.
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: stream=False
Datasets: few requests
"""
frame_len = int(0.03 * sr) # 30ms frames
hop = frame_len // 2
hnr_values: list[float] = []
for start in range(0, len(pcm_samples) - frame_len, hop):
frame = pcm_samples[start : start + frame_len]
if np.max(np.abs(frame)) < 0.01:
continue
ac = np.correlate(frame, frame, mode="full")[len(frame) - 1 :]
ac = ac / (ac[0] + 1e-10)
min_lag = int(sr / 400)
max_lag = min(int(sr / 80), len(ac))
if min_lag >= max_lag:
continue
peak = float(np.max(ac[min_lag:max_lag]))
if 0 < peak < 1:
hnr_values.append(10 * np.log10(peak / (1 - peak + 1e-10)))
return float(np.mean(hnr_values)) if hnr_values else 0.0
request_config = {
"model": omni_server.model,
"input": get_prompt(),
"stream": False,
"response_format": "wav",
"task_type": "Base",
"voice": "clone",
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config, request_num=get_max_batch_size("few"))
class TestQwen3TTSBaseVoiceClone:
"""Regression tests for Base voice-clone (fix #1663)."""
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_base_voice_clone_not_silence(self, omni_server) -> None:
"""PCM output must contain real audio, not all-silence."""
response = make_base_speech_request(
host=omni_server.host,
port=omni_server.port,
response_format="pcm",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert len(response.content) > MIN_AUDIO_BYTES, f"Audio too small: {len(response.content)} bytes"
assert_not_silence(response.content)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_base_voice_clone_whisper_transcription(self, omni_server) -> None:
"""Whisper must transcribe the output as intelligible speech."""
response = make_base_speech_request(
host=omni_server.host,
port=omni_server.port,
response_format="wav",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert verify_wav_audio(response.content), "Response is not valid WAV"
assert len(response.content) > MIN_AUDIO_BYTES
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.write(response.content)
wav_path = f.name
try:
transcript = convert_audio_file_to_text(wav_path)
print(f"Whisper transcript: {transcript}")
assert len(transcript.strip()) > 0, "Whisper returned empty transcript — audio is likely silence"
similarity = cosine_similarity_text(transcript.lower(), SYN_TEXT.lower())
print(f"Cosine similarity: {similarity:.3f}")
assert similarity > 0.9, (
f"Transcript doesn't match input: similarity={similarity:.2f}, transcript='{transcript}'"
)
finally:
os.unlink(wav_path)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_base_voice_clone_wav_format(self, omni_server) -> None:
"""WAV response must have valid headers and sufficient size."""
response = make_base_speech_request(
host=omni_server.host,
port=omni_server.port,
response_format="wav",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert response.headers.get("content-type") == "audio/wav"
assert verify_wav_audio(response.content), "Response is not valid WAV"
assert len(response.content) > MIN_AUDIO_BYTES
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=4)
def test_base_voice_clone_no_distortion(self, omni_server) -> None:
"""Audio must not be distorted (regression for ref_code context loss).
When the decoder loses ref_code speaker context on later streaming
chunks, HNR drops below 1.0 dB. Clean voice clone should be > 1.2 dB.
See https://github.com/vllm-project/vllm-omni/issues/1944
"""
response = make_base_speech_request(
host=omni_server.host,
port=omni_server.port,
response_format="pcm",
)
assert response.status_code == 200, f"Request failed: {response.text}"
assert len(response.content) > MIN_AUDIO_BYTES
pcm_samples = np.frombuffer(response.content, dtype=np.int16).astype(np.float32) / 32768.0
hnr = compute_hnr_db(pcm_samples)
print(f"Voice clone HNR: {hnr:.2f} dB (threshold: {MIN_HNR_DB} dB)")
assert hnr >= MIN_HNR_DB, (
f"Audio distortion detected: HNR={hnr:.2f} dB < {MIN_HNR_DB} dB. "
"Voice clone decoder may be losing ref_code speaker context on later chunks."
)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_text_to_audio_002(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: stream=True
Datasets: single request
"""
request_config = {
"model": omni_server.model,
"input": get_prompt(),
"stream": True,
"response_format": "wav",
"task_type": "Base",
"voice": "clone",
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config)
@@ -0,0 +1,119 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E Online tests for Qwen3-TTS model with text input and audio output.
These tests verify the /v1/audio/speech endpoint works correctly with
actual model inference, not mocks.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.conftest import OmniServerParams
from tests.utils import hardware_test
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
REF_AUDIO_URL = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/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."
def get_stage_config(name: str = "qwen3_tts.yaml"):
"""Get the stage config path from vllm_omni model_executor stage_configs."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
def get_prompt(prompt_type="text"):
"""Text prompt for text-to-audio tests (same as test_qwen3_omni - beijing test case)."""
prompts = {
"text": "The weather is nice today, perfect for a walk in the park.",
}
return prompts.get(prompt_type, prompts["text"])
def get_max_batch_size(size_type="few"):
"""Batch size for concurrent requests (same as test_qwen3_omni)."""
batch_sizes = {"few": 5, "medium": 100, "large": 256}
return batch_sizes.get(size_type, 5)
tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=get_stage_config("qwen3_tts.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="async_chunk",
),
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=get_stage_config("qwen3_tts_no_async_chunk.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="no_async_chunk",
),
]
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_voice_clone_streaming_001(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: stream=True
Datasets: few requests
"""
request_config = {
"model": omni_server.model,
"input": get_prompt(),
"stream": True,
"response_format": "wav",
"task_type": "Base",
"voice": "clone",
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config, request_num=get_max_batch_size("few"))
@pytest.mark.advanced_model
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_response_format_001(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: non-stream PCM
Datasets: few requests
"""
request_config = {
"model": omni_server.model,
"input": get_prompt(),
"response_format": "pcm",
"task_type": "Base",
"voice": "clone",
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
}
openai_client.send_audio_speech_request(request_config)
@@ -0,0 +1,105 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E Online tests for Qwen3-TTS model with text input and audio output.
These tests verify the /v1/audio/speech endpoint works correctly with
actual model inference, not mocks.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.conftest import OmniServerParams
from tests.utils import hardware_test
MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
def get_stage_config(name: str = "qwen3_tts.yaml"):
"""Get the stage config path from vllm_omni model_executor stage_configs."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
def get_prompt(prompt_type="text"):
"""Text prompt for text-to-audio tests (same as test_qwen3_omni - beijing test case)."""
prompts = {
"text": "Beijing, China's capital, blends ancient wonders like the Great Wall with modern marvels. This vibrant metropolis offers rich culture, delicious Peking duck, and endless exploration opportunities.",
}
return prompts.get(prompt_type, prompts["text"])
def get_max_batch_size(size_type="few"):
"""Batch size for concurrent requests (same as test_qwen3_omni)."""
batch_sizes = {"few": 5, "medium": 100, "large": 256}
return batch_sizes.get(size_type, 5)
tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=get_stage_config("qwen3_tts.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="async_chunk",
)
]
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: stream=False
Datasets: few requests
"""
request_config = {
"model": omni_server.model,
"input": get_prompt(),
"stream": False,
"response_format": "wav",
"task_type": "CustomVoice",
"voice": "vivian",
}
openai_client.send_audio_speech_request(request_config, request_num=get_max_batch_size())
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_text_to_audio_002(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: stream=True
Datasets: single request
"""
request_config = {
"model": omni_server.model,
"input": get_prompt(),
"stream": True,
"response_format": "wav",
"task_type": "CustomVoice",
"voice": "vivian",
}
openai_client.send_audio_speech_request(request_config)
@@ -0,0 +1,145 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E Online tests for Qwen3-TTS model with text input and audio output.
These tests verify the /v1/audio/speech endpoint works correctly with
actual model inference, not mocks.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.conftest import OmniServerParams
from tests.utils import hardware_test
MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
def get_stage_config(name: str = "qwen3_tts.yaml"):
"""Get the stage config path from vllm_omni model_executor stage_configs."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
def get_prompt(prompt_type="english"):
"""Text prompt for text-to-audio tests (same as test_qwen3_omni - beijing test case)."""
prompts = {
"english": "Paris is a city in France. It has a tall tower by the river. The streets are old and small. You can walk and see art and food. The sky is light and soft. It is a calm place to rest.",
"chinese": "北京,中国的首都,是一座融合了长城等历史地点与现代建筑的国际化大都市,充满了独特的文化与活力",
}
return prompts.get(prompt_type, prompts["english"])
def get_max_batch_size(size_type="few"):
"""Batch size for concurrent requests (same as test_qwen3_omni)."""
batch_sizes = {"few": 3, "medium": 100, "large": 256}
return batch_sizes.get(size_type)
tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=get_stage_config("qwen3_tts.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="async_chunk",
),
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=get_stage_config("qwen3_tts_no_async_chunk.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="no_async_chunk",
),
]
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_voice_001(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: stream=False, voice=eric
Datasets: few requests
"""
request_config = {
"model": omni_server.model,
"input": get_prompt(),
"stream": True,
"response_format": "wav",
"task_type": "CustomVoice",
"voice": "uncle_fu",
}
# Retry once on assertion failures from transcript similarity / gender checks (flaky ASR or estimators).
for attempt in range(2):
try:
openai_client.send_audio_speech_request(request_config, request_num=get_max_batch_size())
break
except AssertionError:
if attempt == 0:
continue
raise
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_voice_002(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: stream=False, language=chinese
Datasets: few requests
"""
request_config = {
"model": omni_server.model,
"input": get_prompt(),
"stream": False,
"response_format": "wav",
"task_type": "CustomVoice",
"voice": "Serena",
}
openai_client.send_audio_speech_request(request_config)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_language_001(omni_server, openai_client) -> None:
"""
Test text input processing and audio output via OpenAI API.
Deploy Setting: default yaml
Input Modal: text
Output Modal: audio
Input Setting: stream=False, language=chinese
Datasets: few requests
"""
request_config = {
"model": omni_server.model,
"input": get_prompt("chinese"),
"stream": False,
"response_format": "wav",
"task_type": "CustomVoice",
"voice": "vivian",
}
openai_client.send_audio_speech_request(request_config)
@@ -28,6 +28,11 @@ PARALLEL_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
def _get_diffusion_feature_cases(model: str):
return [
pytest.param(
OmniServerParams(model=model, server_args=["--step-execution"]),
id="step_execution",
marks=SINGLE_CARD_FEATURE_MARKS,
),
pytest.param(
OmniServerParams(model=model, server_args=["--cache-backend", "tea_cache"]),
id="cache_tea_cache",
@@ -7,7 +7,7 @@ and are supported by Qwen-Image-Layered model.
Kept cases (maximum feature coverage, no cpu-offload):
sp_001 : cache_dit + Ulysses-SP 2 (2×H100)
cfg_parallel_002 : cache_dit + CFG-Parallel 2 (2×H100)
layers_guard_001 : layerwise offload + layers=2 (1×H100, issue #1969 guard)
layers_guard_001 : layerwise offload + layers=3 (1×H100, issue #1969 guard)
Total distinct features covered: cache_dit, Ulysses-SP, CFG-Parallel, layerwise-offload.
"""
@@ -102,8 +102,8 @@ LAYERS_GUARD_CASES = [
model=MODEL,
server_args=["--enable-layerwise-offload"],
),
2,
id="layers_guard_001_layers2",
3,
id="layers_guard_001_layers3",
marks=SINGLE_CARD_FEATURE_MARKS,
),
]
+2 -2
View File
@@ -33,7 +33,7 @@ stage_args:
temperature: 0.4
top_p: 0.9
top_k: 1
max_tokens: 100
max_tokens: 150
seed: 42
ignore_eos: False
detokenize: True
@@ -74,7 +74,7 @@ stage_args:
devices: "1"
engine_args:
model_stage: code2wav
max_num_seqs: 1
max_num_seqs: 5
model_arch: Qwen3OmniMoeForConditionalGeneration
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
@@ -621,6 +621,13 @@ def test_parameter_validation():
with pytest.raises(ValueError):
ImageGenerationRequest(prompt="test", guidance_scale=21.0)
# Invalid layers for layered models (must stay within the backend-supported range)
with pytest.raises(ValueError):
ImageGenerationRequest(prompt="test", layers=2)
with pytest.raises(ValueError):
ImageGenerationRequest(prompt="test", layers=11)
# Pass-Through Tests
@@ -740,6 +747,29 @@ def test_image_edit_images_processing(async_omni_test_client):
assert processed_images[1].size == (24, 24)
def test_image_edit_rejects_multiple_images_when_model_does_not_support_them(async_omni_test_client):
img_bytes_1 = make_test_image_bytes((16, 16))
img_bytes_2 = make_test_image_bytes((32, 32))
engine = async_omni_test_client.app.state.engine_client
engine.get_diffusion_od_config = lambda: SimpleNamespace(supports_multimodal_inputs=False)
response = async_omni_test_client.post(
"/v1/images/edits",
files=[
("image", img_bytes_1),
("image", img_bytes_2),
],
data={"prompt": "hello world."},
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Received multiple input images. Only a single image is supported by this model."
)
assert engine.captured_prompt is None
def test_image_edit_parameter_pass(async_omni_test_client):
img_bytes_1 = make_test_image_bytes((16, 16))
@@ -845,35 +875,36 @@ def test_image_edit_invalid_resolution(async_omni_test_client):
def test_image_edit_invalid_layers(async_omni_test_client):
"""Test that invalid layers values (< 1) are rejected with 400."""
"""Test that layered image edits reject out-of-range layers with 400."""
img_bytes = make_test_image_bytes((16, 16))
# Test layers = 0
# Test layers below the supported range
response = async_omni_test_client.post(
"/v1/images/edits",
files=[("image", img_bytes)],
data={
"prompt": "test",
"layers": 0,
"layers": 2,
},
)
assert response.status_code == 400
detail = response.json()["detail"]
assert "Invalid layers" in detail
assert "layers must be >= 1" in detail
assert "layers must be between 3 and 10 inclusive" in detail
# Test layers = -1
# Test layers above the supported range
response = async_omni_test_client.post(
"/v1/images/edits",
files=[("image", img_bytes)],
data={
"prompt": "test",
"layers": -1,
"layers": 11,
},
)
assert response.status_code == 400
detail = response.json()["detail"]
assert "Invalid layers" in detail
assert "layers must be between 3 and 10 inclusive" in detail
def test_image_edit_resolution_and_size_conflict(async_omni_test_client):
@@ -735,3 +735,156 @@ def test_extra_params_merged_with_existing_extra_args(test_client, mocker: Mocke
assert captured.extra_args["flow_shift"] == 0.5
assert captured.extra_args["use_zero_init"] is True
assert captured.extra_args["zero_steps"] == 2
# ---------------------------------------------------------------------------
# Sync endpoint tests (POST /v1/videos/sync)
# ---------------------------------------------------------------------------
def _mock_encode_video_bytes(mocker: MockerFixture, return_value: bytes = b"fake-video-bytes"):
"""Mock the raw-bytes encoder used by the sync video path."""
return mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=return_value,
)
def test_sync_t2v_returns_video_bytes(test_client, mocker: MockerFixture):
"""Sync endpoint should block until generation finishes and return raw
video bytes with metadata headers."""
_mock_encode_video_bytes(mocker, b"fake-video-bytes")
response = test_client.post(
"/v1/videos/sync",
data={
"prompt": "A cat running across the street.",
"size": "640x360",
"seconds": "2",
"fps": "12",
},
)
assert response.status_code == 200
assert response.headers["content-type"] == "video/mp4"
assert response.content == b"fake-video-bytes"
assert response.headers["x-request-id"].startswith("video_sync-")
assert response.headers["x-model"] == "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
assert float(response.headers["x-inference-time-s"]) >= 0
def test_sync_i2v_returns_video_bytes(test_client, mocker: MockerFixture):
"""Sync I2V endpoint should accept an uploaded reference image and return
raw video bytes."""
image_bytes = _make_test_image_bytes((48, 32))
_mock_encode_video_bytes(mocker, b"i2v-video-data")
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "A bear playing with yarn."},
files={"input_reference": ("input.png", image_bytes, "image/png")},
)
assert response.status_code == 200
assert response.content == b"i2v-video-data"
assert response.headers["content-type"] == "video/mp4"
def test_sync_i2v_with_image_reference(test_client, mocker: MockerFixture):
"""Sync I2V endpoint should accept a JSON image_reference field."""
_mock_encode_video_bytes(mocker, b"ref-video")
response = test_client.post(
"/v1/videos/sync",
data={
"prompt": "A fox running through snow.",
"image_reference": json.dumps({"image_url": _make_test_image_data_url((40, 24))}),
},
)
assert response.status_code == 200
assert response.content == b"ref-video"
def test_sync_missing_handler_returns_503():
app = FastAPI()
app.include_router(router)
app.state.openai_serving_video = None
client = TestClient(app)
response = client.post(
"/v1/videos/sync",
data={"prompt": "no handler"},
)
assert response.status_code == 503
assert "not initialized" in response.json()["detail"].lower()
def test_sync_missing_prompt_returns_422(test_client):
response = test_client.post(
"/v1/videos/sync",
data={"size": "320x240"},
)
assert response.status_code == 422
def test_sync_rejects_both_references(test_client):
response = test_client.post(
"/v1/videos/sync",
data={
"prompt": "bad refs",
"image_reference": '{"image_url": "https://example.com/cat.png"}',
},
files={"input_reference": ("input.png", _make_test_image_bytes(), "image/png")},
)
assert response.status_code == 400
assert "either input_reference or image_reference" in response.json()["detail"].lower()
def test_sync_generation_error_returns_500(test_client, mocker: MockerFixture):
"""If the underlying generation raises, the sync endpoint should return 500."""
mocker.patch.object(
OmniOpenAIServingVideo,
"generate_video_bytes",
side_effect=RuntimeError("GPU exploded"),
)
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "will fail"},
)
assert response.status_code == 500
assert "GPU exploded" in response.json()["detail"]
def test_sync_does_not_create_store_entry(test_client, mocker: MockerFixture):
"""The sync endpoint should NOT leave any record in VIDEO_STORE — it is
stateless by design."""
_mock_encode_video_bytes(mocker)
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "stateless test"},
)
assert response.status_code == 200
loop = asyncio.new_event_loop()
try:
stored = loop.run_until_complete(api_server.VIDEO_STORE.list_values())
finally:
loop.close()
assert len(stored) == 0
def test_sync_sampling_params_pass_through(test_client, mocker: MockerFixture):
"""Sampling parameters should propagate to the engine through the sync path."""
_mock_encode_video_bytes(mocker)
response = test_client.post(
"/v1/videos/sync",
data={
"prompt": "param pass",
"num_inference_steps": "30",
"guidance_scale": "6.5",
"seed": "42",
},
)
assert response.status_code == 200
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.num_inference_steps == 30
assert captured.guidance_scale == 6.5
assert captured.seed == 42
@@ -0,0 +1,85 @@
import asyncio
from types import SimpleNamespace
import pytest
from vllm_omni.entrypoints.async_omni import AsyncOmni
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_generate_accepts_request_after_repeated_cancellations():
async def run_test():
submitted_request_ids = []
aborted_request_batches = []
async def fake_add_request_async(*, request_id, prompt, sampling_params_list, final_stage_id):
del prompt, sampling_params_list, final_stage_id
submitted_request_ids.append(request_id)
async def fake_abort_async(request_ids):
aborted_request_batches.append(list(request_ids))
async def fake_process_results(request_id, metrics, final_stage_id_for_e2e, req_start_ts, wall_start_ts):
del metrics, final_stage_id_for_e2e, req_start_ts, wall_start_ts
if request_id.startswith("cancel-"):
await asyncio.Future()
return
yield SimpleNamespace(
stage_id=0,
request_output=SimpleNamespace(outputs=[]),
finished=True,
)
async def collect_outputs(request_id):
outputs = []
async for output in AsyncOmni.generate(
omni,
prompt={"prompt": "prompt"},
request_id=request_id,
sampling_params_list=[SimpleNamespace()],
output_modalities=["image"],
):
outputs.append(output)
return outputs
omni = object.__new__(AsyncOmni)
omni._pause_cond = asyncio.Condition()
omni._paused = False
omni.engine = SimpleNamespace(
num_stages=1,
add_request_async=fake_add_request_async,
abort_async=fake_abort_async,
)
omni.log_stats = False
omni.request_states = {}
omni._final_output_handler = lambda: None
omni.resolve_sampling_params_list = lambda params: params
omni._compute_final_stage_id = lambda output_modalities: 0
omni._process_orchestrator_results = fake_process_results
omni._log_summary_and_cleanup = lambda request_id: omni.request_states.pop(request_id, None)
assert len(await collect_outputs("baseline")) == 1
for idx in range(3):
task = asyncio.create_task(collect_outputs(f"cancel-{idx}"))
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert len(await collect_outputs("after-cancel")) == 1
assert submitted_request_ids == [
"baseline",
"cancel-0",
"cancel-1",
"cancel-2",
"after-cancel",
]
assert aborted_request_batches == [
["cancel-0"],
["cancel-1"],
["cancel-2"],
]
asyncio.run(run_test())
@@ -0,0 +1,113 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import vllm_omni.diffusion.stage_diffusion_client as stage_diffusion_client_module
from vllm_omni.diffusion.data import DiffusionRequestAbortedError
from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
from vllm_omni.entrypoints.async_omni_diffusion import AsyncOmniDiffusion
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_get_diffusion_od_config_returns_direct_config():
diffusion = object.__new__(AsyncOmniDiffusion)
diffusion.od_config = object()
assert diffusion.get_diffusion_od_config() is diffusion.od_config
def test_async_omni_diffusion_generate_aborts_engine_on_cancel():
async def run_test():
started = threading.Event()
release = threading.Event()
abort = Mock()
def step(request):
del request
started.set()
release.wait(timeout=5)
return [SimpleNamespace(request_id="req-1")]
diffusion = object.__new__(AsyncOmniDiffusion)
diffusion.engine = SimpleNamespace(step=step, abort=abort)
diffusion._executor = ThreadPoolExecutor(max_workers=1)
task = asyncio.create_task(
diffusion.generate(
prompt="hello",
sampling_params=OmniDiffusionSamplingParams(),
request_id="req-1",
)
)
try:
assert await asyncio.to_thread(started.wait, 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
diffusion._executor.shutdown(wait=True)
abort.assert_called_once_with("req-1")
asyncio.run(run_test())
def test_stage_diffusion_client_abort_requests_forwards_to_engine():
async def run_test():
aborted_request_ids: list[list[str]] = []
async def abort(request_ids):
aborted_request_ids.append(request_ids)
client = object.__new__(StageDiffusionClient)
client._engine = SimpleNamespace(abort=abort)
client._tasks = {}
task = asyncio.create_task(asyncio.sleep(60))
client._tasks["req-1"] = task
await client.abort_requests_async(["req-1", "req-2"])
with pytest.raises(asyncio.CancelledError):
await task
assert client._tasks == {}
assert aborted_request_ids == [["req-1", "req-2"]]
asyncio.run(run_test())
def test_stage_diffusion_client_run_treats_abort_as_normal_path(monkeypatch):
async def run_test():
async def generate(prompt, sampling_params, request_id):
del prompt, sampling_params
raise DiffusionRequestAbortedError(f"Request {request_id} aborted.")
info = Mock()
exception = Mock()
monkeypatch.setattr(stage_diffusion_client_module.logger, "info", info)
monkeypatch.setattr(stage_diffusion_client_module.logger, "exception", exception)
client = object.__new__(StageDiffusionClient)
client.stage_id = 3
client._engine = SimpleNamespace(generate=generate)
client._output_queue = asyncio.Queue()
client._tasks = {"req-1": object()}
await client._run("req-1", "prompt", OmniDiffusionSamplingParams())
assert client._output_queue.empty()
assert client._tasks == {}
info.assert_called_once()
exception.assert_not_called()
asyncio.run(run_test())
@@ -379,6 +379,19 @@ def test_openai_serving_models_can_consume_async_omni_compat_attrs():
assert serving_models.input_processor is input_processor
def test_get_diffusion_od_config_returns_diffusion_stage_config():
diffusion_od_config = object()
omni = object.__new__(AsyncOmni)
omni.engine = SimpleNamespace(
stage_clients=[
SimpleNamespace(stage_type="llm"),
SimpleNamespace(stage_type="diffusion", _engine=SimpleNamespace(od_config=diffusion_od_config)),
]
)
assert omni.get_diffusion_od_config() is diffusion_od_config
@pytest.mark.asyncio
async def test_async_omni_yields_only_final_stage_outputs(monkeypatch: pytest.MonkeyPatch):
engine = FakeAsyncOmniEngine(
@@ -16,6 +16,8 @@ from vllm_omni.model_executor.stage_input_processors.qwen3_tts import (
talker2code2wav_async_chunk,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_FRAME = [1, 2, 3, 4]
_Q = len(_FRAME)
@@ -90,26 +92,55 @@ def test_flush_on_finish():
is_finished=True,
)
assert p is not None
assert p["finished"].item() is True
assert p["finished"] is True
assert len(p["code_predictor_codes"]) == _Q * 24
_CASES = [
# ── IC boundary rule ──────────────────────────────────────────────
# IC phase: length <= chunk_size (uses <=, consistent with fish_speech)
# IC emits fill the entire first chunk_size worth of frames, so the
# normal phase always starts at a clean chunk boundary.
# initial_coverage = (chunk_size // initial_chunk_size) * initial_chunk_size
#
# Dynamic IC=16, cs=25, initial_coverage=16
((25, 25, 0), 24, False, None), # IC phase: 24%16!=0 -> hold
((25, 25, 0), 25, False, None), # transition: adjusted=9, hold (no replay)
((25, 25, 0), 41, False, (16, 41)), # first normal emit, lc=16
# IC does NOT evenly divide cs, so initial_coverage < cs.
# IC emits at 16; frames 17-25 remain in IC phase but 25%16!=0 -> hold.
# Normal phase: adjusted = length - 16, emit when adjusted % 25 == 0.
((25, 25, 0), 24, False, None), # IC: 24<=25, 24%16!=0 -> hold
((25, 25, 0), 25, False, None), # IC: 25<=25, 25%16!=0 -> hold
((25, 25, 0), 41, False, (16, 41)), # normal: adjusted=25, 25%25==0 -> emit, lc=16
#
# Per-request IC=10, cs=25, initial_coverage=20
((25, 25, 10), 9, False, None), # IC: hold
((25, 25, 10), 10, False, (0, 10)), # IC: emit at boundary
((25, 25, 10), 25, False, None), # transition: hold (no replay)
((25, 25, 10), 45, False, (20, 45)), # first normal emit, lc=20
# IC does NOT evenly divide cs; IC emits at 10, 20.
# Frames 21-25 are still IC phase but 21..25 % 10 != 0 -> hold.
((25, 25, 10), 9, False, None), # IC: 9%10!=0 -> hold
((25, 25, 10), 10, False, (0, 10)), # IC: 10%10==0 -> emit, lc=0
((25, 25, 10), 25, False, None), # IC: 25<=25, 25%10!=0 -> hold
((25, 25, 10), 45, False, (20, 45)), # normal: adjusted=25, 25%25==0 -> emit, lc=20
((25, 25, 10), 5, True, (0, 5)), # finished flushes IC tail
((25, 25, 10), 33, True, (20, 33)), # finished flushes normal tail
# IC=8, cs=16: IC divides chunk_size evenly (edge case)
((16, 25, 8), 8, False, (0, 8)), # IC: emit
((16, 25, 8), 16, False, None), # transition: hold (no replay)
((16, 25, 8), 24, False, (8, 24)), # first normal emit, lc=8
#
# IC=8, cs=16: IC evenly divides chunk_size (edge case)
# initial_coverage = (16//8)*8 = 16 == chunk_size.
# IC fills the entire first chunk: emits at 8 and 16.
# Normal phase starts at frame 17; first normal emit at 16+16=32.
((16, 25, 8), 8, False, (0, 8)), # IC: 8%8==0 -> emit, lc=0
((16, 25, 8), 16, False, (8, 16)), # IC: 16<=16, 16%8==0 -> emit, lc=8
((16, 25, 8), 24, False, None), # normal: adjusted=8, 8%16!=0 -> hold
((16, 25, 8), 32, False, (16, 32)), # normal: adjusted=16, 16%16==0 -> first emit, lc=16
#
# IC=5, cs=25: IC evenly divides chunk_size
# initial_coverage = (25//5)*5 = 25 == chunk_size.
# IC fills the entire first chunk: emits at 5, 10, 15, 20, 25.
# Normal phase starts at frame 26; first normal emit at 25+25=50.
# Emit intervals: 5,5,5,5,5,25,25,... — smooth transition, no gap.
((25, 25, 5), 5, False, (0, 5)), # IC: 5%5==0 -> emit, lc=0
((25, 25, 5), 12, False, None), # IC: 12%5!=0 -> hold
((25, 25, 5), 25, False, (20, 25)), # IC: 25<=25, 25%5==0 -> emit, lc=20
((25, 25, 5), 30, False, None), # normal: adjusted=5, 5%25!=0 -> hold
((25, 25, 5), 50, False, (25, 50)), # normal: adjusted=25, 25%25==0 -> first emit, lc=25
#
# Per-request override: IC=15 at n_frames=10 -> 10%15!=0 -> hold
((25, 25, 15), 10, False, None),
]
@@ -158,24 +189,29 @@ def test_dynamic_ic_adapts_to_load():
def test_ic_load_change_mid_request():
"""IC stateless: load spike mid-request shifts initial_coverage."""
"""IC is cached per request; a load spike only affects new requests."""
tm = _tm(chunk_frames=25, left_context=25, max_num_seqs=8)
# Low load -> IC=2 -> emit at frame 2
# Low load -> IC=2 (cached for "r"), emit at frame 2
p1 = _call(tm, "r", n_frames=2)
assert p1 is not None
# Spike load: 6 others -> IC=16 -> initial_coverage=16
# Spike load: 6 others running
for i in range(6):
tm.code_prompt_token_ids[f"other-{i}"] = [[0]] * 10
# adjusted=25-16=9, 9%25!=0 -> hold
# IC for "r" is still cached as 2.
# initial_coverage = ((25-1)//2)*2 = 24, first normal emit at 24+25=49
assert _call(tm, "r", n_frames=25) is None
# First normal emit at 16+25=41
p3 = _call(tm, "r", n_frames=41)
assert _call(tm, "r", n_frames=27) is None
p3 = _call(tm, "r", n_frames=49)
assert p3 is not None
assert p3["left_context_size"] == 16
# A *new* request under high load gets IC=16 (not IC=2).
# Frame 2 would emit under IC=2 but must hold under IC=16.
assert _call(tm, "new_req", n_frames=2) is None
p4 = _call(tm, "new_req", n_frames=16)
assert p4 is not None
@pytest.mark.parametrize(
@@ -295,8 +331,13 @@ def test_non_async_processor_prepends_ref_code_and_sets_trim_context():
],
dtype=torch.long,
)
output = SimpleNamespace(multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code})
stage = SimpleNamespace(engine_outputs=[SimpleNamespace(outputs=[output])])
output = SimpleNamespace(
multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code},
token_ids=list(range(3)),
)
stage = SimpleNamespace(
engine_outputs=[SimpleNamespace(outputs=[output], finished=True)],
)
prompts = talker2code2wav(stage_list=[stage], engine_input_source=[0])
@@ -335,8 +376,13 @@ def test_non_async_processor_filters_out_of_range_codec_values():
],
dtype=torch.long,
)
output = SimpleNamespace(multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code})
stage = SimpleNamespace(engine_outputs=[SimpleNamespace(outputs=[output])])
output = SimpleNamespace(
multimodal_output={"audio_codes": audio_codes, "ref_code": ref_code},
token_ids=list(range(4)),
)
stage = SimpleNamespace(
engine_outputs=[SimpleNamespace(outputs=[output], finished=True)],
)
prompts = talker2code2wav(stage_list=[stage], engine_input_source=[0])
+40 -6
View File
@@ -430,13 +430,41 @@ def xpu_marks(*, res: str, num_cards: int):
return marks + [test_distributed]
def musa_marks(*, res: str, num_cards: int):
"""
Get a collection of pytest marks to apply for `@musa_test`.
Args:
res: Resource type, e.g., "S5000".
num_cards: Number of GPU cards required.
Returns:
List of pytest marks to apply.
"""
test_platform_detail = pytest.mark.musa
if res == "S5000":
test_resource = pytest.mark.S5000
else:
raise ValueError(f"Invalid MUSA resource type: {res}. Supported: S5000")
marks = [test_resource, test_platform_detail]
if num_cards == 1:
return marks
else:
test_distributed = pytest.mark.distributed_musa(num_cards=num_cards)
# TODO: add MUSA support for `skipif_musa` marker
return marks + [test_distributed]
def gpu_marks(*, res: str, num_cards: int):
"""
Get a collection of pytest marks to apply for `@gpu_test`.
Platform is automatically determined based on resource type.
Args:
res: Resource type, e.g., "L4", "H100" for CUDA, or "MI325" for ROCm, or "B60" for XPU.
res: Resource type, e.g., "L4", "H100" for CUDA, or "MI325" for ROCm, or "B60" for XPU, or "S5000" for MUSA.
num_cards: Number of GPU cards required.
Returns:
@@ -449,7 +477,9 @@ def gpu_marks(*, res: str, num_cards: int):
return [test_platform] + rocm_marks(res=res, num_cards=num_cards)
if res == "B60":
return [test_platform] + xpu_marks(res=res, num_cards=num_cards)
raise ValueError(f"Invalid resource type: {res}. Supported: L4, H100, MI325")
if res == "S5000":
return [test_platform] + musa_marks(res=res, num_cards=num_cards)
raise ValueError(f"Invalid resource type: {res}. Supported: L4, H100, MI325, B60, S5000")
def npu_marks(*, res: str, num_cards: int):
@@ -476,13 +506,13 @@ def npu_marks(*, res: str, num_cards: int):
def hardware_marks(*, res: dict[str, str], num_cards: int | dict[str, int] = 1):
"""
Get a collection of pytest marks to apply for `@hardware_test`,
including CUDA, ROCm, XPU, and NPU,
including CUDA, ROCm, XPU, NPU, and MUSA,
based on the specified platforms and resources.
"""
# Validate platforms
# Don't validate platform details in this decorator
for platform, _ in res.items():
if platform not in ("cuda", "rocm", "xpu", "npu"):
if platform not in ("cuda", "rocm", "xpu", "npu", "musa"):
raise ValueError(f"Unsupported platform: {platform}")
# Normalize num_cards
@@ -505,6 +535,8 @@ def hardware_marks(*, res: dict[str, str], num_cards: int | dict[str, int] = 1):
cards = num_cards_dict[platform]
if platform == "cuda" or platform == "rocm" or platform == "xpu":
marks = gpu_marks(res=resource, num_cards=cards)
elif platform == "musa":
marks = musa_marks(res=resource, num_cards=cards)
elif platform == "npu":
marks = npu_marks(res=resource, num_cards=cards)
else:
@@ -522,15 +554,17 @@ def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1):
res: Mapping from platform to resource type. Supported platforms/resources:
- cuda: L4, H100
- rocm: MI325
- xpu: B60
- npu: A2, A3
- musa: S5000
num_cards: Number of cards required. Can be:
- int: same card count for all platforms (default: 1)
- dict: per-platform card count, e.g., {"cuda": 2, "rocm": 2}
Example:
@hardware_test(
res={"cuda": "L4", "rocm": "MI325", "npu": "A2"},
num_cards={"cuda": 2, "rocm": 2, "npu": 2},
res={"cuda": "L4", "rocm": "MI325", "npu": "A2", "musa": "S5000"},
num_cards={"cuda": 2, "rocm": 2, "npu": 2, "musa": 2},
)
def test_multi_platform():
...
+30 -3
View File
@@ -78,16 +78,22 @@ BENCHMARK_COLUMNS: tuple[str, ...] = (
"output_throughput",
"total_token_throughput",
"mean_ttft_ms",
"median_ttft_ms",
"p99_ttft_ms",
"mean_tpot_ms",
"median_tpot_ms",
"p99_tpot_ms",
"mean_itl_ms",
"median_itl_ms",
"p99_itl_ms",
"mean_e2el_ms",
"median_e2el_ms",
"p99_e2el_ms",
"mean_audio_rtf",
"median_audio_rtf",
"p99_audio_rtf",
"mean_audio_duration_s",
"median_audio_duration_s",
"p99_audio_duration_s",
)
# Columns that get float coercion and number format in Excel. Excludes request_rate ("inf" str)
@@ -143,16 +149,22 @@ def _load_summary_columns(script_dir: str) -> list[str]:
"output_throughput",
"total_token_throughput",
"mean_ttft_ms",
"median_ttft_ms",
"p99_ttft_ms",
"mean_tpot_ms",
"median_tpot_ms",
"p99_tpot_ms",
"mean_itl_ms",
"median_itl_ms",
"p99_itl_ms",
"mean_e2el_ms",
"median_e2el_ms",
"p99_e2el_ms",
"mean_audio_rtf",
"median_audio_rtf",
"p99_audio_rtf",
"mean_audio_duration_s",
"median_audio_duration_s",
"p99_audio_duration_s",
"commit_sha",
"build_id",
@@ -447,14 +459,29 @@ def _apply_build_metadata_to_latest_only(
build_id: str | None,
build_url: str | None,
) -> None:
"""Set commit_sha, build_id, build_url only on rows with the latest date.
Other rows get None so that build info is not duplicated for older benchmark data.
"""Set commit_sha, build_id, build_url on rows from the latest calendar day.
Dates are expected like YYYYMMDD-HHMMSS (filename / benchmark convention). All rows
whose date starts with the same YYYYMMDD as the lexicographic max date receive
build metadata; older calendar days get None.
When max date is shorter than 8 chars, falls back to exact match.
"""
if not records:
return
max_date = max((r.get("date") or "") for r in records)
use_day_prefix = len(max_date) >= 8
day_prefix = max_date[:8] if use_day_prefix else ""
for r in records:
if (r.get("date") or "") == max_date:
d = r.get("date") or ""
if use_day_prefix and d.startswith(day_prefix):
in_latest_day = True
elif not use_day_prefix and d == max_date:
in_latest_day = True
else:
in_latest_day = False
if in_latest_day:
r["commit_sha"] = commit_sha
r["build_id"] = build_id
r["build_url"] = build_url
+29 -1
View File
@@ -95,8 +95,19 @@ class OmniARScheduler(VLLMScheduler):
return False
criteria_type = self.kv_transfer_criteria.get("type")
if (
self.kv_transfer_criteria.get("stop_after_transfer", True)
and request.request_id in self.transfer_triggered_requests
):
# For split pipelines that only need the transferred KV
# snapshot, stop AR decode once KV extraction has completed.
# This frees stage-0 resources without requiring an
# orchestrator-side abort.
if request.request_id not in self.active_kv_transfers:
request.status = RequestStatus.FINISHED_STOPPED
return True
return False
# Universal duplicate check for once semantics
if request.request_id in self.transfer_triggered_requests:
return False
@@ -456,6 +467,23 @@ class OmniARScheduler(VLLMScheduler):
kv_extracted_ids = getattr(model_runner_output, "kv_extracted_req_ids", None)
if kv_extracted_ids:
for req_id in kv_extracted_ids:
# Emit a kv_ready signal so the orchestrator can forward
# the request to the DiT stage immediately after KV
# extraction, without waiting for AR decode to finish.
req = self.requests.get(req_id)
if req is not None and not req.is_finished():
eco = engine_core_outputs.get(req.client_index)
if eco is None:
eco = EngineCoreOutputs()
engine_core_outputs[req.client_index] = eco
eco.outputs.append(
EngineCoreOutput(
request_id=req_id,
new_token_ids=[],
kv_transfer_params={"kv_ready": True},
)
)
# Mark transfer as finished
if req_id in self.active_kv_transfers:
self.active_kv_transfers.remove(req_id)
@@ -99,6 +99,8 @@ class AttentionImpl(ABC, Generic[T]):
return self.forward_npu(query, key, value, attn_metadata)
elif current_omni_platform.is_xpu():
return self.forward_xpu(query, key, value, attn_metadata)
elif current_omni_platform.is_musa():
return self.forward_musa(query, key, value, attn_metadata)
else:
raise NotImplementedError(f"No forward implementation for platform: {current_omni_platform}")
@@ -138,3 +140,13 @@ class AttentionImpl(ABC, Generic[T]):
) -> torch.Tensor:
# By default, HIP ops are compatible with CUDA ops.
return self.forward_cuda(query, key, value, attn_metadata)
def forward_musa(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: T | None = None,
) -> torch.Tensor:
# By default, MUSA ops are compatible with CUDA ops.
return self.forward_cuda(query, key, value, attn_metadata)
@@ -35,6 +35,9 @@ elif current_omni_platform.is_xpu():
from vllm.v1.attention.backends.fa_utils import flash_attn_varlen_func # noqa: F401
except (ImportError, ModuleNotFoundError):
pass
elif current_omni_platform.is_musa():
# XXX (MUSA): Add MUSA-specific Flash Attention when available
pass
else:
# CUDA: try FA3 -> FA2 fallback chain
# Try FA3 from fa3-fwd PyPI package
+9
View File
@@ -492,6 +492,9 @@ class OmniDiffusionConfig:
# Step mode settings
step_execution: bool = False
# Maximum number of sequences to generate in a batch
max_num_seqs: int = 1
@property
def is_moe(self) -> bool:
num_experts = self.tf_model_config.get("num_experts", None)
@@ -658,6 +661,8 @@ class DiffusionOutput:
trajectory_latents: torch.Tensor | None = None
trajectory_decoded: list[torch.Tensor] | None = None
error: str | None = None
aborted: bool = False
abort_message: str | None = None
post_process_func: Callable[..., Any] | None = None
@@ -675,6 +680,10 @@ class DiffusionOutput:
peak_memory_mb: float = 0.0
class DiffusionRequestAbortedError(RuntimeError):
"""Raised when a diffusion request ends via user-visible abort."""
class AttentionBackendEnum(enum.Enum):
FA = enum.auto()
SLIDING_TILE_ATTN = enum.auto()
+91 -18
View File
@@ -1,6 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import queue
import threading
import time
from collections.abc import Iterable
@@ -11,7 +14,11 @@ import PIL.Image
import torch
from vllm.logger import init_logger
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.data import (
DiffusionOutput,
DiffusionRequestAbortedError,
OmniDiffusionConfig,
)
from vllm_omni.diffusion.executor.abstract import DiffusionExecutor
from vllm_omni.diffusion.registry import (
DiffusionModelRegistry,
@@ -19,7 +26,9 @@ from vllm_omni.diffusion.registry import (
get_diffusion_pre_process_func,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched import RequestScheduler, SchedulerInterface
from vllm_omni.diffusion.sched import RequestScheduler, SchedulerInterface, StepScheduler
from vllm_omni.diffusion.sched.interface import DiffusionRequestStatus
from vllm_omni.diffusion.worker.utils import RunnerOutput
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
from vllm_omni.outputs import OmniRequestOutput
@@ -72,9 +81,14 @@ class DiffusionEngine:
executor_class = DiffusionExecutor.get_class(od_config)
self.executor = executor_class(od_config)
self.scheduler: SchedulerInterface = scheduler or RequestScheduler()
self.step_execution = bool(getattr(od_config, "step_execution", False))
self.scheduler: SchedulerInterface = scheduler or (
StepScheduler() if self.step_execution else RequestScheduler()
)
self.scheduler.initialize(od_config)
self._rpc_lock = threading.Lock()
self._rpc_lock = threading.RLock()
self.abort_queue: queue.Queue[str] = queue.Queue()
self.execute_fn = self.executor.execute_step if self.step_execution else self.executor.execute_request
try:
self._dummy_run()
@@ -98,6 +112,8 @@ class DiffusionEngine:
output = self.add_req_and_wait_for_response(request)
exec_total_time = time.perf_counter() - exec_start_time
if output.aborted:
raise DiffusionRequestAbortedError(output.abort_message or "Diffusion request aborted.")
if output.error:
raise Exception(f"{output.error}")
logger.info("Generation completed successfully.")
@@ -264,7 +280,7 @@ class DiffusionEngine:
def make_engine(
config: OmniDiffusionConfig,
scheduler: SchedulerInterface | None = None,
) -> "DiffusionEngine":
) -> DiffusionEngine:
"""Factory method to create a DiffusionEngine instance.
Args:
@@ -281,8 +297,11 @@ class DiffusionEngine:
# keep scheduling and executing until the target request is finished
while True:
self._process_aborts_queue()
sched_output = self.scheduler.schedule()
if sched_output.is_empty:
if target_sched_req_id in sched_output.finished_req_ids:
return self._finalize_finished_request(target_sched_req_id)
if not self.scheduler.has_requests():
raise RuntimeError("Diffusion scheduler has no runnable requests.")
continue
@@ -292,21 +311,26 @@ class DiffusionEngine:
# vllm_omni/diffusion/sched/base_scheduler.py), so we directly
# take the single scheduled request here.
sched_req_id = sched_output.scheduled_req_ids[0]
req = sched_output.scheduled_new_reqs[0].req
try:
output = self.executor.add_req(req)
runner_output = self.execute_fn(sched_output)
except Exception as exc:
logger.error(
"Execution failed for diffusion request %s",
sched_req_id,
exc_info=True,
logger.error("Execution failed for diffusion request %s", sched_req_id, exc_info=True)
runner_output = RunnerOutput(
req_id=sched_req_id,
step_index=None,
finished=True,
result=DiffusionOutput(error=str(exc)),
)
output = DiffusionOutput(error=str(exc))
finished_req_ids = self.scheduler.update_from_output(sched_output, output)
self._process_aborts_queue()
finished_req_ids = self.scheduler.update_from_output(sched_output, runner_output)
if target_sched_req_id in finished_req_ids:
self.scheduler.pop_request_state(target_sched_req_id)
return output
return self._finalize_finished_request(
target_sched_req_id,
runner_output=runner_output,
missing_result_error="Diffusion execution finished without a final output.",
)
def profile(self, is_start: bool = True, profile_prefix: str | None = None) -> None:
"""Start or stop torch profiling on all diffusion workers.
@@ -437,6 +461,55 @@ class DiffusionEngine:
self.executor.shutdown()
def abort(self, request_id: str | Iterable[str]) -> None:
# TODO implement it
logger.warning("DiffusionEngine abort is not implemented yet")
pass
request_ids = [request_id] if isinstance(request_id, str) else list(request_id)
for req_id in request_ids:
self.abort_queue.put(req_id)
def _process_aborts_queue(self) -> None:
if self.abort_queue.empty():
return
request_ids: list[str] = []
while not self.abort_queue.empty():
ids = self.abort_queue.get_nowait()
request_ids.extend((ids,) if isinstance(ids, str) else ids)
self._abort_requests(request_ids)
def _abort_requests(self, request_ids: str | Iterable[str]) -> None:
request_ids = [request_ids] if isinstance(request_ids, str) else list(request_ids)
sched_req_ids: list[str] = []
for request_id in dict.fromkeys(request_ids):
sched_req_id = self.scheduler.get_sched_req_id(request_id)
if sched_req_id is not None:
sched_req_ids.append(sched_req_id)
for sched_req_id in dict.fromkeys(sched_req_ids):
if self.scheduler.get_request_state(sched_req_id) is not None:
self.scheduler.finish_requests(sched_req_id, DiffusionRequestStatus.FINISHED_ABORTED)
def _finalize_finished_request(
self,
sched_req_id: str,
runner_output: RunnerOutput | None = None,
missing_result_error: str = "Diffusion scheduler finished target request without execution output.",
) -> DiffusionOutput:
state = self.scheduler.get_request_state(sched_req_id)
popped_state = self.scheduler.pop_request_state(sched_req_id)
state = state or popped_state
if state is None:
raise RuntimeError(f"Diffusion scheduler lost state for request {sched_req_id}.")
if state.status == DiffusionRequestStatus.FINISHED_ABORTED:
request_id = state.req.request_ids[0] if state.req.request_ids else sched_req_id
return DiffusionOutput(
aborted=True,
abort_message=f"Request {request_id} aborted.",
)
if runner_output is not None and runner_output.result is not None:
return runner_output.result
return DiffusionOutput(error=missing_result_error)
+177 -94
View File
@@ -17,12 +17,38 @@ from vllm_omni.diffusion.distributed.parallel_state import (
)
def _wrap(pred: torch.Tensor | tuple[torch.Tensor, ...]) -> tuple[torch.Tensor, ...]:
"""Normalize prediction to tuple form."""
return pred if isinstance(pred, tuple) else (pred,)
def _unwrap(pred: tuple[torch.Tensor, ...]) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""Unwrap single-element tuple to plain tensor; keep multi-element as tuple."""
return pred[0] if len(pred) == 1 else pred
def _slice_pred(pred: tuple[torch.Tensor, ...], output_slice: int) -> tuple[torch.Tensor, ...]:
"""Slice each element along dim 1."""
return tuple(p[:, :output_slice] for p in pred)
class CFGParallelMixin(metaclass=ABCMeta):
"""
Base Mixin class for Diffusion pipelines providing shared CFG methods.
All pipelines should inherit from this class to reuse
classifier-free guidance logic.
CFG Parallel Architecture:
When cfg_world_size > 1, each rank computes one branch (positive or
negative), then all_gather exchanges results. All ranks then compute
the CFG combine and scheduler step locally — no broadcast needed
because the operations are deterministic.
Multi-output models:
Models that return tuple from predict_noise() (e.g., video + audio)
should override combine_cfg_noise() to define per-element combine logic,
and set self.scheduler to a composite scheduler that handles tuples.
"""
def predict_noise_maybe_with_cfg(
@@ -33,7 +59,7 @@ class CFGParallelMixin(metaclass=ABCMeta):
negative_kwargs: dict[str, Any] | None,
cfg_normalize: bool = True,
output_slice: int | None = None,
) -> torch.Tensor | None:
) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""
Predict noise with optional classifier-free guidance.
@@ -43,57 +69,64 @@ class CFGParallelMixin(metaclass=ABCMeta):
positive_kwargs: Kwargs for positive/conditional prediction
negative_kwargs: Kwargs for negative/unconditional prediction
cfg_normalize: Whether to normalize CFG output (default: True)
output_slice: If set, slice output to [:, :output_slice] for image editing
output_slice: If set, slice each output to [:, :output_slice] for image editing
Returns:
Predicted noise tensor (only valid on rank 0 in CFG parallel mode)
Predicted noise tensor or tuple of tensors.
In CFG parallel mode, result is valid on ALL ranks (not just rank 0).
Note:
For multi-output models (e.g., video + audio where predict_noise
returns a tuple), override combine_cfg_noise() for per-element CFG
logic and set self.scheduler to a composite scheduler.
"""
if do_true_cfg:
# Automatically detect CFG parallel configuration
cfg_parallel_ready = get_classifier_free_guidance_world_size() > 1
if cfg_parallel_ready:
# Enable CFG-parallel: rank0 computes positive, rank1 computes negative.
cfg_group = get_cfg_group()
cfg_rank = get_classifier_free_guidance_rank()
if cfg_rank == 0:
local_pred = self.predict_noise(**positive_kwargs)
else:
local_pred = self.predict_noise(**negative_kwargs)
# Each rank computes one branch
kwargs = positive_kwargs if cfg_rank == 0 else negative_kwargs
local_pred = _wrap(self.predict_noise(**kwargs))
# Slice output for image editing pipelines (remove condition latents)
if output_slice is not None:
local_pred = local_pred[:, :output_slice]
local_pred = _slice_pred(local_pred, output_slice)
gathered = cfg_group.all_gather(local_pred, separate_tensors=True)
# All-gather each element, reconstruct positive/negative tuples
gathered = [cfg_group.all_gather(p, separate_tensors=True) for p in local_pred]
positive_noise_pred = tuple(g[0] for g in gathered)
negative_noise_pred = tuple(g[1] for g in gathered)
if cfg_rank == 0:
noise_pred = gathered[0]
neg_noise_pred = gathered[1]
noise_pred = self.combine_cfg_noise(noise_pred, neg_noise_pred, true_cfg_scale, cfg_normalize)
return noise_pred
else:
return None
# All ranks compute combine (deterministic, same result)
return self.combine_cfg_noise(
positive_noise_pred,
negative_noise_pred,
true_cfg_scale,
cfg_normalize,
)
else:
# Sequential CFG: compute both positive and negative
positive_noise_pred = self.predict_noise(**positive_kwargs)
negative_noise_pred = self.predict_noise(**negative_kwargs)
positive_noise_pred = _wrap(self.predict_noise(**positive_kwargs))
negative_noise_pred = _wrap(self.predict_noise(**negative_kwargs))
# Slice output for image editing pipelines
if output_slice is not None:
positive_noise_pred = positive_noise_pred[:, :output_slice]
negative_noise_pred = negative_noise_pred[:, :output_slice]
positive_noise_pred = _slice_pred(positive_noise_pred, output_slice)
negative_noise_pred = _slice_pred(negative_noise_pred, output_slice)
noise_pred = self.combine_cfg_noise(
positive_noise_pred, negative_noise_pred, true_cfg_scale, cfg_normalize
return self.combine_cfg_noise(
positive_noise_pred,
negative_noise_pred,
true_cfg_scale,
cfg_normalize,
)
return noise_pred
else:
# No CFG: only compute positive/conditional prediction
pred = self.predict_noise(**positive_kwargs)
if output_slice is not None:
pred = pred[:, :output_slice]
pred = _unwrap(_slice_pred(_wrap(pred), output_slice))
return pred
def cfg_normalize_function(self, noise_pred: torch.Tensor, comb_pred: torch.Tensor) -> torch.Tensor:
@@ -113,35 +146,61 @@ class CFGParallelMixin(metaclass=ABCMeta):
return noise_pred
def combine_cfg_noise(
self, noise_pred: torch.Tensor, neg_noise_pred: torch.Tensor, true_cfg_scale: float, cfg_normalize: bool = False
) -> torch.Tensor:
self,
positive_noise_pred: torch.Tensor | tuple[torch.Tensor, ...],
negative_noise_pred: torch.Tensor | tuple[torch.Tensor, ...],
true_cfg_scale: float,
cfg_normalize: bool = False,
) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""
Combine conditional and unconditional noise predictions with CFG.
Accepts both plain tensors (backward-compatible, used by LTX2 etc.)
and tuples (multi-output models). Default implementation applies the
standard CFG formula to every element.
Multi-output models can override this to apply different logic per element.
Example override for a model returning (video_pred, audio_pred)::
def combine_cfg_noise(self, positive_noise_pred, negative_noise_pred, scale, normalize):
(video_pos, audio_pos) = positive_noise_pred
(video_neg, audio_neg) = negative_noise_pred
video_combined = super().combine_cfg_noise(video_pos, video_neg, scale, normalize)
return (video_combined, audio_pos) # audio: positive only, no CFG
Args:
noise_pred: Conditional noise prediction
neg_noise_pred: Unconditional noise prediction
positive_noise_pred: Positive/conditional prediction(s) — Tensor or tuple
negative_noise_pred: Negative/unconditional prediction(s) — Tensor or tuple
true_cfg_scale: CFG scale factor
cfg_normalize: Whether to normalize the combined prediction (default: False)
Returns:
Combined noise prediction tensor
Combined noise prediction(s) — same type as inputs
"""
comb_pred = neg_noise_pred + true_cfg_scale * (noise_pred - neg_noise_pred)
pos_t = _wrap(positive_noise_pred)
neg_t = _wrap(negative_noise_pred)
if cfg_normalize:
noise_pred = self.cfg_normalize_function(noise_pred, comb_pred)
else:
noise_pred = comb_pred
results = []
for p, n in zip(pos_t, neg_t):
comb = n + true_cfg_scale * (p - n)
if cfg_normalize:
comb = self.cfg_normalize_function(p, comb)
results.append(comb)
return _unwrap(tuple(results))
return noise_pred
def predict_noise(self, *args: Any, **kwargs: Any) -> torch.Tensor:
def predict_noise(self, *args: Any, **kwargs: Any) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""
Forward pass through transformer to predict noise.
Subclasses should override this if they need custom behavior,
but the default implementation calls self.transformer.
Returns:
Single Tensor for standard models, or tuple of Tensors for
multi-output models (e.g., video + audio). Multi-output models
must also override combine_cfg_noise() and set self.scheduler
to a composite scheduler that handles tuples.
"""
return self.transformer(*args, **kwargs)[0]
@@ -156,52 +215,89 @@ class CFGParallelMixin(metaclass=ABCMeta):
Subclasses MUST implement this method to define the complete
diffusion/denoising loop for their specific model.
Typical implementation pattern:
Typical implementation pattern (single output):
```python
def diffuse(self, latents, timesteps, prompt_embeds, negative_embeds, ...):
for t in timesteps:
# Prepare kwargs for positive and negative predictions
positive_kwargs = {...}
negative_kwargs = {...}
# Predict noise with automatic CFG handling
noise_pred = self.predict_noise_maybe_with_cfg(
do_true_cfg=True,
do_true_cfg=do_true_cfg,
true_cfg_scale=self.guidance_scale,
positive_kwargs=positive_kwargs,
negative_kwargs=negative_kwargs,
)
# Step scheduler with automatic CFG sync
latents = self.scheduler_step_maybe_with_cfg(
noise_pred, t, latents, do_true_cfg=True
noise_pred, t, latents, do_true_cfg=do_true_cfg
)
return latents
```
Multi-output models (e.g., video + audio) should:
1. Override ``predict_noise()`` to return a tuple
2. Override ``combine_cfg_noise()`` for per-element CFG logic
3. Set ``self.scheduler`` to a composite scheduler that handles tuples
```python
def diffuse(self, video_latents, audio_latents, timesteps_video, timesteps_audio, ...):
for t_v, t_a in zip(timesteps_video, timesteps_audio):
positive_kwargs = {...}
negative_kwargs = {...}
# Returns tuple: (video_pred, audio_pred)
video_pred, audio_pred = self.predict_noise_maybe_with_cfg(
do_true_cfg=do_true_cfg,
true_cfg_scale=self.guidance_scale,
positive_kwargs=positive_kwargs,
negative_kwargs=negative_kwargs,
)
# self.scheduler = VideoAudioScheduler(video_sched, audio_sched)
# which accepts and returns tuples
video_latents, audio_latents = self.scheduler_step_maybe_with_cfg(
(video_pred, audio_pred),
(t_v, t_a),
(video_latents, audio_latents),
do_true_cfg=do_true_cfg,
)
return video_latents, audio_latents
```
"""
raise NotImplementedError("Subclasses must implement diffuse")
def scheduler_step(
self,
noise_pred: torch.Tensor,
t: torch.Tensor,
latents: torch.Tensor,
noise_pred: torch.Tensor | tuple[torch.Tensor, ...],
t: torch.Tensor | tuple[torch.Tensor, ...],
latents: torch.Tensor | tuple[torch.Tensor, ...],
per_request_scheduler: Any | None = None,
) -> torch.Tensor:
generator: torch.Generator | None = None,
) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""
Step the scheduler.
Default implementation passes inputs directly to ``self.scheduler.step()``.
For multi-output models, set ``self.scheduler`` to a composite scheduler
that handles tuples (e.g., ``VideoAudioScheduler``).
Args:
noise_pred: Predicted noise
t: Current timestep
latents: Current latents
noise_pred: Predicted noise (Tensor or tuple for multi-output)
t: Current timestep (Tensor or tuple when schedulers differ per output)
latents: Current latents (Tensor or tuple for multi-output)
per_request_scheduler: Optional request-scoped scheduler that
overrides ``self.scheduler`` for this call. This is
primarily used by step-wise execution, where each request
may keep scheduler state in its own runner-managed state
object. Request-level execution should usually leave this
as ``None`` and continue using ``self.scheduler``.
generator: Optional torch Generator for reproducible sampling.
When using CFG parallel, both ranks should receive generators
initialized with the same seed so that non-deterministic
schedulers (e.g., DDPM) produce identical results.
Returns:
Updated latents after scheduler step
@@ -211,62 +307,49 @@ class CFGParallelMixin(metaclass=ABCMeta):
raise ValueError("No scheduler is available. Set self.scheduler or pass per_request_scheduler.")
if not callable(getattr(sched, "step", None)):
raise TypeError("per_request_scheduler must provide a callable step(...) method.")
return sched.step(noise_pred, t, latents, return_dict=False)[0]
step_kwargs = dict(return_dict=False)
if generator is not None:
step_kwargs["generator"] = generator
return sched.step(noise_pred, t, latents, **step_kwargs)[0]
def scheduler_step_maybe_with_cfg(
self,
noise_pred: torch.Tensor,
t: torch.Tensor,
latents: torch.Tensor,
noise_pred: torch.Tensor | tuple[torch.Tensor, ...],
t: torch.Tensor | tuple[torch.Tensor, ...],
latents: torch.Tensor | tuple[torch.Tensor, ...],
do_true_cfg: bool,
per_request_scheduler: Any | None = None,
) -> torch.Tensor:
generator: torch.Generator | None = None,
) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""
Step the scheduler with (maybe) automatic CFG parallel synchronization.
Step the scheduler with automatic CFG parallel handling.
In CFG parallel mode, only rank 0 computes the scheduler step,
then broadcasts the result to other ranks.
All ranks compute the scheduler step locally — no broadcast needed
because predict_noise_maybe_with_cfg already ensures all ranks have
identical noise_pred after all_gather + local combine.
Args:
noise_pred: Predicted noise (only valid on rank 0 in CFG parallel)
t: Current timestep
latents: Current latents
noise_pred: Predicted noise (Tensor or tuple, valid on all ranks)
t: Current timestep (Tensor or tuple when schedulers differ per output)
latents: Current latents (Tensor or tuple)
do_true_cfg: Whether CFG is enabled
per_request_scheduler: Optional request-scoped scheduler that
overrides ``self.scheduler`` for this call. This is mainly
needed by step-wise execution, where scheduler state may be
stored per request. Request-level execution should normally
leave this as ``None``.
generator: Optional torch Generator for reproducible sampling.
When using CFG parallel, both ranks should receive generators
initialized with the same seed so that non-deterministic
schedulers (e.g., DDPM) produce identical results.
Returns:
Updated latents (synchronized across all CFG ranks)
Updated latents (identical across all CFG ranks)
"""
# Automatically detect CFG parallel configuration
cfg_parallel_ready = do_true_cfg and get_classifier_free_guidance_world_size() > 1
if cfg_parallel_ready:
cfg_group = get_cfg_group()
cfg_rank = get_classifier_free_guidance_rank()
# Only rank 0 computes the scheduler step
if cfg_rank == 0:
latents = self.scheduler_step(
noise_pred,
t,
latents,
per_request_scheduler=per_request_scheduler,
)
# Broadcast the updated latents to all ranks
latents = latents.contiguous()
cfg_group.broadcast(latents, src=0)
else:
# No CFG parallel: directly compute scheduler step
latents = self.scheduler_step(
noise_pred,
t,
latents,
per_request_scheduler=per_request_scheduler,
)
return latents
return self.scheduler_step(
noise_pred,
t,
latents,
per_request_scheduler=per_request_scheduler,
generator=generator,
)
@@ -213,7 +213,7 @@ class GroupCoordinator:
input_size[0] *= world_size
output_tensor = torch.empty(input_size, dtype=input_.dtype, device=input_.device)
# All-gather.
torch.distributed.all_gather_into_tensor(output_tensor, input_, group=self.device_group)
torch.distributed.all_gather_into_tensor(output_tensor, input_.contiguous(), group=self.device_group)
if dim != 0:
input_size[0] //= world_size
output_tensor = output_tensor.reshape(
+19 -2
View File
@@ -1,11 +1,17 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from typing import TYPE_CHECKING, Any
from vllm.utils.import_utils import resolve_obj_by_qualname
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.request import OmniDiffusionRequest
if TYPE_CHECKING:
from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput
from vllm_omni.diffusion.worker.utils import RunnerOutput
class DiffusionExecutor(ABC):
"""Abstract base class for Diffusion executors."""
@@ -13,7 +19,7 @@ class DiffusionExecutor(ABC):
uses_multiproc: bool = False
@staticmethod
def get_class(od_config: OmniDiffusionConfig) -> type["DiffusionExecutor"]:
def get_class(od_config: OmniDiffusionConfig) -> type[DiffusionExecutor]:
executor_class: type[DiffusionExecutor]
distributed_executor_backend = od_config.distributed_executor_backend
@@ -63,6 +69,16 @@ class DiffusionExecutor(ABC):
"""Add requests to the execution queue."""
pass
@abstractmethod
def execute_request(self, scheduler_output: DiffusionSchedulerOutput) -> RunnerOutput:
"""Execute request-mode work from a scheduler output."""
pass
@abstractmethod
def execute_step(self, scheduler_output: DiffusionSchedulerOutput) -> RunnerOutput:
"""Execute step-mode work from a scheduler output."""
pass
@abstractmethod
def collective_rpc(
self,
@@ -71,6 +87,7 @@ class DiffusionExecutor(ABC):
args: tuple = (),
kwargs: dict | None = None,
unique_reply_rank: int | None = None,
exec_all_ranks: bool = False,
) -> Any:
"""Execute a method on workers."""
pass
@@ -1,8 +1,10 @@
from __future__ import annotations
import multiprocessing as mp
import time
import weakref
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING, Any
import zmq
from vllm.distributed.device_communicators.shm_broadcast import MessageQueue
@@ -14,6 +16,10 @@ from vllm_omni.diffusion.ipc import unpack_diffusion_output_shm
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.worker import WorkerProc
if TYPE_CHECKING:
from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput
from vllm_omni.diffusion.worker.utils import RunnerOutput
logger = init_logger(__name__)
@@ -190,6 +196,61 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
logger.error(f"Generate call failed: {e}")
raise
def execute_request(self, scheduler_output: DiffusionSchedulerOutput) -> RunnerOutput:
"""Adapt request-mode scheduler output to worker execute_model RPC."""
from vllm_omni.diffusion.worker.utils import RunnerOutput
self._ensure_open()
if scheduler_output.num_scheduled_reqs != 1:
raise ValueError(
f"Request mode currently supports batch_size=1, "
f"but got {scheduler_output.num_scheduled_reqs} scheduled requests."
)
new_req = scheduler_output.scheduled_new_reqs[0]
result = self.collective_rpc(
"execute_model",
args=(new_req.req, self.od_config),
unique_reply_rank=0,
exec_all_ranks=True,
)
if not isinstance(result, DiffusionOutput):
raise RuntimeError(f"Unexpected response type for execute_request: {type(result)!r}")
return RunnerOutput(
req_id=new_req.sched_req_id,
step_index=None,
finished=True,
result=result,
)
def execute_step(self, scheduler_output: DiffusionSchedulerOutput) -> RunnerOutput:
"""Forward step-mode scheduler output to worker execute_stepwise RPC."""
from vllm_omni.diffusion.worker.utils import RunnerOutput
self._ensure_open()
result = self.collective_rpc(
"execute_stepwise",
args=(scheduler_output,),
unique_reply_rank=0,
exec_all_ranks=True,
)
if isinstance(result, RunnerOutput):
return result
# TODO: Remove this fallback; DiffusionOutput cannot faithfully represent
# failed multi-request step batches.
if isinstance(result, DiffusionOutput):
req_id = scheduler_output.scheduled_req_ids[0] if scheduler_output.scheduled_req_ids else ""
return RunnerOutput(
req_id=req_id,
step_index=None,
finished=True,
result=result,
)
else:
raise RuntimeError(f"Unexpected response type for execute_step: {type(result)!r}")
def collective_rpc(
self,
method: str,
@@ -197,6 +258,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
args: tuple = (),
kwargs: dict | None = None,
unique_reply_rank: int | None = None,
exec_all_ranks: bool = False,
) -> Any:
self._ensure_open()
@@ -212,7 +274,7 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
"args": args,
"kwargs": kwargs,
"output_rank": unique_reply_rank if unique_reply_rank is not None else 0,
"exec_all_ranks": unique_reply_rank is None,
"exec_all_ranks": unique_reply_rank is None or exec_all_ranks,
}
try:
@@ -228,6 +290,11 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
try:
response = self._result_mq.dequeue(timeout=dequeue_timeout)
try:
unpack_diffusion_output_shm(response)
except Exception as e:
logger.warning("SHM unpack failed (data may already be inline): %s", e)
# Check if response indicates an error
if isinstance(response, dict) and response.get("status") == "error":
raise RuntimeError(
+6
View File
@@ -25,6 +25,8 @@ class CustomOp(nn.Module):
return self.forward_npu
elif current_omni_platform.is_xpu():
return self.forward_xpu
elif current_omni_platform.is_musa():
return self.forward_musa
else:
return self.forward_native
@@ -51,3 +53,7 @@ class CustomOp(nn.Module):
def forward_hip(self, *args, **kwargs):
# By default, we assume that HIP ops are compatible with CUDA ops.
return self.forward_cuda(*args, **kwargs)
def forward_musa(self, *args, **kwargs):
# By default, we assume that MUSA ops are compatible with CUDA ops.
return self.forward_cuda(*args, **kwargs)
+9
View File
@@ -218,10 +218,16 @@ class DiffusionLoRAManager:
lora_scale: The external scale for the LoRA adapter.
"""
if lora_request is None:
if self._active_adapter_id is None:
logger.debug("No lora_request provided and adapters are already inactive")
return
logger.debug("No lora_request provided, deactivating all LoRA adapters")
self._deactivate_all_adapters()
return
elif math.isclose(0.0, lora_scale):
if self._active_adapter_id is None:
logger.debug("Received LoRA scale 0 with adapters already inactive")
return
logger.warning("Received a request with LoRA scale 0; deactivating all LoRA adapters")
self._deactivate_all_adapters()
return
@@ -605,6 +611,9 @@ class DiffusionLoRAManager:
self._update_adapter_scale(adapter_id, scale)
def _deactivate_all_adapters(self) -> None:
if self._active_adapter_id is None:
logger.debug("All adapters already inactive")
return
logger.info("Deactivating all adapters: %d layers", len(self._lora_modules))
for lora_layer in self._lora_modules.values():
lora_layer.reset_lora(0)
@@ -387,7 +387,12 @@ class BagelPipeline(nn.Module, DiffusionPipelineProfilerMixin):
else:
image_input = (
None if isinstance(first_prompt, str) else (first_prompt.get("multi_modal_data") or {}).get("image")
None
if isinstance(first_prompt, str)
else (
(first_prompt.get("multi_modal_data") or {}).get("image")
or (first_prompt.get("multi_modal_data") or {}).get("img2img")
)
)
if image_input and not isinstance(image_input, list):
image_input = [image_input]
@@ -61,6 +61,9 @@ from vllm_omni.diffusion.attention.backends.abstract import (
)
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.distributed.parallel_state import (
get_cfg_group,
get_classifier_free_guidance_rank,
get_classifier_free_guidance_world_size,
get_pp_group,
get_sequence_parallel_rank,
get_sequence_parallel_world_size,
@@ -2535,6 +2538,61 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
def set_scheduler(self, new_scheduler):
self.register_modules(scheduler=new_scheduler)
@staticmethod
def _split_model_kwargs_for_cfg_parallel(model_kwargs: dict[str, Any], batch_size: int, cfg_rank: int) -> None:
"""Split batch-doubled model_kwargs in-place for CFG parallel.
The tokenizer produces inputs with cfg_factor=2, so all batch-dim
tensors have shape [batch_size*2, ...]. This method slices them
so that rank 0 gets the conditioned half and rank 1 gets the
unconditioned half.
"""
s = slice(cfg_rank * batch_size, (cfg_rank + 1) * batch_size)
# Tensor fields with leading batch dimension
tensor_keys = [
"position_ids",
"image_mask",
"gen_timestep_scatter_index",
"cond_vae_image_mask",
"cond_vit_image_mask",
"cond_timestep_scatter_index",
]
for key in tensor_keys:
if key in model_kwargs and model_kwargs[key] is not None:
model_kwargs[key] = model_kwargs[key][s]
# custom_pos_emb: tuple of (cos, sin)
if "custom_pos_emb" in model_kwargs and model_kwargs["custom_pos_emb"] is not None:
cos, sin = model_kwargs["custom_pos_emb"]
model_kwargs["custom_pos_emb"] = (cos[s], sin[s])
# cond_vae_images: tensor or list
if model_kwargs.get("cond_vae_images") is not None:
v = model_kwargs["cond_vae_images"]
if isinstance(v, torch.Tensor):
model_kwargs["cond_vae_images"] = v[s]
elif isinstance(v, list):
model_kwargs["cond_vae_images"] = v[s.start : s.stop]
# cond_timestep: tensor or list
if model_kwargs.get("cond_timestep") is not None:
v = model_kwargs["cond_timestep"]
if isinstance(v, torch.Tensor):
model_kwargs["cond_timestep"] = v[s]
elif isinstance(v, list):
model_kwargs["cond_timestep"] = v[s.start : s.stop]
# cond_vit_images: list of tensors
if model_kwargs.get("cond_vit_images") is not None:
model_kwargs["cond_vit_images"] = model_kwargs["cond_vit_images"][s.start : s.stop]
# vit_kwargs: dict of lists
if model_kwargs.get("vit_kwargs") is not None:
model_kwargs["vit_kwargs"] = {
k: v[s.start : s.stop] if isinstance(v, list) else v[s] for k, v in model_kwargs["vit_kwargs"].items()
}
@torch.no_grad()
def __call__(
self,
@@ -2621,7 +2679,8 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
self._guidance_scale = guidance_scale
self._guidance_rescale = guidance_rescale
cfg_factor = 1 + self.do_classifier_free_guidance
# Detect CFG parallel configuration (only 2-branch layout is supported)
cfg_parallel_ready = self.do_classifier_free_guidance and get_classifier_free_guidance_world_size() == 2
# Define call parameters
device = self._execution_device
@@ -2649,13 +2708,33 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
# Prepare extra step kwargs.
_scheduler_step_extra_kwargs = self.prepare_extra_func_kwargs(self.scheduler.step, {"generator": generator})
# Prepare model kwargs
# Prepare model kwargs — attention mask is built from the full
# (cfg_factor=2) batch before any splitting so that each rank's
# slice is correct.
input_ids = model_kwargs.pop("input_ids")
attention_mask = self.model._prepare_attention_mask_for_generation( # noqa
input_ids,
self.model.generation_config,
model_kwargs=model_kwargs,
)
# Split inputs for CFG parallel: each rank processes only its branch.
if cfg_parallel_ready:
cfg_group = get_cfg_group()
cfg_rank = get_classifier_free_guidance_rank()
# Ensure all ranks start with the same latents
latents = latents.contiguous()
cfg_group.broadcast(latents, src=0)
# Split batch-doubled tensors: rank 0 → conditioned, rank 1 → unconditioned
s = slice(cfg_rank * batch_size, (cfg_rank + 1) * batch_size)
input_ids = input_ids[s]
attention_mask = attention_mask[s]
self._split_model_kwargs_for_cfg_parallel(model_kwargs, batch_size, cfg_rank)
else:
cfg_factor = 1 + self.do_classifier_free_guidance
b, _, q_len1, seq_len = attention_mask.shape
query_lens = [q_len1] * b
seq_lens = [seq_len] * b
@@ -2678,9 +2757,12 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
with self.progress_bar(total=num_inference_steps) as progress_bar:
for i, t in enumerate(timesteps):
# expand the latents if we are doing classifier free guidance
latent_model_input = torch.cat([latents] * cfg_factor)
# latent_model_input = self.scheduler.scale_model_input(latent_model_input, t)
if cfg_parallel_ready:
# CFG parallel: each rank forwards its own branch (no batch doubling)
latent_model_input = latents
else:
# Sequential CFG: double the batch
latent_model_input = torch.cat([latents] * cfg_factor)
t_expand = t.repeat(latent_model_input.shape[0])
@@ -2721,14 +2803,17 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
# TeaCache fast path: reuse previous prediction
pred = tc_prev_pred
# perform guidance
if self.do_classifier_free_guidance:
# Perform guidance
if cfg_parallel_ready:
# CFG parallel: all_gather → all ranks combine locally (no broadcast needed)
gathered = cfg_group.all_gather(pred, separate_tensors=True)
pred = self.cfg_operator(gathered[0], gathered[1], self.guidance_scale, step=i)
elif self.do_classifier_free_guidance:
pred_cond, pred_uncond = pred.chunk(2)
pred = self.cfg_operator(pred_cond, pred_uncond, self.guidance_scale, step=i)
# compute the previous noisy sample x_t -> x_t-1
# Scheduler step (all ranks compute locally in CFG parallel)
latents = self.scheduler.step(pred, t, latents, **_scheduler_step_extra_kwargs, return_dict=False)[0]
if i != len(timesteps) - 1 and should_compute:
model_kwargs = self.model._update_model_kwargs_for_generation( # noqa
model_output,
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more