Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01551d65da | ||
|
|
99b130d2c3 | ||
|
|
c9dbc0955b | ||
|
|
feefdaee9f | ||
|
|
408365fa0d | ||
|
|
340cba7b04 | ||
|
|
9584dd6e69 | ||
|
|
0998b30cbe | ||
|
|
badbe8eb0f | ||
|
|
5d4c9ec4fa | ||
|
|
0304c975d5 | ||
|
|
08e2e1fc8e | ||
|
|
7a72f34ce4 | ||
|
|
368de99f08 | ||
|
|
93a3fcf48e | ||
|
|
8dd66ceb00 | ||
|
|
5b2c4f909d | ||
|
|
54e964dc0a | ||
|
|
e771842781 | ||
|
|
486d77d797 | ||
|
|
328de586aa | ||
|
|
e23b2634d1 | ||
|
|
8b57c6205e | ||
|
|
f6cfacdd16 | ||
|
|
025408f693 | ||
|
|
b2b2ab0c3c | ||
|
|
88f7ed9fb6 | ||
|
|
dd9ca6feef | ||
|
|
832952b2be | ||
|
|
0824edefd5 | ||
|
|
094907eeee | ||
|
|
6fc38e0467 | ||
|
|
d92439c155 | ||
|
|
0cf38197a0 | ||
|
|
f2227d3c9a | ||
|
|
0059ec878c | ||
|
|
191b9a8dbf | ||
|
|
2804a85e58 | ||
|
|
4c031580cf | ||
|
+11 |
f50c5a413f | ||
|
|
0e83ebe1d4 | ||
|
|
10db95f9a9 | ||
|
|
515d15ef87 | ||
|
|
cd71567b06 | ||
|
|
6dc61c9a20 | ||
|
|
563f73b78a | ||
|
|
7fb86d51cd | ||
|
|
fa275fd1be | ||
|
|
6be5d05a7c | ||
|
|
6211413677 | ||
|
|
728cf6d023 | ||
|
|
50bb47a629 | ||
|
|
ca02351a1e | ||
|
|
458f402323 | ||
|
|
e2892ef6c1 | ||
|
|
c1d2dcc3ac | ||
|
|
900f6aa837 | ||
|
|
d3daafbe41 | ||
|
|
ebc9a8d875 | ||
|
|
9c2a576301 | ||
|
|
9595be59ae | ||
|
|
bbae904f1d | ||
|
|
70a62651b9 | ||
|
|
4e4bbc42a6 | ||
|
|
6ef0e907af | ||
|
|
3def008b32 | ||
|
|
bf5bd0a4c0 | ||
|
|
3fd4a4dc27 | ||
|
|
d40840b114 | ||
|
|
08cb436d4e | ||
|
|
c3376a466b | ||
|
|
080593c364 | ||
|
|
183775ec36 | ||
|
|
7b965a7b6c | ||
|
|
7274e15840 | ||
|
|
dd0b6fd4e8 | ||
|
|
369f301a1b | ||
|
|
f8d0bf5389 | ||
|
|
642f16949d | ||
|
|
a5bf33f25a |
No files matched your search
@@ -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"
|
||||
@@ -10,7 +10,7 @@ steps:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
# Buildkite will automatically replace this with the actual commit hash
|
||||
VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}"
|
||||
VLLM_VERSION: "v0.18.0"
|
||||
VLLM_VERSION: "v0.19.0"
|
||||
priority: 100
|
||||
timeout_in_minutes: 60
|
||||
soft_fail: true
|
||||
+17
-2
@@ -1,6 +1,21 @@
|
||||
# Document 1: Buildkite loads only this block on first parse. The next step resolves docs-only skip-ci
|
||||
# from git diff, then uploads document 2. When docs-only skip applies, image-build still runs if nightly-test
|
||||
# / main NIGHTLY so upload-nightly is not skipped together with test-ready/test-merge.
|
||||
#
|
||||
# Document 2: appended after `---`; same file, read by upload_pipeline_with_skip_ci.sh (not evaluated as a second pipeline by Buildkite).
|
||||
steps:
|
||||
- label: ":github: Resolve skip-ci & upload pipeline"
|
||||
key: upload-ci-pipeline
|
||||
commands:
|
||||
- "bash .buildkite/scripts/upload_pipeline_with_skip_ci.sh"
|
||||
agents:
|
||||
queue: "cpu_queue_premerge"
|
||||
|
||||
---
|
||||
steps:
|
||||
- label: ":docker: Build image"
|
||||
key: image-build
|
||||
if: __IMAGE_BUILD_IF__
|
||||
commands:
|
||||
- "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7"
|
||||
- "docker build --progress=plain --file docker/Dockerfile.ci -t vllm-omni-ci ."
|
||||
@@ -13,7 +28,7 @@ steps:
|
||||
- label: "Upload Ready Pipeline"
|
||||
depends_on: image-build
|
||||
key: upload-ready-pipeline
|
||||
if: build.branch != "main" && build.pull_request.labels includes "ready"
|
||||
if: __UPLOAD_READY_IF__
|
||||
commands:
|
||||
- buildkite-agent pipeline upload .buildkite/test-ready.yml
|
||||
agents:
|
||||
@@ -23,7 +38,7 @@ steps:
|
||||
- label: "Upload Merge Pipeline"
|
||||
depends_on: image-build
|
||||
key: upload-merge-pipeline
|
||||
if: build.branch == "main" && build.env("NIGHTLY") != "1"
|
||||
if: __UPLOAD_MERGE_IF__
|
||||
commands:
|
||||
- buildkite-agent pipeline upload .buildkite/test-merge.yml
|
||||
agents:
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
#!/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 --user $(id -u):$(id -g) -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.bak 's/import regex as re/import re/g' .buildkite/scripts/generate-nightly-index.py && rm -f .buildkite/scripts/generate-nightly-index.py.bak
|
||||
|
||||
# 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
|
||||
s3_version="v$pure_version"
|
||||
echo "Re-generating indices for /omni/$s3_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 "$s3_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/$s3_version/"
|
||||
fi
|
||||
Executable
+193
@@ -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 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}")
|
||||
Executable
+33
@@ -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."
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Evaluate docs-only skip-ci and upload continuation steps from the same `.buildkite/pipeline.yml`
|
||||
# (YAML document after the first `---`). Buildkite `if` is evaluated at upload time.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
PIPELINE_YML="${ROOT}/.buildkite/pipeline.yml"
|
||||
|
||||
# Prints a single digit to stdout: 1 = skip image CI, 0 = run. Logs go to stderr.
|
||||
is_docs_only_change() {
|
||||
local file_path
|
||||
local has_any=0
|
||||
|
||||
while IFS= read -r file_path; do
|
||||
[[ -z "${file_path}" ]] && continue
|
||||
has_any=1
|
||||
|
||||
if [[ "${file_path}" == docs/* ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "${file_path}" == *.md ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "${file_path}" == "mkdocs.yaml" ]]; then
|
||||
continue
|
||||
fi
|
||||
return 1
|
||||
done
|
||||
|
||||
[[ "${has_any}" -eq 1 ]]
|
||||
}
|
||||
|
||||
resolve_skip_ci() {
|
||||
local is_pr_build=0
|
||||
local files
|
||||
local base_branch base_ref
|
||||
|
||||
if [[ "${BUILDKITE_PULL_REQUEST:-false}" != "false" && -n "${BUILDKITE_PULL_REQUEST:-}" ]]; then
|
||||
is_pr_build=1
|
||||
fi
|
||||
|
||||
if [[ "${is_pr_build}" -eq 1 ]]; then
|
||||
base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}"
|
||||
if ! git rev-parse --verify "origin/${base_branch}" >/dev/null 2>&1; then
|
||||
echo "resolve_skip_ci: origin/${base_branch} not found locally; trying fetch" >&2
|
||||
git fetch --depth=200 origin "${base_branch}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
base_ref=""
|
||||
if git rev-parse --verify "origin/${base_branch}" >/dev/null 2>&1; then
|
||||
base_ref="origin/${base_branch}"
|
||||
elif git rev-parse --verify "${base_branch}" >/dev/null 2>&1; then
|
||||
base_ref="${base_branch}"
|
||||
else
|
||||
echo "resolve_skip_ci: cannot resolve PR base ${base_branch}; skip-ci=0" >&2
|
||||
echo -n 0
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! files="$(git diff --name-only "${base_ref}...${BUILDKITE_COMMIT}" 2>/dev/null)"; then
|
||||
echo "resolve_skip_ci: failed to compute PR changed files; skip-ci=0" >&2
|
||||
echo -n 0
|
||||
return 0
|
||||
fi
|
||||
elif [[ "${BUILDKITE_BRANCH:-}" == "main" ]]; then
|
||||
if ! git rev-parse --verify "${BUILDKITE_COMMIT}^" >/dev/null 2>&1; then
|
||||
echo "resolve_skip_ci: commit has no parent on main; skip-ci=0" >&2
|
||||
echo -n 0
|
||||
return 0
|
||||
fi
|
||||
if ! files="$(git diff --name-only "${BUILDKITE_COMMIT}^..${BUILDKITE_COMMIT}" 2>/dev/null)"; then
|
||||
echo "resolve_skip_ci: failed to compute main changed files; skip-ci=0" >&2
|
||||
echo -n 0
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo "resolve_skip_ci: not PR/main build; skip-ci=0" >&2
|
||||
echo -n 0
|
||||
return 0
|
||||
fi
|
||||
|
||||
if is_docs_only_change <<< "${files}"; then
|
||||
echo "resolve_skip_ci: docs-only change detected; skip-ci=1" >&2
|
||||
echo -n 1
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "resolve_skip_ci: non-doc changes detected; skip-ci=0" >&2
|
||||
echo -n 0
|
||||
}
|
||||
|
||||
SKIP_CI="$(resolve_skip_ci)"
|
||||
|
||||
if [[ ! -f "${PIPELINE_YML}" ]]; then
|
||||
echo "upload_pipeline_with_skip_ci: missing ${PIPELINE_YML}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export ROOT SKIP_CI PIPELINE_YML
|
||||
python3 <<'PY' | buildkite-agent pipeline upload
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
path = pathlib.Path(os.environ["PIPELINE_YML"])
|
||||
text = path.read_text(encoding="utf-8")
|
||||
sep = "\n---\n"
|
||||
if sep not in text:
|
||||
raise SystemExit(
|
||||
"upload_pipeline_with_skip_ci: .buildkite/pipeline.yml must contain a '\\n---\\n' separator "
|
||||
"(document 1 = bootstrap, document 2 = uploaded steps)"
|
||||
)
|
||||
_, continuation = text.split(sep, 1)
|
||||
|
||||
skip = os.environ.get("SKIP_CI") == "1"
|
||||
# When docs-only skip-ci: skip default CI image, but still build for L4 nightly (PR label nightly-test or
|
||||
# main NIGHTLY=1), otherwise upload-nightly (depends_on image-build) would be skipped too.
|
||||
nightly_only = (
|
||||
'(build.pull_request.labels includes "nightly-test") '
|
||||
'|| (build.branch == "main" && build.env("NIGHTLY") == "1")'
|
||||
)
|
||||
# Placeholder in pipeline.yml is `if: __IMAGE_BUILD_IF__` (valid YAML); replace value only.
|
||||
if skip:
|
||||
rep = f"'{nightly_only}'"
|
||||
ready_rep = "'false'"
|
||||
merge_rep = "'false'"
|
||||
else:
|
||||
rep = "'true'"
|
||||
ready_rep = "'build.branch != \"main\" && build.pull_request.labels includes \"ready\"'"
|
||||
merge_rep = "'build.branch == \"main\" && build.env(\"NIGHTLY\") != \"1\"'"
|
||||
rendered = (
|
||||
continuation
|
||||
.replace("__IMAGE_BUILD_IF__", rep)
|
||||
.replace("__UPLOAD_READY_IF__", ready_rep)
|
||||
.replace("__UPLOAD_MERGE_IF__", merge_rep)
|
||||
)
|
||||
print(rendered, end="")
|
||||
PY
|
||||
+15
-16
@@ -56,8 +56,8 @@ steps:
|
||||
timeout_in_minutes: 20
|
||||
depends_on: upload-merge-pipeline
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/offline_inference/test_diffusion_cpu_offload.py
|
||||
- pytest -s -v tests/e2e/offline_inference/test_diffusion_layerwise_offload.py
|
||||
# Single pytest session for one combined summary at end of log.
|
||||
- pytest -s -v tests/e2e/offline_inference/test_diffusion_cpu_offload.py tests/e2e/offline_inference/test_diffusion_layerwise_offload.py
|
||||
agents:
|
||||
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
|
||||
plugins:
|
||||
@@ -111,8 +111,7 @@ steps:
|
||||
timeout_in_minutes: 20
|
||||
depends_on: upload-merge-pipeline
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/offline_inference/test_sequence_parallel.py
|
||||
- pytest -s -v tests/diffusion/distributed/test_ulysses_uaa_perf.py
|
||||
- pytest -s -v tests/e2e/offline_inference/test_sequence_parallel.py tests/diffusion/distributed/test_ulysses_uaa_perf.py
|
||||
agents:
|
||||
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
|
||||
plugins:
|
||||
@@ -174,7 +173,7 @@ steps:
|
||||
pytest -s -v tests/engine/test_async_omni_engine_abort.py
|
||||
'
|
||||
agents:
|
||||
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
|
||||
queue: "gpu_1_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
@@ -193,8 +192,7 @@ steps:
|
||||
commands:
|
||||
- export VLLM_LOGGING_LEVEL=DEBUG
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- pytest -s -v tests/e2e/offline_inference/test_qwen2_5_omni.py
|
||||
- pytest -s -v tests/e2e/online_serving/test_qwen2_5_omni.py -m "advanced_model" --run-level "advanced_model"
|
||||
- pytest -s -v tests/e2e/offline_inference/test_qwen2_5_omni.py tests/e2e/online_serving/test_qwen2_5_omni.py -m "advanced_model" --run-level "advanced_model"
|
||||
agents:
|
||||
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
|
||||
plugins:
|
||||
@@ -216,7 +214,7 @@ steps:
|
||||
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
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m "advanced_model" --run-level "advanced_model"
|
||||
'
|
||||
agents:
|
||||
queue: "gpu_1_queue"
|
||||
@@ -239,7 +237,7 @@ steps:
|
||||
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
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model"
|
||||
'
|
||||
agents:
|
||||
queue: "gpu_1_queue"
|
||||
@@ -260,9 +258,7 @@ steps:
|
||||
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"
|
||||
- pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py tests/e2e/online_serving/test_mimo_audio.py -m "advanced_model" --run-level "advanced_model"
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
@@ -348,8 +344,7 @@ steps:
|
||||
export VLLM_TEST_CLEAN_GPU_MEMORY=1
|
||||
export VLLM_IMAGE_FETCH_TIMEOUT=60
|
||||
pytest -s -v tests/e2e/offline_inference/test_bagel_text2img.py -m "advanced_model" --run-level "advanced_model" -k "shared_memory"
|
||||
pytest -s -v tests/e2e/offline_inference/test_bagel_img2img.py -m "advanced_model" --run-level "advanced_model"
|
||||
pytest -s -v tests/e2e/online_serving/test_bagel_online.py -m "advanced_model" --run-level "advanced_model"
|
||||
pytest -s -v tests/e2e/offline_inference/test_bagel_img2img.py tests/e2e/online_serving/test_bagel_online.py -m "advanced_model" --run-level "advanced_model"
|
||||
'
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
@@ -393,8 +388,7 @@ steps:
|
||||
timeout 20m bash -c '
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
|
||||
pytest -s -v tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
|
||||
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
|
||||
'
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
@@ -414,6 +408,11 @@ steps:
|
||||
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:
|
||||
|
||||
+14
-21
@@ -6,16 +6,10 @@ steps:
|
||||
commands:
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/e2e/online_serving/test_qwen3_omni_expansion.py -m "advanced_model" --run-level "advanced_model"
|
||||
EXIT1=$$?
|
||||
pytest -s -v tests/examples/online_serving/test_qwen3_omni.py -m "advanced_model" --run-level "advanced_model"
|
||||
EXIT2=$$?
|
||||
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
|
||||
EXIT3=$$?
|
||||
pytest -s -v tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
|
||||
EXIT4=$$?
|
||||
exit $$((EXIT1 | EXIT2 | EXIT3 | EXIT4))
|
||||
pytest -s -v \
|
||||
tests/examples/ \
|
||||
tests/e2e/online_serving/test_*_expansion.py \
|
||||
-m "advanced_model and H100 and omni" --run-level "advanced_model"
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
@@ -57,8 +51,7 @@ steps:
|
||||
commands:
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- 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"
|
||||
- pytest -s -v tests/examples/ 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:
|
||||
@@ -79,7 +72,7 @@ steps:
|
||||
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
|
||||
commands:
|
||||
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
- pytest -s -v tests/e2e/online_serving/test_*_expansion.py -k "not test_wan22_expansion" -m "advanced_model and diffusion and H100" --run-level "advanced_model"
|
||||
- pytest -s -v tests/e2e/online_serving/test_*_expansion.py -k "not test_wan22_expansion and not test_wan_2_1_vace_expansion" -m "advanced_model and diffusion and H100" --run-level "advanced_model"
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
@@ -114,13 +107,13 @@ steps:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: ":full_moon: Diffusion Model Wan22 completed Test with H100"
|
||||
- label: ":full_moon: Diffusion Model (Wan) Test with H100"
|
||||
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/e2e/online_serving/test_wan22_expansion.py -m "advanced_model" --run-level "advanced_model"
|
||||
- pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py tests/e2e/online_serving/test_wan_2_1_vace_expansion.py -m "advanced_model" --run-level "advanced_model"
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
plugins:
|
||||
@@ -155,7 +148,7 @@ steps:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: ":full_moon: Diffusion Model Test with L4"
|
||||
- label: ":full_moon: Diffusion Model Test"
|
||||
timeout_in_minutes: 60
|
||||
depends_on: upload-nightly-pipeline
|
||||
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
|
||||
@@ -177,7 +170,7 @@ steps:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
|
||||
- label: ":full_moon: Documentation Example Code Test with H100"
|
||||
- label: ":full_moon: Doc Example Code Test with H100"
|
||||
timeout_in_minutes: 60
|
||||
depends_on: upload-nightly-pipeline
|
||||
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
|
||||
@@ -219,7 +212,7 @@ steps:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: ":full_moon: Omni Model Perf Test & Test Case Statistics"
|
||||
- label: ":full_moon: Omni Model Perf Test & Testcase Statistics with H100"
|
||||
key: nightly-omni-performance
|
||||
timeout_in_minutes: 180
|
||||
depends_on: upload-nightly-pipeline
|
||||
@@ -369,7 +362,7 @@ steps:
|
||||
- kubernetes:
|
||||
podSpec:
|
||||
containers:
|
||||
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:c392ce21e9cf9ea65c52b866447793db10e0261c
|
||||
- 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
|
||||
@@ -397,9 +390,9 @@ steps:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: ":full_moon: Qwen-Image Diffusion Perf Test with H100"
|
||||
- label: ":full_moon: Diffusion Perf Test with H100"
|
||||
key: nightly-qwen-image-performance
|
||||
timeout_in_minutes: 300
|
||||
timeout_in_minutes: 180
|
||||
depends_on: upload-nightly-pipeline
|
||||
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
|
||||
commands:
|
||||
|
||||
@@ -180,7 +180,7 @@ steps:
|
||||
pytest -s -v tests/engine/test_async_omni_engine_abort.py
|
||||
'
|
||||
agents:
|
||||
queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
|
||||
queue: "gpu_1_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
@@ -271,7 +271,7 @@ steps:
|
||||
- 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
|
||||
nvidia.com/gpu: 1
|
||||
volumeMounts:
|
||||
- name: devshm
|
||||
mountPath: /dev/shm
|
||||
@@ -320,6 +320,29 @@ steps:
|
||||
volumes:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "OmniVoice E2E Test"
|
||||
timeout_in_minutes: 20
|
||||
depends_on: upload-ready-pipeline
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c '
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -s -v tests/e2e/online_serving/test_omnivoice.py -m "core_model" --run-level "core_model"
|
||||
'
|
||||
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: "Voxtral-TTS E2E Test"
|
||||
timeout_in_minutes: 20
|
||||
depends_on: upload-ready-pipeline
|
||||
@@ -328,8 +351,7 @@ steps:
|
||||
timeout 20m bash -c '
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
|
||||
pytest -s -v tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
|
||||
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "core_model" --run-level "core_model"
|
||||
'
|
||||
agents:
|
||||
queue: "mithril-h100-pool"
|
||||
@@ -526,3 +548,46 @@ steps:
|
||||
hostPath:
|
||||
path: /mnt/hf-cache
|
||||
type: DirectoryOrCreate
|
||||
|
||||
- label: "CosyVoice3-TTS E2E Test"
|
||||
timeout_in_minutes: 20
|
||||
depends_on: upload-ready-pipeline
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c '
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts.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: 1
|
||||
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
|
||||
@@ -7,7 +7,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
#### Please take a look at previous [RFCs](https://github.com/vllm-project/vllm-omni/issues?q=label%3ARFC+sort%3Aupdated-desc) for reference.
|
||||
#### Please take a look at previous [RFCs](https://github.com/vllm-project/vllm-omni/issues?q=in%3Atitle%20RFC%20sort%3Aupdated-desc) for reference.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Motivation.
|
||||
@@ -21,7 +21,7 @@ body:
|
||||
description: >
|
||||
The proposed change of the RFC.
|
||||
value: |
|
||||
Please provide the detailed design document of the RFC using the [template](https://docs.google.com/document/d/12YxSsVeD1jvL-InClkeAEnZyWFDndz_65JmXvsamuV4/edit?tab=t.0#heading=h.4ef4szrsgspp).
|
||||
Please provide the detailed design document of the RFC using the [template](https://docs.google.com/document/d/1jcgR3cDaUQH3VczD4ZcKaJAoYWHjCmnYzHkCNyz-9fk/edit?usp=sharing).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -83,6 +83,9 @@ target/
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# uv
|
||||
uv.lock
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -32,6 +32,9 @@ bash run_benchmark.sh --hf-only
|
||||
# Use a different model (e.g. 1.7B)
|
||||
MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice bash run_benchmark.sh --async-only
|
||||
|
||||
# Use a Voice Clone model
|
||||
MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-Base TASK_TYPE=Base bash run_benchmark.sh --async-only
|
||||
|
||||
# Use bs16 config for higher throughput
|
||||
STAGE_CONFIG=vllm_omni/configs/qwen3_tts_bs16.yaml bash run_benchmark.sh --async-only
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
# # Use 1.7B model:
|
||||
# MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice bash run_benchmark.sh --async-only
|
||||
#
|
||||
# # Use Voice Clone model
|
||||
# MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-Base TASK_TYPE=Base bash run_benchmark.sh --async-only
|
||||
#
|
||||
# # Use batch_size=4 config:
|
||||
# STAGE_CONFIG=vllm_omni/configs/qwen3_tts_bs4.yaml bash run_benchmark.sh --async-only
|
||||
#
|
||||
@@ -35,6 +38,7 @@
|
||||
# GPU_MEM_TALKER - gpu_memory_utilization for talker stage (default: 0.3)
|
||||
# GPU_MEM_CODE2WAV - gpu_memory_utilization for code2wav stage (default: 0.2)
|
||||
# STAGE_CONFIG - Path to stage config YAML (default: configs/qwen3_tts_bs1.yaml)
|
||||
# TASK_TYPE - Task type: CustomVoice, VoiceDesign, Base (default: CustomVoice)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -53,6 +57,7 @@ NUM_WARMUPS="${NUM_WARMUPS:-3}"
|
||||
STAGE_CONFIG="${STAGE_CONFIG:-vllm_omni/configs/qwen3_tts_bs1.yaml}"
|
||||
RESULT_DIR="${SCRIPT_DIR}/results"
|
||||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
TASK_TYPE="${TASK_TYPE:-CustomVoice}"
|
||||
|
||||
# Parse args
|
||||
RUN_ASYNC=true
|
||||
@@ -77,6 +82,7 @@ echo " Concurrency: ${CONCURRENCY}"
|
||||
echo " Port: ${PORT}"
|
||||
echo " Stage config: ${STAGE_CONFIG}"
|
||||
echo " Results: ${RESULT_DIR}"
|
||||
echo " Task type: ${TASK_TYPE}"
|
||||
echo "============================================================"
|
||||
|
||||
# Prepare stage config with correct GPU device and memory settings
|
||||
@@ -195,7 +201,8 @@ run_bench() {
|
||||
--max-concurrency ${conc_args} \
|
||||
--num-warmups "${NUM_WARMUPS}" \
|
||||
--config-name "${config_name}" \
|
||||
--result-dir "${RESULT_DIR}"
|
||||
--result-dir "${RESULT_DIR}" \
|
||||
--task-type "${TASK_TYPE}"
|
||||
|
||||
stop_server
|
||||
|
||||
@@ -222,7 +229,8 @@ if [ "${RUN_HF}" = true ]; then
|
||||
--num-warmups "${NUM_WARMUPS}" \
|
||||
--gpu-device "${GPU_DEVICE}" \
|
||||
--config-name "hf_transformers" \
|
||||
--result-dir "${RESULT_DIR}"
|
||||
--result-dir "${RESULT_DIR}" \
|
||||
--task-type "${TASK_TYPE}"
|
||||
|
||||
# Allow GPU memory to settle
|
||||
sleep 5
|
||||
|
||||
@@ -38,6 +38,10 @@ PROMPTS = [
|
||||
"It was a dark and stormy night when the old lighthouse keeper heard a knock at the door.",
|
||||
]
|
||||
|
||||
REF_AUDIO = "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."
|
||||
INSTRUCT = "Speak in an incredulous tone, but with a hint of panic beginning to creep into your voice."
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
@@ -75,6 +79,29 @@ class BenchmarkResult:
|
||||
per_request: list = field(default_factory=list)
|
||||
|
||||
|
||||
def generate_audio(model, prompt: str, args):
|
||||
if args.task_type == "Base":
|
||||
return model.generate_voice_clone(
|
||||
text=prompt,
|
||||
language=args.language,
|
||||
ref_audio=REF_AUDIO,
|
||||
ref_text=REF_TEXT,
|
||||
)
|
||||
|
||||
if args.task_type == "VoiceDesign":
|
||||
return model.generate_voice_design(
|
||||
text=prompt,
|
||||
language=args.language,
|
||||
instruct=INSTRUCT,
|
||||
)
|
||||
|
||||
return model.generate_custom_voice(
|
||||
text=prompt,
|
||||
language=args.language,
|
||||
speaker=args.voice,
|
||||
)
|
||||
|
||||
|
||||
def run_benchmark(args):
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
@@ -95,11 +122,7 @@ def run_benchmark(args):
|
||||
print(f"Warming up with {args.num_warmups} requests...")
|
||||
for i in range(args.num_warmups):
|
||||
p = PROMPTS[i % len(PROMPTS)]
|
||||
wavs, sr = model.generate_custom_voice(
|
||||
text=p,
|
||||
language=args.language,
|
||||
speaker=args.voice,
|
||||
)
|
||||
wavs, sr = generate_audio(model, p, args)
|
||||
# Sync GPU
|
||||
torch.cuda.synchronize(device)
|
||||
print("Warmup done.")
|
||||
@@ -124,11 +147,7 @@ def run_benchmark(args):
|
||||
torch.cuda.synchronize(device)
|
||||
st = time.perf_counter()
|
||||
|
||||
wavs, sr = model.generate_custom_voice(
|
||||
text=prompt,
|
||||
language=args.language,
|
||||
speaker=args.voice,
|
||||
)
|
||||
wavs, sr = generate_audio(model, prompt, args)
|
||||
|
||||
torch.cuda.synchronize(device)
|
||||
elapsed = time.perf_counter() - st
|
||||
@@ -268,6 +287,7 @@ def parse_args():
|
||||
parser.add_argument("--gpu-device", type=int, default=0)
|
||||
parser.add_argument("--voice", type=str, default="Vivian")
|
||||
parser.add_argument("--language", type=str, default="English")
|
||||
parser.add_argument("--task-type", type=str, default="CustomVoice", choices=["CustomVoice", "VoiceDesign", "Base"])
|
||||
parser.add_argument(
|
||||
"--config-name", type=str, default="hf_transformers", help="Label for this config (used in filenames)"
|
||||
)
|
||||
|
||||
@@ -37,6 +37,9 @@ PROMPTS = [
|
||||
"Could you please turn down the music a little bit, I'm trying to concentrate on my work.",
|
||||
"It was a dark and stormy night when the old lighthouse keeper heard a knock at the door.",
|
||||
]
|
||||
REF_AUDIO = "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."
|
||||
INSTRUCT = "Speak in an incredulous tone, but with a hint of panic beginning to creep into your voice."
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -93,22 +96,39 @@ def pcm_bytes_to_duration(num_bytes: int, sample_rate: int = 24000, sample_width
|
||||
return num_samples / sample_rate
|
||||
|
||||
|
||||
def create_payload(
|
||||
prompt: str, task_type: str = "CustomVoice", voice: str = "vivian", language: str = "English"
|
||||
) -> dict:
|
||||
payload = {
|
||||
"input": prompt,
|
||||
"language": language,
|
||||
"stream": True,
|
||||
"response_format": "pcm",
|
||||
"task_type": task_type,
|
||||
}
|
||||
|
||||
if task_type == "Base":
|
||||
payload["ref_audio"] = REF_AUDIO
|
||||
payload["ref_text"] = REF_TEXT
|
||||
elif task_type == "CustomVoice":
|
||||
payload["voice"] = voice
|
||||
elif task_type == "VoiceDesign":
|
||||
payload["instructions"] = INSTRUCT
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
async def send_tts_request(
|
||||
session: aiohttp.ClientSession,
|
||||
api_url: str,
|
||||
prompt: str,
|
||||
task_type: str = "CustomVoice",
|
||||
voice: str = "vivian",
|
||||
language: str = "English",
|
||||
pbar: tqdm | None = None,
|
||||
) -> RequestResult:
|
||||
"""Send a streaming TTS request and measure latency metrics."""
|
||||
payload = {
|
||||
"input": prompt,
|
||||
"voice": voice,
|
||||
"language": language,
|
||||
"stream": True,
|
||||
"response_format": "pcm",
|
||||
}
|
||||
payload = create_payload(prompt, task_type, voice, language)
|
||||
|
||||
result = RequestResult(prompt=prompt)
|
||||
st = time.perf_counter()
|
||||
@@ -153,6 +173,7 @@ async def run_benchmark(
|
||||
num_prompts: int,
|
||||
max_concurrency: int,
|
||||
num_warmups: int = 3,
|
||||
task_type: str = "CustomVoice",
|
||||
voice: str = "vivian",
|
||||
language: str = "English",
|
||||
) -> BenchmarkResult:
|
||||
@@ -175,7 +196,7 @@ async def run_benchmark(
|
||||
warmup_tasks = []
|
||||
for i in range(num_warmups):
|
||||
prompt = PROMPTS[i % len(PROMPTS)]
|
||||
warmup_tasks.append(send_tts_request(session, api_url, prompt, voice, language))
|
||||
warmup_tasks.append(send_tts_request(session, api_url, prompt, task_type, voice, language))
|
||||
await asyncio.gather(*warmup_tasks)
|
||||
print(" Warmup done.")
|
||||
|
||||
@@ -189,7 +210,7 @@ async def run_benchmark(
|
||||
|
||||
async def limited_request(prompt):
|
||||
async with semaphore:
|
||||
return await send_tts_request(session, api_url, prompt, voice, language, pbar)
|
||||
return await send_tts_request(session, api_url, prompt, task_type, voice, language, pbar)
|
||||
|
||||
start_time = time.perf_counter()
|
||||
tasks = [asyncio.create_task(limited_request(p)) for p in request_prompts]
|
||||
@@ -306,6 +327,7 @@ async def main(args):
|
||||
num_prompts=args.num_prompts,
|
||||
max_concurrency=concurrency,
|
||||
num_warmups=args.num_warmups,
|
||||
task_type=args.task_type,
|
||||
voice=args.voice,
|
||||
language=args.language,
|
||||
)
|
||||
@@ -334,6 +356,7 @@ def parse_args():
|
||||
"--max-concurrency", type=int, nargs="+", default=[1, 4, 10], help="Concurrency levels to test"
|
||||
)
|
||||
parser.add_argument("--num-warmups", type=int, default=3)
|
||||
parser.add_argument("--task-type", type=str, default="CustomVoice", choices=["CustomVoice", "VoiceDesign", "Base"])
|
||||
parser.add_argument("--voice", type=str, default="vivian")
|
||||
parser.add_argument("--language", type=str, default="English")
|
||||
parser.add_argument(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG VLLM_BASE_IMAGE=vllm/vllm-openai
|
||||
ARG VLLM_BASE_TAG=v0.18.0
|
||||
ARG VLLM_BASE_TAG=v0.19.0
|
||||
FROM ${VLLM_BASE_IMAGE}:${VLLM_BASE_TAG}
|
||||
ARG APP_DIR=/workspace/vllm-omni
|
||||
WORKDIR ${APP_DIR}
|
||||
@@ -11,9 +11,7 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install vllm-omni into the same uv-managed Python environment used by the base image.
|
||||
# Use bash -c so that $(python3 -c ...) is expanded inside the container.
|
||||
RUN uv pip install --system --no-cache-dir ".[dev]"
|
||||
RUN uv pip install --system ".[dev]"
|
||||
|
||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
ARG BASE_IMAGE=vllm/vllm-openai:v0.19.0
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG COMMON_WORKDIR=/app
|
||||
|
||||
WORKDIR ${COMMON_WORKDIR}
|
||||
|
||||
# Step 1: Setup - Install system dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ffmpeg git sox libsox-fmt-all jq && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p ${COMMON_WORKDIR}/vllm-omni
|
||||
|
||||
# Step 2: Copy vllm-omni code and install
|
||||
COPY . ${COMMON_WORKDIR}/vllm-omni
|
||||
RUN cd ${COMMON_WORKDIR}/vllm-omni && uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" --no-cache-dir "."
|
||||
|
||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
ENTRYPOINT []
|
||||
+13
-13
@@ -1,20 +1,20 @@
|
||||
ARG VLLM_ASCEND_IMAGE=quay.io/ascend/vllm-ascend
|
||||
ARG VLLM_ASCEND_TAG=v0.17.0rc1
|
||||
ARG VLLM_ASCEND_TAG=v0.18.0rc1
|
||||
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
|
||||
|
||||
WORKDIR /vllm-workspace/vllm
|
||||
RUN git fetch origin --tags && git checkout v0.18.0
|
||||
# WORKDIR /vllm-workspace/vllm
|
||||
# RUN git fetch origin --tags && git checkout v0.18.0
|
||||
|
||||
WORKDIR /vllm-workspace/vllm-ascend
|
||||
RUN git fetch origin releases/v0.18.0 && git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a
|
||||
# Install vllm-ascend
|
||||
# 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 -v -e /vllm-workspace/vllm-ascend/ --extra-index https://download.pytorch.org/whl/cpu/ && \
|
||||
python3 -m pip cache purge
|
||||
# WORKDIR /vllm-workspace/vllm-ascend
|
||||
# RUN git fetch origin releases/v0.18.0 && git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a
|
||||
# # Install vllm-ascend
|
||||
# # 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 -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}
|
||||
|
||||
+13
-13
@@ -1,20 +1,20 @@
|
||||
ARG VLLM_ASCEND_IMAGE=quay.io/ascend/vllm-ascend
|
||||
ARG VLLM_ASCEND_TAG=v0.17.0rc1-a3
|
||||
ARG VLLM_ASCEND_TAG=v0.18.0rc1-a3
|
||||
FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG}
|
||||
|
||||
WORKDIR /vllm-workspace/vllm
|
||||
RUN git fetch origin --tags && git checkout v0.18.0
|
||||
# WORKDIR /vllm-workspace/vllm
|
||||
# RUN git fetch origin --tags && git checkout v0.18.0
|
||||
|
||||
WORKDIR /vllm-workspace/vllm-ascend
|
||||
RUN git fetch origin releases/v0.18.0 && git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a
|
||||
# Install vllm-ascend
|
||||
# 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 -v -e /vllm-workspace/vllm-ascend/ --extra-index https://download.pytorch.org/whl/cpu/ && \
|
||||
python3 -m pip cache purge
|
||||
# WORKDIR /vllm-workspace/vllm-ascend
|
||||
# RUN git fetch origin releases/v0.18.0 && git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a
|
||||
# # Install vllm-ascend
|
||||
# # 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 -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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.18.0
|
||||
ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.19.0
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
|
||||
# Declare a variable to know if we want to use the nightly build or the stable build.
|
||||
|
||||
@@ -76,7 +76,7 @@ ENV UV_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL}
|
||||
ENV UV_INDEX_STRATEGY="unsafe-best-match"
|
||||
ENV UV_LINK_MODE="copy"
|
||||
|
||||
ARG VLLM_VERSION=v0.18.0
|
||||
ARG VLLM_VERSION=v0.19.0
|
||||
RUN git clone -b ${VLLM_VERSION} https://github.com/vllm-project/vllm
|
||||
WORKDIR /workspace/vllm
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
Main entry points for vLLM-Omni inference and serving.
|
||||
|
||||
- [vllm_omni.entrypoints.async_omni.AsyncOmni][]
|
||||
- [vllm_omni.entrypoints.async_omni_diffusion.AsyncOmniDiffusion][]
|
||||
- [vllm_omni.entrypoints.cfg_companion_tracker.CfgCompanionTracker][]
|
||||
- [vllm_omni.entrypoints.cli.benchmark.base.OmniBenchmarkSubcommandBase][]
|
||||
- [vllm_omni.entrypoints.cli.benchmark.main.OmniBenchmarkSubcommand][]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 96 KiB |
@@ -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
|
||||
|
||||
@@ -168,7 +168,6 @@ vllm_omni/ tests/
|
||||
│ └── arg_utils.py │ └── test_arg_utils.py ⬜
|
||||
│
|
||||
├── entrypoints/ → ├── entrypoints/
|
||||
│ ├── async_omni_diffusion.py │ ├── test_async_omni_diffusion_config.py ✅
|
||||
│ ├── stage_utils.py │ ├── test_stage_utils.py ✅
|
||||
│ ├── cli/ │ ├── cli/ (benchmarks/test_serve_cli.py covers CLI serve)
|
||||
│ │ └── ... │ │ └── test_*.py ⬜
|
||||
@@ -272,7 +271,7 @@ Before entering specific testing levels, the project establishes two common spec
|
||||
|
||||
L1 and L2 level testing form the foundation of the quality assurance system. L1 level testing focuses on verifying the internal logic correctness of code units (e.g., functions, classes), ensuring each independent component behaves as designed.
|
||||
|
||||
L2 level testing builds upon L1 by introducing GPU resources and verifying that the end-to-end (E2E) process of the model in basic deployment scenarios is smooth. For example, it uses dummy models to confirm that core interfaces like the inference pipeline, output format, and streaming response work properly. The common goal of these two levels is to provide developers with rapid feedback, discovering and fixing issues early in the development cycle .
|
||||
L2 level testing builds upon L1 by introducing GPU resources and verifying that the end-to-end (E2E) process of the model in basic deployment scenarios is smooth. For example, it uses dummy models to confirm that core interfaces like the inference pipeline, output format, and streaming response work properly. The common goal of these two levels is to provide developers with rapid feedback, discovering and fixing issues early in the development cycle.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ vllm_omni/ tests/
|
||||
│ └── arg_utils.py │ └── test_arg_utils.py ⬜
|
||||
│
|
||||
├── entrypoints/ → ├── entrypoints/
|
||||
│ ├── async_omni_diffusion.py │ ├── test_async_omni_diffusion_config.py ✅
|
||||
│ ├── stage_utils.py │ ├── test_stage_utils.py ✅
|
||||
│ ├── cli/ │ ├── cli/ (benchmarks/test_serve_cli.py covers CLI serve)
|
||||
│ │ └── ... │ │ └── test_*.py ⬜
|
||||
|
||||
@@ -802,7 +802,7 @@ omni = Omni(model="your-model", enable_layerwise_offload=True)
|
||||
|
||||
```python
|
||||
class WanTransformer3DModel(nn.Module):
|
||||
_layerwise_offload_blocks_attr = "blocks" # Attribute name containing transformer blocks
|
||||
_layerwise_offload_blocks_attrs = ["blocks"] # Attribute name containing transformer blocks
|
||||
|
||||
def __init__(self):
|
||||
self.blocks = nn.ModuleList([...]) # Transformer blocks
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# VAE Patch Parallelism
|
||||
|
||||
This document describes how to add **VAE Patch Parallelism** support to a diffusion model.
|
||||
We use **Qwen-Image** as the reference implementation.
|
||||
We use **Qwen-Image** as the reference implementation for decode parallel, and **Wan2.2** for encode parallel.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Step-by-Step Implementation](#step-by-step-implementation)
|
||||
- [Step-by-Step Implementation (Decode)](#step-by-step-implementation-decode)
|
||||
- [Encode Parallel Implementation](#encode-parallel-implementation)
|
||||
- [Testing](#testing)
|
||||
- [Reference Implementations](#reference-implementations)
|
||||
- [Summary](#summary)
|
||||
@@ -19,13 +20,13 @@ We use **Qwen-Image** as the reference implementation.
|
||||
|
||||
### What is Vae Patch parallel?
|
||||
|
||||
**VAE Patch Parallelism** is a decoding acceleration technique. Instead of decoding the entire latent tensor at once, the latent tensor is:
|
||||
**VAE Patch Parallelism** is an acceleration technique for both **encoding** and **decoding**. Instead of processing the entire tensor at once, the tensor is:
|
||||
|
||||
+ Split into multiple spatial tiles
|
||||
|
||||
+ Distributed across multiple ranks
|
||||
|
||||
+ Decoded in parallel
|
||||
+ Encoded/Decoded in parallel
|
||||
|
||||
+ Merged to reconstruct the final output
|
||||
|
||||
@@ -35,10 +36,17 @@ This approach:
|
||||
|
||||
+ Reduces peak memory usage per device
|
||||
|
||||
+ Accelerates decoding latency
|
||||
+ Accelerates encoding/decoding latency
|
||||
|
||||
### When to Use Encode vs Decode Parallel
|
||||
|
||||
| Operation | Use Case | Example |
|
||||
|-----------|----------|---------|
|
||||
| **Decode Parallel** | Text-to-Image, Text-to-Video | Latent → Image/Video |
|
||||
| **Encode Parallel** | Image-to-Video (I2V) | Image → Latent (for conditioning) |
|
||||
|
||||
### Architecture
|
||||
We introduce **DistributedVaeExecutor** as the core component responsible for distributed VAE decoding.
|
||||
We introduce **DistributedVaeExecutor** as the core component responsible for distributed VAE encoding/decoding.
|
||||
|
||||
The executor is model-agnostic and accepts three function parameters:
|
||||
|
||||
@@ -84,7 +92,7 @@ Therefore:
|
||||
|
||||
+ Merge must perform blending to avoid seams
|
||||
|
||||
## Step-by-Step Implementation
|
||||
## Step-by-Step Implementation (Decode)
|
||||
|
||||
### Step 1: Implement DistributedAutoencoderKLQwenImage
|
||||
`QwenImagePipeline` use `AutoencoderKLQwenImage` for vae, so implement a distributed version:
|
||||
@@ -205,14 +213,14 @@ def tile_merge(self, coord_tensor_map: dict[tuple[int, ...], torch.Tensor], grid
|
||||
We need to override tiled_decode, the main logic is:
|
||||
+ check distributed is enabled
|
||||
+ select split/exec/merge
|
||||
+ Invoke self.distributed_decoder.execute to decode
|
||||
+ Invoke self.distributed_executor.execute to decode
|
||||
```
|
||||
def tiled_decode(self, z: torch.Tensor, return_dict: bool = True):
|
||||
if not self.is_distributed_enabled():
|
||||
return super().tiled_decode(z, return_dict=return_dict)
|
||||
|
||||
logger.info("Decode run with distributed executor")
|
||||
result = self.distributed_decoder.execute(
|
||||
result = self.distributed_executor.execute(
|
||||
z,
|
||||
DistributedOperator(split=self.tile_split, exec=self.tile_exec, merge=self.tile_merge),
|
||||
broadcast_result=True,
|
||||
@@ -243,6 +251,166 @@ class YourModelPipeline(nn.Module):
|
||||
+ ).to(self.device)
|
||||
```
|
||||
|
||||
## Encode Parallel Implementation
|
||||
|
||||
For models that require VAE encoding (e.g., Image-to-Video), you can also parallelize the encode operation. We use **Wan2.2** as the reference implementation.
|
||||
|
||||
### Step 1: Implement encode_tile_split
|
||||
|
||||
Similar to decode, split the input tensor into tiles. Key considerations:
|
||||
|
||||
+ **Patchify handling**: If the model uses `patch_size`, scale tile parameters accordingly
|
||||
+ **Temporal chunking**: Video VAEs may have temporal compression (e.g., 4x)
|
||||
|
||||
```python
|
||||
def encode_tile_split(self, x: torch.Tensor) -> tuple[list[TileTask], GridSpec]:
|
||||
_, _, num_frames, height, width = x.shape
|
||||
encode_spatial_compression_ratio = self.spatial_compression_ratio
|
||||
|
||||
# Scale tile parameters for patchified coordinate system
|
||||
tile_sample_min_height = self.tile_sample_min_height
|
||||
tile_sample_min_width = self.tile_sample_min_width
|
||||
tile_sample_stride_height = self.tile_sample_stride_height
|
||||
tile_sample_stride_width = self.tile_sample_stride_width
|
||||
|
||||
if self.config.patch_size is not None:
|
||||
# When input is patchified, scale tile parameters accordingly
|
||||
encode_spatial_compression_ratio = self.spatial_compression_ratio // self.config.patch_size
|
||||
tile_sample_min_height = tile_sample_min_height // self.config.patch_size
|
||||
tile_sample_min_width = tile_sample_min_width // self.config.patch_size
|
||||
tile_sample_stride_height = tile_sample_stride_height // self.config.patch_size
|
||||
tile_sample_stride_width = tile_sample_stride_width // self.config.patch_size
|
||||
|
||||
latent_height = height // encode_spatial_compression_ratio
|
||||
latent_width = width // encode_spatial_compression_ratio
|
||||
|
||||
tile_latent_min_height = tile_sample_min_height // encode_spatial_compression_ratio
|
||||
tile_latent_min_width = tile_sample_min_width // encode_spatial_compression_ratio
|
||||
tile_latent_stride_height = tile_sample_stride_height // encode_spatial_compression_ratio
|
||||
tile_latent_stride_width = tile_sample_stride_width // encode_spatial_compression_ratio
|
||||
|
||||
blend_height = tile_latent_min_height - tile_latent_stride_height
|
||||
blend_width = tile_latent_min_width - tile_latent_stride_width
|
||||
|
||||
tiletask_list = []
|
||||
# Use temporal compression ratio from config instead of hardcoding
|
||||
temporal_compression = self.config.scale_factor_temporal
|
||||
|
||||
for i in range(0, height, tile_sample_stride_height):
|
||||
for j in range(0, width, tile_sample_stride_width):
|
||||
time_list = []
|
||||
frame_range = 1 + (num_frames - 1) // temporal_compression
|
||||
for k in range(frame_range):
|
||||
if k == 0:
|
||||
tile = x[:, :, :1, i : i + tile_sample_min_height, j : j + tile_sample_min_width]
|
||||
else:
|
||||
tile = x[
|
||||
:, :,
|
||||
1 + temporal_compression * (k - 1) : 1 + temporal_compression * k,
|
||||
i : i + tile_sample_min_height,
|
||||
j : j + tile_sample_min_width,
|
||||
]
|
||||
time_list.append(tile)
|
||||
tiletask_list.append(
|
||||
TileTask(len(tiletask_list), (i // tile_sample_stride_height, j // tile_sample_stride_width),
|
||||
time_list, workload=time_list[0].shape[3] * time_list[0].shape[4])
|
||||
)
|
||||
|
||||
grid_spec = GridSpec(
|
||||
split_dims=(3, 4),
|
||||
grid_shape=(tiletask_list[-1].grid_coord[0] + 1, tiletask_list[-1].grid_coord[1] + 1),
|
||||
tile_spec={
|
||||
"latent_height": latent_height, "latent_width": latent_width,
|
||||
"blend_height": blend_height, "blend_width": blend_width,
|
||||
"tile_latent_stride_height": tile_latent_stride_height,
|
||||
"tile_latent_stride_width": tile_latent_stride_width,
|
||||
},
|
||||
output_dtype=self.dtype,
|
||||
)
|
||||
return tiletask_list, grid_spec
|
||||
```
|
||||
|
||||
### Step 2: Implement encode_tile_exec
|
||||
|
||||
```python
|
||||
def encode_tile_exec(self, task: TileTask) -> torch.Tensor:
|
||||
"""Encode a single sample tile into latent space."""
|
||||
self.clear_cache()
|
||||
time = []
|
||||
for k, tile in enumerate(task.tensor):
|
||||
self._enc_conv_idx = [0]
|
||||
encoded = self.encoder(tile, feat_cache=self._enc_feat_map, feat_idx=self._enc_conv_idx)
|
||||
encoded = self.quant_conv(encoded)
|
||||
time.append(encoded)
|
||||
result = torch.cat(time, dim=2)
|
||||
self.clear_cache()
|
||||
return result
|
||||
```
|
||||
|
||||
### Step 3: Implement encode_tile_merge
|
||||
|
||||
```python
|
||||
def encode_tile_merge(
|
||||
self, coord_tensor_map: dict[tuple[int, ...], torch.Tensor], grid_spec: GridSpec
|
||||
) -> torch.Tensor:
|
||||
"""Merge encoded tiles into a full latent tensor."""
|
||||
grid_h, grid_w = grid_spec.grid_shape
|
||||
result_rows = []
|
||||
for i in range(grid_h):
|
||||
result_row = []
|
||||
for j in range(grid_w):
|
||||
tile = coord_tensor_map[(i, j)]
|
||||
if i > 0:
|
||||
tile = self.blend_v(coord_tensor_map[(i - 1, j)], tile, grid_spec.tile_spec["blend_height"])
|
||||
if j > 0:
|
||||
tile = self.blend_h(coord_tensor_map[(i, j - 1)], tile, grid_spec.tile_spec["blend_width"])
|
||||
result_row.append(tile[:, :, :,
|
||||
: grid_spec.tile_spec["tile_latent_stride_height"],
|
||||
: grid_spec.tile_spec["tile_latent_stride_width"]])
|
||||
result_rows.append(torch.cat(result_row, dim=-1))
|
||||
|
||||
enc = torch.cat(result_rows, dim=3)[
|
||||
:, :, :, : grid_spec.tile_spec["latent_height"], : grid_spec.tile_spec["latent_width"]
|
||||
]
|
||||
return enc
|
||||
```
|
||||
|
||||
### Step 4: Override tiled_encode method
|
||||
|
||||
Override `tiled_encode` instead of `encode`. The parent's `_encode()` handles patchify before calling `tiled_encode()`, so input `x` is already patchified.
|
||||
|
||||
```python
|
||||
def tiled_encode(self, x: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Encode using distributed VAE executor.
|
||||
|
||||
Note: x is already patchified by parent's _encode() before calling this method.
|
||||
"""
|
||||
if not self.is_distributed_enabled():
|
||||
return super().tiled_encode(x)
|
||||
|
||||
self.clear_cache()
|
||||
result = self.distributed_executor.execute(
|
||||
x,
|
||||
DistributedOperator(
|
||||
split=self.encode_tile_split,
|
||||
exec=self.encode_tile_exec,
|
||||
merge=self.encode_tile_merge,
|
||||
),
|
||||
broadcast_result=True, # Latents needed by all ranks for diffusion
|
||||
)
|
||||
self.clear_cache()
|
||||
return result
|
||||
```
|
||||
|
||||
**Key differences from decode parallel:**
|
||||
|
||||
| Aspect | Decode Parallel | Encode Parallel |
|
||||
|--------|-----------------|-----------------|
|
||||
| `broadcast_result` | Often `False` (only rank 0 needs output) | `True` (all ranks need latents for diffusion) |
|
||||
| Patchify | Applied in merge (unpatchify) | Handled by parent `_encode()` before `tiled_encode()` |
|
||||
| Temporal chunking | Frame-by-frame | Chunk-based (e.g., 1 + 4n frames) |
|
||||
|
||||
## Testing
|
||||
Verify numerical consistency between:
|
||||
+ vae_patch_parallel_size = 1
|
||||
@@ -272,18 +440,20 @@ When vae_patch_parallel_size is larger than the DiT world size, it will automati
|
||||
|
||||
Complete examples in the codebase:
|
||||
|
||||
| Model | Path | Notes |
|
||||
|-------|------|-------|
|
||||
| **Z-Image** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl.py` | Distributed AutoencoderKL |
|
||||
| **Wan2.2** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py` | Distributed AutoencoderKLWan |
|
||||
| **Qwen-Image** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_qwenimage.py` | Distributed AutoencoderKLQwenImage |
|
||||
| Model | Path | Decode Parallel | Encode Parallel |
|
||||
|-------|------|-----------------|-----------------|
|
||||
| **Z-Image** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl.py` | ✅ | ❌ |
|
||||
| **Wan2.2** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py` | ✅ | ✅ |
|
||||
| **Qwen-Image** | `vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_qwenimage.py` | ✅ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Adding Vae Patch Parallel support to diffusion model:
|
||||
Adding VAE Patch Parallel support to diffusion model:
|
||||
|
||||
1. **Implement Distributed Vae** - mainly copy from `diffusers` tiled_decode, and refactor into split/exec/merge
|
||||
2. **Change vae model in pipeline to Distributed Vae**
|
||||
3. **Test** - Verify with `tensor_parallel_size=N` quality
|
||||
1. **Implement Distributed VAE** - Inherit from base VAE class and `DistributedVaeMixin`
|
||||
2. **Decode Parallel** - Refactor `tiled_decode` into `tile_split`/`tile_exec`/`tile_merge`
|
||||
3. **Encode Parallel** (optional) - Implement `encode_tile_split`/`encode_tile_exec`/`encode_tile_merge` for I2V models
|
||||
4. **Change VAE model in pipeline** - Use the distributed version
|
||||
5. **Test** - Verify numerical consistency with `vae_patch_parallel_size=1` vs `N`
|
||||
@@ -69,7 +69,7 @@
|
||||
[5] Orchestrator._orchestration_loop (loop)
|
||||
-> poll stage output
|
||||
- llm stage: await get_output_async()
|
||||
- diffusion stage: get_diffusion_output_async()
|
||||
- diffusion stage: get_diffusion_output_nowait()
|
||||
-> (llm stage) output_processors[i].process_outputs(...)
|
||||
-> _route_output(...)
|
||||
-> if finished and not final_stage and non-async-chunk:
|
||||
@@ -112,7 +112,7 @@ sequenceDiagram
|
||||
ORCH->>S0: add_request_async
|
||||
|
||||
loop poll route forward
|
||||
ORCH->>S0: get_output_async / get_diffusion_output_async
|
||||
ORCH->>S0: get_output_async / get_diffusion_output_nowait
|
||||
ORCH->>ORCH: _route_output
|
||||
alt need forward to next stage
|
||||
ORCH->>SN: add_request_async
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,4 +6,5 @@ vLLM-Omni supports the following hardware platforms:
|
||||
- [NVIDIA CUDA](gpu.md)
|
||||
- [AMD ROCm](gpu.md)
|
||||
- [Intel XPU](gpu.md)
|
||||
- [MThreads MUSA](gpu.md)
|
||||
- [NPU](npu.md)
|
||||
@@ -22,6 +22,10 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:requirements"
|
||||
|
||||
=== "MThreads MUSA"
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/musa.inc.md:requirements"
|
||||
|
||||
## Set up using Python
|
||||
|
||||
### Create a new Python environment
|
||||
@@ -44,6 +48,10 @@ Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:pre-built-wheels"
|
||||
|
||||
=== "MThreads MUSA"
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/musa.inc.md:pre-built-wheels"
|
||||
|
||||
[](){ #build-from-source }
|
||||
|
||||
### Build wheel from source
|
||||
@@ -60,6 +68,10 @@ Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:build-wheel-from-source"
|
||||
|
||||
=== "MThreads MUSA"
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/musa.inc.md:build-wheel-from-source"
|
||||
|
||||
## Set up using Docker
|
||||
|
||||
### Pre-built images
|
||||
@@ -76,6 +88,10 @@ Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:pre-built-images"
|
||||
|
||||
=== "MThreads MUSA"
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/musa.inc.md:pre-built-images"
|
||||
|
||||
### Build your own docker image
|
||||
|
||||
=== "AMD ROCm"
|
||||
@@ -85,3 +101,7 @@ Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc
|
||||
=== "Intel XPU"
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:build-docker"
|
||||
|
||||
=== "MThreads MUSA"
|
||||
|
||||
--8<-- "docs/getting_started/installation/gpu/musa.inc.md:build-docker"
|
||||
@@ -39,13 +39,13 @@ uv pip install 'vllm-omni[demo]'
|
||||
# --8<-- [start:build-wheel-from-source]
|
||||
|
||||
#### Installation of vLLM
|
||||
If you do not need to modify source code of vLLM, you can directly install the stable 0.18.0 release version of the library
|
||||
If you do not need to modify source code of vLLM, you can directly install the stable 0.19.0 release version of the library
|
||||
|
||||
```bash
|
||||
uv pip install vllm==0.18.0 --torch-backend=auto
|
||||
uv pip install vllm==0.19.0 --torch-backend=auto
|
||||
```
|
||||
|
||||
The 0.18.0 release of vLLM ships CUDA 12.9-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead.
|
||||
The 0.19.0 release of vLLM ships CUDA 13.0-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead.
|
||||
|
||||
#### Installation of vLLM-Omni
|
||||
Since vllm-omni is rapidly evolving, it's recommended to install it from source
|
||||
@@ -66,11 +66,12 @@ If you want to check, modify or debug with source code of vLLM, install the libr
|
||||
```bash
|
||||
git clone https://github.com/vllm-project/vllm.git
|
||||
cd vllm
|
||||
git checkout v0.18.0
|
||||
git checkout v0.19.0
|
||||
```
|
||||
Set up environment variables to get pre-built wheels. If there are internet problems, just download the whl file manually. And set `VLLM_PRECOMPILED_WHEEL_LOCATION` as your local absolute path of whl file.
|
||||
```bash
|
||||
export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.18.0/vllm-0.18.0+cu129-cp38-abi3-manylinux_2_35_x86_64.whl
|
||||
#For CUDA 13.0
|
||||
export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.19.0/vllm-0.19.0+cu130-cp38-abi3-manylinux_2_35_x86_64.whl
|
||||
```
|
||||
Install vllm with command below (If you have no existing PyTorch).
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# --8<-- [start:requirements]
|
||||
|
||||
- GPU: Moore Threads GPU with MUSA SDK installed (validated on MTT S5000)
|
||||
|
||||
# --8<-- [end:requirements]
|
||||
# --8<-- [start:set-up-using-python]
|
||||
|
||||
vLLM-Omni for MUSA requires building from source. Pre-built wheels are not currently available.
|
||||
|
||||
!!! note
|
||||
MUSA platform requires vLLM-MUSA to be installed first.
|
||||
|
||||
# --8<-- [start:pre-built-wheels]
|
||||
|
||||
# --8<-- [end:pre-built-wheels]
|
||||
|
||||
# --8<-- [start:build-wheel-from-source]
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **MUSA SDK**: Download from [MUSA SDK Download](https://developer.mthreads.com/sdk/download/musa)
|
||||
- **torchada**: CUDA→MUSA compatibility layer for PyTorch (`pip install torchada`)
|
||||
- **mthreads-ml-py**: MTML Python bindings (`pip install mthreads-ml-py`)
|
||||
- **MATE**: MUSA AI Tensor Engine ([GitHub](https://github.com/MooreThreads/mate))
|
||||
|
||||
#### Installation of vLLM-MUSA
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MooreThreads/vllm-musa.git
|
||||
cd vllm-musa
|
||||
git checkout v0.18.0-dev
|
||||
pip install . --no-build-isolation -v
|
||||
```
|
||||
|
||||
#### Installation of vLLM-Omni
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vllm-project/vllm-omni.git
|
||||
cd vllm-omni
|
||||
VLLM_OMNI_TARGET_DEVICE=musa pip install -e . --no-build-isolation
|
||||
```
|
||||
|
||||
For Gradio demos:
|
||||
|
||||
```bash
|
||||
pip install -e '.[demo]' --no-build-isolation
|
||||
```
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
```bash
|
||||
export MUSA_VISIBLE_DEVICES=0,1
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
export VLLM_MUSA_CUSTOM_OP_USE_NATIVE=false
|
||||
```
|
||||
|
||||
# --8<-- [end:build-wheel-from-source]
|
||||
|
||||
# --8<-- [start:build-docker]
|
||||
|
||||
# --8<-- [end:build-docker]
|
||||
|
||||
# --8<-- [start:pre-built-images]
|
||||
|
||||
# --8<-- [end:pre-built-images]
|
||||
@@ -13,7 +13,7 @@ vLLM-Omni current recommends the steps in under setup through Docker Images.
|
||||
|
||||
vLLM-Omni is built based on vLLM. Please install it with command below.
|
||||
```bash
|
||||
uv pip install vllm==0.18.0+rocm700 --extra-index-url https://wheels.vllm.ai/rocm/0.18.0/rocm700
|
||||
uv pip install vllm==0.19.0+rocm700 --extra-index-url https://wheels.vllm.ai/rocm/0.19.0/rocm700
|
||||
```
|
||||
|
||||
#### Installation of vLLM-Omni
|
||||
@@ -34,13 +34,13 @@ uv pip install onnxruntime-rocm sox
|
||||
# --8<-- [start:build-wheel-from-source]
|
||||
|
||||
#### Installation of vLLM
|
||||
If you do not need to modify source code of vLLM, you can directly install the stable 0.18.0 release version of the library
|
||||
If you do not need to modify source code of vLLM, you can directly install the stable 0.19.0 release version of the library
|
||||
|
||||
```bash
|
||||
uv pip install vllm==0.18.0+rocm700 --extra-index-url https://wheels.vllm.ai/rocm/0.18.0/rocm700
|
||||
uv pip install vllm==0.19.0+rocm700 --extra-index-url https://wheels.vllm.ai/rocm/0.19.0/rocm700
|
||||
```
|
||||
|
||||
The pre-built 0.18.0 vLLM wheel targets ROCm 7.0. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead.
|
||||
The pre-built 0.19.0 vLLM wheel targets ROCm 7.0. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead.
|
||||
|
||||
#### Installation of vLLM-Omni
|
||||
Since vllm-omni is rapidly evolving, it's recommended to install it from source
|
||||
@@ -58,7 +58,7 @@ If you want to check, modify or debug with source code of vLLM, install the libr
|
||||
```bash
|
||||
git clone https://github.com/vllm-project/vllm.git
|
||||
cd vllm
|
||||
git checkout v0.18.0
|
||||
git checkout v0.19.0
|
||||
python3 -m pip install -r requirements/rocm.txt
|
||||
python3 setup.py develop
|
||||
```
|
||||
@@ -130,7 +130,7 @@ docker run --rm \
|
||||
-v ~/.cache/huggingface:/root/.cache/huggingface \
|
||||
--env "HF_TOKEN=$HF_TOKEN" \
|
||||
-p 8091:8091 \
|
||||
vllm/vllm-omni-rocm:v0.18.0 \
|
||||
vllm/vllm-omni-rocm:v0.19.0 \
|
||||
--model Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
|
||||
```
|
||||
|
||||
@@ -149,7 +149,7 @@ docker run --rm -it \
|
||||
-v ~/.cache/huggingface:/root/.cache/huggingface \
|
||||
--env "HF_TOKEN=$HF_TOKEN" \
|
||||
--entrypoint bash \
|
||||
vllm/vllm-omni-rocm:v0.18.0
|
||||
vllm/vllm-omni-rocm:v0.19.0
|
||||
```
|
||||
|
||||
# --8<-- [end:pre-built-images]
|
||||
@@ -10,10 +10,10 @@ The recommended way to use vLLM-Omni on NPU is through the vllm-ascend pre-built
|
||||
```bash
|
||||
# Update the vllm-ascend image
|
||||
# Atlas A2:
|
||||
# export IMAGE=quay.io/ascend/vllm-ascend:v0.17.0rc1
|
||||
# export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1
|
||||
# Atlas A3:
|
||||
# export IMAGE=quay.io/ascend/vllm-ascend:v0.17.0rc1-a3
|
||||
export IMAGE=quay.io/ascend/vllm-ascend:v0.17.0rc1
|
||||
# export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1-a3
|
||||
export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1
|
||||
docker run --rm \
|
||||
--name vllm-omni-npu \
|
||||
--shm-size=1g \
|
||||
@@ -33,17 +33,6 @@ docker run --rm \
|
||||
-p 8000:8000 \
|
||||
-it $IMAGE bash
|
||||
|
||||
cd /vllm-workspace/vllm
|
||||
git fetch origin --tags
|
||||
git checkout v0.18.0
|
||||
|
||||
# Because vllm-ascend will release v0.18.0rc1 after vllm-omni 0.16.0,
|
||||
# we have to pin vllm-ascend at the current commit.
|
||||
cd /vllm-workspace/vllm-ascend
|
||||
git pull origin main
|
||||
git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a
|
||||
pip install -v -e .
|
||||
|
||||
# Inside the container, install vLLM-Omni from source
|
||||
cd /vllm-workspace
|
||||
git clone -b v0.18.0 https://github.com/vllm-project/vllm-omni.git
|
||||
@@ -68,15 +57,10 @@ You can also build vLLM-Omni from the latest main branch if you want to use the
|
||||
|
||||
```bash
|
||||
# Pin vLLM version to 0.18.0
|
||||
cd /vllm-workspace/vllm
|
||||
git fetch origin --tags
|
||||
git checkout v0.18.0
|
||||
git clone -b v0.18.0 https://github.com/vllm-project/vllm.git
|
||||
VLLM_TARGET_DEVICE=empty pip install -v -e .
|
||||
|
||||
# Because vllm-ascend has not yet entered continuous development and has not been officially released, we need to pin it to a specific commit. Please note that this commit may change over time.
|
||||
cd /vllm-workspace/vllm-ascend
|
||||
git pull origin main
|
||||
git fetch origin --tags
|
||||
git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a
|
||||
git clone -b v0.18.0rc1 https://github.com/vllm-project/vllm-ascend.git
|
||||
pip install -v -e .
|
||||
|
||||
# Install vLLM-Omni from the latest main branch
|
||||
|
||||
@@ -19,10 +19,10 @@ uv venv --python 3.12 --seed
|
||||
source .venv/bin/activate
|
||||
|
||||
# On CUDA
|
||||
uv pip install vllm==0.18.0 --torch-backend=auto
|
||||
uv pip install vllm==0.19.0 --torch-backend=auto
|
||||
|
||||
# On ROCm
|
||||
uv pip install vllm==0.18.0+rocm700 --extra-index-url https://wheels.vllm.ai/rocm/0.18.0/rocm700
|
||||
uv pip install vllm==0.19.0+rocm700 --extra-index-url https://wheels.vllm.ai/rocm/0.19.0/rocm700
|
||||
|
||||
git clone https://github.com/vllm-project/vllm-omni.git
|
||||
cd vllm-omni
|
||||
|
||||
@@ -120,6 +120,7 @@ def extract_omni_serve_subparser_init():
|
||||
"_FlexibleArgumentParser": _FlexibleArgumentParser,
|
||||
"FlexibleArgumentParser": _FlexibleArgumentParser,
|
||||
"make_arg_parser": lambda parser: parser, # no-op for doc
|
||||
"_ensure_vllm_platform": lambda: None, # no-op for doc
|
||||
"VLLM_SUBCMD_PARSER_EPILOG": "",
|
||||
"logger": logger,
|
||||
"DummySubparsers": DummySubparsers,
|
||||
|
||||
@@ -31,9 +31,13 @@ th {
|
||||
| `ZImagePipeline` | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `WanPipeline` | Wan2.1-T2V, Wan2.2-T2V, Wan2.2-TI2V | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers`, `Wan-AI/Wan2.1-T2V-14B-Diffusers`, `Wan-AI/Wan2.2-T2V-A14B-Diffusers`, `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `WanImageToVideoPipeline` | Wan2.2-I2V | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `Wan22VACEPipeline` | Wan2.1-VACE | `Wan-AI/Wan2.1-VACE-1.3B-diffusers`, `Wan-AI/Wan2.1-VACE-14B-diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `LTX2Pipeline` | LTX-2-T2V | `Lightricks/LTX-2` | ✅︎ | ✅︎ | | |
|
||||
| `LTX2ImageToVideoPipeline` | LTX-2-I2V | `Lightricks/LTX-2` | ✅︎ | ✅︎ | | |
|
||||
| `LTX2TwoStagesPipeline` | LTX-2-T2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | |
|
||||
| `LTX2ImageToVideoTwoStagesPipeline` | LTX-2-I2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | |
|
||||
| `HeliosPipeline`, `HeliosPyramidPipeline` | Helios | `BestWishYsh/Helios-Base`, `BestWishYsh/Helios-Mid`, `BestWishYsh/Helios-Distilled` | ✅︎ | ✅︎ | ✅︎ | |
|
||||
| `MagiHumanPipeline` | MagiHuman | `SII-GAIR/daVinci-MagiHuman-Base-1080p` | ✅︎ | ✅︎ | | |
|
||||
| `OvisImagePipeline` | Ovis-Image | `OvisAI/Ovis-Image` | ✅︎ | ✅︎ | | ✅︎ |
|
||||
| `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
|
||||
@@ -118,6 +118,7 @@ Content-Type: application/json
|
||||
| `instructions` | string | "" | Voice style/emotion instructions |
|
||||
| `max_new_tokens` | integer | 2048 | Maximum tokens to generate |
|
||||
| `initial_codec_chunk_frames` | integer | null | Per-request initial chunk size override for TTFA tuning. When null, IC is computed dynamically based on server load. |
|
||||
| `stream` | bool | false | Stream raw PCM chunks as they are decoded (requires `response_format="pcm"`) |
|
||||
|
||||
**Supported languages:** Auto, Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian
|
||||
|
||||
@@ -143,9 +144,23 @@ Lists available voices for the loaded model.
|
||||
|
||||
```json
|
||||
{
|
||||
"voices": ["aiden", "dylan", "eric", "ono_anna", "ryan", "serena", "sohee", "uncle_fu", "vivian"]
|
||||
"voices": ["aiden", "dylan", "eric", "ono_anna", "ryan", "serena", "sohee", "uncle_fu", "vivian", "custom_voice_1"],
|
||||
"uploaded_voices": [
|
||||
{
|
||||
"name": "custom_voice_1",
|
||||
"consent": "user_consent_id",
|
||||
"created_at": 1738660000,
|
||||
"file_size": 1024000,
|
||||
"mime_type": "audio/wav",
|
||||
"ref_text": "The exact transcript of the audio sample.",
|
||||
"speaker_description": "warm narrator"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`uploaded_voices` is always present (empty list when no custom voices have been uploaded). Fields `ref_text` and `speaker_description` are omitted per-entry when not provided at upload time.
|
||||
|
||||
```
|
||||
POST /v1/audio/voices
|
||||
Content-Type: multipart/form-data
|
||||
@@ -161,6 +176,7 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
|
||||
| `consent` | string | Yes | Consent recording ID |
|
||||
| `name` | string | Yes | Name for the new voice |
|
||||
| `ref_text` | string | No | Transcript of the audio. When provided, enables in-context voice cloning (higher quality). Without it, only the speaker embedding is extracted. |
|
||||
| `speaker_description` | string | No | Free-form description of the voice (e.g. "warm narrator", "energetic presenter"). Stored as metadata and returned in `GET /v1/audio/voices`. |
|
||||
|
||||
**Response Example:**
|
||||
|
||||
@@ -172,11 +188,15 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
|
||||
"consent": "user_consent_id",
|
||||
"created_at": 1738660000,
|
||||
"mime_type": "audio/wav",
|
||||
"file_size": 1024000
|
||||
"file_size": 1024000,
|
||||
"ref_text": "The exact transcript of the audio sample.",
|
||||
"speaker_description": "warm narrator"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields `ref_text` and `speaker_description` are omitted when not provided at upload time.
|
||||
|
||||
**Usage Example:**
|
||||
|
||||
```bash
|
||||
@@ -184,7 +204,8 @@ curl -X POST http://localhost:8091/v1/audio/voices \
|
||||
-F "audio_sample=@/path/to/voice_sample.wav" \
|
||||
-F "consent=user_consent_id" \
|
||||
-F "name=custom_voice_1" \
|
||||
-F "ref_text=The exact transcript of the audio sample."
|
||||
-F "ref_text=The exact transcript of the audio sample." \
|
||||
-F "speaker_description=warm narrator"
|
||||
```
|
||||
|
||||
## Streaming Text Input (WebSocket)
|
||||
|
||||
@@ -164,7 +164,7 @@ cache_config={
|
||||
|
||||
**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.
|
||||
- Default `Fn_compute_blocks=1` works well for most cases. Some models (e.g., FLUX.2-klein) 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.
|
||||
|
||||
|
||||
@@ -91,12 +91,19 @@ Models must define the blocks attribute name for layerwise offloading:
|
||||
|
||||
```python
|
||||
class WanTransformer3DModel(nn.Module):
|
||||
_layerwise_offload_blocks_attr = "blocks" # Attribute name containing transformer blocks
|
||||
_layerwise_offload_blocks_attrs = ["blocks"] # Attribute names containing transformer blocks
|
||||
|
||||
def __init__(self):
|
||||
self.blocks = nn.ModuleList([...]) # Transformer blocks
|
||||
```
|
||||
|
||||
For models with multiple block types:
|
||||
|
||||
```python
|
||||
class Flux2Transformer2DModel(nn.Module):
|
||||
_layerwise_offload_blocks_attrs = ["transformer_blocks", "single_transformer_blocks"]
|
||||
```
|
||||
|
||||
### Limitations
|
||||
- Cold start latency increases because of
|
||||
1) components are loaded to CPU first at the very first during initialization,
|
||||
@@ -140,4 +147,4 @@ Factory function `get_offload_backend()` selects the appropriate backend based o
|
||||
|
||||
**Notes:**
|
||||
- Model-Level Offloading is expected to be supported by all common diffusion models (DiT and encoders) naturally
|
||||
- Layerwise Offloading requires DiT class to define `_layerwise_offload_blocks_attr` pointing to transformer blocks
|
||||
- Layerwise Offloading requires DiT class to define `_layerwise_offload_blocks_attrs` pointing to transformer blocks
|
||||
@@ -0,0 +1,91 @@
|
||||
# AutoRound Quantization
|
||||
|
||||
## Overview
|
||||
|
||||
[AutoRound](https://github.com/intel/auto-round) is an advanced quantization toolkit designed for Large Language Models (LLMs), Vision-Language Models (VLMs), and diffusion models. It achieves high accuracy at ultra-low bit widths (2–4 bits) with minimal tuning by leveraging sign-gradient descent, while providing broad hardware compatibility with multi-datatype support.
|
||||
|
||||
The quantization config is auto-detected from the checkpoint's `config.json` (`quantization_config.quant_method = "auto-round"`). No extra CLI flags are needed.
|
||||
|
||||
### Supported Schemes
|
||||
|
||||
| Scheme | Bits | Status |
|
||||
|--------|------|--------|
|
||||
| W4A16 | 4 | ✅ Supported |
|
||||
| W8A16 | 8 | Planned |
|
||||
|
||||
W4A16 is the first supported scheme. Additional schemes will be added in future releases.
|
||||
|
||||
## Configuration
|
||||
|
||||
1. **Python API**: point `model` at a pre-quantized checkpoint. The quantization is detected automatically.
|
||||
|
||||
```python
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
omni = Omni(model="vllm-project-org/FLUX.1-dev-AutoRound-w4a16")
|
||||
|
||||
outputs = omni.generate(
|
||||
"A cat sitting on a windowsill",
|
||||
OmniDiffusionSamplingParams(num_inference_steps=28),
|
||||
)
|
||||
outputs[0].save_images("output.png")
|
||||
```
|
||||
|
||||
2. **CLI**: pass the quantized model path directly.
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
--model vllm-project-org/FLUX.1-dev-AutoRound-w4a16 \
|
||||
--prompt "A cat sitting on a windowsill" \
|
||||
--num-inference-steps 28 \
|
||||
--output outputs/flux_w4a16.png
|
||||
```
|
||||
|
||||
No `--quantization` flag is needed — the quantization method is read from the checkpoint.
|
||||
|
||||
## How It Works
|
||||
|
||||
The checkpoint's `config.json` contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"quantization_config": {
|
||||
"quant_method": "auto-round",
|
||||
"bits": 4,
|
||||
"group_size": 128,
|
||||
"sym": true,
|
||||
"packing_format": "auto_round:auto_gptq",
|
||||
"block_name_to_quantize": "transformer_blocks,single_transformer_blocks"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At load time:
|
||||
|
||||
1. `TransformerConfig.from_dict()` parses the `quantization_config` section and builds a vLLM `INCConfig` via `build_quant_config("auto-round", ...)`.
|
||||
2. `OmniDiffusionConfig.set_tf_model_config()` propagates the detected config to the engine.
|
||||
3. The appropriate compute kernel (e.g. GPTQ-Marlin for W4A16) is selected automatically based on the checkpoint's bit-width and packing format.
|
||||
|
||||
## Supported Models
|
||||
|
||||
| Model | HF Checkpoint | Scheme | Group Size | Backend |
|
||||
|-------|--------------|--------|------------|---------|
|
||||
| FLUX.1-dev | `vllm-project-org/FLUX.1-dev-AutoRound-w4a16` | W4A16 | 128 | GPTQ-Marlin |
|
||||
|
||||
## Creating a Quantized Checkpoint
|
||||
|
||||
Use [AutoRound](https://github.com/intel/auto-round) to quantize a BF16 model. The `--scheme` flag selects the quantization scheme:
|
||||
|
||||
```bash
|
||||
# W4A16 (4-bit weight, 16-bit activation)
|
||||
auto-round \
|
||||
--model black-forest-labs/FLUX.1-dev \
|
||||
--scheme W4A16 \
|
||||
--batch_size 1 \
|
||||
--disable_opt_rtn \
|
||||
--dataset coco2014 \
|
||||
--iters 0
|
||||
```
|
||||
|
||||
The output directory can be used directly as the `model` argument. See the [AutoRound documentation](https://github.com/intel/auto-round) for all available schemes and options.
|
||||
@@ -11,6 +11,7 @@ vLLM-Omni provides a unified quantization framework that supports both diffusion
|
||||
| FP8 | [FP8](fp8.md) | FP8 W8A8, dynamic or static | Z-Image, Qwen-Image, Flux, Bagel | SM 89 (Ada) |
|
||||
| Int8 | [Int8](int8.md) | Int8 W8A8 | Z-Image, Qwen-Image | SM 89 (Ada) / Ascend NPU |
|
||||
| GGUF | [GGUF](gguf.md) | GGUF format, dequant+GEMM for N-D tensors | Z-Image, Flux | SM 60 |
|
||||
| AutoRound | [AutoRound](autoround.md) | W4A16 (pre-quantized) | Flux | SM 80 (Ampere) |
|
||||
|
||||
### Multi-stage Omni Models (Pre-quantized Checkpoints)
|
||||
|
||||
@@ -102,6 +103,9 @@ config = build_quant_config("fp8")
|
||||
# Dict with parameters
|
||||
config = build_quant_config({"method": "fp8", "activation_scheme": "static"})
|
||||
|
||||
# AutoRound / INC (auto-detected from checkpoint, or explicit)
|
||||
config = build_quant_config("auto-round", bits=4, group_size=128)
|
||||
|
||||
# Per-component dict
|
||||
config = build_quant_config({
|
||||
"transformer": {"method": "fp8"},
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
|
||||
@@ -64,6 +65,16 @@ Extension methods add specialized capabilities to diffusion models beyond standa
|
||||
| **[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 |
|
||||
@@ -87,48 +98,50 @@ The following tables show which models support each feature:
|
||||
|
||||
### ImageGen
|
||||
|
||||
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization |
|
||||
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|
|
||||
| **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) | ❌ | ❌ | ✅ | ✅ |
|
||||
| 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** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **MagiHuman** | ❌ | ❌ | ❌ | ❓ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **MammothModa2(T2I)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Nextstep_1(T2I)** | ❓ | ❓ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **OmniGen2** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Ovis-Image** | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Qwen-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ✅ |
|
||||
| **Qwen-Image-2512** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ✅ |
|
||||
| **Qwen-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
|
||||
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ (decode) | ❌ | ❌ |
|
||||
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ✅ | ❌ | ✅ (decode) | ✅ | ❌ |
|
||||
|
||||
> 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.
|
||||
> 2. `Tongyi-MAI/Z-Image-Turbo` and `SII-GAIR/daVinci-MagiHuman-Base-1080p` are distilled models 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 |
|
||||
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|
|
||||
| **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Helios** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
|
||||
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|:----------------:|
|
||||
| **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ |
|
||||
| **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
|
||||
| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Helios** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ |
|
||||
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
### AudioGen
|
||||
|
||||
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization |
|
||||
|-------|:----------:|:-----------:|:---------------------:|:--------------:|:-----------------:|:------:|:------------------------:|:--------------------:|:--------------:|
|
||||
| **Stable-Audio-Open** | ❌ | ❌ | ❓ | ❓ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 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
|
||||
@@ -139,21 +152,22 @@ The following tables show which models support each feature:
|
||||
- ❌: 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 |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| **⚡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** | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | |
|
||||
| | ⚡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
|
||||
|
||||
@@ -162,6 +176,7 @@ The following tables show which models support each feature:
|
||||
3. CPU Offloading (Layerwise) and CPU Offloading (Module-wise) are not compatible.
|
||||
4. CPU Offloading (Layerwise) supports single-card for now.
|
||||
5. Using FP8-Quant as an example of qunatization methods.
|
||||
6. Step Execution is not compatible with cache backends (TeaCache, Cache-DiT) or LoRA.
|
||||
|
||||
|
||||
## Learn More
|
||||
@@ -185,6 +200,10 @@ The following tables show which models support each feature:
|
||||
|
||||
- **[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.
|
||||
|
||||
@@ -159,7 +159,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "Hello, how are you?",
|
||||
"speaker": "vivian",
|
||||
"voice": "vivian",
|
||||
"language": "English"
|
||||
}' --output output.wav
|
||||
|
||||
@@ -168,7 +168,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "I am so excited!",
|
||||
"speaker": "vivian",
|
||||
"voice": "vivian",
|
||||
"instructions": "Speak with great enthusiasm"
|
||||
}' --output excited.wav
|
||||
|
||||
@@ -185,7 +185,7 @@ client = OpenAI(base_url="http://localhost:8091/v1", api_key="none")
|
||||
|
||||
response = client.audio.speech.create(
|
||||
model="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
||||
speaker="vivian",
|
||||
voice="vivian",
|
||||
input="Hello, how are you?",
|
||||
)
|
||||
|
||||
@@ -201,7 +201,7 @@ response = httpx.post(
|
||||
"http://localhost:8091/v1/audio/speech",
|
||||
json={
|
||||
"input": "Hello, how are you?",
|
||||
"speaker": "vivian",
|
||||
"voice": "vivian",
|
||||
"language": "English",
|
||||
},
|
||||
timeout=300.0,
|
||||
@@ -237,12 +237,16 @@ List all available voices/speakers from the loaded model, including both built-i
|
||||
"consent": "user_consent_id",
|
||||
"created_at": 1738660000,
|
||||
"file_size": 1024000,
|
||||
"mime_type": "audio/wav"
|
||||
"mime_type": "audio/wav",
|
||||
"ref_text": "The exact transcript of the audio sample.",
|
||||
"speaker_description": "warm narrator"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Fields `ref_text` and `speaker_description` are omitted per-entry when not provided at upload time.
|
||||
|
||||
#### POST /v1/audio/voices
|
||||
|
||||
Upload a new voice sample for voice cloning in Base task TTS requests.
|
||||
@@ -252,6 +256,7 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
|
||||
- `consent` (required): Consent recording ID
|
||||
- `name` (required): Name for the new voice
|
||||
- `ref_text` (optional): Transcript of the audio. Enables in-context voice cloning (higher quality).
|
||||
- `speaker_description` (optional): Free-form description of the voice (e.g. "warm narrator", "energetic presenter"). Stored as metadata.
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
@@ -262,18 +267,23 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
|
||||
"consent": "user_consent_id",
|
||||
"created_at": 1738660000,
|
||||
"mime_type": "audio/wav",
|
||||
"file_size": 1024000
|
||||
"file_size": 1024000,
|
||||
"ref_text": "The exact transcript of the audio sample.",
|
||||
"speaker_description": "warm narrator"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields `ref_text` and `speaker_description` are omitted when not provided at upload time.
|
||||
|
||||
**Usage Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/audio/voices \
|
||||
curl -X POST http://localhost:8091/v1/audio/voices \
|
||||
-F "audio_sample=@/path/to/voice_sample.wav" \
|
||||
-F "consent=user_consent_id" \
|
||||
-F "name=custom_voice_1" \
|
||||
-F "ref_text=The exact transcript of the audio sample."
|
||||
-F "ref_text=The exact transcript of the audio sample." \
|
||||
-F "speaker_description=warm narrator"
|
||||
```
|
||||
|
||||
### Endpoint
|
||||
@@ -290,7 +300,7 @@ This endpoint follows the [OpenAI Audio Speech API](https://platform.openai.com/
|
||||
```json
|
||||
{
|
||||
"input": "Text to synthesize",
|
||||
"speaker": "vivian",
|
||||
"voice": "vivian",
|
||||
"response_format": "wav",
|
||||
"task_type": "CustomVoice",
|
||||
"language": "Auto",
|
||||
@@ -310,7 +320,7 @@ Returns binary audio data with appropriate `Content-Type` header (e.g., `audio/w
|
||||
|
||||
### Voice and language (summary)
|
||||
|
||||
- **Speaker**: Use the `speaker` request field to select the speaker (e.g., `vivian`, `ryan`, `aiden`). List available speakers with `GET /v1/audio/voices`.
|
||||
- **Speaker**: Use the `voice` request field to select the speaker (e.g., `vivian`, `ryan`, `aiden`). List available speakers with `GET /v1/audio/voices`.
|
||||
- **Language**: Use the `language` field for the codec language tag (`Auto`, `Chinese`, `English`, etc.). Default is `Auto` for automatic detection.
|
||||
- **CustomVoice**: Requires a valid `voice` from the model’s speaker set. **VoiceDesign**: Use `instructions` to describe the voice. **Base**: Use `ref_audio` and `ref_text` for voice cloning.
|
||||
|
||||
@@ -322,7 +332,7 @@ Returns binary audio data with appropriate `Content-Type` header (e.g., `audio/w
|
||||
| ----------------- | ------ | -------------- | ----------------------------------------------------------- |
|
||||
| `input` | string | **required** | Text to synthesize |
|
||||
| `model` | string | server's model | Model to use (optional, should match server if specified) |
|
||||
| `speaker` | string | "vivian" | Speaker name (e.g., vivian, ryan, aiden) |
|
||||
| `voice` | string | "vivian" | Speaker name (e.g., vivian, ryan, aiden) |
|
||||
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus |
|
||||
| `speed` | float | 1.0 | Playback speed (0.25-4.0, not supported with `stream=true`) |
|
||||
|
||||
@@ -357,7 +367,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "Hello, how are you?",
|
||||
"speaker": "vivian",
|
||||
"voice": "vivian",
|
||||
"language": "English",
|
||||
"stream": true,
|
||||
"response_format": "pcm"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,6 +2,10 @@ import argparse
|
||||
import os
|
||||
|
||||
from vllm_omni.inputs.data import OmniPromptType
|
||||
from vllm_omni.model_executor.stage_input_processors.bagel import (
|
||||
GEN_THINK_SYSTEM_PROMPT,
|
||||
VLM_THINK_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
@@ -65,6 +69,17 @@ def parse_args():
|
||||
help="CFG parallel size: 1=batched (single GPU), 2=parallel with 2 branches (text CFG only), 3=parallel (3 GPUs).",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=None, help="Random seed for generation.")
|
||||
parser.add_argument(
|
||||
"--cfg-interval",
|
||||
type=float,
|
||||
nargs=2,
|
||||
default=None,
|
||||
help="CFG interval [start, end] (default: pipeline default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cfg-renorm-type", type=str, default=None, help="CFG renorm type: global, text_channel, channel"
|
||||
)
|
||||
parser.add_argument("--cfg-renorm-min", type=float, default=None, help="CFG renorm min")
|
||||
parser.add_argument(
|
||||
"--enable-diffusion-pipeline-profiler",
|
||||
action="store_true",
|
||||
@@ -76,6 +91,12 @@ def parse_args():
|
||||
default=None,
|
||||
help="Quantization method (e.g. 'fp8').",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--think",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable thinking mode: AR stage decodes <think>...</think> planning tokens before image generation.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
@@ -101,7 +122,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 = []
|
||||
|
||||
@@ -110,8 +131,12 @@ def main():
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
|
||||
omni_kwargs = {}
|
||||
if args.stage_configs_path:
|
||||
omni_kwargs["stage_configs_path"] = args.stage_configs_path
|
||||
stage_configs_path = args.stage_configs_path
|
||||
if args.think and stage_configs_path is None:
|
||||
stage_configs_path = "vllm_omni/model_executor/stage_configs/bagel_think.yaml"
|
||||
print(f"[Info] Think mode enabled, using stage config: {stage_configs_path}")
|
||||
if stage_configs_path:
|
||||
omni_kwargs["stage_configs_path"] = stage_configs_path
|
||||
|
||||
omni_kwargs.update(
|
||||
{
|
||||
@@ -136,7 +161,8 @@ def main():
|
||||
if not args.image_path or not os.path.exists(args.image_path):
|
||||
raise ValueError(f"img2img requires --image-path pointing to an existing file, got: {args.image_path}")
|
||||
loaded_image = Image.open(args.image_path).convert("RGB")
|
||||
final_prompt_text = f"<|fim_middle|><|im_start|>{p}<|im_end|>"
|
||||
think_prefix = f"<|im_start|>{GEN_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
|
||||
final_prompt_text = f"{think_prefix}<|fim_middle|><|im_start|>{p}<|im_end|>"
|
||||
prompt_dict = {
|
||||
"prompt": final_prompt_text,
|
||||
"multi_modal_data": {"img2img": loaded_image},
|
||||
@@ -148,7 +174,10 @@ def main():
|
||||
elif args.modality == "img2text":
|
||||
if args.image_path:
|
||||
loaded_image = Image.open(args.image_path).convert("RGB")
|
||||
final_prompt_text = f"<|im_start|>user\n<|image_pad|>\n{p}<|im_end|>\n<|im_start|>assistant\n"
|
||||
think_prefix = f"<|im_start|>system\n{VLM_THINK_SYSTEM_PROMPT}<|im_end|>\n" if args.think else ""
|
||||
final_prompt_text = (
|
||||
f"{think_prefix}<|im_start|>user\n<|image_pad|>\n{p}<|im_end|>\n<|im_start|>assistant\n"
|
||||
)
|
||||
prompt_dict = {
|
||||
"prompt": final_prompt_text,
|
||||
"multi_modal_data": {"image": loaded_image},
|
||||
@@ -156,11 +185,13 @@ def main():
|
||||
}
|
||||
formatted_prompts.append(prompt_dict)
|
||||
elif args.modality == "text2text":
|
||||
final_prompt_text = f"<|im_start|>user\n{p}<|im_end|>\n<|im_start|>assistant\n"
|
||||
think_prefix = f"<|im_start|>{VLM_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
|
||||
final_prompt_text = f"{think_prefix}<|im_start|>{p}<|im_end|><|im_start|>"
|
||||
prompt_dict = {"prompt": final_prompt_text, "modalities": ["text"]}
|
||||
formatted_prompts.append(prompt_dict)
|
||||
else:
|
||||
final_prompt_text = f"<|im_start|>{p}<|im_end|>"
|
||||
think_prefix = f"<|im_start|>{GEN_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
|
||||
final_prompt_text = f"{think_prefix}<|im_start|>{p}<|im_end|>"
|
||||
prompt_dict = {"prompt": final_prompt_text, "modalities": ["image"]}
|
||||
if args.negative_prompt is not None:
|
||||
prompt_dict["negative_prompt"] = args.negative_prompt
|
||||
@@ -168,7 +199,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
|
||||
@@ -179,6 +209,12 @@ def main():
|
||||
"cfg_text_scale": args.cfg_text_scale,
|
||||
"cfg_img_scale": args.cfg_img_scale,
|
||||
}
|
||||
if args.cfg_interval is not None:
|
||||
extra["cfg_interval"] = tuple(args.cfg_interval)
|
||||
if args.cfg_renorm_type is not None:
|
||||
extra["cfg_renorm_type"] = args.cfg_renorm_type
|
||||
if args.cfg_renorm_min is not None:
|
||||
extra["cfg_renorm_min"] = args.cfg_renorm_min
|
||||
if args.negative_prompt is not None:
|
||||
extra["negative_prompt"] = args.negative_prompt
|
||||
diffusion_params.extra_args = extra # type: ignore
|
||||
@@ -187,6 +223,13 @@ def main():
|
||||
|
||||
img_idx = 0
|
||||
for req_output in omni_outputs:
|
||||
if args.think:
|
||||
ro = getattr(req_output, "request_output", None)
|
||||
if ro and getattr(ro, "outputs", None):
|
||||
txt = "".join(getattr(o, "text", "") or "" for o in ro.outputs)
|
||||
if txt:
|
||||
print(txt)
|
||||
|
||||
images = getattr(req_output, "images", None)
|
||||
|
||||
if not images:
|
||||
@@ -195,6 +238,7 @@ def main():
|
||||
for j, img in enumerate(images):
|
||||
save_path = os.path.join(args.output, f"output_{img_idx}_{j}.png")
|
||||
img.save(save_path)
|
||||
print(f"[Output] Saved image to {save_path}")
|
||||
img_idx += 1
|
||||
|
||||
print(omni_outputs)
|
||||
|
||||
@@ -490,10 +490,6 @@ def main():
|
||||
if frames_np.ndim == 4 and frames_np.shape[-1] == 4:
|
||||
frames_np = frames_np[..., :3]
|
||||
|
||||
frames_np = np.clip(frames_np, 0.0, 1.0)
|
||||
frames_u8 = (frames_np * 255).round().clip(0, 255).astype("uint8")
|
||||
video_tensor = torch.from_numpy(frames_u8)
|
||||
|
||||
audio_out = None
|
||||
if audio is not None:
|
||||
if isinstance(audio, list):
|
||||
@@ -507,7 +503,7 @@ def main():
|
||||
audio_out = audio_out.float().cpu()
|
||||
|
||||
encode_video(
|
||||
video_tensor,
|
||||
frames_np,
|
||||
fps=fps,
|
||||
audio=audio_out,
|
||||
audio_sample_rate=args.audio_sample_rate if audio_out is not None else None,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# MagiHuman Generation
|
||||
|
||||
MagiHuman is an advanced, omni-modality model that generates both high-quality video and lip-synced audio from a text prompt.
|
||||
|
||||
Because MagiHuman is a very large model featuring a powerful DiT MoE backbone and a ~9B parameter T5Gemma text encoder, it natively supports **Tensor Parallelism (TP)** in vLLM-Omni to run efficiently across multi-GPU setups, reducing device memory bottlenecks.
|
||||
|
||||
## Setup
|
||||
|
||||
### Install MagiCompiler (recommended)
|
||||
|
||||
MagiHuman relies on [MagiCompiler](https://github.com/SandAI-org/MagiCompiler) for custom-op registration used by the DiT attention kernels. While the pipeline can fall back to stub implementations, installing MagiCompiler is **strongly recommended** for correct behaviour.
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/SandAI-org/MagiCompiler.git
|
||||
cd MagiCompiler
|
||||
|
||||
# System dependencies (optional, for FX graph visualization; Debian/Ubuntu)
|
||||
sudo apt update && sudo apt install -y graphviz
|
||||
|
||||
# Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install MagiCompiler
|
||||
pip install . # end users (recommended)
|
||||
# pip install -e . # developers (editable install)
|
||||
```
|
||||
|
||||
### Hardware requirements
|
||||
|
||||
Ensure your hardware has enough VRAM. For a standard node with 80GB GPUs, running with `--tensor-parallel-size 4` is recommended to shard both the MoE weights and the T5Gemma text encoder across 4 GPUs, reducing the per-GPU peak VRAM overhead significantly (by roughly ~13.5GB per GPU compared to single-device inference).
|
||||
|
||||
Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) for further details on allocating memory.
|
||||
|
||||
## Run Examples
|
||||
|
||||
Get into the example folder:
|
||||
```bash
|
||||
cd examples/offline_inference/magi_human
|
||||
```
|
||||
|
||||
### End-to-End Generation (Text to Video+Audio)
|
||||
|
||||
Generate a video with synchronized speech natively generated by the model.
|
||||
|
||||
```bash
|
||||
python end2end.py \
|
||||
--model /proj-tango-pvc/users/zhipeng.wang/workspace/models/daVinci-MagiHuman \
|
||||
--prompt "A young woman with long, wavy golden blonde hair..." \
|
||||
--tensor-parallel-size 4 \
|
||||
--output output_magihuman.mp4
|
||||
```
|
||||
|
||||
## Common Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `--model` | *(Required)* | Local model path or HuggingFace ID |
|
||||
| `--prompt` | *(built-in demo prompt)* | Highly detailed text prompt dictating visual look and dialogue text |
|
||||
| `--tensor-parallel-size` | `4` | Tensor parallelism size (Number of GPUs) |
|
||||
| `--height` | `256` | Initial resolution height |
|
||||
| `--width` | `448` | Initial resolution width |
|
||||
| `--num-inference-steps` | `8` | Denoising steps |
|
||||
| `--seed` | `52` | Random seed |
|
||||
| `--output` | `output_magihuman.mp4` | Output video with audio path |
|
||||
|
||||
## Example materials
|
||||
|
||||
??? abstract "end2end.py"
|
||||
``````py
|
||||
--8<-- "examples/offline_inference/magi_human/end2end.py"
|
||||
``````
|
||||
@@ -0,0 +1,117 @@
|
||||
import argparse
|
||||
|
||||
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="End-to-end inference script for MagiHuman.")
|
||||
parser.add_argument("--model", type=str, required=True, help="Path or ID of the MagiHuman model.")
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
type=str,
|
||||
default="",
|
||||
help="Text prompt containing visual description, dialogue, and background sound.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tensor-parallel-size", "-tp", type=int, default=4, help="Tensor parallel size (number of GPUs)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=str, default="output_magihuman.mp4", help="Path to save the generated mp4 file."
|
||||
)
|
||||
parser.add_argument("--height", type=int, default=256, help="Video height.")
|
||||
parser.add_argument("--width", type=int, default=448, help="Video width.")
|
||||
parser.add_argument("--num-inference-steps", type=int, default=8, help="Number of denoising steps.")
|
||||
parser.add_argument("--seed", type=int, default=52, help="Random seed for generation.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
print(f"Initializing MagiHuman pipeline with TP={args.tensor_parallel_size}...")
|
||||
omni = Omni(
|
||||
model=args.model,
|
||||
init_timeout=1200,
|
||||
tensor_parallel_size=args.tensor_parallel_size,
|
||||
devices=list(range(args.tensor_parallel_size)),
|
||||
)
|
||||
|
||||
prompt = args.prompt
|
||||
if not prompt:
|
||||
prompt = (
|
||||
"A young woman with long, wavy golden blonde hair and bright blue eyes, "
|
||||
"wearing a fitted ivory silk blouse with a delicate lace collar, sits "
|
||||
"stationary in front of a softly lit, blurred warm-toned interior. Her "
|
||||
"overall disposition is warm, composed, and gently confident. The camera "
|
||||
"holds a static medium close-up, framing her from the shoulders up, "
|
||||
"with shallow depth of field keeping her face in sharp focus. Soft "
|
||||
"directional key light falls from the upper left, casting a gentle "
|
||||
"highlight along her cheekbone and nose bridge. She draws a quiet breath, "
|
||||
"the levator labii superiors relaxing as her lips part. She speaks in "
|
||||
"clear, warm, unhurried American English: "
|
||||
"\"The most beautiful things in life aren't things at all — "
|
||||
"they're moments, feelings, and the people who make you feel truly alive.\" "
|
||||
"Her jaw descends smoothly on each stressed syllable; the orbicularis oris "
|
||||
"shapes each vowel with precision. A faint, genuine smile engages the "
|
||||
"zygomaticus major, lifting her lip corners fractionally. Her brows rest "
|
||||
"in a soft, neutral arch throughout. She maintains steady, forward-facing "
|
||||
"eye contact. Head position remains level; no torso displacement occurs.\n\n"
|
||||
"Dialogue:\n"
|
||||
"<Young blonde woman, American English>: "
|
||||
"\"The most beautiful things in life aren't things at all — "
|
||||
"they're moments, feelings, and the people who make you feel truly alive.\"\n\n"
|
||||
"Background Sound:\n"
|
||||
"<Soft, warm indoor ambience with a faint distant piano melody>"
|
||||
)
|
||||
|
||||
sampling_params = OmniDiffusionSamplingParams(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
seed=args.seed,
|
||||
extra_args={
|
||||
"seconds": 5,
|
||||
"sr_height": 1080,
|
||||
"sr_width": 1920,
|
||||
"sr_num_inference_steps": 5,
|
||||
},
|
||||
)
|
||||
|
||||
print(f"Generating with prompt: {prompt[:80]}...")
|
||||
outputs = omni.generate(
|
||||
prompts=[prompt],
|
||||
sampling_params_list=[sampling_params],
|
||||
)
|
||||
|
||||
print(f"Generation complete. Output type: {type(outputs)}")
|
||||
if outputs:
|
||||
first = outputs[0]
|
||||
|
||||
if hasattr(first, "images") and first.images:
|
||||
video_frames = first.images[0]
|
||||
print(f"Video frames: shape={video_frames.shape}, dtype={video_frames.dtype}")
|
||||
|
||||
audio_waveform = None
|
||||
if hasattr(first, "multimodal_output") and first.multimodal_output:
|
||||
audio_waveform = first.multimodal_output.get("audio")
|
||||
if audio_waveform is not None:
|
||||
print(f"Audio waveform: shape={audio_waveform.shape}, dtype={audio_waveform.dtype}")
|
||||
|
||||
video_bytes = mux_video_audio_bytes(
|
||||
video_frames,
|
||||
audio_waveform,
|
||||
fps=25.0,
|
||||
audio_sample_rate=44100,
|
||||
)
|
||||
with open(args.output, "wb") as f:
|
||||
f.write(video_bytes)
|
||||
print(f"Saved MP4 ({len(video_bytes)} bytes) to {args.output}")
|
||||
print("SUCCESS: MagiHuman pipeline generation completed.")
|
||||
else:
|
||||
print("WARNING: No outputs returned.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
# OmniVoice
|
||||
|
||||
This directory contains an offline demo for running OmniVoice TTS models with vLLM Omni. It generates speech from text and saves WAV files locally.
|
||||
|
||||
## Model Overview
|
||||
|
||||
[OmniVoice](https://huggingface.co/k2-fsa/OmniVoice) is a zero-shot multilingual TTS model supporting 600+ languages. It uses a diffusion language model (Qwen3-0.6B backbone) with iterative masked unmasking to generate speech.
|
||||
|
||||
Three inference modes are supported:
|
||||
|
||||
- **Auto Voice**: Generate speech without any reference — the model picks a voice automatically.
|
||||
- **Voice Clone**: Clone a voice from a reference audio + transcription.
|
||||
- **Voice Design**: Control voice style via natural language instruction (e.g., "female, low pitch, british accent").
|
||||
|
||||
## Setup
|
||||
|
||||
Ensure the model is downloaded:
|
||||
|
||||
```bash
|
||||
huggingface-cli download k2-fsa/OmniVoice
|
||||
```
|
||||
|
||||
> **Note:** Voice cloning requires `transformers>=5.3.0` for `HiggsAudioV2TokenizerModel`. Auto voice and voice design modes work with `transformers>=4.57.0`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Auto voice (text only):
|
||||
|
||||
```bash
|
||||
python end2end.py --model k2-fsa/OmniVoice --text "Hello, this is a test."
|
||||
```
|
||||
|
||||
Voice design (with style instruction):
|
||||
|
||||
```bash
|
||||
python end2end.py --model k2-fsa/OmniVoice \
|
||||
--text "Hello, this is a test." \
|
||||
--instruct "female, low pitch, british accent"
|
||||
```
|
||||
|
||||
Voice clone (with reference audio):
|
||||
|
||||
```bash
|
||||
python end2end.py --model k2-fsa/OmniVoice \
|
||||
--text "Hello, this is a test." \
|
||||
--ref-audio ref.wav \
|
||||
--ref-text "This is the reference transcription."
|
||||
```
|
||||
|
||||
## Language Support
|
||||
|
||||
Specify a language for improved quality:
|
||||
|
||||
```bash
|
||||
python end2end.py --model k2-fsa/OmniVoice \
|
||||
--text "你好,这是一个测试。" \
|
||||
--lang zh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
OmniVoice uses a two-stage pipeline:
|
||||
|
||||
- **Stage 0 (Generator)**: Qwen3-0.6B transformer with 32-step iterative unmasking and classifier-free guidance. Generates 8-codebook audio tokens from text.
|
||||
- **Stage 1 (Decoder)**: HiggsAudioV2 RVQ quantizer + DAC acoustic decoder. Converts tokens to 24kHz waveform.
|
||||
|
||||
Both stages use `GPUGenerationWorker` with `OmniGenerationScheduler`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Output audio is saved to `output.wav` by default. Use `--output` to change the path.
|
||||
- The model estimates duration from text automatically via `RuleDurationEstimator`.
|
||||
- Use `--stage-init-timeout` to increase the stage initialization timeout for first-time model downloads.
|
||||
@@ -0,0 +1,164 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""End-to-end OmniVoice TTS inference via vLLM-Omni.
|
||||
|
||||
Supports:
|
||||
- Auto voice mode: text only → generated speech
|
||||
- Voice cloning mode: text + reference audio → cloned voice speech
|
||||
|
||||
Usage:
|
||||
# Auto voice
|
||||
python end2end.py --model k2-fsa/OmniVoice --text "Hello world"
|
||||
|
||||
# Voice cloning
|
||||
python end2end.py --model k2-fsa/OmniVoice --text "Hello" \
|
||||
--ref-audio ref.wav --ref-text "reference transcription"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
|
||||
def run_e2e():
|
||||
parser = argparse.ArgumentParser(description="OmniVoice E2E TTS inference")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default="k2-fsa/OmniVoice",
|
||||
help="Model name or path (HuggingFace or local)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stage-config",
|
||||
type=str,
|
||||
default="vllm_omni/model_executor/stage_configs/omnivoice.yaml",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--text",
|
||||
type=str,
|
||||
default="Hello, this is a test of the OmniVoice text to speech system.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ref-audio",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Reference audio for voice cloning (WAV file)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ref-text",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Transcription of reference audio",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lang",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Language code (e.g., 'en', 'zh')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--instruct",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Voice design instruction (e.g., 'female, low pitch, british accent')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default="output.wav",
|
||||
help="Output audio file path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stage-init-timeout",
|
||||
type=int,
|
||||
default=600,
|
||||
help="Stage initialization timeout in seconds",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.stage_config):
|
||||
raise FileNotFoundError(f"Stage config not found: {args.stage_config}")
|
||||
|
||||
print(f"Initializing OmniVoice with model={args.model}")
|
||||
|
||||
omni = Omni(
|
||||
model=args.model,
|
||||
stage_configs_path=args.stage_config,
|
||||
trust_remote_code=True,
|
||||
log_stats=True,
|
||||
)
|
||||
|
||||
print("Model initialized. Preparing inputs...")
|
||||
|
||||
# Build prompt
|
||||
mm_processor_kwargs = {}
|
||||
multi_modal_data = {}
|
||||
|
||||
if args.ref_audio:
|
||||
if not os.path.exists(args.ref_audio):
|
||||
raise FileNotFoundError(f"Reference audio not found: {args.ref_audio}")
|
||||
|
||||
import librosa
|
||||
|
||||
audio_signal, sr = librosa.load(args.ref_audio, sr=None)
|
||||
multi_modal_data["audio"] = (audio_signal.astype(np.float32), sr)
|
||||
mm_processor_kwargs["ref_text"] = args.ref_text or ""
|
||||
mm_processor_kwargs["sample_rate"] = sr
|
||||
|
||||
if args.lang:
|
||||
mm_processor_kwargs["lang"] = args.lang
|
||||
if args.instruct:
|
||||
mm_processor_kwargs["instruct"] = args.instruct
|
||||
|
||||
prompts = {"prompt": args.text}
|
||||
if multi_modal_data:
|
||||
prompts["multi_modal_data"] = multi_modal_data
|
||||
if mm_processor_kwargs:
|
||||
prompts["mm_processor_kwargs"] = mm_processor_kwargs
|
||||
|
||||
sampling_params_list = [OmniDiffusionSamplingParams()]
|
||||
|
||||
print(f"Generating speech for: {args.text}")
|
||||
|
||||
outputs = list(omni.generate(prompts, sampling_params_list=sampling_params_list))
|
||||
|
||||
print(f"Received {len(outputs)} outputs.")
|
||||
for i, output in enumerate(outputs):
|
||||
try:
|
||||
ro = output.request_output
|
||||
if ro is None:
|
||||
print("No request_output found.")
|
||||
continue
|
||||
|
||||
mm = getattr(ro, "multimodal_output", None)
|
||||
if not mm and ro.outputs:
|
||||
mm = getattr(ro.outputs[0], "multimodal_output", None)
|
||||
|
||||
if mm:
|
||||
print(f"Multimodal output keys: {mm.keys()}")
|
||||
if "audio" in mm:
|
||||
audio_out = mm["audio"]
|
||||
sr = mm.get("sr", 24000)
|
||||
if isinstance(audio_out, np.ndarray):
|
||||
audio_np = audio_out
|
||||
else:
|
||||
audio_np = audio_out.cpu().numpy().squeeze()
|
||||
out_path = args.output if i == 0 else f"output_{i}.wav"
|
||||
sf.write(out_path, audio_np, sr)
|
||||
print(f"Saved audio to {out_path} ({sr}Hz, {len(audio_np) / sr:.2f}s)")
|
||||
else:
|
||||
print("No multimodal output found.")
|
||||
except Exception as e:
|
||||
print(f"Error inspecting output: {e}")
|
||||
|
||||
omni.close()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_e2e()
|
||||
@@ -33,6 +33,7 @@ This folder provides several entrypoints for experimenting with text-to-image di
|
||||
| `black-forest-labs/FLUX.2-klein-4B` | 1024 x 1024 | 72.7 | 14.9 |
|
||||
| `black-forest-labs/FLUX.2-klein-9B` | 1024 x 1024 | 37.1 | 32.3 |
|
||||
| `black-forest-labs/FLUX.2-dev` | 1024 x 1024 | 65.7 | >80 (CPU offload required) |
|
||||
| `HunyuanImage-3.0` | 1024 x 1024 | 80.0 (TP≥3) | 160 |
|
||||
|
||||
!!! info
|
||||
*Peak VRAM: based on basic single-card usage, batch size =1, without any acceleration/optimization features. FLUX.2-dev requires `--enable-cpu-offload` on a single 80 GiB GPU.
|
||||
@@ -90,6 +91,8 @@ python text_to_image.py \
|
||||
| `--enable-cpu-offload` | flag | off | Enable CPU offloading for diffusion models |
|
||||
| `--lora-path` | str | — | Path to PEFT LoRA adapter folder |
|
||||
| `--lora-scale` | float | `1.0` | Scale factor for LoRA weights |
|
||||
| `--use-system-prompt` | str | `None` | System prompt preset: `en_unified`, `en_vanilla`, `en_recaption`, `en_think_recaption`, `dynamic`, `None`, or custom text. Recommended: `en_unified`. Only for HunyuanImage-3.0.|
|
||||
| `--system-prompt` | str | `None` | Custom system prompt text. Only used when `--use-system-prompt` is set to `custom`. Only for HunyuanImage-3.0.|
|
||||
|
||||
**NextStep-1.1 specific arguments:**
|
||||
|
||||
|
||||
@@ -242,6 +242,19 @@ def parse_args() -> argparse.Namespace:
|
||||
action="store_true",
|
||||
help="Enable logging of diffusion pipeline stats.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-system-prompt",
|
||||
type=str,
|
||||
default=None,
|
||||
choices=["None", "dynamic", "en_vanilla", "en_recaption", "en_think_recaption", "en_unified", "custom"],
|
||||
help="System prompt preset for generation. Recommended: en_unified.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--system-prompt",
|
||||
type=str,
|
||||
default=None,
|
||||
help=("Custom system prompt. Used when --use-system-prompt is custom. "),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -382,13 +395,13 @@ def main():
|
||||
)
|
||||
|
||||
generation_start = time.perf_counter()
|
||||
|
||||
extra_args = {
|
||||
"timesteps_shift": args.timesteps_shift,
|
||||
"cfg_schedule": args.cfg_schedule,
|
||||
"use_norm": args.use_norm,
|
||||
"use_system_prompt": args.use_system_prompt,
|
||||
"system_prompt": args.system_prompt,
|
||||
}
|
||||
|
||||
if lora_request:
|
||||
extra_args["lora_request"] = lora_request
|
||||
extra_args["lora_scale"] = args.lora_scale
|
||||
|
||||
@@ -56,8 +56,13 @@ def parse_args() -> argparse.Namespace:
|
||||
"Examples: Wan-AI/Wan2.2-T2V-A14B-Diffusers, "
|
||||
"hunyuanvideo-community/HunyuanVideo-1.5-480p_t2v",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model-class-name",
|
||||
default=None,
|
||||
help="Override model class name (e.g., LTX2TwoStagesVideoPipeline).",
|
||||
)
|
||||
parser.add_argument("--prompt", default="A serene lakeside sunrise with mist over the water.", help="Text prompt.")
|
||||
parser.add_argument("--negative-prompt", default="", help="Negative prompt (Wan2.2 only).")
|
||||
parser.add_argument("--negative-prompt", default="", help="Negative prompt.")
|
||||
parser.add_argument("--seed", type=int, default=42, help="Random seed.")
|
||||
parser.add_argument("--guidance-scale", type=float, default=None, help="CFG scale. Default: model-specific.")
|
||||
parser.add_argument(
|
||||
@@ -185,6 +190,7 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
model_class_name = args.model_class_name
|
||||
|
||||
preset = _detect_preset(args.model)
|
||||
for key, default_val in preset.items():
|
||||
@@ -229,6 +235,7 @@ def main():
|
||||
enable_cpu_offload=args.enable_cpu_offload,
|
||||
parallel_config=parallel_config,
|
||||
enforce_eager=args.enforce_eager,
|
||||
model_class_name=model_class_name,
|
||||
cache_backend=args.cache_backend,
|
||||
cache_config=cache_config,
|
||||
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# VACE Video Generation
|
||||
|
||||
[VACE](https://github.com/ali-vilab/VACE) (Video All-in-one Creation Engine) supports multiple video tasks through a single model.
|
||||
|
||||
| Model | Architecture | Model Weights (bf16) | HuggingFace |
|
||||
|-------|-------------|----------------------|-------------|
|
||||
| Wan2.1-VACE (1.3B) | Wan2.1 | ~10 GB | [Wan-AI/Wan2.1-VACE-1.3B-diffusers](https://huggingface.co/Wan-AI/Wan2.1-VACE-1.3B-diffusers) |
|
||||
| Wan2.1-VACE (14B) | Wan2.1 | ~38 GB | [Wan-AI/Wan2.1-VACE-14B-diffusers](https://huggingface.co/Wan-AI/Wan2.1-VACE-14B-diffusers) |
|
||||
|
||||
## Text-to-Video (T2V)
|
||||
|
||||
```bash
|
||||
python vace_video_generation.py \
|
||||
--mode t2v \
|
||||
--prompt "A sleek robot stands in a vast warehouse filled with boxes" \
|
||||
--height 480 --width 832 --num-frames 81 \
|
||||
--num-inference-steps 30 --guidance-scale 5.0 --flow-shift 5.0 \
|
||||
--output t2v_output.mp4
|
||||
```
|
||||
|
||||
## Image-to-Video (I2V)
|
||||
|
||||
First frame is kept, remaining frames are generated:
|
||||
|
||||
```bash
|
||||
python vace_video_generation.py \
|
||||
--mode i2v \
|
||||
--image astronaut.jpg \
|
||||
--prompt "An astronaut emerging from a cracked egg on the moon" \
|
||||
--height 480 --width 832 --num-frames 81 \
|
||||
--output i2v_output.mp4
|
||||
```
|
||||
|
||||
## First-Last-Frame Interpolation (FLF2V)
|
||||
|
||||
```bash
|
||||
python vace_video_generation.py \
|
||||
--mode flf2v \
|
||||
--image first_frame.jpg --last-image last_frame.jpg \
|
||||
--prompt "A bird takes off from a branch and lands on another" \
|
||||
--height 512 --width 512 --num-frames 81 \
|
||||
--output flf2v_output.mp4
|
||||
```
|
||||
|
||||
## Inpainting
|
||||
|
||||
Center vertical stripe is masked and regenerated:
|
||||
|
||||
```bash
|
||||
python vace_video_generation.py \
|
||||
--mode inpaint \
|
||||
--image scene.jpg \
|
||||
--prompt "Shrek walks out of a building" \
|
||||
--height 480 --width 832 --num-frames 81 \
|
||||
--output inpaint_output.mp4
|
||||
```
|
||||
|
||||
## Reference Image-guided (R2V)
|
||||
|
||||
```bash
|
||||
python vace_video_generation.py \
|
||||
--mode r2v \
|
||||
--image reference.jpg \
|
||||
--prompt "Camera slowly zooms out from the character" \
|
||||
--height 480 --width 832 --num-frames 81 \
|
||||
--output r2v_output.mp4
|
||||
```
|
||||
|
||||
## Key Arguments
|
||||
|
||||
- `--mode`: VACE task mode (`t2v`, `i2v`, `flf2v`, `inpaint`, `r2v`).
|
||||
- `--model`: Model ID (default: `Wan-AI/Wan2.1-VACE-1.3B-diffusers`).
|
||||
- `--image`: Input image for I2V, inpainting, and R2V modes.
|
||||
- `--last-image`: Last frame image for FLF2V mode.
|
||||
- `--prompt`: Text description of desired video.
|
||||
- `--height/--width`: Output resolution (default 480x832). Dimensions should be multiples of 16.
|
||||
- `--num-frames`: Number of frames (default 81).
|
||||
- `--guidance-scale`: CFG scale (default 5.0).
|
||||
- `--flow-shift`: Scheduler flow shift (default 5.0).
|
||||
- `--num-inference-steps`: Number of denoising steps (default 30).
|
||||
- `--fps`: Frames per second for the saved MP4 (default 16).
|
||||
- `--output`: Path to save the generated video.
|
||||
- `--vae-use-tiling`: Enable VAE tiling for memory optimization.
|
||||
- `--ulysses-degree`: Ulysses sequence parallelism degree for multi-GPU.
|
||||
- `--cfg-parallel-size`: CFG parallel size for multi-GPU.
|
||||
- `--tensor-parallel-size`: Tensor parallel size.
|
||||
|
||||
> If you encounter OOM errors, try `--vae-use-tiling` or multi-GPU parallelism options.
|
||||
@@ -0,0 +1,209 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""VACE video generation example.
|
||||
|
||||
VACE (Video All-in-one Creation Engine) supports multiple video tasks:
|
||||
- T2V: Text-to-Video
|
||||
- I2V: Image-to-Video (first frame conditioning)
|
||||
- V2LF: Video-to-Last-Frame
|
||||
- FLF2V: First-Last-Frame interpolation
|
||||
- Inpainting: Masked region generation
|
||||
- R2V: Reference image-guided generation
|
||||
|
||||
Usage examples:
|
||||
# T2V (text-to-video)
|
||||
python vace_video_generation.py --mode t2v --prompt "A robot in a warehouse"
|
||||
|
||||
# I2V (image-to-video, first frame kept)
|
||||
python vace_video_generation.py --mode i2v --image input.jpg --prompt "..."
|
||||
|
||||
# FLF2V (first-last frame interpolation)
|
||||
python vace_video_generation.py --mode flf2v --image first.jpg --last-image last.jpg
|
||||
|
||||
# R2V (reference image guided)
|
||||
python vace_video_generation.py --mode r2v --image ref.jpg --prompt "..."
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import PIL.Image
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="VACE video generation.")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="Wan-AI/Wan2.1-VACE-14B-diffusers",
|
||||
help="VACE model ID or local path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
default="t2v",
|
||||
choices=["t2v", "i2v", "v2lf", "flf2v", "inpaint", "r2v"],
|
||||
help="Generation mode.",
|
||||
)
|
||||
parser.add_argument("--prompt", default="A cat walking in a garden", help="Text prompt.")
|
||||
parser.add_argument("--negative-prompt", default="", help="Negative prompt.")
|
||||
parser.add_argument("--image", type=str, default=None, help="Input image path (for I2V, R2V, FLF2V, inpaint).")
|
||||
parser.add_argument("--last-image", type=str, default=None, help="Last frame image path (for FLF2V).")
|
||||
parser.add_argument("--video-dir", type=str, default=None, help="Directory of video frames (for inpaint).")
|
||||
parser.add_argument("--seed", type=int, default=42, help="Random seed.")
|
||||
parser.add_argument("--guidance-scale", type=float, default=5.0, help="CFG guidance scale.")
|
||||
parser.add_argument("--height", type=int, default=480, help="Video height.")
|
||||
parser.add_argument("--width", type=int, default=832, help="Video width.")
|
||||
parser.add_argument("--num-frames", type=int, default=81, help="Number of frames.")
|
||||
parser.add_argument("--num-inference-steps", type=int, default=30, help="Sampling steps.")
|
||||
parser.add_argument("--flow-shift", type=float, default=5.0, help="Scheduler flow_shift.")
|
||||
parser.add_argument("--output", type=str, default="vace_output.mp4", help="Output video path.")
|
||||
parser.add_argument("--fps", type=int, default=16, help="Output video FPS.")
|
||||
parser.add_argument("--vae-use-tiling", action="store_true", default=True, help="Enable VAE tiling.")
|
||||
parser.add_argument("--enforce-eager", action="store_true", help="Disable torch.compile.")
|
||||
parser.add_argument("--ulysses-degree", type=int, default=1, help="Ulysses SP degree.")
|
||||
parser.add_argument("--ring-degree", type=int, default=1, help="Ring attention degree.")
|
||||
parser.add_argument("--cfg-parallel-size", type=int, default=1, choices=[1, 2], help="CFG parallel size.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def build_prompts(args):
|
||||
"""Build prompt dict with multi_modal_data based on mode."""
|
||||
h, w, nf = args.height, args.width, args.num_frames
|
||||
|
||||
gray = PIL.Image.new("RGB", (w, h), (128, 128, 128))
|
||||
mask_black = PIL.Image.new("L", (w, h), 0)
|
||||
mask_white = PIL.Image.new("L", (w, h), 255)
|
||||
|
||||
prompt_data = {
|
||||
"prompt": args.prompt,
|
||||
"negative_prompt": args.negative_prompt,
|
||||
}
|
||||
|
||||
if args.mode == "t2v":
|
||||
return prompt_data
|
||||
|
||||
if args.mode == "r2v":
|
||||
assert args.image, "--image required for R2V mode"
|
||||
ref_img = PIL.Image.open(args.image).convert("RGB").resize((w, h))
|
||||
prompt_data["multi_modal_data"] = {"reference_images": [ref_img]}
|
||||
return prompt_data
|
||||
|
||||
if args.mode == "i2v":
|
||||
assert args.image, "--image required for I2V mode"
|
||||
img = PIL.Image.open(args.image).convert("RGB").resize((w, h))
|
||||
prompt_data["multi_modal_data"] = {
|
||||
"video": [img] + [gray] * (nf - 1),
|
||||
"mask": [mask_black] + [mask_white] * (nf - 1),
|
||||
}
|
||||
return prompt_data
|
||||
|
||||
if args.mode == "v2lf":
|
||||
assert args.image, "--image required for V2LF mode"
|
||||
img = PIL.Image.open(args.image).convert("RGB").resize((w, h))
|
||||
prompt_data["multi_modal_data"] = {
|
||||
"video": [gray] * (nf - 1) + [img],
|
||||
"mask": [mask_white] * (nf - 1) + [mask_black],
|
||||
}
|
||||
return prompt_data
|
||||
|
||||
if args.mode == "flf2v":
|
||||
assert args.image and args.last_image, "--image and --last-image required for FLF2V"
|
||||
first = PIL.Image.open(args.image).convert("RGB").resize((w, h))
|
||||
last = PIL.Image.open(args.last_image).convert("RGB").resize((w, h))
|
||||
prompt_data["multi_modal_data"] = {
|
||||
"video": [first] + [gray] * (nf - 2) + [last],
|
||||
"mask": [mask_black] + [mask_white] * (nf - 2) + [mask_black],
|
||||
}
|
||||
return prompt_data
|
||||
|
||||
if args.mode == "inpaint":
|
||||
assert args.image, "--image required for inpaint mode"
|
||||
img = PIL.Image.open(args.image).convert("RGB").resize((w, h))
|
||||
d = 80
|
||||
frames, masks = [], []
|
||||
for _ in range(nf):
|
||||
base = np.array(img).copy()
|
||||
mask = PIL.Image.new("L", (w, h), 0)
|
||||
stripe = PIL.Image.new("L", (2 * d, h), 255)
|
||||
mask.paste(stripe, (w // 2 - d, 0))
|
||||
base[np.array(mask) > 128] = 128
|
||||
frames.append(PIL.Image.fromarray(base))
|
||||
masks.append(mask)
|
||||
prompt_data["multi_modal_data"] = {"video": frames, "mask": masks}
|
||||
return prompt_data
|
||||
|
||||
raise ValueError(f"Unknown mode: {args.mode}")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
|
||||
|
||||
parallel_config = DiffusionParallelConfig(
|
||||
ulysses_degree=args.ulysses_degree,
|
||||
ring_degree=args.ring_degree,
|
||||
cfg_parallel_size=args.cfg_parallel_size,
|
||||
)
|
||||
|
||||
omni = Omni(
|
||||
model=args.model,
|
||||
vae_use_tiling=args.vae_use_tiling,
|
||||
flow_shift=args.flow_shift,
|
||||
enforce_eager=args.enforce_eager,
|
||||
parallel_config=parallel_config,
|
||||
)
|
||||
|
||||
prompt_data = build_prompts(args)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"VACE {args.mode.upper()} Generation")
|
||||
print(f" Model: {args.model}")
|
||||
print(f" Size: {args.width}x{args.height}, {args.num_frames} frames, {args.num_inference_steps} steps")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
start = time.perf_counter()
|
||||
outputs = omni.generate(
|
||||
prompt_data,
|
||||
OmniDiffusionSamplingParams(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
guidance_scale=args.guidance_scale,
|
||||
generator=generator,
|
||||
),
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
video = outputs[0].images
|
||||
if isinstance(video, list):
|
||||
video = video[0]
|
||||
if isinstance(video, torch.Tensor):
|
||||
video = video.cpu().numpy()
|
||||
if video.ndim == 5:
|
||||
video = video[0]
|
||||
print(f"Output shape: {video.shape}, Time: {elapsed:.1f}s")
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from diffusers.utils import export_to_video
|
||||
|
||||
if np.issubdtype(video.dtype, np.integer):
|
||||
video = video.astype(np.float32) / 255.0
|
||||
export_to_video(list(video), str(output_path), fps=args.fps)
|
||||
print(f"Saved to {output_path}")
|
||||
|
||||
omni.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -82,7 +82,6 @@ def main(output_dir: str):
|
||||
|
||||
data = {
|
||||
"_class_name": "DreamIDOmniPipeline",
|
||||
"fusion": "DreamID-Omni/dreamid_omni.safetensors",
|
||||
}
|
||||
|
||||
with open(os.path.join(output_dir, "model_index.json"), "w", encoding="utf-8") as f:
|
||||
@@ -90,6 +89,12 @@ def main(output_dir: str):
|
||||
|
||||
print(f"model_index.json created at {os.path.join(output_dir, 'model_index.json')}")
|
||||
|
||||
transformer_dir = os.path.join(output_dir, "transformer")
|
||||
os.makedirs(transformer_dir, exist_ok=True)
|
||||
with open(os.path.join(transformer_dir, "config.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"fusion": "DreamID-Omni/dreamid_omni.safetensors"}, f)
|
||||
print(f"transformer/config.json created at {os.path.join(transformer_dir, 'config.json')}")
|
||||
|
||||
# now we download the dependency code
|
||||
download_dependency()
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ dreamid_omni/
|
||||
│ ├── models_t5_umt5-xxl-enc-bf16.pth
|
||||
│ ├── Wan2.2_VAE.pth
|
||||
│
|
||||
├── model_index.json # create by download_dreamid_omni.py
|
||||
├── model_index.json
|
||||
└── transformer/
|
||||
└── config.json # create by download_dreamid_omni.py
|
||||
```
|
||||
|
||||
### Run the Inference
|
||||
|
||||
@@ -132,8 +132,8 @@ def main() -> None:
|
||||
if not outputs:
|
||||
raise RuntimeError("No output returned from DreamID-Omni.")
|
||||
output = outputs[0].request_output
|
||||
generated_video = output[0].images[0][0]
|
||||
generated_audio = output[0].images[0][1]
|
||||
generated_video = output.images[0][0]
|
||||
generated_audio = output.images[0][1]
|
||||
try:
|
||||
from dreamid_omni.utils.io_utils import save_video
|
||||
except Exception as e:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# OmniVoice
|
||||
|
||||
## Model Overview
|
||||
|
||||
| Model | Description |
|
||||
|-------|-------------|
|
||||
| `k2-fsa/OmniVoice` | Zero-shot multilingual TTS (600+ languages) with diffusion language model (Qwen3-0.6B backbone) |
|
||||
|
||||
> **Note:** Requires `transformers>=5.3.0` for voice cloning (HiggsAudioV2 tokenizer). Auto voice and voice design work with `transformers>=4.57.0`.
|
||||
|
||||
## Launch the Server
|
||||
|
||||
```bash
|
||||
vllm serve k2-fsa/OmniVoice \
|
||||
--omni \
|
||||
--port 8091 \
|
||||
--trust-remote-code
|
||||
```
|
||||
|
||||
Or use the convenience script:
|
||||
|
||||
```bash
|
||||
./run_server.sh
|
||||
```
|
||||
|
||||
## Send TTS Request
|
||||
|
||||
### Using curl
|
||||
|
||||
```bash
|
||||
# Basic TTS (auto voice)
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "Hello, how are you?",
|
||||
"voice": "default",
|
||||
"response_format": "wav"
|
||||
}' --output output.wav
|
||||
```
|
||||
|
||||
### Using Python
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
response = httpx.post(
|
||||
"http://localhost:8091/v1/audio/speech",
|
||||
json={
|
||||
"input": "Hello, how are you?",
|
||||
"voice": "default",
|
||||
"response_format": "wav",
|
||||
},
|
||||
timeout=300.0,
|
||||
)
|
||||
|
||||
with open("output.wav", "wb") as f:
|
||||
f.write(response.content)
|
||||
```
|
||||
|
||||
### Using OpenAI SDK
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url="http://localhost:8091/v1", api_key="none")
|
||||
|
||||
response = client.audio.speech.create(
|
||||
model="k2-fsa/OmniVoice",
|
||||
voice="default",
|
||||
input="Hello, how are you?",
|
||||
)
|
||||
|
||||
response.stream_to_file("output.wav")
|
||||
```
|
||||
|
||||
### Using the CLI Client
|
||||
|
||||
```bash
|
||||
cd examples/online_serving/omnivoice
|
||||
|
||||
# Basic TTS
|
||||
python speech_client.py --text "Hello, how are you?"
|
||||
|
||||
# Specify language for improved quality
|
||||
python speech_client.py --text "Bonjour, comment allez-vous?" --language French
|
||||
```
|
||||
|
||||
The CLI client supports:
|
||||
|
||||
- `--api-base`: API base URL (default: `http://localhost:8091`)
|
||||
- `--model` (or `-m`): Model name (default: `k2-fsa/OmniVoice`)
|
||||
- `--text`: Text to synthesize (required)
|
||||
- `--response-format`: Audio format: wav, mp3, flac, pcm, aac, opus (default: wav)
|
||||
- `--language`: Language hint (default: Auto)
|
||||
- `--output` (or `-o`): Output file path (default: `omnivoice_output.wav`)
|
||||
|
||||
## Inference Modes
|
||||
|
||||
OmniVoice supports three inference modes. Currently, **auto voice** is supported through the online Speech API. Voice cloning and voice design are available via offline inference (see `examples/offline_inference/omnivoice/`).
|
||||
|
||||
| Mode | Description | Online API | Offline |
|
||||
|------|-------------|:----------:|:-------:|
|
||||
| Auto Voice | Generate speech without reference | Yes | Yes |
|
||||
| Voice Clone | Clone from reference audio + transcript | - | Yes |
|
||||
| Voice Design | Control style via natural language instruction | - | Yes |
|
||||
|
||||
## Architecture
|
||||
|
||||
OmniVoice uses a single-stage diffusion pipeline:
|
||||
|
||||
- **Stage 0 (Generator)**: Qwen3-0.6B transformer with 32-step iterative masked unmasking and classifier-free guidance. Generates 8-codebook audio tokens from text, then decodes to 24kHz waveform via HiggsAudioV2 RVQ quantizer + DAC acoustic decoder.
|
||||
|
||||
## API Parameters
|
||||
|
||||
OmniVoice uses the standard `/v1/audio/speech` endpoint. See the [Speech API reference](https://docs.vllm.ai/projects/vllm-omni/en/latest/serving/speech_api/) for full documentation.
|
||||
|
||||
Key parameters:
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `input` | string | **required** | Text to synthesize |
|
||||
| `voice` | string | "default" | Voice name |
|
||||
| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus |
|
||||
| `speed` | float | 1.0 | Playback speed (0.25-4.0) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **TTS model did not produce audio output**: Ensure the model is fully downloaded (`huggingface-cli download k2-fsa/OmniVoice`)
|
||||
2. **Connection refused**: Make sure the server is running on the correct port
|
||||
3. **Out of memory**: Reduce `--gpu-memory-utilization` (default stage config uses 0.5)
|
||||
4. **Slow first request**: The model performs warmup on first inference; subsequent requests are faster
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Launch vLLM-Omni server for OmniVoice TTS
|
||||
#
|
||||
# Usage:
|
||||
# ./run_server.sh
|
||||
# CUDA_VISIBLE_DEVICES=0 ./run_server.sh
|
||||
|
||||
set -e
|
||||
|
||||
MODEL="${MODEL:-k2-fsa/OmniVoice}"
|
||||
PORT="${PORT:-8091}"
|
||||
|
||||
echo "Starting OmniVoice server with model: $MODEL"
|
||||
|
||||
vllm serve "$MODEL" \
|
||||
--host 0.0.0.0 \
|
||||
--port "$PORT" \
|
||||
--trust-remote-code \
|
||||
--omni
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Client for OmniVoice TTS via /v1/audio/speech endpoint.
|
||||
|
||||
Examples:
|
||||
# Basic TTS (auto voice)
|
||||
python speech_client.py --text "Hello, how are you?"
|
||||
|
||||
# Specify language
|
||||
python speech_client.py --text "Bonjour, comment allez-vous?" --language French
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_API_BASE = "http://localhost:8091"
|
||||
DEFAULT_API_KEY = "EMPTY"
|
||||
|
||||
|
||||
def run_tts(args) -> None:
|
||||
"""Generate speech via /v1/audio/speech API."""
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"input": args.text,
|
||||
"voice": "default",
|
||||
"response_format": args.response_format,
|
||||
}
|
||||
|
||||
if args.language:
|
||||
payload["language"] = args.language
|
||||
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Text: {args.text}")
|
||||
if args.language:
|
||||
print(f"Language: {args.language}")
|
||||
print("Generating audio...")
|
||||
|
||||
api_url = f"{args.api_base}/v1/audio/speech"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {args.api_key}",
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
response = client.post(api_url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: {response.status_code}")
|
||||
print(response.text)
|
||||
return
|
||||
|
||||
try:
|
||||
text = response.content.decode("utf-8")
|
||||
if text.startswith('{"error"'):
|
||||
print(f"Error: {text}")
|
||||
return
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
output_path = args.output or "omnivoice_output.wav"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
print(f"Audio saved to: {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OmniVoice TTS client")
|
||||
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL")
|
||||
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
|
||||
parser.add_argument("--model", "-m", default="k2-fsa/OmniVoice", help="Model name")
|
||||
parser.add_argument("--text", required=True, help="Text to synthesize")
|
||||
parser.add_argument("--language", default=None, help="Language hint (e.g., English, Chinese, French)")
|
||||
parser.add_argument(
|
||||
"--response-format",
|
||||
default="wav",
|
||||
choices=["wav", "mp3", "flac", "pcm", "aac", "opus"],
|
||||
help="Audio format (default: wav)",
|
||||
)
|
||||
parser.add_argument("--output", "-o", default=None, help="Output file path")
|
||||
args = parser.parse_args()
|
||||
run_tts(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -36,6 +36,45 @@ cd examples/online_serving/qwen3_omni
|
||||
python examples/online_serving/openai_chat_completion_client_for_multimodal_generation.py --model Qwen/Qwen3-Omni-30B-A3B-Instruct --query-type use_image --port 8091 --host "localhost"
|
||||
```
|
||||
|
||||
#### Realtime WebSocket client (`openai_realtime_client.py`)
|
||||
|
||||
[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://<host>:<port>/v1/realtime`**, uploads a local audio file as **PCM16 mono @ 16 kHz** chunks (OpenAI-style `input_audio_buffer.append` / `commit`), and prints **streaming transcription** (`transcription.delta` / `transcription.done`).
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
```bash
|
||||
pip install websockets librosa numpy
|
||||
```
|
||||
|
||||
(ffmpeg may be required by `librosa` for some formats; see the FAQ below.)
|
||||
|
||||
**From this directory** (`examples/online_serving/qwen3_omni`):
|
||||
|
||||
```bash
|
||||
python openai_realtime_client.py \
|
||||
--host localhost \
|
||||
--port 8091 \
|
||||
--model Qwen/Qwen3-Omni-30B-A3B-Instruct \
|
||||
--audio_path /path/to/your.wav
|
||||
```
|
||||
|
||||
If `--audio_path` is omitted, the script uses a bundled default clip (`mary_had_lamb` via vLLM assets).
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--host` | `localhost` | API server host |
|
||||
| `--port` | `8000` | API server port (match your `vllm serve` port, e.g. `8091`) |
|
||||
| `--model` | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | Must match the served model (also sent in `session.update`) |
|
||||
| `--audio_path` | *(optional)* | Path to input audio; resampled to 16 kHz mono inside the client |
|
||||
|
||||
Ensure the vLLM-Omni server is running with realtime support for this endpoint, for example:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
|
||||
```
|
||||
|
||||
The Python client supports the following command-line arguments:
|
||||
|
||||
- `--query-type` (or `-q`): Query type (default: `use_video`). Options: `text`, `use_audio`, `use_image`, `use_video`
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
This script demonstrates how to use the vLLM-Omni Realtime WebSocket API to perform
|
||||
audio transcription by uploading an audio file.
|
||||
|
||||
Before running this script, you must start the vLLM-Omni server with a realtime-capable
|
||||
model, for example:
|
||||
|
||||
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni
|
||||
|
||||
Requirements:
|
||||
- vllm with audio support
|
||||
- websockets
|
||||
- librosa
|
||||
- numpy
|
||||
|
||||
The script:
|
||||
1. Connects to the Realtime WebSocket endpoint
|
||||
2. Converts an audio file to PCM16 @ 16kHz
|
||||
3. Sends audio chunks to the server
|
||||
4. Receives and prints transcription as it streams
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
import websockets
|
||||
from vllm.assets.audio import AudioAsset
|
||||
|
||||
|
||||
def audio_to_pcm16_base64(audio_path: str) -> str:
|
||||
"""
|
||||
Load an audio file and convert it to base64-encoded PCM16 @ 16kHz.
|
||||
"""
|
||||
# Load audio and resample to 16kHz mono
|
||||
audio, _ = librosa.load(audio_path, sr=16000, mono=True)
|
||||
# Convert to PCM16
|
||||
pcm16 = (audio * 32767).astype(np.int16)
|
||||
# Encode as base64
|
||||
return base64.b64encode(pcm16.tobytes()).decode("utf-8")
|
||||
|
||||
|
||||
async def realtime_transcribe(audio_path: str, host: str, port: int, model: str):
|
||||
"""
|
||||
Connect to the Realtime API and transcribe an audio file.
|
||||
"""
|
||||
uri = f"ws://{host}:{port}/v1/realtime"
|
||||
|
||||
async with websockets.connect(uri) as ws:
|
||||
# Wait for session.created
|
||||
response = json.loads(await ws.recv())
|
||||
if response["type"] == "session.created":
|
||||
print(f"Session created: {response['id']}")
|
||||
else:
|
||||
print(f"Unexpected response: {response}")
|
||||
return
|
||||
|
||||
# Validate model
|
||||
await ws.send(json.dumps({"type": "session.update", "model": model}))
|
||||
|
||||
# Signal ready to start
|
||||
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
|
||||
|
||||
# Convert audio file to base64 PCM16
|
||||
print(f"Loading audio from: {audio_path}")
|
||||
audio_base64 = audio_to_pcm16_base64(audio_path)
|
||||
|
||||
# Send audio in chunks (4KB of raw audio = ~8KB base64)
|
||||
chunk_size = 4096
|
||||
audio_bytes = base64.b64decode(audio_base64)
|
||||
total_chunks = (len(audio_bytes) + chunk_size - 1) // chunk_size
|
||||
|
||||
print(f"Sending {total_chunks} audio chunks...")
|
||||
for i in range(0, len(audio_bytes), chunk_size):
|
||||
chunk = audio_bytes[i : i + chunk_size]
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(chunk).decode("utf-8"),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Signal all audio is sent
|
||||
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
|
||||
print("Audio sent. Waiting for transcription...\n")
|
||||
|
||||
# Receive transcription
|
||||
print("Transcription: ", end="", flush=True)
|
||||
while True:
|
||||
response = json.loads(await ws.recv())
|
||||
if response["type"] == "transcription.delta":
|
||||
print(response["delta"], end="", flush=True)
|
||||
elif response["type"] == "transcription.done":
|
||||
print(f"\n\nFinal transcription: {response['text']}")
|
||||
if response.get("usage"):
|
||||
print(f"Usage: {response['usage']}")
|
||||
break
|
||||
elif response["type"] == "error":
|
||||
print(f"\nError: {response['error']}")
|
||||
break
|
||||
|
||||
|
||||
def main(args):
|
||||
if args.audio_path:
|
||||
audio_path = args.audio_path
|
||||
else:
|
||||
# Use default audio asset
|
||||
audio_path = str(AudioAsset("mary_had_lamb").get_local_path())
|
||||
print(f"No audio path provided, using default: {audio_path}")
|
||||
|
||||
asyncio.run(realtime_transcribe(audio_path, args.host, args.port, args.model))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Realtime WebSocket Transcription Client")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default="Qwen/Qwen3-Omni-30B-A3B-Instruct",
|
||||
help="Model that is served and should be pinged.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio_path",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to the audio file to transcribe.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
help="vLLM-Omni server host (default: localhost)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="vLLM-Omni server port (default: 8000)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
main(args)
|
||||
@@ -233,6 +233,7 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
|
||||
- `consent` (required): Consent recording ID
|
||||
- `name` (required): Name for the new voice
|
||||
- `ref_text` (optional): Transcript of the audio. Enables in-context voice cloning (higher quality).
|
||||
- `speaker_description` (optional): Free-form description of the voice (e.g. "warm narrator", "energetic presenter").
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
@@ -243,18 +244,23 @@ Upload a new voice sample for voice cloning in Base task TTS requests.
|
||||
"consent": "user_consent_id",
|
||||
"created_at": 1738660000,
|
||||
"mime_type": "audio/wav",
|
||||
"file_size": 1024000
|
||||
"file_size": 1024000,
|
||||
"ref_text": "The exact transcript of the audio sample.",
|
||||
"speaker_description": "warm narrator"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields `ref_text` and `speaker_description` are omitted when not provided at upload time.
|
||||
|
||||
**Usage Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/audio/voices \
|
||||
-F "audio_sample=@/path/to/voice_sample.wav" \
|
||||
-F "consent=user_consent_id" \
|
||||
-F "name=custom_voice_1" \
|
||||
-F "ref_text=The exact transcript of the audio sample."
|
||||
-F "ref_text=The exact transcript of the audio sample." \
|
||||
-F "speaker_description=warm narrator"
|
||||
```
|
||||
|
||||
### Endpoint
|
||||
|
||||
@@ -71,7 +71,7 @@ def run_tts_generation(args) -> None:
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"input": args.text,
|
||||
"speaker": args.speaker,
|
||||
"voice": args.speaker,
|
||||
"response_format": args.response_format,
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ count, use `size` and `n` rather than `height`, `width`, or
|
||||
| `seed` | int | None | Random seed (reproducible) |
|
||||
| `negative_prompt` | str | None | Negative prompt |
|
||||
| `num_outputs_per_prompt` | int | 1 | Number of images to generate |
|
||||
| `use_system_prompt` | str | None | System prompt preset: `en_unified`, `en_vanilla`, `en_recaption`, `en_think_recaption`, `dynamic`, `None`, or custom text string. Only for HunyuanImage-3.0. |
|
||||
| `system_prompt` | str | None | Custom system prompt text. Only used when `use_system_prompt` is set to `custom`. Only for HunyuanImage-3.0. |
|
||||
|
||||
## Response Format
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ def generate_image(
|
||||
lora_name: str | None = None,
|
||||
lora_scale: float | None = None,
|
||||
lora_int_id: int | None = None,
|
||||
use_system_prompt: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""Generate an image using the images generation API.
|
||||
|
||||
@@ -45,6 +47,8 @@ def generate_image(
|
||||
lora_name: LoRA name (optional, defaults to path stem)
|
||||
lora_scale: LoRA scale factor (default: 1.0)
|
||||
lora_int_id: LoRA integer ID (optional, derived from path if not provided)
|
||||
use_system_prompt: System prompt for generation.
|
||||
system_prompt: Custom system prompt.
|
||||
|
||||
Returns:
|
||||
Image bytes or None if failed
|
||||
@@ -70,7 +74,10 @@ def generate_image(
|
||||
payload["negative_prompt"] = negative_prompt
|
||||
if seed is not None:
|
||||
payload["seed"] = seed
|
||||
|
||||
if use_system_prompt is not None:
|
||||
payload["use_system_prompt"] = use_system_prompt
|
||||
if system_prompt is not None:
|
||||
payload["system_prompt"] = system_prompt
|
||||
# Add LoRA if provided
|
||||
if lora_path:
|
||||
lora_body: dict = {
|
||||
@@ -128,9 +135,21 @@ def main():
|
||||
default=None,
|
||||
help="LoRA integer id (cache key). If omitted, the server derives a stable id from lora_path.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--use-system-prompt",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"System prompt for generation. Use predefined types: 'en_unified', 'en_vanilla', 'en_recaption', 'en_think_recaption', 'dynamic', or 'None'; Or provide custom text string directly. Recommended en_unified. "
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--system-prompt",
|
||||
type=str,
|
||||
default=None,
|
||||
help=("Custom system prompt. Used when --use-system-prompt is custom. "),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Generating image for: {args.prompt}")
|
||||
|
||||
image_bytes = generate_image(
|
||||
@@ -146,6 +165,8 @@ def main():
|
||||
lora_name=args.lora_name,
|
||||
lora_scale=args.lora_scale if args.lora_path else None,
|
||||
lora_int_id=args.lora_int_id if args.lora_path else None,
|
||||
use_system_prompt=args.use_system_prompt,
|
||||
system_prompt=args.system_prompt,
|
||||
)
|
||||
|
||||
if image_bytes:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -177,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",
|
||||
@@ -219,3 +223,4 @@ extend-ignore-identifiers-re = [
|
||||
ue = "ue"
|
||||
semantics = "semantics"
|
||||
fullset = "fullset"
|
||||
Vai = "Vai"
|
||||
@@ -1,4 +1,5 @@
|
||||
# Common dependencies for all platforms
|
||||
av>=14.0.0
|
||||
omegaconf>=2.3.0
|
||||
librosa>=0.11.0
|
||||
resampy>=0.4.3
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-r common.txt
|
||||
# MUSA platform dependencies
|
||||
torchada>=0.1.46
|
||||
onnxruntime>=1.23.2
|
||||
@@ -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:
|
||||
|
||||
+88
-48
@@ -48,6 +48,7 @@ from vllm.utils.network_utils import get_open_port
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -1065,7 +1066,7 @@ def convert_audio_to_text(audio_data):
|
||||
Convert base64 encoded audio data to text using speech recognition.
|
||||
"""
|
||||
audio_data = base64.b64decode(audio_data)
|
||||
output_path = f"./test_{int(time.time())}.wav"
|
||||
output_path = f"./test_{uuid.uuid4().hex}.wav"
|
||||
with open(output_path, "wb") as audio_file:
|
||||
audio_file.write(audio_data)
|
||||
|
||||
@@ -1089,8 +1090,24 @@ def _merge_base64_audio_to_segment(base64_list: list[str]):
|
||||
def _whisper_transcribe_in_current_process(output_path: str) -> str:
|
||||
import whisper
|
||||
|
||||
# Keep Whisper on CPU to avoid consuming GPU memory in tests.
|
||||
model = whisper.load_model("small", device="cpu")
|
||||
# Multi-GPU: use last visible device to avoid colliding with default device 0; single device uses 0.
|
||||
device_index = None
|
||||
if current_omni_platform.is_available():
|
||||
n = current_omni_platform.get_device_count()
|
||||
if n == 1:
|
||||
device_index = 0
|
||||
elif n > 1:
|
||||
device_index = n - 1
|
||||
|
||||
if device_index is not None:
|
||||
torch_device = current_omni_platform.get_torch_device(device_index)
|
||||
current_omni_platform.set_device(torch_device)
|
||||
device = str(torch_device)
|
||||
use_accelerator = True
|
||||
else:
|
||||
use_accelerator = False
|
||||
device = "cpu"
|
||||
model = whisper.load_model("small", device=device)
|
||||
try:
|
||||
text = model.transcribe(
|
||||
output_path,
|
||||
@@ -1101,6 +1118,9 @@ def _whisper_transcribe_in_current_process(output_path: str) -> str:
|
||||
finally:
|
||||
del model
|
||||
gc.collect()
|
||||
if use_accelerator:
|
||||
current_omni_platform.synchronize()
|
||||
current_omni_platform.empty_cache()
|
||||
|
||||
return text or ""
|
||||
|
||||
@@ -1126,18 +1146,6 @@ def convert_audio_bytes_to_text(raw_bytes: bytes) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def merge_base64_and_convert_to_text(base64_list):
|
||||
"""
|
||||
Merge a list of base64 encoded audio data and convert to text.
|
||||
"""
|
||||
merged_audio = _merge_base64_audio_to_segment(base64_list)
|
||||
output_path = f"./test_{uuid.uuid4().hex}.wav"
|
||||
merged_audio.export(output_path, format="wav")
|
||||
print(f"audio data is saved: {output_path}")
|
||||
text = convert_audio_file_to_text(output_path)
|
||||
return text
|
||||
|
||||
|
||||
def modify_stage_config(
|
||||
yaml_path: str,
|
||||
updates: dict[str, Any] = None,
|
||||
@@ -1722,7 +1730,7 @@ def _estimate_voice_gender_from_audio(audio_bytes: bytes) -> str:
|
||||
label = str(top.get("label", "")).lower()
|
||||
conf = float(top.get("score", 0.0))
|
||||
|
||||
if conf < 0.6:
|
||||
if conf < 0.5:
|
||||
gender = "unknown"
|
||||
# Some models use non-English labels (e.g., Russian). Normalize to 'male'/'female'.
|
||||
elif ("female" in label) or ("жен" in label):
|
||||
@@ -1751,6 +1759,34 @@ def _estimate_voice_gender_from_audio(audio_bytes: bytes) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
_PRESET_VOICE_GENDER_MAP: dict[str, str] = {
|
||||
"serena": "female",
|
||||
"uncle_fu": "male",
|
||||
"chelsie": "female",
|
||||
"clone": "female",
|
||||
"ethan": "male",
|
||||
}
|
||||
|
||||
|
||||
def _assert_preset_voice_gender_from_audio(
|
||||
audio_bytes: bytes | None,
|
||||
voice_name: str | None,
|
||||
) -> None:
|
||||
"""If ``voice_name`` matches a known preset, assert classifier gender matches (skip when unknown)."""
|
||||
if not voice_name or not audio_bytes:
|
||||
return
|
||||
key = str(voice_name).lower()
|
||||
expected_gender = _PRESET_VOICE_GENDER_MAP.get(key)
|
||||
if expected_gender is None:
|
||||
return
|
||||
estimated_gender = _estimate_voice_gender_from_audio(audio_bytes)
|
||||
print(f"Preset voice gender check: preset={key!r}, estimated={estimated_gender!r}, expected={expected_gender!r}")
|
||||
if estimated_gender != "unknown":
|
||||
assert estimated_gender == expected_gender, (
|
||||
f"{voice_name!r} is expected {expected_gender}, but estimated gender is {estimated_gender!r}"
|
||||
)
|
||||
|
||||
|
||||
# Threshold aligned with _compute_pcm_hnr_db docstring (clean clone vs distorted).
|
||||
_MIN_PCM_SPEECH_HNR_DB = 1.0
|
||||
|
||||
@@ -1817,6 +1853,12 @@ def assert_omni_response(response: OmniResponse, request_config: dict[str, Any],
|
||||
if "audio" in modalities:
|
||||
assert response.audio_content is not None, "No audio output is generated"
|
||||
print(f"audio content is: {response.audio_content}")
|
||||
speaker = request_config.get("speaker")
|
||||
if speaker:
|
||||
_assert_preset_voice_gender_from_audio(
|
||||
response.audio_bytes,
|
||||
speaker,
|
||||
)
|
||||
|
||||
if "text" in modalities:
|
||||
assert response.text_content is not None, "No text output is generated"
|
||||
@@ -1829,12 +1871,14 @@ def assert_omni_response(response: OmniResponse, request_config: dict[str, Any],
|
||||
keywords = keywords_dict.get(word_type)
|
||||
if "text" in modalities:
|
||||
if keywords:
|
||||
assert any(keyword in response.text_content.lower() for keyword in keywords), (
|
||||
text_lower = response.text_content.lower()
|
||||
assert any(str(kw).lower() in text_lower for kw in keywords), (
|
||||
"The output does not contain any of the keywords."
|
||||
)
|
||||
else:
|
||||
if keywords:
|
||||
assert any(keyword in response.audio_content.lower() for keyword in keywords), (
|
||||
audio_lower = response.audio_content.lower()
|
||||
assert any(str(kw).lower() in audio_lower for kw in keywords), (
|
||||
"The output does not contain any of the keywords."
|
||||
)
|
||||
|
||||
@@ -1888,24 +1932,12 @@ def assert_audio_speech_response(
|
||||
f"Transcript doesn't match input: similarity={similarity:.2f}, transcript='{transcript}'"
|
||||
)
|
||||
|
||||
# Voice gender consistency check:
|
||||
# Voice gender consistency check (preset names in ``_PRESET_VOICE_GENDER_MAP``).
|
||||
# When the estimator returns 'unknown', we treat it as inconclusive and do NOT fail the test.
|
||||
voice = (request_config.get("voice") or "").lower()
|
||||
if voice and response.audio_bytes:
|
||||
estimated_gender = _estimate_voice_gender_from_audio(response.audio_bytes)
|
||||
voice_gender_map = {
|
||||
# adjust this mapping to your actual voice names
|
||||
"serena": "female",
|
||||
"uncle_fu": "male",
|
||||
"clone": "female",
|
||||
}
|
||||
expected_gender = voice_gender_map.get(voice)
|
||||
if expected_gender is not None:
|
||||
print(f"Estimated voice gender from audio: {estimated_gender} (voice='{voice}')")
|
||||
if estimated_gender != "unknown":
|
||||
assert estimated_gender == expected_gender, (
|
||||
f"Voice '{voice}' is expected {expected_gender}, but estimated gender is '{estimated_gender}'"
|
||||
)
|
||||
_assert_preset_voice_gender_from_audio(
|
||||
response.audio_bytes,
|
||||
request_config.get("voice"),
|
||||
)
|
||||
|
||||
|
||||
def assert_diffusion_response(response: DiffusionResponse, request_config: dict[str, Any], run_level: str = None):
|
||||
@@ -2021,7 +2053,11 @@ class OpenAIClientHandler:
|
||||
|
||||
if audio_data or text_content:
|
||||
if audio_data:
|
||||
audio_content = merge_base64_and_convert_to_text(audio_data)
|
||||
merged_seg = _merge_base64_audio_to_segment(audio_data)
|
||||
wav_buf = BytesIO()
|
||||
merged_seg.export(wav_buf, format="wav")
|
||||
result.audio_bytes = wav_buf.getvalue()
|
||||
audio_content = convert_audio_bytes_to_text(result.audio_bytes)
|
||||
if audio_content and text_content:
|
||||
similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
|
||||
|
||||
@@ -2076,7 +2112,8 @@ class OpenAIClientHandler:
|
||||
|
||||
if audio_data or text_content:
|
||||
if audio_data:
|
||||
audio_content = convert_audio_to_text(audio_data)
|
||||
result.audio_bytes = base64.b64decode(audio_data)
|
||||
audio_content = convert_audio_bytes_to_text(result.audio_bytes)
|
||||
if audio_content and text_content:
|
||||
similarity = cosine_similarity_text(audio_content.lower(), text_content.lower())
|
||||
|
||||
@@ -2245,8 +2282,9 @@ class OpenAIClientHandler:
|
||||
request_config: Request configuration dictionary containing parameters like model, messages, stream.
|
||||
Optional ``use_audio_in_video`` (bool): when true, sets
|
||||
``extra_body["mm_processor_kwargs"] = {"use_audio_in_video": True}`` for Qwen-Omni video+audio
|
||||
extraction (merged with any existing ``extra_body`` / ``mm_processor_kwargs``).
|
||||
Optional ``extra_body`` (dict): passed through to ``chat.completions.create`` after merge.
|
||||
extraction.
|
||||
Optional top-level ``speaker`` (str): Qwen3-Omni preset TTS speaker name; sent as
|
||||
``extra_body["speaker"]`` to ``chat.completions.create``.
|
||||
request_num: Number of requests, defaults to 1 (single request)
|
||||
|
||||
Returns:
|
||||
@@ -2258,9 +2296,8 @@ class OpenAIClientHandler:
|
||||
modalities = request_config.get("modalities", ["text", "audio"])
|
||||
|
||||
extra_body: dict[str, Any] = {}
|
||||
raw_extra = request_config.get("extra_body")
|
||||
if raw_extra:
|
||||
extra_body.update(raw_extra)
|
||||
if "speaker" in request_config:
|
||||
extra_body["speaker"] = request_config["speaker"]
|
||||
if request_config.get("use_audio_in_video"):
|
||||
mm = dict(extra_body.get("mm_processor_kwargs") or {})
|
||||
mm["use_audio_in_video"] = True
|
||||
@@ -2292,12 +2329,15 @@ class OpenAIClientHandler:
|
||||
# Send concurrent requests: run create + process in worker so e2e_latency includes full round-trip.
|
||||
def _one_omni_request():
|
||||
start = time.perf_counter()
|
||||
chat_completion = self.client.chat.completions.create(
|
||||
model=request_config.get("model"),
|
||||
messages=request_config.get("messages"),
|
||||
modalities=modalities,
|
||||
stream=stream,
|
||||
)
|
||||
worker_kwargs: dict[str, Any] = {
|
||||
"model": request_config.get("model"),
|
||||
"messages": request_config.get("messages"),
|
||||
"modalities": modalities,
|
||||
"stream": stream,
|
||||
}
|
||||
if extra_body_arg is not None:
|
||||
worker_kwargs["extra_body"] = extra_body_arg
|
||||
chat_completion = self.client.chat.completions.create(**worker_kwargs)
|
||||
if stream:
|
||||
response = self._process_stream_omni_response(chat_completion)
|
||||
else:
|
||||
|
||||
@@ -88,7 +88,7 @@ runtime:
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
codec_chunk_frames: 25
|
||||
codec_left_context_frames: 25
|
||||
codec_left_context_frames: 72
|
||||
|
||||
edges:
|
||||
- from: 0
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Unit tests for DistributedAutoencoderKLWan encode parallel (CPU-only)."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
class _DummyConfig:
|
||||
def __init__(self, patch_size=None, scale_factor_temporal=4):
|
||||
self.patch_size = patch_size
|
||||
self.scale_factor_temporal = scale_factor_temporal
|
||||
|
||||
|
||||
class _DummyWanVae:
|
||||
"""Minimal mock of DistributedAutoencoderKLWan for testing encode_tile_split."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config=None,
|
||||
spatial_compression_ratio=8,
|
||||
tile_sample_min_height=256,
|
||||
tile_sample_min_width=256,
|
||||
tile_sample_stride_height=192,
|
||||
tile_sample_stride_width=192,
|
||||
):
|
||||
self.config = config or _DummyConfig()
|
||||
self.spatial_compression_ratio = spatial_compression_ratio
|
||||
self.tile_sample_min_height = tile_sample_min_height
|
||||
self.tile_sample_min_width = tile_sample_min_width
|
||||
self.tile_sample_stride_height = tile_sample_stride_height
|
||||
self.tile_sample_stride_width = tile_sample_stride_width
|
||||
self.dtype = torch.float32
|
||||
|
||||
# Mock caches
|
||||
self._enc_feat_map = None
|
||||
self._enc_conv_idx = [0]
|
||||
|
||||
def clear_cache(self):
|
||||
self._enc_feat_map = None
|
||||
self._enc_conv_idx = [0]
|
||||
|
||||
def encoder(self, x, feat_cache=None, feat_idx=None): # noqa: ARG002
|
||||
# Simple mock: just return the input
|
||||
return x
|
||||
|
||||
def quant_conv(self, x):
|
||||
return x
|
||||
|
||||
def blend_v(self, _a, b, _blend_extent):
|
||||
return b
|
||||
|
||||
def blend_h(self, _a, b, _blend_extent):
|
||||
return b
|
||||
|
||||
|
||||
def _import_encode_tile_split():
|
||||
"""Import the encode_tile_split method from the module."""
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import (
|
||||
DistributedAutoencoderKLWan,
|
||||
)
|
||||
|
||||
return DistributedAutoencoderKLWan.encode_tile_split
|
||||
|
||||
|
||||
def _import_encode_tile_exec():
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import (
|
||||
DistributedAutoencoderKLWan,
|
||||
)
|
||||
|
||||
return DistributedAutoencoderKLWan.encode_tile_exec
|
||||
|
||||
|
||||
def _import_encode_tile_merge():
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import (
|
||||
DistributedAutoencoderKLWan,
|
||||
)
|
||||
|
||||
return DistributedAutoencoderKLWan.encode_tile_merge
|
||||
|
||||
|
||||
class TestEncodeTileSplit:
|
||||
"""Tests for encode_tile_split method."""
|
||||
|
||||
def test_basic_split_without_patch_size(self):
|
||||
"""Test basic tile splitting without patch_size."""
|
||||
encode_tile_split = _import_encode_tile_split()
|
||||
|
||||
vae = _DummyWanVae(
|
||||
config=_DummyConfig(patch_size=None, scale_factor_temporal=4),
|
||||
spatial_compression_ratio=8,
|
||||
tile_sample_min_height=256,
|
||||
tile_sample_min_width=256,
|
||||
tile_sample_stride_height=192,
|
||||
tile_sample_stride_width=192,
|
||||
)
|
||||
|
||||
# Input: (B, C, T, H, W) = (1, 3, 5, 256, 256)
|
||||
x = torch.randn(1, 3, 5, 256, 256)
|
||||
|
||||
tiletask_list, grid_spec = encode_tile_split(vae, x)
|
||||
|
||||
# With stride 192 and input size 256, we should get:
|
||||
# Height: ceil(256/192) = 2 positions (0, 192) but 192+256 > 256, so only 1
|
||||
# Actually for i in range(0, 256, 192): i = 0, 192 but 192 is out of bounds
|
||||
# So we get 1x1 grid
|
||||
assert len(tiletask_list) >= 1
|
||||
assert grid_spec.grid_shape[0] >= 1
|
||||
assert grid_spec.grid_shape[1] >= 1
|
||||
|
||||
# Check temporal chunking: 5 frames -> 1 + (5-1)//4 = 2 chunks
|
||||
first_task = tiletask_list[0]
|
||||
assert len(first_task.tensor) == 2 # 2 temporal chunks
|
||||
|
||||
def test_split_with_patch_size_scales_coordinates(self):
|
||||
"""Test that patch_size properly scales tile coordinates."""
|
||||
encode_tile_split = _import_encode_tile_split()
|
||||
|
||||
# Without patch_size
|
||||
vae_no_patch = _DummyWanVae(
|
||||
config=_DummyConfig(patch_size=None, scale_factor_temporal=4),
|
||||
spatial_compression_ratio=8,
|
||||
tile_sample_min_height=256,
|
||||
tile_sample_min_width=256,
|
||||
tile_sample_stride_height=128,
|
||||
tile_sample_stride_width=128,
|
||||
)
|
||||
|
||||
# With patch_size=2 (simulating patchified input)
|
||||
vae_with_patch = _DummyWanVae(
|
||||
config=_DummyConfig(patch_size=2, scale_factor_temporal=4),
|
||||
spatial_compression_ratio=8,
|
||||
tile_sample_min_height=256,
|
||||
tile_sample_min_width=256,
|
||||
tile_sample_stride_height=128,
|
||||
tile_sample_stride_width=128,
|
||||
)
|
||||
|
||||
# Same patchified input size
|
||||
x = torch.randn(1, 3, 5, 256, 256)
|
||||
|
||||
tasks_no_patch, _ = encode_tile_split(vae_no_patch, x)
|
||||
tasks_with_patch, _ = encode_tile_split(vae_with_patch, x)
|
||||
|
||||
# With patch_size=2, stride becomes 128//2=64, so more tiles
|
||||
assert len(tasks_with_patch) >= len(tasks_no_patch)
|
||||
|
||||
def test_temporal_compression_from_config(self):
|
||||
"""Test that temporal compression ratio is read from config."""
|
||||
encode_tile_split = _import_encode_tile_split()
|
||||
|
||||
# temporal_compression=4 (default)
|
||||
vae_4x = _DummyWanVae(
|
||||
config=_DummyConfig(scale_factor_temporal=4),
|
||||
tile_sample_min_height=512,
|
||||
tile_sample_min_width=512,
|
||||
tile_sample_stride_height=512,
|
||||
tile_sample_stride_width=512,
|
||||
)
|
||||
|
||||
# temporal_compression=2
|
||||
vae_2x = _DummyWanVae(
|
||||
config=_DummyConfig(scale_factor_temporal=2),
|
||||
tile_sample_min_height=512,
|
||||
tile_sample_min_width=512,
|
||||
tile_sample_stride_height=512,
|
||||
tile_sample_stride_width=512,
|
||||
)
|
||||
|
||||
# 9 frames input
|
||||
x = torch.randn(1, 3, 9, 512, 512)
|
||||
|
||||
tasks_4x, _ = encode_tile_split(vae_4x, x)
|
||||
tasks_2x, _ = encode_tile_split(vae_2x, x)
|
||||
|
||||
# With 4x compression: 1 + (9-1)//4 = 3 chunks
|
||||
assert len(tasks_4x[0].tensor) == 3
|
||||
|
||||
# With 2x compression: 1 + (9-1)//2 = 5 chunks
|
||||
assert len(tasks_2x[0].tensor) == 5
|
||||
|
||||
def test_grid_spec_latent_dimensions(self):
|
||||
"""Test that grid_spec contains correct latent dimensions."""
|
||||
encode_tile_split = _import_encode_tile_split()
|
||||
|
||||
vae = _DummyWanVae(
|
||||
config=_DummyConfig(patch_size=None),
|
||||
spatial_compression_ratio=8,
|
||||
tile_sample_min_height=512,
|
||||
tile_sample_min_width=512,
|
||||
tile_sample_stride_height=512,
|
||||
tile_sample_stride_width=512,
|
||||
)
|
||||
|
||||
# Input: 512x512 with compression 8 -> 64x64 latent
|
||||
x = torch.randn(1, 3, 5, 512, 512)
|
||||
|
||||
_, grid_spec = encode_tile_split(vae, x)
|
||||
|
||||
assert grid_spec.tile_spec["latent_height"] == 64
|
||||
assert grid_spec.tile_spec["latent_width"] == 64
|
||||
|
||||
|
||||
class TestEncodeTileExec:
|
||||
"""Tests for encode_tile_exec method."""
|
||||
|
||||
def test_basic_exec(self):
|
||||
"""Test basic tile execution."""
|
||||
encode_tile_exec = _import_encode_tile_exec()
|
||||
|
||||
vae = _DummyWanVae()
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import (
|
||||
TileTask,
|
||||
)
|
||||
|
||||
# Create a simple task with 2 temporal chunks
|
||||
tile1 = torch.randn(1, 3, 1, 32, 32)
|
||||
tile2 = torch.randn(1, 3, 4, 32, 32)
|
||||
task = TileTask(tile_id=0, grid_coord=(0, 0), tensor=[tile1, tile2])
|
||||
|
||||
result = encode_tile_exec(vae, task)
|
||||
|
||||
# Result should concatenate temporal dimension
|
||||
assert result.shape[2] == 5 # 1 + 4 frames
|
||||
|
||||
|
||||
class TestEncodeTileMerge:
|
||||
"""Tests for encode_tile_merge method."""
|
||||
|
||||
def test_basic_merge(self):
|
||||
"""Test basic tile merging."""
|
||||
encode_tile_merge = _import_encode_tile_merge()
|
||||
|
||||
vae = _DummyWanVae()
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import (
|
||||
GridSpec,
|
||||
)
|
||||
|
||||
# Create 2x2 grid of tiles
|
||||
tile_00 = torch.ones(1, 16, 2, 32, 32) * 0
|
||||
tile_01 = torch.ones(1, 16, 2, 32, 32) * 1
|
||||
tile_10 = torch.ones(1, 16, 2, 32, 32) * 2
|
||||
tile_11 = torch.ones(1, 16, 2, 32, 32) * 3
|
||||
|
||||
coord_tensor_map = {
|
||||
(0, 0): tile_00,
|
||||
(0, 1): tile_01,
|
||||
(1, 0): tile_10,
|
||||
(1, 1): tile_11,
|
||||
}
|
||||
|
||||
grid_spec = GridSpec(
|
||||
split_dims=(3, 4),
|
||||
grid_shape=(2, 2),
|
||||
tile_spec={
|
||||
"latent_height": 48,
|
||||
"latent_width": 48,
|
||||
"blend_height": 8,
|
||||
"blend_width": 8,
|
||||
"tile_latent_stride_height": 24,
|
||||
"tile_latent_stride_width": 24,
|
||||
},
|
||||
)
|
||||
|
||||
result = encode_tile_merge(vae, coord_tensor_map, grid_spec)
|
||||
|
||||
# Output should be (1, 16, 2, 48, 48)
|
||||
assert result.shape == (1, 16, 2, 48, 48)
|
||||
@@ -59,9 +59,9 @@ class E2EOperator:
|
||||
class DummyMixin(DistributedVaeMixin):
|
||||
def __init__(self):
|
||||
self.use_tiling = True
|
||||
self.distributed_decoder = MagicMock()
|
||||
self.distributed_decoder.parallel_size = 2
|
||||
self.distributed_decoder.group = None
|
||||
self.distributed_executor = MagicMock()
|
||||
self.distributed_executor.parallel_size = 2
|
||||
self.distributed_executor.group = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,237 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for shared AdaLayerNorm layers used by FLUX and other models."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_distributed():
|
||||
"""Initialize the minimal distributed environment required by
|
||||
ReplicatedLinear (tensor-parallel group must exist)."""
|
||||
from vllm.distributed.parallel_state import (
|
||||
cleanup_dist_env_and_memory,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29501")
|
||||
init_distributed_environment(
|
||||
world_size=1,
|
||||
rank=0,
|
||||
local_rank=0,
|
||||
distributed_init_method="env://",
|
||||
)
|
||||
initialize_model_parallel()
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_default_gemm(monkeypatch):
|
||||
"""Force CPU-compatible GEMM dispatch for tests using CPU tensors.
|
||||
|
||||
vLLM's dispatch_unquantized_gemm() selects the backend by platform
|
||||
(e.g. rocm_unquantized_gemm on AMD machines), not by tensor device.
|
||||
CPU test tensors crash with NotImplementedError on ROCm. Monkeypatch
|
||||
the dispatcher to always return the default (torch.nn.functional.linear)
|
||||
implementation which works on any device."""
|
||||
from vllm.model_executor.layers.utils import default_unquantized_gemm
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
|
||||
def test_adalayernorm_import_from_shared_module():
|
||||
"""Verify imports work from the shared adalayernorm module."""
|
||||
from vllm_omni.diffusion.layers.adalayernorm import ( # noqa: F401
|
||||
AdaLayerNormContinuous,
|
||||
AdaLayerNormZero,
|
||||
AdaLayerNormZeroSingle,
|
||||
)
|
||||
|
||||
|
||||
def test_adalayernorm_zero_forward_shape():
|
||||
"""AdaLayerNormZero produces correct output shapes (x, gate, shift, scale, gate)."""
|
||||
from vllm_omni.diffusion.layers.adalayernorm import AdaLayerNormZero
|
||||
|
||||
dim = 64
|
||||
batch = 2
|
||||
seq_len = 4
|
||||
norm = AdaLayerNormZero(dim)
|
||||
|
||||
x = torch.randn(batch, seq_len, dim)
|
||||
emb = torch.randn(batch, dim)
|
||||
|
||||
out_x, gate_msa, shift_mlp, scale_mlp, gate_mlp = norm(x, emb)
|
||||
|
||||
assert out_x.shape == (batch, seq_len, dim)
|
||||
assert gate_msa.shape == (batch, dim)
|
||||
assert shift_mlp.shape == (batch, dim)
|
||||
assert scale_mlp.shape == (batch, dim)
|
||||
assert gate_mlp.shape == (batch, dim)
|
||||
|
||||
|
||||
def test_adalayernorm_zero_single_forward_shape():
|
||||
"""AdaLayerNormZeroSingle produces (x, gate) with correct shapes."""
|
||||
from vllm_omni.diffusion.layers.adalayernorm import AdaLayerNormZeroSingle
|
||||
|
||||
dim = 64
|
||||
batch = 2
|
||||
seq_len = 4
|
||||
norm = AdaLayerNormZeroSingle(dim)
|
||||
|
||||
x = torch.randn(batch, seq_len, dim)
|
||||
emb = torch.randn(batch, dim)
|
||||
|
||||
out_x, gate = norm(x, emb)
|
||||
|
||||
assert out_x.shape == (batch, seq_len, dim)
|
||||
assert gate.shape == (batch, dim)
|
||||
|
||||
|
||||
def test_adalayernorm_continuous_forward_shape():
|
||||
"""AdaLayerNormContinuous produces correct output shape."""
|
||||
from vllm_omni.diffusion.layers.adalayernorm import AdaLayerNormContinuous
|
||||
|
||||
dim = 64
|
||||
cond_dim = 64
|
||||
batch = 2
|
||||
seq_len = 4
|
||||
norm = AdaLayerNormContinuous(dim, cond_dim)
|
||||
|
||||
x = torch.randn(batch, seq_len, dim)
|
||||
conditioning = torch.randn(batch, cond_dim)
|
||||
|
||||
out = norm(x, conditioning)
|
||||
|
||||
assert out.shape == (batch, seq_len, dim)
|
||||
|
||||
|
||||
def test_adalayernorm_zero_accepts_quant_config():
|
||||
"""Constructor accepts quant_config=None and prefix='test' without error."""
|
||||
from vllm_omni.diffusion.layers.adalayernorm import (
|
||||
AdaLayerNormContinuous,
|
||||
AdaLayerNormZero,
|
||||
AdaLayerNormZeroSingle,
|
||||
)
|
||||
|
||||
# Should not raise with quant_config=None and prefix
|
||||
AdaLayerNormZero(64, quant_config=None, prefix="test.norm1")
|
||||
AdaLayerNormZeroSingle(64, quant_config=None, prefix="test.norm")
|
||||
AdaLayerNormContinuous(64, 64, quant_config=None, prefix="test.norm_out")
|
||||
|
||||
|
||||
def test_adalayernorm_uses_replicated_linear():
|
||||
"""Verify .linear is a ReplicatedLinear instance (not nn.Linear)."""
|
||||
from vllm.model_executor.layers.linear import ReplicatedLinear
|
||||
|
||||
from vllm_omni.diffusion.layers.adalayernorm import (
|
||||
AdaLayerNormContinuous,
|
||||
AdaLayerNormZero,
|
||||
AdaLayerNormZeroSingle,
|
||||
)
|
||||
|
||||
norm_zero = AdaLayerNormZero(64)
|
||||
assert isinstance(norm_zero.linear, ReplicatedLinear)
|
||||
|
||||
norm_zero_single = AdaLayerNormZeroSingle(64)
|
||||
assert isinstance(norm_zero_single.linear, ReplicatedLinear)
|
||||
|
||||
norm_continuous = AdaLayerNormContinuous(64, 64)
|
||||
assert isinstance(norm_continuous.linear, ReplicatedLinear)
|
||||
|
||||
|
||||
# ── Numerical equivalence tests against diffusers originals ──
|
||||
|
||||
|
||||
def _copy_weights(src_linear, dst_replicated_linear):
|
||||
"""Copy weights from nn.Linear to ReplicatedLinear for comparison."""
|
||||
dst_replicated_linear.weight.data.copy_(src_linear.weight.data)
|
||||
if src_linear.bias is not None and dst_replicated_linear.bias is not None:
|
||||
dst_replicated_linear.bias.data.copy_(src_linear.bias.data)
|
||||
|
||||
|
||||
def test_adalayernorm_zero_matches_diffusers():
|
||||
"""Verify AdaLayerNormZero produces identical output to diffusers original."""
|
||||
from diffusers.models.normalization import (
|
||||
AdaLayerNormZero as DiffusersAdaLayerNormZero,
|
||||
)
|
||||
|
||||
from vllm_omni.diffusion.layers.adalayernorm import AdaLayerNormZero
|
||||
|
||||
dim = 64
|
||||
torch.manual_seed(42)
|
||||
ours = AdaLayerNormZero(dim)
|
||||
ref = DiffusersAdaLayerNormZero(dim)
|
||||
|
||||
# Copy weights: nn.Linear -> ReplicatedLinear
|
||||
_copy_weights(ref.linear, ours.linear)
|
||||
|
||||
x = torch.randn(2, 4, dim)
|
||||
emb = torch.randn(2, dim)
|
||||
|
||||
out_ours = ours(x, emb)
|
||||
out_ref = ref(x, emb=emb)
|
||||
|
||||
for o, r in zip(out_ours, out_ref):
|
||||
torch.testing.assert_close(o, r, atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
def test_adalayernorm_zero_single_matches_diffusers():
|
||||
"""Verify AdaLayerNormZeroSingle produces identical output to diffusers original."""
|
||||
from diffusers.models.normalization import (
|
||||
AdaLayerNormZeroSingle as DiffusersAdaLayerNormZeroSingle,
|
||||
)
|
||||
|
||||
from vllm_omni.diffusion.layers.adalayernorm import AdaLayerNormZeroSingle
|
||||
|
||||
dim = 64
|
||||
torch.manual_seed(42)
|
||||
ours = AdaLayerNormZeroSingle(dim)
|
||||
ref = DiffusersAdaLayerNormZeroSingle(dim)
|
||||
|
||||
_copy_weights(ref.linear, ours.linear)
|
||||
|
||||
x = torch.randn(2, 4, dim)
|
||||
emb = torch.randn(2, dim)
|
||||
|
||||
out_ours = ours(x, emb)
|
||||
out_ref = ref(x, emb=emb)
|
||||
|
||||
for o, r in zip(out_ours, out_ref):
|
||||
torch.testing.assert_close(o, r, atol=1e-5, rtol=1e-5)
|
||||
|
||||
|
||||
def test_adalayernorm_continuous_matches_diffusers():
|
||||
"""Verify AdaLayerNormContinuous produces identical output to diffusers original."""
|
||||
from diffusers.models.normalization import (
|
||||
AdaLayerNormContinuous as DiffusersAdaLayerNormContinuous,
|
||||
)
|
||||
|
||||
from vllm_omni.diffusion.layers.adalayernorm import AdaLayerNormContinuous
|
||||
|
||||
dim = 64
|
||||
cond_dim = 64
|
||||
torch.manual_seed(42)
|
||||
# Match constructor args: diffusers defaults elementwise_affine=True, eps=1e-5
|
||||
ours = AdaLayerNormContinuous(dim, cond_dim, elementwise_affine=False, eps=1e-6)
|
||||
ref = DiffusersAdaLayerNormContinuous(dim, cond_dim, elementwise_affine=False, eps=1e-6)
|
||||
|
||||
_copy_weights(ref.linear, ours.linear)
|
||||
|
||||
x = torch.randn(2, 4, dim)
|
||||
cond = torch.randn(2, cond_dim)
|
||||
|
||||
out_ours = ours(x, cond)
|
||||
out_ref = ref(x, cond)
|
||||
|
||||
torch.testing.assert_close(out_ours, out_ref, atol=1e-5, rtol=1e-5)
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Shared test helpers for diffusion LoRA tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
from vllm.model_executor.layers.linear import LinearBase
|
||||
|
||||
|
||||
class FakeLinearBase(LinearBase):
|
||||
"""Minimal LinearBase stub for LoRA layer discovery."""
|
||||
|
||||
def __init__(self):
|
||||
torch.nn.Module.__init__(self)
|
||||
|
||||
|
||||
class DummyBaseLayerWithLoRA(torch.nn.Module):
|
||||
"""Fake LoRA wrapper that records set/reset/create calls."""
|
||||
|
||||
def __init__(self, base_layer: torch.nn.Module):
|
||||
super().__init__()
|
||||
self.base_layer = base_layer
|
||||
|
||||
self.set_calls: list[
|
||||
tuple[list[torch.Tensor | None] | torch.Tensor, list[torch.Tensor | None] | torch.Tensor]
|
||||
] = []
|
||||
self.reset_calls: int = 0
|
||||
self.create_calls: int = 0
|
||||
|
||||
def set_lora(self, index: int, lora_a, lora_b):
|
||||
assert index == 0
|
||||
self.set_calls.append((lora_a, lora_b))
|
||||
|
||||
def reset_lora(self, index: int):
|
||||
assert index == 0
|
||||
self.reset_calls += 1
|
||||
|
||||
def create_lora_weights(self, max_loras, lora_config, model_config):
|
||||
self.create_calls += 1
|
||||
|
||||
|
||||
def fake_replace_submodule(
|
||||
root: torch.nn.Module,
|
||||
module_name: str,
|
||||
submodule: torch.nn.Module,
|
||||
replace_calls: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Replace a submodule by traversing dotted paths correctly."""
|
||||
if replace_calls is not None:
|
||||
replace_calls.append(module_name)
|
||||
parts = module_name.split(".")
|
||||
parent = root
|
||||
for attr in parts[:-1]:
|
||||
parent = getattr(parent, attr)
|
||||
setattr(parent, parts[-1], submodule)
|
||||
@@ -7,8 +7,12 @@ import pytest
|
||||
import torch
|
||||
from vllm.lora.lora_weights import LoRALayerWeights
|
||||
from vllm.lora.utils import get_supported_lora_modules
|
||||
from vllm.model_executor.layers.linear import LinearBase
|
||||
|
||||
from tests.diffusion.lora.conftest import (
|
||||
DummyBaseLayerWithLoRA,
|
||||
FakeLinearBase,
|
||||
fake_replace_submodule,
|
||||
)
|
||||
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
|
||||
@@ -33,35 +37,9 @@ class _DummyLoRALayer:
|
||||
self.reset_calls += 1
|
||||
|
||||
|
||||
class _FakeLinearBase(LinearBase):
|
||||
def __init__(self):
|
||||
torch.nn.Module.__init__(self)
|
||||
|
||||
|
||||
class _DummyBaseLayerWithLoRA(torch.nn.Module):
|
||||
def __init__(self, base_layer: torch.nn.Module):
|
||||
super().__init__()
|
||||
self.base_layer = base_layer
|
||||
|
||||
self.set_calls: list[
|
||||
tuple[list[torch.Tensor | None] | torch.Tensor, list[torch.Tensor | None] | torch.Tensor]
|
||||
] = []
|
||||
self.reset_calls: int = 0
|
||||
self.create_calls: int = 0
|
||||
|
||||
def set_lora(self, index: int, lora_a, lora_b):
|
||||
assert index == 0
|
||||
self.set_calls.append((lora_a, lora_b))
|
||||
|
||||
def reset_lora(self, index: int):
|
||||
assert index == 0
|
||||
self.reset_calls += 1
|
||||
|
||||
def create_lora_weights(self, max_loras, lora_config, model_config):
|
||||
# Needs to be callable for scale test when rank changes, but not
|
||||
# actually used since we mock everything and check everything based
|
||||
# on set calls.
|
||||
self.create_calls += 1
|
||||
# Aliases for backward compatibility within this file
|
||||
_FakeLinearBase = FakeLinearBase
|
||||
_DummyBaseLayerWithLoRA = DummyBaseLayerWithLoRA
|
||||
|
||||
|
||||
class _DummyPipeline(torch.nn.Module):
|
||||
@@ -555,3 +533,45 @@ def test_lora_manager_max_rank_validation(monkeypatch, rank):
|
||||
req1 = _dummy_lora_request(1)
|
||||
with pytest.raises(ValueError):
|
||||
manager.add_adapter(req1)
|
||||
|
||||
|
||||
def test_lora_manager_discovers_bagel_component(monkeypatch):
|
||||
"""Verify that _replace_layers_with_lora finds layers under 'bagel'."""
|
||||
import vllm_omni.diffusion.lora.manager as manager_mod
|
||||
|
||||
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", _DummyBaseLayerWithLoRA)
|
||||
|
||||
def _fake_from_layer_diffusion(*, layer: torch.nn.Module, **_kwargs):
|
||||
if isinstance(layer, _FakeLinearBase):
|
||||
return _DummyBaseLayerWithLoRA(layer)
|
||||
return layer
|
||||
|
||||
replace_calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(manager_mod, "from_layer_diffusion", _fake_from_layer_diffusion)
|
||||
monkeypatch.setattr(
|
||||
manager_mod,
|
||||
"replace_submodule",
|
||||
lambda root, name, sub: fake_replace_submodule(root, name, sub, replace_calls),
|
||||
)
|
||||
|
||||
# Pipeline with a 'bagel' component (no 'transformer')
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
pipeline.bagel.language_model = torch.nn.Module()
|
||||
pipeline.bagel.language_model.qkv_proj = _FakeLinearBase()
|
||||
|
||||
manager = DiffusionLoRAManager(
|
||||
pipeline=pipeline,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.bfloat16,
|
||||
max_cached_adapters=1,
|
||||
)
|
||||
|
||||
peft_helper = type("_PH", (), {"r": 1})()
|
||||
manager._replace_layers_with_lora(peft_helper)
|
||||
|
||||
assert "language_model.qkv_proj" in replace_calls
|
||||
assert "bagel.language_model.qkv_proj" in manager._lora_modules
|
||||
# Verify the module was actually replaced in the tree (not just recorded)
|
||||
assert isinstance(pipeline.bagel.language_model.qkv_proj, _DummyBaseLayerWithLoRA)
|
||||
@@ -0,0 +1,248 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for BAGEL LoRA support across Stage 0 (Thinker) and Stage 1 (DiT)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from tests.diffusion.lora.conftest import (
|
||||
DummyBaseLayerWithLoRA,
|
||||
FakeLinearBase,
|
||||
fake_replace_submodule,
|
||||
)
|
||||
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_FakeLinearBase = FakeLinearBase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 0 (Thinker / AR) -- packed_modules_mapping on the AR model class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStage0ThinkerLoRA:
|
||||
"""Validate that OmniBagelForConditionalGeneration declares correct LoRA metadata."""
|
||||
|
||||
def test_omni_bagel_supports_lora(self):
|
||||
from vllm_omni.model_executor.models.bagel.bagel import (
|
||||
OmniBagelForConditionalGeneration,
|
||||
)
|
||||
|
||||
assert getattr(OmniBagelForConditionalGeneration, "supports_lora", False) is True
|
||||
|
||||
def test_omni_bagel_packed_modules_mapping_complete(self):
|
||||
from vllm_omni.model_executor.models.bagel.bagel import (
|
||||
OmniBagelForConditionalGeneration,
|
||||
)
|
||||
|
||||
mapping = OmniBagelForConditionalGeneration.packed_modules_mapping
|
||||
# Standard Qwen2 projections
|
||||
assert mapping["qkv_proj"] == ["q_proj", "k_proj", "v_proj"]
|
||||
assert mapping["gate_up_proj"] == ["gate_proj", "up_proj"]
|
||||
# MoE generation-mode projections
|
||||
assert mapping["qkv_proj_moe_gen"] == [
|
||||
"q_proj_moe_gen",
|
||||
"k_proj_moe_gen",
|
||||
"v_proj_moe_gen",
|
||||
]
|
||||
assert mapping["mlp_moe_gen.gate_up_proj"] == [
|
||||
"mlp_moe_gen.gate_proj",
|
||||
"mlp_moe_gen.up_proj",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 (DiT / Diffusion) -- DiffusionLoRAManager with bagel component
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStage1DiTLoRA:
|
||||
"""Validate DiffusionLoRAManager discovers BAGEL's packed modules."""
|
||||
|
||||
def test_diffusion_lora_manager_discovers_bagel_packed_modules(self):
|
||||
"""Manager should derive packed→sublayer mapping from stacked_params_mapping."""
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
|
||||
# Simulate a submodule that exposes stacked_params_mapping
|
||||
# (as Bagel does after load_weights())
|
||||
language_model = torch.nn.Module()
|
||||
language_model.stacked_params_mapping = [
|
||||
(".qkv_proj_moe_gen", ".q_proj_moe_gen", "q"),
|
||||
(".qkv_proj_moe_gen", ".k_proj_moe_gen", "k"),
|
||||
(".qkv_proj_moe_gen", ".v_proj_moe_gen", "v"),
|
||||
(".qkv_proj", ".q_proj", "q"),
|
||||
(".qkv_proj", ".k_proj", "k"),
|
||||
(".qkv_proj", ".v_proj", "v"),
|
||||
(".gate_up_proj", ".gate_proj", 0),
|
||||
(".gate_up_proj", ".up_proj", 1),
|
||||
]
|
||||
pipeline.bagel.language_model = language_model
|
||||
|
||||
manager = DiffusionLoRAManager(
|
||||
pipeline=pipeline,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.bfloat16,
|
||||
max_cached_adapters=1,
|
||||
)
|
||||
|
||||
mapping = manager._packed_modules_mapping
|
||||
assert mapping["qkv_proj"] == ["q_proj", "k_proj", "v_proj"]
|
||||
assert mapping["qkv_proj_moe_gen"] == [
|
||||
"q_proj_moe_gen",
|
||||
"k_proj_moe_gen",
|
||||
"v_proj_moe_gen",
|
||||
]
|
||||
assert mapping["gate_up_proj"] == ["gate_proj", "up_proj"]
|
||||
|
||||
def test_diffusion_lora_manager_replaces_bagel_packed_layer_via_sublayer_target(self, monkeypatch):
|
||||
"""Targeting sublayer 'q_proj' should replace the fused 'qkv_proj' under bagel."""
|
||||
import vllm_omni.diffusion.lora.manager as manager_mod
|
||||
|
||||
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", DummyBaseLayerWithLoRA)
|
||||
|
||||
def _fake_from_layer_diffusion(*, layer, **_kwargs):
|
||||
return DummyBaseLayerWithLoRA(layer)
|
||||
|
||||
replace_calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(manager_mod, "from_layer_diffusion", _fake_from_layer_diffusion)
|
||||
monkeypatch.setattr(
|
||||
manager_mod,
|
||||
"replace_submodule",
|
||||
lambda root, name, sub: fake_replace_submodule(root, name, sub, replace_calls),
|
||||
)
|
||||
|
||||
# Build pipeline with bagel component
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
lm = torch.nn.Module()
|
||||
lm.stacked_params_mapping = [
|
||||
(".qkv_proj", ".q_proj", "q"),
|
||||
(".qkv_proj", ".k_proj", "k"),
|
||||
(".qkv_proj", ".v_proj", "v"),
|
||||
]
|
||||
lm.attn = torch.nn.Module()
|
||||
lm.attn.qkv_proj = _FakeLinearBase()
|
||||
pipeline.bagel.language_model = lm
|
||||
|
||||
manager = DiffusionLoRAManager(
|
||||
pipeline=pipeline,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.bfloat16,
|
||||
max_cached_adapters=1,
|
||||
)
|
||||
|
||||
# Treat qkv_proj as 3-slice packed layer
|
||||
monkeypatch.setattr(manager, "_get_packed_modules_list", lambda _module: ["q", "k", "v"])
|
||||
|
||||
# Target sublayer "q_proj" -- manager should replace the packed "qkv_proj"
|
||||
peft_helper = type("_PH", (), {"r": 1, "target_modules": ["q_proj"]})()
|
||||
manager._replace_layers_with_lora(peft_helper)
|
||||
|
||||
assert "language_model.attn.qkv_proj" in replace_calls
|
||||
assert "bagel.language_model.attn.qkv_proj" in manager._lora_modules
|
||||
# Verify the module was actually replaced in the tree (not just recorded)
|
||||
assert isinstance(pipeline.bagel.language_model.attn.qkv_proj, DummyBaseLayerWithLoRA)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip: synthetic checkpoint → set_active_adapter → verify weights
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_synthetic_lora(
|
||||
adapter_dir: Path,
|
||||
module_name: str,
|
||||
rank: int,
|
||||
in_dim: int,
|
||||
out_dim: int,
|
||||
) -> str:
|
||||
"""Write a minimal LoRA adapter (safetensors + config) to *adapter_dir*."""
|
||||
adapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
lora_a = torch.ones((rank, in_dim), dtype=torch.float32)
|
||||
lora_b = torch.ones((out_dim, rank), dtype=torch.float32) * 2.0
|
||||
save_file(
|
||||
{
|
||||
f"base_model.model.{module_name}.lora_A.weight": lora_a,
|
||||
f"base_model.model.{module_name}.lora_B.weight": lora_b,
|
||||
},
|
||||
str(adapter_dir / "adapter_model.safetensors"),
|
||||
)
|
||||
(adapter_dir / "adapter_config.json").write_text(
|
||||
json.dumps({"r": rank, "lora_alpha": rank, "target_modules": [module_name]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(adapter_dir)
|
||||
|
||||
|
||||
class TestBagelLoRARoundTrip:
|
||||
"""End-to-end: synthetic checkpoint → load → activate → verify weights in fused layer."""
|
||||
|
||||
def test_set_active_adapter_loads_and_activates_bagel_lora(self, tmp_path, monkeypatch):
|
||||
"""Full round-trip through set_active_adapter for a bagel component module."""
|
||||
import vllm_omni.diffusion.lora.manager as manager_mod
|
||||
|
||||
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", DummyBaseLayerWithLoRA)
|
||||
|
||||
# Build pipeline with bagel.language_model.foo (simple non-packed layer)
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
lm = torch.nn.Module()
|
||||
lm.foo = _FakeLinearBase()
|
||||
pipeline.bagel.language_model = lm
|
||||
|
||||
def _fake_from_layer(*, layer, **_kwargs):
|
||||
if isinstance(layer, FakeLinearBase):
|
||||
return DummyBaseLayerWithLoRA(layer)
|
||||
return layer
|
||||
|
||||
monkeypatch.setattr(manager_mod, "from_layer_diffusion", _fake_from_layer)
|
||||
monkeypatch.setattr(
|
||||
manager_mod,
|
||||
"replace_submodule",
|
||||
lambda root, name, sub: fake_replace_submodule(root, name, sub),
|
||||
)
|
||||
|
||||
manager = DiffusionLoRAManager(
|
||||
pipeline=pipeline,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.bfloat16,
|
||||
max_cached_adapters=1,
|
||||
)
|
||||
|
||||
# Write synthetic adapter targeting bagel.language_model.foo
|
||||
module_name = "bagel.language_model.foo"
|
||||
rank = 2
|
||||
in_dim = 4
|
||||
out_dim = 4
|
||||
lora_dir = _write_synthetic_lora(tmp_path / "lora", module_name, rank, in_dim, out_dim)
|
||||
|
||||
lora_request = LoRARequest(
|
||||
lora_name="test_bagel",
|
||||
lora_int_id=42,
|
||||
lora_path=lora_dir,
|
||||
)
|
||||
|
||||
# Full round-trip: load from disk → replace layer → activate weights
|
||||
manager.set_active_adapter(lora_request, lora_scale=0.5)
|
||||
|
||||
# Verify the layer was replaced and weights were set
|
||||
replaced_layer = pipeline.bagel.language_model.foo
|
||||
assert isinstance(replaced_layer, DummyBaseLayerWithLoRA), "Layer should be wrapped with LoRA"
|
||||
assert len(replaced_layer.set_calls) == 1, "set_lora should have been called once"
|
||||
|
||||
lora_a, lora_b = replaced_layer.set_calls[0]
|
||||
# A weights should be ones (as written)
|
||||
assert torch.all(lora_a == 1.0), f"lora_a should be all ones, got {lora_a}"
|
||||
# B weights should be 2.0 * scale(0.5) = 1.0
|
||||
assert torch.allclose(lora_b, torch.ones_like(lora_b)), f"lora_b should be 2.0 * 0.5 = 1.0, got {lora_b}"
|
||||
Whitespace-only changes.
@@ -0,0 +1,134 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests that FLUX transformer blocks correctly propagate `quant_config` and
|
||||
`prefix` through all sub-layers.
|
||||
|
||||
The tests instantiate blocks with a known prefix and verify that all quantization-
|
||||
aware sub-layers (AdaLayerNorm, FeedForward, Attention projections) receive the
|
||||
prefix rooted at the block prefix. This is critical for quantized weight loading
|
||||
to match checkpoint keys to the correct model parameters.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
# Standard dimensions for a minimal FLUX block
|
||||
_DIM = 64
|
||||
_HEADS = 2
|
||||
_HEAD_DIM = _DIM // _HEADS
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_distributed():
|
||||
"""Initialize the minimal distributed environment required by
|
||||
vLLM parallel linear layers (tensor-parallel group must exist)."""
|
||||
from vllm.distributed.parallel_state import (
|
||||
cleanup_dist_env_and_memory,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29502")
|
||||
init_distributed_environment(
|
||||
world_size=1,
|
||||
rank=0,
|
||||
local_rank=0,
|
||||
distributed_init_method="env://",
|
||||
)
|
||||
initialize_model_parallel()
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
def _param_names(module) -> set[str]:
|
||||
"""Return the set of all parameter names in a module."""
|
||||
return {name for name, _ in module.named_parameters()}
|
||||
|
||||
|
||||
def test_flux_transformer_block_passes_prefix():
|
||||
"""FluxTransformerBlock propagates prefix to norm1, norm1_context, attn, ff, ff_context."""
|
||||
from vllm_omni.diffusion.models.flux.flux_transformer import FluxTransformerBlock
|
||||
|
||||
prefix = "transformer_blocks.0"
|
||||
block = FluxTransformerBlock(
|
||||
dim=_DIM,
|
||||
num_attention_heads=_HEADS,
|
||||
attention_head_dim=_HEAD_DIM,
|
||||
quant_config=None,
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
params = _param_names(block)
|
||||
|
||||
# norm1 and norm1_context (AdaLayerNormZero) should have linear weights
|
||||
assert any(name.startswith("norm1.linear.") for name in params), (
|
||||
f"norm1.linear.* not found in params: {sorted(params)}"
|
||||
)
|
||||
assert any(name.startswith("norm1_context.linear.") for name in params), (
|
||||
f"norm1_context.linear.* not found in params: {sorted(params)}"
|
||||
)
|
||||
|
||||
# attn should have QKV projections
|
||||
assert any(name.startswith("attn.to_qkv.") for name in params), (
|
||||
f"attn.to_qkv.* not found in params: {sorted(params)}"
|
||||
)
|
||||
|
||||
# ff and ff_context should have net layers
|
||||
assert any(name.startswith("ff.net.") for name in params), f"ff.net.* not found in params: {sorted(params)}"
|
||||
assert any(name.startswith("ff_context.net.") for name in params), (
|
||||
f"ff_context.net.* not found in params: {sorted(params)}"
|
||||
)
|
||||
|
||||
|
||||
def test_flux_single_transformer_block_passes_prefix():
|
||||
"""FluxSingleTransformerBlock propagates prefix to norm, proj_mlp, attn."""
|
||||
from vllm_omni.diffusion.models.flux.flux_transformer import FluxSingleTransformerBlock
|
||||
|
||||
prefix = "single_transformer_blocks.0"
|
||||
block = FluxSingleTransformerBlock(
|
||||
dim=_DIM,
|
||||
num_attention_heads=_HEADS,
|
||||
attention_head_dim=_HEAD_DIM,
|
||||
quant_config=None,
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
params = _param_names(block)
|
||||
|
||||
# norm (AdaLayerNormZeroSingle) should have linear weights
|
||||
assert any(name.startswith("norm.linear.") for name in params), (
|
||||
f"norm.linear.* not found in params: {sorted(params)}"
|
||||
)
|
||||
|
||||
# proj_mlp (ReplicatedLinear) should have weight
|
||||
assert any(name.startswith("proj_mlp.") for name in params), f"proj_mlp.* not found in params: {sorted(params)}"
|
||||
|
||||
# attn should have QKV projection
|
||||
assert any(name.startswith("attn.to_qkv.") for name in params), (
|
||||
f"attn.to_qkv.* not found in params: {sorted(params)}"
|
||||
)
|
||||
|
||||
|
||||
def test_flux_feedforward_passes_prefix():
|
||||
"""FeedForward propagates prefix to net.0 (GELU proj) and net.2 (output proj)."""
|
||||
from vllm_omni.diffusion.models.flux.flux_transformer import FeedForward
|
||||
|
||||
prefix = "transformer_blocks.0.ff"
|
||||
ff = FeedForward(
|
||||
dim=_DIM,
|
||||
dim_out=_DIM,
|
||||
quant_config=None,
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
params = _param_names(ff)
|
||||
|
||||
# net.0 is ColumnParallelApproxGELU which wraps a ColumnParallelLinear
|
||||
assert any("net.0" in name for name in params), f"net.0 not found in params: {sorted(params)}"
|
||||
|
||||
# net.2 is RowParallelLinear
|
||||
assert any("net.2" in name for name in params), f"net.2 not found in params: {sorted(params)}"
|
||||
@@ -0,0 +1,242 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Unit tests for LayerwiseOffloadHook and LayerWiseOffloadBackend utilities."""
|
||||
|
||||
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 LayerWiseOffloadBackend, 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
|
||||
|
||||
|
||||
class _DummyBlock(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.weight = nn.Parameter(torch.randn(10, 10))
|
||||
|
||||
|
||||
class _SingleBlockModel(nn.Module):
|
||||
_layerwise_offload_blocks_attrs = ["blocks"]
|
||||
|
||||
def __init__(self, num_blocks: int = 3):
|
||||
super().__init__()
|
||||
self.blocks = nn.ModuleList([_DummyBlock() for _ in range(num_blocks)])
|
||||
|
||||
|
||||
class _MultiBlockModel(nn.Module):
|
||||
_layerwise_offload_blocks_attrs = ["transformer_blocks", "single_transformer_blocks"]
|
||||
|
||||
def __init__(self, num_transformer: int = 2, num_single: int = 2):
|
||||
super().__init__()
|
||||
self.transformer_blocks = nn.ModuleList([_DummyBlock() for _ in range(num_transformer)])
|
||||
self.single_transformer_blocks = nn.ModuleList([_DummyBlock() for _ in range(num_single)])
|
||||
|
||||
|
||||
class _EmptyBlocksModel(nn.Module):
|
||||
_layerwise_offload_blocks_attrs = ["blocks"]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.blocks = nn.ModuleList([])
|
||||
|
||||
|
||||
class _InvalidAttrModel(nn.Module):
|
||||
_layerwise_offload_blocks_attrs = ["nonexistent_blocks", "blocks"]
|
||||
|
||||
def __init__(self, num_blocks: int = 2):
|
||||
super().__init__()
|
||||
self.blocks = nn.ModuleList([_DummyBlock() for _ in range(num_blocks)])
|
||||
|
||||
|
||||
class _DeprecatedSingleAttrModel(nn.Module):
|
||||
_layerwise_offload_blocks_attr = "blocks"
|
||||
|
||||
def __init__(self, num_blocks: int = 2):
|
||||
super().__init__()
|
||||
self.blocks = nn.ModuleList([_DummyBlock() for _ in range(num_blocks)])
|
||||
|
||||
|
||||
class _NoAttrsModel(nn.Module):
|
||||
def __init__(self, num_blocks: int = 2):
|
||||
super().__init__()
|
||||
self.blocks = nn.ModuleList([_DummyBlock() for _ in range(num_blocks)])
|
||||
|
||||
|
||||
class TestGetBlocksFromDit:
|
||||
def test_get_blocks_from_dit_single_block_attr(self):
|
||||
model = _SingleBlockModel(num_blocks=3)
|
||||
attr_names, blocks = LayerWiseOffloadBackend.get_blocks_from_dit(model)
|
||||
assert attr_names == ["blocks"]
|
||||
assert len(blocks) == 3
|
||||
assert all(isinstance(b, _DummyBlock) for b in blocks)
|
||||
|
||||
def test_get_blocks_from_dit_multi_block_attrs(self):
|
||||
model = _MultiBlockModel(num_transformer=2, num_single=3)
|
||||
attr_names, blocks = LayerWiseOffloadBackend.get_blocks_from_dit(model)
|
||||
assert set(attr_names) == {"transformer_blocks", "single_transformer_blocks"}
|
||||
assert len(blocks) == 5
|
||||
assert all(isinstance(b, _DummyBlock) for b in blocks)
|
||||
|
||||
def test_get_blocks_from_dit_empty_blocks(self):
|
||||
model = _EmptyBlocksModel()
|
||||
attr_names, blocks = LayerWiseOffloadBackend.get_blocks_from_dit(model)
|
||||
assert attr_names == []
|
||||
assert blocks == []
|
||||
|
||||
def test_get_blocks_from_dit_invalid_attr_name(self):
|
||||
model = _InvalidAttrModel(num_blocks=2)
|
||||
with pytest.raises(
|
||||
AttributeError,
|
||||
match="Attribute 'nonexistent_blocks' declared in _layerwise_offload_blocks_attrs does not exist",
|
||||
):
|
||||
LayerWiseOffloadBackend.get_blocks_from_dit(model)
|
||||
|
||||
def test_get_blocks_from_dit_no_attrs_defined(self):
|
||||
model = _NoAttrsModel(num_blocks=3)
|
||||
attr_names, blocks = LayerWiseOffloadBackend.get_blocks_from_dit(model)
|
||||
assert attr_names == []
|
||||
assert blocks == []
|
||||
|
||||
def test_get_blocks_from_dit_deprecated_single_attr(self):
|
||||
model = _DeprecatedSingleAttrModel(num_blocks=2)
|
||||
attr_names, blocks = LayerWiseOffloadBackend.get_blocks_from_dit(model)
|
||||
assert attr_names == ["blocks"]
|
||||
assert len(blocks) == 2
|
||||
|
||||
|
||||
class TestGetBlocksAttrNames:
|
||||
def test_get_blocks_attr_names_new_format(self):
|
||||
model = _MultiBlockModel()
|
||||
attrs = LayerWiseOffloadBackend.get_blocks_attr_names(model)
|
||||
assert attrs == ["transformer_blocks", "single_transformer_blocks"]
|
||||
|
||||
def test_get_blocks_attr_names_no_attrs(self):
|
||||
model = _NoAttrsModel()
|
||||
attrs = LayerWiseOffloadBackend.get_blocks_attr_names(model)
|
||||
assert attrs == []
|
||||
|
||||
def test_set_blocks_attr_names(self):
|
||||
model = _NoAttrsModel()
|
||||
LayerWiseOffloadBackend.set_blocks_attr_names(model, ["new_blocks"])
|
||||
assert hasattr(model.__class__, "_layerwise_offload_blocks_attrs")
|
||||
assert model.__class__._layerwise_offload_blocks_attrs == ["new_blocks"]
|
||||
@@ -0,0 +1,147 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for INC/AutoRound quantization via the unified framework."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_build_quant_config_autoround():
|
||||
"""build_quant_config("auto-round", ...) should produce an INCConfig."""
|
||||
from vllm.model_executor.layers.quantization.inc import INCConfig
|
||||
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
config = build_quant_config(
|
||||
"auto-round",
|
||||
bits=4,
|
||||
group_size=128,
|
||||
sym=True,
|
||||
packing_format="auto_round:auto_gptq",
|
||||
)
|
||||
assert config is not None
|
||||
assert isinstance(config, INCConfig)
|
||||
assert config.weight_bits == 4
|
||||
assert config.group_size == 128
|
||||
|
||||
|
||||
def test_build_quant_config_inc():
|
||||
"""build_quant_config("inc", ...) should also produce an INCConfig."""
|
||||
from vllm.model_executor.layers.quantization.inc import INCConfig
|
||||
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
config = build_quant_config("inc", bits=4, group_size=128)
|
||||
assert isinstance(config, INCConfig)
|
||||
assert config.weight_bits == 4
|
||||
|
||||
|
||||
def test_build_quant_config_autoround_dict():
|
||||
"""Dict-style config with method=auto-round should work."""
|
||||
from vllm.model_executor.layers.quantization.inc import INCConfig
|
||||
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
config = build_quant_config(
|
||||
{
|
||||
"method": "auto-round",
|
||||
"bits": 4,
|
||||
"group_size": 128,
|
||||
"sym": True,
|
||||
"packing_format": "auto_round:auto_gptq",
|
||||
}
|
||||
)
|
||||
assert isinstance(config, INCConfig)
|
||||
assert config.weight_bits == 4
|
||||
|
||||
|
||||
def test_build_quant_config_autoround_filters_metadata():
|
||||
"""Checkpoint metadata keys (autoround_version, batch_size, iters)
|
||||
should be silently filtered out instead of causing TypeError."""
|
||||
from vllm.model_executor.layers.quantization.inc import INCConfig
|
||||
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
config = build_quant_config(
|
||||
"auto-round",
|
||||
bits=4,
|
||||
group_size=128,
|
||||
sym=True,
|
||||
packing_format="auto_round:auto_gptq",
|
||||
block_name_to_quantize="transformer_blocks,single_transformer_blocks",
|
||||
autoround_version="0.12.0", # metadata — must be filtered
|
||||
batch_size=1, # metadata — must be filtered
|
||||
iters=0, # metadata — must be filtered
|
||||
)
|
||||
assert isinstance(config, INCConfig)
|
||||
assert config.weight_bits == 4
|
||||
assert config.group_size == 128
|
||||
|
||||
|
||||
def test_build_quant_config_bits_to_weight_bits_mapping():
|
||||
"""The 'bits' key from checkpoints should be mapped to 'weight_bits'."""
|
||||
from vllm.model_executor.layers.quantization.inc import INCConfig
|
||||
|
||||
from vllm_omni.quantization import build_quant_config
|
||||
|
||||
# If weight_bits is already provided, bits should be ignored
|
||||
config = build_quant_config("auto-round", weight_bits=4, group_size=128)
|
||||
assert isinstance(config, INCConfig)
|
||||
assert config.weight_bits == 4
|
||||
|
||||
|
||||
def test_autoround_in_supported_methods():
|
||||
"""auto-round and inc should appear in SUPPORTED_QUANTIZATION_METHODS."""
|
||||
from vllm_omni.quantization import SUPPORTED_QUANTIZATION_METHODS
|
||||
|
||||
assert "auto-round" in SUPPORTED_QUANTIZATION_METHODS
|
||||
assert "inc" in SUPPORTED_QUANTIZATION_METHODS
|
||||
|
||||
|
||||
def test_integration_autoround_via_omni_diffusion_config():
|
||||
"""OmniDiffusionConfig with auto-round quantization dict should resolve."""
|
||||
from vllm.model_executor.layers.quantization.inc import INCConfig
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
|
||||
config = OmniDiffusionConfig(
|
||||
model="test",
|
||||
quantization_config={
|
||||
"method": "auto-round",
|
||||
"bits": 4,
|
||||
"group_size": 128,
|
||||
"sym": True,
|
||||
},
|
||||
)
|
||||
assert isinstance(config.quantization_config, INCConfig)
|
||||
assert config.quantization_config.weight_bits == 4
|
||||
|
||||
|
||||
def test_integration_autodetect_from_transformer_config():
|
||||
"""When TransformerConfig has quant_config, OmniDiffusionConfig should
|
||||
auto-detect it even without explicit quantization_config."""
|
||||
from vllm.model_executor.layers.quantization.inc import INCConfig
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig, TransformerConfig
|
||||
|
||||
tf_config = TransformerConfig.from_dict(
|
||||
{
|
||||
"quantization_config": {
|
||||
"quant_method": "auto-round",
|
||||
"bits": 4,
|
||||
"group_size": 128,
|
||||
"sym": True,
|
||||
"packing_format": "auto_round:auto_gptq",
|
||||
"autoround_version": "0.12.0",
|
||||
"batch_size": 1,
|
||||
"iters": 0,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert tf_config.quant_method == "auto-round"
|
||||
assert isinstance(tf_config.quant_config, INCConfig)
|
||||
|
||||
od_config = OmniDiffusionConfig(model="test", tf_model_config=tf_config)
|
||||
assert isinstance(od_config.quantization_config, INCConfig)
|
||||
assert od_config.quantization_config.weight_bits == 4
|
||||
@@ -118,8 +118,12 @@ def _generate_image(omni, config: QualityTestConfig):
|
||||
|
||||
peak_mem = torch.cuda.max_memory_allocated() / (1024**3)
|
||||
first = outputs[0]
|
||||
req_out = first.request_output[0] if hasattr(first, "request_output") else first
|
||||
return req_out.images[0], peak_mem
|
||||
if hasattr(first, "images") and first.images:
|
||||
return first.images[0], peak_mem
|
||||
inner = first.request_output
|
||||
if inner is not None and hasattr(inner, "images") and inner.images:
|
||||
return inner.images[0], peak_mem
|
||||
raise ValueError("Could not extract image from output.")
|
||||
|
||||
|
||||
def _generate_video(omni, config: QualityTestConfig):
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -133,6 +133,22 @@ def test_save_async(build_adapter):
|
||||
assert task["is_finished"] is False
|
||||
|
||||
|
||||
def test_send_single_request_cleans_up_after_finished_payload(build_adapter, monkeypatch):
|
||||
adapter, _ = build_adapter(stage_id=1)
|
||||
request = _req("req-finished", RequestStatus.FINISHED_STOPPED, external_req_id="ext-finished")
|
||||
|
||||
adapter.custom_process_next_stage_input_func = lambda **kwargs: {"x": [1], "finished": True}
|
||||
cleanup_calls = []
|
||||
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: cleanup_calls.append((a, kw)))
|
||||
|
||||
adapter._send_single_request({"pooling_output": None, "request": request, "is_finished": True})
|
||||
|
||||
assert len(cleanup_calls) == 1
|
||||
args, _ = cleanup_calls[0]
|
||||
assert args[0] == "req-finished"
|
||||
assert args[1] == "ext-finished"
|
||||
|
||||
|
||||
def test_update_request_payload(build_adapter):
|
||||
adapter, _ = build_adapter()
|
||||
|
||||
@@ -409,3 +425,86 @@ def test_generation_scheduler_calls_cleanup_on_finished(monkeypatch, mocker: Moc
|
||||
args, _ = cleanup_calls[0]
|
||||
assert args[0] == "req-s1"
|
||||
assert args[1] == "ext-s1"
|
||||
|
||||
|
||||
def test_ar_scheduler_defers_cleanup_and_queues_save_on_finished(mocker: MockerFixture):
|
||||
"""OmniARScheduler should enqueue save; adapter cleanup is handled in save thread."""
|
||||
cleanup_calls = []
|
||||
save_calls = []
|
||||
|
||||
adapter_mock = mocker.MagicMock()
|
||||
adapter_mock.cleanup = lambda *a, **kw: cleanup_calls.append((a, kw))
|
||||
adapter_mock.save_async = lambda *a, **kw: save_calls.append((a, kw))
|
||||
|
||||
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
|
||||
|
||||
scheduler = mocker.MagicMock()
|
||||
scheduler.chunk_transfer_adapter = adapter_mock
|
||||
scheduler.connector = None
|
||||
scheduler.perf_metrics = None
|
||||
scheduler.log_stats = False
|
||||
scheduler.recompute_kv_load_failures = False
|
||||
scheduler.structured_output_manager = mocker.MagicMock()
|
||||
scheduler.structured_output_manager.should_advance.return_value = False
|
||||
scheduler.finished_req_ids_dict = {}
|
||||
scheduler.kv_cache_manager = mocker.MagicMock()
|
||||
scheduler.kv_cache_manager.take_events.return_value = None
|
||||
scheduler.kv_event_publisher = mocker.MagicMock()
|
||||
scheduler.waiting_for_transfer_free = set()
|
||||
scheduler.transfer_triggered_requests = set()
|
||||
scheduler.active_kv_transfers = set()
|
||||
|
||||
request = _HashableRequest(
|
||||
request_id="req-ar",
|
||||
external_req_id="ext-ar",
|
||||
status=RequestStatus.RUNNING,
|
||||
is_finished=lambda: False,
|
||||
num_computed_tokens=1,
|
||||
num_prompt_tokens=1,
|
||||
prompt_token_ids=[1],
|
||||
num_output_placeholders=0,
|
||||
sampling_params=None,
|
||||
pooling_params=None,
|
||||
stop_reason=None,
|
||||
client_index=0,
|
||||
take_events=lambda: [],
|
||||
trace_headers=None,
|
||||
num_cached_tokens=0,
|
||||
num_external_computed_tokens=0,
|
||||
num_nans_in_logits=0,
|
||||
get_finished_reason=lambda: "stop",
|
||||
)
|
||||
scheduler.requests = {"req-ar": request}
|
||||
|
||||
scheduler._update_request_with_output = mocker.MagicMock(return_value=([], True))
|
||||
scheduler._process_kv_transfer_trigger = mocker.MagicMock(return_value=False)
|
||||
scheduler._handle_stopped_request = mocker.MagicMock(return_value=True)
|
||||
scheduler._free_request = mocker.MagicMock(return_value=None)
|
||||
scheduler._get_routed_experts = mocker.MagicMock(return_value=None)
|
||||
scheduler.running = [request]
|
||||
scheduler.waiting = mocker.MagicMock()
|
||||
scheduler.waiting.remove_requests = mocker.MagicMock()
|
||||
scheduler.make_spec_decoding_stats = mocker.MagicMock(return_value=None)
|
||||
scheduler.make_stats = mocker.MagicMock(return_value=None)
|
||||
|
||||
scheduler_output = SimpleNamespace(
|
||||
num_scheduled_tokens={"req-ar": 1},
|
||||
scheduled_spec_decode_tokens={},
|
||||
num_invalid_spec_tokens=0,
|
||||
)
|
||||
model_runner_output = SimpleNamespace(
|
||||
sampled_token_ids=[[123]],
|
||||
logprobs=None,
|
||||
prompt_logprobs_dict={},
|
||||
pooler_output=None,
|
||||
num_nans_in_logits=None,
|
||||
kv_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
req_id_to_index={"req-ar": 0},
|
||||
kv_extracted_req_ids=None,
|
||||
)
|
||||
|
||||
OmniARScheduler.update_from_output(scheduler, scheduler_output, model_runner_output)
|
||||
|
||||
assert len(cleanup_calls) == 0
|
||||
assert len(save_calls) == 1
|
||||
@@ -2,13 +2,20 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
import zmq
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import (
|
||||
OmniCoordClientForStage,
|
||||
StageStatus,
|
||||
)
|
||||
from vllm_omni.distributed.omni_coordinator import (
|
||||
omni_coord_client_for_stage as stage_client_module,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _bind_router() -> tuple[zmq.Context, zmq.Socket, str]:
|
||||
@@ -19,7 +26,8 @@ def _bind_router() -> tuple[zmq.Context, zmq.Socket, str]:
|
||||
return ctx, router, endpoint
|
||||
|
||||
|
||||
def _recv_event(router: zmq.Socket) -> dict:
|
||||
def _recv_event(router: zmq.Socket, timeout_ms: int = 2000) -> dict:
|
||||
assert router.poll(timeout=timeout_ms) != 0, "Timed out waiting for coordinator event"
|
||||
frames = router.recv_multipart()
|
||||
# ROUTER adds identity frame; the last frame is the payload.
|
||||
payload = frames[-1]
|
||||
@@ -108,3 +116,197 @@ def test_stage_client_close_sends_down_status():
|
||||
|
||||
router.close(0)
|
||||
ctx.term()
|
||||
|
||||
|
||||
def test_stage_client_reconnects_after_send_failure(mocker):
|
||||
"""Verify send failure path invokes reconnect before retrying send."""
|
||||
ctx, router, endpoint = _bind_router()
|
||||
|
||||
client = OmniCoordClientForStage(
|
||||
endpoint,
|
||||
"tcp://stage:reconnect-in",
|
||||
"tcp://stage:reconnect-out",
|
||||
0,
|
||||
)
|
||||
|
||||
# Discard initial registration event from the real socket.
|
||||
_recv_event(router)
|
||||
|
||||
class _FlakySocket:
|
||||
def __init__(self):
|
||||
self.send_calls = 0
|
||||
self.closed = False
|
||||
|
||||
def send(self, *_args, **_kwargs):
|
||||
self.send_calls += 1
|
||||
if self.send_calls == 1:
|
||||
raise RuntimeError("simulated send failure")
|
||||
|
||||
def close(self, *_args, **_kwargs):
|
||||
self.closed = True
|
||||
|
||||
flaky_socket = _FlakySocket()
|
||||
client._socket = flaky_socket
|
||||
client._reconnect = mocker.Mock(return_value=True)
|
||||
|
||||
client.update_info(queue_length=1)
|
||||
|
||||
client._reconnect.assert_called_once_with(max_retries=3)
|
||||
assert flaky_socket.send_calls == 2
|
||||
|
||||
client.close()
|
||||
router.close(0)
|
||||
ctx.term()
|
||||
|
||||
|
||||
def test_stage_client_raises_when_reconnect_fails(mocker):
|
||||
"""Verify send failure is propagated when reconnect cannot recover."""
|
||||
ctx, router, endpoint = _bind_router()
|
||||
|
||||
client = OmniCoordClientForStage(
|
||||
endpoint,
|
||||
"tcp://stage:reconnect-fail-in",
|
||||
"tcp://stage:reconnect-fail-out",
|
||||
0,
|
||||
)
|
||||
|
||||
# Discard initial registration event from the real socket.
|
||||
_recv_event(router)
|
||||
|
||||
class _AlwaysFailSocket:
|
||||
def send(self, *_args, **_kwargs):
|
||||
raise RuntimeError("simulated send failure")
|
||||
|
||||
def close(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
client._socket = _AlwaysFailSocket()
|
||||
client._reconnect = mocker.Mock(return_value=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="simulated send failure"):
|
||||
client.update_info(queue_length=2)
|
||||
|
||||
client._reconnect.assert_called_once_with(max_retries=3)
|
||||
client.close()
|
||||
router.close(0)
|
||||
ctx.term()
|
||||
|
||||
|
||||
def test_stage_client_close_handles_runtime_error_in_final_update(mocker):
|
||||
"""Verify close() still releases resources when final update raises RuntimeError."""
|
||||
ctx, router, endpoint = _bind_router()
|
||||
|
||||
client = OmniCoordClientForStage(
|
||||
endpoint,
|
||||
"tcp://stage:close-runtime-in",
|
||||
"tcp://stage:close-runtime-out",
|
||||
0,
|
||||
)
|
||||
|
||||
# Discard initial registration event from the real socket.
|
||||
_recv_event(router)
|
||||
|
||||
client._send_event = mocker.Mock(side_effect=RuntimeError("simulated close-time failure"))
|
||||
client.close()
|
||||
|
||||
assert client._closed
|
||||
assert client._socket.closed
|
||||
|
||||
router.close(0)
|
||||
ctx.term()
|
||||
|
||||
|
||||
def test_reconnect_respects_retry_limit(monkeypatch):
|
||||
"""Verify _reconnect stops after max_retries on repeated failures."""
|
||||
attempts = {"connect": 0}
|
||||
|
||||
class _FailSocket:
|
||||
def close(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def connect(self, *_args, **_kwargs):
|
||||
attempts["connect"] += 1
|
||||
raise zmq.ZMQError("simulated reconnect failure")
|
||||
|
||||
class _FailContext:
|
||||
def socket(self, *_args, **_kwargs):
|
||||
return _FailSocket()
|
||||
|
||||
def term(self):
|
||||
pass
|
||||
|
||||
client = OmniCoordClientForStage.__new__(OmniCoordClientForStage)
|
||||
client._closed = False
|
||||
client._coord_zmq_addr = "tcp://127.0.0.1:9999"
|
||||
client._stop_event = threading.Event()
|
||||
client._send_lock = threading.RLock()
|
||||
client._socket = _FailSocket()
|
||||
client._ctx = _FailContext()
|
||||
|
||||
monkeypatch.setattr(stage_client_module.zmq, "Context", lambda: _FailContext())
|
||||
monkeypatch.setattr(stage_client_module.time, "sleep", lambda *_args, **_kwargs: None)
|
||||
|
||||
assert client._reconnect(max_retries=3, retry_interval=5.0) is False
|
||||
assert attempts["connect"] == 3
|
||||
|
||||
|
||||
def test_heartbeat_loop_retries_after_transient_send_failure():
|
||||
"""Verify heartbeat loop continues after one transient send failure."""
|
||||
|
||||
class _FakeStopEvent:
|
||||
def __init__(self):
|
||||
self.wait_calls = 0
|
||||
self._set = False
|
||||
|
||||
def wait(self, timeout=None):
|
||||
_ = timeout
|
||||
self.wait_calls += 1
|
||||
# Run two loop iterations, then stop.
|
||||
return self._set or self.wait_calls >= 3
|
||||
|
||||
def is_set(self):
|
||||
return self._set
|
||||
|
||||
def set(self):
|
||||
self._set = True
|
||||
|
||||
client = OmniCoordClientForStage.__new__(OmniCoordClientForStage)
|
||||
client._closed = False
|
||||
client._heartbeat_interval = 0.0
|
||||
client._stop_event = _FakeStopEvent()
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
def _fake_send(event_type):
|
||||
assert event_type == "heartbeat"
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
raise RuntimeError("transient heartbeat failure")
|
||||
|
||||
client._send_event = _fake_send
|
||||
|
||||
client._heartbeat_loop()
|
||||
|
||||
assert calls["count"] == 2
|
||||
|
||||
|
||||
def test_update_info_rejected_while_closing():
|
||||
"""Verify update_info is rejected once client enters closing state."""
|
||||
ctx, router, endpoint = _bind_router()
|
||||
|
||||
client = OmniCoordClientForStage(
|
||||
endpoint,
|
||||
"tcp://stage:closing-in",
|
||||
"tcp://stage:closing-out",
|
||||
0,
|
||||
)
|
||||
_recv_event(router)
|
||||
|
||||
client._closing = True
|
||||
with pytest.raises(RuntimeError, match="closing"):
|
||||
client.update_info(queue_length=3)
|
||||
|
||||
client._closing = False
|
||||
client.close()
|
||||
router.close(0)
|
||||
ctx.term()
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user